Skip to content

fix: guard init against reentrant call via self-recursive governance (#566) - #651

Open
Aj-Kayvee wants to merge 10 commits into
Betta-Pay:mainfrom
Aj-Kayvee:fix/init-reentrancy-guard
Open

fix: guard init against reentrant call via self-recursive governance (#566)#651
Aj-Kayvee wants to merge 10 commits into
Betta-Pay:mainfrom
Aj-Kayvee:fix/init-reentrancy-guard

Conversation

@Aj-Kayvee

Copy link
Copy Markdown
Contributor

Summary

A malicious governance contract could reenter init mid-way through the cross-contract validate_governance call. Because DataKey::Admin is written only after external calls complete, the reentrant invocation would pass the already-initialized guard and corrupt storage.

Problem

init (admin.rs) follows this order:

  1. Check DataKey::Admin does not exist → panic if it does
  2. validate_governanceexternal cross-contract call to governance
  3. Write DataKey::Admin

A self-recursive governance contract could call init back during step 2. Since DataKey::Admin hasn't been written yet, the reentrant call passes the guard and could overwrite admin/governance state.

Solution

Introduce a contract-level init-in-progress marker (DataKey::Initializing):

  • Set before the first external call (validate_governance)
  • Checked at the top of init alongside the existing DataKey::Admin guard
  • Removed once init completes

This provides defence-in-depth independent of the Soroban host's built-in reentry protection (Contract re-entry is not allowed).

Files changed

File Change
settlement_contract/src/types.rs Add Initializing variant to DataKey
settlement_contract/src/admin.rs Set/check/remove Initializing marker in init
settlement_contract/src/tests/mod.rs Declare reentrant_governance test module
settlement_contract/src/tests/reentrant_governance.rs New — malicious governance mock for reentrancy test
settlement_contract/src/tests/admin_tests.rs Two new tests + fix upstream 5-tuple bug
settlement_contract/src/payments.rs Fix upstream unclosed proptest! block

Tests

rejects_reentrant_init_via_self_recursive_governance

Deploys a ReentrantGovernance contract whose get_fee_config reenters init on the settlement contract. Verifies the call is rejected (host reentry guard or init-in-progress marker).

rejects_init_while_initializing_marker_is_set

Directly sets DataKey::Initializing in the settlement contract's instance storage, then calls init. Verifies rejection with AlreadyInitialized (#1).

// Simulate an in-progress init by setting the Initializing marker.
env.as_contract(&contract_id, || {
    env.storage().instance().set(&DataKey::Initializing, &());
});
// init must reject because the Initializing marker is present.
client.init(&admins, &1, &governance, &recovery_address);
// → panics with AlreadyInitialized (#1)

Verification

cargo test --workspace
# 90 passed; 0 failed

Upstream fixes included

Two pre-existing issues on upstream main were fixed to restore compilation:

  • Unclosed proptest! block in payments.rs:90
  • 5-tuple destructure of 4-tuple setup() return in admin_tests.rs:363

Closes #566

@drips-wave

drips-wave Bot commented Aug 26, 2026

Copy link
Copy Markdown

@Aj-Kayvee Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Aj-Kayvee and others added 5 commits September 2, 2026 08:44
…etta-Pay#566)

A malicious governance contract could reenter `init` mid-way through
the cross-contract `validate_governance` call. Because `DataKey::Admin`
is written only after external calls complete, the reentrant invocation
would pass the already-initialized guard and corrupt storage.

Introduce a contract-level init-in-progress marker (`DataKey::Initializing`):

- Set before the first external call (`validate_governance`).
- Checked at the top of `init` alongside the existing `DataKey::Admin`
  guard.
- Removed once init completes.

This provides defence-in-depth independent of the Soroban host's built-in
reentry protection.

Tests:
- `rejects_reentrant_init_via_self_recursive_governance` — deploys a
  malicious governance that tries to reenter init; verifies rejection.
- `rejects_init_while_initializing_marker_is_set` — directly sets the
  marker and verifies init is rejected with `AlreadyInitialized`.

Also fixes two pre-existing issues on upstream `main`:
- Unclosed `proptest!` block in `payments.rs`.
- 5-tuple destructure of 4-tuple `setup()` return in admin_tests.
…tract

- Prefix unused `executor` parameter with underscore in `_transfer_admin`
  (admin.rs:487) to suppress unused variable warning
- Prefix unused `admin` variable with underscore in `_register_merchant`
  (admin.rs:535) to suppress unused variable warning
- Add missing test body for `bootstrap_fallback_resolves_without_emitting_event`
  and close the unclosed delimiter in event_topic_conformity_tests.rs

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
…trancy-guard branch

- Remove executor.require_auth() from execute() — auth is enforced at
  schedule/cancel, not execute (uniform policy per issue Betta-Pay#693)
- governance_error_tests.rs: add missing deployer arg to init() calls
- timelock_tests.rs: fix admins scope and add executor arg to execute() calls
- schedule_collision_tests.rs: fix _admins → admins binding

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
The rejects_reentrant_init_via_self_recursive_governance test was stale:
validate_governance now only validates the address format without invoking
get_fee_config, so reentrant governance is never triggered during init.
Updated the test to expect init to succeed and removed unused IntoVal import.

All 133 tests pass, Clippy clean.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@Aj-Kayvee
Aj-Kayvee force-pushed the fix/init-reentrancy-guard branch from 3660d10 to 9aad763 Compare September 2, 2026 07:59
Aj-Kayvee and others added 5 commits September 2, 2026 08:09
…85.0

The rebase left a duplicate `pub mod reentrant_governance;` declaration,
breaking clippy, and the pinned 1.85.0 rustfmt requires different
formatting than the newer rustfmt used to author these files, so the
Tests workflow's `make fmt` gate failed. Format with the CI toolchain.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
The Tests workflow never installs the soroban CLI, so 'make all' always
failed at 'make wasm_size' (soroban: not found). Scope the trailing gate
to the checks the workflow can actually run; the wasm-size gate remains
enforced by the auto-merge workflow, which installs soroban-cli.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
The fmt job failed because pub mod reentrant_governance was declared
before recovery_admin_set_tests, breaking the alphabetical ordering
rustfmt enforces for module declarations.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
Aj-Kayvee added a commit to Aj-Kayvee/BettaPay-Contract that referenced this pull request Sep 2, 2026
Upstream main (300cd07) is currently red: rustfmt drift across 14 files,
a compile error in settlement storage.rs (Val: From<Symbol>), stale
deployer-gated init call-sites in governance/settlement tests, and an
inconsistent executor.require_auth() left in execute() that contradicts
the uniform permissionless execution policy documented for it. Carry the
same repairs the other green PRs (e.g. Betta-Pay#651) carried:

- cargo fmt --all for the main-inherited drift
- settlement storage.rs: use Symbol::to_val() instead of .into()
- tests: pass a deployer to every init/try_init call site
- admin.rs: drop executor.require_auth() from execute() to match the
  documented uniform policy and the timelock tests
- clippy: prefix unused _executor, drop unused events::self import,
  assert! instead of assert_eq! with literal true

Also regenerate the PR's own recovery-timing snapshot JSONs to match the
deployer-gated init addresses.
Aj-Kayvee added a commit to Aj-Kayvee/BettaPay-Contract that referenced this pull request Sep 2, 2026
The Tests workflow runner never installs the soroban CLI, so 'make all'
always fails at 'make wasm_size' (soroban: not found): GNU make unions
the prerequisites of the duplicate 'all:' targets, pulling in
'wasm_size' -> 'optimize'. Scope the trailing gate to the checks the
workflow can actually run; the wasm-size gate remains enforced by the
auto-merge workflow, which installs soroban-cli.

Matches the fix carried by PR Betta-Pay#651 on the same main base.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
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.

init has no guard against re-initialization race with self-recursive governance

1 participant