taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 4e4ee87b17b693437e9d11ce19f7329ceab8ad19
parent 555057c2425142c349c90ed58bb8983abc4fda91
Author: Florian Dold <florian@dold.me>
Date:   Sun, 23 Feb 2025 14:39:04 +0100

wallet-core: implement support for refunds inside refresh group

Diffstat:
Mpackages/taler-util/src/types-taler-wallet.ts | 2++
Mpackages/taler-wallet-core/src/db.ts | 8++++++++
Mpackages/taler-wallet-core/src/deposits.ts | 24+++++++++++++++++++-----
Mpackages/taler-wallet-core/src/dev-experiments.ts | 1+
Mpackages/taler-wallet-core/src/refresh.ts | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 83 insertions(+), 5 deletions(-)

diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -77,6 +77,7 @@ import { DenomKeyType, DenominationPubKey, ExchangeAuditor, + ExchangeRefundRequest, ExchangeWireAccount, PeerContractTerms, UnblindedSignature, @@ -1131,6 +1132,7 @@ export enum RefreshReason { export interface CoinRefreshRequest { readonly coinPub: string; readonly amount: AmountString; + readonly refundRequest?: ExchangeRefundRequest; } /** diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -50,6 +50,7 @@ import { EddsaSignatureString, ExchangeAuditor, ExchangeGlobalFees, + ExchangeRefundRequest, HashCodeString, Logger, RefreshReason, @@ -1020,6 +1021,7 @@ export enum DepositElementStatus { Wired = 0x0500_0000, RefundSuccess = 0x0503_0000, RefundFailed = 0x0501_0000, + RefundNotFound = 0x0501_0001, } export interface RefreshGroupPerExchangeInfo { @@ -1071,6 +1073,12 @@ export interface RefreshGroupRecord { */ statusPerCoin: RefreshCoinStatus[]; + /** + * Refund requests that might still be necessary + * before the refresh can work. + */ + refundRequests: { [n: number]: ExchangeRefundRequest}; + timestampCreated: DbPreciseTimestamp; failReason?: TalerErrorDetail; diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -719,19 +719,28 @@ async function refundDepositGroup( wex: WalletExecutionContext, depositGroup: DepositGroupRecord, ): Promise<TaskRunResult> { + const ctx = new DepositTransactionContext(wex, depositGroup.depositGroupId); + const currency = Amounts.currencyOf(depositGroup.totalPayCost); const statusPerCoin = depositGroup.statusPerCoin; const payCoinSelection = depositGroup.payCoinSelection; + if (!statusPerCoin) { throw Error( "unable to refund deposit group without coin selection (status missing)", ); } + if (!payCoinSelection) { throw Error( "unable to refund deposit group without coin selection (selection missing)", ); } const newTxPerCoin = [...statusPerCoin]; + // Refunds that might need to be handed off to the refresh, + // as we don't know if deposit request will still arrive + // before doing the refresh. + const refundReqPerCoin: ExchangeRefundRequest[] = Array(newTxPerCoin.length); + logger.info(`status per coin: ${j2s(depositGroup.statusPerCoin)}`); for (let i = 0; i < statusPerCoin.length; i++) { const st = statusPerCoin[i]; @@ -751,7 +760,7 @@ async function refundDepositGroup( ); const refundAmount = payCoinSelection.coinContributions[i]; // We use a constant refund transaction ID, since there can - // only be one refund. + // only be one refund for this contract. const rtid = 1; const sig = await wex.cryptoApi.signRefund({ coinPub, @@ -781,6 +790,13 @@ async function refundDepositGroup( if (httpResp.status === 200) { // FIXME: validate response newStatus = DepositElementStatus.RefundSuccess; + } else if (httpResp.status == 404) { + // Exchange doesn't know about the deposit. + // It's possible that we already sent out the + // deposit request, but it didn't arrive yet, + // so the subsequent refresh request might fail. + newStatus = DepositElementStatus.RefundNotFound; + refundReqPerCoin[i] = refundReq; } else { // FIXME: Store problem somewhere! newStatus = DepositElementStatus.RefundFailed; @@ -791,6 +807,7 @@ async function refundDepositGroup( } } } + let isDone = true; for (let i = 0; i < newTxPerCoin.length; i++) { if ( @@ -801,10 +818,6 @@ async function refundDepositGroup( } } - const currency = Amounts.currencyOf(depositGroup.totalPayCost); - - const ctx = new DepositTransactionContext(wex, depositGroup.depositGroupId); - const res = await wex.db.runReadWriteTx( { storeNames: [ @@ -829,6 +842,7 @@ async function refundDepositGroup( refreshCoins.push({ amount: payCoinSelection.coinContributions[i], coinPub: payCoinSelection.coinPubs[i], + refundRequest: refundReqPerCoin[i], }); } let refreshRes: CreateRefreshGroupResult | undefined = undefined; diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts @@ -97,6 +97,7 @@ export async function applyDevExperiment( timestampFinished: undefined, originatingTransactionId: undefined, infoPerExchange: {}, + refundRequests: {}, }; await tx.refreshGroups.put(newRg); const ctx = new RefreshTransactionContext(wex, refreshGroupId); diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -43,6 +43,7 @@ import { ExchangeMeltRequest, ExchangeProtocolVersion, ExchangeRefreshRevealRequest, + ExchangeRefundRequest, fnutil, ForceRefreshRequest, getErrorDetailFromException, @@ -776,6 +777,7 @@ async function refreshMelt( const errDetail = await readTalerErrorResponse(resp); await handleRefreshMeltConflict( ctx, + refreshGroup, coinIndex, errDetail, derived, @@ -873,6 +875,7 @@ async function handleRefreshMeltGone( async function handleRefreshMeltConflict( ctx: RefreshTransactionContext, + refreshGroup: RefreshGroupRecord, coinIndex: number, errDetails: TalerErrorDetail, derived: DerivedRefreshSession, @@ -886,6 +889,46 @@ async function handleRefreshMeltConflict( )} failed in refresh group ${ctx.refreshGroupId} due to conflict`, ); + const refundReq = refreshGroup.refundRequests[coinIndex]; + if (refundReq != null) { + const refundUrl = new URL( + `coins/${oldCoin.coinPub}/refund`, + oldCoin.exchangeBaseUrl, + ); + logger.trace(`Doing deposit in refresh for coin ${coinIndex}`); + const httpResp = await ctx.wex.http.fetch(refundUrl.href, { + method: "POST", + body: refundReq, + cancellationToken: ctx.wex.cancellationToken, + }); + switch (httpResp.status) { + case HttpStatusCode.Ok: + await ctx.wex.db.runReadWriteTx( + { + storeNames: ["refreshGroups"], + }, + async (tx) => { + const rg = await tx.refreshGroups.get(refreshGroup.refreshGroupId); + if (!rg || rg.operationStatus != RefreshOperationStatus.Pending) { + return; + } + delete rg.refundRequests[coinIndex]; + await tx.refreshGroups.put(rg); + }, + ); + break; + default: + // FIXME: Store the error somewhere in the DB? + logger.warn( + `Refund request during refresh failed: ${j2s( + readTalerErrorResponse(httpResp), + )}`, + ); + break; + } + return; + } + const historySig = await ctx.wex.cryptoApi.signCoinHistoryRequest({ coinPriv: oldCoin.coinPriv, coinPub: oldCoin.coinPub, @@ -1776,6 +1819,15 @@ export async function createRefreshGroup( await applyRefreshToOldCoins(wex, tx, oldCoinPubs, refreshGroupId); + const refundRequests: { [n: number]: ExchangeRefundRequest } = {}; + + for (let i = 0; i < oldCoinPubs.length; i++) { + const req = oldCoinPubs[i].refundRequest; + if (req != null) { + refundRequests[i] = req; + } + } + const refreshGroup: RefreshGroupRecord = { operationStatus: RefreshOperationStatus.Pending, currency, @@ -1789,6 +1841,7 @@ export async function createRefreshGroup( expectedOutputPerCoin: estimatedOutputPerCoin.map((x) => Amounts.stringify(x), ), + refundRequests, infoPerExchange: outInfo.perExchangeInfo, timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), };