diff --git a/.github/scripts/coverage.sh b/.github/scripts/coverage.sh index cc84f33..0f5667b 100755 --- a/.github/scripts/coverage.sh +++ b/.github/scripts/coverage.sh @@ -11,7 +11,6 @@ echo "$COVERAGE_OUTPUT" echo "=======================" TOTAL_LINE=$(echo "$COVERAGE_OUTPUT" | grep "| Total.*|") - if [ -z "$TOTAL_LINE" ]; then echo "❌ Could not find Total coverage line" exit 1 @@ -44,7 +43,7 @@ fi if [ $FAIL = 1 ]; then echo "" - echo "Coverage check failed! All metrics must be 100%" + echo "Coverage check failed! All coverage metrics must be 100%" exit 1 else echo "✅ Coverage requirements met!" diff --git a/.github/workflows/foundry.yml b/.github/workflows/foundry.yml index acc2d02..a657467 100644 --- a/.github/workflows/foundry.yml +++ b/.github/workflows/foundry.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - develop pull_request: workflow_dispatch: @@ -44,4 +45,5 @@ jobs: run: forge test -vvv - name: Run coverage + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') run: ./.github/scripts/coverage.sh diff --git a/.husky/commit-msg b/.husky/commit-msg old mode 100644 new mode 100755 index a78cc75..99b9bf1 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1 +1,4 @@ -npx commitlint --edit $1 +#!/usr/bin/env sh +. "$(dirname "$0")/_/h" + +npx commitlint --edit $1 \ No newline at end of file diff --git a/.husky/post-checkout b/.husky/post-checkout old mode 100644 new mode 100755 index 8020652..32d365c --- a/.husky/post-checkout +++ b/.husky/post-checkout @@ -1 +1,4 @@ -npm i +#!/usr/bin/env sh +. "$(dirname "$0")/_/h" + +npm i \ No newline at end of file diff --git a/.husky/post-merge b/.husky/post-merge old mode 100644 new mode 100755 index 8020652..32d365c --- a/.husky/post-merge +++ b/.husky/post-merge @@ -1 +1,4 @@ -npm i +#!/usr/bin/env sh +. "$(dirname "$0")/_/h" + +npm i \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100644 new mode 100755 index 0fc9de3..a9d1ee7 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,3 @@ -npm run lint -.github/scripts/coverage.sh +STAGED_FILES=$(git diff --cached --name-only --diff-filter=d) +forge fmt +echo "$STAGED_FILES" | xargs -r git add diff --git a/contracts/ClaimIssuer.sol b/contracts/ClaimIssuer.sol index 4d729f6..285ad11 100644 --- a/contracts/ClaimIssuer.sol +++ b/contracts/ClaimIssuer.sol @@ -131,9 +131,6 @@ contract ClaimIssuer is IClaimIssuer, Identity, UUPSUpgradeable { */ // solhint-disable-next-line func-name-mixedcase function __ClaimIssuer_init(address initialManagementKey) internal { - // Initialize UUPS upgradeability - __UUPSUpgradeable_init(); - // Initialize Identity functionality __Identity_init(initialManagementKey); } diff --git a/contracts/IdentityUtilities.sol b/contracts/IdentityUtilities.sol index 4766631..6491d01 100644 --- a/contracts/IdentityUtilities.sol +++ b/contracts/IdentityUtilities.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.27; +pragma solidity 0.8.30; import { IClaimIssuer } from "./interface/IClaimIssuer.sol"; import { IIdentity } from "./interface/IIdentity.sol"; diff --git a/contracts/KeyManager.sol b/contracts/KeyManager.sol index 485e8fd..c4d20cb 100644 --- a/contracts/KeyManager.sol +++ b/contracts/KeyManager.sol @@ -54,9 +54,8 @@ contract KeyManager is IERC734 { * Formula: keccak256(abi.encode(uint256(keccak256(bytes(id))) - 1)) & ~bytes32(uint256(0xff)) * where id is the namespace identifier */ - bytes32 internal constant _KEY_STORAGE_SLOT = keccak256( - abi.encode(uint256(keccak256(bytes("onchainid.keymanager.storage"))) - 1) - ) & ~bytes32(uint256(0xff)); + bytes32 internal constant _KEY_STORAGE_SLOT = + keccak256(abi.encode(uint256(keccak256(bytes("onchainid.keymanager.storage"))) - 1)) & ~bytes32(uint256(0xff)); /** * @notice Prevent any direct calls to the implementation contract (marked by _canInteract = false). diff --git a/contracts/factory/IIdFactory.sol b/contracts/factory/IIdFactory.sol index 739ea73..4e048f0 100644 --- a/contracts/factory/IIdFactory.sol +++ b/contracts/factory/IIdFactory.sol @@ -69,9 +69,10 @@ interface IIdFactory { /** * @dev function used to link a new wallet to an existing identity * @param _newWallet the address of the wallet to link - * requires msg.sender to be linked to an existing onchainid + * requires msg.sender to be actively linked to an existing onchainid * the _newWallet will be linked to the same OID contract as msg.sender - * _newWallet cannot be linked to an OID yet + * _newWallet cannot be actively linked to an OID yet + * if _newWallet was previously unlinked, it can only be re-linked to the same identity * _newWallet cannot be address 0 * cannot link more than 100 wallets to an OID, for gas consumption reason */ @@ -80,12 +81,36 @@ interface IIdFactory { /** * @dev function used to unlink a wallet from an existing identity * @param _oldWallet the address of the wallet to unlink - * requires msg.sender to be linked to the same onchainid as _oldWallet + * requires msg.sender to be actively linked to the same onchainid as _oldWallet * msg.sender cannot be _oldWallet to keep at least 1 wallet linked to any OID * _oldWallet cannot be address 0 + * unlinked wallets remain bound to their identity and can only be re-linked to the same identity */ function unlinkWallet(address _oldWallet) external; + /** + * @dev function used to link a wallet to an identity using signature verification + * @param wallet the address of the wallet to link + * @param signature EIP-712 signature provided by the wallet + * @param nonce the current nonce of the wallet (prevents replay attacks) + * @param expiry expiry timestamp for the signature + * requires the wallet to sign an EIP-712 typed message binding wallet, identity, nonce, and expiry + * requires msg.sender to be the identity contract (called via execute()) + * if the wallet was previously unlinked, it can only be re-linked to the same identity + * wallet cannot be address 0 + * signature must not be expired + * nonce must match the current nonce of the wallet + */ + function linkWalletWithSignature(address wallet, bytes calldata signature, uint256 nonce, uint256 expiry) external; + + /** + * @dev function used to unlink a wallet from an identity, callable by the identity contract + * @param wallet the address of the wallet to unlink + * requires msg.sender to be the identity contract that the wallet is linked to + * wallet cannot be address 0 + */ + function unlinkWalletByIdentity(address wallet) external; + /** * @dev function used to register an address as a token factory * @param _factory the address of the token factory @@ -131,6 +156,13 @@ interface IIdFactory { */ function isTokenFactory(address _factory) external view returns (bool); + /** + * @dev getter for the current nonce of a wallet address (used for EIP-712 replay protection) + * @param owner the wallet address to check the nonce for + * @return the current nonce value + */ + function nonces(address owner) external view returns (uint256); + /** * @dev getter to know if a salt is taken for the create2 deployment * @param _salt the salt used for deployment diff --git a/contracts/factory/IdFactory.sol b/contracts/factory/IdFactory.sol index bd03142..e282d69 100644 --- a/contracts/factory/IdFactory.sol +++ b/contracts/factory/IdFactory.sol @@ -2,6 +2,10 @@ pragma solidity ^0.8.27; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { Nonces } from "@openzeppelin/contracts/utils/Nonces.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import { IERC734 } from "../interface/IERC734.sol"; import { Errors } from "../libraries/Errors.sol"; @@ -10,7 +14,14 @@ import { KeyTypes } from "../libraries/KeyTypes.sol"; import { IdentityProxy } from "../proxy/IdentityProxy.sol"; import { IIdFactory } from "./IIdFactory.sol"; -contract IdFactory is IIdFactory, Ownable { +contract IdFactory is IIdFactory, Ownable, EIP712, Nonces { + + using EnumerableSet for EnumerableSet.AddressSet; + + uint256 private constant _MAX_WALLETS_PER_IDENTITY = 101; + + bytes32 private constant _LINK_WALLET_TYPEHASH = + keccak256("LinkWallet(address wallet,address identity,uint256 nonce,uint256 expiry)"); // address of the _implementationAuthority contract making the link to the implementation contract address public immutable implementationAuthority; @@ -21,11 +32,11 @@ contract IdFactory is IIdFactory, Ownable { // salt is taken and which is not mapping(string => bool) private _saltTaken; - // ONCHAINID of the wallet owner + // ONCHAINID of the wallet owner (never cleared — wallet stays bound to its identity forever) mapping(address => address) private _userIdentity; - // wallets currently linked to an ONCHAINID - mapping(address => address[]) private _wallets; + // wallets actively linked to an ONCHAINID + mapping(address => EnumerableSet.AddressSet) private _wallets; // ONCHAINID of the token mapping(address => address) private _tokenIdentity; @@ -34,7 +45,7 @@ contract IdFactory is IIdFactory, Ownable { mapping(address => address) private _tokenAddress; // setting - constructor(address implementationAuthorityAddress) Ownable(msg.sender) { + constructor(address implementationAuthorityAddress) Ownable(msg.sender) EIP712("IdentityFactory", "1") { require(implementationAuthorityAddress != address(0), Errors.ZeroAddress()); implementationAuthority = implementationAuthorityAddress; } @@ -71,7 +82,7 @@ contract IdFactory is IIdFactory, Ownable { address identity = _deployIdentity(oidSalt, _wallet); _saltTaken[oidSalt] = true; _userIdentity[_wallet] = identity; - _wallets[identity].push(_wallet); + _wallets[identity].add(_wallet); emit WalletLinked(_wallet, identity); return identity; } @@ -105,7 +116,7 @@ contract IdFactory is IIdFactory, Ownable { _saltTaken[oidSalt] = true; _userIdentity[_wallet] = identity; - _wallets[identity].push(_wallet); + _wallets[identity].add(_wallet); emit WalletLinked(_wallet, identity); return identity; @@ -119,7 +130,7 @@ contract IdFactory is IIdFactory, Ownable { override returns (address) { - require(isTokenFactory(msg.sender) || msg.sender == owner(), OwnableUnauthorizedAccount(msg.sender)); + require(isTokenFactory(msg.sender) || msg.sender == owner(), Ownable.OwnableUnauthorizedAccount(msg.sender)); require(_token != address(0), Errors.ZeroAddress()); require(_tokenOwner != address(0), Errors.ZeroAddress()); require(keccak256(abi.encode(_salt)) != keccak256(abi.encode("")), Errors.EmptyString()); @@ -139,14 +150,12 @@ contract IdFactory is IIdFactory, Ownable { */ function linkWallet(address _newWallet) external override { require(_newWallet != address(0), Errors.ZeroAddress()); - require(_userIdentity[msg.sender] != address(0), Errors.WalletNotLinkedToIdentity(msg.sender)); - require(_userIdentity[_newWallet] == address(0), Errors.WalletAlreadyLinkedToIdentity(_newWallet)); - require(_tokenIdentity[_newWallet] == address(0), Errors.TokenAlreadyLinked(_newWallet)); address identity = _userIdentity[msg.sender]; - require(_wallets[identity].length < 101, Errors.MaxWalletsPerIdentityExceeded()); - _userIdentity[_newWallet] = identity; - _wallets[identity].push(_newWallet); - emit WalletLinked(_newWallet, identity); + require( + identity != address(0) && _wallets[identity].contains(msg.sender), + Errors.WalletNotLinkedToIdentity(msg.sender) + ); + _linkWallet(_newWallet, identity); } /** @@ -155,18 +164,42 @@ contract IdFactory is IIdFactory, Ownable { function unlinkWallet(address _oldWallet) external override { require(_oldWallet != address(0), Errors.ZeroAddress()); require(_oldWallet != msg.sender, Errors.CannotBeCalledOnSenderAddress()); - require(_userIdentity[msg.sender] == _userIdentity[_oldWallet], Errors.OnlyLinkedWalletCanUnlink()); - address _identity = _userIdentity[_oldWallet]; - delete _userIdentity[_oldWallet]; - uint256 length = _wallets[_identity].length; - for (uint256 i = 0; i < length; i++) { - if (_wallets[_identity][i] == _oldWallet) { - _wallets[_identity][i] = _wallets[_identity][length - 1]; - _wallets[_identity].pop(); - break; - } - } - emit WalletUnlinked(_oldWallet, _identity); + address identity = _userIdentity[msg.sender]; + require(identity != address(0) && _wallets[identity].contains(msg.sender), Errors.OnlyLinkedWalletCanUnlink()); + require( + _userIdentity[_oldWallet] == identity && _wallets[identity].contains(_oldWallet), + Errors.OnlyLinkedWalletCanUnlink() + ); + _unlinkWallet(_oldWallet, identity); + } + + /** + * @dev See {IIdFactory-linkWalletWithSignature}. + */ + function linkWalletWithSignature(address wallet, bytes calldata signature, uint256 nonce, uint256 expiry) + external + override + { + require(wallet != address(0), Errors.ZeroAddress()); + require(block.timestamp <= expiry, Errors.ExpiredSignature(signature)); + + address identity = msg.sender; + + _useCheckedNonce(wallet, nonce); + _verifyWalletSignature(wallet, identity, nonce, expiry, signature); + _linkWallet(wallet, identity); + } + + /** + * @dev See {IIdFactory-unlinkWalletByIdentity}. + */ + function unlinkWalletByIdentity(address wallet) external override { + require(wallet != address(0), Errors.ZeroAddress()); + require( + _userIdentity[wallet] == msg.sender && _wallets[msg.sender].contains(wallet), + Errors.WalletNotLinkedToIdentity(wallet) + ); + _unlinkWallet(wallet, msg.sender); } /** @@ -177,7 +210,11 @@ contract IdFactory is IIdFactory, Ownable { return _tokenIdentity[_wallet]; } - return _userIdentity[_wallet]; + address identity = _userIdentity[_wallet]; + if (identity != address(0) && _wallets[identity].contains(_wallet)) { + return identity; + } + return address(0); } /** @@ -191,7 +228,7 @@ contract IdFactory is IIdFactory, Ownable { * @dev See {IdFactory-getWallets}. */ function getWallets(address _identity) external view override returns (address[] memory) { - return _wallets[_identity]; + return _wallets[_identity].values(); } /** @@ -201,6 +238,13 @@ contract IdFactory is IIdFactory, Ownable { return _tokenAddress[_identity]; } + /** + * @dev See {Nonces-nonces}. + */ + function nonces(address owner) public view override(IIdFactory, Nonces) returns (uint256) { + return super.nonces(owner); + } + /** * @dev See {IdFactory-isTokenFactory}. */ @@ -226,6 +270,25 @@ contract IdFactory is IIdFactory, Ownable { return addr; } + function _linkWallet(address _wallet, address _identity) private { + address boundIdentity = _userIdentity[_wallet]; + if (boundIdentity != address(0)) { + require(boundIdentity == _identity, Errors.WalletBoundToAnotherIdentity(_wallet, boundIdentity)); + } + require(!_wallets[_identity].contains(_wallet), Errors.WalletAlreadyLinkedToIdentity(_wallet)); + require(_tokenIdentity[_wallet] == address(0), Errors.TokenAlreadyLinked(_wallet)); + require(_wallets[_identity].length() < _MAX_WALLETS_PER_IDENTITY, Errors.MaxWalletsPerIdentityExceeded()); + + _userIdentity[_wallet] = _identity; + _wallets[_identity].add(_wallet); + emit WalletLinked(_wallet, _identity); + } + + function _unlinkWallet(address _wallet, address _identity) private { + _wallets[_identity].remove(_wallet); + emit WalletUnlinked(_wallet, _identity); + } + // function used to deploy an identity using CREATE2 function _deployIdentity(string memory _salt, address _wallet) private returns (address) { bytes memory _code = type(IdentityProxy).creationCode; @@ -234,4 +297,18 @@ contract IdFactory is IIdFactory, Ownable { return _deploy(_salt, bytecode); } + function _verifyWalletSignature( + address wallet, + address identity, + uint256 nonce, + uint256 expiry, + bytes calldata signature + ) private view { + bytes32 structHash = keccak256(abi.encode(_LINK_WALLET_TYPEHASH, wallet, identity, nonce, expiry)); + + bytes32 digest = _hashTypedDataV4(structHash); + (address signer, ECDSA.RecoverError error,) = ECDSA.tryRecover(digest, signature); + require(error == ECDSA.RecoverError.NoError && signer == wallet, Errors.InvalidSignature()); + } + } diff --git a/contracts/interface/IIdentityUtilities.sol b/contracts/interface/IIdentityUtilities.sol index 4afeacd..3914e20 100644 --- a/contracts/interface/IIdentityUtilities.sol +++ b/contracts/interface/IIdentityUtilities.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.27; +pragma solidity 0.8.30; /// @title IIdentityUtilities /// @notice Interface for a schema registry that maps topic IDs to structured metadata schemas diff --git a/contracts/libraries/Errors.sol b/contracts/libraries/Errors.sol index ddd01af..93a88c7 100644 --- a/contracts/libraries/Errors.sol +++ b/contracts/libraries/Errors.sol @@ -15,6 +15,9 @@ library Errors { /// @notice Reverts if the factory is already registered error AlreadyAFactory(address factory); + /// @notice Reverts when the recovered signer does not match the wallet being registered + error InvalidSignature(); + /// @notice Reverts if the function is called on the sender address error CannotBeCalledOnSenderAddress(); @@ -33,9 +36,6 @@ library Errors { /// @notice Reverts if the only linked wallet tries to unlink error OnlyLinkedWalletCanUnlink(); - /// @notice Reverts if the account is not authorized to call the function - error OwnableUnauthorizedAccount(address account); // TODO: OZ - /// @notice Reverts if the salt is taken error SaltTaken(string salt); @@ -51,6 +51,9 @@ library Errors { /// @notice Reverts if the wallet is not linked to an identity error WalletNotLinkedToIdentity(address wallet); + /// @notice Reverts if a previously unlinked wallet is being linked to a different identity + error WalletBoundToAnotherIdentity(address wallet, address boundIdentity); + /* ----- Gateway ----- */ /// @notice The maximum number of signers was reached at deployment. @@ -80,25 +83,7 @@ library Errors { /// @notice A call to the factory failed. error CallToFactoryFailed(); - /* ----- Verifier ----- */ - - /// @notice The claim topic already exists. - error ClaimTopicAlreadyExists(uint256 claimTopic); - - /// @notice The maximum number of claim topics is exceeded. - error MaxClaimTopicsExceeded(); - - /// @notice The maximum number of trusted issuers is exceeded. - error MaxTrustedIssuersExceeded(); - - /// @notice The trusted issuer already exists. - error TrustedIssuerAlreadyExists(address trustedIssuer); - - /// @notice The trusted claim topics cannot be empty. - error TrustedClaimTopicsCannotBeEmpty(); - - /// @notice The trusted issuer does not exist. - error NotATrustedIssuer(address trustedIssuer); + /* ----- IdentityProxy ----- */ /* ----- ClaimIssuer ----- */ @@ -143,23 +128,6 @@ library Errors { /// @notice The claim is invalid. error InvalidClaim(); - /* ----- IdentityUtilities ----- */ - - /// @notice 0 is not a valid topic. - error EmptyTopic(); - - /// @notice 0 is not a valid Format. - error EmptyFormat(); - - /// @notice Name cannot be left empty. - error EmptyName(); - - /// @notice Use update function for existing topics. - error TopicAlreadyExists(uint256 topic); - - /// @notice Topic is not registered yet. - error TopicNotFound(uint256 topic); - /* ----- ClaimIssuerFactory ----- */ /// @notice The claim issuer already exists. diff --git a/foundry.toml b/foundry.toml index 9b51c60..70685f9 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,14 +1,16 @@ [profile.default] src = "contracts" out = "out" -test = "test" script = "scripts" -libs = ["dependencies"] +libs = [ "dependencies" ] -solc_version = "0.8.27" +solc_version = "0.8.30" optimizer = true optimizer_runs = 200 -evm_version = "shanghai" + +[rpc_endpoints] +baseSepolia = "https://base-sepolia.g.alchemy.com/v2/${ALCHEMY_KEY}" +base = "https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}" [profile.default.fmt] sort_imports = true @@ -18,15 +20,11 @@ bracket_spacing = true [profile.default.lint] lint_on_build = true -[rpc_endpoints] -baseSepolia = "https://base-sepolia.g.alchemy.com/v2/${ALCHEMY_KEY}" -base = "https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}" - [soldeer] remappings_regenerate = false [dependencies] -forge-std = { version = "v1.14.0", git = "https://github.com/foundry-rs/forge-std.git", tag = "v1.14.0" } -"@openzeppelin-contracts" = "5.2.0" -"@openzeppelin-contracts-upgradeable" = "4.9.6" +"@openzeppelin-contracts" = "5.6.0" +"@openzeppelin-contracts-upgradeable" = "5.6.0" +forge-std = "1.15.0" solady = "0.1.15" diff --git a/remappings.txt b/remappings.txt index c81387d..5fd08b0 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,5 +1,5 @@ -@forge-std/=dependencies/forge-std-v1.14.0/src/ -@openzeppelin/contracts-upgradeable/=dependencies/@openzeppelin-contracts-upgradeable-4.9.6/ -@openzeppelin/contracts/=dependencies/@openzeppelin-contracts-5.2.0/ -forge-std/=dependencies/forge-std-v1.14.0/src/ +@forge-std/=dependencies/forge-std-1.15.0/ +@openzeppelin/contracts-upgradeable/=dependencies/@openzeppelin-contracts-upgradeable-5.6.0/ +@openzeppelin/contracts/=dependencies/@openzeppelin-contracts-5.6.0/ +forge-std/=dependencies/forge-std-1.15.0/src/ solady/=dependencies/solady-0.1.15/ diff --git a/soldeer.lock b/soldeer.lock index db0b719..9b021cb 100644 --- a/soldeer.lock +++ b/soldeer.lock @@ -1,22 +1,23 @@ [[dependencies]] name = "@openzeppelin-contracts" -version = "5.2.0" -url = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts/5_2_0_11-01-2025_09:30:20_contracts.zip" -checksum = "6dbd0440446b2ed16ca25e9f1af08fc0c5c1e73e71fee86ae8a00daa774e3817" -integrity = "4cb7f3777f67fdf4b7d0e2f94d2f93f198b2e5dce718b7062ac7c2c83e1183bd" +version = "5.6.0" +url = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts/5_6_0_27-02-2026_07:51:53_contracts.zip" +checksum = "abed7c3e54d86ad85fb4b349498b80d282ad52c085ec6ae970bd3f5fc54ed4f2" +integrity = "64d33d721fa88f564c134f9049cccbd4996aec6a109e4b33a415cc0fece75d83" [[dependencies]] name = "@openzeppelin-contracts-upgradeable" -version = "4.9.6" -url = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts-upgradeable/4_9_6_14-03-2024_06:12:03_contracts-upgradeable.zip" -checksum = "dddc8efa3da3dcd0dbda63efdc34d6006ece3ae5a8f46b0cf914870df45b9c71" -integrity = "e67f536c763f35149aac483acfa21637c6b6b6c3f8a71860f8e717cf110138cd" +version = "5.6.0" +url = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts-upgradeable/5_6_0_27-02-2026_07:52:01_contracts-upgradeable.zip" +checksum = "9cb4f3f6d82682588f793dd32e43962d3e2f1c017f9291f11236567364bcf7f8" +integrity = "2f539b6241258fc3c127c97225b3cb1b5e9225e422d2c1cb233c52a6f5c29002" [[dependencies]] name = "forge-std" -version = "v1.14.0" -git = "https://github.com/foundry-rs/forge-std.git" -rev = "1801b0541f4fda118a10798fd3486bb7051c5dd6" +version = "1.15.0" +url = "https://soldeer-revisions.s3.amazonaws.com/forge-std/1_15_0_27-02-2026_08:26:17_forge-std-1.15.zip" +checksum = "40d9b3b3d786eec4cd05fb9d818616015cbe7b8866643a9f0854495c938588c4" +integrity = "92accf4f7850eb9f5832f0ea77d633d36ebe993efc6d6c9f32369d31befc8a75" [[dependencies]] name = "solady" diff --git a/test/Proxy.t.sol b/test/Proxy.t.sol index 9fd2948..f09401f 100644 --- a/test/Proxy.t.sol +++ b/test/Proxy.t.sol @@ -1,7 +1,9 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.27; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { ERC1967Utils } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; + import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import { Errors as OZErrors } from "@openzeppelin/contracts/utils/Errors.sol"; @@ -49,7 +51,7 @@ contract ProxyTest is OnchainIDSetup { function test_preventUpdatingWhenNotOwner() public { vm.prank(alice); - vm.expectRevert(abi.encodeWithSelector(Errors.OwnableUnauthorizedAccount.selector, alice)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); onchainidSetup.implementationAuthority.upgradeTo(address(0)); } diff --git a/test/claim-issuers/ClaimIssuer.t.sol b/test/claim-issuers/ClaimIssuer.t.sol index 9123654..19216e4 100644 --- a/test/claim-issuers/ClaimIssuer.t.sol +++ b/test/claim-issuers/ClaimIssuer.t.sol @@ -142,7 +142,7 @@ contract ClaimIssuerTest is OnchainIDSetup { vm.prank(nonOwner); vm.expectRevert(Errors.SenderDoesNotHaveManagementKey.selector); - proxy.upgradeTo(address(newImpl)); + proxy.upgradeToAndCall(address(newImpl), ""); } function test_upgrade_shouldUpgrade() public { @@ -156,7 +156,7 @@ contract ClaimIssuerTest is OnchainIDSetup { ClaimIssuer newImpl = new ClaimIssuer(freshDeployer); vm.prank(freshDeployer); - proxy.upgradeTo(address(newImpl)); + proxy.upgradeToAndCall(address(newImpl), ""); assertTrue(proxy.keyHasPurpose(ClaimSignerHelper.addressToKey(freshDeployer), KeyPurposes.MANAGEMENT)); } diff --git a/test/factory/ClaimIssuerFactory.t.sol b/test/factory/ClaimIssuerFactory.t.sol index 3cd2fd9..4f74c13 100644 --- a/test/factory/ClaimIssuerFactory.t.sol +++ b/test/factory/ClaimIssuerFactory.t.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.27; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { ClaimIssuer } from "contracts/ClaimIssuer.sol"; import { ClaimIssuerFactory } from "contracts/factory/ClaimIssuerFactory.sol"; import { Errors } from "contracts/libraries/Errors.sol"; @@ -49,7 +50,7 @@ contract ClaimIssuerFactoryTest is Test { function test_revertBlacklistNotOwner() public { vm.prank(alice); - vm.expectRevert(abi.encodeWithSelector(Errors.OwnableUnauthorizedAccount.selector, alice)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); factory.blacklistAddress(deployer, true); } @@ -86,13 +87,13 @@ contract ClaimIssuerFactoryTest is Test { function test_revertDeployOnBehalfNotOwner() public { vm.prank(alice); - vm.expectRevert(abi.encodeWithSelector(Errors.OwnableUnauthorizedAccount.selector, alice)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); factory.deployClaimIssuerOnBehalf(alice); } function test_revertUpdateImplementationNotOwner() public { vm.prank(alice); - vm.expectRevert(abi.encodeWithSelector(Errors.OwnableUnauthorizedAccount.selector, alice)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); factory.updateImplementation(alice); } diff --git a/test/factory/IdFactory.t.sol b/test/factory/IdFactory.t.sol index 728892c..ad78b5c 100644 --- a/test/factory/IdFactory.t.sol +++ b/test/factory/IdFactory.t.sol @@ -4,7 +4,9 @@ pragma solidity ^0.8.27; import { ClaimSignerHelper } from "../helpers/ClaimSignerHelper.sol"; import { OnchainIDSetup } from "../helpers/OnchainIDSetup.sol"; import { Constants } from "../utils/Constants.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { Identity } from "contracts/Identity.sol"; +import { IIdFactory } from "contracts/factory/IIdFactory.sol"; import { IdFactory } from "contracts/factory/IdFactory.sol"; import { Errors } from "contracts/libraries/Errors.sol"; import { KeyPurposes } from "contracts/libraries/KeyPurposes.sol"; @@ -13,6 +15,48 @@ import { RevertingIdentity } from "test/mocks/RevertingIdentity.sol"; contract IdFactoryTest is OnchainIDSetup { + bytes32 private constant _LINK_WALLET_TYPEHASH = + keccak256("LinkWallet(address wallet,address identity,uint256 nonce,uint256 expiry)"); + + /// @dev Builds the EIP-712 domain separator matching IdFactory("IdentityFactory", "1") + function _domainSeparator() internal view returns (bytes32) { + return keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256("IdentityFactory"), + keccak256("1"), + block.chainid, + address(onchainidSetup.idFactory) + ) + ); + } + + /// @dev Builds an EIP-712 signature for linkWalletWithSignature + function _signLinkWallet(uint256 signerPk, address wallet, address identity, uint256 nonce, uint256 expiry) + internal + view + returns (bytes memory) + { + bytes32 structHash = keccak256(abi.encode(_LINK_WALLET_TYPEHASH, wallet, identity, nonce, expiry)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + return abi.encodePacked(r, s, v); + } + + /// @dev Helper: sign and call linkWalletWithSignature via execute() + function _linkWalletWithSig(Identity identity, address walletOwner, address wallet, uint256 walletPk) internal { + // Sign the EIP-712 message + uint256 nonce = onchainidSetup.idFactory.nonces(wallet); + uint256 expiry = block.timestamp + 1 hours; + bytes memory signature = _signLinkWallet(walletPk, wallet, address(identity), nonce, expiry); + + // Call linkWalletWithSignature via identity.execute() + bytes memory callData = + abi.encodeWithSelector(IIdFactory.linkWalletWithSignature.selector, wallet, signature, nonce, expiry); + vm.prank(walletOwner); + identity.execute(address(onchainidSetup.idFactory), 0, callData); + } + // ============ createIdentity ============ function test_revertBecauseAuthorityIsZeroAddress() public { @@ -22,7 +66,7 @@ contract IdFactoryTest is OnchainIDSetup { function test_revertBecauseSenderNotAllowedToCreateIdentities() public { vm.prank(alice); - vm.expectRevert(abi.encodeWithSelector(Errors.OwnableUnauthorizedAccount.selector, alice)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); onchainidSetup.idFactory.createIdentity(address(0), "salt1"); } @@ -67,9 +111,11 @@ contract IdFactoryTest is OnchainIDSetup { onchainidSetup.idFactory.linkWallet(david); } - function test_linkWallet_revertForNewWalletAlreadyLinked() public { + function test_linkWallet_revertForNewWalletBoundToAnotherIdentity() public { vm.prank(bob); - vm.expectRevert(abi.encodeWithSelector(Errors.WalletAlreadyLinkedToIdentity.selector, alice)); + vm.expectRevert( + abi.encodeWithSelector(Errors.WalletBoundToAnotherIdentity.selector, alice, address(aliceIdentity)) + ); onchainidSetup.idFactory.linkWallet(alice); } @@ -276,4 +322,463 @@ contract IdFactoryTest is OnchainIDSetup { badFactory.createIdentity(david, "salt1"); } + // ============ linkWalletWithSignature ============ + + /// @notice Successful path: wallet signs EIP-712 message, identity links wallet via execute() + function test_linkWalletWithSignature_shouldLink() public { + _linkWalletWithSig(aliceIdentity, alice, david, davidPk); + + address[] memory wallets = onchainidSetup.idFactory.getWallets(address(aliceIdentity)); + assertEq(wallets.length, 2); + assertEq(wallets[0], alice); + assertEq(wallets[1], david); + assertEq(onchainidSetup.idFactory.getIdentity(david), address(aliceIdentity)); + } + + /// @notice Nonce should increment after a successful link + function test_linkWalletWithSignature_shouldIncrementNonce() public { + assertEq(onchainidSetup.idFactory.nonces(david), 0); + + _linkWalletWithSig(aliceIdentity, alice, david, davidPk); + + assertEq(onchainidSetup.idFactory.nonces(david), 1); + } + + /// @notice Wallet address cannot be zero + function test_linkWalletWithSignature_revertForZeroAddress() public { + uint256 expiry = block.timestamp + 1 hours; + bytes memory signature = _signLinkWallet(davidPk, address(0), address(aliceIdentity), 0, expiry); + + bytes memory callData = + abi.encodeWithSelector(IIdFactory.linkWalletWithSignature.selector, address(0), signature, 0, expiry); + vm.prank(alice); + // execute() will fail silently (ExecutionFailed event), but the inner call reverts with ZeroAddress + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, callData); + + // Verify the wallet was NOT linked + assertEq(onchainidSetup.idFactory.getIdentity(address(0)), address(0)); + } + + /// @notice Expired signature should revert + function test_linkWalletWithSignature_revertForExpiredSignature() public { + uint256 expiry = block.timestamp - 1; // already expired + bytes memory signature = _signLinkWallet(davidPk, david, address(aliceIdentity), 0, expiry); + + bytes memory callData = + abi.encodeWithSelector(IIdFactory.linkWalletWithSignature.selector, david, signature, 0, expiry); + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, callData); + + // Verify the wallet was NOT linked + assertEq(onchainidSetup.idFactory.getIdentity(david), address(0)); + } + + /// @notice Invalid signature (wrong signer) should revert + function test_linkWalletWithSignature_revertForInvalidSignature() public { + uint256 expiry = block.timestamp + 1 hours; + // Sign with carol's key instead of david's + bytes memory badSignature = _signLinkWallet(carolPk, david, address(aliceIdentity), 0, expiry); + + bytes memory callData = + abi.encodeWithSelector(IIdFactory.linkWalletWithSignature.selector, david, badSignature, 0, expiry); + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, callData); + + // Verify the wallet was NOT linked + assertEq(onchainidSetup.idFactory.getIdentity(david), address(0)); + } + + /// @notice Wrong nonce should revert + function test_linkWalletWithSignature_revertForInvalidNonce() public { + uint256 expiry = block.timestamp + 1 hours; + uint256 wrongNonce = 42; + bytes memory signature = _signLinkWallet(davidPk, david, address(aliceIdentity), wrongNonce, expiry); + + bytes memory callData = + abi.encodeWithSelector(IIdFactory.linkWalletWithSignature.selector, david, signature, wrongNonce, expiry); + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, callData); + + // Verify the wallet was NOT linked + assertEq(onchainidSetup.idFactory.getIdentity(david), address(0)); + } + + /// @notice Replay attack: link -> unlink -> replay same signature should revert + function test_linkWalletWithSignature_revertForReplayAttack() public { + // Step 1: Create and use a valid signature (nonce=0) + uint256 expiry = block.timestamp + 1 hours; + uint256 nonce0 = 0; + bytes memory signature0 = _signLinkWallet(davidPk, david, address(aliceIdentity), nonce0, expiry); + + bytes memory linkCallData = + abi.encodeWithSelector(IIdFactory.linkWalletWithSignature.selector, david, signature0, nonce0, expiry); + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, linkCallData); + + // Verify link succeeded + assertEq(onchainidSetup.idFactory.getIdentity(david), address(aliceIdentity)); + assertEq(onchainidSetup.idFactory.nonces(david), 1); + + // Step 3: Unlink david via identity + bytes memory unlinkCallData = abi.encodeWithSelector(IIdFactory.unlinkWalletByIdentity.selector, david); + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, unlinkCallData); + + // Verify unlink succeeded + assertEq(onchainidSetup.idFactory.getIdentity(david), address(0)); + + // Step 4: Attempt to replay the same signature (nonce=0, but current nonce is 1) + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, linkCallData); + + // Verify the replay was rejected — david is still NOT linked + assertEq(onchainidSetup.idFactory.getIdentity(david), address(0)); + + // Step 5: A fresh signature with nonce=1 should work + uint256 nonce1 = 1; + bytes memory signature1 = _signLinkWallet(davidPk, david, address(aliceIdentity), nonce1, expiry); + bytes memory freshCallData = + abi.encodeWithSelector(IIdFactory.linkWalletWithSignature.selector, david, signature1, nonce1, expiry); + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, freshCallData); + + // Verify fresh signature succeeded + assertEq(onchainidSetup.idFactory.getIdentity(david), address(aliceIdentity)); + assertEq(onchainidSetup.idFactory.nonces(david), 2); + } + + /// @notice Wallet bound to a different identity should revert + function test_linkWalletWithSignature_revertForWalletBoundToAnotherIdentity() public { + // bob is already linked to bob's identity via factory setup + uint256 expiry = block.timestamp + 1 hours; + bytes memory signature = _signLinkWallet(bobPk, bob, address(aliceIdentity), 0, expiry); + + bytes memory callData = + abi.encodeWithSelector(IIdFactory.linkWalletWithSignature.selector, bob, signature, 0, expiry); + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, callData); + + // Verify bob is still linked to bob's identity, NOT alice's + assertEq(onchainidSetup.idFactory.getIdentity(bob), address(bobIdentity)); + } + + /// @notice Wallet that is a token address should revert + function test_linkWalletWithSignature_revertForTokenAddress() public { + // Register david as a token identity so _tokenIdentity[david] != address(0) + vm.prank(deployer); + onchainidSetup.idFactory.createTokenIdentity(david, tokenOwner, "tokenDavid"); + + // Sign and attempt to link + uint256 expiry = block.timestamp + 1 hours; + bytes memory signature = _signLinkWallet(davidPk, david, address(aliceIdentity), 0, expiry); + + bytes memory callData = + abi.encodeWithSelector(IIdFactory.linkWalletWithSignature.selector, david, signature, 0, expiry); + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, callData); + + // Verify david was NOT linked as a user wallet (still only a token identity) + address[] memory wallets = onchainidSetup.idFactory.getWallets(address(aliceIdentity)); + assertEq(wallets.length, 1, "Should still have only alice"); + } + + /// @notice Max wallets exceeded should revert + function test_linkWalletWithSignature_revertForMaxWallets() public { + // Fill alice's identity to max wallets (101) using linkWallet + for (uint256 i = 0; i < 100; i++) { + address newWallet = vm.addr(1000 + i); + vm.prank(alice); + onchainidSetup.idFactory.linkWallet(newWallet); + } + + // Now try to link one more via signature + uint256 expiry = block.timestamp + 1 hours; + bytes memory signature = _signLinkWallet(davidPk, david, address(aliceIdentity), 0, expiry); + + bytes memory callData = + abi.encodeWithSelector(IIdFactory.linkWalletWithSignature.selector, david, signature, 0, expiry); + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, callData); + + // Verify the wallet was NOT linked (max exceeded) + assertEq(onchainidSetup.idFactory.getIdentity(david), address(0)); + } + + /// @notice Direct EOA call should fail because signature binds wallet to EOA address (not an identity) + function test_linkWalletWithSignature_revertForWrongIdentityInSignature() public { + uint256 expiry = block.timestamp + 1 hours; + // Sign for david binding to aliceIdentity, but call from bob (EOA) + // Signature was created for identity=aliceIdentity, but msg.sender=bob — signature mismatch + bytes memory signature = _signLinkWallet(davidPk, david, address(aliceIdentity), 0, expiry); + + vm.prank(bob); + vm.expectRevert(); + onchainidSetup.idFactory.linkWalletWithSignature(david, signature, 0, expiry); + } + + // ============ unlinkWalletByIdentity ============ + + /// @notice Happy path: identity unlinks a wallet via execute() + function test_unlinkWalletByIdentity_shouldUnlink() public { + // First link david via signature + _linkWalletWithSig(aliceIdentity, alice, david, davidPk); + assertEq(onchainidSetup.idFactory.getIdentity(david), address(aliceIdentity)); + + // Unlink david via identity + bytes memory callData = abi.encodeWithSelector(IIdFactory.unlinkWalletByIdentity.selector, david); + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, callData); + + // Verify david is unlinked + assertEq(onchainidSetup.idFactory.getIdentity(david), address(0)); + address[] memory wallets = onchainidSetup.idFactory.getWallets(address(aliceIdentity)); + assertEq(wallets.length, 1); + assertEq(wallets[0], alice); + } + + /// @notice Zero address should revert + function test_unlinkWalletByIdentity_revertForZeroAddress() public { + bytes memory callData = abi.encodeWithSelector(IIdFactory.unlinkWalletByIdentity.selector, address(0)); + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, callData); + + // Verify nothing changed (alice is still linked) + assertEq(onchainidSetup.idFactory.getIdentity(alice), address(aliceIdentity)); + } + + /// @notice Wallet not linked to the calling identity should revert + function test_unlinkWalletByIdentity_revertForWalletNotLinkedToIdentity() public { + // Try to unlink bob from alice's identity - bob is linked to bob's identity + bytes memory callData = abi.encodeWithSelector(IIdFactory.unlinkWalletByIdentity.selector, bob); + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, callData); + + // Verify bob is still linked to bob's identity + assertEq(onchainidSetup.idFactory.getIdentity(bob), address(bobIdentity)); + } + + /// @notice Direct EOA call should fail if wallet is linked to a different identity + function test_unlinkWalletByIdentity_revertForNonIdentityCaller() public { + // alice is linked to aliceIdentity. If david (EOA) calls unlinkWalletByIdentity(alice), + // it should revert because _userIdentity[alice] != david + vm.prank(david); + vm.expectRevert(abi.encodeWithSelector(Errors.WalletNotLinkedToIdentity.selector, alice)); + onchainidSetup.idFactory.unlinkWalletByIdentity(alice); + } + + // ============ Re-link restriction tests ============ + + /// @notice linkWallet: unlinked wallet can be re-linked to the same identity + function test_linkWallet_shouldAllowRelinkToSameIdentity() public { + // Link david to alice's identity + vm.prank(alice); + onchainidSetup.idFactory.linkWallet(david); + assertEq(onchainidSetup.idFactory.getIdentity(david), address(aliceIdentity)); + + // Unlink david + vm.prank(alice); + onchainidSetup.idFactory.unlinkWallet(david); + assertEq(onchainidSetup.idFactory.getIdentity(david), address(0)); + + // Re-link david to the SAME identity — should succeed + vm.prank(alice); + onchainidSetup.idFactory.linkWallet(david); + assertEq(onchainidSetup.idFactory.getIdentity(david), address(aliceIdentity)); + } + + /// @notice linkWallet: unlinked wallet cannot be linked to a different identity + function test_linkWallet_revertForRelinkingToDifferentIdentity() public { + // Link david to alice's identity + vm.prank(alice); + onchainidSetup.idFactory.linkWallet(david); + + // Unlink david + vm.prank(alice); + onchainidSetup.idFactory.unlinkWallet(david); + + // Bob tries to link david to bob's identity — should revert + vm.prank(bob); + vm.expectRevert( + abi.encodeWithSelector(Errors.WalletBoundToAnotherIdentity.selector, david, address(aliceIdentity)) + ); + onchainidSetup.idFactory.linkWallet(david); + } + + /// @notice createIdentity: previously linked wallet cannot create a new identity + function test_createIdentity_revertForPreviouslyLinkedWallet() public { + // Link david to alice's identity + vm.prank(alice); + onchainidSetup.idFactory.linkWallet(david); + + // Unlink david + vm.prank(alice); + onchainidSetup.idFactory.unlinkWallet(david); + + // Try to create a new identity for david — should revert + // because _userIdentity[david] != address(0) (still bound to alice's identity) + vm.prank(deployer); + vm.expectRevert(abi.encodeWithSelector(Errors.WalletAlreadyLinkedToIdentity.selector, david)); + onchainidSetup.idFactory.createIdentity(david, "davidSalt"); + } + + /// @notice createIdentityWithManagementKeys: previously linked wallet cannot create a new identity + function test_createIdentityWithManagementKeys_revertForPreviouslyLinkedWallet() public { + // Link david to alice's identity + vm.prank(alice); + onchainidSetup.idFactory.linkWallet(david); + + // Unlink david + vm.prank(alice); + onchainidSetup.idFactory.unlinkWallet(david); + + // Try to create a new identity for david — should revert + bytes32[] memory keys = new bytes32[](1); + keys[0] = ClaimSignerHelper.addressToKey(alice); + + vm.prank(deployer); + vm.expectRevert(abi.encodeWithSelector(Errors.WalletAlreadyLinkedToIdentity.selector, david)); + onchainidSetup.idFactory.createIdentityWithManagementKeys(david, "davidSalt", keys); + } + + /// @notice linkWalletWithSignature: unlinked wallet can be re-linked to the same identity + function test_linkWalletWithSignature_shouldAllowRelinkToSameIdentity() public { + // Link david to alice's identity via signature + _linkWalletWithSig(aliceIdentity, alice, david, davidPk); + assertEq(onchainidSetup.idFactory.getIdentity(david), address(aliceIdentity)); + + // Unlink david via identity + bytes memory unlinkCallData = abi.encodeWithSelector(IIdFactory.unlinkWalletByIdentity.selector, david); + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, unlinkCallData); + assertEq(onchainidSetup.idFactory.getIdentity(david), address(0)); + + // Re-link david to the SAME identity via signature — should succeed + _linkWalletWithSig(aliceIdentity, alice, david, davidPk); + assertEq(onchainidSetup.idFactory.getIdentity(david), address(aliceIdentity)); + } + + /// @notice linkWalletWithSignature: unlinked wallet cannot be linked to a different identity + function test_linkWalletWithSignature_revertForRelinkToDifferentIdentity() public { + // Link david to alice's identity via signature + _linkWalletWithSig(aliceIdentity, alice, david, davidPk); + + // Unlink david via identity + bytes memory unlinkCallData = abi.encodeWithSelector(IIdFactory.unlinkWalletByIdentity.selector, david); + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, unlinkCallData); + + // Try to link david to bob's identity via signature — should fail + uint256 nonce = onchainidSetup.idFactory.nonces(david); + uint256 expiry = block.timestamp + 1 hours; + bytes memory signature = _signLinkWallet(davidPk, david, address(bobIdentity), nonce, expiry); + + bytes memory callData = + abi.encodeWithSelector(IIdFactory.linkWalletWithSignature.selector, david, signature, nonce, expiry); + vm.prank(bob); + bobIdentity.execute(address(onchainidSetup.idFactory), 0, callData); + + // Verify david was NOT linked to bob's identity + assertEq(onchainidSetup.idFactory.getIdentity(david), address(0)); + } + + /// @notice getIdentity should return zero for an unlinked wallet + function test_getIdentity_shouldReturnZeroForUnlinkedWallet() public { + // Link david to alice's identity + vm.prank(alice); + onchainidSetup.idFactory.linkWallet(david); + assertEq(onchainidSetup.idFactory.getIdentity(david), address(aliceIdentity)); + + // Unlink david + vm.prank(alice); + onchainidSetup.idFactory.unlinkWallet(david); + + // getIdentity should return address(0) even though _userIdentity[david] still stores aliceIdentity + assertEq(onchainidSetup.idFactory.getIdentity(david), address(0)); + } + + /// @notice linkWallet: wallet already actively linked to same identity should revert + function test_linkWallet_revertForWalletAlreadyActivelyLinked() public { + // Link david to alice's identity + vm.prank(alice); + onchainidSetup.idFactory.linkWallet(david); + + // Try to link david again — already actively linked + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(Errors.WalletAlreadyLinkedToIdentity.selector, david)); + onchainidSetup.idFactory.linkWallet(david); + } + + /// @notice unlinkWallet: trying to unlink an unlinked wallet (bound but not active) should revert + function test_unlinkWallet_revertForUnlinkedTarget() public { + // Link david and carol to alice's identity + vm.prank(alice); + onchainidSetup.idFactory.linkWallet(david); + vm.prank(alice); + onchainidSetup.idFactory.linkWallet(carol); + + // Unlink david (now bound but not active) + vm.prank(alice); + onchainidSetup.idFactory.unlinkWallet(david); + + // carol tries to unlink david — david is bound but not actively linked + vm.prank(carol); + vm.expectRevert(Errors.OnlyLinkedWalletCanUnlink.selector); + onchainidSetup.idFactory.unlinkWallet(david); + } + + /// @notice linkWalletWithSignature: wallet already actively linked to same identity should revert + function test_linkWalletWithSignature_revertForWalletAlreadyActivelyLinked() public { + // Link david to alice's identity via signature + _linkWalletWithSig(aliceIdentity, alice, david, davidPk); + assertEq(onchainidSetup.idFactory.getIdentity(david), address(aliceIdentity)); + + // Try to link david again via signature — already actively linked + uint256 nonce = onchainidSetup.idFactory.nonces(david); + uint256 expiry = block.timestamp + 1 hours; + bytes memory signature = _signLinkWallet(davidPk, david, address(aliceIdentity), nonce, expiry); + bytes memory callData = + abi.encodeWithSelector(IIdFactory.linkWalletWithSignature.selector, david, signature, nonce, expiry); + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, callData); + + // Verify david is still linked (call failed silently via execute) + assertEq(onchainidSetup.idFactory.getIdentity(david), address(aliceIdentity)); + } + + /// @notice unlinkWalletByIdentity: trying to unlink a previously linked but now unlinked wallet should revert + function test_unlinkWalletByIdentity_revertForUnlinkedWallet() public { + // Link david to alice's identity via signature + _linkWalletWithSig(aliceIdentity, alice, david, davidPk); + + // Unlink david via identity + bytes memory unlinkCallData = abi.encodeWithSelector(IIdFactory.unlinkWalletByIdentity.selector, david); + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, unlinkCallData); + + // Try to unlink david again — already unlinked but still bound + vm.prank(alice); + aliceIdentity.execute(address(onchainidSetup.idFactory), 0, unlinkCallData); + + // Verify david is still unlinked + assertEq(onchainidSetup.idFactory.getIdentity(david), address(0)); + } + + /// @notice unlinkWallet: unlinked sender cannot unlink another wallet + function test_unlinkWallet_revertForUnlinkedSender() public { + // Link david and carol to alice's identity + vm.prank(alice); + onchainidSetup.idFactory.linkWallet(david); + vm.prank(alice); + onchainidSetup.idFactory.linkWallet(carol); + + // Unlink david + vm.prank(alice); + onchainidSetup.idFactory.unlinkWallet(david); + + // david (now unlinked but still bound) tries to unlink carol — should revert + vm.prank(david); + vm.expectRevert(Errors.OnlyLinkedWalletCanUnlink.selector); + onchainidSetup.idFactory.unlinkWallet(carol); + } + } diff --git a/test/factory/TokenOid.t.sol b/test/factory/TokenOid.t.sol index 32790da..2d3ccdd 100644 --- a/test/factory/TokenOid.t.sol +++ b/test/factory/TokenOid.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.27; import { IdentityHelper } from "../helpers/IdentityHelper.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { IdFactory } from "contracts/factory/IdFactory.sol"; import { Errors } from "contracts/libraries/Errors.sol"; import { Test } from "forge-std/Test.sol"; @@ -28,7 +29,7 @@ contract TokenOidTest is Test { function test_addTokenFactory_revertNotOwner() public { vm.prank(alice); - vm.expectRevert(abi.encodeWithSelector(Errors.OwnableUnauthorizedAccount.selector, alice)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); setup.idFactory.addTokenFactory(alice); } @@ -57,7 +58,7 @@ contract TokenOidTest is Test { function test_removeTokenFactory_revertNotOwner() public { vm.prank(alice); - vm.expectRevert(abi.encodeWithSelector(Errors.OwnableUnauthorizedAccount.selector, alice)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); setup.idFactory.removeTokenFactory(bob); } @@ -86,7 +87,7 @@ contract TokenOidTest is Test { function test_createTokenIdentity_revertNotAuthorized() public { vm.prank(alice); - vm.expectRevert(abi.encodeWithSelector(Errors.OwnableUnauthorizedAccount.selector, alice)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); setup.idFactory.createTokenIdentity(alice, alice, "TST"); } diff --git a/test/gateway/Gateway.t.sol b/test/gateway/Gateway.t.sol index 8dd4003..959553b 100644 --- a/test/gateway/Gateway.t.sol +++ b/test/gateway/Gateway.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.27; import { ClaimSignerHelper } from "../helpers/ClaimSignerHelper.sol"; import { IdentityHelper } from "../helpers/IdentityHelper.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { Identity } from "contracts/Identity.sol"; import { IdFactory } from "contracts/factory/IdFactory.sol"; import { Gateway } from "contracts/gateway/Gateway.sol"; @@ -322,7 +323,7 @@ contract GatewayTest is Test { setup.idFactory.transferOwnership(address(gateway)); vm.prank(alice); - vm.expectRevert(abi.encodeWithSelector(Errors.OwnableUnauthorizedAccount.selector, alice)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); gateway.transferFactoryOwnership(bob); } @@ -334,7 +335,7 @@ contract GatewayTest is Test { bytes memory sig = _signDeploy(carolPk, alice, "saltToUse", expiry); vm.prank(alice); - vm.expectRevert(abi.encodeWithSelector(Errors.OwnableUnauthorizedAccount.selector, alice)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); gateway.revokeSignature(sig); } @@ -357,7 +358,7 @@ contract GatewayTest is Test { bytes memory sig = _signDeploy(carolPk, alice, "saltToUse", expiry); vm.prank(alice); - vm.expectRevert(abi.encodeWithSelector(Errors.OwnableUnauthorizedAccount.selector, alice)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); gateway.approveSignature(sig); } @@ -392,7 +393,7 @@ contract GatewayTest is Test { Gateway gateway = _deployGatewayWithCarol(); vm.prank(alice); - vm.expectRevert(abi.encodeWithSelector(Errors.OwnableUnauthorizedAccount.selector, alice)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); gateway.approveSigner(bob); } @@ -428,7 +429,7 @@ contract GatewayTest is Test { Gateway gateway = _deployGateway(signers); vm.prank(alice); - vm.expectRevert(abi.encodeWithSelector(Errors.OwnableUnauthorizedAccount.selector, alice)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); gateway.revokeSigner(bob); } @@ -460,7 +461,7 @@ contract GatewayTest is Test { setup.idFactory.transferOwnership(address(gateway)); vm.prank(alice); - vm.expectRevert(abi.encodeWithSelector(Errors.OwnableUnauthorizedAccount.selector, alice)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); gateway.callFactory(abi.encodeCall(IdFactory.addTokenFactory, (address(0)))); } diff --git a/test/identities/Init.t.sol b/test/identities/Init.t.sol index 1ee5406..e98c8fd 100644 --- a/test/identities/Init.t.sol +++ b/test/identities/Init.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.27; import { OnchainIDSetup } from "../helpers/OnchainIDSetup.sol"; +import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import { Identity } from "contracts/Identity.sol"; import { Errors } from "contracts/libraries/Errors.sol"; @@ -9,7 +10,7 @@ contract InitTest is OnchainIDSetup { function test_revert_whenReinitializingDeployedIdentity() public { vm.prank(alice); - vm.expectRevert("Initializable: contract is already initialized"); + vm.expectRevert(Initializable.InvalidInitialization.selector); aliceIdentity.initialize(alice); } diff --git a/test/mocks/RevertingIdentity.sol b/test/mocks/RevertingIdentity.sol index efb12bf..563aa47 100644 --- a/test/mocks/RevertingIdentity.sol +++ b/test/mocks/RevertingIdentity.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.27; +pragma solidity 0.8.30; /// @notice Mock identity whose initialize always reverts, causing IdentityProxy CREATE2 to fail contract RevertingIdentity { diff --git a/test/mocks/Test.sol b/test/mocks/Test.sol index 4f0318f..aba9123 100644 --- a/test/mocks/Test.sol +++ b/test/mocks/Test.sol @@ -1,4 +1,4 @@ // SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.27; +pragma solidity 0.8.30; contract Test { } // solhint-disable-line diff --git a/test/mocks/TestIdentityUtilities.sol b/test/mocks/TestIdentityUtilities.sol index 65b53a4..a6478df 100644 --- a/test/mocks/TestIdentityUtilities.sol +++ b/test/mocks/TestIdentityUtilities.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.27; +pragma solidity 0.8.30; import { IdentityUtilities } from "contracts/IdentityUtilities.sol"; import { IIdentity } from "contracts/interface/IIdentity.sol";