Skip to content

Fix/cip 68 - #7

Merged
Kammerlo merged 27 commits into
mainfrom
fix/cip-68
Aug 18, 2026
Merged

Fix/cip 68#7
Kammerlo merged 27 commits into
mainfrom
fix/cip-68

Conversation

@Kammerlo

Copy link
Copy Markdown
Member

No description provided.

Kammerlo and others added 27 commits August 13, 2026 16:04
…ription

Adds substandards.disabled (SUBSTANDARDS_DISABLED), defaulting to kyc and
kyc-extended. They are filtered out of the substandard cache, so they vanish
from /substandards and the issuance wizard. The contracts stay vendored and
already-issued tokens keep working — this only removes them as choices for new
tokens, and is reversible by clearing the property.

Substandard also gains name/description, read from an optional metadata.json
beside each plutus.json and falling back to the previous capitalised-id label.
security-token's wording is taken from the upstream contract README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Display name becomes 'RWA Token (German & Swiss profiles)' and CMTA is removed
from the description too, so the two do not disagree. Also renames the four
user-facing 'Security Token' strings in the frontend.

The substandard id stays 'security-token': it is the resource folder name, the
value the frontend routes and substandardId checks compare against, and what
existing registration rows store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ion wizard

The wizard does not read the backend's /substandards list — it has its own
hardcoded flow registry under lib/registration/flows, so the earlier backend
change was invisible there.

- security-token flow renamed to 'RWA Token (German & Swiss profiles)' with the
  matching description
- kyc and kyc-extended default to disabled, mirroring substandards.disabled
- /api/config now reports all five flows, not just dummy and freeze-and-seize,
  so each can be toggled by env var without a rebuild

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…kyc (D1/D6)

The mint has never reached the chain. Two compounding defects made it
unbuildable for every registration with requires_receiver_kyc = true, which
is all six of them.

D1 — buildMintTransaction passed a literal includeKycProof=false, so
buildMembershipProof emitted a placeholder Membership{valid_until_ms = 0,
mpf_proof = []}, and the tx set no validity upper bound at all.
verify_membership_proof (lib/kyc/verify.ak:137-142) needs a Finite upper
bound <= proof.valid_until_ms, so that clause failed regardless of the
proof's contents.

Now the builder reads requires_receiver_kyc off the live GS datum, resolves
a real MPF inclusion proof through allowlistService.inclusionProof — the
same machinery the transfer path uses, fed into the same
buildDestinationAction/buildMembershipProof pair — and always sets
.validTo(), clamped to the membership expiry with a 120s floor so a
near-expiry leaf cannot produce a tx the ledger rejects as
OutsideValidityIntervalUTxO. Missing root, missing leaf and root drift each
fail up front with a message naming the operator action that fixes them,
instead of trapping inside the evaluator.

On the exemption at minting_logic_script.ak:423 (dest_pkh ==
power_user_node_key): the contract is coherent — dest_pkh is the
destination's stake credential because under the CIP-113 address model that
IS the owner identity, and power_user_node_key is constrained only by
must_be_signed_by_credential, which a stake key satisfies. The branch is
dead here because the platform registers power-user nodes under the wallet's
payment credential. So the condition is now computed rather than assumed:
no equality is faked, and the exemption fires by itself on any deployment
that keys its nodes the other way.

D6 — the mint and PauseTransfers both resolved the power-user node from the
registration row's genesis admin PKH while the contract requires that node's
own key to have signed, making both unsatisfiable after a RotateAdmin and
for any non-admin power user. Both now resolve the node from the caller's
payment credential, decode its PowerUser datum via a new parsePowerUser
helper, and refuse with a typed error when the caller has no node or lacks
can_mint / can_pause.

Verified: compileJava + compileTestJava pass. Against the live preview
deployment (via a second, non-indexing app instance so the running backend
was untouched), a mint of 10 "Security" builds and evaluates clean under
AikenTransactionEvaluator — Spend 154878/55836109, Mint 172803/63462474,
Reward 513992/159384620, no ceilingCostFallback — with ttl set,
required_signers = the caller, and the GS output datum decrementing
mintable_amount 20 -> 10. Since the proof was a placeholder and
member_root_hash is empty, that pass is itself proof the exemption disjunct
carried it. PauseTransfers likewise evaluates clean. Nothing was submitted:
the admin wallet's key is not available here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…C (D3/D7 + burn)

Three related changes to the same handler; they share the datum/redeemer helpers
so they land together.

D3 — GlobalStateSpendAction constructor 10 exists on chain and the registration
copy advertises "an irreversible decommission mechanism", but computeActionAndDatum
had no case for it, so the product promised a regulatory kill-switch it could not
invoke. Added, as a nullary Constr 10 [] writing GS_IDX_DEACTIVATED.

Two on-chain preconditions are now checked off chain so the operator learns which
one they tripped instead of reading an evaluator trap. transfers_paused must
already be true, checked against the datum being SPENT — which is what the
contract reads, and which lets a [PauseTransfers, DeactivateContract] batch
satisfy it since the orchestrator rolls currentDatum forward between chained txs.
And a new terminal guard mirrors `expect !input_datum.deactivated`
(global_state.ak:185), which runs before branch dispatch: once set, NO action can
ever spend the global state again, so every action now refuses up front.

D7 — ModifySecurityInfo forwarded arbitrary hex straight through to
PlutusData.deserialize, so "deadbeef" — valid hex, not a CBOR value — surfaced as
a raw parser message from the middle of a transaction build. Added requireHex /
requireHexOfLength / requireCborPlutusData and routed every caller-supplied
payload through them, yielding "change[i]: <field>: <reason>" and an HTTP 400
naming the field.

Trailing bytes are detected by measuring what the CBOR decoder actually CONSUMED,
not by comparing lengths against a re-serialisation. That comparison is unsound
both ways: canonicalisation can expand a value (a 65-byte bytestring re-encodes to
the longer chunked form, hiding a real trailing byte) or shrink it (legal
non-minimal input like 1a00000001 re-encodes to 01, reported as four trailing
bytes that do not exist).

Also aligned the off-chain code with guards the validator already enforces and
which it was silently diverging from: AddTrustedEntity's !has_key (a TreeMap.put
would overwrite instead, producing a datum the validator rejects),
RemoveTrustedEntity's has_key, UpdateTrustedEntity's old_exists + new_clash_ok,
UpdateMemberRootHash's 0-or-32-byte length (any other length soft-bricks the token
because mpf.from_root then traps on every later transfer), and RotateAdmin's
28-byte credential. Map reconstruction now passes an explicit LinkedHashMap rather
than relying on a library default that on-chain datum equality depends on.

