Bounty panel — gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/bounty_panel
A public bounty board on pearl-1 whose resolution authority is separate from its funding authority. A funder escrows GNOT and, at creation, names a panel of resolvers and an M-of-N threshold. Contributors submit work during a submission window; after that window closes the panel votes on the competing submissions, and the first submission to reach M votes wins the escrow. Neither the panel nor the threshold can change after creation.
Built as the application-factory benchmark: an objective rather than a repository was supplied — "create public bounties with a reward, contributors submit work, a designated resolution process determines the winner, the reward is paid on-chain to the winner, preventing unauthorized claims, double payouts, and stuck funds" — and the lifecycle was objective → discovery → architecture → reuse → implementation → testing → adversarial audit → remediation → GitHub → pearl-1.
DISCOVERY / REUSE ANALYSIS (summary; full record in DISCOVERY.md)
Five sources searched: the Cosmic Bull catalog, all existing Cosmic Bull /p/ and /r/ sources, a fresh enumeration of pearl-1 (631 packages — 190 /p/, 441 /r/), the on-chain sources/imports of the closest candidates, and the public Gno ecosystem. No relevant existing implementation was found in the searched sources for a bounty whose resolvers are designated at creation and are not the funder.
The gate's headline finding is that this objective was already 6/7 implemented by the sibling realm grants (live since h584731): escrowed reward, on-chain submissions, restricted award, pull payment, fee model, permissionless refund valve. The delta is exactly one thing, and the build was confined to it:
resolution authority separate from funding authority, designated at creation, immutable thereafter, decided by an M-of-N threshold over competing submissions.
In grants the resolver is the funder by construction. For an adjudicated public bounty that is the wrong trust model: the deciding party has a financial interest in the outcome and sees every submission before deciding.
Classification of the significant hits:
| Component | Class | Action |
|---|---|---|
r/.../grants (own, live) | RELATED IMPLEMENTATION | Pattern source, not modified or redeployed |
p/.../coinio (own, live) | COSMIC BULL EXISTING PRIMITIVE | Reused as deployed |
p/.../feeledger (own, live) | COSMIC BULL EXISTING PRIMITIVE | Reused as deployed |
p/nt/markdown/sanitize/v0 | REUSABLE EXISTING PRIMITIVE | Reused (ecosystem beats ours here) |
p/nt/avl/v0 | REUSABLE EXISTING PRIMITIVE | Reused |
p/.../daocond, p/.../commondao | RELATED IMPLEMENTATION | Rejected, with reasons (below) |
r/samcrew/escrow_v3, r/…/bountyboard | RELATED IMPLEMENTATION | Unsuitable |
daocond/commondao were the one genuine reuse call that went against reuse, and the reasoning is recorded so a reviewer can disagree on evidence: float-valued thresholds (non-exact for a value-critical tally), caller-supplied closures crossing a realm boundary, and a tally shape that approves one proposal rather than selecting among competing submissions.
No new /p/ package was created. Composition was sufficient.
Architecture & state ownership
| Concern | Owner |
|---|---|
Bounty records, panels, submissions, ballots, openTotal, deadlines, roles | this realm (new) |
| Claimable balances (U), fee pot (F), fee arithmetic, overflow checks | feeledger (deployed instance) |
| Receipt guard, payouts, reserve-protected sweeps | coinio (deployed instance) |
Free-text markdown neutralization in Render | p/nt/markdown/sanitize/v0 |
| Ordered iteration | p/nt/avl/v0 |
Lifecycle — terminal states are frozen, exactly one transition per bounty:
Open ──Vote reaches M──> Awarded (winner credited, fee charged)
──funder Cancel───> Cancelled (only while NO submission exists)
──anyone Expire───> Expired (after resolveDeadline + 100)
Non-overlapping windows. Submissions close at submitDeadline and voting opens at that same height. A resolver therefore votes only on content that can no longer change — no bait-and-switch where a submission collects votes and is then edited — and no submission can be added in response to the votes already cast.
Cancel is restricted (the deliberate divergence from grants): once a single contributor has submitted, the funder can no longer unilaterally reclaim the escrow. Only the panel, by awarding, or the expiry valve can end the bounty. That is the anti-harvest guarantee a public bounty needs and a grant programme does not.
Re-voting is allowed until the threshold is reached, and a resolver's previous vote is withdrawn before the new one is recorded, so per-submission counts always sum to the number of live ballots (asserted by voteSum after every vote in the suite).
The funder may be a panel member. Barring them would be unenforceable theatre — a funder can always name an address they control — and the panel is public on-chain from creation, so a self-resolved bounty is visible to contributors before they spend effort. Disclosure beats a prohibition that cannot be enforced.
Invariant specification
Monetary (conservation): H == B + U + F + S, S >= 0 — coins held at the realm address equal open escrow + claimable balances + fee pot + out-of-band surplus. Asserted in delta form at the end of every test (the test banker resets per test while realm globals persist).
Application: openTotal == Σ amount(status == Open); winner != zero iff Awarded; Σ submission.votes == ballots.Size() per bounty; panel, threshold, deadlines and fee bps immutable after creation; 1 <= threshold <= panelSize.
Fee model: fee = floor(amount × bps / 10000), rounding favors the winner; snapshotted at creation (closes the award-time admin race) AND capped by the funder's own maxFeeBps argument (closes the creation-time race); compile-time cap 10%; refunds always fee-free.
The three objective-named hazards:
- Unauthorized claims — every identity comes from
cur.Previous().Address(); no entrypoint accepts a caller identity as a parameter. Claiming another user's winnings, editing another user's submission, or casting another resolver's ballot is impossible by construction, not by check. - Double payouts — award is reachable only from
Open, and setsAwardedin the same transaction as the credit; payout is pull-based and debits the ledger beforecoinio.Payoutmoves the identical amount (checks-effects-interactions). - Stuck funds — the permissionless expiry valve; see the caveat below, which is stated rather than glossed.
Security / audit
Two independent fresh-context audits were run: one over the realm and its tests, one over the composition seams (coinio/feeledger/ sanitizer boundaries). 0 RED. Auditor 2 returned zero findings and ten GREEN. Auditor 1 returned three YELLOW; the two auditors disagreed on one of them and the disagreement was adjudicated on the evidence:
- Y1 —
rejectStraySendscope. Auditor 1 read the fail-open comment as wrong; auditor 2 explicitly downgraded the same candidate to GREEN. Auditor 2 is right on substance:unsafe.OriginSend()reports the originating transaction's envelope, which for a realm-routed call is delivered to the intermediary realm's address and never reaches this one; an intermediary that separately sends coins is making an ordinary transfer that no entrypoint guard could intercept, and it lands asSweepDenom-recoverable surplus. Coins can only become escrow throughCreateBounty. Remediation: the comment was rewritten to state that scope unambiguously. Auditor 1 was nonetheless right on a narrower point — the test'sSurplus() == 0assertion after a routed call was vacuous, sincetesting.SetOriginSendstages an envelope without moving coins. That assertion was replaced with one that proves what the block is for: the routed call is admitted, and credited to the calling realm's address rather than the origin caller's. - Y2 — the expiry valve's "impossible" claim was overstated. Both exits from
Opencredit the sharedfeeledger, so both fail while that ledger is saturated at theint64boundary, leaving escrow temporarily immovable in both directions until some account claims down. Nothing is lost and the valve works again the moment there is headroom. The state needs liabilities within ~50 of 2^63−1 ugnot — beyond the real GNOT supply by orders of magnitude, and unreachable absent a chain-level minting bug, since every credit is backed by an escrowed-send. Remediation: the claim was weakened to the true one — fund-trapping is impossible under any reachable condition — and recorded in the realm header as "THE ONE CAVEAT", exercised byTestSaturatedAwardCannotTrapFunds. - Y3 —
Vote's atomicity rests on transaction-abort semantics, not on local ordering.Votedecrements the previous submission's count, records the new ballot, increments, and may callaward— all without a rollback path of its own. That is correct only because a panic aborts the whole transaction and reverts state. Recorded, not changed: restructuring it would add code without adding safety on this VM. The standing constraint this creates is explicit — introducing anyrecoverorrevivearoundVote(bounty_panel.gno≈ lines 396–430) invalidates the atomicity argument and requires a re-audit of the tally.
Both auditors independently flagged that the local coinio and feeledger sources were not yet byte-verified against their live pearl-1 deployments. That belongs to the PEARL VALIDATION stage and is recorded in the deployment record, not claimed here.
Testing
27 tests, all passing (gno test . → ok . 0.65s); gno lint . and gno fmt -diff -q . clean. The suite covers the state machine, every authorization boundary, both fee races, rounding, window edges, re-voting and threshold arithmetic, terminal-state finality, the stray-send guard on all eleven non-payable entrypoints, sweep isolation, render sanitization of untrusted text, int64 overflow at creation, ledger saturation, and cross-realm callers as first-class participants.
Three suite-scope tests do work that per-test assertions cannot:
TestAdversarialInterleaving— interleaved hostile call sequences.TestValveDrainsEverythingLeftOpen— skips past everyresolveDeadlineand expires every bounty the suite left open, checking each refund lands on its own funder. This turns test cleanup into a whole-suite proof of the no-stuck-funds claim. It was written becauseTestFinalAccountingIsCleancaught a real 500,000 ugnot leftover; the assertion was not weakened to accommodate it.TestFinalAccountingIsClean— end-of-suite conservation, all terms zero.
Harness note that cost real time and is recorded so it is not relearned: testing.SetRealm is keyed by frame index (ctx.RealmFrames[frameIdx] in gnovm/tests/stdlibs/testing/context_testing.go). A helper that sets the realm and returns leaves the test's own frame untouched and the previous staging silently wins. Every caller in this suite is therefore staged inline in the test function's own frame; the one helper that stages (createAs) is safe only because it makes the call from inside its own frame.
Second harness artifact, called out at each use: a panic caught by uassert does not roll back realm globals in the test VM, whereas on-chain it aborts and reverts the whole transaction. Tests that deliberately abort mid-mutation assert only on fields written before the abort point.
Gas / storage characteristics
Bounded by construction: MaxPanelSize 16, MaxSubmissions 500, MaxTitleLen 80, MaxDescLen 2000, MaxURILen 500, MaxRenderRows 20, windows in [1, 10_000_000] blocks. Submissions carry a reference (URL or content hash), never the work itself — the chain cannot judge quality, and storing bulk content pushes unbounded cost onto every future reader. Per-bounty state growth is paid for by the party creating it (each submission costs its submitter a storage deposit); Render is the one surface whose cost lands on third parties, which is why it is the one that paginates.
Deploy payload: 32,743 bytes of realm source + an 88-byte gnomod.toml. Live gas and storage-deposit figures are in the deployment record, not estimated here.
Limitations
- Roles are hot testnet agent keys; one-step
TransferAdmin. ExpiryGraceBlocks = 100(~8 min) is a testnet setting; a production fork would raise it substantially.- Panel membership is fixed at creation — a resolver who loses their key cannot be replaced, and the bounty then relies on either the remaining resolvers reaching M or the expiry valve. This is the deliberate cost of immutability; a replaceable panel would reintroduce the funder-controls-the-outcome problem.
- No per-submission reward splitting: one winner takes the whole escrow.
- Bounty and submission records are never pruned.
- Post-grace, a bounty is both award-able and expire-able; whichever transaction lands first wins (documented race, no fund loss either way).
- The chain cannot verify that submitted work is the submitter's own, or that it exists at all. Resolution is a human judgement the panel makes off-chain; this realm makes that judgement binding and visible, not correct.
Dependency graph
bounty_panel ──┬─> coinio ────> (stdlib)
├─> feeledger ─> p/nt/avl/v0
├─> p/nt/avl/v0
└─> p/nt/markdown/sanitize/v0
Deployment record: pearl/DEPLOYMENT.md.