taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/refresh.test.ts | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/refresh.ts | 12++++++++++--
Mpackages/taler-wallet-core/src/withdraw.ts | 12++++++++----
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 }, ); }); }