commit 86ad465fe282ba77bf50359a7f91ebbccc5be41b
parent ab42503d8c53af369e2abb1170c68d7f7013dfa2
Author: Florian Dold <dold@taler.net>
Date: Thu, 3 Sep 2026 15:51:25 +0200
wallet-core: test change distribution and master keys in selection
Diffstat:
1 file changed, 68 insertions(+), 15 deletions(-)
diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts
@@ -958,7 +958,7 @@ test("demo: deposit max after withdraw raw 13", (t) => {
// current wallet impl fee 0.14
});
-test("overpay when remaining < depositFee", (t) => {
+test("change never drops a contribution below its deposit fee", (t) => {
const instructedAmount = Amounts.parseOrThrow("LOCAL:2.1");
const tally = emptyTallyForPeerPayment({
instructedAmount,
@@ -990,22 +990,75 @@ test("overpay when remaining < depositFee", (t) => {
assert.ok(coins != null);
- assert.deepStrictEqual(coins, {
- "hash0;32;http://exchange.localhost/": {
- exchangeBaseUrl: "http://exchange.localhost/",
- exchangeMasterPub: "123",
- denomPubHash: "hash0",
- maxAge: 32,
- contributions: [Amounts.parseOrThrow("LOCAL:2")],
+ // The excess of 0.9 over the instructed amount comes out of the smallest
+ // coin only down to its deposit fee; the rest comes out of the next one.
+ // The exchange refuses a coin whose contribution is below its fee.
+ let total = Amounts.zeroOfCurrency("LOCAL");
+ for (const sel of Object.values(coins)) {
+ for (const contribution of sel.contributions) {
+ assert.ok(
+ Amounts.cmp(contribution, "LOCAL:0.2") >= 0,
+ `contribution ${Amounts.stringify(contribution)} below deposit fee`,
+ );
+ total = Amounts.add(total, contribution).amount;
+ }
+ }
+ assert.strictEqual(Amounts.stringify(total), "LOCAL:2.1");
+ assert.deepStrictEqual(
+ coins["hash1;32;http://exchange.localhost/"]?.contributions,
+ [Amounts.parseOrThrow("LOCAL:0.2")],
+ );
+ assert.deepStrictEqual(
+ coins["hash0;32;http://exchange.localhost/"]?.contributions,
+ [Amounts.parseOrThrow("LOCAL:1.9")],
+ );
+});
+
+test("coins of one denomination under two master keys stay separate", (t) => {
+ const instructedAmount = Amounts.parseOrThrow("LOCAL:2");
+ const tally = emptyTallyForPeerPayment({
+ instructedAmount,
+ });
+ tally.amountDepositFeeLimitRemaining = Amounts.parseOrThrow("LOCAL:1");
+
+ // The same denomination, re-signed under the exchange's new master key.
+ const candidates = createCandidates([
+ {
+ amount: "LOCAL:1" as AmountString,
+ numAvailable: 1,
+ depositFee: "LOCAL:0" as AmountString,
+ fromExchange: "http://exchange.localhost/",
+ fromMasterPub: "OLDKEY",
},
- "hash1;32;http://exchange.localhost/": {
- exchangeBaseUrl: "http://exchange.localhost/",
- exchangeMasterPub: "123",
- denomPubHash: "hash1",
- maxAge: 32,
- contributions: [Amounts.parseOrThrow("LOCAL:0.1")],
+ {
+ amount: "LOCAL:1" as AmountString,
+ numAvailable: 1,
+ depositFee: "LOCAL:0" as AmountString,
+ fromExchange: "http://exchange.localhost/",
+ fromMasterPub: "NEWKEY",
},
- });
+ ]);
+ candidates[1].denomPubHash = candidates[0].denomPubHash;
+ const coins = testing_selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
+ candidates,
+ tally,
+ );
+
+ assert.ok(coins != null);
+ // Each entry is later looked up in the database by its master key, so
+ // the two coins must not be folded into one entry.
+ const entries = Object.values(coins);
+ assert.strictEqual(entries.length, 2);
+ assert.deepStrictEqual(
+ entries.map((e) => e.exchangeMasterPub).sort(),
+ ["NEWKEY", "OLDKEY"],
+ );
+ for (const e of entries) {
+ assert.deepStrictEqual(e.contributions, [Amounts.parseOrThrow("LOCAL:1")]);
+ }
});
test("DD91 removes redundant small coins after ascending selection", (t) => {