Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d8e42dd
wip
wjmelements Jul 24, 2026
b34b0a7
define errors for unanimous
wjmelements Jul 24, 2026
f46d381
fix(AddressXorSet): correct contains() bitmap reconstruction
wjmelements Jul 24, 2026
3769eb3
fix(Owners): detect xor-collision owners and add test coverage
wjmelements Jul 25, 2026
4ad4963
test(TwoSafeRule): add coverage for the unanimous modifier and veto
wjmelements Jul 25, 2026
25affee
refactor: rename TwoSafeRuler to UnanimousGovernance
wjmelements Jul 25, 2026
4ae6fac
docs(AddressXorSet): document XOR vs Bloom set tradeoff
wjmelements Jul 25, 2026
d90c8b6
refactor(Owners): replace AddressXorSet with bitmask-based OwnerSet
wjmelements Jul 26, 2026
acc647c
chore(Owners): drop dead asOwnerSet overload and add dev notes
wjmelements Jul 26, 2026
540a3e6
refactor(UnanimousGovernance): simplify hold check and rename sentinel
wjmelements Jul 26, 2026
ca82eb8
refactor: rename bit-tracking variables for clarity
wjmelements Jul 26, 2026
d2b9581
docs: add natspec for currentEpoch, unanimous, _veto, and owner funct…
wjmelements Jul 26, 2026
034c8f8
feat(ci): add lint and test workflows
wjmelements Jul 26, 2026
8b3b458
chore(ci): bump actions/checkout to v7
wjmelements Jul 27, 2026
ec1799a
chore(ci): upgrade forge to 1.7.1 and fix lint CI
wjmelements Jul 27, 2026
46eae2d
fix(ci): install dependencies before linting
wjmelements Jul 27, 2026
c4f6c6c
doc: recommend keccak256(msg.data) for taskId
wjmelements Jul 29, 2026
caf47cd
feat: IsASafe.isProbablyASafe
wjmelements Jul 29, 2026
d5ca04b
fix(owners): prevent removing the last owner
wjmelements Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Linter

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: v1.3.5
cache: true

- name: fmt
run: |
forge fmt --check

- name: build
run: |
forge build

- name: Lint
run: |
! (forge lint 2>&1 | grep "")
28 changes: 28 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Test

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: v1.3.5
Comment thread
wjmelements marked this conversation as resolved.
Outdated
cache: true

- name: Install Dependencies
run: |
forge install

- name: Run tests
run: |
forge test
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# forge
out/
cache/

# VIM
*.swp
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "lib/forge-std"]
path = lib/forge-std
url = https://github.com/foundry-rs/forge-std
8 changes: 8 additions & 0 deletions foundry.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"lib/forge-std": {
"tag": {
"name": "v1.16.2",
"rev": "bf647bd6046f2f7da30d0c2bf435e5c76a780c1b"
}
}
}
23 changes: 23 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[profile.default]
src = 'src'
test = 'test'
script = 'script'
out = 'out'
libs = ['lib']
cache_path = 'cache'
solc = "0.8.36"
via_ir = false
optimizer = true
optimizer_runs = 2000
bytecode_hash = "none"

# For dependencies
remappings = [
'forge-std/=lib/forge-std/src/',
#'@fvm-solidity/=lib/pdp/lib/fvm-solidity/src/',
]

[lint]
exclude_lints = [
"incorrect-shift",
]
1 change: 1 addition & 0 deletions lib/forge-std
Submodule forge-std added at bf647b
53 changes: 53 additions & 0 deletions src/lib/Epoch.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
pragma solidity ^0.8.36;

type Epoch is uint96;

using {
add as +,
sub as -,
equals as ==,
greaterThan as >,
lessThan as <,
greaterThanOrEqualTo as >=,
lessThanOrEqualTo as <=
} for Epoch global;

/// @return epoch The current block number
function currentEpoch() view returns (Epoch epoch) {
assembly ("memory-safe") {
epoch := number()
}
}

function add(Epoch epoch, Epoch other) pure returns (Epoch sum) {
assembly ("memory-safe") {
sum := add(epoch, other)
}
}

function sub(Epoch epoch, Epoch other) pure returns (Epoch difference) {
assembly ("memory-safe") {
difference := sub(epoch, other)
}
}

function equals(Epoch epoch, Epoch other) pure returns (bool) {
return Epoch.unwrap(epoch) == Epoch.unwrap(other);
}

function greaterThan(Epoch epoch, Epoch other) pure returns (bool) {
return Epoch.unwrap(epoch) > Epoch.unwrap(other);
}

function lessThan(Epoch epoch, Epoch other) pure returns (bool) {
return Epoch.unwrap(epoch) < Epoch.unwrap(other);
}

function greaterThanOrEqualTo(Epoch epoch, Epoch other) pure returns (bool) {
return Epoch.unwrap(epoch) >= Epoch.unwrap(other);
}

function lessThanOrEqualTo(Epoch epoch, Epoch other) pure returns (bool) {
return Epoch.unwrap(epoch) <= Epoch.unwrap(other);
}
32 changes: 32 additions & 0 deletions src/lib/OwnerSet.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
pragma solidity ^0.8.36;

// OwnerSet is a space-efficient bitmask
// Each owner has a unique representative bit assigned during addOwner

type OwnerSet is uint160;

using {equals as ==, notEquals as !=, or as |, xor as ^, and as &} for OwnerSet global;

OwnerSet constant EMPTY_SET = OwnerSet.wrap(uint160(0));
OwnerSet constant FULL_SET = OwnerSet.wrap(type(uint160).max);

