Step-by-step guide to staking $GITLAWB, registering your node on-chain, and earning protocol fees as a PoS operator.
- A wallet with at least 10,000 $GITLAWB (minimum stake) plus a small amount of ETH on Base for gas
- Docker or Rust 1.91+ (for running the node process)
- A public HTTP URL (your-host.com) — can be a VPS, Fly.io app, or anything reachable. A Fly.io config is provided at
infra/fly/fly.toml(deploy from the repo root withfly deploy -c infra/fly/fly.toml)
curl -fsSL https://gitlawb.com/install.sh | sh
# or build from source:
cargo install --path crates/glEvery node is identified by an Ed25519 DID keypair:
gl identity new
gl identity show
# → did:key:z6Mk...This is your node DID — it's distinct from your Ethereum wallet.
This stakes $GITLAWB and links your node DID to your operator wallet:
export GITLAWB_OPERATOR_PRIVATE_KEY=0xYOUR_KEY
export GITLAWB_TOKEN=0x5F980Dcfc4c0fa3911554cf5ab288ed0eb13DBa3
export GITLAWB_CONTRACT_NODE_STAKING=0xNODE_STAKING_ADDR
export GITLAWB_CHAIN_RPC_URL=https://mainnet.base.org
gl node register \
--stake 10000 \
--http-url https://my-node.example.comWhat this does:
- Checks your $GITLAWB balance
- Calls
token.approve(NodeStaking, 10000e18)if needed - Calls
NodeStaking.registerNode(didHash, httpUrl, 10000e18) - Transfers 10,000 $GITLAWB into escrow
docker run -d \
--name gitlawb-node \
-p 7545:7545 \
-p 7546:7546/udp \
-v gitlawb-data:/data \
-e DATABASE_URL=postgresql://user:pass@host/gitlawb \
-e GITLAWB_PUBLIC_URL=https://my-node.example.com \
-e GITLAWB_OPERATOR_PRIVATE_KEY=$GITLAWB_OPERATOR_PRIVATE_KEY \
-e GITLAWB_CONTRACT_NODE_STAKING=$GITLAWB_CONTRACT_NODE_STAKING \
-e GITLAWB_CHAIN_RPC_URL=$GITLAWB_CHAIN_RPC_URL \
-e GITLAWB_OPERATOR_STRICT_MODE=true \
ghcr.io/gitlawb/node:latestSee docker-compose.yml — bundles node + Postgres.
cargo run -p gitlawb-node --releaseRequired env for on-chain PoS mode:
GITLAWB_CONTRACT_NODE_STAKING— contract addressGITLAWB_OPERATOR_PRIVATE_KEY— key that posts heartbeatsGITLAWB_CHAIN_RPC_URL— Base RPC URL (default Sepolia)
Optional:
GITLAWB_OPERATOR_STRICT_MODE=true— refuse to start if not registered or not currently activeGITLAWB_HEARTBEAT_INTERVAL_HOURS=20— how often to post heartbeats (must be < 24)
gl node onchain-statusExpected output:
On-chain status for did:key:z6Mk...
Operator wallet: 0x...
Staked: 10000 $GITLAWB
HTTP URL: https://my-node.example.com
Last heartbeat: 1760000000 (unix)
Active flag: true
Currently active: true
Pending rewards: 0 $GITLAWB
Check your node logs — you should see operator heartbeat loop starting and a heartbeat tx within a few seconds of startup.
The node's heartbeat loop runs every 20 hours. As long as you stay active:
- Every Sunday, the
FeeDistributordistributes accumulated fees. - 75% of the weekly pot is split across all active nodes (pro-rata by stake).
- Your share accrues as
pendingRewardson-chain — claim anytime without unstaking.
gl node claim7-day cooldown, two steps:
# Step 1: request unstake (starts 7-day timer)
gl node unstake-request
# ... wait 7 days ...
# Step 2: complete — returns stake + any pending rewards
gl node unstakeDuring the cooldown your node still earns rewards if it keeps heartbeating.
The node requires the authenticated pusher to be the repo owner on every
branch. A push whose authenticated DID is not the repo owner is rejected with
HTTP 403 before any ref update is applied. The owner is matched in both the full
did:key:z6Mk… form and its bare z6Mk… suffix.
This is on by default, and the default is the point. The node authenticates every
git-receive-pack push with a valid RFC 9421 did:key signature, but did:key
is self-certifying: any party can generate a key, derive its DID and sign.
Authentication is not authorization, so without this gate every signed caller can
push to every repository — private ones included — on any branch that is not
explicitly protected.
GITLAWB_ENFORCE_OWNER_PUSH=falseBoth the environment variable and --enforce-owner-push false disable it; the
bare --enforce-owner-push flag still means true.
Do this only for a rolling upgrade whose pushers are not yet the repo owner, and treat it as temporary: while it is off, your node accepts a push to any repository from anyone who can generate a keypair.
Push authorization is owner-only today. A UCAN git/push capability is verified
but not yet honored for authorization, so a delegated key cannot push while
this gate is on, even when it holds a valid capability for the repo.
If your automation pushes under its own DID rather than the owner's, it will start
getting 403s. Either have it push as the owner, or set
GITLAWB_ENFORCE_OWNER_PUSH=false until scoped collaborator / UCAN-delegated push
rights land — that work is what removes this trade-off.
| Concern | Recommendation |
|---|---|
| Heartbeat gas | ~$0.03/month on Base L2 — negligible |
| Missed heartbeats | After 3 days without one, you're excluded from rewards until you beat again |
| Operator key | Dedicated wallet, small ETH balance, not your main treasury |
| Monitoring | Watch lastHeartbeat on-chain; alert if > 22h since last beat |
| Public URL | Must resolve and serve /health for liveness and DB-aware /ready for peer readiness |
| Rolling upgrade (advisory-lock algorithm) | The SHA-256 advisory-lock key swap (replacing DefaultHasher) re-keys every repo in the first release that carries it. During a shared-Postgres rolling upgrade, old nodes compute a different i64 key than new nodes for the same repo, so PostgreSQL treats them as independent locks and cross-machine write-exclusion is lost. Drain in-flight writes or cut over through a single node before bringing new-binary nodes online. |
"insufficient $GITLAWB balance" — fund the operator wallet with at least 10,000 $GITLAWB.
Node refuses to start with "strict-mode operator check failed" — either gl node register first, or unset GITLAWB_OPERATOR_STRICT_MODE.
Node refuses to start with "GITLAWB_DB_MAX_CONNECTIONS (20) must be at least max_concurrent_git_pushes (32) + 8 headroom": raise GITLAWB_DB_MAX_CONNECTIONS to at least the push cap plus 8 (40 with the default cap of 32; 48 is the shipped default and the recommended value), or lower GITLAWB_MAX_CONCURRENT_GIT_PUSHES. This bites a node upgraded in place that still sets the old pool size of 20. The check is deliberate: each concurrent push pins one pooled connection for its whole receive-pack, so a pool that does not clear the push cap lets a burst of slow pushes starve every other database path.
Rewards are 0 after a week — run gl node onchain-status. If currentlyActive: false, check your heartbeat loop (node logs for operator heartbeat sent).
Want to rotate operator wallet — requires unstake → re-register with new wallet. No in-place rotation in v1.
See ECONOMICS.md for the full reward math.