diff --git a/.gitignore b/.gitignore index bdc87107e..d2133aa3a 100644 --- a/.gitignore +++ b/.gitignore @@ -120,3 +120,9 @@ package-lock.json # Test config Assets/SequenceSDK/WaaS/Tests/Resources/ + +# Foundry + +/testchain/lib +/testchain/artifacts/* + diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..7a6908774 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "testchain/lib/contracts-library"] + path = testchain/lib/contracts-library + url = https://github.com/0xsequence/contracts-library.git +[submodule "testchain/lib/forge-std"] + path = testchain/lib/forge-std + url = https://github.com/foundry-rs/forge-stdy diff --git a/testchain/README.md b/testchain/README.md new file mode 100644 index 000000000..1e06d0ddc --- /dev/null +++ b/testchain/README.md @@ -0,0 +1,55 @@ +# Testchain +This project contains a hardhat testchain which can be run with `yarn start:hardhat`. + +In addition, we've included some smart contracts. These are compiled with Foundry/Forge. + +## Foundry + +**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** + +Foundry consists of: + +- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). +- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. +- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. +- **Chisel**: Fast, utilitarian, and verbose solidity REPL. + +## Documentation + +https://book.getfoundry.sh/ + +## Installation +https://book.getfoundry.sh/getting-started/installation + +## Usage + +### Build + +From /testchain +```shell +$ forge build +``` + +This will dump compiled contracts into `/artifacts` + +### Installing/upgrading dependencies + +This project has an unusual setup as it is nested in a much larger monorepo. In order to install or update Sequence dependancies please perform the following: + +```shell +cd .. +forge install https://github.com/0xsequence/contracts-library.git --no-commit +``` +Replace the url with another git url if using dependencies from elsewhere. + +This will install at `../lib/`. We want to move here. + +```shell +cd .. +ls lib/ (find out the name of the folder to move, in this case contracts-library) +mv lib/contracts-library testchain/lib/ +``` + +Update entry in .gitmodules + +Update foundry.toml such that remappings are correct diff --git a/testchain/contracts/BurnToMint.sol b/testchain/contracts/BurnToMint.sol new file mode 100644 index 000000000..24c11f6a7 --- /dev/null +++ b/testchain/contracts/BurnToMint.sol @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.19; + +import {IERC1155} from "@0xsequence/erc-1155/contracts/interfaces/IERC1155.sol"; +import {IERC1155TokenReceiver} from "@0xsequence/erc-1155/contracts/interfaces/IERC1155TokenReceiver.sol"; +import {IERC1155ItemsFunctions} from "@0xsequence/contracts-library/tokens/ERC1155/presets/items/IERC1155Items.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +// Thrown when token id is invalid +error InvalidTokenId(); + +// Thrown when mint requirements are not met +error MintRequirementsNotMet(); + +// Thrown when input array length is invalid +error InvalidArrayLength(); + +// Thrown when method called is invalid +error InvalidMethod(); + +interface IERC1155Items is IERC1155ItemsFunctions, IERC1155 { + function batchBurn(uint256[] memory tokenIds, uint256[] memory amounts) external; +} + +struct TokenRequirements { + uint256 tokenId; + uint256 amount; +} + +contract ERC1155BurnToMint is IERC1155TokenReceiver, Ownable { + IERC1155Items private immutable ITEMS; + + mapping(uint256 => TokenRequirements[]) public burnRequirements; + mapping(uint256 => TokenRequirements[]) public holdRequirements; + + constructor(address items, address owner_) { + Ownable.transferOwnership(owner_); + ITEMS = IERC1155Items(items); + } + + /** + * Contract owner can mint anything + */ + function mintOpen(address to, uint256 tokenId, uint256 amount) external onlyOwner { + ITEMS.mint(to, tokenId, amount, ""); + } + + /** + * Owner sets minting requirements for a token. + * @dev This function does not validate inputs ids of the inputs. + * @dev `burnTokenIds` and `holdTokenIds` should not overlap, should be unique and should not contain `mintTokenId`. + */ + function setMintRequirements( + uint256 mintTokenId, + uint256[] calldata burnTokenIds, + uint256[] calldata burnAmounts, + uint256[] calldata holdTokenIds, + uint256[] calldata holdAmounts + ) + external + onlyOwner + { + if (burnTokenIds.length != burnAmounts.length || holdTokenIds.length != holdAmounts.length) { + revert InvalidArrayLength(); + } + + delete burnRequirements[mintTokenId]; + delete holdRequirements[mintTokenId]; + for (uint256 i = 0; i < burnTokenIds.length; i++) { + burnRequirements[mintTokenId].push(TokenRequirements(burnTokenIds[i], burnAmounts[i])); + } + for (uint256 i = 0; i < holdTokenIds.length; i++) { + holdRequirements[mintTokenId].push(TokenRequirements(holdTokenIds[i], holdAmounts[i])); + } + } + + /** + * @notice Use `onERC1155BatchReceived` instead. + */ + function onERC1155Received(address, address, uint256, uint256, bytes calldata) + external + pure + override + returns (bytes4) + { + revert InvalidMethod(); + } + + /** + * Receive tokens for burning and mint new token. + * @dev `data` is abi.encode(mintTokenId). + */ + function onERC1155BatchReceived( + address, + address from, + uint256[] calldata tokenIds, + uint256[] calldata amounts, + bytes calldata data + ) + external + override + returns (bytes4) + { + if (msg.sender != address(ITEMS)) { + // Got tokens from incorrect contract + revert MintRequirementsNotMet(); + } + + // Check mint requirements + uint256 mintTokenId = abi.decode(data, (uint256)); + _checkMintRequirements(from, mintTokenId, tokenIds, amounts); + + // Burn these tokens and mint the new token + ITEMS.batchBurn(tokenIds, amounts); + ITEMS.mint(from, mintTokenId, 1, ""); + + return this.onERC1155BatchReceived.selector; + } + + /** + * Checks mint requirements for a token. + * @dev This function assumes the `burnTokenIds` and `burnAmounts` have been burned. + */ + function _checkMintRequirements( + address holder, + uint256 mintTokenId, + uint256[] calldata burnTokenIds, + uint256[] calldata burnAmounts + ) + internal + view + { + if (burnTokenIds.length != burnAmounts.length || burnTokenIds.length == 0) { + revert InvalidArrayLength(); + } + + // Check burn tokens sent is correct + TokenRequirements[] memory requirements = burnRequirements[mintTokenId]; + if (requirements.length != burnTokenIds.length) { + revert MintRequirementsNotMet(); + } + for (uint256 i = 0; i < requirements.length; i++) { + if (requirements[i].tokenId != burnTokenIds[i] || requirements[i].amount != burnAmounts[i]) { + // Invalid burn token id or amount + revert MintRequirementsNotMet(); + } + } + + // Check held tokens + requirements = holdRequirements[mintTokenId]; + if (requirements.length != 0) { + address[] memory holders = new address[](requirements.length); + uint256[] memory holdTokenIds = new uint256[](requirements.length); + for (uint256 i = 0; i < requirements.length; i++) { + holders[i] = holder; + holdTokenIds[i] = requirements[i].tokenId; + } + uint256[] memory balances = ITEMS.balanceOfBatch(holders, holdTokenIds); + for (uint256 i = 0; i < requirements.length; i++) { + if (balances[i] < requirements[i].amount) { + // Not enough held tokens + revert MintRequirementsNotMet(); + } + } + } + } + + function getMintRequirements(uint256 mintTokenId) + external + view + returns ( + uint256[] memory burnIds, + uint256[] memory burnAmounts, + uint256[] memory holdIds, + uint256[] memory holdAmounts + ) + { + TokenRequirements[] memory requirements = burnRequirements[mintTokenId]; + uint256 requirementsLength = requirements.length; + burnIds = new uint256[](requirementsLength); + burnAmounts = new uint256[](requirementsLength); + for (uint256 i = 0; i < requirementsLength; i++) { + burnIds[i] = requirements[i].tokenId; + burnAmounts[i] = requirements[i].amount; + } + + requirements = holdRequirements[mintTokenId]; + requirementsLength = requirements.length; + holdIds = new uint256[](requirementsLength); + holdAmounts = new uint256[](requirementsLength); + for (uint256 i = 0; i < requirementsLength; i++) { + holdIds[i] = requirements[i].tokenId; + holdAmounts[i] = requirements[i].amount; + } + } +} \ No newline at end of file diff --git a/testchain/contracts/ERC1155Sale.sol b/testchain/contracts/ERC1155Sale.sol new file mode 100644 index 000000000..9fd3ccec5 --- /dev/null +++ b/testchain/contracts/ERC1155Sale.sol @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.19; + +import { + IERC1155Sale, + IERC1155SaleFunctions +} from "@0xsequence/contracts-library/tokens/ERC1155/utility/sale/IERC1155Sale.sol"; +import {ERC1155Supply} from "@0xsequence/contracts-library/tokens/ERC1155/extensions/supply/ERC1155Supply.sol"; +import { + WithdrawControlled, + AccessControlEnumerable, + SafeERC20, + IERC20 +} from "@0xsequence/contracts-library/tokens/common/WithdrawControlled.sol"; +import {MerkleProofSingleUse} from "@0xsequence/contracts-library/tokens/common/MerkleProofSingleUse.sol"; + +import {IERC1155} from "@0xsequence/erc-1155/contracts/interfaces/IERC1155.sol"; +import {IERC1155SupplyFunctions} from + "@0xsequence/contracts-library/tokens/ERC1155/extensions/supply/IERC1155Supply.sol"; +import {IERC1155ItemsFunctions} from "@0xsequence/contracts-library/tokens/ERC1155/presets/items/IERC1155Items.sol"; + +contract ERC1155Sale is IERC1155Sale, WithdrawControlled, MerkleProofSingleUse { + bytes32 internal constant MINT_ADMIN_ROLE = keccak256("MINT_ADMIN_ROLE"); + + bool private _initialized; + address private _items; + + // ERC20 token address for payment. address(0) indicated payment in ETH. + address private _paymentToken; + + SaleDetails private _globalSaleDetails; + mapping(uint256 => SaleDetails) private _tokenSaleDetails; + + /** + * Initialize the contract. + * @param owner Owner address + * @param items The ERC-1155 Items contract address + * @dev This should be called immediately after deployment. + */ + function initialize(address owner, address items) public virtual { + if (_initialized) { + revert InvalidInitialization(); + } + + _items = items; + + _grantRole(DEFAULT_ADMIN_ROLE, owner); + _grantRole(MINT_ADMIN_ROLE, owner); + _grantRole(WITHDRAW_ROLE, owner); + + _initialized = true; + } + + /** + * Checks if the current block.timestamp is out of the give timestamp range. + * @param _startTime Earliest acceptable timestamp (inclusive). + * @param _endTime Latest acceptable timestamp (exclusive). + * @dev A zero endTime value is always considered out of bounds. + */ + function _blockTimeOutOfBounds(uint256 _startTime, uint256 _endTime) private view returns (bool) { + // 0 end time indicates inactive sale. + return _endTime == 0 || block.timestamp < _startTime || block.timestamp >= _endTime; // solhint-disable-line not-rely-on-time + } + + /** + * Checks the sale is active and takes payment. + * @param _tokenIds Token IDs to mint. + * @param _amounts Amounts of tokens to mint. + * @param _expectedPaymentToken ERC20 token address to accept payment in. address(0) indicates ETH. + * @param _maxTotal Maximum amount of payment tokens. + * @param _proof Merkle proof for allowlist minting. + */ + function _payForActiveMint( + uint256[] memory _tokenIds, + uint256[] memory _amounts, + address _expectedPaymentToken, + uint256 _maxTotal, + bytes32[] calldata _proof + ) + private + { + uint256 lastTokenId; + uint256 totalCost; + uint256 totalAmount; + + SaleDetails memory gSaleDetails = _globalSaleDetails; + bool globalSaleInactive = _blockTimeOutOfBounds(gSaleDetails.startTime, gSaleDetails.endTime); + bool globalMerkleCheckRequired = false; + for (uint256 i; i < _tokenIds.length; i++) { + uint256 tokenId = _tokenIds[i]; + // Test tokenIds ordering + if (i != 0 && lastTokenId >= tokenId) { + revert InvalidTokenIds(); + } + lastTokenId = tokenId; + + uint256 amount = _amounts[i]; + + // Active sale test + SaleDetails memory saleDetails = _tokenSaleDetails[tokenId]; + bool tokenSaleInactive = _blockTimeOutOfBounds(saleDetails.startTime, saleDetails.endTime); + if (tokenSaleInactive) { + // Prefer token sale + if (globalSaleInactive) { + // Both sales inactive + revert SaleInactive(tokenId); + } + // Use global sale details + globalMerkleCheckRequired = true; + totalCost += gSaleDetails.cost * amount; + } else { + // Use token sale details + requireMerkleProof(saleDetails.merkleRoot, _proof, msg.sender, bytes32(tokenId)); + totalCost += saleDetails.cost * amount; + } + totalAmount += amount; + } + + if (globalMerkleCheckRequired) { + // Check it once outside the loop only when required + requireMerkleProof(gSaleDetails.merkleRoot, _proof, msg.sender, bytes32(type(uint256).max)); + } + + if (_expectedPaymentToken != _paymentToken) { + // Caller expected different payment token + revert InsufficientPayment(_paymentToken, totalCost, 0); + } + if (_maxTotal < totalCost) { + // Caller expected to pay less + revert InsufficientPayment(_expectedPaymentToken, totalCost, _maxTotal); + } + if (_expectedPaymentToken == address(0)) { + // Paid in ETH + if (msg.value != totalCost) { + // We expect exact value match + revert InsufficientPayment(_expectedPaymentToken, totalCost, msg.value); + } + } else if (msg.value > 0) { + // Paid in ERC20, but sent ETH + revert InsufficientPayment(address(0), 0, msg.value); + } else { + // Paid in ERC20 + SafeERC20.safeTransferFrom(IERC20(_expectedPaymentToken), msg.sender, address(this), totalCost); + } + } + + // + // Minting + // + + /** + * Mint tokens. + * @param to Address to mint tokens to. + * @param tokenIds Token IDs to mint. + * @param amounts Amounts of tokens to mint. + * @param data Data to pass if receiver is contract. + * @param expectedPaymentToken ERC20 token address to accept payment in. address(0) indicates ETH. + * @param maxTotal Maximum amount of payment tokens. + * @param proof Merkle proof for allowlist minting. + * @notice Sale must be active for all tokens. + * @dev tokenIds must be sorted ascending without duplicates. + * @dev An empty proof is supplied when no proof is required. + */ + function mint( + address to, + uint256[] memory tokenIds, + uint256[] memory amounts, + bytes memory data, + address expectedPaymentToken, + uint256 maxTotal, + bytes32[] calldata proof + ) + public + payable + { + _payForActiveMint(tokenIds, amounts, expectedPaymentToken, maxTotal, proof); + + IERC1155SupplyFunctions items = IERC1155SupplyFunctions(_items); + uint256 totalAmount = 0; + uint256 nMint = tokenIds.length; + for (uint256 i = 0; i < nMint; i++) { + // Update storage balance + uint256 tokenSupplyCap = _tokenSaleDetails[tokenIds[i]].supplyCap; + if ( + tokenSupplyCap > 0 && items.tokenSupply(tokenIds[i]) + amounts[i] > tokenSupplyCap + ) { + revert InsufficientSupply(items.tokenSupply(tokenIds[i]), amounts[i], tokenSupplyCap); + } + totalAmount += amounts[i]; + } + uint256 totalSupplyCap = _globalSaleDetails.supplyCap; + if (totalSupplyCap > 0 && items.totalSupply() + totalAmount > totalSupplyCap) { + revert InsufficientSupply(items.totalSupply(), totalAmount, totalSupplyCap); + } + + IERC1155ItemsFunctions(_items).batchMint(to, tokenIds, amounts, data); + } + + // + // Admin + // + + /** + * Set the payment token. + * @param paymentTokenAddr The ERC20 token address to accept payment in. address(0) indicates ETH. + * @dev This should be set before the sale starts. + */ + function setPaymentToken(address paymentTokenAddr) public onlyRole(MINT_ADMIN_ROLE) { + _paymentToken = paymentTokenAddr; + } + + /** + * Set the global sale details. + * @param cost The amount of payment tokens to accept for each token minted. + * @param supplyCap The maximum number of tokens that can be minted. + * @param startTime The start time of the sale. Tokens cannot be minted before this time. + * @param endTime The end time of the sale. Tokens cannot be minted after this time. + * @param merkleRoot The merkle root for allowlist minting. + * @dev A zero end time indicates an inactive sale. + * @notice The payment token is set globally. + */ + function setGlobalSaleDetails( + uint256 cost, + uint256 supplyCap, + uint64 startTime, + uint64 endTime, + bytes32 merkleRoot + ) + public + onlyRole(MINT_ADMIN_ROLE) + { + // solhint-disable-next-line not-rely-on-time + if (endTime < startTime || endTime <= block.timestamp) { + revert InvalidSaleDetails(); + } + _globalSaleDetails = SaleDetails(cost, supplyCap, startTime, endTime, merkleRoot); + emit GlobalSaleDetailsUpdated(cost, supplyCap, startTime, endTime, merkleRoot); + } + + /** + * Set the sale details for an individual token. + * @param tokenId The token ID to set the sale details for. + * @param cost The amount of payment tokens to accept for each token minted. + * @param supplyCap The maximum number of tokens that can be minted. + * @param startTime The start time of the sale. Tokens cannot be minted before this time. + * @param endTime The end time of the sale. Tokens cannot be minted after this time. + * @param merkleRoot The merkle root for allowlist minting. + * @dev A zero end time indicates an inactive sale. + * @notice The payment token is set globally. + */ + function setTokenSaleDetails( + uint256 tokenId, + uint256 cost, + uint256 supplyCap, + uint64 startTime, + uint64 endTime, + bytes32 merkleRoot + ) + public + onlyRole(MINT_ADMIN_ROLE) + { + // solhint-disable-next-line not-rely-on-time + if (endTime < startTime || endTime <= block.timestamp) { + revert InvalidSaleDetails(); + } + _tokenSaleDetails[tokenId] = SaleDetails(cost, supplyCap, startTime, endTime, merkleRoot); + emit TokenSaleDetailsUpdated(tokenId, cost, supplyCap, startTime, endTime, merkleRoot); + } + + // + // Views + // + + /** + * Get global sales details. + * @return Sale details. + * @notice Global sales details apply to all tokens. + * @notice Global sales details are overriden when token sale is active. + */ + function globalSaleDetails() external view returns (SaleDetails memory) { + return _globalSaleDetails; + } + + /** + * Get token sale details. + * @param tokenId Token ID to get sale details for. + * @return Sale details. + * @notice Token sale details override global sale details. + */ + function tokenSaleDetails(uint256 tokenId) external view returns (SaleDetails memory) { + return _tokenSaleDetails[tokenId]; + } + + /** + * Get payment token. + * @return Payment token address. + * @notice address(0) indicates payment in ETH. + */ + function paymentToken() external view returns (address) { + return _paymentToken; + } + + /** + * Check interface support. + * @param interfaceId Interface id + * @return True if supported + */ + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override (AccessControlEnumerable) + returns (bool) + { + return type(IERC1155SaleFunctions).interfaceId == interfaceId || super.supportsInterface(interfaceId); + } +} \ No newline at end of file diff --git a/testchain/contracts/IERC1155Sale.sol b/testchain/contracts/IERC1155Sale.sol new file mode 100644 index 000000000..eff2cc71a --- /dev/null +++ b/testchain/contracts/IERC1155Sale.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.19; + +interface IERC1155SaleFunctions { + + struct SaleDetails { + uint256 cost; + uint256 supplyCap; // 0 supply cap indicates unlimited supply + uint64 startTime; + uint64 endTime; // 0 end time indicates sale inactive + bytes32 merkleRoot; // Root of allowed addresses + } + + /** + * Get global sales details. + * @return Sale details. + * @notice Global sales details apply to all tokens. + * @notice Global sales details are overriden when token sale is active. + */ + function globalSaleDetails() external view returns (SaleDetails memory); + + /** + * Get token sale details. + * @param tokenId Token ID to get sale details for. + * @return Sale details. + * @notice Token sale details override global sale details. + */ + function tokenSaleDetails(uint256 tokenId) external view returns (SaleDetails memory); + + /** + * Get payment token. + * @return Payment token address. + * @notice address(0) indicates payment in ETH. + */ + function paymentToken() external view returns (address); + + /** + * Mint tokens. + * @param to Address to mint tokens to. + * @param tokenIds Token IDs to mint. + * @param amounts Amounts of tokens to mint. + * @param data Data to pass if receiver is contract. + * @param paymentToken ERC20 token address to accept payment in. address(0) indicates ETH. + * @param maxTotal Maximum amount of payment tokens. + * @param proof Merkle proof for allowlist minting. + * @notice Sale must be active for all tokens. + * @dev tokenIds must be sorted ascending without duplicates. + * @dev An empty proof is supplied when no proof is required. + */ + function mint( + address to, + uint256[] memory tokenIds, + uint256[] memory amounts, + bytes memory data, + address paymentToken, + uint256 maxTotal, + bytes32[] calldata proof + ) + external + payable; +} + +interface IERC1155SaleSignals { + + event GlobalSaleDetailsUpdated(uint256 cost, uint256 supplyCap, uint64 startTime, uint64 endTime, bytes32 merkleRoot); + event TokenSaleDetailsUpdated(uint256 tokenId, uint256 cost, uint256 supplyCap, uint64 startTime, uint64 endTime, bytes32 merkleRoot); + + /** + * Contract already initialized. + */ + error InvalidInitialization(); + + /** + * Sale details supplied are invalid. + */ + error InvalidSaleDetails(); + + /** + * Sale is not active globally. + */ + error GlobalSaleInactive(); + + /** + * Sale is not active. + * @param tokenId Invalid Token ID. + */ + error SaleInactive(uint256 tokenId); + + /** + * Insufficient tokens for payment. + * @param currency Currency address. address(0) indicates ETH. + * @param expected Expected amount of tokens. + * @param actual Actual amount of tokens. + */ + error InsufficientPayment(address currency, uint256 expected, uint256 actual); + + /** + * Invalid token IDs. + */ + error InvalidTokenIds(); + + /** + * Insufficient supply of tokens. + */ + error InsufficientSupply(uint256 currentSupply, uint256 requestedAmount, uint256 maxSupply); +} + +interface IERC1155Sale is IERC1155SaleFunctions, IERC1155SaleSignals {} \ No newline at end of file diff --git a/testchain/foundry.toml b/testchain/foundry.toml new file mode 100644 index 000000000..9a3414cf1 --- /dev/null +++ b/testchain/foundry.toml @@ -0,0 +1,24 @@ +[profile.default] +src = "contracts" +out = "artifacts" +libs = [ + "lib", + "node_modules", +] +remappings = [ + "@0xsequence/contracts-library/=lib/contracts-library/src/", + "@0xsequence/erc-1155/=lib/contracts-library/lib/0xsequence/erc-1155/src/", + "@0xsequence/erc20-meta-token/=lib/contracts-library/lib/0xsequence/erc20-meta-token/src/", + "@openzeppelin-upgradeable/=lib/contracts-library/lib/openzeppelin-contracts-upgradeable/", + "@openzeppelin/=node_modules/@openzeppelin/", + "contracts-library/=lib/contracts-library/src/", + "ds-test/=lib/contracts-library/lib/forge-std/lib/ds-test/src/", + "erc721a-upgradeable/=lib/contracts-library/lib/chiru-labs/erc721a-upgradeable/", + "erc721a/=lib/contracts-library/lib/chiru-labs/erc721a/", + "forge-std/=lib/contracts-library/lib/forge-std/src/", + "hardhat/=node_modules/hardhat/", + "murky/=lib/contracts-library/lib/murky/src/", + "solady/=lib/contracts-library/lib/solady/src/", +] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/testchain/lib/forge-std b/testchain/lib/forge-std new file mode 160000 index 000000000..b93cf4bc3 --- /dev/null +++ b/testchain/lib/forge-std @@ -0,0 +1 @@ +Subproject commit b93cf4bc34ff214c099dc970b153f85ade8c9f66