commit f554c3a1abccd9cc04aca4a233f5a7065431b85f
parent 9d1a5a06c40c63481555959ca79beee57771e0e7
Author: Florian Dold <dold@taler.net>
Date: Thu, 20 Aug 2026 19:06:45 +0200
wallet-core: require exact donation unit selection
Diffstat:
2 files changed, 79 insertions(+), 21 deletions(-)
diff --git a/packages/taler-wallet-core/src/donau.test.ts b/packages/taler-wallet-core/src/donau.test.ts
@@ -0,0 +1,43 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { Amounts } from "@gnu-taler/taler-util";
+import assert from "node:assert";
+import { test } from "node:test";
+import {
+ CandidateDonationUnit,
+ selectDonationUnitsExact,
+} from "./donau.js";
+
+function candidate(value: string): CandidateDonationUnit {
+ return {
+ value: `EUR:${value}`,
+ unitHash: `hash-${value}`,
+ unitKey: { cipher: "RSA" },
+ } as unknown as CandidateDonationUnit;
+}
+
+test("donation units must represent the requested amount exactly", () => {
+ const two = candidate("2");
+
+ assert.strictEqual(
+ selectDonationUnitsExact([two], Amounts.parseOrThrow("EUR:3")),
+ undefined,
+ );
+ assert.deepStrictEqual(
+ selectDonationUnitsExact([two], Amounts.parseOrThrow("EUR:4")),
+ [two, two],
+ );
+});
diff --git a/packages/taler-wallet-core/src/donau.ts b/packages/taler-wallet-core/src/donau.ts
@@ -333,12 +333,40 @@ export async function handleGetDonau(
/**
* Info about a donation unit key from the donau.
*/
-interface CandidateDonationUnit {
+export interface CandidateDonationUnit {
value: AmountString;
unitHash: HashCodeString;
unitKey: DonauUnitPubKey;
}
+export function selectDonationUnitsExact(
+ candidates: CandidateDonationUnit[],
+ amount: AmountLike,
+): CandidateDonationUnit[] | undefined {
+ let remaining = Amount.from(amount);
+ const selection: CandidateDonationUnit[] = [];
+
+ let i = 0;
+ while (i < candidates.length) {
+ if (remaining.isZero()) {
+ return selection;
+ }
+ const cand = candidates[i];
+ if (Amounts.isZero(cand.value)) {
+ // Would never reduce the remaining amount.
+ i++;
+ continue;
+ }
+ if (Amounts.cmp(remaining, cand.value) >= 0) {
+ selection.push(cand);
+ remaining = remaining.sub(cand.value);
+ } else {
+ i++;
+ }
+ }
+ return remaining.isZero() ? selection : undefined;
+}
+
/**
* Filter out applicable donation units
* from the donau keys response.
@@ -456,26 +484,13 @@ export async function generateDonauPlanchets(
logger.info(`created ${candidates.length} donau candidates`);
- let remaining = Amount.from(res.donauAmount);
- const selection: CandidateDonationUnit[] = [];
-
- let i = 0;
- while (i < candidates.length) {
- if (remaining.isZero()) {
- break;
- }
- const cand = candidates[i];
- if (Amounts.isZero(cand.value)) {
- // Would never reduce the remaining amount.
- i++;
- continue;
- }
- if (Amounts.cmp(remaining, cand.value) >= 0) {
- selection.push(cand);
- remaining = remaining.sub(cand.value);
- } else {
- i++;
- }
+ const selection = selectDonationUnitsExact(candidates, res.donauAmount);
+ if (!selection) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
+ {},
+ `donation amount ${res.donauAmount} cannot be represented by the offered donation units`,
+ );
}
const donauPlanchets: WalletDonationPlanchet[] = [];