|
-------------------------- MODULE LCDetector_003_draft -----------------------------
|
|
(**
|
|
* This is a specification of the light client detector module.
|
|
* It follows the English specification:
|
|
*
|
|
* https://github.com/tendermint/spec/blob/master/rust-spec/lightclient/detection/detection_003_reviewed.md
|
|
*
|
|
* The assumptions made in this specification:
|
|
*
|
|
* - light client connects to one primary and one secondary peer
|
|
*
|
|
* - the light client has its own local clock that can drift from the reference clock
|
|
* within the envelope [refClock - CLOCK_DRIFT, refClock + CLOCK_DRIFT].
|
|
* The local clock may increase as well as decrease in the the envelope
|
|
* (similar to clock synchronization).
|
|
*
|
|
* - the ratio of the faulty validators is set as the parameter.
|
|
*
|
|
* Igor Konnov, Josef Widder, 2020
|
|
*)
|
|
|
|
EXTENDS Integers
|
|
|
|
\* the parameters of Light Client
|
|
CONSTANTS
|
|
AllNodes,
|
|
(* a set of all nodes that can act as validators (correct and faulty) *)
|
|
TRUSTED_HEIGHT,
|
|
(* an index of the block header that the light client trusts by social consensus *)
|
|
TARGET_HEIGHT,
|
|
(* an index of the block header that the light client tries to verify *)
|
|
TRUSTING_PERIOD,
|
|
(* the period within which the validators are trusted *)
|
|
CLOCK_DRIFT,
|
|
(* the assumed precision of the clock *)
|
|
REAL_CLOCK_DRIFT,
|
|
(* the actual clock drift, which under normal circumstances should not
|
|
be larger than CLOCK_DRIFT (otherwise, there will be a bug) *)
|
|
FAULTY_RATIO,
|
|
(* a pair <<a, b>> that limits that ratio of faulty validator in the blockchain
|
|
from above (exclusive). Tendermint security model prescribes 1 / 3. *)
|
|
IS_PRIMARY_CORRECT,
|
|
IS_SECONDARY_CORRECT
|
|
|
|
VARIABLES
|
|
blockchain, (* the reference blockchain *)
|
|
localClock, (* the local clock of the light client *)
|
|
refClock, (* the reference clock in the reference blockchain *)
|
|
Faulty, (* the set of faulty validators *)
|
|
state, (* the state of the light client detector *)
|
|
fetchedLightBlocks1, (* a function from heights to LightBlocks *)
|
|
fetchedLightBlocks2, (* a function from heights to LightBlocks *)
|
|
fetchedLightBlocks1b, (* a function from heights to LightBlocks *)
|
|
commonHeight, (* the height that is trusted in CreateEvidenceForPeer *)
|
|
nextHeightToTry, (* the index in CreateEvidenceForPeer *)
|
|
evidences (* a set of evidences *)
|
|
|
|
vars == <<state, blockchain, localClock, refClock, Faulty,
|
|
fetchedLightBlocks1, fetchedLightBlocks2, fetchedLightBlocks1b,
|
|
commonHeight, nextHeightToTry, evidences >>
|
|
|
|
\* (old) type annotations in Apalache
|
|
a <: b == a
|
|
|
|
|
|
\* instantiate a reference chain
|
|
ULTIMATE_HEIGHT == TARGET_HEIGHT + 1
|
|
BC == INSTANCE Blockchain_003_draft
|
|
WITH ULTIMATE_HEIGHT <- (TARGET_HEIGHT + 1)
|
|
|
|
\* use the light client API
|
|
LC == INSTANCE LCVerificationApi_003_draft
|
|
|
|
\* evidence type
|
|
ET == [peer |-> STRING, conflictingBlock |-> BC!LBT, commonHeight |-> Int]
|
|
|
|
\* is the algorithm in the terminating state
|
|
IsTerminated ==
|
|
state \in { <<"NoEvidence", "PRIMARY">>,
|
|
<<"NoEvidence", "SECONDARY">>,
|
|
<<"FaultyPeer", "PRIMARY">>,
|
|
<<"FaultyPeer", "SECONDARY">>,
|
|
<<"FoundEvidence", "PRIMARY">> }
|
|
|
|
|
|
(********************************* Initialization ******************************)
|
|
|
|
\* initialization for the light blocks data structure
|
|
InitLightBlocks(lb, Heights) ==
|
|
\* BC!LightBlocks is an infinite set, as time is not restricted.
|
|
\* Hence, we initialize the light blocks by picking the sets inside.
|
|
\E vs, nextVS, lastCommit, commit \in [Heights -> SUBSET AllNodes]:
|
|
\* although [Heights -> Int] is an infinite set,
|
|
\* Apalache needs just one instance of this set, so it does not complain.
|
|
\E timestamp \in [Heights -> Int]:
|
|
LET hdr(h) ==
|
|
[height |-> h,
|
|
time |-> timestamp[h],
|
|
VS |-> vs[h],
|
|
NextVS |-> nextVS[h],
|
|
lastCommit |-> lastCommit[h]]
|
|
IN
|
|
LET lightHdr(h) ==
|
|
[header |-> hdr(h), Commits |-> commit[h]]
|
|
IN
|
|
lb = [ h \in Heights |-> lightHdr(h) ]
|
|
|
|
\* initialize the detector algorithm
|
|
Init ==
|
|
\* initialize the blockchain to TARGET_HEIGHT + 1
|
|
/\ BC!InitToHeight(FAULTY_RATIO)
|
|
/\ \E tm \in Int:
|
|
tm >= 0 /\ LC!IsLocalClockWithinDrift(tm, refClock) /\ localClock = tm
|
|
\* start with the secondary looking for evidence
|
|
/\ state = <<"Init", "SECONDARY">> /\ commonHeight = 0 /\ nextHeightToTry = 0
|
|
/\ evidences = {} <: {ET}
|
|
\* Precompute a possible result of light client verification for the primary.
|
|
\* It is the input to the detection algorithm.
|
|
/\ \E Heights1 \in SUBSET(TRUSTED_HEIGHT..TARGET_HEIGHT):
|
|
/\ TRUSTED_HEIGHT \in Heights1
|
|
/\ TARGET_HEIGHT \in Heights1
|
|
/\ InitLightBlocks(fetchedLightBlocks1, Heights1)
|
|
\* As we have a non-deterministic scheduler, for every trace that has
|
|
\* an unverified block, there is a filtered trace that only has verified
|
|
\* blocks. This is a deep observation.
|
|
/\ LET status == [h \in Heights1 |-> "StateVerified"] IN
|
|
LC!VerifyToTargetPost(blockchain, IS_PRIMARY_CORRECT,
|
|
fetchedLightBlocks1, status,
|
|
TRUSTED_HEIGHT, TARGET_HEIGHT, "finishedSuccess")
|
|
\* initialize the other data structures to the default values
|
|
/\ LET trustedBlock == blockchain[TRUSTED_HEIGHT]
|
|
trustedLightBlock == [header |-> trustedBlock, Commits |-> AllNodes]
|
|
IN
|
|
/\ fetchedLightBlocks2 = [h \in {TRUSTED_HEIGHT} |-> trustedLightBlock]
|
|
/\ fetchedLightBlocks1b = [h \in {TRUSTED_HEIGHT} |-> trustedLightBlock]
|
|
|
|
|
|
(********************************* Transitions ******************************)
|
|
|
|
\* a block should contain a copy of the block from the reference chain,
|
|
\* with a matching commit
|
|
CopyLightBlockFromChain(block, height) ==
|
|
LET ref == blockchain[height]
|
|
lastCommit ==
|
|
IF height < ULTIMATE_HEIGHT
|
|
THEN blockchain[height + 1].lastCommit
|
|
\* for the ultimate block, which we never use,
|
|
\* as ULTIMATE_HEIGHT = TARGET_HEIGHT + 1
|
|
ELSE blockchain[height].VS
|
|
IN
|
|
block = [header |-> ref, Commits |-> lastCommit]
|
|
|
|
\* Either the primary is correct and the block comes from the reference chain,
|
|
\* or the block is produced by a faulty primary.
|
|
\*
|
|
\* [LCV-FUNC-FETCH.1::TLA.1]
|
|
FetchLightBlockInto(isPeerCorrect, block, height) ==
|
|
IF isPeerCorrect
|
|
THEN CopyLightBlockFromChain(block, height)
|
|
ELSE BC!IsLightBlockAllowedByDigitalSignatures(height, block)
|
|
|
|
|
|
(**
|
|
* Pick the next height, for which there is a block.
|
|
*)
|
|
PickNextHeight(fetchedBlocks, height) ==
|
|
LET largerHeights == { h \in DOMAIN fetchedBlocks: h > height } IN
|
|
IF largerHeights = ({} <: {Int})
|
|
THEN -1
|
|
ELSE CHOOSE h \in largerHeights:
|
|
\A h2 \in largerHeights: h <= h2
|
|
|
|
|
|
(**
|
|
* Check, whether the target header matches at the secondary and primary.
|
|
*)
|
|
CompareLast ==
|
|
/\ state = <<"Init", "SECONDARY">>
|
|
\* fetch a block from the secondary:
|
|
\* non-deterministically pick a block that matches the constraints
|
|
/\ \E latest \in BC!LightBlocks:
|
|
\* for the moment, we ignore the possibility of a timeout when fetching a block
|
|
/\ FetchLightBlockInto(IS_SECONDARY_CORRECT, latest, TARGET_HEIGHT)
|
|
/\ IF latest.header = fetchedLightBlocks1[TARGET_HEIGHT].header
|
|
THEN \* if the headers match, CreateEvidence is not called
|
|
/\ state' = <<"NoEvidence", "SECONDARY">>
|
|
\* save the retrieved block for further analysis
|
|
/\ fetchedLightBlocks2' =
|
|
[h \in (DOMAIN fetchedLightBlocks2) \union {TARGET_HEIGHT} |->
|
|
IF h = TARGET_HEIGHT THEN latest ELSE fetchedLightBlocks2[h]]
|
|
/\ UNCHANGED <<commonHeight, nextHeightToTry>>
|
|
ELSE \* prepare the parameters for CreateEvidence
|
|
/\ commonHeight' = TRUSTED_HEIGHT
|
|
/\ nextHeightToTry' = PickNextHeight(fetchedLightBlocks1, TRUSTED_HEIGHT)
|
|
/\ state' = IF nextHeightToTry' >= 0
|
|
THEN <<"CreateEvidence", "SECONDARY">>
|
|
ELSE <<"FaultyPeer", "SECONDARY">>
|
|
/\ UNCHANGED fetchedLightBlocks2
|
|
|
|
/\ UNCHANGED <<blockchain, Faulty,
|
|
fetchedLightBlocks1, fetchedLightBlocks1b, evidences>>
|
|
|
|
|
|
\* the actual loop in CreateEvidence
|
|
CreateEvidence(peer, isPeerCorrect, refBlocks, targetBlocks) ==
|
|
/\ state = <<"CreateEvidence", peer>>
|
|
\* precompute a possible result of light client verification for the secondary
|
|
\* we have to introduce HeightRange, because Apalache can only handle a..b
|
|
\* for constant a and b
|
|
/\ LET HeightRange == { h \in TRUSTED_HEIGHT..TARGET_HEIGHT:
|
|
commonHeight <= h /\ h <= nextHeightToTry } IN
|
|
\E HeightsRange \in SUBSET(HeightRange):
|
|
/\ commonHeight \in HeightsRange /\ nextHeightToTry \in HeightsRange
|
|
/\ InitLightBlocks(targetBlocks, HeightsRange)
|
|
\* As we have a non-deterministic scheduler, for every trace that has
|
|
\* an unverified block, there is a filtered trace that only has verified
|
|
\* blocks. This is a deep observation.
|
|
/\ \E result \in {"finishedSuccess", "finishedFailure"}:
|
|
LET targetStatus == [h \in HeightsRange |-> "StateVerified"] IN
|
|
\* call VerifyToTarget for (commonHeight, nextHeightToTry).
|
|
/\ LC!VerifyToTargetPost(blockchain, isPeerCorrect,
|
|
targetBlocks, targetStatus,
|
|
commonHeight, nextHeightToTry, result)
|
|
\* case 1: the peer has failed (or the trusting period has expired)
|
|
/\ \/ /\ result /= "finishedSuccess"
|
|
/\ state' = <<"FaultyPeer", peer>>
|
|
/\ UNCHANGED <<commonHeight, nextHeightToTry, evidences>>
|
|
\* case 2: success
|
|
\/ /\ result = "finishedSuccess"
|
|
/\ LET block1 == refBlocks[nextHeightToTry] IN
|
|
LET block2 == targetBlocks[nextHeightToTry] IN
|
|
IF block1.header /= block2.header
|
|
THEN \* the target blocks do not match
|
|
/\ state' = <<"FoundEvidence", peer>>
|
|
/\ evidences' = evidences \union
|
|
{[peer |-> peer,
|
|
conflictingBlock |-> block1,
|
|
commonHeight |-> commonHeight]}
|
|
/\ UNCHANGED <<commonHeight, nextHeightToTry>>
|
|
ELSE \* the target blocks match
|
|
/\ nextHeightToTry' = PickNextHeight(refBlocks, nextHeightToTry)
|
|
/\ commonHeight' = nextHeightToTry
|
|
/\ state' = IF nextHeightToTry' >= 0
|
|
THEN state
|
|
ELSE <<"NoEvidence", peer>>
|
|
/\ UNCHANGED evidences
|
|
|
|
SwitchToPrimary ==
|
|
/\ state = <<"FoundEvidence", "SECONDARY">>
|
|
/\ nextHeightToTry' = PickNextHeight(fetchedLightBlocks2, commonHeight)
|
|
/\ state' = <<"CreateEvidence", "PRIMARY">>
|
|
/\ UNCHANGED <<blockchain, refClock, Faulty, localClock,
|
|
fetchedLightBlocks1, fetchedLightBlocks2, fetchedLightBlocks1b,
|
|
commonHeight, evidences >>
|
|
|
|
|
|
CreateEvidenceForSecondary ==
|
|
/\ CreateEvidence("SECONDARY", IS_SECONDARY_CORRECT,
|
|
fetchedLightBlocks1, fetchedLightBlocks2')
|
|
/\ UNCHANGED <<blockchain, refClock, Faulty, localClock,
|
|
fetchedLightBlocks1, fetchedLightBlocks1b>>
|
|
|
|
CreateEvidenceForPrimary ==
|
|
/\ CreateEvidence("PRIMARY", IS_PRIMARY_CORRECT,
|
|
fetchedLightBlocks2,
|
|
fetchedLightBlocks1b')
|
|
/\ UNCHANGED <<blockchain, Faulty,
|
|
fetchedLightBlocks1, fetchedLightBlocks2>>
|
|
|
|
(*
|
|
The local and global clocks can be updated. They can also drift from each other.
|
|
Note that the local clock can actually go backwards in time.
|
|
However, it still stays in the drift envelope
|
|
of [refClock - REAL_CLOCK_DRIFT, refClock + REAL_CLOCK_DRIFT].
|
|
*)
|
|
AdvanceClocks ==
|
|
/\ \E tm \in Int:
|
|
tm >= refClock /\ refClock' = tm
|
|
/\ \E tm \in Int:
|
|
/\ tm >= localClock
|
|
/\ LC!IsLocalClockWithinDrift(tm, refClock')
|
|
/\ localClock' = tm
|
|
|
|
(**
|
|
Execute AttackDetector for one secondary.
|
|
|
|
[LCD-FUNC-DETECTOR.2::LOOP.1]
|
|
*)
|
|
Next ==
|
|
/\ AdvanceClocks
|
|
/\ \/ CompareLast
|
|
\/ CreateEvidenceForSecondary
|
|
\/ SwitchToPrimary
|
|
\/ CreateEvidenceForPrimary
|
|
|
|
|
|
\* simple invariants to see the progress of the detector
|
|
NeverNoEvidence == state[1] /= "NoEvidence"
|
|
NeverFoundEvidence == state[1] /= "FoundEvidence"
|
|
NeverFaultyPeer == state[1] /= "FaultyPeer"
|
|
NeverCreateEvidence == state[1] /= "CreateEvidence"
|
|
|
|
NeverFoundEvidencePrimary == state /= <<"FoundEvidence", "PRIMARY">>
|
|
|
|
NeverReachTargetHeight == nextHeightToTry < TARGET_HEIGHT
|
|
|
|
EvidenceWhenFaultyInv ==
|
|
(state[1] = "FoundEvidence") => (~IS_PRIMARY_CORRECT \/ ~IS_SECONDARY_CORRECT)
|
|
|
|
NoEvidenceForCorrectInv ==
|
|
IS_PRIMARY_CORRECT /\ IS_SECONDARY_CORRECT => evidences = {} <: {ET}
|
|
|
|
(**
|
|
* If we find an evidence by peer A, peer B has ineded given us a corrupted
|
|
* header following the common height. Also, we have a verification trace by peer A.
|
|
*)
|
|
CommonHeightOnEvidenceInv ==
|
|
\A e \in evidences:
|
|
LET conflicting == e.conflictingBlock IN
|
|
LET conflictingHeader == conflicting.header IN
|
|
\* the evidence by suspectingPeer can be verified by suspectingPeer in one step
|
|
LET SoundEvidence(suspectingPeer, peerBlocks) ==
|
|
\/ e.peer /= suspectingPeer
|
|
\* the conflicting block from another peer verifies against the common height
|
|
\/ /\ "SUCCESS" =
|
|
LC!ValidAndVerifiedUntimed(peerBlocks[e.commonHeight], conflicting)
|
|
\* and the headers of the same height by the two peers do not match
|
|
/\ peerBlocks[conflictingHeader.height].header /= conflictingHeader
|
|
IN
|
|
/\ SoundEvidence("PRIMARY", fetchedLightBlocks1b)
|
|
/\ SoundEvidence("SECONDARY", fetchedLightBlocks2)
|
|
|
|
(**
|
|
* If the light client does not find an evidence,
|
|
* then there is no attack on the light client.
|
|
*)
|
|
AccuracyInv ==
|
|
(LC!InTrustingPeriodLocal(fetchedLightBlocks1[TARGET_HEIGHT].header)
|
|
/\ state = <<"NoEvidence", "SECONDARY">>)
|
|
=>
|
|
(fetchedLightBlocks1[TARGET_HEIGHT].header = blockchain[TARGET_HEIGHT]
|
|
/\ fetchedLightBlocks2[TARGET_HEIGHT].header = blockchain[TARGET_HEIGHT])
|
|
|
|
(**
|
|
* The primary reports a corrupted block at the target height. If the secondary is
|
|
* correct and the algorithm has terminated, we should get the evidence.
|
|
* This property is violated due to clock drift. VerifyToTarget may fail with
|
|
* the correct secondary within the trusting period (due to clock drift, locally
|
|
* we think that we are outside of the trusting period).
|
|
*)
|
|
PrecisionInvGrayZone ==
|
|
(/\ fetchedLightBlocks1[TARGET_HEIGHT].header /= blockchain[TARGET_HEIGHT]
|
|
/\ BC!InTrustingPeriod(blockchain[TRUSTED_HEIGHT])
|
|
/\ IS_SECONDARY_CORRECT
|
|
/\ IsTerminated)
|
|
=>
|
|
evidences /= {} <: {ET}
|
|
|
|
(**
|
|
* The primary reports a corrupted block at the target height. If the secondary is
|
|
* correct and the algorithm has terminated, we should get the evidence.
|
|
* This invariant does not fail, as we are using the local clock to check the trusting
|
|
* period.
|
|
*)
|
|
PrecisionInvLocal ==
|
|
(/\ fetchedLightBlocks1[TARGET_HEIGHT].header /= blockchain[TARGET_HEIGHT]
|
|
/\ LC!InTrustingPeriodLocalSurely(blockchain[TRUSTED_HEIGHT])
|
|
/\ IS_SECONDARY_CORRECT
|
|
/\ IsTerminated)
|
|
=>
|
|
evidences /= {} <: {ET}
|
|
|
|
====================================================================================
|