diff --git a/flake.lock b/flake.lock index 053374114d..cafe70968a 100644 --- a/flake.lock +++ b/flake.lock @@ -28,11 +28,11 @@ ] }, "locked": { - "lastModified": 1752867797, - "narHash": "sha256-oT129SDSr7SI9ThTd6ZbpmShh5f2tzUH3S4hl6c5/7w=", + "lastModified": 1758100230, + "narHash": "sha256-sARl8NpG4ifzhd7j5D04A5keJIf0zkP1XYIuDEkzXb4=", "owner": "shazow", "repo": "foundry.nix", - "rev": "d4445852933ab5bc61ca532cb6c5d3276d89c478", + "rev": "e632b06dc759e381ef04f15ff9541f889eda6013", "type": "github" }, "original": { diff --git a/packages/ethereum-contracts/CHANGELOG.md b/packages/ethereum-contracts/CHANGELOG.md index 3eda5ed2c2..8990ae2305 100644 --- a/packages/ethereum-contracts/CHANGELOG.md +++ b/packages/ethereum-contracts/CHANGELOG.md @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added +- `Only712MacroForwarder`: a new macro forwarder that executes EIP-712-signed meta-transactions with properties giving it additional security guarantees. - `SuperToken`: the contract admin can enable/disable a _Yield Backend_ in order to generate a yield on the underlying asset. - `SuperToken`: added `VERSION()` which returns the version string of the logic contract set for the SuperToken, and inline CHANGELOG. diff --git a/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol b/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol index 2ecb2c167f..75bc207791 100644 --- a/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol +++ b/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol @@ -33,7 +33,7 @@ interface IUserDefinedMacro { /* * Additional to the required interface, we recommend to implement one or multiple view functions * which take operation specific typed arguments and return the abi encoded bytes. - * As a convention, the name of those functions shall start with `params`. + * As a convention, the name of those functions shall start with `encode`. * * Implementing this view function(s) has several advantages: * - Allows to build more complex macros with internally encapsulated dispatching logic @@ -44,3 +44,20 @@ interface IUserDefinedMacro { * You can consult the related test code in `MacroForwarderTest.t.sol` for examples. */ } + +// Interface for a macro used with the Only712MacroForwarder. +// Metaphor: a macro is like an api, an action is like an endpoint. +// Each action can have its own type definition (list of arguments). +interface IUserDefined712Macro is IUserDefinedMacro { + // Primary type name (required by the EIP712 type definition), usually rendered prominently by wallets. + // From a users perspective, it should concisely name the action/intent to be signed. + function getPrimaryTypeName(bytes memory params) external view returns (string memory); + + // The EIP-712 type definition of the action, required by Only712MacroForwarder. + // Note that the name of this type must be "Action", only its content is free to choose. + function getActionTypeDefinition(bytes memory params) external view returns (string memory); + + // The struct hash of the action, required by Only712MacroForwarder. + // This hash must be constructed based on the type definition and the data, according to the EIP-712 standard. + function getActionStructHash(bytes memory params) external view returns (bytes32); +} \ No newline at end of file diff --git a/packages/ethereum-contracts/contracts/utils/ForwarderBase.sol b/packages/ethereum-contracts/contracts/utils/ForwarderBase.sol index 3729600133..7f340f09e6 100644 --- a/packages/ethereum-contracts/contracts/utils/ForwarderBase.sol +++ b/packages/ethereum-contracts/contracts/utils/ForwarderBase.sol @@ -24,21 +24,26 @@ abstract contract ForwarderBase { return _forwardBatchCall(ops); } + // helper defaulting to msg.sender as the sender and 0 value to forward function _forwardBatchCall(ISuperfluid.Operation[] memory ops) internal returns (bool) { - return _forwardBatchCallWithValue(ops, 0); + return _forwardBatchCallWithSenderAndValue(ops, msg.sender, 0); } - function _forwardBatchCallWithValue(ISuperfluid.Operation[] memory ops, uint256 valueToForward) + function _forwardBatchCallWithSenderAndValue( + ISuperfluid.Operation[] memory ops, + address msgSender, + uint256 valueToForward + ) internal returns (bool) { bytes memory fwBatchCallData = abi.encodeCall(_host.forwardBatchCall, (ops)); // https://eips.ethereum.org/EIPS/eip-2771 - // we encode the msg.sender as the last 20 bytes per EIP-2771 to extract the original txn signer later on + // we encode the msgSender as the last 20 bytes per EIP-2771 to extract the original txn signer later on // solhint-disable-next-line avoid-low-level-calls (bool success, bytes memory returnedData) = address(_host) - .call{value: valueToForward}(abi.encodePacked(fwBatchCallData, msg.sender)); + .call{value: valueToForward}(abi.encodePacked(fwBatchCallData, msgSender)); if (!success) { CallUtils.revertFromReturnedData(returnedData); diff --git a/packages/ethereum-contracts/contracts/utils/MacroForwarder.sol b/packages/ethereum-contracts/contracts/utils/MacroForwarder.sol index e76d1e12f6..19d700e474 100644 --- a/packages/ethereum-contracts/contracts/utils/MacroForwarder.sol +++ b/packages/ethereum-contracts/contracts/utils/MacroForwarder.sol @@ -34,7 +34,7 @@ contract MacroForwarder is ForwarderBase { function runMacro(IUserDefinedMacro m, bytes calldata params) external payable returns (bool) { ISuperfluid.Operation[] memory operations = buildBatchOperations(m, params); - bool retVal = _forwardBatchCallWithValue(operations, msg.value); + bool retVal = _forwardBatchCallWithSenderAndValue(operations, msg.sender, msg.value); m.postCheck(_host, params, msg.sender); return retVal; } diff --git a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol new file mode 100644 index 0000000000..bfc1385eeb --- /dev/null +++ b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: AGPLv3 +pragma solidity ^0.8.23; + +import { EIP712 } from "@openzeppelin-v5/contracts/utils/cryptography/EIP712.sol"; +import { SignatureChecker } from "@openzeppelin-v5/contracts/utils/cryptography/SignatureChecker.sol"; +import { IAccessControl } from "@openzeppelin-v5/contracts/access/IAccessControl.sol"; +import { IUserDefined712Macro } from "../interfaces/utils/IUserDefinedMacro.sol"; +import { ISuperfluid } from "../interfaces/superfluid/ISuperfluid.sol"; +import { ForwarderBase } from "./ForwarderBase.sol"; + + +/** + * Nonce management functionality following the semantics of ERC-4337. + * Each nonce consists of a 192-bit key and a 64-bit sequence number. + * This allows senders to both have a practically unlimited number of parallel operations + * (meaning signed pending transactions can't block each other), and also the option to enforce + * sequential execution according to the sequence number. + */ +abstract contract NonceManager { + /// nonce already used or out of sequence + error InvalidNonce(address sender, uint256 nonce); + + /// data structure keeping track of the next sequence number by sender and key + mapping(address => mapping(uint192 => uint256)) internal _nonceSequenceNumber; + + /// Returns the next nonce for a given sender and key + function getNonce(address sender, uint192 key) public virtual view returns (uint256 nonce) { + return _nonceSequenceNumber[sender][key] | (uint256(key) << 64); + } + + /// validates the nonce and updates the data structure for correct sequencing + function _validateAndUpdateNonce(address sender, uint256 nonce) internal virtual { + uint192 key = uint192(nonce >> 64); + uint64 seq = uint64(nonce); + if (_nonceSequenceNumber[sender][key]++ != seq) { + revert InvalidNonce(sender, nonce); + } + } +} + +/** + * @dev EIP-712-aware macro forwarder (clear signing). + * In this minimal iteration: decodes payload as appParams and passes through to the macro. + * Envelope verification, nonce, and registry checks to be added in follow-up. + * + * TODO: + * -[X] use SimpleACL for provider authorization + * -[X] add nonce verification + * -[X] add timeframe (validAfter, validBefore) validation + * -[] add missing fields + * -[] extract interface definition + * -[] review naming + */ +contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { + + // STRUCTS, CONSTANTS, IMMUTABLES + + // top-level data structure + struct PrimaryType { + ActionType action; + SecurityType security; + } + struct ActionType { + bytes actionParams; + } + // the action typehash is macro specific + struct SecurityType { + string domain; + string provider; + uint256 validAfter; + uint256 validBefore; + uint256 nonce; + } + bytes internal constant _TYPEDEF_SECURITY = + "Security(string domain,string provider,uint256 validAfter,uint256 validBefore,uint256 nonce)"; + + bytes32 internal constant _TYPEHASH_SECURITY = keccak256(_TYPEDEF_SECURITY); + + IAccessControl internal immutable _providerACL; + + // ERRORS + + error InvalidPayload(string message); + error OutsideValidityWindow(uint256 blockTimestamp, uint256 validBefore, uint256 validAfter); + error ProviderNotAuthorized(string provider, address msgSender); + error InvalidSignature(); + + // INITIALIZATION + + // Here EIP712 domain name and version are set. + // TODO: should the name include "Superfluid"? + constructor(ISuperfluid host) ForwarderBase(host) EIP712("ClearSigning", "1") { + _providerACL = IAccessControl(host.getSimpleACL()); + } + + // PUBLIC FUNCTIONS + + /** + * @dev Run the macro with encoded payload (generic + macro specific fragments). + * @param m Target macro. + * @param params Encoded payload + * @param signer The signer of the payload + * @param signature The signature of the payload + * @return bool True if the macro was executed successfully + */ + function runMacro(IUserDefined712Macro m, bytes calldata params, address signer, bytes calldata signature) + external payable + returns (bool) + { + // decode the payload + PrimaryType memory payload = abi.decode(params, (PrimaryType)); + bytes32 providerRole = keccak256(bytes(payload.security.provider)); + if (!_providerACL.hasRole(providerRole, msg.sender)) { + revert ProviderNotAuthorized(payload.security.provider, msg.sender); + } + + _validateAndUpdateNonce(signer, payload.security.nonce); + + if (block.timestamp < payload.security.validAfter) { + revert OutsideValidityWindow(block.timestamp, payload.security.validBefore, payload.security.validAfter); + } + if (payload.security.validBefore != 0 && block.timestamp > payload.security.validBefore) { + revert OutsideValidityWindow(block.timestamp, payload.security.validBefore, payload.security.validAfter); + } + + bytes32 digest = _getDigest(m, params); + + // verify the signature - this also works for ERC1271 (contract signatures) + if (!SignatureChecker.isValidSignatureNow(signer, digest, signature)) { + revert InvalidSignature(); + } + + // get the operations array from the user macro based on the action params + ISuperfluid.Operation[] memory operations = + m.buildBatchOperations(_host, payload.action.actionParams, signer); + + // forward the operations + bool retVal = _forwardBatchCallWithSenderAndValue(operations, signer, msg.value); + m.postCheck(_host, payload.action.actionParams, signer); + return retVal; + } + + /** + * @dev Encode action and security params into the payload bytes expected by runMacro. + * @param actionParams params specific to the macro action, already ABI-encoded by the caller. + * @param security security related parameters + * @return Encoded payload to pass to runMacro() + */ + function encodeParams(bytes calldata actionParams, SecurityType calldata security) + external pure + returns (bytes memory) + { + PrimaryType memory payload = PrimaryType({ + action: ActionType({ actionParams: actionParams }), + security: security + }); + return abi.encode(payload); + } + + // TODO: should this exist? + function getTypeDefinition(IUserDefined712Macro m, bytes calldata params) external view returns (string memory) { + return _getTypeDefinition(m, params); + } + + // TODO: should this exist? + function getTypeHash(IUserDefined712Macro m, bytes calldata params) public view returns (bytes32) { + return keccak256(abi.encodePacked(_getTypeDefinition(m, params))); + } + + // TODO: should this exist? + function getStructHash(IUserDefined712Macro m, bytes calldata params) external view returns (bytes32) { + return _getStructHash(m, params); + } + + function getDigest(IUserDefined712Macro m, bytes calldata params) external view returns (bytes32) { + return _getDigest(m, params); + } + + // INTERNAL FUNCTIONS + + function _getTypeDefinition(IUserDefined712Macro m, bytes calldata params) internal view returns (string memory) { + return string(abi.encodePacked( + m.getPrimaryTypeName(params), + "(Action action,Security security)", + // nested components need to be in alphabetical order + m.getActionTypeDefinition(params), + _TYPEDEF_SECURITY + )); + } + + function _getStructHash(IUserDefined712Macro m, bytes calldata params) internal view returns (bytes32) { + PrimaryType memory payload = abi.decode(params, (PrimaryType)); + // the action fragment is handled by the user macro. + bytes32 actionStructHash = m.getActionStructHash(payload.action.actionParams); + + bytes32 securityStructHash = _getSecurityStructHash(payload.security); + + // get the typehash + bytes32 primaryTypeHash = getTypeHash(m, params); + + // calculate the struct hash + bytes32 structHash = keccak256( + abi.encode( + primaryTypeHash, + actionStructHash, + securityStructHash + ) + ); + return structHash; + } + + function _getDigest(IUserDefined712Macro m, bytes calldata params) internal view returns (bytes32) { + bytes32 structHash = _getStructHash(m, params); + return _hashTypedDataV4(structHash); + } + + function _getSecurityStructHash(SecurityType memory security) internal pure returns (bytes32) { + return keccak256(abi.encode( + _TYPEHASH_SECURITY, + keccak256(bytes(security.domain)), + keccak256(bytes(security.provider)), + security.validAfter, + security.validBefore, + security.nonce + )); + } +} diff --git a/packages/ethereum-contracts/foundry.toml b/packages/ethereum-contracts/foundry.toml index 949d96e7e1..da329208a3 100644 --- a/packages/ethereum-contracts/foundry.toml +++ b/packages/ethereum-contracts/foundry.toml @@ -39,3 +39,18 @@ verbosity = 2 [profile.ci.fuzz] runs = 1000 + +[lint] +exclude_lints = [ + "asm-keccak256", + "erc20-unchecked-transfer", + "mixed-case-variable", + "unused-import", + "mixed-case-function", + "divide-before-multiply", + "unaliased-plain-import", + "screaming-snake-case-const", + "screaming-snake-case-immutable", + "pascal-case-struct", + "incorrect-shift", +] \ No newline at end of file diff --git a/packages/ethereum-contracts/ops-scripts/deploy-deterministically.js b/packages/ethereum-contracts/ops-scripts/deploy-deterministically.js index a9d50aecd5..3b9cdfd502 100644 --- a/packages/ethereum-contracts/ops-scripts/deploy-deterministically.js +++ b/packages/ethereum-contracts/ops-scripts/deploy-deterministically.js @@ -7,6 +7,7 @@ const SuperfluidLoader = artifacts.require("SuperfluidLoader"); const CFAv1Forwarder = artifacts.require("CFAv1Forwarder"); const GDAv1Forwarder = artifacts.require("GDAv1Forwarder"); const MacroForwarder = artifacts.require("MacroForwarder"); +const Only712MacroForwarder = artifacts.require("Only712MacroForwarder"); /** * @dev Deploy specified contract at a deterministic address (defined by sender, nonce) @@ -89,6 +90,12 @@ module.exports = eval(`(${S.toString()})()`)(async function ( console.log( `setting up MacroForwarder for chainId ${chainId}, host ${hostAddr}` ); + } else if (contractName === "Only712MacroForwarder") { + ContractArtifact = Only712MacroForwarder; + deployArgs = [hostAddr]; + console.log( + `setting up Only712MacroForwarder for chainId ${chainId}, host ${hostAddr}` + ); } else { throw new Error("Contract unknown / not supported"); } diff --git a/packages/ethereum-contracts/package.json b/packages/ethereum-contracts/package.json index d6372c73a8..5c37f0e1d3 100644 --- a/packages/ethereum-contracts/package.json +++ b/packages/ethereum-contracts/package.json @@ -92,7 +92,7 @@ "test-coverage:foundry": "yarn run-foundry coverage --gas-limit 999999999999 --report lcov", "test-slither": "tasks/test-slither.sh", "lint": "run-s lint:*", - "lint:sol": "solhint -w 0 `find contracts -name *.sol` && echo '✔ Your .sol files look good.'", + "lint:sol": "solhint --disc -w 0 `find contracts -name *.sol` && echo '✔ Your .sol files look good.'", "lint-js": "eslint test -c .eslintrc.json --ext .js", "lint:js-eslint": "yarn lint-js --max-warnings=0 --report-unused-disable-directives && echo '✔ Your .js files look good.'", "lint-ts": "eslint test -c .eslintrc.ts.json --ext .ts", diff --git a/packages/ethereum-contracts/scripts/GrantMacroProviderRole.s.sol b/packages/ethereum-contracts/scripts/GrantMacroProviderRole.s.sol new file mode 100644 index 0000000000..d8047b8342 --- /dev/null +++ b/packages/ethereum-contracts/scripts/GrantMacroProviderRole.s.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import { Script } from "forge-std/Script.sol"; +import { console } from "forge-std/console.sol"; +import { IAccessControl } from "@openzeppelin-v5/contracts/access/IAccessControl.sol"; +import { ISuperfluid } from "../contracts/interfaces/superfluid/ISuperfluid.sol"; + +/** + * @title GrantMacroProviderRole + * @dev Grants the macro provider role in SimpleACL for a given provider string (e.g. "macros.superfluid.eth"). + * The role is keccak256(provider). Caller must have DEFAULT_ADMIN_ROLE on SimpleACL. + * + * Usage (with wrapper): + * tasks/grant-macro-provider-role.sh [provider] + * + * Or directly: + * forge script scripts/GrantMacroProviderRole.s.sol --sig "run(address,address,string)" \\ + * [provider] --rpc-url --broadcast + */ +contract GrantMacroProviderRole is Script { + string public constant DEFAULT_PROVIDER = "macros.superfluid.eth"; + + function run(address host, address grantee, string memory provider) external { + if (bytes(provider).length == 0) { + provider = DEFAULT_PROVIDER; + } + + IAccessControl simpleACL = ISuperfluid(host).getSimpleACL(); + bytes32 role = keccak256(bytes(provider)); + + console.log("Host: ", host); + console.log("SimpleACL: ", address(simpleACL)); + console.log("Grantee: ", grantee); + console.log("Provider: ", provider); + console.log("Role (hex):"); + console.logBytes32(role); + + if (simpleACL.hasRole(role, grantee)) { + console.log("Grantee already has the role; no-op."); + return; + } + + vm.startBroadcast(); + simpleACL.grantRole(role, grantee); + vm.stopBroadcast(); + + console.log("Granted macro provider role to", grantee); + } +} diff --git a/packages/ethereum-contracts/tasks/deploy-712-macro-forwarder.sh b/packages/ethereum-contracts/tasks/deploy-712-macro-forwarder.sh new file mode 100755 index 0000000000..773f098c1f --- /dev/null +++ b/packages/ethereum-contracts/tasks/deploy-712-macro-forwarder.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -eu +set -o pipefail + +# Usage: +# tasks/deploy-712-macro-forwarder.sh +# +# The invoking account needs to be (co-)owner of the resolver and governance +# +# important ENV vars: +# RELEASE_VERSION, ONLY712MACROFWD_DEPLOYER_PK +# ONLY712MACROFWD_EXPECTED_ADDRESS: set after generating deployer with vanity-eth +# (e.g. npx vanityeth -i 712f --contract), or set SKIP_ADDRESS_CHECK=1 to skip. +# +# You can use the npm package vanity-eth to get a deployer account for a given contract address: +# Example use: npx vanityeth -i 712f --contract +# +# For optimism the gas estimation doesn't work, requires setting EST_TX_COST +# (the value auto-detected for arbitrum should work). +# +# On some networks you may need to use override ENV vars for the deployment to succeed + +# shellcheck source=/dev/null +source .env + +set -x + +network=$1 +expectedContractAddr="0x712F228ba2638FF22B383d97B0d0D210a06F6547" +deployerPk=$ONLY712MACROFWD_DEPLOYER_PK + +tmpfile="/tmp/$(basename "$0").addr" + +# deploy +DETERMINISTIC_DEPLOYER_PK=$deployerPk npx truffle exec --network "$network" ops-scripts/deploy-deterministically.js : Only712MacroForwarder | tee "$tmpfile" +contractAddr=$(tail -n 1 "$tmpfile") +rm "$tmpfile" + +echo "deployed to $contractAddr" +if [[ -n "$expectedContractAddr" && $contractAddr != "$expectedContractAddr" ]]; then + echo "contract address not as expected!" + if [ -z "$SKIP_ADDRESS_CHECK" ]; then + exit + fi +fi + +# verify (give it a few seconds to pick up the code) +sleep 5 +# allow to fail +set +e +npx truffle run --network "$network" verify Only712MacroForwarder@"$contractAddr" +set -e + +# set resolver +ALLOW_UPDATE=1 npx truffle exec --network "$network" ops-scripts/resolver-set-key-value.js : Only712MacroForwarder "$contractAddr" + +# create gov action +npx truffle exec --network "$network" ops-scripts/gov-set-trusted-forwarder.js : 0x0000000000000000000000000000000000000000 "$contractAddr" 1 + +# TODO: on mainnets, the resolver entry should be set only after the gov action was signed & executed diff --git a/packages/ethereum-contracts/tasks/grant-macro-provider-role.sh b/packages/ethereum-contracts/tasks/grant-macro-provider-role.sh new file mode 100755 index 0000000000..3e71945f65 --- /dev/null +++ b/packages/ethereum-contracts/tasks/grant-macro-provider-role.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -eu +set -o pipefail + +# Usage: +# tasks/grant-macro-provider-role.sh [provider] +# +# Grants the macro provider role in SimpleACL. The role is keccak256(provider); +# default provider is "macros.superfluid.eth". +# +# Network: canonical name from metadata (e.g. optimism-sepolia, eth-sepolia). +# Grantee: address to grant the role (must be valid for the forwarder / macro runner). +# +# The caller must have DEFAULT_ADMIN_ROLE on SimpleACL (e.g. governance). +# Use your usual signer: private key (PRIVATE_KEY / --private-key), foundry wallet +# (--account), or other forge-supported options. +# +# Requires: jq, forge. Run from packages/ethereum-contracts (or repo root; script will cd). + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PKG_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +METADATA_JSON="$PKG_ROOT/../metadata/networks.json" + +network="${1:?Usage: $0 [provider]}" +grantee="${2:?Usage: $0 [provider]}" +provider="${3:-macros.superfluid.eth}" +account="${FOUNDRY_ACCOUNT:-gh-agent}" + +if [[ ! -f "$METADATA_JSON" ]]; then + echo "Metadata not found: $METADATA_JSON" >&2 + exit 1 +fi + +host=$(jq -r '.[] | select(.name == "'"$network"'") | .contractsV1.host' "$METADATA_JSON") +rpc=$(jq -r '.[] | select(.name == "'"$network"'") | .publicRPCs[0]' "$METADATA_JSON") + +if [[ -z "$host" || "$host" == "null" ]]; then + echo "Unknown network: $network (no host in metadata)" >&2 + exit 1 +fi +if [[ -z "$rpc" || "$rpc" == "null" ]]; then + echo "No public RPC for network: $network" >&2 + exit 1 +fi + +echo "Network: $network" +echo "Host: $host" +echo "Grantee: $grantee" +echo "Provider: $provider" +echo "RPC: $rpc" +echo "" + +cd "$PKG_ROOT" +forge script scripts/GrantMacroProviderRole.s.sol \ + --sig "run(address,address,string)" "$host" "$grantee" "$provider" \ + --rpc-url "$rpc" \ + --account "$account" \ + --broadcast diff --git a/packages/ethereum-contracts/test/foundry/utils/MacroForwarder.t.sol b/packages/ethereum-contracts/test/foundry/utils/MacroForwarder.t.sol index cbfbfc294c..aaec4ddab2 100644 --- a/packages/ethereum-contracts/test/foundry/utils/MacroForwarder.t.sol +++ b/packages/ethereum-contracts/test/foundry/utils/MacroForwarder.t.sol @@ -65,7 +65,7 @@ contract GoodMacro is IUserDefinedMacro { function postCheck(ISuperfluid host, bytes memory params, address msgSender) external view { } // recommended view function for parameter encoding - function paramsCreateFlows(ISuperToken token, int96 flowRate, address[] calldata recipients) external pure returns (bytes memory) { + function encodeCreateFlows(ISuperToken token, int96 flowRate, address[] calldata recipients) external pure returns (bytes memory) { return abi.encode(token, flowRate, recipients); } } @@ -103,7 +103,7 @@ contract MultiFlowDeleteMacro is IUserDefinedMacro { } // recommended view function for parameter encoding - function paramsDeleteFlows(ISuperToken superToken, address sender, address[] memory receivers, uint256 minBalanceAfter) + function encodeDeleteFlows(ISuperToken superToken, address sender, address[] memory receivers, uint256 minBalanceAfter) external pure returns (bytes memory) { @@ -320,7 +320,7 @@ contract MacroForwarderTest is FoundrySuperfluidTester { vm.startPrank(admin); // NOTE! This is different from abi.encode(superToken, int96(42), [bob, carol]), // which is a fixed array: address[2]. - sf.macroForwarder.runMacro(m, m.paramsCreateFlows(superToken, int96(42), recipients)); + sf.macroForwarder.runMacro(m, m.encodeCreateFlows(superToken, int96(42), recipients)); assertEq(sf.cfa.getNetFlow(superToken, bob), 42); assertEq(sf.cfa.getNetFlow(superToken, carol), 42); vm.stopPrank(); @@ -355,7 +355,7 @@ contract MacroForwarderTest is FoundrySuperfluidTester { superToken.createFlow(recipients[i], 42); } // now batch-delete them - sf.macroForwarder.runMacro(m, m.paramsDeleteFlows(superToken, sender, recipients, 0)); + sf.macroForwarder.runMacro(m, m.encodeDeleteFlows(superToken, sender, recipients, 0)); for (uint i = 0; i < recipients.length; ++i) { assertEq(sf.cfa.getNetFlow(superToken, recipients[i]), 0); diff --git a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol new file mode 100644 index 0000000000..c1a2748b8f --- /dev/null +++ b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol @@ -0,0 +1,426 @@ +// SPDX-License-Identifier: AGPLv3 +pragma solidity ^0.8.23; + +import { VmSafe } from "forge-std/Vm.sol"; +import { console } from "forge-std/console.sol"; +import { IAccessControl } from "@openzeppelin-v5/contracts/access/IAccessControl.sol"; +import { BatchOperation, ISuperfluid, ISuperfluidToken } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; +import { IUserDefined712Macro } from "../../../contracts/interfaces/utils/IUserDefinedMacro.sol"; +import { Strings } from "@openzeppelin-v5/contracts/utils/Strings.sol"; +import { Only712MacroForwarder, NonceManager } from "../../../contracts/utils/Only712MacroForwarder.sol"; +import { FoundrySuperfluidTester } from "../FoundrySuperfluidTester.t.sol"; + +string constant PRIMARY_TYPE_NAME = "MinimalExample"; +string constant ACTION_TYPEDEF = "Action(string description)"; +string constant SECURITY_DOMAIN = "minimalmacro.xyz"; +string constant SECURITY_PROVIDER = "macros.superfluid.eth"; +string constant SECURITY_TYPEDEF = "Security(string domain,string provider,uint256 validAfter,uint256 validBefore,uint256 nonce)"; +uint256 constant DEFAULT_NONCE = uint256(1) << 64; +uint256 constant TEST_AMOUNT = 100e18; + +// ============== Minimal macro for Only712MacroForwarder ============== +// Implements IUserDefined712Macro and uses *no* postCheck logic. +// Expects params (token, amount); does a SuperToken upgrade from underlying. +// Shows how the params can be different from the type definition, while still being part of the signed data +// (via the dynamic construction of the description string from the params) +contract Minimal712Macro is IUserDefined712Macro { + + string public constant ACTION_TYPE_DEFINITION = "Action(string description)"; + + function _buildDescription(address token, uint256 amount) internal pure returns (string memory) { + return string.concat( + "Upgrade ", + Strings.toString(amount), + " ", + Strings.toHexString(token) + ); + } + + function buildBatchOperations(ISuperfluid, bytes memory params, address /*signer*/) + external + pure + override + returns (ISuperfluid.Operation[] memory operations) + { + (address token, uint256 amount) = abi.decode(params, (address, uint256)); + operations = new ISuperfluid.Operation[](1); + operations[0] = ISuperfluid.Operation({ + operationType: BatchOperation.OPERATION_TYPE_SUPERTOKEN_UPGRADE, + target: token, + data: abi.encode(amount) + }); + } + + function postCheck(ISuperfluid, bytes memory, address) external view override { + // intentionally empty + } + + function getActionTypeDefinition(bytes memory /*params*/) external pure override returns (string memory) { + return ACTION_TYPE_DEFINITION; + } + + function getPrimaryTypeName(bytes memory /*params*/) external pure override returns (string memory) { + return PRIMARY_TYPE_NAME; + } + + function getActionStructHash(bytes memory params) external pure override returns (bytes32) { + (address token, uint256 amount) = abi.decode(params, (address, uint256)); + string memory description = _buildDescription(token, amount); + bytes32 actionTypeHash = keccak256(abi.encodePacked(ACTION_TYPE_DEFINITION)); + return keccak256(abi.encode(actionTypeHash, keccak256(bytes(description)))); + } +} + +// ============== Test Contract ============== + +contract Only712MacroForwarderTest is FoundrySuperfluidTester { + Only712MacroForwarder internal forwarder; + Minimal712Macro internal minimal712Macro; + + constructor() FoundrySuperfluidTester(5) { } + + function setUp() public override { + super.setUp(); + forwarder = new Only712MacroForwarder(sf.host); + minimal712Macro = new Minimal712Macro(); + + IAccessControl acl = IAccessControl(sf.host.getSimpleACL()); + vm.prank(address(sfDeployer)); + acl.grantRole(keccak256(bytes(SECURITY_PROVIDER)), address(this)); + + vm.prank(address(sfDeployer)); + sf.governance.enableTrustedForwarder(sf.host, ISuperfluidToken(address(0)), address(forwarder)); + } + + function testEncodeParams( + uint256 nonce, + uint256 validAfter, + uint256 validBefore, + address token, + uint256 amount + ) external view { + Only712MacroForwarder.PrimaryType memory payload = Only712MacroForwarder.PrimaryType({ + action: Only712MacroForwarder.ActionType({ actionParams: abi.encode(token, amount) }), + security: Only712MacroForwarder.SecurityType({ + domain: SECURITY_DOMAIN, + provider: SECURITY_PROVIDER, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }) + }); + bytes memory localPayload = abi.encode(payload); + + bytes memory forwarderPayload = forwarder.encodeParams( + abi.encode(token, amount), + Only712MacroForwarder.SecurityType({ + domain: SECURITY_DOMAIN, + provider: SECURITY_PROVIDER, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }) + ); + assertEq(localPayload, forwarderPayload, "encodeParams output must match manual PrimaryType encoding"); + } + + function testRunMacro(uint256 signerPrivateKey) external { + signerPrivateKey = bound(signerPrivateKey, 1, SECP256K1_ORDER - 1); + VmSafe.Wallet memory signer = vm.createWallet(signerPrivateKey); + _fundSignerForUpgrade(signer, 1); + + uint256 signerSuperBalanceBefore = superToken.balanceOf(signer.addr); + bytes memory params = _getTestPayload(); + bytes memory signatureVRS = _signPayload(signer, params); + assertTrue(_runMacroAs(address(this), signer.addr, params, signatureVRS)); + assertEq(superToken.balanceOf(signer.addr), signerSuperBalanceBefore + TEST_AMOUNT, "signer super token balance should increase by TEST_AMOUNT"); + } + + function testRevertsWhenCallerMissingProviderRole() external { + VmSafe.Wallet memory signer = vm.createWallet("signer"); + bytes memory params = _getTestPayload(); + bytes memory signatureVRS = _signPayload(signer, params); + vm.expectRevert(abi.encodeWithSelector( + Only712MacroForwarder.ProviderNotAuthorized.selector, SECURITY_PROVIDER, address(0xbad))); + _runMacroAs(address(0xbad), signer.addr, params, signatureVRS); + } + + function testDigestCalculation() external view { + // check the type definition (build same way as forwarder: primary + action typedef + security typedef) + string memory typeDefinition = forwarder.getTypeDefinition(minimal712Macro, _getTestPayload()); + string memory expectedTypeDefinition = string(abi.encodePacked( + PRIMARY_TYPE_NAME, + "(Action action,Security security)", + ACTION_TYPEDEF, + SECURITY_TYPEDEF + )); + assertEq(typeDefinition, expectedTypeDefinition, "typeDefinition mismatch"); + + // check the type hash + bytes32 typeHash = forwarder.getTypeHash(minimal712Macro, _getTestPayload()); + bytes32 expectedTypeHash = vm.eip712HashType(expectedTypeDefinition); + assertEq(typeHash, expectedTypeHash, "typeHash mismatch"); + + // check the struct hash (includes type hash and the struct data) + bytes memory payload = _getTestPayload(); + bytes32 structHash = forwarder.getStructHash(minimal712Macro, payload); + bytes32 actionStructHash = minimal712Macro.getActionStructHash(abi.encode(address(superToken), TEST_AMOUNT)); + bytes32 securityTypeHash = keccak256(abi.encodePacked(SECURITY_TYPEDEF)); + bytes32 securityStructHash = keccak256(abi.encode( + securityTypeHash, + keccak256(bytes(SECURITY_DOMAIN)), + keccak256(bytes(SECURITY_PROVIDER)), + uint256(0), + uint256(0), + DEFAULT_NONCE + )); + bytes32 expectedStructHash = keccak256(abi.encode( + expectedTypeHash, + actionStructHash, + securityStructHash + )); + assertEq(structHash, expectedStructHash, "structHash mismatch"); + + // check the digest + bytes32 digest = forwarder.getDigest(minimal712Macro, payload); + string memory dataToBeSignedJson = _getDataToBeSignedJson(); + console.log(dataToBeSignedJson); + bytes32 expectedDigest = vm.eip712HashTypedData(dataToBeSignedJson); + assertEq(digest, expectedDigest, "digest mismatch"); + } + + function testGetNonce(uint192 key) external { + VmSafe.Wallet memory signer = vm.createWallet("signer"); + _fundSignerForUpgrade(signer, 10); + + for (uint256 i = 0; i < 10; i++) { + uint256 nonce = forwarder.getNonce(signer.addr, key); + bytes memory params = _getPayloadWithTokenAmount(nonce, 0, 0, address(superToken), TEST_AMOUNT); + bytes memory signatureVRS = _signPayload(signer, params); + assertTrue(_runMacroAs(address(this), signer.addr, params, signatureVRS), "runMacro with getNonce() nonce should succeed"); + } + } + + function testCannotReuseNonce(uint192 key) external { + VmSafe.Wallet memory signer = vm.createWallet("signer"); + _fundSignerForUpgrade(signer, 2); + + uint256 nonce = forwarder.getNonce(signer.addr, key); + bytes memory params = _getPayloadWithTokenAmount(nonce, 0, 0, address(superToken), TEST_AMOUNT); + bytes memory signatureVRS = _signPayload(signer, params); + assertTrue(_runMacroAs(address(this), signer.addr, params, signatureVRS)); + + vm.expectRevert(abi.encodeWithSelector(NonceManager.InvalidNonce.selector, signer.addr, nonce)); + _runMacroAs(address(this), signer.addr, params, signatureVRS); + } + + function testValidityWindow(uint32 t0_raw, uint32 t1_raw) external { + uint256 t0 = uint256(t0_raw); + uint256 t1 = uint256(t1_raw); + + vm.warp(t0); + + VmSafe.Wallet memory signer = vm.createWallet("signer"); + _fundSignerForUpgrade(signer, 2); + uint256 nonce = forwarder.getNonce(signer.addr, 0); + bytes memory params = _getPayloadWithTokenAmount(nonce, t0, t1, address(superToken), TEST_AMOUNT); + bytes memory signatureVRS = _signPayload(signer, params); + + // Before validAfter: revert (skip when t0 == 0 to avoid underflow) + if (t0 > 0) { + vm.warp(t0 - 1); + vm.expectRevert(abi.encodeWithSelector( + Only712MacroForwarder.OutsideValidityWindow.selector, t0 - 1, t1, t0)); + _runMacroAs(address(this), signer.addr, params, signatureVRS); + } + + // Within window: success when non-empty (t1 == 0 or t1 >= t0); else revert + if (t1 == 0 || t1 >= t0) { + vm.warp(t1 == 0 ? t0 + 100 : t0 + (t1 - t0) / 2); + assertTrue(_runMacroAs(address(this), signer.addr, params, signatureVRS)); + } else { + vm.warp(t0); + vm.expectRevert(abi.encodeWithSelector( + Only712MacroForwarder.OutsideValidityWindow.selector, t0, t1, t0)); + _runMacroAs(address(this), signer.addr, params, signatureVRS); + } + + // After validBefore: revert (use non-zero validBefore so 0 = unbounded is not used here) + uint256 expiry = t0 > 0 ? t0 : 1; + nonce = forwarder.getNonce(signer.addr, 0); + params = _getPayloadWithTokenAmount(nonce, 0, expiry, address(superToken), TEST_AMOUNT); + signatureVRS = _signPayload(signer, params); + vm.warp(expiry + 1); + vm.expectRevert(abi.encodeWithSelector( + Only712MacroForwarder.OutsideValidityWindow.selector, expiry + 1, expiry, uint256(0))); + _runMacroAs(address(this), signer.addr, params, signatureVRS); + } + + /// For a given key, nonces must be used in sequence (0, 1, 2, ...). Skipping must revert. + function testNonceEnforceInSequence(uint192 key) external { + VmSafe.Wallet memory signer = vm.createWallet("signer"); + _fundSignerForUpgrade(signer, 2); + + // Using seq=1 before seq=0 must revert + uint256 nonceSeq1 = (uint256(key) << 64) | 1; + bytes memory paramsSeq1 = _getPayloadWithTokenAmount(nonceSeq1, 0, 0, address(superToken), TEST_AMOUNT); + bytes memory sig1 = _signPayload(signer, paramsSeq1); + + vm.expectRevert(abi.encodeWithSelector(NonceManager.InvalidNonce.selector, signer.addr, nonceSeq1)); + _runMacroAs(address(this), signer.addr, paramsSeq1, sig1); + + // seq=0 must succeed + uint256 nonceSeq0 = uint256(key) << 64; + bytes memory paramsSeq0 = _getPayloadWithTokenAmount(nonceSeq0, 0, 0, address(superToken), TEST_AMOUNT); + bytes memory sig0 = _signPayload(signer, paramsSeq0); + assertTrue(_runMacroAs(address(this), signer.addr, paramsSeq0, sig0)); + + // now seq=1 must succeed + assertTrue(_runMacroAs(address(this), signer.addr, paramsSeq1, sig1)); + } + + function _fundSignerForUpgrade(VmSafe.Wallet memory signer, uint256 runs) internal { + uint256 total = TEST_AMOUNT * runs; + vm.prank(alice); + token.transfer(signer.addr, total); + vm.prank(signer.addr); + token.approve(address(superToken), total); + } + + function _getPayloadWithTokenAmount( + uint256 nonce, + uint256 validAfter, + uint256 validBefore, + address token, + uint256 amount + ) internal view returns (bytes memory) { + return forwarder.encodeParams( + abi.encode(token, amount), + Only712MacroForwarder.SecurityType({ + domain: SECURITY_DOMAIN, + provider: SECURITY_PROVIDER, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }) + ); + } + + function _getTestPayload() internal view returns (bytes memory) { + return _getPayloadWithTokenAmount(DEFAULT_NONCE, 0, 0, address(superToken), TEST_AMOUNT); + } + + // example: https://github.com/vaquita-fi/vaquita-lisk/blob/c4964af9157c9cca9cfb167ac1a4450e36edb29e/contracts/test/VaquitaPool.t.sol#L142 + // The splitting up into many functions avoids stack too deep error. + function _getDataToBeSignedJson() internal view returns (string memory) { + return string(abi.encodePacked( + '{', + '"types": {', _getTypesJson(), '},', + '"primaryType": "MinimalExample",', // leaving this as literal in order to fit onto the stack + '"domain": {', _getDomainJson(), '},', + '"message": {', + '"action": {', _getActionJson(), '},', + '"security": {', _getSecurityJson(), '}', + '}', + '}' + )); + } + + function _getTypesJson() internal pure returns (string memory) { + return string(abi.encodePacked( + _getEIP712DomainTypeJson(), + _getMinimalExampleTypeJson(), + _getActionTypeJson(), + _getSecurityTypeJson() + )); + } + + function _getEIP712DomainTypeJson() internal pure returns (string memory) { + return string(abi.encodePacked( + '"EIP712Domain": [', + '{"name": "name", "type": "string"},', + '{"name": "version", "type": "string"},', + '{"name": "chainId", "type": "uint256"},', + '{"name": "verifyingContract", "type": "address"}', + '],' + )); + } + + function _getMinimalExampleTypeJson() internal pure returns (string memory) { + return string(abi.encodePacked( + '"MinimalExample": [', + '{"name": "action", "type": "Action"},', + '{"name": "security", "type": "Security"}', + '],' + )); + } + + function _getActionTypeJson() internal pure returns (string memory) { + return string(abi.encodePacked( + '"Action": [', + '{"name": "description", "type": "string"}', + '],' + )); + } + + function _getSecurityTypeJson() internal pure returns (string memory) { + return string(abi.encodePacked( + '"Security": [', + '{"name": "domain", "type": "string"},', + '{"name": "provider", "type": "string"},', + '{"name": "validAfter", "type": "uint256"},', + '{"name": "validBefore", "type": "uint256"},', + '{"name": "nonce", "type": "uint256"}', + ']' + )); + } + + function _getDomainJson() internal view returns (string memory) { + return string(abi.encodePacked( + '"name": "ClearSigning",', + '"version": "1",', + '"chainId": ', vm.toString(block.chainid), ',', + '"verifyingContract": "', vm.toString(address(forwarder)), '"' + )); + } + + function _getExpectedDescription() internal view returns (string memory) { + return string.concat( + "Upgrade ", + Strings.toString(TEST_AMOUNT), + " ", + Strings.toHexString(address(superToken)) + ); + } + + function _getActionJson() internal view returns (string memory) { + return string(abi.encodePacked( + '"description": "', _getExpectedDescription(), '"' + )); + } + + function _getSecurityJson() internal pure returns (string memory) { + // Use string for nonce so Foundry's JSON parser accepts 2^64 as uint256 (avoids type mismatch) + return string(abi.encodePacked( + '"domain": "', SECURITY_DOMAIN, '",', + '"provider": "', SECURITY_PROVIDER, '",', + '"validAfter": "0",', + '"validBefore": "0",', + '"nonce": "', vm.toString(DEFAULT_NONCE), '"' + )); + } + + function _runMacroAs(address relayer, address signer, bytes memory params, bytes memory signatureVRS) + internal + returns (bool) + { + vm.prank(relayer); + return forwarder.runMacro(minimal712Macro, params, signer, signatureVRS); + } + + function _signPayload(VmSafe.Wallet memory signer, bytes memory params) internal returns (bytes memory) { + bytes32 digest = forwarder.getDigest(minimal712Macro, params); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signer, digest); + return abi.encodePacked(r, s, v); + } +}