commit 8928496bcb990c89d191194df5aa80c2621f144f
parent b4d1c980d688c68628b0293b2f313c28990cf17f
Author: Florian Dold <dold@taler.net>
Date: Thu, 30 Jul 2026 22:35:55 +0200
wallet-core: use local time to distinguish purse gone reasons
The exchange answers "gone" whether a purse lapsed, was deleted by its owner
or was already decided, so each handler picked one cause and reported it
unconditionally: peer-pull-debit called every gone purse expired, peer-push-
credit called every one of them an abort. The purse expiration is in the
contract terms, so the local clock settles it without trusting the exchange.
Issue: https://bugs.taler.net/n/11654
Diffstat:
7 files changed, 173 insertions(+), 62 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-peer-pull-debit-purse-gone.ts b/packages/taler-harness/src/integrationtests/test-peer-pull-debit-purse-gone.ts
@@ -114,13 +114,11 @@ export async function runPeerPullDebitPurseGoneTest(t: GlobalTestState) {
await wallet2.call(WalletApiOperation.ConfirmPeerPullDebit, {
transactionId: prepare.transactionId,
});
+ // The invoice was called off well before its expiration, so the payer has
+ // to report it as aborted rather than as expired.
await wallet2.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: prepare.transactionId,
- txState: [
- { major: TransactionMajorState.Aborted },
- { major: TransactionMajorState.Expired },
- { major: TransactionMajorState.Failed },
- ],
+ txState: { major: TransactionMajorState.Aborted },
});
await wallet2.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
diff --git a/packages/taler-harness/src/integrationtests/test-peer-pull.ts b/packages/taler-harness/src/integrationtests/test-peer-pull.ts
@@ -36,12 +36,17 @@ const purse_expiration = AbsoluteTime.toProtocolTimestamp(
AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({ days: 2 })),
);
-const stFailedOrExpired = [
+/**
+ * States a wallet ends up in when it loses the race for an invoice: the purse
+ * is gone by the time it deposits, which aborts the payment, and a deposit
+ * that got as far as the exchange fails instead.
+ */
+const stLostRace = [
{
- major: TransactionMajorState.Failed,
+ major: TransactionMajorState.Aborted,
},
{
- major: TransactionMajorState.Expired,
+ major: TransactionMajorState.Failed,
},
];
@@ -213,7 +218,7 @@ export async function runPeerPullTest(t: GlobalTestState) {
}),
wallet3.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: prepare3.transactionId,
- txState: stFailedOrExpired,
+ txState: stLostRace,
timeout: { seconds: 10 },
}),
wallet4.call(WalletApiOperation.TestingWaitTransactionState, {
@@ -314,14 +319,14 @@ export async function runPeerPullTest(t: GlobalTestState) {
}),
wallet3.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: prepare3.transactionId,
- txState: stFailedOrExpired,
+ txState: stLostRace,
timeout: { seconds: 10 },
}),
]),
Promise.all([
wallet2.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: prepare2.transactionId,
- txState: stFailedOrExpired,
+ txState: stLostRace,
timeout: { seconds: 10 },
}),
wallet3.call(WalletApiOperation.TestingWaitTransactionState, {
@@ -367,14 +372,14 @@ export async function runPeerPullTest(t: GlobalTestState) {
wallet2.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: prepare2.transactionId,
txState: {
- major: TransactionMajorState.Expired,
+ major: TransactionMajorState.Aborted,
},
timeout: { seconds: 10 },
}),
wallet3.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: prepare3.transactionId,
txState: {
- major: TransactionMajorState.Expired,
+ major: TransactionMajorState.Aborted,
},
timeout: { seconds: 10 },
}),
diff --git a/packages/taler-harness/src/integrationtests/test-peer-push.ts b/packages/taler-harness/src/integrationtests/test-peer-push.ts
@@ -36,15 +36,17 @@ const purseExpiration = AbsoluteTime.toProtocolTimestamp(
AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({ days: 2 })),
);
-const stFinalUnsuccessful = [
- {
- major: TransactionMajorState.Failed,
- },
+/**
+ * States the receiver of a push payment ends up in when the payment never
+ * reaches it: another wallet merged the purse first, or the sender called the
+ * payment off. Neither of those is an expiry, so "expired" must not show up.
+ */
+const stNotExpired = [
{
- major: TransactionMajorState.Expired,
+ major: TransactionMajorState.Aborted,
},
{
- major: TransactionMajorState.Aborted,
+ major: TransactionMajorState.Failed,
},
];
@@ -233,13 +235,13 @@ export async function runPeerPushTest(t: GlobalTestState) {
}),
wallet3.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: prepare3.transactionId,
- txState: stFinalUnsuccessful,
+ txState: stNotExpired,
}),
]),
Promise.all([
wallet2.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: prepare2.transactionId,
- txState: stFinalUnsuccessful,
+ txState: stNotExpired,
}),
wallet3.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: prepare3.transactionId,
@@ -341,13 +343,13 @@ export async function runPeerPushTest(t: GlobalTestState) {
}),
wallet3.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: prepare3.transactionId,
- txState: stFinalUnsuccessful,
+ txState: stNotExpired,
}),
]),
Promise.all([
wallet2.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: prepare2.transactionId,
- txState: stFinalUnsuccessful,
+ txState: stNotExpired,
}),
wallet3.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: prepare3.transactionId,
@@ -391,14 +393,16 @@ export async function runPeerPushTest(t: GlobalTestState) {
await wallet2.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: prepare2.transactionId,
- txState: stFinalUnsuccessful,
+ txState: stNotExpired,
});
t.logStep("waiting for wallet3");
await wallet3.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: prepare3.transactionId,
- txState: stFinalUnsuccessful,
+ txState: {
+ major: TransactionMajorState.Aborted,
+ },
});
});
@@ -539,11 +543,15 @@ export async function runPeerPushTest(t: GlobalTestState) {
}),
wallet2.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: prepare2.transactionId,
- txState: stFinalUnsuccessful,
+ txState: {
+ major: TransactionMajorState.Expired,
+ },
}),
wallet3.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: prepare3.transactionId,
- txState: stFinalUnsuccessful,
+ txState: {
+ major: TransactionMajorState.Expired,
+ },
}),
]);
}
diff --git a/packages/taler-util/src/errors.ts b/packages/taler-util/src/errors.ts
@@ -34,6 +34,21 @@ import {
type empty = Record<string, never>;
+/**
+ * Why the exchange does not have the purse of a peer payment anymore.
+ *
+ * The exchange answers with the same status whether the purse lapsed, was
+ * deleted or was already decided, so the reason is derived from the purse
+ * expiration in the contract terms and the local clock.
+ */
+interface PurseGoneDetails {
+ /**
+ * The purse expiration has passed, i.e. the payment lapsed instead of being
+ * called off by the other wallet or completed by someone else.
+ */
+ purseExpired: boolean;
+}
+
export interface DetailsMap {
[TalerErrorCode.WALLET_PAY_MERCHANT_KYC_MISSING]: {
exchangeResponse: any;
@@ -250,7 +265,8 @@ export interface DetailsMap {
bankAccountId: string;
};
[TalerErrorCode.WALLET_PEER_CONTRACT_NOT_FOUND]: empty;
- [TalerErrorCode.WALLET_PEER_PUSH_CREDIT_PURSE_GONE]: empty;
+ [TalerErrorCode.WALLET_PEER_PUSH_CREDIT_PURSE_GONE]: PurseGoneDetails;
+ [TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE]: PurseGoneDetails;
[TalerErrorCode.WALLET_PEER_PULL_DEBIT_ALREADY_PAID]: empty;
[TalerErrorCode.WALLET_TOKENS_IN_USE]: {
tokenFamilyHash: string;
diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts
@@ -16,6 +16,7 @@
*/
import {
+ AbsoluteTime,
AmountJson,
Amounts,
ExchangePurseStatus,
@@ -177,3 +178,21 @@ export function isPurseDeposited(purse: ExchangePurseStatus): boolean {
!TalerProtocolTimestamp.isNever(depositTimestamp)
);
}
+
+/**
+ * Check whether a purse that the exchange reports as gone lapsed.
+ *
+ * The exchange answers "gone" for a purse that reached its expiration, for
+ * one that its owner deleted and for one that was already decided because
+ * another wallet got there first. The purse expiration is part of the
+ * contract terms, so the local clock separates the first cause from the other
+ * two without having to trust the exchange on it: a purse that is not due yet
+ * cannot have lapsed.
+ */
+export function isPurseGoneByExpiration(
+ purseExpiration: TalerProtocolTimestamp,
+): boolean {
+ return AbsoluteTime.isExpired(
+ AbsoluteTime.fromProtocolTimestamp(purseExpiration),
+ );
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
@@ -61,6 +61,7 @@ import {
encodeCrock,
getRandomBytes,
j2s,
+ makeErrorDetail,
} from "@gnu-taler/taler-util";
import {
PreviousPayCoins,
@@ -92,6 +93,7 @@ import {
import {
getTotalPeerPaymentCost,
isPurseDeposited,
+ isPurseGoneByExpiration,
queryCoinInfosForSelection,
} from "./pay-peer-common.js";
import { createRefreshGroup } from "./refresh.js";
@@ -223,7 +225,12 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
});
}
- async expireTransaction(fromSt: PeerPullDebitRecordStatus): Promise<void> {
+ /**
+ * Terminate the transaction because the exchange does not have the purse of
+ * the invoice anymore, which happens when the invoice lapsed, when the
+ * payee withdrew it and when another wallet paid it first.
+ */
+ async purseGoneTransaction(fromSt: PeerPullDebitRecordStatus): Promise<void> {
const { wex } = this;
await wex.runWalletDbTx(async (tx) => {
const [rec, h] = await this.getRecordHandle(tx);
@@ -251,8 +258,20 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
);
rec.abortRefreshGroupId = refresh.refreshGroupId;
}
- rec.status = PeerPullDebitRecordStatus.Expired;
- await h.update(rec, "expire");
+ const ct = await tx.getContractTerms(rec.contractTermsHash);
+ checkDbInvariant(!!ct, "contract terms for P2P payment not found");
+ if (isPurseGoneByExpiration(ct.contractTermsRaw.purse_expiration)) {
+ rec.status = PeerPullDebitRecordStatus.Expired;
+ await h.update(rec, "expire");
+ } else {
+ rec.status = PeerPullDebitRecordStatus.Aborted;
+ rec.abortReason = makeErrorDetail(
+ TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE,
+ { purseExpired: false },
+ "this invoice was already paid or withdrawn by the payee",
+ );
+ await h.update(rec, "purse-gone");
+ }
});
}
@@ -508,8 +527,9 @@ async function processPeerPullDebitDialogProposed(
case "ok":
break;
case HttpStatusCode.Gone:
- // Exchange says that purse doesn't exist anymore => expired!
- await ctx.expireTransaction(PeerPullDebitRecordStatus.DialogProposed);
+ // The purse is gone: the invoice lapsed, the payee withdrew it, or
+ // another wallet paid it.
+ await ctx.purseGoneTransaction(PeerPullDebitRecordStatus.DialogProposed);
return TaskRunResult.finished();
case HttpStatusCode.NotFound:
await ctx.failTransaction(pullIni.status, resp.detail);
@@ -650,7 +670,7 @@ async function processPeerPullDebitPendingDeposit(
case "ok":
continue;
case HttpStatusCode.Gone: {
- await ctx.expireTransaction(peerPullInc.status);
+ await ctx.purseGoneTransaction(peerPullInc.status);
return TaskRunResult.finished();
}
case HttpStatusCode.Conflict:
@@ -986,17 +1006,41 @@ async function internalPreparePeerPullDebit(
pursePub: pursePub,
});
+ let contractTerms: PeerContractTerms;
+
+ // Decoded before asking for the purse status, because the purse expiration
+ // in here is what tells a lapsed purse from a deleted one.
+ if (dec.contractTerms) {
+ contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
+ // FIXME: Check that the purseStatus balance matches contract terms amount
+ } else {
+ // FIXME: In this case, where do we get the purse expiration from?!
+ // https://bugs.gnunet.org/view.php?id=7706
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_UNSUPPORTED,
+ {},
+ "invoices without contract terms are not supported yet",
+ );
+ }
+
const resp = await runWithProgressRetries(wex, () =>
exchangeClient.getPurseStatusAtMerge(pursePub),
);
switch (resp.case) {
case "ok":
break;
- case HttpStatusCode.Gone:
+ case HttpStatusCode.Gone: {
+ const purseExpired = isPurseGoneByExpiration(
+ contractTerms.purse_expiration,
+ );
throw TalerError.fromDetail(
TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE,
- {},
+ { purseExpired },
+ purseExpired
+ ? "this invoice expired"
+ : "this invoice was already paid or withdrawn by the payee",
);
+ }
case HttpStatusCode.NotFound:
throw TalerError.fromDetail(
TalerErrorCode.WALLET_PEER_CONTRACT_NOT_FOUND,
@@ -1018,21 +1062,6 @@ async function internalPreparePeerPullDebit(
const peerPullDebitId = encodeCrock(getRandomBytes(32));
- let contractTerms: PeerContractTerms;
-
- if (dec.contractTerms) {
- contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
- // FIXME: Check that the purseStatus balance matches contract terms amount
- } else {
- // FIXME: In this case, where do we get the purse expiration from?!
- // https://bugs.gnunet.org/view.php?id=7706
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_CONTRACT_TERMS_UNSUPPORTED,
- {},
- "invoices without contract terms are not supported yet",
- );
- }
-
const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms);
// FIXME: Why don't we compute the totalCost here?!
diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts
@@ -54,6 +54,7 @@ import {
encodeCrock,
getRandomBytes,
j2s,
+ makeErrorDetail,
} from "@gnu-taler/taler-util";
import {
PendingTaskType,
@@ -98,7 +99,11 @@ import {
isKycOperationDue,
runKycCheckAlgo,
} from "./kyc.js";
-import { getMergeReserveInfo, isPurseMerged } from "./pay-peer-common.js";
+import {
+ getMergeReserveInfo,
+ isPurseGoneByExpiration,
+ isPurseMerged,
+} from "./pay-peer-common.js";
import {
BalanceEffect,
constructTransactionIdentifier,
@@ -625,12 +630,18 @@ async function internalPreparePeerPushCredit(
switch (resp.case) {
case "ok":
break;
- case HttpStatusCode.Gone:
+ case HttpStatusCode.Gone: {
+ const purseExpired = isPurseGoneByExpiration(
+ contractTerms.purse_expiration,
+ );
throw TalerError.fromDetail(
TalerErrorCode.WALLET_PEER_PUSH_CREDIT_PURSE_GONE,
- {},
- "the sender aborted this payment",
+ { purseExpired },
+ purseExpired
+ ? "this payment expired"
+ : "the sender aborted this payment",
);
+ }
case HttpStatusCode.NotFound:
throw TalerError.fromDetail(
TalerErrorCode.WALLET_PEER_CONTRACT_NOT_FOUND,
@@ -929,7 +940,17 @@ async function processPendingMerge(
await h.update(rec, "merge-conflict");
});
return TaskRunResult.finished();
- case HttpStatusCode.Gone:
+ case HttpStatusCode.Gone: {
+ const purseExpired = isPurseGoneByExpiration(
+ contractTerms.purse_expiration,
+ );
+ const reason = makeErrorDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_CREDIT_PURSE_GONE,
+ { purseExpired },
+ purseExpired
+ ? "this payment expired"
+ : "the sender aborted this payment",
+ );
await ctx.wex.runWalletDbTx(async (tx) => {
const [rec, h] = await ctx.getRecordHandle(tx);
if (!rec) {
@@ -938,10 +959,12 @@ async function processPendingMerge(
switch (rec.status) {
case PeerPushCreditStatus.PendingMergeKycRequired:
case PeerPushCreditStatus.PendingMerge: {
- // Note that we do *not* go to an expired state here,
- // because we also get a Gone status when the other
- // wallet aborted the P2P payment.
- rec.status = PeerPushCreditStatus.Failed;
+ if (purseExpired) {
+ rec.status = PeerPushCreditStatus.Expired;
+ } else {
+ rec.status = PeerPushCreditStatus.Failed;
+ rec.failReason = reason;
+ }
break;
}
default:
@@ -950,6 +973,7 @@ async function processPendingMerge(
await h.update(rec, "merge-gone");
});
return TaskRunResult.finished();
+ }
case HttpStatusCode.Forbidden:
case HttpStatusCode.NotFound:
await ctx.failTransaction(peerInc.status, mergeResp.detail);
@@ -1085,7 +1109,8 @@ async function processPeerPushDebitDialogProposed(
case "ok":
break;
case HttpStatusCode.Gone:
- // Exchange says that purse doesn't exist anymore => expired!
+ // The purse is gone: either it lapsed or the sender called the
+ // payment off.
await ctx.wex.runWalletDbTx(async (tx) => {
const [rec, h] = await ctx.getRecordHandle(tx);
if (!rec) {
@@ -1093,7 +1118,18 @@ async function processPeerPushDebitDialogProposed(
}
switch (rec.status) {
case PeerPushCreditStatus.DialogProposed: {
- rec.status = PeerPushCreditStatus.Aborted;
+ const ct = await tx.getContractTerms(rec.contractTermsHash);
+ checkDbInvariant(!!ct, "contract terms for P2P payment not found");
+ if (isPurseGoneByExpiration(ct.contractTermsRaw.purse_expiration)) {
+ rec.status = PeerPushCreditStatus.Expired;
+ } else {
+ rec.status = PeerPushCreditStatus.Aborted;
+ rec.abortReason = makeErrorDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_CREDIT_PURSE_GONE,
+ { purseExpired: false },
+ "the sender aborted this payment",
+ );
+ }
break;
}
default: