Skip to content
Merged
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
31 changes: 24 additions & 7 deletions contracts/src/Ethscriptions.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pragma solidity 0.8.24;
import "./ERC721EthscriptionsSequentialEnumerableUpgradeable.sol";
import {LibString} from "solady/utils/LibString.sol";
import "./libraries/SSTORE2Unlimited.sol";
import "./libraries/BytePackLib.sol";
import "./libraries/EthscriptionsRendererLib.sol";
import "./EthscriptionsProver.sol";
import "./libraries/Predeploys.sol";
Expand Down Expand Up @@ -75,8 +76,8 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable {
/// @dev Ethscription ID (L1 tx hash) => Ethscription data
mapping(bytes32 => Ethscription) internal ethscriptions;

/// @dev Content SHA => SSTORE2 pointer (single address, no array)
mapping(bytes32 => address) public contentPointerBySha;
/// @dev Content SHA => packed content (for <32 bytes) or SSTORE2 pointer (for >=32 bytes)
mapping(bytes32 => bytes32) public contentStorageBySha;

/// @dev Content URI hash => first ethscription tx hash that used it (for protocol uniqueness check)
/// @dev bytes32(0) means unused, non-zero means the content URI has been used
Expand Down Expand Up @@ -373,7 +374,16 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable {
/// @notice Get content for an ethscription
function getEthscriptionContent(bytes32 ethscriptionId) public view returns (bytes memory) {
Ethscription storage ethscription = _getEthscriptionOrRevert(ethscriptionId);
address pointer = contentPointerBySha[ethscription.contentSha];
bytes32 ref = contentStorageBySha[ethscription.contentSha];

// Check if it's inline content using BytePackLib
if (BytePackLib.isPacked(ref)) {
return BytePackLib.unpack(ref);
}

// It's a pointer to SSTORE2 contract
address pointer = address(uint160(uint256(ref)));

return SSTORE2Unlimited.read(pointer);
}

Expand Down Expand Up @@ -496,14 +506,21 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable {
contentSha = sha256(content);

// Check if content already exists
address existingPointer = contentPointerBySha[contentSha];
if (existingPointer != address(0)) {
bytes32 existing = contentStorageBySha[contentSha];
if (existing != bytes32(0)) {
// Content already stored, just return the SHA
return contentSha;
}

// Content doesn't exist, store it using SSTORE2Unlimited (handles any size)
contentPointerBySha[contentSha] = SSTORE2Unlimited.write(content);
// Store based on size
if (content.length <= 31) {
// Pack small content directly into bytes32 (0-31 bytes)

Copilot AI Nov 4, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition content.length <= 31 is inconsistent with the BytePackLib implementation which supports packing data up to 31 bytes (if (len >= 32) revert). However, the comment on line 79 states for <32 bytes which is correct. The condition should use < instead of <= to match the boundary of 32 bytes mentioned in the comment, or the comment should be updated to say for <=31 bytes. The current code is correct but the comment inconsistency could cause confusion.

Suggested change
// Pack small content directly into bytes32 (0-31 bytes)
// Pack small content directly into bytes32 (for <=31 bytes)

Copilot uses AI. Check for mistakes.
contentStorageBySha[contentSha] = BytePackLib.packCalldata(content);
} else {
// Deploy via SSTORE2 for larger content (32+ bytes)
address pointer = SSTORE2Unlimited.write(content);
contentStorageBySha[contentSha] = bytes32(uint256(uint160(pointer)));
}

return contentSha;
}
Expand Down
68 changes: 68 additions & 0 deletions contracts/src/libraries/BytePackLib.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

/// @title BytePackLib
/// @notice Library for packing small byte arrays (0-31 bytes) into a single bytes32 slot
/// @dev Uses a tag byte (length + 1) to distinguish packed data from regular addresses/data
library BytePackLib {
error ContentTooLarge(uint256 size);
error NotPackedData();

/// @notice Pack bytes calldata up to 31 bytes into a bytes32
/// @dev Calldata version for gas optimization when called with external data
/// @param data The data to pack (must be <= 31 bytes)
/// @return packed The packed bytes32 value
function packCalldata(bytes calldata data) internal pure returns (bytes32 packed) {
uint256 len = data.length;
if (len >= 32) revert ContentTooLarge(len);

assembly {
// Pack: tag byte (len+1) | first 31 bytes of data
packed := or(
shl(248, add(len, 1)), // Tag in first byte
shr(8, calldataload(data.offset)) // Data in remaining 31 bytes
)
}
}

/// @notice Unpack a bytes32 value into bytes
/// @dev Extracts the data based on the tag byte (length + 1)
/// @param packed The packed bytes32 value
/// @return data The unpacked bytes data
function unpack(bytes32 packed) internal pure returns (bytes memory data) {
uint256 tag = uint8(uint256(packed >> 248));
if (tag == 0 || tag > 32) revert NotPackedData();

uint256 len = tag - 1;
data = new bytes(len);

if (len > 0) {
assembly {
// Store the data (shift left by 8 to remove tag byte)
mstore(add(data, 0x20), shl(8, packed))
// Note: No need to zero memory after the data since new bytes() already zeroes it
// and we're only writing up to 31 bytes into a 32-byte word
}
}
}

/// @notice Check if a bytes32 value is packed data
/// @dev Returns true if the first byte indicates packed data (tag between 1-32)
/// @param value The bytes32 value to check
/// @return True if the value is packed data, false otherwise
function isPacked(bytes32 value) internal pure returns (bool) {
// Packed data has a tag byte between 1-32 in the first byte
uint256 tag = uint8(uint256(value >> 248));
return tag > 0 && tag <= 32;
}

/// @notice Get the length of packed data without unpacking
/// @dev Returns the length stored in the tag byte
/// @param packed The packed bytes32 value
/// @return The length of the packed data (0-31)
function packedLength(bytes32 packed) internal pure returns (uint256) {
uint256 tag = uint8(uint256(packed >> 248));
if (tag == 0 || tag > 32) revert NotPackedData();
return tag - 1;
}
}
203 changes: 203 additions & 0 deletions contracts/test/BytePackLib.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Test.sol";
import "forge-std/console2.sol";
import "../src/libraries/BytePackLib.sol";

/// @title TestWrapper
/// @notice Wrapper contract to expose internal library functions for testing
contract TestWrapper {
function packCalldata(bytes calldata data) external pure returns (bytes32) {
return BytePackLib.packCalldata(data);
}

function unpack(bytes32 packed) external pure returns (bytes memory) {
return BytePackLib.unpack(packed);
}

function isPacked(bytes32 value) external pure returns (bool) {
return BytePackLib.isPacked(value);
}

function packedLength(bytes32 packed) external pure returns (uint256) {
return BytePackLib.packedLength(packed);
}
}

contract BytePackLibTest is Test {
TestWrapper wrapper;

function setUp() public {
wrapper = new TestWrapper();
}

/// @notice Test packing and unpacking for all valid sizes (0-31 bytes)
function test_AllValidSizes() public {
for (uint256 i = 0; i <= 31; i++) {
// Test with incrementing pattern
bytes memory data = new bytes(i);
for (uint256 j = 0; j < i; j++) {
data[j] = bytes1(uint8(j % 256));
}
this.helperPackUnpack(data);

// Also test with all zeros
bytes memory zeros = new bytes(i);
this.helperPackUnpack(zeros);

// Also test with all 0xFF
bytes memory ones = new bytes(i);
for (uint256 j = 0; j < i; j++) {
ones[j] = bytes1(0xFF);
}
this.helperPackUnpack(ones);
}
}

function helperPackUnpack(bytes calldata data) external view {
require(data.length < 32, "Data must be less than 32 bytes");

bytes32 packed = wrapper.packCalldata(data);

// Verify it's marked as packed
assertTrue(wrapper.isPacked(packed), "Should be marked as packed");

// Verify the length is correct
assertEq(wrapper.packedLength(packed), data.length, "Length should match");

// Verify unpacking gives back the original data
bytes memory unpacked = wrapper.unpack(packed);
assertEq(unpacked, data, "Unpacked data should match original");

// Verify the tag byte is correct (length + 1)
uint8 tag = uint8(uint256(packed >> 248));
assertEq(tag, data.length + 1, "Tag should be length + 1");
}

/// @notice Test that packing 32+ bytes reverts
function test_PackingTooLarge_Reverts() public {
for (uint256 size = 32; size <= 40; size++) {
bytes memory data = new bytes(size);

vm.expectRevert(abi.encodeWithSelector(BytePackLib.ContentTooLarge.selector, size));
this.helperPackLarge(data);
}
}

function helperPackLarge(bytes calldata data) external view {
wrapper.packCalldata(data);
}

/// @notice Test that unpacking non-packed data reverts
function test_UnpackNonPacked_Reverts() public {
// Test with zero bytes32 (tag = 0)
bytes32 zero = bytes32(0);
assertFalse(wrapper.isPacked(zero), "Zero should not be packed");
vm.expectRevert(BytePackLib.NotPackedData.selector);
wrapper.unpack(zero);

// Test with an address-like value (no tag byte)
bytes32 addressLike = bytes32(uint256(uint160(address(0x1234567890123456789012345678901234567890))));
assertFalse(wrapper.isPacked(addressLike), "Address should not be packed");
vm.expectRevert(BytePackLib.NotPackedData.selector);
wrapper.unpack(addressLike);

// Test with tag byte > 32 (e.g., 0x21 = 33)
bytes32 invalidTag = bytes32(uint256(0x21) << 248);
assertFalse(wrapper.isPacked(invalidTag), "Tag > 32 should not be packed");
vm.expectRevert(BytePackLib.NotPackedData.selector);
wrapper.unpack(invalidTag);

// Test with tag byte = 255 (maximum uint8)
bytes32 maxTag = bytes32(uint256(0xFF) << 248);
assertFalse(wrapper.isPacked(maxTag), "Tag = 255 should not be packed");
vm.expectRevert(BytePackLib.NotPackedData.selector);
wrapper.unpack(maxTag);

// Test getting length of non-packed data
vm.expectRevert(BytePackLib.NotPackedData.selector);
wrapper.packedLength(zero);

vm.expectRevert(BytePackLib.NotPackedData.selector);
wrapper.packedLength(addressLike);

vm.expectRevert(BytePackLib.NotPackedData.selector);
wrapper.packedLength(invalidTag);

vm.expectRevert(BytePackLib.NotPackedData.selector);
wrapper.packedLength(maxTag);
}

/// @notice Test isPacked detection
function test_IsPacked_Detection() public {
// Pack some data and verify detection
bytes memory testData = hex"74657374"; // "test"
bytes32 packed = wrapper.packCalldata(testData);
assertTrue(wrapper.isPacked(packed), "Packed data should be detected");

// Regular addresses should not be detected as packed
address addr = address(0x1234567890123456789012345678901234567890);
bytes32 addrBytes = bytes32(uint256(uint160(addr)));
assertFalse(wrapper.isPacked(addrBytes), "Address should not be detected as packed");

// Zero should not be detected as packed
assertFalse(wrapper.isPacked(bytes32(0)), "Zero should not be detected as packed");

// Random data without tag byte (first byte is 0x00) should not be detected as packed
bytes32 randomData = bytes32(uint256(0x00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff));
assertFalse(wrapper.isPacked(randomData), "Random data should not be detected as packed");
}

/// @notice Test the exact packed format
function test_PackedFormat() public {
// Test empty bytes
bytes memory empty = "";
bytes32 packedEmpty = wrapper.packCalldata(empty);
assertEq(uint256(packedEmpty), uint256(bytes32(bytes1(0x01))), "Empty should pack to 0x01 followed by zeros");

// Test single byte 'A' (0x41)
bytes memory single = hex"41";
bytes32 packedSingle = wrapper.packCalldata(single);
// Should be: tag=0x02, data=0x41, rest zeros
assertEq(uint8(uint256(packedSingle >> 248)), 0x02, "Tag for 1 byte should be 2");
assertEq(uint8(uint256(packedSingle >> 240)), 0x41, "Data should be 0x41");

// Test "ABC" (0x414243)
bytes memory abc = hex"414243";
bytes32 packedAbc = wrapper.packCalldata(abc);
// Should be: tag=0x04, data=0x414243, rest zeros
assertEq(uint8(uint256(packedAbc >> 248)), 0x04, "Tag for 3 bytes should be 4");
assertEq(uint8(uint256(packedAbc >> 240)), 0x41, "First byte should be 0x41");
assertEq(uint8(uint256(packedAbc >> 232)), 0x42, "Second byte should be 0x42");
assertEq(uint8(uint256(packedAbc >> 224)), 0x43, "Third byte should be 0x43");
}

/// @notice Test edge cases
function test_EdgeCases() public {
// Test 31 bytes (maximum packable)
bytes memory max = new bytes(31);
for (uint i = 0; i < 31; i++) {
max[i] = bytes1(uint8(i));
}

bytes32 packed = wrapper.packCalldata(max);
assertTrue(wrapper.isPacked(packed), "31 bytes should be packed");
assertEq(wrapper.packedLength(packed), 31, "Length should be 31");

bytes memory unpacked = wrapper.unpack(packed);
assertEq(unpacked, max, "31 bytes should unpack correctly");

// Verify tag is 32 (31 + 1) - the maximum valid tag
assertEq(uint8(uint256(packed >> 248)), 32, "Tag for 31 bytes should be 32");

// Manually create a bytes32 with tag = 32 (boundary case) and verify it's valid
bytes32 dataBytes;
assembly {
dataBytes := mload(add(max, 0x20))
}
bytes32 boundaryTag = bytes32(uint256(32) << 248) | (dataBytes >> 8);
assertTrue(wrapper.isPacked(boundaryTag), "Tag = 32 should be valid packed data");
assertEq(wrapper.packedLength(boundaryTag), 31, "Tag = 32 means 31 bytes of data");
}
}
25 changes: 21 additions & 4 deletions contracts/test/EthscriptionsWithTestFunctions.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pragma solidity 0.8.24;

import "../src/Ethscriptions.sol";
import "../src/libraries/SSTORE2Unlimited.sol";
import "../src/libraries/BytePackLib.sol";

/// @title EthscriptionsWithTestFunctions
/// @notice Test contract that extends Ethscriptions with additional functions for testing
Expand All @@ -14,18 +15,34 @@ contract EthscriptionsWithTestFunctions is Ethscriptions {
/// @dev Test-only function to check if content exists
function hasContent(bytes32 ethscriptionId) external view returns (bool) {
Ethscription storage ethscription = _getEthscriptionOrRevert(ethscriptionId);
return contentPointerBySha[ethscription.contentSha] != address(0);
return contentStorageBySha[ethscription.contentSha] != bytes32(0);
}

/// @notice Get the content pointer for an ethscription
/// @notice Get the content storage value for an ethscription
/// @dev Test-only function to inspect storage (either packed bytes or SSTORE2 address)
function getContentStorage(bytes32 ethscriptionId) external view returns (bytes32) {
Ethscription storage ethscription = _getEthscriptionOrRevert(ethscriptionId);
return contentStorageBySha[ethscription.contentSha];
}

/// @notice Get the content pointer for an ethscription (only for SSTORE2 stored content)
/// @dev Test-only function to inspect SSTORE2 address
function getContentPointer(bytes32 ethscriptionId) external view returns (address) {
Ethscription storage ethscription = _getEthscriptionOrRevert(ethscriptionId);
return contentPointerBySha[ethscription.contentSha];
bytes32 stored = contentStorageBySha[ethscription.contentSha];

// Check if it's inline content using BytePackLib
if (BytePackLib.isPacked(stored)) {
// It's packed inline content, not a pointer
return address(0);
}

// It's a pointer to SSTORE2 contract
return address(uint160(uint256(stored)));
}

/// @notice Read content directly
/// @dev Test-only function to read content from SSTORE2
/// @dev Test-only function to read content
/// @param ethscriptionId The ethscription ID (L1 tx hash)
/// @return The content data
function readContent(bytes32 ethscriptionId) external view returns (bytes memory) {
Expand Down
Loading