@ocap/resolver 1.18.142 → 1.18.144
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/index.js +31 -0
- package/lib/token-flow.js +330 -0
- package/package.json +12 -12
package/lib/index.js
CHANGED
|
@@ -20,6 +20,7 @@ const { BN, toBN, fromTokenToUnit, toAddress } = require('@ocap/util');
|
|
|
20
20
|
const { DEFAULT_TOKEN_DECIMAL } = require('@ocap/util/lib/constant');
|
|
21
21
|
const { createExecutor } = require('@ocap/tx-protocols');
|
|
22
22
|
const { decodeAnySafe } = require('@ocap/tx-protocols/lib/util');
|
|
23
|
+
|
|
23
24
|
const {
|
|
24
25
|
createIndexedAccount,
|
|
25
26
|
createIndexedAsset,
|
|
@@ -44,6 +45,7 @@ const {
|
|
|
44
45
|
|
|
45
46
|
const debug = require('debug')(require('../package.json').name);
|
|
46
47
|
const hooks = require('./hooks');
|
|
48
|
+
const tokenFlow = require('./token-flow');
|
|
47
49
|
const { getInstance: getTokenCacheInstance } = require('./token-cache');
|
|
48
50
|
|
|
49
51
|
const noop = (x) => x;
|
|
@@ -677,6 +679,14 @@ module.exports = class OCAPResolver {
|
|
|
677
679
|
return this._doPaginatedSearch('listDelegations', args, 'delegations', 'data', ctx);
|
|
678
680
|
}
|
|
679
681
|
|
|
682
|
+
async verifyAccountRisk(args, ctx) {
|
|
683
|
+
return tokenFlow.verifyAccountRisk(args, this, ctx);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async listTokenFlows(args, ctx) {
|
|
687
|
+
return tokenFlow.listTokenFlows(args, this, ctx);
|
|
688
|
+
}
|
|
689
|
+
|
|
680
690
|
async search(args) {
|
|
681
691
|
if (!args.keyword) {
|
|
682
692
|
return { results: [] };
|
|
@@ -966,6 +976,27 @@ module.exports = class OCAPResolver {
|
|
|
966
976
|
return attachPaidTxGas(tx);
|
|
967
977
|
}
|
|
968
978
|
|
|
979
|
+
async _getAllResults(dataKey, fn) {
|
|
980
|
+
const results = [];
|
|
981
|
+
const pageSize = 100;
|
|
982
|
+
|
|
983
|
+
const { paging, [dataKey]: firstPage } = await fn({ size: pageSize });
|
|
984
|
+
if (paging.total < pageSize) {
|
|
985
|
+
return firstPage;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
results.push(...firstPage);
|
|
989
|
+
|
|
990
|
+
const total = Math.floor(paging.total / pageSize);
|
|
991
|
+
for (let i = 1; i <= total; i++) {
|
|
992
|
+
// eslint-disable-next-line no-await-in-loop
|
|
993
|
+
const { [dataKey]: nextPage } = await fn({ size: pageSize, cursor: i * pageSize });
|
|
994
|
+
results.push(...nextPage);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return results;
|
|
998
|
+
}
|
|
999
|
+
|
|
969
1000
|
async formatTokenArray(tokens) {
|
|
970
1001
|
const uniqTokens = uniqBy(tokens, 'address');
|
|
971
1002
|
const tokenStates = await Promise.all(uniqTokens.map((token) => this.tokenCache.get(token.address)));
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/* eslint-disable no-await-in-loop */
|
|
2
|
+
|
|
3
|
+
const { BN, fromTokenToUnit } = require('@ocap/util');
|
|
4
|
+
const { schemas, Joi } = require('@arcblock/validator');
|
|
5
|
+
const { CustomError } = require('@ocap/util/lib/error');
|
|
6
|
+
const uniq = require('lodash/uniq');
|
|
7
|
+
const debug = require('debug')(require('../package.json').name);
|
|
8
|
+
|
|
9
|
+
const ZERO = new BN(0);
|
|
10
|
+
const paramsSchema = Joi.object({
|
|
11
|
+
accountAddress: schemas.tokenHolder.required(),
|
|
12
|
+
tokenAddress: Joi.DID().prefix().role('ROLE_TOKEN').required(),
|
|
13
|
+
resolver: Joi.object().required(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse transfer in/out list from transaction
|
|
18
|
+
* @param {*} tx
|
|
19
|
+
* @param {String} tokenAddress
|
|
20
|
+
* @returns {{
|
|
21
|
+
* transferInList: { address: String, value: BN, action: String }[],
|
|
22
|
+
* transferOutList: { address: String, value: BN, action: String }[]
|
|
23
|
+
* }}
|
|
24
|
+
*/
|
|
25
|
+
const getTransferList = (tx, tokenAddress) => {
|
|
26
|
+
const transferInList = [];
|
|
27
|
+
const transferOutList = [];
|
|
28
|
+
|
|
29
|
+
// Parse receipt to get transfer in/out accounts
|
|
30
|
+
for (const receipt of tx.receipts || []) {
|
|
31
|
+
const changes = receipt.changes.filter((item) => item.target === tokenAddress && item.value !== '0');
|
|
32
|
+
|
|
33
|
+
for (const change of changes) {
|
|
34
|
+
const value = new BN(change.value);
|
|
35
|
+
const item = {
|
|
36
|
+
address: receipt.address,
|
|
37
|
+
value: value.abs(),
|
|
38
|
+
action: change.action,
|
|
39
|
+
};
|
|
40
|
+
if (value.lt(ZERO)) {
|
|
41
|
+
transferOutList.push(item);
|
|
42
|
+
} else {
|
|
43
|
+
transferInList.push(item);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { transferInList, transferOutList };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse transfer flow from transaction
|
|
53
|
+
* @param {*} tx
|
|
54
|
+
* @param {String} tokenAddress
|
|
55
|
+
* @returns {{ from: String, to: String, value: BN, hash: String }[]}
|
|
56
|
+
*/
|
|
57
|
+
const getTransferFlow = (tx, tokenAddress) => {
|
|
58
|
+
const { transferInList, transferOutList } = getTransferList(tx, tokenAddress);
|
|
59
|
+
const txTransfers = [];
|
|
60
|
+
|
|
61
|
+
// Match transfers between accounts
|
|
62
|
+
for (const outItem of transferOutList) {
|
|
63
|
+
if (outItem.archived) continue;
|
|
64
|
+
|
|
65
|
+
// Try to match accounts with exactly equal transfer in/out amounts
|
|
66
|
+
const matchedInItem = transferInList.find((x) => x.value.eq(outItem.value));
|
|
67
|
+
if (matchedInItem) {
|
|
68
|
+
txTransfers.push({
|
|
69
|
+
from: outItem.address,
|
|
70
|
+
to: matchedInItem.address,
|
|
71
|
+
value: outItem.value,
|
|
72
|
+
hash: tx.hash,
|
|
73
|
+
});
|
|
74
|
+
matchedInItem.archived = true;
|
|
75
|
+
outItem.archived = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const inItem of transferInList) {
|
|
80
|
+
if (inItem.archived) continue;
|
|
81
|
+
if (outItem.archived) continue;
|
|
82
|
+
|
|
83
|
+
if (outItem.value.gt(inItem.value)) {
|
|
84
|
+
txTransfers.push({
|
|
85
|
+
from: outItem.address,
|
|
86
|
+
to: inItem.address,
|
|
87
|
+
value: inItem.value,
|
|
88
|
+
hash: tx.hash,
|
|
89
|
+
});
|
|
90
|
+
inItem.archived = true;
|
|
91
|
+
outItem.value = outItem.value.sub(inItem.value);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (outItem.value.lt(inItem.value)) {
|
|
96
|
+
txTransfers.push({
|
|
97
|
+
from: outItem.address,
|
|
98
|
+
to: inItem.address,
|
|
99
|
+
value: outItem.value,
|
|
100
|
+
hash: tx.hash,
|
|
101
|
+
});
|
|
102
|
+
outItem.archived = true;
|
|
103
|
+
inItem.value = inItem.value.sub(outItem.value);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (outItem.value.eq(inItem.value)) {
|
|
108
|
+
txTransfers.push({
|
|
109
|
+
from: outItem.address,
|
|
110
|
+
to: inItem.address,
|
|
111
|
+
value: inItem.value,
|
|
112
|
+
hash: tx.hash,
|
|
113
|
+
});
|
|
114
|
+
inItem.archived = true;
|
|
115
|
+
outItem.archived = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return txTransfers;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const verifyAccountRisk = async ({ accountAddress, tokenAddress }, resolver, ctx = {}) => {
|
|
125
|
+
// validate request params
|
|
126
|
+
const { error } = paramsSchema.validate({ accountAddress, tokenAddress, resolver });
|
|
127
|
+
if (error) {
|
|
128
|
+
throw new CustomError('INVALID_PARAMS', error.message);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const checkedAccounts = new Map();
|
|
132
|
+
const checkedTx = new Map();
|
|
133
|
+
const accountQueue = [accountAddress];
|
|
134
|
+
const maxAccountSize = 400;
|
|
135
|
+
|
|
136
|
+
while (accountQueue.length) {
|
|
137
|
+
// limit
|
|
138
|
+
if (checkedAccounts.size >= maxAccountSize) {
|
|
139
|
+
return {
|
|
140
|
+
isRisky: false,
|
|
141
|
+
reason: 'MAX_ACCOUNT_SIZE_LIMIT',
|
|
142
|
+
data: {
|
|
143
|
+
accountCount: checkedAccounts.size,
|
|
144
|
+
txCount: checkedTx.size,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const address = accountQueue.pop();
|
|
150
|
+
// Avoid circular query
|
|
151
|
+
if (checkedAccounts.has(address)) continue;
|
|
152
|
+
|
|
153
|
+
const transactions = await resolver._getAllResults('transactions', (paging) =>
|
|
154
|
+
resolver.listTransactions({ paging, accountFilter: { accounts: [address] } }, ctx)
|
|
155
|
+
);
|
|
156
|
+
const accountState = await resolver.getAccountState({ address }, ctx);
|
|
157
|
+
if (!accountState) {
|
|
158
|
+
throw new CustomError('INVALID_REQUEST', `Invalid address ${address}`);
|
|
159
|
+
}
|
|
160
|
+
const balance = accountState.tokens.find((item) => item.address === tokenAddress)?.value || 0;
|
|
161
|
+
|
|
162
|
+
let transferIn = ZERO;
|
|
163
|
+
let transferOut = ZERO;
|
|
164
|
+
|
|
165
|
+
// Parse txs to get transfer amounts
|
|
166
|
+
for (const tx of transactions) {
|
|
167
|
+
// cache tx
|
|
168
|
+
if (!checkedTx.has(tx.hash)) {
|
|
169
|
+
checkedTx.set(tx.hash, await getTransferList(tx, tokenAddress));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const { transferInList, transferOutList } = checkedTx.get(tx.hash);
|
|
173
|
+
|
|
174
|
+
// Calculate the total amount of transfer for this address
|
|
175
|
+
transferIn = transferIn.add(
|
|
176
|
+
transferInList
|
|
177
|
+
.filter((item) => item.address === address)
|
|
178
|
+
.map((item) => item.value)
|
|
179
|
+
.reduce((prev, cur) => prev.add(cur), ZERO)
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
transferOut = transferOut.add(
|
|
183
|
+
transferOutList
|
|
184
|
+
.filter((item) => item.address === address)
|
|
185
|
+
.map((item) => item.value)
|
|
186
|
+
.reduce((prev, cur) => prev.add(cur), ZERO)
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// push transferIn accounts to queue for next time check
|
|
190
|
+
if (transferInList.some((item) => item.address === address)) {
|
|
191
|
+
const accountsToQueue = transferOutList
|
|
192
|
+
.filter((item) => {
|
|
193
|
+
if (accountQueue.includes(item.address)) return false;
|
|
194
|
+
// skip gas、fee
|
|
195
|
+
if (['gas', 'fee'].includes(item.action)) return false;
|
|
196
|
+
// Skip not token holders
|
|
197
|
+
if (schemas.tokenHolder.validate(item.address).error) return false;
|
|
198
|
+
|
|
199
|
+
return true;
|
|
200
|
+
})
|
|
201
|
+
.map((item) => item.address);
|
|
202
|
+
|
|
203
|
+
accountQueue.push(...uniq(accountsToQueue));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
checkedAccounts.set(address, true);
|
|
208
|
+
|
|
209
|
+
// Check if the balance not matches the transfer records
|
|
210
|
+
if (!transferIn.eq(transferOut.add(new BN(balance)))) {
|
|
211
|
+
debug('Account balance does not match transfer records', {
|
|
212
|
+
address,
|
|
213
|
+
transferIn: transferIn.toString(),
|
|
214
|
+
transferOut: transferOut.toString(),
|
|
215
|
+
balance,
|
|
216
|
+
sourceAccount: accountAddress,
|
|
217
|
+
});
|
|
218
|
+
return {
|
|
219
|
+
isRisky: true,
|
|
220
|
+
reason: 'INVALID_BALANCE',
|
|
221
|
+
data: {
|
|
222
|
+
address,
|
|
223
|
+
balance,
|
|
224
|
+
transferIn: transferIn.toString(),
|
|
225
|
+
transferOut: transferOut.toString(),
|
|
226
|
+
accountCount: checkedAccounts.size,
|
|
227
|
+
txCount: checkedTx.size,
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
isRisky: false,
|
|
235
|
+
data: {
|
|
236
|
+
accountCount: checkedAccounts.size,
|
|
237
|
+
txCount: checkedTx.size,
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const listTokenFlows = async (
|
|
243
|
+
{ accountAddress, tokenAddress, paging = {}, depth = 2, direction = 'OUT' },
|
|
244
|
+
resolver,
|
|
245
|
+
ctx = {}
|
|
246
|
+
) => {
|
|
247
|
+
// validate request params
|
|
248
|
+
const { error } = paramsSchema.validate({ accountAddress, tokenAddress, resolver });
|
|
249
|
+
if (error) {
|
|
250
|
+
throw new CustomError('INVALID_PARAMS', error.message);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const tokenState = await resolver.tokenCache.get(tokenAddress);
|
|
254
|
+
const minAmount = fromTokenToUnit(1, tokenState?.decimal || 18);
|
|
255
|
+
const maxAccountSize = Math.min(400, paging.size || 400);
|
|
256
|
+
const maxDepth = Math.min(6, depth);
|
|
257
|
+
|
|
258
|
+
const tokenFlows = [];
|
|
259
|
+
const checkedAccounts = new Map();
|
|
260
|
+
const checkedTx = new Map();
|
|
261
|
+
|
|
262
|
+
let curDepth = 1;
|
|
263
|
+
|
|
264
|
+
const depthQueue = {
|
|
265
|
+
[curDepth]: [accountAddress],
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
while (depthQueue[curDepth]?.length && curDepth <= maxDepth && checkedAccounts.size < maxAccountSize) {
|
|
269
|
+
for (const address of depthQueue[curDepth]) {
|
|
270
|
+
// Avoid circular query
|
|
271
|
+
if (checkedAccounts.has(address)) continue;
|
|
272
|
+
// Skip not token holders
|
|
273
|
+
if (schemas.tokenHolder.validate(address).error) continue;
|
|
274
|
+
|
|
275
|
+
const transactions = await resolver._getAllResults('transactions', (page) =>
|
|
276
|
+
resolver.listTransactions(
|
|
277
|
+
{ paging: page, accountFilter: { accounts: [address] }, tokenFilter: { tokens: [tokenAddress] } },
|
|
278
|
+
ctx
|
|
279
|
+
)
|
|
280
|
+
);
|
|
281
|
+
let accountsToQueue = [];
|
|
282
|
+
|
|
283
|
+
for (const tx of transactions) {
|
|
284
|
+
// cache tx
|
|
285
|
+
if (!checkedTx.has(tx.hash)) {
|
|
286
|
+
checkedTx.set(tx.hash, await getTransferFlow(tx, tokenAddress));
|
|
287
|
+
}
|
|
288
|
+
const txTransfers = checkedTx.get(tx.hash).filter((item) => {
|
|
289
|
+
if (direction === 'OUT' && item.from !== address) return false;
|
|
290
|
+
if (direction === 'IN' && item.to !== address) return false;
|
|
291
|
+
return true;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// push to result
|
|
295
|
+
tokenFlows.push(...txTransfers.map((item) => ({ ...item, value: item.value.toString() })));
|
|
296
|
+
|
|
297
|
+
// push to end of queue
|
|
298
|
+
accountsToQueue = accountsToQueue.concat(
|
|
299
|
+
txTransfers
|
|
300
|
+
.filter((item) => item.value.gte(minAmount))
|
|
301
|
+
.map((item) => (direction === 'IN' ? item.from : item.to))
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// cache account
|
|
306
|
+
checkedAccounts.set(address, true);
|
|
307
|
+
// limit
|
|
308
|
+
if (checkedAccounts.size >= maxAccountSize) {
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// push to queue for next depth
|
|
313
|
+
if (!depthQueue[curDepth + 1]) {
|
|
314
|
+
depthQueue[curDepth + 1] = [];
|
|
315
|
+
}
|
|
316
|
+
depthQueue[curDepth + 1].push(...uniq(accountsToQueue));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
curDepth++;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return tokenFlows;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
module.exports = {
|
|
326
|
+
getTransferList,
|
|
327
|
+
getTransferFlow,
|
|
328
|
+
verifyAccountRisk,
|
|
329
|
+
listTokenFlows,
|
|
330
|
+
};
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "1.18.
|
|
6
|
+
"version": "1.18.144",
|
|
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.18.
|
|
26
|
-
"@arcblock/did-util": "1.18.
|
|
27
|
-
"@arcblock/validator": "1.18.
|
|
28
|
-
"@ocap/config": "1.18.
|
|
29
|
-
"@ocap/indexdb": "1.18.
|
|
30
|
-
"@ocap/mcrypto": "1.18.
|
|
31
|
-
"@ocap/message": "1.18.
|
|
32
|
-
"@ocap/state": "1.18.
|
|
33
|
-
"@ocap/tx-protocols": "1.18.
|
|
34
|
-
"@ocap/util": "1.18.
|
|
25
|
+
"@arcblock/did": "1.18.144",
|
|
26
|
+
"@arcblock/did-util": "1.18.144",
|
|
27
|
+
"@arcblock/validator": "1.18.144",
|
|
28
|
+
"@ocap/config": "1.18.144",
|
|
29
|
+
"@ocap/indexdb": "1.18.144",
|
|
30
|
+
"@ocap/mcrypto": "1.18.144",
|
|
31
|
+
"@ocap/message": "1.18.144",
|
|
32
|
+
"@ocap/state": "1.18.144",
|
|
33
|
+
"@ocap/tx-protocols": "1.18.144",
|
|
34
|
+
"@ocap/util": "1.18.144",
|
|
35
35
|
"debug": "^4.3.6",
|
|
36
36
|
"lodash": "^4.17.21"
|
|
37
37
|
},
|
|
38
|
-
"gitHead": "
|
|
38
|
+
"gitHead": "541c65e8f06b20c6182d335a7f2ed6e09c1e289d"
|
|
39
39
|
}
|