@blocklet/meta 1.8.11 → 1.8.14

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.
@@ -1,4 +1,5 @@
1
1
  const get = require('lodash/get');
2
+ const cloneDeep = require('lodash/cloneDeep');
2
3
  const isEqual = require('lodash/isEqual');
3
4
  const normalizePathPrefix = require('@abtnode/util/lib/normalize-path-prefix');
4
5
 
@@ -16,6 +17,29 @@ const parseLink = (input, prefix) => {
16
17
  return parseLinkString(input, prefix);
17
18
  };
18
19
 
20
+ const getGroups = (navigation) => {
21
+ const groups = {};
22
+ for (const nav of navigation) {
23
+ const sections = !nav.section || !nav.section.length ? ['__undefined__'] : nav.section;
24
+ for (const sec of sections) {
25
+ if (!groups[sec]) {
26
+ groups[sec] = [];
27
+ }
28
+
29
+ const item = {
30
+ ...nav,
31
+ };
32
+
33
+ if (nav.section) {
34
+ item.section = sec === '__undefined__' ? [] : [sec];
35
+ }
36
+
37
+ groups[sec].push(item);
38
+ }
39
+ }
40
+ return Object.values(groups);
41
+ };
42
+
19
43
  /**
20
44
  * @param {*} navigation src
21
45
  * @param {*} blocklet
@@ -37,7 +61,7 @@ const doParseNavigation = (navigation, blocklet, prefix = '/', _level = 1) => {
37
61
  title: nav.title,
38
62
  };
39
63
 
40
- if (_level === 1 && nav.section) {
64
+ if (nav.section) {
41
65
  item.section = nav.section;
42
66
  }
43
67
 
@@ -77,50 +101,83 @@ const doParseNavigation = (navigation, blocklet, prefix = '/', _level = 1) => {
77
101
  }
78
102
  const childTitle = child.meta.title || child.meta.name;
79
103
 
80
- const item = {
104
+ const itemProto = {
81
105
  title: nav.title || childTitle,
82
106
  };
83
107
 
84
- if (_level === 1 && nav.section) {
85
- item.section = nav.section;
108
+ if (nav.section) {
109
+ itemProto.section = nav.section;
86
110
  }
87
111
 
88
112
  if (nav.icon) {
89
- item.icon = nav.icon;
113
+ itemProto.icon = nav.icon;
90
114
  }
91
115
 
92
116
  if (nav.role) {
93
- item.role = nav.role;
117
+ itemProto.role = nav.role;
94
118
  }
95
119
 
96
- const childNavigation = get(child, 'meta.navigation', []);
97
- if (!childNavigation.length) {
120
+ // get groups by section
121
+ const groups = getGroups(get(child, 'meta.navigation', []));
122
+
123
+ if (!groups.length) {
98
124
  // child does not declares menu
125
+ const item = cloneDeep(itemProto);
99
126
  item.link = parseLink(child.mountPoint || '/', prefix);
100
127
  result.push(item);
101
- } else if (childNavigation.length === 1) {
102
- // child declares one menu
103
- item.title = nav.title || childNavigation[0].title || childTitle;
104
- item.link = parseLink(childNavigation[0].link || '/', normalizePathPrefix(`${prefix}${child.mountPoint}`));
105
- result.push(item);
106
128
  } else {
107
- // child declares multiple menus
108
- const list = doParseNavigation(
109
- childNavigation,
110
- child,
111
- normalizePathPrefix(`${prefix}${child.mountPoint}`),
112
- _level + 1
113
- );
114
-
115
- if (_level === 1) { // eslint-disable-line
116
- // primary menu
117
- result.push({
118
- ...item,
119
- items: list,
120
- });
121
- } else {
122
- // secondary menu
123
- result.push(...list);
129
+ for (const childNavigation of groups) {
130
+ if (childNavigation.length === 1) {
131
+ // child declares one menu
132
+ const childNav = childNavigation[0];
133
+
134
+ const item = cloneDeep(itemProto);
135
+
136
+ item.title = nav.title || childNav.title || childTitle;
137
+
138
+ if (childNav.icon) {
139
+ item.icon = item.icon || childNav.icon;
140
+ }
141
+
142
+ if (childNav.role) {
143
+ item.role = item.icon || childNav.role;
144
+ }
145
+
146
+ if (childNav.section) {
147
+ item.section = item.section || childNav.section;
148
+ }
149
+
150
+ item.link = parseLink(childNavigation[0].link || '/', normalizePathPrefix(`${prefix}${child.mountPoint}`));
151
+ result.push(item);
152
+ } else {
153
+ // child declares multiple menus
154
+ const groupSection = childNavigation[0].section || [];
155
+
156
+ const list = doParseNavigation(
157
+ childNavigation,
158
+ child,
159
+ normalizePathPrefix(`${prefix}${child.mountPoint}`),
160
+ _level + 1
161
+ );
162
+
163
+ if (_level === 1) { // eslint-disable-line
164
+ // primary menu
165
+ const item = cloneDeep(itemProto);
166
+
167
+ if (groupSection.length) {
168
+ item.section = item.section || groupSection;
169
+ }
170
+
171
+ item.items = list;
172
+ result.push({
173
+ ...item,
174
+ items: list,
175
+ });
176
+ } else {
177
+ // secondary menu
178
+ result.push(...list);
179
+ }
180
+ }
124
181
  }
125
182
  }
126
183
  });
@@ -0,0 +1,7 @@
1
+ const v1 = require('./v1');
2
+ const v2 = require('./v2');
3
+
4
+ module.exports = {
5
+ createNftFactoryItx: v1.createNftFactoryItx, // for backward compatibility
6
+ v2,
7
+ };
@@ -3,7 +3,7 @@ const joinURL = require('url-join');
3
3
  const { BN } = require('@ocap/util');
4
4
  const { isValidFactory } = require('@ocap/asset');
5
5
  const { toFactoryAddress } = require('@arcblock/did-util');
6
- const { getBlockletPurchaseTemplate } = require('./nft-templates');
6
+ const { getBlockletPurchaseTemplate } = require('../nft-templates');
7
7
 
8
8
  const createShareContract = ({ tokens = [], shares = [] }) => {
9
9
  const zeroBN = new BN(0);
@@ -72,9 +72,10 @@ const createNftFactoryItx = ({ meta, tokens, shares, issuers, serviceUrl }) => {
72
72
  ],
73
73
  };
74
74
 
75
+ itx.address = toFactoryAddress(itx);
76
+
75
77
  isValidFactory(itx, true);
76
78
 
77
- itx.address = toFactoryAddress(itx);
78
79
  return itx;
79
80
  };
80
81
 
@@ -0,0 +1,584 @@
1
+ /* eslint-disable no-await-in-loop */
2
+ const crypto = require('crypto');
3
+ const debug = require('debug')('@blocklet/meta:payment');
4
+ const joinURL = require('url-join');
5
+ const axios = require('axios');
6
+ const stableStringify = require('json-stable-stringify');
7
+ const get = require('lodash/get');
8
+ const pick = require('lodash/pick');
9
+ const cloneDeep = require('lodash/cloneDeep');
10
+ const { BN, fromTokenToUnit, fromUnitToToken } = require('@ocap/util');
11
+ const { isValidFactory } = require('@ocap/asset');
12
+ const { fromPublicKey } = require('@ocap/wallet');
13
+ const { toFactoryAddress } = require('@arcblock/did-util');
14
+ const { toTypeInfo } = require('@arcblock/did');
15
+ const { BLOCKLET_STORE_META_PATH } = require('@abtnode/constant');
16
+ const { getBlockletPurchaseTemplate } = require('../nft-templates');
17
+ const { validateMeta } = require('../validate');
18
+ const { isComponentBlocklet, isFreeComponent, isFreeBlocklet } = require('../util');
19
+ const { getBlockletMetaFromUrls, getSourceUrlsFromConfig } = require('../util-meta');
20
+
21
+ const VERSION = '2.0.0';
22
+
23
+ const ZeroBN = new BN(0);
24
+ const defaultDecimals = 1e6; // we only support 6 decimals on share ratio
25
+ const defaultDecimalsBN = new BN(defaultDecimals);
26
+
27
+ const safeMul = (a, b) =>
28
+ Number(
29
+ fromUnitToToken(
30
+ fromTokenToUnit(a)
31
+ .mul(new BN(b * defaultDecimals))
32
+ .div(defaultDecimalsBN)
33
+ )
34
+ );
35
+
36
+ const md5 = (str) => crypto.createHash('md5').update(str).digest('hex');
37
+
38
+ const getStoreInfo = async (url) => {
39
+ const storeMetaUrl = joinURL(new URL(url).origin, BLOCKLET_STORE_META_PATH);
40
+ const { data: info } = await axios.get(storeMetaUrl, { timeout: 8000 });
41
+ return info;
42
+ };
43
+
44
+ /**
45
+ * @typedef {{
46
+ * meta: Object
47
+ * storeInfo: Object
48
+ * storeUrl: string
49
+ * children: Array<Component>
50
+ * }} Component
51
+ *
52
+ * @param {BlockletMeta} inputMeta
53
+ * @param {{
54
+ * ancestors: Array<{BlockletMeta}>
55
+ * bundles: {
56
+ * <bundleName>: <storeId>
57
+ * }
58
+ * }} context
59
+ *
60
+ * @returns {Array<Component>}
61
+ */
62
+ const _getComponents = async (inputMeta, context = {}) => {
63
+ // FIXME 是否需要验证: 在同一个链上; 重复的 component
64
+ const { ancestors = [], bundles = {} } = context;
65
+
66
+ // check ancestor length
67
+ if (ancestors.length > 40) {
68
+ throw new Error('The depth of component should not exceed 40');
69
+ }
70
+
71
+ const configs = inputMeta.children || [];
72
+
73
+ if (!configs || !configs.length) {
74
+ return [];
75
+ }
76
+
77
+ const children = [];
78
+
79
+ for (const config of configs) {
80
+ // get component meta
81
+ const urls = getSourceUrlsFromConfig(config);
82
+ let meta;
83
+ let url;
84
+ try {
85
+ const res = await getBlockletMetaFromUrls(urls, {
86
+ returnUrl: true,
87
+ validateFn: (m) => validateMeta(m),
88
+ ensureTarball: false,
89
+ });
90
+ meta = res.meta;
91
+ url = res.url;
92
+ } catch (err) {
93
+ throw new Error(`Failed get component meta: ${config.title || config.name}: ${err.message}`);
94
+ }
95
+
96
+ // check is component
97
+ if (!isComponentBlocklet(meta)) {
98
+ throw new Error(`The blocklet cannot be a component: ${meta.title}`);
99
+ }
100
+
101
+ // check circular dependencies
102
+ if (ancestors.map((x) => x.meta?.did).indexOf(meta.did) > -1) {
103
+ throw new Error('Blocklet components have circular dependencies');
104
+ }
105
+
106
+ // generate child
107
+ const child = {
108
+ meta,
109
+ };
110
+
111
+ // child store info
112
+ if (config.source.store) {
113
+ const storeInfo = await getStoreInfo(url);
114
+
115
+ // check uniq bundle did in different stores
116
+ if (!bundles[child.meta.did]) {
117
+ bundles[child.meta.did] = storeInfo.id;
118
+ } else if (bundles[child.meta.did] !== storeInfo.id) {
119
+ throw new Error('Bundles with the same did cannot in different stores');
120
+ }
121
+
122
+ child.storeInfo = storeInfo;
123
+ child.storeUrl = new URL(url).origin;
124
+ }
125
+
126
+ // child children
127
+ child.children = await _getComponents(meta, {
128
+ ancestors: [...ancestors, { meta }],
129
+ bundles,
130
+ });
131
+
132
+ children.push(child);
133
+ }
134
+
135
+ return children;
136
+ };
137
+
138
+ /**
139
+ * @typedef {{
140
+ * id: string
141
+ * pk: string
142
+ * url: string
143
+ * componentDids: Array<string>
144
+ * }} Store
145
+ * @param {Array<Component>} components
146
+ * @param {Array<Store>} _stores
147
+ * @returns {Array<Store>}
148
+ */
149
+ const _getStores = (components, _stores = []) => {
150
+ for (const { meta, storeInfo, storeUrl, children } of components) {
151
+ if (storeInfo && (!isFreeBlocklet(meta) || !isFreeComponent(meta))) {
152
+ const store = _stores.find((x) => x.id === storeInfo.id);
153
+ if (!store) {
154
+ _stores.push({ id: storeInfo.id, pk: storeInfo.pk, url: storeUrl, componentDids: new Set([meta.did]) });
155
+ } else {
156
+ store.componentDids.add(meta.did);
157
+ }
158
+ }
159
+
160
+ if (children && children.length > 0) {
161
+ _getStores(children, _stores);
162
+ }
163
+ }
164
+
165
+ return _stores.map((x) => ({ ...x, componentDids: [...x.componentDids] }));
166
+ };
167
+
168
+ const getComponents = async (inputMeta) => {
169
+ const components = await _getComponents(inputMeta);
170
+ const stores = await _getStores(components);
171
+ return { components, stores };
172
+ };
173
+
174
+ const getPriceTokens = async (meta, ocapClient) => {
175
+ const priceTokens = cloneDeep(get(meta, 'payment.price', []));
176
+ for (const token of priceTokens) {
177
+ // eslint-disable-next-line no-await-in-loop
178
+ const { state } = await ocapClient.getTokenState({ address: token.address });
179
+ if (!state) {
180
+ throw new Error(`Token specified in blocklet meta was not found on chain: ${token.address}`);
181
+ }
182
+
183
+ token.decimal = state.decimal;
184
+ }
185
+ return priceTokens;
186
+ };
187
+
188
+ const getChildShare = (childMeta, parentPrice) => {
189
+ if (!childMeta?.payment?.componentPrice) {
190
+ return 0;
191
+ }
192
+
193
+ const priceList = childMeta.payment.componentPrice;
194
+
195
+ let price = 0;
196
+
197
+ for (const { type, value, parentPriceRange } of priceList) {
198
+ const isDefault = !parentPriceRange || !parentPriceRange.length;
199
+ const skip = isDefault && price !== 0;
200
+ const inRange =
201
+ isDefault || (parentPriceRange && parentPrice >= parentPriceRange[0] && parentPrice <= parentPriceRange[1]);
202
+
203
+ if (!skip && inRange) {
204
+ if (type === 'fixed') {
205
+ price = value;
206
+ } else if (type === 'percentage') {
207
+ price = safeMul(parentPrice, value);
208
+ }
209
+ }
210
+ }
211
+
212
+ return price;
213
+ };
214
+
215
+ /**
216
+ * @returns {Array<{
217
+ * tokenAddress: string
218
+ * accountAddress: string
219
+ * amount: BN
220
+ * }>}
221
+ */
222
+ const getTokenTransfers = ({ priceToken, shares = [], components = [] }) => {
223
+ // check share
224
+ const shareSum = shares.reduce((sum, x) => sum + x.value, 0);
225
+ if (shareSum > 1) {
226
+ throw new Error('payment.share invalid: share sum should not be greater than 1');
227
+ }
228
+
229
+ const { value: price } = priceToken;
230
+
231
+ let parentShareBN = fromTokenToUnit(price, priceToken.decimal);
232
+
233
+ const contracts = [];
234
+
235
+ for (const child of components) {
236
+ if (!isFreeComponent(child.meta)) {
237
+ // // check same token
238
+ const [token] = child.meta.payment.price || [];
239
+ if (token && token.address !== priceToken.address) {
240
+ throw new Error(
241
+ `component price token is not same with app price token: ${child.meta.title || child.meta.name}`
242
+ );
243
+ }
244
+
245
+ const childShare = getChildShare(child.meta, price);
246
+
247
+ parentShareBN = parentShareBN.sub(fromTokenToUnit(childShare, priceToken.decimal));
248
+
249
+ if (parentShareBN.lt(ZeroBN)) {
250
+ throw new Error('Price is not enough for component sharing');
251
+ }
252
+
253
+ const componentContracts = getTokenTransfers({
254
+ priceToken: { ...priceToken, value: childShare },
255
+ shares: child.meta.payment.share,
256
+ components: child.children || [],
257
+ });
258
+
259
+ contracts.push(...componentContracts);
260
+ }
261
+ }
262
+
263
+ shares.forEach(({ address: accountAddress, value: ratio }) => {
264
+ contracts.push({
265
+ tokenAddress: priceToken.address,
266
+ accountAddress,
267
+ amount: parentShareBN.mul(new BN(ratio * defaultDecimals)).div(defaultDecimalsBN),
268
+ });
269
+ });
270
+
271
+ const mergedContracts = [];
272
+
273
+ contracts.forEach((x) => {
274
+ const index = mergedContracts.findIndex(
275
+ (y) => y.tokenAddress === x.tokenAddress && y.accountAddress === x.accountAddress
276
+ );
277
+ if (index > -1) {
278
+ mergedContracts[index].amount = mergedContracts[index].amount.add(x.amount);
279
+ } else {
280
+ mergedContracts.push(x);
281
+ }
282
+ });
283
+
284
+ return mergedContracts;
285
+ };
286
+
287
+ const getContract = async ({ meta, priceTokens, components }) => {
288
+ const shares = meta.payment.share || [];
289
+
290
+ const [priceToken] = priceTokens;
291
+
292
+ const contracts = getTokenTransfers({ priceToken, shares, components });
293
+
294
+ return contracts
295
+ .map((x) => `transferToken('${x.tokenAddress}','${x.accountAddress}','${x.amount.toString()}')`)
296
+ .join(';\n');
297
+ };
298
+
299
+ /**
300
+ * we need to ensure that blocklet purchase factory does not change across changes
301
+ *
302
+ * @typedef {{
303
+ * data: {
304
+ * type: 'json'
305
+ * value: {
306
+ * did: string
307
+ * url: string
308
+ * name: string
309
+ * paymentVersion: string
310
+ * stores: Array<{
311
+ * signer: string
312
+ * pk: string
313
+ * signature: string
314
+ * componentDids: Array<string>
315
+ * paymentIntegrity: string
316
+ * }>
317
+ * }
318
+ * }
319
+ * }} Itx
320
+ * @returns {Itx}
321
+ */
322
+ const _createNftFactoryItx = ({ meta, issuers, serviceUrl, storeSignatures, factoryInput, contract }) => {
323
+ const factoryOutput = getBlockletPurchaseTemplate(serviceUrl);
324
+ const itx = {
325
+ name: meta.title || meta.name,
326
+ description: `Purchase NFT factory for blocklet ${meta.name}`,
327
+ settlement: 'instant',
328
+ limit: 0,
329
+ trustedIssuers: issuers,
330
+ input: factoryInput,
331
+ output: {
332
+ issuer: '{{ctx.issuer.id}}',
333
+ parent: '{{ctx.factory}}',
334
+ moniker: 'BlockletPurchaseNFT',
335
+ readonly: true,
336
+ transferrable: false,
337
+ data: factoryOutput,
338
+ },
339
+ data: {
340
+ type: 'json',
341
+ value: {
342
+ did: meta.did,
343
+ url: joinURL(serviceUrl, `/blocklet/${meta.did}`),
344
+ name: meta.name,
345
+ paymentVersion: VERSION,
346
+ stores: storeSignatures.map((x) => pick(x, ['signer', 'pk', 'signature', 'componentDids', 'paymentIntegrity'])),
347
+ },
348
+ },
349
+ hooks: [
350
+ {
351
+ name: 'mint',
352
+ type: 'contract',
353
+ hook: contract,
354
+ },
355
+ ],
356
+ };
357
+
358
+ itx.address = toFactoryAddress(itx);
359
+
360
+ isValidFactory(itx, true);
361
+
362
+ return itx;
363
+ };
364
+
365
+ const getFactoryInput = (inputTokens, { formatToken = true } = {}) => {
366
+ const tokens = cloneDeep(inputTokens);
367
+ tokens.forEach((token) => {
368
+ if (formatToken) {
369
+ token.value = fromTokenToUnit(token.value, token.decimal).toString();
370
+ }
371
+ delete token.decimal;
372
+ });
373
+ return {
374
+ tokens,
375
+ assets: [],
376
+ variables: [],
377
+ };
378
+ };
379
+
380
+ const getPaymentIntegrity = async ({ contract, factoryInput, componentDids, meta, client, storeId }) => {
381
+ if (!contract && !factoryInput && !componentDids) {
382
+ const priceTokens = await getPriceTokens(meta, client);
383
+ const { components, stores } = await getComponents(meta);
384
+ const store = stores.find((x) => x.id === storeId);
385
+
386
+ // eslint-disable-next-line no-param-reassign
387
+ contract = await getContract({ meta, components, priceTokens });
388
+ // eslint-disable-next-line no-param-reassign
389
+ factoryInput = await getFactoryInput(priceTokens);
390
+ // eslint-disable-next-line no-param-reassign
391
+ componentDids = store?.componentDids || [];
392
+ }
393
+
394
+ const paymentData = {
395
+ factoryInput,
396
+ contract,
397
+ componentDids: componentDids || [],
398
+ };
399
+
400
+ const integrity = md5(stableStringify(paymentData));
401
+
402
+ return integrity;
403
+ };
404
+
405
+ const getStoreSignatures = async ({ meta, client, priceTokens, components, stores }) => {
406
+ const factoryInput = getFactoryInput(priceTokens);
407
+ const contract = await getContract({ meta, client, priceTokens, components });
408
+
409
+ const storeSignatures = [];
410
+ for (const store of stores) {
411
+ const { id, url, pk, componentDids } = store;
412
+
413
+ const paymentIntegrity = await getPaymentIntegrity({ factoryInput, contract, componentDids });
414
+
415
+ /**
416
+ * protocol: /api/payment/signature
417
+ * method: POST
418
+ * body: { blockletMeta, paymentIntegrity, paymentVersion }
419
+ * return: { signer, pk, signature}
420
+ */
421
+ const { data: res } = await axios.post(
422
+ `${url}/api/payment/signature`,
423
+ {
424
+ blockletMeta: meta,
425
+ paymentIntegrity,
426
+ paymentVersion: VERSION,
427
+ },
428
+ { timeout: 20000 }
429
+ );
430
+
431
+ if (res.signer !== id) {
432
+ throw new Error('store signature: store id does not match');
433
+ }
434
+
435
+ if (res.pk !== pk) {
436
+ throw new Error('store signature: store pk does not match');
437
+ }
438
+
439
+ // verify sig
440
+ const type = toTypeInfo(id);
441
+ const wallet = fromPublicKey(pk, type);
442
+ const verifyRes = wallet.verify(paymentIntegrity, res.signature);
443
+ if (verifyRes !== true) {
444
+ throw new Error('verify store signature failed');
445
+ }
446
+
447
+ storeSignatures.push({
448
+ signer: res.signer,
449
+ pk: res.pk,
450
+ signature: res.signature,
451
+ componentDids,
452
+ paymentIntegrity,
453
+ storeUrl: url,
454
+ });
455
+ }
456
+
457
+ return {
458
+ storeSignatures,
459
+ factoryInput,
460
+ contract,
461
+ };
462
+ };
463
+
464
+ /**
465
+ * Used by CLI and Store to independent compute factory itx
466
+ * @param {{
467
+ * blockletMeta: BlockletMeta,
468
+ * ocapClient: OcapClient,
469
+ * issuers: Array<string>,
470
+ * storeUrl: string,
471
+ * }}
472
+ * @returns {{
473
+ * itx: Itx
474
+ * store: Array<{id, url}>
475
+ * }}
476
+ */
477
+ const createNftFactoryItx = async ({ blockletMeta, ocapClient, issuers, storeUrl }) => {
478
+ const priceTokens = await getPriceTokens(blockletMeta, ocapClient);
479
+ const { components, stores } = await getComponents(blockletMeta);
480
+
481
+ const { factoryInput, contract, storeSignatures } = await getStoreSignatures({
482
+ meta: blockletMeta,
483
+ client: ocapClient,
484
+ priceTokens,
485
+ components,
486
+ stores,
487
+ });
488
+
489
+ return {
490
+ itx: _createNftFactoryItx({
491
+ meta: blockletMeta,
492
+ issuers,
493
+ serviceUrl: storeUrl,
494
+ priceTokens,
495
+ components,
496
+ stores,
497
+ storeSignatures,
498
+ factoryInput,
499
+ contract,
500
+ }),
501
+ stores: storeSignatures.map((x) => ({ id: x.signer, url: x.storeUrl })),
502
+ };
503
+ };
504
+
505
+ /**
506
+ * Used by Store before generating payment signature
507
+ *
508
+ * @param {{
509
+ * integrity: string,
510
+ * blockletMeta: BlockletMeta,
511
+ * ocapClient: OcapClient,
512
+ * storeId: string
513
+ * }}
514
+ * @returns {string} integrity
515
+ */
516
+ const verifyPaymentIntegrity = async ({ integrity: expected, blockletMeta, ocapClient, storeId }) => {
517
+ const actual = await getPaymentIntegrity({ meta: blockletMeta, client: ocapClient, storeId });
518
+
519
+ if (actual !== expected) {
520
+ throw new Error('verify payment integrity failed');
521
+ }
522
+
523
+ return expected;
524
+ };
525
+
526
+ /**
527
+ * Used by Store before generating downloadToken
528
+ *
529
+ * @param {{
530
+ * {FactoryState} factoryState
531
+ * {Wallet} signerWallet
532
+ * }}
533
+ *
534
+ * @returns {{
535
+ * componentDids: Array<string>
536
+ * }}
537
+ */
538
+ const verifyNftFactory = async ({ factoryState, signerWallet }) => {
539
+ const data = JSON.parse(factoryState?.data?.value);
540
+ const stores = data?.stores || [];
541
+ const store = stores.find((x) => x.signer === signerWallet.address);
542
+
543
+ if (!store) {
544
+ throw new Error(
545
+ `Signer does not found in factory. factory: ${factoryState.address}, signer: ${signerWallet.address}`
546
+ );
547
+ }
548
+
549
+ const c = factoryState.hooks.find((x) => x.type === 'contract');
550
+ const { componentDids } = store;
551
+
552
+ // Token 的字段和 factory 中的字段不一致
553
+ const factoryInput = getFactoryInput(
554
+ factoryState.input.tokens.map((x) => pick(x, ['address', 'value'])),
555
+ { formatToken: false }
556
+ );
557
+
558
+ const integrity = await getPaymentIntegrity({
559
+ contract: c.hook,
560
+ factoryInput,
561
+ componentDids,
562
+ });
563
+
564
+ if (signerWallet.sign(integrity) !== store.signature) {
565
+ debug(store, factoryInput, integrity, componentDids, c.hook);
566
+ throw new Error(`verify nft factory failed: ${factoryState.address}`);
567
+ }
568
+
569
+ return { componentDids };
570
+ };
571
+
572
+ module.exports = {
573
+ createNftFactoryItx,
574
+ verifyPaymentIntegrity,
575
+ verifyNftFactory,
576
+ version: VERSION,
577
+ _test: {
578
+ getPriceTokens,
579
+ getFactoryInput,
580
+ getPaymentIntegrity,
581
+ getComponents,
582
+ getContract,
583
+ },
584
+ };
package/lib/schema.js CHANGED
@@ -307,15 +307,16 @@ const createBlockletSchema = (
307
307
 
308
308
  // Set the price and share of the blocklet
309
309
  payment: Joi.object({
310
- // How much price required to purchase this block
310
+ // Currently only supports 1 token
311
311
  price: Joi.array()
312
- .max(4)
312
+ .max(1)
313
313
  .items(
314
314
  Joi.object({
315
315
  value: Joi.number().greater(0).required(),
316
316
  address: Joi.DID().required(), // token address
317
317
  })
318
- ),
318
+ )
319
+ .default([]),
319
320
  // List of beneficiaries that share the token earns from blocklet purchase
320
321
  // If left empty, blocklet publish workflow will enforce both the developer and the registry account
321
322
  // If not, the blocklet publish workflow will enforce the registry account
@@ -331,6 +332,7 @@ const createBlockletSchema = (
331
332
  value: Joi.number().greater(0).max(1).required(),
332
333
  })
333
334
  )
