UNPKG

11 kBPlain TextView Raw
1import { create as createLogger } from '../common/log'
2const log = createLogger('balance-middleware')
3import { Middleware, MiddlewareCallback, MiddlewareServices, Pipelines } from '../types/middleware'
4import { AccountInfo } from '../types/accounts'
5import BigNumber from 'bignumber.js'
6import * as IlpPacket from 'ilp-packet'
7import Stats from '../services/stats'
8
9const { InsufficientLiquidityError } = IlpPacket.Errors
10
11interface BalanceOpts {
12 initialBalance?: BigNumber
13 minimum?: BigNumber
14 maximum?: BigNumber
15}
16
17class 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
66export 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 // When starting up, check if we need to pre-fund / settle
102 // tslint:disable-next-line:no-floating-promises
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 // Ignore zero amount packets
117 if (parsedPacket.amount === '0') {
118 return next(data)
119 }
120
121 // Increase balance on prepare
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 // Refund on error
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 // Refund on reject
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 // Ignore zero amount packets
173 if (parsedPacket.amount === '0') {
174 return next(data)
175 }
176
177 // We do nothing here (i.e. unlike for incoming packets) and wait until the packet is fulfilled
178 // This means we always take the most conservative view of our balance with the upstream peer
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 // Decrease balance on prepare
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}