Skip to content

Commit

Permalink
ffi svg validation
Browse files Browse the repository at this point in the history
  • Loading branch information
djviau committed Sep 19, 2023
1 parent 71b1df2 commit c68f488
Show file tree
Hide file tree
Showing 6 changed files with 426 additions and 26 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ lcov.info
.DS_Store

test-ffi/scripts/node_modules
test-ffi/tmp/temp.json
test-ffi/tmp/temp*
broadcast
84 changes: 79 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,96 @@
- [ ] SignedZone
- [ ] port from seaport repo

## Running ffi tests.
# Quick Start Guide

To deploy an NFT contract to the Goerli testnet, fund an address with 0.25 Goerli ETH, swap in the appropriate values for `<your_key>` and `<your_pk>` in this command, open a terminal window, and run the following:

```
git clone [email protected]:ProjectOpenSea/shipyard-core.git &&
cd shipyard-core &&
curl -L https://foundry.paradigm.xyz | bash &&
foundryup &&
forge build &&
export GOERLI_RPC='https://goerli.blockpi.network/v1/rpc/public &&
export ETHERSCAN_API_KEY='<your_key>' &&
export MY_ACTUAL_PK_BE_CAREFUL='<your_pk>' &&
forge create --rpc-url $GOERLI_RPC \
--private-key $MY_ACTUAL_PK_BE_CAREFUL \
--etherscan-api-key $ETHERSCAN_API_KEY \
--verify \
src/reference/ExampleNFT.sol:ExampleNFT
```

A quick breakdown of each step follows.

Clone the `shipyard-core` repository and change directories into it:
```
git clone [email protected]:ProjectOpenSea/shipyard-core.git &&
cd shipyard-core
```

Install the `foundryup` up command and run it, which in turn installs forge, cast, anvil, and chisel:
```
curl -L https://foundry.paradigm.xyz | bash &&
foundryup
```

Install dependencies and compile the contracts:
```
forge build
```

Set up your environment variables:
```
export GOERLI_RPC='https://goerli.blockpi.network/v1/rpc/public &&
export ETHERSCAN_API_KEY='<your_key>' &&
export MY_ACTUAL_PK_BE_CAREFUL='<your_pk>'
```

Run the `forge create` command, which deploys the contract:
```
forge create --rpc-url $GOERLI_RPC \
--private-key $MY_ACTUAL_PK_BE_CAREFUL \
--etherscan-api-key $ETHERSCAN_API_KEY \
--verify \
src/reference/ExampleNFT.sol:ExampleNFT
```

See https://book.getfoundry.sh/reference/forge/forge-create for more information on `forge create`.

## Deploying to mainnet

To deploy to mainnet, just replace the value supplied to `--rpc-url` with a mainnet RPC URL. For example:

```
export MAINNET_RPC='https://eth.llamarpc.com' &&
forge create --rpc-url $MAINNET_RPC \
--private-key $MY_ACTUAL_PK_BE_CAREFUL \
--etherscan-api-key $ETHERSCAN_API_KEY \
--verify \
src/reference/ExampleNFT.sol:ExampleNFT
```

Note that this will deploy ExampleNFT to mainnet, which will cost real money and will not produce much value as a result.

# Running ffi tests

Currently, the ffi tests are the only way to test the output of ExampleNFT's tokenURI response. More options soon™.

In general, it's wise to be especially wary of ffi code. In the words of the Foundrybook, "It is generally advised to use this cheat code as a last resort, and to not enable it by default, as anyone who can change the tests of a project will be able to execute arbitrary commands on devices that run the tests."

# Environment configuration
## Environment configuration

To run the ffi tests locally, set `FOUNDRY_PROFILE='ffi'` in your `.env` file, and then source the `.env` file. This will permit Forge to make foreign calls (`ffi = true`) and read and write within the `./test-ffi/` directory. It also tells Forge to run the tests in the `./test-ffi/` directory instead of the tests in the `./test/` directory, which are run by default. Check out the `foundry.toml` file, where all of this and more is configured.

It's necessary to install the dependencies in `./test-ffi/scripts` before running the ffi tests. From the top level, run `cd test-ffi/scripts && yarn && ../..`. Then, running `forge test -vvv` should result in the tests passing.

