This document analyses known attack vectors against the MergeMint contract, rates their severity, describes current mitigations, and identifies residual risk. It covers the current no-escrow design (the contract never holds a token balance; the verifier pushes tokens directly from their own wallet) as well as the planned escrow model where the contract will custody funds.
| Rating | Meaning |
|---|---|
| Critical | Direct, unconditional fund loss or unauthorised privilege escalation |
| High | Likely fund loss or state corruption under realistic conditions |
| Medium | Requires unusual preconditions or yields only partial impact |
| Low | Negligible financial impact; primarily affects data integrity |
Severity: High
Description: The verifier role is unconstrained by the contract — any address that calls complete_bounty and has a sufficient token balance can pay a reward. A verifier and a bounty creator (or a verifier acting alone) can therefore collude: they designate a fake contributor as assignee via claim_bounty, then the verifier calls complete_bounty to transfer tokens to that address and award +10 reputation — all without any genuine work having been done. Because the contract has no on-chain way to verify off-chain contribution quality, this is a social-trust attack rather than a code flaw.
Affected functions: claim_bounty, complete_bounty
Current mitigations:
- All parties must individually authenticate with
require_auth(), so no single actor can execute the attack alone — at least two colluding parties are required (verifier + contributor, or creator + contributor with creator also acting as verifier). - Events (
bounty_claimed,bounty_completed,reward_paid) are emitted on-chain and consumed by the MergeMint API indexer, giving off-chain observers an audit trail.
Residual risk: High. Collusion cannot be detected or prevented purely on-chain. Off-chain reputation systems, dispute resolution, and community governance are the necessary backstop. See also threat vector #3 (self-verify) for the degenerate single-actor case.
Severity: Medium
Description: On Stellar, multiple transactions can land in the same ledger. If a legitimate contributor broadcasts claim_bounty and a malicious observer monitors the network and submits a competing claim_bounty for the same bounty with a higher fee, the malicious transaction may be ordered first within that ledger. The attacker claims the bounty slot before the legitimate contributor.
Affected functions: claim_bounty
Current mitigations:
- The double-assignment guard (
assignees.len() >= max_assignees) ensures only one claim succeeds for a single-assignee bounty — the second transaction will panic cleanly withBOUNTY_ALREADY_ASSIGNED, preventing silent data corruption. - Stellar's ledger ordering is not purely fee-based and is harder to predict than Ethereum's mempool, reducing reliable front-running opportunity.
Residual risk: Medium. A well-resourced attacker with low-latency network access can still outrun a legitimate claim. The protocol does not whitelist specific contributors per bounty. Integrators who need claim exclusivity should add an off-chain reservation layer or implement allowlist logic before calling claim_bounty.
Severity: Medium
Description: There is no on-chain check preventing a bounty creator from also being the contributor who calls claim_bounty. A creator could therefore post a bounty, claim it themselves, then coordinate with a verifier to complete_bounty — earning the reward back plus +10 reputation for no external work. The error constant CREATOR_CANNOT_CLAIM exists in src/errors.rs but is not enforced anywhere in contract.rs.
Affected functions: claim_bounty
Current mitigations: None enforced on-chain.
Residual risk: High. This is an unmitigated on-chain vulnerability. The fix is a single guard at the top of claim_bounty:
if contributor == bounty.creator {
panic!("{}", errors::CREATOR_CANNOT_CLAIM);
}Until this guard is added, off-chain tooling (MergeMint API, front-end) must reject self-claims as a compensating control.
Severity: High — FIXED in this PR
Description: There was no on-chain check preventing the verifier who calls complete_bounty from also being one of the bounty's assignees. In this scenario the verifier calls token.transfer(&verifier, &assignee, &payout) where both sides resolve to their own address — a net-zero token movement — but they still receive +10 reputation and an increment to contribution_count and total_earned. The error constant VERIFIER_CANNOT_BE_ASSIGNEE existed in src/errors.rs but was not enforced in contract.rs.
Affected functions: complete_bounty
Fix applied: A guard iterates the assignees list at the start of complete_bounty (before any token transfer) and panics with "verifier cannot be the assignee" if the verifier address matches any assignee.
for (assignee, _) in bounty.assignees.iter() {
if assignee == verifier {
panic!("verifier cannot be the assignee");
}
}Test added: test_assignee_cannot_self_verify in src/test.rs — asserts that complete_bounty panics with "verifier cannot be the assignee" when the contributor calls the function using their own address as verifier.
Residual risk: None in the current no-escrow model. Once escrow is introduced this guard prevents a full contract drain — see escrow checklist.
Severity: High — FIXED in this PR
Description: complete_bounty did not validate that bounty.status == STATUS_IN_PROGRESS before executing. After the first successful call the status was written as STATUS_COMPLETED and stored, but the assignees list remained populated. A second call by the same verifier therefore passed the assignees.is_empty() guard, executed another token.transfer for each assignee, and incremented every assignee's reputation, total_earned, and contribution_count again. This could be repeated as many times as the verifier held tokens.
Affected functions: complete_bounty
Fix applied: A status guard is now the first business-logic check in complete_bounty (immediately after require_auth and the bounty-not-found check). If the bounty is not in STATUS_IN_PROGRESS, the function panics before any state mutation or token transfer.
if bounty.status != Symbol::new(&env, STATUS_IN_PROGRESS) {
panic!("{}", errors::BOUNTY_NOT_IN_PROGRESS);
}Test added: test_double_complete_panics in src/test.rs — creates a bounty, claims it, completes it once (succeeds), then calls complete_bounty again and asserts a panic with "bounty is not in progress".
Residual risk: None. The guard blocks all repeat calls regardless of caller. Under escrow this guard also prevents draining the contract's entire token balance.
Severity: Low (current model), High (escrow model)
Description: complete_bounty calls token.transfer before updating bounty.status to STATUS_COMPLETED and persisting the change. In the EVM this ordering would be a critical reentrancy bug. On Soroban, the runtime enforces single-contract-at-a-time execution — a token contract cannot call back into MergeMintContract during the same invocation — eliminating classic reentrancy in the current design.
However, when the escrow model is introduced (the contract holds token balances), this ordering will become critical. Any escape to an external contract before the status is written creates a reentrancy window on future Soroban versions or cross-contract paths that may be introduced.
Affected functions: complete_bounty
Current mitigations:
- Soroban's single-contract execution model prevents reentrancy in the current runtime.
Residual risk: Low now, Critical under escrow. Enforce checks-effects-interactions proactively: write bounty.status = STATUS_COMPLETED and call storage::store_bounty before any token.transfer call. This also eliminates the double-completion vulnerability (#5) as a side effect.
Severity: Low
Description: An actor with control over both a verifier key and multiple contributor keys can create many low-value bounties, claim each with a different contributor key, then call complete_bounty for each. Each completion awards +10 reputation and +1 contribution_count. Because reward_amount can be arbitrarily small (no minimum is enforced), the economic cost per reputation point approaches zero.
Affected functions: create_bounty, complete_bounty
Current mitigations:
- Each
claim_bountycall requires a unique contributor address (duplicate-address guard is enforced). - The
active_claimslimit (currently 1 per contributor) prevents a single address from gaming the count without completing prior bounties. - Transaction fees on Stellar impose a small but non-zero cost per operation.
Residual risk: Medium. An attacker with many keys can still inflate reputation cheaply. Mitigations include enforcing a minimum reward_amount in create_bounty and adding a CONTRIBUTOR_HAS_ACTIVE_CLAIM style cap on total lifetime completions per verifier.
When escrow is introduced the contract will hold tokens on behalf of bounty creators (create_bounty transfers reward tokens into the contract; complete_bounty and cancel_bounty transfer them out). This changes the threat surface significantly.
The contract's token balance for any given token must always equal the sum of
reward_amountacross all bounties inopenorin_progressstatus that use that token.
Maintaining this invariant is the primary correctness goal for all escrow-related code paths. Any deviation — even transient — represents a fund safety bug.
Description: A bug prevents a bounty from ever reaching completed or cancelled, locking the escrowed tokens permanently.
Example scenarios:
cancel_bountypanics unconditionally due to a logic error.- Status index corruption leaves a bounty in an unresolvable state.
- A missing code path for a status transition leaves a bounty stuck.
Mitigations:
- Ensure
cancel_bountyis callable by the creator for any bounty inopenorin_progressstatus — it must always provide an exit. - Consider a verifier-only emergency cancel path as a backstop.
- Write invariant-checking tests that verify the contract balance equals the sum of open bounty rewards after every state transition.
Description: An attacker triggers multiple payouts for a single bounty, draining more tokens than the bounty's reward_amount.
Mitigations:
- Enforce checks-effects-interactions strictly: update the bounty status to
completedand persist it before callingtoken.transfer. - Validate that
bounty.status == STATUS_IN_PROGRESSat the top ofcomplete_bounty— this prevents double-completion even without reentrancy guards. This is also the fix for threat #5 in the current model.
Description: A malicious actor creates many bounties (locking the minimum viable reward in each) and immediately cancels them, burning transaction fees and polluting the status index.
Mitigations:
- Enforce a minimum
reward_amountincreate_bountyto raise the economic cost of griefing. - Consider a creation fee (paid to the contract or burned) that is separate from the bounty reward.
| Function | Risk | Key invariant to enforce |
|---|---|---|
create_bounty |
Token transfer in; under-transfer | contract_balance += reward_amount after the call |
complete_bounty |
Token transfer out; double-payment | Status set to completed before token.transfer |
cancel_bounty |
Token transfer out; stuck-fund if blocked | Always reachable by creator; status set before transfer |
- Add status guard to
complete_bounty: panic ifstatus != in_progress. (done — see threat #5) - Enforce
reward_amount > 0increate_bounty. - Add creator-cannot-claim guard in
claim_bounty(fixes threat #3). (done — see threat #3) - Add verifier-cannot-be-assignee guard in
complete_bounty(fixes threat #4). (done — see threat #4) - Fuzz
reward_amountedge cases (0,i128::MAX, negative). - Add integration test:
contract_balance == sum(open + in_progress rewards)after every state transition. - Confirm
complete_bountypanics when called on an already-completed bounty. (done —test_double_complete_panics) - Review token contract for any re-entrant callbacks into this contract.
- Have at least one contributor who was not the author review the token transfer ordering.
Date: 2026-06-29
Scope: All state-mutating functions in src/contract/mutations.rs
require_auth() must be the first executable line in every state-mutating contract function. No storage reads, computations, or cross-contract calls may execute before it. Reasons:
- Fail fast. Unauthenticated calls are rejected before any CPU or storage is consumed.
- Auditability. Reviewers can confirm auth is always present by inspecting the first line alone.
- Side-effect hygiene. On Soroban, failed transactions do not persist state changes, but placing auth after storage reads could expose information about contract state to callers who will never be authorised. Future protocol changes could also make pre-auth side effects observable.
| Function | Auth call | First executable line? | Verdict |
|---|---|---|---|
create_bounty |
creator.require_auth() |
Yes | ✅ Pass |
claim_bounty |
contributor.require_auth() |
Yes | ✅ Pass |
complete_bounty |
verifier.require_auth() |
Yes | ✅ Pass |
approve_completion |
verifier.require_auth() |
Yes | ✅ Pass |
raise_dispute |
caller.require_auth() |
Yes | ✅ Pass |
resolve_dispute |
arbitrator.require_auth() |
Yes | ✅ Pass |
update_contributor_metadata |
contributor.require_auth() |
Yes | ✅ Pass |
cancel_bounty |
caller.require_auth() |
Yes | ✅ Pass |
expire_bounty |
caller.require_auth() |
Yes | ✅ Pass |
Outcome: All 9 mutating functions pass. No reordering was required. # Authorization doc sections were added to approve_completion, raise_dispute, and resolve_dispute, which previously lacked them.
Every new state-mutating function added to MergeMintContract must:
- Accept the authenticated principal as the first argument.
- Call
principal.require_auth()as the very first statement in the function body. - Include a
# Authorizationsection in its doc comment explaining who must authenticate.