taler-typescript-core

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

commit 0ea6d7f07beba25ba2f774ec9bc625bb75cff2a9
parent 59606ac65563fe908ac58bc67f518e2869fb1d41
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 19:06:50 +0200

wallet-core: report deposit abort outcomes

Diffstat:
Mpackages/taler-util/src/types-taler-wallet-transactions.ts | 7++++++-
Mpackages/taler-wallet-core/src/db-common.ts | 14++++++++++++++
Mpackages/taler-wallet-core/src/deposits.test.ts | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-wallet-core/src/deposits.ts | 343+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
4 files changed, 427 insertions(+), 36 deletions(-)

diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts @@ -231,6 +231,11 @@ export enum TransactionMinorState { CreatePurse = "create-purse", DeletePurse = "delete-purse", Deposit = "deposit", + DepositAbortPartial = "deposit-abort-partial", + DepositAbortRecovered = "deposit-abort-recovered", + DepositAbortRecoveryFailed = "deposit-abort-recovery-failed", + DepositAbortRefundFailed = "deposit-abort-refund-failed", + DepositAbortTooLate = "deposit-abort-too-late", Exchange = "exchange", ExchangeWaitReserve = "exchange-wait-reserve", KycAuthRequired = "kyc-auth", @@ -380,7 +385,7 @@ export interface KycAuthTransferInfo { /** * Account public key. - * + * * Included in the transfer subject for some of the transfer options. */ accountPub: string; diff --git a/packages/taler-wallet-core/src/db-common.ts b/packages/taler-wallet-core/src/db-common.ts @@ -1988,6 +1988,8 @@ export enum DepositElementStatus { Tracking = 0x0100_0001, KycRequired = 0x0100_0002, Wired = 0x0500_0000, + /** The exchange has already wired the deposit to the target account. */ + RefundTooLate = 0x0500_0001, RefundSuccess = 0x0503_0000, RefundFailed = 0x0501_0000, RefundNotFound = 0x0501_0001, @@ -2184,10 +2186,22 @@ export enum DepositOperationStatus { Finished = 0x0500_0000, + /** The abort lost the race: every selected coin was already wired. */ + FinishedAbortTooLate = 0x0500_0001, + FailedDeposit = 0x0501_0000, FailedTrack = 0x0501_0001, + /** Some selected coins were recovered and others were already wired. */ + FailedAbortPartial = 0x0501_0002, + + /** The abort refund succeeded, but the recovery refresh did not. */ + FailedAbortRecovery = 0x0501_0003, + + /** A permanent refund response did not prove recovery or delivery. */ + FailedAbortRefund = 0x0501_0004, + AbortedDeposit = 0x0503_0000, } diff --git a/packages/taler-wallet-core/src/deposits.test.ts b/packages/taler-wallet-core/src/deposits.test.ts @@ -14,11 +14,24 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. */ -import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { + HttpStatusCode, + TransactionMajorState, + TransactionMinorState, +} from "@gnu-taler/taler-util"; import assert from "node:assert"; import { test } from "node:test"; -import { DepositElementStatus, WalletDepositGroup } from "./db-common.js"; import { + DepositElementStatus, + DepositOperationStatus, + RefreshCoinStatus, + WalletDepositGroup, + WalletRefreshGroup, +} from "./db-common.js"; +import { + classifyDepositAbortOutcome, + computeDepositAbortAmountEffective, + computeDepositTransactionStatus, depositRefundStatusIsRetryable, reconstructDepositRefundRequests, } from "./deposits.js"; @@ -87,3 +100,85 @@ test("deposit abort reconstructs 404 refund handoffs after restart", async () => ]); assert.strictEqual(signed.length, 1); }); + +test("deposit abort distinguishes delivery, recovery, and unknown failures", () => { + assert.strictEqual( + classifyDepositAbortOutcome([DepositElementStatus.RefundTooLate]), + "too-late", + ); + assert.strictEqual( + classifyDepositAbortOutcome( + [DepositElementStatus.RefundSuccess], + "recovered", + ), + "recovered", + ); + assert.strictEqual( + classifyDepositAbortOutcome( + [DepositElementStatus.RefundSuccess, DepositElementStatus.RefundTooLate], + "recovered", + ), + "partial", + ); + assert.strictEqual( + classifyDepositAbortOutcome( + [DepositElementStatus.RefundNotFound], + "failed", + ), + "recovery-failed", + ); + assert.strictEqual( + classifyDepositAbortOutcome( + [DepositElementStatus.RefundSuccess, DepositElementStatus.RefundFailed], + "recovered", + ), + "refund-failed", + ); +}); + +test("deposit abort effective amount credits only completed recovery", () => { + const refreshGroup = { + expectedOutputPerCoin: ["TESTKUDOS:4", "TESTKUDOS:3"], + statusPerCoin: [RefreshCoinStatus.Finished, RefreshCoinStatus.Failed], + } as WalletRefreshGroup; + + assert.strictEqual( + computeDepositAbortAmountEffective("TESTKUDOS:10", [refreshGroup]), + "TESTKUDOS:6", + ); + assert.strictEqual( + computeDepositAbortAmountEffective("TESTKUDOS:10", []), + "TESTKUDOS:10", + ); +}); + +test("deposit abort terminal states expose precise transaction minors", () => { + const stateFor = (operationStatus: DepositOperationStatus) => + computeDepositTransactionStatus({ + operationStatus, + } as WalletDepositGroup); + + assert.deepStrictEqual( + stateFor(DepositOperationStatus.FinishedAbortTooLate), + { + major: TransactionMajorState.Done, + minor: TransactionMinorState.DepositAbortTooLate, + }, + ); + assert.deepStrictEqual(stateFor(DepositOperationStatus.AbortedDeposit), { + major: TransactionMajorState.Aborted, + minor: TransactionMinorState.DepositAbortRecovered, + }); + assert.deepStrictEqual(stateFor(DepositOperationStatus.FailedAbortPartial), { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.DepositAbortPartial, + }); + assert.deepStrictEqual(stateFor(DepositOperationStatus.FailedAbortRecovery), { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.DepositAbortRecoveryFailed, + }); + assert.deepStrictEqual(stateFor(DepositOperationStatus.FailedAbortRefund), { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.DepositAbortRefundFailed, + }); +}); diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -100,10 +100,12 @@ import { DepositElementStatus, DepositOperationStatus, KycAuthTransferOptionRaw, + RefreshCoinStatus, RefreshOperationStatus, WalletDepositGroup, WalletDepositInfoPerExchange, WalletDepositTrackingInfo, + WalletRefreshGroup, WalletWithdrawalGroup, WithdrawalRecordType, timestampAbsoluteFromDb, @@ -361,6 +363,47 @@ export class DepositTransactionContext implements TransactionContext { } const txState = computeDepositTransactionStatus(dg); + let amountEffective: AmountString; + if (isDepositAbortState(dg.operationStatus) || dg.abortRefreshGroupId) { + const selectedCoins = dg.payCoinSelection + ? await tx.getCoinsByPubs(dg.payCoinSelection.coinPubs) + : []; + const denoms = await getDenomInfos(this.wex, tx, selectedCoins); + const selectedCoinValues = selectedCoins.map((coin) => + denoms.get(denomRefKey(coin)), + ); + const haveGrossInput = + dg.payCoinSelection !== undefined && + selectedCoins.length === dg.payCoinSelection.coinPubs.length && + selectedCoinValues.every((denom) => denom !== undefined); + if (haveGrossInput) { + const grossCoinInput = Amounts.sumOrZero( + dg.currency, + selectedCoinValues.map((denom) => denom!.value), + ).amount; + const relatedRefreshes = + await tx.getRefreshGroupsByOriginatingTransaction(this.transactionId); + amountEffective = computeDepositAbortAmountEffective( + Amounts.stringify(grossCoinInput), + relatedRefreshes, + ); + } else { + // Old or damaged records can lack the selected coins/denominations. + // Fall back to the historical estimate, crediting only the dedicated + // abort refresh rather than inventing unavailable change outputs. + const recoveryRefresh = dg.abortRefreshGroupId + ? await tx.getRefreshGroup(dg.abortRefreshGroupId) + : undefined; + amountEffective = computeDepositAbortAmountEffective( + dg.totalPayCost, + recoveryRefresh ? [recoveryRefresh] : [], + ); + } + } else { + amountEffective = isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(dg.totalPayCost)) + : Amounts.stringify(dg.totalPayCost); + } return { type: TransactionType.Deposit, txState, @@ -371,9 +414,7 @@ export class DepositTransactionContext implements TransactionContext { ), txActions: computeDepositTransactionActions(dg), amountRaw: Amounts.stringify(dg.counterpartyEffectiveDepositAmount), - amountEffective: isUnsuccessfulTransaction(txState) - ? Amounts.stringify(Amounts.zeroOfAmount(dg.totalPayCost)) - : Amounts.stringify(dg.totalPayCost), + amountEffective, timestamp: timestampPreciseFromDb(dg.timestampCreated), targetPaytoUri: dg.wire.payto_uri, wireTransferDeadline: timestampProtocolFromDb(dg.wireTransferDeadline), @@ -460,6 +501,10 @@ export class DepositTransactionContext implements TransactionContext { let newOpStatus: DepositOperationStatus | undefined; switch (dg.operationStatus) { case DepositOperationStatus.AbortedDeposit: + case DepositOperationStatus.FinishedAbortTooLate: + case DepositOperationStatus.FailedAbortPartial: + case DepositOperationStatus.FailedAbortRecovery: + case DepositOperationStatus.FailedAbortRefund: case DepositOperationStatus.FailedDeposit: case DepositOperationStatus.FailedTrack: case DepositOperationStatus.Finished: @@ -542,6 +587,10 @@ export class DepositTransactionContext implements TransactionContext { case DepositOperationStatus.LegacyPendingTrack: case DepositOperationStatus.LegacySuspendedTrack: case DepositOperationStatus.AbortedDeposit: + case DepositOperationStatus.FinishedAbortTooLate: + case DepositOperationStatus.FailedAbortPartial: + case DepositOperationStatus.FailedAbortRecovery: + case DepositOperationStatus.FailedAbortRefund: case DepositOperationStatus.Aborting: case DepositOperationStatus.FailedDeposit: case DepositOperationStatus.FailedTrack: @@ -573,6 +622,10 @@ export class DepositTransactionContext implements TransactionContext { let newOpStatus: DepositOperationStatus | undefined; switch (dg.operationStatus) { case DepositOperationStatus.AbortedDeposit: + case DepositOperationStatus.FinishedAbortTooLate: + case DepositOperationStatus.FailedAbortPartial: + case DepositOperationStatus.FailedAbortRecovery: + case DepositOperationStatus.FailedAbortRefund: case DepositOperationStatus.Aborting: case DepositOperationStatus.FailedDeposit: case DepositOperationStatus.FailedTrack: @@ -641,10 +694,15 @@ export class DepositTransactionContext implements TransactionContext { let newState: DepositOperationStatus; switch (dg.operationStatus) { case DepositOperationStatus.PendingAggregateKyc: - case DepositOperationStatus.SuspendedAggregateKyc: + case DepositOperationStatus.SuspendedAggregateKyc: { + newState = DepositOperationStatus.FailedDeposit; + break; + } case DepositOperationStatus.SuspendedAborting: case DepositOperationStatus.Aborting: { - newState = DepositOperationStatus.FailedDeposit; + newState = dg.abortRefreshGroupId + ? DepositOperationStatus.FailedAbortRecovery + : DepositOperationStatus.FailedAbortRefund; break; } case DepositOperationStatus.LegacyPendingTrack: @@ -653,6 +711,10 @@ export class DepositTransactionContext implements TransactionContext { break; } case DepositOperationStatus.AbortedDeposit: + case DepositOperationStatus.FinishedAbortTooLate: + case DepositOperationStatus.FailedAbortPartial: + case DepositOperationStatus.FailedAbortRecovery: + case DepositOperationStatus.FailedAbortRefund: case DepositOperationStatus.FailedDeposit: case DepositOperationStatus.FailedTrack: case DepositOperationStatus.Finished: @@ -697,6 +759,11 @@ export function computeDepositTransactionStatus( return { major: TransactionMajorState.Done, }; + case DepositOperationStatus.FinishedAbortTooLate: + return { + major: TransactionMajorState.Done, + minor: TransactionMinorState.DepositAbortTooLate, + }; case DepositOperationStatus.PendingDeposit: return { major: TransactionMajorState.Pending, @@ -749,6 +816,22 @@ export function computeDepositTransactionStatus( case DepositOperationStatus.AbortedDeposit: return { major: TransactionMajorState.Aborted, + minor: TransactionMinorState.DepositAbortRecovered, + }; + case DepositOperationStatus.FailedAbortPartial: + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.DepositAbortPartial, + }; + case DepositOperationStatus.FailedAbortRecovery: + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.DepositAbortRecoveryFailed, + }; + case DepositOperationStatus.FailedAbortRefund: + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.DepositAbortRefundFailed, }; case DepositOperationStatus.FailedDeposit: return { @@ -828,6 +911,7 @@ export function computeDepositTransactionActions( ): TransactionAction[] { switch (dg.operationStatus) { case DepositOperationStatus.Finished: + case DepositOperationStatus.FinishedAbortTooLate: return [TransactionAction.Delete]; case DepositOperationStatus.PendingDeposit: return [ @@ -845,6 +929,9 @@ export function computeDepositTransactionActions( ]; case DepositOperationStatus.AbortedDeposit: return [TransactionAction.Delete]; + case DepositOperationStatus.FailedAbortPartial: + case DepositOperationStatus.FailedAbortRecovery: + case DepositOperationStatus.FailedAbortRefund: case DepositOperationStatus.FailedDeposit: case DepositOperationStatus.FailedTrack: return [TransactionAction.Delete]; @@ -883,6 +970,191 @@ export function computeDepositTransactionActions( } } +export type DepositAbortOutcome = + | "refunding" + | "recovering" + | "recovered" + | "too-late" + | "partial" + | "refund-failed" + | "recovery-failed"; + +function isDepositAbortState(status: DepositOperationStatus): boolean { + switch (status) { + case DepositOperationStatus.Aborting: + case DepositOperationStatus.SuspendedAborting: + case DepositOperationStatus.AbortedDeposit: + case DepositOperationStatus.FinishedAbortTooLate: + case DepositOperationStatus.FailedAbortPartial: + case DepositOperationStatus.FailedAbortRecovery: + case DepositOperationStatus.FailedAbortRefund: + return true; + default: + return false; + } +} + +/** + * Classify the financial result of a deposit abort. + * + * A successful refund and a 404 both still require refresh before the wallet + * has recovered value. A 410 is different: it proves that the exchange has + * already wired the deposit. Other permanent refund failures prove neither + * recovery nor delivery and therefore get their own outcome. + */ +export function classifyDepositAbortOutcome( + statusPerCoin: DepositElementStatus[], + recovery?: "pending" | "recovered" | "failed", +): DepositAbortOutcome { + let hasRecoverable = false; + let hasTooLate = false; + let hasRefundFailure = false; + for (const status of statusPerCoin) { + switch (status) { + case DepositElementStatus.RefundSuccess: + case DepositElementStatus.RefundNotFound: + hasRecoverable = true; + break; + case DepositElementStatus.RefundTooLate: + hasTooLate = true; + break; + case DepositElementStatus.RefundFailed: + hasRefundFailure = true; + break; + default: + return "refunding"; + } + } + if (hasRecoverable) { + if (recovery === undefined || recovery === "pending") { + return "recovering"; + } + if (recovery === "failed") { + return "recovery-failed"; + } + } + if (hasRefundFailure) { + return "refund-failed"; + } + if (hasTooLate) { + return hasRecoverable ? "partial" : "too-late"; + } + return "recovered"; +} + +/** + * Compute the net wallet loss of an abort from value that was actually + * recovered. This includes both the change refresh created by the original + * deposit and the later abort refresh. Finished refresh elements have minted + * their expected output; pending and failed elements are deliberately not + * credited here. + */ +export function computeDepositAbortAmountEffective( + grossCoinInput: AmountString, + relatedRefreshes: WalletRefreshGroup[], +): AmountString { + const recoveredOutputs = relatedRefreshes.flatMap((refreshGroup) => + refreshGroup.expectedOutputPerCoin.filter( + (_amount, index) => + refreshGroup.statusPerCoin[index] === RefreshCoinStatus.Finished, + ), + ); + const recoveredAmount = Amounts.sumOrZero( + Amounts.currencyOf(grossCoinInput), + recoveredOutputs, + ).amount; + return Amounts.stringify(Amounts.sub(grossCoinInput, recoveredAmount).amount); +} + +function abortOutcomeStatus( + outcome: DepositAbortOutcome, +): DepositOperationStatus { + switch (outcome) { + case "recovered": + return DepositOperationStatus.AbortedDeposit; + case "too-late": + return DepositOperationStatus.FinishedAbortTooLate; + case "partial": + return DepositOperationStatus.FailedAbortPartial; + case "recovery-failed": + return DepositOperationStatus.FailedAbortRecovery; + case "refund-failed": + return DepositOperationStatus.FailedAbortRefund; + case "refunding": + case "recovering": + throw Error(`deposit abort outcome ${outcome} is not final`); + default: + assertUnreachable(outcome); + } +} + +function describeDepositAbortFailure( + outcome: DepositAbortOutcome, + statusPerCoin: DepositElementStatus[], + refreshGroupId?: string, +): TalerErrorDetail | undefined { + const recovered = statusPerCoin.filter( + (x) => + x === DepositElementStatus.RefundSuccess || + x === DepositElementStatus.RefundNotFound, + ).length; + const delivered = statusPerCoin.filter( + (x) => x === DepositElementStatus.RefundTooLate, + ).length; + const unresolved = statusPerCoin.filter( + (x) => x === DepositElementStatus.RefundFailed, + ).length; + let message: string | undefined; + switch (outcome) { + case "partial": + message = + `deposit abort recovered ${recovered} coin(s), but ` + + `${delivered} coin(s) had already been wired`; + break; + case "recovery-failed": + message = + `deposit abort recovery refresh ${refreshGroupId ?? "<missing>"} ` + + `failed or disappeared; ${delivered} coin(s) were already wired and ` + + `${unresolved} refund outcome(s) remain inconclusive`; + break; + case "refund-failed": + message = + `deposit abort received a permanent, inconclusive refund failure ` + + `for ${unresolved} coin(s); ${delivered} coin(s) were already wired`; + break; + case "refunding": + case "recovering": + case "recovered": + case "too-late": + return undefined; + default: + assertUnreachable(outcome); + } + return makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + {}, + message, + ); +} + +async function finalizeDepositAbort( + h: RecordHandle<WalletDepositGroup>, + depositGroup: WalletDepositGroup, + outcome: DepositAbortOutcome, + cause: string, +): Promise<void> { + depositGroup.operationStatus = abortOutcomeStatus(outcome); + depositGroup.timestampFinished = timestampPreciseToDb( + TalerPreciseTimestamp.now(), + ); + depositGroup.failReason = describeDepositAbortFailure( + outcome, + depositGroup.statusPerCoin ?? [], + depositGroup.abortRefreshGroupId, + ); + await h.update(depositGroup, cause); +} + async function makeDepositRefundRequest( wex: WalletExecutionContext, depositGroup: WalletDepositGroup, @@ -978,6 +1250,7 @@ async function refundDepositGroup( switch (st) { case DepositElementStatus.RefundFailed: case DepositElementStatus.RefundSuccess: + case DepositElementStatus.RefundTooLate: break; case DepositElementStatus.RefundNotFound: { break; @@ -1013,6 +1286,11 @@ async function refundDepositGroup( // so the subsequent refresh request might fail. newStatus = DepositElementStatus.RefundNotFound; refundReqPerCoin[i] = refundReq; + } else if (refundResp.case === HttpStatusCode.Gone) { + // The exchange explicitly confirms that aggregation already won + // the race. This value was delivered and must not be sent to a + // refresh that can no longer recover it. + newStatus = DepositElementStatus.RefundTooLate; } else if (depositRefundStatusIsRetryable(refundResp.case)) { return TaskRunResult.backoff(); } else { @@ -1036,33 +1314,33 @@ async function refundDepositGroup( // Check if we are done trying to refund. const res = await wex.runWalletDbTx(async (tx) => { - const newDg = await tx.getDepositGroup(depositGroup.depositGroupId); + const [newDg, h] = await ctx.getRecordHandle(tx); if (!newDg || !newDg.statusPerCoin) { return; } - let refundsAllDone = true; - for (let i = 0; i < newTxPerCoin.length; i++) { - switch (newTxPerCoin[i]) { - case DepositElementStatus.RefundFailed: - case DepositElementStatus.RefundNotFound: - case DepositElementStatus.RefundSuccess: - break; - default: - refundsAllDone = false; - } - } - if (!refundsAllDone) { + newTxPerCoin = [...newDg.statusPerCoin]; + const outcome = classifyDepositAbortOutcome(newTxPerCoin); + if (outcome === "refunding") { return; } - newTxPerCoin = [...newDg.statusPerCoin]; const refreshCoins: CoinRefreshRequest[] = []; for (let i = 0; i < newTxPerCoin.length; i++) { + if ( + newTxPerCoin[i] !== DepositElementStatus.RefundSuccess && + newTxPerCoin[i] !== DepositElementStatus.RefundNotFound + ) { + continue; + } refreshCoins.push({ amount: payCoinSelection.coinContributions[i], coinPub: payCoinSelection.coinPubs[i], refundRequest: refundReqPerCoin[i], }); } + if (refreshCoins.length === 0) { + await finalizeDepositAbort(h, newDg, outcome, "refund-final"); + return { abortFinalized: true }; + } const refreshRes = await createRefreshGroup( wex, tx, @@ -1075,12 +1353,11 @@ async function refundDepositGroup( }), ); newDg.abortRefreshGroupId = refreshRes.refreshGroupId; - await tx.upsertDepositGroup(newDg); - await ctx.updateTransactionMeta(tx); + await h.update(newDg, "refund-refresh"); return { refreshRes }; }); - if (res?.refreshRes) { + if (res?.refreshRes || res?.abortFinalized) { return TaskRunResult.progress(); } @@ -1141,18 +1418,18 @@ async function waitForRefreshOnDepositGroup( if (!newDg) { return false; } - if (recovery === "recovered") { - newDg.operationStatus = DepositOperationStatus.AbortedDeposit; - await h.update(newDg, "refresh-done"); - } else { - newDg.operationStatus = DepositOperationStatus.FailedDeposit; - newDg.failReason = makeErrorDetail( - TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, - {}, - `abort recovery refresh ${abortRefreshGroupId} failed or disappeared`, - ); - await h.update(newDg, "refresh-failed"); + const outcome = newDg.statusPerCoin + ? classifyDepositAbortOutcome(newDg.statusPerCoin, recovery) + : "recovery-failed"; + if (outcome === "refunding" || outcome === "recovering") { + return false; } + await finalizeDepositAbort( + h, + newDg, + outcome, + recovery === "recovered" ? "refresh-done" : "refresh-failed", + ); return true; }); if (didTransition) {