diff --git a/src/IdentityToken.sol b/src/IdentityToken.sol index d236506..f69da64 100644 --- a/src/IdentityToken.sol +++ b/src/IdentityToken.sol @@ -11,8 +11,12 @@ import { IIdentityToken } from "./interfaces/IIdentityToken.sol"; contract IdentityToken is ERC721, IIdentityToken { error NonTransferable(); + uint256 private constant BACKUP_TIMELOCK = 7 days; + uint256 private _nextTokenId = 1; + bool private _recovering; + // wallet => tokenId (enforce one identity per wallet) mapping(address => uint256) public ownerToTokenId; @@ -35,6 +39,11 @@ contract IdentityToken is ERC721, IIdentityToken { _; } + modifier onlyBackupWallet(uint256 tokenId) { + if (identityStates[tokenId].backupWallet != msg.sender) revert Errors.NotBackupWallet(); + _; + } + constructor() ERC721("IdentityToken", "IDT") {} function supportsInterface(bytes4 interfaceId) public view override(ERC721, IERC165) returns (bool) { @@ -44,8 +53,8 @@ contract IdentityToken is ERC721, IIdentityToken { function _update(address to, uint256 tokenId, address auth) internal override returns (address) { address from = _ownerOf(tokenId); - // prevent transfers (only mint or burn allowed) - if (from != address(0) && to != address(0)) revert NonTransferable(); + // prevent transfers (only mint, burn, or explicit recovery allowed) + if (!_recovering && from != address(0) && to != address(0)) revert NonTransferable(); address prevOwner = super._update(to, tokenId, auth); @@ -185,6 +194,103 @@ contract IdentityToken is ERC721, IIdentityToken { emit Events.EndorsementGiven(fromTokenId, toTokenId, connectionType, validUntil); } + // ------------------------------------------------------------------------- + // Backup Wallet Management + // ------------------------------------------------------------------------- + + /** + * @dev Initiates a timelocked backup wallet change. The pending address is + * stored; the caller must call finalizeBackupUpdate after BACKUP_TIMELOCK + * has elapsed to commit the change. + */ + function initiateBackupUpdate( + uint256 tokenId, + address newBackup + ) external onlyTokenOwner(tokenId) notCompromised(tokenId) { + DataTypes.IdentityState storage state = identityStates[tokenId]; + state.pendingBackupWallet = newBackup; + state.backupUnlockTime = block.timestamp + BACKUP_TIMELOCK; + emit Events.BackupUpdateInitiated(tokenId, newBackup, state.backupUnlockTime); + } + + /** + * @dev Finalizes a pending backup wallet change after the timelock has passed. + */ + function finalizeBackupUpdate(uint256 tokenId) external onlyTokenOwner(tokenId) notCompromised(tokenId) { + DataTypes.IdentityState storage state = identityStates[tokenId]; + if (state.pendingBackupWallet == address(0)) revert Errors.NoPendingUpdate(); + if (block.timestamp < state.backupUnlockTime) revert Errors.TimelockActive(); + + address newBackup = state.pendingBackupWallet; + state.backupWallet = newBackup; + state.pendingBackupWallet = address(0); + state.backupUnlockTime = 0; + + emit Events.BackupUpdated(tokenId, newBackup); + } + + // ------------------------------------------------------------------------- + // Compromise & Recovery + // ------------------------------------------------------------------------- + + /** + * @dev Marks a token as compromised, freezing all attribute and endorsement + * operations. Callable by the token owner or its registered backup wallet. + */ + function flagCompromised(uint256 tokenId) external { + DataTypes.IdentityState storage state = identityStates[tokenId]; + if (ownerOf(tokenId) != msg.sender && state.backupWallet != msg.sender) { + revert Errors.NotTokenOwner(); + } + state.isCompromised = true; + emit Events.IdentityCompromised(tokenId); + } + + /** + * @dev Recovers a compromised (or otherwise inaccessible) identity by + * transferring it to a new owner. Only the registered backup wallet + * may call this. Clears the isCompromised flag post-transfer. + */ + function recoverIdentity(uint256 tokenId, address newOwner) external onlyBackupWallet(tokenId) { + if (balanceOf(newOwner) != 0) revert Errors.AlreadyHasIdentity(); + + address currentOwner = ownerOf(tokenId); + + _recovering = true; + _transfer(currentOwner, newOwner, tokenId); + _recovering = false; + + identityStates[tokenId].isCompromised = false; + + emit Events.IdentityRecovered(tokenId, newOwner); + } + + // ------------------------------------------------------------------------- + // Endorsement Revocation + // ------------------------------------------------------------------------- + + /** + * @dev Allows the original endorser to revoke a previously given endorsement. + * @param targetTokenId The token that received the endorsement. + * @param index The position of the endorsement in endorsements[targetTokenId]. + */ + function revokeEndorsement(uint256 targetTokenId, uint256 index) external { + DataTypes.Endorsement[] storage list = endorsements[targetTokenId]; + + if (index >= list.length) revert Errors.IndexOutOfBounds(); + + DataTypes.Endorsement storage e = list[index]; + + uint256 callerTokenId = ownerToTokenId[msg.sender]; + if (callerTokenId == 0 || e.endorserTokenId != callerTokenId) revert Errors.NotEndorser(); + + if (e.revokedAt != 0) revert Errors.AlreadyRevoked(); + + e.revokedAt = block.timestamp; + + emit Events.EndorsementRevoked(e.endorserTokenId, targetTokenId, index); + } + // ------------------------------------------------------------------------- // Internal // ------------------------------------------------------------------------- diff --git a/test/IdentityToken.t.sol b/test/IdentityToken.t.sol index 27a8ef2..ef04349 100644 --- a/test/IdentityToken.t.sol +++ b/test/IdentityToken.t.sol @@ -14,6 +14,7 @@ contract IdentityTokenTest is Test { IdentityToken public identityToken; address public alice = address(0x1); address public bob = address(0x2); + address public carol = address(0x3); function setUp() public { identityToken = new IdentityToken(); @@ -529,4 +530,299 @@ contract IdentityTokenTest is Test { assertTrue(identityToken.isExpired(tokenId)); } + + // ------------------------------------------------------------------------- + // Backup Wallet Management + // ------------------------------------------------------------------------- + + /// Helper: initiate + warp past timelock + finalize as `owner`. + function _setupBackupWallet(address owner, uint256 tokenId, address backup) internal { + vm.prank(owner); + identityToken.initiateBackupUpdate(tokenId, backup); + vm.warp(block.timestamp + 7 days + 1); + vm.prank(owner); + identityToken.finalizeBackupUpdate(tokenId); + } + + function test_InitiateBackupUpdate_SetsPendingFields() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + vm.prank(alice); + identityToken.initiateBackupUpdate(tokenId, carol); + + DataTypes.Identity memory id = identityToken.getIdentity(tokenId); + assertEq(id.pendingBackupWallet, carol); + assertGt(id.backupUnlockTime, 0); + } + + function test_InitiateBackupUpdate_EmitsEvent() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + uint256 expectedUnlock = block.timestamp + 7 days; + + vm.prank(alice); + vm.expectEmit(true, false, false, true); + emit Events.BackupUpdateInitiated(tokenId, carol, expectedUnlock); + identityToken.initiateBackupUpdate(tokenId, carol); + } + + function test_FinalizeBackupUpdate_CommitsBackupWallet() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + _setupBackupWallet(alice, tokenId, carol); + + DataTypes.Identity memory id = identityToken.getIdentity(tokenId); + assertEq(id.backupWallet, carol); + assertEq(id.pendingBackupWallet, address(0)); + assertEq(id.backupUnlockTime, 0); + } + + function test_FinalizeBackupUpdate_EmitsEvent() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + vm.prank(alice); + identityToken.initiateBackupUpdate(tokenId, carol); + vm.warp(block.timestamp + 7 days + 1); + + vm.prank(alice); + vm.expectEmit(true, false, false, true); + emit Events.BackupUpdated(tokenId, carol); + identityToken.finalizeBackupUpdate(tokenId); + } + + function test_RevertIf_InitiateBackupUpdate_NotOwner() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + vm.prank(bob); + vm.expectRevert(Errors.NotTokenOwner.selector); + identityToken.initiateBackupUpdate(tokenId, carol); + } + + function test_RevertIf_FinalizeBackupUpdate_TimelockActive() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + vm.prank(alice); + identityToken.initiateBackupUpdate(tokenId, carol); + + vm.prank(alice); + vm.expectRevert(Errors.TimelockActive.selector); + identityToken.finalizeBackupUpdate(tokenId); + } + + function test_RevertIf_FinalizeBackupUpdate_NoPendingUpdate() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + vm.prank(alice); + vm.expectRevert(Errors.NoPendingUpdate.selector); + identityToken.finalizeBackupUpdate(tokenId); + } + + function test_RevertIf_FinalizeBackupUpdate_NotOwner() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + vm.prank(alice); + identityToken.initiateBackupUpdate(tokenId, carol); + vm.warp(block.timestamp + 7 days + 1); + + vm.prank(bob); + vm.expectRevert(Errors.NotTokenOwner.selector); + identityToken.finalizeBackupUpdate(tokenId); + } + + function test_InitiateBackupUpdate_RevertIf_Compromised() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + stdstore.target(address(identityToken)).sig("identityStates(uint256)").with_key(tokenId).depth(0).checked_write( + true + ); + + vm.prank(alice); + vm.expectRevert(Errors.IdentityCompromised.selector); + identityToken.initiateBackupUpdate(tokenId, carol); + } + + // ------------------------------------------------------------------------- + // Flag Compromised + // ------------------------------------------------------------------------- + + function test_FlagCompromised_ByOwner_SetsFlag() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + vm.prank(alice); + identityToken.flagCompromised(tokenId); + + DataTypes.Identity memory id = identityToken.getIdentity(tokenId); + assertTrue(id.isCompromised); + } + + function test_FlagCompromised_ByOwner_EmitsEvent() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + vm.prank(alice); + vm.expectEmit(true, false, false, false); + emit Events.IdentityCompromised(tokenId); + identityToken.flagCompromised(tokenId); + } + + function test_FlagCompromised_ByBackupWallet() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + _setupBackupWallet(alice, tokenId, carol); + + vm.prank(carol); + identityToken.flagCompromised(tokenId); + + DataTypes.Identity memory id = identityToken.getIdentity(tokenId); + assertTrue(id.isCompromised); + } + + function test_RevertIf_FlagCompromised_Unauthorized() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + vm.prank(bob); + vm.expectRevert(Errors.NotTokenOwner.selector); + identityToken.flagCompromised(tokenId); + } + + function test_FlagCompromised_FreezesAttributes() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + vm.prank(alice); + identityToken.flagCompromised(tokenId); + + vm.prank(alice); + vm.expectRevert(Errors.IdentityCompromised.selector); + identityToken.setAttribute(tokenId, "name", bytes("Hacker")); + } + + // ------------------------------------------------------------------------- + // Identity Recovery + // ------------------------------------------------------------------------- + + function test_RecoverIdentity_TransfersOwnership() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + _setupBackupWallet(alice, tokenId, carol); + + vm.prank(alice); + identityToken.flagCompromised(tokenId); + + address newOwner = address(0x4); + + vm.prank(carol); + identityToken.recoverIdentity(tokenId, newOwner); + + assertEq(identityToken.ownerOf(tokenId), newOwner); + assertEq(identityToken.ownerToTokenId(newOwner), tokenId); + assertEq(identityToken.ownerToTokenId(alice), 0); + } + + function test_RecoverIdentity_ResetsIsCompromised() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + _setupBackupWallet(alice, tokenId, carol); + + vm.prank(alice); + identityToken.flagCompromised(tokenId); + + vm.prank(carol); + identityToken.recoverIdentity(tokenId, address(0x4)); + + DataTypes.Identity memory id = identityToken.getIdentity(tokenId); + assertFalse(id.isCompromised); + } + + function test_RecoverIdentity_EmitsEvent() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + _setupBackupWallet(alice, tokenId, carol); + + address newOwner = address(0x4); + + vm.prank(carol); + vm.expectEmit(true, false, false, true); + emit Events.IdentityRecovered(tokenId, newOwner); + identityToken.recoverIdentity(tokenId, newOwner); + } + + function test_RecoverIdentity_WorksWithoutFlaggingCompromised() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + _setupBackupWallet(alice, tokenId, carol); + + address newOwner = address(0x4); + + vm.prank(carol); + identityToken.recoverIdentity(tokenId, newOwner); + + assertEq(identityToken.ownerOf(tokenId), newOwner); + } + + function test_RecoverIdentity_NewOwnerCanUseToken() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + _setupBackupWallet(alice, tokenId, carol); + + address newOwner = address(0x4); + + vm.prank(carol); + identityToken.recoverIdentity(tokenId, newOwner); + + vm.prank(newOwner); + identityToken.setAttribute(tokenId, "name", bytes("Recovered Alice")); + + assertEq(string(identityToken.getAttribute(tokenId, "name")), "Recovered Alice"); + } + + function test_RevertIf_RecoverIdentity_NotBackupWallet() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + vm.prank(bob); + vm.expectRevert(Errors.NotBackupWallet.selector); + identityToken.recoverIdentity(tokenId, address(0x4)); + } + + function test_RevertIf_RecoverIdentity_NewOwnerAlreadyHasIdentity() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + _setupBackupWallet(alice, tokenId, carol); + + vm.prank(bob); + identityToken.mint(); + + vm.prank(carol); + vm.expectRevert(Errors.AlreadyHasIdentity.selector); + identityToken.recoverIdentity(tokenId, bob); + } + + function test_RevertIf_RecoverIdentity_NoBackupWalletSet() public { + vm.prank(alice); + uint256 tokenId = identityToken.mint(); + + // carol was never registered as backup + vm.prank(carol); + vm.expectRevert(Errors.NotBackupWallet.selector); + identityToken.recoverIdentity(tokenId, address(0x4)); + } }