Protocol · v1

The rules

Everything the server checks and everything the vault enforces, written so you can replay it. Numbers here are the live parameters; the ledger shows the same values under this round.

Overview

SLUICEBOX is a mining game on Robinhood Chain. The founder forwards the Pons creator fees from $SLUICE to a public contract, the SluiceVault, by plain ETH transfer (every transfer is a public tx on the vault address); anyone can call harvest() to split what arrived 80/20 into the pool. Every hour the settler (a server key with narrow powers) takes a budget from the pool and buries it as nuggets inside four voxel claims. Where the nuggets are is fixed by a secret seed whose hash is written on-chain before the round starts and revealed after it ends.

You paste a Robinhood Chain address, stake a claim, and your browser searches for SHA-256 proofs. One accepted proof digs one voxel; which voxel is taken from the proof's own hash, so nobody can aim. Dig a voxel that holds a nugget and the vault pays that nugget's ETH to your address. The rest of the budget ("dust") is split by credited work at the end of the round.

No login, no signature, no wallet connection, no token needed. Holding $SLUICE changes nothing about mining.

Round
3,600 s, aligned to the UTC hour. Round id r = floor(unix_seconds / 3600).
Claims
4 per round: North Bar (0), Bench (1), Bedrock (2), Tailrace (3). Each 24 × 24 × 12 = 6,912 voxels.
Budget split
75% nuggets, 25% dust. Budget = min(poolFree, maxRoundBudget), max 2 ETH at start.
Fee in
Founder → vault by public ETH transfer. harvest() (anyone) splits whatever arrived: 80% pool, 20% infra. The pool share can only be raised, never lowered.
Difficulty
20–48 leading zero bits, per claim, retargeted every 30 s.
Payout threshold
Nuggets pay instantly. Dust pays when your balance reaches 0.0005 ETH; below that it carries over.

Challenge

Before hashing you ask the server for a challenge. It is a short-lived ticket bound to your address and your claim.

POST /api/challenge
{ "wallet": "0x…40 hex", "claim": 0..3 }

→ { "round": 497520, "challenge_id": "0x…64 hex", "wallet": "0x…", "claim": 2,
    "difficulty": 24, "issued_at": 1792000000000, "expires_at": 1792000030000, "max_proofs": 8 }
  • TTL 30 s. After expires_at a proof for this challenge is rejected with expired. Ask for a new one before it runs out; the miner keeps up to 8 tickets so a late proof still counts.
  • 8 proofs per challenge. The ninth is rejected with challenge_full; ask again.
  • Difficulty is the claim's current difficulty at issue time and is frozen for that ticket.
  • Errors: paused (server paused, retry in 30 s), no_open_round (between rounds, retry in 1 s), rate_limited (HTTP 429 with Retry-After), bad_wallet, bad_claim.

Message & validity

The hashed message is 72 bytes: a 64-byte prefix (exactly one SHA-256 block, so a GPU can precompute the midstate) followed by an 8-byte nonce.

challenge_id32 bytes wallet20 bytes claim1 byte (0–3) zeros11 bytes nonce8 bytes, little-endian
prefix  = challenge_id ‖ wallet ‖ claim ‖ 0x00 × 11        (64 bytes)
message = prefix ‖ nonce_le64                                (72 bytes)
hash    = SHA-256(message)
valid   ⇔ leading_zero_bits(hash) ≥ difficulty

The wallet is inside the hash, so a proof found for one address is worthless for any other (another_wallets_proof). The claim is inside the hash, so a proof cannot be moved to a different claim (wrong_claim). challenge_id is 32 random bytes from the server, so work cannot start before the ticket exists.

Expected hashes per proof at difficulty d is 2^d: about 16.8 million at 24 bits, 1.07 billion at 30 bits.

Proof

POST /api/proof
{ "round": 497520, "challenge_id": "0x…", "wallet": "0x…", "claim": 2,
  "nonce": "000000000a1b2c3d",            // the 64-bit nonce as 16 hex chars (number order, zero-padded)
  "hash":  "0000003f…64 hex" }            // SHA-256(message), lowercase hex

→ { "accepted": true, "credit": "16777216",
    "dig": { "claim": 2, "voxel": 3811, "x": 15, "y": 6, "z": 14 },
    "nugget": null | { "wei": "12100000000000000", "index": 3811 } }
→ { "accepted": false, "reason": "below_target" }

The server rebuilds the 72-byte message from the ticket it issued and recomputes SHA-256 itself; your hash field must equal it (hash_mismatch). The nonce is a 64-bit unsigned integer: it is written in the JSON as its 16-character hex value, and placed in the message as 8 little-endian bytes.

