feat: reset Dust state and re-apply cNight UTxOs during migration - #2012
feat: reset Dust state and re-apply cNight UTxOs during migration#2012ozgb wants to merge 16 commits into
Conversation
…->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>
This comment has been minimized.
This comment has been minimized.
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>
There was a problem hiding this comment.
💡 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".
| #[pallet::storage] | ||
| #[pallet::unbounded] | ||
| pub type PreForkStateKey<T: Config> = StorageValue<_, Vec<u8>, OptionQuery>; |
There was a problem hiding this comment.
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 👍 / 👎.
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>
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>
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>
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>
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
ctimefor all cNight UTxOs will not match realctimefrom CardanoThe 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
ctimeto a cap ofnight_value * night_dust_ratio, reached aftertime_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:
ctimefork - time_to_cap(this PR)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 incheck_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": realctimerestores holders to as-if-never-spent, which over-credits recent spenders just the same.👀 Watching the migration (QA — read this before hunting for events)
In Polkadot.js Apps,
DustReapplyStartedis the only replay event you will see. That does not mean the replay stalled.It is emitted from
on_runtime_upgrade, so its phase isInitialization. Everything else the replay produces —DustReapplyCompleted/DustReapplySkipped/DustReapplyBatchFailed, the replay'sSystemTransactionApplied, andpallet_migrations' ownMigrationAdvanced/MigrationCompleted/UpgradeCompleted— is deposited from a multi-block-migration step, which FRAME runs ininherents_applied()after the block's last extrinsic. Their phase isApplyExtrinsic(n)wherenequals 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 inSystem::Eventsregardless.Worked example from a local
hardfork_e2erun (new code applied at #34):CNightObservation.DustReapplyStartedInitializationMultiBlockMigrations.UpgradeStartedInitializationMidnightSystem.SystemTransactionApplied(52 dustCreates)ApplyExtrinsic(3)— block has extrinsics 0–2CNightObservation.DustReapplyCompleted { applied: 52, skipped: 13 }ApplyExtrinsic(3)MultiBlockMigrations.MigrationCompleted,UpgradeCompletedApplyExtrinsic(3)Raw
System::Eventsat that block 37 contains0d 07 34000000 0d000000— pallet13(CNightObservation) · variant7(DustReapplyCompleted) ·applied = 0x34 = 52·skipped = 0x0d = 13.How to confirm it actually ran
state_getStorageonSystem::Eventsat the block hash, or subxt'sevents(). Anything that walks extrinsics and collects their events will miss these.CNightObservationreaches2once the replay winds up, andPreForkStateKeyis cleared. The version key istwox128("CNightObservation") ++ twox128(":__STORAGE_VERSION__:")— note the trailing colon; without it you getNoneat every block.ObservationsSkippedForMigration(new here) — emitted from theprocess_tokensinherent 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_e2enow asserts the outcome rather than just the wind-up: it decodes theDustReapply*events over the fork window, prints each one, and requires aDustReapplyCompletedwithapplied > 0.🗹 TODO before merging
📌 Submission Checklist
git commit -s) for the DCO🧪 Testing Evidence
Please describe any additional testing aside from CI:
🔱 Fork Strategy
Links