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