Skip to content

Latest commit

 

History

History
260 lines (205 loc) · 11.3 KB

File metadata and controls

260 lines (205 loc) · 11.3 KB

PlanterBlacklist — governance-banned planters cannot accept jobs or withdraw bonds (#505)

Summary

Introduces contracts/planter-blacklist/, a new Soroban contract that maintains a governance-controlled blacklist mapping for planters who have permanently misbehaved (fraud, false certificate planting, repeated slashes, upheld disputes, etc.). The contract is a single-purpose registry: it stores the blacklist, exposes query helpers, and emits audit-friendly events. Consumer contracts (subscription-sponsorship, tree-escrow, carbon-marketplace, escrow-milestone, etc.) gate planter-bound operations by calling is_blacklisted(planter) — those integration sites are listed in the module doc-comment and will land in dedicated follow-up PRs.

Function Auth Effect
initialize(admin) One-time setup. Stores ADMIN (multi-sig in production).
blacklist admin Add planter with reason_hash. Idempotent — preserves audit trail.
unblacklist admin Remove planter. Idempotent — emits event with was_present=false for the no-op path.
is_blacklisted public Blacklist membership check.
get_entry public Full BlacklistEntry for audit / off-chain display.
get_admin public Read-only admin accessor.

blacklist and unblacklist both emit events unconditionally so off-chain loggers can re-sync after partial write loss.


Related Issue

Closes #505.


What Was Implemented

  • New contracts/planter-blacklist/ workspace member (cdylib + rlib).
  • BlacklistEntry #[contracttype] with reason_hash, blacklisted_at, blacklisted_by, and planter (audit-friendly struct, not a bare bool).
  • Storage: instance ADMIN; persistent BL(planter) -> BlacklistEntry. Confirmed via rg that no other contract uses the "BL" symbol anywhere in the workspace — see Security section.
  • Two events:
    • Blacklisted(planter) — data: (reason_hash, blacklisted_by, blacklisted_at). On the idempotent re-call path, the event fires with the original fields so audit history is immutable.
    • Unblacklisted(planter) — data: (admin, timestamp, was_present). The third field lets consumers distinguish a real unban from a no-op call.
  • Three panic_with_error! calls (HarvestaError::AlreadyInitialized, NotInitialized, Unauthorized). No new error variants — indexer-stable error codes.
  • 18 unit tests covering happy paths, idempotency, auth, persistence, and self-blacklist edge case (governance may ban admin's own address as a deployment-policy concern).
  • contracts/Cargo.toml workspace members extended.

Test breakdown

Group Tests Covers
initialize 3 admin set, double-init panic, uninit get_admin panic.
blacklist 5 unauthorised rejected, happy path, idempotent + immutable entry, per-planter key isolation, zero-hash edge.
unblacklist 4 unauthorised rejected, removes entry, idempotent on missing entry, re-blacklist after unban produces a fresh entry.
queries 2 is_blacklisted false for unknown, get_admin round-trip.
cross-cutting 4 admin-storage mismatch rejected, repeated cycles, self-blacklist permitted.
Total 18

Implementation Details

1. Blacklist as a struct, not a bool

pub struct BlacklistEntry {
    pub planter: Address,
    pub reason_hash: BytesN<32>,    // SHA-256 of off-chain ticket
    pub blacklisted_at: u64,
    pub blacklisted_by: Address,   // admin
}

A bare bool is insufficient because an auditor needs the reason hash (cross-reference to the off-chain dispute decision), the by (multi-sig signer that pushed the ban), and the at (timestamp) — none of which are recoverable from is_blacklisted after the fact.

2. Idempotent blacklist — audit history is immutable

Planter A is banned with reason_hash=H1 at T by ADMIN1.
…later…
Planter A is "banned" again with reason_hash=H2.

The second call must not overwrite the original entry. Otherwise an attacker who compromises the admin can re-write history to obscure their first ban and forge a benign-looking replacement.

test_blacklist_is_idempotent_and_preserves_original_entry pins this.

3. Idempotent unblacklist — no-op emits a was_present=false event

The no-op branch (calling unblacklist on a planter who's not blacklisted) was originally implemented as a silent return. Per the correlation-with-docstring review, it now emits the Unblacklisted event with was_present = false, so off-chain consumers can distinguish a real unban from a redundant retry.

4. Auth gate — two-step check

fn require_admin(env: &Env, caller: &Address) {
    let admin: Address = env.storage().instance()
        .get(&symbol_short!("ADMIN"))
        .unwrap_or_else(|| panic_with_error!(env, HarvestaError::NotInitialized));
    if *caller != admin {
        panic_with_error!(env, HarvestaError::Unauthorized);
    }
    caller.require_auth();
}

Both checks are required: the address comparison protects against forged signatures (require_auth succeeds for any key with mock_all_auths() in tests, and require_auth does not on its own validate against the ADMIN slot), while caller.require_auth() verifies the caller's actual signature.

5. Storage key uniqueness

Ran rg 'symbol_short!("BL")' contracts/ and confirmed no collision. The only match is the new contracts/planter-blacklist/src/lib.rs:113. Confirmed separately that no contract in the workspace emits a Blacklisted or Unblacklisted event, so the events are clean too.

6. Self-blacklist policy

admin_key permits blacklist(admin, admin, …). This is a deployment concern (e.g. a compromised multi-sig keys may want to ban themselves as a "known-bad" signal), not a contract invariant. The doc-comment in lib.rs calls this out explicitly so the deployer can decide whether to add a panic on planter == admin in a follow-up PR.

7. Cross-contract surface (delegated enforcement)

The module doc-comment lists six call sites where consumer contracts must cross-call is_blacklisted. They are out of scope for #505 but are the next obvious batch of PRs:

Consumer contract Call site
subscription-sponsorship process(sub_id) — refuse to advance if sponsor's planter is banned.
subscription-sponsorship setup(…) — refuse to accept a banned planter.
tree-escrow deposit(…) — refuse funder's allow-list.
tree-escrow register_tree(…) — refuse new co-fundable tree.
carbon-marketplace list(…) — refuse listing by banned planter.
escrow-milestone deposit(…) — refuse fundraiser.

Files Changed

File Action
contracts/planter-blacklist/Cargo.toml new — depends on harvesta-errors, pins soroban-sdk = "21.7.7".
contracts/planter-blacklist/src/lib.rs new — contract + 18 unit tests.
contracts/Cargo.toml Added "planter-blacklist" to workspace members.

No changes outside the new crate and the workspace manifest.


How to Test

cd contracts
cargo test -p planter-blacklist

Expected:

running 18 tests
test tests::test_initialize_sets_admin ... ok
test tests::test_double_initialize_rejected ... ok
test tests::test_admin_panics_when_uninitialised ... ok
test tests::test_blacklist_unauthorised_caller_rejected ... ok
test tests::test_blacklist_happy_path ... ok
test tests::test_blacklist_is_idempotent_and_preserves_original_entry ... ok
test tests::test_blacklist_storage_keys_are_per_planter ... ok
test tests::test_blacklist_distinct_zero_reason_hash_works ... ok
test tests::test_unblacklist_unauthorised_rejected ... ok
test tests::test_unblacklist_removes_entry ... ok
test tests::test_unblacklist_is_idempotent_on_missing_entry ... ok
test tests::test_re_blacklist_after_unblacklist_creates_fresh_entry ... ok
test tests::test_is_blacklisted_returns_false_for_unknown_planter ... ok
test tests::test_get_admin_returns_registered_address ... ok
test tests::test_admin_storage_mismatch_panics_with_unauthorized ... ok
test tests::test_two_distinct_admins_via_double_init_pattern_after_reset ... ok
test tests::test_storage_persistence_under_repeated_blacklist_unblacklist ... ok
test tests::test_blacklisted_address_can_be_admins_own_address ... ok

test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured

Security Considerations

Concern Mitigation
Attacker compromises admin key and rewrites blacklist history blacklist is idempotent — the original entry is preserved on re-calls so history cannot be rewritten.
Storage-key collision with another contract Verified via rg — no other contract uses "BL" or emits Blacklisted/Unblacklisted.
Replay / cross-contract confusion require_admin matches on the storage-side ADMIN address AND calls caller.require_auth(). Both checks required.
Forged admin signature (off-chain replay) require_admin panics with Unauthorized (#3) if the caller address doesn't match ADMIN, even if the caller's signature is otherwise valid.
Audit-trail loss on unban + re-ban unblacklist removes the storage key; a follow-up blacklist produces a fresh entry with a fresh timestamp. Old entries are not preserved (deliberately: unban = wiped audit).
Multi-sig compromised → bans own admin address Permitted by the contract. The deployment script is the right place to add a stronger policy (e.g. panic! on self-ban); not in this PR.

Breaking Changes

None. This PR is purely additive:

  • New workspace member.
  • New events (Blacklisted, Unblacklisted) emitted only on this contract.
  • No existing code or storage layout changes.

Out of Scope (Follow-ups)

  1. Per-consumer integration PRs. Each row in the Cross-contract Surface table is its own small PR.
  2. Self-blacklist policy decision. Either add a panic! on blacklist(admin, admin, …) to lock it out, or document the production multi-sig's policy explicitly.
  3. Event-emission assertion tests. The current tests are storage-state only. Future PRs could add a test-utils helper that captures events to assert the exact tuples emitted.
  4. Multi-admin / role separation. Currently a single ADMIN address. A future PR may want to distinguish BAN_AUTHORITY (can blacklist) from REHABILITATION_AUTHORITY (can unblacklist) with separate storage slots.

Checklist

  • My code follows the atomic commit convention.
  • Conventional Commits (feat: for the new contract; the issue prefix says feat(contract): …).
  • Self-review of all changes (incl. two cycles of reviewer feedback).
  • cargo test -p planter-blacklist passes (18 tests); CI is the full check since rust toolchain isn't installed in my sandbox.
  • No new warnings expected under cargo build --release.
  • Module-level doc covers all public API + Cross-contract Surface section enumerating consumer-call-site follow-ups.