taler-typescript-core

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

commit cd9f1a96af8a62aad632637e8dd386fa7566e1b0
parent aea675863a48d9bacca475820959cc5ec9ce3b95
Author: Florian Dold <florian@dold.me>
Date:   Fri, 13 Mar 2026 11:18:21 +0100

wallet-core: fix peer-push-credit transaction from kyc state when merge has conflict

Diffstat:
Mpackages/taler-wallet-core/src/common.ts | 4++--
Mpackages/taler-wallet-core/src/db.ts | 6+++---
Mpackages/taler-wallet-core/src/pay-peer-push-credit.ts | 226+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
3 files changed, 134 insertions(+), 102 deletions(-)

diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -60,7 +60,7 @@ import { PeerPullCreditRecord, PeerPullPaymentIncomingRecord, PeerPushDebitRecord, - PeerPushPaymentIncomingRecord, + PeerPushCreditRecord, PurchaseRecord, RecoupGroupRecord, RefreshGroupRecord, @@ -831,7 +831,7 @@ export namespace TaskIdentifiers { return `${PendingTaskType.PeerPullDebit}:${ppi.peerPullDebitId}` as TaskIdStr; } export function forPeerPushCredit( - ppi: PeerPushPaymentIncomingRecord, + ppi: PeerPushCreditRecord, ): TaskIdStr { return `${PendingTaskType.PeerPushCredit}:${ppi.peerPushCreditId}` as TaskIdStr; } diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -84,7 +84,6 @@ import { hash, j2s, stringToBytes, - stringifyScopeInfo, } from "@gnu-taler/taler-util"; import { DbRetryInfo, TaskIdentifiers } from "./common.js"; import { @@ -2440,6 +2439,7 @@ export enum PeerPushCreditStatus { Done = 0x0500_0000, Aborted = 0x0503_0000, Failed = 0x0501_0000, + Expired = 0x0502_0000, } /** @@ -2447,7 +2447,7 @@ export enum PeerPushCreditStatus { * * Unique: (exchangeBaseUrl, pursePub) */ -export interface PeerPushPaymentIncomingRecord { +export interface PeerPushCreditRecord { peerPushCreditId: string; exchangeBaseUrl: string; @@ -3496,7 +3496,7 @@ export const WalletStoresV1 = { ), peerPushCredit: describeStore( "peerPushCredit", - describeContents<PeerPushPaymentIncomingRecord>({ + describeContents<PeerPushCreditRecord>({ keyPath: "peerPushCreditId", }), { diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -66,8 +66,8 @@ import { } from "./common.js"; import { OperationRetryRecord, + PeerPushCreditRecord, PeerPushCreditStatus, - PeerPushPaymentIncomingRecord, WalletDbAllStoresReadOnlyTransaction, WalletDbReadWriteTransaction, WithdrawalGroupRecord, @@ -95,7 +95,6 @@ import { getMergeReserveInfo, isPurseMerged } from "./pay-peer-common.js"; import { constructTransactionIdentifier, isUnsuccessfulTransaction, - ParsedTransactionIdentifier, parseTransactionIdentifier, } from "./transactions.js"; import { WalletExecutionContext, walletExchangeClient } from "./wallet.js"; @@ -263,12 +262,9 @@ export class PeerPushCreditTransactionContext implements TransactionContext { async getRecordHandle( tx: WalletDbReadWriteTransaction<["peerPushCredit", "transactionsMeta"]>, ): Promise< - [ - PeerPushPaymentIncomingRecord | undefined, - RecordHandle<PeerPushPaymentIncomingRecord>, - ] + [PeerPushCreditRecord | undefined, RecordHandle<PeerPushCreditRecord>] > { - return getGenericRecordHandle<PeerPushPaymentIncomingRecord>( + return getGenericRecordHandle<PeerPushCreditRecord>( this, tx as any, async () => tx.peerPushCredit.get(this.peerPushCreditId), @@ -283,7 +279,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext { } async deleteTransaction(): Promise<void> { - const res = await this.wex.db.runReadWriteTx( + await this.wex.db.runReadWriteTx( { storeNames: [ "withdrawalGroups", @@ -339,6 +335,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext { case PeerPushCreditStatus.SuspendedBalanceKycInit: case PeerPushCreditStatus.Failed: case PeerPushCreditStatus.Aborted: + case PeerPushCreditStatus.Expired: return; case PeerPushCreditStatus.PendingBalanceKycRequired: rec.status = PeerPushCreditStatus.SuspendedBalanceKycRequired; @@ -386,6 +383,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext { case PeerPushCreditStatus.PendingMerge: case PeerPushCreditStatus.PendingBalanceKycInit: case PeerPushCreditStatus.SuspendedBalanceKycInit: + case PeerPushCreditStatus.Expired: rec.status = PeerPushCreditStatus.Aborted; break; default: @@ -412,6 +410,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext { case PeerPushCreditStatus.Done: case PeerPushCreditStatus.Aborted: case PeerPushCreditStatus.Failed: + case PeerPushCreditStatus.Expired: return; case PeerPushCreditStatus.SuspendedMerge: rec.status = PeerPushCreditStatus.PendingMerge; @@ -447,6 +446,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext { case PeerPushCreditStatus.Done: case PeerPushCreditStatus.Aborted: case PeerPushCreditStatus.Failed: + case PeerPushCreditStatus.Expired: // Already in a final state. return; case PeerPushCreditStatus.DialogProposed: @@ -500,12 +500,13 @@ export async function preparePeerPushCredit( const existing = await wex.db.runReadOnlyTx( { storeNames: ["contractTerms", "peerPushCredit"] }, async (tx) => { - let existingPushInc: PeerPushPaymentIncomingRecord | undefined; + let existingPushInc: PeerPushCreditRecord | undefined; if (uri) { - existingPushInc = await tx.peerPushCredit.indexes.byExchangeAndContractPriv.get([ - uri.exchangeBaseUrl, - uri.contractPriv, - ]); + existingPushInc = + await tx.peerPushCredit.indexes.byExchangeAndContractPriv.get([ + uri.exchangeBaseUrl, + uri.contractPriv, + ]); } else if (parsedTxId) { existingPushInc = await tx.peerPushCredit.get(parsedTxId); } @@ -528,7 +529,10 @@ export async function preparePeerPushCredit( ); if (existing) { - const exchange = await fetchFreshExchange(wex, existing.existingPushInc.exchangeBaseUrl); + const exchange = await fetchFreshExchange( + wex, + existing.existingPushInc.exchangeBaseUrl, + ); const currency = Amounts.currencyOf(existing.existingContractTerms.amount); const exchangeBaseUrl = existing.existingPushInc.exchangeBaseUrl; const scopeInfo = await wex.db.runAllStoresReadOnlyTx( @@ -639,7 +643,7 @@ export async function preparePeerPushCredit( if (rec) { throw Error("record already exists"); } - const newRec: PeerPushPaymentIncomingRecord = { + const newRec: PeerPushCreditRecord = { peerPushCreditId, contractPriv: contractPriv, exchangeBaseUrl: exchangeBaseUrl, @@ -676,7 +680,7 @@ export async function preparePeerPushCredit( async function processPeerPushDebitMergeKyc( wex: WalletExecutionContext, - peerInc: PeerPushPaymentIncomingRecord, + peerInc: PeerPushCreditRecord, contractTerms: PeerContractTerms, ): Promise<TaskRunResult> { const ctx = new PeerPushCreditTransactionContext( @@ -745,7 +749,7 @@ async function processPeerPushDebitMergeKyc( async function transitionPeerPushCreditKycRequired( wex: WalletExecutionContext, - peerInc: PeerPushPaymentIncomingRecord, + peerInc: PeerPushCreditRecord, kycPending: LegitimizationNeededResponse, ): Promise<TaskRunResult> { const ctx = new PeerPushCreditTransactionContext( @@ -771,7 +775,7 @@ async function transitionPeerPushCreditKycRequired( async function processPendingMerge( wex: WalletExecutionContext, - peerInc: PeerPushPaymentIncomingRecord, + peerInc: PeerPushCreditRecord, contractTerms: PeerContractTerms, ): Promise<TaskRunResult> { const { peerPushCreditId } = peerInc; @@ -795,6 +799,7 @@ async function processPendingMerge( return; } switch (rec.status) { + case PeerPushCreditStatus.PendingMergeKycRequired: case PeerPushCreditStatus.PendingMerge: { rec.status = PeerPushCreditStatus.PendingBalanceKycInit; break; @@ -847,13 +852,12 @@ async function processPendingMerge( reserve_sig: sigRes.accountSig, }; + logger.trace(`merge request: ${j2s(mergeReq)}`); const mergeResp = await exchangeClient.postPurseMerge( peerInc.pursePub, mergeReq, ); - logger.trace(`merge request: ${j2s(mergeReq)}`); - switch (mergeResp.case) { case "ok": logger.trace(`merge response: ${j2s(mergeResp.body)}`); @@ -868,15 +872,16 @@ async function processPendingMerge( ); case HttpStatusCode.Conflict: // FIXME: Check signature. - // FIXME: status completed by other await ctx.wex.db.runAllStoresReadWriteTx({}, async (tx) => { const [rec, h] = await ctx.getRecordHandle(tx); if (!rec) { return; } switch (rec.status) { + case PeerPushCreditStatus.PendingMergeKycRequired: case PeerPushCreditStatus.PendingMerge: { - rec.status = PeerPushCreditStatus.Aborted; + // FIXME: reason / minor state "completed by other"? + rec.status = PeerPushCreditStatus.Failed; break; } default: @@ -886,8 +891,22 @@ async function processPendingMerge( }); return TaskRunResult.finished(); case HttpStatusCode.Gone: - // FIXME: status expired - await ctx.abortTransaction(); + await ctx.wex.db.runAllStoresReadWriteTx({}, async (tx) => { + const [rec, h] = await ctx.getRecordHandle(tx); + if (!rec) { + return; + } + switch (rec.status) { + case PeerPushCreditStatus.PendingMergeKycRequired: + case PeerPushCreditStatus.PendingMerge: { + rec.status = PeerPushCreditStatus.Expired; + break; + } + default: + return; + } + await h.update(rec); + }); return TaskRunResult.finished(); case HttpStatusCode.Forbidden: case HttpStatusCode.NotFound: @@ -911,48 +930,34 @@ async function processPendingMerge( }, }); - await wex.db.runReadWriteTx( - { - storeNames: [ - "contractTerms", - "peerPushCredit", - "withdrawalGroups", - "reserves", - "exchanges", - "exchangeDetails", - "transactionsMeta", - ], - }, - async (tx) => { - const [peerInc, h] = await ctx.getRecordHandle(tx); - if (!peerInc) { - return undefined; - } - let wgCreateRes: PerformCreateWithdrawalGroupResult | undefined = - undefined; - switch (peerInc.status) { - case PeerPushCreditStatus.PendingMerge: - case PeerPushCreditStatus.PendingMergeKycRequired: { - peerInc.status = PeerPushCreditStatus.PendingWithdrawing; - wgCreateRes = await internalPerformCreateWithdrawalGroup( - wex, - tx, - withdrawalGroupPrep, - ); - peerInc.withdrawalGroupId = - wgCreateRes.withdrawalGroup.withdrawalGroupId; - break; - } + await wex.db.runAllStoresReadWriteTx({}, async (tx) => { + const [peerInc, h] = await ctx.getRecordHandle(tx); + if (!peerInc) { + return undefined; + } + let wgCreateRes: PerformCreateWithdrawalGroupResult | undefined = undefined; + switch (peerInc.status) { + case PeerPushCreditStatus.PendingMerge: + case PeerPushCreditStatus.PendingMergeKycRequired: { + peerInc.status = PeerPushCreditStatus.PendingWithdrawing; + wgCreateRes = await internalPerformCreateWithdrawalGroup( + wex, + tx, + withdrawalGroupPrep, + ); + peerInc.withdrawalGroupId = + wgCreateRes.withdrawalGroup.withdrawalGroupId; + break; } - await h.update(peerInc); - }, - ); + } + await h.update(peerInc); + }); return TaskRunResult.backoff(); } async function processPendingWithdrawing( wex: WalletExecutionContext, - peerInc: PeerPushPaymentIncomingRecord, + peerInc: PeerPushCreditRecord, ): Promise<TaskRunResult> { if (!peerInc.withdrawalGroupId) { throw Error("invalid db state (withdrawing, but no withdrawal group ID"); @@ -963,45 +968,66 @@ async function processPendingWithdrawing( peerInc.peerPushCreditId, ); const wgId = peerInc.withdrawalGroupId; - let finished: boolean = false; - await wex.db.runReadWriteTx( - { storeNames: ["peerPushCredit", "withdrawalGroups", "transactionsMeta"] }, - async (tx) => { - const [ppi, h] = await ctx.getRecordHandle(tx); - if (!ppi) { - finished = true; - return; - } - if (ppi.status !== PeerPushCreditStatus.PendingWithdrawing) { - finished = true; - return; - } - const wg = await tx.withdrawalGroups.get(wgId); - if (!wg) { - // FIXME: Fail the operation instead? - return; - } - switch (wg.status) { - case WithdrawalGroupStatus.Done: - finished = true; - ppi.status = PeerPushCreditStatus.Done; - break; - // FIXME: Also handle other final states! - } + return await wex.db.runAllStoresReadWriteTx({}, async (tx) => { + const [ppi, h] = await ctx.getRecordHandle(tx); + if (!ppi) { + return TaskRunResult.finished(); + } + if (ppi.status !== PeerPushCreditStatus.PendingWithdrawing) { + return TaskRunResult.finished(); + } + const wg = await tx.withdrawalGroups.get(wgId); + if (!wg) { + ppi.status = PeerPushCreditStatus.Done; await h.update(ppi); - }, - ); - if (finished) { - return TaskRunResult.finished(); - } else { - // FIXME: Return indicator that we depend on the other operation! - return TaskRunResult.backoff(); - } + return TaskRunResult.finished(); + } + switch (wg.status) { + case WithdrawalGroupStatus.AbortedBank: + case WithdrawalGroupStatus.AbortedExchange: + case WithdrawalGroupStatus.AbortedOtherWallet: + case WithdrawalGroupStatus.AbortedUserRefused: + case WithdrawalGroupStatus.AbortingBank: + case WithdrawalGroupStatus.FailedAbortingBank: + case WithdrawalGroupStatus.FailedBankAborted: + ppi.status = PeerPushCreditStatus.Failed; + await h.update(ppi); + return TaskRunResult.finished(); + case WithdrawalGroupStatus.Done: + ppi.status = PeerPushCreditStatus.Done; + await h.update(ppi); + return TaskRunResult.finished(); + case WithdrawalGroupStatus.PendingQueryingStatus: + case WithdrawalGroupStatus.PendingReady: + case WithdrawalGroupStatus.PendingRedenominate: + case WithdrawalGroupStatus.PendingRegisteringBank: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + case WithdrawalGroupStatus.SuspendedAbortingBank: + case WithdrawalGroupStatus.SuspendedBalanceKyc: + case WithdrawalGroupStatus.SuspendedBalanceKycInit: + case WithdrawalGroupStatus.SuspendedKyc: + case WithdrawalGroupStatus.SuspendedQueryingStatus: + case WithdrawalGroupStatus.SuspendedReady: + case WithdrawalGroupStatus.SuspendedRedenominate: + case WithdrawalGroupStatus.SuspendedRegisteringBank: + case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + return TaskRunResult.backoff(); + case WithdrawalGroupStatus.PendingBalanceKyc: + case WithdrawalGroupStatus.PendingBalanceKycInit: + case WithdrawalGroupStatus.PendingKyc: + case WithdrawalGroupStatus.DialogProposed: + throw Error( + "unexpected status of withdrawal transaction for peer-push-credit", + ); + default: + assertUnreachable(wg.status); + } + }); } async function processPeerPushDebitDialogProposed( wex: WalletExecutionContext, - pullIni: PeerPushPaymentIncomingRecord, + pullIni: PeerPushCreditRecord, ): Promise<TaskRunResult> { const ctx = new PeerPushCreditTransactionContext( wex, @@ -1128,7 +1154,7 @@ export async function processPeerPushCredit( async function processPeerPushCreditBalanceKyc( ctx: PeerPushCreditTransactionContext, - peerInc: PeerPushPaymentIncomingRecord, + peerInc: PeerPushCreditRecord, ): Promise<TaskRunResult> { const exchangeBaseUrl = peerInc.exchangeBaseUrl; const amount = peerInc.estimatedAmountEffective; @@ -1281,7 +1307,7 @@ export async function confirmPeerPushCredit( } export function computePeerPushCreditTransactionState( - pushCreditRecord: PeerPushPaymentIncomingRecord, + pushCreditRecord: PeerPushCreditRecord, ): TransactionState { switch (pushCreditRecord.status) { case PeerPushCreditStatus.DialogProposed: @@ -1365,13 +1391,17 @@ export function computePeerPushCreditTransactionState( major: TransactionMajorState.Suspended, minor: TransactionMinorState.KycInit, }; + case PeerPushCreditStatus.Expired: + return { + major: TransactionMajorState.Expired, + }; default: assertUnreachable(pushCreditRecord.status); } } export function computePeerPushCreditTransactionActions( - pushCreditRecord: PeerPushPaymentIncomingRecord, + pushCreditRecord: PeerPushCreditRecord, ): TransactionAction[] { switch (pushCreditRecord.status) { case PeerPushCreditStatus.DialogProposed: @@ -1414,6 +1444,8 @@ export function computePeerPushCreditTransactionActions( return [TransactionAction.Delete]; case PeerPushCreditStatus.Failed: return [TransactionAction.Delete]; + case PeerPushCreditStatus.Expired: + return [TransactionAction.Delete]; default: assertUnreachable(pushCreditRecord.status); }