@centrifuge/sdk 1.16.0 → 1.18.0
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/dist/Centrifuge.d.ts.map +1 -1
- package/dist/Centrifuge.js +1 -0
- package/dist/Centrifuge.js.map +1 -1
- package/dist/entities/Pool.d.ts.map +1 -1
- package/dist/entities/Pool.js +1 -5
- package/dist/entities/Pool.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/tests/mocks/mockPoolMetadataV2.d.ts.map +1 -1
- package/dist/tests/mocks/mockPoolMetadataV2.js +122 -17
- package/dist/tests/mocks/mockPoolMetadataV2.js.map +1 -1
- package/dist/types/poolInput.d.ts +3 -5
- package/dist/types/poolInput.d.ts.map +1 -1
- package/dist/types/poolMetadata.d.ts +173 -26
- package/dist/types/poolMetadata.d.ts.map +1 -1
- package/dist/types/poolMetadata.js.map +1 -1
- package/dist/utils/poolMetadataMigration.d.ts +18 -5
- package/dist/utils/poolMetadataMigration.d.ts.map +1 -1
- package/dist/utils/poolMetadataMigration.js +497 -67
- package/dist/utils/poolMetadataMigration.js.map +1 -1
- package/dist/utils/poolMetadataMigration.test.js +459 -38
- package/dist/utils/poolMetadataMigration.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -22,84 +22,190 @@ function migrateApyMode(apy) {
|
|
|
22
22
|
function formatMinInvestment(value) {
|
|
23
23
|
return value.toLocaleString('en-US');
|
|
24
24
|
}
|
|
25
|
+
/** Tolerates legacy docs that omit (or malform) an array field by treating it as empty. */
|
|
26
|
+
function asArray(value) {
|
|
27
|
+
return Array.isArray(value) ? value : [];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Folds a legacy off-chain `{ headers, data }` holdings blob into a `table` section, preserving
|
|
31
|
+
* every column verbatim (numbers stay numbers, null/undefined -> '', other values stringified +
|
|
32
|
+
* trimmed). Returns null when there are no headers or no data.
|
|
33
|
+
*/
|
|
34
|
+
function holdingsTableBlock(holdings) {
|
|
35
|
+
if (!holdings || !Array.isArray(holdings.headers) || holdings.headers.length === 0 || !Array.isArray(holdings.data)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const { headers, data } = holdings;
|
|
39
|
+
return {
|
|
40
|
+
type: 'table',
|
|
41
|
+
id: 'holdings',
|
|
42
|
+
title: 'Holdings',
|
|
43
|
+
headers,
|
|
44
|
+
rows: data.map((row) => headers.map((header) => {
|
|
45
|
+
const value = row[header];
|
|
46
|
+
return typeof value === 'number' ? value : value == null ? '' : String(value).trim();
|
|
47
|
+
})),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Normalizes an issuer category `type` for matching (case- and separator-insensitive). */
|
|
51
|
+
function normalizeCategoryType(type) {
|
|
52
|
+
return type.toLowerCase().replace(/[\s_-]/g, '');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Issuer-category `type` values (normalized) that migrate into the "Service providers" group, mapped
|
|
56
|
+
* to their v2 display label (the v1 vocabulary differs from v2, e.g. "Sub-Investment Manager" is the
|
|
57
|
+
* v2 "Portfolio Manager"). Anything not listed stays in "Key facts" with its raw label (the seed is
|
|
58
|
+
* ops-editable, so an unmatched type is low-cost). `administrator` is an alias of Fund Administrator.
|
|
59
|
+
*/
|
|
60
|
+
const SERVICE_PROVIDER_CATEGORIES = new Map([
|
|
61
|
+
['subinvestmentmanager', 'Portfolio Manager'],
|
|
62
|
+
['custodian', 'Custodian'],
|
|
63
|
+
['fundadministrator', 'Fund Administrator'],
|
|
64
|
+
['administrator', 'Fund Administrator'],
|
|
65
|
+
['auditor', 'Auditor'],
|
|
66
|
+
]);
|
|
67
|
+
/**
|
|
68
|
+
* Issuer-category `type` values (normalized) dropped from the factsheet because they are already
|
|
69
|
+
* represented elsewhere: "Investment Manager" is absorbed by the `Issuer` key fact (from
|
|
70
|
+
* `issuer.name`), so it is not rendered as its own row.
|
|
71
|
+
*/
|
|
72
|
+
const ABSORBED_CATEGORY_TYPES = new Set(['investmentmanager']);
|
|
25
73
|
/** Document links only carry web/IPFS URIs; anything else (e.g. `javascript:`) is dropped. */
|
|
26
74
|
function isSafeDocumentUri(uri) {
|
|
27
75
|
const scheme = uri.trim().toLowerCase();
|
|
28
76
|
return scheme.startsWith('https://') || scheme.startsWith('ipfs://');
|
|
29
77
|
}
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Builds a `documents` section from the rating report files (a tile per safe-URI report), or null
|
|
80
|
+
* when none have a usable report. Each tile targets the report file directly (`kind: 'file'`).
|
|
81
|
+
*/
|
|
82
|
+
function ratingDocumentsBlock(poolRatings) {
|
|
83
|
+
const items = asArray(poolRatings)
|
|
84
|
+
.filter((rating) => rating.reportFile?.uri && isSafeDocumentUri(rating.reportFile.uri))
|
|
85
|
+
.map((rating) => ({
|
|
86
|
+
title: `${rating.agency ?? 'Rating'} rating report`,
|
|
87
|
+
target: { kind: 'file', file: rating.reportFile },
|
|
88
|
+
}));
|
|
89
|
+
return items.length > 0 ? { type: 'documents', id: 'documents', title: 'Documents', items } : null;
|
|
33
90
|
}
|
|
34
91
|
/**
|
|
35
92
|
* Builds the seed `factsheet` from the legacy display fields. The migration is content-translating:
|
|
36
93
|
* a migrated pool must render the same content, so legacy fields are projected into ordered key
|
|
37
94
|
* facts + body blocks. The output is a sensible, ops-editable seed, not a frozen contract.
|
|
38
95
|
*/
|
|
39
|
-
function buildFactsheet(pool, shareClasses) {
|
|
96
|
+
function buildFactsheet(pool, shareClasses, holdings) {
|
|
40
97
|
const { issuer, asset, poolStructure, expenseRatio, details, poolRatings } = pool;
|
|
41
98
|
const firstShareClass = Object.values(shareClasses)[0];
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
{ label: '
|
|
46
|
-
{ label: '
|
|
99
|
+
// Trim text key-fact values (issuer/category strings sometimes carry stray whitespace).
|
|
100
|
+
const text = (value) => ({ kind: 'text', text: value.trim() });
|
|
101
|
+
const keyFactItems = [
|
|
102
|
+
{ label: 'Issuer', value: text(issuer.name) },
|
|
103
|
+
{ label: 'Asset type', value: text(`${asset.class} - ${asset.subClass}`) },
|
|
104
|
+
{ label: 'APY', value: { kind: 'ref', ref: 'apy' } },
|
|
105
|
+
{ label: 'Pool structure', value: text(poolStructure) },
|
|
47
106
|
];
|
|
48
107
|
const weightedAverageMaturity = firstShareClass?.weightedAverageMaturity;
|
|
49
108
|
if (weightedAverageMaturity != null) {
|
|
50
|
-
|
|
109
|
+
keyFactItems.push({ label: 'Average asset maturity', value: text(`${weightedAverageMaturity} days`) });
|
|
51
110
|
}
|
|
52
111
|
if (expenseRatio != null && expenseRatio !== '') {
|
|
53
|
-
|
|
112
|
+
keyFactItems.push({ label: 'Expense ratio', value: text(`${expenseRatio}%`) });
|
|
54
113
|
}
|
|
55
114
|
const minInitialInvestment = firstShareClass?.minInitialInvestment;
|
|
56
115
|
if (minInitialInvestment != null) {
|
|
57
|
-
|
|
116
|
+
keyFactItems.push({ label: 'Min. investment', value: text(formatMinInvestment(minInitialInvestment)) });
|
|
117
|
+
}
|
|
118
|
+
const serviceProviderItems = [];
|
|
119
|
+
for (const category of asArray(issuer.categories)) {
|
|
120
|
+
const key = normalizeCategoryType(category.type);
|
|
121
|
+
if (ABSORBED_CATEGORY_TYPES.has(key))
|
|
122
|
+
continue;
|
|
123
|
+
const label = SERVICE_PROVIDER_CATEGORIES.get(key);
|
|
124
|
+
if (label) {
|
|
125
|
+
serviceProviderItems.push({ label, value: text(category.value) });
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
keyFactItems.push({ label: category.type, value: text(category.value) });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Seeded unconditionally because v1 has no source field for either: the app derives the deployed
|
|
132
|
+
// chains, and wallet infrastructure is currently Fordefi for all pools. Ops can edit/remove after.
|
|
133
|
+
keyFactItems.push({
|
|
134
|
+
label: 'Wallet infrastructure',
|
|
135
|
+
value: { kind: 'icons', icons: [{ source: 'app', key: 'fordefi' }] },
|
|
136
|
+
});
|
|
137
|
+
keyFactItems.push({ label: 'Available networks', value: { kind: 'ref', ref: 'availableNetworks' } });
|
|
138
|
+
if (asArray(poolRatings).length > 0) {
|
|
139
|
+
keyFactItems.push({ label: 'Ratings', value: { kind: 'ref', ref: 'ratings' } });
|
|
58
140
|
}
|
|
59
|
-
|
|
60
|
-
|
|
141
|
+
// Right column is groups only (no bare key facts). The "Service providers" group is added only
|
|
142
|
+
// when at least one category mapped to it.
|
|
143
|
+
const keyFacts = [{ type: 'keyFactGroup', id: 'key-facts', title: 'Key facts', items: keyFactItems }];
|
|
144
|
+
if (serviceProviderItems.length > 0) {
|
|
145
|
+
keyFacts.push({
|
|
146
|
+
type: 'keyFactGroup',
|
|
147
|
+
id: 'service-providers',
|
|
148
|
+
title: 'Service providers',
|
|
149
|
+
items: serviceProviderItems,
|
|
150
|
+
});
|
|
61
151
|
}
|
|
62
152
|
const body = [];
|
|
63
153
|
if (issuer.description) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
(details ?? []).forEach((detail, index) => {
|
|
68
|
-
body.push({ type: 'text', id: `detail-${index}`, title: detail.title, body: detail.body });
|
|
69
|
-
});
|
|
70
|
-
const ratingsWithReports = (poolRatings ?? []).filter((rating) => rating.reportFile?.uri && isSafeDocumentUri(rating.reportFile.uri));
|
|
71
|
-
if (ratingsWithReports.length > 0) {
|
|
154
|
+
// Overview card: logo and the "Factsheet" link button are added only when their source field
|
|
155
|
+
// exists, so a missing logo / executiveSummary degrades to a plain card (never a broken one).
|
|
156
|
+
// The Factsheet link references the typed `links.executiveSummary` rather than copying its URI.
|
|
72
157
|
body.push({
|
|
73
158
|
type: 'text',
|
|
74
|
-
id: '
|
|
75
|
-
title: '
|
|
76
|
-
body:
|
|
77
|
-
|
|
78
|
-
|
|
159
|
+
id: 'overview',
|
|
160
|
+
title: 'Overview',
|
|
161
|
+
body: issuer.description,
|
|
162
|
+
...(issuer.logo?.uri ? { logo: issuer.logo } : {}),
|
|
163
|
+
...(pool.links?.executiveSummary?.uri
|
|
164
|
+
? { links: [{ label: 'Factsheet', target: { kind: 'linkRef', linkRef: 'executiveSummary' } }] }
|
|
165
|
+
: {}),
|
|
79
166
|
});
|
|
80
167
|
}
|
|
168
|
+
asArray(details).forEach((detail, index) => {
|
|
169
|
+
body.push({ type: 'text', id: `detail-${index}`, title: detail.title, body: detail.body });
|
|
170
|
+
});
|
|
81
171
|
body.push({ type: 'section', id: 'performance', ref: 'onchainMetrics' });
|
|
82
|
-
//
|
|
83
|
-
|
|
172
|
+
// Full-width region. Rating reports become a `documents` tile block (ratings are also surfaced
|
|
173
|
+
// as the `ratings` key-fact ref pill above; both read the same typed `poolRatings`). `sections` is
|
|
174
|
+
// omitted (so the invest app renders its defaults) only when there is neither a documents block
|
|
175
|
+
// nor a holdings table.
|
|
176
|
+
const sections = [];
|
|
177
|
+
const documentsBlock = ratingDocumentsBlock(poolRatings);
|
|
178
|
+
if (documentsBlock)
|
|
179
|
+
sections.push(documentsBlock);
|
|
180
|
+
const holdingsBlock = holdingsTableBlock(holdings);
|
|
181
|
+
if (holdingsBlock)
|
|
182
|
+
sections.push(holdingsBlock);
|
|
183
|
+
return sections.length > 0 ? { body, keyFacts, sections } : { body, keyFacts };
|
|
84
184
|
}
|
|
85
185
|
/**
|
|
86
186
|
* Pure, idempotent legacy -> v2 migration. Performs no IPFS/network access so it stays
|
|
87
|
-
* unit-testable.
|
|
187
|
+
* unit-testable. A document already at the current v2 shape is returned unchanged; a v2 document
|
|
188
|
+
* written by an earlier SDK is normalized forward via {@link upgradeLegacyV2}.
|
|
88
189
|
*
|
|
89
190
|
* - Strips fields unused by every consumer (`newInvestmentsStatus`, `reports`, `loanTemplates`,
|
|
90
|
-
* `issuer.repName/shortDescription`, onboarding extras) and reshapes `issuer
|
|
191
|
+
* `issuer.repName/shortDescription`, onboarding extras) and reshapes `issuer`. `poolRatings` is
|
|
192
|
+
* preserved verbatim as a typed field (surfaced via the `ratings` key-fact ref).
|
|
91
193
|
* - Maps legacy APY modes across `shareClasses[*].apy`.
|
|
92
|
-
* - Projects the legacy display fields into `pool.factsheet
|
|
194
|
+
* - Projects the legacy display fields into `pool.factsheet`, and folds the off-chain `holdings`
|
|
195
|
+
* blob into a `table` section (`id: 'holdings'`); `holdings` is not carried as a top-level field.
|
|
93
196
|
*/
|
|
94
197
|
export function migratePoolMetadataToV2(legacy) {
|
|
95
198
|
if (isPoolMetadataV2(legacy))
|
|
96
|
-
return legacy;
|
|
97
|
-
const { pool, shareClasses, onboarding, merkleProofManager, holdings, addressLabels, workflowPolicies } = legacy;
|
|
199
|
+
return upgradeLegacyV2(legacy);
|
|
200
|
+
const { pool, shareClasses, onboarding, merkleProofManager, holdings, addressLabels, workflowPolicies, withdrawManagers, } = legacy;
|
|
98
201
|
const { issuer, poolRatings, ...restPool } = pool;
|
|
99
|
-
// Explicitly drop fields that v2 does not carry.
|
|
202
|
+
// Explicitly drop fields that v2 does not carry. `report` (singular) and `poolStatus` are stray
|
|
203
|
+
// non-schema fields seen in some legacy documents (the real fields are `reports` and `status`).
|
|
100
204
|
delete restPool.newInvestmentsStatus;
|
|
101
205
|
delete restPool.reports;
|
|
102
206
|
delete restPool.details;
|
|
207
|
+
delete restPool.report;
|
|
208
|
+
delete restPool.poolStatus;
|
|
103
209
|
const migratedShareClasses = {};
|
|
104
210
|
for (const [scId, shareClass] of Object.entries(shareClasses)) {
|
|
105
211
|
migratedShareClasses[scId] = { ...shareClass, apy: migrateApyMode(shareClass.apy) };
|
|
@@ -109,8 +215,10 @@ export function migratePoolMetadataToV2(legacy) {
|
|
|
109
215
|
pool: {
|
|
110
216
|
...restPool,
|
|
111
217
|
issuer: { name: issuer.name, email: issuer.email, logo: issuer.logo },
|
|
112
|
-
poolRatings
|
|
113
|
-
factsheet
|
|
218
|
+
// `poolRatings` stays a typed field verbatim (incl. reportFile); surfaced via the `ratings`
|
|
219
|
+
// key-fact ref. `holdings` is folded into the factsheet and not carried as a top-level field.
|
|
220
|
+
poolRatings,
|
|
221
|
+
factsheet: buildFactsheet(pool, shareClasses, holdings),
|
|
114
222
|
},
|
|
115
223
|
shareClasses: migratedShareClasses,
|
|
116
224
|
...(merkleProofManager !== undefined ? { merkleProofManager } : {}),
|
|
@@ -122,11 +230,106 @@ export function migratePoolMetadataToV2(legacy) {
|
|
|
122
230
|
},
|
|
123
231
|
}
|
|
124
232
|
: {}),
|
|
125
|
-
...(holdings !== undefined ? { holdings } : {}),
|
|
126
233
|
...(addressLabels !== undefined ? { addressLabels } : {}),
|
|
127
234
|
...(workflowPolicies !== undefined ? { workflowPolicies } : {}),
|
|
235
|
+
...(withdrawManagers !== undefined ? { withdrawManagers } : {}),
|
|
128
236
|
};
|
|
129
237
|
}
|
|
238
|
+
/* ================================================================================================
|
|
239
|
+
* Intra-v2 upgrade: normalize a v2 document written by an earlier SDK to the current v2 shape
|
|
240
|
+
* ============================================================================================== */
|
|
241
|
+
function isKeyFactGroup(value) {
|
|
242
|
+
return typeof value === 'object' && value !== null && value.type === 'keyFactGroup';
|
|
243
|
+
}
|
|
244
|
+
/** Converts an earlier-SDK flat KeyFact (`{ value?: string; valueRef?: 'apy' }`) to the discriminated shape. */
|
|
245
|
+
function upgradeKeyFact(old) {
|
|
246
|
+
const { value, valueRef, ...rest } = old;
|
|
247
|
+
let next;
|
|
248
|
+
if (valueRef === 'apy')
|
|
249
|
+
next = { kind: 'ref', ref: 'apy' };
|
|
250
|
+
else if (value !== null && typeof value === 'object' && 'kind' in value)
|
|
251
|
+
next = value;
|
|
252
|
+
else
|
|
253
|
+
next = { kind: 'text', text: typeof value === 'string' ? value : '' };
|
|
254
|
+
return { ...rest, value: next };
|
|
255
|
+
}
|
|
256
|
+
/** True if `item` is (or, for a tabGroup, contains) a chart still using the `series`/`dataRef` shape. */
|
|
257
|
+
function hasLegacyChart(item) {
|
|
258
|
+
if (typeof item !== 'object' || item === null)
|
|
259
|
+
return false;
|
|
260
|
+
const block = item;
|
|
261
|
+
if (block.type === 'chart') {
|
|
262
|
+
return block.data === undefined && (block.series !== undefined || block.dataRef !== undefined);
|
|
263
|
+
}
|
|
264
|
+
if (block.type === 'tabGroup' && Array.isArray(block.tabs)) {
|
|
265
|
+
return block.tabs.some((tab) => hasLegacyChart(tab?.block));
|
|
266
|
+
}
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
/** Converts an earlier-SDK chart block (`series?`/`dataRef?`) to the discriminated `data` field, in place. */
|
|
270
|
+
function upgradeChartBlockInPlace(block) {
|
|
271
|
+
if (block.type === 'chart' && block.data === undefined) {
|
|
272
|
+
if (Array.isArray(block.series))
|
|
273
|
+
block.data = { kind: 'inline', series: block.series };
|
|
274
|
+
else if (typeof block.dataRef === 'string')
|
|
275
|
+
block.data = { kind: 'ref', dataRef: block.dataRef };
|
|
276
|
+
delete block.series;
|
|
277
|
+
delete block.dataRef;
|
|
278
|
+
}
|
|
279
|
+
if (block.type === 'tabGroup' && Array.isArray(block.tabs)) {
|
|
280
|
+
for (const tab of block.tabs) {
|
|
281
|
+
const inner = tab?.block;
|
|
282
|
+
if (inner && typeof inner === 'object')
|
|
283
|
+
upgradeChartBlockInPlace(inner);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Normalizes a document already at `version: 2` but written by an earlier SDK whose v2 shape differs:
|
|
289
|
+
* a flat `keyFacts: KeyFact[]` (now `KeyFactGroup[]`), chart blocks with `series`/`dataRef` (now a
|
|
290
|
+
* discriminated `data`), and a top-level `holdings` blob (now a factsheet `table` section). Without
|
|
291
|
+
* this, `update()` would re-validate such a document against the current validator and throw, with no
|
|
292
|
+
* upgrade path. Returns the input unchanged (same reference) when it is already the current shape.
|
|
293
|
+
*/
|
|
294
|
+
function upgradeLegacyV2(doc) {
|
|
295
|
+
const raw = doc;
|
|
296
|
+
const pool = (raw.pool ?? {});
|
|
297
|
+
const factsheet = pool.factsheet;
|
|
298
|
+
const keyFacts = factsheet?.keyFacts;
|
|
299
|
+
const layoutItems = [...asArray(factsheet?.body), ...asArray(factsheet?.sections)];
|
|
300
|
+
const needsKeyFactUpgrade = Array.isArray(keyFacts) && keyFacts.some((item) => !isKeyFactGroup(item));
|
|
301
|
+
const needsHoldingsFold = raw.holdings != null;
|
|
302
|
+
const needsChartUpgrade = layoutItems.some(hasLegacyChart);
|
|
303
|
+
if (!needsKeyFactUpgrade && !needsHoldingsFold && !needsChartUpgrade)
|
|
304
|
+
return doc;
|
|
305
|
+
const next = JSON.parse(JSON.stringify(doc));
|
|
306
|
+
const nextPool = next.pool;
|
|
307
|
+
const nextFactsheet = nextPool.factsheet;
|
|
308
|
+
if (nextFactsheet) {
|
|
309
|
+
if (needsKeyFactUpgrade) {
|
|
310
|
+
// Wrap the flat list into one titled "Key facts" group, converting each item's value.
|
|
311
|
+
const items = nextFactsheet.keyFacts.map(upgradeKeyFact);
|
|
312
|
+
nextFactsheet.keyFacts = [{ type: 'keyFactGroup', id: 'key-facts', title: 'Key facts', items }];
|
|
313
|
+
}
|
|
314
|
+
if (needsChartUpgrade) {
|
|
315
|
+
for (const item of [
|
|
316
|
+
...asArray(nextFactsheet.body),
|
|
317
|
+
...asArray(nextFactsheet.sections),
|
|
318
|
+
]) {
|
|
319
|
+
if (item && typeof item === 'object')
|
|
320
|
+
upgradeChartBlockInPlace(item);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (needsHoldingsFold) {
|
|
325
|
+
const holdingsBlock = holdingsTableBlock(raw.holdings);
|
|
326
|
+
delete next.holdings;
|
|
327
|
+
if (holdingsBlock && nextFactsheet) {
|
|
328
|
+
nextFactsheet.sections = [...asArray(nextFactsheet.sections), holdingsBlock];
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return next;
|
|
332
|
+
}
|
|
130
333
|
/* ================================================================================================
|
|
131
334
|
* Validation: hand-rolled structural checks (mirrors src/utils/catalog.ts; no zod dependency)
|
|
132
335
|
* ============================================================================================== */
|
|
@@ -135,7 +338,9 @@ export function migratePoolMetadataToV2(legacy) {
|
|
|
135
338
|
// (see centrifuge/apps-invest#200), but the SDK is the alignment point: an unknown key is rejected
|
|
136
339
|
// on WRITE so the management app, SDK, and invest app stay in sync (adding a key ships in an SDK
|
|
137
340
|
// release). The read path does not validate, so a newer document never breaks an older reader.
|
|
138
|
-
const
|
|
341
|
+
const VISIBILITY_SCALARS = new Set(['public', 'hidden', 'whitelisted', 'geo-restricted']);
|
|
342
|
+
const VISIBILITY_GATES = new Set(['whitelisted', 'geo-restricted']);
|
|
343
|
+
const REGION_CODE_RE = /^[A-Za-z]{2}$/;
|
|
139
344
|
const CHART_TYPES = new Set(['line', 'area', 'bar', 'donut']);
|
|
140
345
|
const AXIS_FORMATS = new Set(['number', 'percent', 'currency']);
|
|
141
346
|
const XAXIS_TYPES = new Set(['category', 'time', 'number']);
|
|
@@ -143,8 +348,19 @@ const DATA_REFS = new Set(['apyVsBenchmarks', 'maturityDistribution']);
|
|
|
143
348
|
const SECTION_REFS = new Set(['onchainMetrics', 'smartContracts']);
|
|
144
349
|
const LIVE_METRICS = new Set(['tokenPrice', 'nav', 'apy30d', 'navChange', 'monthlyReturn']);
|
|
145
350
|
const LIVE_DATASETS = new Set(['monthlySummary']);
|
|
351
|
+
const KEY_FACT_REFS = new Set(['apy', 'availableNetworks', 'ratings']);
|
|
146
352
|
const COLUMN_FORMATS = new Set(['text', 'number', 'percent', 'currency']);
|
|
147
|
-
const CONTENT_BLOCK_TYPES = new Set([
|
|
353
|
+
const CONTENT_BLOCK_TYPES = new Set([
|
|
354
|
+
'text',
|
|
355
|
+
'table',
|
|
356
|
+
'chart',
|
|
357
|
+
'image',
|
|
358
|
+
'kpiGroup',
|
|
359
|
+
'tabGroup',
|
|
360
|
+
'liveTable',
|
|
361
|
+
'documents',
|
|
362
|
+
'accordion',
|
|
363
|
+
]);
|
|
148
364
|
const TAB_BLOCK_TYPES = new Set(['text', 'table', 'chart', 'kpiGroup']);
|
|
149
365
|
const KPI_TRENDS = new Set(['up', 'down', 'neutral']);
|
|
150
366
|
function fail(message) {
|
|
@@ -160,9 +376,27 @@ function assertString(value, where) {
|
|
|
160
376
|
function assertVisibility(value, where) {
|
|
161
377
|
if (value === undefined)
|
|
162
378
|
return;
|
|
163
|
-
if (typeof value
|
|
164
|
-
|
|
379
|
+
if (typeof value === 'string') {
|
|
380
|
+
if (!VISIBILITY_SCALARS.has(value))
|
|
381
|
+
fail(`${where} has invalid visibility "${value}"`);
|
|
382
|
+
return;
|
|
165
383
|
}
|
|
384
|
+
// An array combines gates with AND. `public`/`hidden` are scalars, never array members.
|
|
385
|
+
if (Array.isArray(value)) {
|
|
386
|
+
if (value.length === 0)
|
|
387
|
+
fail(`${where} visibility array must not be empty`);
|
|
388
|
+
const seen = new Set();
|
|
389
|
+
value.forEach((gate) => {
|
|
390
|
+
if (typeof gate !== 'string' || !VISIBILITY_GATES.has(gate)) {
|
|
391
|
+
fail(`${where} has an invalid visibility gate "${String(gate)}"`);
|
|
392
|
+
}
|
|
393
|
+
if (seen.has(gate))
|
|
394
|
+
fail(`${where} has a duplicate visibility gate "${gate}"`);
|
|
395
|
+
seen.add(gate);
|
|
396
|
+
});
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
fail(`${where} has invalid visibility "${String(value)}"`);
|
|
166
400
|
}
|
|
167
401
|
function isPrimitiveCell(value) {
|
|
168
402
|
return typeof value === 'string' || typeof value === 'number';
|
|
@@ -173,20 +407,117 @@ function validateFile(value, where) {
|
|
|
173
407
|
assertString(value.uri, `${where}.uri`);
|
|
174
408
|
assertString(value.mime, `${where}.mime`);
|
|
175
409
|
}
|
|
410
|
+
const BUILTIN_LINK_KEYS = new Set(['executiveSummary', 'website', 'forum']);
|
|
411
|
+
function validateLinkDocuments(documents) {
|
|
412
|
+
if (!Array.isArray(documents))
|
|
413
|
+
fail('`pool.links.documents` must be an array');
|
|
414
|
+
const seen = new Set();
|
|
415
|
+
documents.forEach((doc, index) => {
|
|
416
|
+
const where = `pool.links.documents[${index}]`;
|
|
417
|
+
if (!isObject(doc))
|
|
418
|
+
fail(`${where} must be an object`);
|
|
419
|
+
if (typeof doc.key !== 'string' || doc.key.length === 0)
|
|
420
|
+
fail(`${where}.key must be a non-empty string`);
|
|
421
|
+
if (typeof doc.label !== 'string' || doc.label.length === 0)
|
|
422
|
+
fail(`${where}.label must be a non-empty string`);
|
|
423
|
+
// Exactly one of file (object) or non-empty href. A null file counts as absent.
|
|
424
|
+
const hasFile = isObject(doc.file);
|
|
425
|
+
const hasHref = typeof doc.href === 'string' && doc.href.length > 0;
|
|
426
|
+
if (hasFile === hasHref)
|
|
427
|
+
fail(`${where} must set exactly one of \`file\` or \`href\``);
|
|
428
|
+
if (hasFile)
|
|
429
|
+
validateFile(doc.file, `${where}.file`);
|
|
430
|
+
// Built-ins resolve first, so a colliding key would be ambiguous (unreachable).
|
|
431
|
+
if (BUILTIN_LINK_KEYS.has(doc.key))
|
|
432
|
+
fail(`${where}.key "${doc.key}" collides with a built-in link key`);
|
|
433
|
+
if (seen.has(doc.key))
|
|
434
|
+
fail(`${where}.key "${doc.key}" is duplicated`);
|
|
435
|
+
seen.add(doc.key);
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
function validateLinkTarget(value, where) {
|
|
439
|
+
if (!isObject(value))
|
|
440
|
+
fail(`${where} must be an object`);
|
|
441
|
+
switch (value.kind) {
|
|
442
|
+
case 'file':
|
|
443
|
+
validateFile(value.file, `${where}.file`);
|
|
444
|
+
break;
|
|
445
|
+
case 'href':
|
|
446
|
+
assertString(value.href, `${where}.href`);
|
|
447
|
+
break;
|
|
448
|
+
case 'linkRef':
|
|
449
|
+
// Resolved app-side against the typed `pool.links`; an unknown key hides the link (lenient).
|
|
450
|
+
assertString(value.linkRef, `${where}.linkRef`);
|
|
451
|
+
break;
|
|
452
|
+
default:
|
|
453
|
+
fail(`${where} has an invalid kind "${String(value.kind)}"`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function validateIconRef(value, where) {
|
|
457
|
+
if (!isObject(value))
|
|
458
|
+
fail(`${where} must be an object`);
|
|
459
|
+
if (value.label !== undefined)
|
|
460
|
+
assertString(value.label, `${where}.label`);
|
|
461
|
+
switch (value.source) {
|
|
462
|
+
case 'metadata':
|
|
463
|
+
validateFile(value.file, `${where}.file`);
|
|
464
|
+
if (value.alt !== undefined)
|
|
465
|
+
assertString(value.alt, `${where}.alt`);
|
|
466
|
+
break;
|
|
467
|
+
case 'app':
|
|
468
|
+
// The app-owned icon-key registry is not yet enumerated, so validate shape (string) only.
|
|
469
|
+
assertString(value.key, `${where}.key`);
|
|
470
|
+
break;
|
|
471
|
+
default:
|
|
472
|
+
fail(`${where} has an invalid source "${String(value.source)}"`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function validateKeyFactValue(value, where) {
|
|
476
|
+
if (!isObject(value))
|
|
477
|
+
fail(`${where} must be an object`);
|
|
478
|
+
switch (value.kind) {
|
|
479
|
+
case 'text':
|
|
480
|
+
assertString(value.text, `${where}.text`);
|
|
481
|
+
break;
|
|
482
|
+
case 'icons':
|
|
483
|
+
if (!Array.isArray(value.icons))
|
|
484
|
+
fail(`${where}.icons must be an array`);
|
|
485
|
+
value.icons.forEach((icon, index) => validateIconRef(icon, `${where}.icons[${index}]`));
|
|
486
|
+
break;
|
|
487
|
+
case 'ref':
|
|
488
|
+
// Closed, SDK-enforced registry (extended via a coordinated SDK release).
|
|
489
|
+
if (typeof value.ref !== 'string' || !KEY_FACT_REFS.has(value.ref)) {
|
|
490
|
+
fail(`${where}.ref is invalid (unknown ref "${String(value.ref)}")`);
|
|
491
|
+
}
|
|
492
|
+
break;
|
|
493
|
+
default:
|
|
494
|
+
fail(`${where} has an invalid kind "${String(value.kind)}"`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
176
497
|
function validateKeyFact(value, where) {
|
|
177
498
|
if (!isObject(value))
|
|
178
499
|
fail(`${where} must be an object`);
|
|
179
500
|
assertString(value.label, `${where}.label`);
|
|
180
|
-
|
|
181
|
-
assertString(value.value, `${where}.value`);
|
|
182
|
-
if (value.valueRef !== undefined && value.valueRef !== 'apy')
|
|
183
|
-
fail(`${where}.valueRef must be 'apy'`);
|
|
501
|
+
validateKeyFactValue(value.value, `${where}.value`);
|
|
184
502
|
if (value.tooltip !== undefined)
|
|
185
503
|
assertString(value.tooltip, `${where}.tooltip`);
|
|
186
504
|
if (value.href !== undefined)
|
|
187
505
|
assertString(value.href, `${where}.href`);
|
|
188
506
|
assertVisibility(value.visibility, where);
|
|
189
507
|
}
|
|
508
|
+
function validateKeyFactGroup(value, where) {
|
|
509
|
+
if (!isObject(value))
|
|
510
|
+
fail(`${where} must be an object`);
|
|
511
|
+
if (value.type !== 'keyFactGroup')
|
|
512
|
+
fail(`${where}.type must be 'keyFactGroup'`);
|
|
513
|
+
assertString(value.id, `${where}.id`);
|
|
514
|
+
if (value.title !== undefined)
|
|
515
|
+
assertString(value.title, `${where}.title`);
|
|
516
|
+
assertVisibility(value.visibility, where);
|
|
517
|
+
if (!Array.isArray(value.items))
|
|
518
|
+
fail(`${where}.items must be an array`);
|
|
519
|
+
value.items.forEach((item, index) => validateKeyFact(item, `${where}.items[${index}]`));
|
|
520
|
+
}
|
|
190
521
|
function validateChartSeries(value, where) {
|
|
191
522
|
if (!isObject(value))
|
|
192
523
|
fail(`${where} must be an object`);
|
|
@@ -235,6 +566,21 @@ function validateContentBlock(block, type, where) {
|
|
|
235
566
|
switch (type) {
|
|
236
567
|
case 'text':
|
|
237
568
|
assertString(block.body, `${where}.body`);
|
|
569
|
+
// Optional Overview-card extras.
|
|
570
|
+
if (block.logo !== undefined)
|
|
571
|
+
validateFile(block.logo, `${where}.logo`);
|
|
572
|
+
if (block.background !== undefined)
|
|
573
|
+
assertString(block.background, `${where}.background`);
|
|
574
|
+
if (block.links !== undefined) {
|
|
575
|
+
if (!Array.isArray(block.links))
|
|
576
|
+
fail(`${where}.links must be an array`);
|
|
577
|
+
block.links.forEach((link, index) => {
|
|
578
|
+
if (!isObject(link))
|
|
579
|
+
fail(`${where}.links[${index}] must be an object`);
|
|
580
|
+
assertString(link.label, `${where}.links[${index}].label`);
|
|
581
|
+
validateLinkTarget(link.target, `${where}.links[${index}].target`);
|
|
582
|
+
});
|
|
583
|
+
}
|
|
238
584
|
break;
|
|
239
585
|
case 'table': {
|
|
240
586
|
if (!Array.isArray(block.headers) || !block.headers.every((h) => typeof h === 'string')) {
|
|
@@ -257,17 +603,23 @@ function validateContentBlock(block, type, where) {
|
|
|
257
603
|
if (typeof block.chartType !== 'string' || !CHART_TYPES.has(block.chartType)) {
|
|
258
604
|
fail(`${where}.chartType is invalid`);
|
|
259
605
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
606
|
+
// Data source is a single discriminated `data` (inline series XOR app/indexer dataRef).
|
|
607
|
+
if (!isObject(block.data))
|
|
608
|
+
fail(`${where}.data must be an object`);
|
|
609
|
+
switch (block.data.kind) {
|
|
610
|
+
case 'inline':
|
|
611
|
+
if (!Array.isArray(block.data.series))
|
|
612
|
+
fail(`${where}.data.series must be an array`);
|
|
613
|
+
block.data.series.forEach((s, index) => validateChartSeries(s, `${where}.data.series[${index}]`));
|
|
614
|
+
break;
|
|
615
|
+
case 'ref':
|
|
616
|
+
// Closed, SDK-enforced registry (extended via a coordinated SDK release).
|
|
617
|
+
if (typeof block.data.dataRef !== 'string' || !DATA_REFS.has(block.data.dataRef)) {
|
|
618
|
+
fail(`${where}.data.dataRef is invalid (unknown key "${String(block.data.dataRef)}")`);
|
|
619
|
+
}
|
|
620
|
+
break;
|
|
621
|
+
default:
|
|
622
|
+
fail(`${where}.data has an invalid kind "${String(block.data.kind)}"`);
|
|
271
623
|
}
|
|
272
624
|
if (isObject(block.xAxis) && block.xAxis.type !== undefined && !XAXIS_TYPES.has(block.xAxis.type)) {
|
|
273
625
|
fail(`${where}.xAxis.type is invalid`);
|
|
@@ -307,14 +659,7 @@ function validateContentBlock(block, type, where) {
|
|
|
307
659
|
if (!isObject(tab))
|
|
308
660
|
fail(`${where}.tabs[${index}] must be an object`);
|
|
309
661
|
assertString(tab.label, `${where}.tabs[${index}].label`);
|
|
310
|
-
|
|
311
|
-
fail(`${where}.tabs[${index}].block must be an object`);
|
|
312
|
-
const tabBlock = tab.block;
|
|
313
|
-
if (typeof tabBlock.type !== 'string' || !TAB_BLOCK_TYPES.has(tabBlock.type)) {
|
|
314
|
-
fail(`${where}.tabs[${index}].block has an invalid type "${String(tabBlock.type)}"`);
|
|
315
|
-
}
|
|
316
|
-
assertString(tabBlock.id, `${where}.tabs[${index}].block.id`);
|
|
317
|
-
validateContentBlock(tabBlock, tabBlock.type, `${where}.tabs[${index}].block`);
|
|
662
|
+
validateTabBlock(tab.block, `${where}.tabs[${index}].block`);
|
|
318
663
|
});
|
|
319
664
|
break;
|
|
320
665
|
}
|
|
@@ -327,10 +672,45 @@ function validateContentBlock(block, type, where) {
|
|
|
327
672
|
block.columns.forEach((column, index) => validateLiveColumn(column, `${where}.columns[${index}]`));
|
|
328
673
|
break;
|
|
329
674
|
}
|
|
675
|
+
case 'documents': {
|
|
676
|
+
if (!Array.isArray(block.items))
|
|
677
|
+
fail(`${where}.items must be an array`);
|
|
678
|
+
block.items.forEach((item, index) => {
|
|
679
|
+
if (!isObject(item))
|
|
680
|
+
fail(`${where}.items[${index}] must be an object`);
|
|
681
|
+
assertString(item.title, `${where}.items[${index}].title`);
|
|
682
|
+
validateLinkTarget(item.target, `${where}.items[${index}].target`);
|
|
683
|
+
});
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
case 'accordion': {
|
|
687
|
+
if (!Array.isArray(block.items))
|
|
688
|
+
fail(`${where}.items must be an array`);
|
|
689
|
+
block.items.forEach((item, index) => {
|
|
690
|
+
if (!isObject(item))
|
|
691
|
+
fail(`${where}.items[${index}] must be an object`);
|
|
692
|
+
assertString(item.title, `${where}.items[${index}].title`);
|
|
693
|
+
if (item.defaultOpen !== undefined && typeof item.defaultOpen !== 'boolean') {
|
|
694
|
+
fail(`${where}.items[${index}].defaultOpen must be a boolean`);
|
|
695
|
+
}
|
|
696
|
+
validateTabBlock(item.block, `${where}.items[${index}].block`);
|
|
697
|
+
});
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
330
700
|
default:
|
|
331
701
|
fail(`${where} has an unknown block type "${type}"`);
|
|
332
702
|
}
|
|
333
703
|
}
|
|
704
|
+
/** Validates a leaf block nested in a `tabGroup` tab or `accordion` item (the {@link TabBlock} set). */
|
|
705
|
+
function validateTabBlock(value, where) {
|
|
706
|
+
if (!isObject(value))
|
|
707
|
+
fail(`${where} must be an object`);
|
|
708
|
+
if (typeof value.type !== 'string' || !TAB_BLOCK_TYPES.has(value.type)) {
|
|
709
|
+
fail(`${where} has an invalid type "${String(value.type)}"`);
|
|
710
|
+
}
|
|
711
|
+
assertString(value.id, `${where}.id`);
|
|
712
|
+
validateContentBlock(value, value.type, where);
|
|
713
|
+
}
|
|
334
714
|
function validateLayoutItem(item, where) {
|
|
335
715
|
if (!isObject(item))
|
|
336
716
|
fail(`${where} must be an object`);
|
|
@@ -357,7 +737,7 @@ function validateFactsheet(factsheet) {
|
|
|
357
737
|
factsheet.body.forEach((item, index) => validateLayoutItem(item, `pool.factsheet.body[${index}]`));
|
|
358
738
|
if (!Array.isArray(factsheet.keyFacts))
|
|
359
739
|
fail('`pool.factsheet.keyFacts` must be an array');
|
|
360
|
-
factsheet.keyFacts.forEach((
|
|
740
|
+
factsheet.keyFacts.forEach((group, index) => validateKeyFactGroup(group, `pool.factsheet.keyFacts[${index}]`));
|
|
361
741
|
if (factsheet.sections !== undefined) {
|
|
362
742
|
if (!Array.isArray(factsheet.sections))
|
|
363
743
|
fail('`pool.factsheet.sections` must be an array');
|
|
@@ -368,7 +748,7 @@ function validateFactsheet(factsheet) {
|
|
|
368
748
|
* Hand-rolled structural validator for v2 pool metadata, in the style of
|
|
369
749
|
* {@link parseMarketplaceCatalog}. Validates the discriminant (`version === 2`), `pool.name`, and
|
|
370
750
|
* the full `pool.factsheet` content model (the layout the SDK is responsible for). Engine fields
|
|
371
|
-
* (`shareClasses`, `workflowPolicies`, `
|
|
751
|
+
* (`shareClasses`, `workflowPolicies`, `addressLabels`, …) are passed through
|
|
372
752
|
* unchecked here — they are validated where they are consumed (e.g. workflows via
|
|
373
753
|
* {@link parseMarketplaceCatalog}). Throws `pool metadata v2: …` on the first malformed field;
|
|
374
754
|
* tolerates missing optionals (visibility defaults to `'public'` app-side). Returns the input
|
|
@@ -382,8 +762,58 @@ export function parsePoolMetadataV2(raw) {
|
|
|
382
762
|
if (!isObject(raw.pool))
|
|
383
763
|
fail('`pool` must be an object');
|
|
384
764
|
assertString(raw.pool.name, '`pool.name`');
|
|
765
|
+
if (raw.pool.geoRestrictions !== undefined)
|
|
766
|
+
validateGeoRestrictions(raw.pool.geoRestrictions);
|
|
767
|
+
if (isObject(raw.pool.links) && raw.pool.links.documents !== undefined) {
|
|
768
|
+
validateLinkDocuments(raw.pool.links.documents);
|
|
769
|
+
}
|
|
385
770
|
if (raw.pool.factsheet !== undefined)
|
|
386
771
|
validateFactsheet(raw.pool.factsheet);
|
|
387
772
|
return raw;
|
|
388
773
|
}
|
|
774
|
+
/**
|
|
775
|
+
* Resolves a {@link LinkTarget} to a `{ href }` or `{ file }` against a pool's `links`. `linkRef`
|
|
776
|
+
* checks the built-in keys first (`executiveSummary` -> file, `website`/`forum` -> url), then
|
|
777
|
+
* `links.documents` by `key`. Returns `null` on no match (callers self-blank; never throw). Shared
|
|
778
|
+
* so the SDK and the invest app resolve references identically.
|
|
779
|
+
*/
|
|
780
|
+
export function resolveLinkTarget(links, target) {
|
|
781
|
+
switch (target.kind) {
|
|
782
|
+
case 'file':
|
|
783
|
+
return { file: target.file };
|
|
784
|
+
case 'href':
|
|
785
|
+
return { href: target.href };
|
|
786
|
+
case 'linkRef': {
|
|
787
|
+
const key = target.linkRef;
|
|
788
|
+
if (key === 'executiveSummary')
|
|
789
|
+
return links?.executiveSummary ? { file: links.executiveSummary } : null;
|
|
790
|
+
if (key === 'website')
|
|
791
|
+
return links?.website ? { href: links.website } : null;
|
|
792
|
+
if (key === 'forum')
|
|
793
|
+
return links?.forum ? { href: links.forum } : null;
|
|
794
|
+
const doc = links?.documents?.find((d) => d.key === key);
|
|
795
|
+
if (!doc)
|
|
796
|
+
return null;
|
|
797
|
+
if (doc.file)
|
|
798
|
+
return { file: doc.file };
|
|
799
|
+
if (doc.href)
|
|
800
|
+
return { href: doc.href };
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
default:
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
/** Validates `pool.geoRestrictions.regions` as ISO 3166-1 alpha-2 codes (format only, strict on write). */
|
|
808
|
+
function validateGeoRestrictions(geoRestrictions) {
|
|
809
|
+
if (!isObject(geoRestrictions))
|
|
810
|
+
fail('`pool.geoRestrictions` must be an object');
|
|
811
|
+
if (!Array.isArray(geoRestrictions.regions))
|
|
812
|
+
fail('`pool.geoRestrictions.regions` must be an array');
|
|
813
|
+
geoRestrictions.regions.forEach((region, index) => {
|
|
814
|
+
if (typeof region !== 'string' || !REGION_CODE_RE.test(region)) {
|
|
815
|
+
fail(`pool.geoRestrictions.regions[${index}] must be an ISO 3166-1 alpha-2 code, got "${String(region)}"`);
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
}
|
|
389
819
|
//# sourceMappingURL=poolMetadataMigration.js.map
|