Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
dca2c87
feat(ledger): implement Shelley stake pool validation rules
wolf31o2 Sep 2, 2026
84af1f1
fix(ledger): reject non-reward account headers
wolf31o2 Sep 2, 2026
cc43174
fix(ledger): invalidate pool reward account cache
wolf31o2 Sep 2, 2026
88da24d
fix(ledger): decode Shelley minPoolCost protocol parameter
wolf31o2 Sep 2, 2026
3d14a61
fix(ledger): match reward account header check to reference
wolf31o2 Sep 2, 2026
9f61fc3
chore(ledger): merge origin/main into Shelley pool rules
wolf31o2 Sep 2, 2026
d019b7d
test(ledger): close gaps in the POOL rule coverage
wolf31o2 Sep 2, 2026
cfbb636
fix(ledger): skip pool certificates of phase-2-invalid txs
wolf31o2 Sep 2, 2026
d32ccac
chore: merge main into pool validation
wolf31o2 Sep 2, 2026
faace0f
fix(ledger): enforce pool certificate predicates
wolf31o2 Sep 3, 2026
1bb8e4a
fix(ledger): preserve pool network metadata on cache clear
wolf31o2 Sep 3, 2026
a8081f0
test(ledger): cover mandatory pool epoch state
wolf31o2 Sep 3, 2026
015afc6
chore: merge origin/main into fix/2145-shelley-pool-rules
wolf31o2 Sep 3, 2026
991e502
fix(ledger): keep pool rule compatibility tests current
wolf31o2 Sep 3, 2026
d779412
fix(ledger): preserve Conway pool rule ordering
wolf31o2 Sep 3, 2026
856f20c
chore: merge origin/main into fix/2145-shelley-pool-rules
wolf31o2 Sep 3, 2026
01f6d89
fix(ledger): preserve optional epoch compatibility
wolf31o2 Sep 3, 2026
6992237
test(ledger): cover skipped retirement epoch bound
wolf31o2 Sep 3, 2026
46da2b1
docs(ledger): document EpochState epoch-zero exception
wolf31o2 Sep 3, 2026
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
16 changes: 16 additions & 0 deletions ledger/allegra/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ var UtxoValidationRules = []common.UtxoValidationRuleFunc{
UtxoValidateNativeScripts,
UtxoValidateDelegation,
UtxoValidateWithdrawals,
UtxoValidatePoolCertificates,
}

func UtxoValidateRequiredVKeyWitnesses(
Expand Down Expand Up @@ -264,3 +265,18 @@ func UtxoValidateWithdrawals(
) error {
return shelley.UtxoValidateWithdrawals(tx, slot, ls, pp)
}

// UtxoValidatePoolCertificates applies the Shelley POOL rule, which this era
// inherits unchanged.
//
// Reference: eras/allegra/impl/src/Cardano/Ledger/Allegra/Rules/Pool.hs
// declares only the EraRuleFailure and EraRuleEvent instances and reuses
// Shelley.poolTransition.
func UtxoValidatePoolCertificates(
tx common.Transaction,
slot uint64,
ls common.LedgerState,
pp common.ProtocolParameters,
) error {
return shelley.UtxoValidatePoolCertificates(tx, slot, ls, pp)
}
15 changes: 15 additions & 0 deletions ledger/alonzo/pparams.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,3 +410,18 @@ func UpgradePParams(
MinPoolCost: prevPParams.MinPoolCost,
}
}

// ProtocolMajorVersion returns the active major protocol version.
func (p *AlonzoProtocolParameters) ProtocolMajorVersion() uint {
return p.ProtocolMajor
}

// MinPoolCostValue returns the minPoolCost protocol parameter.
func (p *AlonzoProtocolParameters) MinPoolCostValue() uint64 {
return p.MinPoolCost
}

// PoolRetirementMaxEpoch returns the eMax protocol parameter.
func (p *AlonzoProtocolParameters) PoolRetirementMaxEpoch() uint64 {
return uint64(p.MaxEpoch)
}
16 changes: 16 additions & 0 deletions ledger/alonzo/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ var UtxoValidationRules = []common.UtxoValidationRuleFunc{
UtxoValidatePlutusScripts,
UtxoValidateDelegation,
UtxoValidateWithdrawals,
UtxoValidatePoolCertificates,
}

