Repository navigation
fix(burn): derive the burn's sender offset key from its commitment mask - #144
Merged
Merged
Conversation
Follow-up to #143. The burn output's sender offset key `r` came from the builder's reservation, which mints a random key that is never stored. The stealth claim key C and the ownership proof both depend on `r`, so the `burn_proofs` row was the only copy of a claimable proof: lose it before the claim is made, for example a reinstall or a seed restore, and the burn cannot be claimed. Derive `r` from the output's commitment mask, as the console wallet does, so the seed alone rebuilds `r`, C and the proof. The builder did not mint the key, so its script-offset contribution is registered by hand and the output is declared to the reservation as one that brings its own key. The reservation then only mints the change output's key, and refuses to build with none at all, so an L2 burn that consumes its inputs exactly cannot be built; the console wallet has the same limit. `claim_public_key` was already required (the function failed without one, after locking funds); now it fails before anything is built, and the None branches are gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
sdbondi
approved these changes
Sep 28, 2026
sdbondi
left a comment
Member
There was a problem hiding this comment.
Posted by Claude.
Approved — 0 blocking, 2 non-blocking, 0 nits · head 6e95bc6
Checked against tari_transaction_components 5.7.0-pre.8:
- The script offset balances.
reserve_sender_offset_keysonly mints keys for outputs wheretakes_reserved_keyis set, so the burn'sris left out of the reservation'spartial_offset. It is subtracted once, throughwith_host_derived_partial_script_offset(-r).add_recipientsubtracts nothing, and theSenderOffsetKeyPoolNotDrainedcheck inbuild()only looks atcaller_sender_offset_keys, which is empty here. So nothing is counted twice and nothing is missed. - The key is deterministic.
derive_burn_sender_offset_keyis a domain-separated hash of the mask, and the mask comes fromget_next_commitment_mask_and_script_key. The DH recovery key, the metadata signature andCall use that samer. - The pending declaration matches the output. It is measured from the built output's features, script and covenant, so
check_pending_outputs_match_declarationcannot trip on size. The first, P-basedoutput_featuresis now only used to size the output beforelock, and it is the same size as the final one. - The
claim_public_keychecks happen earlier. Both the missing-key check and the identity check now run beforeFundLocker::lock. Before this PR, a missing key only failed after funds were locked.
Non-blocking
- A no-change burn fails after its inputs are locked, and the error does not say why (
minotari/src/transactions/burn/mod.rs:256). Say the remainder is ≤change_output_fee. Thendecide_fee_and_changeplans no change output, andget_script_offset(_, 0)returnsUnblindedScriptOffset. By that pointFundLocker::lockhas already locked the UTXOs under the idempotency key. A retry with the same key replays the same selection and hits the same error until the lock expires. The PR description mentions the limit, but a user will only see an opaque key-manager error.locked_funds.requires_change_outputis available before anything is built. Checking it and returning a clear "this amount leaves no change; adjust the amount" error, ideally with the reservation released, would make this a recoverable case. - No test builds a whole burn transaction. The updated test checks that
ris deterministic and that the proof verifies againstC. Nothing runscreate_burn_tx→build()and then checks that the script offset balances with the host-derived-r, and that is the part of this change most likely to go wrong without anyone noticing. If it were wrong, the base node would reject the transaction and the funds would stay locked until expiry. A test with one funded input that validates the finalized transaction (or checksΣ script keys − Σ sender offset keys == script_offset) would guard it.
Review follow-ups on the host-derived sender offset: A burn that leaves no change output cannot be built, because the reservation then mints no sender offset key at all and the key manager refuses to blind the input script keys with none. That used to surface as an opaque UnblindedScriptOffset error after the inputs were locked, and a retry under the same idempotency key replayed the same selection until the lock expired. Check `requires_change_output` right after the lock, release the reservation, and say what to change. Add a test that funds an account with a real UTXO, runs `create_burn_tx` through `build()`, and checks that the inputs' script keys and the outputs' sender offset keys balance against the script offset with the host-derived `-r`, which is the part of this change a base node would otherwise be the first to notice. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Contributor
Author
|
Both non-blocking points addressed in the second commit:
Full 🤖 Generated with Claude Code |
SWvheerden
approved these changes
Sep 28, 2026
`cargo lints clippy` denies `cast_possible_wrap`; the value is a test constant, so a checked conversion documents that it fits. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #143, addressing the first non-blocking point in its review.
Problem
The burn output's sender offset key
rcame from the builder's reservation, which mints a random key that is never stored. The stealth claim keyCand the ownership proof both depend onr, so theburn_proofsrow was the only copy of a claimable proof. Lose it before the claim is made, for example a reinstall or a seed restore, and the burn cannot be claimed.Fix
Derive
rfrom the output's commitment mask withderive_burn_sender_offset_key, as the console wallet does, so the seed alone rebuildsr,Cand the proof.r, so its script-offset contribution is registered withwith_host_derived_partial_script_offset(-r), and the output is declared to the reservation withPendingOutput::custom_sender_offsetso it takes no key from the pool.get_script_offsetrefuses to build with no sender offset key at all. So an L2 burn that consumes its inputs exactly, leaving no change, cannot be built. The console wallet has the same limit.claim_public_keywas already required (the function failed without one, after locking funds). It now fails before anything is built, and theNonebranches are gone.Everything else from #143 is unchanged: the proof record keeps the raw
P, the encrypted data is DH toPwith the samerthat goes on chain asR.Test
The existing ownership-proof test now takes
rfromderive_burn_sender_offset_keyand checks that deriving it twice from the same mask gives the same key, alongside the validator-style verification overCand the negative check overP. Wholeminotaripackage suite passes; fmt and clippy clean with the CI flags.Still open from the #143 review
The second point stands: burns made before #143 cannot be re-signed by this wallet alone, because their
rwas random and not persisted. A recovery path needs the L2 wallet to computeCfromRandpand hand it back for the L1 mask to sign over. Not in this PR.🤖 Generated with Claude Code