Go implementation

peoplecoin

Standard library only. Primitives, the state machine, and the nine operations — no networking, no video, no wallet yet.

README.md

141 lines

peoplecoin

Go implementation of the Peoplecoin protocol, built bottom-up from the specification. Batch 1 is the primitives — every deterministic building block, tested against the numbers and formulas in the spec. Batch 2 is the state machine: the tries, the nine operations, blocks, and the period boundary. No networking, no video, no wallet yet.

Standard library only. go test ./... runs in about a second.

params/    constants, token types, opcodes            (the spec's Constants table)
trie/      binary Merkle trie: crit-bit structure, SHA256(pos ‖ count ‖ L ‖ R),
           rank/select for storage tries, persistent (path-copying)
feistel/   the pairing shuffle with cycle walking; Permute, Invert, Partner
coin/      demurrage by squaring chain in 63-bit fixed point, rent, spendable, prunable
sig/       Ed25519 with canonical R, s < L and canonical public keys (RFC 8032)
period/    period/phase arithmetic, the event hour and slot from the period number
rng/       reveal votes, tally (most votes, lowest on tie, 0 if none), seed encoding,
           validator index per slot, election commit
wire/      canonical encodings of trie entries, signing messages of all nine
           operations, tx hash, UTXO and fee-UTXO IDs
state/     the ledger: State (immutable; a block yields a new one), Genesis,
           period rotation, the tip clock, Partner and Validator lookups, the nine operations
           with canonical encodings, validation against pre-block state,
           write-conflict detection, fees, Header/Block hashing and encoding,
           Apply (validate) and Assemble (produce), the fork-choice comparator

The state package's main test runs a nine-person registry through three periods: Verify/Verified with an odd one out, token minting, mixing a registration token to a fresh key, giving invites to the one who failed, voting for two validator keys, commits and reveals, the first UBI, the tally becoming the seed, transfers with fees split between validator and voter, a 2-of-3 multisig spent in owner order, pruning, and the chain ending in a period without votes — with a negative case for nearly every rule.

What the tests pin down

  • Decay0 = 9223368114598617273 is exactly floor(2^63 · 0.983^(1/40320))

(checked with 256-bit floats on both sides of the floor).

  • decay(TOKEN, SlotsPerPeriod) = 98 299 996, so demurrage on one token equals

the UBI within rounding; a uint64 decays to zero by period 2600; a 1 UBI entry becomes prunable in period 292 — all as the spec states.

  • The chain floors after each of up to 27 multiplications, so it sits at most

27 base units below the unrounded product and is not strictly monotone in t for tiny amounts (1 000 units after 1 023 slots < after 1 024 slots). That is inherent to "the squaring chain is the definition" and is tested as a bound, not as monotonicity.

  • The trie root is a pure function of the key set (insertion order does not

matter), branch positions strictly increase along every path, and rank/select agree with a sorted reference over thousands of random keys.

  • Feistel is a bijection on [0, N) with a working inverse for N from 1 to

30 000; pairs are symmetric; an odd N leaves exactly one entry unpaired.

  • Go's ed25519.Verify accepts non-canonical point encodings (documented in

edwards25519); sig.Verify rejects them, and s + L.

  • Every block Assemble produces is accepted by Apply, survives a wire

round trip, and yields the same roots; every header field is checked by breaking it.

Not decisions

Genesis is 07:00 UTC on a Saturday, so every period starts there and the event — at the period start plus SHA256(uint32(period)) mod 24 whole hours, for fifteen minutes — always falls in the first day of phase 1. When to show up is a convention among the people, not a ledger rule; the ledger only maps slots to phases.

The header's time is the slot's start, checked against the previous header — time = prev.time + (slot − prev.slot) · 60 — with the genesis state carrying the genesis time. Any header thus gives the wall-clock time of any slot; no node has to remember when genesis was.

Changes to the spec, agreed with the author

  • `tx_hash = message`. The spec has SHA256(message ‖ sig(s)). The

transaction hash is now the signing message itself: it commits to every field, it is unique by construction (every operation either consumes an input named in the message or makes a once-per-period change keyed by the period and address in it), and with signatures outside it the hash — and the UTXO IDs derived from it — is fixed before signing and cannot be altered by re-signing. That is the SegWit lesson; the trie leaf, whose value is the full operation, is what commits the block to the signatures.

Decisions taken where the spec leaves room

These are choices, not corrections. Each is a one-liner to change.

  1. "SHA256(…) mod N" is the full 256-bit digest as a big-endian integer

reduced mod N (rng.HashMod), used for votes, the validator index and the event hour (confirmed by the author). Note that only a power-of-two modulus could be computed from the trailing bytes alone.

  1. Phases are 0–3 in code (period.PhaseEvent…PhasePause); the spec

says 1–4.

  1. Preimages are 32 bytes (confirmed by the author), so Reveal has a

fixed encoding.

  1. The RNG tally is side state: it lives in no trie and is rebuilt from the

phase-3 Reveals; only the resulting seed persists. rng.Tally is that object.

  1. The trie is persistent (every mutation returns a new root sharing

unchanged subtrees). This is an implementation choice that makes "validate everything against pre-block state" and reorgs trivial; the hashes are unaffected.

  1. Operation and block encodings are mine (state/ops.go,

state/block.go): opcode, then the fields in spec order with uint8 length prefixes, signatures last. The tx-trie value is this encoding, so the tx root commits to the full operations. The header is 472 fixed bytes.

  1. Genesis has number 0; the first block is number 1, at slot ≥ 1, with

prev = 0.

  1. "Validator-only" Prune needs no check: whatever is in a block was put

there by its validator. Nodes simply should not relay Prunes.

  1. Pre-block state means two blocks for Verified → Commit and for

Mix → Register: the second operation cannot see the first in the same block. Wallets should expect that.

  1. Rent on tiny outputs is moot: the decay chain floors after each

multiplication, so ten base units are worth zero after 1 023 slots (ten set bits) and prunable right there. Dust is self-cleaning.

  1. Fork choice is only the comparator (state.Better). The rule that a

fork may not reach back past two period boundaries belongs to the chain store, which is batch 3.

Next: batch 3, the node

  • Chain store: blocks by hash, tips, the two-boundary reorg limit, history

pruning, persistence of states (the tries need a disk format; the in-memory persistent trie is a placeholder for that).

  • Mempool and the validator loop: watch the slot clock, notice your slot

from the commit you hold the nonce for, filter, assemble, sign.

  • p2p: gossip of operations and blocks, header sync from a genesis state.

Then, separately: the video event and lock & key, wallet.

Numbers

On the sandbox CPU: trie insert ~6.5 µs at 10^5 entries (path copying with two hashes per level), partner lookup ~1.7 µs, decay ~100 ns. Validating a block of 1 000 single-signature transfers takes ~72 ms, ~14 000 tx/s on one core; Ed25519 verification is most of it. Since operations are independent, validation parallelizes across cores without any protocol change, as the spec anticipates.