BURN — the same KYC defect as the mint, and worse. third_party_transfer_logic_
script.ak:82-129 subjects every token-bearing output to verify_kyc_proof when
requires_receiver_kyc, and unlike minting_logic_script.ak:419-424 it has NO
`dest_pkh == power_user_node_key` self-exemption. So a power user burning part of
their own holding still needs a real membership proof for their own stake
credential, and the placeholder that was being passed could never verify. Partial
burns now resolve a real MPF proof; full burns emit no token-bearing output, so
the destination list stays empty and none is needed. Every burn now sets .validTo
— without a Finite upper bound verify_membership_proof fails regardless of the
proof (lib/kyc/verify.ak:139-142). The power-user node was already caller-keyed;
it is now decoded via parsePowerUser and checked for BOTH can_burn (minting_logic)
and can_force_transfer (third-party logic), which a burn needs together.

The mint's inline proof-resolution and TTL logic were extracted into
resolveMembershipProof / kycClampedTtlSlot and are now shared by both paths rather
than duplicated; MIN_MINT_TTL_MS became MIN_KYC_TTL_MS accordingly. No D1/D6
behaviour changed — the mint's evaluation is unaffected.

Also fixed in the burn: a full burn skipped the entire MultiAsset entry rather
than just the burned asset, so any sibling asset name under that policy would have
been swept into the fee-payer's change output, moving programmable tokens out of
the prog-logic-base address. And comments claiming registry slot 4 still holds
mintingLogic were corrected — 0ec401a put the real third-party validator there.

KNOWN BLOCKER, pre-existing and NOT fixed here: that third-party reward account is
never registered by any code path (the only registerStakeAddress calls are
mintingLogic at genesis and transferLogic — a different script), so a burn will be
rejected phase-1 at submit with WithdrawalsNotInRewardsCERTS. Script evaluation
does not check reward-account existence, which is why everything still evaluates
clean. A comment marks the site; the fix needs a new registration transaction plus
UI, which is a separate piece of work.

Verified: compileJava + compileTestJava pass. Against the live preview deployment
via a second non-indexing instance on :8099, every GS action builds and evaluates
clean under AikenTransactionEvaluator with no ceilingCostFallback anywhere in the
run — SetRequiresSenderKyc 168524/58699753, UpdateTrustedEntity 185580/63396110
(metadata-only) and 194527/66360095 (re-key), RotateAdmin 165829/57250464 with
BOTH admins in required_signers, ModifySecurityInfo 144079/51670744, and the
[PauseTransfers, DeactivateContract] chain at 232127/81708200 and 148812/52027960.
The chained deactivation was evaluated by seeding the app's own HybridUtxoSupplier
with the predecessor's outputs; evaluateTx's inputUtxos argument does not resolve
them. Decoded output datums confirm exactly one field changes per transaction.
PauseTransfers reproduces the D1 report's figure exactly, so nothing regressed.

The BURN CHANGES ARE UNVERIFIED end-to-end: no security token has ever been minted
under any registration, so no token UTxO exists to burn. What was confirmed is
that a burn request gets PAST the node lookup and both new capability checks,
failing at the token-UTxO lookup — so parsePowerUser decodes the on-chain node
correctly and both flags read true, consistent with the audit's PROVED datum.

Nothing was submitted: the admin wallet's key is not available here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…2/D3/D4/D5/D8/D9)

D2 — RotateAdmin needs signatures from BOTH the outgoing and the incoming admin
(global_state.ak: two must_be_signed_by_credential calls, and the backend declares
both in required_signers, making it a ledger-level requirement). There was no UI at
all, and a manual two-pass co-sign was blocked by a guard in
assembleSignedTxPreservingBody that threw whenever the transaction already carried
a vkey witness.

That guard is now a real witness-set UNION. splitVkeyWitnesses / vkeyOfWitnessEntry
/ mergeVkeyWitnessValues split each key-0 value into its [vkey, signature] entries
keeping each entry's bytes VERBATIM — a signature is never re-encoded — deduplicate
by vkey (the ledger treats key 0 as a set), and re-emit tag + header + entries. The
witness-set map's entry count is unchanged; only key 0's value grows. Body,
redeemers and datums are copied byte-for-byte, so the first signature stays valid
and script_integrity_hash (which covers keys 4/5, never vkey witnesses) still
matches. Both definite- and indefinite-length arrays are handled: this codebase's
own redeemers serialise as d8799f…ff, and a wallet may emit key 0 the same way.

The UI is an explicit three-step flow — build + partial-sign as the current admin,
then either counter-sign with a newly connected wallet or copy the CBOR out of band
and paste the result back, then submit. Two-step rather than in-place dual signing
because one browser session holds one CIP-30 wallet at a time, so the transaction
has to survive the hand-off. A MissingRequiredSigners rejection is translated into
a message saying a signature is still missing.

D3 — DeactivateContract is irreversible: afterwards the validator rejects EVERY
spend of the global state, so transfers freeze permanently and holders' tokens are
immobilised. It lives in its own "Danger zone" outside the ordinary save batch so
it cannot be tripped while editing other fields, requires typing DECOMMISSION, and
spells out each consequence. The contract requires transfers to be paused first, so
the flow chains a PauseTransfers ahead of it when they are active and says two
signatures will be requested. Once deactivated the panel renders a terminal banner
INSTEAD of the controls — offering actions that cannot succeed is worse than
offering none.

D4 — SetRequiresSenderKyc was unreachable: GsChangeSpec omitted
requiresSenderKycEnabled, the exact key the controller reads. Added, with a toggle.
The copy states plainly that this is recorded but NOT enforced — no validator at
the pinned revision reads requires_sender_kyc — so it is not mistaken for a live
compliance switch.

D5 — UpdateTrustedEntity was backend-only. Wired to a per-entity edit control,
staged separately from the add/remove diff so a rename is one transaction instead
of Remove + Add, which is two and leaves the entity visibly absent on chain in
between. The sort order the audit verified on add and remove is untouched.

D8 — handleReset never touched selectedToken, so the fetch effect keyed on it did
not re-run and "Mint More" returned to a form still showing the pre-mint
mintable_amount. This is why a mint that never reached the chain read as a stale
number. The fetch is now a callback with a sequence guard, called on mint success,
on mint failure, and in handleReset; a failed read now DISCARDS the previous value
and shows "unknown" rather than leaving a figure that can no longer be verified.

Failure is now visibly distinct: a persistent inline banner (a toast disappears,
and the form looks identical either way) saying the reason, that nothing was
written, and that the unchanged figures below ARE the failure rather than a stale
view. And success no longer overclaims — setStep("success") fires on mempool
acceptance, not inclusion, so the screen says "Mint Submitted" and polls the global
state until mintable_amount moves, then reports "Confirmed on chain" with the
before/after. If it never moves it says so.

D9 — the `if (submit.error)` branch was dead: the backend returns partial failures
as HTTP 400, so apiPost throws first and the hashes of transactions that DID land
were discarded. That matters because the chain is mempool-chained: a failure at
index i leaves 0..i-1 applied and the global state moved. parseSubmitChainFailure
recovers the structured body, and the UI renders a per-change list of what landed
(with hashes), what was rejected, and what was never attempted. Labels are built
alongside the specs so display index i is the change the backend submitted at i. A
member-root publish among the transactions that landed is still acknowledged.

