smart-byte: governance, burns, and transfers between clusters #135
22 changed files with 12919 additions and 303 deletions
|
|
@ -30,7 +30,7 @@ A pack for a given version contains eight JSON files plus `regeneration.md`:
|
|||
| `signature_vectors.json` | Ed25519 over the messages Smart Byte signs, verified strictly (RFC 8032) |
|
||||
| `kel_vectors.json` | Key-event logs: inception, rotation, interaction, thresholds (identity §3, §4) |
|
||||
| `envelope_vectors.json` | Envelopes decoded and verified against the logs' key states (envelope wire format) |
|
||||
| `state_vectors.json` | The cluster state machine: frames of inputs, their results, the state and its hash (cluster state) |
|
||||
| `state_vectors.json` | The cluster state machine: frames of inputs, their results, the state and its hash — governance, burns and transfers between clusters included (cluster state) |
|
||||
| `lockstep_vectors.json` | Supermajority commit, frame outcomes, dissent log (treatise §13, §14) |
|
||||
| `gossip_vectors.json` | Receiving SAID-addressed objects (identity §2.6; treatise §11.4, §21.4) |
|
||||
|
||||
|
|
@ -195,7 +195,21 @@ they are lowercase hex.
|
|||
| `issuer_not_registered` | a mint by an issuer the registry does not list for the cargo's type |
|
||||
| `duplicate_byte` | a mint of a byte the cluster already holds |
|
||||
| `unknown_byte` | a transfer of a byte the cluster does not hold |
|
||||
| `wrong_frame` | a transfer whose transition names a frame other than the one applied |
|
||||
| `wrong_frame` | a transfer or burn naming a frame other than the one applied |
|
||||
| `byte_retired` | an input acting on a byte that was burned |
|
||||
| `byte_exported` | an input acting on a byte that left for another cluster |
|
||||
| `wrong_cluster` | an input addressed to another cluster, or a cluster named as its own peer |
|
||||
| `unknown_cluster` | a byte sent to, or imported from, a cluster that is not a peer |
|
||||
| `duplicate_import` | an export certificate the cluster has imported already |
|
||||
| `certificate_mismatch` | an import whose envelope is not the byte its certificate certifies |
|
||||
| `stale_sequence` | a proposal made against a governance sequence number other than the cluster's |
|
||||
| `already_registered` | a proposal to add an issuer already registered for the cargo type |
|
||||
| `not_registered` | a proposal to remove an issuer not registered for the cargo type |
|
||||
| `not_a_peer` | a proposal to remove a cluster that is not a peer |
|
||||
| `no_electorate` | a proposal no issuer can approve |
|
||||
| `not_an_elector` | an approval by an issuer with no vote on the proposal |
|
||||
| `duplicate_approver` | one issuer's approval listed twice |
|
||||
| `below_quorum` | fewer approvals than a supermajority of the electorate |
|
||||
|
||||
The reference implementation also reports `truncated`, `malformed`,
|
||||
`too_deep`, `not_a_map`, `missing_said_field` and `malformed_said` for
|
||||
|
|
@ -270,6 +284,10 @@ The pack is the wire-format gate. It does not cover:
|
|||
- Witnessing, delegation and recovery. Spec v1 defines none of them, and a
|
||||
v1 verifier refuses events that use them; the reference implementation
|
||||
tests its extensions for them in its own suite.
|
||||
- Cluster membership: how nodes join and leave a cluster, and how a cluster
|
||||
splits or merges (treatise §11.2, §11.3.2). The state machine starts where
|
||||
membership is settled: a cluster's own nodes are outside its state, and
|
||||
its peers' nodes are in it.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -672,16 +672,27 @@ def provenance_parts(obj, cls):
|
|||
return prov, controller_signature(prov["signature"], cls)
|
||||
|
||||
|
||||
TRANSITION_MEMBERS = {"previous_history_hash", "from", "to", "frame", "signature"}
|
||||
|
||||
|
||||
def transition_parts(obj, cls):
|
||||
"""A transition (envelope wire format §5) of exactly its form: its
|
||||
members and its signature."""
|
||||
tm = members(obj, {"previous_history_hash", "from", "to", "frame", "signature"}, cls)
|
||||
"""A transition (envelope wire format §5) of exactly its form — with a
|
||||
`destination` only when it moves the byte to another cluster: its members
|
||||
and its signature."""
|
||||
present = {k for k, _ in obj} if isinstance(obj, Map) else set()
|
||||
tm = members(obj, TRANSITION_MEMBERS | ({"destination"} & present), cls)
|
||||
if not (isinstance(tm["previous_history_hash"], bytes) and len(tm["previous_history_hash"]) == 32
|
||||
and parse_aid(tm["from"]) and parse_aid(tm["to"]) and is_uint(tm["frame"])):
|
||||
and parse_aid(tm["from"]) and parse_aid(tm["to"]) and is_uint(tm["frame"])
|
||||
and ("destination" not in tm or parse_said(tm["destination"]) is not None)):
|
||||
raise Refused(cls)
|
||||
return tm, controller_signature(tm["signature"], cls)
|
||||
|
||||
|
||||
def with_destination(pairs, t):
|
||||
"""`pairs`, plus the transition's destination when it has one."""
|
||||
return Map(pairs + ([("destination", t["destination"])] if "destination" in t else []))
|
||||
|
||||
|
||||
def attested(prov):
|
||||
"""What the issuer signs: {cargo, issuer, nonce, recipient} (§3)."""
|
||||
return encode(Map([("cargo", prov["cargo"]), ("issuer", prov["issuer"]),
|
||||
|
|
@ -690,14 +701,14 @@ def attested(prov):
|
|||
|
||||
def transition_message(identity, t):
|
||||
"""What an owner signs to transfer a byte (§5)."""
|
||||
return encode(Map([("byte", identity), ("previous_history_hash", t["previous_history_hash"]),
|
||||
("from", t["from"]), ("to", t["to"]), ("frame", t["frame"])]))
|
||||
return encode(with_destination([("byte", identity), ("previous_history_hash", t["previous_history_hash"]),
|
||||
("from", t["from"]), ("to", t["to"]), ("frame", t["frame"])], t))
|
||||
|
||||
|
||||
def next_history(history, t):
|
||||
"""The history hash after transition `t` (§5)."""
|
||||
return blake3(history + encode(Map([("from", t["from"]), ("to", t["to"]), ("frame", t["frame"]),
|
||||
("signature", t["signature"])])))
|
||||
return blake3(history + encode(with_destination([("from", t["from"]), ("to", t["to"]),
|
||||
("frame", t["frame"]), ("signature", t["signature"])], t)))
|
||||
|
||||
|
||||
def decode_envelope(data):
|
||||
|
|
@ -745,7 +756,8 @@ def verify_envelope(data, key_states):
|
|||
raise Refused("frame_not_increasing")
|
||||
signed_by(t["from"], signature, transition_message(identity, t))
|
||||
history = next_history(history, t)
|
||||
owner, last_frame = t["to"], t["frame"]
|
||||
# Frames rise within a cluster; a move to another starts its count.
|
||||
owner, last_frame = t["to"], (None if "destination" in t else t["frame"])
|
||||
return {"identity": identity, "snapshot": env["d"], "issuer": prov["issuer"], "owner": owner,
|
||||
"history_hash": history.hex(), "transfers": len(chain)}
|
||||
|
||||
|
|
@ -753,39 +765,85 @@ def verify_envelope(data, key_states):
|
|||
# ---------------------------------------------------------------------------
|
||||
# The cluster state machine (cluster state spec)
|
||||
#
|
||||
# A state is {"frame", "registry", "controllers", "bytes"}: the registry maps
|
||||
# issuer AIDs to cargo-type lists, controllers and bytes map AIDs and
|
||||
# identities to records holding their members as the spec names them.
|
||||
# A state is a dict of the spec's members. Maps keyed by AID, identity or
|
||||
# cluster are dicts of records; records are dicts of their members; cargo
|
||||
# and change maps stay Maps, as they were decoded.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STATE_MEMBERS = {"frame", "registry", "controllers", "bytes"}
|
||||
STATE_MEMBERS = {"frame", "cluster", "peers", "registry", "governance", "controllers", "bytes", "retired",
|
||||
"exported", "imported"}
|
||||
CONTROLLER_MEMBERS = {"establishment", "keys", "threshold", "next_keys", "next_threshold", "sequence",
|
||||
"last_event"}
|
||||
BYTE_MEMBERS = {"issuer", "cargo", "minted", "owner", "history_hash"}
|
||||
INPUT_CLASSES = ("key_event", "mint", "transfer")
|
||||
INPUT_CLASSES = ("key_event", "governance", "mint", "import", "transfer", "burn")
|
||||
|
||||
|
||||
def record_map(r):
|
||||
return Map(r.items())
|
||||
|
||||
|
||||
def encode_state(st):
|
||||
"""§2: the state's canonical encoding — the checkpoint."""
|
||||
return encode(Map([
|
||||
("frame", st["frame"]),
|
||||
("registry", Map((aid, list(types)) for aid, types in st["registry"].items())),
|
||||
("controllers", Map((aid, Map(r.items())) for aid, r in st["controllers"].items())),
|
||||
("bytes", Map((i, Map(r.items())) for i, r in st["bytes"].items())),
|
||||
("cluster", st["cluster"]),
|
||||
("peers", Map((c, Map([("keys", keys)])) for c, keys in st["peers"].items())),
|
||||
("registry", Map((aid, sorted(types)) for aid, types in st["registry"].items())),
|
||||
("governance", Map([("delay", st["delay"]), ("sequence", st["sequence"]),
|
||||
("pending", [Map([("change", ch), ("effective", eff)]) for ch, eff in st["pending"]])])),
|
||||
("controllers", Map((aid, record_map(r)) for aid, r in st["controllers"].items())),
|
||||
("bytes", Map((i, record_map(r)) for i, r in st["bytes"].items())),
|
||||
("retired", Map((i, record_map(r)) for i, r in st["retired"].items())),
|
||||
("exported", Map((i, record_map(r)) for i, r in st["exported"].items())),
|
||||
("imported", sorted(st["imported"])),
|
||||
]))
|
||||
|
||||
|
||||
def decode_state(data):
|
||||
"""A state from its canonical encoding, which it must reproduce."""
|
||||
st = members(decode(data), STATE_MEMBERS, "malformed_state")
|
||||
out = {
|
||||
"frame": st["frame"],
|
||||
"registry": {aid: list(types) for aid, types in st["registry"]},
|
||||
"controllers": {aid: dict(members(r, CONTROLLER_MEMBERS, "malformed_state")) for aid, r in st["controllers"]},
|
||||
"bytes": {i: dict(members(r, BYTE_MEMBERS, "malformed_state")) for i, r in st["bytes"]},
|
||||
m = members(decode(data), STATE_MEMBERS, "malformed_state")
|
||||
gov = members(m["governance"], {"delay", "sequence", "pending"}, "malformed_state")
|
||||
st = {
|
||||
"frame": m["frame"],
|
||||
"cluster": m["cluster"],
|
||||
"peers": {c: dict(members(p, {"keys"}, "malformed_state"))["keys"] for c, p in m["peers"]},
|
||||
"registry": {aid: list(types) for aid, types in m["registry"]},
|
||||
"delay": gov["delay"],
|
||||
"sequence": gov["sequence"],
|
||||
"pending": [(dict(p)["change"], dict(p)["effective"]) for p in gov["pending"]],
|
||||
"controllers": {aid: dict(members(r, CONTROLLER_MEMBERS, "malformed_state")) for aid, r in m["controllers"]},
|
||||
"bytes": {i: dict(members(r, BYTE_MEMBERS, "malformed_state")) for i, r in m["bytes"]},
|
||||
"retired": {i: dict(members(r, BYTE_MEMBERS | {"retired"}, "malformed_state")) for i, r in m["retired"]},
|
||||
"exported": {i: dict(members(r, BYTE_MEMBERS | {"destination", "exported"}, "malformed_state"))
|
||||
for i, r in m["exported"]},
|
||||
"imported": list(m["imported"]),
|
||||
}
|
||||
check(encode_state(out) == data, "the state does not reproduce its encoding")
|
||||
return out
|
||||
check(encode_state(st) == data, "the state does not reproduce its encoding")
|
||||
return st
|
||||
|
||||
|
||||
def is_ed25519_key(key):
|
||||
"""An Ed25519 key a signature could verify under: 01, then 32 bytes that
|
||||
decode to a point not of small order."""
|
||||
if not (isinstance(key, bytes) and len(key) == 33 and key[0] == ED25519):
|
||||
return False
|
||||
point = _decode_point(key[1:])
|
||||
return point is not None and not _small_order(point)
|
||||
|
||||
|
||||
def change_parts(obj):
|
||||
"""A proposal's change (§4.5) of exactly its form."""
|
||||
action = dict(obj).get("action") if isinstance(obj, Map) else None
|
||||
forms = {"add_issuer": {"action", "cargo_type", "issuer"}, "remove_issuer": {"action", "cargo_type", "issuer"},
|
||||
"set_peer": {"action", "peer", "keys"}, "remove_peer": {"action", "peer"}}
|
||||
if action not in forms:
|
||||
raise Refused("malformed_input")
|
||||
ch = members(obj, forms[action], "malformed_input")
|
||||
if ("cargo_type" in ch and not is_uint(ch["cargo_type"])) or ("issuer" in ch and parse_aid(ch["issuer"]) is None) \
|
||||
or ("peer" in ch and parse_said(ch["peer"]) is None) \
|
||||
or ("keys" in ch and not (isinstance(ch["keys"], list) and all(isinstance(k, bytes) for k in ch["keys"]))):
|
||||
raise Refused("malformed_input")
|
||||
return ch
|
||||
|
||||
|
||||
def parse_input(data):
|
||||
|
|
@ -805,14 +863,55 @@ def parse_input(data):
|
|||
sigs = signature_list(ke["signatures"], "malformed_input")
|
||||
event_bytes = encode(ke["event"])
|
||||
return cls, (decode_event(event_bytes), event_bytes, sigs)
|
||||
if cls == "governance":
|
||||
g = members(body, {"proposal", "approvals"}, "malformed_input")
|
||||
pr = members(g["proposal"], {"cluster", "sequence", "change"}, "malformed_input")
|
||||
if parse_said(pr["cluster"]) is None or not is_uint(pr["sequence"]) or not isinstance(g["approvals"], list):
|
||||
raise Refused("malformed_input")
|
||||
change = change_parts(pr["change"])
|
||||
approvals = []
|
||||
for a in g["approvals"]:
|
||||
ap = members(a, {"approver", "signature"}, "malformed_input")
|
||||
if parse_aid(ap["approver"]) is None:
|
||||
raise Refused("malformed_input")
|
||||
approvals.append((ap["approver"], controller_signature(ap["signature"], "malformed_input")))
|
||||
return cls, (pr, change, encode(g["proposal"]), approvals)
|
||||
if cls == "mint":
|
||||
prov, sig = provenance_parts(body, "malformed_input")
|
||||
return cls, (body, prov, sig)
|
||||
tr = members(body, {"byte", "transition"}, "malformed_input")
|
||||
if parse_said(tr["byte"]) is None:
|
||||
if cls == "import":
|
||||
im = members(body, {"certificate", "envelope", "logs"}, "malformed_input")
|
||||
cert = members(im["certificate"], {"statement", "signatures"}, "malformed_input")
|
||||
stm = members(cert["statement"], {"source", "byte", "export"}, "malformed_input")
|
||||
export = members(stm["export"], BYTE_MEMBERS | {"destination", "exported"}, "malformed_input")
|
||||
cargo = members(export["cargo"], {"type", "bytes"}, "malformed_input")
|
||||
if not (parse_said(stm["source"]) and parse_said(stm["byte"]) and parse_aid(export["issuer"])
|
||||
and is_uint(export["minted"]) and parse_aid(export["owner"])
|
||||
and isinstance(export["history_hash"], bytes) and len(export["history_hash"]) == 32
|
||||
and parse_said(export["destination"]) and is_uint(export["exported"])
|
||||
and is_uint(cargo["type"]) and isinstance(cargo["bytes"], bytes)
|
||||
and isinstance(im["logs"], list) and all(isinstance(log, list) for log in im["logs"])):
|
||||
raise Refused("malformed_input")
|
||||
cert_sigs = signature_list(cert["signatures"], "malformed_input")
|
||||
logs = []
|
||||
for log in im["logs"]:
|
||||
events = []
|
||||
for e in log:
|
||||
le = members(e, {"event", "signatures"}, "malformed_input")
|
||||
events.append((le["event"], signature_list(le["signatures"], "malformed_input")))
|
||||
logs.append(events)
|
||||
return cls, (stm, export, encode(cert["statement"]), cert_sigs, im["envelope"], logs)
|
||||
if cls == "transfer":
|
||||
tr = members(body, {"byte", "transition"}, "malformed_input")
|
||||
if parse_said(tr["byte"]) is None:
|
||||
raise Refused("malformed_input")
|
||||
t, sig = transition_parts(tr["transition"], "malformed_input")
|
||||
return cls, (tr["byte"], t, sig)
|
||||
b = members(body, {"byte", "previous_history_hash", "owner", "frame", "signature"}, "malformed_input")
|
||||
if not (parse_said(b["byte"]) and isinstance(b["previous_history_hash"], bytes)
|
||||
and len(b["previous_history_hash"]) == 32 and parse_aid(b["owner"]) and is_uint(b["frame"])):
|
||||
raise Refused("malformed_input")
|
||||
t, sig = transition_parts(tr["transition"], "malformed_input")
|
||||
return cls, (tr["byte"], t, sig)
|
||||
return cls, (b, controller_signature(b["signature"], "malformed_input"))
|
||||
except Refused as e:
|
||||
return cls, e
|
||||
|
||||
|
|
@ -832,7 +931,7 @@ def record_of(state):
|
|||
|
||||
|
||||
def signed_in_force(st, signer, signature, message):
|
||||
"""§4.5: a signature by `signer` under the establishment event in force."""
|
||||
"""§4.8: a signature by `signer` under the establishment event in force."""
|
||||
establishment, sigs = signature
|
||||
r = st["controllers"].get(signer)
|
||||
if r is None:
|
||||
|
|
@ -844,8 +943,133 @@ def signed_in_force(st, signer, signature, message):
|
|||
raise Refused(err)
|
||||
|
||||
|
||||
def live(st, identity):
|
||||
"""The live record of a byte, or why there is none (§4.4 step 1)."""
|
||||
if identity in st["bytes"]:
|
||||
return st["bytes"][identity]
|
||||
if identity in st["retired"]:
|
||||
raise Refused("byte_retired")
|
||||
if identity in st["exported"]:
|
||||
raise Refused("byte_exported")
|
||||
raise Refused("unknown_byte")
|
||||
|
||||
|
||||
def supermajority(n):
|
||||
return 2 * n // 3 + 1
|
||||
|
||||
|
||||
def apply_governance(st, pr, change, message, approvals):
|
||||
"""§4.5."""
|
||||
if pr["cluster"] != st["cluster"]:
|
||||
raise Refused("wrong_cluster")
|
||||
if pr["sequence"] != st["sequence"]:
|
||||
raise Refused("stale_sequence")
|
||||
action, target = change["action"], None
|
||||
registered = lambda issuer, t: t in st["registry"].get(issuer, [])
|
||||
if action == "add_issuer":
|
||||
if registered(change["issuer"], change["cargo_type"]):
|
||||
raise Refused("already_registered")
|
||||
elif action == "remove_issuer":
|
||||
if not registered(change["issuer"], change["cargo_type"]):
|
||||
raise Refused("not_registered")
|
||||
target = change["issuer"]
|
||||
elif action == "set_peer":
|
||||
if change["peer"] == st["cluster"]:
|
||||
raise Refused("wrong_cluster")
|
||||
if not change["keys"]:
|
||||
raise Refused("invalid_threshold")
|
||||
for i, k in enumerate(change["keys"]):
|
||||
if k in change["keys"][:i]:
|
||||
raise Refused("duplicate_key")
|
||||
if not is_ed25519_key(k):
|
||||
raise Refused("malformed_verifying_key")
|
||||
elif change["peer"] not in st["peers"]:
|
||||
raise Refused("not_a_peer")
|
||||
every = {a for a in st["registry"] if a != target}
|
||||
if action in ("add_issuer", "remove_issuer"):
|
||||
of_type = {a for a, types in st["registry"].items() if change["cargo_type"] in types and a != target}
|
||||
electorate = of_type or every
|
||||
else:
|
||||
electorate = every
|
||||
if not electorate:
|
||||
raise Refused("no_electorate")
|
||||
seen = set()
|
||||
for approver, signature in approvals:
|
||||
if approver not in electorate:
|
||||
raise Refused("not_an_elector")
|
||||
if approver in seen:
|
||||
raise Refused("duplicate_approver")
|
||||
seen.add(approver)
|
||||
signed_in_force(st, approver, signature, message)
|
||||
if len(seen) < supermajority(len(electorate)):
|
||||
raise Refused("below_quorum")
|
||||
st["sequence"] += 1
|
||||
st["pending"].append((pr["change"], st["frame"] + 1 + st["delay"]))
|
||||
|
||||
|
||||
def enact_pending(st):
|
||||
"""§4 step 1: changes in force from this frame take effect, in order."""
|
||||
due = [ch for ch, eff in st["pending"] if eff == st["frame"]]
|
||||
st["pending"] = [(ch, eff) for ch, eff in st["pending"] if eff != st["frame"]]
|
||||
for ch_map in due:
|
||||
ch = dict(ch_map)
|
||||
if ch["action"] == "add_issuer":
|
||||
types = st["registry"].setdefault(ch["issuer"], [])
|
||||
if ch["cargo_type"] not in types:
|
||||
types.append(ch["cargo_type"])
|
||||
elif ch["action"] == "remove_issuer":
|
||||
types = st["registry"].get(ch["issuer"], [])
|
||||
if ch["cargo_type"] in types:
|
||||
types.remove(ch["cargo_type"])
|
||||
if not types:
|
||||
st["registry"].pop(ch["issuer"], None)
|
||||
elif ch["action"] == "set_peer":
|
||||
st["peers"][ch["peer"]] = ch["keys"]
|
||||
else:
|
||||
st["peers"].pop(ch["peer"], None)
|
||||
|
||||
|
||||
def apply_import(st, stm, export, statement_bytes, cert_sigs, envelope, logs):
|
||||
"""§5: a byte from another cluster."""
|
||||
if export["destination"] != st["cluster"]:
|
||||
raise Refused("wrong_cluster")
|
||||
keys = st["peers"].get(stm["source"])
|
||||
if keys is None:
|
||||
raise Refused("unknown_cluster")
|
||||
err = verify_indexed(keys, supermajority(len(keys)), statement_bytes, cert_sigs)
|
||||
if err:
|
||||
raise Refused(err)
|
||||
digest = blake3(statement_bytes)
|
||||
if digest in st["imported"]:
|
||||
raise Refused("duplicate_import")
|
||||
if stm["byte"] in st["bytes"]:
|
||||
raise Refused("duplicate_byte")
|
||||
if stm["byte"] in st["retired"]:
|
||||
raise Refused("byte_retired")
|
||||
env_bytes = encode(envelope)
|
||||
env, prov, _, chain = decode_envelope(env_bytes)
|
||||
owner, history = prov["recipient"], parse_said(prov["d"])
|
||||
for t, _ in chain:
|
||||
owner, history = t["to"], next_history(history, t)
|
||||
if (prov["d"], history, owner, prov["issuer"], encode(prov["cargo"])) != \
|
||||
(stm["byte"], export["history_hash"], export["owner"], export["issuer"], encode(export["cargo"])):
|
||||
raise Refused("certificate_mismatch")
|
||||
key_states = {}
|
||||
for log in logs:
|
||||
try:
|
||||
state = replay([(encode(event), sigs) for event, sigs in log])
|
||||
except LogRefused as r:
|
||||
raise Refused(r.cls) from None
|
||||
for x in state["establishments"]:
|
||||
key_states[(state["i"], x["sequence"])] = (x["keys"], x["threshold"])
|
||||
verify_envelope(env_bytes, key_states)
|
||||
st["exported"].pop(stm["byte"], None)
|
||||
st["bytes"][stm["byte"]] = {f: export[f] for f in BYTE_MEMBERS}
|
||||
st["imported"].append(digest)
|
||||
|
||||
|
||||
def apply_input(st, cls, parts):
|
||||
"""§4.2-§4.4: apply one decoded input, or raise Refused leaving the state
|
||||
"""§4.2-§4.7: apply one decoded input, or raise Refused leaving the state
|
||||
as it was."""
|
||||
if cls == "key_event":
|
||||
e, data, sigs = parts
|
||||
|
|
@ -856,6 +1080,8 @@ def apply_input(st, cls, parts):
|
|||
raise Refused("unknown_controller")
|
||||
state = kel_step(None if held is None else kel_state_of(e["i"], held), e, data, sigs)
|
||||
st["controllers"][e["i"]] = record_of(state)
|
||||
elif cls == "governance":
|
||||
apply_governance(st, *parts)
|
||||
elif cls == "mint":
|
||||
obj, prov, sig = parts
|
||||
if derive(obj, "d")[1] != parse_said(prov["d"]):
|
||||
|
|
@ -863,30 +1089,54 @@ def apply_input(st, cls, parts):
|
|||
if dict(prov["cargo"])["type"] not in st["registry"].get(prov["issuer"], []):
|
||||
raise Refused("issuer_not_registered")
|
||||
signed_in_force(st, prov["issuer"], sig, attested(prov))
|
||||
if prov["d"] in st["bytes"]:
|
||||
if any(prov["d"] in st[m] for m in ("bytes", "retired", "exported")):
|
||||
raise Refused("duplicate_byte")
|
||||
st["bytes"][prov["d"]] = {"issuer": prov["issuer"], "cargo": prov["cargo"], "minted": st["frame"],
|
||||
"owner": prov["recipient"], "history_hash": parse_said(prov["d"])}
|
||||
else:
|
||||
elif cls == "import":
|
||||
apply_import(st, *parts)
|
||||
elif cls == "transfer":
|
||||
identity, t, sig = parts
|
||||
b = st["bytes"].get(identity)
|
||||
if b is None:
|
||||
raise Refused("unknown_byte")
|
||||
b = live(st, identity)
|
||||
if t["frame"] != st["frame"]:
|
||||
raise Refused("wrong_frame")
|
||||
if t["previous_history_hash"] != b["history_hash"]:
|
||||
raise Refused("broken_history_chain")
|
||||
if t["from"] != b["owner"]:
|
||||
raise Refused("not_current_owner")
|
||||
if "destination" in t and t["destination"] not in st["peers"]:
|
||||
raise Refused("unknown_cluster")
|
||||
signed_in_force(st, t["from"], sig, transition_message(identity, t))
|
||||
b["owner"], b["history_hash"] = t["to"], next_history(b["history_hash"], t)
|
||||
history = next_history(b["history_hash"], t)
|
||||
if "destination" in t:
|
||||
del st["bytes"][identity]
|
||||
st["exported"][identity] = dict(b, owner=t["to"], history_hash=history,
|
||||
destination=t["destination"], exported=st["frame"])
|
||||
else:
|
||||
b["owner"], b["history_hash"] = t["to"], history
|
||||
else:
|
||||
burn, sig = parts
|
||||
b = live(st, burn["byte"])
|
||||
if burn["frame"] != st["frame"]:
|
||||
raise Refused("wrong_frame")
|
||||
if burn["previous_history_hash"] != b["history_hash"]:
|
||||
raise Refused("broken_history_chain")
|
||||
if burn["owner"] != b["owner"]:
|
||||
raise Refused("not_current_owner")
|
||||
message = encode(Map([("burn", burn["byte"]), ("previous_history_hash", burn["previous_history_hash"]),
|
||||
("owner", burn["owner"]), ("frame", burn["frame"])]))
|
||||
signed_in_force(st, burn["owner"], sig, message)
|
||||
del st["bytes"][burn["byte"]]
|
||||
st["retired"][burn["byte"]] = dict(b, retired=st["frame"])
|
||||
|
||||
|
||||
def apply_frame(st, inputs):
|
||||
"""§4: one result per distinct input, in the order they apply; the frame
|
||||
number advances."""
|
||||
enact_pending(st)
|
||||
distinct = {blake3(b): b for b in inputs}
|
||||
rank = {"key_event": 0, "mint": 1, "transfer": 2, None: 3}
|
||||
rank = {c: i for i, c in enumerate(INPUT_CLASSES)}
|
||||
rank[None] = len(INPUT_CLASSES)
|
||||
ordered = sorted(((*parse_input(b), digest) for digest, b in distinct.items()),
|
||||
key=lambda x: (rank[x[0]], x[2]))
|
||||
results = []
|
||||
|
|
|
|||
|
|
@ -99,6 +99,9 @@ and the byte that passes between them.
|
|||
- kel-002's interaction anchors env-001's identity.
|
||||
- state-001's founding frame holds kel-001 to kel-003's inceptions and
|
||||
env-001's mint, so the byte the cluster mints is env-001's.
|
||||
- state-019 and state-020 are two clusters exchanging that byte. The export
|
||||
each records is the statement the other imports, under its nodes'
|
||||
certificate.
|
||||
- Every envelope vector is checked against the key states the accepted logs
|
||||
it names establish. Both checkers enforce this: a log that fails takes the
|
||||
envelope vectors that rely on it down with it.
|
||||
|
|
@ -119,6 +122,55 @@ this document planned do not exist.
|
|||
|
||||
## Editorial change log
|
||||
|
||||
### 2026-10-01 — governance, burns, and transfers between clusters
|
||||
|
||||
`state_vectors.json` grows to 23 vectors. The cluster state machine now
|
||||
covers everything its spec had left open. Every check the new rules add has
|
||||
a vector that fails an implementation leaving it out. That includes the
|
||||
quorum and certificate thresholds, which an electorate of three and a peer of
|
||||
three nodes tell apart from a simple majority. It also includes the class
|
||||
order, wherever two classes can touch one byte in one frame.
|
||||
|
||||
**Governance** (state-013 to state-017, and state-022). Registry and peer changes are
|
||||
proposals, approved by a supermajority of the issuers entitled to vote. They
|
||||
are numbered against replay, and take effect after the cluster's time-lock
|
||||
delay (treatise §15.1, §15.5). An issuer being removed has no vote, so it
|
||||
cannot block its own removal, even as its type's only issuer, when every
|
||||
other issuer votes. A registry with no issuers can never change.
|
||||
|
||||
**Burns** (state-018). The owner retires a byte under its own signature, and
|
||||
its identity stays held, so no transfer or replayed mint revives it. Redemption is a return to the
|
||||
issuer and the issuer's burn, in one frame.
|
||||
|
||||
**Transfers between clusters** (state-019 to state-021, and state-023):
|
||||
|
||||
- **Export.** A transition may name a destination cluster. Its signature and
|
||||
history hash cover it.
|
||||
- **Certificate.** The source cluster's nodes certify the byte's state as it
|
||||
left.
|
||||
- **Import.** The destination checks the certificate against a supermajority
|
||||
of its peer's node keys. It then verifies the byte's full history itself,
|
||||
from its envelope and its controllers' logs. Each certificate is imported
|
||||
once.
|
||||
- **The vectors** run clusters A and B, with four nodes each, through a round
|
||||
trip A → B → A, and every refusal along the way. Each member of a
|
||||
certificate's export has its own mismatch case. An import meets the byte
|
||||
live, then burned, in state-023.
|
||||
|
||||
**Also changed:**
|
||||
|
||||
- **The envelope's frame rule.** Frames rise within a cluster, and the frame
|
||||
after a move to another cluster is in that cluster's own count. Requiring
|
||||
frames to rise along the whole chain would have refused every byte that
|
||||
came back to a cluster with a lower frame count.
|
||||
- **The state** gains the cluster's identifier, its peers, governance, and
|
||||
the retired, exported and imported records. Every state vector's state
|
||||
and hash change with it; v1 is DRAFT, so they are regenerated in place.
|
||||
- **Lockstep nodes** sign the certificates for a committed frame's exports,
|
||||
as the committing nodes applied the frame.
|
||||
- **state-011** names `melt` as the class there is not, now that `burn` is
|
||||
one. It adds malformed burn, governance and import inputs.
|
||||
|
||||
### 2026-10-01 — the cluster state machine, and its vectors
|
||||
|
||||
The pack gains `state_vectors.json`: 12 vectors, each a genesis state and
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
1
smart-byte/smart-byte-rs/Cargo.lock
generated
1
smart-byte/smart-byte-rs/Cargo.lock
generated
|
|
@ -6117,6 +6117,7 @@ name = "smart-byte-lockstep"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"smart-byte-core",
|
||||
"smart-byte-keri-witness",
|
||||
"smart-byte-state",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,20 +2,27 @@
|
|||
//! frame by frame.
|
||||
//!
|
||||
//! Each vector starts from a genesis state and applies frames of inputs —
|
||||
//! key events, mints and transfers built from [`World`]'s parties — giving
|
||||
//! the result of every input and the state and state hash every frame
|
||||
//! reaches. As elsewhere in the pack, every expected value is what the
|
||||
//! reference computes, and generation fails if the reference applies or
|
||||
//! drops any input other than as the vector says.
|
||||
//! key events, governance, mints, imports, transfers and burns built from
|
||||
//! [`World`]'s parties — giving the result of every input and the state and
|
||||
//! state hash every frame reaches. Vectors that move a byte between clusters
|
||||
//! run clusters A and B, four nodes each, peering with each other; state-021
|
||||
//! adds a peer D of three. As elsewhere in the pack, every expected value is
|
||||
//! what the reference computes, and generation fails if the reference
|
||||
//! applies or drops any input other than as the vector says.
|
||||
|
||||
use smart_byte_core::canonical::{self, Value};
|
||||
use smart_byte_core::keys::IndexedSignature;
|
||||
use smart_byte_core::ownership::Transition;
|
||||
use smart_byte_core::provenance::Provenance;
|
||||
use smart_byte_core::{Aid, Blake3Hash, Cargo, Envelope, JouleCost, Said, Signer, wire};
|
||||
use smart_byte_core::{
|
||||
Aid, Blake3Hash, Cargo, Envelope, JouleCost, PrefixedKey, Said, Signer, SigningKey, wire,
|
||||
};
|
||||
use smart_byte_keri_witness::VERSION_STRING;
|
||||
use smart_byte_keri_witness::events::{KeyEvent, RecoveryEvent, Threshold, finalize_event};
|
||||
use smart_byte_state::{Input, State};
|
||||
use smart_byte_state::certificate::node_key;
|
||||
use smart_byte_state::{
|
||||
Approval, Burn, Certificate, Change, ExportStatement, Input, Proposal, State,
|
||||
};
|
||||
|
||||
use crate::json::{J, to_json};
|
||||
use crate::logs::patched;
|
||||
|
|
@ -38,6 +45,37 @@ fn state_json(state: &State) -> J {
|
|||
to_json(&canonical::decode(&state.to_cbor()).expect("a state is canonical"))
|
||||
}
|
||||
|
||||
/// The identifier of cluster `label`.
|
||||
fn cluster(label: &str) -> Said {
|
||||
Said::hash(format!("smart-byte/conformance/v1 cluster {label}").as_bytes())
|
||||
}
|
||||
|
||||
/// Cluster `label`'s four node keys.
|
||||
fn nodes(label: &str) -> Vec<SigningKey> {
|
||||
(0..4)
|
||||
.map(|i| {
|
||||
SigningKey::from_bytes(
|
||||
&Said::hash(
|
||||
format!("smart-byte/conformance/v1 cluster {label} node {i}").as_bytes(),
|
||||
)
|
||||
.0,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn node_keys(label: &str) -> Vec<PrefixedKey> {
|
||||
nodes(label).iter().map(node_key).collect()
|
||||
}
|
||||
|
||||
/// `statement`, signed by cluster `label`'s nodes `signers`.
|
||||
fn certify(statement: ExportStatement, label: &str, signers: &[u64]) -> Certificate {
|
||||
let keys = nodes(label);
|
||||
let picked: Vec<(u64, &SigningKey)> =
|
||||
signers.iter().map(|i| (*i, &keys[*i as usize])).collect();
|
||||
Certificate::sign(statement, &picked)
|
||||
}
|
||||
|
||||
/// One vector, built frame by frame against the reference.
|
||||
struct Run {
|
||||
name: &'static str,
|
||||
|
|
@ -45,11 +83,21 @@ struct Run {
|
|||
state: State,
|
||||
genesis: J,
|
||||
frames: Vec<J>,
|
||||
/// What the frames so far exported, for the vectors that import it.
|
||||
exports: Vec<ExportStatement>,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
/// A vector on cluster A, with no peers and no governance delay.
|
||||
fn new(name: &'static str, description: &'static str, registry: &[(Aid, u64)]) -> Run {
|
||||
let state = State::genesis(registry.iter().copied());
|
||||
Run::from(
|
||||
name,
|
||||
description,
|
||||
State::genesis(cluster("A"), registry.iter().copied(), [], 0),
|
||||
)
|
||||
}
|
||||
|
||||
fn from(name: &'static str, description: &'static str, state: State) -> Run {
|
||||
let genesis = J::obj([
|
||||
("state", state_json(&state)),
|
||||
("cbor_hex", J::Str(hex::encode(state.to_cbor()))),
|
||||
|
|
@ -61,6 +109,7 @@ impl Run {
|
|||
state,
|
||||
genesis,
|
||||
frames: Vec::new(),
|
||||
exports: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +118,7 @@ impl Run {
|
|||
let frame = self.state.frame;
|
||||
let submitted: Vec<Vec<u8>> = inputs.iter().map(|(b, _)| b.clone()).collect();
|
||||
let report = self.state.apply_frame(&submitted);
|
||||
self.exports.extend(report.exports.iter().cloned());
|
||||
for (bytes, want) in &inputs {
|
||||
let got = report
|
||||
.results
|
||||
|
|
@ -296,7 +346,7 @@ pub fn states(world: &World) -> J {
|
|||
(h1, h2)
|
||||
};
|
||||
|
||||
J::Arr(vec![
|
||||
let mut vectors = vec![
|
||||
Run::new(
|
||||
"state-001-issue-transfer-rotate",
|
||||
"A cluster's life in five frames. Frame 0 applies the issuer's, Alice's and the custodian's inceptions and the issuer's mint of env-001's USD byte to Alice: genesis issuance is minting at frame 0. Frame 1: Alice transfers the byte to the custodian. Frame 2: Alice rotates her keys, and the custodian, two of its three keys signing, transfers the byte back. Frame 3: Alice's interaction anchors the byte's identity, and she transfers it to the custodian with the key she rotated to. Frame 4 has no inputs and only advances the frame number, which the state hash covers.",
|
||||
|
|
@ -446,7 +496,7 @@ pub fn states(world: &World) -> J {
|
|||
.json(),
|
||||
Run::new(
|
||||
"state-011-malformed-inputs",
|
||||
"Inputs dropped before they are read: bytes that are not canonical; an input naming a class there is not (`burn`); an array, not a map; a key event with a member identity spec §3.3 does not define; a mint whose cargo was changed after its identity was computed; a transfer with a member there is not. Inputs naming no class apply last.",
|
||||
"Inputs dropped before they are read: bytes that are not canonical; an input naming a class there is not (`melt`); an array, not a map; a key event with a member identity spec §3.3 does not define; a mint whose cargo was changed after its identity was computed; a transfer with a member there is not; a burn naming only its byte; a proposal whose change is an action there is not; an import with neither certificate nor envelope. Inputs naming no class apply last.",
|
||||
®istry,
|
||||
)
|
||||
.frame(vec![
|
||||
|
|
@ -454,10 +504,36 @@ pub fn states(world: &World) -> J {
|
|||
patched(&transfer(&alice0, &byte, h0, custodian.aid, 0).0, &[0x65, b'f', b'r', b'a', b'm', b'e', 0x00], &[0x65, b'f', b'r', b'a', b'm', b'e', 0x18, 0x00]),
|
||||
Dropped("non_shortest_integer"),
|
||||
),
|
||||
(
|
||||
canonical::encode(&Value::map([("melt", Value::map([("byte", Value::text(&byte.to_said_text()))]))])).unwrap(),
|
||||
Dropped("malformed_input"),
|
||||
),
|
||||
(
|
||||
canonical::encode(&Value::map([("burn", Value::map([("byte", Value::text(&byte.to_said_text()))]))])).unwrap(),
|
||||
Dropped("malformed_input"),
|
||||
),
|
||||
(
|
||||
canonical::encode(&Value::map([(
|
||||
"governance",
|
||||
Value::map([
|
||||
(
|
||||
"proposal",
|
||||
Value::map([
|
||||
("cluster", Value::text(&cluster("A").to_said_text())),
|
||||
("sequence", Value::Unsigned(0)),
|
||||
("change", Value::map([("action", Value::text("mint_anything"))])),
|
||||
]),
|
||||
),
|
||||
("approvals", Value::Array(vec![])),
|
||||
]),
|
||||
)]))
|
||||
.unwrap(),
|
||||
Dropped("malformed_input"),
|
||||
),
|
||||
(
|
||||
canonical::encode(&Value::map([("import", Value::map([("logs", Value::Array(vec![]))]))])).unwrap(),
|
||||
Dropped("malformed_input"),
|
||||
),
|
||||
(canonical::encode(&Value::Array(vec![Value::Unsigned(1)])).unwrap(), Dropped("malformed_input")),
|
||||
(
|
||||
key_event(&LoggedEvent::signed_bytes(
|
||||
|
|
@ -504,5 +580,505 @@ pub fn states(world: &World) -> J {
|
|||
(mint.clone(), Applied),
|
||||
])
|
||||
.json(),
|
||||
])
|
||||
];
|
||||
vectors.extend(lifecycle(world));
|
||||
J::Arr(vectors)
|
||||
}
|
||||
|
||||
/// A party's log, as an import carries it: each event with its signatures.
|
||||
fn import_log(party: &Party, events: usize) -> Vec<(KeyEvent, Vec<IndexedSignature>)> {
|
||||
party.log[..events]
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
e.event(),
|
||||
e.signatures
|
||||
.iter()
|
||||
.map(|s| IndexedSignature {
|
||||
index: u64::from(s.index),
|
||||
signature: s.signature.clone(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An issuer's approval of `proposal`, signed under `establishment` with
|
||||
/// `keys`.
|
||||
fn approve(
|
||||
party: &Party,
|
||||
establishment: u64,
|
||||
keys: &[(u64, Key)],
|
||||
proposal: &Proposal,
|
||||
) -> Approval {
|
||||
Approval {
|
||||
approver: party.aid,
|
||||
signature: party
|
||||
.signer(establishment, keys)
|
||||
.sign(&wire::to_vec(proposal).expect("a proposal encodes")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Governance, burns, and a byte's round trip between two clusters.
|
||||
fn lifecycle(world: &World) -> Vec<J> {
|
||||
let k = Key::named;
|
||||
let c = |n: usize| k(&format!("custodian-{n}"));
|
||||
let (issuer, alice, custodian) = (&world.issuer, &world.alice, &world.custodian);
|
||||
let usd = 1u64;
|
||||
let icp = |p: &Party| key_event(&p.log[0]);
|
||||
let mint = Input::mint(&world.issued.provenance);
|
||||
let byte = world.issued.identity();
|
||||
let issuer0 = [(0, k("issuer-0"))];
|
||||
let alice0 = [(0, k("alice-0"))];
|
||||
let custodian0 = [(0, c(0)), (2, c(2))];
|
||||
let proposal = |sequence: u64, cluster_label: &str, change: Change| Proposal {
|
||||
cluster: cluster(cluster_label),
|
||||
sequence,
|
||||
change,
|
||||
};
|
||||
let governance = |p: &Proposal, approvals: Vec<Approval>| Input::governance(p, &approvals);
|
||||
let add = |issuer: Aid, cargo_type: u64| Change::AddIssuer { cargo_type, issuer };
|
||||
let remove = |issuer: Aid, cargo_type: u64| Change::RemoveIssuer { cargo_type, issuer };
|
||||
let alice_mint = mint_of(
|
||||
alice,
|
||||
&alice0,
|
||||
custodian.aid,
|
||||
"state-013 alice",
|
||||
Cargo::usd_micros(5_000_000),
|
||||
);
|
||||
let peered = |label: &str, other: &str| {
|
||||
State::genesis(
|
||||
cluster(label),
|
||||
[(issuer.aid, usd)],
|
||||
[(cluster(other), node_keys(other))],
|
||||
0,
|
||||
)
|
||||
};
|
||||
|
||||
let mut vectors = Vec::new();
|
||||
|
||||
// ---- governance -------------------------------------------------------
|
||||
let p_add = proposal(0, "A", add(alice.aid, usd));
|
||||
vectors.push(
|
||||
Run::from(
|
||||
"state-013-issuer-added-after-the-delay",
|
||||
"Frame 0 holds the issuer's and Alice's inceptions and the issuer's approval of adding Alice as a USD issuer. Key events apply before governance, so the approval's signer is known when it is checked. The issuer is the cluster's only issuer: one elector, so the quorum is floor(2·1/3)+1 = 1. The cluster's governance delay is 2, so the change is pending until frame 0 + 1 + 2 = 3: Alice's mint is refused in frames 1 and 2 and applied in frame 3, the change having taken effect at the frame's start. The governance sequence number advances when the proposal is accepted.",
|
||||
State::genesis(cluster("A"), [(issuer.aid, usd)], [], 2),
|
||||
)
|
||||
.frame(vec![
|
||||
(icp(issuer), Applied),
|
||||
(icp(alice), Applied),
|
||||
(governance(&p_add, vec![approve(issuer, 0, &issuer0, &p_add)]), Applied),
|
||||
])
|
||||
.frame(vec![(alice_mint.clone(), Dropped("issuer_not_registered"))])
|
||||
.frame(vec![(alice_mint.clone(), Dropped("issuer_not_registered"))])
|
||||
.frame(vec![(alice_mint.clone(), Applied)])
|
||||
.json(),
|
||||
);
|
||||
|
||||
let p = |sequence: u64, cluster_label: &str, change: Change| {
|
||||
proposal(sequence, cluster_label, change)
|
||||
};
|
||||
let elsewhere = p(0, "B", add(custodian.aid, usd));
|
||||
let stale = p(7, "A", add(custodian.aid, usd));
|
||||
let registered = p(0, "A", add(alice.aid, usd));
|
||||
let unregistered = p(0, "A", remove(custodian.aid, usd));
|
||||
let custodian_add = p(0, "A", add(custodian.aid, usd));
|
||||
let both = |pr: &Proposal| {
|
||||
vec![
|
||||
approve(issuer, 0, &issuer0, pr),
|
||||
approve(alice, 0, &alice0, pr),
|
||||
]
|
||||
};
|
||||
vectors.push(
|
||||
Run::from(
|
||||
"state-014-governance-checks",
|
||||
"Proposals that fail one check each, with the issuer and Alice as the USD electorate (quorum 2): one for cluster B; one at sequence 7; adding Alice, already registered; removing the custodian, never registered; one the custodian approves, who has no vote; one approved twice by the issuer; one with the issuer's approval alone; one whose approval names the issuer but is signed with Alice's key. Every one is dropped and the governance sequence stays at 0.",
|
||||
State::genesis(cluster("A"), [(issuer.aid, usd), (alice.aid, usd)], [], 0),
|
||||
)
|
||||
.frame(vec![(icp(issuer), Applied), (icp(alice), Applied), (icp(custodian), Applied)])
|
||||
.frame(vec![
|
||||
(governance(&elsewhere, both(&elsewhere)), Dropped("wrong_cluster")),
|
||||
(governance(&stale, both(&stale)), Dropped("stale_sequence")),
|
||||
(governance(®istered, both(®istered)), Dropped("already_registered")),
|
||||
(governance(&unregistered, both(&unregistered)), Dropped("not_registered")),
|
||||
(
|
||||
governance(
|
||||
&custodian_add,
|
||||
vec![approve(issuer, 0, &issuer0, &custodian_add), approve(custodian, 0, &custodian0, &custodian_add)],
|
||||
),
|
||||
Dropped("not_an_elector"),
|
||||
),
|
||||
(
|
||||
governance(
|
||||
&custodian_add,
|
||||
vec![approve(issuer, 0, &issuer0, &custodian_add), approve(issuer, 0, &issuer0, &custodian_add)],
|
||||
),
|
||||
Dropped("duplicate_approver"),
|
||||
),
|
||||
(governance(&custodian_add, vec![approve(issuer, 0, &issuer0, &custodian_add)]), Dropped("below_quorum")),
|
||||
(
|
||||
governance(
|
||||
&custodian_add,
|
||||
vec![
|
||||
Approval {
|
||||
approver: issuer.aid,
|
||||
signature: issuer.signer(0, &alice0).sign(&wire::to_vec(&custodian_add).unwrap()),
|
||||
},
|
||||
approve(alice, 0, &alice0, &custodian_add),
|
||||
],
|
||||
),
|
||||
Dropped("signature_invalid"),
|
||||
),
|
||||
])
|
||||
.json(),
|
||||
);
|
||||
|
||||
let p_remove = p(0, "A", remove(custodian.aid, usd));
|
||||
vectors.push(
|
||||
Run::from(
|
||||
"state-015-removal-without-the-target",
|
||||
"Three USD issuers; the custodian is to be removed. An issuer being removed has no vote on its own removal, so the electorate is the other two and the quorum is 2. With the custodian's approval in place of Alice's the proposal is dropped; with the issuer's and Alice's it is accepted, and from frame 3 the custodian can no longer mint.",
|
||||
State::genesis(cluster("A"), [(issuer.aid, usd), (alice.aid, usd), (custodian.aid, usd)], [], 0),
|
||||
)
|
||||
.frame(vec![(icp(issuer), Applied), (icp(alice), Applied), (icp(custodian), Applied)])
|
||||
.frame(vec![(
|
||||
governance(&p_remove, vec![approve(issuer, 0, &issuer0, &p_remove), approve(custodian, 0, &custodian0, &p_remove)]),
|
||||
Dropped("not_an_elector"),
|
||||
)])
|
||||
.frame(vec![(governance(&p_remove, both(&p_remove)), Applied)])
|
||||
.frame(vec![(
|
||||
mint_of(custodian, &custodian0, alice.aid, "state-015 custodian", Cargo::usd_micros(1)),
|
||||
Dropped("issuer_not_registered"),
|
||||
)])
|
||||
.json(),
|
||||
);
|
||||
|
||||
let set_b = p(
|
||||
0,
|
||||
"A",
|
||||
Change::SetPeer {
|
||||
peer: cluster("B"),
|
||||
keys: node_keys("B"),
|
||||
},
|
||||
);
|
||||
let set_self = p(
|
||||
0,
|
||||
"A",
|
||||
Change::SetPeer {
|
||||
peer: cluster("A"),
|
||||
keys: node_keys("A"),
|
||||
},
|
||||
);
|
||||
let set_bad = p(
|
||||
0,
|
||||
"A",
|
||||
Change::SetPeer {
|
||||
peer: cluster("C"),
|
||||
keys: vec![PrefixedKey(vec![0x01, 1, 2, 3])],
|
||||
},
|
||||
);
|
||||
let set_none = p(
|
||||
0,
|
||||
"A",
|
||||
Change::SetPeer {
|
||||
peer: cluster("C"),
|
||||
keys: vec![],
|
||||
},
|
||||
);
|
||||
let set_twice = p(
|
||||
0,
|
||||
"A",
|
||||
Change::SetPeer {
|
||||
peer: cluster("C"),
|
||||
keys: vec![node_keys("C")[0].clone(), node_keys("C")[0].clone()],
|
||||
},
|
||||
);
|
||||
let remove_c = p(0, "A", Change::RemovePeer { peer: cluster("C") });
|
||||
let all_three = |pr: &Proposal| {
|
||||
vec![
|
||||
approve(issuer, 0, &issuer0, pr),
|
||||
approve(alice, 0, &alice0, pr),
|
||||
approve(custodian, 0, &custodian0, pr),
|
||||
]
|
||||
};
|
||||
// Two proposals accepted in one frame, the rekeying first: inputs of a
|
||||
// class apply in digest order, so the removal's approvals are put in the
|
||||
// order that sorts it after the rekeying.
|
||||
let rekey_b = p(
|
||||
1,
|
||||
"A",
|
||||
Change::SetPeer {
|
||||
peer: cluster("B"),
|
||||
keys: node_keys("B rekeyed"),
|
||||
},
|
||||
);
|
||||
let remove_b = p(2, "A", Change::RemovePeer { peer: cluster("B") });
|
||||
let rekey_in = governance(&rekey_b, all_three(&rekey_b));
|
||||
let remove_in = [
|
||||
[0, 1, 2],
|
||||
[0, 2, 1],
|
||||
[1, 0, 2],
|
||||
[1, 2, 0],
|
||||
[2, 0, 1],
|
||||
[2, 1, 0],
|
||||
]
|
||||
.iter()
|
||||
.map(|order| {
|
||||
let approvals = all_three(&remove_b);
|
||||
governance(
|
||||
&remove_b,
|
||||
order.iter().map(|i| approvals[*i].clone()).collect(),
|
||||
)
|
||||
})
|
||||
.find(|input| digest(input) > digest(&rekey_in))
|
||||
.expect("an order of the approvals sorts the removal after the rekeying");
|
||||
vectors.push(
|
||||
Run::from(
|
||||
"state-016-peers-by-governance",
|
||||
"A cluster's peers change by governance, which every issuer votes on: here the issuer and the custodian (USD) and Alice (joules), so the quorum is floor(2·3/3)+1 = 3. In frame 1 six proposals are dropped: cluster B as a peer with two approvals of the three; the cluster as its own peer; a peer whose key is not an Ed25519 key; a peer with no keys; a peer with one key twice; removing cluster C, which is not a peer. In frame 2 all three approve cluster B, which is a peer from frame 3. In frame 3 two proposals are accepted, in this order: B's node keys replaced (sequence 1), then B removed (sequence 2). Both take effect at frame 4, in the order they were accepted, so from frame 4 the cluster has no peers.",
|
||||
State::genesis(
|
||||
cluster("A"),
|
||||
[(issuer.aid, usd), (alice.aid, 2), (custodian.aid, usd)],
|
||||
[],
|
||||
0,
|
||||
),
|
||||
)
|
||||
.frame(vec![(icp(issuer), Applied), (icp(alice), Applied), (icp(custodian), Applied)])
|
||||
.frame(vec![
|
||||
(
|
||||
governance(&set_b, vec![approve(issuer, 0, &issuer0, &set_b), approve(alice, 0, &alice0, &set_b)]),
|
||||
Dropped("below_quorum"),
|
||||
),
|
||||
(governance(&set_self, all_three(&set_self)), Dropped("wrong_cluster")),
|
||||
(governance(&set_bad, all_three(&set_bad)), Dropped("malformed_verifying_key")),
|
||||
(governance(&set_none, all_three(&set_none)), Dropped("invalid_threshold")),
|
||||
(governance(&set_twice, all_three(&set_twice)), Dropped("duplicate_key")),
|
||||
(governance(&remove_c, all_three(&remove_c)), Dropped("not_a_peer")),
|
||||
])
|
||||
.frame(vec![(governance(&set_b, all_three(&set_b)), Applied)])
|
||||
.frame(vec![(rekey_in, Applied), (remove_in, Applied)])
|
||||
.frame(vec![])
|
||||
.json(),
|
||||
);
|
||||
|
||||
let p_first = p(0, "A", add(issuer.aid, usd));
|
||||
vectors.push(
|
||||
Run::from(
|
||||
"state-017-no-electorate",
|
||||
"A cluster whose genesis registry is empty. No issuer can approve anything, so no change can ever be accepted: governance needs a genesis issuer to start from.",
|
||||
State::genesis(cluster("A"), [], [], 0),
|
||||
)
|
||||
.frame(vec![(icp(issuer), Applied), (governance(&p_first, vec![]), Dropped("no_electorate"))])
|
||||
.json(),
|
||||
);
|
||||
|
||||
// ---- burns -------------------------------------------------------------
|
||||
let back = world
|
||||
.issued
|
||||
.transfer(&alice.signer(0, &alice0), issuer.aid, 2);
|
||||
let h_back = back.history_hash();
|
||||
vectors.push(
|
||||
Run::new(
|
||||
"state-018-redemption",
|
||||
"Frame 1: burns refused, one check each — of Alice's byte in her name but signed with the issuer's key; naming frame 0; from a history the byte is not at; by the issuer, who does not own it; and of a byte the cluster has never held. Frame 2 is redemption: Alice returns the byte to its issuer and the issuer burns it, in one frame — burns apply after transfers. The byte moves to `retired`. In frame 3 nothing revives it: Alice's burn, the issuer's transfer and the replayed mint are all refused.",
|
||||
&[(issuer.aid, usd)],
|
||||
)
|
||||
.frame(vec![(icp(issuer), Applied), (icp(alice), Applied), (mint.clone(), Applied)])
|
||||
.frame(vec![
|
||||
(Input::burn(&Burn::sign(&alice.signer(0, &issuer0), byte, byte.0, 1)), Dropped("signature_invalid")),
|
||||
(Input::burn(&Burn::sign(&alice.signer(0, &alice0), byte, byte.0, 0)), Dropped("wrong_frame")),
|
||||
(Input::burn(&Burn::sign(&alice.signer(0, &alice0), byte, [7; 32], 1)), Dropped("broken_history_chain")),
|
||||
(Input::burn(&Burn::sign(&issuer.signer(0, &issuer0), byte, byte.0, 1)), Dropped("not_current_owner")),
|
||||
(
|
||||
Input::burn(&Burn::sign(
|
||||
&alice.signer(0, &alice0),
|
||||
Said::hash(b"smart-byte/conformance/v1 a byte never minted"),
|
||||
[0; 32],
|
||||
1,
|
||||
)),
|
||||
Dropped("unknown_byte"),
|
||||
),
|
||||
])
|
||||
.frame(vec![
|
||||
(Input::burn(&Burn::sign(&issuer.signer(0, &issuer0), byte, h_back, 2)), Applied),
|
||||
(Input::transfer(&byte, back.ownership_chain.last().unwrap()), Applied),
|
||||
])
|
||||
.frame(vec![
|
||||
(Input::burn(&Burn::sign(&alice.signer(0, &alice0), byte, h_back, 3)), Dropped("byte_retired")),
|
||||
(transfer(&issuer.signer(0, &issuer0), &byte, h_back, alice.aid, 3).0, Dropped("byte_retired")),
|
||||
(mint.clone(), Dropped("duplicate_byte")),
|
||||
])
|
||||
.json(),
|
||||
);
|
||||
|
||||
// ---- between clusters ----------------------------------------------------
|
||||
// A: the byte is minted, refused passage to a cluster A does not peer with,
|
||||
// and exported to the custodian in B.
|
||||
let to_c = world
|
||||
.issued
|
||||
.export(&alice.signer(0, &alice0), custodian.aid, 1, cluster("C"));
|
||||
let to_b = world
|
||||
.issued
|
||||
.export(&alice.signer(0, &alice0), custodian.aid, 2, cluster("B"));
|
||||
let a = Run::from(
|
||||
"state-019-export-and-return",
|
||||
"Cluster A, peering with B. Frame 0 mints env-001's byte to Alice. Frame 1: her transfer to the custodian in cluster C is refused, as A does not peer with C. Frame 2: her transfer to the custodian in B is applied, and the byte moves to `exported`; its statement is what A's nodes certify. Frame 3: the byte can no longer be moved or minted in A. Frame 4: it comes back, B's certificate in hand (state-020), and is live in A again; imports apply before transfers, so Alice moves it within A in the same frame.",
|
||||
peered("A", "B"),
|
||||
)
|
||||
.frame(vec![(icp(issuer), Applied), (icp(alice), Applied), (mint.clone(), Applied)])
|
||||
.frame(vec![(Input::transfer(&byte, to_c.ownership_chain.last().unwrap()), Dropped("unknown_cluster"))])
|
||||
.frame(vec![(Input::transfer(&byte, to_b.ownership_chain.last().unwrap()), Applied)])
|
||||
.frame(vec![
|
||||
(transfer(&alice.signer(0, &alice0), &byte, to_b.history_hash(), issuer.aid, 3).0, Dropped("byte_exported")),
|
||||
(mint.clone(), Dropped("duplicate_byte")),
|
||||
]);
|
||||
let from_a = certify(a.exports[0].clone(), "A", &[0, 1, 2]);
|
||||
let a_logs = vec![import_log(issuer, 1), import_log(alice, 1)];
|
||||
|
||||
// B: imports it, refuses the same certificate twice, and sends it back.
|
||||
let home = to_b.export(
|
||||
&custodian.signer(0, &custodian0),
|
||||
alice.aid,
|
||||
1,
|
||||
cluster("A"),
|
||||
);
|
||||
let b = Run::from(
|
||||
"state-020-import-and-send-back",
|
||||
"Cluster B, peering with A. Frame 0 applies the custodian's inception and the import of A's certificate — signed by three of A's four nodes — with the byte's envelope and the issuer's and Alice's logs, which verify its history. The byte is live in B, owned by the custodian. Frame 1: the same certificate again is refused, and the custodian, two of its keys signing, sends the byte back to Alice in A.",
|
||||
peered("B", "A"),
|
||||
)
|
||||
.frame(vec![(icp(custodian), Applied), (Input::import(&from_a, &to_b, &a_logs), Applied)])
|
||||
.frame(vec![
|
||||
(Input::import(&from_a, &to_b, &a_logs), Dropped("duplicate_import")),
|
||||
(Input::transfer(&byte, home.ownership_chain.last().unwrap()), Applied),
|
||||
]);
|
||||
let from_b = certify(b.exports[0].clone(), "B", &[1, 2, 3]);
|
||||
let b_logs = vec![
|
||||
import_log(issuer, 1),
|
||||
import_log(alice, 1),
|
||||
import_log(custodian, 1),
|
||||
];
|
||||
vectors.push(b.json());
|
||||
|
||||
let a = a.frame(vec![
|
||||
(Input::import(&from_b, &home, &b_logs), Applied),
|
||||
(
|
||||
transfer(
|
||||
&alice.signer(0, &alice0),
|
||||
&byte,
|
||||
home.history_hash(),
|
||||
issuer.aid,
|
||||
4,
|
||||
)
|
||||
.0,
|
||||
Applied,
|
||||
),
|
||||
]);
|
||||
vectors.insert(vectors.len() - 1, a.json());
|
||||
|
||||
let mut elsewhere = a_statement_for(&from_a, |s| s.export.destination = cluster("C"));
|
||||
elsewhere = certify(elsewhere.statement, "A", &[0, 1, 2]);
|
||||
let mut unknown = a_statement_for(&from_a, |s| s.source = cluster("C"));
|
||||
unknown = certify(unknown.statement, "A", &[0, 1, 2]);
|
||||
// A's certificate with one member of the export changed, signed again by
|
||||
// A's nodes: the envelope no longer bears it out.
|
||||
let restated = |edit: &dyn Fn(&mut ExportStatement)| {
|
||||
Input::import(
|
||||
&certify(a_statement_for(&from_a, edit).statement, "A", &[0, 1, 2]),
|
||||
&to_b,
|
||||
&a_logs,
|
||||
)
|
||||
};
|
||||
let from_d = certify(
|
||||
a_statement_for(&from_a, |s| s.source = cluster("D")).statement,
|
||||
"D",
|
||||
&[0, 1],
|
||||
);
|
||||
vectors.push(
|
||||
Run::from(
|
||||
"state-021-import-checks",
|
||||
"Imports into cluster B, which peers with A (four nodes) and D (three), that fail one check each: a certificate signed by two of A's four nodes, where floor(2·4/3)+1 = 3 are needed; one signed by two of D's three nodes, where all three are needed; one for a byte A sent to cluster C; one from cluster C, which B does not peer with; A's certificate with the envelope as it was before it left; certificates that differ from the envelope in one member each — the byte, its history hash, its owner, its issuer, its cargo; and A's certificate without Alice's log, so her signature cannot be checked.",
|
||||
State::genesis(
|
||||
cluster("B"),
|
||||
[(issuer.aid, usd)],
|
||||
[
|
||||
(cluster("A"), node_keys("A")),
|
||||
(cluster("D"), node_keys("D")[..3].to_vec()),
|
||||
],
|
||||
0,
|
||||
),
|
||||
)
|
||||
.frame(vec![(icp(custodian), Applied)])
|
||||
.frame(vec![
|
||||
(Input::import(&certify(from_a.statement.clone(), "A", &[0, 2]), &to_b, &a_logs), Dropped("below_threshold")),
|
||||
(Input::import(&from_d, &to_b, &a_logs), Dropped("below_threshold")),
|
||||
(Input::import(&elsewhere, &to_b, &a_logs), Dropped("wrong_cluster")),
|
||||
(Input::import(&unknown, &to_b, &a_logs), Dropped("unknown_cluster")),
|
||||
(Input::import(&from_a, &world.issued, &a_logs), Dropped("certificate_mismatch")),
|
||||
(
|
||||
restated(&|s| s.byte = Said::hash(b"smart-byte/conformance/v1 a byte never minted")),
|
||||
Dropped("certificate_mismatch"),
|
||||
),
|
||||
(restated(&|s| s.export.history_hash = byte.0), Dropped("certificate_mismatch")),
|
||||
(restated(&|s| s.export.owner = alice.aid), Dropped("certificate_mismatch")),
|
||||
(restated(&|s| s.export.issuer = alice.aid), Dropped("certificate_mismatch")),
|
||||
(
|
||||
restated(&|s| s.export.cargo = Cargo::usd_micros(1_000_000_000_000)),
|
||||
Dropped("certificate_mismatch"),
|
||||
),
|
||||
(Input::import(&from_a, &to_b, &a_logs[..1]), Dropped("unknown_key_state")),
|
||||
])
|
||||
.json(),
|
||||
);
|
||||
|
||||
let import_a = Input::import(&from_a, &to_b, &a_logs);
|
||||
let meets = Run::from(
|
||||
"state-023-an-import-meets-the-byte",
|
||||
"Cluster B, peering with A, where the issuer is registered as it is in A. Frame 0 mints env-001's byte in B, and the import of A's certificate for that byte (state-020) is refused with `duplicate_byte`: mints apply before imports. Frame 1: Alice burns the byte, and the same import is refused with `duplicate_byte` again, imports applying before burns. Frame 2: the same import is refused with `byte_retired`.",
|
||||
peered("B", "A"),
|
||||
)
|
||||
.frame(vec![
|
||||
(icp(issuer), Applied),
|
||||
(icp(alice), Applied),
|
||||
(mint.clone(), Applied),
|
||||
(import_a.clone(), Dropped("duplicate_byte")),
|
||||
])
|
||||
.frame(vec![
|
||||
(Input::burn(&Burn::sign(&alice.signer(0, &alice0), byte, byte.0, 1)), Applied),
|
||||
(import_a.clone(), Dropped("duplicate_byte")),
|
||||
])
|
||||
.frame(vec![(import_a, Dropped("byte_retired"))])
|
||||
.json();
|
||||
let p_alone = p(0, "A", remove(alice.aid, 2));
|
||||
vectors.push(
|
||||
Run::from(
|
||||
"state-022-removing-a-type-s-only-issuer",
|
||||
"Alice is the only issuer of joules (cargo type 2). With her excluded from the vote on her own removal, the type has no electorate of its own, so every other issuer votes: here the issuer alone, quorum 1. Her own approval is refused; the issuer's is accepted, and from frame 3 Alice, left with no cargo type, is gone from the registry.",
|
||||
State::genesis(cluster("A"), [(issuer.aid, usd), (alice.aid, 2)], [], 0),
|
||||
)
|
||||
.frame(vec![(icp(issuer), Applied), (icp(alice), Applied)])
|
||||
.frame(vec![(governance(&p_alone, vec![approve(alice, 0, &alice0, &p_alone)]), Dropped("not_an_elector"))])
|
||||
.frame(vec![(governance(&p_alone, vec![approve(issuer, 0, &issuer0, &p_alone)]), Applied)])
|
||||
.frame(vec![])
|
||||
.json(),
|
||||
);
|
||||
vectors.push(meets);
|
||||
|
||||
vectors
|
||||
}
|
||||
|
||||
/// A certificate whose statement is `certificate`'s with `edit` applied,
|
||||
/// unsigned.
|
||||
fn a_statement_for(
|
||||
certificate: &Certificate,
|
||||
edit: impl FnOnce(&mut ExportStatement),
|
||||
) -> Certificate {
|
||||
let mut statement = certificate.statement.clone();
|
||||
edit(&mut statement);
|
||||
Certificate {
|
||||
statement,
|
||||
signatures: vec![],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -283,6 +283,67 @@ fn the_verifier_catches_a_planted_defect_in_every_file() {
|
|||
inputs.retain(|i| i["input"].get("key_event").is_none());
|
||||
}),
|
||||
),
|
||||
(
|
||||
"state_vectors.json",
|
||||
"state-013-issuer-added-after-the-delay",
|
||||
Box::new(|v| v["frames"][2]["expected_results"][0]["error"] = json!("below_quorum")),
|
||||
),
|
||||
(
|
||||
"state_vectors.json",
|
||||
"state-015-removal-without-the-target",
|
||||
Box::new(|v| v["frames"][1]["expected_results"][0]["error"] = json!("below_quorum")),
|
||||
),
|
||||
(
|
||||
"state_vectors.json",
|
||||
"state-018-redemption",
|
||||
Box::new(|v| {
|
||||
let results = v["frames"][3]["expected_results"].as_array_mut().unwrap();
|
||||
let r = results
|
||||
.iter_mut()
|
||||
.find(|r| r["error"] == "duplicate_byte")
|
||||
.unwrap();
|
||||
r["error"] = json!("byte_retired");
|
||||
}),
|
||||
),
|
||||
(
|
||||
"state_vectors.json",
|
||||
"state-019-export-and-return",
|
||||
Box::new(move |v| {
|
||||
let h = v["frames"][2]["expected_state_hash"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
v["frames"][2]["expected_state_hash"] = json!(flip_last(&h));
|
||||
}),
|
||||
),
|
||||
(
|
||||
"state_vectors.json",
|
||||
"state-020-import-and-send-back",
|
||||
Box::new(|v| {
|
||||
let results = v["frames"][0]["expected_results"].as_array_mut().unwrap();
|
||||
let r = results.iter_mut().find(|r| r["class"] == "import").unwrap();
|
||||
r["outcome"] = json!("dropped");
|
||||
r["error"] = json!("duplicate_import");
|
||||
}),
|
||||
),
|
||||
(
|
||||
"state_vectors.json",
|
||||
"state-021-import-checks",
|
||||
Box::new(|v| {
|
||||
let results = v["frames"][1]["expected_results"].as_array_mut().unwrap();
|
||||
let r = results
|
||||
.iter_mut()
|
||||
.find(|r| r["error"] == "below_threshold")
|
||||
.unwrap();
|
||||
r["error"] = json!("signature_invalid");
|
||||
}),
|
||||
),
|
||||
(
|
||||
"state_vectors.json",
|
||||
"state-023-an-import-meets-the-byte",
|
||||
// A burned byte's import reported as if the byte were still live.
|
||||
Box::new(|v| v["frames"][2]["expected_results"][0]["error"] = json!("duplicate_byte")),
|
||||
),
|
||||
(
|
||||
"lockstep_vectors.json",
|
||||
"lockstep-002-supermajority-commit",
|
||||
|
|
@ -602,6 +663,27 @@ fn the_files_describe_one_world() {
|
|||
.any(|i| i["mint"] == issued["envelope"]["provenance"]),
|
||||
"env-001's mint"
|
||||
);
|
||||
// A's export is what B imports, and B's export is what A takes back.
|
||||
let a = by_name("state_vectors.json", "state-019-export-and-return");
|
||||
let b = by_name("state_vectors.json", "state-020-import-and-send-back");
|
||||
let id = issued["expected"]["identity"].as_str().unwrap();
|
||||
let imported = |frame: &Value| {
|
||||
frame["inputs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find_map(|i| i["input"].get("import").cloned())
|
||||
.unwrap()["certificate"]["statement"]["export"]
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
imported(&b["frames"][0]),
|
||||
a["frames"][2]["expected_state"]["exported"][id]
|
||||
);
|
||||
assert_eq!(
|
||||
imported(&a["frames"][4]),
|
||||
b["frames"][1]["expected_state"]["exported"][id]
|
||||
);
|
||||
// The byte the cluster holds is env-001's.
|
||||
let state = &journey["frames"][0]["expected_state"];
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -123,6 +123,21 @@ impl Envelope {
|
|||
Self::stamped(self.provenance.clone(), chain, self.joule_cost)
|
||||
}
|
||||
|
||||
/// Move the byte out of its cluster, to `to` in the cluster
|
||||
/// `destination` (treatise §11.3.3).
|
||||
pub fn export(&self, owner: &Signer, to: Aid, frame: u64, destination: Said) -> Self {
|
||||
let mut chain = self.ownership_chain.clone();
|
||||
chain.push(Transition::sign_export(
|
||||
owner,
|
||||
&self.identity(),
|
||||
self.history_hash(),
|
||||
to,
|
||||
frame,
|
||||
destination,
|
||||
));
|
||||
Self::stamped(self.provenance.clone(), chain, self.joule_cost)
|
||||
}
|
||||
|
||||
/// The same byte with its joule cost replaced: a new snapshot.
|
||||
pub fn with_joule_cost(&self, joule_cost: JouleCost) -> Self {
|
||||
Self::stamped(
|
||||
|
|
@ -240,7 +255,10 @@ impl Envelope {
|
|||
if t.from != owner {
|
||||
return Err(EnvelopeError::NotOwner { transition: k });
|
||||
}
|
||||
// One transfer per byte per frame (treatise §8.4), so frames rise.
|
||||
// One transfer per byte per frame (treatise §8.4), so frames rise
|
||||
// within a cluster. Each cluster counts its own frames, so after a
|
||||
// transition that moves the byte to another cluster the next one
|
||||
// is not compared with it.
|
||||
if last_frame.is_some_and(|f| t.frame <= f) {
|
||||
return Err(EnvelopeError::FrameOrder { transition: k });
|
||||
}
|
||||
|
|
@ -251,7 +269,7 @@ impl Envelope {
|
|||
})?;
|
||||
history = t.next_history_hash();
|
||||
owner = t.to;
|
||||
last_frame = Some(t.frame);
|
||||
last_frame = t.destination.is_none().then_some(t.frame);
|
||||
}
|
||||
Ok(Verified {
|
||||
identity,
|
||||
|
|
@ -342,6 +360,26 @@ mod tests {
|
|||
assert_eq!(Envelope::from_cbor(&stale.to_cbor()).unwrap_err().class(), "said_mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_rise_within_a_cluster_and_restart_in_the_next() {
|
||||
let (issuer, alice, bob) = (party(1), party(2), party(3));
|
||||
let keys = book(&[&issuer, &alice, &bob]);
|
||||
let byte = Envelope::issue(&issuer, alice.aid, [7; 16], Cargo::usd_micros(1), JouleCost::default());
|
||||
// Out at frame 40 of one cluster, on at frame 3 of the next.
|
||||
let moved = byte
|
||||
.export(&alice, bob.aid, 40, Said::hash(b"cluster B"))
|
||||
.transfer(&bob, alice.aid, 3);
|
||||
assert_eq!(moved.verify(&keys).unwrap().owner, alice.aid);
|
||||
// Without the move, the same frames go backwards.
|
||||
let backwards = byte.transfer(&alice, bob.aid, 40).transfer(&bob, alice.aid, 3);
|
||||
assert_eq!(backwards.verify(&keys).unwrap_err().class(), "frame_not_increasing");
|
||||
// The destination is signed: changing it breaks the signature.
|
||||
let mut redirected = byte.export(&alice, bob.aid, 40, Said::hash(b"cluster B"));
|
||||
redirected.ownership_chain[0].destination = Some(Said::hash(b"cluster C"));
|
||||
let redirected = Envelope::stamped(redirected.provenance, redirected.ownership_chain, redirected.joule_cost);
|
||||
assert_eq!(redirected.verify(&keys).unwrap_err().class(), "signature_invalid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_the_envelope_does_not_define_is_refused() {
|
||||
let byte = Envelope::issue(&party(1), party(2).aid, [0; 16], Cargo::opaque(vec![]), JouleCost::default());
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@
|
|||
//! the byte's identity and advances one step per transition:
|
||||
//! `next = BLAKE3(previous ‖ payload)`, the payload being the transition's
|
||||
//! from, to, frame and signature.
|
||||
//!
|
||||
//! A transition that moves the byte to another cluster (treatise §11.3.3)
|
||||
//! also names its `destination`, and the signature and the history hash
|
||||
//! cover it. A transition within a cluster has no `destination` member.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -27,6 +31,10 @@ pub struct Transition {
|
|||
pub to: Aid,
|
||||
/// The lockstep frame the transition is applied in.
|
||||
pub frame: u64,
|
||||
/// The cluster the byte moves to, for a transition that leaves the
|
||||
/// cluster it is applied in (treatise §11.3.3). Absent otherwise.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub destination: Option<Said>,
|
||||
/// `from`'s signature over [`Transition::signed_bytes`].
|
||||
pub signature: ControllerSignature,
|
||||
}
|
||||
|
|
@ -39,6 +47,8 @@ struct Signed<'a> {
|
|||
from: &'a Aid,
|
||||
to: &'a Aid,
|
||||
frame: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
destination: Option<&'a Said>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -46,6 +56,8 @@ struct Payload<'a> {
|
|||
from: &'a Aid,
|
||||
to: &'a Aid,
|
||||
frame: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
destination: Option<&'a Said>,
|
||||
signature: &'a ControllerSignature,
|
||||
}
|
||||
|
||||
|
|
@ -58,12 +70,44 @@ impl Transition {
|
|||
previous_history_hash: Blake3Hash,
|
||||
to: Aid,
|
||||
frame: u64,
|
||||
) -> Self {
|
||||
Self::signed(owner, byte, previous_history_hash, to, frame, None)
|
||||
}
|
||||
|
||||
/// A transition that moves the byte to `destination`, another cluster,
|
||||
/// where `to` becomes its owner (treatise §11.3.3).
|
||||
pub fn sign_export(
|
||||
owner: &Signer,
|
||||
byte: &Said,
|
||||
previous_history_hash: Blake3Hash,
|
||||
to: Aid,
|
||||
frame: u64,
|
||||
destination: Said,
|
||||
) -> Self {
|
||||
Self::signed(
|
||||
owner,
|
||||
byte,
|
||||
previous_history_hash,
|
||||
to,
|
||||
frame,
|
||||
Some(destination),
|
||||
)
|
||||
}
|
||||
|
||||
fn signed(
|
||||
owner: &Signer,
|
||||
byte: &Said,
|
||||
previous_history_hash: Blake3Hash,
|
||||
to: Aid,
|
||||
frame: u64,
|
||||
destination: Option<Said>,
|
||||
) -> Self {
|
||||
let mut t = Transition {
|
||||
previous_history_hash,
|
||||
from: owner.aid,
|
||||
to,
|
||||
frame,
|
||||
destination,
|
||||
signature: ControllerSignature {
|
||||
establishment: 0,
|
||||
signatures: vec![],
|
||||
|
|
@ -74,7 +118,8 @@ impl Transition {
|
|||
}
|
||||
|
||||
/// The message `from` signs: the canonical encoding of (byte identity,
|
||||
/// previous history hash, from, to, frame) (treatise §8.4).
|
||||
/// previous history hash, from, to, frame) — and the destination, for a
|
||||
/// transition that leaves the cluster (treatise §8.4, §11.3.3).
|
||||
pub fn signed_bytes(&self, byte: &Said) -> Vec<u8> {
|
||||
crate::wire::to_vec(&Signed {
|
||||
byte,
|
||||
|
|
@ -82,6 +127,7 @@ impl Transition {
|
|||
from: &self.from,
|
||||
to: &self.to,
|
||||
frame: self.frame,
|
||||
destination: self.destination.as_ref(),
|
||||
})
|
||||
.expect("a transition's signed fields always encode")
|
||||
}
|
||||
|
|
@ -92,6 +138,7 @@ impl Transition {
|
|||
from: &self.from,
|
||||
to: &self.to,
|
||||
frame: self.frame,
|
||||
destination: self.destination.as_ref(),
|
||||
signature: &self.signature,
|
||||
})
|
||||
.expect("a transition's payload always encodes");
|
||||
|
|
|
|||
|
|
@ -60,6 +60,15 @@ pub fn verify(
|
|||
.map_err(|_| SignatureError::SignatureInvalid)
|
||||
}
|
||||
|
||||
/// Whether `key` is an Ed25519 public key that [`verify`] can accept
|
||||
/// signatures under: 32 bytes, `y` below the field prime, on the curve, and
|
||||
/// not of small order.
|
||||
pub fn is_valid_key(key: &[u8]) -> bool {
|
||||
<[u8; 32]>::try_from(key).is_ok_and(|k| {
|
||||
encodes_y_below_p(&k) && VerifyingKey::from_bytes(&k).is_ok_and(|vk| !vk.is_weak())
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a compressed Edwards point's `y` (its low 255 bits) is below the
|
||||
/// field prime `p = 2^255 - 19`. Decoders that skip this accept nineteen
|
||||
/// out-of-range spellings, some of which decompress to valid points.
|
||||
|
|
|
|||
|
|
@ -11,3 +11,6 @@ description = "Smart Byte deterministic lockstep BFT: the supermajority commit r
|
|||
smart-byte-core = { workspace = true }
|
||||
smart-byte-state = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
smart-byte-keri-witness = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@
|
|||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use smart_byte_core::{Blake3Hash, Said};
|
||||
use smart_byte_state::FrameReport;
|
||||
use smart_byte_core::{PrefixedKey, SigningKey};
|
||||
pub use smart_byte_state::State;
|
||||
use smart_byte_state::certificate::node_key;
|
||||
use smart_byte_state::{Certificate, FrameReport};
|
||||
|
||||
/// Errors during a frame step.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
@ -217,6 +219,8 @@ impl<P: Ord + Clone> Tally<P> {
|
|||
pub struct Node {
|
||||
/// Stable identity.
|
||||
pub id: Said,
|
||||
/// The key the node signs export certificates with (treatise §11.3.3).
|
||||
pub key: SigningKey,
|
||||
/// The cluster state, as this node holds it.
|
||||
pub state: State,
|
||||
/// If true, this node broadcasts a hash with its low bit flipped while
|
||||
|
|
@ -226,9 +230,10 @@ pub struct Node {
|
|||
|
||||
impl Node {
|
||||
/// A node holding the cluster's genesis state.
|
||||
pub fn new(id: Said, genesis: &State) -> Self {
|
||||
pub fn new(id: Said, key: SigningKey, genesis: &State) -> Self {
|
||||
Self {
|
||||
id,
|
||||
key,
|
||||
state: genesis.clone(),
|
||||
faulty: false,
|
||||
}
|
||||
|
|
@ -263,6 +268,9 @@ pub struct FrameCommit {
|
|||
/// Distinct inputs the frame applied, and dropped.
|
||||
pub applied: usize,
|
||||
pub dropped: usize,
|
||||
/// For each byte the frame exported, the certificate its committing
|
||||
/// nodes sign for the destination (treatise §11.3.3 step 2).
|
||||
pub certificates: Vec<Certificate>,
|
||||
}
|
||||
|
||||
/// A cluster of [`Node`]s running deterministic lockstep in one process.
|
||||
|
|
@ -275,10 +283,17 @@ pub struct Cluster {
|
|||
}
|
||||
|
||||
impl Cluster {
|
||||
/// A cluster of `n` honest nodes, each holding `genesis`.
|
||||
/// A cluster of `n` honest nodes, each holding `genesis`. Node keys
|
||||
/// derive from the cluster's identifier, so two clusters' nodes never
|
||||
/// share one.
|
||||
pub fn new_honest(n: usize, genesis: &State) -> Self {
|
||||
let cluster = genesis.cluster.to_said_text();
|
||||
let nodes = (0..n)
|
||||
.map(|i| Node::new(Said::hash(format!("node-{i}").as_bytes()), genesis))
|
||||
.map(|i| {
|
||||
let key =
|
||||
SigningKey::from_bytes(&Said::hash(format!("{cluster} node {i}").as_bytes()).0);
|
||||
Node::new(Said::hash(format!("node-{i}").as_bytes()), key, genesis)
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
nodes,
|
||||
|
|
@ -297,28 +312,30 @@ impl Cluster {
|
|||
c
|
||||
}
|
||||
|
||||
/// The keys of this cluster's nodes, in order: what a peer lists for it.
|
||||
pub fn node_keys(&self) -> Vec<PrefixedKey> {
|
||||
self.nodes.iter().map(|n| node_key(&n.key)).collect()
|
||||
}
|
||||
|
||||
/// Run one lockstep frame: every node applies `inputs`, the reported
|
||||
/// hashes are tallied, and the frame commits iff one hash has the
|
||||
/// supermajority.
|
||||
/// supermajority. The committing nodes then certify the frame's exports.
|
||||
pub fn step(&mut self, inputs: Vec<Vec<u8>>) -> Result<FrameCommit, LockstepError> {
|
||||
if self.nodes.is_empty() {
|
||||
return Err(LockstepError::EmptyCluster);
|
||||
}
|
||||
let frame = self.frame;
|
||||
let total = self.nodes.len();
|
||||
// Every node applies the same inputs to the same state, so every
|
||||
// node's frame report is the same; keep the first.
|
||||
let mut report = None;
|
||||
let reports: Vec<(Said, Blake3Hash)> = self
|
||||
let stepped: Vec<(Said, Blake3Hash, FrameReport)> = self
|
||||
.nodes
|
||||
.iter_mut()
|
||||
.map(|node| {
|
||||
let (hash, r) = node.step(&inputs);
|
||||
report.get_or_insert(r);
|
||||
(node.id, hash)
|
||||
let (hash, report) = node.step(&inputs);
|
||||
(node.id, hash, report)
|
||||
})
|
||||
.collect();
|
||||
let report = report.expect("a non-empty cluster");
|
||||
let reports: Vec<(Said, Blake3Hash)> =
|
||||
stepped.iter().map(|(id, hash, _)| (*id, *hash)).collect();
|
||||
let result = tally(total, &reports).expect("one report per node in a non-empty cluster");
|
||||
let Some(state_hash) = result.supermajority_hash else {
|
||||
return Err(LockstepError::NoSupermajority {
|
||||
|
|
@ -327,11 +344,29 @@ impl Cluster {
|
|||
total,
|
||||
});
|
||||
};
|
||||
let committed_by = reports
|
||||
// What the frame did, as a node that committed it applied it: a node
|
||||
// whose state diverged reports what its own state did.
|
||||
let (_, _, report) = stepped
|
||||
.iter()
|
||||
.find(|(_, hash, _)| *hash == state_hash)
|
||||
.expect("the supermajority hash is some node's");
|
||||
let committed_by: HashSet<Said> = reports
|
||||
.iter()
|
||||
.filter(|(_, hash)| *hash == state_hash)
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
let signers: Vec<(u64, &SigningKey)> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, n)| committed_by.contains(&n.id))
|
||||
.map(|(i, n)| (i as u64, &n.key))
|
||||
.collect();
|
||||
let certificates = report
|
||||
.exports
|
||||
.iter()
|
||||
.map(|statement| Certificate::sign(statement.clone(), &signers))
|
||||
.collect();
|
||||
if let Some(entry) = result.dissent_entry(frame, &state_hash) {
|
||||
self.dissent_log.push(entry);
|
||||
}
|
||||
|
|
@ -343,6 +378,7 @@ impl Cluster {
|
|||
dissenters: result.dissenters,
|
||||
applied: report.applied(),
|
||||
dropped: report.results.len() - report.applied(),
|
||||
certificates,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -541,6 +577,99 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_clusters_move_a_byte_by_certificate() {
|
||||
use smart_byte_core::keys::IndexedSignature;
|
||||
use smart_byte_core::{Cargo, Envelope, JouleCost};
|
||||
use smart_byte_keri_witness::{Controller, KeyEvent, KeyPair, Threshold};
|
||||
use smart_byte_state::Input;
|
||||
|
||||
let party = |label: &str| {
|
||||
let key = |n: u8| {
|
||||
KeyPair::from_signing(SigningKey::from_bytes(
|
||||
&Said::hash(format!("{label}-{n}").as_bytes()).0,
|
||||
))
|
||||
};
|
||||
let (c, icp) = Controller::incept(
|
||||
vec![key(0)],
|
||||
vec![key(1)],
|
||||
Threshold(1),
|
||||
Threshold(1),
|
||||
vec![],
|
||||
Threshold(0),
|
||||
)
|
||||
.unwrap();
|
||||
let event = KeyEvent::Inception(icp);
|
||||
let sigs: Vec<IndexedSignature> = c
|
||||
.sign_event(&event)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(index, signature)| IndexedSignature {
|
||||
index: u64::from(index),
|
||||
signature,
|
||||
})
|
||||
.collect();
|
||||
(c, vec![(event, sigs)])
|
||||
};
|
||||
let ((issuer, issuer_log), (alice, alice_log), (bob, bob_log)) =
|
||||
(party("issuer"), party("alice"), party("bob"));
|
||||
let (a_id, b_id) = (Said::hash(b"cluster A"), Said::hash(b"cluster B"));
|
||||
// Each cluster's nodes, then each genesis naming the other's.
|
||||
let a_keys = Cluster::new_honest(4, &State::genesis(a_id, [], [], 0)).node_keys();
|
||||
let b_keys = Cluster::new_honest(4, &State::genesis(b_id, [], [], 0)).node_keys();
|
||||
let mut a = Cluster::new_honest(
|
||||
4,
|
||||
&State::genesis(a_id, [(issuer.aid, 1)], [(b_id, b_keys)], 0),
|
||||
);
|
||||
// A's first node holds no registry, so it never mints the byte and
|
||||
// never exports it: the certificate is what the other three did.
|
||||
a.nodes[0].state.registry.clear();
|
||||
let mut b = Cluster::new_with_faulty(4, 1, &State::genesis(b_id, [], [(a_id, a_keys)], 0));
|
||||
|
||||
let byte = Envelope::issue(
|
||||
&issuer.signer(),
|
||||
alice.aid,
|
||||
[1; 16],
|
||||
Cargo::usd_micros(5),
|
||||
JouleCost::default(),
|
||||
);
|
||||
let incept =
|
||||
|log: &Vec<(KeyEvent, Vec<IndexedSignature>)>| Input::key_event(&log[0].0, &log[0].1);
|
||||
a.step(vec![
|
||||
incept(&issuer_log),
|
||||
incept(&alice_log),
|
||||
Input::mint(&byte.provenance),
|
||||
])
|
||||
.unwrap();
|
||||
b.step(vec![incept(&bob_log)]).unwrap();
|
||||
|
||||
let out = byte.export(&alice.signer(), bob.aid, 1, b_id);
|
||||
let commit = a
|
||||
.step(vec![Input::transfer(
|
||||
&byte.identity(),
|
||||
out.ownership_chain.last().unwrap(),
|
||||
)])
|
||||
.unwrap();
|
||||
assert_eq!(commit.dissenters, vec![a.nodes[0].id]);
|
||||
assert_eq!(commit.certificates.len(), 1);
|
||||
let signers: Vec<u64> = commit.certificates[0]
|
||||
.signatures
|
||||
.iter()
|
||||
.map(|s| s.index)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
signers,
|
||||
[1, 2, 3],
|
||||
"the committing nodes sign, and only they"
|
||||
);
|
||||
|
||||
let import = Input::import(&commit.certificates[0], &out, &[issuer_log, alice_log]);
|
||||
let commit = b.step(vec![import]).unwrap();
|
||||
assert_eq!((commit.applied, commit.dropped), (1, 0));
|
||||
assert_eq!(b.nodes[0].state.bytes[&byte.identity()].owner, bob.aid);
|
||||
assert!(a.nodes[1].state.exported.contains_key(&byte.identity()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_node_whose_state_diverged_is_outvoted() {
|
||||
// A node holding a different genesis computes different hashes from
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
//! Export certificates (cluster state spec §5; treatise §11.3.3 step 2).
|
||||
//!
|
||||
//! When a byte leaves a cluster, the cluster's nodes certify the state it
|
||||
//! left in: a supermajority of them sign the canonical encoding of
|
||||
//!
|
||||
//! ```text
|
||||
//! {"source": cluster, "byte": identity, "export": export record}
|
||||
//! ```
|
||||
//!
|
||||
//! The destination checks the signatures against the source's node keys (a
|
||||
//! peer in its state), and the byte's full history against the certified
|
||||
//! history hash. A certificate is identified by the BLAKE3 digest of its
|
||||
//! statement, and a cluster imports each one once.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smart_byte_core::keys::IndexedSignature;
|
||||
use smart_byte_core::sign::Signer as _;
|
||||
use smart_byte_core::{Blake3Hash, PrefixedKey, Said, SigningKey, wire};
|
||||
|
||||
use crate::state::ExportRecord;
|
||||
|
||||
/// What the source cluster's nodes certify.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExportStatement {
|
||||
/// The cluster the byte left.
|
||||
pub source: Said,
|
||||
/// The byte's identity.
|
||||
pub byte: Said,
|
||||
/// The byte as it left.
|
||||
pub export: ExportRecord,
|
||||
}
|
||||
|
||||
/// A statement and the signatures of the source's nodes over it, each by a
|
||||
/// node's index in the source's key list.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Certificate {
|
||||
pub statement: ExportStatement,
|
||||
pub signatures: Vec<IndexedSignature>,
|
||||
}
|
||||
|
||||
impl ExportStatement {
|
||||
/// The canonical encoding: what the nodes sign.
|
||||
pub fn to_cbor(&self) -> Vec<u8> {
|
||||
wire::to_vec(self).expect("a statement always encodes")
|
||||
}
|
||||
|
||||
/// The certificate's identity: BLAKE3-256 of the statement.
|
||||
pub fn digest(&self) -> Blake3Hash {
|
||||
*blake3::hash(&self.to_cbor()).as_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
impl Certificate {
|
||||
/// `statement`, signed by the nodes `signers` as `(index, key)`.
|
||||
pub fn sign(statement: ExportStatement, signers: &[(u64, &SigningKey)]) -> Self {
|
||||
let message = statement.to_cbor();
|
||||
let signatures = signers
|
||||
.iter()
|
||||
.map(|(index, key)| IndexedSignature {
|
||||
index: *index,
|
||||
signature: [
|
||||
&[smart_byte_core::keys::ED25519][..],
|
||||
&key.sign(&message).to_bytes(),
|
||||
]
|
||||
.concat(),
|
||||
})
|
||||
.collect();
|
||||
Certificate {
|
||||
statement,
|
||||
signatures,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A node's key as a peer entry lists it.
|
||||
pub fn node_key(key: &SigningKey) -> PrefixedKey {
|
||||
PrefixedKey::ed25519(&key.verifying_key())
|
||||
}
|
||||
|
|
@ -17,6 +17,11 @@ use crate::state::State;
|
|||
/// The cargo type the demo issuer is registered for: USD.
|
||||
pub const USD: u64 = 1;
|
||||
|
||||
/// The demo cluster's identifier.
|
||||
pub fn cluster() -> Said {
|
||||
Said::hash(b"smart-byte demo cluster")
|
||||
}
|
||||
|
||||
fn key(label: &str) -> KeyPair {
|
||||
let seed = Said::hash(format!("smart-byte demo key {label}").as_bytes()).0;
|
||||
KeyPair::from_signing(SigningKey::from_bytes(&seed))
|
||||
|
|
@ -120,9 +125,10 @@ impl Workload {
|
|||
}
|
||||
}
|
||||
|
||||
/// The genesis state: the issuer registered for USD.
|
||||
/// The genesis state of the demo cluster: the issuer registered for USD,
|
||||
/// no peers, and no governance delay.
|
||||
pub fn genesis(&self) -> State {
|
||||
State::genesis([(self.issuer.aid(), USD)])
|
||||
State::genesis(cluster(), [(self.issuer.aid(), USD)], [], 0)
|
||||
}
|
||||
|
||||
/// Frame `frame`'s inputs. Frame 0 incepts every party and mints; each
|
||||
|
|
|
|||
|
|
@ -1,26 +1,30 @@
|
|||
//! The frame transition (cluster state spec §4; treatise §9.4 steps 4–5):
|
||||
//! a pure function of the state and the frame's inputs.
|
||||
//!
|
||||
//! 1. Identical inputs are one input.
|
||||
//! 2. Inputs are applied by class — key events, then mints, then transfers —
|
||||
//! and within a class in ascending order of their BLAKE3 digests. Inputs
|
||||
//! that name no class come last; they are dropped.
|
||||
//! 3. Each input is checked on its own against the state as the inputs
|
||||
//! 1. Governance changes whose time has come take effect.
|
||||
//! 2. Identical inputs are one input.
|
||||
//! 3. Inputs are applied by class — key events, governance, mints, imports,
|
||||
//! transfers, burns — and within a class in ascending order of their
|
||||
//! BLAKE3 digests. Inputs that name no class come last; they are dropped.
|
||||
//! 4. Each input is checked on its own against the state as the inputs
|
||||
//! before it left it. One that fails any check is dropped, and changes
|
||||
//! nothing; every honest node drops the same ones.
|
||||
//! 4. The frame number advances by one.
|
||||
//! 5. The frame number advances by one.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use smart_byte_core::keys::verify_against;
|
||||
use smart_byte_core::canonical::{self, Value};
|
||||
use smart_byte_core::keys::{IndexedSignature, KeyBook, verify_against};
|
||||
use smart_byte_core::ownership::Transition;
|
||||
use smart_byte_core::provenance::Provenance;
|
||||
use smart_byte_core::{Blake3Hash, Said};
|
||||
use smart_byte_keri_witness::{KeyEvent, LogVerifier, Replay};
|
||||
use smart_byte_core::{Aid, Blake3Hash, ControllerSignature, Envelope, Said};
|
||||
use smart_byte_keri_witness::{KeyEvent, LogVerifier, Replay, SignedEvent};
|
||||
|
||||
use crate::input::{Input, InputClass, InputError};
|
||||
use crate::state::{ByteRecord, ControllerRecord, State};
|
||||
use crate::certificate::{Certificate, ExportStatement};
|
||||
use crate::input::{Approval, Burn, Input, InputClass, InputError, LoggedEvent, Proposal};
|
||||
use crate::state::{
|
||||
ByteRecord, Change, ControllerRecord, Digest, ExportRecord, Peer, Pending, RetiredRecord, State,
|
||||
};
|
||||
|
||||
/// What became of one input.
|
||||
#[derive(Debug)]
|
||||
|
|
@ -34,14 +38,17 @@ pub struct InputResult {
|
|||
}
|
||||
|
||||
/// What a frame did: one result per distinct input, in the order they were
|
||||
/// applied, and the inputs themselves in that order — the frame's committed
|
||||
/// inputs, identical on every honest node (treatise §16.1).
|
||||
/// applied; the inputs themselves in that order — the frame's committed
|
||||
/// inputs, identical on every honest node (treatise §16.1); and the exports
|
||||
/// it applied, which the cluster's nodes certify once it commits.
|
||||
#[derive(Debug)]
|
||||
pub struct FrameReport {
|
||||
/// The frame applied.
|
||||
pub frame: u64,
|
||||
pub results: Vec<InputResult>,
|
||||
pub inputs: Vec<Vec<u8>>,
|
||||
/// What the nodes certify for each byte the frame exported.
|
||||
pub exports: Vec<ExportStatement>,
|
||||
}
|
||||
|
||||
impl FrameReport {
|
||||
|
|
@ -51,11 +58,20 @@ impl FrameReport {
|
|||
}
|
||||
}
|
||||
|
||||
/// Where a byte the cluster has known stands.
|
||||
enum Standing<'a> {
|
||||
Live(&'a ByteRecord),
|
||||
Retired,
|
||||
Exported,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Apply one frame of inputs, as submitted (duplicates and all), and
|
||||
/// advance the frame number.
|
||||
pub fn apply_frame(&mut self, inputs: &[Vec<u8>]) -> FrameReport {
|
||||
let frame = self.frame;
|
||||
self.enact_pending();
|
||||
let distinct: BTreeMap<Blake3Hash, &Vec<u8>> = inputs
|
||||
.iter()
|
||||
.map(|bytes| (*blake3::hash(bytes).as_bytes(), bytes))
|
||||
|
|
@ -79,9 +95,10 @@ impl State {
|
|||
frame,
|
||||
results: Vec::with_capacity(ordered.len()),
|
||||
inputs: Vec::with_capacity(ordered.len()),
|
||||
exports: Vec::new(),
|
||||
};
|
||||
for (class, hash, bytes, decoded) in ordered {
|
||||
let outcome = decoded.and_then(|input| self.apply(input));
|
||||
let outcome = decoded.and_then(|input| self.apply(input, &mut report.exports));
|
||||
report.results.push(InputResult {
|
||||
input: hash,
|
||||
class,
|
||||
|
|
@ -93,11 +110,77 @@ impl State {
|
|||
report
|
||||
}
|
||||
|
||||
fn apply(&mut self, input: Input) -> Result<(), InputError> {
|
||||
/// Governance changes in force from this frame take effect, in the order
|
||||
/// they were accepted.
|
||||
fn enact_pending(&mut self) {
|
||||
let due: Vec<Pending> = {
|
||||
let (due, later) = std::mem::take(&mut self.governance.pending)
|
||||
.into_iter()
|
||||
.partition(|p| p.effective == self.frame);
|
||||
self.governance.pending = later;
|
||||
due
|
||||
};
|
||||
for pending in due {
|
||||
match pending.change {
|
||||
Change::AddIssuer { cargo_type, issuer } => {
|
||||
self.registry.entry(issuer).or_default().insert(cargo_type);
|
||||
}
|
||||
Change::RemoveIssuer { cargo_type, issuer } => {
|
||||
if let Some(types) = self.registry.get_mut(&issuer) {
|
||||
types.remove(&cargo_type);
|
||||
if types.is_empty() {
|
||||
self.registry.remove(&issuer);
|
||||
}
|
||||
}
|
||||
}
|
||||
Change::SetPeer { peer, keys } => {
|
||||
self.peers.insert(peer, Peer { keys });
|
||||
}
|
||||
Change::RemovePeer { peer } => {
|
||||
self.peers.remove(&peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(
|
||||
&mut self,
|
||||
input: Input,
|
||||
exports: &mut Vec<ExportStatement>,
|
||||
) -> Result<(), InputError> {
|
||||
match input {
|
||||
Input::KeyEvent { event, signatures } => self.key_event(&event, &signatures),
|
||||
Input::Governance {
|
||||
proposal,
|
||||
approvals,
|
||||
} => self.governance(&proposal, &approvals),
|
||||
Input::Mint(provenance) => self.mint(&provenance),
|
||||
Input::Transfer { byte, transition } => self.transfer(&byte, &transition),
|
||||
Input::Import {
|
||||
certificate,
|
||||
envelope,
|
||||
logs,
|
||||
} => self.import(&certificate, &envelope, &logs),
|
||||
Input::Transfer { byte, transition } => self.transfer(&byte, &transition, exports),
|
||||
Input::Burn(burn) => self.burn(&burn),
|
||||
}
|
||||
}
|
||||
|
||||
fn standing(&self, byte: &Said) -> Standing<'_> {
|
||||
match self.bytes.get(byte) {
|
||||
Some(record) => Standing::Live(record),
|
||||
None if self.retired.contains_key(byte) => Standing::Retired,
|
||||
None if self.exported.contains_key(byte) => Standing::Exported,
|
||||
None => Standing::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// The live record of a byte an owner acts on, or why there is none.
|
||||
fn live(&self, byte: &Said) -> Result<&ByteRecord, InputError> {
|
||||
match self.standing(byte) {
|
||||
Standing::Live(record) => Ok(record),
|
||||
Standing::Retired => Err(InputError::ByteRetired),
|
||||
Standing::Exported => Err(InputError::ByteExported),
|
||||
Standing::Unknown => Err(InputError::UnknownByte),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +189,7 @@ impl State {
|
|||
fn key_event(
|
||||
&mut self,
|
||||
event: &KeyEvent,
|
||||
signatures: &[smart_byte_core::keys::IndexedSignature],
|
||||
signatures: &[IndexedSignature],
|
||||
) -> Result<(), InputError> {
|
||||
let controller = *event.controller();
|
||||
let mut replay = match (event, self.controllers.get(&controller)) {
|
||||
|
|
@ -115,24 +198,112 @@ impl State {
|
|||
(_, Some(record)) => Replay::resume(LogVerifier::new(), record.to_state(controller)),
|
||||
(_, None) => return Err(InputError::UnknownController(controller.to_text())),
|
||||
};
|
||||
// An index past u32::MAX is past every key list: let the signature
|
||||
// check report it as out of range.
|
||||
let signatures: Vec<(u32, Vec<u8>)> = signatures
|
||||
.iter()
|
||||
.map(|s| {
|
||||
(
|
||||
u32::try_from(s.index).unwrap_or(u32::MAX),
|
||||
s.signature.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
replay.apply(event, &signatures, &HashMap::new())?;
|
||||
replay.apply(event, &wire_signatures(signatures), &HashMap::new())?;
|
||||
let state = replay.state().expect("an accepted event leaves a state");
|
||||
self.controllers
|
||||
.insert(controller, ControllerRecord::from_state(state));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A governance proposal (cluster state spec §4.5): accepted with a
|
||||
/// supermajority of its electorate's approvals, it takes effect after
|
||||
/// the cluster's governance delay.
|
||||
fn governance(
|
||||
&mut self,
|
||||
proposal: &Proposal,
|
||||
approvals: &[Approval],
|
||||
) -> Result<(), InputError> {
|
||||
if proposal.cluster != self.cluster {
|
||||
return Err(InputError::WrongCluster);
|
||||
}
|
||||
if proposal.sequence != self.governance.sequence {
|
||||
return Err(InputError::StaleSequence {
|
||||
named: proposal.sequence,
|
||||
current: self.governance.sequence,
|
||||
});
|
||||
}
|
||||
let target = match &proposal.change {
|
||||
Change::AddIssuer { cargo_type, issuer } => {
|
||||
if self.authorizes(issuer, *cargo_type) {
|
||||
return Err(InputError::AlreadyRegistered);
|
||||
}
|
||||
None
|
||||
}
|
||||
Change::RemoveIssuer { cargo_type, issuer } => {
|
||||
if !self.authorizes(issuer, *cargo_type) {
|
||||
return Err(InputError::NotRegistered);
|
||||
}
|
||||
Some(*issuer)
|
||||
}
|
||||
Change::SetPeer { peer, keys } => {
|
||||
if *peer == self.cluster {
|
||||
return Err(InputError::WrongCluster);
|
||||
}
|
||||
check_peer_keys(keys)?;
|
||||
None
|
||||
}
|
||||
Change::RemovePeer { peer } => {
|
||||
if !self.peers.contains_key(peer) {
|
||||
return Err(InputError::NotAPeer);
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
let electorate = self.electorate(&proposal.change, target);
|
||||
if electorate.is_empty() {
|
||||
return Err(InputError::NoElectorate);
|
||||
}
|
||||
let message = smart_byte_core::wire::to_vec(proposal).expect("a proposal always encodes");
|
||||
let mut approved = BTreeSet::new();
|
||||
for approval in approvals {
|
||||
if !electorate.contains(&approval.approver) {
|
||||
return Err(InputError::NotAnElector);
|
||||
}
|
||||
if !approved.insert(approval.approver) {
|
||||
return Err(InputError::DuplicateApprover);
|
||||
}
|
||||
self.signed_by(&approval.approver, &approval.signature, &message)?;
|
||||
}
|
||||
let quorum = 2 * electorate.len() / 3 + 1;
|
||||
if approved.len() < quorum {
|
||||
return Err(InputError::BelowQuorum {
|
||||
approvals: approved.len(),
|
||||
quorum,
|
||||
});
|
||||
}
|
||||
self.governance.sequence += 1;
|
||||
self.governance.pending.push(Pending {
|
||||
change: proposal.change.clone(),
|
||||
effective: self.frame + 1 + self.governance.delay,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Who approves `change`: the issuers of its cargo type for a registry
|
||||
/// change, or every issuer when that is none or the change is to the
|
||||
/// peers. An issuer being removed has no vote on its own removal.
|
||||
fn electorate(&self, change: &Change, target: Option<Aid>) -> BTreeSet<Aid> {
|
||||
let all = || {
|
||||
self.registry
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|a| Some(*a) != target)
|
||||
.collect::<BTreeSet<_>>()
|
||||
};
|
||||
match change {
|
||||
Change::AddIssuer { cargo_type, .. } | Change::RemoveIssuer { cargo_type, .. } => {
|
||||
let of_type: BTreeSet<Aid> = self
|
||||
.registry
|
||||
.iter()
|
||||
.filter(|(a, types)| types.contains(cargo_type) && Some(**a) != target)
|
||||
.map(|(a, _)| *a)
|
||||
.collect();
|
||||
if of_type.is_empty() { all() } else { of_type }
|
||||
}
|
||||
Change::SetPeer { .. } | Change::RemovePeer { .. } => all(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A mint (treatise §15.2): an issuer's attestation to a new byte.
|
||||
fn mint(&mut self, provenance: &Provenance) -> Result<(), InputError> {
|
||||
if provenance.compute_said() != provenance.d {
|
||||
|
|
@ -148,7 +319,7 @@ impl State {
|
|||
&provenance.signed_bytes(),
|
||||
)?;
|
||||
let identity = provenance.d;
|
||||
if self.bytes.contains_key(&identity) {
|
||||
if self.knows_byte(&identity) {
|
||||
return Err(InputError::DuplicateByte);
|
||||
}
|
||||
self.bytes.insert(
|
||||
|
|
@ -164,10 +335,90 @@ impl State {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// An import (cluster state spec §5; treatise §11.3.3 step 3): a byte
|
||||
/// another cluster certifies it exported here, whose full history this
|
||||
/// cluster verifies for itself.
|
||||
fn import(
|
||||
&mut self,
|
||||
certificate: &Certificate,
|
||||
envelope: &Value,
|
||||
logs: &[Vec<LoggedEvent>],
|
||||
) -> Result<(), InputError> {
|
||||
let statement = &certificate.statement;
|
||||
let export = &statement.export;
|
||||
if export.destination != self.cluster {
|
||||
return Err(InputError::WrongCluster);
|
||||
}
|
||||
let peer = self
|
||||
.peers
|
||||
.get(&statement.source)
|
||||
.ok_or(InputError::UnknownCluster)?;
|
||||
verify_against(
|
||||
&peer.key_state(),
|
||||
&statement.to_cbor(),
|
||||
&certificate.signatures,
|
||||
)?;
|
||||
let digest = Digest(statement.digest());
|
||||
if self.imported.contains(&digest) {
|
||||
return Err(InputError::DuplicateImport);
|
||||
}
|
||||
match self.standing(&statement.byte) {
|
||||
Standing::Live(_) => return Err(InputError::DuplicateByte),
|
||||
Standing::Retired => return Err(InputError::ByteRetired),
|
||||
Standing::Exported | Standing::Unknown => {}
|
||||
}
|
||||
let bytes = canonical::encode(envelope).map_err(InputError::Canonical)?;
|
||||
let envelope = Envelope::from_cbor(&bytes)?;
|
||||
if envelope.identity() != statement.byte
|
||||
|| envelope.history_hash() != export.history_hash
|
||||
|| envelope.current_owner() != export.owner
|
||||
|| envelope.provenance.issuer != export.issuer
|
||||
|| envelope.provenance.cargo != export.cargo
|
||||
{
|
||||
return Err(InputError::CertificateMismatch);
|
||||
}
|
||||
let mut keys = KeyBook::new();
|
||||
for log in logs {
|
||||
let signed = log
|
||||
.iter()
|
||||
.map(|e| {
|
||||
Ok(SignedEvent {
|
||||
bytes: canonical::encode(&e.event).map_err(InputError::Canonical)?,
|
||||
signatures: wire_signatures(&e.signatures),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, InputError>>()?;
|
||||
LogVerifier::new()
|
||||
.verify_encoded(&signed, &HashMap::new())
|
||||
.map_err(|r| InputError::KeyEvent(r.error))?
|
||||
.add_to(&mut keys);
|
||||
}
|
||||
envelope.verify(&keys)?;
|
||||
self.exported.remove(&statement.byte);
|
||||
self.bytes.insert(
|
||||
statement.byte,
|
||||
ByteRecord {
|
||||
issuer: export.issuer,
|
||||
cargo: export.cargo.clone(),
|
||||
minted: export.minted,
|
||||
owner: export.owner,
|
||||
history_hash: export.history_hash,
|
||||
},
|
||||
);
|
||||
self.imported.insert(digest);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A transfer: the owner signs the byte away, at this frame, from the
|
||||
/// point its history has reached (treatise §8.4, §10.2).
|
||||
fn transfer(&mut self, byte: &Said, transition: &Transition) -> Result<(), InputError> {
|
||||
let record = self.bytes.get(byte).ok_or(InputError::UnknownByte)?;
|
||||
/// point its history has reached (treatise §8.4, §10.2). One that names a
|
||||
/// destination moves the byte out of the cluster (treatise §11.3.3).
|
||||
fn transfer(
|
||||
&mut self,
|
||||
byte: &Said,
|
||||
transition: &Transition,
|
||||
exports: &mut Vec<ExportStatement>,
|
||||
) -> Result<(), InputError> {
|
||||
let record = self.live(byte)?;
|
||||
if transition.frame != self.frame {
|
||||
return Err(InputError::WrongFrame {
|
||||
named: transition.frame,
|
||||
|
|
@ -180,22 +431,81 @@ impl State {
|
|||
if transition.from != record.owner {
|
||||
return Err(InputError::NotCurrentOwner);
|
||||
}
|
||||
if let Some(destination) = &transition.destination
|
||||
&& !self.peers.contains_key(destination)
|
||||
{
|
||||
return Err(InputError::UnknownCluster);
|
||||
}
|
||||
self.signed_by(
|
||||
&transition.from,
|
||||
&transition.signature,
|
||||
&transition.signed_bytes(byte),
|
||||
)?;
|
||||
let record = self.bytes.get_mut(byte).expect("checked above");
|
||||
record.owner = transition.to;
|
||||
record.history_hash = transition.next_history_hash();
|
||||
let history_hash = transition.next_history_hash();
|
||||
match transition.destination {
|
||||
None => {
|
||||
let record = self.bytes.get_mut(byte).expect("checked above");
|
||||
record.owner = transition.to;
|
||||
record.history_hash = history_hash;
|
||||
}
|
||||
Some(destination) => {
|
||||
let record = self.bytes.remove(byte).expect("checked above");
|
||||
let export = ExportRecord {
|
||||
issuer: record.issuer,
|
||||
cargo: record.cargo,
|
||||
minted: record.minted,
|
||||
owner: transition.to,
|
||||
history_hash,
|
||||
destination,
|
||||
exported: self.frame,
|
||||
};
|
||||
exports.push(ExportStatement {
|
||||
source: self.cluster,
|
||||
byte: *byte,
|
||||
export: export.clone(),
|
||||
});
|
||||
self.exported.insert(*byte, export);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A burn (cluster state spec §4.7): the owner retires the byte for good.
|
||||
fn burn(&mut self, burn: &Burn) -> Result<(), InputError> {
|
||||
let record = self.live(&burn.byte)?;
|
||||
if burn.frame != self.frame {
|
||||
return Err(InputError::WrongFrame {
|
||||
named: burn.frame,
|
||||
current: self.frame,
|
||||
});
|
||||
}
|
||||
if burn.previous_history_hash != record.history_hash {
|
||||
return Err(InputError::BrokenHistoryChain);
|
||||
}
|
||||
if burn.owner != record.owner {
|
||||
return Err(InputError::NotCurrentOwner);
|
||||
}
|
||||
self.signed_by(&burn.owner, &burn.signature, &burn.signed_bytes())?;
|
||||
let record = self.bytes.remove(&burn.byte).expect("checked above");
|
||||
self.retired.insert(
|
||||
burn.byte,
|
||||
RetiredRecord {
|
||||
issuer: record.issuer,
|
||||
cargo: record.cargo,
|
||||
minted: record.minted,
|
||||
owner: record.owner,
|
||||
history_hash: record.history_hash,
|
||||
retired: self.frame,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `signature` is `signer`'s, under the establishment event in force now.
|
||||
fn signed_by(
|
||||
&self,
|
||||
signer: &smart_byte_core::Aid,
|
||||
signature: &smart_byte_core::ControllerSignature,
|
||||
signer: &Aid,
|
||||
signature: &ControllerSignature,
|
||||
message: &[u8],
|
||||
) -> Result<(), InputError> {
|
||||
let record = self
|
||||
|
|
@ -215,3 +525,35 @@ impl State {
|
|||
)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Signatures as a key-event log takes them. An index past u32::MAX is past
|
||||
/// every key list: let the signature check report it as out of range.
|
||||
fn wire_signatures(signatures: &[IndexedSignature]) -> Vec<(u32, Vec<u8>)> {
|
||||
signatures
|
||||
.iter()
|
||||
.map(|s| {
|
||||
(
|
||||
u32::try_from(s.index).unwrap_or(u32::MAX),
|
||||
s.signature.clone(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A peer's keys: at least one, none twice, each an Ed25519 key.
|
||||
fn check_peer_keys(keys: &[smart_byte_core::PrefixedKey]) -> Result<(), InputError> {
|
||||
if keys.is_empty() {
|
||||
return Err(InputError::PeerKeys("invalid_threshold"));
|
||||
}
|
||||
for (i, key) in keys.iter().enumerate() {
|
||||
if keys[..i].contains(key) {
|
||||
return Err(InputError::PeerKeys("duplicate_key"));
|
||||
}
|
||||
if key.algorithm() != Some(smart_byte_core::keys::ED25519)
|
||||
|| !smart_byte_core::sign::is_valid_key(key.key())
|
||||
{
|
||||
return Err(InputError::PeerKeys("malformed_verifying_key"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@
|
|||
//! class.
|
||||
//!
|
||||
//! ```text
|
||||
//! {"key_event": {"event": <key event>, "signatures": [{"index", "signature"}]}}
|
||||
//! {"mint": <provenance>}
|
||||
//! {"transfer": {"byte": <identity>, "transition": <transition>}}
|
||||
//! {"key_event": {"event": <key event>, "signatures": [{"index", "signature"}]}}
|
||||
//! {"governance": {"proposal": <proposal>, "approvals": [{"approver", "signature"}]}}
|
||||
//! {"mint": <provenance>}
|
||||
//! {"import": {"certificate": <certificate>, "envelope": <envelope>, "logs": [[<signed event>]]}}
|
||||
//! {"transfer": {"byte": <identity>, "transition": <transition>}}
|
||||
//! {"burn": {"byte", "previous_history_hash", "owner", "frame", "signature"}}
|
||||
//! ```
|
||||
//!
|
||||
//! An input is identified by the BLAKE3-256 digest of its bytes, which is
|
||||
|
|
@ -16,18 +19,33 @@ use smart_byte_core::canonical::{self, CanonicalError, Value};
|
|||
use smart_byte_core::keys::IndexedSignature;
|
||||
use smart_byte_core::ownership::Transition;
|
||||
use smart_byte_core::provenance::Provenance;
|
||||
use smart_byte_core::{ControllerSignatureError, Said, wire};
|
||||
use smart_byte_core::{
|
||||
Aid, Blake3Hash, ControllerSignature, ControllerSignatureError, EnvelopeError, Said, wire,
|
||||
};
|
||||
use smart_byte_keri_witness::{KeriError, KeyEvent};
|
||||
|
||||
/// The three classes of input, in the order a frame applies them: key
|
||||
/// events first, so a rotation retires keys before anything signed with
|
||||
/// them is checked; then mints, so a byte can be minted and transferred in
|
||||
/// one frame (treatise §15.3); then transfers.
|
||||
use crate::certificate::Certificate;
|
||||
use crate::state::Change;
|
||||
|
||||
/// The classes of input, in the order a frame applies them (cluster state
|
||||
/// spec §4):
|
||||
///
|
||||
/// * key events first, so a rotation retires keys before anything signed
|
||||
/// with them is checked;
|
||||
/// * governance, signed by issuers under the keys now in force;
|
||||
/// * mints, then imports, so a byte that arrives in a frame can move in it
|
||||
/// (treatise §15.3);
|
||||
/// * transfers;
|
||||
/// * burns last, so a byte can be returned to its issuer and burned in one
|
||||
/// frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum InputClass {
|
||||
KeyEvent,
|
||||
Governance,
|
||||
Mint,
|
||||
Import,
|
||||
Transfer,
|
||||
Burn,
|
||||
}
|
||||
|
||||
impl InputClass {
|
||||
|
|
@ -35,21 +53,115 @@ impl InputClass {
|
|||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
InputClass::KeyEvent => "key_event",
|
||||
InputClass::Governance => "governance",
|
||||
InputClass::Mint => "mint",
|
||||
InputClass::Import => "import",
|
||||
InputClass::Transfer => "transfer",
|
||||
InputClass::Burn => "burn",
|
||||
}
|
||||
}
|
||||
|
||||
fn named(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"key_event" => Some(InputClass::KeyEvent),
|
||||
"mint" => Some(InputClass::Mint),
|
||||
"transfer" => Some(InputClass::Transfer),
|
||||
_ => None,
|
||||
}
|
||||
[
|
||||
InputClass::KeyEvent,
|
||||
InputClass::Governance,
|
||||
InputClass::Mint,
|
||||
InputClass::Import,
|
||||
InputClass::Transfer,
|
||||
InputClass::Burn,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|c| c.as_str() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A governance proposal: one change to this cluster's registry or peers,
|
||||
/// made against its governance sequence number (cluster state spec §4.5).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Proposal {
|
||||
/// The cluster the change is for.
|
||||
pub cluster: Said,
|
||||
/// The governance sequence number it was made against.
|
||||
pub sequence: u64,
|
||||
pub change: Change,
|
||||
}
|
||||
|
||||
/// One issuer's approval of a proposal: its signature over the proposal's
|
||||
/// canonical encoding.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Approval {
|
||||
pub approver: Aid,
|
||||
pub signature: ControllerSignature,
|
||||
}
|
||||
|
||||
/// A burn: the owner retires a byte (cluster state spec §4.7).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Burn {
|
||||
pub byte: Said,
|
||||
#[serde(with = "smart_byte_core::wire::fixed_bytes")]
|
||||
pub previous_history_hash: Blake3Hash,
|
||||
pub owner: Aid,
|
||||
pub frame: u64,
|
||||
pub signature: ControllerSignature,
|
||||
}
|
||||
|
||||
/// What a burn's owner signs.
|
||||
#[derive(Serialize)]
|
||||
struct BurnMessage<'a> {
|
||||
burn: &'a Said,
|
||||
#[serde(with = "serde_bytes")]
|
||||
previous_history_hash: &'a [u8],
|
||||
owner: &'a Aid,
|
||||
frame: u64,
|
||||
}
|
||||
|
||||
impl Burn {
|
||||
/// The message the owner signs: the canonical encoding of `{"burn":
|
||||
/// identity, "previous_history_hash", "owner", "frame"}`.
|
||||
pub fn signed_bytes(&self) -> Vec<u8> {
|
||||
wire::to_vec(&BurnMessage {
|
||||
burn: &self.byte,
|
||||
previous_history_hash: &self.previous_history_hash,
|
||||
owner: &self.owner,
|
||||
frame: self.frame,
|
||||
})
|
||||
.expect("a burn's signed fields always encode")
|
||||
}
|
||||
|
||||
/// A burn of `byte`, at the point its history has reached, signed by its
|
||||
/// owner.
|
||||
pub fn sign(
|
||||
owner: &smart_byte_core::Signer,
|
||||
byte: Said,
|
||||
previous_history_hash: Blake3Hash,
|
||||
frame: u64,
|
||||
) -> Self {
|
||||
let mut burn = Burn {
|
||||
byte,
|
||||
previous_history_hash,
|
||||
owner: owner.aid,
|
||||
frame,
|
||||
signature: ControllerSignature {
|
||||
establishment: 0,
|
||||
signatures: vec![],
|
||||
},
|
||||
};
|
||||
burn.signature = owner.sign(&burn.signed_bytes());
|
||||
burn
|
||||
}
|
||||
}
|
||||
|
||||
/// A key event and the signatures over it, as an import's logs carry them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LoggedEvent {
|
||||
/// The event, as received: decoded when the import is applied.
|
||||
pub event: Value,
|
||||
pub signatures: Vec<IndexedSignature>,
|
||||
}
|
||||
|
||||
/// A decoded input.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Input {
|
||||
|
|
@ -59,11 +171,25 @@ pub enum Input {
|
|||
event: KeyEvent,
|
||||
signatures: Vec<IndexedSignature>,
|
||||
},
|
||||
/// A change to the registry or peers, with its approvals.
|
||||
Governance {
|
||||
proposal: Proposal,
|
||||
approvals: Vec<Approval>,
|
||||
},
|
||||
/// An issuer's attestation to a new byte: its provenance, whose SAID is
|
||||
/// the byte's identity.
|
||||
Mint(Provenance),
|
||||
/// An owner's transfer of a byte.
|
||||
/// A byte arriving from another cluster: the source's certificate, the
|
||||
/// byte's envelope, and the key-event logs that verify its history.
|
||||
Import {
|
||||
certificate: Certificate,
|
||||
envelope: Value,
|
||||
logs: Vec<Vec<LoggedEvent>>,
|
||||
},
|
||||
/// An owner's transfer of a byte, within the cluster or out of it.
|
||||
Transfer { byte: Said, transition: Transition },
|
||||
/// An owner's burn of a byte.
|
||||
Burn(Burn),
|
||||
}
|
||||
|
||||
/// Why an input was dropped (cluster state spec §4).
|
||||
|
|
@ -75,6 +201,8 @@ pub enum InputError {
|
|||
Malformed(String),
|
||||
#[error("key event: {0}")]
|
||||
KeyEvent(#[from] KeriError),
|
||||
#[error("envelope: {0}")]
|
||||
Envelope(#[from] EnvelopeError),
|
||||
#[error("the provenance does not hash to the identity it states")]
|
||||
SaidMismatch,
|
||||
#[error("an inception for a controller the cluster already holds")]
|
||||
|
|
@ -91,12 +219,42 @@ pub enum InputError {
|
|||
DuplicateByte,
|
||||
#[error("the cluster holds no byte with this identity")]
|
||||
UnknownByte,
|
||||
#[error("the transition names frame {named}; this is frame {current}")]
|
||||
#[error("the byte was burned")]
|
||||
ByteRetired,
|
||||
#[error("the byte left for another cluster")]
|
||||
ByteExported,
|
||||
#[error("the input names frame {named}; this is frame {current}")]
|
||||
WrongFrame { named: u64, current: u64 },
|
||||
#[error("the transition does not continue the byte's history")]
|
||||
#[error("the input does not continue the byte's history")]
|
||||
BrokenHistoryChain,
|
||||
#[error("the transition is not signed away by the byte's owner")]
|
||||
#[error("the input is not signed by the byte's owner")]
|
||||
NotCurrentOwner,
|
||||
#[error("the input is addressed to another cluster")]
|
||||
WrongCluster,
|
||||
#[error("the cluster is not a peer")]
|
||||
UnknownCluster,
|
||||
#[error("the certificate was imported already")]
|
||||
DuplicateImport,
|
||||
#[error("the envelope is not the byte the certificate certifies")]
|
||||
CertificateMismatch,
|
||||
#[error("the proposal names sequence {named}; the cluster is at {current}")]
|
||||
StaleSequence { named: u64, current: u64 },
|
||||
#[error("the issuer is registered for the cargo type already")]
|
||||
AlreadyRegistered,
|
||||
#[error("the issuer is not registered for the cargo type")]
|
||||
NotRegistered,
|
||||
#[error("the cluster has no such peer")]
|
||||
NotAPeer,
|
||||
#[error("no issuer can approve the change")]
|
||||
NoElectorate,
|
||||
#[error("the approver cannot vote on the change")]
|
||||
NotAnElector,
|
||||
#[error("an approver is listed twice")]
|
||||
DuplicateApprover,
|
||||
#[error("{approvals} approvals; the change needs {quorum}")]
|
||||
BelowQuorum { approvals: usize, quorum: usize },
|
||||
#[error("a peer's keys: {0}")]
|
||||
PeerKeys(&'static str),
|
||||
}
|
||||
|
||||
impl InputError {
|
||||
|
|
@ -106,6 +264,7 @@ impl InputError {
|
|||
InputError::Canonical(e) => e.class(),
|
||||
InputError::Malformed(_) => "malformed_input",
|
||||
InputError::KeyEvent(e) => e.class(),
|
||||
InputError::Envelope(e) => e.class(),
|
||||
InputError::SaidMismatch => "said_mismatch",
|
||||
InputError::ControllerExists => "controller_exists",
|
||||
InputError::UnknownController(_) => "unknown_controller",
|
||||
|
|
@ -114,9 +273,24 @@ impl InputError {
|
|||
InputError::Signature(e) => e.class(),
|
||||
InputError::DuplicateByte => "duplicate_byte",
|
||||
InputError::UnknownByte => "unknown_byte",
|
||||
InputError::ByteRetired => "byte_retired",
|
||||
InputError::ByteExported => "byte_exported",
|
||||
InputError::WrongFrame { .. } => "wrong_frame",
|
||||
InputError::BrokenHistoryChain => "broken_history_chain",
|
||||
InputError::NotCurrentOwner => "not_current_owner",
|
||||
InputError::WrongCluster => "wrong_cluster",
|
||||
InputError::UnknownCluster => "unknown_cluster",
|
||||
InputError::DuplicateImport => "duplicate_import",
|
||||
InputError::CertificateMismatch => "certificate_mismatch",
|
||||
InputError::StaleSequence { .. } => "stale_sequence",
|
||||
InputError::AlreadyRegistered => "already_registered",
|
||||
InputError::NotRegistered => "not_registered",
|
||||
InputError::NotAPeer => "not_a_peer",
|
||||
InputError::NoElectorate => "no_electorate",
|
||||
InputError::NotAnElector => "not_an_elector",
|
||||
InputError::DuplicateApprover => "duplicate_approver",
|
||||
InputError::BelowQuorum { .. } => "below_quorum",
|
||||
InputError::PeerKeys(class) => class,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -128,34 +302,74 @@ struct TransferBody {
|
|||
transition: Transition,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct GovernanceBody {
|
||||
proposal: Proposal,
|
||||
approvals: Vec<Approval>,
|
||||
}
|
||||
|
||||
fn one(name: &str, body: Value) -> Vec<u8> {
|
||||
canonical::encode(&Value::Map(vec![(Value::text(name), body)]))
|
||||
.expect("an input always encodes")
|
||||
}
|
||||
|
||||
fn value<T: Serialize + ?Sized>(v: &T) -> Value {
|
||||
wire::to_value(v).expect("an input's parts always encode")
|
||||
}
|
||||
|
||||
impl Input {
|
||||
/// The wire form of a key event and the signatures over it.
|
||||
pub fn key_event(event: &KeyEvent, signatures: &[IndexedSignature]) -> Vec<u8> {
|
||||
one(
|
||||
"key_event",
|
||||
Value::map([("event", value(event)), ("signatures", value(signatures))]),
|
||||
)
|
||||
}
|
||||
|
||||
/// The wire form of a governance proposal and its approvals.
|
||||
pub fn governance(proposal: &Proposal, approvals: &[Approval]) -> Vec<u8> {
|
||||
one(
|
||||
"governance",
|
||||
Value::map([
|
||||
(
|
||||
"event",
|
||||
wire::to_value(event).expect("an event always encodes"),
|
||||
),
|
||||
(
|
||||
"signatures",
|
||||
wire::to_value(signatures).expect("signatures always encode"),
|
||||
),
|
||||
("proposal", value(proposal)),
|
||||
("approvals", value(approvals)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
/// The wire form of a mint.
|
||||
pub fn mint(provenance: &Provenance) -> Vec<u8> {
|
||||
one("mint", value(provenance))
|
||||
}
|
||||
|
||||
/// The wire form of an import: the certificate, the envelope, and the
|
||||
/// signed key events of each log the envelope's history needs.
|
||||
pub fn import(
|
||||
certificate: &Certificate,
|
||||
envelope: &smart_byte_core::Envelope,
|
||||
logs: &[Vec<(KeyEvent, Vec<IndexedSignature>)>],
|
||||
) -> Vec<u8> {
|
||||
let logs = Value::Array(
|
||||
logs.iter()
|
||||
.map(|log| {
|
||||
Value::Array(
|
||||
log.iter()
|
||||
.map(|(event, sigs)| {
|
||||
Value::map([("event", value(event)), ("signatures", value(sigs))])
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
one(
|
||||
"mint",
|
||||
wire::to_value(provenance).expect("a provenance always encodes"),
|
||||
"import",
|
||||
Value::map([
|
||||
("certificate", value(certificate)),
|
||||
("envelope", value(envelope)),
|
||||
("logs", logs),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -163,14 +377,18 @@ impl Input {
|
|||
pub fn transfer(byte: &Said, transition: &Transition) -> Vec<u8> {
|
||||
one(
|
||||
"transfer",
|
||||
wire::to_value(&TransferBody {
|
||||
value(&TransferBody {
|
||||
byte: *byte,
|
||||
transition: transition.clone(),
|
||||
})
|
||||
.expect("a transfer always encodes"),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The wire form of a burn.
|
||||
pub fn burn(burn: &Burn) -> Vec<u8> {
|
||||
one("burn", value(burn))
|
||||
}
|
||||
|
||||
/// Decode an input: its class, if it names one, and the input or why it
|
||||
/// is not one.
|
||||
///
|
||||
|
|
@ -178,6 +396,7 @@ impl Input {
|
|||
/// member's value exactly that class's body. A key event is decoded as
|
||||
/// identity spec §4 step 1 decodes it — `rec` and `dip` are refused before
|
||||
/// anything else is read — so its errors carry the key-event-log classes.
|
||||
/// An import's envelope and logs are decoded when it is applied.
|
||||
pub fn decode(bytes: &[u8]) -> (Option<InputClass>, Result<Input, InputError>) {
|
||||
let value = match canonical::decode(bytes) {
|
||||
Ok(v) => v,
|
||||
|
|
@ -191,18 +410,21 @@ impl Input {
|
|||
_ => return (None, Err(malformed("an input is a map with one member"))),
|
||||
};
|
||||
let Some(class) = InputClass::named(&name) else {
|
||||
return (
|
||||
None,
|
||||
Err(malformed("an input's class is key_event, mint or transfer")),
|
||||
);
|
||||
return (None, Err(malformed("an input names a class there is")));
|
||||
};
|
||||
let input = match class {
|
||||
InputClass::KeyEvent => key_event_body(body),
|
||||
InputClass::Governance => exact::<GovernanceBody>(&body).map(|g| Input::Governance {
|
||||
proposal: g.proposal,
|
||||
approvals: g.approvals,
|
||||
}),
|
||||
InputClass::Mint => exact::<Provenance>(&body).map(Input::Mint),
|
||||
InputClass::Import => import_body(body),
|
||||
InputClass::Transfer => exact::<TransferBody>(&body).map(|t| Input::Transfer {
|
||||
byte: t.byte,
|
||||
transition: t.transition,
|
||||
}),
|
||||
InputClass::Burn => exact::<Burn>(&body).map(Input::Burn),
|
||||
};
|
||||
(Some(class), input)
|
||||
}
|
||||
|
|
@ -218,34 +440,81 @@ fn exact<T: serde::de::DeserializeOwned + Serialize>(value: &Value) -> Result<T,
|
|||
wire::from_slice(&bytes).map_err(|e| InputError::Malformed(e.to_string()))
|
||||
}
|
||||
|
||||
fn key_event_body(body: Value) -> Result<Input, InputError> {
|
||||
let Value::Map(members) = body else {
|
||||
return Err(malformed("a key_event input is a map"));
|
||||
/// The members of a map that must have exactly `names`, in that order.
|
||||
fn members<const N: usize>(
|
||||
body: Value,
|
||||
names: [&str; N],
|
||||
what: &str,
|
||||
) -> Result<[Value; N], InputError> {
|
||||
let Value::Map(entries) = body else {
|
||||
return Err(InputError::Malformed(format!("{what} is a map")));
|
||||
};
|
||||
let mut event = None;
|
||||
let mut signatures = None;
|
||||
for (k, v) in members {
|
||||
match k {
|
||||
Value::Text(t) if t == "event" => event = Some(v),
|
||||
Value::Text(t) if t == "signatures" => signatures = Some(v),
|
||||
let mut out: [Option<Value>; N] = std::array::from_fn(|_| None);
|
||||
for (k, v) in entries {
|
||||
let slot = match &k {
|
||||
Value::Text(t) => names.iter().position(|n| n == t),
|
||||
_ => None,
|
||||
};
|
||||
match slot {
|
||||
Some(i) if out[i].is_none() => out[i] = Some(v),
|
||||
_ => {
|
||||
return Err(malformed(
|
||||
"a key_event input has exactly `event` and `signatures`",
|
||||
));
|
||||
return Err(InputError::Malformed(format!(
|
||||
"{what} has exactly {names:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (Some(event), Some(signatures)) = (event, signatures) else {
|
||||
return Err(malformed(
|
||||
"a key_event input has exactly `event` and `signatures`",
|
||||
));
|
||||
};
|
||||
let signatures: Vec<IndexedSignature> = exact(&signatures)?;
|
||||
let mut values = Vec::with_capacity(N);
|
||||
for v in out {
|
||||
values
|
||||
.push(v.ok_or_else(|| InputError::Malformed(format!("{what} has exactly {names:?}")))?);
|
||||
}
|
||||
Ok(values
|
||||
.try_into()
|
||||
.unwrap_or_else(|_| unreachable!("N values")))
|
||||
}
|
||||
|
||||
fn signed_event(body: Value, what: &str) -> Result<(Value, Vec<IndexedSignature>), InputError> {
|
||||
let [event, signatures] = members(body, ["event", "signatures"], what)?;
|
||||
Ok((event, exact(&signatures)?))
|
||||
}
|
||||
|
||||
fn key_event_body(body: Value) -> Result<Input, InputError> {
|
||||
let (event, signatures) = signed_event(body, "a key_event input")?;
|
||||
let event_bytes = canonical::encode(&event).map_err(InputError::Canonical)?;
|
||||
let event = KeyEvent::decode(&event_bytes, true)?;
|
||||
Ok(Input::KeyEvent { event, signatures })
|
||||
}
|
||||
|
||||
fn import_body(body: Value) -> Result<Input, InputError> {
|
||||
let [certificate, envelope, logs] =
|
||||
members(body, ["certificate", "envelope", "logs"], "an import input")?;
|
||||
let certificate: Certificate = exact(&certificate)?;
|
||||
let Value::Array(logs) = logs else {
|
||||
return Err(malformed("an import's logs are an array of logs"));
|
||||
};
|
||||
let mut decoded = Vec::with_capacity(logs.len());
|
||||
for log in logs {
|
||||
let Value::Array(events) = log else {
|
||||
return Err(malformed("a log is an array of signed events"));
|
||||
};
|
||||
decoded.push(
|
||||
events
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
signed_event(e, "a logged event")
|
||||
.map(|(event, signatures)| LoggedEvent { event, signatures })
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
);
|
||||
}
|
||||
Ok(Input::Import {
|
||||
certificate,
|
||||
envelope,
|
||||
logs: decoded,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -257,7 +526,7 @@ mod tests {
|
|||
(c, r.err().map(|e| e.class()))
|
||||
};
|
||||
assert_eq!(
|
||||
class(Value::map([("burn", Value::Null)])),
|
||||
class(Value::map([("melt", Value::Null)])),
|
||||
(None, Some("malformed_input"))
|
||||
);
|
||||
assert_eq!(
|
||||
|
|
@ -272,6 +541,10 @@ mod tests {
|
|||
class(Value::map([("transfer", Value::Null)])),
|
||||
(Some(InputClass::Transfer), Some("malformed_input"))
|
||||
);
|
||||
assert_eq!(
|
||||
class(Value::map([("burn", Value::Null)])),
|
||||
(Some(InputClass::Burn), Some("malformed_input"))
|
||||
);
|
||||
assert_eq!(
|
||||
class(Value::map([(
|
||||
"key_event",
|
||||
|
|
@ -279,6 +552,13 @@ mod tests {
|
|||
)])),
|
||||
(Some(InputClass::KeyEvent), Some("malformed_input"))
|
||||
);
|
||||
assert_eq!(
|
||||
class(Value::map([(
|
||||
"import",
|
||||
Value::map([("certificate", Value::Null), ("envelope", Value::Null)])
|
||||
)])),
|
||||
(Some(InputClass::Import), Some("malformed_input"))
|
||||
);
|
||||
// Non-canonical bytes are refused before anything is read.
|
||||
let (c, r) = Input::decode(&[0xa1, 0x64, b'm', b'i', b'n', b't', 0x18, 0x00]);
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@
|
|||
//! the bytes the cluster holds. Its canonical encoding is the checkpoint
|
||||
//! and its BLAKE3 digest is the state hash.
|
||||
//! * [`State::apply_frame`] — the transition (treatise §9.4 steps 4–5): key
|
||||
//! events, mints and transfers, deduplicated, ordered, and each applied or
|
||||
//! dropped for a named reason.
|
||||
//! events, governance, mints, imports, transfers and burns, deduplicated,
|
||||
//! ordered, and each applied or dropped for a named reason.
|
||||
//! * [`Certificate`] — what a cluster's nodes sign when a byte leaves for
|
||||
//! another cluster, and what that cluster imports it on (treatise §11.3.3).
|
||||
//! * [`Store`] — checkpoint and replay log (treatise §16).
|
||||
//!
|
||||
//! The lockstep protocol around it — exchanging inputs and tallying state
|
||||
|
|
@ -18,13 +20,18 @@
|
|||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod certificate;
|
||||
pub mod demo;
|
||||
pub mod frame;
|
||||
pub mod input;
|
||||
pub mod state;
|
||||
pub mod store;
|
||||
|
||||
pub use certificate::{Certificate, ExportStatement};
|
||||
pub use frame::{FrameReport, InputResult};
|
||||
pub use input::{Input, InputClass, InputError};
|
||||
pub use state::{ByteRecord, ControllerRecord, State};
|
||||
pub use input::{Approval, Burn, Input, InputClass, InputError, Proposal};
|
||||
pub use state::{
|
||||
ByteRecord, Change, ControllerRecord, ExportRecord, Governance, Peer, Pending, RetiredRecord,
|
||||
State,
|
||||
};
|
||||
pub use store::{Store, StoreError};
|
||||
|
|
|
|||
|
|
@ -4,9 +4,15 @@
|
|||
//! ```text
|
||||
//! {
|
||||
//! "frame": the next frame to apply,
|
||||
//! "cluster": this cluster's identifier,
|
||||
//! "peers": {cluster: node keys} — the clusters bytes move to and from,
|
||||
//! "registry": {issuer AID: [cargo types, ascending]},
|
||||
//! "governance": {"delay", "sequence", "pending"},
|
||||
//! "controllers": {AID: key state},
|
||||
//! "bytes": {identity: owner, history hash, issuer, cargo, mint frame}
|
||||
//! "bytes": {identity: owner, history hash, issuer, cargo, mint frame},
|
||||
//! "retired": {identity: a burned byte's last state},
|
||||
//! "exported": {identity: a byte that left for another cluster},
|
||||
//! "imported": [digests of the export certificates accepted]
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
|
|
@ -30,12 +36,80 @@ pub struct State {
|
|||
/// The next frame to apply. Genesis is frame 0; applying frame `n`
|
||||
/// leaves `n + 1` here, so the state hash covers the new frame number.
|
||||
pub frame: u64,
|
||||
/// This cluster's identifier.
|
||||
pub cluster: Said,
|
||||
/// The clusters bytes move to and from, each with its nodes' keys.
|
||||
pub peers: BTreeMap<Said, Peer>,
|
||||
/// Each issuer and the cargo types it may mint (treatise §15.1).
|
||||
pub registry: BTreeMap<Aid, BTreeSet<u64>>,
|
||||
/// Changes to the registry and the peers, and when they take effect.
|
||||
pub governance: Governance,
|
||||
/// Every controller whose key-event log the cluster has applied.
|
||||
pub controllers: BTreeMap<Aid, ControllerRecord>,
|
||||
/// Every byte the cluster holds, by identity.
|
||||
pub bytes: BTreeMap<Said, ByteRecord>,
|
||||
/// Every byte burned here: its identity can never be minted or moved
|
||||
/// again.
|
||||
pub retired: BTreeMap<Said, RetiredRecord>,
|
||||
/// Every byte that left for another cluster and has not come back.
|
||||
pub exported: BTreeMap<Said, ExportRecord>,
|
||||
/// The digests of the export certificates this cluster has imported:
|
||||
/// each is accepted once.
|
||||
pub imported: BTreeSet<Digest>,
|
||||
}
|
||||
|
||||
/// A 32-byte digest, as a byte string.
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
|
||||
)]
|
||||
pub struct Digest(#[serde(with = "smart_byte_core::wire::fixed_bytes")] pub Blake3Hash);
|
||||
|
||||
/// A peer cluster: the keys of its nodes, any supermajority of which can
|
||||
/// certify an export (treatise §11.3.3).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Peer {
|
||||
/// Its nodes' keys: algorithm identifier and key.
|
||||
pub keys: Vec<PrefixedKey>,
|
||||
}
|
||||
|
||||
/// The cluster's governance (cluster state spec §4.5).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Governance {
|
||||
/// Frames between a change's acceptance and its effect, beyond the next.
|
||||
pub delay: u64,
|
||||
/// The sequence number the next accepted proposal must carry.
|
||||
pub sequence: u64,
|
||||
/// Accepted changes not yet in effect, in the order they were accepted.
|
||||
pub pending: Vec<Pending>,
|
||||
}
|
||||
|
||||
/// A change accepted but not yet in effect.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Pending {
|
||||
pub change: Change,
|
||||
/// The first frame the change is in force.
|
||||
pub effective: u64,
|
||||
}
|
||||
|
||||
/// A change to the registry or the peers.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", deny_unknown_fields)]
|
||||
pub enum Change {
|
||||
/// Authorize `issuer` to mint `cargo_type`.
|
||||
#[serde(rename = "add_issuer")]
|
||||
AddIssuer { cargo_type: u64, issuer: Aid },
|
||||
/// Withdraw that authorization.
|
||||
#[serde(rename = "remove_issuer")]
|
||||
RemoveIssuer { cargo_type: u64, issuer: Aid },
|
||||
/// Add a peer cluster, or replace its nodes' keys.
|
||||
#[serde(rename = "set_peer")]
|
||||
SetPeer { peer: Said, keys: Vec<PrefixedKey> },
|
||||
/// Remove a peer cluster.
|
||||
#[serde(rename = "remove_peer")]
|
||||
RemovePeer { peer: Said },
|
||||
}
|
||||
|
||||
/// A controller's key state as the cluster holds it: what its key-event log
|
||||
|
|
@ -69,7 +143,7 @@ pub struct ByteRecord {
|
|||
pub issuer: Aid,
|
||||
/// The cargo, as its issuer attested it.
|
||||
pub cargo: Cargo,
|
||||
/// The frame it was minted in.
|
||||
/// The frame it was minted in, in the cluster that minted it.
|
||||
pub minted: u64,
|
||||
/// The current owner.
|
||||
pub owner: Aid,
|
||||
|
|
@ -78,15 +152,66 @@ pub struct ByteRecord {
|
|||
pub history_hash: Blake3Hash,
|
||||
}
|
||||
|
||||
/// A burned byte: its last state, and the frame it was burned in.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RetiredRecord {
|
||||
pub issuer: Aid,
|
||||
pub cargo: Cargo,
|
||||
pub minted: u64,
|
||||
/// The owner that burned it.
|
||||
pub owner: Aid,
|
||||
#[serde(with = "smart_byte_core::wire::fixed_bytes")]
|
||||
pub history_hash: Blake3Hash,
|
||||
/// The frame it was burned in.
|
||||
pub retired: u64,
|
||||
}
|
||||
|
||||
/// A byte that left for another cluster: its state as it left, which the
|
||||
/// cluster's nodes certify to the destination (treatise §11.3.3).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExportRecord {
|
||||
pub issuer: Aid,
|
||||
pub cargo: Cargo,
|
||||
pub minted: u64,
|
||||
/// Its owner in the destination cluster.
|
||||
pub owner: Aid,
|
||||
/// Its history hash after the transition that exported it.
|
||||
#[serde(with = "smart_byte_core::wire::fixed_bytes")]
|
||||
pub history_hash: Blake3Hash,
|
||||
/// The cluster it left for.
|
||||
pub destination: Said,
|
||||
/// The frame it left in.
|
||||
pub exported: u64,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// A cluster's genesis state: frame 0, the issuers authorized from the
|
||||
/// start, and nothing else. Genesis bytes and the logs of genesis
|
||||
/// controllers arrive as frame 0's inputs, like any others (§15.4).
|
||||
pub fn genesis(registry: impl IntoIterator<Item = (Aid, u64)>) -> Self {
|
||||
let mut state = State::default();
|
||||
/// A cluster's genesis state: frame 0, its identifier, its peers, the
|
||||
/// issuers authorized from the start, and its governance delay. Genesis
|
||||
/// bytes and the logs of genesis controllers arrive as frame 0's inputs,
|
||||
/// like any others (§15.4).
|
||||
pub fn genesis(
|
||||
cluster: Said,
|
||||
registry: impl IntoIterator<Item = (Aid, u64)>,
|
||||
peers: impl IntoIterator<Item = (Said, Vec<PrefixedKey>)>,
|
||||
governance_delay: u64,
|
||||
) -> Self {
|
||||
let mut state = State {
|
||||
cluster,
|
||||
governance: Governance {
|
||||
delay: governance_delay,
|
||||
..Governance::default()
|
||||
},
|
||||
..State::default()
|
||||
};
|
||||
for (issuer, cargo_type) in registry {
|
||||
state.registry.entry(issuer).or_default().insert(cargo_type);
|
||||
}
|
||||
for (peer, keys) in peers {
|
||||
assert_ne!(peer, cluster, "a cluster is not its own peer");
|
||||
state.peers.insert(peer, Peer { keys });
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
|
|
@ -111,6 +236,25 @@ impl State {
|
|||
.get(issuer)
|
||||
.is_some_and(|types| types.contains(&cargo_type))
|
||||
}
|
||||
|
||||
/// Whether the cluster has ever held a byte with this identity — live,
|
||||
/// burned or exported.
|
||||
pub fn knows_byte(&self, identity: &Said) -> bool {
|
||||
self.bytes.contains_key(identity)
|
||||
|| self.retired.contains_key(identity)
|
||||
|| self.exported.contains_key(identity)
|
||||
}
|
||||
}
|
||||
|
||||
impl Peer {
|
||||
/// The key state a certificate from this peer is checked against: its
|
||||
/// nodes' keys, and the supermajority of them (treatise §13.1).
|
||||
pub fn key_state(&self) -> KeyState {
|
||||
KeyState {
|
||||
keys: self.keys.clone(),
|
||||
threshold: (2 * self.keys.len() / 3 + 1) as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ControllerRecord {
|
||||
|
|
@ -162,9 +306,13 @@ mod tests {
|
|||
Aid::of_inception(Said::hash(label.as_bytes()))
|
||||
}
|
||||
|
||||
fn genesis(registry: &[(Aid, u64)]) -> State {
|
||||
State::genesis(Said::hash(b"cluster"), registry.iter().copied(), [], 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_encoding_is_canonical_whatever_order_the_maps_hold() {
|
||||
let state = State::genesis([(aid("b"), 2), (aid("a"), 1), (aid("b"), 1)]);
|
||||
let state = genesis(&[(aid("b"), 2), (aid("a"), 1), (aid("b"), 1)]);
|
||||
let bytes = state.to_cbor();
|
||||
// The canonical decoder accepts it unchanged: keys are in encoded order.
|
||||
assert_eq!(
|
||||
|
|
@ -178,7 +326,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn a_registry_entry_in_another_order_is_not_the_state_s_encoding() {
|
||||
let state = State::genesis([(aid("a"), 1), (aid("a"), 2)]);
|
||||
let state = genesis(&[(aid("a"), 1), (aid("a"), 2)]);
|
||||
let canonical::Value::Map(mut members) = canonical::decode(&state.to_cbor()).unwrap()
|
||||
else {
|
||||
unreachable!()
|
||||
|
|
@ -200,9 +348,19 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn the_frame_number_is_part_of_the_hash() {
|
||||
let a = State::genesis([(aid("a"), 1)]);
|
||||
let a = genesis(&[(aid("a"), 1)]);
|
||||
let mut b = a.clone();
|
||||
b.frame += 1;
|
||||
assert_ne!(a.hash(), b.hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_peer_certifies_with_a_supermajority_of_its_nodes() {
|
||||
let peer = |n: usize| Peer {
|
||||
keys: (0..n).map(|i| PrefixedKey(vec![1, i as u8])).collect(),
|
||||
};
|
||||
assert_eq!(peer(4).key_state().threshold, 3);
|
||||
assert_eq!(peer(7).key_state().threshold, 5);
|
||||
assert_eq!(peer(1).key_state().threshold, 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ fn world() -> World {
|
|||
JouleCost::default(),
|
||||
);
|
||||
let mint = Input::mint(&byte.provenance);
|
||||
let mut state = State::genesis([(issuer.aid, 1)]);
|
||||
let mut state = State::genesis(Said::hash(b"cluster"), [(issuer.aid, 1)], [], 0);
|
||||
let report = state.apply_frame(&[issuer_icp, alice_icp, bob_icp, mint.clone()]);
|
||||
assert_eq!(classes(&report), vec![Ok(()); 4]);
|
||||
World {
|
||||
|
|
@ -250,7 +250,7 @@ fn a_mint_and_its_first_transfer_share_a_frame() {
|
|||
);
|
||||
let id = byte.identity();
|
||||
let (move_it, _) = transfer(&alice, &id, id.0, issuer.aid, 0);
|
||||
let mut state = State::genesis([(issuer.aid, 1)]);
|
||||
let mut state = State::genesis(Said::hash(b"cluster"), [(issuer.aid, 1)], [], 0);
|
||||
let report = state.apply_frame(&[
|
||||
move_it,
|
||||
Input::mint(&byte.provenance),
|
||||
|
|
@ -283,7 +283,7 @@ fn a_controller_signs_only_once_its_log_is_in_the_cluster() {
|
|||
JouleCost::default(),
|
||||
);
|
||||
let id = byte.identity();
|
||||
let mut state = State::genesis([(issuer.aid, 1)]);
|
||||
let mut state = State::genesis(Said::hash(b"cluster"), [(issuer.aid, 1)], [], 0);
|
||||
state.apply_frame(&[issuer_icp, Input::mint(&byte.provenance)]);
|
||||
let (early, _) = transfer(&carol, &id, id.0, issuer.aid, 1);
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,491 @@
|
|||
//! Governance, burns, and bytes moving between clusters.
|
||||
|
||||
use smart_byte_core::keys::IndexedSignature;
|
||||
use smart_byte_core::{Aid, Cargo, Envelope, JouleCost, Said, SigningKey};
|
||||
use smart_byte_keri_witness::{Controller, KeyEvent, KeyPair, Threshold};
|
||||
use smart_byte_state::certificate::node_key;
|
||||
use smart_byte_state::{Approval, Burn, Certificate, Change, Input, Proposal, State};
|
||||
|
||||
fn key(label: &str) -> KeyPair {
|
||||
KeyPair::from_signing(SigningKey::from_bytes(&Said::hash(label.as_bytes()).0))
|
||||
}
|
||||
|
||||
/// A one-key controller and the signed events of its log so far.
|
||||
struct Party {
|
||||
c: Controller,
|
||||
log: Vec<(KeyEvent, Vec<IndexedSignature>)>,
|
||||
}
|
||||
|
||||
impl Party {
|
||||
fn new(label: &str) -> Self {
|
||||
let (c, icp) = Controller::incept(
|
||||
vec![key(&format!("{label}-0"))],
|
||||
vec![key(&format!("{label}-1"))],
|
||||
Threshold(1),
|
||||
Threshold(1),
|
||||
vec![],
|
||||
Threshold(0),
|
||||
)
|
||||
.unwrap();
|
||||
let event = KeyEvent::Inception(icp);
|
||||
let sigs = sigs(&c, &event);
|
||||
Party {
|
||||
c,
|
||||
log: vec![(event, sigs)],
|
||||
}
|
||||
}
|
||||
fn aid(&self) -> Aid {
|
||||
self.c.aid
|
||||
}
|
||||
fn incept(&self) -> Vec<u8> {
|
||||
Input::key_event(&self.log[0].0, &self.log[0].1)
|
||||
}
|
||||
fn approve(&self, proposal: &Proposal) -> Approval {
|
||||
Approval {
|
||||
approver: self.aid(),
|
||||
signature: self
|
||||
.c
|
||||
.signer()
|
||||
.sign(&smart_byte_core::wire::to_vec(proposal).unwrap()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sigs(c: &Controller, event: &KeyEvent) -> Vec<IndexedSignature> {
|
||||
c.sign_event(event)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(index, signature)| IndexedSignature {
|
||||
index: u64::from(index),
|
||||
signature,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn classes(report: &smart_byte_state::FrameReport) -> Vec<Result<(), &'static str>> {
|
||||
report
|
||||
.results
|
||||
.iter()
|
||||
.map(|r| r.outcome.as_ref().map(|_| ()).map_err(|e| e.class()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sorted(mut v: Vec<Result<(), &'static str>>) -> Vec<Result<(), &'static str>> {
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
const USD: u64 = 1;
|
||||
|
||||
fn cluster(label: &str) -> Said {
|
||||
Said::hash(label.as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_issuer_is_added_by_the_others_and_only_after_the_delay() {
|
||||
let (a, b, c, new) = (
|
||||
Party::new("a"),
|
||||
Party::new("b"),
|
||||
Party::new("c"),
|
||||
Party::new("new"),
|
||||
);
|
||||
let id = cluster("one");
|
||||
let mut state = State::genesis(id, [(a.aid(), USD), (b.aid(), USD), (c.aid(), USD)], [], 2);
|
||||
state.apply_frame(&[a.incept(), b.incept(), c.incept(), new.incept()]);
|
||||
|
||||
let proposal = Proposal {
|
||||
cluster: id,
|
||||
sequence: 0,
|
||||
change: Change::AddIssuer {
|
||||
cargo_type: USD,
|
||||
issuer: new.aid(),
|
||||
},
|
||||
};
|
||||
// Three issuers: the quorum is floor(2·3/3)+1 = 3.
|
||||
let two = Input::governance(&proposal, &[a.approve(&proposal), b.approve(&proposal)]);
|
||||
let three = Input::governance(
|
||||
&proposal,
|
||||
&[
|
||||
a.approve(&proposal),
|
||||
b.approve(&proposal),
|
||||
c.approve(&proposal),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
classes(&state.apply_frame(&[two])),
|
||||
vec![Err("below_quorum")]
|
||||
);
|
||||
assert_eq!(classes(&state.apply_frame(&[three.clone()])), vec![Ok(())]);
|
||||
assert_eq!(state.governance.pending.len(), 1);
|
||||
assert_eq!(state.governance.pending[0].effective, 2 + 1 + 2);
|
||||
|
||||
// The same proposal again: its sequence number is spent.
|
||||
assert_eq!(
|
||||
classes(&state.apply_frame(&[three])),
|
||||
vec![Err("stale_sequence")]
|
||||
);
|
||||
|
||||
let mint = |label: &str, frame_note: &str| {
|
||||
let e = Envelope::issue(
|
||||
&new.c.signer(),
|
||||
a.aid(),
|
||||
[frame_note.len() as u8; 16],
|
||||
Cargo::usd_micros(1),
|
||||
JouleCost::default(),
|
||||
);
|
||||
let _ = label;
|
||||
Input::mint(&e.provenance)
|
||||
};
|
||||
// Frame 4: not yet in force.
|
||||
assert_eq!(state.frame, 4);
|
||||
assert_eq!(
|
||||
classes(&state.apply_frame(&[mint("early", "x")])),
|
||||
vec![Err("issuer_not_registered")]
|
||||
);
|
||||
// Frame 5: in force from its start.
|
||||
assert_eq!(
|
||||
classes(&state.apply_frame(&[mint("in force", "xy")])),
|
||||
vec![Ok(())]
|
||||
);
|
||||
assert!(state.authorizes(&new.aid(), USD) && state.governance.pending.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn governance_is_checked_before_it_is_counted() {
|
||||
let (a, b, outsider) = (Party::new("a"), Party::new("b"), Party::new("outsider"));
|
||||
let id = cluster("one");
|
||||
let mut state = State::genesis(id, [(a.aid(), USD), (b.aid(), USD)], [], 0);
|
||||
state.apply_frame(&[a.incept(), b.incept(), outsider.incept()]);
|
||||
let p = |change: Change, sequence: u64, cluster: Said| Proposal {
|
||||
cluster,
|
||||
sequence,
|
||||
change,
|
||||
};
|
||||
let add = |issuer: Aid| Change::AddIssuer {
|
||||
cargo_type: USD,
|
||||
issuer,
|
||||
};
|
||||
let remove = |issuer: Aid| Change::RemoveIssuer {
|
||||
cargo_type: USD,
|
||||
issuer,
|
||||
};
|
||||
|
||||
let elsewhere = p(add(outsider.aid()), 0, cluster("two"));
|
||||
let stale = p(add(outsider.aid()), 7, id);
|
||||
let registered = p(add(a.aid()), 0, id);
|
||||
let unregistered = p(remove(outsider.aid()), 0, id);
|
||||
let by_outsider = p(add(outsider.aid()), 0, id);
|
||||
let twice = p(add(outsider.aid()), 0, id);
|
||||
let report = state.apply_frame(&[
|
||||
Input::governance(&elsewhere, &[a.approve(&elsewhere), b.approve(&elsewhere)]),
|
||||
Input::governance(&stale, &[a.approve(&stale), b.approve(&stale)]),
|
||||
Input::governance(
|
||||
®istered,
|
||||
&[a.approve(®istered), b.approve(®istered)],
|
||||
),
|
||||
Input::governance(
|
||||
&unregistered,
|
||||
&[a.approve(&unregistered), b.approve(&unregistered)],
|
||||
),
|
||||
Input::governance(
|
||||
&by_outsider,
|
||||
&[a.approve(&by_outsider), outsider.approve(&by_outsider)],
|
||||
),
|
||||
Input::governance(&twice, &[a.approve(&twice), a.approve(&twice)]),
|
||||
]);
|
||||
assert_eq!(
|
||||
sorted(classes(&report)),
|
||||
sorted(vec![
|
||||
Err("wrong_cluster"),
|
||||
Err("stale_sequence"),
|
||||
Err("already_registered"),
|
||||
Err("not_registered"),
|
||||
Err("not_an_elector"),
|
||||
Err("duplicate_approver"),
|
||||
])
|
||||
);
|
||||
assert_eq!(state.governance.sequence, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_issuer_cannot_block_its_own_removal() {
|
||||
let (a, b, c) = (Party::new("a"), Party::new("b"), Party::new("c"));
|
||||
let id = cluster("one");
|
||||
let mut state = State::genesis(id, [(a.aid(), USD), (b.aid(), USD), (c.aid(), USD)], [], 0);
|
||||
state.apply_frame(&[a.incept(), b.incept(), c.incept()]);
|
||||
let proposal = Proposal {
|
||||
cluster: id,
|
||||
sequence: 0,
|
||||
change: Change::RemoveIssuer {
|
||||
cargo_type: USD,
|
||||
issuer: c.aid(),
|
||||
},
|
||||
};
|
||||
// The electorate is the other two; both approve. The target's vote is
|
||||
// not counted, and not needed.
|
||||
let with_target = Input::governance(&proposal, &[a.approve(&proposal), c.approve(&proposal)]);
|
||||
assert_eq!(
|
||||
classes(&state.apply_frame(&[with_target])),
|
||||
vec![Err("not_an_elector")]
|
||||
);
|
||||
let others = Input::governance(&proposal, &[a.approve(&proposal), b.approve(&proposal)]);
|
||||
assert_eq!(classes(&state.apply_frame(&[others])), vec![Ok(())]);
|
||||
state.apply_frame(&[]);
|
||||
assert!(!state.authorizes(&c.aid(), USD));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redemption_is_a_return_to_the_issuer_and_a_burn() {
|
||||
let (issuer, alice) = (Party::new("issuer"), Party::new("alice"));
|
||||
let mut state = State::genesis(cluster("one"), [(issuer.aid(), USD)], [], 0);
|
||||
let byte = Envelope::issue(
|
||||
&issuer.c.signer(),
|
||||
alice.aid(),
|
||||
[1; 16],
|
||||
Cargo::usd_micros(5),
|
||||
JouleCost::default(),
|
||||
);
|
||||
let mint = Input::mint(&byte.provenance);
|
||||
state.apply_frame(&[issuer.incept(), alice.incept(), mint.clone()]);
|
||||
let id = byte.identity();
|
||||
|
||||
// Alice returns it; the issuer burns it in the same frame: burns apply
|
||||
// after transfers.
|
||||
let back = byte.transfer(&alice.c.signer(), issuer.aid(), 1);
|
||||
let t = back.ownership_chain.last().unwrap();
|
||||
let burn = Burn::sign(&issuer.c.signer(), id, back.history_hash(), 1);
|
||||
let by_alice = Burn::sign(&alice.c.signer(), id, back.history_hash(), 1);
|
||||
let report = state.apply_frame(&[
|
||||
Input::burn(&burn),
|
||||
Input::burn(&by_alice),
|
||||
Input::transfer(&id, t),
|
||||
]);
|
||||
assert_eq!(
|
||||
sorted(classes(&report)),
|
||||
sorted(vec![Ok(()), Ok(()), Err("not_current_owner")])
|
||||
);
|
||||
assert!(state.bytes.is_empty());
|
||||
assert_eq!(state.retired[&id].owner, issuer.aid());
|
||||
|
||||
// Burned for good: no transfer, no re-mint.
|
||||
let after = Burn::sign(&issuer.c.signer(), id, back.history_hash(), 2);
|
||||
assert_eq!(
|
||||
sorted(classes(&state.apply_frame(&[Input::burn(&after), mint]))),
|
||||
vec![Err("byte_retired"), Err("duplicate_byte")]
|
||||
);
|
||||
}
|
||||
|
||||
/// Two clusters, each with four nodes, peering with each other.
|
||||
struct Federation {
|
||||
a: State,
|
||||
b: State,
|
||||
a_nodes: Vec<SigningKey>,
|
||||
b_nodes: Vec<SigningKey>,
|
||||
}
|
||||
|
||||
fn federation(registry: &[(Aid, u64)]) -> Federation {
|
||||
let nodes = |label: &str| {
|
||||
(0..4)
|
||||
.map(|i| SigningKey::from_bytes(&Said::hash(format!("{label}-node-{i}").as_bytes()).0))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let (a_nodes, b_nodes) = (nodes("a"), nodes("b"));
|
||||
let keys = |n: &[SigningKey]| n.iter().map(node_key).collect::<Vec<_>>();
|
||||
Federation {
|
||||
a: State::genesis(
|
||||
cluster("A"),
|
||||
registry.iter().copied(),
|
||||
[(cluster("B"), keys(&b_nodes))],
|
||||
0,
|
||||
),
|
||||
b: State::genesis(
|
||||
cluster("B"),
|
||||
registry.iter().copied(),
|
||||
[(cluster("A"), keys(&a_nodes))],
|
||||
0,
|
||||
),
|
||||
a_nodes,
|
||||
b_nodes,
|
||||
}
|
||||
}
|
||||
|
||||
fn certify(
|
||||
statement: smart_byte_state::ExportStatement,
|
||||
nodes: &[SigningKey],
|
||||
signers: &[u64],
|
||||
) -> Certificate {
|
||||
let picked: Vec<(u64, &SigningKey)> =
|
||||
signers.iter().map(|i| (*i, &nodes[*i as usize])).collect();
|
||||
Certificate::sign(statement, &picked)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_byte_moves_between_clusters_and_back() {
|
||||
let (issuer, alice, bob) = (Party::new("issuer"), Party::new("alice"), Party::new("bob"));
|
||||
let mut f = federation(&[(issuer.aid(), USD)]);
|
||||
let byte = Envelope::issue(
|
||||
&issuer.c.signer(),
|
||||
alice.aid(),
|
||||
[1; 16],
|
||||
Cargo::usd_micros(5),
|
||||
JouleCost::default(),
|
||||
);
|
||||
let id = byte.identity();
|
||||
f.a.apply_frame(&[
|
||||
issuer.incept(),
|
||||
alice.incept(),
|
||||
Input::mint(&byte.provenance),
|
||||
]);
|
||||
f.b.apply_frame(&[bob.incept()]);
|
||||
|
||||
// Alice sends it to Bob, in cluster B.
|
||||
let out = byte.export(&alice.c.signer(), bob.aid(), 1, cluster("B"));
|
||||
let report =
|
||||
f.a.apply_frame(&[Input::transfer(&id, out.ownership_chain.last().unwrap())]);
|
||||
assert_eq!(classes(&report), vec![Ok(())]);
|
||||
assert!(f.a.bytes.is_empty() && f.a.exported.contains_key(&id));
|
||||
let statement = report.exports[0].clone();
|
||||
// A transfer of a byte that has left is refused.
|
||||
let stale = byte.transfer(&alice.c.signer(), issuer.aid(), 2);
|
||||
assert_eq!(
|
||||
classes(&f.a.apply_frame(&[Input::transfer(&id, stale.ownership_chain.last().unwrap())])),
|
||||
vec![Err("byte_exported")]
|
||||
);
|
||||
|
||||
let logs = vec![issuer.log.clone(), alice.log.clone()];
|
||||
let import = |cert: &Certificate, logs: &[Vec<(KeyEvent, Vec<IndexedSignature>)>]| {
|
||||
Input::import(cert, &out, logs)
|
||||
};
|
||||
let three = certify(statement.clone(), &f.a_nodes, &[0, 1, 2]);
|
||||
let two = certify(statement.clone(), &f.a_nodes, &[0, 2]);
|
||||
let missing_log = import(&three, &logs[..1]);
|
||||
let report = f.b.apply_frame(&[import(&two, &logs), missing_log]);
|
||||
assert_eq!(
|
||||
sorted(classes(&report)),
|
||||
vec![Err("below_threshold"), Err("unknown_key_state")]
|
||||
);
|
||||
// With three of A's four nodes and every log the history needs: applied.
|
||||
assert_eq!(
|
||||
classes(&f.b.apply_frame(&[import(&three, &logs)])),
|
||||
vec![Ok(())]
|
||||
);
|
||||
assert_eq!(f.b.bytes[&id].owner, bob.aid());
|
||||
assert_eq!(f.b.bytes[&id].history_hash, out.history_hash());
|
||||
// Once.
|
||||
assert_eq!(
|
||||
classes(&f.b.apply_frame(&[import(&three, &logs)])),
|
||||
vec![Err("duplicate_import")]
|
||||
);
|
||||
|
||||
// Bob sends it back to Alice in A; A takes it back over its own export.
|
||||
let home = out.export(&bob.c.signer(), alice.aid(), f.b.frame, cluster("A"));
|
||||
let report =
|
||||
f.b.apply_frame(&[Input::transfer(&id, home.ownership_chain.last().unwrap())]);
|
||||
assert_eq!(classes(&report), vec![Ok(())]);
|
||||
let cert = certify(report.exports[0].clone(), &f.b_nodes, &[1, 2, 3]);
|
||||
let logs = vec![issuer.log.clone(), alice.log.clone(), bob.log.clone()];
|
||||
assert_eq!(
|
||||
classes(&f.a.apply_frame(&[Input::import(&cert, &home, &logs)])),
|
||||
vec![Ok(())]
|
||||
);
|
||||
assert!(f.a.bytes.contains_key(&id) && !f.a.exported.contains_key(&id));
|
||||
// A's first certificate, replayed in A, is not for A.
|
||||
assert_eq!(
|
||||
classes(&f.a.apply_frame(&[import(&three, &[issuer.log.clone(), alice.log.clone()])])),
|
||||
vec![Err("wrong_cluster")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_import_must_be_the_byte_certified() {
|
||||
let (issuer, alice, bob) = (Party::new("issuer"), Party::new("alice"), Party::new("bob"));
|
||||
let mut f = federation(&[(issuer.aid(), USD)]);
|
||||
let byte = Envelope::issue(
|
||||
&issuer.c.signer(),
|
||||
alice.aid(),
|
||||
[1; 16],
|
||||
Cargo::usd_micros(5),
|
||||
JouleCost::default(),
|
||||
);
|
||||
let id = byte.identity();
|
||||
f.a.apply_frame(&[
|
||||
issuer.incept(),
|
||||
alice.incept(),
|
||||
Input::mint(&byte.provenance),
|
||||
]);
|
||||
let out = byte.export(&alice.c.signer(), bob.aid(), 1, cluster("B"));
|
||||
let report =
|
||||
f.a.apply_frame(&[Input::transfer(&id, out.ownership_chain.last().unwrap())]);
|
||||
let cert = certify(report.exports[0].clone(), &f.a_nodes, &[0, 1, 2]);
|
||||
let logs = vec![issuer.log.clone(), alice.log.clone()];
|
||||
// The envelope before the export: not the history A certified.
|
||||
let report = f.b.apply_frame(&[Input::import(&cert, &byte, &logs)]);
|
||||
assert_eq!(classes(&report), vec![Err("certificate_mismatch")]);
|
||||
// A certificate from a cluster B does not peer with.
|
||||
let mut stranger = f.b.clone();
|
||||
stranger.peers.clear();
|
||||
assert_eq!(
|
||||
classes(&stranger.apply_frame(&[Input::import(&cert, &out, &logs)])),
|
||||
vec![Err("unknown_cluster")]
|
||||
);
|
||||
// An export to a cluster A does not peer with is refused in A.
|
||||
let lost = byte.export(&alice.c.signer(), bob.aid(), 2, cluster("nowhere"));
|
||||
let mut a = f.a.clone();
|
||||
a.exported.clear();
|
||||
a.bytes.insert(
|
||||
id,
|
||||
smart_byte_state::ByteRecord {
|
||||
issuer: issuer.aid(),
|
||||
cargo: Cargo::usd_micros(5),
|
||||
minted: 0,
|
||||
owner: alice.aid(),
|
||||
history_hash: id.0,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
classes(&a.apply_frame(&[Input::transfer(&id, lost.ownership_chain.last().unwrap())])),
|
||||
vec![Err("unknown_cluster")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peers_change_by_governance() {
|
||||
let (a, b) = (Party::new("a"), Party::new("b"));
|
||||
let id = cluster("one");
|
||||
let mut state = State::genesis(id, [(a.aid(), USD), (b.aid(), 2)], [], 0);
|
||||
state.apply_frame(&[a.incept(), b.incept()]);
|
||||
let node = SigningKey::from_bytes(&[9; 32]);
|
||||
let proposal = Proposal {
|
||||
cluster: id,
|
||||
sequence: 0,
|
||||
change: Change::SetPeer {
|
||||
peer: cluster("two"),
|
||||
keys: vec![node_key(&node)],
|
||||
},
|
||||
};
|
||||
// Peer changes are every issuer's business: both must approve.
|
||||
let one = Input::governance(&proposal, &[a.approve(&proposal)]);
|
||||
let both = Input::governance(&proposal, &[a.approve(&proposal), b.approve(&proposal)]);
|
||||
assert_eq!(
|
||||
classes(&state.apply_frame(&[one])),
|
||||
vec![Err("below_quorum")]
|
||||
);
|
||||
assert_eq!(classes(&state.apply_frame(&[both])), vec![Ok(())]);
|
||||
state.apply_frame(&[]);
|
||||
assert!(state.peers.contains_key(&cluster("two")));
|
||||
// A peer's keys must be keys.
|
||||
let bad = Proposal {
|
||||
cluster: id,
|
||||
sequence: 1,
|
||||
change: Change::SetPeer {
|
||||
peer: cluster("three"),
|
||||
keys: vec![smart_byte_core::PrefixedKey(vec![1, 2, 3])],
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
classes(
|
||||
&state.apply_frame(&[Input::governance(&bad, &[a.approve(&bad), b.approve(&bad)])])
|
||||
),
|
||||
vec![Err("malformed_verifying_key")]
|
||||
);
|
||||
}
|
||||
|
|
@ -6,13 +6,19 @@
|
|||
|
||||
## 1. What this section fixes
|
||||
|
||||
Treatise [§9.4](./treatise_v1_parts_I_II_III_combined.md) describes a frame as six steps. A node collects inputs, exchanges them with its peers, orders them, applies them to its state, hashes the result, and commits when a supermajority reports the same hash. §15 adds live issuance and §16 adds persistence.
|
||||
Treatise [§9.4](./treatise_v1_parts_I_II_III_combined.md) describes a frame as six steps. A node collects inputs, exchanges them with its peers, orders them, applies them to its state, hashes the result, and commits when a supermajority reports the same hash. Other sections add to this:
|
||||
|
||||
- §15 adds live issuance, and issuer governance.
|
||||
- §11.3.3 adds transfers between clusters.
|
||||
- §16 adds persistence.
|
||||
|
||||
This section fixes what those sections leave open:
|
||||
|
||||
- what the state is, byte for byte;
|
||||
- what each kind of input is;
|
||||
- the order inputs apply in, and every check each one must pass;
|
||||
- how the registry and the peers change;
|
||||
- how a byte is burned, and how it moves between clusters;
|
||||
- the hash a frame commits at.
|
||||
|
||||
The supermajority commit itself is treatise §13. Encodings are canonical CBOR as [identity spec §2.3](./identity_and_key_rotation.md) defines it. Envelopes, provenances and transitions are as the [envelope wire format](./envelope_wire_format.md) defines them.
|
||||
|
|
@ -24,14 +30,26 @@ The supermajority commit itself is treatise §13. Encodings are canonical CBOR a
|
|||
```
|
||||
{
|
||||
"frame": uint: the next frame to apply,
|
||||
"cluster": SAID: this cluster's identifier,
|
||||
"peers": {cluster: {"keys": [bytes]}},
|
||||
"registry": {issuer AID: [cargo type, ...]},
|
||||
"governance": {"delay": uint, "sequence": uint, "pending": [{"change", "effective"}, ...]},
|
||||
"controllers": {AID: controller record},
|
||||
"bytes": {identity: byte record}
|
||||
"bytes": {identity: byte record},
|
||||
"retired": {identity: retired record},
|
||||
"exported": {identity: export record},
|
||||
"imported": [32 bytes, ...]
|
||||
}
|
||||
```
|
||||
|
||||
- **`frame`** is 0 at genesis. Applying frame *n* leaves *n* + 1.
|
||||
- **`cluster`** is the cluster's identifier, chosen at genesis: conventionally the SAID of its founding membership.
|
||||
- **`peers`** maps each cluster this one exchanges bytes with to the keys of that cluster's nodes, each with its algorithm identifier (`01` for Ed25519). A cluster is never its own peer.
|
||||
- **`registry`** maps each issuer, by controller AID, to the cargo types it may mint, in ascending order with each listed once. This is treatise §15.1's issuer registry.
|
||||
- **`governance`** holds:
|
||||
- the frames a change waits before it takes effect (`delay`);
|
||||
- the sequence number the next proposal must carry;
|
||||
- the accepted changes not yet in effect, in the order they were accepted (§4.5).
|
||||
- **`controllers`** holds a record for every controller whose key-event log the cluster has applied:
|
||||
|
||||
```
|
||||
|
|
@ -52,18 +70,28 @@ The supermajority commit itself is treatise §13. Encodings are canonical CBOR a
|
|||
{
|
||||
"issuer": AID: the issuer that minted it,
|
||||
"cargo": cargo, as the issuer attested it (envelope wire format §6),
|
||||
"minted": uint: the frame it was minted in,
|
||||
"minted": uint: the frame it was minted in, in the cluster that minted it,
|
||||
"owner": AID: its current owner,
|
||||
"history_hash": 32 bytes: its current history hash (envelope wire format §5)
|
||||
}
|
||||
```
|
||||
|
||||
- **`retired`** holds every byte burned here (§4.7): the byte record as it was, plus `"retired"`, the frame it was burned in.
|
||||
- **`exported`** holds every byte that left for another cluster and has not come back (§5). The record holds:
|
||||
- the byte's issuer, cargo and mint frame;
|
||||
- its owner and history hash as it left;
|
||||
- `"destination"`, the cluster it left for;
|
||||
- `"exported"`, the frame it left in.
|
||||
- **`imported`** lists, in ascending order, the digests of the export certificates this cluster has imported (§5).
|
||||
|
||||
Every map above has exactly the members shown. The encoding is canonical: map keys sort by their encoded bytes. So two nodes holding the same state write the same bytes, whatever order they hold it in.
|
||||
|
||||
A burned or exported byte's identity stays in the state. So the cluster never mints that identity again, and never moves it, except to take back an exported byte that returns (§5).
|
||||
|
||||
The state holds a byte's current owner and history hash, not its envelope. Holders keep their own envelopes, and the cluster vouches for the current state (treatise §10.3). Two other things are not consensus state:
|
||||
|
||||
- **The dissent log** is kept by each node for itself (treatise §14).
|
||||
- **Joule cost** is reported beside the state, not in it (§7).
|
||||
- **Joule cost** is reported beside the state, not in it (§8).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -72,14 +100,23 @@ The state holds a byte's current owner and history hash, not its envelope. Holde
|
|||
An input is the canonical encoding of a map with one member, which names the input's class:
|
||||
|
||||
```
|
||||
{"key_event": {"event": key event, "signatures": [{"index": uint, "signature": bytes}, ...]}}
|
||||
{"mint": provenance}
|
||||
{"transfer": {"byte": identity, "transition": transition}}
|
||||
{"key_event": {"event": key event, "signatures": [{"index": uint, "signature": bytes}, ...]}}
|
||||
{"governance": {"proposal": proposal, "approvals": [{"approver": AID, "signature": controller signature}, ...]}}
|
||||
{"mint": provenance}
|
||||
{"import": {"certificate": certificate, "envelope": envelope, "logs": [[signed event, ...], ...]}}
|
||||
{"transfer": {"byte": identity, "transition": transition}}
|
||||
{"burn": {"byte": identity, "previous_history_hash": 32 bytes, "owner": AID, "frame": uint, "signature": controller signature}}
|
||||
```
|
||||
|
||||
- **`key_event`** carries one event of a controller's key-event log (identity spec §3.3), and the signatures over its canonical encoding (§3.5).
|
||||
- **`governance`** carries a proposed change and the issuers' approvals of it (§4.5).
|
||||
- **`mint`** is an issuer's provenance (envelope wire format §3). Its `d` is the new byte's identity.
|
||||
- **`transfer`** is a transition (envelope wire format §5) of the byte that `byte` names.
|
||||
- **`import`** brings a byte from another cluster. It carries:
|
||||
- the source cluster's certificate;
|
||||
- the byte's envelope;
|
||||
- the key-event logs that verify its history: each log is the signed events `{"event", "signatures"}` of one controller, from its inception (§5).
|
||||
- **`transfer`** is a transition (envelope wire format §5) of the byte that `byte` names. A transition with a `destination` moves the byte to another cluster (§5).
|
||||
- **`burn`** is an owner's retirement of a byte (§4.7).
|
||||
|
||||
An input's **digest** is the BLAKE3-256 of its bytes.
|
||||
|
||||
|
|
@ -89,22 +126,30 @@ An input's **digest** is the BLAKE3-256 of its bytes.
|
|||
|
||||
A node applies a frame's inputs to a state whose `frame` is *F*:
|
||||
|
||||
1. **Duplicates.** Identical inputs, byte for byte, are one input.
|
||||
2. **Order.** Inputs apply by class: key events first, then mints, then transfers. Inputs that name no class come last. Within a class, inputs apply in ascending bytewise order of their digests (treatise §9.4 step 4).
|
||||
3. **Application.** Each input is checked against the state as the inputs before it left it. An input that fails a check is **dropped**. It changes nothing, and every honest node drops the same inputs. The checks, in order, are given in the subsections below.
|
||||
4. **Advance.** `frame` becomes *F* + 1.
|
||||
1. **Governance.** Pending changes whose `effective` frame is *F* take effect, in the order they were accepted (§4.5), and leave `pending`.
|
||||
2. **Duplicates.** Identical inputs, byte for byte, are one input.
|
||||
3. **Order.** Inputs apply by class: key events, governance, mints, imports, transfers, burns. Inputs that name no class come last. Within a class, inputs apply in ascending bytewise order of their digests (treatise §9.4 step 4).
|
||||
4. **Application.** Each input is checked against the state as the inputs before it left it. An input that fails a check is **dropped**. It changes nothing, and every honest node drops the same inputs. The checks, in order, are given in the subsections below.
|
||||
5. **Advance.** `frame` becomes *F* + 1.
|
||||
|
||||
Key events apply first so that a rotation retires its old keys before anything signed with them is checked. Mints apply before transfers so that a byte can be minted and transferred in one frame (treatise §15.3).
|
||||
The class order has reasons:
|
||||
|
||||
- Key events apply first, so that a rotation retires its old keys before anything signed with them is checked.
|
||||
- Mints and imports apply before transfers, so that a byte can arrive and move in one frame (treatise §15.3).
|
||||
- Burns apply last, so that a byte can be returned to its issuer and burned in one frame.
|
||||
|
||||
### 4.1 Decoding
|
||||
|
||||
For every input:
|
||||
|
||||
- **The bytes are canonical** (identity spec §2.3). The canonical-encoding classes apply.
|
||||
- **They are a map with one text member**, naming `key_event`, `mint` or `transfer`. Otherwise: `malformed_input`.
|
||||
- **They are a map with one text member**, naming one of the six classes. Otherwise: `malformed_input`.
|
||||
- **The member's value is exactly its class's form,** with each part in its own encoding:
|
||||
- A `key_event` holds exactly `event` and `signatures`. The signatures are an array of `{index, signature}` (`malformed_input` otherwise). The event is then decoded as identity spec §4 step 1 decodes one, with that step's error classes: `rec` and `dip` are refused before anything else is read.
|
||||
- A `mint` holds exactly a provenance, and a `transfer` exactly `{byte, transition}`. Otherwise: `malformed_input`.
|
||||
- A `key_event` holds exactly `event` and `signatures`. The signatures are an array of `{index, signature}`. The event is then decoded as identity spec §4 step 1 decodes one, with that step's error classes: `rec` and `dip` are refused before anything else is read.
|
||||
- An `import` holds exactly `certificate`, `envelope` and `logs`. The certificate must have exactly its form, and the logs must be arrays of `{event, signatures}`. The envelope and the logged events are decoded when the import is applied (§5).
|
||||
- A `governance`, `mint`, `transfer` or `burn` input holds exactly its body.
|
||||
|
||||
Any other shape: `malformed_input`.
|
||||
|
||||
### 4.2 Key events
|
||||
|
||||
|
|
@ -117,8 +162,8 @@ An accepted event replaces the controller's record with the key state it establi
|
|||
|
||||
1. The provenance's `d` is its SAID. Otherwise: `said_mismatch`.
|
||||
2. The registry lists the issuer for the cargo's type. Otherwise: `issuer_not_registered`.
|
||||
3. The issuer's signature passes the signature check of §4.5, over the attested fields (envelope wire format §3).
|
||||
4. The cluster holds no byte with this identity. Otherwise: `duplicate_byte`.
|
||||
3. The issuer's signature passes the signature check of §4.8, over the attested fields (envelope wire format §3).
|
||||
4. The cluster has never held a byte with this identity — live, retired or exported. Otherwise: `duplicate_byte`.
|
||||
|
||||
The byte is then held with these values:
|
||||
|
||||
|
|
@ -132,19 +177,81 @@ The byte is then held with these values:
|
|||
|
||||
### 4.4 Transfers
|
||||
|
||||
1. The cluster holds the byte. Otherwise: `unknown_byte`.
|
||||
1. The cluster holds the byte. Otherwise:
|
||||
- `byte_retired`, if it was burned;
|
||||
- `byte_exported`, if it left;
|
||||
- `unknown_byte`, if neither.
|
||||
2. The transition's `frame` is *F*. Otherwise: `wrong_frame`.
|
||||
3. Its `previous_history_hash` is the byte's history hash. Otherwise: `broken_history_chain`.
|
||||
4. Its `from` is the byte's owner. Otherwise: `not_current_owner`.
|
||||
5. The owner's signature passes the signature check of §4.5, over the transition's signed fields (envelope wire format §5).
|
||||
5. A `destination`, if the transition has one, is a peer. Otherwise: `unknown_cluster`.
|
||||
6. The owner's signature passes the signature check of §4.8, over the transition's signed fields (envelope wire format §5).
|
||||
|
||||
The byte's owner becomes `to`, and its history hash advances (envelope wire format §5).
|
||||
A transition with no `destination` makes `to` the byte's owner and advances its history hash. One with a `destination` exports the byte (§5).
|
||||
|
||||
A byte's history hash moves with every transfer. Two transfers of one byte in one frame therefore cannot both apply: the second continues a history that has moved on (`broken_history_chain`). So a byte moves at most once a frame, as treatise §8.4 requires.
|
||||
|
||||
### 4.5 Signatures
|
||||
### 4.5 Governance
|
||||
|
||||
A mint or transfer signature passes when all of the following hold:
|
||||
A proposal is:
|
||||
|
||||
```
|
||||
{"cluster": SAID, "sequence": uint, "change": change}
|
||||
```
|
||||
|
||||
The change is one of:
|
||||
|
||||
```
|
||||
{"action": "add_issuer", "cargo_type": uint, "issuer": AID}
|
||||
{"action": "remove_issuer", "cargo_type": uint, "issuer": AID}
|
||||
{"action": "set_peer", "peer": SAID, "keys": [bytes]}
|
||||
{"action": "remove_peer", "peer": SAID}
|
||||
```
|
||||
|
||||
Each approval is an issuer's controller signature over the proposal's canonical encoding. The checks, in order:
|
||||
|
||||
1. The proposal's `cluster` is this one. Otherwise: `wrong_cluster`.
|
||||
2. Its `sequence` is the governance sequence number. Otherwise: `stale_sequence`.
|
||||
3. The change makes sense:
|
||||
- **`add_issuer`:** the issuer is not registered for the type. Otherwise: `already_registered`.
|
||||
- **`remove_issuer`:** it is registered. Otherwise: `not_registered`.
|
||||
- **`set_peer`:** the peer is not this cluster (`wrong_cluster`). Its keys are at least one (`invalid_threshold`), none twice (`duplicate_key`), each an Ed25519 key (`malformed_verifying_key`).
|
||||
- **`remove_peer`:** the cluster is a peer. Otherwise: `not_a_peer`.
|
||||
4. The change has an **electorate**: the issuers who may approve it.
|
||||
- **Registry changes:** the issuers registered for its cargo type. When no issuer is registered for the type, the electorate is every issuer.
|
||||
- **Peer changes:** every issuer.
|
||||
- **Removals:** an issuer being removed is never in the electorate, so it cannot block its own removal.
|
||||
|
||||
If the electorate is empty: `no_electorate`.
|
||||
5. Each approval, in order, is by a member of the electorate (`not_an_elector`). No member approves twice (`duplicate_approver`). The approval's signature passes the signature check of §4.8.
|
||||
6. The approvals number at least ⌊2*m*/3⌋+1, for an electorate of *m*. Otherwise: `below_quorum`.
|
||||
|
||||
An accepted proposal advances the governance sequence number, so it can never be applied twice. Its change joins `pending` with `effective` *F* + 1 + `delay`. The change takes effect at the start of that frame (§4 step 1):
|
||||
|
||||
- an issuer is added to or removed from its cargo type, and an issuer left with no types leaves the registry;
|
||||
- a peer is set or removed.
|
||||
|
||||
The delay is the time-lock treatise §15.5 asks for. Everyone can see an accepted change before it takes effect.
|
||||
|
||||
### 4.6 Imports
|
||||
|
||||
See §5.
|
||||
|
||||
### 4.7 Burns
|
||||
|
||||
1. The cluster holds the byte, exactly as for a transfer (§4.4 step 1).
|
||||
2. The burn's `frame` is *F*. Otherwise: `wrong_frame`.
|
||||
3. Its `previous_history_hash` is the byte's history hash. Otherwise: `broken_history_chain`.
|
||||
4. Its `owner` is the byte's owner. Otherwise: `not_current_owner`.
|
||||
5. The owner's signature passes the signature check of §4.8, over the canonical encoding of `{"burn": identity, "previous_history_hash", "owner", "frame"}`.
|
||||
|
||||
The byte moves from `bytes` to `retired`, with the frame it was burned in.
|
||||
|
||||
Redemption (treatise §15.4) is a transfer to the issuer followed by the issuer's burn. Both can happen in one frame.
|
||||
|
||||
### 4.8 Signatures
|
||||
|
||||
A signature on a mint, transfer, burn or approval passes when all of the following hold:
|
||||
|
||||
1. The cluster holds the signer's key-event log. Otherwise: `unknown_controller`.
|
||||
2. The signature names the establishment event in force, the record's `establishment`. Otherwise: `establishment_not_in_force`.
|
||||
|
|
@ -154,15 +261,65 @@ A signature made under keys since rotated away still verifies offline (envelope
|
|||
|
||||
---
|
||||
|
||||
## 5. The state hash
|
||||
## 5. Transfers between clusters
|
||||
|
||||
A byte moves from cluster A to cluster B in three steps (treatise §11.3.3).
|
||||
|
||||
**Export.** In A, the owner signs a transition naming B as its `destination`, and A applies it as a transfer (§4.4). The byte leaves `bytes` for `exported`, with its new owner and history hash.
|
||||
|
||||
**Certificate.** Once the frame commits, A's committing nodes sign the canonical encoding of the **statement**:
|
||||
|
||||
```
|
||||
{"source": A, "byte": identity, "export": export record}
|
||||
```
|
||||
|
||||
A **certificate** is that statement and the nodes' signatures:
|
||||
|
||||
```
|
||||
{"statement": statement, "signatures": [{"index": uint, "signature": bytes}, ...]}
|
||||
```
|
||||
|
||||
Each signature is by node `index` in B's peer entry for A: the algorithm identifier, then the signature. A certificate's **digest** is the BLAKE3-256 of its statement.
|
||||
|
||||
**Import.** In B, the import checks, in order:
|
||||
|
||||
1. The export's `destination` is B. Otherwise: `wrong_cluster`.
|
||||
2. The `source` is a peer. Otherwise: `unknown_cluster`.
|
||||
3. The signatures are a valid controller signature over the statement (envelope wire format §7). The key state is the source's node keys, with a threshold of ⌊2*n*/3⌋+1 for *n* keys, the supermajority treatise §13.1 commits at.
|
||||
4. B has not imported this certificate before. Otherwise: `duplicate_import`.
|
||||
5. B does not hold the byte. Otherwise:
|
||||
- `duplicate_byte`, if B holds it live;
|
||||
- `byte_retired`, if B burned it.
|
||||
|
||||
A byte B exported, now returning, is allowed.
|
||||
6. The envelope decodes, with the envelope classes. Its identity, issuer, cargo, current owner and history hash must be the export's. Otherwise: `certificate_mismatch`.
|
||||
7. Each log replays as identity spec §4 describes, with that section's classes. Together they give the key states the envelope's signatures name.
|
||||
8. The envelope verifies against those key states, end to end (envelope wire format §8), with that section's classes.
|
||||
|
||||
The byte is then held in B, with the export's issuer, cargo, mint frame, owner and history hash. It leaves `exported` if it had left B before, and the certificate's digest joins `imported`.
|
||||
|
||||
So B relies on A for one thing: the byte's state at the moment it left, which A's supermajority certifies. B verifies the byte's history itself, from its envelope and its controllers' logs. That includes the issuer's attestation. Whether the issuer was registered when it minted is A's testimony, carried by the certificate (treatise §15.5).
|
||||
|
||||
---
|
||||
|
||||
## 6. The state hash
|
||||
|
||||
After a frame, each node computes the **state hash**: BLAKE3-256 of the state's canonical encoding, new frame number included (treatise §9.4 step 6). The frame commits when ⌊2*n*/3⌋+1 of the cluster's *n* nodes report the same hash (treatise §13).
|
||||
|
||||
---
|
||||
|
||||
## 6. Genesis and persistence
|
||||
## 7. Genesis and persistence
|
||||
|
||||
**Genesis.** A cluster's genesis state has frame 0, its registry, no controllers and no bytes. Genesis controllers and genesis bytes arrive as frame 0's inputs: genesis issuance is minting at frame 0 (treatise §15.4). A node seeds itself with its cluster's genesis state before it applies anything (treatise §16.3).
|
||||
**Genesis.** A cluster's genesis state has:
|
||||
|
||||
- frame 0;
|
||||
- its identifier;
|
||||
- its peers, with their nodes' keys;
|
||||
- its registry;
|
||||
- its governance delay, with sequence 0 and nothing pending;
|
||||
- no controllers, no bytes, nothing retired, exported or imported.
|
||||
|
||||
Genesis controllers and genesis bytes arrive as frame 0's inputs: genesis issuance is minting at frame 0 (treatise §15.4). A node seeds itself with its cluster's genesis state before it applies anything (treatise §16.3).
|
||||
|
||||
**Checkpoint.** A checkpoint is the state's canonical encoding, so its digest is the state hash at its frame (treatise §16.1).
|
||||
|
||||
|
|
@ -177,45 +334,54 @@ The log format is not normative. The checkpoint is, because its digest is the st
|
|||
|
||||
---
|
||||
|
||||
## 7. Where this departs from the treatise
|
||||
## 8. Where this departs from the treatise
|
||||
|
||||
**A byte's identity is its provenance's SAID, with no frame number in it.** Treatise §15.2 step 3 folds the mint frame into the identity. A mint replayed in a later frame then gets a new identity and issues a second byte: §19.3 concedes it becomes "a distinct (and possibly duplicate) byte". Here a replayed mint has an identity the cluster already holds, and is refused in every frame. The issuer's nonce keeps distinct mints distinct.
|
||||
**A byte's identity is its provenance's SAID, with no frame number in it.** Treatise §15.2 step 3 folds the mint frame into the identity. A mint replayed in a later frame then gets a new identity and issues a second byte: §19.3 concedes it becomes "a distinct (and possibly duplicate) byte". Here a replayed mint has an identity the cluster already holds, and is refused in every frame. The issuer's nonce keeps distinct mints distinct. A burned or exported identity is held as well, so a burn never reopens it.
|
||||
|
||||
**Parties are controller AIDs.** The registry names issuers by AID, and every signature is checked against the key state its controller's log has put in force. An issuer that rotates its keys keeps its registration. So the issuer key rotation that treatise §15.5 defers is a key-event log's ordinary rotation.
|
||||
|
||||
**Key events are inputs.** They form a third class, applied before mints and transfers. Treatise §9.1 lists "attestation requests" among a frame's inputs.
|
||||
**Key events and governance are inputs.** They are classes of their own, applied before mints and transfers. Treatise §9.1 lists "attestation requests" among a frame's inputs, and §15.1 asks for issuer-set changes to be inputs.
|
||||
|
||||
**Governance covers the peers as well as the registry.** Both are voted on by issuers, the cluster's governing body in §15.1.
|
||||
|
||||
**An import certificate certifies the byte's state; the history travels as its envelope.** Treatise §11.3.3 has the source cluster sign the byte's full history. Here the source signs the byte's state as it left, and the history hash in that state commits to the full history, which the destination verifies from the envelope.
|
||||
|
||||
**Joule cost is not state.** Measured energy differs from node to node (treatise §8.6), so no hash can agree on it, and the estimate function §8.6 calls for is not yet specified. A byte's joule cost travels in its envelope and in nodes' reports.
|
||||
|
||||
**Not yet specified:**
|
||||
|
||||
- changes to the registry after genesis (treatise §15.1 fixes it at genesis);
|
||||
- burning or redeeming bytes;
|
||||
- transfers between clusters (treatise §11.3.3).
|
||||
**Not specified here: cluster membership.** This section does not cover how nodes join and leave a cluster, or how clusters split and merge (treatise §11.2, §11.3.2). The state machine starts where membership is settled: a cluster's own nodes are outside its state, and its peers' nodes are in it, changed by governance.
|
||||
|
||||
---
|
||||
|
||||
## 8. Error classes
|
||||
## 9. Error classes
|
||||
|
||||
A dropped input reports one of these classes:
|
||||
|
||||
- **Decoding:** the canonical-encoding classes (identity spec §2.3) and `malformed_input`.
|
||||
- **Key events:** identity spec §4's classes, plus `controller_exists` and `unknown_controller`.
|
||||
- **Governance:**
|
||||
- `wrong_cluster`, `stale_sequence`, `already_registered`, `not_registered` and `not_a_peer`;
|
||||
- `no_electorate`, `not_an_elector`, `duplicate_approver` and `below_quorum`;
|
||||
- for a peer's keys, `invalid_threshold`, `duplicate_key` and `malformed_verifying_key`.
|
||||
- **Mints:** `said_mismatch`, `issuer_not_registered` and `duplicate_byte`.
|
||||
- **Transfers:** `unknown_byte`, `wrong_frame`, `broken_history_chain` and `not_current_owner`.
|
||||
- **Imports:**
|
||||
- `wrong_cluster`, `unknown_cluster`, `duplicate_import`, `duplicate_byte`, `byte_retired` and `certificate_mismatch`;
|
||||
- the envelope classes (envelope wire format §8);
|
||||
- the key-event-log classes.
|
||||
- **Transfers and burns:** `unknown_byte`, `byte_retired`, `byte_exported`, `wrong_frame`, `broken_history_chain`, `not_current_owner` and `unknown_cluster`.
|
||||
- **Signatures:** `unknown_controller`, `establishment_not_in_force`, and the controller-signature classes of envelope wire format §7.
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation and vectors
|
||||
## 10. Implementation and vectors
|
||||
|
||||
`smart-byte-rs/crates/smart-byte-state` implements this section:
|
||||
|
||||
- `state.rs`: the state and its hash;
|
||||
- `input.rs`: inputs;
|
||||
- `frame.rs`: the frame transition;
|
||||
- `certificate.rs`: export certificates;
|
||||
- `store.rs`: checkpoint and log.
|
||||
|
||||
`smart-byte-rs/crates/smart-byte-lockstep` runs a cluster of these state machines under the supermajority commit.
|
||||
`smart-byte-rs/crates/smart-byte-lockstep` runs a cluster of these state machines under the supermajority commit, and has the committing nodes sign each frame's export certificates.
|
||||
|
||||
The conformance vectors are `../conformance/v1/state_vectors.json`. Each one starts from a genesis state and gives every frame's inputs, the result of each input, and the state and state hash each frame reaches.
|
||||
The conformance vectors are `../conformance/v1/state_vectors.json`. Each one starts from a genesis state and gives every frame's inputs, the result of each input, and the state and state hash each frame reaches. The vectors on transfers between clusters are state-019 to state-021 and state-023. They run clusters A and B, of four nodes each, and in state-021 a peer D of three.
|
||||
|
|
|
|||
|
|
@ -62,13 +62,15 @@ The envelope's `d` is the SAID of the whole envelope map, computed per identity
|
|||
"from": the current owner's AID,
|
||||
"to": the new owner's AID,
|
||||
"frame": uint: the lockstep frame the transfer is applied in,
|
||||
"destination": SAID: the cluster the byte moves to — only on a transfer between clusters,
|
||||
"signature": from's controller signature (§7)
|
||||
}
|
||||
```
|
||||
|
||||
- `from` signs the canonical encoding of `{"byte", "previous_history_hash", "from", "to", "frame"}`, where `byte` is the identity's SAID text (treatise §8.4). The signature is therefore scoped to exactly one transition, of exactly one byte, at exactly one point in its history.
|
||||
- The **history hash** (treatise §10.1) starts as the identity's 32 digest bytes. Each transition advances it: `next = BLAKE3(previous ‖ payload)`, where `payload` is the canonical encoding of the transition's `{"from", "to", "frame", "signature"}`.
|
||||
- Frames rise strictly along the chain: a byte is transferred at most once per frame (treatise §8.4).
|
||||
- `from` signs the canonical encoding of `{"byte", "previous_history_hash", "from", "to", "frame"}`, where `byte` is the identity's SAID text (treatise §8.4). A transfer between clusters also has `"destination"` in the map it signs. The signature is therefore scoped to exactly one transition, of exactly one byte, at exactly one point in its history, and to where the byte goes.
|
||||
- The **history hash** (treatise §10.1) starts as the identity's 32 digest bytes. Each transition advances it: `next = BLAKE3(previous ‖ payload)`. The `payload` is the canonical encoding of the transition's `{"from", "to", "frame", "signature"}`, with `"destination"` included when the transition has one.
|
||||
- `destination` is present only on a transition that moves the byte to another cluster (treatise §11.3.3; cluster state spec §5). A transition within a cluster has no such member.
|
||||
- **Frames rise strictly within a cluster:** a byte is transferred at most once per frame (treatise §8.4). Each cluster counts its own frames, so the transition after one with a `destination` is in another cluster's count and is not compared with it.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -128,7 +130,7 @@ A verifier holding an envelope's bytes and its parties' key-event logs runs trea
|
|||
3. **Chain.** For each transition in order:
|
||||
- its `previous_history_hash` equals the running history hash;
|
||||
- its `from` is the current owner (the recipient, for the first transition);
|
||||
- its frame exceeds the previous transition's;
|
||||
- its frame exceeds the previous transition's, unless the previous transition named a `destination`;
|
||||
- its signature is valid.
|
||||
|
||||
The running hash then advances, and `to` becomes the owner.
|
||||
|
|
@ -142,23 +144,25 @@ Each failure has an error class, shared with the conformance pack:
|
|||
- `signature_invalid`, `below_threshold`, `duplicate_key_index`, `key_index_out_of_range`, `algorithm_mismatch`, `unsupported_algorithm`, `malformed_signature`, `malformed_verifying_key` — a signature rule fails.
|
||||
- `broken_history_chain` — a transition does not continue the history it follows.
|
||||
- `not_current_owner` — a transition is not signed away by the current owner.
|
||||
- `frame_not_increasing` — two transitions share a frame, or frames go backwards.
|
||||
- `frame_not_increasing` — two transitions in one cluster share a frame, or frames go backwards.
|
||||
- A canonical-encoding class (identity spec §2.3) — the bytes are not canonical.
|
||||
|
||||
---
|
||||
|
||||
## 9. What the cluster decides
|
||||
|
||||
Three questions depend on cluster state rather than on the envelope:
|
||||
Four questions depend on cluster state rather than on the envelope:
|
||||
|
||||
- whether the issuer is registered for the cargo type (treatise §10.2 step 3, §15);
|
||||
- whether a signer's establishment event was the one in force at a transition's frame;
|
||||
- whether the byte may enter or leave the cluster — a destination it peers with, a source it trusts (treatise §11.3.3);
|
||||
- how nodes account the joule cost (treatise §8.6).
|
||||
|
||||
The cluster answers these when it applies a mint or transfer (treatise §9.4 step 5). The [cluster state](./cluster_state.md) section fixes how:
|
||||
The cluster answers these when it applies a mint, import or transfer (treatise §9.4 step 5). The [cluster state](./cluster_state.md) section fixes how:
|
||||
|
||||
- the registry lists the issuers for each cargo type;
|
||||
- a signature must name the establishment event in force when it is applied;
|
||||
- a byte moves only between peer clusters, on a certificate from the source cluster's nodes;
|
||||
- joule cost is reported beside the cluster state, not in it.
|
||||
|
||||
---
|
||||
|
|
|
|||
Loading…
Reference in a new issue