taler-typescript-core

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

commit 346005d77e4ba8694902b1dac5a5d278da5ea8d8
parent 3f1a938baf71e6219f3856250f6dae3281b96226
Author: Florian Dold <florian@dold.me>
Date:   Wed, 20 Nov 2024 18:46:08 +0100

wallet-core: fix various issues found by Marc

- consider p2p and pay transactions in balanceIncoming/balanceOutgoing
- reduce log spam
- reduce unnecessary balance change notifications

Diffstat:
Mpackages/taler-util/src/codec.ts | 22++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-common.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-exchange.ts | 47+++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-wallet-core/src/balance.ts | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mpackages/taler-wallet-core/src/exchanges.ts | 58++++++++++++++++++++--------------------------------------
Mpackages/taler-wallet-core/src/pay-merchant.ts | 4++--
Mpackages/taler-wallet-core/src/query.ts | 2+-
Mpackages/taler-wallet-core/src/shepherd.ts | 1-
Mpackages/taler-wallet-core/src/transactions.ts | 33+++++++++++++++++++++++++++------
Mpackages/taler-wallet-core/src/wallet.ts | 3+--
Mpackages/taler-wallet-core/src/withdraw.ts | 2+-
11 files changed, 279 insertions(+), 88 deletions(-)

diff --git a/packages/taler-util/src/codec.ts b/packages/taler-util/src/codec.ts @@ -74,6 +74,7 @@ type SingletonRecord<K extends keyof any, V> = { [Y in K]: V }; interface Prop { name: string; codec: Codec<any>; + deprecated?: boolean; } interface Alternative { @@ -83,6 +84,7 @@ interface Alternative { class ObjectCodecBuilder<OutputType, PartialOutputType> { private propList: Prop[] = []; + private deprecatedProps: Set<string> = new Set(); private _allowExtra: boolean = false; /** @@ -99,6 +101,19 @@ class ObjectCodecBuilder<OutputType, PartialOutputType> { return this as any; } + /** + * Define a deprecated property for the object. + * + * Deprecated properties won't be validated, their presence will + * be validated in TRACE mode. + */ + deprecatedProperty( + x: string, + ): ObjectCodecBuilder<OutputType, PartialOutputType> { + this.deprecatedProps.add(x); + return this as any; + } + allowExtra(): ObjectCodecBuilder<OutputType, PartialOutputType> { this._allowExtra = true; return this; @@ -113,6 +128,7 @@ class ObjectCodecBuilder<OutputType, PartialOutputType> { build(objectDisplayName: string): Codec<PartialOutputType> { const propList = this.propList; const allowExtra = this._allowExtra; + const deprecatedPros = this.deprecatedProps; return { decode(x: any, c?: Context): PartialOutputType { if (!c) { @@ -142,6 +158,12 @@ class ObjectCodecBuilder<OutputType, PartialOutputType> { } if (allowExtra) { obj[prop] = x[prop]; + } else if (deprecatedPros.has(prop)) { + logger.warn( + `Deprecated operty ${prop} for ${objectDisplayName} at ${renderContext( + c, + )}`, + ); } else { logger.warn( `Extra property ${prop} for ${objectDisplayName} at ${renderContext( diff --git a/packages/taler-util/src/types-taler-common.ts b/packages/taler-util/src/types-taler-common.ts @@ -198,6 +198,38 @@ export class WithdrawOperationStatusResponse { confirm_transfer_url?: string; wire_types: string[]; + + // Currency used for the withdrawal. + // MUST be present when amount is absent. + // @since **v2**, may become mandatory in the future. + currency?: string; + + // Minimum amount that the wallet can choose to withdraw. + // Only applicable when the amount is not fixed. + // @since **v4**. + min_amount?: AmountString; + + // Maximum amount that the wallet can choose to withdraw. + // Only applicable when the amount is not fixed. + // @since **v4**. + max_amount?: AmountString; + + // The non-Taler card fees the customer will have + // to pay to the bank / payment service provider + // they are using to make the withdrawal in addition + // to the amount. + // @since **v4** + card_fees?: AmountString; + + // Exchange account selected by the wallet; + // only non-null if status is selected or confirmed. + // @since **v1** + selected_exchange_account?: string; + + // Reserve public key selected by the exchange, + // only non-null if status is selected or confirmed. + // @since **v1** + selected_reserve_pub?: EddsaPublicKey; } export type LitAmountString = `${string}:${number}`; @@ -225,6 +257,10 @@ export const codecForLibtoolVersion = codecForString; export const codecForCurrencyName = codecForString; // FIXME: implement this codec export const codecForDecimalNumber = codecForString; +// FIXME: implement this codec +export const codecForEddsaPublicKey = codecForString; +// FIXME: implement this codec +export const codecForEddsaSignature = codecForString; export const codecForInternationalizedString = (): Codec<InternationalizedString> => codecForMap(codecForString()); @@ -249,6 +285,12 @@ export const codecForWithdrawOperationStatusResponse = .property("suggested_exchange", codecOptional(codecForString())) .property("confirm_transfer_url", codecOptional(codecForString())) .property("wire_types", codecForList(codecForString())) + .property("currency", codecOptional(codecForString())) + .property("card_fees", codecOptional(codecForAmountString())) + .property("min_amount", codecOptional(codecForAmountString())) + .property("max_amount", codecOptional(codecForAmountString())) + .property("selected_exchange_account", codecOptional(codecForString())) + .property("selected_reserve_pub", codecOptional(codecForEddsaPublicKey())) .build("WithdrawOperationStatusResponse"); export const codecForCurrencySpecificiation = @@ -259,6 +301,7 @@ export const codecForCurrencySpecificiation = .property("num_fractional_normal_digits", codecForNumber()) .property("num_fractional_trailing_zero_digits", codecForNumber()) .property("alt_unit_names", codecForMap(codecForString())) + .deprecatedProperty("currency") .build("CurrencySpecification"); export interface TalerCommonConfigResponse { diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -67,6 +67,8 @@ import { Timestamp, WireSalt, codecForAccessToken, + codecForEddsaPublicKey, + codecForEddsaSignature, codecForInternationalizedString, codecForURLString, } from "./types-taler-common.js"; @@ -469,6 +471,40 @@ export interface ExchangeKeysJson { // operations. // Since protocol **v21**. zero_limits?: ZeroLimitedOperation[]; + + // Absolute cost offset for the STEFAN curve used + // to (over) approximate fees payable by amount. + stefan_abs: AmountString; + + // Factor to multiply the logarithm of the amount + // with to (over) approximate fees payable by amount. + // Note that the total to be paid is first to be + // divided by the smallest denomination to obtain + // the value that the logarithm is to be taken of. + stefan_log: AmountString; + + // Linear cost factor for the STEFAN curve used + // to (over) approximate fees payable by amount. + // + // Note that this is a scalar, as it is multiplied + // with the actual amount. + stefan_lin: number; + + // List of exchanges that this exchange is partnering + // with to enable wallet-to-wallet transfers. + wads: any; + + // Compact EdDSA signature (binary-only) over the + // contatentation of all of the master_sigs (in reverse + // chronological order by group) in the arrays under + // "denominations". Signature of TALER_ExchangeKeySetPS + exchange_sig: EddsaSignature; + + // Public EdDSA key of the exchange that was used to generate the signature. + // Should match one of the exchange's signing keys from signkeys. It is given + // explicitly as the client might otherwise be confused by clock skew as to + // which signing key was used for the exchange_sig. + exchange_pub: EddsaPublicKey; } export interface ExchangeMeltRequest { @@ -905,12 +941,19 @@ export const codecForExchangeKeysJson = (): Codec<ExchangeKeysJson> => "wallet_balance_limit_without_kyc", codecOptional(codecForList(codecForAmountString())), ) + .property("stefan_abs", codecForAmountString()) + .property("stefan_log", codecForAmountString()) + .property("stefan_lin", codecForNumber()) + .property("wads", codecForAny()) + .deprecatedProperty("rewards_allowed") + .property("exchange_pub", codecForEddsaPublicKey()) + .property("exchange_sig", codecForEddsaSignature()) .build("ExchangeKeysJson"); export const codecForWireFeesJson = (): Codec<WireFeesJson> => buildCodecForObject<WireFeesJson>() - .property("wire_fee", codecForString()) - .property("closing_fee", codecForString()) + .property("wire_fee", codecForAmountString()) + .property("closing_fee", codecForAmountString()) .property("sig", codecForString()) .property("start_date", codecForTimestamp) .property("end_date", codecForTimestamp) diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -71,7 +71,11 @@ import { ExchangeEntryDbRecordStatus, OPERATION_STATUS_NONFINAL_FIRST, OPERATION_STATUS_NONFINAL_LAST, + PeerPullDebitRecordStatus, + PeerPullPaymentCreditStatus, + PeerPushCreditStatus, PeerPushDebitStatus, + PurchaseStatus, RefreshGroupRecord, RefreshOperationStatus, WalletDbReadOnlyTransaction, @@ -285,6 +289,11 @@ export async function getBalancesInsideTransaction( "globalCurrencyAuditors", "globalCurrencyExchanges", "peerPushDebit", + "peerPushCredit", + "peerPullCredit", + "peerPullDebit", + "purchases", + "coins", ] >, ): Promise<BalancesResponse> { @@ -319,22 +328,24 @@ export async function getBalancesInsideTransaction( } }); - await tx.refreshGroups.iter().forEachAsync(async (r) => { - switch (r.operationStatus) { - case RefreshOperationStatus.Pending: - case RefreshOperationStatus.Suspended: - break; - default: + await tx.refreshGroups.indexes.byStatus + .iter(keyRangeActive) + .forEachAsync(async (r) => { + switch (r.operationStatus) { + case RefreshOperationStatus.Pending: + case RefreshOperationStatus.Suspended: + break; + default: + return; + } + const perExchange = r.infoPerExchange; + if (!perExchange) { return; - } - const perExchange = r.infoPerExchange; - if (!perExchange) { - return; - } - for (const [e, x] of Object.entries(perExchange)) { - await balanceStore.addAvailable(r.currency, e, x.outputEffective); - } - }); + } + for (const [e, x] of Object.entries(perExchange)) { + await balanceStore.addAvailable(r.currency, e, x.outputEffective); + } + }); await tx.withdrawalGroups.indexes.byStatus .iter(keyRangeActive) @@ -439,6 +450,94 @@ export async function getBalancesInsideTransaction( } }); + await tx.peerPushCredit.indexes.byStatus + .iter(keyRangeActive) + .forEachAsync(async (rec) => { + switch (rec.status) { + case PeerPushCreditStatus.PendingMerge: + case PeerPushCreditStatus.PendingBalanceKycInit: { + const currency = Amounts.currencyOf(rec.estimatedAmountEffective); + const amount = rec.estimatedAmountEffective; + await balanceStore.addPendingIncoming( + currency, + rec.exchangeBaseUrl, + amount, + ); + break; + } + case PeerPushCreditStatus.PendingWithdrawing: + case PeerPushCreditStatus.SuspendedWithdrawing: { + // Here, incoming balance should be covered by internal withdrawal tx + break; + } + } + }); + + await tx.peerPullCredit.indexes.byStatus + .iter(keyRangeActive) + .forEachAsync(async (rec) => { + switch (rec.status) { + case PeerPullPaymentCreditStatus.PendingReady: + case PeerPullPaymentCreditStatus.PendingCreatePurse: + case PeerPullPaymentCreditStatus.SuspendedReady: + case PeerPullPaymentCreditStatus.SuspendedCreatePurse: { + const currency = Amounts.currencyOf(rec.amount); + const amount = rec.estimatedAmountEffective; + await balanceStore.addPendingIncoming( + currency, + rec.exchangeBaseUrl, + amount, + ); + } + } + }); + + await tx.peerPullDebit.indexes.byStatus + .iter(keyRangeActive) + .forEachAsync(async (rec) => { + switch (rec.status) { + case PeerPullDebitRecordStatus.PendingDeposit: + case PeerPullDebitRecordStatus.AbortingRefresh: + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: + case PeerPullDebitRecordStatus.SuspendedDeposit: + const currency = Amounts.currencyOf(rec.amount); + const amount = rec.coinSel?.totalCost ?? rec.amount; + await balanceStore.addPendingOutgoing( + currency, + rec.exchangeBaseUrl, + amount, + ); + break; + } + }); + + await tx.purchases.indexes.byStatus + .iter(keyRangeActive) + .forEachAsync(async (rec) => { + switch (rec.purchaseStatus) { + case PurchaseStatus.SuspendedPaying: + case PurchaseStatus.PendingPaying: + if (!rec.payInfo || !rec.payInfo.payCoinSelection?.coinPubs) { + break; + } + // FIXME: This is pretty slow, cache? + const currency = Amounts.currencyOf(rec.payInfo.totalPayCost); + const sel = rec.payInfo.payCoinSelection; + for (let i = 0; i < sel.coinPubs.length; i++) { + const coinPub = sel.coinPubs[i]; + const coinRec = await tx.coins.get(coinPub); + if (!coinRec) { + continue; + } + await balanceStore.addPendingOutgoing( + currency, + coinRec.exchangeBaseUrl, + sel.coinContributions[i], + ); + } + } + }); + await tx.depositGroups.indexes.byStatus .iter(keyRangeActive) .forEachAsync(async (dgRecord) => { @@ -487,26 +586,9 @@ export async function getBalances( ): Promise<BalancesResponse> { logger.trace("starting to compute balance"); - const wbal = await wex.db.runReadWriteTx( - { - storeNames: [ - "coinAvailability", - "coins", - "depositGroups", - "exchangeDetails", - "exchanges", - "globalCurrencyAuditors", - "globalCurrencyExchanges", - "purchases", - "refreshGroups", - "withdrawalGroups", - "peerPushDebit", - ], - }, - async (tx) => { - return getBalancesInsideTransaction(wex, tx); - }, - ); + const wbal = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { + return getBalancesInsideTransaction(wex, tx); + }); logger.trace("finished computing wallet balance"); diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -274,18 +274,23 @@ export async function getScopeForAllCoins( tx: WalletDbReadOnlyTransaction< [ "exchanges", + "coins", "exchangeDetails", "globalCurrencyExchanges", "globalCurrencyAuditors", ] >, - exs: string[], + coinPubs: string[], ): Promise<ScopeInfo[]> { - const queries = exs.map((exchange) => { - return getExchangeScopeInfoOrUndefined(tx, exchange); - }); - const rs = await Promise.all(queries); - return rs.filter((d): d is ScopeInfo => d !== undefined); + let exchangeSet = new Set<string>(); + for (const pub of coinPubs) { + const coin = await tx.coins.get(pub); + if (!coin) { + continue; + } + exchangeSet.add(coin.exchangeBaseUrl); + } + return await getScopeForAllExchanges(tx, [...exchangeSet]); } /** @@ -309,29 +314,6 @@ export async function getScopeForAllExchanges( return rs.filter((d): d is ScopeInfo => d !== undefined); } -export async function getCoinScopeInfoOrUndefined( - tx: WalletDbReadOnlyTransaction< - [ - "coins", - "exchanges", - "exchangeDetails", - "globalCurrencyExchanges", - "globalCurrencyAuditors", - ] - >, - coinPub: string, -): Promise<ScopeInfo | undefined> { - const coin = await tx.coins.get(coinPub); - if (!coin) { - return undefined; - } - const det = await getExchangeRecordsInternal(tx, coin.exchangeBaseUrl); - if (!det) { - return undefined; - } - return internalGetExchangeScopeInfo(tx, det); -} - export async function getExchangeScopeInfoOrUndefined( tx: WalletDbReadOnlyTransaction< [ @@ -886,7 +868,7 @@ async function downloadExchangeKeysInfo( headers, }); - logger.info("got response to /keys request"); + logger.trace("got response to /keys request"); // We must make sure to parse out the protocol version // before we validate the body. @@ -1149,7 +1131,7 @@ async function startUpdateExchangeEntry( exchangeBaseUrl: string, options: { forceUpdate?: boolean } = {}, ): Promise<void> { - logger.info( + logger.trace( `starting update of exchange entry ${exchangeBaseUrl}, forced=${ options.forceUpdate ?? false }`, @@ -1295,7 +1277,7 @@ export async function fetchFreshExchange( forceUpdate?: boolean; } = {}, ): Promise<ReadyExchangeSummary> { - logger.info(`fetch fresh ${baseUrl} forced ${options.forceUpdate}`); + logger.trace(`fetch fresh ${baseUrl} forced ${options.forceUpdate}`); if (!options.forceUpdate) { const cachedResp = wex.ws.exchangeCache.get(baseUrl); @@ -1583,7 +1565,7 @@ export async function updateExchangeFromUrlHandler( !AbsoluteTime.isNever(nextUpdateStamp) && !AbsoluteTime.isExpired(nextUpdateStamp) ) { - logger.info( + logger.trace( `exchange update for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString( nextUpdateStamp, )}`, @@ -1595,7 +1577,7 @@ export async function updateExchangeFromUrlHandler( !AbsoluteTime.isNever(nextRefreshCheckStamp) && !AbsoluteTime.isExpired(nextRefreshCheckStamp) ) { - logger.info( + logger.trace( `exchange refresh check for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString( nextRefreshCheckStamp, )}`, @@ -2599,7 +2581,7 @@ export async function markExchangeUsed( tx: WalletDbReadWriteTransaction<["exchanges"]>, exchangeBaseUrl: string, ): Promise<{ notif: WalletNotification | undefined }> { - logger.info(`marking exchange ${exchangeBaseUrl} as used`); + logger.trace(`marking exchange ${exchangeBaseUrl} as used`); const exch = await tx.exchanges.get(exchangeBaseUrl); if (!exch) { logger.info(`exchange ${exchangeBaseUrl} NOT found`); @@ -3012,7 +2994,7 @@ export async function checkIncomingAmountLegalUnderKycBalanceThreshold( exchangeBaseUrl: string, amountIncoming: AmountLike, ): Promise<BalanceThresholdCheckResult> { - logger.info(`checking ${exchangeBaseUrl} +${amountIncoming} for KYC`); + logger.trace(`checking ${exchangeBaseUrl} +${amountIncoming} for KYC`); return await wex.db.runReadOnlyTx( { storeNames: [ @@ -3078,7 +3060,7 @@ export async function checkIncomingAmountLegalUnderKycBalanceThreshold( }; } limits.sort((a, b) => Amounts.cmp(a, b)); - logger.info(`applicable limits: ${j2s(limits)}`); + logger.trace(`applicable limits: ${j2s(limits)}`); let limViolated: AmountString | undefined = undefined; let limNext: AmountString | undefined = undefined; for (let i = 0; i < limits.length; i++) { @@ -3091,7 +3073,7 @@ export async function checkIncomingAmountLegalUnderKycBalanceThreshold( } } if (!limViolated) { - logger.info("balance limit okay"); + logger.trace("balance limit okay"); return { result: "ok", }; diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -146,7 +146,7 @@ import { WalletDbReadWriteTransaction, WalletStoresV1, } from "./db.js"; -import { getScopeForAllExchanges } from "./exchanges.js"; +import { getScopeForAllCoins, getScopeForAllExchanges } from "./exchanges.js"; import { DbReadWriteTransaction, StoreNames } from "./query.js"; import { calculateRefreshOutput, @@ -634,7 +634,7 @@ export class RefundTransactionContext implements TransactionContext { const txState = computeRefundTransactionState(refundRecord); return { type: TransactionType.Refund, - scopes: await getScopeForAllExchanges( + scopes: await getScopeForAllCoins( tx, !purchaseRecord || !purchaseRecord.payInfo?.payCoinSelection ? [] diff --git a/packages/taler-wallet-core/src/query.ts b/packages/taler-wallet-core/src/query.ts @@ -313,7 +313,7 @@ export function openDatabase( cause: req.error, }); } - logger.info( + logger.trace( `handling upgradeneeded event on ${databaseName} from ${e.oldVersion} to ${e.newVersion}`, ); onUpgradeNeeded(db, e.oldVersion, newVersion, transaction); diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts @@ -868,7 +868,6 @@ async function makeExchangeRetryNotification( pendingTaskId: string, e: TalerErrorDetail | undefined, ): Promise<WalletNotification | undefined> { - logger.info("making exchange retry notification"); const parsedTaskId = parseTaskIdentifier(pendingTaskId); switch (parsedTaskId.tag) { case PendingTaskType.ExchangeUpdate: diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts @@ -37,6 +37,7 @@ import { TransactionByIdRequest, TransactionIdStr, TransactionMajorState, + TransactionMinorState, TransactionsRequest, TransactionsResponse, TransactionState, @@ -533,7 +534,7 @@ export async function rematerializeTransactions( wex: WalletExecutionContext, tx: WalletDbAllStoresReadWriteTransaction, ): Promise<void> { - logger.info("re-materializing transactions"); + logger.trace("re-materializing transactions"); const allTxMeta = await tx.transactionsMeta.getAll(); for (const txMeta of allTxMeta) { @@ -928,11 +929,7 @@ export function notifyTransition( experimentalUserData, }); - // As a heuristic, we emit balance-change notifications - // whenever the major state changes. - // This sometimes emits more notifications than we need, - // but makes it much more unlikely that we miss any. - if (transitionInfo.newTxState.major !== transitionInfo.oldTxState.major) { + if (couldChangeBalance(transitionInfo)) { wex.ws.notify({ type: NotificationType.BalanceChange, hintTransactionId: transactionId, @@ -940,3 +937,27 @@ export function notifyTransition( } } } + +function couldChangeBalance(ti: TransitionInfo): boolean { + // We emit a balance change notification unless we're sure that + // the transition does not affect the balance. + if (ti.newTxState.major == ti.oldTxState.major) { + return false; + } + + if ( + ti.newTxState.major === TransactionMajorState.Dialog && + ti.newTxState.minor === TransactionMinorState.MerchantOrderProposed + ) { + return false; + } + + if ( + ti.newTxState.major === TransactionMajorState.Pending && + ti.newTxState.minor === TransactionMinorState.ClaimProposal + ) { + return false; + } + + return true; +} diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -2567,7 +2567,7 @@ export class InternalWalletState { ): Promise<T> { let rid = this.longpollRequestIdCounter++; const triggerNextLongpoll = () => { - logger.info(`cleaning up after long-poll ${rid} request to ${hostname}`); + logger.trace(`cleaning up after long-poll ${rid} request to ${hostname}`); const st = this.longpollStatePerHostname.get(hostname); if (!st) { return; @@ -2608,7 +2608,6 @@ export class InternalWalletState { } return doRunLongpoll(); } else { - logger.info(`directly running long-poll request ${rid} to ${hostname}`); this.longpollStatePerHostname.set(hostname, { queue: [], }); diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -3323,7 +3323,7 @@ export async function prepareBankIntegratedWithdrawal( const externalConfirmation = parsedUri.externalConfirmation; - logger.info( + logger.trace( `creating withdrawal with externalConfirmation=${externalConfirmation}`, );