diff --git a/src/settlement-chain/PayerRegistry.sol b/src/settlement-chain/PayerRegistry.sol index c762bcc..e592ff7 100644 --- a/src/settlement-chain/PayerRegistry.sol +++ b/src/settlement-chain/PayerRegistry.sol @@ -49,6 +49,7 @@ contract PayerRegistry is IPayerRegistry, Migratable, Initializable { * @param settler The address of the settler. * @param feeDistributor The address of the fee distributor. * @param payers A mapping of payer addresses to payer information. + * @param delegations A mapping of payer => delegate => delegation info. */ struct PayerRegistryStorage { bool paused; @@ -59,6 +60,7 @@ contract PayerRegistry is IPayerRegistry, Migratable, Initializable { address settler; address feeDistributor; mapping(address account => Payer payer) payers; + mapping(address payer => mapping(address delegate => Delegation delegation)) delegations; } // keccak256(abi.encode(uint256(keccak256("xmtp.storage.PayerRegistry")) - 1)) & ~bytes32(uint256(0xff)) @@ -321,6 +323,43 @@ contract PayerRegistry is IPayerRegistry, Migratable, Initializable { emit PauseStatusUpdated($.paused = paused_); } + /* ============ Delegation Functions ============ */ + + /// @inheritdoc IPayerRegistry + function authorize(address delegate_, uint64 expiry_) external whenNotPaused { + if (_isZero(delegate_)) revert ZeroDelegate(); + + // If expiry is set, it must be in the future + // slither-disable-next-line timestamp + if (expiry_ != 0 && expiry_ <= block.timestamp) revert DelegationExpiryInPast(); + + PayerRegistryStorage storage $ = _getPayerRegistryStorage(); + Delegation storage delegation_ = $.delegations[msg.sender][delegate_]; + + // Check if delegation already exists and is active + if (delegation_.isActive) revert DelegationAlreadyExists(); + + delegation_.isActive = true; + delegation_.expiry = expiry_; + delegation_.createdAt = uint64(block.timestamp); + + emit DelegationAuthorized(msg.sender, delegate_, expiry_); + } + + /// @inheritdoc IPayerRegistry + function revoke(address delegate_) external whenNotPaused { + if (_isZero(delegate_)) revert ZeroDelegate(); + + PayerRegistryStorage storage $ = _getPayerRegistryStorage(); + Delegation storage delegation_ = $.delegations[msg.sender][delegate_]; + + if (!delegation_.isActive) revert DelegationDoesNotExist(); + + delegation_.isActive = false; + + emit DelegationRevoked(msg.sender, delegate_); + } + /// @inheritdoc IMigratable function migrate() external { // NOTE: No access control logic is enforced here, since the migrator is defined by some administered parameter. @@ -437,6 +476,21 @@ contract PayerRegistry is IPayerRegistry, Migratable, Initializable { ); } + /// @inheritdoc IPayerRegistry + function isAuthorized(address payer_, address delegate_) public view returns (bool isAuthorized_) { + PayerRegistryStorage storage $ = _getPayerRegistryStorage(); + Delegation storage delegation_ = $.delegations[payer_][delegate_]; + + // Must be active and either no expiry (0) or not expired + // slither-disable-next-line timestamp + return delegation_.isActive && (delegation_.expiry == 0 || delegation_.expiry > block.timestamp); + } + + /// @inheritdoc IPayerRegistry + function getDelegation(address payer_, address delegate_) external view returns (Delegation memory delegation_) { + return _getPayerRegistryStorage().delegations[payer_][delegate_]; + } + /// @inheritdoc IIdentified function version() external pure returns (string memory version_) { return "1.0.0"; diff --git a/src/settlement-chain/interfaces/IPayerRegistry.sol b/src/settlement-chain/interfaces/IPayerRegistry.sol index 9d6926f..b3b95fb 100644 --- a/src/settlement-chain/interfaces/IPayerRegistry.sol +++ b/src/settlement-chain/interfaces/IPayerRegistry.sol @@ -39,8 +39,35 @@ interface IPayerRegistry is IMigratable, IIdentified, IRegistryParametersErrors uint96 fee; } + /** + * @notice Represents a delegation from a payer to a delegate (e.g., gateway). + * @param isActive Whether the delegation is currently active. + * @param expiry The timestamp when the delegation expires (0 for no expiry). + * @param createdAt The timestamp when the delegation was created. + */ + struct Delegation { + bool isActive; + uint64 expiry; + uint64 createdAt; + } + /* ============ Events ============ */ + /** + * @notice Emitted when a payer authorizes a delegate to sign on their behalf. + * @param payer The address of the payer granting delegation. + * @param delegate The address of the delegate being authorized. + * @param expiry The timestamp when the delegation expires (0 for no expiry). + */ + event DelegationAuthorized(address indexed payer, address indexed delegate, uint64 expiry); + + /** + * @notice Emitted when a payer revokes a delegate's authorization. + * @param payer The address of the payer revoking delegation. + * @param delegate The address of the delegate being revoked. + */ + event DelegationRevoked(address indexed payer, address indexed delegate); + /** * @notice Emitted when the settler is updated. * @param settler The address of the new settler. @@ -189,6 +216,18 @@ interface IPayerRegistry is IMigratable, IIdentified, IRegistryParametersErrors /// @notice Thrown when the recipient is the zero address. error ZeroRecipient(); + /// @notice Thrown when the delegate address is zero. + error ZeroDelegate(); + + /// @notice Thrown when trying to authorize a delegate that is already authorized. + error DelegationAlreadyExists(); + + /// @notice Thrown when trying to revoke a delegation that does not exist. + error DelegationDoesNotExist(); + + /// @notice Thrown when the delegation expiry is in the past. + error DelegationExpiryInPast(); + /* ============ Initialization ============ */ /** @@ -312,6 +351,21 @@ interface IPayerRegistry is IMigratable, IIdentified, IRegistryParametersErrors /// @notice Updates the pause status. function updatePauseStatus() external; + /* ============ Delegation Functions ============ */ + + /** + * @notice Authorizes a delegate to sign payer envelopes on behalf of the caller. + * @param delegate_ The address of the delegate (e.g., gateway) to authorize. + * @param expiry_ The timestamp when the delegation expires (0 for no expiry). + */ + function authorize(address delegate_, uint64 expiry_) external; + + /** + * @notice Revokes a delegate's authorization to sign on behalf of the caller. + * @param delegate_ The address of the delegate to revoke. + */ + function revoke(address delegate_) external; + /* ============ View/Pure Functions ============ */ /// @notice The parameter registry key used to fetch the settler. @@ -390,4 +444,20 @@ interface IPayerRegistry is IMigratable, IIdentified, IRegistryParametersErrors function getPendingWithdrawal( address payer_ ) external view returns (uint96 pendingWithdrawal_, uint32 withdrawableTimestamp_, uint24 nonce_); + + /** + * @notice Checks if a delegate is authorized to sign on behalf of a payer. + * @param payer_ The address of the payer. + * @param delegate_ The address of the delegate. + * @return isAuthorized_ True if the delegate is authorized and delegation has not expired. + */ + function isAuthorized(address payer_, address delegate_) external view returns (bool isAuthorized_); + + /** + * @notice Returns the delegation information for a payer and delegate. + * @param payer_ The address of the payer. + * @param delegate_ The address of the delegate. + * @return delegation_ The delegation struct with isActive, expiry, and createdAt. + */ + function getDelegation(address payer_, address delegate_) external view returns (Delegation memory delegation_); } diff --git a/test/unit/PayerRegistry.t.sol b/test/unit/PayerRegistry.t.sol index e75a23a..caf6b5c 100644 --- a/test/unit/PayerRegistry.t.sol +++ b/test/unit/PayerRegistry.t.sol @@ -1475,4 +1475,177 @@ contract PayerRegistryTests is Test { output_ := input_ } } + + /* ============ authorize ============ */ + + function test_authorize_zeroDelegate() external { + vm.expectRevert(IPayerRegistry.ZeroDelegate.selector); + vm.prank(_alice); + _registry.authorize(address(0), 0); + } + + function test_authorize_paused() external { + _registry.__setPauseStatus(true); + + vm.expectRevert(IPayerRegistry.Paused.selector); + vm.prank(_alice); + _registry.authorize(_bob, 0); + } + + function test_authorize_expiryInPast() external { + vm.warp(1000); + + vm.expectRevert(IPayerRegistry.DelegationExpiryInPast.selector); + vm.prank(_alice); + _registry.authorize(_bob, 500); + } + + function test_authorize_delegationAlreadyExists() external { + vm.prank(_alice); + _registry.authorize(_bob, 0); + + vm.expectRevert(IPayerRegistry.DelegationAlreadyExists.selector); + vm.prank(_alice); + _registry.authorize(_bob, 0); + } + + function test_authorize_success_noExpiry() external { + vm.warp(1000); + + vm.expectEmit(address(_registry)); + emit IPayerRegistry.DelegationAuthorized(_alice, _bob, 0); + + vm.prank(_alice); + _registry.authorize(_bob, 0); + + assertTrue(_registry.isAuthorized(_alice, _bob)); + IPayerRegistry.Delegation memory delegation_ = _registry.getDelegation(_alice, _bob); + assertTrue(delegation_.isActive); + assertEq(delegation_.expiry, 0); + assertEq(delegation_.createdAt, 1000); + } + + function test_authorize_success_withExpiry() external { + vm.warp(1000); + + vm.expectEmit(address(_registry)); + emit IPayerRegistry.DelegationAuthorized(_alice, _bob, 2000); + + vm.prank(_alice); + _registry.authorize(_bob, 2000); + + assertTrue(_registry.isAuthorized(_alice, _bob)); + IPayerRegistry.Delegation memory delegation_ = _registry.getDelegation(_alice, _bob); + assertTrue(delegation_.isActive); + assertEq(delegation_.expiry, 2000); + assertEq(delegation_.createdAt, 1000); + } + + /* ============ revoke ============ */ + + function test_revoke_zeroDelegate() external { + vm.expectRevert(IPayerRegistry.ZeroDelegate.selector); + vm.prank(_alice); + _registry.revoke(address(0)); + } + + function test_revoke_paused() external { + _registry.__setPauseStatus(true); + + vm.expectRevert(IPayerRegistry.Paused.selector); + vm.prank(_alice); + _registry.revoke(_bob); + } + + function test_revoke_delegationDoesNotExist() external { + vm.expectRevert(IPayerRegistry.DelegationDoesNotExist.selector); + vm.prank(_alice); + _registry.revoke(_bob); + } + + function test_revoke_success() external { + vm.prank(_alice); + _registry.authorize(_bob, 0); + + assertTrue(_registry.isAuthorized(_alice, _bob)); + + vm.expectEmit(address(_registry)); + emit IPayerRegistry.DelegationRevoked(_alice, _bob); + + vm.prank(_alice); + _registry.revoke(_bob); + + assertFalse(_registry.isAuthorized(_alice, _bob)); + IPayerRegistry.Delegation memory delegation_ = _registry.getDelegation(_alice, _bob); + assertFalse(delegation_.isActive); + } + + /* ============ isAuthorized ============ */ + + function test_isAuthorized_notAuthorized() external view { + assertFalse(_registry.isAuthorized(_alice, _bob)); + } + + function test_isAuthorized_expired() external { + vm.warp(1000); + + vm.prank(_alice); + _registry.authorize(_bob, 2000); + + assertTrue(_registry.isAuthorized(_alice, _bob)); + + // Move past expiry + vm.warp(2001); + + assertFalse(_registry.isAuthorized(_alice, _bob)); + } + + function test_isAuthorized_noExpiry_stillValid() external { + vm.warp(1000); + + vm.prank(_alice); + _registry.authorize(_bob, 0); + + // Move far into the future + vm.warp(1000000); + + assertTrue(_registry.isAuthorized(_alice, _bob)); + } + + /* ============ getDelegation ============ */ + + function test_getDelegation_notExists() external view { + IPayerRegistry.Delegation memory delegation_ = _registry.getDelegation(_alice, _bob); + assertFalse(delegation_.isActive); + assertEq(delegation_.expiry, 0); + assertEq(delegation_.createdAt, 0); + } + + /* ============ reauthorize after revoke ============ */ + + function test_reauthorize_afterRevoke() external { + vm.warp(1000); + + // First authorize + vm.prank(_alice); + _registry.authorize(_bob, 0); + assertTrue(_registry.isAuthorized(_alice, _bob)); + + // Revoke + vm.prank(_alice); + _registry.revoke(_bob); + assertFalse(_registry.isAuthorized(_alice, _bob)); + + // Authorize again with different expiry + vm.warp(2000); + + vm.prank(_alice); + _registry.authorize(_bob, 5000); + + assertTrue(_registry.isAuthorized(_alice, _bob)); + IPayerRegistry.Delegation memory delegation_ = _registry.getDelegation(_alice, _bob); + assertTrue(delegation_.isActive); + assertEq(delegation_.expiry, 5000); + assertEq(delegation_.createdAt, 2000); + } }