Both the local profile and the CI profile for the ffi tests use a low number of fuzz runs, because the ffi lifecycle is slow. Before yeeting a project to mainnet, it's advisable to crank up the number of fuzz runs to increase the likelihood of catching an issue. It'll take more time, but it increases the likelihood of catching an issue.

# Expected local behavior
## Expected local behavior

The `ExampleNFT.t.sol` file will call `ExampleNFT.sol`'s `tokenURI` function, decode the base64 encoded response, write the decoded version to `./test-ffi/tmp/temp.json`, and then call the `process_json.js` file a few times to get string values. If the expected values and the actual values match, the test will pass. A `temp.json` file will be left behind. You can ignore it or delete it; Forge makes a new one on the fly if it's not there. And it's ignored in the `.gitignore` file, so there's no need to worry about pushing cruft or top secret metadata to a shared/public repo.
The `ExampleNFT.t.sol` file will call `ExampleNFT.sol`'s `tokenURI` function, decode the base64 encoded response, write the decoded version to `./test-ffi/tmp/temp.json`, and then call the `process_json.js` file a few times to get string values. If the expected values and the actual values match, the test will pass. A `temp.json` file will be left behind. You can ignore it or delete it; Forge makes a new one on the fly if it's not there. And it's ignored in the `.gitignore` file, so there's no need to worry about pushing cruft or top secret metadata to a shared/public repo. The tests in `svg.t.sol` behave more or less the same way, except that they'll produce many more temporary files.

# Expected CI behavior
## Expected CI behavior

When a PR is opened or when a new commit is pushed, Github runs a series of actions defined in the files in `.github/workflows/*.yml`. The normal Forge tests and linting are set up in `test.yml`. The ffi tests are set up in `test-ffi.yml`. Forks of this repository can safely disregard it or if it's not necessary, remove it entirely.
285 changes: 285 additions & 0 deletions test-ffi/onchain/svg.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import {Base64} from "solady/utils/Base64.sol";
import {Test} from "forge-std/Test.sol";
import {svg} from "../../src/onchain/svg.sol";
import {ExampleNFT} from "src/reference/ExampleNFT.sol";
import {svg} from "../../src/onchain/svg.sol";

import "forge-std/console.sol";

