mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-06 03:38:24 +00:00
433 lines
16 KiB
TypeScript
433 lines
16 KiB
TypeScript
import {cast, flow, getParent, getRoot, getSnapshot, IAnyStateTreeNode, Instance, IStateTreeNode, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree'
|
|
import {withSetPropAction} from './helpers/withSetPropAction'
|
|
import {
|
|
type GetInfoResponse,
|
|
type MintKeys as CashuMintKeys,
|
|
type MintKeyset as CashuMintKeyset,
|
|
CashuMint,
|
|
} from '@cashu/cashu-ts'
|
|
import {colors, getRandomIconColor} from '../theme'
|
|
import { log } from '../services'
|
|
|
|
import AppError, { Err } from '../utils/AppError'
|
|
import { MintUnit, MintUnits } from '../services/wallet/currency'
|
|
import { getRootStore } from './helpers/getRootStore'
|
|
import { generateId } from '../utils/utils'
|
|
import { Proof } from './Proof'
|
|
import { CashuProof } from '../services/cashu/cashuUtils'
|
|
|
|
|
|
export type MintBalance = {
|
|
mintUrl: string
|
|
balances: {
|
|
[key in MintUnit]?: number
|
|
}
|
|
}
|
|
|
|
export type UnitBalance = {
|
|
unitBalance: number
|
|
unit: MintUnit
|
|
}
|
|
|
|
export type Balances = {
|
|
mintBalances: MintBalance[]
|
|
unitBalances: UnitBalance[]
|
|
}
|
|
|
|
export type InFlightRequest<TRequest = any> = {
|
|
transactionId: number;
|
|
request: TRequest;
|
|
}
|
|
|
|
export enum MintStatus {
|
|
ONLINE = 'ONLINE',
|
|
OFFLINE = 'OFFLINE'
|
|
}
|
|
|
|
export const MintProofsCounterModel = types.model('MintProofsCounter', {
|
|
keyset: types.string,
|
|
unit: types.optional(types.frozen<MintUnit>(), 'sat'),
|
|
counter: types.optional(types.number, 0),
|
|
inFlightRequests: types.array(types.frozen<InFlightRequest>()),
|
|
}).actions(self => ({
|
|
addInFlightRequest(transactionId: number, request: any) {
|
|
if(!self.inFlightRequests.find(r => r.transactionId === transactionId)) {
|
|
self.inFlightRequests.push({transactionId, request})
|
|
log.trace('[addInFlightRequest]', {transactionId, request})
|
|
}
|
|
},
|
|
removeInFlightRequest(transactionId: number) {
|
|
const index = self.inFlightRequests.findIndex(r => r.transactionId === transactionId)
|
|
|
|
if(index !== -1) {
|
|
self.inFlightRequests.splice(index, 1)
|
|
self.inFlightRequests = cast(self.inFlightRequests)
|
|
|
|
log.trace('[removeInFlightRequest]', {transactionId})
|
|
}
|
|
},
|
|
increaseProofsCounter(numberOfProofs: number) {
|
|
if(isNaN(self.counter)) self.counter = 0
|
|
self.counter += numberOfProofs
|
|
log.info('[increaseProofsCounter]', 'Increased proofsCounter', {numberOfProofs, counter: self.counter})
|
|
},
|
|
decreaseProofsCounter(numberOfProofs: number) {
|
|
self.counter -= numberOfProofs
|
|
Math.max(0, self.counter)
|
|
log.trace('[decreaseProofsCounter]', 'Decreased proofsCounter', {numberOfProofs, counter: self.counter})
|
|
},
|
|
}))
|
|
|
|
export type MintProofsCounter = Instance<typeof MintProofsCounterModel>
|
|
|
|
/**
|
|
* This represents a Cashu mint
|
|
*/
|
|
export const MintModel = types
|
|
.model('Mint', {
|
|
id: types.optional(types.identifier, () => generateId(8)),
|
|
mintUrl: types.string,
|
|
hostname: types.maybe(types.string),
|
|
shortname: types.maybe(types.string),
|
|
units: types.array(types.frozen<MintUnit>()),
|
|
keysets: types.array(types.frozen<CashuMintKeyset>()),
|
|
keys: types.array(types.frozen<CashuMintKeys>()),
|
|
proofsCounters: types.array(MintProofsCounterModel),
|
|
color: types.optional(types.string, colors.palette.iconBlue200),
|
|
status: types.optional(types.frozen<MintStatus>(), MintStatus.ONLINE),
|
|
createdAt: types.optional(types.Date, new Date()),
|
|
})
|
|
.actions(withSetPropAction) // TODO? start to use across app to avoid pure setter methods, e.g. mint.setProp('color', '#ccc')
|
|
.actions(self => ({
|
|
addKeyset(keyset: CashuMintKeyset) {
|
|
const alreadyExists = self.keysets.some(k => k.id === keyset.id)
|
|
|
|
if(!alreadyExists) {
|
|
self.keysets.push(keyset)
|
|
self.keysets = cast(self.keysets)
|
|
}
|
|
},
|
|
removeKeyset(keyset: CashuMintKeyset) {
|
|
const index = self.keysets.findIndex(k => k.id === keyset.id)
|
|
|
|
if(index !== -1) {
|
|
self.keysets.splice(index, 1)
|
|
self.keysets = cast(self.keysets)
|
|
}
|
|
},
|
|
setIsActive(freshKeyset: CashuMintKeyset) {
|
|
const keyset = self.keysets.find(k => k.id === freshKeyset.id)
|
|
|
|
if(keyset) {
|
|
keyset.active = freshKeyset.active
|
|
self.keysets = cast(self.keysets)
|
|
}
|
|
},
|
|
setInputFeePpk(keysetId: string, inputFeePpk: number) {
|
|
const keyset = self.keysets.find(k => k.id === keysetId)
|
|
|
|
if(keyset) {
|
|
keyset.input_fee_ppk = inputFeePpk
|
|
self.keysets = cast(self.keysets)
|
|
}
|
|
},
|
|
addKeys(keys: CashuMintKeys) {
|
|
const alreadyExists = self.keys.some(k => k.id === keys.id)
|
|
|
|
if(!alreadyExists) {
|
|
self.keys.push(keys)
|
|
self.keys = cast(self.keys)
|
|
}
|
|
},
|
|
removeKeys(keys: CashuMintKeys) {
|
|
const index = self.keys.findIndex(k => k.id === keys.id)
|
|
|
|
if(index !== -1) {
|
|
self.keys.splice(index, 1)
|
|
self.keys = cast(self.keys)
|
|
}
|
|
},
|
|
addUnit(unit: MintUnit) {
|
|
const alreadyExists = self.units.some(u => u === unit)
|
|
|
|
if(!alreadyExists) {
|
|
self.units.push(unit)
|
|
self.units = cast(self.units)
|
|
}
|
|
},
|
|
removeUnit(unit: MintUnit) {
|
|
const index = self.units.findIndex(u => u === unit)
|
|
|
|
if(index !== -1) {
|
|
self.units.splice(index, 1)
|
|
self.units = cast(self.units)
|
|
}
|
|
},
|
|
addProofsCounter(counter: MintProofsCounter) {
|
|
const alreadyExists = self.proofsCounters.some(p => p.keyset === counter.keyset)
|
|
|
|
if(!alreadyExists) {
|
|
log.trace('[addProofsCounter]', {counter})
|
|
self.proofsCounters.push(counter)
|
|
self.proofsCounters = cast(self.proofsCounters)
|
|
}
|
|
|
|
},
|
|
removeProofsCounter(counter: MintProofsCounter) {
|
|
const index = self.proofsCounters.findIndex(p => p.keyset === counter.keyset)
|
|
|
|
if(index !== -1) {
|
|
self.proofsCounters.splice(index, 0)
|
|
self.proofsCounters = cast(self.proofsCounters)
|
|
}
|
|
},
|
|
getProofsCounter(keysetId: string) {
|
|
const counter = self.proofsCounters.find(c => c.keyset === keysetId)
|
|
|
|
// Make sure we did not lost counter, breaks the wallet
|
|
if(counter && isNaN(counter?.counter)) {
|
|
counter.counter = 0
|
|
self.proofsCounters = cast(self.proofsCounters)
|
|
}
|
|
|
|
return counter
|
|
},
|
|
isUnitSupported(unit: MintUnit): boolean {
|
|
return MintUnits.includes(unit) ? true : false
|
|
},
|
|
keysetExists(keyset: CashuMintKeyset): boolean {
|
|
return self.keysets.some(k => k.id === keyset.id)
|
|
},
|
|
}))
|
|
.actions(self => ({
|
|
createProofsCounter(keyset: CashuMintKeyset) {
|
|
const existing = self.getProofsCounter(keyset.id)
|
|
|
|
if(!existing) {
|
|
const newCounter = MintProofsCounterModel.create({
|
|
keyset: keyset.id,
|
|
unit: keyset.unit as MintUnit,
|
|
counter: 0,
|
|
})
|
|
|
|
self.addProofsCounter(newCounter)
|
|
return newCounter
|
|
}
|
|
|
|
return existing
|
|
}
|
|
}))
|
|
.actions(self => ({
|
|
initKeyset(keyset: CashuMintKeyset) {
|
|
// Do not add unit the wallet does not have configured
|
|
try {
|
|
if(!self.isUnitSupported(keyset.unit as MintUnit)) {
|
|
throw new AppError(Err.VALIDATION_ERROR, `Unsupported unit provided by the mint: ${keyset.unit}`)
|
|
}
|
|
|
|
const existing = self.keysets.find(k => k.id === keyset.id)
|
|
|
|
if(existing) {
|
|
if (existing.unit !== keyset.unit) {
|
|
throw new AppError(Err.VALIDATION_ERROR, `Keyset unit mismatch, got ${keyset.unit}, expected ${existing.unit}`)
|
|
}
|
|
|
|
if(keyset.input_fee_ppk && existing.input_fee_ppk !== keyset.input_fee_ppk) {
|
|
self.setInputFeePpk(existing.id, keyset.input_fee_ppk)
|
|
}
|
|
|
|
return existing
|
|
}
|
|
|
|
if(!keyset.input_fee_ppk) {
|
|
keyset.input_fee_ppk = 0
|
|
}
|
|
|
|
self.addKeyset(keyset)
|
|
self.addUnit(keyset.unit as MintUnit)
|
|
self.createProofsCounter(keyset)
|
|
|
|
log.trace('[initKeyset]', {newKeyset: keyset})
|
|
} catch (e: any) {
|
|
throw new AppError(Err.WALLET_ERROR, '[initKeyset] ' + e.message)
|
|
}
|
|
},
|
|
initKeys(key: CashuMintKeys) {
|
|
// Do not add unit the wallet does not have configured
|
|
if(!self.isUnitSupported(key.unit as MintUnit)) {
|
|
throw new AppError(Err.VALIDATION_ERROR, `Unsupported unit provided by the mint: ${key.unit}`)
|
|
}
|
|
|
|
const existing = self.keys.find(k => k.id === key.id)
|
|
|
|
if(existing) {
|
|
if (existing.unit !== key.unit) {
|
|
throw new AppError(Err.VALIDATION_ERROR, `Keyset unit mismatch, got ${key.unit}, expected ${existing.unit}`)
|
|
}
|
|
|
|
return existing
|
|
}
|
|
|
|
self.addKeys(key)
|
|
log.trace('[initKeys]', {newKeys: key.id})
|
|
},
|
|
validateURL(url: string) {
|
|
try {
|
|
new URL(url)
|
|
return true
|
|
} catch (e) {
|
|
return false
|
|
}
|
|
},
|
|
}))
|
|
.actions(self => ({
|
|
refreshKeysets(freshKeysets: CashuMintKeyset[]) {
|
|
// add new keyset if not exists
|
|
for (const keyset of freshKeysets) {
|
|
self.initKeyset(keyset)
|
|
self.setIsActive(keyset)
|
|
}
|
|
},
|
|
refreshKeys(freshKeys: CashuMintKeys[]) {
|
|
for (const key of freshKeys) {
|
|
self.initKeys(key)
|
|
}
|
|
},
|
|
getProofsCounterByKeysetId(keysetId: string) {
|
|
const counter = self.proofsCounters.find(p => p.keyset === keysetId)
|
|
if(!counter) {
|
|
const keyset = self.keysets.find(k => k.id === keysetId)
|
|
if(!keyset) {
|
|
throw new AppError(Err.VALIDATION_ERROR, 'Missing keyset.')
|
|
}
|
|
return self.createProofsCounter(keyset)
|
|
}
|
|
|
|
return counter
|
|
},
|
|
setHostname() {
|
|
try {
|
|
self.hostname = new URL(self.mintUrl).hostname
|
|
} catch (e) {
|
|
return false
|
|
}
|
|
},
|
|
setMintUrl(url: string) {
|
|
if(self.validateURL(url)) {
|
|
const mintsStore = getRootStore(self).mintsStore
|
|
|
|
if(!mintsStore.alreadyExists(url)) {
|
|
|
|
const proofsStore = getRootStore(self).proofsStore
|
|
proofsStore.updateMintUrl(self.mintUrl, url) // update mintUrl on mint's proofs
|
|
self.mintUrl = url
|
|
|
|
return true
|
|
}
|
|
|
|
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL already exists.', {url})
|
|
} else {
|
|
throw new AppError(Err.VALIDATION_ERROR, 'Invalid Mint URL.', {url})
|
|
}
|
|
},
|
|
setId() { // migration
|
|
self.id = generateId(8)
|
|
},
|
|
setShortname: flow(function* setShortname() {
|
|
// get name from URL as a fallback
|
|
const lastSlashIndex = self.mintUrl.lastIndexOf('/')
|
|
let shortname = self.mintUrl.substring(lastSlashIndex + 1).slice(0, 25)
|
|
|
|
try {
|
|
const cashuMint = new CashuMint(self.mintUrl)
|
|
const info: GetInfoResponse = yield cashuMint.getInfo()
|
|
|
|
if(info.name.length > 0) {
|
|
shortname = info.name
|
|
}
|
|
|
|
self.shortname = shortname
|
|
|
|
} catch (e: any) {
|
|
log.warn('[setShortname]', {error: e.message})
|
|
self.shortname = shortname
|
|
}
|
|
}),
|
|
setRandomColor() {
|
|
self.color = getRandomIconColor()
|
|
},
|
|
setColor(color: string) {
|
|
self.color = color
|
|
},
|
|
setStatus(status: MintStatus) {
|
|
self.status = status
|
|
},
|
|
resetCounters() {
|
|
for(const counter of self.proofsCounters) {
|
|
log.warn('Resetting counter', counter.keyset)
|
|
counter.counter = 0
|
|
}
|
|
|
|
self.proofsCounters = cast(self.proofsCounters)
|
|
},
|
|
getMintFeeReserve(proofs: CashuProof[] | Proof[]): number {
|
|
// Find the corresponding keyset for each proof and sum the input fees
|
|
const totalInputFees = proofs.reduce((sum, proof) => {
|
|
const keyset = self.keysets.find(k => k.id === proof.id)
|
|
return keyset && keyset.input_fee_ppk ? sum + keyset.input_fee_ppk : sum
|
|
}, 0)
|
|
|
|
// Calculate the fees
|
|
const feeReserve = Math.max(Math.floor((totalInputFees + 999) / 1000), 0)
|
|
|
|
log.trace('[getMintFeeReserve]', {feeReserve})
|
|
return feeReserve
|
|
},
|
|
removeAllInFlightRequests() {
|
|
log.trace('[removeAllInFlightRequests] Removing all inFlight requests', {mintUrl: self.mintUrl})
|
|
for(const counter of self.proofsCounters) {
|
|
counter.inFlightRequests.length = 0
|
|
}
|
|
},
|
|
}))
|
|
.views(self => ({
|
|
findInFlightRequestByTId: (transactionId: number) => {
|
|
const inFlightCounters = self.proofsCounters.filter(c => c.inFlightRequests && c.inFlightRequests.length > 0)
|
|
let inFlightRequest: InFlightRequest | undefined = undefined
|
|
|
|
for (const counter of inFlightCounters) {
|
|
const request = counter.inFlightRequests.find(r => r.transactionId === transactionId)
|
|
if(request) {
|
|
inFlightRequest = request
|
|
break
|
|
}
|
|
}
|
|
|
|
return inFlightRequest
|
|
},
|
|
get proofsCountersWithInFlightRequests(): MintProofsCounter[] {
|
|
const counters = self.proofsCounters.filter(c => c.inFlightRequests && c.inFlightRequests.length > 0)
|
|
return counters as MintProofsCounter[]
|
|
},
|
|
get allInFLightRequests(): InFlightRequest[] {
|
|
const requests = self.proofsCounters
|
|
.flatMap((counter) => counter.inFlightRequests) // Combine all `inFlightRequests` arrays
|
|
|
|
return requests
|
|
},
|
|
get balances(): MintBalance | undefined {
|
|
const mintBalance: MintBalance | undefined = getRootStore(self).proofsStore.getMintBalance(self.mintUrl)
|
|
return mintBalance
|
|
},
|
|
get keysetIds(): string[] {
|
|
return self.keysets.map(k => k.id)
|
|
}
|
|
}))
|
|
|
|
|
|
|
|
export type Mint = {
|
|
mintUrl: string
|
|
} & Partial<Instance<typeof MintModel>>
|
|
export interface MintSnapshotOut extends SnapshotOut<typeof MintModel> {}
|
|
export interface MintSnapshotIn extends SnapshotIn<typeof MintModel> {} |