From b8666eb6e6dc7aae7fede1c1d8d5febfbf639374 Mon Sep 17 00:00:00 2001 From: xt0x Date: Sat, 28 Mar 2026 18:27:16 +0900 Subject: [PATCH 1/2] feat(metadata): add BiscuitMetadata and BiscuitRenderer libraries for token metadata generation --- contracts/libs/BiscuitMetadata.sol | 82 +++++++++++++++++ contracts/libs/BiscuitRenderer.sol | 139 +++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 contracts/libs/BiscuitMetadata.sol create mode 100644 contracts/libs/BiscuitRenderer.sol 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( + '', + '', + '', + '', + _generatePathRow(), + '', + '', + '', + _generatePathColumn(), + _generateArt(params.mnemonic), + '', + '' + ) + ); + } + + /** + * @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, ""); + } + } +} From d8da98934c923e13a6dff837be47bbab7b90eb39 Mon Sep 17 00:00:00 2001 From: xt0x Date: Sat, 28 Mar 2026 18:28:03 +0900 Subject: [PATCH 2/2] test(contracts): add unit and integration tests for BiscuitMetadata and BiscuitRenderer --- contracts/test/BiscuitMetadataHarness.sol | 45 ++++++++++++++ contracts/test/BiscuitRendererHarness.sol | 31 ++++++++++ scripts/check-data-uri.mjs | 28 +++++++++ scripts/render-svg.mjs | 19 ++++++ test/biscuit-metadata.test.ts | 64 ++++++++++++++++++++ test/biscuit-renderer.test.ts | 62 +++++++++++++++++++ test/integration/biscuit-integration.test.ts | 43 +++++++++++++ 7 files changed, 292 insertions(+) create mode 100644 contracts/test/BiscuitMetadataHarness.sol create mode 100644 contracts/test/BiscuitRendererHarness.sol create mode 100644 scripts/check-data-uri.mjs create mode 100644 scripts/render-svg.mjs create mode 100644 test/biscuit-metadata.test.ts create mode 100644 test/biscuit-renderer.test.ts create mode 100644 test/integration/biscuit-integration.test.ts 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("alpha")); + }); + + it("tokenURI: returns base64 JSON with attributes", async () => { + const metadata = await viem.deployContract("BiscuitMetadataHarness"); + const letters = stringToHex("QUJD"); + const digits = stringToHex("REVG"); + + const tokenUri = (await metadata.read.tokenURI([ + 42n, + letters, + digits, + ["alpha", "beta"], + ])) as string; + const encoded = tokenUri.split(",")[1]; + const json = JSON.parse(decodeBase64(encoded)); + + assert.equal(json.name, "Biscuit #42"); + assert.ok(json.image.startsWith("data:image/svg+xml;base64,")); + assert.ok(Array.isArray(json.attributes)); + assert.equal(json.attributes.length, 25); + }); +}); diff --git a/test/biscuit-renderer.test.ts b/test/biscuit-renderer.test.ts new file mode 100644 index 0000000..4cb7580 --- /dev/null +++ b/test/biscuit-renderer.test.ts @@ -0,0 +1,62 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { network } from "hardhat"; +import { stringToHex } from "viem"; + +const hexToUtf8 = (hex: `0x${string}`) => Buffer.from(hex.slice(2), "hex").toString("utf8"); + +describe("BiscuitRenderer (unit)", async () => { + const { viem } = await network.connect(); + + it("generatePathRow: renders 4 path uses", async () => { + const renderer = await viem.deployContract("BiscuitRendererHarness"); + const row = (await renderer.read.generatePathRow()) as `0x${string}`; + const rowStr = hexToUtf8(row); + + assert.equal((rowStr.match(/href="#path"/g) || []).length, 4); + assert.ok(rowStr.includes('x="0"')); + assert.ok(rowStr.includes('x="103"')); + assert.ok(rowStr.includes('x="206"')); + assert.ok(rowStr.includes('x="309"')); + }); + + it("generatePathColumn: renders 6 row uses", async () => { + const renderer = await viem.deployContract("BiscuitRendererHarness"); + const column = (await renderer.read.generatePathColumn()) as `0x${string}`; + const columnStr = hexToUtf8(column); + + assert.equal((columnStr.match(/href="#row"/g) || []).length, 6); + assert.ok(columnStr.includes('y="0"')); + assert.ok(columnStr.includes('y="24"')); + assert.ok(columnStr.includes('y="48"')); + assert.ok(columnStr.includes('y="72"')); + assert.ok(columnStr.includes('y="96"')); + assert.ok(columnStr.includes('y="120"')); + }); + + it("generateArt: renders 24 indices and supplied words", async () => { + const renderer = await viem.deployContract("BiscuitRendererHarness"); + const art = (await renderer.read.generateArt([["alpha", "beta"]])) as `0x${string}`; + const artStr = hexToUtf8(art); + + assert.equal((artStr.match(/class=\"index\"/g) || []).length, 24); + assert.ok(artStr.includes(">1. ")); + assert.ok(artStr.includes(">2. ")); + assert.ok(artStr.includes(">alpha")); + assert.ok(artStr.includes(">beta")); + }); + + it("generateSVG: embeds fonts and content", async () => { + const renderer = await viem.deployContract("BiscuitRendererHarness"); + const letters = stringToHex("QUJD"); + const digits = stringToHex("REVG"); + + const svg = (await renderer.read.generateSVG([letters, digits, ["alpha", "beta"]])) as string; + + assert.ok(svg.includes("data:font/woff2; charset=utf-8; base64,QUJD")); + assert.ok(svg.includes("data:font/woff2; charset=utf-8; base64,REVG")); + assert.ok(svg.includes('class="index"')); + assert.ok(svg.includes('class="mnemonic"')); + assert.ok(svg.includes(">alpha")); + }); +}); diff --git a/test/integration/biscuit-integration.test.ts b/test/integration/biscuit-integration.test.ts new file mode 100644 index 0000000..af965c5 --- /dev/null +++ b/test/integration/biscuit-integration.test.ts @@ -0,0 +1,43 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { network } from "hardhat"; +import { stringToHex } from "viem"; + +const decodeBase64 = (input: string) => Buffer.from(input, "base64").toString("utf8"); + +describe("BiscuitRenderer + BiscuitMetadata (integration)", async () => { + const { viem } = await network.connect(); + + it("generateSVGImage matches raw SVG", async () => { + const renderer = await viem.deployContract("BiscuitRendererHarness"); + const metadata = await viem.deployContract("BiscuitMetadataHarness"); + const letters = stringToHex("QUJD"); + const digits = stringToHex("REVG"); + const mnemonic = ["alpha", "beta", "gamma"]; + + const svg = (await renderer.read.generateSVG([letters, digits, mnemonic])) as string; + const image = (await metadata.read.generateSVGImage([letters, digits, mnemonic])) as string; + const decoded = decodeBase64(image); + + assert.equal(decoded, svg); + }); + + it("tokenURI JSON image SVG matches raw SVG", async () => { + const renderer = await viem.deployContract("BiscuitRendererHarness"); + const metadata = await viem.deployContract("BiscuitMetadataHarness"); + const letters = stringToHex("QUJD"); + const digits = stringToHex("REVG"); + const mnemonic = ["alpha", "beta", "gamma"]; + + const svg = (await renderer.read.generateSVG([letters, digits, mnemonic])) as string; + const tokenUri = (await metadata.read.tokenURI([7n, letters, digits, mnemonic])) as string; + const encoded = tokenUri.split(",")[1]; + const json = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")) as { + image: string; + }; + const imageEncoded = json.image.split(",")[1]; + const decoded = Buffer.from(imageEncoded, "base64").toString("utf8"); + + assert.equal(decoded, svg); + }); +});