taler-typescript-core

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

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:
Mpackages/taler-util/src/types-taler-exchange.ts | 7+++++++
Mpackages/taler-wallet-core/src/db/records.ts | 6++++++
Mpackages/taler-wallet-core/src/exchanges.ts | 3+++
Mpackages/taler-wallet-core/src/pay-peer-common.ts | 42++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.ts | 14++++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 7+++++++
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) ||