@fairmint/canton-node-sdk 0.0.269 → 0.0.271

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.
@@ -0,0 +1,1129 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DA_REGISTRY_SETUP_MINT_REFERENCE_PREFIX = void 0;
4
+ exports.desiredDaRegistryInstrumentConfiguration = desiredDaRegistryInstrumentConfiguration;
5
+ exports.buildDaRegistrySetupSteps = buildDaRegistrySetupSteps;
6
+ const choice_context_1 = require("../choice-context");
7
+ const commands_1 = require("../commands");
8
+ const readers_1 = require("../readers");
9
+ const templates_1 = require("../templates");
10
+ const decimal_1 = require("./decimal");
11
+ const types_1 = require("./types");
12
+ const REGISTRY_TEMPLATE_KEYS = [
13
+ 'providerServiceRequest',
14
+ 'providerService',
15
+ 'rejectedProviderServiceRequest',
16
+ 'providerConfiguration',
17
+ 'registrarServiceRequest',
18
+ 'registrarService',
19
+ 'rejectedRegistrarServiceRequest',
20
+ 'allocationFactory',
21
+ 'transferRule',
22
+ 'instrumentConfiguration',
23
+ 'credential',
24
+ // The registrar signs every holding, so its view is the whole register.
25
+ 'holding',
26
+ 'mintOffer',
27
+ ];
28
+ /** Mint offers the setup creates carry this prefix in `reference`; it never touches anyone else's. */
29
+ exports.DA_REGISTRY_SETUP_MINT_REFERENCE_PREFIX = 'da-registry-setup';
30
+ /**
31
+ * Every read of DA's templates first checks what it depends on, so the reading step is blocked on the earlier step
32
+ * instead of printing commands for a state it cannot see:
33
+ *
34
+ * - Reads keep only contracts on the pinned synchronizer, so with a stale pin the registrar's contracts would look absent
35
+ * and every step would plan to recreate them; until the registrar is connected to it, the step is blocked on
36
+ * ledger-access.
37
+ * - The ledger rejects a template filter for a package it does not have; until the pinned packages are on the registrar's
38
+ * node, the step is blocked on da-packages.
39
+ */
40
+ async function requireReadableRegistry(ctx) {
41
+ const { registrar, ledger } = ctx.config;
42
+ const connected = await ctx.ledger.connectedSynchronizerIds(registrar.party);
43
+ if (!connected.includes(ledger.synchronizerId)) {
44
+ throw new types_1.DaRegistrySetupDependencyError('ledger-access', `the registrar is not connected to ${ledger.synchronizerId}`);
45
+ }
46
+ const onNode = await ctx.ledger.listPackageIds();
47
+ const missing = ctx.config.da.packages.required.filter((p) => !onNode.includes(p.packageId));
48
+ if (missing.length > 0) {
49
+ throw new types_1.DaRegistrySetupDependencyError('da-packages', `missing on the registrar's node: ${missing.map((p) => p.name).join(', ')}`);
50
+ }
51
+ }
52
+ /**
53
+ * The registrar's view of the registry. Holdings are the whole register, which grows with every holder, so only the
54
+ * steps that count them (supply, deliveries) read them; for the others `byTemplate('holding')` is empty.
55
+ */
56
+ async function loadRegistryState(ctx, { holdings = false } = {}) {
57
+ await requireReadableRegistry(ctx);
58
+ const keys = REGISTRY_TEMPLATE_KEYS.filter((k) => holdings || k !== 'holding');
59
+ const contracts = await ctx.ledger.activeContracts([ctx.config.registrar.party], keys.map((k) => templates_1.DA_REGISTRY_TEMPLATES[k]));
60
+ return { byTemplate: (key) => contracts.filter((c) => (0, templates_1.isDaRegistryTemplate)(c, key)) };
61
+ }
62
+ function field(contract, name) {
63
+ return contract.createArgument[name];
64
+ }
65
+ function cids(contracts) {
66
+ return contracts.map((c) => c.contractId).join(', ');
67
+ }
68
+ function errorMessage(error) {
69
+ return error instanceof Error ? error.message : String(error);
70
+ }
71
+ function submission(ctx, stepId, actAs, commands) {
72
+ return { commandIdPrefix: `da-registry-${ctx.config.network}-${stepId}`, actAs: [actAs], commands };
73
+ }
74
+ function blocked(summary, notes = []) {
75
+ return { status: 'blocked', summary, notes };
76
+ }
77
+ /** Blocked only until an earlier step is done. */
78
+ function needs(stepId, detail) {
79
+ return { status: 'blocked', summary: `needs ${stepId}${detail ? `: ${detail}` : ''}`, dependency: true };
80
+ }
81
+ /** The contracts the registrar is party to as both provider and registrar, under the configured DA operator. */
82
+ function ownedByRegistrar(ctx, contracts, roles) {
83
+ const { party: registrar } = ctx.config.registrar;
84
+ return contracts.filter((c) => field(c, 'operator') === ctx.config.da.operatorParty && roles.every((role) => field(c, role) === registrar));
85
+ }
86
+ // ── 1. Ledger access ────────────────────────────────────────────────────────────────────────────────────────────────
87
+ function ledgerAccessStep(ctx) {
88
+ const { config } = ctx;
89
+ const ledgerUser = config.ledger.name ? `the ${config.ledger.name} ledger user` : 'the ledger user';
90
+ return {
91
+ id: 'ledger-access',
92
+ title: 'The ledger user can act as the registrar on the configured synchronizer, under the configured DA operator',
93
+ why: [
94
+ `The registrar (${config.registrar.party}) signs every registry contract; ${ledgerUser}`,
95
+ 'must hold CanActAs for it and for every preapproval receiver in the config. DA publishes its operator',
96
+ 'party per network; a network reset changes it, and every contract names it.',
97
+ ].join('\n'),
98
+ async assess() {
99
+ const actAsParties = [...new Set([config.registrar.party, ...config.preapprovals.map((p) => p.party)])];
100
+ const [{ userId, missing }, connections, operator] = await Promise.all([
101
+ ctx.ledger.checkActAs(actAsParties),
102
+ Promise.all(actAsParties.map(async (party) => ctx.ledger.connectedSynchronizerIds(party))),
103
+ ctx.da.operatorParty(),
104
+ ]);
105
+ const notes = [];
106
+ if (missing.length > 0)
107
+ notes.push(`user ${userId} lacks CanActAs for: ${missing.join(', ')}`);
108
+ actAsParties.forEach((party, i) => {
109
+ const connected = connections[i] ?? [];
110
+ if (!connected.includes(config.ledger.synchronizerId)) {
111
+ notes.push(`${party} is not connected to ${config.ledger.synchronizerId} (connected: ${connected.join(', ')})`);
112
+ }
113
+ });
114
+ if (operator !== config.da.operatorParty) {
115
+ notes.push(`DA's API reports operator ${operator}, but the config pins ${config.da.operatorParty}`);
116
+ }
117
+ const evidence = {
118
+ ledgerUser: userId,
119
+ synchronizer: config.ledger.synchronizerId,
120
+ daOperator: operator,
121
+ };
122
+ if (notes.length > 0)
123
+ return { status: 'blocked', summary: 'fix access or the config first', evidence, notes };
124
+ return { status: 'done', summary: 'access, synchronizer and DA operator match the config', evidence };
125
+ },
126
+ };
127
+ }
128
+ // ── 2. DA packages ──────────────────────────────────────────────────────────────────────────────────────────────────
129
+ function packagesStep(ctx) {
130
+ const { config } = ctx;
131
+ const { packages } = config.da;
132
+ const pins = packages.required.map((p) => `${p.name} ${p.version}`).join(', ');
133
+ return {
134
+ id: 'da-packages',
135
+ title: "DA's registry packages are on the registrar's node and vetted by the registrar's node and DA's",
136
+ why: [
137
+ `Every registry transaction needs DA's packages vetted by both confirming nodes. The config pins ${pins}.`,
138
+ 'Queued swaps (Token Standard V2) need the 0.14 bundle. The node operator uploads and vets DA’s packages;',
139
+ 'this setup only checks them and never uploads a DAR.',
140
+ ].join('\n'),
141
+ async assess() {
142
+ const onNode = await ctx.ledger.listPackageIds();
143
+ const evidence = {};
144
+ const missing = packages.required.filter((p) => !onNode.includes(p.packageId));
145
+ for (const req of packages.required) {
146
+ evidence[req.name] =
147
+ `${req.version} ${onNode.includes(req.packageId) ? 'on the registrar’s node' : 'missing on the registrar’s node'}`;
148
+ }
149
+ if (missing.length > 0) {
150
+ return {
151
+ ...blocked('packages missing on the registrar’s node; ask the node operator to upload DA’s bundle', [
152
+ `missing: ${missing.map((p) => `${p.name} ${p.version} (${p.packageId.slice(0, 12)}…)`).join(', ')}`,
153
+ ]),
154
+ evidence,
155
+ };
156
+ }
157
+ // On the registrar's node; now both nodes must vet exactly the pinned ids, which every submission prefers.
158
+ let resolved;
159
+ try {
160
+ resolved = await ctx.ledger.preferredPackages([config.registrar.party, config.da.operatorParty], packages.required.map((p) => p.name), config.ledger.synchronizerId);
161
+ }
162
+ catch (error) {
163
+ return {
164
+ ...blocked('the registrar’s node and DA’s do not both vet the packages', [errorMessage(error)]),
165
+ evidence,
166
+ };
167
+ }
168
+ const mismatches = packages.required.filter((req) => resolved.find((r) => r.packageName === req.name)?.packageId !== req.packageId);
169
+ for (const req of packages.required) {
170
+ const got = resolved.find((r) => r.packageName === req.name);
171
+ if (got)
172
+ evidence[req.name] = `${got.packageVersion} vetted by both (${got.packageId.slice(0, 12)}…)`;
173
+ }
174
+ if (mismatches.length > 0) {
175
+ return {
176
+ ...blocked('the packages both nodes vet differ from the config’s pins', [
177
+ ...mismatches.map((req) => {
178
+ const got = resolved.find((r) => r.packageName === req.name);
179
+ return `${req.name}: both vet ${got ? `${got.packageVersion} (${got.packageId.slice(0, 12)}…)` : 'nothing'}, config pins ${req.version}`;
180
+ }),
181
+ 'if DA’s node lacks the pinned version, ask DA; if both vet another bundle, upgrade @fairmint/canton-node-sdk to a release whose command builders target it, then update the pins in the config',
182
+ ]),
183
+ evidence,
184
+ };
185
+ }
186
+ return { status: 'done', summary: 'the pinned packages are vetted by both nodes', evidence };
187
+ },
188
+ };
189
+ }
190
+ // ── 3. Provider service ─────────────────────────────────────────────────────────────────────────────────────────────
191
+ function providerServiceStep(ctx) {
192
+ const { config } = ctx;
193
+ const registrar = config.registrar.party;
194
+ const operator = config.da.operatorParty;
195
+ return {
196
+ id: 'provider-service',
197
+ title: 'The registrar is a DA provider',
198
+ why: [
199
+ 'DA licenses providers to run a registry; a registrar needs a provider. The registrar requests the service and',
200
+ 'DA’s operator accepts it: automatically on DevNet, after checking the commercial agreement and reward sharing',
201
+ 'on TestNet and MainNet.',
202
+ ].join('\n'),
203
+ awaitMs: config.da.providerApprovalTimeoutMinutes * 60000,
204
+ async assess() {
205
+ const state = await loadRegistryState(ctx);
206
+ const services = ownedByRegistrar(ctx, state.byTemplate('providerService'), ['provider']);
207
+ if (services.length > 0) {
208
+ return { status: 'done', summary: 'provider service exists', evidence: { providerService: cids(services) } };
209
+ }
210
+ const requests = ownedByRegistrar(ctx, state.byTemplate('providerServiceRequest'), ['provider']);
211
+ if (requests.length > 0) {
212
+ return {
213
+ status: 'waiting',
214
+ summary: 'waiting for DA’s operator to accept the provider request',
215
+ evidence: { providerServiceRequest: cids(requests) },
216
+ };
217
+ }
218
+ const rejected = state.byTemplate('rejectedProviderServiceRequest').filter((c) => {
219
+ const request = field(c, 'request');
220
+ return request?.provider === registrar && request.operator === operator;
221
+ });
222
+ if (rejected.length > 0) {
223
+ // The rejection is co-signed by DA's operator, so the registrar cannot clear it; a person takes it up with DA.
224
+ const reasons = rejected.map((c) => String(field(c, 'reason')));
225
+ return blocked('DA rejected the provider request', [
226
+ `reason: ${reasons.join(' | ')}`,
227
+ `cid: ${cids(rejected)}`,
228
+ ]);
229
+ }
230
+ return {
231
+ status: 'pending',
232
+ summary: 'no provider service or request',
233
+ actions: [
234
+ {
235
+ description: 'request the provider service from DA’s operator',
236
+ submission: submission(ctx, 'provider-service', registrar, [(0, commands_1.requestProviderService)(operator, registrar)]),
237
+ },
238
+ ],
239
+ };
240
+ },
241
+ };
242
+ }
243
+ // ── 4. Provider configuration ───────────────────────────────────────────────────────────────────────────────────────
244
+ /** A provider configuration that lets the registrar accept its own registrar request with no credential. */
245
+ function hasNoRequirements(c) {
246
+ return ((0, readers_1.readRequirements)(field(c, 'registrarRequirements')).length === 0 &&
247
+ (0, readers_1.readRequirements)(field(c, 'holderRequirements')).length === 0);
248
+ }
249
+ function providerConfigurationStep(ctx) {
250
+ const registrar = ctx.config.registrar.party;
251
+ return {
252
+ id: 'provider-configuration',
253
+ title: 'The registrar’s provider configuration requires no registrar credential',
254
+ why: 'Accepting a registrar reads the provider configuration. The registrar serves only itself, so the lists are empty.',
255
+ async assess() {
256
+ const state = await loadRegistryState(ctx);
257
+ const [service] = ownedByRegistrar(ctx, state.byTemplate('providerService'), ['provider']);
258
+ if (!service)
259
+ return needs('provider-service');
260
+ const configs = ownedByRegistrar(ctx, state.byTemplate('providerConfiguration'), ['provider']);
261
+ const matching = configs.filter(hasNoRequirements);
262
+ if (matching.length > 0) {
263
+ return { status: 'done', summary: 'configuration exists', evidence: { providerConfiguration: cids(matching) } };
264
+ }
265
+ const [current] = configs;
266
+ const command = current
267
+ ? (0, commands_1.archiveAndCreateProviderConfiguration)(service.contractId, current.contractId)
268
+ : (0, commands_1.createProviderConfiguration)(service.contractId);
269
+ return {
270
+ status: 'pending',
271
+ summary: current ? 'configuration has requirements; replace it' : 'no configuration',
272
+ actions: [
273
+ {
274
+ description: current ? 'replace the provider configuration' : 'create the provider configuration',
275
+ submission: submission(ctx, 'provider-configuration', registrar, [command]),
276
+ },
277
+ ],
278
+ };
279
+ },
280
+ };
281
+ }
282
+ // ── 5. Registrar service ────────────────────────────────────────────────────────────────────────────────────────────
283
+ function registrarServiceStep(ctx) {
284
+ const { config } = ctx;
285
+ const registrar = config.registrar.party;
286
+ return {
287
+ id: 'registrar-service',
288
+ title: 'The registrar holds its own registrar service, with a transfer rule and an allocation factory',
289
+ why: [
290
+ 'The registrar defines instruments, mints, burns and issues credentials. Accepting the request also creates',
291
+ 'the transfer rule every transfer runs through and the allocation factory mints and allocations use. The',
292
+ 'registrar requests and accepts in one transaction, as registrar and as provider.',
293
+ ].join('\n'),
294
+ async assess() {
295
+ const state = await loadRegistryState(ctx);
296
+ const providerConfig = ownedByRegistrar(ctx, state.byTemplate('providerConfiguration'), ['provider']).find(hasNoRequirements);
297
+ const services = ownedByRegistrar(ctx, state.byTemplate('registrarService'), ['provider', 'registrar']);
298
+ const rules = ownedByRegistrar(ctx, state.byTemplate('transferRule'), ['provider', 'registrar']);
299
+ const factories = ownedByRegistrar(ctx, state.byTemplate('allocationFactory'), ['provider', 'registrar']);
300
+ const [service] = services;
301
+ if (service) {
302
+ const evidence = {
303
+ registrarService: cids(services),
304
+ transferRule: rules.length > 0 ? cids(rules) : 'missing',
305
+ allocationFactory: factories.length > 0 ? cids(factories) : 'missing',
306
+ };
307
+ const commands = [];
308
+ if (rules.length === 0)
309
+ commands.push((0, commands_1.createTransferRule)(service.contractId));
310
+ if (factories.length === 0)
311
+ commands.push((0, commands_1.createAllocationFactory)(service.contractId));
312
+ if (commands.length === 0)
313
+ return { status: 'done', summary: 'registrar service is complete', evidence };
314
+ return {
315
+ status: 'pending',
316
+ summary: 'registrar service lacks its rule or factory',
317
+ evidence,
318
+ actions: [
319
+ {
320
+ description: 'create the missing transfer rule / allocation factory',
321
+ submission: submission(ctx, 'registrar-service', registrar, commands),
322
+ },
323
+ ],
324
+ };
325
+ }
326
+ if (!providerConfig)
327
+ return needs('provider-configuration');
328
+ const commands = [];
329
+ for (const rejected of state.byTemplate('rejectedRegistrarServiceRequest')) {
330
+ // A rejected request keeps the original request's fields under `request`; only its provider may delete it.
331
+ const request = field(rejected, 'request');
332
+ if (request?.registrar === registrar &&
333
+ request.provider === registrar &&
334
+ request.operator === config.da.operatorParty) {
335
+ commands.push((0, commands_1.deleteRejectedRegistrarServiceRequest)(rejected.contractId));
336
+ }
337
+ }
338
+ const [pending] = ownedByRegistrar(ctx, state.byTemplate('registrarServiceRequest'), ['provider', 'registrar']);
339
+ commands.push(pending
340
+ ? (0, commands_1.acceptRegistrarServiceRequest)(pending.contractId, providerConfig.contractId)
341
+ : (0, commands_1.requestAndAcceptRegistrarService)(config.da.operatorParty, registrar, providerConfig.contractId));
342
+ return {
343
+ status: 'pending',
344
+ summary: pending ? 'registrar request is pending; accept it' : 'no registrar service',
345
+ actions: [
346
+ {
347
+ description: 'request the registrar service and accept it as provider',
348
+ submission: submission(ctx, 'registrar-service', registrar, commands),
349
+ },
350
+ ],
351
+ };
352
+ },
353
+ };
354
+ }
355
+ // ── 6. Instrument configuration ─────────────────────────────────────────────────────────────────────────────────────
356
+ /** The instrument configuration the declared state requires: the registrar's two claims, and the blocklist setting. */
357
+ function desiredDaRegistryInstrumentConfiguration(config) {
358
+ const registrar = config.registrar.party;
359
+ return {
360
+ instrumentId: config.instrument.id,
361
+ additionalIdentifiers: [],
362
+ // Checked on the receiver of a mint and the holder of a burn.
363
+ issuerRequirements: [(0, readers_1.credentialRequirement)(registrar, config.instrument.claims.redemption)],
364
+ // Checked on sender and receiver of every transfer, allocation and settlement.
365
+ holderRequirements: [(0, readers_1.credentialRequirement)(registrar, config.instrument.claims.transfer)],
366
+ useBlockList: config.instrument.useBlockList,
367
+ };
368
+ }
369
+ function instrumentMatches(contract, payload) {
370
+ const blockList = (0, readers_1.readOptionalBool)(field(contract, 'checkBlockList'));
371
+ const additional = field(contract, 'additionalIdentifiers');
372
+ return ((0, readers_1.sameRequirements)((0, readers_1.readRequirements)(field(contract, 'issuerRequirements')), (0, readers_1.readRequirements)(payload.issuerRequirements)) &&
373
+ (0, readers_1.sameRequirements)((0, readers_1.readRequirements)(field(contract, 'holderRequirements')), (0, readers_1.readRequirements)(payload.holderRequirements)) &&
374
+ (payload.useBlockList ? blockList === true : blockList !== true) &&
375
+ (!Array.isArray(additional) || additional.length === 0));
376
+ }
377
+ function instrumentConfigurations(ctx, state) {
378
+ return ownedByRegistrar(ctx, state.byTemplate('instrumentConfiguration'), ['provider', 'registrar']).filter((c) => field(c, 'defaultIdentifier')?.id === ctx.config.instrument.id);
379
+ }
380
+ function instrumentStep(ctx) {
381
+ const { config } = ctx;
382
+ const registrar = config.registrar.party;
383
+ const { transfer, redemption } = config.instrument.claims;
384
+ return {
385
+ id: 'instrument-configuration',
386
+ title: config.instrument.halted
387
+ ? `The ${config.instrument.id} instrument is halted: no configuration exists`
388
+ : `The ${config.instrument.id} instrument requires the registrar's credentials`,
389
+ why: [
390
+ `Holders need the transfer claim (${transfer.property}=${transfer.value}) to send or receive, and the`,
391
+ `redemption claim (${redemption.property}=${redemption.value}) to receive a mint or burn, so a`,
392
+ 'redemption-only credential freezes trading but keeps redemption open. Blocklist:',
393
+ `${config.instrument.useBlockList ? 'on' : 'off'}. Exactly one configuration may exist per instrument. While the`,
394
+ 'instrument is halted, none may exist, so nothing mints, burns, transfers or settles.',
395
+ ].join('\n'),
396
+ async assess() {
397
+ const state = await loadRegistryState(ctx);
398
+ const [service] = ownedByRegistrar(ctx, state.byTemplate('registrarService'), ['provider', 'registrar']);
399
+ if (!service)
400
+ return needs('registrar-service');
401
+ const payload = desiredDaRegistryInstrumentConfiguration(config);
402
+ const existing = instrumentConfigurations(ctx, state);
403
+ if (config.instrument.halted) {
404
+ if (existing.length === 0)
405
+ return { status: 'done', summary: 'halted: no configuration' };
406
+ return {
407
+ status: 'pending',
408
+ summary: 'halted: archive the configuration',
409
+ evidence: { instrumentConfiguration: cids(existing) },
410
+ actions: [
411
+ {
412
+ description: 'archive the instrument configuration (halted)',
413
+ submission: submission(ctx, 'instrument-configuration', registrar, existing.map((c) => (0, commands_1.archiveInstrumentConfiguration)(service.contractId, c.contractId))),
414
+ },
415
+ ],
416
+ };
417
+ }
418
+ const matching = existing.filter((c) => instrumentMatches(c, payload));
419
+ const [firstMatching] = matching;
420
+ const [firstExisting] = existing;
421
+ if (existing.length === 1 && matching.length === 1) {
422
+ return {
423
+ status: 'done',
424
+ summary: 'configuration matches',
425
+ evidence: { instrumentConfiguration: cids(matching) },
426
+ };
427
+ }
428
+ const commands = [];
429
+ let summary;
430
+ if (!firstExisting) {
431
+ summary = 'no configuration';
432
+ commands.push((0, commands_1.createInstrumentConfiguration)(service.contractId, payload));
433
+ }
434
+ else {
435
+ if (existing.some((c) => !instrumentMatches(c, payload)) && config.network !== 'devnet') {
436
+ // Every holder's credential carries the old claims, so rewriting the requirements freezes them all at once.
437
+ // That holds for any differing live configuration, even beside a matching one; only identical duplicates
438
+ // are archived.
439
+ return {
440
+ ...blocked('the live configuration differs from the config; change it by hand, not by apply', [
441
+ 'rewriting the requirements freezes every holder whose credential carries the old claims',
442
+ 're-issue credentials with the new claims first, then rewrite the configuration deliberately',
443
+ ]),
444
+ evidence: { instrumentConfiguration: cids(existing) },
445
+ };
446
+ }
447
+ // Keep one: a matching one if any, else replace the first. Archive the rest first, so one exists at a time.
448
+ const keep = firstMatching ?? firstExisting;
449
+ for (const extra of existing.filter((c) => c !== keep)) {
450
+ commands.push((0, commands_1.archiveInstrumentConfiguration)(service.contractId, extra.contractId));
451
+ }
452
+ if (!firstMatching) {
453
+ commands.push((0, commands_1.archiveAndCreateInstrumentConfiguration)(service.contractId, keep.contractId, payload));
454
+ }
455
+ summary = firstMatching
456
+ ? `${existing.length} configurations; archive the extras`
457
+ : 'configuration differs from the config';
458
+ }
459
+ return {
460
+ status: 'pending',
461
+ summary,
462
+ evidence: existing.length > 0 ? { instrumentConfiguration: cids(existing) } : {},
463
+ actions: [
464
+ {
465
+ description: 'converge the instrument configuration',
466
+ submission: submission(ctx, 'instrument-configuration', registrar, commands),
467
+ },
468
+ ],
469
+ };
470
+ },
471
+ };
472
+ }
473
+ function readCredential(contract) {
474
+ const rawClaims = field(contract, 'claims');
475
+ const claims = (Array.isArray(rawClaims) ? rawClaims : []).map((c) => {
476
+ const claim = c;
477
+ return { subject: String(claim['subject']), ...(0, readers_1.readClaimTuple)(claim) };
478
+ });
479
+ const optionalTime = (value) => (typeof value === 'string' ? value : null);
480
+ return {
481
+ contract,
482
+ subjects: [...new Set(claims.map((c) => c.subject))],
483
+ claims,
484
+ observers: (0, readers_1.partySetMembers)(field(contract, 'observers')),
485
+ validFrom: optionalTime(field(contract, 'validFrom')),
486
+ validUntil: optionalTime(field(contract, 'validUntil')),
487
+ };
488
+ }
489
+ /** A claim's identity: structured, so no property or value text can make two different claims look alike. */
490
+ function claimKey(c) {
491
+ return JSON.stringify([c.property, c.value]);
492
+ }
493
+ /** A claim as operators read it. */
494
+ function claimLabel(c) {
495
+ return `${c.property}=${c.value}`;
496
+ }
497
+ /** A claim set's identity, independent of order. */
498
+ function claimSetKey(claims) {
499
+ return JSON.stringify(claims.map(claimKey).sort());
500
+ }
501
+ function credentialsStep(ctx) {
502
+ const { config } = ctx;
503
+ const registrar = config.registrar.party;
504
+ return {
505
+ id: 'credentials',
506
+ title: `The ${config.instrument.id} allowlist matches the config`,
507
+ why: [
508
+ 'One issuer-held credential per approved party: the registrar issues and holds it and names the party only in',
509
+ 'its claims, so the party signs nothing. DA’s operator observes it so DA’s API can disclose it in each choice',
510
+ 'context. Credentials are immutable, so every change archives and recreates, atomically per party. No personal',
511
+ 'data: every party to a transfer sees the credentials used in it.',
512
+ ].join('\n'),
513
+ async assess() {
514
+ const validUntilMs = Date.parse(config.credentials.validUntil);
515
+ const renewBy = ctx.now().getTime() + config.credentials.renewWithinDays * 86400000;
516
+ if (validUntilMs <= renewBy) {
517
+ return blocked(`credentials.validUntil ${config.credentials.validUntil} is within the renewal window`, [
518
+ `set a validUntil after ${new Date(renewBy).toISOString()} in ${config.source ?? 'the config'}`,
519
+ ]);
520
+ }
521
+ const state = await loadRegistryState(ctx);
522
+ const managed = instrumentCredentials(ctx, state);
523
+ const notes = [];
524
+ const issuedViaApp = state
525
+ .byTemplate('credential')
526
+ .map(readCredential)
527
+ .filter((v) => field(v.contract, 'issuer') === registrar && field(v.contract, 'holder') !== registrar);
528
+ if (issuedViaApp.length > 0) {
529
+ notes.push(`${issuedViaApp.length} registrar-issued credential(s) held by others are not managed here`);
530
+ }
531
+ const toArchive = new Set();
532
+ const actions = [];
533
+ const evidence = {};
534
+ const entries = new Map(config.credentials.parties.map((entry) => [entry.party, entry]));
535
+ const instrumentClaimKeys = new Set(Object.values(config.instrument.claims).map(claimKey));
536
+ const handled = new Set();
537
+ const desiredClaims = (party) => (entries.get(party)?.claims ?? []).map((kind) => config.instrument.claims[kind]);
538
+ const label = (party) => entries.get(party)?.label ?? party;
539
+ // A credential the registrar issued but its subject holds satisfies the requirements too (the check is issuer and
540
+ // claims, not holder), so one granting a claim the config withholds would leave that party allowed. A person
541
+ // revokes it.
542
+ const grantedElsewhere = [];
543
+ const nowMs = ctx.now().getTime();
544
+ // An expired credential grants nothing, so it cannot keep a removed party allowed.
545
+ for (const view of issuedViaApp.filter((v) => v.validUntil === null || Date.parse(v.validUntil) > nowMs)) {
546
+ for (const claim of view.claims.filter((c) => instrumentClaimKeys.has(claimKey(c)))) {
547
+ const listedClaims = entries.has(claim.subject) ? desiredClaims(claim.subject).map(claimKey) : null;
548
+ const withheld = listedClaims ? !listedClaims.includes(claimKey(claim)) : config.credentials.pruneUnlisted;
549
+ if (withheld)
550
+ grantedElsewhere.push(`${view.contract.contractId} gives ${claim.subject} ${claimLabel(claim)}`);
551
+ }
552
+ }
553
+ if (grantedElsewhere.length > 0) {
554
+ return blocked('a credential its holder holds grants a claim the config withholds', [
555
+ ...grantedElsewhere,
556
+ 'revoke it with Credential_Revoke (actor: the registrar), or give the party that claim in the config',
557
+ ]);
558
+ }
559
+ /**
560
+ * Archiving a credential with other instruments' claims would revoke those too; a person splits it. On a
561
+ * credential this setup issued (it carries the config's id), a claim whose value is this instrument's id is ours
562
+ * even under an old property name, so renaming a claim retires the old credentials. Any other claim is not ours,
563
+ * even if its value matches one of this instrument's claims (`true`, say).
564
+ */
565
+ const outOfScope = (view) => view.claims.filter((c) => !instrumentClaimKeys.has(claimKey(c)) &&
566
+ !(field(view.contract, 'id') === config.credentials.id && c.value === config.instrument.id));
567
+ for (const entry of config.credentials.parties) {
568
+ if (handled.has(entry.party))
569
+ continue;
570
+ const desired = desiredClaims(entry.party);
571
+ const desiredLabels = desired.map(claimLabel).sort();
572
+ const existing = managed.filter((v) => v.subjects.includes(entry.party));
573
+ const [current] = existing;
574
+ const ok = current !== undefined &&
575
+ existing.length === 1 &&
576
+ current.subjects.length === 1 &&
577
+ claimSetKey(current.claims) === claimSetKey(desired) &&
578
+ field(current.contract, 'id') === config.credentials.id &&
579
+ field(current.contract, 'description') === config.credentials.description &&
580
+ current.observers.length === 1 &&
581
+ current.observers[0] === config.da.operatorParty &&
582
+ current.validFrom === null &&
583
+ current.validUntil !== null &&
584
+ Date.parse(current.validUntil) === validUntilMs;
585
+ if (ok) {
586
+ handled.add(entry.party);
587
+ evidence[entry.label] = `${desiredLabels.join(' + ')} (${current.contract.contractId.slice(0, 16)}…)`;
588
+ continue;
589
+ }
590
+ // Credentials shared between parties are replaced together: every credential naming any party in the group,
591
+ // and every party those credentials name, in one transaction, so no party is left without one part-way.
592
+ const group = new Set([entry.party]);
593
+ const affected = new Map();
594
+ for (let grew = true; grew;) {
595
+ grew = false;
596
+ for (const view of managed) {
597
+ if (affected.has(view.contract.contractId) || !view.subjects.some((subject) => group.has(subject))) {
598
+ continue;
599
+ }
600
+ affected.set(view.contract.contractId, view);
601
+ grew = true;
602
+ for (const subject of view.subjects)
603
+ group.add(subject);
604
+ }
605
+ }
606
+ for (const view of affected.values()) {
607
+ const foreign = outOfScope(view);
608
+ if (foreign.length > 0) {
609
+ return blocked(`${entry.label}'s credential also carries claims for other instruments`, [
610
+ `credential ${view.contract.contractId} carries ${foreign.map(claimLabel).join(', ')}`,
611
+ 'split it by hand so this instrument’s claims stand alone',
612
+ ]);
613
+ }
614
+ }
615
+ const unlistedInGroup = [...group].filter((party) => !entries.has(party));
616
+ if (unlistedInGroup.length > 0 && !config.credentials.pruneUnlisted) {
617
+ // Archiving would silently freeze parties this config does not manage.
618
+ return blocked(`${entry.label}'s credential also names unlisted parties`, [
619
+ `shared credential(s) ${[...affected.keys()].join(', ')} name ${unlistedInGroup.join(', ')}`,
620
+ 'list those parties too, or revoke the shared credential by hand',
621
+ ]);
622
+ }
623
+ const commands = [];
624
+ for (const cid of affected.keys()) {
625
+ toArchive.add(cid);
626
+ commands.push((0, commands_1.archiveDaRegistryContract)('credential', cid));
627
+ }
628
+ const recreated = [...group].filter((party) => entries.has(party));
629
+ for (const party of recreated) {
630
+ handled.add(party);
631
+ commands.push((0, commands_1.createCredential)((0, commands_1.issuerHeldCredential)({
632
+ registrar,
633
+ operator: config.da.operatorParty,
634
+ subject: party,
635
+ claims: desiredClaims(party),
636
+ id: config.credentials.id,
637
+ description: config.credentials.description,
638
+ validUntil: config.credentials.validUntil,
639
+ })));
640
+ evidence[label(party)] = affected.size === 0 ? 'missing' : 'differs from the config';
641
+ }
642
+ const who = recreated.map(label).join(', ');
643
+ actions.push({
644
+ description: affected.size === 0
645
+ ? `issue the credential for ${who} (${desiredLabels.join(' + ')})`
646
+ : `replace the credential(s) for ${who}${unlistedInGroup.length > 0 ? `, revoking unlisted ${unlistedInGroup.join(', ')}` : ''}`,
647
+ submission: submission(ctx, 'credentials', registrar, commands),
648
+ });
649
+ }
650
+ const unlisted = managed.filter((v) => !toArchive.has(v.contract.contractId) && v.subjects.some((subject) => !entries.has(subject)));
651
+ if (config.credentials.pruneUnlisted) {
652
+ for (const view of unlisted) {
653
+ const foreign = outOfScope(view);
654
+ if (foreign.length > 0) {
655
+ return blocked('an unlisted party’s credential also carries claims for other instruments', [
656
+ `credential ${view.contract.contractId} carries ${foreign.map(claimLabel).join(', ')}`,
657
+ 'split it by hand so this instrument’s claims stand alone',
658
+ ]);
659
+ }
660
+ toArchive.add(view.contract.contractId);
661
+ actions.push({
662
+ description: `revoke the credential naming unlisted ${view.subjects.join(', ')}`,
663
+ submission: submission(ctx, 'credentials', registrar, [
664
+ (0, commands_1.archiveDaRegistryContract)('credential', view.contract.contractId),
665
+ ]),
666
+ });
667
+ }
668
+ }
669
+ else if (unlisted.length > 0) {
670
+ notes.push(`${unlisted.length} credential(s) name parties this config does not list; left in place`);
671
+ }
672
+ if (actions.length === 0) {
673
+ return { status: 'done', summary: `${config.credentials.parties.length} credentials match`, evidence, notes };
674
+ }
675
+ return { status: 'pending', summary: `${actions.length} credential change(s)`, evidence, notes, actions };
676
+ },
677
+ };
678
+ }
679
+ // ── 8. Transfer preapprovals ────────────────────────────────────────────────────────────────────────────────────────
680
+ function preapprovalsStep(ctx) {
681
+ const { config } = ctx;
682
+ const registrar = config.registrar.party;
683
+ return {
684
+ id: 'transfer-preapprovals',
685
+ title: `Receivers we act for preapprove ${config.instrument.id}`,
686
+ why: [
687
+ 'A transfer preapproval is a standing contract, created by the receiver, that accepts the instrument in',
688
+ 'advance, so a transfer lands in one step. The registrar’s is how withdrawals arrive; listed holders’ are how',
689
+ 'deliveries arrive. Only the receiver can create one, so receivers we do not act for create their own.',
690
+ ].join('\n'),
691
+ async assess() {
692
+ await requireReadableRegistry(ctx);
693
+ const actions = [];
694
+ const evidence = {};
695
+ for (const receiver of config.preapprovals) {
696
+ const found = (await ctx.ledger.activeContracts([receiver.party], [templates_1.DA_REGISTRY_TEMPLATES.transferPreapproval])).filter((c) => {
697
+ const allowances = field(c, 'instrumentAllowances');
698
+ return ((0, templates_1.isDaRegistryTemplate)(c, 'transferPreapproval') &&
699
+ field(c, 'receiver') === receiver.party &&
700
+ field(c, 'operator') === config.da.operatorParty &&
701
+ field(c, 'instrumentAdmin') === registrar &&
702
+ Array.isArray(allowances) &&
703
+ (allowances.length === 0 ||
704
+ allowances.some((a) => a?.id === config.instrument.id)));
705
+ });
706
+ const [preapproval] = found;
707
+ if (preapproval) {
708
+ evidence[receiver.label] = `${preapproval.contractId.slice(0, 16)}…`;
709
+ continue;
710
+ }
711
+ evidence[receiver.label] = 'missing';
712
+ actions.push({
713
+ description: `create the ${config.instrument.id} preapproval for ${receiver.label}`,
714
+ submission: submission(ctx, 'transfer-preapprovals', receiver.party, [
715
+ (0, commands_1.createTransferPreapproval)(config.da.operatorParty, receiver.party, registrar, [config.instrument.id]),
716
+ ]),
717
+ });
718
+ }
719
+ if (actions.length === 0)
720
+ return { status: 'done', summary: 'every listed receiver has one', evidence };
721
+ return { status: 'pending', summary: `${actions.length} preapproval(s) missing`, evidence, actions };
722
+ },
723
+ };
724
+ }
725
+ // ── Shared reads for the token steps ────────────────────────────────────────────────────────────────────────────────
726
+ /** The one instrument configuration that matches the config, if the instrument step is done. */
727
+ function currentInstrumentConfiguration(ctx, state) {
728
+ const payload = desiredDaRegistryInstrumentConfiguration(ctx.config);
729
+ const existing = instrumentConfigurations(ctx, state);
730
+ const [only] = existing;
731
+ return existing.length === 1 && only && instrumentMatches(only, payload) ? only : undefined;
732
+ }
733
+ /**
734
+ * Registrar-issued, registrar-held credentials for this instrument: those carrying one of its claims, and those
735
+ * carrying the config's credential id, so a change to the claim names still retires the credentials issued under the
736
+ * old ones.
737
+ */
738
+ function instrumentCredentials(ctx, state) {
739
+ const registrar = ctx.config.registrar.party;
740
+ const instrumentClaims = new Set(Object.values(ctx.config.instrument.claims).map(claimKey));
741
+ return state
742
+ .byTemplate('credential')
743
+ .map(readCredential)
744
+ .filter((v) => field(v.contract, 'issuer') === registrar &&
745
+ field(v.contract, 'holder') === registrar &&
746
+ (v.claims.some((c) => instrumentClaims.has(claimKey(c))) ||
747
+ field(v.contract, 'id') === ctx.config.credentials.id));
748
+ }
749
+ /** A credential that currently gives `party` the claim, or undefined. */
750
+ function credentialWithClaim(ctx, state, party, claim) {
751
+ const now = ctx.now().getTime();
752
+ return instrumentCredentials(ctx, state).find((v) => v.claims.some((c) => c.subject === party && claimKey(c) === claimKey(claim)) &&
753
+ (v.validFrom === null || Date.parse(v.validFrom) <= now) &&
754
+ (v.validUntil === null || Date.parse(v.validUntil) > now));
755
+ }
756
+ function instrumentHoldings(ctx, state) {
757
+ const registrar = ctx.config.registrar.party;
758
+ return state
759
+ .byTemplate('holding')
760
+ .filter((c) => field(c, 'registrar') === registrar &&
761
+ field(c, 'instrument')?.id === ctx.config.instrument.id)
762
+ .map((c) => {
763
+ const label = field(c, 'label');
764
+ return {
765
+ contractId: c.contractId,
766
+ owner: String(field(c, 'owner')),
767
+ amount: (0, decimal_1.parseDecimal)(String(field(c, 'amount'))),
768
+ locked: field(c, 'lock') != null,
769
+ label: typeof label === 'string' ? label : '',
770
+ };
771
+ });
772
+ }
773
+ function sum(holdings) {
774
+ return (0, decimal_1.sumDecimals)(holdings.map((h) => h.amount));
775
+ }
776
+ function isoOffset(ctx, offsetMs) {
777
+ return new Date(ctx.now().getTime() + offsetMs).toISOString();
778
+ }
779
+ // ── 9. Supply ───────────────────────────────────────────────────────────────────────────────────────────────────────
780
+ function supplyStep(ctx) {
781
+ const { config } = ctx;
782
+ const registrar = config.registrar.party;
783
+ const instrumentId = { admin: registrar, id: config.instrument.id };
784
+ const target = config.supply ? (0, decimal_1.parseDecimal)(config.supply.total) : null;
785
+ return {
786
+ id: 'supply',
787
+ title: target === null
788
+ ? `${config.instrument.id} supply is not managed by this config`
789
+ : `${config.instrument.id} supply is ${(0, decimal_1.formatDecimal)(target)}, minted to the registrar`,
790
+ why: [
791
+ 'Test supply: the registrar mints to itself, then delivers it through holders’ preapprovals, so holders sign',
792
+ 'nothing for a deposit. Minting is an offer from the registrar as registrar, accepted by the registrar as',
793
+ 'holder: DA’s documented path, which checks the registrar’s redemption claim. Supply counts every holding,',
794
+ 'locked ones included, and apply mints back up to the total after test burns. The step never burns and touches',
795
+ `only mint offers whose reference starts with "${exports.DA_REGISTRY_SETUP_MINT_REFERENCE_PREFIX}". Where real mints`,
796
+ 'follow an off-ledger process, leave supply unset.',
797
+ ].join('\n'),
798
+ async assess() {
799
+ const state = await loadRegistryState(ctx, { holdings: true });
800
+ const supply = sum(instrumentHoldings(ctx, state));
801
+ if (target === null) {
802
+ return { status: 'done', summary: 'not managed here', evidence: { supply: (0, decimal_1.formatDecimal)(supply) } };
803
+ }
804
+ if (config.instrument.halted) {
805
+ return { status: 'done', summary: 'instrument halted: no mints', evidence: { supply: (0, decimal_1.formatDecimal)(supply) } };
806
+ }
807
+ const configuration = currentInstrumentConfiguration(ctx, state);
808
+ if (!configuration)
809
+ return needs('instrument-configuration');
810
+ const [factory] = ownedByRegistrar(ctx, state.byTemplate('allocationFactory'), ['provider', 'registrar']);
811
+ if (!factory)
812
+ return needs('registrar-service');
813
+ const registrarCredential = credentialWithClaim(ctx, state, registrar, config.instrument.claims.redemption);
814
+ if (!registrarCredential)
815
+ return needs('credentials', 'the registrar has no valid redemption claim');
816
+ const evidence = { supply: (0, decimal_1.formatDecimal)(supply), target: (0, decimal_1.formatDecimal)(target) };
817
+ const context = (0, choice_context_1.mintChoiceContext)(configuration.contractId, [registrarCredential.contract.contractId]);
818
+ const [offer] = state.byTemplate('mintOffer').filter((c) => {
819
+ const mint = field(c, 'mint');
820
+ return (mint?.holder === registrar &&
821
+ mint.instrumentId?.admin === registrar &&
822
+ mint.instrumentId.id === config.instrument.id &&
823
+ typeof mint.reference === 'string' &&
824
+ mint.reference.startsWith(exports.DA_REGISTRY_SETUP_MINT_REFERENCE_PREFIX));
825
+ });
826
+ if (offer) {
827
+ // Finish (or clear) an offer a previous run left behind before offering more.
828
+ const mint = field(offer, 'mint');
829
+ const expired = Date.parse(mint.executeBefore) <= ctx.now().getTime();
830
+ const overshoots = supply + (0, decimal_1.parseDecimal)(mint.amount) > target;
831
+ const cancel = expired || overshoots;
832
+ return {
833
+ status: 'pending',
834
+ summary: cancel
835
+ ? `cancel mint offer for ${mint.amount} (${expired ? 'expired' : 'would exceed the target'})`
836
+ : `accept the outstanding mint offer for ${mint.amount}`,
837
+ evidence: { ...evidence, mintOffer: offer.contractId },
838
+ actions: [
839
+ {
840
+ description: cancel ? 'cancel the mint offer' : 'accept the mint offer as holder',
841
+ submission: submission(ctx, 'supply', registrar, [
842
+ cancel ? (0, commands_1.cancelMintOffer)(offer.contractId) : (0, commands_1.acceptMintOffer)(offer.contractId, context),
843
+ ]),
844
+ },
845
+ ],
846
+ };
847
+ }
848
+ if (supply === target)
849
+ return { status: 'done', summary: `supply is ${(0, decimal_1.formatDecimal)(supply)}`, evidence };
850
+ if (supply > target) {
851
+ return {
852
+ status: 'blocked',
853
+ summary: `supply ${(0, decimal_1.formatDecimal)(supply)} exceeds the configured ${(0, decimal_1.formatDecimal)(target)}`,
854
+ evidence,
855
+ notes: ['reduce supply by redemption (burn), or raise supply.total in the config'],
856
+ };
857
+ }
858
+ const amount = (0, decimal_1.formatDecimal)(target - supply);
859
+ return {
860
+ status: 'pending',
861
+ summary: `mint ${amount} to the registrar`,
862
+ evidence,
863
+ actions: [
864
+ {
865
+ description: `offer a mint of ${amount} ${config.instrument.id} to the registrar (accepted next round)`,
866
+ submission: submission(ctx, 'supply', registrar, [
867
+ (0, commands_1.offerMint)(factory.contractId, registrar, {
868
+ instrumentId,
869
+ amount,
870
+ holder: registrar,
871
+ reference: `${exports.DA_REGISTRY_SETUP_MINT_REFERENCE_PREFIX} ${config.network}: supply to ${(0, decimal_1.formatDecimal)(target)}`,
872
+ // The factory requires requestedAt in the past by ledger time.
873
+ requestedAt: isoOffset(ctx, -60000),
874
+ executeBefore: isoOffset(ctx, 72 * 3600000),
875
+ meta: { values: {} },
876
+ }, context),
877
+ ]),
878
+ },
879
+ ],
880
+ };
881
+ },
882
+ };
883
+ }
884
+ // ── 10. DA's API serves the registry ────────────────────────────────────────────────────────────────────────────────
885
+ function display(value) {
886
+ if (typeof value === 'string' || typeof value === 'number')
887
+ return String(value);
888
+ return value == null ? 'n/a' : JSON.stringify(value);
889
+ }
890
+ function daApiStep(ctx) {
891
+ const { config } = ctx;
892
+ const registrar = config.registrar.party;
893
+ return {
894
+ id: 'da-api',
895
+ title: `DA's API serves the registrar's registry, ${config.instrument.id} and every listed credential`,
896
+ why: [
897
+ 'Wallets and venues fetch each transaction’s choice context from DA’s API, which finds only contracts DA’s',
898
+ 'operator observes. This read-only check asks for a mint-offer context per redemption-claim party and expects',
899
+ 'that party’s credential back, which confirms DA’s API resolves the registrar’s issuer-held credentials.',
900
+ ].join('\n'),
901
+ awaitMs: 3 * 60000,
902
+ async assess() {
903
+ if (config.instrument.halted)
904
+ return { status: 'done', summary: 'instrument halted: DA’s API not checked' };
905
+ const state = await loadRegistryState(ctx);
906
+ const configuration = currentInstrumentConfiguration(ctx, state);
907
+ if (!configuration)
908
+ return needs('instrument-configuration');
909
+ const activeCredentials = new Set(state.byTemplate('credential').map((c) => c.contractId));
910
+ const activeFactories = new Set(ownedByRegistrar(ctx, state.byTemplate('allocationFactory'), ['provider', 'registrar']).map((c) => c.contractId));
911
+ const [info, instrument] = await Promise.all([
912
+ ctx.da.registryInfo(registrar),
913
+ ctx.da.instrumentMetadata(registrar, config.instrument.id),
914
+ ]);
915
+ const evidence = {};
916
+ const lagging = [];
917
+ const notes = [];
918
+ if (!info)
919
+ lagging.push('DA does not know the registrar yet');
920
+ else {
921
+ const { supportedApis } = info;
922
+ const apis = Object.keys(typeof supportedApis === 'object' && supportedApis !== null ? supportedApis : {}).sort();
923
+ evidence['supportedApis'] = apis.join(', ');
924
+ if (!apis.includes('splice-api-token-transfer-instruction-v2')) {
925
+ notes.push('Token Standard V2 is not served: DA’s API does not list splice-api-token-transfer-instruction-v2 for the registrar; ask the node operator and DA whether the V2 packages are vetted');
926
+ }
927
+ }
928
+ if (!instrument)
929
+ lagging.push(`DA does not know ${config.instrument.id} yet`);
930
+ else
931
+ evidence['totalSupply'] = display(instrument['totalSupply']);
932
+ const transferOnly = config.credentials.parties.filter((p) => !p.claims.includes('redemption'));
933
+ if (transferOnly.length > 0) {
934
+ // DA serves transfer credentials only in a transfer or allocation context, which needs real holdings.
935
+ notes.push(`not checked here (transfer claim only; checked when they first transact): ${transferOnly.map((p) => p.label).join(', ')}`);
936
+ }
937
+ if (lagging.length === 0) {
938
+ for (const entry of config.credentials.parties.filter((p) => p.claims.includes('redemption'))) {
939
+ const credential = credentialWithClaim(ctx, state, entry.party, config.instrument.claims.redemption);
940
+ if (!credential)
941
+ return needs('credentials', `${entry.label} has no valid redemption claim`);
942
+ const offer = await ctx.da.mintOfferFactory(entry.party, { admin: registrar, id: config.instrument.id });
943
+ if (!offer) {
944
+ lagging.push(`${entry.label}: DA has not indexed the allocation factory or instrument yet`);
945
+ continue;
946
+ }
947
+ // After registrar-service recreates an archived factory, DA can still serve the old one for a while.
948
+ if (!activeFactories.has(offer.factoryId)) {
949
+ lagging.push(`${entry.label}: DA serves allocation factory ${offer.factoryId}, which is not active`);
950
+ continue;
951
+ }
952
+ // A context naming an archived configuration fails every transaction built on it, e.g. after a
953
+ // useBlockList change replaced the configuration but not the credentials.
954
+ const { values } = offer.choiceContext.choiceContextData;
955
+ const servedConfiguration = values[choice_context_1.DA_REGISTRY_CONTEXT_KEYS.instrumentConfiguration]?.value;
956
+ if (servedConfiguration !== configuration.contractId) {
957
+ lagging.push(`${entry.label}: DA serves instrument configuration ${String(servedConfiguration)}, not ${configuration.contractId}`);
958
+ continue;
959
+ }
960
+ const served = values[choice_context_1.DA_REGISTRY_CONTEXT_KEYS.issuerCredentials];
961
+ const servedCids = (served?.value ?? []).map((v) => v.value);
962
+ // A context that also names an archived credential fails every transaction built on it.
963
+ const archived = servedCids.filter((cid) => cid === undefined || !activeCredentials.has(cid));
964
+ if (!servedCids.includes(credential.contract.contractId)) {
965
+ lagging.push(`${entry.label}: DA serves [${servedCids.join(', ')}], not ${credential.contract.contractId}`);
966
+ }
967
+ else if (archived.length > 0) {
968
+ lagging.push(`${entry.label}: DA also serves credentials that are no longer active: ${archived.join(', ')}`);
969
+ }
970
+ else
971
+ evidence[entry.label] = 'credential served';
972
+ }
973
+ }
974
+ if (lagging.length > 0) {
975
+ return {
976
+ status: 'waiting',
977
+ summary: 'DA’s API does not serve everything yet',
978
+ evidence,
979
+ notes: [...lagging, ...notes],
980
+ };
981
+ }
982
+ return { status: 'done', summary: 'DA serves the registry, instrument and credentials', evidence, notes };
983
+ },
984
+ };
985
+ }
986
+ // ── 11. Deliveries ──────────────────────────────────────────────────────────────────────────────────────────────────
987
+ function deliveriesStep(ctx) {
988
+ const { config } = ctx;
989
+ const registrar = config.registrar.party;
990
+ return {
991
+ id: 'deliveries',
992
+ title: `Listed holders hold at least their ${config.instrument.id} seed balance`,
993
+ why: [
994
+ 'Test deliveries: the registrar tops each listed holder up from its own holdings through the holder’s',
995
+ 'preapproval, the production deposit flow, with no holder signature. A holder that trades or redeems its',
996
+ 'seed is topped up again on the next apply. DA’s transfer-factory endpoint picks the factory; only a `direct`',
997
+ 'answer (the receiver’s preapproval) is submitted, never a two-step offer that would lock the registrar’s',
998
+ 'holding. One delivery per round, because each spends the change of the one before.',
999
+ ].join('\n'),
1000
+ awaitMs: 3 * 60000,
1001
+ async assess() {
1002
+ if (config.deliveries.length === 0)
1003
+ return { status: 'done', summary: 'no deliveries configured' };
1004
+ if (config.instrument.halted)
1005
+ return { status: 'done', summary: 'instrument halted: no deliveries' };
1006
+ const state = await loadRegistryState(ctx, { holdings: true });
1007
+ if (!currentInstrumentConfiguration(ctx, state))
1008
+ return needs('instrument-configuration');
1009
+ const holdings = instrumentHoldings(ctx, state);
1010
+ // The registrar spends only its basic account (label ""), where every setup mint lands.
1011
+ const spendable = holdings
1012
+ .filter((h) => h.owner === registrar && !h.locked && h.label === '')
1013
+ .sort((a, b) => (b.amount > a.amount ? 1 : b.amount < a.amount ? -1 : 0));
1014
+ const evidence = {};
1015
+ const managedReceivers = new Set(config.preapprovals.map((p) => p.party));
1016
+ const indexing = [];
1017
+ const holderActions = [];
1018
+ let next;
1019
+ let remaining = 0;
1020
+ for (const target of config.deliveries) {
1021
+ const balance = sum(holdings.filter((h) => h.owner === target.party));
1022
+ evidence[target.label] = `${(0, decimal_1.formatDecimal)(balance)} / ${target.minBalance}`;
1023
+ const deficit = (0, decimal_1.parseDecimal)(target.minBalance) - balance;
1024
+ if (deficit <= 0n)
1025
+ continue;
1026
+ if (next) {
1027
+ remaining++;
1028
+ continue;
1029
+ }
1030
+ const inputs = [];
1031
+ let selected = 0n;
1032
+ for (const h of spendable) {
1033
+ if (selected >= deficit)
1034
+ break;
1035
+ inputs.push(h);
1036
+ selected += h.amount;
1037
+ }
1038
+ if (selected < deficit) {
1039
+ return blocked(`the registrar holds ${(0, decimal_1.formatDecimal)(sum(spendable))} spendable, short of ${(0, decimal_1.formatDecimal)(deficit)} for ${target.label}`, ['raise supply.total or lower minBalance in the config']);
1040
+ }
1041
+ const transfer = {
1042
+ sender: registrar,
1043
+ receiver: target.party,
1044
+ amount: (0, decimal_1.formatDecimal)(deficit),
1045
+ instrumentId: { admin: registrar, id: config.instrument.id },
1046
+ requestedAt: isoOffset(ctx, -60000),
1047
+ executeBefore: isoOffset(ctx, 3600000),
1048
+ inputHoldingCids: inputs.map((h) => h.contractId),
1049
+ meta: {
1050
+ values: { 'splice.lfdecentralizedtrust.org/reason': `${exports.DA_REGISTRY_SETUP_MINT_REFERENCE_PREFIX} delivery` },
1051
+ },
1052
+ };
1053
+ let factory;
1054
+ try {
1055
+ factory = await ctx.da.transferFactory(registrar, { ...transfer });
1056
+ }
1057
+ catch (error) {
1058
+ const message = errorMessage(error);
1059
+ if (/not found|archived/i.test(message)) {
1060
+ indexing.push(`${target.label}: DA has not indexed the registrar's holdings or instrument yet (${message})`);
1061
+ continue;
1062
+ }
1063
+ throw error;
1064
+ }
1065
+ const { factoryId } = factory;
1066
+ const factoryIsPreapproval = factory.choiceContext.disclosedContracts.some((d) => d.contractId === factoryId && (0, templates_1.isDaRegistryTemplate)(d, 'transferPreapproval'));
1067
+ if (factory.transferKind !== 'direct' || !factoryIsPreapproval) {
1068
+ // A preapproval the transfer-preapprovals step just created may not be indexed by DA yet; any other receiver
1069
+ // has to create its own.
1070
+ if (managedReceivers.has(target.party)) {
1071
+ indexing.push(`${target.label}: DA does not serve its ${config.instrument.id} preapproval yet`);
1072
+ }
1073
+ else {
1074
+ holderActions.push(`${target.label}: no ${config.instrument.id} preapproval (DA offers '${factory.transferKind}'); the holder must create one`);
1075
+ }
1076
+ continue;
1077
+ }
1078
+ next = {
1079
+ description: `deliver ${(0, decimal_1.formatDecimal)(deficit)} ${config.instrument.id} to ${target.label} through its preapproval`,
1080
+ submission: {
1081
+ ...submission(ctx, 'deliveries', registrar, [
1082
+ (0, commands_1.transferViaFactory)(factoryId, registrar, transfer, factory.choiceContext.choiceContextData),
1083
+ ]),
1084
+ disclosedContracts: factory.choiceContext.disclosedContracts,
1085
+ },
1086
+ };
1087
+ }
1088
+ const notes = [
1089
+ ...indexing,
1090
+ ...holderActions,
1091
+ ...(remaining > 0 ? [`${remaining} more deliver(ies) follow in later rounds`] : []),
1092
+ ];
1093
+ if (next)
1094
+ return { status: 'pending', summary: 'deliveries outstanding', evidence, notes, actions: [next] };
1095
+ if (indexing.length > 0 || holderActions.length > 0) {
1096
+ // Polling helps only while DA's indexer catches up; a missing preapproval waits on the holder.
1097
+ return {
1098
+ status: 'waiting',
1099
+ summary: 'deliveries wait on holders or DA',
1100
+ evidence,
1101
+ notes,
1102
+ poll: indexing.length > 0,
1103
+ };
1104
+ }
1105
+ return { status: 'done', summary: 'every listed holder has its seed balance', evidence };
1106
+ },
1107
+ };
1108
+ }
1109
+ /**
1110
+ * The ordered steps that converge a ledger to `ctx.config`: ledger access, DA packages, provider service and
1111
+ * configuration, registrar service, instrument configuration, credentials (the allowlist), transfer preapprovals, test
1112
+ * supply, DA's API, and test deliveries. Run them with {@link runDaRegistrySetupSteps}.
1113
+ */
1114
+ function buildDaRegistrySetupSteps(ctx) {
1115
+ return [
1116
+ ledgerAccessStep(ctx),
1117
+ packagesStep(ctx),
1118
+ providerServiceStep(ctx),
1119
+ providerConfigurationStep(ctx),
1120
+ registrarServiceStep(ctx),
1121
+ instrumentStep(ctx),
1122
+ credentialsStep(ctx),
1123
+ preapprovalsStep(ctx),
1124
+ supplyStep(ctx),
1125
+ daApiStep(ctx),
1126
+ deliveriesStep(ctx),
1127
+ ];
1128
+ }
1129
+ //# sourceMappingURL=steps.js.map