From 1e7b9cf7d1e20a5a9be93c3d87c8e3358012f567 Mon Sep 17 00:00:00 2001 From: prodbycorne <277280234+prodbycorne@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:20:40 +0100 Subject: [PATCH 1/8] docs: name the cross-contract double-funding gap in Why-three-contracts The section explained the independence tradeoff for compute_split duplication but was silent on a bigger consequence of the same design: escrow and milestones share no registry and never call each other, so nothing on-chain stops the same issue_id from being funded via both instruments at once. --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 6035547..cea00fd 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,20 @@ 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. +### Cross-contract double-funding + +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. + ### Split rounding and dust Team payouts use integer token amounts, so `distributable * bps / 10000` From c2a21dde1fda04fe9bae5bd033a45d594ddc6f60 Mon Sep 17 00:00:00 2001 From: prodbycorne <277280234+prodbycorne@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:21:00 +0100 Subject: [PATCH 2/8] docs: document the on-chain registry option considered for issue #52 A fourth contract that both fund and allocate would call to claim an issue_id closes the double-funding gap on-chain, but reintroduces the cross-contract coupling this design otherwise deliberately avoids. --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index cea00fd..4e34d79 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,16 @@ 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_id` X for contract Y," called by `fund` + and `allocate` before 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. + ### Split rounding and dust Team payouts use integer token amounts, so `distributable * bps / 10000` From c144cfe6c4fae4124d20bc7008a6f7fb3f4497ca Mon Sep 17 00:00:00 2001 From: prodbycorne <277280234+prodbycorne@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:21:16 +0100 Subject: [PATCH 3/8] docs: document the shared-library-crate option considered for issue #52 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A common DataKey convention is lower-coupling than a registry contract, but a shared type alone doesn't close the gap — two independently-deployed contracts still can't see each other's state without either a cross-contract call or a shared storage instance, either of which reintroduces the coupling this option was meant to avoid. --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 4e34d79..f62dcbd 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,13 @@ Three ways to close or accept this gap were considered: 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 `DataKey` convention** — 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. ### Split rounding and dust From 8f9cc63b819eb86d10354450bbf740cd54be0f22 Mon Sep 17 00:00:00 2001 From: prodbycorne <277280234+prodbycorne@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:21:33 +0100 Subject: [PATCH 4/8] docs: document the backend-mitigation option considered for issue #52 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mergefi-backend already watches every fund/allocate call as the system of record for GitHub state, giving it a natural, already- required view of which issue_ids are committed — closing the gap there needs no new contract or cross-contract coupling. --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index f62dcbd..9d982b6 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,13 @@ Three ways to close or accept this gap were considered: 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-backend` already watches every `fund` and `allocate` call 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 an `issue_id` it + already tracks as funded or allocated. No new contract, no new + coupling. ### Split rounding and dust From 5ba98890cf36138f215cfcfb71d20cf954797aad Mon Sep 17 00:00:00 2001 From: prodbycorne <277280234+prodbycorne@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:21:51 +0100 Subject: [PATCH 5/8] docs: record the decision on cross-contract double-funding (#52) Accept the gap on-chain and push mitigation to mergefi-backend, which already watches every fund/allocate call. Stated explicitly as a conscious, accepted limitation of the independent-contracts design rather than a silent gap, per issue #52's requirements. --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 9d982b6..fa5eb7b 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,22 @@ Three ways to close or accept this gap were considered: 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. + ### Split rounding and dust Team payouts use integer token amounts, so `distributable * bps / 10000` From 64040619089b21d95215f9dbeb6619db9401fd0e Mon Sep 17 00:00:00 2001 From: prodbycorne <277280234+prodbycorne@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:22:19 +0100 Subject: [PATCH 6/8] docs(escrow): cross-reference the double-funding limitation from fund() Points at the README's new "Cross-contract double-funding" write-up so the accepted limitation is discoverable from the code, not only from the README. --- contracts/escrow/src/lib.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index c9f95cb..ae1d54a 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -72,6 +72,13 @@ impl EscrowContract { /// after the first uses `contribute` instead. See /// `docs/escrow-crowdfunding-design.md` for why creation and /// contribution are kept as two separate entrypoints. + /// + /// Note: this contract has no visibility into `mergefi-milestones` — + /// nothing here stops the same `issue_id` from also being allocated a + /// budget via `milestones::allocate` for some release milestone. See + /// README "Why three contracts instead of one" → "Cross-contract + /// double-funding" for why that gap is accepted here and handled by + /// `mergefi-backend` instead. pub fn fund( env: Env, issue_id: u64, From dfa1caf9c3d9fe3ee6c7cdcd1c4457cb3c449048 Mon Sep 17 00:00:00 2001 From: prodbycorne <277280234+prodbycorne@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:22:50 +0100 Subject: [PATCH 7/8] docs(milestones): cross-reference the double-funding limitation from allocate() Points at the README's new "Cross-contract double-funding" write-up so the accepted limitation is discoverable from the code, not only from the README. --- contracts/milestones/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/contracts/milestones/src/lib.rs b/contracts/milestones/src/lib.rs index a4b2ad9..9280307 100644 --- a/contracts/milestones/src/lib.rs +++ b/contracts/milestones/src/lib.rs @@ -89,6 +89,12 @@ impl MilestonesContract { /// Admin-only: reserves `amount` of the milestone's remaining budget for /// `issue_id`. Rejects if the issue is already allocated, the milestone /// is closed, or `amount` exceeds the remaining (unallocated) budget. + /// + /// Note: this contract has no visibility into `mergefi-escrow` — + /// nothing here stops the same `issue_id` from also being funded via + /// `escrow::fund` as a standalone bounty. See README "Why three + /// contracts instead of one" → "Cross-contract double-funding" for why + /// that gap is accepted here and handled by `mergefi-backend` instead. pub fn allocate(env: Env, milestone_id: u64, issue_id: u64, amount: i128) -> Result<(), Error> { require_admin(&env)?.require_auth(); From 14d4d64ef958c96e59c62db8af35e8cae34411ba Mon Sep 17 00:00:00 2001 From: prodbycorne <277280234+prodbycorne@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:23:13 +0100 Subject: [PATCH 8/8] docs: add Roadmap entry to revisit cross-contract double-funding (#52) Names the rejected on-chain registry option as the fallback path if backend-layer mitigation ever turns out to be insufficient, so the decision recorded in "Why three contracts instead of one" isn't treated as permanently closed. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index fa5eb7b..2763dfe 100644 --- a/README.md +++ b/README.md @@ -506,3 +506,9 @@ node scripts/invoke.mjs initialize \ - Add integration tests against `stellar-cli`'s local sandbox network once available, to validate actual RPC-level invocation from a `mergefi-backend`-shaped client rather than only `testutils`. +- 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.