Client-side CBOR validation for the security-info and trusted-entity metadata
fields (a dependency-free structural scan) disables Save with an inline message
instead of round-tripping to fail. It accepts what the backend accepts, including
non-minimal encodings and text strings — the backend converts those to byte strings
— because a UI that rejects valid input is as unhelpful as one that accepts invalid
input.

Also: assembleSignedTxPreservingBody silently returned the UNSIGNED transaction
when a wallet produced an empty or key-0-less witness set, which is
indistinguishable from a signed one to every caller and would be submitted. Both
paths now throw.

Verified: npx tsc --noEmit reports no errors in any file touched here (the 10
errors in __tests__/script-parameterization.test.ts are pre-existing — missing
modules and Jest type defs — and untouched). The witness merge is verified by
construction and by review, not by a real two-wallet co-sign, which needs two
funded browser wallets and a submission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Skipping them at load removed their contracts from the cache that
getSubstandardById / getSubstandardValidator serve, which is the runtime path
for tokens that were ALREADY issued. MpfRootSyncJob then failed every tick with
'kyc-extended contract not found: global_state.global_state.mint'.

Load everything as before and filter only in getAllSubstandards(), the list the
issuance wizard offers. Disabling a substandard now means 'no new tokens of this
kind', not 'existing tokens break'.

Stopping a substandard's background jobs is a separate switch
(kycExtended.enabled), which is where that belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two gates are independent in the pinned contract — transfer_logic_script.ak:123
reads requires_sender_kyc for the per-sender loop, :157 reads requires_receiver_kyc
for the per-destination loop. The off-chain side still gated the sender proof on the
receiver flag, which was correct only against the OLD pin (the F-20 bug the re-pin to
@e69c66a fixed on chain).

Effect: a token with sender-KYC off but receiver-KYC on demanded a sender proof the
chain never asks for — and none can be produced when the member root is empty.

Also exposes requiresSenderKyc (and deactivated) on the global-state endpoint and
requiresSenderKyc on token-context, so the UI can gate each side on its own flag
instead of inferring both from one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 'recorded but not yet enforced' note was true against the old pin, where both
KYC loops read requires_receiver_kyc (the F-20 bug). The re-pin to @e69c66a fixed
that: transfer_logic_script.ak:123 gates the per-sender loop on requires_sender_kyc,
independently of :157 which gates the per-destination loop.

Telling an admin a live compliance switch does nothing is worse than saying nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`buildFullRegistrationChain` forced `quantity` to "0", so registration was
always structural and the `willMint` branch of `buildRegistrationTransaction`
— largely written but never exercised — refused every request outright.

Its stated reason was stale. It claimed `RegisterToken` with
`minted_amount > 0` is unreachable because `verify_mint_or_burn` needs a
`can_mint` power user as a signing reference input and none exists at
registration time. True for a standalone registration; false for the chain,
where AddPowerUser is phase 2 and registration is phase 3. The node exists —
as an output of an unsubmitted tx, exactly like the chained GlobalState UTxO
the builder already threads through. `verify_token_registration`'s
`minted_amount > 0` branch (validators/minting_logic_script.ak:236-262) is a
first-class path and the redeemer type's own docstring says a registration MAY
carry the first mint.

So: thread the two missing UTxOs through, and compute the indices.

- `SecurityTokenRegisterRequest.initialMintQuantity` (default "0") is
  forwarded by the chain builder instead of the hard-coded "0". Structural
  registration stays the default — it is the path with on-chain mileage.
- `RegistrationChainInputs` replaces the bare chained-GS `Utxo` overload,
  adding the AddPowerUser node and the denylist root (the absence-covering
  element while the list is empty). Each may be null, falling back to on-chain
  discovery. `f034683`'s prefer-caller-supplied-GS behaviour is preserved.
- When minting: GS is SPENT under `MintSecurity` (not referenced) so the cap is
  enforced on chain, the power-user node joins the reference inputs and its
  node key is added as a required signer alongside the GS admin, and the
  recipient gets a destination action built by the same
  `buildDestinationAction` / `resolveMembershipProof` / `kycClampedTtlSlot`
  helpers the steady-state mint uses (f3724fa), including the self-mint
  exemption. Only `requires_receiver_kyc` is consulted, per f6befc7.
- `verifyRegistrationMintIndices` re-derives every index from the finished
  transaction — sorted inputs, sorted reference inputs, output shape, and the
  issuance mint redeemer's real position in the witness set — and aborts the
  build if the balancer reshaped anything, so a mismatch fails off chain
  instead of trapping on it.

Verified against the live preview deployment with `AikenTransactionEvaluator`
(no `ceilingCostFallback` anywhere). Registration tx with a 250 first mint:
Spend 171832/62001454 (GS MintSecurity) + Spend 118318/37270973 (directory) +
Mint 441930/151659615 (registry) + Mint 187626/68358784 (issuance) + Reward
714940/221329706 (minting_logic RegisterToken). GS datum's mintable_amount
1000 -> 750, exactly the minted quantity. The same chain rebuilt with this
change reverted produces byte-identical CBOR and tx hashes for the zero-mint
path.

Not submitted: the admin wallet key is not in the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…writes rows

buildGlobalStateInitTransaction persists a registration row and a bootstrap
power-user row as a side effect of BUILDING the genesis tx, and every mint
precondition fired in phase 3 — so each refusal left orphan rows behind for a
token that would never reach the chain.

A new PHASE 0 refuses everything derivable from the request before phase 1:
the quantity parses and is non-negative; it is within initialMintableAmount
(exact — the cap is written by this same genesis tx); the bootstrap power-user
pkh is 28-byte hex; the recipient is a base address; and, when minting, both
the power-user key and the admin key equal the fee payer's payment credential.
The last one closes a hole that only surfaced at SUBMIT time, after genesis and
AddPowerUser were already broadcast and mempool-chained.

Receiver KYC is refused up front with the fix named. verify_mint_destinations
wants a membership proof against member_root_hash, genesis writes that root
empty, and no root can be pre-published because the policy id does not exist
until genesis picks its bootstrap UTxO. The check compares the recipient's
STAKE credential against the power-user key, so the contract's own self-mint
exemption still passes; only the genuinely unprovable case is rejected, with
three concrete remedies in the message.

Also: an asset-name mismatch between the persisted security_asset_name (which
parameterises minting_logic) and the request's asset name is now a typed error
instead of a raw `expect` trap in verify_token_registration — reachable via the
public two-arg overload, where a client-supplied name meets a DB-loaded context.

HybridUtxoSupplier's scratchpad becomes thread-local: it is a singleton, and
clear() at the end of one build was deleting the mempool UTxOs of any build
running concurrently on another request thread. Both builders now clear in a
finally, replacing six scattered inline clears that early returns kept missing.