// UtxoValidateOutputTooBigUtxo ensures that transaction output values are not too large
Expand Down Expand Up @@ -920,3 +921,18 @@ func UtxoValidateScriptDataHash(

return nil
}

// UtxoValidatePoolCertificates applies the Shelley POOL rule, which this era
// inherits unchanged.
//
// Reference: eras/alonzo/impl/src/Cardano/Ledger/Alonzo/Rules/Pool.hs declares
// only the EraRuleFailure and EraRuleEvent instances and reuses
// Shelley.poolTransition.
func UtxoValidatePoolCertificates(
tx common.Transaction,
slot uint64,
ls common.LedgerState,
pp common.ProtocolParameters,
) error {
return shelley.UtxoValidatePoolCertificates(tx, slot, ls, pp)
}
15 changes: 15 additions & 0 deletions ledger/babbage/pparams.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,3 +315,18 @@ func UpgradePParams(
}
return ret
}

// ProtocolMajorVersion returns the active major protocol version.
func (p *BabbageProtocolParameters) ProtocolMajorVersion() uint {
return p.ProtocolMajor
}

// MinPoolCostValue returns the minPoolCost protocol parameter.
func (p *BabbageProtocolParameters) MinPoolCostValue() uint64 {
return p.MinPoolCost
}

// PoolRetirementMaxEpoch returns the eMax protocol parameter.
func (p *BabbageProtocolParameters) PoolRetirementMaxEpoch() uint64 {
return uint64(p.MaxEpoch)
}
16 changes: 16 additions & 0 deletions ledger/babbage/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ var UtxoValidationRules = []common.UtxoValidationRuleFunc{
UtxoValidatePlutusScripts,
UtxoValidateDelegation,
UtxoValidateWithdrawals,
UtxoValidatePoolCertificates,
}

func UtxoValidateOutsideValidityIntervalUtxo(
Expand Down Expand Up @@ -1280,3 +1281,18 @@ func UtxoValidateMalformedReferenceScripts(
}
return common.ValidatePlutusScriptsWellFormed(tx, params.ProtocolMajor)
}

// UtxoValidatePoolCertificates applies the Shelley POOL rule, which this era
// inherits unchanged.
//
// Reference: eras/babbage/impl/src/Cardano/Ledger/Babbage/Rules/Pool.hs
// declares only the EraRuleFailure and EraRuleEvent instances and reuses
// Shelley.poolTransition.
func UtxoValidatePoolCertificates(
tx common.Transaction,
slot uint64,
ls common.LedgerState,
pp common.ProtocolParameters,
) error {
return shelley.UtxoValidatePoolCertificates(tx, slot, ls, pp)
}
22 changes: 22 additions & 0 deletions ledger/common/certs.go
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,24 @@ type PoolRegistrationCertificate struct {
PoolOwners []AddrKeyHash `json:"poolOwners"`
Relays []PoolRelay `json:"relays"`
PoolMetadata *PoolMetadata `json:"poolMetadata,omitempty"`

// rewardAccountNetworkId holds the network id from the address header
// byte of the wire reward_account, which RewardAccount itself does not
// retain. rewardAccountNetworkIdKnown is false when the certificate was
// not decoded from a CBOR reward account carrying a header byte (a
// programmatically constructed certificate, one built from JSON, or the
// legacy 28-byte encoding).
rewardAccountNetworkId uint
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
rewardAccountNetworkIdKnown bool
}

// RewardAccountNetworkId returns the network id encoded in the header byte of
// the certificate's reward account. The second return value is false when the
// certificate did not come from a wire reward_account carrying that header, in
// which case no network id is recoverable and callers must not treat the zero
// value as testnet.
func (c *PoolRegistrationCertificate) RewardAccountNetworkId() (uint, bool) {
return c.rewardAccountNetworkId, c.rewardAccountNetworkIdKnown
}

