Bounty/Withdraw
BountyService/WithdrawWorker pulls the escrowed reward into their balance. Combined with any defensive refund into a single atomic reply.
fn withdraw(id: BountyId) -> Result<(), Error>Parameters
idu64 (BountyId)requiredThe bounty's ID. Status must be Accepted and bounty.withdrawn must be false.
Behavior
On success:
bounty.withdrawn = true(one-way; no method resets it).BountyWithdrawnevent emitted.- Reply delivers
value (attached) + bounty.rewardto caller viaCommandReply::with_value(total).
Status stays Accepted. Withdraw is the only method that doesn't move the bounty in bounties_by_status. The withdrawn flag is the disambiguator between "ready to pay" and "paid."
Primitive choice — why CommandReply::with_value
The caller (msg::source()) is the value target (bounty.worker). Under that condition, CommandReply::with_value is the right primitive — it rides on the reply and credits the caller's balance directly.
Compare to a hypothetical future AutoSettle(id) where any third party can push a stuck Accepted bounty to the worker. There, caller ≠ target, so the primitive would be msg::send_bytes(worker, [], reward). Different problem, different primitive. See Anti-cheat.
Value semantics
Not payable, but any attached msg::value() is folded into the success reply:
total = value + bounty.reward (checked_add, panic on overflow)
On Err, the attached value is refunded alone — bounty.reward does NOT leave escrow.
Returns
Result<(), Error>. Ok delivers value + reward; Err refunds only value.
Errors
SelfLoopErrormsg::source() == exec::program_id().
MarketPausedErrorconfig.paused == true.
BountyNotFoundErrorNo bounty with id exists.
BountyNotAcceptedErrorStatus is not Accepted. Common: still Submitted (poster hasn't accepted yet), or Open/Claimed.
UnauthorizedErrormsg::source() != bounty.worker. Only the assigned worker can withdraw.
AlreadyWithdrawnErrorbounty.withdrawn == true. The worker already pulled the reward.
Guard order
1. SelfLoop — anti-cheat
2. MarketPaused — config flag
3. BountyNotFound — state read
4. BountyNotAccepted — status check
5. Unauthorized — worker auth
6. AlreadyWithdrawn — idempotency check (last, so it's cheap to retry)
The idempotency check runs last so a worker who calls Withdraw twice (e.g., due to a wallet glitch) only pays gas for the cheap guards on the second call.
Event emitted (on Ok)
BountyWithdrawn:
{
id: u64,
worker: ActorId,
amount: u128,
withdrawn_at: u32,
}
amount is bounty.reward — the locked-at-Post value. See Events.
Idempotency
Withdraw is idempotent across calls in the strong sense: a second call returns Err(AlreadyWithdrawn) without moving value. No double-spend; no state mutation. Gas is still charged for the cheap guard pass.
The flag flip + event emission + value transfer are atomic. If the event emission panicked, the entire reply rolls back — flag stays false, value stays in escrow. Actor-model semantics.
Example calls
const result = await sdk.withdraw(workerSigner, { id: 42n });
if ('ok' in result.reply) {
console.log("withdrew reward; check balance");
} else if (result.reply.err === 'AlreadyWithdrawn') {
console.log("already withdrew earlier");
} else {
console.error("rejected:", result.reply.err);
}Reference-worker pattern
The reference worker monitors Accepted bounties via the indexer's worker_balance_changed projection and the SDK's onBountyAccepted subscription. When either signal fires, it calls Withdraw and tears down the in-flight FSM. See Build a worker daemon.
Gotchas
- Status stays Accepted forever post-withdraw. Downstream queries should filter on
withdrawn == true, not on status. The PostGraphile schema exposes both — see GraphQL schema. - Worker balance changes by exactly
reward, plus any defensive value. Gas cost is separate (debited from the worker's balance pre-execution). - No path to release escrow to anyone else. If the worker's wallet is lost between Accept and Withdraw, the reward is permanently stuck. A future
AutoSettlewill partially address by allowing third-party push, but the recipient is still locked tobounty.worker.
Source
programs/bountymesh/app/src/service.rs:386-451
Next steps
- BountyWithdrawn event
- Two-phase escrow — design rationale
- Build a worker daemon — full FSM lifecycle