Skip to content

Comprehensive access-control boundary audit, initialize hardening, and sponsor-controlled deadline extension - #34

Merged
chonilius merged 12 commits into
MergeFi:mainfrom
miraclesonly:audit/access-control-refund-analysis
Jul 20, 2026
Merged

Comprehensive access-control boundary audit, initialize hardening, and sponsor-controlled deadline extension#34
chonilius merged 12 commits into
MergeFi:mainfrom
miraclesonly:audit/access-control-refund-analysis

Conversation

@miraclesonly

Copy link
Copy Markdown
Contributor

Summary

Closes #30.

A systematic function-by-function access-control audit across all
three contracts, plus a focused analysis of refund's
permissionless-after-deadline path (economics, griefing surface, and
the sponsor-control design question the issue raises).

Audit table

Full table in
docs/access-control-audit.md,
covering every public function in escrow, milestones, and
maintenance-pool. One real mismatch found and fixed (below); one
already-tracked issue (#5's cancel_milestone/release_issue
asymmetry) explicitly cross-referenced rather than duplicated, since
it's a state-machine gap, not an access-control one (the admin check
on release_issue is already correct — it's what state it can act
on that's the bug).

Finding: initialize had no access control in any contract

Beyond storage().instance().has(&DataKey::Admin) (which blocks
re-init, not the first call), initialize performed zero
require_auth() calls in all three contracts. Fixed by adding
admin.require_auth() to all three.

