@ocap/tx-protocols 1.13.59 → 1.13.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,10 +9,10 @@ const { account, stake, evidence, rollupBlock, Joi } = require('@ocap/state');
9
9
  // eslint-disable-next-line global-require
10
10
  const debug = require('debug')(`${require('../../../package.json').name}:claim-block-reward`);
11
11
 
12
+ const { toStakeAddress } = require('@arcblock/did-util');
12
13
  const VerifySigners = require('./pipes/verify-signers');
13
- const { applyTokenChange, splitBlockReward, getBlockRewardLocker } = require('../../util');
14
+ const { applyTokenChange, splitTxFee, getBNSum, getRewardLocker, RATE_BASE } = require('../../util');
14
15
 
15
- const ZERO = new BN(0);
16
16
  const runner = new Runner();
17
17
 
18
18
  // 1. verify itx
@@ -39,12 +39,21 @@ runner.use((context, next) => {
39
39
  return next(new Error('INVALID_TX', 'itx.publisher must be same with tx.from'));
40
40
  }
41
41
 
42
- context.lockerAddress = getBlockRewardLocker(itx.rollup);
42
+ context.lockerAddress = getRewardLocker(itx.rollup);
43
43
 
44
44
  return next();
45
45
  });
46
46
 
47
- // 2. verify rollup and block state
47
+ // 2. ensure evidence not exist
48
+ runner.use(pipes.ExtractState({ from: 'itx.evidence.hash', to: 'evidenceSeen', status: 'OK', table: 'evidence' }));
49
+ runner.use(pipes.ExtractState({ from: 'itx.blockHash', to: 'blockClaimed', status: 'OK', table: 'evidence' }));
50
+ runner.use(({ evidenceSeen, blockClaimed }, next) => {
51
+ if (evidenceSeen) return next(new Error('INVALID_TX', 'Claim evidence already seen on this chain'));
52
+ if (blockClaimed) return next(new Error('INVALID_TX', 'Block reward already claimed before this tx'));
53
+ return next();
54
+ });
55
+
56
+ // 3. verify rollup and block state
48
57
  runner.use(pipes.ExtractState({ from: 'itx.rollup', to: 'rollupState', status: 'INVALID_ROLLUP', table: 'rollup' }));
49
58
  runner.use(pipes.ExtractState({ from: 'itx.blockHash', to: 'blockState', status: 'INVALID_ROLLUP_BLOCK', table: 'rollupBlock' })); // prettier-ignore
