diff --git a/src/modules/MalleableSapient.sol b/src/modules/MalleableSapient.sol index fb31034..df256e2 100644 --- a/src/modules/MalleableSapient.sol +++ b/src/modules/MalleableSapient.sol @@ -10,7 +10,8 @@ import {LibOptim} from "wallet-contracts-v3/utils/LibOptim.sol"; /// @notice An `ISapient` implementation that lets the caller declare which parts of a transaction bundle are "static" (committed to), /// which parts are "malleable" (can be changed/hydrated at execution), and which parts are "repeatable" (for malleable sections that must match). /// @dev The returned `imageHash` is a rolling hash of: -/// - the payload `space` + `nonce` +/// - the payload `space` +/// - the outer nonce commitment slot (defaults to `payload.nonce`; `MALLEABLE_NONCE_SPACE` maps to a sentinel) /// - the current `block.chainid` /// - each call's metadata (everything except `data`) /// - each "static section" of call `data` as described by `signature` @@ -31,6 +32,12 @@ contract MalleableSapient is ISapient { using LibBytes for bytes; + /// @notice Shared nonce space for nonce-insensitive malleable payloads. + /// @dev uint160(uint256(keccak256("trails.malleable.nonce-space")) | (uint256(1) << 159)) + uint256 public constant MALLEABLE_NONCE_SPACE = uint256(uint160(0xC2519dee4b2BfeFFEddE987259c8c876f5832728)); + + bytes32 private constant NONCE_IGNORED_SENTINEL = bytes32(type(uint256).max); + /// @inheritdoc ISapient /// @dev Computes the `imageHash` for a transaction payload with a malleable `data` commitment. function recoverSapientSignature(Payload.Decoded calldata payload, bytes calldata signature) @@ -42,8 +49,8 @@ contract MalleableSapient is ISapient { revert NonTransactionPayload(); } - // Roll space and nonce - bytes32 root = LibOptim.fkeccak256(bytes32(payload.space), bytes32(payload.nonce)); + // Use the outer nonce commitment by default, with a dedicated nonce-insensitive malleable nonce space. + bytes32 root = LibOptim.fkeccak256(bytes32(payload.space), _outerNonceCommitment(payload)); // Roll chainId if (payload.noChainId) { @@ -116,4 +123,12 @@ contract MalleableSapient is ISapient { function _staticSection(uint256 _tindex, uint256 _cindex, bytes calldata _data) internal pure returns (bytes32) { return keccak256(abi.encode("static-section", _tindex, _cindex, _data)); } + + function _outerNonceCommitment(Payload.Decoded calldata payload) internal pure returns (bytes32) { + if (payload.space == MALLEABLE_NONCE_SPACE) { + return NONCE_IGNORED_SENTINEL; + } + + return bytes32(payload.nonce); + } } diff --git a/test/MalleableSapient.t.sol b/test/MalleableSapient.t.sol index a55e29b..a0c64a3 100644 --- a/test/MalleableSapient.t.sol +++ b/test/MalleableSapient.t.sol @@ -8,6 +8,12 @@ import {Payload} from "wallet-contracts-v3/modules/Payload.sol"; import {LibOptim} from "wallet-contracts-v3/utils/LibOptim.sol"; contract MalleableSapientTest is Test { + MalleableSapient internal sapient; + + function setUp() external { + sapient = new MalleableSapient(); + } + function _randomBytes(uint256 len, bytes32 seed) private pure returns (bytes memory data) { data = new bytes(len); @@ -31,8 +37,16 @@ contract MalleableSapientTest is Test { v = (uint16(uint8(data[index])) << 8) | uint16(uint8(data[index + 1])); } + function _outerNonceCommitment(Payload.Decoded memory payload) private view returns (bytes32) { + if (payload.space == sapient.MALLEABLE_NONCE_SPACE()) { + return bytes32(type(uint256).max); + } + + return bytes32(payload.nonce); + } + function _expectedImageHash(Payload.Decoded memory payload, bytes memory signature) private view returns (bytes32) { - bytes32 root = LibOptim.fkeccak256(bytes32(payload.space), bytes32(payload.nonce)); + bytes32 root = LibOptim.fkeccak256(bytes32(payload.space), _outerNonceCommitment(payload)); if (payload.noChainId) { root = LibOptim.fkeccak256(root, bytes32(0)); } else { @@ -88,8 +102,6 @@ contract MalleableSapientTest is Test { function testFuzz_recoverSapientSignature_reverts_nonTransactions(uint8 kind) external { vm.assume(kind != Payload.KIND_TRANSACTIONS); - MalleableSapient sapient = new MalleableSapient(); - Payload.Decoded memory payload; payload.kind = kind; payload.calls = new Payload.Call[](0); @@ -124,13 +136,61 @@ contract MalleableSapientTest is Test { }); } - MalleableSapient sapient = new MalleableSapient(); - bytes32 got = sapient.recoverSapientSignature(payload, ""); bytes32 expected = _expectedImageHash(payload, ""); assertEq(got, expected); } + function test_recoverSapientSignature_commitsNonceOutsideMalleableSpace() external { + Payload.Decoded memory payload; + payload.kind = Payload.KIND_TRANSACTIONS; + payload.space = 1; + payload.calls = new Payload.Call[](1); + payload.calls[0] = Payload.Call({ + to: address(0x1234), + value: 0, + data: hex"010203", + gasLimit: 0, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 0 + }); + + payload.nonce = 0; + bytes32 firstHash = sapient.recoverSapientSignature(payload, ""); + + payload.nonce = 1; + bytes32 secondHash = sapient.recoverSapientSignature(payload, ""); + + assertNotEq(firstHash, secondHash); + } + + function test_recoverSapientSignature_ignoresNonceInMalleableSpace() external { + Payload.Decoded memory payload; + payload.kind = Payload.KIND_TRANSACTIONS; + payload.space = sapient.MALLEABLE_NONCE_SPACE(); + payload.calls = new Payload.Call[](1); + payload.calls[0] = Payload.Call({ + to: address(0x1234), + value: 0, + data: hex"010203", + gasLimit: 0, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 0 + }); + + payload.nonce = 0; + bytes32 firstHash = sapient.recoverSapientSignature(payload, ""); + assertEq(firstHash, _expectedImageHash(payload, "")); + + payload.nonce = 1; + bytes32 secondHash = sapient.recoverSapientSignature(payload, ""); + assertEq(secondHash, _expectedImageHash(payload, "")); + + assertEq(firstHash, secondHash); + } + struct SignatureParts { uint8 tindex; uint16 cindex; @@ -167,7 +227,7 @@ contract MalleableSapientTest is Test { }); } - // Prevent overlap by requring separate tindex for each repeat section + // Prevent overlap by requiring separate tindex for each repeat section uint256[] memory tindexWithRepeat = new uint256[](sections * 2); SignatureParts memory parts; @@ -216,8 +276,6 @@ contract MalleableSapientTest is Test { } } - MalleableSapient sapient = new MalleableSapient(); - bytes32 got = sapient.recoverSapientSignature(payload, signature); bytes32 expected = _expectedImageHash(payload, signature); assertEq(got, expected); @@ -299,8 +357,6 @@ contract MalleableSapientTest is Test { // At least one of the repeat sections must be invalid vm.assume(invalidParts.size != 0); - MalleableSapient sapient = new MalleableSapient(); - vm.expectRevert( abi.encodeWithSelector( MalleableSapient.InvalidRepeatSection.selector, @@ -315,8 +371,6 @@ contract MalleableSapientTest is Test { } function test_recoverSapientSignature_chainId_included() external { - MalleableSapient sapient = new MalleableSapient(); - Payload.Decoded memory payload; payload.kind = Payload.KIND_TRANSACTIONS; payload.space = 1; @@ -340,8 +394,6 @@ contract MalleableSapientTest is Test { } function test_recoverSapientSignature_noChainId_zeroUsed() external { - MalleableSapient sapient = new MalleableSapient(); - Payload.Decoded memory payload; payload.kind = Payload.KIND_TRANSACTIONS; payload.space = 1; @@ -359,7 +411,7 @@ contract MalleableSapientTest is Test { assertEq(hash, expected); // Verify the hash uses zero by manually computing with zero - bytes32 root = LibOptim.fkeccak256(bytes32(payload.space), bytes32(payload.nonce)); + bytes32 root = LibOptim.fkeccak256(bytes32(payload.space), _outerNonceCommitment(payload)); root = LibOptim.fkeccak256(root, bytes32(0)); // Should use zero, not block.chainid root = LibOptim.fkeccak256( root, @@ -415,8 +467,6 @@ contract MalleableSapientTest is Test { payloadNoChainId.calls[i] = call; } - MalleableSapient sapient = new MalleableSapient(); - bytes32 hashWithChainId = sapient.recoverSapientSignature(payloadWithChainId, ""); bytes32 hashNoChainId = sapient.recoverSapientSignature(payloadNoChainId, "");