@blocklet/meta 1.8.13 → 1.8.16

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/info.js CHANGED
@@ -12,17 +12,20 @@ module.exports = (state, nodeSk, { returnWallet = true } = {}) => {
12
12
 
13
13
  const customDescription = envs.find((x) => x.key === 'BLOCKLET_APP_DESCRIPTION');
14
14
  const customPassportColor = envs.find((x) => x.key === 'BLOCKLET_PASSPORT_COLOR');
15
+ const customAppUrl = envs.find((x) => x.key === 'BLOCKLET_APP_URL');
15
16
 
16
17
  const { did } = state.meta;
17
18
  const name = getDisplayName(state);
18
19
  const description = get(customDescription, 'value', state.meta.description);
19
20
  const passportColor = get(customPassportColor, 'value', 'auto');
21
+ const appUrl = get(customAppUrl, 'value', '');
20
22
 
21
23
  if (!returnWallet) {
22
24
  return {
23
25
  did,
24
26
  name,
25
27
  description,
28
+ appUrl,
26
29
  };
27
30
  }
28
31
 
@@ -54,6 +57,7 @@ module.exports = (state, nodeSk, { returnWallet = true } = {}) => {
54
57
  name,
55
58
  description,
56
59
  passportColor,
60
+ appUrl,
57
61
  wallet,
58
62
  };
59
63
  };
@@ -140,7 +140,7 @@ const doParseNavigation = (navigation, blocklet, prefix = '/', _level = 1) => {
140
140
  }
141
141
 
142
142
  if (childNav.role) {
143
- item.role = item.icon || childNav.role;
143
+ item.role = item.role || childNav.role;
144
144
  }
145
145
 
