commit b1c49f631179bc4a39b00ece8713b3ebdb624162
parent 4d75a0c5f43bf68ed1b7bb3b7847b38fc5329abb
Author: Christian Grothoff <christian@grothoff.org>
Date: Sat, 17 Oct 2026 15:53:25 +0200
enforce new max_p2p_expiration
Diffstat:
6 files changed, 79 insertions(+), 0 deletions(-)
diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts
@@ -532,6 +532,12 @@ export interface ExchangeKeysResponse {
// @since protocol **v35**.
default_p2p_push_expiration?: RelativeTime;
+ // How far in the future may the purse_expiration of a purse be? An
+ // expiring purse refunds the coins deposited into it, and the exchange
+ // must still be able to account for that refund.
+ // @since protocol **v42**.
+ max_p2p_expiration?: RelativeTime;
+
// Signal to the merchant backend that it should swap
// the terms-of-service and KYC auth authentication
// steps in the user experience. Optional, defaults
@@ -2659,6 +2665,7 @@ export const codecForExchangeKeysResponse = (): Codec<ExchangeKeysResponse> =>
.property("disable_direct_deposit", codecOptional(codecForBoolean()))
.property("bank_compliance_language", codecOptional(codecForString()))
.property("default_p2p_push_expiration", codecOptional(codecForDuration))
+ .property("max_p2p_expiration", codecOptional(codecForDuration))
.property("kyc_swap_tos_acceptance", codecOptional(codecForBoolean()))
.deprecatedProperty("rewards_allowed")
.build("TalerExchangeApi.ExchangeKeysResponse");
diff --git a/packages/taler-wallet-core/src/db/records.ts b/packages/taler-wallet-core/src/db/records.ts
@@ -1402,6 +1402,12 @@ export interface WalletExchangeDetails {
bankComplianceLanguage: string | undefined;
defaultPeerPushExpiration: TalerProtocolDuration | undefined;
+
+ /**
+ * How far in the future may a purse expire, as published by the
+ * exchange in /keys. Absent before protocol v42.
+ */
+ maxPeerPushExpiration?: TalerProtocolDuration;
}
/**
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
@@ -793,6 +793,7 @@ export interface ExchangeDetails {
globalFees: ExchangeGlobalFees[];
reserveClosingDelay: TalerProtocolDuration;
defaultPeerPushExpiration?: TalerProtocolDuration;
+ maxPeerPushExpiration?: TalerProtocolDuration;
shoppingUrl?: string;
}
@@ -820,6 +821,7 @@ export async function getExchangeDetailsInTx(
reserveClosingDelay: det.reserveClosingDelay,
shoppingUrl: det.shoppingUrl,
defaultPeerPushExpiration: det.defaultPeerPushExpiration,
+ maxPeerPushExpiration: det.maxPeerPushExpiration,
};
}
@@ -2800,6 +2802,7 @@ export async function updateExchangeFromUrlHandler(
bankComplianceLanguage: keysInfo.bank_compliance_language,
shoppingUrl: keysInfo.shopping_url,
defaultPeerPushExpiration: keysInfo.default_p2p_push_expiration,
+ maxPeerPushExpiration: keysInfo.max_p2p_expiration,
tinyAmount:
keysInfo.tiny_amount != null
? keysInfo.tiny_amount
diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts
@@ -20,12 +20,14 @@ import {
AmountJson,
AmountString,
Amounts,
+ Duration,
ExchangePurseStatus,
HttpStatusCode,
ReserveHistoryMergeEntry,
SelectedProspectiveCoin,
TalerError,
TalerErrorCode,
+ TalerProtocolDuration,
TalerProtocolTimestamp,
WalletAccountMergeFlags,
checkDbInvariant,
@@ -433,6 +435,46 @@ export function isPurseDeposited(purse: ExchangePurseStatus): boolean {
* two without having to trust the exchange on it: a purse that is not due yet
* cannot have lapsed.
*/
+/**
+ * Refuse a purse expiration that the exchange will refuse anyway. A purse
+ * that is not decided refunds the coins deposited into it when it expires,
+ * and the exchange only accepts an expiration for which it can still
+ * account for that refund, which it publishes as "max_p2p_expiration" in
+ * /keys. An exchange that does not publish the limit (before protocol
+ * v42) is not checked against.
+ *
+ * @param purseExpiration expiration the caller asked for
+ * @param maxExpiration limit published by the exchange, if any
+ * @param parameter name of the request parameter to blame
+ */
+export function requirePurseExpirationWithinLimit(
+ purseExpiration: TalerProtocolTimestamp,
+ maxExpiration: TalerProtocolDuration | undefined,
+ parameter: string,
+): void {
+ if (maxExpiration === undefined) {
+ return;
+ }
+ const limit = AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromTalerProtocolDuration(maxExpiration),
+ );
+ if (
+ AbsoluteTime.cmp(
+ AbsoluteTime.fromProtocolTimestamp(purseExpiration),
+ limit,
+ ) <= 0
+ ) {
+ return;
+ }
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter },
+ "peer payment expiration is further in the future than the exchange allows",
+ );
+}
+
+
export function isPurseGoneByExpiration(
purseExpiration: TalerProtocolTimestamp,
): boolean {
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
@@ -91,6 +91,7 @@ import {
BalanceThresholdCheckResult,
checkIncomingAmountLegalUnderKycBalanceThreshold,
fetchFreshExchangeWithRetryNow,
+ getExchangeDetailsInTx,
getLegacyScopesForTransaction,
getPreferredExchangeForCurrency,
getScopeForAllExchanges,
@@ -110,6 +111,7 @@ import {
isPurseGoneByExpiration,
isPurseDeposited,
isPurseMerged,
+ requirePurseExpirationWithinLimit,
} from "./pay-peer-common.js";
import { runWithMaybeProgressContext } from "./progress.js";
import {
@@ -1622,6 +1624,18 @@ async function internalInitiatePeerPullPayment(
requireExchangeKeysConfirmedOrThrow(wex, exchange);
requireExchangeTosAcceptedOrThrow(wex, exchange);
+ // The purse of a pull payment is created together with its merge, but it
+ // still refunds the coins the payer deposited into it if the payer never
+ // pays it in full, so the exchange bounds its expiration as well.
+ await wex.runWalletDbTx(async (tx) => {
+ const det = await getExchangeDetailsInTx(tx, exchangeBaseUrl);
+ requirePurseExpirationWithinLimit(
+ req.partialContractTerms.purse_expiration,
+ det?.maxPeerPushExpiration,
+ "partialContractTerms.purse_expiration",
+ );
+ });
+
if (
checkPeerCreditHardLimitExceeded(exchange, req.partialContractTerms.amount)
) {
diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
@@ -109,6 +109,7 @@ import {
isPurseGoneByExpiration,
isPurseMerged,
queryCoinInfosForSelection,
+ requirePurseExpirationWithinLimit,
} from "./pay-peer-common.js";
import { runWithMaybeProgressContext } from "./progress.js";
import { createRefreshGroup } from "./refresh.js";
@@ -1625,6 +1626,12 @@ export async function initiatePeerPushDebit(
);
}
+ requirePurseExpirationWithinLimit(
+ myExpiration,
+ ex?.maxPeerPushExpiration,
+ "partialContractTerms.purse_expiration",
+ );
+
if (
quote &&
(timestampAfter(myExpiration, quote.maxExpirationDate) ||