Verified on preview against a second backend instance (:8099; the user's :8080
was not touched). The 250-token mint chain evaluates under a direct
AikenTransactionEvaluator run — registration Reward 714940 mem / 221329706
steps, mintable_amount 1000 -> 750 decoded from the GS output datum — with zero
ceilingCostFallback. Every refusal above returns 400 with the row counts
unchanged. The zero-mint chain is BYTE-IDENTICAL to 350fc06 across all four
CBORs and tx hashes, and two concurrent builds both produce validating chains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wizard sent `quantity`, which buildFullRegistrationChain overwrites with
`initialMintQuantity` (default "0") before delegating. So the user typed a
supply, the chain registered zero, and the success screen reported the number
they had typed. Nothing was minted and nothing said so.

The chain request now carries `initialMintQuantity` and drops `quantity`
entirely; the supply is an explicit field on the kyc-config step, pre-filled
from token-details, so the number shown is the number sent. The success screen
prefers what was actually sent over what was typed, and a structural
registration now correctly reports 0.

`requiresReceiverKyc` was hardcoded true, which made a first mint at
registration structurally impossible — the mint needs a membership proof
against a member_root_hash that genesis writes empty. It is now a toggle
(still defaulting on, per BaFin's posture). With it on and a supply requested,
the wizard blocks and explains the conflict inline rather than sending a chain
whose phase 1 would persist rows for a token that can never be registered.

Two further gaps the same reasoning exposed:

- `recipientAddress` was collected (and pre-filled) on token-details and
  carried by the flow, but never sent — every initial supply went to the fee
  payer regardless, and the backend's "send it to an address whose stake
  credential is the power user" escape hatch was unreachable from the UI.
- A partial or failed chain submission showed an error toast and then fell
  through to onComplete, advancing to "Token Registered!" with a supply that
  may never have been minted. It now stops on the step, keeping the policy ids
  and naming the transactions that did land so the operator can recover.

Adjacent copy corrected: the Mintable Amount helper said "Set to 0 for no cap"
when 0 means no tokens can ever be minted, and the chain summary claimed the
registration mints the initial supply unconditionally.

`npx tsc --noEmit` reports no errors outside the pre-existing __tests__ ones.
The exact request shape this now emits was exercised against a live backend:
`requires_receiver_kyc = False` and 250 tokens are visible in the decoded
global-state datum of the built registration transaction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A registration that carries a first mint attached five validators inline —
minting_logic 7328 B, global_state spend 4266, registry_mint 2045,
issuance_mint 1873, registry_spend 1574 = 17 086 against a 16 384-byte
max-tx-size, before a single datum, output or witness. Structurally over
budget, not marginally, and nothing in cardano-client says so: the builder
produces the transaction, the evaluator scores it, and the first sign of
trouble is a submit-time rejection after the earlier links of the chain have
already been broadcast.

So publish the two dominant scripts as reference scripts, the way the core
protocol does for PLB/PLG/unfracking — except these two cannot be published
once globally. Both are parameterised per token, so their hashes do not exist
until this token's genesis tx has picked its bootstrap UTxO, and genesis
already carries ~9.4 KB of inline minting scripts and cannot also carry
~11.6 KB of reference-script outputs. They get their own transaction.

- New chain phase `publishScripts`, between AddPowerUser and registration.
  After genesis because that is what fixes the policy ids; before registration
  because that is what reads them; after AddPowerUser rather than before so
  phases 1 and 2 stay byte-identical to the proven chain. Runs ONLY when the
  registration mints — a structural registration is 10 947 B inline and fits,
  and publishing there would lock ~55 ADA and add a fifth signature for
  reference scripts that chain never reads.
- Outputs go to the fee payer's ENTERPRISE address. Same payment credential so
  the admin can reclaim the min-UTxO, but a different address from the base one
  every builder funds itself from — both the registration's chaining scan and
  AccountService.findAdaOnlyUtxo select by address and would otherwise happily
  spend a reference script. Sized with MinAdaCalculator against live params,
  not guessed. Refuses up front if the funding UTxO cannot cover it, rather
  than letting the balancer top up from UTxOs the unsubmitted genesis tx is
  already spending.
- `HybridScriptSupplier`: the script-side twin of HybridUtxoSupplier. A
  reference script lives in an output and Utxo carries only its HASH, so the
  Aiken evaluator, ReferenceScriptResolver and the Conway ref-script fee all
  resolve it through a ScriptSupplier — which for an unsubmitted output the
  backend cannot answer. Without this the local evaluator errors and
  ceilingCostFallback fabricates the ex-units, i.e. it fails silently.
- registry_mint / registry_spend stay inline deliberately: protocol-global, so
  their home is the protocol deployment's reference set, not a per-token
  publish. 5492 B inline leaves ~11 KB of headroom.
- `checkedTxSize` on every transaction in the chain, so an over-budget tx is a
  build-time refusal naming the transaction rather than a submit-time surprise.

Three wizard changes ride along:

- The kyc-config step no longer asks for the initial supply. It was the same
  number token-details already collects, with nothing keeping the two in sync;
  it now reads that value and shows it read-only. token-details accepts 0 for
  this flow, which is what makes a structural registration reachable at all.
- `requires_receiver_kyc` is exposed on the admin mint step. It SETS the flag,
  via SetRequiresReceiverKyc as its own transaction with a confirmation poll —
  it cannot ride in the mint, because MintSecurity rebuilds the expected GS
  datum with only mintable_amount changed and compares it with equals_data,
  and the mint builder reads the GS UTxO from chain rather than from an
  unsubmitted tx.
- OPT-IN `seedRecipientInAllowlistAtGenesis` seeds the compliance allowlist
  with the recipient's stake credential at genesis and writes the resulting MPF
  root into member_root_hash, which is the only ordering that lets
  requires_receiver_kyc coexist with a first mint. It asserts on chain that the
  recipient is a verified member on the issuer's say-so, with no KYC behind it,
  and that claim covers every later transfer — not just this mint. Off by
  default, amber-labelled as unverified in the UI, WARN-logged by the backend.

Verified against the live preview deployment with a standalone
AikenTransactionEvaluator run outside the app's three-tier evaluator (zero
ceilingCostFallback in the app log either way). Sizes: genesis 10 529,
addPowerUser 6 834, publishScripts 11 813, registration 7 390 (was 17 086
inline), transferLogic cert 6 742 — all under 16 384. mintable_amount 1000 ->
750 for a 250 mint. The zero-mint chain is byte-identical to 60506e0, proved
by building it from a separate worktree at that commit and diffing all four
CBORs and tx hashes. Registration under requires_receiver_kyc=true with an
ordinary recipient now validates via the seeded root.

Not submitted: the admin wallet key is not in the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…phase

None of these were reachable from the wizard's happy path, which is exactly why
they were worth fixing: each one only fails after something has already been
broadcast, or fails silently and honestly-looking.

- An ENTERPRISE fee-payer address collapses the reference-script address onto
  the fee-payer address, because the former is derived from the latter's payment
  credential. The registration's chaining scan then picks a reference-script
  output as its funding input and the transaction spends the very script it is
  reading. PHASE 0 only ever required the RECIPIENT to be a base address, so
  this was reachable. Refused up front, and both funding scans now skip outputs
  carrying a scriptRef as defence in depth.

