Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
64 changes: 38 additions & 26 deletions packages/contracts/scripts/e2e-validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,48 +437,60 @@ async function main() {
console.log(" ✓ adminship rotated + last-admin guard enforced");

// ════════════════════════════════════════════════════════════════════
// ⑫b 2-step proxyAdmin transfer (versioned proxy level).
// deployer is still proxyAdmin here — removing from whitelistedAdmins
// in ⑫ doesn't affect the versioned-proxy admin slot. Transfer it to
// userA (already the sole whitelistedAdmin after ⑫) so one address
// controls both surfaces going forward.
// ⑫b proxyAdmin whitelist rotation (versioned proxy level).
// deployer is still the sole proxyAdmin here — removing it from
// whitelistedAdmins in ⑫ doesn't affect the versioned-proxy admin
// list. Grant userA, then revoke deployer so one address controls
// both surfaces going forward.
// ════════════════════════════════════════════════════════════════════
console.log("\n⑫b 2-step proxyAdmin transfer: deployer → userA …");
console.log("\n⑫b proxyAdmin whitelist rotation: deployer → userA …");
assertEq(
(await versioned.proxyAdmin()).toLowerCase(),
deployer.address.toLowerCase(),
"proxyAdmin still = deployer (whitelist revoke didn't touch versioned admin)",
await versioned.isProxyAdmin(deployer.address),
true,
"deployer still proxyAdmin (whitelist revoke didn't touch versioned admin)",
);
await (await versioned.connect(deployer).transferProxyAdmin(userA.address)).wait();
await (await versioned.connect(deployer).setProxyAdmin(userA.address, true)).wait();
assertEq(
(await versioned.pendingProxyAdmin()).toLowerCase(),
userA.address.toLowerCase(),
"pendingProxyAdmin = userA",
await versioned.isProxyAdmin(userA.address),
true,
"userA granted proxyAdmin",
);
assertEq(
await versioned.proxyAdminCount(),
2n,
"proxyAdminCount = 2 after grant",
);
// Guard: only the pending address can accept
// Guard: non-admin cannot mutate the list
await expectRevertWithName(
() => versioned.connect(nonAdmin).acceptProxyAdmin(),
"VersionedFeeProxy_NotPendingProxyAdmin",
"non-pending accept blocked",
() => versioned.connect(nonAdmin).setProxyAdmin(nonAdmin.address, true),
"VersionedFeeProxy_NotProxyAdmin",
"non-admin setProxyAdmin blocked",
);
await (await versioned.connect(userA).acceptProxyAdmin()).wait();
// Revoke deployer — userA becomes the sole admin
await (await versioned.connect(deployer).setProxyAdmin(deployer.address, false)).wait();
assertEq(
(await versioned.proxyAdmin()).toLowerCase(),
userA.address.toLowerCase(),
"proxyAdmin now = userA",
await versioned.isProxyAdmin(deployer.address),
false,
"deployer revoked",
);
assertEq(
await versioned.pendingProxyAdmin(),
ethers.ZeroAddress,
"pendingProxyAdmin cleared",
await versioned.proxyAdminCount(),
1n,
"proxyAdminCount = 1 after revoke",
);
// Old proxyAdmin locked out
await expectRevertWithName(
() => versioned.connect(deployer).transferProxyAdmin(deployer.address),
() => versioned.connect(deployer).setProxyAdmin(deployer.address, true),
"VersionedFeeProxy_NotProxyAdmin",
"old proxyAdmin locked out",
);
console.log(" ✓ proxyAdmin rotated via 2-step, old admin locked out");
// Last-admin guard — userA cannot self-revoke
await expectRevertWithName(
() => versioned.connect(userA).setProxyAdmin(userA.address, false),
"VersionedFeeProxy_LastProxyAdmin",
"last proxyAdmin cannot self-revoke",
);
console.log(" ✓ proxyAdmin rotated via whitelist, last-admin guard enforced");

// ════════════════════════════════════════════════════════════════════
// ⑬ Final withdrawAll by userA (now sole admin)
Expand Down
9 changes: 6 additions & 3 deletions packages/contracts/src/IntuitionFeeProxyFactory.sol
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,13 @@ contract IntuitionFeeProxyFactory is
(ethMultiVault, depositFixedFee, depositPercentageFee, initialAdmins)
);

