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:
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,