50
59
  runner.use((context, next) => {
@@ -55,24 +64,28 @@ runner.use((context, next) => {
55
64
  if (blockState.height !== itx.blockHeight) {
56
65
  return next(new Error('INVALID_TX', 'Rollup block height does not match'));
57
66
  }
67
+
68
+ // If the publisher is not the producer, he should wait at least rollupState.publishWaitingPeriod
69
+ if (blockState.proposer !== itx.publisher) {
70
+ const proposedAt = +new Date(blockState.context.genesisTime);
71
+ const publishedAt = +new Date(context.txTime);
72
+ if (proposedAt + rollupState.publishWaitingPeriod * 1000 > publishedAt) {
73
+ return next(new Error('INVALID_TX', 'Rollup block can only be published by producer during waiting period'));
74
+ }
75
+ }
76
+
77
+ context.producerStake = toStakeAddress(blockState.proposer, itx.rollup);
78
+
58
79
  return next();
59
80
  });
60
81
 
61
- // 3. verify tx signers & signatures
82
+ // 4. verify tx signers & signatures
62
83
  runner.use(VerifySigners({ signersKey: 'tx.signaturesList', allowSender: true }));
63
84
  runner.use(pipes.VerifyMultiSigV2({ signersKey: 'signers' }));
64
85
 
65
- // 4. verify block reward locker
86
+ // 5. verify block reward locker
66
87
  runner.use(pipes.ExtractState({ from: 'lockerAddress', to: 'lockerState', status: 'INVALID_LOCKER_STATE', table: 'stake' })); // prettier-ignore
67
-
68
- // 5. ensure evidence not exist
69
- runner.use(pipes.ExtractState({ from: 'itx.evidence.hash', to: 'evidenceSeen', status: 'OK', table: 'evidence' }));
70
- runner.use(pipes.ExtractState({ from: 'itx.blockHash', to: 'blockClaimed', status: 'OK', table: 'evidence' }));
71
- runner.use(({ evidenceSeen, blockClaimed }, next) => {
72
- if (evidenceSeen) return next(new Error('INVALID_TX', 'Claim evidence already seen on this chain'));
73
- if (blockClaimed) return next(new Error('INVALID_TX', 'Block reward already claimed before this tx'));
74
- return next();
75
- });
88
+ runner.use(pipes.ExtractState({ from: 'producerStake', to: 'stakeState', status: 'INVALID_STAKE_STATE', table: 'stake' })); // prettier-ignore
76
89
 
77
90
  // 6. verify sender and signer states
78
91
  runner.use(pipes.ExtractState({ from: 'tx.from', to: 'senderState', status: 'INVALID_SENDER_STATE', table: 'account' })); // prettier-ignore
@@ -85,8 +98,7 @@ runner.use(pipes.ExtractState({ from: 'rollupState.tokenAddress', to: 'tokenStat
85
98
  // 8. split and aggregate block reward for each tx and the block
86
99
  runner.use(pipes.ExtractState({ from: 'blockState.txs', to: 'txs', status: 'INVALID_TX', table: 'tx' }));
87
100
  runner.use(async (context, next) => {
88
- const { itx, txs, rollupState, blockState, lockerState, tokenState } = context;
89
- const { depositFeeRate, withdrawFeeRate, minDepositFee, maxDepositFee, minWithdrawFee, maxWithdrawFee } = rollupState;
101
+ const { itx, txs, rollupState, blockState, lockerState, tokenState, stakeState } = context;
90
102
  const { proposerFeeShare, publisherFeeShare } = rollupState;
91
103
 
92
104
  const shares = {
@@ -95,23 +107,28 @@ runner.use(async (context, next) => {
95
107
  validator: 10000 - publisherFeeShare - proposerFeeShare,
96
108
  };
97
109
 
98
- // 1. loop through the tx and split reward
99
110
  const changes = { account: [], stake: [] };
111
+
112
+ // 0. handle slash case
113
+ // TODO: the producer stake balance should be enough for slashing
114
+ const shouldSlash = blockState.proposer !== itx.publisher;
115
+ if (shouldSlash) {
116
+ const slashAmount = new BN(rollupState.minStakeAmount).mul(new BN(rollupState.publishSlashRate)).div(RATE_BASE);
117
+ const slashShare = slashAmount.div(new BN(2));
118
+ changes.stake.push({ address: stakeState.address, delta: `-${slashAmount.toString(10)}`, action: 'slash' });
119
+ changes.stake.push({ address: lockerState.address, delta: slashShare.toString(10), action: 'slash' });
120
+ changes.account.push({ address: itx.publisher, delta: slashShare.toString(10), action: 'reward' });
121
+ }
122
+
123
+ // 1. loop through the tx and split reward
100
124
  txs.forEach((x) => {
101
- const amount = x.tx.itxJson.token.value;
125
+ const { actualFee } = x.tx.itxJson;
126
+ const { publisher, proposer, validator } = splitTxFee({ total: actualFee, shares, stringify: false });
127
+
128
+ changes.stake.push({ address: lockerState.address, delta: `-${actualFee}`, action: 'claim' });
129
+ changes.account.push({ address: itx.publisher, delta: publisher.toString(10), action: 'fee' });
130
+
102
131
  if (x.type === 'deposit_token_v2') {
103
- const { fee, feeShares } = splitBlockReward({
104
- total: amount,
105
- feeRate: depositFeeRate,
106
- maxFee: maxDepositFee,
107
- minFee: minDepositFee,
108
- shares,
109
- stringify: false,
110
- });
111
- changes.stake.push({ address: lockerState.address, delta: `-${fee}`, action: 'claim' });
112
-
113
- const { publisher, proposer, validator } = feeShares;
114
- changes.account.push({ address: itx.publisher, delta: publisher.toString(10), action: 'fee' });
115
132
  changes.account.push({ address: x.tx.itxJson.proposer, delta: proposer.toString(10), action: 'fee' });
116
133
 
117
134
  // block and tx signers share the validator part
@@ -122,18 +139,6 @@ runner.use(async (context, next) => {
122
139
  changes.account.push({ address: v, delta: validatorShare.toString(10), action: 'fee' })
123
140
  );
124
141
  } else if (x.type === 'withdraw_token_v2') {
125
- const { fee, feeShares } = splitBlockReward({
126
- total: amount,
127
- feeRate: withdrawFeeRate,
128
- maxFee: maxWithdrawFee,
129
- minFee: minWithdrawFee,
130
- shares,
131
- stringify: false,
132
- });
133
- changes.stake.push({ address: lockerState.address, delta: `-${fee}`, action: 'claim' });
134
-
135
- const { publisher, proposer, validator } = feeShares;
136
- changes.account.push({ address: itx.publisher, delta: publisher.toString(10), action: 'fee' });
137
142
  changes.account.push({ address: blockState.proposer, delta: proposer.toString(10), action: 'fee' });
138
143
 
139
144
  // block signers share the validator part
@@ -157,7 +162,7 @@ runner.use(async (context, next) => {
157
162
  acc[x] = {
158
163
  address: x,
159
164
  token: tokenState.address,
160
- delta: grouped.stake[x].reduce((a, u) => a.add(new BN(u.delta)), ZERO),
165
+ delta: getBNSum(...grouped.stake[x].map((c) => c.delta)),
161
166
  action: grouped.stake[x][grouped.stake[x].length - 1].action,
162
167
  };
163
168
  return acc;
@@ -166,7 +171,7 @@ runner.use(async (context, next) => {
166
171
  acc[x] = {
167
172
  address: x,
168
173
  token: tokenState.address,
169
- delta: grouped.account[x].reduce((a, u) => a.add(new BN(u.delta)), ZERO),
174
+ delta: getBNSum(...grouped.account[x].map((c) => c.delta)),
170
175
  action: grouped.account[x][grouped.account[x].length - 1].action,
171
176
  };
172
177
  return acc;
@@ -179,8 +184,6 @@ runner.use(async (context, next) => {
179
184
  return next();
180
185
  });
181
186
 
182
- // TODO: verify locker balance
183
-
184
187
  // 9. extract all accounts that will be updated exist
185
188
  runner.use((context, next) => {
186
189
  const { updates, senderState, signerStates } = context;
@@ -208,17 +211,23 @@ runner.use((context, next) => {
208
211
  });
209
212
 
210
213
  // 10. update state
211
- // FIXME: (future) we have a low probability of encounter QLDB 40 documents in a single transaction limit
212
214
  runner.use(
213
215
  async (context, next) => {
214
- const { tx, itx, updates, statedb, senderState, blockState, lockerState, accountStates } = context;
215
-
216
- // This balance from the locker will be checked when apply updates
217
- const lockerUpdates = applyTokenChange(lockerState, updates.stake[lockerState.address]);
216
+ const { tx, itx, updates, statedb, senderState, blockState, lockerState, stakeState, accountStates } = context;
218
217
 
219
- const [newAccountStates, newLockerState, newBlockState] = await Promise.all([
220
- // update locker state
221
- statedb.stake.update(lockerState.address, stake.update(lockerState, lockerUpdates, context), context),
218
+ const [newStakeStates, newAccountStates, newBlockState] = await Promise.all([
219
+ // update stake states
220
+ Promise.all(
221
+ [lockerState, stakeState].map((x) =>
222
+ updates.stake[x.address]
223
+ ? statedb.stake.update(
224
+ x.address,
225
+ stake.update(x, applyTokenChange(x, updates.stake[x.address]), context),
226
+ context
227
+ )
228
+ : x
229
+ )
230
+ ),
222
231
 
223
232
  // update accounts(proposer, publisher, validator)
224
233
  Promise.all(
@@ -254,12 +263,12 @@ runner.use(
254
263
  ),
255
264
  ]);
256
265
 
257
- context.updatedAccounts = cloneDeep([...Object.values(updates.account), ...Object.values(updates.stake)]);
258
- context.updatedAccounts.forEach((x) => (x.delta = x.delta.toString(10))); // eslint-disable-line
266
+ context.stakeStates = newStakeStates;
259
267
  context.accountStates = newAccountStates;
260
- context.stakeState = newLockerState;
261
268
  context.blockState = newBlockState;
262
269
 
270
+ context.updatedAccounts = cloneDeep([...Object.values(updates.account), ...Object.values(updates.stake)]);
271
+
263
272
  debug('claim-block-reward', itx);
264
273
 
265
274
  next();
@@ -1,13 +1,11 @@
1
1
  /* eslint-disable indent */
2
2
  const pick = require('lodash/pick');
3
- const groupBy = require('lodash/groupBy');
4
3
  const Error = require('@ocap/util/lib/error');
5
4
  const MerkleTree = require('@ocap/merkle-tree');
6
- const { BN } = require('@ocap/util');
7
5
  const { formatMessage } = require('@ocap/message');
8
6
  const { toStakeAddress } = require('@arcblock/did-util');
9
7
  const { Runner, pipes } = require('@ocap/tx-pipeline');
10
- const { account, stake, rollup, rollupBlock, evidence, Joi } = require('@ocap/state');
8
+ const { account, stake, rollup, rollupBlock, tx: Tx, Joi } = require('@ocap/state');
11
9
 
12
10
  // eslint-disable-next-line global-require
13
11
  const debug = require('debug')(`${require('../../../package.json').name}:create-rollup-block`);
@@ -15,7 +13,7 @@ const debug = require('debug')(`${require('../../../package.json').name}:create-
15
13
  const VerifySigners = require('./pipes/verify-signers');
16
14
  const VerifyEvidence = require('./pipes/verify-evidence');
17
15
  const VerifyPaused = require('./pipes/verify-paused');
18
- const { applyTokenUpdates, applyTokenChange, splitBlockReward, getBlockRewardLocker } = require('../../util');
16
+ const { applyTokenChange, ensureBlockReward, getRewardLocker } = require('../../util');
19
17
 
20
18
  const runner = new Runner();
21
19
 
@@ -34,6 +32,7 @@ const schema = Joi.object({
34
32
  proposer: Joi.DID().wallet('ethereum').required(),
35
33
  signaturesList: Joi.multiSigSchema.min(1).required(),
36
34
  rollup: Joi.DID().role('ROLE_ROLLUP').required(),
35
+ minReward: Joi.BN().min(0).required(),
37
36
  data: Joi.any().optional(),
38
37
  }).options({ stripUnknown: true, noDefaults: false });
39
38
 
@@ -143,7 +142,6 @@ runner.use((context, next) => {
143
142
  }
144
143
 
145
144
  // ensure minBlockInterval
146
- // TODO: (future) 这里需要再讨论下
147
145
  const minBlockTime = +new Date(previousBlock.context.genesisTime) + rollupState.minBlockInterval * 1000;
148
146
  const newBlockTime = +new Date(txTime);
149
147
  if (newBlockTime < minBlockTime) {
@@ -171,57 +169,42 @@ runner.use(
171
169
  pipes.ExtractState({ from: 'rollupState.tokenAddress', to: 'tokenState', status: 'INVALID_TOKEN', table: 'token' })
172
170
  );
173
171
 
174
- // 8. verify evidence
175
- runner.use(pipes.ExtractState({ from: 'itx.txsList', to: 'evidenceStates', status: 'OK', table: 'evidence' }));
176
- runner.use(({ evidenceStates }, next) => {
177
- if (Array.isArray(evidenceStates) && evidenceStates.length > 0) {
172
+ // 8. verify txs
173
+ runner.use(pipes.ExtractState({ from: 'itx.txsList', to: 'txStates', status: 'INVALID_TX', table: 'tx' }));
174
+ runner.use(({ itx, txStates }, next) => {
175
+ // ensure not finalized
176
+ const finalizedTx = txStates.some((x) => x.finalized);
177
+ if (finalizedTx) {
178
178
  return next(new Error('INVALID_TX', 'Should not include finalized tx in later block'));
179
179
  }
180
180
 
181
- return next();
182
- });
183
-
184
- // 9. verify txs
185
- runner.use(pipes.ExtractState({ from: 'itx.txsList', to: 'txs', status: 'INVALID_TX', table: 'tx' }));
186
- runner.use(({ itx, txs, rollupState }, next) => {
187
181
  // ensure tx are successfully executed
188
- const invalidStatusTx = txs.some((x) => x.code !== 'OK');
182
+ const invalidStatusTx = txStates.some((x) => x.code !== 'OK');
189
183
  if (invalidStatusTx) {
190
184
  return next(new Error('INVALID_TX', 'Rollup block can only include successful executed transactions'));
191
185
  }
192
186
 
193
187
  // ensure tx type
194
- const invalidTypeTx = txs.some((x) => ['deposit_token_v2', 'withdraw_token_v2'].includes(x.type) === false);
188
+ const invalidTypeTx = txStates.some((x) => ['deposit_token_v2', 'withdraw_token_v2'].includes(x.type) === false);
195
189
  if (invalidTypeTx) {
196
190
  return next(new Error('INVALID_TX', "Rollup block can only include 'withdraw_token' and 'deposit_token' tx"));
197
191
  }
198
192
 
199
193
  // ensure rollup belonging
200
- const invalidRollupTx = txs.some((x) => x.tx.itxJson.rollup !== itx.rollup);
194
+ const invalidRollupTx = txStates.some((x) => x.tx.itxJson.rollup !== itx.rollup);
201
195
  if (invalidRollupTx) {
202
196
  return next(new Error('INVALID_TX', `Rollup block can only include tx from rollup ${itx.rollup}`));
203
197
  }
204
198
 
205
199
  // ensure merkleRoot
206
- const { withdrawFeeRate, minWithdrawFee, maxWithdrawFee } = rollupState;
207
200
  const merkleTree = MerkleTree.getBlockMerkleTree(
208
- txs
201
+ txStates
209
202
  .filter((x) => x.type === 'withdraw_token_v2')
210
- .map((x) => {
211
- // User can only withdraw erc20 tokens without the withdraw fee
212
- const { user: userAmount } = splitBlockReward({
213
- total: x.tx.itxJson.token.value,
214
- feeRate: withdrawFeeRate,
215
- maxFee: maxWithdrawFee,
216
- minFee: minWithdrawFee,
217
- });
218
-
219
- return {
220
- hash: x.hash,
221
- to: x.tx.itxJson.to,
222
- amount: userAmount,
223
- };
224
- })
203
+ .map((x) => ({
204
+ hash: x.hash,
205
+ to: x.tx.itxJson.to,
206
+ amount: x.tx.itxJson.token.value,
207
+ }))
225
208
  );
226
209
  if (merkleTree.getHexRoot() !== itx.merkleRoot) {
227
210
  return next(new Error('INVALID_TX', 'Invalid rollup block merkle root'));
@@ -230,118 +213,45 @@ runner.use(({ itx, txs, rollupState }, next) => {
230
213
  return next();
231
214
  });
232
215
 
233
- // 10. verify staking: used for minting & burning
216
+ // 9. verify staking: used for minting & burning
234
217
  runner.use((context, next) => {
235
- const { itx, txs } = context;
218
+ const { itx, txStates } = context;
236
219
 
237
- const depositProposerStakeAddr = txs
220
+ const depositProposerStakeAddr = txStates
238
221
  .filter((x) => x.type === 'deposit_token_v2')
239
222
  .map((x) => toStakeAddress(x.tx.itxJson.proposer, itx.rollup));
240
223
 
241
- const withdrawLockerStakeAddr = txs
224
+ const withdrawLockerStakeAddr = txStates
242
225
  .filter((x) => x.type === 'withdraw_token_v2')
243
226
  .map((x) => toStakeAddress(x.tx.from, itx.rollup));
244
227
 
245
- context.stakeAddress = [...depositProposerStakeAddr, ...withdrawLockerStakeAddr];
246
- context.lockerAddress = getBlockRewardLocker(itx.rollup);
228
+ context.stakeAddress = [...depositProposerStakeAddr, ...withdrawLockerStakeAddr, getRewardLocker(itx.rollup)];
247
229
 
248
230
  return next();
249
231
  });
250
- runner.use(pipes.ExtractState({ from: 'lockerAddress', to: 'lockerState', status: 'OK', table: 'stake' }));
251
- runner.use(
252
- pipes.ExtractState({ from: 'stakeAddress', to: 'stakeStates', status: 'INVALID_STAKE_STATE', table: 'stake' })
253
- );
232
+ runner.use(pipes.ExtractState({ from: 'stakeAddress', to: 'stakeStates', status: 'INVALID_STAKE_STATE', table: 'stake' })); // prettier-ignore
233
+
234
+ // 10. ensure block reward and dynamic tx fees
235
+ runner.use((context, next) => {
236
+ const { itx, txStates, rollupState } = context;
237
+ try {
238
+ const result = ensureBlockReward(rollupState, itx.minReward, txStates);
239
+ context.senders = Object.keys(result.accountUpdates);
240
+ Object.assign(context, result);
241
+ return next();
242
+ } catch (err) {
243
+ return next(err);
244
+ }
245
+ });
246
+ runner.use(pipes.ExtractState({ from: 'senders', to: 'senderStates', status: 'INVALID_SENDER_STATE', table: 'account' })); // prettier-ignore
254
247
 
255
248
  // 11. update state: mint tokens for deposits, burn tokens for withdraws
256
249
  runner.use(
257
250
  async (context, next) => {
258
- const {
259
- tx,
260
- itx,
261
- txs,
262
- statedb,
263
- rollupState,
264
- senderState,
265
- tokenState,
266
- stakeStates,
267
- lockerAddress,
268
- lockerState,
269
- formattedItx,
270
- } = context;
271
- const { depositFeeRate, withdrawFeeRate, minDepositFee, maxDepositFee, minWithdrawFee, maxWithdrawFee } =
272
- rollupState;
273
-
274
- const ZERO = new BN(0);
275
-
276
- context.mintedAmount = new BN(0);
277
- context.burnedAmount = new BN(0);
278
- context.rewardAmount = new BN(0);
279
- context.pendingReward = new BN(0);
280
-
281
- const tokenAddress = tokenState.address;
282
-
283
- // 1. loop through the tx and gen all updates, minted, burned
284
- const updates = { stake: [] };
285
- txs.forEach((x) => {
286
- const amount = x.tx.itxJson.token.value;
287
- if (x.type === 'deposit_token_v2') {
288
- const { fee: feeAmount } = splitBlockReward({
289
- total: amount,
290
- feeRate: depositFeeRate,
291
- maxFee: maxDepositFee,
292
- minFee: minDepositFee,
293
- stringify: false,
294
- });
295
- context.rewardAmount = context.rewardAmount.add(feeAmount);
296
- context.mintedAmount = context.mintedAmount.add(new BN(amount));
297
-
298
- // mint tokens for deposit proposer
299
- updates.stake.push({
300
- address: toStakeAddress(x.tx.itxJson.proposer, itx.rollup),
301
- delta: amount,
302
- action: 'mint',
303
- });
304
- } else if (x.type === 'withdraw_token_v2') {
305
- const { total: totalAmount, fee: feeAmount } = splitBlockReward({
306
- total: amount,
307
- feeRate: withdrawFeeRate,
308
- maxFee: maxWithdrawFee,
309
- minFee: minWithdrawFee,
310
- stringify: false,
311
- });
312
-
313
- // save reward to rollup locker stake
314
- context.pendingReward = context.pendingReward.add(feeAmount);
315
-
316
- context.rewardAmount = context.rewardAmount.add(feeAmount);
317
- context.burnedAmount = context.burnedAmount.add(totalAmount);
318
-
319
- // burn tokens from user locker stakes
320
- updates.stake.push({ address: toStakeAddress(x.tx.from, itx.rollup), delta: `-${amount}`, action: 'burn' });
321
- }
322
- });
323
-
324
- // 2. aggregate stake updates
325
- const grouped = { stake: groupBy(updates.stake, 'address') };
326
- const stakeUpdates = Object.keys(grouped.stake).reduce((acc, x) => {
327
- acc[x] = {
328
- address: x,
329
- token: tokenAddress,
330
- delta: grouped.stake[x].reduce((a, u) => a.add(new BN(u.delta)), ZERO),
331
- action: grouped.stake[x][grouped.stake[x].length - 1].action,
332
- };
333
-
334
- return acc;
335
- }, {});
336
-
337
- const lockerUpdates = applyTokenUpdates(
338
- [{ address: tokenAddress, value: context.pendingReward.toString(10) }],
339
- lockerState || { tokens: {} },
340
- 'add'
341
- );
251
+ const { tx, itx, statedb, rollupState, senderState, stakeStates, txStates, formattedItx } = context;
252
+ const senderStates = context.senderStates || [];
342
253
 
343
- // 3. update account & stake states
344
- const [newSenderState, newStakeStates, newRollupState, rollupBlockState, newLockerState] = await Promise.all([
254
+ const [newSenderState, newStakeStates, newSenderStates, newRollupState, rollupBlockState] = await Promise.all([
345
255
  // update sender(proposer) account
346
256
  statedb.account.update(
347
257
  senderState.address,
@@ -352,16 +262,27 @@ runner.use(
352
262
  // update stake states
353
263
  Promise.all(
354
264
  stakeStates.map((x) =>
355
- stakeUpdates[x.address]
265
+ context.stakeUpdates[x.address]
356
266
  ? statedb.stake.update(
357
267
  x.address,
358
- stake.update(x, applyTokenChange(x, stakeUpdates[x.address]), context),
268
+ stake.update(x, applyTokenChange(x, context.stakeUpdates[x.address]), context),
359
269
  context
360
270
  )
361
271
  : Promise.resolve(x)
362
272
  )
363
273
  ),
364
274
 
275
+ // update tx owner states: those may get refund
276
+ Promise.all(
277
+ senderStates.map((x) =>
278
+ statedb.account.update(
279
+ x.address,
280
+ account.update(x, applyTokenChange(x, context.accountUpdates[x.address]), context),
281
+ context
282
+ )
283
+ )
284
+ ),
285
+
365
286
  // Update rollup state
366
287
  statedb.rollup.update(
367
288
  rollupState.address,
@@ -385,40 +306,17 @@ runner.use(
385
306
  context
386
307
  ),
387
308
 
388
- // Update pending fee locker state
389
- lockerState
390
- ? statedb.stake.update(lockerState.address, stake.update(lockerState, lockerUpdates, context), context)
391
- : statedb.stake.create(
392
- itx.address,
393
- stake.create(
394
- {
395
- address: lockerAddress,
396
- sender: itx.rollup,
397
- receiver: itx.rollup,
398
- revocable: false,
399
- message: 'pending-block-reward',
400
- revokeWaitingPeriod: 0,
401
- ...lockerUpdates,
402
- },
403
- context
404
- ),
405
- context
406
- ),
407
-
408
- // Create evidence
409
- Promise.all(
410
- itx.txsList.map((x) =>
411
- statedb.evidence.create(x, evidence.create({ hash: x, data: 'rollup-block' }, context), context)
412
- )
413
- ),
309
+ // Update tx states: mark as finalized
310
+ Promise.all(txStates.map((x) => statedb.tx.update(x.hash, Tx.update(x, { finalized: true, tx: x.tx }), context))),
414
311
  ]);
415
312
 
416
313
  context.senderState = newSenderState;
417
- context.stakeStates = [...newStakeStates, newLockerState];
314
+ context.stakeStates = newStakeStates;
315
+ context.newSenderStates = newSenderStates;
418
316
  context.rollupState = newRollupState;
419
317
  context.rollupBlockState = rollupBlockState;
420
- context.updatedAccounts = [...Object.values(stakeUpdates)];
421
- context.updatedAccounts.forEach((x) => (x.delta = x.delta.toString(10))); // eslint-disable-line
318
+
319
+ context.updatedAccounts = [...Object.values(context.stakeUpdates), ...Object.values(context.accountUpdates)];
422
320
 
423
321
  debug('create-rollup-block', { itx, updatedAccounts: context.updatedAccounts });
424
322
 
@@ -11,7 +11,7 @@ const debug = require('debug')(`${require('../../../package.json').name}:deposit
11
11
 
12
12
  const VerifySigners = require('../rollup/pipes/verify-signers');
13
13
  const VerifyPaused = require('../rollup/pipes/verify-paused');
14
- const { applyTokenUpdates, splitBlockReward, getBlockRewardLocker } = require('../../util');
14
+ const { applyTokenUpdates, getTxFee, getRewardLocker } = require('../../util');
15
15
 
16
16
  const schema = Joi.object({
17
17
  token: Joi.tokenInputSchema.required(),
@@ -21,6 +21,7 @@ const schema = Joi.object({
21
21
  hash: Joi.string().regex(Joi.hashRegexp).required(),
22
22
  }).required(),
23
23
  rollup: Joi.DID().role('ROLE_ROLLUP').required(),
24
+ actualFee: Joi.BN().min(0).required(),
24
25
  data: Joi.any().optional(),
25
26
  }).options({ stripUnknown: true, noDefaults: false });
26
27
 
@@ -82,21 +83,17 @@ runner.use(VerifySigners({ signersKey: 'tx.signaturesList', allowSender: true })
82
83
  runner.use(pipes.VerifyMultiSigV2({ signersKey: 'signers' }));
83
84
 
84
85
  // 5. verify token state
85
- runner.use(
86
- pipes.ExtractState({ from: 'itx.token.address', to: 'tokenState', status: 'INVALID_TOKEN', table: 'token' })
87
- );
86
+ runner.use(pipes.ExtractState({ from: 'itx.token.address', to: 'tokenState', status: 'INVALID_TOKEN', table: 'token' })); // prettier-ignore
88
87
 
89
88
  // 6. verify staking: get address, extract state, verify amount
90
89
  runner.use((context, next) => {
91
90
  const { itx } = context;
92
91
  context.stakeAddress = toStakeAddress(itx.proposer, itx.rollup);
93
- context.lockerAddress = getBlockRewardLocker(itx.rollup);
92
+ context.lockerAddress = getRewardLocker(itx.rollup);
94
93
  return next();
95
94
  });
96
95
  runner.use(pipes.ExtractState({ from: 'lockerAddress', to: 'lockerState', status: 'OK', table: 'stake' }));
97
- runner.use(
98
- pipes.ExtractState({ from: 'stakeAddress', to: 'stakeState', status: 'INVALID_STAKE_STATE', table: 'stake' })
99
- );
96
+ runner.use(pipes.ExtractState({ from: 'stakeAddress', to: 'stakeState', status: 'INVALID_STAKE_STATE', table: 'stake' })); // prettier-ignore
100
97
  runner.use((context, next) => {
101
98
  const { itx, stakeState, tokenState, rollupState } = context;
102
99
  if (stakeState.revocable === true) {
@@ -136,51 +133,41 @@ runner.use((context, next) => {
136
133
  return next();
137
134
  });
138
135
 
136
+ // 7. verify actualFee
137
+ runner.use((context, next) => {
138
+ const { itx, rollupState, tokenState } = context;
139
+ const { depositFeeRate, maxDepositFee, minDepositFee } = rollupState;
140
+
141
+ context.txFee = getTxFee({
142
+ amount: itx.token.value,
143
+ feeRate: depositFeeRate,
144
+ maxFee: maxDepositFee,
145
+ minFee: minDepositFee,
146
+ });
147
+
148
+ if (new BN(itx.actualFee).lt(new BN(context.txFee.reward))) {
149
+ const expected = fromUnitToToken(context.txFee.reward, tokenState.decimal);
150
+ const actual = fromUnitToToken(itx.actualFee, tokenState.decimal);
151
+ return next(new Error('INVALID_TX', `itx.actualFee too low, expect at least ${expected}, got ${actual}`));
152
+ }
153
+
154
+ return next();
155
+ });
156
+
139
157
  // 7. verify sender and signer states
140
- runner.use(
141
- pipes.ExtractState({ from: 'tx.from', to: 'senderState', status: 'INVALID_SENDER_STATE', table: 'account' })
142
- );
143
- runner.use(
144
- pipes.ExtractState({ from: 'signers', to: 'signerStates', status: 'INVALID_SIGNER_STATE', table: 'account' })
145
- );
158
+ runner.use(pipes.ExtractState({ from: 'tx.from', to: 'senderState', status: 'INVALID_SENDER_STATE', table: 'account' })); // prettier-ignore
159
+ runner.use(pipes.ExtractState({ from: 'signers', to: 'signerStates', status: 'INVALID_SIGNER_STATE', table: 'account' })); // prettier-ignore
146
160
  runner.use(pipes.VerifyAccountMigration({ signerKey: 'signerStates', senderKey: 'senderState' }));
147
161
 
148
162
  // 8. update state: the token minting is done when deposit finalized in rollup-block
149
163
  runner.use(
150
164
  async (context, next) => {
151
- const { tx, itx, statedb, rollupState, senderState, stakeState, stakeAddress, lockerState, lockerAddress } =
152
- context;
153
- const { depositFeeRate, maxDepositFee, minDepositFee } = rollupState;
154
-
155
- const tokenAddress = itx.token.address;
156
- const { user: userAmount, fee: feeAmount } = splitBlockReward({
157
- total: itx.token.value,
158
- feeRate: depositFeeRate,
159
- maxFee: maxDepositFee,
160
- minFee: minDepositFee,
161
- });
162
-
163
- const updatedAccounts = []; // for generating receipts
164
- updatedAccounts.push({ address: senderState.address, token: tokenAddress, delta: userAmount, action: 'unlock' });
165
- // stake for tx proposer is decreased
166
- updatedAccounts.push({
167
- address: stakeAddress,
168
- token: tokenAddress,
169
- delta: `-${itx.token.value}`,
170
- action: 'unlock',
171
- });
172
- // block reward is locked for later claiming
173
- updatedAccounts.push({
174
- address: lockerAddress,
175
- token: tokenAddress,
176
- delta: feeAmount,
177
- action: 'pending',
178
- });
179
-
180
- const senderUpdates = applyTokenUpdates([{ address: tokenAddress, value: userAmount }], senderState, 'add');
181
- const stakeUpdates = applyTokenUpdates([itx.token], stakeState, 'sub');
165
+ const { tx, itx, txFee, statedb, senderState, stakeState, stakeAddress, lockerState, lockerAddress } = context;
166
+
167
+ const stakeUpdates = applyTokenUpdates([{ address: itx.token.address, value: txFee.total }], stakeState, 'sub');
168
+ const senderUpdates = applyTokenUpdates([{ address: itx.token.address, value: txFee.user }], senderState, 'add');
182
169
  const lockerUpdates = applyTokenUpdates(
183
- [{ address: tokenAddress, value: feeAmount }],
170
+ [{ address: itx.token.address, value: txFee.reward }],
184
171
  lockerState || { tokens: {} },
185
172
  'add'
186
173
  );
@@ -228,9 +215,17 @@ runner.use(
228
215
  context.stakeState = newStakeState;
229
216
  context.evidenceState = evidenceState;
230
217
  context.stakeStates = [newStakeState, newLockerState];
231
- context.updatedAccounts = updatedAccounts;
232
218
 
233
- debug('deposit-token-v2', { itx, updatedAccounts });
219
+ context.updatedAccounts = [
220
+ // stake for tx proposer is decreased
221
+ { address: stakeAddress, token: itx.token.address, delta: `-${txFee.total}`, action: 'unlock' },
222
+ // mint to depositor from stake
223
+ { address: senderState.address, token: itx.token.address, delta: txFee.user, action: 'unlock' },
224
+ // block reward is locked for later claiming
225
+ { address: lockerAddress, token: itx.token.address, delta: txFee.reward, action: 'pending' },
226
+ ];
227
+
228
+ debug('deposit-token-v2', itx);
234
229
 
235
230
  next();
236
231
  },
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable indent */
2
2
  const Error = require('@ocap/util/lib/error');
3
+ const getListField = require('@ocap/util/lib/get-list-field');
3
4
  const { BN, fromUnitToToken } = require('@ocap/util');
4
5
  const { Runner, pipes } = require('@ocap/tx-pipeline');
5
6
  const { account, stake, Joi } = require('@ocap/state');
@@ -9,19 +10,22 @@ const { toStakeAddress } = require('@arcblock/did-util');
9
10
  const debug = require('debug')(`${require('../../../package.json').name}:withdraw-token`);
10
11
 
11
12
  const VerifyPaused = require('../rollup/pipes/verify-paused');
12
- const { applyTokenUpdates } = require('../../util');
13
+ const { applyTokenUpdates, getTxFee, getBNSum, getRewardLocker } = require('../../util');
14
+
15
+ const verifyMultiSigV2 = pipes.VerifyMultiSigV2({ signersKey: 'signers' });
13
16
 
14
17
  const schema = Joi.object({
15
18
  token: Joi.tokenInputSchema.required(),
16
19
  to: Joi.DID().wallet('ethereum').required(),
17
20
  rollup: Joi.DID().role('ROLE_ROLLUP').required(),
21
+ proposer: Joi.DID().wallet('ethereum').optional().allow('').default(''),
22
+ actualFee: Joi.BN().min(0).required(),
23
+ maxFee: Joi.BN().min(0).required(),
18
24
  data: Joi.any().optional(),
19
25
  }).options({ stripUnknown: true, noDefaults: false });
20
26
 
21
27
  const runner = new Runner();
22
28
 
23
- runner.use(pipes.VerifyMultiSig(0));
24
-
25
29
  // 1. verify itx
26
30
  runner.use(({ tx, itx }, next) => {
27
31
  const { error } = schema.validate(itx);
@@ -48,18 +52,14 @@ runner.use((context, next) => {
48
52
  return next();
49
53
  });
50
54
 
51
- // 5. verify token state
52
- runner.use(
53
- pipes.ExtractState({ from: 'itx.token.address', to: 'tokenState', status: 'INVALID_TOKEN', table: 'token' })
54
- );
55
+ // 3. verify token state
56
+ runner.use(pipes.ExtractState({ from: 'itx.token.address', to: 'tokenState', status: 'INVALID_TOKEN', table: 'token' })); // prettier-ignore
55
57
 
56
- // 6. verify sender and signer states
57
- runner.use(
58
- pipes.ExtractState({ from: 'tx.from', to: 'senderState', status: 'INVALID_SENDER_STATE', table: 'account' })
59
- );
58
+ // 4. verify sender and signer states
59
+ runner.use(pipes.ExtractState({ from: 'tx.from', to: 'senderState', status: 'INVALID_SENDER_STATE', table: 'account' })); // prettier-ignore
60
60
  runner.use(pipes.VerifyAccountMigration({ senderKey: 'senderState' }));
61
61
 
62
- // 7. verify amount
62
+ // 5. verify amount
63
63
  runner.use((context, next) => {
64
64
  const { tx, itx, tokenState, rollupState } = context;
65
65
 
@@ -85,18 +85,71 @@ runner.use((context, next) => {
85
85
  }
86
86
 
87
87
  // user balance
88
- context.tokenConditions = { owner: tx.from, tokens: [itx.token] };
88
+ const maxAmount = getBNSum(itx.token.value, itx.actualFee, itx.maxFee);
89
+ context.tokenConditions = { owner: tx.from, tokens: [{ address: itx.token.address, value: maxAmount }] };
89
90
 
90
91
  return next();
91
92
  });
92
93
  runner.use(pipes.VerifyTokenBalance({ ownerKey: 'senderState', conditionKey: 'tokenConditions' }));
93
94
 
95
+ // 6. verify actualFee
96
+ runner.use((context, next) => {
97
+ const { itx, rollupState, tokenState } = context;
98
+ const { withdrawFeeRate, maxWithdrawFee, minWithdrawFee } = rollupState;
99
+
100
+ const isFixedFee = new BN(itx.maxFee).isZero();
101
+ if (isFixedFee) {
102
+ context.txFee = getTxFee({
103
+ amount: itx.token.value,
104
+ feeRate: withdrawFeeRate,
105
+ maxFee: maxWithdrawFee,
106
+ minFee: minWithdrawFee,
107
+ });
108
+
109
+ if (new BN(itx.actualFee).lt(new BN(context.txFee.reward))) {
110
+ const expected = fromUnitToToken(context.txFee.reward, tokenState.decimal);
111
+ const actual = fromUnitToToken(itx.actualFee, tokenState.decimal);
112
+ return next(new Error('INVALID_TX', `itx.actualFee too low, expect at least ${expected}, got ${actual}`));
113
+ }
114
+ }
115
+
116
+ return next();
117
+ });
118
+
119
+ // 7. verify maxFee and conditional multi-sig
120
+ // eslint-disable-next-line consistent-return
121
+ runner.use((context, next) => {
122
+ const { tx, itx } = context;
123
+ const isFixedFee = new BN(itx.maxFee).isZero();
124
+ if (isFixedFee) {
125
+ return next();
126
+ }
127
+
128
+ const signatures = getListField(tx, 'signatures');
129
+
130
+ // ensure signature count
131
+ if (signatures.length !== 1) {
132
+ return next(new Error('INVALID_TX', 'Withdraw with maxFee should have one multi-sig'));
133
+ }
134
+
135
+ // ensure proposer in signer list
136
+ if (signatures.some((x) => x.signer === itx.proposer) === false) {
137
+ return next(new Error('INVALID_TX', 'itx.proposer must exist in tx.signatures'));
138
+ }
139
+
140
+ // ensure signature is valid
141
+ context.signers = signatures.map((x) => x.signer);
142
+ verifyMultiSigV2(context, next);
143
+ });
144
+
94
145
  // 8. verify staking: user withdraw is locked in stake
95
146
  runner.use((context, next) => {
96
147
  const { itx, senderState } = context;
97
148
  context.stakeAddress = toStakeAddress(senderState.address, itx.rollup);
149
+ context.lockerAddress = getRewardLocker(itx.rollup);
98
150
  return next();
99
151
  });
152
+ runner.use(pipes.ExtractState({ from: 'lockerAddress', to: 'lockerState', status: 'OK', table: 'stake' }));
100
153
  runner.use(pipes.ExtractState({ from: 'stakeAddress', to: 'stakeState', status: 'OK', table: 'stake' }));
101
154
  runner.use((context, next) => {
102
155
  const { stakeState } = context;
@@ -110,17 +163,28 @@ runner.use((context, next) => {
110
163
  // 8. update state: the fee splitting and token burning is done when withdraw finalized in rollup-block
111
164
  runner.use(
112
165
  async (context, next) => {
113
- const { tx, itx, statedb, stakeState, senderState, stakeAddress } = context;
114
- const updatedAccounts = []; // for generating receipts
115
- const { address, value } = itx.token;
166
+ const { tx, itx, statedb, stakeState, senderState, lockerState, lockerAddress, stakeAddress } = context;
167
+
168
+ const total = getBNSum(itx.token.value, itx.actualFee, itx.maxFee);
169
+ const fee = getBNSum(itx.actualFee, itx.maxFee);
170
+
171
+ const senderUpdates = applyTokenUpdates([{ address: itx.token.address, value: total }], senderState, 'sub');
116
172
 
117
- const senderUpdates = applyTokenUpdates([itx.token], senderState, 'sub');
118
- const stakeUpdates = applyTokenUpdates([itx.token], stakeState || {}, 'add');
173
+ // Burned amount should equal to user received amount
174
+ const stakeUpdates = applyTokenUpdates(
175
+ [{ address: itx.token.address, value: itx.token.value }],
176
+ stakeState || {},
177
+ 'add'
178
+ );
119
179
 
120
- updatedAccounts.push({ address: senderState.address, token: address, delta: `-${value}`, action: 'lock' });
121
- updatedAccounts.push({ address: stakeAddress, token: address, delta: value, action: 'lock' });
180
+ // Fees are locked to reward locker, and funded later
181
+ const lockerUpdates = applyTokenUpdates(
182
+ [{ address: itx.token.address, value: fee }],
183
+ lockerState || { tokens: {} },
184
+ 'add'
185
+ );
122
186
 
123
- const [newSenderState, newStakeState] = await Promise.all([
187
+ const [newSenderState, newStakeState, newLockerState] = await Promise.all([
124
188
  // update user account
125
189
  statedb.account.update(
126
190
  senderState.address,
@@ -147,13 +211,40 @@ runner.use(
147
211
  ),
148
212
  context
149
213
  ),
214
+
215
+ // Update pending fee locker state
216
+ lockerState
217
+ ? statedb.stake.update(lockerState.address, stake.update(lockerState, lockerUpdates, context), context)
218
+ : statedb.stake.create(
219
+ lockerAddress,
220
+ stake.create(
221
+ {
222
+ address: lockerAddress,
223
+ sender: itx.rollup,
224
+ receiver: itx.rollup,
225
+ revocable: false,
226
+ message: 'pending-block-reward',
227
+ revokeWaitingPeriod: 0,
228
+ ...lockerUpdates,
229
+ },
230
+ context
231
+ ),
232
+ context
233
+ ),
150
234
  ]);
151
235
 
152
236
  context.senderState = newSenderState;
153
237
  context.stakeState = newStakeState;
154
- context.updatedAccounts = updatedAccounts;
238
+ context.lockerState = newLockerState;
239
+ context.stakeStates = [newStakeState, newLockerState];
240
+
241
+ context.updatedAccounts = [
242
+ { address: senderState.address, token: itx.token.address, delta: `-${total}`, action: 'lock' },
243
+ { address: stakeAddress, token: itx.token.address, delta: itx.token.value, action: 'lock' },
244
+ { address: lockerAddress, token: itx.token.address, delta: fee, action: 'pending' },
245
+ ];
155
246
 
156
- debug('withdraw-token-v2', { itx, senderUpdates });
247
+ debug('withdraw-token-v2', itx);
157
248
 
158
249
  next();
159
250
  },
package/lib/util.js CHANGED
@@ -1,9 +1,13 @@
1
+ const groupBy = require('lodash/groupBy');
2
+ const flattenDeep = require('lodash/flattenDeep');
3
+ const Error = require('@ocap/util/lib/error');
1
4
  const { BN } = require('@ocap/util');
2
5
  const { decodeAny } = require('@ocap/message');
3
6
  const { toStakeAddress } = require('@arcblock/did-util');
4
7
  const cloneDeep = require('lodash/cloneDeep');
5
8
 
6
9
  const ZERO = new BN(0);
10
+ const RATE_BASE = new BN(10000);
7
11
 
8
12
  const decodeAnyNested = (encoded) => {
9
13
  if (!encoded) {
@@ -50,7 +54,7 @@ const decodeAnySafe = (encoded) => {
50
54
 
51
55
  const applyTokenUpdates = (tokens, state, operator) => {
52
56
  if (['add', 'sub'].includes(operator) === false) {
53
- throw new Error(`Invalid operator when applyTokenUpdates: ${operator}`);
57
+ throw new Error('FORBIDDEN', `Invalid operator when applyTokenUpdates: ${operator}`);
54
58
  }
55
59
 
56
60
  if (!state) {
@@ -65,7 +69,7 @@ const applyTokenUpdates = (tokens, state, operator) => {
65
69
  const balance = new BN(oldTokens[address] || 0);
66
70
  const newBalance = balance[operator](requirement);
67
71
  if (newBalance.lt(ZERO)) {
68
- throw new Error(`Negative token balance when applyTokenUpdates for ${address}`);
72
+ throw new Error('FORBIDDEN', `Negative token balance when applyTokenUpdates for ${address}`);
69
73
  }
70
74
  newTokens[address] = newBalance.toString(10);
71
75
  }
@@ -75,10 +79,11 @@ const applyTokenUpdates = (tokens, state, operator) => {
75
79
  };
76
80
  };
77
81
  const applyTokenChange = (state, change) => {
78
- if (change.delta.gt(ZERO)) {
79
- return applyTokenUpdates([{ address: change.token, value: change.delta.toString(10) }], state, 'add');
82
+ const delta = typeof change.delta === 'string' ? new BN(change.delta) : change.delta;
83
+ if (delta.gt(ZERO)) {
84
+ return applyTokenUpdates([{ address: change.token, value: delta.toString(10) }], state, 'add');
80
85
  }
81
- return applyTokenUpdates([{ address: change.token, value: change.delta.abs().toString(10) }], state, 'sub');
86
+ return applyTokenUpdates([{ address: change.token, value: delta.abs().toString(10) }], state, 'sub');
82
87
  };
83
88
 
84
89
  const fixTokenInput = (input, config) => {
@@ -91,54 +96,221 @@ const fixTokenInput = (input, config) => {
91
96
  return input;
92
97
  };
93
98
 
94
- const RATE_BASE = new BN(10000);
95
- const splitBlockReward = ({ total, feeRate, maxFee, minFee, shares = {}, stringify = true }) => {
99
+ const getTxFee = ({ amount, feeRate, maxFee, minFee, stringify = true }) => {
100
+ const userAmount = new BN(amount);
96
101
  const maxFeeAmount = new BN(maxFee);
97
102
  const minFeeAmount = new BN(minFee);
98
- const totalAmount = new BN(total);
103
+
104
+ if (feeRate < 0) {
105
+ throw new Error('FORBIDDEN', 'Unexpected negative feeRate when getTxFee, abort!');
106
+ }
107
+ if (userAmount.lt(ZERO)) {
108
+ throw new Error('FORBIDDEN', 'Unexpected negative amount when getTxFee, abort!');
109
+ }
110
+ if (maxFeeAmount.lt(ZERO)) {
111
+ throw new Error('FORBIDDEN', 'Unexpected negative maxFee when getTxFee, abort!');
112
+ }
113
+ if (minFeeAmount.lt(ZERO)) {
114
+ throw new Error('FORBIDDEN', 'Unexpected negative minFee when getTxFee, abort!');
115
+ }
99
116
 
100
117
  // total fee
101
- let feeAmount = totalAmount.mul(new BN(feeRate)).div(RATE_BASE);
102
- if (feeAmount.lt(minFeeAmount)) {
103
- feeAmount = minFeeAmount;
118
+ let rewardAmount = userAmount.mul(new BN(feeRate)).div(RATE_BASE);
119
+ if (rewardAmount.lt(minFeeAmount)) {
120
+ rewardAmount = minFeeAmount;
104
121
  }
105
- if (feeAmount.gt(maxFeeAmount)) {
106
- feeAmount = maxFeeAmount;
122
+ if (rewardAmount.gt(maxFeeAmount)) {
123
+ rewardAmount = maxFeeAmount;
107
124
  }
108
125
 
109
- // fee shares
110
- const feeShares = Object.keys(shares).reduce((acc, x) => {
111
- acc[x] = feeAmount.mul(new BN(shares[x])).div(RATE_BASE);
112
- return acc;
113
- }, {});
126
+ // total amount
127
+ const totalAmount = userAmount.add(rewardAmount);
114
128
 
115
129
  if (stringify) {
116
130
  return {
117
131
  total: totalAmount.toString(10),
118
- fee: feeAmount.toString(10),
119
- user: totalAmount.sub(feeAmount).toString(10),
120
- feeShares: Object.keys(feeShares).reduce((acc, x) => {
121
- acc[x] = feeShares[x].toString(10);
122
- return acc;
123
- }, {}),
132
+ user: userAmount.toString(10),
133
+ reward: rewardAmount.toString(10),
124
134
  };
125
135
  }
126
136
 
127
137
  return {
128
138
  total: totalAmount,
129
- fee: feeAmount,
130
- user: totalAmount.sub(feeAmount),
131
- feeShares,
139
+ user: userAmount,
140
+ reward: rewardAmount,
132
141
  };
133
142
  };
134
143
 
135
- const getBlockRewardLocker = (rollupAddress) => toStakeAddress(rollupAddress, rollupAddress);
144
+ const splitTxFee = ({ total, shares = {}, stringify = true }) => {
145
+ const totalAmount = new BN(total);
146
+ if (totalAmount.lt(ZERO)) {
147
+ throw new Error('FORBIDDEN', 'Unexpected negative total when splitTxFee, abort!');
148
+ }
149
+ Object.keys(shares).forEach((key) => {
150
+ if (shares[key] < 0) {
151
+ throw new Error('FORBIDDEN', `Unexpected negative shares[${key}] when splitTxFee, abort!`);
152
+ }
153
+ });
154
+
155
+ const rewardShares = Object.keys(shares).reduce((acc, x) => {
156
+ acc[x] = totalAmount.mul(new BN(shares[x])).div(RATE_BASE);
157
+ return acc;
158
+ }, {});
159
+
160
+ return Object.keys(rewardShares).reduce((acc, x) => {
161
+ acc[x] = stringify ? rewardShares[x].toString(10) : rewardShares[x];
162
+ return acc;
163
+ }, {});
164
+ };
165
+
166
+ const getRewardLocker = (rollupAddress) => toStakeAddress(rollupAddress, rollupAddress);
167
+ const getBNSum = (...args) => flattenDeep(args).reduce((sum, x) => sum.add(new BN(x)), new BN(0)).toString(10); // prettier-ignore
168
+ const isFixedFee = (x) => new BN(x.tx.itxJson.maxFee).isZero();
169
+
170
+ const ensureBlockReward = (rollupState, minReward, txStates) => {
171
+ const { address, withdrawFeeRate, minWithdrawFee, maxWithdrawFee, tokenAddress } = rollupState;
172
+ const locker = getRewardLocker(address);
173
+
174
+ const result = {
175
+ mintedAmount: new BN(0),
176
+ burnedAmount: new BN(0),
177
+ rewardAmount: new BN(0),
178
+ };
179
+
180
+ // 0. ensure reward requirement
181
+ const maxPossibleReward = txStates.reduce(
182
+ (sum, x) => sum.add(new BN(isFixedFee(x) ? x.tx.itxJson.actualFee : x.tx.itxJson.maxFee)),
183
+ new BN(0)
184
+ );
185
+ const minRequiredReward = new BN(minReward);
186
+ if (maxPossibleReward.lt(minRequiredReward)) {
187
+ throw new Error('INVALID_BLOCK', 'Block reward does not match minReward requirement');
188
+ }
189
+
190
+ // 1. find dynamic reward tx
191
+ const dynamicFeeTxs = txStates.filter((x) => isFixedFee(x) === false);
192
+ const totalDynamicFee = dynamicFeeTxs.reduce((sum, x) => sum.add(new BN(x.tx.itxJson.maxFee)), new BN(0));
193
+
194
+ const fixedFeeTxs = txStates.filter((x) => isFixedFee(x));
195
+ const totalFixedFee = fixedFeeTxs.reduce((sum, x) => sum.add(new BN(x.tx.itxJson.actualFee)), new BN(0));
196
+
197
+ const totalMissingFee = minRequiredReward.sub(totalFixedFee);
198
+
199
+ // 2. calculate actual reward for each dynamic reward tx, mark tx to be updated
200
+ const changes = { stake: [], account: [] };
201
+ dynamicFeeTxs.forEach((x) => {
202
+ const maxFee = new BN(x.tx.itxJson.maxFee);
203
+ let actualFee = new BN(0);
204
+ // If totalMissingFee is less than 0, then the tx will be charged for fixedFee
205
+ if (totalMissingFee.lt(ZERO)) {
206
+ const fee = getTxFee({
207
+ amount: x.tx.itxJson.token.value,
208
+ feeRate: withdrawFeeRate,
209
+ maxFee: maxWithdrawFee,
210
+ minFee: minWithdrawFee,
211
+ stringify: false,
212
+ });
213
+ actualFee = fee.reward;
214
+ } else {
215
+ // Else the tx is charged for a portion of totalMissingFee
216
+ actualFee = totalMissingFee.mul(maxFee).div(totalDynamicFee);
217
+ }
218
+
219
+ if (actualFee.lt(ZERO)) {
220
+ throw new Error('FORBIDDEN', 'Got negative actualFee for tx, abort!');
221
+ }
222
+
223
+ // If the actualFee is less than the maxFee, user will have a refund
224
+ if (actualFee.lt(maxFee)) {
225
+ const refundFee = maxFee.sub(actualFee).toString(10);
226
+ changes.account.push({
227
+ address: x.tx.from,
228
+ delta: refundFee,
229
+ action: 'refund',
230
+ });
231
+ changes.stake.push({
232
+ address: locker,
233
+ delta: `-${refundFee}`,
234
+ action: 'refund',
235
+ });
236
+ }
237
+
238
+ x.tx.itxJson.actualFee = actualFee.toString(10);
239
+ });
240
+
241
+ // 3. bur/mint tokens, update stakes
242
+ txStates.forEach((x) => {
243
+ const user = x.tx.itxJson.token.value;
244
+ const fee = x.tx.itxJson.actualFee;
245
+ const total = getBNSum(user, fee);
246
+
247
+ if (x.type === 'deposit_token_v2') {
248
+ result.rewardAmount = result.rewardAmount.add(new BN(fee));
249
+ result.mintedAmount = result.mintedAmount.add(new BN(total));
250
+
251
+ // mint tokens for deposit proposer
252
+ changes.stake.push({
253
+ address: toStakeAddress(x.tx.itxJson.proposer, address),
254
+ delta: total,
255
+ action: 'mint',
256
+ });
257
+ } else if (x.type === 'withdraw_token_v2') {
258
+ result.rewardAmount = result.rewardAmount.add(new BN(fee));
259
+ result.burnedAmount = result.burnedAmount.add(new BN(user));
260
+
261
+ // burn tokens from locked withdraws: user amount
262
+ changes.stake.push({
263
+ address: toStakeAddress(x.tx.from, address),
264
+ delta: `-${user}`,
265
+ action: 'burn',
266
+ });
267
+ }
268
+ });
269
+
270
+ const grouped = {
271
+ stake: groupBy(changes.stake, 'address'),
272
+ account: groupBy(changes.account, 'address'),
273
+ };
274
+
275
+ result.stakeUpdates = Object.keys(grouped.stake).reduce((acc, x) => {
276
+ acc[x] = {
277
+ address: x,
278
+ token: tokenAddress,
279
+ delta: getBNSum(...grouped.stake[x].map((c) => c.delta)),
280
+ action: grouped.stake[x][grouped.stake[x].length - 1].action,
281
+ };
282
+
283
+ return acc;
284
+ }, {});
285
+
286
+ result.accountUpdates = Object.keys(grouped.account).reduce((acc, x) => {
287
+ acc[x] = {
288
+ address: x,
289
+ token: tokenAddress,
290
+ delta: getBNSum(...grouped.account[x].map((c) => c.delta)),
291
+ action: grouped.account[x][grouped.account[x].length - 1].action,
292
+ };
293
+
294
+ return acc;
295
+ }, {});
296
+
297
+ result.mintedAmount = result.mintedAmount.toString(10);
298
+ result.burnedAmount = result.burnedAmount.toString(10);
299
+ result.rewardAmount = result.rewardAmount.toString(10);
300
+
301
+ return result;
302
+ };
136
303
 
137
304
  module.exports = {
138
305
  decodeAnySafe,
139
306
  applyTokenUpdates,
140
307
  applyTokenChange,
141
308
  fixTokenInput,
142
- splitBlockReward,
143
- getBlockRewardLocker,
309
+ getTxFee,
310
+ splitTxFee,
311
+ getRewardLocker,
312
+ ensureBlockReward,
313
+ getBNSum,
314
+ isFixedFee,
315
+ RATE_BASE,
144
316
  };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.13.59",
6
+ "version": "1.13.63",
7
7
  "description": "Predefined tx pipeline sets to execute certain type of transactions",
8
8
  "main": "lib/index.js",
9
9
  "files": [
@@ -19,16 +19,16 @@
19
19
  "author": "wangshijun <wangshijun2010@gmail.com> (http://github.com/wangshijun)",
20
20
  "license": "MIT",
21
21
  "dependencies": {
22
- "@arcblock/did": "1.13.59",
23
- "@arcblock/did-util": "1.13.59",
24
- "@ocap/asset": "1.13.59",
25
- "@ocap/mcrypto": "1.13.59",
26
- "@ocap/merkle-tree": "1.13.59",
27
- "@ocap/message": "1.13.59",
28
- "@ocap/state": "1.13.59",
29
- "@ocap/tx-pipeline": "1.13.59",
30
- "@ocap/util": "1.13.59",
31
- "@ocap/wallet": "1.13.59",
22
+ "@arcblock/did": "1.13.63",
23
+ "@arcblock/did-util": "1.13.63",
24
+ "@ocap/asset": "1.13.63",
25
+ "@ocap/mcrypto": "1.13.63",
26
+ "@ocap/merkle-tree": "1.13.63",
27
+ "@ocap/message": "1.13.63",
28
+ "@ocap/state": "1.13.63",
29
+ "@ocap/tx-pipeline": "1.13.63",
30
+ "@ocap/util": "1.13.63",
31
+ "@ocap/wallet": "1.13.63",
32
32
  "debug": "^4.3.2",
33
33
  "empty-value": "^1.0.1",
34
34
  "lodash": "^4.17.21",
@@ -41,5 +41,5 @@
41
41
  "devDependencies": {
42
42
  "jest": "^27.3.1"
43
43
  },
44
- "gitHead": "534ed3e0856fa239d2962c4f4e2d636351940a91"
44
+ "gitHead": "7e40f22f90f4f5ac08fde3d4d3b7194aa792c95d"
45
45
  }