quorum: make Policy self-describing on the wire (3-of-5)

A quorum written as `"threshold": 3` is ambiguous — it reads as either the
signer count or the polynomial degree, and the two differ by one. That
ambiguity is what produced degree-0 (1-of-n) custody keys from configs that
said 3-of-5.

Policy now implements TextMarshaler/TextUnmarshaler over the operator form, so
any config, genesis blob or API payload that carries a quorum carries it in the
one representation that cannot be misread. Decode of an absent or undeployable
policy is an error rather than a zero value: {0,0} would reach cmp.Keygen as
degree -1, and a wrong-degree key that already holds funds has no recovery path
short of a resharing ceremony.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-25 13:00:38 -07:00
co-authored by Hanzo Dev
parent 247e5e4740
commit 128d8e4840
3 changed files with 98 additions and 1 deletions
+27
View File
@@ -101,6 +101,33 @@ func FromDegree(t, n int) (Policy, error) {
return New(t+1, n)
}
// MarshalText renders the policy in operator form so that any config, genesis
// blob or API payload carrying a Policy is self-describing: "3-of-5" cannot be
// misread as a degree, whereas a bare `"threshold": 3` can and has been.
//
// Policy implements encoding.TextMarshaler/TextUnmarshaler rather than a JSON
// codec so the same single representation works for JSON, YAML, TOML, flags and
// map keys — one form everywhere.
func (p Policy) MarshalText() ([]byte, error) {
if !p.Valid() {
return nil, fmt.Errorf("quorum: refusing to encode invalid policy %d-of-%d", p.K, p.N)
}
return []byte(p.String()), nil
}
// UnmarshalText reads the operator form. An absent or malformed policy is an
// error, never a silent zero value: a Policy that decoded to {0,0} would be
// indistinguishable from "unset" at the keygen boundary, and the failure mode
// of guessing there is an unrecoverable wrong-degree key.
func (p *Policy) UnmarshalText(text []byte) error {
parsed, err := Parse(string(text))
if err != nil {
return err
}
*p = parsed
return nil
}
// Parse reads the operator form, "3-of-5". It accepts surrounding space and
// an "of" separator with or without hyphens ("3 of 5"), because those are the
// forms that show up in runbooks and dashboards.
+4 -1
View File
@@ -48,7 +48,10 @@ func TestPolicy3of5(t *testing.T) {
}
func TestNewRejectsUndeployablePolicies(t *testing.T) {
for _, tc := range []struct{ name string; k, n int }{
for _, tc := range []struct {
name string
k, n int
}{
{"k exceeds n", 6, 5},
{"k=1 is not a threshold policy", 1, 5},
{"k=0", 0, 5},
+67
View File
@@ -0,0 +1,67 @@
package quorum_test
import (
"encoding/json"
"testing"
"github.com/luxfi/threshold/pkg/quorum"
)
// A Policy in a config file must round-trip through the operator form. This is
// the property that lets genesis and node config state a quorum in a way that
// cannot be misread as a polynomial degree.
func TestPolicyJSONRoundTrip(t *testing.T) {
type cfg struct {
Policy quorum.Policy `json:"policy"`
}
in := cfg{Policy: quorum.MustNew(3, 5)}
b, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if got, want := string(b), `{"policy":"3-of-5"}`; got != want {
t.Fatalf("marshal = %s, want %s", got, want)
}
var out cfg
if err := json.Unmarshal(b, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.Policy != in.Policy {
t.Fatalf("round-trip = %s, want %s", out.Policy, in.Policy)
}
if out.Policy.Degree() != 2 {
t.Fatalf("decoded 3-of-5 has degree %d, want 2", out.Policy.Degree())
}
}
// An undeployable or unparseable policy must fail loudly at decode. A Policy
// that silently decoded to the zero value would reach cmp.Keygen as degree -1.
func TestPolicyUnmarshalRejectsGarbage(t *testing.T) {
for _, in := range []string{
`{"policy":"6-of-5"}`, // unsatisfiable
`{"policy":"1-of-5"}`, // not a threshold policy
`{"policy":"3/5"}`, // wrong separator
`{"policy":""}`, // empty
`{"policy":"0-of-0"}`, // zero
} {
var out struct {
Policy quorum.Policy `json:"policy"`
}
if err := json.Unmarshal([]byte(in), &out); err == nil {
t.Errorf("Unmarshal(%s) succeeded as %s, want error", in, out.Policy)
}
}
}
// Encoding an invalid policy must fail rather than emit a string that would
// decode back to something different (or fail asymmetrically).
func TestPolicyMarshalRejectsInvalid(t *testing.T) {
if _, err := (quorum.Policy{K: 0, N: 0}).MarshalText(); err == nil {
t.Fatal("marshalling the zero policy succeeded, want error")
}
if _, err := (quorum.Policy{K: 9, N: 5}).MarshalText(); err == nil {
t.Fatal("marshalling 9-of-5 succeeded, want error")
}
}