Where Open Source Meets Finance.
MergeFi lets sponsors fund open-source work, maintainers turn GitHub issues
into paid bounties, and contributors get paid automatically when their work
is merged. GitHub stays the system of record for code — who opened what,
who merged what, what got approved. This repository is the financial
layer: a set of Soroban smart contracts on the Stellar network that hold
sponsor funds in escrow and release them according to rules that a
trusted, off-chain oracle (the sibling mergefi-backend service) reports.
Flow, end to end:
- A sponsor funds a GitHub issue (or a milestone, or a repo's ongoing maintenance pool) by depositing a Stellar token into one of these contracts.
- A maintainer marks the issue as bounty-eligible and a contributor does the work, exactly as they would on any other GitHub project.
mergefi-backendwatches GitHub webhooks. When it sees the PR referencing the issue get merged/accepted, it callsrelease(orrelease_issue, orwithdraw) on the relevant contract, authenticated as the contract's configured admin/oracle address.- The contract pays the contributor(s) — split across a team if the bounty had multiple collaborators — deducts a small protocol fee to the treasury, and marks the escrow as paid. Double-payment and already-refunded states are rejected at the contract level, so the worst the backend can do is retry a call safely.
- If an issue is cancelled or nobody finishes the work before its deadline, the sponsor (or, after expiry, anyone) can trigger a refund.
GitHub remains the source of truth for whether work happened. These contracts are deliberately dumb about that — they only know what the oracle tells them — and focus entirely on holding and moving money correctly.
The spec allows team-splits/milestones to be either separate contracts or
modules in one. This repo ships them as three independent contract
crates — mergefi-escrow, mergefi-milestones, mergefi-maintenance-pool
— reasoning:
- Different lifecycles. An escrow is single-issue, single-payout,
bounded by a deadline. A milestone is a lump sum sliced across many
issues in a release, closed once. A maintenance pool is open-ended and
repeatedly topped up — it never "finishes." Cramming all three into one
contract's storage model would mean one bloated
DataKeyenum and a lot of variants that don't apply to most calls. - Independent upgrade/audit surface. If a bug is found in milestone allocation logic, you can fix and redeploy that contract without touching escrow funds that are mid-flight.
- Team-splits are not a separate contract. They're a parameter shape
(
Vec<(Address, u32 basis_points)>) accepted byrelease/release_issuein both the escrow and milestone contracts. A single bounty and a team bounty are the same code path; the only difference is how many recipients are in the vector.
The tradeoff: the basis-point split math and fee-deduction logic
(compute_split) is duplicated between mergefi-escrow and
mergefi-milestones rather than shared via a common library crate. For a
codebase this size the duplication is small and readable; the natural
next step if it grows is to extract a mergefi-common crate with shared
types/helpers, imported as a normal (non-contract) Rust dependency by each
contract crate. Noted under Roadmap.
Independence has a cost this section didn't previously name: the three
contracts share no registry and never call each other, so nothing
on-chain stops the same issue_id from being funded twice through two
different instruments — once via escrow::fund(issue_id, ...) and again
via milestones::allocate(milestone_id, issue_id, ...) for some release
milestone. Neither contract's storage namespace (DataKey in
contracts/escrow/src/types.rs vs contracts/milestones/src/types.rs)
overlaps with the other's, and neither contains a contract-id reference to,
or Env::invoke_contract call into, the other. Both can independently
reach release/release_issue and pay out in full for what is, off
GitHub, a single piece of work being compensated twice.
Three ways to close or accept this gap were considered:
- A shared on-chain registry contract — a fourth, minimal contract
whose only job is "claim
issue_idX for contract Y," called byfundandallocatebefore either proceeds. This closes the gap on-chain, but reintroduces the cross-contract calls this design otherwise avoids everywhere else, and makes all three contracts' liveness depend on a fourth one — exactly the coupling the "Independent upgrade/audit surface" reasoning above argues against. - A shared library crate with a common
DataKeyconvention — lower coupling than a live registry contract, but doesn't actually close the gap by itself: without a cross-contract call (or a single shared storage instance both contracts write to, which reintroduces the coupling above by another name), a shared type doesn't stop two independently-deployed contract instances from writing incompatible state that neither can see the other wrote. - Accept the gap on-chain; mitigate at the backend layer.
mergefi-backendalready watches everyfundandallocatecall as the system of record for GitHub state, so it's the one component with a natural, already-required view of "is this issue committed anywhere" — and can refuse to originate a second commitment for anissue_idit already tracks as funded or allocated. No new contract, no new coupling.
Decision: the third option. It's the one actually consistent with
this section's own reasoning above — independence was chosen
deliberately, and a shared registry, on-chain or otherwise, reintroduces
the exact coupling that tradeoff was meant to avoid. The contracts
themselves make no attempt to detect this collision; mergefi-backend
is responsible for refusing to originate a second commitment for an
issue_id it already has on record as funded or allocated by either
contract. This is a known, accepted limitation of the independent-
contracts design, not an oversight: if mergefi-backend's own database is
ever wrong, out of sync, or bypassed, nothing on-chain provides a second
line of defense against the same issue being paid out twice through two
different instruments. See the within-mergefi-milestones
double-allocation gap (narrower, single-contract-scoped, and fixable
independently of this cross-contract question) for the more contained
sibling of this issue.
Team payouts use integer token amounts, so distributable * bps / 10000
can leave rounding dust. Earlier versions assigned all accumulated dust to
the final recipient in the caller-supplied vector. That avoided stranded
funds, but made recipient order economically relevant.
compute_split now uses a largest-remainder allocation in both escrow and
milestone releases:
- each recipient first receives
floor(distributable * bps / 10000); - the remaining dust is always less than
recipients.len()token-minor units, because the basis points sum to exactly 10000; - those units are assigned to the largest fractional remainders, with stable input-order tie-breaking.
This preserves the invariant that sum(shares) == distributable without
systematically rewarding the final recipient.
Core single-issue bounty escrow.
fn initialize(env, admin: Address, treasury: Address, fee_bps: u32) -> Result<(), Error>;
fn fund(env, issue_id: u64, sponsor: Address, token: Address, amount: i128, deadline: u64) -> Result<(), Error>;
fn contribute(env, issue_id: u64, sponsor: Address, amount: i128) -> Result<(), Error>;
fn release(env, issue_id: u64, recipients: Vec<(Address, u32)>) -> Result<(), Error>;
fn refund(env, issue_id: u64) -> Result<(), Error>;
fn extend_deadline(env, issue_id: u64, caller: Address, new_deadline: u64) -> Result<(), Error>;
fn get_escrow(env, issue_id: u64) -> Result<Escrow, Error>;
fn get_contribution(env, issue_id: u64, index: u32) -> Result<Contribution, Error>;
fn get_admin(env) -> Result<Address, Error>;
fn get_treasury(env) -> Result<Address, Error>;
fn get_fee_bps(env) -> Result<u32, Error>;fund:sponsor.require_auth(). Transfersamountoftokenfrom the sponsor into the contract and creates the escrow. One escrow perissue_id— a secondfundcall on the same id is rejected (AlreadyFunded); every sponsor after the first usescontributeinstead.contribute:sponsor.require_auth(). Adds an additional sponsor's funds to an already-funded escrow — this is how crowdfunding a singleissue_idacross several sponsors works. Uses the token already recorded on the escrow (notokenparam, so a top-up can't silently use a different asset). Each contribution is recorded individually (Contribution { sponsor, amount }, queryable viaget_contribution) sorefundcan return each sponsor's own amount to their own address. Capped atMAX_SPONSORS(20) distinct contributions per escrow (TooManySponsorsotherwise). RejectsAlreadyPaid/AlreadyRefunded. Seedocs/escrow-crowdfunding-design.mdfor the full design reasoning.release: admin-only (require_authon the stored admin/oracle address).recipientsbasis points must sum to exactly 10000 or the call is rejected (InvalidSplit) — this is how team-bounty payouts work, a single recipient at 10000 bps is just the single-payee case. Deductsfee_bpsoff the top to the treasury, splits the rest pro-rata, with the last recipient absorbing integer-division remainder so no dust is stranded in the contract. Pays out the full crowdfunded total (escrow.amount, the sum of every contribution) regardless of how many sponsors contributed. RejectsAlreadyPaid/AlreadyRefunded.refund: every contributor gets back exactly what they put in, to their own address — not an even split and not the full amount to a single sponsor. Callable by the admin at any time (e.g. issue cancelled), or by anyone oncedeadlinehas passed — refund is sponsor-protective, so it deliberately doesn't require any contributor's own signature. RejectsAlreadyPaid/AlreadyRefunded. Seedocs/refund-permissionless-analysis.mdfor the economics/griefing analysis of the permissionless path.extend_deadline:caller.require_auth(), andcallermust be any current contributor to the escrow (not necessarily the originalfundcaller) — rejected withUnauthorizedotherwise. Lets a contributor push the shareddeadlinelater if the group wants more time beforerefund's permissionless path opens —new_deadlinemust be strictly later than both the stored deadline and the current ledger time, so it can only delay that window, never shorten it. RejectsAlreadyPaid/AlreadyRefunded. Seedocs/escrow-crowdfunding-design.mdfor why any single contributor (rather than unanimous or weighted consent) can extend.
Lump-sum budget shared across the issues in a release.
fn initialize(env, admin: Address, treasury: Address, fee_bps: u32) -> Result<(), Error>;
fn create_milestone(env, milestone_id: u64, sponsor: Address, token: Address, total_budget: i128) -> Result<(), Error>;
fn contribute(env, milestone_id: u64, sponsor: Address, amount: i128) -> Result<(), Error>;
fn allocate(env, milestone_id: u64, issue_id: u64, amount: i128) -> Result<(), Error>;
fn release_issue(env, milestone_id: u64, issue_id: u64, recipients: Vec<(Address, u32)>) -> Result<(), Error>;
fn cancel_milestone(env, milestone_id: u64) -> Result<(), Error>;
fn get_milestone(env, milestone_id: u64) -> Result<Milestone, Error>;
fn get_issue_status(env, milestone_id: u64, issue_id: u64) -> Result<IssueStatus, Error>;
fn get_contribution(env, milestone_id: u64, index: u32) -> Result<Contribution, Error>;create_milestone: the original sponsor depositstotal_budgetonce; the pool starts fully unallocated (remaining_budget == total_budget). One milestone permilestone_id— a secondcreate_milestoneon the same id is rejected; every sponsor after the first usescontributeinstead.contribute:sponsor.require_auth(). Adds an additional sponsor's funds to an already-create_milestoned pool — this is how crowdfunding a release across several sponsors works. Uses the token already recorded on the milestone (notokenparam, so a top-up can't silently use a different asset). New funds arrive unallocated, so bothtotal_budgetandremaining_budgetgrow by the contribution. Each contribution is recorded individually (Contribution { sponsor, amount }, queryable viaget_contribution, with the original funder always at index 0) so a cancellation refund can return each sponsor's proportional share to their own address. Capped atMAX_SPONSORS(20) distinct contributions per milestone (TooManySponsorsotherwise). RejectsMilestoneClosed. Seedocs/milestones-crowdfunding-design.mdfor the full design reasoning.allocate: admin-only. Reserves a slice ofremaining_budgetfor a specificissue_id. Over-allocating past what's left is rejected (OverAllocation); allocating an issue twice is rejected (IssueAlreadyAllocated).release_issue: admin-only, same split/fee mechanics as escrow'srelease, but draws from the issue's pre-reserved allocation rather than a fresh deposit. Rejects double release (IssueAlreadyReleased).cancel_milestone: admin-only. Refunds whatever is left inremaining_budget(i.e. never allocated) back to the contributors in proportion to what each one put in —remaining_budget * contribution / total_budget, with largest-remainder rounding so no dust is stranded — and closes the milestone; already-released issues are unaffected since their funds already left the contract. A single-sponsor milestone is the degenerate case (the whole remainder goes back to the one sponsor, as before). Seedocs/milestones-crowdfunding-design.mdfor the proportional accounting and why it stays correct across allocate/release cycles.
Recurring, open-ended funding tied to a repo/org rather than one issue.
fn initialize(env, admin: Address, treasury: Address, fee_bps: u32) -> Result<(), Error>;
fn deposit(env, pool_id: u64, sponsor: Address, token: Address, amount: i128) -> Result<(), Error>;
fn withdraw(env, pool_id: u64, recipient: Address, amount: i128) -> Result<(), Error>;
fn get_pool(env, pool_id: u64) -> Result<MaintenancePool, Error>;
fn get_deposit(env, pool_id: u64, index: u32) -> Result<Deposit, Error>;pool_idis an off-chain-assigned identifier for a repo or org (e.g. a hash ofowner/repo, minted bymergefi-backend) — not tied to any single issue.deposit: any sponsor can call repeatedly; the pool is created implicitly on first deposit. All deposits after the first must use the sametoken(TokenMismatchotherwise). Every deposit is recorded (Deposit { sponsor, amount, timestamp }, indexed by an incrementing counter) so the full contribution history is queryable.withdraw: admin-only — the backend authorizes a maintainer draw-down for completed maintenance work (this is not tied to a specific PR merge the way escrow/milestones are; it's off-chain-adjudicated "maintenance credit"). Deducts the fee, rejects ifamountexceeds the pool's current balance (InsufficientBalance).
// escrow
pub enum EscrowStatus { Funded, Paid, Refunded }
pub struct Escrow {
pub token: Address,
pub amount: i128, // sum of every contribution accepted so far
pub status: EscrowStatus,
pub created_at: u64,
pub deadline: u64,
pub contributor_count: u32, // enumerate via get_contribution(0..contributor_count)
}
pub struct Contribution {
pub sponsor: Address,
pub amount: i128,
}
// milestones
pub struct Milestone {
pub sponsor: Address, // original funder; always contribution index 0
pub token: Address,
pub total_budget: i128, // sum of every contribution accepted so far
pub remaining_budget: i128, // unallocated remainder, refunded on cancel
pub created_at: u64,
pub closed: bool,
pub allocations: Map<u64, i128>, // issue_id -> allocated amount
pub contributor_count: u32, // enumerate via get_contribution(0..contributor_count)
}
pub struct Contribution {
pub sponsor: Address,
pub amount: i128,
}
pub enum IssueStatus { Allocated, Released }
// maintenance-pool
pub struct MaintenancePool {
pub token: Address,
pub balance: i128,
pub total_deposited: i128,
pub total_withdrawn: i128,
pub created_at: u64,
pub deposit_count: u32,
}
pub struct Deposit {
pub sponsor: Address,
pub amount: i128,
pub timestamp: u64,
}Each contract's config (Admin, Treasury, FeeBps) lives in instance
storage (small, always loaded with the contract). Per-issue/milestone/pool
records live in persistent storage keyed by an enum (DataKey) so they
survive independently and can be individually TTL-extended
(extend_ttl(..., 100_000, 500_000) ledgers, i.e. re-bumped well before
archival, tuned for a multi-month bounty/release lifecycle).
- Admin / oracle authorization. One
Address(admin), set once atinitializeand immutable thereafter, represents themergefi-backendservice. All state-changing calls that assert "the reported off-chain event actually happened" (release,release_issue, earlyrefund,allocate,withdraw) requireadmin.require_auth(). Soroban'srequire_authmeans the backend's signing key must actually authorize that specific invocation — there's no way to spoof it by simply calling the contract from an arbitrary account. - Sponsor authorization.
fund,create_milestone, anddepositrequire the sponsor's ownrequire_auth()— a backend key can never move a sponsor's funds into escrow on their behalf without their signature (only out, once deposited, per the payout rules above). - No re-initialization.
initializechecksstorage().instance().has(&DataKey::Admin)and rejects withAlreadyInitializedif already set, so admin/treasury/fee can't be silently swapped out post-deployment by callinginitializeagain. initializerequires the named admin's own authorization. All three contracts'initializecalladmin.require_auth(), so nobody can name a third-party address as admin without that address's consent. This is a narrower guarantee than it might sound like — it does not prevent an attacker from front-running the legitimate deployer'sinitializecall by naming themselves as admin instead, since they can trivially authorize their own address. Seedocs/access-control-audit.mdfor the full analysis and why closing that race requires a structural change (an atomic deploy+init constructor) rather than an in-contract check.- Fee mechanics.
fee_bpsis basis points (1/100 of a percent) out of 10000, validated<= 10000atinitialize. It's deducted from the top of every payout (release,release_issue,withdraw) before the remainder is split among recipients — the treasury is paid in the same transaction as the recipients, so there's no separate "sweep fees" step that could be skipped. - Replay / double-spend protection. Every escrow/milestone-issue
carries an explicit status (
Funded → Paid | Refunded, orAllocated → Released).release/release_issue/refundall check this status first and reject (AlreadyPaid,AlreadyRefunded,IssueAlreadyReleased) rather than trusting the caller not to invoke twice — this is what makes it safe for the backend to retry a failed/uncertain call. - Deadline handling.
deadlineis a ledger timestamp (env.ledger().timestamp(), Unix seconds) set by the sponsor atfundtime. Before the deadline, only the admin can force a refund (e.g. issue explicitly cancelled). After the deadline, anyone can callrefund— it always pays out to the original sponsor address stored in the record, never the caller, so permissionless-after-expiry doesn't create a theft vector; it just removes the backend as a liveness dependency for getting stuck funds back. The sponsor can callextend_deadlineat any point before the escrow is paid or refunded to push their own deadline later (never earlier) if they want more time — seedocs/refund-permissionless-analysis.md. - Split validation. Basis points across all recipients in a
releasecall must sum to exactly10_000; anything else is rejected (InvalidSplit) before any tokens move. An empty recipients vector is also rejected rather than silently paying no one. - Token transfers go through the standard Soroban token interface
(
soroban_sdk::token::Client, compatible with the Stellar Asset Contract and any SEP-41-compliant custom token), so these contracts work with any asset issued on Stellar, not just XLM.
mergefi-backend is expected to hold the admin keypair for each
deployed contract (escrow, milestones, maintenance-pool — these can share
one admin key or use separate ones per environment) and drive them over
Soroban RPC using stellar-sdk / soroban-client (or the Rust
soroban-cli/soroban_rpc client, if the backend is Rust). Typical
integration points:
- On issue funded (Stellar payment observed / sponsor UI flow):
nothing to do here —
fund/create_milestone/depositare called directly by the sponsor's wallet, not by the backend. The backend just indexes the resulting contract events /get_escrowstate to reflect funding status in the product UI. - On PR merged (GitHub webhook): backend resolves which
issue_id/milestone_idthe merged PR is tied to, resolves the contributor(s) and their split (single payee, or a team split it computed from co-author metadata / maintainer input), builds arelease/release_issueinvocation, signs it with the admin key, and submits it via Soroban RPC (simulateTransaction→sendTransaction). It should treat the call as idempotent — the contract itself rejects double-release, so a retry after a network timeout is safe to just re-send. - On issue closed without merge / deadline passed: backend calls
refund(admin path) or lets it sit — since refund is permissionless afterdeadline, the backend doesn't strictly need to call it at all once expired, though it likely does for UX (so sponsors don't have to trigger it manually). - Maintenance draw-downs: backend authorizes
withdrawagainst a pool when it determines (via its own off-chain rules — e.g. a maintainer's recurring stipend, or a one-off review-load payout) that a maintainer should be paid from the standing pool. - Reading state: all
get_*view functions are free simulated calls (no signature/fee) and are the primary way the backend/API layer keeps its own database in sync with on-chain truth after any write.
- Rust (this repo was built/tested against
rustc 1.95.0). - The
wasm32v1-nonetarget for building deployable contract wasm:rustup target add wasm32v1-none. (Soroban's host requires this target rather than the legacywasm32-unknown-unknownon Rust 1.82+ —soroban-sdk's build script will tell you this explicitly if you try the wrong one.) stellar-cli(the successor tosoroban-cli) forcontract deploy/contract invokeagainst testnet/mainnet. Not installed in the environment this repo was built in — deploy steps below are documented but untested-in-this-session; contract compilation and all unit tests were verified without it.
make build # cargo build --target wasm32v1-none --release, all 3 contracts
make test # cargo test --workspace (native target, no wasm needed)
make deploy # example stellar contract deploy calls, see MakefileOr directly:
cargo test --workspace
cargo build --target wasm32v1-none --release \
-p mergefi-escrow -p mergefi-milestones -p mergefi-maintenance-poolVerified in this session: cargo test --workspace — 54/54 tests pass
(28 escrow, 19 milestones, 7 maintenance-pool, including the
access-control boundary matrix added in #30 and the multi-sponsor
crowdfunding tests added in #57/#58) on the native target using
soroban_sdk::testutils (Env::default(), Address::generate,
mock_all_auths, register_stellar_asset_contract_v2 for a test token).
The wasm32v1-none release build was also verified — all three contracts
compile to .wasm in target/wasm32v1-none/release/.
All three contracts are deployed and initialized on testnet as of this
writing. stellar-cli's HTTP client couldn't reach the RPC endpoint from
the environment this was deployed from (a local TLS/cert issue, not a
Stellar-side problem), so scripts/deploy.mjs and scripts/invoke.mjs
(thin wrappers around @stellar/stellar-sdk) were used instead to
perform the same upload → create-contract → initialize flow the CLI
would otherwise do.
| Contract | Contract ID |
|---|---|
mergefi-escrow |
CAY77D2SFDVQYONSPYHOEWARE3UIWQDYHWWI2WXNPFBLBKR2Q4GEWXFB |
mergefi-milestones |
CBBRLSL6TM6XCNP2XBVT4GFHJ3NNPFKI2BCZQJ4U3TI7GV7DO2F2HG6F |
mergefi-maintenance-pool |
CD46U7WTEM2I77TXQI2VIBRQXOHEFEYYR2XFA7OVGTXX5M2F7Z3ZQOX2 |
All three were initialized with the same admin/treasury address
(GBUXADZJ7O4NM7S7CDZYVXGP37M772D2TYMFBT2QFH2JSRCFEJPAVW5N, a
throwaway testnet-only account) and a 250 bps (2.5%) treasury fee.
View them on
Stellar Expert.
To redeploy (e.g. after a contract change), once stellar-cli has
working network access:
stellar keys generate mergefi-admin --network testnet --fund
stellar contract deploy \
--wasm target/wasm32v1-none/release/mergefi_escrow.wasm \
--source mergefi-admin \
--network testnet
# then, e.g.
stellar contract invoke \
--id <CONTRACT_ID> --source mergefi-admin --network testnet \
-- initialize --admin <ADMIN_G...> --treasury <TREASURY_G...> --fee_bps 250Or, in an environment where the CLI's own network calls are blocked but
plain Node.js fetch works (as was the case here):
node scripts/deploy.mjs <SECRET_KEY> target/wasm32v1-none/release/mergefi_escrow.wasm escrow
node scripts/invoke.mjs <SECRET_KEY> <CONTRACT_ID> initialize \
address:<ADMIN_G...> address:<TREASURY_G...> u32:250- Extract shared split/fee math (
compute_split) into a common non-contract Rust crate to remove the duplication betweenmergefi-escrowandmergefi-milestonesnoted above. - Emit contract events (
env.events().publish(...)) on fund/release/refund so the backend can index state changes from the ledger directly instead of only pollingget_*view calls. - Consider a two-key admin model (oracle key for routine
releasecalls, separate higher-trust key forinitialize/admin rotation) once the contracts move past initial testnet iteration. - Support partial milestone/pool refunds and issue re-allocation
(currently
allocateis one-shot per issue). - Add integration tests against
stellar-cli's local sandbox network once available, to validate actual RPC-level invocation from amergefi-backend-shaped client rather than onlytestutils. - Revisit the accepted cross-contract double-funding gap (see "Why three contracts instead of one" → "Cross-contract double-funding") if backend- layer mitigation ever proves insufficient in practice — the shared on-chain registry contract considered and rejected there remains the fallback if a stronger, on-chain guarantee becomes worth the coupling cost.