Repository navigation
Unbonding Period and Withdrawal Security
- Introduction
- Project Structure
- Core Components
- Architecture Overview
- Detailed Component Analysis
- Dependency Analysis
- Performance Considerations
- Troubleshooting Guide
- Conclusion
- Appendices
This document explains the unbonding period mechanics and withdrawal security model in the Sigvara staking system. It focuses on how the unbondingPeriod parameter prevents instant withdrawals, keeps queued stakes slashable during a discovery window, and interacts with slash proposals and challenge periods. It also covers the minimum and maximum unbonding constraints, edge cases such as slashing during unbonding, the sweep behavior that captures both active and unbonding stake, and the upgrade path for adding unbonding to existing deployments.
This page describes the contracts on main. The Arc testnet proxies run them: staking since its upgrade to 25c2d59 on 2 November 2026 (it took the bound on upholding a dispute on 28 October), and the oracle bond since its upgrade to 76bbc97 the same day. A withdrawal queued before the staking upgrade of 26 October 2026 still reads its claim time from the live unbondingPeriod at claim; one queued after it has its claim time fixed when it is queued. An oracle operator that started to leave before 2 November keeps the same older rule.
The unbonding and withdrawal logic lives primarily in the staking contract, while deployment scripts configure the default unbonding duration. Tests provide invariant coverage over deposit, withdrawal, slash, dispute, and claim flows. A related unbonding mechanism exists for oracle bonds, which provides a useful parallel for understanding cooldown-based exit safety.
graph TB
subgraph "Protocol Contracts"
Staking["SigvaraStaking<br/>Unbonding + Slash Flow"]
Identity["SigvaraIdentity<br/>Agent Status"]
Reputation["SigvaraReputation<br/>Slashable Reputation"]
OracleBond["SigvaraOracleBond<br/>Operator Bond Unbonding"]
end
subgraph "Deployment & Testing"
Deploy["Deploy.s.sol<br/>Default UNBONDING_PERIOD"]
InvariantTest["StakingInvariant.t.sol<br/>Fuzzed Invariants"]
OracleBondTest["SigvaraOracleBond.t.sol<br/>Cooldown Tests"]
end
Deploy --> Staking
Staking --> Identity
Staking --> Reputation
InvariantTest --> Staking
InvariantTest --> Identity
InvariantTest --> Reputation
OracleBondTest --> OracleBond
Diagram sources
- SigvaraStaking.sol:31-51
- Deploy.s.sol:48-73
- StakingInvariant.t.sol:207-268
- SigvaraOracleBond.sol:22-93
Section sources
- SigvaraStaking.sol:31-51
- Deploy.s.sol:48-73
- StakingInvariant.t.sol:207-268
- SigvaraOracleBond.sol:22-93
- Stake state: Active stake, queued unbonding amount, and the timestamp when unbonding started.
- Unbonding period: A configurable delay between initiating a withdrawal and claiming it; queued amounts remain slashable.
- Withdrawal initiation: Moves funds from active stake into an unbonding queue, enforcing minimum stake rules and slash proposal freezes.
- Withdrawal claim: Transfers queued funds only after the unbonding window closes and no slash is pending or disputed.
- Slash execution: Sweeps both active and unbonding stake, zeroes queued state, marks the agent slashed, and distributes proceeds.
- Minimum and maximum unbonding constraints: Admin cannot set an unbonding period shorter than a protocol-defined lower bound (1 day) or longer than an upper bound (90 days).
- Upgrade initializer: Adds unbonding functionality to proxies deployed before the feature existed, held to the same bounds.
Section sources
- SigvaraStaking.sol:76-85
- SigvaraStaking.sol:124-135
- SigvaraStaking.sol:378-423
- SigvaraStaking.sol:432-462
- SigvaraStaking.sol:715-778
- SigvaraStaking.sol:804-838
- SigvaraStaking.sol:280-303
The unbonding model ties these time windows together:
| Window | Purpose | Key Contract Behavior |
|---|---|---|
| Challenge period | Gives operators time to dispute a slash proposal | Slash initiation records a deadline; execution is blocked until it expires |
| Dispute resolution period | Bounds the admin's time to rule once a dispute is filed | Disputed proposals can be expired by anyone after the deadline |
| Execution period | Bounds how long an unexecuted proposal can hold a bond | A Pending proposal can be lapsed by anyone 14 days after its challenge deadline |
| Re-file cooldown | Leaves the operator a window to act after a dropped proposal | The committee cannot file against the agent again for one challenge period |
| Unbonding period | Prevents instant withdrawal and keeps queued stake slashable | Withdrawals are queued and claimable only after the cooldown, whose end is fixed when the withdrawal is queued |
sequenceDiagram
participant Operator as "Operator"
participant Staking as "SigvaraStaking"
participant Identity as "SigvaraIdentity"
participant Committee as "Slashing Committee"
participant Admin as "Admin"
participant Victim as "Victim"
Operator->>Staking : "initiateWithdrawal(didHash, amount)"
Staking->>Staking : "Move amount to unbonding queue"
Staking-->>Operator : "Event : WithdrawalInitiated(claimableAt)"
Committee->>Staking : "initiateSlash(didHash, victim, evidence)"
Staking->>Identity : "updateStatus(Suspended)"
Staking-->>Committee : "Event : SlashInitiated"
Operator->>Staking : "disputeSlash(didHash)"
Staking->>Staking : "Set state = Disputed"
Note over Staking : "During Pending/Disputed : <br/>- Withdrawal initiation reverts<br/>- Withdrawal claim reverts"
Admin->>Staking : "resolveDispute(didHash, uphold=true)"
Staking->>Staking : "_settleSlash()"
Staking->>Staking : "Sweep active + unbonding stake"
Staking->>Victim : "Credit 25%"
Staking->>Staking : "Burn 75%"
Staking->>Identity : "updateStatus(Slashed)"
Staking-->>Committee : "Event : SlashExecuted"
Diagram sources
- SigvaraStaking.sol:378-423
- SigvaraStaking.sol:474-531
- SigvaraStaking.sol:543-559
- SigvaraStaking.sol:715-778
The stake data structure tracks:
- Active stake balance
- Locked-at timestamp used for off-chain reporting
- Queued unbonding amount
- Timestamp when unbonding was initiated
This design ensures that new storage fields for unbonding do not shift existing mappings, preserving compatibility on live proxy upgrades.
classDiagram
class Stake {
+uint256 amount
+uint256 lockedAt
+uint256 unbondingAmount
+uint256 unbondingAt
}
class SlashProposal {
+bytes32 didHash
+address reporter
+address victim
+uint256 initiatedAt
+SlashState state
+bytes evidenceHash
+uint256 challengeDeadline
+uint256 disputedAt
}
class SigvaraStaking {
+uint256 minimumStake
+uint256 challengePeriod
+uint256 unbondingPeriod
+depositStake(didHash, amount)
+initiateWithdrawal(didHash, amount)
+claimWithdrawal(didHash)
+executeSlash(didHash)
+setUnbondingPeriod(newPeriod)
}
SigvaraStaking --> Stake : "stores per didHash"
SigvaraStaking --> SlashProposal : "stores per didHash"
Diagram sources
- SigvaraStaking.sol:76-99
- SigvaraStaking.sol:124-135
- SigvaraStaking.sol:321-364
- SigvaraStaking.sol:378-462
- SigvaraStaking.sol:715-778
Section sources
When an operator initiates a withdrawal:
- The function verifies there is stake and no existing queued withdrawal.
- It checks the caller is the registered operator.
- It rejects initiation if a slash proposal is pending or disputed.
- It validates the requested amount does not exceed active stake.
- For active agents, it enforces that remaining active stake stays at or above
minimumStake, unless fully exiting via suspension. - It moves the requested amount into the unbonding queue and emits a withdrawal event including the claimable timestamp.
flowchart TD
Start(["initiateWithdrawal Entry"]) --> CheckStake["Check stake > 0"]
CheckStake --> NoStake{"No stake?"}
NoStake --> |Yes| RevertNoStake["Revert: NoStake"]
NoStake --> |Yes| EndExit(["Exit"])
CheckStake --> |No| CheckQueue["Check no queued withdrawal"]
CheckQueue --> HasQueue{"Queued withdrawal?"}
HasQueue --> |Yes| RevertAlreadyPending["Revert: WithdrawalAlreadyPending"]
HasQueue --> |No| VerifyOperator["Verify operator"]
VerifyOperator --> SlashCheck["Check slash state"]
SlashCheck --> SlashActive{"Pending or Disputed?"}
SlashActive --> |Yes| RevertSlashPending["Revert: SlashAlreadyPending"]
SlashActive --> |No| ValidateAmount["Validate amount <= active stake"]
ValidateAmount --> Insufficient{"Insufficient stake?"}
Insufficient --> |Yes| RevertInsufficient["Revert: InsufficientStake"]
Insufficient --> |No| EnforceMinimum["Enforce minimumStake for Active agents"]
EnforceMinimum --> MoveToQueue["Move amount to unbondingAmount/unbondingAt"]
MoveToQueue --> EmitEvent["Emit WithdrawalInitiated"]
EmitEvent --> EndExit
Diagram sources
Section sources
A queued withdrawal becomes claimable only after:
- The current block timestamp reaches the claim time fixed when the withdrawal was queued (
unbondingClaimableAt, the queue time plus theunbondingPeriodthen in force). A withdrawal queued before that field existed usesunbondingAt + unbondingPeriodat claim. - There is no pending or disputed slash proposal.
- The caller is the registered operator.
If a slash executes during the unbonding window, _settleSlash sweeps both active stake and queued unbonding stake, zeroes the queue, and makes the withdrawal unclaimable.
flowchart TD
Start(["claimWithdrawal Entry"]) --> CheckQueue["Check unbondingAmount > 0"]
CheckQueue --> NoQueue{"No queued amount?"}
NoQueue --> |Yes| RevertNoWithdrawal["Revert: NoWithdrawalPending"]
NoQueue --> |No| VerifyOperator["Verify operator"]
VerifyOperator --> SlashCheck["Check slash state"]
SlashCheck --> SlashActive{"Pending or Disputed?"}
SlashActive --> |Yes| RevertSlashPending["Revert: SlashAlreadyPending"]
SlashActive --> |No| CheckTime["Check block.timestamp >= unbondingClaimableAt"]
CheckTime --> NotElapsed{"Not elapsed?"}
NotElapsed --> |Yes| RevertUnbondingActive["Revert: UnbondingPeriodActive"]
NotElapsed --> |No| ZeroQueue["Zero unbondingAmount, unbondingAt and unbondingClaimableAt"]
ZeroQueue --> Transfer["Transfer queued amount to operator"]
Transfer --> EmitClaim["Emit WithdrawalClaimed"]
Diagram sources
Section sources
Slash execution:
- Requires a pending slash proposal.
- Blocks execution until the challenge deadline has passed.
- Sweeps both active stake and queued unbonding stake.
- Zeros active stake, queued amount, and unbonding timestamp.
- Marks the slash executed and updates identity status to slashed.
- Credits 25% to the victim and burns the other 75%. The reporter gets nothing.
flowchart TD
Start(["executeSlash Entry"]) --> CheckProposal["Check slash state == Pending"]
CheckProposal --> NotPending{"Not pending?"}
NotPending --> |Yes| RevertNoActive["Revert: NoActivePendingSlash"]
NotPending --> |No| CheckDeadline["Check block.timestamp > challengeDeadline"]
CheckDeadline --> StillInWindow{"Still in challenge window?"}
StillInWindow --> |Yes| RevertChallengeActive["Revert: ChallengePeriodActive"]
StillInWindow --> |No| Settle["_settleSlash()"]
Settle --> Sweep["Sweep active + unbonding stake"]
Sweep --> ZeroState["Zero stake, unbondingAmount, unbondingAt"]
ZeroState --> Distribute["Distribute 75% burn, 25% victim"]
Distribute --> MarkSlashed["Mark agent Slashed and zero reputation"]
MarkSlashed --> EmitSlash["Emit SlashExecuted"]
Diagram sources
Section sources
The staking contract defines a minimum unbonding period constant. Admin setters enforce this lower bound, preventing administrators from making queued withdrawals instantly claimable while a slash is being prepared. It also defines a maximum (90 days), so one huge value cannot lock every withdrawal queued while it stands. The period is read when a withdrawal is queued and the claim time is fixed then, so changing the setting never moves a withdrawal that is already waiting: an administrator can neither release it early nor hold it back. initialize, setUnbondingPeriod and initializeV2 all hold both bounds.
| Parameter | Default Deployment Value | Protocol Lower Bound | Security Rationale |
|---|---|---|---|
| Challenge period | 7 days | 3 days | Ensures operators have a meaningful dispute window |
| Unbonding period | 21 days | 1 day | Prevents instant withdrawal claims during slash preparation |
| Dispute resolution period | Fixed constant (14 days) | N/A | Bounds the admin's time to rule so disputes cannot freeze bonds indefinitely |
The challenge period also has an upper bound of 30 days, so a slash cannot be put out of reach by a typo or a compromised key.
Section sources
The code explicitly documents that withdrawal security should not rely solely on unbondingPeriod being longer than challengePeriod, because both are admin-tunable. Instead, the contract hardens the flow by:
- Rejecting withdrawal initiation when a slash is pending or disputed.
- Rejecting withdrawal claim when a slash is pending or disputed.
- Sweeping queued unbonding stake during slash execution.
This means even if unbondingPeriod were accidentally set shorter than challengePeriod, an operator could not escape accountability for the queued portion. And because each withdrawal's claim time is fixed when it is queued, the setting cannot be lowered to release one that is already waiting.
The re-file cooldown (one challenge period after a dropped proposal) relies on the same property. A withdrawal queued after a drop stays slashable until it is claimed, and with the default periods (7 days against 21) the committee can file again before it can be claimed, so a cancelled slash is not a way to exit first. The same figures cut the other way: a committee that files again as soon as the cooldown allows freezes a queued withdrawal each time, and only the admin's veto and a committee that does not act alone stop it. That is the trust placed in the committee, not a gap in the contract (ADR 0005, decision 3).
Section sources
- Operator deposits stake.
- Operator initiates withdrawal.
- Queue remains slashable for
unbondingPeriod. - After the cooldown, operator claims the queued amount.
Expected outcome:
- Active stake decreases by the queued amount.
- Queued amount becomes transferable to the operator.
- No slash occurs.
- Operator initiates withdrawal; amount moves to unbonding queue.
- Committee initiates slash during the unbonding window.
- Operator may dispute; if undisputed, anyone executes slash.
- Slash sweeps both active stake and queued unbonding stake.
- Withdrawal claim reverts because the queue is zeroed.
Expected outcome:
- Operator loses both active and queued stake proportionally according to slash distribution.
- Agent status becomes slashed.
- No withdrawal can be claimed afterward.
- Operator suspends the agent.
- Operator queues the entire stake for withdrawal.
- After unbonding, operator claims the full amount.
Expected outcome:
- Active stake drops below
minimumStake, but this is allowed because the agent is not Active. - The operator must later reactivate through the identity registry if they want to return to Active.
Section sources
The invariant test suite fuzzes realistic sequences of actions across multiple operators and agents. It asserts properties that must hold regardless of the order of operations:
| Invariant | Meaning |
|---|---|
| Custody matches recorded obligations | Token balance held equals active stake plus queued withdrawals plus unclaimed slash proceeds |
| Token conservation | Total deposited equals held tokens plus claimed withdrawals plus burned and claimed slash proceeds |
| Slashed agents fully zeroed | Slashed agents have no active stake and no queued withdrawal |
| Burned-only grows | Burn address balance never decreases |
| Disputed agents stay bonded | Agents under dispute retain some bondable stake (active plus queued) |
| Active agents are collateralized | Active agents maintain at least minimumStake when combining active stake and queued withdrawals |
flowchart TD
Setup["Setup contracts, roles, operators, agents"] --> Seed["Seed bonds for all agents"]
Seed --> Fuzz["Fuzz actions:<br/>deposit, initiateWithdrawal, claimWithdrawal,<br/>initiateSlash, disputeSlash, executeSlash,<br/>resolveDispute, expireDispute, expireSlash,<br/>cancelSlash, claimProceeds, warp"]
Fuzz --> AssertCustody["Assert custodyMatchesRecordedObligations"]
Fuzz --> AssertConservation["Assert tokenConservation"]
Fuzz --> AssertSlashedZero["Assert slashedAgentsFullyZeroed"]
Fuzz --> AssertBurnGrows["Assert burnedOnlyGrows"]
Fuzz --> AssertDisputedBonded["Assert disputedAgentsStayBonded"]
Fuzz --> AssertCollateralized["Assert activeAgentsAreCollateralised"]
Diagram sources
Section sources
- Slashing during unbonding: Both active and queued stake are swept; queued state is zeroed.
- Withdrawal initiation blocked by slash: Pending or disputed slash proposals prevent new withdrawals.
- Withdrawal claim blocked by slash: Pending or disputed slash proposals prevent claiming queued funds.
- Admin cannot bypass dispute rights: Minimum challenge period prevents instant slash execution.
-
Freezes bounded: A dispute nobody rules on can be expired after a fixed resolution period, and a
Pendingproposal nobody executes can be lapsed 14 days after its deadline. The admin can also drop either withvetoSlash. - Re-file cooldown: After any dropped proposal the committee waits one challenge period before filing against the same agent again, so it cannot file, cancel and file again to keep a stake frozen.
-
Active agent collateralization: Active agents must keep enough stake (including queued) to satisfy
minimumStake. - Slash proceeds pull model: Proceeds are credited rather than pushed, avoiding permanent freezes caused by recipient reverts.
Section sources
- SigvaraStaking.sol:386-392
- SigvaraStaking.sol:439-449
- SigvaraStaking.sol:486-490
- SigvaraStaking.sol:715-778
- SigvaraStaking.sol:804-838
For proxies deployed before unbonding support:
- The original
initialize()consumes version 1. - A new
initializeV2()function is provided to setunbondingPeriod. - It uses
reinitializer(2)so it can run exactly once on upgraded proxies. - It is admin-gated to prevent strangers from configuring unbonding.
- It is held to the same floor and ceiling as
setUnbondingPeriod, so the one free call a fresh deployment leaves cannot set a shorter period than the setter would.script/Deploy.s.solnow uses it up straight afterinitialize, with the period just set. - Call it through
upgradeToAndCallwhen the caller holds bothDEFAULT_ADMIN_ROLEandUPGRADER_ROLE, as the testnet deployer does. Behind a timelock the upgrade lands with empty data and the admin makes this call in a second transaction. - If not called,
unbondingPeriodwould remain zero, making withdrawals instantly claimable — hence the deployment and upgrade process must ensure the correct unbonding period is set.
sequenceDiagram
participant Admin as "Admin"
participant Proxy as "Upgraded SigvaraStaking Proxy"
participant Impl as "New SigvaraStaking Implementation"
Admin->>Impl : "upgradeToAndCall(initializeV2(unbondingPeriod))"
Impl->>Proxy : "consume reinitializer(2)"
Proxy->>Impl : "set unbondingPeriod"
Impl-->>Admin : "Emit UnbondingPeriodUpdated"
Diagram sources
Section sources
The oracle bond contract implements a similar cooldown pattern:
- Operators call
initiateUnbond()to leave the active set. - The contract records
unbondingAtand fixes the withdrawal time then (unbondingClaimableAt, the start plus theunbondingPeriodin force).removeOperatorfixes it the same way. -
withdrawBond()is blocked until that time. An operator that started to leave before the field existed has none and keeps the older rule,unbondingAtplus the period in force at withdrawal. - As with staking, changing the period never moves an exit already started, so it cannot release a bond early or hold one back.
- While unbonding, the bond remains slashable.
- The cooldown is held between 1 and 90 days, the same bounds as staking's unbonding period.
This reinforces the broader design principle: unbonding is a cooldown, not an immediate exit.
Section sources
The staking contract depends on:
- SigvaraIdentity: To read agent identity and update status during slash flows.
- SigvaraReputation: To zero reputation when an agent is slashed.
-
ERC20 token: As the bond asset; the contract accepts any
IERC20but assumes exact-transfer semantics for accounting correctness. - AccessControlUpgradeable and UUPSUpgradeable: For role-based governance and upgradability.
graph LR
SVR["SVR Token (IERC20)"] --> Staking["SigvaraStaking"]
Identity["SigvaraIdentity"] --> Staking
Reputation["SigvaraReputation"] --> Staking
Access["OpenZeppelin AccessControlUpgradeable"] --> Staking
UUPS["OpenZeppelin UUPSUpgradeable"] --> Staking
Diagram sources
Section sources
- Balance delta measurement: Deposits measure actual token balance change rather than trusting the caller-provided amount, protecting against fee-on-transfer accounting drift.
- Pull model for slash proceeds: Avoids inline transfers to potentially failing recipients, preventing permanent stake freezes.
- View functions: Provide pending withdrawal and slash proposal state without modifying storage.
- Non-reentrant guards: Protect critical state transitions from reentrancy risks.
Common issues and their likely causes:
| Symptom | Likely Cause | Resolution |
|---|---|---|
| Withdrawal initiation reverts with slash-related error | A slash proposal is pending or disputed | Wait for dispute resolution or expiration before initiating another withdrawal |
| Withdrawal claim reverts with unbonding active | The claim time has not been reached | Wait until the claimableAt that getPendingWithdrawal reports, which was fixed when the withdrawal was queued |
| Withdrawal claim reverts with slash-related error | A slash proposal is pending or disputed | Resolve or expire the dispute before claiming |
| Slash execution reverts with challenge period active | The challenge window has not closed | Wait until the challenge deadline passes |
| Slash proceeds cannot be withdrawn | No proceeds are owed to the caller | Use the victim's address; a slash credits nobody else |
| Active agent cannot withdraw to zero | Remaining active stake would fall below minimumStake
|
Suspend the agent first, then withdraw fully |
Section sources
- SigvaraStaking.sol:378-423
- SigvaraStaking.sol:432-462
- SigvaraStaking.sol:715-778
- SigvaraStaking.sol:783-789
The unbonding period in Sigvara is a core security mechanism that prevents instant withdrawals and preserves accountability during slash discovery. By queuing withdrawals, keeping them slashable, blocking slash-related state changes, sweeping both active and unbonding stake on slash execution, and enforcing minimum unbonding constraints, the system ensures operators cannot escape responsibility for misbehavior discovered before their funds clear. Invariant testing further validates that custody, conservation, collateralization, and terminal slash states hold across arbitrary operation sequences.
| Setting | Default Value | Notes |
|---|---|---|
| Minimum stake | 1,000 SVR | Assumes 18 decimals; override for other tokens. On Arc mainnet (5042) the script refuses this default and anything under 10,000 SVR |
| Challenge period | 7 days | Slash dispute window; 3 to 30 days |
| Score challenge window | 6 hours | Reputation score challenge window; 1 hour to 7 days |
| Unbonding period | 21 days | Queued withdrawal cooldown; 1 to 90 days |
| Maturity rate per day | 4 points | Reputation maturity schedule; at most 100 |
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