- The public 2-arg `buildRegistrationTransaction` — the one
  TokenOperationsService dispatches client requests to — takes its quantity
  straight from the request and has no published reference scripts, which is
  precisely the combination that produces a 16 584-byte transaction. It handed
  that back to the caller with no diagnostic, because `checkedTxSize` was only
  called from the chain orchestrator. Moved into the builder, so it covers every
  caller.

- `seedRecipientInAllowlistAtGenesis` was honoured even when the chain minted
  nothing, contradicting both its own javadoc and the UI's copy. Reachable
  without leaving the step: the checkbox renders only while receiver KYC and a
  first mint are both on, but nothing reset the state, so ticking it and then
  turning receiver KYC off kept sending `true` from an invisible control — a
  live, unverified compliance assertion in the datum that nobody could see, and
  that would go live the moment an admin later ran SetRequiresReceiverKyc. The
  backend now gates on `requiresReceiverKyc && initialMintQuantity > 0` and logs
  when it ignores the flag; the frontend clears the state when the control stops
  applying.

- Three inconsistent ADA thresholds across the publish -> registration hand-off:
  the publish tx guaranteed only min-UTxO of change, phase 2.5 demanded > 5 ADA,
  and the registration's chaining scan demanded > 10. A 5-10 ADA change passed
  both earlier guards and died in phase 3 with the opaque "could not chain tx",
  after the operator had been told the chain was buildable. Publish-side
  headroom raised to the 11 ADA the next phases actually need.

Also softens the wizard's claim that the ~55 ADA of reference-script min-UTxO is
reclaimable: it is recoverable in principle, but the platform has no reclaim
action and most wallets will not surface an enterprise address.

The review's one open question — whether `global_state_ref_input_index = 0` is
still safe on the mint path now that index 0 can resolve to a datum-less
reference-script UTxO — is settled by the vendored contract:
`minting_logic_script.ak:212-262` branches on `minted_amount`, and the `> 0` arm
reads `self.inputs` at `global_state_input_index`, never dereferencing the ref
index.

Re-verified: all four chains rebuilt and re-evaluated under a standalone
AikenTransactionEvaluator, sizes unchanged (10 529 / 6 834 / 11 813 / 7 390 /
6 742), zero ceilingCostFallback, and a new case — receiver KYC on, seed on,
mint 0 — confirms the seed is now ignored, leaving member_root_hash empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ty-token

CIP-68 needs two assets under one policy: a (100) reference token of quantity 1
carrying the metadata as an inline datum, and a (222)/(333) user token. The Java
backend minted only an unlabelled user token and discarded the metadata the
wizard collects, so a wallet following the label found no metadata to resolve.

Core CIP-113 `no_escape` requires every output holding a token of the issuance
policy to sit at a programmable_logic_base address with an inline stake
credential. The (100) token is itself a programmable token, so it goes to the
issuer's PLB base address rather than a plain metadata script address — which
also leaves the issuer able to spend it and rewrite the datum later.

dummy and freeze-and-seize mint the pair at registration; the issuance redeemer's
registry-node output index shifts 2 -> 3 to account for the extra output. For
freeze-and-seize the labelled name is settled before buildIssuerAdminScript,
because that script is parameterized by the asset name and issuance_mint by that
script — so the label participates in the policy id. That is also what makes the
backend agree with the TypeScript SDK, which already implemented CIP-68 and
therefore produced a different policy id than the backend for the same input.

security-token cannot fold the reference token into registration: its
verify_token_registration rejects a second asset name under the policy
(minting_logic_script.ak:198-204). Genesis applies the label, and the first
MintBurn adds the (100) token — that path only measures security_asset_name and
verify_mint_destinations skips outputs without it. The reference output is
appended at index 2 because the GS spend redeemer hardcodes gs_output_index = 1.
Genesis persists the metadata (V13) so the mint can complete the pair without the
admin retyping it.

222 for a requested supply of exactly 1, 333 otherwise. For security-token the
supply is initialMintableAmount, since its registration is structurally
mint-free. The pinned SDK hardcodes 333, so the freeze-and-seize SDK toggle is
disabled while CIP-68 is on rather than left as a policy-id trap.

kyc and kyc-extended stay out of scope and now refuse cip68Metadata outright; the
wizard no longer renders the form for them. The dummy/FES mint paths refuse it
too, since their pair is minted at registration. Silently collecting metadata
that goes nowhere is the bug being fixed, so no path swallows it.

Verified by building and phase-2 evaluating the transactions under
AikenTransactionEvaluator in a hermetic offline harness that asserts against the
ceilingCostFallback values, and by decoding the resulting outputs. No .ak file,
core plutus.json, KeriConfig, KeriService, ProtocolScriptBuilderService or
PreviewProtocolDeploymentMintTest was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No Cardano backend is reachable in this environment, so verification runs with
no network at all: rebuild the protocol bootstrap, virtually submit it (its
outputs become the spendable UTxO set), then drive the REAL substandard handlers
against in-memory suppliers and phase-2 evaluate each result.

The handlers never call withTxEvaluator, so QuickTxBuilder falls back to the
injected TransactionProcessor as its evaluator; this one runs the real aiken/uplc
machine. That means the production code path evaluates unmodified — these tests
exercise the shipped handlers, not a re-implementation of them.

reportAndCheckRedeemers asserts every redeemer carries genuine evaluator output:
not the 10000/10000 pre-evaluation placeholder, and not YaciConfiguration's
ceilingCostFallback of 1500000/800000000, which would otherwise launder a script
trap into a successful build. The one exemption is documented in place —
buildRegisterTransferLogicTransaction injects its Cert redeemer in postBalanceTx,
after cost evaluation has run, so its ex-units are hardcoded and no evaluator can
ever see them.

Cip68Evidence decodes the built transactions back rather than trusting the
builder: CIP-67 labels and quantities per output, both outputs at a PLB base
address with a genuinely inline stake credential (AddressType.Base, so a pointer
address cannot pass), and the metadata datum walked field by field out of its
Constr 0 [map, 1, 1] shape.

Covers dummy and freeze-and-seize with and without CIP-68, dummy at quantity 1 to
pin the (222) rule, and the four-transaction security-token chain plus the mint
that completes its pair. All nine transactions evaluate under 16384 bytes; the
security-token mint is the tight one at 14923.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eleven findings from an adversarial review of f032234/8580cd4. The through-line
is that CIP-68's (222)/(333) label is a PROMISE — that a (100) reference token
exists, resolves, and describes exactly this token — and several paths could
break that promise while reporting success.

