Skip to content
This repository has been archived by the owner on May 7, 2024. It is now read-only.

feat: tracking loans history with state snapshot #102

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 36 additions & 11 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -258,11 +258,11 @@ type TrancheBalance @entity {
redeemCollected: BigInt!
}

enum LoanStatus {
CREATED
ACTIVE
CLOSED
}
# enum LoanStatus {
# CREATED
# ACTIVE
# CLOSED
# }

enum LoanType {
BulletLoan
Expand All @@ -274,24 +274,49 @@ type Loan @entity {
id: ID! # poolId - loanId
createdAt: Date!

# collateral:
status: LoanStatus!
collateral: BigInt!

type: LoanType
spec: String

interestRatePerSec: BigInt

adminWrittenOff: Boolean

pool: Pool!
state: LoanState!
}

type LoanState @entity {
id: ID! # poolId - loanId
status: String! @index

outstandingDebt: BigInt

totalBorrowed: BigInt
totalRepaid: BigInt
totalBorrowed_: BigInt
totalRepaid_: BigInt

writeOffIndex: Int
writtenOffPercentage: BigInt
penaltyInterestRatePerSec: BigInt
adminWrittenOff: Boolean
}

pool: Pool!
type LoanSnapshot @entity {
id: ID! # poolId - loanId - blockNumber
loan: Loan!

timestamp: Date!
blockNumber: Int!
periodStart: Date! @index

outstandingDebt: BigInt

totalBorrowed_: BigInt
totalRepaid_: BigInt

writeOffIndex: Int
writtenOffPercentage: BigInt
penaltyInterestRatePerSec: BigInt
}

enum BorrowerTransactionType {
Expand Down
27 changes: 14 additions & 13 deletions src/helpers/stateSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,11 @@ import { getPeriodStart } from './timekeeperService'

interface Constructor<C> {
new (id: string): C
}

interface TypeGetter<C> {
getByType(type: string): Promise<C[]> | undefined
//getByType(type: string): C[] | undefined
}

interface GenericState {
id: string
type: string
save(): Promise<void>
}

Expand All @@ -37,19 +33,24 @@ interface GenericSnapshot {
* @returns A promise resolving when all state manipulations in the DB is completed
*/
export const stateSnapshotter = errorHandler(_stateSnapshotter)
async function _stateSnapshotter<
T extends Constructor<GenericState> & TypeGetter<GenericState>,
U extends Constructor<GenericSnapshot>
>(stateModel: T, snapshotModel: U, block: SubstrateBlock, fkReferenceName: string = undefined): Promise<void> {
async function _stateSnapshotter<T extends Constructor<GenericState>, U extends Constructor<GenericSnapshot>>(
stateModel: T,
snapshotModel: U,
block: SubstrateBlock,
fkReferenceName: string = undefined,
filterKey = 'Type',
filterValue = 'ALL'
): Promise<void> {
const entitySaves: Promise<void>[] = []
const stateModelHasGetByType = Object.prototype.hasOwnProperty.call(stateModel, 'getByType')
if (!stateModelHasGetByType) throw new Error('stateModel has no method .getByType()')
const getterName = `getBy${filterKey}`
const stateModelHasGetByType = Object.prototype.hasOwnProperty.call(stateModel, getterName)
if (!stateModelHasGetByType) throw new Error(`${stateModel.name} has no method .${getterName}()`)
logger.info(`Performing snapshots of ${stateModel.name}`)
const stateEntities = await stateModel.getByType('ALL')
const stateEntities = await stateModel[getterName](filterValue)
for (const stateEntity of stateEntities) {
const blockNumber = block.block.header.number.toNumber()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, type, ...copyStateEntity } = stateEntity
const { id, [filterKey.toLowerCase()]: type, ...copyStateEntity } = stateEntity
logger.info(`Snapshotting ${stateModel.name}: ${id}`)
const snapshotEntity = new snapshotModel(`${id}-${blockNumber.toString()}`)
Object.assign(snapshotEntity, copyStateEntity)
Expand Down
17 changes: 17 additions & 0 deletions src/helpers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,23 @@ export interface LoanSpecs extends Struct {
maturityDate?: u64
}

export interface PricedLoanDetails extends Struct {
loanId: u128
loanType: Enum
interestRatePerSec: u128
originationDate: Option<u64>
normalizedDebt: u128
totalBorrowed: u128
totalRepaid: u128
writeOffStatus: Enum
lastUpdated: u64
}

export interface InterestAccrualRateDetails extends Struct {
accumulatedRate: u128
lastUpdated: u64
}

export type LoanAsset = ITuple<[u64, u128]>
export type PoolEvent = ITuple<[u64]>

Expand Down
13 changes: 12 additions & 1 deletion src/mappings/handlers/blockHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { SubstrateBlock } from '@subql/types'
import { PoolState, PoolSnapshot, TrancheState, TrancheSnapshot } from '../../types'
import { PoolState, PoolSnapshot, TrancheState, TrancheSnapshot, LoanState, LoanSnapshot } from '../../types'
import { getPeriodStart, TimekeeperService } from '../../helpers/timekeeperService'
import { errorHandler } from '../../helpers/errorHandler'
import { stateSnapshotter } from '../../helpers/stateSnapshot'
import { SNAPSHOT_INTERVAL_SECONDS } from '../../config'
import { PoolService } from '../services/poolService'
import { TrancheService } from '../services/trancheService'
import { LoanService } from '../services/loanService'

const timekeeper = TimekeeperService.init()

Expand Down Expand Up @@ -47,11 +48,21 @@ async function _handleBlock(block: SubstrateBlock): Promise<void> {
await tranche.computeYieldAnnualized('yield90DaysAnnualized', blockPeriodStart, daysAgo90)
await tranche.save()
}

// Update loans outstanding debt
const activeLoanData = await pool.getActiveLoanData()
for (const loanId in activeLoanData) {
const loan = await LoanService.getById(pool.pool.id, loanId)
const { normalizedDebt, interestRate } = activeLoanData[loanId]
await loan.updateOutstandingDebt(normalizedDebt, interestRate)
await loan.save()
}
}

//Perform Snapshots and reset accumulators
await stateSnapshotter(PoolState, PoolSnapshot, block, 'poolId')
await stateSnapshotter(TrancheState, TrancheSnapshot, block, 'trancheId')
await stateSnapshotter(LoanState, LoanSnapshot, block, 'loanId', 'Status', 'ACTIVE')

//Update tracking of period and continue
await (await timekeeper).update(blockPeriodStart)
Expand Down
9 changes: 7 additions & 2 deletions src/mappings/handlers/loansHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@ import { AccountService } from '../services/accountService'

export const handleLoanCreated = errorHandler(_handleLoanCreated)
async function _handleLoanCreated(event: SubstrateEvent<LoanCreatedClosedEvent>) {
const [poolId, loanId] = event.event.data
const [poolId, loanId, [, collateral]] = event.event.data
logger.info(`Loan created event for pool: ${poolId.toString()} loan: ${loanId.toString()}`)
const pool = await PoolService.getById(poolId.toString())
const account = await AccountService.getOrInit(event.extrinsic.extrinsic.signer.toString())

const loan = await LoanService.init(poolId.toString(), loanId.toString(), event.block.timestamp)
const loan = await LoanService.init(
poolId.toString(),
loanId.toString(),
collateral.toBigInt(),
event.block.timestamp
)
await loan.save()

const bt = await BorrowerTransactionService.created({
Expand Down
56 changes: 38 additions & 18 deletions src/mappings/services/loanService.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,56 @@
import { Option } from '@polkadot/types'
import { AnyJson } from '@polkadot/types/types'
import { Loan, LoanStatus, LoanType } from '../../types'
import { bnToBn, nToBigInt } from '@polkadot/util'
import { RAY } from '../../config'
import { errorHandler } from '../../helpers/errorHandler'
import { InterestAccrualRateDetails } from '../../helpers/types'
import { Loan, LoanState, LoanType } from '../../types'

export class LoanService {
readonly loan: Loan
readonly loanState: LoanState

constructor(loan: Loan) {
constructor(loan: Loan, loanState: LoanState) {
this.loan = loan
this.loanState = loanState
}

static init = async (poolId: string, loanId: string, timestamp: Date) => {
static init = async (poolId: string, loanId: string, collateral: bigint, timestamp: Date) => {
logger.info(`Initialising loan ${loanId} for pool ${poolId}`)
const loan = new Loan(`${poolId}-${loanId}`)
const loanState = new LoanState(`${poolId}-${loanId}`)

loan.createdAt = timestamp
loan.poolId = poolId
loan.status = LoanStatus.CREATED
loan.outstandingDebt = BigInt(0)
loan.totalBorrowed = BigInt(0)
loan.totalRepaid = BigInt(0)
loan.collateral = collateral
loan.stateId = `${poolId}-${loanId}`
loanState.status = 'CREATED'
loanState.outstandingDebt = BigInt(0)
loanState.totalBorrowed_ = BigInt(0)
loanState.totalRepaid_ = BigInt(0)

return new LoanService(loan)
return new LoanService(loan, loanState)
}

static getById = async (poolId: string, loanId: string) => {
const loan = await Loan.get(`${poolId}-${loanId}`)
if (loan === undefined) return undefined
return new LoanService(loan)
const [loan, loanState] = await Promise.all([Loan.get(`${poolId}-${loanId}`), LoanState.get(`${poolId}-${loanId}`)])
if (loan === undefined || loanState === undefined) return undefined
return new LoanService(loan, loanState)
}

public save = async () => {
await this.loanState.save()
await this.loan.save()
}

public borrow = (amount: bigint) => {
logger.info(`Increasing outstanding debt for loan ${this.loan.id} by ${amount}`)
this.loan.totalBorrowed = this.loan.totalBorrowed + amount
this.loanState.totalBorrowed_ = this.loanState.totalBorrowed_ + amount
}

public repay = (amount: bigint) => {
logger.info(`Decreasing outstanding debt for loan ${this.loan.id} by ${amount}`)
this.loan.totalRepaid = this.loan.totalRepaid + amount
this.loanState.totalRepaid_ = this.loanState.totalRepaid_ + amount
}

public updateInterestRate = (interestRatePerSec: bigint) => {
Expand All @@ -49,9 +60,9 @@ export class LoanService {

public writeOff = (percentage: bigint, penaltyInterestRatePerSec: bigint, writeOffIndex: number) => {
logger.info(`Writing off loan ${this.loan.id} with ${percentage}`)
this.loan.writtenOffPercentage = percentage
this.loan.penaltyInterestRatePerSec = penaltyInterestRatePerSec
this.loan.writeOffIndex = writeOffIndex
this.loanState.writtenOffPercentage = percentage
this.loanState.penaltyInterestRatePerSec = penaltyInterestRatePerSec
this.loanState.writeOffIndex = writeOffIndex
}

public updateLoanType = (loanType: string, loanSpec?: AnyJson) => {
Expand All @@ -63,11 +74,20 @@ export class LoanService {

public activate = () => {
logger.info(`Activating loan ${this.loan.id}`)
this.loan.status = LoanStatus.ACTIVE
this.loanState.status = 'ACTIVE'
}

public close = () => {
logger.info(`Closing loan ${this.loan.id}`)
this.loan.status = LoanStatus.CLOSED
this.loanState.status = 'CLOSED'
}

private _updateOutstandingDebt = async (normalizedDebt: bigint, interestRatePerSec: bigint) => {
const rateDetails = await api.query.interestAccrual.rate<Option<InterestAccrualRateDetails>>(interestRatePerSec)
if (rateDetails.isNone) return
const { accumulatedRate } = rateDetails.unwrap()
this.loanState.outstandingDebt = nToBigInt(bnToBn(normalizedDebt).mul(bnToBn(accumulatedRate)).div(RAY))
logger.info(`Updating outstanding debt for loan: ${this.loan.id} to ${this.loanState.outstandingDebt.toString()}`)
}
public updateOutstandingDebt = errorHandler(this._updateOutstandingDebt)
}
23 changes: 22 additions & 1 deletion src/mappings/services/poolService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Option, u128, u64, Vec } from '@polkadot/types'
import { bnToBn, nToBigInt } from '@polkadot/util'
import { errorHandler } from '../../helpers/errorHandler'
import { ExtendedRpc, NavDetails, PoolDetails, TrancheData } from '../../helpers/types'
import { ExtendedRpc, NavDetails, PoolDetails, PricedLoanDetails, TrancheData } from '../../helpers/types'
import { Pool, PoolState } from '../../types'

export class PoolService {
Expand Down Expand Up @@ -144,4 +144,25 @@ export class PoolService {
return tokenPrices
}
public getTrancheTokenPrices = errorHandler(this._getTrancheTokenPrices)

private _getActiveLoanData = async () => {
logger.info(`Querying active loan data for pool: ${this.pool.id}`)
const loanDetails = await api.query.loans.activeLoans<Vec<PricedLoanDetails>>(this.pool.id)
const activeLoanData = loanDetails.reduce<ActiveLoanData>(
(last, current) => ({
...last,
[current.loanId.toString()]: {
normalizedDebt: current.normalizedDebt.toBigInt(),
interestRate: current.interestRatePerSec.toBigInt(),
},
}),
{}
)
return activeLoanData
}
public getActiveLoanData = errorHandler(this._getActiveLoanData)
}

interface ActiveLoanData {
[loanId: string]: { normalizedDebt: bigint; interestRate: bigint }
}