1 | import { create as createLogger } from '../common/log'
|
2 | const log = createLogger('balance-middleware')
|
3 | import { Middleware, MiddlewareCallback, MiddlewareServices, Pipelines } from '../types/middleware'
|
4 | import { AccountInfo } from '../types/accounts'
|
5 | import BigNumber from 'bignumber.js'
|
6 | import * as IlpPacket from 'ilp-packet'
|
7 | import Stats from '../services/stats'
|
8 |
|
9 | const { InsufficientLiquidityError } = IlpPacket.Errors
|
10 |
|
11 | interface BalanceOpts {
|
12 | initialBalance?: BigNumber
|
13 | minimum?: BigNumber
|
14 | maximum?: BigNumber
|
15 | }
|
16 |
|
17 | class Balance {
|
18 | private balance: BigNumber
|
19 | private minimum: BigNumber
|
20 | private maximum: BigNumber
|
21 | constructor ({
|
22 | initialBalance = new BigNumber(0),
|
23 | minimum = new BigNumber(0),
|
24 | maximum = new BigNumber(Infinity)
|
25 | }: BalanceOpts) {
|
26 | this.balance = initialBalance
|
27 | this.minimum = minimum
|
28 | this.maximum = maximum
|
29 | }
|
30 |
|
31 | add (amount: BigNumber | string | number) {
|
32 | const newBalance = this.balance.plus(amount)
|
33 |
|
34 | if (newBalance.gt(this.maximum)) {
|
35 | log.error('rejected balance update. oldBalance=%s newBalance=%s amount=%s', this.balance, newBalance, amount)
|
36 | throw new InsufficientLiquidityError('exceeded maximum balance.')
|
37 | }
|
38 |
|
39 | this.balance = newBalance
|
40 | }
|
41 |
|
42 | subtract (amount: BigNumber | string | number) {
|
43 | const newBalance = this.balance.minus(amount)
|
44 |
|
45 | if (newBalance.lt(this.minimum)) {
|
46 | log.error('rejected balance update. oldBalance=%s newBalance=%s amount=%s', this.balance, newBalance, amount)
|
47 | throw new Error(`insufficient funds. oldBalance=${this.balance} proposedBalance=${newBalance}`)
|
48 | }
|
49 |
|
50 | this.balance = newBalance
|
51 | }
|
52 |
|
53 | getValue () {
|
54 | return this.balance
|
55 | }
|
56 |
|
57 | toJSON () {
|
58 | return {
|
59 | balance: this.balance.toString(),
|
60 | minimum: this.minimum.toString(),
|
61 | maximum: this.maximum.toString()
|
62 | }
|
63 | }
|
64 | }
|
65 |
|
66 | export default class BalanceMiddleware implements Middleware {
|
67 | private stats: Stats
|
68 | private getInfo: (accountId: string) => AccountInfo
|
69 | private sendMoney: (amount: string, accountId: string) => Promise<void>
|
70 | private balances: Map<string, Balance> = new Map()
|
71 |
|
72 | constructor (opts: {}, { getInfo, sendMoney, stats }: MiddlewareServices) {
|
73 | this.getInfo = getInfo
|
74 | this.sendMoney = sendMoney
|
75 | this.stats = stats
|
76 | }
|
77 |
|
78 | async applyToPipelines (pipelines: Pipelines, accountId: string) {
|
79 | const accountInfo = this.getInfo(accountId)
|
80 | if (!accountInfo) {
|
81 | throw new Error('could not load info for account. accountId=' + accountId)
|
82 | }
|
83 | const account = { accountId, accountInfo }
|
84 | if (accountInfo.balance) {
|
85 | const {
|
86 | minimum = '-Infinity',
|
87 | maximum
|
88 | } = accountInfo.balance
|
89 |
|
90 | const balance = new Balance({
|
91 | minimum: new BigNumber(minimum),
|
92 | maximum: new BigNumber(maximum)
|
93 | })
|
94 | this.balances.set(accountId, balance)
|
95 |
|
96 | log.info('initializing balance for account. accountId=%s minimumBalance=%s maximumBalance=%s', accountId, minimum, maximum)
|
97 |
|
98 | pipelines.startup.insertLast({
|
99 | name: 'balance',
|
100 | method: async (dummy: void, next: MiddlewareCallback<void, void>) => {
|
101 |
|
102 |
|
103 | this.maybeSettle(accountId)
|
104 |
|
105 | this.stats.balance.setValue(account, {}, balance.getValue().toNumber())
|
106 | return next(dummy)
|
107 | }
|
108 | })
|
109 |
|
110 | pipelines.incomingData.insertLast({
|
111 | name: 'balance',
|
112 | method: async (data: Buffer, next: MiddlewareCallback<Buffer, Buffer>) => {
|
113 | if (data[0] === IlpPacket.Type.TYPE_ILP_PREPARE) {
|
114 | const parsedPacket = IlpPacket.deserializeIlpPrepare(data)
|
115 |
|
116 |
|
117 | if (parsedPacket.amount === '0') {
|
118 | return next(data)
|
119 | }
|
120 |
|
121 |
|
122 | balance.add(parsedPacket.amount)
|
123 | log.trace('balance increased due to incoming ilp prepare. accountId=%s amount=%s newBalance=%s', accountId, parsedPacket.amount, balance.getValue())
|
124 | this.stats.balance.setValue(account, {}, balance.getValue().toNumber())
|
125 |
|
126 | let result
|
127 | try {
|
128 | result = await next(data)
|
129 | } catch (err) {
|
130 |
|
131 | balance.subtract(parsedPacket.amount)
|
132 | log.debug('incoming packet refunded due to error. accountId=%s amount=%s newBalance=%s', accountId, parsedPacket.amount, balance.getValue())
|
133 | this.stats.balance.setValue(account, {}, balance.getValue().toNumber())
|
134 | this.stats.incomingDataPacketValue.increment(account, { result : 'failed' }, +parsedPacket.amount)
|
135 | throw err
|
136 | }
|
137 |
|
138 | if (result[0] === IlpPacket.Type.TYPE_ILP_REJECT) {
|
139 |
|
140 | balance.subtract(parsedPacket.amount)
|
141 | log.debug('incoming packet refunded due to ilp reject. accountId=%s amount=%s newBalance=%s', accountId, parsedPacket.amount, balance.getValue())
|
142 | this.stats.balance.setValue(account, {}, balance.getValue().toNumber())
|
143 | this.stats.incomingDataPacketValue.increment(account, { result : 'rejected' }, +parsedPacket.amount)
|
144 | } else if (result[0] === IlpPacket.Type.TYPE_ILP_FULFILL) {
|
145 | this.maybeSettle(accountId).catch(log.error)
|
146 | this.stats.incomingDataPacketValue.increment(account, { result : 'fulfilled' }, +parsedPacket.amount)
|
147 | }
|
148 |
|
149 | return result
|
150 | } else {
|
151 | return next(data)
|
152 | }
|
153 | }
|
154 | })
|
155 |
|
156 | pipelines.incomingMoney.insertLast({
|
157 | name: 'balance',
|
158 | method: async (amount: string, next: MiddlewareCallback<string, void>) => {
|
159 | balance.subtract(amount)
|
160 | log.trace('balance reduced due to incoming settlement. accountId=%s amount=%s newBalance=%s', accountId, amount, balance.getValue())
|
161 | this.stats.balance.setValue(account, {}, balance.getValue().toNumber())
|
162 | return next(amount)
|
163 | }
|
164 | })
|
165 |
|
166 | pipelines.outgoingData.insertLast({
|
167 | name: 'balance',
|
168 | method: async (data: Buffer, next: MiddlewareCallback<Buffer, Buffer>) => {
|
169 | if (data[0] === IlpPacket.Type.TYPE_ILP_PREPARE) {
|
170 | const parsedPacket = IlpPacket.deserializeIlpPrepare(data)
|
171 |
|
172 |
|
173 | if (parsedPacket.amount === '0') {
|
174 | return next(data)
|
175 | }
|
176 |
|
177 |
|
178 |
|
179 | let result
|
180 | try {
|
181 | result = await next(data)
|
182 | } catch (err) {
|
183 | log.debug('outgoing packet not applied due to error. accountId=%s amount=%s newBalance=%s', accountId, parsedPacket.amount, balance.getValue())
|
184 | this.stats.outgoingDataPacketValue.increment(account, { result : 'failed' }, +parsedPacket.amount)
|
185 | throw err
|
186 | }
|
187 |
|
188 | if (result[0] === IlpPacket.Type.TYPE_ILP_REJECT) {
|
189 | log.debug('outgoing packet not applied due to ilp reject. accountId=%s amount=%s newBalance=%s', accountId, parsedPacket.amount, balance.getValue())
|
190 | this.stats.outgoingDataPacketValue.increment(account, { result : 'rejected' }, +parsedPacket.amount)
|
191 | } else if (result[0] === IlpPacket.Type.TYPE_ILP_FULFILL) {
|
192 |
|
193 | balance.subtract(parsedPacket.amount)
|
194 | this.maybeSettle(accountId).catch(log.error)
|
195 | log.trace('balance decreased due to outgoing ilp fulfill. accountId=%s amount=%s newBalance=%s', accountId, parsedPacket.amount, balance.getValue())
|
196 | this.stats.balance.setValue(account, {}, balance.getValue().toNumber())
|
197 | this.stats.outgoingDataPacketValue.increment(account, { result : 'fulfilled' }, +parsedPacket.amount)
|
198 | }
|
199 |
|
200 | return result
|
201 | } else {
|
202 | return next(data)
|
203 | }
|
204 | }
|
205 | })
|
206 |
|
207 | pipelines.outgoingMoney.insertLast({
|
208 | name: 'balance',
|
209 | method: async (amount: string, next: MiddlewareCallback<string, void>) => {
|
210 | balance.add(amount)
|
211 | log.trace('balance increased due to outgoing settlement. accountId=%s amount=%s newBalance=%s', accountId, amount, balance.getValue())
|
212 | this.stats.balance.setValue(account, {}, balance.getValue().toNumber())
|
213 |
|
214 | return next(amount)
|
215 | }
|
216 | })
|
217 | } else {
|
218 | log.warn('(!!!) balance middleware NOT enabled for account, this account can spend UNLIMITED funds. accountId=%s', accountId)
|
219 | }
|
220 | }
|
221 |
|
222 | getStatus () {
|
223 | const accounts = {}
|
224 | this.balances.forEach((balance, accountId) => {
|
225 | accounts[accountId] = balance.toJSON()
|
226 | })
|
227 | return { accounts }
|
228 | }
|
229 |
|
230 | modifyBalance (accountId: string, _amountDiff: BigNumber.Value): BigNumber {
|
231 | const accountInfo = this.getInfo(accountId)
|
232 | if (!accountInfo) {
|
233 | throw new Error('could not load info for account. accountId=' + accountId)
|
234 | }
|
235 | const account = { accountId, accountInfo }
|
236 | const amountDiff = new BigNumber(_amountDiff)
|
237 | const balance = this.getBalance(accountId)
|
238 | log.warn('modifying balance accountId=%s amount=%s', accountId, amountDiff.toString())
|
239 | if (amountDiff.isPositive()) {
|
240 | balance.add(amountDiff)
|
241 | } else {
|
242 | balance.subtract(amountDiff.negated())
|
243 | this.maybeSettle(accountId).catch(log.error)
|
244 | }
|
245 | this.stats.balance.setValue(account, {}, balance.getValue().toNumber())
|
246 | return balance.getValue()
|
247 | }
|
248 |
|
249 | private getBalance (accountId: string): Balance {
|
250 | const balance = this.balances.get(accountId)
|
251 | if (!balance) {
|
252 | throw new Error('account not found. accountId=' + accountId)
|
253 | }
|
254 | return balance
|
255 | }
|
256 |
|
257 | private async maybeSettle (accountId: string): Promise<void> {
|
258 | const accountInfo = this.getInfo(accountId)
|
259 | const { settleThreshold, settleTo = '0' } = accountInfo.balance!
|
260 | const bnSettleThreshold = settleThreshold ? new BigNumber(settleThreshold) : undefined
|
261 | const bnSettleTo = new BigNumber(settleTo)
|
262 | const balance = this.getBalance(accountId)
|
263 |
|
264 | const settle = bnSettleThreshold && bnSettleThreshold.gt(balance.getValue())
|
265 | if (!settle) return
|
266 |
|
267 | const settleAmount = bnSettleTo.minus(balance.getValue())
|
268 | log.debug('settlement triggered. accountId=%s balance=%s settleAmount=%s', accountId, balance.getValue(), settleAmount)
|
269 |
|
270 | await this.sendMoney(settleAmount.toString(), accountId)
|
271 | .catch(e => {
|
272 | let err = e
|
273 | if (!err || typeof err !== 'object') {
|
274 | err = new Error('Non-object thrown: ' + e)
|
275 | }
|
276 | log.error('error occurred during settlement. accountId=%s settleAmount=%s errInfo=%s', accountId, settleAmount, err.stack ? err.stack : err)
|
277 | })
|
278 | }
|
279 | }
|