smart-byte: the cluster state machine, the state hash it commits at, and its vectors #130
30 changed files with 7990 additions and 268 deletions
|
|
@ -18,10 +18,10 @@ contain no `$ref` pointers, no external URLs, and no runtime dependencies.
|
|||
|
||||
Each spec version has its own subdirectory (`v1/`, `v2/`, ...). Today only
|
||||
`v1/` exists; it corresponds to the spec text in `../spec/`
|
||||
(`treatise_v1_parts_I_II_III_combined.md`, `identity_and_key_rotation.md`
|
||||
and `envelope_wire_format.md`).
|
||||
(`treatise_v1_parts_I_II_III_combined.md`, `identity_and_key_rotation.md`,
|
||||
`envelope_wire_format.md` and `cluster_state.md`).
|
||||
|
||||
A pack for a given version contains seven JSON files plus `regeneration.md`:
|
||||
A pack for a given version contains eight JSON files plus `regeneration.md`:
|
||||
|
||||
| File | Tests |
|
||||
| -------------------------------- | --------------------------------------------------------------------- |
|
||||
|
|
@ -30,6 +30,7 @@ A pack for a given version contains seven 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) |
|
||||
| `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) |
|
||||
|
||||
|
|
@ -121,6 +122,16 @@ they are lowercase hex.
|
|||
envelope (envelope wire format §8). An accepted envelope gives `expected`:
|
||||
its identity, snapshot SAID, issuer, current owner, history hash (hex) and
|
||||
number of transfers. A refused one gives the `error` class.
|
||||
- **`state_vectors.json`** — `genesis` is a genesis state: as typed JSON
|
||||
(`state`), as bytes (`cbor_hex`), and its `state_hash`. Apply each of
|
||||
`frames` in order, as the cluster state spec (§4) describes: `inputs` are
|
||||
the frame's inputs as submitted, each as bytes (`cbor_hex`) and, when they
|
||||
are canonical CBOR, typed JSON (`input`). `expected_results` gives one
|
||||
result per distinct input, in the order they apply: the input's BLAKE3
|
||||
digest, the `class` it names (null if none), and `outcome` — `applied`,
|
||||
or `dropped` with the `error` class. Then the state must be
|
||||
`expected_state_cbor_hex`, also given as typed JSON, with hash
|
||||
`expected_state_hash`.
|
||||
- **`lockstep_vectors.json`** — `node_count` is the cluster size n.
|
||||
`node_reported_hashes` are the opaque 32-byte state hashes each node
|
||||
broadcast, and `node_computed_hashes` the ones each computed; they differ
|
||||
|
|
@ -177,6 +188,14 @@ they are lowercase hex.
|
|||
| `broken_history_chain` | a transition that does not continue the history it follows |
|
||||
| `not_current_owner` | a transition not signed away by the byte's current owner |
|
||||
| `frame_not_increasing` | a transition in the same frame as the one before it, or an earlier one |
|
||||
| `malformed_input` | canonical CBOR that is not exactly a frame input of a class there is |
|
||||
| `controller_exists` | an inception for a controller the cluster already holds |
|
||||
| `unknown_controller` | a key event or signature of a controller whose log the cluster does not hold |
|
||||
| `establishment_not_in_force` | a signature naming an establishment event other than the one in force |
|
||||
| `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 |
|
||||
|
||||
The reference implementation also reports `truncated`, `malformed`,
|
||||
`too_deep`, `not_a_map`, `missing_said_field` and `malformed_said` for
|
||||
|
|
@ -248,9 +267,6 @@ The pack is the wire-format gate. It does not cover:
|
|||
- Operational concerns. Cluster topology choice, key custody, hardware
|
||||
procurement, regulatory compliance for specific cargo types — all out
|
||||
of scope for the substrate's conformance pack.
|
||||
- What the cluster decides about a byte (envelope wire format §9): whether
|
||||
an issuer is registered for a cargo type, and whether a signer's
|
||||
establishment event was the one in force at a transfer's frame.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Smart Byte spec versions.
|
|||
|
||||
| Vector pack | Spec version | Spec source | Status | Notes |
|
||||
| ----------- | ------------ | ------------------------------------------------------------- | -------- | --------------------------------------------------------------------- |
|
||||
| `v1/` | v1.0-draft | `../spec/treatise_v1_parts_I_II_III_combined.md` (Parts I-III), `../spec/identity_and_key_rotation.md`, `../spec/envelope_wire_format.md` | DRAFT | Generated by the reference implementation (`smart-byte-conformance`) and reproduced by `crosscheck/verify_v1.py`. Regenerated in place while DRAFT (rule 4); every change is in `v1/regeneration.md`. |
|
||||
| `v1/` | v1.0-draft | `../spec/treatise_v1_parts_I_II_III_combined.md` (Parts I-III), `../spec/identity_and_key_rotation.md`, `../spec/envelope_wire_format.md`, `../spec/cluster_state.md` | DRAFT | Generated by the reference implementation (`smart-byte-conformance`) and reproduced by `crosscheck/verify_v1.py`. Regenerated in place while DRAFT (rule 4); every change is in `v1/regeneration.md`. |
|
||||
|
||||
Future entries are appended below this table when a new spec version is
|
||||
introduced. Older entries are never removed; an implementation that pins to
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ implementation of everything the pack exercises, written in another language
|
|||
from the spec text rather than ported from the Rust: canonical CBOR and its
|
||||
strict decoder (identity spec §2.3), SAID derivation and verification (§2.1,
|
||||
§2.2), Ed25519 (RFC 8032), key-event-log replay (identity spec §3, §4),
|
||||
envelope verification (envelope wire format §8), the lockstep commit rule and
|
||||
dissent log (treatise §13, §14), and the content-addressed gossip intake the
|
||||
gossip vectors describe.
|
||||
envelope verification (envelope wire format §8), the cluster state machine
|
||||
(cluster state spec), the lockstep commit rule and dissent log (treatise §13,
|
||||
§14), and the content-addressed gossip intake the gossip vectors describe.
|
||||
|
||||
It uses the Python standard library only. BLAKE3 and Ed25519 are implemented
|
||||
here from their specifications, and both are checked against published
|
||||
|
|
@ -576,6 +576,51 @@ class LogRefused(Exception):
|
|||
self.cls, self.at = cls, at
|
||||
|
||||
|
||||
def kel_step(state, e, data, sigs):
|
||||
"""Identity spec §4 steps 2-6 for one decoded event `e` (its bytes `data`
|
||||
and signatures `sigs`), against the state its log has reached — None
|
||||
before the inception. Returns the state the event establishes, with every
|
||||
establishment event's key state; raises Refused."""
|
||||
t = e["t"]
|
||||
if (state is None) != (t == "icp"):
|
||||
raise Refused("malformed_event")
|
||||
if e["s"] != (0 if state is None else state["s"] + 1):
|
||||
raise Refused("out_of_sequence")
|
||||
if e["p"] != ("" if state is None else state["d"]):
|
||||
raise Refused("prior_mismatch")
|
||||
if state is not None and e["i"] != state["i"]:
|
||||
raise Refused("controller_mismatch")
|
||||
if t == "icp":
|
||||
if e["a"]:
|
||||
raise Refused("malformed_event")
|
||||
check_key_fields(e)
|
||||
keys, threshold = e["k"], e["kt"]
|
||||
elif t == "rot":
|
||||
if not state["n"]:
|
||||
raise Refused("rotation_not_permitted")
|
||||
if len(e["k"]) != len(state["n"]) or any(
|
||||
per_key_said(k) != n for k, n in zip(e["k"], state["n"])):
|
||||
raise Refused("pre_rotation_mismatch")
|
||||
if e["a"]:
|
||||
raise Refused("malformed_event")
|
||||
check_key_fields(e)
|
||||
# Signed by the keys it reveals, under the committed threshold and
|
||||
# its own (§3.5).
|
||||
keys, threshold = e["k"], max(state["nt"], e["kt"])
|
||||
else:
|
||||
if [e[f] for f in ("k", "kt", "n", "nt")] != [state[f] for f in ("k", "kt", "n", "nt")]:
|
||||
raise Refused("interaction_changes_keys")
|
||||
keys, threshold = state["k"], state["kt"]
|
||||
err = verify_indexed(keys, threshold, data, sigs)
|
||||
if err:
|
||||
raise Refused(err)
|
||||
if t == "ixn":
|
||||
return dict(state, s=e["s"], d=e["d"])
|
||||
establishments = (state["establishments"] if state else []) + [
|
||||
{"sequence": e["s"], "keys": e["k"], "threshold": e["kt"]}]
|
||||
return dict(e, establishments=establishments)
|
||||
|
||||
|
||||
def replay(log):
|
||||
"""Identity spec §4 over [(bytes, [(index, signature)])]. Returns the final
|
||||
state, with every establishment event's key state; raises LogRefused at
|
||||
|
|
@ -583,48 +628,9 @@ def replay(log):
|
|||
state = None
|
||||
for at, (data, sigs) in enumerate(log):
|
||||
try:
|
||||
e = decode_event(data)
|
||||
t = e["t"]
|
||||
if (state is None) != (t == "icp"):
|
||||
raise Refused("malformed_event")
|
||||
if e["s"] != (0 if state is None else state["s"] + 1):
|
||||
raise Refused("out_of_sequence")
|
||||
if e["p"] != ("" if state is None else state["d"]):
|
||||
raise Refused("prior_mismatch")
|
||||
if state is not None and e["i"] != state["i"]:
|
||||
raise Refused("controller_mismatch")
|
||||
if t == "icp":
|
||||
if e["a"]:
|
||||
raise Refused("malformed_event")
|
||||
check_key_fields(e)
|
||||
keys, threshold = e["k"], e["kt"]
|
||||
elif t == "rot":
|
||||
if not state["n"]:
|
||||
raise Refused("rotation_not_permitted")
|
||||
if len(e["k"]) != len(state["n"]) or any(
|
||||
per_key_said(k) != n for k, n in zip(e["k"], state["n"])):
|
||||
raise Refused("pre_rotation_mismatch")
|
||||
if e["a"]:
|
||||
raise Refused("malformed_event")
|
||||
check_key_fields(e)
|
||||
# Signed by the keys it reveals, under the committed threshold
|
||||
# and its own (§3.5).
|
||||
keys, threshold = e["k"], max(state["nt"], e["kt"])
|
||||
else:
|
||||
if [e[f] for f in ("k", "kt", "n", "nt")] != [state[f] for f in ("k", "kt", "n", "nt")]:
|
||||
raise Refused("interaction_changes_keys")
|
||||
keys, threshold = state["k"], state["kt"]
|
||||
err = verify_indexed(keys, threshold, data, sigs)
|
||||
if err:
|
||||
raise Refused(err)
|
||||
state = kel_step(state, decode_event(data), data, sigs)
|
||||
except Refused as r:
|
||||
raise LogRefused(r.cls, at) from None
|
||||
if t == "ixn":
|
||||
state = dict(state, s=e["s"], d=e["d"])
|
||||
else:
|
||||
establishments = (state["establishments"] if state else []) + [
|
||||
{"sequence": e["s"], "keys": e["k"], "threshold": e["kt"]}]
|
||||
state = dict(e, establishments=establishments)
|
||||
if state is None:
|
||||
raise LogRefused("malformed_event", 0)
|
||||
return state
|
||||
|
|
@ -634,17 +640,64 @@ def replay(log):
|
|||
# Envelopes (envelope wire format §2–§8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def controller_signature(x):
|
||||
sig = members(x, {"establishment", "signatures"}, "not_an_envelope")
|
||||
def controller_signature(x, cls):
|
||||
sig = members(x, {"establishment", "signatures"}, cls)
|
||||
if not is_uint(sig["establishment"]) or not isinstance(sig["signatures"], list):
|
||||
raise Refused("not_an_envelope")
|
||||
raise Refused(cls)
|
||||
return sig["establishment"], signature_list(sig["signatures"], cls)
|
||||
|
||||
|
||||
def signature_list(items, cls):
|
||||
"""[{index, signature}] as [(index, signature)]."""
|
||||
if not isinstance(items, list):
|
||||
raise Refused(cls)
|
||||
out = []
|
||||
for item in sig["signatures"]:
|
||||
one = members(item, {"index", "signature"}, "not_an_envelope")
|
||||
for item in items:
|
||||
one = members(item, {"index", "signature"}, cls)
|
||||
if not is_uint(one["index"]) or not isinstance(one["signature"], bytes):
|
||||
raise Refused("not_an_envelope")
|
||||
raise Refused(cls)
|
||||
out.append((one["index"], one["signature"]))
|
||||
return sig["establishment"], out
|
||||
return out
|
||||
|
||||
|
||||
def provenance_parts(obj, cls):
|
||||
"""A provenance (envelope wire format §3) of exactly its form: its
|
||||
members and the issuer's signature."""
|
||||
prov = members(obj, {"d", "issuer", "recipient", "nonce", "cargo", "signature"}, cls)
|
||||
cargo = members(prov["cargo"], {"type", "bytes"}, cls)
|
||||
if not (parse_said(prov["d"]) and parse_aid(prov["issuer"]) and parse_aid(prov["recipient"])
|
||||
and isinstance(prov["nonce"], bytes) and len(prov["nonce"]) == 16
|
||||
and is_uint(cargo["type"]) and isinstance(cargo["bytes"], bytes)):
|
||||
raise Refused(cls)
|
||||
return prov, controller_signature(prov["signature"], cls)
|
||||
|
||||
|
||||
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)
|
||||
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"])):
|
||||
raise Refused(cls)
|
||||
return tm, controller_signature(tm["signature"], cls)
|
||||
|
||||
|
||||
def attested(prov):
|
||||
"""What the issuer signs: {cargo, issuer, nonce, recipient} (§3)."""
|
||||
return encode(Map([("cargo", prov["cargo"]), ("issuer", prov["issuer"]),
|
||||
("nonce", prov["nonce"]), ("recipient", prov["recipient"])]))
|
||||
|
||||
|
||||
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"])]))
|
||||
|
||||
|
||||
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"])])))
|
||||
|
||||
|
||||
def decode_envelope(data):
|
||||
|
|
@ -653,23 +706,12 @@ def decode_envelope(data):
|
|||
[(transition members, its signature)])."""
|
||||
obj = decode(data)
|
||||
env = members(obj, {"d", "provenance", "ownership_chain", "joule_cost"}, "not_an_envelope")
|
||||
prov = members(env["provenance"], {"d", "issuer", "recipient", "nonce", "cargo", "signature"},
|
||||
"not_an_envelope")
|
||||
cargo = members(prov["cargo"], {"type", "bytes"}, "not_an_envelope")
|
||||
cost = members(env["joule_cost"], {"measured_microjoules", "estimated_microjoules"}, "not_an_envelope")
|
||||
if not (parse_said(env["d"]) and parse_said(prov["d"]) and parse_aid(prov["issuer"])
|
||||
and parse_aid(prov["recipient"]) and isinstance(prov["nonce"], bytes)
|
||||
and len(prov["nonce"]) == 16 and is_uint(cargo["type"]) and isinstance(cargo["bytes"], bytes)
|
||||
and all(is_uint(x) for x in cost.values()) and isinstance(env["ownership_chain"], list)):
|
||||
prov, issuer_sig = provenance_parts(env["provenance"], "not_an_envelope")
|
||||
if not (parse_said(env["d"]) and all(is_uint(x) for x in cost.values())
|
||||
and isinstance(env["ownership_chain"], list)):
|
||||
raise Refused("not_an_envelope")
|
||||
issuer_sig = controller_signature(prov["signature"])
|
||||
chain = []
|
||||
for t in env["ownership_chain"]:
|
||||
tm = members(t, {"previous_history_hash", "from", "to", "frame", "signature"}, "not_an_envelope")
|
||||
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"])):
|
||||
raise Refused("not_an_envelope")
|
||||
chain.append((tm, controller_signature(tm["signature"])))
|
||||
chain = [transition_parts(t, "not_an_envelope") for t in env["ownership_chain"]]
|
||||
if derive(obj, "d")[1] != parse_said(env["d"]):
|
||||
raise Refused("said_mismatch")
|
||||
if derive(env["provenance"], "d")[1] != parse_said(prov["d"]):
|
||||
|
|
@ -691,9 +733,7 @@ def verify_envelope(data, key_states):
|
|||
if err:
|
||||
raise Refused(err)
|
||||
|
||||
attested = Map([("cargo", prov["cargo"]), ("issuer", prov["issuer"]),
|
||||
("nonce", prov["nonce"]), ("recipient", prov["recipient"])])
|
||||
signed_by(prov["issuer"], issuer_sig, encode(attested))
|
||||
signed_by(prov["issuer"], issuer_sig, attested(prov))
|
||||
identity = prov["d"]
|
||||
owner, history, last_frame = prov["recipient"], parse_said(identity), None
|
||||
for t, signature in chain:
|
||||
|
|
@ -703,16 +743,167 @@ def verify_envelope(data, key_states):
|
|||
raise Refused("not_current_owner")
|
||||
if last_frame is not None and t["frame"] <= last_frame:
|
||||
raise Refused("frame_not_increasing")
|
||||
signed_by(t["from"], signature, encode(Map([
|
||||
("byte", identity), ("previous_history_hash", t["previous_history_hash"]),
|
||||
("from", t["from"]), ("to", t["to"]), ("frame", t["frame"])])))
|
||||
history = blake3(history + encode(Map([
|
||||
("from", t["from"]), ("to", t["to"]), ("frame", t["frame"]), ("signature", t["signature"])])))
|
||||
signed_by(t["from"], signature, transition_message(identity, t))
|
||||
history = next_history(history, t)
|
||||
owner, last_frame = t["to"], t["frame"]
|
||||
return {"identity": identity, "snapshot": env["d"], "issuer": prov["issuer"], "owner": owner,
|
||||
"history_hash": history.hex(), "transfers": len(chain)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STATE_MEMBERS = {"frame", "registry", "controllers", "bytes"}
|
||||
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")
|
||||
|
||||
|
||||
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())),
|
||||
]))
|
||||
|
||||
|
||||
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"]},
|
||||
}
|
||||
check(encode_state(out) == data, "the state does not reproduce its encoding")
|
||||
return out
|
||||
|
||||
|
||||
def parse_input(data):
|
||||
"""§4.1: the class an input names (None if none), and its parts or why it
|
||||
is not an input (a Refused)."""
|
||||
try:
|
||||
obj = decode(data)
|
||||
except Refused as e:
|
||||
return None, e
|
||||
if not (isinstance(obj, Map) and len(obj) == 1 and isinstance(obj[0][0], str)
|
||||
and obj[0][0] in INPUT_CLASSES):
|
||||
return None, Refused("malformed_input")
|
||||
cls, body = obj[0]
|
||||
try:
|
||||
if cls == "key_event":
|
||||
ke = members(body, {"event", "signatures"}, "malformed_input")
|
||||
sigs = signature_list(ke["signatures"], "malformed_input")
|
||||
event_bytes = encode(ke["event"])
|
||||
return cls, (decode_event(event_bytes), event_bytes, sigs)
|
||||
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:
|
||||
raise Refused("malformed_input")
|
||||
t, sig = transition_parts(tr["transition"], "malformed_input")
|
||||
return cls, (tr["byte"], t, sig)
|
||||
except Refused as e:
|
||||
return cls, e
|
||||
|
||||
|
||||
def kel_state_of(aid, r):
|
||||
"""The key-event-log state a controller record resumes from."""
|
||||
return {"i": aid, "s": r["sequence"], "d": r["last_event"], "k": r["keys"], "kt": r["threshold"],
|
||||
"n": r["next_keys"], "nt": r["next_threshold"],
|
||||
"establishments": [{"sequence": r["establishment"], "keys": r["keys"], "threshold": r["threshold"]}]}
|
||||
|
||||
|
||||
def record_of(state):
|
||||
"""The controller record of a key-event-log state."""
|
||||
return {"establishment": state["establishments"][-1]["sequence"], "keys": state["k"],
|
||||
"threshold": state["kt"], "next_keys": state["n"], "next_threshold": state["nt"],
|
||||
"sequence": state["s"], "last_event": state["d"]}
|
||||
|
||||
|
||||
def signed_in_force(st, signer, signature, message):
|
||||
"""§4.5: a signature by `signer` under the establishment event in force."""
|
||||
establishment, sigs = signature
|
||||
r = st["controllers"].get(signer)
|
||||
if r is None:
|
||||
raise Refused("unknown_controller")
|
||||
if establishment != r["establishment"]:
|
||||
raise Refused("establishment_not_in_force")
|
||||
err = verify_indexed(r["keys"], r["threshold"], message, sigs)
|
||||
if err:
|
||||
raise Refused(err)
|
||||
|
||||
|
||||
def apply_input(st, cls, parts):
|
||||
"""§4.2-§4.4: apply one decoded input, or raise Refused leaving the state
|
||||
as it was."""
|
||||
if cls == "key_event":
|
||||
e, data, sigs = parts
|
||||
held = st["controllers"].get(e["i"])
|
||||
if e["t"] == "icp" and held is not None:
|
||||
raise Refused("controller_exists")
|
||||
if e["t"] != "icp" and held is None:
|
||||
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 == "mint":
|
||||
obj, prov, sig = parts
|
||||
if derive(obj, "d")[1] != parse_said(prov["d"]):
|
||||
raise Refused("said_mismatch")
|
||||
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"]:
|
||||
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:
|
||||
identity, t, sig = parts
|
||||
b = st["bytes"].get(identity)
|
||||
if b is None:
|
||||
raise Refused("unknown_byte")
|
||||
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")
|
||||
signed_in_force(st, t["from"], sig, transition_message(identity, t))
|
||||
b["owner"], b["history_hash"] = t["to"], next_history(b["history_hash"], t)
|
||||
|
||||
|
||||
def apply_frame(st, inputs):
|
||||
"""§4: one result per distinct input, in the order they apply; the frame
|
||||
number advances."""
|
||||
distinct = {blake3(b): b for b in inputs}
|
||||
rank = {"key_event": 0, "mint": 1, "transfer": 2, None: 3}
|
||||
ordered = sorted(((*parse_input(b), digest) for digest, b in distinct.items()),
|
||||
key=lambda x: (rank[x[0]], x[2]))
|
||||
results = []
|
||||
for cls, parts, digest in ordered:
|
||||
result = {"input": digest.hex(), "class": cls}
|
||||
try:
|
||||
if isinstance(parts, Refused):
|
||||
raise parts
|
||||
apply_input(st, cls, parts)
|
||||
result["outcome"] = "applied"
|
||||
except Refused as e:
|
||||
result["outcome"], result["error"] = "dropped", e.cls
|
||||
results.append(result)
|
||||
st["frame"] += 1
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The checks. Each raises Mismatch; none uses `assert`, so `python -O`
|
||||
# cannot switch them off.
|
||||
|
|
@ -864,6 +1055,30 @@ def tally(n, reports):
|
|||
return winner, dissenters
|
||||
|
||||
|
||||
def check_state(v):
|
||||
"""Apply each frame from the genesis state; every result, state and hash
|
||||
must be the vector's."""
|
||||
g = v["genesis"]
|
||||
data = bytes.fromhex(g["cbor_hex"])
|
||||
typed_matches(g, "state", data)
|
||||
st = decode_state(data)
|
||||
check(st["frame"] == 0, "a genesis state is at frame 0")
|
||||
check(blake3(data).hex() == g["state_hash"], "genesis state_hash")
|
||||
for i, f in enumerate(v["frames"]):
|
||||
check(f["frame"] == st["frame"], f"frame {i}: the vector's frame number is not the state's")
|
||||
inputs = []
|
||||
for inp in f["inputs"]:
|
||||
b = bytes.fromhex(inp["cbor_hex"])
|
||||
typed_matches(inp, "input", b)
|
||||
inputs.append(b)
|
||||
results = apply_frame(st, inputs)
|
||||
check(results == f["expected_results"], f"frame {i}: expected_results: computed {results}")
|
||||
after = encode_state(st)
|
||||
typed_matches(f, "expected_state", after)
|
||||
check(after.hex() == f["expected_state_cbor_hex"], f"frame {i}: expected_state_cbor_hex")
|
||||
check(blake3(after).hex() == f["expected_state_hash"], f"frame {i}: expected_state_hash")
|
||||
|
||||
|
||||
def check_lockstep(v):
|
||||
n, frame = v["node_count"], v["frame_number"]
|
||||
reported, computed = v["node_reported_hashes"], v["node_computed_hashes"]
|
||||
|
|
@ -956,6 +1171,7 @@ CHECKS = {
|
|||
"signature_vectors.json": check_signature,
|
||||
"kel_vectors.json": check_kel,
|
||||
"envelope_vectors.json": check_envelope,
|
||||
"state_vectors.json": check_state,
|
||||
"lockstep_vectors.json": check_lockstep,
|
||||
"gossip_vectors.json": check_gossip,
|
||||
}
|
||||
|
|
@ -1005,9 +1221,10 @@ def check_complete(files):
|
|||
for v in vectors:
|
||||
if isinstance(v.get("error"), str):
|
||||
used.add(v["error"])
|
||||
for r in v.get("expected_results", []):
|
||||
if "error" in r:
|
||||
used.add(r["error"])
|
||||
for holder in [v] + v.get("frames", []):
|
||||
for r in holder.get("expected_results", []):
|
||||
if "error" in r:
|
||||
used.add(r["error"])
|
||||
listed = readme_error_classes()
|
||||
check(listed and used == listed,
|
||||
f"README classes unexercised: {sorted(listed - used)}; unlisted classes used: {sorted(used - listed)}")
|
||||
|
|
|
|||
|
|
@ -6,16 +6,18 @@ placeholder.
|
|||
|
||||
- **Generator and verifier:** the `smart-byte-conformance` crate in
|
||||
`../../smart-byte-rs`. Names, descriptions and inputs are written in its
|
||||
`src/pack.rs` and `src/logs.rs`, and the parties the vectors are built from
|
||||
in `src/world.rs`. Every expected value is computed at generation time by
|
||||
`smart-byte-core` (canonical CBOR, SAIDs, signatures, envelopes),
|
||||
`smart-byte-keri-witness` (key-event logs) and `smart-byte-lockstep` (the
|
||||
commit rule and dissent log).
|
||||
`src/pack.rs`, `src/logs.rs` and `src/states.rs`, and the parties the
|
||||
vectors are built from in `src/world.rs`. Every expected value is computed
|
||||
at generation time by:
|
||||
- `smart-byte-core`: canonical CBOR, SAIDs, signatures, envelopes;
|
||||
- `smart-byte-keri-witness`: key-event logs;
|
||||
- `smart-byte-state`: the cluster state machine;
|
||||
- `smart-byte-lockstep`: the commit rule and dissent log.
|
||||
- **Second implementation:** `../crosscheck/verify_v1.py`, Python standard
|
||||
library only, written from the spec text in another language. It implements
|
||||
BLAKE3 and Ed25519 itself and checks both against known answers before it
|
||||
checks any vector, then replays key-event logs and verifies envelopes as
|
||||
the specs describe.
|
||||
checks any vector. It then replays key-event logs, verifies envelopes and
|
||||
runs the cluster state machine, as the specs describe.
|
||||
|
||||
## Procedure
|
||||
|
||||
|
|
@ -71,12 +73,16 @@ The conformance gate runs the second implementation on every pack change.
|
|||
reference against the key states that kel-001 to kel-003 establish. An
|
||||
accepted envelope's `expected` is what verification establishes; a refused
|
||||
one names its class.
|
||||
6. **Lockstep vectors.** The hashes nodes reported are tallied against the
|
||||
6. **State vectors.** Each frame is applied by the reference's state machine,
|
||||
from the vector's genesis state. Every input's result, and the state and
|
||||
hash the frame reaches, are what the reference computes. Generation fails
|
||||
if the reference applies or drops any input other than as the vector says.
|
||||
7. **Lockstep vectors.** The hashes nodes reported are tallied against the
|
||||
threshold floor(2n/3)+1 (treatise §13.1) for the frame's public record.
|
||||
Each node's outcome (§13.3) and dissent log (§14.1) come from its own
|
||||
view: its peers' reported hashes plus the hash it computed, so a node that
|
||||
lies on the wire (§13.4) is modelled exactly as the treatise describes.
|
||||
7. **Gossip vectors.** Each delivery goes through `smart-byte-core`'s inbox,
|
||||
8. **Gossip vectors.** Each delivery goes through `smart-byte-core`'s inbox,
|
||||
which implements identity spec §2.6: canonical bytes, then the SAID they
|
||||
state, then the SAID they arrived under, and only then deduplication.
|
||||
|
||||
|
|
@ -91,6 +97,8 @@ and the byte that passes between them.
|
|||
- sig-001 is the issuer's signature in env-001, and sig-002 is the signature
|
||||
on kel-002's inception, each without its algorithm identifier.
|
||||
- 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.
|
||||
- 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.
|
||||
|
|
@ -111,6 +119,63 @@ this document planned do not exist.
|
|||
|
||||
## Editorial change log
|
||||
|
||||
### 2026-10-01 — the cluster state machine, and its vectors
|
||||
|
||||
The pack gains `state_vectors.json`: 12 vectors, each a genesis state and
|
||||
frames of inputs. Every input's result, and the state and state hash every
|
||||
frame reaches, are pinned. The state hash had been a stand-in. The lockstep
|
||||
crate hashed a rolling chain over each frame's transitions, while treatise
|
||||
§9.4 commits a frame at the BLAKE3 of the canonical whole state. The state
|
||||
behind that hash is now built, and specified in `spec/cluster_state.md`.
|
||||
|
||||
**What the vectors pin:**
|
||||
|
||||
- **The state's encoding:** the frame number, the issuer registry, each
|
||||
controller's key state, and each byte's owner and history.
|
||||
- **The order inputs apply in:** key events, then mints, then transfers, each
|
||||
class by ascending digest; identical inputs are one.
|
||||
- **Every check, with its error class:**
|
||||
- a replayed mint, which is refused in every later frame;
|
||||
- an unregistered issuer, or an unregistered cargo type;
|
||||
- a rotation retiring the old key in the frame it lands in;
|
||||
- a mint and its transfer sharing a frame;
|
||||
- a double spend within one frame;
|
||||
- a wrong frame, a non-owner, a broken history, an unknown byte;
|
||||
- signature-rule failures, and a signer with no log in the cluster;
|
||||
- key-event failures and malformed inputs.
|
||||
|
||||
**Departures from the treatise, recorded in `cluster_state.md` §7:**
|
||||
|
||||
- **A byte's identity carries no frame number.** Treatise §15.2 folded the
|
||||
mint frame into it, and so a mint replayed in a later frame would issue a
|
||||
second byte (§19.3 concedes "a distinct (and possibly duplicate) byte").
|
||||
- **Parties are AIDs, so issuers rotate keys through their logs.** That is
|
||||
the issuer key rotation §15.5 defers.
|
||||
- **Key events are a third input class.**
|
||||
- **Joule cost stays out of the hashed state.** Measured energy differs node
|
||||
by node.
|
||||
|
||||
**Also changed:**
|
||||
|
||||
- `smart-byte-lockstep`'s cluster now runs these state machines, in place of
|
||||
the rolling hash.
|
||||
- The CLI's `cluster simulate` runs a workload of real mints and transfers.
|
||||
- `smart-byte-keri-witness` exposes a controller's state and resumes a replay
|
||||
from it, which is how the cluster extends a log one event at a time.
|
||||
- The completeness checks count the error classes inside frames.
|
||||
|
||||
**Falsified:**
|
||||
|
||||
- **Rust checker:** six planted defects in the state vectors, each caught on
|
||||
its own vector.
|
||||
- **Python checker:** 12 planted defects caught the same way, with and without
|
||||
`-O`. A frame deleted from the only vector exercising a class is caught by
|
||||
the completeness gate.
|
||||
- **Python rules:** 18 state-machine rule mutations, each caught by a state
|
||||
vector.
|
||||
- **Rust rules:** 16 of the reference's state-machine rules mutated one at a
|
||||
time, each compiling and failing named tests.
|
||||
|
||||
### 2026-09-30 — treatise §8.6: a counter counts only while it is live; no wire-format change
|
||||
|
||||
§8.6 now says when a node may read `measured_microjoules` from a hardware
|
||||
|
|
|
|||
4960
smart-byte/conformance/v1/state_vectors.json
Normal file
4960
smart-byte/conformance/v1/state_vectors.json
Normal file
File diff suppressed because it is too large
Load diff
19
smart-byte/smart-byte-rs/Cargo.lock
generated
19
smart-byte/smart-byte-rs/Cargo.lock
generated
|
|
@ -5850,6 +5850,7 @@ dependencies = [
|
|||
"smart-byte-core",
|
||||
"smart-byte-lockstep",
|
||||
"smart-byte-net",
|
||||
"smart-byte-state",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5951,6 +5952,7 @@ dependencies = [
|
|||
"smart-byte-core",
|
||||
"smart-byte-keri-witness",
|
||||
"smart-byte-lockstep",
|
||||
"smart-byte-state",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6114,11 +6116,9 @@ dependencies = [
|
|||
name = "smart-byte-lockstep"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"serde",
|
||||
"smart-byte-core",
|
||||
"smart-byte-state",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6215,6 +6215,19 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smart-byte-state"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"smart-byte-core",
|
||||
"smart-byte-keri-witness",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smart-byte-tendermint"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ members = [
|
|||
"crates/smart-byte-core",
|
||||
"crates/smart-byte-net",
|
||||
"crates/smart-byte-lockstep",
|
||||
"crates/smart-byte-state",
|
||||
"crates/smart-byte",
|
||||
"crates/smart-byte-vc",
|
||||
"crates/smart-byte-did",
|
||||
|
|
@ -68,6 +69,8 @@ futures = "0.3"
|
|||
smart-byte-core = { path = "crates/smart-byte-core" }
|
||||
smart-byte-net = { path = "crates/smart-byte-net" }
|
||||
smart-byte-lockstep = { path = "crates/smart-byte-lockstep" }
|
||||
smart-byte-state = { path = "crates/smart-byte-state" }
|
||||
smart-byte-keri-witness = { path = "crates/smart-byte-keri-witness" }
|
||||
smart-byte-vc = { path = "crates/smart-byte-vc" }
|
||||
smart-byte-pq = { path = "crates/smart-byte-pq" }
|
||||
smart-byte-crdt = { path = "crates/smart-byte-crdt" }
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ publish = false
|
|||
smart-byte-core = { workspace = true }
|
||||
smart-byte-keri-witness = { path = "../smart-byte-keri-witness" }
|
||||
smart-byte-lockstep = { workspace = true }
|
||||
smart-byte-state = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1"
|
||||
hex = "0.4"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
pub mod json;
|
||||
pub mod logs;
|
||||
pub mod pack;
|
||||
pub mod states;
|
||||
pub mod verify;
|
||||
pub mod world;
|
||||
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ fn kel_refused(name: &str, description: &str, log: &[LoggedEvent], class: &str,
|
|||
|
||||
/// `event`'s canonical value with `edit` applied, its SAID — and an
|
||||
/// inception's AID — re-derived over the result.
|
||||
fn restamped(event: &KeyEvent, edit: impl FnOnce(&mut Vec<(Value, Value)>)) -> Vec<u8> {
|
||||
pub(crate) fn restamped(event: &KeyEvent, edit: impl FnOnce(&mut Vec<(Value, Value)>)) -> Vec<u8> {
|
||||
let Value::Map(mut entries) = wire::to_value(event).unwrap() else {
|
||||
unreachable!("an event is a map")
|
||||
};
|
||||
|
|
@ -138,13 +138,13 @@ fn restamped(event: &KeyEvent, edit: impl FnOnce(&mut Vec<(Value, Value)>)) -> V
|
|||
.encoded
|
||||
}
|
||||
|
||||
fn set(entries: &mut Vec<(Value, Value)>, key: &str, value: Value) {
|
||||
pub(crate) fn set(entries: &mut Vec<(Value, Value)>, key: &str, value: Value) {
|
||||
entries.retain(|(k, _)| *k != Value::text(key));
|
||||
entries.push((Value::text(key), value));
|
||||
}
|
||||
|
||||
/// `bytes` with the one occurrence of `from` replaced by `to`.
|
||||
fn patched(bytes: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
|
||||
pub(crate) fn patched(bytes: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
|
||||
let at: Vec<usize> = (0..=bytes.len() - from.len())
|
||||
.filter(|&i| bytes[i..].starts_with(from))
|
||||
.collect();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
//! Inputs, names and descriptions are written here; every expected value is
|
||||
//! computed at generation time by the reference: `smart-byte-core` (canonical
|
||||
//! CBOR, SAIDs, signatures, envelopes, the inbox), `smart-byte-keri-witness`
|
||||
//! (key-event logs) and `smart-byte-lockstep` (the commit rule). A negative
|
||||
//! (key-event logs), `smart-byte-state` (the cluster state machine) and
|
||||
//! `smart-byte-lockstep` (the commit rule). A negative
|
||||
//! vector also states the error class it is meant to produce, and generation
|
||||
//! fails if the reference refuses it for any other reason — so a vector
|
||||
//! cannot pass by accident while testing nothing.
|
||||
|
|
@ -24,12 +25,13 @@ use crate::world::{Key, World};
|
|||
pub use crate::world::{RFC8032_TEST1_SEED, RFC8032_TEST2_SEED};
|
||||
|
||||
/// The pack's vector files, in the order the README lists them.
|
||||
pub const FILES: [&str; 7] = [
|
||||
pub const FILES: [&str; 8] = [
|
||||
"canonicalisation_vectors.json",
|
||||
"said_vectors.json",
|
||||
"signature_vectors.json",
|
||||
"kel_vectors.json",
|
||||
"envelope_vectors.json",
|
||||
"state_vectors.json",
|
||||
"lockstep_vectors.json",
|
||||
"gossip_vectors.json",
|
||||
];
|
||||
|
|
@ -44,8 +46,9 @@ pub fn generate() -> Vec<(&'static str, String)> {
|
|||
(FILES[2], pretty(&signatures(&world))),
|
||||
(FILES[3], pretty(&logs::kel(&world))),
|
||||
(FILES[4], pretty(&logs::envelopes(&world))),
|
||||
(FILES[5], pretty(&lockstep())),
|
||||
(FILES[6], pretty(&gossip(&saids))),
|
||||
(FILES[5], pretty(&crate::states::states(&world))),
|
||||
(FILES[6], pretty(&lockstep())),
|
||||
(FILES[7], pretty(&gossip(&saids))),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,508 @@
|
|||
//! `state_vectors.json`: the cluster state machine (cluster state spec),
|
||||
//! 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.
|
||||
|
||||
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_keri_witness::VERSION_STRING;
|
||||
use smart_byte_keri_witness::events::{KeyEvent, RecoveryEvent, Threshold, finalize_event};
|
||||
use smart_byte_state::{Input, State};
|
||||
|
||||
use crate::json::{J, to_json};
|
||||
use crate::logs::patched;
|
||||
use crate::world::{EventSignature, Key, LoggedEvent, Party, World, interaction, nonce, rotation};
|
||||
|
||||
/// What an input must come to.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Want {
|
||||
Applied,
|
||||
Dropped(&'static str),
|
||||
}
|
||||
|
||||
use Want::{Applied, Dropped};
|
||||
|
||||
fn digest(bytes: &[u8]) -> Blake3Hash {
|
||||
Said::hash(bytes).0
|
||||
}
|
||||
|
||||
fn state_json(state: &State) -> J {
|
||||
to_json(&canonical::decode(&state.to_cbor()).expect("a state is canonical"))
|
||||
}
|
||||
|
||||
/// One vector, built frame by frame against the reference.
|
||||
struct Run {
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
state: State,
|
||||
genesis: J,
|
||||
frames: Vec<J>,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
fn new(name: &'static str, description: &'static str, registry: &[(Aid, u64)]) -> Run {
|
||||
let state = State::genesis(registry.iter().copied());
|
||||
let genesis = J::obj([
|
||||
("state", state_json(&state)),
|
||||
("cbor_hex", J::Str(hex::encode(state.to_cbor()))),
|
||||
("state_hash", J::Str(hex::encode(state.hash()))),
|
||||
]);
|
||||
Run {
|
||||
name,
|
||||
description,
|
||||
state,
|
||||
genesis,
|
||||
frames: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a frame of `inputs`, as submitted, each with what it must come to.
|
||||
fn frame(mut self, inputs: Vec<(Vec<u8>, Want)>) -> 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);
|
||||
for (bytes, want) in &inputs {
|
||||
let got = report
|
||||
.results
|
||||
.iter()
|
||||
.find(|r| r.input == digest(bytes))
|
||||
.expect("every input has a result");
|
||||
let got = got.outcome.as_ref().map(drop).map_err(|e| e.class());
|
||||
match want {
|
||||
Applied => assert!(
|
||||
got.is_ok(),
|
||||
"{} frame {frame}: an input is dropped with {got:?}",
|
||||
self.name
|
||||
),
|
||||
Dropped(class) => assert_eq!(
|
||||
got,
|
||||
Err(*class),
|
||||
"{} frame {frame}: an input is not dropped as the vector says",
|
||||
self.name
|
||||
),
|
||||
}
|
||||
}
|
||||
let results = report
|
||||
.results
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut entries = vec![
|
||||
("input", J::Str(hex::encode(r.input))),
|
||||
("class", r.class.map_or(J::Null, |c| J::from(c.as_str()))),
|
||||
];
|
||||
match &r.outcome {
|
||||
Ok(()) => entries.push(("outcome", J::from("applied"))),
|
||||
Err(e) => {
|
||||
entries.push(("outcome", J::from("dropped")));
|
||||
entries.push(("error", J::from(e.class())));
|
||||
}
|
||||
}
|
||||
J::obj(entries)
|
||||
})
|
||||
.collect();
|
||||
let inputs_json = inputs
|
||||
.iter()
|
||||
.map(|(bytes, _)| {
|
||||
let mut entries = Vec::new();
|
||||
if let Ok(value) = canonical::decode(bytes) {
|
||||
entries.push(("input", to_json(&value)));
|
||||
}
|
||||
entries.push(("cbor_hex", J::Str(hex::encode(bytes))));
|
||||
J::obj(entries)
|
||||
})
|
||||
.collect();
|
||||
self.frames.push(J::obj([
|
||||
("frame", J::from(frame)),
|
||||
("inputs", J::Arr(inputs_json)),
|
||||
("expected_results", J::Arr(results)),
|
||||
("expected_state", state_json(&self.state)),
|
||||
(
|
||||
"expected_state_cbor_hex",
|
||||
J::Str(hex::encode(self.state.to_cbor())),
|
||||
),
|
||||
(
|
||||
"expected_state_hash",
|
||||
J::Str(hex::encode(self.state.hash())),
|
||||
),
|
||||
]));
|
||||
self
|
||||
}
|
||||
|
||||
fn json(self) -> J {
|
||||
J::obj([
|
||||
("name", J::from(self.name)),
|
||||
("description", J::from(self.description)),
|
||||
("genesis", self.genesis),
|
||||
("frames", J::Arr(self.frames)),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// A logged event as a `key_event` input. The event is embedded as its
|
||||
/// bytes decode, so an event the reference would refuse is embedded as is.
|
||||
fn key_event(e: &LoggedEvent) -> Vec<u8> {
|
||||
let signatures: Vec<IndexedSignature> = e
|
||||
.signatures
|
||||
.iter()
|
||||
.map(|s: &EventSignature| IndexedSignature {
|
||||
index: u64::from(s.index),
|
||||
signature: s.signature.clone(),
|
||||
})
|
||||
.collect();
|
||||
canonical::encode(&Value::map([(
|
||||
"key_event",
|
||||
Value::map([
|
||||
(
|
||||
"event",
|
||||
canonical::decode(&e.bytes).expect("a canonical event"),
|
||||
),
|
||||
(
|
||||
"signatures",
|
||||
wire::to_value(&signatures).expect("signatures encode"),
|
||||
),
|
||||
]),
|
||||
)]))
|
||||
.expect("an input encodes")
|
||||
}
|
||||
|
||||
/// A transfer input and the history hash it leads to.
|
||||
fn transfer(
|
||||
signer: &Signer,
|
||||
byte: &Said,
|
||||
history: Blake3Hash,
|
||||
to: Aid,
|
||||
frame: u64,
|
||||
) -> (Vec<u8>, Blake3Hash) {
|
||||
let t = Transition::sign(signer, byte, history, to, frame);
|
||||
(Input::transfer(byte, &t), t.next_history_hash())
|
||||
}
|
||||
|
||||
fn mint_of(
|
||||
issuer: &Party,
|
||||
keys: &[(u64, Key)],
|
||||
recipient: Aid,
|
||||
label: &str,
|
||||
cargo: Cargo,
|
||||
) -> Vec<u8> {
|
||||
let e = Envelope::issue(
|
||||
&issuer.signer(0, keys),
|
||||
recipient,
|
||||
nonce(label),
|
||||
cargo,
|
||||
JouleCost::default(),
|
||||
);
|
||||
Input::mint(&e.provenance)
|
||||
}
|
||||
|
||||
pub fn states(world: &World) -> J {
|
||||
let k = Key::named;
|
||||
let c = |n: usize| k(&format!("custodian-{n}"));
|
||||
let (issuer, alice, custodian) = (&world.issuer, &world.alice, &world.custodian);
|
||||
let registry = [(issuer.aid, 1u64)];
|
||||
let issuer_icp = key_event(&issuer.log[0]);
|
||||
let alice_icp = key_event(&alice.log[0]);
|
||||
let alice_rot = key_event(&alice.log[1]);
|
||||
let alice_ixn = key_event(&alice.log[2]);
|
||||
let custodian_icp = key_event(&custodian.log[0]);
|
||||
let custodian_rot = key_event(&custodian.log[1]);
|
||||
let mint = Input::mint(&world.issued.provenance);
|
||||
let byte = world.issued.identity();
|
||||
let h0 = byte.0;
|
||||
let alice0 = alice.signer(0, &[(0, k("alice-0"))]);
|
||||
let alice1 = alice.signer(1, &[(0, k("alice-1"))]);
|
||||
let custodian0 = |keys: &[(u64, Key)]| custodian.signer(0, keys);
|
||||
let founding = || {
|
||||
vec![
|
||||
(issuer_icp.clone(), Applied),
|
||||
(alice_icp.clone(), Applied),
|
||||
(custodian_icp.clone(), Applied),
|
||||
(mint.clone(), Applied),
|
||||
]
|
||||
};
|
||||
|
||||
// state-006: the double spend's winner is the lower digest.
|
||||
let (to_custodian, _) = transfer(&alice0, &byte, h0, custodian.aid, 1);
|
||||
let (to_issuer, _) = transfer(&alice0, &byte, h0, issuer.aid, 1);
|
||||
let (first, second) = if digest(&to_custodian) < digest(&to_issuer) {
|
||||
(to_custodian.clone(), to_issuer.clone())
|
||||
} else {
|
||||
(to_issuer.clone(), to_custodian.clone())
|
||||
};
|
||||
|
||||
let alice_icp_event = alice.log[0].event();
|
||||
let attacker = || {
|
||||
LoggedEvent::signed(
|
||||
&rotation(
|
||||
&alice_icp_event,
|
||||
&[k("attacker-0")],
|
||||
1,
|
||||
&[k("attacker-1")],
|
||||
1,
|
||||
),
|
||||
&[(0, k("attacker-0"))],
|
||||
)
|
||||
};
|
||||
let recovery = LoggedEvent::signed(
|
||||
&finalize_event(KeyEvent::Recovery(RecoveryEvent {
|
||||
v: VERSION_STRING.into(),
|
||||
d: Said::default(),
|
||||
i: issuer.aid,
|
||||
s: 1,
|
||||
p: issuer.log[0].event().said(),
|
||||
kt: Threshold(1),
|
||||
k: vec![k("attacker-0").public()],
|
||||
nt: Threshold(1),
|
||||
n: vec![k("attacker-1").digest()],
|
||||
a: vec![],
|
||||
}))
|
||||
.unwrap(),
|
||||
&[(0, k("attacker-0"))],
|
||||
);
|
||||
let gap = LoggedEvent::signed(
|
||||
&{
|
||||
let mut e = interaction(
|
||||
&alice_icp_event,
|
||||
&[k("alice-0")],
|
||||
1,
|
||||
&[k("alice-1")],
|
||||
1,
|
||||
vec![],
|
||||
);
|
||||
if let KeyEvent::Interaction(ixn) = &mut e {
|
||||
ixn.s = 2;
|
||||
}
|
||||
finalize_event(e).unwrap()
|
||||
},
|
||||
&[(0, k("alice-0"))],
|
||||
);
|
||||
|
||||
let (h1, h2) = {
|
||||
let (_, h1) = transfer(&alice0, &byte, h0, custodian.aid, 1);
|
||||
let (_, h2) = transfer(
|
||||
&custodian0(&[(0, c(0)), (2, c(2))]),
|
||||
&byte,
|
||||
h1,
|
||||
alice.aid,
|
||||
2,
|
||||
);
|
||||
(h1, h2)
|
||||
};
|
||||
|
||||
J::Arr(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.",
|
||||
®istry,
|
||||
)
|
||||
.frame(founding())
|
||||
.frame(vec![(transfer(&alice0, &byte, h0, custodian.aid, 1).0, Applied)])
|
||||
.frame(vec![
|
||||
(alice_rot.clone(), Applied),
|
||||
(transfer(&custodian0(&[(0, c(0)), (2, c(2))]), &byte, h1, alice.aid, 2).0, Applied),
|
||||
])
|
||||
.frame(vec![
|
||||
(alice_ixn.clone(), Applied),
|
||||
(transfer(&alice1, &byte, h2, custodian.aid, 3).0, Applied),
|
||||
])
|
||||
.frame(vec![])
|
||||
.json(),
|
||||
Run::new(
|
||||
"state-002-replayed-mint-refused",
|
||||
"The issuer's mint, replayed in the next frame. A byte's identity is its provenance's SAID, with no frame number in it, so the replay names a byte the cluster already holds and is dropped. Treatise §15.2 folded the mint frame into the identity, and so a replay in a later frame would have issued a second byte (cluster state spec §7).",
|
||||
®istry,
|
||||
)
|
||||
.frame(vec![(issuer_icp.clone(), Applied), (alice_icp.clone(), Applied), (mint.clone(), Applied)])
|
||||
.frame(vec![(mint.clone(), Dropped("duplicate_byte"))])
|
||||
.json(),
|
||||
Run::new(
|
||||
"state-003-issuer-registration",
|
||||
"Only a registered issuer mints, and only its registered cargo types. Alice — whose log the cluster holds, so her signature would check — mints USD; the issuer mints joules (cargo type 2), for which it is not registered. Both are dropped; the issuer's USD mint is applied.",
|
||||
®istry,
|
||||
)
|
||||
.frame(vec![
|
||||
(issuer_icp.clone(), Applied),
|
||||
(alice_icp.clone(), Applied),
|
||||
(
|
||||
mint_of(alice, &[(0, k("alice-0"))], custodian.aid, "state-003 alice", Cargo::usd_micros(5_000_000)),
|
||||
Dropped("issuer_not_registered"),
|
||||
),
|
||||
(
|
||||
mint_of(issuer, &[(0, k("issuer-0"))], alice.aid, "state-003 joules", Cargo::joules(1_000)),
|
||||
Dropped("issuer_not_registered"),
|
||||
),
|
||||
(mint.clone(), Applied),
|
||||
])
|
||||
.json(),
|
||||
Run::new(
|
||||
"state-004-rotation-retires-old-keys",
|
||||
"Alice rotates in frame 1, and in the same frame a transfer of her byte arrives signed with her pre-rotation key under her establishment event 0 — what a thief holding the old key would send. Key events apply first, so the rotation is in force when the transfer is checked, and it is dropped. In frame 2 the transfer, signed with her new key under establishment event 1, is applied.",
|
||||
®istry,
|
||||
)
|
||||
.frame(founding())
|
||||
.frame(vec![
|
||||
(transfer(&alice0, &byte, h0, custodian.aid, 1).0, Dropped("establishment_not_in_force")),
|
||||
(alice_rot.clone(), Applied),
|
||||
])
|
||||
.frame(vec![(transfer(&alice1, &byte, h0, custodian.aid, 2).0, Applied)])
|
||||
.json(),
|
||||
Run::new(
|
||||
"state-005-mint-and-transfer-in-one-frame",
|
||||
"A byte minted and transferred in the same frame: mints apply before transfers, whatever their digests, so the byte exists when its transfer is checked (treatise §15.3).",
|
||||
®istry,
|
||||
)
|
||||
.frame({
|
||||
let mut inputs = founding();
|
||||
inputs.push((transfer(&alice0, &byte, h0, custodian.aid, 0).0, Applied));
|
||||
inputs
|
||||
})
|
||||
.json(),
|
||||
Run::new(
|
||||
"state-006-one-transfer-per-byte-per-frame",
|
||||
"Alice signs two transfers of her byte in frame 1, one to the custodian and one to the issuer. Within a class inputs apply in ascending digest order: the first applies, and the second continues a history that has moved on, so it is dropped. A byte moves at most once a frame.",
|
||||
®istry,
|
||||
)
|
||||
.frame(founding())
|
||||
.frame(vec![(second, Dropped("broken_history_chain")), (first, Applied)])
|
||||
.json(),
|
||||
Run::new(
|
||||
"state-007-transfer-checks",
|
||||
"Four transfers in frame 1, each failing one check: one names frame 7; one is signed away by the custodian, who does not own the byte; one continues a history the byte never had; one moves a byte the cluster does not hold. All are dropped, and the byte stays with Alice.",
|
||||
®istry,
|
||||
)
|
||||
.frame(founding())
|
||||
.frame(vec![
|
||||
(transfer(&alice0, &byte, h0, custodian.aid, 7).0, Dropped("wrong_frame")),
|
||||
(
|
||||
transfer(&custodian0(&[(0, c(0)), (1, c(1))]), &byte, h0, alice.aid, 1).0,
|
||||
Dropped("not_current_owner"),
|
||||
),
|
||||
(transfer(&alice0, &byte, [0x5a; 32], custodian.aid, 1).0, Dropped("broken_history_chain")),
|
||||
(
|
||||
transfer(&alice0, &world.opaque.identity(), world.opaque.identity().0, custodian.aid, 1).0,
|
||||
Dropped("unknown_byte"),
|
||||
),
|
||||
])
|
||||
.json(),
|
||||
Run::new(
|
||||
"state-008-signature-checks",
|
||||
"The custodian owns the byte after frame 0, and in frame 1 four transfers back to Alice fail its signature rule: one key of the two it needs; one key's signature listed twice; a signature at key index 3 of three; a signature whose algorithm identifier is not its key's.",
|
||||
®istry,
|
||||
)
|
||||
.frame({
|
||||
let mut inputs = founding();
|
||||
inputs.push((transfer(&alice0, &byte, h0, custodian.aid, 0).0, Applied));
|
||||
inputs
|
||||
})
|
||||
.frame({
|
||||
// The history the frame-0 transfer left: a history hash covers
|
||||
// the frame its transition names.
|
||||
let h = transfer(&alice0, &byte, h0, custodian.aid, 0).1;
|
||||
let one_custodian_key = custodian0(&[(0, c(0))]);
|
||||
let twice = custodian0(&[(0, c(0)), (0, c(0))]);
|
||||
let out_of_range = custodian0(&[(0, c(0)), (3, c(2))]);
|
||||
let mut wrong_algorithm = Transition::sign(&custodian0(&[(0, c(0)), (1, c(1))]), &byte, h, alice.aid, 1);
|
||||
wrong_algorithm.signature.signatures[0].signature[0] = 0x02;
|
||||
vec![
|
||||
(transfer(&one_custodian_key, &byte, h, alice.aid, 1).0, Dropped("below_threshold")),
|
||||
(transfer(&twice, &byte, h, alice.aid, 1).0, Dropped("duplicate_key_index")),
|
||||
(transfer(&out_of_range, &byte, h, alice.aid, 1).0, Dropped("key_index_out_of_range")),
|
||||
(Input::transfer(&byte, &wrong_algorithm), Dropped("algorithm_mismatch")),
|
||||
]
|
||||
})
|
||||
.json(),
|
||||
Run::new(
|
||||
"state-009-signers-need-their-log",
|
||||
"The byte is minted to Alice before the cluster holds her log. Receiving needs none, but in frame 1 her transfer cannot be checked and is dropped. In frame 2 her inception and a fresh transfer arrive together; key events apply first, so both are applied.",
|
||||
®istry,
|
||||
)
|
||||
.frame(vec![(issuer_icp.clone(), Applied), (mint.clone(), Applied)])
|
||||
.frame(vec![(transfer(&alice0, &byte, h0, custodian.aid, 1).0, Dropped("unknown_controller"))])
|
||||
.frame(vec![
|
||||
(transfer(&alice0, &byte, h0, custodian.aid, 2).0, Applied),
|
||||
(alice_icp.clone(), Applied),
|
||||
])
|
||||
.json(),
|
||||
Run::new(
|
||||
"state-010-key-event-checks",
|
||||
"Key events the cluster refuses, in frame 1: Alice's inception again; a rotation of the custodian, whose log the cluster does not hold; a rotation of Alice's to a key she never committed to; a recovery event, which v1 reserves; an interaction of Alice's at sequence 2. Each fails the check identity spec §4 names, and every log stays as frame 0 left it.",
|
||||
®istry,
|
||||
)
|
||||
.frame(vec![(issuer_icp.clone(), Applied), (alice_icp.clone(), Applied)])
|
||||
.frame(vec![
|
||||
(alice_icp.clone(), Dropped("controller_exists")),
|
||||
(custodian_rot.clone(), Dropped("unknown_controller")),
|
||||
(key_event(&attacker()), Dropped("pre_rotation_mismatch")),
|
||||
(key_event(&recovery), Dropped("unsupported_event_type")),
|
||||
(key_event(&gap), Dropped("out_of_sequence")),
|
||||
])
|
||||
.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.",
|
||||
®istry,
|
||||
)
|
||||
.frame(vec![
|
||||
(
|
||||
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([("burn", Value::map([("byte", Value::text(&byte.to_said_text()))]))])).unwrap(),
|
||||
Dropped("malformed_input"),
|
||||
),
|
||||
(canonical::encode(&Value::Array(vec![Value::Unsigned(1)])).unwrap(), Dropped("malformed_input")),
|
||||
(
|
||||
key_event(&LoggedEvent::signed_bytes(
|
||||
crate::logs::restamped(&issuer.log[0].event(), |m| crate::logs::set(m, "x", Value::Null)),
|
||||
&[(0, k("issuer-0"))],
|
||||
)),
|
||||
Dropped("malformed_event"),
|
||||
),
|
||||
(
|
||||
Input::mint(&Provenance {
|
||||
cargo: Cargo::usd_micros(1_000_000_000_000),
|
||||
..world.issued.provenance.clone()
|
||||
}),
|
||||
Dropped("said_mismatch"),
|
||||
),
|
||||
(
|
||||
{
|
||||
let t = Transition::sign(&alice0, &byte, h0, custodian.aid, 0);
|
||||
canonical::encode(&Value::map([(
|
||||
"transfer",
|
||||
Value::map([
|
||||
("byte", Value::text(&byte.to_said_text())),
|
||||
("transition", wire::to_value(&t).unwrap()),
|
||||
("memo", Value::text("lunch")),
|
||||
]),
|
||||
)]))
|
||||
.unwrap()
|
||||
},
|
||||
Dropped("malformed_input"),
|
||||
),
|
||||
])
|
||||
.json(),
|
||||
Run::new(
|
||||
"state-012-duplicate-submissions",
|
||||
"The issuer's inception submitted twice and its mint three times, as a client sending to several nodes would. Identical inputs are one input: three results, all applied.",
|
||||
®istry,
|
||||
)
|
||||
.frame(vec![
|
||||
(issuer_icp.clone(), Applied),
|
||||
(mint.clone(), Applied),
|
||||
(issuer_icp.clone(), Applied),
|
||||
(alice_icp.clone(), Applied),
|
||||
(mint.clone(), Applied),
|
||||
(mint.clone(), Applied),
|
||||
])
|
||||
.json(),
|
||||
])
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ use smart_byte_core::sign::{self, Signer as _, SigningKey};
|
|||
use smart_byte_core::{Envelope, KeyBook, Said};
|
||||
use smart_byte_keri_witness::{KeyEvent, LogVerifier, SignedEvent, VerificationReport};
|
||||
use smart_byte_lockstep::{node_view, supermajority_threshold, tally};
|
||||
use smart_byte_state::State;
|
||||
|
||||
use crate::json::{from_json, parse_strict};
|
||||
use crate::pack::FILES;
|
||||
|
|
@ -187,6 +188,7 @@ pub fn verify_pack(dir: &Path) -> Report {
|
|||
"signature_vectors.json" => signature(v),
|
||||
"kel_vectors.json" => continue,
|
||||
"envelope_vectors.json" => envelope(v, &logs),
|
||||
"state_vectors.json" => state_vector(v),
|
||||
"lockstep_vectors.json" => lockstep(v),
|
||||
"gossip_vectors.json" => gossip(v),
|
||||
_ => unreachable!("FILES lists exactly these"),
|
||||
|
|
@ -257,11 +259,8 @@ fn cited_present(files: &BTreeMap<&str, Vec<S>>) -> R {
|
|||
|
||||
fn classes_exercised(files: &BTreeMap<&str, Vec<S>>) -> R {
|
||||
let mut used = BTreeSet::new();
|
||||
for v in files.values().flatten() {
|
||||
if let Some(e) = v.get("error").and_then(S::as_str) {
|
||||
used.insert(e.to_owned());
|
||||
}
|
||||
for r in v
|
||||
let mut results = |holder: &S| {
|
||||
for r in holder
|
||||
.get("expected_results")
|
||||
.and_then(S::as_array)
|
||||
.into_iter()
|
||||
|
|
@ -271,7 +270,18 @@ fn classes_exercised(files: &BTreeMap<&str, Vec<S>>) -> R {
|
|||
used.insert(e.to_owned());
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut errors = BTreeSet::new();
|
||||
for v in files.values().flatten() {
|
||||
if let Some(e) = v.get("error").and_then(S::as_str) {
|
||||
errors.insert(e.to_owned());
|
||||
}
|
||||
results(v);
|
||||
for frame in v.get("frames").and_then(S::as_array).into_iter().flatten() {
|
||||
results(frame);
|
||||
}
|
||||
}
|
||||
used.extend(errors);
|
||||
let listed = readme_error_classes()?;
|
||||
let unexercised: Vec<&String> = listed.difference(&used).collect();
|
||||
let undocumented: Vec<&String> = used.difference(&listed).collect();
|
||||
|
|
@ -656,6 +666,84 @@ fn envelope(v: &S, logs: &BTreeMap<String, VerificationReport>) -> R {
|
|||
}
|
||||
}
|
||||
|
||||
fn state_vector(v: &S) -> R {
|
||||
let genesis = field(v, "genesis")?;
|
||||
let bytes = hex_field(genesis, "cbor_hex")?;
|
||||
typed_matches_bytes(genesis, "state", &bytes)?;
|
||||
let mut state = State::from_cbor(&bytes).map_err(|e| format!("genesis: {e}"))?;
|
||||
if state.frame != 0 {
|
||||
return Err("a genesis state is at frame 0".into());
|
||||
}
|
||||
same(
|
||||
"genesis state_hash",
|
||||
hex::encode(state.hash()),
|
||||
text(genesis, "state_hash")?,
|
||||
)?;
|
||||
for (i, frame) in field(v, "frames")?
|
||||
.as_array()
|
||||
.ok_or("frames is not an array")?
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let at = |m: String| format!("frame {i}: {m}");
|
||||
if field(frame, "frame")?.as_u64() != Some(state.frame) {
|
||||
return Err(at(format!(
|
||||
"the vector's frame number is not the state's, {}",
|
||||
state.frame
|
||||
)));
|
||||
}
|
||||
let mut inputs = Vec::new();
|
||||
for input in field(frame, "inputs")?
|
||||
.as_array()
|
||||
.ok_or("inputs is not an array")?
|
||||
{
|
||||
let bytes = hex_field(input, "cbor_hex")?;
|
||||
typed_matches_bytes(input, "input", &bytes).map_err(at)?;
|
||||
inputs.push(bytes);
|
||||
}
|
||||
let report = state.apply_frame(&inputs);
|
||||
let computed: Vec<S> = report
|
||||
.results
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut j = serde_json::json!({
|
||||
"input": hex::encode(r.input),
|
||||
"class": r.class.map(|c| c.as_str()),
|
||||
});
|
||||
match &r.outcome {
|
||||
Ok(()) => j["outcome"] = "applied".into(),
|
||||
Err(e) => {
|
||||
j["outcome"] = "dropped".into();
|
||||
j["error"] = e.class().into();
|
||||
}
|
||||
}
|
||||
j
|
||||
})
|
||||
.collect();
|
||||
if field(frame, "expected_results")? != &S::Array(computed.clone()) {
|
||||
return Err(at(format!(
|
||||
"expected_results: reference computes {}",
|
||||
S::Array(computed)
|
||||
)));
|
||||
}
|
||||
let after = state.to_cbor();
|
||||
typed_matches_bytes(frame, "expected_state", &after).map_err(at)?;
|
||||
same(
|
||||
"expected_state_cbor_hex",
|
||||
hex::encode(&after),
|
||||
text(frame, "expected_state_cbor_hex")?,
|
||||
)
|
||||
.map_err(at)?;
|
||||
same(
|
||||
"expected_state_hash",
|
||||
hex::encode(state.hash()),
|
||||
text(frame, "expected_state_hash")?,
|
||||
)
|
||||
.map_err(at)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn lockstep(v: &S) -> R {
|
||||
let n = field(v, "node_count")?
|
||||
.as_u64()
|
||||
|
|
|
|||
|
|
@ -218,6 +218,71 @@ fn the_verifier_catches_a_planted_defect_in_every_file() {
|
|||
"env-002-transfers-across-a-rotation",
|
||||
Box::new(|v| v["expected"]["owner"] = v["expected"]["issuer"].clone()),
|
||||
),
|
||||
(
|
||||
"state_vectors.json",
|
||||
"state-001-issue-transfer-rotate",
|
||||
Box::new(move |v| {
|
||||
let h = v["frames"][1]["expected_state_hash"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
v["frames"][1]["expected_state_hash"] = json!(flip_last(&h));
|
||||
}),
|
||||
),
|
||||
(
|
||||
"state_vectors.json",
|
||||
"state-007-transfer-checks",
|
||||
Box::new(|v| {
|
||||
let results = v["frames"][1]["expected_results"].as_array_mut().unwrap();
|
||||
let r = results
|
||||
.iter_mut()
|
||||
.find(|r| r["error"] == "broken_history_chain")
|
||||
.unwrap();
|
||||
r["error"] = json!("unknown_byte");
|
||||
}),
|
||||
),
|
||||
(
|
||||
"state_vectors.json",
|
||||
"state-004-rotation-retires-old-keys",
|
||||
// Application order is part of the result: the key event first.
|
||||
Box::new(|v| {
|
||||
v["frames"][1]["expected_results"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.reverse()
|
||||
}),
|
||||
),
|
||||
(
|
||||
"state_vectors.json",
|
||||
"state-011-malformed-inputs",
|
||||
Box::new(|v| {
|
||||
let results = v["frames"][0]["expected_results"].as_array_mut().unwrap();
|
||||
let r = results
|
||||
.iter_mut()
|
||||
.find(|r| r["error"] == "non_shortest_integer")
|
||||
.unwrap();
|
||||
r["class"] = json!("transfer");
|
||||
}),
|
||||
),
|
||||
(
|
||||
"state_vectors.json",
|
||||
"state-012-duplicate-submissions",
|
||||
// A duplicate counted as a second input.
|
||||
Box::new(|v| {
|
||||
let results = v["frames"][0]["expected_results"].as_array_mut().unwrap();
|
||||
let first = results[0].clone();
|
||||
results.insert(1, first);
|
||||
}),
|
||||
),
|
||||
(
|
||||
"state_vectors.json",
|
||||
"state-009-signers-need-their-log",
|
||||
// Without Alice's inception her transfer cannot be checked.
|
||||
Box::new(|v| {
|
||||
let inputs = v["frames"][2]["inputs"].as_array_mut().unwrap();
|
||||
inputs.retain(|i| i["input"].get("key_event").is_none());
|
||||
}),
|
||||
),
|
||||
(
|
||||
"lockstep_vectors.json",
|
||||
"lockstep-002-supermajority-commit",
|
||||
|
|
@ -510,4 +575,38 @@ fn the_files_describe_one_world() {
|
|||
&alice["events"][0]["signatures"][0]["signature"]
|
||||
))
|
||||
);
|
||||
|
||||
// The cluster's founding frame: the three logs' inceptions and env-001's
|
||||
// mint, as the other files give them.
|
||||
let journey = by_name("state_vectors.json", "state-001-issue-transfer-rotate");
|
||||
let founding: Vec<&Value> = journey["frames"][0]["inputs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|i| &i["input"])
|
||||
.collect();
|
||||
for log in [
|
||||
"kel-001-single-key-inception",
|
||||
"kel-002-rotation-then-interaction",
|
||||
"kel-003-two-of-three-rotation",
|
||||
] {
|
||||
let event = &by_name("kel_vectors.json", log)["events"][0]["event"];
|
||||
assert!(
|
||||
founding.iter().any(|i| &i["key_event"]["event"] == event),
|
||||
"{log}'s inception"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
founding
|
||||
.iter()
|
||||
.any(|i| i["mint"] == issued["envelope"]["provenance"]),
|
||||
"env-001's mint"
|
||||
);
|
||||
// The byte the cluster holds is env-001's.
|
||||
let state = &journey["frames"][0]["expected_state"];
|
||||
assert!(
|
||||
state["bytes"]
|
||||
.get(issued["expected"]["identity"].as_str().unwrap())
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@ pub use events::{
|
|||
pub use oobi::Oobi;
|
||||
pub use storage::{EventLogStorage, FileStorage, MemoryStorage};
|
||||
pub use verifier::{
|
||||
DuplicityEvidence, Establishment, LogRejection, LogVerifier, Replay, SignedEvent,
|
||||
VerificationReport,
|
||||
ControllerState, DuplicityEvidence, Establishment, LogRejection, LogVerifier, Replay,
|
||||
SignedEvent, VerificationReport,
|
||||
};
|
||||
pub use watcher::{DuplicityDetected, Watcher};
|
||||
pub use witness::{Witness, WitnessReceipt};
|
||||
|
|
|
|||
|
|
@ -179,25 +179,37 @@ impl LogVerifier {
|
|||
}
|
||||
}
|
||||
|
||||
/// The state replay has established so far.
|
||||
#[derive(Clone, Debug)]
|
||||
struct State {
|
||||
controller: ControllerAid,
|
||||
current_keys: Vec<PublicKey>,
|
||||
current_threshold: Threshold,
|
||||
next_keys: Vec<Said>,
|
||||
next_threshold: Threshold,
|
||||
last_sequence: u64,
|
||||
last_said: Said,
|
||||
witnesses: BTreeSet<WitnessAid>,
|
||||
witness_threshold: Threshold,
|
||||
/// What a replayed log has established about its controller: the state the
|
||||
/// next event is checked against.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ControllerState {
|
||||
/// The controller's AID.
|
||||
pub controller: ControllerAid,
|
||||
/// The sequence number of the establishment event whose keys are current.
|
||||
pub establishment: u64,
|
||||
/// The signing keys in force.
|
||||
pub current_keys: Vec<PublicKey>,
|
||||
/// The signing threshold in force.
|
||||
pub current_threshold: Threshold,
|
||||
/// The next-key digests committed.
|
||||
pub next_keys: Vec<Said>,
|
||||
/// The next-key threshold committed.
|
||||
pub next_threshold: Threshold,
|
||||
/// The last accepted event's sequence number.
|
||||
pub last_sequence: u64,
|
||||
/// The last accepted event's SAID.
|
||||
pub last_said: Said,
|
||||
/// Extension: the witness pool in force.
|
||||
pub witnesses: BTreeSet<WitnessAid>,
|
||||
/// Extension: the witness-receipt threshold in force.
|
||||
pub witness_threshold: Threshold,
|
||||
}
|
||||
|
||||
/// Incremental replay of one controller's log (spec §4).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Replay {
|
||||
verifier: LogVerifier,
|
||||
state: Option<State>,
|
||||
state: Option<ControllerState>,
|
||||
establishments: Vec<Establishment>,
|
||||
/// Every witness that has been in the pool: whose receipts count as
|
||||
/// evidence of duplicity.
|
||||
|
|
@ -222,6 +234,25 @@ impl Replay {
|
|||
}
|
||||
}
|
||||
|
||||
/// A replay that continues from a controller state reached earlier — a
|
||||
/// cluster's record of the controller, say — rather than from the
|
||||
/// inception. It holds no establishment history before `state`.
|
||||
#[must_use]
|
||||
pub fn resume(verifier: LogVerifier, state: ControllerState) -> Self {
|
||||
Self {
|
||||
ever_witnesses: state.witnesses.clone(),
|
||||
state: Some(state),
|
||||
..Self::new(verifier)
|
||||
}
|
||||
}
|
||||
|
||||
/// The controller state reached so far: `None` until an inception is
|
||||
/// accepted.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> Option<&ControllerState> {
|
||||
self.state.as_ref()
|
||||
}
|
||||
|
||||
/// Accept the next event, or refuse it. A refusal leaves the replay as it
|
||||
/// was.
|
||||
pub fn apply(
|
||||
|
|
@ -300,8 +331,9 @@ impl Replay {
|
|||
let witnesses = pool(&icp.b)?;
|
||||
check_witness_threshold(icp.bt, &witnesses)?;
|
||||
verify_event_signatures(event, &icp.k, icp.kt, signatures)?;
|
||||
next = Some(State {
|
||||
next = Some(ControllerState {
|
||||
controller: icp.i,
|
||||
establishment: 0,
|
||||
current_keys: icp.k.clone(),
|
||||
current_threshold: icp.kt,
|
||||
next_keys: icp.n.clone(),
|
||||
|
|
@ -315,8 +347,9 @@ impl Replay {
|
|||
KeyEvent::Delegation(dip) => {
|
||||
check_establishment(&dip.k, dip.kt, &dip.n, dip.nt)?;
|
||||
verify_event_signatures(event, &dip.k, dip.kt, signatures)?;
|
||||
next = Some(State {
|
||||
next = Some(ControllerState {
|
||||
controller: dip.i,
|
||||
establishment: 0,
|
||||
current_keys: dip.k.clone(),
|
||||
current_threshold: dip.kt,
|
||||
next_keys: dip.n.clone(),
|
||||
|
|
@ -352,6 +385,7 @@ impl Replay {
|
|||
// committed before — so it cannot be lowered without the
|
||||
// pre-rotated keys — and its own (spec §3.5).
|
||||
verify_event_signatures(event, &rot.k, s.next_threshold.max(rot.kt), signatures)?;
|
||||
s.establishment = rot.s;
|
||||
s.current_keys = rot.k.clone();
|
||||
s.current_threshold = rot.kt;
|
||||
s.next_keys = rot.n.clone();
|
||||
|
|
@ -383,6 +417,7 @@ impl Replay {
|
|||
));
|
||||
}
|
||||
check_witness_quorum(event, s, receipts)?;
|
||||
s.establishment = rec.s;
|
||||
s.current_keys = rec.k.clone();
|
||||
s.current_threshold = rec.kt;
|
||||
s.next_keys = rec.n.clone();
|
||||
|
|
@ -566,7 +601,7 @@ fn check_witness_threshold(bt: Threshold, witnesses: &BTreeSet<WitnessAid>) -> R
|
|||
/// receipts that verify, for this controller, sequence and SAID.
|
||||
fn check_witness_quorum(
|
||||
event: &KeyEvent,
|
||||
state: &State,
|
||||
state: &ControllerState,
|
||||
receipts: &HashMap<Said, Vec<WitnessReceipt>>,
|
||||
) -> Result<()> {
|
||||
let receipted: BTreeSet<&WitnessAid> = receipts
|
||||
|
|
|
|||
|
|
@ -5,11 +5,9 @@ edition.workspace = true
|
|||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Smart Byte deterministic lockstep BFT simulation."
|
||||
description = "Smart Byte deterministic lockstep BFT: the supermajority commit rule and an in-process cluster of state machines."
|
||||
|
||||
[dependencies]
|
||||
smart-byte-core = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
smart-byte-state = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,25 +1,27 @@
|
|||
//! Deterministic lockstep BFT cluster simulation (MVP).
|
||||
//! Deterministic lockstep BFT (treatise §9, §13, §14).
|
||||
//!
|
||||
//! Every [`Node`] in a [`Cluster`] runs the *same* state machine: a
|
||||
//! BLAKE3 rolling hash that absorbs the canonical-CBOR encoding of each
|
||||
//! ordered transition. After each [`Frame`] every honest node will
|
||||
//! independently arrive at the same `state_hash`. A frame commits when
|
||||
//! a BFT supermajority (`> 2/3` of nodes) vote for the same hash.
|
||||
//!
|
||||
//! Faulty nodes are modeled as nodes that *flip a bit* in their state
|
||||
//! before voting; the cluster still commits if at least `floor(2n/3)+1`
|
||||
//! honest nodes agree.
|
||||
//! Every [`Node`] in a [`Cluster`] runs the cluster state machine of
|
||||
//! `smart-byte-state`: it applies the frame's inputs to its [`State`] and
|
||||
//! reports the post-frame state hash. A frame commits when a BFT
|
||||
//! supermajority — `floor(2n/3) + 1` of the cluster's nodes — reports the
|
||||
//! same hash.
|
||||
//!
|
||||
//! The commit decision itself is [`tally`]: a pure function from the state
|
||||
//! hashes a frame's nodes reported to the treatise's outcomes — the
|
||||
//! supermajority threshold (§13.1), the four frame outcomes (§13.3) and the
|
||||
//! dissent-log entry a committing node writes (§14.1–§14.2). [`Cluster::step`]
|
||||
//! uses it, and the conformance pack's lockstep vectors are checked against it.
|
||||
//!
|
||||
//! [`Cluster`] runs its nodes in one process, as the treatise's in-process
|
||||
//! transport does (§17.2): every node receives every input. A faulty node is
|
||||
//! modelled as one that applies the frame honestly and broadcasts a hash
|
||||
//! with a bit flipped — the transport-level liar of §13.4.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smart_byte_core::{Aid, Blake3Hash, Said, Signer, SigningKey, ownership::Transition};
|
||||
use smart_byte_core::{Blake3Hash, Said};
|
||||
use smart_byte_state::FrameReport;
|
||||
pub use smart_byte_state::State;
|
||||
|
||||
/// Errors during a frame step.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
@ -210,31 +212,24 @@ impl<P: Ord + Clone> Tally<P> {
|
|||
}
|
||||
}
|
||||
|
||||
/// A single ordered batch of state transitions.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Frame {
|
||||
pub frame_number: u64,
|
||||
pub ordered_transitions: Vec<Transition>,
|
||||
}
|
||||
|
||||
/// A simulated node in the cluster.
|
||||
/// A node in the cluster.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Node {
|
||||
/// Stable identity.
|
||||
pub id: Said,
|
||||
/// Rolling state hash. Each frame absorbs into this.
|
||||
pub state: Blake3Hash,
|
||||
/// If true, this node introduces a deterministic per-frame bit-flip
|
||||
/// before voting. Used to model Byzantine misbehavior.
|
||||
/// The cluster state, as this node holds it.
|
||||
pub state: State,
|
||||
/// If true, this node broadcasts a hash with its low bit flipped while
|
||||
/// keeping its state honest (treatise §13.4).
|
||||
pub faulty: bool,
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Construct a fresh node with all-zero state.
|
||||
pub fn new(id: Said) -> Self {
|
||||
/// A node holding the cluster's genesis state.
|
||||
pub fn new(id: Said, genesis: &State) -> Self {
|
||||
Self {
|
||||
id,
|
||||
state: [0u8; 32],
|
||||
state: genesis.clone(),
|
||||
faulty: false,
|
||||
}
|
||||
}
|
||||
|
|
@ -245,28 +240,15 @@ impl Node {
|
|||
self
|
||||
}
|
||||
|
||||
/// Compute this node's vote for the given frame, advancing its
|
||||
/// internal state to the post-frame hash. Faulty nodes flip the
|
||||
/// low bit of the announced vote (but still update their honest
|
||||
/// state internally; in a real cluster the state divergence would
|
||||
/// be the observable signal).
|
||||
fn step(&mut self, frame: &Frame) -> Blake3Hash {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(&self.state);
|
||||
hasher.update(&frame.frame_number.to_le_bytes());
|
||||
for t in &frame.ordered_transitions {
|
||||
let bytes = smart_byte_core::wire::to_vec(t).expect("a transition always encodes");
|
||||
hasher.update(&bytes);
|
||||
}
|
||||
let honest = *hasher.finalize().as_bytes();
|
||||
self.state = honest;
|
||||
/// Apply the frame's inputs; return the hash this node broadcasts and
|
||||
/// what the frame did.
|
||||
fn step(&mut self, inputs: &[Vec<u8>]) -> (Blake3Hash, FrameReport) {
|
||||
let report = self.state.apply_frame(inputs);
|
||||
let mut hash = self.state.hash();
|
||||
if self.faulty {
|
||||
let mut bad = honest;
|
||||
bad[0] ^= 0x01;
|
||||
bad
|
||||
} else {
|
||||
honest
|
||||
hash[0] ^= 0x01;
|
||||
}
|
||||
(hash, report)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -278,9 +260,12 @@ pub struct FrameCommit {
|
|||
pub committed_by: HashSet<Said>,
|
||||
/// Nodes whose announced hash differed from `state_hash` (§14.1).
|
||||
pub dissenters: Vec<Said>,
|
||||
/// Distinct inputs the frame applied, and dropped.
|
||||
pub applied: usize,
|
||||
pub dropped: usize,
|
||||
}
|
||||
|
||||
/// A federated cluster of [`Node`]s running deterministic lockstep.
|
||||
/// A cluster of [`Node`]s running deterministic lockstep in one process.
|
||||
pub struct Cluster {
|
||||
pub nodes: Vec<Node>,
|
||||
pub frame: u64,
|
||||
|
|
@ -290,52 +275,54 @@ pub struct Cluster {
|
|||
}
|
||||
|
||||
impl Cluster {
|
||||
/// Build a cluster of `n` honest nodes.
|
||||
pub fn new_honest(n: usize) -> Self {
|
||||
/// A cluster of `n` honest nodes, each holding `genesis`.
|
||||
pub fn new_honest(n: usize, genesis: &State) -> Self {
|
||||
let nodes = (0..n)
|
||||
.map(|i| {
|
||||
let id = Said::hash(format!("node-{i}").as_bytes());
|
||||
Node::new(id)
|
||||
})
|
||||
.map(|i| Node::new(Said::hash(format!("node-{i}").as_bytes()), genesis))
|
||||
.collect();
|
||||
Self {
|
||||
nodes,
|
||||
frame: 0,
|
||||
frame: genesis.frame,
|
||||
dissent_log: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a cluster of `n` nodes where `faulty_count` of them are
|
||||
/// Byzantine. The faulty nodes are the *last* `faulty_count`
|
||||
/// entries; honest nodes occupy indices `0..n-faulty_count`.
|
||||
pub fn new_with_faulty(n: usize, faulty_count: usize) -> Self {
|
||||
let mut c = Self::new_honest(n);
|
||||
for i in (n - faulty_count)..n {
|
||||
c.nodes[i].faulty = true;
|
||||
/// A cluster of `n` nodes of which the last `faulty_count` are
|
||||
/// Byzantine.
|
||||
pub fn new_with_faulty(n: usize, faulty_count: usize, genesis: &State) -> Self {
|
||||
let mut c = Self::new_honest(n, genesis);
|
||||
for node in c.nodes.iter_mut().skip(n.saturating_sub(faulty_count)) {
|
||||
node.faulty = true;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Run one lockstep frame. Every node steps; votes are tallied;
|
||||
/// the modal hash wins iff it has a `> 2/3` supermajority.
|
||||
pub fn step(&mut self, transitions: Vec<Transition>) -> Result<FrameCommit, LockstepError> {
|
||||
/// Run one lockstep frame: every node applies `inputs`, the reported
|
||||
/// hashes are tallied, and the frame commits iff one hash has the
|
||||
/// supermajority.
|
||||
pub fn step(&mut self, inputs: Vec<Vec<u8>>) -> Result<FrameCommit, LockstepError> {
|
||||
if self.nodes.is_empty() {
|
||||
return Err(LockstepError::EmptyCluster);
|
||||
}
|
||||
let frame = Frame {
|
||||
frame_number: self.frame,
|
||||
ordered_transitions: transitions,
|
||||
};
|
||||
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
|
||||
.nodes
|
||||
.iter_mut()
|
||||
.map(|node| (node.id, node.step(&frame)))
|
||||
.map(|node| {
|
||||
let (hash, r) = node.step(&inputs);
|
||||
report.get_or_insert(r);
|
||||
(node.id, hash)
|
||||
})
|
||||
.collect();
|
||||
let report = report.expect("a non-empty cluster");
|
||||
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 {
|
||||
frame: frame.frame_number,
|
||||
frame,
|
||||
best: result.leading_votes,
|
||||
total,
|
||||
});
|
||||
|
|
@ -345,63 +332,57 @@ impl Cluster {
|
|||
.filter(|(_, hash)| *hash == state_hash)
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
if let Some(entry) = result.dissent_entry(frame.frame_number, &state_hash) {
|
||||
if let Some(entry) = result.dissent_entry(frame, &state_hash) {
|
||||
self.dissent_log.push(entry);
|
||||
}
|
||||
let commit = FrameCommit {
|
||||
frame: frame.frame_number,
|
||||
self.frame += 1;
|
||||
Ok(FrameCommit {
|
||||
frame,
|
||||
state_hash,
|
||||
committed_by,
|
||||
dissenters: result.dissenters,
|
||||
};
|
||||
self.frame += 1;
|
||||
Ok(commit)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: synthesize a deterministic sequence of transitions for a
|
||||
/// frame index. Used by tests and the CLI demo so that the demo output
|
||||
/// is reproducible.
|
||||
pub fn synthetic_transitions(frame_index: u64, count: usize) -> Vec<Transition> {
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let seed = *blake3::hash(format!("from-{frame_index}-{i}").as_bytes()).as_bytes();
|
||||
let owner = Signer::single(
|
||||
Aid::of_inception(Said::hash(&seed)),
|
||||
0,
|
||||
SigningKey::from_bytes(&seed),
|
||||
);
|
||||
let to = Aid::of_inception(Said::hash(format!("to-{frame_index}-{i}").as_bytes()));
|
||||
let byte = Said::hash(format!("byte-{frame_index}-{i}").as_bytes());
|
||||
Transition::sign(&owner, &byte, byte.0, to, frame_index)
|
||||
applied: report.applied(),
|
||||
dropped: report.results.len() - report.applied(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use smart_byte_state::demo::Workload;
|
||||
|
||||
/// A cluster of `n` nodes (the last `faulty` of them liars) over a
|
||||
/// fresh demo workload.
|
||||
fn cluster(n: usize, faulty: usize) -> (Cluster, Workload) {
|
||||
let workload = Workload::new(3, 2);
|
||||
(
|
||||
Cluster::new_with_faulty(n, faulty, &workload.genesis()),
|
||||
workload,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_node_honest_cluster_converges() {
|
||||
let mut cluster = Cluster::new_honest(4);
|
||||
let mut commits = Vec::new();
|
||||
let (mut cluster, mut workload) = cluster(4, 0);
|
||||
for f in 0..10 {
|
||||
let commit = cluster.step(synthetic_transitions(f, 3)).unwrap();
|
||||
let commit = cluster.step(workload.frame(f)).unwrap();
|
||||
assert_eq!(commit.committed_by.len(), 4);
|
||||
commits.push(commit);
|
||||
assert_eq!(commit.dropped, 0, "frame {f}: every demo input applies");
|
||||
assert_eq!(commit.state_hash, cluster.nodes[0].state.hash());
|
||||
}
|
||||
// All ten frames should have committed under unanimous votes.
|
||||
assert_eq!(commits.len(), 10);
|
||||
// Six bytes minted at frame 0, each moved once a frame since.
|
||||
assert_eq!(cluster.nodes[0].state.bytes.len(), 6);
|
||||
assert_eq!(cluster.nodes[0].state.frame, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_faulty_node_still_commits() {
|
||||
let mut cluster = Cluster::new_with_faulty(4, 1);
|
||||
let (mut cluster, mut workload) = cluster(4, 1);
|
||||
let liar = cluster.nodes[3].id;
|
||||
// 3-of-4 honest nodes is exactly the floor(2*4/3)+1 = 3 threshold.
|
||||
for f in 0..10 {
|
||||
let commit = cluster.step(synthetic_transitions(f, 3)).unwrap();
|
||||
let commit = cluster.step(workload.frame(f)).unwrap();
|
||||
assert_eq!(commit.committed_by.len(), 3);
|
||||
assert_eq!(commit.dissenters, vec![liar]);
|
||||
}
|
||||
|
|
@ -417,11 +398,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn an_honest_cluster_logs_no_dissent() {
|
||||
let mut cluster = Cluster::new_honest(4);
|
||||
let (mut cluster, mut workload) = cluster(4, 0);
|
||||
for f in 0..3 {
|
||||
assert!(
|
||||
cluster
|
||||
.step(synthetic_transitions(f, 2))
|
||||
.step(workload.frame(f))
|
||||
.unwrap()
|
||||
.dissenters
|
||||
.is_empty()
|
||||
|
|
@ -541,19 +522,34 @@ mod tests {
|
|||
#[test]
|
||||
fn two_faulty_nodes_in_four_breaks_supermajority() {
|
||||
// 2 honest, 2 faulty: best is 2/4 which is below the >2/3 = 3/4 threshold.
|
||||
let mut cluster = Cluster::new_with_faulty(4, 2);
|
||||
let err = cluster.step(synthetic_transitions(0, 3)).unwrap_err();
|
||||
let (mut cluster, mut workload) = cluster(4, 2);
|
||||
let err = cluster.step(workload.frame(0)).unwrap_err();
|
||||
assert!(matches!(err, LockstepError::NoSupermajority { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determinism_across_clusters() {
|
||||
let mut a = Cluster::new_honest(4);
|
||||
let mut b = Cluster::new_honest(4);
|
||||
let (mut a, mut wa) = cluster(4, 0);
|
||||
let (mut b, mut wb) = cluster(7, 0);
|
||||
for f in 0..5 {
|
||||
let ca = a.step(synthetic_transitions(f, 2)).unwrap();
|
||||
let cb = b.step(synthetic_transitions(f, 2)).unwrap();
|
||||
assert_eq!(ca.state_hash, cb.state_hash);
|
||||
let ca = a.step(wa.frame(f)).unwrap();
|
||||
let cb = b.step(wb.frame(f)).unwrap();
|
||||
assert_eq!(
|
||||
ca.state_hash, cb.state_hash,
|
||||
"frame {f}: the cluster's size is not part of the state"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_node_whose_state_diverged_is_outvoted() {
|
||||
// A node holding a different genesis computes different hashes from
|
||||
// the first frame on; the other three still commit.
|
||||
let (mut cluster, mut workload) = cluster(4, 0);
|
||||
cluster.nodes[3].state.registry.clear();
|
||||
let odd = cluster.nodes[3].id;
|
||||
let commit = cluster.step(workload.frame(0)).unwrap();
|
||||
assert_eq!(commit.dissenters, vec![odd]);
|
||||
assert_ne!(cluster.nodes[3].state.hash(), commit.state_hash);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
smart-byte/smart-byte-rs/crates/smart-byte-state/Cargo.toml
Normal file
19
smart-byte/smart-byte-rs/crates/smart-byte-state/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "smart-byte-state"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Smart Byte cluster state machine: the state every node holds, the frame transition, the state hash, and checkpoint-and-log persistence."
|
||||
|
||||
[dependencies]
|
||||
smart-byte-core = { workspace = true }
|
||||
smart-byte-keri-witness = { path = "../smart-byte-keri-witness" }
|
||||
serde = { workspace = true }
|
||||
serde_bytes = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
156
smart-byte/smart-byte-rs/crates/smart-byte-state/src/demo.rs
Normal file
156
smart-byte/smart-byte-rs/crates/smart-byte-state/src/demo.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
//! A deterministic workload for simulations and tests: one issuer, a ring of
|
||||
//! holders, and the bytes the issuer mints to them passed around the ring.
|
||||
//!
|
||||
//! Frame 0 carries every party's inception and the mints; each later frame
|
||||
//! moves every byte one holder along. Keys derive from labels, so the same
|
||||
//! workload produces the same inputs — and so the same state hashes — on
|
||||
//! every run.
|
||||
|
||||
use smart_byte_core::keys::IndexedSignature;
|
||||
use smart_byte_core::ownership::Transition;
|
||||
use smart_byte_core::{Aid, Blake3Hash, Cargo, Envelope, JouleCost, Said, Signer, SigningKey};
|
||||
use smart_byte_keri_witness::{Controller, KeyEvent, KeyPair, Threshold};
|
||||
|
||||
use crate::input::Input;
|
||||
use crate::state::State;
|
||||
|
||||
/// The cargo type the demo issuer is registered for: USD.
|
||||
pub const USD: u64 = 1;
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
/// A party: its controller (whose current keys sign) and its inception, as
|
||||
/// an input.
|
||||
struct Party {
|
||||
controller: Controller,
|
||||
inception: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Party {
|
||||
fn new(label: &str) -> Self {
|
||||
let (controller, icp) = Controller::incept(
|
||||
vec![key(&format!("{label}-0"))],
|
||||
vec![key(&format!("{label}-1"))],
|
||||
Threshold(1),
|
||||
Threshold(1),
|
||||
vec![],
|
||||
Threshold(0),
|
||||
)
|
||||
.expect("a one-key inception");
|
||||
let event = KeyEvent::Inception(icp);
|
||||
let signatures: Vec<IndexedSignature> = controller
|
||||
.sign_event(&event)
|
||||
.expect("the controller signs its inception")
|
||||
.into_iter()
|
||||
.map(|(index, signature)| IndexedSignature {
|
||||
index: u64::from(index),
|
||||
signature,
|
||||
})
|
||||
.collect();
|
||||
Party {
|
||||
inception: Input::key_event(&event, &signatures),
|
||||
controller,
|
||||
}
|
||||
}
|
||||
|
||||
fn signer(&self) -> Signer {
|
||||
self.controller.signer()
|
||||
}
|
||||
|
||||
fn aid(&self) -> Aid {
|
||||
self.controller.aid
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte in the workload: its identity, owner and history hash.
|
||||
struct Held {
|
||||
identity: Said,
|
||||
owner: usize,
|
||||
history: Blake3Hash,
|
||||
}
|
||||
|
||||
/// The workload.
|
||||
pub struct Workload {
|
||||
issuer: Party,
|
||||
holders: Vec<Party>,
|
||||
bytes: Vec<Held>,
|
||||
mints: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Workload {
|
||||
/// One issuer, `holders` holders, and `per_holder` USD bytes minted to
|
||||
/// each.
|
||||
pub fn new(holders: usize, per_holder: usize) -> Self {
|
||||
let issuer = Party::new("issuer");
|
||||
let holders: Vec<Party> = (0..holders.max(1))
|
||||
.map(|i| Party::new(&format!("holder-{i}")))
|
||||
.collect();
|
||||
let mut bytes = Vec::new();
|
||||
let mut mints = Vec::new();
|
||||
for (owner, holder) in holders.iter().enumerate() {
|
||||
for k in 0..per_holder {
|
||||
let nonce: [u8; 16] =
|
||||
Said::hash(format!("smart-byte demo nonce {owner}-{k}").as_bytes()).0[..16]
|
||||
.try_into()
|
||||
.expect("sixteen bytes");
|
||||
let micros = 1_000_000 * (1 + (owner * per_holder + k) as i64);
|
||||
let byte = Envelope::issue(
|
||||
&issuer.signer(),
|
||||
holder.aid(),
|
||||
nonce,
|
||||
Cargo::usd_micros(micros),
|
||||
JouleCost::default(),
|
||||
);
|
||||
mints.push(Input::mint(&byte.provenance));
|
||||
bytes.push(Held {
|
||||
identity: byte.identity(),
|
||||
owner,
|
||||
history: byte.identity().0,
|
||||
});
|
||||
}
|
||||
}
|
||||
Workload {
|
||||
issuer,
|
||||
holders,
|
||||
bytes,
|
||||
mints,
|
||||
}
|
||||
}
|
||||
|
||||
/// The genesis state: the issuer registered for USD.
|
||||
pub fn genesis(&self) -> State {
|
||||
State::genesis([(self.issuer.aid(), USD)])
|
||||
}
|
||||
|
||||
/// Frame `frame`'s inputs. Frame 0 incepts every party and mints; each
|
||||
/// later frame moves every byte to the next holder. Frames must be taken
|
||||
/// in order.
|
||||
pub fn frame(&mut self, frame: u64) -> Vec<Vec<u8>> {
|
||||
if frame == 0 {
|
||||
let mut inputs = vec![self.issuer.inception.clone()];
|
||||
inputs.extend(self.holders.iter().map(|h| h.inception.clone()));
|
||||
inputs.extend(self.mints.iter().cloned());
|
||||
return inputs;
|
||||
}
|
||||
let ring = self.holders.len();
|
||||
self.bytes
|
||||
.iter_mut()
|
||||
.map(|held| {
|
||||
let to = (held.owner + 1) % ring;
|
||||
let t = Transition::sign(
|
||||
&self.holders[held.owner].signer(),
|
||||
&held.identity,
|
||||
held.history,
|
||||
self.holders[to].aid(),
|
||||
frame,
|
||||
);
|
||||
held.owner = to;
|
||||
held.history = t.next_history_hash();
|
||||
Input::transfer(&held.identity, &t)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
217
smart-byte/smart-byte-rs/crates/smart-byte-state/src/frame.rs
Normal file
217
smart-byte/smart-byte-rs/crates/smart-byte-state/src/frame.rs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
//! 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
|
||||
//! 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.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use smart_byte_core::keys::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 crate::input::{Input, InputClass, InputError};
|
||||
use crate::state::{ByteRecord, ControllerRecord, State};
|
||||
|
||||
/// What became of one input.
|
||||
#[derive(Debug)]
|
||||
pub struct InputResult {
|
||||
/// BLAKE3-256 of the input's bytes.
|
||||
pub input: Blake3Hash,
|
||||
/// The class it names, if it names one.
|
||||
pub class: Option<InputClass>,
|
||||
/// `Ok` if it was applied; why it was dropped otherwise.
|
||||
pub outcome: Result<(), InputError>,
|
||||
}
|
||||
|
||||
/// 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).
|
||||
#[derive(Debug)]
|
||||
pub struct FrameReport {
|
||||
/// The frame applied.
|
||||
pub frame: u64,
|
||||
pub results: Vec<InputResult>,
|
||||
pub inputs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl FrameReport {
|
||||
/// How many inputs were applied.
|
||||
pub fn applied(&self) -> usize {
|
||||
self.results.iter().filter(|r| r.outcome.is_ok()).count()
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
let distinct: BTreeMap<Blake3Hash, &Vec<u8>> = inputs
|
||||
.iter()
|
||||
.map(|bytes| (*blake3::hash(bytes).as_bytes(), bytes))
|
||||
.collect();
|
||||
let mut ordered: Vec<(
|
||||
Option<InputClass>,
|
||||
Blake3Hash,
|
||||
&Vec<u8>,
|
||||
Result<Input, InputError>,
|
||||
)> = distinct
|
||||
.into_iter()
|
||||
.map(|(hash, bytes)| {
|
||||
let (class, decoded) = Input::decode(bytes);
|
||||
(class, hash, bytes, decoded)
|
||||
})
|
||||
.collect();
|
||||
// By class, inputs naming none last; then by digest.
|
||||
ordered.sort_by_key(|(class, hash, ..)| (class.is_none(), *class, *hash));
|
||||
|
||||
let mut report = FrameReport {
|
||||
frame,
|
||||
results: Vec::with_capacity(ordered.len()),
|
||||
inputs: Vec::with_capacity(ordered.len()),
|
||||
};
|
||||
for (class, hash, bytes, decoded) in ordered {
|
||||
let outcome = decoded.and_then(|input| self.apply(input));
|
||||
report.results.push(InputResult {
|
||||
input: hash,
|
||||
class,
|
||||
outcome,
|
||||
});
|
||||
report.inputs.push(bytes.clone());
|
||||
}
|
||||
self.frame += 1;
|
||||
report
|
||||
}
|
||||
|
||||
fn apply(&mut self, input: Input) -> Result<(), InputError> {
|
||||
match input {
|
||||
Input::KeyEvent { event, signatures } => self.key_event(&event, &signatures),
|
||||
Input::Mint(provenance) => self.mint(&provenance),
|
||||
Input::Transfer { byte, transition } => self.transfer(&byte, &transition),
|
||||
}
|
||||
}
|
||||
|
||||
/// A key event extends its controller's log (identity spec §4), or starts
|
||||
/// one: an inception for a controller the cluster does not hold yet.
|
||||
fn key_event(
|
||||
&mut self,
|
||||
event: &KeyEvent,
|
||||
signatures: &[smart_byte_core::keys::IndexedSignature],
|
||||
) -> Result<(), InputError> {
|
||||
let controller = *event.controller();
|
||||
let mut replay = match (event, self.controllers.get(&controller)) {
|
||||
(KeyEvent::Inception(_), Some(_)) => return Err(InputError::ControllerExists),
|
||||
(KeyEvent::Inception(_), None) => Replay::new(LogVerifier::new()),
|
||||
(_, 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())?;
|
||||
let state = replay.state().expect("an accepted event leaves a state");
|
||||
self.controllers
|
||||
.insert(controller, ControllerRecord::from_state(state));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return Err(InputError::SaidMismatch);
|
||||
}
|
||||
let cargo_type = provenance.cargo.type_tag;
|
||||
if !self.authorizes(&provenance.issuer, cargo_type) {
|
||||
return Err(InputError::IssuerNotRegistered(cargo_type));
|
||||
}
|
||||
self.signed_by(
|
||||
&provenance.issuer,
|
||||
&provenance.signature,
|
||||
&provenance.signed_bytes(),
|
||||
)?;
|
||||
let identity = provenance.d;
|
||||
if self.bytes.contains_key(&identity) {
|
||||
return Err(InputError::DuplicateByte);
|
||||
}
|
||||
self.bytes.insert(
|
||||
identity,
|
||||
ByteRecord {
|
||||
issuer: provenance.issuer,
|
||||
cargo: provenance.cargo.clone(),
|
||||
minted: self.frame,
|
||||
owner: provenance.recipient,
|
||||
history_hash: identity.0,
|
||||
},
|
||||
);
|
||||
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)?;
|
||||
if transition.frame != self.frame {
|
||||
return Err(InputError::WrongFrame {
|
||||
named: transition.frame,
|
||||
current: self.frame,
|
||||
});
|
||||
}
|
||||
if transition.previous_history_hash != record.history_hash {
|
||||
return Err(InputError::BrokenHistoryChain);
|
||||
}
|
||||
if transition.from != record.owner {
|
||||
return Err(InputError::NotCurrentOwner);
|
||||
}
|
||||
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();
|
||||
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,
|
||||
message: &[u8],
|
||||
) -> Result<(), InputError> {
|
||||
let record = self
|
||||
.controllers
|
||||
.get(signer)
|
||||
.ok_or_else(|| InputError::UnknownController(signer.to_text()))?;
|
||||
if signature.establishment != record.establishment {
|
||||
return Err(InputError::EstablishmentNotInForce {
|
||||
named: signature.establishment,
|
||||
in_force: record.establishment,
|
||||
});
|
||||
}
|
||||
Ok(verify_against(
|
||||
&record.key_state(),
|
||||
message,
|
||||
&signature.signatures,
|
||||
)?)
|
||||
}
|
||||
}
|
||||
289
smart-byte/smart-byte-rs/crates/smart-byte-state/src/input.rs
Normal file
289
smart-byte/smart-byte-rs/crates/smart-byte-state/src/input.rs
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
//! Frame inputs (cluster state spec §3): what clients submit and nodes
|
||||
//! exchange, each the canonical encoding of a one-member map naming its
|
||||
//! class.
|
||||
//!
|
||||
//! ```text
|
||||
//! {"key_event": {"event": <key event>, "signatures": [{"index", "signature"}]}}
|
||||
//! {"mint": <provenance>}
|
||||
//! {"transfer": {"byte": <identity>, "transition": <transition>}}
|
||||
//! ```
|
||||
//!
|
||||
//! An input is identified by the BLAKE3-256 digest of its bytes, which is
|
||||
//! also what orders it within its class (treatise §9.4 step 4).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
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_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.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum InputClass {
|
||||
KeyEvent,
|
||||
Mint,
|
||||
Transfer,
|
||||
}
|
||||
|
||||
impl InputClass {
|
||||
/// The class's member name on the wire.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
InputClass::KeyEvent => "key_event",
|
||||
InputClass::Mint => "mint",
|
||||
InputClass::Transfer => "transfer",
|
||||
}
|
||||
}
|
||||
|
||||
fn named(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"key_event" => Some(InputClass::KeyEvent),
|
||||
"mint" => Some(InputClass::Mint),
|
||||
"transfer" => Some(InputClass::Transfer),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A decoded input.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Input {
|
||||
/// A controller's key event and the signatures over it (identity spec
|
||||
/// §3.5).
|
||||
KeyEvent {
|
||||
event: KeyEvent,
|
||||
signatures: Vec<IndexedSignature>,
|
||||
},
|
||||
/// 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.
|
||||
Transfer { byte: Said, transition: Transition },
|
||||
}
|
||||
|
||||
/// Why an input was dropped (cluster state spec §4).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum InputError {
|
||||
#[error("not canonical: {0}")]
|
||||
Canonical(#[from] CanonicalError),
|
||||
#[error("not an input: {0}")]
|
||||
Malformed(String),
|
||||
#[error("key event: {0}")]
|
||||
KeyEvent(#[from] KeriError),
|
||||
#[error("the provenance does not hash to the identity it states")]
|
||||
SaidMismatch,
|
||||
#[error("an inception for a controller the cluster already holds")]
|
||||
ControllerExists,
|
||||
#[error("no key-event log for {0}")]
|
||||
UnknownController(String),
|
||||
#[error("the issuer is not registered for cargo type {0}")]
|
||||
IssuerNotRegistered(u64),
|
||||
#[error("signed under establishment event {named}; {in_force} is in force")]
|
||||
EstablishmentNotInForce { named: u64, in_force: u64 },
|
||||
#[error("signature: {0}")]
|
||||
Signature(#[from] ControllerSignatureError),
|
||||
#[error("the cluster already holds a byte with this identity")]
|
||||
DuplicateByte,
|
||||
#[error("the cluster holds no byte with this identity")]
|
||||
UnknownByte,
|
||||
#[error("the transition names frame {named}; this is frame {current}")]
|
||||
WrongFrame { named: u64, current: u64 },
|
||||
#[error("the transition does not continue the byte's history")]
|
||||
BrokenHistoryChain,
|
||||
#[error("the transition is not signed away by the byte's owner")]
|
||||
NotCurrentOwner,
|
||||
}
|
||||
|
||||
impl InputError {
|
||||
/// The error class as the conformance pack names it.
|
||||
pub fn class(&self) -> &'static str {
|
||||
match self {
|
||||
InputError::Canonical(e) => e.class(),
|
||||
InputError::Malformed(_) => "malformed_input",
|
||||
InputError::KeyEvent(e) => e.class(),
|
||||
InputError::SaidMismatch => "said_mismatch",
|
||||
InputError::ControllerExists => "controller_exists",
|
||||
InputError::UnknownController(_) => "unknown_controller",
|
||||
InputError::IssuerNotRegistered(_) => "issuer_not_registered",
|
||||
InputError::EstablishmentNotInForce { .. } => "establishment_not_in_force",
|
||||
InputError::Signature(e) => e.class(),
|
||||
InputError::DuplicateByte => "duplicate_byte",
|
||||
InputError::UnknownByte => "unknown_byte",
|
||||
InputError::WrongFrame { .. } => "wrong_frame",
|
||||
InputError::BrokenHistoryChain => "broken_history_chain",
|
||||
InputError::NotCurrentOwner => "not_current_owner",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct TransferBody {
|
||||
byte: Said,
|
||||
transition: Transition,
|
||||
}
|
||||
|
||||
fn one(name: &str, body: Value) -> Vec<u8> {
|
||||
canonical::encode(&Value::Map(vec![(Value::text(name), body)]))
|
||||
.expect("an input always encodes")
|
||||
}
|
||||
|
||||
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",
|
||||
wire::to_value(event).expect("an event always encodes"),
|
||||
),
|
||||
(
|
||||
"signatures",
|
||||
wire::to_value(signatures).expect("signatures always encode"),
|
||||
),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
/// The wire form of a mint.
|
||||
pub fn mint(provenance: &Provenance) -> Vec<u8> {
|
||||
one(
|
||||
"mint",
|
||||
wire::to_value(provenance).expect("a provenance always encodes"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The wire form of a transfer.
|
||||
pub fn transfer(byte: &Said, transition: &Transition) -> Vec<u8> {
|
||||
one(
|
||||
"transfer",
|
||||
wire::to_value(&TransferBody {
|
||||
byte: *byte,
|
||||
transition: transition.clone(),
|
||||
})
|
||||
.expect("a transfer always encodes"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Decode an input: its class, if it names one, and the input or why it
|
||||
/// is not one.
|
||||
///
|
||||
/// The bytes must be canonical, a one-member map naming a class, and the
|
||||
/// 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.
|
||||
pub fn decode(bytes: &[u8]) -> (Option<InputClass>, Result<Input, InputError>) {
|
||||
let value = match canonical::decode(bytes) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return (None, Err(e.into())),
|
||||
};
|
||||
let (name, body) = match value {
|
||||
Value::Map(mut members) if members.len() == 1 => match members.pop() {
|
||||
Some((Value::Text(name), body)) => (name, body),
|
||||
_ => return (None, Err(malformed("an input names its class in text"))),
|
||||
},
|
||||
_ => 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")),
|
||||
);
|
||||
};
|
||||
let input = match class {
|
||||
InputClass::KeyEvent => key_event_body(body),
|
||||
InputClass::Mint => exact::<Provenance>(&body).map(Input::Mint),
|
||||
InputClass::Transfer => exact::<TransferBody>(&body).map(|t| Input::Transfer {
|
||||
byte: t.byte,
|
||||
transition: t.transition,
|
||||
}),
|
||||
};
|
||||
(Some(class), input)
|
||||
}
|
||||
}
|
||||
|
||||
fn malformed(why: &str) -> InputError {
|
||||
InputError::Malformed(why.to_owned())
|
||||
}
|
||||
|
||||
/// `value` decoded as a `T`, if it is exactly a `T`'s encoding.
|
||||
fn exact<T: serde::de::DeserializeOwned + Serialize>(value: &Value) -> Result<T, InputError> {
|
||||
let bytes = canonical::encode(value).map_err(InputError::Canonical)?;
|
||||
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"));
|
||||
};
|
||||
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),
|
||||
_ => {
|
||||
return Err(malformed(
|
||||
"a key_event input has exactly `event` and `signatures`",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
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 event_bytes = canonical::encode(&event).map_err(InputError::Canonical)?;
|
||||
let event = KeyEvent::decode(&event_bytes, true)?;
|
||||
Ok(Input::KeyEvent { event, signatures })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_input_is_one_member_naming_its_class() {
|
||||
let class = |v: Value| {
|
||||
let (c, r) = Input::decode(&canonical::encode(&v).unwrap());
|
||||
(c, r.err().map(|e| e.class()))
|
||||
};
|
||||
assert_eq!(
|
||||
class(Value::map([("burn", Value::Null)])),
|
||||
(None, Some("malformed_input"))
|
||||
);
|
||||
assert_eq!(
|
||||
class(Value::map([
|
||||
("mint", Value::Null),
|
||||
("transfer", Value::Null)
|
||||
])),
|
||||
(None, Some("malformed_input"))
|
||||
);
|
||||
assert_eq!(class(Value::Array(vec![])), (None, Some("malformed_input")));
|
||||
assert_eq!(
|
||||
class(Value::map([("transfer", Value::Null)])),
|
||||
(Some(InputClass::Transfer), Some("malformed_input"))
|
||||
);
|
||||
assert_eq!(
|
||||
class(Value::map([(
|
||||
"key_event",
|
||||
Value::map([("event", Value::Null)])
|
||||
)])),
|
||||
(Some(InputClass::KeyEvent), 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!(
|
||||
(c, r.err().map(|e| e.class())),
|
||||
(None, Some("non_shortest_integer"))
|
||||
);
|
||||
}
|
||||
}
|
||||
30
smart-byte/smart-byte-rs/crates/smart-byte-state/src/lib.rs
Normal file
30
smart-byte/smart-byte-rs/crates/smart-byte-state/src/lib.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
//! # smart-byte-state
|
||||
//!
|
||||
//! The Smart Byte cluster state machine (`smart-byte/spec/cluster_state.md`):
|
||||
//! the state every node of a cluster holds, the deterministic frame
|
||||
//! transition that advances it, the state hash a frame commits at, and the
|
||||
//! checkpoint-and-log persistence that survives a restart.
|
||||
//!
|
||||
//! * [`State`] — frame number, issuer registry, controllers' key states and
|
||||
//! 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.
|
||||
//! * [`Store`] — checkpoint and replay log (treatise §16).
|
||||
//!
|
||||
//! The lockstep protocol around it — exchanging inputs and tallying state
|
||||
//! hashes — is `smart-byte-lockstep`.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod demo;
|
||||
pub mod frame;
|
||||
pub mod input;
|
||||
pub mod state;
|
||||
pub mod store;
|
||||
|
||||
pub use frame::{FrameReport, InputResult};
|
||||
pub use input::{Input, InputClass, InputError};
|
||||
pub use state::{ByteRecord, ControllerRecord, State};
|
||||
pub use store::{Store, StoreError};
|
||||
208
smart-byte/smart-byte-rs/crates/smart-byte-state/src/state.rs
Normal file
208
smart-byte/smart-byte-rs/crates/smart-byte-state/src/state.rs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
//! The cluster state (cluster state spec §2): what every node holds, and
|
||||
//! what the state hash commits to.
|
||||
//!
|
||||
//! ```text
|
||||
//! {
|
||||
//! "frame": the next frame to apply,
|
||||
//! "registry": {issuer AID: [cargo types, ascending]},
|
||||
//! "controllers": {AID: key state},
|
||||
//! "bytes": {identity: owner, history hash, issuer, cargo, mint frame}
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! The canonical encoding of this map is the checkpoint (treatise §16.1),
|
||||
//! and its BLAKE3-256 digest is the state hash (§9.4 step 6). Map keys are
|
||||
//! sorted by the encoding, never by the order the nodes hold them in, so
|
||||
//! every node that agrees on the state writes the same bytes.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smart_byte_core::keys::KeyState;
|
||||
use smart_byte_core::wire::{self, WireError};
|
||||
use smart_byte_core::{Aid, Blake3Hash, Cargo, PrefixedKey, Said};
|
||||
use smart_byte_keri_witness::{ControllerState, PublicKey, Threshold};
|
||||
|
||||
/// The state every node of a cluster holds.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
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,
|
||||
/// Each issuer and the cargo types it may mint (treatise §15.1).
|
||||
pub registry: BTreeMap<Aid, BTreeSet<u64>>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// A controller's key state as the cluster holds it: what its key-event log
|
||||
/// has established (identity spec §4).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ControllerRecord {
|
||||
/// The sequence number of the establishment event whose keys are in
|
||||
/// force. Signatures must name it.
|
||||
pub establishment: u64,
|
||||
/// The signing keys in force: algorithm identifier and key.
|
||||
pub keys: Vec<PublicKey>,
|
||||
/// The signing threshold in force.
|
||||
pub threshold: u64,
|
||||
/// The next-key digests committed.
|
||||
pub next_keys: Vec<Said>,
|
||||
/// The next-key threshold committed.
|
||||
pub next_threshold: u64,
|
||||
/// The last applied event's sequence number.
|
||||
pub sequence: u64,
|
||||
/// The last applied event's SAID.
|
||||
pub last_event: Said,
|
||||
}
|
||||
|
||||
/// A byte as the cluster holds it (treatise §10.1): who owns it now, where
|
||||
/// its history stands, and what its issuer attested to.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ByteRecord {
|
||||
/// The issuer that minted it.
|
||||
pub issuer: Aid,
|
||||
/// The cargo, as its issuer attested it.
|
||||
pub cargo: Cargo,
|
||||
/// The frame it was minted in.
|
||||
pub minted: u64,
|
||||
/// The current owner.
|
||||
pub owner: Aid,
|
||||
/// The current history hash (treatise §10.1).
|
||||
#[serde(with = "smart_byte_core::wire::fixed_bytes")]
|
||||
pub history_hash: Blake3Hash,
|
||||
}
|
||||
|
||||
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();
|
||||
for (issuer, cargo_type) in registry {
|
||||
state.registry.entry(issuer).or_default().insert(cargo_type);
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
/// The canonical encoding: the checkpoint.
|
||||
pub fn to_cbor(&self) -> Vec<u8> {
|
||||
wire::to_vec(self).expect("a state always encodes")
|
||||
}
|
||||
|
||||
/// Decode a checkpoint, accepting only a state's own canonical encoding.
|
||||
pub fn from_cbor(bytes: &[u8]) -> Result<Self, WireError> {
|
||||
wire::from_slice(bytes)
|
||||
}
|
||||
|
||||
/// The state hash: BLAKE3-256 of the canonical encoding.
|
||||
pub fn hash(&self) -> Blake3Hash {
|
||||
*blake3::hash(&self.to_cbor()).as_bytes()
|
||||
}
|
||||
|
||||
/// Whether `issuer` may mint `cargo_type`.
|
||||
pub fn authorizes(&self, issuer: &Aid, cargo_type: u64) -> bool {
|
||||
self.registry
|
||||
.get(issuer)
|
||||
.is_some_and(|types| types.contains(&cargo_type))
|
||||
}
|
||||
}
|
||||
|
||||
impl ControllerRecord {
|
||||
/// The record of the state a replayed key-event log reached.
|
||||
pub fn from_state(state: &ControllerState) -> Self {
|
||||
Self {
|
||||
establishment: state.establishment,
|
||||
keys: state.current_keys.clone(),
|
||||
threshold: state.current_threshold.0,
|
||||
next_keys: state.next_keys.clone(),
|
||||
next_threshold: state.next_threshold.0,
|
||||
sequence: state.last_sequence,
|
||||
last_event: state.last_said,
|
||||
}
|
||||
}
|
||||
|
||||
/// The state a replay of `controller`'s log resumes from.
|
||||
pub fn to_state(&self, controller: Aid) -> ControllerState {
|
||||
ControllerState {
|
||||
controller,
|
||||
establishment: self.establishment,
|
||||
current_keys: self.keys.clone(),
|
||||
current_threshold: Threshold(self.threshold),
|
||||
next_keys: self.next_keys.clone(),
|
||||
next_threshold: Threshold(self.next_threshold),
|
||||
last_sequence: self.sequence,
|
||||
last_said: self.last_event,
|
||||
witnesses: BTreeSet::new(),
|
||||
witness_threshold: Threshold(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The key state signatures under the current establishment are checked
|
||||
/// against.
|
||||
pub fn key_state(&self) -> KeyState {
|
||||
KeyState {
|
||||
keys: self.keys.iter().map(PrefixedKey::from).collect(),
|
||||
threshold: self.threshold,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use smart_byte_core::canonical;
|
||||
|
||||
fn aid(label: &str) -> Aid {
|
||||
Aid::of_inception(Said::hash(label.as_bytes()))
|
||||
}
|
||||
|
||||
#[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 bytes = state.to_cbor();
|
||||
// The canonical decoder accepts it unchanged: keys are in encoded order.
|
||||
assert_eq!(
|
||||
canonical::encode(&canonical::decode(&bytes).unwrap()).unwrap(),
|
||||
bytes
|
||||
);
|
||||
assert_eq!(State::from_cbor(&bytes).unwrap(), state);
|
||||
assert_eq!(state.hash(), *blake3::hash(&bytes).as_bytes());
|
||||
assert!(state.authorizes(&aid("b"), 2) && !state.authorizes(&aid("a"), 2));
|
||||
}
|
||||
|
||||
#[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 canonical::Value::Map(mut members) = canonical::decode(&state.to_cbor()).unwrap()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
for (k, v) in &mut members {
|
||||
if *k == canonical::Value::text("registry") {
|
||||
let canonical::Value::Map(issuers) = v else {
|
||||
unreachable!()
|
||||
};
|
||||
issuers[0].1 = canonical::Value::Array(vec![
|
||||
canonical::Value::Unsigned(2),
|
||||
canonical::Value::Unsigned(1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
let reordered = canonical::encode(&canonical::Value::Map(members)).unwrap();
|
||||
assert_eq!(State::from_cbor(&reordered), Err(WireError::NotItsEncoding));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_frame_number_is_part_of_the_hash() {
|
||||
let a = State::genesis([(aid("a"), 1)]);
|
||||
let mut b = a.clone();
|
||||
b.frame += 1;
|
||||
assert_ne!(a.hash(), b.hash());
|
||||
}
|
||||
}
|
||||
174
smart-byte/smart-byte-rs/crates/smart-byte-state/src/store.rs
Normal file
174
smart-byte/smart-byte-rs/crates/smart-byte-state/src/store.rs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
//! Persistence (treatise §16; cluster state spec §6): a checkpoint and a
|
||||
//! replay log.
|
||||
//!
|
||||
//! * `checkpoint.bin` is the state's canonical encoding — so its BLAKE3
|
||||
//! digest is the state hash. It is replaced atomically: written to a
|
||||
//! temporary file, synced, and renamed over the old one.
|
||||
//! * `log.bin` holds one record per frame committed since the checkpoint:
|
||||
//! a four-byte big-endian length, then the canonical encoding of
|
||||
//! `{"frame", "inputs", "state_hash"}` — the frame's committed inputs in
|
||||
//! the order they were applied, and the hash the frame committed at.
|
||||
//!
|
||||
//! Recovery loads the checkpoint and replays the log, refusing a log that
|
||||
//! skips a frame or a replay that reaches any hash but the one recorded.
|
||||
//! A node opened for the first time is seeded with its cluster's genesis
|
||||
//! state, written as the first checkpoint (§16.3).
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smart_byte_core::{Blake3Hash, wire};
|
||||
|
||||
use crate::state::State;
|
||||
|
||||
/// The checkpoint's file name.
|
||||
pub const CHECKPOINT: &str = "checkpoint.bin";
|
||||
/// The log's file name.
|
||||
pub const LOG: &str = "log.bin";
|
||||
|
||||
/// Why a store could not be opened or written.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StoreError {
|
||||
#[error(transparent)]
|
||||
Io(#[from] io::Error),
|
||||
#[error("the store is corrupt: {0}")]
|
||||
Corrupt(String),
|
||||
}
|
||||
|
||||
/// One logged frame.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct LogRecord {
|
||||
frame: u64,
|
||||
inputs: Vec<serde_bytes::ByteBuf>,
|
||||
#[serde(with = "smart_byte_core::wire::fixed_bytes")]
|
||||
state_hash: Blake3Hash,
|
||||
}
|
||||
|
||||
/// A node's persisted state.
|
||||
pub struct Store {
|
||||
dir: PathBuf,
|
||||
cadence: u64,
|
||||
since_checkpoint: u64,
|
||||
log: File,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Open the store in `dir` and recover the state it holds. With no
|
||||
/// checkpoint yet, `genesis` is written as the first. Every `cadence`
|
||||
/// committed frames, [`Store::commit`] checkpoints and empties the log.
|
||||
pub fn open_with_genesis(
|
||||
dir: &Path,
|
||||
cadence: u64,
|
||||
genesis: &State,
|
||||
) -> Result<(Store, State), StoreError> {
|
||||
fs::create_dir_all(dir)?;
|
||||
let checkpoint = dir.join(CHECKPOINT);
|
||||
if !checkpoint.exists() {
|
||||
write_atomically(dir, CHECKPOINT, &genesis.to_cbor())?;
|
||||
}
|
||||
let mut state = State::from_cbor(&fs::read(&checkpoint)?)
|
||||
.map_err(|e| StoreError::Corrupt(format!("checkpoint: {e}")))?;
|
||||
|
||||
let mut since_checkpoint = 0;
|
||||
let log_path = dir.join(LOG);
|
||||
if log_path.exists() {
|
||||
let mut bytes = Vec::new();
|
||||
File::open(&log_path)?.read_to_end(&mut bytes)?;
|
||||
let mut at = 0;
|
||||
while at < bytes.len() {
|
||||
let len = bytes
|
||||
.get(at..at + 4)
|
||||
.map(|b| u32::from_be_bytes(b.try_into().expect("four bytes")) as usize)
|
||||
.ok_or_else(|| StoreError::Corrupt("a log record is cut short".into()))?;
|
||||
let body = bytes
|
||||
.get(at + 4..at + 4 + len)
|
||||
.ok_or_else(|| StoreError::Corrupt("a log record is cut short".into()))?;
|
||||
at += 4 + len;
|
||||
let record: LogRecord = wire::from_slice(body)
|
||||
.map_err(|e| StoreError::Corrupt(format!("log record: {e}")))?;
|
||||
// A crash between writing a checkpoint and emptying the log
|
||||
// leaves records the checkpoint already holds.
|
||||
if record.frame < state.frame {
|
||||
continue;
|
||||
}
|
||||
if record.frame != state.frame {
|
||||
return Err(StoreError::Corrupt(format!(
|
||||
"the log skips from frame {} to {}",
|
||||
state.frame, record.frame
|
||||
)));
|
||||
}
|
||||
let inputs: Vec<Vec<u8>> =
|
||||
record.inputs.into_iter().map(|b| b.into_vec()).collect();
|
||||
state.apply_frame(&inputs);
|
||||
if state.hash() != record.state_hash {
|
||||
return Err(StoreError::Corrupt(format!(
|
||||
"replaying frame {} does not reach the hash it committed at",
|
||||
record.frame
|
||||
)));
|
||||
}
|
||||
since_checkpoint += 1;
|
||||
}
|
||||
}
|
||||
let log = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)?;
|
||||
Ok((
|
||||
Store {
|
||||
dir: dir.to_owned(),
|
||||
cadence: cadence.max(1),
|
||||
since_checkpoint,
|
||||
log,
|
||||
},
|
||||
state,
|
||||
))
|
||||
}
|
||||
|
||||
/// Record a committed frame: the inputs it applied, in order, and the
|
||||
/// state it reached. Every `cadence` frames the state is checkpointed and
|
||||
/// the log emptied.
|
||||
pub fn commit(
|
||||
&mut self,
|
||||
frame: u64,
|
||||
inputs: &[Vec<u8>],
|
||||
state: &State,
|
||||
) -> Result<(), StoreError> {
|
||||
let record = wire::to_vec(&LogRecord {
|
||||
frame,
|
||||
inputs: inputs
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(serde_bytes::ByteBuf::from)
|
||||
.collect(),
|
||||
state_hash: state.hash(),
|
||||
})
|
||||
.expect("a log record always encodes");
|
||||
let len = u32::try_from(record.len())
|
||||
.map_err(|_| StoreError::Corrupt("a log record over 4 GiB".into()))?;
|
||||
self.log.write_all(&len.to_be_bytes())?;
|
||||
self.log.write_all(&record)?;
|
||||
self.log.sync_data()?;
|
||||
self.since_checkpoint += 1;
|
||||
if self.since_checkpoint >= self.cadence {
|
||||
write_atomically(&self.dir, CHECKPOINT, &state.to_cbor())?;
|
||||
self.log.set_len(0)?;
|
||||
self.log.sync_all()?;
|
||||
self.since_checkpoint = 0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace `dir/name` with `bytes` so that a crash leaves the old file or the
|
||||
/// new one, never a part of either.
|
||||
fn write_atomically(dir: &Path, name: &str, bytes: &[u8]) -> io::Result<()> {
|
||||
let tmp = dir.join(format!("{name}.tmp"));
|
||||
let mut f = File::create(&tmp)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()?;
|
||||
fs::rename(&tmp, dir.join(name))?;
|
||||
File::open(dir)?.sync_all()
|
||||
}
|
||||
381
smart-byte/smart-byte-rs/crates/smart-byte-state/tests/frames.rs
Normal file
381
smart-byte/smart-byte-rs/crates/smart-byte-state/tests/frames.rs
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
//! The frame transition and persistence, end to end.
|
||||
|
||||
use smart_byte_core::keys::IndexedSignature;
|
||||
use smart_byte_core::ownership::Transition;
|
||||
use smart_byte_core::{Aid, Cargo, Envelope, JouleCost, Said, SigningKey};
|
||||
use smart_byte_keri_witness::{Controller, KeyEvent, KeyPair, Threshold};
|
||||
use smart_byte_state::demo::Workload;
|
||||
use smart_byte_state::{Input, InputClass, State, Store};
|
||||
|
||||
fn key(label: &str) -> KeyPair {
|
||||
KeyPair::from_signing(SigningKey::from_bytes(&Said::hash(label.as_bytes()).0))
|
||||
}
|
||||
|
||||
/// A one-key controller, its next key, and the input that incepts it.
|
||||
fn party(label: &str) -> (Controller, Vec<u8>) {
|
||||
let (c, icp) = Controller::incept(
|
||||
vec![key(&format!("{label}-0"))],
|
||||
vec![key(&format!("{label}-1"))],
|
||||
Threshold(1),
|
||||
Threshold(1),
|
||||
vec![],
|
||||
Threshold(0),
|
||||
)
|
||||
.unwrap();
|
||||
let input = signed(&c, &KeyEvent::Inception(icp));
|
||||
(c, input)
|
||||
}
|
||||
|
||||
fn signed(c: &Controller, event: &KeyEvent) -> Vec<u8> {
|
||||
let sigs: Vec<IndexedSignature> = c
|
||||
.sign_event(event)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(index, signature)| IndexedSignature {
|
||||
index: u64::from(index),
|
||||
signature,
|
||||
})
|
||||
.collect();
|
||||
Input::key_event(event, &sigs)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
struct World {
|
||||
state: State,
|
||||
issuer: Controller,
|
||||
alice: Controller,
|
||||
bob: Controller,
|
||||
byte: Envelope,
|
||||
mint: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Frame 0 applied: the issuer, Alice and Bob incepted, and a USD byte
|
||||
/// minted to Alice.
|
||||
fn world() -> World {
|
||||
let (issuer, issuer_icp) = party("issuer");
|
||||
let (alice, alice_icp) = party("alice");
|
||||
let (bob, bob_icp) = party("bob");
|
||||
let byte = Envelope::issue(
|
||||
&issuer.signer(),
|
||||
alice.aid,
|
||||
[7; 16],
|
||||
Cargo::usd_micros(5_000_000),
|
||||
JouleCost::default(),
|
||||
);
|
||||
let mint = Input::mint(&byte.provenance);
|
||||
let mut state = State::genesis([(issuer.aid, 1)]);
|
||||
let report = state.apply_frame(&[issuer_icp, alice_icp, bob_icp, mint.clone()]);
|
||||
assert_eq!(classes(&report), vec![Ok(()); 4]);
|
||||
World {
|
||||
state,
|
||||
issuer,
|
||||
alice,
|
||||
bob,
|
||||
byte,
|
||||
mint,
|
||||
}
|
||||
}
|
||||
|
||||
fn transfer(
|
||||
owner: &Controller,
|
||||
byte: &Said,
|
||||
history: [u8; 32],
|
||||
to: Aid,
|
||||
frame: u64,
|
||||
) -> (Vec<u8>, Transition) {
|
||||
let t = Transition::sign(&owner.signer(), byte, history, to, frame);
|
||||
(Input::transfer(byte, &t), t)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_byte_is_minted_and_moves() {
|
||||
let mut w = world();
|
||||
assert_eq!(w.state.frame, 1);
|
||||
let id = w.byte.identity();
|
||||
let record = &w.state.bytes[&id];
|
||||
assert_eq!(
|
||||
(record.owner, record.minted, record.history_hash),
|
||||
(w.alice.aid, 0, id.0)
|
||||
);
|
||||
|
||||
let (input, t) = transfer(&w.alice, &id, id.0, w.bob.aid, 1);
|
||||
let report = w.state.apply_frame(&[input]);
|
||||
assert_eq!(classes(&report), vec![Ok(())]);
|
||||
assert_eq!(w.state.bytes[&id].owner, w.bob.aid);
|
||||
assert_eq!(w.state.bytes[&id].history_hash, t.next_history_hash());
|
||||
// The cluster's view agrees with the envelope a client builds.
|
||||
let envelope = w.byte.transfer(&w.alice.signer(), w.bob.aid, 1);
|
||||
assert_eq!(envelope.history_hash(), w.state.bytes[&id].history_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_replayed_mint_is_refused_in_any_later_frame() {
|
||||
let mut w = world();
|
||||
let report = w.state.apply_frame(&[w.mint.clone()]);
|
||||
assert_eq!(classes(&report), vec![Err("duplicate_byte")]);
|
||||
assert_eq!(w.state.bytes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_registered_issuer_mints_and_only_its_cargo_types() {
|
||||
let mut w = world();
|
||||
let by_alice = Envelope::issue(
|
||||
&w.alice.signer(),
|
||||
w.bob.aid,
|
||||
[1; 16],
|
||||
Cargo::usd_micros(1),
|
||||
JouleCost::default(),
|
||||
);
|
||||
let joules = Envelope::issue(
|
||||
&w.issuer.signer(),
|
||||
w.bob.aid,
|
||||
[2; 16],
|
||||
Cargo::joules(9),
|
||||
JouleCost::default(),
|
||||
);
|
||||
let report = w.state.apply_frame(&[
|
||||
Input::mint(&by_alice.provenance),
|
||||
Input::mint(&joules.provenance),
|
||||
]);
|
||||
assert_eq!(classes(&report), vec![Err("issuer_not_registered"); 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotation_retires_the_old_keys_in_the_frame_it_lands() {
|
||||
let mut w = world();
|
||||
let id = w.byte.identity();
|
||||
// Alice rotates; in the same frame, a transfer signed with her old key.
|
||||
let rot = KeyEvent::Rotation(
|
||||
w.alice
|
||||
.rotate(
|
||||
vec![key("alice-2")],
|
||||
Threshold(1),
|
||||
vec![],
|
||||
vec![],
|
||||
Threshold(0),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let rotation = signed(&w.alice, &rot);
|
||||
let stale = Transition::sign(
|
||||
&smart_byte_core::Signer::single(w.alice.aid, 0, key("alice-0").signing),
|
||||
&id,
|
||||
id.0,
|
||||
w.bob.aid,
|
||||
1,
|
||||
);
|
||||
let report = w
|
||||
.state
|
||||
.apply_frame(&[Input::transfer(&id, &stale), rotation]);
|
||||
// Key events apply first, whatever the inputs' order.
|
||||
assert_eq!(report.results[0].class, Some(InputClass::KeyEvent));
|
||||
assert_eq!(
|
||||
classes(&report),
|
||||
vec![Ok(()), Err("establishment_not_in_force")]
|
||||
);
|
||||
// Under the new establishment, it goes through.
|
||||
let (fresh, _) = transfer(&w.alice, &id, id.0, w.bob.aid, 2);
|
||||
assert_eq!(classes(&w.state.apply_frame(&[fresh])), vec![Ok(())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_byte_moves_once_per_frame() {
|
||||
let mut w = world();
|
||||
let id = w.byte.identity();
|
||||
let (to_bob, _) = transfer(&w.alice, &id, id.0, w.bob.aid, 1);
|
||||
let (to_issuer, _) = transfer(&w.alice, &id, id.0, w.issuer.aid, 1);
|
||||
let report = w.state.apply_frame(&[to_bob.clone(), to_issuer.clone()]);
|
||||
let mut outcomes = classes(&report);
|
||||
outcomes.sort();
|
||||
assert_eq!(outcomes, vec![Ok(()), Err("broken_history_chain")]);
|
||||
// The input with the lower digest won.
|
||||
let lower = std::cmp::min(
|
||||
*blake3::hash(&to_bob).as_bytes(),
|
||||
*blake3::hash(&to_issuer).as_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
(report.results[0].input, report.results[0].outcome.is_ok()),
|
||||
(lower, true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfers_are_checked_against_the_byte_and_the_frame() {
|
||||
let mut w = world();
|
||||
let id = w.byte.identity();
|
||||
let (late, _) = transfer(&w.alice, &id, id.0, w.bob.aid, 9);
|
||||
let (not_owner, _) = transfer(&w.bob, &id, id.0, w.alice.aid, 1);
|
||||
let (stale_history, _) = transfer(&w.alice, &id, [0; 32], w.bob.aid, 1);
|
||||
let (unknown, _) = transfer(
|
||||
&w.alice,
|
||||
&Said::hash(b"no such byte"),
|
||||
[0; 32],
|
||||
w.bob.aid,
|
||||
1,
|
||||
);
|
||||
let report = w
|
||||
.state
|
||||
.apply_frame(&[late, not_owner, stale_history, unknown]);
|
||||
let mut outcomes = classes(&report);
|
||||
outcomes.sort();
|
||||
assert_eq!(
|
||||
outcomes,
|
||||
vec![
|
||||
Err("broken_history_chain"),
|
||||
Err("not_current_owner"),
|
||||
Err("unknown_byte"),
|
||||
Err("wrong_frame")
|
||||
]
|
||||
);
|
||||
assert_eq!(w.state.bytes[&id].owner, w.alice.aid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mint_and_its_first_transfer_share_a_frame() {
|
||||
let (issuer, issuer_icp) = party("issuer");
|
||||
let (alice, alice_icp) = party("alice");
|
||||
let byte = Envelope::issue(
|
||||
&issuer.signer(),
|
||||
alice.aid,
|
||||
[3; 16],
|
||||
Cargo::usd_micros(1),
|
||||
JouleCost::default(),
|
||||
);
|
||||
let id = byte.identity();
|
||||
let (move_it, _) = transfer(&alice, &id, id.0, issuer.aid, 0);
|
||||
let mut state = State::genesis([(issuer.aid, 1)]);
|
||||
let report = state.apply_frame(&[
|
||||
move_it,
|
||||
Input::mint(&byte.provenance),
|
||||
alice_icp,
|
||||
issuer_icp,
|
||||
]);
|
||||
assert_eq!(classes(&report), vec![Ok(()); 4]);
|
||||
assert_eq!(state.bytes[&id].owner, issuer.aid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_inputs_are_one_input() {
|
||||
let mut w = world();
|
||||
let id = w.byte.identity();
|
||||
let (input, _) = transfer(&w.alice, &id, id.0, w.bob.aid, 1);
|
||||
let report = w.state.apply_frame(&[input.clone(), input.clone(), input]);
|
||||
assert_eq!(classes(&report), vec![Ok(())]);
|
||||
assert_eq!(report.inputs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_controller_signs_only_once_its_log_is_in_the_cluster() {
|
||||
let (issuer, issuer_icp) = party("issuer");
|
||||
let (carol, carol_icp) = party("carol");
|
||||
let byte = Envelope::issue(
|
||||
&issuer.signer(),
|
||||
carol.aid,
|
||||
[4; 16],
|
||||
Cargo::usd_micros(1),
|
||||
JouleCost::default(),
|
||||
);
|
||||
let id = byte.identity();
|
||||
let mut state = State::genesis([(issuer.aid, 1)]);
|
||||
state.apply_frame(&[issuer_icp, Input::mint(&byte.provenance)]);
|
||||
let (early, _) = transfer(&carol, &id, id.0, issuer.aid, 1);
|
||||
assert_eq!(
|
||||
classes(&state.apply_frame(&[early])),
|
||||
vec![Err("unknown_controller")]
|
||||
);
|
||||
// Carol's inception and her transfer in one frame: key events first.
|
||||
let (now, _) = transfer(&carol, &id, id.0, issuer.aid, 2);
|
||||
assert_eq!(
|
||||
classes(&state.apply_frame(&[now, carol_icp])),
|
||||
vec![Ok(()), Ok(())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_events_extend_the_cluster_s_logs() {
|
||||
let mut w = world();
|
||||
let (alice_again, _) = party("alice");
|
||||
let (dora, _) = party("dora");
|
||||
let dora_rot = KeyEvent::Rotation({
|
||||
let mut d = dora;
|
||||
d.rotate(
|
||||
vec![key("dora-2")],
|
||||
Threshold(1),
|
||||
vec![],
|
||||
vec![],
|
||||
Threshold(0),
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
let alice_icp_again = {
|
||||
let icp = alice_again.key_event_log[0].clone();
|
||||
signed(&alice_again, &icp)
|
||||
};
|
||||
let report = w
|
||||
.state
|
||||
.apply_frame(&[alice_icp_again, signed(&w.bob, &dora_rot)]);
|
||||
let mut outcomes = classes(&report);
|
||||
outcomes.sort();
|
||||
assert_eq!(
|
||||
outcomes,
|
||||
vec![Err("controller_exists"), Err("unknown_controller")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_demo_workload_runs_and_is_deterministic() {
|
||||
let run = || {
|
||||
let mut workload = Workload::new(3, 2);
|
||||
let mut state = workload.genesis();
|
||||
let mut hashes = Vec::new();
|
||||
for f in 0..5 {
|
||||
let report = state.apply_frame(&workload.frame(f));
|
||||
assert_eq!(
|
||||
report.applied(),
|
||||
report.results.len(),
|
||||
"frame {f}: every input applies"
|
||||
);
|
||||
hashes.push(state.hash());
|
||||
}
|
||||
(state, hashes)
|
||||
};
|
||||
let (a, ha) = run();
|
||||
let (b, hb) = run();
|
||||
assert_eq!((a.frame, a.bytes.len(), a.controllers.len()), (5, 6, 4));
|
||||
assert_eq!((ha, a), (hb, b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_store_recovers_the_state_and_refuses_a_tampered_log() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut workload = Workload::new(2, 1);
|
||||
let genesis = workload.genesis();
|
||||
let (mut store, mut state) = Store::open_with_genesis(dir.path(), 3, &genesis).unwrap();
|
||||
assert_eq!(state, genesis);
|
||||
for f in 0..5 {
|
||||
let report = state.apply_frame(&workload.frame(f));
|
||||
store.commit(report.frame, &report.inputs, &state).unwrap();
|
||||
}
|
||||
drop(store);
|
||||
// Five frames at a cadence of three: a checkpoint at frame 3, two logged.
|
||||
let (_, recovered) = Store::open_with_genesis(dir.path(), 3, &genesis).unwrap();
|
||||
assert_eq!(recovered, state);
|
||||
|
||||
// A flipped byte in the log's last record.
|
||||
let log = dir.path().join(smart_byte_state::store::LOG);
|
||||
let mut bytes = std::fs::read(&log).unwrap();
|
||||
let last = bytes.len() - 1;
|
||||
bytes[last] ^= 1;
|
||||
std::fs::write(&log, bytes).unwrap();
|
||||
assert!(matches!(
|
||||
Store::open_with_genesis(dir.path(), 3, &genesis),
|
||||
Err(smart_byte_state::StoreError::Corrupt(_))
|
||||
));
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ path = "src/main.rs"
|
|||
smart-byte-core = { workspace = true }
|
||||
smart-byte-net = { workspace = true }
|
||||
smart-byte-lockstep = { workspace = true }
|
||||
smart-byte-state = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@
|
|||
//! - `envelope new` — issue a byte from a demo issuer and print it
|
||||
//! - `envelope verify` — decode a byte, check its SAIDs, and check its
|
||||
//! signatures where the CLI knows the signer's keys
|
||||
//! - `cluster simulate` — run the deterministic lockstep demo
|
||||
//! - `cluster simulate` — run a cluster of state machines in lockstep over
|
||||
//! a demo workload: an issuer mints USD bytes to a ring of holders, who
|
||||
//! pass them along a frame at a time
|
||||
//!
|
||||
//! The lockstep demo's output is deterministic: the same `--nodes` /
|
||||
//! `--frames` pair produces byte-identical output across runs.
|
||||
//! The simulation's output is deterministic: the same flags produce
|
||||
//! byte-identical output across runs.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -19,7 +21,8 @@ use smart_byte_core::{
|
|||
Aid, Cargo, ControllerSignatureError, Envelope, EnvelopeError, JouleCost, KeyBook, Said,
|
||||
Signer, SigningKey,
|
||||
};
|
||||
use smart_byte_lockstep::{Cluster, synthetic_transitions};
|
||||
use smart_byte_lockstep::Cluster;
|
||||
use smart_byte_state::demo::Workload;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "smart-byte", version, about = "Smart Byte reference CLI")]
|
||||
|
|
@ -76,6 +79,12 @@ enum ClusterCmd {
|
|||
/// Optional number of Byzantine nodes (must be < nodes / 3).
|
||||
#[arg(long = "faulty", default_value_t = 0)]
|
||||
faulty: usize,
|
||||
/// Holders in the demo workload's ring.
|
||||
#[arg(long, default_value_t = 3)]
|
||||
holders: usize,
|
||||
/// USD bytes minted to each holder at frame 0.
|
||||
#[arg(long = "bytes-per-holder", default_value_t = 2)]
|
||||
bytes_per_holder: usize,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -103,7 +112,9 @@ fn main() -> Result<()> {
|
|||
nodes,
|
||||
frames,
|
||||
faulty,
|
||||
} => cluster_simulate(nodes, frames, faulty),
|
||||
holders,
|
||||
bytes_per_holder,
|
||||
} => cluster_simulate(nodes, frames, faulty, holders, bytes_per_holder),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -166,35 +177,43 @@ fn envelope_verify(file: PathBuf) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn cluster_simulate(nodes: usize, frames: u64, faulty: usize) -> Result<()> {
|
||||
fn cluster_simulate(
|
||||
nodes: usize,
|
||||
frames: u64,
|
||||
faulty: usize,
|
||||
holders: usize,
|
||||
bytes_per_holder: usize,
|
||||
) -> Result<()> {
|
||||
if faulty >= nodes {
|
||||
anyhow::bail!("--faulty must be < --nodes");
|
||||
}
|
||||
let mut cluster = if faulty == 0 {
|
||||
Cluster::new_honest(nodes)
|
||||
} else {
|
||||
Cluster::new_with_faulty(nodes, faulty)
|
||||
};
|
||||
println!(
|
||||
"cluster: nodes={} faulty={} frames={}",
|
||||
nodes, faulty, frames
|
||||
);
|
||||
let mut workload = Workload::new(holders, bytes_per_holder);
|
||||
let genesis = workload.genesis();
|
||||
let mut cluster = Cluster::new_with_faulty(nodes, faulty, &genesis);
|
||||
println!("cluster: nodes={nodes} faulty={faulty} frames={frames}");
|
||||
println!("genesis : state_hash={}", hex8(&genesis.hash()));
|
||||
for f in 0..frames {
|
||||
let transitions = synthetic_transitions(f, 3);
|
||||
let commit = cluster.step(transitions)?;
|
||||
let hex_head: String = commit
|
||||
.state_hash
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect();
|
||||
let commit = cluster.step(workload.frame(f))?;
|
||||
println!(
|
||||
"frame {:>3}: state_hash={}... committers={}/{}",
|
||||
"frame {:>4} : state_hash={}... applied={} dropped={} committers={}/{}",
|
||||
commit.frame,
|
||||
hex_head,
|
||||
hex8(&commit.state_hash),
|
||||
commit.applied,
|
||||
commit.dropped,
|
||||
commit.committed_by.len(),
|
||||
nodes
|
||||
);
|
||||
}
|
||||
let state = &cluster.nodes[0].state;
|
||||
println!(
|
||||
"state : frame={} bytes={} controllers={}",
|
||||
state.frame,
|
||||
state.bytes.len(),
|
||||
state.controllers.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hex8(hash: &[u8; 32]) -> String {
|
||||
hash.iter().take(8).map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
|
|
|||
221
smart-byte/spec/cluster_state.md
Normal file
221
smart-byte/spec/cluster_state.md
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# Cluster State
|
||||
|
||||
*Smart Byte Substrate — Spec, Section: Cluster State. v1.0-draft.*
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
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;
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## 2. The state
|
||||
|
||||
```
|
||||
{
|
||||
"frame": uint: the next frame to apply,
|
||||
"registry": {issuer AID: [cargo type, ...]},
|
||||
"controllers": {AID: controller record},
|
||||
"bytes": {identity: byte record}
|
||||
}
|
||||
```
|
||||
|
||||
- **`frame`** is 0 at genesis. Applying frame *n* leaves *n* + 1.
|
||||
- **`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.
|
||||
- **`controllers`** holds a record for every controller whose key-event log the cluster has applied:
|
||||
|
||||
```
|
||||
{
|
||||
"establishment": uint: the sequence number of the establishment event in force,
|
||||
"keys": [bytes]: the signing keys in force (identity spec §3.3 `k`),
|
||||
"threshold": uint: the signing threshold in force (`kt`),
|
||||
"next_keys": [SAID]: the next-key digests committed (`n`),
|
||||
"next_threshold": uint: the next-key threshold committed (`nt`),
|
||||
"sequence": uint: the last applied event's `s`,
|
||||
"last_event": SAID: the last applied event's `d`
|
||||
}
|
||||
```
|
||||
|
||||
- **`bytes`** holds a record for every byte the cluster holds, keyed by the byte's identity:
|
||||
|
||||
```
|
||||
{
|
||||
"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,
|
||||
"owner": AID: its current owner,
|
||||
"history_hash": 32 bytes: its current history hash (envelope wire format §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.
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## 3. Inputs
|
||||
|
||||
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`** carries one event of a controller's key-event log (identity spec §3.3), and the signatures over its canonical encoding (§3.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.
|
||||
|
||||
An input's **digest** is the BLAKE3-256 of its bytes.
|
||||
|
||||
---
|
||||
|
||||
## 4. A frame
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
### 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`.
|
||||
- **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`.
|
||||
|
||||
### 4.2 Key events
|
||||
|
||||
- **An inception** starts a log. If the cluster already holds its controller: `controller_exists`. Otherwise it is checked as the first event of a log (identity spec §4).
|
||||
- **A rotation or interaction** extends a log. If the cluster holds no log for its controller: `unknown_controller`. Otherwise it is checked as the next event of that log (identity spec §4 steps 2–6), against the controller's record.
|
||||
|
||||
An accepted event replaces the controller's record with the key state it establishes.
|
||||
|
||||
### 4.3 Mints
|
||||
|
||||
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`.
|
||||
|
||||
The byte is then held with these values:
|
||||
|
||||
| Member | Value |
|
||||
| -------------- | ------------------------------ |
|
||||
| `issuer` | the issuer |
|
||||
| `cargo` | the cargo |
|
||||
| `minted` | *F* |
|
||||
| `owner` | the recipient |
|
||||
| `history_hash` | the identity's 32 digest bytes |
|
||||
|
||||
### 4.4 Transfers
|
||||
|
||||
1. The cluster holds the byte. Otherwise: `unknown_byte`.
|
||||
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).
|
||||
|
||||
The byte's owner becomes `to`, and its history hash advances (envelope wire format §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
|
||||
|
||||
A mint or transfer signature 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`.
|
||||
3. It is a valid controller signature under that event's key state (envelope wire format §7, with its error classes).
|
||||
|
||||
A signature made under keys since rotated away still verifies offline (envelope wire format §8). The cluster accepts new signatures only under the keys in force, which is what retires stolen keys.
|
||||
|
||||
---
|
||||
|
||||
## 5. 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
|
||||
|
||||
**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).
|
||||
|
||||
**Checkpoint.** A checkpoint is the state's canonical encoding, so its digest is the state hash at its frame (treatise §16.1).
|
||||
|
||||
**Log.** The reference implementation's log holds one record per frame committed since the checkpoint. Each record is a four-byte big-endian length, then the canonical encoding of `{"frame", "inputs", "state_hash"}`: the frame's distinct inputs in the order they applied, and the hash the frame committed at. Recovery works as follows:
|
||||
|
||||
1. Load the checkpoint.
|
||||
2. Skip log records the checkpoint already covers.
|
||||
3. Replay the rest in order.
|
||||
4. Refuse a log that skips a frame, or a replay that reaches any hash but the one recorded.
|
||||
|
||||
The log format is not normative. The checkpoint is, because its digest is the state hash.
|
||||
|
||||
---
|
||||
|
||||
## 7. 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.
|
||||
|
||||
**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.
|
||||
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
## 8. 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`.
|
||||
- **Mints:** `said_mismatch`, `issuer_not_registered` and `duplicate_byte`.
|
||||
- **Transfers:** `unknown_byte`, `wrong_frame`, `broken_history_chain` and `not_current_owner`.
|
||||
- **Signatures:** `unknown_controller`, `establishment_not_in_force`, and the controller-signature classes of envelope wire format §7.
|
||||
|
||||
---
|
||||
|
||||
## 9. 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;
|
||||
- `store.rs`: checkpoint and log.
|
||||
|
||||
`smart-byte-rs/crates/smart-byte-lockstep` runs a cluster of these state machines under the supermajority commit.
|
||||
|
||||
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.
|
||||
|
|
@ -155,7 +155,11 @@ Three questions depend on cluster state rather than on the envelope:
|
|||
- whether a signer's establishment event was the one in force at a transition's frame;
|
||||
- how nodes account the joule cost (treatise §8.6).
|
||||
|
||||
The cluster answers these when it applies a transfer (treatise §9.4 step 5).
|
||||
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 registry lists the issuers for each cargo type;
|
||||
- a signature must name the establishment event in force when it is applied;
|
||||
- joule cost is reported beside the cluster state, not in it.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue