A staking platform for the ZEN token, built on top of Tally's audited Staker contracts.
Users stake ZEN to earn ZEN rewards. The smart-contract layer is a thin, additive wrapper over the audited base: the only changes are view-only helper functions that reduce RPC round-trips for the frontend. All write-path logic (stake, withdraw, claim) is inherited unchanged.
Security issues are covered by the Horizen bug bounty program on Immunefi: https://immunefi.com/bug-bounty/horizen. Please report privately there, or via this repo's Security → Report a vulnerability — not through public issues or pull requests. Scope, impacts, rewards, rules of engagement (PoC execution is local-only), and the full known-issue list live on the program page and in SECURITY.md.
Before reporting, please check the known behaviors. The staking contracts are a thin, additive wrapper over the audited Tally/ScopeLift Staker base; the one Horizen-specific contract is RewardAccumulator, and its permissionless open-mode behavior is documented and accepted (see SECURITY.md). In particular, the following are out of scope — they are timing effects with no loss, not vulnerabilities:
- an empty / zero-reward
sendRewardsToStaker()flush advancing the reward-window grid and delaying a later deposit by up to one window (self-healing, full delivery); and - a sub-
REWARD_DURATION(dust) contribution reverting the flush via the inheritedStaker__InvalidRewardRateguard (atomic and recoverable — anyone can top the balance above the threshold; funds are never stuck and nothing is stolen).
Loss or incorrect attribution of principal/rewards, permanent denial of distribution via a different mechanism, over-extraction, or theft remain in scope.
ZenStaker gives Horizen holders a native staking experience:
- Stake ZEN - deposit any amount of ZEN into an on-chain position.
- Earn ZEN rewards - rewards accrue continuously, proportional to each user's share of total staked ZEN.
- Claim at any time - no lock-up periods; rewards can be claimed whenever the user wants.
- Delegate - each deposit designates a delegatee address (governance-forwarding is out of scope for Phase 1; a non-voting surrogate holds the tokens).
- Custom claimer - reward collection can be delegated to a separate address (e.g. a vesting contract).
The frontend reads all the data it needs in as few RPC calls as possible by calling the batch view helpers added to ZenStaker.sol.
Deposit ZEN into a new position. Each deposit gets a unique ID and designates:
- a delegatee - the address that receives the deposit's (non-voting) surrogate delegation;
- an optional claimer - a separate address authorised to claim rewards on the owner's behalf.
Two paths:
approve+stake(amount, delegatee)- two transactions.permitAndStake(amount, delegatee, claimer, deadline, v, r, s)- single transaction using an EIP-2612 signature. Note: the production ZEN token does not implement EIP-2612permit. The inherited code swallows the failed permit (try/catch), so without a pre-existing allowance the call reverts attransferFrom— the single-transaction path only works with permit-capable test tokens.
Add ZEN to an existing deposit without opening a new one:
stakeMore(depositId, amount) - owner only; preserves the existing delegatee and claimer.
Collect accrued ZEN rewards for a deposit:
claimReward(depositId) - callable by the deposit owner or its claimer.
Rewards are sent directly to the caller. There are no fees (fee is fixed at 0 in Phase 1).
Pull staked ZEN out of a deposit (partially or fully):
withdraw(depositId, amount) - owner only.
Rewards are not claimed automatically; call claimReward first to avoid leaving them stranded.
Reassign the surrogate delegation of an existing deposit:
alterDelegatee(depositId, newDelegatee) - owner only.
Reassign the address authorised to collect rewards for a deposit:
alterClaimer(depositId, newClaimer) - owner only.
ZenStaker (src/ZenStaker.sol)
├── inherits Staker (src/Staker.sol) ← audited by Sherlock, Offbeat, Cantina
├── inherits StakerPermitAndStake ← EIP-2612 single-tx stake
└── adds view helpers (no new state)
getDepositInfo(id)
getDepositsInfo(ids[])
getGlobalState()
getDepositorSummary(addr)
getDepositorFullSummary(addr, ids[])
ZenDelegationSurrogate (src/ZenStaker.sol)
└── inherits DelegationSurrogate ← holds staked tokens, no vote delegation
IdentityEarningPowerCalculator (src/calculators/)
└── earning power == stake (1:1), used in Phase 1
All changes are additive only. No write-path logic, storage layout, or function behaviour was altered.
| Change | Kind | Behavioural impact |
|---|---|---|
src/ZenStaker.sol - new file |
New concrete implementation | Inherits audited base; adds view helpers |
StakeDeposited.owner → indexed |
Event schema | None - enables efficient frontend filtering |
StakeWithdrawn.owner → indexed |
Event schema | None - enables efficient frontend filtering |
See AUDIT_DELTA.md for the full audit-diff rationale.
The frontend reads on-chain state through the view helpers and listens to indexed events to discover deposit IDs (deposit IDs are not enumerable on-chain).
Key contract calls:
| What to display | Function |
|---|---|
| Protocol-wide ZEN staked & reward rate | getGlobalState() |
| User's total staked (no deposit IDs needed) | getDepositorSummary(addr) |
| User's total staked + unclaimed rewards | getDepositorFullSummary(addr, ids[]) |
| Per-deposit breakdown | getDepositsInfo(ids[]) |
| Discover deposit IDs | StakeDeposited events filtered by owner (indexed) |
For the complete ethers.js integration guide (TypeScript snippets, ABI, error handling, event subscription) see docs/frontend-integration.md.
src/
ZenStaker.sol # ZEN-specific concrete implementation
Staker.sol # Audited base (Tally)
calculators/
IdentityEarningPowerCalculator.sol # Phase 1: earning power == stake
extensions/
StakerPermitAndStake.sol # EIP-2612 single-tx stake
...
script/
DeployZenStaker.s.sol # Foundry deployment script
ConfigureRewardNotifier.s.sol # Post-deploy admin script
test/e2e/
e2e-zen-staker.js # Node.js end-to-end test (ethers v6)
docs/
frontend-integration.md # ethers.js integration guide
audits/ # All upstream audit reports
AUDIT_DELTA.md # Change summary for auditors
- Foundry - for Solidity build and tests
- Node.js ≥ 18 + npm - for the e2e script
forge install
forge buildforge testThe e2e scripts deploy the contract stack and exercise the entire staking lifecycle (stake → accrue rewards → claim → withdraw).
Against a local Anvil node (no .env needed):
npm install
npm run e2e:anvilAgainst a testnet:
cp .env.template .env
# fill in: RPC_URL, DEPLOYER_PRIVATE_KEY, USER1_PRIVATE_KEY, USER2_PRIVATE_KEY
npm run e2eTo deploy the non-upgradeable ZenStaker implementation using Foundry, you will use the provided scripts in the script/ directory.
Export the following environment variables (or set them in .env):
ZEN_TOKEN_ADDRESS: Address of the deployed ZEN ERC20 token.ADMIN_ADDRESS: Address of the Horizen multisig (becomes staker admin).PRIVATE_KEY: Deployer private key (hex, with or without 0x prefix).MAX_BUMP_TIP(Optional): Maximum bump tip (defaults to 0).
Execute the deploy script:
forge script script/DeployZenStaker.s.sol --rpc-url $RPC_URL --broadcastTo authorize a contract or account to send rewards to the staker, export these variables:
STAKER_ADDRESS: Address of the deployedZenStakercontract.REWARD_NOTIFIER_ADDRESS: Address of the reward notifier to enable.PRIVATE_KEY: Admin private key (must be theADMIN_ADDRESSspecified during deploy).
Execute the configuration script:
forge script script/ConfigureRewardNotifier.s.sol --rpc-url $RPC_URL --broadcastZenStaker inherits the Tally Staker base at tag v1.0.1, which was audited by:
- Sherlock (Nov 2024 contest + Jan 2025 public contest)
- Offbeat Security (Jan 2025)
- Cantina (public contest)
- ToB + Code4rena on the original UniStaker foundation (see audits/unistaker/)
The ZenStaker additions (view helpers + event indexing) are stateless and require no re-audit of core logic; see AUDIT_DELTA.md.
AGPL-3.0-only - see LICENSE.