Skip to content
Merged
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
13 changes: 7 additions & 6 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func start(cmd *cobra.Command) error {

netProvider, err := initializeNetwork(
ctx,
admissionApplications(beaconChain, tbtcChain),
admissionApplications(tbtcChain),
operatorPrivateKey,
blockCounter,
)
Expand Down Expand Up @@ -199,14 +199,15 @@ func isBootstrap() bool {
return clientConfig.LibP2P.Bootstrap
}

// admissionApplications lists the chain handles a peer can be recognized by,
// in the order the firewall policy evaluates them. The beacon comes first
// because its predicate is the one that still sees legacy stake delegations.
// admissionApplications lists the chain handles that can authorize a peer.
// Network admission follows the wallet registry's current eligible stake so
// legacy beacon registrations do not bypass authorization changes.
// Random Beacon initialization still requires its operator registration, but
// that startup prerequisite is not a network admission authority.
func admissionApplications(
beaconChain *ethereum.BeaconChain,
tbtcChain *ethereum.TbtcChain,
) []firewall.Application {
return []firewall.Application{beaconChain, tbtcChain}
return []firewall.Application{tbtcChain}
}

// admissionPolicy builds the firewall policy guarding peer connections. The
Expand Down
240 changes: 205 additions & 35 deletions cmd/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@ import (
"reflect"
"strings"
"testing"
"time"

"github.com/ethereum/go-ethereum/common"
"github.com/keep-network/keep-core/config"
"github.com/keep-network/keep-core/internal/ethtest"
"github.com/keep-network/keep-core/internal/testutils"
"github.com/keep-network/keep-core/pkg/chain/ethereum"
"github.com/keep-network/keep-core/pkg/firewall"
"github.com/keep-network/keep-core/pkg/net"
"github.com/keep-network/keep-core/pkg/net/libp2p"
localnet "github.com/keep-network/keep-core/pkg/net/local"
"github.com/keep-network/keep-core/pkg/net/retransmission"
"github.com/keep-network/keep-core/pkg/net/watchtower"
"github.com/keep-network/keep-core/pkg/operator"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -82,26 +86,32 @@ func TestIsBootstrap(t *testing.T) {
func TestStart_AdmissionHandoff(t *testing.T) {
backend := ethtest.New(t, ethtest.AdmissionState(t))

configured := clientConfig.Ethereum
t.Cleanup(func() { clientConfig.Ethereum = configured })
configuredEthereum := clientConfig.Ethereum
configuredNetwork := clientConfig.LibP2P
t.Cleanup(func() {
clientConfig.Ethereum = configuredEthereum
clientConfig.LibP2P = configuredNetwork
})

clientConfig.Ethereum = backend.ChainConfig(t)
clientConfig.LibP2P.Bootstrap = false
clientConfig.LibP2P.Peers = []string{"ordinary-configured-discovery-peer"}

policy := captureAdmissionPolicy(t, func() error {
handoff := captureAdmissionPolicy(t, func() error {
return start(&cobra.Command{})
})

// What the policy admits comes first: the assertions below give up on a
// policy whose shape they cannot read, and stopping there would leave the
// verdicts unexamined.
assertAdmissionTable(t, backend, policy)
assertNoStaticBypass(t, policy)
assertAdmissionTable(t, backend, handoff.policy)
assertNoStaticBypass(t, handoff.policy)

// start builds its own chain handles, so there is no instance here to
// compare them against; what it handed over is pinned by type and order.
// compare it against; what it handed over is pinned by type.
assertAdmissionApplicationTypes(
t,
policy,
reflect.TypeOf((*ethereum.BeaconChain)(nil)),
handoff.policy,
reflect.TypeOf((*ethereum.TbtcChain)(nil)),
)
}
Expand All @@ -120,23 +130,26 @@ func TestInitializeNetwork_AdmissionComposition(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

beaconChain, tbtcChain, blockCounter, _, operatorPrivateKey, err :=
_, tbtcChain, blockCounter, _, operatorPrivateKey, err :=
ethereum.Connect(ctx, backend.ChainConfig(t))
if err != nil {
t.Fatalf("failed to connect to the fixture: %v", err)
}

applications := admissionApplications(beaconChain, tbtcChain)
applications := admissionApplications(tbtcChain)

testutils.AssertIntsEqual(t, "applications", 2, len(applications))
if applications[0] != firewall.Application(beaconChain) {
t.Error("the beacon chain is not the first application evaluated")
}
if applications[1] != firewall.Application(tbtcChain) {
t.Error("the tBTC chain is not the second application evaluated")
testutils.AssertIntsEqual(t, "applications", 1, len(applications))
if applications[0] != firewall.Application(tbtcChain) {
t.Error("the tBTC chain is not the application evaluated")
}

policy := captureAdmissionPolicy(t, func() error {
configuredNetwork := clientConfig.LibP2P
t.Cleanup(func() { clientConfig.LibP2P = configuredNetwork })

clientConfig.LibP2P.Bootstrap = false
clientConfig.LibP2P.Peers = []string{"ordinary-configured-discovery-peer"}

handoff := captureAdmissionPolicy(t, func() error {
_, err := initializeNetwork(
ctx,
applications,
Expand All @@ -146,12 +159,12 @@ func TestInitializeNetwork_AdmissionComposition(t *testing.T) {
return err
})

assertAdmissionTable(t, backend, policy)
assertNoStaticBypass(t, policy)
assertAdmissionTable(t, backend, handoff.policy)
assertNoStaticBypass(t, handoff.policy)

// The policy guards with the very handles it was given, in that order,
// rather than with a set assembled somewhere between here and the network.
assertAdmissionApplications(t, policy, beaconChain, tbtcChain)
// The policy guards with the very handle it was given rather than with one
// assembled somewhere between here and the network.
assertAdmissionApplications(t, handoff.policy, tbtcChain)

t.Run("what a static bypass looks like", func(t *testing.T) {
// The counterexample the assertions above are read against, built as a
Expand Down Expand Up @@ -179,6 +192,136 @@ func TestInitializeNetwork_AdmissionComposition(t *testing.T) {
})
}

// TestStart_AdmissionRevocation verifies that a successful decision is read
// from the wallet registry again. Legacy ownership cannot preserve admission
// after eligible stake is revoked.
func TestStart_AdmissionRevocation(t *testing.T) {
backend := ethtest.New(t, ethtest.AdmissionState(t))
revoked := ethtest.AdmissionCaseNamed(t, "legacy_revoked")
backend.SetEligibleStake(revoked.StakingProvider(t), ethtest.TTokens(40_000))

configuredEthereum := clientConfig.Ethereum
t.Cleanup(func() { clientConfig.Ethereum = configuredEthereum })
clientConfig.Ethereum = backend.ChainConfig(t)

policy := captureAdmissionPolicy(t, func() error {
return start(&cobra.Command{})
}).policy

revokedKey := caseOperatorKey(t, revoked)
backend.ResetCalls()
if err := policy.Validate(revokedKey); err != nil {
t.Fatalf("expected the peer to be initially admitted: %v", err)
}
backend.AssertTrace(t, "initial admission reads", revoked.AdmissionReads(t)...)

backend.SetEligibleStake(revoked.StakingProvider(t), ethtest.TTokens(0))
backend.ResetCalls()
testutils.AssertErrorsSame(t, firewall.ErrNotRecognized, policy.Validate(revokedKey))
backend.AssertTrace(t, "revocation reads", revoked.AdmissionReads(t)...)

eligible := ethtest.AdmissionCaseNamed(t, "post_legacy_authorized")
backend.ResetCalls()
if err := policy.Validate(caseOperatorKey(t, eligible)); err != nil {
t.Fatalf("expected the eligible control to be admitted: %v", err)
}
backend.AssertTrace(t, "eligible control reads", eligible.AdmissionReads(t)...)

unregistered := ethtest.AdmissionCaseNamed(t, "unregistered")
backend.ResetCalls()
testutils.AssertErrorsSame(
t,
firewall.ErrNotRecognized,
policy.Validate(caseOperatorKey(t, unregistered)),
)
backend.AssertTrace(
t,
"unregistered control reads",
unregistered.AdmissionReads(t)...,
)
backend.AssertNoUnexpectedCalls(t)
}

// TestStart_AdmissionRevocationDisconnects verifies that the watchtower
// disconnects a peer once the policy stops admitting it. The connection
// manager and the guard are built here rather than taken from the production
// libp2p wiring, which nothing in this suite exercises directly, so what is
// proven is the watchtower reacting to a Validate error over the production
// policy, not the wiring that installs it.
func TestStart_AdmissionRevocationDisconnects(t *testing.T) {
backend := ethtest.New(t, ethtest.AdmissionState(t))
revoked := ethtest.AdmissionCaseNamed(t, "legacy_revoked")
eligible := ethtest.AdmissionCaseNamed(t, "post_legacy_authorized")
backend.SetEligibleStake(revoked.StakingProvider(t), ethtest.TTokens(40_000))

configuredEthereum := clientConfig.Ethereum
t.Cleanup(func() { clientConfig.Ethereum = configuredEthereum })
clientConfig.Ethereum = backend.ChainConfig(t)

policy := captureAdmissionPolicy(t, func() error {
return start(&cobra.Command{})
}).policy

revokedKey := caseOperatorKey(t, revoked)
eligibleKey := caseOperatorKey(t, eligible)
if err := policy.Validate(revokedKey); err != nil {
t.Fatalf("expected the peer to be initially admitted: %v", err)
}
if err := policy.Validate(eligibleKey); err != nil {
t.Fatalf("expected the control peer to be admitted: %v", err)
}

backend.SetEligibleStake(revoked.StakingProvider(t), ethtest.TTokens(0))
backend.ResetCalls()

provider := localnet.Connect()
const revokedPeer = "revoked-peer"
const eligiblePeer = "eligible-peer"
provider.AddPeer(revokedPeer, revokedKey)
provider.AddPeer(eligiblePeer, eligibleKey)

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
watchtower.NewGuard(
ctx,
&testutils.MockLogger{},
10*time.Millisecond,
policy,
provider.ConnectionManager(),
)

deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
poll := time.NewTicker(5 * time.Millisecond)
defer poll.Stop()

for {
connected := connectedPeerSet(provider.ConnectionManager())
eligibleChecked := operatorLookupObserved(backend, eligible.Operator(t))
if !connected[revokedPeer] && connected[eligiblePeer] && eligibleChecked {
break
}

select {
case <-deadline.C:
t.Fatalf(
"watchtower did not converge; connected peers: %v; chain calls: %v",
connected,
backend.Calls(),
)
case <-poll.C:
}
}

if backend.CallCount(ethtest.RandomBeaconContract, "operatorToStakingProvider") != 0 {
t.Error("watchtower admission read the beacon operator mapping")
}
if backend.CallCount(ethtest.TokenStakingContract, "rolesOf") != 0 {
t.Error("watchtower admission read legacy token staking roles")
}
backend.AssertNoUnexpectedCalls(t)
}

// TestStaticBypassKeys_ReportsAnAllowedKey is the negative control for
// assertNoStaticBypass: a policy carrying one unrelated key has to be reported
// as bypassing the chain. Without it, an assertion that only ever sees empty
Expand Down Expand Up @@ -219,17 +362,21 @@ func TestStaticBypassKeys_ReportsAnAllowedKey(t *testing.T) {
var errNetworkNotOpened = errors.New("the network provider is not opened here")

// captureAdmissionPolicy runs a piece of the client's start path with the
// network provider constructor stubbed out, and returns the firewall that path
// handed it. The stub fails, so the path unwinds at the handoff and nothing
// network provider constructor stubbed out, and returns the firewall handed
// to it. The stub fails, so the path unwinds at the handoff and nothing
// behind it is started.
func captureAdmissionPolicy(t *testing.T, run func() error) net.Firewall {
type admissionHandoff struct {
policy net.Firewall
}

func captureAdmissionPolicy(t *testing.T, run func() error) admissionHandoff {
t.Helper()

var captured net.Firewall
var captured admissionHandoff
handoffs := 0

opened := connectNetwork
t.Cleanup(func() { connectNetwork = opened })
defer func() { connectNetwork = opened }()

connectNetwork = func(
_ context.Context,
Expand All @@ -240,7 +387,7 @@ func captureAdmissionPolicy(t *testing.T, run func() error) net.Firewall {
_ ...libp2p.ConnectOption,
) (net.Provider, error) {
handoffs++
captured = policy
captured.policy = policy
return nil, errNetworkNotOpened
}

Expand All @@ -254,19 +401,43 @@ func captureAdmissionPolicy(t *testing.T, run func() error) net.Firewall {

testutils.AssertIntsEqual(t, "network handoffs", 1, handoffs)

if captured == nil {
if captured.policy == nil {
t.Fatal("the network layer was opened with no firewall")
}

return captured
}

func connectedPeerSet(connectionManager net.ConnectionManager) map[string]bool {
connected := make(map[string]bool)
for _, peer := range connectionManager.ConnectedPeers() {
connected[peer] = true
}

return connected
}

func operatorLookupObserved(
backend *ethtest.Backend,
operatorAddress common.Address,
) bool {
for _, call := range backend.CallsTo(
ethtest.WalletRegistryContract,
"operatorToStakingProvider",
) {
if len(call.Args) == 1 && call.Args[0] == operatorAddress {
return true
}
}

return false
}

// assertAdmissionTable drives every identity of the fixed admission table
// through the given policy and fails unless both the verdict and the reads
// that verdict cost are the ones the table pins. The reads are asserted
// alongside the verdict because a verdict alone cannot tell an on-chain
// decision apart from a bypass, nor beacon-first evaluation apart from the
// reverse.
// decision apart from a bypass or a legacy fallback.
func assertAdmissionTable(
t *testing.T,
backend *ethtest.Backend,
Expand All @@ -290,10 +461,9 @@ func assertAdmissionTable(
testutils.AssertErrorsSame(t, firewall.ErrNotRecognized, err)
}

// The beacon branch opens every one of these reads, so this
// identity was judged by the chain rather than short-circuited
// ahead of it, and the tBTC branch is read only where the beacon
// declined.
// Every expected read belongs to the wallet registry, so this
// identity was judged by current eligibility without a static or
// legacy fallback.
backend.AssertTrace(
t,
"admission reads",
Expand Down
Loading
Loading