Building dapps

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 batchScoreAddresses instead of multiple scoreAddress calls
  • 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