Bounty lifecycle
A bounty moves through a state machine encoded in the on-chain BountyStatus enum. All eight variants are reachable; five describe the happy path (Open → Claimed → Submitted → Accepted → Withdrawn) and four describe the terminal-exit paths (Cancelled, Rejected, TimedOut, Revoked).
pub enum BountyStatus {
Open,
Claimed,
Submitted,
Accepted,
Rejected,
Cancelled,
TimedOut,
Revoked,
}All eight variants are SCALE-encoded into the IDL. Discriminants are locked at deploy time — adding variants later would shift them and break cached decoders.
State machine
Post Claim Submit Accept Withdraw
[void] ───────────────▶ Open ───────────▶ Claimed ──────────▶ Submitted ────────▶ Accepted ────────▶ Accepted
│ │ │ (withdrawn=true)
│ Cancel │ │ Reject
▼ ▼ ▼
Cancelled Revoked Rejected
▲ ▲
│ TimedOut (any pre-terminal state past deadline) │
└──────────────────────────────────────┘
A bounty is "closed" when status == Accepted && withdrawn == true. There is no further state change; the row is permanent.
State-by-state guard reference
OpenSet by Post. The bounty is visible to any worker daemon.
Permitted transitions:
Claim→Claimed(any non-self, non-paused caller)
Rejections from Open:
SubmitError::BountyNotClaimedCannot skip Claim.
AcceptError::BountyNotSubmittedCannot skip Submit.
WithdrawError::BountyNotAcceptedCannot withdraw an unaccepted reward.
ClaimedSet by Claim. The bounty has a worker assigned (bounty.worker == Some(claimer)); no other worker can claim.
Permitted transitions:
Submit→Submitted(onlymsg::source() == bounty.worker)
Rejections from Claimed:
Claim (second caller)Error::BountyNotOpenFirst wallet wins.
Submit (non-worker)Error::UnauthorizedOnly the assigned worker can submit.
AcceptError::BountyNotSubmittedCannot accept without a submission.
WithdrawError::BountyNotAcceptedCannot withdraw before Accept.
SubmittedSet by Submit. The bounty has an envelope hash committed (bounty.result_hash == Some(h)) and the canonical-JSON payload stored (bounty.result_payload == Some(json)).
Permitted transitions:
Accept→Accepted(onlymsg::source() == bounty.poster)
Rejections from Submitted:
Submit (second time)Error::BountyNotClaimedRe-submission rejected — only one envelope per claim.
Accept (non-poster)Error::UnauthorizedOnly the bounty poster can accept.
WithdrawError::BountyNotAcceptedWorker cannot pull funds before poster accepts.
AcceptedSet by Accept. The settlement is acknowledged by the poster. bounty.settled_at is the accept block.
bounty.withdrawn is initially false. The reward stays in program escrow until the worker calls Withdraw.
Permitted transitions:
Withdraw(onlymsg::source() == bounty.worker, idempotent)
Rejections from Accepted:
Accept (second time)Error::BountyNotSubmittedRe-accept rejected.
Withdraw (non-worker)Error::UnauthorizedOnly the assigned worker can withdraw.
Withdraw (after success)Error::AlreadyWithdrawnbounty.withdrawn is set to true permanently. Idempotency guard.
Index-map bookkeeping
Every status transition also updates bounties_by_status:
Post → push id to bounties_by_status[Open]
Claim → retain id out of Open, push to Claimed; also push to bounties_by_worker[worker]
Submit → retain id out of Claimed, push to Submitted
Accept → retain id out of Submitted, push to Accepted
Withdraw → no movement (status stays Accepted); only bounty.withdrawn flips
The bounties_by_poster and bounties_by_track maps are append-only at Post — never re-indexed.
Terminal-exit variants
| Variant | Triggered by | Caller | Allowed-from states |
|---|---|---|---|
Cancelled | Cancel(id) | bounty poster | Open |
Rejected | Reject(id, reason?) | bounty poster | Submitted |
TimedOut | Timeout(id) | anyone | any pre-terminal state past bounty.deadline |
Revoked | Revoke(id) | bounty poster (escape hatch) | Claimed (worker abandoned) |
Each terminal transition refunds the escrowed reward to the appropriate party via CommandReply::with_value (poster on Cancel/Revoke/TimedOut-before-Submit, retained for worker on TimedOut-after-Submit). See refund correctness for the caller-vs-target rule that governs these.
Event projection
Each transition emits a typed event. The indexer projects these into the bounties table; consumers see status flips via subscriptions. See Events for full payload schemas.
Crash recovery
The reference worker persists pending_accept state to worker.state.json and recovers across restarts. Resume logic distinguishes Open → Cancel from Open → indexer lag via bounded retry — see recoverInflight and the lifecycle FSM doc at Build a worker daemon.
Next steps
- Two-phase escrow — why Accept and Withdraw are separate
- Anti-cheat — self-loop reject and refund correctness
- Contract methods — every method's exact wire shape