Core Contracts
Two contracts handle all onchain coordination, deployed on Ethereum (Sepolia testnet).
ProjectRegistry
Stores the canonical onchain state for every project in the network. Radicle project IDs are append-only — once registered, the association between an onchain project and its Radicle repo(s) is permanent and auditable.
State:mapping(uint256 => Project)— project data keyed by numeric IDmapping(uint256 => mapping(address => bool)) maintainers— per-project maintainer rolesmapping(uint256 => mapping(address => bool)) allowlist— per-project bounty-creation allowlist
struct Project {
address owner;
string name;
string description;
string[] radicleIds; // append-only; index 0 is the canonical Radicle ID
}ProjectRegistry
├── createProject(radicleId, name, description) → projectId
├── addRadicleId(projectId, radicleId) // owner only; append-only
├── addMaintainer(projectId, address) // owner only
├── removeMaintainer(projectId, address) // owner only
├── updateAllowlist(projectId, address, bool) // owner only
├── transferOwnership(projectId, newOwner) // owner only
├── canCreateBounty(projectId, caller) → bool // view
├── canManageBounty(projectId, caller) → bool // view
├── getProject(projectId) → (owner, name, description, radicleIds[])
└── getRadicleIds(projectId) → string[]ProjectCreated(projectId, owner, radicleId, name, description)
RadicleIdAdded(projectId, radicleId)
MaintainerAdded(projectId, maintainer)
MaintainerRemoved(projectId, maintainer)
AllowlistUpdated(projectId, wallet, allowed)
OwnershipTransferred(projectId, previousOwner, newOwner)| Action | Who can call |
|---|---|
createBounty (on BountyEscrow) | Owner, maintainer, or allowlisted wallet |
acceptContribution / cancelBounty | Owner or maintainer only |
addMaintainer, updateAllowlist, etc. | Owner only |
BountyEscrow
Holds and distributes ETH rewards for completed work. Reads permissions from ProjectRegistry at call time — no stale per-bounty maintainer state.
struct Bounty {
address creator;
uint256 projectId; // onchain ProjectRegistry ID
uint256 rewardAmount; // promised reward in wei
uint256 fundedAmount; // actual ETH held in escrow
BountyStatus status; // OPEN | FUNDED | ACCEPTED | CANCELLED
}BountyEscrow
├── createBounty(projectId, rewardAmount) → bountyId // owner/maintainer/allowlisted only
├── fundBounty(bountyId) payable // anyone
├── acceptContribution(bountyId, contributor, patchId) // owner/maintainer only
├── cancelBounty(bountyId) // owner/maintainer only
└── getBounty(bountyId) → BountyBountyCreated(bountyId, creator, projectId, rewardAmount)
BountyFunded(bountyId, funder, amount)
ContributionAccepted(bountyId, contributor, patchId, rewardAmount)
BountyCancelled(bountyId, caller, refundAmount)createBountyreverts withNotAuthorizedif the caller is not the project owner, a maintainer, or on the allowlistacceptContributionandcancelBountyrevert withNotAuthorizedif the caller is only allowlisted (not owner or maintainer)fundBountyis unrestricted — anyone can contribute ETH to any open bounty- On
acceptContribution, the fullfundedAmountis transferred tocontributor; oncancelBounty, it is refunded tocreator - Both use
ReentrancyGuard
Deployment
Deploy in order — BountyEscrow constructor requires the ProjectRegistry address:
# 1. Deploy ProjectRegistry
forge script script/DeployProjectRegistry.s.sol \
--rpc-url $SEPOLIA_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY \
--broadcast
# 2. Deploy BountyEscrow (reads PROJECT_REGISTRY_ADDRESS from env)
forge script script/DeployBountyEscrow.s.sol \
--rpc-url $SEPOLIA_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY \
--broadcastAfter deploying, update all four contract address vars in .env and in your Railway environment:
PROJECT_REGISTRY_ADDRESS=0x...
PROJECT_REGISTRY_START_BLOCK=...
BOUNTY_ESCROW_ADDRESS=0x...
BOUNTY_ESCROW_START_BLOCK=...
NEXT_PUBLIC_PROJECT_REGISTRY_ADDRESS=0x...
NEXT_PUBLIC_BOUNTY_ESCROW_ADDRESS=0x...