Skip to content

fix(burn): sign the ownership proof over the stealth claim key - #143

Merged
SWvheerden merged 1 commit into
mainfrom
fix/burn-stealth-claim-key
Sep 28, 2026
Merged

SWvheerden merged 1 commit into
mainfrom
fix/burn-stealth-claim-key

Conversation

@brianp

@brianp brianp commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Problem

create_burn_tx put the recipient's L2 account key P straight into the burn output features and signed the ownership proof over it. The current L2 (tari-ootle v0.41.2, both tari_walletd and the validator's KnowledgeProofVerifier) binds that proof to the signer of the claim transaction, which is the stealth key C = H(r·P)·G + P. Every burn made this way is rejected with ownership proof validation failed, and since a burn cannot be undone the funds are gone.

Found by burning 1000 XTM from Universe (which uses this crate) on esmeralda and trying to auto-claim it with a v0.41.2 wallet daemon.

Fix

Derive C from the output's sender offset key and P, the same way the minotari console wallet does, and use it for both the output features and the ownership proof.

  • The proof record keeps the raw P. That is how the L2 wallet finds the account to claim into.
  • The encrypted data is still DH-encrypted to P, which is what the L2 decrypts with.
  • Output size is unchanged, so the fee reservation measured before locking still covers it.

Test

the_ownership_proof_binds_the_stealth_claim_key_not_the_recipient_key verifies the proof the way a validator does (mask key over commitment || claimant || sidechain_id) and checks that the same signature does not verify over the raw P, which is the old behaviour.

Not in this PR

Burns already made with the old format have a proof that no L2 will accept. Their mask keys are still in the wallet, so a follow-up could re-sign existing burn_proofs rows over C.

🤖 Generated with Claude Code

`create_burn_tx` put the recipient's L2 account key P straight into the
burn output features and signed the ownership proof over it. The L2
(tari-ootle v0.41.2, both walletd and the validator's
KnowledgeProofVerifier) binds that proof to the signer of the claim
transaction, which is the stealth key C = H(r·P)·G + P, so every burn
made this way is rejected with "ownership proof validation failed" and
the funds are unrecoverable.

Derive C from the output's sender offset key and P, exactly as the
minotari console wallet does, and use it for both the output features
and the ownership proof. The proof record keeps the raw P, which is how
the L2 wallet finds the account to claim into; the encrypted data is
still DH-encrypted to P, which is what the L2 decrypts with.

The new test verifies the proof the way a validator does and checks
that the same signature does not verify over the raw P.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@brianp
brianp requested a review from sdbondi September 24, 2026 16:11

@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 with reservations — 0 blocking, 2 non-blocking, 0 nits · head f92cf74

Fix is correct. Checked against tari_transaction_components 5.7.0-pre.8 and the console wallet's burn_tari on development:

  • compute_stealth_claim_public_key(r, P) gives C = H(r·P)·G + P, and generate_burn_claim_signature signs commitment || C || sidechain_id with the mask. That matches service.rs on development.
  • C goes into the output features, so the sidechain-id signature in create_burn_confidential_output is also over C, as in the console wallet. The features are the same size as the ones measured for the reservation (burn/mod.rs:161, :199).
  • burn_proofs.claim_public_key still stores P, and burn_proof_worker::assemble_complete_proof exports it as burn_public_key next to R. That matches PartialBurnClaimProof { claim_public_key } in the console wallet, and the L2 can recompute C from R and p.
  • The encrypted data is still DH(P, r) using the same r that goes on chain as R.
  • The new test and the other burn tests pass locally, and CI is green.

Non-blocking

  1. r is random here, but the console wallet derives it from the seed (burn/mod.rs:205-209). The console wallet uses derive_burn_sender_offset_key(&commitment_mask_key.key_id) for L2-bound burns so the proof "can be rebuilt from seed alone after recovery". This PR takes r from reserve_sender_offset_keys, which calls get_random_key (key_manager/manager.rs:1287), and the secret is never stored. The C-bound proof in burn_proofs is therefore the only copy. If that row is lost, for example after a Universe reinstall or restore before the proof worker completes, this wallet cannot rebuild the proof and the burn cannot be claimed. Before this PR the proof only needed the seed-derived mask and P. Matching the console wallet would use PendingOutput::custom_sender_offset plus with_host_derived_partial_script_offset(-r), which needs a change output. That is fine as a follow-up, but "the same way the minotari console wallet does" in the description holds for C, not for r.

  2. The follow-up in the description won't work as written. It says the mask keys are still available, so old burn_proofs rows could be re-signed over C. Computing C also needs r·P. Old burns used the same random r, and it wasn't persisted either, so this wallet can't compute C for them. A recovery path would have to get C from whoever holds p (the L2 wallet computes H(p·R)·G + P) and have the L1 mask sign over it.