// ErrPoolMarginOutsideUnitInterval identifies a stake-pool margin outside the
Expand Down Expand Up @@ -1076,6 +1094,8 @@ func (c *PoolRegistrationCertificate) UnmarshalCBOR(cborData []byte) error {
c.Cost = tmp.Cost
c.Margin = tmp.Margin
c.RewardAccount = tmp.RewardAccount.credential
c.rewardAccountNetworkId = tmp.RewardAccount.networkId
c.rewardAccountNetworkIdKnown = tmp.RewardAccount.networkIdKnown
c.PoolOwners = tmp.PoolOwners
c.Relays = tmp.Relays
c.PoolMetadata = tmp.PoolMetadata
Expand All @@ -1097,6 +1117,8 @@ func (c *PoolRegistrationCertificate) UnmarshalCBOR(cborData []byte) error {
c.Cost = tmp.Cost
c.Margin = tmp.Margin
c.RewardAccount = tmp.RewardAccount.credential
c.rewardAccountNetworkId = tmp.RewardAccount.networkId
c.rewardAccountNetworkIdKnown = tmp.RewardAccount.networkIdKnown
c.PoolOwners = tmp.PoolOwners
c.Relays = tmp.Relays
c.PoolMetadata = tmp.PoolMetadata
Expand Down
176 changes: 176 additions & 0 deletions ledger/common/pool_reward_account_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Copyright 2026 Blink Labs Software
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package common

import (
"bytes"
"testing"

"github.com/blinklabs-io/gouroboros/cbor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestPoolRegistrationRewardAccountNetworkId covers the network id recovered
// from a pool registration's reward account header byte, which the POOL rule's
// WrongNetworkPOOL predicate compares against the ledger's network.
func TestPoolRegistrationRewardAccountNetworkId(t *testing.T) {
credential := bytes.Repeat([]byte{0x07}, Blake2b224Size)
encode := func(t *testing.T, rewardAccount []byte) []byte {
t.Helper()
wire, err := cbor.Encode([]any{
uint(CertificateTypePoolRegistration),
NewBlake2b224(bytes.Repeat([]byte{0x01}, Blake2b224Size)),
NewBlake2b256(bytes.Repeat([]byte{0x02}, Blake2b256Size)),
uint64(1_000_000),
uint64(340_000_000),
NewGenesisRat(0, 1),
rewardAccount,
[]AddrKeyHash{},
[]PoolRelay{},
nil,
})
require.NoError(t, err)
return wire
}

for _, networkId := range []byte{0, 1} {
t.Run(
"header network "+string(rune('0'+networkId)),
func(t *testing.T) {
rewardAccount := append(
[]byte{0xe0 | networkId},
credential...,
)
cert := &PoolRegistrationCertificate{}
require.NoError(
t,
cert.UnmarshalCBOR(encode(t, rewardAccount)),
)
assert.Equal(
t,
AddrKeyHash(NewBlake2b224(credential)),
cert.RewardAccount,
)
got, known := cert.RewardAccountNetworkId()
require.True(t, known)
assert.Equal(t, uint(networkId), got)
},
)
}

t.Run("script credential header keeps its network", func(t *testing.T) {
// 0xf0 is the script-hash reward-address header. Only the low
// nibble carries the network id.
rewardAccount := append([]byte{0xf1}, credential...)
cert := &PoolRegistrationCertificate{}
require.NoError(t, cert.UnmarshalCBOR(encode(t, rewardAccount)))
got, known := cert.RewardAccountNetworkId()
require.True(t, known)
assert.Equal(t, uint(1), got)
})

t.Run("legacy 28-byte encoding has no network id", func(t *testing.T) {
cert := &PoolRegistrationCertificate{}
require.NoError(t, cert.UnmarshalCBOR(encode(t, credential)))
assert.Equal(
t,
AddrKeyHash(NewBlake2b224(credential)),
cert.RewardAccount,
)
got, known := cert.RewardAccountNetworkId()
assert.False(t, known)
assert.Equal(t, uint(0), got)
})

t.Run("constructed certificate has no network id", func(t *testing.T) {
cert := &PoolRegistrationCertificate{
RewardAccount: NewBlake2b224(credential),
}
_, known := cert.RewardAccountNetworkId()
assert.False(t, known)
})

t.Run("decoding preserves the wire bytes", func(t *testing.T) {
rewardAccount := append([]byte{0xe1}, credential...)
wire := encode(t, rewardAccount)
cert := &PoolRegistrationCertificate{}
require.NoError(t, cert.UnmarshalCBOR(wire))
remarshaled, err := cert.MarshalCBOR()
require.NoError(t, err)
assert.Equal(t, wire, remarshaled)
})
}

// TestPoolMetadataHashLengthIsFixed proves a pool registration whose metadata
// hash is not exactly 32 bytes fails to decode. The Shelley POOL rule's
// PoolMedataHashTooBig predicate is unreachable for that reason, so
// shelley.UtxoValidatePoolCertificates does not reimplement it.
func TestPoolMetadataHashLengthIsFixed(t *testing.T) {
encode := func(t *testing.T, metadata any) []byte {
t.Helper()
wire, err := cbor.Encode([]any{
uint(CertificateTypePoolRegistration),
NewBlake2b224(bytes.Repeat([]byte{0x01}, Blake2b224Size)),
NewBlake2b256(bytes.Repeat([]byte{0x02}, Blake2b256Size)),
uint64(1_000_000),
uint64(340_000_000),
NewGenesisRat(0, 1),
append(
[]byte{0xe1},
bytes.Repeat([]byte{0x07}, Blake2b224Size)...,
),
[]AddrKeyHash{},
[]PoolRelay{},
metadata,
})
require.NoError(t, err)
return wire
}

t.Run("32-byte metadata hash decodes", func(t *testing.T) {
wire := encode(t, []any{
"https://example.com/pool.json",
bytes.Repeat([]byte{0x05}, Blake2b256Size),
})
cert := &PoolRegistrationCertificate{}
require.NoError(t, cert.UnmarshalCBOR(wire))
require.NotNil(t, cert.PoolMetadata)
assert.Equal(
t,
PoolMetadataHash(
NewBlake2b256(bytes.Repeat([]byte{0x05}, Blake2b256Size)),
),
cert.PoolMetadata.Hash,
)
})

wrongSizes := map[string]int{
"one byte short": Blake2b256Size - 1,
"one byte long": Blake2b256Size + 1,
}
for name, size := range wrongSizes {
t.Run(name+" fails to decode", func(t *testing.T) {
wire := encode(t, []any{
"https://example.com/pool.json",
bytes.Repeat([]byte{0x05}, size),
})
cert := &PoolRegistrationCertificate{}
err := cert.UnmarshalCBOR(wire)
require.Error(t, err)
assert.Contains(t, err.Error(), "blake2b-256 hash")
})
}
}
20 changes: 20 additions & 0 deletions ledger/common/pparams.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,26 @@ type ProtocolParameters interface {
Utxorpc() (*cardano.PParams, error)
}

// PoolRuleProtocolParameters is the protocol-parameter view required by the
// Shelley POOL rules. Every protocol parameter type in this repository from
// Shelley onwards implements it, so it is an internal accessor rather than a
// capability a consumer has to supply. Byron has no POOL rule.
//
// Reference: poolTransition in
// eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Pool.hs reads
// ppProtocolVersionL, ppMinPoolCostL and ppEMaxL.
type PoolRuleProtocolParameters interface {
// ProtocolMajorVersion returns the active major protocol version, used
// to gate the era-dependent POOL predicates.
ProtocolMajorVersion() uint
// MinPoolCostValue returns the minPoolCost protocol parameter.
MinPoolCostValue() uint64
// PoolRetirementMaxEpoch returns the eMax protocol parameter, the
// number of epochs after the current one within which a scheduled pool
// retirement must fall.
PoolRetirementMaxEpoch() uint64
}

type ExUnitPrice struct {
cbor.StructAsArray
MemPrice *cbor.Rat
Expand Down
25 changes: 25 additions & 0 deletions ledger/common/protocol_version.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,28 @@ func IsProtocolVersionAtLeast(major, minor, minMajor uint) bool {
_ = minor // minor version not used in current comparison logic
return major >= minMajor
}

// PoolAccountNetworkIdValidated reports whether the POOL rule validates the
// network id of a stake pool registration's reward account.
//
// Reference: eras/shelley/impl/src/Cardano/Ledger/Shelley/Era.hs,
// hardforkAlonzoValidatePoolAccountAddressNetID (pvMajor pv > natVersion @4),
// consumed by poolTransition in
// eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Pool.hs.
//
// Note that this is major version 5, the first Alonzo protocol version, not
// ProtocolVersionAlonzo (6, the second one).
func PoolAccountNetworkIdValidated(major uint) bool {
return major > 4
}

// DuplicateVrfKeysDisallowed reports whether the POOL rule rejects a stake pool
// registration whose VRF key hash is already registered by another pool.
//
// Reference: eras/shelley/impl/src/Cardano/Ledger/Shelley/Era.hs,
// hardforkConwayDisallowDuplicatedVRFKeys (pvMajor pv > natVersion @10),
// consumed by poolTransition in
// eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Pool.hs.
func DuplicateVrfKeysDisallowed(major uint) bool {
return major > 10
}
Loading