App Contracts (app-swap)
On-chain interfaces and state proof validation for the Swap App. The app contracts are kept minimal by making use of several helper contracts found in void/void-contracts/ and follow a pattern of defining functions in an interface only, while the main contract handles them with a global fallback. State validation is also handled in a separate validation contract, which is upgradeable.
Why this pattern:
- Minimal Solidity, maximal portability: most business logic lives in the Rust state machine; contracts are thin adapters.
- Pluggable proving: swap validation logic without changing app call surface.
- Easy upgrades: adding or removing functions doesn't require changes to the core contract.
- Cheaper gas: removing functions from the actual contract saves a bit of gas from the hidden function selector switch-case, but also allows for maintaining upgradability without needing calls to hit a proxy.
- Deterministic replay: on-chain writes are limited; off-chain processing replays compact inputs into the state machine.
- Updating the app involves only updating the
validationcontract.
App Interface
Defined in src/AppSwap.sol and src/AppSwapNull.sol (differences explained in Void vs VoidNull).
interface IAppSwap {
//note: transferData [20bytes to][4bytes tokenId][8bytes amount]
function transfer(bytes32 transferData) external;
//note: swapData [4bytes token in][8bytes amount in][8bytes min amount out]
function swap(bytes32 swapData) external;
//note: addLiquidityData [8bytes amount a][8bytes amount b]
function addLiquidity(bytes32 addLiquidityData) external;
//note: removeLiquidityData [8bytes amount lp]
function removeLiquidity(bytes32 removeLiquidityData) external;
/////////////////////////////////////////
// VoidUpgradeableWithBridge functions //
/////////////////////////////////////////
//note: depositData [20bytes to][4bytes tokenId][8bytes amount]
function deposit(bytes32 depositData) external;
//note: withdrawData [20bytes to][4bytes tokenId][8bytes amount]
function withdraw(bytes32 withdrawData) external;
//note: withdrawData [20bytes to][4bytes tokenId][8bytes amount]
function withdrawFinalize(bytes32 withdrawData, bytes calldata proof) external;
}
- Role: defines the functions available to call on the app contract (note: this is the only place some of the functions are defined as they are handled through a fallback in the core contract).
- Special case: the deposit/withdrawal functions are actual functions defined in the base contract:
VoidUpgradeableWithBridge. - Admin functions: additional admin functions exist and can be found in
VoidUpgradeableWithBridge.
Core Contracts
There are two different version of the core contract that extend from different versions of the core Void contract (see Void vs VoidNull).
-
AppSwapextendsVoidUpgradeableWithBridge.- Constructor:
(address initialOwner, IVoidValidator voidValidator, uint256 bufferSize, uint256 blocksPerBufferSlot, uint256 maxTxnsPerBufferSlot). - Bridge: deposit/withdraw entrypoints are part of the base pattern in
VoidUpgradeableWithBridge. - Functions: app specific functions are defined in the interface only, keeping the core contract minimal.
- Validation: handled through separate upgradeable contract (see Validation).
- Constructor:
-
AppSwapNullextendsVoidNullUpgradeableWithBridge.- Constructor:
(address initialOwner, IVoidValidator voidValidator, uint256 blocksPerBufferSlot, uint256 maxTxnsPerBufferSlot)(same asAppSwapbut without abufferSize). - Bridge: (same as
AppSwap) deposit/withdraw entrypoints are part of the base pattern inVoidUpgradeableWithBridge. - Functions: (same as
AppSwap) app specific functions are defined in the interface only, keeping the core contract minimal. - Validation: (same as
AppSwap) handled through separate upgradeable contract (see Validation).
- Constructor:
Validation
Implement IVoidValidator to verify state roots and log, which power things like withdrawals. There are two different validator contracts implemented in this repo which are used in different contexts.
-
AppValidatorECDSA: attestation-style validator using an ECDSA prover key- Constructor:
(address proverKey). validateState: verifieseventsHashwith ECDSA attestation.validateLog: checks a nonce (validateLogNonce), re-derivessha256(abi.encode(logHash, nonce)), validates state proof, then verifies a Binary Merkle Trie proof againststateRoot.
- Constructor:
-
AppValidatorRiscZero: ZK validator using RISC Zero Groth16 verifier- Constructor:
(RiscZeroGroth16Verifier verifier, bytes32 imageId). validateState: verifies the RISC Zero proof with fixed starting state.validateLog: same flow as ECDSA variant nonce check, state proof verification, then Merkle trie proof.
- Constructor:
Either of these validators can be plugged into the core App contract and are both used in the end-to-end tests included in this repo.
Void vs VoidNull
- Void (hashing + buffer): builds a SHA-256 hash chain of all calls and keeps a bounded ring buffer of end-state hashes on-chain for anchoring of proof verification.
- VoidNull (no hashing, no buffer): only emits the same structured log for each call and enforces per-slot txn limits. It relies on an external oracle/validator to fetch and aggregate logs across chains/sources.
What actually happens under the hood
From void/void-contracts/src/core:
-
Void (
Void.sol)voidRecord(): called on every app call (via the contract’s fallback or on deposit/withdraw hooks)- Builds a message: [4Bytes selector][4Bytes chainId][8Bytes blockNum][20Bytes sender][rest of calldata], and emits it as a log0.
- Computes
dataHash = sha256(message)thennewHash = sha256(prevHash || dataHash)to extend a hash chain. - Updates a ring buffer entry keyed by the current block “slot”.
- During proof verification,
voidValidate(eventsHash, index)checks that the providedeventsHashmatches the buffered end-state atindex. This anchors off-chain proofs to an on-chain truth.
-
VoidNull (
VoidNull.sol)voidRecord(): called on every app call (via the contract’s fallback or on deposit/withdraw hooks)- Builds the same message and emits it as a log0 (identical schema to Void).
- Does NOT compute a hash chain or maintain a ring buffer; it only stores data to enforce
maxTxnsPerBufferSlotperblocksPerBufferSlotwindow.
- During proof verification, there is no
voidValidatestep and proofs are accepted based solely on the validator contract’s checks (typically via an attestation from an oracle).
Design trade-offs and guidance:
-
Trust/anchoring
- Void: on-chain ring buffer anchors the prover’s
eventsHash. Validators still verify state/log proofs, but the additional buffer check ties proofs to a specific on-chain truth. - VoidNull: no on-chain anchoring; depends entirely on the validator/oracle to supply consistent, deduplicated, correctly ordered events.
- Void: on-chain ring buffer anchors the prover’s
-
Multi-source aggregation
- Void: best when a single chain is the canonical source of events or when you want minimal dependence on external oracles.
- VoidNull: best when an oracle aggregates logs from multiple chains or off-chain sources and you don’t want per-chain hash anchoring. Helpful for cross-domain aggregation.
-
Gas/complexity
- Void: slightly higher gas per call due to two SHA-256 operations and buffer writes; constructor requires
bufferSizein addition toblocksPerBufferSlotandmaxTxnsPerBufferSlot. - VoidNull: cheaper per call; constructor omits
bufferSize.
- Void: slightly higher gas per call due to two SHA-256 operations and buffer writes; constructor requires
-
Operational sizing (Void only)
- Choose
bufferSizeso thatbufferSize * blocksPerBufferSlotexceeds the max finalization delay (in blocks) for withdrawals, to avoid overwriting the referenced slot. - Set
maxTxnsPerBufferSlotbased on expected throughput to avoid reverts during bursts.
- Choose