taler-typescript-core

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

commit 08fe0b5b0c80d029fe60c47f08937e93fe5e7cb1
parent 82a69a10cb2e98ff2d6c98afb85abb9bd30d7be9
Author: Florian Dold <dold@taler.net>
Date:   Thu,  3 Sep 2026 15:52:32 +0200

wallet-core: select coins of one denomination per master key

After a confirmed key change the same denomination is held under two
keys; folding them into one entry made the later per-key lookup come
up short and throw.

Diffstat:
Mpackages/taler-wallet-core/src/coinSelection.test.ts | 36++++++++++++++++++------------------
Mpackages/taler-wallet-core/src/coinSelection.ts | 8+++++++-
2 files changed, 25 insertions(+), 19 deletions(-)

diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts @@ -223,7 +223,7 @@ test("p2p: should select the coin", (t) => { assert.ok(coins != null); assert.deepStrictEqual(coins, { - "hash0;32;http://exchange.localhost/": { + "hash0;32;http://exchange.localhost/;123": { exchangeBaseUrl: "http://exchange.localhost/", exchangeMasterPub: "123", denomPubHash: "hash0", @@ -255,7 +255,7 @@ test("p2p: should select 3 coins", (t) => { ); assert.deepStrictEqual(coins, { - "hash0;32;http://exchange.localhost/": { + "hash0;32;http://exchange.localhost/;123": { exchangeBaseUrl: "http://exchange.localhost/", exchangeMasterPub: "123", denomPubHash: "hash0", @@ -322,7 +322,7 @@ test("pay: select one coin to pay with fee", (t) => { ); assert.deepStrictEqual(coins, { - "hash0;32;http://exchange.localhost/": { + "hash0;32;http://exchange.localhost/;123": { exchangeBaseUrl: "http://exchange.localhost/", exchangeMasterPub: "123", denomPubHash: "hash0", @@ -1005,11 +1005,11 @@ test("change never drops a contribution below its deposit fee", (t) => { } assert.strictEqual(Amounts.stringify(total), "LOCAL:2.1"); assert.deepStrictEqual( - coins["hash1;32;http://exchange.localhost/"]?.contributions, + coins["hash1;32;http://exchange.localhost/;123"]?.contributions, [Amounts.parseOrThrow("LOCAL:0.2")], ); assert.deepStrictEqual( - coins["hash0;32;http://exchange.localhost/"]?.contributions, + coins["hash0;32;http://exchange.localhost/;123"]?.contributions, [Amounts.parseOrThrow("LOCAL:1.9")], ); }); @@ -1087,7 +1087,7 @@ test("DD91 removes redundant small coins after ascending selection", (t) => { ); assert.deepStrictEqual(coins, { - "hash0;32;http://exchange.localhost/": { + "hash0;32;http://exchange.localhost/;123": { exchangeBaseUrl: "http://exchange.localhost/", exchangeMasterPub: "123", denomPubHash: "hash0", @@ -1123,7 +1123,7 @@ test("DD91 pruning remains exact when selected values exceed the amount limit", ); assert.deepStrictEqual(Object.keys(coins ?? {}), [ - "hash0;32;http://exchange.localhost/", + "hash0;32;http://exchange.localhost/;123", ]); }); @@ -1162,7 +1162,7 @@ test("DD91 prefers the denomination that expires first", (t) => { ); assert.deepStrictEqual(Object.keys(coins ?? {}), [ - "hash1;32;http://exchange.localhost/", + "hash1;32;http://exchange.localhost/;123", ]); }); @@ -1193,7 +1193,7 @@ test("DD91 replaces small coins when that avoids customer fees", (t) => { ); assert.deepStrictEqual(coins, { - "hash0;32;http://exchange.localhost/": { + "hash0;32;http://exchange.localhost/;123": { exchangeBaseUrl: "http://exchange.localhost/", exchangeMasterPub: "123", denomPubHash: "hash0", @@ -1245,7 +1245,7 @@ test("DD91 prunes coins made redundant by lower fees", (t) => { ); assert.deepStrictEqual(coins, { - "hash0;32;http://exchange.localhost/": { + "hash0;32;http://exchange.localhost/;123": { exchangeBaseUrl: "http://exchange.localhost/", exchangeMasterPub: "123", denomPubHash: "hash0", @@ -1282,7 +1282,7 @@ test("DD91 keeps spending an overabundant small denomination", (t) => { ); assert.deepStrictEqual(coins, { - "hash1;32;http://exchange.localhost/": { + "hash1;32;http://exchange.localhost/;123": { exchangeBaseUrl: "http://exchange.localhost/", exchangeMasterPub: "123", denomPubHash: "hash1", @@ -1324,7 +1324,7 @@ test("DD91 still optimizes at exactly the 5*F_D balance threshold", (t) => { ); assert.deepStrictEqual(Object.keys(coins ?? {}), [ - "hash0;32;http://exchange.localhost/", + "hash0;32;http://exchange.localhost/;123", ]); assert.deepStrictEqual( tally.customerDepositFees, @@ -1404,7 +1404,7 @@ test("prefer exact denom", (t) => { assert.ok(coins != null); assert.deepStrictEqual(coins, { - "hash1;32;http://exchange.localhost/": { + "hash1;32;http://exchange.localhost/;123": { exchangeBaseUrl: "http://exchange.localhost/", exchangeMasterPub: "123", denomPubHash: "hash1", @@ -1751,7 +1751,7 @@ test("legacy-2024: takes the largest coin instead of the exact one", (t) => { // The default algorithm picks the exactly fitting LOCAL:2 coin (hash1). assert.deepStrictEqual(coins, { - "hash0;32;http://exchange.localhost/": { + "hash0;32;http://exchange.localhost/;123": { exchangeBaseUrl: "http://exchange.localhost/", exchangeMasterPub: "123", denomPubHash: "hash0", @@ -1799,14 +1799,14 @@ test("legacy-2024: overspends the deposit fee that the allowance covers", (t) => // fee, so the second coin contributes LOCAL:0.2. That overspending is what // the default algorithm fixed. assert.deepStrictEqual(coins, { - "hash0;32;http://exchange.localhost/": { + "hash0;32;http://exchange.localhost/;123": { exchangeBaseUrl: "http://exchange.localhost/", exchangeMasterPub: "123", denomPubHash: "hash0", maxAge: 32, contributions: [Amounts.parseOrThrow("LOCAL:2")], }, - "hash1;32;http://exchange.localhost/": { + "hash1;32;http://exchange.localhost/;123": { exchangeBaseUrl: "http://exchange.localhost/", exchangeMasterPub: "123", denomPubHash: "hash1", @@ -1850,7 +1850,7 @@ test("legacy-2024: spends the largest coins first", (t) => { ); assert.deepStrictEqual(legacyCoins, { - "hash0;32;http://exchange.localhost/": { + "hash0;32;http://exchange.localhost/;123": { exchangeBaseUrl: "http://exchange.localhost/", exchangeMasterPub: "123", denomPubHash: "hash0", @@ -1870,7 +1870,7 @@ test("legacy-2024: spends the largest coins first", (t) => { ); assert.deepStrictEqual(defaultCoins, { - "hash1;32;http://exchange.localhost/": { + "hash1;32;http://exchange.localhost/;123": { exchangeBaseUrl: "http://exchange.localhost/", exchangeMasterPub: "123", denomPubHash: "hash1", diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts @@ -1149,10 +1149,14 @@ export async function reportInsufficientBalanceDetails( function makeAvailabilityKey( exchangeBaseUrl: string, + exchangeMasterPub: string, denomPubHash: string, maxAge: number, ): string { - return `${denomPubHash};${maxAge};${exchangeBaseUrl}`; + // The same denomination can be held under two master keys after a key + // change; the coins are looked up by master key later, so the entries + // must stay apart. + return `${denomPubHash};${maxAge};${exchangeBaseUrl};${exchangeMasterPub}`; } /** @@ -1195,6 +1199,7 @@ function applyContributions( if (contributions.length) { const avKey = makeAvailabilityKey( denom.exchangeBaseUrl, + denom.exchangeMasterPub, denom.denomPubHash, denom.maxAge, ); @@ -1772,6 +1777,7 @@ function selectForced( aci.numAvailable--; const avKey = makeAvailabilityKey( aci.exchangeBaseUrl, + aci.exchangeMasterPub, aci.denomPubHash, aci.maxAge, );