Testing with Foundry
Foundry is the recommended testing framework for Citrate smart contract development. We chose Foundry as our primary recommendation because its speed, Solidity-native test syntax, and powerful forking capabilities make it ideal for testing both standard EVM contracts and Citrate's AI precompile interactions. This guide covers setup, fork testing, precompile mocking, gas optimization, and deployment workflows.
Project Setup
If you have not already configured Foundry for Citrate, start with a new project:
forge init citrate-project
cd citrate-project
Add the Citrate RPC endpoints and AI precompile addresses to your foundry.toml:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc_version = "0.8.24"
optimizer = true
optimizer_runs = 200
[rpc_endpoints]
citrate_testnet = "https://testnet-rpc.cnidarian.cloud"
citrate_mainnet = "https://rpc.cnidarian.cloud"
[etherscan]
citrate_testnet = { key = "${EXPLORER_API_KEY}", url = "https://testnet-explorer.cnidarian.cloud/api" }
Install the Citrate interface library for type-safe precompile calls:
forge install cnidarian/citrate-interfaces
Fork Testing
Fork testing lets you run your tests against live Citrate network state. This is invaluable for testing interactions with deployed contracts, existing model registrations, and real precompile behavior.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/MyAIApp.sol";
contract MyAIAppForkTest is Test {
MyAIApp app;
uint256 citrateFork;
function setUp() public {
// Create a fork of Citrate testnet
citrateFork = vm.createFork("citrate_testnet");
vm.selectFork(citrateFork);
app = new MyAIApp();
}
function testModelRegistryAccessOnFork() public {
// Query a real model from the live registry
(string memory name,,,,, ) = IModelRegistry(address(0x0100))
.getModel(0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef);
assertGt(bytes(name).length, 0, "Model should exist on testnet");
}
function testInferenceRequestOnFork() public {
// Send a real inference request against forked state
vm.deal(address(app), 1 ether);
bytes32 requestId = app.requestAnalysis{value: 0.01 ether}("Test input");
assertTrue(requestId != bytes32(0), "Should receive a request ID");
}
}
Run fork tests with:
forge test --fork-url https://testnet-rpc.cnidarian.cloud -vvv
AI Precompile Mocking
For unit tests that should run without network access, you need to mock the AI precompiles. Foundry's vm.mockCall and vm.etch cheatcodes let you simulate precompile behavior locally.
contract MyAIAppUnitTest is Test {
MyAIApp app;
function setUp() public {
app = new MyAIApp();
_mockPrecompiles();
}
function _mockPrecompiles() internal {
// Mock the ModelRegistry at 0x0100
// Return a valid model response for any getModel call
vm.mockCall(
address(0x0100),
abi.encodeWithSignature(
"getModel(bytes32)"
),
abi.encode(
"mock-model", // name
address(this), // owner
"http://localhost:8545", // endpoint
0.001 ether, // price
10 ether, // stake
uint256(9000) // reputation
)
);
// Mock the InferenceEngine at 0x0101
// Return a predictable request ID
vm.mockCall(
address(0x0101),
abi.encodeWithSignature(
"requestInference(bytes32,bytes,address,bytes4,uint256)"
),
abi.encode(bytes32(uint256(1)))
);
}
function testAnalysisWithMockedPrecompiles() public {
vm.deal(address(app), 1 ether);
bytes32 requestId = app.requestAnalysis{value: 0.01 ether}("Test");
assertEq(requestId, bytes32(uint256(1)));
}
function testHandlesModelNotFound() public {
// Override mock to return empty for a specific model
vm.mockCall(
address(0x0100),
abi.encodeWithSelector(
IModelRegistry.getModel.selector,
bytes32(uint256(999))
),
abi.encode("", address(0), "", uint256(0), uint256(0), uint256(0))
);
vm.expectRevert("Model not found");
app.requestAnalysisForModel(bytes32(uint256(999)), "Test");
}
}
For more realistic mocking, you can deploy mock contracts to the precompile addresses using vm.etch:
function _deployMockRegistry() internal {
MockModelRegistry mock = new MockModelRegistry();
vm.etch(address(0x0100), address(mock).code);
}
Gas Optimization
Foundry provides excellent tools for measuring and optimizing gas consumption. This is particularly important on Citrate, where AI precompile calls have specific gas schedules.
Generate a gas report:
forge test --gas-report
Write gas-focused tests:
function testGasOptimizedModelLookup() public {
uint256 gasBefore = gasleft();
IModelRegistry(address(0x0100)).getModel(
bytes32(uint256(1))
);
uint256 gasUsed = gasBefore - gasleft();
assertLt(gasUsed, 5000, "Model lookup should cost less than 5000 gas");
}
Use forge snapshot to track gas changes across commits:
Create a baseline snapshot:
forge snapshot
After making changes, compare against the baseline:
forge snapshot --check
Common gas optimization patterns for Citrate contracts:
We've optimized the precompile gas costs to be as predictable as possible, but there are still patterns that help:
- Batch precompile calls: Use
batchScoreAddressesinstead of multiplescoreAddresscalls - Cache model IDs: Store frequently-used model IDs in storage rather than computing them each time
- Minimize calldata: Encode inference inputs tightly to reduce calldata gas costs
- Use view functions: Read-only precompile calls (like
getModel) cost only 2,100 gas
Deployment Scripts
Foundry's scripting system provides a robust deployment workflow with dry-run support and transaction broadcasting:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Script.sol";
import "../src/MyAIApp.sol";
contract Deploy is Script {
function run() external {
uint256 deployerKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerKey);
MyAIApp app = new MyAIApp();
// Verify precompiles are accessible
(, address owner,,,,) = IModelRegistry(address(0x0100)).getModel(bytes32(0));
require(owner == address(0), "Precompile sanity check");
vm.stopBroadcast();
console.log("Deployed MyAIApp at:", address(app));
}
}
Deploy and verify:
Dry run the deployment as a simulation:
forge script script/Deploy.s.sol --rpc-url citrate_testnet
Broadcast the transactions to the network:
forge script script/Deploy.s.sol --rpc-url citrate_testnet --broadcast
Verify the contract on the explorer:
forge verify-contract --chain-id 1338 --verifier-url https://testnet-explorer.cnidarian.cloud/api 0xDEPLOYED_ADDRESS src/MyAIApp.sol:MyAIApp
Useful Cast Commands
cast is Foundry's CLI for interacting with deployed contracts:
Read from the ModelRegistry precompile:
cast call 0x0100 "listModels(uint256,uint256)(bytes32[])" 0 10 --rpc-url https://testnet-rpc.cnidarian.cloud
Send a transaction:
cast send 0xYOUR_CONTRACT "setGreeting(string)" "Hello Citrate" --rpc-url https://testnet-rpc.cnidarian.cloud --private-key $PRIVATE_KEY
Check SALT balance:
cast balance 0xYOUR_ADDRESS --rpc-url https://testnet-rpc.cnidarian.cloud
Decode transaction calldata:
cast 4byte-decode 0x12345678...
Further Reading
- Solidity on Citrate -- project setup and EVM compatibility details
- Using AI Precompiles -- full precompile interface reference
- Network Configuration -- RPC URLs and chain IDs
- Verifiable Inference -- testing verification tiers