@SWvheerden
SWvheerden merged commit 2a72085 into main Sep 28, 2026
2 checks passed
@brianp
brianp deleted the fix/burn-stealth-claim-key branch September 28, 2026 09:21
brianp added a commit to tari-project/universe that referenced this pull request Sep 28, 2026
## What it does

Adds a Layer 2 (Ootle) wallet to Universe. A fourth button on the mini
rail opens a card for it. The card shows the default L2 account's
balance (revealed and confidential), copy address, Receive with a QR
code, History, Send XTR to an Ootle address, Burn to L2 (moved here from
the L1 actions row, with the claim key prefilled from this wallet's own
L2 account), and a list of burns waiting to be claimed with a Claim
button. The L2 seed comes from the L1 seed words when you press Enable
Layer 2, so by default there's nothing new to back up.

The rail shows one wallet card at a time. Opening the L2 card closes the
L1 wallet sidebar and the other way round. An "Allow wallets to be side
by side" toggle in settings turns that off, and then the L2 card sits to
the right of the L1 sidebar.

The L2 store is encrypted with a per-install keyring secret plus your
PIN. After a restart L2 is locked, and the card and the settings tab
show an Unlock Layer 2 button that asks for the PIN, opens the store and
starts the L2 services.

Settings gets an Ootle tab, shown only on Esmeralda. From top to bottom:

