dexprotocol: Reserve binds to the accepted parent — rule 1 enforced

Settle and Release already required a verified AcceptedBlock; Reserve did not,
so a reservation could still be created without any proof that its C parent was
accepted. That left the abandoned-parent case reachable through the one door
that had not been closed.

Reserve(parent AcceptedBlock, v VerifiedExecution) now enforces both halves of
rule 1: the parent must carry the verified sentinel, and the execution must be
scoped to exactly that parent. Both arguments are unforgeable outside this
package, so a reservation can be taken neither against an unauthenticated
execution nor against an unproven C parent.

25 tests passing.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-07 09:15:56 -07:00
co-authored by Hanzo Dev
parent 438ad8eb9a
commit e714094013
3 changed files with 95 additions and 21 deletions
+22 -10
View File
@@ -67,6 +67,18 @@ func acceptedConsuming(t *testing.T, id, parent ids.ID, consumed ...ids.ID) (Acc
return ab, set.Prove(target)
}
// acceptedParent returns a verified AcceptedBlock for P — the parent every sample
// execution is scoped to. Rule 1 requires one to reserve at all.
func acceptedParent(t *testing.T) AcceptedBlock {
t.Helper()
msg := EncodeAcceptedBlock(testParent, ids.ID{0x0B}, 1, NewExecSet().Root())
ab, err := VerifyAcceptedBlock(msg, []byte("cert"), okVerifier{})
if err != nil {
t.Fatalf("VerifyAcceptedBlock(parent): %v", err)
}
return ab
}
func verified(t *testing.T, e Execution) VerifiedExecution {
t.Helper()
v, err := VerifyExecution(witnessFor(e), VerifyContext{
@@ -209,7 +221,7 @@ func TestSettleAndReleaseAreMutuallyExclusiveAndTerminal(t *testing.T) {
t.Run("settled is terminal", func(t *testing.T) {
l := NewLedger()
e := sampleExecution()
if _, err := l.Reserve(verified(t, e)); err != nil {
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
t.Fatalf("reserve: %v", err)
}
if _, err := l.Settle(e.ExecID, accepted, incl); err != nil {
@@ -232,7 +244,7 @@ func TestSettleAndReleaseAreMutuallyExclusiveAndTerminal(t *testing.T) {
t.Run("released is terminal", func(t *testing.T) {
l := NewLedger()
e := sampleExecution()
if _, err := l.Reserve(verified(t, e)); err != nil {
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
t.Fatalf("reserve: %v", err)
}
if _, err := l.Release(e.ExecID, qEmpty, nonIncl); err != nil {
@@ -254,16 +266,16 @@ func TestReplayedCertificateCannotReReserve(t *testing.T) {
e := sampleExecution()
accepted, incl := acceptedConsuming(t, testCBlock, testParent, e.ExecID)
l := NewLedger()
if _, err := l.Reserve(verified(t, e)); err != nil {
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
t.Fatalf("reserve: %v", err)
}
if _, err := l.Settle(e.ExecID, accepted, incl); err != nil {
t.Fatalf("settle: %v", err)
}
if _, err := l.Reserve(verified(t, e)); !errors.Is(err, ErrTerminalExists) {
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); !errors.Is(err, ErrTerminalExists) {
t.Fatalf("re-reserving a terminal ExecID must be refused, got %v", err)
}
if _, err := l.Reserve(verified(t, e)); !errors.Is(err, ErrTerminalExists) {
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); !errors.Is(err, ErrTerminalExists) {
t.Fatalf("still refused on a second attempt, got %v", err)
}
}
@@ -271,10 +283,10 @@ func TestReplayedCertificateCannotReReserve(t *testing.T) {
func TestDoubleReserveRefused(t *testing.T) {
l := NewLedger()
e := sampleExecution()
if _, err := l.Reserve(verified(t, e)); err != nil {
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
t.Fatalf("reserve: %v", err)
}
if _, err := l.Reserve(verified(t, e)); !errors.Is(err, ErrAlreadyOpen) {
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); !errors.Is(err, ErrAlreadyOpen) {
t.Fatalf("double reserve must be refused, got %v", err)
}
}
@@ -289,7 +301,7 @@ func TestSettleChecksTheAcceptedBlocksParent(t *testing.T) {
accepted, incl := acceptedConsuming(t, testCBlock, wrongParent, e.ExecID)
l := NewLedger()
if _, err := l.Reserve(verified(t, e)); err != nil {
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
t.Fatalf("reserve: %v", err)
}
if _, err := l.Settle(e.ExecID, accepted, incl); !errors.Is(err, ErrWrongSuccessor) {
@@ -308,13 +320,13 @@ func TestStorageDomainsAreDistinct(t *testing.T) {
qEmpty, nonIncl := acceptedConsuming(t, testCBlock, testParent)
l := NewLedger()
r, _ := l.Reserve(verified(t, e))
r, _ := l.Reserve(acceptedParent(t), verified(t, e))
tr, err := l.Settle(e.ExecID, accepted, incl)
if err != nil {
t.Fatalf("settle: %v", err)
}
l2 := NewLedger()
r2, _ := l2.Reserve(verified(t, e))
r2, _ := l2.Reserve(acceptedParent(t), verified(t, e))
rel, err := l2.Release(e.ExecID, qEmpty, nonIncl)
if err != nil {
t.Fatalf("release: %v", err)
+47 -3
View File
@@ -125,7 +125,7 @@ func TestSettleRequiresInclusionReleaseRequiresNonInclusion(t *testing.T) {
_, nonIncl := acceptedConsuming(t, testCBlock, testParent)
l := NewLedger()
if _, err := l.Reserve(verified(t, e)); err != nil {
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
t.Fatalf("reserve: %v", err)
}
if _, err := l.Settle(e.ExecID, consumed, nonIncl); !errors.Is(err, ErrWrongProof) {
@@ -148,7 +148,7 @@ func TestUnverifiedParentCannotSettleOrRelease(t *testing.T) {
_, incl := acceptedConsuming(t, testCBlock, testParent, e.ExecID)
l := NewLedger()
if _, err := l.Reserve(verified(t, e)); err != nil {
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
t.Fatalf("reserve: %v", err)
}
var forged AcceptedBlock // the zero value an outside package can build
@@ -199,7 +199,7 @@ func TestUselessButResolvableReservation(t *testing.T) {
q, nonIncl := acceptedConsuming(t, testCBlock, testParent)
l := NewLedger()
if _, err := l.Reserve(verified(t, e)); err != nil {
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
t.Fatalf("reserve: %v", err)
}
if _, err := l.Release(e.ExecID, q, nonIncl); err != nil {
@@ -246,3 +246,47 @@ func TestLeafAndNodeDomainsAreSeparated(t *testing.T) {
t.Fatal("the empty leaf must be distinct from any populated leaf")
}
}
// TestRule1_ReserveRequiresTheAcceptedParent pins rule 1 of the proof system:
//
// Accepted(P) permits Reserve(E) with E.CParent == P
//
// Both halves are enforced. Without the first, a reservation could be taken
// against a merely proposed block, which is the abandoned-parent case the
// accepted-only rule exists to make nonexistent. Without the second, a reservation
// could be taken against the wrong opportunity entirely.
func TestRule1_ReserveRequiresTheAcceptedParent(t *testing.T) {
e := sampleExecution() // scoped to testParent
t.Run("unverified parent is refused", func(t *testing.T) {
l := NewLedger()
var proposed AcceptedBlock // the zero value an outside package can build
if _, err := l.Reserve(proposed, verified(t, e)); !errors.Is(err, ErrAcceptedUnverified) {
t.Fatalf("reserving against an unverified parent must be refused, got %v", err)
}
if r, _, _ := l.Counts(); r != 0 {
t.Fatal("a refused reserve must not enter the ledger")
}
})
t.Run("wrong accepted parent is refused", func(t *testing.T) {
other := testParent
other[0] ^= 1
msg := EncodeAcceptedBlock(other, ids.ID{0x0B}, 1, NewExecSet().Root())
wrong, err := VerifyAcceptedBlock(msg, []byte("cert"), okVerifier{})
if err != nil {
t.Fatalf("VerifyAcceptedBlock: %v", err)
}
l := NewLedger()
if _, err := l.Reserve(wrong, verified(t, e)); !errors.Is(err, ErrWrongParent) {
t.Fatalf("reserving against the wrong accepted parent must be refused, got %v", err)
}
})
t.Run("the accepted parent it is scoped to succeeds", func(t *testing.T) {
l := NewLedger()
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
t.Fatalf("reserving against the correct accepted parent must succeed: %v", err)
}
})
}
+26 -8
View File
@@ -93,12 +93,26 @@ func (r Released) ExecID() ids.ID { return r.exec.ExecID }
func (r Released) CBlock() ids.ID { return r.cBlock }
func (r Released) Key() string { return domainReleased + r.exec.ExecID.String() }
// Reserve admits a verified execution into the reserved domain. It is the ONLY
// entry point into the lifecycle and requires a VerifiedExecution — which cannot be
// constructed outside this package — so a reservation can never be taken against an
// execution nobody authenticated.
func Reserve(v VerifiedExecution) ReservedExecution {
return ReservedExecution{exec: v.Execution()}
// Reserve admits a verified execution into the reserved domain, AGAINST AN ACCEPTED
// C PARENT. This is rule 1 of the proof system:
//
// Accepted(P) permits Reserve(E) with E.CParent == P
//
// Both arguments are unforgeable: VerifiedExecution cannot be constructed outside
// this package, and AcceptedBlock only comes from VerifyAcceptedBlock. So a
// reservation can be taken neither against an execution nobody authenticated nor
// against a C parent nobody proved accepted — which is what makes the
// abandoned-parent case not merely handled but nonexistent.
func Reserve(parent AcceptedBlock, v VerifiedExecution) (ReservedExecution, error) {
if !parent.verified {
return ReservedExecution{}, ErrAcceptedUnverified
}
e := v.Execution()
if e.CParent != parent.ID() {
return ReservedExecution{}, fmt.Errorf("%w: execution scoped to %s, reserving against accepted %s",
ErrWrongParent, e.CParent, parent.ID())
}
return ReservedExecution{exec: e}, nil
}
var (
@@ -107,6 +121,7 @@ var (
ErrAlreadyOpen = errors.New("dexprotocol: an execution is already reserved under that id")
ErrTerminalExists = errors.New("dexprotocol: a terminal execution already exists under that id")
ErrWrongProof = errors.New("dexprotocol: the proof does not match the transition being attempted")
ErrWrongParent = errors.New("dexprotocol: the execution is not scoped to this accepted C parent")
)
// THE ENTIRE PROOF SYSTEM, THREE RULES:
@@ -214,7 +229,7 @@ func (l *Ledger) terminalExists(id ids.ID) bool {
// Reserve admits a verified execution. Refuses an id that is already reserved or
// already terminal — the latter is what stops a replayed certificate from
// re-reserving liquidity that was already settled or released.
func (l *Ledger) Reserve(v VerifiedExecution) (ReservedExecution, error) {
func (l *Ledger) Reserve(parent AcceptedBlock, v VerifiedExecution) (ReservedExecution, error) {
id := v.Execution().ExecID
if l.terminalExists(id) {
return ReservedExecution{}, fmt.Errorf("%w: %s", ErrTerminalExists, id)
@@ -222,7 +237,10 @@ func (l *Ledger) Reserve(v VerifiedExecution) (ReservedExecution, error) {
if _, ok := l.reserved[id]; ok {
return ReservedExecution{}, fmt.Errorf("%w: %s", ErrAlreadyOpen, id)
}
r := Reserve(v)
r, err := Reserve(parent, v)
if err != nil {
return ReservedExecution{}, err
}
l.reserved[id] = r
return r, nil
}