commit efb714f76eca75dfb7ad1b5f0e27d2d87b481440
parent fc237ea7c02f209440a0935f6cd2ce6aa44deee3
Author: Florian Dold <dold@taler.net>
Date: Mon, 14 Sep 2026 01:42:10 +0200
wallet-core: preserve denomination age masks during refresh
Select a compatible member of each denomination family and use the same
age-mask constraint for refresh cost estimates. Families can contain both
restricted and unrestricted denominations.
Diffstat:
3 files changed, 134 insertions(+), 6 deletions(-)
diff --git a/packages/taler-wallet-core/src/refresh.test.ts b/packages/taler-wallet-core/src/refresh.test.ts
@@ -14,9 +14,12 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import {
+ AbsoluteTime,
Amounts,
CoinStatus,
DenominationInfo,
+ DenomKeyType,
+ encodeCrock,
TalerError,
TalerErrorCode,
TransactionAction,
@@ -32,15 +35,128 @@ import {
validateAndRecomputeCoinHistoryBalance,
} from "./refresh.js";
import {
+ DenominationVerificationStatus,
RefreshCoinStatus,
RefreshOperationStatus,
WalletCoin,
WalletCoinAvailability,
+ WalletDenomination,
WalletRefreshGroup,
WalletRefreshSession,
+ timestampProtocolToDb,
} from "./db/records.js";
+import { runnerFactories } from "./db/testing/runners.js";
import { WalletDbTransaction } from "./db/transaction.js";
import { WalletExecutionContext } from "./wallet.js";
+import { getWithdrawableDenomsTx } from "./withdraw.js";
+
+function refreshOutputDenom(ageMask: number): WalletDenomination {
+ const now = AbsoluteTime.toStampMs(AbsoluteTime.now());
+ const hashBytes = new Uint8Array(64);
+ new DataView(hashBytes.buffer).setUint32(0, ageMask);
+ return {
+ exchangeBaseUrl: "https://exchange/",
+ exchangeMasterPub: encodeCrock(new Uint8Array(32)),
+ denomPubHash: encodeCrock(hashBytes),
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key: "dummy",
+ age_mask: ageMask,
+ },
+ currency: "TESTKUDOS",
+ value: "TESTKUDOS:1",
+ fees: {
+ feeWithdraw: "TESTKUDOS:0.1",
+ feeRefresh: "TESTKUDOS:0.1",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ },
+ stampStart: timestampProtocolToDb({ t_s: Math.floor(now / 1000) - 60 }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: Math.floor(now / 1000) + 3600,
+ }),
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: Math.floor(now / 1000) + 7200,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: Math.floor(now / 1000) + 10800,
+ }),
+ isOffered: true,
+ isLost: false,
+ isRevoked: false,
+ masterSig: encodeCrock(new Uint8Array(64)),
+ verificationStatus: DenominationVerificationStatus.VerifiedGood,
+ };
+}
+
+test("refresh costs exclude outputs with a different age mask", () => {
+ const outputs = [0, 257, 513].map(refreshOutputDenom);
+ for (const output of outputs) {
+ const old = {
+ ...output,
+ ...output.fees,
+ value: "TESTKUDOS:5",
+ } as unknown as DenominationInfo;
+ const remaining = Amounts.parseOrThrow("TESTKUDOS:4");
+ assert.strictEqual(
+ Amounts.stringify(getTotalRefreshCostInternal(outputs, old, remaining)),
+ "TESTKUDOS:1",
+ );
+ assert.deepStrictEqual(
+ getTotalRefreshCostInternal(
+ outputs.filter((d) => d !== output),
+ old,
+ remaining,
+ ),
+ remaining,
+ );
+ }
+});
+
+for (const makeRunner of runnerFactories) {
+ test(`${makeRunner.name}: refresh finds a matching age mask within a denomination family`, async () => {
+ const runner = await makeRunner();
+ try {
+ const outputs = [0, 257, 513].map(refreshOutputDenom);
+ await runner.runReadWriteTx(async (tx) => {
+ const first = outputs[0];
+ const serial = await tx.upsertDenominationFamily({
+ familyParams: {
+ exchangeBaseUrl: first.exchangeBaseUrl,
+ exchangeMasterPub: first.exchangeMasterPub,
+ value: first.value,
+ ...first.fees,
+ },
+ });
+ for (const output of outputs) {
+ await tx.upsertDenomination({
+ ...output,
+ denominationFamilySerial: serial,
+ });
+ }
+ });
+ for (const ageMask of [0, 257, 513, 1025]) {
+ const candidates = await runner.runReadWriteTx((tx) =>
+ getWithdrawableDenomsTx(
+ {} as WalletExecutionContext,
+ tx,
+ outputs[0].exchangeBaseUrl,
+ "TESTKUDOS",
+ { ageMask },
+ ),
+ );
+ assert.deepStrictEqual(
+ candidates.map((d) => d.denomPubHash),
+ outputs
+ .filter((d) => d.denomPub.age_mask === ageMask)
+ .map((d) => d.denomPubHash),
+ );
+ }
+ } finally {
+ await runner.close();
+ }
+ });
+}
test("melt noreveal index must be an integer inside kappa", () => {
assert.doesNotThrow(() => requireValidNorevealIndex(0, 3));
diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts
@@ -434,7 +434,7 @@ export async function getTotalRefreshCosts(
}
const groupKey = `${request.refreshedDenom.exchangeBaseUrl}\0${Amounts.currencyOf(
request.amountLeft,
- )}`;
+ )}\0${request.refreshedDenom.denomPub.age_mask}`;
const indices = groups.get(groupKey) ?? [];
indices.push(i);
groups.set(groupKey, indices);
@@ -449,6 +449,7 @@ export async function getTotalRefreshCosts(
tx,
exchangeBaseUrl,
currency,
+ { ageMask: first.refreshedDenom.denomPub.age_mask },
);
for (const index of indices) {
const request = requests[index];
@@ -488,6 +489,11 @@ export function getTotalRefreshCostInternal(
refreshedDenom.value
}, amount left ${Amounts.stringify(amountLeft)}`,
);
+ // Refresh preserves the age commitment. Outputs must use the same age mask
+ // as the old coin, including when it has no age commitment at all.
+ denoms = denoms.filter(
+ (d) => d.denomPub.age_mask === refreshedDenom.denomPub.age_mask,
+ );
if (denoms.length === 0) {
return Amounts.copy(amountLeft);
}
@@ -602,7 +608,9 @@ async function initRefreshSession(
plannedOutput ??
selectWithdrawalDenominations(
availableAmount,
- await getWithdrawableDenomsTx(wex, tx, exchangeBaseUrl, currency),
+ await getWithdrawableDenomsTx(wex, tx, exchangeBaseUrl, currency, {
+ ageMask: oldDenom.denomPub.age_mask,
+ }),
{ limitCoins: maxRefreshSessionSize },
);
diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts
@@ -1435,8 +1435,9 @@ export async function getWithdrawableDenomsTx(
tx: WalletDbTransaction,
exchangeBaseUrl: string,
currency: string,
- maxAmount?: AmountLike,
+ options: { maxAmount?: AmountLike; ageMask?: number } = {},
): Promise<WalletDenomination[]> {
+ const { maxAmount, ageMask } = options;
const dbNow = timestampProtocolToDb(TalerProtocolTimestamp.now());
// Only the key set the user has accepted can be withdrawn from. Families
// of a superseded master public key stay in the database for the coins
@@ -1476,11 +1477,14 @@ export async function getWithdrawableDenomsTx(
}
const fpSerial = fam.denominationFamilySerial;
checkDbInvariant(typeof fpSerial === "number", "denominationFamilySerial");
- // Now we need to find a representative denom for the family.
+ // Families can contain different age masks. Apply the refresh constraint
+ // while searching, so an incompatible first member cannot hide a usable one.
const denom = await tx.findDenominationByFamilyFromExpiry(
fpSerial,
dbNow,
- isCandidateWithdrawableDenomRec,
+ (d) =>
+ (ageMask === undefined || d.denomPub.age_mask === ageMask) &&
+ isCandidateWithdrawableDenomRec(d),
);
if (denom) {
relevantDenoms.push(denom);
@@ -2393,7 +2397,7 @@ async function getWithdrawalCandidateDenoms(
tx,
exchangeBaseUrl,
Amounts.currencyOf(amount),
- amount,
+ { maxAmount: amount },
);
});
}