Soroban contracts for the Surge reward waves.
Two crates, and they overlap. wave-pool and
wave_escrow are both escrows that hold a wave's USDC and pay it out
in proportion to points earned. CI builds and tests both — ci.yml runs the whole
workspace and uploads both wasm modules, and wave-escrow.yml covers the second
crate again with its own property tests.
That duplication is unresolved and should be settled before anything is deployed.
The interface reads a single contract id out of
src/lib/stellar.ts, so two live pools would mean two sets
of figures for one wave and no way to tell which a contributor is owed from. The
document below describes wave-pool.
The frontend still runs on the fixtures in src/lib/model.ts.
This workspace is the on-chain half those fixtures stand in for, built and tested
on its own so the two can be joined once bindings are generated.
A wave has two phases and no way back.
Open. Anyone can transfer the reward asset in (fund_wave); the operator
records points as issues are accepted (award) and corrects them if a review is
revised (revoke). The contract tracks what it actually holds (escrowed)
separately from what the program announced (budget), so an underfunded wave
still pays out coherently.
Closed. close_wave freezes escrowed into pool and fixes a claim
deadline. Every payout divides pool, so two contributors with equal points are
paid equally no matter when — or in what order — they claim. Each contributor
calls claim once for their pool * points / total_points, floored. After the
deadline the operator recovers whatever is left with sweep.
The contract deliberately does not enforce the contribution window. Review lags
submission, so a wave closes when the operator has finished reviewing, not when
the clock runs out; start and end are recorded for the audit trail and for
clients to render.
| Authorised by | ||
|---|---|---|
open_wave(number, start, end, budget) |
admin | Creates a wave. Refuses a number already used. |
fund_wave(number, from, amount) |
from |
Transfers the reward asset into an open wave. |
award(number, contributor, points) |
admin | Adds points. Additive across issues. |
revoke(number, contributor, points) |
admin | Walks an award back. Refuses to underflow. |
close_wave(number, claim_window) |
admin | Freezes the pool, starts the claim window. |
claim(number, contributor) |
contributor |
Pays their share, once. Returns the amount. |
sweep(number, to) |
admin | After the deadline, recovers dust and abandoned shares. |
set_admin(new_admin) |
current admin | Rotates the operator role. |
wave, points, claimable, has_claimed, config |
— | Reads. |
claimable doubles as the projection a contributor dashboard needs: while the
wave is open it quotes against the balance escrowed so far and moves as funding
arrives and others earn points; once closed it is exactly what claim will pay,
and zero once claimed.
These are what the tests exist to hold, and what any change here has to preserve.
- A wave never pays out more than it escrowed. Shares are floored, so they
sum to at most
pool;claimadditionally refuses anything abovepool - paid. - The divisor cannot move after anyone has been paid against it.
poolandtotal_pointsare both frozen at close. - One claim per contributor per wave, and a failed claim never consumes it — otherwise a contributor who tried before the wave was funded would be locked out permanently.
- A wave cannot reach another wave's funds. All escrow shares one token
account, so every payout is bounded by the calling wave's own accounting.
This is not theoretical: see
a_share_cannot_be_claimed_out_of_another_wave_after_a_sweep, which is a regression test for a bug that was real. - The claim deadline is fixed before anyone claims and cannot be moved,
which is what stops
sweepfrom being a discretionary withdrawal.
cd contracts
cargo test # 35 tests, host target
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo build --target wasm32v1-none --release # what actually deploysCI runs all four. The wasm build is separate from cargo test on purpose — the
release profile differs from the test profile in ways that can fail on their own
(no_std, panic = "abort", a different target).
stellar contract deploy `
--wasm contracts/target/wasm32v1-none/release/wave_pool.wasm `
--network testnet `
--source-account <operator> `
-- --admin <operator-address> --token <usdc-sac-address>Admin and token are constructor arguments, not a later initialize call: there
is no window in which the deployed contract has no admin. Neither can be changed
afterwards except by set_admin — a different reward asset means a new
deployment, because every stored amount is denominated in the old one.
claimablereturnsNoPointsRecordedrather than zero for a wave where nobody has been awarded anything yet, so a dashboard querying a brand-new wave has to handle that variant rather than rendering a zero.- There is no pagination or enumeration: the contract can answer "what does this address hold in this wave" but not "who is in this wave". That is what the event stream is for, and it is why every award, claim and sweep emits one.
- Nothing here knows about repositories or issues. The mapping from an accepted
issue to a point value is program policy and stays off-chain, in
pointsFor.