diff --git a/apps/transfers/README.md b/apps/transfers/README.md new file mode 100644 index 0000000..ac02274 --- /dev/null +++ b/apps/transfers/README.md @@ -0,0 +1,495 @@ +# Transfers: + +A VOID Framework application for instant, low-cost token transfers with cryptographic finality. + +## Overview + +The Transfers app demonstrates how to build a scalable payment system where users can: + +- **Deposit**: Lock ERC20 tokens in the on-chain contract +- **Transfer Off-Chain**: Send tokens to other users with minimal gas costs +- **Withdraw**: Exit back to L1 anytime using cryptographic proofs + +**How transfers work**: Users call a minimal on-chain function that emits a `TransferRequest` event. +The VOID app processes this event and updates balances in its off-chain state. This provides: + +- ⚑ **Instant finality**: Off-chain state updates happen immediately after event emission +- πŸ’° **Low gas cost**: Only event emission instead of expensive storage operations +- πŸ”’ **Cryptographic security**: Signed state commitments prove all balance updates + +While the system can be used for cross-chain bridging, its primary value is enabling high-throughput, low-cost transfers between users who have deposited funds. + +This application showcases advanced VOID Framework features including multiple merkle trees, proof generation, and split state architecture. + +## How VOID Framework Enables Off-Chain Transfers + +The VOID Framework provides the cryptographic infrastructure to move token transfers off-chain while maintaining security guarantees: + +### Signed State Commitments + +After each state transition, the VOID app signs a commitment containing merkle roots of all account balances and burn events. +This signature provides **instant finality** - once signed, the state is committed and users can rely on it without waiting for blockchain confirmation. + +```rust +// State commitment signed by the oracle +{ + "balance_root": "0xabc...", // Merkle root of all balances + "burn_root": "0xdef...", // Merkle root of all withdrawals + "block_height": 42, + "signature": "0x..." // ECDSA signature from trusted signer +} +``` + +### Merkle Tree State Management + +VOID's `GenericSparseMerkleTree` enables **trustless withdrawals**. When a user requests withdrawal, the app: + +1. Burns their off-chain balance +2. Inserts the burn event into the `burn_tree` +3. Generates a merkle proof from burn event to `burn_root` + +Users submit this proof on-chain, where the contract verifies it against the signed commitment - no need to trust the operator for withdrawal execution. + +### Off-Chain State Transitions + +While users emit an on-chain event to request transfers, the actual balance updates happen entirely in the VOID app's merkle trees. +The on-chain contract simply emits an event - it doesn't modify any token balances or storage: + +```solidity +// On-chain: minimal gas cost (just event emission) +function transfer(address to, address token, uint256 amount) external { + emit TransferRequest(msg.sender, to, token, amount); +} +``` + +```rust +// Off-chain: VOID app processes event and updates state +state.deduct_balance(from, token, amount)?; +state.add_balance(to, token, amount)?; +// State updates happen in merkle trees, not on-chain +``` + +This hybrid approach reduces gas costs compared to traditional ERC20 transfers, which must read and write storage slots for both sender and recipient balances. + +**Trust Model**: This application uses a **Proof of Authority (POA)** approach - users trust the VOID operator to process events correctly and sign valid state commitments. In exchange, they gain instant finality and reduced gas costs. The cryptographic proofs ensure users can always exit with their funds, even if the operator becomes malicious or goes offline. + +Note that the VOID framework can also be built with **ZK proofs** instead of POA signatures, eliminating the need to trust the operator for correct state transitions. The Transfers app demonstrates the simpler POA model. + +## Use Cases + +- **Payment channels**: High-frequency micropayments between users +- **Gaming**: In-game currency transfers without gas fees +- **Exchanges**: Internal transfers between user accounts +- **Remittances**: Fast, cheap value transfer within a closed system +- **Cross-chain bridging**: Transfer value between different blockchains (with trusted oracle) + +## Architecture + +### State Management + +The application maintains two separate state components: + +**AppState** (Proven - included in signed commitments): + +- `balance_tree`: GenericSparseMerkleTree mapping (user, token) β†’ Balance +- `burn_tree`: GenericSparseMerkleTree mapping withdrawal_id β†’ Burn + +**ApiState** (Not Proven - API-only): + +- `stored_proofs`: HashMap of pending withdrawal proofs +- `latest_commitment`: Most recent signed state commitment +- `latest_block_height/hash`: Synchronization state + +### Event Types + +The system processes four event types from the Transfers.sol contract: + +1. **Deposit** (on-chain β†’ off-chain): User locks tokens in contract; app credits their off-chain balance +2. **TransferRequest** (off-chain only): User requests transfer via on-chain event; app moves balances in off-chain state + - This is just a trigger - the actual transfer happens in the VOID app state + - No tokens move on-chain, no gas fees beyond the event emission +3. **WithdrawalRequest** (off-chain β†’ on-chain): User initiates withdrawal; app burns off-chain balance and generates proof +4. **WithdrawalCompleted** (on-chain): After successful on-chain withdrawal execution, app cleans up stored proof + +### Withdrawal Flow + +``` +1. User calls requestWithdrawal(token, amount, salt) on-chain + β”œβ”€> Generates unique withdrawal_id = keccak256(user, token, amount, salt, chainId) + └─> Emits WithdrawalRequest event + +2. Off-chain app processes the event + β”œβ”€> Deducts balance from user (burn) + β”œβ”€> Inserts burn event into burn_tree + β”œβ”€> Generates merkle proof + └─> Stores PendingWithdrawal in API state + +3. State transition completes + └─> Commitment signed: { balance_root, burn_root } + +4. User queries HTTP API + GET /get-merkle-proof/{withdrawal_id} + └─> Returns: Withdrawal { burn, burn_proof, state_commitment } + +5. User calls executeWithdrawal(withdrawal) on-chain + β”œβ”€> Contract verifies signature on state_commitment + β”œβ”€> Contract verifies burn_proof against burn_root + └─> Contract transfers tokens to user + +6. WithdrawalCompleted event emitted + └─> Off-chain app removes stored proof +``` + +## Building + +### Prerequisites + +Ensure you have Nix installed for building contracts and managing dependencies. + +### Compile Contracts + +Before running the application or tests, compile the Solidity contracts: + +```bash +nix run .#compile-contracts +``` + +This generates `contracts/build/transfers_abi.json` used by the Rust application. + +### Build the Application + +```bash +nix develop # Enter development shell +cargo build --package transfers +``` + +## Running + +### Configuration + +Create an oracle configuration file (YAML or JSON) specifying: + +```yaml +# oracle_config.yaml +rpc_url: "http://localhost:8545" +contracts: + - address: "0x5FbDB2315678afecb367f032d93F642f64180aa3" + events: + - "Deposit" + - "TransferRequest" + - "WithdrawalRequest" + - "WithdrawalCompleted" +start_block: 0 +``` + +### Start the Application + +```bash +# With a specific private key from environment variable +export SIGNER_KEY="0x..." +cargo run --package transfers -- run --key SIGNER_KEY oracle_config.yaml 127.0.0.1:3501 + +# With a random key (for testing) +cargo run --package transfers -- run oracle_config.yaml 127.0.0.1:3501 +``` + +## HTTP API + +### Endpoints + +#### `GET /` + +Health check endpoint. + +**Response**: `"OK"` + +#### `GET /get-balance/{user}/{token}` + +Query a user's balance for a specific token. + +**Parameters**: + +- `user`: Ethereum address (0x-prefixed hex) +- `token`: ERC20 token address (0x-prefixed hex) + +**Response**: `U256` balance amount + +**Example**: + +```bash +curl http://localhost:3501/get-balance/0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266/0x5FbDB2315678afecb367f032d93F642f64180aa3 +``` + +#### `GET /get-state-commitment/` + +Retrieve the latest signed state commitment. + +**Response**: `Option>>` + +```json +{ + "data": { + "balance_root": "0x...", + "burn_root": "0x..." + }, + "block_height": 42, + "signature": "0x..." +} +``` + +#### `GET /get-merkle-proof/{withdrawal_id}` + +Retrieve the complete withdrawal proof for submitting on-chain. + +**Parameters**: + +- `withdrawal_id`: The unique withdrawal identifier (0x-prefixed hex) + +**Response**: `Option` + +```json +{ + "burn": { + "user": "0x...", + "token": "0x...", + "amount": "1000000000000000000", + "salt": "0x...", + "withdrawal_id": "0x...", + "chain_id": "1" + }, + "burn_proof": { + "siblings": ["0x...", "0x...", ...] + }, + "burn_index": "0x...", + "balance_root_at_burn": "0x...", + "state_commitment": { /* signed commitment */ } +} +``` + +## Testing + +### Unit Tests + +```bash +cargo test --package transfers +``` + +### Integration Tests + +The integration tests require a local Ethereum node (Reth): + +```bash +# Run all tests +cargo test --package transfers --tests + +# Run specific test +cargo test --package transfers --test tests test_api + +# Run performance test (requires Reth on PATH) +cargo test --package transfers --test tests test_performance -- --ignored +``` + +### Performance Test + +The performance test measures the system's throughput by: + +- Deploying the Transfers contract +- Deploying a test ERC20 token +- Performing multiple deposit β†’ transfer β†’ withdrawal cycles +- Measuring transactions per second (TPS) + +Results will show both on-chain transaction times and off-chain processing times. + +## Smart Contract + +### Transfers.sol + +The on-chain contract manages: + +- ERC20 token deposits (via `SafeERC20.safeTransferFrom`) +- Pending withdrawal tracking +- Merkle proof verification for withdrawals +- Signature verification for state commitments + +**Key Functions**: + +```solidity +// Deposit tokens into the bridge +function deposit(address token, uint256 amount) external + +// Request an off-chain transfer +function transfer(address to, address token, uint256 amount) external + +// Request a withdrawal (generates withdrawal_id) +function requestWithdrawal(address token, uint256 amount, bytes32 salt) external returns(bytes32) + +// Execute a withdrawal with merkle proof +function executeWithdrawal(Withdrawal calldata withdrawal) external +``` + +**Merkle Proof Verification**: + +The contract verifies two things: + +1. **Signature**: The state commitment is signed by the trusted oracle (POA signer) +2. **Merkle Proof**: The burn event is in the burn tree at the committed burn_root + +This provides trustless bridging: users can prove their withdrawals without trusting the off-chain app. + +## State Transitions + +All state transitions follow a **validate-then-apply** pattern: + +```rust +// 1. VALIDATE +let current_balance = get_balance(state, user, token); +let new_balance = current_balance.checked_sub(amount)?; // Returns error if insufficient + +// 2. APPLY +state.insert_balance(user, token, new_balance); +``` + +This ensures atomicity - either all state changes succeed or none do. + +### Error Handling + +The system uses an **availability-over-consistency** approach: + +- Individual event processing errors are logged but don't halt the system +- Invalid events (zero-value, insufficient balance, etc.) are skipped +- State continues to progress for valid events + +Common error scenarios: + +- Zero-value transactions β†’ Logged and skipped +- Self-transfers β†’ Logged and skipped +- Insufficient balance β†’ Logged and skipped +- Arithmetic overflow/underflow β†’ Logged and skipped + +## Security + +### Cryptographic Guarantees + +1. **State Commitments**: All state roots are signed by the application's private key +2. **Merkle Proofs**: Withdrawals require a valid proof path from burn event to burn_root +3. **Signature Verification**: Contract verifies signatures using `ecrecover` +4. **Replay Protection**: Each withdrawal_id can only be executed once + +### Trust Model + +- **POA (Proof of Authority)**: Users trust the application's signer to process events correctly +- **Verifiable State**: Users can verify their balances match expected values via API +- **Trustless Withdrawals**: Once a state commitment is signed, users can withdraw without further interaction with the app + +### Nonce Management + +Withdrawals use cryptographic uniqueness via keccak256 hashing: + +``` +withdrawal_id = keccak256(user, token, amount, salt, chainId) +``` + +The user-provided `salt` allows generating multiple withdrawal requests for the same amount. The contract tracks used withdrawal IDs to prevent replay attacks. + +## Development + +### Project Structure + +``` +transfers/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ lib.rs # Main application logic and oracle stream +β”‚ β”œβ”€β”€ state.rs # Combined State (AppState + ApiState) +β”‚ β”œβ”€β”€ app_state.rs # Proven state (merkle trees) and state transitions +β”‚ β”œβ”€β”€ api_state.rs # API-only state (withdrawal proofs) +β”‚ β”œβ”€β”€ server.rs # HTTP API server +β”‚ β”œβ”€β”€ signing.rs # Cryptographic signing utilities +β”‚ └── main.rs # CLI entry point +β”œβ”€β”€ contracts/ +β”‚ β”œβ”€β”€ Transfers.sol # Main bridge contract +β”‚ └── TestToken.sol # ERC20 token for testing +└── tests/ + └── tests.rs # Integration tests +``` + +### Adding New Features + +To add a new event type: + +1. Add event to `Transfers.sol`: + +```solidity +event NewEvent(address indexed user, uint256 amount); +``` + +2. Add to `decode_events!` macro in `app_state.rs`: + +```rust +decode_events!( + Deposit, + TransferRequest, + WithdrawalRequest, + WithdrawalCompleted, + NewEvent, // Add here +); +``` + +3. Handle in `update()` function: + +```rust +AppEvent::NewEvent(e) => { + handle_new_event(state, e.user, e.amount)?; +} +``` + +4. Implement handler function: + +```rust +pub fn handle_new_event( + state: &mut impl AccountState, + user: Address, + amount: U256, +) -> Result<()> { + // Your logic here +} +``` + +## Troubleshooting + +### Contracts Not Compiling + +```bash +# Ensure you're running the compilation from the workspace root +cd /path/to/void-examples +nix run .#compile-contracts +``` + +### Tests Failing + +```bash +# Contracts must be compiled first +nix run .#compile-contracts + +# Then run tests +cargo test --package transfers +``` + +### API Returns None for Withdrawal Proof + +- Ensure the withdrawal request was processed (check app logs) +- Verify at least one block has been processed after the withdrawal request +- Check that `latest_commitment` is not None (query `/get-state-commitment/`) + +### Performance Test Times Out + +- Increase block time in test: `.block_time("2s")` +- Ensure Reth is available on PATH: `which reth` +- Check that ports 8545/9000 are available + +## License + +See the root workspace LICENSE file. + +## Further Reading + +- [VOID Framework Documentation](https://docs.void.network) diff --git a/apps/transfers/src/api_state.rs b/apps/transfers/src/api_state.rs index 60d5b57..894093b 100644 --- a/apps/transfers/src/api_state.rs +++ b/apps/transfers/src/api_state.rs @@ -1,3 +1,29 @@ +//! API-specific state management for the Transfers bridge. +//! +//! This module manages state that is used for serving API queries but is **not included** +//! in the cryptographic state commitments. This includes: +//! - Pending withdrawal proofs waiting to be retrieved by users +//! - Latest signed state commitment for attaching to withdrawal proofs +//! - Block height/hash tracking for synchronization +//! +//! # Separation of Concerns +//! +//! The split between `AppState` (proven) and `ApiState` (not proven) enables: +//! - **Minimal commitments**: Only essential state (merkle roots) is signed +//! - **Efficient queries**: Withdrawal proofs are pre-computed and cached +//! - **Flexible storage**: API state could be moved to a separate database in the future +//! +//! # Withdrawal Proof Lifecycle +//! +//! 1. User calls `requestWithdrawal()` on-chain +//! 2. Off-chain app processes `WithdrawalRequest` event +//! 3. `handle_withdrawal_request` creates `PendingWithdrawal` and stores in `stored_proofs` +//! 4. State transition completes and commitment is signed +//! 5. User queries `/get-merkle-proof/{withdrawal_id}` API +//! 6. API combines `PendingWithdrawal` + `latest_commitment` β†’ `Withdrawal` +//! 7. User submits `Withdrawal` to `executeWithdrawal()` on-chain +//! 8. After execution, `WithdrawalCompleted` event triggers proof cleanup + use std::collections::HashMap; use alloy::primitives::{Address, B256, U256}; @@ -13,15 +39,30 @@ use crate::{ state::State, }; +/// API-only state for serving queries and managing withdrawal proofs. +/// +/// This state is not included in cryptographic commitments and is used purely +/// for API functionality. It tracks: +/// - Block synchronization state (height and hash) +/// - Pending withdrawal proofs waiting for user retrieval +/// - Latest signed commitment for attaching to withdrawal proofs #[derive(Default)] pub struct ApiState { + /// Height of the most recent processed block pub latest_block_height: Option, + /// Hash of the most recent processed block pub latest_block_hash: [u8; 32], - /// Stored withdrawal proofs (burn_index -> proof) + /// Pending withdrawal proofs indexed by withdrawal_id + /// + /// Proofs are added when `WithdrawalRequest` events are processed + /// and removed when `WithdrawalCompleted` events are processed. pub stored_proofs: HashMap, - /// Signed Commitment of the latest state + /// Most recent signed state commitment + /// + /// This is attached to withdrawal proofs so users can submit them + /// on-chain with a signature that verifies the burn tree root. // TODO: Consider removing the Option and always having a commitment (a signed empty commitment // at genesis) pub latest_commitment: Option>>, @@ -37,11 +78,31 @@ impl UpdateLatestBlock for ApiState { } } +/// Combined view of application state and API state. +/// +/// This struct provides simultaneous access to both: +/// - `app`: Read-only access to application state (for querying balances and generating proofs) +/// - `api`: Mutable access to API state (for storing withdrawal proofs and commitments) +/// +/// Used by `api_state::update` to create withdrawal proofs after state transitions complete. pub struct AllState<'a, App: AccountStateRef> { pub app: &'a App, pub api: &'a mut ApiState, } +/// Updates API state after a block's state transitions are complete. +/// +/// This function runs after `app_state::state_transition` has updated the merkle trees. +/// It processes withdrawal-related events to manage the proof storage: +/// - `WithdrawalRequest`: Generates and stores merkle proof for user retrieval +/// - `WithdrawalCompleted`: Cleans up stored proof after on-chain execution +/// +/// Other event types (`Deposit`, `TransferRequest`) don't require API state updates. +/// +/// # Arguments +/// +/// * `block` - The block of events that was just processed +/// * `state` - Combined access to app state (read-only) and API state (mutable) pub fn update(block: &Block, mut state: AllState<'_, impl AccountStateRef + AccountProofs>) { for bytes in &block.events { let Ok(input) = decode_input(bytes) else { @@ -67,7 +128,26 @@ pub fn update(block: &Block, mut state: AllState<'_, impl AccountStateRef + Acco } } -/// Process a withdrawal request - burns tokens and generates withdrawal proof +/// Generates and stores a merkle proof for a withdrawal request. +/// +/// After the application state has burned the user's tokens and recorded the burn event +/// in the burn tree, this function: +/// 1. Creates a `Burn` struct with the withdrawal details +/// 2. Captures the balance tree root at the time of the burn +/// 3. Generates a merkle proof from the burn tree +/// 4. Stores the `PendingWithdrawal` in `stored_proofs` for API retrieval +/// +/// Users can then query `/get-merkle-proof/{withdrawal_id}` to retrieve the proof. +/// +/// # Arguments +/// +/// * `state` - Combined app and API state access +/// * `user` - The account requesting withdrawal +/// * `token` - The ERC20 token being withdrawn +/// * `amount` - The amount of tokens to withdraw +/// * `withdrawal_id` - Unique identifier for this withdrawal +/// * `salt` - Random value used to generate the withdrawal ID +/// * `chain_id` - Destination chain for the withdrawal pub fn handle_withdrawal_request( state: &mut AllState<'_, impl AccountStateRef + AccountProofs>, user: Address, @@ -108,7 +188,16 @@ pub fn handle_withdrawal_request( .insert(withdrawal_id, pending_withdrawal.clone()); } -/// Handle withdrawal completed event - cleanup stored proof +/// Cleans up a withdrawal proof after on-chain execution. +/// +/// When a `WithdrawalCompleted` event is received, it means the user successfully +/// executed their withdrawal on-chain. The stored proof is no longer needed and +/// is removed to free memory. +/// +/// # Arguments +/// +/// * `state` - Combined app and API state access +/// * `withdrawal_id` - The unique identifier of the completed withdrawal pub fn handle_withdrawal_completed( state: &mut AllState<'_, impl AccountStateRef>, withdrawal_id: B256, diff --git a/apps/transfers/src/app_state.rs b/apps/transfers/src/app_state.rs index 14f53e4..0f632c9 100644 --- a/apps/transfers/src/app_state.rs +++ b/apps/transfers/src/app_state.rs @@ -1,3 +1,36 @@ +//! Core application state and transition logic for the Transfers bridge. +//! +//! This module defines: +//! - **Data Structures**: `Balance`, `Burn`, `Commit`, `Withdrawal` types +//! - **State Management**: `AppState` with two merkle trees (balances and burns) +//! - **Traits**: `AccountState`, `AccountStateRef`, `AccountProofs` for state abstraction +//! - **State Transitions**: Functions to process `Deposit`, `Transfer`, `WithdrawalRequest`, `WithdrawalCompleted` events +//! +//! # Architecture +//! +//! The `AppState` maintains two `GenericSparseMerkleTree` instances: +//! 1. **balance_tree**: Maps (user, token) β†’ Balance, tracking account balances +//! 2. **burn_tree**: Maps withdrawal_id β†’ Burn, storing burn events for proof generation +//! +//! Both trees use `KeccakHasher` to match Ethereum's hashing for on-chain proof verification. +//! +//! # State Transitions +//! +//! All state transitions follow a **validate-then-apply** pattern to ensure atomicity: +//! 1. Validate all conditions (sufficient balance, non-zero amounts, etc.) +//! 2. Compute new state values +//! 3. Apply all changes atomically +//! +//! This prevents partial updates and maintains consistency even if errors occur. +//! +//! # Event Processing +//! +//! The `state_transition` function processes blocks of events: +//! - Decodes events using the `decode_events!` macro +//! - Routes each event to the appropriate handler +//! - Logs errors but continues processing (availability over consistency) +//! - Returns after processing all events in the block + use alloy::{ primitives::{Address, B256, U256, keccak256}, sol_types::SolValue, @@ -15,6 +48,7 @@ use void_toolkit::{ use crate::Transfers::{Deposit, TransferRequest, WithdrawalCompleted, WithdrawalRequest}; +// Generate event decoder for all Transfers contract events decode_events!( Deposit, TransferRequest, @@ -22,6 +56,11 @@ decode_events!( WithdrawalCompleted ); +/// Represents an account balance for a specific token. +/// +/// Wraps `U256` with safe arithmetic operations (checked_add, checked_sub) +/// to prevent overflow/underflow. Implements `MerkleValue` for storage in +/// the balance merkle tree. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct Balance(pub U256); @@ -53,6 +92,20 @@ impl MerkleValue for Balance { } } +/// Represents a token burn event for a withdrawal request. +/// +/// When a user requests a withdrawal, their tokens are "burned" (deducted from their balance) +/// and this burn event is recorded in the burn merkle tree. The burn event contains all +/// information needed to execute the withdrawal on-chain. +/// +/// # Fields +/// +/// * `user` - The account that requested the withdrawal +/// * `token` - The ERC20 token address being withdrawn +/// * `amount` - The amount of tokens burned +/// * `salt` - Random value for generating unique withdrawal IDs +/// * `withdrawal_id` - Unique identifier for this withdrawal (keccak256 of all parameters) +/// * `chain_id` - The destination chain ID for the withdrawal #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Burn { pub user: Address, @@ -87,55 +140,121 @@ impl MerkleValue for Burn { } } +/// Complete withdrawal proof ready for on-chain execution. +/// +/// This struct contains everything needed to execute a withdrawal on-chain: +/// - The burn event data (user, token, amount, etc.) +/// - Merkle proof showing the burn is in the burn tree +/// - Signed state commitment proving the burn tree root +/// +/// Users retrieve this via the `/get-merkle-proof/{withdrawal_id}` API endpoint +/// and submit it to the `executeWithdrawal()` contract function. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Withdrawal { + /// The burn event being proven pub burn: Burn, + /// Merkle proof showing burn is in the burn tree pub burn_proof: MerkleProof, + /// The withdrawal ID used as the merkle tree index pub burn_index: B256, + /// Balance tree root at the time of the burn (for verification) pub balance_root_at_burn: B256, + /// Signed state commitment containing the burn tree root pub state_commitment: Signed>, } +/// Pending withdrawal waiting for a signed commitment. +/// +/// After a withdrawal request is processed, a `PendingWithdrawal` is created with +/// the burn proof but no signed commitment yet. Once the state is signed and committed, +/// it's converted to a full `Withdrawal` by adding the latest signed commitment. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PendingWithdrawal { + /// The burn event being proven pub burn: Burn, + /// Merkle proof showing burn is in the burn tree pub burn_proof: MerkleProof, + /// The withdrawal ID used as the merkle tree index pub burn_index: B256, + /// Balance tree root at the time of the burn pub balance_root_at_burn: B256, } -/// The witness struct for the bridge state +/// Cryptographic commitment to the current application state. +/// +/// A `Commit` packages the merkle roots of both state trees into a single +/// structure that will be: +/// 1. Serialized and hashed +/// 2. Signed by the application's private key +/// 3. Verified on-chain during withdrawal execution +/// +/// This is the "witness" that proves the off-chain state to on-chain verifiers. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Commit { + /// Root of the balance merkle tree pub balance_root: [u8; 32], + /// Root of the burn merkle tree pub burn_root: [u8; 32], } -// NOTE: Currently, State contains data that is included in the proofs generated by the app, as well -// as data that is not included in the proofs (like stored_proofs and latest_commitment)! These -// non-proven fields will most likely be migrated to the App struct when the toolkit supports doing -// this in a clean way. -/// The state of the bridge application +/// The proven application state for the Transfers bridge. +/// +/// `AppState` contains the two merkle trees that form the core of the bridge's state: +/// - **balance_tree**: Maps (user, token) pairs to balances +/// - **burn_tree**: Maps withdrawal_id to burn events +/// +/// Both trees use `GenericSparseMerkleTree` with `KeccakHasher` to ensure compatibility +/// with Ethereum's keccak256 hashing, allowing on-chain verification of merkle proofs. +/// +/// # State Commitment +/// +/// The roots of both trees are included in the `Commit` struct, which is: +/// 1. Hashed to create a state commitment +/// 2. Signed by the application's private key +/// 3. Used to verify withdrawal proofs on-chain +/// +/// This architecture enables trustless bridging: users can prove their withdrawals +/// against a signed state commitment without requiring the full state on-chain. #[derive(Debug)] pub struct AppState { - /// Account balances tree + /// Balance merkle tree: (user, token) β†’ Balance pub balance_tree: GenericSparseMerkleTree, - /// Burn events tree (for withdrawal proofs) + /// Burn event merkle tree: withdrawal_id β†’ Burn pub burn_tree: GenericSparseMerkleTree, } +/// Read-only access to account state. +/// +/// This trait provides query operations without allowing modifications, +/// useful for contexts that need to check balances without mutating state. pub trait AccountStateRef { + /// Gets the current balance for a user's token. fn get_balance(&self, user: Address, token: Address) -> Balance; } +/// Mutable access to account state for state transitions. +/// +/// Extends `AccountStateRef` with operations that modify state: +/// - Updating balances (deposits, transfers, withdrawals) +/// - Recording burn events (withdrawal requests) pub trait AccountState: AccountStateRef { + /// Updates a user's token balance in the balance tree. fn insert_balance(&mut self, user: Address, token: Address, new_balance: Balance); + + /// Records a burn event in the burn tree for withdrawal proof generation. fn insert_burn(&mut self, withdrawal_id: B256, burn: Burn); } +/// Access to merkle proofs and roots for generating withdrawal proofs. +/// +/// This trait provides the operations needed to create cryptographic proofs +/// that users submit on-chain to execute withdrawals. pub trait AccountProofs { + /// Returns the current balance tree root. fn balance_root(&self) -> B256; + + /// Generates a merkle proof for a specific withdrawal. fn burn_merkle_proof(&self, withdrawal_id: B256) -> MerkleProof; } @@ -200,6 +319,32 @@ impl AppState { // FUNCTIONS FOR STATE TRANSITIONS // ================================ +/// Main state transition function that processes a block of events. +/// +/// This function is called by the VOID framework for each block received from the oracle. +/// It decodes all events in the block and applies the appropriate state transitions. +/// +/// # Error Handling +/// +/// The function uses an **availability-over-consistency** approach: +/// - Individual event processing errors are logged but don't fail the entire block +/// - The system continues processing subsequent events +/// - This prevents a single malformed or invalid event from halting state progression +/// +/// Common errors that are logged and skipped: +/// - Zero-value transactions (deposits, transfers, withdrawals) +/// - Self-transfers (from == to) +/// - Insufficient balances +/// - Arithmetic overflow/underflow +/// +/// # Arguments +/// +/// * `block` - A block of events from the blockchain oracle +/// * `state` - Mutable reference to the application state +/// +/// # Returns +/// +/// Always returns `Ok(())` even if individual events fail processing pub fn state_transition( block: &void_toolkit::types::Block, state: &mut impl AccountState, @@ -226,7 +371,22 @@ pub fn state_transition( Ok(()) } -/// Handle all the different variants of AppEvent and update state accordingly +/// Processes a single decoded event and updates state accordingly. +/// +/// Routes each event type to its specific handler: +/// - `Deposit` β†’ `handle_deposit`: Credits user's balance +/// - `TransferRequest` β†’ `handle_transfer`: Moves tokens between accounts +/// - `WithdrawalRequest` β†’ `handle_withdrawal_request`: Burns tokens and creates proof +/// - `WithdrawalCompleted` β†’ No-op (handled in `api_state::update`) +/// +/// # Arguments +/// +/// * `state` - Mutable reference to the application state +/// * `event` - The decoded event to process +/// +/// # Returns +/// +/// `Ok(())` if the event was processed successfully, or an error describing what went wrong pub fn update(state: &mut impl AccountState, event: AppEvent) -> Result<()> { match event { AppEvent::Deposit(d) => { diff --git a/apps/transfers/src/lib.rs b/apps/transfers/src/lib.rs index 1669e98..1ea801f 100644 --- a/apps/transfers/src/lib.rs +++ b/apps/transfers/src/lib.rs @@ -1,3 +1,56 @@ +//! Transfers: A VOID Framework application for cross-chain token transfers with merkle proofs. +//! +//! This application implements a bridge system that enables users to: +//! - **Deposit** ERC20 tokens into the bridge contract +//! - **Transfer** tokens between accounts off-chain with instant finality +//! - **Withdraw** tokens back to the origin chain using cryptographic proofs +//! +//! # Architecture +//! +//! The Transfers app demonstrates advanced VOID Framework capabilities: +//! - Processing four event types: `Deposit`, `TransferRequest`, `WithdrawalRequest`, `WithdrawalCompleted` +//! - Maintaining two merkle trees: balance tree (account states) and burn tree (withdrawal proofs) +//! - Generating cryptographic proofs for withdrawal execution +//! - Split state architecture: proven application state vs API-only state +//! - HTTP API for balance queries and proof retrieval +//! +//! # State Management +//! +//! **AppState** (included in signed commitments): +//! - `balance_tree`: Tracks user balances per token using `GenericSparseMerkleTree` +//! - `burn_tree`: Stores burn events for generating withdrawal proofs +//! +//! **ApiState** (not included in commitments): +//! - `stored_proofs`: Pending withdrawal proofs indexed by withdrawal ID +//! - `latest_commitment`: Most recent signed state commitment for proof verification +//! +//! # Withdrawal Flow +//! +//! 1. User calls `requestWithdrawal()` on-chain, generating a unique withdrawal ID +//! 2. Off-chain state processes the event: burns tokens, updates balance tree +//! 3. User retrieves withdrawal proof via HTTP API +//! 4. User calls `executeWithdrawal()` with merkle proof to receive tokens +//! 5. Contract verifies proof against signed state commitment and transfers tokens +//! +//! # Example +//! +//! ```no_run +//! use transfers::run_app; +//! use alloy::signers::local::PrivateKeySigner; +//! use void_toolkit::oracle_types::config::Config; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! # /* +//! let signer = PrivateKeySigner::random(); +//! let server = "127.0.0.1:3501".to_string(); +//! let oracle_config = Config { /* ... */ }; +//! run_app(signer, server, oracle_config).await +//! # */ +//! # Ok(()) +//! } +//! ``` + use alloy::{signers::local::PrivateKeySigner, sol}; use futures::TryStreamExt; use void_toolkit::app::VoidStream; @@ -18,6 +71,7 @@ pub mod server; pub mod signing; pub mod state; +// Generate Rust bindings for the Transfers Solidity contract sol!( #[allow(missing_docs)] #[allow(clippy::too_many_arguments)] @@ -26,12 +80,21 @@ sol!( "contracts/build/transfers_abi.json" ); +/// The main application state container. +/// +/// `App` holds all shared state for the Transfers application, including: +/// - Application state (balance and burn merkle trees) +/// - API state (withdrawal proofs and latest signed commitment) +/// +/// All state access is synchronized using `Lock` to ensure thread safety. #[derive(Clone)] pub struct App { + /// Combined application and API state with thread-safe access pub state: Lock, } impl App { + /// Creates a new `App` with default (empty) state. pub fn new() -> Self { Self { state: Lock::new(State::default()), @@ -45,6 +108,22 @@ impl Default for App { } } +/// Runs the Transfers application. +/// +/// This is the main entry point for the application. It: +/// 1. Initializes the app with empty state +/// 2. Spawns the HTTP API server in the background +/// 3. Runs the oracle stream (blocks until shutdown) +/// +/// # Arguments +/// +/// * `signer` - Private key for signing state commitments +/// * `server_endpoint` - Address to bind the HTTP API server (e.g., "127.0.0.1:3501") +/// * `oracle_config` - Configuration for blockchain event sourcing +/// +/// # Returns +/// +/// Never returns under normal operation. Returns an error if initialization fails. pub async fn run_app( signer: PrivateKeySigner, server_endpoint: String, @@ -61,6 +140,20 @@ pub async fn run_app( Ok(()) } +/// Internal function that runs the oracle stream and processes events. +/// +/// This function: +/// 1. Connects to the blockchain via the oracle +/// 2. Streams blocks of events +/// 3. Applies state transitions for each block +/// 4. Signs the resulting state commitments +/// 5. Stores signed commitments for proof generation +/// +/// # Arguments +/// +/// * `app` - The application state +/// * `signer` - Private key for signing commitments +/// * `oracle_config` - Oracle configuration for event sourcing pub async fn run( app: App, signer: PrivateKeySigner, @@ -81,6 +174,20 @@ pub async fn run( Ok(()) } +/// Applies state transitions for a block of events. +/// +/// This function coordinates both types of state updates: +/// 1. **AppState** transitions (balance and burn tree updates via `state_transition`) +/// 2. **ApiState** updates (withdrawal proof generation and cleanup via `api_state::update`) +/// +/// # Arguments +/// +/// * `block` - The block of events to process +/// * `state` - The combined application and API state +/// +/// # Returns +/// +/// A tuple of (processed block, commitment with block height) or an error fn transition(block: Block, state: &mut State) -> anyhow::Result<(Block, Height)> { let res: anyhow::Result<_> = apply_transition(block, state, app_state::state_transition); let (block, commit) = res?; diff --git a/apps/transfers/src/main.rs b/apps/transfers/src/main.rs index 584762f..d19f9c4 100644 --- a/apps/transfers/src/main.rs +++ b/apps/transfers/src/main.rs @@ -1,8 +1,39 @@ +//! CLI entry point for the Transfers bridge application. +//! +//! This binary provides a command-line interface for running the Transfers app. +//! +//! # Usage +//! +//! ```bash +//! # Run with a specific private key from environment variable +//! transfers run --key PRIVATE_KEY oracle_config.yaml 127.0.0.1:3501 +//! +//! # Run with a random private key (for testing) +//! transfers run oracle_config.yaml 127.0.0.1:3501 +//! ``` +//! +//! # Arguments +//! +//! * `--key, -k` - (Optional) Name of environment variable containing the hex-encoded private key +//! * `oracle_config` - Path to the oracle configuration file (YAML or JSON) +//! * `server` - Address to bind the HTTP API server (e.g., "127.0.0.1:3501") +//! +//! # Configuration +//! +//! The oracle config file specifies: +//! - Blockchain RPC endpoint +//! - Contract addresses to monitor +//! - Event filters +//! - Oracle operation mode +//! +//! See `void-toolkit` documentation for oracle configuration details. + use std::path::PathBuf; use clap::{Args, Parser, Subcommand}; use transfers::signing::get_signer; +/// Transfers bridge CLI #[derive(Parser)] #[command(version, about, long_about = None)] struct Cli { @@ -10,20 +41,31 @@ struct Cli { command: Commands, } +/// Available commands #[derive(Subcommand, Clone)] enum Commands { - /// Run the state stream + /// Run the Transfers bridge application Run(Run), } +/// Arguments for the run command #[derive(Args, Clone)] struct Run { #[arg(long, short)] - /// The environment variable for the signing private key + /// Optional environment variable name containing the signing private key (hex) + /// + /// If not provided, a random key will be generated for testing. + /// For production, always provide a persistent key via environment variable. key: Option, - /// Path to the oracle config file + + /// Path to the oracle configuration file (YAML or JSON) + /// + /// The config specifies blockchain connection, contract addresses, and event filters. oracle_config: PathBuf, - /// The server endpoint + + /// HTTP API server bind address (e.g., "127.0.0.1:3501") + /// + /// The server will listen on this address for balance queries and withdrawal proof requests. server: String, } diff --git a/apps/transfers/src/server.rs b/apps/transfers/src/server.rs index d227622..c039eaa 100644 --- a/apps/transfers/src/server.rs +++ b/apps/transfers/src/server.rs @@ -1,3 +1,36 @@ +//! HTTP API server for the Transfers bridge. +//! +//! This module provides a REST API for querying bridge state and retrieving withdrawal proofs. +//! +//! # Endpoints +//! +//! ## `GET /` +//! Health check endpoint. Returns "OK". +//! +//! ## `GET /get-balance/{user}/{token}` +//! Query the balance of a specific user for a specific token. +//! - **Parameters**: `user` (Address), `token` (Address) +//! - **Returns**: `U256` balance amount +//! +//! ## `GET /get-state-commitment/` +//! Retrieve the latest signed state commitment. +//! - **Returns**: `Option>>` - the most recent signed commitment, or `None` if no blocks have been processed yet +//! +//! ## `GET /get-merkle-proof/{withdrawal_id}` +//! Retrieve the complete withdrawal proof for a specific withdrawal. +//! - **Parameters**: `withdrawal_id` (B256 hex string with 0x prefix) +//! - **Returns**: `Option` containing: +//! - Burn event data +//! - Merkle proof (siblings array for path to root) +//! - Signed state commitment +//! +//! Users submit this `Withdrawal` to the `executeWithdrawal()` contract function. +//! +//! # CORS +//! +//! The server enables CORS with permissive settings to allow browser-based dApps to +//! query the API from any origin. + use std::str::FromStr; use alloy::primitives::{Address, B256, U256}; @@ -14,6 +47,15 @@ use crate::{ app_state::{self, Commit}, }; +/// Runs the HTTP API server. +/// +/// Starts an axum server that serves the bridge API endpoints. +/// The server runs indefinitely until the process is terminated. +/// +/// # Arguments +/// +/// * `app` - The application state +/// * `server` - The address to bind (e.g., "127.0.0.1:3501") pub async fn run(app: App, server: String) { let app = Router::new() .route("/", get(|| async { "OK" })) @@ -32,7 +74,14 @@ pub async fn run(app: App, server: String) { axum::serve(listener, app).await.unwrap(); } -/// The default CORS layer. +/// Creates a permissive CORS layer for the API. +/// +/// Allows: +/// - Any origin +/// - GET and OPTIONS methods +/// - Content-Type header +/// +/// This enables browser-based dApps to query the API from any domain. pub fn cors_layer() -> CorsLayer { CorsLayer::new() .allow_origin(tower_http::cors::Any) @@ -40,6 +89,13 @@ pub fn cors_layer() -> CorsLayer { .allow_headers([http::header::CONTENT_TYPE]) } +/// Handler for `GET /get-balance/{user}/{token}`. +/// +/// Queries the balance merkle tree for the specified user's token balance. +/// +/// # Returns +/// +/// JSON-encoded `U256` representing the balance (0 if no balance exists) pub async fn get_balance( State(app): State, Path((user, token)): Path<(Address, Address)>, @@ -48,11 +104,41 @@ pub async fn get_balance( Json(balance) } +/// Handler for `GET /get-state-commitment/`. +/// +/// Returns the most recent signed state commitment, which includes: +/// - Block height +/// - Merkle roots (balance_root, burn_root) +/// - Signature from the application's private key +/// +/// # Returns +/// +/// JSON-encoded `Option>>` - `None` if no blocks processed yet pub async fn get_state_commitment(State(app): State) -> Json>>> { let commitment = app.state.access(|s| s.api_state.latest_commitment.clone()); Json(commitment) } +/// Handler for `GET /get-merkle-proof/{withdrawal_id}`. +/// +/// Retrieves the complete withdrawal proof for a specific withdrawal request. +/// The proof includes: +/// - Burn event data (user, token, amount, etc.) +/// - Merkle proof (siblings array) +/// - Signed state commitment +/// +/// Users submit this to `executeWithdrawal()` on the contract to receive their tokens. +/// +/// # Arguments +/// +/// * `withdrawal_id_str` - Hex-encoded withdrawal ID (with or without 0x prefix) +/// +/// # Returns +/// +/// JSON-encoded `Option` - `None` if: +/// - Withdrawal ID is invalid +/// - No pending withdrawal exists for this ID +/// - No commitment has been signed yet pub async fn get_merkle_proof( State(app): State, Path(withdrawal_id_str): Path, diff --git a/apps/transfers/src/signing.rs b/apps/transfers/src/signing.rs index 1b50b2c..45a5c9f 100644 --- a/apps/transfers/src/signing.rs +++ b/apps/transfers/src/signing.rs @@ -1,3 +1,19 @@ +//! Cryptographic signing utilities for state commitments. +//! +//! This module provides functions for: +//! - Loading private keys from environment variables +//! - Signing state commitments with Ethereum-compatible signatures +//! +//! # Signature Format +//! +//! The `sign` function creates signatures that can be verified on-chain using Solidity's +//! `ecrecover`. The signing process: +//! 1. Encodes (block_height, state_hash) using abi_encode_packed +//! 2. Hashes the encoded data with keccak256 +//! 3. Signs the hash using the private key +//! +//! This matches the verification logic in the Transfers.sol contract. + use alloy::{ hex::FromHex, primitives::{FixedBytes, keccak256}, @@ -6,7 +22,23 @@ use alloy::{ }; use void_toolkit::types::Height; -/// Get a signer from an environment variable or generate a random one. +/// Loads a private key signer from an environment variable or generates a random one. +/// +/// # Arguments +/// +/// * `key` - Optional name of the environment variable containing the private key (hex string) +/// +/// # Returns +/// +/// - If `key` is `Some(var_name)`: Loads private key from environment variable `var_name` +/// - If `key` is `None`: Generates a new random private key +/// +/// # Errors +/// +/// Returns an error if: +/// - The environment variable doesn't exist +/// - The environment variable contains invalid hex +/// - The hex doesn't represent a valid private key pub fn get_signer(key: Option) -> anyhow::Result { match key { Some(key) => { @@ -19,7 +51,26 @@ pub fn get_signer(key: Option) -> anyhow::Result { } } -/// Sign a proof using the provided signer with Ethereum message prefix. +/// Signs a state commitment for on-chain verification. +/// +/// Creates an Ethereum-compatible signature over the state commitment that can be +/// verified by the Transfers contract's `verifyOracleSignature` function. +/// +/// # Signing Process +/// +/// 1. ABI-encode-packed: `(block_height, state_hash)` β†’ bytes +/// 2. Keccak256 hash the encoded bytes +/// 3. Sign the hash using ECDSA +/// 4. Return signature as bytes (65 bytes: r, s, v) +/// +/// # Arguments +/// +/// * `signer` - The private key signer +/// * `digest` - The state commitment hash with block height +/// +/// # Returns +/// +/// 65-byte signature (r: 32 bytes, s: 32 bytes, v: 1 byte) pub fn sign(signer: &PrivateKeySigner, digest: Height<[u8; 32]>) -> anyhow::Result> { let encoded = (digest.block_height, digest.data).abi_encode_packed(); // Hash the encoded data diff --git a/apps/transfers/src/state.rs b/apps/transfers/src/state.rs index a25cc0f..eb581f3 100644 --- a/apps/transfers/src/state.rs +++ b/apps/transfers/src/state.rs @@ -1,3 +1,16 @@ +//! Combined state management for the Transfers application. +//! +//! This module defines the top-level `State` struct that combines: +//! - **AppState**: Proven state (balance and burn merkle trees) included in signed commitments +//! - **ApiState**: API-only state (stored proofs, latest commitment) not included in commitments +//! +//! The split architecture allows the application to maintain both: +//! 1. Core state that must be cryptographically committed and verified +//! 2. Auxiliary state used for serving API queries and managing proofs +//! +//! This separation enables efficient proof generation while keeping the signed commitment +//! data minimal and focused on the essential state transitions. + use alloy::primitives::{Address, B256}; use void_toolkit::app::UpdateLatestBlock; @@ -6,13 +19,24 @@ use crate::{ app_state::{AccountState, AccountStateRef, AppState, Balance, Burn, Commit}, }; +/// Combined application and API state. +/// +/// `State` is the top-level state container that holds both: +/// - Proven application state (merkle trees for balances and burns) +/// - Non-proven API state (withdrawal proofs and latest signed commitment) #[derive(Default)] pub struct State { + /// Application state included in cryptographic commitments pub app_state: AppState, + /// API state used for queries but not included in commitments pub api_state: ApiState, } impl From<&mut State> for Commit { + /// Converts the current state into a cryptographic commitment. + /// + /// This extracts the merkle roots from the app state (balance_root and burn_root) + /// and packages them into a `Commit` that will be signed. fn from(state: &mut State) -> Commit { (&mut state.app_state).into() } @@ -21,22 +45,38 @@ impl From<&mut State> for Commit { impl UpdateLatestBlock for State { type Error = ::Error; + /// Updates the tracked latest block height and hash. + /// + /// This is called by the VOID framework after processing each block to maintain + /// synchronization state. Delegates to `ApiState` since block tracking is not + /// part of the proven state. fn update_latest_block(&mut self, height: u64, hash: [u8; 32]) -> Result<(), Self::Error> { self.api_state.update_latest_block(height, hash) } } impl AccountStateRef for State { + /// Gets the current balance for a user's token. + /// + /// Delegates to `AppState` to query the balance merkle tree. fn get_balance(&self, user: Address, token: Address) -> Balance { self.app_state.get_balance(user, token) } } impl AccountState for State { + /// Updates a user's token balance in the balance merkle tree. + /// + /// This is used by state transition functions to apply balance changes + /// from deposits, transfers, and withdrawals. fn insert_balance(&mut self, user: Address, token: Address, new_balance: Balance) { self.app_state.insert_balance(user, token, new_balance); } + /// Records a burn event in the burn merkle tree. + /// + /// Burn events are stored to generate merkle proofs for withdrawal execution. + /// The withdrawal_id is used as the merkle tree key. fn insert_burn(&mut self, withdrawal_id: B256, burn: Burn) { self.app_state.insert_burn(withdrawal_id, burn); } diff --git a/apps/transfers/tests/tests.rs b/apps/transfers/tests/tests.rs index c6c6660..64de584 100644 --- a/apps/transfers/tests/tests.rs +++ b/apps/transfers/tests/tests.rs @@ -436,3 +436,364 @@ async fn test_complete_bridge_flow() { // This test validates the complete bridge lifecycle: L1 β†’ Bridge β†’ L1 // ============================================================================= } + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "Must be run in a shell that has a Reth node on path. Performance test for benchmarking."] +async fn test_performance() { + use rand::Rng; + + const NUM_OPERATIONS: usize = 50; // 50 cycles of depositβ†’transferβ†’withdrawal_request = 150 transactions + const PROGRESS_INTERVAL: usize = 10; + + println!("\n=== Transfers Performance Test ==="); + println!("Configuration:"); + println!(" - Number of operation cycles: {}", NUM_OPERATIONS); + println!(" - Operations per cycle: 3 (deposit, transfer, withdrawal_request)"); + println!(" - Total transactions: {}", NUM_OPERATIONS * 3); + println!(" - Progress updates every: {} cycles\n", PROGRESS_INTERVAL); + + // Setup: Start Reth node + println!("[Setup] Starting local Reth node..."); + let data_dir_path = tempfile::tempdir().unwrap(); + let reth = Reth::new() + .dev() + .block_time("100ms") + .data_dir(data_dir_path.path()) + .disable_discovery() + .http_port(7550) + .ws_port(7551) + .p2p_port(30310) + .auth_port(8560) + .spawn(); + + // Give Reth time to start up and pre-fund dev accounts + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + println!("[Setup] Reth node started on {}", reth.endpoint()); + + // Setup: Create signer + let mnemonic = "test test test test test test test test test test test junk"; + let path = "m/44'/60'/0'/0/0"; + let signer = MnemonicBuilder::::default() + .phrase(mnemonic) + .index(0) + .unwrap() + .derivation_path(path) + .unwrap() + .build() + .unwrap(); + let user_address = signer.address(); + println!("[Setup] Using signer address: {}", user_address); + + // Setup: Create provider with wallet + let provider = ProviderBuilder::new() + .wallet(signer.clone()) + .connect_http(reth.endpoint().parse().unwrap()); + + // Setup: Deploy Transfers contract + println!("[Setup] Deploying Transfers contract..."); + let transfers_contract = Transfers::deploy(&provider, user_address).await.unwrap(); + let transfers_addr = *transfers_contract.address(); + println!("[Setup] Transfers contract deployed at: {}", transfers_addr); + + // Setup: Deploy TestToken + println!("[Setup] Deploying TestToken contract..."); + let test_token = TestToken::deploy( + &provider, + "TestToken".to_string(), + "TST".to_string(), + 18u8, + U256::from(10_000_000e18), // 10 million tokens + ) + .await + .unwrap(); + let token_addr = *test_token.address(); + println!("[Setup] TestToken deployed at: {}", token_addr); + + // Setup: Mint tokens to main user + println!("[Setup] Minting tokens to main user..."); + test_token + .mint(user_address, U256::from(10_000_000e18)) + .send() + .await + .unwrap() + .watch() + .await + .unwrap(); + + // Setup: Approve transfers contract + println!("[Setup] Approving Transfers contract..."); + test_token + .approve(transfers_addr, U256::from(10_000_000e18)) + .send() + .await + .unwrap() + .watch() + .await + .unwrap(); + + // Setup: Start Transfers app + println!("[Setup] Starting Transfers app..."); + let transfers_endpoint = "127.0.0.1:3505"; + + let deposit_event_signature = Transfers::Deposit::SIGNATURE; + let transfer_event_signature = Transfers::TransferRequest::SIGNATURE; + let withdrawal_request_signature = Transfers::WithdrawalRequest::SIGNATURE; + let withdrawal_completed_signature = Transfers::WithdrawalCompleted::SIGNATURE; + + let query = QueryConfig { + stream_config: vec![StreamConfig::ChainContractLogs(ChainContractLogsConfig { + rpc_url: ConnectionType::Ws(reth.ws_endpoint()), + api_key_env: None, + contract_addresses: vec![transfers_addr.to_string()], + event_signatures: vec![ + deposit_event_signature.to_string(), + transfer_event_signature.to_string(), + withdrawal_request_signature.to_string(), + withdrawal_completed_signature.to_string(), + ], + })], + }; + + let block = BlockConfig { + max_block_size: 1000, + block_duration: std::time::Duration::from_millis(200), // Slightly longer than chain block time + }; + + let oracle_config = Config { query, block }; + + tokio::spawn({ + let app_signer = signer.clone(); + let endpoint = transfers_endpoint.to_string(); + async move { + transfers::run_app(app_signer, endpoint, oracle_config) + .await + .unwrap(); + } + }); + + wait_for_ok(&format!("http://{transfers_endpoint}/")).await; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + println!( + "[Setup] Transfers app started on http://{}\n", + transfers_endpoint + ); + + println!("=== Performance Test Started ===\n"); + + // Tracking variables + let mut on_chain_times = Vec::new(); + let mut off_chain_times = Vec::new(); + + // Create second test user for transfers + // Note: Bob needs a non-zero token amount so create_test_user will fund him with ETH + let (bob_signer, bob_address) = create_test_user( + "bob", + &provider, + &reth.endpoint(), + token_addr, + &transfers_addr, + U256::from(10e18), // ETH + U256::from(1e18), // 1 token (needed to trigger ETH funding in create_test_user) + ) + .await; + let bob_provider = ProviderBuilder::new() + .wallet(bob_signer) + .connect_http(reth.endpoint().parse().unwrap()); + let bob_transfers = Transfers::new(transfers_addr, &bob_provider); + + let start_time = std::time::Instant::now(); + + for i in 0..NUM_OPERATIONS { + // ======================= + // 1. DEPOSIT + // ======================= + let deposit_amount = U256::from(100e18); + let tx_start = std::time::Instant::now(); + + transfers_contract + .deposit(token_addr, deposit_amount) + .send() + .await + .unwrap() + .watch() + .await + .unwrap(); + + let tx_duration = tx_start.elapsed(); + on_chain_times.push(tx_duration); + + // Wait for off-chain processing + let off_chain_start = std::time::Instant::now(); + // User deposits (i+1)*100 total, but has already transferred i*10 to Bob in previous cycles + let expected_balance = U256::from((i + 1) * 100 - i * 10) * U256::from(1e18); + let timeout = std::time::Duration::from_secs(10); + loop { + let balance = get_balance(transfers_endpoint, user_address, token_addr).await; + if balance >= expected_balance { + break; + } + if off_chain_start.elapsed() > timeout { + panic!( + "Timeout waiting for user balance. Expected: {}, Got: {}, Cycle: {}", + expected_balance, balance, i + ); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + let off_chain_duration = off_chain_start.elapsed(); + off_chain_times.push(off_chain_duration); + + // ======================= + // 2. TRANSFER + // ======================= + let transfer_amount = U256::from(10e18); + let tx_start = std::time::Instant::now(); + + transfers_contract + .transfer(bob_address, token_addr, transfer_amount) + .send() + .await + .unwrap() + .watch() + .await + .unwrap(); + + let tx_duration = tx_start.elapsed(); + on_chain_times.push(tx_duration); + + // Wait for off-chain processing + let off_chain_start = std::time::Instant::now(); + // Bob receives 10 tokens each cycle, withdraws 10, so always has ~10 after transfer + // (The 1 token from initial mint is on L1, not in bridge) + let expected_bob_balance = U256::from(10e18); + let timeout = std::time::Duration::from_secs(10); + loop { + let balance = get_balance(transfers_endpoint, bob_address, token_addr).await; + if balance >= expected_bob_balance { + break; + } + if off_chain_start.elapsed() > timeout { + panic!( + "Timeout waiting for Bob balance. Expected: {}, Got: {}, Cycle: {}", + expected_bob_balance, balance, i + ); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + let off_chain_duration = off_chain_start.elapsed(); + off_chain_times.push(off_chain_duration); + + // ======================= + // 3. WITHDRAWAL REQUEST + // ======================= + let withdraw_amount = U256::from(10e18); + let mut rng = rand::thread_rng(); + let mut salt_bytes = [0u8; 32]; + rng.fill(&mut salt_bytes); + let salt = alloy::primitives::B256::from(salt_bytes); + + let tx_start = std::time::Instant::now(); + + bob_transfers + .requestWithdrawal(token_addr, withdraw_amount, salt) + .send() + .await + .unwrap() + .watch() + .await + .unwrap(); + + let tx_duration = tx_start.elapsed(); + on_chain_times.push(tx_duration); + + // Wait for off-chain processing (withdrawal proof generation) + let off_chain_start = std::time::Instant::now(); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let off_chain_duration = off_chain_start.elapsed(); + off_chain_times.push(off_chain_duration); + + // Progress reporting + if (i + 1) % PROGRESS_INTERVAL == 0 || i == NUM_OPERATIONS - 1 { + let elapsed = start_time.elapsed(); + let transactions_completed = (i + 1) * 3; + let tps = transactions_completed as f64 / elapsed.as_secs_f64(); + println!( + "[Progress] {}/{} cycles ({} txs) | Elapsed: {:.2}s | Current TPS: {:.2}", + i + 1, + NUM_OPERATIONS, + transactions_completed, + elapsed.as_secs_f64(), + tps + ); + } + } + + let total_duration = start_time.elapsed(); + println!("\n=== Performance Test Complete ===\n"); + + // Calculate statistics + let total_transactions = NUM_OPERATIONS * 3; + let avg_tps = total_transactions as f64 / total_duration.as_secs_f64(); + + let on_chain_avg = on_chain_times + .iter() + .sum::() + .as_millis() as f64 + / on_chain_times.len() as f64; + let on_chain_min = on_chain_times.iter().min().unwrap().as_millis(); + let on_chain_max = on_chain_times.iter().max().unwrap().as_millis(); + + let off_chain_avg = off_chain_times + .iter() + .sum::() + .as_millis() as f64 + / off_chain_times.len() as f64; + let off_chain_min = off_chain_times.iter().min().unwrap().as_millis(); + let off_chain_max = off_chain_times.iter().max().unwrap().as_millis(); + + println!("=== Detailed Metrics ===\n"); + println!("Total Performance:"); + println!(" - Total transactions: {}", total_transactions); + println!(" - Total duration: {:.2}s", total_duration.as_secs_f64()); + println!(" - Average TPS: {:.2} transactions/second\n", avg_tps); + + println!("On-Chain Transaction Times:"); + println!(" - Average: {:.0}ms", on_chain_avg); + println!(" - Minimum: {}ms", on_chain_min); + println!(" - Maximum: {}ms\n", on_chain_max); + + println!("Off-Chain Processing Times (Oracle + State Update):"); + println!(" - Average: {:.0}ms", off_chain_avg); + println!(" - Minimum: {}ms", off_chain_min); + println!(" - Maximum: {}ms\n", off_chain_max); + + // Final state verification + let final_user_balance = get_balance(transfers_endpoint, user_address, token_addr).await; + let final_bob_balance = get_balance(transfers_endpoint, bob_address, token_addr).await; + + println!("=== State Verification ==="); + println!(" - User balance: {}", final_user_balance); + println!(" - Bob balance: {}", final_bob_balance); + println!( + " - Total bridge balance: {}\n", + final_user_balance + final_bob_balance + ); + + // Verify expected balances + let expected_user_balance = + U256::from(NUM_OPERATIONS * 100 - NUM_OPERATIONS * 10) * U256::from(1e18); + // Bob's initial 1 token is on L1 (not in bridge), receives 10 per cycle, withdraws 10 per cycle + // So final bridge balance is 0 (the 1 token remains on L1) + let expected_bob_balance = U256::ZERO; + + assert_eq!( + final_user_balance, expected_user_balance, + "User balance mismatch" + ); + assert_eq!( + final_bob_balance, expected_bob_balance, + "Bob balance mismatch (should have 0 in bridge, 1 on L1)" + ); + + println!("=== Test Passed ==="); +}