contract svgTest is Test {
ExampleNFT testExampleNft;

string TEMP_JSON_PATH = "./test-ffi/tmp/temp-2.json";
string PROCESS_JSON_PATH = "./test-ffi/scripts/process_json.js";

string TEMP_SVG_DIR_PATH_AND_PREFIX = "./test-ffi/tmp/temp-";
string TEMP_SVG_FILE_TYPE = ".svg";
string VALIDATE_SVG_PATH = "./test-ffi/scripts/validate_svg.js";

function setUp() public {
testExampleNft = new ExampleNFT();
}

function testValidateExampleSvg(uint256 tokenId) public {
// Populate the json file with the json from the tokenURI function.
_populateTempFileWithJson(tokenId);

// Get the output of the NFT's tokenURI function and grab the image from
// it.
string memory image = _getImage();

// Write the svg to a file. It's necessary to create a new file for each
// test to prevent the tests from becoming flaky as a result of timing.
vm.writeFile(string(abi.encodePacked(TEMP_SVG_DIR_PATH_AND_PREFIX, "example", TEMP_SVG_FILE_TYPE)), image);

_validateSvg("example");
}

function testTop() public {
_validateTopLevelSvg(
svg.top('width="100" height="100"', '<circle width="100" height="100"></circle>'),
"top-with-props-and-children"
);
_validateTopLevelSvg(svg.top('width="100" height="100"', ""), "top-with-props");
_validateTopLevelSvg(svg.top("", ""), "top-no-props");
}

function testSvg() public {
_validateTopLevelSvg(
svg.svg_(true, 'width="100" height="100"', '<circle width="100" height="100"></circle>'),
"svg-with-props-and-children"
);
_validateTopLevelSvg(svg.svg_(true, 'width="100" height="100"', ""), "svg-with-props-includeXmlns");
_validateTopLevelSvg(svg.svg_(false, 'width="100" height="100"', ""), "svg-with-props-no-includeXmlns");
_validateTopLevelSvg(svg.svg_(false, "", ""), "svg-no-props");
}

function testG() public {
_validateSvgElement(
svg.g('width="100" height="100"', '<circle width="100" height="100"></circle>'), "g-with-props-and-children"
);
_validateSvgElement(svg.g('width="100" height="100"', ""), "g-with-props");
_validateSvgElement(svg.g("", ""), "g-no-props");
}

function testPath() public {
_validateSvgElement(
svg.path('width="100" height="100"', '<circle width="100" height="100"></circle>'),
"path-with-props-and-children"
);
_validateSvgElement(svg.path('width="100" height="100"', ""), "path-with-props");
_validateSvgElement(svg.path("", ""), "path-no-props");
}

function testText() public {
_validateSvgElement(
svg.text('width="100" height="100"', '<circle width="100" height="100"></circle>'),
"text-with-props-and-children"
);
_validateSvgElement(svg.text('width="100" height="100"', ""), "text-with-props");
_validateSvgElement(svg.text("", ""), "text-no-props");
}

function testLine() public {
_validateSvgElement(
svg.line('width="100" height="100"', '<circle width="100" height="100"></circle>'),
"line-with-props-and-children"
);
_validateSvgElement(svg.line('width="100" height="100"', ""), "line-with-props");
_validateSvgElement(svg.line("", ""), "line-no-props");
}

function testCircle() public {
_validateSvgElement(
svg.circle('width="100" height="100"', '<circle width="100" height="100"></circle>'),
"circle-with-props-and-children"
);
_validateSvgElement(svg.circle('width="100" height="100"', ""), "circle-with-props");
_validateSvgElement(svg.circle("", ""), "circle-no-props");
}

function testCircleNoChildren() public {
_validateSvgElement(svg.circle('width="100" height="100"'), "circle-no-children-with-props");
_validateSvgElement(svg.circle(""), "circle-no-children-no-props");
}

function testRect() public {
_validateSvgElement(
svg.rect('width="100" height="100"', '<circle width="100" height="100"></circle>'),
"rect-with-props-and-children"
);
_validateSvgElement(svg.rect('width="100" height="100"', ""), "rect-with-props");
_validateSvgElement(svg.rect("", ""), "rect-no-props");
}

function testRectNoChildren() public {
_validateSvgElement(svg.rect('width="100" height="100"'), "rect-no-children-with-props");
_validateSvgElement(svg.rect(""), "rect-no-children-no-props");
}

function testFilter() public {
_validateSvgElement(
svg.filter('width="100" height="100"', '<circle width="100" height="100"></circle>'),
"filter-with-props-and-children"
);
_validateSvgElement(svg.filter('width="100" height="100"', ""), "filter-with-props");
_validateSvgElement(svg.filter("", ""), "filter-no-props");
}

function testCdata() public {
_validateSvgElement(svg.cdata("<svg></svg>"), "cdata");
}

function testRadialGradient() public {
_validateSvgElement(
svg.radialGradient('width="100" height="100"', '<circle width="100" height="100"></circle>'),
"radialGradient-with-props-and-children"
);
_validateSvgElement(svg.radialGradient('width="100" height="100"', ""), "radialGradient-with-props");
_validateSvgElement(svg.radialGradient("", ""), "radialGradient-no-props");
}

function testLinearGradient() public {
_validateSvgElement(
svg.linearGradient('width="100" height="100"', '<circle width="100" height="100"></circle>'),
"linearGradient-with-props-and-children"
);
_validateSvgElement(svg.linearGradient('width="100" height="100"', ""), "linearGradient-with-props");
_validateSvgElement(svg.linearGradient("", ""), "linearGradient-no-props");
}

function testGradientStop() public {
_validateSvgElement(svg.gradientStop(0, "red", 'width="100" height="100"'), "gradientStop-with-props");
_validateSvgElement(svg.gradientStop(0, "red", ""), "gradientStop-no-props");
}

function testAnimateTransform() public {
_validateSvgElement(svg.animateTransform('width="100" height="100"'), "animateTransform-with-props");
_validateSvgElement(svg.animateTransform(""), "animateTransform-no-props");
}

function testImage() public {
_validateSvgElement(svg.image("https://example.com", 'width="100" height="100"'), "image-with-props");
_validateSvgElement(svg.image("https://example.com", ""), "image-no-props");
}

function testEl() public {
_validateSvgElement(
svg.el("rect", 'width="100" height="100"', '<circle width="100" height="100"></circle>'),
"el-with-props-and-children"
);
_validateSvgElement(svg.el("rect", 'width="100" height="100"', ""), "el-with-props");
_validateSvgElement(svg.el("rect", "", ""), "el-no-props");
}

function testElNoChildren() public {
_validateSvgElement(svg.el("rect", 'width="100" height="100"'), "el-no-children-with-props");
_validateSvgElement(svg.el("rect", ""), "el-no-children-no-props");
}

function testProp() public {
string memory prop = svg.prop("width", "100");
_validateSvgElement(svg.el("rect", prop, ""), "prop");
}

////////////////////////////////////////////////////////////////////////////
// Helpers //
////////////////////////////////////////////////////////////////////////////

function _validateTopLevelSvg(string memory libOutput, string memory fileName) internal {
// Write the svg to a file.
vm.writeFile(string(abi.encodePacked(TEMP_SVG_DIR_PATH_AND_PREFIX, fileName, TEMP_SVG_FILE_TYPE)), libOutput);

// Validate the svg.
_validateSvg(fileName);
}

function _validateSvgElement(string memory libOutput, string memory fileName) internal {
// Wrap the svg element in a top level svg call.
libOutput = svg.top("", libOutput);

// Validate the svg.
_validateTopLevelSvg(libOutput, fileName);
}

function _validateSvg(string memory fileName) internal {
// Run the validate_svg.js script on the file to validate the svg.
string[] memory commandLineInputs = new string[](3);
commandLineInputs[0] = "node";
commandLineInputs[1] = VALIDATE_SVG_PATH;
commandLineInputs[2] = string(abi.encodePacked(TEMP_SVG_DIR_PATH_AND_PREFIX, fileName, TEMP_SVG_FILE_TYPE));

(bool isValid, string memory svg) = abi.decode(vm.ffi(commandLineInputs), (bool, string));

assertEq(isValid, true, string(abi.encodePacked("The svg should be valid. Invalid svg: ", svg)));
}

function _populateTempFileWithJson(uint256 tokenId) internal {
// Get the raw URI response.
string memory rawUri = testExampleNft.tokenURI(tokenId);
// Remove the data:application/json;base64, prefix.
string memory uri = _cleanedUri(rawUri);
// Decode the base64 encoded json.
bytes memory decoded = Base64.decode(uri);

// Write the decoded json to a file.
vm.writeFile(TEMP_JSON_PATH, string(decoded));
}

function _cleanedUri(string memory uri) internal pure returns (string memory) {
uint256 stringLength;

// Get the length of the string from the abi encoded version.
assembly {
stringLength := mload(uri)
}

// Remove the data:application/json;base64, prefix.
return _substring(uri, 29, stringLength);
}

function _getImage() internal returns (string memory) {
// Run the process_json.js script on the file to extract the values.
// This will also check for json validity.
string[] memory commandLineInputs = new string[](4);
commandLineInputs[0] = "node";
commandLineInputs[1] = PROCESS_JSON_PATH;
// In ffi, the script is executed from the top-level directory, so
// there has to be a way to specify the path to the file where the
// json is written.
commandLineInputs[2] = TEMP_JSON_PATH;
// Optional field. Default is to only get the top level values (name,
// description, and image). This is present for the sake of
// explicitness.
commandLineInputs[3] = "--top-level";

(,, string memory image) = abi.decode(vm.ffi(commandLineInputs), (string, string, string));

return _cleanedSvg(image);
}

function _cleanedSvg(string memory uri) internal pure returns (string memory) {
uint256 stringLength;

// Get the length of the string from the abi encoded version.
assembly {
stringLength := mload(uri)
}

// Remove the "data:image/svg+xml;" prefix.
return _substring(uri, 19, stringLength);
}

function _substring(string memory str, uint256 startIndex, uint256 endIndex) public pure returns (string memory) {
bytes memory strBytes = bytes(str);

bytes memory result = new bytes(endIndex - startIndex);
for (uint256 i = startIndex; i < endIndex; i++) {
result[i - startIndex] = strBytes[i];
}
return string(result);
}
}
Loading

0 comments on commit c68f488

Please sign in to comment.