diff --git a/contracts/libs/BiscuitMetadata.sol b/contracts/libs/BiscuitMetadata.sol
new file mode 100644
index 0000000..2055656
--- /dev/null
+++ b/contracts/libs/BiscuitMetadata.sol
@@ -0,0 +1,82 @@
+// SPDX-License-Identifier: GPL-3.0
+pragma solidity ^0.8.28;
+
+// solhint-disable quotes
+import {Base64} from "@openzeppelin/contracts/utils/Base64.sol";
+import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
+import {BiscuitRenderer} from "./BiscuitRenderer.sol";
+
+/// @title BiscuitMetadata
+library BiscuitMetadata {
+ using Strings for uint256;
+
+ /**
+ * @notice Render the JSON Metadata for a given token.
+ */
+ function tokenURI(
+ uint256 tokenId,
+ BiscuitRenderer.SVGParams memory params
+ ) internal pure returns (string memory) {
+ string memory image = generateSVGImage(params);
+
+ // prettier-ignore
+ bytes memory dataURI = abi.encodePacked(
+ '{', '"name": "Biscuit #', tokenId.toString(), '",',
+ '"description": "",',
+ '"image": "data:image/svg+xml;base64,', image, '",',
+ '"attributes": [', attributes(params.mnemonic), ']',
+ '}'
+ );
+
+ return string(abi.encodePacked("data:application/json;base64,", Base64.encode(dataURI)));
+ }
+
+ /**
+ * @notice Renders base64-encoded JSON metadata for the specified token.
+ */
+ function generateSVGImage(
+ BiscuitRenderer.SVGParams memory params
+ ) internal pure returns (string memory) {
+ return Base64.encode(bytes(BiscuitRenderer._generateSVG(params)));
+ }
+
+ /**
+ * @notice Render the JSON attributes for a given token.
+ */
+ function attributes(string[] memory values) internal pure returns (bytes memory) {
+ unchecked {
+ // prettier-ignore
+ string[24] memory traitIndexLabels = [
+ "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE", "TEN", "ELEVEN",
+ "TWELVE", "THIRTEEN", "FOURTEEN", "FIFTEEN", "SIXTEEN", "SEVENTEEN", "EIGHTEEN",
+ "NINETEEN", "TWENTY", "TWENTY-ONE", "TWENTY-TWO", "TWENTY-THREE", "TWENTY-FOUR"
+ ];
+ bytes memory traits;
+
+ for (uint256 i; i < 24; ++i) {
+ string memory value = i < values.length ? values[i] : "N/A";
+
+ traits = abi.encodePacked(traits, trait(traitIndexLabels[i], value), ",");
+ }
+
+ return abi.encodePacked(traits, trait("WORDS", uint256(values.length).toString()));
+ }
+ }
+
+ /**
+ * @notice Generate the JSON snippet for a single attribute.
+ */
+ function trait(
+ string memory traitType,
+ string memory traitValue
+ ) internal pure returns (bytes memory) {
+ // prettier-ignore
+ return
+ abi.encodePacked(
+ "{",
+ '"trait_type": "', traitType, '",',
+ '"value": "', traitValue, '"',
+ "}"
+ );
+ }
+}
diff --git a/contracts/libs/BiscuitRenderer.sol b/contracts/libs/BiscuitRenderer.sol
new file mode 100644
index 0000000..a41361b
--- /dev/null
+++ b/contracts/libs/BiscuitRenderer.sol
@@ -0,0 +1,139 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.28;
+
+// solhint-disable quotes
+import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
+
+/// @title BiscuitRenderer
+library BiscuitRenderer {
+ using Strings for uint256;
+
+ /// @notice Number of grid columns
+ uint256 private constant _COLS = 4;
+
+ /// @notice Number of rows in grid
+ uint256 private constant _ROWS = 6;
+
+ /// @notice Horizontal offset when duplicating columns
+ uint256 private constant _STEP_X = 103;
+
+ /// @notice Vertical offset when duplicating rows
+ uint256 private constant _STEP_Y = 24;
+
+ /// @notice Offset in X direction from index number to start drawing word
+ uint256 private constant _LETTERS_START_OFFSET_X = 10;
+
+ struct SVGParams {
+ bytes letters; // Caveat Font (base64)
+ bytes digits; // Inter Font (base64)
+ string[] mnemonic; // Mnemonic of 24 words or less
+ }
+
+ /**
+ * @notice Generate the complete SVG code for the given params.
+ */
+ function _generateSVG(SVGParams memory params) internal pure returns (string memory) {
+ return
+ string(
+ // prettier-ignore
+ abi.encodePacked(
+ ''
+ )
+ );
+ }
+
+ /**
+ * @notice Index and mnemonic in place.
+ */
+ function _generateArt(string[] memory data) internal pure returns (bytes memory) {
+ unchecked {
+ bytes memory out;
+ uint256 dataLength = data.length;
+
+ for (uint256 i; i < _COLS; ++i) {
+ for (uint256 j; j < _ROWS; ++j) {
+ uint256 idx = i * _ROWS + j;
+ // prettier-ignore
+ out = abi.encodePacked(
+ out,
+ '',
+ (idx + 1).toString(),
+ ". ",
+ '',
+ idx < dataLength ? data[idx] : "",
+ ""
+ );
+ }
+ }
+ return out;
+ }
+ }
+
+ /**
+ * @notice Generate the SVG code for a single underline row.
+ */
+ function _generatePathRow() internal pure returns (bytes memory) {
+ unchecked {
+ bytes memory rowPaths;
+ for (uint256 i; i < _COLS; ++i) {
+ // prettier-ignore
+ rowPaths = abi.encodePacked(
+ rowPaths,
+ ''
+ );
+ }
+ return abi.encodePacked('', rowPaths, "");
+ }
+ }
+
+ /**
+ * @notice Generate the SVG code for the entire 4x6 underline.
+ */
+ function _generatePathColumn() internal pure returns (bytes memory) {
+ unchecked {
+ bytes memory colPaths;
+ for (uint256 i; i < _ROWS; ++i) {
+ // prettier-ignore
+ colPaths = abi.encodePacked(
+ colPaths,
+ ''
+ );
+ }
+ return abi.encodePacked('', colPaths, "");
+ }
+ }
+}
diff --git a/contracts/test/BiscuitMetadataHarness.sol b/contracts/test/BiscuitMetadataHarness.sol
new file mode 100644
index 0000000..6cde29b
--- /dev/null
+++ b/contracts/test/BiscuitMetadataHarness.sol
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: GPL-3.0
+pragma solidity ^0.8.28;
+
+import {BiscuitMetadata} from "../libs/BiscuitMetadata.sol";
+import {BiscuitRenderer} from "../libs/BiscuitRenderer.sol";
+
+contract BiscuitMetadataHarness {
+ function tokenURI(
+ uint256 tokenId,
+ bytes calldata letters,
+ bytes calldata digits,
+ string[] calldata mnemonic
+ ) external pure returns (string memory) {
+ BiscuitRenderer.SVGParams memory params = BiscuitRenderer.SVGParams({
+ letters: letters,
+ digits: digits,
+ mnemonic: mnemonic
+ });
+ return BiscuitMetadata.tokenURI(tokenId, params);
+ }
+
+ function generateSVGImage(
+ bytes calldata letters,
+ bytes calldata digits,
+ string[] calldata mnemonic
+ ) external pure returns (string memory) {
+ BiscuitRenderer.SVGParams memory params = BiscuitRenderer.SVGParams({
+ letters: letters,
+ digits: digits,
+ mnemonic: mnemonic
+ });
+ return BiscuitMetadata.generateSVGImage(params);
+ }
+
+ function attributes(string[] calldata values) external pure returns (bytes memory) {
+ return BiscuitMetadata.attributes(values);
+ }
+
+ function trait(
+ string calldata traitType,
+ string calldata traitValue
+ ) external pure returns (bytes memory) {
+ return BiscuitMetadata.trait(traitType, traitValue);
+ }
+}
diff --git a/contracts/test/BiscuitRendererHarness.sol b/contracts/test/BiscuitRendererHarness.sol
new file mode 100644
index 0000000..8099550
--- /dev/null
+++ b/contracts/test/BiscuitRendererHarness.sol
@@ -0,0 +1,31 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.28;
+
+import {BiscuitRenderer} from "../libs/BiscuitRenderer.sol";
+
+contract BiscuitRendererHarness {
+ function generateSVG(
+ bytes calldata letters,
+ bytes calldata digits,
+ string[] calldata mnemonic
+ ) external pure returns (string memory) {
+ BiscuitRenderer.SVGParams memory params = BiscuitRenderer.SVGParams({
+ letters: letters,
+ digits: digits,
+ mnemonic: mnemonic
+ });
+ return BiscuitRenderer._generateSVG(params);
+ }
+
+ function generateArt(string[] calldata mnemonic) external pure returns (bytes memory) {
+ return BiscuitRenderer._generateArt(mnemonic);
+ }
+
+ function generatePathRow() external pure returns (bytes memory) {
+ return BiscuitRenderer._generatePathRow();
+ }
+
+ function generatePathColumn() external pure returns (bytes memory) {
+ return BiscuitRenderer._generatePathColumn();
+ }
+}
diff --git a/scripts/check-data-uri.mjs b/scripts/check-data-uri.mjs
new file mode 100644
index 0000000..bc5634c
--- /dev/null
+++ b/scripts/check-data-uri.mjs
@@ -0,0 +1,28 @@
+import { network } from "hardhat";
+import { stringToHex } from "viem";
+
+const { viem } = await network.connect();
+const metadata = await viem.deployContract("BiscuitMetadataHarness");
+
+const letters = stringToHex("QUJD");
+const digits = stringToHex("REVG");
+const mnemonic = ["alpha", "beta", "gamma"];
+
+const tokenUri = await metadata.read.tokenURI([1n, letters, digits, mnemonic]);
+
+const [prefix, encoded] = tokenUri.split(",");
+if (prefix !== "data:application/json;base64") {
+ throw new Error(`Unexpected prefix: ${prefix}`);
+}
+
+const json = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
+const image = json.image;
+const [imgPrefix, imgEncoded] = image.split(",");
+
+console.log("json.name:", json.name);
+console.log("image prefix:", imgPrefix);
+console.log("attributes length:", json.attributes?.length);
+console.log(
+ "svg starts with Buffer.from(hex.slice(2), "hex").toString("utf8");
+const decodeBase64 = (input: string) => Buffer.from(input, "base64").toString("utf8");
+
+describe("BiscuitMetadata (unit)", async () => {
+ const { viem } = await network.connect();
+
+ it("trait: returns JSON snippet", async () => {
+ const metadata = await viem.deployContract("BiscuitMetadataHarness");
+ const trait = (await metadata.read.trait(["FOO", "bar"])) as `0x${string}`;
+ const traitStr = hexToUtf8(trait);
+
+ assert.equal(traitStr, '{"trait_type": "FOO","value": "bar"}');
+ });
+
+ it("attributes: fills missing values and adds WORDS", async () => {
+ const metadata = await viem.deployContract("BiscuitMetadataHarness");
+ const attributes = (await metadata.read.attributes([["alpha", "beta"]])) as `0x${string}`;
+ const attributesStr = hexToUtf8(attributes);
+
+ assert.ok(attributesStr.includes('{"trait_type": "ONE","value": "alpha"}'));
+ assert.ok(attributesStr.includes('{"trait_type": "TWO","value": "beta"}'));
+ assert.ok(attributesStr.includes('{"trait_type": "THREE","value": "N/A"}'));
+ assert.ok(attributesStr.includes('{"trait_type": "WORDS","value": "2"}'));
+ });
+
+ it("generateSVGImage: base64-encodes SVG", async () => {
+ const metadata = await viem.deployContract("BiscuitMetadataHarness");
+ const letters = stringToHex("QUJD");
+ const digits = stringToHex("REVG");
+
+ const image = (await metadata.read.generateSVGImage([letters, digits, ["alpha"]])) as string;
+ const svg = decodeBase64(image);
+
+ assert.ok(svg.includes("