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:
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()),
};