diff --git a/Blockchain/Sources/Blockchain/Disputes.swift b/Blockchain/Sources/Blockchain/Disputes.swift new file mode 100644 index 00000000..eee607d8 --- /dev/null +++ b/Blockchain/Sources/Blockchain/Disputes.swift @@ -0,0 +1,218 @@ +import Utils + +public enum DisputeError: Error { + case invalidEpoch + case invalidValidatorIndex + case invalidJudgementSignature + case invalidCulpritSigner + case invalidCulpritSignature + case invalidFaultSigner + case invalidFaultSignature + case verdictsNotSorted + case culpritsNotSorted + case faultsNotSorted + case duplicatedReport + case judgementsNotSorted + case invalidJudgementsCount + case expectInFaults + case expectInCulprits +} + +public struct ReportItem: Sendable, Equatable, Codable { + public var workReport: WorkReport + public var timeslot: TimeslotIndex + + public init( + workReport: WorkReport, + timeslot: TimeslotIndex + ) { + self.workReport = workReport + self.timeslot = timeslot + } +} + +public struct DisputePostState: Sendable, Equatable { + public var judgements: JudgementsState + public var reports: ConfigFixedSizeArray< + ReportItem?, + ProtocolConfig.TotalNumberOfCores + > + + public init( + judgements: JudgementsState, + reports: ConfigFixedSizeArray< + ReportItem?, + ProtocolConfig.TotalNumberOfCores + > + ) { + self.judgements = judgements + self.reports = reports + } +} + +public protocol Disputes { + var judgements: JudgementsState { get } + var reports: ConfigFixedSizeArray< + ReportItem?, + ProtocolConfig.TotalNumberOfCores + > { get } + var timeslot: TimeslotIndex { get } + var currentValidators: ConfigFixedSizeArray< + ValidatorKey, ProtocolConfig.TotalNumberOfValidators + > { get } + var previousValidators: ConfigFixedSizeArray< + ValidatorKey, ProtocolConfig.TotalNumberOfValidators + > { get } + + func update(config: ProtocolConfigRef, disputes: ExtrinsicDisputes) throws(DisputeError) -> ( + state: DisputePostState, + offenders: [Ed25519PublicKey] + ) + + mutating func mergeWith(postState: DisputePostState) +} + +extension Disputes { + public func update(config: ProtocolConfigRef, disputes: ExtrinsicDisputes) throws(DisputeError) -> ( + state: DisputePostState, + offenders: [Ed25519PublicKey] + ) { + var newJudgements = judgements + var newReports = reports + var offenders: [Ed25519PublicKey] = [] + + let epochLength = UInt32(config.value.epochLength) + let currentEpoch = timeslot / epochLength + let lastEpoch = currentEpoch == 0 ? nil : currentEpoch - 1 + + for verdict in disputes.verdicts { + let isCurrent = verdict.epoch == currentEpoch + let isLast = verdict.epoch == lastEpoch + guard isCurrent || isLast else { + throw .invalidEpoch + } + + let validators = isCurrent ? currentValidators : previousValidators + + for judgement in verdict.judgements { + guard let signer = validators[safe: Int(judgement.validatorIndex)]?.ed25519 else { + throw .invalidValidatorIndex + } + + let prefix = judgement.isValid ? SigningContext.valid : SigningContext.invalid + let payload = prefix + verdict.reportHash.data + guard Ed25519.verify(signature: judgement.signature, message: payload, publicKey: signer) else { + throw .invalidJudgementSignature + } + } + + guard verdict.judgements.isSortedAndUnique(by: { $0.validatorIndex < $1.validatorIndex }) else { + throw .judgementsNotSorted + } + } + + var validSigners = Set(currentValidators.map(\.ed25519)) + validSigners.formUnion(previousValidators.map(\.ed25519)) + validSigners.subtract(judgements.punishSet) + + for culprit in disputes.culprits { + guard validSigners.contains(culprit.validatorKey) else { + throw .invalidCulpritSigner + } + + let payload = SigningContext.guarantee + culprit.reportHash.data + guard Ed25519.verify(signature: culprit.signature, message: payload, publicKey: culprit.validatorKey) else { + throw .invalidCulpritSignature + } + + newJudgements.punishSet.insert(culprit.validatorKey) + offenders.append(culprit.validatorKey) + } + + for fault in disputes.faults { + guard validSigners.contains(fault.validatorKey) else { + throw .invalidFaultSigner + } + + let prefix = fault.vote ? SigningContext.valid : SigningContext.invalid + let payload = prefix + fault.reportHash.data + guard Ed25519.verify(signature: fault.signature, message: payload, publicKey: fault.validatorKey) else { + throw .invalidFaultSignature + } + + newJudgements.punishSet.insert(fault.validatorKey) + offenders.append(fault.validatorKey) + } + + guard disputes.verdicts.isSortedAndUnique(by: { $0.reportHash < $1.reportHash }) else { + throw .verdictsNotSorted + } + + guard disputes.culprits.isSortedAndUnique(by: { $0.validatorKey < $1.validatorKey }) else { + throw .culpritsNotSorted + } + + guard disputes.faults.isSortedAndUnique(by: { $0.validatorKey < $1.validatorKey }) else { + throw .faultsNotSorted + } + + var allReports = Set(disputes.verdicts.map(\.reportHash)) + allReports.formUnion(judgements.goodSet) + allReports.formUnion(judgements.banSet) + allReports.formUnion(judgements.wonkySet) + + let expectedReportCount = disputes.verdicts.count + judgements.goodSet.count + judgements.banSet.count + judgements.wonkySet.count + + guard allReports.count == expectedReportCount else { + throw .duplicatedReport + } + + let votes = disputes.verdicts.map { + (hash: $0.reportHash, vote: $0.judgements.reduce(into: 0) { $0 += $1.isValid ? 1 : 0 }) + } + + var tobeRemoved = Set() + let third_validators = config.value.totalNumberOfValidators / 3 + let two_third_plus_one_validators = config.value.totalNumberOfValidators * 2 / 3 + 1 + for (hash, vote) in votes { + if vote == 0 { + // any verdict containing solely valid judgements + // implies the same report having at least one valid entry in the faults sequence f + guard disputes.faults.contains(where: { $0.reportHash == hash }) else { + throw .expectInFaults + } + + tobeRemoved.insert(hash) + newJudgements.banSet.insert(hash) + } else if vote == third_validators { + // wonky + tobeRemoved.insert(hash) + newJudgements.wonkySet.insert(hash) + } else if vote == two_third_plus_one_validators { + // Any verdict containing solely invalid judgements + // implies the same report having at least two valid entries in the culprits sequence c + guard disputes.culprits.count(where: { $0.reportHash == hash }) >= 2 else { + throw .expectInCulprits + } + + newJudgements.goodSet.insert(hash) + } else { + throw .invalidJudgementsCount + } + } + + for i in 0 ..< newReports.count { + if let report = newReports[i]?.workReport { + let hash = report.hash() + if tobeRemoved.contains(hash) { + newReports[i] = nil + } + } + } + + return (state: DisputePostState( + judgements: newJudgements, + reports: newReports + ), offenders: offenders) + } +} diff --git a/Blockchain/Sources/Blockchain/Runtime.swift b/Blockchain/Sources/Blockchain/Runtime.swift index c068f11e..fe96c911 100644 --- a/Blockchain/Sources/Blockchain/Runtime.swift +++ b/Blockchain/Sources/Blockchain/Runtime.swift @@ -5,6 +5,7 @@ import Utils public final class Runtime { public enum Error: Swift.Error { case safroleError(SafroleError) + case DisputeError(DisputeError) case invalidTimeslot case invalidReportAuthorizer case encodeError(any Swift.Error) @@ -13,6 +14,7 @@ public final class Runtime { case invalidHeaderStateRoot case invalidHeaderEpochMarker case invalidHeaderWinningTickets + case invalidHeaderOffendersMarkers case other(any Swift.Error) } @@ -50,12 +52,11 @@ public final class Runtime { throw Error.invalidTimeslot } - // epoch is validated at apply time + // epoch is validated at apply time by Safrole - // winning tickets is validated at apply time + // winning tickets is validated at apply time by Safrole - // TODO: validate judgementsMarkers - // TODO: validate offendersMarkers + // offendersMarkers is validated at apply time by Disputes // TODO: validate block.header.seal } @@ -88,6 +89,8 @@ public final class Runtime { throw error } catch let error as SafroleError { throw .safroleError(error) + } catch let error as DisputeError { + throw .DisputeError(error) } catch { throw .other(error) } @@ -95,6 +98,16 @@ public final class Runtime { return StateRef(newState) } + public func updateRecentHistory(block: BlockRef, state newState: inout State) throws { + let workReportHashes = block.extrinsic.reports.guarantees.map(\.workReport.packageSpecification.workPackageHash) + try newState.recentHistory.update( + headerHash: block.header.parentHash, + parentStateRoot: block.header.priorStateRoot, + accumulateRoot: Data32(), // TODO: calculate accumulation result + workReportHashes: ConfigLimitedSizeArray(config: config, array: workReportHashes) + ) + } + public func updateSafrole(block: BlockRef, state newState: inout State) throws { let safroleResult = try newState.updateSafrole( config: config, @@ -114,14 +127,13 @@ public final class Runtime { } } - public func updateRecentHistory(block: BlockRef, state newState: inout State) throws { - let workReportHashes = block.extrinsic.reports.guarantees.map(\.workReport.packageSpecification.workPackageHash) - try newState.recentHistory.update( - headerHash: block.header.parentHash, - parentStateRoot: block.header.priorStateRoot, - accumulateRoot: Data32(), // TODO: calculate accumulation result - workReportHashes: ConfigLimitedSizeArray(config: config, array: workReportHashes) - ) + public func updateDisputes(block: BlockRef, state newState: inout State) throws { + let (posState, offenders) = try newState.update(config: config, disputes: block.extrinsic.judgements) + newState.mergeWith(postState: posState) + + guard offenders == block.header.offendersMarkers else { + throw Error.invalidHeaderOffendersMarkers + } } // TODO: add tests diff --git a/Blockchain/Sources/Blockchain/Safrole.swift b/Blockchain/Sources/Blockchain/Safrole.swift index 285c74a9..19e052fb 100644 --- a/Blockchain/Sources/Blockchain/Safrole.swift +++ b/Blockchain/Sources/Blockchain/Safrole.swift @@ -105,18 +105,6 @@ public struct SafrolePostState: Sendable, Equatable { self.ticketsOrKeys = ticketsOrKeys self.ticketsVerifier = ticketsVerifier } - - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.timeslot == rhs.timeslot && - lhs.entropyPool == rhs.entropyPool && - lhs.previousValidators == rhs.previousValidators && - lhs.currentValidators == rhs.currentValidators && - lhs.nextValidators == rhs.nextValidators && - lhs.validatorQueue == rhs.validatorQueue && - lhs.ticketsAccumulator == rhs.ticketsAccumulator && - lhs.ticketsOrKeys == rhs.ticketsOrKeys && - lhs.ticketsVerifier == rhs.ticketsVerifier - } } public protocol Safrole { @@ -336,7 +324,7 @@ extension Safrole { } let newTickets = try extrinsics.getTickets(verifier: verifier, entropy: newEntropyPool.2) - guard newTickets.isSorted() else { + guard newTickets.isSortedAndUnique() else { throw SafroleError.extrinsicsNotSorted } diff --git a/Blockchain/Sources/Blockchain/Types/Header.swift b/Blockchain/Sources/Blockchain/Types/Header.swift index 69d677a5..e0681162 100644 --- a/Blockchain/Sources/Blockchain/Types/Header.swift +++ b/Blockchain/Sources/Blockchain/Types/Header.swift @@ -34,10 +34,6 @@ public struct Header: Sendable, Equatable, Codable { ProtocolConfig.EpochLength >? - // Hj: The verdicts markers must contain exactly the sequence of report hashes of all new - // bad & wonky verdicts. - public var judgementsMarkers: [Data32] - // Ho: The offenders markers must contain exactly the sequence of keys of all new offenders. public var offendersMarkers: [Ed25519PublicKey] @@ -57,7 +53,6 @@ public struct Header: Sendable, Equatable, Codable { Ticket, ProtocolConfig.EpochLength >?, - judgementsMarkers: [Data32], offendersMarkers: [Ed25519PublicKey], authorIndex: ValidatorIndex, vrfSignature: BandersnatchSignature @@ -68,7 +63,6 @@ public struct Header: Sendable, Equatable, Codable { self.timeslot = timeslot self.epoch = epoch self.winningTickets = winningTickets - self.judgementsMarkers = judgementsMarkers self.offendersMarkers = offendersMarkers self.authorIndex = authorIndex self.vrfSignature = vrfSignature @@ -104,7 +98,6 @@ extension Header.Unsigned: Dummy { timeslot: 0, epoch: nil, winningTickets: nil, - judgementsMarkers: [], offendersMarkers: [], authorIndex: 0, vrfSignature: BandersnatchSignature() @@ -138,7 +131,6 @@ extension Header { public var timeslot: TimeslotIndex { unsigned.timeslot } public var epoch: EpochMarker? { unsigned.epoch } public var winningTickets: ConfigFixedSizeArray? { unsigned.winningTickets } - public var judgementsMarkers: [Data32] { unsigned.judgementsMarkers } public var offendersMarkers: [Ed25519PublicKey] { unsigned.offendersMarkers } public var authorIndex: ValidatorIndex { unsigned.authorIndex } public var vrfSignature: BandersnatchSignature { unsigned.vrfSignature } diff --git a/Blockchain/Sources/Blockchain/Types/State.swift b/Blockchain/Sources/Blockchain/Types/State.swift index 20eb688f..6cbe59a6 100644 --- a/Blockchain/Sources/Blockchain/Types/State.swift +++ b/Blockchain/Sources/Blockchain/Types/State.swift @@ -2,19 +2,6 @@ import Codec import Utils public struct State: Sendable, Equatable, Codable { - public struct ReportItem: Sendable, Equatable, Codable { - public var workReport: WorkReport - public var timeslot: TimeslotIndex - - public init( - workReport: WorkReport, - timeslot: TimeslotIndex - ) { - self.workReport = workReport - self.timeslot = timeslot - } - } - public struct PrivilegedServiceIndices: Sendable, Equatable, Codable { public var empower: ServiceIndex public var assign: ServiceIndex @@ -217,3 +204,10 @@ extension State: Safrole { timeslot = postState.timeslot } } + +extension State: Disputes { + public mutating func mergeWith(postState: DisputePostState) { + judgements = postState.judgements + reports = postState.reports + } +} diff --git a/Blockchain/Sources/Blockchain/Types/WorkReport.swift b/Blockchain/Sources/Blockchain/Types/WorkReport.swift index 2768bdc7..fd5da72b 100644 --- a/Blockchain/Sources/Blockchain/Types/WorkReport.swift +++ b/Blockchain/Sources/Blockchain/Types/WorkReport.swift @@ -1,3 +1,4 @@ +import Codec import Foundation import Utils @@ -56,3 +57,9 @@ extension WorkReport: Dummy { ) } } + +extension WorkReport { + public func hash() -> Data32 { + try! JamEncoder.encode(self).blake2b256hash() + } +} diff --git a/JAMTests/jamtestvectors b/JAMTests/jamtestvectors index 2da1072f..0d3ce8e1 160000 --- a/JAMTests/jamtestvectors +++ b/JAMTests/jamtestvectors @@ -1 +1 @@ -Subproject commit 2da1072f733c2f8aec2d6a354d565410ad8dd059 +Subproject commit 0d3ce8e1780197ff40d84222bba8b6ee5d8c7955 diff --git a/Utils/Sources/Utils/Crypto/Ed25519.swift b/Utils/Sources/Utils/Crypto/Ed25519.swift index 25953233..25384ba9 100644 --- a/Utils/Sources/Utils/Crypto/Ed25519.swift +++ b/Utils/Sources/Utils/Crypto/Ed25519.swift @@ -28,7 +28,7 @@ public struct Ed25519 { return Data64(signature)! } - public func verify(signature: Data64, message: Data, publicKey: Data32) -> Bool { + public static func verify(signature: Data64, message: Data, publicKey: Data32) -> Bool { guard let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey.data) else { return false } diff --git a/Utils/Sources/Utils/Extensions/Array+Utils.swift b/Utils/Sources/Utils/Extensions/Array+Utils.swift index 98772411..fc2c5c82 100644 --- a/Utils/Sources/Utils/Extensions/Array+Utils.swift +++ b/Utils/Sources/Utils/Extensions/Array+Utils.swift @@ -1,17 +1,4 @@ -extension Array where Element: Comparable { - public func isSorted(by comparer: (Element, Element) -> Bool = { $0 < $1 }) -> Bool { - var previous: Element? - for element in self { - if let previous { - if !comparer(previous, element), previous != element { - return false - } - } - previous = element - } - return true - } - +extension Array { /// Insert the elements of the given sequence to the array, in sorted order. /// /// - Parameter elements: The elements to insert. @@ -20,9 +7,8 @@ extension Array where Element: Comparable { /// /// - Note: The elements of the sequence must be comparable. /// - Invariant: The array and elements must be sorted according to the given comparison function. - public mutating func insertSorted(_ elements: any Sequence, by comparer: ((Element, Element) throws -> Bool)? = nil) rethrows { + public mutating func insertSorted(_ elements: any Sequence, by comparer: (Element, Element) throws -> Bool) rethrows { reserveCapacity(count + elements.underestimatedCount) - let comparer = comparer ?? { $0 < $1 } var startIdx = 0 for element in elements { if let idx = try self[startIdx...].firstIndex(where: { try !comparer($0, element) }) { @@ -35,3 +21,17 @@ extension Array where Element: Comparable { } } } + +extension Array where Element: Comparable { + /// Insert the elements of the given sequence to the array, in sorted order. + /// + /// - Parameter elements: The elements to insert. + /// - Parameter comparer: The comparison function to use to determine the order of the elements. + /// - Complexity: O(*n*), where *n* is the number of elements in the sequence. + /// + /// - Note: The elements of the sequence must be comparable. + /// - Invariant: The array and elements must be sorted according to the given comparison function. + public mutating func insertSorted(_ elements: any Sequence) { + insertSorted(elements) { $0 < $1 } + } +} diff --git a/Utils/Sources/Utils/Extensions/Sequence+Util.swift b/Utils/Sources/Utils/Extensions/Sequence+Util.swift new file mode 100644 index 00000000..c843ce17 --- /dev/null +++ b/Utils/Sources/Utils/Extensions/Sequence+Util.swift @@ -0,0 +1,37 @@ +extension Sequence { + public func isSorted(by comparer: (Element, Element) throws -> Bool) rethrows -> Bool { + var previous: Element? + for element in self { + if let previous { + guard try !comparer(element, previous) else { + return false + } + } + previous = element + } + return true + } + + public func isSortedAndUnique(by comparer: (Element, Element) throws -> Bool) rethrows -> Bool { + var previous: Element? + for element in self { + if let previous { + guard try comparer(previous, element) else { + return false + } + } + previous = element + } + return true + } +} + +extension Sequence where Element: Comparable { + public func isSorted() -> Bool { + isSorted { $0 < $1 } + } + + public func isSortedAndUnique() -> Bool { + isSortedAndUnique { $0 < $1 } + } +} diff --git a/Utils/Tests/UtilsTests/ArrayTests.swift b/Utils/Tests/UtilsTests/ArrayTests.swift index 21d3a641..700de89b 100644 --- a/Utils/Tests/UtilsTests/ArrayTests.swift +++ b/Utils/Tests/UtilsTests/ArrayTests.swift @@ -4,27 +4,6 @@ import Testing @testable import Utils struct ArrayTests { - @Test(arguments: [ - [], [1], [2, 4], [5, 19, 34, 34, 56, 56], - ]) - func isSorted(testCase: [Int]) { - #expect(testCase.isSorted()) - } - - @Test(arguments: [ - [], [1], [4, 2], [56, 56, 34, 34, 19, 5] - ]) - func isSortedBy(testCase: [Int]) { - #expect(testCase.isSorted(by: >)) - } - - @Test(arguments: [ - [4, 2], [56, 56, 34, 35, 19, 5], [1, 3, 2] - ]) - func notSorted(testCase: [Int]) { - #expect(!testCase.isSorted()) - } - @Test(arguments: [ ([], []), ([], [1]), diff --git a/Utils/Tests/UtilsTests/Crypto/Ed25519Tests.swift b/Utils/Tests/UtilsTests/Crypto/Ed25519Tests.swift index 9035feab..4c05acc3 100644 --- a/Utils/Tests/UtilsTests/Crypto/Ed25519Tests.swift +++ b/Utils/Tests/UtilsTests/Crypto/Ed25519Tests.swift @@ -10,17 +10,17 @@ import Testing let message = Data("test".utf8) let signature = try ed25519.sign(message: message) - #expect(ed25519.verify(signature: signature, message: message, publicKey: publicKey)) + #expect(Ed25519.verify(signature: signature, message: message, publicKey: publicKey)) let invalidMessage = Data("tests".utf8) #expect( - !ed25519.verify(signature: signature, message: invalidMessage, publicKey: publicKey) + !Ed25519.verify(signature: signature, message: invalidMessage, publicKey: publicKey) ) var invalidSignature = signature.data invalidSignature.replaceSubrange(0 ... 1, with: [10, 12]) #expect( - !ed25519.verify( + !Ed25519.verify( signature: Data64(invalidSignature)!, message: message, publicKey: publicKey ) ) diff --git a/Utils/Tests/UtilsTests/SequenceTests.swift b/Utils/Tests/UtilsTests/SequenceTests.swift new file mode 100644 index 00000000..cd917aff --- /dev/null +++ b/Utils/Tests/UtilsTests/SequenceTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing + +@testable import Utils + +struct SequenceTests { + @Test(arguments: [ + [], [1], [2, 4], [5, 19, 34, 34, 56, 56], + ]) + func isSorted(testCase: [Int]) { + #expect(testCase.isSorted()) + } + + @Test(arguments: [ + [], [1], [4, 2], [56, 56, 34, 34, 19, 5] + ]) + func isSortedBy(testCase: [Int]) { + #expect(testCase.isSorted(by: >)) + } + + @Test(arguments: [ + [4, 2], [56, 56, 34, 35, 19, 5], [1, 3, 2] + ]) + func notSorted(testCase: [Int]) { + #expect(!testCase.isSorted()) + } + + @Test(arguments: [ + [], [1], [2, 4, 5] + ]) + func isSortedAndUnique(testCase: [Int]) { + #expect(testCase.isSortedAndUnique()) + } + + @Test(arguments: [ + [], [1], [5, 4, 2] + ]) + func isSortedAndUniqueBy(testCase: [Int]) { + #expect(testCase.isSortedAndUnique(by: >)) + } + + @Test(arguments: [ + [1, 1], [2, 1], [2, 3, 4, 4] + ]) + func notSortedAndUnique(testCase: [Int]) { + #expect(!testCase.isSortedAndUnique()) + } +}