Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions unroll/descriptor_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,10 @@ func (r *DescriptorLineageResolver) resolveValidatedLineage(ctx context.Context,
}

// validateProofDescriptorShape upstream gates every fragment for
// non-nil TreePath, non-empty tree, non-zero CommitmentTxID, and
// non-zero TreeDepth, so we can append every fragment
// unconditionally here.
// non-nil TreePath, non-empty tree, and non-zero CommitmentTxID,
// so we can append every fragment unconditionally here. TreeDepth
// is deliberately not part of that gate (it is expiry-timing
// metadata, not proof material).
for _, a := range desc.Ancestry {
mat.TreePaths = append(mat.TreePaths, a.TreePath)
}
Expand Down
16 changes: 12 additions & 4 deletions unroll/proof_assembler.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,18 @@ func validateProofDescriptorShape(desc *vtxo.Descriptor) error {
// with a confusing "tree path missing root" — at which point the
// FSM has already advanced into AwaitingMaterialization with a
// proof set it can never assemble. Fail fast at the boundary.
//
// Note: TreeDepth is intentionally NOT checked here. The proof
// assembler walks TreePath.Root directly, so the scalar TreeDepth
// is purely expiry-timing metadata (see vtxo.Descriptor.MaxTreeDepth
// and vtxo/expiry.go). An untrusted indexer that returns a
// non-empty TreePath with TreeDepth defaulted/forged to zero must
// NOT block unilateral exit — that would let the operator strand
// otherwise-recoverable funds. Receive-side validation of TreeDepth
// against TreePath.Depth() lives at the ingest boundary
// (oor.validateIncomingAncestry / darepod.ancestryFromRPC); the
// unroll path stays liberal about the scalar so legitimate proofs
// always assemble.
for i, frag := range desc.Ancestry {
switch {
case frag.TreePath == nil:
Expand All @@ -357,10 +369,6 @@ func validateProofDescriptorShape(desc *vtxo.Descriptor) error {
case frag.CommitmentTxID == (chainhash.Hash{}):
return fmt.Errorf("%w: ancestry fragment %d missing "+
"commitment txid", ErrUnrollProofUnavailable, i)

case frag.TreeDepth == 0:
return fmt.Errorf("%w: ancestry fragment %d has zero "+
"tree depth", ErrUnrollProofUnavailable, i)
}
}

Expand Down
49 changes: 42 additions & 7 deletions unroll/proof_assembler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,6 @@ func TestValidateProofDescriptorRejectsMalformedAncestry(t *testing.T) {
},
wantReason: "fragment 0 missing commitment txid",
},
{
name: "fragment 0 zero tree depth",
mutate: func(d *vtxo.Descriptor) {
d.Ancestry[0].TreeDepth = 0
},
wantReason: "ancestry fragment 0 has zero tree depth",
},
{
name: "fragment 1 nil tree path (multi-fragment)",
mutate: func(d *vtxo.Descriptor) {
Expand Down Expand Up @@ -102,6 +95,48 @@ func TestValidateProofDescriptorRejectsMalformedAncestry(t *testing.T) {
}
}

// TestValidateProofDescriptorAcceptsZeroTreeDepth is the regression
// guard for #372 ("Untrusted zero tree depth can block unroll
// proofs"). TreeDepth is expiry-timing metadata, not proof material —
// the proof assembler walks TreePath directly. A malicious indexer
// that supplies a non-empty TreePath but a defaulted/forged TreeDepth
// of zero must NOT prevent unilateral exit; otherwise the operator
// can strand otherwise-recoverable funds simply by zeroing one
// scalar.
//
// The test also asserts the gate has no sticky state: repeated calls
// with the same zero-depth descriptor keep succeeding, so an unroll
// retry after a transient failure earlier in the pipeline still
// reaches proof assembly.
func TestValidateProofDescriptorAcceptsZeroTreeDepth(t *testing.T) {
t.Parallel()

desc := &vtxo.Descriptor{
CommitmentTxID: chainhash.HashH([]byte("commit")),
RoundID: "round-1",
CreatedHeight: 100,
BatchExpiry: 1000,
RelativeExpiry: 144,
Status: vtxo.VTXOStatusLive,
Ancestry: []vtxo.Ancestry{{
TreePath: &tree.Tree{
Root: &tree.Node{},
},
CommitmentTxID: chainhash.HashH([]byte("frag")),
// Zero TreeDepth: hostile/legacy/forged indexer value.
// Proof assembly only needs TreePath, so this must
// pass.
TreeDepth: 0,
}},
}

// Two calls in a row exercise the "no sticky state" invariant:
// the unroll boundary cannot persist a rejection from one call
// into the next.
require.NoError(t, validateProofDescriptor(desc))
require.NoError(t, validateProofDescriptor(desc))
}

// TestValidateProofDescriptorAcceptsWellFormedMultiFragment is the
// positive companion to the rejection table above: a structurally clean
// multi-fragment descriptor must pass validation cleanly so the unroll
Expand Down
Loading