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
82 changes: 82 additions & 0 deletions contracts/libs/BiscuitMetadata.sol
Original file line number Diff line number Diff line change
@@ -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, '"',
"}"
);
}
}
139 changes: 139 additions & 0 deletions contracts/libs/BiscuitRenderer.sol
Original file line number Diff line number Diff line change
@@ -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(
'<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" style="width:100%;background:#fff;">',
'<defs>',
'<style>',
'@font-face{',
'font-display: swap;',
'font-family: \'Caveat\';',
'font-style: normal;',
'font-weight: 700;',
'src: url(\'data:font/woff2; charset=utf-8; base64,', params.letters, ') format(\'woff2\');',
'}',
'@font-face{',
'font-display: swap;',
'font-family: \'Inter\';',
'font-style: italic;',
'font-weight: 700;',
'src: url(\'data:font/woff2; charset=utf-8; base64,', params.digits, ') format(\'woff2\');',
'}',
'.index {',
'font-family: "Inter";',
'font-size: 6px;',
'}',
'.mnemonic {',
'font-family: "Caveat";',
'font-size: 18px;',
'}',
'</style>',
'<path id="path" d="M0 0 83 0" stroke="#000" stroke-width="0.5"/>',
_generatePathRow(),
'</defs>',
'<rect width="512" height="512" fill="#fff"/>',
'<g transform="translate(60, 204)">',
_generatePathColumn(),
_generateArt(params.mnemonic),
'</g>',
'</svg>'
)
);
}

/**
* @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,
'<text class="index" ', 'x="', (i * _STEP_X).toString(), '" y="', (j * _STEP_Y).toString(), '">',
(idx + 1).toString(),
". </text>",
'<text class="mnemonic" x="', (i * _STEP_X + _LETTERS_START_OFFSET_X).toString(), '" y="', (j * _STEP_Y).toString(), '">',
idx < dataLength ? data[idx] : "",
"</text>"
);
}
}
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,
'<use href="#path" x="', (i * _STEP_X).toString(), '" y="0"/>'
);
}
return abi.encodePacked('<g id="row">', rowPaths, "</g>");
}
}

/**
* @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,
'<use href="#row" y="', (i * _STEP_Y).toString(), '"/>'
);
}
return abi.encodePacked('<g id="grid" x="196" y="160">', colPaths, "</g>");
}
}
}
45 changes: 45 additions & 0 deletions contracts/test/BiscuitMetadataHarness.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
31 changes: 31 additions & 0 deletions contracts/test/BiscuitRendererHarness.sol
Original file line number Diff line number Diff line change
@@ -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();
}
}
28 changes: 28 additions & 0 deletions scripts/check-data-uri.mjs
Original file line number Diff line number Diff line change
@@ -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 <svg:",
Buffer.from(imgEncoded, "base64").toString("utf8").startsWith("<svg"),
);
19 changes: 19 additions & 0 deletions scripts/render-svg.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { network } from "hardhat";
import { stringToHex } from "viem";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";

const { viem } = await network.connect();
const renderer = await viem.deployContract("BiscuitRendererHarness");

const letters = stringToHex("QUJD");
const digits = stringToHex("REVG");
const mnemonic = ["alpha", "beta", "gamma", "delta"];

const svg = await renderer.read.generateSVG([letters, digits, mnemonic]);

const outPath = "/Users/xx/Developer/biscuit/tmp/biscuit.svg";
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, svg, "utf8");

console.log(outPath);
Loading
Loading