@easy1staking/cip113-sdk-ts 0.7.0 → 0.10.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.
Files changed (35) hide show
  1. package/README.md +118 -2
  2. package/blueprints/standard/v0.5.0-alpha.3/UPSTREAM_PIN.json +35 -0
  3. package/blueprints/standard/v0.5.0-alpha.3/plutus.json +1126 -0
  4. package/blueprints/standard/v0.5.0-alpha.4/UPSTREAM_PIN.json +36 -0
  5. package/blueprints/standard/v0.5.0-alpha.4/plutus.json +1434 -0
  6. package/dist/core/evo-utils.d.ts +357 -39
  7. package/dist/core/evo-utils.d.ts.map +1 -1
  8. package/dist/core/evo-utils.js +513 -27
  9. package/dist/core/evo-utils.js.map +1 -1
  10. package/dist/core/ledger-order.d.ts +204 -18
  11. package/dist/core/ledger-order.d.ts.map +1 -1
  12. package/dist/core/ledger-order.js +385 -28
  13. package/dist/core/ledger-order.js.map +1 -1
  14. package/dist/index.d.ts +6 -6
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +15 -4
  17. package/dist/index.js.map +1 -1
  18. package/dist/standard/blueprint.d.ts +129 -29
  19. package/dist/standard/blueprint.d.ts.map +1 -1
  20. package/dist/standard/blueprint.js +245 -47
  21. package/dist/standard/blueprint.js.map +1 -1
  22. package/dist/standard/scripts.d.ts +189 -75
  23. package/dist/standard/scripts.d.ts.map +1 -1
  24. package/dist/standard/scripts.js +454 -123
  25. package/dist/standard/scripts.js.map +1 -1
  26. package/dist/substandards/dummy/index.d.ts +19 -0
  27. package/dist/substandards/dummy/index.d.ts.map +1 -1
  28. package/dist/substandards/dummy/index.js +322 -73
  29. package/dist/substandards/dummy/index.js.map +1 -1
  30. package/dist/substandards/freeze-and-seize/index.d.ts.map +1 -1
  31. package/dist/substandards/freeze-and-seize/index.js +455 -117
  32. package/dist/substandards/freeze-and-seize/index.js.map +1 -1
  33. package/dist/types.d.ts +161 -48
  34. package/dist/types.d.ts.map +1 -1
  35. package/package.json +2 -1
@@ -8,8 +8,28 @@
8
8
  import { CIP171_METADATA_LABEL, buildCip171Metadatum } from "../../core/cip171.js";
9
9
  import { Address as EvoAddress, Assets, Data, Transaction, TransactionHash as EvoTransactionHash, TransactionInput as EvoTransactionInput, } from "@evolution-sdk/evolution";
10
10
  import { sortTxInputs, findRefInputIndex, findRegistryNode, findCoveringNode, utxoToTxInput, } from "../../core/registry.js";
11
- import { buildEvoScript, scriptAddress, rewardAddress, baseAddress, stakingCredentialHash, paymentCredentialHash, stringToHex, MAX_NEXT, voidData, registryNodeDatum, decodeRegistryNode, blacklistNodeDatum, mintingProofOutputIndex, mintingProofRefInput, registryInsertRedeemer, blacklistInitRedeemer, blacklistAddRedeemer, blacklistRemoveRedeemer, extractConstrBytesField, getInlineDatum, utxoUnitQty, utxoLovelace, utxoTxHash, utxoOutputIndex, outputAssets, mintAssetsFromMap, REGISTRY_NODE_MIN_ADA, Credential, KeyHash, InlineDatum, labeledAssetName, buildCIP68FTDatum, } from "../../core/evo-utils.js";
12
- import { baseSpendRedeemer, transferRedeemer, thirdPartyRedeemer, withdrawalIndexOf, } from "../../core/ledger-order.js";
11
+ import { buildEvoScript, scriptAddress, rewardAddress, baseAddress, stakingCredentialHash, paymentCredentialHash, stringToHex, MAX_NEXT, voidData, registryNodeDatum, decodeRegistryNode, blacklistNodeDatum, registryInsertRedeemer, blacklistInitRedeemer, blacklistAddRedeemer, blacklistRemoveRedeemer, extractConstrBytesField, getInlineDatum, assertProtocolParamsIssuanceLogic, utxoUnitQty, utxoLovelace, utxoTxHash, utxoOutputIndex, outputAssets, mintAssetsFromMap, REGISTRY_NODE_MIN_ADA, Credential, KeyHash, InlineDatum, labeledAssetName, buildCIP68FTDatum, inlineDatumBytes, assertInlineDatumWithinBound, minUtxoAtLeast, ceilToWholeAda, } from "../../core/evo-utils.js";
12
+ import { baseSpendRedeemer, transferRedeemer, thirdPartyRedeemer, plbWithdrawalPlan, issuancePlan, programmableLogicGlobalRedeemer, } from "../../core/ledger-order.js";
13
+ /**
14
+ * The flat amounts this plugin used to hardcode, kept as FLOORS rather than as
15
+ * answers — see `minUtxoAtLeast`. Every output that carries caller-controlled
16
+ * bytes now computes its requirement from live protocol parameters and takes
17
+ * the larger of the two, so ordinary inputs emit exactly what they always did
18
+ * and only the cases that were genuinely short move.
19
+ *
20
+ * MEASURED 2026-09-01 against preview (`coinsPerUtxoByte` 4310), which is how
21
+ * we know these were not merely theoretical:
22
+ * - the (100) reference output needed 3,021,310 with every CIP-68 field at a
23
+ * consumer's own documented caps and a 12-byte asset name — the flat
24
+ * 3,000,000 was SHORT by 21,310. With a maximal 32-byte CIP-67 name the
25
+ * same datum needs 3,111,820. Both axes are caller-controlled and this
26
+ * package caps NEITHER, so the true ceiling is unbounded.
27
+ * - a token output with a 32-byte CIP-67 asset name needed 1,318,860 — the
28
+ * flat 1,300,000 was SHORT by 18,860, rising to 44,720 at a maximal
29
+ * quantity.
30
+ */
31
+ const TOKEN_OUTPUT_FLOOR = 1300000n;
32
+ const CIP68_REFERENCE_OUTPUT_FLOOR = 3000000n;
13
33
  import { createFESScripts } from "./scripts.js";
14
34
  // ---------------------------------------------------------------------------
15
35
  // Helpers
