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.
Closes #505.
- New
contracts/planter-blacklist/workspace member (cdylib + rlib). -
BlacklistEntry#[contracttype]withreason_hash,blacklisted_at,blacklisted_by, andplanter(audit-friendly struct, not a bare bool). - Storage: instance
ADMIN; persistentBL(planter) -> BlacklistEntry. Confirmed viargthat 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.tomlworkspacemembersextended.
| 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 |
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.
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.
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.
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.
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.
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.
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. |
| 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.
cd contracts
cargo test -p planter-blacklistExpected:
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
| 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. |
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.
- Per-consumer integration PRs. Each row in the Cross-contract Surface table is its own small PR.
- Self-blacklist policy decision. Either add a
panic!onblacklist(admin, admin, …)to lock it out, or document the production multi-sig's policy explicitly. - 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.
- Multi-admin / role separation. Currently a single ADMIN address.
A future PR may want to distinguish
BAN_AUTHORITY(can blacklist) fromREHABILITATION_AUTHORITY(can unblacklist) with separate storage slots.
- My code follows the atomic commit convention.
- Conventional Commits (
feat:for the new contract; the issue prefix saysfeat(contract): …). - Self-review of all changes (incl. two cycles of reviewer feedback).
-
cargo test -p planter-blacklistpasses (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.