From 277c4c0d099be4ba969a9f45e6b8c28dc457e930 Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 28 Jan 2026 17:36:16 +0100 Subject: [PATCH 01/18] first shot --- .../contracts/utils/Only712MacroForwarder.sol | 29 ++ .../foundry/utils/Only712MacroForwarder.t.sol | 307 ++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol create mode 100644 packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol diff --git a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol new file mode 100644 index 0000000000..7c8f029493 --- /dev/null +++ b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPLv3 +pragma solidity ^0.8.23; + +import { IUserDefinedMacro } from "../interfaces/utils/IUserDefinedMacro.sol"; +import { ISuperfluid } from "../interfaces/superfluid/ISuperfluid.sol"; +import { ForwarderBase } from "./ForwarderBase.sol"; + +/** + * @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. + */ +contract Only712MacroForwarder is ForwarderBase { + constructor(ISuperfluid host, address /*registry*/) ForwarderBase(host) {} + + /** + * @dev Run the macro with encoded payload (envelope + app params; envelope verification TBD). + * @param m Target macro. + * @param params Encoded payload. Minimal format: abi.encode(appParams). + */ + function runMacro(IUserDefinedMacro m, bytes calldata params) external payable returns (bool) { + bytes memory appParams = abi.decode(params, (bytes)); + + ISuperfluid.Operation[] memory operations = m.buildBatchOperations(_host, appParams, msg.sender); + bool retVal = _forwardBatchCallWithValue(operations, msg.value); + m.postCheck(_host, appParams, msg.sender); + return retVal; + } +} 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..7f9f2f6962 --- /dev/null +++ b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: AGPLv3 +pragma solidity ^0.8.23; + +import { ISuperfluid, BatchOperation } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; +import { ISuperfluidToken } from "../../../contracts/interfaces/superfluid/ISuperfluidToken.sol"; +import { ISuperToken } from "../../../contracts/superfluid/SuperToken.sol"; +import { IConstantFlowAgreementV1 } from "../../../contracts/interfaces/agreements/IConstantFlowAgreementV1.sol"; +import { IUserDefinedMacro } from "../../../contracts/interfaces/utils/IUserDefinedMacro.sol"; +import { Only712MacroForwarder } from "../../../contracts/utils/Only712MacroForwarder.sol"; +import { SuperUpgrader } from "../../../contracts/utils/SuperUpgrader.sol"; +import { FoundrySuperfluidTester, SuperTokenV1Library } from "../FoundrySuperfluidTester.t.sol"; + +using SuperTokenV1Library for ISuperToken; + +// ============== Mock Macro: Create Flow + Allowance to SuperUpgrader ============== + +/** + * @dev Macro that builds: (1) ERC20 approve SuperToken to SuperUpgrader, (2) CFA createFlow. + * Params: abi.encode(actionCode, lang, actionParams, signatureVRS) + * actionParams: abi.encode(SetFlowAndAllowanceParams) + */ +contract FlowAndUpgradeMacro is IUserDefinedMacro { + struct SetFlowAndAllowanceParams { + ISuperToken superToken; + address receiver; + int96 flowRate; + address superUpgrader; + uint256 upgradeAllowance; + } + + uint8 public constant ACTION_SET_FLOW_AND_ALLOWANCE = 1; + + error UnknownActionCode(uint8 actionCode); + error WrongFlowRate(); + error InsufficientAllowance(); + + function buildBatchOperations(ISuperfluid host, bytes memory params, address /*msgSender*/) + external + override + view + returns (ISuperfluid.Operation[] memory operations) + { + (uint8 actionCode, /*bytes32 lang*/, bytes memory actionParams, /*bytes memory signatureVRS*/) = + abi.decode(params, (uint8, bytes32, bytes, bytes)); + + if (actionCode != ACTION_SET_FLOW_AND_ALLOWANCE) revert UnknownActionCode(actionCode); + + SetFlowAndAllowanceParams memory p = abi.decode(actionParams, (SetFlowAndAllowanceParams)); + + IConstantFlowAgreementV1 cfa = + IConstantFlowAgreementV1(address(host.getAgreementClass( + keccak256("org.superfluid-finance.agreements.ConstantFlowAgreement.v1") + ))); + + operations = new ISuperfluid.Operation[](2); + + // op 1: approve SuperToken to SuperUpgrader (Host supports SuperToken.operationApprove) + operations[0] = ISuperfluid.Operation({ + operationType: BatchOperation.OPERATION_TYPE_ERC20_APPROVE, + target: address(p.superToken), + data: abi.encode(p.superUpgrader, p.upgradeAllowance) + }); + + // op 2: CFA createFlow + operations[1] = ISuperfluid.Operation({ + operationType: BatchOperation.OPERATION_TYPE_SUPERFLUID_CALL_AGREEMENT, + target: address(cfa), + data: abi.encode( + abi.encodeCall(cfa.createFlow, (p.superToken, p.receiver, p.flowRate, new bytes(0))), + new bytes(0) + ) + }); + } + + function postCheck(ISuperfluid /*host*/, bytes memory params, address msgSender) external view override { + (, /*lang*/, bytes memory actionParams,) = abi.decode(params, (uint8, bytes32, bytes, bytes)); + SetFlowAndAllowanceParams memory p = abi.decode(actionParams, (SetFlowAndAllowanceParams)); + + int96 actualFlowRate = p.superToken.getFlowRate(msgSender, p.receiver); + if (actualFlowRate != p.flowRate) revert WrongFlowRate(); + + uint256 allowance = p.superToken.allowance(msgSender, p.superUpgrader); + if (allowance < p.upgradeAllowance) revert InsufficientAllowance(); + } + + function encodeParams( + ISuperToken superToken_, + address receiver_, + int96 flowRate_, + address superUpgrader_, + uint256 upgradeAllowance_ + ) + external + pure + returns (bytes memory) + { + SetFlowAndAllowanceParams memory p = SetFlowAndAllowanceParams({ + superToken: superToken_, + receiver: receiver_, + flowRate: flowRate_, + superUpgrader: superUpgrader_, + upgradeAllowance: upgradeAllowance_ + }); + return abi.encode(ACTION_SET_FLOW_AND_ALLOWANCE, bytes32("en"), abi.encode(p), new bytes(0)); + } +} + +// ============== Test Contract ============== + +contract Only712MacroForwarderTest is FoundrySuperfluidTester { + Only712MacroForwarder internal only712MacroForwarder; + SuperUpgrader internal superUpgrader; + FlowAndUpgradeMacro internal flowAndUpgradeMacro; + + int96 internal constant TEST_FLOW_RATE = 42; + uint256 internal constant TEST_UPGRADE_ALLOWANCE = 1000e18; + + /// @dev Private key for signer; vm.addr(SIGNER_PRIV_KEY) receives tokens and signs envelope + app message. + uint256 internal constant SIGNER_PRIV_KEY = 1; + + constructor() FoundrySuperfluidTester(5) {} + + function setUp() public override { + super.setUp(); + + address[] memory backends = new address[](1); + backends[0] = admin; + superUpgrader = new SuperUpgrader(admin, backends); + + only712MacroForwarder = new Only712MacroForwarder(sf.host, address(0)); + + flowAndUpgradeMacro = new FlowAndUpgradeMacro(); + + vm.prank(address(sfDeployer)); + sf.governance.enableTrustedForwarder( + sf.host, + ISuperfluidToken(address(0)), + address(only712MacroForwarder) + ); + + address signer = vm.addr(SIGNER_PRIV_KEY); + vm.prank(alice); + superToken.transfer(signer, 1e24); + } + + /** + * @dev Happy path for the final implementation: build envelope + envelope sig + app params + app sig, + * call runMacro. Expects revert until forwarder decodes/verifies envelope and macro verifies app sig. + */ + function testFlowAndUpgradeMacroViaOnly712Forwarder_HappyPathExpectRevert() external { + bytes memory payload = _buildFullPayload(); + vm.startPrank(vm.addr(SIGNER_PRIV_KEY)); + vm.expectRevert(); + only712MacroForwarder.runMacro(IUserDefinedMacro(address(flowAndUpgradeMacro)), payload); + vm.stopPrank(); + } + + function _buildFullPayload() internal view returns (bytes memory) { + bytes memory envelopeEncoded = abi.encode( + "test.xyz", + "1", + "en", + "Read before signing.", + "Set flow and allowance", + "Create stream and approve SuperUpgrader for future upgrades", + "macros.superfluid.eth", + block.timestamp, + block.timestamp + 1 hours, + uint256(1), + address(flowAndUpgradeMacro) + ); + bytes32 envelopeDigest = _hashMacroEnvelope(envelopeEncoded, address(only712MacroForwarder)); + (uint8 ev, bytes32 er, bytes32 es) = vm.sign(SIGNER_PRIV_KEY, envelopeDigest); + bytes memory envelopeSig = abi.encodePacked(er, es, ev); + + bytes32 appDigest = _hashSetFlowAndAllowance( + "Set your stream to 42 FTTx/month to bob and approve SuperUpgrader for 1000 FTTx", + address(superToken), + bob, + TEST_FLOW_RATE, + address(superUpgrader), + TEST_UPGRADE_ALLOWANCE, + address(flowAndUpgradeMacro) + ); + (uint8 av, bytes32 ar, bytes32 as_) = vm.sign(SIGNER_PRIV_KEY, appDigest); + + FlowAndUpgradeMacro.SetFlowAndAllowanceParams memory p = FlowAndUpgradeMacro.SetFlowAndAllowanceParams({ + superToken: superToken, + receiver: bob, + flowRate: TEST_FLOW_RATE, + superUpgrader: address(superUpgrader), + upgradeAllowance: TEST_UPGRADE_ALLOWANCE + }); + bytes memory appParams = abi.encode( + flowAndUpgradeMacro.ACTION_SET_FLOW_AND_ALLOWANCE(), + bytes32("en"), + abi.encode(p), + abi.encode(av, ar, as_) + ); + return abi.encode(envelopeEncoded, envelopeSig, appParams); + } + + function _hashMacroEnvelope(bytes memory envelopeEncoded, address verifyingContract) + internal + view + returns (bytes32) + { + bytes32 structHash = _macroEnvelopeStructHash(envelopeEncoded); + return _eip712Digest(_macroEnvelopeDomainSeparator(verifyingContract), structHash); + } + + function _macroEnvelopeStructHash(bytes memory envelopeEncoded) internal pure returns (bytes32) { + ( + string memory domain, + string memory version, + string memory language, + string memory disclaimer, + string memory title, + string memory description, + string memory provider, + uint256 validAfter, + uint256 validBefore, + uint256 nonce, + address macroAddr + ) = abi.decode( + envelopeEncoded, + (string, string, string, string, string, string, string, uint256, uint256, uint256, address) + ); + bytes32 typeHash = keccak256( + "MacroEnvelope(string domain,string version,string language,string disclaimer,string title,string description,string provider,uint256 validAfter,uint256 validBefore,uint256 nonce,address macro)" + ); + return keccak256( + abi.encode( + typeHash, + keccak256(bytes(domain)), + keccak256(bytes(version)), + keccak256(bytes(language)), + keccak256(bytes(disclaimer)), + keccak256(bytes(title)), + keccak256(bytes(description)), + keccak256(bytes(provider)), + validAfter, + validBefore, + nonce, + macroAddr + ) + ); + } + + function _macroEnvelopeDomainSeparator(address verifyingContract) internal view returns (bytes32) { + return keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256("Superfluid Macro"), + keccak256("1"), + block.chainid, + verifyingContract + ) + ); + } + + function _hashSetFlowAndAllowance( + string memory message, + address superToken_, + address receiver_, + int96 flowRate_, + address superUpgrader_, + uint256 upgradeAllowance_, + address verifyingContract + ) + internal + view + returns (bytes32) + { + bytes32 typeHash = keccak256( + "SetFlowAndAllowance(string message,address superToken,address receiver,int96 flowRate,address superUpgrader,uint256 upgradeAllowance)" + ); + bytes32 structHash = keccak256( + abi.encode( + typeHash, + keccak256(bytes(message)), + superToken_, + receiver_, + flowRate_, + superUpgrader_, + upgradeAllowance_ + ) + ); + return _eip712Digest(_flowAndUpgradeMacroDomainSeparator(verifyingContract), structHash); + } + + function _flowAndUpgradeMacroDomainSeparator(address verifyingContract) internal view returns (bytes32) { + return keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256("FlowAndUpgradeMacro"), + keccak256("1"), + block.chainid, + verifyingContract + ) + ); + } + + function _eip712Digest(bytes32 domainSeparator, bytes32 structHash) internal pure returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + } +} From 6d571b25537d7223bb22b98a0e7e9315e08ae3a9 Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 28 Jan 2026 20:33:12 +0100 Subject: [PATCH 02/18] minimal implementation --- .../contracts/utils/Only712MacroForwarder.sol | 118 ++++++++++++++++-- 1 file changed, 109 insertions(+), 9 deletions(-) diff --git a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol index 7c8f029493..27147306e6 100644 --- a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol +++ b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol @@ -1,7 +1,9 @@ // SPDX-License-Identifier: AGPLv3 pragma solidity ^0.8.23; -import { IUserDefinedMacro } from "../interfaces/utils/IUserDefinedMacro.sol"; +import { EIP712 } from "@openzeppelin-v5/contracts/utils/cryptography/EIP712.sol"; +import { SignatureChecker } from "@openzeppelin-v5/contracts/utils/cryptography/SignatureChecker.sol"; +import { IUserDefined712Macro } from "../interfaces/utils/IUserDefinedMacro.sol"; import { ISuperfluid } from "../interfaces/superfluid/ISuperfluid.sol"; import { ForwarderBase } from "./ForwarderBase.sol"; @@ -10,20 +12,118 @@ import { ForwarderBase } from "./ForwarderBase.sol"; * 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. */ -contract Only712MacroForwarder is ForwarderBase { - constructor(ISuperfluid host, address /*registry*/) ForwarderBase(host) {} +contract Only712MacroForwarder is ForwarderBase, EIP712 { + + // top-level data structure + struct Payload { + PayloadMeta meta; + PayloadMessage message; + PayloadSecurity security; + } + struct PayloadMeta { + string domain; + string version; + //string language; + //string disclaimer; + } + bytes32 constant TYPEHASH_META = keccak256("Meta(string domain,string version)"); + struct PayloadMessage { + string title; + //string description; + bytes customPayload; + } + // the message typehash is user macro specific + struct PayloadSecurity { + string provider; + //uint256 validAfter; + //uint256 validBefore; + uint256 nonce; + } + bytes32 constant TYPEHASH_SECURITY = keccak256("Security(string provider,uint256 nonce)"); + + error InvalidPayload(string message); + error InvalidProvider(string provider); + error InvalidSignature(); + + // TODO: should this be something like "Clear Sign" instead? + constructor(ISuperfluid host, address /*registry*/) ForwarderBase(host) EIP712("Only712MacroForwarder", "1") {} /** - * @dev Run the macro with encoded payload (envelope + app params; envelope verification TBD). + * @dev Run the macro with encoded payload (generic + macro specific fragments). * @param m Target macro. - * @param params Encoded payload. Minimal format: abi.encode(appParams). + * @param params Encoded payload */ - function runMacro(IUserDefinedMacro m, bytes calldata params) external payable returns (bool) { - bytes memory appParams = abi.decode(params, (bytes)); + function runMacro(IUserDefined712Macro m, bytes calldata params, address signer, bytes calldata signature) external payable returns (bool) { + //bytes memory appParams = abi.decode(params, (bytes)); + + // decode the payload + Payload memory payload = abi.decode(params, (Payload)); + require( + keccak256(bytes(payload.security.provider)) == keccak256(bytes("macros.superfluid.eth")), + InvalidProvider(payload.security.provider) + ); + // TODO: verify nonce (replay protection) + + bytes32 metaStructHash = getMetaStructHash(payload.meta); + + // the message fragment is handled by the user macro. + bytes32 messageStructHash = m.getMessageStructHash( + abi.encode(payload.message.title, payload.message.customPayload) + ); - ISuperfluid.Operation[] memory operations = m.buildBatchOperations(_host, appParams, msg.sender); + bytes32 securityStructHash = getSecurityStructHash(payload.security); + + // get the typehash + bytes32 primaryTypeHash = keccak256( + abi.encodePacked( + // TODO: shall we name it "ClearSign"? + "Payload(Meta meta,Message message,Security security)", + // nested components need to be in alphabetical order + m.getMessageTypeHash(), + TYPEHASH_META, + TYPEHASH_SECURITY + ) + ); + + // calculate the digest of the entire payload + bytes32 digest = _hashTypedDataV4( + keccak256( + abi.encode( + primaryTypeHash, + metaStructHash, + messageStructHash, + securityStructHash + ) + ) + ); + + // verify the signature - this also works for ERC1271 (contract signatures) + if (!SignatureChecker.isValidSignatureNow(signer, digest, signature)) { + revert InvalidSignature(); // or custom error + } + + // get the operations array from the user macro based on the payload message + ISuperfluid.Operation[] memory operations = m.buildBatchOperations(_host, payload.message.customPayload, msg.sender); + + // forward the operations bool retVal = _forwardBatchCallWithValue(operations, msg.value); - m.postCheck(_host, appParams, msg.sender); + m.postCheck(_host, payload.message.customPayload, msg.sender); return retVal; } + + function getMetaStructHash(PayloadMeta memory meta) internal pure returns (bytes32) { + return keccak256(abi.encode( + TYPEHASH_META, + keccak256(bytes(meta.domain)), + keccak256(bytes(meta.version)) + )); + } + + function getSecurityStructHash(PayloadSecurity memory security) internal pure returns (bytes32) { + return keccak256(abi.encode( + TYPEHASH_SECURITY, + keccak256(bytes(security.provider)), + security.nonce + )); + } } From 4d99c7bf65d9db77fe3149d0258586ff1e375241 Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 28 Jan 2026 21:05:13 +0100 Subject: [PATCH 03/18] smoke test passing --- .../interfaces/utils/IUserDefinedMacro.sol | 8 + .../contracts/utils/Only712MacroForwarder.sol | 64 ++-- .../foundry/utils/Only712MacroForwarder.t.sol | 318 +++--------------- 3 files changed, 93 insertions(+), 297 deletions(-) diff --git a/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol b/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol index 2ecb2c167f..e2b217f9ad 100644 --- a/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol +++ b/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol @@ -44,3 +44,11 @@ interface IUserDefinedMacro { * You can consult the related test code in `MacroForwarderTest.t.sol` for examples. */ } + +interface IUserDefined712Macro is IUserDefinedMacro { + // TODO: this probably needs to be a function of the message, for the dispatching pattern + // the metaphor being: a macro is like an api, an action is like an endpoint (defining the set of arguments) + function getMessageTypeHash() external view returns (bytes32); + // TODO: should it take the known and required fields already decoded instead? + function getMessageStructHash(bytes memory message) external view returns (bytes32); +} \ No newline at end of file diff --git a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol index 27147306e6..b7e9328226 100644 --- a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol +++ b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol @@ -26,7 +26,7 @@ contract Only712MacroForwarder is ForwarderBase, EIP712 { //string language; //string disclaimer; } - bytes32 constant TYPEHASH_META = keccak256("Meta(string domain,string version)"); + bytes32 internal constant _TYPEHASH_META = keccak256("Meta(string domain,string version)"); struct PayloadMessage { string title; //string description; @@ -39,7 +39,7 @@ contract Only712MacroForwarder is ForwarderBase, EIP712 { //uint256 validBefore; uint256 nonce; } - bytes32 constant TYPEHASH_SECURITY = keccak256("Security(string provider,uint256 nonce)"); + bytes32 internal constant _TYPEHASH_SECURITY = keccak256("Security(string provider,uint256 nonce)"); error InvalidPayload(string message); error InvalidProvider(string provider); @@ -53,9 +53,10 @@ contract Only712MacroForwarder is ForwarderBase, EIP712 { * @param m Target macro. * @param params Encoded payload */ - function runMacro(IUserDefined712Macro m, bytes calldata params, address signer, bytes calldata signature) external payable returns (bool) { - //bytes memory appParams = abi.decode(params, (bytes)); - + function runMacro(IUserDefined712Macro m, bytes calldata params, address signer, bytes calldata signature) + external payable + returns (bool) + { // decode the payload Payload memory payload = abi.decode(params, (Payload)); require( @@ -64,14 +65,37 @@ contract Only712MacroForwarder is ForwarderBase, EIP712 { ); // TODO: verify nonce (replay protection) - bytes32 metaStructHash = getMetaStructHash(payload.meta); + bytes32 digest = _getDigest(m, payload); + + // 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 payload message + ISuperfluid.Operation[] memory operations = + m.buildBatchOperations(_host, payload.message.customPayload, signer); + + // forward the operations + bool retVal = _forwardBatchCallWithValue(operations, msg.value); + // TODO: is customPayload the correct argument here? + m.postCheck(_host, payload.message.customPayload, signer); + return retVal; + } + + function getDigest(IUserDefined712Macro m, bytes calldata params) external view returns (bytes32) { + return _getDigest(m, abi.decode(params, (Payload))); + } + + function _getDigest(IUserDefined712Macro m, Payload memory payload) internal view returns (bytes32) { + bytes32 metaStructHash = _getMetaStructHash(payload.meta); // the message fragment is handled by the user macro. bytes32 messageStructHash = m.getMessageStructHash( abi.encode(payload.message.title, payload.message.customPayload) ); - bytes32 securityStructHash = getSecurityStructHash(payload.security); + bytes32 securityStructHash = _getSecurityStructHash(payload.security); // get the typehash bytes32 primaryTypeHash = keccak256( @@ -80,8 +104,8 @@ contract Only712MacroForwarder is ForwarderBase, EIP712 { "Payload(Meta meta,Message message,Security security)", // nested components need to be in alphabetical order m.getMessageTypeHash(), - TYPEHASH_META, - TYPEHASH_SECURITY + _TYPEHASH_META, + _TYPEHASH_SECURITY ) ); @@ -96,32 +120,20 @@ contract Only712MacroForwarder is ForwarderBase, EIP712 { ) ) ); - - // verify the signature - this also works for ERC1271 (contract signatures) - if (!SignatureChecker.isValidSignatureNow(signer, digest, signature)) { - revert InvalidSignature(); // or custom error - } - - // get the operations array from the user macro based on the payload message - ISuperfluid.Operation[] memory operations = m.buildBatchOperations(_host, payload.message.customPayload, msg.sender); - - // forward the operations - bool retVal = _forwardBatchCallWithValue(operations, msg.value); - m.postCheck(_host, payload.message.customPayload, msg.sender); - return retVal; + return digest; } - function getMetaStructHash(PayloadMeta memory meta) internal pure returns (bytes32) { + function _getMetaStructHash(PayloadMeta memory meta) internal pure returns (bytes32) { return keccak256(abi.encode( - TYPEHASH_META, + _TYPEHASH_META, keccak256(bytes(meta.domain)), keccak256(bytes(meta.version)) )); } - function getSecurityStructHash(PayloadSecurity memory security) internal pure returns (bytes32) { + function _getSecurityStructHash(PayloadSecurity memory security) internal pure returns (bytes32) { return keccak256(abi.encode( - TYPEHASH_SECURITY, + _TYPEHASH_SECURITY, keccak256(bytes(security.provider)), security.nonce )); diff --git a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol index 7f9f2f6962..d273a0d320 100644 --- a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol +++ b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol @@ -1,307 +1,83 @@ // SPDX-License-Identifier: AGPLv3 pragma solidity ^0.8.23; -import { ISuperfluid, BatchOperation } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; -import { ISuperfluidToken } from "../../../contracts/interfaces/superfluid/ISuperfluidToken.sol"; -import { ISuperToken } from "../../../contracts/superfluid/SuperToken.sol"; -import { IConstantFlowAgreementV1 } from "../../../contracts/interfaces/agreements/IConstantFlowAgreementV1.sol"; -import { IUserDefinedMacro } from "../../../contracts/interfaces/utils/IUserDefinedMacro.sol"; +import { VmSafe } from "forge-std/Vm.sol"; +import { ISuperfluid, ISuperfluidToken } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; +import { IUserDefined712Macro } from "../../../contracts/interfaces/utils/IUserDefinedMacro.sol"; import { Only712MacroForwarder } from "../../../contracts/utils/Only712MacroForwarder.sol"; -import { SuperUpgrader } from "../../../contracts/utils/SuperUpgrader.sol"; -import { FoundrySuperfluidTester, SuperTokenV1Library } from "../FoundrySuperfluidTester.t.sol"; +import { FoundrySuperfluidTester } from "../FoundrySuperfluidTester.t.sol"; -using SuperTokenV1Library for ISuperToken; +// ============== Minimal mock macro for Only712MacroForwarder ============== +// Implements IUserDefined712Macro and uses *no* postCheck logic. +// Message has only the required `title`; `customPayload` is expected to be empty. +contract Minimal712Macro is IUserDefined712Macro { + bytes32 internal constant MESSAGE_TYPEHASH = keccak256("Message(string title)"); + bytes32 internal constant EXPECTED_TITLE_HASH = keccak256(bytes("Hello 712")); -// ============== Mock Macro: Create Flow + Allowance to SuperUpgrader ============== - -/** - * @dev Macro that builds: (1) ERC20 approve SuperToken to SuperUpgrader, (2) CFA createFlow. - * Params: abi.encode(actionCode, lang, actionParams, signatureVRS) - * actionParams: abi.encode(SetFlowAndAllowanceParams) - */ -contract FlowAndUpgradeMacro is IUserDefinedMacro { - struct SetFlowAndAllowanceParams { - ISuperToken superToken; - address receiver; - int96 flowRate; - address superUpgrader; - uint256 upgradeAllowance; - } - - uint8 public constant ACTION_SET_FLOW_AND_ALLOWANCE = 1; - - error UnknownActionCode(uint8 actionCode); - error WrongFlowRate(); - error InsufficientAllowance(); - - function buildBatchOperations(ISuperfluid host, bytes memory params, address /*msgSender*/) + function buildBatchOperations(ISuperfluid, bytes memory, address) external + pure override - view returns (ISuperfluid.Operation[] memory operations) { - (uint8 actionCode, /*bytes32 lang*/, bytes memory actionParams, /*bytes memory signatureVRS*/) = - abi.decode(params, (uint8, bytes32, bytes, bytes)); - - if (actionCode != ACTION_SET_FLOW_AND_ALLOWANCE) revert UnknownActionCode(actionCode); - - SetFlowAndAllowanceParams memory p = abi.decode(actionParams, (SetFlowAndAllowanceParams)); - - IConstantFlowAgreementV1 cfa = - IConstantFlowAgreementV1(address(host.getAgreementClass( - keccak256("org.superfluid-finance.agreements.ConstantFlowAgreement.v1") - ))); - - operations = new ISuperfluid.Operation[](2); - - // op 1: approve SuperToken to SuperUpgrader (Host supports SuperToken.operationApprove) - operations[0] = ISuperfluid.Operation({ - operationType: BatchOperation.OPERATION_TYPE_ERC20_APPROVE, - target: address(p.superToken), - data: abi.encode(p.superUpgrader, p.upgradeAllowance) - }); - - // op 2: CFA createFlow - operations[1] = ISuperfluid.Operation({ - operationType: BatchOperation.OPERATION_TYPE_SUPERFLUID_CALL_AGREEMENT, - target: address(cfa), - data: abi.encode( - abi.encodeCall(cfa.createFlow, (p.superToken, p.receiver, p.flowRate, new bytes(0))), - new bytes(0) - ) - }); + operations = new ISuperfluid.Operation[](0); } - function postCheck(ISuperfluid /*host*/, bytes memory params, address msgSender) external view override { - (, /*lang*/, bytes memory actionParams,) = abi.decode(params, (uint8, bytes32, bytes, bytes)); - SetFlowAndAllowanceParams memory p = abi.decode(actionParams, (SetFlowAndAllowanceParams)); - - int96 actualFlowRate = p.superToken.getFlowRate(msgSender, p.receiver); - if (actualFlowRate != p.flowRate) revert WrongFlowRate(); + function postCheck(ISuperfluid, bytes memory, address) external view override { + // intentionally empty + } - uint256 allowance = p.superToken.allowance(msgSender, p.superUpgrader); - if (allowance < p.upgradeAllowance) revert InsufficientAllowance(); + function getMessageTypeHash() external pure override returns (bytes32) { + return MESSAGE_TYPEHASH; } - function encodeParams( - ISuperToken superToken_, - address receiver_, - int96 flowRate_, - address superUpgrader_, - uint256 upgradeAllowance_ - ) - external - pure - returns (bytes memory) - { - SetFlowAndAllowanceParams memory p = SetFlowAndAllowanceParams({ - superToken: superToken_, - receiver: receiver_, - flowRate: flowRate_, - superUpgrader: superUpgrader_, - upgradeAllowance: upgradeAllowance_ - }); - return abi.encode(ACTION_SET_FLOW_AND_ALLOWANCE, bytes32("en"), abi.encode(p), new bytes(0)); + function getMessageStructHash(bytes memory message) external pure override returns (bytes32) { + (string memory title, bytes memory customPayload) = abi.decode(message, (string, bytes)); + require(keccak256(bytes(title)) == EXPECTED_TITLE_HASH, "wrong title"); + require(customPayload.length == 0, "customPayload not empty"); + return keccak256(abi.encode(MESSAGE_TYPEHASH, keccak256(bytes(title)))); } } // ============== Test Contract ============== contract Only712MacroForwarderTest is FoundrySuperfluidTester { - Only712MacroForwarder internal only712MacroForwarder; - SuperUpgrader internal superUpgrader; - FlowAndUpgradeMacro internal flowAndUpgradeMacro; - - int96 internal constant TEST_FLOW_RATE = 42; - uint256 internal constant TEST_UPGRADE_ALLOWANCE = 1000e18; + Only712MacroForwarder internal forwarder; + Minimal712Macro internal minimal712Macro; - /// @dev Private key for signer; vm.addr(SIGNER_PRIV_KEY) receives tokens and signs envelope + app message. - uint256 internal constant SIGNER_PRIV_KEY = 1; - - constructor() FoundrySuperfluidTester(5) {} + constructor() FoundrySuperfluidTester(5) { } function setUp() public override { super.setUp(); - - address[] memory backends = new address[](1); - backends[0] = admin; - superUpgrader = new SuperUpgrader(admin, backends); - - only712MacroForwarder = new Only712MacroForwarder(sf.host, address(0)); - - flowAndUpgradeMacro = new FlowAndUpgradeMacro(); + forwarder = new Only712MacroForwarder(sf.host, address(0)); + minimal712Macro = new Minimal712Macro(); vm.prank(address(sfDeployer)); - sf.governance.enableTrustedForwarder( - sf.host, - ISuperfluidToken(address(0)), - address(only712MacroForwarder) - ); - - address signer = vm.addr(SIGNER_PRIV_KEY); - vm.prank(alice); - superToken.transfer(signer, 1e24); + sf.governance.enableTrustedForwarder(sf.host, ISuperfluidToken(address(0)), address(forwarder)); } /** - * @dev Happy path for the final implementation: build envelope + envelope sig + app params + app sig, - * call runMacro. Expects revert until forwarder decodes/verifies envelope and macro verifies app sig. + * @dev Smoke test: build payload, get digest via getDigest(), sign with vm.createWallet + vm.sign, + * call runMacro(m, params, signer, signature), assert success. */ - function testFlowAndUpgradeMacroViaOnly712Forwarder_HappyPathExpectRevert() external { - bytes memory payload = _buildFullPayload(); - vm.startPrank(vm.addr(SIGNER_PRIV_KEY)); - vm.expectRevert(); - only712MacroForwarder.runMacro(IUserDefinedMacro(address(flowAndUpgradeMacro)), payload); - vm.stopPrank(); + function test_Minimal712Macro_runMacro_smoke() external { + VmSafe.Wallet memory signer = vm.createWallet("signer"); + bytes memory params = _buildPayload(); + bytes32 digest = forwarder.getDigest(IUserDefined712Macro(address(minimal712Macro)), params); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signer, digest); + bytes memory signatureVRS = abi.encodePacked(r, s, v); + + vm.prank(signer.addr); + bool ok = forwarder.runMacro(IUserDefined712Macro(address(minimal712Macro)), params, signer.addr, signatureVRS); + assertTrue(ok); } - function _buildFullPayload() internal view returns (bytes memory) { - bytes memory envelopeEncoded = abi.encode( - "test.xyz", - "1", - "en", - "Read before signing.", - "Set flow and allowance", - "Create stream and approve SuperUpgrader for future upgrades", - "macros.superfluid.eth", - block.timestamp, - block.timestamp + 1 hours, - uint256(1), - address(flowAndUpgradeMacro) - ); - bytes32 envelopeDigest = _hashMacroEnvelope(envelopeEncoded, address(only712MacroForwarder)); - (uint8 ev, bytes32 er, bytes32 es) = vm.sign(SIGNER_PRIV_KEY, envelopeDigest); - bytes memory envelopeSig = abi.encodePacked(er, es, ev); - - bytes32 appDigest = _hashSetFlowAndAllowance( - "Set your stream to 42 FTTx/month to bob and approve SuperUpgrader for 1000 FTTx", - address(superToken), - bob, - TEST_FLOW_RATE, - address(superUpgrader), - TEST_UPGRADE_ALLOWANCE, - address(flowAndUpgradeMacro) - ); - (uint8 av, bytes32 ar, bytes32 as_) = vm.sign(SIGNER_PRIV_KEY, appDigest); - - FlowAndUpgradeMacro.SetFlowAndAllowanceParams memory p = FlowAndUpgradeMacro.SetFlowAndAllowanceParams({ - superToken: superToken, - receiver: bob, - flowRate: TEST_FLOW_RATE, - superUpgrader: address(superUpgrader), - upgradeAllowance: TEST_UPGRADE_ALLOWANCE + function _buildPayload() internal pure returns (bytes memory) { + Only712MacroForwarder.Payload memory payload = Only712MacroForwarder.Payload({ + meta: Only712MacroForwarder.PayloadMeta({ domain: "test.xyz", version: "1" }), + message: Only712MacroForwarder.PayloadMessage({ title: "Hello 712", customPayload: new bytes(0) }), + security: Only712MacroForwarder.PayloadSecurity({ provider: "macros.superfluid.eth", nonce: 1 }) }); - bytes memory appParams = abi.encode( - flowAndUpgradeMacro.ACTION_SET_FLOW_AND_ALLOWANCE(), - bytes32("en"), - abi.encode(p), - abi.encode(av, ar, as_) - ); - return abi.encode(envelopeEncoded, envelopeSig, appParams); - } - - function _hashMacroEnvelope(bytes memory envelopeEncoded, address verifyingContract) - internal - view - returns (bytes32) - { - bytes32 structHash = _macroEnvelopeStructHash(envelopeEncoded); - return _eip712Digest(_macroEnvelopeDomainSeparator(verifyingContract), structHash); - } - - function _macroEnvelopeStructHash(bytes memory envelopeEncoded) internal pure returns (bytes32) { - ( - string memory domain, - string memory version, - string memory language, - string memory disclaimer, - string memory title, - string memory description, - string memory provider, - uint256 validAfter, - uint256 validBefore, - uint256 nonce, - address macroAddr - ) = abi.decode( - envelopeEncoded, - (string, string, string, string, string, string, string, uint256, uint256, uint256, address) - ); - bytes32 typeHash = keccak256( - "MacroEnvelope(string domain,string version,string language,string disclaimer,string title,string description,string provider,uint256 validAfter,uint256 validBefore,uint256 nonce,address macro)" - ); - return keccak256( - abi.encode( - typeHash, - keccak256(bytes(domain)), - keccak256(bytes(version)), - keccak256(bytes(language)), - keccak256(bytes(disclaimer)), - keccak256(bytes(title)), - keccak256(bytes(description)), - keccak256(bytes(provider)), - validAfter, - validBefore, - nonce, - macroAddr - ) - ); - } - - function _macroEnvelopeDomainSeparator(address verifyingContract) internal view returns (bytes32) { - return keccak256( - abi.encode( - keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), - keccak256("Superfluid Macro"), - keccak256("1"), - block.chainid, - verifyingContract - ) - ); - } - - function _hashSetFlowAndAllowance( - string memory message, - address superToken_, - address receiver_, - int96 flowRate_, - address superUpgrader_, - uint256 upgradeAllowance_, - address verifyingContract - ) - internal - view - returns (bytes32) - { - bytes32 typeHash = keccak256( - "SetFlowAndAllowance(string message,address superToken,address receiver,int96 flowRate,address superUpgrader,uint256 upgradeAllowance)" - ); - bytes32 structHash = keccak256( - abi.encode( - typeHash, - keccak256(bytes(message)), - superToken_, - receiver_, - flowRate_, - superUpgrader_, - upgradeAllowance_ - ) - ); - return _eip712Digest(_flowAndUpgradeMacroDomainSeparator(verifyingContract), structHash); - } - - function _flowAndUpgradeMacroDomainSeparator(address verifyingContract) internal view returns (bytes32) { - return keccak256( - abi.encode( - keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), - keccak256("FlowAndUpgradeMacro"), - keccak256("1"), - block.chainid, - verifyingContract - ) - ); - } - - function _eip712Digest(bytes32 domainSeparator, bytes32 structHash) internal pure returns (bytes32) { - return keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + return abi.encode(payload); } } From 6ee7437ad50eaf25bb4b5bc55b128df3aa01301d Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 28 Jan 2026 21:07:46 +0100 Subject: [PATCH 04/18] solhint: don't check for updates when not asked to --- packages/ethereum-contracts/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ethereum-contracts/package.json b/packages/ethereum-contracts/package.json index cca0215676..bd8f2ac284 100644 --- a/packages/ethereum-contracts/package.json +++ b/packages/ethereum-contracts/package.json @@ -93,7 +93,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", From 3b54b903ae5cade4115a29ccba0c184e937c5944 Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 30 Jan 2026 11:08:57 +0100 Subject: [PATCH 05/18] updated foundry to v1.3.6 (1.3 improves eip712 support) --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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": { From 3f3e74d46b7f8ee42d9438438f78455a8be640fe Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 30 Jan 2026 15:44:39 +0100 Subject: [PATCH 06/18] disable new linter rules (for now) --- packages/ethereum-contracts/foundry.toml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 From 53f953f09a5b70ba4e613e0be465d21cbbe0d094 Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 30 Jan 2026 20:35:57 +0100 Subject: [PATCH 07/18] more testing --- .../interfaces/utils/IUserDefinedMacro.sol | 17 +- .../contracts/utils/Only712MacroForwarder.sol | 78 +++++--- .../foundry/utils/Only712MacroForwarder.t.sol | 173 ++++++++++++++++-- 3 files changed, 223 insertions(+), 45 deletions(-) diff --git a/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol b/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol index e2b217f9ad..d6635bd2f8 100644 --- a/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol +++ b/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol @@ -45,10 +45,19 @@ interface IUserDefinedMacro { */ } +// 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). +// TODO: for multi-action macros, the getters probably all need to get the encoded message as an argument interface IUserDefined712Macro is IUserDefinedMacro { - // TODO: this probably needs to be a function of the message, for the dispatching pattern - // the metaphor being: a macro is like an api, an action is like an endpoint (defining the set of arguments) - function getMessageTypeHash() external view returns (bytes32); - // TODO: should it take the known and required fields already decoded instead? + // 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() external view returns (string memory); + + // The EIP-712 type definition of the action, required by Only712MacroForwarder. + function getMessageTypeDefinition() 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 getMessageStructHash(bytes memory message) external view returns (bytes32); } \ No newline at end of file diff --git a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol index b7e9328226..02d7c2bef6 100644 --- a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol +++ b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol @@ -15,6 +15,7 @@ import { ForwarderBase } from "./ForwarderBase.sol"; contract Only712MacroForwarder is ForwarderBase, EIP712 { // top-level data structure + // TODO: is "payload" a good name? Does EIP-712 give a good hint for naming this? Something "primary"? struct Payload { PayloadMeta meta; PayloadMessage message; @@ -26,7 +27,8 @@ contract Only712MacroForwarder is ForwarderBase, EIP712 { //string language; //string disclaimer; } - bytes32 internal constant _TYPEHASH_META = keccak256("Meta(string domain,string version)"); + bytes internal constant _TYPEDEF_META = "Meta(string domain,string version)"; + bytes32 internal constant _TYPEHASH_META = keccak256(_TYPEDEF_META); struct PayloadMessage { string title; //string description; @@ -39,14 +41,16 @@ contract Only712MacroForwarder is ForwarderBase, EIP712 { //uint256 validBefore; uint256 nonce; } - bytes32 internal constant _TYPEHASH_SECURITY = keccak256("Security(string provider,uint256 nonce)"); + bytes internal constant _TYPEDEF_SECURITY = "Security(string provider,uint256 nonce)"; + bytes32 internal constant _TYPEHASH_SECURITY = keccak256(_TYPEDEF_SECURITY); error InvalidPayload(string message); error InvalidProvider(string provider); error InvalidSignature(); - // TODO: should this be something like "Clear Sign" instead? - constructor(ISuperfluid host, address /*registry*/) ForwarderBase(host) EIP712("Only712MacroForwarder", "1") {} + // Here EIP712 domain name and version are set. + // TODO: should the name include "Superfluid"? + constructor(ISuperfluid host, address /*registry*/) ForwarderBase(host) EIP712("ClearSigning", "1") {} /** * @dev Run the macro with encoded payload (generic + macro specific fragments). @@ -83,11 +87,41 @@ contract Only712MacroForwarder is ForwarderBase, EIP712 { return retVal; } + // TODO: should this exist? + function getTypeDefinition(IUserDefined712Macro m) external view returns (string memory) { + return _getTypeDefinition(m); + } + + // TODO: should this exist? + function getTypeHash(IUserDefined712Macro m) public view returns (bytes32) { + return keccak256(abi.encodePacked(_getTypeDefinition(m))); + } + + // TODO: should this exist? + function getStructHash(IUserDefined712Macro m, bytes calldata params) external view returns (bytes32) { + return _getStructHash(m, abi.decode(params, (Payload))); + } + function getDigest(IUserDefined712Macro m, bytes calldata params) external view returns (bytes32) { return _getDigest(m, abi.decode(params, (Payload))); } - function _getDigest(IUserDefined712Macro m, Payload memory payload) internal view returns (bytes32) { + // ============================== + // Internal functions + // ============================== + + function _getTypeDefinition(IUserDefined712Macro m) internal view returns (string memory) { + return string(abi.encodePacked( + m.getPrimaryTypeName(), + "(Meta meta,Message message,Security security)", + // nested components need to be in alphabetical order + m.getMessageTypeDefinition(), + _TYPEDEF_META, + _TYPEDEF_SECURITY + )); + } + + function _getStructHash(IUserDefined712Macro m, Payload memory payload) internal view returns (bytes32) { bytes32 metaStructHash = _getMetaStructHash(payload.meta); // the message fragment is handled by the user macro. @@ -98,29 +132,23 @@ contract Only712MacroForwarder is ForwarderBase, EIP712 { bytes32 securityStructHash = _getSecurityStructHash(payload.security); // get the typehash - bytes32 primaryTypeHash = keccak256( - abi.encodePacked( - // TODO: shall we name it "ClearSign"? - "Payload(Meta meta,Message message,Security security)", - // nested components need to be in alphabetical order - m.getMessageTypeHash(), - _TYPEHASH_META, - _TYPEHASH_SECURITY + bytes32 primaryTypeHash = getTypeHash(m); + + // calculate the struct hash + bytes32 structHash = keccak256( + abi.encode( + primaryTypeHash, + metaStructHash, + messageStructHash, + securityStructHash ) ); + return structHash; + } - // calculate the digest of the entire payload - bytes32 digest = _hashTypedDataV4( - keccak256( - abi.encode( - primaryTypeHash, - metaStructHash, - messageStructHash, - securityStructHash - ) - ) - ); - return digest; + function _getDigest(IUserDefined712Macro m, Payload memory payload) internal view returns (bytes32) { + bytes32 structHash = _getStructHash(m, payload); + return _hashTypedDataV4(structHash); } function _getMetaStructHash(PayloadMeta memory meta) internal pure returns (bytes32) { diff --git a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol index d273a0d320..9df55685ba 100644 --- a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol +++ b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol @@ -2,17 +2,34 @@ pragma solidity ^0.8.23; import { VmSafe } from "forge-std/Vm.sol"; +import { console } from "forge-std/console.sol"; import { ISuperfluid, ISuperfluidToken } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; import { IUserDefined712Macro } from "../../../contracts/interfaces/utils/IUserDefinedMacro.sol"; import { Only712MacroForwarder } from "../../../contracts/utils/Only712MacroForwarder.sol"; import { FoundrySuperfluidTester } from "../FoundrySuperfluidTester.t.sol"; -// ============== Minimal mock macro for Only712MacroForwarder ============== +string constant MESSAGE_TITLE = "Hello 712"; +string constant PRIMARY_TYPE_NAME = "MinimalExample"; +string constant META_DOMAIN = "minimalmacro.xyz"; +string constant META_VERSION = "1"; +string constant SECURITY_PROVIDER = "macros.superfluid.eth"; + +// returns the encoded payload for the example macro +function getTestPayload() pure returns (bytes memory) { + Only712MacroForwarder.Payload memory payload = Only712MacroForwarder.Payload({ + meta: Only712MacroForwarder.PayloadMeta({ domain: META_DOMAIN, version: META_VERSION }), + message: Only712MacroForwarder.PayloadMessage({ title: MESSAGE_TITLE, customPayload: new bytes(0) }), + security: Only712MacroForwarder.PayloadSecurity({ provider: SECURITY_PROVIDER, nonce: 1 }) + }); + return abi.encode(payload); +} + +// ============== Minimal macro for Only712MacroForwarder ============== // Implements IUserDefined712Macro and uses *no* postCheck logic. // Message has only the required `title`; `customPayload` is expected to be empty. contract Minimal712Macro is IUserDefined712Macro { - bytes32 internal constant MESSAGE_TYPEHASH = keccak256("Message(string title)"); - bytes32 internal constant EXPECTED_TITLE_HASH = keccak256(bytes("Hello 712")); + + string public constant MESSAGE_TYPE_DEFINITION = "Message(string title)"; function buildBatchOperations(ISuperfluid, bytes memory, address) external @@ -27,15 +44,20 @@ contract Minimal712Macro is IUserDefined712Macro { // intentionally empty } - function getMessageTypeHash() external pure override returns (bytes32) { - return MESSAGE_TYPEHASH; + function getMessageTypeDefinition() external pure override returns (string memory) { + return MESSAGE_TYPE_DEFINITION; + } + + function getPrimaryTypeName() external pure override returns (string memory) { + return PRIMARY_TYPE_NAME; } function getMessageStructHash(bytes memory message) external pure override returns (bytes32) { (string memory title, bytes memory customPayload) = abi.decode(message, (string, bytes)); - require(keccak256(bytes(title)) == EXPECTED_TITLE_HASH, "wrong title"); + require(keccak256(bytes(title)) == keccak256(bytes(MESSAGE_TITLE)), "wrong title"); require(customPayload.length == 0, "customPayload not empty"); - return keccak256(abi.encode(MESSAGE_TYPEHASH, keccak256(bytes(title)))); + bytes32 messageTypeHash = keccak256(abi.encodePacked(MESSAGE_TYPE_DEFINITION)); + return keccak256(abi.encode(messageTypeHash, keccak256(bytes(title)))); } } @@ -60,9 +82,9 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { * @dev Smoke test: build payload, get digest via getDigest(), sign with vm.createWallet + vm.sign, * call runMacro(m, params, signer, signature), assert success. */ - function test_Minimal712Macro_runMacro_smoke() external { + function testRunMacro() external { VmSafe.Wallet memory signer = vm.createWallet("signer"); - bytes memory params = _buildPayload(); + bytes memory params = getTestPayload(); bytes32 digest = forwarder.getDigest(IUserDefined712Macro(address(minimal712Macro)), params); (uint8 v, bytes32 r, bytes32 s) = vm.sign(signer, digest); bytes memory signatureVRS = abi.encodePacked(r, s, v); @@ -72,12 +94,131 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { assertTrue(ok); } - function _buildPayload() internal pure returns (bytes memory) { - Only712MacroForwarder.Payload memory payload = Only712MacroForwarder.Payload({ - meta: Only712MacroForwarder.PayloadMeta({ domain: "test.xyz", version: "1" }), - message: Only712MacroForwarder.PayloadMessage({ title: "Hello 712", customPayload: new bytes(0) }), - security: Only712MacroForwarder.PayloadSecurity({ provider: "macros.superfluid.eth", nonce: 1 }) - }); - return abi.encode(payload); + function testDigestCalculation() external view { + // check the type definition + string memory typeDefinition = forwarder.getTypeDefinition(minimal712Macro); + string memory expectedTypeDefinition = "MinimalExample(Meta meta,Message message,Security security)Message(string title)Meta(string domain,string version)Security(string provider,uint256 nonce)"; + assertEq(typeDefinition, expectedTypeDefinition, "typeDefinition mismatch"); + + // check the type hash + bytes32 typeHash = forwarder.getTypeHash(minimal712Macro); + 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 expectedStructHash = vm.eip712HashStruct(typeDefinition, payload); + 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"); + } + + // 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": {', + '"meta": {', _getMetaJson(), '},', + '"message": {', _getMessageJson(), '},', + '"security": {', _getSecurityJson(), '}', + '}', + '}' + )); + } + + function _getTypesJson() internal pure returns (string memory) { + return string(abi.encodePacked( + _getEIP712DomainTypeJson(), + _getMinimalExampleTypeJson(), + _getMessageTypeJson(), + _getMetaTypeJson(), + _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": "meta", "type": "Meta"},', + '{"name": "message", "type": "Message"},', + '{"name": "security", "type": "Security"}', + '],' + )); + } + + function _getMessageTypeJson() internal pure returns (string memory) { + return string(abi.encodePacked( + '"Message": [', + '{"name": "title", "type": "string"}', + '],' + )); + } + + function _getMetaTypeJson() internal pure returns (string memory) { + return string(abi.encodePacked( + '"Meta": [', + '{"name": "domain", "type": "string"},', + '{"name": "version", "type": "string"}', + '],' + )); + } + + function _getSecurityTypeJson() internal pure returns (string memory) { + return string(abi.encodePacked( + '"Security": [', + '{"name": "provider", "type": "string"},', + '{"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 _getMetaJson() internal pure returns (string memory) { + return string(abi.encodePacked( + '"domain": "', META_DOMAIN, '",', + '"version": "', META_VERSION, '"' + )); + } + + function _getMessageJson() internal pure returns (string memory) { + return string(abi.encodePacked( + '"title": "', MESSAGE_TITLE, '"' + )); + } + + function _getSecurityJson() internal pure returns (string memory) { + return string(abi.encodePacked( + '"provider": "', SECURITY_PROVIDER, '",', + '"nonce": ', '1' + )); } } From 5ee90cb5ffd4de195f512ffb31e6aff9a0fb6234 Mon Sep 17 00:00:00 2001 From: didi Date: Tue, 3 Feb 2026 18:55:38 +0100 Subject: [PATCH 08/18] added EIP-4337-like nonce --- .../contracts/utils/Only712MacroForwarder.sol | 57 +++++++++++-- .../foundry/utils/Only712MacroForwarder.t.sol | 84 +++++++++++++++---- 2 files changed, 120 insertions(+), 21 deletions(-) diff --git a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol index 02d7c2bef6..4bdaad611e 100644 --- a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol +++ b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol @@ -7,12 +7,51 @@ 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: + * -[] use SimpleACL as registry + * -[X] add nonce verification + * -[] add missing fields + * -[] extract interface definition + * -[] review naming */ -contract Only712MacroForwarder is ForwarderBase, EIP712 { +contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { + + // STRUCTS AND CONSTANTS // top-level data structure // TODO: is "payload" a good name? Does EIP-712 give a good hint for naming this? Something "primary"? @@ -44,18 +83,27 @@ contract Only712MacroForwarder is ForwarderBase, EIP712 { bytes internal constant _TYPEDEF_SECURITY = "Security(string provider,uint256 nonce)"; bytes32 internal constant _TYPEHASH_SECURITY = keccak256(_TYPEDEF_SECURITY); + // ERRORS + error InvalidPayload(string message); error InvalidProvider(string provider); error InvalidSignature(); + // INITIALIZATION + // Here EIP712 domain name and version are set. // TODO: should the name include "Superfluid"? constructor(ISuperfluid host, address /*registry*/) ForwarderBase(host) EIP712("ClearSigning", "1") {} + // 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 @@ -67,7 +115,8 @@ contract Only712MacroForwarder is ForwarderBase, EIP712 { keccak256(bytes(payload.security.provider)) == keccak256(bytes("macros.superfluid.eth")), InvalidProvider(payload.security.provider) ); - // TODO: verify nonce (replay protection) + + _validateAndUpdateNonce(signer, payload.security.nonce); bytes32 digest = _getDigest(m, payload); @@ -106,9 +155,7 @@ contract Only712MacroForwarder is ForwarderBase, EIP712 { return _getDigest(m, abi.decode(params, (Payload))); } - // ============================== - // Internal functions - // ============================== + // INTERNAL FUNCTIONS function _getTypeDefinition(IUserDefined712Macro m) internal view returns (string memory) { return string(abi.encodePacked( diff --git a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol index 9df55685ba..3d6c15ff79 100644 --- a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol +++ b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol @@ -5,7 +5,7 @@ import { VmSafe } from "forge-std/Vm.sol"; import { console } from "forge-std/console.sol"; import { ISuperfluid, ISuperfluidToken } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; import { IUserDefined712Macro } from "../../../contracts/interfaces/utils/IUserDefinedMacro.sol"; -import { Only712MacroForwarder } from "../../../contracts/utils/Only712MacroForwarder.sol"; +import { Only712MacroForwarder, NonceManager } from "../../../contracts/utils/Only712MacroForwarder.sol"; import { FoundrySuperfluidTester } from "../FoundrySuperfluidTester.t.sol"; string constant MESSAGE_TITLE = "Hello 712"; @@ -14,12 +14,17 @@ string constant META_DOMAIN = "minimalmacro.xyz"; string constant META_VERSION = "1"; string constant SECURITY_PROVIDER = "macros.superfluid.eth"; -// returns the encoded payload for the example macro +// returns the encoded payload for the example macro (nonce = key 1, sequence 0) function getTestPayload() pure returns (bytes memory) { + return getPayloadWithNonce(uint256(1) << 64); +} + +// returns the encoded payload with the given nonce (for nonce tests) +function getPayloadWithNonce(uint256 nonce) pure returns (bytes memory) { Only712MacroForwarder.Payload memory payload = Only712MacroForwarder.Payload({ meta: Only712MacroForwarder.PayloadMeta({ domain: META_DOMAIN, version: META_VERSION }), message: Only712MacroForwarder.PayloadMessage({ title: MESSAGE_TITLE, customPayload: new bytes(0) }), - security: Only712MacroForwarder.PayloadSecurity({ provider: SECURITY_PROVIDER, nonce: 1 }) + security: Only712MacroForwarder.PayloadSecurity({ provider: SECURITY_PROVIDER, nonce: nonce }) }); return abi.encode(payload); } @@ -78,20 +83,10 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { sf.governance.enableTrustedForwarder(sf.host, ISuperfluidToken(address(0)), address(forwarder)); } - /** - * @dev Smoke test: build payload, get digest via getDigest(), sign with vm.createWallet + vm.sign, - * call runMacro(m, params, signer, signature), assert success. - */ function testRunMacro() external { VmSafe.Wallet memory signer = vm.createWallet("signer"); - bytes memory params = getTestPayload(); - bytes32 digest = forwarder.getDigest(IUserDefined712Macro(address(minimal712Macro)), params); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(signer, digest); - bytes memory signatureVRS = abi.encodePacked(r, s, v); - - vm.prank(signer.addr); - bool ok = forwarder.runMacro(IUserDefined712Macro(address(minimal712Macro)), params, signer.addr, signatureVRS); - assertTrue(ok); + (bytes memory params, bytes memory signatureVRS) = _signPayload(signer, uint256(1) << 64); + assertTrue(_runMacroAs(signer.addr, params, signatureVRS)); } function testDigestCalculation() external view { @@ -119,6 +114,47 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { assertEq(digest, expectedDigest, "digest mismatch"); } + function testGetNonce(uint192 key) external { + VmSafe.Wallet memory signer = vm.createWallet("signer"); + + for (uint256 i = 0; i < 10; i++) { + uint256 nonce = forwarder.getNonce(signer.addr, key); + (bytes memory params, bytes memory signatureVRS) = _signPayload(signer, nonce); + assertTrue(_runMacroAs(signer.addr, params, signatureVRS), "runMacro with getNonce() nonce should succeed"); + } + } + + function testCannotReuseNonce(uint192 key) external { + VmSafe.Wallet memory signer = vm.createWallet("signer"); + + uint256 nonce = forwarder.getNonce(signer.addr, key); + (bytes memory params, bytes memory signatureVRS) = _signPayload(signer, nonce); + assertTrue(_runMacroAs(signer.addr, params, signatureVRS)); + + vm.expectRevert(abi.encodeWithSelector(NonceManager.InvalidNonce.selector, signer.addr, nonce)); + _runMacroAs(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"); + + // Using seq=1 before seq=0 must revert + uint256 nonceSeq1 = (uint256(key) << 64) | 1; + (bytes memory paramsSeq1, bytes memory sig1) = _signPayload(signer, nonceSeq1); + + vm.expectRevert(abi.encodeWithSelector(NonceManager.InvalidNonce.selector, signer.addr, nonceSeq1)); + _runMacroAs(signer.addr, paramsSeq1, sig1); + + // seq=0 must succeed + uint256 nonceSeq0 = uint256(key) << 64; + (bytes memory paramsSeq0, bytes memory sig0) = _signPayload(signer, nonceSeq0); + assertTrue(_runMacroAs(signer.addr, paramsSeq0, sig0)); + + // now seq=1 must succeed + assertTrue(_runMacroAs(signer.addr, paramsSeq1, sig1)); + } + // 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) { @@ -216,9 +252,25 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { } 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( '"provider": "', SECURITY_PROVIDER, '",', - '"nonce": ', '1' + '"nonce": "', vm.toString(uint256(1) << 64), '"' )); } + + function _runMacroAs(address from, bytes memory params, bytes memory signatureVRS) internal returns (bool) { + vm.prank(from); + return forwarder.runMacro(minimal712Macro, params, from, signatureVRS); + } + + function _signPayload(VmSafe.Wallet memory signer, uint256 nonce) + internal + returns (bytes memory params, bytes memory signatureVRS) + { + params = getPayloadWithNonce(nonce); + bytes32 digest = forwarder.getDigest(minimal712Macro, params); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signer, digest); + signatureVRS = abi.encodePacked(r, s, v); + } } From 06d230e29f923e83bf51abecc6028f4d4216ad02 Mon Sep 17 00:00:00 2001 From: didi Date: Tue, 3 Feb 2026 21:02:27 +0100 Subject: [PATCH 09/18] added provider auth --- .../contracts/utils/Only712MacroForwarder.sol | 21 +++++---- .../foundry/utils/Only712MacroForwarder.t.sol | 45 +++++++++++++------ 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol index 4bdaad611e..7a667ddd4c 100644 --- a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol +++ b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol @@ -3,6 +3,7 @@ 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"; @@ -43,7 +44,7 @@ abstract contract NonceManager { * Envelope verification, nonce, and registry checks to be added in follow-up. * * TODO: - * -[] use SimpleACL as registry + * -[X] use SimpleACL for provider authorization * -[X] add nonce verification * -[] add missing fields * -[] extract interface definition @@ -51,7 +52,7 @@ abstract contract NonceManager { */ contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { - // STRUCTS AND CONSTANTS + // STRUCTS, CONSTANTS, IMMUTABLES // top-level data structure // TODO: is "payload" a good name? Does EIP-712 give a good hint for naming this? Something "primary"? @@ -83,17 +84,21 @@ contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { bytes internal constant _TYPEDEF_SECURITY = "Security(string provider,uint256 nonce)"; bytes32 internal constant _TYPEHASH_SECURITY = keccak256(_TYPEDEF_SECURITY); + IAccessControl internal immutable _providerACL; + // ERRORS error InvalidPayload(string message); - error InvalidProvider(string provider); + 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, address /*registry*/) ForwarderBase(host) EIP712("ClearSigning", "1") {} + constructor(ISuperfluid host, address /*registry*/) ForwarderBase(host) EIP712("ClearSigning", "1") { + _providerACL = IAccessControl(host.getSimpleACL()); + } // PUBLIC FUNCTIONS @@ -111,10 +116,10 @@ contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { { // decode the payload Payload memory payload = abi.decode(params, (Payload)); - require( - keccak256(bytes(payload.security.provider)) == keccak256(bytes("macros.superfluid.eth")), - InvalidProvider(payload.security.provider) - ); + 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); diff --git a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol index 3d6c15ff79..49984f32b2 100644 --- a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol +++ b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol @@ -3,6 +3,7 @@ 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 { ISuperfluid, ISuperfluidToken } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; import { IUserDefined712Macro } from "../../../contracts/interfaces/utils/IUserDefinedMacro.sol"; import { Only712MacroForwarder, NonceManager } from "../../../contracts/utils/Only712MacroForwarder.sol"; @@ -13,10 +14,11 @@ string constant PRIMARY_TYPE_NAME = "MinimalExample"; string constant META_DOMAIN = "minimalmacro.xyz"; string constant META_VERSION = "1"; string constant SECURITY_PROVIDER = "macros.superfluid.eth"; +uint256 constant DEFAULT_NONCE = uint256(1) << 64; // returns the encoded payload for the example macro (nonce = key 1, sequence 0) function getTestPayload() pure returns (bytes memory) { - return getPayloadWithNonce(uint256(1) << 64); + return getPayloadWithNonce(DEFAULT_NONCE); } // returns the encoded payload with the given nonce (for nonce tests) @@ -79,14 +81,26 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { forwarder = new Only712MacroForwarder(sf.host, address(0)); 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 testRunMacro() external { VmSafe.Wallet memory signer = vm.createWallet("signer"); - (bytes memory params, bytes memory signatureVRS) = _signPayload(signer, uint256(1) << 64); - assertTrue(_runMacroAs(signer.addr, params, signatureVRS)); + (bytes memory params, bytes memory signatureVRS) = _signPayload(signer, DEFAULT_NONCE); + assertTrue(_runMacroAs(address(this), signer.addr, params, signatureVRS)); + } + + function testRevertsWhenCallerMissingProviderRole() external { + VmSafe.Wallet memory signer = vm.createWallet("signer"); + (bytes memory params, bytes memory signatureVRS) = _signPayload(signer, DEFAULT_NONCE); + vm.expectRevert(abi.encodeWithSelector( + Only712MacroForwarder.ProviderNotAuthorized.selector, SECURITY_PROVIDER, address(0xbad))); + _runMacroAs(address(0xbad), signer.addr, params, signatureVRS); } function testDigestCalculation() external view { @@ -120,7 +134,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { for (uint256 i = 0; i < 10; i++) { uint256 nonce = forwarder.getNonce(signer.addr, key); (bytes memory params, bytes memory signatureVRS) = _signPayload(signer, nonce); - assertTrue(_runMacroAs(signer.addr, params, signatureVRS), "runMacro with getNonce() nonce should succeed"); + assertTrue(_runMacroAs(address(this), signer.addr, params, signatureVRS), "runMacro with getNonce() nonce should succeed"); } } @@ -129,10 +143,10 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { uint256 nonce = forwarder.getNonce(signer.addr, key); (bytes memory params, bytes memory signatureVRS) = _signPayload(signer, nonce); - assertTrue(_runMacroAs(signer.addr, params, signatureVRS)); + assertTrue(_runMacroAs(address(this), signer.addr, params, signatureVRS)); vm.expectRevert(abi.encodeWithSelector(NonceManager.InvalidNonce.selector, signer.addr, nonce)); - _runMacroAs(signer.addr, params, signatureVRS); + _runMacroAs(address(this), signer.addr, params, signatureVRS); } /// For a given key, nonces must be used in sequence (0, 1, 2, ...). Skipping must revert. @@ -144,15 +158,15 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { (bytes memory paramsSeq1, bytes memory sig1) = _signPayload(signer, nonceSeq1); vm.expectRevert(abi.encodeWithSelector(NonceManager.InvalidNonce.selector, signer.addr, nonceSeq1)); - _runMacroAs(signer.addr, paramsSeq1, sig1); + _runMacroAs(address(this), signer.addr, paramsSeq1, sig1); // seq=0 must succeed uint256 nonceSeq0 = uint256(key) << 64; (bytes memory paramsSeq0, bytes memory sig0) = _signPayload(signer, nonceSeq0); - assertTrue(_runMacroAs(signer.addr, paramsSeq0, sig0)); + assertTrue(_runMacroAs(address(this), signer.addr, paramsSeq0, sig0)); // now seq=1 must succeed - assertTrue(_runMacroAs(signer.addr, paramsSeq1, sig1)); + assertTrue(_runMacroAs(address(this), signer.addr, paramsSeq1, sig1)); } // example: https://github.com/vaquita-fi/vaquita-lisk/blob/c4964af9157c9cca9cfb167ac1a4450e36edb29e/contracts/test/VaquitaPool.t.sol#L142 @@ -255,18 +269,21 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { // Use string for nonce so Foundry's JSON parser accepts 2^64 as uint256 (avoids type mismatch) return string(abi.encodePacked( '"provider": "', SECURITY_PROVIDER, '",', - '"nonce": "', vm.toString(uint256(1) << 64), '"' + '"nonce": "', vm.toString(DEFAULT_NONCE), '"' )); } - function _runMacroAs(address from, bytes memory params, bytes memory signatureVRS) internal returns (bool) { - vm.prank(from); - return forwarder.runMacro(minimal712Macro, params, from, signatureVRS); + 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, uint256 nonce) internal - returns (bytes memory params, bytes memory signatureVRS) + returns (bytes memory params, bytes memory signatureVRS) { { params = getPayloadWithNonce(nonce); bytes32 digest = forwarder.getDigest(minimal712Macro, params); From 35c55b16aace9d5bf46ce08bed5d4bc081cb9fed Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 4 Feb 2026 07:29:10 +0100 Subject: [PATCH 10/18] fix mishap --- .../test/foundry/utils/Only712MacroForwarder.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol index 49984f32b2..0ecf018839 100644 --- a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol +++ b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol @@ -283,7 +283,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { function _signPayload(VmSafe.Wallet memory signer, uint256 nonce) internal - returns (bytes memory params, bytes memory signatureVRS) { + returns (bytes memory params, bytes memory signatureVRS) { params = getPayloadWithNonce(nonce); bytes32 digest = forwarder.getDigest(minimal712Macro, params); From 4359446c684e51e9b48f9c53222d5b3e949ba238 Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 4 Feb 2026 13:01:59 +0100 Subject: [PATCH 11/18] added validity time window validation --- packages/ethereum-contracts/CHANGELOG.md | 1 + .../contracts/utils/Only712MacroForwarder.sol | 19 +++++- .../foundry/utils/Only712MacroForwarder.t.sol | 66 ++++++++++++++++++- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/packages/ethereum-contracts/CHANGELOG.md b/packages/ethereum-contracts/CHANGELOG.md index f19cc5760f..648b62c7a5 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/utils/Only712MacroForwarder.sol b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol index 7a667ddd4c..a7b8c00d75 100644 --- a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol +++ b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol @@ -46,6 +46,7 @@ abstract contract NonceManager { * 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 @@ -77,11 +78,13 @@ contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { // the message typehash is user macro specific struct PayloadSecurity { string provider; - //uint256 validAfter; - //uint256 validBefore; + uint256 validAfter; + uint256 validBefore; uint256 nonce; } - bytes internal constant _TYPEDEF_SECURITY = "Security(string provider,uint256 nonce)"; + bytes internal constant _TYPEDEF_SECURITY = + "Security(string provider,uint256 validAfter,uint256 validBefore,uint256 nonce)"; + bytes32 internal constant _TYPEHASH_SECURITY = keccak256(_TYPEDEF_SECURITY); IAccessControl internal immutable _providerACL; @@ -89,6 +92,7 @@ contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { // ERRORS error InvalidPayload(string message); + error OutsideValidityWindow(uint256 blockTimestamp, uint256 validBefore, uint256 validAfter); error ProviderNotAuthorized(string provider, address msgSender); error InvalidSignature(); @@ -123,6 +127,13 @@ contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { _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, payload); // verify the signature - this also works for ERC1271 (contract signatures) @@ -215,6 +226,8 @@ contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { return keccak256(abi.encode( _TYPEHASH_SECURITY, keccak256(bytes(security.provider)), + security.validAfter, + security.validBefore, security.nonce )); } diff --git a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol index 0ecf018839..7e5aab7988 100644 --- a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol +++ b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol @@ -23,10 +23,20 @@ function getTestPayload() pure returns (bytes memory) { // returns the encoded payload with the given nonce (for nonce tests) function getPayloadWithNonce(uint256 nonce) pure returns (bytes memory) { + return getPayloadWithNonceAndTimeframe(nonce, 0, 0); +} + +// returns the encoded payload with the given nonce and timeframe +function getPayloadWithNonceAndTimeframe(uint256 nonce, uint256 validAfter, uint256 validBefore) pure returns (bytes memory) { Only712MacroForwarder.Payload memory payload = Only712MacroForwarder.Payload({ meta: Only712MacroForwarder.PayloadMeta({ domain: META_DOMAIN, version: META_VERSION }), message: Only712MacroForwarder.PayloadMessage({ title: MESSAGE_TITLE, customPayload: new bytes(0) }), - security: Only712MacroForwarder.PayloadSecurity({ provider: SECURITY_PROVIDER, nonce: nonce }) + security: Only712MacroForwarder.PayloadSecurity({ + provider: SECURITY_PROVIDER, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }) }); return abi.encode(payload); } @@ -106,7 +116,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { function testDigestCalculation() external view { // check the type definition string memory typeDefinition = forwarder.getTypeDefinition(minimal712Macro); - string memory expectedTypeDefinition = "MinimalExample(Meta meta,Message message,Security security)Message(string title)Meta(string domain,string version)Security(string provider,uint256 nonce)"; + string memory expectedTypeDefinition = "MinimalExample(Meta meta,Message message,Security security)Message(string title)Meta(string domain,string version)Security(string provider,uint256 validAfter,uint256 validBefore,uint256 nonce)"; assertEq(typeDefinition, expectedTypeDefinition, "typeDefinition mismatch"); // check the type hash @@ -149,6 +159,45 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { _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"); + uint256 nonce = forwarder.getNonce(signer.addr, 0); + (bytes memory params, bytes memory signatureVRS) = _signPayloadWithTimeframe(signer, nonce, t0, t1); + + // 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, signatureVRS) = _signPayloadWithTimeframe(signer, nonce, 0, expiry); + 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"); @@ -238,6 +287,8 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { return string(abi.encodePacked( '"Security": [', '{"name": "provider", "type": "string"},', + '{"name": "validAfter", "type": "uint256"},', + '{"name": "validBefore", "type": "uint256"},', '{"name": "nonce", "type": "uint256"}', ']' )); @@ -269,6 +320,8 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { // Use string for nonce so Foundry's JSON parser accepts 2^64 as uint256 (avoids type mismatch) return string(abi.encodePacked( '"provider": "', SECURITY_PROVIDER, '",', + '"validAfter": "0",', + '"validBefore": "0",', '"nonce": "', vm.toString(DEFAULT_NONCE), '"' )); } @@ -285,7 +338,14 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { internal returns (bytes memory params, bytes memory signatureVRS) { - params = getPayloadWithNonce(nonce); + return _signPayloadWithTimeframe(signer, nonce, 0, 0); + } + + function _signPayloadWithTimeframe(VmSafe.Wallet memory signer, uint256 nonce, uint256 validAfter, uint256 validBefore) + internal + returns (bytes memory params, bytes memory signatureVRS) + { + params = getPayloadWithNonceAndTimeframe(nonce, validAfter, validBefore); bytes32 digest = forwarder.getDigest(minimal712Macro, params); (uint8 v, bytes32 r, bytes32 s) = vm.sign(signer, digest); signatureVRS = abi.encodePacked(r, s, v); From d2ff27f7db8fb4e7ff4f3395f4aeb38b592d35d9 Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 9 Feb 2026 15:52:14 +0100 Subject: [PATCH 12/18] updated to revised simplified schema --- .../interfaces/utils/IUserDefinedMacro.sol | 8 +- .../contracts/utils/Only712MacroForwarder.sol | 94 ++++++---------- .../foundry/utils/Only712MacroForwarder.t.sol | 102 +++++++++--------- 3 files changed, 91 insertions(+), 113 deletions(-) diff --git a/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol b/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol index d6635bd2f8..ceec536ae5 100644 --- a/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol +++ b/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol @@ -48,16 +48,16 @@ interface IUserDefinedMacro { // 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). -// TODO: for multi-action macros, the getters probably all need to get the encoded message as an argument 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() external view returns (string memory); + function getPrimaryTypeName(bytes memory params) external view returns (string memory); // The EIP-712 type definition of the action, required by Only712MacroForwarder. - function getMessageTypeDefinition() external view returns (string memory); + // 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 getMessageStructHash(bytes memory message) external view returns (bytes32); + function getActionStructHash(bytes memory params) external view returns (bytes32); } \ No newline at end of file diff --git a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol index a7b8c00d75..fcbbd6f358 100644 --- a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol +++ b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol @@ -56,34 +56,23 @@ contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { // STRUCTS, CONSTANTS, IMMUTABLES // top-level data structure - // TODO: is "payload" a good name? Does EIP-712 give a good hint for naming this? Something "primary"? - struct Payload { - PayloadMeta meta; - PayloadMessage message; - PayloadSecurity security; + struct PrimaryType { + ActionType action; + SecurityType security; } - struct PayloadMeta { - string domain; - string version; - //string language; - //string disclaimer; - } - bytes internal constant _TYPEDEF_META = "Meta(string domain,string version)"; - bytes32 internal constant _TYPEHASH_META = keccak256(_TYPEDEF_META); - struct PayloadMessage { - string title; - //string description; - bytes customPayload; + struct ActionType { + bytes actionParams; } - // the message typehash is user macro specific - struct PayloadSecurity { + // 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 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); @@ -119,7 +108,7 @@ contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { returns (bool) { // decode the payload - Payload memory payload = abi.decode(params, (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); @@ -134,97 +123,84 @@ contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { revert OutsideValidityWindow(block.timestamp, payload.security.validBefore, payload.security.validAfter); } - bytes32 digest = _getDigest(m, payload); + 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 payload message + // get the operations array from the user macro based on the action params ISuperfluid.Operation[] memory operations = - m.buildBatchOperations(_host, payload.message.customPayload, signer); + m.buildBatchOperations(_host, payload.action.actionParams, signer); // forward the operations bool retVal = _forwardBatchCallWithValue(operations, msg.value); - // TODO: is customPayload the correct argument here? - m.postCheck(_host, payload.message.customPayload, signer); + m.postCheck(_host, payload.action.actionParams, signer); return retVal; } // TODO: should this exist? - function getTypeDefinition(IUserDefined712Macro m) external view returns (string memory) { - return _getTypeDefinition(m); + function getTypeDefinition(IUserDefined712Macro m, bytes calldata params) external view returns (string memory) { + return _getTypeDefinition(m, params); } // TODO: should this exist? - function getTypeHash(IUserDefined712Macro m) public view returns (bytes32) { - return keccak256(abi.encodePacked(_getTypeDefinition(m))); + 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, abi.decode(params, (Payload))); + return _getStructHash(m, params); } function getDigest(IUserDefined712Macro m, bytes calldata params) external view returns (bytes32) { - return _getDigest(m, abi.decode(params, (Payload))); + return _getDigest(m, params); } // INTERNAL FUNCTIONS - function _getTypeDefinition(IUserDefined712Macro m) internal view returns (string memory) { + function _getTypeDefinition(IUserDefined712Macro m, bytes calldata params) internal view returns (string memory) { return string(abi.encodePacked( - m.getPrimaryTypeName(), - "(Meta meta,Message message,Security security)", + m.getPrimaryTypeName(params), + "(Action action,Security security)", // nested components need to be in alphabetical order - m.getMessageTypeDefinition(), - _TYPEDEF_META, + m.getActionTypeDefinition(params), _TYPEDEF_SECURITY )); } - function _getStructHash(IUserDefined712Macro m, Payload memory payload) internal view returns (bytes32) { - bytes32 metaStructHash = _getMetaStructHash(payload.meta); - - // the message fragment is handled by the user macro. - bytes32 messageStructHash = m.getMessageStructHash( - abi.encode(payload.message.title, payload.message.customPayload) - ); + 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); + bytes32 primaryTypeHash = getTypeHash(m, params); // calculate the struct hash bytes32 structHash = keccak256( abi.encode( primaryTypeHash, - metaStructHash, - messageStructHash, + actionStructHash, securityStructHash ) ); return structHash; } - function _getDigest(IUserDefined712Macro m, Payload memory payload) internal view returns (bytes32) { - bytes32 structHash = _getStructHash(m, payload); + function _getDigest(IUserDefined712Macro m, bytes calldata params) internal view returns (bytes32) { + bytes32 structHash = _getStructHash(m, params); return _hashTypedDataV4(structHash); } - function _getMetaStructHash(PayloadMeta memory meta) internal pure returns (bytes32) { - return keccak256(abi.encode( - _TYPEHASH_META, - keccak256(bytes(meta.domain)), - keccak256(bytes(meta.version)) - )); - } - - function _getSecurityStructHash(PayloadSecurity memory security) internal pure returns (bytes32) { + 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, diff --git a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol index 7e5aab7988..5aa3bca0ae 100644 --- a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol +++ b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol @@ -9,11 +9,12 @@ import { IUserDefined712Macro } from "../../../contracts/interfaces/utils/IUserD import { Only712MacroForwarder, NonceManager } from "../../../contracts/utils/Only712MacroForwarder.sol"; import { FoundrySuperfluidTester } from "../FoundrySuperfluidTester.t.sol"; -string constant MESSAGE_TITLE = "Hello 712"; string constant PRIMARY_TYPE_NAME = "MinimalExample"; -string constant META_DOMAIN = "minimalmacro.xyz"; -string constant META_VERSION = "1"; +string constant ACTION_TYPEDEF = "Action(string description)"; +string constant ACTION_DESCRIPTION = "Hello 712"; +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; // returns the encoded payload for the example macro (nonce = key 1, sequence 0) @@ -28,10 +29,10 @@ function getPayloadWithNonce(uint256 nonce) pure returns (bytes memory) { // returns the encoded payload with the given nonce and timeframe function getPayloadWithNonceAndTimeframe(uint256 nonce, uint256 validAfter, uint256 validBefore) pure returns (bytes memory) { - Only712MacroForwarder.Payload memory payload = Only712MacroForwarder.Payload({ - meta: Only712MacroForwarder.PayloadMeta({ domain: META_DOMAIN, version: META_VERSION }), - message: Only712MacroForwarder.PayloadMessage({ title: MESSAGE_TITLE, customPayload: new bytes(0) }), - security: Only712MacroForwarder.PayloadSecurity({ + Only712MacroForwarder.PrimaryType memory payload = Only712MacroForwarder.PrimaryType({ + action: Only712MacroForwarder.ActionType({ actionParams: abi.encode(ACTION_DESCRIPTION) }), + security: Only712MacroForwarder.SecurityType({ + domain: SECURITY_DOMAIN, provider: SECURITY_PROVIDER, validAfter: validAfter, validBefore: validBefore, @@ -43,10 +44,10 @@ function getPayloadWithNonceAndTimeframe(uint256 nonce, uint256 validAfter, uint // ============== Minimal macro for Only712MacroForwarder ============== // Implements IUserDefined712Macro and uses *no* postCheck logic. -// Message has only the required `title`; `customPayload` is expected to be empty. +// The Action type has a description field with hardcoded string value. contract Minimal712Macro is IUserDefined712Macro { - string public constant MESSAGE_TYPE_DEFINITION = "Message(string title)"; + string public constant ACTION_TYPE_DEFINITION = "Action(string description)"; function buildBatchOperations(ISuperfluid, bytes memory, address) external @@ -61,20 +62,19 @@ contract Minimal712Macro is IUserDefined712Macro { // intentionally empty } - function getMessageTypeDefinition() external pure override returns (string memory) { - return MESSAGE_TYPE_DEFINITION; + function getActionTypeDefinition(bytes memory /*params*/) external pure override returns (string memory) { + return ACTION_TYPE_DEFINITION; } - function getPrimaryTypeName() external pure override returns (string memory) { + function getPrimaryTypeName(bytes memory /*params*/) external pure override returns (string memory) { return PRIMARY_TYPE_NAME; } - function getMessageStructHash(bytes memory message) external pure override returns (bytes32) { - (string memory title, bytes memory customPayload) = abi.decode(message, (string, bytes)); - require(keccak256(bytes(title)) == keccak256(bytes(MESSAGE_TITLE)), "wrong title"); - require(customPayload.length == 0, "customPayload not empty"); - bytes32 messageTypeHash = keccak256(abi.encodePacked(MESSAGE_TYPE_DEFINITION)); - return keccak256(abi.encode(messageTypeHash, keccak256(bytes(title)))); + function getActionStructHash(bytes memory params) external pure override returns (bytes32) { + string memory description = abi.decode(params, (string)); + require(keccak256(bytes(description)) == keccak256(bytes(ACTION_DESCRIPTION)), "wrong description"); + bytes32 actionTypeHash = keccak256(abi.encodePacked(ACTION_TYPE_DEFINITION)); + return keccak256(abi.encode(actionTypeHash, keccak256(bytes(description)))); } } @@ -114,20 +114,39 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { } function testDigestCalculation() external view { - // check the type definition - string memory typeDefinition = forwarder.getTypeDefinition(minimal712Macro); - string memory expectedTypeDefinition = "MinimalExample(Meta meta,Message message,Security security)Message(string title)Meta(string domain,string version)Security(string provider,uint256 validAfter,uint256 validBefore,uint256 nonce)"; + // 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); + 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 expectedStructHash = vm.eip712HashStruct(typeDefinition, payload); + bytes32 actionStructHash = minimal712Macro.getActionStructHash(abi.encode(ACTION_DESCRIPTION)); + 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 @@ -227,8 +246,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { '"primaryType": "MinimalExample",', // leaving this as literal in order to fit onto the stack '"domain": {', _getDomainJson(), '},', '"message": {', - '"meta": {', _getMetaJson(), '},', - '"message": {', _getMessageJson(), '},', + '"action": {', _getActionJson(), '},', '"security": {', _getSecurityJson(), '}', '}', '}' @@ -239,8 +257,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { return string(abi.encodePacked( _getEIP712DomainTypeJson(), _getMinimalExampleTypeJson(), - _getMessageTypeJson(), - _getMetaTypeJson(), + _getActionTypeJson(), _getSecurityTypeJson() )); } @@ -259,26 +276,16 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { function _getMinimalExampleTypeJson() internal pure returns (string memory) { return string(abi.encodePacked( '"MinimalExample": [', - '{"name": "meta", "type": "Meta"},', - '{"name": "message", "type": "Message"},', + '{"name": "action", "type": "Action"},', '{"name": "security", "type": "Security"}', '],' )); } - function _getMessageTypeJson() internal pure returns (string memory) { - return string(abi.encodePacked( - '"Message": [', - '{"name": "title", "type": "string"}', - '],' - )); - } - - function _getMetaTypeJson() internal pure returns (string memory) { + function _getActionTypeJson() internal pure returns (string memory) { return string(abi.encodePacked( - '"Meta": [', - '{"name": "domain", "type": "string"},', - '{"name": "version", "type": "string"}', + '"Action": [', + '{"name": "description", "type": "string"}', '],' )); } @@ -286,6 +293,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { 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"},', @@ -303,22 +311,16 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { )); } - function _getMetaJson() internal pure returns (string memory) { - return string(abi.encodePacked( - '"domain": "', META_DOMAIN, '",', - '"version": "', META_VERSION, '"' - )); - } - - function _getMessageJson() internal pure returns (string memory) { + function _getActionJson() internal pure returns (string memory) { return string(abi.encodePacked( - '"title": "', MESSAGE_TITLE, '"' + '"description": "', ACTION_DESCRIPTION, '"' )); } 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",', From 17a38f1e31c979e329c881125dbdfcc0ad3090c2 Mon Sep 17 00:00:00 2001 From: didi Date: Tue, 10 Feb 2026 13:19:34 +0100 Subject: [PATCH 13/18] fix msgSender --- .../contracts/utils/ForwarderBase.sol | 13 +- .../contracts/utils/MacroForwarder.sol | 2 +- .../contracts/utils/Only712MacroForwarder.sol | 2 +- .../foundry/utils/Only712MacroForwarder.t.sol | 136 ++++++++++++------ 4 files changed, 100 insertions(+), 53 deletions(-) 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 index fcbbd6f358..e03b3dda7f 100644 --- a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol +++ b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol @@ -135,7 +135,7 @@ contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { m.buildBatchOperations(_host, payload.action.actionParams, signer); // forward the operations - bool retVal = _forwardBatchCallWithValue(operations, msg.value); + bool retVal = _forwardBatchCallWithSenderAndValue(operations, signer, msg.value); m.postCheck(_host, payload.action.actionParams, signer); return retVal; } diff --git a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol index 5aa3bca0ae..e2dce41e46 100644 --- a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol +++ b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol @@ -4,33 +4,30 @@ 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 { ISuperfluid, ISuperfluidToken } from "../../../contracts/interfaces/superfluid/ISuperfluid.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 ACTION_DESCRIPTION = "Hello 712"; 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; - -// returns the encoded payload for the example macro (nonce = key 1, sequence 0) -function getTestPayload() pure returns (bytes memory) { - return getPayloadWithNonce(DEFAULT_NONCE); -} - -// returns the encoded payload with the given nonce (for nonce tests) -function getPayloadWithNonce(uint256 nonce) pure returns (bytes memory) { - return getPayloadWithNonceAndTimeframe(nonce, 0, 0); -} - -// returns the encoded payload with the given nonce and timeframe -function getPayloadWithNonceAndTimeframe(uint256 nonce, uint256 validAfter, uint256 validBefore) pure returns (bytes memory) { +uint256 constant TEST_AMOUNT = 100e18; + +// returns the encoded payload with the given nonce, timeframe and upgrade params +function getPayloadWithTokenAmount( + uint256 nonce, + uint256 validAfter, + uint256 validBefore, + address token, + uint256 amount +) pure returns (bytes memory) { Only712MacroForwarder.PrimaryType memory payload = Only712MacroForwarder.PrimaryType({ - action: Only712MacroForwarder.ActionType({ actionParams: abi.encode(ACTION_DESCRIPTION) }), + action: Only712MacroForwarder.ActionType({ actionParams: abi.encode(token, amount) }), security: Only712MacroForwarder.SecurityType({ domain: SECURITY_DOMAIN, provider: SECURITY_PROVIDER, @@ -44,18 +41,35 @@ function getPayloadWithNonceAndTimeframe(uint256 nonce, uint256 validAfter, uint // ============== Minimal macro for Only712MacroForwarder ============== // Implements IUserDefined712Macro and uses *no* postCheck logic. -// The Action type has a description field with hardcoded string value. +// 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 buildBatchOperations(ISuperfluid, bytes memory, address) + 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) { - operations = new ISuperfluid.Operation[](0); + (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 { @@ -71,8 +85,8 @@ contract Minimal712Macro is IUserDefined712Macro { } function getActionStructHash(bytes memory params) external pure override returns (bytes32) { - string memory description = abi.decode(params, (string)); - require(keccak256(bytes(description)) == keccak256(bytes(ACTION_DESCRIPTION)), "wrong description"); + (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)))); } @@ -99,15 +113,35 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { sf.governance.enableTrustedForwarder(sf.host, ISuperfluidToken(address(0)), address(forwarder)); } - function testRunMacro() external { - VmSafe.Wallet memory signer = vm.createWallet("signer"); - (bytes memory params, bytes memory signatureVRS) = _signPayload(signer, DEFAULT_NONCE); + function getTestPayload() internal view returns (bytes memory) { + return getPayloadWithTokenAmount(DEFAULT_NONCE, 0, 0, address(superToken), TEST_AMOUNT); + } + + /// @dev Fund the signer with underlying and approve super token so the signer can have tokens upgraded. + 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 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, bytes memory signatureVRS) = _signPayload(signer, DEFAULT_NONCE); + 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); @@ -132,7 +166,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { // 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(ACTION_DESCRIPTION)); + bytes32 actionStructHash = minimal712Macro.getActionStructHash(abi.encode(address(superToken), TEST_AMOUNT)); bytes32 securityTypeHash = keccak256(abi.encodePacked(SECURITY_TYPEDEF)); bytes32 securityStructHash = keccak256(abi.encode( securityTypeHash, @@ -159,19 +193,23 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { 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, bytes memory signatureVRS) = _signPayload(signer, nonce); + 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, bytes memory signatureVRS) = _signPayload(signer, nonce); + 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)); @@ -185,8 +223,10 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { vm.warp(t0); VmSafe.Wallet memory signer = vm.createWallet("signer"); + _fundSignerForUpgrade(signer, 2); uint256 nonce = forwarder.getNonce(signer.addr, 0); - (bytes memory params, bytes memory signatureVRS) = _signPayloadWithTimeframe(signer, nonce, t0, t1); + 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) { @@ -210,7 +250,8 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { // 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, signatureVRS) = _signPayloadWithTimeframe(signer, nonce, 0, expiry); + 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))); @@ -220,17 +261,20 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { /// 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, bytes memory sig1) = _signPayload(signer, nonceSeq1); + 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, bytes memory sig0) = _signPayload(signer, nonceSeq0); + 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 @@ -311,9 +355,18 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { )); } - function _getActionJson() internal pure returns (string memory) { + 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": "', ACTION_DESCRIPTION, '"' + '"description": "', _getExpectedDescription(), '"' )); } @@ -336,20 +389,9 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { return forwarder.runMacro(minimal712Macro, params, signer, signatureVRS); } - function _signPayload(VmSafe.Wallet memory signer, uint256 nonce) - internal - returns (bytes memory params, bytes memory signatureVRS) - { - return _signPayloadWithTimeframe(signer, nonce, 0, 0); - } - - function _signPayloadWithTimeframe(VmSafe.Wallet memory signer, uint256 nonce, uint256 validAfter, uint256 validBefore) - internal - returns (bytes memory params, bytes memory signatureVRS) - { - params = getPayloadWithNonceAndTimeframe(nonce, validAfter, validBefore); + 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); - signatureVRS = abi.encodePacked(r, s, v); + return abi.encodePacked(r, s, v); } } From bd5a524da4a51be26305457652a9309115df8196 Mon Sep 17 00:00:00 2001 From: didi Date: Tue, 10 Feb 2026 18:38:29 +0100 Subject: [PATCH 14/18] remove unneeded constructor arg --- .../contracts/utils/Only712MacroForwarder.sol | 2 +- .../test/foundry/utils/Only712MacroForwarder.t.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol index e03b3dda7f..8dbec7dede 100644 --- a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol +++ b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol @@ -89,7 +89,7 @@ contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { // Here EIP712 domain name and version are set. // TODO: should the name include "Superfluid"? - constructor(ISuperfluid host, address /*registry*/) ForwarderBase(host) EIP712("ClearSigning", "1") { + constructor(ISuperfluid host) ForwarderBase(host) EIP712("ClearSigning", "1") { _providerACL = IAccessControl(host.getSimpleACL()); } diff --git a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol index e2dce41e46..00bddb81ec 100644 --- a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol +++ b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol @@ -102,7 +102,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { function setUp() public override { super.setUp(); - forwarder = new Only712MacroForwarder(sf.host, address(0)); + forwarder = new Only712MacroForwarder(sf.host); minimal712Macro = new Minimal712Macro(); IAccessControl acl = IAccessControl(sf.host.getSimpleACL()); From 809307434bb9349b81ed3d8e54c167e63562387b Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 11 Feb 2026 12:36:30 +0100 Subject: [PATCH 15/18] added encodeParams to forwarder --- .../contracts/utils/Only712MacroForwarder.sol | 17 +++ .../foundry/utils/Only712MacroForwarder.t.sol | 119 +++++++++++------- 2 files changed, 91 insertions(+), 45 deletions(-) diff --git a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol index 8dbec7dede..9d9cb7812b 100644 --- a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol +++ b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol @@ -95,6 +95,23 @@ contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { // PUBLIC FUNCTIONS + /** + * @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); + } + /** * @dev Run the macro with encoded payload (generic + macro specific fragments). * @param m Target macro. diff --git a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol index 00bddb81ec..c1a2748b8f 100644 --- a/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol +++ b/packages/ethereum-contracts/test/foundry/utils/Only712MacroForwarder.t.sol @@ -18,27 +18,6 @@ string constant SECURITY_TYPEDEF = "Security(string domain,string provider,uint2 uint256 constant DEFAULT_NONCE = uint256(1) << 64; uint256 constant TEST_AMOUNT = 100e18; -// returns the encoded payload with the given nonce, timeframe and upgrade params -function getPayloadWithTokenAmount( - uint256 nonce, - uint256 validAfter, - uint256 validBefore, - address token, - uint256 amount -) pure returns (bytes memory) { - 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 - }) - }); - return abi.encode(payload); -} - // ============== Minimal macro for Only712MacroForwarder ============== // Implements IUserDefined712Macro and uses *no* postCheck logic. // Expects params (token, amount); does a SuperToken upgrade from underlying. @@ -113,17 +92,36 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { sf.governance.enableTrustedForwarder(sf.host, ISuperfluidToken(address(0)), address(forwarder)); } - function getTestPayload() internal view returns (bytes memory) { - return getPayloadWithTokenAmount(DEFAULT_NONCE, 0, 0, address(superToken), TEST_AMOUNT); - } - - /// @dev Fund the signer with underlying and approve super token so the signer can have tokens upgraded. - 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 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 { @@ -132,7 +130,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { _fundSignerForUpgrade(signer, 1); uint256 signerSuperBalanceBefore = superToken.balanceOf(signer.addr); - bytes memory params = getTestPayload(); + 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"); @@ -140,7 +138,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { function testRevertsWhenCallerMissingProviderRole() external { VmSafe.Wallet memory signer = vm.createWallet("signer"); - bytes memory params = getTestPayload(); + bytes memory params = _getTestPayload(); bytes memory signatureVRS = _signPayload(signer, params); vm.expectRevert(abi.encodeWithSelector( Only712MacroForwarder.ProviderNotAuthorized.selector, SECURITY_PROVIDER, address(0xbad))); @@ -149,7 +147,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { 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 typeDefinition = forwarder.getTypeDefinition(minimal712Macro, _getTestPayload()); string memory expectedTypeDefinition = string(abi.encodePacked( PRIMARY_TYPE_NAME, "(Action action,Security security)", @@ -159,12 +157,12 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { assertEq(typeDefinition, expectedTypeDefinition, "typeDefinition mismatch"); // check the type hash - bytes32 typeHash = forwarder.getTypeHash(minimal712Macro, getTestPayload()); + 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(); + 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)); @@ -185,7 +183,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { // check the digest bytes32 digest = forwarder.getDigest(minimal712Macro, payload); - string memory dataToBeSignedJson = getDataToBeSignedJson(); + string memory dataToBeSignedJson = _getDataToBeSignedJson(); console.log(dataToBeSignedJson); bytes32 expectedDigest = vm.eip712HashTypedData(dataToBeSignedJson); assertEq(digest, expectedDigest, "digest mismatch"); @@ -197,7 +195,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { 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 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"); } @@ -208,7 +206,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { _fundSignerForUpgrade(signer, 2); uint256 nonce = forwarder.getNonce(signer.addr, key); - bytes memory params = getPayloadWithTokenAmount(nonce, 0, 0, address(superToken), TEST_AMOUNT); + 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)); @@ -225,7 +223,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { 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 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) @@ -250,7 +248,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { // 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); + params = _getPayloadWithTokenAmount(nonce, 0, expiry, address(superToken), TEST_AMOUNT); signatureVRS = _signPayload(signer, params); vm.warp(expiry + 1); vm.expectRevert(abi.encodeWithSelector( @@ -265,7 +263,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { // 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 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)); @@ -273,7 +271,7 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { // seq=0 must succeed uint256 nonceSeq0 = uint256(key) << 64; - bytes memory paramsSeq0 = getPayloadWithTokenAmount(nonceSeq0, 0, 0, address(superToken), TEST_AMOUNT); + 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)); @@ -281,9 +279,40 @@ contract Only712MacroForwarderTest is FoundrySuperfluidTester { 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) { + function _getDataToBeSignedJson() internal view returns (string memory) { return string(abi.encodePacked( '{', '"types": {', _getTypesJson(), '},', From 2f847fcb961e83afb9e9d6834cecdf748cdabf41 Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 13 Feb 2026 16:23:43 +0100 Subject: [PATCH 16/18] change naming convention for encode view functions --- .../contracts/interfaces/utils/IUserDefinedMacro.sol | 2 +- .../test/foundry/utils/MacroForwarder.t.sol | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol b/packages/ethereum-contracts/contracts/interfaces/utils/IUserDefinedMacro.sol index ceec536ae5..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 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); From 7a5ee2969f85f369845e34ad819057421b3bf067 Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 13 Feb 2026 16:24:59 +0100 Subject: [PATCH 17/18] rearrange code --- .../contracts/utils/Only712MacroForwarder.sol | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol index 9d9cb7812b..bfc1385eeb 100644 --- a/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol +++ b/packages/ethereum-contracts/contracts/utils/Only712MacroForwarder.sol @@ -95,23 +95,6 @@ contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { // PUBLIC FUNCTIONS - /** - * @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); - } - /** * @dev Run the macro with encoded payload (generic + macro specific fragments). * @param m Target macro. @@ -157,6 +140,23 @@ contract Only712MacroForwarder is ForwarderBase, EIP712, NonceManager { 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); From 61c13d3efa61284fc5e3c55dbbb109da11e9650c Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 13 Feb 2026 16:32:41 +0100 Subject: [PATCH 18/18] added scripts for managing the 712 macro forwarder and related ACL --- .../ops-scripts/deploy-deterministically.js | 7 +++ .../scripts/GrantMacroProviderRole.s.sol | 50 ++++++++++++++++ .../tasks/deploy-712-macro-forwarder.sh | 60 +++++++++++++++++++ .../tasks/grant-macro-provider-role.sh | 58 ++++++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 packages/ethereum-contracts/scripts/GrantMacroProviderRole.s.sol create mode 100755 packages/ethereum-contracts/tasks/deploy-712-macro-forwarder.sh create mode 100755 packages/ethereum-contracts/tasks/grant-macro-provider-role.sh 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/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