Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions src/modules/MalleableSapient.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
}
84 changes: 67 additions & 17 deletions test/MalleableSapient.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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, "");

Expand Down
Loading