Skip to content

fix(burn): derive the burn's sender offset key from its commitment mask - #144

Merged
brianp merged 3 commits into
mainfrom
fix/burn-seed-derived-sender-offset
Sep 28, 2026
Merged

brianp merged 3 commits into
mainfrom
fix/burn-seed-derived-sender-offset

Conversation

@brianp

@brianp brianp commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #143, addressing the first non-blocking point in its review.

Problem

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.

Fix

Derive r from the output's commitment mask with derive_burn_sender_offset_key, as the console wallet does, so the seed alone rebuilds r, C and the proof.

  • The builder did not mint r, so its script-offset contribution is registered with with_host_derived_partial_script_offset(-r), and the output is declared to the reservation with PendingOutput::custom_sender_offset so it takes no key from the pool.
  • The reservation then only mints the change output's key, and get_script_offset refuses 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.
  • The pending output is measured from the built output's own features, script and covenant, so the declaration cannot come out smaller than what arrives.
  • claim_public_key was already required (the function failed without one, after locking funds). It now fails before anything is built, and the None branches are gone.

Everything else from #143 is unchanged: the proof record keeps the raw P, the encrypted data is DH to P with the same r that goes on chain as R.

Test

The existing ownership-proof test now takes r from derive_burn_sender_offset_key and checks that deriving it twice from the same mask gives the same key, alongside the validator-style verification over C and the negative check over P. Whole minotari package 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 r was random and not persisted. A recovery path needs the L2 wallet to compute C from R and p and hand it back for the L1 mask to sign over. Not in this PR.

🤖 Generated with Claude Code

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 sdbondi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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_keys only mints keys for outputs where takes_reserved_key is set, so the burn's r is left out of the reservation's partial_offset. It is subtracted once, through with_host_derived_partial_script_offset(-r). add_recipient subtracts nothing, and the SenderOffsetKeyPoolNotDrained check in build() only looks at caller_sender_offset_keys, which is empty here. So nothing is counted twice and nothing is missed.
  • The key is deterministic. derive_burn_sender_offset_key is a domain-separated hash of the mask, and the mask comes from get_next_commitment_mask_and_script_key. The DH recovery key, the metadata signature and C all use that same r.
  • The pending declaration matches the output. It is measured from the built output's features, script and covenant, so check_pending_outputs_match_declaration cannot trip on size. The first, P-based output_features is now only used to size the output before lock, and it is the same size as the final one.
  • The claim_public_key checks happen earlier. Both the missing-key check and the identity check now run before FundLocker::lock. Before this PR, a missing key only failed after funds were locked.

Non-blocking

  1. 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. Then decide_fee_and_change plans no change output, and get_script_offset(_, 0) returns UnblindedScriptOffset. By that point FundLocker::lock has 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_output is 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.
  2. No test builds a whole burn transaction. The updated test checks that r is deterministic and that the proof verifies against C. Nothing runs create_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>
@brianp

brianp commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

Both non-blocking points addressed in the second commit:

  1. No-change burn. create_burn_tx now checks requires_change_output right after FundLocker::lock, releases the reservation via expire_and_unlock_pending_transaction (found by the idempotency key, which both callers pass), and returns "Burning N leaves no change output, and an L2 burn needs one; adjust the amount". Retries under the same key no longer replay into the same opaque error.
  2. Whole-burn test. a_built_burn_balances_its_script_offset_with_the_host_derived_sender_offset funds an account with a real UTXO (keys in the account's key manager via TestParams), runs create_burn_tx through build(), and asserts Σ input script keys − Σ output sender offset keys == script_offset·G, plus that the proof's R is the burn output's and that a change output exists.

Full minotari suite passes, fmt and clippy clean.

🤖 Generated with Claude Code

@sdbondi sdbondi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just the lints

`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>
@brianp
brianp merged commit 2e3c760 into main Sep 28, 2026
2 checks passed
@brianp
brianp deleted the fix/burn-seed-derived-sender-offset branch September 28, 2026 11:55
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.

3 participants