Repository navigation
Smart Contract Testing with Foundry
- Introduction
- Project Structure
- Core Components
- Architecture Overview
- Detailed Component Analysis
- Dependency Analysis
- Performance Considerations
- Troubleshooting Guide
- Conclusion
- Appendices
This document explains Sigvara’s smart contract testing framework built on Foundry. It covers how tests are organized, how to write unit, integration, and end-to-end tests, and how the suite validates state transitions, events, error conditions, fuzzing, invariants, and gas-sensitive behavior. It also documents the RegistrationHelper utility and common test helpers for agent registration, stake management, time control, role setup, and mock token usage.
The repository uses:
- Forge-std for EVM utilities, mocking, and assertions.
- OpenZeppelin contracts for proxies, access control, and ERC-20 tokens.
- A consistent pattern of deploying proxy implementations and wiring cross-contract roles in
setUp. - Fuzzing and invariant testing for protocol stability.
- Regression suites that pin security fixes and edge cases.
The Solidity test suite lives under test/, with shared helpers under test/helpers/. The main configuration is in foundry.toml, which defines source/output directories, remappings, optimizer settings, the EVM version (cancun), fuzz runs, RPC endpoints, and filesystem permissions.
As of 29 October 2026 the suite has 699 tests across 30 files, and each of the 19 invariants counts once. Without a fork URL, 692 of them run and 7 skip. Beside the files in the diagram below, other suites cover the consumer reads (SigvaraReader.t.sol, GatedMarketplace.t.sol), the guardian pause (InflowGuard.t.sol), timelocked role grants (TimelockedGrants.t.sol, UpgraderRoleAdmin.t.sol), operator custody (OperatorCustody.t.sol), the move of admin and upgrade rights to a timelock and Safe (SplitAuthority.t.sol, with SplitAuthorityFork.t.sol rehearsing it on a fork of the testnet), the deploy scripts (DeployScripts.t.sol), reentrancy guards (Reentrancy.t.sol), smart-wallet agents (SmartWalletAgent.t.sol), a slash rehearsal against the live testnet deployment (SlashDrillFork.t.sol), and the operator bond registry's invariants (OracleBondInvariant.t.sol). SigvaraReader.t.sol, GatedMarketplace.t.sol, InflowGuard.t.sol, OperatorCustody.t.sol and SmartWalletAgent.t.sol share test/helpers/StackFixture.sol, which deploys the three registries behind proxies, a funded operator and a SigvaraReader. SlashDrillFork.t.sol forks Arc testnet on every run, so it needs the arc_testnet endpoint in foundry.toml to answer, and SplitAuthorityFork.t.sol skips its seven tests unless you pass --fork-url https://rpc.testnet.arc.io.
graph TB
Root["Repository Root"] --> Foundry["foundry.toml"]
Root --> Src["src/*.sol"]
Root --> Test["test/*.t.sol"]
Test --> Helpers["test/helpers/RegistrationHelper.sol"]
Test --> UnitTests["Unit Tests<br/>SigvaraStaking.t.sol<br/>SigvaraIdentity.t.sol"]
Test --> IntegrationTests["Integration / E2E<br/>E2E.t.sol"]
Test --> InvariantTests["Invariant Tests<br/>StakingInvariant.t.sol"]
Test --> EdgeCases["Edge Cases<br/>FeeOnTransfer.t.sol<br/>DisputeFreeze.t.sol<br/>SecurityFixes.t.sol<br/>ReporterAuthority.t.sol<br/>UnbondingSnapshot.t.sol<br/>RejectedEvidence.t.sol"]
Diagram sources
- foundry.toml
- RegistrationHelper.sol:1-42
- E2E.t.sol
- StakingInvariant.t.sol
- SigvaraStaking.t.sol:1-200
- SigvaraIdentity.t.sol:1-200
- FeeOnTransfer.t.sol:1-306
- DisputeFreeze.t.sol:1-202
- SecurityFixes.t.sol:1-200
Section sources
- RegistrationHelper: Shared helper for signing and registering agents across tests.
- Mock SVR Token: Minimal ERC-20 used by most tests to represent the bond/stake token.
- Proxy Setup Pattern: Tests deploy upgradeable implementations behind ERC1967 proxies and wire roles between Identity, Reputation, Staking, and other contracts.
- Time Control: Tests use
vm.warpto advance time through challenge windows, unbonding periods, and dispute resolution windows. - Role Management: Admin grants roles such as STAKING_CORE_ROLE, ORACLE_ROLE, and SLASHING_COMMITTEE_ROLE during setup. That address can do it because
initializealso gives it UPGRADER_ROLE: on main the staking-core, committee and slasher roles are administered by UPGRADER_ROLE, which the timelock holds on a real deployment (TimelockedGrants.t.solandUpgraderRoleAdmin.t.solpin this).
Key responsibilities:
- RegistrationHelper abstracts signature creation and registration calls so tests focus on business logic.
- E2E.t.sol exercises the full lifecycle: register → stake → propose/finalize reputation → slash → zero-reputation, plus disputes, unbonding, and multi-agent scenarios.
- StakingInvariant.t.sol drives randomized call sequences via a handler and asserts global invariants like custody conservation and collateralization.
- FeeOnTransfer.t.sol ensures accounting correctness when the token charges a transfer fee.
- DisputeFreeze.t.sol and SecurityFixes.t.sol pin regression paths for critical fixes.
- ReporterAuthority.t.sol, UnbondingSnapshot.t.sol and RejectedEvidence.t.sol cover three staking changes: a slash is executed only while its reporter is on the committee, a withdrawal's claim time is fixed when it is queued, and evidence the admin rejected is refused for that agent. Each pins its behaviour with tests that fail when the change is removed.
Section sources
- RegistrationHelper.sol:1-42
- E2E.t.sol
- StakingInvariant.t.sol
- FeeOnTransfer.t.sol:1-306
- DisputeFreeze.t.sol:1-202
- SecurityFixes.t.sol:1-200
The test architecture mirrors the production system:
- Identity manages agent identities and status.
- Reputation proposes and finalizes scores with a challenge window.
- Staking holds bonds, enforces slashing, and manages withdrawals.
- Optional contracts (OracleBond, EpochFees) appear in specific test suites.
graph TB
TestSuite["Test Suite"] --> Helper["RegistrationHelper"]
TestSuite --> MockToken["Mock SVR Token"]
TestSuite --> Identity["SigvaraIdentity"]
TestSuite --> Reputation["SigvaraReputation"]
TestSuite --> Staking["SigvaraStaking"]
Identity --> Staking
Reputation --> Staking
Staking --> MockToken
Diagram sources
- E2E.t.sol:1-110
- StakingInvariant.t.sol:207-269
- SigvaraStaking.t.sol:46-98
- RegistrationHelper.sol:1-42
RegistrationHelper centralizes agent registration and signature generation. It:
- Derives the agent address from a private key.
- Computes the registration digest using the identity contract.
- Signs the digest with the agent’s private key using forge-std’s
vm.sign. - Wraps the signature into the format expected by the identity contract.
- Registers the agent under an operator via
registerAgent.
This abstraction removes repetitive signing ceremony code from individual tests and makes tampering with signatures or call parameters straightforward for negative tests.
flowchart TD
Start(["registerSigned"]) --> Addr["Derive agent address from private key"]
Addr --> Digest["Compute registrationDigest(...)"]
Digest --> Sign["Sign digest with vm.sign(...)"]
Sign --> Prank["Prank operator"]
Prank --> Call["Call identity.registerAgent(agentAddress, ed25519PubKey, sig)"]
Call --> Return["Return didHash"]
Diagram sources
Section sources
E2E.t.sol demonstrates the complete agent lifecycle and several advanced flows:
- Registration leaves the agent in PendingBond; depositing stake activates it.
- Oracle proposes reputation; committee can reject within the challenge window.
- Slash initiation suspends the agent; after the challenge period, execution burns half and credits victim/reporter.
- Disputes freeze the bond until resolved or expired.
- Unbonding prevents early withdrawal; queued funds remain slashable.
- Multiple epochs evolve reputation scores over time.
- Multiple agents operate independently.
sequenceDiagram
participant T as "Test"
participant H as "RegistrationHelper"
participant I as "SigvaraIdentity"
participant S as "SigvaraStaking"
participant R as "SigvaraReputation"
participant M as "MockSVR"
T->>H : registerSigned(identity, operator, agentPk, pubKey)
H->>I : registerAgent(agentAddress, pubKey, sig)
T->>S : depositStake(didHash, amount)
T->>R : proposeReputation(didHash, scoreData)
T->>R : finalizeReputation(didHash) after challenge window
T->>S : initiateSlash(didHash, victim, evidence)
T->>S : executeSlash(didHash) after challenge window
T->>S : claimSlashProceeds() for victim and committee
T->>I : verify status Slashed and reputation zeroed
Diagram sources
- E2E.t.sol:116-216
- E2E.t.sol:218-256
- E2E.t.sol:258-295
- E2E.t.sol:297-331
- E2E.t.sol:333-361
- E2E.t.sol:363-407
- E2E.t.sol:409-448
- E2E.t.sol:454-508
Section sources
StakingInvariant.t.sol defines a handler that randomly drives staking operations and tracks totals for deposits, claims, slashed-out amounts, and proceeds claimed. It then asserts invariants:
- Custody matches recorded obligations: stake + queued withdrawals + unclaimed proceeds equals contract balance.
- Token conservation: total deposited equals held + claimed + burned + proceeds claimed.
- Slashed agents are fully zeroed: no stake or queued withdrawal remains.
- Burned-only grows: burn address never decreases.
- Disputed agents stay bonded: disputed proposals cannot release stake.
- Active agents are collateralized: active agents must hold at least minimum stake including queued withdrawals.
The handler rules on disputes as the admin, and it also drives expireSlash, so a proposal nobody executes or cancels can lapse in the fuzzed state space. OracleBondInvariant.t.sol is a second campaign, over the operator bond registry, with five more invariants: signer mappings stay symmetric, a signer is never also an operator, an address has standing only through a bonded Active operator, nobody is left signing for nobody, and the active count and custody still add up. Together the two campaigns hold the 11 invariants.
classDiagram
class StakingHandler {
+deposit(seed, amount)
+initiateWithdrawal(seed, amount)
+claimWithdrawal(seed)
+setStatus(seed, active)
+initiateSlash(seed)
+disputeSlash(seed)
+executeSlash(seed)
+resolveDispute(seed, uphold)
+expireDispute(seed)
+expireSlash(seed)
+cancelSlash(seed)
+claimProceeds(seed)
+warp(secondsForward)
+seedBonds(amount)
}
class StakingInvariantTest {
+invariant_custodyMatchesRecordedObligations()
+invariant_tokenConservation()
+invariant_slashedAgentsFullyZeroed()
+invariant_burnedOnlyGrows()
+invariant_disputedAgentsStayBonded()
+invariant_activeAgentsAreCollateralised()
}
StakingInvariantTest --> StakingHandler : "drives random actions"
Diagram sources
Section sources
SigvaraStaking.t.sol and SigvaraIdentity.t.sol provide granular coverage:
- Deposit accumulation, activation thresholds, and reactivation guards.
- Operator checks and unregistered agent handling.
- Status updates preventing unsafe reactivation without collateral.
- Operator transfer mechanics and index maintenance.
- Event emission verification and error selector checks.
These tests isolate single functions while still wiring up proxies and roles to reflect realistic interactions.
Section sources
FeeOnTransfer.t.sol introduces a token that charges a transfer fee and verifies:
- Deposits credit what actually arrives, not what was requested.
- Booked stake never exceeds contract balance.
- Agents do not activate on tokens they never received.
- Events report credited amounts.
- Exact-transfer tokens remain unaffected.
- Mixed deposits with changing fees net correctly.
It also includes sibling tests for OracleBond and EpochFees to ensure consistent accounting across contracts.
Section sources
DisputeFreeze.t.sol and SecurityFixes.t.sol pin regression paths:
- Disputes keep bonds frozen until resolved or expired.
- Re-initiating slashes over open disputes is blocked.
- Resolving disputes either slashes or reinstates the agent.
- Expired disputes release the bond.
- Cancelled slashes clear abandoned proposals.
- Security fixes include slash guard validations, period floors, deposit validation for unregistered DIDs, zero-bond clearing, and faucet cooldown behavior.
- Three later files each pin one staking change. ReporterAuthority.t.sol: a revoked reporter's proposal cannot be executed, yet a current member can cancel it and anyone can lapse it. UnbondingSnapshot.t.sol: lowering or raising
unbondingPeriodleaves a queued exit where it was, with a fuzz test over any new period and any wait. RejectedEvidence.t.sol: a veto or a ruling for the agent blocks the same evidence, while a committee cancel, a lapse and an expired dispute do not.
Section sources
The test suite depends on:
- Forge-std for VM controls (
prank,warp,sign,addr) and assertions. - OpenZeppelin for ERC1967 proxies, ERC-20 tokens, and access control interfaces.
- Production contracts under
src/for Identity, Reputation, Staking, OracleBond, EpochFees, and SVRToken.
graph LR
ForgeStd["forge-std/Test.sol"] --> Tests["All *.t.sol"]
OZProxies["@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"] --> Tests
OZERC20["@openzeppelin/contracts/token/ERC20/ERC20.sol"] --> Tests
OZAccess["@openzeppelin/contracts/access/IAccessControl.sol"] --> Tests
SrcContracts["src/*.sol"] --> Tests
Helpers["test/helpers/RegistrationHelper.sol"] --> Tests
Diagram sources
- foundry.toml:5-9
- E2E.t.sol:4-11
- StakingInvariant.t.sol:4-11
- SigvaraStaking.t.sol:4-12
- SigvaraIdentity.t.sol:4-8
- FeeOnTransfer.t.sol:4-13
- DisputeFreeze.t.sol:4-11
- SecurityFixes.t.sol:4-14
Section sources
- Optimizer enabled with 200 runs in default profile.
- Fuzz runs are 1,000 in the default profile and are increased to 5000 iterations in the CI profile for stronger coverage.
- Gas-sensitive behavior is validated indirectly through event emissions and accounting constraints (e.g., fee-on-transfer tests).
- Invariant tests stress complex state transitions to expose hidden inefficiencies or vulnerabilities.
Recommendations:
- Use
forge test --match-contract <Contract>to target slow suites. - Keep fuzz runs reasonable locally and rely on CI for heavier runs.
- Prefer delta-based accounting and bounded inputs in fuzz handlers.
Section sources
Common issues and debugging techniques:
- Signature mismatches: Ensure the digest prefix matches wallet expectations; RegistrationHelper applies EIP-191 prefix before signing.
- Role errors: Verify admin pranks and role grants in setUp; many tests require STAKING_CORE_ROLE, ORACLE_ROLE, or SLASHING_COMMITTEE_ROLE.
- Time-related failures: Use
vm.warpto advance past challenge windows, unbonding periods, and dispute resolution windows. - Event assertion failures: Use
vm.expectEmitwith correct selectors and data. - Error selector checks: Assert specific revert selectors rather than generic reverts for precise failure paths.
- Fork and deployment scripts: Use provided RPC endpoints and profiles; avoid adding etherscan entries for unsupported chain IDs.
Foundry-specific tips:
- Use
-vvvfor verbose output in E2E tests. - Leverage
makeAddrandmakeAddrAndKeyfor deterministic actors. - Inspect storage slots with
vm.loadfor index integrity checks. - Use
targetContractin invariant tests to constrain fuzzer input space.
Section sources
Sigvara’s Foundry test suite provides comprehensive coverage across unit, integration, end-to-end, fuzzing, and invariant testing. The RegistrationHelper utility simplifies agent registration and signature handling, while consistent proxy setup and role wiring mirror production behavior. The suite rigorously validates state transitions, event emissions, error conditions, fee-on-transfer accounting, dispute freezes, and security fixes. Invariant testing ensures protocol stability by asserting global properties under randomized action sequences. Together, these practices form a robust foundation for maintaining correctness and security in Sigvara’s smart contracts.
- Fetch the Solidity dependencies first:
sh scripts/fetch-deps.sh(OpenZeppelin and forge-std, each fetched by commit intolib/, which is gitignored; there are no submodules) - All contract tests:
forge test - CI fuzz runs:
FOUNDRY_PROFILE=ci forge test - Invariant tests:
forge test --match-contract StakingInvariantTest - Operator bond invariants:
forge test --match-contract OracleBondInvariantTest - E2E integration tests:
forge test --match-contract E2EIntegrationTest -vvv - Fork suites:
forge test --match-contract SlashDrillFork -vvforks the testnet, andforge test --match-path test/SplitAuthorityFork.t.sol --fork-url https://rpc.testnet.arc.io -vvruns the seven tests that are skipped without a fork URL
Section sources
- Agent Registration and Lifecycle
- Contract Overview and Architecture
- Economic Security Model and Incentives
- Evidence Verification and Merkle Proofs
- Key Management and Rotation
- Maturity Mechanism and Score Release
- Proposal Workflow and Challenge System
- Score Storage and Data Model
- SigvaraIdentity Contract
- SigvaraOracleBond Contract
- SigvaraReputation Contract
- SigvaraStaking Contract
- Slashing Lifecycle and Dispute Resolution
- Stake Management Functions
- Status Management and Control
- Token and Fee Contracts
- Unbonding Period and Withdrawal Security
- Anti-Manipulation and Security Measures
- Community Flag Processing
- Evidence Sources and Verification
- External Trust Integration
- Merkle Proof System
- Oracle Score Computation
- Payment-Backed Attestations
- Scoring Model and Formulas
- Agent Creation and Key Management
- Agent Operations
- Agent Registration Workflow
- Challenge-Response Authentication
- Gate Access Control
- Integration Patterns and Examples
- SDK Overview and Setup
- Verifier Functions