Skip to content

feat: reset Dust state and re-apply cNight UTxOs during migration - #2012

Draft
ozgb wants to merge 16 commits into
mainfrom
ozgb-cnight-mbm
Draft

feat: reset Dust state and re-apply cNight UTxOs during migration#2012
ozgb wants to merge 16 commits into
mainfrom
ozgb-cnight-mbm

Conversation

@ozgb

@ozgb ozgb commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Overview

To support the upcoming Ledger 9 hard fork - this PR resets Ledger Dust state and re-applies cNight UTxOs to the ledger as a multi-block migration.

Limitation

ctime for all cNight UTxOs will not match real ctime from Cardano

The creation time for cNight UTxOs when applied to the ledger state is usually set to match the creation time of the UTxO on Cardano. This PR is implemented to retrieve cNight UTxO data using a mixture of cnight pallet data and data from the ledger state pre-dust-wipe.

The creation time for night utxos is only available as a commitment in the ledger state - it is not publicly visible. This means that to get the real creation time of the UTxOs, we would have to query db-sync.

Instead, the replay stamps fork block time - dust.time_to_cap() (~1 week) as the creation time for every re-applied UTxO.

DUST accrues linearly from ctime to a cap of night_value * night_dust_ratio, reached after time_to_cap. Backdating by exactly that much means every holder is at their DUST cap the moment the replay lands — i.e. the pre-fork steady state, since anyone who had held cNIGHT for a week was already capped. Holders should see nothing change.

The alternatives, for the record:

ctime holder experience at the fork
fork block 0 DUST for everyone, refilling over ~1 week in proportion to holdings — large holders recover in minutes, small holders cannot pay a fee for days
fork - time_to_cap (this PR) at cap immediately; over-credits only cNIGHT locked in the last week, bounded by a cap it would have reached anyway
real, from db-sync also at cap for anything older than a week; needs a new consensus-critical db-sync query + inherent running during the fork
  • Q: Should we query db-sync and get the real creation times? No. The pallet stores only hash("asset_create" ‖ tx_hash ‖ index), never the UTxO id, so db-sync cannot be queried by nonce — the node would have to snapshot the whole live cNIGHT set (a new unbounded db-sync query), match by hash, page it in through a new inherent, and have every validator reproduce it exactly in check_inherent. That is ~600-900 lines of consensus-critical code that runs once, during the fork, and cannot be fixed in flight. It also isn't more "correct": real ctime restores holders to as-if-never-spent, which over-credits recent spenders just the same.
    • Chosen solution: set all existing cNight UTXOs to their Dust Generation cap - this means that after the fork, all registered cNight holders will have max Dust up to their cap. Need to notify downstream wallets

👀 Watching the migration (QA — read this before hunting for events)

In Polkadot.js Apps, DustReapplyStarted is the only replay event you will see. That does not mean the replay stalled.

It is emitted from on_runtime_upgrade, so its phase is Initialization. Everything else the replay produces — DustReapplyCompleted / DustReapplySkipped / DustReapplyBatchFailed, the replay's SystemTransactionApplied, and pallet_migrations' own MigrationAdvanced / MigrationCompleted / UpgradeCompleted — is deposited from a multi-block-migration step, which FRAME runs in inherents_applied() after the block's last extrinsic. Their phase is ApplyExtrinsic(n) where n equals the block's extrinsic count — an index no extrinsic in that block has. PJS renders events grouped under the extrinsic that claims them, so it drops these entirely. They are in System::Events regardless.

