diff --git a/README.md b/README.md index f4c0014f..958f3a50 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,12 @@ src/ │ # - UUID-based replay protection │ ├── mixins/ -│ └── PredicateClient.sol # Inherit this in your contracts -│ # - _initPredicateClient() for setup -│ # - _authorizeTransaction() for validation -│ # - ERC-7201 namespaced storage +│ ├── PredicateClient.sol # Full-featured client (WHO + WHAT validation) +│ │ # - _authorizeTransaction(attestation, encoded, sender, value) +│ │ +│ └── BasicPredicateClient.sol # Simplified client (WHO-only validation) +│ # - _authorizeTransaction(attestation, sender) +│ # - Use when policies only validate sender identity │ ├── interfaces/ │ ├── IPredicateRegistry.sol # Registry interface + Statement/Attestation structs @@ -47,6 +49,26 @@ npm install @predicate/contracts ## Quick Example +**BasicPredicateClient** - Use when your policy only validates WHO is calling (sender identity): + +```solidity +import {BasicPredicateClient} from "@predicate/contracts/src/mixins/BasicPredicateClient.sol"; +import {Attestation} from "@predicate/contracts/src/interfaces/IPredicateRegistry.sol"; + +contract MyVault is BasicPredicateClient { + constructor(address _registry, string memory _policyID) { + _initPredicateClient(_registry, _policyID); + } + + function deposit(uint256 amount, Attestation calldata attestation) external payable { + require(_authorizeTransaction(attestation, msg.sender), "Unauthorized"); + // ... business logic + } +} +``` + +**PredicateClient** - Use when your policy also validates WHAT is being done (function, args, value): + ```solidity import {PredicateClient} from "@predicate/contracts/src/mixins/PredicateClient.sol"; import {Attestation} from "@predicate/contracts/src/interfaces/IPredicateRegistry.sol"; diff --git a/package.json b/package.json index 06e72e01..f5aff138 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@predicate/contracts", - "version": "2.2.0", + "version": "2.2.1", "description": "Core contracts for Predicate AVS", "main": "index.js", "files": [ diff --git a/src/examples/inheritance/AdvancedVault.sol b/src/examples/inheritance/AdvancedVault.sol new file mode 100644 index 00000000..596cd68a --- /dev/null +++ b/src/examples/inheritance/AdvancedVault.sol @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.28; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {PredicateClient} from "../../mixins/PredicateClient.sol"; +import {Attestation} from "../../interfaces/IPredicateRegistry.sol"; + +/** + * @title AdvancedVault + * @author Predicate Labs, Inc (https://predicate.io) + * @notice Minimal example using PredicateClient for function and value-based authorization + * @dev Demonstrates PredicateClient when your policy needs to validate: + * - WHICH function is called (withdraw vs transfer) + * - HOW MUCH value (amount limits) + * - WHAT parameters (recipient addresses) + * + * The key difference: policies can enforce different rules per function + */ +contract AdvancedVault is PredicateClient, Ownable { + mapping(address => uint256) public balances; + + event Deposit(address indexed user, uint256 amount); + event Withdrawal(address indexed user, address indexed to, uint256 amount); + event Transfer(address indexed from, address indexed to, uint256 amount); + + constructor( + address _owner, + address _registry, + string memory _policyID + ) Ownable(_owner) { + _initPredicateClient(_registry, _policyID); + } + + /** + * @notice Deposit ETH - no attestation required + */ + function deposit() external payable { + balances[msg.sender] += msg.value; + emit Deposit(msg.sender, msg.value); + } + + /** + * @notice Withdraw with amount validation + * @dev Policy can enforce max withdrawal limits + * @param _amount Amount to withdraw (validated by policy) + * @param _attestation Must include encoded function data + */ + function withdraw( + uint256 _amount, + Attestation calldata _attestation + ) external { + require(balances[msg.sender] >= _amount, "Insufficient balance"); + + // Encode function signature and parameters for policy validation + bytes memory encoded = abi.encodeWithSignature("_executeWithdraw(address,uint256)", msg.sender, _amount); + + // Advanced authorization with function details + require(_authorizeTransaction(_attestation, encoded, msg.sender, 0), "Unauthorized"); + + _executeWithdraw(msg.sender, _amount); + } + + /** + * @notice Withdraw to specific address with full parameter validation + * @dev Policy validates BOTH recipient AND amount + * @param _to Recipient address (validated by policy) + * @param _amount Amount (validated by policy) + * @param _attestation Must include encoded function data + */ + function withdrawTo( + address _to, + uint256 _amount, + Attestation calldata _attestation + ) external { + require(balances[msg.sender] >= _amount, "Insufficient balance"); + + // Policy sees different function signature and parameters + bytes memory encoded = abi.encodeWithSignature("_executeWithdraw(address,uint256)", _to, _amount); + + require(_authorizeTransaction(_attestation, encoded, msg.sender, 0), "Unauthorized"); + + _executeWithdraw(_to, _amount); + } + + /** + * @notice Transfer between users - different policy rules than withdraw + * @dev Shows how policies can have function-specific rules + * @param _to Recipient + * @param _amount Amount to transfer + * @param _attestation Must include encoded transfer data + */ + function transfer( + address _to, + uint256 _amount, + Attestation calldata _attestation + ) external { + require(balances[msg.sender] >= _amount, "Insufficient balance"); + + // Different function = different policy rules possible + bytes memory encoded = + abi.encodeWithSignature("_executeTransfer(address,address,uint256)", msg.sender, _to, _amount); + + require(_authorizeTransaction(_attestation, encoded, msg.sender, 0), "Unauthorized"); + + _executeTransfer(msg.sender, _to, _amount); + } + + /** + * @notice Payable function example - policy validates msg.value + * @dev Shows value-based authorization for payable functions + * @param _lockPeriod Lock period parameter + * @param _attestation Must include msg.value in validation + */ + function depositAndLock( + uint256 _lockPeriod, + Attestation calldata _attestation + ) external payable { + require(msg.value > 0, "Must send ETH"); + + bytes memory encoded = + abi.encodeWithSignature("_executeLock(address,uint256,uint256)", msg.sender, msg.value, _lockPeriod); + + // Note: msg.value passed for validation + require(_authorizeTransaction(_attestation, encoded, msg.sender, msg.value), "Unauthorized"); + + balances[msg.sender] += msg.value; + emit Deposit(msg.sender, msg.value); + // Lock logic would go here + } + + // Internal execution functions + function _executeWithdraw( + address _to, + uint256 _amount + ) internal { + balances[msg.sender] -= _amount; + payable(_to).transfer(_amount); + emit Withdrawal(msg.sender, _to, _amount); + } + + function _executeTransfer( + address _from, + address _to, + uint256 _amount + ) internal { + balances[_from] -= _amount; + balances[_to] += _amount; + emit Transfer(_from, _to, _amount); + } + + function _executeLock( + address _user, + uint256 _amount, + uint256 _period + ) internal { + // Lock implementation would go here + } + + /** + * @notice Required: Set policy ID with access control + * @dev Business logic contracts MUST implement this with proper access control + */ + function setPolicyID( + string memory _policyID + ) external onlyOwner { + _setPolicyID(_policyID); + } + + /** + * @notice Required: Set registry with access control + * @dev Business logic contracts MUST implement this with proper access control + */ + function setRegistry( + address _registry + ) external onlyOwner { + _setRegistry(_registry); + } + + /** + * @notice Required: Expose policy ID getter + * @dev Inherited from PredicateClient - no implementation needed + */ + // function getPolicyID() external view returns (string memory) - inherited + + /** + * @notice Required: Expose registry getter + * @dev Inherited from PredicateClient - no implementation needed + */ + // function getRegistry() external view returns (address) - inherited +} diff --git a/src/examples/inheritance/BasicVault.sol b/src/examples/inheritance/BasicVault.sol new file mode 100644 index 00000000..d98d26ad --- /dev/null +++ b/src/examples/inheritance/BasicVault.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.28; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {BasicPredicateClient} from "../../mixins/BasicPredicateClient.sol"; +import {Attestation} from "../../interfaces/IPredicateRegistry.sol"; + +/** + * @title BasicVault + * @author Predicate Labs, Inc (https://predicate.io) + * @notice Minimal example using BasicPredicateClient for simple WHO-based access control + * @dev Demonstrates BasicPredicateClient when your policy only validates: + * - WHO can perform actions (allowlist/denylist) + * - WHEN they can act (time-based restrictions) + * + * NOT for policies that need: + * - Function-specific rules (use AdvancedVault) + * - Value-based limits (use AdvancedVault) + * - Parameter validation (use AdvancedVault) + */ +contract BasicVault is BasicPredicateClient, Ownable { + mapping(address => uint256) public balances; + + event Deposit(address indexed user, uint256 amount); + event Withdrawal(address indexed user, uint256 amount); + + constructor( + address _owner, + address _registry, + string memory _policyID + ) Ownable(_owner) { + _initPredicateClient(_registry, _policyID); + } + + /** + * @notice Deposit ETH - no attestation required + */ + function deposit() external payable { + balances[msg.sender] += msg.value; + emit Deposit(msg.sender, msg.value); + } + + /** + * @notice Withdraw with BasicPredicateClient authorization + * @dev Policy validates WHO (sender) and target contract only. + * Cannot validate amount or function specifics. + * @param _amount Amount to withdraw + * @param _attestation Attestation from Predicate API (only from/to/chain fields) + */ + function withdraw( + uint256 _amount, + Attestation calldata _attestation + ) external { + require(balances[msg.sender] >= _amount, "Insufficient balance"); + + // Simple authorization - no encoding needed + require(_authorizeTransaction(_attestation, msg.sender), "Unauthorized"); + + balances[msg.sender] -= _amount; + payable(msg.sender).transfer(_amount); + + emit Withdrawal(msg.sender, _amount); + } + + /** + * @notice Required: Set policy ID with access control + * @dev Business logic contracts MUST implement this with proper access control + */ + function setPolicyID( + string memory _policyID + ) external onlyOwner { + _setPolicyID(_policyID); + } + + /** + * @notice Required: Set registry with access control + * @dev Business logic contracts MUST implement this with proper access control + */ + function setRegistry( + address _registry + ) external onlyOwner { + _setRegistry(_registry); + } + + /** + * @notice Required: Expose policy ID getter + * @dev Inherited from BasicPredicateClient - no implementation needed + */ + // function getPolicyID() external view returns (string memory) - inherited + + /** + * @notice Required: Expose registry getter + * @dev Inherited from BasicPredicateClient - no implementation needed + */ + // function getRegistry() external view returns (address) - inherited +} diff --git a/src/mixins/BasicPredicateClient.sol b/src/mixins/BasicPredicateClient.sol new file mode 100644 index 00000000..fdc78add --- /dev/null +++ b/src/mixins/BasicPredicateClient.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.28; + +import {IPredicateRegistry, Attestation, Statement} from "../interfaces/IPredicateRegistry.sol"; +import {IPredicateClient, PredicateClient__Unauthorized} from "../interfaces/IPredicateClient.sol"; + +/** + * @title BasicPredicateClient + * @author Predicate Labs, Inc (https://predicate.io) + * @notice Simplified authorization for WHO-based policies only + * @dev Use when policies only validate sender identity, not function or value details. + * Uses canonical zero values for encodedSigAndArgs and msgValue. + */ +abstract contract BasicPredicateClient is IPredicateClient { + /// @notice Struct to contain stateful values for PredicateClient-type contracts + /// @custom:storage-location erc7201:predicate.storage.PredicateClient + struct PredicateClientStorage { + IPredicateRegistry registry; + string policy; + } + + /// @notice The storage slot for the PredicateClientStorage struct + /// @dev keccak256(abi.encode(uint256(keccak256("predicate.storage.PredicateClient")) - 1)) & ~bytes32(uint256(0xff)) + /// @dev Same slot as PredicateClient for consistency across implementations + bytes32 private constant _PREDICATE_CLIENT_STORAGE_SLOT = + 0x804776a84f3d03ad8442127b1451e2fbbb6a715c681d6a83c9e9fca787b99300; + + /// @notice Emitted when the PredicateRegistry address is updated + event PredicateRegistryUpdated(address indexed oldRegistry, address indexed newRegistry); + + /// @notice Emitted when the policy ID is updated + event PredicatePolicyIDUpdated(string oldPolicyID, string newPolicyID); + + function _getPredicateClientStorage() private pure returns (PredicateClientStorage storage $) { + assembly { + $.slot := _PREDICATE_CLIENT_STORAGE_SLOT + } + } + + /** + * @notice Initializes with registry and policy ID + * @dev Must be called in constructor + */ + function _initPredicateClient( + address _registryAddress, + string memory _policyID + ) internal { + PredicateClientStorage storage $ = _getPredicateClientStorage(); + $.registry = IPredicateRegistry(_registryAddress); + _setPolicyID(_policyID); + } + + /// @notice Updates the policy ID + function _setPolicyID( + string memory _policyID + ) internal { + PredicateClientStorage storage $ = _getPredicateClientStorage(); + string memory oldPolicyID = $.policy; + + if (keccak256(bytes(oldPolicyID)) != keccak256(bytes(_policyID))) { + $.policy = _policyID; + $.registry.setPolicyID(_policyID); + emit PredicatePolicyIDUpdated(oldPolicyID, _policyID); + } + } + + /// @notice Updates the registry address + function _setRegistry( + address _registryAddress + ) internal { + PredicateClientStorage storage $ = _getPredicateClientStorage(); + address oldRegistry = address($.registry); + + if (oldRegistry != _registryAddress) { + $.registry = IPredicateRegistry(_registryAddress); + + // Re-register cached policy with new registry + string memory cachedPolicy = $.policy; + if (bytes(cachedPolicy).length > 0) { + $.registry.setPolicyID(cachedPolicy); + } + + emit PredicateRegistryUpdated(oldRegistry, _registryAddress); + } + } + + /// @notice Returns the policy ID + function getPolicyID() external view returns (string memory policyID) { + return _getPolicyID(); + } + + function _getPolicyID() internal view returns (string memory policyID) { + return _getPredicateClientStorage().policy; + } + + /// @notice Returns the registry address + function getRegistry() external view returns (address) { + return _getRegistry(); + } + + function _getRegistry() internal view returns (address) { + return address(_getPredicateClientStorage().registry); + } + + modifier onlyPredicateRegistry() { + if (msg.sender != address(_getPredicateClientStorage().registry)) { + revert PredicateClient__Unauthorized(); + } + _; + } + + /** + * @notice Validates transaction with canonical zero values + * @dev Uses canonical msgValue=0 and encodedSigAndArgs="" for simple WHO-based validation + */ + function _authorizeTransaction( + Attestation memory _attestation, + address _msgSender + ) internal returns (bool success) { + PredicateClientStorage storage $ = _getPredicateClientStorage(); + + // Build Statement with canonical zero values + Statement memory statement = Statement({ + msgSender: _msgSender, + target: address(this), + msgValue: 0, // Canonical zero for value + encodedSigAndArgs: hex"", // Canonical empty bytes for function data + policy: $.policy, + expiration: _attestation.expiration, + uuid: _attestation.uuid + }); + + return $.registry.validateAttestation(statement, _attestation); + } +}