From 8d64ab5b5fe596a108ffbd1ea5f2f91d3df4d515 Mon Sep 17 00:00:00 2001 From: Ameesha Agrawal Date: Mon, 22 Dec 2025 20:39:14 +0530 Subject: [PATCH 1/6] fix: batch functions --- .../protocol/switchboard/EVMxSwitchboard.sol | 238 +++++++++++---- .../switchboard/MessageSwitchboard.sol | 275 +++++++++++++----- .../protocol/switchboard/SwitchboardBase.sol | 48 +++ .../switchboard/EVMxSwitchboard.t.sol | 18 +- 4 files changed, 440 insertions(+), 139 deletions(-) diff --git a/contracts/protocol/switchboard/EVMxSwitchboard.sol b/contracts/protocol/switchboard/EVMxSwitchboard.sol index a51f1f49..927f6239 100644 --- a/contracts/protocol/switchboard/EVMxSwitchboard.sol +++ b/contracts/protocol/switchboard/EVMxSwitchboard.sol @@ -33,6 +33,19 @@ contract EVMxSwitchboard is SwitchboardBase { /// @notice Mapping of payload ID and digest to validity status (true if digest is attested by enough watchers) mapping(bytes32 => mapping(bytes32 => bool)) public isValid; + /// @notice Mapping of watcher address to combined key (payloadId + isReverting) to signature status + mapping(address => mapping(bytes32 => bool)) public isRevertingSignedByWatcher; + + /// @notice Mapping of combined key (payloadId + isReverting) to signature count + mapping(bytes32 => uint256) public revertingSignatures; + + /// @notice Mapping of watcher address to payload ID and new digest to signature status + mapping(address => mapping(bytes32 => mapping(bytes32 => bool))) + public isTransmitterSignedByWatcher; + + /// @notice Mapping of payload ID and new digest to signature count + mapping(bytes32 => mapping(bytes32 => uint256)) public transmitterSignatures; + /// @notice Mapping of plug address to app gateway ID mapping(address => bytes32) public plugAppGatewayIds; @@ -81,7 +94,6 @@ contract EVMxSwitchboard is SwitchboardBase { } // --- External Functions --- - /** * @notice Attests a payload digest with watcher signature * @param digest_ The digest of the payload to be executed @@ -90,23 +102,51 @@ contract EVMxSwitchboard is SwitchboardBase { * Payload is uniquely identified by digest. Once attested, payload can be executed. */ function attest(bytes32 payloadId_, bytes32 digest_, bytes calldata proof_) public virtual { - address watcher = _recoverSigner( - keccak256( - abi.encodePacked(toBytes32Format(address(this)), chainSlug, payloadId_, digest_) - ), - proof_ + bytes32 messageHash = keccak256( + abi.encodePacked(toBytes32Format(address(this)), chainSlug, payloadId_, digest_) ); - if (!_hasRole(WATCHER_ROLE, watcher)) revert WatcherNotFound(); + address watcher = _validateSignature(messageHash, proof_, WATCHER_ROLE); + + _processAttestation(payloadId_, digest_, watcher); + } - // Prevent double attestation - if (isAttestedByWatcher[watcher][payloadId_][digest_]) revert AlreadyAttested(); - isAttestedByWatcher[watcher][payloadId_][digest_] = true; - attestations[payloadId_][digest_]++; + /** + * @notice Batch attests a payload digest with multiple watcher signatures + * @param payloadId_ The payload ID to attest + * @param digest_ The digest of the payload to be executed + * @param proofs_ Array of watcher signature proofs + * @dev Processes multiple attestations in a single transaction to save gas. + * Reverts if any watcher is not authorized or has already attested. + * Can be called by any third party as authorization happens through signatures. + */ + function batchAttest( + bytes32 payloadId_, + bytes32 digest_, + bytes[] calldata proofs_ + ) public virtual { + bytes32 messageHash = keccak256( + abi.encodePacked(toBytes32Format(address(this)), chainSlug, payloadId_, digest_) + ); + address[] memory watchers = _validateBatchSignatures(messageHash, proofs_, WATCHER_ROLE); - // Mark digest as valid if enough attestations are reached - if (attestations[payloadId_][digest_] >= totalWatchers) isValid[payloadId_][digest_] = true; + for (uint256 i = 0; i < watchers.length; i++) { + _processAttestation(payloadId_, digest_, watchers[i]); + } + } - emit Attested(payloadId_, digest_, watcher); + /** + * @notice Processes a single watcher attestation + * @param payloadId_ The payload ID + * @param digest_ The digest of the payload + * @param watcher_ The watcher address + * @dev Checks for double attestation, updates state, and emits event + */ + function _processAttestation(bytes32 payloadId_, bytes32 digest_, address watcher_) internal { + if (isAttestedByWatcher[watcher_][payloadId_][digest_]) revert AlreadyAttested(); + isAttestedByWatcher[watcher_][payloadId_][digest_] = true; + uint256 attestationCount = attestations[payloadId_][digest_]++; + if (attestationCount >= totalWatchers) isValid[payloadId_][digest_] = true; + emit Attested(payloadId_, digest_, watcher_); } /** @@ -207,67 +247,137 @@ contract EVMxSwitchboard is SwitchboardBase { } /** - * @notice Sets reverting status for a payload - * @param payloadId_ The payload ID to mark - * @param isReverting_ True if payload should be marked as reverting - * @dev Only callable by owner. Used to mark payloads that are known to revert. + * @notice Processes a single watcher signature for reverting payload + * @param payloadId_ The payload ID + * @param isReverting_ The reverting status flag + * @param watcher_ The watcher address + * @dev Checks for double signature, updates state, and sets reverting status if threshold is reached */ - function setRevertingPayload( + function _processRevertingPayloadSignature( bytes32 payloadId_, bool isReverting_, - uint256 nonce_, - bytes calldata signature_ - ) external { - bytes32 digest = keccak256( - abi.encodePacked( - toBytes32Format(address(this)), - chainSlug, - payloadId_, - isReverting_, - nonce_ - ) - ); + address watcher_ + ) internal { + bytes32 key = keccak256(abi.encodePacked(payloadId_, isReverting_)); + if (isRevertingSignedByWatcher[watcher_][key]) revert AlreadyAttested(); + isRevertingSignedByWatcher[watcher_][key] = true; + uint256 signatureCount = revertingSignatures[key]++; + + if (signatureCount >= totalWatchers) { + revertingPayloadIds[payloadId_] = isReverting_; + emit RevertingPayloadIdset(payloadId_, isReverting_); + } + } - address watcher = _recoverSigner(digest, signature_); - if (!_hasRole(WATCHER_ROLE, watcher)) revert WatcherNotFound(); - if (usedNonces[watcher][nonce_]) revert NonceAlreadyUsed(); - usedNonces[watcher][nonce_] = true; + /** + * @notice Sets reverting payload status with watcher signatures + * @param payloadIds_ Array of payload IDs to mark + * @param isReverting_ Array of reverting status flags + * @param nonces_ Array of nonces to prevent replay attacks + * @param signatures_ Array of watcher signatures + * @dev Processes multiple payloads in a single transaction. Each payload requires totalWatchers signatures. + */ + function setRevertingPayload( + bytes32[] calldata payloadIds_, + bool[] calldata isReverting_, + uint256[] calldata nonces_, + bytes[] calldata signatures_ + ) external { + if ( + payloadIds_.length != isReverting_.length || + payloadIds_.length != nonces_.length || + payloadIds_.length != signatures_.length + ) revert ArrayLengthMismatch(); + + for (uint256 i = 0; i < payloadIds_.length; i++) { + bytes32 digest = keccak256( + abi.encodePacked( + toBytes32Format(address(this)), + chainSlug, + payloadIds_[i], + isReverting_[i], + nonces_[i] + ) + ); + + address watcher = _validateSignature(digest, signatures_[i], WATCHER_ROLE); + if (usedNonces[watcher][nonces_[i]]) revert NonceAlreadyUsed(); + usedNonces[watcher][nonces_[i]] = true; + + _processRevertingPayloadSignature(payloadIds_[i], isReverting_[i], watcher); + } + } - revertingPayloadIds[payloadId_] = isReverting_; - emit RevertingPayloadIdset(payloadId_, isReverting_); + /** + * @notice Processes a single watcher signature for transmitter assignment + * @param payloadId_ The payload ID + * @param newDigest_ The new digest with the new transmitter + * @param newTransmitter_ The new transmitter address + * @param watcher_ The watcher address + * @return wasJustAssigned True if the transmitter was just assigned (threshold reached) + * @dev Checks for double signature, updates state, and assigns transmitter if threshold is reached + */ + function _processTransmitterSignature( + bytes32 payloadId_, + bytes32 newDigest_, + address newTransmitter_, + address watcher_ + ) internal returns (bool wasJustAssigned) { + if (isTransmitterSignedByWatcher[watcher_][payloadId_][newDigest_]) + revert AlreadyAttested(); + isTransmitterSignedByWatcher[watcher_][payloadId_][newDigest_] = true; + uint256 signatureCount = transmitterSignatures[payloadId_][newDigest_]++; + + if (signatureCount >= totalWatchers) { + payloadIdToDigest[payloadId_] = newDigest_; + emit TransmitterAssigned(payloadId_, newTransmitter_); + return true; + } + return false; } /** - * @notice Gets the transmitter address for payload execution - * @param digestParams_ The digest parameters - * @param signature_ The watcher signature - * @dev Only callable by watcher. Used to assign the transmitter address for payload execution. + * @notice Assigns transmitter addresses with watcher signatures + * @param digestParams_ Array of digest parameters + * @param oldTransmitters_ Array of old transmitter addresses + * @param newTransmitters_ Array of new transmitter addresses + * @param nonces_ Array of nonces to prevent replay attacks + * @param signatures_ Array of watcher signatures + * @dev Processes multiple transmitter assignments in a single transaction. Each assignment requires totalWatchers signatures. */ function assignTransmitter( - DigestParams memory digestParams_, - address oldTransmitter_, - address newTransmitter_, - uint256 nonce_, - bytes calldata signature_ + DigestParams[] calldata digestParams_, + address[] calldata oldTransmitters_, + address[] calldata newTransmitters_, + uint256[] calldata nonces_, + bytes[] calldata signatures_ ) external { - bytes32 oldTransmitterBytes32 = toBytes32Format(oldTransmitter_); - digestParams_.transmitter = oldTransmitterBytes32; - bytes32 oldDigest = createDigest(digestParams_); - - if (payloadIdToDigest[digestParams_.payloadId] != oldDigest) revert InvalidDigest(); - - digestParams_.transmitter = toBytes32Format(newTransmitter_); - bytes32 newDigest = createDigest(digestParams_); - - address watcher = _recoverSigner( - keccak256(abi.encodePacked(toBytes32Format(address(this)), chainSlug, oldDigest, newDigest)), - signature_ - ); - if (!_hasRole(WATCHER_ROLE, watcher)) revert WatcherNotFound(); - _validateAndUseNonce(this.assignTransmitter.selector, watcher, nonce_); - - payloadIdToDigest[digestParams_.payloadId] = newDigest; - emit TransmitterAssigned(digestParams_.payloadId, newTransmitter_); + if ( + digestParams_.length != oldTransmitters_.length || + digestParams_.length != newTransmitters_.length || + digestParams_.length != nonces_.length || + digestParams_.length != signatures_.length + ) revert ArrayLengthMismatch(); + + for (uint256 i = 0; i < digestParams_.length; i++) { + bytes32 oldTransmitterBytes32 = toBytes32Format(oldTransmitters_[i]); + DigestParams memory params = digestParams_[i]; + params.transmitter = oldTransmitterBytes32; + bytes32 oldDigest = createDigest(params); + + if (payloadIdToDigest[params.payloadId] != oldDigest) revert InvalidDigest(); + + params.transmitter = toBytes32Format(newTransmitters_[i]); + bytes32 newDigest = createDigest(params); + + bytes32 messageHash = keccak256( + abi.encodePacked(toBytes32Format(address(this)), chainSlug, oldDigest, newDigest) + ); + address watcher = _validateSignature(messageHash, signatures_[i], WATCHER_ROLE); + _validateAndUseNonce(this.assignTransmitter.selector, watcher, nonces_[i]); + + _processTransmitterSignature(params.payloadId, newDigest, newTransmitters_[i], watcher); + } } /** diff --git a/contracts/protocol/switchboard/MessageSwitchboard.sol b/contracts/protocol/switchboard/MessageSwitchboard.sol index 86e9ef6f..8b62b8d4 100644 --- a/contracts/protocol/switchboard/MessageSwitchboard.sol +++ b/contracts/protocol/switchboard/MessageSwitchboard.sol @@ -29,6 +29,25 @@ contract MessageSwitchboard is SwitchboardBase, ReentrancyGuard { /// @notice Mapping of payload ID and digest to validity status (true if digest is attested by enough watchers) mapping(bytes32 => mapping(bytes32 => bool)) public isValid; + /// @notice Mapping of watcher address to combined key (payloadId + isReverting) to signature status + mapping(address => mapping(bytes32 => bool)) public isRevertingSignedByWatcher; + + /// @notice Mapping of combined key (payloadId + isReverting) to signature count + mapping(bytes32 => uint256) public revertingSignatures; + + /// @notice Mapping of watcher address to payload ID and new digest to signature status + mapping(address => mapping(bytes32 => mapping(bytes32 => bool))) + public isTransmitterSignedByWatcher; + + /// @notice Mapping of payload ID and new digest to signature count + mapping(bytes32 => mapping(bytes32 => uint256)) public transmitterSignatures; + + /// @notice Mapping of watcher address to payload ID to refund eligibility signature status + mapping(address => mapping(bytes32 => bool)) public isRefundEligibleSignedByWatcher; + + /// @notice Mapping of payload ID to refund eligibility signature count + mapping(bytes32 => uint256) public refundEligibleSignatures; + /// @notice Mapping of destination chain slug to sibling socket address (bytes32 format) mapping(uint32 => bytes32) public siblingSockets; @@ -133,24 +152,47 @@ contract MessageSwitchboard is SwitchboardBase, ReentrancyGuard { * @dev Enhanced attestation verifies target with sibling chain slug and sibling plug. */ function attest(bytes32 payloadId_, bytes32 digest_, bytes calldata proof_) public { - // Recover watcher from signature - address watcher = _recoverSigner( - keccak256(abi.encodePacked(toBytes32Format(address(this)), chainSlug, payloadId_, digest_)), - proof_ + bytes32 messageHash = keccak256( + abi.encodePacked(toBytes32Format(address(this)), chainSlug, payloadId_, digest_) ); + address watcher = _validateSignature(messageHash, proof_, WATCHER_ROLE); - // Verify watcher has WATCHER_ROLE - if (!_hasRole(WATCHER_ROLE, watcher)) revert WatcherNotFound(); + _processAttestation(payloadId_, digest_, watcher); + } - // Prevent double attestation - if (isAttestedByWatcher[watcher][payloadId_][digest_]) revert AlreadyAttested(); - isAttestedByWatcher[watcher][payloadId_][digest_] = true; - attestations[payloadId_][digest_]++; + /** + * @notice Batch attests a payload with multiple watcher signatures + * @param payloadId_ The payload ID to attest + * @param digest_ The digest of the payload to be executed + * @param proofs_ Array of watcher signature proofs + * @dev Processes multiple attestations in a single transaction to save gas. + * Reverts if any watcher is not authorized or has already attested. + * Can be called by any third party as authorization happens through signatures. + */ + function batchAttest(bytes32 payloadId_, bytes32 digest_, bytes[] calldata proofs_) public { + bytes32 messageHash = keccak256( + abi.encodePacked(toBytes32Format(address(this)), chainSlug, payloadId_, digest_) + ); + address[] memory watchers = _validateBatchSignatures(messageHash, proofs_, WATCHER_ROLE); + for (uint256 i = 0; i < watchers.length; i++) { + _processAttestation(payloadId_, digest_, watchers[i]); + } + } - // Mark digest_ as valid if enough attestations are reached - if (attestations[payloadId_][digest_] >= totalWatchers) isValid[payloadId_][digest_] = true; + /** + * @notice Processes a single watcher attestation + * @param payloadId_ The payload ID + * @param digest_ The digest of the payload + * @param watcher_ The watcher address + * @dev Checks for double attestation, updates state, and emits event + */ + function _processAttestation(bytes32 payloadId_, bytes32 digest_, address watcher_) internal { + if (isAttestedByWatcher[watcher_][payloadId_][digest_]) revert AlreadyAttested(); + isAttestedByWatcher[watcher_][payloadId_][digest_] = true; + uint256 attestationCount = attestations[payloadId_][digest_]++; + if (attestationCount >= totalWatchers) isValid[payloadId_][digest_] = true; - emit Attested(payloadId_, digest_, watcher); + emit Attested(payloadId_, digest_, watcher_); } /** @@ -442,28 +484,49 @@ contract MessageSwitchboard is SwitchboardBase, ReentrancyGuard { } /** - * @dev Mark a payload as eligible for refund (called with watcher signature) + * @notice Processes a single watcher signature for refund eligibility + * @param payloadId_ The payload ID + * @param watcher_ The watcher address + * @dev Checks for double signature, updates state, and marks as refund eligible if threshold is reached + */ + function _processRefundEligibleSignature(bytes32 payloadId_, address watcher_) internal { + if (isRefundEligibleSignedByWatcher[watcher_][payloadId_]) revert AlreadyAttested(); + isRefundEligibleSignedByWatcher[watcher_][payloadId_] = true; + uint256 signatureCount = refundEligibleSignatures[payloadId_]++; + + if (signatureCount >= totalWatchers) { + PayloadFees storage fees = payloadFees[payloadId_]; + fees.isRefundEligible = true; + emit RefundEligibilityMarked(payloadId_, watcher_); + } + } + + /** + * @dev Mark a payload as eligible for refund with watcher signatures * @param payloadId_ Payload ID to mark as refund eligible - * @param nonce_ Nonce to prevent replay attacks - * @param signature_ Watcher signature + * @param nonce_ Nonce to prevent replay attacks (shared across all watchers) + * @param signatures_ Array of watcher signatures + * @dev Requires totalWatchers signatures before marking as refund eligible. + * All watchers must use the same nonce. Processes multiple signatures in a single transaction. */ function markRefundEligible( bytes32 payloadId_, uint256 nonce_, - bytes calldata signature_ + bytes[] calldata signatures_ ) external { PayloadFees storage fees = payloadFees[payloadId_]; if (fees.isRefundEligible) revert AlreadyMarkedRefundEligible(); if (fees.nativeFees == 0) revert NoFeesToRefund(); + bytes32 digest = keccak256( abi.encodePacked(toBytes32Format(address(this)), chainSlug, payloadId_, nonce_) ); - address watcher = _recoverSigner(digest, signature_); - if (!_hasRole(WATCHER_ROLE, watcher)) revert WatcherNotFound(); - _validateAndUseNonce(this.markRefundEligible.selector, watcher, nonce_); + address[] memory watchers = _validateBatchSignatures(digest, signatures_, WATCHER_ROLE); - fees.isRefundEligible = true; - emit RefundEligibilityMarked(payloadId_, watcher); + for (uint256 i = 0; i < watchers.length; i++) { + _validateAndUseNonce(this.markRefundEligible.selector, watchers[i], nonce_); + _processRefundEligibleSignature(payloadId_, watchers[i]); + } } /** @@ -533,9 +596,7 @@ contract MessageSwitchboard is SwitchboardBase, ReentrancyGuard { ) ); - address feeUpdater = _recoverSigner(digest, signature_); - if (!_hasRole(FEE_UPDATER_ROLE, feeUpdater)) revert UnauthorizedFeeUpdater(); - + address feeUpdater = _validateSignature(digest, signature_, FEE_UPDATER_ROLE); _validateAndUseNonce(this.setMinMsgValueFeesBatch.selector, feeUpdater, nonce_); for (uint256 i = 0; i < siblingChainSlugs_.length; i++) { @@ -563,28 +624,64 @@ contract MessageSwitchboard is SwitchboardBase, ReentrancyGuard { emit SiblingConfigSet(chainSlug_, socket_, switchboard_); } - function setRevertingPayload( + /** + * @notice Processes a single watcher signature for reverting payload + * @param payloadId_ The payload ID + * @param isReverting_ The reverting status flag + * @param watcher_ The watcher address + * @dev Checks for double signature, updates state, and sets reverting status if threshold is reached + */ + function _processRevertingPayloadSignature( bytes32 payloadId_, bool isReverting_, - uint256 nonce_, - bytes calldata signature_ - ) external { - bytes32 digest = keccak256( - abi.encodePacked( - toBytes32Format(address(this)), - chainSlug, - payloadId_, - isReverting_, - nonce_ - ) - ); + address watcher_ + ) internal { + bytes32 key = keccak256(abi.encodePacked(payloadId_, isReverting_)); + if (isRevertingSignedByWatcher[watcher_][key]) revert AlreadyAttested(); + isRevertingSignedByWatcher[watcher_][key] = true; + uint256 signatureCount = revertingSignatures[key]++; + + if (signatureCount >= totalWatchers) { + revertingPayloadIds[payloadId_] = isReverting_; + emit RevertingPayloadIdset(payloadId_, isReverting_); + } + } - address watcher = _recoverSigner(digest, signature_); - if (!_hasRole(WATCHER_ROLE, watcher)) revert WatcherNotFound(); - _validateAndUseNonce(this.setRevertingPayload.selector, watcher, nonce_); + /** + * @notice Sets reverting payload status with watcher signatures + * @param payloadIds_ Array of payload IDs to mark + * @param isReverting_ Array of reverting status flags + * @param nonces_ Array of nonces to prevent replay attacks + * @param signatures_ Array of watcher signatures + * @dev Processes multiple payloads in a single transaction. Each payload requires totalWatchers signatures. + */ + function setRevertingPayload( + bytes32[] calldata payloadIds_, + bool[] calldata isReverting_, + uint256[] calldata nonces_, + bytes[] calldata signatures_ + ) external { + if ( + payloadIds_.length != isReverting_.length || + payloadIds_.length != nonces_.length || + payloadIds_.length != signatures_.length + ) revert ArrayLengthMismatch(); + + for (uint256 i = 0; i < payloadIds_.length; i++) { + bytes32 digest = keccak256( + abi.encodePacked( + toBytes32Format(address(this)), + chainSlug, + payloadIds_[i], + isReverting_[i], + nonces_[i] + ) + ); - revertingPayloadIds[payloadId_] = isReverting_; - emit RevertingPayloadIdset(payloadId_, isReverting_); + address watcher = _validateSignature(digest, signatures_[i], WATCHER_ROLE); + _validateAndUseNonce(this.setRevertingPayload.selector, watcher, nonces_[i]); + _processRevertingPayloadSignature(payloadIds_[i], isReverting_[i], watcher); + } } /** @@ -669,39 +766,75 @@ contract MessageSwitchboard is SwitchboardBase, ReentrancyGuard { } /** - * @notice Assigns a transmitter address for payload execution - * @param digestParams_ The digest parameters - * @param oldTransmitter_ The old transmitter address + * @notice Processes a single watcher signature for transmitter assignment + * @param payloadId_ The payload ID + * @param newDigest_ The new digest with the new transmitter * @param newTransmitter_ The new transmitter address - * @param nonce_ Nonce to prevent replay attacks - * @param signature_ Watcher signature - * @dev Only callable by watcher. Used to assign the transmitter address for payload execution. + * @param watcher_ The watcher address + * @return wasJustAssigned True if the transmitter was just assigned (threshold reached) + * @dev Checks for double signature, updates state, and assigns transmitter if threshold is reached */ - function assignTransmitter( - DigestParams memory digestParams_, - address oldTransmitter_, + function _processTransmitterSignature( + bytes32 payloadId_, + bytes32 newDigest_, address newTransmitter_, - uint256 nonce_, - bytes calldata signature_ + address watcher_ + ) internal returns (bool wasJustAssigned) { + if (isTransmitterSignedByWatcher[watcher_][payloadId_][newDigest_]) + revert AlreadyAttested(); + isTransmitterSignedByWatcher[watcher_][payloadId_][newDigest_] = true; + uint256 signatureCount = transmitterSignatures[payloadId_][newDigest_]++; + + if (signatureCount >= totalWatchers) { + payloadIdToDigest[payloadId_] = newDigest_; + emit TransmitterAssigned(payloadId_, newTransmitter_); + return true; + } + return false; + } + + /** + * @notice Batch assigns transmitter addresses with multiple watcher signatures + * @param digestParams_ Array of digest parameters + * @param oldTransmitters_ Array of old transmitter addresses + * @param newTransmitters_ Array of new transmitter addresses + * @param nonces_ Array of nonces to prevent replay attacks + * @param signatures_ Array of watcher signatures + * @dev Processes multiple transmitter assignments in a single transaction. Each assignment requires totalWatchers signatures. + */ + function assignTransmitter( + DigestParams[] calldata digestParams_, + address[] calldata oldTransmitters_, + address[] calldata newTransmitters_, + uint256[] calldata nonces_, + bytes[] calldata signatures_ ) external { - bytes32 oldTransmitterBytes32 = toBytes32Format(oldTransmitter_); - digestParams_.transmitter = oldTransmitterBytes32; - bytes32 oldDigest = createDigest(digestParams_); - - if (payloadIdToDigest[digestParams_.payloadId] != oldDigest) revert InvalidDigest(); - - digestParams_.transmitter = toBytes32Format(newTransmitter_); - bytes32 newDigest = createDigest(digestParams_); - - address watcher = _recoverSigner( - keccak256(abi.encodePacked(toBytes32Format(address(this)), chainSlug, oldDigest, newDigest)), - signature_ - ); - if (!_hasRole(WATCHER_ROLE, watcher)) revert WatcherNotFound(); - _validateAndUseNonce(this.assignTransmitter.selector, watcher, nonce_); + if ( + digestParams_.length != oldTransmitters_.length || + digestParams_.length != newTransmitters_.length || + digestParams_.length != nonces_.length || + digestParams_.length != signatures_.length + ) revert ArrayLengthMismatch(); + + for (uint256 i = 0; i < digestParams_.length; i++) { + bytes32 oldTransmitterBytes32 = toBytes32Format(oldTransmitters_[i]); + DigestParams memory params = digestParams_[i]; + params.transmitter = oldTransmitterBytes32; + bytes32 oldDigest = createDigest(params); + + if (payloadIdToDigest[params.payloadId] != oldDigest) revert InvalidDigest(); - payloadIdToDigest[digestParams_.payloadId] = newDigest; - emit TransmitterAssigned(digestParams_.payloadId, newTransmitter_); + params.transmitter = toBytes32Format(newTransmitters_[i]); + bytes32 newDigest = createDigest(params); + + bytes32 messageHash = keccak256( + abi.encodePacked(toBytes32Format(address(this)), chainSlug, oldDigest, newDigest) + ); + address watcher = _validateSignature(messageHash, signatures_[i], WATCHER_ROLE); + _validateAndUseNonce(this.assignTransmitter.selector, watcher, nonces_[i]); + + _processTransmitterSignature(params.payloadId, newDigest, newTransmitters_[i], watcher); + } } /** diff --git a/contracts/protocol/switchboard/SwitchboardBase.sol b/contracts/protocol/switchboard/SwitchboardBase.sol index 977f4744..630642a9 100644 --- a/contracts/protocol/switchboard/SwitchboardBase.sol +++ b/contracts/protocol/switchboard/SwitchboardBase.sol @@ -130,6 +130,54 @@ abstract contract SwitchboardBase is ISwitchboard, AccessControl { signer = ECDSA.recover(digest, signature_); } + /** + * @notice Validates a single signature and recovers signer + * @param messageHash_ The message hash that was signed + * @param signature_ The signature bytes + * @param requiredRole_ The role that the signer must have (bytes32(0) to skip role check) + * @return signer The recovered signer address + * @dev Reverts if signature is invalid or if signer doesn't have the required role. + * Uses Ethereum signed message format. + */ + function _validateSignature( + bytes32 messageHash_, + bytes calldata signature_, + bytes32 requiredRole_ + ) internal view returns (address signer) { + signer = _recoverSigner(messageHash_, signature_); + if (requiredRole_ != bytes32(0) && !_hasRole(requiredRole_, signer)) { + revert RoleNotAuthorized(requiredRole_); + } + } + + /** + * @notice Validates a batch of signatures and recovers signers + * @param messageHash_ The message hash that was signed + * @param signatures_ Array of signature bytes + * @param requiredRole_ The role that all signers must have (bytes32(0) to skip role check) + * @return signers Array of recovered signer addresses + * @dev Reverts if any signature is invalid or if any signer doesn't have the required role. + * Uses Ethereum signed message format for all signatures. + */ + function _validateBatchSignatures( + bytes32 messageHash_, + bytes[] calldata signatures_, + bytes32 requiredRole_ + ) internal view returns (address[] memory signers) { + signers = new address[](signatures_.length); + bytes32 ethSignedMessageHash = keccak256( + abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash_) + ); + + for (uint256 i = 0; i < signatures_.length; i++) { + address signer = ECDSA.recover(ethSignedMessageHash, signatures_[i]); + if (requiredRole_ != bytes32(0) && !_hasRole(requiredRole_, signer)) { + revert RoleNotAuthorized(requiredRole_); + } + signers[i] = signer; + } + } + // --- Rescue Functions --- /** diff --git a/test/protocol/switchboard/EVMxSwitchboard.t.sol b/test/protocol/switchboard/EVMxSwitchboard.t.sol index 858abb1d..6b04173f 100644 --- a/test/protocol/switchboard/EVMxSwitchboard.t.sol +++ b/test/protocol/switchboard/EVMxSwitchboard.t.sol @@ -287,14 +287,19 @@ contract EVMxSwitchboardTestBase is Test, Utils { ) internal view returns (bytes memory signature) { // Create old digest with current transmitter (before modification) bytes32 oldDigest = createDigest(digestParams_); - + // Create new digest with new transmitter digestParams_.transmitter = toBytes32Format(newTransmitter_); bytes32 newDigest = createDigest(digestParams_); // Create signature digest with both old and new digests bytes32 signatureDigest = keccak256( - abi.encodePacked(toBytes32Format(address(evmxSwitchboard)), CHAIN_SLUG, oldDigest, newDigest) + abi.encodePacked( + toBytes32Format(address(evmxSwitchboard)), + CHAIN_SLUG, + oldDigest, + newDigest + ) ); signature = createSignature(signatureDigest, watcherPrivateKey); } @@ -1270,14 +1275,19 @@ contract SocketPayloadIdVerificationTest is EVMxSwitchboardTestBase { // Create old digest with old transmitter bytes32 oldDigest = createDigest(digestParams); - + // Create new digest with new transmitter digestParams.transmitter = toBytes32Format(newTransmitter); bytes32 newDigest = createDigest(digestParams); // Create signature digest with both old and new digests with non-watcher key bytes32 signatureDigest = keccak256( - abi.encodePacked(toBytes32Format(address(evmxSwitchboard)), CHAIN_SLUG, oldDigest, newDigest) + abi.encodePacked( + toBytes32Format(address(evmxSwitchboard)), + CHAIN_SLUG, + oldDigest, + newDigest + ) ); bytes memory signature = createSignature(signatureDigest, nonWatcherKey); From db66eb96124036166fcaa65a5c036ec610b7e504 Mon Sep 17 00:00:00 2001 From: Ameesha Agrawal Date: Mon, 22 Dec 2025 20:46:01 +0530 Subject: [PATCH 2/6] fix: lint --- contracts/protocol/SocketBatcher.sol | 6 +++++- contracts/utils/common/DigestUtils.sol | 4 ++-- contracts/utils/common/Errors.sol | 3 +++ test/protocol/Socket.t.sol | 20 +++++++++++++------- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/contracts/protocol/SocketBatcher.sol b/contracts/protocol/SocketBatcher.sol index 25d24711..61010e33 100644 --- a/contracts/protocol/SocketBatcher.sol +++ b/contracts/protocol/SocketBatcher.sol @@ -56,7 +56,11 @@ contract SocketBatcher is ISocketBatcher, Ownable { bytes calldata proof_ ) external payable returns (bool, bytes memory) { // Attest digest on FastSwitchboard - IFastSwitchboard(socket__.switchboardAddresses(switchboardId_)).attest(executionParams_.payloadId, digest_, proof_); + IFastSwitchboard(socket__.switchboardAddresses(switchboardId_)).attest( + executionParams_.payloadId, + digest_, + proof_ + ); // Execute payload on socket return socket__.execute{value: msg.value}(executionParams_, transmissionParams_); } diff --git a/contracts/utils/common/DigestUtils.sol b/contracts/utils/common/DigestUtils.sol index 3306df32..11e76fdc 100644 --- a/contracts/utils/common/DigestUtils.sol +++ b/contracts/utils/common/DigestUtils.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.28; -import { DigestParams } from "./Structs.sol"; -import { toBytes32Format } from "./Converters.sol"; +import {DigestParams} from "./Structs.sol"; +import {toBytes32Format} from "./Converters.sol"; /// @notice Creates the digest for the payload execution /// @param digestParams_ The digest parameters diff --git a/contracts/utils/common/Errors.sol b/contracts/utils/common/Errors.sol index 59a5bde8..81dde0e1 100644 --- a/contracts/utils/common/Errors.sol +++ b/contracts/utils/common/Errors.sol @@ -168,3 +168,6 @@ error WatcherFound(); /// @notice Thrown when digest does not match stored digest error InvalidDigest(); + +/// @notice Thrown when role is not authorized +error RoleNotAuthorized(bytes32 role); diff --git a/test/protocol/Socket.t.sol b/test/protocol/Socket.t.sol index 988af43e..4fe38644 100644 --- a/test/protocol/Socket.t.sol +++ b/test/protocol/Socket.t.sol @@ -316,14 +316,16 @@ contract SocketTestBase is Test, Utils { address(this), abi.encodeWithSelector( WritePrecompile.initialize.selector, - address(0), address(0), 1, 1 + address(0), + address(0), + 1, + 1 ) ); writePrecompile = WritePrecompile(proxy); return writePrecompile; } - function _createExecutionParams() internal view returns (ExecutionParams memory) { bytes32 payloadId = createPayloadId( TEST_CHAIN_SLUG, // source chain slug @@ -1116,7 +1118,7 @@ contract SocketUtilsTest is SocketTestBase { 14323, // origin chain slug - evmx 1, // origin watcher id CHAIN_SLUG_SOLANA_MAINNET, // verification chain slug (matches socket) - 1, // verification switchboard id (matches plug's switchboard) + 1, // verification switchboard id (matches plug's switchboard) 600 // pointer / counter ); @@ -1132,7 +1134,8 @@ contract SocketUtilsTest is SocketTestBase { address appGateway = address(0xCDB5fE8572725B20A2C0Db85DDb0D025bCC16f86); // real value taken from 07-calculate-digest.ts in Solana Socket repo test - bytes memory payloadPacked = hex"0d2d692c8c7f3ff61408499c3eb4865321405fb1c9bfc5014a63672453973780cb33f7992e094d6c65e0e95cf54e70c3470840e69d739dfcfcf2e1805fc913d1e8b3cf7c50f0b707dba43ef8042a34802d4f1768c72798bafa24d741c89f9ccf9ded6d20f1f5b9c56cb90ef89fc52d355aaaa868c42738eff11f50d1f81f522a04feb6778939c89983aac734e237dc22f49d7b4418d378a516df15a255d084cb000000000000000000000000000000000000000000000000000000000000000006ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a93339e12fb69289a640420f0000000000"; + bytes + memory payloadPacked = hex"0d2d692c8c7f3ff61408499c3eb4865321405fb1c9bfc5014a63672453973780cb33f7992e094d6c65e0e95cf54e70c3470840e69d739dfcfcf2e1805fc913d1e8b3cf7c50f0b707dba43ef8042a34802d4f1768c72798bafa24d741c89f9ccf9ded6d20f1f5b9c56cb90ef89fc52d355aaaa868c42738eff11f50d1f81f522a04feb6778939c89983aac734e237dc22f49d7b4418d378a516df15a255d084cb000000000000000000000000000000000000000000000000000000000000000006ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a93339e12fb69289a640420f0000000000"; DigestParams memory digestParams = DigestParams({ socket: socketSolana, @@ -1145,20 +1148,23 @@ contract SocketUtilsTest is SocketTestBase { payload: payloadPacked, target: targetSolana, source: abi.encodePacked(toBytes32Format(appGateway)), - prevBatchDigestHash: bytes32(0x614bf23210ee3f9fd714634e1ea66f52cea9e6177140d533a981d511ebd785d3), + prevBatchDigestHash: bytes32( + 0x614bf23210ee3f9fd714634e1ea66f52cea9e6177140d533a981d511ebd785d3 + ), extraData: bytes("") }); // console.log("Source:"); // console.logBytes(abi.encodePacked(toBytes32Format(appGateway))); - bytes32 digest = writePrecompile.getDigest(digestParams); // console.log("Digest solana:"); // console.logBytes32(digest); - assertTrue(digest == bytes32(0x6842eaeba430c89bd05048c9f63394a6c2b0ca5621ae636fef29d4b5293dbc6f)); + assertTrue( + digest == bytes32(0x6842eaeba430c89bd05048c9f63394a6c2b0ca5621ae636fef29d4b5293dbc6f) + ); } } From 6dd5f1ecc703b4c4295f31650b50d167a401c52d Mon Sep 17 00:00:00 2001 From: Ameesha Agrawal Date: Tue, 23 Dec 2025 13:49:42 +0530 Subject: [PATCH 3/6] fix: batch functions --- .../protocol/switchboard/EVMxSwitchboard.sol | 213 ++++++--------- .../switchboard/MessageSwitchboard.sol | 254 +++++++----------- .../protocol/switchboard/SwitchboardBase.sol | 32 +-- 3 files changed, 177 insertions(+), 322 deletions(-) diff --git a/contracts/protocol/switchboard/EVMxSwitchboard.sol b/contracts/protocol/switchboard/EVMxSwitchboard.sol index 927f6239..6af952ac 100644 --- a/contracts/protocol/switchboard/EVMxSwitchboard.sol +++ b/contracts/protocol/switchboard/EVMxSwitchboard.sol @@ -33,19 +33,6 @@ contract EVMxSwitchboard is SwitchboardBase { /// @notice Mapping of payload ID and digest to validity status (true if digest is attested by enough watchers) mapping(bytes32 => mapping(bytes32 => bool)) public isValid; - /// @notice Mapping of watcher address to combined key (payloadId + isReverting) to signature status - mapping(address => mapping(bytes32 => bool)) public isRevertingSignedByWatcher; - - /// @notice Mapping of combined key (payloadId + isReverting) to signature count - mapping(bytes32 => uint256) public revertingSignatures; - - /// @notice Mapping of watcher address to payload ID and new digest to signature status - mapping(address => mapping(bytes32 => mapping(bytes32 => bool))) - public isTransmitterSignedByWatcher; - - /// @notice Mapping of payload ID and new digest to signature count - mapping(bytes32 => mapping(bytes32 => uint256)) public transmitterSignatures; - /// @notice Mapping of plug address to app gateway ID mapping(address => bytes32) public plugAppGatewayIds; @@ -78,6 +65,14 @@ contract EVMxSwitchboard is SwitchboardBase { bytes payload ); + struct AssignTransmitterParams { + DigestParams digestParams; + address oldTransmitter; + address newTransmitter; + uint256 nonce; + bytes[] signatures; // must be totalWatchers length + } + // --- Constructor --- constructor( @@ -122,7 +117,7 @@ contract EVMxSwitchboard is SwitchboardBase { function batchAttest( bytes32 payloadId_, bytes32 digest_, - bytes[] calldata proofs_ + bytes[] memory proofs_ ) public virtual { bytes32 messageHash = keccak256( abi.encodePacked(toBytes32Format(address(this)), chainSlug, payloadId_, digest_) @@ -144,8 +139,9 @@ contract EVMxSwitchboard is SwitchboardBase { function _processAttestation(bytes32 payloadId_, bytes32 digest_, address watcher_) internal { if (isAttestedByWatcher[watcher_][payloadId_][digest_]) revert AlreadyAttested(); isAttestedByWatcher[watcher_][payloadId_][digest_] = true; - uint256 attestationCount = attestations[payloadId_][digest_]++; - if (attestationCount >= totalWatchers) isValid[payloadId_][digest_] = true; + attestations[payloadId_][digest_]++; + + if (attestations[payloadId_][digest_] >= totalWatchers) isValid[payloadId_][digest_] = true; emit Attested(payloadId_, digest_, watcher_); } @@ -246,138 +242,60 @@ contract EVMxSwitchboard is SwitchboardBase { emit PlugConfigUpdated(plug_, appGatewayId_); } - /** - * @notice Processes a single watcher signature for reverting payload - * @param payloadId_ The payload ID - * @param isReverting_ The reverting status flag - * @param watcher_ The watcher address - * @dev Checks for double signature, updates state, and sets reverting status if threshold is reached - */ - function _processRevertingPayloadSignature( - bytes32 payloadId_, - bool isReverting_, - address watcher_ - ) internal { - bytes32 key = keccak256(abi.encodePacked(payloadId_, isReverting_)); - if (isRevertingSignedByWatcher[watcher_][key]) revert AlreadyAttested(); - isRevertingSignedByWatcher[watcher_][key] = true; - uint256 signatureCount = revertingSignatures[key]++; - - if (signatureCount >= totalWatchers) { - revertingPayloadIds[payloadId_] = isReverting_; - emit RevertingPayloadIdset(payloadId_, isReverting_); - } - } - /** * @notice Sets reverting payload status with watcher signatures - * @param payloadIds_ Array of payload IDs to mark - * @param isReverting_ Array of reverting status flags - * @param nonces_ Array of nonces to prevent replay attacks - * @param signatures_ Array of watcher signatures - * @dev Processes multiple payloads in a single transaction. Each payload requires totalWatchers signatures. + * @param payloadId_ payload ID to mark + * @param isReverting_ reverting status flag + * @param nonce_ nonce to prevent replay attacks + * @param signatures_ watcher signature + * @dev Processes multiple payloads in a single transaction. Each payload requires exactly totalWatchers signatures. */ function setRevertingPayload( - bytes32[] calldata payloadIds_, - bool[] calldata isReverting_, - uint256[] calldata nonces_, - bytes[] calldata signatures_ + bytes32 payloadId_, + bool isReverting_, + uint256 nonce_, + bytes[] memory signatures_ ) external { - if ( - payloadIds_.length != isReverting_.length || - payloadIds_.length != nonces_.length || - payloadIds_.length != signatures_.length - ) revert ArrayLengthMismatch(); - - for (uint256 i = 0; i < payloadIds_.length; i++) { - bytes32 digest = keccak256( - abi.encodePacked( - toBytes32Format(address(this)), - chainSlug, - payloadIds_[i], - isReverting_[i], - nonces_[i] - ) - ); - - address watcher = _validateSignature(digest, signatures_[i], WATCHER_ROLE); - if (usedNonces[watcher][nonces_[i]]) revert NonceAlreadyUsed(); - usedNonces[watcher][nonces_[i]] = true; - - _processRevertingPayloadSignature(payloadIds_[i], isReverting_[i], watcher); - } - } + if (signatures_.length != totalWatchers) revert ArrayLengthMismatch(); + bytes32 digest = keccak256( + abi.encodePacked( + toBytes32Format(address(this)), + chainSlug, + payloadId_, + isReverting_, + nonce_ + ) + ); - /** - * @notice Processes a single watcher signature for transmitter assignment - * @param payloadId_ The payload ID - * @param newDigest_ The new digest with the new transmitter - * @param newTransmitter_ The new transmitter address - * @param watcher_ The watcher address - * @return wasJustAssigned True if the transmitter was just assigned (threshold reached) - * @dev Checks for double signature, updates state, and assigns transmitter if threshold is reached - */ - function _processTransmitterSignature( - bytes32 payloadId_, - bytes32 newDigest_, - address newTransmitter_, - address watcher_ - ) internal returns (bool wasJustAssigned) { - if (isTransmitterSignedByWatcher[watcher_][payloadId_][newDigest_]) - revert AlreadyAttested(); - isTransmitterSignedByWatcher[watcher_][payloadId_][newDigest_] = true; - uint256 signatureCount = transmitterSignatures[payloadId_][newDigest_]++; - - if (signatureCount >= totalWatchers) { - payloadIdToDigest[payloadId_] = newDigest_; - emit TransmitterAssigned(payloadId_, newTransmitter_); - return true; + for (uint256 k = 0; k < totalWatchers; k++) { + address watcher = _validateSignature(digest, signatures_[k], WATCHER_ROLE); + _validateAndUseNonce(this.setRevertingPayload.selector, watcher, nonce_); } - return false; + + revertingPayloadIds[payloadId_] = isReverting_; + emit RevertingPayloadIdSet(payloadId_, isReverting_); } - /** - * @notice Assigns transmitter addresses with watcher signatures - * @param digestParams_ Array of digest parameters - * @param oldTransmitters_ Array of old transmitter addresses - * @param newTransmitters_ Array of new transmitter addresses - * @param nonces_ Array of nonces to prevent replay attacks - * @param signatures_ Array of watcher signatures - * @dev Processes multiple transmitter assignments in a single transaction. Each assignment requires totalWatchers signatures. - */ - function assignTransmitter( - DigestParams[] calldata digestParams_, - address[] calldata oldTransmitters_, - address[] calldata newTransmitters_, - uint256[] calldata nonces_, - bytes[] calldata signatures_ - ) external { - if ( - digestParams_.length != oldTransmitters_.length || - digestParams_.length != newTransmitters_.length || - digestParams_.length != nonces_.length || - digestParams_.length != signatures_.length - ) revert ArrayLengthMismatch(); - - for (uint256 i = 0; i < digestParams_.length; i++) { - bytes32 oldTransmitterBytes32 = toBytes32Format(oldTransmitters_[i]); - DigestParams memory params = digestParams_[i]; - params.transmitter = oldTransmitterBytes32; - bytes32 oldDigest = createDigest(params); - - if (payloadIdToDigest[params.payloadId] != oldDigest) revert InvalidDigest(); - - params.transmitter = toBytes32Format(newTransmitters_[i]); - bytes32 newDigest = createDigest(params); - - bytes32 messageHash = keccak256( - abi.encodePacked(toBytes32Format(address(this)), chainSlug, oldDigest, newDigest) - ); - address watcher = _validateSignature(messageHash, signatures_[i], WATCHER_ROLE); - _validateAndUseNonce(this.assignTransmitter.selector, watcher, nonces_[i]); - - _processTransmitterSignature(params.payloadId, newDigest, newTransmitters_[i], watcher); + function assignTransmitter(AssignTransmitterParams memory params_) external { + if (params_.signatures.length != totalWatchers) revert ArrayLengthMismatch(); + + DigestParams memory dp = params_.digestParams; + dp.transmitter = toBytes32Format(params_.oldTransmitter); + bytes32 oldDigest = createDigest(dp); + if (payloadIdToDigest[dp.payloadId] != oldDigest) revert InvalidDigest(); + + dp.transmitter = toBytes32Format(params_.newTransmitter); + bytes32 newDigest = createDigest(dp); + bytes32 messageHash = keccak256( + abi.encodePacked(toBytes32Format(address(this)), chainSlug, oldDigest, newDigest) + ); + for (uint256 k = 0; k < totalWatchers; k++) { + address watcher = _validateSignature(messageHash, params_.signatures[k], WATCHER_ROLE); + _validateAndUseNonce(this.assignTransmitter.selector, watcher, params_.nonce); } + + payloadIdToDigest[dp.payloadId] = newDigest; + emit TransmitterAssigned(dp.payloadId, params_.newTransmitter); } /** @@ -400,6 +318,25 @@ contract EVMxSwitchboard is SwitchboardBase { emit DefaultDeadlineIntervalSet(defaultDeadlineInterval_); } + function _validateBatchSignatures( + bytes32 messageHash_, + bytes[] memory signatures_, + bytes32 requiredRole_ + ) internal view returns (address[] memory signers) { + signers = new address[](signatures_.length); + bytes32 ethSignedMessageHash = keccak256( + abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash_) + ); + + for (uint256 i = 0; i < signatures_.length; i++) { + address signer = ECDSA.recover(ethSignedMessageHash, signatures_[i]); + if (requiredRole_ != bytes32(0) && !_hasRole(requiredRole_, signer)) { + revert RoleNotAuthorized(requiredRole_); + } + signers[i] = signer; + } + } + /** * @inheritdoc ISwitchboard * @notice Returns the plug configuration (app gateway ID) diff --git a/contracts/protocol/switchboard/MessageSwitchboard.sol b/contracts/protocol/switchboard/MessageSwitchboard.sol index 8b62b8d4..73ade4a0 100644 --- a/contracts/protocol/switchboard/MessageSwitchboard.sol +++ b/contracts/protocol/switchboard/MessageSwitchboard.sol @@ -29,25 +29,6 @@ contract MessageSwitchboard is SwitchboardBase, ReentrancyGuard { /// @notice Mapping of payload ID and digest to validity status (true if digest is attested by enough watchers) mapping(bytes32 => mapping(bytes32 => bool)) public isValid; - /// @notice Mapping of watcher address to combined key (payloadId + isReverting) to signature status - mapping(address => mapping(bytes32 => bool)) public isRevertingSignedByWatcher; - - /// @notice Mapping of combined key (payloadId + isReverting) to signature count - mapping(bytes32 => uint256) public revertingSignatures; - - /// @notice Mapping of watcher address to payload ID and new digest to signature status - mapping(address => mapping(bytes32 => mapping(bytes32 => bool))) - public isTransmitterSignedByWatcher; - - /// @notice Mapping of payload ID and new digest to signature count - mapping(bytes32 => mapping(bytes32 => uint256)) public transmitterSignatures; - - /// @notice Mapping of watcher address to payload ID to refund eligibility signature status - mapping(address => mapping(bytes32 => bool)) public isRefundEligibleSignedByWatcher; - - /// @notice Mapping of payload ID to refund eligibility signature count - mapping(bytes32 => uint256) public refundEligibleSignatures; - /// @notice Mapping of destination chain slug to sibling socket address (bytes32 format) mapping(uint32 => bytes32) public siblingSockets; @@ -95,11 +76,19 @@ contract MessageSwitchboard is SwitchboardBase, ReentrancyGuard { /// @notice Event emitted when sponsor revokes a plug event PlugRevoked(address indexed sponsor, address indexed plug); + struct AssignTransmitterParams { + DigestParams digestParams; + address oldTransmitter; + address newTransmitter; + uint256 nonce; + bytes[] signatures; // must be totalWatchers length + } + /// @notice Event emitted when plug configuration is updated event PlugConfigUpdated(address indexed plug, uint32 indexed chainSlug, bytes32 siblingPlug); /// @notice Event emitted when refund eligibility is marked by watcher - event RefundEligibilityMarked(bytes32 indexed payloadId, address indexed watcher); + event RefundEligibilityMarked(bytes32 indexed payloadId); /// @notice Event emitted when refund is issued event Refunded(bytes32 indexed payloadId, address indexed refundAddress, uint256 amount); @@ -169,7 +158,7 @@ contract MessageSwitchboard is SwitchboardBase, ReentrancyGuard { * Reverts if any watcher is not authorized or has already attested. * Can be called by any third party as authorization happens through signatures. */ - function batchAttest(bytes32 payloadId_, bytes32 digest_, bytes[] calldata proofs_) public { + function batchAttest(bytes32 payloadId_, bytes32 digest_, bytes[] memory proofs_) public { bytes32 messageHash = keccak256( abi.encodePacked(toBytes32Format(address(this)), chainSlug, payloadId_, digest_) ); @@ -189,8 +178,8 @@ contract MessageSwitchboard is SwitchboardBase, ReentrancyGuard { function _processAttestation(bytes32 payloadId_, bytes32 digest_, address watcher_) internal { if (isAttestedByWatcher[watcher_][payloadId_][digest_]) revert AlreadyAttested(); isAttestedByWatcher[watcher_][payloadId_][digest_] = true; - uint256 attestationCount = attestations[payloadId_][digest_]++; - if (attestationCount >= totalWatchers) isValid[payloadId_][digest_] = true; + attestations[payloadId_][digest_]++; + if (attestations[payloadId_][digest_] >= totalWatchers) isValid[payloadId_][digest_] = true; emit Attested(payloadId_, digest_, watcher_); } @@ -483,37 +472,21 @@ contract MessageSwitchboard is SwitchboardBase, ReentrancyGuard { } } - /** - * @notice Processes a single watcher signature for refund eligibility - * @param payloadId_ The payload ID - * @param watcher_ The watcher address - * @dev Checks for double signature, updates state, and marks as refund eligible if threshold is reached - */ - function _processRefundEligibleSignature(bytes32 payloadId_, address watcher_) internal { - if (isRefundEligibleSignedByWatcher[watcher_][payloadId_]) revert AlreadyAttested(); - isRefundEligibleSignedByWatcher[watcher_][payloadId_] = true; - uint256 signatureCount = refundEligibleSignatures[payloadId_]++; - - if (signatureCount >= totalWatchers) { - PayloadFees storage fees = payloadFees[payloadId_]; - fees.isRefundEligible = true; - emit RefundEligibilityMarked(payloadId_, watcher_); - } - } - /** * @dev Mark a payload as eligible for refund with watcher signatures * @param payloadId_ Payload ID to mark as refund eligible * @param nonce_ Nonce to prevent replay attacks (shared across all watchers) - * @param signatures_ Array of watcher signatures - * @dev Requires totalWatchers signatures before marking as refund eligible. - * All watchers must use the same nonce. Processes multiple signatures in a single transaction. + * @param signatures_ Array of watcher signatures (must be exactly totalWatchers length) + * @dev Requires exactly totalWatchers signatures before marking as refund eligible. + * All watchers must use the same nonce. */ function markRefundEligible( bytes32 payloadId_, uint256 nonce_, bytes[] calldata signatures_ ) external { + if (signatures_.length != totalWatchers) revert ArrayLengthMismatch(); + PayloadFees storage fees = payloadFees[payloadId_]; if (fees.isRefundEligible) revert AlreadyMarkedRefundEligible(); if (fees.nativeFees == 0) revert NoFeesToRefund(); @@ -521,12 +494,14 @@ contract MessageSwitchboard is SwitchboardBase, ReentrancyGuard { bytes32 digest = keccak256( abi.encodePacked(toBytes32Format(address(this)), chainSlug, payloadId_, nonce_) ); - address[] memory watchers = _validateBatchSignatures(digest, signatures_, WATCHER_ROLE); - for (uint256 i = 0; i < watchers.length; i++) { - _validateAndUseNonce(this.markRefundEligible.selector, watchers[i], nonce_); - _processRefundEligibleSignature(payloadId_, watchers[i]); + for (uint256 i = 0; i < totalWatchers; i++) { + address watcher = _validateSignature(digest, signatures_[i], WATCHER_ROLE); + _validateAndUseNonce(this.markRefundEligible.selector, watcher, nonce_); } + + fees.isRefundEligible = true; + emit RefundEligibilityMarked(payloadId_); } /** @@ -631,57 +606,40 @@ contract MessageSwitchboard is SwitchboardBase, ReentrancyGuard { * @param watcher_ The watcher address * @dev Checks for double signature, updates state, and sets reverting status if threshold is reached */ - function _processRevertingPayloadSignature( - bytes32 payloadId_, - bool isReverting_, - address watcher_ - ) internal { - bytes32 key = keccak256(abi.encodePacked(payloadId_, isReverting_)); - if (isRevertingSignedByWatcher[watcher_][key]) revert AlreadyAttested(); - isRevertingSignedByWatcher[watcher_][key] = true; - uint256 signatureCount = revertingSignatures[key]++; - - if (signatureCount >= totalWatchers) { - revertingPayloadIds[payloadId_] = isReverting_; - emit RevertingPayloadIdset(payloadId_, isReverting_); - } - } /** * @notice Sets reverting payload status with watcher signatures - * @param payloadIds_ Array of payload IDs to mark - * @param isReverting_ Array of reverting status flags - * @param nonces_ Array of nonces to prevent replay attacks + * @param payloadId_ payload ID to mark + * @param isReverting_ reverting status flag + * @param nonce_ nonce to prevent replay attacks * @param signatures_ Array of watcher signatures * @dev Processes multiple payloads in a single transaction. Each payload requires totalWatchers signatures. */ function setRevertingPayload( - bytes32[] calldata payloadIds_, - bool[] calldata isReverting_, - uint256[] calldata nonces_, - bytes[] calldata signatures_ + bytes32 payloadId_, + bool isReverting_, + uint256 nonce_, + bytes[] memory signatures_ ) external { - if ( - payloadIds_.length != isReverting_.length || - payloadIds_.length != nonces_.length || - payloadIds_.length != signatures_.length - ) revert ArrayLengthMismatch(); - - for (uint256 i = 0; i < payloadIds_.length; i++) { - bytes32 digest = keccak256( - abi.encodePacked( - toBytes32Format(address(this)), - chainSlug, - payloadIds_[i], - isReverting_[i], - nonces_[i] - ) - ); + if (signatures_.length != totalWatchers) revert ArrayLengthMismatch(); - address watcher = _validateSignature(digest, signatures_[i], WATCHER_ROLE); - _validateAndUseNonce(this.setRevertingPayload.selector, watcher, nonces_[i]); - _processRevertingPayloadSignature(payloadIds_[i], isReverting_[i], watcher); + bytes32 digest = keccak256( + abi.encodePacked( + toBytes32Format(address(this)), + chainSlug, + payloadId_, + isReverting_, + nonce_ + ) + ); + + for (uint256 k = 0; k < totalWatchers; k++) { + address watcher = _validateSignature(digest, signatures_[k], WATCHER_ROLE); + _validateAndUseNonce(this.setRevertingPayload.selector, watcher, nonce_); } + + revertingPayloadIds[payloadId_] = isReverting_; + emit RevertingPayloadIdSet(payloadId_, isReverting_); } /** @@ -765,76 +723,33 @@ contract MessageSwitchboard is SwitchboardBase, ReentrancyGuard { _revokeRole(role_, grantee_); } - /** - * @notice Processes a single watcher signature for transmitter assignment - * @param payloadId_ The payload ID - * @param newDigest_ The new digest with the new transmitter - * @param newTransmitter_ The new transmitter address - * @param watcher_ The watcher address - * @return wasJustAssigned True if the transmitter was just assigned (threshold reached) - * @dev Checks for double signature, updates state, and assigns transmitter if threshold is reached - */ - function _processTransmitterSignature( - bytes32 payloadId_, - bytes32 newDigest_, - address newTransmitter_, - address watcher_ - ) internal returns (bool wasJustAssigned) { - if (isTransmitterSignedByWatcher[watcher_][payloadId_][newDigest_]) - revert AlreadyAttested(); - isTransmitterSignedByWatcher[watcher_][payloadId_][newDigest_] = true; - uint256 signatureCount = transmitterSignatures[payloadId_][newDigest_]++; - - if (signatureCount >= totalWatchers) { - payloadIdToDigest[payloadId_] = newDigest_; - emit TransmitterAssigned(payloadId_, newTransmitter_); - return true; - } - return false; - } - /** * @notice Batch assigns transmitter addresses with multiple watcher signatures - * @param digestParams_ Array of digest parameters - * @param oldTransmitters_ Array of old transmitter addresses - * @param newTransmitters_ Array of new transmitter addresses - * @param nonces_ Array of nonces to prevent replay attacks - * @param signatures_ Array of watcher signatures - * @dev Processes multiple transmitter assignments in a single transaction. Each assignment requires totalWatchers signatures. + * @param params_ Array of transmitter assignment params (each requires exactly totalWatchers signatures) + * @dev Processes multiple transmitter assignments in a single transaction. Each assignment requires exactly totalWatchers signatures. */ - function assignTransmitter( - DigestParams[] calldata digestParams_, - address[] calldata oldTransmitters_, - address[] calldata newTransmitters_, - uint256[] calldata nonces_, - bytes[] calldata signatures_ - ) external { - if ( - digestParams_.length != oldTransmitters_.length || - digestParams_.length != newTransmitters_.length || - digestParams_.length != nonces_.length || - digestParams_.length != signatures_.length - ) revert ArrayLengthMismatch(); - - for (uint256 i = 0; i < digestParams_.length; i++) { - bytes32 oldTransmitterBytes32 = toBytes32Format(oldTransmitters_[i]); - DigestParams memory params = digestParams_[i]; - params.transmitter = oldTransmitterBytes32; - bytes32 oldDigest = createDigest(params); - - if (payloadIdToDigest[params.payloadId] != oldDigest) revert InvalidDigest(); - - params.transmitter = toBytes32Format(newTransmitters_[i]); - bytes32 newDigest = createDigest(params); - - bytes32 messageHash = keccak256( - abi.encodePacked(toBytes32Format(address(this)), chainSlug, oldDigest, newDigest) - ); - address watcher = _validateSignature(messageHash, signatures_[i], WATCHER_ROLE); - _validateAndUseNonce(this.assignTransmitter.selector, watcher, nonces_[i]); + function assignTransmitter(AssignTransmitterParams memory params_) external { + if (params_.signatures.length != totalWatchers) revert ArrayLengthMismatch(); + + DigestParams memory dp = params_.digestParams; + dp.transmitter = toBytes32Format(params_.oldTransmitter); + + bytes32 oldDigest = createDigest(dp); + if (payloadIdToDigest[dp.payloadId] != oldDigest) revert InvalidDigest(); + dp.transmitter = toBytes32Format(params_.newTransmitter); + + bytes32 newDigest = createDigest(dp); + bytes32 messageHash = keccak256( + abi.encodePacked(toBytes32Format(address(this)), chainSlug, oldDigest, newDigest) + ); - _processTransmitterSignature(params.payloadId, newDigest, newTransmitters_[i], watcher); + for (uint256 k = 0; k < totalWatchers; k++) { + address watcher = _validateSignature(messageHash, params_.signatures[k], WATCHER_ROLE); + _validateAndUseNonce(this.assignTransmitter.selector, watcher, params_.nonce); } + + payloadIdToDigest[dp.payloadId] = newDigest; + emit TransmitterAssigned(dp.payloadId, params_.newTransmitter); } /** @@ -846,4 +761,35 @@ contract MessageSwitchboard is SwitchboardBase, ReentrancyGuard { transmitter = transmitter_; emit TransmitterSet(transmitter_); } + + function _extractSignatures( + bytes[] calldata signatures_, + uint256 startIndex_, + uint256 count_ + ) internal pure returns (bytes[] memory) { + bytes[] memory batchSignatures = new bytes[](count_); + for (uint256 k = 0; k < count_; k++) { + batchSignatures[k] = signatures_[startIndex_ + k]; + } + return batchSignatures; + } + + function _validateBatchSignatures( + bytes32 messageHash_, + bytes[] memory signatures_, + bytes32 requiredRole_ + ) internal view returns (address[] memory signers) { + signers = new address[](signatures_.length); + bytes32 ethSignedMessageHash = keccak256( + abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash_) + ); + + for (uint256 i = 0; i < signatures_.length; i++) { + address signer = ECDSA.recover(ethSignedMessageHash, signatures_[i]); + if (requiredRole_ != bytes32(0) && !_hasRole(requiredRole_, signer)) { + revert RoleNotAuthorized(requiredRole_); + } + signers[i] = signer; + } + } } diff --git a/contracts/protocol/switchboard/SwitchboardBase.sol b/contracts/protocol/switchboard/SwitchboardBase.sol index 630642a9..6115b654 100644 --- a/contracts/protocol/switchboard/SwitchboardBase.sol +++ b/contracts/protocol/switchboard/SwitchboardBase.sol @@ -53,7 +53,7 @@ abstract contract SwitchboardBase is ISwitchboard, AccessControl { event Attested(bytes32 indexed payloadId, bytes32 indexed digest, address indexed watcher); /// @notice Event emitted when reverting payload is set - event RevertingPayloadIdset(bytes32 payloadId, bool isReverting); + event RevertingPayloadIdSet(bytes32 payloadId, bool isReverting); /// @notice Event emitted when default deadline is set event DefaultDeadlineIntervalSet(uint256 defaultDeadlineInterval); @@ -141,7 +141,7 @@ abstract contract SwitchboardBase is ISwitchboard, AccessControl { */ function _validateSignature( bytes32 messageHash_, - bytes calldata signature_, + bytes memory signature_, bytes32 requiredRole_ ) internal view returns (address signer) { signer = _recoverSigner(messageHash_, signature_); @@ -150,34 +150,6 @@ abstract contract SwitchboardBase is ISwitchboard, AccessControl { } } - /** - * @notice Validates a batch of signatures and recovers signers - * @param messageHash_ The message hash that was signed - * @param signatures_ Array of signature bytes - * @param requiredRole_ The role that all signers must have (bytes32(0) to skip role check) - * @return signers Array of recovered signer addresses - * @dev Reverts if any signature is invalid or if any signer doesn't have the required role. - * Uses Ethereum signed message format for all signatures. - */ - function _validateBatchSignatures( - bytes32 messageHash_, - bytes[] calldata signatures_, - bytes32 requiredRole_ - ) internal view returns (address[] memory signers) { - signers = new address[](signatures_.length); - bytes32 ethSignedMessageHash = keccak256( - abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash_) - ); - - for (uint256 i = 0; i < signatures_.length; i++) { - address signer = ECDSA.recover(ethSignedMessageHash, signatures_[i]); - if (requiredRole_ != bytes32(0) && !_hasRole(requiredRole_, signer)) { - revert RoleNotAuthorized(requiredRole_); - } - signers[i] = signer; - } - } - // --- Rescue Functions --- /** From 76e238cf453893de1aa30b4204d959d5eaf4c9d1 Mon Sep 17 00:00:00 2001 From: Ameesha Agrawal Date: Tue, 23 Dec 2025 20:10:19 +0530 Subject: [PATCH 4/6] fix: tests --- .../protocol/switchboard/SwitchboardBase.sol | 4 +- .../switchboard/EVMxSwitchboard.t.sol | 423 +++++++++++------- .../switchboard/MessageSwitchboard.t.sol | 369 ++++++++++----- 3 files changed, 518 insertions(+), 278 deletions(-) diff --git a/contracts/protocol/switchboard/SwitchboardBase.sol b/contracts/protocol/switchboard/SwitchboardBase.sol index 6115b654..fc077749 100644 --- a/contracts/protocol/switchboard/SwitchboardBase.sol +++ b/contracts/protocol/switchboard/SwitchboardBase.sol @@ -5,7 +5,7 @@ import {ECDSA} from "solady/utils/ECDSA.sol"; import "../interfaces/ISocket.sol"; import "../interfaces/ISwitchboard.sol"; import "../../utils/AccessControl.sol"; -import {RESCUE_ROLE, GOVERNANCE_ROLE, WATCHER_ROLE} from "../../utils/common/AccessRoles.sol"; +import {RESCUE_ROLE, GOVERNANCE_ROLE, WATCHER_ROLE, FEE_UPDATER_ROLE} from "../../utils/common/AccessRoles.sol"; import {WRITE} from "../../utils/common/Constants.sol"; import {toBytes32Format} from "../../utils/common/Converters.sol"; import {createDigest} from "../../utils/common/DigestUtils.sol"; @@ -146,6 +146,8 @@ abstract contract SwitchboardBase is ISwitchboard, AccessControl { ) internal view returns (address signer) { signer = _recoverSigner(messageHash_, signature_); if (requiredRole_ != bytes32(0) && !_hasRole(requiredRole_, signer)) { + if (requiredRole_ == WATCHER_ROLE) revert WatcherNotFound(); + if (requiredRole_ == FEE_UPDATER_ROLE) revert UnauthorizedFeeUpdater(); revert RoleNotAuthorized(requiredRole_); } } diff --git a/test/protocol/switchboard/EVMxSwitchboard.t.sol b/test/protocol/switchboard/EVMxSwitchboard.t.sol index 6b04173f..dd2351a6 100644 --- a/test/protocol/switchboard/EVMxSwitchboard.t.sol +++ b/test/protocol/switchboard/EVMxSwitchboard.t.sol @@ -29,8 +29,10 @@ contract EVMxSwitchboardTestBase is Test, Utils { address plugOwner = address(0x2000); address watcher = address(0x3000); - // Private key for watcher signing + // Private keys for watcher signing uint256 watcherPrivateKey = 0x1111111111111111111111111111111111111111111111111111111111111111; + uint256 watcher2PrivateKey = 0x2222222222222222222222222222222222222222222222222222222222222222; + uint256 watcher3PrivateKey = 0x3333333333333333333333333333333333333333333333333333333333333333; Socket socket; EVMxSwitchboard evmxSwitchboard; @@ -186,6 +188,31 @@ contract EVMxSwitchboardTestBase is Test, Utils { return vm.addr(watcherPrivateKey); } + /** + * @dev Helper to get watcher2 address from private key + */ + function getWatcher2Address() public view returns (address) { + return vm.addr(watcher2PrivateKey); + } + + /** + * @dev Helper to get watcher3 address from private key + */ + function getWatcher3Address() public view returns (address) { + return vm.addr(watcher3PrivateKey); + } + + /** + * @dev Helper to get all watcher addresses + */ + function getAllWatcherAddresses() public view returns (address[] memory) { + address[] memory watchers = new address[](3); + watchers[0] = getWatcherAddress(); + watchers[1] = getWatcher2Address(); + watchers[2] = getWatcher3Address(); + return watchers; + } + /** * @dev Helper to create signature for attest function */ @@ -204,6 +231,50 @@ contract EVMxSwitchboardTestBase is Test, Utils { return createSignature(signatureDigest, watcherPrivateKey); } + /** + * @dev Helper to create batch signatures for setRevertingPayload + */ + function _createSetRevertingPayloadSignatures( + bytes32 payloadId_, + bool isReverting_, + uint256 nonce_ + ) internal view returns (bytes[] memory) { + bytes32 digest = keccak256( + abi.encodePacked( + toBytes32Format(address(evmxSwitchboard)), + CHAIN_SLUG, + payloadId_, + isReverting_, + nonce_ + ) + ); + bytes[] memory signatures = new bytes[](1); + signatures[0] = createSignature(digest, watcherPrivateKey); + return signatures; + } + + /** + * @dev Helper to create batch signatures for assignTransmitter + */ + function _createAssignTransmitterBatchSignatures( + bytes32 oldDigest_, + bytes32 newDigest_ + ) internal view returns (bytes[] memory) { + bytes32 messageHash = keccak256( + abi.encodePacked( + toBytes32Format(address(evmxSwitchboard)), + CHAIN_SLUG, + oldDigest_, + newDigest_ + ) + ); + bytes[] memory signatures = new bytes[](3); + signatures[0] = createSignature(messageHash, watcherPrivateKey); + signatures[1] = createSignature(messageHash, watcher2PrivateKey); + signatures[2] = createSignature(messageHash, watcher3PrivateKey); + return signatures; + } + /** * @dev Helper to setup payload for assignTransmitter tests * @return payloadId The created payload ID @@ -303,6 +374,13 @@ contract EVMxSwitchboardTestBase is Test, Utils { ); signature = createSignature(signatureDigest, watcherPrivateKey); } + + function addWatchers() internal { + hoax(owner); + evmxSwitchboard.grantWatcherRole(getWatcher2Address()); + hoax(owner); + evmxSwitchboard.grantWatcherRole(getWatcher3Address()); + } } /** @@ -593,7 +671,7 @@ contract SocketPayloadIdVerificationTest is EVMxSwitchboardTestBase { digest ) ); - uint256 invalidPrivateKey = 0x2222222222222222222222222222222222222222222222222222222222222222; + uint256 invalidPrivateKey = 0x4444444444444444444444444444444444444444444444444444444444444444; bytes memory invalidSignature = createSignature(signatureDigest, invalidPrivateKey); vm.prank(vm.addr(invalidPrivateKey)); @@ -737,33 +815,33 @@ contract SocketPayloadIdVerificationTest is EVMxSwitchboardTestBase { bytes32 payloadId = bytes32(uint256(0x1234)); bool isReverting = true; uint256 nonce = 1; - bytes32 digest = keccak256( - abi.encodePacked( - toBytes32Format(address(evmxSwitchboard)), - CHAIN_SLUG, - payloadId, - isReverting, - nonce - ) + bytes[] memory signatures = _createSetRevertingPayloadSignatures( + payloadId, + isReverting, + nonce ); - bytes memory signature = createSignature(digest, watcherPrivateKey); vm.expectEmit(true, false, false, true); - emit SwitchboardBase.RevertingPayloadIdset(payloadId, isReverting); + emit SwitchboardBase.RevertingPayloadIdSet(payloadId, isReverting); + evmxSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signatures); + assertTrue(evmxSwitchboard.revertingPayloadIds(payloadId)); + } - vm.prank(getWatcherAddress()); - evmxSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signature); + function test_SetRevertingPayload_WrongSignatureCount_Reverts() public { + bytes32 payloadId = bytes32(uint256(0x1234)); + bool isReverting = true; + uint256 nonce = 1; + bytes[] memory signatures = new bytes[](2); // Wrong count - should be totalWatchers - // Verify it was set (check via allowPayload or directly if there's a getter) - // Note: revertingPayloadIds is internal, so we can't directly check it - // But we can verify the event was emitted + vm.expectRevert(ArrayLengthMismatch.selector); + evmxSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signatures); } - function test_SetRevertingPayload_OnlyOwner() public { + function test_SetRevertingPayload_InvalidWatcher_Reverts() public { bytes32 payloadId = bytes32(uint256(0x1234)); bool isReverting = true; - uint256 nonce = 1; + bytes32 digest = keccak256( abi.encodePacked( toBytes32Format(address(evmxSwitchboard)), @@ -773,10 +851,11 @@ contract SocketPayloadIdVerificationTest is EVMxSwitchboardTestBase { nonce ) ); - bytes memory signature = createSignature(digest, uint256(0x1234)); + bytes[] memory signatures = new bytes[](1); + signatures[0] = createSignature(digest, uint256(0x1234)); // Invalid watcher - vm.expectRevert(); - evmxSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signature); + vm.expectRevert(WatcherNotFound.selector); + evmxSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signatures); } // ============================================ @@ -996,11 +1075,12 @@ contract SocketPayloadIdVerificationTest is EVMxSwitchboardTestBase { function test_grantWatcherRole_Success() public { address newWatcher = address(0x5000); + uint256 totalBefore = evmxSwitchboard.totalWatchers(); vm.prank(owner); evmxSwitchboard.grantWatcherRole(newWatcher); assertTrue(evmxSwitchboard.hasRole(WATCHER_ROLE, newWatcher)); - assertEq(evmxSwitchboard.totalWatchers(), 2); // 1 from setUp + 1 new + assertEq(evmxSwitchboard.totalWatchers(), totalBefore + 1); } function test_grantWatcherRole_WatcherFound_Reverts() public { @@ -1078,44 +1158,55 @@ contract SocketPayloadIdVerificationTest is EVMxSwitchboardTestBase { bytes32 payloadId = bytes32(uint256(0x1234)); bool isReverting = true; uint256 nonce = 1; - bytes32 digest = keccak256( - abi.encodePacked( - toBytes32Format(address(evmxSwitchboard)), - CHAIN_SLUG, - payloadId, - isReverting, - nonce - ) + + bytes[] memory signatures = _createSetRevertingPayloadSignatures( + payloadId, + isReverting, + nonce ); - bytes memory signature = createSignature(digest, watcherPrivateKey); // First call succeeds - vm.prank(getWatcherAddress()); - evmxSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signature); + evmxSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signatures); - // Second call with same nonce should revert - vm.prank(getWatcherAddress()); + // Second call with same nonce should revert (nonce already used by first watcher) vm.expectRevert(NonceAlreadyUsed.selector); - evmxSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signature); + evmxSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signatures); } // ============================================ // MISSING TESTS - Attest with Multiple Watchers // ============================================ - function test_Attest_ReachesThreshold_SetsIsValid() public { - // Grant watcher role to 2 more watchers (total 3) - // Use addresses derived from private keys to match signatures - uint256 watcher2Key = 0x2222222222222222222222222222222222222222222222222222222222222222; - uint256 watcher3Key = 0x3333333333333333333333333333333333333333333333333333333333333333; - address watcher2 = vm.addr(watcher2Key); - address watcher3 = vm.addr(watcher3Key); + function test_BatchAttest_Success() public { + addWatchers(); + assertEq(evmxSwitchboard.totalWatchers(), 3); + bytes32 digest = keccak256(abi.encode("test payload")); + bytes32 payloadId = bytes32(uint256(0x1234)); - vm.startPrank(owner); - evmxSwitchboard.grantWatcherRole(watcher2); - evmxSwitchboard.grantWatcherRole(watcher3); - vm.stopPrank(); + bytes[] memory signatures = new bytes[](3); + bytes32 signatureDigest = keccak256( + abi.encodePacked( + toBytes32Format(address(evmxSwitchboard)), + CHAIN_SLUG, + payloadId, + digest + ) + ); + signatures[0] = createSignature(signatureDigest, watcherPrivateKey); + signatures[1] = createSignature(signatureDigest, watcher2PrivateKey); + signatures[2] = createSignature(signatureDigest, watcher3PrivateKey); + + vm.expectEmit(true, false, true, true); + emit SwitchboardBase.Attested(payloadId, digest, getWatcherAddress()); + + evmxSwitchboard.batchAttest(payloadId, digest, signatures); + assertTrue(evmxSwitchboard.isValid(payloadId, digest)); + assertEq(evmxSwitchboard.attestations(payloadId, digest), 3); + } + + function test_Attest_ReachesThreshold_SetsIsValid() public { + addWatchers(); assertEq(evmxSwitchboard.totalWatchers(), 3); bytes32 digest = keccak256(abi.encode("test payload")); @@ -1136,14 +1227,14 @@ contract SocketPayloadIdVerificationTest is EVMxSwitchboardTestBase { digest ) ); - bytes memory signature2 = createSignature(signatureDigest, watcher2Key); - vm.prank(watcher2); + bytes memory signature2 = createSignature(signatureDigest, watcher2PrivateKey); + vm.prank(getWatcher2Address()); evmxSwitchboard.attest(payloadId, digest, signature2); assertFalse(evmxSwitchboard.isValid(payloadId, digest)); // Still not enough // Third attestation - should set isValid to true - bytes memory signature3 = createSignature(signatureDigest, watcher3Key); - vm.prank(watcher3); + bytes memory signature3 = createSignature(signatureDigest, watcher3PrivateKey); + vm.prank(getWatcher3Address()); evmxSwitchboard.attest(payloadId, digest, signature3); assertTrue(evmxSwitchboard.isValid(payloadId, digest)); // Now valid! assertEq(evmxSwitchboard.attestations(payloadId, digest), 3); @@ -1154,7 +1245,6 @@ contract SocketPayloadIdVerificationTest is EVMxSwitchboardTestBase { // ============================================ function test_AssignTransmitter_Success() public { - // Setup payload ( bytes32 payloadId, MockPlug triggerPlug, @@ -1162,51 +1252,88 @@ contract SocketPayloadIdVerificationTest is EVMxSwitchboardTestBase { bytes memory payload ) = _setupPayloadForAssignTransmitter(); - // Get the stored digest bytes32 storedDigest = evmxSwitchboard.payloadIdToDigest(payloadId); - - address oldTransmitter = address(0); // Initial transmitter is address(0) address newTransmitter = address(0x5000); - uint256 nonce = 1; - - // Create digest params with old transmitter DigestParams memory digestParams = _createAssignTransmitterDigestParams( payloadId, address(triggerPlug), appGatewayId, payload, - oldTransmitter + address(0) ); - // Verify old digest matches stored digest bytes32 oldDigest = createDigest(digestParams); assertEq(oldDigest, storedDigest, "Old digest should match stored digest"); - // Create signature for new transmitter - bytes memory signature = _createAssignTransmitterSignature(digestParams, newTransmitter); - - // Create new digest for verification digestParams.transmitter = toBytes32Format(newTransmitter); bytes32 newDigest = createDigest(digestParams); - digestParams.transmitter = toBytes32Format(oldTransmitter); - // Expect event (2 indexed parameters: payloadId and transmitter) + bytes32 messageHash = keccak256( + abi.encodePacked( + toBytes32Format(address(evmxSwitchboard)), + CHAIN_SLUG, + oldDigest, + newDigest + ) + ); + bytes[] memory signatures = new bytes[](1); + signatures[0] = createSignature(messageHash, watcherPrivateKey); + vm.expectEmit(true, true, false, true); emit EVMxSwitchboard.TransmitterAssigned(payloadId, newTransmitter); - // Call assignTransmitter - vm.prank(getWatcherAddress()); - evmxSwitchboard.assignTransmitter( - digestParams, - oldTransmitter, - newTransmitter, - nonce, - signature + digestParams.transmitter = toBytes32Format(address(0)); + _callAssignTransmitter(digestParams, address(0), newTransmitter, 1, signatures); + + assertEq( + evmxSwitchboard.payloadIdToDigest(payloadId), + newDigest, + "Digest should be updated with new transmitter" + ); + } + + function test_AssignTransmitter_WrongSignatureCount_Reverts() public { + ( + bytes32 payloadId, + MockPlug triggerPlug, + bytes32 appGatewayId, + bytes memory payload + ) = _setupPayloadForAssignTransmitter(); + + DigestParams memory digestParams = _createAssignTransmitterDigestParams( + payloadId, + address(triggerPlug), + appGatewayId, + payload, + address(0) ); + bytes32 oldDigest = createDigest(digestParams); + digestParams.transmitter = toBytes32Format(address(0x5000)); + bytes32 newDigest = createDigest(digestParams); + bytes[] memory signatures = new bytes[](2); // Wrong count - should be 3 + + digestParams.transmitter = toBytes32Format(address(0)); + vm.expectRevert(ArrayLengthMismatch.selector); + _callAssignTransmitter(digestParams, address(0), address(0x5000), 1, signatures); + } - // Verify digest was updated - bytes32 updatedDigest = evmxSwitchboard.payloadIdToDigest(payloadId); - assertEq(updatedDigest, newDigest, "Digest should be updated with new transmitter"); + function _callAssignTransmitter( + DigestParams memory digestParams_, + address oldTransmitter_, + address newTransmitter_, + uint256 nonce_, + bytes[] memory signatures_ + ) internal { + EVMxSwitchboard.AssignTransmitterParams memory params = EVMxSwitchboard + .AssignTransmitterParams({ + digestParams: digestParams_, + oldTransmitter: oldTransmitter_, + newTransmitter: newTransmitter_, + nonce: nonce_, + signatures: signatures_ + }); + + evmxSwitchboard.assignTransmitter(params); } function test_AssignTransmitter_InvalidOldDigest_Reverts() public { @@ -1232,23 +1359,33 @@ contract SocketPayloadIdVerificationTest is EVMxSwitchboardTestBase { wrongOldTransmitter ); - // Create signature for new transmitter - bytes memory signature = _createAssignTransmitterSignature(digestParams, newTransmitter); + bytes32 oldDigest = createDigest(digestParams); + digestParams.transmitter = toBytes32Format(newTransmitter); + bytes32 newDigest = createDigest(digestParams); + + bytes32 messageHash = keccak256( + abi.encodePacked( + toBytes32Format(address(evmxSwitchboard)), + CHAIN_SLUG, + oldDigest, + newDigest + ) + ); + bytes[] memory signatures = new bytes[](1); + signatures[0] = createSignature(messageHash, watcherPrivateKey); - // Should revert because old digest doesn't match stored digest - vm.prank(getWatcherAddress()); + digestParams.transmitter = toBytes32Format(wrongOldTransmitter); vm.expectRevert(InvalidDigest.selector); - evmxSwitchboard.assignTransmitter( + _callAssignTransmitter( digestParams, wrongOldTransmitter, newTransmitter, nonce, - signature + signatures ); } function test_AssignTransmitter_InvalidWatcher_Reverts() public { - // Setup payload ( bytes32 payloadId, MockPlug triggerPlug, @@ -1256,32 +1393,21 @@ contract SocketPayloadIdVerificationTest is EVMxSwitchboardTestBase { bytes memory payload ) = _setupPayloadForAssignTransmitter(); - address oldTransmitter = address(0); - address newTransmitter = address(0x5000); - uint256 nonce = 1; - - // Create digest params with old transmitter DigestParams memory digestParams = _createAssignTransmitterDigestParams( payloadId, address(triggerPlug), appGatewayId, payload, - oldTransmitter + address(0) ); - // Create signature with non-watcher private key - uint256 nonWatcherKey = 0x2222222222222222222222222222222222222222222222222222222222222222; - address nonWatcher = vm.addr(nonWatcherKey); - - // Create old digest with old transmitter bytes32 oldDigest = createDigest(digestParams); - - // Create new digest with new transmitter - digestParams.transmitter = toBytes32Format(newTransmitter); + digestParams.transmitter = toBytes32Format(address(0x5000)); bytes32 newDigest = createDigest(digestParams); + digestParams.transmitter = toBytes32Format(address(0)); - // Create signature digest with both old and new digests with non-watcher key - bytes32 signatureDigest = keccak256( + uint256 nonWatcherKey = 0x9999999999999999999999999999999999999999999999999999999999999999; + bytes32 messageHash = keccak256( abi.encodePacked( toBytes32Format(address(evmxSwitchboard)), CHAIN_SLUG, @@ -1289,25 +1415,14 @@ contract SocketPayloadIdVerificationTest is EVMxSwitchboardTestBase { newDigest ) ); - bytes memory signature = createSignature(signatureDigest, nonWatcherKey); + bytes[] memory signatures = new bytes[](1); + signatures[0] = createSignature(messageHash, nonWatcherKey); - // Reset transmitter for the function call - digestParams.transmitter = toBytes32Format(oldTransmitter); - - // Should revert because signer is not a watcher - vm.prank(nonWatcher); vm.expectRevert(WatcherNotFound.selector); - evmxSwitchboard.assignTransmitter( - digestParams, - oldTransmitter, - newTransmitter, - nonce, - signature - ); + _callAssignTransmitter(digestParams, address(0), address(0x5000), 1, signatures); } function test_AssignTransmitter_NonceAlreadyUsed_Reverts() public { - // Setup payload ( bytes32 payloadId, MockPlug triggerPlug, @@ -1315,60 +1430,54 @@ contract SocketPayloadIdVerificationTest is EVMxSwitchboardTestBase { bytes memory payload ) = _setupPayloadForAssignTransmitter(); - address oldTransmitter = address(0); - address newTransmitter = address(0x5000); - uint256 nonce = 1; - - // Create digest params with old transmitter DigestParams memory digestParams = _createAssignTransmitterDigestParams( payloadId, address(triggerPlug), appGatewayId, payload, - oldTransmitter + address(0) ); - // Create signature for new transmitter - bytes memory signature = _createAssignTransmitterSignature(digestParams, newTransmitter); + bytes32 oldDigest = createDigest(digestParams); + digestParams.transmitter = toBytes32Format(address(0x5000)); + bytes32 newDigest = createDigest(digestParams); - // First call succeeds - vm.prank(getWatcherAddress()); - evmxSwitchboard.assignTransmitter( - digestParams, - oldTransmitter, - newTransmitter, - nonce, - signature + bytes32 messageHash = keccak256( + abi.encodePacked( + toBytes32Format(address(evmxSwitchboard)), + CHAIN_SLUG, + oldDigest, + newDigest + ) ); + bytes[] memory signatures = new bytes[](1); + signatures[0] = createSignature(messageHash, watcherPrivateKey); - // Second call with same nonce should revert - // Need to update oldTransmitter to the new one since we already assigned it - address updatedOldTransmitter = newTransmitter; - address anotherNewTransmitter = address(0x6000); - - // Create new digest params with updated old transmitter - DigestParams memory updatedDigestParams = _createAssignTransmitterDigestParams( - payloadId, - address(triggerPlug), - appGatewayId, - payload, - updatedOldTransmitter - ); + digestParams.transmitter = toBytes32Format(address(0)); + _callAssignTransmitter(digestParams, address(0), address(0x5000), 1, signatures); - // Create signature for the new assignment - bytes memory signature2 = _createAssignTransmitterSignature( - updatedDigestParams, - anotherNewTransmitter - ); + vm.expectRevert(InvalidDigest.selector); + _callAssignTransmitter(digestParams, address(0), address(0x5000), 1, signatures); + } - vm.prank(getWatcherAddress()); - vm.expectRevert(NonceAlreadyUsed.selector); - evmxSwitchboard.assignTransmitter( - updatedDigestParams, - updatedOldTransmitter, - anotherNewTransmitter, - nonce, // Same nonce - should revert - signature2 + function _setupSecondPayloadForAssignTransmitter() internal returns (bytes32 payloadId2) { + MockPlug triggerPlug2 = _createTriggerPlug(); + bytes32 appGatewayId2 = toBytes32Format(address(0x5678)); + bytes memory plugConfig2 = abi.encode(appGatewayId2); + vm.prank(address(socket)); + evmxSwitchboard.updatePlugConfig(address(triggerPlug2), plugConfig2); + bytes memory payload2 = abi.encode("test2"); + EVMxOverrides memory overridesParams = EVMxOverrides({ + gasLimit: 0, + deadline: 0, + maxFees: 0 + }); + bytes memory overrides = abi.encode(overridesParams); + vm.prank(address(socket)); + payloadId2 = evmxSwitchboard.processPayload{value: 0}( + address(triggerPlug2), + payload2, + overrides ); } } diff --git a/test/protocol/switchboard/MessageSwitchboard.t.sol b/test/protocol/switchboard/MessageSwitchboard.t.sol index 7437580d..aac418de 100644 --- a/test/protocol/switchboard/MessageSwitchboard.t.sol +++ b/test/protocol/switchboard/MessageSwitchboard.t.sol @@ -27,6 +27,8 @@ contract MessageSwitchboardTest is Test, Utils { // Private keys for signing uint256 watcherPrivateKey = 0x1111111111111111111111111111111111111111111111111111111111111111; + uint256 watcher2PrivateKey = 0x2222222222222222222222222222222222222222222222222222222222222222; + uint256 watcher3PrivateKey = 0x3333333333333333333333333333333333333333333333333333333333333333; uint256 feeUpdaterPrivateKey = 0x5555555555555555555555555555555555555555555555555555555555555555; @@ -64,6 +66,16 @@ contract MessageSwitchboardTest is Test, Utils { return vm.addr(0x1111111111111111111111111111111111111111111111111111111111111111); } + // Helper to get watcher2 address + function getWatcher2Address() public pure returns (address) { + return vm.addr(0x2222222222222222222222222222222222222222222222222222222222222222); + } + + // Helper to get watcher3 address + function getWatcher3Address() public pure returns (address) { + return vm.addr(0x3333333333333333333333333333333333333333333333333333333333333333); + } + // Helper to get fee updater address from private key function getFeeUpdaterAddress() public view returns (address) { return vm.addr(feeUpdaterPrivateKey); @@ -366,6 +378,65 @@ contract MessageSwitchboardTest is Test, Utils { return createSignature(digest, watcherPrivateKey); } + function _createMarkRefundEligibleBatchSignatures( + bytes32 payloadId, + uint256 nonce + ) internal view returns (bytes[] memory) { + bytes32 digest = keccak256( + abi.encodePacked( + toBytes32Format(address(messageSwitchboard)), + SRC_CHAIN, + payloadId, + nonce + ) + ); + bytes[] memory signatures = new bytes[](3); + signatures[0] = createSignature(digest, watcherPrivateKey); + signatures[1] = createSignature(digest, watcher2PrivateKey); + signatures[2] = createSignature(digest, watcher3PrivateKey); + return signatures; + } + + function _createSetRevertingPayloadBatchSignatures( + bytes32 payloadId, + bool isReverting, + uint256 nonce + ) internal view returns (bytes[] memory) { + bytes32 digest = keccak256( + abi.encodePacked( + toBytes32Format(address(messageSwitchboard)), + SRC_CHAIN, + payloadId, + isReverting, + nonce + ) + ); + bytes[] memory signatures = new bytes[](3); + signatures[0] = createSignature(digest, watcherPrivateKey); + signatures[1] = createSignature(digest, watcher2PrivateKey); + signatures[2] = createSignature(digest, watcher3PrivateKey); + return signatures; + } + + function _createAssignTransmitterBatchSignatures( + bytes32 oldDigest, + bytes32 newDigest + ) internal view returns (bytes[] memory) { + bytes32 messageHash = keccak256( + abi.encodePacked( + toBytes32Format(address(messageSwitchboard)), + SRC_CHAIN, + oldDigest, + newDigest + ) + ); + bytes[] memory signatures = new bytes[](3); + signatures[0] = createSignature(messageHash, watcherPrivateKey); + signatures[1] = createSignature(messageHash, watcher2PrivateKey); + signatures[2] = createSignature(messageHash, watcher3PrivateKey); + return signatures; + } + /** * @dev Create watcher signature for a given payload ID (backwards compatibility, uses nonce 0) * @param payloadId The payload ID to sign @@ -971,37 +1042,48 @@ contract MessageSwitchboardTest is Test, Utils { // Mark eligible with nonce uint256 nonce = 1; - bytes memory signature = _createWatcherSignature(payloadId, nonce); + bytes[] memory signatures = new bytes[](1); + signatures[0] = _createWatcherSignature(payloadId, nonce); vm.expectEmit(true, true, false, false); - emit MessageSwitchboard.RefundEligibilityMarked(payloadId, getWatcherAddress()); + emit MessageSwitchboard.RefundEligibilityMarked(payloadId); - vm.prank(getWatcherAddress()); - messageSwitchboard.markRefundEligible(payloadId, nonce, signature); + messageSwitchboard.markRefundEligible(payloadId, nonce, signatures); // Verify marked eligible (, , bool isEligible, , ) = messageSwitchboard.payloadFees(payloadId); assertTrue(isEligible); - // Verify nonce was used + // Verify nonces were used uint256 namespacedNonce = uint256( keccak256(abi.encodePacked(messageSwitchboard.markRefundEligible.selector, nonce)) ); assertTrue(messageSwitchboard.usedNonces(getWatcherAddress(), namespacedNonce)); + // Only the single configured watcher should have its nonce used + } + + function test_markRefundEligible_WrongSignatureCount_Reverts() public { + _setupCompleteNative(); + bytes32 payloadId = _createNativePayload("test", MIN_FEES); + uint256 nonce = 1; + bytes[] memory signatures = new bytes[](2); // Wrong count - should be 3 + + vm.expectRevert(ArrayLengthMismatch.selector); + messageSwitchboard.markRefundEligible(payloadId, nonce, signatures); } function test_markRefundEligible_NoFeesToRefund_Reverts() public { // Create a non-existent payloadId (one that was never created) bytes32 payloadId = bytes32(uint256(0x9999)); - // Create valid watcher signature (this will pass watcher check) + // Create valid watcher signatures (this will pass watcher check) uint256 nonce = 1; - bytes memory signature = _createWatcherSignature(payloadId, nonce); + bytes[] memory signatures = new bytes[](1); + signatures[0] = _createWatcherSignature(payloadId, nonce); // Should revert with NoFeesToRefund because payload doesn't exist - vm.prank(getWatcherAddress()); vm.expectRevert(NoFeesToRefund.selector); - messageSwitchboard.markRefundEligible(payloadId, nonce, signature); + messageSwitchboard.markRefundEligible(payloadId, nonce, signatures); } function test_markRefundEligible_AlreadyMarkedRefundEligible_Reverts() public { @@ -1011,16 +1093,16 @@ contract MessageSwitchboardTest is Test, Utils { // Mark eligible first time uint256 nonce = 1; - bytes memory signature = _createWatcherSignature(payloadId, nonce); - vm.prank(getWatcherAddress()); - messageSwitchboard.markRefundEligible(payloadId, nonce, signature); + bytes[] memory signatures = new bytes[](1); + signatures[0] = _createWatcherSignature(payloadId, nonce); + messageSwitchboard.markRefundEligible(payloadId, nonce, signatures); // Try to mark eligible again with different nonce - should revert (already marked) uint256 nonce2 = 2; - bytes memory signature2 = _createWatcherSignature(payloadId, nonce2); - vm.prank(getWatcherAddress()); + bytes[] memory signatures2 = new bytes[](1); + signatures2[0] = _createWatcherSignature(payloadId, nonce2); vm.expectRevert(AlreadyMarkedRefundEligible.selector); - messageSwitchboard.markRefundEligible(payloadId, nonce2, signature2); + messageSwitchboard.markRefundEligible(payloadId, nonce2, signatures2); } function test_markRefundEligible_NonceAlreadyUsed_Reverts() public { @@ -1031,15 +1113,15 @@ contract MessageSwitchboardTest is Test, Utils { // Mark first payload eligible with nonce uint256 nonce = 1; - bytes memory signature1 = _createWatcherSignature(payloadId1, nonce); - vm.prank(getWatcherAddress()); - messageSwitchboard.markRefundEligible(payloadId1, nonce, signature1); + bytes[] memory signatures1 = new bytes[](1); + signatures1[0] = _createWatcherSignature(payloadId1, nonce); + messageSwitchboard.markRefundEligible(payloadId1, nonce, signatures1); // Try to use the same nonce again on a different payload - should revert with NonceAlreadyUsed - bytes memory signature2 = _createWatcherSignature(payloadId2, nonce); - vm.prank(getWatcherAddress()); + bytes[] memory signatures2 = new bytes[](1); + signatures2[0] = _createWatcherSignature(payloadId2, nonce); vm.expectRevert(NonceAlreadyUsed.selector); - messageSwitchboard.markRefundEligible(payloadId2, nonce, signature2); + messageSwitchboard.markRefundEligible(payloadId2, nonce, signatures2); } function test_markRefundEligible_AfterRefund_Reverts() public { @@ -1049,9 +1131,9 @@ contract MessageSwitchboardTest is Test, Utils { // Mark eligible and refund uint256 nonce = 1; - bytes memory signature = _createWatcherSignature(payloadId, nonce); - vm.prank(getWatcherAddress()); - messageSwitchboard.markRefundEligible(payloadId, nonce, signature); + bytes[] memory signatures = new bytes[](1); + signatures[0] = _createWatcherSignature(payloadId, nonce); + messageSwitchboard.markRefundEligible(payloadId, nonce, signatures); vm.deal(address(messageSwitchboard), MIN_FEES); vm.prank(refundAddress); @@ -1060,10 +1142,10 @@ contract MessageSwitchboardTest is Test, Utils { // After refund, isRefundEligible is still true, so trying to mark eligible again // will revert with AlreadyMarkedRefundEligible (line 429), not AlreadyRefunded (line 430) uint256 nonce2 = 2; - bytes memory signature2 = _createWatcherSignature(payloadId, nonce2); - vm.prank(getWatcherAddress()); + bytes[] memory signatures2 = new bytes[](1); + signatures2[0] = _createWatcherSignature(payloadId, nonce2); vm.expectRevert(AlreadyMarkedRefundEligible.selector); - messageSwitchboard.markRefundEligible(payloadId, nonce2, signature2); + messageSwitchboard.markRefundEligible(payloadId, nonce2, signatures2); } function test_markRefundEligible_InvalidWatcher_Reverts() public { @@ -1084,12 +1166,12 @@ contract MessageSwitchboardTest is Test, Utils { nonce ) ); - bytes memory signature = createSignature(digest, nonWatcherPrivateKey); + bytes[] memory signatures = new bytes[](1); + signatures[0] = createSignature(digest, nonWatcherPrivateKey); // Should revert with WatcherNotFound at line 460 - vm.prank(nonWatcher); vm.expectRevert(WatcherNotFound.selector); - messageSwitchboard.markRefundEligible(payloadId, nonce, signature); + messageSwitchboard.markRefundEligible(payloadId, nonce, signatures); } function test_refund_Success() public { @@ -1100,9 +1182,9 @@ contract MessageSwitchboardTest is Test, Utils { // Mark eligible uint256 nonce = 1; - bytes memory signature = _createWatcherSignature(payloadId, nonce); - vm.prank(getWatcherAddress()); - messageSwitchboard.markRefundEligible(payloadId, nonce, signature); + bytes[] memory signatures = new bytes[](1); + signatures[0] = _createWatcherSignature(payloadId, nonce); + messageSwitchboard.markRefundEligible(payloadId, nonce, signatures); // Refund uint256 balanceBefore = refundAddress.balance; @@ -1136,9 +1218,9 @@ contract MessageSwitchboardTest is Test, Utils { // Mark eligible and refund once uint256 nonce = 1; - bytes memory signature = _createWatcherSignature(payloadId, nonce); - vm.prank(getWatcherAddress()); - messageSwitchboard.markRefundEligible(payloadId, nonce, signature); + bytes[] memory signatures = new bytes[](1); + signatures[0] = _createWatcherSignature(payloadId, nonce); + messageSwitchboard.markRefundEligible(payloadId, nonce, signatures); vm.deal(address(messageSwitchboard), MIN_FEES); vm.prank(refundAddress); @@ -1419,7 +1501,6 @@ contract MessageSwitchboardTest is Test, Utils { bytes32 payloadId = bytes32(uint256(0x1234)); bool isReverting = true; uint256 nonce = 1; - bytes32 digest = keccak256( abi.encodePacked( toBytes32Format(address(messageSwitchboard)), @@ -1429,35 +1510,53 @@ contract MessageSwitchboardTest is Test, Utils { nonce ) ); - bytes memory signature = createSignature(digest, watcherPrivateKey); + bytes[] memory signatures = new bytes[](1); + signatures[0] = createSignature(digest, watcherPrivateKey); vm.expectEmit(true, true, false, true); - emit SwitchboardBase.RevertingPayloadIdset(payloadId, isReverting); + emit SwitchboardBase.RevertingPayloadIdSet(payloadId, isReverting); - vm.prank(getWatcherAddress()); - messageSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signature); + messageSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signatures); assertTrue(messageSwitchboard.revertingPayloadIds(payloadId)); } - function test_setRevertingPayload_NotOwner_Reverts() public { + function test_setRevertingPayload_WrongSignatureCount_Reverts() public { + bytes32 payloadId = bytes32(uint256(0x1234)); + bool isReverting = true; + uint256 nonce = 1; + bytes[] memory signatures = new bytes[](2); // Wrong count - should be 3 + + vm.expectRevert(ArrayLengthMismatch.selector); + messageSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signatures); + } + + function test_setRevertingPayload_InvalidWatcher_Reverts() public { bytes32 payloadId = bytes32(uint256(0x1234)); bool isReverting = true; uint256 nonce = 1; bytes32 digest = keccak256( - abi.encodePacked(address(messageSwitchboard), SRC_CHAIN, payloadId, isReverting, nonce) + abi.encodePacked( + toBytes32Format(address(messageSwitchboard)), + SRC_CHAIN, + payloadId, + isReverting, + nonce + ) ); - bytes memory signature = createSignature(digest, feeUpdaterPrivateKey); + bytes[] memory signatures = new bytes[](1); + signatures[0] = createSignature(digest, feeUpdaterPrivateKey); - vm.expectRevert(); - messageSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signature); + vm.expectRevert(WatcherNotFound.selector); + messageSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signatures); } function test_setRevertingPayload_SetToFalse() public { bytes32 payloadId = bytes32(uint256(0x1234)); uint256 nonce = 1; bool isReverting = true; + bytes32 digest = keccak256( abi.encodePacked( toBytes32Format(address(messageSwitchboard)), @@ -1467,12 +1566,11 @@ contract MessageSwitchboardTest is Test, Utils { nonce ) ); - - bytes memory signature = createSignature(digest, watcherPrivateKey); + bytes[] memory signatures = new bytes[](1); + signatures[0] = createSignature(digest, watcherPrivateKey); // First set to true - vm.prank(getWatcherAddress()); - messageSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signature); + messageSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signatures); assertTrue(messageSwitchboard.revertingPayloadIds(payloadId)); nonce++; @@ -1486,15 +1584,12 @@ contract MessageSwitchboardTest is Test, Utils { nonce ) ); - - signature = createSignature(digest, watcherPrivateKey); + signatures[0] = createSignature(digest, watcherPrivateKey); // Then set to false vm.expectEmit(true, false, false, true); - emit SwitchboardBase.RevertingPayloadIdset(payloadId, isReverting); - - vm.prank(getWatcherAddress()); - messageSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signature); + emit SwitchboardBase.RevertingPayloadIdSet(payloadId, isReverting); + messageSwitchboard.setRevertingPayload(payloadId, isReverting, nonce, signatures); assertFalse(messageSwitchboard.revertingPayloadIds(payloadId)); } @@ -1879,6 +1974,7 @@ contract MessageSwitchboardTest is Test, Utils { function test_grantWatcherRole_Success() public { address newWatcher = address(0x5000); + uint256 totalBefore = messageSwitchboard.totalWatchers(); vm.startPrank(owner); messageSwitchboard.grantRole(GOVERNANCE_ROLE, owner); vm.stopPrank(); @@ -1886,7 +1982,7 @@ contract MessageSwitchboardTest is Test, Utils { vm.prank(owner); messageSwitchboard.grantWatcherRole(newWatcher); assertTrue(messageSwitchboard.hasRole(WATCHER_ROLE, newWatcher)); - assertEq(messageSwitchboard.totalWatchers(), 2); // 1 from setUp + 1 new + assertEq(messageSwitchboard.totalWatchers(), totalBefore + 1); } function test_grantWatcherRole_WatcherFound_Reverts() public { @@ -2445,33 +2541,59 @@ contract MessageSwitchboardTest is Test, Utils { bytes32 oldDigest = createDigest(digestParams); assertEq(oldDigest, storedDigest, "Old digest should match stored digest"); - // Create signature for new transmitter - bytes memory signature = _createAssignTransmitterSignature(digestParams, newTransmitter); - // Create new digest for verification digestParams.transmitter = toBytes32Format(newTransmitter); bytes32 newDigest = createDigest(digestParams); digestParams.transmitter = toBytes32Format(oldTransmitter); + // Create single watcher signature + bytes32 messageHash = keccak256( + abi.encodePacked( + toBytes32Format(address(messageSwitchboard)), + SRC_CHAIN, + oldDigest, + newDigest + ) + ); + bytes[] memory signatures = new bytes[](1); + signatures[0] = createSignature(messageHash, watcherPrivateKey); + // Expect event (2 indexed parameters: payloadId and transmitter) vm.expectEmit(true, true, false, true); emit MessageSwitchboard.TransmitterAssigned(payloadId, newTransmitter); - // Call assignTransmitter - vm.prank(getWatcherAddress()); - messageSwitchboard.assignTransmitter( - digestParams, - oldTransmitter, - newTransmitter, - nonce, - signature - ); + _callAssignTransmitter(digestParams, oldTransmitter, newTransmitter, nonce, signatures); // Verify digest was updated bytes32 updatedDigest = messageSwitchboard.payloadIdToDigest(payloadId); assertEq(updatedDigest, newDigest, "Digest should be updated with new transmitter"); } + function test_AssignTransmitter_WrongSignatureCount_Reverts() public { + ( + bytes32 payloadId, + MockPlug triggerPlug, + bytes32 appGatewayId, + bytes memory payload + ) = _setupPayloadForAssignTransmitter(); + + DigestParams memory digestParams = _createAssignTransmitterDigestParams( + payloadId, + address(triggerPlug), + appGatewayId, + payload, + address(0) + ); + bytes32 oldDigest = createDigest(digestParams); + digestParams.transmitter = toBytes32Format(address(0x5000)); + bytes32 newDigest = createDigest(digestParams); + bytes[] memory signatures = new bytes[](2); // Wrong count - should be 3 + + digestParams.transmitter = toBytes32Format(address(0)); + vm.expectRevert(ArrayLengthMismatch.selector); + _callAssignTransmitter(digestParams, address(0), address(0x5000), 1, signatures); + } + function test_AssignTransmitter_InvalidOldDigest_Reverts() public { // Setup payload ( @@ -2495,18 +2617,31 @@ contract MessageSwitchboardTest is Test, Utils { wrongOldTransmitter ); - // Create signature for new transmitter - bytes memory signature = _createAssignTransmitterSignature(digestParams, newTransmitter); + bytes32 oldDigest = createDigest(digestParams); + digestParams.transmitter = toBytes32Format(newTransmitter); + bytes32 newDigest = createDigest(digestParams); + digestParams.transmitter = toBytes32Format(wrongOldTransmitter); + + // Create single watcher signature + bytes32 messageHash = keccak256( + abi.encodePacked( + toBytes32Format(address(messageSwitchboard)), + SRC_CHAIN, + oldDigest, + newDigest + ) + ); + bytes[] memory signatures = new bytes[](1); + signatures[0] = createSignature(messageHash, watcherPrivateKey); // Should revert because old digest doesn't match stored digest - vm.prank(getWatcherAddress()); vm.expectRevert(InvalidDigest.selector); - messageSwitchboard.assignTransmitter( + _callAssignTransmitter( digestParams, wrongOldTransmitter, newTransmitter, nonce, - signature + signatures ); } @@ -2544,7 +2679,7 @@ contract MessageSwitchboardTest is Test, Utils { bytes32 newDigest = createDigest(digestParams); // Create signature digest with both old and new digests with non-watcher key - bytes32 signatureDigest = keccak256( + bytes32 messageHash = keccak256( abi.encodePacked( toBytes32Format(address(messageSwitchboard)), SRC_CHAIN, @@ -2552,21 +2687,15 @@ contract MessageSwitchboardTest is Test, Utils { newDigest ) ); - bytes memory signature = createSignature(signatureDigest, nonWatcherKey); + bytes[] memory signatures = new bytes[](1); + signatures[0] = createSignature(messageHash, nonWatcherKey); // Reset transmitter for the function call digestParams.transmitter = toBytes32Format(oldTransmitter); // Should revert because signer is not a watcher - vm.prank(nonWatcher); vm.expectRevert(WatcherNotFound.selector); - messageSwitchboard.assignTransmitter( - digestParams, - oldTransmitter, - newTransmitter, - nonce, - signature - ); + _callAssignTransmitter(digestParams, oldTransmitter, newTransmitter, nonce, signatures); } function test_AssignTransmitter_NonceAlreadyUsed_Reverts() public { @@ -2591,48 +2720,48 @@ contract MessageSwitchboardTest is Test, Utils { oldTransmitter ); - // Create signature for new transmitter - bytes memory signature = _createAssignTransmitterSignature(digestParams, newTransmitter); + bytes32 oldDigest = createDigest(digestParams); + digestParams.transmitter = toBytes32Format(newTransmitter); + bytes32 newDigest = createDigest(digestParams); + digestParams.transmitter = toBytes32Format(oldTransmitter); - // First call succeeds - vm.prank(getWatcherAddress()); - messageSwitchboard.assignTransmitter( - digestParams, - oldTransmitter, - newTransmitter, - nonce, - signature + // Create single watcher signature + bytes32 messageHash = keccak256( + abi.encodePacked( + toBytes32Format(address(messageSwitchboard)), + SRC_CHAIN, + oldDigest, + newDigest + ) ); + bytes[] memory signatures = new bytes[](1); + signatures[0] = createSignature(messageHash, watcherPrivateKey); - // Second call with same nonce should revert - // Need to update oldTransmitter to the new one since we already assigned it - address updatedOldTransmitter = newTransmitter; - address anotherNewTransmitter = address(0x6000); + // First call succeeds + _callAssignTransmitter(digestParams, oldTransmitter, newTransmitter, nonce, signatures); - // Create new digest params with updated old transmitter - DigestParams memory updatedDigestParams = _createAssignTransmitterDigestParams( - payloadId, - address(triggerPlug), - appGatewayId, - payload, - updatedOldTransmitter - ); + // Second call with same nonce should revert (digest already updated, so oldDigest mismatch) + vm.expectRevert(InvalidDigest.selector); + _callAssignTransmitter(digestParams, oldTransmitter, newTransmitter, nonce, signatures); + } - // Create signature for the new assignment - bytes memory signature2 = _createAssignTransmitterSignature( - updatedDigestParams, - anotherNewTransmitter - ); + function _callAssignTransmitter( + DigestParams memory digestParams_, + address oldTransmitter_, + address newTransmitter_, + uint256 nonce_, + bytes[] memory signatures_ + ) internal { + MessageSwitchboard.AssignTransmitterParams memory params = MessageSwitchboard + .AssignTransmitterParams({ + digestParams: digestParams_, + oldTransmitter: oldTransmitter_, + newTransmitter: newTransmitter_, + nonce: nonce_, + signatures: signatures_ + }); - vm.prank(getWatcherAddress()); - vm.expectRevert(NonceAlreadyUsed.selector); - messageSwitchboard.assignTransmitter( - updatedDigestParams, - updatedOldTransmitter, - anotherNewTransmitter, - nonce, // Same nonce - should revert - signature2 - ); + messageSwitchboard.assignTransmitter(params); } } From 0f39e19d919b4f622824dbd9cf9d5db67dc44082 Mon Sep 17 00:00:00 2001 From: Ameesha Agrawal Date: Wed, 24 Dec 2025 18:58:09 +0530 Subject: [PATCH 5/6] fix: refactor --- .../protocol/switchboard/EVMxSwitchboard.sol | 19 ------------------- .../switchboard/MessageSwitchboard.sol | 19 ------------------- .../protocol/switchboard/SwitchboardBase.sol | 17 +++++++++++++++++ 3 files changed, 17 insertions(+), 38 deletions(-) diff --git a/contracts/protocol/switchboard/EVMxSwitchboard.sol b/contracts/protocol/switchboard/EVMxSwitchboard.sol index 6af952ac..a210e0a3 100644 --- a/contracts/protocol/switchboard/EVMxSwitchboard.sol +++ b/contracts/protocol/switchboard/EVMxSwitchboard.sol @@ -318,25 +318,6 @@ contract EVMxSwitchboard is SwitchboardBase { emit DefaultDeadlineIntervalSet(defaultDeadlineInterval_); } - function _validateBatchSignatures( - bytes32 messageHash_, - bytes[] memory signatures_, - bytes32 requiredRole_ - ) internal view returns (address[] memory signers) { - signers = new address[](signatures_.length); - bytes32 ethSignedMessageHash = keccak256( - abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash_) - ); - - for (uint256 i = 0; i < signatures_.length; i++) { - address signer = ECDSA.recover(ethSignedMessageHash, signatures_[i]); - if (requiredRole_ != bytes32(0) && !_hasRole(requiredRole_, signer)) { - revert RoleNotAuthorized(requiredRole_); - } - signers[i] = signer; - } - } - /** * @inheritdoc ISwitchboard * @notice Returns the plug configuration (app gateway ID) diff --git a/contracts/protocol/switchboard/MessageSwitchboard.sol b/contracts/protocol/switchboard/MessageSwitchboard.sol index 73ade4a0..f9c949e2 100644 --- a/contracts/protocol/switchboard/MessageSwitchboard.sol +++ b/contracts/protocol/switchboard/MessageSwitchboard.sol @@ -773,23 +773,4 @@ contract MessageSwitchboard is SwitchboardBase, ReentrancyGuard { } return batchSignatures; } - - function _validateBatchSignatures( - bytes32 messageHash_, - bytes[] memory signatures_, - bytes32 requiredRole_ - ) internal view returns (address[] memory signers) { - signers = new address[](signatures_.length); - bytes32 ethSignedMessageHash = keccak256( - abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash_) - ); - - for (uint256 i = 0; i < signatures_.length; i++) { - address signer = ECDSA.recover(ethSignedMessageHash, signatures_[i]); - if (requiredRole_ != bytes32(0) && !_hasRole(requiredRole_, signer)) { - revert RoleNotAuthorized(requiredRole_); - } - signers[i] = signer; - } - } } diff --git a/contracts/protocol/switchboard/SwitchboardBase.sol b/contracts/protocol/switchboard/SwitchboardBase.sol index fc077749..1f1201a5 100644 --- a/contracts/protocol/switchboard/SwitchboardBase.sol +++ b/contracts/protocol/switchboard/SwitchboardBase.sol @@ -152,6 +152,23 @@ abstract contract SwitchboardBase is ISwitchboard, AccessControl { } } + function _validateBatchSignatures( + bytes32 messageHash_, + bytes[] memory signatures_, + bytes32 requiredRole_ + ) internal view returns (address[] memory signers) { + signers = new address[](signatures_.length); + bytes32 ethSignedMessageHash = keccak256( + abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash_) + ); + + for (uint256 i = 0; i < signatures_.length; i++) { + address signer = ECDSA.recover(ethSignedMessageHash, signatures_[i]); + signer = _validateSignature(messageHash_, signatures_[i], requiredRole_); + signers[i] = signer; + } + } + // --- Rescue Functions --- /** From b9048284c791d051cd40b1360c33da381206acf1 Mon Sep 17 00:00:00 2001 From: Ameesha Agrawal Date: Wed, 24 Dec 2025 18:59:17 +0530 Subject: [PATCH 6/6] fix: remove extra code --- contracts/protocol/switchboard/SwitchboardBase.sol | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/contracts/protocol/switchboard/SwitchboardBase.sol b/contracts/protocol/switchboard/SwitchboardBase.sol index 1f1201a5..d0b05e50 100644 --- a/contracts/protocol/switchboard/SwitchboardBase.sol +++ b/contracts/protocol/switchboard/SwitchboardBase.sol @@ -158,14 +158,8 @@ abstract contract SwitchboardBase is ISwitchboard, AccessControl { bytes32 requiredRole_ ) internal view returns (address[] memory signers) { signers = new address[](signatures_.length); - bytes32 ethSignedMessageHash = keccak256( - abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash_) - ); - for (uint256 i = 0; i < signatures_.length; i++) { - address signer = ECDSA.recover(ethSignedMessageHash, signatures_[i]); - signer = _validateSignature(messageHash_, signatures_[i], requiredRole_); - signers[i] = signer; + signers[i] = _validateSignature(messageHash_, signatures_[i], requiredRole_); } }