address proxyAdmin_ = initialAdmins.length > 0 ? initialAdmins[0] : address(0);

// Pass the full initialAdmins array to the proxy's Role 1 whitelist.
// Both Role 1 (proxyAdmins, upgrade authority) and Role 2
// (whitelistedAdmins, fees) start from the same set — admins can
// diverge the two later via setProxyAdmin / setWhitelistedAdmin
// if their team / safe layout needs it.
proxy = address(
new IntuitionVersionedFeeProxy(proxyAdmin_, version, impl, initData, name)
new IntuitionVersionedFeeProxy(initialAdmins, version, impl, initData, name)
);

proxiesByDeployer[msg.sender].push(proxy);
Expand Down
115 changes: 69 additions & 46 deletions packages/contracts/src/IntuitionVersionedFeeProxy.sol
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ import {Errors} from "./libraries/Errors.sol";
/// with the logic implementation's regular storage.
/// - No `receive()`: direct ETH transfers revert. All legitimate fee flows
/// carry calldata, so a bare transfer would only be a mis-send.
/// - Admin gating is a single `proxyAdmin` address; users should point it at a
/// multisig. Transferable via `transferProxyAdmin`.
/// - ⚠️ **`name` is admin-controlled metadata, NOT a trust anchor.** The
/// - Admin gating is a **whitelist of proxyAdmins** (any of them can act).
/// Grant / revoke via `setProxyAdmin`. The last remaining admin cannot
/// self-revoke (a runtime guard prevents accidental lock-out). Recommended
/// setup: at least one Gnosis Safe in the list.
/// - ⚠️ **`name` is admin-controlled metadata, NOT a trust anchor.** Any
/// proxy-admin can rename the proxy at any time — including to mimic a
/// known brand. Consumers MUST derive identity / "official" status from
/// the proxy address itself (e.g. the Factory's `isProxyFromFactory`
Expand Down Expand Up @@ -52,14 +54,18 @@ contract IntuitionVersionedFeeProxy is IIntuitionVersionedFeeProxy {
bytes32[] versionList;
mapping(bytes32 => address) implementations;
mapping(bytes32 => bool) versionExists;
address proxyAdmin;
// Multi-admin whitelist for Role 1 (upgrade authority). Any admin in
// the mapping can register versions, set default, rename, and grant /
// revoke other proxyAdmins. Mirrors the Role 2 (fee-admin) shape so
// both rotation flows feel symmetric. The pre-rotation single-slot
// 2-step model (proxyAdmin + pendingProxyAdmin) was dropped — the
// safety it provided is delivered by the recommendation to put a
// Safe multisig in the list, which gives N-of-M signing semantics.
mapping(address => bool) proxyAdmins;
// Count tracked alongside the mapping so the last-admin guard is O(1).
// Replaces the 2-step pending-admin pattern.
uint256 proxyAdminCount;
bytes32 name;
// 2-step admin transfer: `pendingProxyAdmin` holds the candidate set by
// the current admin via `transferProxyAdmin`. Only that address can
// promote itself via `acceptProxyAdmin`. Prevents fat-fingered
// transfers to lost / wrong addresses. Appended — ERC-7201 namespaced
// slot mask (~0xff) reserves 256 slots, plenty of room.
address pendingProxyAdmin;
}

