This document outlines the security architecture, trust assumptions, assets, actors, threat analysis, and mitigations for the Dongle smart contract.
- Project Registry State: The integrity of project ownership, names, slugs, and associated metadata (website, description, tags, social links).
- Review & Rating Data: The validity of reviews, individual ratings, aggregated statistics (average ratings, total counts), and flag counts.
- Escrowed / Paid Fees: Token balances paid for registration or verification, held in the contract or directed to the configured treasury.
- Administrative Credentials: The set of authorized admin addresses and the parameters governing multisig execution thresholds and timelocks.
- Project Owner: An external Stellar account that registers a project. Trusted only to modify their own project metadata.
- Reviewer / General User: Any external Stellar account that reads/writes reviews, follows, or bookmarks projects. Untrusted.
- Admins: A set of trusted accounts authorized to manage contract configuration and perform moderation. Trust is distributed across multiple admins using an on-chain multisig proposal system.
- Smart Contract Boundary: All entry points validate authentication via
Address::require_auth().
graph TD
User[General User / Reviewer] -->|Untrusted| Contract[Dongle Contract Boundary]
Owner[Project Owner] -->|Trusted for own metadata| Contract
Admins[Admin Multisig] -->|Highly Trusted| Contract
Contract -->|Saves State| Ledger[(Stellar Ledger)]
- Threat: An attacker registers a project using the name/slug of a popular external project to divert traffic or conduct phishing. Or, a hacker modifies metadata of an existing legitimate project.
- Mitigation:
require_auth()prevents unauthorized metadata changes. Only the registered project owner (or authorized maintainers) can invokeupdate_project.- Uniqueness Constraints: Slugs and names are verified for uniqueness upon registration and updates. Once a slug is registered, it cannot be claimed by another project.
- Verified Field Freezing: Once a project is verified by admins (
VerificationStatus::Verified), its identity-critical fields (name,slug,category,logo_cid,metadata_cid) are frozen. They cannot be updated by the owner unless the verification is revoked first.
- Threat: An attacker registers thousands of fake projects or writes thousands of fake reviews to skew averages, manipulate rankings, or bloat ledger state.
- Mitigation:
- Registration & Verification Fees: Admins can configure non-zero fee requirements for registration and verification to make spam financially expensive.
- Ownership Limits: A strict limit (
MAX_PROJECTS_PER_USER) restricts the number of active projects a single Stellar address can register. - Moderation: Admins can flag and hide/delete reviews that are determined to be abusive or spam. General users can report reviews to trigger admin inspection.
- Threat: A compromised or malicious admin single-handedly alters fee rates, routes treasury funds to a private key, or falsely approves/revokes project verifications.
- Mitigation:
- Multisig Governance: Highly sensitive administrative actions (adding/removing admins, changing fees, setting thresholds) cannot be executed by a single admin. They require submitting a formal
AdminProposaland gathering approvals up to a defined threshold (threshold). - Timelock Delay: Changes to critical parameters (like fee configuration updates) require scheduling via a timelock, introducing a delay that allows users to notice and react before changes take effect.
- Multisig Governance: Highly sensitive administrative actions (adding/removing admins, changing fees, setting thresholds) cannot be executed by a single admin. They require submitting a formal
A panic inside a Soroban contract aborts the current invocation. Under the
release profile this crate builds with (panic = "abort", see
dongle-smartcontract/Cargo.toml), a panic unwinds the whole host call frame
and the transaction fails. This is not a persistent contract halt — the
next invocation starts from clean ledger state — but it is still an
availability problem:
- Griefing / targeted DoS: if an attacker can craft an input that makes a victim's transaction panic (for example a paginated getter over a project the attacker can influence), the victim's operation can be blocked repeatedly at the cost of one failed transaction per attempt.
- Fee loss: the victim still pays the Soroban resource fee for the failed invocation.
- Indexer / frontend breakage: read paths that panic instead of returning an empty result force every downstream consumer to special-case the failure.
The risk is only material when a panic site is reachable from an external entry point with attacker-influenced input or state. Panics that can only fire on genuine contract bugs (invariant violations) are lower priority but are still tracked below.
The inventory was produced by scanning every non-test module under
dongle-smartcontract/src/ for panic!, unreachable!, todo!,
unimplemented!, .unwrap(), .expect(...), panic_with_error!, and
unchecked slice / index / arithmetic operations, then tracing each hit back to
the public entry points in lib.rs that can reach it.
Result: there are currently no reachable panic sites in production contract code. Every module listed in the original draft has been remediated. The table below records each historical panic site, where it lived, and how it was resolved, so that regressions are easy to spot in review.
| # | Module | Historical panic site | Entry point(s) | Attacker-influenced? | Status | Resolution |
|---|---|---|---|---|---|---|
| 1 | timelock_manager.rs |
panic! on future-timestamp / min-delay validation |
schedule_set_fee, schedule_add_admin, schedule_remove_admin |
Admin-only input | ✅ Resolved | validate_timelock() returns Err(ContractError) |
| 2 | timelock_manager.rs |
unwrap_or_else(|| panic!(...)) when action not found |
execute_scheduled_*, cancel_scheduled_action |
Action id (public) | ✅ Resolved | get_action_unchecked() → .ok_or(ContractError::InvalidStatus)? |
| 3 | timelock_manager.rs |
panic! on invalid action state (executed / cancelled / not expired) |
execute_scheduled_* |
Action id (public) | ✅ Resolved | require_pending() / require_expired() return typed errors |
| 4 | timelock_manager.rs |
.expect() on stored fee / admin-add / admin-remove params |
execute_scheduled_* |
Corrupt storage only | ✅ Resolved | .ok_or(ContractError::InvalidStatus)? |
| 5 | endorsement_registry.rs |
panic!("project not found") |
endorse_project, unendorse_project |
Project id (public) | ✅ Resolved | ProjectRegistry::get_project(...).ok_or(ContractError::ProjectNotFound)? |
| 6 | bookmark_registry.rs |
panic!("project not found") |
bookmark_project, unbookmark_project |
Project id (public) | ✅ Resolved | return Err(ContractError::ProjectNotFound) |
| 7 | dependency_registry.rs |
.unwrap() on core::str::from_utf8 of a key buffer (4 sites) |
add_project_dependency, update_project_dependency, remove_project_dependency, get_project_dependencies |
Dep metadata (public) | ✅ Resolved | .map_err(|_| ContractError::InvalidProjectData)? |
| 8 | review_registry (bubble sort) |
all.get(j).unwrap() / all.get(j + 1).unwrap() in sort loop |
list_reviews_sorted |
Review volume (public) | ✅ Resolved | Extracted to utils::bubble_sort_by, which uses if let (Some(a), Some(b)) + saturating_sub |
| 9 | project_registry.rs (bubble sort) |
all.get(j).unwrap() / all.get(j + 1).unwrap() in sort loop |
list_projects_sorted |
Project volume (public) | ✅ Resolved | Same shared utils::bubble_sort_by helper |
| 10 | admin_manager.rs |
panic!("Contract already initialized") |
initialize |
One-shot, pre-auth | ✅ Resolved | return Err(ContractError::AlreadyInitialized) |
Remaining .unwrap() occurrences in the tree are all inside #[cfg(test)]
modules (src/tests/, src/verification_registry/state_machine.rs test
block) or in doc-comments explaining why a call is safe — none are compiled
into the deployed WASM.
| Defence | Where | Effect |
|---|---|---|
Uniform Result<T, ContractError> return type |
Every state-mutating entry point in lib.rs |
Errors are values, not aborts; callers get a typed code from docs/ERROR_CODES.md |
#[repr(u32)] enum ContractError with 74 variants |
src/errors.rs |
Every failure mode has a dedicated, stable code — no reason to reach for panic! |
checked_add / checked_sub / checked_mul / saturating_* (43 call sites) |
Rating aggregation, counts, pagination math | Arithmetic returns ContractError::ArithmeticOverflow instead of overflow-panicking |
overflow-checks = true in [profile.release] |
Cargo.toml |
Any unchecked arithmetic that slips through still traps deterministically rather than wrapping silently — a defence-in-depth backstop, not the primary control |
Bounds-safe Vec access (.get() + if let Some) |
utils::bubble_sort_by, pagination helpers |
Out-of-range indices yield None, never a panic |
| Slice reads guarded by explicit length checks | utils.rs / dependency_registry.rs CID parsing |
Buffers are validated before slicing; UTF-8 conversion failures map to ContractError |
wasm-check + clippy -D warnings CI gates |
.github/workflows/ci.yml |
clippy::unwrap_used-class regressions surface in review; see docs/CI_CD.md |
no_std crate |
#![no_std] in lib.rs |
No access to std panic machinery / unwind; keeps the panic surface minimal |
Scoring: Likelihood and Impact are Low / Medium / High. "Residual" is the risk that remains after the resolution in §4.2 and the defences in §4.3.
| Panic class | Example entry point | Likelihood (pre-fix) | Impact (pre-fix) | Inherent risk | Residual risk | Notes |
|---|---|---|---|---|---|---|
| Not-found lookups (project / action id) | endorse_project, execute_scheduled_set_fee |
High — any caller passes an id | Medium — griefing + fee loss | High | Low | Now typed ProjectNotFound / InvalidStatus |
| Malformed-buffer UTF-8 parse | add_project_dependency |
Medium — needs crafted metadata | Medium — blocks the caller's own write | Medium | Low | InvalidProjectData returned; input length-validated first |
| Sort / pagination out-of-bounds | list_projects_sorted, list_reviews_sorted |
Low — needs an index-bounds bug | High — breaks a public read path for everyone | Medium | Low | Shared bubble_sort_by is bounds-safe by construction |
| Arithmetic overflow in aggregates | add_review → stats update |
Low — needs ~2³²–2⁶⁴ reviews | Medium — blocks further writes to one project | Medium | Low | checked_* → ArithmeticOverflow; overflow-checks backstop |
| Re-initialization panic | initialize |
Low — one-shot, guarded | Low — no funds/state at risk | Low | Low | AlreadyInitialized returned |
Storage-corruption .expect() |
execute_scheduled_* |
Very Low — implies host/ledger fault | Medium | Low | Low | .ok_or(...)?; only reachable if ledger data is already inconsistent |
| Admin-only validation panic | schedule_set_fee |
Low — requires admin auth | Low — admin can retry | Low | Low | Typed errors; not attacker-facing |
| Priority | Item | State | Follow-up |
|---|---|---|---|
| P0 | Eliminate all attacker-reachable panic! / unwrap / expect from entry-point paths |
✅ Done (items 1–10, §4.2) | Keep the §4.2 table current in every PR that touches a registry module |
| P1 | Enforce checked_* arithmetic on all counters and aggregates |
✅ Done — 43 call sites, ArithmeticOverflow variant |
Add a clippy lint (clippy::arithmetic_side_effects) to make this mechanical |
| P1 | Bounds-safe shared sort / pagination helpers | ✅ Done — utils::bubble_sort_by, pagination.rs |
— |
| P2 | CI lint to forbid new .unwrap() / .expect() / panic! in src/ (excluding src/tests/) |
⬜ Open | Add #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic))] or a grep gate in ci.yml |
| P2 | Property-based / fuzz tests that assert "no entry point panics on arbitrary input" | 🟡 Partial — proptest_pagination.rs covers pagination; other modules rely on unit tests |
Extend proptest coverage to dependency-metadata parsing and sorted listings; track in docs/TEST_COVERAGE.md |
| P3 | Document panic semantics for integrators (indexers must still tolerate a failed read) | ✅ Done — this section + docs/ERROR_CODES.md |
— |
When reviewing a PR that touches any src/*_registry*.rs, timelock_manager.rs,
admin_manager.rs, utils.rs, or rating_calculator.rs, reject the change if it:
- introduces
panic!,unreachable!,todo!,unimplemented!,.unwrap(), or.expect(...)outside a#[cfg(test)]block; - performs bare
+,-,*on balances / counts / indices instead of thechecked_*/saturating_*forms; - indexes a
Vec/ slice with[i]or.get(i).unwrap()instead of pattern -matching on.get(i); - adds a public entry point whose signature is not
-> Result<_, ContractError>for any state-mutating operation.
- IPFS / Off-Chain Data Availability:
- Risk: Review contents, descriptions, and verification evidence are stored as CIDs (IPFS hashes). The contract cannot guarantee that the off-chain content behind these CIDs is pinned, accessible, or matches the schema.
- Assumption: Indexers and frontends must handle missing or slow CID resolution gracefully.
- Admin Collusion:
- Risk: If a threshold of admins colludes, they can override any constraint, approve malicious projects, or arbitrarily set exorbitant fees.
- Assumption: The admin keys are held by separate, independent entities, and their private keys are secured.
- Off-Chain Identity Verification Diligence:
- Risk: Verification approval depends on the manual, off-chain diligence of admins confirming that the requester owns the project. If admins perform poor validation, incorrect verifications can occur.
- Soroban Host / SDK Faults:
- Risk: A panic originating inside
soroban-sdkor the host (rather than contract code) is outside this contract's control. The.expect()-replacement.ok_or(...)?sites in §4.2 assume the host returns consistent storage data. - Assumption: The Stellar validator set and the pinned
soroban-sdk22.0.0 behave to spec.
- Risk: A panic originating inside
To reproduce the §4.2 inventory:
cd dongle-smartcontract
# Reachable panic surface in production code (should return only comments / test asserts):
grep -rn "panic!\|unreachable!\|todo!\|unimplemented!\|\.unwrap()\|\.expect(" src --include="*.rs" \
| grep -v "/tests/"
# Checked-arithmetic coverage:
grep -rn "checked_add\|checked_sub\|checked_mul\|saturating_" src --include="*.rs" | grep -v "/tests/" | wc -l
# Confirm every mutating entry point returns Result:
grep -n "pub fn " src/lib.rsRe-run this scan whenever a registry module changes and update the §4.2 table and §4.5 status column accordingly.
Last Updated: 2026-08-27
Scope: dongle-contract v0.6.0, soroban-sdk 22.0.0, toolchain 1.85.0
Related docs: ERROR_CODES.md, CI_CD.md, TEST_COVERAGE.md, ARCHITECTURE.md