UNPKG

@sovryn-zero/lib-base

Version:
555 lines (461 loc) 15.4 kB
import assert from "assert"; import { Decimal } from "./Decimal"; import { StabilityDeposit } from "./StabilityDeposit"; import { Trove, TroveWithPendingRedistribution, UserTrove } from "./Trove"; import { Fees } from "./Fees"; import { ZEROStake } from "./ZEROStake"; import { FrontendStatus } from "./ReadableLiquity"; /** * State variables read from the blockchain. * * @public */ export interface LiquityStoreBaseState { /** Status of currently used frontend. */ frontend: FrontendStatus; /** Status of user's own frontend. */ ownFrontend: FrontendStatus; /** Number of Troves that are currently open. */ numberOfTroves: number; /** User's native currency balance (e.g. Ether). */ accountBalance: Decimal; /** User's ZUSD token balance. */ zusdBalance: Decimal; /** User's NUE token balance. */ nueBalance: Decimal; /** User's ZERO token balance. */ zeroBalance: Decimal; /** * Amount of leftover collateral available for withdrawal to the user. * * @remarks * See {@link ReadableLiquity.getCollateralSurplusBalance | getCollateralSurplusBalance()} for * more information. */ collateralSurplusBalance: Decimal; /** Current price of the native currency (e.g. Ether) in USD. */ price: Decimal; /** Total amount of ZUSD currently deposited in the Stability Pool. */ zusdInStabilityPool: Decimal; /** Total collateral and debt in the Zero system. */ total: Trove; /** * Total collateral and debt per stake that has been liquidated through redistribution. * * @remarks * Needed when dealing with instances of {@link TroveWithPendingRedistribution}. */ totalRedistributed: Trove; /** * User's Trove in its state after the last direct modification. * * @remarks * The current state of the user's Trove can be found as * {@link LiquityStoreDerivedState.trove | trove}. */ troveBeforeRedistribution: TroveWithPendingRedistribution; /** User's stability deposit. */ stabilityDeposit: StabilityDeposit; /** @internal */ _feesInNormalMode: Fees; /** User's ZERO stake. */ zeroStake: ZEROStake; /** Total amount of ZERO currently staked. */ totalStakedZERO: Decimal; /** @internal */ _riskiestTroveBeforeRedistribution: TroveWithPendingRedistribution; } /** * State variables derived from {@link LiquityStoreBaseState}. * * @public */ export interface LiquityStoreDerivedState { /** Current state of user's Trove */ trove: UserTrove; /** Calculator for current fees. */ fees: Fees; /** * Current borrowing rate. * * @remarks * A value between 0 and 1. * * @example * For example a value of 0.01 amounts to a borrowing fee of 1% of the borrowed amount. */ borrowingRate: Decimal; /** * Current redemption rate. * * @remarks * Note that the actual rate paid by a redemption transaction will depend on the amount of ZUSD * being redeemed. * * Use {@link Fees.redemptionRate} to calculate a precise redemption rate. */ redemptionRate: Decimal; /** * Whether there are any Troves with collateral ratio below the * {@link MINIMUM_COLLATERAL_RATIO | minimum}. */ haveUndercollateralizedTroves: boolean; } /** * Type of {@link LiquityStore}'s {@link LiquityStore.state | state}. * * @remarks * It combines all properties of {@link LiquityStoreBaseState} and {@link LiquityStoreDerivedState} * with optional extra state added by the particular `LiquityStore` implementation. * * The type parameter `T` may be used to type the extra state. * * @public */ export type LiquityStoreState<T = unknown> = LiquityStoreBaseState & LiquityStoreDerivedState & T; /** * Parameters passed to {@link LiquityStore} listeners. * * @remarks * Use the {@link LiquityStore.subscribe | subscribe()} function to register a listener. * @public */ export interface LiquityStoreListenerParams<T = unknown> { /** The entire previous state. */ newState: LiquityStoreState<T>; /** The entire new state. */ oldState: LiquityStoreState<T>; /** Only the state variables that have changed. */ stateChange: Partial<LiquityStoreState<T>>; } const strictEquals = <T>(a: T, b: T) => a === b; const eq = <T extends { eq(that: T): boolean }>(a: T, b: T) => a.eq(b); const equals = <T extends { equals(that: T): boolean }>(a: T, b: T) => a.equals(b); const frontendStatusEquals = (a: FrontendStatus, b: FrontendStatus) => a.status === "unregistered" ? b.status === "unregistered" : b.status === "registered" && a.kickbackRate.eq(b.kickbackRate); const showFrontendStatus = (x: FrontendStatus) => x.status === "unregistered" ? '{ status: "unregistered" }' : `{ status: "registered", kickbackRate: ${x.kickbackRate} }`; const wrap = <A extends unknown[], R>(f: (...args: A) => R) => (...args: A) => f(...args); const difference = <T>(a: T, b: T) => Object.fromEntries( Object.entries(a).filter(([key, value]) => value !== (b as Record<string, unknown>)[key]) ) as Partial<T>; /** * Abstract base class of Zero data store implementations. * * @remarks * The type parameter `T` may be used to type extra state added to {@link LiquityStoreState} by the * subclass. * * Implemented by {@link @sovryn-zero/lib-ethers#BlockPolledLiquityStore}. * * @public */ export abstract class LiquityStore<T = unknown> { /** Turn console logging on/off. */ logging = false; /** * Called after the state is fetched for the first time. * * @remarks * See {@link LiquityStore.start | start()}. */ onLoaded?: () => void; /** @internal */ protected _loaded = false; private _baseState?: LiquityStoreBaseState; private _derivedState?: LiquityStoreDerivedState; private _extraState?: T; private _updateTimeoutId: ReturnType<typeof setTimeout> | undefined; private _listeners = new Set<(params: LiquityStoreListenerParams<T>) => void>(); /** * The current store state. * * @remarks * Should not be accessed before the store is loaded. Assign a function to * {@link LiquityStore.onLoaded | onLoaded} to get a callback when this happens. * * See {@link LiquityStoreState} for the list of properties returned. */ get state(): LiquityStoreState<T> { return Object.assign({}, this._baseState, this._derivedState, this._extraState); } /** @internal */ protected abstract _doStart(): () => void; /** * Start monitoring the blockchain for Zero state changes. * * @remarks * The {@link LiquityStore.onLoaded | onLoaded} callback will be called after the state is fetched * for the first time. * * Use the {@link LiquityStore.subscribe | subscribe()} function to register listeners. * * @returns Function to stop the monitoring. */ start(): () => void { const doStop = this._doStart(); return () => { doStop(); this._cancelUpdateIfScheduled(); }; } private _cancelUpdateIfScheduled() { if (this._updateTimeoutId !== undefined) { clearTimeout(this._updateTimeoutId); } } private _scheduleUpdate() { this._cancelUpdateIfScheduled(); this._updateTimeoutId = setTimeout(() => { this._updateTimeoutId = undefined; this._update(); }, 30000); } private _logUpdate<U>(name: string, next: U, show?: (next: U) => string): U { if (this.logging) { console.log(`${name} updated to ${show ? show(next) : next}`); } return next; } private _updateIfChanged<U>( equals: (a: U, b: U) => boolean, name: string, prev: U, next?: U, show?: (next: U) => string ): U { return next !== undefined && !equals(prev, next) ? this._logUpdate(name, next, show) : prev; } private _silentlyUpdateIfChanged<U>(equals: (a: U, b: U) => boolean, prev: U, next?: U): U { return next !== undefined && !equals(prev, next) ? next : prev; } private _updateFees(name: string, prev: Fees, next?: Fees): Fees { if (next && !next.equals(prev)) { // Filter out fee update spam that happens on every new block by only logging when string // representation changes. if (`${next}` !== `${prev}`) { this._logUpdate(name, next); } return next; } else { return prev; } } private _reduce( baseState: LiquityStoreBaseState, baseStateUpdate: Partial<LiquityStoreBaseState> ): LiquityStoreBaseState { return { frontend: this._updateIfChanged( frontendStatusEquals, "frontend", baseState.frontend, baseStateUpdate.frontend, showFrontendStatus ), ownFrontend: this._updateIfChanged( frontendStatusEquals, "ownFrontend", baseState.ownFrontend, baseStateUpdate.ownFrontend, showFrontendStatus ), numberOfTroves: this._updateIfChanged( strictEquals, "numberOfTroves", baseState.numberOfTroves, baseStateUpdate.numberOfTroves ), accountBalance: this._updateIfChanged( eq, "accountBalance", baseState.accountBalance, baseStateUpdate.accountBalance ), zusdBalance: this._updateIfChanged( eq, "zusdBalance", baseState.zusdBalance, baseStateUpdate.zusdBalance ), nueBalance: this._updateIfChanged( eq, "nueBalance", baseState.nueBalance, baseStateUpdate.nueBalance ), zeroBalance: this._updateIfChanged( eq, "zeroBalance", baseState.zeroBalance, baseStateUpdate.zeroBalance ), collateralSurplusBalance: this._updateIfChanged( eq, "collateralSurplusBalance", baseState.collateralSurplusBalance, baseStateUpdate.collateralSurplusBalance ), price: this._updateIfChanged(eq, "price", baseState.price, baseStateUpdate.price), zusdInStabilityPool: this._updateIfChanged( eq, "zusdInStabilityPool", baseState.zusdInStabilityPool, baseStateUpdate.zusdInStabilityPool ), total: this._updateIfChanged(equals, "total", baseState.total, baseStateUpdate.total), totalRedistributed: this._updateIfChanged( equals, "totalRedistributed", baseState.totalRedistributed, baseStateUpdate.totalRedistributed ), troveBeforeRedistribution: this._updateIfChanged( equals, "troveBeforeRedistribution", baseState.troveBeforeRedistribution, baseStateUpdate.troveBeforeRedistribution ), stabilityDeposit: this._updateIfChanged( equals, "stabilityDeposit", baseState.stabilityDeposit, baseStateUpdate.stabilityDeposit ), _feesInNormalMode: this._silentlyUpdateIfChanged( equals, baseState._feesInNormalMode, baseStateUpdate._feesInNormalMode ), zeroStake: this._updateIfChanged( equals, "zeroStake", baseState.zeroStake, baseStateUpdate.zeroStake ), totalStakedZERO: this._updateIfChanged( eq, "totalStakedZERO", baseState.totalStakedZERO, baseStateUpdate.totalStakedZERO ), _riskiestTroveBeforeRedistribution: this._silentlyUpdateIfChanged( equals, baseState._riskiestTroveBeforeRedistribution, baseStateUpdate._riskiestTroveBeforeRedistribution ) }; } private _derive({ troveBeforeRedistribution, totalRedistributed, _feesInNormalMode, total, price, _riskiestTroveBeforeRedistribution }: LiquityStoreBaseState): LiquityStoreDerivedState { const fees = _feesInNormalMode._setRecoveryMode(total.collateralRatioIsBelowCritical(price)); return { trove: troveBeforeRedistribution.applyRedistribution(totalRedistributed), fees, borrowingRate: fees.borrowingRate(), redemptionRate: fees.redemptionRate(), haveUndercollateralizedTroves: _riskiestTroveBeforeRedistribution .applyRedistribution(totalRedistributed) .collateralRatioIsBelowMinimum(price) }; } private _reduceDerived( derivedState: LiquityStoreDerivedState, derivedStateUpdate: LiquityStoreDerivedState ): LiquityStoreDerivedState { return { fees: this._updateFees("fees", derivedState.fees, derivedStateUpdate.fees), trove: this._updateIfChanged(equals, "trove", derivedState.trove, derivedStateUpdate.trove), borrowingRate: this._silentlyUpdateIfChanged( eq, derivedState.borrowingRate, derivedStateUpdate.borrowingRate ), redemptionRate: this._silentlyUpdateIfChanged( eq, derivedState.redemptionRate, derivedStateUpdate.redemptionRate ), haveUndercollateralizedTroves: this._updateIfChanged( strictEquals, "haveUndercollateralizedTroves", derivedState.haveUndercollateralizedTroves, derivedStateUpdate.haveUndercollateralizedTroves ) }; } /** @internal */ protected abstract _reduceExtra(extraState: T, extraStateUpdate: Partial<T>): T; private _notify(params: LiquityStoreListenerParams<T>) { // Iterate on a copy of `_listeners`, to avoid notifying any new listeners subscribed by // existing listeners, as that could result in infinite loops. // // Before calling a listener from our copy of `_listeners`, check if it has been removed from // the original set. This way we avoid calling listeners that have already been unsubscribed // by an earlier listener callback. [...this._listeners].forEach(listener => { if (this._listeners.has(listener)) { listener(params); } }); } /** * Register a state change listener. * * @param listener - Function that will be called whenever state changes. * @returns Function to unregister this listener. */ subscribe(listener: (params: LiquityStoreListenerParams<T>) => void): () => void { const uniqueListener = wrap(listener); this._listeners.add(uniqueListener); return () => { this._listeners.delete(uniqueListener); }; } /** @internal */ protected _load(baseState: LiquityStoreBaseState, extraState?: T): void { assert(!this._loaded); this._baseState = baseState; this._derivedState = this._derive(baseState); this._extraState = extraState; this._loaded = true; this._scheduleUpdate(); if (this.onLoaded) { this.onLoaded(); } } /** @internal */ protected _update( baseStateUpdate?: Partial<LiquityStoreBaseState>, extraStateUpdate?: Partial<T> ): void { assert(this._baseState && this._derivedState); const oldState = this.state; if (baseStateUpdate) { this._baseState = this._reduce(this._baseState, baseStateUpdate); } // Always running this lets us derive state based on passage of time, like baseRate decay this._derivedState = this._reduceDerived(this._derivedState, this._derive(this._baseState)); if (extraStateUpdate) { assert(this._extraState); this._extraState = this._reduceExtra(this._extraState, extraStateUpdate); } this._scheduleUpdate(); this._notify({ newState: this.state, oldState, stateChange: difference(this.state, oldState) }); } }