@easy1staking/cip113-sdk-ts 0.10.0 → 0.11.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.
@@ -0,0 +1,1077 @@
1
+ /**
2
+ * Protocol bootstrap — the transactions that stand up a CIP-113 instance.
3
+ *
4
+ * TARGETS CIP-113 0.5.0-alpha.4 (upstream 7e8a63198c5b240135f1aa2f043ce5d7c046b2c4).
5
+ *
6
+ * ⚑ WHY THIS IS EXPORTED AT ALL. Until 2026-09-17 this sequence lived only in
7
+ * `test/harness/bootstrap.ts`, which `files: ["dist","blueprints"]` does not
8
+ * ship — so the platform maintained ITS OWN PORT of a protocol-critical
9
+ * sequence. That port had already diverged (it reserves the seed UTxOs from
10
+ * coin selection where the harness did not) and the divergence was CORRECT.
11
+ * Two implementations, one right and the other not yet wrong, is worse than one
12
+ * supported export. See CLAUDE.md, "Protocol bootstrap — AMENDED 2026-09-17",
13
+ * and `design/bootstrap-export.md` for the step boundaries and their reasons.
14
+ *
15
+ * ⛔ WHAT THIS MODULE DOES NOT DO, AND WILL NOT. It does not sign, submit, await,
16
+ * poll, fund, retry, or hold a key. It builds unsigned transactions and returns
17
+ * them. Orchestration is the caller's, and that is not a gap — it is what lets
18
+ * the platform keep its own UTxO reservation and its own confirmation strategy.
19
+ *
20
+ * ⛔ AND IT SHIPS NO FIXTURE VALUES. No mnemonic, no endpoint, no nonce, no
21
+ * security parameter, no placeholder policy id, no `any`. A value the caller
22
+ * must decide is a REQUIRED INPUT, never a default. That rule is the whole
23
+ * reason this export is safe to make: everything the devnet fixture chose for
24
+ * itself is now something its CALL passes in.
25
+ *
26
+ * ---------------------------------------------------------------------------
27
+ * THE SEQUENCE IS DEPENDENT, SO THE EXPORT IS STEPWISE
28
+ * ---------------------------------------------------------------------------
29
+ *
30
+ * The one-shot minting policies are parameterised by SPECIFIC OUTPUT REFERENCES
31
+ * of an earlier transaction, so step N+1 cannot be built until step N has been
32
+ * submitted and its outputs observed. There is no "give me five transactions"
33
+ * shape available; asking for one would mean signing, and signing is out.
34
+ *
35
+ * 1 buildSeedTx -> three distinct seed UTxOs
36
+ * 2 buildMultisigGenesisTx -> the upgrade authority's config UTxO
37
+ * 3 buildProtocolGenesisTx -> params, registry origin, issuance CBOR
38
+ * 4 buildReferenceScriptsTx -> the seven reference scripts
39
+ * 5 buildStakeRegistrationTx -> the six withdraw-0 registrations
40
+ * assembleDeploymentParams -> DeploymentParams (pure)
41
+ *
42
+ * ⚑ RESUMPTION. `planBootstrap(config)` is pure and deterministic: persist the
43
+ * CONFIG and every hash, address, datum and asset unit can be re-derived on any
44
+ * machine with no chain access. No step needs an earlier step's CONSUMED
45
+ * inputs, which is the property that makes resuming at step N work at all — by
46
+ * the time you resume at step 4 the seeds are spent and unfetchable.
47
+ */
48
+ import { Address as EvoAddress, Assets as EvoAssets, Bytes, Credential, Data, InlineDatum, Transaction as EvoTransaction, TransactionHash as EvoTransactionHash, UPLC, } from "@evolution-sdk/evolution";
49
+ import { buildCip171RecordFromPin } from "../core/provenance.js";
50
+ import { buildCip171Metadatum, CIP171_METADATA_LABEL } from "../core/cip171.js";
51
+ import { buildEvoScript, ceilToWholeAda, decodeMultisigScript, getInlineDatum, mintAssetsFromMap, minUtxoAtLeast, minUtxoForOutput, multisigScriptDatum, outputAssets, protocolParamsDatum, registryInitRedeemer, registryNodeDatum, REGISTRY_NODE_MIN_ADA, scriptAddress, stringToHex, voidData, } from "../core/evo-utils.js";
52
+ import { STANDARD_VALIDATORS, validateStandardBlueprint } from "./blueprint.js";
53
+ import { createStandardScripts, UNFRACKING_DISABLED, } from "./scripts.js";
54
+ // ---------------------------------------------------------------------------
55
+ // Constants that are PROTOCOL FACTS, not caller choices
56
+ // ---------------------------------------------------------------------------
57
+ /**
58
+ * Three seed UTxOs, and the count is not negotiable.
59
+ *
60
+ * utxo1 -> protocol_params AND registry, utxo2 -> issuance_cbor_hex_mint,
61
+ * utxo3 -> upgrade_multisig.
62
+ *
63
+ * ⛔ THREE DISTINCT ONES, NOT ONE REUSED. Reusing utxo1 for upgrade_multisig
64
+ * deploys perfectly well — a one-shot outref is consumed once, and the two
65
+ * policies consume it in different transactions, so nothing on chain objects.
66
+ * The reason it must be distinct is OFF chain: `DeploymentParams` carries TWO
67
+ * same-typed one-shot outrefs, `protocolParams.txInput` and
68
+ * `upgradeMultisig.txInput`, and `assertDeploymentScripts` derives a hash from
69
+ * each. A record that gives them ONE value makes the upgrade_multisig check
70
+ * pass whichever field the code reads, so the check cannot fail and a real
71
+ * defect in which field is read is invisible. That is not hypothetical: it hid
72
+ * a wrong derivation for an entire migration once already.
73
+ */
74
+ export const BOOTSTRAP_SEED_COUNT = 3;
75
+ /**
76
+ * The reference-script publication order, which is ONE FACT WRITTEN ONCE.
77
+ *
78
+ * ⛔ APPENDED, NEVER INSERTED. `buildReferenceScriptsTx` pays them in this
79
+ * order and `assembleDeploymentParams` derives every `…RefInput` index from the
80
+ * same array. alpha.3 inserted the dispatcher at index 1 and shifted all three
81
+ * delegates down — and a mismatch here does not fail loudly. It hands out a
82
+ * reference input carrying the WRONG script, and the transaction dies at
83
+ * evaluation naming neither.
84
+ *
85
+ * ⛔ AND APPENDING IS NOT FREE — THERE IS A CAP AND THIS LIST IS NEAR IT (F-9).
86
+ * MEASURED on devnet, 2026-09-17: the transaction publishing these seven comes
87
+ * to **12,496 bytes against a 16,384-byte protocol maximum — 76%**. Two more
88
+ * scripts of the size already here would burst it, and the failure arrives at
89
+ * SUBMISSION rather than at build. This is the same cap that forced the
90
+ * mint/publish/register split in the first place: the 0.3.x single-transaction
91
+ * fixture measured 21,816 bytes. `buildReferenceScriptsTx` now refuses over the
92
+ * chain's own `maxTxSize` rather than letting the ledger say it; when that
93
+ * refusal fires, the answer is a SECOND publish transaction and a second
94
+ * recorded tx hash, not a shorter list.
95
+ */
96
+ export const REFERENCE_SCRIPT_ORDER = [
97
+ "programmableLogicBase",
98
+ "programmableLogicGlobal",
99
+ "transfer",
100
+ "thirdParty",
101
+ "unfracking",
102
+ "issuanceLogic",
103
+ "upgradeMultisig",
104
+ ];
105
+ /**
106
+ * The six withdraw-0 credentials a bootstrap must register.
107
+ *
108
+ * ⛔ SIX, AND EVERY OMISSION IS UNDIAGNOSABLE. An unregistered stake credential
109
+ * does not fail with "not registered": the ledger answers code 3141, "rewards
110
+ * withdrawals must consume rewards in full", which reads as a balance problem
111
+ * and sends the reader to the wrong subsystem entirely. MEASURED on devnet —
112
+ * every programmable operation failed that way until the dispatcher joined this
113
+ * list, and `issuance_logic` rides EVERY mint and EVERY burn.
114
+ */
115
+ export const STAKE_REGISTRATION_ORDER = [
116
+ "programmableLogicGlobal",
117
+ "transfer",
118
+ "thirdParty",
119
+ "unfracking",
120
+ "issuanceLogic",
121
+ "upgradeMultisig",
122
+ ];
123
+ /** The five steps, in order. Exported so a caller can name where it resumed. */
124
+ export const BOOTSTRAP_STEPS = [
125
+ "seed",
126
+ "multisig-genesis",
127
+ "protocol-genesis",
128
+ "reference-scripts",
129
+ "stake-registrations",
130
+ ];
131
+ /**
132
+ * A FLOOR for solved min-UTxO amounts, not a chosen output size.
133
+ *
134
+ * ⚠ Every datum-bearing output below is SOLVED with `minUtxoAtLeast`, never set
135
+ * flat: min-UTxO scales with serialised output size, and Evolution does NOT
136
+ * rescue an under-funded `payToAddress`. The shortfall survives to submission
137
+ * and is reported as "insufficient Ada", which sends the reader to the wallet
138
+ * balance rather than to the datum that grew. The floor only keeps the figure
139
+ * monotone against what this package already emitted; the solved value governs.
140
+ */
141
+ const MIN_UTXO_FLOOR = 2000000n;
142
+ /**
143
+ * Solve min-UTxO for a genesis output, then ROUND UP TO A WHOLE ADA.
144
+ *
145
+ * ⛔ THE ROUNDING IS DELIBERATE AND IT IS NOT TIDINESS (F-7). `minUtxoAtLeast`
146
+ * returns the EXACT minimum, and its own docstring promises only that the
147
+ * figure "only ever rises" relative to what this package used to emit — it
148
+ * makes no promise about the ENCODER. The figure is
149
+ * `coinsPerUtxoByte × (160 + serialised output size)`, and the serialised size
150
+ * comes from Evolution's `TxOut` encoder, which this package does not own.
151
+ *
152
+ * ⚠ THE FAILURE THAT BUYS: a one-byte widening in that encoder — a version
153
+ * bump, a CBOR canonicalisation change — under-funds an output by
154
+ * `coinsPerUtxoByte` lovelace. Evolution does NOT rescue an under-funded
155
+ * `payToAddress`: the shortfall survives to submission and the ledger rejects
156
+ * it as "insufficient Ada", which sends the reader to the wallet balance. On
157
+ * the protocol genesis that rejection lands on an IRREVERSIBLE step, after the
158
+ * multisig config UTxO already exists on chain.
159
+ *
160
+ * ⇒ A whole-ADA ceiling is a RULE, not a guessed constant, and at
161
+ * `coinsPerUtxoByte` 4310 it absorbs on the order of a hundred bytes of encoder
162
+ * drift on the largest output here. It is still an order of magnitude below the
163
+ * flat 15 ADA this sequence used to write.
164
+ */
165
+ function genesisOutputLovelace(params) {
166
+ return ceilToWholeAda(minUtxoAtLeast(MIN_UTXO_FLOOR, params));
167
+ }
168
+ /**
169
+ * The lowest lovelace an output of this shape can legally carry — a FLOOR to
170
+ * refuse below, never a sufficiency check.
171
+ *
172
+ * ⚠ Clearing min-UTxO is necessary and not sufficient: a seed output also has
173
+ * to fund the fee of the transaction that consumes it, and this says nothing
174
+ * about that.
175
+ */
176
+ function minLovelaceForPlainOutput(address, coinsPerUtxoByte) {
177
+ return minUtxoForOutput({ address, assets: outputAssets(0n), coinsPerUtxoByte });
178
+ }
179
+ /**
180
+ * The 28-byte marker `issuance_mint` is parameterised against so the stored
181
+ * CBOR can be cut either side of its minting-logic argument.
182
+ *
183
+ * ⛔ NOT A PARAMETER, NOT A DEFAULT, AND NEVER DEPLOYED. It is applied and
184
+ * immediately cut back out; what reaches the chain is `pre` and `post` with the
185
+ * marker gone from between them, so that a later registration can splice a REAL
186
+ * minting-logic hash in without re-deriving the script. Passing this value
187
+ * anywhere a minting-logic hash is expected would produce a policy nobody can
188
+ * mint under — it is a cutting guide, not an identifier.
189
+ *
190
+ * ⚑ EXPORTED SO THE OCCURRENCE GUARD CAN BE EXERCISED, and for no other reason.
191
+ * `splitParts.length !== 2` below is the entire reason shipping a fixed hex
192
+ * marker is tolerable — and an unmutated guard is a claim rather than a check.
193
+ * A test cannot construct a body that collides with a marker it cannot see, so
194
+ * the marker is visible. (MEASURED: the `pre`/`post` halves do not depend on
195
+ * this VALUE — two different markers yield byte-identical halves, because the
196
+ * argument occupies the same bit positions either way.)
197
+ */
198
+ export const ISSUANCE_SPLICE_MARKER = "deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeef";
199
+ /**
200
+ * The three withdraw-0 delegates whose registration runs a `publish` handler.
201
+ *
202
+ * Registering emits a Conway RegCert, which executes the script under the
203
+ * PUBLISH purpose — so each needs a publish handler or the transaction dies at
204
+ * evaluation with a bare "machine terminated" and an EMPTY trace list. The
205
+ * check is kept because the failure it diagnoses is undiagnosable without it.
206
+ */
207
+ const REQUIRED_PUBLISH_HANDLERS = [
208
+ "transfer.transfer.publish",
209
+ "third_party.third_party.publish",
210
+ "unfracking.unfracking.publish",
211
+ ];
212
+ // ---------------------------------------------------------------------------
213
+ // Required-input refusals — by name, and before anything is derived
214
+ // ---------------------------------------------------------------------------
215
+ function requireHex(value, field, bytes) {
216
+ if (typeof value !== "string" || value.length === 0) {
217
+ throw new Error(`bootstrap: ${field} is required and must be a non-empty hex string, got ` +
218
+ `${value === undefined ? "undefined" : JSON.stringify(value)}. This SDK ships no ` +
219
+ `default for it — it is a value the caller must decide.`);
220
+ }
221
+ if (!/^[0-9a-fA-F]+$/.test(value) || value.length % 2 !== 0) {
222
+ throw new Error(`bootstrap: ${field} must be an even-length hex string with no 0x prefix, got ` +
223
+ `${JSON.stringify(value)}.`);
224
+ }
225
+ if (bytes !== undefined && value.length !== bytes * 2) {
226
+ throw new Error(`bootstrap: ${field} must be exactly ${bytes} bytes (${bytes * 2} hex chars), got ` +
227
+ `${value.length / 2}.`);
228
+ }
229
+ return value;
230
+ }
231
+ function requireTxInput(value, field) {
232
+ const ref = value;
233
+ if (!ref || typeof ref !== "object") {
234
+ throw new Error(`bootstrap: ${field} is required — a { txHash, outputIndex } naming the one-shot UTxO ` +
235
+ `this policy is parameterised by. Got ${JSON.stringify(value)}.`);
236
+ }
237
+ requireHex(ref.txHash, `${field}.txHash`, 32);
238
+ if (!Number.isInteger(ref.outputIndex) || ref.outputIndex < 0) {
239
+ throw new Error(`bootstrap: ${field}.outputIndex must be a non-negative integer, got ` +
240
+ `${JSON.stringify(ref.outputIndex)}.`);
241
+ }
242
+ return { txHash: ref.txHash.toLowerCase(), outputIndex: ref.outputIndex };
243
+ }
244
+ const refKey = (ref) => `${ref.txHash.toLowerCase()}#${ref.outputIndex}`;
245
+ function requireSeeds(seeds) {
246
+ const s = seeds;
247
+ if (!s || typeof s !== "object") {
248
+ throw new Error(`bootstrap: seeds is required — three DISTINCT one-shot output references, as ` +
249
+ `{ protocolParams, issuance, upgradeMultisig }. Got ${JSON.stringify(seeds)}.`);
250
+ }
251
+ const resolved = {
252
+ protocolParams: requireTxInput(s.protocolParams, "seeds.protocolParams"),
253
+ issuance: requireTxInput(s.issuance, "seeds.issuance"),
254
+ upgradeMultisig: requireTxInput(s.upgradeMultisig, "seeds.upgradeMultisig"),
255
+ };
256
+ // ⛔ DISTINCTNESS IS A REFUSAL, NOT A WARNING. See BOOTSTRAP_SEED_COUNT: two
257
+ // roles sharing one outref deploy fine and make the off-chain derivation
258
+ // check for the shared pair VACUOUS. A check that cannot fail is worse than
259
+ // an absent one, because it reads as coverage.
260
+ const seen = new Map();
261
+ for (const [role, ref] of Object.entries(resolved)) {
262
+ const key = refKey(ref);
263
+ const previous = seen.get(key);
264
+ if (previous) {
265
+ throw new Error(`bootstrap: seeds.${previous} and seeds.${role} are the SAME output reference ` +
266
+ `(${key}). The three one-shot policies must be parameterised by three DISTINCT ` +
267
+ `UTxOs: DeploymentParams carries protocolParams.txInput and upgradeMultisig.txInput ` +
268
+ `as two same-typed fields, and a record that gives them one value makes ` +
269
+ `assertDeploymentScripts pass whichever field the code reads — so the check cannot ` +
270
+ `fail and a real defect in which field is read becomes invisible.`);
271
+ }
272
+ seen.set(key, role);
273
+ }
274
+ return resolved;
275
+ }
276
+ function requirePublishHandlers(blueprint) {
277
+ const titles = blueprint.validators.map((v) => v.title);
278
+ const missing = REQUIRED_PUBLISH_HANDLERS.filter((t) => !titles.includes(t));
279
+ if (missing.length > 0) {
280
+ throw new Error(`This blueprint cannot bootstrap a protocol: it lacks publish handler(s) ` +
281
+ `${missing.join(", ")}, so registering the corresponding stake credential fails at ` +
282
+ `script evaluation (purpose "publish") with no diagnostic.\n` +
283
+ `Blueprint: "${blueprint.preamble.title}" v${blueprint.preamble.version}.\n` +
284
+ `Present handlers: ${titles.filter((t) => t.endsWith(".publish")).join(", ") || "(none)"}`);
285
+ }
286
+ }
287
+ /** Extract the PlutusV3 script body hex (inner UPLC, no outer CBOR wrap). */
288
+ function scriptBodyHex(compiledCode) {
289
+ const level = UPLC.getCborEncodingLevel(compiledCode);
290
+ if (level !== "double")
291
+ return compiledCode;
292
+ const raw = Bytes.fromHex(compiledCode);
293
+ const additionalInfo = raw[0] & 0x1f;
294
+ const headerLen = additionalInfo < 24 ? 1 : additionalInfo === 24 ? 2 : additionalInfo === 25 ? 3 : 5;
295
+ return Bytes.toHex(raw.slice(headerLen));
296
+ }
297
+ // ---------------------------------------------------------------------------
298
+ // planBootstrap
299
+ // ---------------------------------------------------------------------------
300
+ /**
301
+ * Derive a whole protocol instance from a config. Pure: no client, no network,
302
+ * no clock, no filesystem.
303
+ *
304
+ * ⚠ THE PARAMETERISATION ORDER IS LOAD-BEARING and each script's hash feeds the
305
+ * next. It is NOT a single chain — `programmable_logic_base` hangs off the
306
+ * params-NFT policy and everything downstream fans out — but the edges that do
307
+ * exist cannot be reordered. See `scripts.ts` for the full graph.
308
+ *
309
+ * ⛔ PARAMETER TYPES ARE NOT INTERCHANGEABLE AND TYPESCRIPT CANNOT TELL THEM
310
+ * APART: every one is a hex string at the call site. `issuance_logic` in
311
+ * particular takes TWO ADJACENT PolicyIds — `registry_node_cs` then
312
+ * `params_policy` — and swapping them yields a script that builds, hashes,
313
+ * deploys and is registered without a murmur. It simply looks for the registry
314
+ * under the params policy for the rest of its life.
315
+ */
316
+ export function planBootstrap(config) {
317
+ if (!config || typeof config !== "object") {
318
+ throw new Error("planBootstrap: a BootstrapConfig is required.");
319
+ }
320
+ const { blueprint } = config;
321
+ if (!blueprint || typeof blueprint !== "object" || !Array.isArray(blueprint.validators)) {
322
+ throw new Error("planBootstrap: blueprint is required — the CIP-57 standard blueprint this instance is " +
323
+ "built from. Import it via the package's \"./blueprints/*\" export path.");
324
+ }
325
+ validateStandardBlueprint(blueprint);
326
+ requirePublishHandlers(blueprint);
327
+ if (typeof config.networkId !== "number" || !Number.isInteger(config.networkId)) {
328
+ throw new Error(`planBootstrap: networkId is required and must be an integer (0 for any testnet, 1 for ` +
329
+ `mainnet), got ${JSON.stringify(config.networkId)}. Every address this plan derives ` +
330
+ `depends on it, and a wrong one yields addresses that are valid and unreachable.`);
331
+ }
332
+ const seeds = requireSeeds(config.seeds);
333
+ const alwaysFailNonce = requireHex(config.alwaysFailNonce, "alwaysFailNonce");
334
+ if (typeof config.maxInlineDatumBytes !== "bigint" || config.maxInlineDatumBytes <= 0n) {
335
+ throw new Error(`planBootstrap: maxInlineDatumBytes is required and must be a positive bigint, got ` +
336
+ `${JSON.stringify(String(config.maxInlineDatumBytes))}. It is a SECURITY PARAMETER ` +
337
+ `baked into the hashes of transfer, third_party, unfracking and issuance_logic, and ` +
338
+ `this package deliberately ships no default for it — upstream gives no guidance and ` +
339
+ `the devnet fixture's 1024 is explicitly not a recommendation.`);
340
+ }
341
+ if (config.unfracking !== "enabled" && config.unfracking !== "disabled") {
342
+ throw new Error(`planBootstrap: unfracking is required and must be "enabled" or "disabled", got ` +
343
+ `${JSON.stringify(config.unfracking)}. The choice is baked into the dispatcher's hash, ` +
344
+ `so two deployments differing only here are DIFFERENT PROTOCOLS — it cannot be defaulted.`);
345
+ }
346
+ const events = [];
347
+ const builders = createStandardScripts(blueprint, (e) => events.push(e));
348
+ const max = config.maxInlineDatumBytes;
349
+ const alwaysFail = builders.alwaysFail(alwaysFailNonce);
350
+ // Depends on nothing but its own seed, so it can be built first — and its
351
+ // config UTxO must be minted BEFORE the protocol genesis names it. See
352
+ // buildMultisigGenesisTx for why that ordering is not a style choice.
353
+ const upgradeMultisig = builders.upgradeMultisig(seeds.upgradeMultisig);
354
+ // NO NONCE AND NO ORDERING EDGE. protocol_params takes only its one-shot
355
+ // utxo_ref: the mint side no longer depends on a spend-side address, so the
356
+ // cycle that once forced a coordination script to be built first is
357
+ // dissolved. Its hash is BOTH the NFT policy id and the address payment
358
+ // credential — the minting policy naming itself.
359
+ const protocolParams = builders.protocolParams(seeds.protocolParams);
360
+ const paramsPolicy = protocolParams.hash;
361
+ const programmableLogicBase = builders.programmableLogicBase(paramsPolicy);
362
+ // The registry does not touch the params chain at all — it reads its own
363
+ // policy off its own input's payment credential — so it can be built as soon
364
+ // as issuance_cbor_hex_mint exists. Its hash is also BOTH policy and address.
365
+ const issuanceCborHexMint = builders.issuanceCborHexMint(seeds.issuance, alwaysFail.hash);
366
+ const registry = builders.registry(seeds.protocolParams, issuanceCborHexMint.hash);
367
+ // ⚠ progLogicCred is programmable_logic_base's hash, NOT the dispatcher's.
368
+ // The dispatcher is parameterised BY these three, so the reverse would be a
369
+ // parameter cycle: a value derived from your own script hash can never be
370
+ // your own parameter.
371
+ const transfer = builders.transfer(programmableLogicBase.hash, registry.hash, max);
372
+ const thirdParty = builders.thirdParty(programmableLogicBase.hash, registry.hash, max);
373
+ const unfracking = builders.unfracking(programmableLogicBase.hash, registry.hash, max);
374
+ const unfrackingParameter = config.unfracking === "enabled" ? unfracking.hash : UNFRACKING_DISABLED;
375
+ // Built LAST of the delegate family: it names all three at compile time, so
376
+ // replacing ONE delegate requires deploying a new dispatcher too.
377
+ const programmableLogicGlobal = builders.programmableLogicGlobal(transfer.hash, thirdParty.hash, unfrackingParameter);
378
+ // ⛔ PARAMETERS 2 AND 3 ARE TWO ADJACENT PolicyIds AND BOTH ARE `string`:
379
+ // 1 progLogicCred = programmable_logic_base (NOT the dispatcher)
380
+ // 2 registryPolicy <- registry_node_cs
381
+ // 3 paramsPolicy <- params_policy
382
+ // 4 maxInlineDatumBytes
383
+ // Nothing — not the compiler, not the parameteriser, not the hash — can tell
384
+ // a swap from the intended order.
385
+ const issuanceLogic = builders.issuanceLogic(programmableLogicBase.hash, registry.hash, paramsPolicy, max);
386
+ // issuance_mint is parameterised per minting logic, which is not known until
387
+ // a token is registered. Build it once against a marker and store the CBOR
388
+ // either side of it, so registration can splice in the real hash without
389
+ // re-deriving the whole script.
390
+ //
391
+ // ⚑ TWO ARGUMENTS, CREDENTIAL FIRST: `(mintingLogicHash, paramsPolicy)`. The
392
+ // delegate credentials it used to be compiled against now reach it through
393
+ // the params datum instead — which is the whole point of the alpha.4 split.
394
+ const issuanceMarker = builders.issuanceMint(ISSUANCE_SPLICE_MARKER, paramsPolicy);
395
+ const markerBody = scriptBodyHex(issuanceMarker.compiledCode);
396
+ const splitParts = markerBody.split(ISSUANCE_SPLICE_MARKER);
397
+ /**
398
+ * ⛔ A HARD FAILURE, AND IT MUST NOT BECOME A WARNING. The splice reassembles
399
+ * the script from `pre + <real minting logic hash> + post`, so it is correct
400
+ * only while the marker occurs EXACTLY ONCE. Zero means the parameter is no
401
+ * longer inlined where we think; two means the splice would silently rewrite
402
+ * an unrelated byte run.
403
+ *
404
+ * ⚠ And flat UPLC is BIT-packed: a parameter is findable as a whole number of
405
+ * hex bytes only when it happens to land on a byte boundary. That alignment
406
+ * is a property to be MEASURED per artefact, never assumed — this check is
407
+ * what measures it.
408
+ */
409
+ if (splitParts.length !== 2) {
410
+ throw new Error(`planBootstrap: the issuance_mint splice marker appeared ${splitParts.length - 1} times ` +
411
+ `in the compiled body — expected exactly once. The stored IssuanceCborHex datum is ` +
412
+ `reassembled around this cut, so zero occurrences means the parameter is no longer ` +
413
+ `inlined where this code expects, and more than one means the splice would rewrite an ` +
414
+ `unrelated byte run. Blueprint: "${blueprint.preamble.title}" v${blueprint.preamble.version}.`);
415
+ }
416
+ const [cborPre, cborPost] = splitParts;
417
+ const scripts = {
418
+ alwaysFail,
419
+ protocolParams,
420
+ programmableLogicBase,
421
+ issuanceCborHexMint,
422
+ registry,
423
+ transfer,
424
+ thirdParty,
425
+ unfracking,
426
+ programmableLogicGlobal,
427
+ issuanceLogic,
428
+ upgradeMultisig,
429
+ };
430
+ /**
431
+ * SIX fields, written BY NAME and never as a positional spread.
432
+ *
433
+ * ⛔ INDEX 1 CHANGED MEANING BETWEEN alpha.3 AND alpha.4. `issuance_logic_cred`
434
+ * was INSERTED at index 1, displacing `transfer_cred` to index 2. Both are
435
+ * `Credential` — same constructor, same 28 bytes — so a datum written in
436
+ * alpha.3's order with two fields appended is SIX fields long, passes the
437
+ * arity check, passes `params_wellformed` (which only asserts each credential
438
+ * is 28 bytes), decodes into a well-formed record, and hands `issuance_mint`
439
+ * the TRANSFER credential as its issuance authority. Nothing at deploy time
440
+ * catches it. Keying the record is what makes the compiler an ally.
441
+ *
442
+ * ⛔ `pendingUpgradeCred` MUST BE null AT GENESIS. `protocol_params` runs
443
+ * `params_wellformed(genesis_params, is_init: True)`, and `is_init: True` is
444
+ * exactly what forbids a nomination baked into the genesis datum.
445
+ */
446
+ const paramsDatum = protocolParamsDatum({
447
+ plgCred: { type: "script", hash: programmableLogicGlobal.hash },
448
+ issuanceLogicCred: { type: "script", hash: issuanceLogic.hash },
449
+ transferCred: { type: "script", hash: transfer.hash },
450
+ thirdPartyCred: { type: "script", hash: thirdParty.hash },
451
+ upgradeCred: { type: "script", hash: upgradeMultisig.hash },
452
+ pendingUpgradeCred: null,
453
+ });
454
+ // Sentinel head of the registry linked list: key "", next 0xff*30, every
455
+ // delegate slot empty.
456
+ const EMPTY_CRED = { type: "key", hash: "" };
457
+ const originDatum = registryNodeDatum({
458
+ key: "",
459
+ next: "ff".repeat(30),
460
+ mintingLogicScript: EMPTY_CRED,
461
+ transferLogicScript: EMPTY_CRED,
462
+ thirdPartyTransferLogicScript: EMPTY_CRED,
463
+ unfrackingLogicScript: EMPTY_CRED,
464
+ globalStateCs: "",
465
+ });
466
+ const issuanceDatum = Data.constr(0n, [Data.bytearray(cborPre), Data.bytearray(cborPost)]);
467
+ const byName = {
468
+ programmableLogicBase,
469
+ programmableLogicGlobal,
470
+ transfer,
471
+ thirdParty,
472
+ unfracking,
473
+ issuanceLogic,
474
+ upgradeMultisig,
475
+ };
476
+ return {
477
+ config: { ...config, seeds, alwaysFailNonce },
478
+ scripts,
479
+ unfrackingParameter,
480
+ addresses: {
481
+ protocolParams: scriptAddress(config.networkId, paramsPolicy),
482
+ registry: scriptAddress(config.networkId, registry.hash),
483
+ issuanceCborHex: scriptAddress(config.networkId, alwaysFail.hash),
484
+ upgradeMultisig: scriptAddress(config.networkId, upgradeMultisig.hash),
485
+ },
486
+ assetUnits: {
487
+ protocolParamsNft: paramsPolicy + stringToHex("ProtocolParams"),
488
+ // Empty asset name, so the unit IS the policy.
489
+ registryNode: registry.hash,
490
+ issuanceCborHexNft: issuanceCborHexMint.hash + stringToHex("IssuanceCborHex"),
491
+ upgradeMultisigNft: upgradeMultisig.hash + stringToHex("UpgradeMultisig"),
492
+ },
493
+ datums: {
494
+ protocolParams: paramsDatum,
495
+ registryOrigin: originDatum,
496
+ issuanceCborHex: issuanceDatum,
497
+ },
498
+ issuanceCbor: { pre: cborPre, post: cborPost },
499
+ referenceScripts: REFERENCE_SCRIPT_ORDER.map((n) => byName[n]),
500
+ stakeCredentialScripts: STAKE_REGISTRATION_ORDER.map((n) => byName[n]),
501
+ parameterizations: events
502
+ .filter((e) => e.title !== STANDARD_VALIDATORS.ISSUANCE_MINT)
503
+ .map((e) => ({ rawScriptHash: e.rawScriptHash, params: e.params })),
504
+ };
505
+ }
506
+ const isSignBuilder = (built) => "chainResult" in built;
507
+ function requireBuildContext(ctx, step) {
508
+ if (!ctx || typeof ctx !== "object") {
509
+ throw new Error(`bootstrap ${step}: a build context is required.`);
510
+ }
511
+ if (!ctx.client || typeof ctx.client.newTx !== "function") {
512
+ throw new Error(`bootstrap ${step}: client is required — an Evolution SDK ReadOnlyClient or SigningClient. ` +
513
+ `This SDK does not construct clients, hold keys, or name endpoints.`);
514
+ }
515
+ if (typeof ctx.changeAddress !== "string" || ctx.changeAddress.length === 0) {
516
+ throw new Error(`bootstrap ${step}: changeAddress is required (bech32).`);
517
+ }
518
+ if (!Array.isArray(ctx.availableUtxos)) {
519
+ throw new Error(`bootstrap ${step}: availableUtxos is required — exactly the UTxOs this transaction may ` +
520
+ `spend. There is deliberately no default: without it coin selection is free to spend ` +
521
+ `reference-script UTxOs and seed UTxOs a later step still needs.`);
522
+ }
523
+ if (ctx.availableUtxos.length === 0) {
524
+ throw new Error(`bootstrap ${step}: availableUtxos is empty. Nothing can fund this transaction. If you ` +
525
+ `filtered a wallet view, check the filter before checking the wallet.`);
526
+ }
527
+ }
528
+ function buildOptions(ctx) {
529
+ return {
530
+ changeAddress: EvoAddress.fromBech32(ctx.changeAddress),
531
+ availableUtxos: ctx.availableUtxos,
532
+ ...(ctx.evaluator ? { evaluator: ctx.evaluator } : {}),
533
+ };
534
+ }
535
+ /**
536
+ * Build and shape into the SDK's one result type.
537
+ *
538
+ * ⚠ `_signBuilder` is the SAME field every other operation in this package
539
+ * returns, not a new escape hatch: `UnsignedTx` already carries it and the
540
+ * README's quick start already signs through it. Omitting it here would make
541
+ * the bootstrap the only operation whose result cannot be submitted — the
542
+ * defect `dummy.transfer` already recorded once. `cbor` is the supported path
543
+ * and the one a key-holding caller should prefer; this SDK signs nothing.
544
+ */
545
+ async function finish(tx, ctx, step, metadata) {
546
+ const built = await tx.build(buildOptions(ctx));
547
+ const cbor = EvoTransaction.toCBORHex(await built.toTransaction());
548
+ const txHash = isSignBuilder(built) ? built.chainResult().txHash : "";
549
+ return { cbor, txHash, metadata: { step, ...metadata }, _signBuilder: built };
550
+ }
551
+ function requireSeedMatches(utxo, expected, role) {
552
+ if (!utxo || typeof utxo !== "object" || utxo.transactionId === undefined) {
553
+ throw new Error(`bootstrap: the ${role} seed UTxO is required — the unspent output this plan's one-shot ` +
554
+ `policy is parameterised by (${refKey(expected)}).`);
555
+ }
556
+ const actual = {
557
+ txHash: EvoTransactionHash.toHex(utxo.transactionId),
558
+ outputIndex: Number(utxo.index),
559
+ };
560
+ if (refKey(actual) !== refKey(expected)) {
561
+ throw new Error(`bootstrap: the ${role} seed UTxO is ${refKey(actual)}, but this plan was parameterised ` +
562
+ `by ${refKey(expected)}. A one-shot policy's hash is a function of its outref, so ` +
563
+ `spending a different UTxO builds a transaction against a script this deployment does ` +
564
+ `not own — and it fails on chain naming neither. Rebuild the plan from the seeds you ` +
565
+ `actually hold, or pass the seeds this plan names.`);
566
+ }
567
+ }
568
+ /**
569
+ * Step 1 — fragment the wallet into {@link BOOTSTRAP_SEED_COUNT} distinct UTxOs.
570
+ *
571
+ * ⚑ THIS STEP IS OPTIONAL. A caller that already holds three distinct unspent
572
+ * UTxOs may skip it and call {@link planBootstrap} with their outrefs directly.
573
+ * It exists because most callers do not.
574
+ *
575
+ * ⚠ Its outputs cannot be predicted: the plan needs the OBSERVED outrefs. Submit
576
+ * this, wait for it, read the outputs back, then {@link selectBootstrapSeeds}.
577
+ */
578
+ export async function buildSeedTx(params) {
579
+ requireBuildContext(params, "seed");
580
+ if (typeof params.ownerAddress !== "string" || params.ownerAddress.length === 0) {
581
+ throw new Error("bootstrap seed: ownerAddress is required (bech32).");
582
+ }
583
+ if (typeof params.seedLovelace !== "bigint" || params.seedLovelace <= 0n) {
584
+ throw new Error(`bootstrap seed: seedLovelace is required and must be a positive bigint, got ` +
585
+ `${JSON.stringify(String(params.seedLovelace))}. Each seed funds the transaction that ` +
586
+ `consumes it, so it must clear min-UTxO with room for a fee — a figure this package ` +
587
+ `cannot choose for a chain it does not know.`);
588
+ }
589
+ // ⛔ REQUIRED IS NOT THE SAME AS VALID (F-11). A caller passing `1n` built
590
+ // happily and failed only at submission, as "insufficient Ada" — the exact
591
+ // failure min-UTxO solving exists to prevent, arriving from the one figure
592
+ // this builder does NOT solve because the caller chose it.
593
+ const { coinsPerUtxoByte } = await params.client.getProtocolParameters();
594
+ const seedFloor = minLovelaceForPlainOutput(params.ownerAddress, coinsPerUtxoByte);
595
+ if (params.seedLovelace < seedFloor) {
596
+ throw new Error(`bootstrap seed: seedLovelace is ${params.seedLovelace} lovelace, below the min-UTxO ` +
597
+ `floor of ${seedFloor} for a plain output at ${params.ownerAddress} ` +
598
+ `(coinsPerUtxoByte ${coinsPerUtxoByte}). The ledger would reject the seed transaction ` +
599
+ `as "insufficient Ada" at submission. ⚠ This floor is NECESSARY, NOT SUFFICIENT: each ` +
600
+ `seed also has to fund the fee of the transaction that consumes it, and nothing here ` +
601
+ `checks that.`);
602
+ }
603
+ const owner = EvoAddress.fromBech32(params.ownerAddress);
604
+ let tx = params.client.newTx();
605
+ for (let i = 0; i < BOOTSTRAP_SEED_COUNT; i++) {
606
+ tx = tx.payToAddress({ address: owner, assets: EvoAssets.fromLovelace(params.seedLovelace) });
607
+ }
608
+ return finish(tx, params, "seed", { seedCount: BOOTSTRAP_SEED_COUNT });
609
+ }
610
+ /**
611
+ * Pick the three seeds out of a seed transaction's observed outputs.
612
+ *
613
+ * ⚠ REFUSES FEWER THAN THREE, LOUDLY. An earlier version of this logic asked
614
+ * for three and tolerated two, which handed `undefined` to the third consumer.
615
+ *
616
+ * The change output of the seed transaction lands at the same address and is a
617
+ * legitimate candidate; ordering by output index keeps the assignment stable
618
+ * across reads of an indexer that does not preserve order.
619
+ */
620
+ export function selectBootstrapSeeds(utxos, seedTxHash) {
621
+ const wanted = requireHex(seedTxHash, "seedTxHash", 32).toLowerCase();
622
+ const mine = utxos
623
+ .filter((u) => EvoTransactionHash.toHex(u.transactionId).toLowerCase() === wanted)
624
+ .sort((a, b) => Number(a.index) - Number(b.index));
625
+ if (mine.length < BOOTSTRAP_SEED_COUNT) {
626
+ throw new Error(`bootstrap: the seed transaction ${wanted} shows ${mine.length} output(s) in the UTxO set ` +
627
+ `supplied, need ≥${BOOTSTRAP_SEED_COUNT}. If the transaction was submitted, the ` +
628
+ `provider's view has probably not settled yet — wait for it rather than proceeding ` +
629
+ `with fewer, which hands a later step an undefined seed.`);
630
+ }
631
+ const [one, two, three] = mine;
632
+ const ref = (u) => ({
633
+ txHash: EvoTransactionHash.toHex(u.transactionId).toLowerCase(),
634
+ outputIndex: Number(u.index),
635
+ });
636
+ return {
637
+ seeds: {
638
+ protocolParams: ref(one),
639
+ issuance: ref(two),
640
+ upgradeMultisig: ref(three),
641
+ },
642
+ utxos: { protocolParams: one, issuance: two, upgradeMultisig: three },
643
+ };
644
+ }
645
+ /**
646
+ * Step 2 — mint the `upgrade_multisig` one-shot NFT and lock it with the signer
647
+ * tree, BEFORE the protocol genesis names this authority.
648
+ *
649
+ * ⛔ THE ORDER IS THE POINT, AND IT IS NOT A STYLE CHOICE. Step 3 writes a
650
+ * genesis datum naming `upgrade_cred = Script(upgrade_multisig)`. That authority
651
+ * is usable only while its config UTxO exists — the tree lives there, not in the
652
+ * script's parameters. Running this AFTER the protocol genesis and failing
653
+ * leaves a protocol on chain naming an authority whose config UTxO does not
654
+ * exist: upstream's documented ONE-WAY BRICK, manufactured by transaction
655
+ * ordering rather than by any defect in the validators. Failing BEFORE the
656
+ * irreversible step costs one transaction and nothing else.
657
+ *
658
+ * The four rails `upgrade_multisig.mint` enforces, all satisfied here:
659
+ * 1. the named UTxO is consumed -> collectFrom
660
+ * 2. exactly one "UpgradeMultisig" token of this policy -> mintAssets
661
+ * 3. an output found by `has_nft_strict` -> the output below
662
+ * 4. well_formed(tree), NO reference script, address == from_script(policy)
663
+ *
664
+ * ⛔ THE NFT AND NOTHING ELSE in that output. `has_nft_strict` is strict about
665
+ * the WHOLE value: bundling any other asset with the config NFT means the output
666
+ * is simply NOT FOUND by `list.expect_find`, and the genesis fails naming
667
+ * nothing about bundling. Change is a separate output.
668
+ *
669
+ * ⚠ NO `script:` ON THE OUTPUT — rail 4 requires `reference_script == None`.
670
+ * The reference script is published in step 4 like every other one.
671
+ *
672
+ * ⚑ Additional outputs are the CALLER's to add in their own transaction if they
673
+ * want them. Rail 3 uses `list.expect_find`, which SKIPS a non-matching output
674
+ * rather than rejecting it, so a junk UTxO parked at this address is harmless —
675
+ * but it is not protocol state and this step will not mint one.
676
+ */
677
+ export async function buildMultisigGenesisTx(params) {
678
+ requireBuildContext(params, "multisig-genesis");
679
+ const { plan } = params;
680
+ if (!plan || typeof plan !== "object" || !plan.scripts) {
681
+ throw new Error("bootstrap multisig-genesis: plan is required — see planBootstrap().");
682
+ }
683
+ if (!params.upgradeMultisigTree || typeof params.upgradeMultisigTree !== "object") {
684
+ throw new Error(`bootstrap multisig-genesis: upgradeMultisigTree is required — the MultisigScript tree ` +
685
+ `that IS this protocol's upgrade authority. There is no default: shipping one would ` +
686
+ `mean shipping a decision about who controls every deployment made with this package.`);
687
+ }
688
+ requireSeedMatches(params.seedUtxo, plan.config.seeds.upgradeMultisig, "upgradeMultisig");
689
+ // Enforces upstream's `well_formed`; throws by name on an unsatisfiable leaf.
690
+ const datum = multisigScriptDatum(params.upgradeMultisigTree);
691
+ const nftUnit = plan.assetUnits.upgradeMultisigNft;
692
+ const assets = new Map([[nftUnit, 1n]]);
693
+ const coinsPerUtxoByte = (await params.client.getProtocolParameters()).coinsPerUtxoByte;
694
+ const lovelace = genesisOutputLovelace({
695
+ address: plan.addresses.upgradeMultisig,
696
+ assets: outputAssets(0n, assets),
697
+ datum,
698
+ coinsPerUtxoByte,
699
+ });
700
+ let tx = params.client.newTx();
701
+ tx = tx.collectFrom({ inputs: [params.seedUtxo] });
702
+ tx = tx.mintAssets({ assets: mintAssetsFromMap(assets), redeemer: voidData() });
703
+ tx = tx.payToAddress({
704
+ address: EvoAddress.fromBech32(plan.addresses.upgradeMultisig),
705
+ assets: outputAssets(lovelace, assets),
706
+ datum: new InlineDatum.InlineDatum({ data: datum }),
707
+ });
708
+ tx = tx.attachScript({ script: buildEvoScript(plan.scripts.upgradeMultisig.compiledCode) });
709
+ return finish(tx, params, "multisig-genesis", {
710
+ configUtxoOutputIndex: 0,
711
+ nftUnit,
712
+ address: plan.addresses.upgradeMultisig,
713
+ });
714
+ }
715
+ /**
716
+ * Assert the multisig config UTxO on chain is the one the genesis datum is
717
+ * about to name. PURE — the caller fetches the UTxOs, this decides.
718
+ *
719
+ * ⛔ AN OPERABILITY GATE, NOT DECORATION. The measured instance of "correct and
720
+ * useless" in this repo is a deployment naming an authority credential that
721
+ * could not be registered at all: every hash reproduced, every read-back
722
+ * matched, every test passed, and the protocol's upgrade path was permanently
723
+ * unsatisfiable. "It exists and is well-formed" left "and can be used" untested.
724
+ * Run this between step 2 and step 3, so it is structurally impossible for the
725
+ * genesis datum to name an authority that is not there.
726
+ *
727
+ * ⚑ FILTERS STRUCTURALLY, BY POLICY, EXACTLY AS THE VALIDATOR DOES — not by
728
+ * equality against a unit string we ourselves built. A lookup keyed on our own
729
+ * constructed unit shares a blind spot with the code that constructed it: get
730
+ * the asset name wrong in both places and the check agrees with itself.
731
+ */
732
+ export function assertMultisigConfigUtxo(params) {
733
+ const { plan } = params;
734
+ const policy = plan.scripts.upgradeMultisig.hash;
735
+ const address = plan.addresses.upgradeMultisig;
736
+ const candidates = (params.utxosAtAddress ?? []).filter((u) => EvoAssets.getUnits(u.assets).some((unit) => unit !== "lovelace" && unit.slice(0, 56) === policy));
737
+ if (candidates.length !== 1) {
738
+ throw new Error(`upgrade_multisig config UTxO: expected exactly 1 UTxO at ${address} carrying an asset ` +
739
+ `of policy ${policy}, found ${candidates.length}. The NFT is one-shot, so zero means ` +
740
+ `the genesis output is not where the validator locks it, and more than one means this ` +
741
+ `address is not what we think it is. Refusing to let a genesis datum name an authority ` +
742
+ `whose config UTxO is not exactly one well-formed UTxO.`);
743
+ }
744
+ const utxo = candidates[0];
745
+ const onChain = getInlineDatum(utxo);
746
+ if (!onChain) {
747
+ throw new Error(`upgrade_multisig config UTxO at ${address} carries no inline datum. The tree IS the ` +
748
+ `authority; without it the credential is unsatisfiable and there is no repair path.`);
749
+ }
750
+ // ⚠ `decodeMultisigScript` deliberately enforces none of upstream's
751
+ // `well_formed` rules — that asymmetry with the encoder is intentional — so
752
+ // the SHAPE is asserted here, against what the caller said it minted.
753
+ const tree = decodeMultisigScript(onChain);
754
+ if (!sameTree(tree, params.expectedTree)) {
755
+ throw new Error(`upgrade_multisig config UTxO holds a different authority tree than the one this ` +
756
+ `bootstrap minted.\n expected: ${JSON.stringify(params.expectedTree, replacer)}\n ` +
757
+ `on chain: ${JSON.stringify(tree, replacer)}\nAn authority nobody can satisfy is a ` +
758
+ `permanent brick with no repair path, so this refuses before the genesis rather than ` +
759
+ `after it.`);
760
+ }
761
+ return {
762
+ utxo,
763
+ ref: {
764
+ txHash: EvoTransactionHash.toHex(utxo.transactionId).toLowerCase(),
765
+ outputIndex: Number(utxo.index),
766
+ },
767
+ };
768
+ }
769
+ const replacer = (_k, v) => (typeof v === "bigint" ? `${v}` : v);
770
+ /** Structural equality for a MultisigScript tree. Order-sensitive, as the encoding is. */
771
+ function sameTree(a, b) {
772
+ if (a.type !== b.type)
773
+ return false;
774
+ switch (a.type) {
775
+ case "signature":
776
+ return a.keyHash.toLowerCase() === b.keyHash.toLowerCase();
777
+ case "script":
778
+ return a.scriptHash.toLowerCase() === b.scriptHash.toLowerCase();
779
+ case "before":
780
+ case "after":
781
+ return a.time === b.time;
782
+ case "at-least":
783
+ if (a.required !== b.required)
784
+ return false;
785
+ // falls through — the child list is compared the same way
786
+ case "all-of":
787
+ case "any-of": {
788
+ const bs = b.scripts;
789
+ const as = a.scripts;
790
+ return as.length === bs.length && as.every((c, i) => sameTree(c, bs[i]));
791
+ }
792
+ }
793
+ }
794
+ /**
795
+ * Step 3 — the protocol genesis: three one-shot mints and the three UTxOs that
796
+ * hold this instance's state.
797
+ *
798
+ * Outputs, and the indices are POSITIONAL — `assembleDeploymentParams` reads
799
+ * output 0 and nothing checks the rest but the chain:
800
+ * 0 the params UTxO (NFT + the six-field params datum)
801
+ * 1 the registry origin node (NFT + the sentinel head of the linked list)
802
+ * 2 the issuance CBOR UTxO (NFT + the spliced issuance_mint body)
803
+ */
804
+ export async function buildProtocolGenesisTx(params) {
805
+ requireBuildContext(params, "protocol-genesis");
806
+ const { plan } = params;
807
+ if (!plan || typeof plan !== "object" || !plan.scripts) {
808
+ throw new Error("bootstrap protocol-genesis: plan is required — see planBootstrap().");
809
+ }
810
+ requireSeedMatches(params.protocolParamsSeedUtxo, plan.config.seeds.protocolParams, "protocolParams");
811
+ requireSeedMatches(params.issuanceSeedUtxo, plan.config.seeds.issuance, "issuance");
812
+ const coinsPerUtxoByte = (await params.client.getProtocolParameters()).coinsPerUtxoByte;
813
+ const paramsNft = new Map([[plan.assetUnits.protocolParamsNft, 1n]]);
814
+ const registryNft = new Map([[plan.assetUnits.registryNode, 1n]]);
815
+ const issuanceNft = new Map([[plan.assetUnits.issuanceCborHexNft, 1n]]);
816
+ let tx = params.client.newTx();
817
+ if (params.provenancePin) {
818
+ const record = buildCip171RecordFromPin(plan.config.blueprint, params.provenancePin, plan.parameterizations);
819
+ tx = tx.attachMetadata({
820
+ label: CIP171_METADATA_LABEL,
821
+ metadata: buildCip171Metadatum(record),
822
+ });
823
+ }
824
+ tx = tx.collectFrom({ inputs: [params.protocolParamsSeedUtxo, params.issuanceSeedUtxo] });
825
+ // ⚠ THREE POLICIES, THREE REDEEMERS, EACH THE GENESIS ARM OF ITS OWN
826
+ // VALIDATOR. The indices are not a shared enum and do not correspond to each
827
+ // other; they are copied verbatim from the sequence proven on chain.
828
+ tx = tx.mintAssets({
829
+ assets: mintAssetsFromMap(registryNft),
830
+ redeemer: registryInitRedeemer(),
831
+ });
832
+ tx = tx.mintAssets({
833
+ assets: mintAssetsFromMap(paramsNft),
834
+ redeemer: Data.constr(1n, []),
835
+ });
836
+ tx = tx.mintAssets({
837
+ assets: mintAssetsFromMap(issuanceNft),
838
+ redeemer: Data.constr(2n, []),
839
+ });
840
+ // ⚠ SOLVED, NOT FLAT, for every datum-bearing output here. The params datum
841
+ // grew from four fields to six and the origin node from five to seven;
842
+ // min-UTxO scales with serialised output size, and an under-funded output is
843
+ // reported as "insufficient Ada" — which sends the reader to the wallet
844
+ // balance rather than to the datum.
845
+ tx = tx.payToAddress({
846
+ address: EvoAddress.fromBech32(plan.addresses.protocolParams),
847
+ assets: outputAssets(genesisOutputLovelace({
848
+ address: plan.addresses.protocolParams,
849
+ assets: outputAssets(0n, paramsNft),
850
+ datum: plan.datums.protocolParams,
851
+ coinsPerUtxoByte,
852
+ }), paramsNft),
853
+ datum: new InlineDatum.InlineDatum({ data: plan.datums.protocolParams }),
854
+ });
855
+ tx = tx.payToAddress({
856
+ address: EvoAddress.fromBech32(plan.addresses.registry),
857
+ assets: outputAssets(ceilToWholeAda(minUtxoAtLeast(REGISTRY_NODE_MIN_ADA, {
858
+ address: plan.addresses.registry,
859
+ assets: outputAssets(0n, registryNft),
860
+ datum: plan.datums.registryOrigin,
861
+ coinsPerUtxoByte,
862
+ })), registryNft),
863
+ datum: new InlineDatum.InlineDatum({ data: plan.datums.registryOrigin }),
864
+ });
865
+ // The issuance datum carries the whole spliced script body, so its min-UTxO
866
+ // is an order of magnitude above the others. Solved for the same reason.
867
+ tx = tx.payToAddress({
868
+ address: EvoAddress.fromBech32(plan.addresses.issuanceCborHex),
869
+ assets: outputAssets(genesisOutputLovelace({
870
+ address: plan.addresses.issuanceCborHex,
871
+ assets: outputAssets(0n, issuanceNft),
872
+ datum: plan.datums.issuanceCborHex,
873
+ coinsPerUtxoByte,
874
+ }), issuanceNft),
875
+ datum: new InlineDatum.InlineDatum({ data: plan.datums.issuanceCborHex }),
876
+ });
877
+ tx = tx.attachScript({ script: buildEvoScript(plan.scripts.registry.compiledCode) });
878
+ tx = tx.attachScript({ script: buildEvoScript(plan.scripts.protocolParams.compiledCode) });
879
+ tx = tx.attachScript({ script: buildEvoScript(plan.scripts.issuanceCborHexMint.compiledCode) });
880
+ return finish(tx, params, "protocol-genesis", {
881
+ outputIndices: { protocolParams: 0, registryOrigin: 1, issuanceCborHex: 2 },
882
+ cip171: Boolean(params.provenancePin),
883
+ });
884
+ }
885
+ /**
886
+ * Step 4 — publish the seven reference scripts, in {@link REFERENCE_SCRIPT_ORDER}.
887
+ *
888
+ * Cannot be folded into step 3: a transaction cannot reference a script it is
889
+ * itself creating.
890
+ */
891
+ export async function buildReferenceScriptsTx(params) {
892
+ requireBuildContext(params, "reference-scripts");
893
+ const { plan } = params;
894
+ if (!plan || typeof plan !== "object" || !plan.referenceScripts) {
895
+ throw new Error("bootstrap reference-scripts: plan is required — see planBootstrap().");
896
+ }
897
+ if (typeof params.referenceScriptAddress !== "string" ||
898
+ params.referenceScriptAddress.length === 0) {
899
+ throw new Error(`bootstrap reference-scripts: referenceScriptAddress is required (bech32). These outputs ` +
900
+ `are protocol infrastructure for the life of the deployment; where they live is a ` +
901
+ `decision, not a default.`);
902
+ }
903
+ if (typeof params.referenceScriptLovelace !== "bigint" || params.referenceScriptLovelace <= 0n) {
904
+ throw new Error(`bootstrap reference-scripts: referenceScriptLovelace is required and must be a positive ` +
905
+ `bigint, got ${JSON.stringify(String(params.referenceScriptLovelace))}. min-UTxO for a ` +
906
+ `script-bearing output scales with the script's size.`);
907
+ }
908
+ // ⛔ REQUIRED IS NOT THE SAME AS VALID (F-11), and a script-bearing output is
909
+ // where the gap bites hardest: the script's own bytes are part of the
910
+ // serialised output, so its min-UTxO is an order of magnitude above a plain
911
+ // one. A caller passing a plain-output figure builds happily and is refused
912
+ // at submission as "insufficient Ada".
913
+ //
914
+ // ⚠ A SOUND LOWER BOUND, NOT THE EXACT FIGURE. `minUtxoForOutput` takes no
915
+ // script, so this adds the largest script's own bytes at `coinsPerUtxoByte`
916
+ // each. The true requirement is higher by the script ref's CBOR wrapping —
917
+ // which is why this REFUSES BELOW the bound and never reports it as
918
+ // sufficient.
919
+ const { coinsPerUtxoByte, maxTxSize } = await params.client.getProtocolParameters();
920
+ let largest = { name: "(none)", bytes: 0 };
921
+ plan.referenceScripts.forEach((script, i) => {
922
+ const bytes = scriptBodyHex(script.compiledCode).length / 2;
923
+ if (bytes > largest.bytes)
924
+ largest = { name: REFERENCE_SCRIPT_ORDER[i], bytes };
925
+ });
926
+ const refFloor = minLovelaceForPlainOutput(params.referenceScriptAddress, coinsPerUtxoByte) +
927
+ coinsPerUtxoByte * BigInt(largest.bytes);
928
+ if (params.referenceScriptLovelace < refFloor) {
929
+ throw new Error(`bootstrap reference-scripts: referenceScriptLovelace is ` +
930
+ `${params.referenceScriptLovelace} lovelace, below the ${refFloor} a script-bearing ` +
931
+ `output needs at coinsPerUtxoByte ${coinsPerUtxoByte} — the largest script here is ` +
932
+ `${largest.name} at ${largest.bytes} bytes, and a script's own bytes count toward ` +
933
+ `min-UTxO. The ledger would reject this as "insufficient Ada" at submission. ⚠ The ` +
934
+ `figure quoted is a LOWER BOUND: the true requirement is higher by the script ref's ` +
935
+ `CBOR wrapping.`);
936
+ }
937
+ const to = EvoAddress.fromBech32(params.referenceScriptAddress);
938
+ let tx = params.client.newTx();
939
+ for (const script of plan.referenceScripts) {
940
+ tx = tx.payToAddress({
941
+ address: to,
942
+ assets: outputAssets(params.referenceScriptLovelace),
943
+ script: buildEvoScript(script.compiledCode),
944
+ });
945
+ }
946
+ const unsigned = await finish(tx, params, "reference-scripts", {
947
+ order: [...REFERENCE_SCRIPT_ORDER],
948
+ outputIndices: Object.fromEntries(REFERENCE_SCRIPT_ORDER.map((n, i) => [n, i])),
949
+ });
950
+ // ⛔ THE SIZE CAP, CHECKED HERE RATHER THAN DISCOVERED AT SUBMISSION (F-9).
951
+ // This transaction carries every published script body at once and is the
952
+ // one that grows when REFERENCE_SCRIPT_ORDER grows — MEASURED at 12,496 of
953
+ // 16,384 bytes for the current seven. Unsigned, so this is a LOWER BOUND on
954
+ // the signed size; it refuses the certain failures and cannot promise the
955
+ // marginal ones.
956
+ const bytes = unsigned.cbor.length / 2;
957
+ if (bytes >= maxTxSize) {
958
+ throw new Error(`bootstrap reference-scripts: the unsigned transaction publishing ` +
959
+ `${plan.referenceScripts.length} reference scripts is ${bytes} bytes, at or over this ` +
960
+ `chain's ${maxTxSize}-byte maximum — and witnesses have not been added yet. Split the ` +
961
+ `publication across two transactions and record both hashes; do not shorten ` +
962
+ `REFERENCE_SCRIPT_ORDER, whose indices a deployment record already depends on.`);
963
+ }
964
+ return { ...unsigned, metadata: { ...unsigned.metadata, unsignedBytes: bytes, maxTxSize } };
965
+ }
966
+ /**
967
+ * Step 5 — the six Conway `RegCert`s, in one transaction.
968
+ *
969
+ * ⛔ `registerStake` + `attachScript` + a void redeemer, NEVER
970
+ * `registerAndDelegateTo`, FOR A SCRIPT CREDENTIAL. MEASURED: a combined
971
+ * certificate is a Conway `vote_reg_deleg_cert`, which arrives at the `publish`
972
+ * handler as a DIFFERENT `Certificate` constructor, and both
973
+ * `upgrade_multisig.publish` and `issuance_logic.publish` admit
974
+ * `RegisterCredential` and nothing else.
975
+ *
976
+ * ⚠ The DRep delegation a KEY credential needs is not missing here by oversight.
977
+ * A script withdraw-0 needs three things — a script witness, a registration, and
978
+ * the withdrawal itself. The DRep delegation is the FOURTH thing a key
979
+ * credential needs, and a script cannot have one.
980
+ *
981
+ * ⚠ Kept separate from step 3 because each certificate executes its script under
982
+ * the PUBLISH purpose: carrying all six script bodies alongside the mint
983
+ * witnesses is what breaks the 16,384-byte size cap. MEASURED at 21,816 bytes
984
+ * when this was one transaction.
985
+ */
986
+ export async function buildStakeRegistrationTx(params) {
987
+ requireBuildContext(params, "stake-registrations");
988
+ const { plan } = params;
989
+ if (!plan || typeof plan !== "object" || !plan.stakeCredentialScripts) {
990
+ throw new Error("bootstrap stake-registrations: plan is required — see planBootstrap().");
991
+ }
992
+ let tx = params.client.newTx();
993
+ for (const script of plan.stakeCredentialScripts) {
994
+ tx = tx.registerStake({
995
+ stakeCredential: Credential.makeScriptHash(Bytes.fromHex(script.hash)),
996
+ redeemer: voidData(),
997
+ });
998
+ tx = tx.attachScript({ script: buildEvoScript(script.compiledCode) });
999
+ }
1000
+ return finish(tx, params, "stake-registrations", {
1001
+ order: [...STAKE_REGISTRATION_ORDER],
1002
+ credentials: plan.stakeCredentialScripts.map((s) => s.hash),
1003
+ });
1004
+ }
1005
+ /**
1006
+ * Assemble `DeploymentParams` from the plan and what was observed. Pure.
1007
+ *
1008
+ * ⛔ THE RECORD MUST SAY WHAT THE DATUM SAYS. `upgradeAuthority` is the
1009
+ * deployment's account of the ACTIVE authority and the genesis datum writes
1010
+ * `upgradeCred: Script(upgradeMultisig)`. Nothing derives one from the other and
1011
+ * nothing may check them against each other on chain — which is exactly why
1012
+ * both come from the same plan here, rather than being written twice.
1013
+ */
1014
+ export function assembleDeploymentParams(plan, observed) {
1015
+ if (!plan || typeof plan !== "object" || !plan.scripts) {
1016
+ throw new Error("assembleDeploymentParams: plan is required — see planBootstrap().");
1017
+ }
1018
+ const genesisTxHash = requireHex(observed?.protocolGenesisTxHash, "observed.protocolGenesisTxHash", 32).toLowerCase();
1019
+ const refTxHash = requireHex(observed?.referenceScriptsTxHash, "observed.referenceScriptsTxHash", 32).toLowerCase();
1020
+ const multisigUtxo = requireTxInput(observed?.multisigConfigUtxo, "observed.multisigConfigUtxo");
1021
+ const refIdx = (name) => ({
1022
+ txHash: refTxHash,
1023
+ outputIndex: REFERENCE_SCRIPT_ORDER.indexOf(name),
1024
+ });
1025
+ const s = plan.scripts;
1026
+ return {
1027
+ txHash: genesisTxHash,
1028
+ protocolParams: {
1029
+ txInput: plan.config.seeds.protocolParams,
1030
+ // ONE value: policy id AND address payment credential.
1031
+ policyId: s.protocolParams.hash,
1032
+ utxo: { txHash: genesisTxHash, outputIndex: 0 },
1033
+ },
1034
+ programmableLogicBase: { scriptHash: s.programmableLogicBase.hash },
1035
+ transfer: { scriptHash: s.transfer.hash },
1036
+ thirdParty: { scriptHash: s.thirdParty.hash },
1037
+ unfracking: { scriptHash: s.unfracking.hash },
1038
+ programmableLogicGlobal: {
1039
+ scriptHash: s.programmableLogicGlobal.hash,
1040
+ // ⛔ WHAT THE DISPATCHER WAS COMPILED AGAINST, and it is NOT derivable
1041
+ // from `unfracking.scriptHash` above: a deployment may have unfracking
1042
+ // fully deployed, published and recorded while compiling this dispatcher
1043
+ // against the sentinel, so the arm can never be satisfied. Two fields,
1044
+ // two different facts, and neither may be defaulted from the other.
1045
+ unfrackingParameter: plan.unfrackingParameter,
1046
+ },
1047
+ maxInlineDatumBytes: Number(plan.config.maxInlineDatumBytes),
1048
+ issuanceLogic: { scriptHash: s.issuanceLogic.hash },
1049
+ upgradeMultisig: {
1050
+ scriptHash: s.upgradeMultisig.hash,
1051
+ // ⚠ The upgradeMultisig seed, and NOT the protocolParams one. Same type,
1052
+ // not interchangeable — see BOOTSTRAP_SEED_COUNT.
1053
+ txInput: plan.config.seeds.upgradeMultisig,
1054
+ utxo: multisigUtxo,
1055
+ },
1056
+ upgradeAuthority: { type: "script", hash: s.upgradeMultisig.hash },
1057
+ issuance: {
1058
+ txInput: plan.config.seeds.issuance,
1059
+ policyId: s.issuanceCborHexMint.hash,
1060
+ alwaysFailScriptHash: s.alwaysFail.hash,
1061
+ },
1062
+ registry: {
1063
+ txInput: plan.config.seeds.protocolParams,
1064
+ issuanceScriptHash: s.issuanceCborHexMint.hash,
1065
+ // ONE value again: node NFT policy AND node address payment credential.
1066
+ scriptHash: s.registry.hash,
1067
+ },
1068
+ programmableBaseRefInput: refIdx("programmableLogicBase"),
1069
+ programmableLogicGlobalRefInput: refIdx("programmableLogicGlobal"),
1070
+ transferRefInput: refIdx("transfer"),
1071
+ thirdPartyRefInput: refIdx("thirdParty"),
1072
+ unfrackingRefInput: refIdx("unfracking"),
1073
+ issuanceLogicRefInput: refIdx("issuanceLogic"),
1074
+ upgradeMultisigRefInput: refIdx("upgradeMultisig"),
1075
+ };
1076
+ }
1077
+ //# sourceMappingURL=bootstrap.js.map