H4, stated first because the rest leans on it: a (222) label is now used only
where a validator caps lifetime supply on chain. dummy and freeze-and-seize cap
nothing — `issue` is `redeemer == 100`, issuer_admin ignores its _asset_name —
and both expose an unconstrained later mint, so a one-unit registration there
could become a two-unit "non-fungible" token; they are always (333).
security-token may take (222) at a cap of 1, because GlobalState's
mintable_amount only ever decreases. userTokenLabel now takes an explicit
lifetimeSupplyCapped flag so the decision cannot be made by accident.

That also dissolves M10 at the root: the FES label no longer depends on
quantity, so the blacklist init and the registration can no longer derive
different issuer_admin reward addresses from different quantities. The residual
on/off mismatch is pinned by a new cip68_enabled column (V14) and cross-checked
BEFORE building, since discovering it on chain costs the init deposit.

H2: kyc/kyc-extended refused cip68Metadata at registration but silently
DISCARDED it on mint, so a direct API call minted a labelled token with no
reference token behind it. Both mint paths now refuse as their first statement.

H3: "metadata is stored" and "the reference still needs minting" were conflated,
so every later ordinary mint reloaded the stored metadata, found the (100), and
refused — advising the caller to "omit cip68Metadata", a field they had never
supplied. An ordinary second mint now simply works. The lookup is by policy +
asset name chain-wide rather than scanning one fee payer's PLB address, so a
moved reference or a different stake credential can no longer produce a second
(100); confirmed issuance is one-time state and no longer auto-clears.

H5: dummy's mint is bound to the registry's stored name, and refuses (100)
outright. M6: (100) is refused in both transfer paths, which would have rebuilt
it with Constr(0) and erased the datum. M7: the FES input selector asked
quantity > 1, so a one-unit holding was invisible and reported "Not enough
funds"; it now asks > 0. M8: (100) seizure is refused (issuer_admin is
parameterised by the USER token's name, so it can never validate) and the
continuing output preserves the input's own datum. M9: per-field ceilings and a
512-byte datum budget inside buildDatum, plus a preflight of the finished CBOR
against the live maxTxSize — refusing, never truncating, because the datum is
the token's permanent on-chain description. L11: a blank base name, which both
readers reject as unlabelled, is refused at the source.

H1 is a pre-existing weakness in the pinned contract and is NOT fixed here:
MintBurn measures only security_asset_name, so a can_burn-only operator can mint
arbitrary siblings with expected_minted_amount = 0. The off-chain is bounded so
it never builds that shape, and the defect is reported upstream as Defect E —
but that is containment of our own client, not a fix.

38/38 CIP-68 tests green, 20 of them new and written for the adversarial cases.
Full suite: 66 failures out of 155, identical to the 66 measured on the stashed
tree — zero newly-introduced failures. Non-CIP-68 sizes and ex-units are
unchanged digit for digit (dummy 6882, FES 7112). ceilingCostFallback did not
fire and does not appear anywhere in the output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…data length

The frontend re-derives the CIP-67 label locally to record the asset name that
went on chain, so it has to agree with Cip68.userTokenLabel exactly — if the two
drift, the recorded name stops resolving. userTokenLabelFor now takes the same
lifetimeSupplyCapped flag as the backend, and userTokenLabelForSubstandard keeps
the capped/uncapped decision in one place rather than at each call site. Both
existing call sites are dummy and freeze-and-seize, which are uncapped, so they
pass the substandard explicitly rather than relying on a default that could
silently drift.

The CIP-68 form had no length limits at all against roughly 1.4 KB of headroom
on the tightest path, so maxLength now mirrors the backend's per-field ceilings
via one shared CIP68_FIELD_MAX_LENGTHS constant. The server enforces them
regardless; these exist so the user finds out before paying for a blacklist init
or a genesis. Nothing is truncated on either side.

Also corrects a now-stale comment on the freeze-and-seize SDK toggle: it claimed
the two builders disagree on the label for a supply of 1, which is no longer
true — both are (333). The toggle stays disabled while CIP-68 is on, but for the
honest reason: whether the two produce byte-identical datums, min-UTxO sizing
and output ordering is unverified, and any divergence yields two different
policy ids for one wizard input.

npx tsc --noEmit output is byte-identical to its baseline (the 12 pre-existing
__tests__ lines, nothing new).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
H3 — a stale indexer could still authorise a second (100). UtxoProvider now
answers existence as a tri-state (assetPresence): only a 200-with-holders or a
404 are evidence, and every other failure is UNKNOWN rather than absence. The
security-token mint refuses on UNKNOWN instead of guessing, treats the persisted
cip68ReferenceMinted flag as authoritative once set (explicit metadata no longer
forces a retry past it), and claims the mint with a conditional UPDATE —
compare-and-set, issued after the build and before the CBOR is returned, so a
race loser never receives a signable transaction and an unrelated build failure
does not strand the pair.

H4 — security-token now labels (333) too. mintable_amount is not a lifetime cap:
global_state.ak computes remaining = mintable_amount - minted_amount with a
signed minted_amount, so a burn restores the allowance and mint 1 -> burn 1 ->
mint 1 exceeds a supply of one under an NFT label. No pinned contract caps
lifetime supply, so no substandard may claim (222). Rationale comments updated
in Cip68, the request DTOs and the TS mirror.

H5 — dummy's ordinary mint bound the canonical-name check to the policy id the
request claimed, while the transaction derived its issuance policy from
caller-supplied bootstrap params. Naming policy A / asset A while selecting
bootstrap B minted asset A under policy B. The derived policy is now checked
against the claimed one and refused on mismatch.

M9 — Cip68.preflightTxSize fails closed: a missing or non-positive maxTxSize is
refused, not skipped. The frontend gets a shared validateCip68Metadata that
validateForm and the SDK route in cip113-context both run, so per-field ceilings
survive restored or programmatic state; the four unwired field errors are now
rendered.

M10/R1 — cip68Enabled is carried through the DB import path and the SDK
registration callback. Import previously dropped it, turning a known true/false
into NULL and silently disabling the registration cross-check.

L11 — labelAssetNameHex refuses a blank base and an over-long name, matching
Cip68.labeledAssetName.

R2 — the FES SDK-toggle copy no longer claims a quantity-one token is (222).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the adversarial cases the reviewer noted were missing: a stale/failing
asset lookup must refuse rather than mint a second (100) (H3), a cap-1
security token must still label (333) (H4), a cross-bootstrap mint must be
refused (H5), a missing maxTxSize must fail closed (M9), and a blank base name
must be rejected in both mirrors (L11).

Also realigns one assertion the H3 fix invalidated: the refusal now reports
that the reference token 'has already been issued' — the persisted flag is
authoritative — rather than claiming a live UTxO was observed.

43 CIP-68 tests, 0 failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reconciles the v0.4.0 platform port with the CIP-68 work. Five conflicts:

  SecurityTokenSubstandardHandler.java  both branches grew the handler
  security-token.ts                     both extended the wire type
  token-details-step.tsx                both destructured wizardState
  kyc-config-step.tsx                   union: feat's body + CIP-68 metadata
  (plus a duplicated protocolParamsSupplier field, folded into one)

