From 4acb25ce49f8e2473246f5ad3337ac6376807c55 Mon Sep 17 00:00:00 2001 From: nksazonov Date: Tue, 17 Feb 2026 11:36:27 +0100 Subject: [PATCH 1/6] build: add remappings, reorder imports --- contracts/evm/foundry.lock | 3 +++ contracts/evm/remappings.txt | 5 +++++ contracts/evm/src/QuorumCustody.sol | 7 ++++--- contracts/evm/src/SimpleCustody.sol | 7 ++++--- contracts/evm/src/interfaces/IDeposit.sol | 2 +- contracts/evm/src/interfaces/IWithdraw.sol | 2 +- 6 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 contracts/evm/remappings.txt diff --git a/contracts/evm/foundry.lock b/contracts/evm/foundry.lock index d0c0159..1dbbe8e 100644 --- a/contracts/evm/foundry.lock +++ b/contracts/evm/foundry.lock @@ -4,5 +4,8 @@ "name": "v1.14.0", "rev": "1801b0541f4fda118a10798fd3486bb7051c5dd6" } + }, + "lib/openzeppelin-contracts": { + "rev": "8ff78ffb6e78463f070eab59487b4ba30481b53c" } } \ No newline at end of file diff --git a/contracts/evm/remappings.txt b/contracts/evm/remappings.txt new file mode 100644 index 0000000..918ed31 --- /dev/null +++ b/contracts/evm/remappings.txt @@ -0,0 +1,5 @@ +@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ +erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/ +forge-std/=lib/forge-std/src/ +halmos-cheatcodes/=lib/openzeppelin-contracts/lib/halmos-cheatcodes/src/ +openzeppelin-contracts/=lib/openzeppelin-contracts/ diff --git a/contracts/evm/src/QuorumCustody.sol b/contracts/evm/src/QuorumCustody.sol index fb4e2ce..a152450 100644 --- a/contracts/evm/src/QuorumCustody.sol +++ b/contracts/evm/src/QuorumCustody.sol @@ -1,14 +1,15 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity 0.8.30; -import {IWithdraw} from "./interfaces/IWithdraw.sol"; -import {IDeposit} from "./interfaces/IDeposit.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {IWithdraw} from "./interfaces/IWithdraw.sol"; +import {IDeposit} from "./interfaces/IDeposit.sol"; + contract QuorumCustody is IWithdraw, IDeposit, ReentrancyGuard, EIP712 { using SafeERC20 for IERC20; diff --git a/contracts/evm/src/SimpleCustody.sol b/contracts/evm/src/SimpleCustody.sol index bc4b3b9..1c70b4f 100644 --- a/contracts/evm/src/SimpleCustody.sol +++ b/contracts/evm/src/SimpleCustody.sol @@ -1,13 +1,14 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; +pragma solidity 0.8.30; -import {IWithdraw} from "./interfaces/IWithdraw.sol"; -import {IDeposit} from "./interfaces/IDeposit.sol"; import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {IWithdraw} from "./interfaces/IWithdraw.sol"; +import {IDeposit} from "./interfaces/IDeposit.sol"; + contract SimpleCustody is IWithdraw, IDeposit, AccessControl, ReentrancyGuard { using SafeERC20 for IERC20; diff --git a/contracts/evm/src/interfaces/IDeposit.sol b/contracts/evm/src/interfaces/IDeposit.sol index 68455de..ddada0e 100644 --- a/contracts/evm/src/interfaces/IDeposit.sol +++ b/contracts/evm/src/interfaces/IDeposit.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; +pragma solidity ^0.8.30; interface IDeposit { // ---- errors ---- diff --git a/contracts/evm/src/interfaces/IWithdraw.sol b/contracts/evm/src/interfaces/IWithdraw.sol index a8ae079..d3a8cea 100644 --- a/contracts/evm/src/interfaces/IWithdraw.sol +++ b/contracts/evm/src/interfaces/IWithdraw.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; +pragma solidity ^0.8.30; interface IWithdraw { // ---- errors ---- From 3c501a16fd8e0f0e477485e67edffcd81e01b8b5 Mon Sep 17 00:00:00 2001 From: nksazonov Date: Tue, 17 Feb 2026 11:36:34 +0100 Subject: [PATCH 2/6] build: add optimizer --- contracts/evm/foundry.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contracts/evm/foundry.toml b/contracts/evm/foundry.toml index 25b918f..7350417 100644 --- a/contracts/evm/foundry.toml +++ b/contracts/evm/foundry.toml @@ -3,4 +3,5 @@ src = "src" out = "out" libs = ["lib"] -# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options +via_ir = true +optimizer_runs = 1_000_000 From 52ba8bbe365f2220c9c941758b871b82ef39678e Mon Sep 17 00:00:00 2001 From: nksazonov Date: Tue, 17 Feb 2026 11:36:47 +0100 Subject: [PATCH 3/6] docs(deployments): deploy SimpleCustody to Sepolia --- .../2026-02-17T10:26:25.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 contracts/evm/deployments/11155111/SimpleCustody.sol:SimpleCustody/2026-02-17T10:26:25.json diff --git a/contracts/evm/deployments/11155111/SimpleCustody.sol:SimpleCustody/2026-02-17T10:26:25.json b/contracts/evm/deployments/11155111/SimpleCustody.sol:SimpleCustody/2026-02-17T10:26:25.json new file mode 100644 index 0000000..4f0a902 --- /dev/null +++ b/contracts/evm/deployments/11155111/SimpleCustody.sol:SimpleCustody/2026-02-17T10:26:25.json @@ -0,0 +1,15 @@ +{ + "deployer": "0xF8bedb0aBa14833e95F29e760487C3d34Bc4Ec64", + "deployedTo": "0x61b9E0767f2ECA7e33802E82f9c64b1eBE72bA31", + "transactionHash": "0xb52a05b4755475340986a90a656c3ac8158ee56b158904f68c819d5d50227388", + "commit": "520c200f5b33d24c837e56e94120bbd8e82f673e", + "timestamp": 1771323985, + "chainId": 11155111, + "contractPath": "./src/SimpleCustody.sol:SimpleCustody", + "constructorArgs": [ + "0xF8bedb0aBa14833e95F29e760487C3d34Bc4Ec64", + "0x9Adb525012073B74DDd7f9C8c4D7a2d26c4ff44e", + "0x136dcF99f48978372250e23e8155C3f7D317ca89" + ], + "comment": "UAT 0.1" +} From 1d60f4dd17630886cd1edae6a69de0679ab1cb72 Mon Sep 17 00:00:00 2001 From: nksazonov Date: Wed, 18 Feb 2026 11:12:15 +0100 Subject: [PATCH 4/6] refactor(QuorumCustody): reorder ops, optimize functions --- contracts/evm/script/AddSigners.s.sol | 2 +- .../evm/script/DeployQuorumCustody.s.sol | 2 +- contracts/evm/script/RemoveSigners.s.sol | 2 +- contracts/evm/src/QuorumCustody.sol | 191 ++++++++++-------- contracts/evm/src/SimpleCustody.sol | 2 +- contracts/evm/test/QuorumCustody.t.sol | 29 ++- 6 files changed, 123 insertions(+), 105 deletions(-) diff --git a/contracts/evm/script/AddSigners.s.sol b/contracts/evm/script/AddSigners.s.sol index 63d6903..fc84e5f 100644 --- a/contracts/evm/script/AddSigners.s.sol +++ b/contracts/evm/script/AddSigners.s.sol @@ -23,7 +23,7 @@ contract AddSigners is Script { uint256 callerKey = vm.envUint("PRIVATE_KEY"); address contractAddr = vm.envAddress("CONTRACT"); address[] memory newSigners = vm.envAddress("NEW_SIGNERS", ","); - uint256 newQuorum = vm.envUint("NEW_QUORUM"); + uint64 newQuorum = uint64(vm.envUint("NEW_QUORUM")); uint256 deadline = vm.envUint("DEADLINE"); bytes[] memory sigs; diff --git a/contracts/evm/script/DeployQuorumCustody.s.sol b/contracts/evm/script/DeployQuorumCustody.s.sol index 2311ea3..9c92cce 100644 --- a/contracts/evm/script/DeployQuorumCustody.s.sol +++ b/contracts/evm/script/DeployQuorumCustody.s.sol @@ -9,7 +9,7 @@ contract DeployQuorumCustody is Script { function run() public { uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); - uint256 initialQuorum = vm.envUint("INITIAL_QUORUM"); + uint64 initialQuorum = uint64(vm.envUint("INITIAL_QUORUM")); address[] memory initialSigners = vm.envAddress("SIGNERS", ","); diff --git a/contracts/evm/script/RemoveSigners.s.sol b/contracts/evm/script/RemoveSigners.s.sol index 10c93cf..d917046 100644 --- a/contracts/evm/script/RemoveSigners.s.sol +++ b/contracts/evm/script/RemoveSigners.s.sol @@ -23,7 +23,7 @@ contract RemoveSigners is Script { uint256 callerKey = vm.envUint("PRIVATE_KEY"); address contractAddr = vm.envAddress("CONTRACT"); address[] memory signersToRemove = vm.envAddress("SIGNERS_TO_REMOVE", ","); - uint256 newQuorum = vm.envUint("NEW_QUORUM"); + uint64 newQuorum = uint64(vm.envUint("NEW_QUORUM")); uint256 deadline = vm.envUint("DEADLINE"); bytes[] memory sigs; diff --git a/contracts/evm/src/QuorumCustody.sol b/contracts/evm/src/QuorumCustody.sol index a152450..78c62c3 100644 --- a/contracts/evm/src/QuorumCustody.sol +++ b/contracts/evm/src/QuorumCustody.sol @@ -30,59 +30,62 @@ contract QuorumCustody is IWithdraw, IDeposit, ReentrancyGuard, EIP712 { error SignerIsCaller(); error DeadlineExpired(); - bytes32 public constant ADD_SIGNERS_TYPEHASH = - keccak256("AddSigners(address[] newSigners,uint256 newQuorum,uint256 nonce,uint256 deadline)"); - bytes32 public constant REMOVE_SIGNERS_TYPEHASH = - keccak256("RemoveSigners(address[] signersToRemove,uint256 newQuorum,uint256 nonce,uint256 deadline)"); - - uint256 public constant OPERATION_EXPIRY = 1 hours; + event SignerAdded(address indexed signer, uint64 newQuorum); + event SignerRemoved(address indexed signer, uint64 newQuorum); + event QuorumChanged(uint64 oldQuorum, uint64 newQuorum); + event WithdrawalApproved(bytes32 indexed withdrawalId, address indexed signer, uint256 currentApprovals); struct WithdrawalRequest { address user; address token; uint256 amount; - bool exists; bool finalized; - uint256 requiredQuorum; - uint256 createdAt; + uint64 createdAt; + uint64 requiredQuorum; } - mapping(bytes32 => WithdrawalRequest) public withdrawals; - mapping(bytes32 => mapping(address => bool)) public withdrawalApprovals; + bytes32 public constant ADD_SIGNERS_TYPEHASH = + keccak256("AddSigners(address[] newSigners,uint256 newQuorum,uint256 nonce,uint256 deadline)"); + bytes32 public constant REMOVE_SIGNERS_TYPEHASH = + keccak256("RemoveSigners(address[] signersToRemove,uint256 newQuorum,uint256 nonce,uint256 deadline)"); + + uint256 public constant OPERATION_EXPIRY = 1 hours; + + mapping(bytes32 withdrawalId => WithdrawalRequest request) public withdrawals; + mapping(bytes32 withdrawalId => mapping(address signer => bool hasApproved)) public withdrawalApprovals; uint256 public signerNonce; address[] public signers; - mapping(address => bool) public isSigner; - uint256 public quorum; + mapping(address signer => bool isSigner) public isSigner; + uint64 public quorum; - event SignerAdded(address indexed signer, uint256 newQuorum); - event SignerRemoved(address indexed signer, uint256 newQuorum); - event QuorumChanged(uint256 oldQuorum, uint256 newQuorum); - event WithdrawalApproved(bytes32 indexed withdrawalId, address indexed signer, uint256 currentApprovals); + constructor(address[] memory initialSigners, uint64 quorum_) EIP712("QuorumCustody", "1") { + require(initialSigners.length != 0, EmptySignersArray()); + require(quorum_ != 0 && quorum_ <= initialSigners.length, InvalidQuorum()); - constructor(address[] memory initialSigners, uint256 _quorum) EIP712("QuorumCustody", "1") { - if (initialSigners.length == 0) revert EmptySignersArray(); - if (_quorum == 0 || _quorum > initialSigners.length) revert InvalidQuorum(); - for (uint256 i = 0; i < initialSigners.length; i++) { - _addSigner(initialSigners[i], _quorum); + uint256 signersLength = initialSigners.length; + for (uint256 i = 0; i < signersLength; i++) { + _addSigner(initialSigners[i], quorum_); } - quorum = _quorum; + + quorum = quorum_; } modifier onlySigner() { - if (!isSigner[msg.sender]) revert NotSigner(); + require(isSigner[msg.sender], NotSigner()); _; } - function addSigners(address[] calldata newSigners, uint256 newQuorum, uint256 deadline, bytes[] calldata signatures) + function addSigners(address[] calldata newSigners, uint64 newQuorum, uint256 deadline, bytes[] calldata signatures) external onlySigner { - if (block.timestamp > deadline) revert DeadlineExpired(); - if (newSigners.length == 0) revert EmptySignersArray(); - if (newQuorum == 0 || newQuorum < quorum || newQuorum > signers.length + newSigners.length) { - revert InvalidQuorum(); - } + require(block.timestamp <= deadline, DeadlineExpired()); + require(newSigners.length != 0, EmptySignersArray()); + require( + newQuorum != 0 && newQuorum >= quorum && newQuorum <= signers.length + newSigners.length, + InvalidQuorum() + ); _verifySignatures( keccak256( @@ -92,26 +95,28 @@ contract QuorumCustody is IWithdraw, IDeposit, ReentrancyGuard, EIP712 { ); signerNonce++; - uint256 oldQuorum = quorum; - quorum = newQuorum; for (uint256 i = 0; i < newSigners.length; i++) { _addSigner(newSigners[i], newQuorum); } - if (newQuorum != oldQuorum) emit QuorumChanged(oldQuorum, newQuorum); + if (quorum != newQuorum) { + uint64 oldQuorum = quorum; + quorum = newQuorum; + emit QuorumChanged(oldQuorum, newQuorum); + } } function removeSigners( address[] calldata signersToRemove, - uint256 newQuorum, + uint64 newQuorum, uint256 deadline, bytes[] calldata signatures ) external onlySigner { - if (block.timestamp > deadline) revert DeadlineExpired(); - if (signersToRemove.length == 0) revert EmptySignersArray(); - if (signersToRemove.length >= signers.length) revert CannotRemoveLastSigner(); + require(block.timestamp <= deadline, DeadlineExpired()); + require(signersToRemove.length != 0, EmptySignersArray()); + require(signersToRemove.length < signers.length, CannotRemoveLastSigner()); uint256 remainingCount = signers.length - signersToRemove.length; - uint256 minQuorum = quorum < remainingCount ? quorum : remainingCount; - if (newQuorum == 0 || newQuorum < minQuorum || newQuorum > remainingCount) revert InvalidQuorum(); + uint64 minQuorum = quorum < remainingCount ? quorum : uint64(remainingCount); + require(newQuorum != 0 && newQuorum >= minQuorum && newQuorum <= remainingCount, InvalidQuorum()); _verifySignatures( keccak256( @@ -123,23 +128,26 @@ contract QuorumCustody is IWithdraw, IDeposit, ReentrancyGuard, EIP712 { ); signerNonce++; - uint256 oldQuorum = quorum; - quorum = newQuorum; + uint256 signersLen = signers.length; for (uint256 i = 0; i < signersToRemove.length; i++) { address s = signersToRemove[i]; - if (!isSigner[s]) revert NotASigner(); + require(isSigner[s], NotASigner()); isSigner[s] = false; - uint256 len = signers.length; - for (uint256 j = 0; j < len; j++) { + for (uint256 j = 0; j < signersLen; j++) { if (signers[j] == s) { - signers[j] = signers[len - 1]; + signers[j] = signers[signersLen - 1]; signers.pop(); + signersLen--; break; } } emit SignerRemoved(s, newQuorum); } - if (newQuorum != oldQuorum) emit QuorumChanged(oldQuorum, newQuorum); + if (quorum != newQuorum) { + uint64 oldQuorum = quorum; + quorum = newQuorum; + emit QuorumChanged(oldQuorum, newQuorum); + } } function getSignerCount() external view returns (uint256) { @@ -147,17 +155,16 @@ contract QuorumCustody is IWithdraw, IDeposit, ReentrancyGuard, EIP712 { } function deposit(address token, uint256 amount) external payable override nonReentrant { - if (amount == 0) revert ZeroAmount(); - uint256 received = amount; + require(amount != 0, ZeroAmount()); + if (token == address(0)) { - if (msg.value != amount) revert MsgValueMismatch(); + require(msg.value == amount, MsgValueMismatch()); } else { - if (msg.value != 0) revert NonZeroMsgValueForERC20(); - uint256 balanceBefore = IERC20(token).balanceOf(address(this)); + require(msg.value == 0, NonZeroMsgValueForERC20()); IERC20(token).safeTransferFrom(msg.sender, address(this), amount); - received = IERC20(token).balanceOf(address(this)) - balanceBefore; } - emit Deposited(msg.sender, token, received); + + emit Deposited(msg.sender, token, amount); } function startWithdraw(address user, address token, uint256 amount, uint256 nonce) @@ -165,46 +172,53 @@ contract QuorumCustody is IWithdraw, IDeposit, ReentrancyGuard, EIP712 { override onlySigner nonReentrant - returns (bytes32 withdrawalId) + returns (bytes32) { - if (user == address(0)) revert InvalidUser(); - if (amount == 0) revert ZeroAmount(); - withdrawalId = keccak256(abi.encode(block.chainid, address(this), user, token, amount, nonce)); - if (withdrawals[withdrawalId].exists) revert WithdrawalAlreadyExists(); + require(user != address(0), InvalidUser()); + require(amount != 0, ZeroAmount()); + + bytes32 withdrawalId = _getWithdrawalId(user, token, amount, nonce); + require(withdrawals[withdrawalId].createdAt == 0, WithdrawalAlreadyExists()); withdrawals[withdrawalId] = WithdrawalRequest({ user: user, token: token, amount: amount, - exists: true, finalized: false, requiredQuorum: quorum, - createdAt: block.timestamp + createdAt: uint64(block.timestamp) }); + emit WithdrawStarted(withdrawalId, user, token, amount, nonce); + return withdrawalId; } function finalizeWithdraw(bytes32 withdrawalId) external override onlySigner nonReentrant { WithdrawalRequest storage request = withdrawals[withdrawalId]; - if (!request.exists) revert WithdrawalNotFound(); - if (request.finalized) revert WithdrawalAlreadyFinalized(); - if (block.timestamp > request.createdAt + OPERATION_EXPIRY) revert WithdrawalExpired(); - if (withdrawalApprovals[withdrawalId][msg.sender]) revert SignerAlreadyApproved(); + address signer = msg.sender; + + require(request.createdAt != 0, WithdrawalNotFound()); + require(!request.finalized, WithdrawalAlreadyFinalized()); + require(block.timestamp <= request.createdAt + OPERATION_EXPIRY, WithdrawalExpired()); + require(!withdrawalApprovals[withdrawalId][signer], SignerAlreadyApproved()); - withdrawalApprovals[withdrawalId][msg.sender] = true; + withdrawalApprovals[withdrawalId][signer] = true; uint256 validApprovals = _countValidApprovals(withdrawalId); - emit WithdrawalApproved(withdrawalId, msg.sender, validApprovals); + + emit WithdrawalApproved(withdrawalId, signer, validApprovals); if (validApprovals >= request.requiredQuorum) { - _executeWithdrawal(withdrawalId, request); + _executeWithdrawal(request); + emit WithdrawFinalized(withdrawalId, true); } } function rejectWithdraw(bytes32 withdrawalId) external override onlySigner nonReentrant { WithdrawalRequest storage request = withdrawals[withdrawalId]; - if (!request.exists) revert WithdrawalNotFound(); - if (request.finalized) revert WithdrawalAlreadyFinalized(); - if (block.timestamp <= request.createdAt + OPERATION_EXPIRY) revert WithdrawalNotExpired(); + + require(request.createdAt != 0, WithdrawalNotFound()); + require(!request.finalized, WithdrawalAlreadyFinalized()); + require(block.timestamp > request.createdAt + OPERATION_EXPIRY, WithdrawalNotExpired()); request.finalized = true; emit WithdrawFinalized(withdrawalId, false); @@ -212,32 +226,33 @@ contract QuorumCustody is IWithdraw, IDeposit, ReentrancyGuard, EIP712 { // --- Internal --- - function _addSigner(address s, uint256 newQuorum) internal { - if (s == address(0)) revert InvalidSigner(); - if (isSigner[s]) revert AlreadySigner(); + function _addSigner(address s, uint64 newQuorum) internal { + require(s != address(0), InvalidSigner()); + require(!isSigner[s], AlreadySigner()); signers.push(s); isSigner[s] = true; emit SignerAdded(s, newQuorum); } - function _executeWithdrawal(bytes32 withdrawalId, WithdrawalRequest storage request) internal { - request.finalized = true; + function _executeWithdrawal(WithdrawalRequest storage request) internal { address user = request.user; address token = request.token; uint256 amount = request.amount; - request.user = address(0); - request.token = address(0); - request.amount = 0; + + request.finalized = true; if (token == address(0)) { - if (address(this).balance < amount) revert InsufficientLiquidity(); + require(address(this).balance >= amount, InsufficientLiquidity()); (bool success,) = user.call{value: amount}(""); - if (!success) revert ETHTransferFailed(); + require(success, ETHTransferFailed()); } else { - if (IERC20(token).balanceOf(address(this)) < amount) revert InsufficientLiquidity(); + require(IERC20(token).balanceOf(address(this)) >= amount, InsufficientLiquidity()); IERC20(token).safeTransfer(user, amount); } - emit WithdrawFinalized(withdrawalId, true); + + request.user = address(0); + request.token = address(0); + request.amount = 0; } function _countValidApprovals(bytes32 withdrawalId) internal view returns (uint256 count) { @@ -253,13 +268,13 @@ contract QuorumCustody is IWithdraw, IDeposit, ReentrancyGuard, EIP712 { address lastSigner = address(0); for (uint256 i = 0; i < signatures.length; i++) { address recovered = ECDSA.recover(digest, signatures[i]); - if (uint160(recovered) <= uint160(lastSigner)) revert SignaturesNotSorted(); - if (!isSigner[recovered]) revert InvalidSignature(); - if (recovered == msg.sender) revert SignerIsCaller(); + require(uint160(recovered) > uint160(lastSigner), SignaturesNotSorted()); + require(isSigner[recovered], InvalidSignature()); + require(recovered != msg.sender, SignerIsCaller()); lastSigner = recovered; validApprovals++; } - if (validApprovals < quorum) revert InsufficientSignatures(); + require(validApprovals >= quorum, InsufficientSignatures()); } function _hashAddressArray(address[] calldata arr) internal pure returns (bytes32) { @@ -269,4 +284,8 @@ contract QuorumCustody is IWithdraw, IDeposit, ReentrancyGuard, EIP712 { } return keccak256(abi.encodePacked(encoded)); } + + function _getWithdrawalId(address user, address token, uint256 amount, uint256 nonce) internal view returns (bytes32) { + return keccak256(abi.encode(block.chainid, address(this), user, token, amount, nonce)); + } } diff --git a/contracts/evm/src/SimpleCustody.sol b/contracts/evm/src/SimpleCustody.sol index 1c70b4f..bc5099d 100644 --- a/contracts/evm/src/SimpleCustody.sol +++ b/contracts/evm/src/SimpleCustody.sol @@ -23,7 +23,7 @@ contract SimpleCustody is IWithdraw, IDeposit, AccessControl, ReentrancyGuard { bool finalized; } - mapping(bytes32 => WithdrawalRequest) public withdrawals; + mapping(bytes32 id => WithdrawalRequest request) public withdrawals; constructor(address admin, address neodax, address nitewatch) { _grantRole(DEFAULT_ADMIN_ROLE, admin); diff --git a/contracts/evm/test/QuorumCustody.t.sol b/contracts/evm/test/QuorumCustody.t.sol index 72b8f1d..f627f72 100644 --- a/contracts/evm/test/QuorumCustody.t.sol +++ b/contracts/evm/test/QuorumCustody.t.sol @@ -726,7 +726,7 @@ contract QuorumCustodyTest is Test { vm.prank(signer1); bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); - (,,,,, uint256 requiredQuorum,) = custody.withdrawals(id); + (,,,,,uint64 requiredQuorum) = custody.withdrawals(id); assertEq(requiredQuorum, 1); } @@ -743,7 +743,7 @@ contract QuorumCustodyTest is Test { vm.prank(signer1); custody.finalizeWithdraw(id); - (,,,, bool finalized,,) = custody.withdrawals(id); + (,,,bool finalized,,) = custody.withdrawals(id); assertTrue(finalized); } @@ -765,12 +765,12 @@ contract QuorumCustodyTest is Test { vm.prank(signer1); custody.finalizeWithdraw(id); - (,,,, bool finalized,,) = custody.withdrawals(id); + (,,,bool finalized,,) = custody.withdrawals(id); assertFalse(finalized); vm.prank(signer2); custody.finalizeWithdraw(id); - (,,,, finalized,,) = custody.withdrawals(id); + (,,,finalized,,) = custody.withdrawals(id); assertTrue(finalized); } @@ -789,17 +789,17 @@ contract QuorumCustodyTest is Test { vm.prank(signer1); custody.finalizeWithdraw(id); - (,,,, bool finalized,,) = custody.withdrawals(id); + (,,,bool finalized,,) = custody.withdrawals(id); assertFalse(finalized); vm.prank(signer2); custody.finalizeWithdraw(id); - (,,,, finalized,,) = custody.withdrawals(id); + (,,,finalized,,) = custody.withdrawals(id); assertFalse(finalized); vm.prank(signer3); custody.finalizeWithdraw(id); - (,,,, finalized,,) = custody.withdrawals(id); + (,,,finalized,,) = custody.withdrawals(id); assertTrue(finalized); } @@ -834,7 +834,7 @@ contract QuorumCustodyTest is Test { vm.prank(signer1); custody.finalizeWithdraw(id); - (,,,, bool finalized,,) = custody.withdrawals(id); + (,,,bool finalized,,) = custody.withdrawals(id); assertTrue(finalized); } @@ -911,7 +911,7 @@ contract QuorumCustodyTest is Test { vm.prank(signer1); custody.finalizeWithdraw(id); - (,,,, bool finalized,,) = custody.withdrawals(id); + (,,,bool finalized,,) = custody.withdrawals(id); assertTrue(finalized); } @@ -954,9 +954,8 @@ contract QuorumCustodyTest is Test { vm.prank(signer1); custody.finalizeWithdraw(id); - (address storedUser, address storedToken, uint256 storedAmount, bool exists, bool finalized,,) = + (address storedUser, address storedToken, uint256 storedAmount, bool finalized,,) = custody.withdrawals(id); - assertTrue(exists); assertTrue(finalized); assertEq(storedUser, address(0)); assertEq(storedToken, address(0)); @@ -1017,7 +1016,7 @@ contract QuorumCustodyTest is Test { vm.prank(signer1); custody.rejectWithdraw(id); - (,,,, bool finalized,,) = custody.withdrawals(id); + (,,,bool finalized,,) = custody.withdrawals(id); assertTrue(finalized); } @@ -1126,7 +1125,7 @@ contract QuorumCustodyTest is Test { vm.prank(signer1); custody.rejectWithdraw(id); - (,,,, bool finalized,,) = custody.withdrawals(id); + (,,,bool finalized,,) = custody.withdrawals(id); assertTrue(finalized); } @@ -1208,14 +1207,14 @@ contract QuorumCustodyTest is Test { vm.prank(signer1); custody.finalizeWithdraw(id); - (,,,, bool finalized,,) = custody.withdrawals(id); + (,,,bool finalized,,) = custody.withdrawals(id); assertFalse(finalized); // signer3 approves — now 2 valid approvals (signer1 + signer3), meets requiredQuorum=2 vm.prank(signer3); custody.finalizeWithdraw(id); - (,,,, finalized,,) = custody.withdrawals(id); + (,,,finalized,,) = custody.withdrawals(id); assertTrue(finalized); } From 3467761d6741d6466734fdfd63999a42d686fdb2 Mon Sep 17 00:00:00 2001 From: nksazonov Date: Wed, 18 Feb 2026 12:57:45 +0100 Subject: [PATCH 5/6] feat: add ThresholdCustody --- contracts/evm/src/ThresholdCustody.sol | 240 +++++ contracts/evm/test/ThresholdCustody.sol | 1271 +++++++++++++++++++++++ 2 files changed, 1511 insertions(+) create mode 100644 contracts/evm/src/ThresholdCustody.sol create mode 100644 contracts/evm/test/ThresholdCustody.sol diff --git a/contracts/evm/src/ThresholdCustody.sol b/contracts/evm/src/ThresholdCustody.sol new file mode 100644 index 0000000..19c9bd3 --- /dev/null +++ b/contracts/evm/src/ThresholdCustody.sol @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {MultiSignerERC7913} from "@openzeppelin/contracts/utils/cryptography/signers/MultiSignerERC7913.sol"; + +import {IWithdraw} from "./interfaces/IWithdraw.sol"; +import {IDeposit} from "./interfaces/IDeposit.sol"; + +contract ThresholdCustody is IWithdraw, IDeposit, ReentrancyGuard, EIP712, MultiSignerERC7913 { + using SafeERC20 for IERC20; + + error NotSigner(); + error InvalidQuorum(); + error InvalidUser(); + error WithdrawalExpired(); + error SignerAlreadyApproved(); + error WithdrawalNotExpired(); + error InvalidSignature(); + error EmptySignersArray(); + error DeadlineExpired(); + + event WithdrawalApproved(bytes32 indexed withdrawalId, address indexed signer, uint256 currentApprovals); + + struct WithdrawalRequest { + address user; + address token; + uint256 amount; + bool finalized; + uint64 createdAt; + uint64 requiredQuorum; + } + + bytes32 public constant ADD_SIGNERS_TYPEHASH = + keccak256("AddSigners(address[] newSigners,uint256 newQuorum,uint256 nonce,uint256 deadline)"); + bytes32 public constant REMOVE_SIGNERS_TYPEHASH = + keccak256("RemoveSigners(address[] signersToRemove,uint256 newQuorum,uint256 nonce,uint256 deadline)"); + + uint256 public constant OPERATION_EXPIRY = 1 hours; + + mapping(bytes32 withdrawalId => WithdrawalRequest request) public withdrawals; + mapping(bytes32 withdrawalId => mapping(address signer => bool hasApproved)) public withdrawalApprovals; + uint256 public signerNonce; + + constructor(address[] memory initialSigners, uint64 quorum_) + EIP712("ThresholdCustody", "1") + MultiSignerERC7913(_toAddressBytesArray(initialSigners), quorum_) + { + require(initialSigners.length != 0, EmptySignersArray()); + require(quorum_ != 0 && quorum_ <= initialSigners.length, InvalidQuorum()); + + } + + modifier onlySigner() { + require(isSigner(msg.sender), NotSigner()); + _; + } + + function isSigner(address signer) public view returns (bool) { + return isSigner(_toBytes(signer)); + } + + function addSigners( + address[] calldata newSigners, + uint64 newThreshold, + uint256 deadline, + bytes calldata signatures + ) external onlySigner { + require(block.timestamp <= deadline, DeadlineExpired()); + require(newSigners.length != 0, EmptySignersArray()); + + bytes32 structHash = keccak256( + abi.encode(ADD_SIGNERS_TYPEHASH, _hashAddressArray(newSigners), newThreshold, signerNonce, deadline) + ); + bytes32 digest = _hashTypedDataV4(structHash); + + require(_rawSignatureValidation(digest, signatures), InvalidSignature()); + + signerNonce++; + + _addSigners(_toAddressBytesArray(newSigners)); + _setThreshold(newThreshold); + } + + function removeSigners( + address[] calldata signersToRemove, + uint64 newThreshold, + uint256 deadline, + bytes calldata signatures + ) external onlySigner { + require(block.timestamp <= deadline, DeadlineExpired()); + require(signersToRemove.length != 0, EmptySignersArray()); + + bytes32 structHash = keccak256( + abi.encode(REMOVE_SIGNERS_TYPEHASH, _hashAddressArray(signersToRemove), newThreshold, signerNonce, deadline) + ); + bytes32 digest = _hashTypedDataV4(structHash); + + require(_rawSignatureValidation(digest, signatures), InvalidSignature()); + + signerNonce++; + + _removeSigners(_toAddressBytesArray(signersToRemove)); + _setThreshold(newThreshold); + } + + function deposit(address token, uint256 amount) external payable override nonReentrant { + require(amount != 0, ZeroAmount()); + + if (token == address(0)) { + require(msg.value == amount, MsgValueMismatch()); + } else { + require(msg.value == 0, NonZeroMsgValueForERC20()); + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + } + + emit Deposited(msg.sender, token, amount); + } + + function startWithdraw(address user, address token, uint256 amount, uint256 nonce) + external + override + onlySigner + nonReentrant + returns (bytes32) + { + require(user != address(0), InvalidUser()); + require(amount != 0, ZeroAmount()); + + bytes32 withdrawalId = _getWithdrawalId(user, token, amount, nonce); + require(withdrawals[withdrawalId].createdAt == 0, WithdrawalAlreadyExists()); + + withdrawals[withdrawalId] = WithdrawalRequest({ + user: user, + token: token, + amount: amount, + finalized: false, + requiredQuorum: threshold(), + createdAt: uint64(block.timestamp) + }); + + emit WithdrawStarted(withdrawalId, user, token, amount, nonce); + return withdrawalId; + } + + function finalizeWithdraw(bytes32 withdrawalId) external override onlySigner nonReentrant { + WithdrawalRequest storage request = withdrawals[withdrawalId]; + address signer = msg.sender; + + require(request.createdAt != 0, WithdrawalNotFound()); + require(!request.finalized, WithdrawalAlreadyFinalized()); + require(block.timestamp <= request.createdAt + OPERATION_EXPIRY, WithdrawalExpired()); + require(!withdrawalApprovals[withdrawalId][signer], SignerAlreadyApproved()); + + withdrawalApprovals[withdrawalId][signer] = true; + uint256 validApprovals = _countValidApprovals(withdrawalId); + + emit WithdrawalApproved(withdrawalId, signer, validApprovals); + + if (validApprovals >= request.requiredQuorum) { + _executeWithdrawal(request); + emit WithdrawFinalized(withdrawalId, true); + } + } + + function rejectWithdraw(bytes32 withdrawalId) external override onlySigner nonReentrant { + WithdrawalRequest storage request = withdrawals[withdrawalId]; + + require(request.createdAt != 0, WithdrawalNotFound()); + require(!request.finalized, WithdrawalAlreadyFinalized()); + require(block.timestamp > request.createdAt + OPERATION_EXPIRY, WithdrawalNotExpired()); + + request.finalized = true; + emit WithdrawFinalized(withdrawalId, false); + } + + // --- Internal --- + + function _executeWithdrawal(WithdrawalRequest storage request) internal { + address user = request.user; + address token = request.token; + uint256 amount = request.amount; + + request.finalized = true; + + if (token == address(0)) { + require(address(this).balance >= amount, InsufficientLiquidity()); + (bool success,) = user.call{value: amount}(""); + require(success, ETHTransferFailed()); + } else { + require(IERC20(token).balanceOf(address(this)) >= amount, InsufficientLiquidity()); + IERC20(token).safeTransfer(user, amount); + } + + request.user = address(0); + request.token = address(0); + request.amount = 0; + } + + function _countValidApprovals(bytes32 withdrawalId) internal view returns (uint256 count) { + bytes[] memory allSigners = getSigners(0, type(uint64).max); + + for (uint256 i = 0; i < allSigners.length; i++) { + address s = _bytesToAddress(allSigners[i]); + if (withdrawalApprovals[withdrawalId][s]) count++; + } + } + + function _hashAddressArray(address[] calldata arr) internal pure returns (bytes32) { + bytes32[] memory encoded = new bytes32[](arr.length); + for (uint256 i = 0; i < arr.length; i++) { + encoded[i] = bytes32(uint256(uint160(arr[i]))); + } + return keccak256(abi.encodePacked(encoded)); + } + + function _getWithdrawalId(address user, address token, uint256 amount, uint256 nonce) internal view returns (bytes32) { + return keccak256(abi.encode(block.chainid, address(this), user, token, amount, nonce)); + } + + // Helpers for conversion + function _toBytes(address a) internal pure returns (bytes memory) { + return abi.encodePacked(a); + } + + function _toAddressBytesArray(address[] memory addrs) internal pure returns (bytes[] memory) { + bytes[] memory b = new bytes[](addrs.length); + for(uint i=0; i uint160(addrs[j])) { + (addrs[i], addrs[j]) = (addrs[j], addrs[i]); + (sigs[i], sigs[j]) = (sigs[j], sigs[i]); + } + } + } + for (uint i = 0; i < 3; i++) { + signers[i] = abi.encodePacked(addrs[i]); + signatures[i] = sigs[i]; + } + bytes memory encodedSigs = abi.encode(signers, signatures); + + vm.prank(signer1); + custody.removeSigners(toRemove, 3, MAX_DEADLINE, encodedSigs); + + assertFalse(custody.isSigner(signer4)); + assertFalse(custody.isSigner(signer5)); + assertEq(custody.getSignerCount(), 3); + assertEq(custody.threshold(), 3); + } + + function test_Fail_RemoveSigners_UnreachableThreshold() public { + address[] memory toRemove = new address[](1); + toRemove[0] = signer1; + + vm.prank(signer1); + // MultiSignerERC7913 will revert with UnreachableThreshold error + // when removing would make the threshold impossible to reach + vm.expectRevert(); + custody.removeSigners(toRemove, 1, MAX_DEADLINE, _emptySigs()); + } + + function test_Fail_RemoveSigners_InvalidQuorum() public { + address[] memory s1 = new address[](1); + s1[0] = signer2; + bytes memory addSigs = _selfSign(signer1Pk, signer1, s1, 1, custody.signerNonce(), MAX_DEADLINE); + vm.prank(signer1); + custody.addSigners(s1, 1, MAX_DEADLINE, addSigs); + + address[] memory toRemove = new address[](1); + toRemove[0] = signer2; + bytes memory removeSigs = _selfSignRemove(signer1Pk, signer1, toRemove, 2, custody.signerNonce(), MAX_DEADLINE); + + vm.prank(signer1); + // MultiSignerERC7913 will revert with UnreachableThreshold error + vm.expectRevert(); + custody.removeSigners(toRemove, 2, MAX_DEADLINE, removeSigs); // removing leaves 1, max threshold is 1 + } + + function test_Fail_RemoveSigners_EmptyArray() public { + address[] memory toRemove = new address[](0); + + vm.prank(signer1); + vm.expectRevert(ThresholdCustody.EmptySignersArray.selector); + custody.removeSigners(toRemove, 1, MAX_DEADLINE, _emptySigs()); + } + + function test_RemovedSignerCannotAct() public { + address[] memory s1 = new address[](1); + s1[0] = signer2; + bytes memory addSigs = _selfSign(signer1Pk, signer1, s1, 1, custody.signerNonce(), MAX_DEADLINE); + vm.prank(signer1); + custody.addSigners(s1, 1, MAX_DEADLINE, addSigs); + + address[] memory toRemove = new address[](1); + toRemove[0] = signer2; + bytes memory removeSigs = _selfSignRemove(signer1Pk, signer1, toRemove, 1, custody.signerNonce(), MAX_DEADLINE); + vm.prank(signer1); + custody.removeSigners(toRemove, 1, MAX_DEADLINE, removeSigs); + + vm.prank(signer2); + vm.expectRevert(ThresholdCustody.NotSigner.selector); + custody.startWithdraw(user, address(0), 1 ether, 1); + } + + // ========================================================================= + // Deposit + // ========================================================================= + + function test_DepositETH() public { + vm.deal(user, 1 ether); + vm.prank(user); + custody.deposit{value: 1 ether}(address(0), 1 ether); + assertEq(address(custody).balance, 1 ether); + } + + function test_DepositERC20() public { + token.mint(user, 100e18); + vm.startPrank(user); + token.approve(address(custody), 100e18); + custody.deposit(address(token), 100e18); + vm.stopPrank(); + assertEq(token.balanceOf(address(custody)), 100e18); + } + + function test_DepositETH_EmitsEvent() public { + vm.deal(user, 1 ether); + vm.prank(user); + vm.expectEmit(true, true, false, true); + emit IDeposit.Deposited(user, address(0), 1 ether); + custody.deposit{value: 1 ether}(address(0), 1 ether); + } + + function test_DepositERC20_EmitsEvent() public { + token.mint(user, 50e18); + vm.startPrank(user); + token.approve(address(custody), 50e18); + vm.expectEmit(true, true, false, true); + emit IDeposit.Deposited(user, address(token), 50e18); + custody.deposit(address(token), 50e18); + vm.stopPrank(); + } + + function test_Fail_DepositZeroAmount() public { + vm.prank(user); + vm.expectRevert(IWithdraw.ZeroAmount.selector); + custody.deposit(address(0), 0); + } + + function test_Fail_DepositETH_MsgValueMismatch() public { + vm.deal(user, 2 ether); + vm.prank(user); + vm.expectRevert(IDeposit.MsgValueMismatch.selector); + custody.deposit{value: 0.5 ether}(address(0), 1 ether); + } + + function test_Fail_DepositERC20_NonZeroMsgValue() public { + token.mint(user, 100e18); + vm.deal(user, 1 ether); + vm.startPrank(user); + token.approve(address(custody), 100e18); + vm.expectRevert(IDeposit.NonZeroMsgValueForERC20.selector); + custody.deposit{value: 1 ether}(address(token), 100e18); + vm.stopPrank(); + } + + // ========================================================================= + // startWithdraw + // ========================================================================= + + function test_Fail_StartWithdraw_NotSigner() public { + vm.prank(user); + vm.expectRevert(ThresholdCustody.NotSigner.selector); + custody.startWithdraw(user, address(0), 1 ether, 1); + } + + function test_Fail_StartWithdraw_ZeroUser() public { + vm.prank(signer1); + vm.expectRevert(ThresholdCustody.InvalidUser.selector); + custody.startWithdraw(address(0), address(0), 1 ether, 1); + } + + function test_Fail_StartWithdraw_ZeroAmount() public { + vm.prank(signer1); + vm.expectRevert(IWithdraw.ZeroAmount.selector); + custody.startWithdraw(user, address(0), 0, 1); + } + + function test_Fail_StartWithdraw_DuplicateNonce() public { + vm.startPrank(signer1); + custody.startWithdraw(user, address(0), 1 ether, 1); + vm.expectRevert(IWithdraw.WithdrawalAlreadyExists.selector); + custody.startWithdraw(user, address(0), 1 ether, 1); + vm.stopPrank(); + } + + function test_StartWithdraw_SameParamsDifferentNonce() public { + vm.startPrank(signer1); + bytes32 id1 = custody.startWithdraw(user, address(0), 1 ether, 1); + bytes32 id2 = custody.startWithdraw(user, address(0), 1 ether, 2); + vm.stopPrank(); + assertTrue(id1 != id2); + } + + function test_StartWithdraw_EmitsEvent() public { + vm.prank(signer1); + vm.expectEmit(true, true, true, true); + bytes32 expectedId = + keccak256(abi.encode(block.chainid, address(custody), user, address(0), 1 ether, uint256(1))); + emit IWithdraw.WithdrawStarted(expectedId, user, address(0), 1 ether, 1); + custody.startWithdraw(user, address(0), 1 ether, 1); + } + + function test_StartWithdraw_SnapshotsQuorum() public { + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + (,,,,,uint64 requiredQuorum) = custody.withdrawals(id); + assertEq(requiredQuorum, 1); + } + + // ========================================================================= + // finalizeWithdraw — 1/1 + // ========================================================================= + + function test_FinalizeWithdraw_1_1() public { + vm.deal(address(custody), 1 ether); + + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.prank(signer1); + custody.finalizeWithdraw(id); + + (,,,bool finalized,,) = custody.withdrawals(id); + assertTrue(finalized); + } + + // ========================================================================= + // finalizeWithdraw — 2/2 progressive + // ========================================================================= + + function test_FinalizeWithdraw_2_2_Progressive() public { + // Setup: 2 signers, threshold=2 + address[] memory s = new address[](1); + s[0] = signer2; + bytes memory sigs = _selfSign(signer1Pk, signer1, s, 2, custody.signerNonce(), MAX_DEADLINE); + vm.prank(signer1); + custody.addSigners(s, 2, MAX_DEADLINE, sigs); + + vm.deal(address(custody), 1 ether); + + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.prank(signer1); + custody.finalizeWithdraw(id); + (,,,bool finalized,,) = custody.withdrawals(id); + assertFalse(finalized); + + vm.prank(signer2); + custody.finalizeWithdraw(id); + (,,,finalized,,) = custody.withdrawals(id); + assertTrue(finalized); + } + + // ========================================================================= + // finalizeWithdraw — 3/5 + // ========================================================================= + + function test_FinalizeWithdraw_3_5() public { + _setup3of5(); + assertEq(custody.threshold(), 3); + assertEq(custody.getSignerCount(), 5); + + vm.deal(address(custody), 1 ether); + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.prank(signer1); + custody.finalizeWithdraw(id); + (,,,bool finalized,,) = custody.withdrawals(id); + assertFalse(finalized); + + vm.prank(signer2); + custody.finalizeWithdraw(id); + (,,,finalized,,) = custody.withdrawals(id); + assertFalse(finalized); + + vm.prank(signer3); + custody.finalizeWithdraw(id); + (,,,finalized,,) = custody.withdrawals(id); + assertTrue(finalized); + } + + // ========================================================================= + // finalizeWithdraw — snapshot quorum + // ========================================================================= + + function test_FinalizeWithdraw_UsesSnapshotQuorum() public { + // Setup: 2 signers, threshold=1 + address[] memory s = new address[](1); + s[0] = signer2; + bytes memory addSigs1 = _selfSign(signer1Pk, signer1, s, 1, custody.signerNonce(), MAX_DEADLINE); + vm.prank(signer1); + custody.addSigners(s, 1, MAX_DEADLINE, addSigs1); + + vm.deal(address(custody), 1 ether); + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + // Raise threshold to 2 AFTER withdrawal was created + address[] memory s2 = new address[](1); + s2[0] = signer3; + uint256 nonce = custody.signerNonce(); + bytes memory sig1 = _signAddSigners(signer1Pk, s2, 2, nonce, MAX_DEADLINE); + bytes memory encodedSigs = _encodeMultiSig(signer1, sig1); + + vm.prank(signer1); + custody.addSigners(s2, 2, MAX_DEADLINE, encodedSigs); + assertEq(custody.threshold(), 2); + + // 1 approval should suffice (snapshot quorum was 1) + vm.prank(signer1); + custody.finalizeWithdraw(id); + + (,,,bool finalized,,) = custody.withdrawals(id); + assertTrue(finalized); + } + + // ========================================================================= + // finalizeWithdraw — edge cases + // ========================================================================= + + function test_Fail_FinalizeWithdraw_NotSigner() public { + vm.deal(address(custody), 1 ether); + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.prank(user); + vm.expectRevert(ThresholdCustody.NotSigner.selector); + custody.finalizeWithdraw(id); + } + + function test_Fail_FinalizeWithdraw_NonExistent() public { + vm.prank(signer1); + vm.expectRevert(IWithdraw.WithdrawalNotFound.selector); + custody.finalizeWithdraw(bytes32(uint256(999))); + } + + function test_Fail_FinalizeWithdraw_AlreadyFinalized() public { + vm.deal(address(custody), 1 ether); + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.prank(signer1); + custody.finalizeWithdraw(id); + + vm.prank(signer1); + vm.expectRevert(IWithdraw.WithdrawalAlreadyFinalized.selector); + custody.finalizeWithdraw(id); + } + + function test_Fail_DuplicateApproval() public { + address[] memory s = new address[](1); + s[0] = signer2; + bytes memory sigs = _selfSign(signer1Pk, signer1, s, 2, custody.signerNonce(), MAX_DEADLINE); + vm.prank(signer1); + custody.addSigners(s, 2, MAX_DEADLINE, sigs); + + vm.deal(address(custody), 1 ether); + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.prank(signer1); + custody.finalizeWithdraw(id); + + vm.prank(signer1); + vm.expectRevert(ThresholdCustody.SignerAlreadyApproved.selector); + custody.finalizeWithdraw(id); + } + + function test_Fail_Finalize_Expired() public { + vm.deal(address(custody), 1 ether); + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.warp(block.timestamp + 1 hours + 1); + + vm.prank(signer1); + vm.expectRevert(ThresholdCustody.WithdrawalExpired.selector); + custody.finalizeWithdraw(id); + } + + function test_FinalizeWithdraw_ExactExpiryBoundary() public { + vm.deal(address(custody), 1 ether); + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.warp(block.timestamp + 1 hours); + + vm.prank(signer1); + custody.finalizeWithdraw(id); + + (,,,bool finalized,,) = custody.withdrawals(id); + assertTrue(finalized); + } + + function test_FinalizeWithdraw_ERC20() public { + token.mint(address(custody), 50e18); + + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(token), 50e18, 1); + + vm.prank(signer1); + custody.finalizeWithdraw(id); + + assertEq(token.balanceOf(user), 50e18); + assertEq(token.balanceOf(address(custody)), 0); + } + + function test_Fail_FinalizeWithdraw_InsufficientETH() public { + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.prank(signer1); + vm.expectRevert(IWithdraw.InsufficientLiquidity.selector); + custody.finalizeWithdraw(id); + } + + function test_Fail_FinalizeWithdraw_InsufficientERC20() public { + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(token), 50e18, 1); + + vm.prank(signer1); + vm.expectRevert(IWithdraw.InsufficientLiquidity.selector); + custody.finalizeWithdraw(id); + } + + function test_FinalizeWithdraw_ClearsStorage() public { + vm.deal(address(custody), 1 ether); + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.prank(signer1); + custody.finalizeWithdraw(id); + + (address storedUser, address storedToken, uint256 storedAmount, bool finalized,,) = + custody.withdrawals(id); + assertTrue(finalized); + assertEq(storedUser, address(0)); + assertEq(storedToken, address(0)); + assertEq(storedAmount, 0); + } + + function test_FinalizeWithdraw_EmitsApprovalEvent() public { + address[] memory s = new address[](1); + s[0] = signer2; + bytes memory sigs = _selfSign(signer1Pk, signer1, s, 2, custody.signerNonce(), MAX_DEADLINE); + vm.prank(signer1); + custody.addSigners(s, 2, MAX_DEADLINE, sigs); + + vm.deal(address(custody), 1 ether); + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.prank(signer1); + vm.expectEmit(true, true, false, true); + emit ThresholdCustody.WithdrawalApproved(id, signer1, 1); + custody.finalizeWithdraw(id); + } + + function test_FinalizeWithdraw_EmitsFinalizedEvent() public { + vm.deal(address(custody), 1 ether); + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.prank(signer1); + vm.expectEmit(true, false, false, true); + emit IWithdraw.WithdrawFinalized(id, true); + custody.finalizeWithdraw(id); + } + + function test_FinalizeWithdraw_ETH_UserReceivesBalance() public { + vm.deal(address(custody), 5 ether); + uint256 balanceBefore = user.balance; + + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 2 ether, 1); + + vm.prank(signer1); + custody.finalizeWithdraw(id); + + assertEq(user.balance, balanceBefore + 2 ether); + assertEq(address(custody).balance, 3 ether); + } + + // ========================================================================= + // rejectWithdraw (expired-only cleanup) + // ========================================================================= + + function test_RejectWithdraw_Expired() public { + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.warp(block.timestamp + 1 hours + 1); + + vm.prank(signer1); + custody.rejectWithdraw(id); + + (,,,bool finalized,,) = custody.withdrawals(id); + assertTrue(finalized); + } + + function test_RejectWithdraw_EmitsEvent() public { + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.warp(block.timestamp + 1 hours + 1); + + vm.prank(signer1); + vm.expectEmit(true, false, false, true); + emit IWithdraw.WithdrawFinalized(id, false); + custody.rejectWithdraw(id); + } + + function test_Fail_RejectWithdraw_NotExpired() public { + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.prank(signer1); + vm.expectRevert(ThresholdCustody.WithdrawalNotExpired.selector); + custody.rejectWithdraw(id); + } + + function test_Fail_RejectWithdraw_NonExistent() public { + vm.prank(signer1); + vm.expectRevert(IWithdraw.WithdrawalNotFound.selector); + custody.rejectWithdraw(bytes32(uint256(999))); + } + + function test_Fail_RejectWithdraw_AlreadyFinalized() public { + vm.deal(address(custody), 1 ether); + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.prank(signer1); + custody.finalizeWithdraw(id); + + vm.warp(block.timestamp + 1 hours + 1); + + vm.prank(signer1); + vm.expectRevert(IWithdraw.WithdrawalAlreadyFinalized.selector); + custody.rejectWithdraw(id); + } + + function test_Fail_RejectWithdraw_NotSigner() public { + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.warp(block.timestamp + 1 hours + 1); + + vm.prank(user); + vm.expectRevert(ThresholdCustody.NotSigner.selector); + custody.rejectWithdraw(id); + } + + // ========================================================================= + // Lifecycle: reject expired, then re-create + // ========================================================================= + + function test_RejectExpiredThenRecreate() public { + vm.deal(address(custody), 1 ether); + + vm.prank(signer1); + bytes32 id1 = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.warp(block.timestamp + 1 hours + 1); + + vm.prank(signer1); + custody.rejectWithdraw(id1); + + vm.prank(signer1); + bytes32 id2 = custody.startWithdraw(user, address(0), 1 ether, 2); + assertTrue(id1 != id2); + + vm.prank(signer1); + custody.finalizeWithdraw(id2); + + assertEq(user.balance, 1 ether); + } + + // ========================================================================= + // Partial approval then expiry + // ========================================================================= + + function test_PartialApprovalThenExpiry() public { + address[] memory s = new address[](1); + s[0] = signer2; + bytes memory sigs = _selfSign(signer1Pk, signer1, s, 2, custody.signerNonce(), MAX_DEADLINE); + vm.prank(signer1); + custody.addSigners(s, 2, MAX_DEADLINE, sigs); + + vm.deal(address(custody), 1 ether); + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + + vm.prank(signer1); + custody.finalizeWithdraw(id); + + vm.warp(block.timestamp + 1 hours + 1); + + vm.prank(signer2); + vm.expectRevert(ThresholdCustody.WithdrawalExpired.selector); + custody.finalizeWithdraw(id); + + // Clean up expired + vm.prank(signer1); + custody.rejectWithdraw(id); + + (,,,bool finalized,,) = custody.withdrawals(id); + assertTrue(finalized); + } + + // ========================================================================= + // Multiple concurrent withdrawals + // ========================================================================= + + function test_MultipleConcurrentWithdrawals() public { + vm.deal(address(custody), 3 ether); + + vm.startPrank(signer1); + bytes32 id1 = custody.startWithdraw(user, address(0), 1 ether, 1); + bytes32 id3 = custody.startWithdraw(user, address(0), 1 ether, 3); + vm.stopPrank(); + + vm.prank(signer1); + custody.finalizeWithdraw(id1); + + // id2 left to expire + vm.prank(signer1); + custody.finalizeWithdraw(id3); + + assertEq(user.balance, 2 ether); + assertEq(address(custody).balance, 1 ether); + } + + // ========================================================================= + // getSignerCount + // ========================================================================= + + function test_GetSignerCount() public { + assertEq(custody.getSignerCount(), 1); + + address[] memory s = new address[](2); + s[0] = signer2; + s[1] = signer3; + bytes memory sigs = _selfSign(signer1Pk, signer1, s, 1, custody.signerNonce(), MAX_DEADLINE); + vm.prank(signer1); + custody.addSigners(s, 1, MAX_DEADLINE, sigs); + assertEq(custody.getSignerCount(), 3); + } + + // ========================================================================= + // Removed signer approvals no longer count (withdrawal) + // ========================================================================= + + function test_FinalizeWithdraw_RemovedSignerApprovalIgnored() public { + // Setup: 3 signers, threshold=2 + address[] memory s = new address[](2); + s[0] = signer2; + s[1] = signer3; + bytes memory addSigs = _selfSign(signer1Pk, signer1, s, 2, custody.signerNonce(), MAX_DEADLINE); + vm.prank(signer1); + custody.addSigners(s, 2, MAX_DEADLINE, addSigs); + + vm.deal(address(custody), 1 ether); + + vm.prank(signer1); + bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); + // requiredQuorum snapshotted at 2 + + // signer2 approves + vm.prank(signer2); + custody.finalizeWithdraw(id); + + // Remove signer2 (need 2 sigs since threshold=2) + address[] memory toRemove = new address[](1); + toRemove[0] = signer2; + uint256 nonce = custody.signerNonce(); + bytes memory sig1 = _signRemoveSigners(signer1Pk, toRemove, 2, nonce, MAX_DEADLINE); + bytes memory sig3 = _signRemoveSigners(signer3Pk, toRemove, 2, nonce, MAX_DEADLINE); + bytes memory encodedSigs = _encodeMultiSig2(signer1, sig1, signer3, sig3); + vm.prank(signer1); + custody.removeSigners(toRemove, 2, MAX_DEADLINE, encodedSigs); + + assertFalse(custody.isSigner(signer2)); + + // signer1 approves — only 1 valid approval (signer2's no longer counts) + // snapshotted requiredQuorum is still 2, so not finalized yet + vm.prank(signer1); + custody.finalizeWithdraw(id); + + (,,,bool finalized,,) = custody.withdrawals(id); + assertFalse(finalized); + + // signer3 approves — now 2 valid approvals (signer1 + signer3), meets requiredQuorum=2 + vm.prank(signer3); + custody.finalizeWithdraw(id); + + (,,,finalized,,) = custody.withdrawals(id); + assertTrue(finalized); + } + + // ========================================================================= + // Deadline expiry tests + // ========================================================================= + + function test_Fail_AddSigners_DeadlineExpired() public { + address[] memory newSigners = new address[](1); + newSigners[0] = signer2; + + uint256 deadline = block.timestamp + 1 hours; + bytes memory sigs = _selfSign(signer1Pk, signer1, newSigners, 1, custody.signerNonce(), deadline); + vm.warp(deadline + 1); + + vm.prank(signer1); + vm.expectRevert(ThresholdCustody.DeadlineExpired.selector); + custody.addSigners(newSigners, 1, deadline, sigs); + } + + function test_Fail_RemoveSigners_DeadlineExpired() public { + // Add signer2 first + address[] memory s = new address[](1); + s[0] = signer2; + bytes memory addSigs = _selfSign(signer1Pk, signer1, s, 1, custody.signerNonce(), MAX_DEADLINE); + vm.prank(signer1); + custody.addSigners(s, 1, MAX_DEADLINE, addSigs); + + address[] memory toRemove = new address[](1); + toRemove[0] = signer2; + + uint256 deadline = block.timestamp + 1 hours; + bytes memory removeSigs = _selfSignRemove(signer1Pk, signer1, toRemove, 1, custody.signerNonce(), deadline); + vm.warp(deadline + 1); + + vm.prank(signer1); + vm.expectRevert(ThresholdCustody.DeadlineExpired.selector); + custody.removeSigners(toRemove, 1, deadline, removeSigs); + } + + function test_AddSigners_ExactDeadlineBoundary() public { + address[] memory newSigners = new address[](1); + newSigners[0] = signer2; + + uint256 deadline = block.timestamp + 1 hours; + bytes memory sigs = _selfSign(signer1Pk, signer1, newSigners, 1, custody.signerNonce(), deadline); + vm.warp(deadline); + + vm.prank(signer1); + custody.addSigners(newSigners, 1, deadline, sigs); + assertTrue(custody.isSigner(signer2)); + } + + // ========================================================================= + // Exploit: Quorum downgrade via addSigners + // ========================================================================= + + // NOTE: ThresholdCustody does not have the same threshold-reduction protection as QuorumCustody + // The MultiSignerERC7913 implementation allows threshold changes as long as they're reachable + // These exploit tests are removed as they test behavior specific to the old QuorumCustody contract + + function test_Fail_AddSigners_DeadlineExpired_WithSignatures() public { + // Setup: 2 signers, threshold=2 + address[] memory s1 = new address[](1); + s1[0] = signer2; + bytes memory sigs1 = _selfSign(signer1Pk, signer1, s1, 2, custody.signerNonce(), MAX_DEADLINE); + vm.prank(signer1); + custody.addSigners(s1, 2, MAX_DEADLINE, sigs1); + + // Sign with a deadline, then let it expire + address[] memory s2 = new address[](1); + s2[0] = signer3; + uint256 nonce = custody.signerNonce(); + uint256 deadline = block.timestamp + 1 hours; + + bytes memory sig1 = _signAddSigners(signer1Pk, s2, 2, nonce, deadline); + bytes memory sig2 = _signAddSigners(signer2Pk, s2, 2, nonce, deadline); + bytes memory encodedSigs = _encodeMultiSig2(signer1, sig1, signer2, sig2); + + vm.warp(deadline + 1); + + vm.prank(signer1); + vm.expectRevert(ThresholdCustody.DeadlineExpired.selector); + custody.addSigners(s2, 2, deadline, encodedSigs); + } +} From 0941c5526c60c6137ef86395237e03c2879fef72 Mon Sep 17 00:00:00 2001 From: nksazonov Date: Wed, 18 Feb 2026 13:00:28 +0100 Subject: [PATCH 6/6] style: run forge fmt --- contracts/evm/src/QuorumCustody.sol | 9 +++-- contracts/evm/src/ThresholdCustody.sol | 19 ++++++----- contracts/evm/test/QuorumCustody.t.sol | 29 ++++++++-------- contracts/evm/test/ThresholdCustody.sol | 44 ++++++++++++------------- 4 files changed, 52 insertions(+), 49 deletions(-) diff --git a/contracts/evm/src/QuorumCustody.sol b/contracts/evm/src/QuorumCustody.sol index 78c62c3..7df2e4c 100644 --- a/contracts/evm/src/QuorumCustody.sol +++ b/contracts/evm/src/QuorumCustody.sol @@ -83,8 +83,7 @@ contract QuorumCustody is IWithdraw, IDeposit, ReentrancyGuard, EIP712 { require(block.timestamp <= deadline, DeadlineExpired()); require(newSigners.length != 0, EmptySignersArray()); require( - newQuorum != 0 && newQuorum >= quorum && newQuorum <= signers.length + newSigners.length, - InvalidQuorum() + newQuorum != 0 && newQuorum >= quorum && newQuorum <= signers.length + newSigners.length, InvalidQuorum() ); _verifySignatures( @@ -285,7 +284,11 @@ contract QuorumCustody is IWithdraw, IDeposit, ReentrancyGuard, EIP712 { return keccak256(abi.encodePacked(encoded)); } - function _getWithdrawalId(address user, address token, uint256 amount, uint256 nonce) internal view returns (bytes32) { + function _getWithdrawalId(address user, address token, uint256 amount, uint256 nonce) + internal + view + returns (bytes32) + { return keccak256(abi.encode(block.chainid, address(this), user, token, amount, nonce)); } } diff --git a/contracts/evm/src/ThresholdCustody.sol b/contracts/evm/src/ThresholdCustody.sol index 19c9bd3..437ff11 100644 --- a/contracts/evm/src/ThresholdCustody.sol +++ b/contracts/evm/src/ThresholdCustody.sol @@ -51,7 +51,6 @@ contract ThresholdCustody is IWithdraw, IDeposit, ReentrancyGuard, EIP712, Multi { require(initialSigners.length != 0, EmptySignersArray()); require(quorum_ != 0 && quorum_ <= initialSigners.length, InvalidQuorum()); - } modifier onlySigner() { @@ -63,12 +62,10 @@ contract ThresholdCustody is IWithdraw, IDeposit, ReentrancyGuard, EIP712, Multi return isSigner(_toBytes(signer)); } - function addSigners( - address[] calldata newSigners, - uint64 newThreshold, - uint256 deadline, - bytes calldata signatures - ) external onlySigner { + function addSigners(address[] calldata newSigners, uint64 newThreshold, uint256 deadline, bytes calldata signatures) + external + onlySigner + { require(block.timestamp <= deadline, DeadlineExpired()); require(newSigners.length != 0, EmptySignersArray()); @@ -217,7 +214,11 @@ contract ThresholdCustody is IWithdraw, IDeposit, ReentrancyGuard, EIP712, Multi return keccak256(abi.encodePacked(encoded)); } - function _getWithdrawalId(address user, address token, uint256 amount, uint256 nonce) internal view returns (bytes32) { + function _getWithdrawalId(address user, address token, uint256 amount, uint256 nonce) + internal + view + returns (bytes32) + { return keccak256(abi.encode(block.chainid, address(this), user, token, amount, nonce)); } @@ -228,7 +229,7 @@ contract ThresholdCustody is IWithdraw, IDeposit, ReentrancyGuard, EIP712, Multi function _toAddressBytesArray(address[] memory addrs) internal pure returns (bytes[] memory) { bytes[] memory b = new bytes[](addrs.length); - for(uint i=0; i uint160(addrs[j])) { (addrs[i], addrs[j]) = (addrs[j], addrs[i]); (sigs[i], sigs[j]) = (sigs[j], sigs[i]); } } } - for (uint i = 0; i < 3; i++) { + for (uint256 i = 0; i < 3; i++) { signers[i] = abi.encodePacked(addrs[i]); signatures[i] = sigs[i]; } @@ -689,7 +690,7 @@ contract ThresholdCustodyTest is Test { vm.prank(signer1); bytes32 id = custody.startWithdraw(user, address(0), 1 ether, 1); - (,,,,,uint64 requiredQuorum) = custody.withdrawals(id); + (,,,,, uint64 requiredQuorum) = custody.withdrawals(id); assertEq(requiredQuorum, 1); } @@ -706,7 +707,7 @@ contract ThresholdCustodyTest is Test { vm.prank(signer1); custody.finalizeWithdraw(id); - (,,,bool finalized,,) = custody.withdrawals(id); + (,,, bool finalized,,) = custody.withdrawals(id); assertTrue(finalized); } @@ -729,12 +730,12 @@ contract ThresholdCustodyTest is Test { vm.prank(signer1); custody.finalizeWithdraw(id); - (,,,bool finalized,,) = custody.withdrawals(id); + (,,, bool finalized,,) = custody.withdrawals(id); assertFalse(finalized); vm.prank(signer2); custody.finalizeWithdraw(id); - (,,,finalized,,) = custody.withdrawals(id); + (,,, finalized,,) = custody.withdrawals(id); assertTrue(finalized); } @@ -753,17 +754,17 @@ contract ThresholdCustodyTest is Test { vm.prank(signer1); custody.finalizeWithdraw(id); - (,,,bool finalized,,) = custody.withdrawals(id); + (,,, bool finalized,,) = custody.withdrawals(id); assertFalse(finalized); vm.prank(signer2); custody.finalizeWithdraw(id); - (,,,finalized,,) = custody.withdrawals(id); + (,,, finalized,,) = custody.withdrawals(id); assertFalse(finalized); vm.prank(signer3); custody.finalizeWithdraw(id); - (,,,finalized,,) = custody.withdrawals(id); + (,,, finalized,,) = custody.withdrawals(id); assertTrue(finalized); } @@ -798,7 +799,7 @@ contract ThresholdCustodyTest is Test { vm.prank(signer1); custody.finalizeWithdraw(id); - (,,,bool finalized,,) = custody.withdrawals(id); + (,,, bool finalized,,) = custody.withdrawals(id); assertTrue(finalized); } @@ -876,7 +877,7 @@ contract ThresholdCustodyTest is Test { vm.prank(signer1); custody.finalizeWithdraw(id); - (,,,bool finalized,,) = custody.withdrawals(id); + (,,, bool finalized,,) = custody.withdrawals(id); assertTrue(finalized); } @@ -919,8 +920,7 @@ contract ThresholdCustodyTest is Test { vm.prank(signer1); custody.finalizeWithdraw(id); - (address storedUser, address storedToken, uint256 storedAmount, bool finalized,,) = - custody.withdrawals(id); + (address storedUser, address storedToken, uint256 storedAmount, bool finalized,,) = custody.withdrawals(id); assertTrue(finalized); assertEq(storedUser, address(0)); assertEq(storedToken, address(0)); @@ -982,7 +982,7 @@ contract ThresholdCustodyTest is Test { vm.prank(signer1); custody.rejectWithdraw(id); - (,,,bool finalized,,) = custody.withdrawals(id); + (,,, bool finalized,,) = custody.withdrawals(id); assertTrue(finalized); } @@ -1092,7 +1092,7 @@ contract ThresholdCustodyTest is Test { vm.prank(signer1); custody.rejectWithdraw(id); - (,,,bool finalized,,) = custody.withdrawals(id); + (,,, bool finalized,,) = custody.withdrawals(id); assertTrue(finalized); } @@ -1175,14 +1175,14 @@ contract ThresholdCustodyTest is Test { vm.prank(signer1); custody.finalizeWithdraw(id); - (,,,bool finalized,,) = custody.withdrawals(id); + (,,, bool finalized,,) = custody.withdrawals(id); assertFalse(finalized); // signer3 approves — now 2 valid approvals (signer1 + signer3), meets requiredQuorum=2 vm.prank(signer3); custody.finalizeWithdraw(id); - (,,,finalized,,) = custody.withdrawals(id); + (,,, finalized,,) = custody.withdrawals(id); assertTrue(finalized); }