UNPKG

@sovryn-zero/lib-base

Version:
825 lines (716 loc) 24.2 kB
import assert from "assert"; import { Decimal, Decimalish } from "./Decimal"; import { MINIMUM_COLLATERAL_RATIO, CRITICAL_COLLATERAL_RATIO, ZUSD_LIQUIDATION_RESERVE, MINIMUM_BORROWING_RATE } from "./constants"; /** @internal */ export type _CollateralDeposit<T> = { depositCollateral: T }; /** @internal */ export type _CollateralWithdrawal<T> = { withdrawCollateral: T }; /** @internal */ export type _ZUSDBorrowing<T> = { borrowZUSD: T }; /** @internal */ export type _ZUSDRepayment<T> = { repayZUSD: T }; /** @internal */ export type _NoCollateralDeposit = Partial<_CollateralDeposit<undefined>>; /** @internal */ export type _NoCollateralWithdrawal = Partial<_CollateralWithdrawal<undefined>>; /** @internal */ export type _NoZUSDBorrowing = Partial<_ZUSDBorrowing<undefined>>; /** @internal */ export type _NoZUSDRepayment = Partial<_ZUSDRepayment<undefined>>; /** @internal */ export type _CollateralChange<T> = | (_CollateralDeposit<T> & _NoCollateralWithdrawal) | (_CollateralWithdrawal<T> & _NoCollateralDeposit); /** @internal */ export type _NoCollateralChange = _NoCollateralDeposit & _NoCollateralWithdrawal; /** @internal */ export type _DebtChange<T> = | (_ZUSDBorrowing<T> & _NoZUSDRepayment) | (_ZUSDRepayment<T> & _NoZUSDBorrowing); /** @internal */ export type _NoDebtChange = _NoZUSDBorrowing & _NoZUSDRepayment; /** * Parameters of an {@link TransactableLiquity.openTrove | openTrove()} transaction. * * @remarks * The type parameter `T` specifies the allowed value type(s) of the particular `TroveCreationParams` * object's properties. * * <h2>Properties</h2> * * <table> * * <tr> * <th> Property </th> * <th> Type </th> * <th> Description </th> * </tr> * * <tr> * <td> depositCollateral </td> * <td> T </td> * <td> The amount of collateral that's deposited. </td> * </tr> * * <tr> * <td> borrowZUSD </td> * <td> T </td> * <td> The amount of ZUSD that's borrowed. </td> * </tr> * * </table> * * @public */ export type TroveCreationParams<T = unknown> = _CollateralDeposit<T> & _NoCollateralWithdrawal & _ZUSDBorrowing<T> & _NoZUSDRepayment; /** * Parameters of a {@link TransactableLiquity.closeTrove | closeTrove()} transaction. * * @remarks * The type parameter `T` specifies the allowed value type(s) of the particular `TroveClosureParams` * object's properties. * * <h2>Properties</h2> * * <table> * * <tr> * <th> Property </th> * <th> Type </th> * <th> Description </th> * </tr> * * <tr> * <td> withdrawCollateral </td> * <td> T </td> * <td> The amount of collateral that's withdrawn. </td> * </tr> * * <tr> * <td> repayZUSD? </td> * <td> T </td> * <td> <i>(Optional)</i> The amount of ZUSD that's repaid. </td> * </tr> * * </table> * * @public */ export type TroveClosureParams<T> = _CollateralWithdrawal<T> & _NoCollateralDeposit & Partial<_ZUSDRepayment<T>> & _NoZUSDBorrowing; /** * Parameters of an {@link TransactableLiquity.adjustTrove | adjustTrove()} transaction. * * @remarks * The type parameter `T` specifies the allowed value type(s) of the particular * `TroveAdjustmentParams` object's properties. * * Even though all properties are optional, a valid `TroveAdjustmentParams` object must define at * least one. * * Defining both `depositCollateral` and `withdrawCollateral`, or both `borrowZUSD` and `repayZUSD` * at the same time is disallowed, and will result in a type-checking error. * * <h2>Properties</h2> * * <table> * * <tr> * <th> Property </th> * <th> Type </th> * <th> Description </th> * </tr> * * <tr> * <td> depositCollateral? </td> * <td> T </td> * <td> <i>(Optional)</i> The amount of collateral that's deposited. </td> * </tr> * * <tr> * <td> withdrawCollateral? </td> * <td> T </td> * <td> <i>(Optional)</i> The amount of collateral that's withdrawn. </td> * </tr> * * <tr> * <td> borrowZUSD? </td> * <td> T </td> * <td> <i>(Optional)</i> The amount of ZUSD that's borrowed. </td> * </tr> * * <tr> * <td> repayZUSD? </td> * <td> T </td> * <td> <i>(Optional)</i> The amount of ZUSD that's repaid. </td> * </tr> * * </table> * * @public */ export type TroveAdjustmentParams<T = unknown> = | (_CollateralChange<T> & _NoDebtChange) | (_DebtChange<T> & _NoCollateralChange) | (_CollateralChange<T> & _DebtChange<T>); /** * Describes why a Trove could not be created. * * @remarks * See {@link TroveChange}. * * <h2>Possible values</h2> * * <table> * * <tr> * <th> Value </th> * <th> Reason </th> * </tr> * * <tr> * <td> "missingLiquidationReserve" </td> * <td> A Trove's debt cannot be less than the liquidation reserve. </td> * </tr> * * </table> * * More errors may be added in the future. * * @public */ export type TroveCreationError = "missingLiquidationReserve"; /** * Represents the change between two Trove states. * * @remarks * Returned by {@link Trove.whatChanged}. * * Passed as a parameter to {@link Trove.apply}. * * @public */ export type TroveChange<T> = | { type: "invalidCreation"; invalidTrove: Trove; error: TroveCreationError } | { type: "creation"; params: TroveCreationParams<T> } | { type: "closure"; params: TroveClosureParams<T> } | { type: "adjustment"; params: TroveAdjustmentParams<T>; setToZero?: "collateral" | "debt" }; // This might seem backwards, but this way we avoid spamming the .d.ts and generated docs type InvalidTroveCreation = Extract<TroveChange<never>, { type: "invalidCreation" }>; type TroveCreation<T> = Extract<TroveChange<T>, { type: "creation" }>; type TroveClosure<T> = Extract<TroveChange<T>, { type: "closure" }>; type TroveAdjustment<T> = Extract<TroveChange<T>, { type: "adjustment" }>; const invalidTroveCreation = ( invalidTrove: Trove, error: TroveCreationError ): InvalidTroveCreation => ({ type: "invalidCreation", invalidTrove, error }); const troveCreation = <T>(params: TroveCreationParams<T>): TroveCreation<T> => ({ type: "creation", params }); const troveClosure = <T>(params: TroveClosureParams<T>): TroveClosure<T> => ({ type: "closure", params }); const troveAdjustment = <T>( params: TroveAdjustmentParams<T>, setToZero?: "collateral" | "debt" ): TroveAdjustment<T> => ({ type: "adjustment", params, setToZero }); const valueIsDefined = <T>(entry: [string, T | undefined]): entry is [string, T] => entry[1] !== undefined; type AllowedKey<T> = Exclude< { [P in keyof T]: T[P] extends undefined ? never : P; }[keyof T], undefined >; const allowedTroveCreationKeys: AllowedKey<TroveCreationParams>[] = [ "depositCollateral", "borrowZUSD" ]; function checkAllowedTroveCreationKeys<T>( entries: [string, T][] ): asserts entries is [AllowedKey<TroveCreationParams>, T][] { const badKeys = entries .filter(([k]) => !(allowedTroveCreationKeys as string[]).includes(k)) .map(([k]) => `'${k}'`); if (badKeys.length > 0) { throw new Error(`TroveCreationParams: property ${badKeys.join(", ")} not allowed`); } } const troveCreationParamsFromEntries = <T>( entries: [AllowedKey<TroveCreationParams>, T][] ): TroveCreationParams<T> => { const params = Object.fromEntries(entries) as Record<AllowedKey<TroveCreationParams>, T>; const missingKeys = allowedTroveCreationKeys.filter(k => !(k in params)).map(k => `'${k}'`); if (missingKeys.length > 0) { throw new Error(`TroveCreationParams: property ${missingKeys.join(", ")} missing`); } return params; }; const decimalize = <T>([k, v]: [T, Decimalish]): [T, Decimal] => [k, Decimal.from(v)]; const nonZero = <T>([, v]: [T, Decimal]): boolean => !v.isZero; /** @internal */ export const _normalizeTroveCreation = ( params: Record<string, Decimalish | undefined> ): TroveCreationParams<Decimal> => { const definedEntries = Object.entries(params).filter(valueIsDefined); checkAllowedTroveCreationKeys(definedEntries); const nonZeroEntries = definedEntries.map(decimalize); return troveCreationParamsFromEntries(nonZeroEntries); }; const allowedTroveAdjustmentKeys: AllowedKey<TroveAdjustmentParams>[] = [ "depositCollateral", "withdrawCollateral", "borrowZUSD", "repayZUSD" ]; function checkAllowedTroveAdjustmentKeys<T>( entries: [string, T][] ): asserts entries is [AllowedKey<TroveAdjustmentParams>, T][] { const badKeys = entries .filter(([k]) => !(allowedTroveAdjustmentKeys as string[]).includes(k)) .map(([k]) => `'${k}'`); if (badKeys.length > 0) { throw new Error(`TroveAdjustmentParams: property ${badKeys.join(", ")} not allowed`); } } const collateralChangeFrom = <T>({ depositCollateral, withdrawCollateral }: Partial<Record<AllowedKey<TroveAdjustmentParams>, T>>): _CollateralChange<T> | undefined => { if (depositCollateral !== undefined && withdrawCollateral !== undefined) { throw new Error( "TroveAdjustmentParams: 'depositCollateral' and 'withdrawCollateral' " + "can't be present at the same time" ); } if (depositCollateral !== undefined) { return { depositCollateral }; } if (withdrawCollateral !== undefined) { return { withdrawCollateral }; } }; const debtChangeFrom = <T>({ borrowZUSD, repayZUSD }: Partial<Record<AllowedKey<TroveAdjustmentParams>, T>>): _DebtChange<T> | undefined => { if (borrowZUSD !== undefined && repayZUSD !== undefined) { throw new Error( "TroveAdjustmentParams: 'borrowZUSD' and 'repayZUSD' can't be present at the same time" ); } if (borrowZUSD !== undefined) { return { borrowZUSD }; } if (repayZUSD !== undefined) { return { repayZUSD }; } }; const troveAdjustmentParamsFromEntries = <T>( entries: [AllowedKey<TroveAdjustmentParams>, T][] ): TroveAdjustmentParams<T> => { const params = Object.fromEntries(entries) as Partial< Record<AllowedKey<TroveAdjustmentParams>, T> >; const collateralChange = collateralChangeFrom(params); const debtChange = debtChangeFrom(params); if (collateralChange !== undefined && debtChange !== undefined) { return { ...collateralChange, ...debtChange }; } if (collateralChange !== undefined) { return collateralChange; } if (debtChange !== undefined) { return debtChange; } throw new Error("TroveAdjustmentParams: must include at least one non-zero parameter"); }; /** @internal */ export const _normalizeTroveAdjustment = ( params: Record<string, Decimalish | undefined> ): TroveAdjustmentParams<Decimal> => { const definedEntries = Object.entries(params).filter(valueIsDefined); checkAllowedTroveAdjustmentKeys(definedEntries); const nonZeroEntries = definedEntries.map(decimalize).filter(nonZero); return troveAdjustmentParamsFromEntries(nonZeroEntries); }; const applyFee = (borrowingRate: Decimalish, debtIncrease: Decimal) => debtIncrease.mul(Decimal.ONE.add(borrowingRate)); const unapplyFee = (borrowingRate: Decimalish, debtIncrease: Decimal) => debtIncrease._divCeil(Decimal.ONE.add(borrowingRate)); const NOMINAL_COLLATERAL_RATIO_PRECISION = Decimal.from(100); /** * A combination of collateral and debt. * * @public */ export class Trove { /** Amount of native currency (e.g. Ether) collateralized. */ readonly collateral: Decimal; /** Amount of ZUSD owed. */ readonly debt: Decimal; /** @internal */ constructor(collateral = Decimal.ZERO, debt = Decimal.ZERO) { this.collateral = collateral; this.debt = debt; } get isEmpty(): boolean { return this.collateral.isZero && this.debt.isZero; } /** * Amount of ZUSD that must be repaid to close this Trove. * * @remarks * This doesn't include the liquidation reserve, which is refunded in case of normal closure. */ get netDebt(): Decimal { if (this.debt.lt(ZUSD_LIQUIDATION_RESERVE)) { throw new Error(`netDebt should not be used when debt < ${ZUSD_LIQUIDATION_RESERVE}`); } return this.debt.sub(ZUSD_LIQUIDATION_RESERVE); } /** @internal */ get _nominalCollateralRatio(): Decimal { return this.collateral.mulDiv(NOMINAL_COLLATERAL_RATIO_PRECISION, this.debt); } /** Calculate the Trove's collateralization ratio at a given price. */ collateralRatio(price: Decimalish): Decimal { return this.collateral.mulDiv(price, this.debt); } /** * Whether the Trove is undercollateralized at a given price. * * @returns * `true` if the Trove's collateralization ratio is less than the * {@link MINIMUM_COLLATERAL_RATIO}. */ collateralRatioIsBelowMinimum(price: Decimalish): boolean { return this.collateralRatio(price).lt(MINIMUM_COLLATERAL_RATIO); } /** * Whether the collateralization ratio is less than the {@link CRITICAL_COLLATERAL_RATIO} at a * given price. * * @example * Can be used to check whether the Zero protocol is in recovery mode by using it on the return * value of {@link ReadableLiquity.getTotal | getTotal()}. For example: * * ```typescript * const total = await zero.getTotal(); * const price = await zero.getPrice(); * * if (total.collateralRatioIsBelowCritical(price)) { * // Recovery mode is active * } * ``` */ collateralRatioIsBelowCritical(price: Decimalish): boolean { return this.collateralRatio(price).lt(CRITICAL_COLLATERAL_RATIO); } /** Whether the Trove is sufficiently collateralized to be opened during recovery mode. */ isOpenableInRecoveryMode(price: Decimalish): boolean { return this.collateralRatio(price).gte(CRITICAL_COLLATERAL_RATIO); } /** @internal */ toString(): string { return `{ collateral: ${this.collateral}, debt: ${this.debt} }`; } equals(that: Trove): boolean { return this.collateral.eq(that.collateral) && this.debt.eq(that.debt); } add(that: Trove): Trove { return new Trove(this.collateral.add(that.collateral), this.debt.add(that.debt)); } addCollateral(collateral: Decimalish): Trove { return new Trove(this.collateral.add(collateral), this.debt); } addDebt(debt: Decimalish): Trove { return new Trove(this.collateral, this.debt.add(debt)); } subtract(that: Trove): Trove { const { collateral, debt } = that; return new Trove( this.collateral.gt(collateral) ? this.collateral.sub(collateral) : Decimal.ZERO, this.debt.gt(debt) ? this.debt.sub(debt) : Decimal.ZERO ); } subtractCollateral(collateral: Decimalish): Trove { return new Trove( this.collateral.gt(collateral) ? this.collateral.sub(collateral) : Decimal.ZERO, this.debt ); } subtractDebt(debt: Decimalish): Trove { return new Trove(this.collateral, this.debt.gt(debt) ? this.debt.sub(debt) : Decimal.ZERO); } multiply(multiplier: Decimalish): Trove { return new Trove(this.collateral.mul(multiplier), this.debt.mul(multiplier)); } setCollateral(collateral: Decimalish): Trove { return new Trove(Decimal.from(collateral), this.debt); } setDebt(debt: Decimalish): Trove { return new Trove(this.collateral, Decimal.from(debt)); } private _debtChange({ debt }: Trove, borrowingRate: Decimalish): _DebtChange<Decimal> { return debt.gt(this.debt) ? { borrowZUSD: unapplyFee(borrowingRate, debt.sub(this.debt)) } : { repayZUSD: this.debt.sub(debt) }; } private _collateralChange({ collateral }: Trove): _CollateralChange<Decimal> { return collateral.gt(this.collateral) ? { depositCollateral: collateral.sub(this.collateral) } : { withdrawCollateral: this.collateral.sub(collateral) }; } /** * Calculate the difference between this Trove and another. * * @param that - The other Trove. * @param borrowingRate - Borrowing rate to use when calculating a borrowed amount. * * @returns * An object representing the change, or `undefined` if the Troves are equal. */ whatChanged( that: Trove, borrowingRate: Decimalish = MINIMUM_BORROWING_RATE ): TroveChange<Decimal> | undefined { if (this.collateral.eq(that.collateral) && this.debt.eq(that.debt)) { return undefined; } if (this.isEmpty) { if (that.debt.lt(ZUSD_LIQUIDATION_RESERVE)) { return invalidTroveCreation(that, "missingLiquidationReserve"); } return troveCreation({ depositCollateral: that.collateral, borrowZUSD: unapplyFee(borrowingRate, that.netDebt) }); } if (that.isEmpty) { return troveClosure( this.netDebt.nonZero ? { withdrawCollateral: this.collateral, repayZUSD: this.netDebt } : { withdrawCollateral: this.collateral } ); } return this.collateral.eq(that.collateral) ? troveAdjustment<Decimal>(this._debtChange(that, borrowingRate), that.debt.zero && "debt") : this.debt.eq(that.debt) ? troveAdjustment<Decimal>(this._collateralChange(that), that.collateral.zero && "collateral") : troveAdjustment<Decimal>( { ...this._debtChange(that, borrowingRate), ...this._collateralChange(that) }, (that.debt.zero && "debt") ?? (that.collateral.zero && "collateral") ); } /** * Make a new Trove by applying a {@link TroveChange} to this Trove. * * @param change - The change to apply. * @param borrowingRate - Borrowing rate to use when adding a borrowed amount to the Trove's debt. */ apply( change: TroveChange<Decimal> | undefined, borrowingRate: Decimalish = MINIMUM_BORROWING_RATE ): Trove { if (!change) { return this; } switch (change.type) { case "invalidCreation": if (!this.isEmpty) { throw new Error("Can't create onto existing Trove"); } return change.invalidTrove; case "creation": { if (!this.isEmpty) { throw new Error("Can't create onto existing Trove"); } const { depositCollateral, borrowZUSD } = change.params; return new Trove( depositCollateral, ZUSD_LIQUIDATION_RESERVE.add(applyFee(borrowingRate, borrowZUSD)) ); } case "closure": if (this.isEmpty) { throw new Error("Can't close empty Trove"); } return _emptyTrove; case "adjustment": { const { setToZero, params: { depositCollateral, withdrawCollateral, borrowZUSD, repayZUSD } } = change; const collateralDecrease = withdrawCollateral ?? Decimal.ZERO; const collateralIncrease = depositCollateral ?? Decimal.ZERO; const debtDecrease = repayZUSD ?? Decimal.ZERO; const debtIncrease = borrowZUSD ? applyFee(borrowingRate, borrowZUSD) : Decimal.ZERO; return setToZero === "collateral" ? this.setCollateral(Decimal.ZERO).addDebt(debtIncrease).subtractDebt(debtDecrease) : setToZero === "debt" ? this.setDebt(Decimal.ZERO) .addCollateral(collateralIncrease) .subtractCollateral(collateralDecrease) : this.add(new Trove(collateralIncrease, debtIncrease)).subtract( new Trove(collateralDecrease, debtDecrease) ); } } } /** * Calculate the result of an {@link TransactableLiquity.openTrove | openTrove()} transaction. * * @param params - Parameters of the transaction. * @param borrowingRate - Borrowing rate to use when calculating the Trove's debt. */ static create(params: TroveCreationParams<Decimalish>, borrowingRate?: Decimalish): Trove { return _emptyTrove.apply(troveCreation(_normalizeTroveCreation(params)), borrowingRate); } /** * Calculate the parameters of an {@link TransactableLiquity.openTrove | openTrove()} transaction * that will result in the given Trove. * * @param that - The Trove to recreate. * @param borrowingRate - Current borrowing rate. */ static recreate(that: Trove, borrowingRate?: Decimalish): TroveCreationParams<Decimal> { const change = _emptyTrove.whatChanged(that, borrowingRate); assert(change?.type === "creation"); return change.params; } /** * Calculate the result of an {@link TransactableLiquity.adjustTrove | adjustTrove()} transaction * on this Trove. * * @param params - Parameters of the transaction. * @param borrowingRate - Borrowing rate to use when adding to the Trove's debt. */ adjust(params: TroveAdjustmentParams<Decimalish>, borrowingRate?: Decimalish): Trove { return this.apply(troveAdjustment(_normalizeTroveAdjustment(params)), borrowingRate); } /** * Calculate the parameters of an {@link TransactableLiquity.adjustTrove | adjustTrove()} * transaction that will change this Trove into the given Trove. * * @param that - The desired result of the transaction. * @param borrowingRate - Current borrowing rate. */ adjustTo(that: Trove, borrowingRate?: Decimalish): TroveAdjustmentParams<Decimal> { const change = this.whatChanged(that, borrowingRate); assert(change?.type === "adjustment"); return change.params; } } /** @internal */ export const _emptyTrove = new Trove(); /** * Represents whether a UserTrove is open or not, or why it was closed. * * @public */ export type UserTroveStatus = | "nonExistent" | "open" | "closedByOwner" | "closedByLiquidation" | "closedByRedemption"; /** * A Trove that is associated with a single owner. * * @remarks * The SDK uses the base {@link Trove} class as a generic container of collateral and debt, for * example to represent the {@link ReadableLiquity.getTotal | total collateral and debt} locked up * in the protocol. * * The `UserTrove` class extends `Trove` with extra information that's only available for Troves * that are associated with a single owner (such as the owner's address, or the Trove's status). * * @public */ export class UserTrove extends Trove { /** Address that owns this Trove. */ readonly ownerAddress: string; /** Provides more information when the UserTrove is empty. */ readonly status: UserTroveStatus; /** @internal */ constructor(ownerAddress: string, status: UserTroveStatus, collateral?: Decimal, debt?: Decimal) { super(collateral, debt); this.ownerAddress = ownerAddress; this.status = status; } equals(that: UserTrove): boolean { return ( super.equals(that) && this.ownerAddress === that.ownerAddress && this.status === that.status ); } /** @internal */ toString(): string { return ( `{ ownerAddress: "${this.ownerAddress}"` + `, collateral: ${this.collateral}` + `, debt: ${this.debt}` + `, status: "${this.status}" }` ); } } /** * A Trove in its state after the last direct modification. * * @remarks * The Trove may have received collateral and debt shares from liquidations since then. * Use {@link TroveWithPendingRedistribution.applyRedistribution | applyRedistribution()} to * calculate the Trove's most up-to-date state. * * @public */ export class TroveWithPendingRedistribution extends UserTrove { private readonly stake: Decimal; private readonly snapshotOfTotalRedistributed: Trove; /** @internal */ constructor( ownerAddress: string, status: UserTroveStatus, collateral?: Decimal, debt?: Decimal, stake = Decimal.ZERO, snapshotOfTotalRedistributed = _emptyTrove ) { super(ownerAddress, status, collateral, debt); this.stake = stake; this.snapshotOfTotalRedistributed = snapshotOfTotalRedistributed; } applyRedistribution(totalRedistributed: Trove): UserTrove { const afterRedistribution = this.add( totalRedistributed.subtract(this.snapshotOfTotalRedistributed).multiply(this.stake) ); return new UserTrove( this.ownerAddress, this.status, afterRedistribution.collateral, afterRedistribution.debt ); } equals(that: TroveWithPendingRedistribution): boolean { return ( super.equals(that) && this.stake.eq(that.stake) && this.snapshotOfTotalRedistributed.equals(that.snapshotOfTotalRedistributed) ); } }