From 02fa356be1f35143a1af3020cba6c38e55813b78 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 7 Mar 2025 16:06:08 +0100 Subject: [PATCH 01/11] social recovery --- .../account/extensions/ISocialRecovery.sol | 75 ++++ .../account/extensions/SocialRecovery.sol | 322 ++++++++++++++++++ lib/@openzeppelin-contracts | 2 +- lib/@openzeppelin-contracts-upgradeable | 2 +- lib/forge-std | 2 +- 5 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 contracts/account/extensions/ISocialRecovery.sol create mode 100644 contracts/account/extensions/SocialRecovery.sol diff --git a/contracts/account/extensions/ISocialRecovery.sol b/contracts/account/extensions/ISocialRecovery.sol new file mode 100644 index 00000000..9410a996 --- /dev/null +++ b/contracts/account/extensions/ISocialRecovery.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/// Identity = [ verifyingContract (address) || signer (bytes) ] +// type Identity = bytes; + +/// Guardian = [ property (uint64) | Identity (bytes) ] = [ property (uint64) || verifyingContract (address) || signer (bytes) ] +// type Guardian = bytes; + +struct Permission { + bytes identity; + bytes signature; +} + +struct ThresholdConfig { + uint64 threshold; // Threshold value + uint48 lockPeriod; // Lock period for the threshold +} + +struct RecoveryConfigArg { + address verifier; + bytes[] guardians; + ThresholdConfig[] thresholds; +} + +interface IPermissionVerifier { + /// @dev Check if the signer key format is correct + function isValidSigner(bytes calldata signer) external view returns (bool); + + /// @dev Validate signature for a given signer + function isValidPermission( + bytes32 hash, + bytes calldata signer, + bytes calldata signature + ) external view returns (bool); +} + +interface IRecoveryPolicyVerifier { + function verifyRecoveryPolicy( + address account, + Permission[] calldata permissions, + uint64[] calldata properties + ) external view returns (bool succ, uint64 weight); +} + +interface ISocialRecoveryModule { + function updateGuardians(RecoveryConfigArg calldata recoveryConfigArg) external; + + function startRecovery(bytes calldata recoveryCall, Permission[] calldata permissions) external; + + function startRecovery(address account, bytes calldata recoveryCall, Permission[] calldata permissions) external; + + function executeRecovery() external; + + function executeRecovery(address account) external; + + function cancelRecovery() external; + + function cancelRecoveryByGuardians(Permission[] calldata permissions) external; + + function cancelRecoveryByGuardians(address account, Permission[] calldata permissions) external; + + function isGuardian(bytes calldata guardian) external view returns (bool exist, uint64 property); + + function isGuardian(address account, bytes calldata guardian) external view returns (bool exist, uint64 property); + + function getAccountConfigs() external view returns (RecoveryConfigArg memory recoveryConfigArg); + + function getAccountConfigs(address account) external view returns (RecoveryConfigArg memory recoveryConfigArg); + + function getRecoveryStatus() external view returns (bool isRecovering, uint48 expiryTime); + + function getRecoveryStatus(address account) external view returns (bool isRecovering, uint48 expiryTime); +} diff --git a/contracts/account/extensions/SocialRecovery.sol b/contracts/account/extensions/SocialRecovery.sol new file mode 100644 index 00000000..89bb4b77 --- /dev/null +++ b/contracts/account/extensions/SocialRecovery.sol @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import {IERC7579Module, MODULE_TYPE_EXECUTOR, MODULE_TYPE_FALLBACK} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import {EnumerableMap} from "@openzeppelin/contracts/utils/structs/EnumerableMap.sol"; +import {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol"; + +import {Permission, ThresholdConfig, RecoveryConfigArg, IPermissionVerifier, IRecoveryPolicyVerifier, ISocialRecoveryModule} from "./ISocialRecovery.sol"; + +contract SocialRecoveryModule is EIP712("SocialRecovery", "1"), Nonces, IERC7579Module, IRecoveryPolicyVerifier { + using Checkpoints for *; + using EnumerableMap for *; + using SafeCast for *; + + bytes32 private constant START_RECOVERY_TYPEHASH = + keccak256("StartRecovery(address account, bytes recovery, uint256 nonce)"); + bytes32 private constant CANCEL_RECOVERY_TYPEHASH = + keccak256("CancelRecovery(address account, bytes recovery, uint256 nonce)"); + + struct AccountConfig { + address verifier; + // EnumerableMap.BytesToUintMap guardians; + EnumerableMap.Bytes32ToUintMap guardians; + Checkpoints.Trace160 thresholds; + bytes recoveryCall; + uint48 expiryTime; + } + + mapping(address account => AccountConfig) private _configs; + + modifier onlyNotRecovering(address account) { + require(_configs[account].expiryTime == 0, "recovering"); + _; + } + + modifier onlyRecovering(address account) { + require(_configs[account].expiryTime != 0, "not recovering"); + _; + } + + modifier onlyRecoveryReady(address account) { + uint48 expiryTime = _configs[account].expiryTime; + require(expiryTime != 0 && expiryTime <= block.timestamp, "not recovering"); + _; + } + + /**************************************************************************************************************** + * IERC7579Module * + ****************************************************************************************************************/ + function onInstall(bytes calldata data) public virtual {} + + function onUninstall(bytes calldata data) public virtual {} + + function isModuleType(uint256 moduleTypeId) public view virtual returns (bool) { + return moduleTypeId == MODULE_TYPE_EXECUTOR || moduleTypeId == MODULE_TYPE_FALLBACK; + } + + /**************************************************************************************************************** + * Social recovery - IRecoveryPolicyVerifier * + ****************************************************************************************************************/ + function verifyRecoveryPolicy( + address, + Permission[] calldata permissions, + uint64[] calldata properties + ) public view virtual returns (bool success, uint64 weight) { + if (permissions.length != properties.length) return (false, 0); + + success = true; + weight = 0; + + bytes32 previousHash = bytes32(0); + for (uint256 i = 0; i < permissions.length; ++i) { + // unicity + bytes32 newHash = keccak256(permissions[i].identity); + if (newHash <= previousHash) return (false, 0); + previousHash = newHash; + // total weight + weight += properties[i]; + } + } + + /**************************************************************************************************************** + * Social recovery - Core * + ****************************************************************************************************************/ + function updateGuardians(RecoveryConfigArg calldata recoveryConfigArg) public virtual { + _updateGuardians(msg.sender, recoveryConfigArg); + } + + function startRecovery(bytes calldata recoveryCall, Permission[] calldata permissions) external { + startRecovery(msg.sender, recoveryCall, permissions); + } + + function startRecovery( + address account, + bytes calldata recoveryCall, + Permission[] calldata permissions + ) public virtual { + uint48 lockPeriod = _checkPermissions( + account, + _hashTypedDataV4(keccak256(abi.encode(START_RECOVERY_TYPEHASH, account, recoveryCall, _useNonce(account)))), + permissions + ); + _startRecovery(account, recoveryCall, lockPeriod); + } + + function executeRecovery() external { + executeRecovery(msg.sender); + } + + function executeRecovery(address account) public virtual { + _executeRecovery(account); + } + + function cancelRecovery() public virtual { + _cancelRecovery(msg.sender); + } + + function cancelRecoveryByGuardians(Permission[] calldata permissions) external { + cancelRecoveryByGuardians(msg.sender, permissions); + } + + function cancelRecoveryByGuardians(address account, Permission[] calldata permissions) public virtual { + _checkPermissions( + account, + _hashTypedDataV4( + keccak256( + abi.encode(CANCEL_RECOVERY_TYPEHASH, account, _configs[account].recoveryCall, _useNonce(account)) + ) + ), + permissions + ); + _cancelRecovery(account); + } + + function isGuardian(bytes calldata guardian) external view returns (bool exist, uint64 property) { + return isGuardian(msg.sender, guardian); + } + + function isGuardian( + address account, + bytes calldata guardian + ) public view virtual returns (bool exist, uint64 property) { + (bool exist_, uint256 property_) = _configs[account].guardians.tryGet(bytes32(guardian)); + return (exist_, property_.toUint48()); + } + + function getAccountConfigs() external view returns (RecoveryConfigArg memory recoveryConfigArg) { + return getAccountConfigs(msg.sender); + } + + function getAccountConfigs( + address account + ) public view virtual returns (RecoveryConfigArg memory recoveryConfigArg) { + AccountConfig storage config = _configs[account]; + + bytes[] memory guardians = new bytes[](config.guardians.length()); + for (uint256 i = 0; i < guardians.length; ++i) { + (bytes32 identity, uint256 property) = config.guardians.at(i); + guardians[i] = _formatGuardian(property.toUint64(), identity); + } + + ThresholdConfig[] memory thresholds = new ThresholdConfig[](config.thresholds.length()); + for (uint256 i = 0; i < thresholds.length; ++i) { + Checkpoints.Checkpoint160 memory ckpt = config.thresholds.at(i.toUint32()); + thresholds[i].threshold = ckpt._key.toUint64(); + thresholds[i].lockPeriod = ckpt._value.toUint48(); + } + + return RecoveryConfigArg({verifier: config.verifier, guardians: guardians, thresholds: thresholds}); + } + + function getRecoveryNonce() external view returns (uint256 nonce) { + return nonces(msg.sender); + } + + function getRecoveryStatus() external view returns (bool isRecovering, uint48 expiryTime) { + return getRecoveryStatus(msg.sender); + } + + function getRecoveryStatus(address account) public view virtual returns (bool isRecovering, uint48 expiryTime) { + expiryTime = _configs[account].expiryTime; + isRecovering = expiryTime != 0; + } + + /**************************************************************************************************************** + * Social recovery - internal * + ****************************************************************************************************************/ + function _updateGuardians(address account, RecoveryConfigArg calldata recoveryConfigArgs) internal virtual { + AccountConfig storage config = _configs[account]; + + // verifier + config.verifier = recoveryConfigArgs.verifier == address(0) ? address(this) : recoveryConfigArgs.verifier; + + // guardians + config.guardians.clear(); + for (uint256 i = 0; i < recoveryConfigArgs.guardians.length; ++i) { + (uint64 property, bytes calldata identity) = _parseGuardian(recoveryConfigArgs.guardians[i]); + config.guardians.set(bytes32(identity), property); + } + + // threshold + Checkpoints.Checkpoint160[] storage ckpts = config.thresholds._checkpoints; + assembly ("memory-safe") { + sstore(ckpts.slot, 0) + } + for (uint256 i = 0; i < recoveryConfigArgs.thresholds.length; ++i) { + config.thresholds.push( + recoveryConfigArgs.thresholds[i].threshold, + recoveryConfigArgs.thresholds[i].lockPeriod + ); + } + + // TODO emit event + } + + function _checkPermissions( + address account, + bytes32 hash, + Permission[] calldata permissions + ) internal virtual returns (uint48) { + AccountConfig storage config = _configs[account]; + + // verify signature and get properties + uint64[] memory properties = new uint64[](permissions.length); + for (uint256 i = 0; i < permissions.length; ++i) { + require(config.guardians.contains(bytes32(permissions[i].identity)), "invalid guardian"); + require( + _verifyIdentitySignature(permissions[i].identity, hash, permissions[i].signature), + "InvalidSignature" + ); + properties[i] = config.guardians.get(bytes32(permissions[i].identity)).toUint64(); + } + + // verify recovery policy + (bool success, uint64 weight) = IRecoveryPolicyVerifier(config.verifier).verifyRecoveryPolicy( + account, + permissions, + properties + ); + require(success); + + // get lock period + uint48 lockPeriod = config.thresholds.upperLookup(weight).toUint48(); // uint160 -> uint48 + require(lockPeriod > 0); // TODO: case where the delay is zero ? + + return lockPeriod; + } + + function _startRecovery( + address account, + bytes calldata recoveryCall, + uint48 lockPeriod + ) internal virtual onlyNotRecovering(account) { + // set recovery details + _configs[account].recoveryCall = recoveryCall; + _configs[account].expiryTime = SafeCast.toUint48(block.timestamp + lockPeriod); + + // TODO emit event + } + + function _executeRecovery(address account) internal virtual onlyRecoveryReady(account) { + // cache + bytes memory recoveryCall = _configs[account].recoveryCall; + + // clean (prevents reentry) + delete _configs[account].recoveryCall; + delete _configs[account].expiryTime; + + // perform call + Address.functionCall(account, recoveryCall); + + // TODO emit event + } + + function _cancelRecovery(address account) internal virtual onlyRecovering(account) { + // clean + delete _configs[account].recoveryCall; + delete _configs[account].expiryTime; + + // TODO emit event + } + + /**************************************************************************************************************** + * Helpers * + ****************************************************************************************************************/ + function _formatGuardian(uint64 property, bytes32 identity) internal pure virtual returns (bytes memory guardian) { + return abi.encodePacked(property, identity); + } + + function _parseGuardian( + bytes calldata guardian + ) internal pure virtual returns (uint64 property, bytes calldata identity) { + property = uint64(bytes8(guardian[0:8])); + identity = guardian[8:]; + } + + function _parseIdentity( + bytes calldata identity + ) internal pure virtual returns (address verifyingContract, bytes calldata signer) { + verifyingContract = address(bytes20(identity[0:20])); + signer = identity[20:]; + } + + function _verifyIdentitySignature( + bytes calldata identity, + bytes32 hash, + bytes calldata signature + ) internal view virtual returns (bool) { + (address verifyingContract, bytes calldata signer) = _parseIdentity(identity); + return + (signer.length == 0) + ? SignatureChecker.isValidSignatureNow(verifyingContract, hash, signature) + : IPermissionVerifier(verifyingContract).isValidPermission(hash, signer, signature); + } +} diff --git a/lib/@openzeppelin-contracts b/lib/@openzeppelin-contracts index 441dc141..ca7a4e39 160000 --- a/lib/@openzeppelin-contracts +++ b/lib/@openzeppelin-contracts @@ -1 +1 @@ -Subproject commit 441dc141ac99622de7e535fa75dfc74af939019c +Subproject commit ca7a4e39de0860bbaadf95824207886e6de9fa64 diff --git a/lib/@openzeppelin-contracts-upgradeable b/lib/@openzeppelin-contracts-upgradeable index 266b24b1..79b95d61 160000 --- a/lib/@openzeppelin-contracts-upgradeable +++ b/lib/@openzeppelin-contracts-upgradeable @@ -1 +1 @@ -Subproject commit 266b24b1338f88281040cab1e805f96795d59d3e +Subproject commit 79b95d61ba44b53a5eaf4971201fae7c0b83da6f diff --git a/lib/forge-std b/lib/forge-std index 3b20d60d..8ba9031f 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 3b20d60d14b343ee4f908cb8079495c07f5e8981 +Subproject commit 8ba9031ffcbe25aa0d1224d3ca263a995026e477 From c0eca2bcac1fc164fa17fe32cca7feb4d32f734a Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 14 Mar 2025 14:43:38 +0100 Subject: [PATCH 02/11] Squashed commit of the following: commit 582e86c487e2fe953f470a5e16c6b35ac6df3cb3 Merge: 0796a04 30ad910 Author: Hadrien Croubois Date: Fri Mar 14 14:34:07 2025 +0100 Merge branch 'master' into refacture/extended-enumerable-structs commit 30ad910774ffe9f2157713a511b196871c2726cb Author: Hadrien Croubois Date: Thu Mar 13 21:27:29 2025 +0100 Add missing changelog entry (#90) commit f9f1dc852085a7b8f1d83ee8e9285b97edd74b62 Author: Eric Lau Date: Thu Mar 13 15:18:36 2025 -0400 Update link to ERC-7802 in ERC20Bridgeable (#91) commit 0796a043ea6617413b9f085e4ad8e04f6e7ad970 Author: Hadrien Croubois Date: Thu Mar 13 16:06:56 2025 +0100 update packge-lock.json commit 8af16c5115ac50b51034ac43e6eeeac7f6316a16 Author: Hadrien Croubois Date: Thu Mar 13 16:00:39 2025 +0100 documentation commit 87008a516d9f4903f28004243c6ea63477042be8 Author: Hadrien Croubois Date: Thu Mar 13 15:56:41 2025 +0100 add Changelog entry commit 66d0a96403fbc003561bb3324fff0aa6ea2bba20 Author: Hadrien Croubois Date: Thu Mar 13 15:53:28 2025 +0100 Test generation in CI commit ce9c0059708e3cc634fd0c820a180702e47b4296 Author: Hadrien Croubois Date: Thu Mar 13 15:48:30 2025 +0100 Add EnumerableSetExtended and EnumerableMapExtended commit 18db32c3db4085ae94cd4c06dff9efa31294f414 Author: Hadrien Croubois Date: Tue Mar 11 14:16:20 2025 +0100 Add messageId in IERC7786Receiver.executeMessage (#88) Co-authored-by: Francisco Giordano commit 3342e64e42d49998786e13e26f834c8b0653000f Author: Hadrien Croubois Date: Fri Mar 7 22:32:12 2025 +0100 ERC-7786 N-of-M Aggregator (#82) Co-authored-by: Francisco Giordano --- .github/workflows/checks.yml | 2 + CHANGELOG.md | 8 + contracts/crosschain/ERC7786Aggregator.sol | 314 ++++++++++++++ .../crosschain/axelar/AxelarGatewayBase.sol | 4 +- .../axelar/AxelarGatewayDestination.sol | 7 +- .../crosschain/axelar/AxelarGatewaySource.sol | 2 +- .../crosschain/utils/ERC7786Receiver.sol | 4 +- contracts/interfaces/IERC7786.sol | 1 + .../mocks/crosschain/ERC7786GatewayMock.sol | 2 +- .../crosschain/ERC7786ReceiverInvalidMock.sol | 1 + .../mocks/crosschain/ERC7786ReceiverMock.sol | 12 +- .../crosschain/ERC7786ReceiverRevertMock.sol | 17 + .../crosschain/axelar/AxelarGatewayMock.sol | 3 +- .../ERC20/extensions/ERC20Bridgeable.sol | 2 +- contracts/utils/README.adoc | 7 + .../utils/structs/EnumerableMapExtended.sol | 249 +++++++++++ .../utils/structs/EnumerableSetExtended.sol | 385 ++++++++++++++++++ lib/@openzeppelin-contracts | 2 +- lib/@openzeppelin-contracts-upgradeable | 2 +- package-lock.json | 24 +- package.json | 4 +- scripts/checks/generation.sh | 6 + scripts/generate/run.js | 41 ++ scripts/generate/templates/Enumerable.opts.js | 63 +++ .../templates/EnumerableMapExtended.js | 147 +++++++ .../templates/EnumerableSetExtended.js | 286 +++++++++++++ test/crosschain/ERC7786Aggregator.test.js | 173 ++++++++ test/crosschain/ERC7786Receiver.test.js | 2 +- test/crosschain/axelar/AxelarGateway.test.js | 46 +-- test/crosschain/axelar/AxelarHelper.js | 23 ++ .../structs/EnumerableMapExtended.test.js | 66 +++ .../structs/EnumerableSetExtended.test.js | 62 +++ 32 files changed, 1914 insertions(+), 53 deletions(-) create mode 100644 contracts/crosschain/ERC7786Aggregator.sol create mode 100644 contracts/mocks/crosschain/ERC7786ReceiverRevertMock.sol create mode 100644 contracts/utils/structs/EnumerableMapExtended.sol create mode 100644 contracts/utils/structs/EnumerableSetExtended.sol create mode 100755 scripts/checks/generation.sh create mode 100755 scripts/generate/run.js create mode 100644 scripts/generate/templates/Enumerable.opts.js create mode 100644 scripts/generate/templates/EnumerableMapExtended.js create mode 100644 scripts/generate/templates/EnumerableSetExtended.js create mode 100644 test/crosschain/ERC7786Aggregator.test.js create mode 100644 test/crosschain/axelar/AxelarHelper.js create mode 100644 test/utils/structs/EnumerableMapExtended.test.js create mode 100644 test/utils/structs/EnumerableSetExtended.test.js diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index fce5b1c8..81a62184 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -45,6 +45,8 @@ jobs: run: npm run test:inheritance - name: Check pragma consistency between files run: npm run test:pragma + - name: Check procedurally generated contracts are up-to-date + run: npm run test:generation coverage: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index b94f3acd..aa641ed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## XX-XX-XXXX + +- `EnumerableSetExtended` and `EnumerableMapExtended`: Extensions of the `EnumerableSet` and `EnumerableMap` libraries with more types, including non-value types. + +## 07-03-2025 + +- `ERC7786Aggregator`: Add an aggregator that implements a meta gateway on top of multiple ERC-7786 gateways. + ## 31-01-2025 - `PaymasterCore`: Add a simple ERC-4337 paymaster implementation with minimal logic. diff --git a/contracts/crosschain/ERC7786Aggregator.sol b/contracts/crosschain/ERC7786Aggregator.sol new file mode 100644 index 00000000..da074d5a --- /dev/null +++ b/contracts/crosschain/ERC7786Aggregator.sol @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {CAIP2} from "@openzeppelin/contracts/utils/CAIP2.sol"; +import {CAIP10} from "@openzeppelin/contracts/utils/CAIP10.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {IERC7786GatewaySource, IERC7786Receiver} from "../interfaces/IERC7786.sol"; + +/** + * @dev N of M gateway: Sends your message through M independent gateways. It will be delivered to the receiver by an + * equivalent aggregator on the destination chain if N of the M gateways agree. + */ +contract ERC7786Aggregator is IERC7786GatewaySource, IERC7786Receiver, Ownable, Pausable { + using EnumerableSet for *; + using Strings for *; + + struct Outbox { + address gateway; + bytes32 id; + } + + struct Tracker { + mapping(address => bool) receivedBy; + uint8 countReceived; + bool executed; + } + + event OutboxDetails(bytes32 indexed outboxId, Outbox[] outbox); + event Received(bytes32 indexed receiveId, address gateway); + event ExecutionSuccess(bytes32 indexed receiveId); + event ExecutionFailed(bytes32 indexed receiveId); + event GatewayAdded(address indexed gateway); + event GatewayRemoved(address indexed gateway); + event ThresholdUpdated(uint8 threshold); + + error ERC7786AggregatorValueNotSupported(); + error ERC7786AggregatorInvalidCrosschainSender(); + error ERC7786AggregatorAlreadyExecuted(); + error ERC7786AggregatorRemoteNotRegistered(string caip2); + error ERC7786AggregatorGatewayAlreadyRegistered(address gateway); + error ERC7786AggregatorGatewayNotRegistered(address gateway); + error ERC7786AggregatorThresholdViolation(); + error ERC7786AggregatorInvalidExecutionReturnValue(); + + /**************************************************************************************************************** + * S T A T E V A R I A B L E S * + ****************************************************************************************************************/ + + /// @dev address of the matching aggregator for a given CAIP2 chain + mapping(string caip2 => string) private _remotes; + + /// @dev Tracking of the received message pending final delivery + mapping(bytes32 id => Tracker) private _trackers; + + /// @dev List of authorized IERC7786 gateways (M is the length of this set) + EnumerableSet.AddressSet private _gateways; + + /// @dev Threshold for message reception + uint8 private _threshold; + + /// @dev Nonce for message deduplication (internal) + uint256 private _nonce; + + /**************************************************************************************************************** + * E V E N T S & E R R O R S * + ****************************************************************************************************************/ + event RemoteRegistered(string chainId, string aggregator); + error RemoteAlreadyRegistered(string chainId); + + /**************************************************************************************************************** + * F U N C T I O N S * + ****************************************************************************************************************/ + constructor(address owner_, address[] memory gateways_, uint8 threshold_) Ownable(owner_) { + for (uint256 i = 0; i < gateways_.length; ++i) { + _addGateway(gateways_[i]); + } + _setThreshold(threshold_); + } + + // ============================================ IERC7786GatewaySource ============================================ + + /// @inheritdoc IERC7786GatewaySource + function supportsAttribute(bytes4 /*selector*/) public view virtual returns (bool) { + return false; + } + + /// @inheritdoc IERC7786GatewaySource + /// @dev Using memory instead of calldata avoids stack too deep errors + function sendMessage( + string calldata destinationChain, + string memory receiver, + bytes memory payload, + bytes[] memory attributes + ) public payable virtual whenNotPaused returns (bytes32 outboxId) { + if (attributes.length > 0) revert UnsupportedAttribute(bytes4(attributes[0])); + if (msg.value > 0) revert ERC7786AggregatorValueNotSupported(); + // address of the remote aggregator, revert if not registered + string memory aggregator = getRemoteAggregator(destinationChain); + + // wrapping the payload + bytes memory wrappedPayload = abi.encode(++_nonce, msg.sender.toChecksumHexString(), receiver, payload); + + // Post on all gateways + Outbox[] memory outbox = new Outbox[](_gateways.length()); + bool needsId = false; + for (uint256 i = 0; i < outbox.length; ++i) { + address gateway = _gateways.at(i); + // send message + bytes32 id = IERC7786GatewaySource(gateway).sendMessage( + destinationChain, + aggregator, + wrappedPayload, + attributes + ); + // if ID, track it + if (id != bytes32(0)) { + outbox[i] = Outbox(gateway, id); + needsId = true; + } + } + + if (needsId) { + outboxId = keccak256(abi.encode(outbox)); + emit OutboxDetails(outboxId, outbox); + } + + emit MessagePosted( + outboxId, + CAIP10.local(msg.sender), + CAIP10.format(destinationChain, receiver), + payload, + attributes + ); + } + + // ============================================== IERC7786Receiver =============================================== + + /** + * @inheritdoc IERC7786Receiver + * + * @dev This function serves a dual purpose: + * + * It will be called by ERC-7786 gateways with message coming from the the corresponding aggregator on the source + * chain. These "signals" are tracked until the threshold is reached. At that point the message is sent to the + * destination. + * + * It can also be called by anyone (including an ERC-7786 gateway) to retry the execution. This can be useful if + * the automatic execution (that is triggered when the threshold is reached) fails, and someone wants to retry it. + * + * When a message is forwarded by a known gateway, a {Received} event is emitted. If a known gateway calls this + * function more than once (for a given message), only the first call is counts toward the threshold and emits an + * {Received} event. + * + * This function revert if: + * * the message is not properly formatted or does not originate from the registered aggregator on the source + * chain. + * * someone tries re-execute a message that was already successfully delivered. This includes gateways that call + * this function a second time with a message that was already executed. + * * the execution of the message (on the {IERC7786Receiver} receiver) is successful but fails to return the + * executed value. + * + * This function does not revert if: + * * A known gateway delivers a message for the first time, and that message was already executed. In that case + * the message is NOT re-executed, and the correct "magic value" is returned. + * * The execution of the message (on the {IERC7786Receiver} receiver) reverts. In that case a {ExecutionFailed} + * event is emitted. + * + * This function emits: + * * {Received} when a known ERC-7786 gateway delivers a message for the first time. + * * {ExecutionSuccess} when a message is successfully delivered to the receiver. + * * {ExecutionFailed} when a message delivery to the receiver reverted (for example because of OOG error). + * + * NOTE: interface requires this function to be payable. Even if we don't expect any value, a gateway may pass + * some value for unknown reason. In that case we want to register this gateway having delivered the message and + * not revert. Any value accrued that way can be recovered by the admin using the {sweep} function. + */ + function executeMessage( + string calldata /*messageId*/, // gateway specific, empty or unique + string calldata sourceChain, // CAIP-2 chain identifier + string calldata sender, // CAIP-10 account address (does not include the chain identifier) + bytes calldata payload, + bytes[] calldata attributes + ) public payable virtual whenNotPaused returns (bytes4) { + // Check sender is a trusted remote aggregator + if (!_remotes[sourceChain].equal(sender)) revert ERC7786AggregatorInvalidCrosschainSender(); + + // Message reception tracker + bytes32 id = keccak256(abi.encode(sourceChain, sender, payload, attributes)); + Tracker storage tracker = _trackers[id]; + + // If call is first from a trusted gateway + if (_gateways.contains(msg.sender) && !tracker.receivedBy[msg.sender]) { + // Count number of time received + tracker.receivedBy[msg.sender] = true; + ++tracker.countReceived; + emit Received(id, msg.sender); + + // if already executed, leave gracefully + if (tracker.executed) return IERC7786Receiver.executeMessage.selector; + } else if (tracker.executed) { + revert ERC7786AggregatorAlreadyExecuted(); + } + + // Parse payload + (, string memory originalSender, string memory receiver, bytes memory unwrappedPayload) = abi.decode( + payload, + (uint256, string, string, bytes) + ); + + // If ready to execute, and not yet executed + if (tracker.countReceived >= getThreshold()) { + // prevent re-entry + tracker.executed = true; + + bytes memory call = abi.encodeCall( + IERC7786Receiver.executeMessage, + (uint256(id).toHexString(32), sourceChain, originalSender, unwrappedPayload, attributes) + ); + // slither-disable-next-line reentrancy-no-eth + (bool success, bytes memory returndata) = receiver.parseAddress().call(call); + + if (!success) { + // rollback to enable retry + tracker.executed = false; + emit ExecutionFailed(id); + } else if (bytes32(returndata) == bytes32(IERC7786Receiver.executeMessage.selector)) { + // call successful and correct value returned + emit ExecutionSuccess(id); + } else { + // call successful but invalid value returned, we need to revert the subcall + revert ERC7786AggregatorInvalidExecutionReturnValue(); + } + } + + return IERC7786Receiver.executeMessage.selector; + } + + // =================================================== Getters =================================================== + + function getGateways() public view virtual returns (address[] memory) { + return _gateways.values(); + } + + function getThreshold() public view virtual returns (uint8) { + return _threshold; + } + + function getRemoteAggregator(string calldata caip2) public view virtual returns (string memory) { + string memory aggregator = _remotes[caip2]; + if (bytes(aggregator).length == 0) revert ERC7786AggregatorRemoteNotRegistered(caip2); + return aggregator; + } + + // =================================================== Setters =================================================== + + function addGateway(address gateway) public virtual onlyOwner { + _addGateway(gateway); + } + + function removeGateway(address gateway) public virtual onlyOwner { + _removeGateway(gateway); + } + + function setThreshold(uint8 newThreshold) public virtual onlyOwner { + _setThreshold(newThreshold); + } + + function registerRemoteAggregator(string memory caip2, string memory aggregator) public virtual onlyOwner { + _registerRemoteAggregator(caip2, aggregator); + } + + function pause() public virtual onlyOwner { + _pause(); + } + + function unpause() public virtual onlyOwner { + _unpause(); + } + + /// @dev Recovery method in case value is ever received through {executeMessage} + function sweep(address payable to) public virtual onlyOwner { + Address.sendValue(to, address(this).balance); + } + + // ================================================== Internal =================================================== + + function _addGateway(address gateway) internal virtual { + if (!_gateways.add(gateway)) revert ERC7786AggregatorGatewayAlreadyRegistered(gateway); + emit GatewayAdded(gateway); + } + + function _removeGateway(address gateway) internal virtual { + if (!_gateways.remove(gateway)) revert ERC7786AggregatorGatewayNotRegistered(gateway); + if (_threshold > _gateways.length()) revert ERC7786AggregatorThresholdViolation(); + emit GatewayRemoved(gateway); + } + + function _setThreshold(uint8 newThreshold) internal virtual { + if (newThreshold == 0 || _threshold > _gateways.length()) revert ERC7786AggregatorThresholdViolation(); + _threshold = newThreshold; + emit ThresholdUpdated(newThreshold); + } + + function _registerRemoteAggregator(string memory caip2, string memory aggregator) internal virtual { + _remotes[caip2] = aggregator; + + emit RemoteRegistered(caip2, aggregator); + } +} diff --git a/contracts/crosschain/axelar/AxelarGatewayBase.sol b/contracts/crosschain/axelar/AxelarGatewayBase.sol index 95073554..cf342381 100644 --- a/contracts/crosschain/axelar/AxelarGatewayBase.sol +++ b/contracts/crosschain/axelar/AxelarGatewayBase.sol @@ -26,14 +26,14 @@ abstract contract AxelarGatewayBase is Ownable { error RemoteGatewayAlreadyRegistered(string caip2); /// @dev Axelar's official gateway for the current chain. - IAxelarGateway public immutable localGateway; + IAxelarGateway internal immutable _axelarGateway; mapping(string caip2 => string remoteGateway) private _remoteGateways; mapping(string caip2OrAxelar => string axelarOrCaip2) private _chainEquivalence; /// @dev Sets the local gateway address (i.e. Axelar's official gateway for the current chain). constructor(IAxelarGateway _gateway) { - localGateway = _gateway; + _axelarGateway = _gateway; } /// @dev Returns the equivalent chain given an id that can be either CAIP-2 or an Axelar network identifier. diff --git a/contracts/crosschain/axelar/AxelarGatewayDestination.sol b/contracts/crosschain/axelar/AxelarGatewayDestination.sol index 96bd841b..2f0411f5 100644 --- a/contracts/crosschain/axelar/AxelarGatewayDestination.sol +++ b/contracts/crosschain/axelar/AxelarGatewayDestination.sol @@ -14,8 +14,7 @@ import {AxelarGatewayBase} from "./AxelarGatewayBase.sol"; * workflow into the standard ERC-7786. */ abstract contract AxelarGatewayDestination is AxelarGatewayBase, AxelarExecutable { - using Strings for address; - using Strings for string; + using Strings for *; error InvalidOriginGateway(string sourceChain, string axelarSourceAddress); error ReceiverExecutionFailed(); @@ -33,10 +32,13 @@ abstract contract AxelarGatewayDestination is AxelarGatewayBase, AxelarExecutabl * the message) */ function _execute( + bytes32 commandId, string calldata axelarSourceChain, // chain of the remote gateway - axelar format string calldata axelarSourceAddress, // address of the remote gateway bytes calldata adapterPayload ) internal override { + string memory messageId = uint256(commandId).toHexString(32); + // Parse the package (string memory sender, string memory receiver, bytes memory payload, bytes[] memory attributes) = abi.decode( adapterPayload, @@ -53,6 +55,7 @@ abstract contract AxelarGatewayDestination is AxelarGatewayBase, AxelarExecutabl ); bytes4 result = IERC7786Receiver(receiver.parseAddress()).executeMessage( + messageId, sourceChain, sender, payload, diff --git a/contracts/crosschain/axelar/AxelarGatewaySource.sol b/contracts/crosschain/axelar/AxelarGatewaySource.sol index 1290e8e5..d6b2bd17 100644 --- a/contracts/crosschain/axelar/AxelarGatewaySource.sol +++ b/contracts/crosschain/axelar/AxelarGatewaySource.sol @@ -53,7 +53,7 @@ abstract contract AxelarGatewaySource is IERC7786GatewaySource, AxelarGatewayBas // Send the message string memory axelarDestination = getEquivalentChain(destinationChain); string memory remoteGateway = getRemoteGateway(destinationChain); - localGateway.callContract(axelarDestination, remoteGateway, adapterPayload); + _axelarGateway.callContract(axelarDestination, remoteGateway, adapterPayload); return outboxId; } diff --git a/contracts/crosschain/utils/ERC7786Receiver.sol b/contracts/crosschain/utils/ERC7786Receiver.sol index a889bf02..314b418c 100644 --- a/contracts/crosschain/utils/ERC7786Receiver.sol +++ b/contracts/crosschain/utils/ERC7786Receiver.sol @@ -22,13 +22,14 @@ abstract contract ERC7786Receiver is IERC7786Receiver { /// @inheritdoc IERC7786Receiver function executeMessage( + string calldata messageId, string calldata source, string calldata sender, bytes calldata payload, bytes[] calldata attributes ) public payable virtual returns (bytes4) { require(_isKnownGateway(msg.sender), ERC7786ReceiverInvalidGateway(msg.sender)); - _processMessage(msg.sender, source, sender, payload, attributes); + _processMessage(msg.sender, messageId, source, sender, payload, attributes); return IERC7786Receiver.executeMessage.selector; } @@ -38,6 +39,7 @@ abstract contract ERC7786Receiver is IERC7786Receiver { /// @dev Virtual function that should contain the logic to execute when a cross-chain message is received. function _processMessage( address gateway, + string calldata messageId, string calldata sourceChain, string calldata sender, bytes calldata payload, diff --git a/contracts/interfaces/IERC7786.sol b/contracts/interfaces/IERC7786.sol index 3195cde6..5ea40666 100644 --- a/contracts/interfaces/IERC7786.sol +++ b/contracts/interfaces/IERC7786.sol @@ -60,6 +60,7 @@ interface IERC7786Receiver { * This function may be called directly by the gateway. */ function executeMessage( + string calldata messageId, // gateway specific, empty or unique string calldata sourceChain, // CAIP-2 chain identifier string calldata sender, // CAIP-10 account address (does not include the chain identifier) bytes calldata payload, diff --git a/contracts/mocks/crosschain/ERC7786GatewayMock.sol b/contracts/mocks/crosschain/ERC7786GatewayMock.sol index a1b09313..aa089245 100644 --- a/contracts/mocks/crosschain/ERC7786GatewayMock.sol +++ b/contracts/mocks/crosschain/ERC7786GatewayMock.sol @@ -32,7 +32,7 @@ contract ERC7786GatewayMock is IERC7786GatewaySource { address target = Strings.parseAddress(receiver); require( - IERC7786Receiver(target).executeMessage(source, sender, payload, attributes) == + IERC7786Receiver(target).executeMessage("", source, sender, payload, attributes) == IERC7786Receiver.executeMessage.selector, "Receiver error" ); diff --git a/contracts/mocks/crosschain/ERC7786ReceiverInvalidMock.sol b/contracts/mocks/crosschain/ERC7786ReceiverInvalidMock.sol index 742f5e33..99bc59fd 100644 --- a/contracts/mocks/crosschain/ERC7786ReceiverInvalidMock.sol +++ b/contracts/mocks/crosschain/ERC7786ReceiverInvalidMock.sol @@ -6,6 +6,7 @@ import {IERC7786Receiver} from "../../interfaces/IERC7786.sol"; contract ERC7786ReceiverInvalidMock is IERC7786Receiver { function executeMessage( + string calldata, string calldata, string calldata, bytes calldata, diff --git a/contracts/mocks/crosschain/ERC7786ReceiverMock.sol b/contracts/mocks/crosschain/ERC7786ReceiverMock.sol index b8188ada..7a2f0262 100644 --- a/contracts/mocks/crosschain/ERC7786ReceiverMock.sol +++ b/contracts/mocks/crosschain/ERC7786ReceiverMock.sol @@ -7,7 +7,14 @@ import {ERC7786Receiver} from "../../crosschain/utils/ERC7786Receiver.sol"; contract ERC7786ReceiverMock is ERC7786Receiver { address private immutable _gateway; - event MessageReceived(address gateway, string source, string sender, bytes payload, bytes[] attributes); + event MessageReceived( + address gateway, + string messageId, + string source, + string sender, + bytes payload, + bytes[] attributes + ); constructor(address gateway_) { _gateway = gateway_; @@ -19,11 +26,12 @@ contract ERC7786ReceiverMock is ERC7786Receiver { function _processMessage( address gateway, + string calldata messageId, string calldata source, string calldata sender, bytes calldata payload, bytes[] calldata attributes ) internal virtual override { - emit MessageReceived(gateway, source, sender, payload, attributes); + emit MessageReceived(gateway, messageId, source, sender, payload, attributes); } } diff --git a/contracts/mocks/crosschain/ERC7786ReceiverRevertMock.sol b/contracts/mocks/crosschain/ERC7786ReceiverRevertMock.sol new file mode 100644 index 00000000..9caaa956 --- /dev/null +++ b/contracts/mocks/crosschain/ERC7786ReceiverRevertMock.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import {IERC7786Receiver} from "../../interfaces/IERC7786.sol"; + +contract ERC7786ReceiverRevertMock is IERC7786Receiver { + function executeMessage( + string calldata, + string calldata, + string calldata, + bytes calldata, + bytes[] calldata + ) public payable virtual returns (bytes4) { + revert(); + } +} diff --git a/contracts/mocks/crosschain/axelar/AxelarGatewayMock.sol b/contracts/mocks/crosschain/axelar/AxelarGatewayMock.sol index b0aa1a1e..9aeec2fc 100644 --- a/contracts/mocks/crosschain/axelar/AxelarGatewayMock.sol +++ b/contracts/mocks/crosschain/axelar/AxelarGatewayMock.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.27; import {IAxelarGateway} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGateway.sol"; +import {IBaseAmplifierGateway} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IBaseAmplifierGateway.sol"; import {IAxelarExecutable} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarExecutable.sol"; import {BitMaps} from "@openzeppelin/contracts/utils/structs/BitMaps.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; @@ -64,7 +65,7 @@ contract AxelarGatewayMock { if (_pendingCommandIds.get(uint256(commandId))) { _pendingCommandIds.unset(uint256(commandId)); - emit IAxelarGateway.ContractCallExecuted(commandId); + emit IBaseAmplifierGateway.MessageExecuted(commandId); return commandId == diff --git a/contracts/token/ERC20/extensions/ERC20Bridgeable.sol b/contracts/token/ERC20/extensions/ERC20Bridgeable.sol index aec8fbc4..218f5876 100644 --- a/contracts/token/ERC20/extensions/ERC20Bridgeable.sol +++ b/contracts/token/ERC20/extensions/ERC20Bridgeable.sol @@ -8,7 +8,7 @@ import {IERC7802} from "../../../interfaces/IERC7802.sol"; /** * @dev ERC20 extension that implements the standard token interface according to - * https://github.com/ethereum/ERCs/blob/bcea9feb6c3f3ded391e33690056635d722b101e/ERCS/erc-7802.md[ERC-7802]. + * https://github.com/ethereum/ERCs/blob/master/ERCS/erc-7802.md[ERC-7802]. * * NOTE: To implement a crosschain gateway for a chain, consider using an implementation if {IERC7786} token * bridge (e.g. {AxelarGatewaySource}, {AxelarGatewayDestination}). diff --git a/contracts/utils/README.adoc b/contracts/utils/README.adoc index bb4c33f3..f8d0ab15 100644 --- a/contracts/utils/README.adoc +++ b/contracts/utils/README.adoc @@ -9,6 +9,7 @@ Miscellaneous contracts and libraries containing utility functions you can use t * {ERC7739}: An abstract contract to validate signatures following the rehashing scheme from `ERC7739Utils`. * {ERC7739Utils}: Utilities library that implements a defensive rehashing mechanism to prevent replayability of smart contract signatures based on ERC-7739. * {SignerECDSA}, {SignerP256}, {SignerRSA}: Implementations of an {AbstractSigner} with specific signature validation algorithms. + * {EnumerableSetExtended} and {EnumerableMapExtended}: Extensions of the `EnumerableSet` and `EnumerableMap` libraries with more types, including non-value types. * {Masks}: Library to handle `bytes32` masks. == Cryptography @@ -25,6 +26,12 @@ Miscellaneous contracts and libraries containing utility functions you can use t {{SignerRSA}} +== Structs + +{{EnumerableSetExtended}} + +{{EnumerableMapExtended}} + == Libraries {{Masks}} diff --git a/contracts/utils/structs/EnumerableMapExtended.sol b/contracts/utils/structs/EnumerableMapExtended.sol new file mode 100644 index 00000000..a2468ca4 --- /dev/null +++ b/contracts/utils/structs/EnumerableMapExtended.sol @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: MIT +// This file was procedurally generated from scripts/generate/templates/EnumerableMapExtended.js. + +pragma solidity ^0.8.20; + +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {EnumerableSetExtended} from "./EnumerableSetExtended.sol"; + +/** + * @dev Library for managing an enumerable variant of Solidity's + * https://solidity.readthedocs.io/en/latest/types.html#mapping-types[`mapping`] + * type. + * + * Note: Extensions of openzeppelin/contracts/utils/struct/EnumerableMap.sol. + */ +library EnumerableMapExtended { + using EnumerableSet for *; + using EnumerableSetExtended for *; + + /** + * @dev Query for a nonexistent map key. + */ + error EnumerableMapNonexistentBytesKey(bytes key); + + struct BytesToUintMap { + // Storage of keys + EnumerableSetExtended.BytesSet _keys; + mapping(bytes key => uint256) _values; + } + + /** + * @dev Adds a key-value pair to a map, or updates the value for an existing + * key. O(1). + * + * Returns true if the key was added to the map, that is if it was not + * already present. + */ + function set(BytesToUintMap storage map, bytes memory key, uint256 value) internal returns (bool) { + map._values[key] = value; + return map._keys.add(key); + } + + /** + * @dev Removes a key-value pair from a map. O(1). + * + * Returns true if the key was removed from the map, that is if it was present. + */ + function remove(BytesToUintMap storage map, bytes memory key) internal returns (bool) { + delete map._values[key]; + return map._keys.remove(key); + } + + /** + * @dev Removes all the entries from a map. O(n). + * + * WARNING: Developers should keep in mind that this function has an unbounded cost and using it may render the + * function uncallable if the map grows to the point where clearing it consumes too much gas to fit in a block. + */ + function clear(BytesToUintMap storage map) internal { + uint256 len = length(map); + for (uint256 i = 0; i < len; ++i) { + delete map._values[map._keys.at(i)]; + } + map._keys.clear(); + } + + /** + * @dev Returns true if the key is in the map. O(1). + */ + function contains(BytesToUintMap storage map, bytes memory key) internal view returns (bool) { + return map._keys.contains(key); + } + + /** + * @dev Returns the number of key-value pairs in the map. O(1). + */ + function length(BytesToUintMap storage map) internal view returns (uint256) { + return map._keys.length(); + } + + /** + * @dev Returns the key-value pair stored at position `index` in the map. O(1). + * + * Note that there are no guarantees on the ordering of entries inside the + * array, and it may change when more entries are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function at(BytesToUintMap storage map, uint256 index) internal view returns (bytes memory key, uint256 value) { + key = map._keys.at(index); + value = map._values[key]; + } + + /** + * @dev Tries to returns the value associated with `key`. O(1). + * Does not revert if `key` is not in the map. + */ + function tryGet(BytesToUintMap storage map, bytes memory key) internal view returns (bool exists, uint256 value) { + value = map._values[key]; + exists = value != uint256(0) || contains(map, key); + } + + /** + * @dev Returns the value associated with `key`. O(1). + * + * Requirements: + * + * - `key` must be in the map. + */ + function get(BytesToUintMap storage map, bytes memory key) internal view returns (uint256 value) { + bool exists; + (exists, value) = tryGet(map, key); + if (!exists) { + revert EnumerableMapNonexistentBytesKey(key); + } + } + + /** + * @dev Return the an array containing all the keys + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the map grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function keys(BytesToUintMap storage map) internal view returns (bytes[] memory) { + return map._keys.values(); + } + + /** + * @dev Query for a nonexistent map key. + */ + error EnumerableMapNonexistentStringKey(string key); + + struct StringToStringMap { + // Storage of keys + EnumerableSetExtended.StringSet _keys; + mapping(string key => string) _values; + } + + /** + * @dev Adds a key-value pair to a map, or updates the value for an existing + * key. O(1). + * + * Returns true if the key was added to the map, that is if it was not + * already present. + */ + function set(StringToStringMap storage map, string memory key, string memory value) internal returns (bool) { + map._values[key] = value; + return map._keys.add(key); + } + + /** + * @dev Removes a key-value pair from a map. O(1). + * + * Returns true if the key was removed from the map, that is if it was present. + */ + function remove(StringToStringMap storage map, string memory key) internal returns (bool) { + delete map._values[key]; + return map._keys.remove(key); + } + + /** + * @dev Removes all the entries from a map. O(n). + * + * WARNING: Developers should keep in mind that this function has an unbounded cost and using it may render the + * function uncallable if the map grows to the point where clearing it consumes too much gas to fit in a block. + */ + function clear(StringToStringMap storage map) internal { + uint256 len = length(map); + for (uint256 i = 0; i < len; ++i) { + delete map._values[map._keys.at(i)]; + } + map._keys.clear(); + } + + /** + * @dev Returns true if the key is in the map. O(1). + */ + function contains(StringToStringMap storage map, string memory key) internal view returns (bool) { + return map._keys.contains(key); + } + + /** + * @dev Returns the number of key-value pairs in the map. O(1). + */ + function length(StringToStringMap storage map) internal view returns (uint256) { + return map._keys.length(); + } + + /** + * @dev Returns the key-value pair stored at position `index` in the map. O(1). + * + * Note that there are no guarantees on the ordering of entries inside the + * array, and it may change when more entries are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function at( + StringToStringMap storage map, + uint256 index + ) internal view returns (string memory key, string memory value) { + key = map._keys.at(index); + value = map._values[key]; + } + + /** + * @dev Tries to returns the value associated with `key`. O(1). + * Does not revert if `key` is not in the map. + */ + function tryGet( + StringToStringMap storage map, + string memory key + ) internal view returns (bool exists, string memory value) { + value = map._values[key]; + exists = bytes(value).length != 0 || contains(map, key); + } + + /** + * @dev Returns the value associated with `key`. O(1). + * + * Requirements: + * + * - `key` must be in the map. + */ + function get(StringToStringMap storage map, string memory key) internal view returns (string memory value) { + bool exists; + (exists, value) = tryGet(map, key); + if (!exists) { + revert EnumerableMapNonexistentStringKey(key); + } + } + + /** + * @dev Return the an array containing all the keys + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the map grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function keys(StringToStringMap storage map) internal view returns (string[] memory) { + return map._keys.values(); + } +} diff --git a/contracts/utils/structs/EnumerableSetExtended.sol b/contracts/utils/structs/EnumerableSetExtended.sol new file mode 100644 index 00000000..819b27c4 --- /dev/null +++ b/contracts/utils/structs/EnumerableSetExtended.sol @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: MIT +// This file was procedurally generated from scripts/generate/templates/EnumerableSetExtended.js. + +pragma solidity ^0.8.20; + +import {Arrays} from "@openzeppelin/contracts/utils/Arrays.sol"; +import {Hashes} from "@openzeppelin/contracts/utils/cryptography/Hashes.sol"; + +/** + * @dev Library for managing + * https://en.wikipedia.org/wiki/Set_(abstract_data_type)[sets] of primitive + * types. + * + * Note: Extensions of openzeppelin/contracts/utils/struct/EnumerableSet.sol. + */ +library EnumerableSetExtended { + struct StringSet { + // Storage of set values + string[] _values; + // Position is the index of the value in the `values` array plus 1. + // Position 0 is used to mean a value is not in the self. + mapping(string value => uint256) _positions; + } + + /** + * @dev Add a value to a self. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ + function add(StringSet storage self, string memory value) internal returns (bool) { + if (!contains(self, value)) { + self._values.push(value); + // The value is stored at length-1, but we add 1 to all indexes + // and use 0 as a sentinel value + self._positions[value] = self._values.length; + return true; + } else { + return false; + } + } + + /** + * @dev Removes a value from a self. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ + function remove(StringSet storage self, string memory value) internal returns (bool) { + // We cache the value's position to prevent multiple reads from the same storage slot + uint256 position = self._positions[value]; + + if (position != 0) { + // Equivalent to contains(self, value) + // To delete an element from the _values array in O(1), we swap the element to delete with the last one in + // the array, and then remove the last element (sometimes called as 'swap and pop'). + // This modifies the order of the array, as noted in {at}. + + uint256 valueIndex = position - 1; + uint256 lastIndex = self._values.length - 1; + + if (valueIndex != lastIndex) { + string memory lastValue = self._values[lastIndex]; + + // Move the lastValue to the index where the value to delete is + self._values[valueIndex] = lastValue; + // Update the tracked position of the lastValue (that was just moved) + self._positions[lastValue] = position; + } + + // Delete the slot where the moved value was stored + self._values.pop(); + + // Delete the tracked position for the deleted slot + delete self._positions[value]; + + return true; + } else { + return false; + } + } + + /** + * @dev Removes all the values from a set. O(n). + * + * WARNING: Developers should keep in mind that this function has an unbounded cost and using it may render the + * function uncallable if the set grows to the point where clearing it consumes too much gas to fit in a block. + */ + function clear(StringSet storage set) internal { + uint256 len = length(set); + for (uint256 i = 0; i < len; ++i) { + delete set._positions[set._values[i]]; + } + Arrays.unsafeSetLength(set._values, 0); + } + + /** + * @dev Returns true if the value is in the self. O(1). + */ + function contains(StringSet storage self, string memory value) internal view returns (bool) { + return self._positions[value] != 0; + } + + /** + * @dev Returns the number of values on the self. O(1). + */ + function length(StringSet storage self) internal view returns (uint256) { + return self._values.length; + } + + /** + * @dev Returns the value stored at position `index` in the self. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function at(StringSet storage self, uint256 index) internal view returns (string memory) { + return self._values[index]; + } + + /** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function values(StringSet storage self) internal view returns (string[] memory) { + return self._values; + } + + struct BytesSet { + // Storage of set values + bytes[] _values; + // Position is the index of the value in the `values` array plus 1. + // Position 0 is used to mean a value is not in the self. + mapping(bytes value => uint256) _positions; + } + + /** + * @dev Add a value to a self. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ + function add(BytesSet storage self, bytes memory value) internal returns (bool) { + if (!contains(self, value)) { + self._values.push(value); + // The value is stored at length-1, but we add 1 to all indexes + // and use 0 as a sentinel value + self._positions[value] = self._values.length; + return true; + } else { + return false; + } + } + + /** + * @dev Removes a value from a self. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ + function remove(BytesSet storage self, bytes memory value) internal returns (bool) { + // We cache the value's position to prevent multiple reads from the same storage slot + uint256 position = self._positions[value]; + + if (position != 0) { + // Equivalent to contains(self, value) + // To delete an element from the _values array in O(1), we swap the element to delete with the last one in + // the array, and then remove the last element (sometimes called as 'swap and pop'). + // This modifies the order of the array, as noted in {at}. + + uint256 valueIndex = position - 1; + uint256 lastIndex = self._values.length - 1; + + if (valueIndex != lastIndex) { + bytes memory lastValue = self._values[lastIndex]; + + // Move the lastValue to the index where the value to delete is + self._values[valueIndex] = lastValue; + // Update the tracked position of the lastValue (that was just moved) + self._positions[lastValue] = position; + } + + // Delete the slot where the moved value was stored + self._values.pop(); + + // Delete the tracked position for the deleted slot + delete self._positions[value]; + + return true; + } else { + return false; + } + } + + /** + * @dev Removes all the values from a set. O(n). + * + * WARNING: Developers should keep in mind that this function has an unbounded cost and using it may render the + * function uncallable if the set grows to the point where clearing it consumes too much gas to fit in a block. + */ + function clear(BytesSet storage set) internal { + uint256 len = length(set); + for (uint256 i = 0; i < len; ++i) { + delete set._positions[set._values[i]]; + } + Arrays.unsafeSetLength(set._values, 0); + } + + /** + * @dev Returns true if the value is in the self. O(1). + */ + function contains(BytesSet storage self, bytes memory value) internal view returns (bool) { + return self._positions[value] != 0; + } + + /** + * @dev Returns the number of values on the self. O(1). + */ + function length(BytesSet storage self) internal view returns (uint256) { + return self._values.length; + } + + /** + * @dev Returns the value stored at position `index` in the self. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function at(BytesSet storage self, uint256 index) internal view returns (bytes memory) { + return self._values[index]; + } + + /** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function values(BytesSet storage self) internal view returns (bytes[] memory) { + return self._values; + } + + struct Bytes32x2Set { + // Storage of set values + bytes32[2][] _values; + // Position is the index of the value in the `values` array plus 1. + // Position 0 is used to mean a value is not in the self. + mapping(bytes32 valueHash => uint256) _positions; + } + + /** + * @dev Add a value to a self. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ + function add(Bytes32x2Set storage self, bytes32[2] memory value) internal returns (bool) { + if (!contains(self, value)) { + self._values.push(value); + // The value is stored at length-1, but we add 1 to all indexes + // and use 0 as a sentinel value + self._positions[_hash(value)] = self._values.length; + return true; + } else { + return false; + } + } + + /** + * @dev Removes a value from a self. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ + function remove(Bytes32x2Set storage self, bytes32[2] memory value) internal returns (bool) { + // We cache the value's position to prevent multiple reads from the same storage slot + bytes32 valueHash = _hash(value); + uint256 position = self._positions[valueHash]; + + if (position != 0) { + // Equivalent to contains(self, value) + // To delete an element from the _values array in O(1), we swap the element to delete with the last one in + // the array, and then remove the last element (sometimes called as 'swap and pop'). + // This modifies the order of the array, as noted in {at}. + + uint256 valueIndex = position - 1; + uint256 lastIndex = self._values.length - 1; + + if (valueIndex != lastIndex) { + bytes32[2] memory lastValue = self._values[lastIndex]; + + // Move the lastValue to the index where the value to delete is + self._values[valueIndex] = lastValue; + // Update the tracked position of the lastValue (that was just moved) + self._positions[_hash(lastValue)] = position; + } + + // Delete the slot where the moved value was stored + self._values.pop(); + + // Delete the tracked position for the deleted slot + delete self._positions[valueHash]; + + return true; + } else { + return false; + } + } + + /** + * @dev Removes all the values from a set. O(n). + * + * WARNING: Developers should keep in mind that this function has an unbounded cost and using it may render the + * function uncallable if the set grows to the point where clearing it consumes too much gas to fit in a block. + */ + function clear(Bytes32x2Set storage self) internal { + bytes32[2][] storage v = self._values; + + uint256 len = length(self); + for (uint256 i = 0; i < len; ++i) { + delete self._positions[_hash(v[i])]; + } + assembly ("memory-safe") { + sstore(v.slot, 0) + } + } + + /** + * @dev Returns true if the value is in the self. O(1). + */ + function contains(Bytes32x2Set storage self, bytes32[2] memory value) internal view returns (bool) { + return self._positions[_hash(value)] != 0; + } + + /** + * @dev Returns the number of values on the self. O(1). + */ + function length(Bytes32x2Set storage self) internal view returns (uint256) { + return self._values.length; + } + + /** + * @dev Returns the value stored at position `index` in the self. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function at(Bytes32x2Set storage self, uint256 index) internal view returns (bytes32[2] memory) { + return self._values[index]; + } + + /** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function values(Bytes32x2Set storage self) internal view returns (bytes32[2][] memory) { + return self._values; + } + + function _hash(bytes32[2] memory value) private pure returns (bytes32) { + return Hashes.efficientKeccak256(value[0], value[1]); + } +} diff --git a/lib/@openzeppelin-contracts b/lib/@openzeppelin-contracts index ca7a4e39..fda6b85f 160000 --- a/lib/@openzeppelin-contracts +++ b/lib/@openzeppelin-contracts @@ -1 +1 @@ -Subproject commit ca7a4e39de0860bbaadf95824207886e6de9fa64 +Subproject commit fda6b85f2c65d146b86d513a604554d15abd6679 diff --git a/lib/@openzeppelin-contracts-upgradeable b/lib/@openzeppelin-contracts-upgradeable index 79b95d61..36ec7079 160000 --- a/lib/@openzeppelin-contracts-upgradeable +++ b/lib/@openzeppelin-contracts-upgradeable @@ -1 +1 @@ -Subproject commit 79b95d61ba44b53a5eaf4971201fae7c0b83da6f +Subproject commit 36ec7079af1a68bd866f6b9f4cf2f4dddee1e7bc diff --git a/package-lock.json b/package-lock.json index 8529afb7..09d1f1a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "@axelar-network/axelar-gmp-sdk-solidity": "^5.10.0" + "@axelar-network/axelar-gmp-sdk-solidity": "^6.0.6" }, "devDependencies": { "@openzeppelin/contracts": "file:lib/@openzeppelin-contracts", @@ -39,7 +39,7 @@ "eslint-config-prettier": "^9.0.0", "ethers": "^6.13.4", "glob": "^11.0.0", - "globals": "^15.3.0", + "globals": "^16.0.0", "graphlib": "^2.1.8", "hardhat": "^2.22.2", "hardhat-exposed": "^0.3.15", @@ -59,7 +59,7 @@ "solidity-ast": "^0.4.50", "solidity-coverage": "^0.8.5", "solidity-docgen": "^0.6.0-beta.29", - "undici": "^7.0.0", + "undici": "^7.4.0", "yargs": "^17.0.0" } }, @@ -103,9 +103,9 @@ "license": "MIT" }, "node_modules/@axelar-network/axelar-gmp-sdk-solidity": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/@axelar-network/axelar-gmp-sdk-solidity/-/axelar-gmp-sdk-solidity-5.10.0.tgz", - "integrity": "sha512-s8SImALvYB+5AeiT3tbfWNBI2Mhqw1x91i/zM3DNpVUCnAR2HKtsB9T84KnUn/OJjOVgb4h0lv7q9smeYniRPw==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@axelar-network/axelar-gmp-sdk-solidity/-/axelar-gmp-sdk-solidity-6.0.6.tgz", + "integrity": "sha512-XIcDlr1HoYSqcxuvvusILmiqerh2bL9NJLwU4lFBAJK5E/st/q3Em9ropBBZML9iuUZ+hDsch8Ev9rMO+ulaSQ==", "license": "MIT", "engines": { "node": ">=18" @@ -4986,9 +4986,9 @@ } }, "node_modules/globals": { - "version": "15.13.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.13.0.tgz", - "integrity": "sha512-49TewVEz0UxZjr1WYYsWpPrhyC/B/pA8Bq0fUmet2n+eR7yn0IvNzNaoBwnK6mdkzcN+se7Ez9zUgULTz2QH4g==", + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.0.0.tgz", + "integrity": "sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==", "dev": true, "license": "MIT", "engines": { @@ -9612,9 +9612,9 @@ } }, "node_modules/undici": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.2.0.tgz", - "integrity": "sha512-klt+0S55GBViA9nsq48/NSCo4YX5mjydjypxD7UmHh/brMu8h/Mhd/F7qAeoH2NOO8SDTk6kjnTFc4WpzmfYpQ==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.5.0.tgz", + "integrity": "sha512-NFQG741e8mJ0fLQk90xKxFdaSM7z4+IQpAgsFI36bCDY9Z2+aXXZjVy2uUksMouWfMI9+w5ejOq5zYYTBCQJDQ==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index a0a04a8b..5cb35152 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "lint:sol": "prettier --log-level warn --ignore-path .gitignore '{contracts,test}/**/*.sol' --check && solhint '{contracts,test}/**/*.sol'", "lint:sol:fix": "prettier --log-level warn --ignore-path .gitignore '{contracts,test}/**/*.sol' --write", "coverage": "scripts/checks/coverage.sh", + "generate": "scripts/generate/run.js", "test": "hardhat test", + "test:generation": "scripts/checks/generation.sh", "test:inheritance": "scripts/checks/inheritance-ordering.js artifacts/build-info/*", "test:pragma": "scripts/checks/pragma-consistency.js artifacts/build-info/*" }, @@ -43,7 +45,7 @@ "zeppelin" ], "dependencies": { - "@axelar-network/axelar-gmp-sdk-solidity": "^5.10.0" + "@axelar-network/axelar-gmp-sdk-solidity": "^6.0.6" }, "devDependencies": { "@openzeppelin/contracts": "file:lib/@openzeppelin-contracts", diff --git a/scripts/checks/generation.sh b/scripts/checks/generation.sh new file mode 100755 index 00000000..00d609f9 --- /dev/null +++ b/scripts/checks/generation.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +npm run generate +git diff -R --exit-code diff --git a/scripts/generate/run.js b/scripts/generate/run.js new file mode 100755 index 00000000..2f54e4cf --- /dev/null +++ b/scripts/generate/run.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node + +const cp = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const format = require('@openzeppelin/contracts/scripts/generate/format-lines'); + +function getVersion(path) { + try { + return fs.readFileSync(path, 'utf8').match(/\/\/ OpenZeppelin Community Contracts \(last updated v[^)]+\)/)[0]; + } catch { + return null; + } +} + +function generateFromTemplate(file, template, outputPrefix = '', lint = false) { + const script = path.relative(path.join(__dirname, '../..'), __filename); + const input = path.join(path.dirname(script), template); + const output = path.join(outputPrefix, file); + const version = getVersion(output); + const content = format( + '// SPDX-License-Identifier: MIT', + ...(version ? [version + ` (${file})`] : []), + `// This file was procedurally generated from ${input}.`, + '', + require(template).trimEnd(), + ); + fs.writeFileSync(output, content); + lint && cp.execFileSync('prettier', ['--write', output]); +} + +// Some templates needs to go through the linter after generation +const needsLinter = ['utils/structs/EnumerableMapExtended.sol']; + +// Contracts +for (const [file, template] of Object.entries({ + 'utils/structs/EnumerableSetExtended.sol': './templates/EnumerableSetExtended.js', + 'utils/structs/EnumerableMapExtended.sol': './templates/EnumerableMapExtended.js', +})) { + generateFromTemplate(file, template, './contracts/', needsLinter.indexOf(file) != -1); +} diff --git a/scripts/generate/templates/Enumerable.opts.js b/scripts/generate/templates/Enumerable.opts.js new file mode 100644 index 00000000..34c09c5c --- /dev/null +++ b/scripts/generate/templates/Enumerable.opts.js @@ -0,0 +1,63 @@ +const { capitalize, mapValues } = require('@openzeppelin/contracts/scripts/helpers'); + +const typeDescr = ({ type, size = 0, memory = false }) => { + memory |= size > 0; + + const name = [type == 'uint256' ? 'Uint' : capitalize(type), size].filter(Boolean).join('x'); + const base = size ? type : undefined; + const typeFull = size ? `${type}[${size}]` : type; + const typeLoc = memory ? `${typeFull} memory` : typeFull; + return { name, type: typeFull, typeLoc, base, size, memory }; +}; + +const toSetTypeDescr = value => ({ + name: value.name + 'Set', + value, +}); + +const toMapTypeDescr = ({ key, value }) => ({ + name: `${key.name}To${value.name}Map`, + keySet: toSetTypeDescr(key), + key, + value, +}); + +const SET_TYPES = [ + // { type: 'bytes32' }, // part of the vanilla repo + // { type: 'address' }, // part of the vanilla repo + // { type: 'uint256' }, // part of the vanilla repo + { type: 'bytes32', size: 2 }, + { type: 'string', memory: true }, + { type: 'bytes', memory: true }, +] + .map(typeDescr) + .map(toSetTypeDescr); + +const MAP_TYPES = [ + // { key: { type: 'uint256' }, value: { type: 'uint256' } }, // part of the vanilla repo + // { key: { type: 'uint256' }, value: { type: 'address' } }, // part of the vanilla repo + // { key: { type: 'uint256' }, value: { type: 'bytes32' } }, // part of the vanilla repo + // { key: { type: 'address' }, value: { type: 'uint256' } }, // part of the vanilla repo + // { key: { type: 'address' }, value: { type: 'address' } }, // part of the vanilla repo + // { key: { type: 'address' }, value: { type: 'bytes32' } }, // part of the vanilla repo + // { key: { type: 'bytes32' }, value: { type: 'uint256' } }, // part of the vanilla repo + // { key: { type: 'bytes32' }, value: { type: 'address' } }, // part of the vanilla repo + { key: { type: 'bytes', memory: true }, value: { type: 'uint256' } }, + { key: { type: 'string', memory: true }, value: { type: 'string', memory: true } }, +] + .map(entry => mapValues(entry, typeDescr)) + .map(toMapTypeDescr); + +/// Sanity - Disabled because some types might be provided by the vanilla repository. +// MAP_TYPES.forEach(entry => { +// if (!SET_TYPES.some(set => set.structName == entry.key.structName)) +// throw new Error(`${entry.structName} requires a "${entry.key.structName}" set of "${entry.key.type}"`); +// }); + +module.exports = { + SET_TYPES, + MAP_TYPES, + typeDescr, + toSetTypeDescr, + toMapTypeDescr, +}; diff --git a/scripts/generate/templates/EnumerableMapExtended.js b/scripts/generate/templates/EnumerableMapExtended.js new file mode 100644 index 00000000..8d0c1096 --- /dev/null +++ b/scripts/generate/templates/EnumerableMapExtended.js @@ -0,0 +1,147 @@ +const format = require('@openzeppelin/contracts/scripts/generate/format-lines'); +const { SET_TYPES, MAP_TYPES } = require('./Enumerable.opts'); + +const header = `\ +pragma solidity ^0.8.20; + +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {EnumerableSetExtended} from "./EnumerableSetExtended.sol"; + +/** + * @dev Library for managing an enumerable variant of Solidity's + * https://solidity.readthedocs.io/en/latest/types.html#mapping-types[\`mapping\`] + * type. + * + * Note: Extensions of openzeppelin/contracts/utils/struct/EnumerableMap.sol. + */ +`; + +const map = ({ name, keySet, key, value }) => `\ +/** + * @dev Query for a nonexistent map key. + */ +error EnumerableMapNonexistent${key.name}Key(${key.type} key); + +struct ${name} { + // Storage of keys + ${SET_TYPES.some(el => el.name == keySet.name) ? 'EnumerableSetExtended' : 'EnumerableSet'}.${keySet.name} _keys; + mapping(${key.type} key => ${value.type}) _values; +} + +/** + * @dev Adds a key-value pair to a map, or updates the value for an existing + * key. O(1). + * + * Returns true if the key was added to the map, that is if it was not + * already present. + */ +function set(${name} storage map, ${key.typeLoc} key, ${value.typeLoc} value) internal returns (bool) { + map._values[key] = value; + return map._keys.add(key); +} + +/** + * @dev Removes a key-value pair from a map. O(1). + * + * Returns true if the key was removed from the map, that is if it was present. + */ +function remove(${name} storage map, ${key.typeLoc} key) internal returns (bool) { + delete map._values[key]; + return map._keys.remove(key); +} + +/** + * @dev Removes all the entries from a map. O(n). + * + * WARNING: Developers should keep in mind that this function has an unbounded cost and using it may render the + * function uncallable if the map grows to the point where clearing it consumes too much gas to fit in a block. + */ +function clear(${name} storage map) internal { + uint256 len = length(map); + for (uint256 i = 0; i < len; ++i) { + delete map._values[map._keys.at(i)]; + } + map._keys.clear(); +} + +/** + * @dev Returns true if the key is in the map. O(1). + */ +function contains(${name} storage map, ${key.typeLoc} key) internal view returns (bool) { + return map._keys.contains(key); +} + +/** + * @dev Returns the number of key-value pairs in the map. O(1). + */ +function length(${name} storage map) internal view returns (uint256) { + return map._keys.length(); +} + +/** + * @dev Returns the key-value pair stored at position \`index\` in the map. O(1). + * + * Note that there are no guarantees on the ordering of entries inside the + * array, and it may change when more entries are added or removed. + * + * Requirements: + * + * - \`index\` must be strictly less than {length}. + */ +function at( + ${name} storage map, + uint256 index +) internal view returns (${key.typeLoc} key, ${value.typeLoc} value) { + key = map._keys.at(index); + value = map._values[key]; +} + +/** + * @dev Tries to returns the value associated with \`key\`. O(1). + * Does not revert if \`key\` is not in the map. + */ +function tryGet( + ${name} storage map, + ${key.typeLoc} key +) internal view returns (bool exists, ${value.typeLoc} value) { + value = map._values[key]; + exists = ${value.memory ? 'bytes(value).length != 0' : `value != ${value.type}(0)`} || contains(map, key); +} + +/** + * @dev Returns the value associated with \`key\`. O(1). + * + * Requirements: + * + * - \`key\` must be in the map. + */ +function get(${name} storage map, ${key.typeLoc} key) internal view returns (${value.typeLoc} value) { + bool exists; + (exists, value) = tryGet(map, key); + if (!exists) { + revert EnumerableMapNonexistent${key.name}Key(key); + } +} + +/** + * @dev Return the an array containing all the keys + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the map grows to a point where copying to memory consumes too much gas to fit in a block. + */ +function keys(${name} storage map) internal view returns (${key.type}[] memory) { + return map._keys.values(); +} +`; + +// GENERATE +module.exports = format( + header.trimEnd(), + 'library EnumerableMapExtended {', + format( + [].concat('using EnumerableSet for *;', 'using EnumerableSetExtended for *;', '', MAP_TYPES.map(map)), + ).trimEnd(), + '}', +); diff --git a/scripts/generate/templates/EnumerableSetExtended.js b/scripts/generate/templates/EnumerableSetExtended.js new file mode 100644 index 00000000..d1a2f19f --- /dev/null +++ b/scripts/generate/templates/EnumerableSetExtended.js @@ -0,0 +1,286 @@ +const format = require('@openzeppelin/contracts/scripts/generate/format-lines'); +const { SET_TYPES } = require('./Enumerable.opts'); + +const header = `\ +pragma solidity ^0.8.20; + +import {Arrays} from "@openzeppelin/contracts/utils/Arrays.sol"; +import {Hashes} from "@openzeppelin/contracts/utils/cryptography/Hashes.sol"; + +/** + * @dev Library for managing + * https://en.wikipedia.org/wiki/Set_(abstract_data_type)[sets] of primitive + * types. + * + * Note: Extensions of openzeppelin/contracts/utils/struct/EnumerableSet.sol. + */ +`; + +const set = ({ name, value }) => `\ +struct ${name} { + // Storage of set values + ${value.type}[] _values; + // Position is the index of the value in the \`values\` array plus 1. + // Position 0 is used to mean a value is not in the self. + mapping(${value.type} value => uint256) _positions; +} + +/** + * @dev Add a value to a self. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ +function add(${name} storage self, ${value.type} memory value) internal returns (bool) { + if (!contains(self, value)) { + self._values.push(value); + // The value is stored at length-1, but we add 1 to all indexes + // and use 0 as a sentinel value + self._positions[value] = self._values.length; + return true; + } else { + return false; + } +} + +/** + * @dev Removes a value from a self. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ +function remove(${name} storage self, ${value.type} memory value) internal returns (bool) { + // We cache the value's position to prevent multiple reads from the same storage slot + uint256 position = self._positions[value]; + + if (position != 0) { + // Equivalent to contains(self, value) + // To delete an element from the _values array in O(1), we swap the element to delete with the last one in + // the array, and then remove the last element (sometimes called as 'swap and pop'). + // This modifies the order of the array, as noted in {at}. + + uint256 valueIndex = position - 1; + uint256 lastIndex = self._values.length - 1; + + if (valueIndex != lastIndex) { + ${value.type} memory lastValue = self._values[lastIndex]; + + // Move the lastValue to the index where the value to delete is + self._values[valueIndex] = lastValue; + // Update the tracked position of the lastValue (that was just moved) + self._positions[lastValue] = position; + } + + // Delete the slot where the moved value was stored + self._values.pop(); + + // Delete the tracked position for the deleted slot + delete self._positions[value]; + + return true; + } else { + return false; + } +} + +/** + * @dev Removes all the values from a set. O(n). + * + * WARNING: Developers should keep in mind that this function has an unbounded cost and using it may render the + * function uncallable if the set grows to the point where clearing it consumes too much gas to fit in a block. + */ +function clear(${name} storage set) internal { + uint256 len = length(set); + for (uint256 i = 0; i < len; ++i) { + delete set._positions[set._values[i]]; + } + Arrays.unsafeSetLength(set._values, 0); +} + +/** + * @dev Returns true if the value is in the self. O(1). + */ +function contains(${name} storage self, ${value.type} memory value) internal view returns (bool) { + return self._positions[value] != 0; +} + +/** + * @dev Returns the number of values on the self. O(1). + */ +function length(${name} storage self) internal view returns (uint256) { + return self._values.length; +} + +/** + * @dev Returns the value stored at position \`index\` in the self. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - \`index\` must be strictly less than {length}. + */ +function at(${name} storage self, uint256 index) internal view returns (${value.type} memory) { + return self._values[index]; +} + +/** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ +function values(${name} storage self) internal view returns (${value.type}[] memory) { + return self._values; +} +`; + +const arraySet = ({ name, value }) => `\ +struct ${name} { + // Storage of set values + ${value.type}[] _values; + // Position is the index of the value in the \`values\` array plus 1. + // Position 0 is used to mean a value is not in the self. + mapping(bytes32 valueHash => uint256) _positions; +} + +/** + * @dev Add a value to a self. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ +function add(${name} storage self, ${value.type} memory value) internal returns (bool) { + if (!contains(self, value)) { + self._values.push(value); + // The value is stored at length-1, but we add 1 to all indexes + // and use 0 as a sentinel value + self._positions[_hash(value)] = self._values.length; + return true; + } else { + return false; + } +} + +/** + * @dev Removes a value from a self. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ +function remove(${name} storage self, ${value.type} memory value) internal returns (bool) { + // We cache the value's position to prevent multiple reads from the same storage slot + bytes32 valueHash = _hash(value); + uint256 position = self._positions[valueHash]; + + if (position != 0) { + // Equivalent to contains(self, value) + // To delete an element from the _values array in O(1), we swap the element to delete with the last one in + // the array, and then remove the last element (sometimes called as 'swap and pop'). + // This modifies the order of the array, as noted in {at}. + + uint256 valueIndex = position - 1; + uint256 lastIndex = self._values.length - 1; + + if (valueIndex != lastIndex) { + ${value.type} memory lastValue = self._values[lastIndex]; + + // Move the lastValue to the index where the value to delete is + self._values[valueIndex] = lastValue; + // Update the tracked position of the lastValue (that was just moved) + self._positions[_hash(lastValue)] = position; + } + + // Delete the slot where the moved value was stored + self._values.pop(); + + // Delete the tracked position for the deleted slot + delete self._positions[valueHash]; + + return true; + } else { + return false; + } +} + +/** + * @dev Removes all the values from a set. O(n). + * + * WARNING: Developers should keep in mind that this function has an unbounded cost and using it may render the + * function uncallable if the set grows to the point where clearing it consumes too much gas to fit in a block. + */ +function clear(${name} storage self) internal { + ${value.type}[] storage v = self._values; + + uint256 len = length(self); + for (uint256 i = 0; i < len; ++i) { + delete self._positions[_hash(v[i])]; + } + assembly ("memory-safe") { + sstore(v.slot, 0) + } +} + +/** + * @dev Returns true if the value is in the self. O(1). + */ +function contains(${name} storage self, ${value.type} memory value) internal view returns (bool) { + return self._positions[_hash(value)] != 0; +} + +/** + * @dev Returns the number of values on the self. O(1). + */ +function length(${name} storage self) internal view returns (uint256) { + return self._values.length; +} + +/** + * @dev Returns the value stored at position \`index\` in the self. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - \`index\` must be strictly less than {length}. + */ +function at(${name} storage self, uint256 index) internal view returns (${value.type} memory) { + return self._values[index]; +} + +/** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ +function values(${name} storage self) internal view returns (${value.type}[] memory) { + return self._values; +} +`; + +const hashes = `\ +function _hash(bytes32[2] memory value) private pure returns (bytes32) { + return Hashes.efficientKeccak256(value[0], value[1]); +} +`; + +// GENERATE +module.exports = format( + header.trimEnd(), + 'library EnumerableSetExtended {', + format( + [].concat( + SET_TYPES.filter(({ value }) => value.size == 0).map(set), + SET_TYPES.filter(({ value }) => value.size > 0).map(arraySet), + hashes, + ), + ).trimEnd(), + '}', +); diff --git a/test/crosschain/ERC7786Aggregator.test.js b/test/crosschain/ERC7786Aggregator.test.js new file mode 100644 index 00000000..c7d91625 --- /dev/null +++ b/test/crosschain/ERC7786Aggregator.test.js @@ -0,0 +1,173 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); + +const AxelarHelper = require('./axelar/AxelarHelper'); + +const getAddress = account => ethers.getAddress(account.target ?? account.address ?? account); + +const N = 3; +const M = 5; + +async function fixture() { + const [owner, sender, ...accounts] = await ethers.getSigners(); + + const protocoles = await Promise.all( + Array(M) + .fill() + .map(() => AxelarHelper.deploy(owner)), + ); + + const { CAIP2 } = protocoles.at(0); + const asCAIP10 = account => `${CAIP2}:${getAddress(account)}`; + + const aggregatorA = await ethers.deployContract('ERC7786Aggregator', [ + owner, + protocoles.map(({ gatewayA }) => gatewayA), + N, + ]); + const aggregatorB = await ethers.deployContract('ERC7786Aggregator', [ + owner, + protocoles.map(({ gatewayB }) => gatewayB), + N, + ]); + await aggregatorA.registerRemoteAggregator(CAIP2, getAddress(aggregatorB)); + await aggregatorB.registerRemoteAggregator(CAIP2, getAddress(aggregatorA)); + + return { owner, sender, accounts, CAIP2, asCAIP10, protocoles, aggregatorA, aggregatorB }; +} + +describe('ERC7786Aggregator', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('initial setup', async function () { + await expect(this.aggregatorA.getGateways()).to.eventually.deep.equal( + this.protocoles.map(({ gatewayA }) => getAddress(gatewayA)), + ); + await expect(this.aggregatorA.getThreshold()).to.eventually.equal(N); + await expect(this.aggregatorA.getRemoteAggregator(this.CAIP2)).to.eventually.equal(getAddress(this.aggregatorB)); + + await expect(this.aggregatorB.getGateways()).to.eventually.deep.equal( + this.protocoles.map(({ gatewayB }) => getAddress(gatewayB)), + ); + await expect(this.aggregatorB.getThreshold()).to.eventually.equal(N); + await expect(this.aggregatorB.getRemoteAggregator(this.CAIP2)).to.eventually.equal(getAddress(this.aggregatorA)); + }); + + describe('cross chain call', function () { + it('valid receiver', async function () { + this.destination = await ethers.deployContract('$ERC7786ReceiverMock', [this.aggregatorB]); + this.payload = ethers.randomBytes(128); + this.attributes = []; + this.opts = {}; + this.outcome = true; // execution success + }); + + it('with attributes', async function () { + this.destination = this.accounts[0]; + this.payload = ethers.randomBytes(128); + this.attributes = [ethers.randomBytes(32)]; + this.opts = {}; + this.outcome = 'UnsupportedAttribute'; + }); + + it('with value', async function () { + this.destination = this.accounts[0]; + this.payload = ethers.randomBytes(128); + this.attributes = []; + this.opts = { value: 1n }; + this.outcome = 'ERC7786AggregatorValueNotSupported'; + }); + + it('invalid receiver - receiver revert', async function () { + this.destination = await ethers.deployContract('$ERC7786ReceiverRevertMock'); + this.payload = ethers.randomBytes(128); + this.attributes = []; + this.opts = {}; + this.outcome = false; // execution failed + }); + + it('invalid receiver - bad return value', async function () { + this.destination = await ethers.deployContract('$ERC7786ReceiverInvalidMock'); + this.payload = ethers.randomBytes(128); + this.attributes = []; + this.opts = {}; + this.outcome = 'ERC7786AggregatorInvalidExecutionReturnValue'; // revert with custom error + }); + + it('invalid receiver - EOA', async function () { + this.destination = this.accounts[0]; + this.payload = ethers.randomBytes(128); + this.attributes = []; + this.opts = {}; + this.outcome = 'ERC7786AggregatorInvalidExecutionReturnValue'; // revert with custom error + }); + + afterEach(async function () { + const txPromise = this.aggregatorA + .connect(this.sender) + .sendMessage(this.CAIP2, getAddress(this.destination), this.payload, this.attributes, this.opts ?? {}); + + switch (typeof this.outcome) { + case 'string': { + await expect(txPromise).to.be.revertedWithCustomError(this.aggregatorB, this.outcome); + break; + } + case 'boolean': { + const { logs } = await txPromise.then(tx => tx.wait()); + const [resultId] = logs.find(ev => ev?.fragment?.name == 'Received').args; + + // Message was posted + await expect(txPromise) + .to.emit(this.aggregatorA, 'MessagePosted') + .withArgs( + ethers.ZeroHash, + this.asCAIP10(this.sender), + this.asCAIP10(this.destination), + this.payload, + this.attributes, + ); + + // MessagePosted to all gateways on the A side and received from all gateways on the B side + for (const { gatewayA, gatewayB } of this.protocoles) { + await expect(txPromise) + .to.emit(gatewayA, 'MessagePosted') + .withArgs( + ethers.ZeroHash, + this.asCAIP10(this.aggregatorA), + this.asCAIP10(this.aggregatorB), + anyValue, + anyValue, + ) + .to.emit(this.aggregatorB, 'Received') + .withArgs(resultId, gatewayB); + } + + if (this.outcome) { + await expect(txPromise) + .to.emit(this.destination, 'MessageReceived') + .withArgs(this.aggregatorB, anyValue, this.CAIP2, getAddress(this.sender), this.payload, this.attributes) + .to.emit(this.aggregatorB, 'ExecutionSuccess') + .withArgs(resultId) + .to.not.emit(this.aggregatorB, 'ExecutionFailed'); + + // Number of times the execution succeeded + expect(logs.filter(ev => ev?.fragment?.name == 'ExecutionSuccess').length).to.equal(1); + } else { + await expect(txPromise) + .to.emit(this.aggregatorB, 'ExecutionFailed') + .withArgs(resultId) + .to.not.emit(this.aggregatorB, 'ExecutionSuccess'); + + // Number of times the execution failed + expect(logs.filter(ev => ev?.fragment?.name == 'ExecutionFailed').length).to.equal(M - N + 1); + } + break; + } + } + }); + }); +}); diff --git a/test/crosschain/ERC7786Receiver.test.js b/test/crosschain/ERC7786Receiver.test.js index 84e9dadc..020e5aae 100644 --- a/test/crosschain/ERC7786Receiver.test.js +++ b/test/crosschain/ERC7786Receiver.test.js @@ -33,6 +33,6 @@ describe('ERC7786Receiver', function () { .to.emit(this.gateway, 'MessagePosted') .withArgs(ethers.ZeroHash, this.toCaip10(this.sender), this.toCaip10(this.receiver), payload, attributes) .to.emit(this.receiver, 'MessageReceived') - .withArgs(this.gateway, this.caip2, getAddress(this.sender), payload, attributes); + .withArgs(this.gateway, '', this.caip2, getAddress(this.sender), payload, attributes); // ERC7786GatewayMock uses empty messageId }); }); diff --git a/test/crosschain/axelar/AxelarGateway.test.js b/test/crosschain/axelar/AxelarGateway.test.js index b2802f07..d4585dc6 100644 --- a/test/crosschain/axelar/AxelarGateway.test.js +++ b/test/crosschain/axelar/AxelarGateway.test.js @@ -3,27 +3,21 @@ const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); +const AxelarHelper = require('./AxelarHelper'); + const getAddress = account => ethers.getAddress(account.target ?? account.address ?? account); async function fixture() { const [owner, sender, ...accounts] = await ethers.getSigners(); - const { chainId } = await ethers.provider.getNetwork(); - const CAIP2 = `eip155:${chainId}`; - const asCAIP10 = account => `eip155:${chainId}:${getAddress(account)}`; + const { CAIP2, axelar, gatewayA, gatewayB } = await AxelarHelper.deploy(owner); - const axelar = await ethers.deployContract('$AxelarGatewayMock'); - const srcGateway = await ethers.deployContract('$AxelarGatewaySource', [owner, axelar]); - const dstGateway = await ethers.deployContract('$AxelarGatewayDestination', [owner, axelar, axelar]); - const receiver = await ethers.deployContract('$ERC7786ReceiverMock', [dstGateway]); + const receiver = await ethers.deployContract('$ERC7786ReceiverMock', [gatewayB]); const invalidReceiver = await ethers.deployContract('$ERC7786ReceiverInvalidMock'); - await srcGateway.registerChainEquivalence(CAIP2, 'local'); - await dstGateway.registerChainEquivalence(CAIP2, 'local'); - await srcGateway.registerRemoteGateway(CAIP2, getAddress(dstGateway)); - await dstGateway.registerRemoteGateway(CAIP2, getAddress(srcGateway)); + const asCAIP10 = account => `${CAIP2}:${getAddress(account)}`; - return { owner, sender, accounts, CAIP2, asCAIP10, axelar, srcGateway, dstGateway, receiver, invalidReceiver }; + return { owner, sender, accounts, CAIP2, asCAIP10, axelar, gatewayA, gatewayB, receiver, invalidReceiver }; } describe('AxelarGateway', function () { @@ -32,13 +26,13 @@ describe('AxelarGateway', function () { }); it('initial setup', async function () { - await expect(this.srcGateway.localGateway()).to.eventually.equal(this.axelar); - await expect(this.srcGateway.getEquivalentChain(this.CAIP2)).to.eventually.equal('local'); - await expect(this.srcGateway.getRemoteGateway(this.CAIP2)).to.eventually.equal(getAddress(this.dstGateway)); + await expect(this.gatewayA.gateway()).to.eventually.equal(this.axelar); + await expect(this.gatewayA.getEquivalentChain(this.CAIP2)).to.eventually.equal('local'); + await expect(this.gatewayA.getRemoteGateway(this.CAIP2)).to.eventually.equal(getAddress(this.gatewayB)); - await expect(this.dstGateway.localGateway()).to.eventually.equal(this.axelar); - await expect(this.dstGateway.getEquivalentChain(this.CAIP2)).to.eventually.equal('local'); - await expect(this.dstGateway.getRemoteGateway(this.CAIP2)).to.eventually.equal(getAddress(this.srcGateway)); + await expect(this.gatewayB.gateway()).to.eventually.equal(this.axelar); + await expect(this.gatewayB.getEquivalentChain(this.CAIP2)).to.eventually.equal('local'); + await expect(this.gatewayB.getRemoteGateway(this.CAIP2)).to.eventually.equal(getAddress(this.gatewayA)); }); it('workflow', async function () { @@ -52,29 +46,29 @@ describe('AxelarGateway', function () { ); await expect( - this.srcGateway.connect(this.sender).sendMessage(this.CAIP2, getAddress(this.receiver), payload, attributes), + this.gatewayA.connect(this.sender).sendMessage(this.CAIP2, getAddress(this.receiver), payload, attributes), ) - .to.emit(this.srcGateway, 'MessagePosted') + .to.emit(this.gatewayA, 'MessagePosted') .withArgs(ethers.ZeroHash, srcCAIP10, dstCAIP10, payload, attributes) .to.emit(this.axelar, 'ContractCall') - .withArgs(this.srcGateway, 'local', getAddress(this.dstGateway), ethers.keccak256(encoded), encoded) - .to.emit(this.axelar, 'ContractCallExecuted') + .withArgs(this.gatewayA, 'local', getAddress(this.gatewayB), ethers.keccak256(encoded), encoded) + .to.emit(this.axelar, 'MessageExecuted') .withArgs(anyValue) .to.emit(this.receiver, 'MessageReceived') - .withArgs(this.dstGateway, this.CAIP2, getAddress(this.sender), payload, attributes); + .withArgs(this.gatewayB, anyValue, this.CAIP2, getAddress(this.sender), payload, attributes); }); it('invalid receiver - bad return value', async function () { await expect( - this.srcGateway + this.gatewayA .connect(this.sender) .sendMessage(this.CAIP2, getAddress(this.invalidReceiver), ethers.randomBytes(128), []), - ).to.be.revertedWithCustomError(this.dstGateway, 'ReceiverExecutionFailed'); + ).to.be.revertedWithCustomError(this.gatewayB, 'ReceiverExecutionFailed'); }); it('invalid receiver - EOA', async function () { await expect( - this.srcGateway + this.gatewayA .connect(this.sender) .sendMessage(this.CAIP2, getAddress(this.accounts[0]), ethers.randomBytes(128), []), ).to.be.revertedWithoutReason(); diff --git a/test/crosschain/axelar/AxelarHelper.js b/test/crosschain/axelar/AxelarHelper.js new file mode 100644 index 00000000..d9b06aed --- /dev/null +++ b/test/crosschain/axelar/AxelarHelper.js @@ -0,0 +1,23 @@ +const { ethers } = require('hardhat'); + +async function deploy(owner, CAIP2 = undefined) { + CAIP2 ??= await ethers.provider.getNetwork().then(({ chainId }) => `eip155:${chainId}`); + + const axelar = await ethers.deployContract('AxelarGatewayMock'); + + const gatewayA = await ethers.deployContract('AxelarGatewayDuplex', [axelar, owner]); + const gatewayB = await ethers.deployContract('AxelarGatewayDuplex', [axelar, owner]); + + await Promise.all([ + gatewayA.connect(owner).registerChainEquivalence(CAIP2, 'local'), + gatewayB.connect(owner).registerChainEquivalence(CAIP2, 'local'), + gatewayA.connect(owner).registerRemoteGateway(CAIP2, gatewayB.target), + gatewayB.connect(owner).registerRemoteGateway(CAIP2, gatewayA.target), + ]); + + return { CAIP2, axelar, gatewayA, gatewayB }; +} + +module.exports = { + deploy, +}; diff --git a/test/utils/structs/EnumerableMapExtended.test.js b/test/utils/structs/EnumerableMapExtended.test.js new file mode 100644 index 00000000..ba4942d0 --- /dev/null +++ b/test/utils/structs/EnumerableMapExtended.test.js @@ -0,0 +1,66 @@ +const { ethers } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { mapValues } = require('@openzeppelin/contracts/test/helpers/iterate'); +const { generators } = require('@openzeppelin/contracts/test/helpers/random'); +const { MAP_TYPES } = require('../../../scripts/generate/templates/Enumerable.opts'); + +const { shouldBehaveLikeMap } = require('@openzeppelin/contracts/test/utils/structs/EnumerableMap.behavior'); + +async function fixture() { + const mock = await ethers.deployContract('$EnumerableMapExtended'); + + const env = Object.fromEntries( + MAP_TYPES.map(({ name, key, value }) => [ + name, + { + key, + value, + keys: Array.from({ length: 3 }, generators[key.type]), + values: Array.from({ length: 3 }, generators[value.type]), + zeroValue: generators[value.type].zero, + methods: mapValues( + { + set: `$set(uint256,${key.type},${value.type})`, + get: `$get(uint256,${key.type})`, + tryGet: `$tryGet(uint256,${key.type})`, + remove: `$remove(uint256,${key.type})`, + clear: `$clear_EnumerableMapExtended_${name}(uint256)`, + length: `$length_EnumerableMapExtended_${name}(uint256)`, + at: `$at_EnumerableMapExtended_${name}(uint256,uint256)`, + contains: `$contains(uint256,${key.type})`, + keys: `$keys_EnumerableMapExtended_${name}(uint256)`, + }, + fnSig => + (...args) => + mock.getFunction(fnSig)(0, ...args), + ), + events: { + setReturn: `return$set_EnumerableMapExtended_${name}_${key.type}_${value.type}`, + removeReturn: `return$remove_EnumerableMapExtended_${name}_${key.type}`, + }, + error: key.memory || value.memory ? `EnumerableMapNonexistent${key.name}Key` : `EnumerableMapNonexistentKey`, + }, + ]), + ); + + return { mock, env }; +} + +describe('EnumerableMapExtended', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + for (const { name, key, value } of MAP_TYPES) { + describe(`${name} (enumerable map from ${key.type} to ${value.type})`, function () { + beforeEach(async function () { + Object.assign(this, this.env[name]); + [this.keyA, this.keyB, this.keyC] = this.keys; + [this.valueA, this.valueB, this.valueC] = this.values; + }); + + shouldBehaveLikeMap(); + }); + } +}); diff --git a/test/utils/structs/EnumerableSetExtended.test.js b/test/utils/structs/EnumerableSetExtended.test.js new file mode 100644 index 00000000..d3f698c7 --- /dev/null +++ b/test/utils/structs/EnumerableSetExtended.test.js @@ -0,0 +1,62 @@ +const { ethers } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { mapValues } = require('@openzeppelin/contracts/test/helpers/iterate'); +const { generators } = require('@openzeppelin/contracts/test/helpers/random'); +const { SET_TYPES } = require('../../../scripts/generate/templates/Enumerable.opts'); + +const { shouldBehaveLikeSet } = require('@openzeppelin/contracts/test/utils/structs/EnumerableSet.behavior'); + +async function fixture() { + const mock = await ethers.deployContract('$EnumerableSetExtended'); + + const env = Object.fromEntries( + SET_TYPES.map(({ name, value }) => [ + name, + { + value, + values: Array.from( + { length: 3 }, + value.size ? () => Array.from({ length: value.size }, generators[value.base]) : generators[value.type], + ), + methods: mapValues( + { + add: `$add(uint256,${value.type})`, + remove: `$remove(uint256,${value.type})`, + contains: `$contains(uint256,${value.type})`, + clear: `$clear_EnumerableSetExtended_${name}(uint256)`, + length: `$length_EnumerableSetExtended_${name}(uint256)`, + at: `$at_EnumerableSetExtended_${name}(uint256,uint256)`, + values: `$values_EnumerableSetExtended_${name}(uint256)`, + }, + fnSig => + (...args) => + mock.getFunction(fnSig)(0, ...args), + ), + events: { + addReturn: `return$add_EnumerableSetExtended_${name}_${value.type.replace(/[[\]]/g, '_')}`, + removeReturn: `return$remove_EnumerableSetExtended_${name}_${value.type.replace(/[[\]]/g, '_')}`, + }, + }, + ]), + ); + + return { mock, env }; +} + +describe('EnumerableSetExtended', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + for (const { name, value } of SET_TYPES) { + describe(`${name} (enumerable set of ${value.type})`, function () { + beforeEach(function () { + Object.assign(this, this.env[name]); + [this.valueA, this.valueB, this.valueC] = this.values; + }); + + shouldBehaveLikeSet(); + }); + } +}); From 341fbf99f775341d9abb28b9b8dae69c47c251e8 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 14 Mar 2025 18:29:52 +0100 Subject: [PATCH 03/11] initial testing of the recovery module --- .../ERC7579SocialRecovery.sol} | 125 +++++++++---- .../ISocialRecovery.sol | 2 +- .../mocks/account/AccountERC7579Mock.sol | 4 +- .../account/modules/ERC7579ValidatorMock.sol | 9 + .../modules/ERC7579SocialRecovery.test.js | 177 ++++++++++++++++++ test/helpers/eip712-types.js | 10 + 6 files changed, 286 insertions(+), 41 deletions(-) rename contracts/account/{extensions/SocialRecovery.sol => modules/ERC7579SocialRecovery.sol} (73%) rename contracts/account/{extensions => modules}/ISocialRecovery.sol (98%) create mode 100644 test/account/modules/ERC7579SocialRecovery.test.js diff --git a/contracts/account/extensions/SocialRecovery.sol b/contracts/account/modules/ERC7579SocialRecovery.sol similarity index 73% rename from contracts/account/extensions/SocialRecovery.sol rename to contracts/account/modules/ERC7579SocialRecovery.sol index 89bb4b77..8d0e1e4a 100644 --- a/contracts/account/extensions/SocialRecovery.sol +++ b/contracts/account/modules/ERC7579SocialRecovery.sol @@ -5,29 +5,27 @@ pragma solidity ^0.8.0; import {IERC7579Module, MODULE_TYPE_EXECUTOR, MODULE_TYPE_FALLBACK} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; -import {EnumerableMap} from "@openzeppelin/contracts/utils/structs/EnumerableMap.sol"; import {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol"; +import {EnumerableMapExtended} from "../../utils/structs/EnumerableMapExtended.sol"; +import {Permission, ThresholdConfig, RecoveryConfigArg, IPermissionVerifier, IRecoveryPolicyVerifier, ISocialRecovery} from "./ISocialRecovery.sol"; -import {Permission, ThresholdConfig, RecoveryConfigArg, IPermissionVerifier, IRecoveryPolicyVerifier, ISocialRecoveryModule} from "./ISocialRecovery.sol"; - -contract SocialRecoveryModule is EIP712("SocialRecovery", "1"), Nonces, IERC7579Module, IRecoveryPolicyVerifier { +abstract contract ERC7579SocialRecovery is EIP712, Nonces, IERC7579Module, ISocialRecovery, IRecoveryPolicyVerifier { using Checkpoints for *; - using EnumerableMap for *; + using EnumerableMapExtended for *; using SafeCast for *; bytes32 private constant START_RECOVERY_TYPEHASH = - keccak256("StartRecovery(address account, bytes recovery, uint256 nonce)"); + keccak256("StartRecovery(address account,bytes recovery,uint256 nonce)"); bytes32 private constant CANCEL_RECOVERY_TYPEHASH = - keccak256("CancelRecovery(address account, bytes recovery, uint256 nonce)"); + keccak256("CancelRecovery(address account,bytes recovery,uint256 nonce)"); struct AccountConfig { address verifier; - // EnumerableMap.BytesToUintMap guardians; - EnumerableMap.Bytes32ToUintMap guardians; + EnumerableMapExtended.BytesToUintMap guardians; Checkpoints.Trace160 thresholds; bytes recoveryCall; uint48 expiryTime; @@ -35,28 +33,47 @@ contract SocialRecoveryModule is EIP712("SocialRecovery", "1"), Nonces, IERC7579 mapping(address account => AccountConfig) private _configs; + event RecoveryConfigCleared(address indexed account, RecoveryConfigArg recoveryConfigArgs); + event RecoveryConfigCleared(address indexed account); + event RecoveryStarted(address indexed account, bytes recoveryCall, uint48 expiryTime); + event RecoveryExecuted(address indexed account, bytes recoveryCall); + event RecoveryCanceled(address indexed account); + error InvalidGuardian(address account, bytes identity); + error InvalidSignature(address account, bytes identity); + error PolicyVerificationFailed(address account); + error ThresholdNotReached(address account, uint64 weight); + error AccountNotInRecovery(address account); + error AccountRecoveryPending(address account); + error AccountRecoveryNotReady(address account); + modifier onlyNotRecovering(address account) { - require(_configs[account].expiryTime == 0, "recovering"); + require(_configs[account].expiryTime == 0, AccountRecoveryPending(account)); _; } modifier onlyRecovering(address account) { - require(_configs[account].expiryTime != 0, "not recovering"); + require(_configs[account].expiryTime != 0, AccountNotInRecovery(account)); _; } modifier onlyRecoveryReady(address account) { uint48 expiryTime = _configs[account].expiryTime; - require(expiryTime != 0 && expiryTime <= block.timestamp, "not recovering"); + require(expiryTime != 0 && expiryTime <= block.timestamp, AccountRecoveryNotReady(account)); _; } /**************************************************************************************************************** * IERC7579Module * ****************************************************************************************************************/ - function onInstall(bytes calldata data) public virtual {} + function onInstall(bytes calldata data) public virtual { + if (data.length > 0) { + Address.functionDelegateCall(address(this), data); + } + } - function onUninstall(bytes calldata data) public virtual {} + function onUninstall(bytes calldata /*data*/) public virtual { + _clearConfig(msg.sender); + } function isModuleType(uint256 moduleTypeId) public view virtual returns (bool) { return moduleTypeId == MODULE_TYPE_EXECUTOR || moduleTypeId == MODULE_TYPE_FALLBACK; @@ -77,7 +94,7 @@ contract SocialRecoveryModule is EIP712("SocialRecovery", "1"), Nonces, IERC7579 bytes32 previousHash = bytes32(0); for (uint256 i = 0; i < permissions.length; ++i) { - // unicity + // uniqueness bytes32 newHash = keccak256(permissions[i].identity); if (newHash <= previousHash) return (false, 0); previousHash = newHash; @@ -90,7 +107,7 @@ contract SocialRecoveryModule is EIP712("SocialRecovery", "1"), Nonces, IERC7579 * Social recovery - Core * ****************************************************************************************************************/ function updateGuardians(RecoveryConfigArg calldata recoveryConfigArg) public virtual { - _updateGuardians(msg.sender, recoveryConfigArg); + _overrideConfig(msg.sender, recoveryConfigArg); } function startRecovery(bytes calldata recoveryCall, Permission[] calldata permissions) external { @@ -104,7 +121,9 @@ contract SocialRecoveryModule is EIP712("SocialRecovery", "1"), Nonces, IERC7579 ) public virtual { uint48 lockPeriod = _checkPermissions( account, - _hashTypedDataV4(keccak256(abi.encode(START_RECOVERY_TYPEHASH, account, recoveryCall, _useNonce(account)))), + _hashTypedDataV4( + keccak256(abi.encode(START_RECOVERY_TYPEHASH, account, keccak256(recoveryCall), _useNonce(account))) + ), permissions ); _startRecovery(account, recoveryCall, lockPeriod); @@ -131,7 +150,12 @@ contract SocialRecoveryModule is EIP712("SocialRecovery", "1"), Nonces, IERC7579 account, _hashTypedDataV4( keccak256( - abi.encode(CANCEL_RECOVERY_TYPEHASH, account, _configs[account].recoveryCall, _useNonce(account)) + abi.encode( + CANCEL_RECOVERY_TYPEHASH, + account, + keccak256(_configs[account].recoveryCall), + _useNonce(account) + ) ) ), permissions @@ -147,7 +171,7 @@ contract SocialRecoveryModule is EIP712("SocialRecovery", "1"), Nonces, IERC7579 address account, bytes calldata guardian ) public view virtual returns (bool exist, uint64 property) { - (bool exist_, uint256 property_) = _configs[account].guardians.tryGet(bytes32(guardian)); + (bool exist_, uint256 property_) = _configs[account].guardians.tryGet(guardian); return (exist_, property_.toUint48()); } @@ -162,7 +186,7 @@ contract SocialRecoveryModule is EIP712("SocialRecovery", "1"), Nonces, IERC7579 bytes[] memory guardians = new bytes[](config.guardians.length()); for (uint256 i = 0; i < guardians.length; ++i) { - (bytes32 identity, uint256 property) = config.guardians.at(i); + (bytes memory identity, uint256 property) = config.guardians.at(i); guardians[i] = _formatGuardian(property.toUint64(), identity); } @@ -192,24 +216,21 @@ contract SocialRecoveryModule is EIP712("SocialRecovery", "1"), Nonces, IERC7579 /**************************************************************************************************************** * Social recovery - internal * ****************************************************************************************************************/ - function _updateGuardians(address account, RecoveryConfigArg calldata recoveryConfigArgs) internal virtual { + function _overrideConfig(address account, RecoveryConfigArg calldata recoveryConfigArgs) internal virtual { + _clearConfig(account); + AccountConfig storage config = _configs[account]; // verifier config.verifier = recoveryConfigArgs.verifier == address(0) ? address(this) : recoveryConfigArgs.verifier; // guardians - config.guardians.clear(); for (uint256 i = 0; i < recoveryConfigArgs.guardians.length; ++i) { (uint64 property, bytes calldata identity) = _parseGuardian(recoveryConfigArgs.guardians[i]); - config.guardians.set(bytes32(identity), property); + config.guardians.set(identity, property); } // threshold - Checkpoints.Checkpoint160[] storage ckpts = config.thresholds._checkpoints; - assembly ("memory-safe") { - sstore(ckpts.slot, 0) - } for (uint256 i = 0; i < recoveryConfigArgs.thresholds.length; ++i) { config.thresholds.push( recoveryConfigArgs.thresholds[i].threshold, @@ -217,7 +238,27 @@ contract SocialRecoveryModule is EIP712("SocialRecovery", "1"), Nonces, IERC7579 ); } - // TODO emit event + emit RecoveryConfigCleared(account, recoveryConfigArgs); + } + + function _clearConfig(address account) internal virtual { + AccountConfig storage config = _configs[account]; + + // clear enumerable map + config.guardians.clear(); + + // clear threshold + Checkpoints.Checkpoint160[] storage ckpts = config.thresholds._checkpoints; + assembly ("memory-safe") { + sstore(ckpts.slot, 0) + } + + // clear remaining + delete config.verifier; + delete config.recoveryCall; + delete config.expiryTime; + + emit RecoveryConfigCleared(account); } function _checkPermissions( @@ -230,12 +271,13 @@ contract SocialRecoveryModule is EIP712("SocialRecovery", "1"), Nonces, IERC7579 // verify signature and get properties uint64[] memory properties = new uint64[](permissions.length); for (uint256 i = 0; i < permissions.length; ++i) { - require(config.guardians.contains(bytes32(permissions[i].identity)), "invalid guardian"); + bool validIdentity; + (validIdentity, properties[i]) = isGuardian(account, permissions[i].identity); + require(validIdentity, InvalidGuardian(account, permissions[i].identity)); require( _verifyIdentitySignature(permissions[i].identity, hash, permissions[i].signature), - "InvalidSignature" + InvalidSignature(account, permissions[i].identity) ); - properties[i] = config.guardians.get(bytes32(permissions[i].identity)).toUint64(); } // verify recovery policy @@ -244,11 +286,13 @@ contract SocialRecoveryModule is EIP712("SocialRecovery", "1"), Nonces, IERC7579 permissions, properties ); - require(success); + require(success, PolicyVerificationFailed(account)); // get lock period uint48 lockPeriod = config.thresholds.upperLookup(weight).toUint48(); // uint160 -> uint48 - require(lockPeriod > 0); // TODO: case where the delay is zero ? + + // TODO: case where the delay really is zero vs case where there is no delay? + require(lockPeriod > 0, ThresholdNotReached(account, weight)); return lockPeriod; } @@ -258,11 +302,13 @@ contract SocialRecoveryModule is EIP712("SocialRecovery", "1"), Nonces, IERC7579 bytes calldata recoveryCall, uint48 lockPeriod ) internal virtual onlyNotRecovering(account) { + uint48 expiryTime = SafeCast.toUint48(block.timestamp + lockPeriod); + // set recovery details _configs[account].recoveryCall = recoveryCall; - _configs[account].expiryTime = SafeCast.toUint48(block.timestamp + lockPeriod); + _configs[account].expiryTime = expiryTime; - // TODO emit event + emit RecoveryStarted(account, recoveryCall, expiryTime); } function _executeRecovery(address account) internal virtual onlyRecoveryReady(account) { @@ -276,7 +322,7 @@ contract SocialRecoveryModule is EIP712("SocialRecovery", "1"), Nonces, IERC7579 // perform call Address.functionCall(account, recoveryCall); - // TODO emit event + emit RecoveryExecuted(account, recoveryCall); } function _cancelRecovery(address account) internal virtual onlyRecovering(account) { @@ -284,13 +330,16 @@ contract SocialRecoveryModule is EIP712("SocialRecovery", "1"), Nonces, IERC7579 delete _configs[account].recoveryCall; delete _configs[account].expiryTime; - // TODO emit event + emit RecoveryCanceled(account); } /**************************************************************************************************************** * Helpers * ****************************************************************************************************************/ - function _formatGuardian(uint64 property, bytes32 identity) internal pure virtual returns (bytes memory guardian) { + function _formatGuardian( + uint64 property, + bytes memory identity + ) internal pure virtual returns (bytes memory guardian) { return abi.encodePacked(property, identity); } diff --git a/contracts/account/extensions/ISocialRecovery.sol b/contracts/account/modules/ISocialRecovery.sol similarity index 98% rename from contracts/account/extensions/ISocialRecovery.sol rename to contracts/account/modules/ISocialRecovery.sol index 9410a996..b293cbd1 100644 --- a/contracts/account/extensions/ISocialRecovery.sol +++ b/contracts/account/modules/ISocialRecovery.sol @@ -44,7 +44,7 @@ interface IRecoveryPolicyVerifier { ) external view returns (bool succ, uint64 weight); } -interface ISocialRecoveryModule { +interface ISocialRecovery { function updateGuardians(RecoveryConfigArg calldata recoveryConfigArg) external; function startRecovery(bytes calldata recoveryCall, Permission[] calldata permissions) external; diff --git a/contracts/mocks/account/AccountERC7579Mock.sol b/contracts/mocks/account/AccountERC7579Mock.sol index cf89281f..05638ade 100644 --- a/contracts/mocks/account/AccountERC7579Mock.sol +++ b/contracts/mocks/account/AccountERC7579Mock.sol @@ -8,7 +8,7 @@ import {MODULE_TYPE_VALIDATOR} from "@openzeppelin/contracts/interfaces/draft-IE import {AccountERC7579} from "../../account/extensions/AccountERC7579.sol"; abstract contract AccountERC7579Mock is EIP712, AccountERC7579 { - bytes32 internal constant _PACKED_USER_OPERATION = + bytes32 internal constant PACKED_USER_OPERATION = keccak256( "PackedUserOperation(address sender,uint256 nonce,bytes initCode,bytes callData,bytes32 accountGasLimits,uint256 preVerificationGas,bytes32 gasFees,bytes paymasterAndData)" ); @@ -25,7 +25,7 @@ abstract contract AccountERC7579Mock is EIP712, AccountERC7579 { _hashTypedDataV4( keccak256( abi.encode( - _PACKED_USER_OPERATION, + PACKED_USER_OPERATION, userOp.sender, userOp.nonce, keccak256(userOp.initCode), diff --git a/contracts/mocks/account/modules/ERC7579ValidatorMock.sol b/contracts/mocks/account/modules/ERC7579ValidatorMock.sol index 3eb685e0..9179ccfc 100644 --- a/contracts/mocks/account/modules/ERC7579ValidatorMock.sol +++ b/contracts/mocks/account/modules/ERC7579ValidatorMock.sol @@ -42,4 +42,13 @@ abstract contract ERC7579ValidatorMock is ERC7579ModuleMock(MODULE_TYPE_VALIDATO ? IERC1271.isValidSignature.selector : bytes4(0xffffffff); } + + function getSigner(address account) public view virtual returns (address) { + return _associatedSigners[account]; + } + + function updateSigner(address newSigner) public virtual { + require(newSigner != address(0) && _associatedSigners[msg.sender] != address(0)); + _associatedSigners[msg.sender] = newSigner; + } } diff --git a/test/account/modules/ERC7579SocialRecovery.test.js b/test/account/modules/ERC7579SocialRecovery.test.js new file mode 100644 index 00000000..d662a648 --- /dev/null +++ b/test/account/modules/ERC7579SocialRecovery.test.js @@ -0,0 +1,177 @@ +const { ethers, entrypoint } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); + +const { getDomain } = require('@openzeppelin/contracts/test/helpers/eip712'); +const { + encodeMode, + encodeSingle, + CALL_TYPE_CALL, + MODULE_TYPE_EXECUTOR, +} = require('@openzeppelin/contracts/test/helpers/erc7579'); +const time = require('@openzeppelin/contracts/test/helpers/time'); + +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { PackedUserOperation, StartRecovery } = require('../../helpers/eip712-types'); + +const comp = (a, b) => a > b || -(a < b); + +async function fixture() { + // EOAs and environment + const [other, ...accounts] = await ethers.getSigners(); + + // ERC-7579 validator + const validator = await ethers.deployContract('$ERC7579ValidatorMock'); + const socialRecovery = await ethers.deployContract('$ERC7579SocialRecovery', ['SocialRecovery', '1']); + + // ERC-4337 signer + const signer = ethers.Wallet.createRandom(); + + // ERC-4337 account + const helper = new ERC4337Helper(); + const env = await helper.wait(); + const mock = await helper.newAccount('$AccountERC7579Mock', [ + 'AccountERC7579', + '1', + validator, + ethers.solidityPacked(['address'], [signer.address]), + ]); + + // domain cannot be fetched using getDomain(mock) before the mock is deployed + const domain = { + name: 'AccountERC7579', + version: '1', + chainId: env.chainId, + verifyingContract: mock.address, + }; + + return { ...env, validator, socialRecovery, domain, mock, signer, other, accounts }; +} + +describe('ERC7579SocialRecovery', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + + this.userOp = { nonce: ethers.zeroPadBytes(ethers.hexlify(this.validator.target), 32) }; + this.signUserOp = (userOp, signer = this.signer) => + signer + .signTypedData(this.domain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + }); + + describe('with guardians', function () { + beforeEach(async function () { + await this.other.sendTransaction({ to: this.mock.target, value: ethers.parseEther('1') }); + await this.mock.deploy(); + + this.guardians = [ + { signer: this.accounts[0], weight: 1n }, + { signer: this.accounts[1], weight: 1n }, + { signer: this.accounts[2], weight: 1n }, + ] + .map(entry => + Object.assign(entry, { + identity: ethers.solidityPacked( + ['address', 'bytes'], + [entry.signer.target ?? entry.signer.address ?? entry.signer, entry.data ?? '0x'], + ), + guardian: ethers.solidityPacked( + ['uint64', 'address', 'bytes'], + [entry.weight, entry.signer.target ?? entry.signer.address ?? entry.signer, entry.data ?? '0x'], + ), + }), + ) + .sort((g1, g2) => + comp( + ethers.toBigInt(ethers.keccak256(ethers.getBytes(g1.identity))), + ethers.toBigInt(ethers.keccak256(ethers.getBytes(g2.identity))), + ), + ); + + this.thresholds = [ + { threshold: 2n, lockPeriod: time.duration.days(7n) }, + { threshold: 3n, lockPeriod: time.duration.hours(1n) }, + ]; + }); + + it('workflow', async function () { + // install + await this.mock + .createUserOp({ + ...this.userOp, + target: this.mock.target, + callData: this.mock.interface.encodeFunctionData('installModule', [ + MODULE_TYPE_EXECUTOR, + this.socialRecovery.target, + this.socialRecovery.interface.encodeFunctionData('updateGuardians', [ + { + verifier: this.socialRecovery.target, + guardians: this.guardians.map(({ guardian }) => guardian), + thresholds: this.thresholds, + }, + ]), + ]), + callGas: 1_000_000n, // TODO: estimate ? + }) + .then(op => this.signUserOp(op)) + .then(op => entrypoint.handleOps([op.packed], this.other)); + + // check config + await expect(this.socialRecovery.getAccountConfigs(ethers.Typed.address(this.mock))).to.eventually.deep.equal([ + this.socialRecovery.target, + this.guardians.map(({ guardian }) => guardian), + this.thresholds.map(Object.values), + ]); + + // prepare recovery + const socialRecoveryMessage = { + account: this.mock.target, + recovery: this.mock.interface.encodeFunctionData('executeFromExecutor', [ + encodeMode({ callType: CALL_TYPE_CALL }), + encodeSingle( + this.validator, + 0n, + this.validator.interface.encodeFunctionData('updateSigner', [this.other.address]), + ), + ]), + nonce: await this.socialRecovery.nonces(this.mock), + }; + + const signatures = await getDomain(this.socialRecovery).then(domain => + Promise.all( + this.guardians.map(({ signer, identity }) => + signer + .signTypedData(domain, { StartRecovery }, socialRecoveryMessage) + .then(signature => ({ identity, signature })), + ), + ), + ); + + // start recovery + await expect( + this.socialRecovery.startRecovery( + ethers.Typed.address(socialRecoveryMessage.account), + socialRecoveryMessage.recovery, + signatures, + ), + ) + .to.emit(this.socialRecovery, 'RecoveryStarted') + .withArgs(this.mock, socialRecoveryMessage.recovery, anyValue); + + // wait + await time.increaseBy.timestamp(time.duration.hours(1n)); + + // signer before the recovery + await expect(this.validator.getSigner(this.mock)).to.eventually.equal(this.signer); + + // execute + await expect(this.socialRecovery.executeRecovery(ethers.Typed.address(socialRecoveryMessage.account))) + .to.emit(this.socialRecovery, 'RecoveryExecuted') + .withArgs(this.mock, socialRecoveryMessage.recovery); + + // signer after the recovery + await expect(this.validator.getSigner(this.mock)).to.eventually.equal(this.other); + }); + }); +}); diff --git a/test/helpers/eip712-types.js b/test/helpers/eip712-types.js index ddcfa303..e90dca9c 100644 --- a/test/helpers/eip712-types.js +++ b/test/helpers/eip712-types.js @@ -26,6 +26,16 @@ module.exports = mapValues( validAfter: 'uint48', validUntil: 'uint48', }, + StartRecovery: { + account: 'address', + recovery: 'bytes', + nonce: 'uint256', + }, + CancelRecovery: { + account: 'address', + recovery: 'bytes', + nonce: 'uint256', + }, }, formatType, ); From 2e83566e72c14761a720b47e14a94d10ba4c275e Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 14 Mar 2025 18:35:59 +0100 Subject: [PATCH 04/11] fix pragma --- contracts/account/modules/ERC7579SocialRecovery.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/account/modules/ERC7579SocialRecovery.sol b/contracts/account/modules/ERC7579SocialRecovery.sol index 8d0e1e4a..489a80fc 100644 --- a/contracts/account/modules/ERC7579SocialRecovery.sol +++ b/contracts/account/modules/ERC7579SocialRecovery.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; +pragma solidity ^0.8.27; import {IERC7579Module, MODULE_TYPE_EXECUTOR, MODULE_TYPE_FALLBACK} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; From 051262e58d896d593d19090af1ab1acf17ae7277 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 17 Mar 2025 09:38:24 +0100 Subject: [PATCH 05/11] remove recoveryCall details from cancel --- .../account/modules/ERC7579SocialRecovery.sol | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/contracts/account/modules/ERC7579SocialRecovery.sol b/contracts/account/modules/ERC7579SocialRecovery.sol index 489a80fc..177a6c15 100644 --- a/contracts/account/modules/ERC7579SocialRecovery.sol +++ b/contracts/account/modules/ERC7579SocialRecovery.sol @@ -20,15 +20,14 @@ abstract contract ERC7579SocialRecovery is EIP712, Nonces, IERC7579Module, ISoci bytes32 private constant START_RECOVERY_TYPEHASH = keccak256("StartRecovery(address account,bytes recovery,uint256 nonce)"); - bytes32 private constant CANCEL_RECOVERY_TYPEHASH = - keccak256("CancelRecovery(address account,bytes recovery,uint256 nonce)"); + bytes32 private constant CANCEL_RECOVERY_TYPEHASH = keccak256("CancelRecovery(address account,uint256 nonce)"); struct AccountConfig { - address verifier; EnumerableMapExtended.BytesToUintMap guardians; Checkpoints.Trace160 thresholds; - bytes recoveryCall; + address verifier; uint48 expiryTime; + bytes recoveryCall; } mapping(address account => AccountConfig) private _configs; @@ -148,16 +147,7 @@ abstract contract ERC7579SocialRecovery is EIP712, Nonces, IERC7579Module, ISoci function cancelRecoveryByGuardians(address account, Permission[] calldata permissions) public virtual { _checkPermissions( account, - _hashTypedDataV4( - keccak256( - abi.encode( - CANCEL_RECOVERY_TYPEHASH, - account, - keccak256(_configs[account].recoveryCall), - _useNonce(account) - ) - ) - ), + _hashTypedDataV4(keccak256(abi.encode(CANCEL_RECOVERY_TYPEHASH, account, _useNonce(account)))), permissions ); _cancelRecovery(account); @@ -255,8 +245,8 @@ abstract contract ERC7579SocialRecovery is EIP712, Nonces, IERC7579Module, ISoci // clear remaining delete config.verifier; - delete config.recoveryCall; delete config.expiryTime; + delete config.recoveryCall; emit RecoveryConfigCleared(account); } @@ -305,8 +295,8 @@ abstract contract ERC7579SocialRecovery is EIP712, Nonces, IERC7579Module, ISoci uint48 expiryTime = SafeCast.toUint48(block.timestamp + lockPeriod); // set recovery details - _configs[account].recoveryCall = recoveryCall; _configs[account].expiryTime = expiryTime; + _configs[account].recoveryCall = recoveryCall; emit RecoveryStarted(account, recoveryCall, expiryTime); } @@ -316,8 +306,8 @@ abstract contract ERC7579SocialRecovery is EIP712, Nonces, IERC7579Module, ISoci bytes memory recoveryCall = _configs[account].recoveryCall; // clean (prevents reentry) - delete _configs[account].recoveryCall; delete _configs[account].expiryTime; + delete _configs[account].recoveryCall; // perform call Address.functionCall(account, recoveryCall); @@ -327,8 +317,8 @@ abstract contract ERC7579SocialRecovery is EIP712, Nonces, IERC7579Module, ISoci function _cancelRecovery(address account) internal virtual onlyRecovering(account) { // clean - delete _configs[account].recoveryCall; delete _configs[account].expiryTime; + delete _configs[account].recoveryCall; emit RecoveryCanceled(account); } From 702fb0aa081466561d178c5c45b3d18c48c08455 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 27 Mar 2025 20:32:51 +0100 Subject: [PATCH 06/11] use ERC7913 and remove external RecoveryPolicyVerifier --- .../account/modules/ERC7579SocialRecovery.sol | 161 +++++++----------- contracts/account/modules/ISocialRecovery.sol | 75 -------- contracts/interfaces/IERC7913.sol | 17 ++ contracts/utils/cryptography/ERC7913Utils.sol | 28 +++ .../modules/ERC7579SocialRecovery.test.js | 22 +-- 5 files changed, 114 insertions(+), 189 deletions(-) delete mode 100644 contracts/account/modules/ISocialRecovery.sol create mode 100644 contracts/interfaces/IERC7913.sol create mode 100644 contracts/utils/cryptography/ERC7913Utils.sol diff --git a/contracts/account/modules/ERC7579SocialRecovery.sol b/contracts/account/modules/ERC7579SocialRecovery.sol index 177a6c15..bc49b915 100644 --- a/contracts/account/modules/ERC7579SocialRecovery.sol +++ b/contracts/account/modules/ERC7579SocialRecovery.sol @@ -10,10 +10,10 @@ import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol"; +import {ERC7913Utils} from "../../utils/cryptography/ERC7913Utils.sol"; import {EnumerableMapExtended} from "../../utils/structs/EnumerableMapExtended.sol"; -import {Permission, ThresholdConfig, RecoveryConfigArg, IPermissionVerifier, IRecoveryPolicyVerifier, ISocialRecovery} from "./ISocialRecovery.sol"; -abstract contract ERC7579SocialRecovery is EIP712, Nonces, IERC7579Module, ISocialRecovery, IRecoveryPolicyVerifier { +abstract contract ERC7579SocialRecovery is EIP712, Nonces, IERC7579Module { using Checkpoints for *; using EnumerableMapExtended for *; using SafeCast for *; @@ -22,23 +22,32 @@ abstract contract ERC7579SocialRecovery is EIP712, Nonces, IERC7579Module, ISoci keccak256("StartRecovery(address account,bytes recovery,uint256 nonce)"); bytes32 private constant CANCEL_RECOVERY_TYPEHASH = keccak256("CancelRecovery(address account,uint256 nonce)"); + struct Permission { + bytes signer; + bytes signature; + } + + struct ThresholdConfig { + uint64 threshold; // Threshold value + uint48 lockPeriod; // Lock period for the threshold + } + struct AccountConfig { EnumerableMapExtended.BytesToUintMap guardians; Checkpoints.Trace160 thresholds; - address verifier; uint48 expiryTime; bytes recoveryCall; } mapping(address account => AccountConfig) private _configs; - event RecoveryConfigCleared(address indexed account, RecoveryConfigArg recoveryConfigArgs); + event RecoveryConfigSet(address indexed account, bytes[] guardians, ThresholdConfig[] thresholds); event RecoveryConfigCleared(address indexed account); event RecoveryStarted(address indexed account, bytes recoveryCall, uint48 expiryTime); event RecoveryExecuted(address indexed account, bytes recoveryCall); event RecoveryCanceled(address indexed account); - error InvalidGuardian(address account, bytes identity); - error InvalidSignature(address account, bytes identity); + error InvalidGuardian(address account, bytes signer); + error InvalidSignature(address account, bytes signer); error PolicyVerificationFailed(address account); error ThresholdNotReached(address account, uint64 weight); error AccountNotInRecovery(address account); @@ -78,35 +87,11 @@ abstract contract ERC7579SocialRecovery is EIP712, Nonces, IERC7579Module, ISoci return moduleTypeId == MODULE_TYPE_EXECUTOR || moduleTypeId == MODULE_TYPE_FALLBACK; } - /**************************************************************************************************************** - * Social recovery - IRecoveryPolicyVerifier * - ****************************************************************************************************************/ - function verifyRecoveryPolicy( - address, - Permission[] calldata permissions, - uint64[] calldata properties - ) public view virtual returns (bool success, uint64 weight) { - if (permissions.length != properties.length) return (false, 0); - - success = true; - weight = 0; - - bytes32 previousHash = bytes32(0); - for (uint256 i = 0; i < permissions.length; ++i) { - // uniqueness - bytes32 newHash = keccak256(permissions[i].identity); - if (newHash <= previousHash) return (false, 0); - previousHash = newHash; - // total weight - weight += properties[i]; - } - } - /**************************************************************************************************************** * Social recovery - Core * ****************************************************************************************************************/ - function updateGuardians(RecoveryConfigArg calldata recoveryConfigArg) public virtual { - _overrideConfig(msg.sender, recoveryConfigArg); + function updateGuardians(bytes[] calldata guardians, ThresholdConfig[] calldata thresholds) public virtual { + _overrideConfig(msg.sender, guardians, thresholds); } function startRecovery(bytes calldata recoveryCall, Permission[] calldata permissions) external { @@ -153,41 +138,39 @@ abstract contract ERC7579SocialRecovery is EIP712, Nonces, IERC7579Module, ISoci _cancelRecovery(account); } - function isGuardian(bytes calldata guardian) external view returns (bool exist, uint64 property) { + function isGuardian(bytes calldata guardian) external view returns (bool exist, uint64 weight) { return isGuardian(msg.sender, guardian); } function isGuardian( address account, bytes calldata guardian - ) public view virtual returns (bool exist, uint64 property) { - (bool exist_, uint256 property_) = _configs[account].guardians.tryGet(guardian); - return (exist_, property_.toUint48()); + ) public view virtual returns (bool exist, uint64 weight) { + (bool exist_, uint256 weight_) = _configs[account].guardians.tryGet(guardian); + return (exist_, weight_.toUint48()); } - function getAccountConfigs() external view returns (RecoveryConfigArg memory recoveryConfigArg) { + function getAccountConfigs() external view returns (bytes[] memory guardians, ThresholdConfig[] memory thresholds) { return getAccountConfigs(msg.sender); } function getAccountConfigs( address account - ) public view virtual returns (RecoveryConfigArg memory recoveryConfigArg) { + ) public view virtual returns (bytes[] memory guardians, ThresholdConfig[] memory thresholds) { AccountConfig storage config = _configs[account]; - bytes[] memory guardians = new bytes[](config.guardians.length()); + guardians = new bytes[](config.guardians.length()); for (uint256 i = 0; i < guardians.length; ++i) { - (bytes memory identity, uint256 property) = config.guardians.at(i); - guardians[i] = _formatGuardian(property.toUint64(), identity); + (bytes memory signer, uint256 weight) = config.guardians.at(i); + guardians[i] = _formatGuardian(weight.toUint64(), signer); } - ThresholdConfig[] memory thresholds = new ThresholdConfig[](config.thresholds.length()); + thresholds = new ThresholdConfig[](config.thresholds.length()); for (uint256 i = 0; i < thresholds.length; ++i) { Checkpoints.Checkpoint160 memory ckpt = config.thresholds.at(i.toUint32()); thresholds[i].threshold = ckpt._key.toUint64(); thresholds[i].lockPeriod = ckpt._value.toUint48(); } - - return RecoveryConfigArg({verifier: config.verifier, guardians: guardians, thresholds: thresholds}); } function getRecoveryNonce() external view returns (uint256 nonce) { @@ -206,29 +189,27 @@ abstract contract ERC7579SocialRecovery is EIP712, Nonces, IERC7579Module, ISoci /**************************************************************************************************************** * Social recovery - internal * ****************************************************************************************************************/ - function _overrideConfig(address account, RecoveryConfigArg calldata recoveryConfigArgs) internal virtual { + function _overrideConfig( + address account, + bytes[] calldata guardians, + ThresholdConfig[] calldata thresholds + ) internal virtual { _clearConfig(account); AccountConfig storage config = _configs[account]; - // verifier - config.verifier = recoveryConfigArgs.verifier == address(0) ? address(this) : recoveryConfigArgs.verifier; - // guardians - for (uint256 i = 0; i < recoveryConfigArgs.guardians.length; ++i) { - (uint64 property, bytes calldata identity) = _parseGuardian(recoveryConfigArgs.guardians[i]); - config.guardians.set(identity, property); + for (uint256 i = 0; i < guardians.length; ++i) { + (uint64 weight, bytes calldata signer) = _parseGuardian(guardians[i]); + config.guardians.set(signer, weight); } // threshold - for (uint256 i = 0; i < recoveryConfigArgs.thresholds.length; ++i) { - config.thresholds.push( - recoveryConfigArgs.thresholds[i].threshold, - recoveryConfigArgs.thresholds[i].lockPeriod - ); + for (uint256 i = 0; i < thresholds.length; ++i) { + config.thresholds.push(thresholds[i].threshold, thresholds[i].lockPeriod); } - emit RecoveryConfigCleared(account, recoveryConfigArgs); + emit RecoveryConfigSet(account, guardians, thresholds); } function _clearConfig(address account) internal virtual { @@ -244,7 +225,6 @@ abstract contract ERC7579SocialRecovery is EIP712, Nonces, IERC7579Module, ISoci } // clear remaining - delete config.verifier; delete config.expiryTime; delete config.recoveryCall; @@ -259,30 +239,31 @@ abstract contract ERC7579SocialRecovery is EIP712, Nonces, IERC7579Module, ISoci AccountConfig storage config = _configs[account]; // verify signature and get properties - uint64[] memory properties = new uint64[](permissions.length); + uint64 totalWeight = 0; + bytes32 previousHash = bytes32(0); for (uint256 i = 0; i < permissions.length; ++i) { - bool validIdentity; - (validIdentity, properties[i]) = isGuardian(account, permissions[i].identity); - require(validIdentity, InvalidGuardian(account, permissions[i].identity)); + // uniqueness + bytes32 newHash = keccak256(permissions[i].signer); + require(previousHash < newHash, PolicyVerificationFailed(account)); + previousHash = newHash; + + // validity of signer and signature + (bool validIdentity, uint64 weight) = isGuardian(account, permissions[i].signer); + require(validIdentity, InvalidGuardian(account, permissions[i].signer)); require( - _verifyIdentitySignature(permissions[i].identity, hash, permissions[i].signature), - InvalidSignature(account, permissions[i].identity) + ERC7913Utils.isValidSignatureNow(permissions[i].signer, hash, permissions[i].signature), + InvalidSignature(account, permissions[i].signer) ); - } - // verify recovery policy - (bool success, uint64 weight) = IRecoveryPolicyVerifier(config.verifier).verifyRecoveryPolicy( - account, - permissions, - properties - ); - require(success, PolicyVerificationFailed(account)); + // total weight + totalWeight += weight; + } // get lock period - uint48 lockPeriod = config.thresholds.upperLookup(weight).toUint48(); // uint160 -> uint48 + uint48 lockPeriod = config.thresholds.upperLookup(totalWeight).toUint48(); // uint160 -> uint48 // TODO: case where the delay really is zero vs case where there is no delay? - require(lockPeriod > 0, ThresholdNotReached(account, weight)); + require(lockPeriod > 0, ThresholdNotReached(account, totalWeight)); return lockPeriod; } @@ -326,36 +307,14 @@ abstract contract ERC7579SocialRecovery is EIP712, Nonces, IERC7579Module, ISoci /**************************************************************************************************************** * Helpers * ****************************************************************************************************************/ - function _formatGuardian( - uint64 property, - bytes memory identity - ) internal pure virtual returns (bytes memory guardian) { - return abi.encodePacked(property, identity); + function _formatGuardian(uint64 weight, bytes memory signer) internal pure virtual returns (bytes memory guardian) { + return abi.encodePacked(weight, signer); } function _parseGuardian( bytes calldata guardian - ) internal pure virtual returns (uint64 property, bytes calldata identity) { - property = uint64(bytes8(guardian[0:8])); - identity = guardian[8:]; - } - - function _parseIdentity( - bytes calldata identity - ) internal pure virtual returns (address verifyingContract, bytes calldata signer) { - verifyingContract = address(bytes20(identity[0:20])); - signer = identity[20:]; - } - - function _verifyIdentitySignature( - bytes calldata identity, - bytes32 hash, - bytes calldata signature - ) internal view virtual returns (bool) { - (address verifyingContract, bytes calldata signer) = _parseIdentity(identity); - return - (signer.length == 0) - ? SignatureChecker.isValidSignatureNow(verifyingContract, hash, signature) - : IPermissionVerifier(verifyingContract).isValidPermission(hash, signer, signature); + ) internal pure virtual returns (uint64 weight, bytes calldata signer) { + weight = uint64(bytes8(guardian[0:8])); + signer = guardian[8:]; } } diff --git a/contracts/account/modules/ISocialRecovery.sol b/contracts/account/modules/ISocialRecovery.sol deleted file mode 100644 index b293cbd1..00000000 --- a/contracts/account/modules/ISocialRecovery.sol +++ /dev/null @@ -1,75 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity ^0.8.0; - -/// Identity = [ verifyingContract (address) || signer (bytes) ] -// type Identity = bytes; - -/// Guardian = [ property (uint64) | Identity (bytes) ] = [ property (uint64) || verifyingContract (address) || signer (bytes) ] -// type Guardian = bytes; - -struct Permission { - bytes identity; - bytes signature; -} - -struct ThresholdConfig { - uint64 threshold; // Threshold value - uint48 lockPeriod; // Lock period for the threshold -} - -struct RecoveryConfigArg { - address verifier; - bytes[] guardians; - ThresholdConfig[] thresholds; -} - -interface IPermissionVerifier { - /// @dev Check if the signer key format is correct - function isValidSigner(bytes calldata signer) external view returns (bool); - - /// @dev Validate signature for a given signer - function isValidPermission( - bytes32 hash, - bytes calldata signer, - bytes calldata signature - ) external view returns (bool); -} - -interface IRecoveryPolicyVerifier { - function verifyRecoveryPolicy( - address account, - Permission[] calldata permissions, - uint64[] calldata properties - ) external view returns (bool succ, uint64 weight); -} - -interface ISocialRecovery { - function updateGuardians(RecoveryConfigArg calldata recoveryConfigArg) external; - - function startRecovery(bytes calldata recoveryCall, Permission[] calldata permissions) external; - - function startRecovery(address account, bytes calldata recoveryCall, Permission[] calldata permissions) external; - - function executeRecovery() external; - - function executeRecovery(address account) external; - - function cancelRecovery() external; - - function cancelRecoveryByGuardians(Permission[] calldata permissions) external; - - function cancelRecoveryByGuardians(address account, Permission[] calldata permissions) external; - - function isGuardian(bytes calldata guardian) external view returns (bool exist, uint64 property); - - function isGuardian(address account, bytes calldata guardian) external view returns (bool exist, uint64 property); - - function getAccountConfigs() external view returns (RecoveryConfigArg memory recoveryConfigArg); - - function getAccountConfigs(address account) external view returns (RecoveryConfigArg memory recoveryConfigArg); - - function getRecoveryStatus() external view returns (bool isRecovering, uint48 expiryTime); - - function getRecoveryStatus(address account) external view returns (bool isRecovering, uint48 expiryTime); -} diff --git a/contracts/interfaces/IERC7913.sol b/contracts/interfaces/IERC7913.sol new file mode 100644 index 00000000..58e33409 --- /dev/null +++ b/contracts/interfaces/IERC7913.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/** + * @dev Signature verifier interface. + */ +interface IERC7913SignatureVerifier { + /** + * @dev Verifies `signature` as a valid signature of `hash` by `key`. + * + * MUST return the bytes4 magic value IERC7913SignatureVerifier.verify.selector if the signature is valid. + * SHOULD return 0xffffffff or revert if the signature is not valid. + * SHOULD return 0xffffffff or revert if the key is empty + */ + function verify(bytes calldata key, bytes32 hash, bytes calldata signature) external view returns (bytes4); +} diff --git a/contracts/utils/cryptography/ERC7913Utils.sol b/contracts/utils/cryptography/ERC7913Utils.sol new file mode 100644 index 00000000..75a33dd2 --- /dev/null +++ b/contracts/utils/cryptography/ERC7913Utils.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import {IERC7913SignatureVerifier} from "../../interfaces/IERC7913.sol"; + +library ERC7913Utils { + function isValidSignatureNow( + bytes calldata signer, + bytes32 hash, + bytes memory signature + ) internal view returns (bool) { + if (signer.length < 20) { + return false; + } else if (signer.length == 20) { + return SignatureChecker.isValidSignatureNow(address(bytes20(signer)), hash, signature); + } else { + try IERC7913SignatureVerifier(address(bytes20(signer[0:20]))).verify(signer[20:], hash, signature) returns ( + bytes4 magic + ) { + return magic == IERC7913SignatureVerifier.verify.selector; + } catch { + return false; + } + } + } +} diff --git a/test/account/modules/ERC7579SocialRecovery.test.js b/test/account/modules/ERC7579SocialRecovery.test.js index d662a648..3ac77c5a 100644 --- a/test/account/modules/ERC7579SocialRecovery.test.js +++ b/test/account/modules/ERC7579SocialRecovery.test.js @@ -72,20 +72,20 @@ describe('ERC7579SocialRecovery', function () { ] .map(entry => Object.assign(entry, { - identity: ethers.solidityPacked( + erc7913signer: ethers.solidityPacked( ['address', 'bytes'], - [entry.signer.target ?? entry.signer.address ?? entry.signer, entry.data ?? '0x'], + [entry.signer.target ?? entry.signer.address ?? entry.signer, entry.key ?? '0x'], ), guardian: ethers.solidityPacked( ['uint64', 'address', 'bytes'], - [entry.weight, entry.signer.target ?? entry.signer.address ?? entry.signer, entry.data ?? '0x'], + [entry.weight, entry.signer.target ?? entry.signer.address ?? entry.signer, entry.key ?? '0x'], ), }), ) .sort((g1, g2) => comp( - ethers.toBigInt(ethers.keccak256(ethers.getBytes(g1.identity))), - ethers.toBigInt(ethers.keccak256(ethers.getBytes(g2.identity))), + ethers.toBigInt(ethers.keccak256(ethers.getBytes(g1.erc7913signer))), + ethers.toBigInt(ethers.keccak256(ethers.getBytes(g2.erc7913signer))), ), ); @@ -105,11 +105,8 @@ describe('ERC7579SocialRecovery', function () { MODULE_TYPE_EXECUTOR, this.socialRecovery.target, this.socialRecovery.interface.encodeFunctionData('updateGuardians', [ - { - verifier: this.socialRecovery.target, - guardians: this.guardians.map(({ guardian }) => guardian), - thresholds: this.thresholds, - }, + this.guardians.map(({ guardian }) => guardian), + this.thresholds, ]), ]), callGas: 1_000_000n, // TODO: estimate ? @@ -119,7 +116,6 @@ describe('ERC7579SocialRecovery', function () { // check config await expect(this.socialRecovery.getAccountConfigs(ethers.Typed.address(this.mock))).to.eventually.deep.equal([ - this.socialRecovery.target, this.guardians.map(({ guardian }) => guardian), this.thresholds.map(Object.values), ]); @@ -140,10 +136,10 @@ describe('ERC7579SocialRecovery', function () { const signatures = await getDomain(this.socialRecovery).then(domain => Promise.all( - this.guardians.map(({ signer, identity }) => + this.guardians.map(({ signer, erc7913signer }) => signer .signTypedData(domain, { StartRecovery }, socialRecoveryMessage) - .then(signature => ({ identity, signature })), + .then(signature => ({ signer: erc7913signer, signature })), ), ), ); From d3ae4af535fb20e8ede7b41f586ef338997ae717 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 11 Apr 2025 21:13:49 -0600 Subject: [PATCH 07/11] Fix conflict issues --- CHANGELOG.md | 3 --- contracts/utils/structs/EnumerableMapExtended.sol | 6 ------ 2 files changed, 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e98e74..c9ae9e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,6 @@ - `EnumerableSetExtended` and `EnumerableMapExtended`: Extensions of the `EnumerableSet` and `EnumerableMap` libraries with more types, including non-value types. -<<<<<<< HEAD -======= ## 03-04-2025 - `PaymasterERC20`: Extension of `PaymasterCore` that sponsors user operations against payment in ERC-20 tokens. @@ -14,7 +12,6 @@ - Deprecate `Account` and rename `AccountCore` to `Account`. - Update `Account` and `Paymaster` to support entrypoint v0.8.0. ->>>>>>> master ## 07-03-2025 - `ERC7786Aggregator`: Add an aggregator that implements a meta gateway on top of multiple ERC-7786 gateways. diff --git a/contracts/utils/structs/EnumerableMapExtended.sol b/contracts/utils/structs/EnumerableMapExtended.sol index 8c4d4a68..b568dec1 100644 --- a/contracts/utils/structs/EnumerableMapExtended.sol +++ b/contracts/utils/structs/EnumerableMapExtended.sol @@ -9,11 +9,6 @@ import {EnumerableSetExtended} from "./EnumerableSetExtended.sol"; /** * @dev Library for managing an enumerable variant of Solidity's * https://solidity.readthedocs.io/en/latest/types.html#mapping-types[`mapping`] -<<<<<<< HEAD - * type. - * - * Note: Extensions of openzeppelin/contracts/utils/struct/EnumerableMap.sol. -======= * type for non-value types as keys. * * Maps have the following properties: @@ -49,7 +44,6 @@ import {EnumerableSetExtended} from "./EnumerableSetExtended.sol"; * ==== * * NOTE: Extensions of openzeppelin/contracts/utils/struct/EnumerableMap.sol. ->>>>>>> master */ library EnumerableMapExtended { using EnumerableSet for *; From 21c10253fe5e2105085968f4a7946cb8896fbbeb Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 11 Apr 2025 22:40:22 -0600 Subject: [PATCH 08/11] Document and test EIP7913Utils --- contracts/crosschain/README.adoc | 42 ++++++ contracts/mocks/ERC7913VerifierMock.sol | 28 ++++ contracts/mocks/import.sol | 1 + contracts/utils/cryptography/ERC7913Utils.sol | 42 ++++++ test/utils/cryptography/ERC7913Utils.test.js | 127 ++++++++++++++++++ 5 files changed, 240 insertions(+) create mode 100644 contracts/crosschain/README.adoc create mode 100644 contracts/mocks/ERC7913VerifierMock.sol create mode 100644 test/utils/cryptography/ERC7913Utils.test.js diff --git a/contracts/crosschain/README.adoc b/contracts/crosschain/README.adoc new file mode 100644 index 00000000..1a5707fa --- /dev/null +++ b/contracts/crosschain/README.adoc @@ -0,0 +1,42 @@ += Crosschain + +[.readme-notice] +NOTE: This document is better viewed at https://docs.openzeppelin.com/community-contracts/api/crosschain + +Gateways are contracts that enable cross-chain communication. These can either be a message source or a destination according to ERC-7786, which defines the following interfaces: + + * {IERC7786GatewaySource}: A contract interface to send a message to another contract on a different chain. + * {IERC7786Receiver}: An interface that allows an smart contract to receive a crosschain message provided by a trusted destination gateway. + +The library provides an implementation of an ERC-7786 receiver contract as a building block: + + * {ERC7786Receiver}: ERC-7786 cross-chain message receiver. + +Given ERC-7786 could be enabled natively or via adapters. Developers can access interoperability protocols through gateway adapters. The library includes the following gateway adapters: + + * {AxelarGatewayBase}: Core gateway logic for the https://www.axelar.network/[Axelar] adapter. + * {AxelarGatewaySource}: ERC-7786 source gateway adapter (sending side) for Axelar. + * {AxelarGatewayDestination}: ERC-7786 destination gateway adapter (receiving side) for Axelar. + * {AxelarGatewayDuplex}: ERC-7786 gateway adapter that operates in both directions (i.e. send and receive messages) using the Axelar network. + +== Gateways + +{{IERC7786GatewaySource}} + +== Clients + +{{IERC7786Receiver}} + +{{ERC7786Receiver}} + +== Adapters + +=== Axelar + +{{AxelarGatewayBase}} + +{{AxelarGatewaySource}} + +{{AxelarGatewayDestination}} + +{{AxelarGatewayDuplex}} diff --git a/contracts/mocks/ERC7913VerifierMock.sol b/contracts/mocks/ERC7913VerifierMock.sol new file mode 100644 index 00000000..ac0eeff4 --- /dev/null +++ b/contracts/mocks/ERC7913VerifierMock.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IERC7913SignatureVerifier} from "../../contracts/interfaces/IERC7913.sol"; + +contract ERC7913VerifierMock is IERC7913SignatureVerifier { + // Store valid keys and their corresponding signatures + mapping(bytes32 => bool) private _validKeys; + mapping(bytes32 => mapping(bytes32 => bool)) private _validSignatures; + + constructor() { + // For testing purposes, we'll consider a specific key as valid + bytes32 validKeyHash = keccak256(abi.encodePacked("valid_key")); + _validKeys[validKeyHash] = true; + } + + function verify(bytes calldata key, bytes32 /* hash */, bytes calldata signature) external pure returns (bytes4) { + // For testing purposes, we'll only accept a specific key and signature combination + if ( + keccak256(key) == keccak256(abi.encodePacked("valid_key")) && + keccak256(signature) == keccak256(abi.encodePacked("valid_signature")) + ) { + return IERC7913SignatureVerifier.verify.selector; + } + return 0xffffffff; + } +} diff --git a/contracts/mocks/import.sol b/contracts/mocks/import.sol index a9ceef65..4c81918e 100644 --- a/contracts/mocks/import.sol +++ b/contracts/mocks/import.sol @@ -3,3 +3,4 @@ pragma solidity ^0.8.20; import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import {ERC1271WalletMock} from "@openzeppelin/contracts/mocks/ERC1271WalletMock.sol"; diff --git a/contracts/utils/cryptography/ERC7913Utils.sol b/contracts/utils/cryptography/ERC7913Utils.sol index 75a33dd2..dc422c17 100644 --- a/contracts/utils/cryptography/ERC7913Utils.sol +++ b/contracts/utils/cryptography/ERC7913Utils.sol @@ -5,7 +5,49 @@ pragma solidity ^0.8.20; import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; import {IERC7913SignatureVerifier} from "../../interfaces/IERC7913.sol"; +/** + * @dev Library that provides common ERC-7913 utility functions. + * + * This library extends the functionality of + * https://docs.openzeppelin.com/contracts/5.x/api/utils#SignatureChecker[SignatureChecker] + * to support signature verification for keys that do not have an Ethereum address of their own + * as with ERC-1271. + * + * ERC-7913 enables signature verification for non-Ethereum cryptographic keys such as: + * - Non-native cryptographic curves (e.g., secp256r1, RSA) + * - Hardware devices + * - Email addresses + * - JWT tokens from Web2 services + * + * A signer is represented as a `bytes` object that is the concatenation of a verifier address + * and optionally a key: `verifier || key`. The verifier is a smart contract that implements + * {IERC7913SignatureVerifier} and is responsible for verifying signatures for a specific type + * of key. + * + * This library provides backward compatibility with existing systems: + * - For EOAs: The signer is just the EOA address (20 bytes) + * - For ERC-1271 contracts: The signer is just the contract address (20 bytes) + * - For ERC-7913 keys: The signer is the verifier address concatenated with the key + * + * See https://eips.ethereum.org/EIPS/eip-7913[ERC-7913]. + */ library ERC7913Utils { + /** + * @dev Verifies a signature for a given signer and hash. + * + * The signer is a `bytes` object that is the concatenation of an address and optionally a key: + * `verifier || key`. A signer must be at least 20 bytes long. + * + * Verification is done as follows: + * - If `signer.length < 20`: verification fails + * - If `signer.length == 20`: verification is done using {SignatureChecker} + * - Otherwise: verification is done using {IERC7913SignatureVerifier} + * + * @param signer The signer bytes (verifier address || key) + * @param hash The hash of the message being signed + * @param signature The signature to verify + * @return bool Whether the signature is valid + */ function isValidSignatureNow( bytes calldata signer, bytes32 hash, diff --git a/test/utils/cryptography/ERC7913Utils.test.js b/test/utils/cryptography/ERC7913Utils.test.js new file mode 100644 index 00000000..21908358 --- /dev/null +++ b/test/utils/cryptography/ERC7913Utils.test.js @@ -0,0 +1,127 @@ +const { expect } = require('chai'); +const { ethers } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const TEST_MESSAGE = ethers.id('OpenZeppelin'); +const TEST_MESSAGE_HASH = ethers.hashMessage(TEST_MESSAGE); + +const WRONG_MESSAGE = ethers.id('Nope'); +const WRONG_MESSAGE_HASH = ethers.hashMessage(WRONG_MESSAGE); + +async function fixture() { + const [signer, other] = await ethers.getSigners(); + const mock = await ethers.deployContract('$ERC7913Utils'); + + // Deploy a mock ERC-1271 wallet + const wallet = await ethers.deployContract('ERC1271WalletMock', [signer]); + + // Deploy a mock ERC-7913 verifier + const verifier = await ethers.deployContract('ERC7913VerifierMock'); + + // Create test keys + const validKey = ethers.toUtf8Bytes('valid_key'); + const invalidKey = ethers.randomBytes(32); + + // Create signer bytes (verifier address + key) + const validSignerBytes = ethers.concat([verifier.target, validKey]); + const invalidKeySignerBytes = ethers.concat([verifier.target, invalidKey]); + + // Create test signatures + const validSignature = ethers.toUtf8Bytes('valid_signature'); + const invalidSignature = ethers.randomBytes(65); + + // Get EOA signature from the signer + const eoaSignature = await signer.signMessage(TEST_MESSAGE); + + return { + signer, + other, + mock, + wallet, + verifier, + validKey, + invalidKey, + validSignerBytes, + invalidKeySignerBytes, + validSignature, + invalidSignature, + eoaSignature, + }; +} + +describe('ERC7913Utils', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('isValidSignatureNow', function () { + describe('with EOA signer', function () { + it('with matching signer and signature', async function () { + const eoaSigner = ethers.zeroPadValue(this.signer.address, 20); + await expect(this.mock.$isValidSignatureNow(eoaSigner, TEST_MESSAGE_HASH, this.eoaSignature)).to.eventually.be + .true; + }); + + it('with invalid signer', async function () { + const eoaSigner = ethers.zeroPadValue(this.other.address, 20); + await expect(this.mock.$isValidSignatureNow(eoaSigner, TEST_MESSAGE_HASH, this.eoaSignature)).to.eventually.be + .false; + }); + + it('with invalid signature', async function () { + const eoaSigner = ethers.zeroPadValue(this.signer.address, 20); + await expect(this.mock.$isValidSignatureNow(eoaSigner, WRONG_MESSAGE_HASH, this.eoaSignature)).to.eventually.be + .false; + }); + }); + + describe('with ERC-1271 wallet', function () { + it('with matching signer and signature', async function () { + const walletSigner = ethers.zeroPadValue(this.wallet.target, 20); + await expect(this.mock.$isValidSignatureNow(walletSigner, TEST_MESSAGE_HASH, this.eoaSignature)).to.eventually + .be.true; + }); + + it('with invalid signer', async function () { + const walletSigner = ethers.zeroPadValue(this.mock.target, 20); + await expect(this.mock.$isValidSignatureNow(walletSigner, TEST_MESSAGE_HASH, this.eoaSignature)).to.eventually + .be.false; + }); + + it('with invalid signature', async function () { + const walletSigner = ethers.zeroPadValue(this.wallet.target, 20); + await expect(this.mock.$isValidSignatureNow(walletSigner, WRONG_MESSAGE_HASH, this.eoaSignature)).to.eventually + .be.false; + }); + }); + + describe('with ERC-7913 verifier', function () { + it('with matching signer and signature', async function () { + await expect(this.mock.$isValidSignatureNow(this.validSignerBytes, TEST_MESSAGE_HASH, this.validSignature)).to + .eventually.be.true; + }); + + it('with invalid verifier', async function () { + const invalidVerifierSigner = ethers.concat([this.mock.target, this.validKey]); + await expect(this.mock.$isValidSignatureNow(invalidVerifierSigner, TEST_MESSAGE_HASH, this.validSignature)).to + .eventually.be.false; + }); + + it('with invalid key', async function () { + await expect(this.mock.$isValidSignatureNow(this.invalidKeySignerBytes, TEST_MESSAGE_HASH, this.validSignature)) + .to.eventually.be.false; + }); + + it('with invalid signature', async function () { + await expect(this.mock.$isValidSignatureNow(this.validSignerBytes, TEST_MESSAGE_HASH, this.invalidSignature)).to + .eventually.be.false; + }); + + it('with signer too short', async function () { + const shortSigner = ethers.randomBytes(19); + await expect(this.mock.$isValidSignatureNow(shortSigner, TEST_MESSAGE_HASH, this.validSignature)).to.eventually + .be.false; + }); + }); + }); +}); From 221c7430ce302e1098acc793d272060d6b5484e0 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 11 Apr 2025 22:46:05 -0600 Subject: [PATCH 09/11] simplify --- contracts/utils/cryptography/ERC7913Utils.sol | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/contracts/utils/cryptography/ERC7913Utils.sol b/contracts/utils/cryptography/ERC7913Utils.sol index dc422c17..157dcbfb 100644 --- a/contracts/utils/cryptography/ERC7913Utils.sol +++ b/contracts/utils/cryptography/ERC7913Utils.sol @@ -13,22 +13,6 @@ import {IERC7913SignatureVerifier} from "../../interfaces/IERC7913.sol"; * to support signature verification for keys that do not have an Ethereum address of their own * as with ERC-1271. * - * ERC-7913 enables signature verification for non-Ethereum cryptographic keys such as: - * - Non-native cryptographic curves (e.g., secp256r1, RSA) - * - Hardware devices - * - Email addresses - * - JWT tokens from Web2 services - * - * A signer is represented as a `bytes` object that is the concatenation of a verifier address - * and optionally a key: `verifier || key`. The verifier is a smart contract that implements - * {IERC7913SignatureVerifier} and is responsible for verifying signatures for a specific type - * of key. - * - * This library provides backward compatibility with existing systems: - * - For EOAs: The signer is just the EOA address (20 bytes) - * - For ERC-1271 contracts: The signer is just the contract address (20 bytes) - * - For ERC-7913 keys: The signer is the verifier address concatenated with the key - * * See https://eips.ethereum.org/EIPS/eip-7913[ERC-7913]. */ library ERC7913Utils { @@ -42,11 +26,6 @@ library ERC7913Utils { * - If `signer.length < 20`: verification fails * - If `signer.length == 20`: verification is done using {SignatureChecker} * - Otherwise: verification is done using {IERC7913SignatureVerifier} - * - * @param signer The signer bytes (verifier address || key) - * @param hash The hash of the message being signed - * @param signature The signature to verify - * @return bool Whether the signature is valid */ function isValidSignatureNow( bytes calldata signer, From bdc6b4d5c24dbfb58dc37781ecdc184ca9fafd31 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 11 Apr 2025 22:47:54 -0600 Subject: [PATCH 10/11] remove unnecessary file --- contracts/crosschain/README.adoc | 42 -------------------------------- 1 file changed, 42 deletions(-) delete mode 100644 contracts/crosschain/README.adoc diff --git a/contracts/crosschain/README.adoc b/contracts/crosschain/README.adoc deleted file mode 100644 index 1a5707fa..00000000 --- a/contracts/crosschain/README.adoc +++ /dev/null @@ -1,42 +0,0 @@ -= Crosschain - -[.readme-notice] -NOTE: This document is better viewed at https://docs.openzeppelin.com/community-contracts/api/crosschain - -Gateways are contracts that enable cross-chain communication. These can either be a message source or a destination according to ERC-7786, which defines the following interfaces: - - * {IERC7786GatewaySource}: A contract interface to send a message to another contract on a different chain. - * {IERC7786Receiver}: An interface that allows an smart contract to receive a crosschain message provided by a trusted destination gateway. - -The library provides an implementation of an ERC-7786 receiver contract as a building block: - - * {ERC7786Receiver}: ERC-7786 cross-chain message receiver. - -Given ERC-7786 could be enabled natively or via adapters. Developers can access interoperability protocols through gateway adapters. The library includes the following gateway adapters: - - * {AxelarGatewayBase}: Core gateway logic for the https://www.axelar.network/[Axelar] adapter. - * {AxelarGatewaySource}: ERC-7786 source gateway adapter (sending side) for Axelar. - * {AxelarGatewayDestination}: ERC-7786 destination gateway adapter (receiving side) for Axelar. - * {AxelarGatewayDuplex}: ERC-7786 gateway adapter that operates in both directions (i.e. send and receive messages) using the Axelar network. - -== Gateways - -{{IERC7786GatewaySource}} - -== Clients - -{{IERC7786Receiver}} - -{{ERC7786Receiver}} - -== Adapters - -=== Axelar - -{{AxelarGatewayBase}} - -{{AxelarGatewaySource}} - -{{AxelarGatewayDestination}} - -{{AxelarGatewayDuplex}} From 326626cfcf545bdacbc8b87bc467674b17036dc5 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 11 Apr 2025 23:06:23 -0600 Subject: [PATCH 11/11] Adjustments --- .../modules/ERC7579SocialRecovery.test.js | 17 +++++------------ test/utils/cryptography/ERC7913Utils.test.js | 2 +- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/test/account/modules/ERC7579SocialRecovery.test.js b/test/account/modules/ERC7579SocialRecovery.test.js index 3ac77c5a..d7bda891 100644 --- a/test/account/modules/ERC7579SocialRecovery.test.js +++ b/test/account/modules/ERC7579SocialRecovery.test.js @@ -32,21 +32,14 @@ async function fixture() { const helper = new ERC4337Helper(); const env = await helper.wait(); const mock = await helper.newAccount('$AccountERC7579Mock', [ - 'AccountERC7579', - '1', validator, ethers.solidityPacked(['address'], [signer.address]), ]); - // domain cannot be fetched using getDomain(mock) before the mock is deployed - const domain = { - name: 'AccountERC7579', - version: '1', - chainId: env.chainId, - verifyingContract: mock.address, - }; + // ERC-4337 Entrypoint domain + const entrypointDomain = await getDomain(entrypoint.v08); - return { ...env, validator, socialRecovery, domain, mock, signer, other, accounts }; + return { ...env, validator, socialRecovery, entrypointDomain, mock, signer, other, accounts }; } describe('ERC7579SocialRecovery', function () { @@ -56,7 +49,7 @@ describe('ERC7579SocialRecovery', function () { this.userOp = { nonce: ethers.zeroPadBytes(ethers.hexlify(this.validator.target), 32) }; this.signUserOp = (userOp, signer = this.signer) => signer - .signTypedData(this.domain, { PackedUserOperation }, userOp.packed) + .signTypedData(this.entrypointDomain, { PackedUserOperation }, userOp.packed) .then(signature => Object.assign(userOp, { signature })); }); @@ -112,7 +105,7 @@ describe('ERC7579SocialRecovery', function () { callGas: 1_000_000n, // TODO: estimate ? }) .then(op => this.signUserOp(op)) - .then(op => entrypoint.handleOps([op.packed], this.other)); + .then(op => entrypoint.v08.handleOps([op.packed], this.other)); // check config await expect(this.socialRecovery.getAccountConfigs(ethers.Typed.address(this.mock))).to.eventually.deep.equal([ diff --git a/test/utils/cryptography/ERC7913Utils.test.js b/test/utils/cryptography/ERC7913Utils.test.js index 21908358..6760b839 100644 --- a/test/utils/cryptography/ERC7913Utils.test.js +++ b/test/utils/cryptography/ERC7913Utils.test.js @@ -9,7 +9,7 @@ const WRONG_MESSAGE = ethers.id('Nope'); const WRONG_MESSAGE_HASH = ethers.hashMessage(WRONG_MESSAGE); async function fixture() { - const [signer, other] = await ethers.getSigners(); + const [, signer, other] = await ethers.getSigners(); const mock = await ethers.deployContract('$ERC7913Utils'); // Deploy a mock ERC-1271 wallet