commit 0d7621291e0ea3b74160e3b29536cf321816d66a parent ff8cd80798bcea5dda1429d27cdfc124c57a7cbe Author: Florian Dold <florian@dold.me> Date: Thu, 29 Jan 2026 02:54:35 +0100 wallet-core: preparations for denom families Diffstat:
19 files changed, 515 insertions(+), 375 deletions(-)
diff --git a/.vscode/settings.json b/.vscode/settings.json @@ -50,17 +50,5 @@ "typescript.preferences.importModuleSpecifier": "project-relative", "javascript.preferences.importModuleSpecifier": "project-relative", "javascript.preferences.importModuleSpecifierEnding": "js", - "makefile.configureOnOpen": false, - "javascript.preferences.autoImportFileExcludePatterns": [ - "**/index.*.ts", - "**/index.*.js", - "**/index.js", - "**/index.ts", - ], - "typescript.preferences.autoImportFileExcludePatterns": [ - "**/index.*.ts", - "**/index.*.js", - "**/index.js", - "**/index.ts", - ] + "makefile.configureOnOpen": false } diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts @@ -256,10 +256,22 @@ advancedCli }) .requiredArgument("payto", clk.STRING) .action((args) => { - console.log(`normalized base64: ${encodeCrock(hashNormalizedPaytoUri(args.hashPayto.payto))}`); - console.log(`normalized hex: ${toHexString(hashNormalizedPaytoUri(args.hashPayto.payto))}`); - console.log(`full base64: ${encodeCrock(hashFullPaytoUri(args.hashPayto.payto))}`); - console.log(`full hex: ${toHexString(hashFullPaytoUri(args.hashPayto.payto))}`); + console.log( + `normalized base64: ${encodeCrock( + hashNormalizedPaytoUri(args.hashPayto.payto), + )}`, + ); + console.log( + `normalized hex: ${toHexString( + hashNormalizedPaytoUri(args.hashPayto.payto), + )}`, + ); + console.log( + `full base64: ${encodeCrock(hashFullPaytoUri(args.hashPayto.payto))}`, + ); + console.log( + `full hex: ${toHexString(hashFullPaytoUri(args.hashPayto.payto))}`, + ); }); advancedCli diff --git a/packages/taler-harness/src/integrationtests/test-denom-lost-complex.ts b/packages/taler-harness/src/integrationtests/test-denom-lost-complex.ts @@ -24,8 +24,7 @@ import { TalerWireGatewayHttpClient, TransactionMajorState, } from "@gnu-taler/taler-util"; -import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { WithdrawalGroupStatus } from "../../../taler-wallet-core/src/db.js"; +import { WalletApiOperation, WithdrawalGroupStatus } from "@gnu-taler/taler-wallet-core"; import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; import { getTestHarnessPaytoForLabel, diff --git a/packages/taler-harness/src/integrationtests/test-exchange-purse.ts b/packages/taler-harness/src/integrationtests/test-exchange-purse.ts @@ -148,7 +148,7 @@ export async function runExchangePurseTest(t: GlobalTestState) { contribution: amount, denomPubHash: coin.denomPubHash, denomSig: coin.denomSig, - feeDeposit: d1.fees.feeDeposit, + feeDeposit: d1.feeDeposit, }; const depositSigsResp = await cryptoApi.signPurseDeposits({ diff --git a/packages/taler-harness/src/integrationtests/test-wallet-network-availability.ts b/packages/taler-harness/src/integrationtests/test-wallet-network-availability.ts @@ -24,16 +24,24 @@ import { TransactionMajorState, TransactionType, } from "@gnu-taler/taler-util"; -import { WalletApiOperation, parseTransactionIdentifier } from "@gnu-taler/taler-wallet-core"; -import { GlobalTestState, getTestHarnessPaytoForLabel } from "harness/harness.js"; -import { createSimpleTestkudosEnvironmentV3, withdrawViaBankV3 } from "harness/environments.js"; -import { TaskRunResultType } from "../../../taler-wallet-core/src/common.js"; +import { + WalletApiOperation, + parseTransactionIdentifier, + TaskRunResultType, +} from "@gnu-taler/taler-wallet-core"; +import { + createSimpleTestkudosEnvironmentV3, + withdrawViaBankV3, +} from "harness/environments.js"; +import { + GlobalTestState, + getTestHarnessPaytoForLabel, +} from "harness/harness.js"; /** * Run test for hintNetworkAvailability in wallet-core */ export async function runWalletNetworkAvailabilityTest(t: GlobalTestState) { - // Set up test environment const { bankClient, walletClient, exchange } = await createSimpleTestkudosEnvironmentV3(t, undefined, { @@ -51,22 +59,27 @@ export async function runWalletNetworkAvailabilityTest(t: GlobalTestState) { await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); const networkRequiredCond = walletClient.waitForNotificationCond((x) => { - return (x.type === NotificationType.TaskObservabilityEvent - && x.event.type === ObservabilityEventType.ShepherdTaskResult - && x.event.resultType === TaskRunResultType.NetworkRequired + return ( + x.type === NotificationType.TaskObservabilityEvent && + x.event.type === ObservabilityEventType.ShepherdTaskResult && + x.event.resultType === TaskRunResultType.NetworkRequired ); }); const refreshCreatedCond = walletClient.waitForNotificationCond((x) => { - return (x.type === NotificationType.TransactionStateTransition && - parseTransactionIdentifier(x.transactionId)?.tag === TransactionType.Refresh + return ( + x.type === NotificationType.TransactionStateTransition && + parseTransactionIdentifier(x.transactionId)?.tag === + TransactionType.Refresh ); }); const refreshDoneCond = walletClient.waitForNotificationCond((x) => { - return (x.type === NotificationType.TransactionStateTransition && - parseTransactionIdentifier(x.transactionId)?.tag === TransactionType.Refresh - && x.newTxState.major === TransactionMajorState.Done + return ( + x.type === NotificationType.TransactionStateTransition && + parseTransactionIdentifier(x.transactionId)?.tag === + TransactionType.Refresh && + x.newTxState.major === TransactionMajorState.Done ); }); diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-idempotent.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-idempotent.ts @@ -21,6 +21,7 @@ import { AgeRestriction, Amounts, AmountString, + DenominationInfo, encodeCrock, ExchangeWithdrawRequest, getRandomBytes, @@ -30,7 +31,6 @@ import { import { HttpRequestLibrary } from "@gnu-taler/taler-util/http"; import { CryptoDispatcher, - DenominationRecord, SynchronousCryptoWorkerFactoryPlain, TalerCryptoInterface, } from "@gnu-taler/taler-wallet-core"; @@ -103,14 +103,14 @@ async function myWithdrawCoin(args: { http: HttpRequestLibrary; cryptoApi: TalerCryptoInterface; reserveKeyPair: ReserveKeypair; - denom: DenominationRecord; + denom: DenominationInfo; exchangeBaseUrl: string; }): Promise<CoinInfo> { const { http, cryptoApi, reserveKeyPair, denom, exchangeBaseUrl } = args; const planchet = await cryptoApi.createPlanchet({ coinIndex: 0, denomPub: denom.denomPub, - feeWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw), + feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw), reservePriv: reserveKeyPair.reservePriv, reservePub: reserveKeyPair.reservePub, secretSeed: encodeCrock(getRandomBytes(32)), @@ -121,7 +121,7 @@ async function myWithdrawCoin(args: { const sigResp = await cryptoApi.signWithdrawal({ amount: Amounts.stringify(denom.value), - fee: Amounts.stringify(denom.fees.feeWithdraw), + fee: Amounts.stringify(denom.feeWithdraw), coinEvs: [planchet.coinEv], denomsPubHashes: [planchet.denomPubHash], reservePriv: reserveKeyPair.reservePriv, @@ -161,8 +161,8 @@ async function myWithdrawCoin(args: { denomSig: ubSig, denomPub: denom.denomPub, denomPubHash: denom.denomPubHash, - feeDeposit: Amounts.stringify(denom.fees.feeDeposit), - feeRefresh: Amounts.stringify(denom.fees.feeRefresh), + feeDeposit: Amounts.stringify(denom.feeDeposit), + feeRefresh: Amounts.stringify(denom.feeRefresh), exchangeBaseUrl: args.exchangeBaseUrl, maxAge: AgeRestriction.AGE_UNRESTRICTED, }; diff --git a/packages/taler-util/src/performance.ts b/packages/taler-util/src/performance.ts @@ -22,11 +22,8 @@ /** * Imports. */ -import { assertUnreachable } from "./index.node.js"; -import { - ObservabilityEventType, - ObservabilityEvent, -} from "./notifications.js"; +import { assertUnreachable } from "./index.js"; +import { ObservabilityEvent, ObservabilityEventType } from "./notifications.js"; export enum PerformanceStatType { HttpFetch = "http-fetch", @@ -38,43 +35,49 @@ export enum PerformanceStatType { export type PerformanceStat = | { - type: PerformanceStatType.HttpFetch; - url: string; - durationMs: number; - } | { - type: PerformanceStatType.DbQuery; - name: string, - location: string, - durationMs: number; - } | { - type: PerformanceStatType.Crypto; - operation: string; - durationMs: number; - } | { - type: PerformanceStatType.WalletRequest; - operation: string; - requestId: string; - durationMs: number; - } | { - type: PerformanceStatType.WalletTask; - taskId: string; - durationMs: number; - }; + type: PerformanceStatType.HttpFetch; + url: string; + durationMs: number; + } + | { + type: PerformanceStatType.DbQuery; + name: string; + location: string; + durationMs: number; + } + | { + type: PerformanceStatType.Crypto; + operation: string; + durationMs: number; + } + | { + type: PerformanceStatType.WalletRequest; + operation: string; + requestId: string; + durationMs: number; + } + | { + type: PerformanceStatType.WalletTask; + taskId: string; + durationMs: number; + }; export namespace PerformanceStat { export function fromNotification( evt: ObservabilityEvent & { durationMs: number }, ): PerformanceStat | undefined { - if (evt.type === ObservabilityEventType.HttpFetchFinishSuccess - || evt.type === ObservabilityEventType.HttpFetchFinishError + if ( + evt.type === ObservabilityEventType.HttpFetchFinishSuccess || + evt.type === ObservabilityEventType.HttpFetchFinishError ) { return { type: PerformanceStatType.HttpFetch, url: evt.url, durationMs: evt.durationMs, }; - } else if (evt.type === ObservabilityEventType.DbQueryFinishSuccess - || evt.type === ObservabilityEventType.DbQueryFinishError + } else if ( + evt.type === ObservabilityEventType.DbQueryFinishSuccess || + evt.type === ObservabilityEventType.DbQueryFinishError ) { return { type: PerformanceStatType.DbQuery, @@ -82,16 +85,18 @@ export namespace PerformanceStat { location: evt.location, durationMs: evt.durationMs, }; - } else if (evt.type === ObservabilityEventType.CryptoFinishSuccess - || evt.type === ObservabilityEventType.CryptoFinishError + } else if ( + evt.type === ObservabilityEventType.CryptoFinishSuccess || + evt.type === ObservabilityEventType.CryptoFinishError ) { return { type: PerformanceStatType.Crypto, operation: evt.operation, durationMs: evt.durationMs, }; - } else if (evt.type === ObservabilityEventType.RequestFinishSuccess - || evt.type === ObservabilityEventType.RequestFinishError + } else if ( + evt.type === ObservabilityEventType.RequestFinishSuccess || + evt.type === ObservabilityEventType.RequestFinishError ) { return { type: PerformanceStatType.WalletRequest, @@ -110,16 +115,15 @@ export namespace PerformanceStat { return undefined; } - export function equals( - a: PerformanceStat, - b: PerformanceStat, - ): boolean { + export function equals(a: PerformanceStat, b: PerformanceStat): boolean { if (a.type !== b.type) return false; if (a.type === PerformanceStatType.HttpFetch) { return a.url === b["url" as keyof typeof b]; } else if (a.type === PerformanceStatType.DbQuery) { - return a.name === b["name" as keyof typeof b] - && a.location === b["location" as keyof typeof b]; + return ( + a.name === b["name" as keyof typeof b] && + a.location === b["location" as keyof typeof b] + ); } else if (a.type === PerformanceStatType.Crypto) { return a.operation === b["operation" as keyof typeof b]; } else if (a.type === PerformanceStatType.WalletRequest) { @@ -138,7 +142,7 @@ export type PerformanceTable = { export namespace PerformanceTable { export function insertEvent(tab: PerformanceTable, evt: ObservabilityEvent) { - if ("durationMs" in evt && typeof evt.durationMs === 'number') { + if ("durationMs" in evt && typeof evt.durationMs === "number") { const stat = PerformanceStat.fromNotification(evt); if (!stat) return; if (!tab[stat.type]) { @@ -154,9 +158,7 @@ export namespace PerformanceTable { } } - export function compactSort( - tab: PerformanceTable, - ): PerformanceTable { + export function compactSort(tab: PerformanceTable): PerformanceTable { const compacted: PerformanceTable = {}; for (const key of Object.keys(tab)) { const groupedIdx: number[] = []; @@ -186,8 +188,9 @@ export namespace PerformanceTable { } // insert in descending order according to durationMs - let insertIndex = compacted[key as keyof typeof tab]!! - .findIndex((el) => el.durationMs <= eventA.durationMs); + let insertIndex = compacted[key as keyof typeof tab]!!.findIndex( + (el) => el.durationMs <= eventA.durationMs, + ); if (insertIndex === -1) { insertIndex = compacted[key as keyof typeof tab]!!.length; } @@ -197,4 +200,4 @@ export namespace PerformanceTable { return compacted; } -}; +} diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -149,68 +149,6 @@ export const codecForDenominationPubKey = () => .alternative(DenomKeyType.ClauseSchnorr, codecForCsDenominationPubKey()) .build("DenominationPubKey"); -/** - * Denomination as found in the /keys response from the exchange. - */ -export interface ExchangeDenomination { - /** - * Value of one coin of the denomination. - */ - value: string; - - /** - * Public signing key of the denomination. - */ - denom_pub: DenominationPubKey; - - /** - * Fee for withdrawing. - */ - fee_withdraw: string; - - /** - * Fee for depositing. - */ - fee_deposit: string; - - /** - * Fee for refreshing. - */ - fee_refresh: string; - - /** - * Fee for refunding. - */ - fee_refund: string; - - /** - * Start date from which withdraw is allowed. - */ - stamp_start: TalerProtocolTimestamp; - - /** - * End date for withdrawing. - */ - stamp_expire_withdraw: TalerProtocolTimestamp; - - /** - * Expiration date after which the exchange can forget about - * the currency. - */ - stamp_expire_legal: TalerProtocolTimestamp; - - /** - * Date after which the coins of this denomination can't be - * deposited anymore. - */ - stamp_expire_deposit: TalerProtocolTimestamp; - - /** - * Signature over the denomination information by the exchange's master - * signing key. - */ - master_sig: string; -} /** * Signature by the auditor that a particular denomination key is audited. @@ -685,21 +623,6 @@ export interface ExchangeRevealResponse { ev_sigs: ExchangeRevealItem[]; } -export const codecForDenomination = (): Codec<ExchangeDenomination> => - buildCodecForObject<ExchangeDenomination>() - .property("value", codecForString()) - .property("denom_pub", codecForDenominationPubKey()) - .property("fee_withdraw", codecForString()) - .property("fee_deposit", codecForString()) - .property("fee_refresh", codecForString()) - .property("fee_refund", codecForString()) - .property("stamp_start", codecForTimestamp) - .property("stamp_expire_withdraw", codecForTimestamp) - .property("stamp_expire_legal", codecForTimestamp) - .property("stamp_expire_deposit", codecForTimestamp) - .property("master_sig", codecForString()) - .build("Denomination"); - export const codecForAuditorDenomSig = (): Codec<AuditorDenomSig> => buildCodecForObject<AuditorDenomSig>() .property("denom_pub_h", codecForString()) diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -1683,6 +1683,12 @@ export interface DenominationInfo { exchangeBaseUrl: string; exchangeMasterPub: string; + + isLost: boolean; + + isOffered: boolean; + + masterSig: string; } export type DenomOperation = "deposit" | "withdraw" | "refresh" | "refund"; diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts @@ -219,6 +219,9 @@ function createCandidates( exchangeMasterPub: r.fromMasterPub, numAvailable: r.numAvailable, maxAge: 32, + isLost: false, + isOffered: true, + masterSig: "DUMMY", }; }); } @@ -331,6 +334,9 @@ function defaultFeeConfig( stampExpireWithdraw: TalerProtocolTimestamp.never(), stampStart: TalerProtocolTimestamp.never(), value: Amounts.stringify(value), + isLost: false, + isOffered: true, + masterSig: "DUMMY", }; } diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts @@ -646,9 +646,11 @@ export async function reportInsufficientBalanceDetails( exchDet.maxMerchantEffectiveDepositAmount, ), missingGlobalFees, - exchangeMasterPubMismatch: Amounts.cmp( - exchDet.balanceReceiverExchangeUrlAcceptable, - exchDet.balanceReceiverExchangePubAcceptable) != 0, + exchangeMasterPubMismatch: + Amounts.cmp( + exchDet.balanceReceiverExchangeUrlAcceptable, + exchDet.balanceReceiverExchangePubAcceptable, + ) != 0, causeHint: !!wex.ws.devExperimentState.merchantDepositInsufficient ? InsufficientBalanceHint.MerchantDepositInsufficient : getHint(req, exchDet), diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -75,9 +75,11 @@ import { WithdrawalExchangeAccountDetails, ZeroLimitedOperation, canonicalJson, + checkDbInvariant, codecForAny, encodeCrock, hash, + hashTruncate32, j2s, stringToBytes, stringifyScopeInfo, @@ -171,7 +173,7 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName"; * backwards-compatible way or object stores and indices * are added. */ -export const WALLET_DB_MINOR_VERSION = 26; +export const WALLET_DB_MINOR_VERSION = 27; declare const symDbProtocolTimestamp: unique symbol; @@ -258,6 +260,20 @@ export function timestampOptionalAbsoluteFromDb( return AbsoluteTime.fromMilliseconds(Math.floor(stamp / 1000)); } +export interface DenomFamilyParams { + exchangeBaseUrl: string; + exchangeMasterPub: string; + value: AmountString; + feeWithdraw: AmountString; + feeDeposit: AmountString; + feeRefresh: AmountString; + feeRefund: AmountString; +} + +export function hashDenomFamilyParams(dfp: DenomFamilyParams): string { + return encodeCrock(hashTruncate32(stringToBytes(canonicalJson(dfp) + "\0"))); +} + /** * Format of the operation status code: 0x0abc_nnnn @@ -490,6 +506,15 @@ export interface DenomFees { feeRefund: AmountString; } +export interface DenominationFamilyRecord { + denominationFamilySerial?: number; + /** + * Hash (32 byte truncated SHA-512) of canonicalized parameter JSON. + */ + familyParamsHash: string; + familyParams: DenomFamilyParams; +} + /** * Denomination record as stored in the wallet's database. */ @@ -516,6 +541,8 @@ export interface DenominationRecord { fees: DenomFees; + denominationFamilySerial: number; + /** * Validity start date of the denomination. */ @@ -584,6 +611,7 @@ export namespace DenominationRecord { export function toDenomInfo(d: DenominationRecord): DenominationInfo { return { denomPub: d.denomPub, + exchangeMasterPub: d.exchangeMasterPub, denomPubHash: d.denomPubHash, feeDeposit: Amounts.stringify(d.fees.feeDeposit), feeRefresh: Amounts.stringify(d.fees.feeRefresh), @@ -595,7 +623,9 @@ export namespace DenominationRecord { stampStart: timestampProtocolFromDb(d.stampStart), value: Amounts.stringify(d.value), exchangeBaseUrl: d.exchangeBaseUrl, - exchangeMasterPub: d.exchangeMasterPub, + isLost: d.isLost ?? false, + masterSig: d.masterSig, + isOffered: d.isOffered, }; } } @@ -3174,6 +3204,33 @@ export const WalletStoresV1 = { versionAdded: 26, }, ), + byDenominationFamilySerialAndStampExpireWithdraw: describeIndex( + "byDenominationFamilySerialAndStampExpireWithdraw", + ["denominationFamilySerial", "stampExpireWithdraw"], + { + versionAdded: 27, + }, + ), + }, + ), + denominationFamilies: describeStore( + "denominationFamilies", + describeContents<DenominationFamilyRecord>({ + keyPath: "denominationFamilySerial", + versionAdded: 27, + autoIncrement: true, + }), + { + byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { + versionAdded: 27, + }), + byFamilyParamsHash: describeIndex( + "byFamilyParamsHash", + "familyParamsHash", + { + versionAdded: 27, + }, + ), }, ), exchanges: describeStore( @@ -3847,8 +3904,54 @@ export const walletDbFixups: FixupDescription[] = [ fn: fixup20260116BadRefreshCoinSelection, name: "fixup20260116BadRefreshCoinSelection", }, + // Denom families were introduced. + // This migration creates denom families + // for existing denomination records. + { + fn: fixup20260129DenomFamilyMigration, + name: "fixup20260129DenomFamilyMigration", + }, ]; +async function fixup20260129DenomFamilyMigration( + tx: WalletDbAllStoresReadWriteTransaction, +): Promise<void> { + // FIXME: Batch the DB fetches, with some forEachAsyncBatched + await tx.denominations.iter().forEachAsync(async (r) => { + if (r.denominationFamilySerial != null) { + return; + } + const fp: DenomFamilyParams = { + exchangeBaseUrl: r.exchangeBaseUrl, + exchangeMasterPub: r.exchangeMasterPub, + feeDeposit: r.fees.feeDeposit, + feeRefresh: r.fees.feeRefresh, + feeRefund: r.fees.feeRefund, + feeWithdraw: r.fees.feeWithdraw, + value: r.value, + }; + const fph = hashDenomFamilyParams(fp); + const dfRec = + await tx.denominationFamilies.indexes.byFamilyParamsHash.get(fph); + let denominationFamilySerial; + if (dfRec) { + denominationFamilySerial = dfRec.denominationFamilySerial; + } else { + const insRes = await tx.denominationFamilies.put({ + familyParams: fp, + familyParamsHash: fph, + }); + denominationFamilySerial = insRes.key; + } + checkDbInvariant( + typeof denominationFamilySerial == "number", + "denominationFamilySerial", + ); + r.denominationFamilySerial = denominationFamilySerial; + await tx.denominations.put(r); + }); +} + async function fixup20260116BadRefreshCoinSelection( tx: WalletDbAllStoresReadWriteTransaction, ): Promise<void> { @@ -3862,7 +3965,9 @@ async function fixup20260116BadRefreshCoinSelection( rec.expectedOutputPerCoin, ).amount; if (Amounts.isNonZero(inputAmount) && Amounts.isZero(outputAmount)) { - logger.info(`fixing up refresh group ${rec.refreshGroupId}, setting status to PendingRedenominate`); + logger.info( + `fixing up refresh group ${rec.refreshGroupId}, setting status to PendingRedenominate`, + ); rec.operationStatus = RefreshOperationStatus.PendingRedenominate; delete rec.timestampFinished; await tx.refreshGroups.put(rec); diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts @@ -28,10 +28,11 @@ import { AbsoluteTime, AgeRestriction, - AmountJson, AmountString, Amounts, BatchDepositRequestCoin, + DenomKeyType, + DenominationInfo, DenominationPubKey, EddsaPrivateKeyString, EddsaPublicKeyString, @@ -47,6 +48,7 @@ import { codecForBatchDepositSuccess, encodeCrock, getRandomBytes, + hashDenomPub, hashWire, j2s, parsePaytoUri, @@ -58,7 +60,6 @@ import { } from "@gnu-taler/taler-util/http"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { DenominationRecord } from "./db.js"; -import { isCandidateWithdrawableDenom } from "./denominations.js"; import { ExchangeInfo, downloadExchangeInfo } from "./exchanges.js"; import { getBankStatusUrl, getBankWithdrawalInfo } from "./withdraw.js"; @@ -135,14 +136,14 @@ export async function withdrawCoin(args: { http: HttpRequestLibrary; cryptoApi: TalerCryptoInterface; reserveKeyPair: ReserveKeypair; - denom: DenominationRecord; + denom: DenominationInfo; exchangeBaseUrl: string; }): Promise<CoinInfo> { const { http, cryptoApi, reserveKeyPair, denom, exchangeBaseUrl } = args; const planchet = await cryptoApi.createPlanchet({ coinIndex: 0, denomPub: denom.denomPub, - feeWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw), + feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw), reservePriv: reserveKeyPair.reservePriv, reservePub: reserveKeyPair.reservePub, secretSeed: encodeCrock(getRandomBytes(32)), @@ -152,7 +153,7 @@ export async function withdrawCoin(args: { const sigResp = await cryptoApi.signWithdrawal({ amount: Amounts.stringify(denom.value), - fee: Amounts.stringify(denom.fees.feeWithdraw), + fee: Amounts.stringify(denom.feeWithdraw), coinEvs: [planchet.coinEv], denomsPubHashes: [planchet.denomPubHash], reservePriv: reserveKeyPair.reservePriv, @@ -181,8 +182,8 @@ export async function withdrawCoin(args: { denomSig: ubSig, denomPub: denom.denomPub, denomPubHash: denom.denomPubHash, - feeDeposit: Amounts.stringify(denom.fees.feeDeposit), - feeRefresh: Amounts.stringify(denom.fees.feeRefresh), + feeDeposit: Amounts.stringify(denom.feeDeposit), + feeRefresh: Amounts.stringify(denom.feeRefresh), exchangeBaseUrl: args.exchangeBaseUrl, maxAge: AgeRestriction.AGE_UNRESTRICTED, }; @@ -194,11 +195,48 @@ export function findDenomOrThrow( exchangeInfo: ExchangeInfo, amount: AmountString, options: FindDenomOptions = {}, -): DenominationRecord { - for (const d of exchangeInfo.keys.currentDenominations) { - const value: AmountJson = Amounts.parseOrThrow(d.value); - if (Amounts.cmp(value, amount) === 0 && isCandidateWithdrawableDenom(d)) { - return d; +): DenominationInfo { + let ageMask = 0; + for (const denomFamily of exchangeInfo.keys.denominations) { + switch (denomFamily.cipher) { + case "CS": + case "CS+age_restricted": { + logger.warn("Clause-Schnorr denominations not supported"); + continue; + } + case "RSA": + case "RSA+age_restricted": { + if (denomFamily.cipher === "RSA+age_restricted") { + ageMask = denomFamily.age_mask; + } + for (const denom of denomFamily.denoms) { + const denomPub: DenominationPubKey = { + age_mask: ageMask, + cipher: DenomKeyType.Rsa, + rsa_public_key: denom.rsa_pub, + }; + const denomPubHash = encodeCrock(hashDenomPub(denomPub)); + const di: DenominationInfo = { + denomPub, + denomPubHash, + exchangeBaseUrl: exchangeInfo.keys.base_url, + exchangeMasterPub: exchangeInfo.keys.master_public_key, + feeDeposit: denomFamily.fee_deposit, + feeRefresh: denomFamily.fee_refresh, + feeRefund: denomFamily.fee_refund, + feeWithdraw: denomFamily.fee_withdraw, + stampExpireDeposit: denom.stamp_expire_deposit, + stampExpireLegal: denom.stamp_expire_legal, + stampExpireWithdraw: denom.stamp_expire_withdraw, + stampStart: denom.stamp_start, + value: denomFamily.value, + isOffered: true, + isLost: denom.lost ?? false, + masterSig: denom.master_sig, + }; + return di; + } + } } } throw new Error("no matching denomination found"); @@ -322,7 +360,7 @@ export async function refreshCoin(req: { http: HttpRequestLibrary; cryptoApi: TalerCryptoInterface; oldCoin: CoinInfo; - newDenoms: DenominationRecord[]; + newDenoms: DenominationInfo[]; }): Promise<void> { const { cryptoApi, oldCoin, http } = req; const refreshSessionSeed = encodeCrock(getRandomBytes(64)); @@ -337,7 +375,7 @@ export async function refreshCoin(req: { count: 1, denomPub: x.denomPub, denomPubHash: x.denomPubHash, - feeWithdraw: x.fees.feeWithdraw, + feeWithdraw: x.feeWithdraw, value: x.value, })), meltCoinMaxAge: oldCoin.maxAge, diff --git a/packages/taler-wallet-core/src/denominations.ts b/packages/taler-wallet-core/src/denominations.ts @@ -488,7 +488,7 @@ export function isWithdrawableDenom(d: DenominationRecord): boolean { default: assertUnreachable(d.verificationStatus); } - return isCandidateWithdrawableDenom(d); + return isCandidateWithdrawableDenomRec(d); } /** @@ -498,7 +498,9 @@ export function isWithdrawableDenom(d: DenominationRecord): boolean { * Denominations with an unverified signature * are considered candidates. */ -export function isCandidateWithdrawableDenom(d: DenominationRecord): boolean { +export function isCandidateWithdrawableDenomRec( + d: DenominationRecord, +): boolean { const now = AbsoluteTime.now(); const start = AbsoluteTime.fromProtocolTimestamp( timestampProtocolFromDb(d.stampStart), @@ -526,6 +528,22 @@ export function isCandidateWithdrawableDenom(d: DenominationRecord): boolean { return started && stillOkay && !d.isRevoked && d.isOffered && !d.isLost; } +export function isCandidateWithdrawableDenomInfo(d: DenominationInfo): boolean { + const now = AbsoluteTime.now(); + const start = AbsoluteTime.fromProtocolTimestamp(d.stampStart); + const started = AbsoluteTime.cmp(now, start) >= 0; + const withdrawExpire = AbsoluteTime.fromProtocolTimestamp( + d.stampExpireWithdraw, + ); + const lastPossibleWithdraw = AbsoluteTime.subtractDuraction( + withdrawExpire, + Duration.fromSpec({ minutes: 5 }), + ); + const remaining = Duration.getRemaining(lastPossibleWithdraw, now); + const stillOkay = remaining.d_ms !== 0; + return started && stillOkay && d.isOffered && !d.isLost; +} + export async function isValidDenomRecord( wex: WalletExecutionContext, exchangeMasterPub: string, diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -33,7 +33,6 @@ import { CancellationToken, CoinRefreshRequest, CoinStatus, - CurrencySpecification, DeleteExchangeRequest, DenomKeyType, DenomLossEventType, @@ -47,12 +46,11 @@ import { ExchangeDetailedResponse, ExchangeEntryState, ExchangeGlobalFees, + ExchangeKeysResponse, ExchangeListItem, - ExchangeSignKeyJson, ExchangeTosStatus, ExchangeUpdateStatus, ExchangeWalletKycStatus, - ExchangeWireAccount, ExchangesListResponse, FeeDescription, GetExchangeEntryByUrlRequest, @@ -91,7 +89,6 @@ import { WalletNotification, WireFee, WireFeeMap, - WireFeesJson, WireInfo, ZeroLimitedOperation, assertUnreachable, @@ -104,7 +101,6 @@ import { durationMul, encodeCrock, getRandomBytes, - hashDenomPub, hashFullPaytoUri, j2s, makeErrorDetail, @@ -115,13 +111,13 @@ import { } from "@gnu-taler/taler-util"; import { HttpRequestLibrary, - getExpiry, readResponseJsonOrThrow, readSuccessResponseJsonOrThrow, readSuccessResponseTextOrThrow, readTalerErrorResponse, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; +import { hashDenomPub } from "../../taler-util/src/taler-crypto.js"; import { PendingTaskType, TaskIdStr, @@ -140,8 +136,10 @@ import { getExchangeUpdateStatusFromRecord, } from "./common.js"; import { + DenomFamilyParams, DenomLossEventRecord, DenomLossStatus, + DenominationFamilyRecord, DenominationRecord, DenominationVerificationStatus, ExchangeDetailsRecord, @@ -156,6 +154,7 @@ import { WalletDbHelpers, WalletDbReadOnlyTransaction, WalletDbReadWriteTransaction, + hashDenomFamilyParams, timestampAbsoluteFromDb, timestampOptionalPreciseFromDb, timestampPreciseFromDb, @@ -165,7 +164,7 @@ import { } from "./db.js"; import { createTimeline, - isCandidateWithdrawableDenom, + isCandidateWithdrawableDenomRec, selectBestForOverlappingDenominations, selectMinimumFee, } from "./denominations.js"; @@ -706,7 +705,7 @@ export async function forgetExchangeTermsOfService( async function validateWireInfo( wex: WalletExecutionContext, versionCurrent: number, - wireInfo: ExchangeKeysDownloadSuccessResult, + wireInfo: ExchangeKeysResponse, masterPublicKey: string, ): Promise<WireInfo> { for (const a of wireInfo.accounts) { @@ -732,9 +731,9 @@ async function validateWireInfo( } logger.trace("account validation done"); const feesForType: WireFeeMap = {}; - for (const wireMethod of Object.keys(wireInfo.wireFees)) { + for (const wireMethod of Object.keys(wireInfo.wire_fees)) { const feeList: WireFee[] = []; - for (const x of wireInfo.wireFees[wireMethod]) { + for (const x of wireInfo.wire_fees[wireMethod]) { const startStamp = x.start_date; const endStamp = x.end_date; const fee: WireFee = { @@ -903,33 +902,8 @@ async function provideExchangeRecordInTx( return { exchange, exchangeDetails }; } -// FIXME: Get rid of this, return response directly instead. -export interface ExchangeKeysDownloadSuccessResult { - baseUrl: string; - masterPublicKey: string; - currency: string; - auditors: ExchangeAuditor[]; - currentDenominations: DenominationRecord[]; - protocolVersion: string; - signingKeys: ExchangeSignKeyJson[]; - reserveClosingDelay: TalerProtocolDuration; - expiry: TalerProtocolTimestamp; - recoup: Recoup[]; - listIssueDate: TalerProtocolTimestamp; - globalFees: GlobalFees[]; - accounts: ExchangeWireAccount[]; - wireFees: { [methodName: string]: WireFeesJson[] }; - currencySpecification?: CurrencySpecification; - walletBalanceLimits: AmountString[] | undefined; - hardLimits: AccountLimit[] | undefined; - zeroLimits: ZeroLimitedOperation[] | undefined; - bankComplianceLanguage: string | undefined; - directDepositDisabled: boolean | undefined; - shoppingUrl: string | undefined; -} - export type ExchangeKeysDownloadResult = - | { type: "ok"; res: ExchangeKeysDownloadSuccessResult } + | { type: "ok"; res: ExchangeKeysResponse } | { type: "version-incompatible"; exchangeProtocolVersion: string }; /** @@ -1014,12 +988,12 @@ async function downloadExchangeKeysInfo( }; } - const exchangeKeysResponseUnchecked = await readSuccessResponseJsonOrThrow( + const exchangeKeysResponseParsed = await readSuccessResponseJsonOrThrow( resp, codecForExchangeKeysResponse(), ); - if (exchangeKeysResponseUnchecked.denominations.length === 0) { + if (exchangeKeysResponseParsed.denominations.length === 0) { throw TalerError.fromDetail( TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT, { @@ -1029,101 +1003,9 @@ async function downloadExchangeKeysInfo( ); } - const currency = exchangeKeysResponseUnchecked.currency; - - const currentDenominations: DenominationRecord[] = []; - - for (const denomGroup of exchangeKeysResponseUnchecked.denominations) { - switch (denomGroup.cipher) { - case "RSA": - case "RSA+age_restricted": { - let ageMask = 0; - if (denomGroup.cipher === "RSA+age_restricted") { - ageMask = denomGroup.age_mask; - } - for (const denomIn of denomGroup.denoms) { - const denomPub: DenominationPubKey = { - age_mask: ageMask, - cipher: DenomKeyType.Rsa, - rsa_public_key: denomIn.rsa_pub, - }; - const denomPubHash = encodeCrock(hashDenomPub(denomPub)); - const value = Amounts.parseOrThrow(denomGroup.value); - const rec: DenominationRecord = { - denomPub, - denomPubHash, - exchangeBaseUrl: baseUrl, - exchangeMasterPub: exchangeKeysResponseUnchecked.master_public_key, - isOffered: true, - isRevoked: false, - isLost: denomIn.lost ?? false, - value: Amounts.stringify(value), - currency: value.currency, - stampExpireDeposit: timestampProtocolToDb( - denomIn.stamp_expire_deposit, - ), - stampExpireLegal: timestampProtocolToDb(denomIn.stamp_expire_legal), - stampExpireWithdraw: timestampProtocolToDb( - denomIn.stamp_expire_withdraw, - ), - stampStart: timestampProtocolToDb(denomIn.stamp_start), - verificationStatus: DenominationVerificationStatus.Unverified, - masterSig: denomIn.master_sig, - fees: { - feeDeposit: Amounts.stringify(denomGroup.fee_deposit), - feeRefresh: Amounts.stringify(denomGroup.fee_refresh), - feeRefund: Amounts.stringify(denomGroup.fee_refund), - feeWithdraw: Amounts.stringify(denomGroup.fee_withdraw), - }, - }; - currentDenominations.push(rec); - } - break; - } - case "CS+age_restricted": - case "CS": - logger.warn("Clause-Schnorr denominations not supported"); - continue; - default: - logger.warn( - `denomination type ${(denomGroup as any).cipher} not supported`, - ); - continue; - } - } - - const res: ExchangeKeysDownloadSuccessResult = { - masterPublicKey: exchangeKeysResponseUnchecked.master_public_key, - currency, - baseUrl: exchangeKeysResponseUnchecked.base_url, - auditors: exchangeKeysResponseUnchecked.auditors, - currentDenominations, - protocolVersion: exchangeKeysResponseUnchecked.version, - signingKeys: exchangeKeysResponseUnchecked.signkeys, - reserveClosingDelay: exchangeKeysResponseUnchecked.reserve_closing_delay, - expiry: AbsoluteTime.toProtocolTimestamp( - getExpiry(resp, { - minDuration: Duration.fromSpec({ hours: 1 }), - }), - ), - recoup: exchangeKeysResponseUnchecked.recoup ?? [], - listIssueDate: exchangeKeysResponseUnchecked.list_issue_date, - globalFees: exchangeKeysResponseUnchecked.global_fees, - accounts: exchangeKeysResponseUnchecked.accounts, - wireFees: exchangeKeysResponseUnchecked.wire_fees, - currencySpecification: exchangeKeysResponseUnchecked.currency_specification, - walletBalanceLimits: - exchangeKeysResponseUnchecked.wallet_balance_limit_without_kyc, - hardLimits: exchangeKeysResponseUnchecked.hard_limits, - zeroLimits: exchangeKeysResponseUnchecked.zero_limits, - bankComplianceLanguage: - exchangeKeysResponseUnchecked.bank_compliance_language, - shoppingUrl: exchangeKeysResponseUnchecked.shopping_url, - directDepositDisabled: exchangeKeysResponseUnchecked.disable_direct_deposit, - }; return { type: "ok", - res, + res: exchangeKeysResponseParsed, }; } @@ -1216,7 +1098,7 @@ async function checkExchangeEntryOutdated( await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl); logger.trace(`exchange entry has ${denoms.length} denominations`); for (const denom of denoms) { - const denomOkay = isCandidateWithdrawableDenom(denom); + const denomOkay = isCandidateWithdrawableDenomRec(denom); if (denomOkay) { numOkay++; } @@ -1603,11 +1485,9 @@ export async function waitReadyExchange( return res; } -function checkPeerPaymentsDisabled( - keysInfo: ExchangeKeysDownloadSuccessResult, -): boolean { +function checkPeerPaymentsDisabled(keysInfo: ExchangeKeysResponse): boolean { const now = AbsoluteTime.now(); - for (let gf of keysInfo.globalFees) { + for (let gf of keysInfo.global_fees) { const isActive = AbsoluteTime.isBetween( now, AbsoluteTime.fromProtocolTimestamp(gf.start_date), @@ -1622,8 +1502,8 @@ function checkPeerPaymentsDisabled( return true; } -function checkNoFees(keysInfo: ExchangeKeysDownloadSuccessResult): boolean { - for (const gf of keysInfo.globalFees) { +function checkNoFees(keysInfo: ExchangeKeysResponse): boolean { + for (const gf of keysInfo.global_fees) { if (!Amounts.isZero(gf.account_fee)) { return false; } @@ -1634,21 +1514,21 @@ function checkNoFees(keysInfo: ExchangeKeysDownloadSuccessResult): boolean { return false; } } - for (const denom of keysInfo.currentDenominations) { - if (!Amounts.isZero(denom.fees.feeWithdraw)) { + for (const denomFamily of keysInfo.denominations) { + if (!Amounts.isZero(denomFamily.fee_withdraw)) { return false; } - if (!Amounts.isZero(denom.fees.feeDeposit)) { + if (!Amounts.isZero(denomFamily.fee_deposit)) { return false; } - if (!Amounts.isZero(denom.fees.feeRefund)) { + if (!Amounts.isZero(denomFamily.fee_refund)) { return false; } - if (!Amounts.isZero(denom.fees.feeRefresh)) { + if (!Amounts.isZero(denomFamily.fee_refresh)) { return false; } } - for (const wft of Object.values(keysInfo.wireFees)) { + for (const wft of Object.values(keysInfo.wire_fees)) { for (const wf of wft) { if (!Amounts.isZero(wf.wire_fee)) { return false; @@ -1857,15 +1737,15 @@ export async function updateExchangeFromUrlHandler( wex.ws.devExperimentState.fakeMasterPub?.get(exchangeBaseUrl); if (fakePub) { logger.warn("devexperiment: faking exchange pub"); - keysInfoRes.res.masterPublicKey = fakePub.fakeMasterPub; + keysInfoRes.res.master_public_key = fakePub.fakeMasterPub; } } const keysInfo = keysInfoRes.res; - if (keysInfo.baseUrl != exchangeBaseUrl) { + if (keysInfo.base_url != exchangeBaseUrl) { const plan = wex.ws.exchangeMigrationPlan.get(exchangeBaseUrl); - if (plan?.newExchangeBaseUrl === keysInfo.baseUrl) { + if (plan?.newExchangeBaseUrl === keysInfo.base_url) { const newExchangeClient = walletExchangeClient( plan.newExchangeBaseUrl, wex, @@ -1880,7 +1760,7 @@ export async function updateExchangeFromUrlHandler( TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH, { urlWallet: exchangeBaseUrl, - urlExchange: keysInfo.baseUrl, + urlExchange: keysInfo.base_url, }, ); return TaskRunResult.error(errorDetail); @@ -1897,13 +1777,13 @@ export async function updateExchangeFromUrlHandler( TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH, { urlWallet: exchangeBaseUrl, - urlExchange: keysInfo.baseUrl, + urlExchange: keysInfo.base_url, }, ); return TaskRunResult.error(errorDetail); } - const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion); + const version = LibtoolVersion.parseVersion(keysInfo.version); if (!version) { // Should have been validated earlier. throw Error("unexpected invalid version"); @@ -1913,13 +1793,13 @@ export async function updateExchangeFromUrlHandler( wex, version.current, keysInfo, - keysInfo.masterPublicKey, + keysInfo.master_public_key, ); const globalFees = await validateGlobalFees( wex, - keysInfo.globalFees, - keysInfo.masterPublicKey, + keysInfo.global_fees, + keysInfo.master_public_key, ); logger.trace("finished validating exchange /wire info"); @@ -1929,15 +1809,68 @@ export async function updateExchangeFromUrlHandler( logger.trace("updating exchange info in database"); let ageMask = 0; - for (const x of keysInfo.currentDenominations) { - if (isCandidateWithdrawableDenom(x) && x.denomPub.age_mask != 0) { - ageMask = x.denomPub.age_mask; - break; - } - } + let noFees = checkNoFees(keysInfo); let peerPaymentsDisabled = checkPeerPaymentsDisabled(keysInfo); + const denomInfos: DenominationInfo[] = []; + // Se of all current denoms offered by exchange + const currentDenomSet = new Set<string>(); + const fpMap = new Map<DenominationInfo, string>(); + for (const denomFamily of keysInfo.denominations) { + switch (denomFamily.cipher) { + case "CS": + case "CS+age_restricted": { + logger.warn("Clause-Schnorr denominations not supported"); + continue; + } + case "RSA": + case "RSA+age_restricted": { + if (denomFamily.cipher === "RSA+age_restricted") { + ageMask = denomFamily.age_mask; + } + for (const denom of denomFamily.denoms) { + const denomPub: DenominationPubKey = { + age_mask: ageMask, + cipher: DenomKeyType.Rsa, + rsa_public_key: denom.rsa_pub, + }; + const denomPubHash = encodeCrock(hashDenomPub(denomPub)); + const fp: DenomFamilyParams = { + exchangeBaseUrl: exchangeBaseUrl, + exchangeMasterPub: keysInfo.master_public_key, + feeDeposit: denomFamily.fee_deposit, + feeRefresh: denomFamily.fee_refresh, + feeRefund: denomFamily.fee_refund, + feeWithdraw: denomFamily.fee_withdraw, + value: denomFamily.value, + }; + const di: DenominationInfo = { + denomPub, + denomPubHash, + exchangeBaseUrl: exchangeBaseUrl, + exchangeMasterPub: keysInfo.master_public_key, + feeDeposit: denomFamily.fee_deposit, + feeRefresh: denomFamily.fee_refresh, + feeRefund: denomFamily.fee_refund, + feeWithdraw: denomFamily.fee_withdraw, + stampExpireDeposit: denom.stamp_expire_deposit, + stampExpireLegal: denom.stamp_expire_legal, + stampExpireWithdraw: denom.stamp_expire_withdraw, + stampStart: denom.stamp_start, + value: denomFamily.value, + isOffered: true, + isLost: denom.lost ?? false, + masterSig: denom.master_sig, + }; + denomInfos.push(di); + fpMap.set(di, hashDenomFamilyParams(fp)); + currentDenomSet.add(denomPubHash); + } + } + } + } + const updated = await wex.db.runReadWriteTx( { storeNames: [ @@ -1945,6 +1878,7 @@ export async function updateExchangeFromUrlHandler( "exchangeDetails", "exchangeSignKeys", "denominations", + "denominationFamilies", "coins", "refreshGroups", "recoupGroups", @@ -1972,7 +1906,7 @@ export async function updateExchangeFromUrlHandler( let detailsIncompatible = false; let conflictHint: string | undefined = undefined; if (existingDetails) { - if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) { + if (existingDetails.masterPublicKey !== keysInfo.master_public_key) { detailsIncompatible = true; detailsPointerChanged = true; conflictHint = "master public key changed"; @@ -2013,22 +1947,22 @@ export async function updateExchangeFromUrlHandler( const newDetails: ExchangeDetailsRecord = { auditors: keysInfo.auditors, currency: keysInfo.currency, - masterPublicKey: keysInfo.masterPublicKey, - protocolVersionRange: keysInfo.protocolVersion, - reserveClosingDelay: keysInfo.reserveClosingDelay, + masterPublicKey: keysInfo.master_public_key, + protocolVersionRange: keysInfo.version, + reserveClosingDelay: keysInfo.reserve_closing_delay, globalFees, exchangeBaseUrl: r.baseUrl, wireInfo, ageMask, - walletBalanceLimits: keysInfo.walletBalanceLimits, - hardLimits: keysInfo.hardLimits, - zeroLimits: keysInfo.zeroLimits, - bankComplianceLanguage: keysInfo.bankComplianceLanguage, - shoppingUrl: keysInfo.shoppingUrl, + walletBalanceLimits: keysInfo.wallet_balance_limit_without_kyc, + hardLimits: keysInfo.hard_limits, + zeroLimits: keysInfo.zero_limits, + bankComplianceLanguage: keysInfo.bank_compliance_language, + shoppingUrl: keysInfo.shopping_url, }; r.noFees = noFees; r.peerPaymentsDisabled = peerPaymentsDisabled; - r.directDepositDisabled = keysInfo.directDepositDisabled; + r.directDepositDisabled = keysInfo.disable_direct_deposit; switch (tosMeta.type) { case "not-found": r.tosCurrentEtag = undefined; @@ -2043,7 +1977,11 @@ export async function updateExchangeFromUrlHandler( r.lastUpdate = timestampPreciseToDb(TalerPreciseTimestamp.now()); r.nextUpdateStamp = timestampPreciseToDb( AbsoluteTime.toPreciseTimestamp( - AbsoluteTime.fromProtocolTimestamp(keysInfo.expiry), + // FIXME! + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ hours: 2 }), + ), ), ); // New denominations might be available. @@ -2062,11 +2000,11 @@ export async function updateExchangeFromUrlHandler( r.cachebreakNextUpdate = false; await tx.exchanges.put(r); - if (keysInfo.currencySpecification) { + if (keysInfo.currency_specification) { // Since this is the per-exchange currency info, // we update it when the exchange changes it. await WalletDbHelpers.upsertCurrencyInfo(tx, { - currencySpec: keysInfo.currencySpecification, + currencySpec: keysInfo.currency_specification, scopeInfo: { type: ScopeType.Exchange, currency: newDetails.currency, @@ -2082,7 +2020,7 @@ export async function updateExchangeFromUrlHandler( "exchange details key is not a number", ); - for (const sk of keysInfo.signingKeys) { + for (const sk of keysInfo.signkeys) { // FIXME: validate signing keys before inserting them await tx.exchangeSignKeys.put({ exchangeDetailsRowId: drRowId.key, @@ -2105,11 +2043,78 @@ export async function updateExchangeFromUrlHandler( } logger.trace("updating denominations in database"); - const currentDenomSet = new Set<string>( - keysInfo.currentDenominations.map((x) => x.denomPubHash), - ); - for (const currentDenom of keysInfo.currentDenominations) { + for (const currentDenom of denomInfos) { + // FIXME: Check if we really already need the denomination. + const familyParamHash = fpMap.get(currentDenom); + if (!familyParamHash) { + logger.error("missing family param hash"); + continue; + } + let fpRec: DenominationFamilyRecord | undefined = + await tx.denominationFamilies.indexes.byFamilyParamsHash.get( + familyParamHash, + ); + let denominationFamilySerial; + if (fpRec == null) { + const fp: DenomFamilyParams = { + exchangeBaseUrl: exchangeBaseUrl, + exchangeMasterPub: keysInfo.master_public_key, + feeDeposit: currentDenom.feeDeposit, + feeRefresh: currentDenom.feeRefresh, + feeRefund: currentDenom.feeRefund, + feeWithdraw: currentDenom.feeWithdraw, + value: currentDenom.value, + }; + fpRec = { + familyParams: fp, + familyParamsHash: familyParamHash, + }; + const insRes = await tx.denominationFamilies.put(fpRec); + denominationFamilySerial = insRes.key; + } else { + denominationFamilySerial = fpRec.denominationFamilySerial; + } + + checkDbInvariant( + typeof denominationFamilySerial === "number", + "denominationFamilySerial", + ); + + // First, find denom family + + const denomRec: DenominationRecord = { + currency: keysInfo.currency, + denominationFamilySerial, + denomPub: currentDenom.denomPub, + denomPubHash: currentDenom.denomPubHash, + exchangeBaseUrl: exchangeBaseUrl, + exchangeMasterPub: keysInfo.master_public_key, + fees: { + feeDeposit: currentDenom.feeDeposit, + feeRefresh: currentDenom.feeRefresh, + feeRefund: currentDenom.feeRefund, + feeWithdraw: currentDenom.feeWithdraw, + }, + isOffered: currentDenom.isOffered, + // If revoked, should not show up in keys response. + isRevoked: false, + masterSig: currentDenom.masterSig, + stampExpireDeposit: timestampProtocolToDb( + currentDenom.stampExpireDeposit, + ), + stampExpireLegal: timestampProtocolToDb( + currentDenom.stampExpireLegal, + ), + stampExpireWithdraw: timestampProtocolToDb( + currentDenom.stampExpireWithdraw, + ), + stampStart: timestampProtocolToDb(currentDenom.stampStart), + value: currentDenom.value, + verificationStatus: DenominationVerificationStatus.Unverified, + isLost: currentDenom.isLost, + }; + const oldDenom = oldDenomByDph.get(currentDenom.denomPubHash); if (oldDenom) { // FIXME: Do consistency check, report to auditor if necessary. @@ -2121,10 +2126,10 @@ export async function updateExchangeFromUrlHandler( `marking denomination ${currentDenom.denomPubHash} of ${exchangeBaseUrl} as lost`, ); oldDenom.isLost = true; - await tx.denominations.put(currentDenom); + await tx.denominations.put(denomRec); } } else { - await tx.denominations.put(currentDenom); + await tx.denominations.put(denomRec); } } @@ -2160,7 +2165,9 @@ export async function updateExchangeFromUrlHandler( exchangeBaseUrl, ); - await handleRecoup(wex, tx, exchangeBaseUrl, keysInfo.recoup); + if (keysInfo.recoup != null) { + await handleRecoup(wex, tx, exchangeBaseUrl, keysInfo.recoup); + } const newExchangeState = getExchangeState(r); @@ -2821,7 +2828,7 @@ export async function getExchangeTos( * obtained by requesting /keys. */ export interface ExchangeInfo { - keys: ExchangeKeysDownloadSuccessResult; + keys: ExchangeKeysResponse; } /** @@ -3198,6 +3205,20 @@ async function purgeExchange( } } + { + const denomFamilyRecs = + await tx.denominationFamilies.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); + for (const rec of denomFamilyRecs) { + checkDbInvariant( + rec.denominationFamilySerial != null, + "denominationFamilySerial", + ); + await tx.denominationFamilies.delete(rec.denominationFamilySerial); + } + } + // Always delete withdrawals, even if no explicit // transaction deletion was requested. { diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts @@ -43,8 +43,8 @@ export { exportDb, importDb, WalletStoresV1, + WithdrawalGroupStatus, } from "./db.js"; export { DbAccess } from "./query.js"; -// FIXME: Required for a test in harness, but we should remove it. -export { DenominationRecord } from "./db.js"; +export { TaskRunResult, TaskRunResultType } from "./common.js"; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -331,7 +331,7 @@ import { walletDbFixups, } from "./db.js"; import { - isCandidateWithdrawableDenom, + isCandidateWithdrawableDenomRec, isWithdrawableDenom, } from "./denominations.js"; import { @@ -769,7 +769,7 @@ async function dumpCoins(wex: WalletExecutionContext): Promise<CoinDumpJson> { const historyRec = await tx.coinHistory.get(c.coinPub); coinsJson.coins.push({ coinPub: c.coinPub, - denomPub: denomInfo.denomPub, + denomPub: denom.denomPub, denomPubHash: c.denomPubHash, denomValue: denom.value, exchangeBaseUrl: c.exchangeBaseUrl, @@ -2043,7 +2043,7 @@ export async function handleGetDiagnostics( if (isWithdrawableDenom(d)) { numWithdrawableDenoms++; } - if (isCandidateWithdrawableDenom(d)) { + if (isCandidateWithdrawableDenomRec(d)) { numCandidateWithdrawableDenoms++; } } diff --git a/packages/taler-wallet-core/src/withdraw.test.ts b/packages/taler-wallet-core/src/withdraw.test.ts @@ -32,6 +32,7 @@ test("withdrawal selection bug repro", (t) => { const denoms: DenominationRecord[] = [ { + denominationFamilySerial: 0, denomPub: { cipher: DenomKeyType.Rsa, rsa_public_key: @@ -85,6 +86,7 @@ test("withdrawal selection bug repro", (t) => { value: "KUDOS:1000" as AmountString, }, { + denominationFamilySerial: 0, denomPub: { cipher: DenomKeyType.Rsa, rsa_public_key: @@ -139,6 +141,7 @@ test("withdrawal selection bug repro", (t) => { currency: "KUDOS", }, { + denominationFamilySerial: 0, denomPub: { cipher: DenomKeyType.Rsa, rsa_public_key: @@ -192,6 +195,7 @@ test("withdrawal selection bug repro", (t) => { currency: "KUDOS", }, { + denominationFamilySerial: 0, denomPub: { cipher: DenomKeyType.Rsa, rsa_public_key: @@ -246,6 +250,7 @@ test("withdrawal selection bug repro", (t) => { currency: "KUDOS", }, { + denominationFamilySerial: 0, denomPub: { cipher: DenomKeyType.Rsa, rsa_public_key: @@ -303,6 +308,7 @@ test("withdrawal selection bug repro", (t) => { currency: "KUDOS", }, { + denominationFamilySerial: 0, denomPub: { cipher: DenomKeyType.Rsa, rsa_public_key: diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -158,7 +158,7 @@ import { selectWithdrawalDenominations, } from "./denomSelection.js"; import { - isCandidateWithdrawableDenom, + isCandidateWithdrawableDenomRec, isValidDenomRecord, isWithdrawableDenom, } from "./denominations.js"; @@ -1399,7 +1399,7 @@ export async function getWithdrawableDenomsTx( if (denom.currency !== currency) { continue; } - if (!isCandidateWithdrawableDenom(denom)) { + if (!isCandidateWithdrawableDenomRec(denom)) { continue; } if ( @@ -2099,7 +2099,7 @@ export async function updateWithdrawalDenomsForExchange( ); denominations = allDenoms .filter((d) => d.currency === exchangeDetails.currency) - .filter((d) => isCandidateWithdrawableDenom(d)); + .filter((d) => isCandidateWithdrawableDenomRec(d)); } return { exchangeDetails, denominations }; },