Skip to content

Commit e165017

Browse files
authored
Qbft gossip validation (#454)
1 parent a597f9a commit e165017

File tree

17 files changed

+585
-41
lines changed

17 files changed

+585
-41
lines changed

app/src/integrationTest/kotlin/maru/app/MaruMultiValidatorTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import kotlin.time.Duration.Companion.seconds
1414
import kotlin.time.toJavaDuration
1515
import maru.consensus.qbft.ProposerSelectorImpl
1616
import maru.core.SealedBeaconBlock
17-
import maru.crypto.Crypto
17+
import maru.crypto.SecpCrypto
1818
import maru.database.BeaconChain
1919
import maru.extensions.encodeHex
2020
import org.apache.logging.log4j.LogManager
@@ -80,8 +80,8 @@ class MaruMultiValidatorTest {
8080

8181
@Test
8282
fun `maru with multiple validators is able to produce blocks`() {
83-
val validator1Address = Crypto.privateKeyToValidator(Crypto.privateKeyBytesWithoutPrefix(key1))
84-
val validator2Address = Crypto.privateKeyToValidator(Crypto.privateKeyBytesWithoutPrefix(key2))
83+
val validator1Address = SecpCrypto.privateKeyToValidator(SecpCrypto.privateKeyBytesWithoutPrefix(key1))
84+
val validator2Address = SecpCrypto.privateKeyToValidator(SecpCrypto.privateKeyBytesWithoutPrefix(key2))
8585
log.info("Validator 1 (key1) address: ${validator1Address.address.encodeHex()}")
8686
log.info("Validator 2 (key2) address: ${validator2Address.address.encodeHex()}")
8787
val initialValidators = setOf(validator1Address, validator2Address)

app/src/main/kotlin/maru/app/MaruApp.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import maru.consensus.qbft.DifficultyAwareQbftFactory
2323
import maru.consensus.state.FinalizationProvider
2424
import maru.core.Protocol
2525
import maru.core.Validator
26-
import maru.crypto.Crypto
26+
import maru.crypto.SecpCrypto
2727
import maru.database.BeaconChain
2828
import maru.extensions.encodeHex
2929
import maru.finalization.LineaFinalizationProvider
@@ -65,13 +65,13 @@ class MaruApp(
6565
) : LongRunningCloseable {
6666
private val log: Logger = LogManager.getLogger(this.javaClass)
6767

68-
private fun getPrivateKeyWithoutPrefix() = Crypto.privateKeyBytesWithoutPrefix(privateKeyProvider())
68+
private fun getPrivateKeyWithoutPrefix() = SecpCrypto.privateKeyBytesWithoutPrefix(privateKeyProvider())
6969

7070
init {
7171
if (config.qbft == null) {
7272
log.info("Qbft options are not defined. nodeRole=follower")
7373
} else {
74-
val localValidator = Crypto.privateKeyToValidator(getPrivateKeyWithoutPrefix())
74+
val localValidator = SecpCrypto.privateKeyToValidator(getPrivateKeyWithoutPrefix())
7575
log.info("Qbft options are defined. nodeRole=validator with address={}", localValidator.address.encodeHex())
7676
// TODO: This may be not needed when we use dynamic validator set from a smart contract
7777
warnIfValidatorIsNotInTheGenesis(localValidator)

consensus/build.gradle

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,6 @@
1515

1616
plugins {
1717
id 'maru.kotlin-library-conventions'
18-
id 'java-test-fixtures'
19-
}
20-
21-
sourceSets {
22-
testFixtures {
23-
compileClasspath += sourceSets.main.compileClasspath
24-
runtimeClasspath += sourceSets.main.runtimeClasspath
25-
}
2618
}
2719

2820
dependencies {
@@ -58,6 +50,7 @@ dependencies {
5850
implementation "tech.pegasys.teku.internal:spec"
5951

6052
testImplementation(testFixtures(project(":core")))
53+
testImplementation(testFixtures(project(":crypto")))
6154
testImplementation(project(":jvm-libs:test-utils"))
6255
testImplementation "org.jetbrains.kotlin:kotlin-test:2.1.0"
6356
testImplementation "tech.pegasys.teku.internal:executionclient"
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Copyright Consensys Software Inc.
3+
*
4+
* This file is dual-licensed under either the MIT license or Apache License 2.0.
5+
* See the LICENSE-MIT and LICENSE-APACHE files in the repository root for details.
6+
*
7+
* SPDX-License-Identifier: MIT OR Apache-2.0
8+
*/
9+
package maru.consensus.qbft
10+
11+
import maru.core.Seal
12+
import maru.crypto.Crypto
13+
import org.apache.tuweni.bytes.Bytes
14+
import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1
15+
import org.hyperledger.besu.consensus.qbft.core.types.QbftMessage
16+
import org.hyperledger.besu.datatypes.Address
17+
import org.hyperledger.besu.datatypes.Hash.hash
18+
import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput
19+
import org.hyperledger.besu.ethereum.rlp.RLP.input
20+
21+
/**
22+
* Minimal RLP decoder for QBFT messages, extracting only the roundIdentifier, signature, and author needed for
23+
* minimal validation. This prevents more expensive decoding of the full message with the Block or other fields
24+
* which can be large.
25+
*/
26+
class MinimalQbftMessageDecoder(
27+
private val crypto: Crypto,
28+
) {
29+
data class QbftMessageMetadata(
30+
val messageCode: Int,
31+
val sequenceNumber: Long,
32+
val roundNumber: Int,
33+
val author: Address,
34+
)
35+
36+
/**
37+
* Deserializes a QBFT message to extract metadata.
38+
*
39+
* The RLP structure of signed data varies by message type:
40+
* - **PREPARE/COMMIT**: `[payload, signature]`
41+
* - Schema: [org.hyperledger.besu.consensus.common.bft.payload.SignedData]
42+
* - Message wrappers: [org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare],
43+
* [org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit]
44+
* - **PROPOSAL**: `[[payload], signature]` (extra list wrapper around payload)
45+
* - Schema: [org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal]
46+
* - **ROUND_CHANGE**: `[[payload], signature]` (extra list wrapper around payload)
47+
* - Schema: [org.hyperledger.besu.consensus.qbft.core.messagewrappers.RoundChange]
48+
*
49+
* All payloads contain: `[sequenceNumber: LONG_SCALAR, roundNumber: INT_SCALAR, ...]`
50+
*
51+
* @param qbftMessage The QBFT message to decode
52+
* @return The decoded metadata
53+
*/
54+
fun deserialize(qbftMessage: QbftMessage): QbftMessageMetadata {
55+
val messageCode = qbftMessage.data.code
56+
val signedDataBytes = qbftMessage.data.data
57+
58+
// SignedData list [payload, signature] or [[payload], signature] for PROPOSAL/ROUND_CHANGE
59+
val signedDataRlp = input(signedDataBytes)
60+
// For PROPOSAL and ROUND_CHANGE, there's an extra list wrapper
61+
if (messageCode == QbftV1.PROPOSAL || messageCode == QbftV1.ROUND_CHANGE) {
62+
signedDataRlp.enterList()
63+
}
64+
signedDataRlp.enterList()
65+
val payloadRlp = signedDataRlp.readAsRlp()
66+
67+
// Payload list [sequenceNumber, roundNumber, ...]
68+
payloadRlp.enterList()
69+
val sequenceNumber = payloadRlp.readLongScalar()
70+
val roundNumber = payloadRlp.readIntScalar()
71+
val payloadHash = hashForSignature(messageCode, payloadRlp.raw())
72+
val signatureBytes = signedDataRlp.readBytes()
73+
signedDataRlp.leaveList()
74+
75+
val author = crypto.signatureToAddress(Seal(signatureBytes.toArray()), payloadHash)
76+
return QbftMessageMetadata(
77+
messageCode = messageCode,
78+
sequenceNumber = sequenceNumber,
79+
roundNumber = roundNumber,
80+
author = Address.wrap(Bytes.wrap(author)),
81+
)
82+
}
83+
84+
/**
85+
* Compute the hash used for signature verification, implementing the same algorithm as
86+
* [org.hyperledger.besu.consensus.qbft.core.payload.QbftPayload.hashForSignature].
87+
*
88+
* This is the hash of the RLP encoding of: `LIST [messageType: INT_SCALAR, encodedPayload: RAW_BYTES]`
89+
*/
90+
internal fun hashForSignature(
91+
messageType: Int,
92+
encodedPayload: Bytes,
93+
): ByteArray {
94+
val out = BytesValueRLPOutput()
95+
out.startList()
96+
out.writeIntScalar(messageType)
97+
out.writeRaw(encodedPayload)
98+
out.endList()
99+
return hash(out.encoded()).toArray()
100+
}
101+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright Consensys Software Inc.
3+
*
4+
* This file is dual-licensed under either the MIT license or Apache License 2.0.
5+
* See the LICENSE-MIT and LICENSE-APACHE files in the repository root for details.
6+
*
7+
* SPDX-License-Identifier: MIT OR Apache-2.0
8+
*/
9+
package maru.consensus.qbft
10+
11+
import maru.consensus.qbft.MinimalQbftMessageDecoder.QbftMessageMetadata
12+
import maru.consensus.qbft.adapters.QbftBlockchainAdapter
13+
import maru.consensus.qbft.adapters.QbftValidatorProviderAdapter
14+
import maru.consensus.qbft.adapters.toQbftReceivedMessageEvent
15+
import maru.p2p.QbftMessageHandler
16+
import maru.p2p.ValidationResult
17+
import maru.p2p.ValidationResult.Companion.Ignore
18+
import maru.p2p.ValidationResult.Companion.Invalid
19+
import maru.p2p.ValidationResult.Companion.Valid
20+
import org.hyperledger.besu.consensus.common.bft.BftEventQueue
21+
import org.hyperledger.besu.consensus.qbft.core.types.QbftMessage
22+
import org.hyperledger.besu.datatypes.Address
23+
import tech.pegasys.teku.infrastructure.async.SafeFuture
24+
25+
/**
26+
* Processes QBFT messages received from the P2P network by applying a light validation, adding them to the
27+
* event queue if they are valid and returning the validation result to gossip if they are a current message.
28+
*
29+
* This mirrors the logic in Besu QbftController.processMessage but adapted for LibP2P message handling.
30+
*
31+
* Validation rules:
32+
* - Old messages (sequence < chainHeight): Ignored and not added to the event queue
33+
* - Future messages (sequence > chainHeight): Added to the event queue but not gossiped
34+
* - Current messages (sequence == chainHeight): Validated for author and local validator status and gossiped if valid
35+
*/
36+
class QbftMessageProcessor(
37+
private val blockChain: QbftBlockchainAdapter,
38+
private val validatorProvider: QbftValidatorProviderAdapter,
39+
private val localAddress: Address,
40+
private val bftEventQueue: BftEventQueue,
41+
private val messageDecoder: MinimalQbftMessageDecoder,
42+
) : QbftMessageHandler<ValidationResult> {
43+
/**
44+
* Validates a QBFT message and determines whether it should be gossiped.
45+
*
46+
* @param qbftMessage The QBFT message to validate
47+
* @return A future containing the validation result
48+
*/
49+
override fun handleQbftMessage(qbftMessage: QbftMessage): SafeFuture<ValidationResult> =
50+
try {
51+
val metadata = messageDecoder.deserialize(qbftMessage)
52+
val result = processMessage(qbftMessage, metadata)
53+
SafeFuture.completedFuture(result)
54+
} catch (e: Exception) {
55+
SafeFuture.completedFuture(Invalid("Failed to decode or validate message: ${e.message}"))
56+
}
57+
58+
private fun processMessage(
59+
qbftMessage: QbftMessage,
60+
metadata: QbftMessageMetadata,
61+
): ValidationResult {
62+
if (isMsgForCurrentHeight(metadata.sequenceNumber)) {
63+
val validators = validatorProvider.getValidatorsForBlock(blockChain.chainHeadHeader)
64+
return if (!isMsgFromKnownValidator(metadata.author, validators)) {
65+
Invalid("Message from unknown validator: ${metadata.author}")
66+
} else if (!isLocalNodeValidator(validators)) {
67+
Ignore("Local node is not a validator")
68+
} else {
69+
bftEventQueue.add(qbftMessage.toQbftReceivedMessageEvent())
70+
Valid
71+
}
72+
} else if (isMsgForFutureChainHeight(metadata.sequenceNumber)) {
73+
bftEventQueue.add(qbftMessage.toQbftReceivedMessageEvent())
74+
return Ignore("Future message, will be processed when chain reaches height ${metadata.sequenceNumber}")
75+
} else {
76+
return Ignore("Old message: sequence ${metadata.sequenceNumber} < height ${blockChain.chainHeadBlockNumber}")
77+
}
78+
}
79+
80+
private fun isMsgFromKnownValidator(
81+
messageAuthor: Address,
82+
validators: Collection<Address>,
83+
): Boolean = validators.contains(messageAuthor)
84+
85+
private fun isMsgForCurrentHeight(sequenceNumber: Long): Boolean = sequenceNumber == blockChain.chainHeadBlockNumber
86+
87+
private fun isMsgForFutureChainHeight(sequenceNumber: Long): Boolean =
88+
sequenceNumber > blockChain.chainHeadBlockNumber
89+
90+
private fun isLocalNodeValidator(validators: Collection<Address>): Boolean = validators.contains(localAddress)
91+
}

consensus/src/main/kotlin/maru/consensus/qbft/QbftValidatorFactory.kt

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ import maru.consensus.qbft.adapters.QbftFinalStateAdapter
3434
import maru.consensus.qbft.adapters.QbftProtocolScheduleAdapter
3535
import maru.consensus.qbft.adapters.QbftValidatorModeTransitionLoggerAdapter
3636
import maru.consensus.qbft.adapters.QbftValidatorProviderAdapter
37-
import maru.consensus.qbft.adapters.toQbftReceivedMessageEvent
3837
import maru.consensus.qbft.adapters.toSealedBeaconBlock
3938
import maru.consensus.state.FinalizationProvider
4039
import maru.consensus.state.StateTransition
@@ -44,6 +43,7 @@ import maru.core.BeaconState
4443
import maru.core.Protocol
4544
import maru.core.Validator
4645
import maru.crypto.Hashing
46+
import maru.crypto.SecpCrypto
4747
import maru.crypto.Signing
4848
import maru.database.BeaconChain
4949
import maru.executionlayer.manager.ExecutionLayerManager
@@ -66,13 +66,11 @@ import org.hyperledger.besu.consensus.qbft.core.types.QbftMessage
6666
import org.hyperledger.besu.consensus.qbft.core.types.QbftMinedBlockObserver
6767
import org.hyperledger.besu.consensus.qbft.core.types.QbftNewChainHead
6868
import org.hyperledger.besu.consensus.qbft.core.validation.MessageValidatorFactory
69-
import org.hyperledger.besu.crypto.SignatureAlgorithmFactory
7069
import org.hyperledger.besu.cryptoservices.KeyPairSecurityModule
7170
import org.hyperledger.besu.cryptoservices.NodeKey
7271
import org.hyperledger.besu.ethereum.core.Util
7372
import org.hyperledger.besu.plugin.services.MetricsSystem
7473
import org.hyperledger.besu.util.Subscribers
75-
import tech.pegasys.teku.infrastructure.async.SafeFuture
7674

7775
class QbftValidatorFactory(
7876
private val beaconChain: BeaconChain,
@@ -91,7 +89,7 @@ class QbftValidatorFactory(
9189
) : ProtocolFactory {
9290
override fun create(forkSpec: ForkSpec): Protocol {
9391
val protocolConfig = forkSpec.configuration as QbftConsensusConfig
94-
val signatureAlgorithm = SignatureAlgorithmFactory.getInstance()
92+
val signatureAlgorithm = SecpCrypto.signatureAlgorithm
9593
val privateKey = signatureAlgorithm.createPrivateKey(Bytes32.wrap(privateKeyBytes))
9694
val keyPair = signatureAlgorithm.createKeyPair(privateKey)
9795
val securityModule = KeyPairSecurityModule(keyPair)
@@ -253,12 +251,18 @@ class QbftValidatorFactory(
253251
val eventProcessor = QbftEventProcessor(bftEventQueue, eventMultiplexer)
254252
val eventQueueExecutor = Executors.newSingleThreadExecutor(Thread.ofPlatform().daemon().factory())
255253

256-
// Subscribe to QBFT messages from P2P network and add them to the event queue
257-
p2PNetwork.subscribeToQbftMessages { qbftMessage ->
258-
bftEventQueue.add(qbftMessage.toQbftReceivedMessageEvent())
259-
// TODO: Validate QBFT messages and return the appropriate ValidationResult
260-
SafeFuture.completedFuture(ValidationResult.Companion.Valid)
261-
}
254+
val messageDecoder = MinimalQbftMessageDecoder(SecpCrypto)
255+
val qbftMessageProcessor =
256+
QbftMessageProcessor(
257+
blockChain = blockChain,
258+
validatorProvider = besuValidatorProvider,
259+
localAddress = localAddress,
260+
bftEventQueue = bftEventQueue,
261+
messageDecoder = messageDecoder,
262+
)
263+
264+
// Subscribe to QBFT messages from P2P network and validate before adding to event queue
265+
p2PNetwork.subscribeToQbftMessages(qbftMessageProcessor)
262266

263267
return QbftConsensusValidator(
264268
qbftController = qbftController,

consensus/src/main/kotlin/maru/consensus/validation/SealVerifier.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ import com.github.michaelbull.result.Result
1414
import maru.core.BeaconBlockHeader
1515
import maru.core.Seal
1616
import maru.core.Validator
17+
import maru.crypto.SecpCrypto
1718
import org.apache.tuweni.bytes.Bytes
1819
import org.apache.tuweni.bytes.Bytes32
1920
import org.hyperledger.besu.crypto.SignatureAlgorithm
20-
import org.hyperledger.besu.crypto.SignatureAlgorithmFactory
2121
import org.hyperledger.besu.ethereum.core.Util
2222

2323
interface SealVerifier {
@@ -32,7 +32,7 @@ interface SealVerifier {
3232
}
3333

3434
class SCEP256SealVerifier(
35-
private val signatureAlgorithm: SignatureAlgorithm = SignatureAlgorithmFactory.getInstance(),
35+
private val signatureAlgorithm: SignatureAlgorithm = SecpCrypto.signatureAlgorithm,
3636
) : SealVerifier {
3737
override fun extractValidator(
3838
seal: Seal,

0 commit comments

Comments
 (0)