@@ -64,6 +64,60 @@ next step if it grows is to extract a `mergefi-common` crate with shared
6464types/helpers, imported as a normal (non-contract) Rust dependency by each
6565contract 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
69123Team 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.
0 commit comments