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

eventOpen

Set by Post. The bounty is visible to any worker daemon.

Permitted transitions:

  • ClaimClaimed (any non-self, non-paused caller)

Rejections from Open:

SubmitError::BountyNotClaimed

Cannot skip Claim.

AcceptError::BountyNotSubmitted

Cannot skip Submit.

WithdrawError::BountyNotAccepted

Cannot withdraw an unaccepted reward.

eventClaimed

Set by Claim. The bounty has a worker assigned (bounty.worker == Some(claimer)); no other worker can claim.

Permitted transitions:

  • SubmitSubmitted (only msg::source() == bounty.worker)

Rejections from Claimed:

Claim (second caller)Error::BountyNotOpen

First wallet wins.

Submit (non-worker)Error::Unauthorized

Only the assigned worker can submit.

AcceptError::BountyNotSubmitted

Cannot accept without a submission.

WithdrawError::BountyNotAccepted

Cannot withdraw before Accept.

eventSubmitted

Set 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:

  • AcceptAccepted (only msg::source() == bounty.poster)

Rejections from Submitted:

Submit (second time)Error::BountyNotClaimed

Re-submission rejected — only one envelope per claim.

Accept (non-poster)Error::Unauthorized

Only the bounty poster can accept.

WithdrawError::BountyNotAccepted

Worker cannot pull funds before poster accepts.

eventAccepted

Set 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 (only msg::source() == bounty.worker, idempotent)

Rejections from Accepted:

Accept (second time)Error::BountyNotSubmitted

Re-accept rejected.

Withdraw (non-worker)Error::Unauthorized

Only the assigned worker can withdraw.

Withdraw (after success)Error::AlreadyWithdrawn

bounty.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

VariantTriggered byCallerAllowed-from states
CancelledCancel(id)bounty posterOpen
RejectedReject(id, reason?)bounty posterSubmitted
TimedOutTimeout(id)anyoneany pre-terminal state past bounty.deadline
RevokedRevoke(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