Skip to content

oor: Bind OOR checkpoint outputs to the session operator key - #699

Merged
ellemouton merged 1 commit into
mainfrom
ellemouton/oor-rotation-keys
Jun 9, 2026
Merged

oor: Bind OOR checkpoint outputs to the session operator key#699
ellemouton merged 1 commit into
mainfrom
ellemouton/oor-rotation-keys

Conversation

@ellemouton

Copy link
Copy Markdown
Member

What

When building an OOR transfer, the client derived the checkpoint output's owner collaborative leaf from the spent input VTXO's operator key. But the checkpoint output — and the Ark tx that spends it cooperatively — is governed by the session checkpoint policy, whose operator key is the operator's current key at session-creation time.

After the operator rotates its key, a VTXO created under an older key (X) would produce a checkpoint output committed to X, while the server (correctly) rebuilds and co-signs that output under the current/session key (Y). The post-rotation submit was rejected at AwaitingSubmitValidationState with "owner leaf policy does not contain operator key", before the server's per-input co-sign path even ran — so any OOR spend of a pre-rotation VTXO failed. Before a rotation X == Y, so this was invisible.

Fix

Rebind each standard input's checkpoint-output owner leaf to the session operator key (StartTransferEvent.Policy.OperatorKey) in the StartTransfer transition, before the deterministic build and before the inputs flow into signing/persistence (normalizeCheckpointOwnerLeaves).

The input spend path is untouched — spending the VTXO still uses its own tapscript, committed to the historical key, which the server resolves and co-signs per input. Custom spends (e.g. vHTLC) carry their own owner leaf and are skipped.