@@ -77,14 +97,27 @@ builder, changeAddress, availableUtxos, passAdditionalUtxos = false) {
77
97
  * next run failed with 3011.
78
98
  */
79
99
  function spendableWalletUtxos(walletUtxos, deployment) {
80
- const reserved = new Set([
100
+ const reservedRefs = [
81
101
  deployment.programmableBaseRefInput,
102
+ // alpha.3: the dispatcher's reference script joined the set the bootstrap
103
+ // publishes. Naming it here keeps the explicit list complete — the general
104
+ // scriptRef rule below already covers it, but a named list that silently
105
+ // omits a live deployment's script invites the next reader to trust it.
106
+ // alpha.4 adds issuance_logic and upgrade_multisig; the newest member is
107
+ // exactly the one least likely to be covered anywhere else.
108
+ deployment.programmableLogicGlobalRefInput,
82
109
  deployment.transferRefInput,
83
110
  deployment.thirdPartyRefInput,
84
111
  deployment.unfrackingRefInput,
85
- ]
86
- .filter(Boolean)
87
- .map((r) => `${r.txHash}#${r.outputIndex}`));
112
+ deployment.issuanceLogicRefInput,
113
+ deployment.upgradeMultisigRefInput,
114
+ ].filter(Boolean);
115
+ if (reservedRefs.length !== 7) {
116
+ throw new Error(`spendableWalletUtxos: the named deployment reference-script reservation list must ` +
117
+ `contain exactly 7 entries; got ${reservedRefs.length}. A membership-only list ` +
118
+ `decays into a stale subset and silently stops protecting its newest member.`);
119
+ }
120
+ const reserved = new Set(reservedRefs.map((r) => `${r.txHash}#${r.outputIndex}`));
88
121
  return walletUtxos.filter((u) => {
89
122
  // ⛔ ANY UTxO CARRYING A REFERENCE SCRIPT IS OFF LIMITS, not merely the four
90
123
  // this deployment names.
@@ -133,11 +166,15 @@ function selectUtxosForAmount(utxos, unit, requiredAmount) {
133
166
  async function findProtocolParamsUtxo(client, networkId, deployment) {
134
167
  const ppUnit = deployment.protocolParams.policyId + stringToHex("ProtocolParams");
135
168
  const addr = EvoAddress.fromBech32(
136
- // 0.5.x: params NFT lives at coordination_spend, not always_fail.
137
- scriptAddress(networkId, deployment.coordination.scriptHash));
169
+ // alpha.3: protocol_params is one validator whose hash is BOTH the NFT
170
+ // policy and the address holding it — the minting policy naming itself.
171
+ scriptAddress(networkId, deployment.protocolParams.policyId));
138
172
  const utxos = await client.getUtxosWithUnit(addr, ppUnit);
139
- if (utxos.length > 0)
140
- return utxos[0];
173
+ if (utxos.length > 0) {
174
+ const utxo = utxos[0];
175
+ assertProtocolParamsIssuanceLogic(utxo, deployment.issuanceLogic.scriptHash);
176
+ return utxo;
177
+ }
141
178
  throw new Error(`Protocol params UTxO not found (unit: ${ppUnit})`);
142
179
  }
143
180
  /** Find issuance CBOR hex UTxO */
@@ -149,6 +186,28 @@ async function findIssuanceCborHexUtxo(client, networkId, deployment) {
149
186
  return utxos[0];
150
187
  throw new Error(`Issuance CBOR hex UTxO not found (unit: ${icUnit})`);
151
188
  }
189
+ /**
190
+ * The `issuance_logic` reference-script UTxO.
191
+ *
192
+ * Its withdraw-0 rides on every mint and burn, making this the most
193
+ * load-bearing reference-script output in the deployment. Attaching the body
194
+ * here would hide a spent deployment output instead of naming the breakage.
195
+ */
196
+ async function findIssuanceLogicRefUtxo(client, deployment) {
197
+ const { txHash, outputIndex } = deployment.issuanceLogicRefInput;
198
+ const utxos = await client.getUtxosByOutRef([
199
+ new EvoTransactionInput.TransactionInput({
200
+ transactionId: EvoTransactionHash.fromHex(txHash),
201
+ index: BigInt(outputIndex),
202
+ }),
203
+ ]);
204
+ if (utxos.length === 0) {
205
+ throw new Error(`issuance_logic reference script not found on-chain at ${txHash}#${outputIndex}. ` +
206
+ `Deployment reference scripts are load-bearing: if an earlier transaction SPENT this ` +
207
+ `output, the deployment is broken and must be re-published.`);
208
+ }
209
+ return utxos[0];
210
+ }
152
211
  /** Find the NFT unit in a covering node's value that belongs to a given policy */
153
212
  function findCoveringNodeNftUnit(utxo, policyId) {
154
213
  const units = Assets.getUnits(utxo.assets);
@@ -191,14 +250,18 @@ export function freezeAndSeizeSubstandard(config) {
191
250
  // ---------------------------------------------------------------------
192
251
  //
193
252
  // The blanket refusal that D-14 installed is GONE: `register`, `mint`,
194
- // `transfer`, `initCompliance`, `freeze` and `unfreeze` are migrated to
195
- // CIP-113 0.5.0-alpha.2 and this plugin initialises.
253
+ // Every operation is migrated to CIP-113 0.5.0-alpha.3.
254
+ //
255
+ // `seize` and `burn` take the THIRD-PARTY route: the standalone
256
+ // `third_party` validator's withdraw-0 with a ThirdPartyRedeemer, reached
257
+ // through the `programmable_logic_global` dispatcher, plus a
258
+ // BaseSpendRedeemer on every programmable input.
196
259
  //
197
- // `seize` and `burn` are migrated too (W-E S-5): both take the
198
- // THIRD-PARTY route, which #110 moved off the deleted `ThirdPartyAct` and
199
- // onto the standalone `third_party` validator's withdraw-0 with a
200
- // ThirdPartyRedeemer, plus BaseSpendRedeemer(SpendViaThirdParty) on every
201
- // programmable input.
260
+ // In alpha.2 the dispatch choice lived on that BaseSpendRedeemer as
261
+ // `SpendViaThirdParty`. alpha.3 made the redeemer a single-constructor
262
+ // record and moved the choice to the dispatcher's own redeemer. The two
263
+ // encodings are BYTE-IDENTICAL for the transfer arm, so a stale builder
264
+ // is not rejected — see the guards in core/ledger-order.ts.
202
265
  //
203
266
  // ⚠ The seize/burn paths still need re-validating against upstream #79
204
267
  // (UTxO contamination), #80 (issuance delegation scope) and #115 (datum
@@ -246,9 +309,27 @@ export function freezeAndSeizeSubstandard(config) {
246
309
  // Reference token (only when CIP-68)
247
310
  const refAssetNameHex = hasCIP68 ? labeledAssetName(100, assetNameHex) : null;
248
311
  const refUnit = refAssetNameHex ? scripts.tokenPolicyId + refAssetNameHex : null;
312
+ let cip68Datum;
313
+ let cip68DatumBytes;
314
+ if (hasCIP68) {
315
+ cip68Datum = buildCIP68FTDatum(params.cip68Metadata);
316
+ // alpha.4's issuance_logic `no_escape` now applies
317
+ // is_seizable_output_shape_bounded to PLB outputs carrying the minted
318
+ // policy. lib/assets.ak marks CIP-68's former exemption "(expired)": a
319
+ // data-URI logo over the bound fails on chain with an empty trace.
320
+ // Refuse before selecting UTxOs or constructing the transaction so the
321
+ // caller gets the measured, named diagnosis instead.
322
+ assertInlineDatumWithinBound(cip68Datum, ctx.deployment.maxInlineDatumBytes, "the CIP-68 (100) reference datum");
323
+ cip68DatumBytes = inlineDatumBytes(cip68Datum);
324
+ }
249
325
  // 1. Find covering registry node
250
- const registrySpendAddr = scriptAddress(networkId, ctx.standardScripts.registrySpend.hash);
251
- const registryUtxos = await client.getUtxos(EvoAddress.fromBech32(registrySpendAddr));
326
+ // ONE HASH, TWO ROLES. registry_mint and registry_spend merged (#117), so
327
+ // the node NFT policy id and the node address's payment credential are the
328
+ // same value — the minting policy naming itself. The old names
329
+ // (registrySpendAddr / registryMintPolicyId) asserted a distinction that no
330
+ // longer exists and would tell a reader to look for two hashes.
331
+ const registryAddr = scriptAddress(networkId, ctx.standardScripts.registry.hash);
332
+ const registryUtxos = await client.getUtxos(EvoAddress.fromBech32(registryAddr));
252
333
  const coveringNodeUtxo = findCoveringNode(registryUtxos, scripts.tokenPolicyId);
253
334
  if (!coveringNodeUtxo)
254
335
  throw new Error("Could not find covering registry node for insertion");
@@ -258,14 +339,19 @@ export function freezeAndSeizeSubstandard(config) {
258
339
  // 2. Get reference inputs
259
340
  const protocolParamsUtxo = await findProtocolParamsUtxo(client, networkId, ctx.deployment);
260
341
  const issuanceCborHexUtxo = await findIssuanceCborHexUtxo(client, networkId, ctx.deployment);
342
+ const issuanceLogicRefUtxo = await findIssuanceLogicRefUtxo(client, ctx.deployment);
343
+ // One array supplies both the plan's index arithmetic and the transaction.
344
+ // Constructing either set twice would let two valid integer indices drift.
345
+ const refUtxos = [protocolParamsUtxo, issuanceCborHexUtxo, issuanceLogicRefUtxo];
261
346
  // 3. Build datums
262
- // UNREACHABLE — this plugin refuses at init (T-D09). Retained, and made to
263
- // typecheck against the 7-field layout, so the freeze-and-seize epic gets a
264
- // readable diff instead of a rewrite from memory.
265
- //
266
347
  // The index-based reads that used to be here (2/3/4) were silently wrong
267
- // under the 7-field layout. Replaced with decodeRegistryNode, which is
348
+ // when RegistryNode grew. Replaced with decodeRegistryNode, which is
268
349
  // positional-safe and fails loudly on a short record.
350
+ //
351
+ // ⚑ RegistryNode is STILL SEVEN FIELDS in alpha.3 — it is the PARAMS datum
352
+ // that went 7 -> 4, not this one. Two adjacent records, one changed and one
353
+ // did not, is exactly the pairing that invites a "consistency" edit; the
354
+ // decoder's arity check is what stops it.
269
355
  if (!coveringDatum) {
270
356
  throw new Error("register: the covering registry node has no inline datum. The directory " +
271
357
  "cannot be traversed without it — a node with no datum is malformed, not empty.");
@@ -290,9 +376,32 @@ export function freezeAndSeizeSubstandard(config) {
290
376
  unfrackingLogicScript: { type: "script", hash: scripts.issuerAdmin.hash },
291
377
  globalStateCs: "",
292
378
  });
293
- // 4. Build redeemers registry output index shifts when CIP-68 adds an extra output
294
- const registryOutputIndex = hasCIP68 ? 3 : 2;
295
- const issuanceRedeemer = mintingProofOutputIndex(registryOutputIndex);
379
+ // The former branch-dependent registry-output literal is gone. The index
380
+ // now derives from the declared output set, so adding an output shifts it
381
+ // automatically instead of requiring a second edit in step. Its CIP-68
382
+ // branch has never executed on chain: no test has passed
383
+ // `cip68Metadata` yet; T-F04-4 owns that first execution.
384
+ const outputTags = [
385
+ "user-token",
386
+ ...(hasCIP68 ? ["cip68-reference"] : []),
387
+ "covering-node",
388
+ "new-node",
389
+ ];
390
+ // No plgHash: register spends a registry node and wallet funds, never a
391
+ // programmable_logic_base input.
392
+ const plan = issuancePlan({
393
+ issuanceLogicHash: ctx.deployment.issuanceLogic.scriptHash,
394
+ otherWithdrawals: [{ hash: scripts.issuerAdmin.hash, isScript: true }],
395
+ referenceInputs: refUtxos.map(utxoToTxInput),
396
+ paramsRefInput: utxoToTxInput(protocolParamsUtxo),
397
+ outputs: outputTags,
398
+ issued: [
399
+ {
400
+ policyId: scripts.tokenPolicyId,
401
+ proof: { kind: "output", tag: "new-node" },
402
+ },
403
+ ],
404
+ });
296
405
  const registryMintRedeemer = registryInsertRedeemer(scripts.issuanceMint.hash, { type: "script", hash: scripts.issuerAdmin.hash });
297
406
  const tokenDatum = voidData();
298
407
  // 5. Determine if chaining from initCompliance
@@ -300,16 +409,16 @@ export function freezeAndSeizeSubstandard(config) {
300
409
  // 6. Build addresses
301
410
  const plbHash = ctx.standardScripts.programmableLogicBase.hash;
302
411
  const recipientPlbAddr = baseAddress(networkId, plbHash, recipient);
303
- const registryMintPolicyId = ctx.standardScripts.registryMint.hash;
412
+ const registryPolicyId = ctx.standardScripts.registry.hash;
304
413
  // 7. Build asset maps — include CIP-68 ref token in the same mint (same policy + redeemer)
305
414
  const mintEntries = new Map([[unit, quantity]]);
306
415
  if (hasCIP68 && refUnit) {
307
416
  mintEntries.set(refUnit, 1n);
308
417
  }
309
418
  const tokenAssets = mintAssetsFromMap(mintEntries);
310
- const registryNftUnit = registryMintPolicyId + scripts.tokenPolicyId;
419
+ const registryNftUnit = registryPolicyId + scripts.tokenPolicyId;
311
420
  const registryNftAssets = mintAssetsFromMap(new Map([[registryNftUnit, 1n]]));
312
- const coveringNftUnit = findCoveringNodeNftUnit(coveringNodeUtxo, registryMintPolicyId);
421
+ const coveringNftUnit = findCoveringNodeNftUnit(coveringNodeUtxo, registryPolicyId);
313
422
  // 8. Build transaction
314
423
  let tx = client.newTx();
315
424
  // CIP-171 provenance, carried by the transaction that parameterises the
@@ -321,39 +430,70 @@ export function freezeAndSeizeSubstandard(config) {
321
430
  });
322
431
  }
323
432
  tx = tx.collectFrom({ inputs: [coveringNodeUtxo], redeemer: voidData() });
324
- tx = tx.withdraw({
325
- stakeCredential: Credential.makeScriptHash(new Uint8Array(Buffer.from(scripts.issuerAdmin.hash, "hex"))),
326
- amount: 0n,
327
- redeemer: voidData(),
328
- });
329
- tx = tx.mintAssets({ assets: tokenAssets, redeemer: issuanceRedeemer });
433
+ const withdrawalSpecs = [
434
+ { hash: scripts.issuerAdmin.hash, redeemer: voidData() },
435
+ {
436
+ hash: ctx.deployment.issuanceLogic.scriptHash,
437
+ redeemer: plan.issuanceLogicRedeemer,
438
+ },
439
+ ];
440
+ if (withdrawalSpecs.length !== plan.withdrawals.length) {
441
+ throw new Error(`register: issuance plan declares ${plan.withdrawals.length} withdrawals but ` +
442
+ `${withdrawalSpecs.length} are emitted`);
443
+ }
444
+ for (const withdrawal of withdrawalSpecs) {
445
+ tx = tx.withdraw({
446
+ stakeCredential: Credential.makeScriptHash(new Uint8Array(Buffer.from(withdrawal.hash, "hex"))),
447
+ amount: 0n,
448
+ redeemer: withdrawal.redeemer,
449
+ });
450
+ }
451
+ tx = tx.mintAssets({ assets: tokenAssets, redeemer: plan.issuanceRedeemer });
330
452
  tx = tx.mintAssets({ assets: registryNftAssets, redeemer: registryMintRedeemer });
331
- // Output 0: user token to recipient (with label 333 prefix if CIP-68)
453
+ // min-UTxO is sized from live protocol parameters, not guessed: `unit`
454
+ // carries a caller-supplied asset name and `quantity` a caller-supplied
455
+ // magnitude, and both widen the serialised output.
456
+ const coinsPerUtxoByte = (await client.getProtocolParameters()).coinsPerUtxoByte;
457
+ // outputTags: "user-token" — minted supply at the recipient's PLB.
332
458
  tx = tx.payToAddress({
333
459
  address: EvoAddress.fromBech32(recipientPlbAddr),
334
- assets: outputAssets(1300000n, new Map([[unit, quantity]])),
460
+ assets: outputAssets(minUtxoAtLeast(TOKEN_OUTPUT_FLOOR, {
461
+ address: recipientPlbAddr,
462
+ assets: outputAssets(0n, new Map([[unit, quantity]])),
463
+ datum: tokenDatum,
464
+ coinsPerUtxoByte,
465
+ }), new Map([[unit, quantity]])),
335
466
  datum: new InlineDatum.InlineDatum({ data: tokenDatum }),
336
467
  });
337
- // Output 1 (CIP-68 only): reference token to issuer's PLB address with metadata datum
468
+ // outputTags: "cip68-reference" optional reference token and metadata.
338
469
  if (hasCIP68 && refUnit) {
339
470
  const issuerPlbAddr = baseAddress(networkId, plbHash, feePayerAddress);
340
- const cip68Datum = buildCIP68FTDatum(params.cip68Metadata);
471
+ // THE ONE OUTPUT SIZED BY USER-SUPPLIED STRINGS. Rounded UP TO A
472
+ // WHOLE ADA deliberately: the reference implementation this SDK is
473
+ // diffed against does the same, and a builder-equivalence check is only
474
+ // useful while both sides agree. The cost is at most ~1 ADA, once, on an
475
+ // output that exists for the life of the token.
341
476
  tx = tx.payToAddress({
342
477
  address: EvoAddress.fromBech32(issuerPlbAddr),
343
- assets: outputAssets(3000000n, new Map([[refUnit, 1n]])),
478
+ assets: outputAssets(ceilToWholeAda(minUtxoAtLeast(CIP68_REFERENCE_OUTPUT_FLOOR, {
479
+ address: issuerPlbAddr,
480
+ assets: outputAssets(0n, new Map([[refUnit, 1n]])),
481
+ datum: cip68Datum,
482
+ coinsPerUtxoByte,
483
+ })), new Map([[refUnit, 1n]])),
344
484
  datum: new InlineDatum.InlineDatum({ data: cip68Datum }),
345
485
  });
346
486
  }
347
- // Updated covering node (output 1 or 2)
487
+ // outputTags: "covering-node" predecessor with its updated link.
348
488
  const coveringNodeTokenMap = new Map();
349
489
  if (coveringNftUnit)
350
490
  coveringNodeTokenMap.set(coveringNftUnit, 1n);
351
491
  tx = tx.payToAddress({
352
- address: EvoAddress.fromBech32(registrySpendAddr),
492
+ address: EvoAddress.fromBech32(registryAddr),
353
493
  assets: outputAssets(utxoLovelace(coveringNodeUtxo), coveringNodeTokenMap),
354
494
  datum: new InlineDatum.InlineDatum({ data: updatedCoveringDatum }),
355
495
  });
356
- // New registry node (output 2 or 3).
496
+ // outputTags: "new-node" — the node named by the issuance proof.
357
497
  //
358
498
  // 3 ADA, not 2: the RegistryNode datum is SEVEN fields in 0.5.x and
359
499
  // min-UTxO scales with serialised output size. MEASURED at 2,038,630 for
@@ -364,17 +504,43 @@ export function freezeAndSeizeSubstandard(config) {
364
504
  // covering UTxO's OWN lovelace forward, so it inherits whatever the
365
505
  // bootstrap funded the origin with.)
366
506
  tx = tx.payToAddress({
367
- address: EvoAddress.fromBech32(registrySpendAddr),
507
+ address: EvoAddress.fromBech32(registryAddr),
368
508
  assets: outputAssets(REGISTRY_NODE_MIN_ADA, new Map([[registryNftUnit, 1n]])),
369
509
  datum: new InlineDatum.InlineDatum({ data: newRegistryNodeDatum }),
370
510
  });
371
511
  // Reference inputs
372
- tx = tx.readFrom({ referenceInputs: [protocolParamsUtxo, issuanceCborHexUtxo] });
512
+ // THE PROTOCOL-PARAMS REFERENCE INPUT STAYS — and as of S-11 that is a
513
+ // MEASURED decision, not a cautious one.
514
+ //
515
+ // alpha.3's registry no longer reads it (#117). `issuance_mint` does, to
516
+ // pull the LIVE delegate credentials from its datum, and that locate is
517
+ // DELIBERATELY NON-FAILING: absent params UTxO means "no delegation",
518
+ // falling back to local `no_escape` custody. So removing it raises no
519
+ // error — it silently selects a different custody path.
520
+ //
521
+ // MEASURED ON DEVNET (S-11): removing it from BOTH substandards' register
522
+ // paths, the full lifecycle still passes — dummy 2/2, FES 3/3. The two
523
+ // custody branches do converge here, as the reasoning predicted.
524
+ //
525
+ // ⇒ AND IT STAYS ANYWAY, for a reason the measurement itself supplies:
526
+ // the tests pass EITHER WAY. That is precisely what makes dropping it
527
+ // unsafe to keep. Nothing in this suite would notice if `issuance_mint`'s
528
+ // delegation semantics changed and the absent input started to matter —
529
+ // the guard against that is the input being there, not a test. The saving
530
+ // is one reference input on a once-per-token transaction; the exposure is
531
+ // a silent custody change nobody would see.
532
+ // alpha.4 makes the params reference input mandatory rather than an
533
+ // optional custody hint: with_protocol_params_fields uses expect_at.
534
+ tx = tx.readFrom({ referenceInputs: refUtxos });
373
535
  // Attach scripts
374
- tx = tx.attachScript({ script: buildEvoScript(ctx.standardScripts.registrySpend.compiledCode) });
536
+ // ONE attach, not two. registry_mint and registry_spend merged into a
537
+ // single validator (#117): this transaction both MINTS a node NFT and
538
+ // SPENDS the covering node, which in alpha.2 needed two scripts. It is
539
+ // now one script serving both purposes — attaching it twice would put a
540
+ // duplicate witness in the transaction.
541
+ tx = tx.attachScript({ script: buildEvoScript(ctx.standardScripts.registry.compiledCode) });
375
542
  tx = tx.attachScript({ script: buildEvoScript(scripts.issuerAdmin.compiledCode) });
376
543
  tx = tx.attachScript({ script: buildEvoScript(scripts.issuanceMint.compiledCode) });
377
- tx = tx.attachScript({ script: buildEvoScript(ctx.standardScripts.registryMint.compiledCode) });
378
544
  // Required signer
379
545
  tx = tx.addSigner({ keyHash: KeyHash.fromHex(config.deployment.adminPkh) });
380
546
  const built = await buildAndSerialize(tx, feePayerAddress, useChaining ? chainedUtxos : undefined, useChaining);
@@ -386,8 +552,12 @@ export function freezeAndSeizeSubstandard(config) {
386
552
  metadata: {
387
553
  issuerAdminScriptHash: scripts.issuerAdmin.hash,
388
554
  transferScriptHash: scripts.transfer.hash,
555
+ outputIndices: {
556
+ OUT_NEW_NODE: plan.outputIndexOf("new-node"),
557
+ },
389
558
  ...(hasCIP68 && {
390
559
  cip68Enabled: true,
560
+ cip68DatumBytes,
391
561
  userAssetNameHex,
392
562
  refAssetNameHex,
393
563
  }),
@@ -406,17 +576,30 @@ export function freezeAndSeizeSubstandard(config) {
406
576
  throw new Error(`Token policy ${tokenPolicyId} does not match this FES instance (${scripts.tokenPolicyId})`);
407
577
  }
408
578
  // 1. Find registry node as RefInput proof
409
- const registrySpendAddr = scriptAddress(networkId, ctx.standardScripts.registrySpend.hash);
410
- const registryUtxos = await client.getUtxos(EvoAddress.fromBech32(registrySpendAddr));
579
+ const registryAddr = scriptAddress(networkId, ctx.standardScripts.registry.hash);
580
+ const registryUtxos = await client.getUtxos(EvoAddress.fromBech32(registryAddr));
411
581
  const registryUtxo = findRegistryNode(registryUtxos, tokenPolicyId);
412
582
  if (!registryUtxo)
413
583
  throw new Error(`Registry node not found for ${tokenPolicyId}`);
414
- // 2. Sort reference inputs
415
- const regRef = utxoToTxInput(registryUtxo);
416
- const sortedRefInputs = sortTxInputs([regRef]);
417
- const registryRefIdx = findRefInputIndex(sortedRefInputs, regRef);
584
+ // 2. Find the two alpha.4 protocol reference inputs. The params input is
585
+ // NEW here: with_protocol_params_fields begins with a hard expect_at,
586
+ // and S-7's open mint-vs-burn inconsistency is answered at this site.
587
+ const protocolParamsUtxo = await findProtocolParamsUtxo(client, networkId, ctx.deployment);
588
+ const issuanceLogicRefUtxo = await findIssuanceLogicRefUtxo(client, ctx.deployment);
589
+ const refUtxos = [protocolParamsUtxo, registryUtxo, issuanceLogicRefUtxo];
590
+ const plan = issuancePlan({
591
+ issuanceLogicHash: ctx.deployment.issuanceLogic.scriptHash,
592
+ otherWithdrawals: [{ hash: scripts.issuerAdmin.hash, isScript: true }],
593
+ referenceInputs: refUtxos.map(utxoToTxInput),
594
+ paramsRefInput: utxoToTxInput(protocolParamsUtxo),
595
+ issued: [
596
+ {
597
+ policyId: tokenPolicyId,
598
+ proof: { kind: "reference-input", input: utxoToTxInput(registryUtxo) },
599
+ },
600
+ ],
601
+ });
418
602
  // 3. Build redeemers
419
- const issuanceRedeemer = mintingProofRefInput(registryRefIdx);
420
603
  const tokenDatum = voidData();
421
604
  // 4. Build PLB address
422
605
  const plbHash = ctx.standardScripts.programmableLogicBase.hash;
@@ -428,18 +611,37 @@ export function freezeAndSeizeSubstandard(config) {
428
611
  let tx = client.newTx();
429
612
  const spendableUtxos = spendableWalletUtxos(walletUtxos, ctx.deployment);
430
613
  tx = tx.collectFrom({ inputs: spendableUtxos.slice(0, 2) });
431
- tx = tx.withdraw({
432
- stakeCredential: Credential.makeScriptHash(new Uint8Array(Buffer.from(scripts.issuerAdmin.hash, "hex"))),
433
- amount: 0n,
434
- redeemer: voidData(),
435
- });
436
- tx = tx.mintAssets({ assets: tokenAssets, redeemer: issuanceRedeemer });
614
+ const withdrawalSpecs = [
615
+ { hash: scripts.issuerAdmin.hash, redeemer: voidData() },
616
+ {
617
+ hash: ctx.deployment.issuanceLogic.scriptHash,
618
+ redeemer: plan.issuanceLogicRedeemer,
619
+ },
620
+ ];
621
+ if (withdrawalSpecs.length !== plan.withdrawals.length) {
622
+ throw new Error(`mint: issuance plan declares ${plan.withdrawals.length} withdrawals but ` +
623
+ `${withdrawalSpecs.length} are emitted`);
624
+ }
625
+ for (const withdrawal of withdrawalSpecs) {
626
+ tx = tx.withdraw({
627
+ stakeCredential: Credential.makeScriptHash(new Uint8Array(Buffer.from(withdrawal.hash, "hex"))),
628
+ amount: 0n,
629
+ redeemer: withdrawal.redeemer,
630
+ });
631
+ }
632
+ tx = tx.mintAssets({ assets: tokenAssets, redeemer: plan.issuanceRedeemer });
633
+ const coinsPerUtxoByte = (await client.getProtocolParameters()).coinsPerUtxoByte;
437
634
  tx = tx.payToAddress({
438
635
  address: EvoAddress.fromBech32(recipientPlbAddr),
439
- assets: outputAssets(1300000n, new Map([[unit, quantity]])),
636
+ assets: outputAssets(minUtxoAtLeast(TOKEN_OUTPUT_FLOOR, {
637
+ address: recipientPlbAddr,
638
+ assets: outputAssets(0n, new Map([[unit, quantity]])),
639
+ datum: tokenDatum,
640
+ coinsPerUtxoByte,
641
+ }), new Map([[unit, quantity]])),
440
642
  datum: new InlineDatum.InlineDatum({ data: tokenDatum }),
441
643
  });
442
- tx = tx.readFrom({ referenceInputs: [registryUtxo] });
644
+ tx = tx.readFrom({ referenceInputs: refUtxos });
443
645
  tx = tx.attachScript({ script: buildEvoScript(scripts.issuerAdmin.compiledCode) });
444
646
  tx = tx.attachScript({ script: buildEvoScript(scripts.issuanceMint.compiledCode) });
445
647
  tx = tx.addSigner({ keyHash: KeyHash.fromHex(config.deployment.adminPkh) });
@@ -469,22 +671,18 @@ export function freezeAndSeizeSubstandard(config) {
469
671
  throw new Error(`No tokens of ${unit} in UTxO`);
470
672
  // 2. Find reference inputs
471
673
  const protocolParamsUtxo = await findProtocolParamsUtxo(client, networkId, ctx.deployment);
472
- const registrySpendAddr = scriptAddress(networkId, ctx.standardScripts.registrySpend.hash);
473
- const registryUtxos = await client.getUtxos(EvoAddress.fromBech32(registrySpendAddr));
674
+ const registryAddr = scriptAddress(networkId, ctx.standardScripts.registry.hash);
675
+ const registryUtxos = await client.getUtxos(EvoAddress.fromBech32(registryAddr));
474
676
  const registryUtxo = findRegistryNode(registryUtxos, tokenPolicyId);
475
677
  if (!registryUtxo)
476
678
  throw new Error(`Registry node not found for ${tokenPolicyId}`);
477
- // 3. Sort reference inputs
478
- const allRefInputRefs = [utxoToTxInput(protocolParamsUtxo), utxoToTxInput(registryUtxo)];
479
- const sortedRefInputs = sortTxInputs(allRefInputRefs);
480
- const registryIdx = findRefInputIndex(sortedRefInputs, utxoToTxInput(registryUtxo));
481
- // 4. Build redeemers
482
- const issuanceRedeemer = mintingProofRefInput(registryIdx);
483
- const paramsIdx = findRefInputIndex(sortedRefInputs, utxoToTxInput(protocolParamsUtxo));
679
+ const issuanceLogicRefUtxo = await findIssuanceLogicRefUtxo(client, ctx.deployment);
680
+ const refUtxos = [protocolParamsUtxo, registryUtxo, issuanceLogicRefUtxo];
484
681
  // 0.5.x third-party route. `ThirdPartyAct` was DELETED by #110: the
485
682
  // administrative path is now the standalone `third_party` validator, and
486
- // programmable_logic_base dispatches to it via SpendViaThirdParty — so
487
- // this transaction never loads the `transfer` reference script at all.
683
+ // programmable_logic_base withdraws through the dispatcher, whose redeemer
684
+ // carries ThirdPartyAct — so this transaction never loads the `transfer`
685
+ // reference script at all.
488
686
  //
489
687
  // The withdrawal set is BOTH scripts: the framework delegate, and the
490
688
  // issuer authority the registry node names in
@@ -497,15 +695,36 @@ export function freezeAndSeizeSubstandard(config) {
497
695
  isScript: true,
498
696
  };
499
697
  const issuerAuthorityKey = { hash: scripts.issuerAdmin.hash, isScript: true };
500
- const thirdPartyWdrlIdx = withdrawalIndexOf([thirdPartyKey, issuerAuthorityKey], thirdPartyKey);
698
+ // One plan owns the COMPLETE four-withdrawal and three-reference-input
699
+ // sets. In particular, adding issuance_logic's reference script can move
700
+ // every reference-input index that sorts after it.
701
+ const plan = issuancePlan({
702
+ issuanceLogicHash: ctx.deployment.issuanceLogic.scriptHash,
703
+ plgHash: ctx.standardScripts.programmableLogicGlobal.hash,
704
+ otherWithdrawals: [thirdPartyKey, issuerAuthorityKey],
705
+ referenceInputs: refUtxos.map(utxoToTxInput),
706
+ paramsRefInput: utxoToTxInput(protocolParamsUtxo),
707
+ issued: [
708
+ {
709
+ policyId: tokenPolicyId,
710
+ proof: { kind: "reference-input", input: utxoToTxInput(registryUtxo) },
711
+ },
712
+ ],
713
+ });
714
+ const registryIdx = plan.referenceInputIndexOf(utxoToTxInput(registryUtxo));
501
715
  // outputs_start_idx = 0: `third_party` PAIRS each programmable input with
502
716
  // the NEXT output (same address, datum and reference script, lovelace
503
717
  // ratcheting up) and reads the seized amount as the DELTA. Destination
504
718
  // outputs must therefore sit AMONG THE LEADING ones it skips.
505
719
  // See docs/api-reference.md — getting this backwards fails with an EMPTY
506
720
  // TRACE LIST, because it is a structural expect and not a traced check.
507
- const plgRedeemer = thirdPartyRedeemer(paramsIdx, registryIdx, 0);
508
- const plbSpendRedeemer = baseSpendRedeemer("THIRD_PARTY", paramsIdx, thirdPartyWdrlIdx);
721
+ // params_idx dropped: delegates no longer read the params datum.
722
+ // ThirdPartyRedeemer.registry_node_idx and the issuance map's
723
+ // RefInput{index} MUST be the same integer: upstream
724
+ // third_party_covers_own_registry_node compares them. One plan computes
725
+ // both, so the pair cannot drift when a reference input is added.
726
+ const plgRedeemer = thirdPartyRedeemer(registryIdx, 0);
727
+ const plbSpendRedeemer = baseSpendRedeemer(plan.paramsIdx, plan.plgIdx());
509
728
  const tokenDatum = voidData();
510
729
  // 5. Compute remaining assets (remove burned token's policy)
511
730
  const remainingTokens = new Map();
@@ -528,16 +747,38 @@ export function freezeAndSeizeSubstandard(config) {
528
747
  const spendableUtxos = spendableWalletUtxos(walletUtxos, ctx.deployment);
529
748
  tx = tx.collectFrom({ inputs: spendableUtxos.slice(0, 2) });
530
749
  tx = tx.collectFrom({ inputs: [utxoToBurn], redeemer: plbSpendRedeemer });
531
- tx = tx.withdraw({
532
- stakeCredential: Credential.makeScriptHash(new Uint8Array(Buffer.from(scripts.issuerAdmin.hash, "hex"))),
533
- amount: 0n,
534
- redeemer: voidData(),
535
- });
536
- tx = tx.withdraw({
537
- stakeCredential: Credential.makeScriptHash(new Uint8Array(Buffer.from(ctx.standardScripts.thirdParty.hash, "hex"))),
538
- amount: 0n,
539
- redeemer: plgRedeemer,
540
- });
750
+ const withdrawalSpecs = [
751
+ { hash: scripts.issuerAdmin.hash, redeemer: voidData() },
752
+ {
753
+ hash: ctx.standardScripts.programmableLogicGlobal.hash,
754
+ redeemer: programmableLogicGlobalRedeemer("THIRD_PARTY"),
755
+ },
756
+ { hash: ctx.standardScripts.thirdParty.hash, redeemer: plgRedeemer },
757
+ {
758
+ hash: ctx.deployment.issuanceLogic.scriptHash,
759
+ redeemer: plan.issuanceLogicRedeemer,
760
+ },
761
+ ];
762
+ if (withdrawalSpecs.length !== plan.withdrawals.length) {
763
+ throw new Error(`burn: issuance plan declares ${plan.withdrawals.length} withdrawals but ` +
764
+ `${withdrawalSpecs.length} are emitted`);
765
+ }
766
+ for (const withdrawal of withdrawalSpecs) {
767
+ tx = tx.withdraw({
768
+ stakeCredential: Credential.makeScriptHash(new Uint8Array(Buffer.from(withdrawal.hash, "hex"))),
769
+ amount: 0n,
770
+ redeemer: withdrawal.redeemer,
771
+ });
772
+ }
773
+ // ⚑ NO min-UTxO COMPUTATION HERE, AND THAT IS CORRECT — do not "fix" it to
774
+ // match the other outputs. This is the PAIRED CONTINUATION: `third_party`
775
+ // requires it to preserve the input's address, datum and reference script
776
+ // with lovelace only ratcheting UP, so it carries the INPUT'S OWN lovelace
777
+ // forward. That figure was already min-UTxO-valid when the input was
778
+ // created, and burning REMOVES tokens — a smaller output has a LOWER
779
+ // requirement. Carrying it forward is therefore always sufficient, and
780
+ // recomputing could only produce a smaller number, which the ratchet rule
781
+ // forbids.
541
782
  tx = tx.payToAddress({
542
783
  address: utxoToBurn.address,
543
784
  assets: outputAssets(utxoLovelace(utxoToBurn), remainingTokens.size > 0 ? remainingTokens : undefined),
@@ -548,9 +789,15 @@ export function freezeAndSeizeSubstandard(config) {
548
789
  // coincidence rather than a guarantee.
549
790
  datum: new InlineDatum.InlineDatum({ data: getInlineDatum(utxoToBurn) ?? tokenDatum }),
550
791
  });
551
- tx = tx.mintAssets({ assets: burnAssets, redeemer: issuanceRedeemer });
552
- tx = tx.readFrom({ referenceInputs: [protocolParamsUtxo, registryUtxo] });
792
+ tx = tx.mintAssets({ assets: burnAssets, redeemer: plan.issuanceRedeemer });
793
+ tx = tx.readFrom({ referenceInputs: refUtxos });
553
794
  tx = tx.attachScript({ script: buildEvoScript(ctx.standardScripts.programmableLogicBase.compiledCode) });
795
+ // ⛔ THE DISPATCHER'S OWN SCRIPT WITNESS. A withdraw-0 needs the script in
796
+ // full, not just a redeemer — alpha.3 added the dispatcher's withdrawal to
797
+ // every programmable transaction, and S-6 wired the withdrawal and the
798
+ // redeemer but not this. The ledger says "An associated script witness is
799
+ // missing" on purpose=withdraw, which names the shape but not the script.
800
+ tx = tx.attachScript({ script: buildEvoScript(ctx.standardScripts.programmableLogicGlobal.compiledCode) });
554
801
  tx = tx.attachScript({ script: buildEvoScript(ctx.standardScripts.thirdParty.compiledCode) });
555
802
  tx = tx.attachScript({ script: buildEvoScript(scripts.issuerAdmin.compiledCode) });
556
803
  tx = tx.attachScript({ script: buildEvoScript(scripts.issuanceMint.compiledCode) });
@@ -582,8 +829,8 @@ export function freezeAndSeizeSubstandard(config) {
582
829
  const { selected, totalTokenAmount } = selectUtxosForAmount(tokenUtxos, unit, quantity);
583
830
  const returningAmount = totalTokenAmount - quantity;
584
831
  // 4. Find registry node reference input
585
- const registrySpendAddr = scriptAddress(networkId, ctx.standardScripts.registrySpend.hash);
586
- const registryUtxos = await client.getUtxos(EvoAddress.fromBech32(registrySpendAddr));
832
+ const registryAddr = scriptAddress(networkId, ctx.standardScripts.registry.hash);
833
+ const registryUtxos = await client.getUtxos(EvoAddress.fromBech32(registryAddr));
587
834
  const registryUtxo = findRegistryNode(registryUtxos, tokenPolicyId);
588
835
  if (!registryUtxo)
589
836
  throw new Error(`Registry node not found for ${tokenPolicyId}`);
@@ -610,6 +857,27 @@ export function freezeAndSeizeSubstandard(config) {
610
857
  utxoToTxInput(registryUtxo),
611
858
  ];
612
859
  const sortedRefInputs = sortTxInputs(allRefInputRefs);
860
+ // ⚠ NOTE THE ASYMMETRY WITH `register`, WHICH S-11 MEASURED: there,
861
+ // removing the params reference input is behaviour-preserving. HERE it is
862
+ // not, and the reason has nothing to do with custody — see below. Two
863
+ // transactions, one shared input, two unrelated dependencies on it.
864
+ //
865
+ // ⛔ A SECOND REASON THE PROTOCOL-PARAMS REFERENCE INPUT MUST STAY, beyond
866
+ // the custody one recorded at `register`.
867
+ //
868
+ // Every index below — the blacklist proof indices, registryIdx, paramsIdx
869
+ // — is a position in THIS sorted set. FES's own transfer validator reads
870
+ // its blacklist nodes with `list.at(reference_inputs, node_idx)`, an
871
+ // INDEX, not a search. Removing the params UTxO from the set therefore
872
+ // shifts every proof index that sorts after it, and the failure is a
873
+ // blacklist proof resolving to the WRONG NODE — a well-formed proof about
874
+ // something else.
875
+ //
876
+ // So "the registry no longer needs this input" (#117) is true and still
877
+ // does not license dropping it: two unrelated consumers in this same
878
+ // transaction depend on it, one for custody semantics and one for index
879
+ // arithmetic. Requirements belong to the TRANSACTION, not to the
880
+ // component being edited.
613
881
  const proofIndices = selected.map(() => findRefInputIndex(sortedRefInputs, utxoToTxInput(proofUtxos[0])));
614
882
  const registryIdx = findRefInputIndex(sortedRefInputs, utxoToTxInput(registryUtxo));
615
883
  const paramsIdx = findRefInputIndex(sortedRefInputs, utxoToTxInput(protocolParamsUtxo));
@@ -618,15 +886,19 @@ export function freezeAndSeizeSubstandard(config) {
618
886
  // 9. Build redeemers — 0.5.x shapes.
619
887
  //
620
888
  // The withdrawal set must be COMPLETE and in LEDGER order: script
621
- // credentials before key credentials, bytewise within each. This
622
- // transaction carries two, both scripts — the framework's `transfer`
623
- // delegate and this substandard's own transfer logic.
889
+ // credentials before key credentials, bytewise within each. alpha.3 makes
890
+ // it THREE, all scripts — the dispatcher, the framework's `transfer`
891
+ // delegate, and this substandard's own transfer logic. Every index in the
892
+ // transaction shifted when the dispatcher joined.
624
893
  const coreTransferKey = {
625
894
  hash: ctx.standardScripts.transfer.hash,
626
895
  isScript: true,
627
896
  };
628
897
  const fesLogicKey = { hash: scripts.transfer.hash, isScript: true };
629
- const transferWdrlIdx = withdrawalIndexOf([coreTransferKey, fesLogicKey], coreTransferKey);
898
+ const transferPlan = plbWithdrawalPlan({
899
+ plgHash: ctx.standardScripts.programmableLogicGlobal.hash,
900
+ others: [coreTransferKey, fesLogicKey],
901
+ });
630
902
  if (process.env.DUMP_TX_STRUCTURE) {
631
903
  // Computed indices, printed beside the artefact's own order so the two
632
904
  // can be COMPARED rather than reasoned about. A structural expect in
@@ -639,19 +911,16 @@ export function freezeAndSeizeSubstandard(config) {
639
911
  ` registryIdx=${registryIdx}\n` +
640
912
  ` coreTransfer=${ctx.standardScripts.transfer.hash}\n` +
641
913
  ` fesLogic =${scripts.transfer.hash}\n` +
642
- ` transferWdrlIdx=${transferWdrlIdx}\n` +
914
+ ` plgWdrlIdx=${transferPlan.plgIdx}\n` +
643
915
  "=== END COMPUTED ===");
644
916
  }
645
917
  const fesTransferRedeemer = Data.list(proofIndices.map((idx) => Data.constr(0n, [Data.int(BigInt(idx))])));
646
- // TransferRedeemer { params_idx, proofs } — params_idx was PREPENDED by
647
- // #109; an old TransferAct encoder's bytes are not compatible.
648
- const plgRedeemer = transferRedeemer(paramsIdx, [
649
- { type: "exists", nodeIdx: registryIdx },
650
- ]);
651
- // programmable_logic_base no longer takes an untyped redeemer: it
652
- // dispatches on the constructor and witnesses where its delegate's
653
- // withdrawal sits.
654
- const spendRdmr = baseSpendRedeemer("TRANSFER", paramsIdx, transferWdrlIdx);
918
+ // TransferRedeemer { proofs } — params_idx was dropped in alpha.3; the
919
+ // delegates stop reading the params datum entirely.
920
+ const plgRedeemer = transferRedeemer([{ type: "exists", nodeIdx: registryIdx }]);
921
+ // PLB witnesses where the DISPATCHER's withdrawal sits; the dispatch
922
+ // choice itself moved to the dispatcher's own redeemer.
923
+ const spendRdmr = baseSpendRedeemer(paramsIdx, transferPlan.plgIdx);
655
924
  const tokenDatum = voidData();
656
925
  // 10. Build transaction
657
926
  let tx = client.newTx();
@@ -660,6 +929,13 @@ export function freezeAndSeizeSubstandard(config) {
660
929
  // resolves the withdrawal at `wdrl_idx` and requires it to equal the
661
930
  // credential the params datum names for THIS dispatch arm, so withdrawing
662
931
  // the wrong delegate fails the spend with an empty trace list.
932
+ // The DISPATCHER's own withdraw-0 — new in alpha.3, required on every
933
+ // programmable transaction, and the entry PLB's wdrl_idx resolves to.
934
+ tx = tx.withdraw({
935
+ stakeCredential: Credential.makeScriptHash(new Uint8Array(Buffer.from(ctx.standardScripts.programmableLogicGlobal.hash, "hex"))),
936
+ amount: 0n,
937
+ redeemer: programmableLogicGlobalRedeemer("TRANSFER"),
938
+ });
663
939
  tx = tx.withdraw({
664
940
  stakeCredential: Credential.makeScriptHash(new Uint8Array(Buffer.from(ctx.standardScripts.transfer.hash, "hex"))),
665
941
  amount: 0n,
@@ -670,20 +946,37 @@ export function freezeAndSeizeSubstandard(config) {
670
946
  amount: 0n,
671
947
  redeemer: fesTransferRedeemer,
672
948
  });
949
+ const coinsPerUtxoByte = (await client.getProtocolParameters()).coinsPerUtxoByte;
673
950
  if (returningAmount > 0n) {
674
951
  tx = tx.payToAddress({
675
952
  address: EvoAddress.fromBech32(senderPlbAddr),
676
- assets: outputAssets(1300000n, new Map([[unit, returningAmount]])),
953
+ assets: outputAssets(minUtxoAtLeast(TOKEN_OUTPUT_FLOOR, {
954
+ address: senderPlbAddr,
955
+ assets: outputAssets(0n, new Map([[unit, returningAmount]])),
956
+ datum: tokenDatum,
957
+ coinsPerUtxoByte,
958
+ }), new Map([[unit, returningAmount]])),
677
959
  datum: new InlineDatum.InlineDatum({ data: tokenDatum }),
678
960
  });
679
961
  }
680
962
  tx = tx.payToAddress({
681
963
  address: EvoAddress.fromBech32(recipientPlbAddr),
682
- assets: outputAssets(1300000n, new Map([[unit, quantity]])),
964
+ assets: outputAssets(minUtxoAtLeast(TOKEN_OUTPUT_FLOOR, {
965
+ address: recipientPlbAddr,
966
+ assets: outputAssets(0n, new Map([[unit, quantity]])),
967
+ datum: tokenDatum,
968
+ coinsPerUtxoByte,
969
+ }), new Map([[unit, quantity]])),
683
970
  datum: new InlineDatum.InlineDatum({ data: tokenDatum }),
684
971
  });
685
972
  tx = tx.readFrom({ referenceInputs: [...proofUtxos, protocolParamsUtxo, registryUtxo] });
686
973
  tx = tx.attachScript({ script: buildEvoScript(ctx.standardScripts.programmableLogicBase.compiledCode) });
974
+ // ⛔ THE DISPATCHER'S OWN SCRIPT WITNESS. A withdraw-0 needs the script in
975
+ // full, not just a redeemer — alpha.3 added the dispatcher's withdrawal to
976
+ // every programmable transaction, and S-6 wired the withdrawal and the
977
+ // redeemer but not this. The ledger says "An associated script witness is
978
+ // missing" on purpose=withdraw, which names the shape but not the script.
979
+ tx = tx.attachScript({ script: buildEvoScript(ctx.standardScripts.programmableLogicGlobal.compiledCode) });
687
980
  tx = tx.attachScript({ script: buildEvoScript(ctx.standardScripts.transfer.compiledCode) });
688
981
  tx = tx.attachScript({ script: buildEvoScript(scripts.transfer.compiledCode) });
689
982
  tx = tx.addSigner({ keyHash: KeyHash.fromHex(senderStakingHash) });
@@ -729,7 +1022,16 @@ export function freezeAndSeizeSubstandard(config) {
729
1022
  address: EvoAddress.fromBech32(feePayerAddress),
730
1023
  assets: outputAssets(40000000n),
731
1024
  });
732
- // Output: blacklist origin node
1025
+ // Output: blacklist origin node.
1026
+ //
1027
+ // ✅ FLAT CONSTANT IS CORRECT HERE, AND IT IS PROVABLE RATHER THAN
1028
+ // ASSUMED — this output carries NO caller-controlled bytes. The unit is
1029
+ // `blacklistMint.hash + ""` (an EMPTY asset name, fixed above), and the
1030
+ // datum is `blacklistNodeDatum("", MAX_NEXT)` — an empty key and a
1031
+ // 30-byte sentinel, both constants. MEASURED on preview
1032
+ // (coinsPerUtxoByte 4310): 1,198,180 required against 1,300,000 supplied,
1033
+ // 101,820 of headroom, and the live origin node on preview sits at
1034
+ // exactly 1,300,000. Nothing a caller types can move it.
733
1035
  tx = tx.payToAddress({
734
1036
  address: EvoAddress.fromBech32(blacklistSpendAddr),
735
1037
  assets: outputAssets(1300000n, new Map([[blacklistOriginUnit, 1n]])),
@@ -805,7 +1107,17 @@ export function freezeAndSeizeSubstandard(config) {
805
1107
  assets: outputAssets(utxoLovelace(coveringNode), coveringTokenMap),
806
1108
  datum: new InlineDatum.InlineDatum({ data: updatedCoveringDatum }),
807
1109
  });
808
- // Output 1: new blacklist node
1110
+ // Output 1: new blacklist node.
1111
+ //
1112
+ // ✅ FLAT CONSTANT IS CORRECT HERE TOO, for the same provable reason. The
1113
+ // unit is `blacklistMint.hash + targetStakingHash` — a 28-byte staking
1114
+ // credential, fixed width — and the datum is two hashes. A blacklist entry
1115
+ // has no user-supplied strings in it at all. MEASURED: 1,448,160 required
1116
+ // against 2,000,000 supplied, 551,840 of headroom.
1117
+ //
1118
+ // ⚠ If a future node datum gains a field, this stops being true silently.
1119
+ // That is exactly how REGISTRY_NODE_MIN_ADA went wrong when the registry
1120
+ // datum grew from five fields to seven.
809
1121
  tx = tx.payToAddress({
810
1122
  address: EvoAddress.fromBech32(blacklistSpendAddr),
811
1123
  assets: outputAssets(2000000n, new Map([[nftUnit, 1n]])),
@@ -904,8 +1216,8 @@ export function freezeAndSeizeSubstandard(config) {
904
1216
  throw new Error(`No tokens of ${unit} in UTxO`);
905
1217
  // 2. Find reference inputs
906
1218
  const protocolParamsUtxo = await findProtocolParamsUtxo(client, networkId, ctx.deployment);
907
- const registrySpendAddr = scriptAddress(networkId, ctx.standardScripts.registrySpend.hash);
908
- const registryUtxos = await client.getUtxos(EvoAddress.fromBech32(registrySpendAddr));
1219
+ const registryAddr = scriptAddress(networkId, ctx.standardScripts.registry.hash);
1220
+ const registryUtxos = await client.getUtxos(EvoAddress.fromBech32(registryAddr));
909
1221
  const registryUtxo = findRegistryNode(registryUtxos, tokenPolicyId);
910
1222
  if (!registryUtxo)
911
1223
  throw new Error(`Registry node not found for ${tokenPolicyId}`);
@@ -945,8 +1257,9 @@ export function freezeAndSeizeSubstandard(config) {
945
1257
  const paramsIdx = findRefInputIndex(sortedRefInputs, utxoToTxInput(protocolParamsUtxo));
946
1258
  // 0.5.x third-party route. `ThirdPartyAct` was DELETED by #110: the
947
1259
  // administrative path is now the standalone `third_party` validator, and
948
- // programmable_logic_base dispatches to it via SpendViaThirdParty — so
949
- // this transaction never loads the `transfer` reference script at all.
1260
+ // programmable_logic_base withdraws through the dispatcher, whose redeemer
1261
+ // carries ThirdPartyAct — so this transaction never loads the `transfer`
1262
+ // reference script at all.
950
1263
  //
951
1264
  // The withdrawal set is BOTH scripts: the framework delegate, and the
952
1265
  // issuer authority the registry node names in
@@ -959,15 +1272,21 @@ export function freezeAndSeizeSubstandard(config) {
959
1272
  isScript: true,
960
1273
  };
961
1274
  const issuerAuthorityKey = { hash: scripts.issuerAdmin.hash, isScript: true };
962
- const thirdPartyWdrlIdx = withdrawalIndexOf([thirdPartyKey, issuerAuthorityKey], thirdPartyKey);
1275
+ // alpha.3: the DISPATCHER withdraws on every programmable transaction and
1276
+ // PLB's wdrl_idx points at IT, not at the delegate.
1277
+ const plan = plbWithdrawalPlan({
1278
+ plgHash: ctx.standardScripts.programmableLogicGlobal.hash,
1279
+ others: [thirdPartyKey, issuerAuthorityKey],
1280
+ });
963
1281
  // outputs_start_idx = 1: `third_party` PAIRS each programmable input with
964
1282
  // the NEXT output (same address, datum and reference script, lovelace
965
1283
  // ratcheting up) and reads the seized amount as the DELTA. Destination
966
1284
  // outputs must therefore sit AMONG THE LEADING ones it skips.
967
1285
  // See docs/api-reference.md — getting this backwards fails with an EMPTY
968
1286
  // TRACE LIST, because it is a structural expect and not a traced check.
969
- const plgRedeemer = thirdPartyRedeemer(paramsIdx, registryIdx, 1);
970
- const plbSpendRedeemer = baseSpendRedeemer("THIRD_PARTY", paramsIdx, thirdPartyWdrlIdx);
1287
+ // params_idx dropped: delegates no longer read the params datum.
1288
+ const plgRedeemer = thirdPartyRedeemer(registryIdx, 1);
1289
+ const plbSpendRedeemer = baseSpendRedeemer(paramsIdx, plan.plgIdx);
971
1290
  const tokenDatum = voidData();
972
1291
  // 5. Build recipient PLB address
973
1292
  const recipientPlbAddr = baseAddress(networkId, plbHash, destinationAddress);
@@ -994,15 +1313,28 @@ export function freezeAndSeizeSubstandard(config) {
994
1313
  amount: 0n,
995
1314
  redeemer: voidData(),
996
1315
  });
1316
+ // The DISPATCHER's own withdraw-0 — new in alpha.3, required on every
1317
+ // programmable transaction, and the entry PLB's wdrl_idx resolves to.
1318
+ tx = tx.withdraw({
1319
+ stakeCredential: Credential.makeScriptHash(new Uint8Array(Buffer.from(ctx.standardScripts.programmableLogicGlobal.hash, "hex"))),
1320
+ amount: 0n,
1321
+ redeemer: programmableLogicGlobalRedeemer("THIRD_PARTY"),
1322
+ });
997
1323
  tx = tx.withdraw({
998
1324
  stakeCredential: Credential.makeScriptHash(new Uint8Array(Buffer.from(ctx.standardScripts.thirdParty.hash, "hex"))),
999
1325
  amount: 0n,
1000
1326
  redeemer: plgRedeemer,
1001
1327
  });
1328
+ const coinsPerUtxoByte = (await client.getProtocolParameters()).coinsPerUtxoByte;
1002
1329
  // Output 0: seized tokens to recipient
1003
1330
  tx = tx.payToAddress({
1004
1331
  address: EvoAddress.fromBech32(recipientPlbAddr),
1005
- assets: outputAssets(1300000n, new Map([[unit, seizedAmount]])),
1332
+ assets: outputAssets(minUtxoAtLeast(TOKEN_OUTPUT_FLOOR, {
1333
+ address: recipientPlbAddr,
1334
+ assets: outputAssets(0n, new Map([[unit, seizedAmount]])),
1335
+ datum: tokenDatum,
1336
+ coinsPerUtxoByte,
1337
+ }), new Map([[unit, seizedAmount]])),
1006
1338
  datum: new InlineDatum.InlineDatum({ data: tokenDatum }),
1007
1339
  });
1008
1340
  // Output 1: remaining value to original address
@@ -1026,6 +1358,12 @@ export function freezeAndSeizeSubstandard(config) {
1026
1358
  "=== END SEIZE SCRIPTS ===");
1027
1359
  }
1028
1360
  tx = tx.attachScript({ script: buildEvoScript(ctx.standardScripts.programmableLogicBase.compiledCode) });
1361
+ // ⛔ THE DISPATCHER'S OWN SCRIPT WITNESS. A withdraw-0 needs the script in
1362
+ // full, not just a redeemer — alpha.3 added the dispatcher's withdrawal to
1363
+ // every programmable transaction, and S-6 wired the withdrawal and the
1364
+ // redeemer but not this. The ledger says "An associated script witness is
1365
+ // missing" on purpose=withdraw, which names the shape but not the script.
1366
+ tx = tx.attachScript({ script: buildEvoScript(ctx.standardScripts.programmableLogicGlobal.compiledCode) });
1029
1367
  // ⚠ `third_party` is NOT attached here. Its withdrawal already carries the
1030
1368
  // script witness, and attaching it again makes the duplicate extraneous —
1031
1369
  // the ledger rejects the whole transaction with code 3104. MEASURED: the