diff --git a/contracts/BiscuitBuilder.sol b/contracts/BiscuitBuilder.sol new file mode 100644 index 0000000..e455a18 --- /dev/null +++ b/contracts/BiscuitBuilder.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IBiscuitBuilder} from "./interfaces/IBiscuitBuilder.sol"; +import {IBiscuitFont} from "./interfaces/IBiscuitFont.sol"; +import {IMnemonic} from "./interfaces/IMnemonic.sol"; +import {BiscuitMetadata} from "./libs/BiscuitMetadata.sol"; +import {BiscuitRenderer} from "./libs/BiscuitRenderer.sol"; +import {Utils} from "./libs/Utils.sol"; + +/// @title BiscuitBuilder +contract BiscuitBuilder is Ownable, IBiscuitBuilder { + /// @notice BiscuitFont contract instance + IBiscuitFont public font; + + /// @notice Mnemonic contract instance + IMnemonic public mnemonic; + + /// @notice Access modifier for font function + bool public isFontLocked; + + /// @notice Access modifier for mnemonic function + bool public isMnemonicLocked; + + /** + * @notice Require that the seeder has not been locked. + */ + modifier whenFontNotLocked() { + require(!isFontLocked, "Font is locked"); + _; + } + + /** + * @notice Require that the seeder has not been locked. + */ + modifier whenMnemonicNotLocked() { + require(!isMnemonicLocked, "Mnemonic is locked"); + _; + } + + constructor(address initialOwner, IBiscuitFont _font, IMnemonic _mnemonic) Ownable(initialOwner) { + font = _font; + mnemonic = _mnemonic; + } + + /** + * @notice Set the font. + * @dev This function can only be called by the owner. + */ + function setFont(IBiscuitFont _font) external onlyOwner whenFontNotLocked { + address oldFont = address(font); + font = _font; + + emit SetFontUpdated(oldFont, address(_font)); + } + + /** + * @notice Lock the builder. + * @dev This cannot be reversed and is only callable by the owner when not locked. + */ + function lockFont() external onlyOwner whenFontNotLocked { + require(!isFontLocked, "Already locked"); + isFontLocked = true; + + emit FontLocked(); + } + + /** + * @notice Set the Mnemonic. + * @dev This function can only be called by the owner. + */ + function setMnemonic(IMnemonic _mnemonic) external onlyOwner whenMnemonicNotLocked { + address oldMnemonic = address(mnemonic); + mnemonic = _mnemonic; + + emit SetMnemonicUpdated(oldMnemonic, address(_mnemonic)); + } + + /** + * @notice Lock the builder. + * @dev This cannot be reversed and is only callable by the owner when not locked. + */ + function lockMnemonic() external onlyOwner whenMnemonicNotLocked { + require(!isMnemonicLocked, "Already locked"); + isMnemonicLocked = true; + + emit MnemonicLocked(); + } + + /** + * @notice Given a token ID and seed, construct a token URI for a Biscuit token. + * @dev The returned value may be a base64 encoded data URI or an API URL. + */ + function tokenURI( + uint256 tokenId, + Seed calldata seed + ) external view override returns (string memory) { + return BiscuitMetadata.tokenURI(tokenId, generateSVGParams(seed)); + } + + /** + * @notice Given a seed, construct a base64 encoded SVG image. + */ + function generateSVGImage(Seed calldata seed) external view override returns (string memory) { + return BiscuitMetadata.generateSVGImage(generateSVGParams(seed)); + } + + /** + * @notice Given a seed, Get raw svg + */ + function svg(Seed calldata seed) external view override returns (string memory) { + return BiscuitRenderer._generateSVG(generateSVGParams(seed)); + } + + /** + * @notice Generates parameters necessary for SVG rendering. + */ + function generateSVGParams( + Seed calldata seed + ) internal view returns (BiscuitRenderer.SVGParams memory) { + return + BiscuitRenderer.SVGParams({ + letters: font.letters(), + digits: font.digits(), + mnemonic: mnemonic.generateMnemonic(seed.mnemonicStrength, seed.mnemonicSeed) + }); + } + + /** + * @notice Generate a pseudo-random seed using block data and tokenId. + */ + function generateSeed(uint256 tokenId) external view override returns (Seed memory) { + uint256 randomness = Utils.random(tokenId); + uint256 strengthIndex = 8; + + return + Seed({ + mnemonicSeed: keccak256(abi.encodePacked(randomness)), + mnemonicStrength: (Utils.random(randomness, strengthIndex) + 1) << 5 // Must be a multiple of 32 from 32 to 256 + }); + } +} diff --git a/contracts/BiscuitToken.sol b/contracts/BiscuitToken.sol new file mode 100644 index 0000000..830c6f3 --- /dev/null +++ b/contracts/BiscuitToken.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ERC2981} from "@openzeppelin/contracts/token/common/ERC2981.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {IBiscuitBuilder} from "./interfaces/IBiscuitBuilder.sol"; +import {IBiscuitToken} from "./interfaces/IBiscuitToken.sol"; +import {ERC721A} from "erc721a/contracts/ERC721A.sol"; + +/// @title BiscuitToken +contract BiscuitToken is IBiscuitToken, ERC721A, Ownable, ReentrancyGuard, ERC2981 { + /// @notice BiscuitBuilder contract instance + IBiscuitBuilder public builder; + + /// @notice Max supply + uint256 public constant MAX_SUPPLY = 256; + + /// @notice Mint price + uint256 public constant PRICE = 0.01 ether; + + /// @notice Access modifier for mint function + bool public isMintActive; + + /// @notice Access modifier for burn function + bool public isBurnActive; + + /// @notice Access modifier for builder function + bool public isBuilderLocked; + + /// @notice Mapping of tokenId to seed + mapping(uint256 => IBiscuitBuilder.Seed) public seeds; + + /** + * @notice Require that the seeder has not been locked. + */ + modifier whenBuilderNotLocked() { + require(!isBuilderLocked, "Builder is locked"); + _; + } + + constructor( + address initialOwner, + address receiver, + uint96 feeNumerator, + IBiscuitBuilder _builder + ) ERC721A("Biscuit", "BISCUIT") Ownable(initialOwner) { + _setDefaultRoyalty(receiver, feeNumerator); + builder = _builder; + } + + /** + * @notice Set the builder. + * @dev This function can only be called by the owner. + */ + function setBuilder(IBiscuitBuilder _builder) external onlyOwner whenBuilderNotLocked { + address oldBuilder = address(builder); + builder = _builder; + + emit BuilderUpdated(oldBuilder, address(_builder)); + } + + /** + * @notice Lock the builder. + * @dev This cannot be reversed and is only callable by the owner when not locked. + */ + function lockBuilder() external onlyOwner whenBuilderNotLocked { + isBuilderLocked = true; + + emit BuilderLocked(); + } + + /** + * @notice Securely mint the specified quantity of tokens. + * @dev To counter reentry attacks, use ReentrancyGuard to protect + * against malicious duplicate calls. + * @param quantity The number of tokens to mint. + */ + function safeMint(uint256 quantity) external payable nonReentrant { + require(isMintActive, "Biscuit Mint Not Active"); + require(totalSupply() + quantity <= MAX_SUPPLY, "Exceeds token supply"); + require(PRICE * quantity == msg.value, "Insufficient ETH"); + + uint256 startId = _nextTokenId(); + for (uint256 i = 0; i < quantity; ++i) { + seeds[startId + i] = builder.generateSeed(startId + i); + } + + _safeMint(msg.sender, quantity); + } + + /** + * @notice Mint active status updated. + * @dev This function can only be called by the owner. + */ + function setMintActive(bool _val) external onlyOwner { + isMintActive = _val; + } + + /** + * @notice Burn active status updated. + * @dev This function can only be called by the owner. + */ + function setBurnActive(bool _val) external onlyOwner { + isBurnActive = _val; + } + + /** + * @notice Burn a token. + */ + function burn(uint256 tokenId) external { + require(isBurnActive, "Burning is not active"); + _burn(tokenId, true); + + emit BiscuitBurned(tokenId); + } + + /** + * @notice Return the token URI metadata for a given token. + */ + function tokenURI(uint256 tokenId) public view override returns (string memory) { + require(_exists(tokenId), "URI query for nonexistent token"); + return builder.tokenURI(tokenId, seeds[tokenId]); + } + + /** + * @notice Render the base64-encoded SVG image for the specified token. + */ + function generateSVGImage(uint256 tokenId) public view returns (string memory) { + require(_exists(tokenId), "URI query for nonexistent token"); + return builder.generateSVGImage(seeds[tokenId]); + } + + /** + * @notice Render the SVG for a given token. + */ + function svg(uint256 tokenId) public view returns (string memory) { + require(_exists(tokenId), "URI query for nonexistent token"); + return builder.svg(seeds[tokenId]); + } + + /** + * @notice Reset default royalties. + * @dev This function can only be called by the owner. + * @param receiver New royalty recipient address. + * @param feeNumerator feeNumerator Percentage of royalties (n/10000). + */ + function setDefaultRoyalty(address receiver, uint96 feeNumerator) public onlyOwner { + require(feeNumerator <= 1000, "Royalty too high"); + _setDefaultRoyalty(receiver, feeNumerator); + + emit DefaultRoyaltySet(receiver, feeNumerator); + } + + /** + * @notice Interface support determination. + */ + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC721A, ERC2981) returns (bool) { + return ERC721A.supportsInterface(interfaceId) || ERC2981.supportsInterface(interfaceId); + } + + /** + * @notice Withdraw ether from the contract. + * @dev This function can only be called by the owner. + */ + function withdraw() external onlyOwner nonReentrant { + uint256 balance = address(this).balance; + require(balance > 0, "No ether left to withdraw"); + // send ETH to msg.sender + (bool success, ) = (msg.sender).call{value: balance}(""); + require(success, "Transfer failed."); + } +} diff --git a/contracts/Mnemonic.sol b/contracts/Mnemonic.sol index 1da4516..69f8607 100644 --- a/contracts/Mnemonic.sol +++ b/contracts/Mnemonic.sol @@ -4,9 +4,10 @@ pragma solidity ^0.8.28; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {BIP39} from "./libs/BIP39.sol"; import {BIP39Storage} from "./libs/BIP39Storage.sol"; +import {IMnemonic} from "./interfaces/IMnemonic.sol"; /// @notice Stores a BIP39 wordlist and exposes mnemonic generation/validation. -contract Mnemonic is Ownable { +contract Mnemonic is IMnemonic, Ownable { using BIP39Storage for BIP39Storage.Storage; /// @notice Revert when the wordlist is already locked. @@ -15,9 +16,6 @@ contract Mnemonic is Ownable { /// @notice Revert when attempting to lock before full registration. error WordListIncomplete(uint16 count); - /// @notice Emitted when the wordlist is locked. - event SetWordListLocked(); - /// @notice Storage structure holding a BIP39 word list of 2048 words BIP39Storage.Storage private bip39Storage; @@ -43,7 +41,10 @@ contract Mnemonic is Ownable { /** * @notice Generate a BIP39 mnemonic from the given seed. */ - function generateMnemonic(uint256 strength, bytes32 seed) public view returns (string[] memory) { + function generateMnemonic( + uint256 strength, + bytes32 seed + ) public view override returns (string[] memory) { return BIP39.generate(bip39Storage, strength, seed); } diff --git a/contracts/interfaces/IBiscuitBuilder.sol b/contracts/interfaces/IBiscuitBuilder.sol new file mode 100644 index 0000000..a38ebb2 --- /dev/null +++ b/contracts/interfaces/IBiscuitBuilder.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +interface IBiscuitBuilder { + struct Seed { + bytes32 mnemonicSeed; + uint256 mnemonicStrength; + } + + event SetFontUpdated(address, address); + + event FontLocked(); + + event SetMnemonicUpdated(address, address); + + event MnemonicLocked(); + + event BIP39Updated(address, address); + + function tokenURI(uint256 tokenId, Seed memory seed) external view returns (string memory); + + function generateSVGImage(Seed memory seed) external view returns (string memory); + + function svg(Seed memory seed) external view returns (string memory); + + function generateSeed(uint256 tokenId) external view returns (Seed memory); +} diff --git a/contracts/interfaces/IBiscuitToken.sol b/contracts/interfaces/IBiscuitToken.sol new file mode 100644 index 0000000..39e1aee --- /dev/null +++ b/contracts/interfaces/IBiscuitToken.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +interface IBiscuitToken { + event BuilderUpdated(address, address builder); + + event BuilderLocked(); + + event BiscuitBurned(uint256); + + event DefaultRoyaltySet(address, uint96); +} diff --git a/contracts/interfaces/IMnemonic.sol b/contracts/interfaces/IMnemonic.sol new file mode 100644 index 0000000..efe85cf --- /dev/null +++ b/contracts/interfaces/IMnemonic.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +interface IMnemonic { + event SetWordListLocked(); + + function generateMnemonic(uint256 strength, bytes32 seed) external view returns (string[] memory); +} diff --git a/contracts/libs/Utils.sol b/contracts/libs/Utils.sol new file mode 100644 index 0000000..32ef53c --- /dev/null +++ b/contracts/libs/Utils.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +library Utils { + /** + * @notice Generate a pseudo-random seed using block data and tokenId. + * @dev Predictable on-chain randomness. Do not use for fairness-critical use cases. + */ + function random(uint256 tokenId) internal view returns (uint256) { + return + uint256( + keccak256(abi.encodePacked(tokenId, block.prevrandao, block.timestamp, block.number)) + ); + } + + /** + * @notice Generates a pseudo-random number between 0 and '_max' + * from an arbitrary input value. + */ + function random(uint256 input, uint256 _max) internal pure returns (uint256) { + require(_max > 0, "Max must be > 0"); + return uint256(keccak256(abi.encodePacked(input))) % _max; + } +} diff --git a/contracts/test/MockMnemonic.sol b/contracts/test/MockMnemonic.sol new file mode 100644 index 0000000..1f4b986 --- /dev/null +++ b/contracts/test/MockMnemonic.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {IMnemonic} from "../interfaces/IMnemonic.sol"; + +contract MockMnemonic is IMnemonic { + string[] private _words; + + constructor(string[] memory words) { + for (uint256 i = 0; i < words.length; ++i) { + _words.push(words[i]); + } + } + + function generateMnemonic(uint256, bytes32) external view returns (string[] memory) { + string[] memory out = new string[](_words.length); + for (uint256 i = 0; i < _words.length; ++i) { + out[i] = _words[i]; + } + return out; + } +} diff --git a/contracts/test/UtilsHarness.sol b/contracts/test/UtilsHarness.sol new file mode 100644 index 0000000..7a258d5 --- /dev/null +++ b/contracts/test/UtilsHarness.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Utils} from "../libs/Utils.sol"; + +contract UtilsHarness { + function randomToken(uint256 tokenId) external view returns (uint256) { + return Utils.random(tokenId); + } + + function randomMax(uint256 input, uint256 max) external pure returns (uint256) { + return Utils.random(input, max); + } +} diff --git a/test/biscuit-builder.test.ts b/test/biscuit-builder.test.ts new file mode 100644 index 0000000..b34b984 --- /dev/null +++ b/test/biscuit-builder.test.ts @@ -0,0 +1,284 @@ +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"); +const toBigInt = (value: bigint | number) => (typeof value === "bigint" ? value : BigInt(value)); +const zeroAddress = "0x0000000000000000000000000000000000000000" as const; + +const wordsA = ["alpha", "beta", "gamma"]; +const wordsB = ["delta", "epsilon", "zeta"]; +const letters = stringToHex("QUJD"); +const digits = stringToHex("REVG"); + +type WalletClient = Awaited< + ReturnType +>["viem"]["getWalletClients"] extends () => Promise<(infer T)[]> + ? T + : never; +type ViemHarness = Awaited>["viem"]; + +async function deployFont(viem: ViemHarness, owner: WalletClient) { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + await font.write.addLetters([letters], { account: owner.account }); + await font.write.addDigits([digits], { account: owner.account }); + return font; +} + +describe("BiscuitBuilder (unit)", async () => { + const { viem } = await network.connect(); + const publicClient = await viem.getPublicClient(); + const [owner, other] = await viem.getWalletClients(); + + it("constructor: sets owner, font, mnemonic", async () => { + const font = await deployFont(viem, owner); + const mnemonic = await viem.deployContract("MockMnemonic", [wordsA]); + const builder = await viem.deployContract("BiscuitBuilder", [ + owner.account.address, + font.address, + mnemonic.address, + ]); + + const contractOwner = (await builder.read.owner()) as string; + const storedFont = (await builder.read.font()) as string; + const storedMnemonic = (await builder.read.mnemonic()) as string; + + assert.equal(contractOwner.toLowerCase(), owner.account.address.toLowerCase()); + assert.equal(storedFont.toLowerCase(), font.address.toLowerCase()); + assert.equal(storedMnemonic.toLowerCase(), mnemonic.address.toLowerCase()); + }); + + it("setFont: onlyOwner + emits event", async () => { + const fontA = await deployFont(viem, owner); + const fontB = await deployFont(viem, owner); + const mnemonic = await viem.deployContract("MockMnemonic", [wordsA]); + const builder = await viem.deployContract("BiscuitBuilder", [ + owner.account.address, + fontA.address, + mnemonic.address, + ]); + + await viem.assertions.revertWithCustomError( + builder.write.setFont([fontB.address], { account: other.account }), + builder, + "OwnableUnauthorizedAccount", + ); + + const hash = await builder.write.setFont([fontB.address], { account: owner.account }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + const events = await publicClient.getContractEvents({ + address: builder.address, + abi: builder.abi, + eventName: "SetFontUpdated", + 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(String(event.args[0]).toLowerCase(), fontA.address.toLowerCase()); + assert.equal(String(event.args[1]).toLowerCase(), fontB.address.toLowerCase()); + }); + + it("setFont: allows zero address (intentional)", async () => { + const font = await deployFont(viem, owner); + const mnemonic = await viem.deployContract("MockMnemonic", [wordsA]); + const builder = await viem.deployContract("BiscuitBuilder", [ + owner.account.address, + font.address, + mnemonic.address, + ]); + + await builder.write.setFont([zeroAddress], { account: owner.account }); + const storedFont = (await builder.read.font()) as string; + assert.equal(storedFont.toLowerCase(), zeroAddress.toLowerCase()); + }); + + it("lockFont: onlyOwner + sets flag + emits", async () => { + const font = await deployFont(viem, owner); + const mnemonic = await viem.deployContract("MockMnemonic", [wordsA]); + const builder = await viem.deployContract("BiscuitBuilder", [ + owner.account.address, + font.address, + mnemonic.address, + ]); + + await viem.assertions.revertWithCustomError( + builder.write.lockFont({ account: other.account }), + builder, + "OwnableUnauthorizedAccount", + ); + + const hash = await builder.write.lockFont({ account: owner.account }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + const events = await publicClient.getContractEvents({ + address: builder.address, + abi: builder.abi, + eventName: "FontLocked", + fromBlock: receipt.blockNumber, + toBlock: receipt.blockNumber, + }); + + assert.equal(events.length, 1); + const locked = (await builder.read.isFontLocked()) as boolean; + assert.equal(locked, true); + + await assert.rejects(builder.write.lockFont({ account: owner.account }), /Font is locked/); + }); + + it("setMnemonic: onlyOwner + emits event", async () => { + const font = await deployFont(viem, owner); + const mnemonicA = await viem.deployContract("MockMnemonic", [wordsA]); + const mnemonicB = await viem.deployContract("MockMnemonic", [wordsB]); + const builder = await viem.deployContract("BiscuitBuilder", [ + owner.account.address, + font.address, + mnemonicA.address, + ]); + + await viem.assertions.revertWithCustomError( + builder.write.setMnemonic([mnemonicB.address], { account: other.account }), + builder, + "OwnableUnauthorizedAccount", + ); + + const hash = await builder.write.setMnemonic([mnemonicB.address], { account: owner.account }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + const events = await publicClient.getContractEvents({ + address: builder.address, + abi: builder.abi, + eventName: "SetMnemonicUpdated", + 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(String(event.args[0]).toLowerCase(), mnemonicA.address.toLowerCase()); + assert.equal(String(event.args[1]).toLowerCase(), mnemonicB.address.toLowerCase()); + }); + + it("setMnemonic: allows zero address (intentional)", async () => { + const font = await deployFont(viem, owner); + const mnemonic = await viem.deployContract("MockMnemonic", [wordsA]); + const builder = await viem.deployContract("BiscuitBuilder", [ + owner.account.address, + font.address, + mnemonic.address, + ]); + + await builder.write.setMnemonic([zeroAddress], { account: owner.account }); + const storedMnemonic = (await builder.read.mnemonic()) as string; + assert.equal(storedMnemonic.toLowerCase(), zeroAddress.toLowerCase()); + }); + + it("lockMnemonic: onlyOwner + sets flag + emits", async () => { + const font = await deployFont(viem, owner); + const mnemonic = await viem.deployContract("MockMnemonic", [wordsA]); + const builder = await viem.deployContract("BiscuitBuilder", [ + owner.account.address, + font.address, + mnemonic.address, + ]); + + await viem.assertions.revertWithCustomError( + builder.write.lockMnemonic({ account: other.account }), + builder, + "OwnableUnauthorizedAccount", + ); + + const hash = await builder.write.lockMnemonic({ account: owner.account }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + const events = await publicClient.getContractEvents({ + address: builder.address, + abi: builder.abi, + eventName: "MnemonicLocked", + fromBlock: receipt.blockNumber, + toBlock: receipt.blockNumber, + }); + + assert.equal(events.length, 1); + const locked = (await builder.read.isMnemonicLocked()) as boolean; + assert.equal(locked, true); + + await assert.rejects( + builder.write.lockMnemonic({ account: owner.account }), + /Mnemonic is locked/, + ); + }); + + it("setFont/setMnemonic: blocked when locked", async () => { + const fontA = await deployFont(viem, owner); + const fontB = await deployFont(viem, owner); + const mnemonicA = await viem.deployContract("MockMnemonic", [wordsA]); + const mnemonicB = await viem.deployContract("MockMnemonic", [wordsB]); + const builder = await viem.deployContract("BiscuitBuilder", [ + owner.account.address, + fontA.address, + mnemonicA.address, + ]); + + await builder.write.lockFont({ account: owner.account }); + await builder.write.lockMnemonic({ account: owner.account }); + + await assert.rejects( + builder.write.setFont([fontB.address], { account: owner.account }), + /Font is locked/, + ); + await assert.rejects( + builder.write.setMnemonic([mnemonicB.address], { account: owner.account }), + /Mnemonic is locked/, + ); + }); + + it("tokenURI/generateSVGImage/svg: render using font and mnemonic", async () => { + const font = await deployFont(viem, owner); + const mnemonic = await viem.deployContract("MockMnemonic", [wordsA]); + const builder = await viem.deployContract("BiscuitBuilder", [ + owner.account.address, + font.address, + mnemonic.address, + ]); + + const seed = (await builder.read.generateSeed([1n])) as { + mnemonicSeed: `0x${string}`; + mnemonicStrength: bigint; + }; + + const tokenUri = (await builder.read.tokenURI([7n, seed])) as string; + const encoded = tokenUri.split(",")[1]; + const json = JSON.parse(decodeBase64(encoded)) as { image: string; name: string }; + + assert.equal(json.name, "Biscuit #7"); + assert.ok(json.image.startsWith("data:image/svg+xml;base64,")); + + const image = (await builder.read.generateSVGImage([seed])) as string; + const svg = (await builder.read.svg([seed])) as string; + + const decoded = decodeBase64(image); + assert.equal(decoded, svg); + assert.ok(svg.includes("alpha")); + assert.ok(svg.includes("beta")); + }); + + it("generateSeed: strength is within 32..256 and multiple of 32", async () => { + const font = await deployFont(viem, owner); + const mnemonic = await viem.deployContract("MockMnemonic", [wordsA]); + const builder = await viem.deployContract("BiscuitBuilder", [ + owner.account.address, + font.address, + mnemonic.address, + ]); + + const seed = (await builder.read.generateSeed([99n])) as { + mnemonicSeed: `0x${string}`; + mnemonicStrength: bigint | number; + }; + const strength = toBigInt(seed.mnemonicStrength); + assert.ok(strength >= 32n && strength <= 256n); + assert.equal(strength % 32n, 0n); + }); +}); diff --git a/test/biscuit-token.test.ts b/test/biscuit-token.test.ts new file mode 100644 index 0000000..f8bbfa3 --- /dev/null +++ b/test/biscuit-token.test.ts @@ -0,0 +1,387 @@ +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"); +const toBigInt = (value: bigint | number) => (typeof value === "bigint" ? value : BigInt(value)); +const zeroAddress = "0x0000000000000000000000000000000000000000" as const; + +const words = ["alpha", "beta", "gamma"]; +const letters = stringToHex("QUJD"); +const digits = stringToHex("REVG"); + +type WalletClient = Awaited< + ReturnType +>["viem"]["getWalletClients"] extends () => Promise<(infer T)[]> + ? T + : never; +type ViemHarness = Awaited>["viem"]; + +async function deployFont(viem: ViemHarness, owner: WalletClient) { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + await font.write.addLetters([letters], { account: owner.account }); + await font.write.addDigits([digits], { account: owner.account }); + return font; +} + +async function deployBuilder(viem: ViemHarness, owner: WalletClient) { + const font = await deployFont(viem, owner); + const mnemonic = await viem.deployContract("MockMnemonic", [words]); + const builder = await viem.deployContract("BiscuitBuilder", [ + owner.account.address, + font.address, + mnemonic.address, + ]); + return { builder, font, mnemonic }; +} + +describe("BiscuitToken (unit)", async () => { + const { viem } = await network.connect(); + const publicClient = await viem.getPublicClient(); + const [owner, receiver, other] = await viem.getWalletClients(); + + it("constructor: sets owner, builder, and royalties", async () => { + const { builder } = await deployBuilder(viem, owner); + const token = await viem.deployContract("BiscuitToken", [ + owner.account.address, + receiver.account.address, + 500n, + builder.address, + ]); + + const contractOwner = (await token.read.owner()) as string; + const storedBuilder = (await token.read.builder()) as string; + assert.equal(contractOwner.toLowerCase(), owner.account.address.toLowerCase()); + assert.equal(storedBuilder.toLowerCase(), builder.address.toLowerCase()); + + const royalty = (await token.read.royaltyInfo([1n, 10_000n])) as [string, bigint]; + assert.equal(royalty[0].toLowerCase(), receiver.account.address.toLowerCase()); + assert.equal(royalty[1], 500n); + }); + + it("setBuilder: onlyOwner + emits event", async () => { + const { builder } = await deployBuilder(viem, owner); + const { builder: builderB } = await deployBuilder(viem, owner); + const token = await viem.deployContract("BiscuitToken", [ + owner.account.address, + receiver.account.address, + 500n, + builder.address, + ]); + + await viem.assertions.revertWithCustomError( + token.write.setBuilder([builderB.address], { account: other.account }), + token, + "OwnableUnauthorizedAccount", + ); + + const hash = await token.write.setBuilder([builderB.address], { account: owner.account }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + const events = await publicClient.getContractEvents({ + address: token.address, + abi: token.abi, + eventName: "BuilderUpdated", + 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(String(event.args[0]).toLowerCase(), builder.address.toLowerCase()); + assert.equal(String(event.args[1]).toLowerCase(), builderB.address.toLowerCase()); + }); + + it("setBuilder: allows zero address (intentional)", async () => { + const { builder } = await deployBuilder(viem, owner); + const token = await viem.deployContract("BiscuitToken", [ + owner.account.address, + receiver.account.address, + 500n, + builder.address, + ]); + + await token.write.setBuilder([zeroAddress], { account: owner.account }); + const storedBuilder = (await token.read.builder()) as string; + assert.equal(storedBuilder.toLowerCase(), zeroAddress.toLowerCase()); + }); + + it("lockBuilder: onlyOwner + sets flag + emits", async () => { + const { builder } = await deployBuilder(viem, owner); + const { builder: builderB } = await deployBuilder(viem, owner); + const token = await viem.deployContract("BiscuitToken", [ + owner.account.address, + receiver.account.address, + 500n, + builder.address, + ]); + + await viem.assertions.revertWithCustomError( + token.write.lockBuilder({ account: other.account }), + token, + "OwnableUnauthorizedAccount", + ); + + const hash = await token.write.lockBuilder({ account: owner.account }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + const events = await publicClient.getContractEvents({ + address: token.address, + abi: token.abi, + eventName: "BuilderLocked", + fromBlock: receipt.blockNumber, + toBlock: receipt.blockNumber, + }); + + assert.equal(events.length, 1); + const locked = (await token.read.isBuilderLocked()) as boolean; + assert.equal(locked, true); + + await assert.rejects( + token.write.setBuilder([builderB.address], { account: owner.account }), + /Builder is locked/, + ); + }); + + it("setMintActive/setBurnActive: onlyOwner", async () => { + const { builder } = await deployBuilder(viem, owner); + const token = await viem.deployContract("BiscuitToken", [ + owner.account.address, + receiver.account.address, + 500n, + builder.address, + ]); + + await viem.assertions.revertWithCustomError( + token.write.setMintActive([true], { account: other.account }), + token, + "OwnableUnauthorizedAccount", + ); + + await viem.assertions.revertWithCustomError( + token.write.setBurnActive([true], { account: other.account }), + token, + "OwnableUnauthorizedAccount", + ); + + await token.write.setMintActive([true], { account: owner.account }); + await token.write.setBurnActive([true], { account: owner.account }); + + assert.equal(await token.read.isMintActive(), true); + assert.equal(await token.read.isBurnActive(), true); + }); + + it("safeMint: enforces mint active, price, and max supply", async () => { + const { builder } = await deployBuilder(viem, owner); + const token = await viem.deployContract("BiscuitToken", [ + owner.account.address, + receiver.account.address, + 500n, + builder.address, + ]); + + await assert.rejects( + token.write.safeMint([1n], { account: owner.account, value: 0n }), + /Biscuit Mint Not Active/, + ); + + await token.write.setMintActive([true], { account: owner.account }); + + await assert.rejects( + token.write.safeMint([1n], { account: owner.account, value: 0n }), + /Insufficient ETH/, + ); + + const maxSupply = (await token.read.MAX_SUPPLY()) as bigint | number; + await assert.rejects( + token.write.safeMint([toBigInt(maxSupply) + 1n], { account: owner.account, value: 0n }), + /Exceeds token supply/, + ); + }); + + it("safeMint: mints and stores seeds", async () => { + const { builder } = await deployBuilder(viem, owner); + const token = await viem.deployContract("BiscuitToken", [ + owner.account.address, + receiver.account.address, + 500n, + builder.address, + ]); + + await token.write.setMintActive([true], { account: owner.account }); + const price = (await token.read.PRICE()) as bigint | number; + const quantity = 2n; + await token.write.safeMint([quantity], { + account: owner.account, + value: toBigInt(price) * quantity, + }); + + const totalSupply = (await token.read.totalSupply()) as bigint | number; + assert.equal(toBigInt(totalSupply), quantity); + + const ownerOf0 = (await token.read.ownerOf([0n])) as string; + assert.equal(ownerOf0.toLowerCase(), owner.account.address.toLowerCase()); + + const seed0 = (await token.read.seeds([0n])) as { + mnemonicSeed?: `0x${string}`; + mnemonicStrength?: bigint | number; + 0?: `0x${string}`; + 1?: bigint | number; + }; + const strength = toBigInt(seed0.mnemonicStrength ?? seed0[1] ?? 0n); + assert.ok(strength >= 32n && strength <= 256n); + }); + + it("burn: requires burn active and ownership", async () => { + const { builder } = await deployBuilder(viem, owner); + const token = await viem.deployContract("BiscuitToken", [ + owner.account.address, + receiver.account.address, + 500n, + builder.address, + ]); + + await token.write.setMintActive([true], { account: owner.account }); + const price = (await token.read.PRICE()) as bigint | number; + await token.write.safeMint([1n], { account: owner.account, value: toBigInt(price) }); + + await assert.rejects( + token.write.burn([0n], { account: owner.account }), + /Burning is not active/, + ); + + await token.write.setBurnActive([true], { account: owner.account }); + const hash = await token.write.burn([0n], { account: owner.account }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + const events = await publicClient.getContractEvents({ + address: token.address, + abi: token.abi, + eventName: "BiscuitBurned", + fromBlock: receipt.blockNumber, + toBlock: receipt.blockNumber, + }); + assert.equal(events.length, 1); + + await assert.rejects(token.read.ownerOf([0n])); + }); + + it("tokenURI/generateSVGImage/svg: require token existence", async () => { + const { builder } = await deployBuilder(viem, owner); + const token = await viem.deployContract("BiscuitToken", [ + owner.account.address, + receiver.account.address, + 500n, + builder.address, + ]); + + await assert.rejects(token.read.tokenURI([0n]), /URI query for nonexistent token/); + await assert.rejects(token.read.generateSVGImage([0n]), /URI query for nonexistent token/); + await assert.rejects(token.read.svg([0n]), /URI query for nonexistent token/); + }); + + it("tokenURI/generateSVGImage/svg: render minted token", async () => { + const { builder } = await deployBuilder(viem, owner); + const token = await viem.deployContract("BiscuitToken", [ + owner.account.address, + receiver.account.address, + 500n, + builder.address, + ]); + + await token.write.setMintActive([true], { account: owner.account }); + const price = (await token.read.PRICE()) as bigint | number; + await token.write.safeMint([1n], { account: owner.account, value: toBigInt(price) }); + + const tokenUri = (await token.read.tokenURI([0n])) as string; + const encoded = tokenUri.split(",")[1]; + const json = JSON.parse(decodeBase64(encoded)) as { image: string }; + assert.ok(json.image.startsWith("data:image/svg+xml;base64,")); + + const image = (await token.read.generateSVGImage([0n])) as string; + const svg = (await token.read.svg([0n])) as string; + const decoded = decodeBase64(image); + assert.equal(decoded, svg); + assert.ok(svg.includes("alpha")); + }); + + it("setDefaultRoyalty: onlyOwner and fee cap", async () => { + const { builder } = await deployBuilder(viem, owner); + const token = await viem.deployContract("BiscuitToken", [ + owner.account.address, + receiver.account.address, + 500n, + builder.address, + ]); + + await viem.assertions.revertWithCustomError( + token.write.setDefaultRoyalty([receiver.account.address, 500n], { account: other.account }), + token, + "OwnableUnauthorizedAccount", + ); + + await assert.rejects( + token.write.setDefaultRoyalty([receiver.account.address, 2000n], { account: owner.account }), + /Royalty too high/, + ); + + const hash = await token.write.setDefaultRoyalty([receiver.account.address, 250n], { + account: owner.account, + }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + const events = await publicClient.getContractEvents({ + address: token.address, + abi: token.abi, + eventName: "DefaultRoyaltySet", + fromBlock: receipt.blockNumber, + toBlock: receipt.blockNumber, + }); + + assert.equal(events.length, 1); + const royalty = (await token.read.royaltyInfo([1n, 10_000n])) as [string, bigint]; + assert.equal(royalty[1], 250n); + }); + + it("supportsInterface: ERC721 & ERC2981", async () => { + const { builder } = await deployBuilder(viem, owner); + const token = await viem.deployContract("BiscuitToken", [ + owner.account.address, + receiver.account.address, + 500n, + builder.address, + ]); + + const ERC721_ID = "0x80ac58cd" as const; + const ERC2981_ID = "0x2a55205a" as const; + const ERC165_ID = "0x01ffc9a7" as const; + + assert.equal(await token.read.supportsInterface([ERC721_ID]), true); + assert.equal(await token.read.supportsInterface([ERC2981_ID]), true); + assert.equal(await token.read.supportsInterface([ERC165_ID]), true); + }); + + it("withdraw: onlyOwner and non-zero balance", async () => { + const { builder } = await deployBuilder(viem, owner); + const token = await viem.deployContract("BiscuitToken", [ + owner.account.address, + receiver.account.address, + 500n, + builder.address, + ]); + + await assert.rejects( + token.write.withdraw({ account: owner.account }), + /No ether left to withdraw/, + ); + + await token.write.setMintActive([true], { account: owner.account }); + const price = (await token.read.PRICE()) as bigint | number; + await token.write.safeMint([1n], { account: owner.account, value: toBigInt(price) }); + + const balanceBefore = await publicClient.getBalance({ address: token.address }); + assert.equal(balanceBefore, toBigInt(price)); + + await token.write.withdraw({ account: owner.account }); + const balanceAfter = await publicClient.getBalance({ address: token.address }); + assert.equal(balanceAfter, 0n); + }); +}); diff --git a/test/integration/biscuit-stack-integration.test.ts b/test/integration/biscuit-stack-integration.test.ts new file mode 100644 index 0000000..8edbb12 --- /dev/null +++ b/test/integration/biscuit-stack-integration.test.ts @@ -0,0 +1,120 @@ +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"); +const toBigInt = (value: bigint | number) => (typeof value === "bigint" ? value : BigInt(value)); + +const letters = stringToHex("QUJD"); +const digits = stringToHex("REVG"); +const wordsA = ["alpha", "beta", "gamma"]; +const wordsB = ["delta", "epsilon", "zeta"]; + +type WalletClient = Awaited< + ReturnType +>["viem"]["getWalletClients"] extends () => Promise<(infer T)[]> + ? T + : never; +type ViemHarness = Awaited>["viem"]; + +async function deployFont(viem: ViemHarness, owner: WalletClient) { + const font = await viem.deployContract("BiscuitFont", [owner.account.address]); + await font.write.addLetters([letters], { account: owner.account }); + await font.write.addDigits([digits], { account: owner.account }); + return font; +} + +describe("BiscuitBuilder + BiscuitToken (integration)", async () => { + const { viem } = await network.connect(); + const publicClient = await viem.getPublicClient(); + const [owner, receiver] = await viem.getWalletClients(); + + it("end-to-end flow across builder and token", async () => { + const fontA = await deployFont(viem, owner); + const fontB = await deployFont(viem, owner); + const mnemonicA = await viem.deployContract("MockMnemonic", [wordsA]); + const mnemonicB = await viem.deployContract("MockMnemonic", [wordsB]); + + const builderA = await viem.deployContract("BiscuitBuilder", [ + owner.account.address, + fontA.address, + mnemonicA.address, + ]); + + assert.equal(await builderA.read.isFontLocked(), false); + assert.equal(await builderA.read.isMnemonicLocked(), false); + assert.equal((await builderA.read.font()).toLowerCase(), fontA.address.toLowerCase()); + assert.equal((await builderA.read.mnemonic()).toLowerCase(), mnemonicA.address.toLowerCase()); + + const seed = (await builderA.read.generateSeed([1n])) as { + mnemonicSeed: `0x${string}`; + mnemonicStrength: bigint; + }; + + await builderA.write.setFont([fontB.address], { account: owner.account }); + await builderA.write.setMnemonic([mnemonicB.address], { account: owner.account }); + + const svg = (await builderA.read.svg([seed])) as string; + const image = (await builderA.read.generateSVGImage([seed])) as string; + const tokenUri = (await builderA.read.tokenURI([7n, seed])) as string; + + assert.equal(decodeBase64(image), svg); + assert.ok(tokenUri.startsWith("data:application/json;base64,")); + + await builderA.write.lockFont({ account: owner.account }); + await builderA.write.lockMnemonic({ account: owner.account }); + assert.equal(await builderA.read.isFontLocked(), true); + assert.equal(await builderA.read.isMnemonicLocked(), true); + + const builderB = await viem.deployContract("BiscuitBuilder", [ + owner.account.address, + fontA.address, + mnemonicA.address, + ]); + + const token = await viem.deployContract("BiscuitToken", [ + owner.account.address, + receiver.account.address, + 500n, + builderA.address, + ]); + + assert.equal(await token.read.isMintActive(), false); + assert.equal(await token.read.isBurnActive(), false); + assert.equal(await token.read.isBuilderLocked(), false); + assert.equal((await token.read.builder()).toLowerCase(), builderA.address.toLowerCase()); + + await token.write.setBuilder([builderB.address], { account: owner.account }); + await token.write.lockBuilder({ account: owner.account }); + + await token.write.setMintActive([true], { account: owner.account }); + await token.write.setBurnActive([true], { account: owner.account }); + + const price = (await token.read.PRICE()) as bigint | number; + await token.write.safeMint([1n], { account: owner.account, value: toBigInt(price) }); + + const mintedUri = (await token.read.tokenURI([0n])) as string; + const encoded = mintedUri.split(",")[1]; + const json = JSON.parse(decodeBase64(encoded)) as { image: string }; + assert.ok(json.image.startsWith("data:image/svg+xml;base64,")); + + const mintedImage = (await token.read.generateSVGImage([0n])) as string; + const mintedSvg = (await token.read.svg([0n])) as string; + assert.equal(decodeBase64(mintedImage), mintedSvg); + + await token.write.burn([0n], { account: owner.account }); + + await token.write.setDefaultRoyalty([receiver.account.address, 250n], { + account: owner.account, + }); + const supportsErc721 = await token.read.supportsInterface(["0x80ac58cd"]); + assert.equal(supportsErc721, true); + + const balanceBefore = await publicClient.getBalance({ address: token.address }); + await token.write.withdraw({ account: owner.account }); + const balanceAfter = await publicClient.getBalance({ address: token.address }); + assert.equal(balanceBefore, toBigInt(price)); + assert.equal(balanceAfter, 0n); + }); +}); diff --git a/test/utils.test.ts b/test/utils.test.ts new file mode 100644 index 0000000..dd0bf77 --- /dev/null +++ b/test/utils.test.ts @@ -0,0 +1,27 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { network } from "hardhat"; + +const toBigInt = (value: bigint | number) => (typeof value === "bigint" ? value : BigInt(value)); + +describe("Utils (unit)", async () => { + const { viem } = await network.connect(); + + it("random(tokenId): returns a value", async () => { + const utils = await viem.deployContract("UtilsHarness"); + const value = (await utils.read.randomToken([123n])) as bigint | number; + assert.equal(typeof toBigInt(value), "bigint"); + }); + + it("random(input, max): returns < max", async () => { + const utils = await viem.deployContract("UtilsHarness"); + const max = 10n; + const value = (await utils.read.randomMax([456n, max])) as bigint | number; + assert.ok(toBigInt(value) < max); + }); + + it("random(input, max): reverts on max == 0", async () => { + const utils = await viem.deployContract("UtilsHarness"); + await assert.rejects(utils.read.randomMax([1n, 0n]), /Max must be > 0/); + }); +});