Conditional Unlocks

Advanced guide to implementing and using conditional unlock mechanisms in RealSafe.

Overview

Conditional unlocks allow you to create custom logic for when positions can be unlocked, beyond simple time-based locks. This opens up powerful use cases for DeFi protocols, DAOs, and advanced liquidity management strategies.

Table of Contents


Understanding Conditions

What is a Condition?

A condition is a smart contract that implements the ILockCondition interface:

interface ILockCondition {
    function isUnlocked(address locker, uint256 lockId) external view returns (bool);
}

When a lock has a condition set:

  1. Time Check: Standard unlock time is checked first

  2. Condition Check: If time hasn't passed, condition is checked

  3. Either/Or: Lock can be unlocked if EITHER condition is true

How Conditions Are Used

// When creating a lock with condition
lockPosition(
    tokenId,
    unlockDate,         // Standard time-based unlock
    collectAddress,
    conditionAddress    // Custom condition contract
);

// When unlocking
function _canUnlock(Lock storage lock) internal view returns (bool) {
    // Check 1: Time-based
    if (block.timestamp >= lock.unlockDate) {
        return true;
    }
    
    // Check 2: Condition-based
    if (lock.condition != address(0)) {
        try ILockCondition(lock.condition).isUnlocked(address(this), lockId) {
            returns (bool unlocked) {
            return unlocked;
        } catch {
            return false;  // Safe fallback
        }
    }
    
    return false;
}

Key Benefits

  • Flexibility: Unlock based on any on-chain logic

  • Composability: Combine multiple conditions

  • Safety: Falls back to time-based if condition fails

  • Transparency: All logic is on-chain and verifiable


Built-in Conditions

TimeBasedCondition

Simple admin-controlled unlock timestamps.

Deployment Addresses:

  • Monad Testnet: 0x3F400BaE5037474C3b8531CC61Cd177589093C9f

  • Monad Testnet: 0x3F400BaE5037474C3b8531CC61Cd177589093C9f

Use Case: Admin wants fine-grained control over individual lock unlock times.

// Lock with time condition
uint256 lockId = v3Locker.lockPosition{value: fee}(
    tokenId,
    block.timestamp + 365 days,  // Max time
    msg.sender,
    timeConditionAddress
);

// Admin can set earlier unlock time
timeCondition.setUnlockTimestamp(
    v3LockerAddress,
    lockId,
    block.timestamp + 30 days    // Unlock in 30 days instead
);

Custom Condition Examples

1. Price-Based Unlock

Unlock when an asset reaches a target price.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ILockCondition} from "../interfaces/ILockCondition.sol";
import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

contract PriceCondition is ILockCondition {
    AggregatorV3Interface public immutable priceFeed;
    address public immutable admin;
    
    struct PriceTarget {
        int256 targetPrice;
        bool above;  // true = unlock when price above, false = below
    }
    
    mapping(address => mapping(uint256 => PriceTarget)) public targets;
    
    constructor(address _priceFeed) {
        priceFeed = AggregatorV3Interface(_priceFeed);
        admin = msg.sender;
    }
    
    function setTarget(
        address locker,
        uint256 lockId,
        int256 targetPrice,
        bool above
    ) external {
        require(msg.sender == admin, "Not admin");
        targets[locker][lockId] = PriceTarget(targetPrice, above);
    }
    
    function isUnlocked(address locker, uint256 lockId) 
        external 
        view 
        override 
        returns (bool) 
    {
        PriceTarget memory target = targets[locker][lockId];
        if (target.targetPrice == 0) return false;
        
        (, int256 price, , , ) = priceFeed.latestRoundData();
        
        return target.above ? 
            price >= target.targetPrice : 
            price <= target.targetPrice;
    }
}

Usage:

// Deploy price condition
PriceCondition priceCondition = new PriceCondition(ethUsdPriceFeed);

// Lock: unlock when ETH > $5000
priceCondition.setTarget(
    v3LockerAddress,
    lockId,
    5000 * 1e8,  // $5000 (8 decimals)
    true         // above
);

2. TVL-Based Unlock

Unlock when protocol TVL reaches a threshold.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ILockCondition} from "../interfaces/ILockCondition.sol";

interface ITVLOracle {
    function getTVL() external view returns (uint256);
}

contract TVLCondition is ILockCondition {
    ITVLOracle public immutable tvlOracle;
    
    mapping(address => mapping(uint256 => uint256)) public tvlThresholds;
    
    constructor(address _tvlOracle) {
        tvlOracle = ITVLOracle(_tvlOracle);
    }
    
    function setThreshold(
        address locker,
        uint256 lockId,
        uint256 tvlThreshold
    ) external {
        tvlThresholds[locker][lockId] = tvlThreshold;
    }
    
    function isUnlocked(address locker, uint256 lockId) 
        external 
        view 
        override 
        returns (bool) 
    {
        uint256 threshold = tvlThresholds[locker][lockId];
        if (threshold == 0) return false;
        
        uint256 currentTVL = tvlOracle.getTVL();
        return currentTVL >= threshold;
    }
}

