Rain is a pot that pays out on a schedule, on Algorand. You fill it once, say how much should leave it each interval and who is allowed in, and it keeps dripping without anybody tending it. By CorvidLabs, built with Algorand Python (Puya) and AlgoKit.
This is Discord rain, on chain. People show up, and a drop goes to the people
who showed up. It is not a jackpot of the whole pot: drip is what falls, the
pot survives the payout, and the same rain can fall every day for as long as
somebody keeps filling it.
One app is a hub holding many rains. Each rain is a box, and opening one is permissionless: any NFT collection as the gate, ALGO or any ASA as the prize, any drip, any interval, and one of three ways it falls. CorvidLabs uses the same hub as everybody else, with no more authority on it than a stranger.
Warning
Unaudited, TestNet only, and immutable. No third party has reviewed this
contract. The hub declares no update and no delete handler — every method is
NoOp-only, which you can check for yourself in
smart_contracts/artifacts/rain/Rain.arc56.json — so a deployed hub cannot
be patched by anyone, including us. That is a real guarantee and a real
hazard in the same sentence: nobody can change the rules under your pot, and
nobody can fix a bug in it either. The only remedy for a bad hub is a new hub
that everyone re-enters by hand.
Put nothing on it you would mind losing.
| TestNet hub | 770746178 |
| Page | https://corvidlabs.xyz/rain/ |
| Woken by | a keeper network, hourly — see Who calls it. Concretely, Arcron upkeep 113 on keeper app 769891898 |
| Deployed from | commit 81aaefb, 2026-08-30 22:35 −0600. The app was created two minutes earlier, at round 66,837,443. smart_contracts/rain/contract.py has not changed since, which is why verify_build still matches from HEAD — but HEAD is not the deploy commit, and verify_build prints HEAD |
| Approval program | 4,971 bytes, combined sha256 ecebeca3f32cb0ebc913ff40b04650453618d1214079027fecb4c78f361d6db3 |
Because the hub can never be updated, "the code you can read is the code that is running" is the only trust story it has — and that claim is worth nothing unless somebody who does not trust us can check it. So check it:
poetry run python -m scripts.verify_build --network testnet --contract rain --app-id 770746178It rebuilds from this working tree, fetches what algod reports for that app id,
and compares the compiled bytecode — not the source text, which comments and
formatting do not survive. On 2026-08-31 it printed
✔ The deployed app is this source, byte for byte. Run it rather than
believe this README. Read the answer narrowly: it proves the deployed
programs are the ones this tree compiles to, and nothing about who created the
app or whether this tree is the tag you think it is.
Read from the chain at round 66,860,306 on 2026-08-31, the hub holds two
rains — a daily SPLIT one for everyone in, and a GM (WAVE) one for whoever
checks in — both gated to the Corvid TestNet collection, and zero tickets
between them. Only one of the two is even funded: the daily rain's pot is
100,000 µALGO against a 50,000 drip, and the GM rain's pot is 0, so
_fire_wave would return on pot < paid even with a full wave. The app
account holds 391,400 µALGO against a 291,400 minimum for its floor and two
rain boxes, and the 100,000 µALGO of that which is free is exactly that one
pot: nothing else has ever been deposited. No drop has been credited, no
claim has run, no ONE draw has been resolved.
Upkeep 113's counter said 16 executions at that round, and it rises every hour, so treat any number here as a floor. Every one of those calls correctly did nothing: a rain with no tickets is a rain with nobody to pay.
The upkeep escrow is nearly out. At that round upkeep 113 held 16,000 µALGO against a 4,000 µALGO fee — four more hourly firings, about four hours. When it empties, no keeper is paid, nothing fires the hub, and any rain on it goes quiet until somebody tops the upkeep up. If you open a rain here, check that first.
That is the honest state: the contract is deployed, verified and scheduled, and nobody has used it. Everything below about how a drop behaves in practice rests on LocalNet demos and on the previous hub, not on this one.
Picked once, when the rain is created, and never changed afterwards. The page calls them by what they do; the contract calls them by number.
| Mode | On the page | You do this | Each drop goes to |
|---|---|---|---|
| SPLIT (0) | Everyone | take a ticket once | everybody holding a ticket, in equal shares |
| ONE (1) | One person | take a ticket once | one ticket, picked by a block seed nobody chose |
| WAVE (2) | Who shows up | take a ticket, then check in each interval | the first wave_cap people who checked in |
SPLIT is fill-and-forget. Put a billion of a token in the pot, drip ten thousand a day, and stop thinking about it — the cost of a drop does not grow with the number of holders, because it moves one accumulator rather than writing to every ticket. Enter late and you collect the drops that fall after you, and none of the ones before.
WAVE is the Discord shape: being in the room during the interval is what earns
the drop. You still take a ticket once; gm is the check-in on top of it, and
only the first wave_cap people each interval are in. Shares nobody came back
for return to the pot at the next drop.
ONE is a draw, and it is the only mode that takes two calls instead of one. See The randomness is not free.
Whatever the mode, a drop is credited, not sent. You pull it with claim
whenever you like, which is why nobody has to be online at the moment a rain
falls.
A smart contract cannot wake itself. There is no on-chain timer on Algorand, or anywhere else; every "this happened at midnight" is a transaction somebody sent. So a rain needs somebody to poke it.
That somebody is Arcron, a
permissionless keeper network. One upkeep is registered against this hub's
draw() method with a cadence and a fee escrowed in ALGO, and after that any
account at all may execute it and collect the fee. Keepers compete for the
money; nobody is in charge; the hub does not know or care which account called
it.
A person using a rain never touches any of this. They enter, and later they claim. The schedule is somebody else's job, and it is paid for out of the upkeep's escrow rather than out of the pot. If you open a rain on the live hub, upkeep 113 already fires it — but check that it still has escrow, because when the escrow runs out no keeper is paid and nothing fires the hub at all. It was down to about four hours of runway on 2026-08-31. If you deploy a hub of your own, you register exactly one upkeep, once, and keeping it funded is yours.
A ONE rain needs a second call that no keeper makes; see The randomness is not free.
The dependency runs one way and must stay that way: Rain depends on Arcron; Arcron must never depend on Rain. A keeper network with a favourite application is not permissionless.
draw is the call Arcron makes. It takes no arguments, walks a few rain boxes,
credits the ones that are due, and returns — including when there is nothing to
do. It moves no money and makes no inner call.
That is not tidiness. A scheduled call reaches only what the keeper's own
transaction made available to it, and a payment to an account that had closed
or opted out of the prize asset would fail the whole execution — stalling the
schedule for every other rain in the hub, because one recipient left. So draw
does accounting only, and the party who wants the money is the party who asks
for it.
bootstrap() creator funds the app's own floor, once
opt_in_prize_asset() anyone lets the hub hold one ASA, so rains can pay in it
create_rain() anyone opens a rain: gate, prize, drip, interval, mode
set_rain() that rain's creator retunes its drip and interval
enter() one ticket per account per rain
gm() WAVE check-in, first wave_cap this interval
deposit() anyone tops the pot up (deposit_asset for an ASA)
draw() ZERO ARGS. The Arcron hook. Fires due rains, moves nothing.
resolve() ONE: read the committed round's seed, credit a winner
abandon() ONE: the seed window closed; return the locked prize
claim() pull what you are owed
Each rain also enforces its own interval_rounds, so an extra draw from a
stranger is a no-op rather than a way to drain a pot. Arcron fires the hook;
the rain decides whether anything falls.
The hub takes nothing. There is no protocol fee, no rake, and no owner
withdrawal. Past bootstrap, the app's creator has no more authority than
anyone else.
A ONE draw cannot pick a winner in the same call that opens it: a transaction
cannot see a random number that did not exist when it was sent. So draw locks
the drip and names a round eight ahead, and a later resolve reads that
round's block seed and credits whoever it lands on.
Three consequences worth knowing before you choose ONE:
- Somebody has to send that
resolve, and today nobody does, including us.scripts/rain_bot.pyis a scan that sends nothing: itsscan_oncereads global state, logs oneidleline and returns. Itsshould_resolveandshould_abandonare dead code nothing calls, and it is written against the superseded single-rain contract — it looks for globals the hub does not keep, builds ticket box names the hub does not use, and carries a 1,000-round beacon window against the contract'sSEED_WINDOW = 800. Repointing or retiring it is an open task inspecs/rain/tasks.md. Until then, on any rain — ours or yours — that somebody is you, by hand or by a script you write. - A block seed stays readable for only about a thousand rounds, and the hub
gives itself 800. Past that the draw can never be resolved, and
abandonreturns the locked drip to the pot instead. Nothing else can unstick it: a ONE rain with an open draw fires nothing further until one of the two runs. - Entry is refused while a draw is open. Once the committed round is mined its seed is public, and a batch of tickets bought against a known seed could be sized to catch the modulo, so the set the winner is drawn from freezes at the moment of the fire — eight rounds before the seed exists.
A block proposer can influence a seed. That trade is deliberate and bounded: it
is the right source of randomness for a drip and the wrong one for a jackpot.
smart_contracts/beacon_stub/ is kept, referenced by nothing, so the trip back
to a randomness beacon stays testable if a pot ever grows worth aiming at.
None of this is theoretical, and none of it is fixable on a deployed hub.
The gate gives no Sybil resistance, and it is not staking. A gated rain
asks whether the sender holds a token from the collection at enter, again at
claim when there is something to pay, and — on a WAVE rain only — at every
gm check-in. Every one of those is a point-in-time balance read: nothing is
locked, nothing is at risk, and re-checking a snapshot more often does not make
it a stake. One NFT can therefore walk through the gate from as many wallets as
you care to fund, passing it back between them if a WAVE rain asks again. Per
extra identity: 0.0285 ALGO of ticket box minimum that never comes back (0.0506
on a ONE rain), plus 0.1 ALGO of account minimum and 0.1 of asset minimum for
the opt-in to hold the gate NFT — both of which you recover when you close the
wallet — plus fees. So about 0.23 ALGO tied up and about 0.03 actually
spent, per identity. Design a rain around "one share per holder" and you have
designed it around something the contract does not enforce. The same snapshot
cuts the other way: someone can enter with a borrowed NFT, sell it, and hold
credit they can never claim.
A popular SPLIT rain divides to zero and stops, silently. share = drip // tickets is integer division, and tickets only ever accumulate — there is no
leave. Once the ticket count passes the drip, every share rounds to zero, the
contract declines the drop, and it declines it without touching
last_rain_round, so the rain reads as due for ever while paying nothing. The
only cure is the creator raising the drip with set_rain, and set_rain has
no builder in js/ and no button on the page: today it can only be sent from a
script. A rain whose creator has walked away is dead, and it will go on
advertising itself as due.
There is no way to delete a rain, and draw only scans four. DRAW_SCAN
is 4, so a hub of N rains gives each one a visit every ⌈N/4⌉ calls of the
upkeep. Nothing removes a rain box — not its creator, not us — so every
abandoned, empty or joke rain anybody ever opens permanently lengthens the gap
between visits for everybody else. Nothing rate-limits create_rain beyond its
95,700 µALGO box minimum.
opt_in_prize_asset has no unit test at all. tests/test_rain.py opts the
hub into a prize by patching holdings directly, so the method never runs there.
It is the one method carrying real money-safety assertions — it refuses an
asset with a clawback, freeze or manager address, or one frozen by default,
which is what stops an issuer clawing a pot back out from under the people who
entered for it — and not one of those four refusals is exercised by anything.
Only the happy path has ever run: once on the previous hub, and in
fledge run smoke-community-rain on LocalNet.
abandon has never run on this hub. It gained a transaction builder in
js/ and a button on the page on 2026-08-31, having spent the contract's whole
life before that with no way to send it — so a stalled ONE rain was stalled for
good. It has executed exactly once on chain, on the previous hub, at round
66,801,521. On hub 770746178 it is untried outside the unit tests, and the unit
tests run against a mock.
The unit tests cannot see money move. algorand-python-testing records
inner transactions rather than executing them, and enforces no minimum
balances. So no test in tests/ shows ALGO or an ASA actually arriving
anywhere, or the app account actually affording its boxes. Those claims are
settled by fledge run smoke-rain and fledge run smoke-community-rain on
LocalNet, and by fledge run rain-one-draw on TestNet, and nowhere else.
specs/rain/tasks.md keeps the fuller list of untested paths, including the
rekey and close-remainder assertions, which are reviewed rather than proven.
Read its prose with the date in mind: specs/rain/context.md, tasks.md and
.env.testnet.template were all written before hub 770746178 existed and still
say no deployment matches this tree and no app id here should be described as
live. That is stale, not a second opinion — the hub above is real and
verify_build affirms it — and specsync check --strict passes 2/2 because it
checks the API surface, not the prose. Fixing those three files is itself an
open task.
An earlier hub, 770130162, ran on TestNet under the arcron repository. It is superseded and must not be used.
It predates the fix that refuses enter while a ONE draw is open. Its
committed seed becomes public the moment that round is mined, and it accepts
tickets afterwards — so a ONE draw on it can be aimed by anybody willing to buy
enough tickets once the answer is visible. The difference between the two hubs
is eight bytes of approval program, 4,963 against 4,971, and those eight bytes
are the assert. It is immutable, so it cannot be repaired; a fix is a new app
id, by construction. That is arcron issue #232.
It is still live and still holds money. At round 66,860,306 its app account
held 3.1628 ALGO, of which 0.9728 is minimum balance it can never release:
0.1 for the account, 0.7728 for its 16 boxes (5 rain, 8 ticket, 3 ONE-draw
index), and 0.1 for the one ASA it opted into as a prize. The pots are
0.8 + 0.95 + 0.2 + 0 + 0 ALGO, plus 0.05 locked in an unresolved draw:
2.0 ALGO across five rains, not 3.16. That draw is rain 3, locked at round
66,831,686 and committed to the seed of 66,831,694 — a round that passed tens
of thousands of rounds ago with nobody resolving it. Its seed window (800
rounds) has long since closed, so only abandon can free it, and nobody has
called it. Anyone holding a ticket there should know there is no leave — the
ticket and rain box minimums stay with that hub whatever they do.
And the other half of the project is still paying to keep it running.
Nothing in this repository defaults to it, names it as a deployment, or
points a script at it — but Arcron upkeep 91, registered by the same
deployer account that created both hubs, still fires draw() on it hourly and
still pays a keeper 4,000 µALGO to do so. It has run 43 times and holds about
29 days of escrow. Rain 1 on that hub credited a drop as recently as round
66,859,560, so it is not a dormant app — it is a running one.
Cancelling 91 is the remedy and it has not been done; that is arcron issue
#232. "Superseded" here
means nobody should use it, not that it has been switched off.
Algorand charges for storage as a minimum balance the app must keep, and the
hub collects it from whoever causes the box to exist. Every figure is exact and
derived in smart_contracts/rain/contract.py.
None of it comes back. There is no leave and no way to close a rain, so a
ticket's minimum stays with the hub for good. That is the price of a
permissionless create_rain: it is also the only thing stopping a stranger
opening ten thousand empty rains for the hub to pay for.
| µALGO | Paid by | |
|---|---|---|
| The hub's own floor | 100,000 | the creator, once, at bootstrap |
| One rain | 95,700 | whoever opens it |
| One ticket | 28,500 | whoever enters |
| A ONE rain's ticket index | 22,100 | whoever enters, in addition |
| Holding one ASA as a prize | 100,000 | whoever calls opt_in_prize_asset, permanently |
An MBR payment above the minimum is a donation: the contract asserts >=,
credits nothing, and there is no path back out.
claim pays through an inner transaction with a zero fee, so the caller's
group has to pool it. A claim submitted at the bare minimum fee fails before it
reaches a block.
| Cadence | Interval, in rounds |
|---|---|
| hourly | 1,286 |
| daily | 30,857 |
| weekly | 216,000 |
| monthly | 925,714 |
Those are the nominal 2.8-second round. TestNet measured 2.695 on 2026-08-28, so a "daily" rain really fires every 23.1 hours — about an hour early, every day, compounding. Rounds are what the contract counts; wall-clock is an estimate, and this one is generous. An interval must be between 10 and 1,000,000,000 rounds.
Register the draw()uint64 upkeep at least as often as the shortest rain you
care about. One upkeep serves every rain in the hub; upkeep 113 runs hourly.
Python 3.13 (>=3.12,<3.14 — never 3.14, coincurve publishes no wheels for
it), Poetry, AlgoKit,
Bun and fledge.
poetry install
fledge lanes run ci # build, unit tests, spec check, TypeScript, the page. No chain.algokit localnet start
fledge lanes run local # the above, plus the rendered-page audit and both demosOn 2026-08-31 that is 62 pytest tests, 74 TypeScript client tests, 93 page unit
tests, 73 rendered-page checks, and 2 specs held to specsync check --strict.
Anything that touches TestNet needs .env.testnet: copy
.env.testnet.template, fill in a throwaway DEPLOYER_MNEMONIC, and set
RAIN_APP_ID=770746178. The template is committed; .env.testnet is
gitignored and must stay that way. Never put a mnemonic you care about in it.
LocalNet needs none of this — its accounts come from KMD. (The template still
carries a comment saying no hub of this repository's own is deployed and leaves
RAIN_APP_ID commented out. That comment predates 770746178 and has not been
updated.)
The two LocalNet demos are the part that proves anything about money, for the reason given above: the unit tests run against mocks that record inner transactions rather than executing them.
fledge run smoke-rain # a SPLIT rain in ALGO, end to end
fledge run smoke-community-rain # gated to an NFT collection, paying an ASA| Task | What it does |
|---|---|
fledge run build |
compile the contract and regenerate the typed client |
fledge run test |
the unit tests |
fledge run spec |
specsync check --strict — fails on drift between spec and code |
fledge run verify |
print the hash of the contract this tree builds |
fledge run web-render |
what the page actually renders, in a browser, in both themes |
fledge run deploy-localnet |
a bare hub on LocalNet |
fledge run deploy-testnet |
a new hub, bootstrapped, with its rains and its Arcron upkeep |
fledge run rain-bot |
does nothing today. It was written for the single-rain contract the hub replaced; scan_once logs one line and sends no transaction. Kept so an existing cron unit does not start failing. See specs/rain/tasks.md |
fledge run rain-one-draw |
prove a ONE draw picks a stranger, on TestNet, with real ALGO |
rain-one-draw is in no lane on purpose. It spends real money and waits on
real rounds, and it exists because a ONE draw had been fired many times without
ever having chosen: the mocked test plants a seed, and the first live proof
entered with a single account, where seed % 1 is zero for every seed there
has ever been.
| Path | What it is |
|---|---|
smart_contracts/rain/ |
the hub |
smart_contracts/beacon_stub/ |
a randomness stub, referenced by nothing, kept so the trip back is testable |
specs/rain/ |
the spec specsync check --strict holds the contract to |
scripts/ |
the bot, the demos, the deployers, the build verifier |
js/ |
@corvidlabs/arcron-rain: box decoding and transaction builders. Unpublished; see js/README.md |
web/ |
the page at corvidlabs.xyz/rain. See web/README.md |
tests/ |
unit tests, against mocks |
Managed with spec-sync v6.0.0
(strict) and
fledge lanes. Every contract has a spec
under specs/ covering its public API, invariants, error cases and testing,
and specsync check --strict runs in the ci lane: a contract whose surface
changed without its spec changing fails the build rather than rotting quietly.
specs/rain/tasks.md is where the known gaps are written down.
Apache-2.0 for the code, which carries an express patent grant — worth having for contract code other people are asked to put money into.
CorvidLabs' name, logo mark and mascot are trademarks, and Apache-2.0 §6 grants no rights in them; NOTICE says so plainly rather than leaving it to be inferred. Fork this freely, and run it under your own marks.