BountyEscrow
Data bounty escrow contract with three payment tiers, oracle-driven quality assurance, auto-payout on approval, timelocked payouts, and fee routing through FeeManager.
Overview
BountyEscrow is the escrow layer for the PRIV data bounty board. Companies deposit PRIV to create bounties specifying what data they need, how many submissions they require, and how much they will pay per approved submission. Contributors fulfill bounties off-chain, and an oracle network (backed by ApproverRegistry) verifies data quality. On approval, payouts auto-release to contributors. Fees route through FeeManagerV2 for burn, staker distribution, and treasury allocation.
Three payment tiers offer different cost structures depending on how the creator interacts with the PRIV token. Creators who stake PRIV receive the deepest discounts and lowest fees.
| Property | Value |
|---|---|
| Contract | BountyEscrow |
| Address (Base Sepolia) | 0xac973946325750b73BAB26bc7D978AFcFB62591e |
| Standard Fee | 3% (300 bps) |
| Staked Fee | 2% (200 bps) |
| Direct PRIV Discount | 15% |
| Staked PRIV Discount | 20% |
| Min Creator Stake (Tier 3) | 1,000 PRIV |
| Stake Lock Duration | 30 days |
| Cancel Cooldown | 48 hours |
| Min Bounty Duration | 1 day |
| Max Bounty Duration | 365 days |
| Network | Base (Chain ID: 8453) |
Payment Tiers
Bounty creators choose a payment tier when creating a bounty. The tier determines the effective price per submission and the protocol fee rate.
| Tier | Name | Price Discount | Protocol Fee | Requirement |
|---|---|---|---|---|
| 0 | Standard | None (full price) | 3% | None |
| 1 | DirectPRIV | 15% off | 3% | None |
| 2 | StakedPRIV | 20% off | 2% | Stake >= 1,000 PRIV |
Tier Economics Example
For a bounty offering 100 PRIV per submission with 50 target submissions:
| Tier | Effective Price | Fee Per Submission | Total Deposit |
|---|---|---|---|
| Standard | 100 PRIV | 3.00 PRIV | 5,150 PRIV |
| DirectPRIV | 85 PRIV | 2.55 PRIV | 4,377.50 PRIV |
| StakedPRIV | 80 PRIV | 1.60 PRIV | 4,080 PRIV |
The total deposit is calculated as (effectivePrice + feePerSubmission) * targetSubmissions and locked in escrow at creation time.
Bounty Lifecycle
Creator deposits PRIV --> Bounty active --> Contributors submit data --> Oracle approves
| | | |
v v v v
createBounty() Status: Active Off-chain data approveSubmission()
funds locked (1-365 days) tracked by hash M-of-N consensus
|
v
Auto-payout to
contributor + fee
to FeeManagerStatus Transitions
| From | To | Trigger |
|---|---|---|
| Active | Completed | All target submissions approved |
| Active | Expired | block.timestamp >= expiresAt |
| Active | Cancelled | Creator calls cancelBounty() (after 48h cooldown) |
Step-by-Step
-
Create: Creator calls
createBounty()with price, target count, duration, metadata hash, tier, and optional badge requirement. The full deposit (payouts + fees) is transferred to escrow. -
Submit: Contributors submit data off-chain. Each submission is identified by a
bytes32hash. -
Approve: An oracle calls
approveSubmission()with the bounty ID, contributor address, and submission hash. When the M-of-N confirmation threshold is met, the payout executes automatically. -
Payout: The contributor receives the effective price per submission. The protocol fee is sent to FeeManagerV2. If ApproverRegistry is configured and the contributor has a payout delay, funds are held in a timelock.
-
Complete/Expire: When all target submissions are approved, the bounty transitions to Completed. If the deadline passes first, it becomes Expired. The creator can withdraw any remaining deposit from completed, expired, or cancelled bounties.
Creator Staking
Creators can stake PRIV to unlock Tier 2 (StakedPRIV) benefits. Staking is separate from bounty deposits.
| Parameter | Value |
|---|---|
| Minimum Stake | 1,000 PRIV |
| Lock Duration | 30 days |
function stakeAsCreator(uint256 amount) external nonReentrant whenNotPaused
function unstakeAsCreator(uint256 amount) external nonReentrant- Staking locks tokens for 30 days from the last stake timestamp.
- Unstaking is only possible after the lock period elapses.
- The
isStakedCreator(address)view function checks whether an address meets the 1,000 PRIV minimum.
Badge Gating
Bounties can optionally require contributors to hold a specific PrivBadge (ERC-1155 skill credential). When requiredBadgeId is set to a non-zero value and the PrivBadges contract is configured, contributors must hold that badge to have submissions approved.
if (bounty.requiredBadgeId != 0 && address(privBadges) != address(0)) {
if (privBadges.balanceOf(contributor, bounty.requiredBadgeId) == 0)
revert MissingRequiredBadge();
}This allows creators to target contributors with verified skills (e.g., medical data annotation, multilingual labeling).
Oracle System
Submission approvals use an M-of-N oracle consensus model. The default configuration requires 1 confirmation (suitable for MVP with ApproverRegistry acting as the single trusted oracle).
Oracle Requirements
- Oracles must be registered by the contract owner via
addOracle(). - Each oracle must call
oracleHeartbeatPing()at least once per hour to remain active. Stale oracles cannot approve submissions. - Multiple oracles can confirm the same submission. The approval executes when confirmations reach
minOracleConfirmations. - Each oracle can only confirm a given submission once (prevents double-counting).
Heartbeat
function oracleHeartbeatPing() external // Only callable by registered oraclesOracles that have not pinged within ORACLE_STALENESS (1 hour) are rejected when attempting to approve submissions.
Timelocked Payouts
When an ApproverRegistry is configured, contributor payouts may be subject to a delay based on the contributor's reputation. New or low-reputation contributors receive timelocked payouts that can be slashed if their submissions are later disputed.
Claiming
Contributors call claimPayouts() with an array of payout indices once the timelock has elapsed.
function claimPayouts(uint256[] calldata payoutIndices) external nonReentrant whenNotPausedSlashing
The ApproverRegistry can call slashPendingPayouts() to slash all unclaimed, un-slashed payouts for a contributor. Slashed funds are sent to FeeManagerV2 for redistribution.
function slashPendingPayouts(address contributor) external nonReentrant returns (uint256 slashedAmount)Only the registered ApproverRegistry contract can call this function.
Fee Routing
Protocol fees are collected on each approved submission and forwarded to FeeManagerV2 for distribution:
| Destination | Share |
|---|---|
| Burn | 40% |
| Stakers (PRIVStakingV2) | 35% |
| Treasury | 25% |
The fee rate depends on the bounty's payment tier: 3% (300 bps) for Standard and DirectPRIV, 2% (200 bps) for StakedPRIV.
Contract Interface
Constants
uint256 public constant STANDARD_FEE_BPS = 300; // 3%
uint256 public constant STAKED_FEE_BPS = 200; // 2%
uint256 public constant DIRECT_DISCOUNT_BPS = 1500; // 15%
uint256 public constant STAKED_DISCOUNT_BPS = 2000; // 20%
uint256 public constant BPS_DENOMINATOR = 10_000;
uint256 public constant MIN_DURATION = 1 days;
uint256 public constant MAX_DURATION = 365 days;
uint256 public constant MIN_CREATOR_STAKE = 1000e18; // 1,000 PRIV
uint256 public constant STAKE_LOCK_DURATION = 30 days;
uint256 public constant CANCEL_COOLDOWN = 48 hours;
uint256 public constant MIN_PRICE_PER_SUBMISSION = 10_000_000_000_000_000; // 0.01 PRIV
uint256 public constant ORACLE_STALENESS = 1 hours;State Variables
IERC20 public immutable privToken; // PRIV token contract
IFeeManager public feeManager; // FeeManagerV2 for fee distribution
uint256 public nextBountyId;
uint256 public nextApprovalId;
uint256 public totalEscrowBalance; // PRIV currently held in escrow
uint256 public totalPaidToContributors; // Lifetime payouts
uint256 public totalProtocolFees; // Lifetime fees
uint256 public totalBountiesCreated;
uint256 public totalCreatorStakes; // Total staked by creators
uint256 public minOracleConfirmations; // M-of-N threshold (default: 1)Structs
Bounty
struct Bounty {
address creator;
uint256 totalDeposit;
uint256 pricePerSubmission; // Effective price (after discount)
uint256 targetSubmissions;
uint256 approvedCount;
uint256 totalPaidOut;
uint256 protocolFeePaid;
uint256 expiresAt;
uint256 createdAt;
PaymentTier tier;
BountyStatus status;
bytes32 metadataHash; // IPFS hash of bounty specs
uint256 requiredBadgeId; // 0 if no badge required
}PendingPayout
struct PendingPayout {
uint256 amount;
uint256 unlockTime;
bool claimed;
bool slashed;
}Approval
struct Approval {
address contributor;
uint256 bountyId;
uint256 payout;
uint256 protocolFee;
uint256 approvedAt;
bytes32 submissionHash;
}Functions
Creator Functions
createBounty
Create a bounty with a PRIV deposit. Requires prior ERC-20 approval for the total deposit amount.
function createBounty(
uint256 pricePerSubmission,
uint256 targetSubmissions,
uint256 duration,
bytes32 metadataHash,
PaymentTier tier,
uint256 requiredBadgeId
) external nonReentrant whenNotPaused returns (uint256 bountyId)| Parameter | Type | Description |
|---|---|---|
pricePerSubmission | uint256 | Base price per submission in PRIV (before discount) |
targetSubmissions | uint256 | Number of submissions needed |
duration | uint256 | Bounty duration in seconds (1 day -- 365 days) |
metadataHash | bytes32 | IPFS hash of the bounty specification |
tier | PaymentTier | Payment tier (0=Standard, 1=DirectPRIV, 2=StakedPRIV) |
requiredBadgeId | uint256 | Required PrivBadge ID (0 for no requirement) |
Requirements:
pricePerSubmission >= 0.01 PRIVtargetSubmissions > 0durationbetween 1 day and 365 days- Tier 2 requires
creatorStakes[msg.sender] >= 1000 PRIV - Caller must have approved the contract for the total deposit
cancelBounty
Cancel an active bounty and receive a refund of remaining funds.
function cancelBounty(uint256 bountyId) external nonReentrantRequirements:
- Caller must be the bounty creator
- Bounty must be Active
- At least 48 hours must have passed since creation
withdrawRemaining
Withdraw remaining funds from a completed, expired, or cancelled bounty.
function withdrawRemaining(uint256 bountyId) external nonReentrantRequirements:
- Caller must be the bounty creator
- Bounty must not be Active (must be Completed, Expired, or Cancelled)
- Remaining balance must be greater than 0
stakeAsCreator
Stake PRIV to qualify for Tier 2 (StakedPRIV) benefits.
function stakeAsCreator(uint256 amount) external nonReentrant whenNotPausedunstakeAsCreator
Withdraw staked PRIV after the 30-day lock period.
function unstakeAsCreator(uint256 amount) external nonReentrantContributor Functions
claimPayouts
Claim timelocked payouts that have passed their unlock time.
function claimPayouts(uint256[] calldata payoutIndices) external nonReentrant whenNotPausedOracle Functions
approveSubmission
Confirm a submission for payout. Auto-executes when M-of-N threshold is met.
function approveSubmission(
uint256 bountyId,
address contributor,
bytes32 submissionHash
) external nonReentrant whenNotPaused| Parameter | Type | Description |
|---|---|---|
bountyId | uint256 | The bounty to approve against |
contributor | address | The contributor receiving payout |
submissionHash | bytes32 | Off-chain reference to the submission |
Requirements:
- Caller must be a registered, non-stale oracle
- Bounty must be Active
- Target submissions must not already be met
- Submission must not already be approved
- Contributor must hold required badge (if set)
oracleHeartbeatPing
Signal oracle liveness. Must be called at least once per hour.
function oracleHeartbeatPing() externalView Functions
getEffectivePrice
Calculate the effective price after tier discount.
function getEffectivePrice(uint256 basePrice, PaymentTier tier) external pure returns (uint256)getBountyTotalCost
Calculate the total deposit required to create a bounty.
function getBountyTotalCost(
uint256 pricePerSubmission,
uint256 targetSubmissions,
PaymentTier tier
) external pure returns (uint256 totalCost, uint256 effectivePrice, uint256 totalFees)getBountyRemaining
Get the remaining escrowed balance for a bounty.
function getBountyRemaining(uint256 bountyId) external view returns (uint256)isStakedCreator
Check whether a creator has enough staked PRIV for Tier 2.
function isStakedCreator(address creator) external view returns (bool)getCreatorBountyCount / getContributorApprovalCount
function getCreatorBountyCount(address creator) external view returns (uint256)
function getContributorApprovalCount(address contributor) external view returns (uint256)Admin Functions
addOracle / removeOracle
Manage the oracle whitelist.
function addOracle(address oracle) external onlyOwner
function removeOracle(address oracle) external onlyOwnersetMinOracleConfirmations
Set the M-of-N threshold for submission approvals.
function setMinOracleConfirmations(uint256 _min) external onlyOwnersetFeeManager
Update the FeeManager contract address.
function setFeeManager(address _feeManager) external onlyOwnersetPrivBadges
Set the PrivBadges contract for skill-gated bounties.
function setPrivBadges(address _privBadges) external onlyOwnersetApproverRegistry
Set the ApproverRegistry for timelocked payouts and slashing.
function setApproverRegistry(address _registry) external onlyOwnerpause / unpause
Emergency pause controls.
function pause() external onlyOwner
function unpause() external onlyOwnerEvents
event BountyCreated(
uint256 indexed bountyId,
address indexed creator,
uint256 totalDeposit,
uint256 pricePerSubmission,
uint256 targetSubmissions,
PaymentTier tier,
uint256 expiresAt,
uint256 requiredBadgeId
);
event SubmissionApproved(
uint256 indexed bountyId,
uint256 indexed approvalId,
address indexed contributor,
uint256 payout,
uint256 protocolFee
);
event BountyCompleted(uint256 indexed bountyId, uint256 totalPaidOut, uint256 totalFees);
event BountyExpired(uint256 indexed bountyId);
event BountyCancelled(uint256 indexed bountyId, uint256 refunded);
event RemainingWithdrawn(uint256 indexed bountyId, uint256 amount);
event CreatorStaked(address indexed creator, uint256 amount);
event CreatorUnstaked(address indexed creator, uint256 amount);
event OracleAdded(address indexed oracle);
event OracleRemoved(address indexed oracle);
event FeeManagerUpdated(address indexed newFeeManager);Errors
error ZeroAddress();
error ZeroAmount();
error InvalidBountyId();
error BountyNotActive();
error BountyNotExpired();
error InsufficientDeposit();
error TargetAlreadyMet();
error NotBountyCreator();
error NotOracle();
error StaleOracle(); // Oracle heartbeat > 1 hour old
error AlreadyApproved(); // Submission or oracle confirmation duplicate
error InsufficientStake(); // Below 1,000 PRIV for Tier 2
error StakeStillLocked(); // 30-day lock not elapsed
error NothingToWithdraw();
error InvalidTier();
error InvalidDuration(); // Outside 1 day -- 365 days
error InvalidTarget(); // targetSubmissions == 0
error CooldownNotElapsed(); // Cancel before 48h cooldown
error MissingRequiredBadge(); // Contributor lacks required PrivBadge
error NotApproverRegistry(); // Slash caller is not ApproverRegistry
error PayoutStillLocked(); // Timelock not elapsed
error PayoutAlreadyProcessed(); // Payout already claimed or slashedUsage Examples
Create a Bounty (Standard Tier)
import { useWriteContract } from 'wagmi'
import { parseEther } from 'viem'
function useCreateBounty() {
const { writeContract } = useWriteContract()
return async (
pricePerSubmission: string,
targetSubmissions: number,
durationDays: number,
metadataHash: `0x${string}`
) => {
const price = parseEther(pricePerSubmission)
const duration = BigInt(durationDays * 86400)
// 1. Calculate total deposit needed
// totalDeposit = (price + price * 300 / 10000) * targetSubmissions
const fee = (price * 300n) / 10000n
const totalDeposit = (price + fee) * BigInt(targetSubmissions)
// 2. Approve PRIV spending
await writeContract({
address: PRIV_TOKEN_ADDRESS,
abi: erc20Abi,
functionName: 'approve',
args: [BOUNTY_ESCROW_ADDRESS, totalDeposit],
})
// 3. Create bounty (tier 0 = Standard, badgeId 0 = no requirement)
await writeContract({
address: BOUNTY_ESCROW_ADDRESS,
abi: bountyEscrowAbi,
functionName: 'createBounty',
args: [price, BigInt(targetSubmissions), duration, metadataHash, 0, 0n],
})
}
}Check Bounty Status
import { useReadContract } from 'wagmi'
function useBountyInfo(bountyId: number) {
const { data: bounty } = useReadContract({
address: BOUNTY_ESCROW_ADDRESS,
abi: bountyEscrowAbi,
functionName: 'bounties',
args: [BigInt(bountyId)],
})
const { data: remaining } = useReadContract({
address: BOUNTY_ESCROW_ADDRESS,
abi: bountyEscrowAbi,
functionName: 'getBountyRemaining',
args: [BigInt(bountyId)],
})
return { bounty, remaining }
}Stake as Creator for Tier 2
import { useWriteContract } from 'wagmi'
import { parseEther } from 'viem'
function useCreatorStake() {
const { writeContract } = useWriteContract()
return async (amount: string) => {
const amountWei = parseEther(amount)
// 1. Approve PRIV spending
await writeContract({
address: PRIV_TOKEN_ADDRESS,
abi: erc20Abi,
functionName: 'approve',
args: [BOUNTY_ESCROW_ADDRESS, amountWei],
})
// 2. Stake
await writeContract({
address: BOUNTY_ESCROW_ADDRESS,
abi: bountyEscrowAbi,
functionName: 'stakeAsCreator',
args: [amountWei],
})
}
}Calculate Bounty Cost Before Creation
function useBountyCostEstimate(
pricePerSubmission: bigint,
targetSubmissions: number,
tier: number
) {
const { data: cost } = useReadContract({
address: BOUNTY_ESCROW_ADDRESS,
abi: bountyEscrowAbi,
functionName: 'getBountyTotalCost',
args: [pricePerSubmission, BigInt(targetSubmissions), tier],
})
// cost = [totalCost, effectivePrice, totalFees]
return cost
}Security Notes
-
Escrow Isolation: All bounty deposits are tracked per-bounty. The
totalEscrowBalancestate variable tracks the aggregate, and each bounty's remaining balance is computed from its owntotalDeposit - totalPaidOut - protocolFeePaid. -
Double-Approval Prevention: Each submission hash can only be approved once per bounty via the
submissionApprovedmapping. Each oracle can only confirm a given submission once via theoracleConfirmedmapping. -
Oracle Liveness: Oracles must maintain a heartbeat within the 1-hour staleness window. Stale oracles are rejected from approving submissions, preventing abandoned oracles from affecting the system.
-
Cancel Cooldown: Creators cannot cancel a bounty within 48 hours of creation. This prevents a griefing attack where a creator posts a bounty, waits for submissions, then cancels before approval.
-
Reentrancy Protection: All fund-moving functions (create, cancel, withdraw, approve, claim, slash) use the
nonReentrantmodifier. -
Timelock Slashing: When integrated with ApproverRegistry, contributor payouts can be delayed and slashed if data quality disputes arise. Slashed funds flow to FeeManagerV2 rather than being lost.
-
Emergency Pause: The owner can pause all creation, approval, and claiming operations. Cancellation and withdrawal of completed/expired bounties are not affected by pause state.