diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 4f92d619..4d12e11e 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -3,40 +3,54 @@ import { SnapshotOut, types, isStateTreeNode, - detach, + isAlive, + getSnapshot, flow, } from 'mobx-state-tree' -import {withSetPropAction} from './helpers/withSetPropAction' -import {ProofModel, Proof, ProofRecord} from './Proof' -import {log} from '../services/logService' -import {getRootStore} from './helpers/getRootStore' -import AppError, {Err} from '../utils/AppError' -import {Mint, MintBalance, UnitBalance} from './Mint' -import {Database} from '../services' +import { withSetPropAction } from './helpers/withSetPropAction' +import { ProofModel, Proof, ProofRecord } from './Proof' +import { log } from '../services/logService' +import { getRootStore } from './helpers/getRootStore' +import AppError, { Err } from '../utils/AppError' +import { Mint, MintBalance, UnitBalance } from './Mint' +import { Database } from '../services' import { MintUnit } from '../services/wallet/currency' - export const ProofsStoreModel = types - .model('Proofs', { - proofs: types.array(ProofModel), - pendingProofs: types.array(ProofModel), + .model('ProofsStore', { + // Map with `secret` as key → O(1) lookup, safe delete, no duplicates + proofs: types.map(ProofModel), + pendingProofs: types.map(ProofModel), pendingByMintSecrets: types.array(types.string), }) .actions(withSetPropAction) + + // ───────────────────── VIEWS ───────────────────── .views(self => ({ - getBySecret(secret: string, isPending: boolean = false): Proof | undefined { - const proofs = isPending ? self.pendingProofs : self.proofs - return proofs.find(proof => proof.secret === secret) || undefined + getBySecret(secret: string, isPending = false): Proof | undefined { + const map = isPending ? self.pendingProofs : self.proofs + return map.get(secret) + }, + + getByTransactionId(tId: number, isPending = false): Proof[] { + const map = isPending ? self.pendingProofs : self.proofs + return Array.from(map.values()).filter(proof => proof.tId === tId) + }, + + alreadyExists(proof: Proof | { secret: string }, isPending = false): boolean { + const map = isPending ? self.pendingProofs : self.proofs + return map.has(typeof proof === 'object' ? proof.secret : proof) + }, + + getProofInstance(proof: Proof | { secret: string }, isPending = false): Proof | undefined { + return this.getBySecret(typeof proof === 'object' ? proof.secret : proof, isPending) }, - getByTransactionId(tId: number, isPending: boolean = false): Proof[] { - const proofs = isPending ? self.pendingProofs : self.proofs - return proofs.filter(proof => proof.tId === tId) - } })) + .views(self => ({ getMintFromProof(proof: Proof): Mint | undefined { const rootStore = getRootStore(self) - const {mintsStore} = rootStore + const { mintsStore } = rootStore for (const mint of mintsStore.allMints) { for (const counter of mint.proofsCounters) { @@ -45,421 +59,273 @@ export const ProofsStoreModel = types } } } - return undefined }, + getByMint( mintUrl: string, options: { - isPending: boolean, - unit?: MintUnit, + isPending: boolean + unit?: MintUnit keysetIds?: string[] ascending?: boolean } ): Proof[] { - let proofs: Proof[] = [] - if(options.keysetIds && options.keysetIds.length > 0) { - proofs = options.isPending ? - self.pendingProofs.filter(p => options.keysetIds?.includes(p.id)) : - self.proofs.filter(p => options.keysetIds?.includes(p.id)) - } else { - proofs = options.isPending ? self.pendingProofs : self.proofs + const map = options.isPending ? self.pendingProofs : self.proofs + let proofs = Array.from(map.values()) + + // Filter by keysetIds if provided + if (options.keysetIds && options.keysetIds.length > 0) { + proofs = proofs.filter(p => options.keysetIds!.includes(p.id)) } + // Filter by mintUrl and optionally unit + proofs = proofs.filter(p => p.mintUrl === mintUrl) if (options.unit) { - if(options.ascending) { - return proofs.filter( - proof => proof.mintUrl === mintUrl && - proof.unit === options.unit - ) - .slice() - .sort((a, b) => a.amount - b.amount) - } else { - return proofs.filter( - proof => proof.mintUrl === mintUrl && - proof.unit === options.unit - ) - .slice() - .sort((a, b) => b.amount - a.amount) - } + proofs = proofs.filter(p => p.unit === options.unit) } - if(options.ascending) { - return proofs.filter(proof => proof.mintUrl === mintUrl) + // Sort ascending/descending by amount + return proofs .slice() - .sort((a, b) => a.amount - b.amount) - } else { - return proofs.filter(proof => proof.mintUrl === mintUrl) - .slice() - .sort((a, b) => b.amount - a.amount) - } - }, - getProofInstance(proof: Proof, isPending: boolean = false) { - return self.getBySecret(proof.secret, isPending) - }, - alreadyExists(proof: Proof, isPending: boolean = false) { - const proofs = isPending ? self.pendingProofs : self.proofs - return proofs.some(p => p.secret === proof.secret) ? true : false + .sort((a, b) => (options.ascending ? a.amount - b.amount : b.amount - a.amount)) }, })) + + // ───────────────────── ACTIONS ───────────────────── .actions(self => ({ - // Proofs are not more persisted in mmkv storage but loaded from database when hydrating the model - loadProofsFromDatabase: flow(function* loadProofsFromDatabase() { - - const unspentAndPendingProofs = yield Database.getProofs(true, true, false) - - // Group into isUnspent and isPending - const groupedProofs = unspentAndPendingProofs.reduce( - (acc: {isPending: ProofRecord[], isUnspent: ProofRecord[]}, proof: ProofRecord) => { - if (proof.isPending) { - acc.isPending.push(proof); - } else { - acc.isUnspent.push(proof); + loadProofsFromDatabase: flow(function* loadProofsFromDatabase() { + const unspentAndPendingProofs: ProofRecord[] = yield Database.getProofs(true, true, false) + + const cleanProof = (p: ProofRecord): Proof => { + const { isPending, isSpent, updatedAt, ...cleaned } = p + return cleaned as Proof + } + + // Clear current maps + self.proofs.clear() + self.pendingProofs.clear() + + for (const record of unspentAndPendingProofs) { + const proof = cleanProof(record) + const targetMap = record.isPending ? self.pendingProofs : self.proofs + targetMap.put(ProofModel.create(proof)) // put() uses `secret` as key + } + + log.trace('[loadProofsFromDatabase]', `Loaded ${self.proofs.size} proofs + ${self.pendingProofs.size} pending`) + }), + + addProofs(newProofs: Proof[], isPending = false): { addedAmount: number; addedProofs: Proof[] } { + if (newProofs.length === 0) return { addedAmount: 0, addedProofs: [] } + + const map = isPending ? self.pendingProofs : self.proofs + let addedAmount = 0 + const addedProofs: Proof[] = [] + const unit = newProofs[0].unit + + const mintsStore = getRootStore(self).mintsStore + const mintInstance = mintsStore.findByUrl(newProofs[0].mintUrl) + + if (!mintInstance) { + throw new AppError(Err.VALIDATION_ERROR, 'Mint not found', { mintUrl: newProofs[0].mintUrl }) + } + + const proofsByKeyset = new Map() + + for (const proof of newProofs) { + if (self.alreadyExists(proof, isPending)) { + log.warn('[addProofs] Duplicate proof skipped', { secret: proof.secret }) + continue } - return acc; - }, - { isUnspent: [], isPending: [] } // Initialize the groups + if (proof.unit !== unit) { + log.error('[addProofs] Mixed units in batch') + continue + } + + // Create MST node or plain object → always use put() + const proofNode = isStateTreeNode(proof) ? proof : ProofModel.create(proof) + map.put(proofNode) // ← automatically uses `secret` as key + + addedAmount += proof.amount + addedProofs.push(proofNode) + + proofsByKeyset.set(proof.id, (proofsByKeyset.get(proof.id) || []).concat(proofNode)) + } + + // Update counters + for (const [keysetId, proofs] of proofsByKeyset) { + const counter = mintInstance.getProofsCounterByKeysetId(keysetId) + counter?.increaseProofsCounter(proofs.length) + } + + if (addedProofs.length > 0) { + Database.addOrUpdateProofs(addedProofs, isPending) // isSpent = false + } + + log.trace('[addProofs]', `Added ${addedProofs.length} ${isPending ? 'pending ' : ''}proofs`) + return { addedAmount, addedProofs } + }, + + removeProofs( + proofsToRemove: Proof[] | { secret: string }[], // allow plain objects with at least secret + isPending = false, + isRecoveredFromPending = false + ) { + const map = isPending ? self.pendingProofs : self.proofs + + const instancesToRemove = proofsToRemove + .map((p): Proof | undefined => { + if (isStateTreeNode(p)) { + return p as Proof + } + // p is now narrowed to { secret: string } + return map.get((p as any).secret) ?? undefined + }) + .filter((p): p is Proof => p !== undefined) + + if (instancesToRemove.length === 0) return + + Database.addOrUpdateProofs( + instancesToRemove, + false, + isRecoveredFromPending ? false : true ) - const cleanProofs = (proofs: ProofRecord[]) => { - return proofs.map(p => { - // Destructure the unwanted properties and collect the rest in `cleaned` - const { isPending, isSpent, updatedAt, ...cleaned } = p - // Return the cleaned object - return cleaned as Proof - }) + for (const proof of instancesToRemove) { + map.delete(proof.secret) // safe, automatic detach } - self.proofs.replace(cleanProofs(groupedProofs.isUnspent)) - self.pendingProofs.replace(cleanProofs(groupedProofs.isPending)) - }), - })) - .actions(self => ({ - addProofs(newProofs: Proof[], isPending: boolean = false): {addedAmount: number, addedProofs: Proof[]} { - try { - const proofs = isPending ? self.pendingProofs : self.proofs - - if(newProofs.length === 0) { - log.error('[addProofs]', 'No proofs to add') - return {addedAmount: 0, addedProofs: []} - } - - let addedAmount: number = 0 - let addedProofs: Proof[] = [] - const unit: MintUnit = newProofs[0].unit - - const proofsByKeysetId = new Map>(); - - for (const proof of newProofs) { - if (!proofsByKeysetId.has(proof.id)) { - proofsByKeysetId.set(proof.id, []); - } - proofsByKeysetId.get(proof.id)!.push(proof); - } - - // Step 2: Update counters and prepare batches for insertion - const mintsStore = getRootStore(self).mintsStore - const mintInstance = mintsStore.findByUrl(newProofs[0].mintUrl as string) - - if(!mintInstance) { - throw new AppError(Err.STORAGE_ERROR, 'Could not find mint', {mintUrl: newProofs[0].mintUrl}) - } - - for (const [keysetId, keysetProofs] of Array.from(proofsByKeysetId.entries())) { - - for (const proof of keysetProofs) { - if(self.alreadyExists(proof)) { - log.error('[addProofs]', `${isPending ? ' pending' : ''} proof with this secret already exists in the ProofsStore`, {proof}) - continue - } - - if(proof.unit !== unit) { - log.error('[addProofs]', `Proof has a different unit then others`, {proof, unit}) - continue - } - - if (isStateTreeNode(proof)) { - proofs.push(proof) - } else { - const proofInstance = ProofModel.create(proof) - proofs.push(proofInstance) - } - - addedAmount += proof.amount - addedProofs.push(proof) - } - - // If we do not just move proofs to pending, we need to update the proofsCounter - if(isPending) { - continue - } - // Find the corresponding counter for this keysetId - const proofsCounter = mintInstance.getProofsCounterByKeysetId(keysetId) - // Increment the counter by the number of proofs to insert - proofsCounter.increaseProofsCounter(keysetProofs.length) - } - - log.trace('[addProofs]', `Added new ${addedProofs.length}${isPending ? ' pending' : ''} proofs to the ProofsStore`) - - if (addedProofs.length > 0) { - Database.addOrUpdateProofs(addedProofs, isPending) // isSpent = false - } - - return { addedAmount, addedProofs } - - } catch (e: any) { - throw new AppError(Err.STORAGE_ERROR, e.message, {caller: 'addProofs'}) - } + log.trace('[removeProofs]', `${instancesToRemove.length} ${isPending ? 'pending ' : ''}proofs removed`) }, - removeProofs(proofsToRemove: Proof[], isPending: boolean = false, isRecoveredFromPending: boolean = false) { - try { - const proofs = isPending ? self.pendingProofs : self.proofs - const count = proofsToRemove.length - - if(isRecoveredFromPending) { - Database.addOrUpdateProofs(proofsToRemove, false, false) // isPending = false, isSpent = false - } else { - Database.addOrUpdateProofs(proofsToRemove, false, true) // isPending = false, isSpent = true - } - proofsToRemove.forEach((proof) => { - if (isStateTreeNode(proof)) { - detach(proof) // vital - } else { - const proofInstance = self.getProofInstance(proof, isPending) - if(proofInstance) { - detach(proofInstance) // vital - } - } - }) - - proofs.replace(proofs.filter(proof => !proofsToRemove.some(removed => removed.secret === proof.secret))) - - log.trace('[removeProofs]', `${count} ${(isPending) ? 'pending' : ''} proofs removed from ProofsStore`) - - } catch (e: any) { - throw new AppError(Err.STORAGE_ERROR, e.message.toString()) - } - }, addToPendingByMint(proof: Proof): boolean { - if(self.pendingByMintSecrets.some(s => s === proof.secret)) { - return false - } - + if (self.pendingByMintSecrets.includes(proof.secret)) return false self.pendingByMintSecrets.push(proof.secret) - log.trace('[addToPendingByMint]', 'Proof marked as pending by mint, secret', proof.secret) - return true + log.trace('[addToPendingByMint]', proof.secret) + return true }, + removeFromPendingByMint(proof: Proof) { self.pendingByMintSecrets.remove(proof.secret) - log.trace('[removeFromPendingByMint]', 'Proof removed from pending by mint, secret', proof.secret) + log.trace('[removeFromPendingByMint]', proof.secret) }, + removeManyFromPendingByMint(secretsToRemove: string[]) { - const secrets = self.pendingByMintSecrets - secrets.replace(secrets.filter(secret => !secretsToRemove.includes(secret))) - log.trace('[removeManyFromPendingByMint]', 'Secrets removed from pending by mint', {secretsToRemove, remaining: secrets}) + self.pendingByMintSecrets.replace( + self.pendingByMintSecrets.filter(s => !secretsToRemove.includes(s)) + ) }, + updateMintUrl(currentMintUrl: string, updatedMintUrl: string) { - log.trace('[proofStore.updateMintUrl] start') - for (const proof of self.proofs) { - if(proof.mintUrl === currentMintUrl) { - proof.setMintUrl(updatedMintUrl) + const updateInMap = (map: typeof self.proofs) => { + for (const proof of map.values()) { + if (proof.mintUrl === currentMintUrl) { + proof.setMintUrl(updatedMintUrl) + } } } - for (const proof of self.pendingProofs) { - if(proof.mintUrl === currentMintUrl) { - proof.setMintUrl(updatedMintUrl) - } - } + updateInMap(self.proofs) + updateInMap(self.pendingProofs) Database.updateProofsMintUrl(currentMintUrl, updatedMintUrl) + log.trace('[updateMintUrl] Updated mint URL in proofs') }, })) + + // ───────────────────── DERIVED VIEWS ───────────────────── .views(self => ({ - get proofsCount() { - return self.proofs.length - }, - get pendingProofsCount() { - return self.pendingProofs.length - }, - get allProofs() { - return self.proofs - }, - get allPendingProofs() { - return self.pendingProofs - }, + get proofsCount() { return self.proofs.size }, + get pendingProofsCount() { return self.pendingProofs.size }, + get allProofs() { return Array.from(self.proofs.values()) }, + get allPendingProofs() { return Array.from(self.pendingProofs.values()) }, })) + .views(self => ({ get balances() { - const mintBalancesMap: Map = new Map() - const unitBalancesMap: Map = new Map() - const mintPendingBalancesMap: Map = new Map() - const unitPendingBalancesMap: Map = new Map() + const mintBalancesMap = new Map() + const unitBalancesMap = new Map() + const mintPendingMap = new Map() + const unitPendingMap = new Map() - const mints: Mint[] = getRootStore(self).mintsStore.allMints + const mints = getRootStore(self).mintsStore.allMints - // make sure balances are defined even if we have no proofs + // Initialize zero balances for (const mint of mints) { - const {mintUrl, units} = mint - const zeroBalances = Object.fromEntries(units!.map(unit => [unit, 0])) as { [key in MintUnit]: number }; - - // make distinct copies of zeroBalances so that balances are not summed by reference!!! - mintBalancesMap.set(mintUrl, { mintUrl, balances: JSON.parse(JSON.stringify(zeroBalances))}) - mintPendingBalancesMap.set(mintUrl, { mintUrl, balances: JSON.parse(JSON.stringify(zeroBalances))}) - - for (const unit of mint.units!) { - unitBalancesMap.set(unit, 0) - } + const zero = Object.fromEntries(mint.units!.map(u => [u, 0])) as Record + mintBalancesMap.set(mint.mintUrl, { mintUrl: mint.mintUrl, balances: { ...zero } }) + mintPendingMap.set(mint.mintUrl, { mintUrl: mint.mintUrl, balances: { ...zero } }) } - //log.trace('[getBalances] zero balances', [...mintBalancesMap.entries()]) - //log.trace('[getBalances] zero pendingBalances', [...mintPendingBalancesMap.entries()]) + for (const proof of self.proofs.values()) { + const mb = mintBalancesMap.get(proof.mintUrl) + if (!mb) continue - //log.trace('[getBalances] proofs count', self.proofs.length) - - for (const proof of self.proofs) { - const { mintUrl, unit, amount } = proof - - // Make sure to not cause madness from orphaned proofs if it would happen - if (!mintBalancesMap.has(mintUrl)) { - continue - } - - // Update balance for the unit - const mintBalance = mintBalancesMap.get(mintUrl)! - mintBalance.balances[unit] = (mintBalance.balances[unit] || 0) + amount - unitBalancesMap.set(unit, (unitBalancesMap.get(unit) || 0) + amount) - } - - const mintBalances: MintBalance[] = Array.from(mintBalancesMap.values()) - - //log.trace('[getBalances] filled balances', [...mintBalancesMap.entries()]) - - //log.trace('[getBalances] zero pendingBalances', [...mintPendingBalancesMap.entries()]) - - // Convert map to array of UnitBalance objects - const unitBalances: UnitBalance[] = Array.from(unitBalancesMap.entries()).map(([unit, unitBalance]) => ({ - unitBalance, - unit - })) - - for (const proof of self.pendingProofs) { - const { mintUrl, unit, amount } = proof - - //log.trace('adding pending proof amount', proof.amount) - - // Make sure to not cause madness from orphaned proofs if it would happen - if (!mintPendingBalancesMap.has(mintUrl)) { - continue - } - - // Update balance for the unit - const mintPendingBalance = mintPendingBalancesMap.get(mintUrl)! - mintPendingBalance.balances[unit] = (mintPendingBalance.balances[unit] || 0) + amount - unitPendingBalancesMap.set(unit, (unitPendingBalancesMap.get(unit) || 0) + amount) + mb.balances[proof.unit]! += proof.amount + unitBalancesMap.set(proof.unit, (unitBalancesMap.get(proof.unit) || 0) + proof.amount) } - //log.trace('[getBalances] pendingBalances', [...mintPendingBalancesMap.entries()]) - - const mintPendingBalances: MintBalance[] = Array.from(mintPendingBalancesMap.values()) + for (const proof of self.pendingProofs.values()) { + const mb = mintPendingMap.get(proof.mintUrl) + if (!mb) continue - // Convert map to array of UnitBalance objects - const unitPendingBalances: UnitBalance[] = Array.from(unitPendingBalancesMap.entries()).map(([unit, unitBalance]) => ({ - unitBalance, - unit - })) - - const balances = { - mintBalances, - mintPendingBalances, - unitBalances, - unitPendingBalances, + mb.balances[proof.unit]! += proof.amount + unitPendingMap.set(proof.unit, (unitPendingMap.get(proof.unit) || 0) + proof.amount) } - - // log.debug('[getBalances]', balances) - // console.log(balances) + + const balances = { + mintBalances: Array.from(mintBalancesMap.values()), + mintPendingBalances: Array.from(mintPendingMap.values()), + unitBalances: Array.from(unitBalancesMap.entries()).map(([unit, unitBalance]) => ({ unit, unitBalance })), + unitPendingBalances: Array.from(unitPendingMap.entries()).map(([unit, unitBalance]) => ({ unit, unitBalance })), + } + + log.trace('[balances]', balances) return balances }, })) - .views(self => ({ // Move to MintsStore? - getMintBalance: (mintUrl: string) => { - const balances = self.balances.mintBalances - const mintBalance = balances - .find((balance: MintBalance) => balance.mintUrl === mintUrl) + .views(self => ({ + getMintBalance: (mintUrl: string) => self.balances.mintBalances.find(b => b.mintUrl === mintUrl), + getMintBalancesWithEnoughBalance: (amount: number, unit: MintUnit) => + self.balances.mintBalances + .filter(b => (b.balances[unit] || 0) >= amount) + .sort((a, b) => (b.balances[unit] || 0) - (a.balances[unit] || 0)), - return mintBalance - }, - getMintBalancesWithEnoughBalance: (amount: number, unit: MintUnit) => { - const balances = self.balances.mintBalances + getMintBalancesWithUnit: (unit: MintUnit) => + self.balances.mintBalances + .filter(b => unit in b.balances) + .sort((a, b) => (b.balances[unit] || 0) - (a.balances[unit] || 0)), - const filteredMintBalances = balances - .filter((balance: MintBalance) => { - if((balance.balances[unit] || 0) >= amount) { - return true - } - return false - }) - .slice() - .sort((a, b) => b.balances[unit]! - a.balances[unit]!) - - return filteredMintBalances - }, - getMintBalancesWithUnit: (unit: MintUnit) => { - const balances = self.balances.mintBalances - - const filteredMintBalances = balances - .filter((balance: MintBalance) => { - if(Object.keys(balance.balances).includes(unit)) { - return true - } - return false - }) - .slice() - .sort((a, b) => b.balances[unit]! - a.balances[unit]!) - - return filteredMintBalances - }, getMintBalanceWithMaxBalance: (unit: MintUnit) => { - const balances = self.balances.mintBalances - let maxBalance: MintBalance | undefined = undefined; - let maxAmount = -Infinity; - - for (const balance of balances) { - const amount = balance.balances[unit]; - if (amount !== undefined && amount > maxAmount) { - maxAmount = amount; - maxBalance = balance; + let max: MintBalance | undefined + let maxAmt = -1 + for (const b of self.balances.mintBalances) { + const amt = b.balances[unit] || 0 + if (amt > maxAmt) { + maxAmt = amt + max = b } } - - log.debug('[getMintBalanceWithMaxBalance]', {maxBalance}) - return maxBalance; + return max }, - getUnitBalance: (unit: MintUnit) => { - const balances = self.balances.unitBalances - const unitBalance = balances - .find((balance: UnitBalance) => balance.unit === unit) + getUnitBalance: (unit: MintUnit) => + self.balances.unitBalances.find(b => b.unit === unit), - return unitBalance - }, getProofsSubset: (proofs: Proof[], proofsToRemove: Proof[]) => { - // return proofs.filter(proof => !proofsToRemove.includes(proof)) - const secrets = new Set(proofsToRemove.map(p => p.secret)); - return proofs.filter(p => !secrets.has(p.secret)); + const removeSecrets = new Set(proofsToRemove.map(p => p.secret)) + return proofs.filter(p => !removeSecrets.has(p.secret)) }, - })).postProcessSnapshot((snapshot) => { // NOT persisted to storage except last pendingByMintSecrets! - return { - proofs: [], - pendingProofs: [], - pendingByMintSecrets: snapshot.pendingByMintSecrets - } - }) - + })) -export interface Proofs extends Instance {} -export interface ProofsStoreSnapshot - extends SnapshotOut {} + // Only persist pendingByMintSecrets (proofs are loaded from DB on startup) + .postProcessSnapshot(snapshot => ({ + proofs: [], + pendingProofs: [], + pendingByMintSecrets: snapshot.pendingByMintSecrets, + })) + +export interface ProofsStore extends Instance {} +export interface ProofsStoreSnapshot extends SnapshotOut {} \ No newline at end of file