mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
LP-134 was renumbered LP-1340 (lp-1340-lux-chain-topology); 21 citations across 16 files still named the old number, which resolves to nothing. Corona rests on Module-LWE, not Ring-LWE — LP-4805 says so in three places and b17e086 already corrected one file, leaving evm/main.go and schain/SECURITY_review.md claiming the wrong hardness assumption. The two-family argument in LP-4900 depends on which lattice problem this is. P3Q at 0x012205 is kind-byte signature dispatch; STARK/FRI is starkfri at 0x012220 (LP-7050). Naming P3Q for a scheme at another slot describes a precompile that is not the one being imported. Carries the substance of 2f22fa2, the one commit the parallel line held that this history did not.
532 lines
20 KiB
Go
532 lines
20 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package mpcvm
|
|
|
|
// state.go — M-Chain's persisted state, and the line between what is
|
|
// replicated and what is not.
|
|
//
|
|
// # Two keyspaces, two trust properties
|
|
//
|
|
// M-Chain custody state divides cleanly in two, and braiding them is how an
|
|
// off-chain signer cluster ends up with nodes that silently disagree about
|
|
// which key is the custodian:
|
|
//
|
|
// - CONSENSUS state (prefix "c/") is replicated. Every honest validator holds
|
|
// byte-identical consensus state at a given height, and Root() commits to
|
|
// it. It contains only public values: which custody keys exist, their
|
|
// quorum policy, their group public key, and the ceremonies that produced
|
|
// or used them. A secret never appears here.
|
|
//
|
|
// - NODE state (prefix "n/") is this validator's own. It holds this node's
|
|
// private key shares and its view pointers. It is NOT replicated, NOT
|
|
// committed by Root(), and differs legitimately between nodes — party A's
|
|
// share is not party B's.
|
|
//
|
|
// The prefixes are disjoint by construction, and only operation digests folded
|
|
// through advance() enter Root() — never a raw stored value. So "could a secret
|
|
// have reached consensus state" is answerable by reading the writers of the c/
|
|
// prefix, which are the three Put* methods below, rather than auditing the
|
|
// whole VM.
|
|
//
|
|
// # What Root() commits to
|
|
//
|
|
// Root is a hash chain over applied operations: r0 = H(TAG_GENESIS ‖ chainID),
|
|
// r_{i+1} = H(TAG_STEP ‖ r_i ‖ opDigest). Because every validator applies the
|
|
// same accepted blocks in the same order to the same initial state, equal
|
|
// histories give equal roots and any divergence in applied state shows up as a
|
|
// root mismatch at the next block — which Block.Verify rejects.
|
|
//
|
|
// This is a transition-log commitment, not a membership-proof commitment: it
|
|
// proves two nodes agree on the whole registry, but it cannot prove to a third
|
|
// party that one particular key is in it without replaying the log. That is
|
|
// enough for M-Chain's job (validators must agree on custody), and it is what
|
|
// LP-1340's mchain_ceremony_root asks for. A Merkle registry root is the upgrade
|
|
// if external membership proofs are ever needed; it is deliberately not built
|
|
// on speculation.
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
|
|
"github.com/luxfi/chains/mpcvm/types"
|
|
"github.com/luxfi/database"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/threshold/pkg/party"
|
|
"github.com/luxfi/threshold/pkg/quorum"
|
|
)
|
|
|
|
// Keyspace prefixes. Disjoint by construction; see the package comment.
|
|
var (
|
|
prefixKeyRecord = []byte("c/key/") // replicated: custody key registry
|
|
prefixCeremony = []byte("c/ceremony/") // replicated: ceremony log
|
|
keyRoot = []byte("c/root") // replicated: current state root
|
|
prefixShare = []byte("n/share/") // NODE-PRIVATE: this party's key share
|
|
)
|
|
|
|
// Domain separation tags. Every hash M-Chain computes is tagged, so a digest
|
|
// from one context can never be replayed as a digest from another — in
|
|
// particular a ceremony id can never alias a signing preimage, and a state root
|
|
// can never alias a key commitment.
|
|
const (
|
|
tagRootGenesis = "LUX_MCHAIN_ROOT_GENESIS_v1"
|
|
tagRootStep = "LUX_MCHAIN_ROOT_STEP_v1"
|
|
tagOpKeygen = "LUX_MCHAIN_OP_KEYGEN_v1"
|
|
tagOpSign = "LUX_MCHAIN_OP_SIGN_v1"
|
|
// tagKeyCommit is the message a freshly generated group key signs to prove
|
|
// possession. See KeyCommitDigest.
|
|
tagKeyCommit = "LUX_MCHAIN_KEY_COMMIT_v1"
|
|
)
|
|
|
|
var (
|
|
ErrKeyExists = errors.New("mpcvm: key already registered")
|
|
ErrUnknownKey = errors.New("mpcvm: key not registered")
|
|
ErrCeremonyExists = errors.New("mpcvm: ceremony already recorded")
|
|
ErrShareNotHeld = errors.New("mpcvm: this node holds no share for that key")
|
|
ErrPolicyMismatch = errors.New("mpcvm: key policy does not match its participant set")
|
|
ErrRootMismatch = errors.New("mpcvm: post-state root mismatch")
|
|
)
|
|
|
|
// KeyRecord is the replicated, public record of one custody key.
|
|
//
|
|
// It is deliberately share-free: the group public key and the policy are
|
|
// everything a validator needs to check a signature and everything B-Chain
|
|
// needs to know who the custodian is. The share that produced it lives in node
|
|
// state and never leaves the node that generated it.
|
|
type KeyRecord struct {
|
|
KeyID string
|
|
// Kind is the threshold protocol that generated the key (cggmp21, frost,
|
|
// ...). A key is bound to its protocol: cross-scheme reuse of a share is
|
|
// prohibited (LP-4700), and Kind is what enforces the binding at signing.
|
|
Kind string
|
|
// Policy is the quorum in operator form: K signers of N parties. The
|
|
// polynomial degree the protocol was parameterised with is Policy.Degree(),
|
|
// derived — never stored independently, because two stored numbers can
|
|
// disagree and one cannot.
|
|
Policy quorum.Policy
|
|
// Participants are the parties holding a share, in canonical (sorted) order
|
|
// so every validator hashes the same bytes. len(Participants) == Policy.N.
|
|
Participants []party.ID
|
|
// GroupPublicKey is the compressed secp256k1 point (33 bytes) that
|
|
// signatures verify under.
|
|
GroupPublicKey []byte
|
|
// Address is the 20-byte Ethereum-style address of GroupPublicKey — the
|
|
// external-chain custody address that actually holds bridged funds.
|
|
Address []byte
|
|
// Generation increments on each resharing/refresh of the same public key.
|
|
Generation uint64
|
|
// CreatedHeight is the M-Chain height at which the key was registered.
|
|
CreatedHeight uint64
|
|
}
|
|
|
|
// Degree returns the polynomial degree this key was generated with. It is the
|
|
// value to hand to cmp/frost, and it is derived from the policy at the one
|
|
// boundary that is allowed to convert between k and t.
|
|
func (r *KeyRecord) Degree() int { return r.Policy.Degree() }
|
|
|
|
// Validate checks the record's internal consistency. A record that fails this
|
|
// must never be admitted to consensus state, because every later signature
|
|
// check trusts these fields.
|
|
func (r *KeyRecord) Validate() error {
|
|
if r.KeyID == "" {
|
|
return errors.New("mpcvm: key record has no id")
|
|
}
|
|
if r.Kind == "" {
|
|
return errors.New("mpcvm: key record has no protocol kind")
|
|
}
|
|
if !r.Policy.Valid() {
|
|
return fmt.Errorf("mpcvm: key %s has undeployable policy %s", r.KeyID, r.Policy)
|
|
}
|
|
// The custody floor, defined once in the shared substrate: a key whose
|
|
// policy admits two disjoint quorums lets two halves of the committee
|
|
// authorise contradictory releases of the same funds.
|
|
if !types.HasUniqueQuorum(r.Policy) {
|
|
return fmt.Errorf("mpcvm: key %s policy %s admits two disjoint quorums", r.KeyID, r.Policy)
|
|
}
|
|
if len(r.Participants) != r.Policy.N {
|
|
return fmt.Errorf("%w: %s has %d participants for policy %s",
|
|
ErrPolicyMismatch, r.KeyID, len(r.Participants), r.Policy)
|
|
}
|
|
if !sortedUnique(r.Participants) {
|
|
return fmt.Errorf("mpcvm: key %s participants not sorted+unique (non-canonical)", r.KeyID)
|
|
}
|
|
if len(r.GroupPublicKey) != 33 {
|
|
return fmt.Errorf("mpcvm: key %s group public key is %d bytes, want 33 (compressed secp256k1)",
|
|
r.KeyID, len(r.GroupPublicKey))
|
|
}
|
|
if len(r.Address) != 20 {
|
|
return fmt.Errorf("mpcvm: key %s address is %d bytes, want 20", r.KeyID, len(r.Address))
|
|
}
|
|
// Generation counts resharings of the same public key, and M-Chain has no
|
|
// resharing ceremony, so the only generation a key can honestly be at is
|
|
// its first. Left unchecked the field is proposer-chosen: a registration
|
|
// could enter consensus state claiming a rotation history the chain has no
|
|
// record of, and every reader of the registry would believe it.
|
|
if r.Generation != 0 {
|
|
return fmt.Errorf("mpcvm: key %s registers at generation %d; a key is registered once, at 0", r.KeyID, r.Generation)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// KeyCommitDigest is the message a newly generated group key signs to prove
|
|
// possession of itself. Binding the policy, the participant set and the group
|
|
// key together means a proposer cannot register a key under a policy or a
|
|
// committee other than the one the ceremony actually ran with and still produce
|
|
// a verifying proof.
|
|
//
|
|
// What this proves and what it does not: a valid proof-of-possession shows that
|
|
// whoever produced it can sign under GroupPublicKey, which rules out a proposer
|
|
// registering a public key it does not control (rogue-key registration). It
|
|
// does NOT by itself prove the declared degree — a single party holding the
|
|
// whole secret could also sign. Degree is established by the participant
|
|
// cross-check in Block.Verify: a validator that holds a share for this key
|
|
// compares the record against its own config and rejects a mismatch, so one
|
|
// honest participant is enough to stop a mis-declared key.
|
|
func KeyCommitDigest(r *KeyRecord) [32]byte {
|
|
h := sha256.New()
|
|
writeTagged(h, tagKeyCommit)
|
|
writeField(h, []byte(r.KeyID))
|
|
writeField(h, []byte(r.Kind))
|
|
writeUint64(h, uint64(r.Policy.K))
|
|
writeUint64(h, uint64(r.Policy.N))
|
|
writeParties(h, r.Participants)
|
|
writeField(h, r.GroupPublicKey)
|
|
writeUint64(h, r.Generation)
|
|
var out [32]byte
|
|
copy(out[:], h.Sum(nil))
|
|
return out
|
|
}
|
|
|
|
// CeremonyRecord is the replicated record of one completed ceremony. It is the
|
|
// audit trail: which key, over which digest, by which signers, producing what.
|
|
type CeremonyRecord struct {
|
|
// ID is the derived ceremony id — H(tag ‖ keyID ‖ digest ‖ sorted signers).
|
|
// Derived, not announced: every validator computing the same task lands on
|
|
// the same id with no coordination round and no coordinator.
|
|
ID string
|
|
// Kind is the operation: OpTypeKeygen or OpTypeSign.
|
|
Kind string
|
|
// KeyID is the custody key the ceremony created or used.
|
|
KeyID string
|
|
// Digest is the 32-byte message that was signed (sign ceremonies) or the
|
|
// key-commit digest (keygen ceremonies).
|
|
Digest []byte
|
|
// Signers are the parties that participated, in canonical order.
|
|
Signers []party.ID
|
|
// Artifact is the ceremony's verifiable output: for a sign ceremony the
|
|
// 65-byte r‖s‖v signature; for a keygen ceremony the proof-of-possession
|
|
// over KeyCommitDigest.
|
|
Artifact []byte
|
|
// RequestingChain names the chain that asked for this ceremony (B-Chain for
|
|
// bridge custody). Empty for locally initiated ceremonies.
|
|
RequestingChain string
|
|
// Height is the M-Chain height at which the ceremony was recorded.
|
|
Height uint64
|
|
}
|
|
|
|
// State is M-Chain's persisted state over one database. It owns the encoding
|
|
// and the root; it does not know what a ceremony means.
|
|
//
|
|
// State is not safe for concurrent use; the VM holds it under its own lock.
|
|
// That is deliberate — a state machine with its own internal locking invites
|
|
// callers to interleave reads and writes across a transition and observe a
|
|
// half-applied block.
|
|
type State struct {
|
|
db database.Database
|
|
root [32]byte
|
|
}
|
|
|
|
// NewState opens state over db, resuming from whatever is already persisted.
|
|
// chainID seeds the genesis root so two different chains running the same
|
|
// operations do not produce the same root.
|
|
func NewState(db database.Database, chainID ids.ID) (*State, error) {
|
|
s := &State{db: db}
|
|
switch stored, err := db.Get(keyRoot); {
|
|
case err == nil:
|
|
if len(stored) != 32 {
|
|
return nil, fmt.Errorf("mpcvm: stored root is %d bytes, want 32", len(stored))
|
|
}
|
|
copy(s.root[:], stored)
|
|
case errors.Is(err, database.ErrNotFound):
|
|
s.root = genesisRoot(chainID)
|
|
if err := db.Put(keyRoot, s.root[:]); err != nil {
|
|
return nil, fmt.Errorf("mpcvm: seed root: %w", err)
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("mpcvm: read root: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Root returns the current state root.
|
|
func (s *State) Root() [32]byte { return s.root }
|
|
|
|
// genesisRoot is the root of empty state for a given chain.
|
|
func genesisRoot(chainID ids.ID) [32]byte {
|
|
h := sha256.New()
|
|
writeTagged(h, tagRootGenesis)
|
|
writeField(h, chainID[:])
|
|
var out [32]byte
|
|
copy(out[:], h.Sum(nil))
|
|
return out
|
|
}
|
|
|
|
// advance folds one operation digest into the root. Pure: it returns the next
|
|
// root without touching state, so Verify can compute a candidate post-state
|
|
// root without mutating anything.
|
|
func advance(root [32]byte, opDigest [32]byte) [32]byte {
|
|
h := sha256.New()
|
|
writeTagged(h, tagRootStep)
|
|
writeField(h, root[:])
|
|
writeField(h, opDigest[:])
|
|
var out [32]byte
|
|
copy(out[:], h.Sum(nil))
|
|
return out
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Key registry (replicated)
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// PutKey registers a custody key. Registration is once-only: a key id is a
|
|
// permanent binding to a group public key, and silently rebinding it would let
|
|
// a later ceremony redirect custody of live funds.
|
|
//
|
|
// It is the ONLY writer of the key registry. There was a second — a ReplaceKey
|
|
// that overwrote a record in place for a resharing path that does not exist —
|
|
// and a second writer into a registry whose whole value is "this binding never
|
|
// moves" is a rebinding waiting for its first caller.
|
|
func (s *State) PutKey(r *KeyRecord) error {
|
|
if err := r.Validate(); err != nil {
|
|
return err
|
|
}
|
|
k := append(append([]byte(nil), prefixKeyRecord...), r.KeyID...)
|
|
switch has, err := s.db.Has(k); {
|
|
case err != nil:
|
|
return err
|
|
case has:
|
|
return fmt.Errorf("%w: %s", ErrKeyExists, r.KeyID)
|
|
}
|
|
return s.db.Put(k, marshalKeyRecord(r))
|
|
}
|
|
|
|
// GetKey reads a registered custody key.
|
|
func (s *State) GetKey(keyID string) (*KeyRecord, error) {
|
|
b, err := s.db.Get(append(append([]byte(nil), prefixKeyRecord...), keyID...))
|
|
if errors.Is(err, database.ErrNotFound) {
|
|
return nil, fmt.Errorf("%w: %s", ErrUnknownKey, keyID)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseKeyRecord(b)
|
|
}
|
|
|
|
// HasKey reports whether a key id is registered.
|
|
func (s *State) HasKey(keyID string) (bool, error) {
|
|
return s.db.Has(append(append([]byte(nil), prefixKeyRecord...), keyID...))
|
|
}
|
|
|
|
// Keys returns every registered custody key in key-id order.
|
|
func (s *State) Keys() ([]*KeyRecord, error) {
|
|
it := s.db.NewIteratorWithPrefix(prefixKeyRecord)
|
|
defer it.Release()
|
|
var out []*KeyRecord
|
|
for it.Next() {
|
|
r, err := parseKeyRecord(it.Value())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, it.Error()
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Ceremony log (replicated)
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// PutCeremony records a completed ceremony. Ceremony ids are derived from
|
|
// (key, digest, signer set), so a repeat id means a genuine replay of an
|
|
// identical task and is rejected: recording it twice would double-count a
|
|
// bridge release.
|
|
func (s *State) PutCeremony(c *CeremonyRecord) error {
|
|
if c.ID == "" {
|
|
return errors.New("mpcvm: ceremony has no id")
|
|
}
|
|
k := append(append([]byte(nil), prefixCeremony...), c.ID...)
|
|
switch has, err := s.db.Has(k); {
|
|
case err != nil:
|
|
return err
|
|
case has:
|
|
return fmt.Errorf("%w: %s", ErrCeremonyExists, c.ID)
|
|
}
|
|
return s.db.Put(k, marshalCeremonyRecord(c))
|
|
}
|
|
|
|
// GetCeremony reads a recorded ceremony.
|
|
func (s *State) GetCeremony(id string) (*CeremonyRecord, error) {
|
|
b, err := s.db.Get(append(append([]byte(nil), prefixCeremony...), id...))
|
|
if errors.Is(err, database.ErrNotFound) {
|
|
return nil, fmt.Errorf("mpcvm: no ceremony %s", id)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseCeremonyRecord(b)
|
|
}
|
|
|
|
// Ceremonies returns every recorded ceremony in id order.
|
|
func (s *State) Ceremonies() ([]*CeremonyRecord, error) {
|
|
it := s.db.NewIteratorWithPrefix(prefixCeremony)
|
|
defer it.Release()
|
|
var out []*CeremonyRecord
|
|
for it.Next() {
|
|
c, err := parseCeremonyRecord(it.Value())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, it.Error()
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Node-private share store (NOT replicated, NOT in Root)
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// PutShare stores this node's secret key share for keyID.
|
|
//
|
|
// This is the one secret M-Chain writes to disk, and it is written under the
|
|
// node/ prefix so it is structurally outside consensus state and outside
|
|
// Root(). The trust model is the validator's own disk — the same place its
|
|
// staking key lives. A node that loses this cannot sign for keyID and must be
|
|
// re-shared in; a node that leaks it has leaked one share, which is below the
|
|
// policy's corruption bound unless K-1 others leak too.
|
|
func (s *State) PutShare(keyID string, share []byte) error {
|
|
if len(share) == 0 {
|
|
return errors.New("mpcvm: refusing to store an empty share")
|
|
}
|
|
return s.db.Put(append(append([]byte(nil), prefixShare...), keyID...), share)
|
|
}
|
|
|
|
// GetShare reads this node's secret share for keyID.
|
|
func (s *State) GetShare(keyID string) ([]byte, error) {
|
|
b, err := s.db.Get(append(append([]byte(nil), prefixShare...), keyID...))
|
|
if errors.Is(err, database.ErrNotFound) {
|
|
return nil, fmt.Errorf("%w: %s", ErrShareNotHeld, keyID)
|
|
}
|
|
return b, err
|
|
}
|
|
|
|
// HasShare reports whether this node participates in keyID's committee.
|
|
func (s *State) HasShare(keyID string) (bool, error) {
|
|
return s.db.Has(append(append([]byte(nil), prefixShare...), keyID...))
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Chain pointers
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// WriteRoot stages the root a block reaches. It is staged, not landed: the
|
|
// block, its height entry, the tip pointer and every record the block wrote all
|
|
// commit together or not at all, so a root can never name a state the chain
|
|
// does not have.
|
|
func (s *State) WriteRoot(root [32]byte) error {
|
|
return s.db.Put(keyRoot, root[:])
|
|
}
|
|
|
|
// HoldRoot makes a committed root current. It runs after the commit, so the
|
|
// root this node reports is always one that is on disk.
|
|
func (s *State) HoldRoot(root [32]byte) { s.root = root }
|
|
|
|
// ReadRoot puts the committed root back, discarding an advance that belonged
|
|
// to a block whose writes were discarded.
|
|
func (s *State) ReadRoot() error {
|
|
stored, err := s.db.Get(keyRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(stored) != 32 {
|
|
return fmt.Errorf("mpcvm: stored root is %d bytes, want 32", len(stored))
|
|
}
|
|
copy(s.root[:], stored)
|
|
return nil
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Canonical hashing helpers
|
|
// -----------------------------------------------------------------------------
|
|
//
|
|
// Every variable-length field is length-prefixed before hashing so that
|
|
// H(a‖b) cannot collide with H(a'‖b') for a different split — without this,
|
|
// ("ab","c") and ("a","bc") hash identically and a ceremony id could be forged
|
|
// by moving a byte between the key id and the digest.
|
|
|
|
type byteWriter interface{ Write([]byte) (int, error) }
|
|
|
|
func writeTagged(h byteWriter, tag string) { writeField(h, []byte(tag)) }
|
|
|
|
func writeField(h byteWriter, b []byte) {
|
|
var n [8]byte
|
|
binary.BigEndian.PutUint64(n[:], uint64(len(b)))
|
|
_, _ = h.Write(n[:])
|
|
_, _ = h.Write(b)
|
|
}
|
|
|
|
func writeUint64(h byteWriter, v uint64) {
|
|
var n [8]byte
|
|
binary.BigEndian.PutUint64(n[:], v)
|
|
_, _ = h.Write(n[:])
|
|
}
|
|
|
|
func writeParties(h byteWriter, ps []party.ID) {
|
|
writeUint64(h, uint64(len(ps)))
|
|
for _, p := range ps {
|
|
writeField(h, []byte(p))
|
|
}
|
|
}
|
|
|
|
// canonicalParties returns a sorted, de-duplicated copy. Sorting is what makes
|
|
// a signer set order-independent: two validators that enumerate the same
|
|
// committee in different orders must derive the same ceremony id.
|
|
func canonicalParties(ps []party.ID) []party.ID {
|
|
out := append([]party.ID(nil), ps...)
|
|
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
|
dedup := out[:0]
|
|
for i, p := range out {
|
|
if i == 0 || p != out[i-1] {
|
|
dedup = append(dedup, p)
|
|
}
|
|
}
|
|
return dedup
|
|
}
|
|
|
|
func sortedUnique(ps []party.ID) bool {
|
|
for i := 1; i < len(ps); i++ {
|
|
if ps[i-1] >= ps[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// samePartySet reports whether two canonical party lists are the same set. Both
|
|
// sides are sorted and de-duplicated wherever this is called, so equality of
|
|
// the sequences is equality of the sets, and a caller cannot smuggle a
|
|
// difference past it by reordering or repeating a name.
|
|
func samePartySet(a, b []party.ID) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return sortedUnique(a)
|
|
}
|