Skip to content
Closed
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
54 changes: 54 additions & 0 deletions src/settlement-chain/PayerRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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))
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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";
Expand Down
70 changes: 70 additions & 0 deletions src/settlement-chain/interfaces/IPayerRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 ============ */

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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_);
}
173 changes: 173 additions & 0 deletions test/unit/PayerRegistry.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}