Two follow-on fixes the merge forced:

- OfflineCip68EvalTest passed an unstubbed CardanoConverters mock. The merged
  mint path clamps its validity bound through cardanoConverters.time(), so
  time() returning null killed all four security-token CIP-68 tests with an
  NPE. Replaced with a real PREVIEW converter.
- The same fixture predates hybridScriptSupplier; without it the evaluator
  cannot resolve reference scripts from unsubmitted outputs and silently
  falls back to fabricated ex-units.

Verified against the pre-merge baseline (05764f8): both sides fail the same
64 unique tests, byte-identical sets, so the merge adds no regressions. Those
64 are pre-existing environmental failures (Spring/DB service tests and the
Preview* on-chain integration tests), not introduced here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…party reward account

The first burn ever attempted was rejected at 21480 bytes against the ledger's
16384 limit, and would have been rejected again at submit even if it had fit.

Two independent blockers, both structural.

SIZE. The burn needs four validators and can drop none of them: ThirdPartyAct
requires the withdrawal keyed on registry-node slot 4, minting_logic gates
can_burn, GlobalState must be spent to decrement the supply, and issuance_mint
is what actually burns. Inline that is 19385 bytes:

    7211  minting_logic
    6269  third_party_transfer_logic
    4183  global_state spend
    1722  issuance_mint

Three are now read from reference scripts, leaving only issuance_mint inline.
The registration chain already published two of them, but into local variables —
used by the next transaction and then forgotten. A burn happens days later from
another process and cannot rediscover them: they sit at the admin's enterprise
address, indistinguishable from change, under per-token parameterized hashes.
V15 records where they landed.

They do NOT all fit in one publish transaction — a publish carries the scripts
in its own outputs, so all three measured 18273 bytes, over the same limit.
third_party_transfer_logic therefore rides on the cert transaction that
registers its reward account, which already carries it as a RegCert witness and
was otherwise nearly empty. Hence two tx-hash columns, not one.

REWARD ACCOUNT. The burn withdraws 0 from third_party_transfer_logic's reward
account, which nothing ever registered. The substandard registered minting_logic
and transfer_logic — different scripts. Slot 4 used to hold minting_logic, so
the burn's existing withdrawal satisfied ThirdPartyAct for free; 0ec401a put the
real validator there and silently created the gap. Script evaluation cannot
catch it (reward-account existence is a ledger rule, not a Plutus one), so the
burn evaluated perfectly and would fail phase-1 at submit with
WithdrawalsNotInRewardsCERTS, after signing. Added a builder mirroring
buildRegisterTransferLogicTransaction, wired as chain phase 6 and as
POST /{policyId}/register-third-party-transfer-logic, plus a burn precondition
that refuses up front instead of handing back a doomed transaction.

Also fixes a defect this work surfaced: buildFullRegistrationChain aligned the
request's quantity with the persisted row but not its asset name. A CIP-68
registration labels the name (333) at genesis and parameterizes minting_logic
with the labelled form, so the builder's own mismatch guard refused EVERY CIP-68
registration carrying a first mint — the exact combination the chain exists to
support. The persisted name is the authority.

Verified by a new offline test that drives a real registration-with-first-mint
chain and burns the resulting token UTxO under AikenTransactionEvaluator:

    genesis                          10531
    addPowerUser                      6834
    publishScripts                   11829
    registration                      7266
    registerTransferLogic             6750
    registerThirdPartyTransferLogic  13277
    burn                              3361   (was 21480)

All six burn redeemers evaluate with real ex-units — including the three Reward
redeemers — so the size figure reflects scripts that actually ran, not
ceilingCostFallback. Full suite: same 66 pre-existing environmental failures as
before the change, no regressions, one new passing test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sing

Three separate defects, all landing on the same symptom: a registration
certificate emitted for a credential that already exists
(StakeKeyAlreadyRegisteredDELEG), or skipped for one that does not
(WithdrawalsNotInRewardsCERTS). Neither is catchable by script evaluation —
reward-account existence is a ledger rule, not a Plutus one — so every one of
these builds and evaluates cleanly and fails at submit, after the user signs.

1. DUMMY had the predicate inverted:

     registered   = required.filter(isRegistered)          // subset of required
     toRegister   = registered.filter(!required.contains)  // ALWAYS EMPTY

   The registered list is by construction a subset of the required one, so the
   second predicate was never true. The method always returned a null CBOR, the
   wizard reported "all required stake addresses are already registered", and no
   certificate was ever built for anyone.

2. FREEZE-AND-SEIZE answered buildPreRegistrationTransaction with a hard error,
   "Use DenyList Init instead". That holds only while a token's blacklist init
   and its registration are built back-to-back. issuer_admin is parameterized by
   the ASSET NAME, so registering a second token against an existing blacklist
   needs a reward account no init ever saw. Implemented it properly, deriving the
   credential exactly as the registration path does — a different derivation
   would register a different address and reintroduce the bug it fixes.

3. The SDK-facing check, /script-registration/check, asked Blockfrost for the
   account's `active` flag. That is the wrong question for a script stake
   credential: these exist only to be withdrawn-from, never delegate and never
   earn rewards, so the flag reads false — or the account 404s — even when the
   credential is properly registered. Every failure path returned false, i.e.
   "register it again", and the SDK's freeze-and-seize path emits a certificate
   on exactly that answer. Now reads the indexed certificate history and takes
   the most recent certificate for the address, so a later deregistration
   correctly flips the answer back — something `active` could not express.

Six regression tests pin the truth table rather than the implementation, so an
inversion cannot return unnoticed: none/one/both registered for dummy, the
build-vs-skip pair for freeze-and-seize, and the check's own semantics including
deregistration. They discover the required addresses by asking the handler
rather than hard-coding script hashes, which change whenever a blueprint is
rebuilt.

Full suite: same 66 pre-existing environmental failures as before, no
regressions, six new passing tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… credential exists

Follow-up to bda4c00, which made dummy actually build its certificate transaction
and then hit the opposite wall:

  3145 — Trying to re-register some already known credentials.
         knownCredential: 17e9e9e3412e7198877557fab1181f394ed36cf5db27b418d3f68990

That hash is the dummy transfer validator's. It IS registered on chain, and the
stake_registration table has no row for it — so the handler, which consulted only
that table, concluded "not registered" and emitted a certificate.

Absence from the index is not evidence of absence from the chain. The index is
populated from sync-start-slot, which is genesis only on a devnet (mainnet starts
at the block that minted the contract ref input), and it starts empty again after
any devnet reset. The dummy issue/transfer validators are protocol-GLOBAL and
unparameterized, so they are registered exactly once per network — almost
certainly before whatever slot a given deployment syncs from. The index will
normally NOT have them.

So isStakeAddressRegistered now asks the ledger first and falls back to the
indexed certificates. Blockfrost's `active` is the account's registration state
(a never-registered account 404s rather than reporting false), so a successful
response is authoritative in both directions and is taken as final. The fallback
still takes the most recent certificate, so a later deregistration flips the
answer back — which the account flag alone cannot express.

