diff --git a/CHANGELOG.md b/CHANGELOG.md index d6bfd69c4..389910672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bumped Rollups Contracts to 2.0 +- Changed `cartesi-rollups-cli app register --prt` to assert the detected consensus type instead of forcing PRT registration, and `cartesi-rollups-cli deploy application --consensus` to reject PRT consensus addresses - Normalized boolean configuration parameters (`CARTESI_LEGACY_BLOCKCHAIN_ENABLED`, `CARTESI_FEATURE_CLAIMER_ENABLED`, `CARTESI_FEATURE_MACHINE_HASH_CHECK_ENABLED`, `CARTESI_EXPERIMENTAL_SERVER_MANAGER_LOG_BYPASS_ENABLED` and `CARTESI_LOG_PRETTY_ENABLED`) and adjusted their logic accordingly ### Removed @@ -24,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed `server-manager` from rollups-node Docker image - Removed support to host mode +### Fixed + +- Fixed `cartesi-rollups-cli` consensus type detection to use EIP-165 instead of probing a quorum method + ## [1.5.1] 2024-08-26 ### Added diff --git a/cmd/cartesi-rollups-cli/root/app/register/register.go b/cmd/cartesi-rollups-cli/root/app/register/register.go index d15266d43..b2d7aff76 100644 --- a/cmd/cartesi-rollups-cli/root/app/register/register.go +++ b/cmd/cartesi-rollups-cli/root/app/register/register.go @@ -94,7 +94,8 @@ func init() { "Enable or disable machine hash check (DO NOT DISABLE IN PRODUCTION)") cobra.CheckErr(viper.BindPFlag(config.FEATURE_MACHINE_HASH_CHECK_ENABLED, Cmd.Flags().Lookup("machine-hash-check"))) - Cmd.Flags().BoolVarP(&applicationTypePRT, "prt", "", false, "Register as PRT application.") + Cmd.Flags().BoolVarP(&applicationTypePRT, "prt", "", false, + "Assert that the application consensus is a PRT (Dave) consensus.") origHelpFunc := Cmd.HelpFunc() Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { @@ -160,7 +161,18 @@ func run(cmd *cobra.Command, _ []string) { } } - if !cmd.Flags().Changed("epoch-length") && !applicationTypePRT { + consensusType, err := getConsensusType(ctx, consensus) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to detect consensus type: %v\n", err) + os.Exit(1) + } + if err := validateRegistrationConsensusType(consensusType, applicationTypePRT); err != nil { + fmt.Fprintf(os.Stderr, "Consensus type validation failed: %v\n", err) + os.Exit(1) + } + isPRT := consensusType == model.Consensus_PRT + + if !cmd.Flags().Changed("epoch-length") && !isPRT { epochLength, err = getEpochLength(ctx, consensus) if err != nil { fmt.Fprintf(os.Stderr, "Failed to get epoch length from consensus: %v\n", @@ -215,12 +227,6 @@ func run(cmd *cobra.Command, _ []string) { os.Exit(1) } - consensusType, err := getConsensusType(ctx, consensus, applicationTypePRT) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to detect consensus type: %v\n", err) - os.Exit(1) - } - application := model.Application{ Name: validName, IApplicationAddress: address, @@ -368,19 +374,10 @@ func getClaimStagingPeriod( return ethutil.GetClaimStagingPeriod(ctx, client, consensusAddr) } -type quorumConsensusProbe interface { - NumOfValidators(opts *bind.CallOpts) (*big.Int, error) -} - func getConsensusType( ctx context.Context, consensusAddr common.Address, - applicationTypePRT bool, ) (model.Consensus, error) { - if applicationTypePRT { - return model.Consensus_PRT, nil - } - ethEndpoint, err := config.GetBlockchainHttpEndpoint() if err != nil { return "", fmt.Errorf("failed to get blockchain http endpoint address: %w", err) @@ -389,28 +386,40 @@ func getConsensusType( if err != nil { return "", fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) } - quorum, err := iquorum.NewIQuorum(consensusAddr, client) + + callOpts := &bind.CallOpts{Context: ctx} + caller, err := iconsensus.NewIConsensusCaller(consensusAddr, client) + if err != nil { + return "", fmt.Errorf("failed to bind consensus contract: %w", err) + } + detected, _, err := ethutil.DetectConsensusType(callOpts, caller) if err != nil { return "", err } - return consensusTypeFromQuorumProbe(applicationTypePRT, quorum) + + if detected == ethutil.ConsensusQuorum { + quorum, err := iquorum.NewIQuorum(consensusAddr, client) + if err != nil { + return "", err + } + if err := ethutil.ValidateQuorum(callOpts, quorum); err != nil { + return "", err + } + } + + return detected.ToModel(), nil } -func consensusTypeFromQuorumProbe( +// validateRegistrationConsensusType keeps --prt as an assertion now that the +// consensus type comes from the chain instead of the flag. +func validateRegistrationConsensusType( + consensusType model.Consensus, applicationTypePRT bool, - probe quorumConsensusProbe, -) (model.Consensus, error) { - if applicationTypePRT { - return model.Consensus_PRT, nil - } - numOfValidators, err := probe.NumOfValidators(nil) - if err != nil { - return model.Consensus_Authority, nil - } - if numOfValidators == nil || numOfValidators.Sign() == 0 { - return "", fmt.Errorf("quorum consensus reports zero validators") +) error { + if applicationTypePRT && consensusType != model.Consensus_PRT { + return fmt.Errorf("--prt was passed, but the consensus is %s", consensusType) } - return model.Consensus_Quorum, nil + return nil } func readApplicationWithdrawalConfig( diff --git a/cmd/cartesi-rollups-cli/root/app/register/register_test.go b/cmd/cartesi-rollups-cli/root/app/register/register_test.go index 5720161cd..3dc54d2fb 100644 --- a/cmd/cartesi-rollups-cli/root/app/register/register_test.go +++ b/cmd/cartesi-rollups-cli/root/app/register/register_test.go @@ -4,71 +4,56 @@ package register import ( - "errors" - "math/big" "testing" "github.com/cartesi/rollups-node/internal/model" - "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/stretchr/testify/require" ) -type quorumConsensusProbeStub struct { - numOfValidators *big.Int - err error - called bool -} - -func (p *quorumConsensusProbeStub) NumOfValidators(_ *bind.CallOpts) (*big.Int, error) { - p.called = true - return p.numOfValidators, p.err -} - -func TestConsensusTypeFromQuorumProbe_PRTSkipsProbe(t *testing.T) { - probe := &quorumConsensusProbeStub{ - numOfValidators: big.NewInt(3), +func TestValidateRegistrationConsensusType(t *testing.T) { + tests := []struct { + name string + consensusType model.Consensus + applicationTypePRT bool + wantErr string + }{ + { + name: "prt flag with prt consensus", + consensusType: model.Consensus_PRT, + applicationTypePRT: true, + }, + { + name: "prt flag with authority consensus", + consensusType: model.Consensus_Authority, + applicationTypePRT: true, + wantErr: "--prt was passed", + }, + { + name: "prt flag with quorum consensus", + consensusType: model.Consensus_Quorum, + applicationTypePRT: true, + wantErr: "--prt was passed", + }, + { + name: "no prt flag with prt consensus", + consensusType: model.Consensus_PRT, + }, + { + name: "no prt flag with authority consensus", + consensusType: model.Consensus_Authority, + }, } - consensusType, err := consensusTypeFromQuorumProbe(true, probe) - - require.NoError(t, err) - require.Equal(t, model.Consensus_PRT, consensusType) - require.False(t, probe.called) -} - -func TestConsensusTypeFromQuorumProbe_AuthorityWhenProbeFails(t *testing.T) { - probe := &quorumConsensusProbeStub{ - err: errors.New("execution reverted"), + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRegistrationConsensusType(tt.consensusType, tt.applicationTypePRT) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) } - - consensusType, err := consensusTypeFromQuorumProbe(false, probe) - - require.NoError(t, err) - require.Equal(t, model.Consensus_Authority, consensusType) - require.True(t, probe.called) -} - -func TestConsensusTypeFromQuorumProbe_QuorumWhenValidatorsExist(t *testing.T) { - probe := &quorumConsensusProbeStub{ - numOfValidators: big.NewInt(3), - } - - consensusType, err := consensusTypeFromQuorumProbe(false, probe) - - require.NoError(t, err) - require.Equal(t, model.Consensus_Quorum, consensusType) - require.True(t, probe.called) -} - -func TestConsensusTypeFromQuorumProbe_RejectsZeroValidatorQuorum(t *testing.T) { - probe := &quorumConsensusProbeStub{ - numOfValidators: big.NewInt(0), - } - - _, err := consensusTypeFromQuorumProbe(false, probe) - - require.ErrorContains(t, err, "zero validators") - require.True(t, probe.called) } func TestRegisterCommandDoesNotExposeLegacyInputBoxFlags(t *testing.T) { diff --git a/cmd/cartesi-rollups-cli/root/contract/contract.go b/cmd/cartesi-rollups-cli/root/contract/contract.go index 5b8acd526..e5721259c 100644 --- a/cmd/cartesi-rollups-cli/root/contract/contract.go +++ b/cmd/cartesi-rollups-cli/root/contract/contract.go @@ -15,7 +15,6 @@ import ( "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" - "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -84,89 +83,17 @@ func runContract(cmd *cobra.Command, _ []string) { cobra.CheckErr(err) } -// consensusType represents the detected on-chain consensus mechanism. -type consensusType int +// consensusType aliases the shared detector's result type. The local names keep +// the inspector's existing switches and output labels unchanged. +type consensusType = ethutil.ConsensusType const ( - consensusUnknown consensusType = iota - consensusAuthority // IAuthority (single validator, Ownable) - consensusQuorum // IQuorum (multi-validator, majority threshold) - consensusDave // IDaveConsensus (PRT) + consensusUnknown = ethutil.ConsensusUnknown + consensusAuthority = ethutil.ConsensusAuthority + consensusQuorum = ethutil.ConsensusQuorum + consensusDave = ethutil.ConsensusPRT ) -func (t consensusType) String() string { - switch t { - case consensusAuthority: - return "Authority" - case consensusQuorum: - return "Quorum" - case consensusDave: - return "DaveConsensus (PRT)" - case consensusUnknown: - return "Unknown" - } - return "Unknown" -} - -// ERC-165 interface IDs for consensus type detection. -// Solidity's type(I).interfaceId XORs only functions DEFINED in I, excluding inherited ones. -var ( - // IDataProvider: provideMerkleRootOfInput(uint256,bytes) - iDataProviderInterfaceID = [4]byte{0x7a, 0x96, 0xf4, 0x80} - // IConsensus interface IDs by version (own functions only, excluding inherited - // isOutputsMerkleRootValid). Checked in order; first match wins. - // v3.0.0-alpha: computed at init from the binding's ABI to stay in lockstep - // with the contract — see computeIConsensusV3InterfaceID. - iConsensusInterfaceIDv30 = computeIConsensusV3InterfaceID() - // v2.2.0: submitClaim ^ getEpochLength ^ getNumberOfAcceptedClaims ^ getNumberOfSubmittedClaims - iConsensusInterfaceIDv220 = [4]byte{0x90, 0xb2, 0xf3, 0x46} - // v2.1.x: submitClaim ^ getEpochLength ^ getNumberOfAcceptedClaims (no getNumberOfSubmittedClaims) - iConsensusInterfaceIDv21x = [4]byte{0x7e, 0xec, 0xfc, 0xec} - // IQuorum: own 7 functions (excluding inherited IConsensus). Signatures (types) - // are identical across v2.1.x / v2.2.0 / v3 — only Solidity param NAMES changed, - // which do not affect the selector. - iQuorumInterfaceID = [4]byte{0x3c, 0x92, 0x5a, 0x62} -) - -// computeIConsensusV3InterfaceID returns the ERC-165 interface ID of v3 IConsensus. -// Per Solidity's `type(I).interfaceId`, the ID is the XOR of selectors of the -// functions DECLARED in IConsensus (8 of them); inherited functions -// (`isOutputsMerkleRootValid` from IOutputsMerkleRootValidator, `version` from -// IVersionGetter, IApplicationChecker which has no functions) are excluded. -// Computed at package init from the binding's embedded ABI so a contract-level -// rename or signature change is automatically reflected. -func computeIConsensusV3InterfaceID() [4]byte { - parsed, err := iconsensus.IConsensusMetaData.GetAbi() - if err != nil { - panic(fmt.Errorf("computeIConsensusV3InterfaceID: parse ABI: %w", err)) - } - // Methods declared in IConsensus.sol (v3), excluding inherited functions. - methodNames := []string{ - "submitClaim", - "acceptClaim", - "getEpochLength", - "getClaimStagingPeriod", - "getNumberOfAcceptedClaims", - "getNumberOfStagedClaims", - "getNumberOfSubmittedClaims", - "getClaim", - } - var id [4]byte - for _, name := range methodNames { - m, ok := parsed.Methods[name] - if !ok { - panic(fmt.Errorf("computeIConsensusV3InterfaceID: method %q not found in IConsensus ABI", name)) - } - if len(m.ID) != 4 { //nolint:mnd // ABI method selectors are exactly four bytes. - panic(fmt.Errorf("computeIConsensusV3InterfaceID: method %q selector is %d bytes, expected 4", name, len(m.ID))) - } - for i := range 4 { - id[i] ^= m.ID[i] - } - } - return id -} - // chainClient holds the shared Ethereum client and call options for all subcommands. // All view functions are called through this client to ensure consistent block-number // queries. The block number is ALWAYS pinned to a concrete value (never nil/latest). @@ -285,18 +212,6 @@ func (c *chainClient) ensureContract(addr common.Address, label string) error { return nil } -// iConsensusVersion pairs an ERC-165 interface ID with a human-readable contract version label. -type iConsensusVersion struct { - id [4]byte - version string -} - -var iConsensusVersions = []iConsensusVersion{ - {iConsensusInterfaceIDv30, "v3.0.0-alpha"}, - {iConsensusInterfaceIDv220, "v2.2.0"}, - {iConsensusInterfaceIDv21x, "v2.1.x"}, -} - // detectConsensus uses ERC-165 supportsInterface to determine the consensus type. // Checks IDataProvider → Dave, IQuorum → Quorum, IConsensus → Authority. // The returned contractVersion is non-empty only for Authority/Quorum, indicating which @@ -304,51 +219,15 @@ var iConsensusVersions = []iConsensusVersion{ func (c *chainClient) detectConsensus( consensusAddr common.Address, ) (consensusType, string, error) { - caller, err := idaveconsensus.NewIDaveConsensusCaller(consensusAddr, c.eth) + caller, err := iconsensus.NewIConsensusCaller(consensusAddr, c.eth) if err != nil { return consensusUnknown, "", fmt.Errorf("bind consensus contract: %w", err) } - - isDave, err := caller.SupportsInterface(c.callOpts, iDataProviderInterfaceID) + detected, version, err := ethutil.DetectConsensusType(c.callOpts, caller) if err != nil { - return consensusUnknown, "", fmt.Errorf("supportsInterface(IDataProvider): %w", err) + return detected, version, fmt.Errorf("consensus at %s: %w", consensusAddr, err) } - if isDave { - return consensusDave, "", nil - } - - isQuorum, err := caller.SupportsInterface(c.callOpts, iQuorumInterfaceID) - if err != nil { - return consensusUnknown, "", fmt.Errorf("supportsInterface(IQuorum): %w", err) - } - if isQuorum { - // Quorum is also an IConsensus; probe the current IConsensus interface - // to surface the contract version. Older Quorum versions (pre-v3) report - // empty and the caller renders the label without the version suffix. - isCurrent, err := caller.SupportsInterface(c.callOpts, iConsensusInterfaceIDv30) - if err != nil { - return consensusUnknown, "", fmt.Errorf( - "supportsInterface(IConsensus v3.0.0-alpha) for Quorum: %w", err) - } - if isCurrent { - return consensusQuorum, "v3.0.0-alpha", nil - } - return consensusQuorum, "", nil - } - - for _, cv := range iConsensusVersions { - isConsensus, err := caller.SupportsInterface(c.callOpts, cv.id) - if err != nil { - return consensusUnknown, "", fmt.Errorf("supportsInterface(IConsensus): %w", err) - } - if isConsensus { - return consensusAuthority, cv.version, nil - } - } - - return consensusUnknown, "", fmt.Errorf( - "consensus at %s does not implement IDataProvider, IQuorum, or IConsensus"+ - " — wrong address or unsupported consensus type", consensusAddr) + return detected, version, nil } // safeUint64 converts a *big.Int to uint64, returning an error if the value is nil or overflows. diff --git a/cmd/cartesi-rollups-cli/root/contract/contract_test.go b/cmd/cartesi-rollups-cli/root/contract/contract_test.go index 35c6a411c..caedda1d4 100644 --- a/cmd/cartesi-rollups-cli/root/contract/contract_test.go +++ b/cmd/cartesi-rollups-cli/root/contract/contract_test.go @@ -161,23 +161,3 @@ func TestValidateHash(t *testing.T) { }) } } - -func TestConsensusTypeString(t *testing.T) { - assert.Equal(t, "Authority", consensusAuthority.String()) - assert.Equal(t, "Quorum", consensusQuorum.String()) - assert.Equal(t, "DaveConsensus (PRT)", consensusDave.String()) - assert.Equal(t, "Unknown", consensusUnknown.String()) -} - -// TestIConsensusV3InterfaceID locks down the v3 interface ID computation. -// If a method is renamed in the binding or this list drifts from the -// IConsensus.sol interface, this test surfaces the change as a value mismatch. -func TestIConsensusV3InterfaceID(t *testing.T) { - // Non-zero, distinct from the v2 IDs. - assert.NotEqual(t, [4]byte{}, iConsensusInterfaceIDv30, - "v3 interface ID should be non-zero") - assert.NotEqual(t, iConsensusInterfaceIDv220, iConsensusInterfaceIDv30, - "v3 interface ID should differ from v2.2.0") - assert.NotEqual(t, iConsensusInterfaceIDv21x, iConsensusInterfaceIDv30, - "v3 interface ID should differ from v2.1.x") -} diff --git a/cmd/cartesi-rollups-cli/root/deploy/application.go b/cmd/cartesi-rollups-cli/root/deploy/application.go index beaadfefd..9bc5e92bb 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/application.go +++ b/cmd/cartesi-rollups-cli/root/deploy/application.go @@ -6,6 +6,7 @@ package deploy import ( "context" "encoding/json" + "errors" "fmt" "os" "strings" @@ -600,34 +601,54 @@ func customConsensus( return common.Address{}, 0, 0, "", err } - consensus, err := iconsensus.NewIConsensus(consensusAddress, client) + callOpts := &bind.CallOpts{Context: ctx} + consensusCaller, err := iconsensus.NewIConsensusCaller(consensusAddress, client) if err != nil { return common.Address{}, 0, 0, "", err } - callOpts := &bind.CallOpts{Context: ctx} - epochLengthBig, err := consensus.GetEpochLength(callOpts) + detected, err := classifyApplicationConsensus(callOpts, consensusCaller) + if err != nil { + return common.Address{}, 0, 0, "", fmt.Errorf("consensus %s: %w", consensusAddress, err) + } + + if detected == ethutil.ConsensusQuorum { + quorum, err := iquorum.NewIQuorum(consensusAddress, client) + if err != nil { + return common.Address{}, 0, 0, "", err + } + if err := ethutil.ValidateQuorum(callOpts, quorum); err != nil { + return common.Address{}, 0, 0, "", err + } + } + + epochLengthBig, err := consensusCaller.GetEpochLength(callOpts) if err != nil { return common.Address{}, 0, 0, "", fmt.Errorf("failed to retrieve consensus epoch length: %w", err) } - claimStagingPeriodBig, err := consensus.GetClaimStagingPeriod(callOpts) + claimStagingPeriodBig, err := consensusCaller.GetClaimStagingPeriod(callOpts) if err != nil { return common.Address{}, 0, 0, "", fmt.Errorf("failed to retrieve consensus claim staging period: %w", err) } - consensusType := model.Consensus_Authority - quorum, err := iquorum.NewIQuorum(consensusAddress, client) + return consensusAddress, epochLengthBig.Uint64(), claimStagingPeriodBig.Uint64(), detected.ToModel(), nil +} + +// classifyApplicationConsensus detects the consensus behind an existing +// --consensus address. It aborts on probe errors and rejects a Dave/PRT +// consensus, which must be deployed through --prt. +func classifyApplicationConsensus( + callOpts *bind.CallOpts, + caller ethutil.SupportsInterfaceCaller, +) (ethutil.ConsensusType, error) { + detected, _, err := ethutil.DetectConsensusType(callOpts, caller) if err != nil { - return common.Address{}, 0, 0, "", err + return ethutil.ConsensusUnknown, fmt.Errorf("failed to detect consensus type: %w", err) } - numOfValidators, err := quorum.NumOfValidators(callOpts) - if err == nil { - if numOfValidators.Sign() == 0 { - return common.Address{}, 0, 0, "", fmt.Errorf("quorum consensus reports zero validators") - } - consensusType = model.Consensus_Quorum + if detected == ethutil.ConsensusPRT { + return ethutil.ConsensusUnknown, errors.New( + "PRT (Dave) consensus; use --prt to deploy a PRT application") } - - return consensusAddress, epochLengthBig.Uint64(), claimStagingPeriodBig.Uint64(), consensusType, nil + return detected, nil } diff --git a/cmd/cartesi-rollups-cli/root/deploy/application_test.go b/cmd/cartesi-rollups-cli/root/deploy/application_test.go index cbc09d0b2..e789095a9 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/application_test.go +++ b/cmd/cartesi-rollups-cli/root/deploy/application_test.go @@ -4,6 +4,7 @@ package deploy import ( + "errors" "testing" "github.com/cartesi/rollups-node/internal/cli" @@ -79,3 +80,33 @@ func TestApplicationDeploymentValidatesBeforeRunning(t *testing.T) { }) } } + +type consensusProbeStub struct { + supported bool + err error +} + +func (s consensusProbeStub) SupportsInterface(_ *bind.CallOpts, _ [4]byte) (bool, error) { + return s.supported, s.err +} + +func TestClassifyApplicationConsensus(t *testing.T) { + callOpts := &bind.CallOpts{Context: t.Context()} + + t.Run("probe error aborts classification", func(t *testing.T) { + _, err := classifyApplicationConsensus(callOpts, consensusProbeStub{ + err: errors.New("provider 429"), + }) + require.ErrorContains(t, err, "failed to detect consensus type") + }) + + t.Run("prt consensus requires the prt deployment path", func(t *testing.T) { + _, err := classifyApplicationConsensus(callOpts, consensusProbeStub{supported: true}) + require.ErrorContains(t, err, "use --prt") + }) + + t.Run("unsupported consensus is rejected", func(t *testing.T) { + _, err := classifyApplicationConsensus(callOpts, consensusProbeStub{}) + require.ErrorContains(t, err, "does not implement") + }) +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/broadcast_test.go b/cmd/cartesi-rollups-cli/root/deploy/broadcast_test.go index 821e9018c..9aa0de758 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/broadcast_test.go +++ b/cmd/cartesi-rollups-cli/root/deploy/broadcast_test.go @@ -79,6 +79,17 @@ func TestDeploymentCLIReportsPredictedAddress(t *testing.T) { predictedAddress := common.HexToAddress("0x3000") secondaryAddress := common.HexToAddress("0x4000") applicationArgs := []string{"application", "--register=false", "--template-hash=" + (common.Hash{}).Hex()} + + consensusABI, err := iconsensus.IConsensusMetaData.GetAbi() + require.NoError(t, err) + supportsInterfaceMethod := consensusABI.Methods["supportsInterface"] + var supportsInterfaceID [4]byte + copy(supportsInterfaceID[:], supportsInterfaceMethod.ID) + unsupportedConsensus, err := supportsInterfaceMethod.Outputs.Pack(false) + require.NoError(t, err) + supportedConsensus, err := supportsInterfaceMethod.Outputs.Pack(true) + require.NoError(t, err) + for _, test := range []struct { name string args []string @@ -94,6 +105,9 @@ func TestDeploymentCLIReportsPredictedAddress(t *testing.T) { backend := &deploymentCLIBackend{ responses: map[string]hexutil.Bytes{}, sent: make(chan *types.Transaction, 2), predictedAddress: predictedAddress, secondaryAddress: secondaryAddress, + supportsInterfaceID: supportsInterfaceID, + supportsInterfaceResponses: []hexutil.Bytes{unsupportedConsensus, unsupportedConsensus, supportedConsensus}, + supportsInterfaceFallback: unsupportedConsensus, } for _, response := range []struct { metadata *bind.MetaData @@ -164,6 +178,11 @@ type deploymentCLIBackend struct { broadcast atomic.Bool receiptReads atomic.Int32 postBroadcastReads atomic.Int32 + + supportsInterfaceID [4]byte + supportsInterfaceResponses []hexutil.Bytes + supportsInterfaceFallback hexutil.Bytes + supportsInterfaceCalls atomic.Int32 } func (*deploymentCLIBackend) ChainId(context.Context) *hexutil.Big { //nolint:revive // Ethereum RPC method eth_chainId. @@ -203,6 +222,14 @@ func (b *deploymentCLIBackend) Call(_ context.Context, call struct { b.postBroadcastReads.Add(1) } if len(call.Input) >= 4 { + if b.supportsInterfaceID != ([4]byte{}) && + bytes.Equal(call.Input[:4], b.supportsInterfaceID[:]) { + index := int(b.supportsInterfaceCalls.Add(1)) - 1 + if index < len(b.supportsInterfaceResponses) { + return b.supportsInterfaceResponses[index], nil + } + return b.supportsInterfaceFallback, nil + } if response, found := b.responses[string(call.Input[:4])]; found { return response, nil } diff --git a/pkg/ethutil/detect_consensus.go b/pkg/ethutil/detect_consensus.go new file mode 100644 index 000000000..16a196db3 --- /dev/null +++ b/pkg/ethutil/detect_consensus.go @@ -0,0 +1,201 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "errors" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" +) + +// ConsensusType represents the detected on-chain consensus mechanism. +type ConsensusType int + +const ( + ConsensusUnknown ConsensusType = iota + ConsensusAuthority // IAuthority (single validator, Ownable) + ConsensusQuorum // IQuorum (multi-validator, majority threshold) + ConsensusPRT // IDaveConsensus (PRT) +) + +// String returns the human-readable consensus label. +func (t ConsensusType) String() string { + switch t { + case ConsensusAuthority: + return "Authority" + case ConsensusQuorum: + return "Quorum" + case ConsensusPRT: + return "DaveConsensus (PRT)" + case ConsensusUnknown: + return "Unknown" + } + return "Unknown" +} + +// ToModel maps the detected consensus type to the node's persisted enum. +// ConsensusUnknown maps to the empty value. +func (t ConsensusType) ToModel() model.Consensus { + switch t { + case ConsensusAuthority: + return model.Consensus_Authority + case ConsensusQuorum: + return model.Consensus_Quorum + case ConsensusPRT: + return model.Consensus_PRT + case ConsensusUnknown: + } + return "" +} + +// ERC-165 interface IDs for consensus type detection. +// Solidity's type(I).interfaceId XORs only functions DEFINED in I, excluding inherited ones. +var ( + // IDataProvider: provideMerkleRootOfInput(uint256,bytes) + iDataProviderInterfaceID = [4]byte{0x7a, 0x96, 0xf4, 0x80} + // IConsensus interface IDs by version (own functions only, excluding inherited + // isOutputsMerkleRootValid). Checked in order; first match wins. + // v3.0.0-alpha: computed at init from the binding's ABI to stay in lockstep + // with the contract — see computeIConsensusV3InterfaceID. + iConsensusInterfaceIDv30 = computeIConsensusV3InterfaceID() + // v2.2.0: submitClaim ^ getEpochLength ^ getNumberOfAcceptedClaims ^ getNumberOfSubmittedClaims + iConsensusInterfaceIDv220 = [4]byte{0x90, 0xb2, 0xf3, 0x46} + // v2.1.x: submitClaim ^ getEpochLength ^ getNumberOfAcceptedClaims (no getNumberOfSubmittedClaims) + iConsensusInterfaceIDv21x = [4]byte{0x7e, 0xec, 0xfc, 0xec} + // IQuorum: own 7 functions (excluding inherited IConsensus). Signatures (types) + // are identical across v2.1.x / v2.2.0 / v3 — only Solidity param NAMES changed, + // which do not affect the selector. + iQuorumInterfaceID = [4]byte{0x3c, 0x92, 0x5a, 0x62} +) + +// computeIConsensusV3InterfaceID returns the ERC-165 interface ID of v3 IConsensus. +// Per Solidity's `type(I).interfaceId`, the ID is the XOR of selectors of the +// functions DECLARED in IConsensus (8 of them); inherited functions +// (`isOutputsMerkleRootValid` from IOutputsMerkleRootValidator, `version` from +// IVersionGetter, IApplicationChecker which has no functions) are excluded. +// Computed at package init from the binding's embedded ABI so a contract-level +// rename or signature change is automatically reflected. +func computeIConsensusV3InterfaceID() [4]byte { + parsed, err := iconsensus.IConsensusMetaData.GetAbi() + if err != nil { + panic(fmt.Errorf("computeIConsensusV3InterfaceID: parse ABI: %w", err)) + } + // Methods declared in IConsensus.sol (v3), excluding inherited functions. + methodNames := []string{ + "submitClaim", + "acceptClaim", + "getEpochLength", + "getClaimStagingPeriod", + "getNumberOfAcceptedClaims", + "getNumberOfStagedClaims", + "getNumberOfSubmittedClaims", + "getClaim", + } + var id [4]byte + for _, name := range methodNames { + m, ok := parsed.Methods[name] + if !ok { + panic(fmt.Errorf("computeIConsensusV3InterfaceID: method %q not found in IConsensus ABI", name)) + } + if len(m.ID) != 4 { //nolint:mnd // ABI method selectors are exactly four bytes. + panic(fmt.Errorf("computeIConsensusV3InterfaceID: method %q selector is %d bytes, expected 4", name, len(m.ID))) + } + for i := range 4 { + id[i] ^= m.ID[i] + } + } + return id +} + +// SupportsInterfaceCaller is the subset of an ERC-165 contract binding needed +// to identify a consensus implementation. +type SupportsInterfaceCaller interface { + SupportsInterface(opts *bind.CallOpts, interfaceID [4]byte) (bool, error) +} + +// ValidatorCounter is the subset of an IQuorum binding needed to validate the +// validator set of a detected Quorum consensus. +type ValidatorCounter interface { + NumOfValidators(opts *bind.CallOpts) (*big.Int, error) +} + +// iConsensusVersion pairs an ERC-165 interface ID with a human-readable contract version label. +type iConsensusVersion struct { + id [4]byte + version string +} + +var iConsensusVersions = []iConsensusVersion{ + {iConsensusInterfaceIDv30, "v3.0.0-alpha"}, + {iConsensusInterfaceIDv220, "v2.2.0"}, + {iConsensusInterfaceIDv21x, "v2.1.x"}, +} + +// DetectConsensusType uses ERC-165 supportsInterface to determine the consensus type. +// Checks IDataProvider -> PRT, IQuorum -> Quorum, IConsensus -> Authority. +// Any read error aborts detection; there is no fallback classification. +// The returned contractVersion is non-empty only for Authority/Quorum, indicating which +// IConsensus interface version was matched. +func DetectConsensusType( + opts *bind.CallOpts, + caller SupportsInterfaceCaller, +) (ConsensusType, string, error) { + isPRT, err := caller.SupportsInterface(opts, iDataProviderInterfaceID) + if err != nil { + return ConsensusUnknown, "", fmt.Errorf("supportsInterface(IDataProvider): %w", err) + } + if isPRT { + return ConsensusPRT, "", nil + } + + isQuorum, err := caller.SupportsInterface(opts, iQuorumInterfaceID) + if err != nil { + return ConsensusUnknown, "", fmt.Errorf("supportsInterface(IQuorum): %w", err) + } + if isQuorum { + // Quorum is also an IConsensus; probe the current IConsensus interface + // to surface the contract version. Older Quorum versions (pre-v3) report + // empty and the caller renders the label without the version suffix. + isCurrent, err := caller.SupportsInterface(opts, iConsensusInterfaceIDv30) + if err != nil { + return ConsensusUnknown, "", fmt.Errorf( + "supportsInterface(IConsensus v3.0.0-alpha) for Quorum: %w", err) + } + if isCurrent { + return ConsensusQuorum, "v3.0.0-alpha", nil + } + return ConsensusQuorum, "", nil + } + + for _, cv := range iConsensusVersions { + isConsensus, err := caller.SupportsInterface(opts, cv.id) + if err != nil { + return ConsensusUnknown, "", fmt.Errorf("supportsInterface(IConsensus): %w", err) + } + if isConsensus { + return ConsensusAuthority, cv.version, nil + } + } + + return ConsensusUnknown, "", errors.New( + "contract does not implement IDataProvider, IQuorum, or IConsensus" + + " — wrong address or unsupported consensus type") +} + +// ValidateQuorum verifies that a Quorum consensus has at least one validator. +// It rejects a nil count instead of dereferencing it. +func ValidateQuorum(opts *bind.CallOpts, counter ValidatorCounter) error { + numOfValidators, err := counter.NumOfValidators(opts) + if err != nil { + return fmt.Errorf("NumOfValidators: %w", err) + } + if numOfValidators == nil || numOfValidators.Sign() == 0 { + return errors.New("quorum consensus reports zero validators") + } + return nil +} diff --git a/pkg/ethutil/detect_consensus_test.go b/pkg/ethutil/detect_consensus_test.go new file mode 100644 index 000000000..57c11140d --- /dev/null +++ b/pkg/ethutil/detect_consensus_test.go @@ -0,0 +1,195 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type supportsInterfaceStub struct { + supported map[[4]byte]bool + errs map[[4]byte]error + opts *bind.CallOpts +} + +func (s *supportsInterfaceStub) SupportsInterface( + opts *bind.CallOpts, interfaceID [4]byte, +) (bool, error) { + s.opts = opts + if err := s.errs[interfaceID]; err != nil { + return false, err + } + return s.supported[interfaceID], nil +} + +func TestDetectConsensusType(t *testing.T) { + callOpts := &bind.CallOpts{Context: t.Context()} + + tests := []struct { + name string + supported map[[4]byte]bool + errs map[[4]byte]error + want ConsensusType + wantVersion string + wantErr string + }{ + { + name: "dave", + supported: map[[4]byte]bool{iDataProviderInterfaceID: true}, + want: ConsensusPRT, + }, + { + name: "quorum v3", + supported: map[[4]byte]bool{ + iQuorumInterfaceID: true, + iConsensusInterfaceIDv30: true, + }, + want: ConsensusQuorum, + wantVersion: "v3.0.0-alpha", + }, + { + name: "quorum legacy", + supported: map[[4]byte]bool{iQuorumInterfaceID: true}, + want: ConsensusQuorum, + }, + { + name: "authority v3", + supported: map[[4]byte]bool{iConsensusInterfaceIDv30: true}, + want: ConsensusAuthority, + wantVersion: "v3.0.0-alpha", + }, + { + name: "authority v2.2.0", + supported: map[[4]byte]bool{iConsensusInterfaceIDv220: true}, + want: ConsensusAuthority, + wantVersion: "v2.2.0", + }, + { + name: "authority v2.1.x", + supported: map[[4]byte]bool{iConsensusInterfaceIDv21x: true}, + want: ConsensusAuthority, + wantVersion: "v2.1.x", + }, + { + name: "no supported interface", + wantErr: "does not implement", + }, + { + name: "dave probe failure", + errs: map[[4]byte]error{iDataProviderInterfaceID: &rpc.HTTPError{StatusCode: 429}}, + wantErr: "supportsInterface(IDataProvider)", + }, + { + name: "quorum probe failure", + errs: map[[4]byte]error{iQuorumInterfaceID: errors.New("provider 503")}, + wantErr: "supportsInterface(IQuorum)", + }, + { + name: "authority probe failure", + errs: map[[4]byte]error{iConsensusInterfaceIDv30: context.Canceled}, + wantErr: "supportsInterface(IConsensus)", + }, + { + name: "quorum version probe failure", + supported: map[[4]byte]bool{iQuorumInterfaceID: true}, + errs: map[[4]byte]error{iConsensusInterfaceIDv30: context.DeadlineExceeded}, + wantErr: "for Quorum", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + probe := &supportsInterfaceStub{supported: tt.supported, errs: tt.errs} + + got, version, err := DetectConsensusType(callOpts, probe) + + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + // A probe failure must never fall back to Authority. + assert.Equal(t, ConsensusUnknown, got) + assert.Empty(t, version) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantVersion, version) + assert.Same(t, callOpts, probe.opts) + }) + } +} + +type validatorCounterStub struct { + count *big.Int + err error +} + +func (s validatorCounterStub) NumOfValidators(_ *bind.CallOpts) (*big.Int, error) { + return s.count, s.err +} + +func TestValidateQuorum(t *testing.T) { + const zeroValidators = "zero validators" + + tests := []struct { + name string + counter validatorCounterStub + wantErr string + }{ + {name: "nonzero validators", counter: validatorCounterStub{count: big.NewInt(3)}}, + {name: "zero validators", counter: validatorCounterStub{count: big.NewInt(0)}, + wantErr: zeroValidators}, + {name: "nil validators", counter: validatorCounterStub{}, + wantErr: zeroValidators}, + {name: "read failure", counter: validatorCounterStub{err: errors.New("provider 503")}, + wantErr: "NumOfValidators"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateQuorum(&bind.CallOpts{Context: t.Context()}, tt.counter) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestConsensusTypeString(t *testing.T) { + assert.Equal(t, "Authority", ConsensusAuthority.String()) + assert.Equal(t, "Quorum", ConsensusQuorum.String()) + assert.Equal(t, "DaveConsensus (PRT)", ConsensusPRT.String()) + assert.Equal(t, "Unknown", ConsensusUnknown.String()) +} + +func TestConsensusTypeToModel(t *testing.T) { + assert.Equal(t, model.Consensus_Authority, ConsensusAuthority.ToModel()) + assert.Equal(t, model.Consensus_Quorum, ConsensusQuorum.ToModel()) + assert.Equal(t, model.Consensus_PRT, ConsensusPRT.ToModel()) + assert.Equal(t, model.Consensus(""), ConsensusUnknown.ToModel()) +} + +// TestIConsensusV3InterfaceID locks down the v3 interface ID computation. +// If a method is renamed in the binding or this list drifts from the +// IConsensus.sol interface, this test surfaces the change as a value mismatch. +func TestIConsensusV3InterfaceID(t *testing.T) { + // Non-zero, distinct from the v2 IDs. + assert.NotEqual(t, [4]byte{}, iConsensusInterfaceIDv30, + "v3 interface ID should be non-zero") + assert.NotEqual(t, iConsensusInterfaceIDv220, iConsensusInterfaceIDv30, + "v3 interface ID should differ from v2.2.0") + assert.NotEqual(t, iConsensusInterfaceIDv21x, iConsensusInterfaceIDv30, + "v3 interface ID should differ from v2.1.x") +}