@ocap/resolver 1.19.1 → 1.19.3

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.
package/lib/hooks.js CHANGED
@@ -295,7 +295,14 @@ const onAccountMigrate = async (tx, resolver) => {
295
295
  }
296
296
  };
297
297
 
298
- const onCreateTx = (tx, ctx, resolver) => {
298
+ const onCreateTx = async (tx, ctx, resolver) => {
299
+ // Always update distribution even if tx failed cause there might be had gas
300
+ try {
301
+ await resolver.tokenDistribution.updateByTx(tx, ctx);
302
+ } catch (e) {
303
+ resolver.logger.error('Failed to update token distribution', e);
304
+ }
305
+
299
306
  if (tx.code !== 'OK') {
300
307
  return;
301
308
  }
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-loop-func */
1
2
  /* eslint-disable no-await-in-loop */
2
3
  /* eslint-disable newline-per-chained-call */
3
4
  const get = require('lodash/get');
@@ -20,6 +21,7 @@ const { BN, toBN, fromTokenToUnit, toAddress } = require('@ocap/util');
20
21
  const { DEFAULT_TOKEN_DECIMAL } = require('@ocap/util/lib/constant');
21
22
  const { createExecutor } = require('@ocap/tx-protocols');
22
23
  const { decodeAnySafe } = require('@ocap/tx-protocols/lib/util');
24
+ const { Joi } = require('@arcblock/validator');
23
25
 
24
26
  const {
25
27
  createIndexedAccount,
@@ -48,6 +50,7 @@ const hooks = require('./hooks');
48
50
  const tokenFlow = require('./token-flow');
49
51
  const { getInstance: getTokenCacheInstance } = require('./token-cache');
50
52
  const { MigrationChainManager } = require('./migration-chain');
53
+ const { TokenDistributionManager } = require('./token-distribution');
51
54
 
52
55
  const noop = (x) => x;
53
56
  const CHAIN_ADDR = md5('OCAP_CHAIN_ADDR');
@@ -183,6 +186,7 @@ module.exports = class OCAPResolver {
183
186
 
184
187
  if (indexdb) {
185
188
  this.tokenCache = getTokenCacheInstance(indexdb.token);
189
+ this.tokenDistribution = new TokenDistributionManager(this);
186
190
  }
187
191
 
188
192
  if (this.tokenItx) {
@@ -842,6 +846,9 @@ module.exports = class OCAPResolver {
842
846
  }
843
847
  } catch (error) {
844
848
  console.error('create tx index failed', { account: x, error });
849
+ } finally {
850
+ // eslint-disable-next-line no-console
851
+ console.log(x);
845
852
  }
846
853
  });
847
854
 
@@ -1242,6 +1249,158 @@ module.exports = class OCAPResolver {
1242
1249
 
1243
1250
  return updatedTxs.filter(Boolean);
1244
1251
  }
1252
+
1253
+ async updateTokenDistribution({ tokenAddress = this.config.token.address, force = false } = {}) {
1254
+ const paramsSchema = Joi.object({
1255
+ tokenAddress: Joi.DID().prefix().role('ROLE_TOKEN').required(),
1256
+ force: Joi.boolean().required(),
1257
+ });
1258
+ const validation = paramsSchema.validate({ tokenAddress, force });
1259
+ if (validation.error) {
1260
+ throw new CustomError('INVALID_PARAMS', validation.error.message);
1261
+ }
1262
+
1263
+ const data = await this.tokenDistribution.updateByToken(tokenAddress, force);
1264
+ return data;
1265
+ }
1266
+
1267
+ async getTokenDistribution({ tokenAddress = this.config.token.address }) {
1268
+ const data = await this.tokenDistribution.getDistribution(tokenAddress);
1269
+ return data;
1270
+ }
1271
+
1272
+ /**
1273
+ * Fetch data in chunks to handle large datasets
1274
+ * @param {Function} fn Function to fetch data
1275
+ * @param {Object} param
1276
+ * @param {number} [param.concurrency=3] Number of concurrent requests
1277
+ * @param {number} [param.chunkSize=2000] Maximum number of items to return in each next() call
1278
+ * @param {number} [param.pageSize=100] Number of items per page
1279
+ * @param {Function} param.getTime Function to get timestamp from item
1280
+ * @param {string} param.dataKey Key to access data in response
1281
+ * @returns {Promise<Array>} Array of fetched items
1282
+ */
1283
+ listChunks(fn, { concurrency = 3, chunkSize = 2000, pageSize = 100, getTime, dataKey }) {
1284
+ let totalPage;
1285
+ let curPage;
1286
+ let done = false;
1287
+ let time;
1288
+
1289
+ const fetchFirstPage = async () => {
1290
+ const { paging, page, [dataKey]: list } = await fn({ size: pageSize, cursor: '0' });
1291
+ const total = paging?.total || page?.total;
1292
+ curPage = 1;
1293
+ totalPage = Math.ceil(total / pageSize);
1294
+ return list;
1295
+ };
1296
+
1297
+ const next = async () => {
1298
+ if (done) {
1299
+ return [];
1300
+ }
1301
+
1302
+ let results = [];
1303
+
1304
+ // first page
1305
+ if (!totalPage) {
1306
+ const data = await fetchFirstPage();
1307
+ // only 1 page
1308
+ if (data.length < pageSize) {
1309
+ done = true;
1310
+ return data;
1311
+ }
1312
+
1313
+ time = getTime(data[data.length - 1]);
1314
+ results = results.concat(data);
1315
+
1316
+ // limit
1317
+ if (data.length >= chunkSize) {
1318
+ return results;
1319
+ }
1320
+ }
1321
+
1322
+ // next pages
1323
+ for (; curPage < totalPage; curPage += concurrency) {
1324
+ const batchResults = await Promise.all(
1325
+ new Array(concurrency).fill(true).map(async (_, i) => {
1326
+ const { [dataKey]: list } = await fn({
1327
+ size: pageSize,
1328
+ cursor: i * pageSize,
1329
+ time,
1330
+ });
1331
+ return list;
1332
+ })
1333
+ );
1334
+ const flatResults = batchResults.flat();
1335
+
1336
+ // finish
1337
+ if (!flatResults.length) {
1338
+ done = true;
1339
+ return [];
1340
+ }
1341
+
1342
+ results = results.concat(flatResults);
1343
+ time = results.length ? getTime(results[results.length - 1]) : null;
1344
+
1345
+ // limit
1346
+ if (results.length >= chunkSize) {
1347
+ return results;
1348
+ }
1349
+ }
1350
+
1351
+ done = true;
1352
+ return results;
1353
+ };
1354
+
1355
+ return {
1356
+ next,
1357
+ done,
1358
+ };
1359
+ }
1360
+
1361
+ listTransactionsChunks(args = {}, { chunkSize = 2000, pageSize = 100 } = {}) {
1362
+ return this.listChunks(
1363
+ ({ size, time, cursor }) =>
1364
+ this.listTransactions({
1365
+ ...args,
1366
+ paging: {
1367
+ order: { field: 'time', type: 'asc' },
1368
+ ...(args.paging || {}),
1369
+ size,
1370
+ cursor,
1371
+ },
1372
+ timeFilter: Object.assign(args.timeFilter || {}, time ? { startDateTime: time } : {}),
1373
+ }),
1374
+ {
1375
+ dataKey: 'transactions',
1376
+ getTime: (tx) => tx?.time,
1377
+ chunkSize,
1378
+ pageSize,
1379
+ }
1380
+ );
1381
+ }
1382
+
1383
+ listStakeChunks(args = {}, { chunkSize = 2000, pageSize = 100 } = {}) {
1384
+ return this.listChunks(
1385
+ ({ size, time, cursor }) =>
1386
+ this.listStakes({
1387
+ ...args,
1388
+ paging: {
1389
+ order: { field: 'time', type: 'asc' },
1390
+ ...(args.paging || {}),
1391
+ size,
1392
+ cursor,
1393
+ },
1394
+ timeFilter: Object.assign(args.timeFilter || {}, time ? { startDateTime: time } : {}),
1395
+ }),
1396
+ {
1397
+ dataKey: 'stakes',
1398
+ getTime: (state) => state?.time,
1399
+ chunkSize,
1400
+ pageSize,
1401
+ }
1402
+ );
1403
+ }
1245
1404
  };
1246
1405
 
1247
1406
  module.exports.formatData = formatData;
@@ -0,0 +1,334 @@
1
+ /* eslint-disable no-loop-func */
2
+ /* eslint-disable no-await-in-loop */
3
+ /* eslint-disable consistent-return */
4
+ const { BN, isSameDid, fromTokenToUnit } = require('@ocap/util');
5
+ const { toTypeInfo } = require('@arcblock/did');
6
+ const {
7
+ types: { RoleType },
8
+ } = require('@ocap/mcrypto');
9
+ const { createIndexedTokenDistribution } = require('@ocap/indexdb/lib/util');
10
+ const { eachReceipts } = require('@ocap/state/lib/states/tx');
11
+ const { isGasStakeAddress } = require('@ocap/tx-protocols/lib/util');
12
+
13
+ const ZERO = new BN(0);
14
+
15
+ class TokenDistributionManager {
16
+ constructor(resolver) {
17
+ this.resolver = resolver;
18
+ this.indexdb = resolver.indexdb;
19
+ }
20
+
21
+ formatDistribution(distribution) {
22
+ const { tokenAddress, account, gas, fee, slashedVault, stake, revokedStake, gasStake, other, txTime } =
23
+ distribution;
24
+ return {
25
+ tokenAddress,
26
+ account: new BN(account || 0),
27
+ gas: new BN(gas || 0),
28
+ fee: new BN(fee || 0),
29
+ slashedVault: new BN(slashedVault || 0),
30
+ stake: new BN(stake || 0),
31
+ revokedStake: new BN(revokedStake || 0),
32
+ gasStake: new BN(gasStake || 0),
33
+ other: new BN(other || 0),
34
+ txTime: txTime || new Date(0).toISOString(),
35
+ };
36
+ }
37
+
38
+ async getDistribution(tokenAddress) {
39
+ const data = await this.indexdb.tokenDistribution.get(tokenAddress);
40
+ return data && createIndexedTokenDistribution(data);
41
+ }
42
+
43
+ async saveDistribution(distribution) {
44
+ const data = createIndexedTokenDistribution(distribution);
45
+ const indexdbDistribution = await this.getDistribution(data.tokenAddress);
46
+
47
+ if (!indexdbDistribution) {
48
+ await this.indexdb.tokenDistribution.insert(data);
49
+ } else {
50
+ // ensure txTime is latest
51
+ const latestTime = Math.max(new Date(indexdbDistribution.txTime).getTime(), new Date(data.txTime).getTime());
52
+ data.txTime = new Date(latestTime).toISOString();
53
+ await this.indexdb.tokenDistribution.update(data.tokenAddress, data);
54
+ }
55
+
56
+ return data;
57
+ }
58
+
59
+ /**
60
+ * Calculate token distribution based on all transaction data.
61
+ * This method is usually used to update historical token distribution data
62
+ *
63
+ * @param {string} tokenAddress Token address
64
+ * @param {boolean} force If force is false, only calculate distributions for new transactions after txTime in indexdb. default is false
65
+ * @returns {Promise<Object>}
66
+ */
67
+ async updateByToken(tokenAddress, force) {
68
+ const { logger, config } = this.resolver;
69
+
70
+ const distribution = force
71
+ ? this.formatDistribution({ tokenAddress })
72
+ : this.formatDistribution((await this.getDistribution(tokenAddress)) || { tokenAddress });
73
+ const isDefaultToken = tokenAddress === config.token.address;
74
+
75
+ logger?.info(`Update distribution by token (${tokenAddress})`, {
76
+ distribution: createIndexedTokenDistribution(distribution),
77
+ });
78
+
79
+ if (force && isDefaultToken) {
80
+ this.handleModerator(distribution);
81
+ }
82
+
83
+ const { next } = await this.resolver.listTransactionsChunks({
84
+ timeFilter: { startDateTime: distribution.txTime },
85
+ // Default token is used for gas payment and may appear in any transaction, so we cannot filter by tokenFilter
86
+ tokenFilter: isDefaultToken ? {} : { tokenFilter: { tokens: [tokenAddress] } },
87
+ });
88
+ let nextData = await next();
89
+
90
+ // Process transactions in chunks and update indexdb
91
+ while (nextData.length) {
92
+ logger?.info('Updating token distribution in chunks', {
93
+ chunkSize: nextData.length,
94
+ startTime: nextData[0].time,
95
+ startHash: nextData[0].hash,
96
+ endTime: nextData[nextData.length - 1].time,
97
+ });
98
+
99
+ const handlePromises = nextData.map((tx) => this.handleTx(tx, distribution));
100
+ await Promise.all(handlePromises);
101
+
102
+ // update indexdb
103
+ await this.saveDistribution(distribution);
104
+ nextData = await next();
105
+ }
106
+
107
+ // We cannot distinguish between revokedStake and stake from tx receipts here,
108
+ // so we need to read all stake transactions and recalculate token distribution based on their revokeTokens and tokens
109
+ await this.splitStake(distribution, force);
110
+ await this.saveDistribution(distribution);
111
+
112
+ const result = createIndexedTokenDistribution(distribution);
113
+
114
+ logger.info(`Token distribution update completed (${tokenAddress})`, { distribution: result });
115
+
116
+ return result;
117
+ }
118
+
119
+ /**
120
+ * Split out revokedStake / gasStake from stake
121
+ *
122
+ * @param {Object} distribution
123
+ * @param {boolean} force If force is false, only process transactions after txTime in indexdb. Default is false
124
+ * @returns {Promise<Object>}
125
+ */
126
+ async splitStake(distribution, force) {
127
+ const { tokenAddress } = distribution;
128
+
129
+ const { next } = await this.resolver.listStakeChunks({
130
+ timeFilter: !force ? { startDateTime: distribution.txTime } : {},
131
+ });
132
+
133
+ let nextData = await next();
134
+
135
+ // Process transactions in chunks and update indexdb
136
+ while (nextData.length) {
137
+ nextData.forEach((stakeState) => {
138
+ const isGasStake = this.isGasStake(stakeState);
139
+ const token = stakeState.tokens.find((x) => x.address === tokenAddress);
140
+ const revokedToken = stakeState.revokedTokens.find((x) => x.address === tokenAddress);
141
+ const revokedBalance = new BN(revokedToken?.balance || 0);
142
+ const balance = new BN(token?.balance || 0);
143
+
144
+ // stake -->> revokedStake
145
+ distribution.revokedStake = distribution.revokedStake.add(revokedBalance);
146
+ distribution.stake = distribution.stake.sub(revokedBalance);
147
+
148
+ // stake -->> gasStake
149
+ if (isGasStake) {
150
+ distribution.gasStake = distribution.gasStake.add(balance);
151
+ distribution.stake = distribution.stake.sub(balance);
152
+ }
153
+ });
154
+
155
+ // continue
156
+ nextData = await next();
157
+ }
158
+
159
+ return distribution;
160
+ }
161
+
162
+ /**
163
+ * Update token distribution by a single transaction.
164
+ * This method is usually used when a tx is completed.
165
+ *
166
+ * @param {Object} tx The transaction object
167
+ * @param {Object} context The transaction context
168
+ * @returns {Promise<Object>} The updated token distributions
169
+ */
170
+ async updateByTx(tx, context) {
171
+ const formattedTx = await this.resolver.formatTx(tx);
172
+ const tokens = formattedTx.tokenSymbols.map(({ address }) => address);
173
+
174
+ // Parse all tokens in this transaction
175
+ const results = await Promise.all(
176
+ tokens.map(async (tokenAddress) => {
177
+ const distribution = this.formatDistribution((await this.getDistribution(tokenAddress)) || { tokenAddress });
178
+ await this.handleTx(tx, distribution, { context, isHandleStake: true });
179
+ const data = await this.saveDistribution(distribution);
180
+ return data;
181
+ })
182
+ );
183
+
184
+ return results;
185
+ }
186
+
187
+ /**
188
+ * Parse token distribution for a single transaction
189
+ * @param {Object} tx
190
+ * @param {String} distribution
191
+ * @param {Object} param
192
+ * @param {Object} param.context
193
+ * @param {Boolean} param.isHandleStake Whether to handle revoked / gas stake tokens
194
+ * @returns {Promise<Object>} token distribution
195
+ */
196
+ async handleTx(tx, distribution, { context, isHandleStake = false } = {}) {
197
+ if (isHandleStake && !context) {
198
+ throw new Error('FORBIDDEN', 'context is missing, handle revoke stake is not supported without context');
199
+ }
200
+
201
+ const type = tx.tx?.itxJson?._type;
202
+ const receipts = tx.receipts || [];
203
+ const { tokenAddress } = distribution;
204
+
205
+ // Iterate through each transaction receipt and distribute tokens based on account type
206
+ const handlePromises = eachReceipts(receipts, async (address, change) => {
207
+ if (!isSameDid(change.target, tokenAddress)) return;
208
+ // In migrate tx, tokens are frozen for the old account and minted for the new account
209
+ // It behaves like minting new tokens in receipts
210
+ // So we should skip here
211
+ if (change.action === 'migrate') return;
212
+
213
+ const roleType = toTypeInfo(address).role;
214
+ const value = new BN(change.value);
215
+
216
+ // Stake
217
+ if (roleType === RoleType.ROLE_STAKE) {
218
+ if (isHandleStake) {
219
+ const stakeState = await this.getStakeState(address, context);
220
+ const isGasStake = this.isGasStake(stakeState);
221
+ // stake revokedTokens -->> account tokens
222
+ if (type === 'ClaimStakeTx') {
223
+ distribution.revokedStake = distribution.revokedStake.add(value);
224
+ return;
225
+ }
226
+ // stake tokens + stake revokedTokens -->> account tokens
227
+ if (type === 'SlashStakeTx') {
228
+ const beforeState = context.stateSnapshot[address];
229
+ const afterState = context.stakeState;
230
+ if (!beforeState || !afterState) {
231
+ throw new Error('INVALID_TOKEN_DISTRIBUTION', 'stake state is missing on slash stake tx');
232
+ }
233
+ const revokeTokenDiff = new BN(beforeState.revokedTokens[tokenAddress]).sub(
234
+ new BN(afterState.revokedTokens[tokenAddress])
235
+ );
236
+ const tokenDiff = new BN(beforeState.tokens[tokenAddress]).sub(new BN(afterState.tokens[tokenAddress]));
237
+
238
+ distribution.revokedStake = distribution.revokedStake.sub(revokeTokenDiff);
239
+ if (isGasStake) {
240
+ distribution.gasStake = distribution.gasStake.sub(tokenDiff);
241
+ } else {
242
+ distribution.stake = distribution.stake.sub(tokenDiff);
243
+ }
244
+ return;
245
+ }
246
+ // gasStake tokens -->> account tokens
247
+ if (isGasStake) {
248
+ distribution.gasStake = distribution.gasStake.add(value);
249
+ return;
250
+ }
251
+ }
252
+ distribution.stake = distribution.stake.add(value);
253
+ }
254
+ // Gas
255
+ else if (change.action === 'gas' && value.gt(ZERO)) {
256
+ distribution.gas = distribution.gas.add(value);
257
+ }
258
+ // Fee
259
+ else if (change.action === 'fee' && value.gt(ZERO)) {
260
+ distribution.fee = distribution.fee.add(value);
261
+ }
262
+ // SlashVault
263
+ else if (
264
+ type === 'SlashStakeTx' &&
265
+ value.gt(ZERO) &&
266
+ isSameDid(address, this.resolver.config.vaults.slashedStake)
267
+ ) {
268
+ distribution.slashedVault = distribution.slashedVault.add(value);
269
+ }
270
+ // Others: Account
271
+ else {
272
+ distribution.account = distribution.account.add(value);
273
+ }
274
+ });
275
+
276
+ await Promise.all(handlePromises);
277
+
278
+ if (isHandleStake && type === 'RevokeStakeTx') {
279
+ // RevokedStake has no receipts
280
+ // stake tokens -->> stake revokedTokens
281
+ const { address } = tx.tx.itxJson;
282
+ const stakeState = await this.getStakeState(address, context);
283
+ const isGasStake = this.isGasStake(stakeState);
284
+
285
+ tx.tx.itxJson.outputs.forEach((x) => {
286
+ x.tokens
287
+ .filter((change) => isSameDid(change.address, tokenAddress))
288
+ .forEach((change) => {
289
+ const value = new BN(change.value);
290
+ distribution.revokedStake = distribution.revokedStake.add(value);
291
+ if (isGasStake) {
292
+ distribution.gasStake = distribution.gasStake.sub(value);
293
+ } else {
294
+ distribution.stake = distribution.stake.sub(value);
295
+ }
296
+ });
297
+ });
298
+ }
299
+
300
+ distribution.txTime = tx.time;
301
+
302
+ return distribution;
303
+ }
304
+
305
+ handleModerator(distribution) {
306
+ const { config } = this.resolver;
307
+
308
+ if (distribution.tokenAddress !== config.token.address) {
309
+ return distribution;
310
+ }
311
+
312
+ const value = config.accounts
313
+ .filter((account) => account.balance > 0)
314
+ .map((account) => new BN(account.balance))
315
+ .reduce((cur, balance) => cur.add(balance), ZERO);
316
+
317
+ distribution.account = distribution.account.add(fromTokenToUnit(value.toString()));
318
+ return distribution;
319
+ }
320
+
321
+ async getStakeState(address, ctx) {
322
+ const stakeState =
323
+ ctx?.stateSnapshot?.[address] ||
324
+ (await this.resolver.runAsLambda((txn) => this.resolver.statedb.stake.get(address, { txn })));
325
+
326
+ return stakeState;
327
+ }
328
+
329
+ isGasStake(stakeState) {
330
+ return isGasStakeAddress(stakeState.sender, stakeState.address) && stakeState.message === 'stake-for-gas';
331
+ }
332
+ }
333
+
334
+ module.exports = { TokenDistributionManager };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.19.1",
6
+ "version": "1.19.3",
7
7
  "description": "GraphQL resolver built upon ocap statedb and GQL layer",
8
8
  "main": "lib/index.js",
9
9
  "files": [
@@ -22,18 +22,18 @@
22
22
  "jest": "^29.7.0"
23
23
  },
24
24
  "dependencies": {
25
- "@arcblock/did": "1.19.1",
26
- "@arcblock/did-util": "1.19.1",
27
- "@arcblock/validator": "1.19.1",
28
- "@ocap/config": "1.19.1",
29
- "@ocap/indexdb": "1.19.1",
30
- "@ocap/mcrypto": "1.19.1",
31
- "@ocap/message": "1.19.1",
32
- "@ocap/state": "1.19.1",
33
- "@ocap/tx-protocols": "1.19.1",
34
- "@ocap/util": "1.19.1",
25
+ "@arcblock/did": "1.19.3",
26
+ "@arcblock/did-util": "1.19.3",
27
+ "@arcblock/validator": "1.19.3",
28
+ "@ocap/config": "1.19.3",
29
+ "@ocap/indexdb": "1.19.3",
30
+ "@ocap/mcrypto": "1.19.3",
31
+ "@ocap/message": "1.19.3",
32
+ "@ocap/state": "1.19.3",
33
+ "@ocap/tx-protocols": "1.19.3",
34
+ "@ocap/util": "1.19.3",
35
35
  "debug": "^4.3.6",
36
36
  "lodash": "^4.17.21"
37
37
  },
38
- "gitHead": "21184488172c6c824ebd1714f728ff2aee4a3ac0"
38
+ "gitHead": "756076dad0df7468beecc95c8effd55f8c4c4f49"
39
39
  }