- Status. Without a PIN it shows the same "set a PIN" prompt as the L2
card. With a PIN but L2 off, the same Enable Layer 2 button. While
locked, Unlock Layer 2.
- The side by side toggle.
- Default account. The Ootle address and the owner public key, each with
a label and a copy button.
- Indexer. The configured indexer URL, read only.
- Seed words. A note says whether L2 runs on your Tari wallet seed or on
words you imported for L2 only. Then the same show, copy and edit
control as the L1 seed words, with the same 5 minute auto hide. Editing
imports different words for L2 only, the L1 wallet isn't touched. After
an import, a "Use my Tari wallet seed" button puts L2 back on the L1
seed. Both the import and the reset ask for confirmation first ("your
current Layer 2 wallet will be replaced, you can only get it back with
its seed words") and then for the PIN, once.

## How it's built

- The Ootle wallet runs in-process from the tari-ootle wallet crates
(`tari_ootle_wallet_sdk`, `tari_ootle_wallet_sdk_services`,
`tari_ootle_wallet_storage_sqlite`, `ootle-rs`). No walletd sidecar, no
socket, no JSON-RPC, no tapplets. `OotleWalletManager` in
`src-tauri/src/ootle` owns it. The wallet phase only looks for the store
and notes whether L2 was enabled in it. The store opens when you enable
or unlock it with the PIN.
- A PIN is required for everything L2. Without one the backend refuses
every L2 command with `NoPinConfigured` and the card only shows a "set a
PIN" prompt that hands off to the existing create-PIN flow.
- Esmeralda only, enforced in the backend and the UI.
`network_supports_burn` became `network_supports_l2`. The indexer URL is
a core config entry with an Esmeralda default.
- L2 sends and claims go through the same `send_gate::pass_gates` as L1
sends and burns (new `SpendKind::L2Send` and `L2Claim`), so they get the
same one-at-a-time permit and confirmation payload. The PIN for an L2
send or claim is asked after the dry run has priced it, so the prompt
shows the amount and the fee.
- State reaches the frontend through events into a new
`useL2WalletStore`, no polling. Headless replays it. `L2WalletState`
carries `locked` and `seed_source: "l1" | "imported"`.
- The Ootle store records the L1 wallet id it was created for in an
owner file, `l1-wallet-id`. If the L1 wallet is imported or replaced,
the store is dropped on the next open and L2 goes back to "not enabled".
An imported L2 seed writes the literal `imported` there instead, and
`claim_store_for` leaves an `imported` store alone, so an L1 seed import
doesn't throw away an L2 wallet that never came from the L1 seed.
- Seed words commands, all PIN gated and refused off Esmeralda or
without a PIN through `check_l2_allowed`: `l2_get_seed_words` reads the
words with the SDK's `load_seed_words`; `l2_import_seed_words` parses
the words with the SDK mnemonic parser before it touches anything, then
swaps the store; `l2_use_l1_seed` does the same swap with the L1 seed
and writes the L1 wallet id back as owner.
- The SDK only restores into a store with no seed, so the swap shuts the
wallet phase down, drops the SDK, builds the new store next to the old
one and resumes the phase. The swap then opens the new store with the
PIN it already has, so there's no second prompt. The phase resumes even
if the restore fails, so the L1 wallet always comes back.
- The settings tab reuses `SeedWords.tsx` with an `isL2` prop (smaller
than copying it). One side effect on L1: the shared seed words copy
button is now disabled while an L1 import runs, same as the show and
edit buttons already were.

## Review fixes

A first review of this PR found six problems. Each one is fixed here.

1. Refresh wallet history deleted burn proofs. Proof files lived in
`minotari-wallet/<network>/burn_proofs`, which refresh and L1 seed
import delete. They now live in `<app local
data>/burn_proofs/<network>`, and an existing folder is moved there on
first use. A burn that's broadcast but not mined only exists as a row in
`wallet.db`, which can't be moved, so refresh and seed import refuse
while one is waiting ("1 burn to Layer 2 is still waiting to be mined.
Try again once it's mined."). A rejected burn doesn't count, so it can't
block them forever.
2. The L2 store kept a copy of the L1 seed that only the per-install
keyring password protected. The store is now encrypted with the PIN
(round two below adds the keyring secret back in front of it). That's
why L2 starts locked and has an Unlock button. A store an older build
encrypted with the keyring password is rebuilt under the PIN from its
own words on the first unlock, keeping its owner file. After a forgotten
PIN reset the store is rebuilt under the new PIN, or deleted when it
held imported words nobody can open any more.
3. Two PIN prompts at once could both take the one PIN the user typed.
Only one prompt is outstanding at a time now. A second one waits until
the first is answered.
4. A failed seed swap deleted the old store first. The swap now renames
the old store aside, builds the new one and deletes the old one only
once the owner file is written. On an error it puts the old one back. A
swap cut short by a crash is settled on the next start.
5. A claim that finished while Universe was closed stayed claimable.
Submitted claims are saved to `pending_claims.json` next to the proofs
and checked against the wallet's own record of them on start and after
missed events. Accepted ones are marked claimed, rejected or unknown
ones stay claimable.
6. A send the network rejected outright showed "Sent". After submitting,
a send or claim now reads the stored transaction and fails with the
rejection reason when it was rejected or invalid, so the modal shows the
error and a rejected claim stays claimable.

A second round of reviews found more. Fixed here:

- C1 fee ceiling. The fee used to be whatever the indexer's dry run
said. It's now capped at 0.1 XTR for sends and claims, and a claim may
also pay at most half the burn. Above that the send or claim is refused
with the fee in the error. The PIN prompt shows the fee. Every indexer
call made while the send gate permit is held (epoch, dry runs, the
transfer build) times out after 30 seconds, so a stalled indexer can't
wedge every spend behind it.
- C2 store password. The password is now the per-install keyring secret
followed by the PIN, so neither a copy of the store file nor the keyring
alone opens it. A store from an older build, keyring secret only or PIN
only, is rebuilt under the new password from its own words on the first
unlock, keeping its owner file.
- C3 and C9 submit outcome. A transport error on submit no longer
reports a failure or frees the inputs, since the wallet keeps the
transaction and resubmits it. Only an explicit rejection fails. Every L2
send is now tracked in `pending_claims.json` the same way claims are (a
`null` entry), and when it finalizes or turns out invalid a toast says
so. The send modal's done state reads "Submitted, waiting for the
network".
- C4 burn broadcast. A transport error while broadcasting a burn leaves
it pending, since it may still be mined. Only an explicit node rejection
marks it rejected. Rejected burns leave the pending list in the panel
too.
- C5 and C6 PIN prompts. Unlock, seed words reveal, seed import and
reset to the L1 seed each show their own purpose line. Every prompt
carries an id and the backend ignores an answer with the wrong id, so a
late or duplicate answer can't satisfy the next prompt. A prompt nobody
answers closes itself after 5 minutes and returns "PIN entry cancelled",
which frees the next one.
- C8 lifecycle lock. One lock covers finding, opening, swapping and
rebuilding the store, so a wallet phase restart can't interleave with an
unlock or a swap. No PIN prompt runs under it, and a swap lets it go
before it waits for the restarted phase.
- C11 test literals. The test PIN literal in `ootle/mod.rs` is gone.
GitGuardian still flags it in an older commit of this branch
(42afffd), which only a history rewrite or a maintainer's skip clears.
- C12 burn guard. The pending burn check fails closed when it can't read
`wallet.db`, and refresh and seed import run it after the wallet phase
is down, so a burn racing the refresh is seen.
- C14 forgot PIN. When the L2 store holds imported words, the forgot PIN
screen warns first that the L2 wallet will be deleted.

A third round of review (four reviewers: backend, security, frontend,
dependencies) found no high-severity issue and eight medium ones. Fixed
here, head a27f7e1:

- M1 The final L2 submit ran under the send gate permit with no timeout,
unlike every other indexer call in the spend path. It now uses the same
30 second limit; a timeout is a transport error, so the transaction
stays pending and the input lock is kept.
- M2 Forgot-PIN recovery returned success when the wallet phase had not
yet found the L2 store, leaving the store under the old PIN. It now
works on the store on disk.
- M3 An L1-sourced store that no password opened (lost keyring entry)
had no way out. Enable and Unlock now rebuild it from the L1 seed,
keeping the owner file. Imported stores still refuse.
- M4 (code part) The default indexer URL is resolved at read time
instead of being written into the config file, and only http or https
URLs are accepted. TLS in front of the indexer is an infrastructure
change and stays open.
- M5 The L2 send modal stayed mounted, so closing it while a send was in
flight left a stale "Submitted" screen for the next send. It is mounted
only while open, like the burn modal.
- M6 PIN cancel on L1 send, L2 send and burn set a form error nothing
rendered. A shared `FormRootError` now shows it in all three.
- Lows: the PIN-only legacy store password path is gone (offline brute
force surface, no shipped build wrote such a store); a burn with a claim
already waiting cannot be claimed twice; `pending_claims.json` is
written to a temp file and renamed; the store swap holds the setup
manager's restart lock so a node-type restart cannot interleave; the
node-corruption restart keeps the wallet folder while a burn is waiting
to be mined; the PIN travels as a string so leading zeros survive
(pre-existing on main); Enable and Unlock no longer show "PIN entry
cancelled" as an error; the claim wait time is translated; side by side
off only closes the card once the setting saved; the L2 history filter
has its own test id; a late burn list cannot overwrite a newer one; the
unlock prompt's second sentence sits on its own line.

Still open as follow-ups outside this PR: TLS on the Esmeralda indexer,
`cargo audit` or `cargo deny` in CI (the audit workflow is npm only), a
tag for the tari-ootle backport branch so the pin is not on a branch,
and a feature gate for `webauthn-rs` in the Ootle SDK (pulls OpenSSL and
an x509 stack onto every target).

## Not in this PR

Auto-claim, confidential or stealth output choices when sending, NFTs,
tapplets, and locales other than English (they fall back).

## Dependencies

tari-ootle is pinned at git rev `6b0a9d745` (branch
`chore/libsqlite3-sys-0.35-v0.41.2`), which is tag v0.41.2 plus the
libsqlite3-sys range fix from tari-project/tari-ootle#2696. v0.41.2 is
what the Esmeralda indexer runs. tari-ootle pins `libsqlite3-sys` at
exactly 0.30.1, while Universe already needs 0.35 through minotari-cli's
`rusqlite` 0.37, and Cargo allows only one crate that links `sqlite3` in
a build. I'll re-pin to a merged rev once that fix lands upstream.

minotari-cli is pinned at `f92cf74` from tari-project/minotari-cli#143.
It signs the burn ownership proof over the stealth claim key, which is
what Ootle's claim expects. A burn from that build was claimed end to
end on Esmeralda.

## Relationship to #3372

This branch includes the burn to L2 work from #3372 (burn, the isolation
allowlist fix, and the PIN requirement on burns). Merge #3372 first, or
merge this and close #3372 as superseded.

## Verified

From `src-tauri` on head a27f7e1: `cargo fmt --check`, `cargo lints
clippy --all-targets --all-features`, `cargo test ootle` (28), `cargo
test minotari_wallet` (12), `cargo test send_gate` (26), `cargo test
pin` (9). From the root: `npx tsc --noEmit`, `npx eslint src`, `npx
knip`, `npx vitest run` (1137 tests), `node
scripts/check-isolation-allowlist.mjs`. All pass.

Headless test-mode build on Esmeralda with the test profile, driven with
Playwright. Screenshots are `r2-*.png` (the round one run is in
`review-*.png`):

- Startup with the round one store, encrypted with the PIN alone. The
card and the Ootle tab showed Unlock Layer 2 (`r2-a-1-panel-locked.png`,
`r2-a-2-settings-locked.png`). Unlocking logged "L2 store moved onto the
keyring secret and the PIN". The owner file stayed `2qXB72`, the default
account `968c1686...` came back and the L2 seed words equal the L1 words
(`r2-a-3-settings-unlocked.png`). After a restart, unlocking went
straight to "L2 wallet unlocked" with no migration.
- Each PIN prompt says what it's for: unlock (`r2-a-prompt-unlock.png`),
seed words reveal (`r2-a-prompt-l2-seed-reveal.png`), import
(`r2-c-prompt-import.png`) and reset to the Tari seed
(`r2-c-prompt-l1.png`). An L1 and an L2 seed words reveal started
together: only the first prompt showed until it was answered, then the
second came up (`r2-b-concurrent-1.png`, `r2-b-concurrent-2.png`). On a
build with the prompt timeout cut to 20 seconds, an unanswered prompt
closed itself at 20 seconds, the call returned "PIN entry cancelled",
and the next prompt worked (`r2-b-timeout-1-prompt.png`,
`r2-b-timeout-2-closed.png`).
- Importing a fresh 24 word seed asked for the PIN once, the store
opened by itself after the phase restart, the owner file became
`imported`, `seed_source` was `imported` and no `esmeralda.old` was left
(`r2-c-import-done.png`). Use my Tari wallet seed did the same, owner
back to `2qXB72` and account `968c1686...` back (`r2-c-l1-done.png`).
The log shows the restarted phase finding the store only after the swap
let the lifecycle lock go, and no "not started yet" refusal on the
automatic open.
- A send with 0 XTR failed at the dry run with "Error sending on Layer
2: L2 send failed: Stealth outputs error: Insufficient funds", no PIN
prompt, never "Sent" (`r2-d-2-send-error.png`).
- Refresh wallet history with no burn waiting rescanned and brought the
L1 wallet back, and the proof files survived
(`r2-e-nopending-refresh-clicked.png`). With a pending burn row put into
`wallet.db` by hand, refresh showed "Could not refresh wallet history: 1
burn to Layer 2 is still waiting to be mined" and `import_seed_words`
refused with the same text. Both refusals came after the wallet phase
shutdown and the phases came back (`r2-e-pending-refresh-clicked.png`).
- `exit_application` shut everything down and logged "shut down
successfully" (`r2-g-final-panel.png` just before).

## Not verified

- The fee line on the PIN prompt and the fee ceiling against a real dry
run. The test wallet has no L2 funds, so every send fails before the fee
is known. The ceiling and the claim fraction are unit tested.
- A funded L2 send, so a send reaching submit, the `null` entry in
`pending_claims.json`, its toast, a transport error on submit and a
network rejection. The submit decision is unit tested on the status.
- A real claim. The only proof on the test profile is for another
wallet's claim key.
- A burn that's really pending, and a burn broadcast hitting a transport
error. The refusal was checked with a hand inserted row.
- The forgotten PIN flow, its imported seed warning, and moving L2 onto
the new PIN.
- Crash recovery of a seed swap and a failed swap rolling back, beyond
the unit tests.
- The side by side toggle and the one-card-at-a-time rail in the running
app. vitest covers both.
- Windows and macOS.
- SIGTERM. The app has no SIGTERM handler (same on main). Not something
this PR changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
brianp added a commit that referenced this pull request Sep 28, 2026
…sk (#144)

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](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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