335
+ .default([])
334
336
  .custom((value) => {
335
337
  // If share is not empty, the total value should be 1
336
338
  if (value.length === 0) {
@@ -342,6 +344,35 @@ const createBlockletSchema = (
342
344
  }
343
345
  return value;
344
346
  }, 'invalid blocklet share'),
347
+ componentPrice: Joi.array()
348
+ .items(
349
+ Joi.object({
350
+ parentPriceRange: Joi.array()
351
+ .items(Joi.number())
352
+ // FIXME
353
+ // 1. 有重叠的区间时
354
+ // 2. 区间不连续时
355
+ // 3. 区间边界
356
+ .custom((value, helper) => {
357
+ if (value.length !== 2) {
358
+ return helper.message('length of range should be 2');
359
+ }
360
+
361
+ if (value[0] < 0) {
362
+ return helper.message('the first value should not less than 0 in range');
363
+ }
364
+
365
+ if (value[1] <= value[0]) {
366
+ return helper.message('the second value should greater than the first value in range');
367
+ }
368
+
369
+ return value;
370
+ }),
371
+ type: Joi.string().valid('fixed', 'percentage').required(),
372
+ value: Joi.number().greater(0).required(),
373
+ })
374
+ )
375
+ .single(),
345
376
  }).default({ price: [], share: [] }),