3. Multi-Signature Unlock

Require multiple signatures to unlock.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ILockCondition} from "../interfaces/ILockCondition.sol";

contract MultiSigCondition is ILockCondition {
    struct MultiSig {
        address[] signers;
        uint256 threshold;
        uint256 signCount;
        mapping(address => bool) hasSigned;
    }
    
    mapping(address => mapping(uint256 => MultiSig)) private multiSigs;
    
    event MultiSigCreated(address indexed locker, uint256 indexed lockId);
    event Signed(address indexed locker, uint256 indexed lockId, address indexed signer);
    
    function createMultiSig(
        address locker,
        uint256 lockId,
        address[] calldata signers,
        uint256 threshold
    ) external {
        require(signers.length > 0, "No signers");
        require(threshold > 0 && threshold <= signers.length, "Invalid threshold");
        
        MultiSig storage ms = multiSigs[locker][lockId];
        ms.signers = signers;
        ms.threshold = threshold;
        
        emit MultiSigCreated(locker, lockId);
    }
    
    function sign(address locker, uint256 lockId) external {
        MultiSig storage ms = multiSigs[locker][lockId];
        require(!ms.hasSigned[msg.sender], "Already signed");
        
        bool isSigner = false;
        for (uint i = 0; i < ms.signers.length; i++) {
            if (ms.signers[i] == msg.sender) {
                isSigner = true;
                break;
            }
        }
        require(isSigner, "Not a signer");
        
        ms.hasSigned[msg.sender] = true;
        ms.signCount++;
        
        emit Signed(locker, lockId, msg.sender);
    }
    
    function isUnlocked(address locker, uint256 lockId) 
        external 
        view 
        override 
        returns (bool) 
    {
        MultiSig storage ms = multiSigs[locker][lockId];
        return ms.signCount >= ms.threshold;
    }
    
    function getSignCount(address locker, uint256 lockId) 
        external 
        view 
        returns (uint256) 
    {
        return multiSigs[locker][lockId].signCount;
    }
}

Usage:

// Create 3-of-5 multisig
address[] memory signers = new address[](5);
signers[0] = address1;
signers[1] = address2;
signers[2] = address3;
signers[3] = address4;
signers[4] = address5;

multiSigCondition.createMultiSig(
    v3LockerAddress,
    lockId,
    signers,
    3  // 3 signatures required
);

// Each signer calls
multiSigCondition.sign(v3LockerAddress, lockId);

4. Governance Vote Condition

Unlock based on DAO vote outcome.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ILockCondition} from "../interfaces/ILockCondition.sol";

interface IGovernor {
    enum ProposalState { Pending, Active, Canceled, Defeated, Succeeded, Queued, Expired, Executed }
    function state(uint256 proposalId) external view returns (ProposalState);
}

contract GovernanceCondition is ILockCondition {
    IGovernor public immutable governor;
    
    mapping(address => mapping(uint256 => uint256)) public proposalIds;
    
    constructor(address _governor) {
        governor = IGovernor(_governor);
    }
    
    function setProposal(
        address locker,
        uint256 lockId,
        uint256 proposalId
    ) external {
        proposalIds[locker][lockId] = proposalId;
    }
    
    function isUnlocked(address locker, uint256 lockId) 
        external 
        view 
        override 
        returns (bool) 
    {
        uint256 proposalId = proposalIds[locker][lockId];
        if (proposalId == 0) return false;
        
        IGovernor.ProposalState state = governor.state(proposalId);
        return state == IGovernor.ProposalState.Executed;
    }
}

5. Milestone-Based Unlock

Unlock when project milestones are achieved.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ILockCondition} from "../interfaces/ILockCondition.sol";

