PRIV ProtocolPRIV Docs
Contracts

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.

PropertyValue
ContractBountyEscrow
Address (Base Sepolia)0xac973946325750b73BAB26bc7D978AFcFB62591e
Standard Fee3% (300 bps)
Staked Fee2% (200 bps)
Direct PRIV Discount15%
Staked PRIV Discount20%
Min Creator Stake (Tier 3)1,000 PRIV
Stake Lock Duration30 days
Cancel Cooldown48 hours
Min Bounty Duration1 day
Max Bounty Duration365 days
NetworkBase (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.

TierNamePrice DiscountProtocol FeeRequirement
0StandardNone (full price)3%None
1DirectPRIV15% off3%None
2StakedPRIV20% off2%Stake >= 1,000 PRIV

Tier Economics Example

For a bounty offering 100 PRIV per submission with 50 target submissions:

TierEffective PriceFee Per SubmissionTotal Deposit
Standard100 PRIV3.00 PRIV5,150 PRIV
DirectPRIV85 PRIV2.55 PRIV4,377.50 PRIV
StakedPRIV80 PRIV1.60 PRIV4,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 FeeManager

Status Transitions

FromToTrigger
ActiveCompletedAll target submissions approved
ActiveExpiredblock.timestamp >= expiresAt
ActiveCancelledCreator calls cancelBounty() (after 48h cooldown)

Step-by-Step

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

  2. Submit: Contributors submit data off-chain. Each submission is identified by a bytes32 hash.

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

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

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

ParameterValue
Minimum Stake1,000 PRIV
Lock Duration30 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 oracles

Oracles 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 whenNotPaused

Slashing

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:

DestinationShare
Burn40%
Stakers (PRIVStakingV2)35%
Treasury25%

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)
ParameterTypeDescription
pricePerSubmissionuint256Base price per submission in PRIV (before discount)
targetSubmissionsuint256Number of submissions needed
durationuint256Bounty duration in seconds (1 day -- 365 days)
metadataHashbytes32IPFS hash of the bounty specification
tierPaymentTierPayment tier (0=Standard, 1=DirectPRIV, 2=StakedPRIV)
requiredBadgeIduint256Required PrivBadge ID (0 for no requirement)

Requirements:

  • pricePerSubmission >= 0.01 PRIV
  • targetSubmissions > 0
  • duration between 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 nonReentrant

Requirements:

  • 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 nonReentrant

Requirements:

  • 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 whenNotPaused

unstakeAsCreator

Withdraw staked PRIV after the 30-day lock period.

function unstakeAsCreator(uint256 amount) external nonReentrant

Contributor Functions

claimPayouts

Claim timelocked payouts that have passed their unlock time.

function claimPayouts(uint256[] calldata payoutIndices) external nonReentrant whenNotPaused

Oracle 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
ParameterTypeDescription
bountyIduint256The bounty to approve against
contributoraddressThe contributor receiving payout
submissionHashbytes32Off-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() external

View 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 onlyOwner

setMinOracleConfirmations

Set the M-of-N threshold for submission approvals.

function setMinOracleConfirmations(uint256 _min) external onlyOwner

setFeeManager

Update the FeeManager contract address.

function setFeeManager(address _feeManager) external onlyOwner

setPrivBadges

Set the PrivBadges contract for skill-gated bounties.

function setPrivBadges(address _privBadges) external onlyOwner

setApproverRegistry

Set the ApproverRegistry for timelocked payouts and slashing.

function setApproverRegistry(address _registry) external onlyOwner

pause / unpause

Emergency pause controls.

function pause() external onlyOwner
function unpause() external onlyOwner

Events

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 slashed

Usage 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

  1. Escrow Isolation: All bounty deposits are tracked per-bounty. The totalEscrowBalance state variable tracks the aggregate, and each bounty's remaining balance is computed from its own totalDeposit - totalPaidOut - protocolFeePaid.

  2. Double-Approval Prevention: Each submission hash can only be approved once per bounty via the submissionApproved mapping. Each oracle can only confirm a given submission once via the oracleConfirmed mapping.

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

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

  5. Reentrancy Protection: All fund-moving functions (create, cancel, withdraw, approve, claim, slash) use the nonReentrant modifier.

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

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


Source Code

View on GitHub