Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 108 additions & 2 deletions src/IdentityToken.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -35,6 +39,11 @@ contract IdentityToken is ERC721, IIdentityToken {
_;
}

modifier onlyBackupWallet(uint256 tokenId) {
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onlyBackupWallet checks identityStates[tokenId].backupWallet before verifying the token exists. For a nonexistent tokenId, recoverIdentity will revert with NotBackupWallet rather than the standard ERC721 nonexistent-token revert, which is misleading and can break callers relying on that behavior. Consider validating ownership/existence first (e.g., _requireOwned(tokenId) or ownerOf(tokenId) inside the modifier) before checking the backup wallet.

Suggested change
modifier onlyBackupWallet(uint256 tokenId) {
modifier onlyBackupWallet(uint256 tokenId) {
_requireOwned(tokenId);

Copilot uses AI. Check for mistakes.
if (identityStates[tokenId].backupWallet != msg.sender) revert Errors.NotBackupWallet();
_;
}

constructor() ERC721("IdentityToken", "IDT") {}

function supportsInterface(bytes4 interfaceId) public view override(ERC721, IERC165) returns (bool) {
Expand All @@ -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);

Expand Down Expand Up @@ -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);
}
Comment thread
dhruvi-16-me marked this conversation as resolved.

/**
* @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);
}
Comment thread
dhruvi-16-me marked this conversation as resolved.

/**
* @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;
Comment thread
dhruvi-16-me marked this conversation as resolved.
Comment on lines +254 to +261
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recoverIdentity currently allows a backup wallet to transfer the identity even when it is not marked compromised. That creates a general-purpose transfer path (owner can set a backup wallet, wait the timelock, and have it “recover” to any address), which weakens the intended soulbound / non-transferable guarantees. If recovery is meant to be an emergency-only path, require identityStates[tokenId].isCompromised == true (or another explicit recovery-initiated flag) before allowing the transfer.

Copilot uses AI. Check for mistakes.

identityStates[tokenId].isCompromised = false;
Comment on lines +252 to +263
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After recoverIdentity, the previous backupWallet remains set. This means the same backup wallet can immediately “recover” again (and the new owner can only change the backup via a 7-day timelock), which is a strong and possibly unintended power over the recovered identity. Consider clearing backupWallet/pending fields on recovery, or updating the backup wallet as part of the recovery flow so the new owner isn’t stuck with the prior guardian.

Suggested change
* 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;
* may call this. Clears the isCompromised flag post-transfer and
* removes the existing backup wallet to avoid repeated recoveries
* by the previous guardian.
*/
function recoverIdentity(uint256 tokenId, address newOwner) external onlyBackupWallet(tokenId) {
if (balanceOf(newOwner) != 0) revert Errors.AlreadyHasIdentity();
address currentOwner = ownerOf(tokenId);
DataTypes.IdentityState storage state = identityStates[tokenId];
_recovering = true;
_transfer(currentOwner, newOwner, tokenId);
_recovering = false;
state.isCompromised = false;
state.backupWallet = address(0);

Copilot uses AI. Check for mistakes.

emit Events.IdentityRecovered(tokenId, newOwner);
}
Comment thread
dhruvi-16-me marked this conversation as resolved.

// -------------------------------------------------------------------------
// 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();
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flagCompromised’s docstring says it freezes “all attribute and endorsement operations,” but revokeEndorsement has no notCompromised check for the caller’s identity. Either add a compromised-state guard for revocations (e.g., require the caller’s tokenId is not compromised) or adjust the documentation/expectations so it’s clear revocation remains allowed while compromised.

Suggested change
if (callerTokenId == 0 || e.endorserTokenId != callerTokenId) revert Errors.NotEndorser();
if (callerTokenId == 0 || e.endorserTokenId != callerTokenId) revert Errors.NotEndorser();
if (identityStates[callerTokenId].isCompromised) revert Errors.CompromisedIdentity();

Copilot uses AI. Check for mistakes.

if (e.revokedAt != 0) revert Errors.AlreadyRevoked();

e.revokedAt = block.timestamp;

emit Events.EndorsementRevoked(e.endorserTokenId, targetTokenId, index);
}
Comment thread
dhruvi-16-me marked this conversation as resolved.
Comment on lines +277 to +292
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

revokeEndorsement is newly introduced behavior but there are no tests covering the happy path (revoking an active endorsement), the authorization checks (NotEndorser), and the double-revoke case (AlreadyRevoked). Adding targeted tests would help ensure revocation semantics stay correct and don’t regress.

Copilot uses AI. Check for mistakes.

// -------------------------------------------------------------------------
// Internal
// -------------------------------------------------------------------------
Expand Down
Loading
Loading