Important caveat, spelled out in the audit doc and doc comments:
this does not close initializer front-running — an attacker can
still race the legitimate deployer by calling initialize and naming
themselves as admin, since they trivially satisfy require_auth()
for their own address. That requires a structural fix (an atomic
deploy+init via Soroban's __constructor), which is a real deploy-flow
change (scripts, Makefile, README, breaks the already-initialized
testnet deployments) — too large to bundle here, so I filed
#33 to track it separately. This PR's fix should be read as closing
the narrower "name a third party as admin without their consent" gap,
not the front-running race.

refund permissionless-path analysis

Full writeup in
docs/refund-permissionless-analysis.md.
Short version: calling refund costs the caller real transaction fees
for zero reward, so it's not realistically load-bearing via altruistic
strangers — the realistic callers are mergefi-backend or the sponsor
themselves. Where "anyone can call it" actually earns its keep is the
tail case: if both the backend and the sponsor are unavailable, a
third party with some other reason to care can still return funds to
the sponsor with no coordination needed. That's a genuine
bankruptcy/custody-remoteness guarantee, not a UX nicety. Griefing is
not possible in the "steal funds" sense (always pays the stored
sponsor address, caller controls nothing) — the real gap is that the
sponsor has no way to change their mind about the deadline once set.

Design decision: added extend_deadline (sponsor-only, monotonic —
new_deadline must be strictly later than both the current deadline
and current ledger time). Considered and rejected an admin/backend-
controlled grace period, since that would undermine the exact
guarantee refund's permissionlessness exists to provide (recovery that
doesn't depend on the backend). Sponsor-only + monotonic-only
preserves that guarantee while directly answering the issue's question
with a real mechanism instead of "no, and that's fine."

Test plan

  • Access-control boundary matrix added across all three contracts
    (initialize/fund/create_milestone/deposit reject calls without
    the relevant address's auth; allocate/release_issue/
    cancel_milestone/withdraw reject calls without admin auth;
    refund's pre-deadline path requires admin auth; refund's
    post-deadline path succeeds with zero auths mocked at all,
    proving the permissionless path is real).
  • extend_deadline tests: sponsor-only, successfully re-closes the
    permissionless window after extension, rejects non-increasing/
    not-yet-future deadlines, blocked once paid/refunded.
  • cargo test --workspace34/34 pass (17 escrow, 10
    milestones, 7 maintenance-pool; up from 16 before this PR).
  • cargo clippy --workspace --all-targets -- -D warnings — clean.
  • cargo fmt --all --check — clean.
  • cargo build --target wasm32v1-none --release — all three
    contracts still build to valid wasm (matches the wasm-build CI
    job).

Follow-ups filed

@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

@miraclesonly is attempting to deploy a commit to the chonilius' projects Team on Vercel.

A member of the Team first needs to authorize it.

Function-by-function audit across all three contracts (~20 public
entrypoints) comparing intended access level against what's actually
enforced in code, for MergeFi#30. Surfaces one real mismatch (initialize has
no access control in any contract, fixed in the following commits)
and cross-references two already-tracked findings (MergeFi#5, MergeFi#21) instead
of duplicating fixes for them.
Focused deep-dive for MergeFi#30 on escrow::refund's permissionless-after-
deadline path: whether "anyone can call it" is realistically load-
bearing given caller incentives (it isn't, for altruistic strangers —
no rational disinterested party pays fees for zero reward — but it is
for the bankruptcy/custody-remoteness case, backend/admin permanently
unavailable), and whether a malicious caller can grief a sponsor by
triggering it early (no: funds always go to the sponsor, and the
deadline is the sponsor's own prior commitment). Documents the design
decision landing in the next few commits: a sponsor-only, monotonic
extend_deadline, chosen over an admin/backend-controlled grace period
because the latter would undermine the exact guarantee refund's
permissionlessness exists to provide.
initialize performed zero require_auth() calls — the only guard was
storage().instance().has(&DataKey::Admin), which blocks
re-initialization but not the first call. Add admin.require_auth() so
nobody can name a third-party address as admin without their consent
(MergeFi#30). This is a partial mitigation, not a full fix for initializer
front-running — see the doc comment and docs/access-control-audit.md
for why, and MergeFi#33 for the actual structural fix.

test_unauthorized_release_rejected needed its auth-mocking order
adjusted: it deferred mock_all_auths() until after setup() specifically
to test release's auth boundary, but setup()'s own initialize() call
now needs auth too, so mocking has to start before setup() runs.
Same gap and same fix as escrow (MergeFi#30): initialize had no access
control at all beyond the re-initialization guard. Add
admin.require_auth() — see docs/access-control-audit.md for what this
does and does not protect against.
Same gap and same fix as escrow/milestones (MergeFi#30): initialize had no
access control at all beyond the re-initialization guard. Add
admin.require_auth() — see docs/access-control-audit.md for what this
does and does not protect against.
Answers the design question raised in MergeFi#30's refund analysis: a
sponsor who wants more time before refund's permissionless path opens
now has a real, safe mechanism to signal that, rather than no
recourse at all. escrow.sponsor.require_auth() gates it, and
new_deadline must be strictly later than both the stored deadline and
the current ledger time (InvalidDeadline otherwise) — so it can only
ever delay the permissionless window, never shorten it, and only the
sponsor whose funds these are can call it. Full reasoning for why this
shape was chosen over an admin/backend-controlled grace period is in
docs/refund-permissionless-analysis.md.
Covers the claims in docs/access-control-audit.md for MergeFi#30:
initialize/fund reject calls with no auth for the relevant address,
refund's early (pre-deadline) path requires admin auth, and —
explicitly required by the issue — refund's post-deadline path
genuinely succeeds with zero auths mocked at all, proving the
"anyone" path is real and not accidentally gated by something else.
Covers: only the sponsor can call it; a successful extension actually
re-closes the permissionless refund window (old deadline passes, new
one hasn't — refund still requires admin auth); non-increasing or
not-yet-future deadlines are rejected (InvalidDeadline); and it's
blocked once the escrow is already paid or refunded, same as the
existing early-refund admin path.
Covers the claims in docs/access-control-audit.md for MergeFi#30:
initialize/create_milestone reject calls with no auth for the
relevant address, and allocate/release_issue/cancel_milestone all
reject calls without admin auth.
Covers the claims in docs/access-control-audit.md for MergeFi#30:
initialize/deposit reject calls with no auth for the relevant
address, and withdraw rejects calls without admin auth.
Documents extend_deadline's signature/semantics, the initialize auth
addition and its limits, cross-references the two new docs added in
this PR, and refreshes the "verified in this session" test count
(16/16 -> 34/34) to match the new access-control boundary matrix.


Pre-existing breakage unrelated to MergeFi#30, surfaced when rebasing this
branch onto main after MergeFi#32 (adversarial-ordering split fix) merged:
the test used the now-deprecated env.register_contract and called
compute_split directly without the env.as_contract wrapper
compute_split's storage access needs, so it panicked on any run
("this function is not accessible outside of a contract") and failed
clippy's -D warnings for the deprecated call. Confirmed both failures
reproduce on a pristine upstream/main checkout, independent of any
change in this branch.

Fix is mechanical: env.register_contract(None, ...) -> env.register(...,
()), and wrap the two direct compute_split calls in
env.as_contract(&contract_id, || ...) so they run with the right
storage context. No behavior/assertion changes.
@miraclesonly
miraclesonly force-pushed the audit/access-control-refund-analysis branch from 7fd2d09 to cea9372 Compare July 20, 2026 17:45
@chonilius
chonilius merged commit 8c31a5b into MergeFi:main Jul 20, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Comprehensive access-control boundary audit across all entrypoints, including the permissionless-after-deadline refund path

2 participants