function _layout() private pure returns (Layout storage s) {
Expand All @@ -72,27 +78,30 @@ contract IntuitionVersionedFeeProxy is IIntuitionVersionedFeeProxy {
// ============ Modifiers ============

modifier onlyProxyAdmin() {
if (msg.sender != _layout().proxyAdmin) {
if (!_layout().proxyAdmins[msg.sender]) {
revert Errors.VersionedFeeProxy_NotProxyAdmin();
}
_;
}

// ============ Constructor ============

/// @param admin Proxy-admin authorized for version management (recommend: Safe)
/// @param initialProxyAdmins Initial whitelist (at least one non-zero, no
/// duplicates). For production: include at least one Safe multisig.
/// @param initialVersion Identifier for the initial registered version (e.g. bytes32("v2.0.0"))
/// @param initialImpl Address of the initial logic implementation
/// @param initData Calldata forwarded via delegatecall to initialize the logic
/// @param initialName Optional human-readable name (bytes32 — empty for none, editable by proxyAdmin via setName)
/// @param initialName Optional human-readable name (bytes32 — empty for none, editable by a proxyAdmin via setName)
constructor(
address admin,
address[] memory initialProxyAdmins,
bytes32 initialVersion,
address initialImpl,
bytes memory initData,
bytes32 initialName
) {
if (admin == address(0)) revert Errors.IntuitionFeeProxy_ZeroAddress();
if (initialProxyAdmins.length == 0) {
revert Errors.IntuitionFeeProxy_NoAdminsProvided();
}
if (initialVersion == bytes32(0)) revert Errors.VersionedFeeProxy_InvalidVersion();
if (initialImpl == address(0) || initialImpl.code.length == 0) {
revert Errors.VersionedFeeProxy_InvalidImplementation();
Expand All @@ -107,7 +116,23 @@ contract IntuitionVersionedFeeProxy is IIntuitionVersionedFeeProxy {
}

Layout storage s = _layout();
s.proxyAdmin = admin;

// Seed the proxyAdmin whitelist. Reject zero addresses and dedupe
// (silently — duplicates in the list would otherwise inflate the
// count and break the last-admin guard).
uint256 added;
uint256 len = initialProxyAdmins.length;
for (uint256 i = 0; i < len; i++) {
address a = initialProxyAdmins[i];
if (a == address(0)) revert Errors.IntuitionFeeProxy_ZeroAddress();
if (!s.proxyAdmins[a]) {
s.proxyAdmins[a] = true;
emit ProxyAdminGranted(a);
unchecked { ++added; }
}
}
s.proxyAdminCount = added;

s.implementations[initialVersion] = initialImpl;
s.versionExists[initialVersion] = true;
s.versionList.push(initialVersion);
Expand All @@ -116,7 +141,6 @@ contract IntuitionVersionedFeeProxy is IIntuitionVersionedFeeProxy {

_mirrorEip1967(initialImpl);

emit ProxyAdminTransferred(address(0), admin);
emit VersionRegistered(initialVersion, initialImpl);
emit DefaultVersionChanged(bytes32(0), initialVersion);
if (initialName != bytes32(0)) {
Expand Down Expand Up @@ -213,11 +237,11 @@ contract IntuitionVersionedFeeProxy is IIntuitionVersionedFeeProxy {
/// `receiver == msg.sender || approvals[receiver][msg.sender] & DEPOSIT`,
/// and `msg.sender` from the MultiVault's POV is this proxy. A new
/// impl could legally route deposits to any address that has approved
/// this proxy on the MultiVault. The trust model relies on
/// `proxyAdmin` being a Safe multisig (M-of-N, M ≥ 3 recommended).
/// Users who want to be insulated from default-version switches MUST
/// pin a specific version via `executeAtVersion(version, …)` rather
/// than relying on the fallback.
/// this proxy on the MultiVault. The trust model relies on the
/// `proxyAdmins` whitelist including a Safe multisig (M-of-N, M ≥ 3
/// recommended). Users who want to be insulated from default-version
/// switches MUST pin a specific version via `executeAtVersion(version, …)`
/// rather than relying on the fallback.
function setDefaultVersion(bytes32 version) external onlyProxyAdmin {
Layout storage s = _layout();
if (!s.versionExists[version]) revert Errors.VersionedFeeProxy_VersionNotFound();
Expand All @@ -229,22 +253,25 @@ contract IntuitionVersionedFeeProxy is IIntuitionVersionedFeeProxy {
}

/// @inheritdoc IIntuitionVersionedFeeProxy
function transferProxyAdmin(address newAdmin) external onlyProxyAdmin {
if (newAdmin == address(0)) revert Errors.IntuitionFeeProxy_ZeroAddress();
Layout storage s = _layout();
s.pendingProxyAdmin = newAdmin;
emit ProxyAdminTransferStarted(s.proxyAdmin, newAdmin);
}

/// @inheritdoc IIntuitionVersionedFeeProxy
function acceptProxyAdmin() external {
function setProxyAdmin(address admin, bool status) external onlyProxyAdmin {
if (admin == address(0)) revert Errors.IntuitionFeeProxy_ZeroAddress();
Layout storage s = _layout();
address pending = s.pendingProxyAdmin;
if (msg.sender != pending) revert Errors.VersionedFeeProxy_NotPendingProxyAdmin();
address old = s.proxyAdmin;
s.proxyAdmin = pending;
delete s.pendingProxyAdmin;
emit ProxyAdminTransferred(old, pending);
bool current = s.proxyAdmins[admin];
if (current == status) revert Errors.VersionedFeeProxy_ProxyAdminAlreadySet();
// Last-admin guard: refuse to revoke the only remaining proxyAdmin,
// otherwise the role would be permanently lost (no one can grant it
// back). Mirrors the Role 2 (fee-admin) `adminCount > 0` invariant.
if (!status && s.proxyAdminCount == 1) {
revert Errors.VersionedFeeProxy_LastProxyAdmin();
}
s.proxyAdmins[admin] = status;
if (status) {
unchecked { ++s.proxyAdminCount; }
emit ProxyAdminGranted(admin);
} else {
unchecked { --s.proxyAdminCount; }
emit ProxyAdminRevoked(admin);
}
}

/// @inheritdoc IIntuitionVersionedFeeProxy
Expand Down Expand Up @@ -279,13 +306,13 @@ contract IntuitionVersionedFeeProxy is IIntuitionVersionedFeeProxy {
}

/// @inheritdoc IIntuitionVersionedFeeProxy
function proxyAdmin() external view returns (address) {
return _layout().proxyAdmin;
function isProxyAdmin(address candidate) external view returns (bool) {
return _layout().proxyAdmins[candidate];
}

/// @notice The candidate admin awaiting acceptance, or address(0) if none.
function pendingProxyAdmin() external view returns (address) {
return _layout().pendingProxyAdmin;
/// @inheritdoc IIntuitionVersionedFeeProxy
function proxyAdminCount() external view returns (uint256) {
return _layout().proxyAdminCount;
}

// ============ ERC-165 ============
Expand Down Expand Up @@ -355,10 +382,6 @@ contract IntuitionVersionedFeeProxy is IIntuitionVersionedFeeProxy {
}
}

// NOTE: no `receive()` — ETH transfers without calldata revert. Fee flows
// all come with calldata (createAtoms / deposit / …), so bare transfers
// would only be mis-sends.

// ============ Internal ============

/// @dev Writes `impl` into the EIP-1967 implementation slot and emits
Expand Down
37 changes: 23 additions & 14 deletions packages/contracts/src/interfaces/IIntuitionVersionedFeeProxy.sol
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ interface IIntuitionVersionedFeeProxy {
event VersionRegistered(bytes32 indexed version, address indexed implementation);
event VersionRemoved(bytes32 indexed version);
event DefaultVersionChanged(bytes32 indexed oldVersion, bytes32 indexed newVersion);
event ProxyAdminTransferred(address indexed oldAdmin, address indexed newAdmin);

/// @notice Emitted when a new proxy-admin transfer is initiated (pending the new admin's acceptance).
event ProxyAdminTransferStarted(address indexed currentAdmin, address indexed pendingAdmin);
/// @notice Emitted when an address gains the proxyAdmin role.
event ProxyAdminGranted(address indexed admin);
/// @notice Emitted when an address loses the proxyAdmin role.
event ProxyAdminRevoked(address indexed admin);

/// @notice Emitted when the proxy's human-readable name is set or changed.
event NameChanged(bytes32 indexed oldName, bytes32 indexed newName);
Expand All @@ -27,17 +28,20 @@ interface IIntuitionVersionedFeeProxy {
function removeVersion(bytes32 version) external;
function setDefaultVersion(bytes32 version) external;

/// @notice Initiate a proxy-admin transfer. The new admin takes over only
/// after they call `acceptProxyAdmin`. Passing `address(0)` reverts.
/// Calling again before acceptance overwrites the pending admin.
function transferProxyAdmin(address newAdmin) external;

/// @notice Finalize a pending proxy-admin transfer. Only callable by the
/// address set as `pendingProxyAdmin` via `transferProxyAdmin`.
function acceptProxyAdmin() external;
/// @notice Grant or revoke the proxyAdmin role for an address.
/// `status = true` adds; `status = false` removes. Idempotent
/// calls (status already matches) revert with
/// `ProxyAdminAlreadySet`. The last remaining admin cannot
/// self-revoke (revert `LastProxyAdmin`).
/// @dev Any current proxyAdmin can call this. For production, the
/// recommended setup is to keep at least one Gnosis Safe
/// multisig in the whitelist — the Safe's internal quorum
/// provides the safety net the previous 2-step transfer flow
/// used to enforce.
function setProxyAdmin(address admin, bool status) external;

/// @notice Set or rename the proxy's human-readable label. Pass bytes32(0) to clear.
/// @dev ⚠️ **The name is NOT a trust anchor.** The proxy-admin can
/// @dev ⚠️ **The name is NOT a trust anchor.** Any proxy-admin can
/// rename the proxy at any time — including to mimic a known
/// brand. Frontends MUST NOT use `name` to derive an "official"
/// / "verified" badge. Use the Factory's `isProxyFromFactory`
Expand All @@ -50,8 +54,13 @@ interface IIntuitionVersionedFeeProxy {
function getImplementation(bytes32 version) external view returns (address);
function getDefaultVersion() external view returns (bytes32);
function getVersions() external view returns (bytes32[] memory);
function proxyAdmin() external view returns (address);
function pendingProxyAdmin() external view returns (address);

/// @notice Returns true if `candidate` is currently a proxyAdmin.
function isProxyAdmin(address candidate) external view returns (bool);

/// @notice Returns the number of addresses currently holding the proxyAdmin role.
function proxyAdminCount() external view returns (uint256);

/// @notice Returns the proxy's current human-readable label.
/// @dev ⚠️ See the warning on `setName` — a name is admin-controlled
/// metadata, never a source of trust.
Expand Down
12 changes: 10 additions & 2 deletions packages/contracts/src/libraries/Errors.sol
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,16 @@ library Errors {
/// @notice Delegatecall into the versioned implementation failed without returndata
error VersionedFeeProxy_DelegateCallFailed();

/// @notice `acceptProxyAdmin` called by an address that is not the pending admin
error VersionedFeeProxy_NotPendingProxyAdmin();
/// @notice `setProxyAdmin` called with status matching the current state
/// (granting an already-admin or revoking a non-admin). Idempotent
/// calls revert so the on-chain log of grant/revoke events stays
/// truthful (every emit corresponds to a real state change).
error VersionedFeeProxy_ProxyAdminAlreadySet();

/// @notice `setProxyAdmin(admin, false)` would leave the proxy with zero
/// admins, permanently locking the role. The last remaining
/// proxyAdmin cannot self-revoke.
error VersionedFeeProxy_LastProxyAdmin();

/// @notice The new impl's `STORAGE_COMPAT_ID` differs from the proxy's
/// reference (default version's impl). Registering an incompatible
Expand Down
Loading
Loading