taler-typescript-core

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

commit 72001fe8bc0d698974adf36466b32485c65ceb5b
parent 845a792d033a2563b8057fbee007384b2e5e67a7
Author: Florian Dold <dold@taler.net>
Date:   Thu, 13 Aug 2026 12:47:10 +0200

wallet-core: preserve reviewed peer payment terms

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-peer-pull.ts | 3+++
Mpackages/taler-util/src/taler-error-codes.ts | 8++++++++
Mpackages/taler-util/src/types-taler-wallet.ts | 18++++++++++++++++++
Mpackages/taler-wallet-core/src/coinSelection.ts | 6++++++
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.ts | 6++++++
Apackages/taler-wallet-core/src/pay-peer-push-debit.test.ts | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 275 insertions(+), 0 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-peer-pull.ts b/packages/taler-harness/src/integrationtests/test-peer-pull.ts @@ -133,6 +133,9 @@ export async function runPeerPullTest(t: GlobalTestState) { ]); t.assertDeepEqual(checkfive.amountRaw, "TESTKUDOS:5"); + t.assertDeepEqual(checkfive.defaultExpiration, { + d_us: 86_400_000_000, + }); t.assertDeepEqual(checkzero.amountRaw, "TESTKUDOS:0"); t.assertDeepEqual(checkzero.amountEffective, "TESTKUDOS:0"); diff --git a/packages/taler-util/src/taler-error-codes.ts b/packages/taler-util/src/taler-error-codes.ts @@ -4601,6 +4601,14 @@ export enum TalerErrorCode { WALLET_EXCHANGE_KEY_CHANGE_MISMATCH = 7068, /** + * The values of a reviewed peer push payment changed before confirmation. + * The caller must prepare and show the payment again. + * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0). + * (A value of 0 indicates that the error is generated client-side). + */ + WALLET_PEER_PUSH_PAYMENT_QUOTE_CHANGED = 7069, + + /** * We encountered a timeout with our payment backend. * Returned with an HTTP status code of #MHD_HTTP_GATEWAY_TIMEOUT (504). * (A value of 0 indicates that the error is generated client-side). diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -3625,6 +3625,15 @@ export interface CheckPeerPushDebitOkResponse { * (or 1 week if the exchange does not specify it). */ defaultExpiration: TalerProtocolDuration; + + /** + * Opaque description of the values reviewed by the caller. Passing this + * back to initiatePeerPushDebit makes wallet-core reject the operation when + * coin selection, fees or the selected exchange changed in the meantime. + * + * Optional for compatibility with older wallet-core implementations. + */ + peerPushDebitQuote?: string; } export interface InitiatePeerPushDebitRequest { @@ -3636,6 +3645,9 @@ export interface InitiatePeerPushDebitRequest { */ restrictScope?: ScopeInfo; + /** Quote returned by checkPeerPushDebitV2. */ + peerPushDebitQuote?: string; + partialContractTerms: PartialPeerContractTerms; } @@ -3653,6 +3665,7 @@ export const codecForInitiatePeerPushDebitRequest = .property("partialContractTerms", codecForPartialPeerContractTerms()) .property("exchangeBaseUrl", codecOptional(codecForCanonBaseUrl())) .property("restrictScope", codecOptional(codecForScopeInfo())) + .property("peerPushDebitQuote", codecOptional(codecForString())) .build("InitiatePeerPushDebitRequest"); /** @@ -3808,6 +3821,11 @@ export interface CheckPeerPullCreditResponse { amountEffective: AmountString; /** + * Wallet-selected default expiration for the request. + */ + defaultExpiration: TalerProtocolDuration; + + /** * Number of coins that will be used, * can be used by the UI to warn if excessively large. */ diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts @@ -1594,6 +1594,9 @@ export interface PeerCoinSelectionRequest { */ restrictScope?: ScopeInfo; + /** Additionally pin selection to this exchange. */ + exchangeBaseUrl?: string; + /** * Instruct the coin selection to repair this coin * selection instead of selecting completely new coins. @@ -1738,6 +1741,9 @@ export async function selectPeerCoinsInTx( if (exch.detailsPointer?.currency !== currency) { continue; } + if (req.exchangeBaseUrl && req.exchangeBaseUrl !== exch.baseUrl) { + continue; + } const exchWire = await getExchangeDetailsInTx(tx, exch.baseUrl); if (!exchWire) { continue; diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -19,6 +19,7 @@ import { CheckPeerPullCreditRequest, CheckPeerPullCreditResponse, ContractTermsUtil, + Duration, ExchangeReservePurseRequest, ExchangeWalletKycStatus, HostPortPath, @@ -107,6 +108,10 @@ import { const logger = new Logger("pay-peer-pull-credit.ts"); +const defaultPeerPullExpiration = Duration.toTalerProtocolDuration( + Duration.fromSpec({ days: 1 }), +); + export class PeerPullCreditTransactionContext implements TransactionContext { readonly transactionId: TransactionIdStr; readonly taskId: TaskIdStr; @@ -1146,6 +1151,7 @@ export async function internalCheckPeerPullCredit( exchangeBaseUrl: exchangeUrl, amountEffective: wi.withdrawalAmountEffective, amountRaw: req.amount, + defaultExpiration: defaultPeerPullExpiration, numCoins, }; } diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.test.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ScopeType, TalerProtocolTimestamp } from "@gnu-taler/taler-util"; +import { + decodePeerPushDebitQuote, + encodePeerPushDebitQuote, +} from "./pay-peer-push-debit.js"; + +test("peer push debit quote round-trips reviewed values", () => { + const quote = { + version: 1 as const, + amount: "CHF:10", + amountEffective: "CHF:10.25", + exchangeBaseUrl: "https://exchange.example/", + restrictScope: { + type: ScopeType.Auditor, + currency: "CHF", + url: "https://auditor.example/", + } as const, + maxExpirationDate: TalerProtocolTimestamp.fromSeconds(2_000_000_000), + }; + assert.deepEqual( + decodePeerPushDebitQuote(encodePeerPushDebitQuote(quote)), + quote, + ); +}); + +test("peer push debit quote rejects malformed and unknown versions", () => { + assert.throws(() => decodePeerPushDebitQuote("not-a-quote")); + assert.throws(() => + decodePeerPushDebitQuote( + encodePeerPushDebitQuote({ + version: 1, + amount: "CHF:1", + amountEffective: "CHF:1", + exchangeBaseUrl: "https://exchange.example/", + maxExpirationDate: TalerProtocolTimestamp.never(), + }).replace(/^PPDQ1-/, "PPDQ2-"), + ), + ); + assert.throws(() => + decodePeerPushDebitQuote( + encodePeerPushDebitQuote({ + version: 1, + amount: "CHF:1", + amountEffective: "EUR:1", + exchangeBaseUrl: "https://exchange.example/", + maxExpirationDate: TalerProtocolTimestamp.never(), + }), + ), + ); +}); diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -52,9 +52,15 @@ import { TransactionType, assertUnreachable, checkDbInvariant, + canonicalJson, + canonicalizeBaseUrl, + codecForScopeInfo, + bytesToString, + decodeCrock, encodeCrock, getRandomBytes, j2s, + stringToBytes, throwUnexpectedResponse, } from "@gnu-taler/taler-util"; import { @@ -434,6 +440,109 @@ const fallbackDefaultPeerPushExpiration = Duration.toTalerProtocolDuration( Duration.fromSpec({ days: 7 }), ); +interface PeerPushDebitQuoteV1 { + version: 1; + amount: string; + amountEffective: string; + exchangeBaseUrl: string; + restrictScope?: ScopeInfo; + maxExpirationDate: TalerProtocolTimestamp; +} + +const peerPushDebitQuotePrefix = "PPDQ1-"; + +export function encodePeerPushDebitQuote(quote: PeerPushDebitQuoteV1): string { + return ( + peerPushDebitQuotePrefix + encodeCrock(stringToBytes(canonicalJson(quote))) + ); +} + +export function decodePeerPushDebitQuote(token: string): PeerPushDebitQuoteV1 { + if (!token.startsWith(peerPushDebitQuotePrefix)) { + throw Error("unsupported peer push debit quote version"); + } + const value: unknown = JSON.parse( + bytesToString(decodeCrock(token.slice(peerPushDebitQuotePrefix.length))), + ); + if (!value || typeof value !== "object") { + throw Error("peer push debit quote is not an object"); + } + const quote = value as Partial<PeerPushDebitQuoteV1>; + let canonicalExchange: string | undefined; + let restrictScope: ScopeInfo | undefined; + let scopeValid = true; + try { + canonicalExchange = + typeof quote.exchangeBaseUrl === "string" + ? canonicalizeBaseUrl(quote.exchangeBaseUrl) + : undefined; + } catch { + canonicalExchange = undefined; + } + try { + restrictScope = + quote.restrictScope === undefined + ? undefined + : codecForScopeInfo().decode(quote.restrictScope); + } catch { + scopeValid = false; + } + const amount = + typeof quote.amount === "string" ? Amounts.parse(quote.amount) : undefined; + const amountEffective = + typeof quote.amountEffective === "string" + ? Amounts.parse(quote.amountEffective) + : undefined; + const maxExpiration = quote.maxExpirationDate?.t_s; + if ( + quote.version !== 1 || + !amount || + !amountEffective || + amount.currency !== amountEffective.currency || + (restrictScope !== undefined && + restrictScope.currency !== amount.currency) || + !scopeValid || + typeof quote.exchangeBaseUrl !== "string" || + canonicalExchange !== quote.exchangeBaseUrl || + !TalerProtocolTimestamp.isTimestamp(quote.maxExpirationDate) || + (maxExpiration !== "never" && + (typeof maxExpiration !== "number" || + !Number.isSafeInteger(maxExpiration) || + maxExpiration < 0)) + ) { + throw Error("peer push debit quote has invalid fields"); + } + return { + version: 1, + amount: quote.amount!, + amountEffective: quote.amountEffective!, + exchangeBaseUrl: quote.exchangeBaseUrl, + ...(restrictScope === undefined ? {} : { restrictScope }), + maxExpirationDate: quote.maxExpirationDate!, + }; +} + +function sameScope(left: ScopeInfo | undefined, right: ScopeInfo | undefined) { + return canonicalJson(left ?? null) === canonicalJson(right ?? null); +} + +function timestampAfter( + left: TalerProtocolTimestamp, + right: TalerProtocolTimestamp, +): boolean { + if (right.t_s === "never") return false; + if (left.t_s === "never") return true; + return left.t_s > right.t_s; +} + +function quoteChanged(message: string): never { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_QUOTE_CHANGED, + {}, + message, + ); +} + async function getDefaultPeerPushExpiration( wex: WalletExecutionContext, exchangeBaseUrl: string, @@ -487,6 +596,14 @@ async function internalCheckPeerPushDebit( wex, exchangeBaseUrl, ), + peerPushDebitQuote: encodePeerPushDebitQuote({ + version: 1, + amount: req.amount, + amountEffective: req.amount, + exchangeBaseUrl, + restrictScope: req.restrictScope, + maxExpirationDate: TalerProtocolTimestamp.never(), + }), }; } const coinSelRes = await selectPeerCoins(wex, { @@ -534,6 +651,14 @@ async function internalCheckPeerPushDebit( amountRaw: req.amount, maxExpirationDate: coinSelRes.result.maxExpirationDate, defaultExpiration: Duration.toTalerProtocolDuration(defaultExp), + peerPushDebitQuote: encodePeerPushDebitQuote({ + version: 1, + amount: req.amount, + amountEffective: Amounts.stringify(totalAmount), + exchangeBaseUrl, + restrictScope: req.restrictScope, + maxExpirationDate: coinSelRes.result.maxExpirationDate, + }), }; } @@ -1136,6 +1261,40 @@ export async function initiatePeerPushDebit( ); const currency = Amounts.currencyOf(instructedAmount); + let quote: PeerPushDebitQuoteV1 | undefined; + if (req.peerPushDebitQuote) { + try { + quote = decodePeerPushDebitQuote(req.peerPushDebitQuote); + } catch (cause) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "peerPushDebitQuote" }, + cause instanceof Error + ? cause.message + : "invalid peer push debit quote", + ); + } + if ( + quote.amount !== req.partialContractTerms.amount || + !sameScope(quote.restrictScope, req.restrictScope) || + (req.exchangeBaseUrl != null && + req.exchangeBaseUrl !== quote.exchangeBaseUrl) + ) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "peerPushDebitQuote" }, + "peer push debit quote does not match the request", + ); + } + if (!req.partialContractTerms.purse_expiration) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "partialContractTerms.purse_expiration" }, + "quoted peer push debit requires an explicit expiration", + ); + } + } + if (req.exchangeBaseUrl != null && req.restrictScope != null) { throw TalerError.fromDetail( TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, @@ -1175,6 +1334,7 @@ export async function initiatePeerPushDebit( const coinSelRes = await selectPeerCoinsInTx(wex, tx, { instructedAmount, restrictScope: restrictScope, + exchangeBaseUrl: quote?.exchangeBaseUrl, feesCoveredByCounterparty: false, }); @@ -1182,6 +1342,9 @@ export async function initiatePeerPushDebit( switch (coinSelRes.type) { case "failure": + if (quote) { + quoteChanged("the reviewed funds are no longer available"); + } throw TalerError.fromDetail( TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, { @@ -1213,6 +1376,10 @@ export async function initiatePeerPushDebit( exchangeBaseUrl = coinSelRes.result.exchangeBaseUrl; + if (quote && exchangeBaseUrl !== quote.exchangeBaseUrl) { + quoteChanged("the selected exchange changed"); + } + const ex = await getExchangeDetailsInTx(tx, exchangeBaseUrl); const myExpiration = req.partialContractTerms.purse_expiration ?? @@ -1225,6 +1392,14 @@ export async function initiatePeerPushDebit( ), ); + if ( + quote && + (timestampAfter(myExpiration, quote.maxExpirationDate) || + timestampAfter(myExpiration, coinSelRes.result.maxExpirationDate)) + ) { + quoteChanged("the reviewed expiration is no longer available"); + } + const contractTerms: PeerContractTerms = { ...req.partialContractTerms, purse_expiration: myExpiration, @@ -1235,6 +1410,13 @@ export async function initiatePeerPushDebit( const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); const totalAmount = await getTotalPeerPaymentCostInTx(wex, tx, coins); + if ( + quote && + Amounts.cmp(totalAmount, Amounts.parseOrThrow(quote.amountEffective)) !== + 0 + ) { + quoteChanged("the effective amount changed"); + } const ppi: WalletPeerPushDebit = { amount: Amounts.stringify(instructedAmount), restrictScope: req.restrictScope,