Soroban contracts for GrainHack. The contract-side protocol is documented in
docs/MERKLE_LEAF_FORMAT.md; that document is
self-contained and uses contracts/grainhack-escrow/src/lib.rs as its source
of truth.
One event, one chain. Holds the prize pools, publishes commitments, and honours pull-based Merkle claims.
Two rules govern the design:
The chain never decides anything. Judging, assignment, appeals and payout arithmetic stay off-chain and authoritative. This contract cannot compute a bucket, a unit value or a payout — only honour a root the backend published. There is deliberately no business logic here that could disagree with the backend.
Claims are pull-based. Contributors claim against a published root and pay their own gas. Nothing is pushed to a list of addresses: a failed push mid-loop leaves an event half-paid with no clean recovery.
| Guarantee | How |
|---|---|
| Maintainer funds cannot pay contributors, or vice versa | Two balances under separate storage keys, and the pool is inside the leaf hash |
| A root cannot promise more than the escrow holds | Checked at publish_root against that pool's balance |
| A draw-seed commit cannot be changed after the fact | Commitments are write-once; a second write errors |
| A published root cannot be swapped | Roots are write-once per pool |
| A proof cannot be replayed by another address | The claiming address is inside the leaf hash |
| An internal node cannot be claimed as a leaf | Domain separation: 0x00 for leaves, 0x01 for nodes |
| Funds cannot be withdrawn at will | The only non-claim exit is sweep: admin-authorised, time-locked, to a destination fixed at initialisation |
| A settled event cannot be cancelled | cancel is unreachable from Settled |
leaf = SHA256(
0x00 || pool_byte || address_length_be16 || claimant_address_utf8
|| identity_hash_32 || amount_be32
)
The fields are concatenated in exactly this order before SHA-256:
| Field | Encoding | Width |
|---|---|---|
| Domain prefix | 0x00 |
1 byte |
| Pool | Contributor = 0x00, Maintainer = 0x01 |
1 byte |
| Address length | Unsigned big-endian byte length | 2 bytes |
| Claimant | Canonical Soroban Address::to_string() bytes |
length above |
| Identity hash | The 32 raw bytes of identity_hash |
32 bytes |
| Amount | Positive i128, zero-extended and right-aligned |
32 bytes |
The address length covers only the address bytes, not the two-byte prefix. The
amount is fixed-width big-endian, so a client must reject non-positive amounts
before encoding. identity_hash is SHA256(github_login_lower || per_event_salt), computed off-chain. The salt is never published —
releasing it would let anyone with a list of GitHub logins match leaves to
addresses and reconstruct the github_login → wallet mapping the design
exists to prevent.
The backend must build identical leaves and use the same sorted-pair tree rules.
The contract exposes leaf so an integration can compare its implementation
against the contract-side construction. Internal nodes use
SHA256(0x01 || lower_digest || higher_digest) with the two 32-byte digests in
lexicographic order. See the complete leaf and tree format guide.
cargo test
cargo build --target wasm32-unknown-unknown --release
stellar contract buildThe repository pins Rust and the wasm target in rust-toolchain.toml. The
same commands, plus formatting and clippy with warnings denied, run in
.github/workflows/ci.yml. stellar contract build requires the Stellar CLI.
The release wasm embeds its name, Cargo version, contract description,
canonical repository, and package name using Soroban's contractmeta! macro.
Inspect the compiled artifact—not only the source—with:
scripts/inspect-contract-metadata.shThe helper prints the verified metadata, wasm size, and SHA-256 digest. It can
also inspect an existing artifact with --wasm or emit a CI-friendly report
with --json. See the metadata release policy
for the release checklist and size-reporting guidance.
Install just, then run just at the
repository root to list the verified commands. The common commands are
just build, just test, just wasm-release, just stellar-build,
just format, just fmt-check, just lint, and just check. just ci is an
alias for the complete local verification set. The runner only wraps the real
Cargo and Stellar CLI commands; see the task-runner guide
for prerequisites and troubleshooting.
The repository includes a repeatable, testnet-only deployment command. It
builds the contract, uploads the wasm, creates an instance, calls
initialise, and writes the resulting IDs and configuration to a local JSON
record. The four initialise values are deliberately supplied by the caller:
scripts/deploy-testnet.sh \
--network testnet \
--source-account grainlify-deployer \
--admin G... \
--token C... \
--sweep-dest G... \
--sweep-delay 604800The source identity must already be configured and funded on testnet. The
script refuses an unset or non-testnet network, validates the account before
submitting transactions, and refuses to reuse an existing deployment record
unless --replace is supplied. It never prints or writes a secret key.
The generated record is ignored by Git because testnet addresses are
environment-specific. Testnet is periodically reset, so a recorded contract
ID is a deployment receipt rather than a permanent address. Use --help for
equivalent environment-variable names and output-path options.
The test suite records CPU instructions and memory bytes for every public
entry point. claim is measured at proof depths 1, 4, and 8 so the cost
growth is visible. The checked-in baseline also records the optimized wasm
size. Measurements may be up to 10% above baseline to allow for normal test
host noise while still catching meaningful regressions.
Run the full guard, including the wasm-size check, with:
scripts/check-resource-regressions.shIf a deliberate change requires a new baseline, run
scripts/update-resource-baseline.sh, review the measured JSON diff, and
commit it separately from unrelated changes. The guard reports the entry
point, metric, baseline, observed value, tolerance, and regeneration command
when it fails. The current measurements are well below Soroban's documented
per-transaction CPU and memory budgets. At baseline, the depth-8 claim uses
1,971,762 CPU instructions against the current 400,000,000-instruction testnet
limit and 187,459 memory bytes against the 40 MiB limit (more than 99.5%
headroom in both dimensions). Network validators can change these limits, so
re-check them when reviewing a deployment or a large proof-path change.
Deployment, funding, key handling and mainnet operations are human tasks and are deliberately absent from this repo. Per the spec: signing keys, treasury custody and mainnet deploys are human operations.
This contract has not been audited. §8 of the spec makes an external audit non-negotiable before it holds real money, and that is the reason for shipping one chain first — one audit, not four.
See docs/SECURITY-MODEL.md for the current trust
assumptions, enforced invariants, out-of-scope items, and open questions for
maintainer review.