Both handlers now route their register/skip decision through that service rather
than querying the repository directly. Getting this wrong toward "false" is the
expensive direction: every caller registers on that answer, and re-registering is
rejected outright, whereas a wrong "true" merely surfaces as the withdrawal
failure the pre-registration step already exists to prevent.

Note bda4c00's premise was half wrong and is corrected here: `active` is not the
delegation flag, so the original Blockfrost check was asking the right question —
it just flattened every transport failure to "not registered". The index is the
right fallback, not the right primary.

Test note: the new fixtures originally passed Mockito.mock(QuickTxBuilder.class)
where the service does not use a builder at all. Under the inline mock maker that
instruments the class JVM-wide, which broke the security-token burn's real
evaluator whenever OfflineCip68EvalTest ran as a whole while passing in
isolation. Passing null instead — with a comment saying why — restores it.

Full suite: 168 tests, same 66 pre-existing environmental failures, no
regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…remember, and recover

Third attempt at the same 3145, and the first one grounded in a measurement
rather than an assumption about the backend.

WHY THE PREVIOUS TWO FIXES COULD NOT WORK

Both available sources are blind here, for structural reasons:

  * The account endpoint does not exist on this backend. /accounts 404s, and so
    does /blocks/latest — a Spring "no handler" body, not a Blockfrost "not
    found". So isSuccessful() is false for every address forever, and caea118's
    ledger-first lookup can never return an answer at all.

  * The indexed certificates cover a WINDOW, not a history. Measured on the
    devnet that produced this commit:

        earliest indexed certificate   119969749
        chain tip                      120386215

    The table is populated from sync-start-slot and starts empty again after any
    database reset, so anything registered earlier is permanently invisible.

That window is exactly the wrong shape for the credentials that matter. dummy's
issue and transfer validators are protocol-GLOBAL and unparameterized: registered
ONCE per network, on the first registration anyone ever performed, which is
older than the window. Credential 17e9e9e3… — the dummy transfer validator, the
one in the error — is registered on chain and absent from stake_registration.
Neither source could ever say otherwise, so every attempt failed identically.

WHAT THIS DOES INSTEAD

Adds a third source: what this deployment has been told. known_script_registration
is consulted first, before the ledger and the index. It is deliberately a cache of
observations rather than a derived view — nothing in it can be recomputed from
chain data this deployment is able to see, which is the whole reason it exists.

And it makes the failure self-correcting rather than terminal. Error 3145 names
the offending credential, so POST /script-registration/known records it, and the
wizard now does that automatically and retries once: it extracts the credential
from the wallet's error (searching the stringified error, since wallets surface
submit failures as strings, Errors-wrapping-JSON or nested objects), records it,
and rebuilds without it. A per-credential guard means a genuinely different
failure cannot loop.

Two new tests cover the states that actually occur: ledger says registered while
the index is empty, and BOTH chain sources blind with the credential on record —
the retry that has to succeed after a 3145.

Full suite: 169 tests, same 66 pre-existing environmental failures, no
regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ial; explain dummy's policy collision

Two unrelated things, both surfaced while the previous fix was being exercised.

SECURITY — /script-registration/known was an unauthenticated write

V16 added that endpoint so a submit rejected with 3145 could tell the platform
what it had no other way to learn. It took a credential from the caller and
trusted it. Nothing in this service authenticates anything, and unlike the other
40 POST endpoints — which build unsigned CBOR that still needs a wallet signature
to have any effect — this one persists a fact consulted before every later build.

Marking an arbitrary credential registered makes the pre-registration step SKIP
it; the registration that follows then withdraws-0 from a reward account that
does not exist and is rejected with WithdrawalsNotInRewardsCERTS. Durable, and
invisible both on chain and in the logs — the same diagnostic dead end this
whole thread has been about.

The row must now exist before it can be confirmed. The platform inserts it with
registered = false when it builds a certificate for that credential, and the
endpoint may only flip an existing row to true, returning 409 otherwise. Callers
can confirm what this deployment was already attempting — the entire recovery
case — and cannot name a credential of their own choosing. Also: the ledger now
outranks the learned record rather than the reverse (the record only exists for
when the ledger cannot answer), knownCredential is validated against
^[0-9a-fA-F]{56}$, and stakeAddress must look like a reward address.

DUMMY — "Token policy … already registered" is correct, and now says why

Reported after the stake registration started working. It is not transient and
not fixable by choosing another asset name: dummy's issuer and transfer
validators take NO per-token parameters, so its issuance policy is a function of
the protocol deployment alone and every dummy token on a network resolves to the
same policy id. CIP-113 keys registry nodes by policy id, so the directory holds
exactly one dummy entry. (This is the same property that makes dummy's stake
credentials protocol-global — the root of the 3145 that preceded this.)

Supporting more than one would require parameterizing the dummy validators
per token, an upstream Aiken change that would move the existing token's policy
id. So the message now states the constraint and points at the two ways forward:
mint more of the existing token, or use a substandard whose issuance policy is
parameterised per token.

Full suite: 171 tests, same 66 pre-existing environmental failures, frontend
typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same failure as dummy's, one credential later:

  3145 — knownCredential 3f2f58e1c37e9039aadd85d71a867317f00bf171e060ad070a155dfc

That credential is registered on chain and absent from stake_registration, which
is the shape this whole thread has been about. But freeze-and-seize could not use
the recovery built for dummy, for two independent reasons.

First, it does not go through the pre-registration step at all. Without CIP-68 it
takes the SDK path, which builds init + registration client-side in
CombinedBuildSignSubmitStep — a component the earlier fix never touched. The
recovery now lives there too, and REBUILDS rather than re-submits, because the
certificates are baked into the init transaction.

Second, V17's evidence rule would have refused the confirmation. On that path the
backend never builds the transaction, so no row exists and
/script-registration/known would 409 — the caveat flagged when that rule landed,
now real. Its only involvement is answering /script-registration/check with "not
registered", which is precisely the platform telling the caller to register the
credential, so that answer now records the attempt.

That is defence in depth, not authorization, and the commit says so: no endpoint
here authenticates anyone, so a determined caller can always call /check first.
It raises poisoning from one unconstrained write to a credential the platform
actually advised registering. Authenticating the API remains the real fix.

extractKnownCredential moved to lib/utils/known-credential.ts now that both flows
need it; the regex is unchanged (the literal key plus exactly 56 hex characters,
searched against the stringified error, since wallets surface submit failures as
strings, Errors-wrapping-JSON or nested objects).

Full suite: 172 tests, same 66 pre-existing environmental failures, frontend
typecheck clean. The two remaining exhaustive-deps warnings in the FES step
pre-date this change; handleBuild is in the dependency array because the recovery
rebuilds through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Kammerlo
Kammerlo requested a review from nemo83 August 18, 2026 10:43
@Kammerlo
Kammerlo merged commit 4e4cee0 into main Aug 18, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant