# Signals Docs > Signals - Community-driven initiative governance with token-weighted voting ## Capital allocation Signals can be used as a capital allocation mechanism. Token holders lock governance tokens behind funding proposals they want to see capitalised. Because locked tokens cannot simultaneously back multiple initiatives, every allocation decision carries opportunity cost. The result is a ranked signal of where the community wants capital deployed — something binary yes/no votes on individual proposals cannot produce. ### RFP evaluation A DAO creates a board scoped to a specific RFP. Service providers submit initiatives describing their approach, timeline, and cost. Token holders lock tokens behind the provider they want to see selected. The first proposal to cross the acceptance threshold wins the RFP. | Parameter | Suggested value | Why | | ------------------- | --------------------- | --------------------------------------------------------- | | Interval | 1 day | Daily granularity for a multi-week evaluation period | | Max lock duration | 6 months | High enough ceiling for strong conviction signals | | Decay curve | Linear, moderate rate | Prevents early submissions from coasting on stale support | | Release timelock | Duration of the RFP | Prevents token recycling between competing bids | | Board closes | End of RFP window | Fixes the evaluation period | | Acceptance criteria | Percentage of supply | Scales with participation | The release timelock is the critical parameter. Setting it to match or exceed the RFP duration means a supporter cannot back one provider, wait for acceptance, reclaim tokens, and redirect them to a second. Every token holder must decide upfront which bid they believe in. ### Pre-funded programme A DAO earmarks a fixed budget (e.g. 100,000 USDC) for ecosystem development and creates a board that stays open for a year, pre-funded with the full amount. Each accepted initiative receives a capped payout (e.g. up to 30,000 USDC). Anyone can submit a proposal at any time. When a proposal crosses the acceptance threshold, it gets funded and clears the board. The remaining budget stays available for future proposals. This replaces the conventional quarterly funding round — collect proposals for a month, vote, allocate, repeat — with a single governance decision that produces a year of autonomous prioritisation. Proposals are evaluated on their own merits as they arrive, rather than being batched into artificial deadlines. This configuration uses a release timelock of zero (immediate release) and a low decay rate so that participation stays fluid across a long time horizon. ### Discretionary budget A DAO can allocate a fixed budget to a specific category — events, marketing, developer tooling, community programmes — through a single governance proposal that creates the board, funds it, and sets its rules on-chain. Once deployed, the board operates without further governance votes. Anyone who wants funding submits a proposal with their request, scope, and deliverables. Token holders lock support. When a proposal crosses the acceptance threshold, it gets funded immediately from the board's pre-allocated pool. The remaining budget is visible on-chain at all times. When the budget runs out or the fiscal period ends, the board closes. This matters because in most current governance systems, getting a 3,000 USDC event funded requires the same process as a 300,000 USDC protocol upgrade. That overhead discourages small-but-useful spending and rewards people who are good at campaigning over people who are good at executing. A dedicated board with a pre-set budget removes the bottleneck. The model scales across categories. Each gets its own board with its own budget. The DAO makes a handful of high-level allocation decisions per quarter and delegates the rest to the community through Signals. ### Reviewer incentives Capital allocation suffers from a free-rider problem: everyone benefits from good allocation decisions, but evaluating proposals takes effort. Two mechanisms address this. **Bounties** attached to funding initiatives reward the token holders who do the work of evaluating proposals and backing good ones. On acceptance, bounty payouts split between supporters, the protocol, and a treasury at configured ratios. **Early-supporter multipliers** from the incentives pool reward participants who identify strong proposals before they become obvious. The first supporters of an eventually-accepted initiative earn a larger share of incentive rewards than those who lock later. This creates an incentive to do diligence early rather than wait for social proof. ## Use cases ### Long-running board A board with no close date functions as a continuous prioritisation engine. Communities that need ongoing signal about product direction or funding priorities can leave a board open indefinitely, and initiatives rise and fall organically as sentiment shifts. Long max lock durations let committed supporters generate significant weight, while a low decay rate keeps older support relevant for longer. Setting the release timelock to zero means tokens return immediately on acceptance, so participants can redirect them to the next priority without delay. | Parameter | Suggested value | Why | | ----------------- | ---------------- | ---------------------------------------------------------- | | Interval | 1 day | Frequent weight recalculation keeps the ranking responsive | | Max lock duration | 1 year | Rewards long-term conviction with high initial weight | | Decay curve | Linear, low rate | Older support stays relevant; priorities shift gradually | | Release timelock | 0 (immediate) | Accepted initiatives free tokens for reuse instantly | | Board closes | Never | The board runs as a standing prioritisation surface | Accepted initiatives can feed directly into a product roadmap or grants pipeline. ### Time-boxed board A board with a fixed close date creates urgency. Participants know the window is finite, which compresses decision-making and raises the cost of spreading support too thin. The release timelock is the key parameter. Setting it to expire after the board closes prevents participants from backing a frontrunner, reclaiming their tokens once it passes, and redirecting them to another initiative before the window ends. That constraint forces genuine prioritisation — support committed to one initiative is unavailable for others for the duration of the board. | Parameter | Suggested value | Why | | ----------------- | --------------------- | ----------------------------------------------------------- | | Interval | 6 hours | Faster recalculation matches the compressed timeline | | Max lock duration | 6 months | Long enough to generate meaningful weight within the window | | Decay curve | Linear, moderate rate | Older support decays faster, keeping the ranking dynamic | | Release timelock | 7 days | Exceeds the board duration, preventing token recycling | | Board closes | 7 days | Fixed window forces hard tradeoffs | This configuration works well for sprint-style governance: surface the top priorities from a set of competing proposals, close the board, and act on the results. ## Closing and Cancelling a Board There are two ways to shut down a board, and they have different consequences for locked tokens. Both are **owner-only** and **irreversible**. ### Closing a board ```solidity function closeBoard() external onlyOwner ``` Closing sets `closesAt` to the current timestamp. The board stops accepting new proposals and new support. Existing locks follow their **normal redemption rules**: accepted initiatives respect the `releaseLockDuration` timelock, expired initiatives release immediately, and individual locks can still be redeemed after their natural expiry. Use `closeBoard()` when the board has served its purpose and you want to wind down gracefully without disrupting existing commitments. ```solidity // Emitted on close event BoardClosed(address indexed actor) ``` ### Cancelling a board ```solidity function cancelBoard() external onlyOwner ``` Cancellation does everything `closeBoard()` does, plus sets a `boardCancelled` flag that **bypasses all timelocks**. Every locked token becomes immediately redeemable regardless of initiative state, lock duration, or release timelock. Use `cancelBoard()` when something has gone wrong and token holders need their tokens back now. ```solidity // Emitted on cancel event BoardCancelled(address indexed actor) ``` ### How redemption changes after each action | Scenario | Close | Cancel | | -------------------------------------------- | ------------------------- | ---------------------- | | Accepted initiative, within release timelock | Must wait for timelock | Redeemable immediately | | Proposed initiative, lock not yet expired | Must wait for lock expiry | Redeemable immediately | | Expired initiative | Redeemable immediately | Redeemable immediately | | Lock past its natural expiry | Redeemable immediately | Redeemable immediately | ### After closure or cancellation Initiative states are unchanged. Proposed initiatives stay Proposed, accepted stay Accepted. The board simply stops accepting new activity. Token holders call `redeemLock()` or `redeemLocksForInitiative()` to withdraw: ```solidity // Redeem a single lock position function redeemLock(uint256 lockId) external nonReentrant // Batch redeem all locks for an initiative function redeemLocksForInitiative(uint256 initiativeId, uint256[] calldata lockIds) external nonReentrant ``` > Both actions are permanent. There is no way to reopen a closed or cancelled board. ## Creating a Signals Board Board creation is a two-phase process. First, deploy the board through the **SignalsFactory** with a complete `BoardConfig`. Then, optionally attach an incentives pool before the board opens. Acceptance criteria, locking parameters, and decay curves are **immutable after deployment**. The board owner can be any address. Setting it to a DAO multisig or governor contract means ownership functions (accepting initiatives, closing the board) require a governance vote, enabling fully decentralized operation. ### Deploy through the factory ```solidity SignalsFactory factory = SignalsFactory(FACTORY_ADDRESS); address board = factory.create(config); ``` The factory deploys a minimal proxy (EIP-1167) of the Signals implementation contract and calls `initialize()` with your config. This emits a `BoardCreated` event with the new board address. ### Example: complete deployment ```solidity address board = factory.create(BoardConfig({ version: "0.3.2", owner: 0xDAO_MULTISIG, underlyingToken: 0xGOV_TOKEN, opensAt: block.timestamp + 1 days, closesAt: 0, // never closes boardMetadata: Metadata({ title: "Community Priorities", body: "Signal which initiatives matter most", attachments: new string[](0) }), acceptanceCriteria: AcceptanceCriteria({ permissions: AcceptancePermissions.Permissionless, thresholdOverride: ThresholdOverride.None, thresholdPercentTotalSupplyWAD: 0.05e18, minThreshold: 100_000e18 }), proposerRequirements: ParticipantRequirements({ token: 0xGOV_TOKEN, minBalance: 50_000e18, minHoldingDuration: 0, minLockAmount: 0 }), supporterRequirements: ParticipantRequirements({ token: 0xGOV_TOKEN, minBalance: 0, minHoldingDuration: 0, minLockAmount: 1e18 }), lockingConfig: LockingConfig({ lockInterval: 1 days, maxLockIntervals: 365, releaseLockDuration: 0, inactivityTimeout: 60 days }), decayConfig: DecayConfig({ curveType: DecayCurveType.Linear, params: [0.5e18] }) })); ``` See [Example Configurations](/reference/board-configuration/example-configurations) for more complete configurations with rationale, or [Parameters](/reference/board-configuration/parameters) for the full parameter reference. ### Post-deployment configuration The owner can adjust timing and attach incentives **before the board opens**: ```solidity Signals board = Signals(boardAddress); // Adjust open time (only before board opens) board.setOpensAt(newOpenTime); // Attach incentives pool (only before board opens, can only be set once) board.setIncentivesPool(poolAddress, config); ``` Once the board is open, the owner can extend or set a close time: ```solidity // Extend or set close time (only while board is open, must be in the future) board.setClosesAt(newCloseTime); ``` > Attach your incentives pool before the board opens. This cannot be done after participation begins, and can only be set once. ### Emergency controls Both of these are owner-only and require the board to be open: ```solidity // Stop new activity, existing locks follow normal redemption rules board.closeBoard(); // Immediately release all locked tokens regardless of state board.cancelBoard(); ``` `closeBoard()` sets `closesAt` to the current timestamp. No new proposals or support, but tokens are redeemed normally. `cancelBoard()` does the same but additionally flags the board as cancelled, which bypasses all timelocks and allows immediate token withdrawal. See [Closing a Signals Board](/signals-board/close-cancel-board) for details. ## Incentive Pools import { Steps, Step } from 'vocs/components' Incentives are board-wide reward pools that distribute tokens to supporters of accepted initiatives. The distribution is **time-weighted**: earlier supporters earn a larger share than those who lock later. Rewards are auto-claimed when supporters redeem their lock positions. | | Incentive Pools | Bounties (RFC) | | ---------------- | ---------------------------------------- | --------------------------- | | **Scope** | Board-wide | Per-initiative | | **Funded by** | DAO / board owner | Anyone | | **Token** | Single ERC20 | Multiple whitelisted ERC20s | | **Reward basis** | Time-weighted (early supporters favored) | Proportional to lock amount | | **Claiming** | Auto-claimed on redemption | Not yet implemented | | **Contract** | `IncentivesPool.sol` | `Bounties.sol` | See [Bounties](/rewards-and-incentives/bounties) for details on initiative-specific rewards. ### Distribution When a supporter locks tokens, the pool records an **incentive credit** with the lock amount and timestamp. The timestamp determines which **time bucket** the credit falls into. Each initiative tracks 24 time buckets that expand as needed (starting at 1-hour intervals, compressing when full). Each bucket has a **multiplier** derived from the incentive curve. Earlier buckets get higher multipliers, later buckets get lower ones. A supporter who locks on day 1 has their credit weighted more heavily than one who locks on day 10. On redemption, the reward formula is: ``` supporterReward = totalRewardPerInitiative × (supporterWeightedCredits / totalWeightedCredits) ``` Where `weightedCredits = lockAmount × bucketMultiplier` for each lock. The pool sums weighted credits across all supporters to determine each individual's share. Rewards are auto-claimed during `redeemLock()`. No separate transaction needed. Redeeming before acceptance forfeits incentive rewards. Acceptance itself does not call the pool, and is **non-blocking** even if the pool is depleted. ### Incentive curve The curve determines how early-supporter bonuses are calculated. Only **linear curves** are currently supported. Parameters are provided as `incentiveParametersWAD` (at least 2 values, up to 24) and interpolated across time buckets from `opensAt` to acceptance. Earlier buckets receive higher multipliers. A supporter who locks on day 1 earns more per token than one who locks on day 10, even if both lock the same amount for the same duration. ### Setup The incentives pool must be linked to the board **before it opens**. Once `opensAt` passes, you cannot attach or change the pool. The pool manages reward token storage, distribution calculations, and board approvals. Transfer reward tokens to the pool using `addFundsToPool()`. The total amount is shared across all accepted initiatives on the board. The pool must explicitly approve which boards can draw from it. Call `approveBoard()` on the pool contract with the board address. Call `setIncentivesPool(poolAddress, config)` on the board. This can only be called once and only before `opensAt`. See the [Incentives Configuration reference](/reference/board-configuration/incentives-configuration) for function signatures and parameters. ### Multiple boards, one pool A single IncentivesPool can serve multiple boards. Each board is approved independently with its own `totalRewardPerInitiative` and `boardRemainingBudget`. The pool's total funds are shared across all boards, with each board drawing from the pool as its initiatives are accepted. ![One incentive pool serving multiple boards](/incentive-pool-chart.svg) Each board has a fixed reward amount per accepted initiative. As initiatives are accepted and supporters redeem, rewards draw down from the board's remaining budget. When a board's budget is exhausted, later acceptances on that board receive reduced or zero rewards, but other boards are unaffected. ### Common issues | Issue | Cause | Solution | | ------------------------------------ | ----------------------- | ----------------------------------------------------------- | | Cannot set pool | Board already opened | Must set before `opensAt` | | Pool not approved | Forgot `approveBoard()` | Approve the board in the pool first | | Reduced rewards on later initiatives | Pool partially depleted | Monitor pool balance; deploy a new board for a fresh budget | ## Adding Bounties Bounties let anyone attach external ERC20 token rewards to a specific initiative. When the initiative is accepted, the bounty is split three ways between the protocol, supporters, and a treasury according to configured percentages. > Bounties are initiative-specific and externally funded. For board-wide, DAO-funded rewards, see [Incentives](/rewards-and-incentives/adding-incentives). ### Adding a bounty 1. The token must be whitelisted in the board's **TokenRegistry** 2. Approve the Bounties contract to spend your tokens 3. Call `addBounty()` with the initiative ID, token address, amount, expiration, and conditions Tokens are transferred immediately on `addBounty()`. Multiple bounties (including different token types) can be attached to the same initiative. ### Three-way split On acceptance, each bounty token is distributed according to the board's configured split: * **Protocol fee** (e.g. 5%) * **Supporter rewards** (e.g. 20%) * **Treasury allocation** (e.g. 75%) Percentages must sum to 100. Each supporter's share of the voter pool is proportional to their locked amount relative to the total locked in the initiative. ### Versioned splits Board owners can update split percentages with `updateSplits()`. When splits change: * A new configuration version is created * Existing bounties use their **original version's splits** * New bounties use the current version This prevents retroactive changes to existing commitments. ### Expiration Bounties can have an optional expiration timestamp (`expiresAt`). Expired bounties are excluded from distribution. Set `expiresAt` to `0` for bounties that never expire. ### Current limitations Bounty distribution is tracked on-chain but **claim/withdrawal functions are not yet implemented**. Balances accumulate in the contract but cannot be withdrawn by supporters in the current version. Refund logic for expired bounties is also pending. See the [Adding Bounties reference](/reference/initiative-actions/add-bounty) for function signatures and parameters. ## Accepting Initiatives ### `acceptInitiative()` ```solidity function acceptInitiative(uint256 initiativeId) external ``` | Parameter | Type | Description | | -------------- | --------- | -------------------------------------------------- | | `initiativeId` | `uint256` | Initiative to accept. Must be in `Proposed` state. | Sets state to `Accepted`, records `acceptanceTimestamp`. Supporters can redeem after `releaseLockDuration` passes. ### Access control | `permissions` | `thresholdOverride` | Who can call | Threshold required | | ---------------- | ------------------- | ------------ | ----------------------- | | `Permissionless` | `None` | Anyone | Yes | | `Permissionless` | `OnlyOwner` | Anyone | Yes, but owner bypasses | | `OnlyOwner` | `None` | Owner only | Yes | | `OnlyOwner` | `OnlyOwner` | Owner only | No | ### Events ```solidity event InitiativeAccepted(uint256 indexed initiativeId, address indexed actor) ``` ### Errors | Error | Condition | | ---------------------------------- | ----------------------------------- | | `Signals_InvalidID` | Initiative doesn't exist | | `Signals_IncorrectInitiativeState` | Initiative not in `Proposed` state | | `OwnableUnauthorizedAccount` | Caller not owner (when `OnlyOwner`) | | `Signals_InsufficientSupport` | Weight below threshold | ## Adding Bounties > Distribution and refunds are not yet triggered on-chain. Balances are tracked but cannot be claimed. ### `addBounty()` ```solidity function addBounty( uint256 _initiativeId, address _token, uint256 _amount, uint256 _expiresAt, Conditions _terms ) external ``` | Parameter | Type | Description | | --------------- | ------------ | ------------------------------------------------------------------- | | `_initiativeId` | `uint256` | Initiative to attach bounty to | | `_token` | `address` | ERC20 token (must be whitelisted in TokenRegistry) | | `_amount` | `uint256` | Tokens to contribute. Requires prior approval on Bounties contract. | | `_expiresAt` | `uint256` | Expiration timestamp. `0` = never expires. | | `_terms` | `Conditions` | Distribution conditions (stored, not enforced yet) | Tokens are transferred immediately on call. ### Distribution formula ```solidity protocolAmount = (totalAmount * protocolAllocation) / 100 voterAmount = (totalAmount * voterAllocation) / 100 treasuryAmount = (totalAmount * treasuryAllocation) / 100 // Per supporter supporterShare = (lockedAmount / totalLocked) * voterAmount ``` Allocations are configured via `updateSplits()` and must sum to 100. Versioned to prevent retroactive changes. ### Data structures ```solidity struct Bounty { uint256 initiativeId; IERC20 token; uint256 amount; uint256 paid; uint256 refunded; uint256 expiresAt; address contributor; Conditions terms; } ``` ### Query functions ```solidity // Aggregated bounties by token, excluding expired function getBounties(uint256 _initiativeId) external view returns (address[] memory tokens, uint256[] memory amounts, uint256 expiredCount) // Returns 0 (not implemented) function previewRewards(uint256 _initiativeId, uint256 _tokenId) external view returns (uint256) ``` ### Events ```solidity event BountyAdded(uint256 indexed bountyId, uint256 indexed initiativeId, address indexed token, uint256 amount, uint256 expiresAt, Conditions terms) event BountiesUpdated(uint256 version) ``` ### Errors | Error | Condition | | -------------------------------- | ------------------------------ | | `Bounties_TokenNotRegistered` | Token not in whitelist | | `Bounties_InvalidInitiative` | Initiative doesn't exist | | `Bounties_InsufficientBalance` | Insufficient token balance | | `Bounties_InsufficientAllowance` | Bounties contract not approved | | `Bounties_InvalidAllocation` | Splits don't sum to 100 | | `Bounties_NotAuthorized` | Caller not authorized | ## Expiring Initiatives ### `expireInitiative()` ```solidity function expireInitiative(uint256 initiativeId) external onlyOwner ``` | Parameter | Type | Description | | -------------- | --------- | -------------------------------------------------- | | `initiativeId` | `uint256` | Initiative to expire. Must be in `Proposed` state. | Owner-only. Sets state to `Expired`. Requires `block.timestamp > lastActivity + inactivityTimeout`. `lastActivity` updates on initiative creation and each time a supporter locks tokens. Tokens locked against expired initiatives are redeemable immediately. No incentive rewards are distributed. ### Events ```solidity event InitiativeExpired(uint256 indexed initiativeId, address indexed actor) ``` ### Errors | Error | Condition | | ---------------------------------- | ------------------------------------------------------- | | `Signals_InvalidID` | Initiative doesn't exist | | `Signals_IncorrectInitiativeState` | Not in `Proposed` state or not yet inactive long enough | | `OwnableUnauthorizedAccount` | Caller not owner | ## Proposing Initiatives ### `proposeInitiative()` ```solidity function proposeInitiative( Metadata calldata _metadata ) external returns (uint256 initiativeId) ``` | Parameter | Type | Description | | ----------- | ---------- | ----------------------------------------------- | | `_metadata` | `Metadata` | Title (required), body, and up to 5 attachments | **Returns:** `initiativeId` of the newly created initiative. ### `proposeInitiativeWithLock()` ```solidity function proposeInitiativeWithLock( Metadata calldata _metadata, uint256 _amount, uint256 _lockDuration ) external returns (uint256 initiativeId, uint256 tokenId) ``` | Parameter | Type | Description | | --------------- | ---------- | ---------------------------------------------------------------- | | `_metadata` | `Metadata` | Title (required), body, and up to 5 attachments | | `_amount` | `uint256` | Tokens to lock (wei). Requires prior ERC20 approval. | | `_lockDuration` | `uint256` | Lock duration in intervals. Must be > 0 and ≤ `maxLockIntervals` | **Returns:** `initiativeId` and `tokenId` (ERC721 lock NFT). ### Events ```solidity event InitiativeProposed(uint256 indexed initiativeId, address indexed proposer, Metadata metadata) event InitiativeSupported(uint256 indexed initiativeId, address indexed supporter, uint256 tokenAmount, uint256 lockDuration, uint256 tokenId) ``` `InitiativeSupported` is only emitted by `proposeInitiativeWithLock()`. ### Errors | Error | Condition | | ------------------------------------- | --------------------------------------- | | `Signals_EmptyTitleOrBody` | Title is empty | | `Signals_AttachmentLimitExceeded` | More than 5 attachments | | `Signals_IncorrectBoardState` | Board not open | | `Signals_InsufficientTokens` | Balance {'<'} `minBalance` | | `Signals_InsufficientTokenDuration` | Historical balance {'<'} `minBalance` | | `Signals_InsufficientLockAmount` | Amount {'<'} `minLockAmount` | | `Signals_TokenHasNoCheckpointSupport` | Token doesn't implement `IVotes` | | `Signals_InvalidArguments` | Invalid lock duration or attachment URI | | `Signals_TokenTransferFailed` | ERC20 transfer reverted | ## Redeeming Tokens ### `redeemLock()` ```solidity function redeemLock(uint256 lockId) external nonReentrant ``` | Parameter | Type | Description | | --------- | --------- | ------------------------------------ | | `lockId` | `uint256` | ERC721 token ID of the lock position | Burns the NFT, transfers locked tokens back to the holder. Auto-claims incentive rewards for accepted initiatives. ### `redeemLocksForInitiative()` ```solidity function redeemLocksForInitiative( uint256 initiativeId, uint256[] calldata lockIds ) external nonReentrant ``` | Parameter | Type | Description | | -------------- | ----------- | -------------------------------------------------------- | | `initiativeId` | `uint256` | Initiative ID. All locks must belong to this initiative. | | `lockIds` | `uint256[]` | Array of lock token IDs to redeem | Batch redemption. More gas efficient than individual calls. Claims all incentive rewards in a single transaction. ### Eligibility A lock is redeemable when **any** of these conditions is true: * Board is cancelled * Initiative is `Expired` * Lock has naturally expired (`lock.created + lockDuration × lockInterval` has passed) * Initiative is `Accepted` and `block.timestamp > acceptanceTimestamp + releaseLockDuration` ### Events ```solidity event Redeemed(uint256 indexed tokenId) ``` ### Errors | Error | Condition | | ------------------------------ | ------------------------------------------------------------ | | `Signals_TokenAlreadyRedeemed` | Lock already withdrawn | | `Signals_NotOwner` | Caller doesn't own the NFT | | `Signals_StillTimelocked` | Release timelock not passed and lock not expired | | `Signals_InvalidID` | Lock doesn't belong to the specified initiative (batch only) | ## Supporting Initiatives ### `supportInitiative()` ```solidity function supportInitiative( uint256 initiativeId, uint256 amount, uint256 lockDuration ) external returns (uint256 tokenId) ``` | Parameter | Type | Description | | -------------- | --------- | -------------------------------------------------------- | | `initiativeId` | `uint256` | Initiative to support. Must be in `Proposed` state. | | `amount` | `uint256` | Tokens to lock (wei). Requires prior ERC20 approval. | | `lockDuration` | `uint256` | Lock duration in intervals. Must be ≤ `maxLockIntervals` | **Returns:** `tokenId` (ERC721 lock NFT). ### Data structures ```solidity struct TokenLock { uint256 initiativeId; address supporter; uint256 tokenAmount; uint256 lockDuration; uint256 created; bool withdrawn; } ``` ### Events ```solidity event InitiativeSupported(uint256 indexed initiativeId, address indexed supporter, uint256 tokenAmount, uint256 lockDuration, uint256 tokenId) ``` ### Errors | Error | Condition | | ------------------------------------- | ------------------------------------- | | `Signals_IncorrectBoardState` | Board not open | | `Signals_InvalidID` | Initiative doesn't exist | | `Signals_IncorrectInitiativeState` | Initiative not in `Proposed` state | | `Signals_InvalidArguments` | Duration is 0 or > `maxLockIntervals` | | `Signals_InsufficientTokens` | Balance {'<'} `minBalance` | | `Signals_InsufficientLockAmount` | Amount {'<'} `minLockAmount` | | `Signals_InsufficientTokenDuration` | Historical balance {'<'} `minBalance` | | `Signals_TokenHasNoCheckpointSupport` | Token doesn't implement `IVotes` | | `Signals_TokenTransferFailed` | ERC20 transfer reverted | ## Tracking Support ### Querying Initiative Data #### Get Initiative Details ```solidity function getInitiative(uint256 initiativeId) external view returns (Initiative memory) ``` Returns complete initiative information including state, proposer, timestamps. #### Get Total Initiatives ```solidity function initiativeCount() external view returns (uint256) ``` Returns the total number of initiatives created on the board. ### Querying Weight #### Current Weight ```solidity function getWeight(uint256 initiativeId) external view returns (uint256) ``` Returns the initiative's current total weight (at `block.timestamp`), accounting for all active locks and decay. #### Historical Weight ```solidity function getWeightAt(uint256 initiativeId, uint256 timestamp) external view returns (uint256) ``` Returns the initiative's weight at a specific timestamp. Useful for: * Creating weight charts over time * Verifying historical data * Calculating rewards #### Supporter's Weight ```solidity function getWeightForSupporterAt( uint256 initiativeId, address supporter, uint256 timestamp ) external view returns (uint256) ``` Returns a specific supporter's weight contribution at a given timestamp. ### Querying Lock Positions #### Get Lock Details ```solidity function getTokenLock(uint256 tokenId) external view returns (TokenLock memory) ``` Returns detailed lock information for a specific NFT token ID. #### Get Standardized Lock Data ```solidity function getLockData(uint256 tokenId) external view returns (LockData memory) ``` Returns standardized lock data conforming to the ISignalsLock interface. #### Get Initiative's Lock Positions ```solidity function locksForInitiative(uint256 initiativeId) external view returns (uint256[] memory) ``` Returns all NFT token IDs supporting a specific initiative. ### Checking Acceptance Threshold #### Get Threshold ```solidity function getAcceptanceThreshold() external view returns (uint256) ``` Returns the current acceptance threshold, calculated as: ```solidity max(totalSupply * thresholdPercentTotalSupplyWAD / 1e18, minThreshold) ``` #### Get Full Acceptance Criteria ```solidity function getAcceptanceCriteria() external view returns (AcceptanceCriteria memory) ``` Returns the complete acceptance criteria including permissions, threshold override, and both threshold values. #### Check if Threshold Reached Compare `getWeight(initiativeId)` to `getAcceptanceThreshold()` to determine progress toward acceptance. ### Weight Decay Configuration #### Query Decay Settings ```solidity // Get decay curve type (0 = linear, 1 = exponential) function decayCurveType() external view returns (uint256) // Get decay parameter at index 0 function decayCurveParameters(uint256 index) external view returns (uint256) // Get interval duration in seconds function lockInterval() external view returns (uint256) // Get maximum lock duration function maxLockIntervals() external view returns (uint256) ``` #### Understanding Weight Decay **Linear Decay (Type 0):** ``` weight = lockAmount * lockDuration - (lockAmount * elapsedIntervals * decayRate) ``` **Exponential Decay (Type 1):** ``` weight = lockAmount * lockDuration * (decayMultiplier ^ elapsedIntervals) ``` Both curves have a floor at `lockAmount` (nominal value). ### ERC721 Position Enumeration #### Standard ERC721 Functions ```solidity // Get NFT count for owner function balanceOf(address owner) external view returns (uint256) // Get token ID by index function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256) // Get total supply of NFTs function totalSupply() external view returns (uint256) // Get lock count function lockCount() external view returns (uint256) ``` ### Events Monitor these events for real-time tracking: * `InitiativeProposed(uint256 indexed initiativeId, address indexed proposer)` — new initiative created * `InitiativeSupported(uint256 indexed initiativeId, uint256 indexed tokenId, address indexed supporter)` — tokens locked * `InitiativeAccepted(uint256 indexed initiativeId)` — initiative accepted * `InitiativeExpired(uint256 indexed initiativeId)` — initiative expired due to inactivity * `Redeemed(uint256 indexed tokenId)` — lock position redeemed * `Transfer(address indexed from, address indexed to, uint256 indexed tokenId)` — NFT position transferred ## Example Configurations Each example below is a complete `BoardConfig` that can be passed directly to `SignalsFactory.create()`. The parameter values are opinionated starting points. Adjust thresholds and durations to match your community's size and governance cadence. ### Community prioritization board A board that stays open indefinitely and surfaces evolving community priorities. Low decay keeps older support relevant. Immediate token release on acceptance means supporters can continuously reallocate across initiatives as priorities shift. ```solidity BoardConfig({ version: "0.3.2", owner: 0xDAO_MULTISIG, underlyingToken: 0xGOV_TOKEN, opensAt: block.timestamp, closesAt: 0, // never closes boardMetadata: Metadata({ title: "Community Priorities", body: "Signal which initiatives matter most", attachments: new string[](0) }), acceptanceCriteria: AcceptanceCriteria({ permissions: AcceptancePermissions.Permissionless, thresholdOverride: ThresholdOverride.None, thresholdPercentTotalSupplyWAD: 0.05e18, // 5% of supply minThreshold: 100_000e18 // floor of 100k tokens }), proposerRequirements: ParticipantRequirements({ token: 0xGOV_TOKEN, minBalance: 50_000e18, minHoldingDuration: 0, minLockAmount: 0 }), supporterRequirements: ParticipantRequirements({ token: 0xGOV_TOKEN, minBalance: 0, minHoldingDuration: 0, minLockAmount: 1e18 }), lockingConfig: LockingConfig({ lockInterval: 1 days, maxLockIntervals: 365, // up to 1 year releaseLockDuration: 0, // immediate release on acceptance inactivityTimeout: 60 days }), decayConfig: DecayConfig({ curveType: DecayCurveType.Linear, params: [0.5e18] // slow decay }) }) ``` **Why these values:** `releaseLockDuration: 0` keeps participation fluid on a long-lived board. The 5% supply threshold with a 100k token floor prevents both whale-dominated acceptance and dust attacks. `inactivityTimeout: 60 days` cleans up abandoned initiatives without expiring active ones too aggressively. ### Time-boxed sprint board A board that runs for one week and forces hard tradeoffs. The `releaseLockDuration` matches the board duration, which prevents supporters from backing a frontrunner, reclaiming tokens on acceptance, and redirecting them to a second initiative before the board closes. ```solidity BoardConfig({ version: "0.3.2", owner: 0xDAO_MULTISIG, underlyingToken: 0xGOV_TOKEN, opensAt: SPRINT_START, closesAt: SPRINT_START + 7 days, boardMetadata: Metadata({ title: "Q1 Sprint Priorities", body: "Identify top initiatives for the quarter", attachments: new string[](0) }), acceptanceCriteria: AcceptanceCriteria({ permissions: AcceptancePermissions.Permissionless, thresholdOverride: ThresholdOverride.None, thresholdPercentTotalSupplyWAD: 0.1e18, // 10% of supply minThreshold: 50_000e18 }), proposerRequirements: ParticipantRequirements({ token: 0xGOV_TOKEN, minBalance: 10_000e18, minHoldingDuration: 0, minLockAmount: 0 }), supporterRequirements: ParticipantRequirements({ token: 0xGOV_TOKEN, minBalance: 0, minHoldingDuration: 0, minLockAmount: 1e18 }), lockingConfig: LockingConfig({ lockInterval: 6 hours, maxLockIntervals: 2920, // up to 6 months in 6hr intervals releaseLockDuration: 7 days, // matches board duration inactivityTimeout: 7 days }), decayConfig: DecayConfig({ curveType: DecayCurveType.Linear, params: [1e18] // moderate decay }) }) ``` **Why these values:** `lockInterval: 6 hours` gives finer granularity over a short board. `releaseLockDuration: 7 days` is the anti-gaming parameter, it eliminates sequential token recycling. The higher threshold (10%) is appropriate because the compressed timeframe concentrates participation. ### RFP selection board A board scoped to a single request for proposals. Service providers submit competing bids, token holders lock support behind their preferred provider. Only the board owner can accept, giving the DAO final say over which provider is selected. ```solidity BoardConfig({ version: "0.3.2", owner: 0xDAO_MULTISIG, underlyingToken: 0xGOV_TOKEN, opensAt: RFP_START, closesAt: RFP_START + 30 days, boardMetadata: Metadata({ title: "RFP: Oracle Integration", body: "Select a provider for oracle infrastructure", attachments: new string[](0) }), acceptanceCriteria: AcceptanceCriteria({ permissions: AcceptancePermissions.OnlyOwner, thresholdOverride: ThresholdOverride.None, thresholdPercentTotalSupplyWAD: 0.05e18, // 5% of supply minThreshold: 75_000e18 }), proposerRequirements: ParticipantRequirements({ token: 0xGOV_TOKEN, minBalance: 0, // open to external providers minHoldingDuration: 0, minLockAmount: 0 }), supporterRequirements: ParticipantRequirements({ token: 0xGOV_TOKEN, minBalance: 1_000e18, // minimum stake to participate minHoldingDuration: 7200, // ~1 day in blocks, proves token commitment minLockAmount: 500e18 }), lockingConfig: LockingConfig({ lockInterval: 1 days, maxLockIntervals: 180, releaseLockDuration: 30 days, // matches RFP duration inactivityTimeout: 30 days }), decayConfig: DecayConfig({ curveType: DecayCurveType.Linear, params: [0.8e18] }) }) ``` **Why these values:** `AcceptancePermissions.OnlyOwner` ensures the DAO multisig makes the final call, while the threshold requirement means the owner can only accept a bid that has genuine community backing. `proposerRequirements.minBalance: 0` opens submissions to external service providers who may not hold governance tokens. `supporterRequirements.minHoldingDuration` filters out participants who acquired tokens solely to influence the RFP outcome. ### Discretionary budget board A board pre-funded with a fixed budget for a specific spending category. Low barrier to propose, low threshold to accept, immediate token release. Designed to handle small, recurring allocations without full governance overhead. ```solidity BoardConfig({ version: "0.3.2", owner: 0xDAO_MULTISIG, underlyingToken: 0xGOV_TOKEN, opensAt: QUARTER_START, closesAt: QUARTER_START + 90 days, boardMetadata: Metadata({ title: "Events Budget Q2 2026", body: "20,000 USDC allocated for community events", attachments: new string[](0) }), acceptanceCriteria: AcceptanceCriteria({ permissions: AcceptancePermissions.Permissionless, thresholdOverride: ThresholdOverride.OnlyOwner, thresholdPercentTotalSupplyWAD: 0.02e18, // 2% of supply minThreshold: 25_000e18 }), proposerRequirements: ParticipantRequirements({ token: 0xGOV_TOKEN, minBalance: 1_000e18, // low barrier minHoldingDuration: 0, minLockAmount: 0 }), supporterRequirements: ParticipantRequirements({ token: 0xGOV_TOKEN, minBalance: 0, minHoldingDuration: 0, minLockAmount: 1e18 }), lockingConfig: LockingConfig({ lockInterval: 1 days, maxLockIntervals: 90, // matches quarter releaseLockDuration: 0, // immediate release inactivityTimeout: 14 days // short timeout for small requests }), decayConfig: DecayConfig({ curveType: DecayCurveType.Linear, params: [1.5e18] // aggressive decay keeps evaluation pressure on }) }) ``` **Why these values:** Low thresholds and immediate release keep the board responsive for small allocations. `ThresholdOverride.OnlyOwner` gives the DAO multisig an escape hatch to fast-track urgent requests. Aggressive decay (`1.5e18`) ensures proposals that don't attract sustained support drop off quickly, keeping the board focused on active requests. Short `inactivityTimeout` cleans up stale proposals faster, appropriate for a board handling time-sensitive spending. ## Incentives Configuration Reference Complete technical reference for configuring and deploying the IncentivesPool system. ### Setup Summary 1. Deploy `IncentivesPool` with the reward token address. 2. Fund the pool via `addFundsToPool`. 3. Approve each board with a budget and per-initiative cap. 4. Link the pool to the board via `setIncentivesPool` **before** `opensAt`. ### IncentivesConfig Structure ```solidity struct IncentivesConfig { IncentiveType incentiveType; // Linear only (exponential reserved) uint256[] incentiveParametersWAD; // 2-24 values defining the curve } enum IncentiveType { Linear, Exponential } ``` **Note:** `Exponential` is not implemented yet; only `Linear` is supported. ### Pool Deployment & Funding #### Deploy IncentivesPool ```solidity constructor(address rewardToken) ``` #### Fund the Pool ```solidity function addFundsToPool(uint256 amount) external ``` If tokens are transferred in externally, the owner can sync the balance: ```solidity function updateAvailableRewards(uint256 newBalance) external onlyOwner ``` ### Approve Board ```solidity function approveBoard( address board, uint256 boardMaxBudget_, uint256 totalRewardPerInitiative_ ) external onlyOwner ``` * `boardMaxBudget_`: total budget allocated to this board * `totalRewardPerInitiative_`: max reward per initiative for this board ### Link Pool to Board ```solidity // In Signals.sol function setIncentivesPool( address incentivesPool_, IncentivesConfig calldata incentivesConfig_ ) external onlyOwner ``` **Critical:** Must be called before `opensAt`. ### Incentive Weighting (Bucket Model) * Signals credits each lock into a time bucket when the lock is created. * `incentiveParametersWAD` is interpolated and normalized into per-bucket multipliers. * On claim, each lock’s reward share is proportional to its credit and bucket multiplier. Simplified flow: ``` multipliers = normalize(interpolate(parameters, buckets)) lockPercent = credit * multipliers[bucketIndex] / totalCredits reward = totalRewardPerInitiative * lockPercent ``` ### Distribution Mechanics * On lock, Signals calls `addIncentivesCreditForLock` on the pool. * On redemption, Signals calls `claimIncentivesForLocks` if the release timelock has passed. * If redeeming before acceptance, Signals calls `removeIncentivesCreditForLocks`. ### Query Functions ```solidity function approvedBoards(address board) external view returns (bool) function totalRewardPerInitiative(address board) external view returns (uint256) function boardRemainingBudget(address board) external view returns (uint256) function availableRewards() external view returns (uint256) function totalBoardBudgets() external view returns (uint256) function distributedRewards() external view returns (uint256) function REWARD_TOKEN() external view returns (address) ``` ### Events ```solidity event FundsAddedToPool(uint256 amount) event AvailableRewardsUpdated(uint256 newBalance) event BoardApproved(address indexed board) event BoardRevoked(address indexed board) event RewardsPaidOut( address indexed board, uint256 indexed initiativeId, address indexed supporter, uint256 percentOfInitiativeRewards, uint256 remainingBoardBudget, uint256 amountPaid ) event RewardsClaimed( uint256 indexed initiativeId, uint256 indexed lockId, address indexed claimant, uint256 percentOfInitiativeRewardsWAD ) ``` ### Validation & Errors | Error | Condition | Solution | | ------------------------------------- | ------------------------------------ | -------------------------------------------- | | `IncentivesPool_InvalidConfiguration` | Invalid token, budget, or amounts | Fix parameters before deployment or approval | | `IncentivesPool_InsufficientFunds` | Budget exceeds available rewards | Fund the pool or reduce budgets | | `IncentivesPool_BoardAlreadyApproved` | Board already approved | Use `revokeBoard` then re-approve if needed | | `IncentivesPool_NotApprovedBoard` | Unauthorized board call | Approve the board first | | `Signals_IncorrectBoardState` | Pool set after board opens | Call `setIncentivesPool` before `opensAt` | | `Signals_IncentivesPoolAlreadySet` | Pool already configured | Pool can be set only once | | `Signals_IncentivesPoolNotApproved` | Pool has not approved the board | Call `approveBoard` on the pool | | `Signals_InvalidArguments` | Invalid incentives config parameters | Use 2-24 values for linear curve | ## BoardConfig ```solidity struct BoardConfig { string version; address owner; address underlyingToken; uint256 opensAt; uint256 closesAt; Metadata boardMetadata; AcceptanceCriteria acceptanceCriteria; IAuthorizer.ParticipantRequirements proposerRequirements; IAuthorizer.ParticipantRequirements supporterRequirements; LockingConfig lockingConfig; DecayConfig decayConfig; } ``` *** ### `version` Off-chain compatibility marker. Current version: `"0.3.2"`. ### `owner` Board controller. Can accept initiatives, close/cancel the board, and adjust timing parameters. Use a multisig or DAO governor contract for decentralized operation. ### `underlyingToken` ERC20 token used for locking and weight calculation. Cannot be `address(0)`. This is the token supporters lock when backing initiatives. ### `opensAt` Timestamp when the board starts accepting proposals and support. Set to `block.timestamp` to open immediately, or a future timestamp to schedule. ### `closesAt` Timestamp when the board stops accepting new activity. Must be ≥ `opensAt`. Set to `0` for a board that never closes. ### `boardMetadata` ```solidity struct Metadata { string title; string body; Attachment[] attachments; } struct Attachment { string uri; string mimeType; string description; } ``` Title, body (markdown), and up to 5 attachments. Emitted in the `BoardCreated` event, not stored on-chain. `uri` must be non-empty (`https://`, `ipfs://`, or on-chain pointer). `mimeType` and `description` are optional hints for clients. ### `acceptanceCriteria` ```solidity struct AcceptanceCriteria { AcceptancePermissions permissions; ThresholdOverride thresholdOverride; uint256 thresholdPercentTotalSupplyWAD; uint256 minThreshold; } ``` #### Threshold The effective threshold is the greater of two values: ```solidity threshold = max(totalSupply * thresholdPercentTotalSupplyWAD / 1e18, minThreshold) ``` **`thresholdPercentTotalSupplyWAD`** — Percentage of total token supply in WAD notation. `0.05e18` = 5%. Must be {'<'} `1e18`. **`minThreshold`** — Fixed minimum weight, used as a floor. Prevents dust attacks when total supply is low. At least one must be non-zero. #### Permissions `permissions` and `thresholdOverride` control who can call `acceptInitiative()` and whether the threshold check applies: | `permissions` | `thresholdOverride` | Who can accept | Threshold required | | ---------------- | ------------------- | -------------- | ----------------------- | | `Permissionless` | `None` | Anyone | Yes, for everyone | | `Permissionless` | `OnlyOwner` | Anyone | Yes, but owner bypasses | | `OnlyOwner` | `None` | Owner only | Yes | | `OnlyOwner` | `OnlyOwner` | Owner only | No (owner bypasses) | ### `proposerRequirements` ```solidity struct ParticipantRequirements { address token; uint256 minBalance; uint256 minHoldingDuration; uint256 minLockAmount; } ``` Controls who can call `proposeInitiative()` and `proposeInitiativeWithLock()`. Immutable after deployment. #### Fields **`token`** — ERC20 checked for eligibility. Can differ from `underlyingToken`. Cannot be `address(0)`. **`minBalance`** — Minimum current balance required. When non-zero, checks `IERC20(token).balanceOf(caller) >= minBalance`. **`minHoldingDuration`** — Minimum blocks the caller must have held `minBalance`. When non-zero, calls `IVotes(token).getPastVotes(caller, block.number - minHoldingDuration)`. Requires `minBalance > 0` and an **ERC20Votes** token. **`minLockAmount`** — Minimum tokens to lock when proposing with lock. `proposeInitiative()` (without lock) reverts if `minLockAmount > 0`. Must be ≤ `minBalance`. #### Eligibility modes The mode is inferred from field values. There is no explicit enum. | `minBalance` | `minHoldingDuration` | Mode | Token type | | ------------ | -------------------- | ----------------------------------- | ------------------- | | `0` | `0` | Open access, anyone can participate | Any ERC20 | | `> 0` | `0` | Current balance check | Any ERC20 | | `> 0` | `> 0` | Historical balance check (snapshot) | ERC20Votes required | > For the historical balance mode, users must **delegate their tokens** (even to themselves) to activate checkpoints. Without delegation, `getPastVotes` returns zero. #### Block time reference for `minHoldingDuration` | Chain | Block time | \~7 days | \~30 days | | -------- | ---------- | --------- | ---------- | | Ethereum | 12s | 50,400 | 216,000 | | Base | 2s | 302,400 | 1,296,000 | | Arbitrum | \~0.25s | 2,419,200 | 10,368,000 | ### `supporterRequirements` Same `IAuthorizer.ParticipantRequirements` struct as above. Controls who can call `supportInitiative()`. Immutable after deployment. Configured independently. A board can have strict proposer requirements (high balance, holding duration) with open supporter requirements (anyone can lock tokens). ### `lockingConfig` ```solidity struct LockingConfig { uint256 lockInterval; uint256 maxLockIntervals; uint256 releaseLockDuration; uint256 inactivityTimeout; } ``` #### `lockInterval` Base time unit in seconds. Lock durations and decay calculations are measured in multiples of this value. Must be > 0. #### `maxLockIntervals` Maximum number of intervals a lock can span. Must be > 0. The maximum lock duration in seconds is `lockInterval × maxLockIntervals`. *Example:* `lockInterval = 1 days` and `maxLockIntervals = 365` allows locks up to 1 year. #### `releaseLockDuration` Additional delay in seconds after an initiative is accepted before supporters can redeem their tokens. Set to `0` for immediate release on acceptance. This is separate from lock duration. A supporter's lock may have expired, but if the initiative was just accepted, they still wait `releaseLockDuration` before redeeming. > On a time-boxed board, setting `releaseLockDuration` ≥ the board duration prevents token recycling. Supporters cannot back one initiative, wait for acceptance, reclaim, and redirect to another before the board closes. #### `inactivityTimeout` Seconds of inactivity before the owner can expire an initiative via `expireInitiative()`. An initiative's `lastActivity` timestamp updates on creation and each time a supporter locks tokens. ``` expirable when: block.timestamp > lastActivity + inactivityTimeout ``` ### `decayConfig` ```solidity struct DecayConfig { DecayCurveType curveType; uint256[] params; } ``` **`curveType`** — `0` = Linear, `1` = Exponential. **`params`** — Curve-specific parameters: * **Linear** (`0`): `params = [decayRate]`. Rate of `1e18` = 1:1 decay per interval. * **Exponential** (`1`): `params = [decayMultiplier]`. Multiplier of `0.9e18` = 10% reduction per interval. ## Querying Board Configuration View functions for reading board configuration and checking participant eligibility. ### Participant eligibility ```solidity // Read proposer requirements IAuthorizer.ParticipantRequirements memory reqs = signals.getProposerRequirements(); // Read supporter requirements IAuthorizer.ParticipantRequirements memory reqs = signals.getParticipantRequirements(); // Check if a specific account can propose (with optional lock amount) bool canPropose = signals.accountCanPropose(account, lockAmount); // Check if a specific account can support (with given lock amount) bool canSupport = signals.accountCanSupport(account, lockAmount); ``` ### Acceptance criteria ```solidity // Get the full acceptance criteria struct AcceptanceCriteria memory criteria = signals.getAcceptanceCriteria(); // Get the current effective threshold uint256 threshold = signals.getAcceptanceThreshold(); ``` The effective threshold is dynamic. It recalculates based on current `totalSupply()`, so the value may change between calls if the underlying token supply changes. ### Board state ```solidity // Timing uint256 opens = signals.opensAt(); uint256 closes = signals.closesAt(); // Status bool open = signals.isBoardOpen(); bool closed = signals.isBoardClosed(); bool cancelled = signals.boardCancelled(); // Locking uint256 interval = signals.lockInterval(); uint256 maxIntervals = signals.maxLockIntervals(); uint256 releaseDuration = signals.releaseLockDuration(); uint256 timeout = signals.inactivityTimeout(); // Decay uint256 curveType = signals.decayCurveType(); uint256 param = signals.decayCurveParameters(0); // Token address token = signals.underlyingToken(); ``` ## Validation Errors Errors thrown during board initialization via `SignalsFactory.create()` and during participant eligibility checks at runtime. ### Initialization errors These revert during `create()` if the `BoardConfig` is invalid. | Error | Condition | | --------------------------------- | -------------------------------------------------------------- | | `SignalsFactory_ZeroAddressOwner` | `owner` is `address(0)` | | `Signals_InvalidArguments` | `underlyingToken` or participant `token` is `address(0)` | | `Signals_InvalidArguments` | Both `thresholdPercentTotalSupplyWAD` and `minThreshold` are 0 | | `Signals_InvalidArguments` | `thresholdPercentTotalSupplyWAD` ≥ `1e18` | | `Signals_InvalidArguments` | `maxLockIntervals` or `lockInterval` is 0 | | `Signals_InvalidArguments` | Invalid `DecayCurveType` | | `Signals_InvalidArguments` | `closesAt` {'<'} `opensAt` | | `Signals_InvalidArguments` | `minHoldingDuration > 0` with `minBalance == 0` | | `Signals_InvalidArguments` | `minLockAmount > minBalance` | ### Runtime errors These revert when a participant tries to propose or support an initiative. | Error | Condition | | ------------------------------------- | ---------------------------------------------------------------------------- | | `Signals_InsufficientTokens` | Current balance {'<'} `minBalance` | | `Signals_InsufficientTokenDuration` | Historical balance {'<'} `minBalance` at `block.number - minHoldingDuration` | | `Signals_TokenHasNoCheckpointSupport` | `minHoldingDuration > 0` but token doesn't implement `IVotes` | | `Signals_InsufficientLockAmount` | Lock amount {'<'} `minLockAmount` | ## Accepting and Expiring Initiatives An initiative in the **Proposed** state can transition to **Accepted** (threshold met) or **Expired** (inactive too long). Both transitions are permanent and one-way. ``` Proposed → Accepted (via acceptInitiative) Proposed → Expired (via expireInitiative) ``` ### `acceptInitiative()` ```solidity function acceptInitiative(uint256 initiativeId) external ``` Accepts an initiative that has reached the board's acceptance threshold. Records `acceptanceTimestamp`, which starts the `releaseLockDuration` countdown for token redemption. If an incentives pool is configured, supporters earn rewards on redemption. #### Access control Who can call `acceptInitiative()` depends on the board's `AcceptanceCriteria`: | `permissions` | `thresholdOverride` | Who can accept | Threshold required | | ---------------- | ------------------- | -------------- | -------------------- | | `OnlyOwner` | `None` | Owner only | Yes | | `OnlyOwner` | `OnlyOwner` | Owner only | No (owner bypasses) | | `Permissionless` | `None` | Anyone | Yes | | `Permissionless` | `OnlyOwner` | Anyone | Yes (owner bypasses) | The threshold is calculated as: ```solidity threshold = max(totalSupply * thresholdPercentTotalSupplyWAD / 1e18, minThreshold) ``` Query the current value with `getAcceptanceThreshold()` and compare against `getWeight(initiativeId)`. ### `expireInitiative()` ```solidity function expireInitiative(uint256 initiativeId) external onlyOwner ``` Expires an initiative that has been inactive for longer than the board's `inactivityTimeout`. Owner-only. The initiative must still be in the **Proposed** state. **Inactivity** is tracked by `lastActivity`, which updates when the initiative is created and each time a supporter locks tokens. If `block.timestamp > lastActivity + inactivityTimeout`, the initiative can be expired. Tokens locked against an expired initiative are redeemable immediately with no waiting period. No incentive rewards are distributed. ### Events ```solidity event InitiativeAccepted(uint256 indexed initiativeId, address indexed actor) event InitiativeExpired(uint256 indexed initiativeId, address indexed actor) ``` See [Redeeming Tokens](/initiatives/reclaim-tokens) for how supporters reclaim tokens after acceptance or expiry. ## Redeeming Tokens When you lock tokens behind an initiative, you receive an ERC721 NFT representing that position. Redemption burns the NFT and returns your tokens. When and how you can redeem depends on the state of the initiative and the board. ### Redemption rules A lock becomes redeemable when **any** of the following conditions is true: * **Initiative accepted** and `releaseLockDuration` has passed since acceptance * **Initiative expired** (immediate, no waiting) * **Lock naturally expired** (lock duration elapsed, regardless of initiative state) * **Board cancelled** (immediate, bypasses all timelocks) If the board is **closed** (not cancelled), normal rules still apply. Closing a board stops new activity but does not accelerate redemption. ### What you receive **From accepted initiatives:** your locked tokens plus any incentive rewards (auto-claimed during redemption). Bounty rewards are tracked separately in the Bounties contract. **From expired initiatives or natural lock expiry:** your locked tokens only. No incentive or bounty rewards. > Redeeming before an initiative is accepted forfeits all incentive rewards for that position. ### How to redeem ```solidity // Single lock function redeemLock(uint256 lockId) external nonReentrant // Batch: all your locks on one initiative function redeemLocksForInitiative( uint256 initiativeId, uint256[] calldata lockIds ) external nonReentrant ``` The function validates ownership, checks the lock is redeemable, marks it as withdrawn, burns the NFT, and transfers tokens back. For accepted initiatives, incentive rewards are claimed automatically. Use `redeemLocksForInitiative()` when you have multiple positions on the same initiative. It's more gas efficient and claims all rewards in a single transaction. ### Finding your lock positions Lock NFTs are ERC721Enumerable. Use `balanceOf(address)` and `tokenOfOwnerByIndex(address, index)` to enumerate your positions. Use `getTokenLock(lockId)` to inspect a specific lock's state. See the [Redeeming Tokens reference](/reference/initiative-actions/redeem) for full function signatures and examples. ## Proposing an Initiative An initiative is a proposal submitted to a Signals board. Anyone who meets the board's **proposer requirements** can create one. Both methods accept a title, description (markdown supported), and up to **5 attachments** (URIs pointing to supporting documents, designs, or forum posts). > Check eligibility before proposing with `accountCanPropose(address, lockAmount)`. This returns whether the address meets the board's proposer requirements. ### `proposeInitiative()` Creates the initiative without locking tokens. Other supporters can lock tokens behind it afterward. ```solidity function proposeInitiative(Metadata calldata metadata) external returns (uint256 initiativeId) ``` ### `proposeInitiativeWithLock()` Creates the initiative and locks your tokens in a single transaction. This gives the initiative immediate support weight and signals that the proposer has skin in the game. ```solidity function proposeInitiativeWithLock( Metadata calldata metadata, uint256 amount, uint256 lockDuration ) external returns (uint256 initiativeId, uint256 tokenId) ``` You must first approve the Signals contract to transfer your tokens. The contract uses `safeTransferFrom` to pull the locked amount. ### Attachments Attachments let you include supporting resources like proposal documents, designs, or forum posts. * Up to **5 attachments** per initiative * Each attachment requires a non-empty URI (`https://`, `ipfs://`, or on-chain pointer) * MIME type and description fields are optional hints for clients * Pass an empty array if you don't need attachments ### Proposer eligibility Boards configure proposer requirements at deployment. There are three levels: * **No requirements:** anyone can propose. Set `minBalance: 0` and `minHoldingDuration: 0`. * **Minimum balance:** proposers must hold at least `minBalance` tokens at the time of proposal. * **Minimum balance and holding duration:** proposers must hold at least `minBalance` tokens *and* have held them for at least `minHoldingDuration` blocks. This requires the token to support `ERC20Votes` checkpoints. ### Lock duration is measured in intervals When proposing with a lock, you specify duration as a number of **intervals**, not seconds. The board's `lockInterval` parameter defines how long one interval is (typically 1 day). A duration of `30` on a board with `lockInterval = 1 day` locks your tokens for 30 days. The maximum is capped by `maxLockIntervals`. ### After proposal Your initiative starts in the **Proposed** state. From here it can be: * **Accepted** when its aggregate support weight crosses the board's acceptance threshold * **Expired** by the board owner if no new support is added within the `inactivityTimeout` window See [Lock Tokens to Support an Initiative](/initiatives/support-initiative) for how other token holders add support, and [Accepting and Expiring Initiatives](/initiatives/action-expire-initiative) for how initiatives change state. ## Supporting an Initiative Lock ERC20 tokens behind an initiative to generate **support weight** that counts toward its acceptance threshold. Each lock mints a transferable ERC721 NFT representing the position. For example, a board using ENS token would mint NFTs named `ENS Locked Support` with symbol `sxENS`. Trading the NFT transfers the right to redeem the underlying tokens. ### `supportInitiative()` ```solidity function supportInitiative( uint256 initiativeId, uint256 amount, uint256 lockDuration ) external returns (uint256 tokenId) ``` Call with the initiative ID, the amount of tokens to lock, and a lock duration (measured in intervals). You must have approved the Signals contract to transfer your tokens beforehand. The initiative must be in the **Proposed** state and the board must be open. Check eligibility with `accountCanSupport(address, amount)`. You can support the same initiative multiple times. Each call creates a separate NFT with its own amount, duration, and decay calculation. ### Weight calculation and decay Support weight determines when an initiative can be accepted. **Initial weight** = `lockAmount * lockDuration` Locking 100 tokens for 10 intervals produces an initial weight of 1,000. Locking 50 tokens for 20 intervals also produces 1,000. The mechanism rewards large commitments and long commitments equally. ![Weight decay over time for a single lock position](/weight-decay-chart.svg) Both curves start at 1,000 (100 tokens x 10 intervals). Linear decay drops steadily until it hits the **weight floor** at 100 (the locked amount), then holds there until the lock expires. Exponential decay (0.9x per interval) falls steeply at first, then flattens, staying above the floor for longer. **Weight decay** reduces the initial value over elapsed intervals according to the board's configured decay curve: * **Linear decay:** weight decreases by a fixed rate per interval. With a rate of `1e18`, 100 tokens locked for 10 intervals loses 100 weight per interval elapsed. * **Exponential decay:** weight is multiplied by a decay factor each interval. With a multiplier of `0.9e18`, weight retains 90% of its value each interval, compounding. **Weight floor:** a lock's weight never drops below the locked amount itself. Even after heavy decay, 100 locked tokens still contribute at least 100 weight until the lock expires. This guarantees early supporters always retain baseline influence. **Aggregate weight** for an initiative is the sum of all individual lock weights at a given timestamp. When aggregate weight crosses the acceptance threshold, the initiative can be accepted. ### Acceptance threshold ```solidity threshold = max(totalSupply * thresholdPercentTotalSupplyWAD / 1e18, minThreshold) ``` The effective threshold is the greater of a percentage of total token supply or a fixed minimum. *Example: board with 5% supply threshold and 100k minThreshold, 10M total supply* ![Weight accumulation toward acceptance threshold](/weight-threshold-chart.svg) **Initiative B** accumulates sustained support and crosses the 500k threshold (5% of 10M supply) on day 15. It can now be accepted. **Initiative A** attracted early support but stalled. Without new locks, decay pulls its weight down. It never reaches threshold. Query the current threshold with `getAcceptanceThreshold()`. Query an initiative's current weight with `getWeight(initiativeId)` or at a specific time with `getWeightAt(initiativeId, timestamp)`. ### Incentive rewards If the board has an **incentives pool** attached, lock positions earn rewards weighted by creation time. Earlier locks receive higher multipliers. Rewards are auto-claimed during [redemption](/initiatives/reclaim-tokens). Redeeming before acceptance forfeits incentive rewards for that position. See the [Supporting Initiatives reference](/reference/initiative-actions/support-initiative) for full function details.