From a7441a92638c1ff00f7da6f9745a506cdf23ff36 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 4 Mar 2026 22:16:55 +0100 Subject: [PATCH 1/4] Add an AbiDecode library for safe abi decoding (no revert) --- contracts/utils/AbiDecode.sol | 63 +++++++++++++++++++++++++++++++++++ contracts/utils/Memory.sol | 5 +++ test/utils/AbiDecode.t.sol | 48 ++++++++++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 contracts/utils/AbiDecode.sol create mode 100644 test/utils/AbiDecode.t.sol diff --git a/contracts/utils/AbiDecode.sol b/contracts/utils/AbiDecode.sol new file mode 100644 index 00000000000..664debdd218 --- /dev/null +++ b/contracts/utils/AbiDecode.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {Calldata} from "./Calldata.sol"; +import {Memory} from "./Memory.sol"; + +/// @dev Utilities to decode ABI-encoded data without reverting. +library AbiDecode { + using Memory for *; + + /** + * @dev Attempts to decode a `bytes` value from a bytes input (in memory). Returns a boolean indicating success + * and a slice pointing to the decoded buffer. If decoding fails, returns an empty slice. + */ + function tryDecodeBytes(bytes memory input) internal pure returns (bool success, Memory.Slice output) { + return tryDecodeBytes(input.asSlice()); + } + + /** + * @dev Attempts to decode a `bytes` value from a Memory.Slice input. Returns a boolean indicating success and a + * slice pointing to the decoded buffer. If decoding fails, returns an empty slice. + */ + function tryDecodeBytes(Memory.Slice input) internal pure returns (bool success, Memory.Slice output) { + unchecked { + uint256 inputLength = input.length(); + if (inputLength < 0x20) { + return (false, Memory.emptySlice()); + } + uint256 offset = uint256(input.load(0)); + if (inputLength - 0x20 < offset) { + return (false, Memory.emptySlice()); + } + uint256 length = uint256(input.load(offset)); + if (inputLength - 0x20 - offset < length) { + return (false, Memory.emptySlice()); + } + return (true, input.slice(0x20 + offset, length)); + } + } + + /** + * @dev Attempts to decode a `bytes` value from a bytes input (in calldata). Returns a boolean indicating success + * and a slice pointing to the decoded buffer. If decoding fails, returns an empty slice. + */ + function tryDecodeBytesCalldata(bytes calldata input) internal pure returns (bool success, bytes calldata output) { + unchecked { + uint256 inputLength = input.length; + if (inputLength < 0x20) { + return (false, Calldata.emptyBytes()); + } + uint256 offset = uint256(bytes32(input[0x00:0x20])); + if (inputLength - 0x20 < offset) { + return (false, Calldata.emptyBytes()); + } + uint256 length = uint256(bytes32(input[offset:offset + 0x20])); + if (inputLength - 0x20 - offset < length) { + return (false, Calldata.emptyBytes()); + } + return (true, input[0x20 + offset:0x20 + offset + length]); + } + } +} diff --git a/contracts/utils/Memory.sol b/contracts/utils/Memory.sol index 9b8cb8961b9..80ae1dac012 100644 --- a/contracts/utils/Memory.sol +++ b/contracts/utils/Memory.sol @@ -54,6 +54,11 @@ library Memory { type Slice is bytes32; + /// @dev Empty slice + function emptySlice() internal pure returns (Slice) { + return Slice.wrap(0); + } + /// @dev Get a slice representation of a bytes object in memory function asSlice(bytes memory self) internal pure returns (Slice result) { assembly ("memory-safe") { diff --git a/test/utils/AbiDecode.t.sol b/test/utils/AbiDecode.t.sol new file mode 100644 index 00000000000..b9b0fa63d23 --- /dev/null +++ b/test/utils/AbiDecode.t.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {AbiDecode} from "@openzeppelin/contracts/utils/AbiDecode.sol"; +import {Memory} from "@openzeppelin/contracts/utils/Memory.sol"; + +contract AbiDecodeTest is Test { + using AbiDecode for *; + using Memory for *; + + function testDecode(bytes memory buffer) public pure { + (bool success, Memory.Slice output) = abi.encode(buffer).tryDecodeBytes(); + assertTrue(success); + assertEq(output.toBytes(), buffer); + } + + function testDecodeNoRevert(bytes memory buffer) public pure { + (bool success, Memory.Slice output) = buffer.tryDecodeBytes(); + if (success) { + assertEq(output.toBytes(), abi.decode(buffer, (bytes))); + } else { + assertEq(output.toBytes(), new bytes(0)); + } + } + + function testDecodeCalldata(bytes memory buffer) public view { + (bool success, bytes memory output) = this.__tryDecodeBytesCalldata(abi.encode(buffer)); + assertTrue(success); + assertEq(output, buffer); + } + + function testDecodeCalldataNoRevert(bytes calldata buffer) public pure { + (bool success, bytes calldata output) = buffer.tryDecodeBytesCalldata(); + if (success) { + assertEq(output, new bytes(0)); + } else { + assertEq(output, new bytes(0)); + } + } + + function __tryDecodeBytesCalldata( + bytes calldata buffer + ) external pure returns (bool success, bytes calldata output) { + return buffer.tryDecodeBytesCalldata(); + } +} From 04a24248f5910a17f829068f28fc078bb5df9cdd Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 4 Mar 2026 22:25:45 +0100 Subject: [PATCH 2/4] changeset and docs --- .changeset/tired-ghosts-obey.md | 5 +++++ contracts/mocks/Stateless.sol | 1 + contracts/utils/README.adoc | 1 + 3 files changed, 7 insertions(+) create mode 100644 .changeset/tired-ghosts-obey.md diff --git a/.changeset/tired-ghosts-obey.md b/.changeset/tired-ghosts-obey.md new file mode 100644 index 00000000000..578352aab75 --- /dev/null +++ b/.changeset/tired-ghosts-obey.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`AbiDecode`: Added a library for decoding abi-encoded buffers in a way were decoding errors can be processed safely. diff --git a/contracts/mocks/Stateless.sol b/contracts/mocks/Stateless.sol index 0669c675248..09d22170417 100644 --- a/contracts/mocks/Stateless.sol +++ b/contracts/mocks/Stateless.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.26; // We keep these imports and a dummy contract just to we can run the test suite after transpilation. +import {AbiDecode} from "../utils/AbiDecode.sol"; import {Accumulators} from "../utils/structs/Accumulators.sol"; import {Address} from "../utils/Address.sol"; import {Arrays} from "../utils/Arrays.sol"; diff --git a/contracts/utils/README.adoc b/contracts/utils/README.adoc index d6b0f720f3e..feda864d456 100644 --- a/contracts/utils/README.adoc +++ b/contracts/utils/README.adoc @@ -22,6 +22,7 @@ Miscellaneous contracts and libraries containing utility functions you can use t * {EnumerableSet}: Like {EnumerableMap}, but for https://en.wikipedia.org/wiki/Set_(abstract_data_type)[sets]. Can be used to store privileged accounts, issued IDs, etc. * {Heap}: A library that implements a https://en.wikipedia.org/wiki/Binary_heap[binary heap] in storage. * {MerkleTree}: A library with https://wikipedia.org/wiki/Merkle_Tree[Merkle Tree] data structures and helper functions. + * {AbiDecode}: Collection of function to decode abi-encoded buffers in a way were decoding errors can be processed safely. * {Address}: Collection of functions for overloading Solidity's https://docs.soliditylang.org/en/latest/types.html#address[`address`] type. * {Arrays}: Collection of functions that operate on https://docs.soliditylang.org/en/latest/types.html#arrays[`arrays`]. * {Base58}: On-chain base58 encoding and decoding. From 21342403c1082cdf847b067eaf1b3fc9d8f9aa21 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 5 Mar 2026 20:40:48 +0100 Subject: [PATCH 3/4] coverage --- test/utils/AbiDecode.t.sol | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/utils/AbiDecode.t.sol b/test/utils/AbiDecode.t.sol index b9b0fa63d23..8087f752993 100644 --- a/test/utils/AbiDecode.t.sol +++ b/test/utils/AbiDecode.t.sol @@ -40,6 +40,42 @@ contract AbiDecodeTest is Test { } } + function testDecodeDegenerateCase() public view { + bytes memory buffer = abi.encodePacked(uint256(0x00)); // offset to itself + length = 0 + + (bool success, Memory.Slice output) = buffer.tryDecodeBytes(); + assertTrue(success); + assertEq(output.toBytes(), new bytes(0)); + + (bool successCalldata, bytes memory outputCalldata) = this.__tryDecodeBytesCalldata(buffer); + assertTrue(successCalldata); + assertEq(outputCalldata, new bytes(0)); + } + + function testDecodeOutOfBoundOffset() public view { + bytes memory buffer = abi.encodePacked(uint256(0x20)); + + (bool success, Memory.Slice output) = buffer.tryDecodeBytes(); + assertFalse(success); + assertEq(output.toBytes(), new bytes(0)); + + (bool successCalldata, bytes memory outputCalldata) = this.__tryDecodeBytesCalldata(buffer); + assertFalse(successCalldata); + assertEq(outputCalldata, new bytes(0)); + } + + function testDecodeLengthExceedsBuffer() public view { + bytes memory buffer = abi.encodePacked(uint256(0x20), uint256(0x40)); + + (bool success, Memory.Slice output) = buffer.tryDecodeBytes(); + assertFalse(success); + assertEq(output.toBytes(), new bytes(0)); + + (bool successCalldata, bytes memory outputCalldata) = this.__tryDecodeBytesCalldata(buffer); + assertFalse(successCalldata); + assertEq(outputCalldata, new bytes(0)); + } + function __tryDecodeBytesCalldata( bytes calldata buffer ) external pure returns (bool success, bytes calldata output) { From 0d667c9ded2281e991640cfcf3b6749a9a07cc5b Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 5 Mar 2026 20:44:53 +0100 Subject: [PATCH 4/4] fix test --- test/utils/AbiDecode.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/utils/AbiDecode.t.sol b/test/utils/AbiDecode.t.sol index 8087f752993..4856f1929fa 100644 --- a/test/utils/AbiDecode.t.sol +++ b/test/utils/AbiDecode.t.sol @@ -34,7 +34,7 @@ contract AbiDecodeTest is Test { function testDecodeCalldataNoRevert(bytes calldata buffer) public pure { (bool success, bytes calldata output) = buffer.tryDecodeBytesCalldata(); if (success) { - assertEq(output, new bytes(0)); + assertEq(output, abi.decode(buffer, (bytes))); } else { assertEq(output, new bytes(0)); }