Worked example from a local hardfork_e2e run (new code applied at #34):

block event phase in PJS
35 CNightObservation.DustReapplyStarted Initialization
35 MultiBlockMigrations.UpgradeStarted Initialization
36 MidnightSystem.SystemTransactionApplied (52 dust Creates) ApplyExtrinsic(3) — block has extrinsics 0–2
37 CNightObservation.DustReapplyCompleted { applied: 52, skipped: 13 } ApplyExtrinsic(3)
37 MultiBlockMigrations.MigrationCompleted, UpgradeCompleted ApplyExtrinsic(3)

Raw System::Events at that block 37 contains 0d 07 34000000 0d000000 — pallet 13 (CNightObservation) · variant 7 (DustReapplyCompleted) · applied = 0x34 = 52 · skipped = 0x0d = 13.

How to confirm it actually ran

  • Node log — every one of these events is logged under its own name, so grepping the log for the event name finds it whether or not an explorer shows it:
    DustReapplyStarted: recorded pre-fork ledger state key for the dust generation replay
    ObservationsSkippedForMigration: skipping process_tokens (on-chain storage version StorageVersion(1) < StorageVersion(2)); MBM in progress
    DustReapplyCompleted: dust generation replay complete, 52 applied, 13 skipped
    
  • Events, keyed by event rather than by extrinsicstate_getStorage on System::Events at the block hash, or subxt's events(). Anything that walks extrinsics and collects their events will miss these.
  • Storage versionCNightObservation reaches 2 once the replay winds up, and PreForkStateKey is cleared. The version key is twox128("CNightObservation") ++ twox128(":__STORAGE_VERSION__:") — note the trailing colon; without it you get None at every block.
  • ObservationsSkippedForMigration (new here) — emitted from the process_tokens inherent every block the migration gate holds, so this one is visible in PJS and brackets the window in which Cardano observations are being ignored.

hardfork_e2e now asserts the outcome rather than just the wind-up: it decodes the DustReapply* events over the fork window, prints each one, and requires a DustReapplyCompleted with applied > 0.

🗹 TODO before merging

  • Update Ledger to rc.4 (includes Dust fix + state translation with reset)
  • Ready

📌 Submission Checklist

  • All commits are signed off (git commit -s) for the DCO
  • Changes are backward-compatible (or flagged if breaking)
  • Pull request description explains why the change is needed
  • Self-reviewed the diff
  • I have included a change file, or skipped for this reason:
  • If the changes introduce a new feature, I have bumped the node minor version
  • Update documentation (if relevant)
  • Updated AGENTS.md if build commands, architecture, or workflows changed
  • No new todos introduced

🧪 Testing Evidence

Please describe any additional testing aside from CI:

  • Additional tests are provided (if possible)

🔱 Fork Strategy

  • Node Runtime Update
  • Node Client Update
  • Other:
  • N/A

Links

ozgb added 4 commits August 7, 2026 13:57
…->9 wipe

A forthcoming ledger update makes the ledger 8 -> 9 hardfork wipe dust state,
which would silently stop DUST generation for every cNIGHT holder. Rebuild
cnight's slice of the ledger's dust generating set as a multi-block migration
(pallet storage version 1 -> 2):

- `RecordPreForkState` (single-block, ordered before the pallet-midnight
  translation) saves the still-untranslated ledger-8 arena root, the only place
  the wiped entries' night value and dust owner survive.
- `MigrateV1ToV2` pages through `UtxoOwners` — the provenance-and-liveness
  filter for which nonces are cnight's and still live — reads each nonce's
  pre-wipe `(value, owner)` through the new `dust_generation_values_v8` host
  function, and re-applies them as `CNightGeneratesDustUpdate` system
  transactions, 200 per block.
- Observations are ignored while it runs: `process_tokens`' existing storage
  version gate covers the new migration for free, leaving `NextCardanoPosition`
  untouched so the observer re-delivers everything afterwards.

Restored entries are field-for-field the wiped ones. Only the accrual clock
resets: the replay stamps the fork block's time, because the original ctime
lives on the dust UTXO the wipe takes, and replaying an older one would
re-credit accrual holders have already claimed and spent.

Only cnight's slice is restored. Native NIGHT registers generation entries too
and nothing here records which of those the wipe took, so `DustReapplyCompleted`
must not be read as "all dust generation restored".

Inert until the wiping ledger ships: today's translation table carries dust
across, so the first replayed `Create` collides with
`GenerationInfoAlreadyPresent` and the migration self-cancels with
`DustReapplySkipped`. It activates on its own when that ledger change lands.

Pacing is explicit rather than metered. `process_tokens`' benchmark observes
registration UTXOs, which never reach the ledger, so its ~15ms for 200 UTXOs
says nothing about 200 dust `Create`s; against `MbmServiceWeight` the meter
would service ~100 batches — 20k ledger creates — in a single block. Each step
charges half a block instead, so exactly one batch lands per block.

Live `UtxoOwners` sizes measured on 2026-08-06 (`state_getKeysPaged` over the
storage prefix): mainnet 4870 (finalized #2019697), preview 1524 (#301994),
preprod 85 (#1985972). At 200 per block that is ~25 blocks (~2.5 min) of gated
observation on mainnet, ~8 on preview, 1 on preprod.

Tested against a real ledger: the pallet tests seed a ledger-8 dust state in the
same arena the mock's ledger-9 state uses, so the happy path asserts the
ledger's own value and owner and the fork ctime, and the self-cancel genuinely
fails with `GenerationInfoAlreadyPresent`.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
The ledger 8 -> 9 hardfork is specified to wipe dust state; the translation
table was still carrying it across, which left the cNIGHT dust re-apply
migration self-cancelling on `GenerationInfoAlreadyPresent`.

Drop the v8 dust state and install the empty one genesis starts from, and
mirror it in the toolkit: `fork_context_8_to_9` now resets every wallet's
local dust state (keys kept) so it does not build transactions spending dust
the chain no longer has.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
The fork wipes dust state and the `dev` preset has no `UtxoOwners` for the
cNIGHT replay to restore, so the genesis wallets cross it holding NIGHT but
generating no DUST — leaving the post-fork `single-tx` with no way to pay a
fee.

Register the source wallet's dust address again before it, self-funded from
the retroactive DUST its now-generationless NIGHT accrued (the path a real
holder takes after the wipe), and let a couple of blocks accrue before
spending.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
@datadog-official

This comment has been minimized.

ozgb added 2 commits August 11, 2026 12:56
The dust replay stamped the fork block's own time as `ctime`, which starts
every cNIGHT holder at zero DUST and refills over ~1 week in proportion to
holdings: large holders recover in minutes, small holders cannot pay a fee for
days. That drought never existed pre-fork.

Stamp `fork block time - dust.time_to_cap()` instead. DUST accrues linearly
from `ctime` to a cap of `night_value * night_dust_ratio` reached after
`time_to_cap`, so backdating by exactly that much lands every restored entry at
its cap — the pre-fork steady state, since anyone holding cNIGHT for a week was
already capped.

`dust_generation_values_v8` now also serves `time_to_cap` from the same v8
state it already loads (the 8 -> 9 translation recasts `parameters.dust`
unchanged), so no second host call or arena load.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
…play docs

The original `ctime` is not publicly visible in ledger state — it is stored as
a commitment only, which is why the replay has to pick one.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
@ozgb
ozgb marked this pull request as ready for review August 11, 2026 12:07
@ozgb
ozgb requested a review from a team as a code owner August 11, 2026 12:07
@ozgb
ozgb marked this pull request as draft August 11, 2026 12:07

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 93ceb4766d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +331 to +333
#[pallet::storage]
#[pallet::unbounded]
pub type PreForkStateKey<T: Config> = StorageValue<_, Vec<u8>, OptionQuery>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Regenerate runtime metadata for new pallet storage

This adds new pallet storage items, but the f027c98c diff does not update any checked-in metadata/static/midnight_metadata*.scale artifact. Consumers and tests that rely on the repository metadata will still see the old cnight-observation storage layout/events, so please run the metadata rebuild and commit the regenerated metadata with this change.

AGENTS.md reference: AGENTS.md:L146-L149

Useful? React with 👍 / 👎.

ozgb added a commit that referenced this pull request Aug 12, 2026
The change file came across from the still-open #2012, which left the
`PR:` field as a placeholder.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
ozgb added a commit that referenced this pull request Aug 13, 2026
Reverts a5310e4 ("feat: reset Dust state and re-apply cNight UTxOs
during migration (#2012)") and its follow-up 5b6bdc1, backing the
cNIGHT dust generation replay out of the ledger-hardfork backport.
PR #2012 is still open on main, so it lands there rather than here.

Note: `metadata/static/midnight_metadata*.scale` were regenerated in
2ff2b44 while the reverted storage items (`PreForkStateKey`,
`DustReapplyCtime`, `DustReapplyProgress`) were present, so metadata
needs a rebuild.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
ozgb added 5 commits August 13, 2026 12:35
The cNIGHT dust generation replay applies its batches from a multi-block
migration step, which `frame_executive` runs in `inherents_applied()` — after
the block's inherents and outside any extrinsic. The resulting
`SystemTransactionApplied` event therefore carries an `ApplyExtrinsic` phase
index one past the block's last extrinsic (only inherents are admitted while an
MBM is in flight), and no extrinsic claims it.

The fetcher scanned events from inside its loop over extrinsics, keeping only
those whose phase matched the extrinsic it was on, so every replayed batch was
dropped from `RawBlockData` and a post-fork replay would fail state root
verification.

Collect them in a single pass keyed on the event's own phase instead, and sort
the block's transactions into execution order. The sort is stable and events are
pushed after calls, so a system transaction still lands behind the extrinsic
that triggered it. As a side effect, an extrinsic whose call data fails to
decode no longer takes its system transaction events down with it. The indexer
already collected these events unconditionally.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
`NODE_BINARY` points the test at a locally built node instead of the
`midnight-node` docker image, which skips the image build while iterating.
The runtime WASM to upgrade to then comes from next to the binary, with
`RUNTIME_WASM` to override where to look.

The node takes its run arguments from the cfg rather than from argv, so the
ports go through `APPEND_ARGS`; the RPC port is an ephemeral one so a local
run cannot collide with a node the developer already has running.

The fork-from chain-spec still comes from a docker image - that runtime is a
past release.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Step 5b checked that the cNIGHT dust replay *wound up* — storage version 2,
`PreForkStateKey` cleared — which is equally true of a self-cancel or a replay
that restored nothing. It also could not pass at all: FRAME's storage-version
key postfix is `:__STORAGE_VERSION__:` with a trailing colon, and the test
omitted it, so `state_getStorage` returned `None` at every block.

Fix the key and add the assertion that was missing: decode the pallet's
`DustReapply*` events over the fork window, print each one, and require a
`DustReapplyCompleted` carrying `applied > 0`. Decoding rather than reading
storage because the payload is the point, and it is safe here even though
`spec_version_at` deliberately avoids subxt's metadata — every block in this
window already runs the new runtime.

The comment claiming the `dev` preset carries no `UtxoOwners`, and so that this
step exercises only the arming and wind-up, was wrong. `res/dev/cnight-config.json`
has none at genesis, but the fork-from chain-spec ships 65 with matching ledger-8
dust entries; the replay restores 52 of them and skips 13 already-destroyed
nonces. Correct that, and the related claim in 5c — the genesis wallets need
re-registering because they hold *native* NIGHT, which this replay does not
restore, not because there is nothing to restore.

Also resolve `NODE_BINARY` against the repo root in both places that use it.
`start_node` spawns the node with `current_dir(repo root)`, so a relative binary
path resolved there, while `runtime_wasm` resolved the same path against the
test's own CWD (the toolkit crate) and failed to find the runtime WASM beside it.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
…vents

Everything the replay does after the upgrade block is invisible in a block
explorer, which makes a working migration look like a stalled one. MBM steps run
in `inherents_applied()`, after the block's last extrinsic, so every event they
deposit — this pallet's `DustReapply*`, the replay's `SystemTransactionApplied`,
and `pallet_migrations`' own `MigrationAdvanced`/`MigrationCompleted`/
`UpgradeCompleted` — carries phase `ApplyExtrinsic(n)` with `n` equal to the
block's extrinsic count, an index no extrinsic in that block claims. Polkadot.js
Apps renders events grouped under the extrinsic that claims them and drops these.
`DustReapplyStarted` is the one exception and hence the only one you see: it
comes from `on_runtime_upgrade`, phase `Initialization`.

Nothing here changes that — it is how FRAME emits everything from an MBM step —
so make the replay legible instead:

- new `ObservationsSkippedForMigration`, emitted once per block for as long as
  the storage-version gate ignores Cardano observations. It comes from the
  `process_tokens` inherent, so unlike the `DustReapply*` events it is visible in
  an explorer, and it brackets the window — up to ~25 blocks on mainnet — in
  which observations are being dropped. Until now that window was a `log::warn!`
  and nothing else.
- every event the replay deposits is named in its own log line, so grepping the
  node log for the event name finds it whether or not an explorer shows it. That
  includes `DustReapplySkipped` and `DustReapplyBatchFailed`, which previously
  logged only a reason and no event name.
- the phase behaviour is documented where people will look for it: on the events
  themselves, on `apply_batch`, and in the change file, which gains a "watching
  it happen" section for QA.

Note this adds a runtime event, so the checked-in metadata needs rebuilding.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
@ozgb ozgb added the bot:ai-assisted Authored or substantially edited by an AI agent label Aug 14, 2026
ozgb added 5 commits August 14, 2026 10:03
Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
…to the event enum

Inserting it before `DustReapplyStarted` shifted every `DustReapply*` variant
index by one, which the change file and the migration docs quote when explaining
how to read these events straight out of `System::Events` (the only way to see
most of them). Append instead, so those stay put.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
`dust_generation_values_v8` returned `(u64, Vec<Option<(u128, Vec<u8>)>>)`,
which tripped clippy's `type_complexity` and left the call site destructuring
an anonymous pair. Replace it with `DustGenerationValues { time_to_cap,
entries }` over `DustGenerationEntry { value, owner }` in the shared
`common::types`, so the pallet reads `entry.value` / `entry.owner` and the
field docs carry what the tuple positions used to explain.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:ai-assisted Authored or substantially edited by an AI agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant