From 372a76ce8901aab082eaefa77043fdc3b72ba3a1 Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Thu, 16 Apr 2026 20:21:21 +0800 Subject: [PATCH 1/3] feat: add dedicated nonce space for nonce insensitive workflows --- src/modules/MalleableSapient.sol | 21 ++++++-- test/MalleableSapient.t.sol | 82 +++++++++++++++++++++++++------- 2 files changed, 84 insertions(+), 19 deletions(-) diff --git a/src/modules/MalleableSapient.sol b/src/modules/MalleableSapient.sol index fb31034..4ee5993 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 legacy outer nonce commitment by default, with a dedicated nonce-insensitive malleable lane. + 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..1df7f5d 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; @@ -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, ""); From 0189a063a7c3263a921cba4b43da648ff68a7905 Mon Sep 17 00:00:00 2001 From: Shun Kakinoki <39187513+shunkakinoki@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:37:51 +0800 Subject: [PATCH 2/3] Fix typo in comment about nonce commitment --- src/modules/MalleableSapient.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/MalleableSapient.sol b/src/modules/MalleableSapient.sol index 4ee5993..972edf1 100644 --- a/src/modules/MalleableSapient.sol +++ b/src/modules/MalleableSapient.sol @@ -49,7 +49,7 @@ contract MalleableSapient is ISapient { revert NonTransactionPayload(); } - // Use the legacy outer nonce commitment by default, with a dedicated nonce-insensitive malleable lane. + // Use the outer nonce commitment by default, with a dedicated nonce-insensitive malleable nocne space. bytes32 root = LibOptim.fkeccak256(bytes32(payload.space), _outerNonceCommitment(payload)); // Roll chainId From 79ccf3ffe9fdb3f502c92beb7c572b01dfb6e9f5 Mon Sep 17 00:00:00 2001 From: agusx1211 Date: Fri, 17 Apr 2026 06:22:58 +0000 Subject: [PATCH 3/3] Fix typos --- src/modules/MalleableSapient.sol | 2 +- test/MalleableSapient.t.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/MalleableSapient.sol b/src/modules/MalleableSapient.sol index 972edf1..df256e2 100644 --- a/src/modules/MalleableSapient.sol +++ b/src/modules/MalleableSapient.sol @@ -49,7 +49,7 @@ contract MalleableSapient is ISapient { revert NonTransactionPayload(); } - // Use the outer nonce commitment by default, with a dedicated nonce-insensitive malleable nocne space. + // 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 diff --git a/test/MalleableSapient.t.sol b/test/MalleableSapient.t.sol index 1df7f5d..a0c64a3 100644 --- a/test/MalleableSapient.t.sol +++ b/test/MalleableSapient.t.sol @@ -227,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;