taler-typescript-core

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

commit ce49869a0bfe7e13b92b40caeba550cade97fa44
parent d768391c048a6581ad719a21d83c1c544a4540d3
Author: Florian Dold <dold@taler.net>
Date:   Sun, 19 Jul 2026 23:20:03 +0200

wallet: invalidate caches in the DAL instead of via DB triggers

Both backends drop the exchange, denomination and refresh-cost caches
after a mutating transaction.  The native backend had no invalidation.
The IndexedDB trigger fired on any store access, dropping caches on a
plain read.

Diffstat:
Mpackages/taler-wallet-core/src/db-indexeddb.ts | 2--
Apackages/taler-wallet-core/src/dbtx-cache-invalidation.test.ts | 232+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/dbtx-runners.ts | 1-
Mpackages/taler-wallet-core/src/dbtx-shared.ts | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/exchanges.ts | 2--
Mpackages/taler-wallet-core/src/query.ts | 56+++-----------------------------------------------------
Mpackages/taler-wallet-core/src/requests.ts | 10++++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 82++++++++++++++++++++++++++++++++++---------------------------------------------
8 files changed, 335 insertions(+), 105 deletions(-)

diff --git a/packages/taler-wallet-core/src/db-indexeddb.ts b/packages/taler-wallet-core/src/db-indexeddb.ts @@ -2017,7 +2017,6 @@ export async function openStoredBackupsDatabase( const handle = new DbAccessImpl( backupsDbHandle, StoredBackupStores, - {}, CancellationToken.CONTINUE, ); return handle; @@ -2045,7 +2044,6 @@ export async function openTalerDatabase( const metaDb = new DbAccessImpl( metaDbHandle, walletMetadataStore, - {}, CancellationToken.CONTINUE, ); let currentMainVersion: string | undefined; diff --git a/packages/taler-wallet-core/src/dbtx-cache-invalidation.test.ts b/packages/taler-wallet-core/src/dbtx-cache-invalidation.test.ts @@ -0,0 +1,232 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * The wallet caches exchange entries, denomination info and refresh costs in + * memory. Those caches are dropped after any transaction that changed a + * record they are derived from, which the DAL detects by watching for calls + * to the methods named in CACHE_INVALIDATING_METHODS. + * + * That list is maintained by hand, and a stale entry fails silently: the + * wallet keeps serving a cached denomination that no longer exists in the + * database, with no error anywhere. This test derives the list that *should* + * be there from the IndexedDB implementation and compares. + */ + +import assert from "node:assert"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; + +import { runnerFactories } from "./dbtx-runners.js"; +import { + CACHE_INVALIDATING_METHODS, + watchForCacheInvalidation, +} from "./dbtx-shared.js"; + +/** + * The IndexedDB object stores the three caches are derived from, and the + * sqlite tables holding the same entities. A write to any of these can + * invalidate a cached value; a write to anything else cannot. + */ +const CACHE_BACKING_STORES = [ + "exchanges", + "exchangeDetails", + "denominations", + "globalCurrencyAuditors", + "globalCurrencyExchanges", +]; +const CACHE_BACKING_TABLES = [ + "exchanges", + "exchange_details", + "denominations", + "global_currency_auditors", + "global_currency_exchanges", +]; + +/** + * Locate a file under src/, from wherever this test happens to be running. + * It normally runs from the compiled lib/, so "next to me" is not the answer. + */ +function readSourceFile(name: string): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 5; i++) { + const candidate = join(dir, "src", name); + if (existsSync(candidate)) { + return readFileSync(candidate, "utf-8"); + } + dir = dirname(dir); + } + throw Error(`could not locate src/${name}`); +} + +/** + * Split a transaction implementation into its methods, keyed by name. + * + * Whole methods rather than single lines, because an SQL statement is often + * built across several concatenated string literals, with the table name on a + * different line from the verb. + */ +function methodBodies(src: string): Map<string, string> { + // Class members sit at exactly two spaces of indentation. + const methodRe = /^ {2}(?:async )?([A-Za-z_$][\w$]*)\s*[(<]/; + const bodies = new Map<string, string>(); + let current: string | undefined; + let buf: string[] = []; + const flush = () => { + if (current !== undefined) { + bodies.set(current, (bodies.get(current) ?? "") + buf.join("\n")); + } + }; + for (const line of src.split("\n")) { + const m = methodRe.exec(line); + if (m) { + flush(); + current = m[1]; + buf = []; + } + buf.push(line); + } + flush(); + return bodies; +} + +/** + * Find the methods of one implementation that write a cache-backing entity. + */ +function findMutators(src: string, writeRe: RegExp): Set<string> { + const found = new Set<string>(); + for (const [name, body] of methodBodies(src)) { + // Collapse the string concatenation SQL is assembled from, so a statement + // split across lines reads as one. + const flat = body.replace(/"\s*\+\s*"/g, "").replace(/\s+/g, " "); + if (writeRe.test(flat)) { + found.add(name); + } + } + return found; +} + +test("every mutator of a cache-backing store invalidates the caches", () => { + const stores = CACHE_BACKING_STORES.join("|"); + const tables = CACHE_BACKING_TABLES.join("|"); + + const impls: Array<{ file: string; writeRe: RegExp }> = [ + { + // Writes look like `tx.denominations.put(rec)`, or `this.tx.exchanges + // .delete(baseUrl)` where the store handle was taken off `this`. + file: "dbtx-indexeddb.ts", + writeRe: new RegExp( + `\\b(?:this\\.)?tx\\.(?:${stores})\\.(?:put|add|delete|clear)\\(`, + ), + }, + { + // Both implementations are scanned, not just IndexedDB: a method that + // writes one of these tables only on the sqlite side would otherwise + // never be noticed, and would silently stop invalidating caches on the + // backend it applies to. + file: "dbtx-sqlite.ts", + writeRe: new RegExp( + `(?:INSERT(?:\\s+OR\\s+\\w+)?\\s+INTO|DELETE\\s+FROM|UPDATE)\\s+"?(?:${tables})"?\\b`, + ), + }, + ]; + + const mutators = new Set<string>(); + for (const impl of impls) { + const found = findMutators(readSourceFile(impl.file), impl.writeRe); + // A scan that finds nothing would make this test vacuously pass, which is + // exactly the failure mode a source-scanning test has to rule out. + assert.ok( + found.size >= 8, + `only found ${found.size} cache-backing mutators in ${impl.file}` + + ` — the scan is probably broken, not the code`, + ); + for (const name of found) { + mutators.add(name); + } + } + + assert.deepStrictEqual( + [...mutators].sort(), + [...CACHE_INVALIDATING_METHODS].sort(), + "methods that write a cache-backing entity must be listed in" + + " CACHE_INVALIDATING_METHODS (left: found in the implementations," + + " right: declared in dbtx-shared.ts)", + ); +}); + +/** + * The static test above only checks that the list is complete. This one + * checks that the wrapper acting on it actually works, against both real + * transaction implementations rather than a stub — the two differ in how + * their methods are defined (prototype methods vs. own properties), which is + * exactly what a Proxy `get` trap is sensitive to. + */ +for (const makeRunner of runnerFactories) { + test(`cache invalidation fires on writes only`, async (t) => { + const runner = await makeRunner(); + await t.test(runner.name, async () => { + // A read must not invalidate. The old store-based trigger got this + // wrong: it fired whenever a readwrite transaction so much as *looked* + // at the denominations store, dropping every cache on a pure read. + const readFlag = { dirty: false }; + await runner.runTx(async (tx) => { + await watchForCacheInvalidation( + tx, + readFlag, + ).listGlobalCurrencyExchanges(); + }); + assert.strictEqual( + readFlag.dirty, + false, + "a read-only transaction must not invalidate the caches", + ); + + // A write to a cache-backing store must. + const writeFlag = { dirty: false }; + await runner.runTx(async (tx) => { + await watchForCacheInvalidation( + tx, + writeFlag, + ).addGlobalCurrencyExchange({ + currency: "TESTKUDOS", + exchangeBaseUrl: "https://exchange.test/", + exchangeMasterPub: encodeCrock(stringToBytes("master-pub-0000")), + }); + }); + assert.strictEqual( + writeFlag.dirty, + true, + "a transaction that wrote a cache-backing store must invalidate", + ); + + // The wrapper must not otherwise change behaviour: results still come + // back, and methods still see the right `this`. + const rows = await runner.runTx(async (tx) => { + return await watchForCacheInvalidation(tx, { + dirty: false, + }).listGlobalCurrencyExchanges(); + }); + assert.strictEqual(rows.length, 1); + assert.strictEqual(rows[0].currency, "TESTKUDOS"); + }); + await runner.close(); + }); +} diff --git a/packages/taler-wallet-core/src/dbtx-runners.ts b/packages/taler-wallet-core/src/dbtx-runners.ts @@ -58,7 +58,6 @@ export async function makeIdbRunner( const db = new DbAccessImpl( dbHandle, WalletIndexedDbStoresV1, - {}, CancellationToken.CONTINUE, ); return { diff --git a/packages/taler-wallet-core/src/dbtx-shared.ts b/packages/taler-wallet-core/src/dbtx-shared.ts @@ -103,3 +103,58 @@ export async function getExchangeScopeInfoGeneric( url: det.exchangeBaseUrl, }; } + +/** + * DAL methods after which the wallet's in-memory caches are stale. + * + * The caches hold exchange summaries, denomination info and refresh costs, + * all derived from these entities. Listing the methods here rather than + * having each backend decide keeps the two implementations from drifting: a + * cache that is dropped on one backend and not the other is a bug that only + * shows up as stale data much later. + * + * Mutations only. Reading a denomination cannot invalidate anything derived + * from denominations. + */ +export const CACHE_INVALIDATING_METHODS: ReadonlySet<string> = new Set([ + "upsertExchange", + "deleteExchange", + "upsertExchangeDetails", + "deleteExchangeDetails", + "upsertDenomination", + "deleteDenomination", + "addGlobalCurrencyExchange", + "deleteGlobalCurrencyExchange", + "addGlobalCurrencyAuditor", + "deleteGlobalCurrencyAuditor", +]); + +/** + * Wrap a transaction so that calls to cache-invalidating methods are noticed. + * + * `flag.dirty` is set as a side effect; the caller drops the caches after the + * transaction commits, never before, so a rolled-back transaction does not + * invalidate anything. + */ +export function watchForCacheInvalidation<T extends WalletDbTransaction>( + tx: T, + flag: { dirty: boolean }, +): T { + return new Proxy(tx, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (typeof prop === "string" && CACHE_INVALIDATING_METHODS.has(prop)) { + if (typeof value === "function") { + return (...args: unknown[]) => { + flag.dirty = true; + return value.apply(target, args); + }; + } + } + if (typeof value === "function") { + return value.bind(target); + } + return value; + }, + }); +} diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -1891,8 +1891,6 @@ export async function updateExchangeFromUrlHandler( return TaskRunResult.progress(); } - wex.ws.clearAllCaches(); - const oldExchangeState = getExchangeState(r); const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl); let detailsPointerChanged = false; diff --git a/packages/taler-wallet-core/src/query.ts b/packages/taler-wallet-core/src/query.ts @@ -737,13 +737,11 @@ function makeTxClientContext( indexes[indexAlias] = { get(key) { internalContext.throwIfInactive(); - internalContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).index(indexName).get(key); return requestToPromise(req, internalContext); }, iter(query) { internalContext.throwIfInactive(); - internalContext.storesAccessed.add(storeName); const req = tx .objectStore(storeName) .index(indexName) @@ -752,7 +750,6 @@ function makeTxClientContext( }, getAll(query, count) { internalContext.throwIfInactive(); - internalContext.storesAccessed.add(storeName); const req = tx .objectStore(storeName) .index(indexName) @@ -761,7 +758,6 @@ function makeTxClientContext( }, getAllKeys(query, count) { internalContext.throwIfInactive(); - internalContext.storesAccessed.add(storeName); const req = tx .objectStore(storeName) .index(indexName) @@ -770,7 +766,6 @@ function makeTxClientContext( }, count(query) { internalContext.throwIfInactive(); - internalContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).index(indexName).count(query); return requestToPromise(req, internalContext); }, @@ -780,19 +775,16 @@ function makeTxClientContext( indexes, get(key) { internalContext.throwIfInactive(); - internalContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).get(key); return requestToPromise(req, internalContext); }, getAll(query, count) { internalContext.throwIfInactive(); - internalContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).getAll(query, count); return requestToPromise(req, internalContext); }, iter(query) { internalContext.throwIfInactive(); - internalContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).openCursor(query); return new ResultStream<any>(req); }, @@ -801,8 +793,6 @@ function makeTxClientContext( if (!internalContext.allowWrite) { throw Error("attempting write in a read-only transaction"); } - internalContext.storesAccessed.add(storeName); - internalContext.storesModified.add(storeName); const req = tx.objectStore(storeName).add(r, k); const key = await requestToPromise(req, internalContext); return { @@ -814,8 +804,6 @@ function makeTxClientContext( if (!internalContext.allowWrite) { throw Error("attempting write in a read-only transaction"); } - internalContext.storesAccessed.add(storeName); - internalContext.storesModified.add(storeName); const req = tx.objectStore(storeName).put(r, k); const key = await requestToPromise(req, internalContext); return { @@ -827,14 +815,11 @@ function makeTxClientContext( if (!internalContext.allowWrite) { throw Error("attempting write in a read-only transaction"); } - internalContext.storesAccessed.add(storeName); - internalContext.storesModified.add(storeName); const req = tx.objectStore(storeName).delete(k); return requestToPromise(req, internalContext); }, count(query) { internalContext.throwIfInactive(); - internalContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).count(query); return requestToPromise(req, internalContext); }, @@ -873,46 +858,22 @@ export interface DbAccess<Stores extends StoreMap> { ): Promise<T>; } -export interface AfterCommitInfo { - mode: IDBTransactionMode; - scope: Set<string>; - accessedStores: Set<string>; - modifiedStores: Set<string>; -} - -export interface TriggerSpec { - /** - * Trigger run after every successful commit, run outside of the transaction. - */ - afterCommit?: (info: AfterCommitInfo) => void; - - // onRead(store, value) - // initState<State> () => State - // beforeCommit<State>? (tx: Transaction, s: State | undefined) => Promise<void>; -} - /** * Additional state we store for every IndexedDB transaction opened * via the query helper. */ class InternalTransactionContext { isAborted = false; - storesScope: Set<string>; - storesAccessed: Set<string> = new Set(); - storesModified: Set<string> = new Set(); allowWrite: boolean; abortExn: TransactionAbortedError | undefined; notifications: WalletNotification[] = []; afterCommitHandlers: (() => void)[] = []; constructor( - private readonly triggerSpec: TriggerSpec, private readonly mode: IDBTransactionMode, - readonly scope: string[], public readonly cancellationToken: CancellationToken, public readonly applyNotifications?: (notifs: WalletNotification[]) => void, ) { - this.storesScope = new Set(scope); this.allowWrite = mode === "readwrite" || mode === "versionchange"; } @@ -925,14 +886,6 @@ class InternalTransactionContext { } handleAfterCommit() { - if (this.triggerSpec.afterCommit) { - this.triggerSpec.afterCommit({ - mode: this.mode, - accessedStores: this.storesAccessed, - modifiedStores: this.storesModified, - scope: this.storesScope, - }); - } for (const f of this.afterCommitHandlers) { f(); } @@ -955,7 +908,6 @@ export class DbAccessImpl<Stores extends StoreMap> implements DbAccess<Stores> { constructor( private db: IDBDatabase, private stores: Stores, - private triggers: TriggerSpec = {}, private cancellationToken: CancellationToken, private applyNotifications?: (notifs: WalletNotification[]) => void, ) {} @@ -982,10 +934,8 @@ export class DbAccessImpl<Stores extends StoreMap> implements DbAccess<Stores> { accessibleStores[swi.storeName] = swi; } const mode = "readwrite"; - const triggerContext = new InternalTransactionContext( - this.triggers, + const internalContext = new InternalTransactionContext( mode, - strStoreNames, this.cancellationToken, this.applyNotifications, ); @@ -993,8 +943,8 @@ export class DbAccessImpl<Stores extends StoreMap> implements DbAccess<Stores> { const writeContext = makeTxClientContext( tx, accessibleStores, - triggerContext, + internalContext, ); - return await runTx(tx, writeContext, txf, triggerContext); + return await runTx(tx, writeContext, txf, internalContext); } } diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts @@ -690,6 +690,10 @@ async function recoverStoredBackup( await rematerializeTransactions(wex, tx); }); } + + // Both branches replaced the database wholesale, bypassing the DAL that + // normally notices such changes, so the caches have to be dropped by hand. + wex.ws.clearAllCaches(); logger.info(`import done`); } @@ -1658,6 +1662,12 @@ async function handleImportDb( await wex.runWalletDbTx(async (tx) => { await rematerializeTransactions(wex, tx); }); + + // The import replaced the database underneath the DAL, writing through the + // raw IndexedDB handle, so none of the automatic invalidation saw it. Every + // cached exchange, denomination and refresh cost now describes records that + // are gone. + wex.ws.clearAllCaches(); return {}; } diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -75,15 +75,12 @@ import { import { ConfigRecordKey, WalletDenomination } from "./db-common.js"; import { WalletIndexedDbStoresV1, - WalletIndexedDbTransaction, applyFixups, openTalerDatabase, } from "./db-indexeddb.js"; import { WalletDbTransaction } from "./dbtx.js"; +import { watchForCacheInvalidation } from "./dbtx-shared.js"; import { - clearNativeSqliteWalletDb, - exportNativeSqliteDb, - importNativeSqliteDb, NativeSqliteWalletDb, runNativeSqliteWalletTx, } from "./dbtx-sqlite.js"; @@ -102,13 +99,7 @@ import { observeTalerCrypto, } from "./observable-wrappers.js"; import { ProgressContext } from "./progress.js"; -import { - AfterCommitInfo, - DbAccess, - DbAccessImpl, - TransactionAbortedError, - TriggerSpec, -} from "./query.js"; +import { DbAccess, DbAccessImpl, TransactionAbortedError } from "./query.js"; import { TaskScheduler, TaskSchedulerImpl } from "./shepherd.js"; import { rematerializeTransactions } from "./transactions.js"; import { @@ -395,6 +386,11 @@ async function runWalletDbTx<T>( wex: WalletExecutionContext, f: (tx: WalletDbTransaction) => Promise<T>, ): Promise<T> { + // Set by the wrapper below when the transaction calls a method that makes + // the wallet's caches stale. Acted on only after a successful commit: a + // transaction that rolls back changed nothing, and an attempt that is about + // to be retried resets the flag so a discarded write cannot carry over. + const dirty = { dirty: false }; const ndb = wex.ws.nativeSqliteDb; if (ndb) { // Native backend: a real sqlite transaction, no IndexedDB emulation. @@ -407,6 +403,7 @@ async function runWalletDbTx<T>( // not touch, so without this the native backend would be invisible to // the observability API. return await handleTxRetries(wex, async () => { + dirty.dirty = false; const location = getCallerInfo(); wex.oc.observe({ type: ObservabilityEventType.DbQueryStart, @@ -418,7 +415,7 @@ async function runWalletDbTx<T>( const ret = await runNativeSqliteWalletTx( ndb, (notif) => wex.ws.notify(notif), - async (tx) => await f(tx), + async (tx) => await f(watchForCacheInvalidation(tx, dirty)), ); wex.oc.observe({ type: ObservabilityEventType.DbQueryFinishSuccess, @@ -426,6 +423,9 @@ async function runWalletDbTx<T>( location, durationMs: performanceDelta(start, performanceNow()), }); + if (dirty.dirty) { + wex.ws.clearAllCaches(); + } return ret; } catch (e) { wex.oc.observe({ @@ -440,10 +440,15 @@ async function runWalletDbTx<T>( }); } return await handleTxRetries(wex, async () => { - return await wex.db.runAllStoresReadWriteTx({}, async (mytx) => { + dirty.dirty = false; + const ret = await wex.db.runAllStoresReadWriteTx({}, async (mytx) => { const tx = new IdbWalletTransaction(mytx); - return await f(tx); + return await f(watchForCacheInvalidation(tx, dirty)); }); + if (dirty.dirty) { + wex.ws.clearAllCaches(); + } + return ret; }); } @@ -668,34 +673,6 @@ export class Wallet { } /** - * Implementation of triggers for the wallet DB. - */ -class WalletDbTriggerSpec implements TriggerSpec { - constructor(public ws: InternalWalletState) {} - - afterCommit(info: AfterCommitInfo): void { - if (info.mode !== "readwrite") { - return; - } - logger.trace( - `in after commit callback for readwrite, modified ${j2s([ - ...info.modifiedStores, - ])}`, - ); - const modified = info.accessedStores; - if ( - modified.has(WalletIndexedDbStoresV1.exchanges.storeName) || - modified.has(WalletIndexedDbStoresV1.exchangeDetails.storeName) || - modified.has(WalletIndexedDbStoresV1.denominations.storeName) || - modified.has(WalletIndexedDbStoresV1.globalCurrencyAuditors.storeName) || - modified.has(WalletIndexedDbStoresV1.globalCurrencyExchanges.storeName) - ) { - this.ws.clearAllCaches(); - } - } -} - -/** * Internal state of the wallet. * * This ties together all the operation implementations. @@ -796,23 +773,35 @@ export class InternalWalletState { async runStandaloneWalletDbTx<T>( f: (tx: WalletDbTransaction) => Promise<T>, ): Promise<T> { + const dirty = { dirty: false }; const ndb = this.nativeSqliteDb; if (ndb) { - return await runNativeSqliteWalletTx( + const ret = await runNativeSqliteWalletTx( ndb, (notif) => this.notify(notif), - async (tx) => await f(tx), + async (tx) => await f(watchForCacheInvalidation(tx, dirty)), ); + if (dirty.dirty) { + this.clearAllCaches(); + } + return ret; } if (!this._dbAccessHandle) { this._dbAccessHandle = this.createDbAccessHandle( CancellationToken.CONTINUE, ); } - return await this._dbAccessHandle.runAllStoresReadWriteTx( + const ret = await this._dbAccessHandle.runAllStoresReadWriteTx( {}, - async (mytx) => await f(new IdbWalletTransaction(mytx)), + async (mytx) => + await f( + watchForCacheInvalidation(new IdbWalletTransaction(mytx), dirty), + ), ); + if (dirty.dirty) { + this.clearAllCaches(); + } + return ret; } /** @@ -878,7 +867,6 @@ export class InternalWalletState { return new DbAccessImpl( this._indexedDbHandle, WalletIndexedDbStoresV1, - new WalletDbTriggerSpec(this), cancellationToken, (notifs: WalletNotification[]): void => { for (const notif of notifs) {