diff --git a/contracts/BiscuitFont.sol b/contracts/BiscuitFont.sol new file mode 100644 index 0000000..56b110d --- /dev/null +++ b/contracts/BiscuitFont.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {SSTORE2} from "./libs/SSTORE2.sol"; +import {Memory} from "./libs/Memory.sol"; +import {IBiscuitFont} from "./interfaces/IBiscuitFont.sol"; + +/// @notice Stores font data in SSTORE2-backed pages. +contract BiscuitFont is IBiscuitFont, Ownable { + /// @notice SSTORE2 data offset (skip STOP opcode). + uint256 private constant _DATA_OFFSET = 1; + + /// @notice Letters trait storage. + Trait private _letters; + + /// @notice Digits trait storage. + Trait private _digits; + + constructor(address initialOwner) Ownable(initialOwner) {} + + /** + * @notice Return the number of stored letters chunks. + */ + function lettersCount() external view returns (uint256) { + return _letters.pages.length; + } + + /** + * @notice Return the number of stored digits chunks. + */ + function digitsCount() external view returns (uint256) { + return _digits.pages.length; + } + + /** + * @notice Return the concatenated letters binary payload. + */ + function letters() public view override returns (bytes memory) { + return _concatenate(_letters); + } + + /** + * @notice Return the concatenated digits binary payload. + */ + function digits() public view override returns (bytes memory) { + return _concatenate(_digits); + } + + /** + * @notice Add a batch of Letters images. + * @dev This function can only be called by the owner. + * Uses the “Caveat” typeface by Impallari Type for creating this letters. + * License: https://fonts.google.com/specimen/Caveat/license + */ + function addLetters(bytes calldata data) external override onlyOwner { + _addPage(_letters, data); + emit LettersPageAdded(_letters.pages.length - 1, _letters.totalBytes); + } + + /** + * @notice Add a batch of Digits images. + * @dev This function can only be called by the owner. + * Uses the “Inter” typeface by Impallari Type for creating this letters. + * License: https://fonts.google.com/specimen/Inter/license + */ + function addDigits(bytes calldata data) external override onlyOwner { + _addPage(_digits, data); + emit DigitsPageAdded(_digits.pages.length - 1, _digits.totalBytes); + } + + /** + * @notice Add a batch of font data from an existing storage contract. + * @dev This function can only be called by the owner. + */ + function addLettersFromPointer(address pointer, uint256 len) external override onlyOwner { + _addPointer(_letters, pointer, len); + emit LettersPageAdded(_letters.pages.length - 1, _letters.totalBytes); + } + + /** + * @notice Add a batch of font data from an existing storage contract. + * @dev This function can only be called by the owner. + */ + function addDigitsFromPointer(address pointer, uint256 len) external override onlyOwner { + _addPointer(_digits, pointer, len); + emit DigitsPageAdded(_digits.pages.length - 1, _digits.totalBytes); + } + + /** + * @notice Write arbitrary binary data to SSTORE2 and register its pointer to Trait. + * @dev Throws EmptyBytes error if data is empty. + * @param trait Trait structure to write to + * @param data byte sequence to be written + */ + function _addPage(Trait storage trait, bytes calldata data) internal { + if (data.length == 0) revert EmptyBytes(); + address pointer = SSTORE2.write(data); + trait.pages.push(pointer); + trait.totalBytes += data.length; + } + + /** + * @notice Register an existing SSTORE2 pointer and byte length. + * @dev Throws ZeroPointer or BadLength on invalid inputs. + * @param trait Trait structure to which to add + * @param pointer Address of SSTORE2 contract + * @param len Chunk byte length + */ + function _addPointer(Trait storage trait, address pointer, uint256 len) internal { + if (pointer == address(0)) revert ZeroPointer(); + uint256 codeLength = pointer.code.length; + if (codeLength <= _DATA_OFFSET) revert BadLength(); + uint256 dataLength = codeLength - _DATA_OFFSET; + if (len == 0 || len != dataLength) revert BadLength(); + trait.pages.push(pointer); + trait.totalBytes += len; + } + + /** + * @notice Combines all chunks registered in Trait and returns them as a single bytes array. + */ + function _concatenate(Trait storage trait) internal view returns (bytes memory out) { + out = new bytes(trait.totalBytes); + + uint256 dst; + assembly { + dst := add(out, 0x20) + } + for (uint256 i; i < trait.pages.length; ) { + bytes memory data = SSTORE2.read(trait.pages[i]); + + uint256 src; + uint256 len; + assembly { + src := add(data, 0x20) + len := mload(data) + } + + Memory.copy(src, dst, len); + dst += len; + + unchecked { + ++i; + } + } + } +} diff --git a/contracts/interfaces/IBiscuitFont.sol b/contracts/interfaces/IBiscuitFont.sol new file mode 100644 index 0000000..40fd784 --- /dev/null +++ b/contracts/interfaces/IBiscuitFont.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +interface IBiscuitFont { + struct Trait { + address[] pages; + uint256 totalBytes; + } + + function lettersCount() external view returns (uint256); + + function digitsCount() external view returns (uint256); + + function letters() external view returns (bytes memory); + + function digits() external view returns (bytes memory); + + function addLetters(bytes calldata data) external; + + function addDigits(bytes calldata data) external; + + /** + * @notice Register letters data from an existing SSTORE2 storage contract. + * @dev The provided length must match the stored byte length. + */ + function addLettersFromPointer(address pointer, uint256 len) external; + + /** + * @notice Register digits data from an existing SSTORE2 storage contract. + * @dev The provided length must match the stored byte length. + */ + function addDigitsFromPointer(address pointer, uint256 len) external; + + event LettersPageAdded(uint256 indexed pageIndex, uint256 totalBytes); + + event DigitsPageAdded(uint256 indexed pageIndex, uint256 totalBytes); + + error EmptyBytes(); + + error ZeroPointer(); + + error BadLength(); +} diff --git a/contracts/libs/Memory.sol b/contracts/libs/Memory.sol new file mode 100644 index 0000000..de6e076 --- /dev/null +++ b/contracts/libs/Memory.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/// @notice https://github.com/ethereum/solidity-examples/blob/master/src/unsafe/Memory.sol +library Memory { + uint256 internal constant WORD_SIZE = 32; + + /** + * @notice Copy `len` bytes from memory address `src` to address `dest`. + */ + function copy(uint256 src, uint256 dest, uint256 len) internal pure { + for (; len >= WORD_SIZE; len -= WORD_SIZE) { + assembly { + mstore(dest, mload(src)) + } + dest += WORD_SIZE; + src += WORD_SIZE; + } + + if (len == 0) return; + + uint256 mask = 256 ** (WORD_SIZE - len) - 1; + assembly { + let srcpart := and(mload(src), not(mask)) + let destpart := and(mload(dest), mask) + mstore(dest, or(destpart, srcpart)) + } + } + + /** + * @notice Return the data pointer and length for a bytes array. + */ + function fromBytes(bytes memory bts) internal pure returns (uint256 addr, uint256 len) { + len = bts.length; + assembly { + addr := add(bts, 32) + } + } +} diff --git a/contracts/test/SSTORE2Harness.sol b/contracts/test/SSTORE2Harness.sol new file mode 100644 index 0000000..1ab86a7 --- /dev/null +++ b/contracts/test/SSTORE2Harness.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {SSTORE2} from "../libs/SSTORE2.sol"; + +/// @title SSTORE2 Test Harness +/// @notice Exposes SSTORE2 library functions for unit tests. +contract SSTORE2Harness { + address public lastPointer; + + function write(bytes calldata data) external returns (address) { + address pointer = SSTORE2.write(data); + lastPointer = pointer; + return pointer; + } + + function read(address pointer) external view returns (bytes memory) { + return SSTORE2.read(pointer); + } +} diff --git a/contracts/test/StopCode.sol b/contracts/test/StopCode.sol new file mode 100644 index 0000000..6ab05c1 --- /dev/null +++ b/contracts/test/StopCode.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/// @title STOP Code Contract +/// @notice Deploys with a 1-byte runtime (0x00). +contract StopCode { + constructor() { + assembly { + mstore(0x00, 0x00) + return(0x00, 0x01) + } + } +} diff --git a/test/biscuit-font.test.ts b/test/biscuit-font.test.ts new file mode 100644 index 0000000..7e1d3c4 --- /dev/null +++ b/test/biscuit-font.test.ts @@ -0,0 +1,286 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { network } from "hardhat"; +import { stringToHex } from "viem"; + +const byteLength = (hex: `0x${string}`) => BigInt((hex.length - 2) / 2); +const concatHex = (left: `0x${string}`, right: `0x${string}`) => + `0x${left.slice(2)}${right.slice(2)}` as `0x${string}`; +const repeatHexByte = (hexByte: string, count: number) => + `0x${hexByte.repeat(count)}` as `0x${string}`; +const toBigInt = (value: bigint | number) => (typeof value === "bigint" ? value : BigInt(value)); + +describe("BiscuitFont (unit)", async () => { + const { viem } = await network.connect(); + const publicClient = await viem.getPublicClient(); + const [owner, other] = await viem.getWalletClients(); + const zeroAddress = "0x0000000000000000000000000000000000000000" as const; + + it("lettersCount/digitsCount: initial 0 & increments", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + const store = await viem.deployContract("SSTORE2Harness"); + + const lettersCountStart = (await font.read.lettersCount()) as bigint | number; + const digitsCountStart = (await font.read.digitsCount()) as bigint | number; + assert.equal(toBigInt(lettersCountStart), 0n); + assert.equal(toBigInt(digitsCountStart), 0n); + + await font.write.addLetters([stringToHex("L1")], { account: owner.account }); + await font.write.addDigits([stringToHex("D1")], { account: owner.account }); + + const lettersCountAfter = (await font.read.lettersCount()) as bigint | number; + const digitsCountAfter = (await font.read.digitsCount()) as bigint | number; + assert.equal(toBigInt(lettersCountAfter), 1n); + assert.equal(toBigInt(digitsCountAfter), 1n); + + await store.write.write([stringToHex("L2")]); + const pointer = (await store.read.lastPointer()) as `0x${string}`; + await font.write.addLettersFromPointer([pointer, byteLength(stringToHex("L2"))], { + account: owner.account, + }); + + const lettersCountFinal = (await font.read.lettersCount()) as bigint | number; + assert.equal(toBigInt(lettersCountFinal), 2n); + }); + + it("addLetters: rejects empty bytes", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + + await viem.assertions.revertWithCustomError( + font.write.addLetters(["0x"], { account: owner.account }), + font, + "EmptyBytes", + ); + }); + + it("addDigits: rejects empty bytes", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + + await viem.assertions.revertWithCustomError( + font.write.addDigits(["0x"], { account: owner.account }), + font, + "EmptyBytes", + ); + }); + + it("addLetters/addDigits: onlyOwner", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + + await viem.assertions.revertWithCustomError( + font.write.addLetters([stringToHex("L1")], { account: other.account }), + font, + "OwnableUnauthorizedAccount", + ); + + await viem.assertions.revertWithCustomError( + font.write.addDigits([stringToHex("D1")], { account: other.account }), + font, + "OwnableUnauthorizedAccount", + ); + }); + + it("addLetters: emits LettersPageAdded", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + const data = stringToHex("L1"); + const hash = await font.write.addLetters([data], { account: owner.account }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + const events = await publicClient.getContractEvents({ + address: font.address, + abi: font.abi, + eventName: "LettersPageAdded", + fromBlock: receipt.blockNumber, + toBlock: receipt.blockNumber, + }); + + assert.equal(events.length, 1); + const event = events[0]; + if (!event.args) throw new Error("Missing event args"); + assert.equal(toBigInt(event.args.pageIndex!), 0n); + assert.equal(toBigInt(event.args.totalBytes!), byteLength(data)); + }); + + it("addDigits: emits DigitsPageAdded", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + const data = stringToHex("D1"); + const hash = await font.write.addDigits([data], { account: owner.account }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + const events = await publicClient.getContractEvents({ + address: font.address, + abi: font.abi, + eventName: "DigitsPageAdded", + fromBlock: receipt.blockNumber, + toBlock: receipt.blockNumber, + }); + + assert.equal(events.length, 1); + const event = events[0]; + if (!event.args) throw new Error("Missing event args"); + assert.equal(toBigInt(event.args.pageIndex!), 0n); + assert.equal(toBigInt(event.args.totalBytes!), byteLength(data)); + }); + + it("addLettersFromPointer/addDigitsFromPointer: onlyOwner", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + const store = await viem.deployContract("SSTORE2Harness"); + const data = stringToHex("P1"); + + await store.write.write([data]); + const pointer = (await store.read.lastPointer()) as `0x${string}`; + const len = byteLength(data); + + await viem.assertions.revertWithCustomError( + font.write.addLettersFromPointer([pointer, len], { account: other.account }), + font, + "OwnableUnauthorizedAccount", + ); + + await viem.assertions.revertWithCustomError( + font.write.addDigitsFromPointer([pointer, len], { account: other.account }), + font, + "OwnableUnauthorizedAccount", + ); + }); + + it("addLettersFromPointer: rejects zero pointer", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + + await viem.assertions.revertWithCustomError( + font.write.addLettersFromPointer([zeroAddress, 1n], { account: owner.account }), + font, + "ZeroPointer", + ); + }); + + it("addLettersFromPointer: rejects EOA pointer (code length 0)", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + + await viem.assertions.revertWithCustomError( + font.write.addLettersFromPointer([owner.account.address, 1n], { account: owner.account }), + font, + "BadLength", + ); + }); + + it("addLettersFromPointer: rejects STOP-only pointer (code length 1)", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + const stop = await viem.deployContract("StopCode"); + + await viem.assertions.revertWithCustomError( + font.write.addLettersFromPointer([stop.address, 1n], { account: owner.account }), + font, + "BadLength", + ); + }); + + it("addDigitsFromPointer: rejects len == 0", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + const store = await viem.deployContract("SSTORE2Harness"); + const data = stringToHex("1234"); + await store.write.write([data]); + const pointer = (await store.read.lastPointer()) as `0x${string}`; + + await viem.assertions.revertWithCustomError( + font.write.addDigitsFromPointer([pointer, 0n], { account: owner.account }), + font, + "BadLength", + ); + }); + + it("addLettersFromPointer: rejects len mismatch", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + const store = await viem.deployContract("SSTORE2Harness"); + const data = stringToHex("abcd"); + await store.write.write([data]); + const pointer = (await store.read.lastPointer()) as `0x${string}`; + + await viem.assertions.revertWithCustomError( + font.write.addLettersFromPointer([pointer, byteLength(data) + 1n], { + account: owner.account, + }), + font, + "BadLength", + ); + }); + + it("letters: concatenates valid pages", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + const store = await viem.deployContract("SSTORE2Harness"); + const partA = stringToHex("Hello "); + const partB = stringToHex("World"); + + await store.write.write([partA]); + const pointerA = (await store.read.lastPointer()) as `0x${string}`; + + await store.write.write([partB]); + const pointerB = (await store.read.lastPointer()) as `0x${string}`; + + await font.write.addLettersFromPointer([pointerA, byteLength(partA)], { + account: owner.account, + }); + await font.write.addLettersFromPointer([pointerB, byteLength(partB)], { + account: owner.account, + }); + + const letters = (await font.read.letters()) as `0x${string}`; + assert.equal(letters, concatHex(partA, partB)); + }); + + it("addLettersFromPointer: accepts valid pointer", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + const store = await viem.deployContract("SSTORE2Harness"); + const data = stringToHex("ABC"); + + await store.write.write([data]); + const pointer = (await store.read.lastPointer()) as `0x${string}`; + + await font.write.addLettersFromPointer([pointer, byteLength(data)], { + account: owner.account, + }); + + const letters = (await font.read.letters()) as `0x${string}`; + assert.equal(letters, data); + }); + + it("digits: concatenates valid pages", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + const partA = stringToHex("123"); + const partB = stringToHex("456"); + + await font.write.addDigits([partA], { account: owner.account }); + await font.write.addDigits([partB], { account: owner.account }); + + const digits = (await font.read.digits()) as `0x${string}`; + assert.equal(digits, concatHex(partA, partB)); + }); + + it("addDigitsFromPointer: accepts valid pointer", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + const store = await viem.deployContract("SSTORE2Harness"); + const data = stringToHex("789"); + + await store.write.write([data]); + const pointer = (await store.read.lastPointer()) as `0x${string}`; + + await font.write.addDigitsFromPointer([pointer, byteLength(data)], { + account: owner.account, + }); + + const digits = (await font.read.digits()) as `0x${string}`; + assert.equal(digits, data); + }); + + it("letters: handles large multi-page payload", async () => { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + const partA = repeatHexByte("ab", 2048); + const partB = repeatHexByte("cd", 2048); + const partC = repeatHexByte("ef", 2048); + + await font.write.addLetters([partA], { account: owner.account }); + await font.write.addLetters([partB], { account: owner.account }); + await font.write.addLetters([partC], { account: owner.account }); + + const letters = (await font.read.letters()) as `0x${string}`; + const expected = concatHex(concatHex(partA, partB), partC); + assert.equal(letters, expected); + }); +});