This document records the security review of the IndigoPay contract.
The previous design had three single-admin SPOFs:
- Admin transfer was instant — a single compromised signature could silently give the attacker full control.
- No contract-level pause — only per-project pause existed, leaving no way to halt the contract during an incident.
- Upgrade was instant —
upgrade(admin, new_wasm_hash)swapped the WASM in one transaction, with no community review window.
Phase A replaces all three with a stronger trust model.
The admin key is now a two-step handoff:
- Step 1 — current admin calls
transfer_admin(admin, new_admin). The proposed admin is stored underDataKey::PendingAdminand anad_xferevent is emitted. - Step 2 — the proposed admin calls
accept_admin(). The contract reads the pending entry and promotes it. Auth is gated bypending.require_auth(), so only the proposed recipient (not the old admin) can promote themselves. - Cancel — the current admin may call
cancel_admin_transfer(admin)to clear the pending entry if the proposed recipient lost their key or the transfer was a mistake.
State invariants:
accept_adminpanics with"No pending admin transfer"if no proposal exists.transfer_adminpanics with"Admin transfer already pending; cancel first"if a proposal is already in flight, preventing an attacker from overwriting a pending recipient.accept_admindoes not take a caller argument — the only value the contract trusts to become admin is the stored pending entry. There is no path for an imposter to promote a different address.
A single boolean DataKey::ContractPaused (default false) gates every state-mutating public function:
donate,donate_usdcmint_impact_nft,mint_project_nftcreate_proposal,vote_verify_projectregister_project,batch_register_projectsupdate_project_co2_rate,deactivate_project,deactivate_all_projectsset_usdc_token,set_oracle
Read-only getters continue to work while the contract is paused, so off-chain UIs and indexers can keep polling.
The pause functions (pause_contract / unpause_contract), the admin-recovery functions (transfer_admin / accept_admin / cancel_admin_transfer), and the upgrade lifecycle (propose_upgrade / execute_upgrade / cancel_upgrade) are deliberately not pause-gated so the admin can always recover from a paused contract or cancel a pending upgrade during an incident.
The require_not_paused helper is called immediately after require_auth and before any storage read, so a paused-contract call panics as cheaply as possible.
The old single-step upgrade(admin, new_wasm_hash) is removed in favour of a 48-hour timelock:
- Step 1 — admin calls
propose_upgrade(admin, new_wasm_hash). The hash is stored underDataKey::PendingUpgrade; the earliest executable ledger is stored underDataKey::UpgradeEffectiveAt. Anupg_propevent is emitted with both values. - Wait 48h —
UPGRADE_TIMELOCK_LEDGERS = 34_560ledgers (48h × 3600s / 5s/ledger) must elapse. - Step 2 — anyone may call
execute_upgrade()after the timelock has elapsed. On success the contract WASM is swapped viaenv.deployer().update_current_contract_wasm, the executed hash is recorded underDataKey::LastExecutedUpgrade, and anupg_execevent is emitted. - Cancel — admin may call
cancel_upgrade(admin)at any time before execution to drop a pending upgrade.
SECURITY: the 48h timelock is the SOLE delay between a proposed upgrade and its execution. If the admin key is compromised, the attacker can propose_upgrade immediately, but the community has 48h to react (exit positions, deploy a rescue contract, signal objections off-chain) before the WASM is swapped. There is no second gate.
Helpers:
get_pending_upgrade() -> Option<(BytesN<32>, u32)>— hash + effective_at ledger of the pending upgrade, orNone.get_last_executed_upgrade() -> Option<BytesN<32>>— hash of the most-recently executed upgrade.Noneif the contract has never been upgraded.
Every state change in the new trust model emits an indexed event for indexer consumers:
| Event topic | Trigger |
|---|---|
ad_xfer |
transfer_admin queued |
ad_acc |
accept_admin promoted |
ad_xfc |
cancel_admin_transfer cleared |
paused |
pause_contract set the pause flag |
unpause |
unpause_contract lifted the pause flag |
upg_prop |
propose_upgrade queued (hash + effective_at) |
upg_exec |
execute_upgrade swapped the WASM |
upg_cncl |
cancel_upgrade dropped the pending upgrade |
This section records the security review of arithmetic operations in the IndigoPay contract, with focus on integer overflow in global stats accumulators.
Audit covers all arithmetic in record_donation and related functions that update global state:
GlobalTotalRaised(i128)GlobalCO2OffsetGrams(i128)- Project and donor statistics
All critical arithmetic operations use Rust's checked_add to prevent silent overflow:
-
GlobalTotalRaised updates
- Line 311:
gr.checked_add(amount).expect("GlobalTotalRaised overflow") - Line 610:
gr.checked_add(xlm_equivalent).expect(...) - Panics if sum exceeds i128::MAX (9,223,372,036,854,775,807)
- Line 311:
-
GlobalCO2OffsetGrams updates
- Line 315:
gc.checked_add(co2_increment).expect("GlobalCO2 overflow") - Line 614:
gg.checked_add(co2_increment).expect(...) - Panics if sum exceeds i128::MAX
- Line 315:
-
Pre-computation of CO2 increment
- Line 260:
xlm_units.checked_mul(project.co2_per_xlm as i128).expect("CO2 calculation overflow") - Prevents multiplication overflow before accumulation
- Line 260:
-
Project and Donor statistics
- Line 273: Project total_raised uses checked_add
- Line 283: Donor total_donated uses checked_add
- Line 287: Donor co2_offset_grams uses checked_add
- All checked operations with panic on overflow
Max donation scenarios:
-
Single donation: i128::MAX stroops (9.22e18 XLM equivalent)
-
With CO2 factor: 100 grams/XLM max project setting
- Overflow would occur at: i128::MAX / 100 = 9.22e16 XLM
- Current check prevents all overflow paths
-
Multiple donations accumulating to GlobalTotalRaised:
- Each donation checked individually before accumulation
- Cumulative cap: i128::MAX (9.22e18 stroops total)
- Current design prevents integer wrap-around
No silent overflows possible. All operations that could exceed i128::MAX will panic with descriptive messages. The contract is safe for production use with any realistic donation volume.