5/5
## Bridging Off-Chain Logic with On-Chain Tests: An Overview of Foundry FFI This lesson explores how to enhance your Solidity testing workflows by integrating off-chain computations performed by a TypeScript script. We'll specifically focus on generating a cryptographic commitment using TypeScript and then calling this script from a Solidity test within the Foundry framework, leveraging its Foreign Function Interface (FFI) capabilities. This approach is invaluable for testing smart contracts that interact with complex, computationally intensive, or cryptographically sensitive data prepared off-chain. ## Configuring Your Foundry Project for Foreign Function Interface (FFI) To enable communication between your Solidity tests and external scripts, you'll need to make a few adjustments to your project structure and Foundry's configuration. ### Directory Structure for Helper Scripts First, organize your helper scripts. We'll create a dedicated folder for JavaScript/TypeScript files: 1. Create a new folder named `js-scripts` at the root of your Foundry project. This will house our TypeScript commitment generation script. 2. If you have a pre-existing `script` folder intended for Solidity scripts and it's not being used for this FFI setup, you can remove it to avoid confusion. 3. Inside the `js-scripts` folder, create a new TypeScript file named `generateCommitment.ts`. This file will contain the core logic for generating our cryptographic commitment. ### Enabling FFI in `foundry.toml` Foundry requires explicit permission to execute external scripts via FFI. This is configured in the `foundry.toml` file. Open your `foundry.toml` file and add or modify the `ffi` flag under the default profile: ```toml [profile.default] src = "src" out = "out" libs = ["lib"] ffi = true # This line enables FFI remappings = [ '@poseidon/=lib/poseidon2-evm/' ] # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options ``` **Crucial Security Note:** Enabling FFI (`ffi = true`) grants your Solidity test environment the ability to execute arbitrary commands on your system through the Command Line Interface (CLI). This introduces a potential security risk, especially if you are working with or running tests from untrusted projects. Always exercise extreme caution and thoroughly review any external scripts before enabling FFI and executing tests. ## Developing the TypeScript Cryptographic Commitment Generator With the project structure in place, let's implement the TypeScript script (`js-scripts/generateCommitment.ts`) that will generate the cryptographic commitment. ### Installing Dependencies Navigate to your `js-scripts` directory in the terminal: `cd js-scripts` Then, install the necessary Node.js packages: * **ethers:** For ABI encoding utilities, ensuring data is correctly formatted for Solidity. `npm install ethers` * **@aztec/bb.js:** Barretenberg.js, a library for advanced cryptographic operations, including Poseidon hashing and field element arithmetic. We'll use a specific version. `npm install @aztec/bb.js@0.82.2` * **@noir-lang/noir_js:** Noir language JavaScript bindings. While not directly used in this specific commitment script, it's often part of a broader ZK development stack. `npm install @noir-lang/noir_js@1.0.0-beta.3` ### `generateCommitment.ts` Script Implementation Below is the TypeScript code for `js-scripts/generateCommitment.ts`. This script initializes Barretenberg, generates random values for a nullifier and a secret, computes a Poseidon hash to create a commitment, ABI-encodes the commitment, and prints it to standard output. ```typescript import { Barretenberg, Fr } from "@aztec/bb.js"; // Barretenberg library and Field element class import { ethers } from "ethers"; // Ethers.js for ABI encoding // Main function to generate the commitment export default async function generateCommitment(): Promise<string> { // Initialize the Barretenberg WASM const bb = await Barretenberg.new(); // Initializes Barretenberg // Generate random nullifier and secret as field elements const nullifier: Fr = Fr.random(); const secret: Fr = Fr.random(); // Create the commitment by hashing the nullifier and secret using Poseidon2 const commitment: Fr = await bb.poseidon2Hash([nullifier, secret]); // ABI encode the commitment (which is an Fr type) into a bytes32 hex string // The commitment (an Fr object) is converted to a Buffer. // This buffer is then ABI-encoded as a bytes32 string, expected by Solidity. const result = ethers.AbiCoder.defaultAbiCoder().encode( ["bytes32"], // The type Solidity expects [commitment.toBuffer()] // Convert Fr to Buffer for encoding as bytes32 ); return result; // Return the ABI-encoded commitment as a hex string } // IIFE (Immediately Invoked Function Expression) to run the script (async () => { generateCommitment() .then((result) => { // Write the ABI-encoded result to standard output for FFI // Foundry's FFI captures this stdout as the result in the Solidity test. process.stdout.write(result); process.exit(0); // Exit with success code }) .catch((error) => { console.error("Error generating commitment:", error); // Log any errors to standard error process.exit(1); // Exit with failure code }); })(); ``` **Script Breakdown:** * **Imports:** We import `Barretenberg` and the `Fr` (Field element) class from `@aztec/bb.js`, and `ethers` for its `AbiCoder`. * **`generateCommitment` async function:** * `await Barretenberg.new()`: Asynchronously initializes the Barretenberg library. * `Fr.random()`: Generates cryptographically random field elements for the `nullifier` and `secret`. * `await bb.poseidon2Hash([nullifier, secret])`: Computes the Poseidon hash of the nullifier and secret. The result, `commitment`, is also a field element (`Fr`). * `ethers.AbiCoder.defaultAbiCoder().encode(["bytes32"], [commitment.toBuffer()])`: This is a critical step. The `Fr` commitment is first converted to a `Buffer` via `commitment.toBuffer()`. Then, `ethers.AbiCoder` encodes this buffer into a hex string formatted as a `bytes32` value, which is the type our Solidity contract will expect. * **IIFE (Immediately Invoked Function Expression):** * The `(async () => { ... })();` construct ensures `generateCommitment()` is executed when the script runs. * `process.stdout.write(result);`: The ABI-encoded commitment string is written to standard output. Foundry's FFI mechanism reads this output. * `process.exit(0);`: Signals successful script execution. * Error handling is included to print errors to `stderr` and exit with a non-zero code on failure. ## Calling Your TypeScript Script from Solidity: The `vm.ffi` Cheatcode Now, let's modify our Solidity test file, `Mixer.t.sol` (or your relevant test file), to call the TypeScript script. ### Solidity Test Setup (`Mixer.t.sol`) We'll define a helper function `_getCommitment` within our test contract to encapsulate the FFI call. ```solidity // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import {Test, console} from "forge-std/Test.sol"; // ... other necessary imports for Mixer, HonkVerifier, Poseidon2 etc. ... contract MixerTest is Test { // ... (Instances of Mixer, HonkVerifier, Poseidon2 if needed) ... address public recipient = makeAddr("recipient"); // makeAddr is part of Test // ... (setUp function if you have one) ... function _getCommitment() public returns (bytes32 _commitment) { // Define the command and arguments to run the TS script string[] memory inputs = new string[](3); inputs[0] = "npx"; // Node Package Execute: runs CLI tools from npm packages inputs[1] = "tsx"; // TypeScript runner: executes TypeScript files directly inputs[2] = "js-scripts/generateCommitment.ts"; // Path to our TypeScript script // Execute the script via FFI and capture its standard output bytes memory result = vm.ffi(inputs); // Decode the result, which is expected to be ABI-encoded bytes32 _commitment = abi.decode(result, (bytes32)); return _commitment; // Return the decoded commitment } function testMakeDeposit() public { // Create a commitment by calling the external script bytes32 _commitment = _getCommitment(); // Log the commitment for verification during testing console.log("Commitment from TS script:"); console.logBytes32(_commitment); // Example usage (commented out for this lesson's focus): // mixer.deposit(_commitment); } } ``` **Explanation of `_getCommitment`:** * `string[] memory inputs = new string[](3);`: An array of strings is prepared to hold the command and its arguments. * `inputs[0] = "npx";`: We use `npx`, the Node Package Execute tool, which allows running CLI tools hosted on the npm registry or local packages. * `inputs[1] = "tsx";`: `tsx` is a command-line tool that provides a seamless way to execute TypeScript and ESM files directly, without a separate compilation step. * `inputs[2] = "js-scripts/generateCommitment.ts";`: This is the relative path from the project root to our TypeScript script. * `bytes memory result = vm.ffi(inputs);`: The core FFI call. Foundry executes the command `npx tsx js-scripts/generateCommitment.ts`. The standard output of this command is captured as `bytes` in the `result` variable. * `_commitment = abi.decode(result, (bytes32));`: The `result` (which is the ABI-encoded string from our TypeScript script) is decoded back into a `bytes32` Solidity type. * The function is declared with `returns (bytes32 _commitment)` to specify the return type and named return variable. **`testMakeDeposit` Function:** This function demonstrates how to use `_getCommitment`. It calls the helper, retrieves the commitment, and logs it to the console using `console.logBytes32` for clear output of `bytes32` values. ## Executing and Debugging Your FFI-Integrated Solidity Tests To run your tests and see the FFI in action, use the standard Foundry test command, often with increased verbosity for debugging: `forge test -vvv` ### Common Debugging Steps & Corrections: During development, you might encounter issues. Here are some common ones and their fixes, as identified in typical FFI setups: 1. **Solidity Compiler Error for `makeAddr` or `vm`:** * **Error Example:** `Error: (7576) Undeclared identifier. --> test/Mixer.t.sol:13:32` for `makeAddr` or similar errors for `vm`. * **Fix:** Ensure your test contract inherits from Foundry's `Test` contract: `contract MixerTest is Test { ... }`. Also, import `Test` (and `console` for logging) from `forge-std`: `import {Test, console} from "forge-std/Test.sol";`. 2. **Improper Logging of `bytes32` in Solidity:** * **Issue:** Using `console.log("Commitment: ", _commitment)` for a `bytes32` variable might not display the value in a human-readable hex format. * **Fix:** Use `console.logBytes32(_commitment);` for a clearer, hex-formatted output of `bytes32` data. 3. **FFI Script Path Error:** * **Error Example (in terminal):** `Error: cheatcodes: non-empty stderr ... "tsx", "js-script/generateCommitment.ts" ... Error: ERROR_MODULE_NOT_FOUND` or similar file not found errors. * **Fix:** Double-check the path to your TypeScript script specified in the `inputs` array within your Solidity test function (e.g., `js-scripts/generateCommitment.ts`). Ensure it correctly matches the folder and file name (e.g., `js-scripts` vs. `js-script`). 4. **Missing Solidity Function Return Type Specification:** * **Issue:** A Solidity function that uses a named return variable might be missing the explicit `returns (...)` clause in its signature, or the final `return ...;` statement. * **Fix:** Ensure your FFI helper function (like `_getCommitment`) has a complete signature, including `returns (bytes32 _commitment)`, and ends with `return _commitment;`. After addressing such potential issues, your `testMakeDeposit` should successfully execute the TypeScript script, retrieve the generated commitment, and log it to the console, confirming the FFI integration is working. ## Key Technologies and Concepts in FFI-Driven Development This lesson touched upon several important technologies and concepts: * **Foreign Function Interface (FFI):** A mechanism allowing code written in one programming language to call routines or make use of services written in another. Foundry's `vm.ffi()` cheatcode enables this for Solidity tests. * **Commitment Scheme:** A cryptographic primitive allowing one to commit to a chosen value while keeping it hidden, with the ability to reveal and prove the committed value later. In our example, `commitment = PoseidonHash(nullifier, secret)`. * **Poseidon Hash:** A SNARK/STARK-friendly hash function, optimized for efficiency within zero-knowledge circuits and widely used in the web3 ecosystem. * **Barretenberg.js (`@aztec/bb.js`):** A powerful JavaScript/TypeScript library providing WebAssembly-powered implementations of low-level cryptographic functions, including Poseidon hashing, elliptic curve operations, and field arithmetic. It's crucial for ZK-related development. * **Field Elements (`Fr`):** These are elements of a finite field, the fundamental mathematical structure underpinning most modern cryptography, especially in ZK systems. `bb.js` uses the `Fr` class to represent these. * **TypeScript for Off-Chain Logic:** Leveraging TypeScript (or JavaScript) allows for more complex, potentially asynchronous, or computationally intensive logic to be handled off-chain, complementing Solidity's on-chain strengths. * **`npx` and `tsx`:** `npx` is a Node.js tool for executing npm package binaries. `tsx` is a command-line tool for running TypeScript and ES Modules directly without a separate pre-compilation step, simplifying development workflows. * **ABI Encoding/Decoding:** The Application Binary Interface (ABI) defines how data is structured and passed between different systems or components. `ethers.js` provides `AbiCoder` for encoding JavaScript/TypeScript data into a format Solidity understands, and Solidity has built-in `abi.decode` for the reverse. * **Node.js `process.stdout.write` and `process.exit`:** Standard Node.js methods for a script to output data (which FFI reads from `stdout`) and to signal its execution status (success or failure). * **JavaScript Promises and `async/await`:** Used for managing asynchronous operations common in JavaScript, such as initializing WASM modules (like Barretenberg) or performing I/O, ensuring code executes in the correct order. * **Immediately Invoked Function Expression (IIFE):** A common JavaScript pattern to execute a function as soon as it is defined. This is useful for creating self-contained scripts that run their main logic automatically. By mastering these concepts and techniques, you can significantly expand the capabilities of your Foundry testing environment, enabling more comprehensive and realistic testing scenarios for your smart contracts.
An illuminating guide to Bridging Off-Chain Logic with On-Chain Tests: An Overview of Foundry FFI - Discover how to enhance Solidity testing by integrating off-chain TypeScript computations using Foundry's Foreign Function Interface (FFI). This lesson walks through creating a TypeScript commitment generator and calling it from your smart contract tests for robust validation.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Noir syntax
Create a witness, a proof, and Solidity verifier contracts
Use the Poseidon commitment scheme
Create ZK circuits and build a full ZK protocol
ZK Merkle trees and hashing in Noir
Verify signatures without revealing the signer
Build the backend for a full-stack ZK application with noir.js and bb.js
How to create proofs and verify them in a front-end
Last updated on June 12, 2025
Duration: 6min
Duration: 1h 11min
Duration: 2h 12min
Duration: 3h 19min
Course Overview
About the course
Noir syntax
Create a witness, a proof, and Solidity verifier contracts
Use the Poseidon commitment scheme
Create ZK circuits and build a full ZK protocol
ZK Merkle trees and hashing in Noir
Verify signatures without revealing the signer
Build the backend for a full-stack ZK application with noir.js and bb.js
How to create proofs and verify them in a front-end
Last updated on June 12, 2025