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
11 changes: 4 additions & 7 deletions app/models/ethscription_transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class EthscriptionTransaction < T::Struct
# Debug info (can be removed if not needed)
prop :ethscription_operation, T.nilable(String) # 'create', 'transfer', 'transfer_with_previous_owner'

MAX_MIMETYPE_LENGTH = 1000
DEPOSIT_TX_TYPE = 0x7D
MINT = 0
VALUE = 0
Expand Down Expand Up @@ -107,7 +108,7 @@ def self.transfer_multiple_ethscriptions(
def function_selector
function_signature = case ethscription_operation
when 'create'
'createEthscription((bytes32,bytes32,address,bytes,string,string,string,bool,(string,string,bytes)))'
'createEthscription((bytes32,bytes32,address,bytes,string,bool,(string,string,bytes)))'
when 'transfer'
if transfer_ids && transfer_ids.any?
'transferMultipleEthscriptions(bytes32[],address)'
Expand Down Expand Up @@ -218,9 +219,7 @@ def build_create_calldata
# Both input and event-based creates use data URI format
# Events are "equivalent of an EOA hex-encoding contentURI and putting it in the calldata"
data_uri = DataUri.new(content_uri)
mimetype = data_uri.mimetype.to_s
media_type = mimetype&.split('/')&.first
mime_subtype = mimetype&.split('/')&.last
mimetype = data_uri.mimetype.to_s.first(MAX_MIMETYPE_LENGTH)

Copilot AI Oct 17, 2025

Copy link

Choose a reason for hiding this comment

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

The first method on String returns the first character, not the first N characters. Use [0, MAX_MIMETYPE_LENGTH] or truncate(MAX_MIMETYPE_LENGTH, omission: '') instead to limit the mimetype length properly.

Suggested change
mimetype = data_uri.mimetype.to_s.first(MAX_MIMETYPE_LENGTH)
mimetype = data_uri.mimetype.to_s[0, MAX_MIMETYPE_LENGTH]

Copilot uses AI. Check for mistakes.
raw_content = data_uri.decoded_data.b
esip6 = DataUri.esip6?(content_uri) || false

Expand Down Expand Up @@ -249,14 +248,12 @@ def build_create_calldata
owner_bin, # address
raw_content, # bytes content
mimetype.b, # string
media_type.to_s.b, # string
mime_subtype.to_s.b, # string
esip6, # bool esip6
protocol_params # ProtocolParams tuple
]

encoded = Eth::Abi.encode(
['(bytes32,bytes32,address,bytes,string,string,string,bool,(string,string,bytes))'],
['(bytes32,bytes32,address,bytes,string,bool,(string,string,bytes))'],
[params]
)

