wallet-core: fix withdrawal idempotency

This commit is contained in:
Florian Dold 2022-08-24 19:44:24 +02:00
parent d32d2895ce
commit 42c2b7508f
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
6 changed files with 78 additions and 38 deletions

View File

@ -452,7 +452,6 @@ export interface BankWithdrawDetails {
suggestedExchange?: string; suggestedExchange?: string;
confirmTransferUrl?: string; confirmTransferUrl?: string;
wireTypes: string[]; wireTypes: string[];
extractedStatusUrl: string;
} }
export interface AcceptWithdrawalResponse { export interface AcceptWithdrawalResponse {

View File

@ -24,6 +24,7 @@ import {
BankApi, BankApi,
BankAccessApi, BankAccessApi,
} from "@gnu-taler/taler-wallet-core"; } from "@gnu-taler/taler-wallet-core";
import { j2s } from "@gnu-taler/taler-util";
/** /**
* Run test for basic, bank-integrated withdrawal. * Run test for basic, bank-integrated withdrawal.
@ -62,6 +63,14 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) {
talerWithdrawUri: wop.taler_withdraw_uri, talerWithdrawUri: wop.taler_withdraw_uri,
}, },
); );
// Do it twice to check idempotency
const r3 = await wallet.client.call(
WalletApiOperation.AcceptBankIntegratedWithdrawal,
{
exchangeBaseUrl: exchange.baseUrl,
talerWithdrawUri: wop.taler_withdraw_uri,
},
);
await wallet.runPending(); await wallet.runPending();
// Confirm it // Confirm it
@ -75,7 +84,8 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) {
const balResp = await wallet.client.call(WalletApiOperation.GetBalances, {}); const balResp = await wallet.client.call(WalletApiOperation.GetBalances, {});
t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available); t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available);
await t.shutdown(); const txn = await wallet.client.call(WalletApiOperation.GetTransactions, {});
console.log(`transactions: ${j2s(txn)}`);
} }
runWithdrawalBankIntegratedTest.suites = ["wallet"]; runWithdrawalBankIntegratedTest.suites = ["wallet"];

View File

@ -112,11 +112,7 @@ export enum ReserveRecordStatus {
* with a bank-integrated withdrawal. * with a bank-integrated withdrawal.
*/ */
export interface ReserveBankInfo { export interface ReserveBankInfo {
/** talerWithdrawUri: string;
* Status URL that the wallet will use to query the status
* of the Taler withdrawal operation on the bank's side.
*/
statusUrl: string;
/** /**
* URL that the user can be redirected to, and allows * URL that the user can be redirected to, and allows
@ -1799,6 +1795,10 @@ export const WalletStoresV1 = {
{ {
byReservePub: describeIndex("byReservePub", "reservePub"), byReservePub: describeIndex("byReservePub", "reservePub"),
byStatus: describeIndex("byStatus", "operationStatus"), byStatus: describeIndex("byStatus", "operationStatus"),
byTalerWithdrawUri: describeIndex(
"byTalerWithdrawUri",
"bankInfo.talerWithdrawUri",
),
}, },
), ),
planchets: describeStore( planchets: describeStore(

View File

@ -46,6 +46,8 @@ import {
parsePaytoUri, parsePaytoUri,
AbsoluteTime, AbsoluteTime,
UnblindedSignature, UnblindedSignature,
BankWithdrawDetails,
parseWithdrawUri,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
import { DenominationRecord } from "./db.js"; import { DenominationRecord } from "./db.js";
@ -57,7 +59,12 @@ import {
isWithdrawableDenom, isWithdrawableDenom,
readSuccessResponseJsonOrThrow, readSuccessResponseJsonOrThrow,
} from "./index.browser.js"; } from "./index.browser.js";
import { BankAccessApi, BankApi, BankServiceHandle } from "./index.js"; import {
BankAccessApi,
BankApi,
BankServiceHandle,
getBankStatusUrl,
} from "./index.js";
const logger = new Logger("dbless.ts"); const logger = new Logger("dbless.ts");
@ -119,7 +126,7 @@ export async function topupReserveWithDemobank(
amount, amount,
); );
const bankInfo = await getBankWithdrawalInfo(http, wopi.taler_withdraw_uri); const bankInfo = await getBankWithdrawalInfo(http, wopi.taler_withdraw_uri);
const bankStatusUrl = bankInfo.extractedStatusUrl; const bankStatusUrl = getBankStatusUrl(wopi.taler_withdraw_uri);
if (!bankInfo.suggestedExchange) { if (!bankInfo.suggestedExchange) {
throw Error("no suggested exchange"); throw Error("no suggested exchange");
} }

View File

@ -36,7 +36,8 @@ import {
codecForWithdrawResponse, codecForWithdrawResponse,
DenomKeyType, DenomKeyType,
Duration, Duration,
durationFromSpec, encodeCrock, durationFromSpec,
encodeCrock,
ExchangeListItem, ExchangeListItem,
ExchangeWithdrawRequest, ExchangeWithdrawRequest,
ForcedDenomSel, ForcedDenomSel,
@ -54,7 +55,8 @@ import {
VersionMatchResult, VersionMatchResult,
WithdrawBatchResponse, WithdrawBatchResponse,
WithdrawResponse, WithdrawResponse,
WithdrawUriInfoResponse WithdrawUriInfoResponse,
WithdrawUriResult,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { EddsaKeypair } from "../crypto/cryptoImplementation.js"; import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
import { import {
@ -71,12 +73,12 @@ import {
ReserveBankInfo, ReserveBankInfo,
ReserveRecordStatus, ReserveRecordStatus,
WalletStoresV1, WalletStoresV1,
WithdrawalGroupRecord WithdrawalGroupRecord,
} from "../db.js"; } from "../db.js";
import { import {
getErrorDetailFromException, getErrorDetailFromException,
makeErrorDetail, makeErrorDetail,
TalerError TalerError,
} from "../errors.js"; } from "../errors.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { assertUnreachable } from "../util/assertUnreachable.js"; import { assertUnreachable } from "../util/assertUnreachable.js";
@ -85,24 +87,21 @@ import {
HttpRequestLibrary, HttpRequestLibrary,
readSuccessResponseJsonOrErrorCode, readSuccessResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow, readSuccessResponseJsonOrThrow,
throwUnexpectedRequestError throwUnexpectedRequestError,
} from "../util/http.js"; } from "../util/http.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import { import { DbAccess, GetReadOnlyAccess } from "../util/query.js";
DbAccess,
GetReadOnlyAccess
} from "../util/query.js";
import { RetryInfo } from "../util/retries.js"; import { RetryInfo } from "../util/retries.js";
import { import {
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION WALLET_EXCHANGE_PROTOCOL_VERSION,
} from "../versions.js"; } from "../versions.js";
import { guardOperationException } from "./common.js"; import { guardOperationException } from "./common.js";
import { import {
getExchangeDetails, getExchangeDetails,
getExchangePaytoUri, getExchangePaytoUri,
getExchangeTrust, getExchangeTrust,
updateExchangeFromUrl updateExchangeFromUrl,
} from "./exchanges.js"; } from "./exchanges.js";
/** /**
@ -241,7 +240,7 @@ export function selectWithdrawalDenominations(
for (const d of denoms) { for (const d of denoms) {
let count = 0; let count = 0;
const cost = Amounts.add(d.value, d.feeWithdraw).amount; const cost = Amounts.add(d.value, d.feeWithdraw).amount;
for (; ;) { for (;;) {
if (Amounts.cmp(remaining, cost) < 0) { if (Amounts.cmp(remaining, cost) < 0) {
break; break;
} }
@ -384,7 +383,6 @@ export async function getBankWithdrawalInfo(
return { return {
amount: Amounts.parseOrThrow(status.amount), amount: Amounts.parseOrThrow(status.amount),
confirmTransferUrl: status.confirm_transfer_url, confirmTransferUrl: status.confirm_transfer_url,
extractedStatusUrl: reqUrl.href,
selectionDone: status.selection_done, selectionDone: status.selection_done,
senderWire: status.sender_wire, senderWire: status.sender_wire,
suggestedExchange: status.suggested_exchange, suggestedExchange: status.suggested_exchange,
@ -898,7 +896,8 @@ export async function updateWithdrawalDenoms(
denom.verificationStatus === DenominationVerificationStatus.Unverified denom.verificationStatus === DenominationVerificationStatus.Unverified
) { ) {
logger.trace( logger.trace(
`Validating denomination (${current + 1}/${denominations.length `Validating denomination (${current + 1}/${
denominations.length
}) signature of ${denom.denomPubHash}`, }) signature of ${denom.denomPubHash}`,
); );
let valid = false; let valid = false;
@ -1025,7 +1024,7 @@ async function queryReserve(
if ( if (
resp.status === 404 && resp.status === 404 &&
result.talerErrorResponse.code === result.talerErrorResponse.code ===
TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN
) { ) {
ws.notify({ ws.notify({
type: NotificationType.ReserveNotYetFound, type: NotificationType.ReserveNotYetFound,
@ -1315,7 +1314,7 @@ export async function getExchangeWithdrawalInfo(
) { ) {
logger.warn( logger.warn(
`wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` + `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
`(exchange has ${exchangeDetails.protocolVersion}), checking for updates`, `(exchange has ${exchangeDetails.protocolVersion}), checking for updates`,
); );
} }
} }
@ -1400,11 +1399,10 @@ export async function getWithdrawalDetailsForUri(
const exchangeRecords = await tx.exchanges.iter().toArray(); const exchangeRecords = await tx.exchanges.iter().toArray();
for (const r of exchangeRecords) { for (const r of exchangeRecords) {
const details = await ws.exchangeOps.getExchangeDetails(tx, r.baseUrl); const details = await ws.exchangeOps.getExchangeDetails(tx, r.baseUrl);
const denominations = await tx.denominations.indexes const denominations = await tx.denominations.indexes.byExchangeBaseUrl
.byExchangeBaseUrl.iter(r.baseUrl).toArray(); .iter(r.baseUrl)
.toArray();
if (details && denominations) { if (details && denominations) {
exchanges.push({ exchanges.push({
exchangeBaseUrl: details.exchangeBaseUrl, exchangeBaseUrl: details.exchangeBaseUrl,
currency: details.currency, currency: details.currency,
@ -1417,7 +1415,7 @@ export async function getWithdrawalDetailsForUri(
paytoUris: details.wireInfo.accounts.map((x) => x.payto_uri), paytoUris: details.wireInfo.accounts.map((x) => x.payto_uri),
auditors: details.auditors, auditors: details.auditors,
wireInfo: details.wireInfo, wireInfo: details.wireInfo,
denominations: denominations denominations: denominations,
}); });
} }
} }
@ -1502,6 +1500,18 @@ export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
); );
} }
export function getBankStatusUrl(talerWithdrawUri: string): string {
const uriResult = parseWithdrawUri(talerWithdrawUri);
if (!uriResult) {
throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
}
const url = new URL(
`withdrawal-operation/${uriResult.withdrawalOperationId}`,
uriResult.bankIntegrationApiBaseUrl,
);
return url.href;
}
async function registerReserveWithBank( async function registerReserveWithBank(
ws: InternalWalletState, ws: InternalWalletState,
withdrawalGroupId: string, withdrawalGroupId: string,
@ -1524,7 +1534,7 @@ async function registerReserveWithBank(
if (!bankInfo) { if (!bankInfo) {
return; return;
} }
const bankStatusUrl = bankInfo.statusUrl; const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
const httpResp = await ws.http.postJson( const httpResp = await ws.http.postJson(
bankStatusUrl, bankStatusUrl,
{ {
@ -1584,10 +1594,12 @@ async function processReserveBankStatus(
default: default:
return; return;
} }
const bankStatusUrl = withdrawalGroup.bankInfo?.statusUrl; if (!withdrawalGroup.bankInfo) {
if (!bankStatusUrl) {
return; return;
} }
const bankStatusUrl = getBankStatusUrl(
withdrawalGroup.bankInfo.talerWithdrawUri,
);
const statusResp = await ws.http.get(bankStatusUrl, { const statusResp = await ws.http.get(bankStatusUrl, {
timeout: getReserveRequestTimeout(withdrawalGroup), timeout: getReserveRequestTimeout(withdrawalGroup),
@ -1778,6 +1790,21 @@ export async function acceptWithdrawalFromUri(
restrictAge?: number; restrictAge?: number;
}, },
): Promise<AcceptWithdrawalResponse> { ): Promise<AcceptWithdrawalResponse> {
const existingWithdrawalGroup = await ws.db
.mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
.runReadOnly(async (tx) => {
return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
req.talerWithdrawUri,
);
});
if (existingWithdrawalGroup) {
return {
reservePub: existingWithdrawalGroup.reservePub,
confirmTransferUrl: existingWithdrawalGroup.bankInfo?.confirmUrl,
};
}
await updateExchangeFromUrl(ws, req.selectedExchange); await updateExchangeFromUrl(ws, req.selectedExchange);
const withdrawInfo = await getBankWithdrawalInfo( const withdrawInfo = await getBankWithdrawalInfo(
ws.http, ws.http,
@ -1796,7 +1823,7 @@ export async function acceptWithdrawalFromUri(
reserveStatus: ReserveRecordStatus.RegisteringBank, reserveStatus: ReserveRecordStatus.RegisteringBank,
bankInfo: { bankInfo: {
exchangePaytoUri, exchangePaytoUri,
statusUrl: withdrawInfo.extractedStatusUrl, talerWithdrawUri: req.talerWithdrawUri,
confirmUrl: withdrawInfo.confirmTransferUrl, confirmUrl: withdrawInfo.confirmTransferUrl,
}, },
}); });

View File

@ -24,7 +24,6 @@
*/ */
import { import {
AbsoluteTime, AbsoluteTime,
AcceptManualWithdrawalResult,
AmountJson, AmountJson,
Amounts, Amounts,
BalancesResponse, BalancesResponse,
@ -98,7 +97,6 @@ import {
CoinSourceType, CoinSourceType,
exportDb, exportDb,
importDb, importDb,
ReserveRecordStatus,
WalletStoresV1, WalletStoresV1,
} from "./db.js"; } from "./db.js";
import { getErrorDetailFromException, TalerError } from "./errors.js"; import { getErrorDetailFromException, TalerError } from "./errors.js";
@ -187,9 +185,8 @@ import {
acceptWithdrawalFromUri, acceptWithdrawalFromUri,
createManualWithdrawal, createManualWithdrawal,
getExchangeWithdrawalInfo, getExchangeWithdrawalInfo,
getFundingPaytoUrisTx,
getWithdrawalDetailsForUri, getWithdrawalDetailsForUri,
processWithdrawalGroup as processWithdrawalGroup, processWithdrawalGroup,
} from "./operations/withdraw.js"; } from "./operations/withdraw.js";
import { import {
PendingOperationsResponse, PendingOperationsResponse,