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