Skip to content

Commit

Permalink
Add dispatcher components (#66)
Browse files Browse the repository at this point in the history
* add dispatcher components

* address issues raised in PR

* Removes unused openzeppelin submodule

* Simplifies test dispatcher invoke functions

* Simplifies contract invocations in tests

* fix: typo in README.md

* Adds a few event checks

* Further simplification of `AccessControl` tests

* fix: syntax typos

* add audit fixes

---------

Co-authored-by: nonergodic <[email protected]>
Co-authored-by: Sebastián Claudio Nale <[email protected]>
  • Loading branch information
3 people authored Jan 16, 2025
1 parent 5c24ed7 commit af97781
Show file tree
Hide file tree
Showing 20 changed files with 1,870 additions and 36 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ For example, if you are using a solc version newer than `0.8.19` and are plannin

It is strongly recommended that you run the forge test suite of this SDK with your own compiler version to catch potential errors that stem from differences in compiler versions early. Yes, strictly speaking the Solidity version pragma should prevent these issues, but better to be safe than sorry, especially given that some components make extensive use of inline assembly.

**IERC20 Remapping**
**IERC20 and SafeERC20 Remapping**

This SDK comes with its own IERC20 interface. Given that projects tend to combine different SDKs, there's often this annoying issue of clashes of IERC20 interfaces, even though the are effectively the same. We handle this issue by importing `IERC20/IERC20.sol` which allows remapping the `IERC20/` prefix to whatever directory contains `IERC20.sol` in your project, thus providing an override mechanism that should allow dealing with this problem seamlessly until forge allows remapping of individual files.
This SDK comes with its own IERC20 interface and SafeERC20 implementation. Given that projects tend to combine different SDKs, there's often this annoying issue of clashes of IERC20 interfaces, even though they are effectively the same. We handle this issue by importing `IERC20/IERC20.sol` which allows remapping the `IERC20/` prefix to whatever directory contains `IERC20.sol` in your project, thus providing an override mechanism that should allow dealing with this problem seamlessly until forge allows remapping of individual files. The same approach is used for SafeERC20.

## Components

Expand Down
1 change: 1 addition & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ remappings = [
"forge-std/=lib/forge-std/src/",
"wormhole-sdk/=src/",
"IERC20/=src/interfaces/token/",
"SafeERC20/=src/libraries/",
]

verbosity = 3
31 changes: 4 additions & 27 deletions src/Utils.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,9 @@ pragma solidity ^0.8.4;

import { WORD_SIZE, SCRATCH_SPACE_PTR, FREE_MEMORY_PTR } from "./constants/Common.sol";

error NotAnEvmAddress(bytes32);

function toUniversalAddress(address addr) pure returns (bytes32 universalAddr) {
universalAddr = bytes32(uint256(uint160(addr)));
}

function fromUniversalAddress(bytes32 universalAddr) pure returns (address addr) {
if (bytes12(universalAddr) != 0)
revert NotAnEvmAddress(universalAddr);

/// @solidity memory-safe-assembly
assembly {
addr := universalAddr
}
}

/**
* Reverts with a given buffer data.
* Meant to be used to easily bubble up errors from low level calls when they fail.
*/
function reRevert(bytes memory err) pure {
/// @solidity memory-safe-assembly
assembly {
revert(add(err, 32), mload(err))
}
}
import {tokenOrNativeTransfer} from "wormhole-sdk/utils/Transfer.sol";
import {reRevert} from "wormhole-sdk/utils/Revert.sol";
import {toUniversalAddress, fromUniversalAddress} from "wormhole-sdk/utils/UniversalAddress.sol";

//see Optimization.md for rationale on avoiding short-circuiting
function eagerAnd(bool lhs, bool rhs) pure returns (bool ret) {
Expand All @@ -43,7 +20,7 @@ function eagerOr(bool lhs, bool rhs) pure returns (bool ret) {
/// @solidity memory-safe-assembly
assembly {
ret := or(lhs, rhs)
}
}
}

function keccak256Word(bytes32 word) pure returns (bytes32 hash) {
Expand Down
298 changes: 298 additions & 0 deletions src/components/dispatcher/AccessControl.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
// SPDX-License-Identifier: Apache 2

pragma solidity ^0.8.4;

import {BytesParsing} from "wormhole-sdk/libraries/BytesParsing.sol";
import {
ACCESS_CONTROL_ID,
ACCESS_CONTROL_QUERIES_ID,
OWNER_ID,
PENDING_OWNER_ID,
IS_ADMIN_ID,
ADMINS_ID,
REVOKE_ADMIN_ID,
ADD_ADMIN_ID,
PROPOSE_OWNERSHIP_TRANSFER_ID,
ACQUIRE_OWNERSHIP_ID,
RELINQUISH_OWNERSHIP_ID,
CANCEL_OWNERSHIP_TRANSFER_ID
} from "wormhole-sdk/components/dispatcher/Ids.sol";

//rationale for different roles (owner, admin):
// * owner should be a mulit-sig / ultra cold wallet that is only activated in exceptional
// circumstances.
// * admin should also be either a cold wallet or Admin contract. In either case,
// the expectation is that multiple, slightly less trustworthy parties than the owner will
// have access to it, lowering trust assumptions and increasing attack surface. Admins
// perform rare but not exceptional operations.

struct AccessControlState {
address owner; //puts owner address in eip1967 admin slot
address pendingOwner;
address[] admins;
mapping(address => uint256) isAdmin;
}

// we use the designated eip1967 admin storage slot:
// keccak256("eip1967.proxy.admin") - 1
bytes32 constant ACCESS_CONTROL_STORAGE_SLOT =
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;

function accessControlState() pure returns (AccessControlState storage state) {
assembly ("memory-safe") { state.slot := ACCESS_CONTROL_STORAGE_SLOT }
}

error NotAuthorized();
error InvalidAccessControlCommand(uint8 command);
error InvalidAccessControlQuery(uint8 query);

event OwnerUpdated(address oldAddress, address newAddress, uint256 timestamp);
event AdminsUpdated(address addr, bool isAdmin, uint256 timestamp);

enum Role {
None,
Owner,
Admin
}

function failAuthIf(bool condition) pure {
if (condition)
revert NotAuthorized();
}

function senderAtLeastAdmin() view returns (Role) {
Role role = senderRole();
failAuthIf(role == Role.None);

return role;
}

function senderRole() view returns (Role) {
AccessControlState storage state = accessControlState();
if (msg.sender == state.owner) //check highest privilege level first
return Role.Owner;

return state.isAdmin[msg.sender] != 0 ? Role.Admin : Role.None;
}

abstract contract AccessControl {
using BytesParsing for bytes;

// ---- construction ----

function _accessControlConstruction(
address owner,
address[] memory admins
) internal {
AccessControlState storage state = accessControlState();
state.owner = owner;
for (uint i = 0; i < admins.length; ++i)
_updateAdmins(state, admins[i], true);
}

// ---- external -----

//selector: f2fde38b
function transferOwnership(address newOwner) external {
AccessControlState storage state = accessControlState();
if (msg.sender != state.owner)
revert NotAuthorized();

_proposeOwnershipTransfer(state, newOwner);
}

//selector: 23452b9c
function cancelOwnershipTransfer() external {
AccessControlState storage state = accessControlState();
if (msg.sender != state.owner)
revert NotAuthorized();

_cancelOwnershipTransfer(state);
}

//selector: 1c74a301
function receiveOwnership() external {
_acquireOwnership();
}

// ---- internals ----

/**
* Dispatch an execute function. Execute functions almost always modify contract state.
*/
function dispatchExecAccessControl(
bytes calldata data,
uint offset,
uint8 command
) internal returns (bool, uint) {
if (command == ACCESS_CONTROL_ID)
offset = _batchAccessControlCommands(data, offset);
else if (command == ACQUIRE_OWNERSHIP_ID)
_acquireOwnership();
else
return (false, offset);

return (true, offset);
}

/**
* Dispatch a query function. Query functions never modify contract state.
*/
function dispatchQueryAccessControl(
bytes calldata data,
uint offset,
uint8 query
) view internal returns (bool, bytes memory, uint) {
bytes memory result;
if (query == ACCESS_CONTROL_QUERIES_ID)
(result, offset) = _batchAccessControlQueries(data, offset);
else
return (false, new bytes(0), offset);

return (true, result, offset);
}

function _batchAccessControlCommands(
bytes calldata commands,
uint offset
) internal returns (uint) {
AccessControlState storage state = accessControlState();
bool isOwner = senderAtLeastAdmin() == Role.Owner;

uint remainingCommands;
(remainingCommands, offset) = commands.asUint8CdUnchecked(offset);
for (uint i = 0; i < remainingCommands; ++i) {
uint8 command;
(command, offset) = commands.asUint8CdUnchecked(offset);
if (command == REVOKE_ADMIN_ID) {
address admin;
(admin, offset) = commands.asAddressCdUnchecked(offset);
_updateAdmins(state, admin, false);
}
else {
if (!isOwner)
revert NotAuthorized();

if (command == ADD_ADMIN_ID) {
address newAdmin;
(newAdmin, offset) = commands.asAddressCdUnchecked(offset);
_updateAdmins(state, newAdmin, true);
}
else if (command == PROPOSE_OWNERSHIP_TRANSFER_ID) {
address newOwner;
(newOwner, offset) = commands.asAddressCdUnchecked(offset);

_proposeOwnershipTransfer(state, newOwner);
}
else if (command == CANCEL_OWNERSHIP_TRANSFER_ID) {
_cancelOwnershipTransfer(state);
}
else if (command == RELINQUISH_OWNERSHIP_ID) {
_relinquishOwnership(state);

//ownership relinquishment must be the last command in the batch
BytesParsing.checkLength(offset, commands.length);
}
else
revert InvalidAccessControlCommand(command);
}
}
return offset;
}

function _batchAccessControlQueries(
bytes calldata queries,
uint offset
) internal view returns (bytes memory, uint) {
AccessControlState storage state = accessControlState();
bytes memory ret;

uint remainingQueries;
(remainingQueries, offset) = queries.asUint8CdUnchecked(offset);
for (uint i = 0; i < remainingQueries; ++i) {
uint8 query;
(query, offset) = queries.asUint8CdUnchecked(offset);

if (query == IS_ADMIN_ID) {
address admin;
(admin, offset) = queries.asAddressCdUnchecked(offset);
ret = abi.encodePacked(ret, state.isAdmin[admin] != 0);
}
else if (query == ADMINS_ID) {
ret = abi.encodePacked(ret, uint8(state.admins.length));
for (uint j = 0; j < state.admins.length; ++j)
ret = abi.encodePacked(ret, state.admins[j]);
}
else {
address addr;
if (query == OWNER_ID)
addr = state.owner;
else if (query == PENDING_OWNER_ID)
addr = state.pendingOwner;
else
revert InvalidAccessControlQuery(query);

ret = abi.encodePacked(ret, addr);
}
}

return (ret, offset);
}

// ---- private ----

function _acquireOwnership() private {
AccessControlState storage state = accessControlState();
if (state.pendingOwner != msg.sender)
revert NotAuthorized();

_updateOwner(state, msg.sender);
}

function _relinquishOwnership(AccessControlState storage state) private {
_updateOwner(state, address(0));
}

function _updateOwner(AccessControlState storage state, address newOwner) private {
address oldAddress = state.owner;
state.owner = newOwner;
state.pendingOwner = address(0);

emit OwnerUpdated(oldAddress, newOwner, block.timestamp);
}

function _proposeOwnershipTransfer(AccessControlState storage state, address newOwner) private {
state.pendingOwner = newOwner;
}

function _cancelOwnershipTransfer(AccessControlState storage state) private {
state.pendingOwner = address(0);
}

function _updateAdmins(
AccessControlState storage state,
address admin,
bool authorization
) private { unchecked {
if ((state.isAdmin[admin] != 0) == authorization)
return;

if (authorization) {
state.admins.push(admin);
state.isAdmin[admin] = state.admins.length;
}
else {
uint256 rawIndex = state.isAdmin[admin];
if (rawIndex != state.admins.length) {
address tmpAdmin = state.admins[state.admins.length - 1];
state.isAdmin[tmpAdmin] = rawIndex;
state.admins[rawIndex - 1] = tmpAdmin;
}

state.isAdmin[admin] = 0;
state.admins.pop();
}

emit AdminsUpdated(admin, authorization, block.timestamp);
}}
}
Loading

0 comments on commit af97781

Please sign in to comment.