consistent error handling for HTTP request (and some other things)

This commit is contained in:
Florian Dold 2020-07-22 14:22:03 +05:30
parent f4a8702b3c
commit e60563fb54
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
28 changed files with 638 additions and 555 deletions

View File

@ -114,6 +114,8 @@ export class AndroidHttpLib implements HttpRequestLibrary {
const headers = new Headers(); const headers = new Headers();
if (msg.status != 0) { if (msg.status != 0) {
const resp: HttpResponse = { const resp: HttpResponse = {
// FIXME: pass through this URL
requestUrl: "",
headers, headers,
status: msg.status, status: msg.status,
json: async () => JSON.parse(msg.responseText), json: async () => JSON.parse(msg.responseText),
@ -196,7 +198,10 @@ class AndroidWalletMessageHandler {
} }
case "getWithdrawalDetailsForAmount": { case "getWithdrawalDetailsForAmount": {
const wallet = await this.wp.promise; const wallet = await this.wp.promise;
return await wallet.getWithdrawalDetailsForAmount(args.exchangeBaseUrl, args.amount); return await wallet.getWithdrawalDetailsForAmount(
args.exchangeBaseUrl,
args.amount,
);
} }
case "withdrawTestkudos": { case "withdrawTestkudos": {
const wallet = await this.wp.promise; const wallet = await this.wp.promise;
@ -218,7 +223,10 @@ class AndroidWalletMessageHandler {
} }
case "setExchangeTosAccepted": { case "setExchangeTosAccepted": {
const wallet = await this.wp.promise; const wallet = await this.wp.promise;
await wallet.acceptExchangeTermsOfService(args.exchangeBaseUrl, args.acceptedEtag); await wallet.acceptExchangeTermsOfService(
args.exchangeBaseUrl,
args.acceptedEtag,
);
return {}; return {};
} }
case "retryPendingNow": { case "retryPendingNow": {
@ -237,7 +245,10 @@ class AndroidWalletMessageHandler {
} }
case "acceptManualWithdrawal": { case "acceptManualWithdrawal": {
const wallet = await this.wp.promise; const wallet = await this.wp.promise;
const res = await wallet.acceptManualWithdrawal(args.exchangeBaseUrl, Amounts.parseOrThrow(args.amount)); const res = await wallet.acceptManualWithdrawal(
args.exchangeBaseUrl,
Amounts.parseOrThrow(args.amount),
);
return res; return res;
} }
case "startTunnel": { case "startTunnel": {

View File

@ -27,6 +27,8 @@ import {
} from "../util/http"; } from "../util/http";
import { RequestThrottler } from "../util/RequestThrottler"; import { RequestThrottler } from "../util/RequestThrottler";
import Axios from "axios"; import Axios from "axios";
import { OperationFailedError, makeErrorDetails } from "../operations/errors";
import { TalerErrorCode } from "../TalerErrorCode";
/** /**
* Implementation of the HTTP request library interface for node. * Implementation of the HTTP request library interface for node.
@ -63,17 +65,44 @@ export class NodeHttpLib implements HttpRequestLibrary {
const respText = resp.data; const respText = resp.data;
if (typeof respText !== "string") { if (typeof respText !== "string") {
throw Error("unexpected response type"); throw new OperationFailedError(
makeErrorDetails(
TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
"unexpected response type",
{
httpStatusCode: resp.status,
requestUrl: url,
},
),
);
} }
const makeJson = async (): Promise<any> => { const makeJson = async (): Promise<any> => {
let responseJson; let responseJson;
try { try {
responseJson = JSON.parse(respText); responseJson = JSON.parse(respText);
} catch (e) { } catch (e) {
throw Error("Invalid JSON from HTTP response"); throw new OperationFailedError(
makeErrorDetails(
TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
"invalid JSON",
{
httpStatusCode: resp.status,
requestUrl: url,
},
),
);
} }
if (responseJson === null || typeof responseJson !== "object") { if (responseJson === null || typeof responseJson !== "object") {
throw Error("Invalid JSON from HTTP response"); throw new OperationFailedError(
makeErrorDetails(
TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
"invalid JSON",
{
httpStatusCode: resp.status,
requestUrl: url,
},
),
);
} }
return responseJson; return responseJson;
}; };
@ -82,6 +111,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
headers.set(hn, resp.headers[hn]); headers.set(hn, resp.headers[hn]);
} }
return { return {
requestUrl: url,
headers, headers,
status: resp.status, status: resp.status,
text: async () => resp.data, text: async () => resp.data,

View File

@ -143,7 +143,10 @@ export async function withdrawTestBalance(
exchangeBaseUrl = "https://exchange.test.taler.net/", exchangeBaseUrl = "https://exchange.test.taler.net/",
): Promise<void> { ): Promise<void> {
await myWallet.updateExchangeFromUrl(exchangeBaseUrl, true); await myWallet.updateExchangeFromUrl(exchangeBaseUrl, true);
const reserveResponse = await myWallet.acceptManualWithdrawal(exchangeBaseUrl, Amounts.parseOrThrow(amount)); const reserveResponse = await myWallet.acceptManualWithdrawal(
exchangeBaseUrl,
Amounts.parseOrThrow(amount),
);
const reservePub = reserveResponse.reservePub; const reservePub = reserveResponse.reservePub;

View File

@ -25,7 +25,6 @@ import { NodeHttpLib } from "./NodeHttpLib";
import { Wallet } from "../wallet"; import { Wallet } from "../wallet";
import { Configuration } from "../util/talerconfig"; import { Configuration } from "../util/talerconfig";
import { Amounts, AmountJson } from "../util/amounts"; import { Amounts, AmountJson } from "../util/amounts";
import { OperationFailedAndReportedError, OperationFailedError } from "../operations/errors";
const logger = new Logger("integrationtest.ts"); const logger = new Logger("integrationtest.ts");
@ -70,9 +69,9 @@ async function makePayment(
} }
const confirmPayResult = await wallet.confirmPay( const confirmPayResult = await wallet.confirmPay(
preparePayResult.proposalId, preparePayResult.proposalId,
undefined, undefined,
); );
console.log("confirmPayResult", confirmPayResult); console.log("confirmPayResult", confirmPayResult);

View File

@ -37,7 +37,10 @@ export class MerchantBackendConnection {
reason: string, reason: string,
refundAmount: string, refundAmount: string,
): Promise<string> { ): Promise<string> {
const reqUrl = new URL(`private/orders/${orderId}/refund`, this.merchantBaseUrl); const reqUrl = new URL(
`private/orders/${orderId}/refund`,
this.merchantBaseUrl,
);
const refundReq = { const refundReq = {
reason, reason,
refund: refundAmount, refund: refundAmount,
@ -123,7 +126,8 @@ export class MerchantBackendConnection {
} }
async checkPayment(orderId: string): Promise<CheckPaymentResponse> { async checkPayment(orderId: string): Promise<CheckPaymentResponse> {
const reqUrl = new URL(`private/orders/${orderId}`, this.merchantBaseUrl).href; const reqUrl = new URL(`private/orders/${orderId}`, this.merchantBaseUrl)
.href;
const resp = await axios({ const resp = await axios({
method: "get", method: "get",
url: reqUrl, url: reqUrl,

View File

@ -30,7 +30,7 @@ import {
setupRefreshPlanchet, setupRefreshPlanchet,
encodeCrock, encodeCrock,
} from "../crypto/talerCrypto"; } from "../crypto/talerCrypto";
import { OperationFailedAndReportedError, OperationFailedError } from "../operations/errors"; import { OperationFailedAndReportedError } from "../operations/errors";
import { Bank } from "./bank"; import { Bank } from "./bank";
import { classifyTalerUri, TalerUriType } from "../util/taleruri"; import { classifyTalerUri, TalerUriType } from "../util/taleruri";
import { Configuration } from "../util/talerconfig"; import { Configuration } from "../util/talerconfig";
@ -527,7 +527,10 @@ advancedCli
.maybeOption("sessionIdOverride", ["--session-id"], clk.STRING) .maybeOption("sessionIdOverride", ["--session-id"], clk.STRING)
.action(async (args) => { .action(async (args) => {
await withWallet(args, async (wallet) => { await withWallet(args, async (wallet) => {
wallet.confirmPay(args.payConfirm.proposalId, args.payConfirm.sessionIdOverride); wallet.confirmPay(
args.payConfirm.proposalId,
args.payConfirm.sessionIdOverride,
);
}); });
}); });

View File

@ -23,14 +23,15 @@
/** /**
* Imports. * Imports.
*/ */
import { OperationError } from "../types/walletTypes"; import { OperationErrorDetails } from "../types/walletTypes";
import { TalerErrorCode } from "../TalerErrorCode";
/** /**
* This exception is there to let the caller know that an error happened, * This exception is there to let the caller know that an error happened,
* but the error has already been reported by writing it to the database. * but the error has already been reported by writing it to the database.
*/ */
export class OperationFailedAndReportedError extends Error { export class OperationFailedAndReportedError extends Error {
constructor(public operationError: OperationError) { constructor(public operationError: OperationErrorDetails) {
super(operationError.message); super(operationError.message);
// Set the prototype explicitly. // Set the prototype explicitly.
@ -43,7 +44,15 @@ export class OperationFailedAndReportedError extends Error {
* responsible for recording the failure in the database. * responsible for recording the failure in the database.
*/ */
export class OperationFailedError extends Error { export class OperationFailedError extends Error {
constructor(public operationError: OperationError) { static fromCode(
ec: TalerErrorCode,
message: string,
details: Record<string, unknown>,
): OperationFailedError {
return new OperationFailedError(makeErrorDetails(ec, message, details));
}
constructor(public operationError: OperationErrorDetails) {
super(operationError.message); super(operationError.message);
// Set the prototype explicitly. // Set the prototype explicitly.
@ -51,6 +60,19 @@ export class OperationFailedError extends Error {
} }
} }
export function makeErrorDetails(
ec: TalerErrorCode,
message: string,
details: Record<string, unknown>,
): OperationErrorDetails {
return {
talerErrorCode: ec,
talerErrorHint: `Error: ${TalerErrorCode[ec]}`,
details: details,
message,
};
}
/** /**
* Run an operation and call the onOpError callback * Run an operation and call the onOpError callback
* when there was an exception or operation error that must be reported. * when there was an exception or operation error that must be reported.
@ -58,7 +80,7 @@ export class OperationFailedError extends Error {
*/ */
export async function guardOperationException<T>( export async function guardOperationException<T>(
op: () => Promise<T>, op: () => Promise<T>,
onOpError: (e: OperationError) => Promise<void>, onOpError: (e: OperationErrorDetails) => Promise<void>,
): Promise<T> { ): Promise<T> {
try { try {
return await op(); return await op();
@ -71,21 +93,28 @@ export async function guardOperationException<T>(
throw new OperationFailedAndReportedError(e.operationError); throw new OperationFailedAndReportedError(e.operationError);
} }
if (e instanceof Error) { if (e instanceof Error) {
const opErr = { const opErr = makeErrorDetails(
type: "exception", TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
message: e.message, `unexpected exception (message: ${e.message})`,
details: {}, {},
}; );
await onOpError(opErr); await onOpError(opErr);
throw new OperationFailedAndReportedError(opErr); throw new OperationFailedAndReportedError(opErr);
} }
const opErr = { // Something was thrown that is not even an exception!
type: "exception", // Try to stringify it.
message: "unexpected exception thrown", let excString: string;
details: { try {
value: e.toString(), excString = e.toString();
}, } catch (e) {
}; // Something went horribly wrong.
excString = "can't stringify exception";
}
const opErr = makeErrorDetails(
TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
`unexpected exception (not an exception, ${excString})`,
{},
);
await onOpError(opErr); await onOpError(opErr);
throw new OperationFailedAndReportedError(opErr); throw new OperationFailedAndReportedError(opErr);
} }

View File

@ -16,12 +16,11 @@
import { InternalWalletState } from "./state"; import { InternalWalletState } from "./state";
import { import {
ExchangeKeysJson,
Denomination, Denomination,
codecForExchangeKeysJson, codecForExchangeKeysJson,
codecForExchangeWireJson, codecForExchangeWireJson,
} from "../types/talerTypes"; } from "../types/talerTypes";
import { OperationError } from "../types/walletTypes"; import { OperationErrorDetails } from "../types/walletTypes";
import { import {
ExchangeRecord, ExchangeRecord,
ExchangeUpdateStatus, ExchangeUpdateStatus,
@ -38,6 +37,7 @@ import { parsePaytoUri } from "../util/payto";
import { import {
OperationFailedAndReportedError, OperationFailedAndReportedError,
guardOperationException, guardOperationException,
makeErrorDetails,
} from "./errors"; } from "./errors";
import { import {
WALLET_CACHE_BREAKER_CLIENT_VERSION, WALLET_CACHE_BREAKER_CLIENT_VERSION,
@ -46,6 +46,11 @@ import {
import { getTimestampNow } from "../util/time"; import { getTimestampNow } from "../util/time";
import { compare } from "../util/libtoolVersion"; import { compare } from "../util/libtoolVersion";
import { createRecoupGroup, processRecoupGroup } from "./recoup"; import { createRecoupGroup, processRecoupGroup } from "./recoup";
import { TalerErrorCode } from "../TalerErrorCode";
import {
readSuccessResponseJsonOrThrow,
readSuccessResponseTextOrThrow,
} from "../util/http";
async function denominationRecordFromKeys( async function denominationRecordFromKeys(
ws: InternalWalletState, ws: InternalWalletState,
@ -77,7 +82,7 @@ async function denominationRecordFromKeys(
async function setExchangeError( async function setExchangeError(
ws: InternalWalletState, ws: InternalWalletState,
baseUrl: string, baseUrl: string,
err: OperationError, err: OperationErrorDetails,
): Promise<void> { ): Promise<void> {
console.log(`last error for exchange ${baseUrl}:`, err); console.log(`last error for exchange ${baseUrl}:`, err);
const mut = (exchange: ExchangeRecord): ExchangeRecord => { const mut = (exchange: ExchangeRecord): ExchangeRecord => {
@ -102,88 +107,40 @@ async function updateExchangeWithKeys(
if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FetchKeys) { if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FetchKeys) {
return; return;
} }
const keysUrl = new URL("keys", baseUrl); const keysUrl = new URL("keys", baseUrl);
keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
let keysResp; const resp = await ws.http.get(keysUrl.href);
try { const exchangeKeysJson = await readSuccessResponseJsonOrThrow(
const r = await ws.http.get(keysUrl.href); resp,
if (r.status !== 200) { codecForExchangeKeysJson(),
throw Error(`unexpected status for keys: ${r.status}`); );
}
keysResp = await r.json();
} catch (e) {
const m = `Fetching keys failed: ${e.message}`;
const opErr = {
type: "network",
details: {
requestUrl: e.config?.url,
},
message: m,
};
await setExchangeError(ws, baseUrl, opErr);
throw new OperationFailedAndReportedError(opErr);
}
let exchangeKeysJson: ExchangeKeysJson;
try {
exchangeKeysJson = codecForExchangeKeysJson().decode(keysResp);
} catch (e) {
const m = `Parsing /keys response failed: ${e.message}`;
const opErr = {
type: "protocol-violation",
details: {},
message: m,
};
await setExchangeError(ws, baseUrl, opErr);
throw new OperationFailedAndReportedError(opErr);
}
const lastUpdateTimestamp = exchangeKeysJson.list_issue_date;
if (!lastUpdateTimestamp) {
const m = `Parsing /keys response failed: invalid list_issue_date.`;
const opErr = {
type: "protocol-violation",
details: {},
message: m,
};
await setExchangeError(ws, baseUrl, opErr);
throw new OperationFailedAndReportedError(opErr);
}
if (exchangeKeysJson.denoms.length === 0) { if (exchangeKeysJson.denoms.length === 0) {
const m = "exchange doesn't offer any denominations"; const opErr = makeErrorDetails(
const opErr = { TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
type: "protocol-violation", "exchange doesn't offer any denominations",
details: {}, {
message: m, exchangeBaseUrl: baseUrl,
}; },
);
await setExchangeError(ws, baseUrl, opErr); await setExchangeError(ws, baseUrl, opErr);
throw new OperationFailedAndReportedError(opErr); throw new OperationFailedAndReportedError(opErr);
} }
const protocolVersion = exchangeKeysJson.version; const protocolVersion = exchangeKeysJson.version;
if (!protocolVersion) {
const m = "outdate exchange, no version in /keys response";
const opErr = {
type: "protocol-violation",
details: {},
message: m,
};
await setExchangeError(ws, baseUrl, opErr);
throw new OperationFailedAndReportedError(opErr);
}
const versionRes = compare(WALLET_EXCHANGE_PROTOCOL_VERSION, protocolVersion); const versionRes = compare(WALLET_EXCHANGE_PROTOCOL_VERSION, protocolVersion);
if (versionRes?.compatible != true) { if (versionRes?.compatible != true) {
const m = "exchange protocol version not compatible with wallet"; const opErr = makeErrorDetails(
const opErr = { TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
type: "protocol-incompatible", "exchange protocol version not compatible with wallet",
details: { {
exchangeProtocolVersion: protocolVersion, exchangeProtocolVersion: protocolVersion,
walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION, walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
}, },
message: m, );
};
await setExchangeError(ws, baseUrl, opErr); await setExchangeError(ws, baseUrl, opErr);
throw new OperationFailedAndReportedError(opErr); throw new OperationFailedAndReportedError(opErr);
} }
@ -197,6 +154,8 @@ async function updateExchangeWithKeys(
), ),
); );
const lastUpdateTimestamp = getTimestampNow();
const recoupGroupId: string | undefined = undefined; const recoupGroupId: string | undefined = undefined;
await ws.db.runWithWriteTransaction( await ws.db.runWithWriteTransaction(
@ -331,11 +290,7 @@ async function updateExchangeWithTermsOfService(
}; };
const resp = await ws.http.get(reqUrl.href, { headers }); const resp = await ws.http.get(reqUrl.href, { headers });
if (resp.status !== 200) { const tosText = await readSuccessResponseTextOrThrow(resp);
throw Error(`/terms response has unexpected status code (${resp.status})`);
}
const tosText = await resp.text();
const tosEtag = resp.headers.get("etag") || undefined; const tosEtag = resp.headers.get("etag") || undefined;
await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => { await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => {
@ -393,14 +348,11 @@ async function updateExchangeWithWireInfo(
reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
const resp = await ws.http.get(reqUrl.href); const resp = await ws.http.get(reqUrl.href);
if (resp.status !== 200) { const wireInfo = await readSuccessResponseJsonOrThrow(
throw Error(`/wire response has unexpected status code (${resp.status})`); resp,
} codecForExchangeWireJson(),
const wiJson = await resp.json(); );
if (!wiJson) {
throw Error("/wire response malformed");
}
const wireInfo = codecForExchangeWireJson().decode(wiJson);
for (const a of wireInfo.accounts) { for (const a of wireInfo.accounts) {
console.log("validating exchange acct"); console.log("validating exchange acct");
const isValid = await ws.cryptoApi.isValidWireAccount( const isValid = await ws.cryptoApi.isValidWireAccount(
@ -461,7 +413,7 @@ export async function updateExchangeFromUrl(
baseUrl: string, baseUrl: string,
forceNow = false, forceNow = false,
): Promise<ExchangeRecord> { ): Promise<ExchangeRecord> {
const onOpErr = (e: OperationError): Promise<void> => const onOpErr = (e: OperationErrorDetails): Promise<void> =>
setExchangeError(ws, baseUrl, e); setExchangeError(ws, baseUrl, e);
return await guardOperationException( return await guardOperationException(
() => updateExchangeFromUrlImpl(ws, baseUrl, forceNow), () => updateExchangeFromUrlImpl(ws, baseUrl, forceNow),

View File

@ -38,7 +38,6 @@ import {
} from "../types/dbTypes"; } from "../types/dbTypes";
import { NotificationType } from "../types/notifications"; import { NotificationType } from "../types/notifications";
import { import {
PayReq,
codecForProposal, codecForProposal,
codecForContractTerms, codecForContractTerms,
CoinDepositPermission, CoinDepositPermission,
@ -46,7 +45,7 @@ import {
} from "../types/talerTypes"; } from "../types/talerTypes";
import { import {
ConfirmPayResult, ConfirmPayResult,
OperationError, OperationErrorDetails,
PreparePayResult, PreparePayResult,
RefreshReason, RefreshReason,
} from "../types/walletTypes"; } from "../types/walletTypes";
@ -59,7 +58,10 @@ import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
import { InternalWalletState } from "./state"; import { InternalWalletState } from "./state";
import { getTimestampNow, timestampAddDuration } from "../util/time"; import { getTimestampNow, timestampAddDuration } from "../util/time";
import { strcmp, canonicalJson } from "../util/helpers"; import { strcmp, canonicalJson } from "../util/helpers";
import { httpPostTalerJson } from "../util/http"; import {
readSuccessResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow,
} from "../util/http";
/** /**
* Logger. * Logger.
@ -515,7 +517,7 @@ function getNextUrl(contractData: WalletContractData): string {
async function incrementProposalRetry( async function incrementProposalRetry(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
err: OperationError | undefined, err: OperationErrorDetails | undefined,
): Promise<void> { ): Promise<void> {
await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => { await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => {
const pr = await tx.get(Stores.proposals, proposalId); const pr = await tx.get(Stores.proposals, proposalId);
@ -538,7 +540,7 @@ async function incrementProposalRetry(
async function incrementPurchasePayRetry( async function incrementPurchasePayRetry(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
err: OperationError | undefined, err: OperationErrorDetails | undefined,
): Promise<void> { ): Promise<void> {
console.log("incrementing purchase pay retry with error", err); console.log("incrementing purchase pay retry with error", err);
await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => { await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
@ -554,7 +556,9 @@ async function incrementPurchasePayRetry(
pr.lastPayError = err; pr.lastPayError = err;
await tx.put(Stores.purchases, pr); await tx.put(Stores.purchases, pr);
}); });
ws.notify({ type: NotificationType.PayOperationError }); if (err) {
ws.notify({ type: NotificationType.PayOperationError, error: err });
}
} }
export async function processDownloadProposal( export async function processDownloadProposal(
@ -562,7 +566,7 @@ export async function processDownloadProposal(
proposalId: string, proposalId: string,
forceNow = false, forceNow = false,
): Promise<void> { ): Promise<void> {
const onOpErr = (err: OperationError): Promise<void> => const onOpErr = (err: OperationErrorDetails): Promise<void> =>
incrementProposalRetry(ws, proposalId, err); incrementProposalRetry(ws, proposalId, err);
await guardOperationException( await guardOperationException(
() => processDownloadProposalImpl(ws, proposalId, forceNow), () => processDownloadProposalImpl(ws, proposalId, forceNow),
@ -604,14 +608,15 @@ async function processDownloadProposalImpl(
).href; ).href;
logger.trace("downloading contract from '" + orderClaimUrl + "'"); logger.trace("downloading contract from '" + orderClaimUrl + "'");
const proposalResp = await httpPostTalerJson({ const reqestBody = {
url: orderClaimUrl, nonce: proposal.noncePub,
body: { };
nonce: proposal.noncePub,
}, const resp = await ws.http.postJson(orderClaimUrl, reqestBody);
codec: codecForProposal(), const proposalResp = await readSuccessResponseJsonOrThrow(
http: ws.http, resp,
}); codecForProposal(),
);
// The proposalResp contains the contract terms as raw JSON, // The proposalResp contains the contract terms as raw JSON,
// as the coded to parse them doesn't necessarily round-trip. // as the coded to parse them doesn't necessarily round-trip.
@ -779,15 +784,17 @@ export async function submitPay(
purchase.contractData.merchantBaseUrl, purchase.contractData.merchantBaseUrl,
).href; ).href;
const merchantResp = await httpPostTalerJson({ const reqBody = {
url: payUrl, coins: purchase.coinDepositPermissions,
body: { session_id: purchase.lastSessionId,
coins: purchase.coinDepositPermissions, };
session_id: purchase.lastSessionId,
}, const resp = await ws.http.postJson(payUrl, reqBody);
codec: codecForMerchantPayResponse(),
http: ws.http, const merchantResp = await readSuccessResponseJsonOrThrow(
}); resp,
codecForMerchantPayResponse(),
);
console.log("got success from pay URL", merchantResp); console.log("got success from pay URL", merchantResp);
@ -1050,7 +1057,7 @@ export async function processPurchasePay(
proposalId: string, proposalId: string,
forceNow = false, forceNow = false,
): Promise<void> { ): Promise<void> {
const onOpErr = (e: OperationError): Promise<void> => const onOpErr = (e: OperationErrorDetails): Promise<void> =>
incrementPurchasePayRetry(ws, proposalId, e); incrementPurchasePayRetry(ws, proposalId, e);
await guardOperationException( await guardOperationException(
() => processPurchasePayImpl(ws, proposalId, forceNow), () => processPurchasePayImpl(ws, proposalId, forceNow),

View File

@ -44,17 +44,17 @@ import { forceQueryReserve } from "./reserves";
import { Amounts } from "../util/amounts"; import { Amounts } from "../util/amounts";
import { createRefreshGroup, processRefreshGroup } from "./refresh"; import { createRefreshGroup, processRefreshGroup } from "./refresh";
import { RefreshReason, OperationError } from "../types/walletTypes"; import { RefreshReason, OperationErrorDetails } from "../types/walletTypes";
import { TransactionHandle } from "../util/query"; import { TransactionHandle } from "../util/query";
import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
import { getTimestampNow } from "../util/time"; import { getTimestampNow } from "../util/time";
import { guardOperationException } from "./errors"; import { guardOperationException } from "./errors";
import { httpPostTalerJson } from "../util/http"; import { readSuccessResponseJsonOrThrow } from "../util/http";
async function incrementRecoupRetry( async function incrementRecoupRetry(
ws: InternalWalletState, ws: InternalWalletState,
recoupGroupId: string, recoupGroupId: string,
err: OperationError | undefined, err: OperationErrorDetails | undefined,
): Promise<void> { ): Promise<void> {
await ws.db.runWithWriteTransaction([Stores.recoupGroups], async (tx) => { await ws.db.runWithWriteTransaction([Stores.recoupGroups], async (tx) => {
const r = await tx.get(Stores.recoupGroups, recoupGroupId); const r = await tx.get(Stores.recoupGroups, recoupGroupId);
@ -69,7 +69,9 @@ async function incrementRecoupRetry(
r.lastError = err; r.lastError = err;
await tx.put(Stores.recoupGroups, r); await tx.put(Stores.recoupGroups, r);
}); });
ws.notify({ type: NotificationType.RecoupOperationError }); if (err) {
ws.notify({ type: NotificationType.RecoupOperationError, error: err });
}
} }
async function putGroupAsFinished( async function putGroupAsFinished(
@ -147,12 +149,11 @@ async function recoupWithdrawCoin(
const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin); const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin);
const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl); const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
const recoupConfirmation = await httpPostTalerJson({ const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
url: reqUrl.href, const recoupConfirmation = await readSuccessResponseJsonOrThrow(
body: recoupRequest, resp,
codec: codecForRecoupConfirmation(), codecForRecoupConfirmation(),
http: ws.http, );
});
if (recoupConfirmation.reserve_pub !== reservePub) { if (recoupConfirmation.reserve_pub !== reservePub) {
throw Error(`Coin's reserve doesn't match reserve on recoup`); throw Error(`Coin's reserve doesn't match reserve on recoup`);
@ -223,12 +224,11 @@ async function recoupRefreshCoin(
const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl); const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
console.log("making recoup request"); console.log("making recoup request");
const recoupConfirmation = await httpPostTalerJson({ const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
url: reqUrl.href, const recoupConfirmation = await readSuccessResponseJsonOrThrow(
body: recoupRequest, resp,
codec: codecForRecoupConfirmation(), codecForRecoupConfirmation(),
http: ws.http, );
});
if (recoupConfirmation.old_coin_pub != cs.oldCoinPub) { if (recoupConfirmation.old_coin_pub != cs.oldCoinPub) {
throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`); throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`);
@ -298,7 +298,7 @@ export async function processRecoupGroup(
forceNow = false, forceNow = false,
): Promise<void> { ): Promise<void> {
await ws.memoProcessRecoup.memo(recoupGroupId, async () => { await ws.memoProcessRecoup.memo(recoupGroupId, async () => {
const onOpErr = (e: OperationError): Promise<void> => const onOpErr = (e: OperationErrorDetails): Promise<void> =>
incrementRecoupRetry(ws, recoupGroupId, e); incrementRecoupRetry(ws, recoupGroupId, e);
return await guardOperationException( return await guardOperationException(
async () => await processRecoupGroupImpl(ws, recoupGroupId, forceNow), async () => await processRecoupGroupImpl(ws, recoupGroupId, forceNow),

View File

@ -34,7 +34,7 @@ import { Logger } from "../util/logging";
import { getWithdrawDenomList } from "./withdraw"; import { getWithdrawDenomList } from "./withdraw";
import { updateExchangeFromUrl } from "./exchanges"; import { updateExchangeFromUrl } from "./exchanges";
import { import {
OperationError, OperationErrorDetails,
CoinPublicKey, CoinPublicKey,
RefreshReason, RefreshReason,
RefreshGroupId, RefreshGroupId,
@ -43,6 +43,11 @@ import { guardOperationException } from "./errors";
import { NotificationType } from "../types/notifications"; import { NotificationType } from "../types/notifications";
import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto"; import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto";
import { getTimestampNow } from "../util/time"; import { getTimestampNow } from "../util/time";
import { readSuccessResponseJsonOrThrow } from "../util/http";
import {
codecForExchangeMeltResponse,
codecForExchangeRevealResponse,
} from "../types/talerTypes";
const logger = new Logger("refresh.ts"); const logger = new Logger("refresh.ts");
@ -243,34 +248,12 @@ async function refreshMelt(
}; };
logger.trace(`melt request for coin:`, meltReq); logger.trace(`melt request for coin:`, meltReq);
const resp = await ws.http.postJson(reqUrl.href, meltReq); const resp = await ws.http.postJson(reqUrl.href, meltReq);
if (resp.status !== 200) { const meltResponse = await readSuccessResponseJsonOrThrow(
console.log(`got status ${resp.status} for refresh/melt`); resp,
try { codecForExchangeMeltResponse(),
const respJson = await resp.json(); );
console.log(
`body of refresh/melt error response:`,
JSON.stringify(respJson, undefined, 2),
);
} catch (e) {
console.log(`body of refresh/melt error response is not JSON`);
}
throw Error(`unexpected status code ${resp.status} for refresh/melt`);
}
const respJson = await resp.json(); const norevealIndex = meltResponse.noreveal_index;
logger.trace("melt response:", respJson);
if (resp.status !== 200) {
console.error(respJson);
throw Error("refresh failed");
}
const norevealIndex = respJson.noreveal_index;
if (typeof norevealIndex !== "number") {
throw Error("invalid response");
}
refreshSession.norevealIndex = norevealIndex; refreshSession.norevealIndex = norevealIndex;
@ -355,30 +338,15 @@ async function refreshReveal(
refreshSession.exchangeBaseUrl, refreshSession.exchangeBaseUrl,
); );
let resp; const resp = await ws.http.postJson(reqUrl.href, req);
try { const reveal = await readSuccessResponseJsonOrThrow(
resp = await ws.http.postJson(reqUrl.href, req); resp,
} catch (e) { codecForExchangeRevealResponse(),
console.error("got error during /refresh/reveal request"); );
console.error(e);
return;
}
if (resp.status !== 200) {
console.error("error: /refresh/reveal returned status " + resp.status);
return;
}
const respJson = await resp.json();
if (!respJson.ev_sigs || !Array.isArray(respJson.ev_sigs)) {
console.error("/refresh/reveal did not contain ev_sigs");
return;
}
const coins: CoinRecord[] = []; const coins: CoinRecord[] = [];
for (let i = 0; i < respJson.ev_sigs.length; i++) { for (let i = 0; i < reveal.ev_sigs.length; i++) {
const denom = await ws.db.get(Stores.denominations, [ const denom = await ws.db.get(Stores.denominations, [
refreshSession.exchangeBaseUrl, refreshSession.exchangeBaseUrl,
refreshSession.newDenoms[i], refreshSession.newDenoms[i],
@ -389,7 +357,7 @@ async function refreshReveal(
} }
const pc = refreshSession.planchetsForGammas[norevealIndex][i]; const pc = refreshSession.planchetsForGammas[norevealIndex][i];
const denomSig = await ws.cryptoApi.rsaUnblind( const denomSig = await ws.cryptoApi.rsaUnblind(
respJson.ev_sigs[i].ev_sig, reveal.ev_sigs[i].ev_sig,
pc.blindingKey, pc.blindingKey,
denom.denomPub, denom.denomPub,
); );
@ -457,7 +425,7 @@ async function refreshReveal(
async function incrementRefreshRetry( async function incrementRefreshRetry(
ws: InternalWalletState, ws: InternalWalletState,
refreshGroupId: string, refreshGroupId: string,
err: OperationError | undefined, err: OperationErrorDetails | undefined,
): Promise<void> { ): Promise<void> {
await ws.db.runWithWriteTransaction([Stores.refreshGroups], async (tx) => { await ws.db.runWithWriteTransaction([Stores.refreshGroups], async (tx) => {
const r = await tx.get(Stores.refreshGroups, refreshGroupId); const r = await tx.get(Stores.refreshGroups, refreshGroupId);
@ -472,7 +440,9 @@ async function incrementRefreshRetry(
r.lastError = err; r.lastError = err;
await tx.put(Stores.refreshGroups, r); await tx.put(Stores.refreshGroups, r);
}); });
ws.notify({ type: NotificationType.RefreshOperationError }); if (err) {
ws.notify({ type: NotificationType.RefreshOperationError, error: err });
}
} }
export async function processRefreshGroup( export async function processRefreshGroup(
@ -481,7 +451,7 @@ export async function processRefreshGroup(
forceNow = false, forceNow = false,
): Promise<void> { ): Promise<void> {
await ws.memoProcessRefresh.memo(refreshGroupId, async () => { await ws.memoProcessRefresh.memo(refreshGroupId, async () => {
const onOpErr = (e: OperationError): Promise<void> => const onOpErr = (e: OperationErrorDetails): Promise<void> =>
incrementRefreshRetry(ws, refreshGroupId, e); incrementRefreshRetry(ws, refreshGroupId, e);
return await guardOperationException( return await guardOperationException(
async () => await processRefreshGroupImpl(ws, refreshGroupId, forceNow), async () => await processRefreshGroupImpl(ws, refreshGroupId, forceNow),

View File

@ -25,7 +25,7 @@
*/ */
import { InternalWalletState } from "./state"; import { InternalWalletState } from "./state";
import { import {
OperationError, OperationErrorDetails,
RefreshReason, RefreshReason,
CoinPublicKey, CoinPublicKey,
} from "../types/walletTypes"; } from "../types/walletTypes";
@ -52,15 +52,18 @@ import { randomBytes } from "../crypto/primitives/nacl-fast";
import { encodeCrock } from "../crypto/talerCrypto"; import { encodeCrock } from "../crypto/talerCrypto";
import { getTimestampNow } from "../util/time"; import { getTimestampNow } from "../util/time";
import { Logger } from "../util/logging"; import { Logger } from "../util/logging";
import { readSuccessResponseJsonOrThrow } from "../util/http";
const logger = new Logger("refund.ts"); const logger = new Logger("refund.ts");
/**
* Retry querying and applying refunds for an order later.
*/
async function incrementPurchaseQueryRefundRetry( async function incrementPurchaseQueryRefundRetry(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
err: OperationError | undefined, err: OperationErrorDetails | undefined,
): Promise<void> { ): Promise<void> {
console.log("incrementing purchase refund query retry with error", err);
await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => { await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
const pr = await tx.get(Stores.purchases, proposalId); const pr = await tx.get(Stores.purchases, proposalId);
if (!pr) { if (!pr) {
@ -74,54 +77,12 @@ async function incrementPurchaseQueryRefundRetry(
pr.lastRefundStatusError = err; pr.lastRefundStatusError = err;
await tx.put(Stores.purchases, pr); await tx.put(Stores.purchases, pr);
}); });
ws.notify({ type: NotificationType.RefundStatusOperationError }); if (err) {
} ws.notify({
type: NotificationType.RefundStatusOperationError,
export async function getFullRefundFees( error: err,
ws: InternalWalletState, });
refundPermissions: MerchantRefundDetails[],
): Promise<AmountJson> {
if (refundPermissions.length === 0) {
throw Error("no refunds given");
} }
const coin0 = await ws.db.get(Stores.coins, refundPermissions[0].coin_pub);
if (!coin0) {
throw Error("coin not found");
}
let feeAcc = Amounts.getZero(
Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency,
);
const denoms = await ws.db
.iterIndex(Stores.denominations.exchangeBaseUrlIndex, coin0.exchangeBaseUrl)
.toArray();
for (const rp of refundPermissions) {
const coin = await ws.db.get(Stores.coins, rp.coin_pub);
if (!coin) {
throw Error("coin not found");
}
const denom = await ws.db.get(Stores.denominations, [
coin0.exchangeBaseUrl,
coin.denomPub,
]);
if (!denom) {
throw Error(`denom not found (${coin.denomPub})`);
}
// FIXME: this assumes that the refund already happened.
// When it hasn't, the refresh cost is inaccurate. To fix this,
// we need introduce a flag to tell if a coin was refunded or
// refreshed normally (and what about incremental refunds?)
const refundAmount = Amounts.parseOrThrow(rp.refund_amount);
const refundFee = Amounts.parseOrThrow(rp.refund_fee);
const refreshCost = getTotalRefreshCost(
denoms,
denom,
Amounts.sub(refundAmount, refundFee).amount,
);
feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount;
}
return feeAcc;
} }
function getRefundKey(d: MerchantRefundDetails): string { function getRefundKey(d: MerchantRefundDetails): string {
@ -310,14 +271,14 @@ async function acceptRefundResponse(
p.lastRefundStatusError = undefined; p.lastRefundStatusError = undefined;
p.refundStatusRetryInfo = initRetryInfo(false); p.refundStatusRetryInfo = initRetryInfo(false);
p.refundStatusRequested = false; p.refundStatusRequested = false;
console.log("refund query done"); logger.trace("refund query done");
} else { } else {
// No error, but we need to try again! // No error, but we need to try again!
p.timestampLastRefundStatus = now; p.timestampLastRefundStatus = now;
p.refundStatusRetryInfo.retryCounter++; p.refundStatusRetryInfo.retryCounter++;
updateRetryInfoTimeout(p.refundStatusRetryInfo); updateRetryInfoTimeout(p.refundStatusRetryInfo);
p.lastRefundStatusError = undefined; p.lastRefundStatusError = undefined;
console.log("refund query not done"); logger.trace("refund query not done");
} }
p.refundsRefreshCost = { ...p.refundsRefreshCost, ...refundsRefreshCost }; p.refundsRefreshCost = { ...p.refundsRefreshCost, ...refundsRefreshCost };
@ -369,7 +330,7 @@ async function startRefundQuery(
async (tx) => { async (tx) => {
const p = await tx.get(Stores.purchases, proposalId); const p = await tx.get(Stores.purchases, proposalId);
if (!p) { if (!p) {
console.log("no purchase found for refund URL"); logger.error("no purchase found for refund URL");
return false; return false;
} }
p.refundStatusRequested = true; p.refundStatusRequested = true;
@ -401,7 +362,7 @@ export async function applyRefund(
): Promise<{ contractTermsHash: string; proposalId: string }> { ): Promise<{ contractTermsHash: string; proposalId: string }> {
const parseResult = parseRefundUri(talerRefundUri); const parseResult = parseRefundUri(talerRefundUri);
console.log("applying refund", parseResult); logger.trace("applying refund", parseResult);
if (!parseResult) { if (!parseResult) {
throw Error("invalid refund URI"); throw Error("invalid refund URI");
@ -432,7 +393,7 @@ export async function processPurchaseQueryRefund(
proposalId: string, proposalId: string,
forceNow = false, forceNow = false,
): Promise<void> { ): Promise<void> {
const onOpErr = (e: OperationError): Promise<void> => const onOpErr = (e: OperationErrorDetails): Promise<void> =>
incrementPurchaseQueryRefundRetry(ws, proposalId, e); incrementPurchaseQueryRefundRetry(ws, proposalId, e);
await guardOperationException( await guardOperationException(
() => processPurchaseQueryRefundImpl(ws, proposalId, forceNow), () => processPurchaseQueryRefundImpl(ws, proposalId, forceNow),
@ -464,27 +425,23 @@ async function processPurchaseQueryRefundImpl(
if (!purchase) { if (!purchase) {
return; return;
} }
if (!purchase.refundStatusRequested) { if (!purchase.refundStatusRequested) {
return; return;
} }
const refundUrlObj = new URL("refund", purchase.contractData.merchantBaseUrl); const request = await ws.http.get(
refundUrlObj.searchParams.set("order_id", purchase.contractData.orderId); new URL(
const refundUrl = refundUrlObj.href; `orders/${purchase.contractData.orderId}`,
let resp; purchase.contractData.merchantBaseUrl,
try { ).href,
resp = await ws.http.get(refundUrl);
} catch (e) {
console.error("error downloading refund permission", e);
throw e;
}
if (resp.status !== 200) {
throw Error(`unexpected status code (${resp.status}) for /refund`);
}
const refundResponse = codecForMerchantRefundResponse().decode(
await resp.json(),
); );
const refundResponse = await readSuccessResponseJsonOrThrow(
request,
codecForMerchantRefundResponse(),
);
await acceptRefundResponse( await acceptRefundResponse(
ws, ws,
proposalId, proposalId,

View File

@ -17,7 +17,7 @@
import { import {
CreateReserveRequest, CreateReserveRequest,
CreateReserveResponse, CreateReserveResponse,
OperationError, OperationErrorDetails,
AcceptWithdrawalResponse, AcceptWithdrawalResponse,
} from "../types/walletTypes"; } from "../types/walletTypes";
import { canonicalizeBaseUrl } from "../util/helpers"; import { canonicalizeBaseUrl } from "../util/helpers";
@ -56,7 +56,7 @@ import {
import { import {
guardOperationException, guardOperationException,
OperationFailedAndReportedError, OperationFailedAndReportedError,
OperationFailedError, makeErrorDetails,
} from "./errors"; } from "./errors";
import { NotificationType } from "../types/notifications"; import { NotificationType } from "../types/notifications";
import { codecForReserveStatus } from "../types/ReserveStatus"; import { codecForReserveStatus } from "../types/ReserveStatus";
@ -67,6 +67,11 @@ import {
} from "../util/reserveHistoryUtil"; } from "../util/reserveHistoryUtil";
import { TransactionHandle } from "../util/query"; import { TransactionHandle } from "../util/query";
import { addPaytoQueryParams } from "../util/payto"; import { addPaytoQueryParams } from "../util/payto";
import { TalerErrorCode } from "../TalerErrorCode";
import {
readSuccessResponseJsonOrErrorCode,
throwUnexpectedRequestError,
} from "../util/http";
const logger = new Logger("reserves.ts"); const logger = new Logger("reserves.ts");
@ -107,7 +112,9 @@ export async function createReserve(
if (req.bankWithdrawStatusUrl) { if (req.bankWithdrawStatusUrl) {
if (!req.exchangePaytoUri) { if (!req.exchangePaytoUri) {
throw Error("Exchange payto URI must be specified for a bank-integrated withdrawal"); throw Error(
"Exchange payto URI must be specified for a bank-integrated withdrawal",
);
} }
bankInfo = { bankInfo = {
statusUrl: req.bankWithdrawStatusUrl, statusUrl: req.bankWithdrawStatusUrl,
@ -285,7 +292,7 @@ export async function processReserve(
forceNow = false, forceNow = false,
): Promise<void> { ): Promise<void> {
return ws.memoProcessReserve.memo(reservePub, async () => { return ws.memoProcessReserve.memo(reservePub, async () => {
const onOpError = (err: OperationError): Promise<void> => const onOpError = (err: OperationErrorDetails): Promise<void> =>
incrementReserveRetry(ws, reservePub, err); incrementReserveRetry(ws, reservePub, err);
await guardOperationException( await guardOperationException(
() => processReserveImpl(ws, reservePub, forceNow), () => processReserveImpl(ws, reservePub, forceNow),
@ -344,7 +351,7 @@ export async function processReserveBankStatus(
ws: InternalWalletState, ws: InternalWalletState,
reservePub: string, reservePub: string,
): Promise<void> { ): Promise<void> {
const onOpError = (err: OperationError): Promise<void> => const onOpError = (err: OperationErrorDetails): Promise<void> =>
incrementReserveRetry(ws, reservePub, err); incrementReserveRetry(ws, reservePub, err);
await guardOperationException( await guardOperationException(
() => processReserveBankStatusImpl(ws, reservePub), () => processReserveBankStatusImpl(ws, reservePub),
@ -423,7 +430,7 @@ async function processReserveBankStatusImpl(
async function incrementReserveRetry( async function incrementReserveRetry(
ws: InternalWalletState, ws: InternalWalletState,
reservePub: string, reservePub: string,
err: OperationError | undefined, err: OperationErrorDetails | undefined,
): Promise<void> { ): Promise<void> {
await ws.db.runWithWriteTransaction([Stores.reserves], async (tx) => { await ws.db.runWithWriteTransaction([Stores.reserves], async (tx) => {
const r = await tx.get(Stores.reserves, reservePub); const r = await tx.get(Stores.reserves, reservePub);
@ -444,7 +451,7 @@ async function incrementReserveRetry(
if (err) { if (err) {
ws.notify({ ws.notify({
type: NotificationType.ReserveOperationError, type: NotificationType.ReserveOperationError,
operationError: err, error: err,
}); });
} }
} }
@ -466,35 +473,32 @@ async function updateReserve(
return; return;
} }
const reqUrl = new URL(`reserves/${reservePub}`, reserve.exchangeBaseUrl); const resp = await ws.http.get(
let resp; new URL(`reserves/${reservePub}`, reserve.exchangeBaseUrl).href,
try { );
resp = await ws.http.get(reqUrl.href);
console.log("got reserves/${RESERVE_PUB} response", await resp.json()); const result = await readSuccessResponseJsonOrErrorCode(
if (resp.status === 404) { resp,
const m = "reserve not known to the exchange yet"; codecForReserveStatus(),
throw new OperationFailedError({ );
type: "waiting", if (result.isError) {
message: m, if (
details: {}, resp.status === 404 &&
result.talerErrorResponse.code === TalerErrorCode.RESERVE_STATUS_UNKNOWN
) {
ws.notify({
type: NotificationType.ReserveNotYetFound,
reservePub,
}); });
await incrementReserveRetry(ws, reservePub, undefined);
return;
} else {
throwUnexpectedRequestError(resp, result.talerErrorResponse);
} }
if (resp.status !== 200) {
throw Error(`unexpected status code ${resp.status} for reserve/status`);
}
} catch (e) {
logger.trace("caught exception for reserve/status");
const m = e.message;
const opErr = {
type: "network",
details: {},
message: m,
};
await incrementReserveRetry(ws, reservePub, opErr);
throw new OperationFailedAndReportedError(opErr);
} }
const respJson = await resp.json();
const reserveInfo = codecForReserveStatus().decode(respJson); const reserveInfo = result.response;
const balance = Amounts.parseOrThrow(reserveInfo.balance); const balance = Amounts.parseOrThrow(reserveInfo.balance);
const currency = balance.currency; const currency = balance.currency;
await ws.db.runWithWriteTransaction( await ws.db.runWithWriteTransaction(
@ -656,14 +660,12 @@ async function depleteReserve(
// Only complain about inability to withdraw if we // Only complain about inability to withdraw if we
// didn't withdraw before. // didn't withdraw before.
if (Amounts.isZero(summary.withdrawnAmount)) { if (Amounts.isZero(summary.withdrawnAmount)) {
const m = `Unable to withdraw from reserve, no denominations are available to withdraw.`; const opErr = makeErrorDetails(
const opErr = { TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
type: "internal", `Unable to withdraw from reserve, no denominations are available to withdraw.`,
message: m, {},
details: {}, );
};
await incrementReserveRetry(ws, reserve.reservePub, opErr); await incrementReserveRetry(ws, reserve.reservePub, opErr);
console.log(m);
throw new OperationFailedAndReportedError(opErr); throw new OperationFailedAndReportedError(opErr);
} }
return; return;

View File

@ -16,7 +16,7 @@
import { InternalWalletState } from "./state"; import { InternalWalletState } from "./state";
import { parseTipUri } from "../util/taleruri"; import { parseTipUri } from "../util/taleruri";
import { TipStatus, OperationError } from "../types/walletTypes"; import { TipStatus, OperationErrorDetails } from "../types/walletTypes";
import { import {
TipPlanchetDetail, TipPlanchetDetail,
codecForTipPickupGetResponse, codecForTipPickupGetResponse,
@ -43,6 +43,7 @@ import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto";
import { guardOperationException } from "./errors"; import { guardOperationException } from "./errors";
import { NotificationType } from "../types/notifications"; import { NotificationType } from "../types/notifications";
import { getTimestampNow } from "../util/time"; import { getTimestampNow } from "../util/time";
import { readSuccessResponseJsonOrThrow } from "../util/http";
export async function getTipStatus( export async function getTipStatus(
ws: InternalWalletState, ws: InternalWalletState,
@ -57,13 +58,10 @@ export async function getTipStatus(
tipStatusUrl.searchParams.set("tip_id", res.merchantTipId); tipStatusUrl.searchParams.set("tip_id", res.merchantTipId);
console.log("checking tip status from", tipStatusUrl.href); console.log("checking tip status from", tipStatusUrl.href);
const merchantResp = await ws.http.get(tipStatusUrl.href); const merchantResp = await ws.http.get(tipStatusUrl.href);
if (merchantResp.status !== 200) { const tipPickupStatus = await readSuccessResponseJsonOrThrow(
throw Error(`unexpected status ${merchantResp.status} for tip-pickup`); merchantResp,
} codecForTipPickupGetResponse(),
const respJson = await merchantResp.json(); );
console.log("resp:", respJson);
const tipPickupStatus = codecForTipPickupGetResponse().decode(respJson);
console.log("status", tipPickupStatus); console.log("status", tipPickupStatus);
const amount = Amounts.parseOrThrow(tipPickupStatus.amount); const amount = Amounts.parseOrThrow(tipPickupStatus.amount);
@ -133,7 +131,7 @@ export async function getTipStatus(
async function incrementTipRetry( async function incrementTipRetry(
ws: InternalWalletState, ws: InternalWalletState,
refreshSessionId: string, refreshSessionId: string,
err: OperationError | undefined, err: OperationErrorDetails | undefined,
): Promise<void> { ): Promise<void> {
await ws.db.runWithWriteTransaction([Stores.tips], async (tx) => { await ws.db.runWithWriteTransaction([Stores.tips], async (tx) => {
const t = await tx.get(Stores.tips, refreshSessionId); const t = await tx.get(Stores.tips, refreshSessionId);
@ -156,7 +154,7 @@ export async function processTip(
tipId: string, tipId: string,
forceNow = false, forceNow = false,
): Promise<void> { ): Promise<void> {
const onOpErr = (e: OperationError): Promise<void> => const onOpErr = (e: OperationErrorDetails): Promise<void> =>
incrementTipRetry(ws, tipId, e); incrementTipRetry(ws, tipId, e);
await guardOperationException( await guardOperationException(
() => processTipImpl(ws, tipId, forceNow), () => processTipImpl(ws, tipId, forceNow),

View File

@ -177,50 +177,57 @@ export async function getTransactions(
} }
switch (wsr.source.type) { switch (wsr.source.type) {
case WithdrawalSourceType.Reserve: { case WithdrawalSourceType.Reserve:
const r = await tx.get(Stores.reserves, wsr.source.reservePub); {
if (!r) { const r = await tx.get(Stores.reserves, wsr.source.reservePub);
break; if (!r) {
} break;
let amountRaw: AmountJson | undefined = undefined; }
if (wsr.withdrawalGroupId === r.initialWithdrawalGroupId) { let amountRaw: AmountJson | undefined = undefined;
amountRaw = r.instructedAmount; if (wsr.withdrawalGroupId === r.initialWithdrawalGroupId) {
} else { amountRaw = r.instructedAmount;
amountRaw = wsr.denomsSel.totalWithdrawCost; } else {
} amountRaw = wsr.denomsSel.totalWithdrawCost;
let withdrawalDetails: WithdrawalDetails; }
if (r.bankInfo) { let withdrawalDetails: WithdrawalDetails;
if (r.bankInfo) {
withdrawalDetails = { withdrawalDetails = {
type: WithdrawalType.TalerBankIntegrationApi, type: WithdrawalType.TalerBankIntegrationApi,
confirmed: true, confirmed: true,
bankConfirmationUrl: r.bankInfo.confirmUrl, bankConfirmationUrl: r.bankInfo.confirmUrl,
}; };
} else { } else {
const exchange = await tx.get(Stores.exchanges, r.exchangeBaseUrl); const exchange = await tx.get(
if (!exchange) { Stores.exchanges,
// FIXME: report somehow r.exchangeBaseUrl,
break; );
if (!exchange) {
// FIXME: report somehow
break;
}
withdrawalDetails = {
type: WithdrawalType.ManualTransfer,
exchangePaytoUris:
exchange.wireInfo?.accounts.map((x) => x.payto_uri) ?? [],
};
} }
withdrawalDetails = { transactions.push({
type: WithdrawalType.ManualTransfer, type: TransactionType.Withdrawal,
exchangePaytoUris: exchange.wireInfo?.accounts.map((x) => x.payto_uri) ?? [], amountEffective: Amounts.stringify(
}; wsr.denomsSel.totalCoinValue,
),
amountRaw: Amounts.stringify(amountRaw),
withdrawalDetails,
exchangeBaseUrl: wsr.exchangeBaseUrl,
pending: !wsr.timestampFinish,
timestamp: wsr.timestampStart,
transactionId: makeEventId(
TransactionType.Withdrawal,
wsr.withdrawalGroupId,
),
});
} }
transactions.push({ break;
type: TransactionType.Withdrawal,
amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
amountRaw: Amounts.stringify(amountRaw),
withdrawalDetails,
exchangeBaseUrl: wsr.exchangeBaseUrl,
pending: !wsr.timestampFinish,
timestamp: wsr.timestampStart,
transactionId: makeEventId(
TransactionType.Withdrawal,
wsr.withdrawalGroupId,
),
});
}
break;
default: default:
// Tips are reported via their own event // Tips are reported via their own event
break; break;
@ -254,7 +261,7 @@ export async function getTransactions(
type: WithdrawalType.TalerBankIntegrationApi, type: WithdrawalType.TalerBankIntegrationApi,
confirmed: false, confirmed: false,
bankConfirmationUrl: r.bankInfo.confirmUrl, bankConfirmationUrl: r.bankInfo.confirmUrl,
} };
} else { } else {
withdrawalDetails = { withdrawalDetails = {
type: WithdrawalType.ManualTransfer, type: WithdrawalType.ManualTransfer,
@ -264,9 +271,7 @@ export async function getTransactions(
transactions.push({ transactions.push({
type: TransactionType.Withdrawal, type: TransactionType.Withdrawal,
amountRaw: Amounts.stringify(r.instructedAmount), amountRaw: Amounts.stringify(r.instructedAmount),
amountEffective: Amounts.stringify( amountEffective: Amounts.stringify(r.initialDenomSel.totalCoinValue),
r.initialDenomSel.totalCoinValue,
),
exchangeBaseUrl: r.exchangeBaseUrl, exchangeBaseUrl: r.exchangeBaseUrl,
pending: true, pending: true,
timestamp: r.timestampCreated, timestamp: r.timestampCreated,

View File

@ -33,7 +33,7 @@ import {
BankWithdrawDetails, BankWithdrawDetails,
ExchangeWithdrawDetails, ExchangeWithdrawDetails,
WithdrawalDetailsResponse, WithdrawalDetailsResponse,
OperationError, OperationErrorDetails,
} from "../types/walletTypes"; } from "../types/walletTypes";
import { import {
codecForWithdrawOperationStatusResponse, codecForWithdrawOperationStatusResponse,
@ -54,7 +54,7 @@ import {
timestampCmp, timestampCmp,
timestampSubtractDuraction, timestampSubtractDuraction,
} from "../util/time"; } from "../util/time";
import { httpPostTalerJson } from "../util/http"; import { readSuccessResponseJsonOrThrow } from "../util/http";
const logger = new Logger("withdraw.ts"); const logger = new Logger("withdraw.ts");
@ -142,14 +142,11 @@ export async function getBankWithdrawalInfo(
throw Error(`can't parse URL ${talerWithdrawUri}`); throw Error(`can't parse URL ${talerWithdrawUri}`);
} }
const resp = await ws.http.get(uriResult.statusUrl); const resp = await ws.http.get(uriResult.statusUrl);
if (resp.status !== 200) { const status = await readSuccessResponseJsonOrThrow(
throw Error( resp,
`unexpected status (${resp.status}) from bank for ${uriResult.statusUrl}`, codecForWithdrawOperationStatusResponse(),
); );
}
const respJson = await resp.json();
const status = codecForWithdrawOperationStatusResponse().decode(respJson);
return { return {
amount: Amounts.parseOrThrow(status.amount), amount: Amounts.parseOrThrow(status.amount),
confirmTransferUrl: status.confirm_transfer_url, confirmTransferUrl: status.confirm_transfer_url,
@ -310,12 +307,11 @@ async function processPlanchet(
exchange.baseUrl, exchange.baseUrl,
).href; ).href;
const r = await httpPostTalerJson({ const resp = await ws.http.postJson(reqUrl, wd);
url: reqUrl, const r = await readSuccessResponseJsonOrThrow(
body: wd, resp,
codec: codecForWithdrawResponse(), codecForWithdrawResponse(),
http: ws.http, );
});
logger.trace(`got response for /withdraw`); logger.trace(`got response for /withdraw`);
@ -505,7 +501,7 @@ export async function selectWithdrawalDenoms(
async function incrementWithdrawalRetry( async function incrementWithdrawalRetry(
ws: InternalWalletState, ws: InternalWalletState,
withdrawalGroupId: string, withdrawalGroupId: string,
err: OperationError | undefined, err: OperationErrorDetails | undefined,
): Promise<void> { ): Promise<void> {
await ws.db.runWithWriteTransaction([Stores.withdrawalGroups], async (tx) => { await ws.db.runWithWriteTransaction([Stores.withdrawalGroups], async (tx) => {
const wsr = await tx.get(Stores.withdrawalGroups, withdrawalGroupId); const wsr = await tx.get(Stores.withdrawalGroups, withdrawalGroupId);
@ -530,7 +526,7 @@ export async function processWithdrawGroup(
withdrawalGroupId: string, withdrawalGroupId: string,
forceNow = false, forceNow = false,
): Promise<void> { ): Promise<void> {
const onOpErr = (e: OperationError): Promise<void> => const onOpErr = (e: OperationErrorDetails): Promise<void> =>
incrementWithdrawalRetry(ws, withdrawalGroupId, e); incrementWithdrawalRetry(ws, withdrawalGroupId, e);
await guardOperationException( await guardOperationException(
() => processWithdrawGroupImpl(ws, withdrawalGroupId, forceNow), () => processWithdrawGroupImpl(ws, withdrawalGroupId, forceNow),

View File

@ -28,7 +28,6 @@ import {
Auditor, Auditor,
CoinDepositPermission, CoinDepositPermission,
MerchantRefundDetails, MerchantRefundDetails,
PayReq,
TipResponse, TipResponse,
ExchangeSignKeyJson, ExchangeSignKeyJson,
MerchantInfo, MerchantInfo,
@ -36,7 +35,7 @@ import {
} from "./talerTypes"; } from "./talerTypes";
import { Index, Store } from "../util/query"; import { Index, Store } from "../util/query";
import { OperationError, RefreshReason } from "./walletTypes"; import { OperationErrorDetails, RefreshReason } from "./walletTypes";
import { import {
ReserveTransaction, ReserveTransaction,
ReserveCreditTransaction, ReserveCreditTransaction,
@ -319,7 +318,7 @@ export interface ReserveRecord {
* Last error that happened in a reserve operation * Last error that happened in a reserve operation
* (either talking to the bank or the exchange). * (either talking to the bank or the exchange).
*/ */
lastError: OperationError | undefined; lastError: OperationErrorDetails | undefined;
} }
/** /**
@ -633,7 +632,7 @@ export interface ExchangeRecord {
*/ */
updateDiff: ExchangeUpdateDiff | undefined; updateDiff: ExchangeUpdateDiff | undefined;
lastError?: OperationError; lastError?: OperationErrorDetails;
} }
/** /**
@ -890,14 +889,14 @@ export interface ProposalRecord {
*/ */
retryInfo: RetryInfo; retryInfo: RetryInfo;
lastError: OperationError | undefined; lastError: OperationErrorDetails | undefined;
} }
/** /**
* Status of a tip we got from a merchant. * Status of a tip we got from a merchant.
*/ */
export interface TipRecord { export interface TipRecord {
lastError: OperationError | undefined; lastError: OperationErrorDetails | undefined;
/** /**
* Has the user accepted the tip? Only after the tip has been accepted coins * Has the user accepted the tip? Only after the tip has been accepted coins
@ -982,9 +981,9 @@ export interface RefreshGroupRecord {
*/ */
retryInfo: RetryInfo; retryInfo: RetryInfo;
lastError: OperationError | undefined; lastError: OperationErrorDetails | undefined;
lastErrorPerCoin: { [coinIndex: number]: OperationError }; lastErrorPerCoin: { [coinIndex: number]: OperationErrorDetails };
refreshGroupId: string; refreshGroupId: string;
@ -1012,7 +1011,7 @@ export interface RefreshGroupRecord {
* Ongoing refresh * Ongoing refresh
*/ */
export interface RefreshSessionRecord { export interface RefreshSessionRecord {
lastError: OperationError | undefined; lastError: OperationErrorDetails | undefined;
/** /**
* Public key that's being melted in this session. * Public key that's being melted in this session.
@ -1330,7 +1329,7 @@ export interface PurchaseRecord {
payRetryInfo: RetryInfo; payRetryInfo: RetryInfo;
lastPayError: OperationError | undefined; lastPayError: OperationErrorDetails | undefined;
/** /**
* Retry information for querying the refund status with the merchant. * Retry information for querying the refund status with the merchant.
@ -1340,7 +1339,7 @@ export interface PurchaseRecord {
/** /**
* Last error (or undefined) for querying the refund status with the merchant. * Last error (or undefined) for querying the refund status with the merchant.
*/ */
lastRefundStatusError: OperationError | undefined; lastRefundStatusError: OperationErrorDetails | undefined;
/** /**
* Continue querying the refund status until this deadline has expired. * Continue querying the refund status until this deadline has expired.
@ -1492,9 +1491,9 @@ export interface WithdrawalGroupRecord {
* Last error per coin/planchet, or undefined if no error occured for * Last error per coin/planchet, or undefined if no error occured for
* the coin/planchet. * the coin/planchet.
*/ */
lastErrorPerCoin: { [coinIndex: number]: OperationError }; lastErrorPerCoin: { [coinIndex: number]: OperationErrorDetails };
lastError: OperationError | undefined; lastError: OperationErrorDetails | undefined;
} }
export interface BankWithdrawUriRecord { export interface BankWithdrawUriRecord {
@ -1559,7 +1558,7 @@ export interface RecoupGroupRecord {
/** /**
* Last error that occured, if any. * Last error that occured, if any.
*/ */
lastError: OperationError | undefined; lastError: OperationErrorDetails | undefined;
} }
export const enum ImportPayloadType { export const enum ImportPayloadType {

View File

@ -22,7 +22,7 @@
/** /**
* Imports. * Imports.
*/ */
import { OperationError } from "./walletTypes"; import { OperationErrorDetails } from "./walletTypes";
import { WithdrawalSource } from "./dbTypes"; import { WithdrawalSource } from "./dbTypes";
export const enum NotificationType { export const enum NotificationType {
@ -54,6 +54,7 @@ export const enum NotificationType {
TipOperationError = "tip-error", TipOperationError = "tip-error",
PayOperationError = "pay-error", PayOperationError = "pay-error",
WithdrawOperationError = "withdraw-error", WithdrawOperationError = "withdraw-error",
ReserveNotYetFound = "reserve-not-yet-found",
ReserveOperationError = "reserve-error", ReserveOperationError = "reserve-error",
InternalError = "internal-error", InternalError = "internal-error",
PendingOperationProcessed = "pending-operation-processed", PendingOperationProcessed = "pending-operation-processed",
@ -72,6 +73,11 @@ export interface InternalErrorNotification {
exception: any; exception: any;
} }
export interface ReserveNotYetFoundNotification {
type: NotificationType.ReserveNotYetFound;
reservePub: string;
}
export interface CoinWithdrawnNotification { export interface CoinWithdrawnNotification {
type: NotificationType.CoinWithdrawn; type: NotificationType.CoinWithdrawn;
} }
@ -148,27 +154,32 @@ export interface RefundFinishedNotification {
export interface ExchangeOperationErrorNotification { export interface ExchangeOperationErrorNotification {
type: NotificationType.ExchangeOperationError; type: NotificationType.ExchangeOperationError;
error: OperationErrorDetails;
} }
export interface RefreshOperationErrorNotification { export interface RefreshOperationErrorNotification {
type: NotificationType.RefreshOperationError; type: NotificationType.RefreshOperationError;
error: OperationErrorDetails;
} }
export interface RefundStatusOperationErrorNotification { export interface RefundStatusOperationErrorNotification {
type: NotificationType.RefundStatusOperationError; type: NotificationType.RefundStatusOperationError;
error: OperationErrorDetails;
} }
export interface RefundApplyOperationErrorNotification { export interface RefundApplyOperationErrorNotification {
type: NotificationType.RefundApplyOperationError; type: NotificationType.RefundApplyOperationError;
error: OperationErrorDetails;
} }
export interface PayOperationErrorNotification { export interface PayOperationErrorNotification {
type: NotificationType.PayOperationError; type: NotificationType.PayOperationError;
error: OperationErrorDetails;
} }
export interface ProposalOperationErrorNotification { export interface ProposalOperationErrorNotification {
type: NotificationType.ProposalOperationError; type: NotificationType.ProposalOperationError;
error: OperationError; error: OperationErrorDetails;
} }
export interface TipOperationErrorNotification { export interface TipOperationErrorNotification {
@ -177,16 +188,17 @@ export interface TipOperationErrorNotification {
export interface WithdrawOperationErrorNotification { export interface WithdrawOperationErrorNotification {
type: NotificationType.WithdrawOperationError; type: NotificationType.WithdrawOperationError;
error: OperationError, error: OperationErrorDetails;
} }
export interface RecoupOperationErrorNotification { export interface RecoupOperationErrorNotification {
type: NotificationType.RecoupOperationError; type: NotificationType.RecoupOperationError;
error: OperationErrorDetails;
} }
export interface ReserveOperationErrorNotification { export interface ReserveOperationErrorNotification {
type: NotificationType.ReserveOperationError; type: NotificationType.ReserveOperationError;
operationError: OperationError; error: OperationErrorDetails;
} }
export interface ReserveCreatedNotification { export interface ReserveCreatedNotification {
@ -238,4 +250,5 @@ export type WalletNotification =
| InternalErrorNotification | InternalErrorNotification
| PendingOperationProcessedNotification | PendingOperationProcessedNotification
| ProposalRefusedNotification | ProposalRefusedNotification
| ReserveRegisteredWithBankNotification; | ReserveRegisteredWithBankNotification
| ReserveNotYetFoundNotification;

View File

@ -21,7 +21,7 @@
/** /**
* Imports. * Imports.
*/ */
import { OperationError, WalletBalance } from "./walletTypes"; import { OperationErrorDetails, WalletBalance } from "./walletTypes";
import { WithdrawalSource, RetryInfo, ReserveRecordStatus } from "./dbTypes"; import { WithdrawalSource, RetryInfo, ReserveRecordStatus } from "./dbTypes";
import { Timestamp, Duration } from "../util/time"; import { Timestamp, Duration } from "../util/time";
import { ReserveType } from "./history"; import { ReserveType } from "./history";
@ -68,7 +68,7 @@ export interface PendingExchangeUpdateOperation {
stage: ExchangeUpdateOperationStage; stage: ExchangeUpdateOperationStage;
reason: string; reason: string;
exchangeBaseUrl: string; exchangeBaseUrl: string;
lastError: OperationError | undefined; lastError: OperationErrorDetails | undefined;
} }
/** /**
@ -112,7 +112,7 @@ export interface PendingReserveOperation {
*/ */
export interface PendingRefreshOperation { export interface PendingRefreshOperation {
type: PendingOperationType.Refresh; type: PendingOperationType.Refresh;
lastError?: OperationError; lastError?: OperationErrorDetails;
refreshGroupId: string; refreshGroupId: string;
finishedPerCoin: boolean[]; finishedPerCoin: boolean[];
retryInfo: RetryInfo; retryInfo: RetryInfo;
@ -127,7 +127,7 @@ export interface PendingProposalDownloadOperation {
proposalTimestamp: Timestamp; proposalTimestamp: Timestamp;
proposalId: string; proposalId: string;
orderId: string; orderId: string;
lastError?: OperationError; lastError?: OperationErrorDetails;
retryInfo: RetryInfo; retryInfo: RetryInfo;
} }
@ -172,7 +172,7 @@ export interface PendingPayOperation {
proposalId: string; proposalId: string;
isReplay: boolean; isReplay: boolean;
retryInfo: RetryInfo; retryInfo: RetryInfo;
lastError: OperationError | undefined; lastError: OperationErrorDetails | undefined;
} }
/** /**
@ -183,14 +183,14 @@ export interface PendingRefundQueryOperation {
type: PendingOperationType.RefundQuery; type: PendingOperationType.RefundQuery;
proposalId: string; proposalId: string;
retryInfo: RetryInfo; retryInfo: RetryInfo;
lastError: OperationError | undefined; lastError: OperationErrorDetails | undefined;
} }
export interface PendingRecoupOperation { export interface PendingRecoupOperation {
type: PendingOperationType.Recoup; type: PendingOperationType.Recoup;
recoupGroupId: string; recoupGroupId: string;
retryInfo: RetryInfo; retryInfo: RetryInfo;
lastError: OperationError | undefined; lastError: OperationErrorDetails | undefined;
} }
/** /**
@ -199,7 +199,7 @@ export interface PendingRecoupOperation {
export interface PendingWithdrawOperation { export interface PendingWithdrawOperation {
type: PendingOperationType.Withdraw; type: PendingOperationType.Withdraw;
source: WithdrawalSource; source: WithdrawalSource;
lastError: OperationError | undefined; lastError: OperationErrorDetails | undefined;
withdrawalGroupId: string; withdrawalGroupId: string;
numCoinsWithdrawn: number; numCoinsWithdrawn: number;
numCoinsTotal: number; numCoinsTotal: number;

View File

@ -433,7 +433,6 @@ export class ContractTerms {
extra: any; extra: any;
} }
/** /**
* Refund permission in the format that the merchant gives it to us. * Refund permission in the format that the merchant gives it to us.
*/ */
@ -788,6 +787,53 @@ export interface MerchantPayResponse {
sig: string; sig: string;
} }
export interface ExchangeMeltResponse {
/**
* Which of the kappa indices does the client not have to reveal.
*/
noreveal_index: number;
/**
* Signature of TALER_RefreshMeltConfirmationPS whereby the exchange
* affirms the successful melt and confirming the noreveal_index
*/
exchange_sig: EddsaSignatureString;
/*
* public EdDSA key of the exchange that was used to generate the signature.
* Should match one of the exchange's signing keys from /keys. Again given
* explicitly as the client might otherwise be confused by clock skew as to
* which signing key was used.
*/
exchange_pub: EddsaPublicKeyString;
/*
* Base URL to use for operations on the refresh context
* (so the reveal operation). If not given,
* the base URL is the same as the one used for this request.
* Can be used if the base URL for /refreshes/ differs from that
* for /coins/, i.e. for load balancing. Clients SHOULD
* respect the refresh_base_url if provided. Any HTTP server
* belonging to an exchange MUST generate a 307 or 308 redirection
* to the correct base URL should a client uses the wrong base
* URL, or if the base URL has changed since the melt.
*
* When melting the same coin twice (technically allowed
* as the response might have been lost on the network),
* the exchange may return different values for the refresh_base_url.
*/
refresh_base_url?: string;
}
export interface ExchangeRevealItem {
ev_sig: string;
}
export interface ExchangeRevealResponse {
// List of the exchange's blinded RSA signatures on the new coins.
ev_sigs: ExchangeRevealItem[];
}
export type AmountString = string; export type AmountString = string;
export type Base32String = string; export type Base32String = string;
export type EddsaSignatureString = string; export type EddsaSignatureString = string;
@ -1028,3 +1074,23 @@ export const codecForMerchantPayResponse = (): Codec<MerchantPayResponse> =>
makeCodecForObject<MerchantPayResponse>() makeCodecForObject<MerchantPayResponse>()
.property("sig", codecForString) .property("sig", codecForString)
.build("MerchantPayResponse"); .build("MerchantPayResponse");
export const codecForExchangeMeltResponse = (): Codec<ExchangeMeltResponse> =>
makeCodecForObject<ExchangeMeltResponse>()
.property("exchange_pub", codecForString)
.property("exchange_sig", codecForString)
.property("noreveal_index", codecForNumber)
.property("refresh_base_url", makeCodecOptional(codecForString))
.build("ExchangeMeltResponse");
export const codecForExchangeRevealItem = (): Codec<ExchangeRevealItem> =>
makeCodecForObject<ExchangeRevealItem>()
.property("ev_sig", codecForString)
.build("ExchangeRevealItem");
export const codecForExchangeRevealResponse = (): Codec<
ExchangeRevealResponse
> =>
makeCodecForObject<ExchangeRevealResponse>()
.property("ev_sigs", makeCodecForList(codecForExchangeRevealItem()))
.build("ExchangeRevealResponse");

View File

@ -303,7 +303,7 @@ export class ReturnCoinsRequest {
* Wire details for the bank account of the customer that will * Wire details for the bank account of the customer that will
* receive the funds. * receive the funds.
*/ */
senderWire?: object; senderWire?: string;
/** /**
* Verify that a value matches the schema of this class and convert it into a * Verify that a value matches the schema of this class and convert it into a
@ -406,10 +406,11 @@ export interface WalletDiagnostics {
dbOutdated: boolean; dbOutdated: boolean;
} }
export interface OperationError { export interface OperationErrorDetails {
type: string; talerErrorCode: number;
talerErrorHint: string;
message: string; message: string;
details: any; details: unknown;
} }
export interface PlanchetCreationResult { export interface PlanchetCreationResult {

View File

@ -24,9 +24,7 @@ const jAmt = (
currency: string, currency: string,
): AmountJson => ({ value, fraction, currency }); ): AmountJson => ({ value, fraction, currency });
const sAmt = ( const sAmt = (s: string): AmountJson => Amounts.parseOrThrow(s);
s: string
): AmountJson => Amounts.parseOrThrow(s);
test("amount addition (simple)", (t) => { test("amount addition (simple)", (t) => {
const a1 = jAmt(1, 0, "EUR"); const a1 = jAmt(1, 0, "EUR");

View File

@ -349,7 +349,7 @@ function mult(a: AmountJson, n: number): Result {
n = n / 2; n = n / 2;
} else { } else {
n = (n - 1) / 2; n = (n - 1) / 2;
const r2 = add(acc, x) const r2 = add(acc, x);
if (r2.saturated) { if (r2.saturated) {
return r2; return r2;
} }

View File

@ -14,18 +14,26 @@
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { Codec } from "./codec";
import { OperationFailedError } from "../operations/errors";
/** /**
* Helpers for doing XMLHttpRequest-s that are based on ES6 promises. * Helpers for doing XMLHttpRequest-s that are based on ES6 promises.
* Allows for easy mocking for test cases. * Allows for easy mocking for test cases.
*/ */
/**
* Imports
*/
import { Codec } from "./codec";
import { OperationFailedError, makeErrorDetails } from "../operations/errors";
import { TalerErrorCode } from "../TalerErrorCode";
import { Logger } from "./logging";
const logger = new Logger("http.ts");
/** /**
* An HTTP response that is returned by all request methods of this library. * An HTTP response that is returned by all request methods of this library.
*/ */
export interface HttpResponse { export interface HttpResponse {
requestUrl: string;
status: number; status: number;
headers: Headers; headers: Headers;
json(): Promise<any>; json(): Promise<any>;
@ -67,10 +75,20 @@ export class Headers {
} }
/** /**
* The request library is bundled into an interface to m responseJson: object & any;ake mocking easy. * Interface for the HTTP request library used by the wallet.
*
* The request library is bundled into an interface to make mocking and
* request tunneling easy.
*/ */
export interface HttpRequestLibrary { export interface HttpRequestLibrary {
/**
* Make an HTTP GET request.
*/
get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse>; get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse>;
/**
* Make an HTTP POST request with a JSON body.
*/
postJson( postJson(
url: string, url: string,
body: any, body: any,
@ -105,18 +123,29 @@ export class BrowserHttpLib implements HttpRequestLibrary {
} }
myRequest.onerror = (e) => { myRequest.onerror = (e) => {
console.error("http request error"); logger.error("http request error");
reject(Error("could not make XMLHttpRequest")); reject(
OperationFailedError.fromCode(
TalerErrorCode.WALLET_NETWORK_ERROR,
"Could not make request",
{
requestUrl: url,
},
),
);
}; };
myRequest.addEventListener("readystatechange", (e) => { myRequest.addEventListener("readystatechange", (e) => {
if (myRequest.readyState === XMLHttpRequest.DONE) { if (myRequest.readyState === XMLHttpRequest.DONE) {
if (myRequest.status === 0) { if (myRequest.status === 0) {
reject( const exc = OperationFailedError.fromCode(
Error( TalerErrorCode.WALLET_NETWORK_ERROR,
"HTTP Request failed (status code 0, maybe URI scheme is wrong?)", "HTTP request failed (status 0, maybe URI scheme was wrong?)",
), {
requestUrl: url,
},
); );
reject(exc);
return; return;
} }
const makeJson = async (): Promise<any> => { const makeJson = async (): Promise<any> => {
@ -124,10 +153,24 @@ export class BrowserHttpLib implements HttpRequestLibrary {
try { try {
responseJson = JSON.parse(myRequest.responseText); responseJson = JSON.parse(myRequest.responseText);
} catch (e) { } catch (e) {
throw Error("Invalid JSON from HTTP response"); throw OperationFailedError.fromCode(
TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
"Invalid JSON from HTTP response",
{
requestUrl: url,
httpStatusCode: myRequest.status,
},
);
} }
if (responseJson === null || typeof responseJson !== "object") { if (responseJson === null || typeof responseJson !== "object") {
throw Error("Invalid JSON from HTTP response"); throw OperationFailedError.fromCode(
TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
"Invalid JSON from HTTP response",
{
requestUrl: url,
httpStatusCode: myRequest.status,
},
);
} }
return responseJson; return responseJson;
}; };
@ -141,13 +184,14 @@ export class BrowserHttpLib implements HttpRequestLibrary {
const parts = line.split(": "); const parts = line.split(": ");
const headerName = parts.shift(); const headerName = parts.shift();
if (!headerName) { if (!headerName) {
console.error("invalid header"); logger.warn("skipping invalid header");
return; return;
} }
const value = parts.join(": "); const value = parts.join(": ");
headerMap.set(headerName, value); headerMap.set(headerName, value);
}); });
const resp: HttpResponse = { const resp: HttpResponse = {
requestUrl: url,
status: myRequest.status, status: myRequest.status,
headers: headerMap, headers: headerMap,
json: makeJson, json: makeJson,
@ -165,7 +209,7 @@ export class BrowserHttpLib implements HttpRequestLibrary {
postJson( postJson(
url: string, url: string,
body: any, body: unknown,
opt?: HttpRequestOptions, opt?: HttpRequestOptions,
): Promise<HttpResponse> { ): Promise<HttpResponse> {
return this.req("post", url, JSON.stringify(body), opt); return this.req("post", url, JSON.stringify(body), opt);
@ -176,114 +220,121 @@ export class BrowserHttpLib implements HttpRequestLibrary {
} }
} }
export interface PostJsonRequest<RespType> { type TalerErrorResponse = {
http: HttpRequestLibrary; code: number;
url: string; } & unknown;
body: any;
codec: Codec<RespType>;
}
/** type ResponseOrError<T> =
* Helper for making Taler-style HTTP POST requests with a JSON payload and response. | { isError: false; response: T }
*/ | { isError: true; talerErrorResponse: TalerErrorResponse };
export async function httpPostTalerJson<RespType>(
req: PostJsonRequest<RespType>,
): Promise<RespType> {
const resp = await req.http.postJson(req.url, req.body);
if (resp.status !== 200) { export async function readSuccessResponseJsonOrErrorCode<T>(
let exc: OperationFailedError | undefined = undefined; httpResponse: HttpResponse,
try { codec: Codec<T>,
const errorJson = await resp.json(); ): Promise<ResponseOrError<T>> {
const m = `received error response (status ${resp.status})`; if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
exc = new OperationFailedError({ const errJson = await httpResponse.json();
type: "protocol", const talerErrorCode = errJson.code;
message: m, if (typeof talerErrorCode !== "number") {
details: { throw new OperationFailedError(
httpStatusCode: resp.status, makeErrorDetails(
errorResponse: errorJson, TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
}, "Error response did not contain error code",
}); {
} catch (e) { requestUrl: httpResponse.requestUrl,
const m = "could not parse response JSON"; },
exc = new OperationFailedError({ ),
type: "network", );
message: m,
details: {
status: resp.status,
},
});
} }
throw exc; return {
isError: true,
talerErrorResponse: errJson,
};
} }
let json: any; const respJson = await httpResponse.json();
let parsedResponse: T;
try { try {
json = await resp.json(); parsedResponse = codec.decode(respJson);
} catch (e) { } catch (e) {
const m = "could not parse response JSON"; throw OperationFailedError.fromCode(
throw new OperationFailedError({ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
type: "network", "Response invalid",
message: m, {
details: { requestUrl: httpResponse.requestUrl,
status: resp.status, httpStatusCode: httpResponse.status,
validationError: e.toString(),
}, },
}); );
} }
return req.codec.decode(json); return {
isError: false,
response: parsedResponse,
};
} }
export function throwUnexpectedRequestError(
export interface GetJsonRequest<RespType> { httpResponse: HttpResponse,
http: HttpRequestLibrary; talerErrorResponse: TalerErrorResponse,
url: string; ): never {
codec: Codec<RespType>; throw new OperationFailedError(
makeErrorDetails(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
"Unexpected error code in response",
{
requestUrl: httpResponse.requestUrl,
httpStatusCode: httpResponse.status,
errorResponse: talerErrorResponse,
},
),
);
} }
/** export async function readSuccessResponseJsonOrThrow<T>(
* Helper for making Taler-style HTTP GET requests with a JSON payload. httpResponse: HttpResponse,
*/ codec: Codec<T>,
export async function httpGetTalerJson<RespType>( ): Promise<T> {
req: GetJsonRequest<RespType>, const r = await readSuccessResponseJsonOrErrorCode(httpResponse, codec);
): Promise<RespType> { if (!r.isError) {
const resp = await req.http.get(req.url); return r.response;
}
throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
}
if (resp.status !== 200) { export async function readSuccessResponseTextOrErrorCode<T>(
let exc: OperationFailedError | undefined = undefined; httpResponse: HttpResponse,
try { ): Promise<ResponseOrError<string>> {
const errorJson = await resp.json(); if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
const m = `received error response (status ${resp.status})`; const errJson = await httpResponse.json();
exc = new OperationFailedError({ const talerErrorCode = errJson.code;
type: "protocol", if (typeof talerErrorCode !== "number") {
message: m, throw new OperationFailedError(
details: { makeErrorDetails(
httpStatusCode: resp.status, TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
errorResponse: errorJson, "Error response did not contain error code",
}, {
}); requestUrl: httpResponse.requestUrl,
} catch (e) { },
const m = "could not parse response JSON"; ),
exc = new OperationFailedError({ );
type: "network",
message: m,
details: {
status: resp.status,
},
});
} }
throw exc; return {
isError: true,
talerErrorResponse: errJson,
};
} }
let json: any; const respJson = await httpResponse.text();
try { return {
json = await resp.json(); isError: false,
} catch (e) { response: respJson,
const m = "could not parse response JSON"; };
throw new OperationFailedError({ }
type: "network",
message: m, export async function readSuccessResponseTextOrThrow<T>(
details: { httpResponse: HttpResponse,
status: resp.status, ): Promise<string> {
}, const r = await readSuccessResponseTextOrErrorCode(httpResponse);
}); if (!r.isError) {
} return r.response;
return req.codec.decode(json); }
throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
} }

View File

@ -52,7 +52,7 @@ import {
ReserveRecordStatus, ReserveRecordStatus,
CoinSourceType, CoinSourceType,
} from "./types/dbTypes"; } from "./types/dbTypes";
import { MerchantRefundDetails, CoinDumpJson } from "./types/talerTypes"; import { CoinDumpJson } from "./types/talerTypes";
import { import {
BenchmarkResult, BenchmarkResult,
ConfirmPayResult, ConfirmPayResult,
@ -106,11 +106,7 @@ import {
} from "./types/pending"; } from "./types/pending";
import { WalletNotification, NotificationType } from "./types/notifications"; import { WalletNotification, NotificationType } from "./types/notifications";
import { HistoryQuery, HistoryEvent } from "./types/history"; import { HistoryQuery, HistoryEvent } from "./types/history";
import { import { processPurchaseQueryRefund, applyRefund } from "./operations/refund";
processPurchaseQueryRefund,
getFullRefundFees,
applyRefund,
} from "./operations/refund";
import { durationMin, Duration } from "./util/time"; import { durationMin, Duration } from "./util/time";
import { processRecoupGroup } from "./operations/recoup"; import { processRecoupGroup } from "./operations/recoup";
import { OperationFailedAndReportedError } from "./operations/errors"; import { OperationFailedAndReportedError } from "./operations/errors";
@ -372,12 +368,12 @@ export class Wallet {
type: NotificationType.InternalError, type: NotificationType.InternalError,
message: "uncaught exception", message: "uncaught exception",
exception: e, exception: e,
}); });
} }
} }
this.ws.notify({ this.ws.notify({
type: NotificationType.PendingOperationProcessed, type: NotificationType.PendingOperationProcessed,
}); });
} }
} }
} }
@ -712,12 +708,6 @@ export class Wallet {
return this.db.get(Stores.purchases, contractTermsHash); return this.db.get(Stores.purchases, contractTermsHash);
} }
async getFullRefundFees(
refundPermissions: MerchantRefundDetails[],
): Promise<AmountJson> {
return getFullRefundFees(this.ws, refundPermissions);
}
async acceptTip(talerTipUri: string): Promise<void> { async acceptTip(talerTipUri: string): Promise<void> {
try { try {
return acceptTip(this.ws, talerTipUri); return acceptTip(this.ws, talerTipUri);

View File

@ -35,7 +35,9 @@ import {
} from "../wxApi"; } from "../wxApi";
function WithdrawalDialog(props: { talerWithdrawUri: string }): JSX.Element { function WithdrawalDialog(props: { talerWithdrawUri: string }): JSX.Element {
const [details, setDetails] = useState<WithdrawalDetailsResponse | undefined>(); const [details, setDetails] = useState<
WithdrawalDetailsResponse | undefined
>();
const [selectedExchange, setSelectedExchange] = useState< const [selectedExchange, setSelectedExchange] = useState<
string | undefined string | undefined
>(); >();

View File

@ -29,10 +29,7 @@ import {
openTalerDatabase, openTalerDatabase,
WALLET_DB_MINOR_VERSION, WALLET_DB_MINOR_VERSION,
} from "../db"; } from "../db";
import { import { ReturnCoinsRequest, WalletDiagnostics } from "../types/walletTypes";
ReturnCoinsRequest,
WalletDiagnostics,
} from "../types/walletTypes";
import { BrowserHttpLib } from "../util/http"; import { BrowserHttpLib } from "../util/http";
import { OpenedPromise, openPromise } from "../util/promiseUtils"; import { OpenedPromise, openPromise } from "../util/promiseUtils";
import { classifyTalerUri, TalerUriType } from "../util/taleruri"; import { classifyTalerUri, TalerUriType } from "../util/taleruri";