Skip to content

Commit a190da6

Browse files
authored
Merge pull request #67 from prodbycorne/docs/cross-contract-double-funding-52
docs: document and accept the cross-contract double-funding gap (#52)
2 parents 086dbfb + 14d4d64 commit a190da6

3 files changed

Lines changed: 73 additions & 0 deletions

File tree

README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,60 @@ next step if it grows is to extract a `mergefi-common` crate with shared
6464
types/helpers, imported as a normal (non-contract) Rust dependency by each
6565
contract crate. Noted under Roadmap.
6666

67+
### Cross-contract double-funding
68+
69+
Independence has a cost this section didn't previously name: **the three
70+
contracts share no registry and never call each other**, so nothing
71+
on-chain stops the same `issue_id` from being funded twice through two
72+
different instruments — once via `escrow::fund(issue_id, ...)` and again
73+
via `milestones::allocate(milestone_id, issue_id, ...)` for some release
74+
milestone. Neither contract's storage namespace (`DataKey` in
75+
`contracts/escrow/src/types.rs` vs `contracts/milestones/src/types.rs`)
76+
overlaps with the other's, and neither contains a contract-id reference to,
77+
or `Env::invoke_contract` call into, the other. Both can independently
78+
reach `release`/`release_issue` and pay out in full for what is, off
79+
GitHub, a single piece of work being compensated twice.
80+
81+
Three ways to close or accept this gap were considered:
82+
83+
- **A shared on-chain registry contract** — a fourth, minimal contract
84+
whose only job is "claim `issue_id` X for contract Y," called by `fund`
85+
and `allocate` before either proceeds. This closes the gap on-chain,
86+
but reintroduces the cross-contract calls this design otherwise avoids
87+
everywhere else, and makes all three contracts' liveness depend on a
88+
fourth one — exactly the coupling the "Independent upgrade/audit
89+
surface" reasoning above argues against.
90+
- **A shared library crate with a common `DataKey` convention** — lower
91+
coupling than a live registry contract, but doesn't actually close the
92+
gap by itself: without a cross-contract call (or a single shared
93+
storage instance both contracts write to, which reintroduces the
94+
coupling above by another name), a shared *type* doesn't stop two
95+
independently-deployed contract instances from writing incompatible
96+
state that neither can see the other wrote.
97+
- **Accept the gap on-chain; mitigate at the backend layer.**
98+
`mergefi-backend` already watches every `fund` and `allocate` call as
99+
the system of record for GitHub state, so it's the one component with
100+
a natural, already-required view of "is this issue committed anywhere"
101+
— and can refuse to originate a second commitment for an `issue_id` it
102+
already tracks as funded or allocated. No new contract, no new
103+
coupling.
104+
105+
**Decision: the third option.** It's the one actually consistent with
106+
this section's own reasoning above — independence was chosen
107+
deliberately, and a shared registry, on-chain or otherwise, reintroduces
108+
the exact coupling that tradeoff was meant to avoid. The contracts
109+
themselves make **no attempt to detect this collision**; `mergefi-backend`
110+
is responsible for refusing to originate a second commitment for an
111+
`issue_id` it already has on record as funded or allocated by either
112+
contract. This is a known, accepted limitation of the independent-
113+
contracts design, not an oversight: if `mergefi-backend`'s own database is
114+
ever wrong, out of sync, or bypassed, nothing on-chain provides a second
115+
line of defense against the same issue being paid out twice through two
116+
different instruments. See the within-`mergefi-milestones`
117+
double-allocation gap (narrower, single-contract-scoped, and fixable
118+
independently of this cross-contract question) for the more contained
119+
sibling of this issue.
120+
67121
### Split rounding and dust
68122

69123
Team payouts use integer token amounts, so `distributable * bps / 10000`
@@ -452,3 +506,9 @@ node scripts/invoke.mjs <SECRET_KEY> <CONTRACT_ID> initialize \
452506
- Add integration tests against `stellar-cli`'s local sandbox network
453507
once available, to validate actual RPC-level invocation from a
454508
`mergefi-backend`-shaped client rather than only `testutils`.
509+
- Revisit the accepted cross-contract double-funding gap (see "Why three
510+
contracts instead of one" → "Cross-contract double-funding") if backend-
511+
layer mitigation ever proves insufficient in practice — the shared
512+
on-chain registry contract considered and rejected there remains the
513+
fallback if a stronger, on-chain guarantee becomes worth the coupling
514+
cost.

contracts/escrow/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ impl EscrowContract {
7272
/// after the first uses `contribute` instead. See
7373
/// `docs/escrow-crowdfunding-design.md` for why creation and
7474
/// contribution are kept as two separate entrypoints.
75+
///
76+
/// Note: this contract has no visibility into `mergefi-milestones` —
77+
/// nothing here stops the same `issue_id` from also being allocated a
78+
/// budget via `milestones::allocate` for some release milestone. See
79+
/// README "Why three contracts instead of one" → "Cross-contract
80+
/// double-funding" for why that gap is accepted here and handled by
81+
/// `mergefi-backend` instead.
7582
pub fn fund(
7683
env: Env,
7784
issue_id: u64,

contracts/milestones/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ impl MilestonesContract {
8989
/// Admin-only: reserves `amount` of the milestone's remaining budget for
9090
/// `issue_id`. Rejects if the issue is already allocated, the milestone
9191
/// is closed, or `amount` exceeds the remaining (unallocated) budget.
92+
///
93+
/// Note: this contract has no visibility into `mergefi-escrow` —
94+
/// nothing here stops the same `issue_id` from also being funded via
95+
/// `escrow::fund` as a standalone bounty. See README "Why three
96+
/// contracts instead of one" → "Cross-contract double-funding" for why
97+
/// that gap is accepted here and handled by `mergefi-backend` instead.
9298
pub fn allocate(env: Env, milestone_id: u64, issue_id: u64, amount: i128) -> Result<(), Error> {
9399
require_admin(&env)?.require_auth();
94100

0 commit comments

Comments
 (0)