mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
cert: derive a certificate's quorum from the set, never from the certificate
A certificate carries a Threshold, and until now that field was its own authority on the count-only road: Verify's last clause counts distinct valid accepts against the number the CERTIFICATE names, so one declaring 1 clears it on a single signature. C++ has always demanded the derived floor exactly, so the same certificate was accepted by two implementations and refused by the third. VerifyWeighted now requires Threshold == SignerFloor(tier, n) — the floor this set gives this rung. Equality and not a lower bound: an over-claim names a bar this set does not set, and tolerating it would let a certificate redefine the rung upward exactly as tolerating an under-claim lets it redefine the rung down. SignerFloor is the one definition of a rung's count floor, read by the rung predicates, by the assemblers and by the new clause, so the number enforced and the number declared cannot drift. Two corollaries the rule forces: No set, no export. BuildVerifiedQuorumCert refuses any tier that authorizes export when no stake source is supplied. That road counts votes against the caller's own alpha, so an export token could be minted over one signer with alpha of one — and AcceptWithCert trusts the token, which is the point of the token. The accept rung keeps the road: it authorizes local execution the chain can still reorg away. The assembler reads the same n the verifier does. assembleCertLocked derived Nova's declared floor from effectiveCommittee, which sizes the SAMPLE and is floored at the minimum Byzantine committee. At three signers the sampler said three and the set said two, so a node would have built a certificate its own peers refuse. The export path already read SignerCount. The corpus grows five rows — three that declare a quorum of their own, and two lopsided sets where the stake half binds alone, which an equal set cannot state because both halves bind at one edge there. Two frozen verdicts change the clause they are NAMED by, from the stake floor to the count, and neither changes from refused: over an equal set a certificate short of its stake is also short of the quorum it now has to declare. The rungs' own count clauses are unreachable as a result — Verify proves count reaches the declaration and the new clause proves the declaration is the floor — and are kept as the place the floor is defined and as the guard that survives any change to the declaration rule. Written down in LLM.md beside the other walls behind walls. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -386,6 +386,33 @@ from the authoritative validator set at the cert's epoch:
|
||||
| Nova | n ≥ 1 | ≥ `chain.NovaSignerFloor(n)` | > `config.HalfStakeFloor(signer)` | local execution (reorgable) |
|
||||
| Quasar | n ≥ `minBFTCommittee` (4) | ≥ `config.TwoThirdsCount(n)` | > `config.TwoThirdsStakeFloor(signer)` | export — bridges, DEX, cross-chain |
|
||||
|
||||
A certificate also DECLARES a quorum, in `QuorumCert.Threshold`, and that number
|
||||
must be exactly `chain.SignerFloor(tier, n)` — the floor the set derives for the
|
||||
rung. **A certificate states its quorum; it does not choose it.** Without the
|
||||
clause the field is load-bearing: `Verify`'s last clause counts distinct valid
|
||||
accepts against the certificate's OWN threshold, so one declaring 1 clears it on a
|
||||
single signature, and only the floors above stand between that and finality. The
|
||||
rule is EQUALITY, not a lower bound — an over-claim names a bar this set does not
|
||||
set, and tolerating it would let a certificate redefine the rung upward exactly as
|
||||
tolerating an under-claim lets it redefine the rung down. `SignerFloor` is the ONE
|
||||
definition (Rust `finality::signer_floor`, C++ `signer_floor(Tier, n)`), read by
|
||||
the rung predicates, by the assemblers, and by this clause, so the number enforced
|
||||
and the number declared cannot drift.
|
||||
|
||||
Two corollaries, both load-bearing:
|
||||
|
||||
- **No set, no export.** `BuildVerifiedQuorumCert` refuses any tier for which
|
||||
`Finality.AuthorizesExport` holds when `stake` is nil (`ErrExportNeedsStake`).
|
||||
The count-only road counts votes against the caller's own alpha, so an export
|
||||
token could be minted over one signer with `alpha=1`. The accept rung keeps that
|
||||
road — it authorizes only local execution the chain can reorg away.
|
||||
- **The assembler reads the same n the verifier does.** `assembleCertLocked`
|
||||
derives Nova's declared floor from `stakeSource.SignerCount`, not from
|
||||
`effectiveCommittee` (which sizes the SAMPLE and is floored at
|
||||
`minBFTCommittee`). At three signers the two disagreed — the sampler said three,
|
||||
the set said two — so a node would have built a certificate its own peers refuse.
|
||||
`attestation.go` already read `SignerCount` for the export rung.
|
||||
|
||||
Both numbers are read over the SIGNERS: `signer` is `StakeSource.SignerStake` and
|
||||
`n` is `StakeSource.SignerCount`, never the membership roll. A member the chain
|
||||
carries without a key is a spectator — it holds stake and can never cast a vote
|
||||
@@ -491,6 +518,16 @@ Four statements in the consensus core are unreachable through any public entry
|
||||
point. They are walls behind walls, they are correct, and chasing them costs an
|
||||
afternoon each — so they are written down instead.
|
||||
|
||||
- `cert.go` `verifyNovaMajority`/`verifyQuasarSupermajority`'s COUNT clauses
|
||||
(`VoterCount() < SignerFloor(...)`), since the derived-threshold rule landed.
|
||||
`Verify` proves `count >= Threshold` and the derived clause proves
|
||||
`Threshold == SignerFloor`, so `count >= SignerFloor` follows and neither clause
|
||||
can fire. They are kept deliberately: they are where the floor is DEFINED for a
|
||||
reader, and they are the guard that survives any future change to the
|
||||
declaration rule — removing them would leave the count floor resting entirely on
|
||||
a clause about a wire field. Rust and C++ carry the same redundancy for the same
|
||||
reason. This is why the corpus's under-quorum rows are now named `belowThreshold`
|
||||
by `Verify` rather than by the rung.
|
||||
- `cert.go` `VerifyWeighted`'s `default:` unknown-tier arm. `Verify` runs first
|
||||
and refuses any tier outside {Nova, Quasar}, so the switch below it never sees
|
||||
one. `TestATierIsRefusedByVerifyBeforeVerifyWeightedSeesIt` pins that ORDER, so
|
||||
|
||||
+712
-14
@@ -232,7 +232,7 @@
|
||||
]
|
||||
},
|
||||
"verdict": {
|
||||
"note": "what the predicate DECIDES, not what the encoder emits. Every vote is resolved as correctly signed, so a case turns on the weighted half alone: the distinct-signer floor and the stake floor. BOTH rungs carry both. Nova (local execution) needs signers \u003e= novaSignerFloor(n) AND voted \u003e floor(total/2). Quasar (export) needs n \u003e= 4 AND signers \u003e= floor(2*n/3)+1 AND voted \u003e floor(2*total/3) — the same supermajority in seats and in stake, and neither half is sufficient alone, because stake alone lets one holder of two thirds mint export finality on one signature. The n \u003e= 4 clause is a floor on the SET and not on the voters: Byzantine tolerance is f = floor((n-1)/3), which is zero for n of one, two and three, so a two-thirds supermajority over such a set tolerates no fault and one compromised key forges it. Both quorum floors shrink with n and neither catches it — floor(2*1/3)+1 is 1 — so it is stated separately. n and total are read over the SIGNERS: a seat marked keyless holds stake, can never sign, and is in neither denominator. The decision does not depend on the position the votes were cast over, so a runner may weigh these votes over any position its encoder can build.",
|
||||
"note": "what the predicate DECIDES, not what the encoder emits. Every vote is resolved as correctly signed, so a case turns on the weighted half alone: the distinct-signer floor and the stake floor. BOTH rungs carry both. Nova (local execution) needs signers \u003e= novaSignerFloor(n) AND voted \u003e floor(total/2). Quasar (export) needs n \u003e= 4 AND signers \u003e= floor(2*n/3)+1 AND voted \u003e floor(2*total/3) — the same supermajority in seats and in stake, and neither half is sufficient alone, because stake alone lets one holder of two thirds mint export finality on one signature. The n \u003e= 4 clause is a floor on the SET and not on the voters: Byzantine tolerance is f = floor((n-1)/3), which is zero for n of one, two and three, so a two-thirds supermajority over such a set tolerates no fault and one compromised key forges it. Both quorum floors shrink with n and neither catches it — floor(2*1/3)+1 is 1 — so it is stated separately. n and total are read over the SIGNERS: a seat marked keyless holds stake, can never sign, and is in neither denominator. The decision does not depend on the position the votes were cast over, so a runner may weigh these votes over any position its encoder can build. DERIVED AUTHORITY, the clause every row above also states: a certificate DECLARES a threshold, and it must be exactly signerFloor — the floor the rung derives over this set. A certificate states its quorum; it does not choose it. Without that clause the field is load-bearing, because the tier-agnostic count clause counts distinct valid accepts against the certificate's OWN declaration, so one declaring 1 clears it on a single signature. Equality and not a lower bound: an over-claim names a bar this set does not set, and tolerating it would let a certificate redefine the rung upward exactly as tolerating an under-claim lets it redefine the rung down. The threshold field is recorded per row because three rows declare something else on purpose, and a runner must be handed the number the certificate carried.",
|
||||
"epoch": "7",
|
||||
"finality": [
|
||||
{
|
||||
@@ -408,7 +408,7 @@
|
||||
"total": "41",
|
||||
"signerFloor": 3,
|
||||
"stakeFloor": "20",
|
||||
"threshold": 20,
|
||||
"threshold": 3,
|
||||
"signers": [
|
||||
"0000000000000000000000000000000000000001",
|
||||
"0000000000000000000000000000000000000002",
|
||||
@@ -608,7 +608,7 @@
|
||||
"total": "41",
|
||||
"signerFloor": 3,
|
||||
"stakeFloor": "20",
|
||||
"threshold": 21,
|
||||
"threshold": 3,
|
||||
"signers": [
|
||||
"0000000000000000000000000000000000000001",
|
||||
"0000000000000000000000000000000000000002",
|
||||
@@ -638,7 +638,7 @@
|
||||
},
|
||||
{
|
||||
"name": "quasar_below_supermajority",
|
||||
"note": "twenty-seven of forty-one: exactly the supermajority floor, refused for the same reason",
|
||||
"note": "twenty-seven of forty-one: exactly the supermajority floor, and equality is not a quorum here either. Over an EQUAL set the two halves of the export rule bind at the same edge, so the certificate is one signature short of the twenty-eight it declares and the count clause names it — the stake is equally short, and quasar_count_met_stake_short is the lopsided row where the stake half binds on its own",
|
||||
"rung": "quasar",
|
||||
"set": [
|
||||
{
|
||||
@@ -809,7 +809,7 @@
|
||||
"total": "41",
|
||||
"signerFloor": 28,
|
||||
"stakeFloor": "27",
|
||||
"threshold": 27,
|
||||
"threshold": 28,
|
||||
"signers": [
|
||||
"0000000000000000000000000000000000000001",
|
||||
"0000000000000000000000000000000000000002",
|
||||
@@ -841,7 +841,7 @@
|
||||
],
|
||||
"voted": "27",
|
||||
"accept": false,
|
||||
"refusal": "stakeBelowSupermajority"
|
||||
"refusal": "belowThreshold"
|
||||
},
|
||||
{
|
||||
"name": "quasar_supermajority",
|
||||
@@ -1076,7 +1076,7 @@
|
||||
"total": "4",
|
||||
"signerFloor": 3,
|
||||
"stakeFloor": "2",
|
||||
"threshold": 1,
|
||||
"threshold": 3,
|
||||
"signers": [
|
||||
"0000000000000000000000000000000000000001"
|
||||
],
|
||||
@@ -1086,7 +1086,7 @@
|
||||
},
|
||||
{
|
||||
"name": "quasar_one_of_four",
|
||||
"note": "one of four equal seats at the export rung: refused on stake, which is the only floor this rung has",
|
||||
"note": "one of four equal seats at the export rung. One signature is short of the three this set derives and one unit is short of two thirds of four, so both halves refuse and the count names it. The rung has TWO floors and this row clears neither; quasar_whale_alone isolates the count and quasar_count_met_stake_short isolates the stake",
|
||||
"rung": "quasar",
|
||||
"set": [
|
||||
{
|
||||
@@ -1109,13 +1109,13 @@
|
||||
"total": "4",
|
||||
"signerFloor": 3,
|
||||
"stakeFloor": "2",
|
||||
"threshold": 1,
|
||||
"threshold": 3,
|
||||
"signers": [
|
||||
"0000000000000000000000000000000000000001"
|
||||
],
|
||||
"voted": "1",
|
||||
"accept": false,
|
||||
"refusal": "stakeBelowSupermajority"
|
||||
"refusal": "belowThreshold"
|
||||
},
|
||||
{
|
||||
"name": "nova_whale_alone",
|
||||
@@ -1146,7 +1146,7 @@
|
||||
"total": "104",
|
||||
"signerFloor": 3,
|
||||
"stakeFloor": "52",
|
||||
"threshold": 1,
|
||||
"threshold": 3,
|
||||
"signers": [
|
||||
"0000000000000000000000000000000000000001"
|
||||
],
|
||||
@@ -1222,7 +1222,7 @@
|
||||
"total": "104",
|
||||
"signerFloor": 4,
|
||||
"stakeFloor": "69",
|
||||
"threshold": 1,
|
||||
"threshold": 4,
|
||||
"signers": [
|
||||
"0000000000000000000000000000000000000001"
|
||||
],
|
||||
@@ -1344,7 +1344,7 @@
|
||||
"total": "600",
|
||||
"signerFloor": 5,
|
||||
"stakeFloor": "400",
|
||||
"threshold": 6,
|
||||
"threshold": 5,
|
||||
"signers": [
|
||||
"0000000000000000000000000000000000000001",
|
||||
"0000000000000000000000000000000000000002",
|
||||
@@ -1357,6 +1357,704 @@
|
||||
"accept": true,
|
||||
"refusal": ""
|
||||
},
|
||||
{
|
||||
"name": "quasar_threshold_underclaimed",
|
||||
"note": "THE UNDER-CLAIM. The accepting twenty-eight-of-forty-one certificate above, byte for byte, except that it declares a quorum of ONE. Every signature still verifies and the stake is still a supermajority; the only thing wrong is the number. It matters because the tier-agnostic count clause counts distinct valid accepts against the certificate's OWN declaration, so a declaration of one turns 'twenty-eight validators agreed' into 'one did' for any reader that has no validator set to check against. The floor a certificate is held to is a function of the SET and the RUNG, so the declaration is a claim to be checked and never a value to adopt",
|
||||
"rung": "quasar",
|
||||
"set": [
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000001",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000002",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000003",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000004",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000005",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000006",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000007",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000008",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000009",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000a",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000b",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000c",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000d",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000e",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000f",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000010",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000011",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000012",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000013",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000014",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000015",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000016",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000017",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000018",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000019",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001a",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001b",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001c",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001d",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001e",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001f",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000020",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000021",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000022",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000023",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000024",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000025",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000026",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000027",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000028",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000029",
|
||||
"weight": "1"
|
||||
}
|
||||
],
|
||||
"total": "41",
|
||||
"signerFloor": 28,
|
||||
"stakeFloor": "27",
|
||||
"threshold": 1,
|
||||
"signers": [
|
||||
"0000000000000000000000000000000000000001",
|
||||
"0000000000000000000000000000000000000002",
|
||||
"0000000000000000000000000000000000000003",
|
||||
"0000000000000000000000000000000000000004",
|
||||
"0000000000000000000000000000000000000005",
|
||||
"0000000000000000000000000000000000000006",
|
||||
"0000000000000000000000000000000000000007",
|
||||
"0000000000000000000000000000000000000008",
|
||||
"0000000000000000000000000000000000000009",
|
||||
"000000000000000000000000000000000000000a",
|
||||
"000000000000000000000000000000000000000b",
|
||||
"000000000000000000000000000000000000000c",
|
||||
"000000000000000000000000000000000000000d",
|
||||
"000000000000000000000000000000000000000e",
|
||||
"000000000000000000000000000000000000000f",
|
||||
"0000000000000000000000000000000000000010",
|
||||
"0000000000000000000000000000000000000011",
|
||||
"0000000000000000000000000000000000000012",
|
||||
"0000000000000000000000000000000000000013",
|
||||
"0000000000000000000000000000000000000014",
|
||||
"0000000000000000000000000000000000000015",
|
||||
"0000000000000000000000000000000000000016",
|
||||
"0000000000000000000000000000000000000017",
|
||||
"0000000000000000000000000000000000000018",
|
||||
"0000000000000000000000000000000000000019",
|
||||
"000000000000000000000000000000000000001a",
|
||||
"000000000000000000000000000000000000001b",
|
||||
"000000000000000000000000000000000000001c"
|
||||
],
|
||||
"voted": "28",
|
||||
"accept": false,
|
||||
"refusal": "thresholdNotDerived"
|
||||
},
|
||||
{
|
||||
"name": "quasar_threshold_overclaimed",
|
||||
"note": "THE OVER-CLAIM, refused for the same reason in the other direction. THIRTY of forty-one sign — a supermajority in seats and in stake, so this certificate would carry — and it declares a quorum of twenty-nine where the set derives twenty-eight. Thirty signatures clear twenty-nine, so the count clause has nothing to say and the ONLY thing refusing this row is the derived clause. That is the point of stating it: an over-claim ABOVE the vote count is already caught by counting, and one below it is caught by nothing else. A verifier that tolerated this would let a certificate redefine the rung upward exactly as tolerating the under-claim lets it redefine the rung down. Neither direction is the set's number, so the rule is equality",
|
||||
"rung": "quasar",
|
||||
"set": [
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000001",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000002",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000003",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000004",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000005",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000006",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000007",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000008",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000009",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000a",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000b",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000c",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000d",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000e",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000f",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000010",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000011",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000012",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000013",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000014",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000015",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000016",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000017",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000018",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000019",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001a",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001b",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001c",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001d",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001e",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001f",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000020",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000021",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000022",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000023",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000024",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000025",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000026",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000027",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000028",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000029",
|
||||
"weight": "1"
|
||||
}
|
||||
],
|
||||
"total": "41",
|
||||
"signerFloor": 28,
|
||||
"stakeFloor": "27",
|
||||
"threshold": 29,
|
||||
"signers": [
|
||||
"0000000000000000000000000000000000000001",
|
||||
"0000000000000000000000000000000000000002",
|
||||
"0000000000000000000000000000000000000003",
|
||||
"0000000000000000000000000000000000000004",
|
||||
"0000000000000000000000000000000000000005",
|
||||
"0000000000000000000000000000000000000006",
|
||||
"0000000000000000000000000000000000000007",
|
||||
"0000000000000000000000000000000000000008",
|
||||
"0000000000000000000000000000000000000009",
|
||||
"000000000000000000000000000000000000000a",
|
||||
"000000000000000000000000000000000000000b",
|
||||
"000000000000000000000000000000000000000c",
|
||||
"000000000000000000000000000000000000000d",
|
||||
"000000000000000000000000000000000000000e",
|
||||
"000000000000000000000000000000000000000f",
|
||||
"0000000000000000000000000000000000000010",
|
||||
"0000000000000000000000000000000000000011",
|
||||
"0000000000000000000000000000000000000012",
|
||||
"0000000000000000000000000000000000000013",
|
||||
"0000000000000000000000000000000000000014",
|
||||
"0000000000000000000000000000000000000015",
|
||||
"0000000000000000000000000000000000000016",
|
||||
"0000000000000000000000000000000000000017",
|
||||
"0000000000000000000000000000000000000018",
|
||||
"0000000000000000000000000000000000000019",
|
||||
"000000000000000000000000000000000000001a",
|
||||
"000000000000000000000000000000000000001b",
|
||||
"000000000000000000000000000000000000001c",
|
||||
"000000000000000000000000000000000000001d",
|
||||
"000000000000000000000000000000000000001e"
|
||||
],
|
||||
"voted": "30",
|
||||
"accept": false,
|
||||
"refusal": "thresholdNotDerived"
|
||||
},
|
||||
{
|
||||
"name": "nova_threshold_underclaimed",
|
||||
"note": "THE UNDER-CLAIM at the accept rung, because the rule is one rule and not an export special case. Twenty-one of forty-one carry a stake majority and the certificate declares a quorum of one; the accept rung derives three over this set — its count floor saturates — and three is what the certificate must say",
|
||||
"rung": "nova",
|
||||
"set": [
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000001",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000002",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000003",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000004",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000005",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000006",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000007",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000008",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000009",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000a",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000b",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000c",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000d",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000e",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000000f",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000010",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000011",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000012",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000013",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000014",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000015",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000016",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000017",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000018",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000019",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001a",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001b",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001c",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001d",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001e",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "000000000000000000000000000000000000001f",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000020",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000021",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000022",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000023",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000024",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000025",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000026",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000027",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000028",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000029",
|
||||
"weight": "1"
|
||||
}
|
||||
],
|
||||
"total": "41",
|
||||
"signerFloor": 3,
|
||||
"stakeFloor": "20",
|
||||
"threshold": 1,
|
||||
"signers": [
|
||||
"0000000000000000000000000000000000000001",
|
||||
"0000000000000000000000000000000000000002",
|
||||
"0000000000000000000000000000000000000003",
|
||||
"0000000000000000000000000000000000000004",
|
||||
"0000000000000000000000000000000000000005",
|
||||
"0000000000000000000000000000000000000006",
|
||||
"0000000000000000000000000000000000000007",
|
||||
"0000000000000000000000000000000000000008",
|
||||
"0000000000000000000000000000000000000009",
|
||||
"000000000000000000000000000000000000000a",
|
||||
"000000000000000000000000000000000000000b",
|
||||
"000000000000000000000000000000000000000c",
|
||||
"000000000000000000000000000000000000000d",
|
||||
"000000000000000000000000000000000000000e",
|
||||
"000000000000000000000000000000000000000f",
|
||||
"0000000000000000000000000000000000000010",
|
||||
"0000000000000000000000000000000000000011",
|
||||
"0000000000000000000000000000000000000012",
|
||||
"0000000000000000000000000000000000000013",
|
||||
"0000000000000000000000000000000000000014",
|
||||
"0000000000000000000000000000000000000015"
|
||||
],
|
||||
"voted": "21",
|
||||
"accept": false,
|
||||
"refusal": "thresholdNotDerived"
|
||||
},
|
||||
{
|
||||
"name": "quasar_count_met_stake_short",
|
||||
"note": "Four of five sign and the holder of a hundred of a hundred and four is not among them. Four distinct signers meet floor(2*5/3)+1, so the export count floor is satisfied outright and the certificate declares exactly it — and four units of stake does not exceed floor(2*104/3)=69, so the row is refused on STAKE alone. It is the mirror of quasar_whale_alone, which has the stake and not the seats; between them the two halves of the export rule are each shown binding on their own",
|
||||
"rung": "quasar",
|
||||
"set": [
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000001",
|
||||
"weight": "100"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000002",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000003",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000004",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000005",
|
||||
"weight": "1"
|
||||
}
|
||||
],
|
||||
"total": "104",
|
||||
"signerFloor": 4,
|
||||
"stakeFloor": "69",
|
||||
"threshold": 4,
|
||||
"signers": [
|
||||
"0000000000000000000000000000000000000002",
|
||||
"0000000000000000000000000000000000000003",
|
||||
"0000000000000000000000000000000000000004",
|
||||
"0000000000000000000000000000000000000005"
|
||||
],
|
||||
"voted": "4",
|
||||
"accept": false,
|
||||
"refusal": "stakeBelowSupermajority"
|
||||
},
|
||||
{
|
||||
"name": "nova_count_met_stake_short",
|
||||
"note": "The same shape one rung down. Three of the minimum registrations sign while the whale abstains: three distinct signers meet the accept rung's saturating floor of three, and three units of a hundred and four is not a strict majority of the stake, so the row is refused on STAKE. Nova reads its majority in weight and not in heads, and this is the row that says so",
|
||||
"rung": "nova",
|
||||
"set": [
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000001",
|
||||
"weight": "100"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000002",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000003",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000004",
|
||||
"weight": "1"
|
||||
},
|
||||
{
|
||||
"nodeID": "0000000000000000000000000000000000000005",
|
||||
"weight": "1"
|
||||
}
|
||||
],
|
||||
"total": "104",
|
||||
"signerFloor": 3,
|
||||
"stakeFloor": "52",
|
||||
"threshold": 3,
|
||||
"signers": [
|
||||
"0000000000000000000000000000000000000002",
|
||||
"0000000000000000000000000000000000000003",
|
||||
"0000000000000000000000000000000000000004"
|
||||
],
|
||||
"voted": "3",
|
||||
"accept": false,
|
||||
"refusal": "stakeBelowMajority"
|
||||
},
|
||||
{
|
||||
"name": "quasar_keyless_count",
|
||||
"note": "THE KEYLESS DENOMINATOR, count half, isolated. Four seats of a hundred hold keys; two more hold one unit each and no key. The keyless weight is a rounding error on purpose, so the stake floor is cleared under EITHER denominator — four hundred exceeds floor(2*400/3)=266 and floor(2*402/3)=268 alike — and stake cannot be what decides. The count can: floor(2*4/3)+1 is three over the signers and floor(2*6/3)+1 is five over the roll, and four signatures is every one this set is able to produce. Read over the roll, two members holding two units between them strand the export rung of a chain whose four real validators all agree. This is the row a stake-only fix passes and a correct one must also pass",
|
||||
@@ -1392,7 +2090,7 @@
|
||||
"total": "400",
|
||||
"signerFloor": 3,
|
||||
"stakeFloor": "266",
|
||||
"threshold": 4,
|
||||
"threshold": 3,
|
||||
"signers": [
|
||||
"0000000000000000000000000000000000000001",
|
||||
"0000000000000000000000000000000000000002",
|
||||
|
||||
+117
-12
@@ -77,6 +77,11 @@ type Seat struct {
|
||||
// the count floor, the stake floor, or which of the two applies to that rung.
|
||||
// BOTH are the rung's own: Nova's count floor saturates at three, Quasar's is
|
||||
// the export supermajority in seats and grows with the set.
|
||||
// Threshold is what the certificate DECLARES its quorum to be, and every honest
|
||||
// case declares SignerFloor — the floor the rung derives over this set. It is a
|
||||
// recorded field rather than a derived one because the derived-authority rows
|
||||
// declare something else on purpose, and a runner has to be handed the number the
|
||||
// certificate carried in order to reach the same verdict.
|
||||
type FinalityCase struct {
|
||||
Name string `json:"name"`
|
||||
Note string `json:"note"`
|
||||
@@ -244,6 +249,8 @@ func refusal(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case errors.Is(err, chain.ErrQCThresholdNotDerived):
|
||||
return "thresholdNotDerived"
|
||||
case errors.Is(err, chain.ErrQCBelowThreshold):
|
||||
return "belowThreshold"
|
||||
case errors.Is(err, chain.ErrQCStakeBelowMajority):
|
||||
@@ -259,14 +266,26 @@ func refusal(err error) string {
|
||||
}
|
||||
}
|
||||
|
||||
// finality weighs one certificate and records what the live predicate decides.
|
||||
// finality weighs one certificate that declares the quorum its set DERIVES —
|
||||
// what an honest assembler stamps on it — and records what the live predicate
|
||||
// decides.
|
||||
//
|
||||
// The cert's own threshold is set to the number of votes it carries, so the
|
||||
// tier-agnostic count clause always passes and the weighted half is the only
|
||||
// thing a case can turn on. That is the isolation the section is for: a runner
|
||||
// that fails one of these has failed the stake floor or the signer floor, not
|
||||
// the wire and not a signature.
|
||||
// The declared threshold is not a free variable any more. A certificate's floor
|
||||
// is a function of the set and the rung, and the predicate now refuses one that
|
||||
// declares any other number, so "the count clause is neutralised by declaring the
|
||||
// vote count" is no longer a thing a case may do. What isolates the weighted half
|
||||
// here is that every vote is resolved as correctly signed and every certificate
|
||||
// declares the one threshold it is allowed to: a runner that fails one of these
|
||||
// has failed a floor, not the wire and not a signature.
|
||||
func finality(name, note string, rung chain.Finality, s stake, signers []int) FinalityCase {
|
||||
return weigh(name, note, rung, s, signers,
|
||||
chain.SignerFloor(rung, s.SignerCount(verdictEpoch)))
|
||||
}
|
||||
|
||||
// weigh is the body: one certificate, declaring `declared`, weighed against one
|
||||
// set at one rung. Cases that pass anything but the derived floor are stating the
|
||||
// derived-authority attack — a certificate naming a quorum of its own choosing.
|
||||
func weigh(name, note string, rung chain.Finality, s stake, signers []int, declared uint32) FinalityCase {
|
||||
votes := make([]chain.SignedVote, 0, len(signers))
|
||||
names := make([]string, 0, len(signers))
|
||||
var voted uint64
|
||||
@@ -277,10 +296,16 @@ func finality(name, note string, rung chain.Finality, s stake, signers []int) Fi
|
||||
voted += s.Weight(id, verdictEpoch)
|
||||
}
|
||||
|
||||
// Assembled at the vote count so the canonical ordering and dedup clauses run,
|
||||
// then stamped with what this case's certificate DECLARES. Assembly is not the
|
||||
// subject here — a verifier is handed a byte string claiming a threshold, and
|
||||
// an honest assembler cannot even build the under-quorum certificates a
|
||||
// verifier must still refuse.
|
||||
cert, err := chain.AssembleQuorumCert(spec(), rung, uint32(len(votes)), votes)
|
||||
if err != nil {
|
||||
panic("conformance: " + name + ": " + err.Error())
|
||||
}
|
||||
cert.Threshold = declared
|
||||
|
||||
seats := make([]Seat, 0, len(s))
|
||||
for i, m := range s {
|
||||
@@ -298,11 +323,14 @@ func finality(name, note string, rung chain.Finality, s stake, signers []int) Fi
|
||||
total := s.SignerStake(verdictEpoch)
|
||||
n := s.SignerCount(verdictEpoch)
|
||||
floor := config.HalfStakeFloor(total)
|
||||
signerFloor := chain.NovaSignerFloor(n)
|
||||
if rung == chain.Quasar {
|
||||
floor = config.TwoThirdsStakeFloor(total)
|
||||
signerFloor = config.TwoThirdsCount(n)
|
||||
}
|
||||
// Read from the engine's own definition, never restated: signerFloor is the
|
||||
// number the derived-authority clause compares a certificate's declaration
|
||||
// against, so a corpus that computed it a second way could freeze a floor the
|
||||
// predicate does not enforce.
|
||||
signerFloor := int(chain.SignerFloor(rung, n))
|
||||
|
||||
// The decision, read from the live predicate — never restated here.
|
||||
decided := cert.VerifyWeighted(trust{set: s}, s, verdictEpoch)
|
||||
@@ -430,7 +458,17 @@ func verdicts() Verdict {
|
||||
"separately. n and total are read over the SIGNERS: a seat marked keyless holds " +
|
||||
"stake, can never sign, and is in neither denominator. The decision does not " +
|
||||
"depend on the position the votes were cast over, so a runner may weigh these " +
|
||||
"votes over any position its encoder can build.",
|
||||
"votes over any position its encoder can build. DERIVED AUTHORITY, the clause "+
|
||||
"every row above also states: a certificate DECLARES a threshold, and it must be "+
|
||||
"exactly signerFloor — the floor the rung derives over this set. A certificate "+
|
||||
"states its quorum; it does not choose it. Without that clause the field is "+
|
||||
"load-bearing, because the tier-agnostic count clause counts distinct valid "+
|
||||
"accepts against the certificate's OWN declaration, so one declaring 1 clears it "+
|
||||
"on a single signature. Equality and not a lower bound: an over-claim names a bar "+
|
||||
"this set does not set, and tolerating it would let a certificate redefine the "+
|
||||
"rung upward exactly as tolerating an under-claim lets it redefine the rung down. "+
|
||||
"The threshold field is recorded per row because three rows declare something "+
|
||||
"else on purpose, and a runner must be handed the number the certificate carried.",
|
||||
Epoch: u64(verdictEpoch),
|
||||
Finality: []FinalityCase{
|
||||
finality("nova_below_majority",
|
||||
@@ -440,7 +478,12 @@ func verdicts() Verdict {
|
||||
"twenty-one of forty-one: one seat past the floor is the whole difference",
|
||||
chain.Nova, equal41, first(21)),
|
||||
finality("quasar_below_supermajority",
|
||||
"twenty-seven of forty-one: exactly the supermajority floor, refused for the same reason",
|
||||
"twenty-seven of forty-one: exactly the supermajority floor, and equality is not "+
|
||||
"a quorum here either. Over an EQUAL set the two halves of the export rule "+
|
||||
"bind at the same edge, so the certificate is one signature short of the "+
|
||||
"twenty-eight it declares and the count clause names it — the stake is "+
|
||||
"equally short, and quasar_count_met_stake_short is the lopsided row where "+
|
||||
"the stake half binds on its own",
|
||||
chain.Quasar, equal41, first(27)),
|
||||
finality("quasar_supermajority",
|
||||
"twenty-eight of forty-one: the export edge. Note the same twenty-eight signers "+
|
||||
@@ -451,8 +494,11 @@ func verdicts() Verdict {
|
||||
"before stake is ever read",
|
||||
chain.Nova, equal4, first(1)),
|
||||
finality("quasar_one_of_four",
|
||||
"one of four equal seats at the export rung: refused on stake, which is the only "+
|
||||
"floor this rung has",
|
||||
"one of four equal seats at the export rung. One signature is short of the "+
|
||||
"three this set derives and one unit is short of two thirds of four, so "+
|
||||
"both halves refuse and the count names it. The rung has TWO floors and "+
|
||||
"this row clears neither; quasar_whale_alone isolates the count and "+
|
||||
"quasar_count_met_stake_short isolates the stake",
|
||||
chain.Quasar, equal4, first(1)),
|
||||
finality("nova_whale_alone",
|
||||
"the holder of a hundred of a hundred and four signs alone: a stake majority many "+
|
||||
@@ -505,6 +551,65 @@ func verdicts() Verdict {
|
||||
"thing that decides this row, and an implementation that moved only the "+
|
||||
"count fails it. Seven members, six signers, above the committee floor",
|
||||
chain.Quasar, keylessStake, first(6)),
|
||||
// THE DERIVED-AUTHORITY ROWS. Everything above is a certificate that
|
||||
// declares the quorum its set gives it; these three declare something
|
||||
// else, which is the only remaining way a certificate could name its own
|
||||
// authority. They are stated over the sets the rows above already use, so
|
||||
// the only variable is the declaration.
|
||||
weigh("quasar_threshold_underclaimed",
|
||||
"THE UNDER-CLAIM. The accepting twenty-eight-of-forty-one certificate above, "+
|
||||
"byte for byte, except that it declares a quorum of ONE. Every signature "+
|
||||
"still verifies and the stake is still a supermajority; the only thing "+
|
||||
"wrong is the number. It matters because the tier-agnostic count clause "+
|
||||
"counts distinct valid accepts against the certificate's OWN declaration, "+
|
||||
"so a declaration of one turns 'twenty-eight validators agreed' into 'one "+
|
||||
"did' for any reader that has no validator set to check against. The floor "+
|
||||
"a certificate is held to is a function of the SET and the RUNG, so the "+
|
||||
"declaration is a claim to be checked and never a value to adopt",
|
||||
chain.Quasar, equal41, first(28), 1),
|
||||
weigh("quasar_threshold_overclaimed",
|
||||
"THE OVER-CLAIM, refused for the same reason in the other direction. THIRTY of "+
|
||||
"forty-one sign — a supermajority in seats and in stake, so this certificate "+
|
||||
"would carry — and it declares a quorum of twenty-nine where the set derives "+
|
||||
"twenty-eight. Thirty signatures clear twenty-nine, so the count clause has "+
|
||||
"nothing to say and the ONLY thing refusing this row is the derived clause. "+
|
||||
"That is the point of stating it: an over-claim ABOVE the vote count is "+
|
||||
"already caught by counting, and one below it is caught by nothing else. A "+
|
||||
"verifier that tolerated this would let a certificate redefine the rung "+
|
||||
"upward exactly as tolerating the under-claim lets it redefine the rung "+
|
||||
"down. Neither direction is the set's number, so the rule is equality",
|
||||
chain.Quasar, equal41, first(30), 29),
|
||||
weigh("nova_threshold_underclaimed",
|
||||
"THE UNDER-CLAIM at the accept rung, because the rule is one rule and not an "+
|
||||
"export special case. Twenty-one of forty-one carry a stake majority and "+
|
||||
"the certificate declares a quorum of one; the accept rung derives three "+
|
||||
"over this set — its count floor saturates — and three is what the "+
|
||||
"certificate must say",
|
||||
chain.Nova, equal41, first(21), 1),
|
||||
|
||||
// THE STAKE HALF, ISOLATED, at each rung. Over an EQUAL set the count and
|
||||
// the stake bind at the same edge, so neither can be seen alone there;
|
||||
// these two are lopsided on purpose. The whale abstains and the minimum
|
||||
// registrations sign, so the quorum is present in SEATS and nowhere near
|
||||
// present in weight — which is the clause a count-only implementation
|
||||
// passes every other row without ever running.
|
||||
finality("quasar_count_met_stake_short",
|
||||
"Four of five sign and the holder of a hundred of a hundred and four is not "+
|
||||
"among them. Four distinct signers meet floor(2*5/3)+1, so the export count "+
|
||||
"floor is satisfied outright and the certificate declares exactly it — and "+
|
||||
"four units of stake does not exceed floor(2*104/3)=69, so the row is "+
|
||||
"refused on STAKE alone. It is the mirror of quasar_whale_alone, which has "+
|
||||
"the stake and not the seats; between them the two halves of the export "+
|
||||
"rule are each shown binding on their own",
|
||||
chain.Quasar, whale, []int{2, 3, 4, 5}),
|
||||
finality("nova_count_met_stake_short",
|
||||
"The same shape one rung down. Three of the minimum registrations sign while "+
|
||||
"the whale abstains: three distinct signers meet the accept rung's "+
|
||||
"saturating floor of three, and three units of a hundred and four is not a "+
|
||||
"strict majority of the stake, so the row is refused on STAKE. Nova reads "+
|
||||
"its majority in weight and not in heads, and this is the row that says so",
|
||||
chain.Nova, whale, []int{2, 3, 4}),
|
||||
|
||||
finality("quasar_keyless_count",
|
||||
"THE KEYLESS DENOMINATOR, count half, isolated. Four seats of a hundred "+
|
||||
"hold keys; two more hold one unit each and no key. The keyless weight "+
|
||||
|
||||
@@ -103,6 +103,12 @@ func TestVerdictsStatedOutright(t *testing.T) {
|
||||
t.Fatalf("%s: a certificate attests nova or quasar, not %q", c.Name, c.Rung)
|
||||
}
|
||||
|
||||
// DERIVED AUTHORITY, transcribed: whatever the floors say, a certificate that
|
||||
// declares a quorum other than the one its set derives is refused. It is
|
||||
// ANDed last because it is an independent clause and not a re-reading of
|
||||
// either floor — every row it decides clears both.
|
||||
want = want && uint32(floor) == c.Threshold
|
||||
|
||||
if c.Accept != want {
|
||||
t.Errorf("%s: corpus says accept=%v, the transcribed rule says %v "+
|
||||
"(rung=%s n=%d signers=%d floor=%d voted=%s total=%s)",
|
||||
@@ -158,10 +164,64 @@ func TestVerdictTallyIsTheSignersSum(t *testing.T) {
|
||||
t.Errorf("%s: recorded tally %s is not the sum of its signers' weights %s",
|
||||
c.Name, c.Voted, sum)
|
||||
}
|
||||
if uint32(len(c.Signers)) != c.Threshold {
|
||||
t.Errorf("%s: threshold %d is not the vote count %d — the count clause is "+
|
||||
"supposed to be satisfied so the weighted half is what decides",
|
||||
c.Name, c.Threshold, len(c.Signers))
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerdictThresholdIsDerived is the derived-authority invariant, stated
|
||||
// independently of the generator: a certificate's declared quorum is a function
|
||||
// of the SET and the RUNG, so every honest row must declare exactly the floor the
|
||||
// corpus records for it. The rows that declare anything else are the ones whose
|
||||
// whole subject is the declaration, and they are required to be refused BY the
|
||||
// derived clause — a row that mis-declared and was refused for some other reason
|
||||
// would leave the clause untested.
|
||||
func TestVerdictThresholdIsDerived(t *testing.T) {
|
||||
misdeclared := 0
|
||||
for _, c := range Build().Verdict.Finality {
|
||||
derived := uint32(c.SignerFloor)
|
||||
if c.Threshold == derived {
|
||||
continue
|
||||
}
|
||||
misdeclared++
|
||||
if c.Accept {
|
||||
t.Errorf("%s: declares %d where the set derives %d and was ACCEPTED — a certificate "+
|
||||
"named its own quorum", c.Name, c.Threshold, derived)
|
||||
}
|
||||
if c.Refusal != "thresholdNotDerived" {
|
||||
t.Errorf("%s: declares %d where the set derives %d but was refused as %q — the "+
|
||||
"derived clause never ran on the row that exists to run it",
|
||||
c.Name, c.Threshold, derived, c.Refusal)
|
||||
}
|
||||
}
|
||||
if misdeclared == 0 {
|
||||
t.Error("no row states a certificate declaring a quorum of its own; the derived clause " +
|
||||
"is frozen by nothing and could be deleted without a failing case")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerdictSignerFloorIsTheRungs holds the recorded floor to the rung's own
|
||||
// arithmetic over the SIGNERS — the number both the predicate and the declaration
|
||||
// are read against, so a corpus that recorded a third number would be telling a
|
||||
// runner the wrong one.
|
||||
func TestVerdictSignerFloorIsTheRungs(t *testing.T) {
|
||||
for _, c := range Build().Verdict.Finality {
|
||||
n := 0
|
||||
for _, s := range c.Set {
|
||||
if !s.Keyless {
|
||||
n++
|
||||
}
|
||||
}
|
||||
// Transcribed, never called: this guard shares no code with the floors it
|
||||
// checks, which is the whole reason it exists beside the golden file.
|
||||
want := n/2 + 1
|
||||
if want > 3 {
|
||||
want = 3
|
||||
}
|
||||
if c.Rung == "quasar" {
|
||||
want = 2*n/3 + 1
|
||||
}
|
||||
if c.SignerFloor != want {
|
||||
t.Errorf("%s: signer floor %d over %d signers, the %s rung derives %d",
|
||||
c.Name, c.SignerFloor, n, c.Rung, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -474,7 +534,7 @@ func TestAdmissionWeightRulesStatedOutright(t *testing.T) {
|
||||
func TestVerdictSectionIsPopulated(t *testing.T) {
|
||||
v := Build().Verdict
|
||||
|
||||
if got, want := len(v.Finality), 13; got != want {
|
||||
if got, want := len(v.Finality), 18; got != want {
|
||||
t.Errorf("%d finality cases, want %d — adding one is deliberate, losing one is not", got, want)
|
||||
}
|
||||
if got, want := len(v.Admission), 4; got != want {
|
||||
|
||||
+71
-7
@@ -110,8 +110,40 @@ var (
|
||||
ErrQCStakeBelowSupermajority = errors.New("chain: cert voters' stake below 2/3 of signer stake (count quorum reached but not stake-weighted supermajority)")
|
||||
ErrQCStakeBelowMajority = errors.New("chain: nova cert voters' stake is not a strict majority of signer stake at the epoch")
|
||||
ErrQCUnknownTier = errors.New("chain: quorum cert finality tier is not Nova or Quasar (a cert attests exactly one of the two accept/export tiers)")
|
||||
// ErrQCThresholdNotDerived is the derived-authority refusal: the cert declares
|
||||
// a quorum floor that is not the one this validator set derives for the tier
|
||||
// the cert attests. A cert may state its floor; it may not CHOOSE it.
|
||||
ErrQCThresholdNotDerived = errors.New("chain: quorum cert threshold is not the floor the validator set derives for its tier (a cert states its quorum, it does not choose it)")
|
||||
)
|
||||
|
||||
// SignerFloor is the number of DISTINCT signers a cert must carry to attest
|
||||
// `tier` over a set of n signers. It is the whole of a certificate's authority
|
||||
// in seats, and it is a function of the set and the rung — never of the cert.
|
||||
//
|
||||
// One definition, read in three places: the assembler picks alpha from it, the
|
||||
// weighted predicate enforces it, and the derived-threshold clause compares the
|
||||
// cert's own declaration against it. A second spelling anywhere is how a
|
||||
// certificate acquires a quorum of its own choosing.
|
||||
//
|
||||
// - Nova → NovaSignerFloor(n), which saturates at three: the local-execution
|
||||
// rung must stay reachable on a small chain, and it is reorgable.
|
||||
// - Quasar → config.TwoThirdsCount(n) = ⌊2n/3⌋+1, the export supermajority read
|
||||
// in seats — the same supermajority the stake clause reads in weight.
|
||||
//
|
||||
// An unknown tier has no floor, so it gets none: 0 is returned and every caller
|
||||
// treats it as a refusal. n<1 is an unresolved set, refused by the predicate
|
||||
// before this is consulted.
|
||||
func SignerFloor(tier Finality, n int) uint32 {
|
||||
switch tier {
|
||||
case Nova:
|
||||
return uint32(NovaSignerFloor(n))
|
||||
case Quasar:
|
||||
return uint32(config.TwoThirdsCount(n))
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// SignedVote is one validator's signed ACCEPT decision over a consensus
|
||||
// position. It is the atom a QuorumCert is assembled from. The signature is
|
||||
// over CanonicalVoteMessage(position) under a scheme the engine does not need
|
||||
@@ -446,11 +478,16 @@ func (f ValidatorSetRootFunc) ValidatorSetRoot(height uint64) ids.ID { return f(
|
||||
// "how much stake agreed" is not enough: it must also be how MANY, out of enough
|
||||
// parties for a supermajority to carry a fault budget at all.
|
||||
//
|
||||
// The tier is read from the cert, but the threshold is re-derived from the authoritative
|
||||
// validator set rather than taken from the cert's self-declared Threshold, so a cert
|
||||
// cannot forge its tier upward: a Nova set of votes relabeled Quasar fails the ⅔-by-stake
|
||||
// check, and a Quasar cert relabeled Nova merely under-claims. An unknown tier fails
|
||||
// closed.
|
||||
// The tier is read from the cert, but every floor is derived from the authoritative
|
||||
// validator set rather than taken from the cert, so a cert cannot forge its tier upward:
|
||||
// a Nova set of votes relabeled Quasar fails the ⅔-by-stake check, and a Quasar cert
|
||||
// relabeled Nova merely under-claims. An unknown tier fails closed.
|
||||
//
|
||||
// DERIVED AUTHORITY, stated once and enforced here: the cert's own Threshold must EQUAL
|
||||
// SignerFloor(tier, n) — the floor this set gives this rung. A certificate may state its
|
||||
// quorum; it may not choose it. That is what makes the field inert: Verify counts votes
|
||||
// against it, so a cert declaring 1 would otherwise clear the count clause on one
|
||||
// signature, and only the floors below would stand between that and finality.
|
||||
//
|
||||
// A nil stake source means no stake model is supplied — the caller uses Verify instead
|
||||
// and is responsible for the equal-stake admission invariant documented on the engine.
|
||||
@@ -463,6 +500,33 @@ func (c *QuorumCert) VerifyWeighted(verifier VoteVerifier, stake StakeSource, ep
|
||||
if stake == nil {
|
||||
return fmt.Errorf("%w: stake source nil", ErrQCStakeBelowSupermajority)
|
||||
}
|
||||
// DERIVED AUTHORITY. A certificate states the quorum it was built to; the SET
|
||||
// decides what that quorum is. The two must be the same number, so the field
|
||||
// carries no authority of its own — it is a claim this clause checks, never a
|
||||
// value the verifier adopts.
|
||||
//
|
||||
// Without it the field is load-bearing on the count-only road: Verify's last
|
||||
// clause counts distinct valid accepts against the cert's OWN Threshold, so a
|
||||
// cert declaring 1 clears it on one signature. The weighted floors below then
|
||||
// catch that, which is why this was a divergence rather than a forgery — but
|
||||
// only Go and Rust asked the weaker question. C++ has always demanded the
|
||||
// derived floor exactly (QuorumCertEngine::verify_cert), so the same cert was
|
||||
// accepted by two implementations and refused by the third. One rule now.
|
||||
//
|
||||
// EQUALITY, not a lower bound. An over-claim is a certificate asserting a
|
||||
// quorum this set does not require, and a verifier that tolerated it would be
|
||||
// letting a cert redefine the rung upward exactly as tolerating an under-claim
|
||||
// lets it redefine the rung downward. Neither direction is the set's number.
|
||||
//
|
||||
// An unresolved set (n<1) derives no floor, so nothing is compared against it
|
||||
// and the rung's own clause refuses the cert by the name it has always been
|
||||
// refused under. Skipping here is not a pass: both rungs fail closed on n<1.
|
||||
if n := stake.SignerCount(epochHeight); n >= 1 {
|
||||
if floor := SignerFloor(c.Tier, n); c.Threshold != floor {
|
||||
return fmt.Errorf("%w: cert declares %d, a set of %d signers derives %d for %s at epoch %d",
|
||||
ErrQCThresholdNotDerived, c.Threshold, n, floor, c.Tier, epochHeight)
|
||||
}
|
||||
}
|
||||
switch c.Tier {
|
||||
case Nova:
|
||||
return c.verifyNovaMajority(stake, epochHeight)
|
||||
@@ -501,7 +565,7 @@ func (c *QuorumCert) verifyNovaMajority(stake StakeSource, epochHeight uint64) e
|
||||
return fmt.Errorf("%w: nova tier over an unresolved validator set (n=%d) at epoch %d",
|
||||
ErrQCBelowThreshold, n, epochHeight)
|
||||
}
|
||||
if floor := NovaSignerFloor(n); c.VoterCount() < floor {
|
||||
if floor := int(SignerFloor(Nova, n)); c.VoterCount() < floor {
|
||||
return fmt.Errorf("%w: nova cert has %d distinct voters, need at least %d of %d at epoch %d",
|
||||
ErrQCBelowThreshold, c.VoterCount(), floor, n, epochHeight)
|
||||
}
|
||||
@@ -619,7 +683,7 @@ func (c *QuorumCert) verifyQuasarSupermajority(stake StakeSource, epochHeight ui
|
||||
"Byzantine committee f=⌊(n−1)/3⌋ is 0 and a two-thirds supermajority tolerates no "+
|
||||
"fault, at epoch %d", ErrQCBelowThreshold, n, minBFTCommittee, epochHeight)
|
||||
}
|
||||
if floor := config.TwoThirdsCount(n); c.VoterCount() < floor {
|
||||
if floor := int(SignerFloor(Quasar, n)); c.VoterCount() < floor {
|
||||
return fmt.Errorf("%w: quasar cert has %d distinct voters, need at least %d of %d at epoch %d",
|
||||
ErrQCBelowThreshold, c.VoterCount(), floor, n, epochHeight)
|
||||
}
|
||||
|
||||
@@ -86,13 +86,21 @@ func newCertFixture(t *testing.T, n int) certFixture {
|
||||
return certFixture{vs: vs, pos: pos, votes: votes, stake: equalStake(vs)}
|
||||
}
|
||||
|
||||
// cert assembles a certificate from the first k of the fixture's votes.
|
||||
// cert assembles a certificate from the first k of the fixture's votes and stamps
|
||||
// what it DECLARES as its quorum.
|
||||
//
|
||||
// Assembly runs at the vote count and the declaration is stamped after, because
|
||||
// the two are different questions and the rows below ask the second one: an
|
||||
// under-quorum certificate is one no honest assembler can build and one every
|
||||
// verifier must still refuse, so stating one means building it the way an
|
||||
// adversary would. The ordering and dedup clauses still run.
|
||||
func (f certFixture) cert(t *testing.T, tier Finality, threshold uint32, k int) *QuorumCert {
|
||||
t.Helper()
|
||||
c, err := AssembleQuorumCert(f.pos, tier, threshold, f.votes[:k])
|
||||
c, err := AssembleQuorumCert(f.pos, tier, uint32(k), f.votes[:k])
|
||||
if err != nil {
|
||||
t.Fatalf("assemble %s cert over %d votes: %v", tier, k, err)
|
||||
}
|
||||
c.Threshold = threshold
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -335,7 +343,7 @@ func TestWeightedRefusalTable(t *testing.T) {
|
||||
},
|
||||
{
|
||||
holds: "nova needs its distinct-signer floor, so a stake majority cannot self-ignite",
|
||||
cert: f.cert(t, Nova, 2, 2),
|
||||
cert: f.cert(t, Nova, uint32(NovaSignerFloor(4)), 2),
|
||||
stake: honest,
|
||||
want: ErrQCBelowThreshold,
|
||||
},
|
||||
@@ -394,12 +402,17 @@ func TestNovaRefusesTheStakeWithoutTheSeats(t *testing.T) {
|
||||
f.vs.nodeID(0): 97, f.vs.nodeID(1): 1, f.vs.nodeID(2): 1, f.vs.nodeID(3): 1,
|
||||
}, signerStake: 100, signers: 4, carried: 100}
|
||||
|
||||
alone, err := AssembleQuorumCert(f.pos, Nova, 1, f.votes[0:1])
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
if err := alone.Verify(f.vs, f.pos.Height); err != nil {
|
||||
t.Fatalf("the lone-holder cert must be signature-valid: %v", err)
|
||||
// The certificate declares the floor the four-seat set derives for the accept
|
||||
// rung — the only threshold VerifyWeighted admits — so the row turns on the lone
|
||||
// signature and not on a number the certificate chose for itself.
|
||||
alone := certDeclaring(t, f.pos, Nova, 4, f.votes[0:1])
|
||||
// The SIGNATURE is real, which is what this line is for: a row of refusals over
|
||||
// a fixture that never signed anything proves only that the fixture is broken.
|
||||
// The certificate itself no longer clears Verify — one signer is below the floor
|
||||
// the set derives and the certificate declares — and that is the same clause the
|
||||
// row is about, reached one step earlier.
|
||||
if !f.vs.VerifyVote(f.votes[0].NodeID, CanonicalVoteMessage(f.pos), f.votes[0].Signature, f.pos.Height) {
|
||||
t.Fatal("the lone-holder signature must verify, or the row proves nothing")
|
||||
}
|
||||
if err := alone.VerifyWeighted(f.vs, lopsided, f.pos.Height); !errors.Is(err, ErrQCBelowThreshold) {
|
||||
t.Fatalf("a stake majority on one signature must not ignite nova, got %v", err)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// derived_authority_test.go — a certificate states its quorum; it does not choose it.
|
||||
//
|
||||
// The floor a certificate is counted against is a function of the SET it is
|
||||
// weighed against and the RUNG it attests. The Threshold field carries that
|
||||
// number so a reader knows what was required, and VerifyWeighted checks the
|
||||
// field against what the set actually derives — so the field is a claim, never a
|
||||
// value the verifier adopts.
|
||||
//
|
||||
// Without the clause the field is load-bearing on the count-only road, because
|
||||
// Verify's last clause counts distinct valid accepts against the certificate's
|
||||
// OWN Threshold. A certificate declaring 1 clears it on one signature.
|
||||
package chain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/consensus/config"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// certDeclaring assembles a certificate over votes and stamps the threshold a set
|
||||
// of n signers DERIVES for the rung — the only declaration VerifyWeighted admits.
|
||||
//
|
||||
// Assembly runs at the vote count rather than at the floor on purpose: an
|
||||
// under-quorum certificate is one no honest assembler can build and one every
|
||||
// verifier must still refuse, so a test that wants to state one has to build it
|
||||
// the way an adversary would. The ordering and dedup clauses still run.
|
||||
func certDeclaring(t *testing.T, pos VotePosition, tier Finality, n int, votes []SignedVote) *QuorumCert {
|
||||
t.Helper()
|
||||
c, err := AssembleQuorumCert(pos, tier, uint32(len(votes)), votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
c.Threshold = SignerFloor(tier, n)
|
||||
return c
|
||||
}
|
||||
|
||||
// TestSignerFloorIsTheOneDefinition holds the two rungs' floors to the arithmetic
|
||||
// the rest of the standard states them in, and holds a non-accept rung to zero —
|
||||
// which every caller reads as a refusal.
|
||||
func TestSignerFloorIsTheOneDefinition(t *testing.T) {
|
||||
for n := 1; n <= 64; n++ {
|
||||
if got, want := SignerFloor(Nova, n), uint32(NovaSignerFloor(n)); got != want {
|
||||
t.Fatalf("n=%d: nova floor %d, want %d", n, got, want)
|
||||
}
|
||||
if got, want := SignerFloor(Quasar, n), uint32(config.TwoThirdsCount(n)); got != want {
|
||||
t.Fatalf("n=%d: quasar floor %d, want %d", n, got, want)
|
||||
}
|
||||
}
|
||||
for _, rung := range []Finality{Photon, Wave, Horizon} {
|
||||
if got := SignerFloor(rung, 41); got != 0 {
|
||||
t.Fatalf("%s is not an accept rung and must derive no floor, got %d", rung, got)
|
||||
}
|
||||
}
|
||||
// The function is exported and its result is unsigned, so a set size below one
|
||||
// must not underflow into a floor no quorum could ever reach. Both rungs are
|
||||
// total there and answer one — the predicate refuses an unresolved set before
|
||||
// consulting this, and a wrapped floor would make that refusal look like a bar.
|
||||
for _, n := range []int{0, -1, -41} {
|
||||
for _, rung := range []Finality{Nova, Quasar} {
|
||||
if got := SignerFloor(rung, n); got != 1 {
|
||||
t.Fatalf("%s over n=%d derives %d, want 1 — an unresolved set has no bar to raise",
|
||||
rung, n, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivedThresholdRefusesAnUnderClaim is finding-3, stated as the attack it
|
||||
// is. A certificate carrying a real export quorum but DECLARING a quorum of one
|
||||
// is refused — and refused on the derived clause, before the rung's own floors,
|
||||
// because the number it declared was never the set's to give.
|
||||
//
|
||||
// The under-claim is what makes the count-only road exploitable: Verify counts
|
||||
// against the declaration, so declaring 1 turns "α distinct validators agreed"
|
||||
// into "one did".
|
||||
func TestDerivedThresholdRefusesAnUnderClaim(t *testing.T) {
|
||||
vs := newTestValidatorSet(4)
|
||||
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()}
|
||||
set := &stakeMap{w: map[ids.NodeID]uint64{
|
||||
vs.nodeID(0): 1, vs.nodeID(1): 1, vs.nodeID(2): 1, vs.nodeID(3): 1}, total: 4}
|
||||
|
||||
votes := make([]SignedVote, 0, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
|
||||
// The honest certificate: four of four, declaring the floor the set derives.
|
||||
honest := certDeclaring(t, pos, Quasar, 4, votes)
|
||||
if honest.Threshold != uint32(config.TwoThirdsCount(4)) {
|
||||
t.Fatalf("the honest cert declares %d, the set derives %d", honest.Threshold, config.TwoThirdsCount(4))
|
||||
}
|
||||
if err := honest.VerifyWeighted(vs, set, 1); err != nil {
|
||||
t.Fatalf("a unanimous export cert declaring the derived floor must verify: %v", err)
|
||||
}
|
||||
|
||||
// The same votes, under-claiming. Every signature still verifies and the stake
|
||||
// is still unanimous — the ONLY thing wrong is the number the cert names.
|
||||
under := certDeclaring(t, pos, Quasar, 4, votes)
|
||||
under.Threshold = 1
|
||||
err := under.VerifyWeighted(vs, set, 1)
|
||||
if !errors.Is(err, ErrQCThresholdNotDerived) {
|
||||
t.Fatalf("a cert declaring a quorum of one must be refused on the derived clause, got %v", err)
|
||||
}
|
||||
|
||||
// And an OVER-claim, for the same reason in the other direction: a certificate
|
||||
// asserting a quorum this set does not require is still naming a number that is
|
||||
// not the set's. Tolerating it would let a cert redefine the rung upward exactly
|
||||
// as tolerating the under-claim lets it redefine the rung down.
|
||||
over := certDeclaring(t, pos, Quasar, 4, votes)
|
||||
over.Threshold = 4
|
||||
if err := over.VerifyWeighted(vs, set, 1); !errors.Is(err, ErrQCThresholdNotDerived) {
|
||||
t.Fatalf("a cert declaring a quorum the set does not require must be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountOnlyRoadCannotMintExport is MED-2. BuildVerifiedQuorumCert is the sole
|
||||
// producer of the finality authority token, and its count-only branch counts votes
|
||||
// against the number the CALLER names. Handed an export rung, one vote and an
|
||||
// alpha of one, every clause on that branch is satisfied by the same self-declared
|
||||
// 1 — and AcceptWithCert trusts the token it returns, which is the whole point of
|
||||
// the token.
|
||||
//
|
||||
// An export certificate's floors are read off a validator set. With no set there is
|
||||
// nothing to read them from, so there is nothing to mint.
|
||||
func TestCountOnlyRoadCannotMintExport(t *testing.T) {
|
||||
vs := newTestValidatorSet(4)
|
||||
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()}
|
||||
one := []SignedVote{{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)}}
|
||||
|
||||
v, err := BuildVerifiedQuorumCert(vs, nil, Quasar, 1, 1, pos, one)
|
||||
if !errors.Is(err, ErrExportNeedsStake) {
|
||||
t.Fatalf("an export token minted over one signer with no stake source, err=%v", err)
|
||||
}
|
||||
if !errors.Is(err, ErrNoVerifiedQC) {
|
||||
t.Fatalf("the refusal must still present as the liveness answer, got %v", err)
|
||||
}
|
||||
if !v.IsZero() {
|
||||
t.Fatal("a refused build must return the zero token")
|
||||
}
|
||||
|
||||
// Every export rung, not a list of tier names kept in step with another list.
|
||||
for _, rung := range []Finality{Quasar, Horizon} {
|
||||
if !rung.AuthorizesExport() {
|
||||
t.Fatalf("%s is expected to authorize export", rung)
|
||||
}
|
||||
if _, err := BuildVerifiedQuorumCert(vs, nil, rung, 1, 1, pos, one); !errors.Is(err, ErrExportNeedsStake) {
|
||||
t.Fatalf("%s minted with no stake source: %v", rung, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The accept rung keeps its count-only road: it authorizes local execution the
|
||||
// chain can still reorg away, and an equal-stake chain has to be able to make
|
||||
// progress on it. Three of four, declaring the count majority that road enforces.
|
||||
votes := make([]SignedVote, 0, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
nova, err := BuildVerifiedQuorumCert(vs, nil, Nova, uint32(NovaQuorum(4)), 1, pos, votes)
|
||||
if err != nil {
|
||||
t.Fatalf("the accept rung must still build without a stake source: %v", err)
|
||||
}
|
||||
if nova.IsZero() {
|
||||
t.Fatal("a nova build that returned no error must carry a witness")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivedThresholdSurvivesAnUnresolvedSet holds the one place the clause steps
|
||||
// aside. A source reporting no signers derives no floor, so there is nothing to
|
||||
// compare a declaration against — and the rung's own clause refuses the cert under
|
||||
// the name it has always been refused by. Stepping aside is not a pass.
|
||||
func TestDerivedThresholdSurvivesAnUnresolvedSet(t *testing.T) {
|
||||
vs := newTestValidatorSet(4)
|
||||
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()}
|
||||
votes := []SignedVote{{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)}}
|
||||
|
||||
cert := certDeclaring(t, pos, Quasar, 0, votes)
|
||||
cert.Threshold = 1 // SignerFloor over an unresolved set is 1; say so outright.
|
||||
err := cert.VerifyWeighted(vs, &stakeMap{w: map[ids.NodeID]uint64{}, total: 0}, 1)
|
||||
if err == nil {
|
||||
t.Fatal("an unresolved set must refuse, not pass")
|
||||
}
|
||||
if errors.Is(err, ErrQCThresholdNotDerived) {
|
||||
t.Fatalf("an unresolved set derives no floor and must not be named by the derived clause: %v", err)
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -3232,7 +3232,17 @@ func (t *Transitive) assembleCertLocked(pending *PendingBlock, blockID ids.ID) *
|
||||
n, _ := t.effectiveCommittee(epochHeight)
|
||||
novaThreshold := NovaQuorum(n)
|
||||
if t.stakeSource != nil {
|
||||
novaThreshold = NovaSignerFloor(n)
|
||||
// DERIVED AUTHORITY, read over the SIGNING SET — the same n VerifyWeighted
|
||||
// derives its floor from, not the sample committee. effectiveCommittee sizes
|
||||
// how many peers a ROUND asks; a certificate's floor is a property of the set
|
||||
// that can sign it, and the two are different numbers whenever K is clamped or
|
||||
// floored away from the live count. Declaring one and being checked against the
|
||||
// other is an assembler that disagrees with its own verifier: at three signers
|
||||
// effectiveCommittee floors K to four and stamps a floor of three, while the
|
||||
// set derives two, and the certificate this node just built would be refused by
|
||||
// the node it is sent to. This is the export path's rule (attestation.go reads
|
||||
// SignerCount for the same reason) applied to the accept rung.
|
||||
novaThreshold = int(SignerFloor(Nova, t.stakeSource.SignerCount(epochHeight)))
|
||||
}
|
||||
if novaThreshold <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -45,6 +45,10 @@ func (s flatSet) unanimous(t *testing.T) *QuorumCert {
|
||||
if err != nil {
|
||||
t.Fatalf("assemble over %d seats: %v", s.n, err)
|
||||
}
|
||||
// The certificate declares the floor this set derives for the export rung — the
|
||||
// only threshold VerifyWeighted admits — so a row that refuses is refused on the
|
||||
// committee floor and not on a number the certificate chose for itself.
|
||||
cert.Threshold = SignerFloor(Quasar, s.n)
|
||||
return cert
|
||||
}
|
||||
|
||||
@@ -131,6 +135,7 @@ func TestExportCommitteeFloorLeavesNovaAlone(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("n=%d: assemble nova: %v", n, err)
|
||||
}
|
||||
cert.Threshold = SignerFloor(Nova, n)
|
||||
if err := cert.VerifyWeighted(alwaysValid{}, set, 0); err != nil {
|
||||
t.Errorf("n=%d: a unanimous NOVA certificate was refused (%v). Nova ignites "+
|
||||
"local execution on a bare majority and has no Byzantine claim to protect; "+
|
||||
|
||||
@@ -118,10 +118,10 @@ func TestKeylessStakeIsNotInTheDenominator(t *testing.T) {
|
||||
for i := 0; i < 6; i++ {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: []byte{0x01}})
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, uint32(len(votes)), votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
// Declaring the floor the SIX SIGNERS derive, not the seven members: a
|
||||
// certificate states the quorum its set gives it, and reading the declaration
|
||||
// over the roll would be the same keyless bug one layer out.
|
||||
cert := certDeclaring(t, pos, Quasar, 6, votes)
|
||||
|
||||
if err := cert.VerifyWeighted(alwaysValid{}, set, epoch); err != nil {
|
||||
t.Fatalf("export refused with the whole signing set agreeing: %v\n"+
|
||||
@@ -131,10 +131,7 @@ func TestKeylessStakeIsNotInTheDenominator(t *testing.T) {
|
||||
// And the rung is still a rung: four of the six signers is short of two thirds
|
||||
// of the stake that CAN sign, so the fix did not flatten the floor into an
|
||||
// accept-anything.
|
||||
short, err := AssembleQuorumCert(pos, Quasar, 4, votes[:4])
|
||||
if err != nil {
|
||||
t.Fatalf("assemble short: %v", err)
|
||||
}
|
||||
short := certDeclaring(t, pos, Quasar, 6, votes[:4])
|
||||
if err := short.VerifyWeighted(alwaysValid{}, set, epoch); err == nil {
|
||||
t.Fatal("four hundred of six hundred cleared the export floor; the rung is gone")
|
||||
}
|
||||
@@ -188,10 +185,10 @@ func TestKeylessCountIsNotInTheDenominator(t *testing.T) {
|
||||
for i := 0; i < 4; i++ {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: []byte{0x01}})
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, uint32(len(votes)), votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
// Declaring the floor the FOUR SIGNERS derive — three — and not the five the
|
||||
// six-member roll would ask for. That is the same denominator, read at the
|
||||
// certificate's declaration rather than at the verifier's floor.
|
||||
cert := certDeclaring(t, pos, Quasar, 4, votes)
|
||||
if err := cert.VerifyWeighted(alwaysValid{}, set, epoch); err != nil {
|
||||
t.Fatalf("export refused with all four signers agreeing: %v\n"+
|
||||
"R5: the COUNT floor was read over seats that cannot sign", err)
|
||||
@@ -199,17 +196,11 @@ func TestKeylessCountIsNotInTheDenominator(t *testing.T) {
|
||||
|
||||
// The rung is still a rung, and its edge is sharp on the signer denominator:
|
||||
// three signers sit exactly ON ⌊2*4/3⌋+1 and carry, two sit below it and do not.
|
||||
edge, err := AssembleQuorumCert(pos, Quasar, 3, votes[:3])
|
||||
if err != nil {
|
||||
t.Fatalf("assemble edge: %v", err)
|
||||
}
|
||||
edge := certDeclaring(t, pos, Quasar, 4, votes[:3])
|
||||
if err := edge.VerifyWeighted(alwaysValid{}, set, epoch); err != nil {
|
||||
t.Fatalf("three signers sit on the export floor and were refused: %v", err)
|
||||
}
|
||||
short, err := AssembleQuorumCert(pos, Quasar, 2, votes[:2])
|
||||
if err != nil {
|
||||
t.Fatalf("assemble short: %v", err)
|
||||
}
|
||||
short := certDeclaring(t, pos, Quasar, 4, votes[:2])
|
||||
if err := short.VerifyWeighted(alwaysValid{}, set, epoch); err == nil {
|
||||
t.Fatal("two of four signers cleared the export rung; the floor is gone")
|
||||
}
|
||||
|
||||
@@ -213,11 +213,10 @@ func TestQuasarCountFloor_StakeCannotBuyExport(t *testing.T) {
|
||||
for _, i := range idx {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
c, err := AssembleQuorumCert(pos, Quasar, uint32(len(idx)), votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble %v: %v", idx, err)
|
||||
}
|
||||
return c
|
||||
// Declaring the floor the five-seat set derives — the only threshold the
|
||||
// export rung admits — so every row below turns on the count or on the
|
||||
// stake, never on a number the certificate named for itself.
|
||||
return certDeclaring(t, pos, Quasar, 5, votes)
|
||||
}
|
||||
|
||||
// The premise: the lone signer really does hold the stake supermajority, so a
|
||||
|
||||
@@ -49,10 +49,9 @@ func certFor(t *testing.T, vs *testValidatorSet, pos VotePosition, voters ...int
|
||||
for _, i := range voters {
|
||||
sv = append(sv, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, uint32(len(voters)), sv)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble cert %v: %v", voters, err)
|
||||
}
|
||||
// The certificate declares the floor the SET derives for the export rung, which
|
||||
// is the only threshold the verifier on the other side of the wire admits.
|
||||
cert := certDeclaring(t, pos, Quasar, len(vs.ids), sv)
|
||||
b, err := cert.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal cert: %v", err)
|
||||
@@ -100,10 +99,7 @@ func decidesViaRuntimeCert(t *testing.T, n int, weights []uint64, alpha int, vot
|
||||
for _, i := range voters {
|
||||
sv = append(sv, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, uint32(len(voters)), sv)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble cert (voters=%v): %v", voters, err)
|
||||
}
|
||||
cert := certDeclaring(t, pos, Quasar, len(vs.ids), sv)
|
||||
certBytes, err := cert.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal cert: %v", err)
|
||||
@@ -270,15 +266,12 @@ func TestBlue_QuorumMatrix_DuplicateAndEquivocation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// the genuine 4-of-4 (>⅔ of four equal seats) finalizes.
|
||||
real4, err := AssembleQuorumCert(pos, Quasar, 4, []SignedVote{
|
||||
real4 := certDeclaring(t, pos, Quasar, len(vs.ids), []SignedVote{
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
|
||||
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
||||
{NodeID: vs.nodeID(3), Accept: true, Signature: vs.sign(3, pos)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("assemble real: %v", err)
|
||||
}
|
||||
b, _ := real4.MarshalBinary()
|
||||
if !rt.HandleIncomingCert(b) || blk.AcceptCalled() != 1 {
|
||||
t.Fatalf("the genuine 4-of-4 (>⅔ stake) must finalize exactly once; accept=%d", blk.AcceptCalled())
|
||||
|
||||
@@ -55,10 +55,7 @@ func TestNovaTallyRefusesAWrappingStakeSource(t *testing.T) {
|
||||
for _, i := range []int{0, 1, 2} {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, Nova, 3, votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
cert := certDeclaring(t, pos, Nova, 5, votes)
|
||||
|
||||
// The count clause must be satisfied, or the case would be refused before
|
||||
// the tally is ever read and would prove nothing about it.
|
||||
@@ -83,7 +80,7 @@ func TestNovaTallyRefusesAWrappingStakeSource(t *testing.T) {
|
||||
}
|
||||
|
||||
// And the predicate refuses it, on the tally rather than on the floor.
|
||||
err = cert.VerifyWeighted(vs, src, epoch)
|
||||
err := cert.VerifyWeighted(vs, src, epoch)
|
||||
if !errors.Is(err, validators.ErrWeightOverflow) {
|
||||
t.Fatalf("a wrapping tally cleared the Nova majority: %v", err)
|
||||
}
|
||||
@@ -99,16 +96,23 @@ func TestQuasarTallyRefusesAWrappingStakeSource(t *testing.T) {
|
||||
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()}
|
||||
const epoch = uint64(1)
|
||||
|
||||
// Two seats hold a weight that wraps when summed, and two hold a unit each so
|
||||
// the certificate reaches the export rung's DISTINCT-SIGNER floor of four. The
|
||||
// count clause has to be satisfied or the tally is never read, and a row about
|
||||
// the tally that is answered by the count proves nothing about the tally.
|
||||
const w = uint64(1)<<63 + 7_000_000_000_000_000_000
|
||||
src := wrappingStake(vs, 5, map[int]uint64{0: w, 1: w})
|
||||
src := wrappingStake(vs, 5, map[int]uint64{0: w, 1: w, 2: 1, 3: 1})
|
||||
|
||||
votes := make([]SignedVote, 0, 2)
|
||||
for _, i := range []int{0, 1} {
|
||||
votes := make([]SignedVote, 0, 4)
|
||||
for _, i := range []int{0, 1, 2, 3} {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, 2, votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
// Declaring the floor the five-signer set derives, so the row reaches the tally
|
||||
// rather than being answered by a threshold the certificate named for itself.
|
||||
cert := certDeclaring(t, pos, Quasar, 5, votes)
|
||||
if floor := int(SignerFloor(Quasar, src.SignerCount(epoch))); cert.VoterCount() < floor {
|
||||
t.Fatalf("this set does not reach the export signer floor (%d of %d), so the tally is never read",
|
||||
cert.VoterCount(), floor)
|
||||
}
|
||||
|
||||
var wrapped uint64
|
||||
@@ -120,7 +124,7 @@ func TestQuasarTallyRefusesAWrappingStakeSource(t *testing.T) {
|
||||
"fail-open left to demonstrate", wrapped, got)
|
||||
}
|
||||
|
||||
err = cert.VerifyWeighted(vs, src, epoch)
|
||||
err := cert.VerifyWeighted(vs, src, epoch)
|
||||
if !errors.Is(err, validators.ErrWeightOverflow) {
|
||||
t.Fatalf("a wrapping tally cleared the export supermajority: %v", err)
|
||||
}
|
||||
@@ -153,10 +157,7 @@ func TestTallyStillSumsASetThatFits(t *testing.T) {
|
||||
for _, i := range []int{0, 1, 2, 3} {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, uint32(minBFTCommittee), votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
cert := certDeclaring(t, pos, Quasar, minBFTCommittee, votes)
|
||||
if err := cert.VerifyWeighted(vs, src, epoch); err != nil {
|
||||
t.Fatalf("a set that fits was refused: %v", err)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ import "errors"
|
||||
// finalize in response to it.
|
||||
var ErrNoVerifiedQC = errors.New("chain: no verified quorum cert for block — not final (liveness retry, not an accept)")
|
||||
|
||||
// ErrExportNeedsStake is the derived-authority refusal at the minting door: an
|
||||
// export-grade authority token was asked for with no stake source to derive the
|
||||
// rung's floors from. Export finality is a claim about a validator set; without
|
||||
// one there is nothing to make the claim against, so there is nothing to mint.
|
||||
var ErrExportNeedsStake = errors.New("chain: an export-tier cert cannot be minted without a stake source — its floors are derived from the validator set, and there is no set here")
|
||||
|
||||
// VerifiedQuorumCert is proof that a block met the finality predicate: α distinct
|
||||
// validators signed ACCEPT over the exact position AND (on a stake-weighted
|
||||
// chain) those voters hold a strict ⅔ supermajority of stake at the cert's
|
||||
@@ -42,9 +48,9 @@ var ErrNoVerifiedQC = errors.New("chain: no verified quorum cert for block — n
|
||||
//
|
||||
// The wrapped cert is unexported. There is deliberately NO exported field and NO
|
||||
// exported raw constructor: a VerifiedQuorumCert can be produced ONLY by
|
||||
// BuildVerifiedQuorumCert (which verifies) or BuildSingleValidatorCert (the
|
||||
// 1-of-1 quorum for K==1). A zero VerifiedQuorumCert{} carries a nil cert and is
|
||||
// rejected by AcceptWithCert — so even a zero literal cannot finalize anything.
|
||||
// BuildVerifiedQuorumCert, which verifies. A zero VerifiedQuorumCert{} carries a
|
||||
// nil cert and is rejected by AcceptWithCert — so even a zero literal cannot
|
||||
// finalize anything.
|
||||
type VerifiedQuorumCert struct {
|
||||
// qc is the verified witness. Unexported: unforgeable outside this file.
|
||||
// nil ⇒ the zero value ⇒ NOT a finality authority (AcceptWithCert refuses it).
|
||||
@@ -68,12 +74,19 @@ func (v VerifiedQuorumCert) Cert() *QuorumCert { return v.qc }
|
||||
//
|
||||
// verifier — the chain's VoteVerifier (BLS / ML-DSA / secp256k1). nil ⇒ fail closed.
|
||||
// stake — the chain's StakeSource. Non-nil ⇒ stake-weighted (VerifyWeighted,
|
||||
// tier-selected). nil ⇒ count-only Verify (equal-stake chains only; the
|
||||
// chain MUST enforce the equal-stake admission invariant).
|
||||
// tier-selected). nil ⇒ count-only Verify, and then Nova only: an
|
||||
// export tier with no stake source is refused (ErrExportNeedsStake),
|
||||
// because its floors are DERIVED from the set and there is no set.
|
||||
// The count-only road is for equal-stake chains, which MUST enforce
|
||||
// the equal-stake admission invariant.
|
||||
// tier — Nova (local-execution majority) or Quasar (export ⅔-by-stake); selects
|
||||
// which threshold VerifyWeighted enforces.
|
||||
// alpha — the count floor the votes must reach to assemble (NovaQuorum for Nova,
|
||||
// the ⅔ count for Quasar).
|
||||
// alpha — the quorum floor the cert will DECLARE, and it must be the floor the
|
||||
// set derives: SignerFloor(tier, stake.SignerCount(epochHeight)). It is
|
||||
// a parameter because the caller assembles before the set is consulted,
|
||||
// not because the caller may choose it — VerifyWeighted refuses a cert
|
||||
// whose declared threshold is not the derived one, so an alpha that
|
||||
// disagrees with the set yields no token.
|
||||
// epochHeight — the P-chain epoch the per-voter pubkeys, set-root and stake tally
|
||||
// are all read at (MEDIUM-1).
|
||||
// pos — the consensus position the votes (and the cert) bind to.
|
||||
@@ -95,6 +108,26 @@ func BuildVerifiedQuorumCert(
|
||||
if verifier == nil {
|
||||
return VerifiedQuorumCert{}, ErrNoVerifiedQC
|
||||
}
|
||||
// DERIVED AUTHORITY at the minting door. An export cert's floors are read off
|
||||
// the validator set — how much stake agreed, how many signers, how big the set
|
||||
// is at all. With no stake source there is no set to read them from, and the
|
||||
// only predicate left is the count-only Verify, which counts votes against the
|
||||
// number the CERT declares. That road can mint a Quasar token over one signer:
|
||||
// pass alpha 1 and one vote and the assemble clause and the count clause are
|
||||
// both satisfied by the same self-declared 1. Nothing downstream re-checks —
|
||||
// AcceptWithCert trusts the token, which is the whole point of the token.
|
||||
//
|
||||
// So the export rung is refused here rather than weakened there. The count-only
|
||||
// road remains open at Nova, which authorizes local execution the chain can
|
||||
// still reorg away and is documented as equal-stake only; it may never carry a
|
||||
// rung a bridge reads. Asked through Finality.AuthorizesExport, so the rule
|
||||
// covers every export tier that exists and every one that is added — this is not
|
||||
// a list of tier names to keep in step with another list somewhere else.
|
||||
if tier.AuthorizesExport() {
|
||||
if stake == nil {
|
||||
return VerifiedQuorumCert{}, errors.Join(ErrNoVerifiedQC, ErrExportNeedsStake)
|
||||
}
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, tier, alpha, votes)
|
||||
if err != nil {
|
||||
// Quorum not assembled yet (sub-threshold / not-yet-arrived). Liveness:
|
||||
|
||||
@@ -112,11 +112,11 @@ func TestWeighted_VerifyWeightedThreshold(t *testing.T) {
|
||||
for _, i := range idx {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
c, err := AssembleQuorumCert(pos, Quasar, uint32(len(idx)), votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
return c
|
||||
// The certificate declares the floor the four-seat set DERIVES for the export
|
||||
// rung, which is the only threshold VerifyWeighted admits. A two-signer row is
|
||||
// then a certificate short of its own quorum, which is what a two-signer export
|
||||
// certificate is.
|
||||
return certDeclaring(t, pos, Quasar, 4, votes)
|
||||
}
|
||||
|
||||
// The stake maps are height-independent, so the epoch height passed to
|
||||
|
||||
+48
-7
@@ -42,9 +42,8 @@ use blst::min_pk::{PublicKey, Signature};
|
||||
use blst::BLST_ERROR;
|
||||
|
||||
use crate::finality::{
|
||||
canonical_vote_message, half_stake_floor, nova_signer_floor, two_thirds_count,
|
||||
two_thirds_stake_floor, Finality, Position, MIN_BFT_COMMITTEE, QC_FINALITY,
|
||||
QUORUM_CERT_VERSION,
|
||||
canonical_vote_message, half_stake_floor, signer_floor, two_thirds_stake_floor, Finality,
|
||||
Position, MIN_BFT_COMMITTEE, QC_FINALITY, QUORUM_CERT_VERSION,
|
||||
};
|
||||
use crate::pop::{self, PopError};
|
||||
|
||||
@@ -137,6 +136,15 @@ pub enum CertError {
|
||||
/// A proof of possession that is not a valid signature by this key over its
|
||||
/// own (node, key) message under [`crate::pop::POP_DST`].
|
||||
PopInvalid,
|
||||
/// The certificate declares a quorum floor that is not the one this validator
|
||||
/// set derives for the rung it attests. A certificate may STATE its quorum; it
|
||||
/// may not choose it. Go's `ErrQCThresholdNotDerived`.
|
||||
///
|
||||
/// Equality, not a lower bound: an over-claim asserts a quorum this set does
|
||||
/// not require, and tolerating it would let a certificate redefine the rung
|
||||
/// upward exactly as tolerating an under-claim lets it redefine the rung down.
|
||||
/// Neither direction is the set's number.
|
||||
ThresholdNotDerived { declared: u32, derived: i64, n: i64 },
|
||||
/// The set's weights sum past what a `u64` can hold. Go's
|
||||
/// `ErrWeightOverflow`, and refused for the reason Go refuses it: every
|
||||
/// threshold in this crate is read against the total, so a total that
|
||||
@@ -193,6 +201,10 @@ impl std::fmt::Display for CertError {
|
||||
CertError::DuplicateKey => write!(f, "public key is registered to more than one node"),
|
||||
CertError::DuplicateNode => write!(f, "node is registered more than once"),
|
||||
CertError::PopInvalid => write!(f, "proof of possession does not verify for this key"),
|
||||
CertError::ThresholdNotDerived { declared, derived, n } => write!(
|
||||
f,
|
||||
"cert declares threshold {declared}, a set of {n} signers derives {derived}"
|
||||
),
|
||||
CertError::WeightOverflow => write!(f, "weight overflowed"),
|
||||
}
|
||||
}
|
||||
@@ -407,8 +419,10 @@ impl QuorumCert {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The full predicate: [`Self::verify`], then the tier's stake floor,
|
||||
/// recomputed from the live set so a certificate can never declare its own.
|
||||
/// The full predicate: [`Self::verify`], then the DERIVED authority — the
|
||||
/// certificate's declared threshold must equal [`signer_floor`] for its rung
|
||||
/// over this set — then the rung's stake and signer floors, all recomputed
|
||||
/// from the live set so a certificate can never declare its own.
|
||||
pub fn verify_weighted(
|
||||
&self,
|
||||
verifier: &dyn VoteVerifier,
|
||||
@@ -416,6 +430,33 @@ impl QuorumCert {
|
||||
epoch_height: u64,
|
||||
) -> Result<(), CertError> {
|
||||
self.verify(verifier, epoch_height)?;
|
||||
// DERIVED AUTHORITY. The certificate states the quorum it was built to; the
|
||||
// SET decides what that quorum is. The two must be the same number, so the
|
||||
// field carries no authority of its own — it is a claim this clause checks,
|
||||
// never a value the verifier adopts.
|
||||
//
|
||||
// Without it the field is load-bearing on the count-only road: `verify`'s
|
||||
// last clause counts distinct valid accepts against the certificate's OWN
|
||||
// threshold, so one declaring 1 clears it on a single signature. The floors
|
||||
// below then catch that, which is why this was a divergence rather than a
|
||||
// forgery — but only Go and Rust asked the weaker question, while C++ has
|
||||
// always demanded the derived floor exactly. One rule now.
|
||||
//
|
||||
// An unresolved set (n < 1) derives no floor, so nothing is compared against
|
||||
// it and the rung's own clause refuses the certificate under the name it has
|
||||
// always been refused by. Skipping here is not a pass: both rungs fail closed
|
||||
// on n < 1.
|
||||
let n = stake.signer_count(epoch_height);
|
||||
if n >= 1 {
|
||||
let derived = signer_floor(self.tier, n);
|
||||
if i64::from(self.threshold) != derived {
|
||||
return Err(CertError::ThresholdNotDerived {
|
||||
declared: self.threshold,
|
||||
derived,
|
||||
n,
|
||||
});
|
||||
}
|
||||
}
|
||||
match self.tier {
|
||||
Finality::Nova => self.verify_nova_majority(stake, epoch_height),
|
||||
Finality::Quasar => self.verify_quasar_supermajority(stake, epoch_height),
|
||||
@@ -439,7 +480,7 @@ impl QuorumCert {
|
||||
if n < 1 {
|
||||
return Err(CertError::UnresolvedSet { n });
|
||||
}
|
||||
let floor = nova_signer_floor(n);
|
||||
let floor = signer_floor(Finality::Nova, n);
|
||||
if self.voter_count() < floor {
|
||||
return Err(CertError::SignerFloor {
|
||||
have: self.voter_count(),
|
||||
@@ -537,7 +578,7 @@ impl QuorumCert {
|
||||
need: MIN_BFT_COMMITTEE,
|
||||
});
|
||||
}
|
||||
let need = two_thirds_count(n);
|
||||
let need = signer_floor(Finality::Quasar, n);
|
||||
if self.voter_count() < need {
|
||||
return Err(CertError::SignerFloor {
|
||||
have: self.voter_count(),
|
||||
|
||||
+192
-44
@@ -19,7 +19,8 @@ use lux_consensus::cert::{
|
||||
DST, SIGNATURE_LEN,
|
||||
};
|
||||
use lux_consensus::finality::{
|
||||
canonical_vote_message, two_thirds_count, two_thirds_stake_floor, Finality, Position,
|
||||
canonical_vote_message, nova_signer_floor, signer_floor, two_thirds_count,
|
||||
two_thirds_stake_floor, Finality, Position,
|
||||
};
|
||||
use lux_consensus::pop;
|
||||
|
||||
@@ -97,7 +98,10 @@ fn position() -> Position {
|
||||
}
|
||||
}
|
||||
|
||||
/// A valid Quasar certificate from the first `k` members of the committee.
|
||||
/// A valid Quasar certificate from the first `k` members of the committee,
|
||||
/// declaring `threshold` as its quorum. The structural clauses are what it is
|
||||
/// for: `verify` counts against the declaration, so a case about ordering or a
|
||||
/// signature states what the certificate claims and moves on.
|
||||
fn valid_cert(signers: &[Signer], k: usize, threshold: u32) -> QuorumCert {
|
||||
let pos = position();
|
||||
let message = canonical_vote_message(&pos, true);
|
||||
@@ -105,6 +109,23 @@ fn valid_cert(signers: &[Signer], k: usize, threshold: u32) -> QuorumCert {
|
||||
QuorumCert::assemble(Finality::Quasar, pos, threshold, &votes).expect("assemble")
|
||||
}
|
||||
|
||||
/// A certificate from the first `k` of a committee of `n`, declaring the quorum
|
||||
/// that set DERIVES for `tier` — the only threshold `verify_weighted` admits.
|
||||
///
|
||||
/// Assembly runs at the vote count and the declaration is stamped after, because
|
||||
/// the two are different questions: a certificate below its set's floor is one no
|
||||
/// honest assembler can build and one every verifier must still refuse, so stating
|
||||
/// it means building it the way an adversary would. The ordering and dedup clauses
|
||||
/// still run.
|
||||
fn declaring(signers: &[Signer], tier: Finality, n: i64, k: usize) -> QuorumCert {
|
||||
let pos = position();
|
||||
let message = canonical_vote_message(&pos, true);
|
||||
let votes: Vec<Vote> = signers[..k].iter().map(|s| s.vote(&message)).collect();
|
||||
let mut c = QuorumCert::assemble(tier, pos, votes.len() as u32, &votes).expect("assemble");
|
||||
c.threshold = signer_floor(tier, n) as u32;
|
||||
c
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the happy path
|
||||
|
||||
#[test]
|
||||
@@ -325,11 +346,26 @@ fn a_count_quorum_without_the_stake_is_refused() {
|
||||
/// export at all, which would make the passing half of this case untestable.
|
||||
#[test]
|
||||
fn exactly_two_thirds_is_refused_and_one_more_passes() {
|
||||
// Six validators of 100. floor(2·600/3) = 400, so 400 must fail.
|
||||
let (signers, set) = committee(6, 100);
|
||||
let four = valid_cert(&signers, 4, 4);
|
||||
// Six validators summing to 600, LOPSIDED: five of eighty and one of two
|
||||
// hundred. floor(2·600/3) = 400, so the five light seats hold exactly two
|
||||
// thirds and must fail — while five signatures already meet the count floor
|
||||
// floor(2·6/3)+1 = 5, so the stake half is the only thing left to decide it.
|
||||
// On an equal six the two halves bind at the same edge and neither can be
|
||||
// seen alone; the weights are uneven here for exactly that reason.
|
||||
let signers: Vec<Signer> = (1..=6u8)
|
||||
.map(|i| Signer::new(i, if i == 6 { 200 } else { 80 }))
|
||||
.collect();
|
||||
let mut set = ValidatorSet::new();
|
||||
for s in &signers {
|
||||
set.insert(s.id, s.weight, &s.public(), &s.pop()).expect("insert");
|
||||
}
|
||||
assert_eq!(set.signer_stake(0), 600);
|
||||
assert_eq!(two_thirds_stake_floor(600), 400);
|
||||
|
||||
let five = declaring(&signers, Finality::Quasar, 6, 5);
|
||||
assert_eq!(five.voter_count(), two_thirds_count(6), "the count floor is met");
|
||||
assert_eq!(
|
||||
four.verify_weighted(&set, &set, 0),
|
||||
five.verify_weighted(&set, &set, 0),
|
||||
Err(CertError::StakeBelowSupermajority {
|
||||
voted: 400,
|
||||
signer: 600,
|
||||
@@ -337,8 +373,8 @@ fn exactly_two_thirds_is_refused_and_one_more_passes() {
|
||||
})
|
||||
);
|
||||
|
||||
let five = valid_cert(&signers, 5, 5);
|
||||
assert_eq!(five.verify_weighted(&set, &set, 0), Ok(()));
|
||||
let six = declaring(&signers, Finality::Quasar, 6, 6);
|
||||
assert_eq!(six.verify_weighted(&set, &set, 0), Ok(()));
|
||||
}
|
||||
|
||||
/// An epoch that resolves to no stake at all cannot support a claim about a
|
||||
@@ -387,21 +423,22 @@ fn nova_needs_both_the_majority_and_the_signer_floor() {
|
||||
set.insert(s.id, s.weight, &s.public(), &s.pop()).expect("insert");
|
||||
}
|
||||
|
||||
let pos = position();
|
||||
let message = canonical_vote_message(&pos, true);
|
||||
let message = canonical_vote_message(&position(), true);
|
||||
|
||||
// The stake majority holder, alone.
|
||||
let alone =
|
||||
QuorumCert::assemble(Finality::Nova, pos.clone(), 1, &[signers[0].vote(&message)]).unwrap();
|
||||
assert_eq!(alone.verify(&set, 0), Ok(()));
|
||||
let alone = declaring(&signers, Finality::Nova, 5, 1);
|
||||
// The SIGNATURE is real, which is what this line is for. The certificate is
|
||||
// below the floor it declares, so `verify` answers for it too — the same
|
||||
// clause, one step earlier.
|
||||
let lone = signers[0].vote(&message);
|
||||
assert!(set.verify_vote(&lone.node_id, &message, &lone.signature, 0));
|
||||
assert_eq!(
|
||||
alone.verify_weighted(&set, &set, 0),
|
||||
Err(CertError::SignerFloor { have: 1, need: 3, n: 5 })
|
||||
Err(CertError::BelowThreshold { have: 1, need: 3 })
|
||||
);
|
||||
|
||||
// Three signers including it: floor met, and 998 of 1000 is a majority.
|
||||
let votes: Vec<Vote> = signers[..3].iter().map(|s| s.vote(&message)).collect();
|
||||
let three = QuorumCert::assemble(Finality::Nova, pos, 3, &votes).unwrap();
|
||||
let three = declaring(&signers, Finality::Nova, 5, 3);
|
||||
assert_eq!(three.verify_weighted(&set, &set, 0), Ok(()));
|
||||
}
|
||||
|
||||
@@ -1074,13 +1111,15 @@ fn a_clamping_stake_source_is_refused_rather_than_read() {
|
||||
"the source clamps, as the set used to"
|
||||
);
|
||||
|
||||
// Two of four: half the stake, and every signature genuine.
|
||||
let cert = valid_cert(&signers, 2, 2);
|
||||
// Three of four: the export count floor is met, so the certificate reaches the
|
||||
// tally and the tally is the only thing left to decide it. Every signature is
|
||||
// genuine — the stake is what is under test.
|
||||
let cert = declaring(&signers, Finality::Quasar, 4, 3);
|
||||
assert_eq!(cert.verify(&set, 0), Ok(()));
|
||||
assert_eq!(
|
||||
cert.verify_weighted(&set, &clamping, 0),
|
||||
Err(CertError::WeightOverflow),
|
||||
"half the stake is not two thirds of it, whatever the arithmetic clamps to"
|
||||
"a clamped total is not a total, whatever fraction of it the votes reach"
|
||||
);
|
||||
|
||||
// The same refusal one rung down, so neither tier reads a clamped total.
|
||||
@@ -1226,17 +1265,26 @@ fn a_lone_holder_of_two_thirds_cannot_export() {
|
||||
// The premise: the stake half is satisfied outright. floor(2·104/3) = 69.
|
||||
assert_eq!(two_thirds_stake_floor(104), 69);
|
||||
|
||||
// The certificate declares the floor this set derives — four — so a one-signer
|
||||
// and a three-signer certificate are each SHORT OF THEIR OWN DECLARED QUORUM,
|
||||
// and the count clause names them with that floor in the `need` field. It is
|
||||
// the same number the rung enforces, reached one clause earlier: once the
|
||||
// declaration must equal the derived floor, counting against the declaration
|
||||
// IS counting against the floor.
|
||||
assert_eq!(
|
||||
valid_cert(&signers, 1, 1).verify_weighted(&set, &set, 0),
|
||||
Err(CertError::SignerFloor { have: 1, need: 4, n: 5 }),
|
||||
declaring(&signers, Finality::Quasar, 5, 1).verify_weighted(&set, &set, 0),
|
||||
Err(CertError::BelowThreshold { have: 1, need: 4 }),
|
||||
);
|
||||
// Three is still one short of floor(2·5/3)+1 = 4, and the stake is untouched.
|
||||
assert_eq!(
|
||||
valid_cert(&signers, 3, 3).verify_weighted(&set, &set, 0),
|
||||
Err(CertError::SignerFloor { have: 3, need: 4, n: 5 }),
|
||||
declaring(&signers, Finality::Quasar, 5, 3).verify_weighted(&set, &set, 0),
|
||||
Err(CertError::BelowThreshold { have: 3, need: 4 }),
|
||||
);
|
||||
// At the floor the same stake carries: the count was the binding clause.
|
||||
assert_eq!(valid_cert(&signers, 4, 4).verify_weighted(&set, &set, 0), Ok(()));
|
||||
assert_eq!(
|
||||
declaring(&signers, Finality::Quasar, 5, 4).verify_weighted(&set, &set, 0),
|
||||
Ok(())
|
||||
);
|
||||
}
|
||||
|
||||
/// Neither half is sufficient. The four light members meet the count floor
|
||||
@@ -1295,7 +1343,7 @@ fn an_export_certificate_over_an_unresolved_set_fails_closed() {
|
||||
}
|
||||
|
||||
let (signers, set) = committee(4, 100);
|
||||
let cert = valid_cert(&signers, 4, 4);
|
||||
let cert = declaring(&signers, Finality::Quasar, 4, 4);
|
||||
// Against the real set this certificate exports.
|
||||
assert_eq!(cert.verify_weighted(&set, &set, 0), Ok(()));
|
||||
assert_eq!(
|
||||
@@ -1353,7 +1401,7 @@ fn keyless_stake_is_in_no_floor_and_export_still_reaches() {
|
||||
);
|
||||
|
||||
// The whole signing set signs, and the export rung admits it.
|
||||
let cert = valid_cert(&signers, 6, 6);
|
||||
let cert = declaring(&signers, Finality::Quasar, set.signer_count(0), 6);
|
||||
assert!(cert.voter_count() >= two_thirds_count(set.signer_count(0)));
|
||||
assert_eq!(cert.verify(&set, 0), Ok(()));
|
||||
assert_eq!(
|
||||
@@ -1362,16 +1410,16 @@ fn keyless_stake_is_in_no_floor_and_export_still_reaches() {
|
||||
"export refused with every signer in the set agreeing"
|
||||
);
|
||||
|
||||
// And the rung is still a rung: four of six is short of two thirds of the
|
||||
// stake that can sign, so the floor was moved off the spectator, not removed.
|
||||
let four = valid_cert(&signers, 4, 4);
|
||||
// And the rung is still a rung: four of six does not reach the export quorum
|
||||
// this set derives, so the floor was moved off the spectator and not removed.
|
||||
// Over six EQUAL signers the two halves of the rule bind at the same edge —
|
||||
// five signatures and more than four hundred are the same bar in two units —
|
||||
// so this row is answered by the count. `exactly_two_thirds_is_refused_and_
|
||||
// one_more_passes` is the lopsided set where the stake half binds alone.
|
||||
let four = declaring(&signers, Finality::Quasar, set.signer_count(0), 4);
|
||||
assert_eq!(
|
||||
four.verify_weighted(&set, &set, 0),
|
||||
Err(CertError::StakeBelowSupermajority {
|
||||
voted: 400,
|
||||
signer: 600,
|
||||
need_above: 400,
|
||||
})
|
||||
Err(CertError::BelowThreshold { have: 4, need: 5 })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1411,7 +1459,8 @@ fn keyless_seats_are_in_no_count_floor_either() {
|
||||
"the roll floor is within reach of four signers; nothing is stranded"
|
||||
);
|
||||
|
||||
let cert = valid_cert(&signers, 4, 4);
|
||||
let n = set.signer_count(0);
|
||||
let cert = declaring(&signers, Finality::Quasar, n, 4);
|
||||
assert_eq!(
|
||||
cert.verify_weighted(&set, &set, 0),
|
||||
Ok(()),
|
||||
@@ -1421,8 +1470,13 @@ fn keyless_seats_are_in_no_count_floor_either() {
|
||||
|
||||
// The rung is still a rung, and its edge is sharp on the signer denominator:
|
||||
// three signers sit exactly ON the floor and carry, two sit below it.
|
||||
assert_eq!(valid_cert(&signers, 3, 3).verify_weighted(&set, &set, 0), Ok(()));
|
||||
assert!(valid_cert(&signers, 2, 2).verify_weighted(&set, &set, 0).is_err());
|
||||
assert_eq!(
|
||||
declaring(&signers, Finality::Quasar, n, 3).verify_weighted(&set, &set, 0),
|
||||
Ok(())
|
||||
);
|
||||
assert!(declaring(&signers, Finality::Quasar, n, 2)
|
||||
.verify_weighted(&set, &set, 0)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ the Byzantine committee
|
||||
@@ -1443,7 +1497,7 @@ fn keyless_seats_are_in_no_count_floor_either() {
|
||||
fn an_export_certificate_needs_a_byzantine_committee() {
|
||||
for n in 1..4u8 {
|
||||
let (signers, set) = committee(n, 100);
|
||||
let cert = valid_cert(&signers, n as usize, u32::from(n));
|
||||
let cert = declaring(&signers, Finality::Quasar, i64::from(n), usize::from(n));
|
||||
|
||||
// Both quorum floors are MET, so neither can be what refuses it.
|
||||
assert!(cert.voter_count() >= two_thirds_count(set.signer_count(0)), "n={n}");
|
||||
@@ -1465,7 +1519,7 @@ fn an_export_certificate_needs_a_byzantine_committee() {
|
||||
// ban on small chains certifying anything.
|
||||
let (signers, set) = committee(4, 100);
|
||||
assert_eq!(
|
||||
valid_cert(&signers, 4, 4).verify_weighted(&set, &set, 0),
|
||||
declaring(&signers, Finality::Quasar, 4, 4).verify_weighted(&set, &set, 0),
|
||||
Ok(()),
|
||||
"the minimum Byzantine committee cannot export"
|
||||
);
|
||||
@@ -1481,11 +1535,7 @@ fn an_export_certificate_needs_a_byzantine_committee() {
|
||||
fn nova_ignites_below_the_byzantine_committee() {
|
||||
for n in 1..4u8 {
|
||||
let (signers, set) = committee(n, 100);
|
||||
let pos = position();
|
||||
let message = canonical_vote_message(&pos, true);
|
||||
let votes: Vec<Vote> = signers.iter().map(|s| s.vote(&message)).collect();
|
||||
let cert = QuorumCert::assemble(Finality::Nova, pos, u32::from(n), &votes)
|
||||
.expect("assemble");
|
||||
let cert = declaring(&signers, Finality::Nova, i64::from(n), usize::from(n));
|
||||
assert_eq!(
|
||||
cert.verify_weighted(&set, &set, 0),
|
||||
Ok(()),
|
||||
@@ -1516,3 +1566,101 @@ fn a_keyless_member_named_as_a_voter_is_refused() {
|
||||
"a vote from a member with no registered key must not verify"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- derived authority (finding-3)
|
||||
|
||||
/// A certificate states its quorum; it does not choose it.
|
||||
///
|
||||
/// The floor a certificate is counted against is a function of the SET it is
|
||||
/// weighed against and the RUNG it attests. The threshold field carries that
|
||||
/// number so a reader knows what was required, and `verify_weighted` checks the
|
||||
/// field against what the set actually derives — so the field is a claim, never a
|
||||
/// value the verifier adopts.
|
||||
///
|
||||
/// Without the clause the field is load-bearing, because `verify`'s last clause
|
||||
/// counts distinct valid accepts against the certificate's OWN threshold: one
|
||||
/// declaring 1 clears it on a single signature.
|
||||
#[test]
|
||||
fn a_certificate_may_not_declare_a_quorum_the_set_does_not_derive() {
|
||||
let (signers, set) = committee(4, 100);
|
||||
|
||||
// The honest certificate: four of four, declaring what the set derives.
|
||||
let honest = declaring(&signers, Finality::Quasar, 4, 4);
|
||||
assert_eq!(honest.threshold, two_thirds_count(4) as u32);
|
||||
assert_eq!(honest.verify_weighted(&set, &set, 0), Ok(()));
|
||||
|
||||
// The same votes, under-claiming. Every signature still verifies and the stake
|
||||
// is still unanimous — the ONLY thing wrong is the number the certificate names.
|
||||
let mut under = declaring(&signers, Finality::Quasar, 4, 4);
|
||||
under.threshold = 1;
|
||||
assert_eq!(
|
||||
under.verify_weighted(&set, &set, 0),
|
||||
Err(CertError::ThresholdNotDerived { declared: 1, derived: 3, n: 4 }),
|
||||
);
|
||||
|
||||
// And an over-claim, for the same reason in the other direction: a certificate
|
||||
// asserting a quorum this set does not require is still naming a number that is
|
||||
// not the set's. Four signatures clear a declared four, so the count clause has
|
||||
// nothing to say and this is refused by the derived clause alone.
|
||||
let mut over = declaring(&signers, Finality::Quasar, 4, 4);
|
||||
over.threshold = 4;
|
||||
assert_eq!(
|
||||
over.verify_weighted(&set, &set, 0),
|
||||
Err(CertError::ThresholdNotDerived { declared: 4, derived: 3, n: 4 }),
|
||||
);
|
||||
}
|
||||
|
||||
/// The rule is one rule and not an export special case.
|
||||
#[test]
|
||||
fn the_accept_rung_derives_its_quorum_the_same_way() {
|
||||
let (signers, set) = committee(5, 100);
|
||||
|
||||
let honest = declaring(&signers, Finality::Nova, 5, 3);
|
||||
assert_eq!(honest.threshold, nova_signer_floor(5) as u32);
|
||||
assert_eq!(honest.verify_weighted(&set, &set, 0), Ok(()));
|
||||
|
||||
let mut under = declaring(&signers, Finality::Nova, 5, 3);
|
||||
under.threshold = 1;
|
||||
assert_eq!(
|
||||
under.verify_weighted(&set, &set, 0),
|
||||
Err(CertError::ThresholdNotDerived { declared: 1, derived: 3, n: 5 }),
|
||||
);
|
||||
}
|
||||
|
||||
/// One definition of the floor, and a rung that is not an accept tier gets none.
|
||||
#[test]
|
||||
fn the_signer_floor_is_the_rungs_own_arithmetic() {
|
||||
for n in 1..=64i64 {
|
||||
assert_eq!(signer_floor(Finality::Nova, n), nova_signer_floor(n), "n={n}");
|
||||
assert_eq!(signer_floor(Finality::Quasar, n), two_thirds_count(n), "n={n}");
|
||||
}
|
||||
for rung in [Finality::Photon, Finality::Wave, Finality::Horizon] {
|
||||
assert_eq!(signer_floor(rung, 41), 0, "{rung:?} is not an accept rung");
|
||||
}
|
||||
}
|
||||
|
||||
/// An unresolved set derives no floor, so nothing is compared against it and the
|
||||
/// rung's own clause refuses the certificate under the name it has always carried.
|
||||
/// Stepping aside is not a pass.
|
||||
#[test]
|
||||
fn an_unresolved_set_is_not_named_by_the_derived_clause() {
|
||||
struct Unresolved;
|
||||
impl StakeSource for Unresolved {
|
||||
fn weight(&self, _: &NodeId, _: u64) -> u64 {
|
||||
10
|
||||
}
|
||||
fn signer_stake(&self, _: u64) -> u64 {
|
||||
10
|
||||
}
|
||||
fn signer_count(&self, _: u64) -> i64 {
|
||||
0
|
||||
}
|
||||
}
|
||||
let (signers, set) = committee(4, 100);
|
||||
let mut cert = declaring(&signers, Finality::Quasar, 4, 4);
|
||||
cert.threshold = 1; // whatever it says, an unknown set has no number to check it against
|
||||
assert_eq!(
|
||||
cert.verify_weighted(&set, &Unresolved, 0),
|
||||
Err(CertError::UnresolvedSet { n: 0 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use blst::min_pk::SecretKey;
|
||||
use lux_consensus::cert::{
|
||||
CertError, NodeId, QuorumCert, StakeSource, ValidatorSet, Vote, VoteVerifier, DST,
|
||||
};
|
||||
use lux_consensus::finality::{canonical_vote_message, Finality, Position};
|
||||
use lux_consensus::finality::{canonical_vote_message, signer_floor, Finality, Position};
|
||||
use lux_consensus::pop;
|
||||
|
||||
/// A validator: a key, an id, and a weight. Deterministic per `n`, so a failure
|
||||
@@ -84,11 +84,17 @@ fn position() -> Position {
|
||||
}
|
||||
}
|
||||
|
||||
fn cert(signers: &[Signer], k: usize, threshold: u32, tier: Finality) -> QuorumCert {
|
||||
/// A certificate from the first `k` of a committee of `n`, declaring the quorum
|
||||
/// that set DERIVES for `tier` — the only threshold `verify_weighted` admits, so
|
||||
/// every row below turns on the clause it is about and not on a number the
|
||||
/// certificate named for itself.
|
||||
fn cert(signers: &[Signer], k: usize, n: i64, tier: Finality) -> QuorumCert {
|
||||
let pos = position();
|
||||
let message = canonical_vote_message(&pos, true);
|
||||
let votes: Vec<Vote> = signers[..k].iter().map(|s| s.vote(&message)).collect();
|
||||
QuorumCert::assemble(tier, pos, threshold, &votes).expect("assemble")
|
||||
let mut c = QuorumCert::assemble(tier, pos, votes.len() as u32, &votes).expect("assemble");
|
||||
c.threshold = signer_floor(tier, n) as u32;
|
||||
c
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- the accept rung's stake
|
||||
@@ -131,7 +137,7 @@ impl StakeSource for Fabricated<'_> {
|
||||
#[test]
|
||||
fn nova_fails_closed_when_the_source_reports_no_signer_stake() {
|
||||
let (signers, set) = committee(5, 100);
|
||||
let c = cert(&signers, 4, 4, Finality::Nova);
|
||||
let c = cert(&signers, 4, 5, Finality::Nova);
|
||||
|
||||
let source = Fabricated {
|
||||
set: &set,
|
||||
@@ -348,15 +354,26 @@ fn every_refusal_names_its_own_clause_and_carries_its_numbers() {
|
||||
/// discriminant would satisfy the first and fail an operator at the second.
|
||||
#[test]
|
||||
fn a_real_shortfall_reports_the_stake_it_actually_had() {
|
||||
let (signers, set) = committee(5, 100);
|
||||
// Three of five: 300 staked, and floor(2·500/3) is 333.
|
||||
let text = cert(&signers, 3, 3, Finality::Quasar)
|
||||
// One holder of a hundred and four minimum registrations. The four light seats
|
||||
// sign: that MEETS the export count floor floor(2·5/3)+1 = 4, so the refusal is
|
||||
// the stake clause and the numbers in it are the tally, the signer stake and
|
||||
// the floor — four of a hundred and four against floor(2·104/3) = 69. An equal
|
||||
// set cannot state this row: there the count and the stake bind at one edge.
|
||||
let signers: Vec<Signer> = (1..=5u8)
|
||||
.map(|i| Signer::new(i, if i == 1 { 100 } else { 1 }))
|
||||
.collect();
|
||||
let mut set = ValidatorSet::new();
|
||||
for sgn in &signers {
|
||||
set.insert(sgn.id, sgn.weight, &sgn.public(), &sgn.pop()).expect("insert");
|
||||
}
|
||||
|
||||
let text = cert(&signers[1..], 4, 5, Finality::Quasar)
|
||||
.verify_weighted(&set, &set, 0)
|
||||
.expect_err("three of five is not an export supermajority")
|
||||
.expect_err("four minimum registrations are not an export supermajority")
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
text.contains("300") && text.contains("500") && text.contains("333"),
|
||||
text.contains('4') && text.contains("104") && text.contains("69"),
|
||||
"the refusal {text:?} does not say how short the quorum was"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ fn refusal(rung: &str, err: &CertError) -> &'static str {
|
||||
"stakeBelowSupermajority"
|
||||
}
|
||||
}
|
||||
CertError::ThresholdNotDerived { .. } => "thresholdNotDerived",
|
||||
CertError::ZeroWeight => "zeroWeight",
|
||||
CertError::WeightOverflow => "weightOverflow",
|
||||
other => panic!("a verdict the corpus cannot name: {other}"),
|
||||
@@ -211,8 +212,16 @@ fn the_weighted_decision_is_the_one_go_made() {
|
||||
// The decision does not depend on the position, which the corpus states
|
||||
// outright — so the default one is as good as any and this harness needs
|
||||
// to carry no position material at all.
|
||||
let cert = QuorumCert::assemble(tier, Position::default(), threshold, &votes)
|
||||
//
|
||||
// Assembled at the vote count and then stamped with what the row's
|
||||
// certificate DECLARES, because the two are different questions. A row
|
||||
// below the floor its set derives is a certificate no honest assembler can
|
||||
// build and one every verifier must still refuse, so stating it means
|
||||
// building it the way an adversary would. The ordering and dedup clauses
|
||||
// still run.
|
||||
let mut cert = QuorumCert::assemble(tier, Position::default(), votes.len() as u32, &votes)
|
||||
.unwrap_or_else(|e| panic!("{name}: assemble: {e}"));
|
||||
cert.threshold = threshold;
|
||||
|
||||
let decided = cert.verify_weighted(&Trust(set.clone()), &set, epoch);
|
||||
let accept = case["accept"].as_bool().expect("case has no accept");
|
||||
@@ -241,8 +250,8 @@ fn the_weighted_decision_is_the_one_go_made() {
|
||||
// empty list, which is the failure mode the whole file exists to close.
|
||||
assert_eq!(
|
||||
cases.len(),
|
||||
13,
|
||||
"expected 13 frozen finality verdicts, checked {}",
|
||||
18,
|
||||
"expected 18 frozen finality verdicts, checked {}",
|
||||
cases.len()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user