function equals(OwnerSet a, OwnerSet b) pure returns (bool) {
return OwnerSet.unwrap(a) == OwnerSet.unwrap(b);
}

function notEquals(OwnerSet a, OwnerSet b) pure returns (bool) {
return OwnerSet.unwrap(a) != OwnerSet.unwrap(b);
}

function or(OwnerSet a, OwnerSet b) pure returns (OwnerSet) {
return OwnerSet.wrap(OwnerSet.unwrap(a) | OwnerSet.unwrap(b));
}

function xor(OwnerSet a, OwnerSet b) pure returns (OwnerSet) {
return OwnerSet.wrap(OwnerSet.unwrap(a) ^ OwnerSet.unwrap(b));
}

function and(OwnerSet a, OwnerSet b) pure returns (OwnerSet) {
return OwnerSet.wrap(OwnerSet.unwrap(a) & OwnerSet.unwrap(b));
}
99 changes: 99 additions & 0 deletions src/lib/Owners.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
pragma solidity ^0.8.36;

import {EMPTY_SET, FULL_SET, OwnerSet} from "./OwnerSet.sol";

library OwnersLibrary {
struct OwnerInfo {
uint8 bitId; // [0, 160]
}

/// @custom:storage-location erc7201:Solstice.Owners
struct Owners {
mapping(address => OwnerInfo) ownerInfo;
uint8 nextBitCursor; // [0, 160)
OwnerSet allOwners;
}

// keccak256(abi.encode(uint256(keccak256("Solstice.Owners")) - 1)) & ~bytes32(uint256(0xff));
bytes32 private constant OWNERS_SLOT = 0x7d2e7f914625694dd929b468ac404d7943373f4d24421c78ac93b57cc8efb500;

function getOwnersSlot() internal pure returns (Owners storage owners) {
assembly ("memory-safe") {
owners.slot := OWNERS_SLOT
}
}

event OwnerAdded(address indexed owner);
event OwnerRemoved(address indexed owner);

function isOwner(address someone) internal view returns (bool) {
return getOwnersSlot().ownerInfo[someone].bitId != 0;
}

/// @dev Returns EMPTY_SET if `owner` is not a current owner.
function asOwnerSet(address owner) internal view returns (OwnerSet mask) {
uint8 ownerBit = getOwnersSlot().ownerInfo[owner].bitId;
assembly ("memory-safe") {
mask := shl(sub(ownerBit, 1), 1)
}
}

function getAllOwners() internal view returns (OwnerSet) {
return getOwnersSlot().allOwners;
}

// Proposed owner is already an owner
error AlreadyOwner(address owner);
// Unsupported ownership count (> 160)
error MaximumOwnersReached();

/// @param owner The address to grant ownership to
function addOwner(address owner) internal {
require(!isOwner(owner), AlreadyOwner(owner));
Comment thread
rvagg marked this conversation as resolved.

Owners storage owners = getOwnersSlot();
uint8 ownerBit = owners.nextBitCursor;
OwnerSet allOwners = owners.allOwners;

require(allOwners != FULL_SET, MaximumOwnersReached());

OwnerSet ownerSet = EMPTY_SET;

// assign next free bit
while (true) {
assembly ("memory-safe") {
ownerSet := shl(ownerBit, 1)
ownerBit := add(1, ownerBit)
}
if (ownerSet & allOwners == EMPTY_SET) {
break;
} else {
ownerBit %= 160;
}
}

owners.ownerInfo[owner].bitId = ownerBit;
owners.allOwners = allOwners | ownerSet;
owners.nextBitCursor = ownerBit % 160;

emit OwnerAdded(owner);
}

// Address to remove is not a current owner
error NotOwner(address owner);

/// @param owner The address to revoke ownership from
/// @dev A removed owner's bit may be recycled to a future owner by addOwner.
/// @dev Veto stale PendingTasks when removing an owner to avoid approvals carrying over.
function removeOwner(address owner) internal {
require(isOwner(owner), NotOwner(owner));
Comment thread
rvagg marked this conversation as resolved.

Owners storage owners = getOwnersSlot();
OwnerSet mask = asOwnerSet(owner);
owners.allOwners = owners.allOwners ^ mask;
delete owners.ownerInfo[owner];

emit OwnerRemoved(owner);
}
}
30 changes: 30 additions & 0 deletions src/lib/PendingTask.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
pragma solidity ^0.8.36;

import {Epoch} from "./Epoch.sol";
import {OwnerSet} from "./OwnerSet.sol";

struct PendingTask {
Epoch modified;
OwnerSet approvals;
}

struct PendingTaskInfo {
PendingTask task;
}
Comment thread
rvagg marked this conversation as resolved.

library PendingTaskLibrary {
/// @custom:storage-location erc7201:Solstice.PendingTasks
struct PendingTasks {
mapping(bytes32 taskId => PendingTaskInfo) tasks;
}

// keccak256(abi.encode(uint256(keccak256("Solstice.PendingTasks")) - 1)) & ~bytes32(uint256(0xff));
bytes32 private constant PENDING_TASKS_SLOT = 0x635f64a8ec66823e68578973f5bc466fd4e0eadd655f760cfc91e860524aa300;

function getTasksSlot() internal pure returns (mapping(bytes32 taskId => PendingTaskInfo) storage tasks) {
assembly ("memory-safe") {
tasks.slot := PENDING_TASKS_SLOT
}
}
}
Loading
Loading