This is the client half of the operator-key-rotation OOR work. The server half (per-input historical co-sign + session-bound checkpoint policy persistence + indexer surfacing) is in darepo (stacked on #525 work).

Testing

  • New unit tests (oor/checkpoint_owner_leaf_test.go): owner leaf rebinds to the session key (and not the input's historical key); custom spends untouched; nil policy key rejected.
  • End-to-end (server repo itests, now un-skipped): TestOORSendAfterOperatorKeyRotation (single rotated-key OOR send) and TestOORSendMixedHistoricalOperatorKeys (one transfer spending inputs under two different historical keys) both pass.

Part of the operator-key-rotation epic (#525).

🤖 Generated with Claude Code

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces a mechanism to normalize checkpoint owner leaves to the session operator key rather than the spent input VTXO's historical operator key, preventing submission failures after operator key rotation. This logic is integrated into the StartTransferEvent processing, and comprehensive unit tests have been added. The feedback suggests a minor refactoring in the loop of normalizeCheckpointOwnerLeaves to use a pointer to the slice elements for better readability and idiomatic Go code.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread oor/transitions.go
@ellemouton

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Jun 5, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 2m 19s —— View job


Review: oor — Bind OOR checkpoint outputs to the session operator key

  • Read the changed files (oor/transitions.go, oor/checkpoint_owner_leaf_test.go)
  • Understand the surrounding StartTransfer transition + helper context
  • Analyze correctness, edge cases, style, and the gemini suggestion
  • Post review feedback

Overall this is a well-targeted, well-documented fix. The core insight — that the checkpoint output owner leaf is governed by the session policy while the input spend stays on the historical key — is correct, and the placement of the normalization (before buildSubmitPackage, before the inputs flow into signing/persistence) is exactly right. The doc comment on normalizeCheckpointOwnerLeaves is excellent.

I traced the fix through the downstream TransferInput.Validate() path to confirm it sticks, and it does (details below). I was not able to run the unit tests in this environment (the go test invocation needs an approval I don't have), so the notes below are from reading.

Correctness — the fix composes cleanly ✅

The one thing worth confirming is that the later TransferInput.Validate() in oor/transfer_inputs.go:108-161 doesn't clobber or reject the rebound leaf. It doesn't:

  • Validate derives defaultLeaf/defaultPolicy from the historical i.VTXO.OperatorKey, but only assigns them when the fields are empty. After normalizeCheckpointOwnerLeaves, both OwnerLeafScript and OwnerLeafPolicy are populated, so the auto-derive branches (lines 122-130, 138-139, 150-157) are skipped.
  • The consistency check at oor/transfer_inputs.go:141-145 recompiles from OwnerLeafPolicy (the session policy) and compares to OwnerLeafScript (the session leaf). Both come from the same defaultOwnerLeaf(clientKey, sessionKey) call, so they match — no "owner leaf script and policy mismatch" error.
  • VTXOPolicyTemplate (the spend path) is left untouched, so the spend stays committed to the historical key. This matches the PR's stated invariant. 👍

Minor: unreachable len(leaf) == 0 branch

leaf, leafPolicy, err := defaultOwnerLeaf(
    inputs[i].VTXO.ClientKey.PubKey, policy.OperatorKey,
)
if err != nil {
    return err
}
if len(leaf) == 0 {
    continue
}

defaultOwnerLeaf only returns an empty leaf (with a nil error) when one of its key args is nil (oor/transfer_inputs.go:373-375). But this loop already guards policy.OperatorKey != nil (top of the function) and inputs[i].VTXO.ClientKey.PubKey != nil (the continue above), so len(leaf) == 0 is effectively unreachable here. It's harmless defensive code, but if you want to keep it, a one-line comment noting it's a belt-and-suspenders guard would help a future reader who'd otherwise wonder when it fires.

Style nit (agree with gemini) — input := &inputs[i]

The reviewer's suggestion to take input := &inputs[i] at the top of the loop body is idiomatic and removes five repetitions of the index expression. Worth applying. Fix this →

Questions / things to confirm (not blockers)

  1. In-place mutation of evt.VTXOInputs. normalizeCheckpointOwnerLeaves mutates the event's slice elements in place. The StartTransfer transition already mutates these inputs via buildSubmitPackage, so this is consistent with existing behavior, and because the rebind is deterministic (always the session key) a re-processed/retried event is a no-op. Just flagging it's intentional.

  2. Restore/resume of pre-fix sessions. A session created before this fix and restored from a persisted snapshot will carry the old historical-key owner leaf in its TransferInputSnapshot, and the restore path reconstructs from the snapshot rather than re-running this normalization. Given deterministic-resume requires byte-identical PSBTs, I assume that's intentional and such sessions are expected to fail/be drained pre-rotation — but worth a sentence in the PR or a follow-up note confirming there's no in-flight-session migration concern.

  3. Test coverage gap (minor). The three new tests cover the happy path, custom-spend skip, and nil-policy-key rejection nicely. Two small additions would round it out:

    • an assertion that the spend side (VTXOPolicyTemplate / the resolved spend path) still commits to the historical key after normalization — this is the other half of the invariant and is currently only verified server-side;
    • a case exercising the VTXO == nil / ClientKey.PubKey == nil skip branch.

    These are nice-to-haves; the existing coverage plus the un-skipped server itests (TestOORSendAfterOperatorKeyRotation, TestOORSendMixedHistoricalOperatorKeys) give good confidence.

Nits

  • Per the repo guideline, please run make fmt-changed / make lint-changed-local before merge if not already done — the diff looks compliant (8-space tabs, 80-col, name-prefixed doc comments) from here.

Nothing here is blocking; the logic is sound and the design rationale is clearly captured. 👍
· ellemouton/oor-rotation-keys

@ellemouton
ellemouton force-pushed the ellemouton/oor-rotation-keys branch from aa20c30 to 582d18c Compare June 5, 2026 22:07
@ellemouton

Copy link
Copy Markdown
Member Author

Thanks for the review! Addressed in the amended commit:

  • input := &inputs[i] — applied; the loop body no longer repeats the index expression.
  • Unreachable len(leaf) == 0 — kept as a belt-and-suspenders guard with a comment noting both key args are non-nil here, so it can't actually fire.
  • Test coverage — added an assertion that the spend side (VTXO.OperatorKey, from which the historical collaborative spend path derives) is untouched after normalization, and a TestNormalizeCheckpointOwnerLeavesSkipsIncompleteInput case for the VTXO == nil / ClientKey.PubKey == nil skip branch.

Re: resume of pre-fix sessions (Q2) — no in-flight migration concern. A session created before this change was necessarily created when the input and current operator keys were equal (no rotation relevant to it had occurred yet), and its point-of-no-return co-sign happened under that key. Deterministic resume reconstructs byte-identically under that same key, so it completes consistently. This fix only changes how new sessions (created after the upgrade) bind the checkpoint-output owner leaf — it never re-derives an already-bound session's leaf.

The end-to-end rotation itests on the server side (TestOORSendAfterOperatorKeyRotation, TestOORSendMixedHistoricalOperatorKeys) pass against this branch.

@ellemouton
ellemouton marked this pull request as ready for review June 8, 2026 20:47

@Roasbeef Roasbeef 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.

LGTM 🫐

@ellemouton
ellemouton force-pushed the ellemouton/oor-rotation-keys branch from 582d18c to fb55fd2 Compare June 9, 2026 03:37
@ellemouton
ellemouton force-pushed the ellemouton/round-scoped-signing-keys branch from 9cf0912 to 96f6cb4 Compare June 9, 2026 03:41
@ellemouton
ellemouton force-pushed the ellemouton/oor-rotation-keys branch from fb55fd2 to 6ed2fde Compare June 9, 2026 03:41
@ellemouton
ellemouton force-pushed the ellemouton/round-scoped-signing-keys branch from 96f6cb4 to 33e433c Compare June 9, 2026 03:54
@ellemouton
ellemouton force-pushed the ellemouton/oor-rotation-keys branch from 6ed2fde to 9dc752f Compare June 9, 2026 03:54
@ellemouton
ellemouton force-pushed the ellemouton/round-scoped-signing-keys branch from 33e433c to 5bdf260 Compare June 9, 2026 04:41
@ellemouton
ellemouton force-pushed the ellemouton/oor-rotation-keys branch 2 times, most recently from 4ae736b to 1a3fa02 Compare June 9, 2026 15:30
@ellemouton
ellemouton changed the base branch from ellemouton/round-scoped-signing-keys to main June 9, 2026 15:30
The checkpoint OUTPUT owner collaborative leaf was built from the spent
input VTXO's operator key. But the checkpoint output -- and the Ark tx
that spends it cooperatively -- is governed by the session checkpoint
policy, whose operator key is the operator's current key at session
creation. A VTXO created under an older operator key (after the operator
rotated) therefore produced a checkpoint output committed to a stale
key: the server's submit-rebuild rejects it ("owner leaf policy does not
contain operator key") and the operator's Ark co-signature, made with
the session key, fails the leaf. Before any rotation the input and
session keys are equal, so this was invisible.

Rebind each standard input's checkpoint output owner leaf to the session
operator key in the StartTransfer transition, before the deterministic
build and before the inputs flow into signing and persistence. The input
SPEND path is untouched -- spending the VTXO still uses its own
tapscript, committed to the historical key, which the server resolves
and co-signs per input. Custom spends (e.g. vHTLC) carry their own owner
leaf and are skipped.

This is the client half of the operator-key-rotation OOR work; the
server already co-signs each checkpoint input with its historical key
and binds and persists the session checkpoint policy.
@ellemouton
ellemouton force-pushed the ellemouton/oor-rotation-keys branch from 1a3fa02 to 144ea95 Compare June 9, 2026 16:15
@ellemouton
ellemouton merged commit 03cf7a4 into main Jun 9, 2026
42 of 50 checks passed
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