Dig placement

Which voxel your proof digs is read from the proof's own hash, after acceptance, so neither you nor the server can aim:

voxel = uint64(hash[8:16], big-endian) mod remaining(claim)

remaining(claim) is the number of voxels still standing in that claim at the moment of acceptance, and voxel is an index into the claim's ordered list of still-standing voxels. The order is the natural order index = y × 576 + z × 24 + x (y = depth 0–11, z and x = 0–23) with dug voxels removed. Every accepted proof is logged in order, so the whole hour is replayable from the log.

Bytes 0–7 of the hash are mostly zeros (that is the difficulty), which is why placement uses bytes 8–15.

Credit

Every accepted proof credits 2^difficulty hash-equivalents to your address for the round, regardless of what it dug. Your dust share at the end of the round is:

dust_share = your_credit ÷ round_credit × dust_budget        (dust_budget = 25% of the round budget)

Rejected proofs credit nothing. Credit is per round; it does not carry over, but unpaid dust balance does (see payouts).

Difficulty retarget

Each claim has its own difficulty, re-evaluated every 30 s and clamped to 20–48 bits. The goal is that a claim is roughly dug out by the end of the hour, no matter how many miners are on it:

targetProofsPerSecond = max( remainingVoxels / max(remainingSeconds, 60), 0.2 )
nextDifficulty        = clamp( round( log2( observedHashrate / targetProofsPerSecond ) ), 20, 48 )

observedHashrate is the server's estimate from accepted proofs on that claim over the last window (Σ 2^difficulty ÷ seconds). More miners on a claim → higher hashrate → harder → each miner digs fewer voxels. That is the crowding mechanic: a busy claim is dug out fast but the nuggets are shared among many; a quiet claim is slow but yours. New challenges carry the new difficulty; tickets already issued keep theirs. Dry and preview rounds use the same rule.

Rounds & claims

At every hour boundary the settler runs this sequence:

  1. Settle the previous round: compute dust shares, queue dust payouts, send revealRound(r−1, seed).
  2. Harvest: split any ETH that was forwarded to the vault since the last harvest (anyone may also call harvest() at any time).
  3. Budget = min(poolFree(), maxRoundBudget). If it is 0 the round is dry.
  4. Draw a seed: 32 random bytes. Derive the nuggets (below).
  5. Commit: commitRound(r, commitment, budget). The budget moves from the pool into the round. The vault refuses a budget above poolFree() and refuses round ids that do not increase.

24 hours after a reveal, anyone may call closeRound(r): whatever of the budget was not paid returns to the pool. If a round is never revealed (the settler lost the seed), anyone may close it 7 days after its commit; the whole budget returns to the pool. Payouts for a round are possible from its commit until its close (so nuggets pay during the hour and dust settles after the reveal).

Claim geometry
24 (x) × 24 (z) × 12 (y, depth). index = y × 576 + z × 24 + x, 0 ≤ index < 6,912.
Nuggets per claim
K = 3 + (r mod 3), so 3, 4 or 5.
Nugget budget
floor(budget × 7500 / 10000), split equally: each claim gets floor(nuggetBudget / 4).
Minimum nugget
0.0005 ETH. If the split would make a nugget smaller, that claim gets fewer paid nuggets (the positions still exist as pyrite).
Exhausted claim
When all 6,912 voxels are dug the claim is "worked out": proofs for it are rejected with claim_exhausted. Re-stake another claim.

Nugget derivation

