This document outlines security best practices and checklist items for Callora vault contracts to improve audit readiness and reviewer confidence.
- All privileged functions protected by
require_auth()orrequire_auth_for_args()viaAddress - Admin state stored securely (e.g., using
env.storage().instance()) - Admin rotation/transfer tested and documented
- No integer overflow/underflow possible
- For Soroban/Rust:
checked_add/checked_subused for all balance mutations -
overflow-checksenabled in both dev and release profiles
All balance mutations in
callora-vault(deposit,deduct,batch_deduct,withdraw,withdraw_to) andcallora-revenue-pool(batch_distribute) usechecked_add/checked_suband panic with a descriptive message on overflow.callora-settlement(receive_payment) does the same. The workspaceCargo.tomlsetsoverflow-checks = truefor bothdevandreleaseprofiles, so even plain arithmetic would trap in debug builds — the explicit checked calls make the intent clear and guarantee the same behaviour in all build configurations.
Additional hardening note:
- Removed a duplicated
get_max_deductentrypoint declaration incallora-vaultto avoid ambiguous review surfaces and keep ABI-facing code paths singular. The function is retained as a private internal helper called bydeductandbatch_deduct.
-
initializefunction protected against multiple calls (e.g., checking if admin key exists ininstance()storage) - Contract upgrades (
env.deployer().update_current_contract_wasm()) protected byrequire_auth()— tracked in #264 - No unprotected re-init functions —
distribute::initincallora-revenue-poolis currently callable without authentication; tracked in #265 - Unrestricted upgrade cooldown setter reviewed —
set_upgrade_cooldowncurrently has no auth gate; tracked in #266 -
initializevalidates all input parameters
- Emergency pause mechanism implemented via state flag in
instance()storage - Paused state blocks fund movement (e.g., reverting via
panic_with_error!) - Pause/unpause flows tested
-
is_paused()view function exposed for off-chain monitoring - View function is read-only, deterministic, and non-panicking
- Safe default state (returns
falsewhen unset)
- Ownership transfer is two-step (optional but recommended)
- Ownership transfer emits events — tracked in #267
- Renounce ownership reviewed and justified — tracked in #268
The vault exposes a dedicated authorized_caller role (stored in VaultMeta
and settable via set_authorized_caller) that is permitted to invoke
balance-mutating operations such as deduct and batch_deduct. This role is
distinct from owner and admin, and reviewers should confirm the following
controls are in place:
-
authorized_calleris stored inVaultMetaunder theMetainstance storage key and is not duplicated in any other location - Only the current
ownercan set or rotateauthorized_callerviaset_authorized_caller(enforced bymeta.owner.require_auth()) -
set_authorized_calleremits aset_authorized_callerevent with the owner as topic and(old_authorized_caller, new_authorized_caller)as data, enabling off-chain monitoring of role changes and clear audit diffs during rotation -
deductandbatch_deductreject callers that are not the currently configuredauthorized_caller(panic:unauthorized: caller is not the authorized caller) - When
authorized_callerisNone, deduct-class operations fall back to owner-only execution; non-owner callers remain rejected - Rotation flow (set → use → rotate → old caller rejected) covered by
unit tests in
contracts/vault/src/test.rs— tracked in #269 - Role changes are reviewed as part of the operational runbook; the new
caller address is verified off-chain (e.g. multisig or governance) before
the owner signs
set_authorized_caller— tracked in #270 -
authorized_calleris scoped strictly to deduct-class operations and does not grant the ability to withdraw, distribute, pause, or upgrade the contract — tracked in #271
Security note:
authorized_calleris intentionally a narrow-privilege role meant for the off-chain billing/settlement driver. It can spend vault balance viadeduct/batch_deductwithin the configuredmax_deductlimit, so the owning key should rotate it immediately if the off-chain driver's signing key is suspected of compromise. Because rotation is a single-call owner-only operation with an emitted event, recovery is observable and atomic.
-
deductandbatch_deducttreatrequest_idas a single-use idempotency key when it is provided - Duplicate
request_idvalues are rejected before any balance mutation, transfer, or event emission - Batch validation rejects both replayed request ids from prior calls and duplicate ids repeated inside the same batch
- Unit tests cover duplicate single-call replay and duplicate-in-batch rejection with atomic balance assertions
- Token transfers strictly rely on
soroban_sdk::token::Client— tracked in #272 - Cross-contract calls handle potential errors/panics gracefully — tracked in #273
- State changes are persisted before making cross-contract calls to mitigate subtle state-caching issues — tracked in #274
- Checks-effects-interactions pattern followed — tracked in #275
The vault performs USDC transfers to configurable counterpart addresses on every
deduct and batch_deduct call. These external transfers are justified as follows:
- settlement address: set and updated exclusively by the on-chain admin via
set_settlement. This function emits aset_settlementevent to provide a clear audit trail for address rotation. Transfers to this address implement the documentedVault → Settlementrevenue flow described inSETTLEMENT_IMPLEMENTATION.md. - revenue_pool address: retained as an informational configuration slot via
set_revenue_pool/get_revenue_pool. It is no longer consulted during deducts —deductandbatch_deductalways route to the settlement address. - CRITICAL — Settlement Required (Issue #263):
deductandbatch_deductpanic with"settlement address not set"whenset_settlementhas not been called. The panic occurs before any balance mutation or event emission, so the transaction reverts atomically with no observable state change. This closes the silent-loss-of-accounting window where the internalbalancecould previously decrement without a corresponding on-ledger USDC transfer. - Address Validation: Both
set_settlement()andset_revenue_pool()validate that the provided address is NOT the vault's own address, preventing self-referential routing loops. - Atomic Updates: Each address is updated atomically in a single storage write, ensuring no partial update is observable by other callers.
- Audit Trail: All routing configuration changes emit events:
set_settlement(admin) → addresswhen setting settlementset_revenue_pool(admin) → addresswhen setting revenue poolclear_revenue_pool(admin) → ()when clearing revenue pool
- Deposit/withdraw invariants tested — tracked in #276
- Vault balance accounting verified — tracked in #277
- Funds cannot be locked permanently — tracked in #278
- Minimum deposit requirements enforced — tracked in #279
- Maximum deduction limits enforced (
get_max_deduct/set_max_deduct) with explicit positive-value validation and dedicated unit tests. - Revenue pool transfers validated
- Settlement developer address required when routing to specific developer.
- Settlement developer address must be None when routing to global pool.
- Batch operations respect individual limits — tracked in #280
The Revenue Pool contract (contracts/revenue_pool) operates under the following security assumptions and threat models:
-
Malicious Admin: The
adminrole has the authority to distribute funds and replace the admin address. A compromised or malicious admin could drain the pool's USDC balance.- Mitigation: The
adminshould always be a heavily guarded multisig account or a rigorously audited governance contract.
- Mitigation: The
-
Wrong USDC Token Initialization: The
usdc_tokenaddress is set once duringinit. If initialized with a malicious or incorrect token address, the pool will process the wrong asset.- Mitigation: The deployment process must verify the official Stellar USDC (or appropriate wrapped USDC) contract address before initialization. The
initfunction guards against re-initialization.
- Mitigation: The deployment process must verify the official Stellar USDC (or appropriate wrapped USDC) contract address before initialization. The
-
Operational Griefing (Balances): Anyone can effectively transfer USDC to the revenue pool. If an attacker sends unsolicited funds, it increases the
balance()but does not disrupt thedistributelogic, as distribution is explicitly controlled by the admin.- Mitigation: The pool does not rely on strict balance equality invariants for its core operations, mitigating balance-based operational griefing. The
receive_paymententrypoint is admin-only and event-only (no token movement), so indexers should reconcilereceive_paymentlogs with actual token transfers.
- Mitigation: The pool does not rely on strict balance equality invariants for its core operations, mitigating balance-based operational griefing. The
-
Resource Exhaustion via Unbounded Batch:
batch_distributeaccepts aVec<(Address, i128)>. Without a cap, a compromised admin key could submit thousands of entries, exhausting Soroban's per-transaction CPU/memory budget and causing unpredictable mid-execution failures.- Mitigation:
batch_distributeenforces1 <= payments.len() <= MAX_BATCH_SIZE(currently 50), matching the vault'sbatch_deductcap. Empty vectors and oversized vectors are rejected before any iteration or USDC transfer occurs. The cap keeps resource consumption well within Soroban network limits.
- Mitigation:
-
Excessive Single-Leg Distribution: A compromised admin could still try to distribute a huge amount in a single
distribute()or individualbatch_distributeleg, increasing the blast radius for a compromised admin key.- Mitigation:
callora-revenue-poolnow exposes a configurablemax_distributecap. Everydistributeand every individualbatch_distributepayment leg is validated against this cap. The cap is admin-gated, must be positive, and defaults toi128::MAXuntil configured.
- Mitigation:
-
Emergency Pause Delegation: The admin can configure a
pause_guardianfor operational emergencies where the pool needs to be stopped quickly without sharing full admin power.- Mitigation:
pause_guardianis scoped topauseonly. It cannot unpause, distribute funds, rotate admin, update caps, clear or replace itself, or upgrade the contract.set_pause_guardianandclear_pause_guardianare admin-only and emit dedicated events for monitoring.
- Mitigation:
-
Emergency Drain (Multisig + Timelock): The admin can move the entire pool balance to a treasury address as a last-resort evacuation. Without controls, this could be exploited by a compromised admin key.
- Mitigation:
propose_emergency_drainstores aPendingEmergencyDrainsnapshot.execute_emergency_drainis only callable afterEMERGENCY_DRAIN_TIMELOCK_SECONDS(86 400 s = 24 h) have elapsed, giving operators a window to callcancel_emergency_drainbefore funds move. A new proposal replaces any existing one, resetting the clock. When the admin is a Stellar multisig account,require_authenforces the native multi-signature threshold automatically — no additional multi-sig logic is required in the contract. Every state change emits an audit event (emergency_drain_proposed,emergency_drain_executed,emergency_drain_cancelled).
- Mitigation:
- All amounts validated to be > 0 — tracked in #281
- Address/parameter validation on all public functions — tracked in #282
- Boundary conditions tested (max values, zero values) — tracked in #283
- Error messages provide clear context for debugging — tracked in #284
callora-vault::initenforcesmin_deposit > 0; omitted values default to1.
- All state changes emit appropriate events — tracked in #285
- Event schema documented and indexed — tracked in #286
- Critical operations (deposit, withdraw, deduct) logged with full context — tracked in #287
- Unit tests assert
depositanddeductevent topics/data (caller, request_id semantics, and resulting balance). -
callora-revenue-poolemitsadmin_changedcarrying(old_admin, new_admin)only fromaccept_admin()/claim_admin(). Nomination (set_admin) emitsadmin_transfer_startedalone and cancellation emitsadmin_cancelledalone, so noadmin_changedevent exists unless the transfer completed; unit tests pin topics/data for all three cases.
- Unit tests cover all public functions
- Edge cases and boundary conditions tested
- Panic scenarios tested with
#[should_panic] - Integration tests for complete user flows — tracked in #288
- Minimum 95% test coverage maintained (enforced via
cargo tarpaulinwithfail-under = 95.0)
Before any mainnet deployment:
-
Engage an independent third-party security auditor
- Choose auditors with experience in Soroban/Stellar smart contracts
- Ensure auditor understands vault-specific risk patterns
-
Perform a full smart contract audit
- Review all contract code for security vulnerabilities
- Analyze upgrade patterns and migration paths
- Validate mathematical correctness of balance operations
-
Address all high and medium severity findings
- Create tracking system for audit findings
- Implement fixes for all H/M severity issues
- Document rationale for any low severity findings that won't be fixed
-
Publish audit report for transparency
- Make audit report publicly available
- Include summary of findings and remediation steps
- Provide evidence of test coverage and validation
- WASM compilation verified and reproducible (
stellar contract build/cargo build --target wasm32-unknown-unknown --release) — tracked in #289 - Storage lifespan (
extend_ttl) implemented to prevent state archiving for critical data — tracked in #290 - Stellar network parameters validated (budget, CPU/RAM limits) — tracked in #291
- Cross-contract call security and generic type usage (
Val) reviewed — tracked in #292 - Storage patterns optimized and secure (e.g., correct usage of
persistentvsinstancevstemporarykeys) — tracked in #293
- Fee structures reviewed for economic attacks — tracked in #294
- Revenue pool distribution validated — tracked in #295
- Maximum loss scenarios analyzed — tracked in #296
- Slippage and market impact considered — tracked in #297
- Deployment process documented and automated — tracked in #298
- Key management procedures established — tracked in #299
- Monitoring and alerting configured — tracked in #300
- Incident response plan prepared — tracked in #301
- Stellar Security Best Practices
- Soroban Documentation
- Smart Contract Weakness Classification Registry
Note: This checklist should be reviewed and updated regularly as new security patterns emerge and the codebase evolves.
We take the security of Callora contracts seriously. If you believe you have found a vulnerability, please report it responsibly.
- Contact: security@callora.org (PGP key fingerprint published in
docs/AUDIT_BUNDLE.md). - Do not open a public GitHub issue for undisclosed vulnerabilities.
- Include: affected contract and entrypoint, a minimal reproduction (test or transaction sequence), impact assessment, and any suggested fix.
- Acknowledgement: we aim to acknowledge reports within 72 hours and provide a remediation timeline within 7 days.
- Coordinated disclosure: please allow up to 90 days before public disclosure so a fix can be deployed and audited.
- Safe harbour: good-faith research that respects user funds and privacy will not be pursued legally.
See docs/AUDIT_BUNDLE.md for the current audit scope, artifacts, and
disclosure key material.
All privileged entrypoints across vault, revenue_pool, and settlement contracts
have been audited for require_auth() coverage as part of Issue #160.
- All privileged functions call
require_auth()on the caller before executing. ✅ - Negative tests added to each crate's
test.rsconfirming unauthenticated calls are rejected.
| Contract | Function | Reason |
|---|---|---|
| settlement | init() |
One-time initializer guarded by already-initialized panic; no auth required by design. |
| vault | require_owner() |
Internal helper using assert! for address equality. All public callers invoke caller.require_auth() before calling this helper, so host-level auth is enforced transitively. Documented gap: require_owner itself does not call require_auth(). |
| revenue_pool | distribute::init() |
Currently callable without authentication; tracked in #265. |
| revenue_pool | set_upgrade_cooldown() |
Currently has no auth gate; tracked in #266. |
- Audit branch:
test/require-auth-sweep - Tests:
contracts/vault/src/test.rs,contracts/revenue_pool/src/test.rs,contracts/settlement/src/test.rs
As part of the authorization matrix hardening for the callora-settlement contract:
get_all_developer_balancesnow requiresadminauthorization viarequire_auth(). This prevents bulk data scraping while allowing administrative oversight.- Comprehensive negative tests have been added to
contracts/settlement/src/test.rscoveringreceive_payment,set_admin,set_vault, andget_all_developer_balances. - Overflow regression tests now assert
receive_paymentpanics with"pool balance overflow"and"developer balance overflow"when credits would exceedi128::MAX. - Admin rotation (two-step) has been verified to correctly gate access during the transition period.