|
1 | | -## Foundry |
| 1 | +# Stem |
2 | 2 |
|
3 | | -This repo contains a Foundry Solidity project and a Rust workspace under `crates/`. Run Solidity tests with `forge test` and the Rust crate with `cargo test -p stem`. |
| 3 | +Off-chain runtime for the [Stem smart contract](src/Stem.sol). |
4 | 4 |
|
5 | | -**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** |
| 5 | +Stem indexes `HeadUpdated` events emitted by an on-chain anchor contract, |
| 6 | +finalizes them with reorg safety (configurable confirmation depth), and exposes |
| 7 | +epoch-scoped authority to clients via Cap'n Proto capabilities. When the |
| 8 | +on-chain head advances, every capability issued under the previous epoch |
| 9 | +hard-fails — clients re-graft to recover. |
6 | 10 |
|
7 | | -Foundry consists of: |
| 11 | +## Architecture |
8 | 12 |
|
9 | | -- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). |
10 | | -- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. |
11 | | -- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. |
12 | | -- **Chisel**: Fast, utilitarian, and verbose solidity REPL. |
| 13 | +``` |
| 14 | +HeadUpdated → Indexer → Finalizer → Epoch → Membrane |
| 15 | + │ |
| 16 | + graft(signer) |
| 17 | + │ |
| 18 | + Session |
| 19 | + ┌─────┴─────┐ |
| 20 | + statusPoller (future caps) |
| 21 | + │ |
| 22 | + Ok / RPC error |
| 23 | +``` |
| 24 | + |
| 25 | +The runtime is a four-stage pipeline: |
| 26 | + |
| 27 | +### 1. Stem contract (`src/Stem.sol`) |
| 28 | + |
| 29 | +On-chain anchor. The owner calls `setHead(newCid)` to advance a monotonic |
| 30 | +`seq` and emit a `HeadUpdated` event. The `head()` view returns the canonical |
| 31 | +`(seq, cid)` pair for cross-checking. |
| 32 | + |
| 33 | +```solidity |
| 34 | +event HeadUpdated( |
| 35 | + uint64 indexed seq, |
| 36 | + address indexed writer, |
| 37 | + bytes cid, |
| 38 | + bytes32 indexed cidHash |
| 39 | +); |
| 40 | +``` |
| 41 | + |
| 42 | +### 2. Indexer (`StemIndexer`) |
| 43 | + |
| 44 | +Subscribes to `HeadUpdated` via WebSocket for live events and backfills |
| 45 | +missed blocks via HTTP `eth_getLogs` on startup and reconnect. Broadcasts |
| 46 | +`HeadUpdatedObserved` values to downstream consumers. Reconnects with |
| 47 | +exponential backoff and jitter. Client-side filtering handles RPC nodes |
| 48 | +(e.g. Anvil) that don't support topic filters natively. |
| 49 | + |
| 50 | +The indexer is observation-only — it makes no reorg-safety guarantees. |
| 51 | + |
| 52 | +### 3. Finalizer (`Finalizer` / `FinalizerBuilder`) |
| 53 | + |
| 54 | +Consumes observed events from the indexer and outputs only those that are |
| 55 | +**eligible** and **canonical**: |
| 56 | + |
| 57 | +- **Eligibility** is decided by a pluggable `Strategy` trait. The built-in |
| 58 | + `ConfirmationDepth(K)` strategy requires `tip >= event.block_number + K`. |
| 59 | +- **Canonical cross-check**: after eligibility, the finalizer calls |
| 60 | + `Stem.head()` and only emits if the on-chain `(seq, cid)` matches the |
| 61 | + candidate event. |
| 62 | +- **Deduplication** by `(tx_hash, log_index)` ensures exactly-once delivery |
| 63 | + across reconnects and backfills. |
| 64 | + |
| 65 | +Each output is a `FinalizedEvent` containing `seq`, `cid`, `block_number`, |
| 66 | +`tx_hash`, `log_index`, and `writer`. |
13 | 67 |
|
14 | | -## Documentation |
| 68 | +### 4. Membrane (`MembraneServer` / Cap'n Proto RPC) |
15 | 69 |
|
16 | | -https://book.getfoundry.sh/ |
| 70 | +The capability layer. A `MembraneServer` holds a `watch::Receiver<Epoch>` |
| 71 | +and exposes a single entry point: |
17 | 72 |
|
18 | | -## Usage |
| 73 | +``` |
| 74 | +graft(signer) → Session { issuedEpoch, statusPoller } |
| 75 | +``` |
| 76 | + |
| 77 | +All capabilities inside a `Session` share an `EpochGuard` that checks |
| 78 | +`current.seq == issued_seq` on every RPC call. When the epoch advances, |
| 79 | +every outstanding capability fails with a `staleEpoch` RPC error. Clients |
| 80 | +call `graft()` again to obtain a fresh session under the new epoch. |
| 81 | + |
| 82 | +## Getting started |
| 83 | + |
| 84 | +### Prerequisites |
| 85 | + |
| 86 | +- [Rust](https://rustup.rs/) (stable) |
| 87 | +- [Foundry](https://getfoundry.sh/) (forge, anvil, cast) |
| 88 | +- [Cap'n Proto compiler](https://capnproto.org/install.html) (`capnp`) |
19 | 89 |
|
20 | 90 | ### Build |
21 | 91 |
|
22 | | -```shell |
23 | | -$ forge build |
| 92 | +```bash |
| 93 | +forge build |
| 94 | +cargo build -p stem |
24 | 95 | ``` |
25 | 96 |
|
26 | 97 | ### Test |
27 | 98 |
|
28 | | -```shell |
29 | | -$ forge test |
| 99 | +```bash |
| 100 | +forge test |
| 101 | +cargo test -p stem |
30 | 102 | ``` |
31 | 103 |
|
32 | | -### Format |
| 104 | +## Deploy (local) |
| 105 | + |
| 106 | +Start Anvil in one terminal: |
33 | 107 |
|
34 | | -```shell |
35 | | -$ forge fmt |
| 108 | +```bash |
| 109 | +anvil |
36 | 110 | ``` |
37 | 111 |
|
38 | | -### Gas Snapshots |
| 112 | +Deploy the contract and set the first head: |
39 | 113 |
|
40 | | -```shell |
41 | | -$ forge snapshot |
| 114 | +```bash |
| 115 | +# Deploy |
| 116 | +forge script script/Deploy.s.sol \ |
| 117 | + --rpc-url http://127.0.0.1:8545 \ |
| 118 | + --broadcast \ |
| 119 | + --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 |
| 120 | + |
| 121 | +# Note the deployed address from the output, then set the first head: |
| 122 | +cast send <STEM_ADDRESS> "setHead(bytes)" "0x$(echo -n 'ipfs://first' | xxd -p)" \ |
| 123 | + --rpc-url http://127.0.0.1:8545 \ |
| 124 | + --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 |
42 | 125 | ``` |
43 | 126 |
|
44 | | -### Anvil |
| 127 | +## Examples |
45 | 128 |
|
46 | | -```shell |
47 | | -$ anvil |
48 | | -``` |
| 129 | +All examples live in `crates/stem/examples/` and connect to a running node |
| 130 | +with a deployed Stem contract. |
49 | 131 |
|
50 | | -### Deploy |
| 132 | +### `stem_indexer` — raw observed events |
51 | 133 |
|
52 | | -Deploy the Stem contract (e.g. to local Anvil). The script prints the deployed address; use it with the stem examples. |
| 134 | +Prints every `HeadUpdated` event as it arrives (no finalization). |
53 | 135 |
|
54 | | -```shell |
55 | | -$ anvil |
56 | | -$ forge script script/Deploy.s.sol:Deploy --rpc-url http://127.0.0.1:8545 --broadcast --private-key <your_private_key> |
57 | | -# Stem deployed at: 0x... <- use this as --contract below |
| 136 | +```bash |
| 137 | +cargo run -p stem --example stem_indexer -- \ |
| 138 | + --rpc-url http://127.0.0.1:8545 \ |
| 139 | + --contract <STEM_ADDRESS> |
58 | 140 | ``` |
59 | 141 |
|
60 | | -**Membrane (ocap API):** The membrane is the capability API on top of the Stem finalizer/adopted-epoch stream. Processes may persist across epochs, but privileged capabilities do not—authority is bound to the current adopted epoch. After a staleEpoch, clients must re-graft (e.g. `Membrane.graft`) to obtain a new session. |
| 142 | +### `finalizer` — finalized events as JSON |
61 | 143 |
|
62 | | -**Stem examples (Rust):** |
| 144 | +Runs the full indexer + finalizer pipeline and prints one JSON object per |
| 145 | +finalized event. |
63 | 146 |
|
64 | | -- **stem_indexer** — observed HeadUpdated events (no confirmations): |
65 | | - `cargo run -p stem --example stem_indexer -- --rpc-url http://127.0.0.1:8545 --contract 0x...` |
| 147 | +```bash |
| 148 | +cargo run -p stem --example finalizer -- \ |
| 149 | + --ws-url ws://127.0.0.1:8545 \ |
| 150 | + --http-url http://127.0.0.1:8545 \ |
| 151 | + --contract <STEM_ADDRESS> \ |
| 152 | + --depth 2 |
| 153 | +``` |
66 | 154 |
|
67 | | -- **finalizer** — finalized events only (confirmation-depth + canonical cross-check); prints one-line JSON per event: |
68 | | - `cargo run -p stem --example finalizer -- --ws-url ws://127.0.0.1:8545 --http-url http://127.0.0.1:8545 --contract 0x... [--depth 6]` |
| 155 | +### `membrane_poll` — epoch expiration and re-graft |
69 | 156 |
|
70 | | -### Cast |
| 157 | +Demonstrates the full pipeline: indexer, finalizer, membrane, graft, poll. |
| 158 | +When a second `setHead` is finalized the existing session's `statusPoller` |
| 159 | +fails with a `staleEpoch` error; the example re-grafts and polls successfully |
| 160 | +under the new epoch. |
71 | 161 |
|
72 | | -```shell |
73 | | -$ cast <subcommand> |
| 162 | +```bash |
| 163 | +cargo run -p stem --example membrane_poll -- \ |
| 164 | + --ws-url ws://127.0.0.1:8545 \ |
| 165 | + --http-url http://127.0.0.1:8545 \ |
| 166 | + --contract <STEM_ADDRESS> \ |
| 167 | + --depth 2 |
74 | 168 | ``` |
75 | 169 |
|
76 | | -### Help |
| 170 | +## Cap'n Proto schema |
77 | 171 |
|
78 | | -```shell |
79 | | -$ forge --help |
80 | | -$ anvil --help |
81 | | -$ cast --help |
82 | | -``` |
| 172 | +The RPC interface is defined in [`capnp/stem.capnp`](capnp/stem.capnp): |
| 173 | + |
| 174 | +| Type | Kind | Description | |
| 175 | +|------|------|-------------| |
| 176 | +| `Epoch` | struct | `seq`, `head`, `adoptedBlock` — identifies a finalized head | |
| 177 | +| `Status` | enum | `ok`, `unauthorized`, `internalError` | |
| 178 | +| `Signer` | interface | `sign(domain, nonce) → sig` — client-supplied signing capability | |
| 179 | +| `StatusPoller` | interface | `pollStatus() → status` — epoch-scoped health check | |
| 180 | +| `Session` | struct | `issuedEpoch`, `statusPoller` — returned by `graft` | |
| 181 | +| `Membrane` | interface | `graft(signer) → session` — the sole entry point | |
| 182 | + |
| 183 | +## License |
| 184 | + |
| 185 | +MIT |
0 commit comments