Skip to content

Unbonding Period and Withdrawal Security

RunTimeAdmin edited this page Nov 3, 2026 · 18 revisions

Unbonding Period and Withdrawal Security

**Referenced Files in This Document** - [SigvaraStaking.sol](https://github.com/RunTimeAdmin/Sigvara/blob/main/src/SigvaraStaking.sol) - [Deploy.s.sol](https://github.com/RunTimeAdmin/Sigvara/blob/main/script/Deploy.s.sol) - [StakingInvariant.t.sol](https://github.com/RunTimeAdmin/Sigvara/blob/main/test/StakingInvariant.t.sol) - [SigvaraOracleBond.sol](https://github.com/RunTimeAdmin/Sigvara/blob/main/src/SigvaraOracleBond.sol) - [SigvaraOracleBond.t.sol](https://github.com/RunTimeAdmin/Sigvara/blob/main/test/SigvaraOracleBond.t.sol) - [0005-governance-and-emergency-powers.md](https://github.com/RunTimeAdmin/Sigvara/blob/main/docs/adr/0005-governance-and-emergency-powers.md)

Table of Contents

  1. Introduction
  2. Project Structure
  3. Core Components
  4. Architecture Overview
  5. Detailed Component Analysis
  6. Dependency Analysis
  7. Performance Considerations
  8. Troubleshooting Guide
  9. Conclusion
  10. Appendices

Introduction

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.

Project Structure

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
Loading

Diagram sources

Section sources

Core Components

  • 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

Architecture Overview

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"
Loading

Diagram sources

Detailed Component Analysis

Unbonding State and Data Model

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"
Loading

Diagram sources

Section sources

Withdrawal Initiation Mechanics

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
Loading

Diagram sources

Section sources

Withdrawal Claim and Slash Interaction

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 the unbondingPeriod then in force). A withdrawal queued before that field existed uses unbondingAt + unbondingPeriod at 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"]
Loading

Diagram sources

Section sources

Slash Execution and Sweep Behavior

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"]
Loading

Diagram sources

Section sources

Minimum Unbonding Period Constraints

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

Relationship Between Unbonding Period and Challenge Period

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

Examples of Withdrawal Timing Scenarios

Scenario 1: Normal Unbonding Without Slash

  1. Operator deposits stake.
  2. Operator initiates withdrawal.
  3. Queue remains slashable for unbondingPeriod.
  4. 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.

Scenario 2: Slash During Unbonding

  1. Operator initiates withdrawal; amount moves to unbonding queue.
  2. Committee initiates slash during the unbonding window.
  3. Operator may dispute; if undisputed, anyone executes slash.
  4. Slash sweeps both active stake and queued unbonding stake.
  5. 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.

Scenario 3: Full Exit With Suspension

  1. Operator suspends the agent.
  2. Operator queues the entire stake for withdrawal.
  3. 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

Invariant Testing Approach

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"]
Loading

Diagram sources

Section sources

Edge Cases Covered by the Implementation

  • 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 Pending proposal nobody executes can be lapsed 14 days after its deadline. The admin can also drop either with vetoSlash.
  • 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

Upgrade Path for Adding Unbonding Functionality

For proxies deployed before unbonding support:

  • The original initialize() consumes version 1.
  • A new initializeV2() function is provided to set unbondingPeriod.
  • 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.sol now uses it up straight after initialize, with the period just set.
  • Call it through upgradeToAndCall when the caller holds both DEFAULT_ADMIN_ROLE and UPGRADER_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, unbondingPeriod would 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"
Loading

Diagram sources

Section sources

Parallel Unbonding Model in Oracle Bonds

The oracle bond contract implements a similar cooldown pattern:

  • Operators call initiateUnbond() to leave the active set.
  • The contract records unbondingAt and fixes the withdrawal time then (unbondingClaimableAt, the start plus the unbondingPeriod in force). removeOperator fixes 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, unbondingAt plus 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

Dependency Analysis

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 IERC20 but 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
Loading

Diagram sources

Section sources

Performance Considerations

  • 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.

Troubleshooting Guide

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

Conclusion

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.

Appendices

Default Configuration Values

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

Overview

Clone this wiki locally