Stellar Governance Guardians
Read Soroban governance on Stellar without trusting a UI: decode the raw
ScVal payloads, see which published risk rule matched, index the events into
Postgres, and read delegate behaviour as raw counts plus formulas.
The suite is three public repositories with a strict dependency direction:
soroban-governance-parser → governance-event-indexer → delegate-portal-dashboard
(Rust + WASM) (TypeScript) (Next.js)
| Repository | What it does today | Status |
|---|---|---|
| parser | Decodes ScVal → JSON; extracts and parses contractspecv0; classifies risk from a published rule table; decodes Script3 proposals and events; models dry-run output from committed captures; ships a WASM package; exposes sgp simulate for validated transaction envelopes. | Script3 adapter implemented. OpenZeppelin adapter planned. |
| indexer | Ingests raw governance events from the public testnet RPC into Postgres with cursors, ≤1000-ledger windows, retention-gap recording, fixture replay, and a read-only GraphQL health/raw-event connection. | Ingestion and initial GraphQL surface built. Decode, reconciliation and deploy remain planned. |
| dashboard | Decodes a pasted base64 ScVal in the browser with the pinned parser WASM; shows indexer health and the exact interface pins. | Scaffold merged: /, /simulate, /status. Data pages are planned. |
Who this is for
- Indexer and dashboard builders who need decoded governance payloads without reimplementing governor logic.
- Delegate-tooling authors who want risk classifications that expose the rule that fired instead of a composite score.
- Anyone who needs to read a proposal, a vote or a tally from raw chain data and check the reading themselves.
How this site is built
Everything on this site is either a page in
docs-site/src/ or generated from a committed source file by a script in
docs-site/scripts/:
| Page | Generated from |
|---|---|
| Risk rules | crates/core/src/risk.rs |
| CLI reference | the sgp binary’s own --help output |
| API reference | schemas/governance-v1.graphql |
| Evidence | deployments.json |
| Upstream pins | docs-site/sources.lock |
CI rebuilds every generated page and fails on any diff, so a page cannot
describe a rule table, an export list, a schema or a contract that the source
files do not contain. Prose claims are checked by
docs-site/scripts/check-docs-claims.py.
What is not built
The honest limitations page aggregates every gap across the three repositories — including the OpenZeppelin adapter, decoded indexer tables, reconciliation, deployment, and the dashboard’s planned data pages. Nothing on this site describes a planned feature as if it existed; planned pages carry a Status: planned banner.
License
MIT, in all three repositories.
Architecture
Three repositories, one dependency direction, and no shared runtime state beyond pinned artifacts.
flowchart LR
subgraph chain["Stellar testnet"]
RPC["Soroban RPC"]
end
subgraph parser["soroban-governance-parser (Rust)"]
CORE["core: ScVal, spec, risk,
GovernorAdapter, simulate"]
WASM["wasm: wasm-bindgen surface"]
CLI["cli: sgp (network IO)"]
CORE --> WASM
CORE --> CLI
end
subgraph indexer["governance-event-indexer (TypeScript)"]
ING["ingest: windows, cursors,
gaps, replay"]
DEC["decode: pinned WASM"]
GQL["GraphQL: governance-v1"]
ING --> DEC --> GQL
end
subgraph dashboard["delegate-portal-dashboard (Next.js)"]
SIM["/simulate: browser decode"]
PAGES["pages: indexer GraphQL"]
end
RPC -->|getEvents| ING
CLI -->|getHealth, getEvents,
simulateTransaction| RPC
WASM -->|.tgz release asset| DEC
WASM -->|.tgz release asset| SIM
GQL -->|HTTP| PAGES
SDL["governance-v1.graphql"] --> GQL
SDL --> PAGES
Data flow
- Chain → parser. The parser decodes raw
ScValvalues, extractscontractspecv0from a contract WASM, and applies the published risk rule table. Governor-specific decoding lives behind theGovernorAdaptertrait; the Script3 adapter is implemented. - Chain → indexer. The indexer polls the public RPC’s
getEventsin windows of at most 1000 ledgers, stores raw events keyed by(ledger, tx_hash, op_index, event_index), and advances one cursor per registered contract in the same transaction. Events older than the RPC’s retention window are recorded iningest_gapsrather than skipped. - Indexer → dashboard. The dashboard reads the indexer’s GraphQL endpoint
from
INDEXER_URLand validates every field of every response against the vendored SDL. There is no bundled sample dataset and no cached fallback. - Parser → both consumers. The WASM package and the SDL travel as content-pinned artifacts, never as source imports.
The pin model
A pin is a content hash, not a version range. Each consumer vendors the bytes it depends on and verifies them before use:
| Consumer | Artifact | Pin file | Checked by |
|---|---|---|---|
| indexer | parser WASM release asset | parser.lock | the indexer’s pin check (offline, every PR) |
| indexer | governance-v1.graphql | schema.lock | same |
| indexer | deployments.json | registry.lock | same |
| dashboard | parser WASM release asset | parser.lock | npm run check:pins (offline, every PR) |
| dashboard | governance-v1.graphql | schema.lock | same |
| this site | sibling docs it summarises | docs-site/sources.lock | docs-site/scripts/check-docs-claims.py |
Bumping a pin is a PR that shows the diff and re-runs the contract tests. A pin never points at an asset that does not exist yet: that rule is why the indexer’s decode milestone waits on a real parser release instead of a planned one.
Why the boundaries sit there
- Governor logic is parser-only. Adapters (
matches_event,normalize_event,decode_call) live behindGovernorAdapter. An indexer or dashboard that reimplemented them would drift from the parser’s golden tests. - The parser core does no IO.
crates/coreis sans-IO; network calls live in the CLI (crates/cli/src/rpc.rs) and in the indexer’s RPC client, so decode logic is testable offline. - Consumers validate, they do not coerce. The dashboard rejects a response whose fields do not match the SDL instead of filling in defaults.
- Fail closed, fail loud. Missing spec, unknown function, unreadable ledger
range, absent
INDEXER_URL, broken pin: each becomes an explicit error orUNVERIFIEDvalue that a reader can see.
Crates and workspaces
| Path | Role | IO |
|---|---|---|
crates/core | ScVal → JSON, WASM custom-section extraction, contractspecv0, risk rules, GovernorAdapter, power/tally replicas, dry-run model | none |
crates/wasm | wasm-bindgen surface over core for the browser and Node | none |
crates/cli | sgp operator tool | Soroban JSON-RPC |
governance-event-indexer/src | ingestion, durable event decode and GraphQL | Postgres, Soroban RPC |
delegate-portal-dashboard/app | routes; /simulate runs in the browser | HTTP to the indexer |
Quickstart
Each repository runs on its own and has its own gate. Nothing here needs a sibling checkout: cross-repo inputs are pinned artifacts.
| Repository | Toolchain | Time to a green check | Start here |
|---|---|---|---|
| parser | Rust 1.96.0, Node 24 for WASM tests | a few minutes for a cold build | Parser |
| indexer | Node ≥ 24, Postgres 16 | a few minutes | Indexer |
| dashboard | Node ≥ 24, Chrome for e2e | a few minutes | Dashboard |
The PR gate in every repository is offline and deterministic. Live-testnet checks run in a nightly or manual workflow and never block a merge.
Parser quickstart
Rust workspace: crates/core (sans-IO decode), crates/wasm (wasm-bindgen),
crates/cli (sgp).
Prerequisites
- Rust 1.96.0 — pinned by
rust-toolchain.toml, sorustuppicks it up. wasm32-unknown-unknowntarget for the WASM crate:rustup target add wasm32-unknown-unknown.wasm-pack 0.13.1for packaging the browser/Node builds.
Build, test, check
git clone https://github.com/Stellar-Governance-Guardians/soroban-governance-parser
cd soroban-governance-parser
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings # pedantic, unwrap/expect/panic denied
cargo test --workspace # 85 tests
OFFLINE=1 bash scripts/check-claims.sh # 21 pass, 0 fail, 2 skipped (network)
python3 scripts/check-deployments.py # registry vs committed fixtures
All of the above run in the PR gate, offline. The two skipped claims need the
public testnet and run in the nightly live-checks workflow.
Decode something
# A captured `proposal_created` event topic (symbol ScVal), no network needed
cargo run -p soroban-governance-cli --bin sgp -- decode-scval \
AAAADwAAABBwcm9wb3NhbF9jcmVhdGVk
The full command list, including ttl, is on the CLI reference.
Package the WASM
wasm-pack build crates/wasm --target nodejs --out-dir pkg/nodejs --out-name sgp_parser_wasm
wasm-pack build crates/wasm --target web --out-dir pkg/web --out-name sgp_parser_wasm
node --test crates/wasm/js-tests/node.test.mjs
node crates/wasm/js-tests/browser.test.mjs
The exported surface is listed on WASM usage. The
current release is v0.1.0-alpha.2 with sha256
65193e43642f55abd083e2283aa5ed3caee5839c40a74c7b3f30398ea3ec0629; the release
lock file is releases/parser-wasm.lock.json.
What is not wired
sgp simulateaccepts a validated transaction envelope and emits an estimate; it does not assemble proposal actions or estimate rent. See Dry run.- The OpenZeppelin adapter is not implemented. See OpenZeppelin adapter.
Indexer quickstart
Node ≥ 24, Postgres 16, and the vendored pinned artifacts (committed).
Run the checks
git clone https://github.com/Stellar-Governance-Guardians/governance-event-indexer
cd governance-event-indexer
npm ci
docker compose up -d postgres
export DATABASE_URL='postgres://indexer:indexer-local-only@localhost:5432/indexer'
npm run check-pins # sha256 of the vendored WASM, SDL and registry
npm run typecheck
npm run lint
npm run test:unit # 48 tests, no database, no network
npm run test:integration # 11 tests against Postgres
npm run build
The integration suite creates a throwaway database next to DATABASE_URL and
drops it, so “migrations from empty” is exercised every run. Without
DATABASE_URL it fails with an explicit message instead of skipping.
Apply migrations and ingest
node dist/cli.js migrate
node dist/cli.js ingest # polls getEvents in ≤1000-ledger windows
node dist/cli.js replay <dir> # replays committed raw responses, source='fixture-replay'
What exists and what does not
Built: ingestion (I2) — registry, cursors, windowed getEvents, idempotent raw
storage, retention-gap recording, fixture replay, durable parser-backed event
decode, and an initial GraphQL health/raw-event connection.
Not built: proposal/impact/delegate projections, reconciliation (I5), deploy (I6) and the clean-clone prove script (I7). The API reference describes the pinned schema and the implemented health/event surface.
The remaining decode milestone depends on persisted tables and replay semantics, not on a missing parser export; see Honest limitations.
Pins
| Pin | Artifact |
|---|---|
parser.lock | WASM release asset v0.1.0-alpha.2 (sha256 65193e43…) |
schema.lock | governance-v1.graphql @ parser ba3bf1a3… (sha256 cfbd85a8…) |
registry.lock | deployments.json @ the same parser commit |
The PR gate verifies all three offline; the nightly nightly-pins workflow
checks that the pinned URLs still resolve to identical bytes.
Dashboard quickstart
Next.js 16 App Router, strict TypeScript, Tailwind v4. Node ≥ 24.
Run it
git clone https://github.com/Stellar-Governance-Guardians/delegate-portal-dashboard
cd delegate-portal-dashboard
npm ci
npm run dev # http://localhost:3000
The dashboard’s predev/prebuild/pretest:unit hooks run its unpack step
(unpack-wasm.mjs),
which verifies the vendored parser WASM tarball against parser.lock and
unpacks it into generated/wasm/. No network access is needed.
To show indexer data, point it at a GraphQL endpoint:
INDEXER_URL=http://127.0.0.1:4000/graphql npm run dev
INDEXER_URL has no default. Unset, malformed or unreachable, every data
surface renders the exact reason instead of an empty table.
Checks
npm run check:pins # vendored WASM + SDL vs the lock files
npm run typecheck
npm run lint
npm run test:unit # 25 unit/component tests, real pinned WASM, no mock decode
npm run build
npx playwright test # 4 e2e tests against `next build && next start`
bash scripts/check-claims.sh # 7/7 README claims
The e2e run deliberately leaves INDEXER_URL unset, so it proves the
unavailable-indexer state rather than a happy path that needs a live server.
Routes
| Route | Behaviour |
|---|---|
/ | Product summary, indexer health, explicit list of what is not built |
/simulate | Decodes a pasted base64 XDR ScVal in the browser with the pinned WASM |
/status | The exact interface pins and indexerHealth, or the exact reason it is unavailable |
Proposal, delegate and impact pages are planned; they need the indexer’s decoded tables.
Parser
crates/core decodes Soroban governance data without IO. crates/wasm exposes
it to JavaScript. crates/cli is the operator tool, and network calls live
there (crates/cli/src/rpc.rs), not in the core.
What it decodes
| Input | Output |
|---|---|
base64 XDR ScVal | JSON, total over the stellar-xdr 28 type set |
| contract WASM | contractspecv0 parsed into functions, structs, unions, enums, errors and events |
| raw event | a NormalizedEvent through a governor adapter, or unverified |
| proposal call | a DecodedCall with named arguments when the spec is verified |
| proposal votes | tally and power reconstructed through pure replicas |
simulateTransaction response | an ExecutionImpact estimate with simulatedAtLedger |
What it refuses to do
- Guess an argument name without a contract spec: the result is
decoding: "unverified", not a plausible label. - Downgrade an unknown function to
low: it isUNVERIFIEDwith the reason. - Coerce a value into JSON: integers at 64 bits and above become decimal strings, and non-representable maps are errors.
- Emit a dry-run without a ledger:
simulatedAtLedgeris required, and every impact carriesestimate: true.
Design rules
- Adapters own governor knowledge. The
GovernorAdaptertrait (crates/core/src/adapter.rs) is where a governor’s events, calls and state are understood. See the adapter model and the authoring guide. - Rules are data. Risk tiers come from the published table in
crates/core/src/risk.rs; every classification exposes the matched rule. See Risk rules. - The core does no IO. Network access is confined to
crates/cli/src/rpc.rs.
Pages
- Decoding rules
- Adapter model
- Script3 adapter
- OpenZeppelin adapter
- Risk rules
- CLI reference
- WASM usage
- Dry run (estimate)
Decoding rules
crates/core/src/scval.rs converts an ScVal to JSON. The mapping is total
over the stellar-xdr 28 type set and preserves value exactly; it does not
approximate.
Value mapping
ScVal kind | JSON |
|---|---|
Void | null |
Bool | true / false |
U32, I32 | number |
U64, I64, Timepoint, Duration | { "_type": "u64", "value": "<decimal>" } (and i64, timepoint, duration) |
U128, I128, U256, I256 | { "_type": "i128", "value": "<decimal>" } (and u128, u256, i256) |
Bytes | { "_type": "bytes", "value": "<base64>" } |
String, Symbol | string |
Address (account) | { "_type": "address:account", "value": "G…" } |
Address (contract) | { "_type": "address:contract", "value": "C…" } |
Vec | array |
Map with string or symbol keys | object |
Map with any other key type | error, not a stringified key |
Error | { "_type": "error", "value": "contract:5" } |
LedgerKeyContractInstance | { "_type": "ledger_key_contract_instance", "value": null } |
LedgerKeyNonce | { "_type": "ledger_key_nonce", "value": "<nonce>" } |
Two rules matter in practice:
- Wide integers keep their width and value as a string. A 128-bit token
amount cannot round-trip through a JavaScript number, so it stays a tagged
object with a decimal string (
{"_type":"i128","value":"…"}) all the way to the page. Consumers that need arithmetic parse it explicitly. - Non-representable maps are errors. Stringifying a struct key would silently change the value’s identity, so the decoder refuses.
Argument decoding
Positional ScVal arguments are zipped with the input names declared by the
contract’s own contractspecv0 section:
- names verified against the spec ⇒
decoding: "VERIFIED", arguments as a named object; - spec missing or governor unrecognized ⇒
decoding: "UNVERIFIED", arguments as a positional array; - arity mismatch ⇒ explicit error.
governor: "unverified" is a literal value in the output, never a guess at
which governor produced a call.
Risk classification
Every decoded call carries a RiskClassification:
{
"tier": "CRITICAL",
"matchedRule": "transfer",
"verified": true
}
- The tier comes from an exact, case-sensitive match on the verified function name; the generated rule table is the whole list.
- No match ⇒
tier: "UNVERIFIED"withmatchedRule: "no published rule matched this function name". - A function name that could not be verified against a spec is always
UNVERIFIED, even if its text matches a rule. - Contextual rules (
treasury_outflow,self_call,large_value,batched_actions) add evidence from the arguments; they never change the function-name tier and never collapse into a composite score.
Tally and power
Votes are not summed ad hoc. Two pure replicas reproduce the pinned Script3 behaviour:
crates/core/src/checkpoint.rs— checkpointed voting power, so a vote counts the power held at the proposal’s snapshot ledger, not at vote time;crates/core/src/tally.rs— tally and quorum arithmetic with the contract’s rounding.
Both are covered by property tests (zero votes, exact ties, i128 extremes,
empty checkpoint history) and by differential tests against committed on-chain
reads.
Adapter model
A governor is understood by exactly one module behind the GovernorAdapter
trait (crates/core/src/adapter.rs). Nothing outside the adapter needs to know
the governor exists — that is what keeps governor logic out of TypeScript.
The trait
| Method | Responsibility |
|---|---|
name() | stable adapter id, e.g. script3-soroban-governor |
matches_event(event) | is this event ours? contract id + topic symbol + exact arity; false on any doubt |
normalize_event(event) | decode topics and data into a NormalizedEvent; Err on an unexpected shape |
decode_call(call, spec) | decode a proposal action into a DecodedCall; the spec supplies argument names |
Governor-specific helpers — state and tally reconstruction, action enums, contextual rule projection — live in the same module.
Rules that are not optional
- Fail closed. Unknown variant ⇒ a preserved
Unknownand anErr, never a plausible guess. Missing field ⇒Err. Absent spec ⇒decoding: "unverified". - Exact matching.
matches_eventdemands the contract id, the topic symbol and the exact topic arity. A superset match would decode another governor’s events under the wrong rules. - No governor logic elsewhere. Reimplementing proposal state, tally or action variants in the indexer or the dashboard duplicates the parser’s golden tests and drifts from them. The consumers call the pinned WASM.
- Unknown is data. A
ProposalActionvariant the adapter does not know is preserved with its raw payload, not dropped and not mapped to a known one.
Implementation status
| Adapter | Status | Where |
|---|---|---|
Script3 (script3-soroban-governor) | implemented | crates/core/src/adapters/script3.rs |
| OpenZeppelin | not implemented; fixtures and research committed | docs/adapters/openzeppelin.md |
To add a governor, follow the authoring guide.
Status: implemented and differentially tested against committed on-chain reads. Provenance for every fixture is in
tests/fixtures/README.md.
Adapter notes: Script3 soroban-governor
Status: implemented in crates/core/src/adapters/script3.rs
(Script3Adapter), verified offline against committed live-testnet captures by
crates/core/tests/differential.rs. Everything below was read from the pinned
upstream source; the source-to-behavior mapping table at the bottom says which
file each decoded shape comes from.
Repo lives at github.com/script3/soroban-governor (org script3, NOT script3-io).
Version verified
- Latest release v1.1.1 (2024-07-09), built with Soroban CLI 21.0.0; workspace pins
soroban-sdk = "20.5.0". Source: https://api.github.com/repos/script3/soroban-governor/releases , https://github.com/script3/soroban-governor/blob/main/Cargo.toml
Architecture verified
- Two contracts: Governor (
contracts/governor, cratesoroban-governor) and Votes/Voter (contracts/votes, cratesoroban-votes). No separate timelock contract — timelock is a Governor setting. Treasury = the Governor contract itself. - Voter has feature-flag variants:
bonding(default, wraps a Stellar Asset 1:1), admin variant (mint/clawback),sep-0041variant. Source: https://github.com/script3/soroban-governor/blob/main/docs/architecture.md
Governor functions verified (contracts/governor/src/contract.rs)
initialize(votes: Address, council: Address, settings: GovernorSettings)settings() -> GovernorSettings,council() -> Address,vote_token() -> Addresspropose(creator: Address, title: String, description: String, action: ProposalAction) -> u32get_proposal(proposal_id: u32) -> Option<Proposal>close(proposal_id: u32)— computeseta; there is NOqueuefunctionexecute(proposal_id: u32),cancel(from: Address, proposal_id: u32)vote(voter: Address, proposal_id: u32, support: u32)(0=Against, 1=For, 2=Abstain)get_vote(voter, proposal_id) -> Option<u32>,get_proposal_votes(proposal_id) -> Option<VoteCount>
Types verified (contracts/governor/src/types.rs)
Proposal { id: u32, config: ProposalConfig, data: ProposalData }ProposalConfig { title: String, description: String, action: ProposalAction }ProposalData { creator: Address, vote_start: u32, vote_end: u32, eta: u32, status: ProposalStatus, executable: bool }ProposalStatus: Open=0, Successful=1, Defeated=2, Expired=3, Executed=4, Canceled=5VoteCount { against: i128, _for: i128, abstain: i128 }GovernorSettings { proposal_threshold: i128, vote_delay: u32, vote_period: u32, timelock: u32, grace_period: u32, quorum: u32 (BPS), counting_type: u32 (bitmask), vote_threshold: u32 (BPS) }- Proposal calldata:
ProposalActionenum =Calldata(Calldata) | Upgrade(BytesN<32>) | Settings(GovernorSettings) | Council(Address) | Snapshot;Calldata { contract_id: Address, function: Symbol, args: Vec<Val>, auths: Vec<Calldata> }(recursive; nested auths becomeInvokerContractAuthEntrysub-invocations at execute time).
Events verified (contracts/governor/src/events.rs)
| topic[0] | topics[1..] | data |
|---|---|---|
proposal_created | proposal_id: u32, proposer: Address | title, description, action, vote_start, vote_end |
proposal_canceled | proposal_id: u32 | () |
proposal_voting_closed | proposal_id: u32, status: u32, eta: u32 | VoteCount |
proposal_executed | proposal_id: u32 | () |
proposal_expired | proposal_id: u32 | () |
vote_cast | proposal_id: u32, voter: Address | support: u32, amount: i128 |
Votes contract events verified (contracts/votes/src/events.rs)
| topic[0] | topics[1..] | data |
|---|---|---|
delegate | delegator, delegatee | old_delegatee |
votes_changed | delegate | old_votes: i128, new_votes: i128 |
set_admin | admin | new_admin |
deposit/withdraw/claim (bonding only) | account | amount: i128 |
set_emissions | eps: u64, expiration: u64 | () |
Votes checkpoints: packed u128 = (sequence: u32) << 96 | amount (u96); per-user current
checkpoint in persistent storage, history Vec<u128> in temporary storage; upper_lookup
binary search. Functions: get_votes, get_past_votes(user, sequence), get_delegate,
delegate(account, delegatee) (no chaining), total_supply, get_past_total_supply.
Source: https://github.com/script3/soroban-governor/tree/main/contracts/votes/src
Published deployments (MAINNET only; from Script3’s governance explorer config)
| DAO | Governor | Votes |
|---|---|---|
| YieldBlox | CANSYFVMIP7JVYEZQ463Y2I2VLEVNLDJJ4QNZTDBGLOOGKURPTW4A6FQ | CAZ2U7Z2LS3N72NKMOR34HOV4WFDCMA2TPWD3KU4EA2VKZARFPMLF3LI |
| Stellar Community Fund | CD5BO2F56Q7WK37SWASI7J5J46H37ZWEERJWZFJLPQLP6Y6I76MQZCZO | CDHUZKXBNMAHTK62HDS6AVIZANVOABKRGY3SP3EUKYE634RFQ5NPG |
| Soroban Domains | CDXTT5PBPZ3ERZCC5S6NE2M3NMUNDKL4SJLT26DD3CYUWUWSUV5QPZRW | CC4Y5WAI5MF3HF77BAM4YOS3Y4NFZAW3FTRFS25GRLCRBGPDWPYN6VZ3 |
Source: JS config of https://mainnet.governance.script3.io (linked from https://script3.io).
Source-to-behavior mapping (adapter implementation)
Adapter: crates/core/src/adapters/script3.rs, pinned SHA
a2ac6de81055be5bd13e31f922c9546309bfdb8a (same commit as
scripts/upstream/upstream.lock.json). Each decoded shape maps to one file:
| Adapter code path | Upstream file | What it decodes |
|---|---|---|
EVENT_ARITY + matches_event | contracts/governor/src/events.rs | topic[0] symbol + exact topic arity per event |
normalize_event proposal_created | contracts/governor/src/events.rs | topics [symbol, id u32, proposer]; data [title, desc, action, vote_start, vote_end] |
normalize_event vote_cast | contracts/governor/src/events.rs | topics [symbol, id u32, voter]; data [support u32, amount i128] |
normalize_event proposal_voting_closed | contracts/governor/src/events.rs | topics [symbol, id u32, status u32, eta u32]; data VoteCount map |
decode_action (5 variants + fail-closed Unknown) | contracts/governor/src/types.rs | `ProposalAction = Calldata |
decode_calldata | contracts/governor/src/types.rs | Calldata { contract_id, function, args, auths } (recursive) |
decode_settings | contracts/governor/src/types.rs | GovernorSettings (all 9 fields required) |
decode_vote_count | contracts/governor/src/types.rs | VoteCount { against, _for, abstain } |
state_from_chain | contracts/governor/src/types.rs | Proposal { id, config, data } layout from get_proposal |
crates/core/src/tally.rs | contracts/governor/src/vote_count.rs, constants.rs | add_vote, count_quorum, is_over_quorum, is_over_threshold (strict >, floored) |
crates/core/src/checkpoint.rs | contracts/votes/src/checkpoints.rs | packed u128 checkpoints, upper_lookup floor semantics |
AMOUNT_POSITIONS | contracts/votes/src/contract.rs | transfer(from,to,amount) → amount at args[2] |
VotePowerModel | contracts/governor/src/contract.rs:268 | power = get_past_votes(voter, vote_start) at snapshot |
Every row is asserted against committed raw captures in
crates/core/tests/differential.rs (ids 0-5, offline).
Vote-weight decay: no decay term found (source reading)
contracts/votes/src/ in the pinned Script3 source contains no decay term —
power is a stored checkpoint read (get_past_votes → upper_lookup), never a
function of elapsed time. The OpenZeppelin stack
(packages/governance/src/votes/) is the same shape. Found by full-text search
of both trees at their pinned SHAs on 2026-10-08; this is a source reading, not
a live-verified claim. The adapter implements no decay because neither
governor’s vote accounting contains one.
UNVERIFIED / honest limitations
- No official testnet deployment IDs found. The repo Makefile ID is a localhost
Standalone placeholder. For testnet proof we must deploy via
scripts/seed-testnet.*. - Event shapes above are from source reading; not yet observed on live testnet by us.
Adapter implementation must re-verify against emitted events from our seeded testnet
deployment before marking anything
verifiedat runtime. - Whether these exact event shapes are emitted by mainnet deployments at v1.1.1: UNVERIFIED (we have not queried mainnet).
WASM adapter bindings
The WASM package exposes the adapter through sans-IO JSON bindings. All base64
values below are individual XDR-encoded ScVal values; malformed JSON, XDR,
unknown event shapes, and missing contract specs return an explicit error or an
unverified result. The caller must select this adapter from its own registry;
the parser never guesses an adapter from an event alone.
| Export | Inputs | Output and failure behavior |
|---|---|---|
identify_governor() | none | "script3-soroban-governor" |
matches_event(contractId, ledger, txHash, topicsJson, value) | Complete topic vector as a JSON array of base64 XDR plus value | Boolean structural match; malformed input is an error and a non-matching contract/topic shape is false |
normalize_event(contractId, ledger, txHash, topicsJson, value) | Same event inputs | JSON NormalizedEvent with verified payload, or an explicit normalization error |
decode_call(contractId, functionName, argsJson, specJson?) | JSON array of argument XDR and optional ContractSpec JSON | JSON DecodedCall; without a usable spec, arguments remain positional and decoding is unverified |
The complete topic vector is required so arity and topic positions are checked;
passing just topic[0] is not sufficient for a match.
Status: planned — not implemented. No OpenZeppelin proposal is decoded by the parser today. The commitments below are the shipped test contracts and the research that a future adapter will use; the adapter itself does not exist, so the indexer cannot decode OpenZeppelin governance events yet.
Adapter notes: OpenZeppelin stellar-contracts Governor
Status: researched from source, adapter NOT yet implemented (phase 2 deliverable).
Everything below was read from the repository/docs on 2026-10-05.
Repo: github.com/OpenZeppelin/stellar-contracts (created 2024-12-11 under this name;
no former name found — claims of a rename are UNVERIFIED/not applicable).
Version verified
- Latest stable v0.7.2 (2026-06-09); latest overall v0.8.0-rc.3 (2026-06-16), whose release notes state it is “not yet been audited, and not ready for use in production”.
- Governance (Governor/Votes/Timelock) first shipped in v0.7.0 (2026-04-03).
- Workspace pins
soroban-sdk = "28.0.0". - README describes the project as preliminary/“as-is” software. Sources: https://api.github.com/repos/OpenZeppelin/stellar-contracts/releases , https://raw.githubusercontent.com/OpenZeppelin/stellar-contracts/main/Cargo.toml , https://raw.githubusercontent.com/OpenZeppelin/stellar-contracts/main/README.md
Governor module verified (packages/governance/src/governor/mod.rs, storage.rs)
voting_delay()/voting_period()are in ledgers (DAY_IN_LEDGERS = 17280).counting_mode() -> Symbolreturns"simple".proposal_threshold() -> u128,quorum(ledger: u32) -> u128(checkpoint-based),get_token_contract() -> Address,has_voted(proposal_id: BytesN<32>, account) -> bool.propose(targets: Vec<Address>, functions: Vec<Symbol>, args: Vec<Vec<Val>>, description: String, proposer: Address) -> BytesN<32>cast_vote(proposal_id: BytesN<32>, vote_type: u32, reason: String, voter: Address) -> u128(0=Against, 1=For, 2=Abstain)queue(targets, functions, args, description_hash, eta: u32, operator) -> BytesN<32>;execute(...)/cancel(...)have no default impl (implementation required);proposals_need_queuing() -> booldefaults tofalse.get_proposal_id(...)= keccak256 of XDR-serialized targets/functions/args/description_hash.
State machine verified
ProposalState: Pending=0, Active=1, Defeated=2, Canceled=3, Succeeded=4, Queued=5, Expired=6, Executed=7. Pending/Active/Defeated are time-derived; others stored.- CRITICAL FACT: proposal calldata is NOT stored on-chain. Only
ProposalCore { proposer: Address, vote_snapshot: u32, vote_end: u32, state: ProposalState }is stored. Callers re-supply targets/functions/args at queue/execute time; the keccak256 proposal_id binds them. Consequence for our product: for OZ governors, the indexer must capture calldata fromproposal_createdevent data (which does carry it) — the contract state alone can never show it.
Events verified (governor/mod.rs #[contractevent] structs)
| topic[0] | topics[1..] | data |
|---|---|---|
proposal_created | proposal_id: BytesN<32>, proposer: Address | targets, functions, args, vote_snapshot: u32, vote_end: u32, description: String |
vote_cast | voter: Address, proposal_id | vote_type: u32, weight: u128, reason: String |
proposal_queued | proposal_id | eta: u32 |
proposal_executed | proposal_id | — |
proposal_cancelled | proposal_id | — |
quorum_changed | — | old_quorum: u128, new_quorum: u128 |
Note topic ORDER differs from Script3 for vote_cast (voter before proposal_id).
Votes module verified (packages/governance/src/votes/)
get_votes(account) -> u128,get_votes_at_checkpoint(account, ledger: u32) -> u128(error FutureLookup=4100 if ledger >= current),get_total_supply(),get_total_supply_at_checkpoint(ledger),get_delegate(account) -> Option<Address>,delegate(account, delegatee). Only delegated power counts; no undelegate.- Checkpoints:
Checkpoint { ledger: u32, votes: u128 }; keysDelegatee(Address),NumCheckpoints(Address),DelegateCheckpoint(Address,u32),VotingUnits(Address). - Events:
delegate_changedtopics[.., delegator]data[from: Option<Address>, to: Address];delegate_votes_changedtopics[.., delegate]data[previous: u128, new: u128].
Timelock verified (packages/governance/src/timelock/)
- Governor queueing: with
proposals_need_queuing()=true,queuemoves Succeeded→Queued, emitsproposal_queued.etais informational — not enforced by the governor itself. Timelocktrait:get_min_delay() -> u32(ledgers),hash_operation(target, function, args, predecessor, salt),schedule/execute/cancel/update_delay(no default impls; access control left to implementer).- Events:
operation_scheduled[id, target] data [function, args, predecessor, salt, delay];operation_executed[id, target];operation_cancelled[id];min_delay_changed. - Example integration:
examples/fungible-governor-timelock/governor/src/governor.rs.
Deployments
- UNVERIFIED / none found for governance. No testnet/mainnet governor contract IDs in
repo or docs. Only multisig example IDs exist (not governance). For testnet proof we must
build and deploy the example governor ourselves via
scripts/seed-testnet.*.
UNVERIFIED / honest limitations
- Whether the v0.7.0 audit covered the governance packages: UNVERIFIED (audit PDF not read).
- Event shapes are from source reading; not yet observed on live testnet by us.
- v0.8.0-rc API may change shapes; adapter targets v0.7.x line.
Risk rules
Generated from crates/core/src/risk.rs by docs-site/scripts/gen-risk-rules.py. Do not edit by hand: CI regenerates this page and fails on any diff.
Classification is an exact, case-sensitive match on the verified function name. A function name that matches no rule is UNVERIFIED — never downgraded to LOW. Every classification exposes the rule that matched, so no tier is an opaque score.
The table holds 31 rules across 4 tiers.
Critical — 11 rules
Rules that can move value or change contract code/ownership.
| Function name | Why it is this tier |
|---|---|
transfer | moves token value |
transfer_from | moves token value on behalf of another account |
mint | creates new token supply |
burn | destroys token supply |
upgrade | replaces contract WASM |
update_wasm_hash | replaces contract WASM |
set_admin | changes contract administrator |
change_admin | changes contract administrator |
transfer_ownership | changes contract owner |
renounce_ownership | removes contract owner |
execute | executes a queued governance payload; effect depends on payload |
High — 8 rules
Rules that change governance parameters or state.
| Function name | Why it is this tier |
|---|---|
queue | schedules a passed proposal for execution |
cancel | cancels a proposal |
set_voting_delay | changes governance timing |
set_voting_period | changes governance timing |
set_quorum | changes quorum requirement |
set_proposal_threshold | changes who may propose |
update_config | changes contract configuration |
set_config | changes contract configuration |
Medium — 5 rules
Rules with observable but bounded effects.
| Function name | Why it is this tier |
|---|---|
propose | creates a proposal; effect deferred to execution |
delegate | moves voting power between accounts |
vote | casts a vote |
cast_vote | casts a vote |
approve | grants a token allowance |
Low — 7 rules
Read-only or informational actions.
| Function name | Why it is this tier |
|---|---|
get_proposal | read-only accessor |
get_votes | read-only accessor |
balance | read-only accessor |
decimals | read-only accessor |
name | read-only accessor |
symbol | read-only accessor |
version | read-only accessor |
Contextual rules
The rules above match on the function name. These additional rules evaluate the decoded arguments and emit every rule that fired, each with its evidence:
batched_actionslarge_valueself_calltreasury_outflow
Contextual rules never change the function-name tier; they add evidence next to it. See docs-site/src/parser/decoding-rules.md for what a caller receives.
CLI reference
Generated from the sgp binary (crates/cli) by docs-site/scripts/gen-cli.py. Do not edit by hand: CI regenerates this page and fails on any diff. Binary version: sgp 0.1.0.
sgp is the operator tool. Network IO lives in crates/cli/src/rpc.rs; the core crate stays sans-IO.
Top-level
Soroban governance payload parser (fail-closed)
Usage: sgp <COMMAND>
Commands:
decode-scval Decode a base64 XDR ScVal into JSON. Offline, pure
spec-from-wasm Extract and parse the contractspecv0 section from a WASM file. Offline
fetch-spec Fetch a contract's WASM from a Soroban RPC and parse its contractspecv0
health Print RPC health: latest ledger, oldest ledger, retention window
ttl Read a contract's instance/code TTL entries (protocol 29) and check remaining ledgers. Fails closed: missing TTL entries are errors
simulate Simulate a fully assembled TransactionEnvelope. The result is always labeled as an estimate and can optionally be captured verbatim
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
sgp decode-scval
Decode a base64 XDR ScVal into JSON. Offline, pure
Decode a base64 XDR ScVal into JSON. Offline, pure
Usage: sgp decode-scval <XDR_BASE64>
Arguments:
<XDR_BASE64> base64 XDR of an ScVal (or "-" to read stdin)
Options:
-h, --help Print help
sgp spec-from-wasm
Extract and parse the contractspecv0 section from a WASM file. Offline
Extract and parse the contractspecv0 section from a WASM file. Offline
Usage: sgp spec-from-wasm <WASM>
Arguments:
<WASM> path to contract .wasm
Options:
-h, --help Print help
sgp fetch-spec
Fetch a contract’s WASM from a Soroban RPC and parse its contractspecv0
Fetch a contract's WASM from a Soroban RPC and parse its contractspecv0
Usage: sgp fetch-spec [OPTIONS] --contract <CONTRACT>
Options:
--contract <CONTRACT> contract id (strkey C...)
--rpc <RPC> Soroban RPC URL (default: SDF public testnet) [default: https://soroban-testnet.stellar.org]
-h, --help Print help
sgp health
Print RPC health: latest ledger, oldest ledger, retention window
Print RPC health: latest ledger, oldest ledger, retention window
Usage: sgp health [OPTIONS]
Options:
--rpc <RPC> [default: https://soroban-testnet.stellar.org]
-h, --help Print help
sgp ttl
Read a contract’s instance/code TTL entries (protocol 29) and check remaining ledgers. Fails closed: missing TTL entries are errors
Read a contract's instance/code TTL entries (protocol 29) and check remaining ledgers. Fails closed: missing TTL entries are errors
Usage: sgp ttl [OPTIONS] --contract <CONTRACT>
Options:
--contract <CONTRACT> contract id (strkey C...)
--rpc <RPC> [default: https://soroban-testnet.stellar.org]
--min-remaining <MIN_REMAINING> exit 1 if remaining ledgers fall below this threshold
-h, --help Print help
sgp simulate
Simulate a fully assembled TransactionEnvelope. The result is always labeled as an estimate and can optionally be captured verbatim
Simulate a fully assembled TransactionEnvelope. The result is always labeled as an estimate and can optionally be captured verbatim
Usage: sgp simulate [OPTIONS] <TRANSACTION>
Arguments:
<TRANSACTION> base64 XDR TransactionEnvelope (or "-" to read stdin)
Options:
--rpc <RPC> [default: https://soroban-testnet.stellar.org]
--capture <CAPTURE> write the raw JSON-RPC response to this path
-h, --help Print help
WASM usage
crates/wasm exposes the core crate to JavaScript through wasm-bindgen. The
package is published as a GitHub pre-release asset, nodejs and web targets
in one tarball, and consumed by content pin rather than by version range.
Current release
| Field | Value |
|---|---|
| Version | 0.1.0-alpha.2 |
| Asset | sgg-parser-wasm-0.1.0-alpha.2.tgz |
| sha256 | 65193e43642f55abd083e2283aa5ed3caee5839c40a74c7b3f30398ea3ec0629 |
| Bytes | 283,043 |
| Targets | nodejs, web |
| Schema | v1 |
| Toolchain | rustc 1.96.0 / wasm-pack 0.13.1 |
releases/parser-wasm.lock.json is the committed source of this table, and the
PR gate checks that the README, the lock file and the asset agree.
Exports
| Export | Purpose |
|---|---|
decode_scval_base64(xdr_base64) | ScVal → JSON string |
parse_contract_spec(wasm_bytes) | extract and parse contractspecv0 |
classify_risk(function_name, verified) | apply the published rule table |
script3_decode_proposal(...) | decode a Script3 proposal into actions |
script3_state_from_chain(...) | reconstruct proposal state from chain reads |
script3_tally_from_chain(...) | reconstruct the tally from chain reads |
evaluate_risk_context(call_json, risk_context_json) | contextual rules over decoded arguments |
evaluate_tally(...) | tally and quorum arithmetic |
identify_governor() | identify the compiled governor adapter |
matches_event(...) | complete-topic structural event matching |
normalize_event(...) | verified event normalization with explicit failures |
decode_call(...) | spec-aware call decoding, or positional unverified output |
Event matching and normalization require the complete topic vector and the caller-supplied registry context; an unregistered contract or malformed shape returns an explicit failure or an unverified fallback. Call decoding without a usable contract spec remains positional and unverified.
Node
const wasm = require('./pkg/nodejs/sgp_parser_wasm.js');
const decoded = JSON.parse(wasm.decode_scval_base64('AAAADwAAABBwcm9wb3NhbF9jcmVhdGVk'));
// → "proposal_created"
Browser
import init, { decode_scval_base64 } from './pkg/web/sgp_parser_wasm.js';
await init();
const decoded = JSON.parse(decode_scval_base64('AAAAAwAAAAE='));
// → 1
The dashboard loads the web target from a hash-verified vendored tarball. Its
Content-Security-Policy allows script-src 'self' 'wasm-unsafe-eval', which the
WASM instantiation requires.
Tests over the artifact
Both targets are tested against the built package, not against a mock:
crates/wasm/js-tests/node.test.mjs— Node test runner;crates/wasm/js-tests/browser.test.mjs— headless Chromium;- the dashboard’s Vitest suite loads the same artifact through its
nodejstarget, and its Playwright suite decodes in a real browser.
Versioning
Consumers pin the asset by sha256. The simulation request/response surface and the OpenZeppelin adapter remain separate future work.
Status: model and CLI boundary implemented. The sans-IO parser models committed real
simulateTransactioncaptures and the offline tests exercise it.sgp simulateaccepts a fully assembled transaction envelope; no page in the dashboard or indexer renders an impact today.
Dry-run (simulateTransaction) modeling
Sans-IO parser side of the dry-run feature. Code: crates/core/src/simulate.rs;
offline tests: crates/core/tests/simulate.rs (committed real captures).
Charter rule 5
Every dry-run output is an estimate. ExecutionImpact.estimate is always
true, and simulated_at_ledger is mandatory — a response without
latestLedger is an error, never a default. Simulation runs against the current
ledger, not the future execution ledger; timelocks and state changes between now
and execution can change the real result.
Modeled fields
Modeled against the committed raw captures under
tests/fixtures/seed-v2/rpc/*-simulateTransaction-*.json. The RPC
response.result object carries exactly:
| RPC field | ExecutionImpact field | notes |
|---|---|---|
latestLedger | simulated_at_ledger | always present; required |
minResourceFee | min_resource_fee_stroops | string; absent ⇒ null |
stateChanges | state_changes | present only when the call writes; else null |
results | (presence only) | presence ⇒ success; XDR not decoded here ⇒ decode_status: unverified |
cost.cpuInsns | cpu_instructions | not present in any capture ⇒ always null |
cost.memBytes | memory_bytes | not present in any capture ⇒ always null |
Absent means null; nothing is defaulted to zero or to “success”.
Rent
Not modeled. The captured responses contain no rent figure, and the Soroban
rent model depends on entry TTLs and ledger timing the simulation response does
not expose directly. Rather than derive a number from an assumption, the field is
omitted and this limitation recorded. A future capture that includes a rent
figure (or a documented derivation from stateChanges durability + TTL) can add
it.
IO split
build_simulate_request(transaction_xdr_base64, request_id) returns the JSON-RPC
2.0 request body and validates the envelope (fail-closed). Assembling the
transaction from a decoded action, and sending it, is IO and lives in the CLI’s
RpcClient; the core crate stays sans-IO.
The CLI exposes the same boundary without guessing how to assemble a proposal:
sgp simulate <transaction-envelope-base64> --capture raw-simulate-response.json
cat transaction-envelope-base64.txt | sgp simulate -
The command validates the envelope before making the RPC call, prints only the
parsed ExecutionImpact, and writes the raw response only when --capture is
provided. The output remains an estimate and is not a guarantee of future
execution.
Not yet done
- The CLI accepts a fully assembled transaction envelope; it does not assemble proposal actions it cannot verify.
decode_statusisunverified: the impact parser does not decode the result XDR against a contract spec.
Indexer
governance-event-indexer moves governance events from the public Stellar
testnet RPC into Postgres — raw events are preserved and parser-backed event
decodes are stored durably — and serves an initial read-only GraphQL API for
governance-v1 schema.
Status: ingestion, durable event decode, the initial GraphQL surface, the deploy recipe (I6) and the clean-clone prove script (I7) are built. Proposal, vote and delegate projections and reconciliation (I5) remain planned. The GraphQL endpoint serves health and normalized event records; proposal, impact and delegate fields remain unavailable until their tables are implemented.
What is built
Registry and pins
- The pinned
deployments.jsonfrom the parser repo, hash-verified on every load;GOVERNOR_REGISTRY_OVERRIDEadds contract ids at runtime from validated strkeys, failing closed on anything malformed. - Three pins verified offline in every PR:
parser.lock(WASM asset),schema.lock(SDL) andregistry.lock(deployments). A nightly workflow checks that the pinned URLs still resolve to identical bytes.
Ingestion
- Windows.
getEventsin ranges of at most 1000 ledgers, because the public RPC times out on wider ranges;INGEST_WINDOW_LEDGERSis rejected above 1000. - Cursors. One persisted cursor per registered contract, initialized at the start of the RPC’s retention window.
- Storage. Raw events land in
raw_events, keyed(ledger, tx_hash, op_index, event_index), inserted idempotently. A window commits together with its cursor advance, so a killed process re-fetches and converges. - Retention gaps. A cursor behind the RPC’s oldest ledger writes the
unreadable range to
ingest_gapswith reasonretentionand advances past it in the same transaction — loud, never a silent skip. - Replay.
node dist/cli.js replay <dir>runs committed raw responses through the same raw-plus-decode path, tagging rowssource='fixture-replay'. Live rows aresource='live', and a later write never flips a row’s source.
Storage
Five tables exist after migrate (forward-only, tested from an empty database):
| Table | Holds |
|---|---|
raw_events | every ingested event, keyed (ledger, tx_hash, op_index, event_index), with source = live or fixture-replay |
decoded_events | parser-versioned normalized topics/payload and explicit VERIFIED or UNVERIFIED status for each raw event |
ingest_cursors | one cursor per registered contract, advanced in the same transaction as its window |
ingest_gaps | unreadable ledger ranges with the reason (retention), written instead of skipping silently |
indexer_meta | indexer bookkeeping (schema version and run metadata) |
Proposal-level tables (proposals, proposal_actions, risk_flags, votes,
power_checkpoints, delegations, delegates, reconciliation_runs) arrive
with I5 and later. Event-level decode is persisted in decoded_events.
Retry behaviour
Bounded exponential backoff with jitter on RPC failures. There is no unbounded retry loop: a failure that exceeds the bound surfaces as an error, and the cursor does not advance past data that was not stored.
What is not built
| Milestone | State |
|---|---|
| I3 decode through the pinned Script3 adapter | event-level decode persisted through the pinned WASM; proposal projections remain planned |
| I4 GraphQL (Yoga) implementing the vendored SDL exactly | initial health and event connection implemented; proposal/impact/delegate fields remain planned |
| I5 reconciliation of SQL tallies against on-chain reads | not built; drift checks remain planned |
| I6 Docker/compose deploy recipe | recipe and deploy.yml shipped; no instance has been deployed |
| I7 clean-clone prove script | built; asserts GraphQL values against the committed fixture replay, never yet run against a live indexer |
Decoding governor events in TypeScript would put governor logic outside the
parser’s GovernorAdapter and break the project’s rule that governor logic
lives in the parser. The event-level boundary now uses the pinned parser
artifact; remaining work is the durable projection of proposal entities.
Checks
| Command | What it proves |
|---|---|
npm run check-pins | vendored bytes match all three locks, offline |
npm run typecheck, npm run lint | strict TypeScript and ESLint pass |
npm run test:unit | 59 tests, no database, no network |
npm run test:integration | 18 tests: migrations from empty, windowing, idempotency, restart safety, gaps, replay |
Provenance
The indexer consumes the parser through pins: the SDL and registry at parser
commit ba3bf1a3…, the WASM asset v0.1.0-alpha.2. Its README pin table is the
authoritative list; this page summarises it, and the revisions this summary was
written against are recorded in docs-site/sources.lock.
API reference (governance-v1)
Generated from schemas/governance-v1.graphql by docs-site/scripts/gen-api.py. Do not edit by hand; CI regenerates it and fails on any diff. SDL sha256: cfbd85a82ee834ac14723cd5dd73ce18a5aba0313a91a0aaf6053d1a486ae275.
This SDL is the shared interface contract. The indexer serves it; the dashboard validates every response field against it. Bumping it requires a schema.lock update in each consumer repo, and the parser’s PR gate refuses non-additive changes to the v1 schema.
Server status: not served yet. The indexer currently stops at raw event ingestion (I2); the GraphQL server (I4) is planned. Response examples on this page are the schema, not captured responses.
schema
Root operations: query: Query.
enum DecodingStatus
| Value | Description |
|---|---|
VERIFIED | Names verified against the contract’s contractspecv0 section. |
UNVERIFIED | Raw ScVal passthrough; spec unavailable or governor unrecognized. |
enum RiskTier
| Value | Description |
|---|---|
CRITICAL | |
HIGH | |
MEDIUM | |
LOW | |
UNVERIFIED |
type RiskClassification
| Field | Type | Arguments | Description |
|---|---|---|---|
tier | RiskTier! | ||
matchedRule | String! | The exact published rule that matched, or the exact reason for UNVERIFIED. Classifications are data, not verdicts — the rule is always exposed. | |
verified | Boolean! |
type DecodedCall
| Field | Type | Arguments | Description |
|---|---|---|---|
contractId | String! | ||
functionName | String! | ||
arguments | JSON! | JSON: named object when decoding=VERIFIED, positional array when UNVERIFIED. | |
decoding | DecodingStatus! | ||
risk | RiskClassification! |
type DecodedProposal
| Field | Type | Arguments | Description |
|---|---|---|---|
governor | String! | Adapter id, or the literal ‘unverified’. Never guessed. | |
proposalId | String! | ||
proposer | String | ||
calls | [DecodedCall!]! | ||
decoding | DecodingStatus! |
type NormalizedEvent
| Field | Type | Arguments | Description |
|---|---|---|---|
governor | String! | ||
topics | [String!]! | ||
ledger | Int! | ||
txHash | String! | ||
contractId | String! | ||
payload | JSON! | ||
decoding | DecodingStatus! |
type ExecutionImpact
Dry-run simulation output. ALWAYS an estimate (charter rule 5): it runs against the current ledger, not the future execution ledger. Timelocks and state changes between now and execution can alter the real result.
| Field | Type | Arguments | Description |
|---|---|---|---|
estimate | Boolean! | ||
simulatedAtLedger | Int! | ||
cpuInstructions | String | ||
memoryBytes | String | ||
minResourceFeeStroops | String | ||
stateChanges | JSON | null when the RPC returned no state changes; absence is meaningful. | |
decodeStatus | DecodingStatus! |
type DelegateMetrics
Delegate metric: raw counts + published formula. No composite scores.
| Field | Type | Arguments | Description |
|---|---|---|---|
delegate | String! | ||
votesCast | Int! | Number of governance votes cast by this delegate that we indexed. | |
proposalsEligible | Int! | Number of proposals that were open while this delegate held voting power. | |
participationRatio | Float | votesCast / proposalsEligible. Inputs are exposed above; formula published in docs. A ratio, not a verdict. | |
delegatedPower | String! |
scalar JSON
Opaque scalar serialized as JSON.
type Query
| Field | Type | Arguments | Description |
|---|---|---|---|
events | EventConnection! | contractId: StringfromLedger: InttoLedger: Intfirst: Int = 50after: String | Governance events indexed from live testnet, oldest first. |
proposal | DecodedProposal | proposalId: String!governorContract: String! | |
executionImpact | ExecutionImpact! | proposalId: String!governorContract: String! | Dry-run impact estimate for a proposal. Every field is labeled estimate. |
delegateMetrics | DelegateMetrics! | delegate: String! | |
indexerHealth | IndexerHealth! | Indexer health: cursor position, RPC retention window, lag. Fail-loud: a stale or broken indexer surfaces here, never as an empty result set. |
type IndexerHealth
| Field | Type | Arguments | Description |
|---|---|---|---|
cursorLedger | Int! | Last ledger the indexer successfully processed. | |
latestLedger | Int! | ||
oldestLedger | Int! | ||
ledgerRetentionWindow | Int! | Retention window of the RPC in use, as reported by getHealth. | |
lagLedgers | Int! | ||
healthy | Boolean! |
type EventConnection
| Field | Type | Arguments | Description |
|---|---|---|---|
edges | [EventEdge!]! | ||
pageInfo | PageInfo! |
type EventEdge
| Field | Type | Arguments | Description |
|---|---|---|---|
cursor | String! | ||
node | NormalizedEvent! |
type PageInfo
| Field | Type | Arguments | Description |
|---|---|---|---|
hasNextPage | Boolean! | ||
endCursor | String |
Full SDL
# Soroban Governance — GraphQL SDL v1
# Single source of truth shared by governance-event-indexer (server) and
# delegate-portal-dashboard (client). Field names mirror schema-v1.json.
# Bumping this version requires a schema.lock update in every consumer repo.
schema {
query: Query
}
enum DecodingStatus {
"""Names verified against the contract's contractspecv0 section."""
VERIFIED
"""Raw ScVal passthrough; spec unavailable or governor unrecognized."""
UNVERIFIED
}
enum RiskTier {
CRITICAL
HIGH
MEDIUM
LOW
UNVERIFIED
}
type RiskClassification {
tier: RiskTier!
"""The exact published rule that matched, or the exact reason for UNVERIFIED.
Classifications are data, not verdicts — the rule is always exposed."""
matchedRule: String!
verified: Boolean!
}
type DecodedCall {
contractId: String!
functionName: String!
"""JSON: named object when decoding=VERIFIED, positional array when UNVERIFIED."""
arguments: JSON!
decoding: DecodingStatus!
risk: RiskClassification!
}
type DecodedProposal {
"""Adapter id, or the literal 'unverified'. Never guessed."""
governor: String!
proposalId: String!
proposer: String
calls: [DecodedCall!]!
decoding: DecodingStatus!
}
type NormalizedEvent {
governor: String!
topics: [String!]!
ledger: Int!
txHash: String!
contractId: String!
payload: JSON!
decoding: DecodingStatus!
}
"""Dry-run simulation output. ALWAYS an estimate (charter rule 5): it runs
against the current ledger, not the future execution ledger. Timelocks and
state changes between now and execution can alter the real result."""
type ExecutionImpact {
estimate: Boolean!
simulatedAtLedger: Int!
cpuInstructions: String
memoryBytes: String
minResourceFeeStroops: String
"""null when the RPC returned no state changes; absence is meaningful."""
stateChanges: JSON
decodeStatus: DecodingStatus!
}
"""Delegate metric: raw counts + published formula. No composite scores."""
type DelegateMetrics {
delegate: String!
"""Number of governance votes cast by this delegate that we indexed."""
votesCast: Int!
"""Number of proposals that were open while this delegate held voting power."""
proposalsEligible: Int!
"""votesCast / proposalsEligible. Inputs are exposed above; formula published
in docs. A ratio, not a verdict."""
participationRatio: Float
delegatedPower: String!
}
scalar JSON
type Query {
"""Governance events indexed from live testnet, oldest first."""
events(
contractId: String
fromLedger: Int
toLedger: Int
first: Int = 50
after: String
): EventConnection!
proposal(proposalId: String!, governorContract: String!): DecodedProposal
"""Dry-run impact estimate for a proposal. Every field is labeled estimate."""
executionImpact(proposalId: String!, governorContract: String!): ExecutionImpact!
delegateMetrics(delegate: String!): DelegateMetrics!
"""Indexer health: cursor position, RPC retention window, lag. Fail-loud:
a stale or broken indexer surfaces here, never as an empty result set."""
indexerHealth: IndexerHealth!
}
type IndexerHealth {
"""Last ledger the indexer successfully processed."""
cursorLedger: Int!
latestLedger: Int!
oldestLedger: Int!
"""Retention window of the RPC in use, as reported by getHealth."""
ledgerRetentionWindow: Int!
lagLedgers: Int!
healthy: Boolean!
}
type EventConnection {
edges: [EventEdge!]!
pageInfo: PageInfo!
}
type EventEdge {
cursor: String!
node: NormalizedEvent!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
Dashboard
delegate-portal-dashboard is a read-only Next.js application: no wallet, no
signing, no keys, no server-side secrets.
Status: scaffold merged. Two working surfaces; proposal, delegate and impact pages are planned. They need the indexer’s decoded tables, which do not exist yet.
Routes
| Route | Behaviour | Depends on |
|---|---|---|
/ | Product summary, indexer health, explicit list of what is not built | INDEXER_URL |
/simulate | Decodes a pasted base64 XDR ScVal in the browser with the pinned parser WASM; validates charset, 64 KiB cap and base64 framing before decoding | parser WASM |
/status | The exact interface pins and indexerHealth, or the exact reason it is unavailable | INDEXER_URL |
Error and staleness states
- No fallback data.
INDEXER_URLhas no default. Unset, malformed, credential-bearing, unreachable, non-JSON and schema-mismatched endpoints each render a specific reason. - Strict response validation.
lib/graphql.tschecks every field against the vendored SDL: integers must be safe non-negative integers,healthymust be a boolean, GraphQLerrorsare surfaced verbatim. Nothing is coerced or defaulted. - No cached health. Health is requested per request
(
dynamic = 'force-dynamic'), so a stopped indexer cannot read as healthy. - No empty-but-fine tables. Planned data pages will show an error state rather than an empty list that reads as “no data”.
Decode path
/simulate loads the web build of the pinned WASM from a vendored,
sha256-verified tarball. The same artifact, through its nodejs target, is the
decoder used by the Vitest suite — there is no mock decode path. Playwright
exercises the decode in real Chrome against the production build.
Interface pins
| Artifact | Pin |
|---|---|
| Parser WASM | v0.1.0-alpha.2, sha256 65193e43… |
| GraphQL SDL | parser commit ba3bf1a3…, sha256 cfbd85a8… |
Checks
| Command | Result recorded in the PR gate |
|---|---|
npm run check:pins | vendored WASM and SDL match both locks |
npm run typecheck, npm run lint | strict TypeScript, ESLint with the no-fixture-imports rule |
npm run test:unit | 25 unit/component tests, real pinned WASM |
npm run build | production build |
npx playwright test | 4 e2e tests with INDEXER_URL deliberately unset |
bash scripts/check-claims.sh | 7/7 README claims |
Planned
/proposal/[id],/delegates,/delegate/[address], execution impact with an ESTIMATE at ledger N badge.graphql-codegenfrom the vendored SDL, replacing the hand-written query.- Event XDR decode, transaction-hash lookup and risk classification on
/simulate, once the parser exposes those exports. - A global staleness banner and visible
fixture-replayvslivedistinction. - Lighthouse accessibility measurement, claimed after it is measured.
Metrics
Status: planned. No metric below is served today: the indexer has no GraphQL API (I4). The
governance-v1SDL already exposesvotesCast,proposalsEligible,participationRatioanddelegatedPoweronDelegateMetrics; the remaining definitions are the published contract the API must implement.
Delegate behaviour is reported as raw counts plus a published formula. There is no composite score and no ranking. Every ratio below names its numerator and its denominator, and both inputs are returned next to it so a reader can recompute the number.
Eligible proposals
Definition. Proposals that were open for voting while the delegate held voting power greater than zero at the proposal’s snapshot ledger.
- Numerator/denominator: this is a count, not a ratio. It is the denominator of participation.
- Snapshot rule: power is read at the proposal’s voting-start ledger from checkpointed power, not at the delegate’s current balance.
- Exclusions: proposals whose voting window closed before the indexer’s cursor started (unreadable retention range) are not counted, and the open gap is reported on the health surface.
Participation
Definition. Of the proposals the delegate was eligible for, the share on which they cast any vote.
participation = votesCast / proposalsEligible
- Numerator:
votesCast— governance votes cast by the delegate that the indexer decoded, one per proposal. - Denominator:
proposalsEligible— as defined above. proposalsEligible = 0⇒participationRatioisnull, not0. A zero denominator is not “no participation”.
Outcome alignment
Definition. Of the proposals the delegate voted on, the share where their vote side matches the proposal’s final outcome.
outcome_alignment = votesWithWinningOutcome / votesCast
- Numerator: votes whose side (for / against / abstain) equals the recorded final outcome.
- Denominator:
votesCast. - Ties and cancelled proposals: excluded from both numerator and denominator,
and counted in a separate
excludedProposalsfigure. - This is a descriptive ratio about past votes. It is not a prediction and not a quality score.
Power-majority alignment
Definition. Of the proposals the delegate voted on, the share where their side matches the side that held the majority of voting power.
power_majority_alignment = votesWithPowerMajority / votesCast
- Numerator: votes whose side equals the side with the greater total voting power at close.
- Denominator:
votesCast. - Excluded: proposals with no votes, exact power ties, and proposals whose
tally could not be reconstructed (recorded as
unverified).
Abstain rate
Definition. Of the delegate’s votes, the share that abstained.
abstain_rate = abstainVotes / votesCast
- Numerator: votes with side
abstain. - Denominator:
votesCast.
Median vote latency
Definition. The median number of ledgers between a proposal becoming eligible and the delegate’s vote, over the proposals they voted on.
latency_i = voteLedger_i - votingStartLedger_i
median_vote_latency = median(latency_i)
- Unit: ledgers, reported as a median. The count of samples (
votesCast) is returned with it; a median over a small sample is reported as such, not hidden. - Votes cast in the same ledger as voting start have latency
0. - No votes ⇒
null, not0.
Data rules
- Numbers are rendered exactly as the API returned them. Nothing is rounded away and nothing is combined into a single score.
- Rows produced by fixture replay carry
source='fixture-replay'; live rows carrysource='live'. No API response mixes them silently. - If a denominator is zero, the ratio is
null. A missing input is never rendered as zero.
Evidence
Generated from deployments.json by docs-site/scripts/gen-evidence.py (sha256 b8c71c34bd5bdd64183b6dae39bdbcd0641a5d8d7084cc751925f4e7e14a3a6d). Do not edit by hand. The registry is checked against the committed fixtures and upstream pins by scripts/check-deployments.py in the PR gate; this page renders it.
Every contract id below was deployed on live testnet with the recorded transaction hash, or captured from the public source named in tests/fixtures/README.md. There are no mock ids.
Network: testnet
| Field | Value |
|---|---|
| RPC | https://soroban-testnet.stellar.org |
| Passphrase | Test SDF Network ; September 2015 |
| RPC retention window | 120,960 ledgers |
| Retention verified | 2026-10-05 |
| Retention evidence | tests/fixtures/phase1-proof.json |
Contracts (8)
| Key | Contract id | Purpose | Deployed at ledger | Deploy tx |
|---|---|---|---|---|
fixtureGovernor | CDJWPKSQ4NA67PKT… | Real WASM-backed governance-shaped contract for parser proof. NOT a production governor. | 5,035,901 | a37b875db6c6… |
seedV2Script3.script3Votes | CCAUJK6V6GIYQANK… | votes (checkpointed voting power) for the Script3 governor | 5,083,606 | 9896582ef9b4… |
seedV2Script3.script3Governor | CCKGJBCBBYNWCMSB… | the pinned Script3 governor | 5,083,608 | 9508d28ca1ae… |
seedV2Script3.script3MockSubcall | CAQCXFI6YSXWGCB3… | Script3-side out-of-surface target used by the s4/o4 unknown-contract-call proposals | 5,083,610 | 45395fede4b9… |
seedV2OpenZeppelin.ozToken | CA77UOHZIPLJKPRV… | the pinned OpenZeppelin votes/fungible token (holds voting power) | 5,083,612 | 026fd6edeeea… |
seedV2OpenZeppelin.ozGovernor | CAONSV2R2YCNJS6D… | the pinned OpenZeppelin governor | 5,083,614 | fed6be425422… |
seedV2OpenZeppelin.ozUpgradeableV1 | CC3SMQABLUGPGUD5… | upgrade target for the s2/o2 contract-upgrade proposals | 5,083,616 | 468c69a101d9… |
seedV2OpenZeppelin.ozMockSubcall | CAXHKR66BEHSTAX4… | OpenZeppelin-side out-of-surface target used by the s4/o4 unknown-contract-call proposals | 5,083,618 | aec4e10d1de6… |
WASM hashes
| Key | WASM sha256 | Bytes | Upload tx |
|---|---|---|---|
fixtureGovernor | a54dd248e58f0fa945c718ac3badfa03dbaa1642d2a08dc409caeaf3d3555b38 | 2,951 | 5b6fc833ed21… |
seedV2Script3.script3Votes | e8bbc883b2c7fcd8716b07271929eca801fb40f2d3cf5964b139fa741c40f787 | 24,926 | not recorded |
seedV2Script3.script3Governor | 16033b38b740b1d5af82025a671490b3cea2a0df59b7ffad005f3f7ca3fa27f0 | 39,581 | not recorded |
seedV2Script3.script3MockSubcall | 670215400fffd3d0f6303000d18dfd5bab0e2b1a7b26219e4a8a3bce08eab763 | 7,652 | not recorded |
seedV2OpenZeppelin.ozToken | b5363f54a65e4134f838c03ae9b90718cd7913be5bb3b446aa731e72c7d97e4b | 23,856 | not recorded |
seedV2OpenZeppelin.ozGovernor | 77a0400e446947330f8b80f3a57f078d375a793e7aacf3674f5289bacb66edf7 | 26,096 | not recorded |
seedV2OpenZeppelin.ozUpgradeableV1 | 1018bca637343ad206e568dd78a89f0b2a2f768477947be2546682113216c248 | 15,640 | not recorded |
seedV2OpenZeppelin.ozMockSubcall | 670215400fffd3d0f6303000d18dfd5bab0e2b1a7b26219e4a8a3bce08eab763 | 7,652 | not recorded |
Fixtures and proofs
The raw RPC responses these contracts were deployed with are committed under tests/fixtures/; tests/fixtures/README.md records the provenance of every capture, and tests/fixtures/phase1-proof.json is the recorded live-testnet proof that the phase 1 claims rest on. python3 scripts/check-deployments.py re-checks this registry against those fixtures and the upstream pins in the PR gate.
Totals
- Registered contracts: 8
- Networks: 1
The contract ids above are testnet deployments. Testnet contracts can be removed, archived or lose their TTL entries; sgp ttl --contract <id> checks the live remaining ledgers and fails closed when entries are missing.
Honest limitations
Everything on this page is a gap, not a claim. If a feature appears elsewhere on the site, it is either implemented and checked, or it carries a Status: planned banner. Where a gap blocks a milestone, the blocker is named.
Parser
| Limitation | Detail |
|---|---|
| OpenZeppelin adapter not implemented | Research, committed test contracts and fixtures exist; no adapter decodes OZ proposals or events. claims.json asserts this state (oz-adapter-not-implemented). |
| WASM event surface | matches_event, normalize_event and decode_call are exported in v0.1.0-alpha.2; the indexer still needs persisted decode tables. |
| CLI simulation boundary | sgp simulate accepts a validated transaction envelope and emits an estimate; proposal-action assembly and rent remain out of scope. |
| Rent is not modelled | The committed simulateTransaction captures contain no rent figure; the field is omitted rather than estimated from an assumption. |
| Dry-run results are estimates | estimate: true and simulatedAtLedger are mandatory. A simulation runs against the current ledger, not the future execution ledger. |
| No mainnet DAO exercised | Fixtures come from testnet deployments listed in Evidence. |
| TLS advisories fixed by patch update | cargo audit reported six advisories in rustls 0.23.45, rustls-webpki 0.103.15 and webpki-root-certs 1.0.9. Updated to 0.23.46, 0.103.16 and 1.0.11; the nightly audit job passes. One unsound-API warning (tokio, RUSTSEC-2026-0386) remains a warning with no reported patched release. |
| Expired live fixtures need the deployer key | Six seed-v2 contracts deployed on 2026-10-08 answer with liveUntilLedger: 0. The live tier reports each one as a warning with the re-seed command; refreshing them needs the funded deployer account, which CI does not hold. The phase-1 fixture governor and the seed-v2 governor remain live. |
Indexer
| Limitation | Detail |
|---|---|
| No proposal-level decode | Event normalization is persisted through the pinned WASM; proposal/action projections remain planned. |
| GraphQL surface scope | /graphql exposes health and a raw-event connection; proposal, impact, and delegate fields remain unimplemented. |
| Health surface is narrow | /health fails on open ingestion gaps, but reconciliation drift checks arrive with I5. |
| No reconciliation | SQL tallies have not been compared against on-chain reads (I5). |
| No deploy, no soak | The Docker/compose recipe and deploy.yml (I6) ship, but no instance has been deployed and no 72-hour run has happened. |
| Indexer prove is fixture-only | The indexer’s clean-clone proof (I7) replays committed fixtures and asserts the GraphQL values; it has not been run against a live indexer or a live ledger. |
| Integration tests need Postgres | npm test fails loudly without DATABASE_URL instead of skipping; CI provides a service container. |
| Testnet resets | The public testnet is reset periodically; pinned artifacts and contract ids can become unreachable. Health and gap reporting are designed to make that visible. |
Dashboard
| Limitation | Detail |
|---|---|
| Data pages not built | /proposal/[id], /delegates, /delegate/[address] and execution-impact panels need the indexer’s decoded tables. |
| No codegen | The single query is hand-written and strictly validated; graphql-codegen waits for the full schema to be served. |
/simulate decodes one ScVal | Event XDR, transaction-hash lookup, proposal call decode and risk flags are not wired into the dashboard yet. |
| No live-indexer run | No page has been exercised end to end against a deployed indexer; the local GraphQL contract now exists. |
| Accessibility checked structurally, not measured | Landmarks, skip link, labelled controls and an aria-live region are in place; no Lighthouse score has been measured and none is claimed. |
CSP uses 'unsafe-inline' | Required for Next’s hydration bootstrap today; a nonce-based policy is planned. |
This site
| Limitation | Detail |
|---|---|
| Sibling prose is summarised, not embedded | The indexer and dashboard pages are parser-repo summaries written against pinned revisions recorded in sources.lock. They are not the sibling repositories’ own documentation, and they will lag until the pin is bumped each phase. |
| External links checked nightly | The PR gate builds and link-checks offline; live URL checks (including the deployed site) run in the nightly tier and never block a merge. |
| No translation | The site is English. |
Adding a governor adapter
A new governor is one module behind the GovernorAdapter trait
(crates/core/src/adapter.rs). Nothing outside the adapter should need to know
the governor exists — that is the whole point of the trait.
The trait
Implement these in a new crates/core/src/adapters/<governor>.rs, then add
pub mod <governor>; to crates/core/src/adapters/mod.rs:
| method | responsibility |
|---|---|
name() | stable adapter id, e.g. script3-soroban-governor |
matches_event(event) | is this event ours? contract id + topic symbol + exact arity; return false on any doubt |
normalize_event(event) | decode topics/data into NormalizedEvent; Err on an unexpected shape |
decode_call(call, spec) | decode a proposal action into DecodedCall; use spec only for argument names |
Governor-specific helpers (state/tally reconstruction, action enums, contextual rules projection) live in the same file.
Rules that are not optional
- Fail closed. Unknown variant ⇒ a preserved
Unknown/Err, never a plausible guess. Missing field ⇒Err. Absent spec ⇒decoding: "unverified"with positional args, never invented names. - No mock data. The adapter is pure: it takes
RawEvent/RawCall/ScValvalues. It must not importtests/fixtures(CI enforces this). - Every shape is cited. Transcribe each decoded structure from the pinned
upstream source at the SHA in
scripts/upstream/upstream.lock.json, and add a row to adocs/adapters/<governor>.mdsource-to-behavior table. - Differential test it. Add offline tests that decode committed raw captures and assert equality with the on-chain reads. If a read you need was not captured, extend the capture tooling, re-capture, and commit — do not invent the value.
- Register the claims. Anything the README then states about the adapter
gets a
claims.jsonentry.
Sketch
#![allow(unused)]
fn main() {
// crates/core/src/adapters/acme.rs
use crate::adapter::{GovernorAdapter, RawCall, RawEvent};
use crate::error::AdapterError;
use crate::types::{DecodedCall, DecodingStatus, NormalizedEvent};
pub const ADAPTER_NAME: &str = "acme-governor";
pub struct AcmeAdapter;
impl GovernorAdapter for AcmeAdapter {
fn name(&self) -> &'static str { ADAPTER_NAME }
fn matches_event(&self, event: &RawEvent<'_>) -> bool {
// contract id + topic symbol + exact arity; false on doubt
todo!()
}
fn normalize_event(&self, event: &RawEvent<'_>) -> Result<NormalizedEvent, AdapterError> {
todo!()
}
fn decode_call(
&self,
call: &RawCall<'_>,
spec: Option<&crate::spec::ContractSpec>,
) -> Result<DecodedCall, AdapterError> {
// names only from spec; positional + Unverified otherwise
todo!()
}
}
}
Checklist before a PR
-
cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace -
OFFLINE=1 bash scripts/check-claims.sh(add claims for new README statements) -
python3 scripts/check-deployments.pyif you registered fixtures - differential tests cover every captured proposal of the governor
Contributing
The full guide is in CONTRIBUTING.md. This page summarises what a pull request must satisfy in each repository.
Rules that apply everywhere
- Land every change through a branch and a PR. One logical unit per commit, conventional commit message, and a PR body that lists evidence for each “verified” claim.
- The PR gate is offline and deterministic. Tests pass with the network disabled. Live-testnet and external-link checks run in the nightly tier and never block a merge.
- Fixtures are committed raw responses with provenance. Production code never imports them; CI enforces this in every repository.
- Cross-repo coupling is by pin. A commit SHA plus sha256, or a release asset URL plus sha256. No relative-path dependency between repositories.
- No secrets in git. gitleaks runs over the worktree and the full history.
- Claims are machine-checked. If a README states a fact,
claims.jsonhas a command that verifies it. - Never loosen a check to make it pass. Fix the cause, or report the blocker.
Per repository
| Repository | Local gate |
|---|---|
| parser | cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, OFFLINE=1 bash scripts/check-claims.sh, python3 scripts/check-deployments.py |
| indexer | npm run check-pins, npm run typecheck, npm run lint, npm run test:unit, npm run test:integration, npm run build |
| dashboard | npm run check:pins, npm run typecheck, npm run lint, npm run test:unit, npm run build, npx playwright test, bash scripts/check-claims.sh |
Documentation
A feature PR is not done until its documentation exists in that repository’s
docs/ and is pinned into this site by a parser-repo PR that bumps
docs-site/sources.lock. Generated pages are never edited by hand:
cd docs-site
python3 scripts/gen-risk-rules.py
python3 scripts/gen-cli.py
python3 scripts/gen-api.py
python3 scripts/gen-evidence.py
python3 scripts/gen-upstream.py
Then build and check:
mdbook build
python3 scripts/check-links.py
python3 scripts/check-docs-claims.py
Code of conduct
Participation is covered by the Code of Conduct. Security reports go through SECURITY.md, never a public issue.
Roadmap
Ordered by dependency, not by desire: each step unblocks the next.
Done
| Step | State |
|---|---|
ScVal → JSON, contractspecv0, risk rules, GovernorAdapter, power/tally replicas | implemented, differentially tested |
| Script3 adapter | implemented, tested against committed on-chain reads |
| v1 schema frozen, additive-only gate | implemented |
WASM package v0.1.0-alpha.2 with a reproducible sha256 | released as a pre-release |
Dry-run model and sgp simulate command | implemented, offline-tested; accepts validated transaction envelopes |
| Indexer ingestion (I0–I2) | implemented, integration-tested |
Dashboard scaffold: /, /simulate, /status | merged, CI green |
| This documentation site | built and deployed from the parser repo |
Next
| Step | Depends on | Status |
|---|---|---|
| Dependency refresh (TLS advisories) | — | done: rustls 0.23.46, rustls-webpki 0.103.16, webpki-root-certs 1.0.11 |
Parser WASM v0.1.0-alpha.2: adapter event surface + simulation request/response | dependency refresh | implemented |
| Indexer I3: decode through the pinned adapter | alpha.2 asset | planned |
| Indexer I4: GraphQL health and raw-event connection | I2 | implemented; decoded entities remain planned |
| Indexer I5: reconciliation | I4 | planned |
| Indexer I6–I7: deploy recipe, clean-clone proof | I4 | shipped; no deployed instance yet |
| Dashboard data pages, codegen, live-indexer e2e | I4 | planned |
| OpenZeppelin adapter | fixtures already committed | planned, bounded effort |
| 72-hour soak on a host | deploy recipe + a host | blocked on infrastructure |
Not scheduled
- Vote casting or any write path: the suite is read-only.
- A composite trust score for delegates: metrics stay raw counts plus formulas.
- Mainnet deployments: fixtures and evidence are testnet.
Dates are deliberately absent. Each row moves when its dependency lands and its evidence exists, and this page is updated in the same PR.
Changelog
Release history for the parser, which carries the suite’s tagged releases. Indexer and dashboard changes are tracked in their own repositories and summarised on their pages here.
v0.1.0-alpha.1 — 2026-10-08
Pre-release: parser WASM package.
- Deterministic
wasm-packbuild for thenodejsandwebtargets, packaged into one.tgz. - Committed release lock with the asset sha256
7b2eb9420f0cf0582d079f9603beb1e8548e1c7bac165724688cbae5b9237ecd(245,210 bytes), which is what consumers pin. - Node test-runner and headless-Chromium tests drive the built artifact against committed captures.
- CI gains the
wasm-packagejob. - Exports:
decode_scval_base64,parse_contract_spec,classify_risk,script3_decode_proposal,script3_state_from_chain,script3_tally_from_chain,evaluate_risk_context,evaluate_tally.
Release notes: https://github.com/Stellar-Governance-Guardians/soroban-governance-parser/releases/tag/v0.1.0-alpha.1
Schema v1 — 2026-10-08
schemas/governance-v1.graphqlfrozen with an additive-only gate: the PR gate rejects any removal or incompatible change relative togovernance-v1.baseline.graphql.- sha256
cfbd85a82ee834ac14723cd5dd73ce18a5aba0313a91a0aaf6053d1a486ae275, pinned by the indexer and the dashboard.
Earlier milestones
| Date | Change |
|---|---|
| 2026-10-08 | Script3 adapter and snapshot power; sans-IO dry-run model from real captures; hardening (fuzz targets, audit, adapter guide); seed-v2 verification that works from a clean clone |
| 2026-10-06 | Step 0 governance files and charter CI across the three repositories |
| 2026-10-05 | Phase 1 testnet proof: deployments registry and committed fixtures |
Unreleased
alpha.3 line — export-surface break (no release yet)
-
Breaking change on the alpha line: the
governor_identify,governor_matches_event,governor_normalize_eventandgovernor_decode_callexports are removed. They read justtopic[0], ignored the topic count, dropped the contract id on call decode, and two of the four carried no#[wasm_bindgen]attribute. Useidentify_governor,matches_event,normalize_eventanddecode_call, which take the complete topic vector and preserve the contract id. -
Exactly one
matches_eventcontract remains: full topic vector plus value,falsefor a foreign contract or wrong topic shape, an explicit error for malformed XDR. -
The Node and headless-browser suites now cover
identify_governor,matches_event(match, foreign contract, topic-count mismatch, wrong topic shape, malformed XDR) andnormalize_event(verified decode plus a matched event with an unexpected payload shape), anddecode_callwith and without a contract spec. -
No asset has been published for this line yet:
releases/parser-wasm.lock.jsonstill pinsv0.1.0-alpha.2. -
v0.1.0-alpha.2: adapter event surface (matches_event/identify,normalize_eventwith theunverifiedfallback,decode_call) and the simulation request/response surface. -
Dependency refresh for the open
cargo auditadvisories. -
This documentation site.
Upstream pins
Generated from docs-site/sources.lock by docs-site/scripts/gen-upstream.py. Do not edit by hand.
The indexer and dashboard pages on this site are summaries written in this repository, not embedded copies of those repositories’ own documentation. Each summary names the exact revision it was written against below, and the bytes of that revision are vendored under docs-site/pinned/ and hashed by the PR gate. The nightly docs-live workflow re-fetches the pinned file and fails if the content no longer matches — so a silent change upstream cannot leave this site claiming something stale.
| Repository | Path | Commit | sha256 |
|---|---|---|---|
| https://github.com/Stellar-Governance-Guardians/governance-event-indexer | README.md | 68aa657c68cb1cc1fca1f87da5f55ec74e7272bf | b78f22a64605059e2db939671280835a4ee072947a0d7c1cbe7200da44ea49b3 |
| https://github.com/Stellar-Governance-Guardians/delegate-portal-dashboard | README.md | 0c622709848637eb5908d9d0432475ce1cc92fe6 | 9406bedbc6fcdddbde6278e6bb0ee166390035241fd3d6810660ca899b73bb6c |
Bumping a pin is a parser-repo PR that shows the vendored byte diff and the review on this site that the summary still matches. This happens at the end of every phase.
What is not pinned
Nothing from a sibling repository is rendered as prose on this site today. If a later phase embeds sibling documentation directly, it must enter through this lock file first.