1 | "use strict";
|
2 | Object.defineProperty(exports, "__esModule", { value: true });
|
3 | const log_1 = require("../common/log");
|
4 | const log = log_1.create('balance-middleware');
|
5 | const bignumber_js_1 = require("bignumber.js");
|
6 | const IlpPacket = require("ilp-packet");
|
7 | const { InsufficientLiquidityError } = IlpPacket.Errors;
|
8 | class Balance {
|
9 | constructor({ initialBalance = new bignumber_js_1.default(0), minimum = new bignumber_js_1.default(0), maximum = new bignumber_js_1.default(Infinity) }) {
|
10 | this.balance = initialBalance;
|
11 | this.minimum = minimum;
|
12 | this.maximum = maximum;
|
13 | }
|
14 | add(amount) {
|
15 | const newBalance = this.balance.plus(amount);
|
16 | if (newBalance.gt(this.maximum)) {
|
17 | log.error('rejected balance update. oldBalance=%s newBalance=%s amount=%s', this.balance, newBalance, amount);
|
18 | throw new InsufficientLiquidityError('exceeded maximum balance.');
|
19 | }
|
20 | this.balance = newBalance;
|
21 | }
|
22 | subtract(amount) {
|
23 | const newBalance = this.balance.minus(amount);
|
24 | if (newBalance.lt(this.minimum)) {
|
25 | log.error('rejected balance update. oldBalance=%s newBalance=%s amount=%s', this.balance, newBalance, amount);
|
26 | throw new Error(`insufficient funds. oldBalance=${this.balance} proposedBalance=${newBalance}`);
|
27 | }
|
28 | this.balance = newBalance;
|
29 | }
|
30 | getValue() {
|
31 | return this.balance;
|
32 | }
|
33 | toJSON() {
|
34 | return {
|
35 | balance: this.balance.toString(),
|
36 | minimum: this.minimum.toString(),
|
37 | maximum: this.maximum.toString()
|
38 | };
|
39 | }
|
40 | }
|
41 | class BalanceMiddleware {
|
42 | constructor(opts, { getInfo, sendMoney, stats }) {
|
43 | this.balances = new Map();
|
44 | this.getInfo = getInfo;
|
45 | this.sendMoney = sendMoney;
|
46 | this.stats = stats;
|
47 | }
|
48 | async applyToPipelines(pipelines, accountId) {
|
49 | const accountInfo = this.getInfo(accountId);
|
50 | if (!accountInfo) {
|
51 | throw new Error('could not load info for account. accountId=' + accountId);
|
52 | }
|
53 | const account = { accountId, accountInfo };
|
54 | if (accountInfo.balance) {
|
55 | const { minimum = '-Infinity', maximum } = accountInfo.balance;
|
56 | const balance = new Balance({
|
57 | minimum: new bignumber_js_1.default(minimum),
|
58 | maximum: new bignumber_js_1.default(maximum)
|
59 | });
|
60 | this.balances.set(accountId, balance);
|
61 | log.info('initializing balance for account. accountId=%s minimumBalance=%s maximumBalance=%s', accountId, minimum, maximum);
|
62 | pipelines.startup.insertLast({
|
63 | name: 'balance',
|
64 | method: async (dummy, next) => {
|
65 | this.maybeSettle(accountId);
|
66 | this.stats.balance.setValue(account, {}, balance.getValue().toNumber());
|
67 | return next(dummy);
|
68 | }
|
69 | });
|
70 | pipelines.incomingData.insertLast({
|
71 | name: 'balance',
|
72 | method: async (data, next) => {
|
73 | if (data[0] === IlpPacket.Type.TYPE_ILP_PREPARE) {
|
74 | const parsedPacket = IlpPacket.deserializeIlpPrepare(data);
|
75 | if (parsedPacket.amount === '0') {
|
76 | return next(data);
|
77 | }
|
78 | balance.add(parsedPacket.amount);
|
79 | log.trace('balance increased due to incoming ilp prepare. accountId=%s amount=%s newBalance=%s', accountId, parsedPacket.amount, balance.getValue());
|
80 | this.stats.balance.setValue(account, {}, balance.getValue().toNumber());
|
81 | let result;
|
82 | try {
|
83 | result = await next(data);
|
84 | }
|
85 | catch (err) {
|
86 | balance.subtract(parsedPacket.amount);
|
87 | log.debug('incoming packet refunded due to error. accountId=%s amount=%s newBalance=%s', accountId, parsedPacket.amount, balance.getValue());
|
88 | this.stats.balance.setValue(account, {}, balance.getValue().toNumber());
|
89 | this.stats.incomingDataPacketValue.increment(account, { result: 'failed' }, +parsedPacket.amount);
|
90 | throw err;
|
91 | }
|
92 | if (result[0] === IlpPacket.Type.TYPE_ILP_REJECT) {
|
93 | balance.subtract(parsedPacket.amount);
|
94 | log.debug('incoming packet refunded due to ilp reject. accountId=%s amount=%s newBalance=%s', accountId, parsedPacket.amount, balance.getValue());
|
95 | this.stats.balance.setValue(account, {}, balance.getValue().toNumber());
|
96 | this.stats.incomingDataPacketValue.increment(account, { result: 'rejected' }, +parsedPacket.amount);
|
97 | }
|
98 | else if (result[0] === IlpPacket.Type.TYPE_ILP_FULFILL) {
|
99 | this.maybeSettle(accountId).catch(log.error);
|
100 | this.stats.incomingDataPacketValue.increment(account, { result: 'fulfilled' }, +parsedPacket.amount);
|
101 | }
|
102 | return result;
|
103 | }
|
104 | else {
|
105 | return next(data);
|
106 | }
|
107 | }
|
108 | });
|
109 | pipelines.incomingMoney.insertLast({
|
110 | name: 'balance',
|
111 | method: async (amount, next) => {
|
112 | balance.subtract(amount);
|
113 | log.trace('balance reduced due to incoming settlement. accountId=%s amount=%s newBalance=%s', accountId, amount, balance.getValue());
|
114 | this.stats.balance.setValue(account, {}, balance.getValue().toNumber());
|
115 | return next(amount);
|
116 | }
|
117 | });
|
118 | pipelines.outgoingData.insertLast({
|
119 | name: 'balance',
|
120 | method: async (data, next) => {
|
121 | if (data[0] === IlpPacket.Type.TYPE_ILP_PREPARE) {
|
122 | const parsedPacket = IlpPacket.deserializeIlpPrepare(data);
|
123 | if (parsedPacket.amount === '0') {
|
124 | return next(data);
|
125 | }
|
126 | let result;
|
127 | try {
|
128 | result = await next(data);
|
129 | }
|
130 | catch (err) {
|
131 | log.debug('outgoing packet not applied due to error. accountId=%s amount=%s newBalance=%s', accountId, parsedPacket.amount, balance.getValue());
|
132 | this.stats.outgoingDataPacketValue.increment(account, { result: 'failed' }, +parsedPacket.amount);
|
133 | throw err;
|
134 | }
|
135 | if (result[0] === IlpPacket.Type.TYPE_ILP_REJECT) {
|
136 | log.debug('outgoing packet not applied due to ilp reject. accountId=%s amount=%s newBalance=%s', accountId, parsedPacket.amount, balance.getValue());
|
137 | this.stats.outgoingDataPacketValue.increment(account, { result: 'rejected' }, +parsedPacket.amount);
|
138 | }
|
139 | else if (result[0] === IlpPacket.Type.TYPE_ILP_FULFILL) {
|
140 | balance.subtract(parsedPacket.amount);
|
141 | this.maybeSettle(accountId).catch(log.error);
|
142 | log.trace('balance decreased due to outgoing ilp fulfill. accountId=%s amount=%s newBalance=%s', accountId, parsedPacket.amount, balance.getValue());
|
143 | this.stats.balance.setValue(account, {}, balance.getValue().toNumber());
|
144 | this.stats.outgoingDataPacketValue.increment(account, { result: 'fulfilled' }, +parsedPacket.amount);
|
145 | }
|
146 | return result;
|
147 | }
|
148 | else {
|
149 | return next(data);
|
150 | }
|
151 | }
|
152 | });
|
153 | pipelines.outgoingMoney.insertLast({
|
154 | name: 'balance',
|
155 | method: async (amount, next) => {
|
156 | balance.add(amount);
|
157 | log.trace('balance increased due to outgoing settlement. accountId=%s amount=%s newBalance=%s', accountId, amount, balance.getValue());
|
158 | this.stats.balance.setValue(account, {}, balance.getValue().toNumber());
|
159 | return next(amount);
|
160 | }
|
161 | });
|
162 | }
|
163 | else {
|
164 | log.warn('(!!!) balance middleware NOT enabled for account, this account can spend UNLIMITED funds. accountId=%s', accountId);
|
165 | }
|
166 | }
|
167 | getStatus() {
|
168 | const accounts = {};
|
169 | this.balances.forEach((balance, accountId) => {
|
170 | accounts[accountId] = balance.toJSON();
|
171 | });
|
172 | return { accounts };
|
173 | }
|
174 | modifyBalance(accountId, _amountDiff) {
|
175 | const accountInfo = this.getInfo(accountId);
|
176 | if (!accountInfo) {
|
177 | throw new Error('could not load info for account. accountId=' + accountId);
|
178 | }
|
179 | const account = { accountId, accountInfo };
|
180 | const amountDiff = new bignumber_js_1.default(_amountDiff);
|
181 | const balance = this.getBalance(accountId);
|
182 | log.warn('modifying balance accountId=%s amount=%s', accountId, amountDiff.toString());
|
183 | if (amountDiff.isPositive()) {
|
184 | balance.add(amountDiff);
|
185 | }
|
186 | else {
|
187 | balance.subtract(amountDiff.negated());
|
188 | this.maybeSettle(accountId).catch(log.error);
|
189 | }
|
190 | this.stats.balance.setValue(account, {}, balance.getValue().toNumber());
|
191 | return balance.getValue();
|
192 | }
|
193 | getBalance(accountId) {
|
194 | const balance = this.balances.get(accountId);
|
195 | if (!balance) {
|
196 | throw new Error('account not found. accountId=' + accountId);
|
197 | }
|
198 | return balance;
|
199 | }
|
200 | async maybeSettle(accountId) {
|
201 | const accountInfo = this.getInfo(accountId);
|
202 | const { settleThreshold, settleTo = '0' } = accountInfo.balance;
|
203 | const bnSettleThreshold = settleThreshold ? new bignumber_js_1.default(settleThreshold) : undefined;
|
204 | const bnSettleTo = new bignumber_js_1.default(settleTo);
|
205 | const balance = this.getBalance(accountId);
|
206 | const settle = bnSettleThreshold && bnSettleThreshold.gt(balance.getValue());
|
207 | if (!settle)
|
208 | return;
|
209 | const settleAmount = bnSettleTo.minus(balance.getValue());
|
210 | log.debug('settlement triggered. accountId=%s balance=%s settleAmount=%s', accountId, balance.getValue(), settleAmount);
|
211 | await this.sendMoney(settleAmount.toString(), accountId)
|
212 | .catch(e => {
|
213 | let err = e;
|
214 | if (!err || typeof err !== 'object') {
|
215 | err = new Error('Non-object thrown: ' + e);
|
216 | }
|
217 | log.error('error occurred during settlement. accountId=%s settleAmount=%s errInfo=%s', accountId, settleAmount, err.stack ? err.stack : err);
|
218 | });
|
219 | }
|
220 | }
|
221 | exports.default = BalanceMiddleware;
|
222 |
|
\ | No newline at end of file |