Everything about the nuggets of round r follows from the 32-byte seed. All hashes are keccak-256 over abi.encodePacked bytes; uint32(h) means the low 32 bits of the hash read as a number (the last 4 bytes, big-endian; the same as Solidity's uint32(uint256(h))).

Positions

j = 0, positions = []
while positions.length < K:
    pos = uint32( keccak256( seed ‖ uint64(r) ‖ uint8(claim) ‖ uint32(j) ) ) mod 6912
    if pos not in positions: positions.push(pos)
    j += 1

Nugget i of a claim is positions[i] (i = 0 is the largest). Duplicates are skipped by moving to the next j; i only counts accepted positions.

Sizes

B = claim nugget budget (wei)
u_i = uint32( keccak256( seed ‖ "size" ‖ uint64(r) ‖ uint8(claim) ‖ uint32(i) ) )
j_i = 8000 + floor( u_i × 4000 / 2^32 )              // jitter in [8000, 12000)
W_i = j_i × 2^(K−1−i)                                  // geometric, halving each step
amount_i = floor( B × W_i / Σ W )                      // integer wei
amount_0 += B − Σ amount_i                             // rounding remainder to the largest
if amount_(K−1) < 0.0005 ETH: redo with K−1 (down to 0)  // extra positions are pyrite (0 wei)

With this split the largest nugget is always at least 40% of the claim's nugget budget, and the amounts add up to exactly B. The "size" tag is the 4 ASCII bytes 73 69 7a 65.

The verify box on the ledger runs exactly this code in your browser against any revealed round.

Commitment

commitment = keccak256( abi.encodePacked( uint64 round, bytes32 seed ) )     // 8 + 32 = 40 bytes

Written on-chain by commitRound before the round opens; checked on-chain by revealRound, which refuses any seed that does not hash to the commitment and can only be called once per round. Because the positions are a pure function of (seed, round), once the commitment is on-chain the server cannot move, add or remove a nugget. After the reveal anyone can recompute every position and every size and compare them with the payouts that were made.

What the commitment does not prove: that the seed was drawn fairly. A dishonest settler could grind seeds before committing. The round's budget and the number of nuggets are fixed by the rules, so grinding can only change where nuggets sit, and digs are placed by your proof hashes, which the settler does not control. See risks.

Payouts

Nuggets
Queued the moment the voxel is dug. The settler sends payout(round, [to…], [wei…], kind = 0) in batches every 60 s. You see the tx hash in the feed and on the ledger.
Dust
Computed at round end from credit, added to your balance. When the balance reaches 0.0005 ETH it is sent with payout(kind = 1); below that it carries over to the next round.
Cap on-chain
For every round, Σ paid ≤ round.budget is enforced by the vault. The settler cannot pay more than what was committed.
Failed push
If your address rejects ETH (a contract without receive()), the amount is recorded as owed[you]; call pull() on the vault to collect.
Recipients per tx
At most 200 per payout call.

Reject reasons

A rejected proof returns { "accepted": false, "reason": "…" } with one of these exact strings:

duplicateThis (challenge_id, wallet, nonce) was already accepted. Each nonce counts once. expiredThe challenge's 30 s TTL passed before the proof arrived. Ask for a new challenge. challenge_full8 proofs were already accepted for this challenge. Ask for a new one. unknown_challengeThe server never issued this challenge_id (or restarted and forgot it). Ask again. round_closedThe round in the proof has ended and is settling or settled. Only the open round accepts proofs. wrong_roundThe round field does not match the round the challenge belongs to. below_targetThe hash has fewer leading zero bits than the challenge's difficulty. hash_mismatchThe server's own SHA-256 of the rebuilt message differs from your hash. Nonce, address or claim does not match the ticket. another_wallets_proofThe wallet field differs from the one the challenge was issued to. Proofs cannot be transferred. wrong_claimThe claim field differs from the one in the challenge. malformedMissing field, bad hex, nonce not 16 hex chars, hash not 64 hex chars, or an address that is not 20 bytes. settlingThe hour boundary is being processed. Retry in a second. claim_exhaustedEvery voxel of this claim is already dug. Stake another claim.

When the pool is empty: dry rounds

If poolFree() is 0 at the hour boundary, or before the vault exists at all (preview mode), the round is dry: budget 0, nothing is committed on-chain, the nugget positions are still derived from a seed so the claims look and dig the same, but every nugget is pyrite (0 ETH) and dust is 0. Work in a dry round counts for the season leaderboard only. The ledger and the game mark dry rounds everywhere.

The pool refills only from harvest(), which splits the creator fees the founder has forwarded to the vault, fees that $SLUICE trading on Pons has actually generated. There is no manual pace and no top-up promise: the vault pays only what Pons fees have filled. If the founder stops forwarding, rounds go dry; nothing else changes.

Anti-abuse

  • Dedup on (challenge_id, wallet, nonce); the wallet is in the hash input, so share theft is impossible.
  • Challenge rate limits per IP and per wallet; HTTP 429 with Retry-After. The miner backs off up to 30 s.
  • Wallets must be valid 20-byte addresses and may not be the vault, the zero address or a precompile.
  • Proofs are verified serially per challenge; the settler only ever pays what the vault's per-round cap allows.

Your CPU

Mining uses your CPU. You choose the share (10–100%); the miner runs max(1, round(cores × share)) web workers, each hashing 65,536 nonces per batch with a hand-written SHA-256. It stops the moment the tab closes or you press stop; nothing runs in the background. An optional WebGPU miner can use your GPU instead; every GPU hit is re-verified in JavaScript before it is sent. Expect fan noise and battery drain at high shares.

Not financial advice. Unaudited contracts. Nugget positions are committed on-chain before each round and revealed after it. Mining uses your CPU (you pick the %), stops when the tab closes; the vault pays only what Pons fees have actually filled; dry rounds pay nothing.