Expand Down
29 changes: 14 additions & 15 deletions contracts/script/L2Genesis.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,23 @@ contract GenesisEthscriptions is Ethscriptions {

// Set all values including genesis-specific ones
ethscriptions[params.transactionHash] = Ethscription({
content: ContentInfo({
contentUriHash: params.contentUriHash,
contentSha: contentSha,
mimetype: params.mimetype,
mediaType: params.mediaType,
mimeSubtype: params.mimeSubtype,
esip6: params.esip6
}),
// Fixed-size fields
contentUriHash: params.contentUriHash,
contentSha: contentSha,
l1BlockHash: l1BlockHash,
// Packed slot 3
creator: creator,
createdAt: uint48(createdAt),
l1BlockNumber: uint48(l1BlockNumber),
// Dynamic field
mimetype: params.mimetype,
// Packed slot N
initialOwner: params.initialOwner,
ethscriptionNumber: uint48(totalSupply()),
esip6: params.esip6,
// Packed slot N+1
previousOwner: creator,
ethscriptionNumber: totalSupply(),
createdAt: createdAt,
l1BlockNumber: l1BlockNumber,
l2BlockNumber: 0, // Genesis ethscriptions have no L2 block
l1BlockHash: l1BlockHash
l2BlockNumber: 0 // Genesis ethscriptions have no L2 block
});

// Use ethscription number as token ID
Expand Down Expand Up @@ -337,8 +338,6 @@ contract L2Genesis is Script {
params.initialOwner = initialOwner;
params.content = vm.parseJsonBytes(json, string.concat(basePath, ".content"));
params.mimetype = vm.parseJsonString(json, string.concat(basePath, ".mimetype"));
params.mediaType = vm.parseJsonString(json, string.concat(basePath, ".media_type"));
params.mimeSubtype = vm.parseJsonString(json, string.concat(basePath, ".mime_subtype"));
params.esip6 = vm.parseJsonBool(json, string.concat(basePath, ".esip6"));
params.protocolParams = Ethscriptions.ProtocolParams({
protocolName: "",
Expand Down
10 changes: 0 additions & 10 deletions contracts/script/TestTokenUri.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ contract TestTokenUri is Script {
initialOwner: address(0x1111),
content: bytes("Hello World!"),
mimetype: "text/plain",
mediaType: "text",
mimeSubtype: "plain",
esip6: false,
protocolParams: Ethscriptions.ProtocolParams("", "", "")
}));
Expand All @@ -36,8 +34,6 @@ contract TestTokenUri is Script {
initialOwner: address(0x2222),
content: bytes('{"p":"erc-20","op":"mint","tick":"test","amt":"1000"}'),
mimetype: "application/json",
mediaType: "application",
mimeSubtype: "json",
esip6: false,
protocolParams: Ethscriptions.ProtocolParams("", "", "")
}));
Expand All @@ -50,8 +46,6 @@ contract TestTokenUri is Script {
initialOwner: address(0x3333),
content: bytes('<html><body style="background:linear-gradient(45deg,#ff006e,#8338ec);color:white;font-family:monospace;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><h1>Ethscriptions Rule!</h1></body></html>'),
mimetype: "text/html",
mediaType: "text",
mimeSubtype: "html",
esip6: false,
protocolParams: Ethscriptions.ProtocolParams("", "", "")
}));
Expand All @@ -65,8 +59,6 @@ contract TestTokenUri is Script {
initialOwner: address(0x4444),
content: redPixelPng,
mimetype: "image/png",
mediaType: "image",
mimeSubtype: "png",
esip6: false,
protocolParams: Ethscriptions.ProtocolParams("", "", "")
}));
Expand All @@ -79,8 +71,6 @@ contract TestTokenUri is Script {
initialOwner: address(0x5555),
content: bytes("body { background: #000; color: #0f0; font-family: 'Courier New'; }"),
mimetype: "text/css",
mediaType: "text",
mimeSubtype: "css",
esip6: false,
protocolParams: Ethscriptions.ProtocolParams("", "", "")
}));
Expand Down
63 changes: 28 additions & 35 deletions contracts/src/Ethscriptions.sol
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,24 @@ contract Ethscriptions is ERC721EthscriptionsUpgradeable {
// STRUCTS
// =============================================================

struct ContentInfo {
bytes32 contentUriHash; // SHA256 of raw content URI string (for protocol uniqueness)
bytes32 contentSha; // SHA256 of decoded raw bytes (for storage reference)
string mimetype; // Full MIME type (e.g., "text/plain")
string mediaType; // e.g., "text", "image"
string mimeSubtype; // e.g., "plain", "png"
bool esip6;
}

struct Ethscription {
ContentInfo content;
// Full slots
bytes32 contentUriHash;
bytes32 contentSha;
bytes32 l1BlockHash;
// Packed slot (32 bytes)
address creator;
uint48 createdAt;
uint48 l1BlockNumber;
Comment on lines +33 to +34

Copilot AI Oct 17, 2025

Copy link

Choose a reason for hiding this comment

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

Using uint48 for timestamps and block numbers may cause overflow issues. Block numbers and timestamps can exceed the uint48 maximum value (281,474,976,710,655). Consider using uint64 or keeping uint256 for these critical fields to prevent future overflow.

Copilot uses AI. Check for mistakes.
// Dynamic
string mimetype;
// Packed slot (27 bytes used, 5 free)
address initialOwner;
uint48 ethscriptionNumber;
bool esip6;
// Packed slot (26 bytes used, 6 free)
address previousOwner;
uint256 ethscriptionNumber;
uint256 createdAt; // Timestamp when created
uint64 l1BlockNumber; // L1 block number when created
uint64 l2BlockNumber; // L2 block number when created
bytes32 l1BlockHash; // L1 block hash when created
uint48 l2BlockNumber;
}

struct ProtocolParams {
Expand All @@ -56,8 +55,6 @@ contract Ethscriptions is ERC721EthscriptionsUpgradeable {
address initialOwner;
bytes content; // Raw decoded bytes (not Base64)
string mimetype;
string mediaType;
string mimeSubtype;
bool esip6;
ProtocolParams protocolParams; // Protocol operation data (optional)
}
Expand Down Expand Up @@ -216,22 +213,18 @@ contract Ethscriptions is ERC721EthscriptionsUpgradeable {
bytes32 contentSha = _storeContent(params.content);

ethscriptions[params.transactionHash] = Ethscription({
content: ContentInfo({
contentUriHash: params.contentUriHash,
contentSha: contentSha,
mimetype: params.mimetype,
mediaType: params.mediaType,
mimeSubtype: params.mimeSubtype,
esip6: params.esip6
}),
contentUriHash: params.contentUriHash,
contentSha: contentSha,
l1BlockHash: l1Block.hash(),
creator: creator,
createdAt: uint48(block.timestamp),
l1BlockNumber: uint48(l1Block.number()),
mimetype: params.mimetype,
initialOwner: params.initialOwner,
previousOwner: creator, // Initially same as creator
ethscriptionNumber: totalSupply(),
createdAt: block.timestamp,
l1BlockNumber: l1Block.number(),
l2BlockNumber: uint64(block.number),
l1BlockHash: l1Block.hash()
ethscriptionNumber: uint48(totalSupply()),
esip6: params.esip6,
previousOwner: creator,
l2BlockNumber: uint48(block.number)
});

// Use ethscription number as token ID
Expand Down Expand Up @@ -377,7 +370,7 @@ contract Ethscriptions is ERC721EthscriptionsUpgradeable {
/// @notice Get content for an ethscription
function getEthscriptionContent(bytes32 txHash) public view requireExists(txHash) returns (bytes memory) {
Ethscription storage etsc = ethscriptions[txHash];
address[] storage pointers = contentPointersBySha[etsc.content.contentSha];
address[] storage pointers = contentPointersBySha[etsc.contentSha];
// Empty content is valid - returns "" for empty pointers array
return pointers.read();
}
Expand All @@ -388,7 +381,7 @@ contract Ethscriptions is ERC721EthscriptionsUpgradeable {
/// @return content The content bytes
function getEthscriptionWithContent(bytes32 txHash) external view requireExists(txHash) returns (Ethscription memory ethscription, bytes memory content) {
ethscription = ethscriptions[txHash];
address[] storage pointers = contentPointersBySha[ethscription.content.contentSha];
address[] storage pointers = contentPointersBySha[ethscription.contentSha];
// Empty content is valid - returns "" for empty pointers array
content = pointers.read();
}
Expand Down Expand Up @@ -620,8 +613,8 @@ contract Ethscriptions is ERC721EthscriptionsUpgradeable {
txHash,
etsc.creator,
etsc.initialOwner,
etsc.content.contentUriHash,
etsc.content.contentSha,
etsc.contentUriHash,
etsc.contentSha,
etsc.ethscriptionNumber
);
}
Expand Down
30 changes: 15 additions & 15 deletions contracts/src/EthscriptionsProver.sol
Original file line number Diff line number Diff line change
Expand Up @@ -19,26 +19,26 @@ contract EthscriptionsProver {

/// @notice Info stored when an ethscription is queued for proving
struct QueuedProof {
uint256 l2BlockNumber;
uint256 l2BlockTimestamp;
bytes32 l1BlockHash;
uint256 l1BlockNumber;
uint48 l2BlockNumber;
uint48 l2BlockTimestamp;
uint48 l1BlockNumber;
Comment on lines +23 to +25

Copilot AI Oct 17, 2025

Copy link

Choose a reason for hiding this comment

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

Using uint48 for block numbers and timestamps risks overflow. Ethereum block numbers and timestamps will eventually exceed uint48's maximum value of ~281 trillion. This could cause silent overflow and incorrect data storage.

Copilot uses AI. Check for mistakes.
}

/// @notice Struct for ethscription data proof
struct EthscriptionDataProof {
bytes32 ethscriptionTxHash;
bytes32 contentSha;
bytes32 contentUriHash;
bytes32 l1BlockHash;
address creator;
address currentOwner;
address previousOwner;
uint256 ethscriptionNumber;
bool esip6;
bytes32 l1BlockHash;
uint256 l1BlockNumber;
uint256 l2BlockNumber;
uint256 l2Timestamp;
uint48 ethscriptionNumber;
uint48 l1BlockNumber;
uint48 l2BlockNumber;
uint48 l2Timestamp;
}

// =============================================================
Expand Down Expand Up @@ -99,10 +99,10 @@ contract EthscriptionsProver {
// Capture the L1 block hash and number at the time of queuing
L1Block l1Block = L1Block(L1_BLOCK);
queuedProofInfo[txHash] = QueuedProof({
l2BlockNumber: block.number,
l2BlockTimestamp: block.timestamp,
l1BlockHash: l1Block.hash(),
l1BlockNumber: l1Block.number()
l2BlockNumber: uint48(block.number),
l2BlockTimestamp: uint48(block.timestamp),
l1BlockNumber: uint48(l1Block.number())
});
}
}
Expand Down Expand Up @@ -143,14 +143,14 @@ contract EthscriptionsProver {
// Create proof struct with all ethscription data
EthscriptionDataProof memory proof = EthscriptionDataProof({
ethscriptionTxHash: ethscriptionTxHash,
contentSha: etsc.content.contentSha,
contentUriHash: etsc.content.contentUriHash,
contentSha: etsc.contentSha,
contentUriHash: etsc.contentUriHash,
l1BlockHash: proofInfo.l1BlockHash,
creator: etsc.creator,
currentOwner: currentOwner,
previousOwner: etsc.previousOwner,
esip6: etsc.esip6,
ethscriptionNumber: etsc.ethscriptionNumber,
esip6: etsc.content.esip6,
l1BlockHash: proofInfo.l1BlockHash,
l1BlockNumber: proofInfo.l1BlockNumber,
l2BlockNumber: proofInfo.l2BlockNumber,
l2Timestamp: proofInfo.l2BlockTimestamp
Expand Down
29 changes: 11 additions & 18 deletions contracts/src/libraries/EthscriptionsRendererLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,11 @@ library EthscriptionsRendererLib {

string memory part2 = string.concat(
'"},{"trait_type":"Content SHA","value":"',
uint256(etsc.content.contentSha).toHexString(),
uint256(etsc.contentSha).toHexString(),
'"},{"trait_type":"MIME Type","value":"',
etsc.content.mimetype.escapeJSON(),
'"},{"trait_type":"Media Type","value":"',
etsc.content.mediaType.escapeJSON(),
'"},{"trait_type":"MIME Subtype","value":"',
etsc.content.mimeSubtype.escapeJSON()
);

string memory part3 = string.concat(
etsc.mimetype.escapeJSON(),
'"},{"trait_type":"ESIP-6","value":"',
etsc.content.esip6 ? "true" : "false",
etsc.esip6 ? "true" : "false",
'"},{"trait_type":"L1 Block Number","display_type":"number","value":',
uint256(etsc.l1BlockNumber).toString(),
'},{"trait_type":"L2 Block Number","display_type":"number","value":',
Expand All @@ -55,7 +48,7 @@ library EthscriptionsRendererLib {
'}]'
);

return string.concat(part1, part2, part3);
return string.concat(part1, part2);
}

/// @notice Generate the media URI for an ethscription
Expand All @@ -68,22 +61,22 @@ library EthscriptionsRendererLib {
view
returns (string memory mediaType, string memory mediaUri)
{
if (etsc.content.mimetype.startsWith("image/")) {
if (etsc.mimetype.startsWith("image/")) {
// Image content: wrap in SVG for pixel-perfect rendering
string memory imageDataUri = constructDataURI(etsc.content.mimetype, content);
string memory imageDataUri = constructDataURI(etsc.mimetype, content);
string memory svg = wrapImageInSVG(imageDataUri);
mediaUri = constructDataURI("image/svg+xml", bytes(svg));
return ("image", mediaUri);
} else {
// Non-image content: use animation_url
if (etsc.content.mimetype.startsWith("video/") ||
etsc.content.mimetype.startsWith("audio/") ||
etsc.content.mimetype.eq("text/html")) {
if (etsc.mimetype.startsWith("video/") ||
etsc.mimetype.startsWith("audio/") ||
etsc.mimetype.eq("text/html")) {
// Video, audio, and HTML pass through directly as data URIs
mediaUri = constructDataURI(etsc.content.mimetype, content);
mediaUri = constructDataURI(etsc.mimetype, content);
} else {
// Everything else (text/plain, application/json, etc.) uses the HTML viewer
mediaUri = createTextViewerDataURI(etsc.content.mimetype, content);
mediaUri = createTextViewerDataURI(etsc.mimetype, content);
}
return ("animation_url", mediaUri);
}
Expand Down
Loading