From af49d15e8e3adc8ec9d4cec6d2e16bb508727cbe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 14:05:58 +0000 Subject: [PATCH 1/2] feat: add configurable forward-tolerance buffer for future attestation check On L2s like Base, the sequencer's block.timestamp can lag behind real-world time, causing valid attestations to be rejected as "future". This adds a configurable futureAttestationBuffer (default 10 minutes) so that attestations with issuedAt slightly ahead of block.timestamp are accepted. Co-Authored-By: Claude Opus 4.6 --- contracts/interfaces/Events.sol | 4 ++ contracts/interfaces/ICredentialRegistry.sol | 4 ++ .../registry/base/AttestationVerifier.sol | 2 +- contracts/registry/base/RegistryAdmin.sol | 7 ++ contracts/registry/base/RegistryStorage.sol | 5 ++ test/CredentialRegistry.t.sol | 66 +++++++++++++++++++ 6 files changed, 87 insertions(+), 1 deletion(-) diff --git a/contracts/interfaces/Events.sol b/contracts/interfaces/Events.sol index 7d27d10..f7689b5 100644 --- a/contracts/interfaces/Events.sol +++ b/contracts/interfaces/Events.sol @@ -136,6 +136,10 @@ event AttestationValidityDurationSet(uint256 duration); /// @param status The new credential group status. event CredentialGroupStatusChanged(uint256 indexed credentialGroupId, ICredentialRegistry.CredentialGroupStatus status); +/// @notice Emitted when the future attestation buffer is updated. +/// @param buffer The new buffer duration in seconds. +event FutureAttestationBufferSet(uint256 buffer); + /// @notice Emitted when the registry-level default Merkle tree duration is updated. /// @param duration The new default duration in seconds. event DefaultMerkleTreeDurationSet(uint256 indexed duration); diff --git a/contracts/interfaces/ICredentialRegistry.sol b/contracts/interfaces/ICredentialRegistry.sol index e463c5f..e2c7faf 100644 --- a/contracts/interfaces/ICredentialRegistry.sol +++ b/contracts/interfaces/ICredentialRegistry.sol @@ -287,6 +287,10 @@ interface ICredentialRegistry { /// @param verifier_ The verifier address to remove. function removeTrustedVerifier(address verifier_) external; + /// @notice Updates the forward-tolerance buffer for future attestation timestamps. + /// @param buffer_ New buffer in seconds (0 to disable tolerance). + function setFutureAttestationBuffer(uint256 buffer_) external; + /// @notice Updates the registry-level default Merkle tree duration for new Semaphore groups. /// @param duration_ New default Merkle tree duration in seconds. function setDefaultMerkleTreeDuration(uint256 duration_) external; diff --git a/contracts/registry/base/AttestationVerifier.sol b/contracts/registry/base/AttestationVerifier.sol index 7d4ee63..6cfc4a4 100644 --- a/contracts/registry/base/AttestationVerifier.sol +++ b/contracts/registry/base/AttestationVerifier.sol @@ -30,7 +30,7 @@ abstract contract AttestationVerifier is RegistryStorage { if (apps[attestation_.appId].status != AppStatus.ACTIVE) revert AppNotActive(); if (attestation_.registry != address(this)) revert WrongRegistryAddress(); if (attestation_.chainId != block.chainid) revert WrongChain(); - if (attestation_.issuedAt > block.timestamp) revert FutureAttestation(); + if (attestation_.issuedAt > block.timestamp + futureAttestationBuffer) revert FutureAttestation(); if (block.timestamp > attestation_.issuedAt + attestationValidityDuration) revert AttestationExpired(); signer = keccak256(abi.encode(attestation_)).toEthSignedMessageHash().recover(v, r, s); diff --git a/contracts/registry/base/RegistryAdmin.sol b/contracts/registry/base/RegistryAdmin.sol index cd635a5..a216e61 100644 --- a/contracts/registry/base/RegistryAdmin.sol +++ b/contracts/registry/base/RegistryAdmin.sol @@ -72,6 +72,13 @@ abstract contract RegistryAdmin is RegistryStorage { emit AttestationValidityDurationSet(duration_); } + /// @notice Updates the forward-tolerance buffer for future attestation timestamps. + /// @param buffer_ New buffer in seconds (0 to disable tolerance). + function setFutureAttestationBuffer(uint256 buffer_) public onlyOwner { + futureAttestationBuffer = buffer_; + emit FutureAttestationBufferSet(buffer_); + } + /// @notice Updates the registry-level default Merkle tree duration for new Semaphore groups. /// @dev Does not propagate to existing groups. Only affects groups created after this call. /// @param duration_ New duration in seconds (must be > 0). diff --git a/contracts/registry/base/RegistryStorage.sol b/contracts/registry/base/RegistryStorage.sol index df48d3c..0c32d52 100644 --- a/contracts/registry/base/RegistryStorage.sol +++ b/contracts/registry/base/RegistryStorage.sol @@ -46,6 +46,11 @@ abstract contract RegistryStorage is ICredentialRegistry, Ownable2Step, Pausable /// @notice Maximum age (in seconds) an attestation is accepted. Default 30 minutes. uint256 public attestationValidityDuration = 30 minutes; + /// @notice Forward-tolerance buffer (in seconds) for attestation issuedAt timestamps. + /// On L2s like Base, the sequencer's block.timestamp can lag behind real-world time, + /// causing valid attestations to be rejected as "future". Default 10 minutes. + uint256 public futureAttestationBuffer = 10 minutes; + /// @notice Array of all registered credential group IDs (for enumeration). uint256[] public credentialGroupIds; diff --git a/test/CredentialRegistry.t.sol b/test/CredentialRegistry.t.sol index 9d7cb4e..ef2fee7 100644 --- a/test/CredentialRegistry.t.sol +++ b/test/CredentialRegistry.t.sol @@ -1938,6 +1938,7 @@ contract CredentialRegistryTest is Test { bytes32 credentialId = keccak256("blinded-id"); uint256 commitment = COMMITMENT_12345; + // issuedAt exceeds the 10-minute buffer ICredentialRegistry.Attestation memory att = ICredentialRegistry.Attestation({ registry: address(registry), chainId: block.chainid, @@ -1953,6 +1954,71 @@ contract CredentialRegistryTest is Test { registry.registerCredential(att, v, r, s); } + function testRegisterCredentialFutureAttestationWithinBuffer() public { + uint256 credentialGroupId = 1; + registry.createCredentialGroup(credentialGroupId, 0, 0); + + bytes32 credentialId = keccak256("blinded-id"); + uint256 commitment = COMMITMENT_12345; + + // issuedAt is 5 minutes ahead, within the 10-minute default buffer + ICredentialRegistry.Attestation memory att = ICredentialRegistry.Attestation({ + registry: address(registry), + chainId: block.chainid, + credentialGroupId: credentialGroupId, + credentialId: credentialId, + appId: DEFAULT_APP_ID, + semaphoreIdentityCommitment: commitment, + issuedAt: block.timestamp + 5 minutes + }); + (uint8 v, bytes32 r, bytes32 s) = _signAttestation(att); + + registry.registerCredential(att, v, r, s); + } + + function testRegisterCredentialFutureAttestationBufferZero() public { + // Disable the buffer + registry.setFutureAttestationBuffer(0); + + uint256 credentialGroupId = 1; + registry.createCredentialGroup(credentialGroupId, 0, 0); + + bytes32 credentialId = keccak256("blinded-id"); + uint256 commitment = COMMITMENT_12345; + + // Even 1 second ahead should revert with buffer=0 + ICredentialRegistry.Attestation memory att = ICredentialRegistry.Attestation({ + registry: address(registry), + chainId: block.chainid, + credentialGroupId: credentialGroupId, + credentialId: credentialId, + appId: DEFAULT_APP_ID, + semaphoreIdentityCommitment: commitment, + issuedAt: block.timestamp + 1 + }); + (uint8 v, bytes32 r, bytes32 s) = _signAttestation(att); + + vm.expectRevert(FutureAttestation.selector); + registry.registerCredential(att, v, r, s); + } + + function testSetFutureAttestationBuffer() public { + assertEq(registry.futureAttestationBuffer(), 10 minutes); + + vm.expectEmit(false, false, false, true); + emit FutureAttestationBufferSet(5 minutes); + + registry.setFutureAttestationBuffer(5 minutes); + assertEq(registry.futureAttestationBuffer(), 5 minutes); + } + + function testSetFutureAttestationBufferOnlyOwner() public { + address notOwner = makeAddr("not-owner"); + vm.prank(notOwner); + vm.expectRevert("Ownable: caller is not the owner"); + registry.setFutureAttestationBuffer(5 minutes); + } + function testRegisterCredentialExpiredAttestation() public { uint256 credentialGroupId = 1; registry.createCredentialGroup(credentialGroupId, 0, 0); From e8cf313ccf1105fdc9fb5f77df548461b98b9a5d Mon Sep 17 00:00:00 2001 From: Mikhail Date: Mon, 23 Feb 2026 22:10:58 +0800 Subject: [PATCH 2/2] Fix comment formatting in RegistryStorage.sol --- contracts/registry/base/RegistryStorage.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/registry/base/RegistryStorage.sol b/contracts/registry/base/RegistryStorage.sol index 0c32d52..9063672 100644 --- a/contracts/registry/base/RegistryStorage.sol +++ b/contracts/registry/base/RegistryStorage.sol @@ -47,7 +47,7 @@ abstract contract RegistryStorage is ICredentialRegistry, Ownable2Step, Pausable uint256 public attestationValidityDuration = 30 minutes; /// @notice Forward-tolerance buffer (in seconds) for attestation issuedAt timestamps. - /// On L2s like Base, the sequencer's block.timestamp can lag behind real-world time, + /// On L2s a sequencer's block.timestamp can lag behind real-world time, /// causing valid attestations to be rejected as "future". Default 10 minutes. uint256 public futureAttestationBuffer = 10 minutes;