Smart Contract

LendingAprSnapshot

A.7bb1e28c69407925.LendingAprSnapshot

Deployed

1d ago
Feb 27, 2026, 01:43:14 AM UTC

Dependents

0 imports
1import LendingConfig from 0x2df970b6cdee5735
2import LendingError from 0x2df970b6cdee5735
3import LendingInterfaces from 0x2df970b6cdee5735
4
5pub contract LendingAprSnapshot {
6    pub let AdminStoragePath: StoragePath
7    /// { marketAddr => perMarketAprData}
8    access(self) let _markets: {Address: AprSnapshot}
9
10    /// Reserved parameter fields: {ParamName: Value}
11    access(self) let _reservedFields: {String: AnyStruct}
12
13    pub event MarketDataTracked(market: Address, marketType: String, startTrackingFrom: UFix64)
14    pub event MarketDataErased(market: Address, erasedFrom: UFix64)
15    pub event AprSampled(market: Address, truncatedTimestamp: UInt64, supplyApr: UFix64, borrowApr: UFix64)
16
17    /// Per-snapshot data point
18    pub struct Observation {
19        // Unix timestamp
20        pub let timestamp: UFix64
21        // supplyApr in ufix64
22        pub let supplyApr: UFix64
23        // borrowApr in ufix64 (e.g. 0.12345678 => 12.35%)
24        pub let borrowApr: UFix64
25
26        init (t: UFix64, supplyApr: UFix64, borrowApr: UFix64) {
27            self.timestamp = t
28            self.supplyApr = supplyApr
29            self.borrowApr = borrowApr
30        }
31    }
32
33    /// Per-market snapshot configurations and data points
34    pub struct AprSnapshot {
35        /// Contains functions to query public market data
36        pub let poolPublicCap: Capability<&{LendingInterfaces.PoolPublic}>
37        /// Each sample covers a 6-hour window: i.e. sampleLength = 21600s
38        pub let sampleLength: UInt64
39        /// We store 1 year of apr data in maximum: i.e. numSamples = 1460
40        pub let numSamples: UInt64
41        /// A circular buffer storing apr samples
42        access(self) let aprObservations: [Observation]
43        /// Reserved parameter fields: {ParamName: Value}
44        access(self) let _reservedFields: {String: AnyStruct}
45
46        /// Returns the index into the circular buffer of the given timestamp
47        pub fun observationIndexOf(timestamp: UFix64): UInt64 {
48            return UInt64(timestamp) / self.sampleLength % self.numSamples
49        }
50
51        pub fun sample(): Bool {
52            let now = getCurrentBlock().timestamp
53            let idx = self.observationIndexOf(timestamp: now)
54            let ob = self.aprObservations[idx]
55            let timeElapsed = now - ob.timestamp
56
57            if (UInt64(timeElapsed) > self.sampleLength) {
58                let poolRef = self.poolPublicCap.borrow()
59                    ?? panic("cannot borrow reference to lendingPool")
60                let newSupplyApr: UFix64 = LendingConfig.ScaledUInt256ToUFix64(poolRef.getPoolSupplyAprScaled())
61                let newBorrowApr: UFix64 = LendingConfig.ScaledUInt256ToUFix64(poolRef.getPoolBorrowAprScaled())
62                // Truncate timestamp for better plotting in frontend
63                let samplePeriodStart: UInt64 = UInt64(now) / self.sampleLength * self.sampleLength
64                self.aprObservations[idx] = Observation(t: UFix64(samplePeriodStart), supplyApr: newSupplyApr, borrowApr: newBorrowApr)
65                emit AprSampled(market: poolRef.getPoolAddress(), truncatedTimestamp: samplePeriodStart, supplyApr: newSupplyApr, borrowApr: newBorrowApr)
66                return true
67            }
68            return false
69        }
70
71        pub fun queryHistoricalAprData(scale: UInt8, plotPoints: UInt64): [Observation] {
72            let now = getCurrentBlock().timestamp
73            let idxNow: UInt64 = self.observationIndexOf(timestamp: now)
74            var idxPrev: UInt64 = 0
75            switch scale {
76                case 0:
77                    // idx for timestamp 1 month ago
78                    idxPrev = self.observationIndexOf(timestamp: now - 30.0 * UFix64(self.sampleLength) * 4.0)
79                case 1:
80                    // idx for timestamp 6 month ago
81                    idxPrev = self.observationIndexOf(timestamp: now - 180.0 * UFix64(self.sampleLength) * 4.0)
82                case 2:
83                    // idx for timestamp 1 year ago. (Use 360 instead of 365 for the purpose of exact-division)
84                    idxPrev = self.observationIndexOf(timestamp: now - 360.0 * UFix64(self.sampleLength) * 4.0)
85                default:
86                    panic("invalid spanning param")
87            }
88            let numSampledPoints = idxPrev < idxNow ? (idxNow - idxPrev + 1) : (self.numSamples + idxNow - idxPrev + 1)
89            assert(
90                plotPoints <= numSampledPoints,
91                message: "invalid plotPoints param: cannot plot due to insufficient samples"
92            )
93            let step: UInt64 = numSampledPoints / plotPoints
94            var res: [Observation] = []
95            var i: UInt64 = 0
96            while i < plotPoints {
97                let ob = self.aprObservations[idxPrev]
98                // Filtering non-meaningful data
99                if (ob.timestamp > 0.0) {
100                    res.append(
101                        Observation(
102                            t: ob.timestamp,
103                            supplyApr: ob.supplyApr,
104                            borrowApr: ob.borrowApr
105                        )
106                    )
107                }
108                idxPrev = idxPrev + step
109                if idxPrev >= self.numSamples {
110                    idxPrev = idxPrev - self.numSamples
111                }
112                i = i + 1
113            }
114            return res
115        }
116
117        pub fun getLatestData(): Observation {
118            let now = getCurrentBlock().timestamp
119            let idx = self.observationIndexOf(timestamp: now)
120            return self.aprObservations[idx]
121        }
122
123        /// Proposed params: sampleLength = 21600 (6h) && numSamples = 1460 (store 1 year's data)
124        init(poolPublicCap: Capability<&{LendingInterfaces.PoolPublic}>) {
125            self.poolPublicCap = poolPublicCap
126            // 6h
127            self.sampleLength = 30
128            // stores 1 year's data
129            self.numSamples = 1460
130            self.aprObservations = []
131            var i: UInt64 = 0
132            // Init circular buffer
133            while (i < self.numSamples) {
134                self.aprObservations.append(Observation(t: 0.0, supplyApr: 0.0, borrowApr: 0.0))
135                i = i + 1
136            }
137            self._reservedFields = {}
138        }
139    }
140
141    /// sample() is made public so everyone can sample the given market's apr data, as long as it's expired.
142    /// @Returns sampled or not
143    pub fun sample(poolAddr: Address): Bool {
144        pre {
145            self._markets.containsKey(poolAddr) == true:
146                LendingError.ErrorEncode(
147                        msg: "Market not tracked yet",
148                        err: LendingError.ErrorCode.MARKET_NOT_OPEN
149                )         
150        }
151        return self._markets[poolAddr]!.sample()
152    }
153
154/////////////// TODO: Check if it's ok to pull 120 x (3 UFix64) in 1 script?
155    /// A getter function for frontend to query stored samples and plot data.
156    /// @scale: Spanning of time the drawing should cover - 0 (1 month), 1 (6 months), 2 (1 year). 
157    /// @plotPoints: Maximum data points the drawing needs, e.g. 120 points in maximum
158    /// @Returns historical apy data in a timestamp-ascending order. Note: only meaningful data is returned, so the length is not guaranteed to be equal to `plotPoints`.
159    pub fun queryHistoricalAprData(poolAddr: Address, scale: UInt8, plotPoints: UInt64): [Observation] {
160        pre {
161            self._markets.containsKey(poolAddr) == true:
162                LendingError.ErrorEncode(
163                        msg: "Market not tracked yet",
164                        err: LendingError.ErrorCode.MARKET_NOT_OPEN
165                )         
166        }
167        return self._markets[poolAddr]!.queryHistoricalAprData(scale: scale, plotPoints: plotPoints)
168    }
169
170    pub fun getLatestData(poolAddr: Address): Observation {
171        return self._markets[poolAddr]!.getLatestData()
172    }
173
174    access(contract) fun trackMarketData(poolAddr: Address) {
175        pre {
176            self._markets.containsKey(poolAddr) == false:
177                LendingError.ErrorEncode(
178                        msg: "Market has already been tracked",
179                        err: LendingError.ErrorCode.ADD_MARKET_DUPLICATED
180                )         
181        }
182        // Start tracking a new market
183        let poolPublicCap = getAccount(poolAddr).getCapability<&{LendingInterfaces.PoolPublic}>(LendingConfig.PoolPublicPublicPath)
184        assert(poolPublicCap.check() == true, message:
185            LendingError.ErrorEncode(
186                msg: "Cannot borrow reference to PoolPublic resource",
187                err: LendingError.ErrorCode.CANNOT_ACCESS_POOL_PUBLIC_CAPABILITY
188            )
189        )
190
191        self._markets[poolAddr] = AprSnapshot(poolPublicCap: poolPublicCap)
192        emit MarketDataTracked(
193            market: poolAddr,
194            marketType: poolPublicCap.borrow()!.getUnderlyingTypeString(),
195            startTrackingFrom: getCurrentBlock().timestamp
196        )
197    }
198
199    access(contract) fun eraseMarketData(poolAddr: Address) {
200        pre {
201            self._markets.containsKey(poolAddr) == true:
202                LendingError.ErrorEncode(
203                        msg: "Market not tracked yet",
204                        err: LendingError.ErrorCode.MARKET_NOT_OPEN
205                )         
206        }
207        self._markets.remove(key: poolAddr)
208        emit MarketDataErased(
209            market: poolAddr,
210            erasedFrom: getCurrentBlock().timestamp
211        )
212    }
213  
214    pub resource Admin {
215        pub fun trackMarketData(poolAddr: Address) {
216            LendingAprSnapshot.trackMarketData(poolAddr: poolAddr)
217        }
218        pub fun eraseMarketData(poolAddr: Address) {
219            LendingAprSnapshot.eraseMarketData(poolAddr: poolAddr)
220        }
221    }
222
223    init() {
224        self.AdminStoragePath = /storage/lendingAprSnapshotAdmin
225        self._markets = {}
226        self._reservedFields = {}
227
228        destroy <-self.account.load<@AnyResource>(from: self.AdminStoragePath)
229        self.account.save(<-create Admin(), to: self.AdminStoragePath)
230    }
231}