contract MilestoneCondition is ILockCondition {
    address public immutable admin;
    
    struct Milestone {
        string description;
        bool achieved;
        uint256 achievedAt;
    }
    
    mapping(address => mapping(uint256 => Milestone[])) public milestones;
    mapping(address => mapping(uint256 => uint256)) public requiredMilestones;
    
    event MilestoneAdded(address indexed locker, uint256 indexed lockId, string description);
    event MilestoneAchieved(address indexed locker, uint256 indexed lockId, uint256 milestoneIndex);
    
    constructor() {
        admin = msg.sender;
    }
    
    function addMilestone(
        address locker,
        uint256 lockId,
        string calldata description
    ) external {
        require(msg.sender == admin, "Not admin");
        
        milestones[locker][lockId].push(Milestone({
            description: description,
            achieved: false,
            achievedAt: 0
        }));
        
        emit MilestoneAdded(locker, lockId, description);
    }
    
    function setRequiredMilestones(
        address locker,
        uint256 lockId,
        uint256 required
    ) external {
        require(msg.sender == admin, "Not admin");
        requiredMilestones[locker][lockId] = required;
    }
    
    function achieveMilestone(
        address locker,
        uint256 lockId,
        uint256 milestoneIndex
    ) external {
        require(msg.sender == admin, "Not admin");
        
        Milestone storage milestone = milestones[locker][lockId][milestoneIndex];
        require(!milestone.achieved, "Already achieved");
        
        milestone.achieved = true;
        milestone.achievedAt = block.timestamp;
        
        emit MilestoneAchieved(locker, lockId, milestoneIndex);
    }
    
    function isUnlocked(address locker, uint256 lockId) 
        external 
        view 
        override 
        returns (bool) 
    {
        uint256 required = requiredMilestones[locker][lockId];
        if (required == 0) return false;
        
        Milestone[] storage lockMilestones = milestones[locker][lockId];
        uint256 achieved = 0;
        
        for (uint i = 0; i < lockMilestones.length; i++) {
            if (lockMilestones[i].achieved) {
                achieved++;
            }
        }
        
        return achieved >= required;
    }
    
    function getAchievedCount(address locker, uint256 lockId) 
        external 
        view 
        returns (uint256) 
    {
        Milestone[] storage lockMilestones = milestones[locker][lockId];
        uint256 achieved = 0;
        
        for (uint i = 0; i < lockMilestones.length; i++) {
            if (lockMilestones[i].achieved) {
                achieved++;
            }
        }
        
        return achieved;
    }
}

Advanced Patterns

Composite Conditions

Combine multiple conditions with AND/OR logic.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ILockCondition} from "../interfaces/ILockCondition.sol";

contract CompositeCondition is ILockCondition {
    enum LogicOperator { AND, OR }
    
    struct Composite {
        address[] conditions;
        LogicOperator operator;
    }
    
    mapping(address => mapping(uint256 => Composite)) public composites;
    
    function setComposite(
        address locker,
        uint256 lockId,
        address[] calldata conditions,
        LogicOperator operator
    ) external {
        composites[locker][lockId] = Composite(conditions, operator);
    }
    
    function isUnlocked(address locker, uint256 lockId) 
        external 
        view 
        override 
        returns (bool) 
    {
        Composite storage composite = composites[locker][lockId];
        if (composite.conditions.length == 0) return false;
        
        if (composite.operator == LogicOperator.AND) {
            // All conditions must be true
            for (uint i = 0; i < composite.conditions.length; i++) {
                try ILockCondition(composite.conditions[i]).isUnlocked(locker, lockId) 
                    returns (bool unlocked) {
                    if (!unlocked) return false;
                } catch {
                    return false;
                }
            }
            return true;
        } else {
            // Any condition can be true
            for (uint i = 0; i < composite.conditions.length; i++) {
                try ILockCondition(composite.conditions[i]).isUnlocked(locker, lockId) 
                    returns (bool unlocked) {
                    if (unlocked) return true;
                } catch {
                    continue;
                }
            }
            return false;
        }
    }
}

Security Considerations

Risks

  1. Malicious Conditions: Could permanently lock funds

  2. Admin Control: Centralization risk

  3. Gas Griefing: Expensive condition checks

  4. Oracle Manipulation: Price/data manipulation

  5. Reentrancy: If condition calls external contracts

Mitigations

  • Try/Catch Wrapper: Locker wraps condition calls

  • Time-Based Fallback: Standard unlock always available

  • View Functions Only: isUnlocked() should be view

  • Gas Limits: Keep checks simple

  • Audited Conditions: Use verified contracts only


Best Practices

For Condition Developers

  1. Keep isUnlocked() simple and gas-efficient

  2. Use view modifier (no state changes in unlock check)

  3. Handle edge cases gracefully

  4. Implement proper access control

  5. Add comprehensive events

  6. Document all functions

  7. Test thoroughly

For Users

  1. Verify condition contract code

  2. Understand the unlock logic

  3. Check admin addresses

  4. Test with small amounts first

  5. Use time-based fallback as safety net

  6. Monitor condition state


Testing Conditions

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {PriceCondition} from "../src/conditions/PriceCondition.sol";

contract PriceConditionTest is Test {
    PriceCondition condition;
    address mockPriceFeed;
    
    function setUp() public {
        // Deploy mock price feed
        mockPriceFeed = address(new MockPriceFeed());
        condition = new PriceCondition(mockPriceFeed);
    }
    
    function testUnlockWhenPriceAboveTarget() public {
        // Set target price
        condition.setTarget(
            address(this),
            1,
            3000 * 1e8,  // $3000
            true          // above
        );
        
        // Set current price below target
        MockPriceFeed(mockPriceFeed).setPrice(2900 * 1e8);
        assertFalse(condition.isUnlocked(address(this), 1));
        
        // Set price above target
        MockPriceFeed(mockPriceFeed).setPrice(3100 * 1e8);
        assertTrue(condition.isUnlocked(address(this), 1));
    }
}

Last updated