346
377
 
347
378
  keywords: Joi.alternatives()
@@ -468,6 +499,7 @@ const createBlockletSchema = (
468
499
  // NOTE: following fields only exist in blocklet server and cannot be set manually
469
500
  bundleName: Joi.string(),
470
501
  bundleDid: Joi.DID().trim(),
502
+ storeId: Joi.string(),
471
503
  }).options({ stripUnknown: true, noDefaults: false, ...schemaOptions });
472
504
  };
473
505
 
@@ -0,0 +1,173 @@
1
+ const fs = require('fs');
2
+ const axios = require('axios');
3
+ const any = require('promise.any');
4
+ const joinUrl = require('url-join');
5
+
6
+ const { BLOCKLET_STORE_API_BLOCKLET_PREFIX } = require('@abtnode/constant');
7
+
8
+ const toBlockletDid = require('./did');
9
+ const { validateMeta, fixAndValidateService } = require('./validate');
10
+
11
+ const validateUrl = async (url, expectedHttpResTypes = ['application/json', 'text/plain']) => {
12
+ const parsed = new URL(url);
13
+ const { protocol, pathname } = parsed;
14
+
15
+ // file
16
+ if (protocol.startsWith('file')) {
17
+ const decoded = decodeURIComponent(pathname);
18
+ if (!fs.existsSync(decoded)) {
19
+ throw new Error(`File does not exist: ${decoded}`);
20
+ }
21
+ return true;
22
+ }
23
+
24
+ // http(s)
25
+ if (protocol.startsWith('http')) {
26
+ let res;
27
+
28
+ try {
29
+ res = await axios({ url, method: 'HEAD', timeout: 1000 * 10 });
30
+ } catch (err) {
31
+ throw new Error(`Cannot get content-type from ${url}: ${err.message}`);
32
+ }
33
+
34
+ if (
35
+ res.headers['content-type'] &&
36
+ expectedHttpResTypes.some((x) => res.headers['content-type'].includes(x)) === false
37
+ ) {
38
+ throw new Error(`Unexpected content-type from ${url}: ${res.headers['content-type']}`);
39
+ }
40
+
41
+ return true;
42
+ }
43
+
44
+ throw new Error(`Invalid url protocol: ${protocol.replace(/:$/, '')}`);
45
+ };
46
+
47
+ const validateBlockletMeta = (meta, opts = {}) => {
48
+ fixAndValidateService(meta);
49
+ return validateMeta(meta, opts);
50
+ };
51
+
52
+ const getBlockletMetaByUrl = async (url) => {
53
+ const { protocol, pathname } = new URL(url);
54
+
55
+ if (protocol.startsWith('file')) {
56
+ const decoded = decodeURIComponent(pathname);
57
+ if (!fs.existsSync(decoded)) {
58
+ throw new Error(`File does not exist: ${decoded}`);
59
+ }
60
+ const d = await fs.promises.readFile(decoded);
61
+ const meta = JSON.parse(d);
62
+ return meta;
63
+ }
64
+
65
+ if (protocol.startsWith('http')) {
66
+ const { data: meta } = await axios({ url, method: 'GET', timeout: 1000 * 20 });
67
+ if (Object.prototype.toString.call(meta) !== '[object Object]') {
68
+ throw new Error('Url is not valid');
69
+ }
70
+ return meta;
71
+ }
72
+
73
+ throw new Error(`Invalid url protocol: ${protocol.replace(/:$/, '')}`);
74
+ };
75
+
76
+ const getBlockletMetaFromUrl = async (
77
+ url,
78
+ { validateFn = validateBlockletMeta, returnUrl = false, ensureTarball = true, logger } = {}
79
+ ) => {
80
+ const meta = await getBlockletMetaByUrl(url);
81
+ delete meta.htmlAst;
82
+
83
+ const newMeta = validateFn(meta, { ensureDist: true });
84
+
85
+ if (ensureTarball) {
86
+ try {
87
+ const { href } = new URL(newMeta.dist.tarball, url);
88
+ const tarball = decodeURIComponent(href);
89
+
90
+ try {
91
+ await validateUrl(tarball, ['application/octet-stream', 'application/x-gzip']);
92
+ } catch (error) {
93
+ if (!error.message.startsWith('Cannot get content-type')) {
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ newMeta.dist.tarball = tarball;
99
+ } catch (err) {
100
+ const msg = `Invalid blocklet meta: dist.tarball is not a valid url ${err.message}`;
101
+
102
+ if (logger) {
103
+ logger.error(msg);
104
+ }
105
+
106
+ throw new Error(msg);
107
+ }
108
+ }
109
+
110
+ if (returnUrl) {
111
+ return { meta: newMeta, url };
112
+ }
113
+
114
+ return newMeta;
115
+ };
116
+
117
+ const getBlockletMetaFromUrls = async (urls, { validateFn, returnUrl = false, ensureTarball = true, logger } = {}) => {
118
+ try {
119
+ const res = await any(
120
+ urls.map((url) => getBlockletMetaFromUrl(url, { validateFn, returnUrl, ensureTarball, logger }))
121
+ );
122
+ return res;
123
+ } catch (err) {
124
+ let { message } = err;
125
+ if (Array.isArray(err.errors)) {
126
+ message = err.errors.map((x) => x.message).join(', ');
127
+ }
128
+
129
+ if (logger) {
130
+ logger.error('failed get blocklet meta', { urls, message });
131
+ }
132
+
133
+ throw new Error(message);
134
+ }
135
+ };
136
+
137
+ /**
138
+ * @param {*} config defined in childrenSchema in blocklet meta schema
139
+ */
140
+ const getSourceUrlsFromConfig = (config) => {
141
+ if (config.source) {
142
+ if (config.source.url) {
143
+ return [config.source.url].flat();
144
+ }
145
+
146
+ const { store, version, name } = config.source;
147
+ return [store]
148
+ .flat()
149
+ .map((x) =>
150
+ joinUrl(
151
+ x,
152
+ BLOCKLET_STORE_API_BLOCKLET_PREFIX,
153
+ toBlockletDid(name),
154
+ !version || version === 'latest' ? '' : version,
155
+ 'blocklet.json'
156
+ )
157
+ );
158
+ }
159
+
160
+ if (config.resolved) {
161
+ return [config.resolved];
162
+ }
163
+
164
+ throw new Error('Invalid child config');
165
+ };
166
+
167
+ module.exports = {
168
+ validateUrl,
169
+ getBlockletMetaByUrl,
170
+ getBlockletMetaFromUrl,
171
+ getBlockletMetaFromUrls,
172
+ getSourceUrlsFromConfig,
173
+ };
package/lib/util.js CHANGED
@@ -259,6 +259,18 @@ const isFreeBlocklet = (meta) => {
259
259
  return priceList.every((x) => x === 0);
260
260
  };
261
261
 
262
+ const isFreeComponent = (meta) => {
263
+ if (!meta.payment) {
264
+ return true;
265
+ }
266
+
267
+ if (!meta.payment.componentPrice) {
268
+ return true;
269
+ }
270
+
271
+ return !meta.payment.componentPrice.length;
272
+ };
273
+
262
274
  const isComponentBlocklet = (meta) => {
263
275
  return get(meta, 'capabilities.component') !== false;
264
276
  };
@@ -428,6 +440,7 @@ const urlFriendly = (name) => slugify(name.replace(/^[@./-]/, '').replace(/[@./_
428
440
 
429
441
  module.exports = {
430
442
  isFreeBlocklet,
443
+ isFreeComponent,
431
444
  isComponentBlocklet,
432
445
  forEachBlocklet,
433
446
  forEachBlockletSync,
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.8.11",
6
+ "version": "1.8.14",
7
7
  "description": "Library to parse/validate/fix blocklet meta",
8
8
  "main": "lib/index.js",
9
9
  "files": [
@@ -18,17 +18,18 @@
18
18
  "author": "wangshijun <wangshijun2020@gmail.com> (http://github.com/wangshijun)",
19
19
  "license": "MIT",
20
20
  "dependencies": {
21
- "@abtnode/constant": "1.8.11",
22
- "@abtnode/util": "1.8.11",
23
- "@arcblock/did": "1.17.11",
24
- "@arcblock/did-ext": "1.17.11",
25
- "@arcblock/did-util": "1.17.11",
26
- "@arcblock/jwt": "1.17.11",
27
- "@ocap/asset": "1.17.11",
28
- "@ocap/mcrypto": "1.17.11",
29
- "@ocap/util": "1.17.11",
30
- "@ocap/wallet": "1.17.11",
21
+ "@abtnode/constant": "1.8.14",
22
+ "@abtnode/util": "1.8.14",
23
+ "@arcblock/did": "1.17.17",
24
+ "@arcblock/did-ext": "1.17.17",
25
+ "@arcblock/did-util": "1.17.17",
26
+ "@arcblock/jwt": "1.17.17",
27
+ "@ocap/asset": "1.17.17",
28
+ "@ocap/mcrypto": "1.17.17",
29
+ "@ocap/util": "1.17.17",
30
+ "@ocap/wallet": "1.17.17",
31
31
  "ajv": "^8.11.0",
32
+ "axios": "^0.27.2",
32
33
  "cjk-length": "^1.0.0",
33
34
  "debug": "^4.3.4",
34
35
  "fs-extra": "^10.1.0",
@@ -39,12 +40,18 @@
39
40
  "js-yaml": "^4.1.0",
40
41
  "json-stable-stringify": "^1.0.1",
41
42
  "lodash": "^4.17.21",
43
+ "promise.any": "^2.0.4",
42
44
  "slugify": "^1.6.5",
43
45
  "url-join": "^4.0.1",
44
46
  "validate-npm-package-name": "^3.0.0"
45
47
  },
46
48
  "devDependencies": {
49
+ "body-parser": "^1.20.0",
50
+ "compression": "^1.7.4",
51
+ "detect-port": "^1.3.0",
52
+ "expand-tilde": "^2.0.2",
53
+ "express": "^4.18.1",
47
54
  "jest": "^27.5.1"
48
55
  },
49
- "gitHead": "389148562de1ce5b45097e764bc9d1718009e527"
56
+ "gitHead": "4b0cee41fd03d09301e2c35af46dd9e513473ef8"
50
57
  }