146
146
  if (childNav.section) {
@@ -151,6 +151,8 @@ const doParseNavigation = (navigation, blocklet, prefix = '/', _level = 1) => {
151
151
  result.push(item);
152
152
  } else {
153
153
  // child declares multiple menus
154
+ const groupSection = childNavigation[0].section || [];
155
+
154
156
  const list = doParseNavigation(
155
157
  childNavigation,
156
158
  child,
@@ -161,6 +163,11 @@ const doParseNavigation = (navigation, blocklet, prefix = '/', _level = 1) => {
161
163
  if (_level === 1) { // eslint-disable-line
162
164
  // primary menu
163
165
  const item = cloneDeep(itemProto);
166
+
167
+ if (groupSection.length) {
168
+ item.section = item.section || groupSection;
169
+ }
170
+
164
171
  item.items = list;
165
172
  result.push({
166
173
  ...item,
@@ -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);
@@ -0,0 +1,619 @@
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
+ * components: Array<{did: string, version: 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({
155
+ id: storeInfo.id,
156
+ pk: storeInfo.pk,
157
+ url: storeUrl,
158
+ components: [{ did: meta.did, version: meta.version }],
159
+ });
160
+ } else if (!store.components.some((x) => x.did === meta.did && x.version === meta.version)) {
161
+ store.components.push({ did: meta.did, version: meta.version });
162
+ }
163
+ }
164
+
165
+ if (children && children.length > 0) {
166
+ _getStores(children, _stores);
167
+ }
168
+ }
169
+
170
+ return _stores;
171
+ };
172
+
173
+ const getComponents = async (inputMeta) => {
174
+ const components = await _getComponents(inputMeta);
175
+ const stores = await _getStores(components);
176
+ return { components, stores };
177
+ };
178
+
179
+ const getPriceTokens = async (meta, ocapClient) => {
180
+ const priceTokens = cloneDeep(get(meta, 'payment.price', []));
181
+ for (const token of priceTokens) {
182
+ // eslint-disable-next-line no-await-in-loop
183
+ const { state } = await ocapClient.getTokenState({ address: token.address });
184
+ if (!state) {
185
+ throw new Error(`Token specified in blocklet meta was not found on chain: ${token.address}`);
186
+ }
187
+
188
+ token.decimal = state.decimal;
189
+ }
190
+ return priceTokens;
191
+ };
192
+
193
+ const getChildShare = (childMeta, parentPrice) => {
194
+ if (!childMeta?.payment?.componentPrice) {
195
+ return 0;
196
+ }
197
+
198
+ const priceList = childMeta.payment.componentPrice;
199
+
200
+ let price = 0;
201
+
202
+ for (const { type, value, parentPriceRange } of priceList) {
203
+ const isDefault = !parentPriceRange || !parentPriceRange.length;
204
+ const skip = isDefault && price !== 0;
205
+ const inRange =
206
+ isDefault || (parentPriceRange && parentPrice >= parentPriceRange[0] && parentPrice <= parentPriceRange[1]);
207
+
208
+ if (!skip && inRange) {
209
+ if (type === 'fixed') {
210
+ price = value;
211
+ } else if (type === 'percentage') {
212
+ price = safeMul(parentPrice, value);
213
+ }
214
+ }
215
+ }
216
+
217
+ return price;
218
+ };
219
+
220
+ /**
221
+ * @returns {Array<{
222
+ * tokenAddress: string
223
+ * accountAddress: string
224
+ * amount: BN
225
+ * }>}
226
+ */
227
+ const getTokenTransfers = ({ priceToken, shares = [], components = [] }) => {
228
+ // check share
229
+ const shareSum = shares.reduce((sum, x) => sum + x.value, 0);
230
+ if (shareSum > 1) {
231
+ throw new Error('payment.share invalid: share sum should not be greater than 1');
232
+ }
233
+
234
+ const { value: price } = priceToken;
235
+
236
+ let parentShareBN = fromTokenToUnit(price, priceToken.decimal);
237
+
238
+ const contracts = [];
239
+
240
+ for (const child of components) {
241
+ if (!isFreeComponent(child.meta)) {
242
+ // // check same token
243
+ const [token] = child.meta.payment.price || [];
244
+ if (token && token.address !== priceToken.address) {
245
+ throw new Error(
246
+ `component price token is not same with app price token: ${child.meta.title || child.meta.name}`
247
+ );
248
+ }
249
+
250
+ const childShare = getChildShare(child.meta, price);
251
+
252
+ parentShareBN = parentShareBN.sub(fromTokenToUnit(childShare, priceToken.decimal));
253
+
254
+ if (parentShareBN.lt(ZeroBN)) {
255
+ throw new Error('Price is not enough for component sharing');
256
+ }
257
+
258
+ const componentContracts = getTokenTransfers({
259
+ priceToken: { ...priceToken, value: childShare },
260
+ shares: child.meta.payment.share,
261
+ components: child.children || [],
262
+ });
263
+
264
+ contracts.push(...componentContracts);
265
+ }
266
+ }
267
+
268
+ shares.forEach(({ name, address: accountAddress, value: ratio }) => {
269
+ contracts.push({
270
+ tokenAddress: priceToken.address,
271
+ accountName: name,
272
+ accountAddress,
273
+ amount: parentShareBN.mul(new BN(ratio * defaultDecimals)).div(defaultDecimalsBN),
274
+ });
275
+ });
276
+
277
+ const mergedContracts = [];
278
+
279
+ contracts.forEach((x) => {
280
+ const index = mergedContracts.findIndex(
281
+ (y) => y.tokenAddress === x.tokenAddress && y.accountAddress === x.accountAddress
282
+ );
283
+ if (index > -1) {
284
+ mergedContracts[index].amount = mergedContracts[index].amount.add(x.amount);
285
+ } else {
286
+ mergedContracts.push(x);
287
+ }
288
+ });
289
+
290
+ return mergedContracts;
291
+ };
292
+
293
+ const getContract = async ({ meta, priceTokens, components }) => {
294
+ const shares = meta.payment.share || [];
295
+
296
+ const [priceToken] = priceTokens;
297
+
298
+ const contracts = getTokenTransfers({ priceToken, shares, components });
299
+
300
+ const code = contracts
301
+ .map((x) => `transferToken('${x.tokenAddress}','${x.accountAddress}','${x.amount.toString()}')`)
302
+ .join(';\n');
303
+
304
+ const shareList = contracts.map((x) => ({
305
+ ...x,
306
+ amount: fromUnitToToken(x.amount, priceToken.decimal),
307
+ }));
308
+
309
+ return {
310
+ code,
311
+ shares: shareList,
312
+ };
313
+ };
314
+
315
+ /**
316
+ * we need to ensure that blocklet purchase factory does not change across changes
317
+ *
318
+ * @typedef {{
319
+ * data: {
320
+ * type: 'json'
321
+ * value: {
322
+ * did: string
323
+ * url: string
324
+ * name: string
325
+ * version: string
326
+ * payment: {
327
+ * version: string
328
+ * }
329
+ * stores: Array<{
330
+ * signer: string
331
+ * pk: string
332
+ * signature: string
333
+ * components: Array<{did: string, version: string}>
334
+ * paymentIntegrity: string
335
+ * }>
336
+ * }
337
+ * }
338
+ * }} Itx
339
+ * @returns {Itx}
340
+ */
341
+ const _createNftFactoryItx = ({ meta, issuers, serviceUrl, storeSignatures, factoryInput, contract }) => {
342
+ const factoryOutput = getBlockletPurchaseTemplate(serviceUrl);
343
+ const itx = {
344
+ name: meta.title || meta.name,
345
+ description: `Purchase NFT factory for blocklet ${meta.name}`,
346
+ settlement: 'instant',
347
+ limit: 0,
348
+ trustedIssuers: issuers,
349
+ input: factoryInput,
350
+ output: {
351
+ issuer: '{{ctx.issuer.id}}',
352
+ parent: '{{ctx.factory}}',
353
+ moniker: 'BlockletPurchaseNFT',
354
+ readonly: true,
355
+ transferrable: false,
356
+ data: factoryOutput,
357
+ },
358
+ data: {
359
+ type: 'json',
360
+ value: {
361
+ did: meta.did,
362
+ url: joinURL(serviceUrl, `/blocklet/${meta.did}`),
363
+ name: meta.name,
364
+ version: meta.version,
365
+ payment: {
366
+ version: VERSION,
367
+ },
368
+ stores: storeSignatures.map((x) => pick(x, ['signer', 'pk', 'signature', 'components', 'paymentIntegrity'])),
369
+ },
370
+ },
371
+ hooks: [
372
+ {
373
+ name: 'mint',
374
+ type: 'contract',
375
+ hook: contract,
376
+ },
377
+ ],
378
+ };
379
+
380
+ itx.address = toFactoryAddress(itx);
381
+
382
+ isValidFactory(itx, true);
383
+
384
+ return itx;
385
+ };
386
+
387
+ const getFactoryInput = (inputTokens, { formatToken = true } = {}) => {
388
+ const tokens = cloneDeep(inputTokens);
389
+ tokens.forEach((token) => {
390
+ if (formatToken) {
391
+ token.value = fromTokenToUnit(token.value, token.decimal).toString();
392
+ }
393
+ delete token.decimal;
394
+ });
395
+ return {
396
+ tokens,
397
+ assets: [],
398
+ variables: [],
399
+ };
400
+ };
401
+
402
+ const getPaymentIntegrity = async ({ contract, factoryInput, storeComponents, meta, client, storeId }) => {
403
+ if (!contract && !factoryInput && !storeComponents) {
404
+ const priceTokens = await getPriceTokens(meta, client);
405
+ const { components, stores } = await getComponents(meta);
406
+ const store = stores.find((x) => x.id === storeId);
407
+
408
+ // eslint-disable-next-line no-param-reassign
409
+ contract = (await getContract({ meta, components, priceTokens })).code;
410
+ // eslint-disable-next-line no-param-reassign
411
+ factoryInput = await getFactoryInput(priceTokens);
412
+ // eslint-disable-next-line no-param-reassign
413
+ storeComponents = store?.components || [];
414
+ }
415
+
416
+ const paymentData = {
417
+ factoryInput,
418
+ contract,
419
+ components: storeComponents || [],
420
+ };
421
+
422
+ const integrity = md5(stableStringify(paymentData));
423
+
424
+ return integrity;
425
+ };
426
+
427
+ const getStoreSignatures = async ({ meta, stores, factoryInput, contract }) => {
428
+ const storeSignatures = [];
429
+ for (const store of stores) {
430
+ const { id, url, pk, components: storeComponents } = store;
431
+
432
+ const paymentIntegrity = await getPaymentIntegrity({ factoryInput, contract, storeComponents });
433
+
434
+ /**
435
+ * protocol: /api/payment/signature
436
+ * method: POST
437
+ * body: { blockletMeta, paymentIntegrity, paymentVersion }
438
+ * return: { signer, pk, signature}
439
+ */
440
+ const { data: res } = await axios.post(
441
+ `${url}/api/payment/signature`,
442
+ {
443
+ blockletMeta: meta,
444
+ paymentIntegrity,
445
+ paymentVersion: VERSION,
446
+ },
447
+ { timeout: 20000 }
448
+ );
449
+
450
+ if (res.signer !== id) {
451
+ throw new Error('store signature: store id does not match');
452
+ }
453
+
454
+ if (res.pk !== pk) {
455
+ throw new Error('store signature: store pk does not match');
456
+ }
457
+
458
+ // verify sig
459
+ const type = toTypeInfo(id);
460
+ const wallet = fromPublicKey(pk, type);
461
+ const verifyRes = wallet.verify(paymentIntegrity, res.signature);
462
+ if (verifyRes !== true) {
463
+ throw new Error('verify store signature failed');
464
+ }
465
+
466
+ storeSignatures.push({
467
+ signer: res.signer,
468
+ pk: res.pk,
469
+ signature: res.signature,
470
+ components: storeComponents,
471
+ paymentIntegrity,
472
+ storeUrl: url,
473
+ });
474
+ }
475
+
476
+ return {
477
+ storeSignatures,
478
+ };
479
+ };
480
+
481
+ /**
482
+ * Used by CLI and Store to independent compute factory itx
483
+ *
484
+ * @param {{
485
+ * blockletMeta: BlockletMeta,
486
+ * ocapClient: OcapClient,
487
+ * issuers: Array<string>,
488
+ * storeUrl: string,
489
+ * }}
490
+ * @returns {{
491
+ * itx: Itx
492
+ * store: Array<{id, url}>
493
+ * shares: Array<{
494
+ * accountName: string
495
+ * accountAddress: DID
496
+ * tokenAddress: DID
497
+ * amount: string|number,
498
+ * }>
499
+ * }}
500
+ */
501
+ const createNftFactoryItx = async ({ blockletMeta, ocapClient, issuers, storeUrl }) => {
502
+ const priceTokens = await getPriceTokens(blockletMeta, ocapClient);
503
+ const { components, stores } = await getComponents(blockletMeta);
504
+
505
+ const factoryInput = getFactoryInput(priceTokens);
506
+ const { code: contract, shares } = await getContract({
507
+ meta: blockletMeta,
508
+ client: ocapClient,
509
+ priceTokens,
510
+ components,
511
+ });
512
+
513
+ const { storeSignatures } = await getStoreSignatures({
514
+ meta: blockletMeta,
515
+ client: ocapClient,
516
+ priceTokens,
517
+ components,
518
+ stores,
519
+ factoryInput,
520
+ contract,
521
+ });
522
+
523
+ return {
524
+ itx: _createNftFactoryItx({
525
+ meta: blockletMeta,
526
+ issuers,
527
+ serviceUrl: storeUrl,
528
+ priceTokens,
529
+ components,
530
+ stores,
531
+ storeSignatures,
532
+ factoryInput,
533
+ contract,
534
+ }),
535
+ stores: storeSignatures.map((x) => ({ id: x.signer, url: x.storeUrl })),
536
+ shares,
537
+ };
538
+ };
539
+
540
+ /**
541
+ * Used by Store before generating payment signature
542
+ *
543
+ * @param {{
544
+ * integrity: string,
545
+ * blockletMeta: BlockletMeta,
546
+ * ocapClient: OcapClient,
547
+ * storeId: string
548
+ * }}
549
+ * @returns {string} integrity
550
+ */
551
+ const verifyPaymentIntegrity = async ({ integrity: expected, blockletMeta, ocapClient, storeId }) => {
552
+ const actual = await getPaymentIntegrity({ meta: blockletMeta, client: ocapClient, storeId });
553
+
554
+ if (actual !== expected) {
555
+ throw new Error('verify payment integrity failed');
556
+ }
557
+
558
+ return expected;
559
+ };
560
+
561
+ /**
562
+ * Used by Store before generating downloadToken
563
+ *
564
+ * @param {{
565
+ * {FactoryState} factoryState
566
+ * {Wallet} signerWallet
567
+ * }}
568
+ *
569
+ * @returns {{
570
+ * components: Array<{did: string, version: string}>
571
+ * }}
572
+ */
573
+ const verifyNftFactory = async ({ factoryState, signerWallet }) => {
574
+ const data = JSON.parse(factoryState?.data?.value);
575
+ const stores = data?.stores || [];
576
+ const store = stores.find((x) => x.signer === signerWallet.address);
577
+
578
+ if (!store) {
579
+ throw new Error(
580
+ `Signer does not found in factory. factory: ${factoryState.address}, signer: ${signerWallet.address}`
581
+ );
582
+ }
583
+
584
+ const c = factoryState.hooks.find((x) => x.type === 'contract');
585
+ const { components } = store;
586
+
587
+ // Token 的字段和 factory 中的字段不一致
588
+ const factoryInput = getFactoryInput(
589
+ factoryState.input.tokens.map((x) => pick(x, ['address', 'value'])),
590
+ { formatToken: false }
591
+ );
592
+
593
+ const integrity = await getPaymentIntegrity({
594
+ contract: c.hook,
595
+ factoryInput,
596
+ storeComponents: components,
597
+ });
598
+
599
+ if (signerWallet.sign(integrity) !== store.signature) {
600
+ debug(store, factoryInput, integrity, components, c.hook);
601
+ throw new Error(`verify nft factory failed: ${factoryState.address}`);
602
+ }
603
+
604
+ return { components };
605
+ };
606
+
607
+ module.exports = {
608
+ createNftFactoryItx,
609
+ verifyPaymentIntegrity,
610
+ verifyNftFactory,
611
+ version: VERSION,
612
+ _test: {
613
+ getPriceTokens,
614
+ getFactoryInput,
615
+ getPaymentIntegrity,
616
+ getComponents,
617
+ getContract,
618
+ },
619
+ };
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.13",
6
+ "version": "1.8.16",
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.13",
22
- "@abtnode/util": "1.8.13",
23
- "@arcblock/did": "1.17.15",
24
- "@arcblock/did-ext": "1.17.15",
25
- "@arcblock/did-util": "1.17.15",
26
- "@arcblock/jwt": "1.17.15",
27
- "@ocap/asset": "1.17.15",
28
- "@ocap/mcrypto": "1.17.15",
29
- "@ocap/util": "1.17.15",
30
- "@ocap/wallet": "1.17.15",
21
+ "@abtnode/constant": "1.8.16",
22
+ "@abtnode/util": "1.8.16",
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": "028f33d8a3a4f999456bfe8e7bd2e1a53b664b47"
56
+ "gitHead": "2605cf6ebf2a0c3bb5524cb0f1385ad565fd85d6"
50
57
  }