diff --git a/packages/cashu/cashu.test.ts b/packages/cashu/cashu.test.ts index 2e5aca5b..954a274e 100644 --- a/packages/cashu/cashu.test.ts +++ b/packages/cashu/cashu.test.ts @@ -446,7 +446,7 @@ Deno.test('getWallet function is working', async () => { await relay.event(wallet); - const walletEntity = await getWallet(relay, pubkey, signer); + const { wallet: walletEntity } = await getWallet(relay, pubkey, signer); assertEquals(walletEntity, { balance: 38, diff --git a/packages/cashu/cashu.ts b/packages/cashu/cashu.ts index aa1b3583..a4998159 100644 --- a/packages/cashu/cashu.ts +++ b/packages/cashu/cashu.ts @@ -244,12 +244,12 @@ async function getWallet( pubkey: string, signer: SetRequired, opts?: { signal?: AbortSignal }, -): Promise { +): Promise<{ wallet: Wallet; error: null } | { wallet: null; error: CustomError }> { const { data, error } = await validateAndParseWallet(store, signer, pubkey, { signal: opts?.signal }); if (error) { logi({ level: 'error', ns: 'ditto.cashu.get_wallet', error: errorJson(error) }); - return; + return { wallet: null, error }; } const { p2pk, mints, relays } = data; @@ -283,7 +283,7 @@ async function getWallet( balance, }; - return walletEntity; + return { wallet: walletEntity, error: null }; } /** Serialize an error into JSON for JSON logging. */ diff --git a/packages/ditto/cache/sessionCache.ts b/packages/ditto/cache/sessionCache.ts new file mode 100644 index 00000000..602ecd26 --- /dev/null +++ b/packages/ditto/cache/sessionCache.ts @@ -0,0 +1,73 @@ +/** + * A simple in-memory session cache for storing small pieces of data + * with an optional TTL. + */ +export class SessionCache { + private cache = new Map(); + + /** + * Set a value in the cache + * @param key The cache key + * @param value The value to store + * @param ttlSec Optional TTL in seconds + */ + set(key: string, value: any, ttlSec?: number): void { + const expires = ttlSec ? Date.now() + (ttlSec * 1000) : undefined; + this.cache.set(key, { value, expires }); + } + + /** + * Get a value from the cache + * @param key The cache key + * @returns The cached value or undefined if not found or expired + */ + get(key: string): any { + const item = this.cache.get(key); + + if (!item) { + return undefined; + } + + if (item.expires && item.expires < Date.now()) { + this.cache.delete(key); + return undefined; + } + + return item.value; + } + + /** + * Remove a value from the cache + * @param key The cache key + */ + delete(key: string): void { + this.cache.delete(key); + } + + /** + * Clear all values from the cache + */ + clear(): void { + this.cache.clear(); + } + + /** + * Run cleanup to remove expired items + */ + cleanup(): void { + const now = Date.now(); + for (const [key, item] of this.cache.entries()) { + if (item.expires && item.expires < now) { + this.cache.delete(key); + } + } + } +} + +// Create and export a singleton instance +export const sessionCache = new SessionCache(); + +// Run cleanup every minute +setInterval(() => { + sessionCache.cleanup(); +}, 60 * 1000); diff --git a/packages/ditto/cache/walletCache.ts b/packages/ditto/cache/walletCache.ts new file mode 100644 index 00000000..335f6c55 --- /dev/null +++ b/packages/ditto/cache/walletCache.ts @@ -0,0 +1,133 @@ +import { Wallet } from '@ditto/cashu'; +import { logi } from '@soapbox/logi'; +import { errorJson } from '@/utils/log.ts'; + +/** + * A simple in-memory cache for wallet data + * - Keys are pubkeys + * - Values are wallet data and timestamp + */ +interface CachedWallet { + wallet: Wallet; + timestamp: number; + lastQueryTimestamp: number; +} + +export class WalletCache { + private cache = new Map(); + private ttlMs: number; + private queryTtlMs: number; + + /** + * @param ttlSec Cache TTL in seconds + * @param queryTtlSec How long we should wait between full queries in seconds + */ + constructor(ttlSec = 60, queryTtlSec = 5) { + this.ttlMs = ttlSec * 1000; + this.queryTtlMs = queryTtlSec * 1000; + + // Periodic cleanup + setInterval(() => this.cleanup(), 60 * 1000); + } + + /** + * Get wallet from cache + * @param pubkey User's pubkey + * @returns The cached wallet if present and valid, null otherwise + */ + get(pubkey: string): { wallet: Wallet; shouldRefresh: boolean } | null { + const entry = this.cache.get(pubkey); + if (!entry) { + return null; + } + + const now = Date.now(); + const age = now - entry.timestamp; + + // If cache entry is too old, consider it invalid + if (age > this.ttlMs) { + return null; + } + + // Check if we should refresh the data in the background + // This is determined by how long since the last full query + const queryAge = now - entry.lastQueryTimestamp; + const shouldRefresh = queryAge > this.queryTtlMs; + + return { wallet: entry.wallet, shouldRefresh }; + } + + /** + * Store wallet in cache + * @param pubkey User's pubkey + * @param wallet Wallet data + * @param isQueryResult Whether this is from a full query or just a balance update + */ + set(pubkey: string, wallet: Wallet, isQueryResult = true): void { + const now = Date.now(); + const existing = this.cache.get(pubkey); + + this.cache.set(pubkey, { + wallet, + timestamp: now, + // If this is just a balance update, preserve the lastQueryTimestamp + lastQueryTimestamp: isQueryResult ? now : (existing?.lastQueryTimestamp || now), + }); + } + + /** + * Update balance for a wallet without doing a full refresh + * @param pubkey User's pubkey + * @param deltaAmount Amount to add to balance (negative to subtract) + * @returns true if updated, false if wallet not in cache + */ + updateBalance(pubkey: string, deltaAmount: number): boolean { + const entry = this.cache.get(pubkey); + if (!entry) { + return false; + } + + const newWallet = { + ...entry.wallet, + balance: entry.wallet.balance + deltaAmount, + }; + + this.set(pubkey, newWallet, false); + return true; + } + + /** + * Remove expired entries from cache + */ + private cleanup(): void { + const now = Date.now(); + let deletedCount = 0; + + for (const [pubkey, entry] of this.cache.entries()) { + if (now - entry.timestamp > this.ttlMs) { + this.cache.delete(pubkey); + deletedCount++; + } + } + + if (deletedCount > 0) { + logi({ + level: 'debug', + ns: 'ditto.cache.wallet', + message: `Cleaned up ${deletedCount} expired wallet cache entries`, + }); + } + } + + /** + * Get cache statistics + */ + getStats(): { size: number } { + return { + size: this.cache.size, + }; + } +} + +// Singleton instance +export const walletCache = new WalletCache(); diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 96b50916..d6b1248e 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -1,13 +1,5 @@ import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts'; -import { - getWallet, - organizeProofs, - proofSchema, - renderTransaction, - tokenEventSchema, - validateAndParseWallet, - type Wallet, -} from '@ditto/cashu'; +import { getWallet, organizeProofs, proofSchema, renderTransaction, tokenEventSchema, type Wallet } from '@ditto/cashu'; import { userMiddleware } from '@ditto/mastoapi/middleware'; import { paginated, paginationSchema } from '@ditto/mastoapi/pagination'; import { DittoRoute } from '@ditto/mastoapi/router'; @@ -28,13 +20,6 @@ import { DittoEvent } from '@/interfaces/DittoEvent.ts'; const route = new DittoRoute(); -interface Nutzap { - amount: number; - event_id?: string; - mint: string; // mint the nutzap was created - recipient_pubkey: string; -} - const createMintQuoteSchema = z.object({ mint: z.string().url(), amount: z.number().int(), @@ -258,18 +243,13 @@ route.get('/wallet', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, as const pubkey = await user.signer.getPublicKey(); - const { error } = await validateAndParseWallet(relay, user.signer, pubkey, { signal }); + const { wallet, error } = await getWallet(relay, pubkey, user.signer, { signal }); + if (error) { return c.json({ error: error.message }, 404); } - const walletEntity = await getWallet(relay, pubkey, user.signer); - - if (!walletEntity) { - return c.json({ 'error': 'Wallet not found' }, 404); - } - - return c.json(walletEntity, 200); + return c.json(wallet, 200); }); /** Gets a history of transactions. */