From e756d042b32ef816993e245df8944fd875885940 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 26 Dec 2025 17:35:26 +0200 Subject: [PATCH 1/7] TSOL initial commit from Boosty Labs --- .gitattributes | 3 + .gitignore | 92 ++ .gitmodules | 6 + README.md | 307 ++++ backend/README.md | 156 ++ backend/build.gradle | 49 + .../gradle/wrapper/gradle-wrapper.properties | 7 + backend/gradlew | 248 ++++ backend/gradlew.bat | 93 ++ backend/settings.gradle | 1 + .../dao/tron/tsol/TsolBackendApplication.java | 15 + .../dao/tron/tsol/config/BatchProperties.java | 39 + .../dao/tron/tsol/config/ChainProperties.java | 30 + .../dao/tron/tsol/config/FeeProperties.java | 29 + .../tron/tsol/config/SchedulerProperties.java | 72 + .../tsol/config/SettlementProperties.java | 68 + .../dao/tron/tsol/config/TokenProperties.java | 29 + .../tron/tsol/config/WhitelistProperties.java | 38 + .../controller/BatchMonitoringController.java | 415 ++++++ .../controller/TransferIntentController.java | 24 + .../tron/tsol/event/BatchSubmittedEvent.java | 22 + .../tsol/event/BatchSubmittedEventReader.java | 233 +++ .../java/dao/tron/tsol/model/BatchStatus.java | 11 + .../java/dao/tron/tsol/model/LocalBatch.java | 26 + .../dao/tron/tsol/model/StoredTransfer.java | 18 + .../dao/tron/tsol/model/TransferData.java | 17 + .../tsol/model/TransferIntentRequest.java | 30 + .../tron/tsol/repository/BatchRepository.java | 20 + .../repository/InMemoryBatchRepository.java | 85 ++ .../tsol/scheduler/BatchingScheduler.java | 51 + .../tsol/scheduler/ExecutionScheduler.java | 93 ++ .../dao/tron/tsol/service/BatchService.java | 120 ++ .../tron/tsol/service/BatchSubmission.java | 18 + .../tron/tsol/service/ExecutionService.java | 104 ++ .../tron/tsol/service/MerkleTreeService.java | 261 ++++ .../service/SettlementContractClient.java | 13 + .../SettlementContractClientTrident.java | 438 ++++++ .../tsol/service/TransferIntentService.java | 40 + .../tron/tsol/service/WhitelistService.java | 409 ++++++ .../java/dao/tron/tsol/util/CryptoUtil.java | 45 + backend/src/main/resources/application.yaml | 69 + .../tsol/TsolBackendApplicationTests.java | 13 + .../BatchSubmittedEventReaderTest.java | 65 + .../tsol/service/MerkleRootDebugTest.java | 88 ++ .../tsol/service/MerkleTreeServiceTest.java | 454 ++++++ .../tsol/service/ScriptMerkleParityTest.java | 109 ++ backend/test-10-intents-batched.sh | 252 ++++ backend/test-20-intents.sh | 79 + backend/test-two-intents-batched-flow.sh | 141 ++ backend/test-two-intents-full-flow.sh | 462 ++++++ contracts/README.md | 91 ++ contracts/foundry.lock | 14 + contracts/foundry.toml | 23 + contracts/package.json | 35 + .../script/for-tests/DeployFeeModule.s.sol | 14 + .../script/for-tests/DeployRegistry.s.sol | 19 + .../script/for-tests/DeploySettlement.s.sol | 15 + contracts/script/for-tests/HelperConfig.s.sol | 44 + contracts/script/interactions/1_set.js | 59 + contracts/script/interactions/2_signRoot.js | 100 ++ contracts/script/interactions/3_updateRoot.js | 100 ++ .../script/interactions/4_submitBatch.js | 72 + .../script/interactions/5_approveToken.js | 64 + .../script/interactions/6_executeTransfer.js | 177 +++ contracts/script/interactions/addUpdater.js | 47 + .../script/interactions/approveAggregator.js | 41 + .../interactions/fullSuccessScenario.js | 165 +++ contracts/script/interactions/signature.json | 7 + .../script/merkle/batch/generateBatchRoot.py | 224 +++ .../merkle/batch/generateBatchRootDeploy.py | 256 ++++ .../script/merkle/batch/merkle_data.json | 398 +++++ .../merkle/batch/merkle_data_deploy.json | 91 ++ .../script/merkle/whitelist/generateRoot.py | 85 ++ .../whitelist/generateWhitelistRootDeploy.py | 88 ++ .../script/tron-deploy/deployFeeModule.js | 42 + .../script/tron-deploy/deploySettlement.js | 42 + .../tron-deploy/deployWhitelistRegistry.js | 46 + contracts/src/FeeModule.sol | 379 +++++ contracts/src/Settlement.sol | 556 +++++++ contracts/src/WhitelistRegistry.sol | 349 +++++ contracts/src/interfaces/IFeeModule.sol | 109 ++ contracts/src/interfaces/ISettlement.sol | 240 +++ .../src/interfaces/IWhitelistRegistry.sol | 174 +++ contracts/src/libraries/Errors.sol | 45 + contracts/src/libraries/Types.sol | 68 + .../integration/FeeModuleIntegration.t.sol | 329 +++++ .../integration/RegistryIntegration.t.sol | 547 +++++++ .../integration/SettlementIntegration.t.sol | 1295 +++++++++++++++++ contracts/test/mocks/MaliciousReceiver.sol | 8 + contracts/test/unit/FeeModuleUnit.t.sol | 219 +++ contracts/test/unit/RegistryUnit.t.sol | 486 +++++++ contracts/test/unit/SettlementUnit.sol | 605 ++++++++ .../test/utils/IntegrationDeployHelpers.sol | 58 + contracts/test/utils/TestConstants.sol | 21 + 94 files changed, 13354 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 README.md create mode 100644 backend/README.md create mode 100644 backend/build.gradle create mode 100644 backend/gradle/wrapper/gradle-wrapper.properties create mode 100755 backend/gradlew create mode 100644 backend/gradlew.bat create mode 100644 backend/settings.gradle create mode 100644 backend/src/main/java/dao/tron/tsol/TsolBackendApplication.java create mode 100644 backend/src/main/java/dao/tron/tsol/config/BatchProperties.java create mode 100644 backend/src/main/java/dao/tron/tsol/config/ChainProperties.java create mode 100644 backend/src/main/java/dao/tron/tsol/config/FeeProperties.java create mode 100644 backend/src/main/java/dao/tron/tsol/config/SchedulerProperties.java create mode 100644 backend/src/main/java/dao/tron/tsol/config/SettlementProperties.java create mode 100644 backend/src/main/java/dao/tron/tsol/config/TokenProperties.java create mode 100644 backend/src/main/java/dao/tron/tsol/config/WhitelistProperties.java create mode 100644 backend/src/main/java/dao/tron/tsol/controller/BatchMonitoringController.java create mode 100644 backend/src/main/java/dao/tron/tsol/controller/TransferIntentController.java create mode 100644 backend/src/main/java/dao/tron/tsol/event/BatchSubmittedEvent.java create mode 100644 backend/src/main/java/dao/tron/tsol/event/BatchSubmittedEventReader.java create mode 100644 backend/src/main/java/dao/tron/tsol/model/BatchStatus.java create mode 100644 backend/src/main/java/dao/tron/tsol/model/LocalBatch.java create mode 100644 backend/src/main/java/dao/tron/tsol/model/StoredTransfer.java create mode 100644 backend/src/main/java/dao/tron/tsol/model/TransferData.java create mode 100644 backend/src/main/java/dao/tron/tsol/model/TransferIntentRequest.java create mode 100644 backend/src/main/java/dao/tron/tsol/repository/BatchRepository.java create mode 100644 backend/src/main/java/dao/tron/tsol/repository/InMemoryBatchRepository.java create mode 100644 backend/src/main/java/dao/tron/tsol/scheduler/BatchingScheduler.java create mode 100644 backend/src/main/java/dao/tron/tsol/scheduler/ExecutionScheduler.java create mode 100644 backend/src/main/java/dao/tron/tsol/service/BatchService.java create mode 100644 backend/src/main/java/dao/tron/tsol/service/BatchSubmission.java create mode 100644 backend/src/main/java/dao/tron/tsol/service/ExecutionService.java create mode 100644 backend/src/main/java/dao/tron/tsol/service/MerkleTreeService.java create mode 100644 backend/src/main/java/dao/tron/tsol/service/SettlementContractClient.java create mode 100644 backend/src/main/java/dao/tron/tsol/service/SettlementContractClientTrident.java create mode 100644 backend/src/main/java/dao/tron/tsol/service/TransferIntentService.java create mode 100644 backend/src/main/java/dao/tron/tsol/service/WhitelistService.java create mode 100644 backend/src/main/java/dao/tron/tsol/util/CryptoUtil.java create mode 100644 backend/src/main/resources/application.yaml create mode 100644 backend/src/test/java/dao/tron/tsol/TsolBackendApplicationTests.java create mode 100644 backend/src/test/java/dao/tron/tsol/service/BatchSubmittedEventReaderTest.java create mode 100644 backend/src/test/java/dao/tron/tsol/service/MerkleRootDebugTest.java create mode 100644 backend/src/test/java/dao/tron/tsol/service/MerkleTreeServiceTest.java create mode 100644 backend/src/test/java/dao/tron/tsol/service/ScriptMerkleParityTest.java create mode 100755 backend/test-10-intents-batched.sh create mode 100755 backend/test-20-intents.sh create mode 100755 backend/test-two-intents-batched-flow.sh create mode 100755 backend/test-two-intents-full-flow.sh create mode 100644 contracts/README.md create mode 100644 contracts/foundry.lock create mode 100644 contracts/foundry.toml create mode 100644 contracts/package.json create mode 100644 contracts/script/for-tests/DeployFeeModule.s.sol create mode 100644 contracts/script/for-tests/DeployRegistry.s.sol create mode 100644 contracts/script/for-tests/DeploySettlement.s.sol create mode 100644 contracts/script/for-tests/HelperConfig.s.sol create mode 100644 contracts/script/interactions/1_set.js create mode 100644 contracts/script/interactions/2_signRoot.js create mode 100644 contracts/script/interactions/3_updateRoot.js create mode 100644 contracts/script/interactions/4_submitBatch.js create mode 100644 contracts/script/interactions/5_approveToken.js create mode 100644 contracts/script/interactions/6_executeTransfer.js create mode 100644 contracts/script/interactions/addUpdater.js create mode 100644 contracts/script/interactions/approveAggregator.js create mode 100644 contracts/script/interactions/fullSuccessScenario.js create mode 100644 contracts/script/interactions/signature.json create mode 100644 contracts/script/merkle/batch/generateBatchRoot.py create mode 100644 contracts/script/merkle/batch/generateBatchRootDeploy.py create mode 100644 contracts/script/merkle/batch/merkle_data.json create mode 100644 contracts/script/merkle/batch/merkle_data_deploy.json create mode 100644 contracts/script/merkle/whitelist/generateRoot.py create mode 100644 contracts/script/merkle/whitelist/generateWhitelistRootDeploy.py create mode 100644 contracts/script/tron-deploy/deployFeeModule.js create mode 100644 contracts/script/tron-deploy/deploySettlement.js create mode 100644 contracts/script/tron-deploy/deployWhitelistRegistry.js create mode 100644 contracts/src/FeeModule.sol create mode 100644 contracts/src/Settlement.sol create mode 100644 contracts/src/WhitelistRegistry.sol create mode 100644 contracts/src/interfaces/IFeeModule.sol create mode 100644 contracts/src/interfaces/ISettlement.sol create mode 100644 contracts/src/interfaces/IWhitelistRegistry.sol create mode 100644 contracts/src/libraries/Errors.sol create mode 100644 contracts/src/libraries/Types.sol create mode 100644 contracts/test/integration/FeeModuleIntegration.t.sol create mode 100644 contracts/test/integration/RegistryIntegration.t.sol create mode 100644 contracts/test/integration/SettlementIntegration.t.sol create mode 100644 contracts/test/mocks/MaliciousReceiver.sol create mode 100644 contracts/test/unit/FeeModuleUnit.t.sol create mode 100644 contracts/test/unit/RegistryUnit.t.sol create mode 100644 contracts/test/unit/SettlementUnit.sol create mode 100644 contracts/test/utils/IntegrationDeployHelpers.sol create mode 100644 contracts/test/utils/TestConstants.sol diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e580fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,92 @@ +######################################## +# OS / Editor +######################################## +.DS_Store +Thumbs.db + +######################################## +# Environment / Secrets +######################################## +.env +.env.* +*.env + +######################################## +# Logs +######################################## +*.log +hs_err_pid* +replay_pid* + +######################################## +# Node / JS +######################################## +node_modules/ +package-lock.json + +######################################## +# Java / Gradle +######################################## +*.class +*.jar +*.war +*.ear + +.gradle/ +build/ +**/build/ + +# Keep Gradle wrapper +!gradle/wrapper/gradle-wrapper.jar + +######################################## +# Repo-local build artifacts +######################################## +bin/ + +######################################## +# IDEs +######################################## +# IntelliJ IDEA +.idea/ +*.iml +*.ipr +*.iws +out/ + +# Eclipse / STS +.project +.classpath +.settings/ +.springBeans +.sts4-cache +.apt_generated +.factorypath + +# NetBeans +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +# VS Code +.vscode/ + +######################################## +# Solidity / Foundry +######################################## +# Compiler outputs +cache/ +out/ + +# Foundry broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +######################################## +# Docs / local notes +######################################## +docs/ +HELP.md diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e80ffd8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/openzeppelin/openzeppelin-contracts diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a87c3d --- /dev/null +++ b/README.md @@ -0,0 +1,307 @@ +# TRON Settlement Batching Layer (TSOL) + +This repository is a **monorepo** implementing the **TRON Settlement Batching Layer (TSOL)** — a hybrid off-chain/on-chain system for collecting transfer intents, batching them into Merkle trees, and executing transfers on TRON using Merkle proofs with optional whitelist-based batching. + +The system consists of two main parts: + +* **Backend (`/backend`)** — off-chain intent collection, batching, Merkle tree construction, and on-chain orchestration. +* **Smart contracts (`/contracts`)** — on-chain settlement, fee calculation, whitelist verification, and secure execution. + +--- + +## Repository structure + +```text +tron-settlement-batching-layer/ +│ +├── backend/ # Spring Boot backend (intent intake, batching, Merkle, execution) +├── contracts/ # Solidity smart contracts (Foundry-based) +├── docs/ # (optional) architecture & protocol docs +└── README.md # this file +``` + +--- + +## High-level flow + +``` +User / App + ↓ +Backend API (submit intent) + ↓ +Intent batching (off-chain) + ↓ +Merkle tree construction + ↓ +submitBatch(root) ───────────▶ Settlement.sol + │ + │ (time lock) + ▼ +executeTransfer(proof, data) ─▶ Merkle verification + Fee calculation + Token transfer +``` + +--- + +# Backend (`/backend`) + +Spring Boot backend responsible for **intent submission**, **batching**, **Merkle tree construction**, and **interaction with on-chain contracts**. + +### What the backend does + +* **Accepts transfer intents** + + * REST API for submitting `(from, to, amount, nonce, txType, …)` +* **Batching** + + * Periodic scheduler groups pending intents (size- or time-based) +* **Merkle** + + * Builds Merkle trees, computes root and per-transfer proofs +* **Settlement submission** + + * Submits batch metadata to `Settlement.sol` +* **Execution** + + * Executes individual transfers on-chain using Merkle proofs +* **Whitelist support** + + * For `txType = 2 (BATCHED)`: + + * Generates whitelist Merkle proofs + * Syncs whitelist root on startup +* **Monitoring APIs** + + * Script-friendly endpoints for debugging and automation + +--- + +## Backend tech stack + +* **Java**: JDK **25** (via Gradle toolchain) +* **Framework**: Spring Boot 4 (WebMVC, Validation) +* **TRON client**: Trident (`io.github.tronprotocol:trident`) +* **Crypto utilities**: web3j (ECDSA, ABI decoding) +* **Build**: Gradle + +--- + +## Backend requirements + +* JDK **25** +* `bash`, `curl`, `jq` (used by test scripts) + +--- + +## Backend quick start + +```bash +cd backend +./gradlew bootRun +``` + +* Default port: `8080` + +### Run tests + +```bash +./gradlew test +``` + +### Build runnable JAR + +```bash +./gradlew bootJar +java -jar build/libs/tsol-backend-0.0.1-SNAPSHOT.jar +``` + +--- + +## Backend configuration + +Configuration is resolved in the following order: + +1. **Environment variables** +2. **`.env` file** (via `spring-dotenv`) +3. **Defaults in `application.yaml`** + +Example `.env` (do **not** commit): + +```bash +# Server +PORT=8080 + +# TRON network +NODE_ENDPOINT=grpc.nile.trongrid.io:50051 +CHAIN_ID=3448148188 + +# Settlement +SETTLEMENT_ADDRESS=YOUR_SETTLEMENT_CONTRACT_BASE58 +UPDATER_PRIVATE_KEY=YOUR_64_CHAR_HEX_PRIVATE_KEY_NO_0x +UPDATER_ADDRESS=YOUR_AGGREGATOR_BASE58 + +# Whitelist (for txType=2) +WHITELIST_REGISTRY_ADDRESS=YOUR_WHITELIST_REGISTRY_BASE58 +WL_NEW_ROOT=0xYOUR_WHITELIST_ROOT_HEX +WL_NONCE=0 +WHITELIST_ADDRESSES=BASE58_ADDR_1,BASE58_ADDR_2 + +# Fee module +FEE_MODULE_ADDRESS=YOUR_FEE_MODULE_BASE58 +TOKEN_ADDRESS=TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf +``` + +--- + +## Backend API + +### Submit transfer intent + +**POST** `/api/intents` → `202 Accepted` + +```bash +curl -X POST "http://localhost:8080/api/intents" \ + -H "Content-Type: application/json" \ + -d '{ + "from": "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", + "to": "TFZMxv9HUzvsL3M7obrvikSQkuvJsopgMU", + "amount": "1000000", + "nonce": 123, + "timestamp": 1735000000, + "recipientCount": 1, + "txType": 0 + }' +``` + +**txType mapping:** + +* `0` — DELAYED +* `1` — INSTANT +* `2` — BATCHED (requires whitelist) + +--- + +### Monitoring endpoints + +* `GET /api/monitor/stats` +* `GET /api/monitor/batches` +* `GET /api/monitor/batch/{batchId}` +* `GET /api/monitor/merkle-root/{rootHash}` +* `GET /api/monitor/transfers` +* `POST /api/monitor/create-batch-now` + +--- + +## Backend test scripts + +Located in `/backend`: + +* `test-two-intents-full-flow.sh` +* `test-two-intents-batched-flow.sh` +* `test-20-intents.sh` +* `test-10-intents-batched.sh` + +Run example: + +```bash +./test-two-intents-full-flow.sh +``` + +--- + +## Backend notes + +* **No private key → no on-chain ops** +* **Minimum batch size = 2** +* **txType=2 requires whitelist** +* **Persistence is in-memory** (restart clears state) + +--- + +# Smart Contracts (`/contracts`) + +Solidity contracts implementing **on-chain settlement, fee logic, and whitelist verification**. + +Built and tested using **Foundry**. + +--- + +## Core contracts + +### WhitelistRegistry.sol + +* Stores whitelist Merkle root +* Verifies whitelist proofs +* Allows controlled root updates + +**Key functions** + +* `verifyWhitelist(user, proof)` +* `updateMerkleRoot(newRoot, sig)` +* `requestWhitelist(proof)` + +--- + +### FeeModule.sol + +Responsible for **fee calculation and accounting**. + +**Features** + +* Free tier limits +* Fee logic based on `TxType` +* Batch-level and per-user fee tracking +* Whitelist-aware batching discounts + +--- + +### Settlement.sol + +Core on-chain settlement logic. + +**Responsibilities** + +* Accept batched Merkle roots +* Enforce time lock (delayed finality) +* Verify Merkle proofs +* Apply fees +* Execute token transfers +* Prevent double execution + +--- + +## On-chain execution flow + +``` +submitBatch(merkleRoot, txCount) + ↓ + time lock + ↓ +executeTransfer(proof, data) + ↓ +Merkle verification +Fee calculation +Token transfer +``` + +--- + +## Development (contracts) + +```bash +cd contracts +forge build +forge test +``` + +--- + +## Summary + +This monorepo cleanly separates: + +* **Protocol logic (on-chain)** — deterministic, auditable, minimal +* **Operational logic (off-chain)** — batching, scheduling, orchestration + +Together they form a **scalable, auditable, and gas-efficient settlement layer** for TRON. diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..60bd9c2 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,156 @@ +### tsol-backend + +Spring Boot backend for submitting **transfer intents**, batching them into a **Merkle tree**, submitting the batch to an on-chain **Settlement** contract on TRON, and executing transfers using Merkle proofs (optionally with whitelist proofs for batched tx types). + +### What this service does + +- **Accept intents**: REST endpoint to submit transfer intents (from/to/amount/nonce/etc.). +- **Batching**: scheduler groups pending intents into batches (size/time based). +- **Merkle**: builds leaf hashes + Merkle root + per-transfer proofs. +- **Settlement submission**: submits the batch (root + tx count) to the Settlement contract. +- **Execution**: after timelock/unlock, executes each transfer on-chain using proofs. +- **Whitelist support**: for `txType=2` (BATCHED) the backend generates a whitelist proof and also syncs whitelist root on startup. +- **Monitoring APIs**: endpoints under `/api/monitor/*` for scripts and debugging. + +### Tech stack + +- **Java**: JDK **25** (Gradle toolchain is set to 25) +- **Framework**: Spring Boot 4 (WebMVC + Validation) +- **TRON client**: Trident (`io.github.tronprotocol:trident`) +- **Crypto utilities**: web3j (ECDSA/ABI decoding) + +### Requirements + +- **JDK 25** installed (or a Gradle toolchain configured on your machine to provision it) +- **bash + curl + jq** (the repo’s `test-*.sh` scripts use `jq`) + +### Quick start + +- **Run locally** (default port `8080`): + +```bash +./gradlew bootRun +``` + +- **Run tests**: + +```bash +./gradlew test +``` + +- **Build a runnable jar**: + +```bash +./gradlew bootJar +java -jar build/libs/tsol-backend-0.0.1-SNAPSHOT.jar +``` + +### Configuration + +Runtime config lives in `src/main/resources/application.yaml` and is driven by: + +- **(1) Environment variables** +- **(2) `.env` file** (supported via `spring-dotenv`) +- **(3) Defaults in `application.yaml`** + +Create a `.env` in the repo root (do **not** commit it): + +```bash +# Server +PORT=8080 + +# TRON gRPC endpoint (Nile default) +NODE_ENDPOINT=grpc.nile.trongrid.io:50051 +CHAIN_ID=3448148188 + +# Settlement contract + aggregator key +SETTLEMENT_ADDRESS=YOUR_SETTLEMENT_CONTRACT_BASE58 +UPDATER_PRIVATE_KEY=YOUR_64_CHAR_HEX_PRIVATE_KEY_NO_0x + +# Optional +UPDATER_ADDRESS=YOUR_AGGREGATOR_BASE58 + +# Whitelist (required for txType=2 / BATCHED) +WHITELIST_REGISTRY_ADDRESS=YOUR_WHITELIST_REGISTRY_BASE58 +WL_NEW_ROOT=0xYOUR_WHITELIST_ROOT_HEX +WL_NONCE=0 +WHITELIST_ADDRESSES=BASE58_ADDR_1,BASE58_ADDR_2 + +# Fee module + token +FEE_MODULE_ADDRESS=YOUR_FEE_MODULE_BASE58 +TOKEN_ADDRESS=TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf +``` + +### API + +#### Submit a transfer intent + +- **Endpoint**: `POST /api/intents` +- **Response**: `202 Accepted` + +Example: + +```bash +curl -X POST "http://localhost:8080/api/intents" \ + -H "Content-Type: application/json" \ + -d '{ + "from": "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", + "to": "TFZMxv9HUzvsL3M7obrvikSQkuvJsopgMU", + "amount": "1000000", + "nonce": 123, + "timestamp": 1735000000, + "recipientCount": 1, + "txType": 0 + }' +``` + +Request fields: + +- **from**: TRON address (base58) +- **to**: TRON address (base58) +- **amount**: string decimal (commonly token smallest-unit as a string) +- **nonce**: integer +- **timestamp**: unix seconds +- **recipientCount**: used by fee logic (scripts use `1` for txType `0/1`, and `>1` for txType `2`) +- **txType**: integer mapped to Solidity `uint8` (scripts commonly use `0`=DELAYED, `1`=INSTANT, `2`=BATCHED) + +#### Monitoring endpoints (script-friendly) + +- **GET** `/api/monitor/stats`: scheduler status + summary counts +- **GET** `/api/monitor/batches`: all batches with transfers and stats +- **GET** `/api/monitor/batch/{batchId}`: one batch by on-chain batchId +- **GET** `/api/monitor/merkle-root/{rootHash}`: find batch by Merkle root +- **GET** `/api/monitor/transfers`: all transfers across all batches +- **POST** `/api/monitor/create-batch-now`: manual batching trigger (requires at least 2 pending intents) + +### Repo test scripts + +These scripts assume the backend is running on `http://localhost:8080` and your `.env` config is set for Nile. + +- `test-two-intents-full-flow.sh`: submits 2 intents, forces batching, monitors execution +- `test-two-intents-batched-flow.sh`: same as above but uses `txType=2` and validates whitelist proof generation +- `test-20-intents.sh`: submits 20 intents (alternating txType 0/1) and waits for batching+execution +- `test-10-intents-batched.sh`: submits 10 intents with `txType=2`, forces batches, waits for completion + +Run example: + +```bash +./test-two-intents-full-flow.sh +``` + +### Important notes / troubleshooting + +- **No private key = no on-chain ops**: if `UPDATER_PRIVATE_KEY` is missing/invalid, the app will start but blockchain operations (submit/execute/event reads) will be disabled. +- **Minimum batch size is 2**: the scheduler and `/create-batch-now` require at least 2 pending intents (single-tx batches don’t produce valid Merkle proofs in this implementation). +- **txType=2 requires whitelist**: + - `WHITELIST_ADDRESSES` must include the `from` address + - `WHITELIST_REGISTRY_ADDRESS` and `WL_NEW_ROOT` must be correct + - Restart the backend after changing whitelist config (root sync runs on startup) +- **Persistence**: current repository is **in-memory** (`InMemoryBatchRepository`) — restarting the service clears batch state. + +### Docs + +- `FUNCTIONALITY_TABLE.md`: high-level “done vs missing” feature tracking +- `HELP.md`: Spring/Gradle reference links (generated template) + + diff --git a/backend/build.gradle b/backend/build.gradle new file mode 100644 index 0000000..8190afb --- /dev/null +++ b/backend/build.gradle @@ -0,0 +1,49 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.0' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'dao.tron' +version = '0.0.1-SNAPSHOT' +description = 'Demo project for Spring Boot' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation('io.github.tronprotocol:trident:0.10.0') + + // Environment variables from .env file support + implementation 'me.paulschwarz:spring-dotenv:4.0.0' + + // Web3j for cryptographic operations (ECDSA signing) + implementation 'org.web3j:crypto:4.9.8' + // Web3j core (ABI decode utilities used for event log decoding) + implementation 'org.web3j:core:4.9.8' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-validation-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..23449a2 --- /dev/null +++ b/backend/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend/gradlew b/backend/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/backend/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend/gradlew.bat b/backend/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/backend/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/settings.gradle b/backend/settings.gradle new file mode 100644 index 0000000..01d9ebc --- /dev/null +++ b/backend/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'tsol-backend' diff --git a/backend/src/main/java/dao/tron/tsol/TsolBackendApplication.java b/backend/src/main/java/dao/tron/tsol/TsolBackendApplication.java new file mode 100644 index 0000000..e69b2e5 --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/TsolBackendApplication.java @@ -0,0 +1,15 @@ +package dao.tron.tsol; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class TsolBackendApplication { + + public static void main(String[] args) { + SpringApplication.run(TsolBackendApplication.class, args); + } + +} diff --git a/backend/src/main/java/dao/tron/tsol/config/BatchProperties.java b/backend/src/main/java/dao/tron/tsol/config/BatchProperties.java new file mode 100644 index 0000000..0e049da --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/config/BatchProperties.java @@ -0,0 +1,39 @@ +package dao.tron.tsol.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "batch") +@Data +public class BatchProperties { + + /** + * Maximum number of transactions per batch + */ + private Integer maxTxPerBatch; + + /** + * Timelock duration in seconds before batch can be executed + */ + private Long timelockDuration; + + /** + * Current batch Merkle root (hex format with 0x prefix) + * Example: 0x82067662081cf3c1061cae00166d580285a337264c1eb3c91673579a814d32ea + */ + private String merkleRoot; +} + + + + + + + + + + + + diff --git a/backend/src/main/java/dao/tron/tsol/config/ChainProperties.java b/backend/src/main/java/dao/tron/tsol/config/ChainProperties.java new file mode 100644 index 0000000..8a27961 --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/config/ChainProperties.java @@ -0,0 +1,30 @@ +package dao.tron.tsol.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "chain") +@Data +public class ChainProperties { + + /** + * TRON chain ID + * Nile testnet: 3448148188 + * Mainnet: 728126428 + */ + private Long id; +} + + + + + + + + + + + + diff --git a/backend/src/main/java/dao/tron/tsol/config/FeeProperties.java b/backend/src/main/java/dao/tron/tsol/config/FeeProperties.java new file mode 100644 index 0000000..f205b8d --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/config/FeeProperties.java @@ -0,0 +1,29 @@ +package dao.tron.tsol.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "fee") +@Data +public class FeeProperties { + + /** + * Fee module contract address (base58 format) + * Example: TUqVYQLKtNvLCjHw6uGPLw4Qmw7vXEavnc + */ + private String moduleAddress; +} + + + + + + + + + + + + diff --git a/backend/src/main/java/dao/tron/tsol/config/SchedulerProperties.java b/backend/src/main/java/dao/tron/tsol/config/SchedulerProperties.java new file mode 100644 index 0000000..3d1d097 --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/config/SchedulerProperties.java @@ -0,0 +1,72 @@ +package dao.tron.tsol.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "scheduler") +public class SchedulerProperties { + + private BatchingConfig batching = new BatchingConfig(); + private ExecutionConfig execution = new ExecutionConfig(); + + @Data + public static class BatchingConfig { + /** + * Enable/disable automatic batching + * Default: true + */ + private boolean enabled = true; + + /** + * Maximum number of intents before triggering batch creation + * Default: 5 intents + */ + private int maxIntents = 5; + + /** + * Maximum delay in seconds before triggering batch creation + * Default: 30 seconds + */ + private long maxDelaySeconds = 30; + + /** + * How often to check for batching conditions (in milliseconds) + * Default: 3000ms (3 seconds) + */ + private long checkIntervalMs = 3000; + } + + @Data + public static class ExecutionConfig { + /** + * How often to check for unlocked batches to execute (in milliseconds) + * Default: 5000ms (5 seconds) + */ + private long checkIntervalMs = 5000; + + /** + * Enable/disable automatic execution + * Default: true + */ + private boolean enabled = true; + + /** + * Max number of transfers to execute concurrently per batch. + * Default: 3 (bounded parallelism; improves throughput while staying gentle on public nodes). + */ + private int maxParallel = 3; + } +} + + + + + + + + + + diff --git a/backend/src/main/java/dao/tron/tsol/config/SettlementProperties.java b/backend/src/main/java/dao/tron/tsol/config/SettlementProperties.java new file mode 100644 index 0000000..68e185b --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/config/SettlementProperties.java @@ -0,0 +1,68 @@ +package dao.tron.tsol.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "settlement") +@Data +public class SettlementProperties { + + /** + * gRPC or HTTP endpoint for TRON node + * Example: grpc.nile.trongrid.io:50051 + */ + private String nodeEndpoint; + + /** + * Settlement contract address (base58 format) + * Example: TAhZaywaWM1zAQPADJA39FyoQk8cokRLCd + */ + private String contractAddress; + + /** + * Aggregator private key (hex format, 64 characters) + */ + private String privateKey; + + /** + * Aggregator address (base58 format, derived from private key) + * Example: TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M + */ + private String aggregatorAddress; + + /** + * Transaction polling settings (to reduce RPC load). + */ + private Polling polling = new Polling(); + + @Data + public static class Polling { + /** + * Timeout for getting TransactionInfo after broadcasting a tx. + */ + private long txInfoTimeoutSeconds = 60; + /** + * Initial poll interval for TransactionInfo. + */ + private long txInfoPollInitialMs = 250; + /** + * Maximum poll interval for TransactionInfo (backoff cap). + */ + private long txInfoPollMaxMs = 2000; + + /** + * Timeout for reading BatchSubmitted event. + */ + private long batchSubmittedTimeoutSeconds = 60; + /** + * Initial poll interval for BatchSubmitted event. + */ + private long batchSubmittedPollInitialMs = 500; + /** + * Maximum poll interval for BatchSubmitted event (backoff cap). + */ + private long batchSubmittedPollMaxMs = 3000; + } +} diff --git a/backend/src/main/java/dao/tron/tsol/config/TokenProperties.java b/backend/src/main/java/dao/tron/tsol/config/TokenProperties.java new file mode 100644 index 0000000..3e634b2 --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/config/TokenProperties.java @@ -0,0 +1,29 @@ +package dao.tron.tsol.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "token") +@Data +public class TokenProperties { + + /** + * ERC20 token contract address (base58 format) + * Example: TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf + */ + private String address; +} + + + + + + + + + + + + diff --git a/backend/src/main/java/dao/tron/tsol/config/WhitelistProperties.java b/backend/src/main/java/dao/tron/tsol/config/WhitelistProperties.java new file mode 100644 index 0000000..ab931f4 --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/config/WhitelistProperties.java @@ -0,0 +1,38 @@ +package dao.tron.tsol.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "whitelist") +@Data +public class WhitelistProperties { + + /** + * Whitelist registry contract address (base58 format) + * Example: TToEDBXQkGuYGsnyJASTM5JZweb7Rvrnfn + */ + private String registryAddress; + + /** + * Current Merkle root for whitelist (hex format with 0x prefix) + * Example: 0x02012517de2680f90c5eb1b6c64e04e21424609e331954b45e202ace05e2938b + */ + private String merkleRoot; + + /** + * Nonce for whitelist updates + */ + private Long nonce; + + /** + * List of whitelisted addresses (base58 format) + * Example: ["TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", "TVKAAcqpQxz3J4waayePr8dQjSQ2XHkdbF"] + */ + private java.util.List addresses; +} + + + + diff --git a/backend/src/main/java/dao/tron/tsol/controller/BatchMonitoringController.java b/backend/src/main/java/dao/tron/tsol/controller/BatchMonitoringController.java new file mode 100644 index 0000000..c40af5b --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/controller/BatchMonitoringController.java @@ -0,0 +1,415 @@ +package dao.tron.tsol.controller; + +import dao.tron.tsol.model.LocalBatch; +import dao.tron.tsol.model.StoredTransfer; +import dao.tron.tsol.model.TransferData; +import dao.tron.tsol.service.TransferIntentService; +import dao.tron.tsol.service.BatchService; +import dao.tron.tsol.service.MerkleTreeService; +import dao.tron.tsol.config.SchedulerProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.*; +import java.util.stream.Stream; + +/** + * Comprehensive monitoring endpoint for batches, transfers, and Merkle trees + * Shows all information from the repository + */ +@Slf4j +@RestController +@RequestMapping("/api/monitor") +public class BatchMonitoringController { + + private final BatchService batchService; + private final MerkleTreeService merkleTreeService; + private final TransferIntentService intentService; + private final SchedulerProperties schedulerProps; + + public BatchMonitoringController(BatchService batchService, + MerkleTreeService merkleTreeService, + dao.tron.tsol.service.SettlementContractClient settlementClient, + TransferIntentService intentService, + SchedulerProperties schedulerProps) { + this.batchService = batchService; + this.merkleTreeService = merkleTreeService; + this.intentService = intentService; + this.schedulerProps = schedulerProps; + } + + + + /** + * GET /api/monitor/batches + * Get all batches with complete information + */ + @GetMapping("/batches") + public ResponseEntity> getAllBatches() { + Map response = new LinkedHashMap<>(); + + try { + List batches = batchService.getBatches(); + + List> batchInfo = new ArrayList<>(); + + for (LocalBatch batch : batches) { + Map info = buildBatchInfo(batch); + batchInfo.add(info); + } + + response.put("status", "SUCCESS"); + response.put("totalBatches", batches.size()); + response.put("batches", batchInfo); + + // Summary statistics + int totalTransfers = batches.stream() + .mapToInt(b -> b.getTransfers() != null ? b.getTransfers().size() : 0) + .sum(); + + int executedTransfers = batches.stream() + .flatMap(b -> b.getTransfers() != null ? b.getTransfers().stream() : java.util.stream.Stream.empty()) + .mapToInt(t -> t.isExecuted() ? 1 : 0) + .sum(); + + response.put("statistics", Map.of( + "totalBatches", batches.size(), + "totalTransfers", totalTransfers, + "executedTransfers", executedTransfers, + "pendingTransfers", totalTransfers - executedTransfers + )); + + } catch (Exception e) { + log.error("Error getting all batches", e); + response.put("status", "ERROR"); + response.put("error", e.getMessage()); + return ResponseEntity.status(500).body(response); + } + + return ResponseEntity.ok(response); + } + + /** + * GET /api/monitor/stats + * + * Script-friendly endpoint used by test scripts in repo root. + */ + @GetMapping("/stats") + public ResponseEntity> getStats() { + Map response = new LinkedHashMap<>(); + + List batches = batchService.getBatches(); + int pendingIntents = intentService.getPendingCount(); + + int totalBatches = batches.size(); + long completedBatches = batches.stream().filter(b -> b.getStatus() != null && b.getStatus().name().equals("COMPLETED")).count(); + + int totalTransfersInBatches = batches.stream() + .mapToInt(b -> b.getTransfers() != null ? b.getTransfers().size() : 0) + .sum(); + + int executedTransfers = batches.stream() + .flatMap(b -> b.getTransfers() != null ? b.getTransfers().stream() : Stream.empty()) + .mapToInt(t -> t.isExecuted() ? 1 : 0) + .sum(); + + response.put("status", "SUCCESS"); + response.put("schedulers", Map.of( + "batching", Map.of( + "enabled", schedulerProps.getBatching().isEnabled(), + "maxIntents", schedulerProps.getBatching().getMaxIntents(), + "maxDelaySeconds", schedulerProps.getBatching().getMaxDelaySeconds() + ), + "execution", Map.of( + "enabled", schedulerProps.getExecution().isEnabled() + ) + )); + + response.put("statistics", Map.of( + "totalTransfers", totalTransfersInBatches + pendingIntents, + "pendingTransfers", pendingIntents, + "executedTransfers", executedTransfers, + "totalBatches", totalBatches, + "completedBatches", completedBatches + )); + + return ResponseEntity.ok(response); + } + + /** + * POST /api/monitor/create-batch-now + * + * Script-friendly manual trigger for batching. + */ + @PostMapping("/create-batch-now") + public ResponseEntity> createBatchNow() { + Map response = new LinkedHashMap<>(); + + try { + int pending = intentService.getPendingCount(); + if (pending < 2) { + response.put("success", false); + response.put("error", "Need at least 2 pending intents to create a valid batch (current=" + pending + ")"); + return ResponseEntity.badRequest().body(response); + } + + int maxIntents = schedulerProps.getBatching().getMaxIntents(); + int before = batchService.getBatches().size(); + + batchService.createAndSubmitBatch(maxIntents); + + List afterBatches = batchService.getBatches(); + if (afterBatches.size() <= before) { + response.put("success", false); + response.put("error", "Batch was not created (no new LocalBatch stored)"); + return ResponseEntity.status(500).body(response); + } + + LocalBatch newest = afterBatches.stream() + .max(Comparator.comparingLong(LocalBatch::getLocalId)) + .orElseThrow(); + + response.put("success", true); + response.put("batchId", newest.getOnChainBatchId()); + response.put("merkleRoot", newest.getMerkleRootHex()); + response.put("txCount", newest.getTxCount()); + return ResponseEntity.ok(response); + } catch (Exception e) { + response.put("success", false); + response.put("error", e.getMessage()); + return ResponseEntity.status(500).body(response); + } + } + + /** + * GET /api/monitor/batch/{batchId} + * Get complete information about a specific batch + */ + @GetMapping("/batch/{batchId}") + public ResponseEntity> getBatchDetails(@PathVariable Long batchId) { + Map response = new LinkedHashMap<>(); + + try { + LocalBatch batch = batchService.getByOnChainBatchId(batchId); + + Map info = buildBatchInfo(batch); + + response.put("status", "SUCCESS"); + // Script compatibility: scripts expect a top-level "batch" object + response.put("batch", info); + // Also keep legacy flat keys for humans/debugging + response.putAll(info); + + } catch (IllegalArgumentException e) { + response.put("status", "NOT_FOUND"); + response.put("error", e.getMessage()); + return ResponseEntity.status(404).body(response); + } catch (Exception e) { + log.error("Error getting batch details", e); + response.put("status", "ERROR"); + response.put("error", e.getMessage()); + return ResponseEntity.status(500).body(response); + } + + return ResponseEntity.ok(response); + } + + /** + * GET /api/monitor/merkle-root/{rootHash} + * Get batch by Merkle root hash + */ + @GetMapping("/merkle-root/{rootHash}") + public ResponseEntity> getBatchByMerkleRoot(@PathVariable String rootHash) { + Map response = new LinkedHashMap<>(); + + try { + // Ensure root has 0x prefix + if (!rootHash.startsWith("0x")) { + rootHash = "0x" + rootHash; + } + + LocalBatch batch = batchService.getByMerkleRoot(rootHash); + + Map info = buildBatchInfo(batch); + + response.put("status", "SUCCESS"); + response.putAll(info); + + } catch (IllegalArgumentException e) { + response.put("status", "NOT_FOUND"); + response.put("error", e.getMessage()); + return ResponseEntity.status(404).body(response); + } catch (Exception e) { + log.error("Error getting batch by merkle root", e); + response.put("status", "ERROR"); + response.put("error", e.getMessage()); + return ResponseEntity.status(500).body(response); + } + + return ResponseEntity.ok(response); + } + + /** + * GET /api/monitor/transfers + * Get all transfers across all batches + */ + @GetMapping("/transfers") + public ResponseEntity> getAllTransfers() { + Map response = new LinkedHashMap<>(); + + try { + List batches = batchService.getBatches(); + List> allTransfers = new ArrayList<>(); + + for (LocalBatch batch : batches) { + if (batch.getTransfers() == null) continue; + + for (int i = 0; i < batch.getTransfers().size(); i++) { + StoredTransfer st = batch.getTransfers().get(i); + Map transferInfo = buildTransferInfo(st, i, batch); + allTransfers.add(transferInfo); + } + } + + response.put("status", "SUCCESS"); + response.put("totalTransfers", allTransfers.size()); + response.put("transfers", allTransfers); + + // Group by status + long executed = allTransfers.stream().filter(t -> Boolean.TRUE.equals(t.get("executed"))).count(); + long pending = allTransfers.size() - executed; + + response.put("summary", Map.of( + "total", allTransfers.size(), + "executed", executed, + "pending", pending + )); + + } catch (Exception e) { + log.error("Error getting all transfers", e); + response.put("status", "ERROR"); + response.put("error", e.getMessage()); + return ResponseEntity.status(500).body(response); + } + + return ResponseEntity.ok(response); + } + + private Map buildBatchInfo(LocalBatch batch) { + Map info = new LinkedHashMap<>(); + + info.put("batchId", batch.getOnChainBatchId()); + info.put("submitTxId", batch.getSubmitTxId()); + info.put("merkleRoot", batch.getMerkleRootHex()); + info.put("txCount", batch.getTxCount()); + info.put("submittedAt", batch.getSubmittedAt()); + info.put("submittedAtReadable", batch.getSubmittedAt() > 0 ? + new Date(batch.getSubmittedAt() * 1000).toString() : "N/A"); + info.put("status", batch.getStatus() != null ? batch.getStatus().toString() : "UNKNOWN"); + info.put("unlockTime", batch.getUnlockTime()); + info.put("unlockTimeReadable", batch.getUnlockTime() > 0 ? + new Date(batch.getUnlockTime() * 1000).toString() : "N/A"); + + if (batch.getTransfers() != null) { + info.put("transferCount", batch.getTransfers().size()); + + List> transfers = new ArrayList<>(); + for (int i = 0; i < batch.getTransfers().size(); i++) { + StoredTransfer st = batch.getTransfers().get(i); + transfers.add(buildTransferInfo(st, i, batch)); + } + info.put("transfers", transfers); + + // Transfer execution summary + long executed = batch.getTransfers().stream().filter(StoredTransfer::isExecuted).count(); + info.put("executionSummary", Map.of( + "total", batch.getTransfers().size(), + "executed", executed, + "pending", batch.getTransfers().size() - executed + )); + } else { + info.put("transferCount", 0); + info.put("transfers", Collections.emptyList()); + } + + return info; + } + + private Map buildTransferInfo(StoredTransfer st, int index, LocalBatch batch) { + Map info = new LinkedHashMap<>(); + + TransferData td = st.getTxData(); + + info.put("index", index); + info.put("batchId", batch.getOnChainBatchId()); + // Script compatibility: scripts expect transfer.txData.{from,to,amount,...} + Map txData = new LinkedHashMap<>(); + txData.put("from", td.getFrom()); + txData.put("to", td.getTo()); + txData.put("amount", td.getAmount()); + txData.put("nonce", td.getNonce()); + txData.put("timestamp", td.getTimestamp()); + txData.put("recipientCount", td.getRecipientCount()); + txData.put("txType", td.getTxType()); + txData.put("batchId", td.getBatchId()); + info.put("txData", txData); + + // Keep legacy flattened fields for existing users + info.put("from", td.getFrom()); + info.put("to", td.getTo()); + info.put("amount", td.getAmount()); + info.put("nonce", td.getNonce()); + info.put("timestamp", td.getTimestamp()); + info.put("timestampReadable", new Date(td.getTimestamp() * 1000).toString()); + info.put("recipientCount", td.getRecipientCount()); + info.put("txType", td.getTxType()); + info.put("executed", st.isExecuted()); + info.put("executionTxId", st.getExecutionTxId()); + info.put("proofSize", st.getTxProof() != null ? st.getTxProof().size() : 0); + // Helpful for txType=2 monitoring (BATCHED requires a whitelist proof). + // Keep only the size (do not expose full proof array in monitoring response). + int wlSize = st.getWhitelistProof() != null ? st.getWhitelistProof().size() : 0; + info.put("whitelistProofSize", wlSize); + + // Calculate tx hash for reference + byte[] txHash = merkleTreeService.leafHash(td, batch.getBatchSalt()); + info.put("txHash", "0x" + bytesToHex(txHash)); + + return info; + } + + @SuppressWarnings("unused") + private Map buildDetailedTransferInfo(StoredTransfer st, int index, LocalBatch batch) { + Map info = buildTransferInfo(st, index, batch); + + if (st.getTxProof() != null && !st.getTxProof().isEmpty()) { + info.put("merkleProof", st.getTxProof()); + } else { + info.put("merkleProof", Collections.emptyList()); + } + + if (st.getWhitelistProof() != null && !st.getWhitelistProof().isEmpty()) { + info.put("whitelistProof", st.getWhitelistProof()); + } else { + info.put("whitelistProof", Collections.emptyList()); + } + + info.put("batch", Map.of( + "batchId", batch.getOnChainBatchId(), + "merkleRoot", batch.getMerkleRootHex(), + "status", batch.getStatus() != null ? batch.getStatus().toString() : "UNKNOWN" + )); + + return info; + } + + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } +} + diff --git a/backend/src/main/java/dao/tron/tsol/controller/TransferIntentController.java b/backend/src/main/java/dao/tron/tsol/controller/TransferIntentController.java new file mode 100644 index 0000000..78dc67e --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/controller/TransferIntentController.java @@ -0,0 +1,24 @@ +package dao.tron.tsol.controller; + +import dao.tron.tsol.model.TransferIntentRequest; +import dao.tron.tsol.service.TransferIntentService; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/intents") +public class TransferIntentController { + + private final TransferIntentService intentService; + + public TransferIntentController(TransferIntentService intentService) { + this.intentService = intentService; + } + + @PostMapping + public ResponseEntity submitIntent(@Valid @RequestBody TransferIntentRequest req) { + intentService.addIntent(req); + return ResponseEntity.accepted().build(); + } +} diff --git a/backend/src/main/java/dao/tron/tsol/event/BatchSubmittedEvent.java b/backend/src/main/java/dao/tron/tsol/event/BatchSubmittedEvent.java new file mode 100644 index 0000000..715ffdd --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/event/BatchSubmittedEvent.java @@ -0,0 +1,22 @@ +package dao.tron.tsol.event; + +/** + * DTO representing the Settlement BatchSubmitted event. + * + * Solidity: + * event BatchSubmitted(uint64 batchId, bytes32 merkleRoot, uint32 txCount, uint48 timestamp); + * + * NOTE: + * - Older contracts emitted all params as non-indexed (all values in log.data) + * - Newer contracts index batchId + merkleRoot (so those are in topics, while txCount+timestamp are in log.data) + * + * This record represents the fully decoded values independent of how they were indexed. + */ +public record BatchSubmittedEvent( + long batchId, + String merkleRootHex, + int txCount, + long timestamp +) {} + + diff --git a/backend/src/main/java/dao/tron/tsol/event/BatchSubmittedEventReader.java b/backend/src/main/java/dao/tron/tsol/event/BatchSubmittedEventReader.java new file mode 100644 index 0000000..e11adaa --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/event/BatchSubmittedEventReader.java @@ -0,0 +1,233 @@ +package dao.tron.tsol.event; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.tron.trident.core.ApiWrapper; +import org.tron.trident.proto.Response; +import org.tron.trident.utils.Numeric; +import org.web3j.abi.FunctionReturnDecoder; +import org.web3j.abi.TypeReference; +import org.web3j.abi.datatypes.Type; +import org.web3j.abi.datatypes.generated.Bytes32; +import org.web3j.abi.datatypes.generated.Uint256; +import org.web3j.abi.datatypes.generated.Uint32; +import org.web3j.abi.datatypes.generated.Uint64; +import org.web3j.crypto.Hash; + +import java.time.Duration; +import java.util.concurrent.ThreadLocalRandom; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +/** + * Reads Settlement BatchSubmitted event from TRON tx receipt logs using Trident. + *

+ * Requirements: + * - poll ApiWrapper.getTransactionInfoById(txid) + * - scan TransactionInfo.log[] topics for topic0 == keccak256("BatchSubmitted(uint64,bytes32,uint32,uint48)") + * - decode indexed params from topics when present (new contracts index batchId and merkleRoot) + * - decode log.data for non-indexed params (txCount, timestamp) + * - fallback handled elsewhere + */ +@Slf4j +@Service +public class BatchSubmittedEventReader { + + public static final String EVENT_SIGNATURE = "BatchSubmitted(uint64,bytes32,uint32,uint48)"; + + // topic0 = keccak256(eventSignature) + private static final String TOPIC0_HEX = Hash.sha3String(EVENT_SIGNATURE); // 0x... + private static final String TOPIC0_NORM32 = normalizeHexN(TOPIC0_HEX).toLowerCase(Locale.ROOT); + + private final ApiWrapper wrapper; + + public BatchSubmittedEventReader(dao.tron.tsol.config.SettlementProperties settlementProps) { + String privateKey = settlementProps.getPrivateKey(); + if (privateKey == null || privateKey.isBlank() || privateKey.equals("YOUR_PRIVATE_KEY_HERE")) { + this.wrapper = null; + log.warn("BatchSubmittedEventReader: missing UPDATER_PRIVATE_KEY, event reading disabled."); + } else { + this.wrapper = ApiWrapper.ofNile(privateKey); + } + } + + public Optional readWithTimeout(String txId, Duration timeout, Duration pollInterval) { + if (wrapper == null) return Optional.empty(); + long deadline = System.currentTimeMillis() + timeout.toMillis(); + long sleepMs = Math.max(200, pollInterval.toMillis()); + // Cap backoff at ~5x the initial interval (or 3s minimum cap). + long maxSleepMs = Math.max(sleepMs * 5, 3000L); + + while (System.currentTimeMillis() < deadline) { + Response.TransactionInfo info; + try { + info = wrapper.getTransactionInfoById(txId); + } catch (Exception e) { + log.debug("txInfo not available yet for {}: {}", txId, e.getMessage()); + info = null; + } + + if (info != null) { + Optional ev = findEventInTxInfo(info); + if (ev.isPresent()) return ev; + } + + // Backoff + jitter to reduce load on public nodes (especially when many txs are in-flight). + long jitter = ThreadLocalRandom.current().nextLong(0, 150); + if (!sleepQuietly(sleepMs + jitter)) return Optional.empty(); + sleepMs = Math.min(maxSleepMs, (long) Math.ceil(sleepMs * 1.5)); + } + + return Optional.empty(); + } + + public Optional findEventInTxInfo(Response.TransactionInfo info) { + if (info == null) return Optional.empty(); + + // TRON: receipt/logs may exist even if reverted; caller should validate receipt separately if desired. + int logCount = info.getLogCount(); + if (logCount == 0) return Optional.empty(); + + for (int i = 0; i < logCount; i++) { + Response.TransactionInfo.Log l = info.getLog(i); + if (l.getTopicsCount() == 0) continue; + + String topic0 = Numeric.toHexString(l.getTopics(0).toByteArray()); + if (!normalizeHexN(topic0).equalsIgnoreCase(TOPIC0_NORM32)) { + continue; + } + + try { + // New Settlement contract (per sc/src/interfaces/ISettlement.sol): + // event BatchSubmitted(uint64 indexed batchId, bytes32 indexed merkleRoot, uint32 txCount, uint48 timestamp); + if (l.getTopicsCount() >= 3) { + String topicBatchId = Numeric.toHexString(l.getTopics(1).toByteArray()); + String topicMerkleRoot = Numeric.toHexString(l.getTopics(2).toByteArray()); + String dataHex = Numeric.toHexString(l.getData().toByteArray()); + return Optional.of(decodeIndexedLog(topicBatchId, topicMerkleRoot, dataHex)); + } + + // Backward-compatibility: if contracts ever change to non-indexed params. + String dataHex = Numeric.toHexString(l.getData().toByteArray()); + return Optional.of(decodeLogData(dataHex)); + } catch (Exception e) { + log.warn("Failed to decode BatchSubmitted log data for tx {}: {}", info.getId(), e.getMessage()); + } + } + + return Optional.empty(); + } + + /** + * Decode ABI log.data for BatchSubmitted(uint64,bytes32,uint32,uint48). + * + * Data layout (4 x 32-byte slots): + * 0: uint64 batchId + * 1: bytes32 merkleRoot + * 2: uint32 txCount + * 3: uint48 timestamp (encoded as uint256 slot) + */ + public static BatchSubmittedEvent decodeLogData(String dataHex) { + List> decoded = decodeWeb3Abi( + dataHex, + new TypeReference() {}, + new TypeReference() {}, + new TypeReference() {}, + new TypeReference() {} + ); + requireDecodedSize(decoded, 4); + + Uint64 batchId = (Uint64) decoded.get(0); + Bytes32 root = (Bytes32) decoded.get(1); + Uint32 txCount = (Uint32) decoded.get(2); + Uint256 ts = (Uint256) decoded.get(3); + + return new BatchSubmittedEvent( + batchId.getValue().longValue(), + "0x" + Numeric.toHexStringNoPrefix(root.getValue()), + txCount.getValue().intValue(), + ts.getValue().longValue() + ); + } + + /** + * Decode indexed BatchSubmitted log: + * topics[1] = uint64 batchId (left padded to 32 bytes) + * topics[2] = bytes32 merkleRoot + * data = abi.encode(uint32 txCount, uint48 timestamp) => 2x 32-byte slots + */ + public static BatchSubmittedEvent decodeIndexedLog(String topicBatchIdHex, String topicMerkleRootHex, String dataHex) { + java.math.BigInteger batchId = Numeric.toBigInt(topicBatchIdHex); + String merkleRootHex = normalizeHexN(topicMerkleRootHex); + + List> decoded = decodeWeb3Abi( + dataHex, + new TypeReference() {}, + new TypeReference() {} // timestamp stored in 32-byte slot + ); + requireDecodedSize(decoded, 2); + + Uint32 txCount = (Uint32) decoded.get(0); + Uint256 ts = (Uint256) decoded.get(1); + + return new BatchSubmittedEvent( + batchId.longValue(), + merkleRootHex, + txCount.getValue().intValue(), + ts.getValue().longValue() + ); + } + + private static String strip0x(String v) { + if (v == null) return ""; + return v.startsWith("0x") || v.startsWith("0X") ? v.substring(2) : v; + } + + private static String ensure0x(String hex) { + String h = hex == null ? "" : hex; + return (h.startsWith("0x") || h.startsWith("0X")) ? h : ("0x" + h); + } + + private static String normalizeHexN(String hex) { + String c = strip0x(hex); + int n = 32 * 2; + // Ensure fixed width: take least-significant bytes, left-pad with 0s + if (c.length() < n) { + c = "0".repeat(n - c.length()) + c; + } else if (c.length() > n) { + c = c.substring(c.length() - n); + } + return "0x" + c; + } + + private static void requireDecodedSize(List decoded, int expected) { + if (decoded.size() != expected) { + throw new IllegalStateException("Unexpected decoded outputs=" + decoded.size() + ", expected=" + expected); + } + } + + private static boolean sleepQuietly(long ms) { + try { + Thread.sleep(ms); + return true; + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return false; + } + } + + private static List> decodeWeb3Abi(String dataHex, TypeReference... outputs) { + String hex = ensure0x(dataHex); + + @SuppressWarnings({"rawtypes", "unchecked"}) + List> typed = (List) Arrays.asList(outputs); + + @SuppressWarnings({"rawtypes", "unchecked"}) + List> decoded = (List) FunctionReturnDecoder.decode(hex, typed); + return decoded; + } +} + + diff --git a/backend/src/main/java/dao/tron/tsol/model/BatchStatus.java b/backend/src/main/java/dao/tron/tsol/model/BatchStatus.java new file mode 100644 index 0000000..955920b --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/model/BatchStatus.java @@ -0,0 +1,11 @@ +package dao.tron.tsol.model; + +public enum BatchStatus { + CREATED, + SUBMITTED_ONCHAIN, + UNLOCKED, + EXECUTING, + COMPLETED, + FAILED +} + diff --git a/backend/src/main/java/dao/tron/tsol/model/LocalBatch.java b/backend/src/main/java/dao/tron/tsol/model/LocalBatch.java new file mode 100644 index 0000000..2ceffd3 --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/model/LocalBatch.java @@ -0,0 +1,26 @@ +package dao.tron.tsol.model; + +import lombok.Data; + +import java.util.List; + +@Data +public class LocalBatch { + + private long localId; + private long onChainBatchId; + private String submitTxId; + private String merkleRootHex; + private int txCount; + private long submittedAt; // unix seconds (from BatchSubmitted event) + private long unlockTime; // unix seconds + /** + * Salt used when computing txHash/leaf hashes for this batch. + * MUST match the value passed to on-chain submitBatch(..., batchSalt). + * + * Note: batchId is NOT part of txHash anymore; only batchSalt is. + */ + private long batchSalt; + private BatchStatus status; + private List transfers; +} diff --git a/backend/src/main/java/dao/tron/tsol/model/StoredTransfer.java b/backend/src/main/java/dao/tron/tsol/model/StoredTransfer.java new file mode 100644 index 0000000..52c72a9 --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/model/StoredTransfer.java @@ -0,0 +1,18 @@ +package dao.tron.tsol.model; + + +import lombok.Data; + +import java.util.List; + +@Data +public class StoredTransfer { + + private TransferData txData; + private List txProof; // hex-encoded bytes32[] + private List whitelistProof; // hex-encoded bytes32[] + private boolean executed; + /** TRON transaction id of the successful on-chain executeTransfer (if executed). */ + private String executionTxId; +} + diff --git a/backend/src/main/java/dao/tron/tsol/model/TransferData.java b/backend/src/main/java/dao/tron/tsol/model/TransferData.java new file mode 100644 index 0000000..24163c8 --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/model/TransferData.java @@ -0,0 +1,17 @@ +package dao.tron.tsol.model; + +import lombok.Data; + +@Data +public class TransferData { + + private String from; + private String to; + private String amount; + private long nonce; + private long timestamp; + private int recipientCount; + private long batchId; // filled after submitBatch + private int txType; +} + diff --git a/backend/src/main/java/dao/tron/tsol/model/TransferIntentRequest.java b/backend/src/main/java/dao/tron/tsol/model/TransferIntentRequest.java new file mode 100644 index 0000000..b6c085d --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/model/TransferIntentRequest.java @@ -0,0 +1,30 @@ +package dao.tron.tsol.model; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class TransferIntentRequest { + + @NotBlank + private String from; + + @NotBlank + private String to; + + @NotBlank + private String amount; // string decimal + + @NotNull + private Long nonce; + + @NotNull + private Long timestamp; // unix seconds + + @NotNull + private Integer recipientCount; // for fee calc + + @NotNull + private Integer txType; // map to Solidity uint8 +} diff --git a/backend/src/main/java/dao/tron/tsol/repository/BatchRepository.java b/backend/src/main/java/dao/tron/tsol/repository/BatchRepository.java new file mode 100644 index 0000000..fd3599e --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/repository/BatchRepository.java @@ -0,0 +1,20 @@ +package dao.tron.tsol.repository; + + +import dao.tron.tsol.model.LocalBatch; + +import java.util.List; +import java.util.Optional; + +public interface BatchRepository { + + void save(LocalBatch batch); + + List findAll(); + + Optional findByLocalId(long localId); + + Optional findByOnChainBatchId(long onChainBatchId); + + Optional findByMerkleRoot(String merkleRootHex); +} diff --git a/backend/src/main/java/dao/tron/tsol/repository/InMemoryBatchRepository.java b/backend/src/main/java/dao/tron/tsol/repository/InMemoryBatchRepository.java new file mode 100644 index 0000000..df1d3b2 --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/repository/InMemoryBatchRepository.java @@ -0,0 +1,85 @@ +package dao.tron.tsol.repository; + +import dao.tron.tsol.model.LocalBatch; +import org.springframework.stereotype.Repository; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +@Repository +public class InMemoryBatchRepository implements BatchRepository { + + // key: localId + private final Map batchesByLocalId = new ConcurrentHashMap<>(); + + // key: on-chain batchId + private final Map localIdByOnChainId = new ConcurrentHashMap<>(); + + // key: merkleRootHex + private final Map localIdByMerkleRoot = new ConcurrentHashMap<>(); + + // key: submitTxId + private final Map localIdBySubmitTxId = new ConcurrentHashMap<>(); + + // key: merkleRootHex -> batchId (requested mapping) + private final Map batchIdByMerkleRoot = new ConcurrentHashMap<>(); + + // key: submitTxId -> batchId (requested mapping) + private final Map batchIdBySubmitTxId = new ConcurrentHashMap<>(); + + private final AtomicLong localIdSeq = new AtomicLong(1); + + @Override + public synchronized void save(LocalBatch batch) { + // assign localId if new + if (batch.getLocalId() == 0L) { + batch.setLocalId(localIdSeq.getAndIncrement()); + } + + batchesByLocalId.put(batch.getLocalId(), batch); + + if (batch.getOnChainBatchId() != 0L) { + localIdByOnChainId.put(batch.getOnChainBatchId(), batch.getLocalId()); + } + + if (batch.getMerkleRootHex() != null) { + localIdByMerkleRoot.put(batch.getMerkleRootHex(), batch.getLocalId()); + if (batch.getOnChainBatchId() != 0L) { + batchIdByMerkleRoot.put(batch.getMerkleRootHex(), batch.getOnChainBatchId()); + } + } + + if (batch.getSubmitTxId() != null && !batch.getSubmitTxId().isBlank()) { + localIdBySubmitTxId.put(batch.getSubmitTxId(), batch.getLocalId()); + if (batch.getOnChainBatchId() != 0L) { + batchIdBySubmitTxId.put(batch.getSubmitTxId(), batch.getOnChainBatchId()); + } + } + + } + + @Override + public List findAll() { + return new ArrayList<>(batchesByLocalId.values()); + } + + @Override + public Optional findByLocalId(long localId) { + return Optional.ofNullable(batchesByLocalId.get(localId)); + } + + @Override + public Optional findByOnChainBatchId(long onChainBatchId) { + Long localId = localIdByOnChainId.get(onChainBatchId); + if (localId == null) return Optional.empty(); + return Optional.ofNullable(batchesByLocalId.get(localId)); + } + + @Override + public Optional findByMerkleRoot(String merkleRootHex) { + Long localId = localIdByMerkleRoot.get(merkleRootHex); + if (localId == null) return Optional.empty(); + return Optional.ofNullable(batchesByLocalId.get(localId)); + } +} diff --git a/backend/src/main/java/dao/tron/tsol/scheduler/BatchingScheduler.java b/backend/src/main/java/dao/tron/tsol/scheduler/BatchingScheduler.java new file mode 100644 index 0000000..5271456 --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/scheduler/BatchingScheduler.java @@ -0,0 +1,51 @@ +package dao.tron.tsol.scheduler; + +import dao.tron.tsol.config.SchedulerProperties; +import dao.tron.tsol.service.BatchService; +import dao.tron.tsol.service.TransferIntentService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class BatchingScheduler { + + private final TransferIntentService intentService; + private final BatchService batchService; + private final SchedulerProperties schedulerProps; + + public BatchingScheduler(TransferIntentService intentService, + BatchService batchService, + SchedulerProperties schedulerProps) { + this.intentService = intentService; + this.batchService = batchService; + this.schedulerProps = schedulerProps; + } + + @Scheduled(fixedDelayString = "${scheduler.batching.check-interval-ms:3000}") + public void maybeCreateBatch() { + if (!schedulerProps.getBatching().isEnabled()) { + return; + } + if (intentService.isEmpty()) return; + + int count = intentService.getPendingCount(); + long oldestAge = intentService.getOldestAgeSeconds(); + + int maxIntents = schedulerProps.getBatching().getMaxIntents(); + long maxDelaySeconds = schedulerProps.getBatching().getMaxDelaySeconds(); + + // IMPORTANT: Require minimum 2 transactions for valid Merkle proofs + // Single-transaction batches have empty/invalid proofs that fail verification + if (count < 2) { + log.debug("Waiting for at least 2 transactions (current: {})", count); + return; + } + + if (count >= maxIntents || oldestAge >= maxDelaySeconds) { + log.info("Creating batch: pendingCount={}, oldestAge={}", count, oldestAge); + batchService.createAndSubmitBatch(maxIntents); + } + } +} diff --git a/backend/src/main/java/dao/tron/tsol/scheduler/ExecutionScheduler.java b/backend/src/main/java/dao/tron/tsol/scheduler/ExecutionScheduler.java new file mode 100644 index 0000000..8f7406c --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/scheduler/ExecutionScheduler.java @@ -0,0 +1,93 @@ +package dao.tron.tsol.scheduler; + +import dao.tron.tsol.config.SchedulerProperties; +import dao.tron.tsol.model.BatchStatus; +import dao.tron.tsol.model.LocalBatch; +import dao.tron.tsol.service.BatchService; +import dao.tron.tsol.service.ExecutionService; +import dao.tron.tsol.service.SettlementContractClient; +import dao.tron.tsol.service.WhitelistService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +public class ExecutionScheduler { + + private final BatchService batchService; + private final SettlementContractClient settlementClient; + private final ExecutionService executionService; + private final SchedulerProperties schedulerProps; + private final WhitelistService whitelistService; + + public ExecutionScheduler(BatchService batchService, + SettlementContractClient settlementClient, + ExecutionService executionService, + SchedulerProperties schedulerProps, + WhitelistService whitelistService) { + this.batchService = batchService; + this.settlementClient = settlementClient; + this.executionService = executionService; + this.schedulerProps = schedulerProps; + this.whitelistService = whitelistService; + } + + @EventListener(ApplicationReadyEvent.class) + public void syncWhitelistRootOnStartup() { + // Script-equivalent: ensure whitelist root is correct before any BATCHED txType=2 execution. + try { + if (!whitelistService.ensureWhitelistRootMatchesConfig()) { + log.warn("Whitelist root sync did not succeed (txType=2 may revert as NotWhitelisted)."); + } + } catch (Exception e) { + log.warn("Whitelist root sync failed (txType=2 may revert as NotWhitelisted): {}", e.getMessage()); + } + } + + @Scheduled(fixedDelayString = "${scheduler.execution.check-interval-ms:5000}") + public void executeUnlockedBatches() { + if (!schedulerProps.getExecution().isEnabled()) { + return; + } + + long now = System.currentTimeMillis() / 1000L; + List batches = batchService.getBatches(); + + if (batches.isEmpty()) { + return; + } + + for (LocalBatch batch : batches) { + if (batch.getStatus() != BatchStatus.SUBMITTED_ONCHAIN && + batch.getStatus() != BatchStatus.UNLOCKED) { + continue; + } + + if (batch.getUnlockTime() == 0L) { + try { + long unlockTime = settlementClient.getUnlockTime(batch.getOnChainBatchId()); + batch.setUnlockTime(unlockTime); + log.info("Batch {} unlock time: {} (now: {})", batch.getOnChainBatchId(), unlockTime, now); + } catch (Exception e) { + log.warn("Batch {} not found on-chain. Marking as failed.", batch.getOnChainBatchId()); + batch.setStatus(BatchStatus.FAILED); + continue; + } + } + + if (now < batch.getUnlockTime()) { + continue; + } + + log.info("Executing batch {} (onChainId={})", batch.getLocalId(), batch.getOnChainBatchId()); + + executionService.executeAll(batch); + log.info("Batch {} execution complete", batch.getOnChainBatchId()); + } + } +} diff --git a/backend/src/main/java/dao/tron/tsol/service/BatchService.java b/backend/src/main/java/dao/tron/tsol/service/BatchService.java new file mode 100644 index 0000000..e0a1590 --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/service/BatchService.java @@ -0,0 +1,120 @@ +package dao.tron.tsol.service; + +import dao.tron.tsol.model.*; +import dao.tron.tsol.repository.BatchRepository; +import dao.tron.tsol.util.CryptoUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +public class BatchService { + + private final TransferIntentService intentService; + private final MerkleTreeService merkleTreeService; + private final SettlementContractClient settlementClient; + private final BatchRepository batchRepository; + private final WhitelistService whitelistService; + + public BatchService(TransferIntentService intentService, + MerkleTreeService merkleTreeService, + SettlementContractClient settlementClient, + BatchRepository batchRepository, + WhitelistService whitelistService) { + this.intentService = intentService; + this.merkleTreeService = merkleTreeService; + this.settlementClient = settlementClient; + this.batchRepository = batchRepository; + this.whitelistService = whitelistService; + } + + public synchronized void createAndSubmitBatch(int maxTxPerBatch) { + List intents = intentService.drainUpTo(maxTxPerBatch); + if (intents.isEmpty()) return; + + // Per-batch salt used for txHash / Merkle leaf hashing (batchId is NOT hashed anymore) + long batchSalt = CryptoUtil.randomUint64PositiveNonZero(); + + // map to TransferData + // NOTE: batchId is NOT part of Merkle tree calculation in new Settlement contract + List txs = new ArrayList<>(); + for (TransferIntentRequest req : intents) { + TransferData d = new TransferData(); + d.setFrom(req.getFrom()); + d.setTo(req.getTo()); + d.setAmount(req.getAmount()); + d.setNonce(req.getNonce()); + d.setTimestamp(req.getTimestamp()); + d.setRecipientCount(req.getRecipientCount()); + d.setTxType(req.getTxType()); + txs.add(d); + } + + // leaves - now with correct batchId in each leaf hash + List leaves = new ArrayList<>(); + for (TransferData d : txs) { + leaves.add(merkleTreeService.leafHash(d, batchSalt)); + } + + String rootHex = merkleTreeService.computeMerkleRoot(leaves); + + // stored transfers with proofs + List stored = new ArrayList<>(); + for (int i = 0; i < txs.size(); i++) { + TransferData tx = txs.get(i); + StoredTransfer st = new StoredTransfer(); + st.setTxData(tx); + st.setTxProof(merkleTreeService.buildProof(leaves, i)); + + // Generate whitelist proof for BATCHED transactions (txType=2) + if (tx.getTxType() == 2) { // BATCHED + List whitelistProof = whitelistService.generateWhitelistProof(tx.getFrom()); + st.setWhitelistProof(whitelistProof); + } else { + // DELAYED (0), INSTANT (1), FREE_TIER (3) don't need whitelist proof + st.setWhitelistProof(List.of()); + } + + st.setExecuted(false); + stored.add(st); + } + + // Call contract: submitBatch(root, txCount) + BatchSubmission submission = settlementClient.submitBatchWithTxId(rootHex, txs.size(), batchSalt); + long onChainBatchId = submission.batchId(); + + // Set batchId in each TransferData (for storage/tracking purposes only, NOT for hash) + stored.forEach(st -> st.getTxData().setBatchId(onChainBatchId)); + + // Build LocalBatch and save in repository + LocalBatch batch = new LocalBatch(); + batch.setOnChainBatchId(onChainBatchId); + batch.setSubmitTxId(submission.submitTxId()); + batch.setMerkleRootHex(rootHex); + batch.setTxCount(submission.txCount()); + batch.setStatus(BatchStatus.SUBMITTED_ONCHAIN); + batch.setSubmittedAt(submission.submittedAt()); + batch.setUnlockTime(submission.unlockTime()); + batch.setBatchSalt(batchSalt); + batch.setTransfers(stored); + + batchRepository.save(batch); + } + + public List getBatches() { + return batchRepository.findAll(); + } + + public LocalBatch getByOnChainBatchId(long onChainBatchId) { + return batchRepository.findByOnChainBatchId(onChainBatchId) + .orElseThrow(() -> new IllegalArgumentException("Batch not found: " + onChainBatchId)); + } + + public LocalBatch getByMerkleRoot(String merkleRootHex) { + return batchRepository.findByMerkleRoot(merkleRootHex) + .orElseThrow(() -> new IllegalArgumentException("Batch not found for root: " + merkleRootHex)); + } +} diff --git a/backend/src/main/java/dao/tron/tsol/service/BatchSubmission.java b/backend/src/main/java/dao/tron/tsol/service/BatchSubmission.java new file mode 100644 index 0000000..f0600db --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/service/BatchSubmission.java @@ -0,0 +1,18 @@ +package dao.tron.tsol.service; + +/** + * Result of submitBatch() including txId and resolved batchId. + */ +public record BatchSubmission( + String submitTxId, + long batchId, + String merkleRootHex, + int txCount, + long submittedAt, + long unlockTime +) {} + + + + + diff --git a/backend/src/main/java/dao/tron/tsol/service/ExecutionService.java b/backend/src/main/java/dao/tron/tsol/service/ExecutionService.java new file mode 100644 index 0000000..e511b2d --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/service/ExecutionService.java @@ -0,0 +1,104 @@ +package dao.tron.tsol.service; + +import dao.tron.tsol.config.SchedulerProperties; +import dao.tron.tsol.model.BatchStatus; +import dao.tron.tsol.model.LocalBatch; +import dao.tron.tsol.model.StoredTransfer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; + +@Slf4j +@Service +public class ExecutionService { + + private final SettlementContractClient settlementClient; + private final SchedulerProperties schedulerProps; + private final ExecutorService executor; + + public ExecutionService(SettlementContractClient settlementClient, SchedulerProperties schedulerProps) { + this.settlementClient = settlementClient; + this.schedulerProps = schedulerProps; + // Upper bound to avoid accidental massive fan-out; can be increased if needed. + int threadSize = Math.max(1, Math.min(8, schedulerProps.getExecution().getMaxParallel())); + this.executor = Executors.newFixedThreadPool(threadSize); + } + + public void executeAll(LocalBatch batch) { + batch.setStatus(BatchStatus.EXECUTING); + + int maxParallel = Math.max(1, schedulerProps.getExecution().getMaxParallel()); + if (maxParallel == 1) { + executeSequential(batch); + return; + } + + // Pre-fix missing batchId once to avoid repeated warnings in parallel tasks. + for (StoredTransfer st : batch.getTransfers()) { + if (st.isExecuted()) continue; + long batchId = st.getTxData().getBatchId(); + if (batchId == 0) { + st.getTxData().setBatchId(batch.getOnChainBatchId()); + } + } + + List> futures = new ArrayList<>(); + for (StoredTransfer st : batch.getTransfers()) { + if (st.isExecuted()) continue; + futures.add(CompletableFuture.supplyAsync(() -> executeOne(st), executor)); + } + + boolean allOk = true; + for (CompletableFuture f : futures) { + try { + allOk &= f.get(); + } catch (Exception e) { + allOk = false; + log.error("Transfer execution task failed: {}", e.getMessage()); + } + } + + batch.setStatus(allOk ? BatchStatus.COMPLETED : BatchStatus.FAILED); + log.info("Batch {} execution finished: status={} (maxParallel={})", batch.getOnChainBatchId(), batch.getStatus(), maxParallel); + } + + private void executeSequential(LocalBatch batch) { + boolean allOk = true; + for (StoredTransfer st : batch.getTransfers()) { + if (st.isExecuted()) continue; + + long batchId = st.getTxData().getBatchId(); + if (batchId == 0) { + log.warn("Transfer missing batchId, setting to: {}", batch.getOnChainBatchId()); + st.getTxData().setBatchId(batch.getOnChainBatchId()); + } + + allOk &= executeOne(st); + } + + batch.setStatus(allOk ? BatchStatus.COMPLETED : BatchStatus.FAILED); + log.info("Batch {} execution finished: status={} (sequential)", batch.getOnChainBatchId(), batch.getStatus()); + } + + private boolean executeOne(StoredTransfer st) { + try { + settlementClient.executeTransfer(st); + st.setExecuted(true); + log.info("Transfer executed: from={}, to={}, amount={}", + st.getTxData().getFrom(), st.getTxData().getTo(), st.getTxData().getAmount()); + return true; + } catch (Exception e) { + log.error("Transfer execution failed: from={}, to={}, error={}", + st.getTxData().getFrom(), st.getTxData().getTo(), e.getMessage()); + return false; + } + } + + @jakarta.annotation.PreDestroy + public void shutdown() { + executor.shutdown(); + } +} diff --git a/backend/src/main/java/dao/tron/tsol/service/MerkleTreeService.java b/backend/src/main/java/dao/tron/tsol/service/MerkleTreeService.java new file mode 100644 index 0000000..c7aa2bd --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/service/MerkleTreeService.java @@ -0,0 +1,261 @@ +package dao.tron.tsol.service; + +import dao.tron.tsol.model.TransferData; +import org.bouncycastle.jcajce.provider.digest.Keccak; +import org.springframework.stereotype.Service; +import org.tron.trident.core.ApiWrapper; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Service +public class MerkleTreeService { + + /** + * Compute leaf hash using abi.encodePacked for minimal byte representation. + * MUST match Solidity Settlement.sol _calculateTxHash(). + * Note: batchId is NOT included in txHash calculation. + * IMPORTANT: batchSalt IS included (last field). + */ + public byte[] leafHash(TransferData txData, long batchSalt) { + byte[] from = tronAddressToAddressBytes(txData.getFrom()); + byte[] to = tronAddressToAddressBytes(txData.getTo()); + + BigInteger amount = new BigInteger(txData.getAmount()); + + byte[] amountBytes = uint256ToBytes(amount); + byte[] nonceBytes = uint64ToBytes(txData.getNonce()); + byte[] timestampBytes = uint48ToBytes(txData.getTimestamp()); + byte[] recipientCountBytes = uint32ToBytes(txData.getRecipientCount()); + byte[] txTypeBytes = uint8ToBytes((byte) txData.getTxType()); + byte[] batchSaltBytes = uint64ToBytes(batchSalt); + + byte[] packed = concat(from, to); + packed = concat(packed, amountBytes); + packed = concat(packed, nonceBytes); + packed = concat(packed, timestampBytes); + packed = concat(packed, recipientCountBytes); + packed = concat(packed, txTypeBytes); + packed = concat(packed, batchSaltBytes); + + return keccak256(packed); + } + + /** + * Compute Merkle root using sorted-pair hashing (OpenZeppelin MerkleProof style). + * Odd number of nodes on a level -> last one is promoted (carried up unchanged). + * + * IMPORTANT: This must match the scripts in `sc/script/merkle/**` which promote odd nodes. + */ + public String computeMerkleRoot(List leaves) { + if (leaves == null || leaves.isEmpty()) { + throw new IllegalArgumentException("No leaves"); + } + + List level = new ArrayList<>(leaves.size()); + for (byte[] leaf : leaves) { + if (leaf == null || leaf.length != 32) { + throw new IllegalArgumentException("Each leaf must be 32 bytes"); + } + level.add(leaf.clone()); + } + + while (level.size() > 1) { + List next = new ArrayList<>(); + + for (int i = 0; i < level.size(); i += 2) { + byte[] left = level.get(i); + if (i + 1 < level.size()) { + byte[] right = level.get(i + 1); + next.add(hashPair(left, right)); + } else { + next.add(left); + } + } + + level = next; + } + + return "0x" + bytesToHex(level.getFirst()); + } + + /** + * Build Merkle proof for leaf at index. + * Returns list of hex-encoded bytes32 (0x-prefixed) in bottom-up order. + */ + public List buildProof(List leaves, int index) { + if (leaves == null || leaves.isEmpty()) { + throw new IllegalArgumentException("No leaves"); + } + if (index < 0 || index >= leaves.size()) { + throw new IndexOutOfBoundsException("Invalid leaf index: " + index); + } + + List> layers = new ArrayList<>(); + List current = new ArrayList<>(leaves.size()); + for (byte[] leaf : leaves) { + if (leaf == null || leaf.length != 32) { + throw new IllegalArgumentException("Each leaf must be 32 bytes"); + } + current.add(leaf.clone()); + } + layers.add(current); + + while (current.size() > 1) { + List next = new ArrayList<>(); + for (int i = 0; i < current.size(); i += 2) { + byte[] left = current.get(i); + if (i + 1 < current.size()) { + byte[] right = current.get(i + 1); + next.add(hashPair(left, right)); + } else { + // Promote odd leaf + next.add(left); + } + } + layers.add(next); + current = next; + } + + List proof = new ArrayList<>(); + int idx = index; + + for (int layerIdx = 0; layerIdx < layers.size() - 1; layerIdx++) { + List layer = layers.get(layerIdx); + int layerSize = layer.size(); + if (layerSize == 1) break; + + int siblingIndex; + if (idx % 2 == 0) { + if (idx + 1 < layerSize) { + siblingIndex = idx + 1; + } else { + // No sibling at this level (odd leaf promoted) + idx = idx / 2; + continue; + } + } else { + siblingIndex = idx - 1; + } + + byte[] sibling = layer.get(siblingIndex); + proof.add("0x" + bytesToHex(sibling)); + + idx = idx / 2; + } + + return proof; + } + + /** + * Hash a pair of 32-byte nodes with sorted-pair keccak. + */ + private byte[] hashPair(byte[] left, byte[] right) { + if (left == null || right == null || left.length != 32 || right.length != 32) { + throw new IllegalArgumentException("hashPair requires two 32-byte inputs"); + } + + if (compareBytes(left, right) <= 0) { + return keccak256(concat(left, right)); + } else { + return keccak256(concat(right, left)); + } + } + + private static byte[] keccak256(byte[] data) { + Keccak.Digest256 digest = new Keccak.Digest256(); + digest.update(data, 0, data.length); + return digest.digest(); + } + + private static byte[] concat(byte[] a, byte[] b) { + byte[] out = new byte[a.length + b.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(b, 0, out, a.length, b.length); + return out; + } + + private static int compareBytes(byte[] a, byte[] b) { + int len = Math.min(a.length, b.length); + for (int i = 0; i < len; i++) { + int ai = a[i] & 0xff; + int bi = b[i] & 0xff; + if (ai != bi) return ai - bi; + } + return a.length - b.length; + } + + /** + * Convert TRON base58 address to 20-byte EVM address. + */ + private static byte[] tronAddressToAddressBytes(String base58) { + byte[] raw = ApiWrapper.parseAddress(base58).toByteArray(); + if (raw.length < 21) { + throw new IllegalArgumentException("Parsed address length < 21 bytes for " + base58); + } + return Arrays.copyOfRange(raw, raw.length - 20, raw.length); + } + + private static byte[] uint256ToBytes(BigInteger value) { + if (value == null) { + throw new IllegalArgumentException("uint256 value is null"); + } + if (value.signum() < 0) { + throw new IllegalArgumentException("uint256 cannot be negative"); + } + byte[] raw = value.toByteArray(); + if (raw.length > 32) { + throw new IllegalArgumentException("uint256 value too large"); + } + byte[] out = new byte[32]; + System.arraycopy(raw, 0, out, 32 - raw.length, raw.length); + return out; + } + + private static byte[] uint8ToBytes(byte v) { + return new byte[]{ v }; + } + + private static byte[] uint32ToBytes(int v) { + return new byte[]{ + (byte)(v >>> 24), + (byte)(v >>> 16), + (byte)(v >>> 8), + (byte)v + }; + } + + private static byte[] uint48ToBytes(long v) { + return new byte[]{ + (byte)(v >>> 40), + (byte)(v >>> 32), + (byte)(v >>> 24), + (byte)(v >>> 16), + (byte)(v >>> 8), + (byte)v + }; + } + + private static byte[] uint64ToBytes(long v) { + return new byte[]{ + (byte)(v >>> 56), + (byte)(v >>> 48), + (byte)(v >>> 40), + (byte)(v >>> 32), + (byte)(v >>> 24), + (byte)(v >>> 16), + (byte)(v >>> 8), + (byte)v + }; + } + + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } +} diff --git a/backend/src/main/java/dao/tron/tsol/service/SettlementContractClient.java b/backend/src/main/java/dao/tron/tsol/service/SettlementContractClient.java new file mode 100644 index 0000000..8f1973f --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/service/SettlementContractClient.java @@ -0,0 +1,13 @@ +package dao.tron.tsol.service; + + +public interface SettlementContractClient { + + long submitBatch(String merkleRootHex, int txCount, long batchSalt); + + BatchSubmission submitBatchWithTxId(String merkleRootHex, int txCount, long batchSalt); + + long getUnlockTime(long batchId); + + void executeTransfer(dao.tron.tsol.model.StoredTransfer transfer); +} diff --git a/backend/src/main/java/dao/tron/tsol/service/SettlementContractClientTrident.java b/backend/src/main/java/dao/tron/tsol/service/SettlementContractClientTrident.java new file mode 100644 index 0000000..5e16e9c --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/service/SettlementContractClientTrident.java @@ -0,0 +1,438 @@ +package dao.tron.tsol.service; + +import dao.tron.tsol.config.SettlementProperties; +import dao.tron.tsol.event.BatchSubmittedEvent; +import dao.tron.tsol.event.BatchSubmittedEventReader; +import dao.tron.tsol.model.StoredTransfer; +import dao.tron.tsol.model.TransferData; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.tron.trident.abi.FunctionEncoder; +import org.tron.trident.abi.FunctionReturnDecoder; +import org.tron.trident.abi.TypeReference; +import org.tron.trident.abi.datatypes.*; +import org.tron.trident.abi.datatypes.generated.Bytes32; +import org.tron.trident.abi.datatypes.generated.Uint256; +import org.tron.trident.abi.datatypes.generated.Uint32; +import org.tron.trident.abi.datatypes.generated.Uint48; +import org.tron.trident.abi.datatypes.generated.Uint64; +import org.tron.trident.abi.datatypes.generated.Uint8; +import org.tron.trident.core.ApiWrapper; +import org.tron.trident.core.NodeType; +import org.tron.trident.proto.Chain; +import org.tron.trident.proto.Response; +import org.tron.trident.utils.Numeric; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.time.Duration; +import java.util.concurrent.ThreadLocalRandom; + +@Slf4j +@Service +public class SettlementContractClientTrident implements SettlementContractClient { + + private final ApiWrapper wrapper; + @Getter + private final String aggregatorAddress; + private final String contractAddress; + private final BatchSubmittedEventReader eventReader; + private final SettlementProperties.Polling polling; + /** + * Guard signing/broadcasting so concurrent execution doesn't trip over non-thread-safe internals. + * Receipt polling is intentionally done outside this lock. + */ + private final Object broadcastLock = new Object(); + + private static final long DEFAULT_FEE_LIMIT = 100_000_000L; + + public SettlementContractClientTrident(SettlementProperties props, BatchSubmittedEventReader eventReader) { + this.contractAddress = props.getContractAddress(); + this.eventReader = eventReader; + this.polling = props.getPolling(); + + String privateKey = props.getPrivateKey(); + if (privateKey == null || privateKey.isEmpty() || privateKey.equals("YOUR_PRIVATE_KEY_HERE")) { + log.warn("No valid private key configured. Set UPDATER_PRIVATE_KEY to enable blockchain operations."); + this.wrapper = null; + this.aggregatorAddress = "NOT_CONFIGURED"; + return; + } + + if (privateKey.length() % 2 != 0) { + log.error("Invalid private key format: odd-length hex string"); + this.wrapper = null; + this.aggregatorAddress = "INVALID_KEY_FORMAT"; + return; + } + + ApiWrapper tempWrapper; + String tempAggregatorAddress; + + try { + tempWrapper = ApiWrapper.ofNile(privateKey); + tempAggregatorAddress = tempWrapper.keyPair.toBase58CheckAddress(); + log.info("SettlementContractClientTrident initialized: aggregator={}, contract={}", + tempAggregatorAddress, contractAddress); + } catch (Exception e) { + log.error("Failed to initialize: {}", e.getMessage()); + tempWrapper = null; + tempAggregatorAddress = "INIT_FAILED"; + } + + this.wrapper = tempWrapper; + this.aggregatorAddress = tempAggregatorAddress; + } + + @Override + public long submitBatch(String merkleRootHex, int txCount, long batchSalt) { + return submitBatchWithTxId(merkleRootHex, txCount, batchSalt).batchId(); + } + + @Override + public BatchSubmission submitBatchWithTxId(String merkleRootHex, int txCount, long batchSalt) { + try { + String cleanRoot = cleanHex(merkleRootHex); + byte[] rootBytes = Numeric.hexStringToByteArray(cleanRoot); + if (rootBytes.length != 32) { + throw new IllegalArgumentException("Merkle root must be 32 bytes, got " + rootBytes.length); + } + + Function submitBatchFn = new Function( + "submitBatch", + Arrays.asList( + new Bytes32(rootBytes), + new Uint32(BigInteger.valueOf(txCount)), + new Uint64(BigInteger.valueOf(batchSalt)) + ), + Arrays.asList( + new TypeReference() {}, + new TypeReference() {} + ) + ); + + String encodedHex = FunctionEncoder.encode(submitBatchFn); + + Response.TransactionExtention txnExt = wrapper.triggerContract( + aggregatorAddress, + contractAddress, + encodedHex, + 0L, + 0L, + null, + DEFAULT_FEE_LIMIT + ); + + if (!txnExt.getResult().getResult()) { + String msg = txnExt.getResult().getMessage().toStringUtf8(); + throw new RuntimeException("submitBatch trigger failed: " + msg); + } + + String txId; + synchronized (broadcastLock) { + Chain.Transaction signed = wrapper.signTransaction(txnExt); + txId = wrapper.broadcastTransaction(signed); + } + + // Make failures explicit (revert/OUT_OF_ENERGY/etc.) rather than timing out on event polling. + Response.TransactionInfo txInfo = waitForTxInfo( + txId, + Duration.ofSeconds(polling.getTxInfoTimeoutSeconds()), + Duration.ofMillis(polling.getTxInfoPollInitialMs()), + Duration.ofMillis(polling.getTxInfoPollMaxMs()) + ); + if (txInfo == null) { + throw new RuntimeException("submitBatch failed: no TransactionInfo after timeout. txId=" + txId); + } + if (txInfo.getResult() != Response.TransactionInfo.code.SUCESS) { + String errorMsg = txInfo.getResMessage() != null ? txInfo.getResMessage().toStringUtf8() : "Unknown error"; + throw new RuntimeException("submitBatch failed on-chain: " + errorMsg + ". txId=" + txId); + } + + // Prefer event parsing (source of truth for batchId) + var evOpt = eventReader.readWithTimeout( + txId, + Duration.ofSeconds(polling.getBatchSubmittedTimeoutSeconds()), + Duration.ofMillis(polling.getBatchSubmittedPollInitialMs()) + ); + if (evOpt.isPresent()) { + BatchSubmittedEvent ev = evOpt.get(); + if (!cleanHex(ev.merkleRootHex()).equalsIgnoreCase(cleanHex(merkleRootHex))) { + log.warn("BatchSubmitted merkleRoot mismatch: expected={}, got={}", merkleRootHex, ev.merkleRootHex()); + } + if (ev.txCount() != txCount) { + log.warn("BatchSubmitted txCount mismatch: expected={}, got={}", txCount, ev.txCount()); + } + long unlockTime = getUnlockTime(ev.batchId()); + return new BatchSubmission(txId, ev.batchId(), merkleRootHex, txCount, ev.timestamp(), unlockTime); + } + + // Fallback: poll getBatchIdByRoot(root) until non-zero + long batchId = pollBatchIdByRoot(merkleRootHex, Duration.ofSeconds(polling.getBatchSubmittedTimeoutSeconds())); + if (batchId == 0L) { + throw new RuntimeException("submitBatch failed: could not resolve batchId from event or getBatchIdByRoot within timeout. txId=" + txId); + } + OnChainBatch b = getBatchById(batchId); + return new BatchSubmission(txId, batchId, merkleRootHex, txCount, b.timestamp(), b.unlockTime()); + } catch (Exception e) { + log.error("submitBatch failed", e); + throw new RuntimeException("submitBatch failed: " + e.getMessage(), e); + } + } + + private Response.TransactionInfo waitForTxInfo(String txId, Duration timeout, Duration pollInitial, Duration pollMax) { + long deadline = System.currentTimeMillis() + timeout.toMillis(); + long sleepMs = Math.max(100, pollInitial.toMillis()); + long maxSleepMs = Math.max(sleepMs, pollMax.toMillis()); + while (System.currentTimeMillis() < deadline) { + try { + Response.TransactionInfo info = wrapper.getTransactionInfoById(txId); + if (info != null) return info; + } catch (Exception ignored) {} + try { + long jitter = ThreadLocalRandom.current().nextLong(0, 150); + Thread.sleep(sleepMs + jitter); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return null; + } + sleepMs = Math.min(maxSleepMs, (long) Math.ceil(sleepMs * 1.5)); + } + return null; + } + + @Override + public long getUnlockTime(long batchId) { + try { + Function getBatchFn = new Function( + "getBatchById", + Collections.singletonList(new Uint64(batchId)), + Arrays.asList( + new TypeReference() {}, + new TypeReference() {}, + new TypeReference() {}, + new TypeReference() {}, + new TypeReference() {} + ) + ); + + String encodedHex = FunctionEncoder.encode(getBatchFn); + + Response.TransactionExtention txn = wrapper.triggerConstantContract( + aggregatorAddress, + contractAddress, + encodedHex, + NodeType.SOLIDITY_NODE + ); + + if (!txn.getResult().getResult()) { + throw new RuntimeException("getBatchById failed: " + txn.getResult().getMessage().toStringUtf8()); + } + + if (txn.getConstantResultCount() == 0) { + throw new IllegalStateException("No constantResult for getBatchById"); + } + + String resultHex = Numeric.toHexString(txn.getConstantResult(0).toByteArray()); + @SuppressWarnings("rawtypes") + List decoded = + FunctionReturnDecoder.decode(resultHex, getBatchFn.getOutputParameters()); + + if (decoded.size() != 5) { + throw new IllegalStateException("Unexpected getBatchById outputs=" + decoded.size()); + } + + Uint256 unlockTime = (Uint256) decoded.get(3); + return unlockTime.getValue().longValue(); + } catch (Exception e) { + log.error("getUnlockTime failed", e); + throw new RuntimeException("getUnlockTime failed: " + e.getMessage(), e); + } + } + + @Override + public void executeTransfer(StoredTransfer transfer) { + try { + TransferData d = transfer.getTxData(); + + List txProofElems = new ArrayList<>(); + for (String hex : transfer.getTxProof()) { + byte[] b = Numeric.hexStringToByteArray(cleanHex(hex)); + if (b.length != 32) { + throw new IllegalArgumentException("txProof element not 32 bytes: " + hex); + } + txProofElems.add(new Bytes32(b)); + } + DynamicArray txProofArray = new DynamicArray<>(Bytes32.class, txProofElems); + + List wlProofElems = new ArrayList<>(); + for (String hex : transfer.getWhitelistProof()) { + byte[] b = Numeric.hexStringToByteArray(cleanHex(hex)); + if (b.length != 32) { + throw new IllegalArgumentException("whitelistProof element not 32 bytes: " + hex); + } + wlProofElems.add(new Bytes32(b)); + } + DynamicArray wlProofArray = new DynamicArray<>(Bytes32.class, wlProofElems); + + StaticStruct txDataTuple = new StaticStruct( + new Address(d.getFrom()), + new Address(d.getTo()), + new Uint256(new BigInteger(d.getAmount())), + new Uint64(BigInteger.valueOf(d.getNonce())), + new Uint48(BigInteger.valueOf(d.getTimestamp())), + new Uint32(BigInteger.valueOf(d.getRecipientCount())), + new Uint64(BigInteger.valueOf(d.getBatchId())), + new Uint8(d.getTxType()) + ); + + Function execFn = new Function( + "executeTransfer", + Arrays.asList(txProofArray, wlProofArray, txDataTuple), + Collections.singletonList(new TypeReference() {}) + ); + + String encodedHex = FunctionEncoder.encode(execFn); + + Response.TransactionExtention txnExt = wrapper.triggerContract( + aggregatorAddress, + contractAddress, + encodedHex, + 0L, + 0L, + null, + DEFAULT_FEE_LIMIT + ); + + if (!txnExt.getResult().getResult()) { + throw new RuntimeException("executeTransfer trigger failed: " + txnExt.getResult().getMessage().toStringUtf8()); + } + + String txId; + synchronized (broadcastLock) { + Chain.Transaction signed = wrapper.signTransaction(txnExt); + txId = wrapper.broadcastTransaction(signed); + } + transfer.setExecutionTxId(txId); + // Don't hard-sleep: poll receipt until available (faster on good days, clearer failure on reverts). + Response.TransactionInfo txInfo = waitForTxInfo( + txId, + Duration.ofSeconds(polling.getTxInfoTimeoutSeconds()), + Duration.ofMillis(polling.getTxInfoPollInitialMs()), + Duration.ofMillis(polling.getTxInfoPollMaxMs()) + ); + if (txInfo == null) { + throw new RuntimeException("Transaction failed: no TransactionInfo after timeout. txId=" + txId); + } + if (txInfo.getResult() != Response.TransactionInfo.code.SUCESS) { + String errorMsg = txInfo.getResMessage() != null ? txInfo.getResMessage().toStringUtf8() : "Unknown error"; + throw new RuntimeException("Transaction failed: " + errorMsg + ". txId=" + txId); + } + + log.info("executeTransfer SUCCESS: txId={}", txId); + + } catch (Exception e) { + log.error("executeTransfer failed", e); + throw new RuntimeException("executeTransfer failed: " + e.getMessage(), e); + } + } + + private String cleanHex(String value) { + if (value == null) return ""; + return (value.startsWith("0x") || value.startsWith("0X")) + ? value.substring(2) + : value; + } + + private long pollBatchIdByRoot(String merkleRootHex, Duration timeout) { + long deadline = System.currentTimeMillis() + timeout.toMillis(); + while (System.currentTimeMillis() < deadline) { + try { + long id = getBatchIdByRoot(merkleRootHex); + if (id != 0L) return id; + } catch (Exception ignored) {} + try { Thread.sleep(1000); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return 0L; } + } + return 0L; + } + + private record OnChainBatch(String merkleRootHex, long timestamp, int txCount, long unlockTime, long batchSalt) {} + + private OnChainBatch getBatchById(long batchId) { + Function getBatchFn = new Function( + "getBatchById", + Collections.singletonList(new Uint64(batchId)), + Arrays.asList( + new TypeReference() {}, + new TypeReference() {}, + new TypeReference() {}, + new TypeReference() {}, + new TypeReference() {} + ) + ); + + String encodedHex = FunctionEncoder.encode(getBatchFn); + Response.TransactionExtention txn = wrapper.triggerConstantContract( + aggregatorAddress, + contractAddress, + encodedHex, + NodeType.SOLIDITY_NODE + ); + if (!txn.getResult().getResult() || txn.getConstantResultCount() == 0) { + throw new RuntimeException("getBatchById query failed"); + } + String resultHex = Numeric.toHexString(txn.getConstantResult(0).toByteArray()); + @SuppressWarnings("rawtypes") + List decoded = + FunctionReturnDecoder.decode(resultHex, getBatchFn.getOutputParameters()); + if (decoded.size() != 5) { + throw new IllegalStateException("Unexpected getBatchById outputs=" + decoded.size()); + } + Bytes32 root = (Bytes32) decoded.get(0); + Uint256 timestamp = (Uint256) decoded.get(1); + Uint256 txCount = (Uint256) decoded.get(2); + Uint256 unlock = (Uint256) decoded.get(3); + Uint64 batchSalt = (Uint64) decoded.get(4); + return new OnChainBatch( + "0x" + Numeric.toHexStringNoPrefix(root.getValue()), + timestamp.getValue().longValue(), + txCount.getValue().intValue(), + unlock.getValue().longValue(), + batchSalt.getValue().longValue() + ); + } + + private long getBatchIdByRoot(String merkleRootHex) { + String cleanRoot = cleanHex(merkleRootHex); + byte[] rootBytes = Numeric.hexStringToByteArray(cleanRoot); + if (rootBytes.length != 32) throw new IllegalArgumentException("Merkle root must be 32 bytes"); + + Function fn = new Function( + "getBatchIdByRoot", + Collections.singletonList(new Bytes32(rootBytes)), + Collections.singletonList(new TypeReference() {}) + ); + + String encodedHex = FunctionEncoder.encode(fn); + Response.TransactionExtention txn = wrapper.triggerConstantContract( + aggregatorAddress, + contractAddress, + encodedHex, + NodeType.SOLIDITY_NODE + ); + if (!txn.getResult().getResult() || txn.getConstantResultCount() == 0) { + return 0L; + } + String resultHex = Numeric.toHexString(txn.getConstantResult(0).toByteArray()); + @SuppressWarnings("rawtypes") + List decoded = + FunctionReturnDecoder.decode(resultHex, fn.getOutputParameters()); + if (decoded.isEmpty()) return 0L; + Uint64 v = (Uint64) decoded.getFirst(); + return v.getValue().longValue(); + } +} diff --git a/backend/src/main/java/dao/tron/tsol/service/TransferIntentService.java b/backend/src/main/java/dao/tron/tsol/service/TransferIntentService.java new file mode 100644 index 0000000..9528e30 --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/service/TransferIntentService.java @@ -0,0 +1,40 @@ +package dao.tron.tsol.service; + +import dao.tron.tsol.model.TransferIntentRequest; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class TransferIntentService { + + private final List pending = new ArrayList<>(); + + public synchronized void addIntent(TransferIntentRequest req) { + pending.add(req); + } + + public synchronized boolean isEmpty() { + return pending.isEmpty(); + } + + public synchronized int getPendingCount() { + return pending.size(); + } + + public synchronized long getOldestAgeSeconds() { + if (pending.isEmpty()) return 0L; + long oldestTs = pending.getFirst().getTimestamp(); + long now = System.currentTimeMillis() / 1000L; + return now - oldestTs; + } + + public synchronized List drainUpTo(int max) { + if (pending.isEmpty()) return List.of(); + int n = Math.min(max, pending.size()); + List res = new ArrayList<>(pending.subList(0, n)); + pending.subList(0, n).clear(); + return res; + } +} diff --git a/backend/src/main/java/dao/tron/tsol/service/WhitelistService.java b/backend/src/main/java/dao/tron/tsol/service/WhitelistService.java new file mode 100644 index 0000000..2984f71 --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/service/WhitelistService.java @@ -0,0 +1,409 @@ +package dao.tron.tsol.service; + +import dao.tron.tsol.config.ChainProperties; +import dao.tron.tsol.config.SettlementProperties; +import dao.tron.tsol.config.WhitelistProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.tron.trident.abi.FunctionEncoder; +import org.tron.trident.abi.FunctionReturnDecoder; +import org.tron.trident.abi.TypeReference; +import org.tron.trident.abi.datatypes.DynamicBytes; +import org.tron.trident.abi.datatypes.Function; +import org.tron.trident.abi.datatypes.generated.Bytes32; +import org.tron.trident.abi.datatypes.generated.Uint64; +import org.tron.trident.core.ApiWrapper; +import org.tron.trident.proto.Chain; +import org.tron.trident.proto.Response; +import org.tron.trident.utils.Numeric; +import org.web3j.crypto.ECKeyPair; +import org.web3j.crypto.Hash; +import org.web3j.crypto.Sign; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Slf4j +@Service +public class WhitelistService { + + private final WhitelistProperties whitelistProps; + private final ApiWrapper wrapper; + private final String updaterBase58; + private final ECKeyPair keyPair; + private final long chainId; + private final String registryBase58; + + private static final long DEFAULT_FEE_LIMIT = 50_000_000L; + + public WhitelistService(WhitelistProperties whitelistProps, + SettlementProperties settlementProps, + ChainProperties chainProps) { + this.whitelistProps = whitelistProps; + this.registryBase58 = whitelistProps.getRegistryAddress(); + this.chainId = chainProps.getId() != null ? chainProps.getId() : 3448148188L; + + String privateKey = settlementProps.getPrivateKey(); + if (privateKey == null || privateKey.isBlank()) { + log.warn("WhitelistService: No valid private key configured. Whitelist sync/signing disabled."); + this.wrapper = null; + this.updaterBase58 = "NOT_CONFIGURED"; + this.keyPair = null; + } else { + // NOTE: this project targets Nile; if you need mainnet, plumb node selection like SettlementContractClientTrident. + this.wrapper = ApiWrapper.ofNile(privateKey); + this.updaterBase58 = this.wrapper.keyPair.toBase58CheckAddress(); + String pkHex = privateKey.startsWith("0x") ? privateKey : "0x" + privateKey; + this.keyPair = ECKeyPair.create(new BigInteger(pkHex.substring(2), 16)); + } + + log.info("WhitelistService initialized: updater={}, registry={}, chainId={}, addresses={}", + updaterBase58, + registryBase58, + chainId, + whitelistProps.getAddresses() != null ? whitelistProps.getAddresses().size() : 0); + } + + public List generateWhitelistProof(String addressBase58) { + try { + String target = addressBase58 == null ? "" : addressBase58.trim(); + List configured = whitelistProps.getAddresses(); + if (configured == null || configured.isEmpty()) { + return List.of(); + } + + // Spring may bind `whitelist.addresses` as: + // - a real list, OR + // - a single comma-separated string (e.g. from env), sometimes with spaces/CRLF. + // Normalize by flattening + trimming + dropping empties. + List whitelistAddresses = new ArrayList<>(); + for (String entry : configured) { + if (entry == null) continue; + String e = entry.trim(); + if (e.isEmpty()) continue; + if (e.contains(",")) { + for (String part : e.split(",")) { + String p = part.trim(); + if (!p.isEmpty()) whitelistAddresses.add(p); + } + } else { + whitelistAddresses.add(e); + } + } + if (whitelistAddresses.isEmpty()) { + return List.of(); + } + + List leaves = new ArrayList<>(); + int targetIndex = -1; + + for (int i = 0; i < whitelistAddresses.size(); i++) { + String addr = whitelistAddresses.get(i); + byte[] leaf = whitelistAddressToLeaf(addr); + leaves.add(leaf); + + if (addr.equalsIgnoreCase(target)) { + targetIndex = i; + } + } + + if (targetIndex == -1) { + log.debug("Whitelist proof requested for non-whitelisted address: {} (configuredCount={})", target, whitelistAddresses.size()); + return List.of(); + } + + // Whitelist scripts build a standard OZ-sorted-pair tree with "duplicate last" behavior. + List proof = buildProofDuplicateOddSortedPairs(leaves, targetIndex); + List out = new ArrayList<>(proof.size()); + for (byte[] p : proof) { + out.add("0x" + bytesToHex(p)); + } + return out; + + } catch (Exception e) { + log.error("Failed to generate whitelist proof", e); + return List.of(); + } + } + + /** + * Convert Tron address to whitelist leaf hash: keccak256(bytes32(address)) + */ + private byte[] whitelistAddressToLeaf(String addressBase58) { + // Reuse trident parsing for base58 -> 21 bytes (0x41 + 20 bytes); keep the last 20 bytes. + byte[] raw = org.tron.trident.core.ApiWrapper.parseAddress(addressBase58).toByteArray(); + if (raw.length < 21) { + throw new IllegalArgumentException("Parsed address length < 21 bytes for " + addressBase58); + } + byte[] addr20 = java.util.Arrays.copyOfRange(raw, raw.length - 20, raw.length); + + byte[] bytes32 = new byte[32]; + System.arraycopy(addr20, 0, bytes32, 12, 20); + + return Hash.sha3(bytes32); + } + + /** + * Ensure the on-chain whitelist root matches the addresses configured in `whitelist.addresses`. + * This is the Java equivalent of the scripts `2_signRoot.js` + `3_updateRoot.js`. + */ + public boolean ensureWhitelistRootMatchesConfig() { + if (wrapper == null || keyPair == null) { + log.warn("WhitelistService not configured for on-chain sync (missing private key)."); + return false; + } + try { + String desiredRoot = computeWhitelistRootFromConfig(); + String currentRoot = getCurrentMerkleRoot(); + + if (normalizeHex32(desiredRoot).equalsIgnoreCase(normalizeHex32(currentRoot))) { + log.info("Whitelist root already matches config: {}", desiredRoot); + return true; + } + + long nonce = getCurrentNonce(); + byte[] sig = signWhitelistUpdate(desiredRoot, nonce); + String txId = updateMerkleRoot(desiredRoot, nonce, sig); + + Thread.sleep(5000); + String after = getCurrentMerkleRoot(); + boolean ok = normalizeHex32(desiredRoot).equalsIgnoreCase(normalizeHex32(after)); + log.info("Whitelist root sync result: ok={}, txId={}, old={}, new={}, desired={}", + ok, txId, currentRoot, after, desiredRoot); + return ok; + } catch (Exception e) { + log.error("ensureWhitelistRootMatchesConfig failed", e); + return false; + } + } + + public long getCurrentNonce() { + Function fn = new Function( + "getCurrentNonce", + List.of(), + List.of(new TypeReference() {}) + ); + Response.TransactionExtention txn = wrapper.triggerConstantContract( + updaterBase58, + registryBase58, + FunctionEncoder.encode(fn), + org.tron.trident.core.NodeType.SOLIDITY_NODE + ); + if (!txn.getResult().getResult() || txn.getConstantResultCount() == 0) { + throw new RuntimeException("getCurrentNonce failed: " + txn.getResult().getMessage().toStringUtf8()); + } + String resultHex = Numeric.toHexString(txn.getConstantResult(0).toByteArray()); + @SuppressWarnings("rawtypes") + List decoded = FunctionReturnDecoder.decode(resultHex, fn.getOutputParameters()); + Uint64 v = (Uint64) decoded.get(0); + return v.getValue().longValue(); + } + + public String getCurrentMerkleRoot() { + Function fn = new Function( + "getCurrentMerkleRoot", + List.of(), + List.of(new TypeReference() {}) + ); + Response.TransactionExtention txn = wrapper.triggerConstantContract( + updaterBase58, + registryBase58, + FunctionEncoder.encode(fn), + org.tron.trident.core.NodeType.SOLIDITY_NODE + ); + if (!txn.getResult().getResult() || txn.getConstantResultCount() == 0) { + throw new RuntimeException("getCurrentMerkleRoot failed: " + txn.getResult().getMessage().toStringUtf8()); + } + String resultHex = Numeric.toHexString(txn.getConstantResult(0).toByteArray()); + @SuppressWarnings("rawtypes") + List decoded = FunctionReturnDecoder.decode(resultHex, fn.getOutputParameters()); + Bytes32 root = (Bytes32) decoded.get(0); + return "0x" + bytesToHex(root.getValue()); + } + + public String updateMerkleRoot(String newRootHex, long nonce, byte[] signature) { + try { + String cleanRoot = cleanHex(newRootHex); + byte[] rootBytes = Numeric.hexStringToByteArray(cleanRoot); + if (rootBytes.length != 32) throw new IllegalArgumentException("Root must be 32 bytes"); + + Function fn = new Function( + "updateMerkleRoot", + Arrays.asList( + new Bytes32(rootBytes), + new Uint64(BigInteger.valueOf(nonce)), + new DynamicBytes(signature) + ), + List.of() + ); + + Response.TransactionExtention txnExt = wrapper.triggerContract( + updaterBase58, + registryBase58, + FunctionEncoder.encode(fn), + 0L, + 0L, + null, + DEFAULT_FEE_LIMIT + ); + if (!txnExt.getResult().getResult()) { + throw new RuntimeException("updateMerkleRoot trigger failed: " + txnExt.getResult().getMessage().toStringUtf8()); + } + Chain.Transaction signed = wrapper.signTransaction(txnExt); + return wrapper.broadcastTransaction(signed); + } catch (Exception e) { + throw new RuntimeException("updateMerkleRoot failed: " + e.getMessage(), e); + } + } + + /** + * Signature compatible with Solidity: + * hash = keccak256(abi.encodePacked(newRoot, nonce, chainid, address(registry))); + * signedHash = toEthSignedMessageHash(hash); + * signature = sign(signedHash) + */ + private byte[] signWhitelistUpdate(String newRootHex, long nonce) { + if (keyPair == null) throw new IllegalStateException("No keyPair configured"); + String cleanRoot = cleanHex(newRootHex); + byte[] rootBytes = Numeric.hexStringToByteArray(cleanRoot); + if (rootBytes.length != 32) throw new IllegalArgumentException("Root must be 32 bytes"); + + // registry as 20-byte EVM address (strip 0x41 prefix from TRON address bytes) + byte[] regRaw = ApiWrapper.parseAddress(registryBase58).toByteArray(); + if (regRaw.length < 21) throw new IllegalArgumentException("Registry address parse failed"); + byte[] reg20 = Arrays.copyOfRange(regRaw, regRaw.length - 20, regRaw.length); + + byte[] nonce8 = new byte[8]; + long n = nonce; + for (int i = 7; i >= 0; i--) { + nonce8[i] = (byte) (n & 0xFF); + n >>= 8; + } + + byte[] chainId32 = new byte[32]; + byte[] cid = BigInteger.valueOf(chainId).toByteArray(); + System.arraycopy(cid, 0, chainId32, 32 - cid.length, cid.length); + + byte[] packed = new byte[32 + 8 + 32 + 20]; + System.arraycopy(rootBytes, 0, packed, 0, 32); + System.arraycopy(nonce8, 0, packed, 32, 8); + System.arraycopy(chainId32, 0, packed, 40, 32); + System.arraycopy(reg20, 0, packed, 72, 20); + + byte[] digest = Hash.sha3(packed); + Sign.SignatureData sig = Sign.signPrefixedMessage(digest, keyPair); + + byte[] out = new byte[65]; + System.arraycopy(sig.getR(), 0, out, 0, 32); + System.arraycopy(sig.getS(), 0, out, 32, 32); + out[64] = sig.getV()[0]; + return out; + } + + /** + * Compute whitelist merkle root from the configured base58 addresses. + * Leaf = keccak256(bytes32(address)) (left padded 12 bytes). + * Internal nodes: keccak256(min(a,b) || max(a,b)) (sorted pair). + * Odd nodes: duplicate the last element (script behavior for whitelist trees). + */ + private String computeWhitelistRootFromConfig() { + List addrs = whitelistProps.getAddresses(); + if (addrs == null || addrs.isEmpty()) { + throw new IllegalStateException("whitelist.addresses is empty"); + } + List leaves = new ArrayList<>(addrs.size()); + for (String a : addrs) leaves.add(whitelistAddressToLeaf(a)); + byte[] root = computeRootDuplicateOddSortedPairs(leaves); + return "0x" + bytesToHex(root); + } + + private static byte[] computeRootDuplicateOddSortedPairs(List leaves) { + List level = new ArrayList<>(leaves.size()); + for (byte[] l : leaves) level.add(l.clone()); + while (level.size() > 1) { + List next = new ArrayList<>(); + for (int i = 0; i < level.size(); i += 2) { + byte[] left = level.get(i); + byte[] right = (i + 1 < level.size()) ? level.get(i + 1) : left; // duplicate odd + next.add(hashPairSorted(left, right)); + } + level = next; + } + return level.get(0); + } + + private static List buildProofDuplicateOddSortedPairs(List leaves, int index) { + List> layers = new ArrayList<>(); + List cur = new ArrayList<>(leaves.size()); + for (byte[] l : leaves) cur.add(l.clone()); + layers.add(cur); + + while (cur.size() > 1) { + List next = new ArrayList<>(); + for (int i = 0; i < cur.size(); i += 2) { + byte[] left = cur.get(i); + byte[] right = (i + 1 < cur.size()) ? cur.get(i + 1) : left; // duplicate odd + next.add(hashPairSorted(left, right)); + } + layers.add(next); + cur = next; + } + + List proof = new ArrayList<>(); + int idx = index; + for (int layerIdx = 0; layerIdx < layers.size() - 1; layerIdx++) { + List layer = layers.get(layerIdx); + int sib = idx ^ 1; + if (sib < layer.size()) { + proof.add(layer.get(sib)); + } + idx /= 2; + } + return proof; + } + + private static byte[] hashPairSorted(byte[] a, byte[] b) { + if (compareBytes(a, b) <= 0) { + return Hash.sha3(concat(a, b)); + } + return Hash.sha3(concat(b, a)); + } + + private static int compareBytes(byte[] a, byte[] b) { + int len = Math.min(a.length, b.length); + for (int i = 0; i < len; i++) { + int ai = a[i] & 0xff; + int bi = b[i] & 0xff; + if (ai != bi) return ai - bi; + } + return a.length - b.length; + } + + private static byte[] concat(byte[] a, byte[] b) { + byte[] out = new byte[a.length + b.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(b, 0, out, a.length, b.length); + return out; + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) sb.append(String.format("%02x", b & 0xff)); + return sb.toString(); + } + + private static String normalizeHex32(String h) { + if (h == null) return null; + String s = h.toLowerCase(); + return s.startsWith("0x") ? s : "0x" + s; + } + + private static String cleanHex(String value) { + if (value == null) return ""; + return (value.startsWith("0x") || value.startsWith("0X")) + ? value.substring(2) + : value; + } +} diff --git a/backend/src/main/java/dao/tron/tsol/util/CryptoUtil.java b/backend/src/main/java/dao/tron/tsol/util/CryptoUtil.java new file mode 100644 index 0000000..8507fce --- /dev/null +++ b/backend/src/main/java/dao/tron/tsol/util/CryptoUtil.java @@ -0,0 +1,45 @@ +package dao.tron.tsol.util; + +import java.security.SecureRandom; + +/** + * Cryptographic utilities. + * + * IMPORTANT: + * - Use SecureRandom for salts/nonces intended to be unpredictable. + * - For Settlement batchSalt, the on-chain contract (sc/) currently expects uint64; we therefore expose a uint64-safe + * generator that returns a non-zero positive long (1..Long.MAX_VALUE). + */ +public final class CryptoUtil { + private CryptoUtil() {} + + private static final SecureRandom RNG = new SecureRandom(); + + public static byte[] randomBytes32() { + byte[] salt = new byte[32]; + RNG.nextBytes(salt); + return salt; + } + + public static String toHex0x(byte[] bytes) { + StringBuilder sb = new StringBuilder("0x"); + for (byte b : bytes) sb.append(String.format("%02x", b)); + return sb.toString(); + } + + /** + * Generate a non-zero uint64 value that is safe to round-trip in Java as a signed long + * and safe to encode using Uint64(BigInteger.valueOf(...)). + */ + public static long randomUint64PositiveNonZero() { + long v; + do { + v = RNG.nextLong() & Long.MAX_VALUE; + } while (v == 0L); + return v; + } +} + + + + diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml new file mode 100644 index 0000000..d7d2d75 --- /dev/null +++ b/backend/src/main/resources/application.yaml @@ -0,0 +1,69 @@ +spring: + application: + name: tsol-backend + +# ----------------------------------------------------------------------------- +# Configuration priority (highest to lowest): +# 1) System environment variables +# 2) .env file (loaded by spring-dotenv) +# 3) Defaults below +# ----------------------------------------------------------------------------- + +server: + port: ${PORT:8080} + +settlement: + # TRON node gRPC endpoint + node-endpoint: ${NODE_ENDPOINT:grpc.nile.trongrid.io:50051} + # Settlement contract address (base58) + contract-address: ${SETTLEMENT_ADDRESS} + # Aggregator private key (64 hex chars, no 0x) - MUST be set in .env or environment + private-key: ${UPDATER_PRIVATE_KEY} + # Optional: aggregator base58 address (if you want to pin it; otherwise derived from key where applicable) + aggregator-address: ${UPDATER_ADDRESS} + polling: + tx-info-timeout-seconds: ${SETTLEMENT_TX_INFO_TIMEOUT_SECONDS:60} + tx-info-poll-initial-ms: ${SETTLEMENT_TX_INFO_POLL_INITIAL_MS:250} + tx-info-poll-max-ms: ${SETTLEMENT_TX_INFO_POLL_MAX_MS:2000} + batch-submitted-timeout-seconds: ${SETTLEMENT_BATCH_SUBMITTED_TIMEOUT_SECONDS:60} + batch-submitted-poll-initial-ms: ${SETTLEMENT_BATCH_SUBMITTED_POLL_INITIAL_MS:500} + batch-submitted-poll-max-ms: ${SETTLEMENT_BATCH_SUBMITTED_POLL_MAX_MS:3000} + +whitelist: + # Whitelist registry contract address (base58) + registry-address: ${WHITELIST_REGISTRY_ADDRESS} + # Current whitelist merkle root (0x-prefixed hex) + merkle-root: ${WL_NEW_ROOT} + # Whitelist update nonce + nonce: ${WL_NONCE:0} + # List of whitelisted addresses (comma-separated) + addresses: ${WHITELIST_ADDRESSES} + +fee: + # FeeModule contract address (base58) + module-address: ${FEE_MODULE_ADDRESS} + +token: + # ERC20/TRC20 token contract address (base58) + address: ${TOKEN_ADDRESS:TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf} + +batch: + # Batch processing defaults + max-tx-per-batch: ${MAX_TX_PER_BATCH:5} + timelock-duration: ${TIMELOCK_DURATION:0} + merkle-root: ${BATCH_MERKLE_ROOT:} + +chain: + # Nile=3448148188, Mainnet=728126428 + id: ${CHAIN_ID:3448148188} + +scheduler: + batching: + enabled: ${SCHEDULER_BATCHING_ENABLED:true} + check-interval-ms: ${SCHEDULER_BATCHING_CHECK_INTERVAL_MS:3000} + max-intents: ${SCHEDULER_BATCHING_MAX_INTENTS:5} + max-delay-seconds: ${SCHEDULER_BATCHING_MAX_DELAY_SECONDS:30} + execution: + enabled: ${SCHEDULER_EXECUTION_ENABLED:true} + check-interval-ms: ${SCHEDULER_EXECUTION_CHECK_INTERVAL_MS:5000} + max-parallel: ${SCHEDULER_EXECUTION_MAX_PARALLEL:3} \ No newline at end of file diff --git a/backend/src/test/java/dao/tron/tsol/TsolBackendApplicationTests.java b/backend/src/test/java/dao/tron/tsol/TsolBackendApplicationTests.java new file mode 100644 index 0000000..292deee --- /dev/null +++ b/backend/src/test/java/dao/tron/tsol/TsolBackendApplicationTests.java @@ -0,0 +1,13 @@ +package dao.tron.tsol; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class TsolBackendApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/backend/src/test/java/dao/tron/tsol/service/BatchSubmittedEventReaderTest.java b/backend/src/test/java/dao/tron/tsol/service/BatchSubmittedEventReaderTest.java new file mode 100644 index 0000000..06b8950 --- /dev/null +++ b/backend/src/test/java/dao/tron/tsol/service/BatchSubmittedEventReaderTest.java @@ -0,0 +1,65 @@ +package dao.tron.tsol.service; + +import dao.tron.tsol.event.BatchSubmittedEvent; +import dao.tron.tsol.event.BatchSubmittedEventReader; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BatchSubmittedEventReaderTest { + + @Test + void decodeLogData_decodesAllFields() { + long batchId = 7L; + String merkleRoot = "0x" + "11".repeat(32); + int txCount = 2; + long timestamp = 123456L; + + String dataHex = + "0x" + + pad32(BigInteger.valueOf(batchId)) + + strip0x(merkleRoot) + + pad32(BigInteger.valueOf(txCount)) + + pad32(BigInteger.valueOf(timestamp)); + + BatchSubmittedEvent ev = BatchSubmittedEventReader.decodeLogData(dataHex); + assertEquals(batchId, ev.batchId()); + assertEquals(merkleRoot.toLowerCase(), ev.merkleRootHex().toLowerCase()); + assertEquals(txCount, ev.txCount()); + assertEquals(timestamp, ev.timestamp()); + } + + @Test + void decodeIndexedLog_decodesIndexedBatchIdAndRoot_plusDataFields() { + long batchId = 9L; + String merkleRoot = "0x" + "aa".repeat(32); + int txCount = 5; + long timestamp = 999L; + + String topicBatchId = "0x" + pad32(BigInteger.valueOf(batchId)); + String topicMerkleRoot = merkleRoot; + String dataHex = + "0x" + + pad32(BigInteger.valueOf(txCount)) + + pad32(BigInteger.valueOf(timestamp)); + + BatchSubmittedEvent ev = BatchSubmittedEventReader.decodeIndexedLog(topicBatchId, topicMerkleRoot, dataHex); + assertEquals(batchId, ev.batchId()); + assertEquals(merkleRoot.toLowerCase(), ev.merkleRootHex().toLowerCase()); + assertEquals(txCount, ev.txCount()); + assertEquals(timestamp, ev.timestamp()); + } + + private static String pad32(BigInteger v) { + String h = v.toString(16); + return "0".repeat(64 - h.length()) + h; + } + + private static String strip0x(String h) { + return h.startsWith("0x") ? h.substring(2) : h; + } +} + + diff --git a/backend/src/test/java/dao/tron/tsol/service/MerkleRootDebugTest.java b/backend/src/test/java/dao/tron/tsol/service/MerkleRootDebugTest.java new file mode 100644 index 0000000..a8294fe --- /dev/null +++ b/backend/src/test/java/dao/tron/tsol/service/MerkleRootDebugTest.java @@ -0,0 +1,88 @@ +package dao.tron.tsol.service; + +import dao.tron.tsol.model.TransferData; +import org.junit.jupiter.api.Test; +import org.tron.trident.utils.Numeric; + +import java.util.Arrays; +import java.util.List; + +public class MerkleRootDebugTest { + + @Test + public void testBatch20MerkleRoot() { + MerkleTreeService merkleService = new MerkleTreeService(); + long batchSalt = 1L; // TODO: set to the on-chain batchSalt for Batch #20 when known + + // Transfer 1 - EXACT values from Batch #20 + TransferData tx1 = new TransferData(); + tx1.setFrom("TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M"); + tx1.setTo("TToEDBXQkGuYGsnyJASTM5JZweb7Rvrnfn"); + tx1.setAmount("2000"); + tx1.setNonce(1765547218L); + tx1.setTimestamp(1765547218L); + tx1.setRecipientCount(1); + tx1.setBatchId(20); + tx1.setTxType(1); // DELAYED + + // Transfer 2 - EXACT values from Batch #20 + TransferData tx2 = new TransferData(); + tx2.setFrom("TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M"); + tx2.setTo("TToEDBXQkGuYGsnyJASTM5JZweb7Rvrnfn"); + tx2.setAmount("2100"); + tx2.setNonce(1765560218L); + tx2.setTimestamp(1765547218L); + tx2.setRecipientCount(1); + tx2.setBatchId(20); + tx2.setTxType(1); // DELAYED + + // Calculate leaf hashes + byte[] leaf1 = merkleService.leafHash(tx1, batchSalt); + byte[] leaf2 = merkleService.leafHash(tx2, batchSalt); + + System.out.println("========================================"); + System.out.println("JAVA MERKLE CALCULATION"); + System.out.println("========================================"); + System.out.println("\nTransfer 1:"); + System.out.println(" amount: " + tx1.getAmount()); + System.out.println(" nonce: " + tx1.getNonce() + " (uint64)"); + System.out.println(" timestamp: " + tx1.getTimestamp() + " (uint48)"); + System.out.println(" recipientCount: " + tx1.getRecipientCount() + " (uint32)"); + System.out.println(" batchId: " + tx1.getBatchId() + " (uint64)"); + System.out.println(" txType: " + tx1.getTxType() + " (uint8)"); + System.out.println(" TxHash: 0x" + Numeric.toHexStringNoPrefix(leaf1)); + + System.out.println("\nTransfer 2:"); + System.out.println(" amount: " + tx2.getAmount()); + System.out.println(" nonce: " + tx2.getNonce() + " (uint64)"); + System.out.println(" timestamp: " + tx2.getTimestamp() + " (uint48)"); + System.out.println(" recipientCount: " + tx2.getRecipientCount() + " (uint32)"); + System.out.println(" batchId: " + tx2.getBatchId() + " (uint64)"); + System.out.println(" txType: " + tx2.getTxType() + " (uint8)"); + System.out.println(" TxHash: 0x" + Numeric.toHexStringNoPrefix(leaf2)); + + // Calculate Merkle root + List leaves = Arrays.asList(leaf1, leaf2); + String merkleRoot = merkleService.computeMerkleRoot(leaves); + + System.out.println("\nMerkle Root: " + merkleRoot); + + System.out.println("\n========================================"); + System.out.println("COMPARISON WITH PYTHON:"); + System.out.println("========================================"); + System.out.println("Expected leaf1: 0x256dc8edc121a6dcabc8caa43ca6b51c402d6a829521e41dabbeeb1595aade23"); + System.out.println("Expected leaf2: 0xb782790b082dfa67521f90ebaf61602895a93bc0e47b5295916bf249618e0d9e"); + System.out.println("Expected root: 0xe12481e04ad5ad0d8891587442f69e975981a33865472c77557cb7226227ec2c"); + System.out.println("\nOn-chain root: 0xa842d9c15cf0db21ca4349de1c9f27333fa4eba1cb29c600ee7603ab539276ca"); + System.out.println("========================================"); + } +} + + + + + + + + + diff --git a/backend/src/test/java/dao/tron/tsol/service/MerkleTreeServiceTest.java b/backend/src/test/java/dao/tron/tsol/service/MerkleTreeServiceTest.java new file mode 100644 index 0000000..a636e99 --- /dev/null +++ b/backend/src/test/java/dao/tron/tsol/service/MerkleTreeServiceTest.java @@ -0,0 +1,454 @@ +package dao.tron.tsol.service; + +import dao.tron.tsol.model.TransferData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for MerkleTreeService + * Validates that Merkle tree generation matches Solidity contract expectations + */ +class MerkleTreeServiceTest { + + private MerkleTreeService merkleTreeService; + private static final long BATCH_SALT = 1L; // source of truth: sc/script/merkle/** uses uint64 batch_salt + + @BeforeEach + void setUp() { + merkleTreeService = new MerkleTreeService(); + } + + @Test + @DisplayName("Test leaf hash generation with sample transfer data") + void testLeafHashGeneration() { + // Arrange: Create sample transfer data + TransferData transfer = createSampleTransfer( + "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", // from + "TToEDBXQkGuYGsnyJASTM5JZweb7Rvrnfn", // to + "1000000", // amount (1 USDT with 6 decimals) + 1L, // nonce + 1702332000L, // timestamp + 1, // recipientCount + 1L, // batchId + 0 // txType (DELAYED) + ); + + // Act: Generate leaf hash + byte[] leafHash = merkleTreeService.leafHash(transfer, BATCH_SALT); + + // Assert: Verify hash is 32 bytes + assertNotNull(leafHash, "Leaf hash should not be null"); + assertEquals(32, leafHash.length, "Leaf hash should be 32 bytes"); + + // Log the hash for manual verification + System.out.println("Leaf hash (hex): 0x" + bytesToHex(leafHash)); + } + + @Test + @DisplayName("Test Merkle root generation with single transfer") + void testMerkleRootWithSingleTransfer() { + // Arrange: Create one transfer + TransferData transfer = createSampleTransfer( + "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", + "TToEDBXQkGuYGsnyJASTM5JZweb7Rvrnfn", + "1000000", + 1L, + 1702332000L, + 1, + 1L, + 0 + ); + + List leaves = new ArrayList<>(); + leaves.add(merkleTreeService.leafHash(transfer, BATCH_SALT)); + + // Act: Compute Merkle root + String merkleRoot = merkleTreeService.computeMerkleRoot(leaves); + + // Assert + assertNotNull(merkleRoot, "Merkle root should not be null"); + assertTrue(merkleRoot.startsWith("0x"), "Merkle root should start with 0x"); + assertEquals(66, merkleRoot.length(), "Merkle root should be 66 chars (0x + 64 hex chars)"); + + System.out.println("Single transfer Merkle root: " + merkleRoot); + } + + @Test + @DisplayName("Test Merkle root generation with multiple transfers") + void testMerkleRootWithMultipleTransfers() { + // Arrange: Create 5 transfers (typical batch) + List transfers = new ArrayList<>(); + + transfers.add(createSampleTransfer( + "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", + "TToEDBXQkGuYGsnyJASTM5JZweb7Rvrnfn", + "1000000", + 1L, + 1702332000L, + 1, + 1L, + 0 + )); + + transfers.add(createSampleTransfer( + "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", + "TUqVYQLKtNvLCjHw6uGPLw4Qmw7vXEavnc", + "2000000", + 2L, + 1702332001L, + 1, + 1L, + 1 + )); + + transfers.add(createSampleTransfer( + "TToEDBXQkGuYGsnyJASTM5JZweb7Rvrnfn", + "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + "500000", + 3L, + 1702332002L, + 1, + 1L, + 2 + )); + + transfers.add(createSampleTransfer( + "TUqVYQLKtNvLCjHw6uGPLw4Qmw7vXEavnc", + "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", + "3000000", + 4L, + 1702332003L, + 2, + 1L, + 0 + )); + + transfers.add(createSampleTransfer( + "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + "TToEDBXQkGuYGsnyJASTM5JZweb7Rvrnfn", + "1500000", + 5L, + 1702332004L, + 1, + 1L, + 1 + )); + + // Generate leaves + List leaves = new ArrayList<>(); + for (TransferData transfer : transfers) { + leaves.add(merkleTreeService.leafHash(transfer, BATCH_SALT)); + } + + // Act: Compute Merkle root + String merkleRoot = merkleTreeService.computeMerkleRoot(leaves); + + // Assert + assertNotNull(merkleRoot, "Merkle root should not be null"); + assertTrue(merkleRoot.startsWith("0x"), "Merkle root should start with 0x"); + assertEquals(66, merkleRoot.length(), "Merkle root should be 66 chars"); + + System.out.println("\n=== BATCH OF 5 TRANSFERS ==="); + System.out.println("Merkle root: " + merkleRoot); + System.out.println("Number of leaves: " + leaves.size()); + + // Print each leaf for debugging + for (int i = 0; i < leaves.size(); i++) { + System.out.println("Leaf " + i + ": 0x" + bytesToHex(leaves.get(i))); + } + } + + @Test + @DisplayName("Test Merkle proof generation and structure") + void testMerkleProofGeneration() { + // Arrange: Create 5 transfers + List transfers = createBatchOfTransfers(5, 1L); + + List leaves = new ArrayList<>(); + for (TransferData transfer : transfers) { + leaves.add(merkleTreeService.leafHash(transfer, BATCH_SALT)); + } + + String merkleRoot = merkleTreeService.computeMerkleRoot(leaves); + + // Act: Generate proofs for each transfer + System.out.println("\n=== MERKLE PROOF GENERATION ==="); + System.out.println("Merkle root: " + merkleRoot); + System.out.println(); + + for (int i = 0; i < leaves.size(); i++) { + List proof = merkleTreeService.buildProof(leaves, i); + + // Assert: Proof should not be empty for multiple leaves + assertNotNull(proof, "Proof should not be null for index " + i); + + // For 5 leaves, we need ceil(log2(5)) = 3 levels, so proof size should be 3 + assertTrue(proof.size() > 0, "Proof should have at least one sibling"); + assertTrue(proof.size() <= 4, "Proof should not have more than 4 siblings for 5 leaves"); + + // All proof elements should be 0x-prefixed 32-byte hashes + for (String proofElement : proof) { + assertTrue(proofElement.startsWith("0x"), "Proof element should start with 0x"); + assertEquals(66, proofElement.length(), "Proof element should be 66 chars"); + } + + System.out.println("Transfer #" + i + " proof (" + proof.size() + " siblings):"); + for (int j = 0; j < proof.size(); j++) { + System.out.println(" [" + j + "] " + proof.get(j)); + } + System.out.println(); + } + } + + @Test + @DisplayName("Test that same data produces same Merkle root (deterministic)") + void testDeterministicMerkleRoot() { + // Arrange: Create same batch twice + List transfers1 = createBatchOfTransfers(3, 1L); + List transfers2 = createBatchOfTransfers(3, 1L); + + List leaves1 = new ArrayList<>(); + List leaves2 = new ArrayList<>(); + + for (int i = 0; i < transfers1.size(); i++) { + leaves1.add(merkleTreeService.leafHash(transfers1.get(i), BATCH_SALT)); + leaves2.add(merkleTreeService.leafHash(transfers2.get(i), BATCH_SALT)); + } + + // Act: Compute roots + String root1 = merkleTreeService.computeMerkleRoot(leaves1); + String root2 = merkleTreeService.computeMerkleRoot(leaves2); + + // Assert: Should be identical + assertEquals(root1, root2, "Same data should produce same Merkle root"); + + System.out.println("Deterministic test - both roots: " + root1); + } + + @Test + @DisplayName("Test that different data produces different Merkle root") + void testDifferentDataProducesDifferentRoot() { + // Arrange: Create two different batches. + // IMPORTANT: batchId is NOT included in txHash (leaf hash), so changing only batchId must NOT change root. + // To validate "different data => different root", we change a hashed field (nonce). + List transfers1 = createBatchOfTransfers(3, 1L); + List transfers2 = createBatchOfTransfers(3, 1L); + transfers2.get(0).setNonce(transfers2.get(0).getNonce() + 1); // change hashed field + + List leaves1 = new ArrayList<>(); + List leaves2 = new ArrayList<>(); + + for (int i = 0; i < transfers1.size(); i++) { + leaves1.add(merkleTreeService.leafHash(transfers1.get(i), BATCH_SALT)); + leaves2.add(merkleTreeService.leafHash(transfers2.get(i), BATCH_SALT)); + } + + // Act: Compute roots + String root1 = merkleTreeService.computeMerkleRoot(leaves1); + String root2 = merkleTreeService.computeMerkleRoot(leaves2); + + // Assert: Should be different + assertNotEquals(root1, root2, "Different data should produce different Merkle roots"); + + System.out.println("Different data test:"); + System.out.println(" Batch 1 root: " + root1); + System.out.println(" Batch 2 root: " + root2); + } + + @Test + @DisplayName("Test Merkle proof for specific transaction in batch") + void testSpecificTransactionProof() { + // Arrange: Create a realistic batch + List transfers = new ArrayList<>(); + + // Transfer 0: Alice sends 10 USDT to Bob + transfers.add(createSampleTransfer( + "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", // Alice + "TToEDBXQkGuYGsnyJASTM5JZweb7Rvrnfn", // Bob + "10000000", // 10 USDT + 1L, + 1702332000L, + 1, + 1L, + 0 + )); + + // Transfer 1: Bob sends 5 USDT to Charlie + transfers.add(createSampleTransfer( + "TToEDBXQkGuYGsnyJASTM5JZweb7Rvrnfn", // Bob + "TUqVYQLKtNvLCjHw6uGPLw4Qmw7vXEavnc", // Charlie + "5000000", // 5 USDT + 2L, + 1702332001L, + 1, + 1L, + 0 + )); + + // Transfer 2: Charlie sends 2 USDT to Alice + transfers.add(createSampleTransfer( + "TUqVYQLKtNvLCjHw6uGPLw4Qmw7vXEavnc", // Charlie + "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", // Alice + "2000000", // 2 USDT + 3L, + 1702332002L, + 1, + 1L, + 0 + )); + + List leaves = new ArrayList<>(); + for (TransferData transfer : transfers) { + leaves.add(merkleTreeService.leafHash(transfer, BATCH_SALT)); + } + + String merkleRoot = merkleTreeService.computeMerkleRoot(leaves); + + // Act: Get proof for Transfer 1 (Bob -> Charlie) + List proof = merkleTreeService.buildProof(leaves, 1); + + // Assert + assertNotNull(proof, "Proof should exist"); + assertFalse(proof.isEmpty(), "Proof should not be empty"); + + System.out.println("\n=== SPECIFIC TRANSACTION PROOF ==="); + System.out.println("Transaction: Bob sends 5 USDT to Charlie (index 1)"); + System.out.println("Merkle Root: " + merkleRoot); + System.out.println("Proof elements: " + proof.size()); + System.out.println("Proof:"); + for (int i = 0; i < proof.size(); i++) { + System.out.println(" [" + i + "] " + proof.get(i)); + } + + // This proof can be used to call executeTransfer on-chain + System.out.println("\nThis proof can be submitted to the Settlement contract!"); + } + + @Test + @DisplayName("Test edge case: Odd number of leaves") + void testOddNumberOfLeaves() { + // Arrange: Create 3 transfers (odd number) + List transfers = createBatchOfTransfers(3, 1L); + + List leaves = new ArrayList<>(); + for (TransferData transfer : transfers) { + leaves.add(merkleTreeService.leafHash(transfer, BATCH_SALT)); + } + + // Act & Assert: Should not throw exception + assertDoesNotThrow(() -> { + String merkleRoot = merkleTreeService.computeMerkleRoot(leaves); + assertNotNull(merkleRoot, "Should handle odd number of leaves"); + + // Should be able to generate proofs for all + for (int i = 0; i < leaves.size(); i++) { + List proof = merkleTreeService.buildProof(leaves, i); + assertNotNull(proof, "Should generate proof for index " + i); + } + }); + } + + @Test + @DisplayName("Test edge case: Single leaf") + void testSingleLeaf() { + // Arrange + TransferData transfer = createSampleTransfer( + "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", + "TToEDBXQkGuYGsnyJASTM5JZweb7Rvrnfn", + "1000000", + 1L, + 1702332000L, + 1, + 1L, + 0 + ); + + List leaves = new ArrayList<>(); + leaves.add(merkleTreeService.leafHash(transfer, BATCH_SALT)); + + // Act: Compute root + String merkleRoot = merkleTreeService.computeMerkleRoot(leaves); + + // Assert: Root should equal the leaf hash itself + String expectedRoot = "0x" + bytesToHex(leaves.get(0)); + assertEquals(expectedRoot.toLowerCase(), merkleRoot.toLowerCase(), + "Single leaf root should equal leaf hash"); + + // Proof should be empty for single leaf + List proof = merkleTreeService.buildProof(leaves, 0); + assertTrue(proof.isEmpty(), "Proof for single leaf should be empty"); + } + + // ========================================================================= + // HELPER METHODS + // ========================================================================= + + private TransferData createSampleTransfer( + String from, + String to, + String amount, + long nonce, + long timestamp, + int recipientCount, + long batchId, + int txType + ) { + TransferData transfer = new TransferData(); + transfer.setFrom(from); + transfer.setTo(to); + transfer.setAmount(amount); + transfer.setNonce(nonce); + transfer.setTimestamp(timestamp); + transfer.setRecipientCount(recipientCount); + transfer.setBatchId(batchId); + transfer.setTxType(txType); + return transfer; + } + + private List createBatchOfTransfers(int count, long batchId) { + List transfers = new ArrayList<>(); + String[] addresses = { + "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", + "TToEDBXQkGuYGsnyJASTM5JZweb7Rvrnfn", + "TUqVYQLKtNvLCjHw6uGPLw4Qmw7vXEavnc", + "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + "TAhZaywaWM1zAQPADJA39FyoQk8cokRLCd" + }; + + for (int i = 0; i < count; i++) { + transfers.add(createSampleTransfer( + addresses[i % addresses.length], + addresses[(i + 1) % addresses.length], + String.valueOf((i + 1) * 1000000), // 1, 2, 3... USDT + i + 1L, + 1702332000L + i, + 1, + batchId, + i % 3 // Rotate through txTypes 0, 1, 2 + )); + } + + return transfers; + } + + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } +} + + + + + + + diff --git a/backend/src/test/java/dao/tron/tsol/service/ScriptMerkleParityTest.java b/backend/src/test/java/dao/tron/tsol/service/ScriptMerkleParityTest.java new file mode 100644 index 0000000..251686a --- /dev/null +++ b/backend/src/test/java/dao/tron/tsol/service/ScriptMerkleParityTest.java @@ -0,0 +1,109 @@ +package dao.tron.tsol.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import dao.tron.tsol.model.TransferData; +import org.junit.jupiter.api.Test; +import org.tron.trident.utils.Numeric; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Parity test: Java Merkle logic must match the scripts (source of truth). + * + * This test loads sc/script/merkle/batch/merkle_data_deploy.json (generated by the Python script) + * and verifies: + * - each txHash (leaf) matches Java leafHash() + * - the computed Merkle root matches + * - per-index Merkle proofs match exactly + * + * NOTE: Script JSON contains both EVM (0x...) and TRON base58 addresses. We use TRON base58 + * for Java TransferData because the Java backend operates on base58 inputs, but the underlying + * 20-byte address is identical, so the resulting hashes must match. + */ +public class ScriptMerkleParityTest { + + private static final String SCRIPT_JSON_PATH = "sc/script/merkle/batch/merkle_data_deploy.json"; + + @Test + void merkleMatchesScriptJson() throws Exception { + ObjectMapper om = new ObjectMapper(); + JsonNode rootNode = om.readTree(new File(SCRIPT_JSON_PATH)); + + String expectedRoot = rootNode.get("merkleRoot").asText(); + long batchSalt = rootNode.hasNonNull("batchSalt") ? rootNode.get("batchSalt").asLong() : 0L; + JsonNode txs = rootNode.get("transactions"); + + MerkleTreeService merkle = new MerkleTreeService(); + + List leaves = new ArrayList<>(); + List expectedLeafHex = new ArrayList<>(); + List> expectedProofs = new ArrayList<>(); + List transfers = new ArrayList<>(); + + for (JsonNode tx : txs) { + // addresses: use TRON base58 version from JSON to match Java inputs + String fromBase58 = tx.get("tronAddresses").get("from").asText(); + String toBase58 = tx.get("tronAddresses").get("to").asText(); + + JsonNode txDataStruct = tx.get("txDataStruct"); + String amount = txDataStruct.get(2).asText(); // string in JSON + long nonce = txDataStruct.get(3).asLong(); + long timestamp = txDataStruct.get(4).asLong(); + int recipientCount = txDataStruct.get(5).asInt(); + long batchId = txDataStruct.get(6).asLong(); // NOT hashed, but carried in TransferData + int txType = txDataStruct.get(7).asInt(); + + TransferData td = new TransferData(); + td.setFrom(fromBase58); + td.setTo(toBase58); + td.setAmount(amount); + td.setNonce(nonce); + td.setTimestamp(timestamp); + td.setRecipientCount(recipientCount); + td.setBatchId(batchId); + td.setTxType(txType); + + transfers.add(td); + + expectedLeafHex.add(tx.get("txHash").asText().toLowerCase()); + + List proof = new ArrayList<>(); + for (JsonNode p : tx.get("proof")) { + proof.add(p.asText().toLowerCase()); + } + expectedProofs.add(proof); + } + + // 1) Leaf hashes must match script txHash + for (int i = 0; i < transfers.size(); i++) { + byte[] leaf = merkle.leafHash(transfers.get(i), batchSalt); + leaves.add(leaf); + String javaLeafHex = "0x" + Numeric.toHexStringNoPrefix(leaf); + assertEquals(expectedLeafHex.get(i), javaLeafHex.toLowerCase(), "leaf/txHash mismatch at index " + i); + } + + // 2) Root must match script merkleRoot + String javaRoot = merkle.computeMerkleRoot(leaves); + assertEquals(expectedRoot.toLowerCase(), javaRoot.toLowerCase(), "merkleRoot mismatch"); + + // 3) Proofs must match exactly per index + for (int i = 0; i < leaves.size(); i++) { + List javaProof = merkle.buildProof(leaves, i); + List expectedProof = expectedProofs.get(i); + assertEquals(expectedProof, toLower(javaProof), "proof mismatch at index " + i); + } + } + + private static List toLower(List in) { + List out = new ArrayList<>(in.size()); + for (String s : in) out.add(s.toLowerCase()); + return out; + } +} + + diff --git a/backend/test-10-intents-batched.sh b/backend/test-10-intents-batched.sh new file mode 100755 index 0000000..5071989 --- /dev/null +++ b/backend/test-10-intents-batched.sh @@ -0,0 +1,252 @@ +#!/bin/bash +set -euo pipefail + +# ═══════════════════════════════════════════════════════════════════════════ +# 🚀 BATCHED FLOW TEST - 10 INTENTS (txType=2) ON NILE +# ═══════════════════════════════════════════════════════════════════════════ +# +# What this script does: +# 1) Checks backend + schedulers are running +# 2) Submits 10 intents with txType=2 (BATCHED) +# 3) Forces creation of batches immediately (expected: 2 batches with default maxIntents=5) +# 4) Monitors until all created batches are COMPLETED +# +# Important for txType=2: +# - Sender (from) must be in `whitelist.addresses` +# - On startup, backend tries to sync WhitelistRegistry root to match config +# If you changed whitelist config, restart backend before running this script. + +BASE_URL="${BASE_URL:-http://localhost:8080}" + +# Sender/recipient used previously in successful Nile tests +FROM="${FROM_ADDRESS:-TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M}" +TO="${TO_ADDRESS:-TFZMxv9HUzvsL3M7obrvikSQkuvJsopgMU}" + +COUNT="${COUNT:-10}" +SLEEP_BETWEEN="${SLEEP_BETWEEN:-0.1}" +TXTYPE="${TXTYPE:-2}" # 2 = BATCHED (requires whitelist proof) +RECIPIENT_COUNT="${RECIPIENT_COUNT:-$COUNT}" + +# FeeModule requires recipientCount > 1 for txType=2 (BATCHED). +if [ "${TXTYPE}" = "2" ] && [ "${RECIPIENT_COUNT}" -le 1 ]; then + echo "❌ Invalid RECIPIENT_COUNT=${RECIPIENT_COUNT} for txType=2 (BATCHED). Must be > 1." + echo "Tip: set RECIPIENT_COUNT=$COUNT (default) or at least 2." + exit 1 +fi + +echo "BATCHED flow: submitting ${COUNT} intents to ${BASE_URL}" +echo "FROM=${FROM}" +echo "TO=${TO}" +echo "txType=${TXTYPE} (BATCHED)" +echo + +# ──────────────────────────────────────────────────────────────────────────── +# Step 0: backend/scheduler sanity checks +# ──────────────────────────────────────────────────────────────────────────── +if ! curl -s -f "${BASE_URL}/api/monitor/stats" > /dev/null 2>&1; then + echo "❌ Backend is not running at ${BASE_URL}" + echo "Start it first: ./gradlew bootRun" + exit 1 +fi + +BASELINE_BATCH_IDS=() +if curl -s -f "${BASE_URL}/api/monitor/batches" > /dev/null 2>&1; then + # Track existing batch IDs so we can detect *all* new batches created (including ones created by scheduler). + # NOTE: macOS ships Bash 3.2 by default (no `mapfile`), so we avoid it. + BASELINE_BATCH_IDS=($(curl -s "${BASE_URL}/api/monitor/batches" | jq -r '.batches[].batchId')) +fi + +STATS=$(curl -s "${BASE_URL}/api/monitor/stats") +BATCHING_ENABLED=$(echo "$STATS" | jq -r '.schedulers.batching.enabled') +EXECUTION_ENABLED=$(echo "$STATS" | jq -r '.schedulers.execution.enabled') +MAX_INTENTS=$(echo "$STATS" | jq -r '.schedulers.batching.maxIntents') + +echo "Backend OK. Schedulers:" +echo " batching.enabled=${BATCHING_ENABLED}" +echo " execution.enabled=${EXECUTION_ENABLED}" +echo " batching.maxIntents=${MAX_INTENTS}" +echo + +if [ "${BATCHING_ENABLED}" != "true" ] || [ "${EXECUTION_ENABLED}" != "true" ]; then + echo "❌ Schedulers must be enabled for this test." + echo "Set env vars and restart backend:" + echo " SCHEDULER_BATCHING_ENABLED=true" + echo " SCHEDULER_EXECUTION_ENABLED=true" + exit 1 +fi + +# Capture baseline counts +BASELINE_BATCHES=$(echo "$STATS" | jq -r '.statistics.totalBatches') +BASELINE_TRANSFERS=$(echo "$STATS" | jq -r '.statistics.totalTransfers') +echo "Baseline: totalBatches=${BASELINE_BATCHES}, totalTransfers=${BASELINE_TRANSFERS}" +echo + +refresh_new_batch_ids() { + # Recompute NEW_BATCH_IDS by diffing current /batches against BASELINE_BATCH_IDS. + # Also keeps any already-known batch IDs (order: as discovered). + local current_ids new_ids id old seen + current_ids=($(curl -s "${BASE_URL}/api/monitor/batches" | jq -r '.batches[].batchId')) + new_ids=() + for id in "${current_ids[@]}"; do + seen=false + for old in "${BASELINE_BATCH_IDS[@]}"; do + if [ "$id" = "$old" ]; then + seen=true + break + fi + done + if [ "$seen" = "false" ]; then + # ensure uniqueness in new_ids + local already=false + local x + for x in "${new_ids[@]}"; do + if [ "$x" = "$id" ]; then + already=true + break + fi + done + if [ "$already" = "false" ]; then + new_ids+=("$id") + fi + fi + done + NEW_BATCH_IDS=("${new_ids[@]}") +} + +# ──────────────────────────────────────────────────────────────────────────── +# Step 1: submit intents (txType=2) +# ──────────────────────────────────────────────────────────────────────────── +TS=$(date +%s) + +for ((i=0; i=2)." + echo " Submit one more intent or wait for manual handling." + fi + break + fi + + RESP=$(curl -s -X POST "${BASE_URL}/api/monitor/create-batch-now") + OK=$(echo "$RESP" | jq -r '.success // false') + if [ "$OK" != "true" ]; then + echo "❌ create-batch-now failed:" + echo "$RESP" | jq '.' + exit 1 + fi + + BID=$(echo "$RESP" | jq -r '.batchId') + CREATED_BATCH_IDS+=("$BID") + echo "✅ Created batchId=${BID} (pending was ${PENDING})" + + # Small pause so monitor endpoints reflect newly stored batch + sleep 1 +done + +# Resolve final list of newly created batch IDs (covers scheduler-created batches too). +NEW_BATCH_IDS=() +refresh_new_batch_ids + +if [ "${#NEW_BATCH_IDS[@]}" -eq 0 ]; then + echo "❌ No NEW batches detected after submitting intents." + echo "Check backend logs and /api/monitor/batches." + exit 1 +fi + +echo +echo "New batches detected: ${NEW_BATCH_IDS[*]}" +echo + +# ──────────────────────────────────────────────────────────────────────────── +# Step 3: monitor execution until all created batches complete +# ──────────────────────────────────────────────────────────────────────────── +echo "Monitoring execution (up to 180s)..." +DEADLINE=$(( $(date +%s) + 180 )) + +while [ $(date +%s) -lt $DEADLINE ]; do + # Pick up any additional batches that might have been created asynchronously by the scheduler. + refresh_new_batch_ids + if [ "${#NEW_BATCH_IDS[@]}" -eq 0 ]; then + echo "⚠️ No new batches visible yet; waiting..." + sleep 2 + continue + fi + + ALL_DONE=true + echo "---- $(date) ----" + for BID in "${NEW_BATCH_IDS[@]}"; do + DETAILS=$(curl -s "${BASE_URL}/api/monitor/batch/${BID}") + STATUS=$(echo "$DETAILS" | jq -r '.batch.status // .status // "UNKNOWN"') + EXECUTED=$(echo "$DETAILS" | jq -r '.batch.transfers | map(select(.executed == true)) | length') + TOTAL=$(echo "$DETAILS" | jq -r '.batch.transfers | length') + echo "batchId=${BID} status=${STATUS} executed=${EXECUTED}/${TOTAL}" + + if [ "$STATUS" = "FAILED" ]; then + # Helpful hint for txType=2 issues: if whitelistProof is empty, contract will revert NotWhitelisted. + NEEDS_WL=$(echo "$DETAILS" | jq -r '[.batch.transfers[].txData.txType] | any(. == 2)') + if [ "$NEEDS_WL" = "true" ]; then + WL_EMPTY_CNT=$(echo "$DETAILS" | jq -r '[.batch.transfers[] | select(.txData.txType == 2) | (.whitelistProofSize // 0)] | map(select(. == 0)) | length') + if [ "$WL_EMPTY_CNT" != "0" ]; then + echo " ↳ Detected txType=2 with EMPTY whitelistProof (count=${WL_EMPTY_CNT})." + echo " ↳ Fix: ensure the sender is in whitelist config and restart backend so it can sync the on-chain whitelist root." + echo " - Check application.yaml: whitelist.addresses" + echo " - If you use .env/env vars, make sure WHITELIST_ADDRESSES is NOT empty and includes FROM=${FROM}" + fi + fi + echo "❌ Batch ${BID} FAILED. Check backend logs (common causes: whitelist root mismatch, not whitelisted, insufficient balance/allowance)." + exit 1 + fi + if [ "$STATUS" != "COMPLETED" ]; then + ALL_DONE=false + fi + done + + if [ "$ALL_DONE" = "true" ]; then + echo "✅ All created batches COMPLETED." + exit 0 + fi + + sleep 5 +done + +echo "⚠️ Timed out waiting for completion. Check:" +echo " - ${BASE_URL}/api/monitor/batches" +echo " - backend logs" +exit 1 + + diff --git a/backend/test-20-intents.sh b/backend/test-20-intents.sh new file mode 100755 index 0000000..c61cd53 --- /dev/null +++ b/backend/test-20-intents.sh @@ -0,0 +1,79 @@ +#!/bin/bash +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:8080}" + +# Sender/recipient used previously in successful Nile tests +FROM="${FROM_ADDRESS:-TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M}" +TO="${TO_ADDRESS:-TFZMxv9HUzvsL3M7obrvikSQkuvJsopgMU}" + +COUNT="${COUNT:-20}" +SLEEP_BETWEEN="${SLEEP_BETWEEN:-0.1}" + +echo "Submitting ${COUNT} intents to ${BASE_URL}" +echo "FROM=${FROM}" +echo "TO=${TO}" + +# Capture baseline +BASELINE=$(curl -s "${BASE_URL}/api/monitor/batches") +BASELINE_BATCHES=$(echo "$BASELINE" | jq -r '.totalBatches') +BASELINE_TRANSFERS=$(echo "$BASELINE" | jq -r '.statistics.totalTransfers') +echo "Baseline: totalBatches=${BASELINE_BATCHES}, totalTransfers=${BASELINE_TRANSFERS}" + +TS=$(date +%s) + +for ((i=0; i/dev/null 2>&1; then + echo "❌ Backend is not running at ${BASE_URL}" + exit 1 +fi + +STATS=$(curl -s "${BASE_URL}/api/monitor/stats") +BATCHING_ENABLED=$(echo "$STATS" | jq -r '.schedulers.batching.enabled') +EXECUTION_ENABLED=$(echo "$STATS" | jq -r '.schedulers.execution.enabled') +if [ "$BATCHING_ENABLED" != "true" ] || [ "$EXECUTION_ENABLED" != "true" ]; then + echo "❌ Schedulers must be enabled." + echo " batching.enabled=$BATCHING_ENABLED" + echo " execution.enabled=$EXECUTION_ENABLED" + exit 1 +fi + +NONCE1=$TIMESTAMP +NONCE2=$((TIMESTAMP + 1)) + +AMOUNT1=1000000 +AMOUNT2=2000000 +# For txType=2 (BATCHED) the FeeModule requires recipientCount > 1. +# For this 2-intents batch, recipientCount=2 is the natural value. +RECIPIENT_COUNT=2 + +submit_intent() { + local nonce="$1" + local amount="$2" + local body + body=$(jq -nc \ + --arg from "$FROM_ADDRESS" \ + --arg to "$TO_ADDRESS" \ + --arg amount "$amount" \ + --argjson nonce "$nonce" \ + --argjson timestamp "$TIMESTAMP" \ + --argjson recipientCount "$RECIPIENT_COUNT" \ + --argjson txType "$TXTYPE" \ + '{from:$from,to:$to,amount:$amount,nonce:$nonce,timestamp:$timestamp,recipientCount:$recipientCount,txType:$txType}') + + local http + http=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${BASE_URL}/api/intents" \ + -H "Content-Type: application/json" -d "$body") + if [ "$http" != "202" ]; then + echo "❌ Intent submit failed (HTTP=$http): $body" + exit 1 + fi +} + +echo "Submitting intent 1..." +submit_intent "$NONCE1" "$AMOUNT1" +echo "✅ Intent 1 submitted" + +echo "Submitting intent 2..." +submit_intent "$NONCE2" "$AMOUNT2" +echo "✅ Intent 2 submitted" +echo + +echo "Triggering batch creation..." +RESP=$(curl -s -X POST "${BASE_URL}/api/monitor/create-batch-now") +OK=$(echo "$RESP" | jq -r '.success // false') +if [ "$OK" != "true" ]; then + echo "❌ create-batch-now failed:" + echo "$RESP" | jq '.' + exit 1 +fi + +BATCH_ID=$(echo "$RESP" | jq -r '.batchId') +echo "✅ Batch created: batchId=${BATCH_ID}" +echo + +echo "Checking whitelistProof..." +DETAILS=$(curl -s "${BASE_URL}/api/monitor/batch/${BATCH_ID}") +WL_SIZES=$(echo "$DETAILS" | jq -r '.batch.transfers | map(.whitelistProofSize)') +echo "whitelistProof sizes: ${WL_SIZES}" + +EMPTY_CNT=$(echo "$DETAILS" | jq -r '[.batch.transfers[] | .whitelistProofSize] | map(select(. == 0)) | length') +if [ "$EMPTY_CNT" != "0" ]; then + echo "❌ whitelistProof is empty for ${EMPTY_CNT} transfers." + echo "This means the backend did NOT generate whitelist proofs (txType=2 will revert NotWhitelisted)." + echo "Fix:" + echo " - Ensure FROM is in whitelist.addresses (TRON base58)." + echo " - Restart backend (whitelist root sync runs on startup)." + exit 1 +fi +echo "✅ whitelistProof generated for all transfers" +echo + +echo "Monitoring execution (up to 60s)..." +DEADLINE=$(( $(date +%s) + 60 )) +while [ $(date +%s) -lt $DEADLINE ]; do + DETAILS=$(curl -s "${BASE_URL}/api/monitor/batch/${BATCH_ID}") + STATUS=$(echo "$DETAILS" | jq -r '.batch.status') + EXECUTED=$(echo "$DETAILS" | jq -r '.batch.transfers | map(select(.executed == true)) | length') + TOTAL=$(echo "$DETAILS" | jq -r '.batch.transfers | length') + echo "status=${STATUS} executed=${EXECUTED}/${TOTAL}" + + if [ "$STATUS" = "COMPLETED" ]; then + echo "✅ COMPLETED" + exit 0 + fi + if [ "$STATUS" = "FAILED" ]; then + echo "❌ FAILED (check backend logs for revert reason)" + exit 1 + fi + sleep 5 +done + +echo "⚠️ Timed out waiting for completion. Check:" +echo " ${BASE_URL}/api/monitor/batch/${BATCH_ID}" +exit 1 + + diff --git a/backend/test-two-intents-full-flow.sh b/backend/test-two-intents-full-flow.sh new file mode 100755 index 0000000..da1b7f4 --- /dev/null +++ b/backend/test-two-intents-full-flow.sh @@ -0,0 +1,462 @@ +#!/bin/bash + +# ═══════════════════════════════════════════════════════════════════════════ +# 🚀 FULL FLOW TEST - 2 INTENTS ON NILE +# ═══════════════════════════════════════════════════════════════════════════ +# +# This script tests the COMPLETE Java backend flow with 2 intents: +# 1. Submit 2 transfer intents via REST API +# 2. Monitor batch creation +# 3. Wait for batch unlock time +# 4. Monitor execution +# 5. Verify success on Nile blockchain +# +# ═══════════════════════════════════════════════════════════════════════════ + +set -e + +BASE_URL="http://localhost:8080" +TIMESTAMP=$(date +%s) + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +MAGENTA='\033[0;35m' +NC='\033[0m' # No Color + +echo "" +echo "╔══════════════════════════════════════════════════════════════════════════╗" +echo "║ 🚀 FULL FLOW TEST - 2 INTENTS ON NILE 🚀 ║" +echo "╚══════════════════════════════════════════════════════════════════════════╝" +echo "" + +# ═══════════════════════════════════════════════════════════════════════════ +# STEP 0: Check backend is running +# ═══════════════════════════════════════════════════════════════════════════ + +echo -e "${CYAN}═══ STEP 0: Checking Backend Status ═══${NC}" +echo "" + +if ! curl -s -f $BASE_URL/api/monitor/stats > /dev/null 2>&1; then + echo -e "${RED}❌ Backend is not running!${NC}" + echo "" + echo "Please start the backend first:" + echo " ./gradlew bootRun" + echo "" + exit 1 +fi + +echo -e "${GREEN}✅ Backend is running${NC}" + +# Check configuration +CONFIG=$(curl -s $BASE_URL/api/monitor/stats) +BATCHING_ENABLED=$(echo $CONFIG | jq -r '.schedulers.batching.enabled') +EXECUTION_ENABLED=$(echo $CONFIG | jq -r '.schedulers.execution.enabled') +MAX_INTENTS=$(echo $CONFIG | jq -r '.schedulers.batching.maxIntents') +MAX_DELAY=$(echo $CONFIG | jq -r '.schedulers.batching.maxDelaySeconds') + +echo "" +echo "Configuration:" +echo " • Batching enabled: $BATCHING_ENABLED" +echo " • Execution enabled: $EXECUTION_ENABLED" +echo " • Max intents: $MAX_INTENTS" +echo " • Max delay: ${MAX_DELAY}s" +echo "" + +if [ "$BATCHING_ENABLED" != "true" ] || [ "$EXECUTION_ENABLED" != "true" ]; then + echo -e "${RED}❌ Schedulers are not enabled!${NC}" + echo "" + echo "Please enable schedulers in application.yaml or set environment variables:" + echo " SCHEDULER_BATCHING_ENABLED=true" + echo " SCHEDULER_EXECUTION_ENABLED=true" + echo "" + exit 1 +fi + +# ═══════════════════════════════════════════════════════════════════════════ +# STEP 1: Get initial statistics +# ═══════════════════════════════════════════════════════════════════════════ + +echo -e "${CYAN}═══ STEP 1: Initial Statistics ═══${NC}" +echo "" + +INITIAL_STATS=$(curl -s $BASE_URL/api/monitor/stats) +INITIAL_TRANSFERS=$(echo $INITIAL_STATS | jq -r '.statistics.totalTransfers') +INITIAL_BATCHES=$(echo $INITIAL_STATS | jq -r '.statistics.totalBatches') +INITIAL_COMPLETED=$(echo $INITIAL_STATS | jq -r '.statistics.completedBatches') + +echo "Current state:" +echo " • Total transfers: $INITIAL_TRANSFERS" +echo " • Total batches: $INITIAL_BATCHES" +echo " • Completed batches: $INITIAL_COMPLETED" +echo "" + +# ═══════════════════════════════════════════════════════════════════════════ +# STEP 2: Submit 2 transfer intents +# ═══════════════════════════════════════════════════════════════════════════ + +echo -e "${CYAN}═══ STEP 2: Submit 2 Transfer Intents ═══${NC}" +echo "" + +FROM_ADDRESS="TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M" +TO_ADDRESS="TFZMxv9HUzvsL3M7obrvikSQkuvJsopgMU" + +# Intent 1 - DELAYED (txType 0) +NONCE1=$TIMESTAMP +AMOUNT1=5000000 # 5 USDT + +echo -e "${BLUE}Intent 1 (DELAYED):${NC}" +echo " • From: $FROM_ADDRESS" +echo " • To: $TO_ADDRESS" +echo " • Amount: 5.0 USDT" +echo " • Nonce: $NONCE1" +echo " • Type: 0 (DELAYED)" +echo "" + +HTTP_CODE1=$(curl -s -w "%{http_code}" -o /dev/null -X POST $BASE_URL/api/intents \ + -H "Content-Type: application/json" \ + -d "{ + \"from\": \"$FROM_ADDRESS\", + \"to\": \"$TO_ADDRESS\", + \"amount\": \"$AMOUNT1\", + \"nonce\": $NONCE1, + \"timestamp\": $TIMESTAMP, + \"recipientCount\": 1, + \"txType\": 0 + }") + +if [ "$HTTP_CODE1" != "202" ]; then + echo -e "${RED}❌ Failed to submit intent 1! HTTP Code: $HTTP_CODE1${NC}" + exit 1 +fi + +echo -e "${GREEN}✅ Intent 1 submitted${NC}" +sleep 0.5 + +# Intent 2 - INSTANT (txType 1) +NONCE2=$((TIMESTAMP + 1)) +AMOUNT2=10000000 # 10 USDT + +echo "" +echo -e "${BLUE}Intent 2 (INSTANT):${NC}" +echo " • From: $FROM_ADDRESS" +echo " • To: $TO_ADDRESS" +echo " • Amount: 10.0 USDT" +echo " • Nonce: $NONCE2" +echo " • Type: 1 (INSTANT)" +echo "" + +HTTP_CODE2=$(curl -s -w "%{http_code}" -o /dev/null -X POST $BASE_URL/api/intents \ + -H "Content-Type: application/json" \ + -d "{ + \"from\": \"$FROM_ADDRESS\", + \"to\": \"$TO_ADDRESS\", + \"amount\": \"$AMOUNT2\", + \"nonce\": $NONCE2, + \"timestamp\": $TIMESTAMP, + \"recipientCount\": 1, + \"txType\": 1 + }") + +if [ "$HTTP_CODE2" != "202" ]; then + echo -e "${RED}❌ Failed to submit intent 2! HTTP Code: $HTTP_CODE2${NC}" + exit 1 +fi + +echo -e "${GREEN}✅ Intent 2 submitted${NC}" +echo "" + +sleep 1 + +# Verify pending count +STATS=$(curl -s $BASE_URL/api/monitor/stats) +PENDING=$(echo $STATS | jq -r '.statistics.pendingTransfers') + +echo "Pending transfers: $PENDING" +echo "" + +if [ "$PENDING" != "2" ]; then + echo -e "${YELLOW}⚠️ Expected 2 pending transfers, got $PENDING${NC}" +fi + +# ═══════════════════════════════════════════════════════════════════════════ +# STEP 3: Trigger batch creation immediately +# ═══════════════════════════════════════════════════════════════════════════ + +echo -e "${CYAN}═══ STEP 3: Trigger Batch Creation ═══${NC}" +echo "" + +echo "Note: We have 2 intents and max is $MAX_INTENTS" +echo "We can either:" +echo " a) Wait ${MAX_DELAY}s for auto-batching" +echo " b) Submit $((MAX_INTENTS - 2)) more intents" +echo " c) Manually trigger batching now" +echo "" + +echo -e "${BLUE}Manually triggering batch creation...${NC}" +echo "" + +BATCH_RESPONSE=$(curl -s -X POST $BASE_URL/api/monitor/create-batch-now) +echo "$BATCH_RESPONSE" | jq '.' +echo "" + +BATCH_CREATED=$(echo $BATCH_RESPONSE | jq -r '.success') + +if [ "$BATCH_CREATED" = "true" ]; then + BATCH_ID=$(echo $BATCH_RESPONSE | jq -r '.batchId') + MERKLE_ROOT=$(echo $BATCH_RESPONSE | jq -r '.merkleRoot') + TX_COUNT=$(echo $BATCH_RESPONSE | jq -r '.txCount') + + echo -e "${GREEN}✅ Batch created successfully!${NC}" + echo "" + echo "Batch details:" + echo " • Batch ID: $BATCH_ID" + echo " • Merkle Root: ${MERKLE_ROOT:0:20}..." + echo " • TX Count: $TX_COUNT" + echo "" +else + echo -e "${RED}❌ Batch creation failed!${NC}" + echo "" + ERROR=$(echo $BATCH_RESPONSE | jq -r '.error // "Unknown error"') + echo "Error: $ERROR" + echo "" + exit 1 +fi + +# ═══════════════════════════════════════════════════════════════════════════ +# STEP 4: Check batch status and unlock time +# ═══════════════════════════════════════════════════════════════════════════ + +echo -e "${CYAN}═══ STEP 4: Check Batch Status ═══${NC}" +echo "" + +sleep 2 # Wait for batch submission to finalize + +BATCH_DETAILS=$(curl -s "$BASE_URL/api/monitor/batch/$BATCH_ID") +STATUS=$(echo $BATCH_DETAILS | jq -r '.batch.status') +UNLOCK_TIME=$(echo $BATCH_DETAILS | jq -r '.batch.unlockTime') +CURRENT_TIME=$(date +%s) + +echo "Batch status:" +echo " • Status: $STATUS" +echo " • Unlock time: $UNLOCK_TIME" +echo " • Current time: $CURRENT_TIME" +echo "" + +if [ "$UNLOCK_TIME" != "null" ] && [ "$UNLOCK_TIME" != "0" ]; then + WAIT_TIME=$((UNLOCK_TIME - CURRENT_TIME)) + if [ "$WAIT_TIME" -gt 0 ]; then + echo -e "${YELLOW}⚠️ Batch is locked for ${WAIT_TIME} more seconds${NC}" + echo "" + echo -e "${BLUE}Waiting for unlock time...${NC}" + echo "" + + # Wait with countdown + for ((i=$WAIT_TIME; i>0; i--)); do + if [ $((i % 5)) -eq 0 ] || [ $i -le 5 ]; then + echo -e " ⏳ ${i}s remaining..." + fi + sleep 1 + done + + echo "" + echo -e "${GREEN}✅ Batch is now unlocked!${NC}" + echo "" + else + echo -e "${GREEN}✅ Batch is already unlocked!${NC}" + echo "" + fi +else + echo -e "${GREEN}✅ No timelock (unlock time is 0)${NC}" + echo "" +fi + +# ═══════════════════════════════════════════════════════════════════════════ +# STEP 5: Monitor execution +# ═══════════════════════════════════════════════════════════════════════════ + +echo -e "${CYAN}═══ STEP 5: Monitor Execution ═══${NC}" +echo "" + +echo "Execution scheduler checks every 5 seconds" +echo "Monitoring for up to 60 seconds..." +echo "" + +EXECUTION_SUCCESS=false + +for i in {1..12}; do + sleep 5 + + BATCH_DETAILS=$(curl -s "$BASE_URL/api/monitor/batch/$BATCH_ID") + STATUS=$(echo $BATCH_DETAILS | jq -r '.batch.status') + EXECUTED_COUNT=$(echo $BATCH_DETAILS | jq -r '.batch.transfers | map(select(.executed == true)) | length') + TOTAL_COUNT=$(echo $BATCH_DETAILS | jq -r '.batch.transfers | length') + + echo -e " [${i}/12] Status: ${STATUS}, Executed: ${EXECUTED_COUNT}/${TOTAL_COUNT}" + + if [ "$STATUS" = "COMPLETED" ]; then + echo "" + echo -e "${GREEN}✅ Batch execution completed!${NC}" + EXECUTION_SUCCESS=true + break + elif [ "$STATUS" = "FAILED" ]; then + echo "" + echo -e "${RED}❌ Batch execution failed!${NC}" + break + fi +done + +echo "" + +if [ "$EXECUTION_SUCCESS" != "true" ]; then + if [ "$STATUS" = "FAILED" ]; then + echo -e "${RED}Execution failed. Check logs for details.${NC}" + else + echo -e "${YELLOW}Execution still in progress or not started yet.${NC}" + echo "Current status: $STATUS" + fi + echo "" +fi + +# ═══════════════════════════════════════════════════════════════════════════ +# STEP 6: View detailed batch information +# ═══════════════════════════════════════════════════════════════════════════ + +echo -e "${CYAN}═══ STEP 6: Batch Details ═══${NC}" +echo "" + +BATCH_DETAILS=$(curl -s "$BASE_URL/api/monitor/batch/$BATCH_ID") +echo "$BATCH_DETAILS" | jq '{ + batchId: .batch.batchId, + status: .batch.status, + merkleRoot: .batch.merkleRoot, + unlockTime: .batch.unlockTime, + transfers: .batch.transfers | map({ + from: .txData.from, + to: .txData.to, + amount: .txData.amount, + txType: .txData.txType, + executed: .executed + }) +}' +echo "" + +# ═══════════════════════════════════════════════════════════════════════════ +# STEP 7: Verify on Nile blockchain (if execution succeeded) +# ═══════════════════════════════════════════════════════════════════════════ + +if [ "$EXECUTION_SUCCESS" = "true" ]; then + echo -e "${CYAN}═══ STEP 7: Verify on Nile Blockchain ═══${NC}" + echo "" + + # Extract transaction details + TRANSFER_1_FROM=$(echo $BATCH_DETAILS | jq -r '.batch.transfers[0].txData.from') + TRANSFER_1_TO=$(echo $BATCH_DETAILS | jq -r '.batch.transfers[0].txData.to') + TRANSFER_1_AMOUNT=$(echo $BATCH_DETAILS | jq -r '.batch.transfers[0].txData.amount') + + TRANSFER_2_FROM=$(echo $BATCH_DETAILS | jq -r '.batch.transfers[1].txData.from') + TRANSFER_2_TO=$(echo $BATCH_DETAILS | jq -r '.batch.transfers[1].txData.to') + TRANSFER_2_AMOUNT=$(echo $BATCH_DETAILS | jq -r '.batch.transfers[1].txData.amount') + + echo "Transfer 1:" + echo " • From: $TRANSFER_1_FROM" + echo " • To: $TRANSFER_1_TO" + echo " • Amount: $TRANSFER_1_AMOUNT (5.0 USDT)" + echo " • Type: DELAYED" + echo "" + + echo "Transfer 2:" + echo " • From: $TRANSFER_2_FROM" + echo " • To: $TRANSFER_2_TO" + echo " • Amount: $TRANSFER_2_AMOUNT (10.0 USDT)" + echo " • Type: INSTANT" + echo "" + + echo -e "${BLUE}To verify on Nile blockchain:${NC}" + echo "" + echo "1. Check Settlement contract events:" + echo " https://nile.tronscan.org/#/contract/TDum6BeRGA5hruf1Z2FRfavEZTn5DfWqAJ/events" + echo "" + echo "2. Look for 'TransferExecuted' events for batch ID: $BATCH_ID" + echo "" + echo "3. Check recipient balance:" + echo " https://nile.tronscan.org/#/address/$TO_ADDRESS" + echo "" +fi + +# ═══════════════════════════════════════════════════════════════════════════ +# STEP 8: Final statistics +# ═══════════════════════════════════════════════════════════════════════════ + +echo -e "${CYAN}═══ STEP 8: Final Statistics ═══${NC}" +echo "" + +FINAL_STATS=$(curl -s $BASE_URL/api/monitor/stats) +FINAL_TRANSFERS=$(echo $FINAL_STATS | jq -r '.statistics.totalTransfers') +FINAL_BATCHES=$(echo $FINAL_STATS | jq -r '.statistics.totalBatches') +FINAL_COMPLETED=$(echo $FINAL_STATS | jq -r '.statistics.completedBatches') +FINAL_PENDING=$(echo $FINAL_STATS | jq -r '.statistics.pendingTransfers') + +echo "Statistics:" +echo " • Total transfers: $FINAL_TRANSFERS (was $INITIAL_TRANSFERS, +$((FINAL_TRANSFERS - INITIAL_TRANSFERS)))" +echo " • Total batches: $FINAL_BATCHES (was $INITIAL_BATCHES, +$((FINAL_BATCHES - INITIAL_BATCHES)))" +echo " • Completed batches: $FINAL_COMPLETED (was $INITIAL_COMPLETED, +$((FINAL_COMPLETED - INITIAL_COMPLETED)))" +echo " • Pending transfers: $FINAL_PENDING" +echo "" + +# ═══════════════════════════════════════════════════════════════════════════ +# SUMMARY +# ═══════════════════════════════════════════════════════════════════════════ + +echo "" +echo "╔══════════════════════════════════════════════════════════════════════════╗" +echo "║ 🎯 TEST SUMMARY 🎯 ║" +echo "╚══════════════════════════════════════════════════════════════════════════╝" +echo "" + +echo -e "${GREEN}✓${NC} Backend running and configured" +echo -e "${GREEN}✓${NC} 2 transfer intents submitted" +echo -e "${GREEN}✓${NC} Batch created (ID: $BATCH_ID)" + +if [ "$EXECUTION_SUCCESS" = "true" ]; then + echo -e "${GREEN}✓${NC} Batch execution completed successfully" + echo "" + echo -e "${MAGENTA}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${MAGENTA} 🎉 SUCCESS! ALL TESTS PASSED 🎉 ${NC}" + echo -e "${MAGENTA}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo "Your Java backend successfully:" + echo " 1. ✓ Received 2 transfer intents" + echo " 2. ✓ Created a merkle tree batch" + echo " 3. ✓ Submitted batch to Nile blockchain" + echo " 4. ✓ Waited for unlock time" + echo " 5. ✓ Executed both transfers on-chain" + echo "" + echo "Verify on Nile blockchain:" + echo " 🔗 https://nile.tronscan.org/#/contract/TDum6BeRGA5hruf1Z2FRfavEZTn5DfWqAJ/events" + echo "" +else + echo -e "${YELLOW}⚠${NC} Batch execution status: $STATUS" + echo "" + echo "Possible reasons:" + echo " • Still processing (check logs)" + echo " • Insufficient balance or allowance" + echo " • Contract configuration issue" + echo "" + echo "Check backend logs for details:" + echo " ./gradlew bootRun" +fi + +echo "" +echo "View all batches:" +echo " curl $BASE_URL/api/monitor/batches | jq" +echo "" +echo "View this batch:" +echo " curl $BASE_URL/api/monitor/batch/$BATCH_ID | jq" +echo "" +echo "═══════════════════════════════════════════════════════════════════════════" +echo "" + diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 0000000..1c6b0da --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,91 @@ +## TSOL Architecture + +### **WhitelistRegistry.sol** + + ──────── STATE VARIABLES ──────── + ├── bytes32 merkleRoot + ├── uint256 lastUpdate + ──────── FUNCTIONS ──────── + ├── function verifyWhitelist(user, proof) + ├── function updateMerkleRoot(newRoot, sig) + ├── function requestWhitelist(proof) + ──────── EVENTS ──────── + ├── emits WhitelistUpdated, WhitelistRequested + +### **FeeModule.sol** + + ──────── TYPES ──────── + ├── enum TxType + ├── mapping(address => uint256) dailyTxCount + ├── mapping(address => uint256) lastResetTimestamp + ├── mapping(bytes32 transferHash => FeeRecord) feeRecords + ├── mapping(address => bytes32[]) userFeeHistory + ├── mapping(bytes32 batchId => uint256) batchTotalFees + ──────── STATE VARIABLES ──────── + ├── ITSOLWhitelistRegistry public whitelistRegistry + ├── uint256 public totalFeesCollected + ├── uint256 FREE_TIER_LIMIT = 10 tx/day + ├── uint256 INSTANT_FEE = 0.2 TRX + ├── uint256 BATCH_FEE_PER_RECIPIENT = 0.05 TRX/rcpt + ├── uint256 BASE_FEE = 0.1 TRX + ──────── FUNCTIONS ──────── + ├── function calculateFee(sender, TxType, volume, recipientCount) + ├── 1. Check whitelist status (for batch processing) + ├── 2. Check large volume → ENERGY-FREE + ├── 3. Check daily free tier (for small users) + ├── 4. Calculate fee based on TxType + └── 5. Return fee & whitelist status + ├── function applyFee(sender, fee, TxType, transferHash, batchId) + ──────── EVENTS ──────── + ├── emits FeeCalculated, FeeApplied, FreeTierUsed + +### **Settlement.sol** + + ──────── TYPES ──────── + ├── struct Batch + ├── struct TransferData + ├── mapping(bytes32 batchId => Batch) batches + ├── mapping (uint256 => bool) executedTransfers + ├── mapping(address => bool) approvedAggregators + ──────── STATE VARIABLES ──────── + ├── ITSOLFeeModule public feeModule + ├── ITSOLWhitelistRegistry public whitelistRegistry + ├── uint256 maxTxPerBatch + ├── uint256 public timeLockDuration + ──────── MODIFIERS ──────── + ├── modifier onlyOwner() + ├── modifier onlyApprovedAggregator() + ──────── FUNCTIONS ──────── + ├── function submitBatch(rootHash, txCount, batchMetadata) onlyApprovedAggregator + ├── 1. Validate + ├── 2. Store batch + ├── 3. Time lock (delayed finality) + └── 4. Emit BatchSubmitted event + ├── function executeTransfer(proof, transactionData) + ├── 1. Get batch merkleRoot from metadata + ├── 2. Validate batch exists and time lock passed + ├── 3. Generate transfer hash + ├── 4. Check not executed + ├── 5. Verify Merkle proof + ├── 6. Calculate fee (with whitelist check) + ├── 7. Apply fee + ├── 8. Execute token transfer + ├── 9. Mark as executed + └── 10. Emit TransferExecuted event + ├── function _verifyMerkleProof(root, leaf, proof) + ├── function setFeeModule(_feeModule) onlyOwner + ├── function setWhitelistRegistry(_registry) onlyOwner + ├── function approveAggregator(aggregator, approved) onlyOwner + ├── function setMaxTxPerBatch(_max) onlyOwner + ├── function setTimeLockDuration(_duration) onlyOwner + ──────── EVENTS ──────── + ├── emits BatchSubmitted, TransferExecuted + + +**flow:** + +``` +On-chain: submitBatch(merkleRoot) → time lock 1 min + ↓ + executeTransfer(proof, data) → Merkle verify → transfer tokens +``` \ No newline at end of file diff --git a/contracts/foundry.lock b/contracts/foundry.lock new file mode 100644 index 0000000..dc035b1 --- /dev/null +++ b/contracts/foundry.lock @@ -0,0 +1,14 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.11.0", + "rev": "8e40513d678f392f398620b3ef2b418648b33e89" + } + }, + "lib/openzeppelin-contracts": { + "tag": { + "name": "v5.5.0", + "rev": "fcbae5394ae8ad52d8e580a3477db99814b9d565" + } + } +} \ No newline at end of file diff --git a/contracts/foundry.toml b/contracts/foundry.toml new file mode 100644 index 0000000..2166e98 --- /dev/null +++ b/contracts/foundry.toml @@ -0,0 +1,23 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +via_ir = true +optimizer = true +optimizer_runs = 200 + +remappings = ["@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts"] + +# Formatter settings +line_length = 120 +tab_width = 4 +bracket_spacing = false +int_types = "long" +multiline_func_header = "attributes_first" +quote_style = "double" +number_underscore = "thousands" +override_spacing = true +wrap_comments = true +ignore = [] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/contracts/package.json b/contracts/package.json new file mode 100644 index 0000000..f6f5808 --- /dev/null +++ b/contracts/package.json @@ -0,0 +1,35 @@ +{ + "name": "tron-sc", + "version": "1.0.0", + "description": "──────── STATE VARIABLES ──────── ├── bytes32 merkleRoot ├── uint256 lastUpdate ──────── FUNCTIONS ──────── ├── function verifyWhitelist(user, proof) ├── function updateMerkleRoot(newRoot, sig) ├── function requestWhitelist(proof) ──────── EVENTS ──────── ├── emits WhitelistUpdated, WhitelistRequested", + "main": "index.js", + "directories": { + "lib": "lib", + "test": "test" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "lint": "solhint 'src/**/*.sol'", + "lint:fix": "solhint 'src/**/*.sol' --fix" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/BoostyLabs/tron-sc.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "bugs": { + "url": "https://github.com/BoostyLabs/tron-sc/issues" + }, + "homepage": "https://github.com/BoostyLabs/tron-sc#readme", + "dependencies": { + "dotenv": "^17.2.3", + "ethereumjs-util": "^7.1.5", + "tronweb": "^6.1.0" + }, + "devDependencies": { + "solhint": "^6.0.1" + } +} diff --git a/contracts/script/for-tests/DeployFeeModule.s.sol b/contracts/script/for-tests/DeployFeeModule.s.sol new file mode 100644 index 0000000..96fc38f --- /dev/null +++ b/contracts/script/for-tests/DeployFeeModule.s.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Script} from "forge-std/Script.sol"; +import {FeeModule} from "../../src/FeeModule.sol"; + +contract DeployFeeModule is Script { + function run() public returns (FeeModule) { + vm.startBroadcast(); + FeeModule feeModule = new FeeModule(); + vm.stopBroadcast(); + return feeModule; + } +} diff --git a/contracts/script/for-tests/DeployRegistry.s.sol b/contracts/script/for-tests/DeployRegistry.s.sol new file mode 100644 index 0000000..1d422b1 --- /dev/null +++ b/contracts/script/for-tests/DeployRegistry.s.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Script} from "forge-std/Script.sol"; +import {WhitelistRegistry} from "../../src/WhitelistRegistry.sol"; +import {HelperConfig} from "./HelperConfig.s.sol"; + +contract DeployRegistry is Script { + function run() public returns (WhitelistRegistry) { + HelperConfig helperConfig = new HelperConfig(); + + vm.startBroadcast(); + address updater = helperConfig.getActiveNetworkConfig(); + WhitelistRegistry registry = new WhitelistRegistry(updater); + vm.stopBroadcast(); + + return registry; + } +} diff --git a/contracts/script/for-tests/DeploySettlement.s.sol b/contracts/script/for-tests/DeploySettlement.s.sol new file mode 100644 index 0000000..1d6c4cc --- /dev/null +++ b/contracts/script/for-tests/DeploySettlement.s.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Script} from "forge-std/Script.sol"; +import {Settlement} from "../../src/Settlement.sol"; + +contract DeploySettlement is Script { + function run() public returns (Settlement) { + vm.startBroadcast(); + Settlement settlement = new Settlement(); + vm.stopBroadcast(); + + return settlement; + } +} diff --git a/contracts/script/for-tests/HelperConfig.s.sol b/contracts/script/for-tests/HelperConfig.s.sol new file mode 100644 index 0000000..fe5a56d --- /dev/null +++ b/contracts/script/for-tests/HelperConfig.s.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Script} from "forge-std/Script.sol"; + +contract HelperConfig is Script { + struct NetworkConfig { + address updater; + } + + NetworkConfig public activeNetworkConfig; + + constructor() { + if (block.chainid == 2494104990) { + activeNetworkConfig = getShastaTestnetConfig(); + } else if (block.chainid == 728126428) { + activeNetworkConfig = getTronMainnetConfig(); + } else { + activeNetworkConfig = getOrCreateAnvilEthConfig(); + } + } + + function getShastaTestnetConfig() public pure returns (NetworkConfig memory) { + // change address + address updater = address(1); + return NetworkConfig({updater: updater}); + } + + function getTronMainnetConfig() public pure returns (NetworkConfig memory) { + // change address + address updater = address(1); + return NetworkConfig({updater: updater}); + } + + function getOrCreateAnvilEthConfig() public pure returns (NetworkConfig memory) { + // default anvil address + address updater = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; + return NetworkConfig({updater: updater}); + } + + function getActiveNetworkConfig() public view returns (address) { + return (activeNetworkConfig.updater); + } +} diff --git a/contracts/script/interactions/1_set.js b/contracts/script/interactions/1_set.js new file mode 100644 index 0000000..e6d3a73 --- /dev/null +++ b/contracts/script/interactions/1_set.js @@ -0,0 +1,59 @@ +const fs = require('fs'); +const path = require('path'); +const { TronWeb } = require('tronweb'); +require('dotenv').config({ quiet: true }); + +const NETWORKS = { + nile: { fullHost: 'https://nile.trongrid.io' }, + mainnet: { fullHost: 'https://api.trongrid.io' } +}; + +const FEE_LIMIT = 500_000_000; + +function loadArtifact(name) { + const p = path.join(__dirname, '../../out', `${name}.sol`, `${name}.json`); + const j = JSON.parse(fs.readFileSync(p, 'utf8')); + return { abi: j.abi, bytecode: j.bytecode?.object || j.bytecode }; +} + +async function main() { + try { + const network = process.argv[2] || 'nile'; + const pk = process.env.UPDATER_PRIVATE_KEY; + if (!NETWORKS[network]) throw new Error('Network must be nile or mainnet'); + if (!pk) throw new Error('Set UPDATER_PRIVATE_KEY in .env'); + + const tronWeb = new TronWeb({ fullHost: NETWORKS[network].fullHost, privateKey: pk }); + + const { abi: feeAbi } = loadArtifact('FeeModule'); + const { abi: settlementAbi } = loadArtifact('Settlement'); + const { abi: whitelistAbi } = loadArtifact('WhitelistRegistry'); + + const feeModule = await tronWeb.contract(feeAbi, process.env.FEE_MODULE_ADDRESS); + const settlement = await tronWeb.contract(settlementAbi, process.env.SETTLEMENT_ADDRESS); + + let tx = await feeModule.setSettlement(process.env.SETTLEMENT_ADDRESS).send({ feeLimit: FEE_LIMIT }); + console.log('FeeModule setSettlement txID:', tx); + + tx = await settlement.setFeeModule(process.env.FEE_MODULE_ADDRESS).send({ feeLimit: FEE_LIMIT }); + console.log('Settlement setFeeModule txID:', tx); + + tx = await settlement.setWhitelistRegistry(process.env.WHITELIST_REGISTRY_ADDRESS).send({ feeLimit: FEE_LIMIT }); + console.log('Settlement setWhitelistRegistry txID:', tx); + + tx = await settlement.setMaxTxPerBatch(process.env.MAX_TX_PER_BATCH).send({ feeLimit: FEE_LIMIT }); + console.log('Settlement setMaxTxPerBatch txID:', tx); + + tx = await settlement.setTimelockDuration(process.env.TIMELOCK_DURATION).send({ feeLimit: FEE_LIMIT }); + console.log('Settlement setTimelockDuration txID:', tx); + + tx = await settlement.setToken(process.env.TOKEN_ADDRESS).send({ feeLimit: FEE_LIMIT }); + console.log('Settlement setToken txID:', tx); + + } catch (error) { + console.error(error); + process.exit(1); + } +} + +main(); \ No newline at end of file diff --git a/contracts/script/interactions/2_signRoot.js b/contracts/script/interactions/2_signRoot.js new file mode 100644 index 0000000..3d0e3d7 --- /dev/null +++ b/contracts/script/interactions/2_signRoot.js @@ -0,0 +1,100 @@ +const fs = require('fs'); +const path = require('path'); +const { TronWeb } = require('tronweb'); +const { ethers } = require('ethers'); +require('dotenv').config({ quiet: true }); + +const NETWORKS = { + nile: { fullHost: 'https://nile.trongrid.io' }, + mainnet: { fullHost: 'https://api.trongrid.io' } +}; + +function requireEnv(name) { + const v = process.env[name]; + if (!v) throw new Error(`Missing env: ${name}`); + return v.trim(); +} + +function tronBase58ToEvm0x(base58) { + const hex = TronWeb.address.toHex(base58); + const hexNo0x = hex.startsWith('0x') ? hex.slice(2) : hex; + if (!hexNo0x.toLowerCase().startsWith('41')) throw new Error(`Unexpected TRON hex: ${hex}`); + const evmHexNo0x = hexNo0x.slice(2); + if (evmHexNo0x.length !== 40) throw new Error(`Invalid EVM address length`); + return ethers.getAddress('0x' + evmHexNo0x); +} + +function ensureBytes32(hex) { + const h = ethers.hexlify(hex); + const b = ethers.getBytes(h); + if (b.length !== 32) throw new Error('Expected bytes32'); + return h; +} + +function loadArtifact(name) { + const p = path.join(__dirname, '../../out', `${name}.sol`, `${name}.json`); + const j = JSON.parse(fs.readFileSync(p, 'utf8')); + return { abi: j.abi }; +} + +async function main() { + const network = process.argv[2] || 'nile'; + if (!NETWORKS[network]) throw new Error('Network must be nile or mainnet'); + + const UPDATER_PK = requireEnv('UPDATER_PRIVATE_KEY'); + const REGISTRY_BASE58 = requireEnv('WHITELIST_REGISTRY_ADDRESS'); + const WL_NEW_ROOT = requireEnv('WL_NEW_ROOT'); + const CHAIN_ID = requireEnv('CHAIN_ID'); + + const wallet = new ethers.Wallet(UPDATER_PK.startsWith('0x') ? UPDATER_PK : `0x${UPDATER_PK}`); + + const tronWeb = new TronWeb({ + fullHost: NETWORKS[network].fullHost, + privateKey: UPDATER_PK + }); + + const { abi: registryAbi } = loadArtifact('WhitelistRegistry'); + const registry = await tronWeb.contract(registryAbi, REGISTRY_BASE58); + + const preNonceEnv = requireEnv('WL_NONCE'); + const preNonce = BigInt(preNonceEnv); + + const updaterBase58 = TronWeb.address.fromPrivateKey(UPDATER_PK); + const isAuth = await registry.isAuthorizedUpdater(updaterBase58).call(); + if (!isAuth) throw new Error('Updater is NOT authorized. Call addAuthorizedUpdater(updaterBase58) from an admin.'); + + const root32 = ensureBytes32(WL_NEW_ROOT); + const chainIdBig = ethers.toBigInt(CHAIN_ID); + const registry0x = tronBase58ToEvm0x(REGISTRY_BASE58); + + const packed = ethers.solidityPacked( + ['bytes32', 'uint64', 'uint256', 'address'], + [root32, preNonce, chainIdBig, registry0x] + ); + const digest = ethers.keccak256(packed); + const signature = await wallet.signMessage(ethers.getBytes(digest)); + + const recovered = ethers.verifyMessage(ethers.getBytes(digest), signature); + if (ethers.getAddress(recovered) !== ethers.getAddress(wallet.address)) { + throw new Error(`Signature mismatch: recovered ${recovered} != wallet ${wallet.address}`); + } + + const out = { + root: root32, + nonce: preNonce.toString(), + chainId: chainIdBig.toString(), + registry0x, + signature + }; + fs.writeFileSync(path.join(__dirname, 'signature.json'), JSON.stringify(out, null, 2)); + + console.log('On-chain nonce:', preNonce.toString()); + console.log('Updater authorized:', isAuth); + console.log('CHAIN_ID:', CHAIN_ID); + console.log('Signature generated and saved to signature.json'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); \ No newline at end of file diff --git a/contracts/script/interactions/3_updateRoot.js b/contracts/script/interactions/3_updateRoot.js new file mode 100644 index 0000000..292a694 --- /dev/null +++ b/contracts/script/interactions/3_updateRoot.js @@ -0,0 +1,100 @@ +const fs = require('fs'); +const path = require('path'); +const { TronWeb } = require('tronweb'); +require('dotenv').config({ quiet: true }); + +const NETWORKS = { + nile: { fullHost: 'https://nile.trongrid.io' }, + mainnet: { fullHost: 'https://api.trongrid.io' } +}; +const FEE_LIMIT = 500_000_000; + +function requireEnv(name) { + const v = process.env[name]; + if (!v) throw new Error(`Missing env: ${name}`); + return v.trim(); +} + +function loadArtifact(name) { + const p = path.join(__dirname, '../../out', `${name}.sol`, `${name}.json`); + const j = JSON.parse(fs.readFileSync(p, 'utf8')); + return { abi: j.abi }; +} + +async function waitReceipt(tronWeb, txId, tries = 10, delayMs = 1500) { + for (let i = 0; i < tries; i++) { + const r = await tronWeb.trx.getTransactionInfo(txId); + const status = r?.receipt?.result || r?.result; + if (status) return { status, receipt: r }; + await new Promise(res => setTimeout(res, delayMs)); + } + return { status: 'UNKNOWN', receipt: {} }; +} + +function normalizeHex32(h) { + if (!h) return h; + const s = h.toLowerCase(); + return s.startsWith('0x') ? s : `0x${s}`; +} + +async function main() { + const network = process.argv[2] || 'nile'; + if (!NETWORKS[network]) throw new Error('Network must be nile or mainnet'); + + const TX_PK = requireEnv('UPDATER_PRIVATE_KEY'); // same signer + const REGISTRY_BASE58 = requireEnv('WHITELIST_REGISTRY_ADDRESS'); + + const sigPath = path.join(__dirname, 'signature.json'); + if (!fs.existsSync(sigPath)) throw new Error('Missing signature.json'); + const sig = JSON.parse(fs.readFileSync(sigPath, 'utf8')); + const { root, nonce, signature, network: signedNetwork } = sig; + + if (signedNetwork && signedNetwork !== network) { + throw new Error(`Network mismatch: signature.json was created for ${signedNetwork}, you are submitting to ${network}. Re-sign or switch network.`); + } + + const expectedRoot = normalizeHex32(root); + + const tronWeb = new TronWeb({ + fullHost: NETWORKS[network].fullHost, + privateKey: TX_PK + }); + + const { abi: registryAbi } = loadArtifact('WhitelistRegistry'); + const registry = await tronWeb.contract(registryAbi, REGISTRY_BASE58); + + const preRoot = normalizeHex32(await registry.getCurrentMerkleRoot().call()); + const preNonceBN = await registry.getCurrentNonce().call(); + const preNonce = BigInt(preNonceBN.toString()); + + console.log('Submitting updateMerkleRoot...'); + const txId = await registry.updateMerkleRoot(root, nonce, signature).send({ feeLimit: FEE_LIMIT }); + console.log('updateMerkleRoot txID:', txId); + + const { status, receipt } = await waitReceipt(tronWeb, txId); + console.log('Receipt status:', status); + if (status !== 'SUCCESS') console.log('Receipt (full):', receipt); + + const postRoot = normalizeHex32(await registry.getCurrentMerkleRoot().call()); + const postNonceBN = await registry.getCurrentNonce().call(); + const postNonce = BigInt(postNonceBN.toString()); + + console.log('current root:', postRoot); + console.log('current nonce:', postNonce.toString()); + + const nonceOk = postNonce === preNonce + 1n; + const rootOk = postRoot === expectedRoot; + + if (nonceOk && rootOk) { + console.log('Success: merkle root updated and nonce incremented.'); + } else if (nonceOk && !rootOk) { + console.log('Partial success: nonce incremented, but root != expected. Check that WL_NEW_ROOT matches what was signed.'); + } else { + console.log('Update did not apply. Check nonce (must sign with current s_nonce), chainId, authorization, duplicate root, and pause state.'); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/contracts/script/interactions/4_submitBatch.js b/contracts/script/interactions/4_submitBatch.js new file mode 100644 index 0000000..681196d --- /dev/null +++ b/contracts/script/interactions/4_submitBatch.js @@ -0,0 +1,72 @@ +const fs = require('fs'); +const path = require('path'); +const { TronWeb } = require('tronweb'); +require('dotenv').config({ quiet: true }); + +const NETWORKS = { + nile: { fullHost: 'https://nile.trongrid.io' }, + mainnet: { fullHost: 'https://api.trongrid.io' }, +}; + +function loadArtifact(name) { + const p = path.join(__dirname, '../../out', `${name}.sol`, `${name}.json`); + const j = JSON.parse(fs.readFileSync(p, 'utf8')); + return { abi: j.abi }; +} + +function loadMerkleJson(filename = 'merkle_data_deploy.json') { + const p = path.join(__dirname, '../merkle/batch', filename); + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +async function main() { + try { + const network = process.argv[2] || 'nile'; + const pk = process.env.UPDATER_PRIVATE_KEY; + const settlementAddr = process.env.SETTLEMENT_ADDRESS; + if (!NETWORKS[network]) throw new Error('Network must be nile or mainnet'); + if (!pk) throw new Error('Set UPDATER_PRIVATE_KEY in .env'); + if (!settlementAddr) throw new Error('Set SETTLEMENT_ADDRESS in .env'); + + const tronWeb = new TronWeb({ fullHost: NETWORKS[network].fullHost, privateKey: pk }); + const { abi } = loadArtifact('Settlement'); + const settlement = await tronWeb.contract(abi, settlementAddr); + + const merkle = loadMerkleJson('merkle_data_deploy.json'); + let root = merkle.merkleRoot; + if (!root.startsWith('0x')) { + root = '0x' + root; + } + const txCount = merkle.txCount; + const batchSalt = merkle.batchSalt || 1; + + console.log('Submitting batch:'); + console.log(' merkleRoot:', root); + console.log(' txCount:', txCount); + console.log(' batchSalt:', batchSalt); + + const res = await settlement.submitBatch(root, txCount, batchSalt).send({ + feeLimit: 100_000_000, + shouldPollResponse: false, + callValue: 0, + }); + + console.log('Submitted. TX:', res); + + const batchId = await settlement.getBatchIdByRoot(root).call(); + console.log('Assigned batchId:', batchId.toString()); + + const batch = await settlement.getBatchById(batchId).call(); + console.log('UnlockTime:', batch.unlockTime.toString()); + + console.log('Wait until unlockTime, then execute transfers.'); + } catch (e) { + console.error('Submit failed:', e.message); + if (e.output && e.output.contractResult) { + console.error('Contract error:', e.output.contractResult); + } + process.exit(1); + } +} + +main(); \ No newline at end of file diff --git a/contracts/script/interactions/5_approveToken.js b/contracts/script/interactions/5_approveToken.js new file mode 100644 index 0000000..a4a8623 --- /dev/null +++ b/contracts/script/interactions/5_approveToken.js @@ -0,0 +1,64 @@ +const { TronWeb } = require('tronweb'); +require('dotenv').config({ quiet: true }); + +const NETWORKS = { + nile: { fullHost: 'https://nile.trongrid.io' }, + mainnet: { fullHost: 'https://api.trongrid.io' }, +}; + +const FEE_LIMIT = 500_000_000; +const MAX_UINT256 = '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; + +const TRC20_ABI = [ + { type: 'function', name: 'approve', inputs: [{ type: 'address', name: 'spender' }, { type: 'uint256', name: 'amount' }], outputs: [{ type: 'bool', name: '' }], stateMutability: 'nonpayable' }, + { type: 'function', name: 'allowance', inputs: [{ type: 'address', name: 'owner' }, { type: 'address', name: 'spender' }], outputs: [{ type: 'uint256', name: '' }], stateMutability: 'view' }, + { type: 'function', name: 'balanceOf', inputs: [{ type: 'address', name: 'owner' }], outputs: [{ type: 'uint256', name: '' }], stateMutability: 'view' }, +]; + +function parseArgs() { + const network = process.argv[2] || 'nile'; + const amount = process.argv[3] || MAX_UINT256; + return { network, amount }; +} + +async function main() { + try { + const { network, amount } = parseArgs(); + + const pk = process.env.UPDATER_PRIVATE_KEY; + const tokenAddr = process.env.TOKEN_ADDRESS; + const settlementAddr = process.env.SETTLEMENT_ADDRESS; + + if (!NETWORKS[network]) throw new Error('Network must be nile or mainnet'); + if (!pk) throw new Error('Set UPDATER_PRIVATE_KEY in .env (must be the sender account that owns tokens)'); + if (!tokenAddr) throw new Error('Set TOKEN_ADDRESS in .env (the token configured in Settlement)'); + if (!settlementAddr) throw new Error('Set SETTLEMENT_ADDRESS in .env'); + + const tronWeb = new TronWeb({ fullHost: NETWORKS[network].fullHost, privateKey: pk }); + const token = await tronWeb.contract(TRC20_ABI, tokenAddr); + + const ownerBase58 = tronWeb.address.fromPrivateKey(pk); // sender address (T-addr) + + console.log('Approving token allowance...'); + console.log(`Network: ${network}`); + console.log(`Token: ${tokenAddr}`); + console.log(`Owner: ${ownerBase58}`); + console.log(`Spender (Settlement): ${settlementAddr}`); + console.log(`Amount: ${amount}`); + + const tx = await token.approve(settlementAddr, String(amount)).send({ feeLimit: FEE_LIMIT }); + console.log('approve txID:', tx); + + const allowance = await token.allowance(ownerBase58, settlementAddr).call(); + const balance = await token.balanceOf(ownerBase58).call(); + + console.log('allowance(owner, Settlement):', allowance.toString()); + console.log('balanceOf(owner):', balance.toString()); + console.log('Done.'); + } catch (error) { + console.error(error); + process.exit(1); + } +} + +main(); diff --git a/contracts/script/interactions/6_executeTransfer.js b/contracts/script/interactions/6_executeTransfer.js new file mode 100644 index 0000000..dea82bb --- /dev/null +++ b/contracts/script/interactions/6_executeTransfer.js @@ -0,0 +1,177 @@ +const fs = require('fs'); +const path = require('path'); +const { TronWeb } = require('tronweb'); +require('dotenv').config({ quiet: true }); + +const NETWORKS = { + nile: { fullHost: 'https://nile.trongrid.io' }, + mainnet: { fullHost: 'https://api.trongrid.io' }, +}; + +function loadArtifact(name) { + const p = path.join(__dirname, '../../out', `${name}.sol`, `${name}.json`); + const j = JSON.parse(fs.readFileSync(p, 'utf8')); + return { abi: j.abi }; +} + +function loadMerkleJson(filename = 'merkle_data_deploy.json') { + const p = path.join(__dirname, '../merkle/batch', filename); + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function formatAmount(amount, decimals = 6) { + return (BigInt(amount) / BigInt(10 ** decimals)).toString(); +} + +async function main() { + try { + const network = process.argv[2] || 'nile'; + const txIndex = parseInt(process.argv[3]) || 0; + const pk = process.env.EXECUTOR_PRIVATE_KEY || process.env.UPDATER_PRIVATE_KEY; + const settlementAddr = process.env.SETTLEMENT_ADDRESS; + + if (!NETWORKS[network]) throw new Error('Network must be nile or mainnet'); + if (!pk) throw new Error('Set EXECUTOR_PRIVATE_KEY or UPDATER_PRIVATE_KEY in .env'); + if (!settlementAddr) throw new Error('Set SETTLEMENT_ADDRESS in .env'); + + const tronWeb = new TronWeb({ fullHost: NETWORKS[network].fullHost, privateKey: pk }); + + // Load Settlement contract + const settlementAbi = loadArtifact('Settlement').abi; + const settlement = tronWeb.contract(settlementAbi, settlementAddr); + + // Load Merkle data + const merkle = loadMerkleJson('merkle_data_deploy.json'); + + if (txIndex >= merkle.transactions.length) { + throw new Error(`Transaction index ${txIndex} out of range (max: ${merkle.transactions.length - 1})`); + } + + const tx = merkle.transactions[txIndex]; + + console.log(`\n Executing Transfer #${txIndex} (${tx.type})`); + console.log(` From: ${tx.tronAddresses.from}`); + console.log(` To: ${tx.tronAddresses.to}`); + console.log(` Amount: ${formatAmount(tx.txDataStruct[2])} tokens`); + + // Look up the REAL batch ID from the contract using the Merkle root + const realBatchIdRaw = await settlement.getBatchIdByRoot(merkle.merkleRoot).call(); + const realBatchId = BigInt(realBatchIdRaw).toString(); + const batchId = realBatchId; + + // Prepare transaction data struct as ARRAY (not object) + // Order: [from, to, amount, nonce, timestamp, recipientCount, batchId, txType] + const txData = [ + tx.txDataStruct[0], // from + tx.txDataStruct[1], // to + tx.txDataStruct[2], // amount + tx.txDataStruct[3], // nonce + tx.txDataStruct[4], // timestamp + tx.txDataStruct[5], // recipientCount + batchId, // batchId (from contract) + tx.txDataStruct[7] // txType + ]; + + // Validate contract state + const tokenAddr = await settlement.getToken().call(); + const feeModuleAddr = await settlement.getFeeModule().call(); + const isPaused = await settlement.paused().call(); + + if (isPaused) { + throw new Error('Settlement contract is PAUSED!'); + } + + // Validate batch status + const batch = await settlement.getBatchById(batchId).call(); + const unlockTime = batch.unlockTime.toString(); + const currentTime = Math.floor(Date.now() / 1000); + + if (currentTime < parseInt(unlockTime)) { + const waitTime = parseInt(unlockTime) - currentTime; + throw new Error(`Batch is still LOCKED! Wait ${waitTime} seconds`); + } + + // Check if already executed + const isExecuted = await settlement.isExecutedTransfer(tx.txHash).call(); + if (isExecuted) { + throw new Error('Transfer has already been EXECUTED!'); + } + + // Validate token balances and allowances + const tokenAbi = [ + { "constant": true, "inputs": [], "name": "symbol", "outputs": [{ "name": "", "type": "string" }], "type": "function" }, + { "constant": true, "inputs": [], "name": "decimals", "outputs": [{ "name": "", "type": "uint8" }], "type": "function" }, + { "constant": true, "inputs": [{ "name": "who", "type": "address" }], "name": "balanceOf", "outputs": [{ "name": "", "type": "uint256" }], "type": "function" }, + { "constant": true, "inputs": [{ "name": "owner", "type": "address" }, { "name": "spender", "type": "address" }], "name": "allowance", "outputs": [{ "name": "", "type": "uint256" }], "type": "function" } + ]; + const token = tronWeb.contract(tokenAbi, tronWeb.address.fromHex(tokenAddr)); + + const tokenSymbol = await token.symbol().call(); + const tokenDecimals = await token.decimals().call(); + const senderBalance = await token.balanceOf(tx.evmAddresses.from).call(); + const allowance = await token.allowance(tx.evmAddresses.from, settlementAddr).call(); + + if (BigInt(senderBalance.toString()) < BigInt(tx.txDataStruct[2])) { + throw new Error(`Insufficient balance! Has ${senderBalance.toString()}, needs ${tx.txDataStruct[2]}`); + } + + if (BigInt(allowance.toString()) < BigInt(tx.txDataStruct[2])) { + throw new Error(`Insufficient allowance! Has ${allowance.toString()}, needs ${tx.txDataStruct[2]}`); + } + + // Calculate fees + + let feeModuleAbi; + try { + feeModuleAbi = loadArtifact('FeeModule').abi; + } catch (e) { + feeModuleAbi = [ + { "inputs": [{ "name": "sender", "type": "address" }, { "name": "txType", "type": "uint8" }, { "name": "volume", "type": "uint256" }, { "name": "recipientCount", "type": "uint256" }], "name": "calculateFee", "outputs": [{ "components": [{ "name": "fee", "type": "uint256" }, { "name": "txType", "type": "uint8" }], "name": "info", "type": "tuple" }], "stateMutability": "view", "type": "function" } + ]; + } + const feeModule = tronWeb.contract(feeModuleAbi, tronWeb.address.fromHex(feeModuleAddr)); + + let feeAmount = '0'; + try { + const feeInfo = await feeModule.calculateFee( + tx.evmAddresses.from, + txData[7], // txType + txData[2], // amount + txData[5] // recipientCount + ).call(); + + const feeAmountRaw = feeInfo.fee || feeInfo[0] || '0'; + feeAmount = BigInt(feeAmountRaw).toString(); + + const totalRequired = BigInt(tx.txDataStruct[2]) + BigInt(feeAmount); + if (BigInt(senderBalance.toString()) < totalRequired) { + throw new Error(`Insufficient balance for amount + fee!`); + } + } catch (e) { + // Continue without fee validation + } + + // Execute the transfer + const txProof = tx.proof; + const whitelistProof = ['0x2c27f532fe88e4b25c84c1d9e51fb97002414c2ed55927eeb815cfa1733c688e']; + + const res = await settlement.executeTransfer(txProof, whitelistProof, txData).send({ + feeLimit: 150_000_000, + shouldPollResponse: false, + callValue: 0, + }); + + // Success output + console.log(` ✓ Transfer executed successfully`); + console.log(` TX Hash: ${res}`); + if (feeAmount && feeAmount !== '0') { + console.log(` Fee: ${formatAmount(feeAmount, parseInt(tokenDecimals))} ${tokenSymbol}`); + } + + } catch (e) { + console.error(` ✗ Execution failed: ${e.message}`); + process.exit(1); + } +} + +main(); diff --git a/contracts/script/interactions/addUpdater.js b/contracts/script/interactions/addUpdater.js new file mode 100644 index 0000000..d4c7aee --- /dev/null +++ b/contracts/script/interactions/addUpdater.js @@ -0,0 +1,47 @@ +const fs = require('fs'); +const path = require('path'); +const { TronWeb } = require('tronweb'); +require('dotenv').config({ quiet: true }); + +const NETWORKS = { + nile: { fullHost: 'https://nile.trongrid.io' }, + mainnet: { fullHost: 'https://api.trongrid.io' } +}; + +const FEE_LIMIT = 500_000_000; + +function loadArtifact(name) { + const p = path.join(__dirname, '../../out', `${name}.sol`, `${name}.json`); + const j = JSON.parse(fs.readFileSync(p, 'utf8')); + return { abi: j.abi, bytecode: j.bytecode?.object || j.bytecode }; +} + +async function main() { + try { + const network = process.argv[2] || 'nile'; + const pk = process.env.UPDATER_PRIVATE_KEY; + const registryAddress = process.env.WHITELIST_REGISTRY_ADDRESS; + const updater = process.argv[3] || process.env.WL_UPDATER_ADDRESS; + + if (!NETWORKS[network]) throw new Error('Network must be nile or mainnet'); + if (!pk) throw new Error('Set UPDATER_PRIVATE_KEY in .env'); + if (!registryAddress) throw new Error('Set WL_REGISTRY_ADDRESS in .env'); + if (!updater) throw new Error('Provide updater address as arg3 or set WL_UPDATER_ADDRESS in .env'); + + const tronWeb = new TronWeb({ fullHost: NETWORKS[network].fullHost, privateKey: pk }); + + const { abi: wlAbi } = loadArtifact('WhitelistRegistry'); + const wl = await tronWeb.contract(wlAbi, registryAddress); + + const zeroAddr = 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb'; + if (updater === zeroAddr) throw new Error('Updater cannot be zero address'); + + const tx = await wl.addAuthorizedUpdater(updater).send({ feeLimit: FEE_LIMIT }); + console.log('Authorized updater added:', tx); + } catch (error) { + console.error(error); + process.exit(1); + } +} + +main(); diff --git a/contracts/script/interactions/approveAggregator.js b/contracts/script/interactions/approveAggregator.js new file mode 100644 index 0000000..feccaaa --- /dev/null +++ b/contracts/script/interactions/approveAggregator.js @@ -0,0 +1,41 @@ +const fs = require('fs'); +const path = require('path'); +const { TronWeb } = require('tronweb'); +require('dotenv').config({ quiet: true }); + +const NETWORKS = { + nile: { fullHost: 'https://nile.trongrid.io' }, + mainnet: { fullHost: 'https://api.trongrid.io' } +}; + +const FEE_LIMIT = 500_000_000; + +function loadArtifact(name) { + const p = path.join(__dirname, '../../out', `${name}.sol`, `${name}.json`); + const j = JSON.parse(fs.readFileSync(p, 'utf8')); + return { abi: j.abi, bytecode: j.bytecode?.object || j.bytecode }; +} + +async function main() { + try { + const network = process.argv[2] || 'nile'; + const pk = process.env.UPDATER_PRIVATE_KEY; + if (!NETWORKS[network]) throw new Error('Network must be nile or mainnet'); + if (!pk) throw new Error('Set UPDATER_PRIVATE_KEY in .env'); + + const tronWeb = new TronWeb({ fullHost: NETWORKS[network].fullHost, privateKey: pk }); + + const { abi: settlementAbi } = loadArtifact('Settlement'); + + const settlement = await tronWeb.contract(settlementAbi, process.env.SETTLEMENT_ADDRESS); + + let tx = await settlement.approveAggregator(process.env.AGGREGATOR_ADDRESS).send({ feeLimit: FEE_LIMIT }); + console.log('Aggregator approved:', tx); + + } catch (error) { + console.error(error); + process.exit(1); + } +} + +main(); \ No newline at end of file diff --git a/contracts/script/interactions/fullSuccessScenario.js b/contracts/script/interactions/fullSuccessScenario.js new file mode 100644 index 0000000..bc5a146 --- /dev/null +++ b/contracts/script/interactions/fullSuccessScenario.js @@ -0,0 +1,165 @@ +const { execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +const SCRIPTS_DIR = path.join(__dirname); +const PROJECT_ROOT = path.join(__dirname, '..', '..'); +const COLOR_GREEN = '\x1b[32m'; +const COLOR_BLUE = '\x1b[34m'; +const COLOR_YELLOW = '\x1b[33m'; +const COLOR_RED = '\x1b[31m'; +const COLOR_RESET = '\x1b[0m'; + +function log(message, color = COLOR_RESET) { + console.log(`${color}${message}${COLOR_RESET}`); +} + +function runScript(scriptName, args = []) { + const scriptPath = path.join(SCRIPTS_DIR, scriptName); + const command = `node "${scriptPath}" ${args.join(' ')}`; + + log(`\n${'='.repeat(80)}`, COLOR_BLUE); + log(`Running: ${scriptName} ${args.join(' ')}`, COLOR_BLUE); + log('='.repeat(80), COLOR_BLUE); + + try { + execSync(command, { stdio: 'inherit', cwd: PROJECT_ROOT }); + log(`✅ ${scriptName} completed successfully\n`, COLOR_GREEN); + return true; + } catch (error) { + log(`❌ ${scriptName} failed!`, COLOR_RED); + throw error; + } +} + +async function waitForUnlockTime(network) { + const { TronWeb } = require('tronweb'); + require('dotenv').config({ quiet: true }); + + const NETWORKS = { + nile: { fullHost: 'https://nile.trongrid.io' }, + mainnet: { fullHost: 'https://api.trongrid.io' } + }; + + const pk = process.env.UPDATER_PRIVATE_KEY; + const settlementAddress = process.env.SETTLEMENT_ADDRESS; + + if (!pk || !settlementAddress) { + throw new Error('Missing UPDATER_PRIVATE_KEY or SETTLEMENT_ADDRESS in .env'); + } + + const tronWeb = new TronWeb({ + fullHost: NETWORKS[network].fullHost, + privateKey: pk + }); + + function loadArtifact(name) { + const p = path.join(__dirname, '../../out', `${name}.sol`, `${name}.json`); + const j = JSON.parse(fs.readFileSync(p, 'utf8')); + return { abi: j.abi }; + } + + const { abi } = loadArtifact('Settlement'); + const settlement = await tronWeb.contract(abi, settlementAddress); + + // Load batch data to get batchId + const batchFilePath = path.join(__dirname, '../merkle/batch/merkle_data_deploy.json'); + const batchData = JSON.parse(fs.readFileSync(batchFilePath, 'utf8')); + const batchId = batchData.batchId || 1; + + const batch = await settlement.getBatchById(batchId).call(); + const unlockTime = parseInt(batch.unlockTime.toString()); + const currentTime = Math.floor(Date.now() / 1000); + const waitSeconds = unlockTime - currentTime; + + if (waitSeconds > 0) { + log(`\n⏳ Batch is locked. Waiting ${waitSeconds} seconds for unlock time...`, COLOR_YELLOW); + log(` Current time: ${currentTime}`, COLOR_YELLOW); + log(` Unlock time: ${unlockTime}`, COLOR_YELLOW); + + // Wait with countdown + for (let i = waitSeconds; i > 0; i--) { + if (i % 10 === 0 || i <= 5) { + process.stdout.write(`\r ⏳ ${i} seconds remaining...`); + } + await new Promise(resolve => setTimeout(resolve, 1000)); + } + process.stdout.write('\r'); + log('\n✅ Batch is now unlocked!', COLOR_GREEN); + } else { + log('\n✅ Batch is already unlocked!', COLOR_GREEN); + } +} + +async function main() { + const network = process.argv[2] || 'nile'; + + if (!['nile', 'mainnet'].includes(network)) { + throw new Error('Network must be nile or mainnet'); + } + + log('\n' + '='.repeat(80), COLOR_BLUE); + log('🚀 FULL SUCCESS PASS SCENARIO - 3 TRANSFERS', COLOR_BLUE); + log('='.repeat(80), COLOR_BLUE); + log(`Network: ${network}\n`, COLOR_BLUE); + + try { + // Step 1: Sign the whitelist root + log('📝 STEP 1/6: Sign Whitelist Root', COLOR_YELLOW); + runScript('2_signRoot.js', [network]); + + // Step 2: Update the whitelist root on-chain + log('📤 STEP 2/6: Update Whitelist Root On-Chain', COLOR_YELLOW); + runScript('3_updateRoot.js', [network]); + + // Step 4: Submit the batch + log('📦 STEP 3/6: Submit Batch', COLOR_YELLOW); + runScript('4_submitBatch.js', [network]); + + // Step 5: Wait for unlock time + log('⏰ STEP 4/6: Wait for Batch Unlock Time', COLOR_YELLOW); + await waitForUnlockTime(network); + + // Step 6: Approve tokens + log('✅ STEP 5/6: Approve Tokens for Settlement Contract', COLOR_YELLOW); + runScript('5_approveToken.js', [network]); + + // Step 7: Execute all 3 transfers + log('💸 STEP 6/6: Execute All 3 Transfers', COLOR_YELLOW); + + for (let i = 0; i < 3; i++) { + log(`\n Transfer ${i + 1}/3:`, COLOR_YELLOW); + runScript('6_executeTransfer.js', [network, i.toString()]); + } + + // Wait for transactions to be processed on-chain + log('\n⏳ Waiting for transactions to be processed...', COLOR_YELLOW); + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Success summary + log('\n' + '='.repeat(80), COLOR_GREEN); + log('🎉 SUCCESS! All steps completed successfully!', COLOR_GREEN); + log('='.repeat(80), COLOR_GREEN); + log('\nSummary:', COLOR_GREEN); + log(' ✅ Whitelist root signed and updated', COLOR_GREEN); + log(' ✅ Aggregator approved', COLOR_GREEN); + log(' ✅ Batch submitted and unlocked', COLOR_GREEN); + log(' ✅ Tokens approved', COLOR_GREEN); + log(' ✅ Transfer #0 executed (DELAYED)', COLOR_GREEN); + log(' ✅ Transfer #1 executed (INSTANT)', COLOR_GREEN); + log(' ✅ Transfer #2 executed (BATCHED)', COLOR_GREEN); + log('\n' + '='.repeat(80), COLOR_GREEN); + + } catch (error) { + log('\n' + '='.repeat(80), COLOR_RED); + log('❌ SCENARIO FAILED!', COLOR_RED); + log('='.repeat(80), COLOR_RED); + log(`\nError: ${error.message}`, COLOR_RED); + process.exit(1); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/contracts/script/interactions/signature.json b/contracts/script/interactions/signature.json new file mode 100644 index 0000000..d25237c --- /dev/null +++ b/contracts/script/interactions/signature.json @@ -0,0 +1,7 @@ +{ + "root": "0x02012517de2680f90c5eb1b6c64e04e21424609e331954b45e202ace05e2938b", + "nonce": "0", + "chainId": "3448148188", + "registry0x": "0xA07Bae6d66eff93594e7540F27065d82CCBB1944", + "signature": "0x7018478d3192f1560c15ae4897adbc3f89d036aed1e0ce6fec48dd55667a0f49010e5897c74c708f1cf7e3316db3fbc0399853a6910d40b775fd459f38ca7add1b" +} \ No newline at end of file diff --git a/contracts/script/merkle/batch/generateBatchRoot.py b/contracts/script/merkle/batch/generateBatchRoot.py new file mode 100644 index 0000000..cac5cef --- /dev/null +++ b/contracts/script/merkle/batch/generateBatchRoot.py @@ -0,0 +1,224 @@ +from web3 import Web3 +from enum import IntEnum +from dataclasses import dataclass +from typing import List, Dict, Any +import json +import time + +class TxType(IntEnum): + DELAYED = 0 + INSTANT = 1 + BATCHED = 2 + FREE_TIER = 3 + +@dataclass +class TransferData: + from_address: str + to_address: str + amount: int + nonce: int + timestamp: int + recipient_count: int + batch_id: int # keep for off-chain grouping and on-chain params, but not hashed + tx_type: int + batch_salt: int # uint64 salt used by backend to build merkle root + +def calculate_tx_hash(tx: TransferData) -> bytes: + """ + Match Settlement._calculateTxHash: + keccak256(abi.encodePacked( + from, to, amount, nonce(uint64), timestamp(uint48), recipientCount(uint32), txType(uint8), batchSalt(uint64) + )) + IMPORTANT: batchId is NOT included. + """ + return Web3.solidity_keccak( + ['address', 'address', 'uint256', 'uint64', 'uint48', 'uint32', 'uint8', 'uint64'], + [ + Web3.to_checksum_address(tx.from_address), + Web3.to_checksum_address(tx.to_address), + tx.amount, + tx.nonce, # uint64 + tx.timestamp, # uint48 + tx.recipient_count, # uint32 + tx.tx_type, # uint8 + tx.batch_salt # uint64 + ] + ) + +class MerkleTree: + def __init__(self, leaves: List[bytes]): + self.leaves = leaves + self.tree = self._build_tree(leaves) + + def _build_tree(self, leaves: List[bytes]) -> List[List[bytes]]: + if not leaves: + return [[]] + tree = [leaves] + current = leaves + while len(current) > 1: + nxt = [] + for i in range(0, len(current), 2): + if i + 1 < len(current): + left = current[i] + right = current[i + 1] + # sorted pair hashing to match OpenZeppelin's MerkleProof standard pattern + if left > right: + left, right = right, left + combined = Web3.solidity_keccak(['bytes32', 'bytes32'], [left, right]) + else: + combined = current[i] + nxt.append(combined) + tree.append(nxt) + current = nxt + return tree + + def get_root(self) -> bytes: + return self.tree[-1][0] if self.tree and self.tree[-1] else b'\x00' * 32 + + def get_proof(self, index: int) -> List[bytes]: + proof = [] + idx = index + for level in range(len(self.tree) - 1): + curr = self.tree[level] + if idx % 2 == 0: + if idx + 1 < len(curr): + proof.append(curr[idx + 1]) + else: + proof.append(curr[idx - 1]) + idx //= 2 + return proof + + def verify_proof(self, leaf: bytes, proof: List[bytes], root: bytes) -> bool: + computed = leaf + for p in proof: + if computed <= p: + computed = Web3.solidity_keccak(['bytes32', 'bytes32'], [computed, p]) + else: + computed = Web3.solidity_keccak(['bytes32', 'bytes32'], [p, computed]) + return computed == root + +def generate_test_transactions() -> List[TransferData]: + SENDER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + RECIPIENT_1 = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + RECIPIENT_2 = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" + RECIPIENT_3 = "0x90F79bf6EB2c4f870365E785982E1f101E93b906" + RANDOM_ADDR = "0x1234567890123456789012345678901234567890" + ZERO_ADDR = "0x0000000000000000000000000000000000000000" + + BATCH_ID = 1 + BATCH_SALT = 1 # Salt used by backend to build merkle root + base_timestamp = int(time.time()) + ONE_TRX = 1_000_000 + + txs: List[TransferData] = [] + + for i in range(10): + txs.append(TransferData( + from_address=SENDER, + to_address=RECIPIENT_1, + amount=100 * ONE_TRX, + nonce=i + 1, + timestamp=base_timestamp + i, + recipient_count=1, + batch_id=BATCH_ID, + tx_type=TxType.DELAYED, + batch_salt=BATCH_SALT + )) + + txs.append(TransferData(SENDER, RECIPIENT_1, 50 * ONE_TRX, 11, base_timestamp + 10, 1, BATCH_ID, TxType.FREE_TIER, BATCH_SALT)) + txs.append(TransferData(SENDER, RECIPIENT_1, 200 * ONE_TRX, 12, base_timestamp + 11, 1, BATCH_ID, TxType.INSTANT, BATCH_SALT)) + txs.append(TransferData(SENDER, RECIPIENT_1, 300 * ONE_TRX, 13, base_timestamp + 12, 3, BATCH_ID, TxType.INSTANT, BATCH_SALT)) + txs.append(TransferData(SENDER, RECIPIENT_2, 500 * ONE_TRX, 14, base_timestamp + 13, 5, BATCH_ID, TxType.BATCHED, BATCH_SALT)) + txs.append(TransferData(RANDOM_ADDR, RECIPIENT_3, 150 * ONE_TRX, 15, base_timestamp + 14, 3, BATCH_ID, TxType.BATCHED, BATCH_SALT)) + txs.append(TransferData(SENDER, RECIPIENT_1, 100 * ONE_TRX, 16, base_timestamp + 15, 1, BATCH_ID, TxType.BATCHED, BATCH_SALT)) + txs.append(TransferData(SENDER, RECIPIENT_1, 1_000_000_000 * ONE_TRX, 17, base_timestamp + 16, 1, BATCH_ID, TxType.INSTANT, BATCH_SALT)) + txs.append(TransferData(ZERO_ADDR, RECIPIENT_1, 100 * ONE_TRX, 18, base_timestamp + 17, 1, BATCH_ID, TxType.DELAYED, BATCH_SALT)) + txs.append(TransferData(SENDER, ZERO_ADDR, 100 * ONE_TRX, 19, base_timestamp + 18, 1, BATCH_ID, TxType.DELAYED, BATCH_SALT)) + + return txs + +def generate_merkle_data(transactions: List[TransferData]) -> Dict[str, Any]: + leaves = [calculate_tx_hash(tx) for tx in transactions] + tree = MerkleTree(leaves) + root = tree.get_root() + + proofs_data = [] + for i, tx in enumerate(transactions): + leaf = leaves[i] + proof = tree.get_proof(i) + is_valid = tree.verify_proof(leaf, proof, root) + proofs_data.append({ + 'index': i, + 'transaction': tx, + 'tx_hash': '0x' + leaf.hex(), + 'proof': ['0x' + p.hex() for p in proof], + 'valid': is_valid + }) + + return { + 'merkle_root': '0x' + root.hex(), + 'tx_count': len(transactions), + 'transactions': transactions, + 'leaves': ['0x' + l.hex() for l in leaves], + 'proofs_data': proofs_data + } + +def print_results(data: Dict[str, Any]): + print("=" * 80) + print("MERKLE ROOT FOR BATCH") + print("=" * 80) + print(f"Merkle Root: {data['merkle_root']}") + print(f"Transaction Count: {data['tx_count']}") + print() + print("=" * 80) + print("TRANSACTIONS & PROOFS") + print("=" * 80) + + for item in data['proofs_data']: + tx = item['transaction'] + print(f"\n--- Transaction {item['index']} ---") + print(f"Type: {TxType(tx.tx_type).name}") + print(f"From: {tx.from_address}") + print(f"To: {tx.to_address}") + print(f"Amount: {tx.amount}") + print(f"Nonce: {tx.nonce}") + print(f"Timestamp: {tx.timestamp}") + print(f"Recipient Count: {tx.recipient_count}") + print(f"Batch ID (not hashed): {tx.batch_id}") + print(f"Batch Salt: {tx.batch_salt}") + print(f"TX Hash: {item['tx_hash']}") + print(f"Proof Valid: {item['valid']}") + print(f"Proof: [{', '.join(item['proof'])}]") + +def save_json(data: Dict[str, Any], filename: str = 'merkle_data.json'): + json_data = { + 'merkleRoot': data['merkle_root'], + 'txCount': data['tx_count'], + 'transactions': [] + } + for item in data['proofs_data']: + tx = item['transaction'] + json_data['transactions'].append({ + 'index': item['index'], + 'type': TxType(tx.tx_type).name, + 'from': tx.from_address, + 'to': tx.to_address, + 'amount': str(tx.amount), + 'nonce': tx.nonce, + 'timestamp': tx.timestamp, + 'recipientCount': tx.recipient_count, + 'batchId': tx.batch_id, # present for contract calls, not part of tx hash + 'batchSalt': tx.batch_salt, # used in tx hash for merkle root generation + 'txHash': item['tx_hash'], + 'proof': item['proof'], + 'valid': item['valid'] + }) + with open(filename, 'w') as f: + json.dump(json_data, f, indent=2) + print(f"\nData saved to: {filename}") + +if __name__ == "__main__": + transactions = generate_test_transactions() + merkle_data = generate_merkle_data(transactions) + print_results(merkle_data) + save_json(merkle_data) \ No newline at end of file diff --git a/contracts/script/merkle/batch/generateBatchRootDeploy.py b/contracts/script/merkle/batch/generateBatchRootDeploy.py new file mode 100644 index 0000000..ec5e2a6 --- /dev/null +++ b/contracts/script/merkle/batch/generateBatchRootDeploy.py @@ -0,0 +1,256 @@ +import time +import json +import os +import base58 +from web3 import Web3 +from typing import List, Dict, Any +from dataclasses import dataclass +from enum import IntEnum + +# --- CONFIGURATION --- + +TRON_SENDER = "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M" +TRON_RECIPIENT = "TFZMxv9HUzvsL3M7obrvikSQkuvJsopgMU" + +BATCH_ID = 40 +BATCH_SALT = 1 # Salt used by backend to build merkle root +TOKEN_DECIMALS = 6 # e.g. TRC20 USDT has 6 decimals + +# --- TYPES --- + +class TxType(IntEnum): + DELAYED = 0 + INSTANT = 1 + BATCHED = 2 + FREE_TIER = 3 + +@dataclass +class TransferData: + # EVM 0x addresses (Tron Base58 converted by stripping 0x41) + from_address: str + to_address: str + + # Original Tron Base58 for logs/UI only + original_tron_from: str + original_tron_to: str + + amount: int # uint256 + nonce: int # uint64 + timestamp: int # uint48 + recipient_count: int # uint32 + batch_id: int # NOT hashed + tx_type: int # uint8 + batch_salt: int # uint64 salt used by backend to build merkle root + +# --- HELPERS --- + +def tron_to_evm_address(tron_addr: str) -> str: + """ + Convert Tron Base58Check addr (T...) to EVM 0x address by stripping leading 0x41. + Returns checksummed 0x address. + """ + decoded = base58.b58decode_check(tron_addr) + if len(decoded) < 21 or decoded[0] != 0x41: + raise ValueError(f"Invalid Tron address: {tron_addr}") + evm_hex = decoded[1:].hex() + return Web3.to_checksum_address("0x" + evm_hex) + +def ensure_uint_bounds(nonce: int, timestamp: int, recipient_count: int) -> None: + if not (0 <= nonce <= (2**64 - 1)): + raise ValueError("nonce exceeds uint64") + if not (0 <= timestamp <= (2**48 - 1)): + raise ValueError("timestamp exceeds uint48") + if not (0 <= recipient_count <= (2**32 - 1)): + raise ValueError("recipient_count exceeds uint32") + +# --- MERKLE LOGIC --- + +class MerkleTree: + def __init__(self, leaves: List[bytes]): + self.leaves = leaves + self.tree = self._build_tree(leaves) + + def _build_tree(self, leaves: List[bytes]) -> List[List[bytes]]: + if not leaves: + return [[]] + tree = [leaves] + current_level = leaves + while len(current_level) > 1: + next_level: List[bytes] = [] + for i in range(0, len(current_level), 2): + if i + 1 < len(current_level): + left = current_level[i] + right = current_level[i + 1] + # Sorted-pair hashing for OZ-compatible proofs + if left > right: + left, right = right, left + combined = Web3.solidity_keccak(['bytes32', 'bytes32'], [left, right]) + else: + # Promote odd leaf + combined = current_level[i] + next_level.append(combined) + tree.append(next_level) + current_level = next_level + return tree + + def get_root(self) -> bytes: + return self.tree[-1][0] if self.tree and self.tree[-1] else b'\x00' * 32 + + def get_proof(self, index: int) -> List[bytes]: + proof: List[bytes] = [] + idx = index + for level in range(len(self.tree) - 1): + curr = self.tree[level] + if idx % 2 == 0: + if idx + 1 < len(curr): + proof.append(curr[idx + 1]) + else: + proof.append(curr[idx - 1]) + idx //= 2 + return proof + +# --- HASHING (matches Settlement._calculateTxHash) --- + +def calculate_tx_hash(tx: TransferData) -> bytes: + """ + keccak256(abi.encodePacked( + from(address), to(address), + amount(uint256), nonce(uint64), timestamp(uint48), recipientCount(uint32), + txType(uint8), batchSalt(uint64) + )) + batchId is EXCLUDED from the hash. + """ + ensure_uint_bounds(tx.nonce, tx.timestamp, tx.recipient_count) + return Web3.solidity_keccak( + ['address', 'address', 'uint256', 'uint64', 'uint48', 'uint32', 'uint8', 'uint64'], + [ + Web3.to_checksum_address(tx.from_address), + Web3.to_checksum_address(tx.to_address), + tx.amount, + tx.nonce, + tx.timestamp, + tx.recipient_count, + tx.tx_type, + tx.batch_salt + ] + ) + +# --- GENERATION --- + +def generate_batch() -> Dict[str, Any]: + sender_evm = tron_to_evm_address(TRON_SENDER) + recipient_evm = tron_to_evm_address(TRON_RECIPIENT) + + base_ts = int(time.time()) + one_token = 10 ** TOKEN_DECIMALS + + txs: List[TransferData] = [] + + # Two transfers to ensure non-empty proofs + txs.append(TransferData( + from_address=sender_evm, + to_address=recipient_evm, + original_tron_from=TRON_SENDER, + original_tron_to=TRON_RECIPIENT, + amount=10 * one_token, + nonce=1, + timestamp=base_ts, + recipient_count=1, + batch_id=BATCH_ID, + tx_type=TxType.DELAYED, + batch_salt=BATCH_SALT + )) + + txs.append(TransferData( + from_address=sender_evm, + to_address=recipient_evm, + original_tron_from=TRON_SENDER, + original_tron_to=TRON_RECIPIENT, + amount=20 * one_token, + nonce=2, + timestamp=base_ts + 1, + recipient_count=1, + batch_id=BATCH_ID, + tx_type=TxType.INSTANT, + batch_salt=BATCH_SALT + )) + + txs.append(TransferData( + from_address=sender_evm, + to_address=recipient_evm, + original_tron_from=TRON_SENDER, + original_tron_to=TRON_RECIPIENT, + amount=30 * one_token, + nonce=3, + timestamp=base_ts + 2, + recipient_count=3, + batch_id=BATCH_ID, + tx_type=TxType.BATCHED, + batch_salt=BATCH_SALT + )) + + leaves = [calculate_tx_hash(tx) for tx in txs] + tree = MerkleTree(leaves) + root = tree.get_root() + + output: Dict[str, Any] = { + "merkleRoot": "0x" + root.hex(), + "txCount": len(txs), + "batchId": BATCH_ID, + "batchSalt": BATCH_SALT, + "transactions": [] + } + + print("\n" + "=" * 60) + print(f"MERKLE ROOT: 0x{root.hex()}") + print("=" * 60) + + for i, tx in enumerate(txs): + leaf = leaves[i] + proof = tree.get_proof(i) + proof_hex = ["0x" + p.hex() for p in proof] + + tx_data_struct = [ + tx.from_address, # address (EVM 0x) + tx.to_address, # address (EVM 0x) + str(tx.amount), # uint256 (string for JSON safety) + tx.nonce, # uint64 + tx.timestamp, # uint48 + tx.recipient_count, # uint32 + tx.batch_id, # uint64 (NOT hashed) + tx.tx_type, # uint8 + tx.batch_salt # uint64 (used in hash) + ] + + entry = { + "index": i, + "type": TxType(tx.tx_type).name, + "txHash": "0x" + leaf.hex(), + "txDataStruct": tx_data_struct, + "evmAddresses": {"from": tx.from_address, "to": tx.to_address}, + "tronAddresses": {"from": tx.original_tron_from, "to": tx.original_tron_to}, + "proof": proof_hex + } + output["transactions"].append(entry) + + print(f"TX {i} ({TxType(tx.tx_type).name})") + print(f" From (Tron/EVM): {tx.original_tron_from} / {tx.from_address}") + print(f" To (Tron/EVM): {tx.original_tron_to} / {tx.to_address}") + print(f" Amount: {tx.amount}") + print(f" Nonce: {tx.nonce} Timestamp: {tx.timestamp} RecipientCount: {tx.recipient_count}") + print(f" BatchSalt: {tx.batch_salt}") + print(f" Hash: 0x{leaf.hex()}") + print(f" Proof: {proof_hex}") + + return output + +def save_json(output: Dict[str, Any], filename: str = "merkle_data_deploy.json") -> None: + script_dir = os.path.dirname(os.path.abspath(__file__)) + path = os.path.join(script_dir, filename) + with open(path, "w") as f: + json.dump(output, f, indent=2) + print(f"\nSaved: {path}") + +if __name__ == "__main__": + data = generate_batch() + save_json(data) diff --git a/contracts/script/merkle/batch/merkle_data.json b/contracts/script/merkle/batch/merkle_data.json new file mode 100644 index 0000000..1eaf19d --- /dev/null +++ b/contracts/script/merkle/batch/merkle_data.json @@ -0,0 +1,398 @@ +{ + "merkleRoot": "0x3a0c41421185f03cda4c7149849489222399b27838a28ef7931c459b142b0877", + "txCount": 19, + "transactions": [ + { + "index": 0, + "type": "DELAYED", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "amount": "100000000", + "nonce": 1, + "timestamp": 1766392469, + "recipientCount": 1, + "batchId": 1, + "batchSalt": 1, + "txHash": "0xa230e58e7054695cc88f543500b68c91e2bf2460ea4f50ac925640251a0e9c45", + "proof": [ + "0xaa343f658768354a32adde8928537360916413cc47445617177a82024c422cf7", + "0xd5ec552c4f23fb2dbf907bf031e9e0d2e7c4a81c756071675b4a0625ad37f04c", + "0xea1b9fa23ead5569d3206b76771c4f20e36694822032d195804bebccca4aec51", + "0xb57edcb183a69f8f06421d1c7de51760cd1973914376637f50f10c3ed0fafeb2", + "0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc" + ], + "valid": true + }, + { + "index": 1, + "type": "DELAYED", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "amount": "100000000", + "nonce": 2, + "timestamp": 1766392470, + "recipientCount": 1, + "batchId": 1, + "batchSalt": 1, + "txHash": "0xaa343f658768354a32adde8928537360916413cc47445617177a82024c422cf7", + "proof": [ + "0xa230e58e7054695cc88f543500b68c91e2bf2460ea4f50ac925640251a0e9c45", + "0xd5ec552c4f23fb2dbf907bf031e9e0d2e7c4a81c756071675b4a0625ad37f04c", + "0xea1b9fa23ead5569d3206b76771c4f20e36694822032d195804bebccca4aec51", + "0xb57edcb183a69f8f06421d1c7de51760cd1973914376637f50f10c3ed0fafeb2", + "0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc" + ], + "valid": true + }, + { + "index": 2, + "type": "DELAYED", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "amount": "100000000", + "nonce": 3, + "timestamp": 1766392471, + "recipientCount": 1, + "batchId": 1, + "batchSalt": 1, + "txHash": "0x520268ed8b0dc9d3da345cf990135351322b673e52f2f58656bce527e24ecb4f", + "proof": [ + "0x8d2fc6d794ed4c205b3516a6c58f6f87eac5b6324dc4e6f88d46ca0cd622e523", + "0x33fe1a228e60193eb5abdba6048af955b49d849dc59ab5766873907ad10ad7f6", + "0xea1b9fa23ead5569d3206b76771c4f20e36694822032d195804bebccca4aec51", + "0xb57edcb183a69f8f06421d1c7de51760cd1973914376637f50f10c3ed0fafeb2", + "0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc" + ], + "valid": true + }, + { + "index": 3, + "type": "DELAYED", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "amount": "100000000", + "nonce": 4, + "timestamp": 1766392472, + "recipientCount": 1, + "batchId": 1, + "batchSalt": 1, + "txHash": "0x8d2fc6d794ed4c205b3516a6c58f6f87eac5b6324dc4e6f88d46ca0cd622e523", + "proof": [ + "0x520268ed8b0dc9d3da345cf990135351322b673e52f2f58656bce527e24ecb4f", + "0x33fe1a228e60193eb5abdba6048af955b49d849dc59ab5766873907ad10ad7f6", + "0xea1b9fa23ead5569d3206b76771c4f20e36694822032d195804bebccca4aec51", + "0xb57edcb183a69f8f06421d1c7de51760cd1973914376637f50f10c3ed0fafeb2", + "0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc" + ], + "valid": true + }, + { + "index": 4, + "type": "DELAYED", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "amount": "100000000", + "nonce": 5, + "timestamp": 1766392473, + "recipientCount": 1, + "batchId": 1, + "batchSalt": 1, + "txHash": "0xe19c6d48248dbca0b142cf85dd63eee8608c91acfefebbdc15b0efd3708d53ff", + "proof": [ + "0x31d36b489cdd2b5dbeb0e095f19a3db47bf729f1dc646977c3347a530dfd638f", + "0xee6a9ff5e1399fa946da5482a6f5d659903e7309c8747f7d22f554d54fc097e2", + "0xcbc1b17bd75d49c23213016ca8a662f2adaee9977ad570f2930a8275b25bb398", + "0xb57edcb183a69f8f06421d1c7de51760cd1973914376637f50f10c3ed0fafeb2", + "0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc" + ], + "valid": true + }, + { + "index": 5, + "type": "DELAYED", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "amount": "100000000", + "nonce": 6, + "timestamp": 1766392474, + "recipientCount": 1, + "batchId": 1, + "batchSalt": 1, + "txHash": "0x31d36b489cdd2b5dbeb0e095f19a3db47bf729f1dc646977c3347a530dfd638f", + "proof": [ + "0xe19c6d48248dbca0b142cf85dd63eee8608c91acfefebbdc15b0efd3708d53ff", + "0xee6a9ff5e1399fa946da5482a6f5d659903e7309c8747f7d22f554d54fc097e2", + "0xcbc1b17bd75d49c23213016ca8a662f2adaee9977ad570f2930a8275b25bb398", + "0xb57edcb183a69f8f06421d1c7de51760cd1973914376637f50f10c3ed0fafeb2", + "0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc" + ], + "valid": true + }, + { + "index": 6, + "type": "DELAYED", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "amount": "100000000", + "nonce": 7, + "timestamp": 1766392475, + "recipientCount": 1, + "batchId": 1, + "batchSalt": 1, + "txHash": "0x3d5be0058769e8f9e9d472a4f48190b04ccfa6e7aa2fa412653ed22b7649c75c", + "proof": [ + "0x9f8a7e4c0b8338ac03dd39fb2d6fa8082cf1d32badb2fa29fa959f626793e191", + "0xb3b6b7cdecd8ccdf8ba346985f530fbc878292d55f0ac9cda8689b4744570c7f", + "0xcbc1b17bd75d49c23213016ca8a662f2adaee9977ad570f2930a8275b25bb398", + "0xb57edcb183a69f8f06421d1c7de51760cd1973914376637f50f10c3ed0fafeb2", + "0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc" + ], + "valid": true + }, + { + "index": 7, + "type": "DELAYED", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "amount": "100000000", + "nonce": 8, + "timestamp": 1766392476, + "recipientCount": 1, + "batchId": 1, + "batchSalt": 1, + "txHash": "0x9f8a7e4c0b8338ac03dd39fb2d6fa8082cf1d32badb2fa29fa959f626793e191", + "proof": [ + "0x3d5be0058769e8f9e9d472a4f48190b04ccfa6e7aa2fa412653ed22b7649c75c", + "0xb3b6b7cdecd8ccdf8ba346985f530fbc878292d55f0ac9cda8689b4744570c7f", + "0xcbc1b17bd75d49c23213016ca8a662f2adaee9977ad570f2930a8275b25bb398", + "0xb57edcb183a69f8f06421d1c7de51760cd1973914376637f50f10c3ed0fafeb2", + "0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc" + ], + "valid": true + }, + { + "index": 8, + "type": "DELAYED", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "amount": "100000000", + "nonce": 9, + "timestamp": 1766392477, + "recipientCount": 1, + "batchId": 1, + "batchSalt": 1, + "txHash": "0xc8570da104d31b70fa27d5a6db45ef393d9150795802006294ee05aaaf99ff4c", + "proof": [ + "0x5215e0e0db139e499cdf1c49c9eb5b1ec1de1eb4e527b8a5c9566128f5c20f05", + "0x4c263c99b9e277450d7f2ef634c883775456dd4dffb96f46ae22fffa0ba532b1", + "0x8097f9b2b4adcb7ea03968a76be829838c7e54478a37edf3c2060e1626be7fb9", + "0x31994ea0b0c611d5c8673e93d7acac8ce3c40c1a7ff9612645345abc39d59975", + "0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc" + ], + "valid": true + }, + { + "index": 9, + "type": "DELAYED", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "amount": "100000000", + "nonce": 10, + "timestamp": 1766392478, + "recipientCount": 1, + "batchId": 1, + "batchSalt": 1, + "txHash": "0x5215e0e0db139e499cdf1c49c9eb5b1ec1de1eb4e527b8a5c9566128f5c20f05", + "proof": [ + "0xc8570da104d31b70fa27d5a6db45ef393d9150795802006294ee05aaaf99ff4c", + "0x4c263c99b9e277450d7f2ef634c883775456dd4dffb96f46ae22fffa0ba532b1", + "0x8097f9b2b4adcb7ea03968a76be829838c7e54478a37edf3c2060e1626be7fb9", + "0x31994ea0b0c611d5c8673e93d7acac8ce3c40c1a7ff9612645345abc39d59975", + "0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc" + ], + "valid": true + }, + { + "index": 10, + "type": "FREE_TIER", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "amount": "50000000", + "nonce": 11, + "timestamp": 1766392479, + "recipientCount": 1, + "batchId": 1, + "batchSalt": 1, + "txHash": "0x462abd36dedb15579a43289e45666716fa82e276865699156f10f01fce09bea4", + "proof": [ + "0x24f873b47ae80c05f69983aace4819e4a005ab024673cc39313788f7a17d305b", + "0x591ff5b157a63b6c10acb4331c80fe4c013f946c177848663e17c995d065ab6c", + "0x8097f9b2b4adcb7ea03968a76be829838c7e54478a37edf3c2060e1626be7fb9", + "0x31994ea0b0c611d5c8673e93d7acac8ce3c40c1a7ff9612645345abc39d59975", + "0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc" + ], + "valid": true + }, + { + "index": 11, + "type": "INSTANT", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "amount": "200000000", + "nonce": 12, + "timestamp": 1766392480, + "recipientCount": 1, + "batchId": 1, + "batchSalt": 1, + "txHash": "0x24f873b47ae80c05f69983aace4819e4a005ab024673cc39313788f7a17d305b", + "proof": [ + "0x462abd36dedb15579a43289e45666716fa82e276865699156f10f01fce09bea4", + "0x591ff5b157a63b6c10acb4331c80fe4c013f946c177848663e17c995d065ab6c", + "0x8097f9b2b4adcb7ea03968a76be829838c7e54478a37edf3c2060e1626be7fb9", + "0x31994ea0b0c611d5c8673e93d7acac8ce3c40c1a7ff9612645345abc39d59975", + "0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc" + ], + "valid": true + }, + { + "index": 12, + "type": "INSTANT", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "amount": "300000000", + "nonce": 13, + "timestamp": 1766392481, + "recipientCount": 3, + "batchId": 1, + "batchSalt": 1, + "txHash": "0x7401529a3f64f3280807f8c1c25476cb42f45a8d53eb4a8ce0489ca439530b43", + "proof": [ + "0x560083d61bbad176074a8ff65fa7a2b20cbc6080b8176f55d47bdd63503e7b49", + "0x30265017c12a0d5f16aa7c34dea1bf8fc8554bbdc356287671971c4a4da6b460", + "0x210818ded81351b6dc7f13a560472c3185d0bae960d8f7212190674caacdcfa8", + "0x31994ea0b0c611d5c8673e93d7acac8ce3c40c1a7ff9612645345abc39d59975", + "0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc" + ], + "valid": true + }, + { + "index": 13, + "type": "BATCHED", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "amount": "500000000", + "nonce": 14, + "timestamp": 1766392482, + "recipientCount": 5, + "batchId": 1, + "batchSalt": 1, + "txHash": "0x560083d61bbad176074a8ff65fa7a2b20cbc6080b8176f55d47bdd63503e7b49", + "proof": [ + "0x7401529a3f64f3280807f8c1c25476cb42f45a8d53eb4a8ce0489ca439530b43", + "0x30265017c12a0d5f16aa7c34dea1bf8fc8554bbdc356287671971c4a4da6b460", + "0x210818ded81351b6dc7f13a560472c3185d0bae960d8f7212190674caacdcfa8", + "0x31994ea0b0c611d5c8673e93d7acac8ce3c40c1a7ff9612645345abc39d59975", + "0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc" + ], + "valid": true + }, + { + "index": 14, + "type": "BATCHED", + "from": "0x1234567890123456789012345678901234567890", + "to": "0x90F79bf6EB2c4f870365E785982E1f101E93b906", + "amount": "150000000", + "nonce": 15, + "timestamp": 1766392483, + "recipientCount": 3, + "batchId": 1, + "batchSalt": 1, + "txHash": "0x864e732fa92158038c741b630577f00889c249ae8441ddaff05c2e185153afc6", + "proof": [ + "0xac933fbe94da3ad81a1b622b7e9e83dce453dc6588b908ec4e39edc0afb91dc6", + "0x287d3ddd35e0c598f46919e410628799adaf9b4d25fcd921e17b0077e12bde90", + "0x210818ded81351b6dc7f13a560472c3185d0bae960d8f7212190674caacdcfa8", + "0x31994ea0b0c611d5c8673e93d7acac8ce3c40c1a7ff9612645345abc39d59975", + "0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc" + ], + "valid": true + }, + { + "index": 15, + "type": "BATCHED", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "amount": "100000000", + "nonce": 16, + "timestamp": 1766392484, + "recipientCount": 1, + "batchId": 1, + "batchSalt": 1, + "txHash": "0xac933fbe94da3ad81a1b622b7e9e83dce453dc6588b908ec4e39edc0afb91dc6", + "proof": [ + "0x864e732fa92158038c741b630577f00889c249ae8441ddaff05c2e185153afc6", + "0x287d3ddd35e0c598f46919e410628799adaf9b4d25fcd921e17b0077e12bde90", + "0x210818ded81351b6dc7f13a560472c3185d0bae960d8f7212190674caacdcfa8", + "0x31994ea0b0c611d5c8673e93d7acac8ce3c40c1a7ff9612645345abc39d59975", + "0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc" + ], + "valid": true + }, + { + "index": 16, + "type": "INSTANT", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "amount": "1000000000000000", + "nonce": 17, + "timestamp": 1766392485, + "recipientCount": 1, + "batchId": 1, + "batchSalt": 1, + "txHash": "0xa477e0f7047a672583f1234bbd1485e57bda886eeea3ea1c2f76e242083c7d85", + "proof": [ + "0xed3ccf36419833c97271315356d4320595eee8cc5119ee118bcdf1e2775bc52f", + "0x587b51946820bae3febac952ae82d0a10a2c1991bc887caf0c9831de2adb24bb", + "0xb6576b13bfbf97dae871a1b20d938a53329e10800ce093213abb811e236b7f6c" + ], + "valid": true + }, + { + "index": 17, + "type": "DELAYED", + "from": "0x0000000000000000000000000000000000000000", + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "amount": "100000000", + "nonce": 18, + "timestamp": 1766392486, + "recipientCount": 1, + "batchId": 1, + "batchSalt": 1, + "txHash": "0xed3ccf36419833c97271315356d4320595eee8cc5119ee118bcdf1e2775bc52f", + "proof": [ + "0xa477e0f7047a672583f1234bbd1485e57bda886eeea3ea1c2f76e242083c7d85", + "0x587b51946820bae3febac952ae82d0a10a2c1991bc887caf0c9831de2adb24bb", + "0xb6576b13bfbf97dae871a1b20d938a53329e10800ce093213abb811e236b7f6c" + ], + "valid": true + }, + { + "index": 18, + "type": "DELAYED", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "to": "0x0000000000000000000000000000000000000000", + "amount": "100000000", + "nonce": 19, + "timestamp": 1766392487, + "recipientCount": 1, + "batchId": 1, + "batchSalt": 1, + "txHash": "0x587b51946820bae3febac952ae82d0a10a2c1991bc887caf0c9831de2adb24bb", + "proof": [ + "0xc86dbe3ece3fe96d98622181ce07212d536e3217636b501a87e8a0e98b001a84", + "0xb6576b13bfbf97dae871a1b20d938a53329e10800ce093213abb811e236b7f6c" + ], + "valid": true + } + ] +} \ No newline at end of file diff --git a/contracts/script/merkle/batch/merkle_data_deploy.json b/contracts/script/merkle/batch/merkle_data_deploy.json new file mode 100644 index 0000000..dcd526d --- /dev/null +++ b/contracts/script/merkle/batch/merkle_data_deploy.json @@ -0,0 +1,91 @@ +{ + "merkleRoot": "0x25daa48bca5790b366cbf2fc525223961e425bc236b4d1372797a25f811f6d93", + "txCount": 3, + "batchId": 40, + "batchSalt": 1, + "transactions": [ + { + "index": 0, + "type": "DELAYED", + "txHash": "0x88e80272276e3500f4154a9dfc1763981c2ae6be570747a65bc00ba6ef6b37cc", + "txDataStruct": [ + "0x68b86Ce0e9E72367e20a0e144bECE5e2Bb61f403", + "0x3D4e40ecD81BADC2aEE1e62E694a6c969F29586e", + "10000000", + 1, + 1766393245, + 1, + 40, + 0, + 1 + ], + "evmAddresses": { + "from": "0x68b86Ce0e9E72367e20a0e144bECE5e2Bb61f403", + "to": "0x3D4e40ecD81BADC2aEE1e62E694a6c969F29586e" + }, + "tronAddresses": { + "from": "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", + "to": "TFZMxv9HUzvsL3M7obrvikSQkuvJsopgMU" + }, + "proof": [ + "0x7399cc043427bd28ce666fe8890e8757c5aec90ec1da28e5a4ad389f3dbc1cd0", + "0x61c3acfb278fef59a1435801cec08385d51a38be09b91872e89084aa55f22f59" + ] + }, + { + "index": 1, + "type": "INSTANT", + "txHash": "0x7399cc043427bd28ce666fe8890e8757c5aec90ec1da28e5a4ad389f3dbc1cd0", + "txDataStruct": [ + "0x68b86Ce0e9E72367e20a0e144bECE5e2Bb61f403", + "0x3D4e40ecD81BADC2aEE1e62E694a6c969F29586e", + "20000000", + 2, + 1766393246, + 1, + 40, + 1, + 1 + ], + "evmAddresses": { + "from": "0x68b86Ce0e9E72367e20a0e144bECE5e2Bb61f403", + "to": "0x3D4e40ecD81BADC2aEE1e62E694a6c969F29586e" + }, + "tronAddresses": { + "from": "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", + "to": "TFZMxv9HUzvsL3M7obrvikSQkuvJsopgMU" + }, + "proof": [ + "0x88e80272276e3500f4154a9dfc1763981c2ae6be570747a65bc00ba6ef6b37cc", + "0x61c3acfb278fef59a1435801cec08385d51a38be09b91872e89084aa55f22f59" + ] + }, + { + "index": 2, + "type": "BATCHED", + "txHash": "0x61c3acfb278fef59a1435801cec08385d51a38be09b91872e89084aa55f22f59", + "txDataStruct": [ + "0x68b86Ce0e9E72367e20a0e144bECE5e2Bb61f403", + "0x3D4e40ecD81BADC2aEE1e62E694a6c969F29586e", + "30000000", + 3, + 1766393247, + 3, + 40, + 2, + 1 + ], + "evmAddresses": { + "from": "0x68b86Ce0e9E72367e20a0e144bECE5e2Bb61f403", + "to": "0x3D4e40ecD81BADC2aEE1e62E694a6c969F29586e" + }, + "tronAddresses": { + "from": "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", + "to": "TFZMxv9HUzvsL3M7obrvikSQkuvJsopgMU" + }, + "proof": [ + "0x8a573f8b2597359ebf470b8f913c96712f4c911d6f50e7e797477facb424a820" + ] + } + ] +} \ No newline at end of file diff --git a/contracts/script/merkle/whitelist/generateRoot.py b/contracts/script/merkle/whitelist/generateRoot.py new file mode 100644 index 0000000..f772f15 --- /dev/null +++ b/contracts/script/merkle/whitelist/generateRoot.py @@ -0,0 +1,85 @@ +# filename: generate_root_evm_sorted.py +from eth_utils import keccak, is_hex_address, to_checksum_address + +def normalize(addr: str) -> str: + addr = addr.strip() + if not addr.startswith("0x"): + addr = "0x" + addr + if not is_hex_address(addr): + raise ValueError(f"Invalid address: {addr}") + return addr.lower() + +def leaf_hash(addr: str) -> bytes: + a = normalize(addr) + b20 = bytes.fromhex(a[2:]) + return keccak(b"\x00" * 12 + b20) + +def hash_pair(a: bytes, b: bytes) -> bytes: + if a < b: + return keccak(a + b) + else: + return keccak(b + a) + +def build_leaves(addrs): + addrs_sorted = sorted([normalize(a) for a in addrs]) + leaves = [leaf_hash(a) for a in addrs_sorted] + return leaves, addrs_sorted + +def build_tree(leaves): + layers = [leaves[:]] + while len(layers[-1]) > 1: + cur = layers[-1] + nxt = [] + for i in range(0, len(cur), 2): + l = cur[i] + r = cur[i+1] if i+1 < len(cur) else cur[i] + nxt.append(hash_pair(l, r)) + layers.append(nxt) + return layers + +def proof(layers, idx): + pf = [] + for layer in layers[:-1]: + sib = idx ^ 1 + if sib < len(layer): + pf.append(layer[sib]) + idx //= 2 + return pf + +def verify_sorted(leaf, pf, rt): + h = leaf + for p in pf: + if h < p: + h = keccak(h + p) + else: + h = keccak(p + h) + return h == rt + +if __name__ == "__main__": + user1 = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + user2 = "0xBD26367c4B23A6D3713A1e1a50B2D67E8748cB98" + user3 = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + + whitelist = [user1, user2, user3] + + leaves, addrs_sorted = build_leaves(whitelist) + layers = build_tree(leaves) + rt = layers[-1][0] + print(f"Merkle Root: 0x{rt.hex()}") + + for addr in addrs_sorted: + lf = leaf_hash(addr) + idx = leaves.index(lf) + pf = proof(layers, idx) + + ok = verify_sorted(lf, pf, rt) + print(f"\nAddress {to_checksum_address(addr)} is whitelisted: {ok}") + + pf_hex = ["0x" + x.hex() for x in pf] + print("Proof:", "[" + ", ".join(pf_hex) + "]") + + name = to_checksum_address(addr).replace("0x","").upper() + print("\n// Solidity") + print(f"PROOF_{name} = new bytes32[]({len(pf_hex)});") + for i, p in enumerate(pf_hex): + print(f"PROOF_{name}[{i}] = {p};") \ No newline at end of file diff --git a/contracts/script/merkle/whitelist/generateWhitelistRootDeploy.py b/contracts/script/merkle/whitelist/generateWhitelistRootDeploy.py new file mode 100644 index 0000000..4e4dbde --- /dev/null +++ b/contracts/script/merkle/whitelist/generateWhitelistRootDeploy.py @@ -0,0 +1,88 @@ +import base58 +from eth_utils import keccak + +def tron_to_evm_bytes32(tron_addr: str) -> bytes: + raw = base58.b58decode_check(tron_addr) + addr20 = raw[1:] # 20 bytes + return b'\x00' * 12 + addr20 # pad left to 32 bytes like Solidity assembly + +def merkle_tree(leaves): + tree = [leaves] + while len(tree[-1]) > 1: + layer = tree[-1] + next_layer = [] + for i in range(0, len(layer), 2): + left = layer[i] + right = layer[i+1] if i+1 < len(layer) else layer[i] + # Sort the pair to ensure consistency with Solidity assembly logic + if left > right: + left, right = right, left + combined = keccak(left + right) + next_layer.append(combined) + tree.append(next_layer) + return tree + +def merkle_root(tree): + return tree[-1][0] if tree else None + +def merkle_proof(tree, index): + proof = [] + for layer in tree[:-1]: + pair_index = index ^ 1 + if pair_index < len(layer): + sibling = layer[pair_index] + node = layer[index] + # Sort the pair to ensure consistency with Solidity assembly logic + # The proof always includes the sibling hash + proof.append(sibling) + index //= 2 + return proof + +def verify_proof(leaf, proof, root): + computed_hash = leaf + for sibling in proof: + if computed_hash > sibling: + computed_hash = keccak(sibling + computed_hash) + else: + computed_hash = keccak(computed_hash + sibling) + return computed_hash == root + +# List of whitelisted TRON addresses +whitelist = [ + "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", + "TVKAAcqpQxz3J4waayePr8dQjSQ2XHkdbF", +] + +# Compute leaves as keccak256 of 32 bytes (12 zeros + 20-byte address) +leaves = [keccak(tron_to_evm_bytes32(addr)) for addr in whitelist] + +# Build Merkle tree +tree = merkle_tree(leaves) + +# Get Merkle root +root = merkle_root(tree) + +def get_proof_for_address(tron_addr): + leaf = keccak(tron_to_evm_bytes32(tron_addr)) + try: + index = leaves.index(leaf) + except ValueError: + return None, None + proof = merkle_proof(tree, index) + return leaf, proof + +if __name__ == "__main__": + test_addresses = [ + "TKWvD71EMFTpFVGZyqqX9fC6MQgcR9H76M", # whitelisted + "TVKAAcqpQxz3J4waayePr8dQjSQ2XHkdbF", # whitelisted + ] + + print("Merkle Root:", root.hex()) + for addr in test_addresses: + leaf, proof = get_proof_for_address(addr) + if leaf is None: + print(f"Address {addr} is NOT in the whitelist.") + continue + is_valid = verify_proof(leaf, proof, root) + print(f"Address {addr} is whitelisted: {is_valid}") + print("Proof:", "[" + ", ".join(f"0x{p.hex()}" for p in proof) + "]") \ No newline at end of file diff --git a/contracts/script/tron-deploy/deployFeeModule.js b/contracts/script/tron-deploy/deployFeeModule.js new file mode 100644 index 0000000..4d98bd5 --- /dev/null +++ b/contracts/script/tron-deploy/deployFeeModule.js @@ -0,0 +1,42 @@ +const fs = require('fs'); +const path = require('path'); +const { TronWeb } = require("tronweb"); +require('dotenv').config({ quiet: true }); + +const NETWORKS = { + nile: { fullHost: 'https://nile.trongrid.io' }, + mainnet: { fullHost: 'https://api.trongrid.io' } +}; + +const FEE_LIMIT = 500_000_000; + +function loadArtifact(name) { + const p = path.join('out', `${name}.sol`, `${name}.json`); + const j = JSON.parse(fs.readFileSync(p, 'utf8')); + return { abi: j.abi, bytecode: j.bytecode?.object || j.bytecode }; +} + +async function main() { + const network = process.argv[2] || 'nile'; + const CONTRACT_NAME = 'FeeModule'; + const pk = process.env.UPDATER_PRIVATE_KEY; + if (!NETWORKS[network]) throw new Error('Network must be nile or mainnet'); + if (!pk) throw new Error('Set UPDATER_PRIVATE_KEY in .env'); + + const tronWeb = new TronWeb({ fullHost: NETWORKS[network].fullHost, privateKey: pk }); + + const { abi, bytecode } = loadArtifact(CONTRACT_NAME); + const deployed = await tronWeb.contract().new({ + abi, + bytecode, + feeLimit: FEE_LIMIT, + callValue: 0 + }); + + console.log(`${CONTRACT_NAME} deployed: ${tronWeb.address.fromHex(deployed.address)}`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); \ No newline at end of file diff --git a/contracts/script/tron-deploy/deploySettlement.js b/contracts/script/tron-deploy/deploySettlement.js new file mode 100644 index 0000000..49cdb31 --- /dev/null +++ b/contracts/script/tron-deploy/deploySettlement.js @@ -0,0 +1,42 @@ +const fs = require('fs'); +const path = require('path'); +const { TronWeb } = require("tronweb"); +require('dotenv').config({ quiet: true }); + +const NETWORKS = { + nile: { fullHost: 'https://nile.trongrid.io' }, + mainnet: { fullHost: 'https://api.trongrid.io' } +}; + +const FEE_LIMIT = 500_000_000; + +function loadArtifact(name) { + const p = path.join('out', `${name}.sol`, `${name}.json`); + const j = JSON.parse(fs.readFileSync(p, 'utf8')); + return { abi: j.abi, bytecode: j.bytecode?.object || j.bytecode }; +} + +async function main() { + const network = process.argv[2] || 'nile'; + const CONTRACT_NAME = 'Settlement'; + const pk = process.env.UPDATER_PRIVATE_KEY; + if (!NETWORKS[network]) throw new Error('Network must be nile or mainnet'); + if (!pk) throw new Error('Set UPDATER_PRIVATE_KEY in .env'); + + const tronWeb = new TronWeb({ fullHost: NETWORKS[network].fullHost, privateKey: pk }); + + const { abi, bytecode } = loadArtifact(CONTRACT_NAME); + const deployed = await tronWeb.contract().new({ + abi, + bytecode, + feeLimit: FEE_LIMIT, + callValue: 0 + }); + + console.log(`${CONTRACT_NAME} deployed: ${tronWeb.address.fromHex(deployed.address)}`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/contracts/script/tron-deploy/deployWhitelistRegistry.js b/contracts/script/tron-deploy/deployWhitelistRegistry.js new file mode 100644 index 0000000..db731e2 --- /dev/null +++ b/contracts/script/tron-deploy/deployWhitelistRegistry.js @@ -0,0 +1,46 @@ +const fs = require('fs'); +const path = require('path'); +const { TronWeb } = require("tronweb"); +require('dotenv').config({ quiet: true }); + +const NETWORKS = { + nile: { fullHost: 'https://nile.trongrid.io' }, + mainnet: { fullHost: 'https://api.trongrid.io' } +}; + +const FEE_LIMIT = 500_000_000; + +function loadArtifact(name) { + const p = path.join('out', `${name}.sol`, `${name}.json`); + const j = JSON.parse(fs.readFileSync(p, 'utf8')); + return { abi: j.abi, bytecode: j.bytecode?.object || j.bytecode }; +} + +async function main() { + const network = process.argv[2] || 'nile'; + const CONTRACT_NAME = 'WhitelistRegistry'; + const pk = process.env.UPDATER_PRIVATE_KEY; + const updater = process.env.UPDATER_ADDRESS; + + if (!NETWORKS[network]) throw new Error('Network must be nile or mainnet'); + if (!pk) throw new Error('Set UPDATER_PRIVATE_KEY in .env'); + if (!updater) throw new Error('Set UPDATER_ADDRESS in .env'); + + const tronWeb = new TronWeb({ fullHost: NETWORKS[network].fullHost, privateKey: pk }); + + const { abi, bytecode } = loadArtifact(CONTRACT_NAME); + const deployed = await tronWeb.contract().new({ + abi, + bytecode, + feeLimit: FEE_LIMIT, + callValue: 0, + parameters: [updater] + }); + + console.log(`${CONTRACT_NAME} deployed: ${tronWeb.address.fromHex(deployed.address)}`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); \ No newline at end of file diff --git a/contracts/src/FeeModule.sol b/contracts/src/FeeModule.sol new file mode 100644 index 0000000..0a41663 --- /dev/null +++ b/contracts/src/FeeModule.sol @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +import {IFeeModule} from "./interfaces/IFeeModule.sol"; +import {Types} from "./libraries/Types.sol"; +import {Errors} from "./libraries/Errors.sol"; + +/** + * @title FeeModule + * @notice Statistical fee calculation module + * @dev ⚠️ IMPORTANT: This module does NOT collect actual fees + * All fee calculations are for UI/analytics display purposes only + * No TRX/tokens are transferred or deducted during fee application + */ +contract FeeModule is IFeeModule, Ownable { + /// @dev Mapping of batch ID to transfer hash to fee amount paid + mapping(bytes32 transferHash => uint256 fee) private s_transferFees; + + /// @dev Mapping of batch ID to total fees collected for that batch + mapping(uint64 batchId => uint256 fee) private s_batchTotalFees; + + /// @dev Mapping of user address to their free transaction usage information + mapping(address => Types.FreeTxInfo) private s_freeTxUsage; + + /* -------------------------------------------------------------------------- */ + /* STATE VARIABLES */ + /* -------------------------------------------------------------------------- */ + + /// @dev Total fees collected across all transactions + uint256 private s_totalFees; + + /// @dev Address of the Settlement contract authorized to apply fees + address private s_settlement; + + /// @dev Base fee for delayed transactions (0.1 TRX) + uint256 private constant BASE_FEE = 100_000; + + /// @dev Fee per recipient for batched transactions (0.05 TRX) + uint256 private constant BATCH_FEE = 50_000; + + /// @dev Fee for instant transactions (0.2 TRX) + uint256 private constant INSTANT_FEE = 200_000; + + /// @dev Number of free transactions per day for eligible users + uint256 private constant FREE_TX_AMOUNT = 10; + + /// @dev Volume threshold for large transactions (no fee applied) + uint256 private constant LARGE_VOLUME = 1_000_000_000; + + /* -------------------------------------------------------------------------- */ + /* CONSTRUCTOR */ + /* -------------------------------------------------------------------------- */ + + /** + * @notice Contract constructor + * @dev Sets the deployer as the owner + */ + constructor() Ownable(msg.sender) {} + + /* -------------------------------------------------------------------------- */ + /* FUNCTIONS */ + /* -------------------------------------------------------------------------- */ + + /** + * @notice Calculates the fee for a transaction based on type and parameters + * @dev Resets quota daily based on block.timestamp + * Users near day boundaries may access up to 20 transactions within a short window + * This is an accepted edge case to avoid complex timestamp tracking + * @param sender Address initiating the transaction + * @param txType Type of transaction (FREE_TIER, DELAYED, INSTANT, or BATCHED) + * @param volume Transaction volume/amount + * @param recipientCount Number of recipients (must be >1 for BATCHED, =1 for others) + * @return info FeeInfo struct containing fee amount, transaction type, and remaining free quota + */ + function calculateFee(address sender, Types.TxType txType, uint256 volume, uint256 recipientCount) + external + view + returns (Types.FeeInfo memory info) + { + _validateCalculateFeeInput(sender, volume, recipientCount); + _validateTxType(txType); + _validateRecipientCount(txType, recipientCount); + + if (volume >= LARGE_VOLUME) { + info.fee = 0; + info.txType = txType; + info.freeQuota = _getRemainingFreeTxQuota(sender); + return info; + } + + if (txType == Types.TxType.DELAYED || txType == Types.TxType.FREE_TIER) { + return _calculateDelayedOrFreeFee(sender, txType); + } else if (txType == Types.TxType.INSTANT) { + return _calculateInstantFee(sender); + } else if (txType == Types.TxType.BATCHED) { + return _calculateBatchedFee(sender, recipientCount); + } + + return info; + } + + /** + * @notice Applies the calculated fee to a transaction + * @dev Can only be called by the authorized Settlement contract + * @param sender Address initiating the transaction + * @param fee Fee amount to apply + * @param transferHash Unique hash of the transfer + * @param batchId ID of the batch containing this transaction + * @param txType Type of transaction being processed + */ + function applyFee(address sender, uint256 fee, bytes32 transferHash, uint64 batchId, Types.TxType txType) external { + if (sender == address(0) || transferHash == bytes32(0) || batchId == 0) { + revert Errors.FeeModule__InvalidInput(); + } + + if (msg.sender != s_settlement) { + revert Errors.FeeModule__NotAuthorized(); + } + + if (txType == Types.TxType.FREE_TIER) { + _consumeFreeTxQuota(sender); + } + + s_transferFees[transferHash] = fee; + unchecked { + s_batchTotalFees[batchId] += fee; + s_totalFees += fee; + } + + emit FeeApplied(sender, fee, transferHash, batchId); + } + + /* SETTERS */ + + /** + * @notice Sets the Settlement contract address + * @dev Can only be called by owner. Only this address can call applyFee + * @param settlement Address of the Settlement contract + */ + function setSettlement(address settlement) external onlyOwner { + if (settlement == address(0)) { + revert Errors.FeeModule__InvalidInput(); + } + + if (s_settlement == settlement) { + revert Errors.FeeModule__AlreadySettlement(); + } + + s_settlement = settlement; + + emit SettlementUpdated(settlement); + } + + /* INTERNAL */ + + /** + * @notice Validates basic input parameters for fee calculation + * @param sender Address initiating the transaction + * @param volume Transaction volume/amount + * @param recipientCount Number of recipients + */ + function _validateCalculateFeeInput(address sender, uint256 volume, uint256 recipientCount) internal pure { + if (sender == address(0) || volume == 0 || recipientCount == 0) { + revert Errors.FeeModule__InvalidInput(); + } + } + + /** + * @notice Validates transaction type is one of the allowed types + * @param txType Type of transaction to validate + */ + function _validateTxType(Types.TxType txType) internal pure { + if ( + txType != Types.TxType.FREE_TIER && txType != Types.TxType.DELAYED && txType != Types.TxType.INSTANT + && txType != Types.TxType.BATCHED + ) { + revert Errors.FeeModule__InvalidTxType(); + } + } + + /** + * @notice Validates recipient count matches transaction type requirements + * @param txType Type of transaction + * @param recipientCount Number of recipients + */ + function _validateRecipientCount(Types.TxType txType, uint256 recipientCount) internal pure { + if (txType == Types.TxType.BATCHED && recipientCount <= 1) { + revert Errors.FeeModule__InvalidRecipientCount(); + } + + if (txType != Types.TxType.BATCHED && recipientCount > 1) { + revert Errors.FeeModule__InvalidRecipientCount(); + } + } + + /** + * @notice Calculates fee for delayed or free tier transactions + * @param sender Address initiating the transaction + * @param txType Type of transaction (FREE_TIER or DELAYED) + * @return info FeeInfo struct with fee details + */ + function _calculateDelayedOrFreeFee(address sender, Types.TxType txType) + internal + view + returns (Types.FeeInfo memory info) + { + uint256 remainingQuota = _getRemainingFreeTxQuota(sender); + + if (remainingQuota != 0) { + info.fee = 0; + info.txType = Types.TxType.FREE_TIER; + info.freeQuota = remainingQuota; + } else { + if (txType == Types.TxType.FREE_TIER) { + revert Errors.FeeModule__FreeTierLimitExceeded(); + } + info.fee = BASE_FEE; + info.txType = Types.TxType.DELAYED; + info.freeQuota = 0; + } + } + + /** + * @notice Calculates fee for instant transactions + * @param sender Address initiating the transaction + * @return info FeeInfo struct with fee details + */ + function _calculateInstantFee(address sender) internal view returns (Types.FeeInfo memory info) { + info.fee = INSTANT_FEE; + info.txType = Types.TxType.INSTANT; + info.freeQuota = _getRemainingFreeTxQuota(sender); + } + + /** + * @notice Calculates fee for batched transactions + * @param sender Address initiating the transaction + * @param recipientCount Number of recipients in the batch + * @return info FeeInfo struct with fee details + */ + function _calculateBatchedFee(address sender, uint256 recipientCount) + internal + view + returns (Types.FeeInfo memory info) + { + info.fee = BATCH_FEE * recipientCount; + info.txType = Types.TxType.BATCHED; + info.freeQuota = _getRemainingFreeTxQuota(sender); + } + + /** + * @notice Internal function to get remaining free transaction quota for a user + * @dev Resets quota daily based on block.timestamp + * Users near day boundaries may access up to 20 transactions within a short window + * This is an accepted edge case to avoid complex timestamp tracking + * @param sender Address to check quota for + * @return Remaining number of free transactions available + */ + function _getRemainingFreeTxQuota(address sender) internal view returns (uint256) { + Types.FreeTxInfo storage usage = s_freeTxUsage[sender]; + uint256 currentDay; + unchecked { + currentDay = block.timestamp / 1 days; + } + + if (usage.day < currentDay) { + return FREE_TX_AMOUNT; + } + + if (usage.count < FREE_TX_AMOUNT) { + unchecked { + return FREE_TX_AMOUNT - usage.count; + } + } + + return 0; + } + + /** + * @notice Internal function to consume one free transaction from user's quota + * @dev Updates daily counter and emits event. Reverts if quota exceeded + * @param sender Address consuming the free transaction + */ + function _consumeFreeTxQuota(address sender) internal { + Types.FreeTxInfo storage usage = s_freeTxUsage[sender]; + uint256 currentDay; + unchecked { + currentDay = block.timestamp / 1 days; + } + + if (usage.day < currentDay) { + // Safe: days since epoch fits in uint128 for trillions of years + if (currentDay > type(uint128).max) { + revert Errors.FeeModule__InvalidInput(); + } + usage.day = uint128(currentDay); + usage.count = 0; + } + + if (usage.count < FREE_TX_AMOUNT) { + unchecked { + usage.count += 1; + uint256 remaining = FREE_TX_AMOUNT - usage.count; + emit FreeTierUsed(sender, remaining); + } + } else { + revert Errors.FeeModule__FreeTierLimitExceeded(); + } + } + + /* -------------------------------------------------------------------------- */ + /* GETTERS */ + /* -------------------------------------------------------------------------- */ + + /** + * @notice Returns the Settlement contract address + * @return Address of the Settlement contract + */ + function getSettlement() external view returns (address) { + return s_settlement; + } + + /** + * @notice Returns the contract owner + * @return Address of the owner + */ + function getOwner() external view returns (address) { + return owner(); + } + + /** + * @notice Returns the free transaction usage info for a user + * @param user Address to query + * @return FreeTxInfo struct containing day and count + */ + function getFreeTxUsage(address user) external view returns (Types.FreeTxInfo memory) { + return s_freeTxUsage[user]; + } + + /** + * @notice Returns the fee paid for a specific transaction + * @param transferHash Hash of the transfer + * @return fee Fee amount in wei + */ + function getFeeOfTransaction(bytes32 transferHash) external view returns (uint256 fee) { + return s_transferFees[transferHash]; + } + + /** + * @notice Returns CALCULATED fees for statistical purposes only + * @dev WARNING: Fees are NOT actually collected or transferred + */ + function getTotalFeesCollected() external view returns (uint256 total) { + return s_totalFees; + } + + /** + * @notice Returns total fees collected for a specific batch + * @param batchId ID of the batch + * @return total Total fees for the batch in wei + */ + function getBatchTotalFees(uint64 batchId) external view returns (uint256 total) { + return s_batchTotalFees[batchId]; + } + + /** + * @notice Returns remaining free tier transactions for a user today + * @param sender Address to query + * @return remaining Number of free transactions remaining + */ + function getRemainingFreeTierTransactions(address sender) external view returns (uint256 remaining) { + Types.FreeTxInfo storage usage = s_freeTxUsage[sender]; + uint256 currentDay = block.timestamp / 1 days; + + uint256 count = usage.day < currentDay ? 0 : usage.count; + return FREE_TX_AMOUNT > count ? FREE_TX_AMOUNT - count : 0; + } +} diff --git a/contracts/src/Settlement.sol b/contracts/src/Settlement.sol new file mode 100644 index 0000000..f312f93 --- /dev/null +++ b/contracts/src/Settlement.sol @@ -0,0 +1,556 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +import {ISettlement} from "./interfaces/ISettlement.sol"; +import {IFeeModule} from "./interfaces/IFeeModule.sol"; +import {IWhitelistRegistry} from "./interfaces/IWhitelistRegistry.sol"; +import {Types} from "./libraries/Types.sol"; +import {Errors} from "./libraries/Errors.sol"; + +/** + * @title Settlement + * @notice Contract for batch processing and execution of token transfers using Merkle Trees + * @dev Uses Merkle proofs for transaction verification, timelock for security, and whitelist for access control + */ +contract Settlement is ISettlement, Ownable, ReentrancyGuard, Pausable { + using SafeERC20 for IERC20; + + /* -------------------------------------------------------------------------- */ + /* TYPES */ + /* -------------------------------------------------------------------------- */ + + /// @dev Mapping of batch ID to batch data + mapping(uint64 batchId => Types.Batch) private s_batches; + + /// @dev Mapping of Merkle root to batch ID for quick lookup + mapping(bytes32 => uint64) private s_batchIdsByRoot; + + /// @dev Mapping of transaction hashes to execution status (prevents replay attacks) + mapping(bytes32 txHash => bool executed) private s_executedTransfers; + + /// @dev Mapping of aggregator addresses to approval status + mapping(address => bool) private s_approvedAggregators; + + /* -------------------------------------------------------------------------- */ + /* STATE VARIABLES */ + /* -------------------------------------------------------------------------- */ + + /// @dev Module for calculating and applying fees + IFeeModule private s_feeModule; + + /// @dev Whitelist registry for user verification + IWhitelistRegistry private s_registry; + + /// @dev ERC20 token used for transfers + IERC20 private s_token; + + /// @dev Batch ID counter + uint64 private s_batchIds; + + /// @dev Maximum number of transactions per batch + uint32 private s_maxTxPerBatch; + + /// @dev Timelock duration (delay before batch can be executed) + uint48 private s_timelockDuration; + + /// @dev Configuration status + bool private s_configured; + + /* -------------------------------------------------------------------------- */ + /* CONSTRUCTOR */ + /* -------------------------------------------------------------------------- */ + + /** + * @notice Contract constructor + * @dev Sets the deployer as owner and approved aggregator + */ + constructor() Ownable(msg.sender) { + s_approvedAggregators[msg.sender] = true; + } + + /* -------------------------------------------------------------------------- */ + /* FUNCTIONS */ + /* -------------------------------------------------------------------------- */ + + /** + * @notice Submits a new batch of transactions + * @dev Can only be called by approved aggregators. Creates a new batch with timelock + * @param merkleRoot The Merkle root of the transaction batch + * @param txCount The number of transactions in the batch + * @return success Boolean indicating success + * @return batchId The ID of the created batch + */ + function submitBatch(bytes32 merkleRoot, uint32 txCount, uint64 batchSalt) external returns (bool, uint64) { + _requireConfigured(); + _onlyApprovedAggregator(); + + if (merkleRoot == bytes32(0) || txCount == 0 || txCount > s_maxTxPerBatch) { + revert Errors.Settlement__InvalidInput(); + } + + if (s_batchIdsByRoot[merkleRoot] != 0) { + revert Errors.Settlement__BatchAlreadySubmitted(); + } + + if (block.timestamp > type(uint48).max) { + revert Errors.Settlement__InvalidInput(); + } + + uint256 calculatedUnlockTime = block.timestamp + s_timelockDuration; + if (calculatedUnlockTime > type(uint48).max) { + revert Errors.Settlement__InvalidInput(); + } + + ++s_batchIds; + uint64 batchId = s_batchIds; + + s_batches[batchId] = Types.Batch({ + merkleRoot: merkleRoot, + // Safe: block.timestamp fits in uint48 until year ~8.9M AD + timestamp: uint48(block.timestamp), + txCount: txCount, + // Safe: overflow checked above (line 106) + unlockTime: uint48(calculatedUnlockTime), + batchSalt: batchSalt + }); + + s_batchIdsByRoot[merkleRoot] = batchId; + + emit BatchSubmitted(batchId, merkleRoot, txCount, uint48(block.timestamp)); + return (true, batchId); + } + + /** + * @notice Executes a transfer from a submitted batch + * @dev Verifies Merkle proof, whitelist status, and timelock before executing transfer + * @param txProof Merkle proof for the transaction + * @param whitelistProof Merkle proof for whitelist verification + * @param txData Transfer data including from, to, amount, and other parameters + * @return success Boolean indicating successful execution + */ + function executeTransfer( + bytes32[] memory txProof, + bytes32[] memory whitelistProof, + Types.TransferData memory txData + ) external nonReentrant whenNotPaused returns (bool) { + IFeeModule feeModule = s_feeModule; + IERC20 token = s_token; + + _validateTransferInput(txData); + _validateBatched(whitelistProof, txData); + + bytes32 txHash = _validateBatchAndProof(txProof, txData); + + Types.FeeInfo memory fee = + feeModule.calculateFee(txData.from, txData.txType, txData.amount, txData.recipientCount); + + if (token.balanceOf(txData.from) < txData.amount) { + revert Errors.Settlement__InsufficientBalance(); + } + + if (token.allowance(txData.from, address(this)) < txData.amount) { + revert Errors.Settlement__InsufficientAllowance(); + } + + feeModule.applyFee(txData.from, fee.fee, txHash, txData.batchId, fee.txType); + + token.safeTransferFrom(txData.from, txData.to, txData.amount); + s_executedTransfers[txHash] = true; + + emit TransferExecuted(txData.from, txData.to, txData.amount, txData.nonce); + return true; + } + + /** + * @notice Approves a new aggregator + * @dev Can only be called by owner + * @param aggregator Address of the aggregator to approve + */ + function approveAggregator(address aggregator) external onlyOwner { + if (aggregator == address(0)) { + revert Errors.Settlement__InvalidInput(); + } + + if (s_approvedAggregators[aggregator]) { + revert Errors.Settlement__AlreadyAggregator(); + } + + s_approvedAggregators[aggregator] = true; + emit AggregatorApproved(aggregator); + } + + /** + * @notice Removes approval from an aggregator + * @dev Can only be called by owner + * @param aggregator Address of the aggregator to disapprove + */ + function disapproveAggregator(address aggregator) external onlyOwner { + if (aggregator == address(0)) { + revert Errors.Settlement__InvalidInput(); + } + + if (!s_approvedAggregators[aggregator]) { + revert Errors.Settlement__AggregatorNotApproved(); + } + + s_approvedAggregators[aggregator] = false; + emit AggregatorDisapproved(aggregator); + } + + /** + * @notice Pauses the contract + * @dev Can only be called by owner. Prevents executeTransfer calls + */ + function pause() external onlyOwner { + _pause(); + } + + /** + * @notice Unpauses the contract + * @dev Can only be called by owner + */ + function unpause() external onlyOwner { + _unpause(); + } + + /* SETTERS */ + + /** + * @notice Sets the whitelist registry address + * @dev Can only be called by owner + * @param whitelistRegistry Address of the whitelist registry contract + */ + function setWhitelistRegistry(address whitelistRegistry) external onlyOwner { + if (whitelistRegistry == address(0)) { + revert Errors.Settlement__InvalidInput(); + } + + if (s_registry == IWhitelistRegistry(whitelistRegistry)) { + revert Errors.Settlement__AlreadyRegistry(); + } + + s_registry = IWhitelistRegistry(whitelistRegistry); + _recomputeConfigured(); + emit WhitelistRegistryUpdated(whitelistRegistry); + } + + /** + * @notice Sets the fee module address + * @dev Can only be called by owner + * @param feeModule Address of the fee module contract + */ + function setFeeModule(address feeModule) external onlyOwner { + if (feeModule == address(0)) { + revert Errors.Settlement__InvalidInput(); + } + + if (s_feeModule == IFeeModule(feeModule)) { + revert Errors.Settlement__AlreadyFeeModule(); + } + + s_feeModule = IFeeModule(feeModule); + _recomputeConfigured(); + emit FeeModuleUpdated(feeModule); + } + + /** + * @notice Sets the maximum number of transactions per batch + * @dev Can only be called by owner + * @param maxTx Maximum transaction count + */ + function setMaxTxPerBatch(uint32 maxTx) external onlyOwner { + if (maxTx == 0) { + revert Errors.Settlement__InvalidInput(); + } + + if (s_maxTxPerBatch == maxTx) { + revert Errors.Settlement__AlreadySet(); + } + + s_maxTxPerBatch = maxTx; + emit MaxTxPerBatchUpdated(maxTx); + } + + /** + * @notice Sets the timelock duration for batches + * @dev Can only be called by owner. Duration in seconds + * @param duration Timelock duration in seconds + */ + function setTimelockDuration(uint48 duration) external onlyOwner { + if (duration == s_timelockDuration) { + revert Errors.Settlement__AlreadyTimelockDuration(); + } + + s_timelockDuration = uint48(duration); + emit TimelockDurationUpdated(duration); + } + + /** + * @notice Sets the token address for transfers + * @dev Can only be called by owner + * @param tokenAddress Address of the ERC20 token contract + */ + function setToken(address tokenAddress) external onlyOwner { + if (tokenAddress == address(0)) { + revert Errors.Settlement__InvalidInput(); + } + + if (s_token == IERC20(tokenAddress)) { + revert Errors.Settlement__AlreadyToken(); + } + + s_token = IERC20(tokenAddress); + _recomputeConfigured(); + emit TokenUpdated(tokenAddress); + } + + /* INTERNAL */ + + /** + * @notice Internal function to check if caller is an approved aggregator + * @dev Reverts if caller is not approved + */ + function _onlyApprovedAggregator() internal view { + if (!s_approvedAggregators[msg.sender]) { + revert Errors.Settlement__AggregatorNotApproved(); + } + } + + /** + * @notice Validates if the transaction is batched and checks whitelist status + * @param whitelistProof Merkle proof for whitelist verification + * @param txData Transfer data structure + */ + function _validateBatched(bytes32[] memory whitelistProof, Types.TransferData memory txData) internal view { + if (txData.txType == Types.TxType.BATCHED) { + if (whitelistProof.length == 0) { + revert Errors.Settlement__NotWhitelisted(); + } + + if (!s_registry.verifyWhitelist(whitelistProof, txData.from)) { + revert Errors.Settlement__NotWhitelisted(); + } + } + } + + /** + * @notice Calculates the hash of a transfer + * @dev Uses keccak256 to hash all transfer parameters including batchSalt + * @param txData Transfer data structure + * @param batchSalt Salt used by backend to build merkle root + * @return txHash The calculated transaction hash + */ + function _calculateTxHash(Types.TransferData memory txData, uint64 batchSalt) + internal + pure + returns (bytes32 txHash) + { + txHash = keccak256( + abi.encodePacked( + txData.from, + txData.to, + txData.amount, + txData.nonce, + txData.timestamp, + txData.recipientCount, + txData.txType, + batchSalt + ) + ); + } + + /** + * @notice Recomputes the configuration status of the contract + * @dev Sets s_configured to true if all required modules are set + */ + function _recomputeConfigured() internal { + s_configured = + (address(s_registry) != address(0) && address(s_feeModule) != address(0) && address(s_token) != address(0)); + } + + /** + * @notice Ensures the contract is fully configured + * @dev Reverts if not configured + */ + function _requireConfigured() internal view { + if (!s_configured) { + revert Errors.Settlement__NotConfigured(); + } + } + + /** + * @notice Validates basic transfer inputs + * @param txData Transfer data structure + */ + function _validateTransferInput(Types.TransferData memory txData) internal view { + _requireConfigured(); + + if (txData.from == address(0) || txData.to == address(0) || txData.batchId == 0) { + revert Errors.Settlement__InvalidInput(); + } + } + + /** + * @notice Validates batch and Merkle proof + * @param txProof Merkle proof for the transaction + * @param txData Transfer data structure + * @return txHash The calculated transaction hash + */ + function _validateBatchAndProof(bytes32[] memory txProof, Types.TransferData memory txData) + internal + view + returns (bytes32 txHash) + { + Types.Batch storage batch = s_batches[txData.batchId]; + bytes32 merkleRoot = batch.merkleRoot; + uint256 unlockTime = batch.unlockTime; + uint64 batchSalt = batch.batchSalt; + + if (txProof.length == 0 || txData.amount == 0) { + revert Errors.Settlement__InvalidInput(); + } + + if (merkleRoot == bytes32(0)) { + revert Errors.Settlement__InvalidBatch(); + } + + if (block.timestamp < unlockTime) { + revert Errors.Settlement__BatchLocked(); + } + + txHash = _calculateTxHash(txData, batchSalt); + if (!MerkleProof.verify(txProof, merkleRoot, txHash)) { + revert Errors.Settlement__InvalidMerkleProof(); + } + + if (s_executedTransfers[txHash]) { + revert Errors.Settlement__TransferAlreadyExecuted(); + } + } + + /* -------------------------------------------------------------------------- */ + /* GETTERS */ + /* -------------------------------------------------------------------------- */ + + /** + * @notice Returns the contract owner + * @return Owner address + */ + function getOwner() external view returns (address) { + return owner(); + } + + /** + * @notice Returns the whitelist registry address + * @return Whitelist registry address + */ + function getWhitelistRegistry() external view returns (address) { + return address(s_registry); + } + + /** + * @notice Returns the fee module address + * @return Fee module address + */ + function getFeeModule() external view returns (address) { + return address(s_feeModule); + } + + /** + * @notice Returns the token address + * @return Token address + */ + function getToken() external view returns (address) { + return address(s_token); + } + + /** + * @notice Returns the current batch ID counter + * @return Current batch ID + */ + function getCurrentBatchId() external view returns (uint64) { + return s_batchIds; + } + + /** + * @notice Returns the maximum transactions per batch + * @return Maximum transaction count per batch + */ + function getMaxTxPerBatch() external view returns (uint32) { + return s_maxTxPerBatch; + } + + /** + * @notice Returns the timelock duration + * @return Timelock duration in seconds + */ + function getTimelockDuration() external view returns (uint48) { + return s_timelockDuration; + } + + /** + * @notice Checks if an address is an approved aggregator + * @param aggregator Address to check + * @return True if approved, false otherwise + */ + function isApprovedAggregator(address aggregator) external view returns (bool) { + return s_approvedAggregators[aggregator]; + } + + /** + * @notice Returns batch ID by Merkle root hash + * @param rootHash Merkle root hash + * @return Batch ID + */ + function getBatchIdByRoot(bytes32 rootHash) external view returns (uint64) { + if (rootHash == bytes32(0)) { + revert Errors.Settlement__InvalidInput(); + } + return s_batchIdsByRoot[rootHash]; + } + + /** + * @notice Returns batch data by ID + * @param batchId Batch ID + * @return Batch data structure + */ + function getBatchById(uint64 batchId) external view returns (Types.Batch memory) { + return s_batches[batchId]; + } + + /** + * @notice Checks if a transfer has been executed in a specific batch + * @param transferHash Transfer hash + * @return True if executed, false otherwise + */ + function isExecutedTransfer(bytes32 transferHash) external view returns (bool) { + return s_executedTransfers[transferHash]; + } + + /** + * @notice Returns the Merkle root for a given batch ID + * @param batchId Batch ID + * @return Merkle root of the batch + */ + function getRootByBatchId(uint64 batchId) external view returns (bytes32) { + if (batchId == 0 || batchId > s_batchIds) { + revert Errors.Settlement__InvalidInput(); + } + return s_batches[batchId].merkleRoot; + } + + /** + * @notice Checks if the contract is fully configured + * @return True if configured, false otherwise + */ + function isConfigured() external view returns (bool) { + return s_configured; + } +} diff --git a/contracts/src/WhitelistRegistry.sol b/contracts/src/WhitelistRegistry.sol new file mode 100644 index 0000000..efe6d85 --- /dev/null +++ b/contracts/src/WhitelistRegistry.sol @@ -0,0 +1,349 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; +import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +import {IWhitelistRegistry} from "./interfaces/IWhitelistRegistry.sol"; +import {Errors} from "./libraries/Errors.sol"; + +/** + * @title WhitelistRegistry + * @notice Contract for managing whitelisted addresses for batched transfers using Merkle tree verification + * @dev Uses ECDSA signatures for authorized updates, role-based access control, and collects fees for whitelist requests + */ +contract WhitelistRegistry is AccessControl, IWhitelistRegistry, Pausable { + using ECDSA for bytes32; + using MessageHashUtils for bytes32; + using MerkleProof for bytes32[]; + + /* -------------------------------------------------------------------------- */ + /* TYPES */ + /* -------------------------------------------------------------------------- */ + + mapping(address => bool) private s_authorizedUpdaters; + mapping(address => uint48) private s_lastRequestedTime; + + /* -------------------------------------------------------------------------- */ + /* STATE VARIABLES */ + /* -------------------------------------------------------------------------- */ + + bytes32 private s_merkleRoot; + // Packed into single slot (saves 2 storage slots): + uint128 private s_totalCollectedFees; // Max: 3.4×10^32 TRX - safe for trillions of years + uint64 private s_nonce; // Max: 18 quintillion updates - more than sufficient + uint48 private s_lastUpdate; // Timestamp - safe until year ~8.9M AD + uint256 private constant REQUEST_COOLDOWN = 24 hours; + uint256 private constant REQUEST_FEE = 10e6; // 10 TRX + + bytes32 private constant WITHDRAW_ROLE = keccak256("WITHDRAW_ROLE"); + + /* -------------------------------------------------------------------------- */ + /* CONSTRUCTOR */ + /* -------------------------------------------------------------------------- */ + + /** + * @notice Contract constructor + * @dev Sets up initial admin and updater roles for the specified address + * @param updater Address to grant admin, withdraw, and updater roles + */ + constructor(address updater) { + if (updater == address(0)) { + revert Errors.WhitelistRegistry__InvalidInput(); + } + _setRoleAdmin(WITHDRAW_ROLE, DEFAULT_ADMIN_ROLE); + _grantRole(DEFAULT_ADMIN_ROLE, updater); + _grantRole(WITHDRAW_ROLE, updater); + s_authorizedUpdaters[updater] = true; + } + + /* -------------------------------------------------------------------------- */ + /* FUNCTIONS */ + /* -------------------------------------------------------------------------- */ + + /** + * @notice Updates the Merkle root for the whitelist + * @dev Requires valid signature from authorized updater. Increments nonce after successful update + * @param newRoot New Merkle root hash + * @param nonce The next nonce value (must match contract state after update) + * @param signature ECDSA signature from authorized updater + */ + function updateMerkleRoot(bytes32 newRoot, uint64 nonce, bytes calldata signature) external whenNotPaused { + bytes32 oldRoot = s_merkleRoot; + + if (newRoot == s_merkleRoot) { + revert Errors.WhitelistRegistry__DuplicateUpdate(); + } + + _onlyAuthorizedUpdater(newRoot, nonce, signature); + s_merkleRoot = newRoot; + // Safe: block.timestamp fits in uint48 until year ~8.9M AD + s_lastUpdate = uint48(block.timestamp); + + emit WhitelistUpdated(oldRoot, newRoot, nonce); + } + + /** + * @notice Requests whitelist inclusion by paying a fee + * @dev Enforces 24-hour cooldown between requests and minimum fee of 10 TRX + * @return success True if request was successfully recorded + */ + function requestWhitelist() external payable whenNotPaused returns (bool success) { + if (msg.value < REQUEST_FEE) { + revert Errors.WhitelistRegistry__InsufficientFee(); + } + + uint256 lastRequest = uint48(s_lastRequestedTime[msg.sender]); + if (lastRequest != 0 && block.timestamp < lastRequest + REQUEST_COOLDOWN) { + revert Errors.WhitelistRegistry__RequestTooFrequent(); + } + + s_lastRequestedTime[msg.sender] = uint48(block.timestamp); + unchecked { + // Safe: REQUEST_FEE is small (10 TRX), total won't exceed uint128 max + s_totalCollectedFees += uint128(msg.value); + } + + emit WhitelistRequested(msg.sender); + + return true; + } + + /** + * @notice Withdraws all collected fees to caller + * @dev Can only be called by addresses with WITHDRAW_ROLE + */ + function withdraw() external { + if (!hasRole(WITHDRAW_ROLE, msg.sender)) { + revert Errors.WhitelistRegistry__NotAuthorized(); + } + + uint256 balance = s_totalCollectedFees; + + if (balance == 0) { + revert Errors.WhitelistRegistry__NothingToWithdraw(); + } + + s_totalCollectedFees = 0; + (bool success,) = msg.sender.call{value: balance}(""); + + if (!success) { + revert Errors.WhitelistRegistry__WithdrawFailed(); + } + + emit WithdrawSuccess(msg.sender, balance); + } + + /** + * @notice Adds a new authorized updater + * @dev Can only be called by DEFAULT_ADMIN_ROLE + * @param updater Address to authorize for Merkle root updates + */ + function addAuthorizedUpdater(address updater) external { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) { + revert Errors.WhitelistRegistry__NotAuthorized(); + } + if (updater == address(0)) { + revert Errors.WhitelistRegistry__InvalidInput(); + } + if (s_authorizedUpdaters[updater]) { + revert Errors.WhitelistRegistry__AlreadyAuthorized(); + } + + s_authorizedUpdaters[updater] = true; + emit AuthorizedUpdaterAdded(updater); + } + + /** + * @notice Removes an authorized updater + * @dev Can only be called by DEFAULT_ADMIN_ROLE + * @param updater Address to remove from authorized updaters + */ + function removeAuthorizedUpdater(address updater) external { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) { + revert Errors.WhitelistRegistry__NotAuthorized(); + } + if (updater == address(0)) { + revert Errors.WhitelistRegistry__InvalidInput(); + } + if (!s_authorizedUpdaters[updater]) { + revert Errors.WhitelistRegistry__NotAuthorized(); + } + + s_authorizedUpdaters[updater] = false; + emit AuthorizedUpdaterRemoved(updater); + } + + /** + * @notice Internal function to verify authorized updater signature + * @dev Validates nonce, signature, and signer authorization. Increments nonce on success + * @param newRoot Proposed new Merkle root + * @param nonce Nonce value from caller + * @param signature ECDSA signature to verify + */ + function _onlyAuthorizedUpdater(bytes32 newRoot, uint64 nonce, bytes calldata signature) internal { + if (newRoot == bytes32(0) || signature.length == 0) { + revert Errors.WhitelistRegistry__InvalidInput(); + } + + if (nonce != s_nonce) { + revert Errors.WhitelistRegistry__InvalidNonce(); + } + + bytes32 hash = keccak256(abi.encodePacked(newRoot, nonce, block.chainid, address(this))); + bytes32 signedHash = hash.toEthSignedMessageHash(); + address signer = ECDSA.recover(signedHash, signature); + + if (signer == address(0)) { + revert Errors.WhitelistRegistry__InvalidInput(); + } + + if (!s_authorizedUpdaters[signer]) { + revert Errors.WhitelistRegistry__NotAuthorized(); + } + + s_nonce++; + } + + /** + * @notice Pauses the contract + * @dev Can only be called by DEFAULT_ADMIN_ROLE. Prevents state-changing operations + */ + function pause() external onlyRole(DEFAULT_ADMIN_ROLE) { + _pause(); + } + + /** + * @notice Unpauses the contract + * @dev Can only be called by DEFAULT_ADMIN_ROLE + */ + function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) { + _unpause(); + } + + /* -------------------------------------------------------------------------- */ + /* GETTERS */ + /* -------------------------------------------------------------------------- */ + + /** + * @notice Returns the current Merkle root + * @return Current Merkle root hash + */ + function getCurrentMerkleRoot() external view returns (bytes32) { + return s_merkleRoot; + } + + /** + * @notice Returns total fees collected from whitelist requests + * @return Total collected fees in wei + */ + function getTotalCollectedFees() external view returns (uint128) { + return s_totalCollectedFees; + } + + /** + * @notice Returns the timestamp of the last Merkle root update + * @return Timestamp of last update + */ + function getLastUpdateTime() external view returns (uint48) { + return s_lastUpdate; + } + + /** + * @notice Returns the current nonce value + * @return Current nonce + */ + function getCurrentNonce() external view returns (uint64) { + return s_nonce; + } + + /** + * @notice Checks if an address is an authorized updater + * @param updater Address to check + * @return True if authorized, false otherwise + */ + function isAuthorizedUpdater(address updater) external view returns (bool) { + return s_authorizedUpdaters[updater]; + } + + /** + * @notice Returns the last time an address requested whitelist inclusion + * @param requester Address to check + * @return Timestamp of last request + */ + function getLastRequestedTime(address requester) external view returns (uint48) { + return uint48(s_lastRequestedTime[requester]); + } + + /** + * @notice Returns the cooldown period between whitelist requests + * @return Cooldown duration in seconds (24 hours) + */ + function getRequestCooldown() external pure returns (uint256) { + return REQUEST_COOLDOWN; + } + + /** + * @notice Returns the fee required for whitelist requests + * @return Fee amount in wei (10 TRX) + */ + function getRequestFee() external pure returns (uint256) { + return REQUEST_FEE; + } + + /** + * @notice Verifies if an address is whitelisted using Merkle proof + * @param proof Merkle proof array + * @param user Address to verify + * @return valid True if address is whitelisted, false otherwise + */ + function verifyWhitelist(bytes32[] calldata proof, address user) external view returns (bool valid) { + if (proof.length == 0 || user == address(0)) { + revert Errors.WhitelistRegistry__InvalidInput(); + } + + bytes32 leaf; + assembly { + mstore(0x0, user) + leaf := keccak256(0x0, 0x20) + } + valid = MerkleProof.verify(proof, s_merkleRoot, leaf); + } + + /** + * @notice Returns the WITHDRAW_ROLE identifier + * @return WITHDRAW_ROLE bytes32 identifier + */ + function getWithdrawRole() external pure returns (bytes32) { + return WITHDRAW_ROLE; + } + + /** + * @notice Returns the DEFAULT_ADMIN_ROLE identifier + * @return DEFAULT_ADMIN_ROLE bytes32 identifier + */ + function getDefaultAdminRole() external pure returns (bytes32) { + return DEFAULT_ADMIN_ROLE; + } + + /** + * @notice Checks if an address has DEFAULT_ADMIN_ROLE + * @param account Address to check + * @return True if address is admin, false otherwise + */ + function isAdmin(address account) external view returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, account); + } + + /** + * @notice Checks if an address has WITHDRAW_ROLE + * @param account Address to check + * @return True if address can withdraw, false otherwise + */ + function isWithdrawer(address account) external view returns (bool) { + return hasRole(WITHDRAW_ROLE, account); + } +} diff --git a/contracts/src/interfaces/IFeeModule.sol b/contracts/src/interfaces/IFeeModule.sol new file mode 100644 index 0000000..a58abd2 --- /dev/null +++ b/contracts/src/interfaces/IFeeModule.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Types} from "../libraries/Types.sol"; + +/** + * @title IFeeModule + * @notice Interface for fee calculation and application used by the settlement layer + * @dev Implementations should track fees per transfer and optionally a free-tier allowance + */ +interface IFeeModule { + /** + * @notice Calculate fee for a transaction and return details in a struct + * @param sender The address initiating the transfer + * @param txType The type of transaction (see Types.TxType) + * @param volume The volume of the transfer (used for volume-based fees) + * @param recipientCount Number of recipients for batched transfers (1 for single transfers) + * @return info A FeeInfo containing the fee and related info + */ + function calculateFee(address sender, Types.TxType txType, uint256 volume, uint256 recipientCount) + external + view + returns (Types.FeeInfo memory info); + + /** + * @notice Apply a previously calculated fee to a transfer + * @param sender The address paying the fee + * @param fee Fee amount to apply (in wei / smallest token unit) + * @param transferHash Unique hash identifying the transfer + * @param batchId Batch identifier + * @param txType The type of transaction (see Types.TxType) + */ + function applyFee(address sender, uint256 fee, bytes32 transferHash, uint64 batchId, Types.TxType txType) external; + + /** + * @notice Set the Settlement contract address + * @param settlement Address of the Settlement contract + */ + function setSettlement(address settlement) external; + + /** + * @notice Get the fee applied to a specific transfer + * @param transferHash Unique hash identifying the transfer + * @return fee The fee previously applied to the given transfer + */ + function getFeeOfTransaction(bytes32 transferHash) external view returns (uint256 fee); + + /** + * @notice Get total fees collected by the module + * @notice Returns CALCULATED fees for statistical purposes only + * @dev WARNING: Fees are NOT actually collected or transferred + */ + function getTotalFeesCollected() external view returns (uint256 total); + + /** + * @notice Get remaining number of free-tier transactions for a given sender + * @param sender Address to query + * @return remaining Number of free-tier transactions remaining + */ + function getRemainingFreeTierTransactions(address sender) external view returns (uint256 remaining); + + /** + * @notice Get the Settlement contract address + * @return Address of the Settlement contract + */ + function getSettlement() external view returns (address); + + /** + * @notice Get the contract owner + * @return Address of the owner + */ + function getOwner() external view returns (address); + + /** + * @notice Get the free transaction usage info for a user + * @param user Address to query + * @return FreeTxInfo struct containing day and count + */ + function getFreeTxUsage(address user) external view returns (Types.FreeTxInfo memory); + + /** + * @notice Get total fees collected for a specific batch + * @param batchId ID of the batch + * @return total Total fees for the batch in wei + */ + function getBatchTotalFees(uint64 batchId) external view returns (uint256 total); + + /** + * @notice Emitted when a fee is applied to a transfer + * @param sender The address who paid the fee + * @param fee The fee amount applied + * @param transferHash Transfer identifier for which the fee was applied + * @param batchId Batch identifier if this was part of a batched transfer + */ + event FeeApplied(address indexed sender, uint256 fee, bytes32 transferHash, uint64 batchId); + + /** + * @notice Emitted when a free-tier allowance is consumed + * @param sender The address that used a free-tier transaction + * @param remainingFreeTx Remaining free-tier transactions after use + */ + event FreeTierUsed(address indexed sender, uint256 remainingFreeTx); + + /** + * @notice Emitted when the settlement contract address is updated + * @param settlement The new settlement contract address + */ + event SettlementUpdated(address indexed settlement); +} diff --git a/contracts/src/interfaces/ISettlement.sol b/contracts/src/interfaces/ISettlement.sol new file mode 100644 index 0000000..dd1a35b --- /dev/null +++ b/contracts/src/interfaces/ISettlement.sol @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Types} from "../libraries/Types.sol"; + +/** + * @title ISettlement + * @notice Interface for batch submission and verified transfer execution + * @dev Admin setters must be access controlled + */ +interface ISettlement { + /** + * @notice Submit a batch by Merkle root + * @dev Validates limits stores metadata emits BatchSubmitted + * @param merkleRoot Batch root + * @param txCount Transactions count + * @param batchSalt Salt used by backend to build merkle root + * @return success True if accepted + * @return batchId Assigned ID + */ + function submitBatch(bytes32 merkleRoot, uint32 txCount, uint64 batchSalt) external returns (bool, uint64); + + /** + * @notice Execute a proven transfer + * @dev Verifies txProof optional whitelistProof applies fees emits TransferExecuted + * @param txProof Proof for txData + * @param whitelistProof Proof for sender whitelist + * @param txData Transfer data + * @return success True if executed + */ + function executeTransfer( + bytes32[] calldata txProof, + bytes32[] calldata whitelistProof, + Types.TransferData memory txData + ) external returns (bool); + + /** + * @notice Approve aggregator + * @dev Admin only Emits AggregatorApproved + * @param aggregator Address to approve + */ + function approveAggregator(address aggregator) external; + + /** + * @notice Disapprove aggregator + * @dev Admin only Emits AggregatorDisapproved + * @param aggregator Address to disapprove + */ + function disapproveAggregator(address aggregator) external; + + /** + * @notice Pause the contract + * @dev Admin only Prevents executeTransfer calls + */ + function pause() external; + + /** + * @notice Unpause the contract + * @dev Admin only + */ + function unpause() external; + + /** + * @notice Set whitelist registry + * @dev Admin only Emits WhitelistRegistryUpdated + * @param whitelistRegistry Registry address + */ + function setWhitelistRegistry(address whitelistRegistry) external; + + /** + * @notice Set fee module + * @dev Admin only Emits FeeModuleUpdated + * @param feeModule Fee module address + */ + function setFeeModule(address feeModule) external; + + /** + * @notice Set max tx per batch + * @dev Admin only Emits MaxTxPerBatchUpdated + * @param maxTx New limit + */ + function setMaxTxPerBatch(uint32 maxTx) external; + + /** + * @notice Set timelock duration + * @dev Admin only Emits TimelockDurationUpdated + * @param duration Seconds + */ + function setTimelockDuration(uint48 duration) external; + + /** + * @notice Set token address + * @dev Admin only Emits TokenUpdated + * @param tokenAddress Token address + */ + function setToken(address tokenAddress) external; + + /** + * @notice Get owner + * @return Address of owner + */ + function getOwner() external view returns (address); + + /** + * @notice Get whitelist registry + * @return Address of registry + */ + function getWhitelistRegistry() external view returns (address); + + /** + * @notice Get fee module + * @return Address of fee module + */ + function getFeeModule() external view returns (address); + + /** + * @notice Get token + * @return Address of token + */ + function getToken() external view returns (address); + + /** + * @notice Get current batch ID + * @return Current batch ID counter + */ + function getCurrentBatchId() external view returns (uint64); + + /** + * @notice Check approved aggregator + * @param aggregator Address to check + * @return True if approved + */ + function isApprovedAggregator(address aggregator) external view returns (bool); + + /** + * @notice Get max tx per batch + * @return maxTx Limit + */ + function getMaxTxPerBatch() external view returns (uint32); + + /** + * @notice Get timelock duration + * @return duration Seconds + */ + function getTimelockDuration() external view returns (uint48); + + /** + * @notice Get batch ID by root + * @param rootHash Batch root + * @return batchId ID + */ + function getBatchIdByRoot(bytes32 rootHash) external view returns (uint64); + + /** + * @notice Get batch by ID + * @param batchId Batch ID + * @return batch Stored metadata + */ + function getBatchById(uint64 batchId) external view returns (Types.Batch memory); + + /** + * @notice Check if transfer executed + * @param transferHash Transfer hash + * @return True if executed + */ + function isExecutedTransfer(bytes32 transferHash) external view returns (bool); + + /** + * @notice Get Merkle root by batch ID + * @param batchId Batch ID + * @return Merkle root of the batch + */ + function getRootByBatchId(uint64 batchId) external view returns (bytes32); + + /** + * @notice Check if contract is configured + * @return True if configured + */ + function isConfigured() external view returns (bool); + + /** + * @notice Emitted on batch submission + * @param batchId Assigned ID + * @param merkleRoot Batch root + * @param txCount Count + * @param timestamp Block time + */ + event BatchSubmitted(uint64 indexed batchId, bytes32 indexed merkleRoot, uint32 txCount, uint48 timestamp); + + /** + * @notice Emitted on transfer execution + * @param from Sender + * @param to Recipient + * @param amount Amount + * @param nonce Nonce + */ + event TransferExecuted(address indexed from, address indexed to, uint256 amount, uint64 nonce); + + /** + * @notice Emitted on whitelist registry update + * @param whitelistRegistry New address + */ + event WhitelistRegistryUpdated(address indexed whitelistRegistry); + + /** + * @notice Emitted on fee module update + * @param feeModule New address + */ + event FeeModuleUpdated(address indexed feeModule); + + /** + * @notice Emitted on aggregator approval + * @param aggregator Approved address + */ + event AggregatorApproved(address indexed aggregator); + + /** + * @notice Emitted on aggregator disapproval + * @param aggregator Disapproved address + */ + event AggregatorDisapproved(address indexed aggregator); + + /** + * @notice Emitted on max tx per batch update + * @param maxTx New limit + */ + event MaxTxPerBatchUpdated(uint32 indexed maxTx); + + /** + * @notice Emitted on timelock duration update + * @param duration New seconds + */ + event TimelockDurationUpdated(uint48 indexed duration); + + /** + * @notice Emitted on token address update + * @param tokenAddress New token + */ + event TokenUpdated(address indexed tokenAddress); +} diff --git a/contracts/src/interfaces/IWhitelistRegistry.sol b/contracts/src/interfaces/IWhitelistRegistry.sol new file mode 100644 index 0000000..521e4b1 --- /dev/null +++ b/contracts/src/interfaces/IWhitelistRegistry.sol @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +/** + * @title IWhitelistRegistry + * @notice Registry used to manage and verify a Merkle-tree based whitelist + * @dev Implementations should allow updating the merkle root (with authorization) + * and enable verification/requests against the stored root + */ +interface IWhitelistRegistry { + /** + * @notice Update the Merkle root used for whitelist proofs + * @dev A signature or other authorization proof may be supplied to prove the + * caller is allowed to update the root (implementation-specific) + * @param newRoot New Merkle root to set + * @param signature Authorization signature or proof for the update (implementation-specific) + */ + function updateMerkleRoot(bytes32 newRoot, uint64 nonce, bytes calldata signature) external; + + /** + * @notice Request whitelist access (implementation may emit an event) + * @dev Implementations may require additional off-chain verification or queueing logic + * @return success True if the request was accepted + */ + function requestWhitelist() external payable returns (bool success); + + /** + * @notice Withdraw accumulated funds from the contract + * @dev Caller must have appropriate permissions (implementation-specific) + */ + function withdraw() external; + + /** + * @notice Add an authorized updater + * @dev Admin only Emits AuthorizedUpdaterAdded + * @param updater Address to authorize + */ + function addAuthorizedUpdater(address updater) external; + + /** + * @notice Remove an authorized updater + * @dev Admin only Emits AuthorizedUpdaterRemoved + * @param updater Address to remove authorization + */ + function removeAuthorizedUpdater(address updater) external; + + /** + * @notice Pause the contract + * @dev Admin only + */ + function pause() external; + + /** + * @notice Unpause the contract + * @dev Admin only + */ + function unpause() external; + + /** + * @notice Get the currently active Merkle root used for whitelist verification + * @return root Current Merkle root + */ + function getCurrentMerkleRoot() external view returns (bytes32 root); + + /** + * @notice Verify whether a given user is included in the whitelist + * @param proof Merkle proof (array of sibling hashes) proving inclusion + * @param user Address to verify + * @return valid True if the user is included according to the current merkle root + */ + function verifyWhitelist(bytes32[] calldata proof, address user) external view returns (bool valid); + + /** + * @notice Get total collected fees + * @return Total fees collected + */ + function getTotalCollectedFees() external view returns (uint128); + + /** + * @notice Get last update time + * @return Last update timestamp + */ + function getLastUpdateTime() external view returns (uint48); + + /** + * @notice Get current nonce + * @return Current nonce value + */ + function getCurrentNonce() external view returns (uint64); + + /** + * @notice Check if address is authorized updater + * @param updater Address to check + * @return True if authorized + */ + function isAuthorizedUpdater(address updater) external view returns (bool); + + /** + * @notice Get last requested time for a requester + * @param requester Address to check + * @return Last request timestamp + */ + function getLastRequestedTime(address requester) external view returns (uint48); + + /** + * @notice Get request cooldown period + * @return Cooldown duration in seconds + */ + function getRequestCooldown() external pure returns (uint256); + + /** + * @notice Get request fee amount + * @return Fee amount required for requests + */ + function getRequestFee() external pure returns (uint256); + + /** + * @notice Get withdraw role identifier + * @return Withdraw role bytes32 identifier + */ + function getWithdrawRole() external pure returns (bytes32); + + /** + * @notice Get default admin role identifier + * @return Default admin role bytes32 identifier + */ + function getDefaultAdminRole() external pure returns (bytes32); + + /** + * @notice Check if account has admin role + * @param account Address to check + * @return True if account is admin + */ + function isAdmin(address account) external view returns (bool); + + /** + * @notice Check if account has withdraw role + * @param account Address to check + * @return True if account can withdraw + */ + function isWithdrawer(address account) external view returns (bool); + + /** + * @notice Emitted when the whitelist Merkle root is updated + * @param oldRoot The previous Merkle root + * @param newRoot The new Merkle root that was set + * @param nonce The nonce used in the update + */ + event WhitelistUpdated(bytes32 oldRoot, bytes32 newRoot, uint64 nonce); + + /** + * @notice Emitted when an address requests to be added to the whitelist + * @param requester Address that requested whitelist access + */ + event WhitelistRequested(address indexed requester); + + /** + * @notice Emitted when funds are withdrawn from the contract + * @param requester Address that initiated the withdrawal + * @param amount Amount of funds withdrawn + */ + event WithdrawSuccess(address indexed requester, uint256 amount); + /** + * @notice Emitted when a new authorized updater is added + * @param updater Address of the authorized updater added + */ + event AuthorizedUpdaterAdded(address indexed updater); + + /** + * @notice Emitted when an authorized updater is removed + * @param updater Address of the authorized updater removed + */ + event AuthorizedUpdaterRemoved(address indexed updater); +} diff --git a/contracts/src/libraries/Errors.sol b/contracts/src/libraries/Errors.sol new file mode 100644 index 0000000..7d27e1e --- /dev/null +++ b/contracts/src/libraries/Errors.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +/** + * @title Errors + * @notice Custom error definitions for the protocol + * @dev Using custom errors instead of require strings saves gas + */ +library Errors { + error WhitelistRegistry__NotAuthorized(); + error WhitelistRegistry__InsufficientFee(); + error WhitelistRegistry__RequestTooFrequent(); + error WhitelistRegistry__InvalidNonce(); + error WhitelistRegistry__WithdrawFailed(); + error WhitelistRegistry__InvalidInput(); + error WhitelistRegistry__AlreadyAuthorized(); + error WhitelistRegistry__DuplicateUpdate(); + error WhitelistRegistry__NothingToWithdraw(); + + error FeeModule__InvalidInput(); + error FeeModule__InvalidRecipientCount(); + error FeeModule__FreeTierLimitExceeded(); + error FeeModule__InvalidTxType(); + error FeeModule__NotAuthorized(); + error FeeModule__AlreadySettlement(); + + error Settlement__AggregatorNotApproved(); + error Settlement__InvalidInput(); + error Settlement__AlreadyRegistry(); + error Settlement__BatchLocked(); + error Settlement__BatchAlreadySubmitted(); + error Settlement__InvalidBatch(); + error Settlement__InvalidMerkleProof(); + error Settlement__AlreadyFeeModule(); + error Settlement__AlreadySet(); + error Settlement__AlreadyTimelockDuration(); + error Settlement__AlreadyToken(); + error Settlement__TransferAlreadyExecuted(); + error Settlement__NotWhitelisted(); + error Settlement__InsufficientBalance(); + error Settlement__InsufficientAllowance(); + error Settlement__AlreadyAggregator(); + error Settlement__NotConfigured(); + error Settlement__InsufficientAllowance(); +} diff --git a/contracts/src/libraries/Types.sol b/contracts/src/libraries/Types.sol new file mode 100644 index 0000000..344b357 --- /dev/null +++ b/contracts/src/libraries/Types.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +/** + * @title Types + * @notice Core types and data structures used across the protocol + */ +library Types { + /** + * @notice Transaction processing modes that determine fee calculation + * @dev Single-recipient: DELAYED (standard), INSTANT (premium) + * Multi-recipient: BATCHED (per-recipient fee) + * Special: FREE_TIER (limited daily quota) + */ + enum TxType { + DELAYED, // Standard processing with lower fee + INSTANT, // Premium processing with higher fee + BATCHED, // Multi-recipient with per-recipient fee + FREE_TIER // Uses daily free transaction quota + } + + /** + * @notice Fee calculation result + * @param fee Amount in smallest token unit (e.g., wei/sun) + * @param txType Actual transaction type applied + * @param freeQuota Remaining free transactions for the day + */ + struct FeeInfo { + uint256 fee; + TxType txType; + uint256 freeQuota; + } + + /** + * @notice Free-tier transaction usage tracking + * @param count Number of free transactions used today + * @param day Last day (timestamp) when free transactions were used + */ + struct FreeTxInfo { + uint128 count; + uint128 day; + } + + /** + * @notice Batch processing information + */ + struct Batch { + bytes32 merkleRoot; // Root of merkle tree containing transactions + uint48 timestamp; // Batch creation time + uint32 txCount; // Number of transactions in batch + uint48 unlockTime; // Time when batch can be processed + uint64 batchSalt; // Salt used by backend to build merkle root + } + + /** + * @notice Individual transfer details + */ + struct TransferData { + address from; // Sender address + address to; // Recipient address + uint256 amount; // Transfer amount + uint64 nonce; // Unique identifier per sender + uint48 timestamp; // Transfer initiation time + uint32 recipientCount; // Number of recipients (used for BATCHED fee calc) + uint64 batchId; // Batch identifier + TxType txType; // Processing mode + } +} diff --git a/contracts/test/integration/FeeModuleIntegration.t.sol b/contracts/test/integration/FeeModuleIntegration.t.sol new file mode 100644 index 0000000..af8ca15 --- /dev/null +++ b/contracts/test/integration/FeeModuleIntegration.t.sol @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {IntegrationDeployHelpers} from "../utils/IntegrationDeployHelpers.sol"; +import {TestConstants as TC} from "../utils/TestConstants.sol"; + +import {Types} from "../../src/libraries/Types.sol"; +import {Errors} from "../../src/libraries/Errors.sol"; + +contract FeeModuleIntegrationTest is Test, IntegrationDeployHelpers { + // DEFAULT_SENDER = 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38; + function setUp() public { + _initUser(); + _initUser2(); + _initFeeModule(); + _initSettlement(); + + vm.prank(DEFAULT_SENDER); + feeModule.setSettlement(address(settlement)); + } + + /* -------------------------------------------------------------------------- */ + /* INITIAL STATE */ + /* -------------------------------------------------------------------------- */ + + function test_Constructor_InitialValues() public view { + assertNotEq(feeModule.getSettlement(), address(0)); + assertEq(feeModule.getSettlement(), address(settlement)); + assertEq(address(feeModule.owner()), DEFAULT_SENDER); + } + + /* -------------------------------------------------------------------------- */ + /* CALCULATIONS */ + /* -------------------------------------------------------------------------- */ + + function test_CalculateFee_FreeQuota_NoChanges() public view { + Types.FeeInfo memory feeInfo = feeModule.calculateFee(user, Types.TxType.DELAYED, TC.VOLUME, 1); + assertEq(feeInfo.fee, 0); + assertEq(uint256(feeInfo.txType), uint256(Types.TxType.FREE_TIER)); + assertEq(feeInfo.freeQuota, TC.FREE_TX_AMOUNT); + + for (uint256 i = 0; i < TC.FREE_TX_AMOUNT; i++) { + feeInfo = feeModule.calculateFee(user, Types.TxType.DELAYED, TC.VOLUME, 1); + assertEq(feeInfo.fee, 0); + assertEq(uint256(feeInfo.txType), uint256(Types.TxType.FREE_TIER)); + assertEq(feeInfo.freeQuota, TC.FREE_TX_AMOUNT); + } + } + + function test__CalculateFee_Batched_MultipleRecipients() public view { + uint256 recipients = 5; + Types.FeeInfo memory feeInfo = feeModule.calculateFee(user, Types.TxType.BATCHED, TC.VOLUME, recipients); + assertEq(feeInfo.fee, TC.BATCH_FEE * recipients); + assertEq(uint256(feeInfo.txType), uint256(Types.TxType.BATCHED)); + } + + function test__CalculateFee_Instant() public view { + Types.FeeInfo memory feeInfo = feeModule.calculateFee(user, Types.TxType.INSTANT, TC.VOLUME, 1); + assertEq(feeInfo.fee, TC.INSTANT_FEE); + assertEq(uint256(feeInfo.txType), uint256(Types.TxType.INSTANT)); + } + + function test_CalculateFee_FreeTier_ResetsNextDay() public { + for (uint256 i = 0; i < TC.FREE_TX_AMOUNT; i++) { + feeModule.calculateFee(user, Types.TxType.DELAYED, TC.VOLUME, 1); + vm.prank(address(settlement)); + feeModule.applyFee(user, 0, keccak256(abi.encodePacked(i)), 1, Types.TxType.FREE_TIER); + } + + Types.FeeInfo memory feeInfo = feeModule.calculateFee(user, Types.TxType.DELAYED, TC.VOLUME, 1); + assertEq(feeInfo.fee, TC.BASE_FEE); + + vm.warp(block.timestamp + 1 days); + + feeInfo = feeModule.calculateFee(user, Types.TxType.DELAYED, TC.VOLUME, 1); + assertEq(feeInfo.fee, 0); + assertEq(feeInfo.freeQuota, TC.FREE_TX_AMOUNT); + } + + function test_CalculateFee_LargeVolume() public view { + Types.FeeInfo memory feeInfo = feeModule.calculateFee(user, Types.TxType.DELAYED, TC.LARGE_VOLUME, 1); + assertEq(feeInfo.fee, 0); + assertEq(uint256(feeInfo.txType), uint256(Types.TxType.DELAYED)); + assertEq(feeInfo.freeQuota, TC.FREE_TX_AMOUNT); + + feeInfo = feeModule.calculateFee(user, Types.TxType.INSTANT, TC.LARGE_VOLUME, 1); + assertEq(feeInfo.fee, 0); + + feeInfo = feeModule.calculateFee(user, Types.TxType.BATCHED, TC.LARGE_VOLUME, 2); + assertEq(feeInfo.fee, 0); + } + + function test_CalculateFee_BatchedWithOneRecipient() public { + vm.expectRevert(Errors.FeeModule__InvalidRecipientCount.selector); + feeModule.calculateFee(user, Types.TxType.BATCHED, TC.VOLUME, 1); + } + + function test__CalculateFee_NonBatched_MultipleRecipients_Reverts() public { + vm.expectRevert(Errors.FeeModule__InvalidRecipientCount.selector); + feeModule.calculateFee(user, Types.TxType.DELAYED, TC.VOLUME, 5); + + vm.expectRevert(Errors.FeeModule__InvalidRecipientCount.selector); + feeModule.calculateFee(user, Types.TxType.INSTANT, TC.VOLUME, 3); + + vm.expectRevert(Errors.FeeModule__InvalidRecipientCount.selector); + feeModule.calculateFee(user, Types.TxType.FREE_TIER, TC.VOLUME, 2); + } + + function test__CalculateFee_Batched_SingleRecipient_Reverts() public { + vm.expectRevert(Errors.FeeModule__InvalidRecipientCount.selector); + feeModule.calculateFee(user, Types.TxType.BATCHED, TC.VOLUME, 1); + } + + function test__CalculateFee_Batched_Success() public view { + Types.FeeInfo memory feeInfo = feeModule.calculateFee(user, Types.TxType.BATCHED, TC.VOLUME, 5); + assertEq(feeInfo.fee, TC.BATCH_FEE * 5); + assertEq(uint256(feeInfo.txType), uint256(Types.TxType.BATCHED)); + } + + function test_CalculateFee_Batched_LargeVolume() public view { + Types.FeeInfo memory feeInfo = feeModule.calculateFee(user, Types.TxType.BATCHED, TC.LARGE_VOLUME, 3); + assertEq(feeInfo.fee, 0); + assertEq(uint256(feeInfo.txType), uint256(Types.TxType.BATCHED)); + } + + /* -------------------------------------------------------------------------- */ + /* applyFee */ + /* -------------------------------------------------------------------------- */ + + function test_ApplyFee_UpdatesTotalFees() public { + bytes32 transferHash = keccak256(abi.encodePacked("transfer1")); + uint64 batchId = 1; + uint256 fee = TC.BASE_FEE; + + vm.prank(address(settlement)); + feeModule.applyFee(user, fee, transferHash, batchId, Types.TxType.DELAYED); + + assertEq(feeModule.getFeeOfTransaction(transferHash), fee); + assertEq(feeModule.getTotalFeesCollected(), fee); + assertEq(feeModule.getBatchTotalFees(batchId), fee); + } + + function test_ApplyFee_Multiple_AccumulatesCorrectly() public { + bytes32 hash1 = keccak256(abi.encodePacked("tx1")); + bytes32 hash2 = keccak256(abi.encodePacked("tx2")); + uint64 batchId = 1; + + vm.startPrank(address(settlement)); + feeModule.applyFee(user, TC.BASE_FEE, hash1, batchId, Types.TxType.INSTANT); + feeModule.applyFee(user, TC.INSTANT_FEE, hash2, batchId, Types.TxType.INSTANT); + vm.stopPrank(); + + assertEq(feeModule.getTotalFeesCollected(), TC.BASE_FEE + TC.INSTANT_FEE); + assertEq(feeModule.getBatchTotalFees(batchId), TC.BASE_FEE + TC.INSTANT_FEE); + } + + /* -------------------------------------------------------------------------- */ + /* FULL FLOW SCENARIOS */ + /* -------------------------------------------------------------------------- */ + + function test_FullFlow_CalculateAndApplyFee() public { + Types.FeeInfo memory feeInfo = feeModule.calculateFee(user, Types.TxType.INSTANT, TC.VOLUME, 1); + assertEq(feeInfo.fee, TC.INSTANT_FEE); + + bytes32 transferHash = keccak256(abi.encodePacked("transfer1")); + uint64 batchId = 1; + + vm.prank(address(settlement)); + feeModule.applyFee(user, feeInfo.fee, transferHash, batchId, Types.TxType.INSTANT); + + assertEq(feeModule.getFeeOfTransaction(transferHash), TC.INSTANT_FEE); + assertEq(feeModule.getTotalFeesCollected(), TC.INSTANT_FEE); + assertEq(feeModule.getBatchTotalFees(batchId), TC.INSTANT_FEE); + } + + function test_MultipleUsers_IndependentFreeTier() public { + address user2 = makeAddr("user2"); + + vm.startPrank(address(settlement)); + + for (uint256 i = 0; i < 5; i++) { + Types.FeeInfo memory feeInfo1 = feeModule.calculateFee(user, Types.TxType.DELAYED, TC.VOLUME, 1); + assertEq(feeInfo1.fee, 0); + assertEq(uint256(feeInfo1.txType), uint256(Types.TxType.FREE_TIER)); + + bytes32 txHash = keccak256(abi.encodePacked(user, i)); + feeModule.applyFee(user, 0, txHash, 1, Types.TxType.FREE_TIER); + } + + Types.FeeInfo memory feeInfo2 = feeModule.calculateFee(user2, Types.TxType.DELAYED, TC.VOLUME, 1); + assertEq(feeInfo2.fee, 0); + assertEq(uint256(feeInfo2.txType), uint256(Types.TxType.FREE_TIER)); + assertEq(feeInfo2.freeQuota, TC.FREE_TX_AMOUNT); + + bytes32 txHash2 = keccak256(abi.encodePacked(user2, uint256(0))); + feeModule.applyFee(user2, 0, txHash2, 2, Types.TxType.FREE_TIER); + + assertEq(feeModule.getRemainingFreeTierTransactions(user), TC.FREE_TX_AMOUNT - 5); + assertEq(feeModule.getRemainingFreeTierTransactions(user2), TC.FREE_TX_AMOUNT - 1); + + vm.stopPrank(); + } + + function test_FreeQuota_AfterApply() public { + Types.FeeInfo memory feeInfo; + + for (uint256 i = 0; i < TC.FREE_TX_AMOUNT; i++) { + feeInfo = feeModule.calculateFee(user, Types.TxType.DELAYED, TC.VOLUME, 1); + vm.prank(address(settlement)); + feeModule.applyFee(user, feeInfo.fee, keccak256(abi.encodePacked(i)), 1, feeInfo.txType); + + assertEq(feeModule.getRemainingFreeTierTransactions(user), TC.FREE_TX_AMOUNT - (i + 1)); + } + + feeInfo = feeModule.calculateFee(user, Types.TxType.DELAYED, TC.VOLUME, 1); + assertEq(feeInfo.fee, TC.BASE_FEE); + } + + function test_MultipleBatches_SeparateFeeAccumulation() public { + bytes32 hash1 = keccak256(abi.encodePacked("tx1")); + bytes32 hash2 = keccak256(abi.encodePacked("tx2")); + bytes32 hash3 = keccak256(abi.encodePacked("tx3")); + + vm.startPrank(address(settlement)); + feeModule.applyFee(user, TC.BASE_FEE, hash1, 1, Types.TxType.DELAYED); + feeModule.applyFee(user, TC.INSTANT_FEE, hash2, 1, Types.TxType.INSTANT); + feeModule.applyFee(user, TC.BATCH_FEE * 3, hash3, 2, Types.TxType.BATCHED); + vm.stopPrank(); + + assertEq(feeModule.getBatchTotalFees(1), TC.BASE_FEE + TC.INSTANT_FEE); + assertEq(feeModule.getBatchTotalFees(2), TC.BATCH_FEE * 3); + assertEq(feeModule.getTotalFeesCollected(), TC.BASE_FEE + TC.INSTANT_FEE + TC.BATCH_FEE * 3); + } + + function test_LargeVolumeUser_ThenSmallVolume() public view { + Types.FeeInfo memory feeInfo = feeModule.calculateFee(user, Types.TxType.DELAYED, TC.LARGE_VOLUME, 1); + assertEq(feeInfo.fee, 0); + assertEq(feeInfo.freeQuota, TC.FREE_TX_AMOUNT); + + feeInfo = feeModule.calculateFee(user, Types.TxType.DELAYED, TC.VOLUME, 1); + assertEq(feeInfo.fee, 0); + assertEq(uint256(feeInfo.txType), uint256(Types.TxType.FREE_TIER)); + assertEq(feeInfo.freeQuota, TC.FREE_TX_AMOUNT); + } + + function test_FreeTier_AcrossDayBoundary() public { + for (uint256 i = 0; i < 3; i++) { + feeModule.calculateFee(user, Types.TxType.DELAYED, TC.VOLUME, 1); + } + assertEq(feeModule.getRemainingFreeTierTransactions(user), 10); + + vm.prank(address(settlement)); + feeModule.applyFee(user, 0, keccak256(abi.encodePacked("tx1")), 1, Types.TxType.FREE_TIER); + assertEq(feeModule.getRemainingFreeTierTransactions(user), 9); + + vm.warp(block.timestamp + 12 hours); + assertEq(feeModule.getRemainingFreeTierTransactions(user), 9); + + vm.warp(block.timestamp + 13 hours); // total 25 hours + assertEq(feeModule.getRemainingFreeTierTransactions(user), TC.FREE_TX_AMOUNT); + } + + function test_CalculateFee_FreeTierLimitExceeded() public { + vm.startPrank(address(settlement)); + for (uint256 i = 0; i < TC.FREE_TX_AMOUNT; i++) { + Types.FeeInfo memory feeInfo1 = feeModule.calculateFee(user, Types.TxType.DELAYED, TC.VOLUME, 1); + assertEq(feeInfo1.fee, 0); + assertEq(uint256(feeInfo1.txType), uint256(Types.TxType.FREE_TIER)); + + bytes32 txHash = keccak256(abi.encodePacked(i)); + feeModule.applyFee(user, 0, txHash, 1, Types.TxType.FREE_TIER); + } + + vm.expectRevert(Errors.FeeModule__FreeTierLimitExceeded.selector); + feeModule.calculateFee(user, Types.TxType.FREE_TIER, TC.VOLUME, 1); + vm.stopPrank(); + } + + function test_ApplyFee_FreeTierLimitExceeded() public { + for (uint256 i = 0; i < TC.FREE_TX_AMOUNT; i++) { + vm.prank(address(settlement)); + feeModule.applyFee(user, 0, keccak256(abi.encodePacked(i)), 1, Types.TxType.FREE_TIER); + } + + vm.prank(address(settlement)); + vm.expectRevert(Errors.FeeModule__FreeTierLimitExceeded.selector); + feeModule.applyFee(user, 0, keccak256("overflow"), 1, Types.TxType.FREE_TIER); + } + + /* -------------------------------------------------------------------------- */ + /* GETTERS */ + /* -------------------------------------------------------------------------- */ + + function test_Getters_All() public { + bytes32 txHash1 = keccak256("tx1"); + bytes32 txHash2 = keccak256("tx2"); + uint64 batchId1 = 1; + uint64 batchId2 = 2; + + vm.startPrank(address(settlement)); + feeModule.applyFee(user, TC.BASE_FEE, txHash1, 1, Types.TxType.DELAYED); + feeModule.applyFee(user, TC.INSTANT_FEE, txHash2, 2, Types.TxType.INSTANT); + vm.stopPrank(); + + feeModule.calculateFee(user2, Types.TxType.DELAYED, TC.VOLUME, 1); + vm.prank(address(settlement)); + feeModule.applyFee(user2, 0, keccak256("freeTx"), 3, Types.TxType.FREE_TIER); + + assertEq(feeModule.getSettlement(), address(settlement)); + assertEq(feeModule.getOwner(), DEFAULT_SENDER); + + Types.FreeTxInfo memory usage = feeModule.getFreeTxUsage(user2); + assertEq(usage.count, 1); + assertEq(usage.day, block.timestamp / 1 days); + + assertEq(feeModule.getFeeOfTransaction(txHash1), TC.BASE_FEE); + assertEq(feeModule.getFeeOfTransaction(txHash2), TC.INSTANT_FEE); + + assertEq(feeModule.getTotalFeesCollected(), TC.BASE_FEE + TC.INSTANT_FEE); + + assertEq(feeModule.getBatchTotalFees(1), TC.BASE_FEE); + assertEq(feeModule.getBatchTotalFees(2), TC.INSTANT_FEE); + assertEq(feeModule.getBatchTotalFees(3), 0); + + uint256 remaining = feeModule.getRemainingFreeTierTransactions(user2); + assertEq(remaining, TC.FREE_TX_AMOUNT - 1); + } +} diff --git a/contracts/test/integration/RegistryIntegration.t.sol b/contracts/test/integration/RegistryIntegration.t.sol new file mode 100644 index 0000000..5907239 --- /dev/null +++ b/contracts/test/integration/RegistryIntegration.t.sol @@ -0,0 +1,547 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +import {IWhitelistRegistry} from "../../src/interfaces/IWhitelistRegistry.sol"; +import {MaliciousReceiver} from "../mocks/MaliciousReceiver.sol"; + +import {IntegrationDeployHelpers} from "../utils/IntegrationDeployHelpers.sol"; +import {TestConstants as TC} from "../utils/TestConstants.sol"; +import {Errors} from "../../src/libraries/Errors.sol"; + +contract RegistryIntegrationTest is Test, IntegrationDeployHelpers { + using MessageHashUtils for bytes32; + + address updater = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; + uint256 updaterPrivKey = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; + + address user1 = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8; + + // ------------------ Whitelist Merkle Test Data ------------------ + + bytes32 MERKLE_ROOT = 0x813e418bccb26456db980833fb3f2d171569401dca4ddd31ba78b99f5d99e242; + + bytes32[] private PROOF_USER1; + bytes32[] private PROOF_USER2; + bytes32[] private PROOF_USER3; + + function setUp() public { + _initRegistry(); + _initUser(); + + PROOF_USER1 = new bytes32[](2); + PROOF_USER1[0] = 0x6a65260b54e189b9d496c6e25ab6e91aef04672387dc6e4b559dd6f6335197a6; + PROOF_USER1[1] = 0x4044ec0d82f345979063e37b899875d71b453c276b360523e82b432c04ea3f17; + + PROOF_USER2 = new bytes32[](2); + PROOF_USER2[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + PROOF_USER2[1] = 0x4044ec0d82f345979063e37b899875d71b453c276b360523e82b432c04ea3f17; + + PROOF_USER3 = new bytes32[](1); + PROOF_USER3[0] = 0x1a5324f5a19c274c2f9bfcfcdefcefc0ec65fef7db5a54fe78fca8007b4fe93a; + } + + function _sign(bytes32 mesHash, uint256 privKey) internal pure returns (bytes memory signature) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privKey, mesHash); + signature = abi.encodePacked(r, s, v); + } + + function _signMerkleRoot(uint256 privKey, uint64 nonce) public view returns (bytes memory signature) { + bytes32 messageHash = keccak256(abi.encodePacked(MERKLE_ROOT, nonce, block.chainid, address(registry))); + bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash(); + signature = _sign(ethSignedMessageHash, privKey); + } + + /* -------------------------------------------------------------------------- */ + /* INITIAL STATE */ + /* -------------------------------------------------------------------------- */ + + function test_Constructor_InitialValues() public view { + assertTrue(registry.hasRole(TC.DEFAULT_ADMIN_ROLE, updater)); + assertTrue(registry.hasRole(TC.WITHDRAW_ROLE, updater)); + assertTrue(registry.isAuthorizedUpdater(updater)); + + uint256 fee = registry.getRequestFee(); + assertEq(fee, TC.REQUEST_FEE); + + uint256 cooldown = registry.getRequestCooldown(); + assertEq(cooldown, TC.REQUEST_COOLDOWN); + } + + /* -------------------------------------------------------------------------- */ + /* updateMerkleRoot */ + /* -------------------------------------------------------------------------- */ + + function test_UpdateMerkleRoot() public { + uint64 currentNonce = registry.getCurrentNonce(); + bytes32 oldRoot = registry.getCurrentMerkleRoot(); + + vm.prank(updater); + bytes memory signature = _signMerkleRoot(updaterPrivKey, currentNonce); + + vm.expectEmit(false, false, false, true); + emit IWhitelistRegistry.WhitelistUpdated(oldRoot, MERKLE_ROOT, currentNonce); + registry.updateMerkleRoot(MERKLE_ROOT, currentNonce, signature); + + bytes32 currentRoot = registry.getCurrentMerkleRoot(); + uint48 lastUpdate = uint48(registry.getLastUpdateTime()); + assertEq(currentRoot, MERKLE_ROOT); + assertEq(lastUpdate, uint48(block.timestamp)); + } + + function test_UpdateMerkleRoot_DuplicateUpdate() public { + uint64 currentNonce = registry.getCurrentNonce(); + + vm.startPrank(updater); + bytes memory signature = _signMerkleRoot(updaterPrivKey, currentNonce); + + registry.updateMerkleRoot(MERKLE_ROOT, uint64(currentNonce), signature); + + uint64 newNonce = registry.getCurrentNonce(); + bytes memory newSignature = _signMerkleRoot(updaterPrivKey, newNonce); + + vm.expectRevert(Errors.WhitelistRegistry__DuplicateUpdate.selector); + registry.updateMerkleRoot(MERKLE_ROOT, newNonce, newSignature); + vm.stopPrank(); + } + + function test_UpdateMerkleRoot_InvalidNonce() public { + uint256 currentNonce = registry.getCurrentNonce(); + uint64 randomNonce = 2131; + assertNotEq(currentNonce, randomNonce); + + vm.startPrank(updater); + bytes memory signature = _signMerkleRoot(updaterPrivKey, randomNonce); + + vm.expectRevert(Errors.WhitelistRegistry__InvalidNonce.selector); + registry.updateMerkleRoot(MERKLE_ROOT, randomNonce, signature); + vm.stopPrank(); + } + + function test_UpdateMerkleRoot_InvalidUpdater() public { + uint64 currentNonce = registry.getCurrentNonce(); + + bytes memory signature = _signMerkleRoot(userPrivKey, currentNonce); + + vm.prank(user); + vm.expectRevert(Errors.WhitelistRegistry__NotAuthorized.selector); + registry.updateMerkleRoot(MERKLE_ROOT, currentNonce, signature); + } + + function test_UpdateMerkleRoot_RightKeyRandomUpdater() public { + uint64 currentNonce = registry.getCurrentNonce(); + + bytes memory signature = _signMerkleRoot(updaterPrivKey, currentNonce); + + vm.prank(user); + registry.updateMerkleRoot(MERKLE_ROOT, currentNonce, signature); + } + + // replay attack + function test_UpdateMerkleRoot_OldSignatureWithCurrentNonce() public { + // set the root1 + uint64 currentNonce = registry.getCurrentNonce(); + bytes memory signature = _signMerkleRoot(updaterPrivKey, currentNonce); + vm.prank(updater); + registry.updateMerkleRoot(MERKLE_ROOT, currentNonce, signature); + bytes32 currentRoot = registry.getCurrentMerkleRoot(); + assertEq(currentRoot, MERKLE_ROOT); + // set another root2 + + bytes32 newRoot = keccak256(abi.encodePacked("another merkle root")); + uint64 newNonce = registry.getCurrentNonce(); + bytes32 messageHash = keccak256(abi.encodePacked(newRoot, newNonce, block.chainid, address(registry))); + bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash(); + bytes memory signatureTwo = _sign(ethSignedMessageHash, updaterPrivKey); + + vm.prank(updater); + registry.updateMerkleRoot(newRoot, newNonce, signatureTwo); + bytes32 updatedRoot = registry.getCurrentMerkleRoot(); + assertEq(updatedRoot, newRoot); + + // try to use old sig from root1 to update it one more time + uint64 finalNonce = registry.getCurrentNonce(); + vm.prank(updater); + vm.expectRevert(Errors.WhitelistRegistry__NotAuthorized.selector); + registry.updateMerkleRoot(MERKLE_ROOT, finalNonce, signature); + assertEq(registry.getCurrentMerkleRoot(), newRoot); + } + + function test_UpdateRoot_InvalidNonce_OldNonce() public { + test_UpdateMerkleRoot(); + + bytes32 newRoot = keccak256(abi.encodePacked("attempted replay root")); + uint64 oldNonce = 0; + + bytes32 hash = keccak256(abi.encodePacked(newRoot, oldNonce, block.chainid, address(registry))); + bytes32 signedHash = hash.toEthSignedMessageHash(); + bytes memory signature = _sign(signedHash, updaterPrivKey); + + vm.prank(updater); + vm.expectRevert(Errors.WhitelistRegistry__InvalidNonce.selector); + registry.updateMerkleRoot(newRoot, oldNonce, signature); + } + + function test_UpdateRoot_InvalidNonce_FutureNonce() public { + uint64 currentNonce = registry.getCurrentNonce(); + + bytes32 newRoot = keccak256(abi.encodePacked("future nonce root")); + uint64 futureNonce = currentNonce + 1; + + bytes32 hash = keccak256(abi.encodePacked(newRoot, futureNonce, block.chainid, address(registry))); + bytes32 signedHash = hash.toEthSignedMessageHash(); + bytes memory signature = _sign(signedHash, updaterPrivKey); + + vm.prank(updater); + vm.expectRevert(Errors.WhitelistRegistry__InvalidNonce.selector); + registry.updateMerkleRoot(newRoot, futureNonce, signature); + } + + function test_UpdateMerkleRoot_WhitelistVerification() public { + uint64 currentNonce = registry.getCurrentNonce(); + bytes memory signature = _signMerkleRoot(updaterPrivKey, currentNonce); + + registry.updateMerkleRoot(MERKLE_ROOT, currentNonce, signature); + + bool isValidBefore = registry.verifyWhitelist(PROOF_USER1, user1); + assertTrue(isValidBefore); + + bytes32 newRoot = 0x5e287fa07343625f048462384a5432c590d780ed2c5f765210ef0e2e3ebddcfe; + uint64 newNonce = registry.getCurrentNonce(); + bytes32 hash = keccak256(abi.encodePacked(newRoot, newNonce, block.chainid, address(registry))); + bytes memory newSig = _sign(hash.toEthSignedMessageHash(), updaterPrivKey); + + registry.updateMerkleRoot(newRoot, newNonce, newSig); + + bool isValidAfter = registry.verifyWhitelist(PROOF_USER1, user1); + assertFalse(isValidAfter); + } + + /* -------------------------------------------------------------------------- */ + /* requestWhitelist */ + /* -------------------------------------------------------------------------- */ + + function test_RequestWhitelist_Lifecycle() public { + vm.deal(user, 100 ether); + + vm.startPrank(user); + registry.requestWhitelist{value: TC.REQUEST_FEE}(); + uint256 firstRequestTime = registry.getLastRequestedTime(user); + assertEq(firstRequestTime, block.timestamp); + vm.stopPrank(); + + vm.warp(block.timestamp + TC.REQUEST_COOLDOWN - 1 seconds); + vm.startPrank(user); + vm.expectRevert(Errors.WhitelistRegistry__RequestTooFrequent.selector); + registry.requestWhitelist{value: TC.REQUEST_FEE}(); + vm.stopPrank(); + + vm.warp(block.timestamp + 2 seconds); + + vm.startPrank(user); + registry.requestWhitelist{value: TC.REQUEST_FEE}(); + uint256 secondRequestTime = registry.getLastRequestedTime(user); + assertEq(secondRequestTime, block.timestamp); + assertTrue(secondRequestTime > firstRequestTime); + vm.stopPrank(); + + assertEq(registry.getTotalCollectedFees(), TC.REQUEST_FEE * 2); + } + + function test_RequestWhitelist_CooldownResetSuccess() public { + uint256 requestFee = registry.getRequestFee(); + + vm.deal(user, requestFee * 2); + + vm.prank(user); + registry.requestWhitelist{value: requestFee}(); + + uint256 cooldown = registry.getRequestCooldown(); + vm.warp(block.timestamp + cooldown + 1); + + uint256 totalCollectedFeesBefore = registry.getTotalCollectedFees(); + + uint256 blockTimestampBefore = block.timestamp; + + vm.prank(user); + registry.requestWhitelist{value: requestFee}(); + + assertEq(registry.getLastRequestedTime(user), blockTimestampBefore); + assertEq(registry.getTotalCollectedFees(), totalCollectedFeesBefore + requestFee); + } + + function test_RequestWhitelist_EconomicFlow() public { + address user2 = makeAddr("user2"); + vm.deal(user, 10 ether); + vm.deal(user2, 10 ether); + + vm.prank(user); + registry.requestWhitelist{value: TC.REQUEST_FEE}(); + + uint256 overpayment = TC.REQUEST_FEE * 2; + vm.prank(user2); + registry.requestWhitelist{value: overpayment}(); + + uint256 expectedTotal = TC.REQUEST_FEE + overpayment; + assertEq(registry.getTotalCollectedFees(), expectedTotal); + assertEq(address(registry).balance, expectedTotal); + + uint256 adminBalanceBefore = updater.balance; + + vm.prank(updater); + registry.withdraw(); + + assertEq(address(registry).balance, 0); + assertEq(updater.balance, adminBalanceBefore + expectedTotal); + assertEq(registry.getTotalCollectedFees(), 0); + } + + function test_RequestWhitelist_IndependentCooldowns() public { + address user2 = makeAddr("user2"); + vm.deal(user, 10 ether); + vm.deal(user2, 10 ether); + + vm.prank(user); + registry.requestWhitelist{value: TC.REQUEST_FEE}(); + + vm.warp(block.timestamp + 1); + + vm.prank(user); + vm.expectRevert(Errors.WhitelistRegistry__RequestTooFrequent.selector); + registry.requestWhitelist{value: TC.REQUEST_FEE}(); + + vm.prank(user2); + bool success = registry.requestWhitelist{value: TC.REQUEST_FEE}(); + assertTrue(success); + + assertEq(registry.getLastRequestedTime(user2), block.timestamp); + assertEq(registry.getLastRequestedTime(user), block.timestamp - 1); + } + + /* -------------------------------------------------------------------------- */ + /* withdraw */ + /* -------------------------------------------------------------------------- */ + + function test_Withdraw_FailsIfRecipientCannotReceive() public { + MaliciousReceiver malReceiver = new MaliciousReceiver(); + + vm.startPrank(updater); + registry.grantRole(TC.WITHDRAW_ROLE, address(malReceiver)); + vm.stopPrank(); + + vm.deal(user, 10 ether); + vm.prank(user); + registry.requestWhitelist{value: TC.REQUEST_FEE}(); + + vm.prank(address(malReceiver)); + vm.expectRevert(Errors.WhitelistRegistry__WithdrawFailed.selector); + registry.withdraw(); + } + + function test_Withdraw_MultipleCycles() public { + vm.deal(user, 50 ether); + address user2 = makeAddr("user2"); + vm.deal(user2, 50 ether); + + vm.prank(user); + registry.requestWhitelist{value: TC.REQUEST_FEE}(); + + uint256 balBefore1 = updater.balance; + vm.prank(updater); + registry.withdraw(); + assertEq(updater.balance, balBefore1 + TC.REQUEST_FEE); + assertEq(registry.getTotalCollectedFees(), 0); + + vm.warp(block.timestamp + 25 hours); + vm.prank(user); + registry.requestWhitelist{value: TC.REQUEST_FEE}(); + + vm.prank(user2); + registry.requestWhitelist{value: TC.REQUEST_FEE}(); + + assertEq(registry.getTotalCollectedFees(), TC.REQUEST_FEE * 2); + + uint256 balBefore2 = updater.balance; + vm.prank(updater); + registry.withdraw(); + + assertEq(updater.balance, balBefore2 + (TC.REQUEST_FEE * 2)); + assertEq(address(registry).balance, 0); + } + + function test_Withdraw_RoleRotation() public { + address newManager = makeAddr("newManager"); + vm.deal(user, 10 ether); + + vm.prank(user); + registry.requestWhitelist{value: TC.REQUEST_FEE}(); + + vm.prank(newManager); + vm.expectRevert(Errors.WhitelistRegistry__NotAuthorized.selector); + registry.withdraw(); + + vm.startPrank(updater); + + registry.grantRole(TC.WITHDRAW_ROLE, newManager); + registry.revokeRole(TC.WITHDRAW_ROLE, updater); + + vm.stopPrank(); + + vm.prank(updater); + vm.expectRevert(Errors.WhitelistRegistry__NotAuthorized.selector); + registry.withdraw(); + + uint256 managerBalanceBefore = newManager.balance; + + vm.prank(newManager); + registry.withdraw(); + + assertEq(newManager.balance, managerBalanceBefore + TC.REQUEST_FEE); + assertEq(registry.getTotalCollectedFees(), 0); + + assertTrue(registry.hasRole(TC.WITHDRAW_ROLE, newManager)); + assertFalse(registry.hasRole(TC.WITHDRAW_ROLE, updater)); + } + + /* -------------------------------------------------------------------------- */ + /* authorizedUpdater */ + /* -------------------------------------------------------------------------- */ + + function test_AddAuthorizedUpdater_CanSignUpdates() public { + (address newUpdater, uint256 newUpdaterPrivKey) = makeAddrAndKey("newUpdater"); + + vm.prank(updater); + registry.addAuthorizedUpdater(newUpdater); + + assertTrue(registry.isAuthorizedUpdater(newUpdater)); + + uint64 nonce = registry.getCurrentNonce(); + bytes32 messageHash = keccak256(abi.encodePacked(MERKLE_ROOT, nonce, block.chainid, address(registry))); + bytes memory signature = _sign(messageHash.toEthSignedMessageHash(), newUpdaterPrivKey); + + registry.updateMerkleRoot(MERKLE_ROOT, nonce, signature); + + assertEq(registry.getCurrentMerkleRoot(), MERKLE_ROOT); + } + + function test_AddAuthorizedUpdater_DoesNotGrantAdminOrWithdraw() public { + address newUpdater = makeAddr("newUpdater"); + + vm.prank(updater); + registry.addAuthorizedUpdater(newUpdater); + + assertFalse(registry.hasRole(TC.DEFAULT_ADMIN_ROLE, newUpdater)); + assertFalse(registry.hasRole(TC.WITHDRAW_ROLE, newUpdater)); + + vm.deal(address(registry), 1 ether); + + vm.prank(newUpdater); + vm.expectRevert(Errors.WhitelistRegistry__NotAuthorized.selector); + registry.withdraw(); + + address anotherGuy = makeAddr("anotherGuy"); + vm.prank(newUpdater); + vm.expectRevert(Errors.WhitelistRegistry__NotAuthorized.selector); + registry.addAuthorizedUpdater(anotherGuy); + } + + function test_AddAuthorizedUpdater_BothKeysWork() public { + (address newUpdater, uint256 newUpdaterPrivKey) = makeAddrAndKey("newUpdater"); + + vm.prank(updater); + registry.addAuthorizedUpdater(newUpdater); + + uint64 nonce1 = registry.getCurrentNonce(); + bytes32 root1 = keccak256(abi.encodePacked("root1")); + + bytes32 messageHash1 = keccak256(abi.encodePacked(root1, nonce1, block.chainid, address(registry))); + bytes memory sig1 = _sign(messageHash1.toEthSignedMessageHash(), updaterPrivKey); + + registry.updateMerkleRoot(root1, nonce1, sig1); + assertEq(registry.getCurrentMerkleRoot(), root1); + + uint64 nonce2 = registry.getCurrentNonce(); + bytes32 root2 = keccak256(abi.encodePacked("root2")); + + bytes32 messageHash2 = keccak256(abi.encodePacked(root2, nonce2, block.chainid, address(registry))); + bytes memory sig2 = _sign(messageHash2.toEthSignedMessageHash(), newUpdaterPrivKey); + + registry.updateMerkleRoot(root2, nonce2, sig2); + assertEq(registry.getCurrentMerkleRoot(), root2); + } + + function test_AuthorizedUpdater_Workflow() public { + assertEq(registry.getCurrentMerkleRoot(), bytes32(0)); + (address newUpdater, uint256 newUpdaterPrivKey) = makeAddrAndKey("newUpdater"); + + vm.prank(updater); + registry.addAuthorizedUpdater(newUpdater); + + uint64 nonce = registry.getCurrentNonce(); + bytes32 messageHash = keccak256(abi.encodePacked(MERKLE_ROOT, nonce, block.chainid, address(registry))); + bytes memory signature = _sign(messageHash.toEthSignedMessageHash(), newUpdaterPrivKey); + + vm.prank(newUpdater); + registry.updateMerkleRoot(MERKLE_ROOT, nonce, signature); + assertEq(registry.getCurrentMerkleRoot(), MERKLE_ROOT); + + vm.prank(TC.UPDATER); + registry.removeAuthorizedUpdater(newUpdater); + assertFalse(registry.isAuthorizedUpdater(newUpdater)); + + uint64 newNonce = registry.getCurrentNonce(); + bytes32 newRoot = keccak256(abi.encodePacked("new root")); + bytes32 newMessageHash = keccak256(abi.encodePacked(newRoot, newNonce, block.chainid, address(registry))); + bytes memory newSignature = _sign(newMessageHash.toEthSignedMessageHash(), newUpdaterPrivKey); + + vm.prank(newUpdater); + vm.expectRevert(Errors.WhitelistRegistry__NotAuthorized.selector); + registry.updateMerkleRoot(newRoot, newNonce, newSignature); + assertNotEq(registry.getCurrentMerkleRoot(), newRoot); + + vm.prank(TC.UPDATER); + registry.addAuthorizedUpdater(newUpdater); + assertTrue(registry.isAuthorizedUpdater(newUpdater)); + + uint64 finalNonce = registry.getCurrentNonce(); + bytes32 finalMessageHash = keccak256(abi.encodePacked(newRoot, finalNonce, block.chainid, address(registry))); + bytes memory finalSignature = _sign(finalMessageHash.toEthSignedMessageHash(), newUpdaterPrivKey); + + vm.prank(newUpdater); + registry.updateMerkleRoot(newRoot, finalNonce, finalSignature); + assertEq(registry.getCurrentMerkleRoot(), newRoot); + } + + /* -------------------------------------------------------------------------- */ + /* pause/unpause */ + /* -------------------------------------------------------------------------- */ + + function test_PauseUnpause_Workflow() public { + uint64 currentNonce = registry.getCurrentNonce(); + uint256 fee = registry.getRequestFee(); + vm.deal(user, 10 ether); + + vm.startPrank(updater); + registry.pause(); + + bytes memory signature = _signMerkleRoot(updaterPrivKey, currentNonce); + vm.expectRevert(Pausable.EnforcedPause.selector); + registry.updateMerkleRoot(MERKLE_ROOT, uint64(currentNonce), signature); + vm.stopPrank(); + + vm.prank(user); + vm.expectRevert(Pausable.EnforcedPause.selector); + registry.requestWhitelist{value: fee}(); + + vm.startPrank(updater); + registry.unpause(); + registry.updateMerkleRoot(MERKLE_ROOT, uint64(currentNonce), signature); + vm.stopPrank(); + + vm.prank(user); + registry.requestWhitelist{value: fee}(); + } +} diff --git a/contracts/test/integration/SettlementIntegration.t.sol b/contracts/test/integration/SettlementIntegration.t.sol new file mode 100644 index 0000000..bacadaa --- /dev/null +++ b/contracts/test/integration/SettlementIntegration.t.sol @@ -0,0 +1,1295 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Test, console} from "forge-std/Test.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +import {IntegrationDeployHelpers} from "../utils/IntegrationDeployHelpers.sol"; +import {TestConstants as TC} from "../utils/TestConstants.sol"; + +import {ISettlement} from "../../src/interfaces/ISettlement.sol"; + +import {Types} from "../../src/libraries/Types.sol"; +import {Errors} from "../../src/libraries/Errors.sol"; + +contract SettlementIntegrationTest is Test, IntegrationDeployHelpers { + using MessageHashUtils for bytes32; + + struct ExecuteData { + bytes32[] txProof; + bytes32[] wlProof; + Types.TransferData data; + } + + // Updated Merkle root including batchSalt + bytes32 constant BATCH_MERKLE_ROOT = 0x3a0c41421185f03cda4c7149849489222399b27838a28ef7931c459b142b0877; + bytes32 constant WHITELIST_MERKLE_ROOT = 0x9026d8a85fee65817561c5d02b985f4e34a8f70d19b21f5382e13c646a71176a; + + function setUp() public { + _initUser(); + _initUser2(); + _initFeeModule(); + _initRegistry(); + _initSettlement(); + _initToken(); + + vm.startPrank(DEFAULT_SENDER); + feeModule.setSettlement(address(settlement)); + settlement.setWhitelistRegistry(address(registry)); + vm.stopPrank(); + + vm.startPrank(TC.UPDATER); + registry.addAuthorizedUpdater(user); + + vm.startPrank(DEFAULT_SENDER); + settlement.setFeeModule(address(feeModule)); + settlement.setMaxTxPerBatch(uint32(TC.MAX_TX_PER_BATCH)); + settlement.setTimelockDuration(uint48(TC.TIMELOCK_DURATION)); + settlement.setToken(address(mockToken)); + settlement.approveAggregator(user2); + _updateMerkleRoot(); + vm.stopPrank(); + } + + /* -------------------------------------------------------------------------- */ + /* INITIAL STATE */ + /* -------------------------------------------------------------------------- */ + + function test_Constructor_InitialValues() public view { + assert(address(settlement.getFeeModule()) == address(feeModule)); + assert(address(settlement.getWhitelistRegistry()) == address(registry)); + assert(address(settlement.getToken()) == address(mockToken)); + assert(settlement.getMaxTxPerBatch() == TC.MAX_TX_PER_BATCH); + assert(settlement.getTimelockDuration() == TC.TIMELOCK_DURATION); + + assert(settlement.isApprovedAggregator(user2)); + } + + /* -------------------------------------------------------------------------- */ + /* HELPERS */ + /* -------------------------------------------------------------------------- */ + + function _updateMerkleRoot() public returns (bytes memory signature) { + uint64 currentNonce = registry.getCurrentNonce(); + bytes32 hash = + keccak256(abi.encodePacked(WHITELIST_MERKLE_ROOT, currentNonce, block.chainid, address(registry))); + bytes32 signedHash = hash.toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivKey, signedHash); + signature = abi.encodePacked(r, s, v); + + registry.updateMerkleRoot(WHITELIST_MERKLE_ROOT, currentNonce, signature); + } + + function _mintTokensAndApprove(address to, uint256 amount) internal { + mockToken.mint(to, amount); + vm.prank(to); + mockToken.approve(address(settlement), amount); + } + + function _submitBatch() public { + vm.prank(user2); + settlement.submitBatch(BATCH_MERKLE_ROOT, uint32(TC.MAX_TX_PER_BATCH), 1); + vm.warp(25 hours); + } + + /* -------------------------------------------------------------------------- */ + /* submitBatch */ + /* -------------------------------------------------------------------------- */ + + function test_SubmitBatch_EmitsEvent() public { + vm.prank(user2); + + vm.expectEmit(true, false, false, true); + emit ISettlement.BatchSubmitted(1, BATCH_MERKLE_ROOT, uint32(TC.MAX_TX_PER_BATCH), uint48(block.timestamp)); + (bool success, uint256 batchId) = settlement.submitBatch(BATCH_MERKLE_ROOT, uint32(TC.MAX_TX_PER_BATCH), 1); + + Types.Batch memory batch = settlement.getBatchById(uint64(batchId)); + assertTrue(success); + assertEq(settlement.getCurrentBatchId(), batchId); + + assertEq(batch.merkleRoot, BATCH_MERKLE_ROOT); + assertEq(batch.timestamp, block.timestamp); + assertEq(batch.txCount, TC.MAX_TX_PER_BATCH); + assertEq(batch.unlockTime, block.timestamp + TC.TIMELOCK_DURATION); + } + + function test_SubmitBatch_InvalidTxCount() public { + vm.startPrank(user2); + + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.submitBatch(BATCH_MERKLE_ROOT, 0, 1); + + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.submitBatch(BATCH_MERKLE_ROOT, uint32(TC.MAX_TX_PER_BATCH) + 1, 1); + + vm.stopPrank(); + } + + function test_SubmitBatch_AlreadySubmitted() public { + _submitBatch(); + + vm.prank(user2); + vm.expectRevert(Errors.Settlement__BatchAlreadySubmitted.selector); + settlement.submitBatch(BATCH_MERKLE_ROOT, uint32(TC.MAX_TX_PER_BATCH), 1); + } + + function test_SubmitBatch_DynamicConfigChanges() public { + uint32 newMaxTx = 5; + uint48 newTimelock = 2 days; + + vm.startPrank(DEFAULT_SENDER); + settlement.setMaxTxPerBatch(uint32(newMaxTx)); + settlement.setTimelockDuration(newTimelock); + vm.stopPrank(); + + vm.startPrank(user2); + uint32 oldLimitTxCount = uint32(TC.MAX_TX_PER_BATCH); + + assertGt(oldLimitTxCount, newMaxTx); + + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.submitBatch(BATCH_MERKLE_ROOT, oldLimitTxCount, 1); + + (bool success, uint256 batchId) = settlement.submitBatch(BATCH_MERKLE_ROOT, newMaxTx, 1); + assertTrue(success); + + Types.Batch memory batch = settlement.getBatchById(uint64(batchId)); + assertEq(batch.unlockTime, block.timestamp + newTimelock); + vm.stopPrank(); + } + + function test_SubmitBatch_BatchIdIncrement() public { + vm.prank(user2); + (bool success1, uint256 batchId1) = settlement.submitBatch(BATCH_MERKLE_ROOT, uint32(TC.MAX_TX_PER_BATCH), 1); + assertTrue(success1); + + vm.warp(block.timestamp + 1 hours); + + bytes32 newRoot = keccak256(abi.encodePacked("newRoot")); + vm.prank(user2); + (bool success2, uint256 batchId2) = settlement.submitBatch(newRoot, uint32(TC.MAX_TX_PER_BATCH), 1); + assertTrue(success2); + + assertEq(batchId2, batchId1 + 1); + } + + function test_SubmitBatch_StateIntegrity() public { + bytes32 root1 = keccak256(abi.encodePacked("root1")); + bytes32 root2 = keccak256(abi.encodePacked("root2")); + + vm.startPrank(user2); + + (, uint256 batchId1) = settlement.submitBatch(root1, uint32(TC.MAX_TX_PER_BATCH), 1); + + vm.warp(block.timestamp + 1 hours); + (, uint256 batchId2) = settlement.submitBatch(root2, uint32(TC.MAX_TX_PER_BATCH), 1); + + vm.stopPrank(); + + assertEq(batchId2, batchId1 + 1); + assertEq(settlement.getBatchIdByRoot(root1), batchId1); + assertEq(settlement.getBatchIdByRoot(root2), batchId2); + + Types.Batch memory batch1Data = settlement.getBatchById(uint64(batchId1)); + assertEq(batch1Data.merkleRoot, root1); + assert(batch1Data.timestamp < settlement.getBatchById(uint64(batchId2)).timestamp); + } + + function test_SubmitBatch_AggregatorLifecycle() public { + _submitBatch(); + + vm.prank(user2); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user2)); + settlement.disapproveAggregator(user2); + + vm.prank(DEFAULT_SENDER); + settlement.disapproveAggregator(user2); + + bytes32 newRoot = keccak256(abi.encodePacked("newRoot")); + vm.prank(user2); + vm.expectRevert(Errors.Settlement__AggregatorNotApproved.selector); + settlement.submitBatch(newRoot, uint32(TC.MAX_TX_PER_BATCH), 1); + + vm.prank(user2); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user2)); + settlement.approveAggregator(user2); + + vm.prank(DEFAULT_SENDER); + settlement.approveAggregator(user2); + + vm.prank(user2); + (bool success,) = settlement.submitBatch(newRoot, uint32(TC.MAX_TX_PER_BATCH), 1); + assertTrue(success); + } + + function testFuzz_SubmitBatch_Success(bytes32 merkleRoot, uint256 txCount) public { + txCount = bound(txCount, 1, TC.MAX_TX_PER_BATCH); + + vm.assume(merkleRoot != bytes32(0)); + vm.assume(merkleRoot != BATCH_MERKLE_ROOT); + + vm.prank(user2); + (bool success, uint256 batchId) = settlement.submitBatch(merkleRoot, uint32(txCount), 1); + + assertTrue(success); + + Types.Batch memory batch = settlement.getBatchById(uint64(batchId)); + assertEq(batch.merkleRoot, merkleRoot); + assertEq(batch.txCount, txCount); + assertEq(settlement.getBatchIdByRoot(merkleRoot), batchId); + } + + function testFuzz_SubmitBatch_RevertIfMaxTxExceeded(bytes32 merkleRoot, uint256 txCount) public { + txCount = bound(txCount, TC.MAX_TX_PER_BATCH + 1, type(uint32).max); + + vm.prank(user2); + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.submitBatch(merkleRoot, uint32(txCount), 1); + } + + /* -------------------------------------------------------------------------- */ + /* executeTransfer */ + /* -------------------------------------------------------------------------- */ + + function test_ExecuteTransfer_SuccessAndEmits() public { + _submitBatch(); + ExecuteData memory executeData = _getExecuteDataForIndex11(); + + _mintTokensAndApprove(executeData.data.from, executeData.data.amount); + + vm.expectEmit(true, true, false, true); + emit ISettlement.TransferExecuted( + executeData.data.from, executeData.data.to, executeData.data.amount, executeData.data.nonce + ); + + bool success = settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + assertTrue(success); + assertEq(mockToken.balanceOf(executeData.data.from), 0); + assertEq(mockToken.balanceOf(executeData.data.to), executeData.data.amount); + } + + function test_ExecuteTransfer_RevertIfBeforeUnlock() public { + vm.prank(user2); + settlement.submitBatch(BATCH_MERKLE_ROOT, uint32(TC.MAX_TX_PER_BATCH), 1); + vm.warp(block.timestamp + TC.TIMELOCK_DURATION - 1); + + ExecuteData memory executeData = _getExecuteDataForIndex11(); + + vm.expectRevert(Errors.Settlement__BatchLocked.selector); + settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + } + + function test_ExecuteTransfer_RevertIfAlreadyExecuted() public { + _submitBatch(); + ExecuteData memory executeData = _getExecuteDataForIndex11(); + + _mintTokensAndApprove(executeData.data.from, executeData.data.amount * 2); + + settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + vm.expectRevert(Errors.Settlement__TransferAlreadyExecuted.selector); + settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + } + + function test_ExecuteTransfer_RevertIfInsufficientBalance() public { + _submitBatch(); + ExecuteData memory executeData = _getExecuteDataForIndex11(); + + _mintTokensAndApprove(executeData.data.from, executeData.data.amount - 1); + + vm.expectRevert(Errors.Settlement__InsufficientBalance.selector); + settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + } + + function test_ExecuteTransfer_RevertIfInvalidTxProof() public { + _submitBatch(); + ExecuteData memory executeData = _getExecuteDataForIndex11(); + + executeData.txProof[0] = bytes32(uint256(executeData.txProof[0]) + 1); + + vm.expectRevert(Errors.Settlement__InvalidMerkleProof.selector); + settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + } + + function test_ExecuteTransfer_RevertIfInvalidBatch() public { + ExecuteData memory executeData = _getExecuteDataForIndex11(); + executeData.data.batchId = 999; + + vm.expectRevert(Errors.Settlement__InvalidBatch.selector); + settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + } + + function test_ExecuteTransfer_RevertIfBatchIdMismatch() public { + vm.startPrank(user2); + settlement.submitBatch(BATCH_MERKLE_ROOT, uint32(TC.MAX_TX_PER_BATCH), 1); + bytes32 otherRoot = keccak256("other"); + (, uint64 batchB) = settlement.submitBatch(otherRoot, uint32(TC.MAX_TX_PER_BATCH), 1); + vm.stopPrank(); + + vm.warp(25 hours); + ExecuteData memory executeData = _getExecuteDataForIndex11(); + executeData.data.batchId = batchB; + vm.expectRevert(Errors.Settlement__InvalidMerkleProof.selector); + settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + } + + function test_ExecuteTransfer_Batched_NoWhitelistProof_Reverts() public { + _submitBatch(); + ExecuteData memory executeData = _getExecuteDataForIndex13(); // BATCHED + + // Видаляємо whitelist proof повністю + bytes32[] memory emptyProof = new bytes32[](0); + executeData.wlProof = emptyProof; + + _mintTokensAndApprove(executeData.data.from, executeData.data.amount); + + vm.expectRevert(Errors.Settlement__NotWhitelisted.selector); + bool success = settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + assertFalse(success); + assertEq(mockToken.balanceOf(executeData.data.from), executeData.data.amount); + assertEq(mockToken.balanceOf(executeData.data.to), 0); + } + + function test_ExecuteTransfer_Batched_InvalidWhitelistProof_Reverts() public { + _submitBatch(); + ExecuteData memory executeData = _getExecuteDataForIndex13(); // BATCHED + + executeData.wlProof[0] = keccak256(abi.encodePacked("invalid")); + + _mintTokensAndApprove(executeData.data.from, executeData.data.amount); + + vm.expectRevert(Errors.Settlement__NotWhitelisted.selector); + bool success = settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + assertFalse(success); + assertEq(mockToken.balanceOf(executeData.data.from), executeData.data.amount); + assertEq(mockToken.balanceOf(executeData.data.to), 0); + } + + function test_ExecuteTransfer_NonBatched_SkipsWhitelistValidation() public { + _submitBatch(); + { + ExecuteData memory instantData = _getExecuteDataForIndex11(); + instantData.data.txType = Types.TxType.INSTANT; + bytes32[] memory emptyProof = new bytes32[](0); + instantData.wlProof = emptyProof; + + uint256 balanceBefore = mockToken.balanceOf(instantData.data.to); + + _mintTokensAndApprove(instantData.data.from, instantData.data.amount); + bool successInstant = settlement.executeTransfer(instantData.txProof, instantData.wlProof, instantData.data); + assertTrue(successInstant); + + uint256 balanceAfter = mockToken.balanceOf(instantData.data.to); + assertEq(balanceAfter, balanceBefore + instantData.data.amount); + } + + { + ExecuteData memory delayedData = _getExecuteDataForIndex0(); + delayedData.data.txType = Types.TxType.DELAYED; + bytes32[] memory emptyProof = new bytes32[](0); + delayedData.wlProof = emptyProof; + + uint256 balanceBefore = mockToken.balanceOf(delayedData.data.to); + + _mintTokensAndApprove(delayedData.data.from, delayedData.data.amount); + bool successDelayed = settlement.executeTransfer(delayedData.txProof, delayedData.wlProof, delayedData.data); + assertTrue(successDelayed); + + uint256 balanceAfter = mockToken.balanceOf(delayedData.data.to); + assertEq(balanceAfter, balanceBefore + delayedData.data.amount); + } + } + + /* -------------------------------------------------------------------------- */ + /* Fee Types Tests */ + /* -------------------------------------------------------------------------- */ + + function test_ExecuteTransfer_FreeWithDelayedFee() public { + _submitBatch(); + uint64 batchId = settlement.getCurrentBatchId(); + ExecuteData memory executeData = _getExecuteDataForIndex0(); + executeData.data.txType = Types.TxType.DELAYED; + executeData.data.amount = 100000000; + + uint256 expectedFee = 0; + + _mintTokensAndApprove(executeData.data.from, executeData.data.amount); + + bool success = settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + assertTrue(success); + assertEq(mockToken.balanceOf(executeData.data.from), 0); + assertEq(mockToken.balanceOf(executeData.data.to), executeData.data.amount); + + bytes32 txHash = keccak256( + abi.encodePacked( + executeData.data.from, + executeData.data.to, + executeData.data.amount, + executeData.data.nonce, + executeData.data.timestamp, + executeData.data.recipientCount, + executeData.data.txType, + uint64(1) // batchSalt + ) + ); + + assertEq(feeModule.getFeeOfTransaction(txHash), expectedFee); + assertEq(feeModule.getBatchTotalFees(executeData.data.batchId), expectedFee); + } + + function test_ExecuteTransfer_Delayed_InvalidFrom() public { + _submitBatch(); + ExecuteData memory executeData = _getExecuteDataForIndex17(); + + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + bool success = settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + assertFalse(success); + } + + function test_ExecuteTransfer_Delayed_InvalidTo() public { + _submitBatch(); + ExecuteData memory executeData = _getExecuteDataForIndex18(); + + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + bool success = settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + assertFalse(success); + } + + function test_ExecuteTransfer_WithInstantFee() public { + _submitBatch(); + uint64 batchId = settlement.getCurrentBatchId(); + ExecuteData memory executeData = _getExecuteDataForIndex11(); + + executeData.data.txType = Types.TxType.INSTANT; + executeData.data.amount = 200000000; // Less than LARGE_VOLUME + + // INSTANT_FEE = 200_000 + uint256 expectedFee = 200_000; + + _mintTokensAndApprove(executeData.data.from, executeData.data.amount); + + bool success = settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + assertTrue(success); + assertEq(mockToken.balanceOf(executeData.data.from), 0); + assertEq(mockToken.balanceOf(executeData.data.to), executeData.data.amount); + + bytes32 txHash = keccak256( + abi.encodePacked( + executeData.data.from, + executeData.data.to, + executeData.data.amount, + executeData.data.nonce, + executeData.data.timestamp, + executeData.data.recipientCount, + executeData.data.txType, + uint64(1) // batchSalt + ) + ); + + assertEq(feeModule.getFeeOfTransaction(txHash), expectedFee); + assertEq(feeModule.getBatchTotalFees(executeData.data.batchId), expectedFee); + } + + function test_ExecuteTransfer_Instant_InvalidRecipientCount() public { + _submitBatch(); + ExecuteData memory executeData = _getExecuteDataForIndex12(); + + executeData.data.txType = Types.TxType.INSTANT; + executeData.data.amount = 300000000; + + _mintTokensAndApprove(executeData.data.from, executeData.data.amount); + + vm.expectRevert(Errors.FeeModule__InvalidRecipientCount.selector); + bool success = settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + assertFalse(success); + assertEq(mockToken.balanceOf(executeData.data.from), executeData.data.amount); + assertEq(mockToken.balanceOf(executeData.data.to), 0); + } + + function test_ExecuteTransfer_WithBatchedFee() public { + _submitBatch(); + uint64 batchId = settlement.getCurrentBatchId(); + ExecuteData memory executeData = _getExecuteDataForIndex13(); + + uint256 expectedFee = TC.BATCH_FEE * executeData.data.recipientCount; + + _mintTokensAndApprove(executeData.data.from, executeData.data.amount); + + bool success = settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + assertTrue(success); + assertEq(mockToken.balanceOf(executeData.data.from), 0); + assertEq(mockToken.balanceOf(executeData.data.to), executeData.data.amount); + + bytes32 txHash = keccak256( + abi.encodePacked( + executeData.data.from, + executeData.data.to, + executeData.data.amount, + executeData.data.nonce, + executeData.data.timestamp, + executeData.data.recipientCount, + executeData.data.txType, + uint64(1) // batchSalt + ) + ); + + assertEq(feeModule.getFeeOfTransaction(txHash), expectedFee); + assertEq(feeModule.getBatchTotalFees(executeData.data.batchId), expectedFee); + assertEq(feeModule.getTotalFeesCollected(), expectedFee); + } + + function test_ExecuteTransfer_Batched_NotWhitelisted() public { + _submitBatch(); + ExecuteData memory executeData = _getExecuteDataForIndex14(); + + _mintTokensAndApprove(executeData.data.from, executeData.data.amount); + + vm.expectRevert(Errors.Settlement__NotWhitelisted.selector); + bool success = settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + assertFalse(success); + assertEq(mockToken.balanceOf(executeData.data.from), executeData.data.amount); + assertEq(mockToken.balanceOf(executeData.data.to), 0); + } + + function test_ExecuteTransfer_Batched_InvalidRecipientCount() public { + _submitBatch(); + ExecuteData memory executeData = _getExecuteDataForIndex15(); + + _mintTokensAndApprove(executeData.data.from, executeData.data.amount); + + vm.expectRevert(Errors.FeeModule__InvalidRecipientCount.selector); + bool success = settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + assertFalse(success); + assertEq(mockToken.balanceOf(executeData.data.from), executeData.data.amount); + assertEq(mockToken.balanceOf(executeData.data.to), 0); + } + + function test_ExecuteTransfer_WithFreeTier() public { + _submitBatch(); + uint64 batchId = settlement.getCurrentBatchId(); + ExecuteData memory executeData = _getExecuteDataForIndex10(); + + executeData.data.txType = Types.TxType.FREE_TIER; + executeData.data.amount = 50000000; + + // First free transaction - no fee + uint256 expectedFee = 0; + + _mintTokensAndApprove(executeData.data.from, executeData.data.amount); + + // Check remaining free tier before + uint256 remainingBefore = feeModule.getRemainingFreeTierTransactions(executeData.data.from); + assertEq(remainingBefore, 10); // All 10 available + + bool success = settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + assertTrue(success); + assertEq(mockToken.balanceOf(executeData.data.from), 0); + assertEq(mockToken.balanceOf(executeData.data.to), executeData.data.amount); + + bytes32 txHash = keccak256( + abi.encodePacked( + executeData.data.from, + executeData.data.to, + executeData.data.amount, + executeData.data.nonce, + executeData.data.timestamp, + executeData.data.recipientCount, + executeData.data.txType, + uint64(1) // batchSalt + ) + ); + + assertEq(feeModule.getFeeOfTransaction(txHash), expectedFee); + + // Check remaining free tier after + uint256 remainingAfter = feeModule.getRemainingFreeTierTransactions(executeData.data.from); + assertEq(remainingAfter, 9); // 1 used + } + + function test_ExecuteTransfer_DelayedFeeWithinFreeTier() public { + _submitBatch(); + uint64 batchId = settlement.getCurrentBatchId(); + ExecuteData memory executeData = _getExecuteDataForIndex0(); + + uint256 expectedFee = 0; + + _mintTokensAndApprove(executeData.data.from, executeData.data.amount); + + bool success = settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + assertTrue(success); + assertEq(mockToken.balanceOf(executeData.data.from), 0); + assertEq(mockToken.balanceOf(executeData.data.to), executeData.data.amount); + + bytes32 txHash = keccak256( + abi.encodePacked( + executeData.data.from, + executeData.data.to, + executeData.data.amount, + executeData.data.nonce, + executeData.data.timestamp, + executeData.data.recipientCount, + executeData.data.txType, + uint64(1) // batchSalt + ) + ); + + assertEq(feeModule.getFeeOfTransaction(txHash), expectedFee); + uint256 remaining = feeModule.getRemainingFreeTierTransactions(executeData.data.from); + assertEq(remaining, 9); + } + + function test_ExecuteTransfer_LargeVolumeNoFee() public { + _submitBatch(); + uint64 batchId = settlement.getCurrentBatchId(); + ExecuteData memory executeData = _getExecuteDataForIndex16(); + + executeData.data.txType = Types.TxType.INSTANT; + executeData.data.amount = 1000000000000000; + + // No fee for large volumes + uint256 expectedFee = 0; + + _mintTokensAndApprove(executeData.data.from, executeData.data.amount); + + bool success = settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + assertTrue(success); + assertEq(mockToken.balanceOf(executeData.data.from), 0); + assertEq(mockToken.balanceOf(executeData.data.to), executeData.data.amount); + + bytes32 txHash = keccak256( + abi.encodePacked( + executeData.data.from, + executeData.data.to, + executeData.data.amount, + executeData.data.nonce, + executeData.data.timestamp, + executeData.data.recipientCount, + executeData.data.txType, + uint64(1) // batchSalt + ) + ); + + assertEq(feeModule.getFeeOfTransaction(txHash), expectedFee); + assertEq(feeModule.getBatchTotalFees(executeData.data.batchId), expectedFee); + } + + function test_ExecuteTransfer_DelayedTxFlow() public { + _submitBatch(); + ExecuteData memory executeData0 = _getExecuteDataForIndex0(); + + _mintTokensAndApprove(executeData0.data.from, executeData0.data.amount); + + bool success = settlement.executeTransfer(executeData0.txProof, executeData0.wlProof, executeData0.data); + assertTrue(success); + + ExecuteData memory executeData1 = _getExecuteDataForIndex1(); + _mintTokensAndApprove(executeData1.data.from, executeData1.data.amount); + + bool success1 = settlement.executeTransfer(executeData1.txProof, executeData1.wlProof, executeData1.data); + assertTrue(success1); + + ExecuteData memory executeData2 = _getExecuteDataForIndex2(); + _mintTokensAndApprove(executeData2.data.from, executeData2.data.amount); + + bool success2 = settlement.executeTransfer(executeData2.txProof, executeData2.wlProof, executeData2.data); + assertTrue(success2); + + // 3 + ExecuteData memory executeData3 = _getExecuteDataForIndex3(); + _mintTokensAndApprove(executeData3.data.from, executeData3.data.amount); + + bool success3 = settlement.executeTransfer(executeData3.txProof, executeData3.wlProof, executeData3.data); + assertTrue(success3); + + // 4 + ExecuteData memory executeData4 = _getExecuteDataForIndex4(); + _mintTokensAndApprove(executeData4.data.from, executeData4.data.amount); + + bool success4 = settlement.executeTransfer(executeData4.txProof, executeData4.wlProof, executeData4.data); + assertTrue(success4); + + // 5 + ExecuteData memory executeData5 = _getExecuteDataForIndex5(); + _mintTokensAndApprove(executeData5.data.from, executeData5.data.amount); + + bool success5 = settlement.executeTransfer(executeData5.txProof, executeData5.wlProof, executeData5.data); + assertTrue(success5); + + // 6 + ExecuteData memory executeData6 = _getExecuteDataForIndex6(); + _mintTokensAndApprove(executeData6.data.from, executeData6.data.amount); + + bool success6 = settlement.executeTransfer(executeData6.txProof, executeData6.wlProof, executeData6.data); + assertTrue(success6); + + // 7 + ExecuteData memory executeData7 = _getExecuteDataForIndex7(); + _mintTokensAndApprove(executeData7.data.from, executeData7.data.amount); + + bool success7 = settlement.executeTransfer(executeData7.txProof, executeData7.wlProof, executeData7.data); + assertTrue(success7); + + // 8 + ExecuteData memory executeData8 = _getExecuteDataForIndex8(); + _mintTokensAndApprove(executeData8.data.from, executeData8.data.amount); + + bool success8 = settlement.executeTransfer(executeData8.txProof, executeData8.wlProof, executeData8.data); + assertTrue(success8); + + // 9 + ExecuteData memory executeData9 = _getExecuteDataForIndex9(); + _mintTokensAndApprove(executeData9.data.from, executeData9.data.amount); + + bool success9 = settlement.executeTransfer(executeData9.txProof, executeData9.wlProof, executeData9.data); + assertTrue(success9); + + bytes32 txHash9 = keccak256( + abi.encodePacked( + executeData9.data.from, + executeData9.data.to, + executeData9.data.amount, + executeData9.data.nonce, + executeData9.data.timestamp, + executeData9.data.recipientCount, + executeData9.data.txType + ) + ); + assertEq(feeModule.getFeeOfTransaction(txHash9), 0); + + // 10 + ExecuteData memory executeData10 = _getExecuteDataForIndex10(); + _mintTokensAndApprove(executeData10.data.from, executeData10.data.amount); + + vm.expectRevert(Errors.FeeModule__FreeTierLimitExceeded.selector); + bool success10 = settlement.executeTransfer(executeData10.txProof, executeData10.wlProof, executeData10.data); + assertFalse(success10); + } + + function test_ExecuteTransfer_GasComparison() public { + _submitBatch(); + + // === DELAYED (free tier) === + ExecuteData memory delayedData = _getExecuteDataForIndex0(); + _mintTokensAndApprove(delayedData.data.from, delayedData.data.amount); + + uint256 gasStartDelayed = gasleft(); + settlement.executeTransfer(delayedData.txProof, delayedData.wlProof, delayedData.data); + uint256 gasUsedDelayed = gasStartDelayed - gasleft(); + + // === INSTANT === + ExecuteData memory instantData = _getExecuteDataForIndex11(); + _mintTokensAndApprove(instantData.data.from, instantData.data.amount); + + uint256 gasStartInstant = gasleft(); + settlement.executeTransfer(instantData.txProof, instantData.wlProof, instantData.data); + uint256 gasUsedInstant = gasStartInstant - gasleft(); + + // === BATCHED === + ExecuteData memory batchedData = _getExecuteDataForIndex13(); + _mintTokensAndApprove(batchedData.data.from, batchedData.data.amount); + + uint256 gasStartBatched = gasleft(); + settlement.executeTransfer(batchedData.txProof, batchedData.wlProof, batchedData.data); + uint256 gasUsedBatched = gasStartBatched - gasleft(); + + console.log("=== GAS COMPARISON ==="); + console.log("DELAYED (free): ", gasUsedDelayed); + console.log("INSTANT: ", gasUsedInstant); + console.log("BATCHED (15 rec):", gasUsedBatched); + + assertGt(gasUsedDelayed, 0); + assertGt(gasUsedInstant, 0); + assertGt(gasUsedBatched, 0); + } + + /* -------------------------------------------------------------------------- */ + /* pause/unpause */ + /* -------------------------------------------------------------------------- */ + + function test_ExecuteTransfer_PauseUnpause_Workflow() public { + _submitBatch(); + ExecuteData memory executeData = _getExecuteDataForIndex11(); + + _mintTokensAndApprove(executeData.data.from, executeData.data.amount); + + vm.prank(DEFAULT_SENDER); + settlement.pause(); + + vm.expectRevert(Pausable.EnforcedPause.selector); + settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + vm.prank(DEFAULT_SENDER); + settlement.unpause(); + + bool success = settlement.executeTransfer(executeData.txProof, executeData.wlProof, executeData.data); + + assertTrue(success); + assertEq(mockToken.balanceOf(executeData.data.from), 0); + assertEq(mockToken.balanceOf(executeData.data.to), executeData.data.amount); + } + + /* -------------------------------------------------------------------------- */ + /* executeData HEPLERS */ + /* -------------------------------------------------------------------------- */ + + function _getExecuteDataForIndex11() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](5); + txProof[0] = 0x462abd36dedb15579a43289e45666716fa82e276865699156f10f01fce09bea4; + txProof[1] = 0x591ff5b157a63b6c10acb4331c80fe4c013f946c177848663e17c995d065ab6c; + txProof[2] = 0x8097f9b2b4adcb7ea03968a76be829838c7e54478a37edf3c2060e1626be7fb9; + txProof[3] = 0x31994ea0b0c611d5c8673e93d7acac8ce3c40c1a7ff9612645345abc39d59975; + txProof[4] = 0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc; + executeData.txProof = txProof; + + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, + amount: 200000000, + nonce: 12, + timestamp: 1766392480, + recipientCount: 1, + batchId: 1, + txType: Types.TxType.INSTANT + }); + } + + function _getExecuteDataForIndex13() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](5); + txProof[0] = 0x7401529a3f64f3280807f8c1c25476cb42f45a8d53eb4a8ce0489ca439530b43; + txProof[1] = 0x30265017c12a0d5f16aa7c34dea1bf8fc8554bbdc356287671971c4a4da6b460; + txProof[2] = 0x210818ded81351b6dc7f13a560472c3185d0bae960d8f7212190674caacdcfa8; + txProof[3] = 0x31994ea0b0c611d5c8673e93d7acac8ce3c40c1a7ff9612645345abc39d59975; + txProof[4] = 0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc; + executeData.txProof = txProof; + + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC, + amount: 500000000, + nonce: 14, + timestamp: 1766392482, + recipientCount: 5, + batchId: 1, + txType: Types.TxType.BATCHED + }); + } + + function _getExecuteDataForIndex14() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](5); + txProof[0] = 0xac933fbe94da3ad81a1b622b7e9e83dce453dc6588b908ec4e39edc0afb91dc6; + txProof[1] = 0x287d3ddd35e0c598f46919e410628799adaf9b4d25fcd921e17b0077e12bde90; + txProof[2] = 0x210818ded81351b6dc7f13a560472c3185d0bae960d8f7212190674caacdcfa8; + txProof[3] = 0x31994ea0b0c611d5c8673e93d7acac8ce3c40c1a7ff9612645345abc39d59975; + txProof[4] = 0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc; + executeData.txProof = txProof; + + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + + executeData.data = Types.TransferData({ + from: 0x1234567890123456789012345678901234567890, + to: 0x90F79bf6EB2c4f870365E785982E1f101E93b906, + amount: 150000000, + nonce: 15, + timestamp: 1766392483, + recipientCount: 3, + batchId: 1, + txType: Types.TxType.BATCHED + }); + } + + function _getExecuteDataForIndex15() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](5); + txProof[0] = 0x864e732fa92158038c741b630577f00889c249ae8441ddaff05c2e185153afc6; + txProof[1] = 0x287d3ddd35e0c598f46919e410628799adaf9b4d25fcd921e17b0077e12bde90; + txProof[2] = 0x210818ded81351b6dc7f13a560472c3185d0bae960d8f7212190674caacdcfa8; + txProof[3] = 0x31994ea0b0c611d5c8673e93d7acac8ce3c40c1a7ff9612645345abc39d59975; + txProof[4] = 0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc; + executeData.txProof = txProof; + + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, + amount: 100000000, + nonce: 16, + timestamp: 1766392484, + recipientCount: 1, + batchId: 1, + txType: Types.TxType.BATCHED + }); + } + + function _getExecuteDataForIndex12() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](5); + txProof[0] = 0x560083d61bbad176074a8ff65fa7a2b20cbc6080b8176f55d47bdd63503e7b49; + txProof[1] = 0x30265017c12a0d5f16aa7c34dea1bf8fc8554bbdc356287671971c4a4da6b460; + txProof[2] = 0x210818ded81351b6dc7f13a560472c3185d0bae960d8f7212190674caacdcfa8; + txProof[3] = 0x31994ea0b0c611d5c8673e93d7acac8ce3c40c1a7ff9612645345abc39d59975; + txProof[4] = 0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc; + executeData.txProof = txProof; + + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, + amount: 300000000, + nonce: 13, + timestamp: 1766392481, + recipientCount: 3, + batchId: 1, + txType: Types.TxType.INSTANT + }); + } + + function _getExecuteDataForIndex17() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](3); + txProof[0] = 0xa477e0f7047a672583f1234bbd1485e57bda886eeea3ea1c2f76e242083c7d85; + txProof[1] = 0x587b51946820bae3febac952ae82d0a10a2c1991bc887caf0c9831de2adb24bb; + txProof[2] = 0xb6576b13bfbf97dae871a1b20d938a53329e10800ce093213abb811e236b7f6c; + executeData.txProof = txProof; + + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + + executeData.data = Types.TransferData({ + from: 0x0000000000000000000000000000000000000000, + to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, + amount: 100000000, + nonce: 18, + timestamp: 1766392486, + recipientCount: 1, + batchId: 1, + txType: Types.TxType.DELAYED + }); + } + + function _getExecuteDataForIndex18() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](2); + txProof[0] = 0xc86dbe3ece3fe96d98622181ce07212d536e3217636b501a87e8a0e98b001a84; + txProof[1] = 0xb6576b13bfbf97dae871a1b20d938a53329e10800ce093213abb811e236b7f6c; + executeData.txProof = txProof; + + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x0000000000000000000000000000000000000000, + amount: 100000000, + nonce: 19, + timestamp: 1766392487, + recipientCount: 1, + batchId: 1, + txType: Types.TxType.DELAYED + }); + } + + function _getExecuteDataForIndex10() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](5); + txProof[0] = 0x24f873b47ae80c05f69983aace4819e4a005ab024673cc39313788f7a17d305b; + txProof[1] = 0x591ff5b157a63b6c10acb4331c80fe4c013f946c177848663e17c995d065ab6c; + txProof[2] = 0x8097f9b2b4adcb7ea03968a76be829838c7e54478a37edf3c2060e1626be7fb9; + txProof[3] = 0x31994ea0b0c611d5c8673e93d7acac8ce3c40c1a7ff9612645345abc39d59975; + txProof[4] = 0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc; + executeData.txProof = txProof; + + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, + amount: 50000000, + nonce: 11, + timestamp: 1766392479, + recipientCount: 1, + batchId: 1, + txType: Types.TxType.FREE_TIER + }); + } + + function _getExecuteDataForIndex0() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](5); txProof[0] = 0xaa343f658768354a32adde8928537360916413cc47445617177a82024c422cf7; + txProof[1] = 0xd5ec552c4f23fb2dbf907bf031e9e0d2e7c4a81c756071675b4a0625ad37f04c; + txProof[2] = 0xea1b9fa23ead5569d3206b76771c4f20e36694822032d195804bebccca4aec51; + txProof[3] = 0xb57edcb183a69f8f06421d1c7de51760cd1973914376637f50f10c3ed0fafeb2; + txProof[4] = 0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc; + + executeData.txProof = txProof; + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, + amount: 100000000, + nonce: 1, + timestamp: 1766392469, + recipientCount: 1, + batchId: 1, + txType: Types.TxType.DELAYED + }); + } + + function _getExecuteDataForIndex1() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](5); txProof[0] = 0xa230e58e7054695cc88f543500b68c91e2bf2460ea4f50ac925640251a0e9c45; + txProof[1] = 0xd5ec552c4f23fb2dbf907bf031e9e0d2e7c4a81c756071675b4a0625ad37f04c; + txProof[2] = 0xea1b9fa23ead5569d3206b76771c4f20e36694822032d195804bebccca4aec51; + txProof[3] = 0xb57edcb183a69f8f06421d1c7de51760cd1973914376637f50f10c3ed0fafeb2; + txProof[4] = 0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc; + + executeData.txProof = txProof; + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, + amount: 100000000, + nonce: 2, + timestamp: 1766392470, + recipientCount: 1, + batchId: 1, + txType: Types.TxType.DELAYED + }); + } + + function _getExecuteDataForIndex2() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](5); txProof[0] = 0x8d2fc6d794ed4c205b3516a6c58f6f87eac5b6324dc4e6f88d46ca0cd622e523; + txProof[1] = 0x33fe1a228e60193eb5abdba6048af955b49d849dc59ab5766873907ad10ad7f6; + txProof[2] = 0xea1b9fa23ead5569d3206b76771c4f20e36694822032d195804bebccca4aec51; + txProof[3] = 0xb57edcb183a69f8f06421d1c7de51760cd1973914376637f50f10c3ed0fafeb2; + txProof[4] = 0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc; + + executeData.txProof = txProof; + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, + amount: 100000000, + nonce: 3, + timestamp: 1766392471, + recipientCount: 1, + batchId: 1, + txType: Types.TxType.DELAYED + }); + } + + function _getExecuteDataForIndex3() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](5); txProof[0] = 0x520268ed8b0dc9d3da345cf990135351322b673e52f2f58656bce527e24ecb4f; + txProof[1] = 0x33fe1a228e60193eb5abdba6048af955b49d849dc59ab5766873907ad10ad7f6; + txProof[2] = 0xea1b9fa23ead5569d3206b76771c4f20e36694822032d195804bebccca4aec51; + txProof[3] = 0xb57edcb183a69f8f06421d1c7de51760cd1973914376637f50f10c3ed0fafeb2; + txProof[4] = 0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc; + + executeData.txProof = txProof; + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, + amount: 100000000, + nonce: 4, + timestamp: 1766392472, + recipientCount: 1, + batchId: 1, + txType: Types.TxType.DELAYED + }); + } + + function _getExecuteDataForIndex4() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](5); txProof[0] = 0x31d36b489cdd2b5dbeb0e095f19a3db47bf729f1dc646977c3347a530dfd638f; + txProof[1] = 0xee6a9ff5e1399fa946da5482a6f5d659903e7309c8747f7d22f554d54fc097e2; + txProof[2] = 0xcbc1b17bd75d49c23213016ca8a662f2adaee9977ad570f2930a8275b25bb398; + txProof[3] = 0xb57edcb183a69f8f06421d1c7de51760cd1973914376637f50f10c3ed0fafeb2; + txProof[4] = 0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc; + + executeData.txProof = txProof; + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, + amount: 100000000, + nonce: 5, + timestamp: 1766392473, + recipientCount: 1, + batchId: 1, + txType: Types.TxType.DELAYED + }); + } + + function _getExecuteDataForIndex5() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](5); txProof[0] = 0xe19c6d48248dbca0b142cf85dd63eee8608c91acfefebbdc15b0efd3708d53ff; + txProof[1] = 0xee6a9ff5e1399fa946da5482a6f5d659903e7309c8747f7d22f554d54fc097e2; + txProof[2] = 0xcbc1b17bd75d49c23213016ca8a662f2adaee9977ad570f2930a8275b25bb398; + txProof[3] = 0xb57edcb183a69f8f06421d1c7de51760cd1973914376637f50f10c3ed0fafeb2; + txProof[4] = 0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc; + + executeData.txProof = txProof; + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, + amount: 100000000, + nonce: 6, + timestamp: 1766392474, + recipientCount: 1, + batchId: 1, + txType: Types.TxType.DELAYED + }); + } + + function _getExecuteDataForIndex6() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](5); txProof[0] = 0x9f8a7e4c0b8338ac03dd39fb2d6fa8082cf1d32badb2fa29fa959f626793e191; + txProof[1] = 0xb3b6b7cdecd8ccdf8ba346985f530fbc878292d55f0ac9cda8689b4744570c7f; + txProof[2] = 0xcbc1b17bd75d49c23213016ca8a662f2adaee9977ad570f2930a8275b25bb398; + txProof[3] = 0xb57edcb183a69f8f06421d1c7de51760cd1973914376637f50f10c3ed0fafeb2; + txProof[4] = 0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc; + + executeData.txProof = txProof; + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, + amount: 100000000, + nonce: 7, + timestamp: 1766392475, + recipientCount: 1, + batchId: 1, + txType: Types.TxType.DELAYED + }); + } + + function _getExecuteDataForIndex7() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](5); txProof[0] = 0x3d5be0058769e8f9e9d472a4f48190b04ccfa6e7aa2fa412653ed22b7649c75c; + txProof[1] = 0xb3b6b7cdecd8ccdf8ba346985f530fbc878292d55f0ac9cda8689b4744570c7f; + txProof[2] = 0xcbc1b17bd75d49c23213016ca8a662f2adaee9977ad570f2930a8275b25bb398; + txProof[3] = 0xb57edcb183a69f8f06421d1c7de51760cd1973914376637f50f10c3ed0fafeb2; + txProof[4] = 0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc; + + executeData.txProof = txProof; + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, + amount: 100000000, + nonce: 8, + timestamp: 1766392476, + recipientCount: 1, + batchId: 1, + txType: Types.TxType.DELAYED + }); + } + + function _getExecuteDataForIndex8() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](5); txProof[0] = 0x5215e0e0db139e499cdf1c49c9eb5b1ec1de1eb4e527b8a5c9566128f5c20f05; + txProof[1] = 0x4c263c99b9e277450d7f2ef634c883775456dd4dffb96f46ae22fffa0ba532b1; + txProof[2] = 0x8097f9b2b4adcb7ea03968a76be829838c7e54478a37edf3c2060e1626be7fb9; + txProof[3] = 0x31994ea0b0c611d5c8673e93d7acac8ce3c40c1a7ff9612645345abc39d59975; + txProof[4] = 0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc; + + executeData.txProof = txProof; + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, + amount: 100000000, + nonce: 9, + timestamp: 1766392477, + recipientCount: 1, + batchId: 1, + txType: Types.TxType.DELAYED + }); + } + + function _getExecuteDataForIndex9() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](5); txProof[0] = 0xc8570da104d31b70fa27d5a6db45ef393d9150795802006294ee05aaaf99ff4c; + txProof[1] = 0x4c263c99b9e277450d7f2ef634c883775456dd4dffb96f46ae22fffa0ba532b1; + txProof[2] = 0x8097f9b2b4adcb7ea03968a76be829838c7e54478a37edf3c2060e1626be7fb9; + txProof[3] = 0x31994ea0b0c611d5c8673e93d7acac8ce3c40c1a7ff9612645345abc39d59975; + txProof[4] = 0xc4b8283ef7ff08ddc4b02fd8a783c2b52430b7a7b359b905c99b2c99248471dc; + + executeData.txProof = txProof; + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, + amount: 100000000, + nonce: 10, + timestamp: 1766392478, + recipientCount: 1, + batchId: 1, + txType: Types.TxType.DELAYED + }); + } + + function _getExecuteDataForIndex16() internal pure returns (ExecuteData memory executeData) { + bytes32[] memory txProof = new bytes32[](3); txProof[0] = 0xed3ccf36419833c97271315356d4320595eee8cc5119ee118bcdf1e2775bc52f; + txProof[1] = 0x587b51946820bae3febac952ae82d0a10a2c1991bc887caf0c9831de2adb24bb; + txProof[2] = 0xb6576b13bfbf97dae871a1b20d938a53329e10800ce093213abb811e236b7f6c; + executeData.txProof = txProof; + bytes32[] memory wlProof = new bytes32[](1); + wlProof[0] = 0x7ceb58780fb137bb02223b79c88bc6404f736f8bb4d1f0895d9884122804fb73; + executeData.wlProof = wlProof; + executeData.data = Types.TransferData({ + from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, + amount: 1000000000000000, + nonce: 17, + timestamp: 1766392485, + recipientCount: 1, + batchId: 1, + txType: Types.TxType.INSTANT + }); + } +} diff --git a/contracts/test/mocks/MaliciousReceiver.sol b/contracts/test/mocks/MaliciousReceiver.sol new file mode 100644 index 0000000..c2b6acb --- /dev/null +++ b/contracts/test/mocks/MaliciousReceiver.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +contract MaliciousReceiver { + fallback() external payable { + while (true) {} + } +} diff --git a/contracts/test/unit/FeeModuleUnit.t.sol b/contracts/test/unit/FeeModuleUnit.t.sol new file mode 100644 index 0000000..dbebc25 --- /dev/null +++ b/contracts/test/unit/FeeModuleUnit.t.sol @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {TestConstants as TC} from "../utils/TestConstants.sol"; + +import {FeeModule} from "../../src/FeeModule.sol"; +import {IFeeModule} from "../../src/interfaces/IFeeModule.sol"; + +import {Types} from "../../src/libraries/Types.sol"; +import {Errors} from "../../src/libraries/Errors.sol"; + +contract FeeModuleUnitTest is Test { + FeeModule feeModule; + address settlementAddr; + address user; + address owner; + + function setUp() public { + owner = makeAddr("owner"); + + vm.prank(owner); + feeModule = new FeeModule(); + settlementAddr = makeAddr("settlement"); + user = makeAddr("user"); + + vm.prank(owner); + feeModule.setSettlement(settlementAddr); + } + + /* -------------------------------------------------------------------------- */ + /* INITIAL STATE */ + /* -------------------------------------------------------------------------- */ + + function test_Constructor_InitialValues() public view { + assertEq(feeModule.owner(), owner); + assertEq(feeModule.getSettlement(), settlementAddr); + } + + /* -------------------------------------------------------------------------- */ + /* calculateFee */ + /* -------------------------------------------------------------------------- */ + + function test_CalculateFee_InvalidInput_Reverts() public { + // sender + vm.expectRevert(Errors.FeeModule__InvalidInput.selector); + feeModule.calculateFee(address(0), Types.TxType.INSTANT, TC.VOLUME, 1); + + // volume + vm.expectRevert(Errors.FeeModule__InvalidInput.selector); + feeModule.calculateFee(user, Types.TxType.INSTANT, 0, 1); + + // recipient count + vm.expectRevert(Errors.FeeModule__InvalidInput.selector); + feeModule.calculateFee(user, Types.TxType.INSTANT, TC.VOLUME, 0); + + // recipient count + vm.expectRevert(Errors.FeeModule__InvalidRecipientCount.selector); + feeModule.calculateFee(user, Types.TxType.INSTANT, TC.VOLUME, 3); + + // recipient count + vm.expectRevert(Errors.FeeModule__InvalidRecipientCount.selector); + feeModule.calculateFee(user, Types.TxType.BATCHED, TC.VOLUME, 1); + + vm.expectRevert(Errors.FeeModule__InvalidInput.selector); + feeModule.calculateFee(address(0), Types.TxType.FREE_TIER, 0, 0); + } + + function test_CalculateFee_InstantFee() public view { + Types.FeeInfo memory info = feeModule.calculateFee(user, Types.TxType.INSTANT, TC.VOLUME, 1); + assertEq(info.fee, TC.INSTANT_FEE); + assertEq(uint256(info.txType), uint256(Types.TxType.INSTANT)); + } + + function test_CalculateFee_BatchedFee() public view { + uint256 recipientCount = 3; + Types.FeeInfo memory info = feeModule.calculateFee(user, Types.TxType.BATCHED, TC.VOLUME, recipientCount); + assertEq(info.fee, TC.BATCH_FEE * recipientCount); + assertEq(uint256(info.txType), uint256(Types.TxType.BATCHED)); + } + + function test_CalculateFee_LargeVolumeNoFee() public view { + Types.FeeInfo memory info = feeModule.calculateFee(user, Types.TxType.DELAYED, TC.LARGE_VOLUME, 1); + assertEq(info.fee, 0); + assertEq(uint256(info.txType), uint256(Types.TxType.DELAYED)); + } + + function test_CalculateFee_ReturnsCorrectFee() public view { + Types.FeeInfo memory info = feeModule.calculateFee(user, Types.TxType.INSTANT, TC.VOLUME, 1); + + assertEq(info.fee, TC.INSTANT_FEE); + assertEq(uint256(info.txType), uint256(Types.TxType.INSTANT)); + } + + function test_CalculateFee_FreeTier_NoStateChanges() public view { + Types.FeeInfo memory info = feeModule.calculateFee(user, Types.TxType.FREE_TIER, TC.VOLUME, 1); + + assertEq(info.fee, 0); + assertEq(uint256(info.txType), uint256(Types.TxType.FREE_TIER)); + } + + /* -------------------------------------------------------------------------- */ + /* applyFee */ + /* -------------------------------------------------------------------------- */ + + function test_ApplyFee_InvalidInput() public { + uint256 feeAmount = 100; + // sender + vm.expectRevert(Errors.FeeModule__InvalidInput.selector); + feeModule.applyFee(address(0), feeAmount, keccak256(abi.encodePacked("1")), 1, Types.TxType.DELAYED); + + // transferHash + vm.expectRevert(Errors.FeeModule__InvalidInput.selector); + feeModule.applyFee(user, feeAmount, bytes32(0), 1, Types.TxType.DELAYED); + + // batch id + vm.expectRevert(Errors.FeeModule__InvalidInput.selector); + feeModule.applyFee(user, feeAmount, keccak256(abi.encodePacked("1")), 0, Types.TxType.DELAYED); + } + + function test_ApplyFee_NotSettlement_Reverts() public { + vm.expectRevert(Errors.FeeModule__NotAuthorized.selector); + feeModule.applyFee(user, 1, keccak256(abi.encodePacked("1")), 1, Types.TxType.DELAYED); + } + + function test_ApplyFee_ZeroFeeSucceeds() public { + uint256 feeAmount = 0; + vm.prank(settlementAddr); + feeModule.applyFee(user, feeAmount, keccak256(abi.encodePacked("1")), 1, Types.TxType.DELAYED); + } + + function test_ApplyFee_Succeeds() public { + uint256 feeAmount = 200_000; + bytes32 transferHash = keccak256(abi.encodePacked("tx1")); + uint64 batchId = 1; + + // set transfer fee first + vm.prank(settlementAddr); + vm.expectEmit(true, false, false, true); + emit IFeeModule.FeeApplied(user, feeAmount, transferHash, batchId); + feeModule.applyFee(user, feeAmount, transferHash, batchId, Types.TxType.INSTANT); + + // verify totalFees and batchTotalFees updated + uint256 totalFees = feeModule.getTotalFeesCollected(); + assertEq(totalFees, feeAmount); + + uint256 batchFees = feeModule.getBatchTotalFees(batchId); + assertEq(batchFees, feeAmount); + } + + function test_ApplyFee_FreeTier_ConsumesQuota() public { + uint256 feeAmount = 0; + bytes32 transferHash = keccak256(abi.encodePacked("txFreeTier")); + uint64 batchId = 3; + + uint256 initialQuota = feeModule.getRemainingFreeTierTransactions(user); + + vm.prank(settlementAddr); + vm.expectEmit(true, false, false, true); + emit IFeeModule.FreeTierUsed(user, initialQuota - 1); + feeModule.applyFee(user, feeAmount, transferHash, batchId, Types.TxType.FREE_TIER); + + uint256 finalQuota = feeModule.getRemainingFreeTierTransactions(user); + assertEq(finalQuota, initialQuota - 1); + } + + /* -------------------------------------------------------------------------- */ + /* setSettlement */ + /* -------------------------------------------------------------------------- */ + + function test_SetSettlement_InvalidInput() public { + vm.prank(owner); + vm.expectRevert(Errors.FeeModule__InvalidInput.selector); + feeModule.setSettlement(address(0)); + } + + function test_SetSettlement_AlreadySettlement() public { + vm.prank(owner); + vm.expectRevert(Errors.FeeModule__AlreadySettlement.selector); + feeModule.setSettlement(settlementAddr); + } + + function test_SetSettlement_SetsAndEmits() public { + address newSettlement = makeAddr("newSettlement"); + vm.prank(owner); + vm.expectEmit(true, false, false, false); + emit IFeeModule.SettlementUpdated(newSettlement); + feeModule.setSettlement(newSettlement); + + assertEq(feeModule.getSettlement(), newSettlement); + } + + /* -------------------------------------------------------------------------- */ + /* GETTERS */ + /* -------------------------------------------------------------------------- */ + + function test_Getters() public { + assertEq(feeModule.getSettlement(), settlementAddr); + + assertEq(feeModule.getOwner(), owner); + + assertEq(feeModule.getFreeTxUsage(user).count, 0); + assertEq(feeModule.getFreeTxUsage(user).day, 0); + + assertEq(feeModule.getRemainingFreeTierTransactions(user), 10); + + uint256 feeAmount = 200_000; + bytes32 transferHash = keccak256(abi.encodePacked("tx2")); + uint64 batchId = 2; + vm.prank(settlementAddr); + feeModule.applyFee(user, feeAmount, transferHash, batchId, Types.TxType.INSTANT); + + uint256 fee = feeModule.getFeeOfTransaction(transferHash); + assertEq(fee, feeAmount); + + assertEq(feeModule.getTotalFeesCollected(), feeAmount); + assertEq(feeModule.getBatchTotalFees(batchId), feeAmount); + } +} diff --git a/contracts/test/unit/RegistryUnit.t.sol b/contracts/test/unit/RegistryUnit.t.sol new file mode 100644 index 0000000..c278248 --- /dev/null +++ b/contracts/test/unit/RegistryUnit.t.sol @@ -0,0 +1,486 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +import {WhitelistRegistry} from "../../src/WhitelistRegistry.sol"; +import {IWhitelistRegistry} from "../../src/interfaces/IWhitelistRegistry.sol"; + +import {MaliciousReceiver} from "../mocks/MaliciousReceiver.sol"; +import {Errors} from "../../src/libraries/Errors.sol"; + +contract WhitelistRegistryUnitTest is Test { + using MessageHashUtils for bytes32; + + WhitelistRegistry registry; + + address updater; + address randomUser; + uint256 updaterPk; + uint256 randomUserPk; + + bytes32 constant DEFAULT_ADMIN_ROLE = 0x00; + + function setUp() public { + (updater, updaterPk) = makeAddrAndKey("updater"); + (randomUser, randomUserPk) = makeAddrAndKey("randomUser"); + vm.deal(randomUser, 10 ether); + + registry = new WhitelistRegistry(updater); + } + + /* -------------------------------------------------------------------------- */ + /* HELPERS */ + /* -------------------------------------------------------------------------- */ + + function _sign(bytes32 hash, uint256 privKey) internal pure returns (bytes memory signature) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privKey, hash); + signature = abi.encodePacked(r, s, v); + } + + function _updateRoot() internal { + uint64 currentNonce = registry.getCurrentNonce(); + bytes32 root = keccak256(abi.encodePacked("new merkle root")); + + bytes32 hash = keccak256(abi.encodePacked(root, currentNonce, block.chainid, address(registry))); + bytes32 signedHash = hash.toEthSignedMessageHash(); + bytes memory sig = _sign(signedHash, updaterPk); + vm.prank(updater); + registry.updateMerkleRoot(root, currentNonce, sig); + } + + /* -------------------------------------------------------------------------- */ + /* INITIAL STATE */ + /* -------------------------------------------------------------------------- */ + + function test_Constructor_InitialValues() public view { + assertTrue(registry.hasRole(registry.getWithdrawRole(), updater)); + assertTrue(registry.hasRole(0x00, updater)); + assertTrue(registry.isAuthorizedUpdater(updater)); + assertFalse(registry.isAuthorizedUpdater(randomUser)); + } + + function test_Constructor_InvalidInput() public { + vm.expectRevert(Errors.WhitelistRegistry__InvalidInput.selector); + new WhitelistRegistry(address(0)); + } + + /* -------------------------------------------------------------------------- */ + /* updateMerkleRoot */ + /* -------------------------------------------------------------------------- */ + + function test_UpdateRoot_InvalidInput() public { + _updateRoot(); + + // invalid root + bytes32 invalidRoot = bytes32(0); + bytes memory signature = _sign("randomInput", updaterPk); + uint256 currentNonce = registry.getCurrentNonce(); + + vm.startPrank(updater); + + vm.expectRevert(Errors.WhitelistRegistry__InvalidInput.selector); + registry.updateMerkleRoot(invalidRoot, uint64(currentNonce), signature); + + // invalid signature length + bytes32 newRoot = keccak256(abi.encodePacked("new new merkle root")); + bytes memory invalidSignature = hex"1234"; + vm.expectRevert(); + registry.updateMerkleRoot(newRoot, uint64(currentNonce), invalidSignature); + + bytes memory emptySig = ""; + vm.expectRevert(Errors.WhitelistRegistry__InvalidInput.selector); + registry.updateMerkleRoot(newRoot, uint64(currentNonce), emptySig); + + // zero signature + bytes memory zeroSig = new bytes(65); + vm.expectRevert(); // без селектора + registry.updateMerkleRoot(newRoot, uint64(currentNonce), zeroSig); + + // invalid both + vm.expectRevert(Errors.WhitelistRegistry__InvalidInput.selector); + registry.updateMerkleRoot(invalidRoot, uint64(currentNonce), invalidSignature); + + vm.stopPrank(); + } + + function test_UpdateRoot_NonAuthorizedCaller() public { + bytes32 currentRoot = registry.getCurrentMerkleRoot(); + uint256 currentNonce = registry.getCurrentNonce(); + + vm.startPrank(randomUser); + bytes32 newRoot = keccak256(abi.encodePacked("new merkle root")); + + bytes32 hash = keccak256(abi.encodePacked(newRoot, currentNonce, block.chainid, address(registry))); + bytes32 signedHash = hash.toEthSignedMessageHash(); + bytes memory signature = _sign(signedHash, randomUserPk); + vm.stopPrank(); + + vm.prank(updater); + vm.expectRevert(Errors.WhitelistRegistry__NotAuthorized.selector); + registry.updateMerkleRoot(newRoot, uint64(currentNonce), signature); + + bytes32 rootAfter = registry.getCurrentMerkleRoot(); + assertEq(currentRoot, rootAfter); + } + + // random address with correct signature can update + function test_UpdateRoot_CorrectSignature() public { + bytes32 newRoot = keccak256(abi.encodePacked("new merkle root")); + uint64 currentNonce = registry.getCurrentNonce(); + + bytes32 hash = keccak256(abi.encodePacked(newRoot, currentNonce, block.chainid, address(registry))); + bytes32 signedHash = hash.toEthSignedMessageHash(); + bytes memory signature = _sign(signedHash, updaterPk); + + assertFalse(registry.isAuthorizedUpdater(randomUser)); + + vm.prank(randomUser); + registry.updateMerkleRoot(newRoot, currentNonce, signature); + + // verify that the root was not updated + bytes32 updatedRoot = registry.getCurrentMerkleRoot(); + assertEq(updatedRoot, registry.getCurrentMerkleRoot()); + } + + function test_UpdateRoot_DuplicateUpdate() public { + bytes32 sameRoot = bytes32(0); + uint64 currentNonce = registry.getCurrentNonce(); + + bytes memory signatureOne = _sign("randomInput", updaterPk); + + vm.prank(updater); + vm.expectRevert(Errors.WhitelistRegistry__DuplicateUpdate.selector); + registry.updateMerkleRoot(sameRoot, currentNonce, signatureOne); + + _updateRoot(); + + bytes32 currentRoot = registry.getCurrentMerkleRoot(); + uint64 currentNonceAfter = registry.getCurrentNonce(); + + // try to update with the same root + bytes32 hash = keccak256(abi.encodePacked(currentRoot, currentNonceAfter, block.chainid, address(registry))); + bytes32 signedHash = hash.toEthSignedMessageHash(); + bytes memory signatureTwo = _sign(signedHash, updaterPk); + + vm.prank(updater); + vm.expectRevert(Errors.WhitelistRegistry__DuplicateUpdate.selector); + registry.updateMerkleRoot(currentRoot, currentNonceAfter, signatureTwo); + + bytes32 rootAfter = registry.getCurrentMerkleRoot(); + assertEq(currentRoot, rootAfter); + } + + function test_UpdateRoot_Success() public { + assertTrue(registry.isAuthorizedUpdater(updater)); + bytes32 notUpdatedRoot = registry.getCurrentMerkleRoot(); + uint256 before = registry.getLastUpdateTime(); + uint64 currentNonce = registry.getCurrentNonce(); + bytes32 oldRoot = registry.getCurrentMerkleRoot(); + + vm.expectEmit(false, false, false, true); + emit IWhitelistRegistry.WhitelistUpdated(oldRoot, keccak256(abi.encodePacked("new merkle root")), currentNonce); + _updateRoot(); + + uint256 afterTime = registry.getLastUpdateTime(); + bytes32 updatedRoot = registry.getCurrentMerkleRoot(); + assertNotEq(updatedRoot, notUpdatedRoot); + assertTrue(afterTime != before); + assertEq(afterTime, block.timestamp); + } + + function test_UpdateRoot_SigByNewAuthorizedUpdater_Success() public { + uint64 currentNonce = registry.getCurrentNonce(); + + vm.prank(updater); + registry.addAuthorizedUpdater(randomUser); + + bytes32 newRoot = keccak256(abi.encodePacked("authorized new merkle root")); + + bytes32 hash = keccak256(abi.encodePacked(newRoot, currentNonce, block.chainid, address(registry))); + bytes32 signedHash = hash.toEthSignedMessageHash(); + bytes memory signature = _sign(signedHash, randomUserPk); + + registry.updateMerkleRoot(newRoot, currentNonce, signature); + + assertEq(registry.getCurrentMerkleRoot(), newRoot); + } + + /* -------------------------------------------------------------------------- */ + /* requestWhitelist */ + /* -------------------------------------------------------------------------- */ + + function test_RequestWhitelist_InsufficientFee() public { + uint256 requestFee = registry.getRequestFee(); + + vm.prank(randomUser); + vm.expectRevert(Errors.WhitelistRegistry__InsufficientFee.selector); + registry.requestWhitelist{value: requestFee - 1}(); + } + + function test_RequestWhitelist_RequestToFrequent() public { + uint256 requestFee = registry.getRequestFee(); + + vm.prank(randomUser); + registry.requestWhitelist{value: requestFee}(); + + vm.prank(randomUser); + vm.expectRevert(Errors.WhitelistRegistry__RequestTooFrequent.selector); + registry.requestWhitelist{value: requestFee}(); + } + + function test_RequestWhitelist_Success() public { + uint256 requestFee = registry.getRequestFee(); + + uint256 lastRequestedTimeBefore = registry.getLastRequestedTime(randomUser); + assertEq(lastRequestedTimeBefore, 0); + uint256 totalCollectedFeesBefore = registry.getTotalCollectedFees(); + + vm.prank(randomUser); + vm.expectEmit(true, false, false, false); + emit IWhitelistRegistry.WhitelistRequested(randomUser); + + uint256 blockTimestampBefore = block.timestamp; + + bool success = registry.requestWhitelist{value: requestFee}(); + assertTrue(success); + + uint256 lastRequestedTimeAfter = registry.getLastRequestedTime(randomUser); + uint256 totalCollectedFeesAfter = registry.getTotalCollectedFees(); + + assertEq(lastRequestedTimeAfter, blockTimestampBefore); + assertEq(totalCollectedFeesAfter, totalCollectedFeesBefore + requestFee); + } + + /* -------------------------------------------------------------------------- */ + /* withdraw */ + /* -------------------------------------------------------------------------- */ + + function test_Withdraw_NotAuthorized() public { + vm.prank(randomUser); + vm.expectRevert(Errors.WhitelistRegistry__NotAuthorized.selector); + registry.withdraw(); + } + + function test_Withdraw_TransferFailed() public { + vm.deal(address(registry), 1 ether); + + address maliciousReceiver = address(new MaliciousReceiver()); + + vm.startPrank(updater); + registry.grantRole(registry.getWithdrawRole(), maliciousReceiver); + vm.stopPrank(); + + vm.prank(maliciousReceiver); + vm.expectRevert(Errors.WhitelistRegistry__NothingToWithdraw.selector); + registry.withdraw(); + + vm.deal(randomUser, 10 ether); + vm.prank(randomUser); + registry.requestWhitelist{value: registry.getRequestFee()}(); + + vm.prank(maliciousReceiver); + vm.expectRevert(Errors.WhitelistRegistry__WithdrawFailed.selector); + registry.withdraw(); + assertEq(registry.getTotalCollectedFees(), registry.getRequestFee()); + assertEq(maliciousReceiver.balance, 0); + } + + function test_Withdraw_Success() public { + uint256 requestFee = registry.getRequestFee(); + + vm.prank(randomUser); + (bool success) = registry.requestWhitelist{value: requestFee}(); + assertTrue(success); + assertEq(registry.getTotalCollectedFees(), requestFee); + + uint256 updaterBalanceBefore = updater.balance; + + // Expect event + vm.prank(updater); + vm.expectEmit(true, false, false, true); + emit IWhitelistRegistry.WithdrawSuccess(updater, requestFee); + + registry.withdraw(); + + uint256 updaterBalanceAfter = updater.balance; + assertEq(updaterBalanceAfter, updaterBalanceBefore + requestFee); + assertEq(registry.getTotalCollectedFees(), 0); + } + + /* -------------------------------------------------------------------------- */ + /* addAuthorizedUpdater */ + /* -------------------------------------------------------------------------- */ + + function test_AddAuthorizedUpdater_NotAuthorized() public { + vm.prank(randomUser); + vm.expectRevert(Errors.WhitelistRegistry__NotAuthorized.selector); + registry.addAuthorizedUpdater(randomUser); + } + + function test_AddAuthorizedUpdater_InvalidInput() public { + vm.prank(updater); + vm.expectRevert(Errors.WhitelistRegistry__InvalidInput.selector); + registry.addAuthorizedUpdater(address(0)); + } + + function test_AddAuthorizedUpdater_AlreadyAuthorized() public { + assertTrue(registry.isAuthorizedUpdater(updater)); + + vm.prank(updater); + vm.expectRevert(Errors.WhitelistRegistry__AlreadyAuthorized.selector); + registry.addAuthorizedUpdater(updater); + } + + function test_AddAuthorizedUpdater_Success() public { + assertFalse(registry.isAuthorizedUpdater(randomUser)); + + vm.prank(updater); + vm.expectEmit(true, false, false, false); + emit IWhitelistRegistry.AuthorizedUpdaterAdded(randomUser); + registry.addAuthorizedUpdater(randomUser); + + assertTrue(registry.isAuthorizedUpdater(randomUser)); + } + + /* -------------------------------------------------------------------------- */ + /* removeAuthorizedUpdater */ + /* -------------------------------------------------------------------------- */ + + function test_RemoveAuthorizedUpdater_NotAuthorized() public { + vm.prank(randomUser); + vm.expectRevert(Errors.WhitelistRegistry__NotAuthorized.selector); + registry.removeAuthorizedUpdater(updater); + } + + function test_RemoveAuthorizedUpdater_InvalidInput() public { + vm.prank(updater); + vm.expectRevert(Errors.WhitelistRegistry__InvalidInput.selector); + registry.removeAuthorizedUpdater(address(0)); + } + + function test_RemoveAuthorizedUpdater_NotAuthorizedUpdater() public { + assertFalse(registry.isAuthorizedUpdater(randomUser)); + + vm.prank(updater); + vm.expectRevert(Errors.WhitelistRegistry__NotAuthorized.selector); + registry.removeAuthorizedUpdater(randomUser); + } + + function test_RemoveAuthorizedUpdater_Success() public { + assertTrue(registry.isAuthorizedUpdater(updater)); + + vm.prank(updater); + vm.expectEmit(true, false, false, false); + emit IWhitelistRegistry.AuthorizedUpdaterRemoved(updater); + registry.removeAuthorizedUpdater(updater); + + assertFalse(registry.isAuthorizedUpdater(updater)); + } + + /* -------------------------------------------------------------------------- */ + /* pause/unpause */ + /* -------------------------------------------------------------------------- */ + + function test_PauseUnpause_NotAuthorized() public { + vm.startPrank(randomUser); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, randomUser, registry.getDefaultAdminRole() + ) + ); + registry.pause(); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, randomUser, registry.getDefaultAdminRole() + ) + ); + registry.unpause(); + vm.stopPrank(); + } + + function test_PauseUnpause_Success() public { + vm.prank(updater); + registry.pause(); + assertTrue(registry.paused()); + + vm.prank(updater); + registry.unpause(); + assertFalse(registry.paused()); + } + + function test_UpdateMerkleRoot_RevertsWhenPaused() public { + vm.prank(updater); + registry.pause(); + assertTrue(registry.paused()); + + bytes32 newRoot = keccak256(abi.encodePacked("new merkle root")); + uint256 currentNonce = registry.getCurrentNonce(); + + bytes32 hash = keccak256(abi.encodePacked(newRoot, currentNonce, block.chainid, address(registry))); + bytes32 signedHash = hash.toEthSignedMessageHash(); + bytes memory signature = _sign(signedHash, updaterPk); + + vm.prank(randomUser); + vm.expectRevert(Pausable.EnforcedPause.selector); + registry.updateMerkleRoot(newRoot, uint64(currentNonce), signature); + } + + function test_RequestWhitelist_RevertsWhenPaused() public { + vm.prank(updater); + registry.pause(); + uint256 fee = registry.getRequestFee(); + + vm.prank(randomUser); + vm.expectRevert(Pausable.EnforcedPause.selector); + registry.requestWhitelist{value: fee}(); + } + + /* -------------------------------------------------------------------------- */ + /* GETTERS */ + /* -------------------------------------------------------------------------- */ + + function test_Getters_InitialValues() public view { + // initial merkle root and timestamps + assertEq(registry.getCurrentMerkleRoot(), bytes32(0)); + assertEq(registry.getTotalCollectedFees(), 0); + assertEq(registry.getLastUpdateTime(), 0); + + // authorized updater checks + + assertTrue(registry.isAuthorizedUpdater(updater)); + assertFalse(registry.isAuthorizedUpdater(randomUser)); + + // request-related getters + assertEq(registry.getLastRequestedTime(randomUser), 0); + assertEq(registry.getRequestCooldown(), 24 hours); + assertEq(registry.getRequestFee(), 10e6); + + // roles + assertEq(registry.getDefaultAdminRole(), DEFAULT_ADMIN_ROLE); + assertTrue(registry.isAdmin(updater)); + assertFalse(registry.isAdmin(randomUser)); + assertTrue(registry.isWithdrawer(updater)); + assertFalse(registry.isWithdrawer(randomUser)); + + bytes32 expectedWithdrawRole = keccak256(abi.encodePacked("WITHDRAW_ROLE")); + assertEq(registry.getWithdrawRole(), expectedWithdrawRole); + } + + function test_VerifyWhitelist_InvalidInputs() public { + bytes32[] memory emptyProof = new bytes32[](0); + + vm.expectRevert(Errors.WhitelistRegistry__InvalidInput.selector); + registry.verifyWhitelist(emptyProof, randomUser); + + bytes32[] memory someProof = new bytes32[](1); + someProof[0] = keccak256(abi.encodePacked("some")); + + vm.expectRevert(Errors.WhitelistRegistry__InvalidInput.selector); + registry.verifyWhitelist(someProof, address(0)); + } +} diff --git a/contracts/test/unit/SettlementUnit.sol b/contracts/test/unit/SettlementUnit.sol new file mode 100644 index 0000000..b5e4d23 --- /dev/null +++ b/contracts/test/unit/SettlementUnit.sol @@ -0,0 +1,605 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; + +import {Settlement} from "../../src/Settlement.sol"; +import {ISettlement} from "../../src/interfaces/ISettlement.sol"; + +import {TestConstants as TC} from "../utils/TestConstants.sol"; +import {Types} from "../../src/libraries/Types.sol"; +import {Errors} from "../../src/libraries/Errors.sol"; + +contract SettlementUnitTest is Test { + Settlement settlement; + address feeModule; + address registry; + address recipient; + address token; + address user; + address owner; + + function setUp() public { + owner = makeAddr("owner"); + feeModule = makeAddr("feeModule"); + registry = makeAddr("registry"); + recipient = makeAddr("recipient"); + token = makeAddr("token"); + user = makeAddr("user"); + + vm.startPrank(owner); + + settlement = new Settlement(); + settlement.setWhitelistRegistry(registry); + settlement.setFeeModule(feeModule); + settlement.setToken(token); + + settlement.setMaxTxPerBatch(uint32(TC.MAX_TX_PER_BATCH)); + settlement.setTimelockDuration(uint48(TC.TIMELOCK_DURATION)); + vm.stopPrank(); + } + + /* -------------------------------------------------------------------------- */ + /* HELPERS */ + /* -------------------------------------------------------------------------- */ + + function _createBatchData() internal pure returns (bytes32 merkleRoot, uint32 txCount) { + merkleRoot = keccak256(abi.encodePacked("merkle root")); + txCount = uint32(TC.MAX_TX_PER_BATCH); + } + + function _createTransferData() internal view returns (Types.TransferData memory) { + Types.TransferData memory txData = Types.TransferData({ + from: user, + to: recipient, + amount: 1000, + nonce: 1, + timestamp: uint48(block.timestamp), + recipientCount: 1, + batchId: 0, + txType: Types.TxType.DELAYED + }); + return txData; + } + + function _createMerkleProofs() + internal + pure + returns (bytes32[] memory validTxProof, bytes32[] memory validWhitelistProof) + { + validTxProof = new bytes32[](3); + validTxProof[0] = keccak256(abi.encodePacked("tx proof 1")); + validTxProof[1] = keccak256(abi.encodePacked("tx proof 2")); + validTxProof[2] = keccak256(abi.encodePacked("tx proof 3")); + + validWhitelistProof = new bytes32[](3); + validWhitelistProof[0] = keccak256(abi.encodePacked("whitelist proof 1")); + validWhitelistProof[1] = keccak256(abi.encodePacked("whitelist proof 2")); + validWhitelistProof[2] = keccak256(abi.encodePacked("whitelist proof 3")); + } + + /* -------------------------------------------------------------------------- */ + /* INITIAL STATE */ + /* -------------------------------------------------------------------------- */ + + function test_Constructor_InitialValues() public view { + assertEq(settlement.getWhitelistRegistry(), address(registry)); + assertEq(settlement.getFeeModule(), address(feeModule)); + assertEq(settlement.getToken(), address(token)); + assertEq(settlement.getMaxTxPerBatch(), TC.MAX_TX_PER_BATCH); + assertEq(settlement.getTimelockDuration(), TC.TIMELOCK_DURATION); + assertTrue(settlement.isConfigured()); + } + + function test_IsConfigured() public { + Settlement unconfiguredSettlement = new Settlement(); + assertFalse(unconfiguredSettlement.isConfigured()); + + unconfiguredSettlement.setWhitelistRegistry(registry); + unconfiguredSettlement.setFeeModule(feeModule); + unconfiguredSettlement.setToken(token); + + assertTrue(unconfiguredSettlement.isConfigured()); + } + + /* -------------------------------------------------------------------------- */ + /* submitBatch */ + /* -------------------------------------------------------------------------- */ + + function test_SubmitBatch_AggregatorNotApproved() public { + (bytes32 merkleRoot, uint256 txCount) = _createBatchData(); + + vm.prank(user); + vm.expectRevert(Errors.Settlement__AggregatorNotApproved.selector); + settlement.submitBatch(merkleRoot, uint32(txCount), 1); + } + + function test_SubmitBatch_InvalidInput() public { + (bytes32 merkleRoot, uint32 txCount) = _createBatchData(); + uint32 zeroTxCount = 0; + bytes32 zeroMerkleRoot = bytes32(0); + + vm.startPrank(owner); + + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.submitBatch(zeroMerkleRoot, txCount, 1); + + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.submitBatch(merkleRoot, zeroTxCount, 1); + + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.submitBatch(merkleRoot, txCount + 1, 1); + + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.submitBatch(zeroMerkleRoot, zeroTxCount, 1); + + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.submitBatch(zeroMerkleRoot, txCount + 1, 1); + + vm.stopPrank(); + } + + function test_SubmitBatch_AlreadySubmitted() public { + (bytes32 merkleRoot, uint32 txCount) = _createBatchData(); + + vm.startPrank(owner); + settlement.submitBatch(merkleRoot, txCount, 1); + + vm.expectRevert(Errors.Settlement__BatchAlreadySubmitted.selector); + settlement.submitBatch(merkleRoot, txCount, 1); + + vm.stopPrank(); + } + + function test_SubmitBatch_SuccessAndEmits() public { + uint256 initialBatchId = settlement.getCurrentBatchId(); + assertEq(initialBatchId, 0); + (bytes32 merkleRoot, uint32 txCount) = _createBatchData(); + + vm.prank(owner); + vm.expectEmit(true, false, false, true); + emit ISettlement.BatchSubmitted(1, merkleRoot, txCount, uint48(block.timestamp)); + + (bool success, uint64 returnedBatchId) = settlement.submitBatch(merkleRoot, txCount, 1); + + assertTrue(success); + assertEq(returnedBatchId, 1); + + uint256 newBatchId = settlement.getCurrentBatchId(); + + Types.Batch memory submittedBatch = settlement.getBatchById(uint64(newBatchId)); + assertEq(newBatchId, initialBatchId + 1); + assertEq(submittedBatch.merkleRoot, merkleRoot); + assertEq(submittedBatch.txCount, txCount); + assertEq(submittedBatch.timestamp, block.timestamp); + assertEq(submittedBatch.unlockTime, block.timestamp + settlement.getTimelockDuration()); + + assertEq(settlement.getBatchIdByRoot(merkleRoot), newBatchId); + } + + /* -------------------------------------------------------------------------- */ + /* executeTransfer */ + /* -------------------------------------------------------------------------- */ + + function test_ExecuteTransfer_NotConfigured() public { + Settlement unconfiguredSettlement = new Settlement(); + + (bytes32[] memory txProof, bytes32[] memory whitelistProof) = _createMerkleProofs(); + Types.TransferData memory txData = _createTransferData(); + + vm.expectRevert(Errors.Settlement__NotConfigured.selector); + unconfiguredSettlement.executeTransfer(txProof, whitelistProof, txData); + } + + function test_ExecuteTransfer_InvalidInput() public { + bytes32[] memory invalidTxProof = new bytes32[](0); + bytes32[] memory invalidWhitelistProof = new bytes32[](0); + + (bytes32[] memory validTxProof, bytes32[] memory validWhitelistProof) = _createMerkleProofs(); + Types.TransferData memory txData = _createTransferData(); + + // invalid txProof + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.executeTransfer(invalidTxProof, validWhitelistProof, txData); + + // invalid whitelistProof + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.executeTransfer(validTxProof, invalidWhitelistProof, txData); + + // invalid both + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.executeTransfer(invalidTxProof, invalidWhitelistProof, txData); + + // zero batch + txData.batchId = 0; + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.executeTransfer(validTxProof, validWhitelistProof, txData); + + // zero amount + txData.amount = 0; + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.executeTransfer(validTxProof, validWhitelistProof, txData); + } + + function test_ExecuteTransfer_InvalidTxData() public { + (bytes32[] memory txProof, bytes32[] memory whitelistProof) = _createMerkleProofs(); + + Types.TransferData memory invalidFromData = _createTransferData(); + invalidFromData.from = address(0); + + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.executeTransfer(txProof, whitelistProof, invalidFromData); + + Types.TransferData memory invalidToData = _createTransferData(); + invalidToData.to = address(0); + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.executeTransfer(txProof, whitelistProof, invalidToData); + + Types.TransferData memory invalidData = _createTransferData(); + invalidData.from = address(0); + invalidData.to = address(0); + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.executeTransfer(txProof, whitelistProof, invalidData); + } + + // Settlement__InvalidBatch + function test_ExecuteTransfer_InvalidBatch() public { + (bytes32[] memory txProof, bytes32[] memory whitelistProof) = _createMerkleProofs(); + + Types.TransferData memory txData = _createTransferData(); + txData.batchId = 999; + + vm.expectRevert(Errors.Settlement__InvalidBatch.selector); + settlement.executeTransfer(txProof, whitelistProof, txData); + } + + // Settlement__BatchLocked + function test_ExecuteTransfer_BatchLocked() public { + (bytes32 merkleRoot, uint32 txCount) = _createBatchData(); + + vm.prank(owner); + settlement.submitBatch(merkleRoot, txCount, 1); + + (bytes32[] memory txProof, bytes32[] memory whitelistProof) = _createMerkleProofs(); + + Types.TransferData memory txData = _createTransferData(); + txData.batchId = 1; + + vm.expectRevert(Errors.Settlement__BatchLocked.selector); + settlement.executeTransfer(txProof, whitelistProof, txData); + } + + /* -------------------------------------------------------------------------- */ + /* approveAggregator */ + /* -------------------------------------------------------------------------- */ + + function test_ApproveAggregator_InvalidInput() public { + vm.prank(owner); + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.approveAggregator(address(0)); + } + + function test_ApproveAggregator_AlreadyAggregator() public { + vm.prank(owner); + vm.expectRevert(Errors.Settlement__AlreadyAggregator.selector); + settlement.approveAggregator(owner); + } + + function test_ApproveAggregator_ApprovesAndEmits() public { + vm.prank(owner); + vm.expectEmit(true, false, false, false); + emit ISettlement.AggregatorApproved(user); + settlement.approveAggregator(user); + + bool isApproved = settlement.isApprovedAggregator(user); + assertTrue(isApproved); + } + + /* -------------------------------------------------------------------------- */ + /* disapproveAggregator */ + /* -------------------------------------------------------------------------- */ + + function test_DisapproveAggregator_InvalidInput() public { + vm.prank(owner); + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.disapproveAggregator(address(0)); + } + + function test_DisapproveAggregator_NotAggregator() public { + vm.prank(owner); + vm.expectRevert(Errors.Settlement__AggregatorNotApproved.selector); + settlement.disapproveAggregator(user); + } + + function test_DisapproveAggregator_DisapprovesAndEmits() public { + vm.prank(owner); + settlement.approveAggregator(user); + + vm.prank(owner); + vm.expectEmit(true, false, false, false); + emit ISettlement.AggregatorDisapproved(user); + settlement.disapproveAggregator(user); + + bool isApproved = settlement.isApprovedAggregator(user); + assertFalse(isApproved); + } + + /* onlyOwner */ + + function test_OnlyOwner() public { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + settlement.approveAggregator(user); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + settlement.setWhitelistRegistry(registry); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + settlement.setFeeModule(feeModule); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + settlement.setMaxTxPerBatch(1); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + settlement.setTimelockDuration(1); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + settlement.setToken(token); + } + + /* -------------------------------------------------------------------------- */ + /* pause/unpause */ + /* -------------------------------------------------------------------------- */ + + function test_PauseUnpause_NotAuthorized() public { + vm.startPrank(user); + vm.expectRevert( + abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user, settlement.getOwner()) + ); + settlement.pause(); + + vm.expectRevert( + abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user, settlement.getOwner()) + ); + settlement.unpause(); + vm.stopPrank(); + } + + function test_PauseUnpause_Success() public { + vm.prank(owner); + settlement.pause(); + assertTrue(settlement.paused()); + + vm.prank(owner); + settlement.unpause(); + assertFalse(settlement.paused()); + } + + function test_ExecuteTransfer_EnforcedPause() public { + vm.prank(owner); + settlement.pause(); + + (bytes32[] memory txProof, bytes32[] memory whitelistProof) = _createMerkleProofs(); + Types.TransferData memory txData = _createTransferData(); + + vm.expectRevert(Pausable.EnforcedPause.selector); + settlement.executeTransfer(txProof, whitelistProof, txData); + } + + /* -------------------------------------------------------------------------- */ + /* SETTERS */ + /* -------------------------------------------------------------------------- */ + + /* setWhitelistRegistry */ + + function test_SetWhitelistRegistry_InvalidInput() public { + vm.prank(owner); + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.setWhitelistRegistry(address(0)); + } + + function test_SetWhitelistRegistry_AlreadyRegistry() public { + vm.prank(owner); + vm.expectRevert(Errors.Settlement__AlreadyRegistry.selector); + settlement.setWhitelistRegistry(registry); + } + + function test_SetWhitelistRegistry_SetsAndEmits() public { + address newRegistry = makeAddr("newRegistry"); + vm.prank(owner); + vm.expectEmit(true, false, false, false); + emit ISettlement.WhitelistRegistryUpdated(newRegistry); + settlement.setWhitelistRegistry(newRegistry); + + address actual = settlement.getWhitelistRegistry(); + assertEq(actual, newRegistry); + } + + /* setFeeModule */ + + function test_SetFeeModule_InvalidInput() public { + vm.prank(owner); + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.setFeeModule(address(0)); + } + + function test_SetFeeModule_AlreadyFeeModule() public { + vm.prank(owner); + vm.expectRevert(Errors.Settlement__AlreadyFeeModule.selector); + settlement.setFeeModule(feeModule); + } + + function test_SetFeeModule_SetsAndEmits() public { + address newFeeModule = makeAddr("newFeeModule"); + vm.prank(owner); + vm.expectEmit(true, false, false, false); + emit ISettlement.FeeModuleUpdated(newFeeModule); + settlement.setFeeModule(newFeeModule); + + address actual = settlement.getFeeModule(); + assertEq(actual, newFeeModule); + } + + /* setMaxTxPerBatch */ + + function test_SetMaxTxPerBatch_InvalidInput() public { + vm.prank(owner); + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.setMaxTxPerBatch(0); + } + + function test_SetMaxTxPerBatch_AlreadySet() public { + vm.prank(owner); + settlement.setMaxTxPerBatch(100); + + vm.prank(owner); + vm.expectRevert(Errors.Settlement__AlreadySet.selector); + settlement.setMaxTxPerBatch(100); + } + + function test_SetMaxTxPerBatch_SetsAndEmits() public { + uint32 maxTx = 150; + + vm.prank(owner); + vm.expectEmit(true, false, false, false); + emit ISettlement.MaxTxPerBatchUpdated(maxTx); + settlement.setMaxTxPerBatch(uint32(maxTx)); + + uint32 actual = settlement.getMaxTxPerBatch(); + assertEq(actual, maxTx); + } + + /* setTimelockDuration */ + + function test_SetTimelockDuration_AllowZero() public { + vm.startPrank(owner); + settlement.setTimelockDuration(1); + settlement.setTimelockDuration(0); + vm.stopPrank(); + + uint256 actual = settlement.getTimelockDuration(); + assertEq(actual, 0); + } + + function test_SetTimelockDuration_AlreadyTimelock_Zero() public { + vm.prank(owner); + settlement.setTimelockDuration(0); + + vm.prank(owner); + vm.expectRevert(Errors.Settlement__AlreadyTimelockDuration.selector); + settlement.setTimelockDuration(0); + } + + function test_SetTimelockDuration_AlreadyTimelock() public { + vm.startPrank(owner); + + vm.expectRevert(Errors.Settlement__AlreadyTimelockDuration.selector); + settlement.setTimelockDuration(uint32(TC.TIMELOCK_DURATION)); + + settlement.setTimelockDuration(600); + + vm.expectRevert(Errors.Settlement__AlreadyTimelockDuration.selector); + settlement.setTimelockDuration(600); + + vm.stopPrank(); + } + + function test_SetTimelockDuration_SetsAndEmits() public { + uint48 duration = 600; + vm.prank(owner); + vm.expectEmit(true, false, false, false); + emit ISettlement.TimelockDurationUpdated(duration); + settlement.setTimelockDuration(duration); + + uint48 actual = settlement.getTimelockDuration(); + assertEq(actual, duration); + } + + /* setToken */ + + function test_SetToken_InvalidInput() public { + vm.prank(owner); + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.setToken(address(0)); + } + + function test_SetToken_AlreadyToken() public { + vm.prank(owner); + vm.expectRevert(Errors.Settlement__AlreadyToken.selector); + settlement.setToken(token); + } + + function test_SetToken_SetsAndEmits() public { + address newToken = makeAddr("newToken"); + + vm.prank(owner); + vm.expectEmit(true, false, false, false); + emit ISettlement.TokenUpdated(newToken); + settlement.setToken(newToken); + + address actual = settlement.getToken(); + assertEq(actual, newToken); + } + + /* -------------------------------------------------------------------------- */ + /* GETTERS */ + /* -------------------------------------------------------------------------- */ + + function test_AllGetters() public { + assertEq(settlement.getOwner(), owner); + + // getBatchIdByHash root==0 + vm.expectRevert(Errors.Settlement__InvalidInput.selector); + settlement.getBatchIdByRoot(bytes32(0)); + + vm.prank(owner); + address newAggregator = address(0xBEEF); + settlement.approveAggregator(newAggregator); + assertTrue(settlement.isApprovedAggregator(newAggregator)); + + vm.prank(owner); + settlement.disapproveAggregator(newAggregator); + assertFalse(settlement.isApprovedAggregator(newAggregator)); + + vm.prank(owner); + bytes32 root = keccak256("root"); + (bool ok, uint256 batchId) = settlement.submitBatch(root, 3, 1); + assertTrue(ok); + + assertEq(settlement.getCurrentBatchId(), batchId); + assertEq(settlement.getBatchIdByRoot(root), batchId); + assertEq(settlement.getRootByBatchId(uint64(batchId)), root); + + Types.Batch memory batch = settlement.getBatchById(uint64(batchId)); + assertEq(batch.merkleRoot, root); + assertEq(batch.txCount, 3); + assertEq(batch.timestamp, block.timestamp); + assertEq(batch.unlockTime, block.timestamp + settlement.getTimelockDuration()); + + Types.TransferData memory txData = Types.TransferData({ + from: address(this), + to: address(0x1234), + amount: 1, + nonce: 1, + timestamp: uint48(block.timestamp), + recipientCount: 1, + batchId: uint64(batchId), + txType: Types.TxType.DELAYED + }); + + bytes32 txHash = keccak256( + abi.encodePacked( + txData.from, + txData.to, + txData.amount, + txData.nonce, + txData.timestamp, + txData.recipientCount, + txData.txType + ) + ); + + assertFalse(settlement.isExecutedTransfer(txHash)); + } +} diff --git a/contracts/test/utils/IntegrationDeployHelpers.sol b/contracts/test/utils/IntegrationDeployHelpers.sol new file mode 100644 index 0000000..3452123 --- /dev/null +++ b/contracts/test/utils/IntegrationDeployHelpers.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {DeployFeeModule} from "../../script/for-tests/DeployFeeModule.s.sol"; +import {DeploySettlement} from "../../script/for-tests/DeploySettlement.s.sol"; +import {DeployRegistry} from "../../script/for-tests/DeployRegistry.s.sol"; + +import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; +import {FeeModule} from "../../src/FeeModule.sol"; +import {Settlement} from "../../src/Settlement.sol"; +import {WhitelistRegistry} from "../../src/WhitelistRegistry.sol"; + +abstract contract IntegrationDeployHelpers is Test { + DeployRegistry internal _registryDeployer; + WhitelistRegistry internal registry; + + DeployFeeModule internal _feeDeployer; + FeeModule internal feeModule; + + DeploySettlement internal _settlementDeployer; + Settlement internal settlement; + + ERC20Mock mockToken; + + address internal user; + uint256 internal userPrivKey; + + address internal user2; + uint256 internal user2PrivKey; + + function _initUser() internal { + (user, userPrivKey) = makeAddrAndKey("user"); + } + + function _initUser2() internal { + (user2, user2PrivKey) = makeAddrAndKey("user2"); + } + + function _initFeeModule() internal { + _feeDeployer = new DeployFeeModule(); + feeModule = _feeDeployer.run(); + } + + function _initRegistry() internal { + _registryDeployer = new DeployRegistry(); + registry = _registryDeployer.run(); + } + + function _initSettlement() internal { + _settlementDeployer = new DeploySettlement(); + settlement = _settlementDeployer.run(); + } + + function _initToken() internal { + mockToken = new ERC20Mock(); + } +} diff --git a/contracts/test/utils/TestConstants.sol b/contracts/test/utils/TestConstants.sol new file mode 100644 index 0000000..b28ef2e --- /dev/null +++ b/contracts/test/utils/TestConstants.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +library TestConstants { + uint256 internal constant BASE_FEE = 100_000; // Base fee = 0.1 TRX + uint256 internal constant BATCH_FEE = 50_000; // Batch fee = 0.05 TRX per recipient + uint256 internal constant INSTANT_FEE = 200_000; // Instant fee = 0.2 TRX + uint256 internal constant FREE_TX_AMOUNT = 10; // Free tier = first 10 tx/day for unbatched small users + uint256 internal constant LARGE_VOLUME = 1_000_000_000; + uint256 internal constant VOLUME = 10_000; + + uint256 internal constant REQUEST_COOLDOWN = 24 hours; + uint256 internal constant REQUEST_FEE = 10e6; // 10 TRX + bytes32 internal constant WITHDRAW_ROLE = keccak256("WITHDRAW_ROLE"); + bytes32 internal constant DEFAULT_ADMIN_ROLE = 0x00; + + uint256 internal constant MAX_TX_PER_BATCH = 22; + uint256 internal constant TIMELOCK_DURATION = 1 days; + + address internal constant UPDATER = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; +} From ce378dc64316131d36e5c9ebfd848549859287f0 Mon Sep 17 00:00:00 2001 From: soffije Date: Fri, 26 Dec 2025 17:55:24 +0200 Subject: [PATCH 2/7] TSOL docs init --- .gitignore | 1 - docs/TIP-bftch Guide.md | 0 2 files changed, 1 deletion(-) create mode 100644 docs/TIP-bftch Guide.md diff --git a/.gitignore b/.gitignore index 0e580fe..d08bb18 100644 --- a/.gitignore +++ b/.gitignore @@ -88,5 +88,4 @@ out/ ######################################## # Docs / local notes ######################################## -docs/ HELP.md diff --git a/docs/TIP-bftch Guide.md b/docs/TIP-bftch Guide.md new file mode 100644 index 0000000..e69de29 From 9c0953a00c6c586d5e4e2799de955e28db8e30fe Mon Sep 17 00:00:00 2001 From: sonia <93443981+soffije@users.noreply.github.com> Date: Fri, 26 Dec 2025 20:11:42 +0200 Subject: [PATCH 3/7] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7a87c3d..dd0286c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# TRON Settlement Batching Layer (TSOL) +# TRON Settlement Batching Layer (TSBL) -This repository is a **monorepo** implementing the **TRON Settlement Batching Layer (TSOL)** — a hybrid off-chain/on-chain system for collecting transfer intents, batching them into Merkle trees, and executing transfers on TRON using Merkle proofs with optional whitelist-based batching. +This repository is a **monorepo** implementing the **TRON Settlement Batching Layer (TSBL)** — a hybrid off-chain/on-chain system for collecting transfer intents, batching them into Merkle trees, and executing transfers on TRON using Merkle proofs with optional whitelist-based batching. The system consists of two main parts: From a11e2a41dcdcbf629583445b5962c182997c644e Mon Sep 17 00:00:00 2001 From: sonia <93443981+soffije@users.noreply.github.com> Date: Mon, 29 Dec 2025 11:48:05 +0200 Subject: [PATCH 4/7] Update README.md (#2) --- backend/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/README.md b/backend/README.md index 60bd9c2..28434b2 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,4 +1,4 @@ -### tsol-backend +### TSBL-backend Spring Boot backend for submitting **transfer intents**, batching them into a **Merkle tree**, submitting the batch to an on-chain **Settlement** contract on TRON, and executing transfers using Merkle proofs (optionally with whitelist proofs for batched tx types). From dac13a8f13a2f0e7e168ac2d91be702973aecfba Mon Sep 17 00:00:00 2001 From: sonia <93443981+soffije@users.noreply.github.com> Date: Mon, 29 Dec 2025 11:48:39 +0200 Subject: [PATCH 5/7] Update README.md (#3) --- contracts/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/README.md b/contracts/README.md index 1c6b0da..fd141aa 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,4 +1,4 @@ -## TSOL Architecture +## TSBL-contracts ### **WhitelistRegistry.sol** @@ -88,4 +88,4 @@ On-chain: submitBatch(merkleRoot) → time lock 1 min ↓ executeTransfer(proof, data) → Merkle verify → transfer tokens -``` \ No newline at end of file +``` From 418e437335720198112869c2ab9c58c0a8177ace Mon Sep 17 00:00:00 2001 From: soffije Date: Mon, 29 Dec 2025 12:40:20 +0200 Subject: [PATCH 6/7] README.md updated + backend FT added --- backend/FUNCTIONALITY_TABLE.md | 19 +++++++++ backend/README.md | 1 - contracts/README.md | 78 +++++++++++++++++++++++++++++++--- 3 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 backend/FUNCTIONALITY_TABLE.md diff --git a/backend/FUNCTIONALITY_TABLE.md b/backend/FUNCTIONALITY_TABLE.md new file mode 100644 index 0000000..5645233 --- /dev/null +++ b/backend/FUNCTIONALITY_TABLE.md @@ -0,0 +1,19 @@ +### Functionality table (based on `description.text`) — Done vs Should be added + +| Area | Functionality | Status | Where it exists now | What should be added (gap) | +|---|---|---|---|---| +| **Intents** | Accept transfer intents via API | **Done** | `POST /api/intents` (`TransferIntentController`), `TransferIntentService` | Signed intents (canonical format + signature verification), nonce/replay protection, rate limits | +| **Batching** | Create batches from pending intents (timer/quantity) | **Done (basic)** | `BatchingScheduler`, `BatchService` | More flexible batching policy (priority flags, max-wait per intent), single-intent support if required | +| **Merkle** | Compute tx leaf hash + Merkle root + proofs | **Done** | `MerkleTreeService` + scripts in `sc/script/merkle/` | Formal test vectors + cross-language verifier library | +| **On-chain Settlement** | Submit batch (root+count) | **Done** | `Settlement.sol` + `SettlementContractClientTrident.submitBatchWithTxId` | Production reconciliation (detect stuck submits, backfill event scanning) | +| **Timelock / Deferred** | Unlock time gating before execution | **Done** | `Settlement.sol` unlockTime, `ExecutionScheduler` | Operational controls (pause/resume execution), SLA monitoring | +| **Execution** | Execute transfer with proofs | **Done** | `Settlement.sol.executeTransfer`, `SettlementContractClientTrident.executeTransfer` | **Idempotency check** using `isExecutedTransfer(bytes32)` before sending; better error decoding; retry strategy | +| **Whitelist** | Whitelist Merkle root registry | **Done** | `WhitelistRegistry.sol` | Automated whitelist scoring + scheduled root updates (analytics node) | +| **Whitelist enforcement** | Require whitelist proof only for batched txType | **Done** | `Settlement.sol._validateBatched`, Java `BatchService` proof selection | Tools/SDK to generate proofs for external clients | +| **Fee module** | Fee calculation based on txType + free tier quota | **Done (analytics-only)** | `FeeModule.sol` | If “real fees” are required: actual fee collection/transfer + accounting + reporting | +| **Monitoring** | API to view batches/transfers/state | **Done** | `BatchMonitoringController` | Prometheus metrics, dashboards, alerts, audit logs | +| **Persistence** | Store batches/transfers reliably | **Not done** | Current: `InMemoryBatchRepository` | Postgres (or other DB), migrations, restart recovery, indexing strategy | +| **Security model (full vision)** | Rollup/channels, fraud proofs or ZK proofs | **Not done** | — | Off-chain ledger, state root commitments, challenge window (optimistic) or ZK proof pipeline | +| **Router / Custody (full vision)** | Contract accepts deposits, buffers, routes, withdrawals | **Not done** | — | Router contract + event ingestion + exit/withdraw flows | +| **Governance (full vision)** | DAO changes parameters (timings, batch rules, free tier) | **Partial** | Owner/admin controls in contracts | DAO/multisig integration + timelocked parameter changes | +| **Verifier library (full vision)** | OSS verifier for signatures, Merkle proofs, nonce rules | **Not done** | — | Publish libs (JS + backend language) + test vectors + CI | \ No newline at end of file diff --git a/backend/README.md b/backend/README.md index 28434b2..1a3917c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -151,6 +151,5 @@ Run example: ### Docs - `FUNCTIONALITY_TABLE.md`: high-level “done vs missing” feature tracking -- `HELP.md`: Spring/Gradle reference links (generated template) diff --git a/contracts/README.md b/contracts/README.md index fd141aa..c50b2b4 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,5 +1,9 @@ ## TSBL-contracts +This folder contains the **on-chain core of the TRON Settlement Batching Layer (TSBL)** — a set of smart contracts that implement **batch-based token transfer execution** using Merkle trees, delayed finality (time-lock), and modular fee logic. +The contracts are designed so that **all critical validation happens on-chain**. +The backend acts only as an **aggregator/operator**, not as a trusted execution component. + ### **WhitelistRegistry.sol** ──────── STATE VARIABLES ──────── @@ -12,6 +16,20 @@ ──────── EVENTS ──────── ├── emits WhitelistUpdated, WhitelistRequested +### Purpose + +`WhitelistRegistry` manages **permissioned access for batched transactions** (`TxType.BATCHED`) using **Merkle tree–based whitelists**. + +Whitelist verification is **only required for batched transactions**. +Non-batched transaction types do not depend on this contract. + +### Core idea + +- The whitelist is represented by a **single Merkle root stored on-chain** +- Users prove inclusion via a **Merkle proof**, without storing addresses on-chain +- Updates to the whitelist are **authorized via ECDSA signatures** and protected by a nonce +- Users may submit whitelist requests by paying a small fee (anti-spam + signaling) + ### **FeeModule.sol** ──────── TYPES ──────── @@ -39,6 +57,26 @@ ──────── EVENTS ──────── ├── emits FeeCalculated, FeeApplied, FreeTierUsed +### Purpose + +`FeeModule` is responsible for **on-chain fee calculation and accounting**, but **does not collect or transfer real funds**. + +> ⚠️ **Important** +> This module is **purely logical and statistical**: +> - It calculates *what the fee should be* +> - It records fee usage for analytics and UX +> - It does **not** deduct TRX or tokens + +### Core idea + +- Fee calculation depends on: + - transaction type (`TxType`) + - recipient count (`recipientCount`) + - transfer volume + - user free-tier quota +- **Backend never calculates fees** — it only calls `calculateFee` +- Fee logic is deterministic and fully verifiable on-chain + ### **Settlement.sol** ──────── TYPES ──────── @@ -80,12 +118,38 @@ ├── function setTimeLockDuration(_duration) onlyOwner ──────── EVENTS ──────── ├── emits BatchSubmitted, TransferExecuted - -**flow:** +### Purpose + +`Settlement` is the **execution layer of the protocol**. + +It is responsible for: +- accepting batches (Merkle roots) +- enforcing delayed finality (time-lock) +- executing **exactly one transfer per Merkle leaf** + +### Core idea + +> **Batch ≠ multi-send transaction** + +A batch is a **commitment (Merkle root)** to many transfers. +Each transfer is executed **individually**, using its own Merkle proof. + +This design preserves: +- replay protection +- deterministic execution +- partial batch execution safety + +## On-chain Execution Flow -``` -On-chain: submitBatch(merkleRoot) → time lock 1 min - ↓ - executeTransfer(proof, data) → Merkle verify → transfer tokens -``` +```text +Aggregator / Backend + | + | submitBatch(merkleRoot, txCount) + v +Settlement + | (time-lock delay) + | + | executeTransfer(proof, data) + v +Merkle verification → fee calculation → token transfer From 5e3fae0df9261064831672db89d97f133f8ecb7c Mon Sep 17 00:00:00 2001 From: soffije Date: Fri, 2 Jan 2026 11:58:46 +0200 Subject: [PATCH 7/7] Docs added --- docs/Contract structure overview.png | Bin 0 -> 150208 bytes docs/On-chain contract dependencies.png | Bin 0 -> 26464 bytes docs/System flow diagram.png | Bin 0 -> 219698 bytes docs/TIP-batch-MVP-guide.md | 613 ++++++++++++++++++++++++ docs/TIP-bftch Guide.md | 0 5 files changed, 613 insertions(+) create mode 100644 docs/Contract structure overview.png create mode 100644 docs/On-chain contract dependencies.png create mode 100644 docs/System flow diagram.png create mode 100644 docs/TIP-batch-MVP-guide.md delete mode 100644 docs/TIP-bftch Guide.md diff --git a/docs/Contract structure overview.png b/docs/Contract structure overview.png new file mode 100644 index 0000000000000000000000000000000000000000..8be48454620e65df585ac9bac9ec8da0a9876d75 GIT binary patch literal 150208 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�kMZi{B+{S`P|#|6?jElY=ll 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

  • 0x;=ss#h5Ib?OrdIDZJ>S>Mzz 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 literal 0 HcmV?d00001 diff --git a/docs/On-chain contract dependencies.png b/docs/On-chain contract dependencies.png new file mode 100644 index 0000000000000000000000000000000000000000..b0f33e0a3504ca673befba45f09ce9794c741658 GIT binary patch literal 26464 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 DK6qvZ literal 0 HcmV?d00001 diff --git a/docs/System flow diagram.png b/docs/System flow diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..3fc2c4f5c6acb8d2b1f394d34b74056df9b6a914 GIT binary patch literal 219698 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

    @h@Yv1Wsj?i9m2=wZPt|WLa~IF;!PpFj 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

    w$t$YYSFl7@~%U-XPs~M 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

    w;ic4*%6KSf|fPLEm{NiZ=0XM74e73gwFqnT#! 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 literal 0 HcmV?d00001 diff --git a/docs/TIP-batch-MVP-guide.md b/docs/TIP-batch-MVP-guide.md new file mode 100644 index 0000000..4342629 --- /dev/null +++ b/docs/TIP-batch-MVP-guide.md @@ -0,0 +1,613 @@ +# TIP-batch MVP guide + +- [TIP-batch MVP guide](#tip-batch-mvp-guide) + - [General technical guide](#general-technical-guide) + - [Purpose and scope](#purpose-and-scope) + - [Audience](#audience) + - [Primary audience](#primary-audience) + - [Secondary audience](#secondary-audience) + - [MVP scope versus proposal scope](#mvp-scope-versus-proposal-scope) + - [What the protocol does in this MVP](#what-the-protocol-does-in-this-mvp) + - [What the protocol does not do in this MVP](#what-the-protocol-does-not-do-in-this-mvp) + - [Conventions and normative language](#conventions-and-normative-language) + - [System model](#system-model) + - [Component map](#component-map) + - [Roles and responsibilities](#roles-and-responsibilities) + - [Architecture sketch alignment](#architecture-sketch-alignment) + - [Architecture diagrams](#architecture-diagrams) + - [On-chain contracts](#on-chain-contracts) + - [Settlement contract](#settlement-contract) + - [Whitelist registry contract](#whitelist-registry-contract) + - [Fee module contract](#fee-module-contract) + - [Off-chain components](#off-chain-components) + - [Batch builder and executor service (Java application)](#batch-builder-and-executor-service-java-application) + - [Merkle tooling scripts (Python and Java)](#merkle-tooling-scripts-python-and-java) + - [Data model](#data-model) + - [Transfer leaf payload](#transfer-leaf-payload) + - [Whitelist leaf payload](#whitelist-leaf-payload) + - [Protocol flows](#protocol-flows) + - [Flow A: whitelist root update](#flow-a-whitelist-root-update) + - [Flow B: batch commit](#flow-b-batch-commit) + - [Flow C: approve and transfer execution](#flow-c-approve-and-transfer-execution) + - [Optional versus required elements](#optional-versus-required-elements) + - [Required for MVP operation](#required-for-mvp-operation) + - [Optional in the MVP design](#optional-in-the-mvp-design) + - [Security and trust model](#security-and-trust-model) + - [Centralization and operator trust](#centralization-and-operator-trust) + - [Allowance-based risk surface](#allowance-based-risk-surface) + - [Unlock time semantics](#unlock-time-semantics) + - [TRON resource considerations](#tron-resource-considerations) + - [Developer-oriented notes](#developer-oriented-notes) + - [Design decisions](#design-decisions) + - [Merkle root as batch commitment](#merkle-root-as-batch-commitment) + - [Per-transfer execution instead of single-call settlement](#per-transfer-execution-instead-of-single-call-settlement) + - [Sponsored execution via transferFrom](#sponsored-execution-via-transferfrom) + - [Whitelist gating via Merkle proof](#whitelist-gating-via-merkle-proof) + - [Unlock time as a review window](#unlock-time-as-a-review-window) + - [Off-chain state as in-memory storage](#off-chain-state-as-in-memory-storage) + - [Integration notes](#integration-notes) + - [Integration roles](#integration-roles) + - [Integration sequence for a dApp](#integration-sequence-for-a-dapp) + - [Edge cases](#edge-cases) + - [Allowance and balance changes between commit and execution](#allowance-and-balance-changes-between-commit-and-execution) + - [Nonce collisions and replay](#nonce-collisions-and-replay) + - [Token contract behavior](#token-contract-behavior) + - [Batch composition risks](#batch-composition-risks) + - [Limitations and non-goals](#limitations-and-non-goals) + - [Reference and examples](#reference-and-examples) + - [Terms](#terms) + - [Examples](#examples) + - [Transfer leaf payload](#transfer-leaf-payload) + - [Example whitelist input](#example-whitelist-input) + - [Sequence diagram: whitelist root update](#sequence-diagram-whitelist-root-update) + - [Sequence diagram: batch commit and execution](#sequence-diagram-batch-commit-and-execution) + - [Pseudocode: batch boundary selection](#pseudocode-batch-boundary-selection) + - [Pseudocode: Merkle commitment and per-leaf execution](#pseudocode-merkle-commitment-and-per-leaf-execution) + - [Failure classes](#failure-classes) + +This document describes the general provisions of TIP-batch MVP and consists of the following components: + +1. General technical guide. +2. Developer-oriented notes. +3. Reference and examples. + +## General technical guide {#general-technical-guide} + +### Purpose and scope {#purpose-and-scope} + +This document describes the implemented MVP of TIP-batch (TRON settlement batching layer), based on developer transcripts and a high-level architecture sketch. \ +This document targets protocol-level readers and describes current MVP behavior, not the full proposal design. + +### Audience {#audience} + +#### Primary audience {#primary-audience} + +* TRON core developers and maintainers. +* Protocol and blockchain engineers. +* Infrastructure engineers and L1/L2 engineers. +* Developers who work with TRON TVM, Energy/Bandwidth, and Stake 2.0. +* TRON community members who author or review TIPs. + +#### Secondary audience {#secondary-audience} + +* dApp developers who build sponsored execution (MetaFee-like) flows and gasless UX on TRON. +* Auditors and researchers who analyze protocol designs and MVP implementations. + +### MVP scope versus proposal scope {#mvp-scope-versus-proposal-scope} + +#### What the protocol does in this MVP {#what-the-protocol-does-in-this-mvp} + +* The system commits a Merkle root for a set of TRC-20 transfers to an on-chain settlement contract. +* The system enforces an unlock time (time lock) before transfer execution. +* The system executes each transfer on-chain after the caller supplies a Merkle inclusion proof. +* The system optionally gates a “batch-transfer” type via a whitelist Merkle proof for the sender address (txData.from). +* The system computes “virtual” fees via a fee module contract, without enforcing real fee collection in the MVP. + +#### What the protocol does not do in this MVP {#what-the-protocol-does-not-do-in-this-mvp} + +* The system does not execute an entire batch with a single on-chain token transfer call. +* The system does not implement a full dispute mechanism (fraud-proof and on-chain rollback). +* The system does not define a complete user-signed intent scheme in the transcripts. +* Existence of user-signed transfer intents in code, including signature verification and authorization rules. +* Existence of on-chain dispute hooks, batch invalidation, or batch cancellation. +* Exact mapping between the published proposal terminology and the deployed MVP contracts. + +### Conventions and normative language {#conventions-and-normative-language} + +* MUST, MUST NOT, SHOULD, SHOULD NOT, MAY indicate normative requirements for the MVP flows described in this guide. +* “Batch commitment” means the on-chain record that anchors a batch root and related metadata. +* “Transfer leaf” means the off-chain representation that the Merkle tree uses as a leaf payload, and that the settlement contract verifies via an inclusion proof. +* “Sender” means txData.from in the transfer payload passed to Settlement.executeTransfer. +* “Executor” means the account that submits on-chain transactions (submitBatch, executeTransfer) and consumes TRON resources (Energy/Bandwidth). +* “Whitelist gating” means a membership check for the sender (txData.from) under the WhitelistRegistry Merkle root, when the transaction type uses batch-transfer gating. + +### System model {#system-model} + +#### Component map {#component-map} + +On-chain components: + +* Settlement contract. +* Whitelist registry contract. +* Fee module contract. + +Off-chain components: + +* Batch builder and executor service (Java application in the MVP). +* Merkle tooling scripts (Python and Java in the MVP). +* TRON node RPC endpoint (external node, as described in transcripts). + +#### Roles and responsibilities {#roles-and-responsibilities} + +Batch builder (off-chain): + +* Collects transfer requests into a queue. +* Chooses batch boundaries (count threshold or time window). +* Builds a Merkle tree and produces a batch root. +* Produces Merkle proofs for each transfer leaf. +* Submits the batch root to the settlement contract. + +Executor (off-chain): + +* Calls executeTransfer on the settlement contract for each transfer leaf after unlock time. +* Pays TRON resource costs (Energy/Bandwidth) for the on-chain transactions. +* Treats whitelist proofs as proofs for the sender (txData.from), not for the executor address. + +Root signer (off-chain key role): + +* Signs the whitelist Merkle root, which authorizes an on-chain update of the whitelist root. + +Settlement contract (on-chain): + +* Stores batch commitments (root and metadata). +* Enforces unlock time for each committed batch. +* Verifies Merkle inclusion proofs for transfer leaves. +* Calls TRC-20 transferFrom to execute token transfers. +* Marks executed transfers to prevent replay. +* Calls the fee module for virtual fee computation. +* Queries the whitelist registry for sender whitelist gating (txData.from) when required by transaction type. + +Whitelist registry contract (on-chain): + +* Stores a whitelist Merkle root for eligible sender addresses (txData.from). +* Verifies authorization for updating the root via a role system and a root signature check. +* Emits an event for “request whitelist” as an off-chain signal. + +Fee module contract (on-chain): + +* Computes virtual fees by transaction type and parameters. +* Restricts fee application calls to the settlement contract. +* Does not enforce real fee collection in the MVP. + +#### Architecture sketch alignment {#architecture-sketch-alignment} + +The architecture sketch shows the following call direction: + +* The node commits a batch to the settlement contract. +* The settlement contract calls the whitelist registry and fee module. +* The settlement contract executes TRC-20 transfers via transferFrom. + +#### Architecture diagrams {#architecture-diagrams} + +System flow diagram: + +![System flow diagram.png](System%20flow%20diagram.png) + +On-chain contract dependencies: + +![On-chain contract dependencies.png](On-chain%20contract%20dependencies.png) + +Contract structure overview: + +![Contract structure overview.png](Contract%20structure%20overview.png) + +### On-chain contracts {#on-chain-contracts} + + +#### Settlement contract {#settlement-contract} + +Responsibilities: + +* Accept a batch commitment (Merkle root and batch metadata). +* Enforce an unlock time (challenge window) before executing transfers for that batch. +* Execute a single transfer per call, based on a verified Merkle proof. +* Prevent replay by marking a transfer as executed. + +Key operations: + +* Submit batch: store the batch root and metadata, and set an unlock time. +* Execute transfer: verify inclusion, verify optional whitelist membership for txData.from, compute fee, execute transferFrom, mark executed. + +Batch id semantics: \ +Settlement derives batchId during batch submission and uses batchId as the on-chain key for batches[batchId]. \ +Off-chain systems MAY treat batchId as a logical handle for monitoring, indexing, and API correlation. \ +Transfer leaf hashing does not require batchId when leaf encoding includes a root-binding mechanism (for example, batchSalt in metadata or an equivalent binding), because the Merkle proof already ties the leaf to the committed root. + +Per-transfer execution model: + +* The settlement contract executes exactly one TRC-20 transfer per executeTransfer call. +* Recipient count affects fee computation and tree structure metadata, but recipient count does not reduce the number of on-chain token transfer calls. + +#### Whitelist registry contract {#whitelist-registry-contract} + +Responsibilities: + +* Store a whitelist root that represents eligible sender addresses (txData.from). +* Provide a proof-based membership check for batch-transfer gating of txData.from. +* Provide administrative control over root updates via roles and signature checks. + +Root update model: + +* The root signer signs a new whitelist root off-chain. +* Any account MAY submit the signed root to the whitelist registry contract. +* The contract verifies the root signature and updates the stored whitelist root. +* An admin role manages which accounts can manage updater roles, as described in transcripts. + +Request whitelist model: + +* A user MAY call a requestWhitelist-like function and pay a small fee. +* The contract emits an event. +* An off-chain process MUST observe this event and decide whether to include the address in the next root. +* Exact role identifiers and role hierarchy in the whitelist registry. +* Fee handling for request whitelist, including fee recipient and accounting. +* Off-chain policy and SLA for processing request whitelist events. + +#### Fee module contract {#fee-module-contract} + +Responsibilities: + +* Compute virtual fees for analytics and future enforcement. +* Apply fee accounting only when the settlement contract calls the fee module. + +Fee types described in transcripts: +* Base fee for a standard transfer type. +* Batch fee for a batch-transfer type, lower than other types. +* Instant fee for an “instant” type, without actual prioritization in the MVP. +* Free tier of 10 transfers per day for each user, gated by a transaction type choice. +* Volume-based fee adjustments based on fixed constants defined in the contract. +* Exact constants and thresholds, including “volume” definition. +* Exact rules for “10 free transfers per day”, including day boundary and per-user accounting state. +* Whether the MVP stores fee counters on-chain or treats fees as off-chain metrics only. + +### Off-chain components {#off-chain-components} + +#### Batch builder and executor service (Java application) {#batch-builder-and-executor-service-java-application} + +Batch boundary rules in the MVP: \ +The MVP uses two batch boundary conditions, and either condition triggers batch creation. + +* Count condition: the service creates a batch when the queue reaches a fixed number of transfers (example value: 5 transfers). +* Time condition: the service creates a batch when a fixed time window elapses (example value: 30 seconds), even if the queue contains fewer transfers. + +State storage in the MVP: + +* The MVP stores batches and transfers in memory and locally, without a database. +* The MVP accepts this limitation due to delivery time constraints. + +TRON connectivity in the MVP: + +* The MVP uses a Java library described as “3Dent” to access TRON nodes and interact with smart contracts. +* The service sends requests to an external TRON node. +* Exact library name, artifact coordinates, and supported features (signing, contract calls, event decoding). +* Exact RPC node type (full node, solidity node) and network (Nile testnet, Shasta, mainnet). + +Operational visibility: + +* The service exposes controllers that report batch status and per-batch transaction ids. +* The service supports opening transaction ids in a TRON block explorer for testnet validation. + +#### Merkle tooling scripts (Python and Java) {#merkle-tooling-scripts-python-and-java} + +Responsibilities: + +* Generate the whitelist Merkle root from an address list. +* Generate the batch Merkle root from transfer leaf payloads. +* Generate inclusion proofs for transfer execution. +* Support deployment and end-to-end demo flows, as described in transcripts. +* Leaf encoding rules, hash function, and concatenation rules. +* Sorting rules, padding rules, and odd-leaf handling. +* Cross-language consistency checks between Python and Java implementations. + +### Data model {#data-model} + +#### Transfer leaf payload {#transfer-leaf-payload} + +The transcripts describe the following logical fields for a transfer leaf: +* Sender address (from, equals txData.from). +* Recipient address (to). +* Token amount (amount). +* Timestamp (timestamp). +* Nonce (nonce). +* Transaction type (type). +* Recipient count (recipientCount), used for fee computation, not for on-chain execution fan-out. +* Batch reference fields (batchId) as off-chain metadata, where Settlement defines batchId at submit time, and leaf hashing can omit batchId when root-binding exists. + +#### Whitelist leaf payload {#whitelist-leaf-payload} + +* The whitelist leaf represents a sender address membership element, typically an address or an address hash. +* Whether the tree uses raw addresses or hashed addresses as leaves. +* Whether the contract normalizes addresses before hashing. + +### Protocol flows {#protocol-flows} + +#### Flow A: whitelist root update {#flow-a-whitelist-root-update} + +1. The off-chain process collects eligible sender addresses (txData.from candidates). +2. The off-chain process builds a whitelist Merkle tree and produces a whitelist root. +3. The root signer signs the whitelist root. +4. A submitter sends the signed root to the whitelist registry contract. +5. The whitelist registry contract verifies the signature and stores the new root. + +#### Flow B: batch commit {#flow-b-batch-commit} + +1. The batch builder collects transfer requests into a queue. +2. The batch builder selects a batch boundary by count or time window. +3. The batch builder builds a batch Merkle tree and produces a batch root. +4. The submitter commits the batch root to the settlement contract. +5. The settlement contract stores the commitment and sets an unlock time. +6. The settlement contract defines batchId as an internal identifier, and off-chain systems treat batchId as a logical handle for monitoring and correlation. + +#### Flow C: approve and transfer execution {#flow-c-approve-and-transfer-execution} + +1. The sender submits a TRC-20 approve transaction that grants allowance to the settlement contract. +2. The executor waits until the unlock time passes. +3. The executor calls executeTransfer for a specific transfer leaf and supplies the leaf payload and Merkle proof. +4. The executor supplies a whitelist proof for the sender address (txData.from) when the transaction type requires whitelist gating. +5. The settlement contract verifies proofs, computes a virtual fee, and calls TRC-20 transferFrom. +6. The settlement contract marks the transfer as executed and rejects repeated execution attempts. + +### Optional versus required elements {#optional-versus-required-elements} + +#### Required for MVP operation {#required-for-mvp-operation} + +* Settlement contract deployment and configuration. +* Off-chain batch builder that produces batch roots and proofs. +* A funded executor account that can pay TRON resources for on-chain calls. +* TRC-20 approve from each sender address, sized to the intended transfer amounts. + +#### Optional in the MVP design {#optional-in-the-mvp-design} + +* Whitelist gating for batch-transfer types. +* Fee module integration beyond analytics and virtual accounting. +* Request whitelist flow, beyond event emission. +* Operational controllers and dashboards. + +### Security and trust model {#security-and-trust-model} + +#### Centralization and operator trust {#centralization-and-operator-trust} + +* The MVP assumes a trusted operator group for batch creation and root submission. +* The MVP uses contract ownership and role-based access control for administrative actions. +* This trust assumption acts as an explicit MVP trade-off, and Merkle commitments plus per-leaf execution provide a baseline for a later permissionless model with additional constraints. + +#### Allowance-based risk surface {#allowance-based-risk-surface} + +* The settlement contract uses transferFrom, so sender allowances define the maximum amount the settlement contract can transfer. +* A sender SHOULD scope allowances to intended amounts to limit exposure. +* Existence of per-transfer user signatures, and how the contract verifies them. +* Existence of additional constraints that bind leaf “from” to an authenticated actor. + +#### Unlock time semantics {#unlock-time-semantics} + +* The settlement contract enforces a time lock before execution. +* The MVP does not implement an on-chain fraud proof or on-chain batch rollback in transcripts. +* Unlock time acts as an operational review window for the operator group or automated checks. +* Whether the system supports batch cancellation before unlock. +* Whether the system supports marking a batch invalid after commit. + +#### TRON resource considerations {#tron-resource-considerations} + +* The executor account pays Energy/Bandwidth for commit batch and execute transfer calls. +* Stake 2.0 resource provisioning determines sustainable throughput for the executor. +* Measured Energy/Bandwidth usage per commit batch and per execute transfer. +* Maximum batch size limits and expected commit cadence. + +## Developer-oriented notes {#developer-oriented-notes} + +### Design decisions {#design-decisions} + +#### Merkle root as batch commitment {#merkle-root-as-batch-commitment} + +* The MVP uses a Merkle root to anchor a set of transfers with constant-size on-chain storage per batch. +* The MVP verifies inclusion per transfer via a Merkle proof and executes transfers individually. + +#### Per-transfer execution instead of single-call settlement {#per-transfer-execution-instead-of-single-call-settlement} + +* The MVP prioritizes correctness and implementation speed by executing one transfer per executeTransfer call. +* The MVP does not compress multiple transfers into one on-chain state transition. + +#### Sponsored execution via transferFrom {#sponsored-execution-via-transferfrom} + +* The MVP uses TRC-20 approve and transferFrom so an executor can sponsor transfer execution. +* The sender pays for approval, and the executor pays for executeTransfer resource costs. + +#### Whitelist gating via Merkle proof {#whitelist-gating-via-merkle-proof} + +* The MVP stores whitelist membership as a Merkle root to avoid large on-chain address lists. +* The MVP requires a whitelist proof only for batch-transfer types, and the whitelist proof targets the sender address (txData.from). + +#### Unlock time as a review window {#unlock-time-as-a-review-window} + +* The MVP inserts a time delay between commit and execution. +* The MVP uses unlock time as a control point for operator review, without an on-chain dispute mechanism. + +#### Off-chain state as in-memory storage {#off-chain-state-as-in-memory-storage} + +* The MVP uses in-memory and local storage for speed of delivery. +* The MVP accepts restart and durability risks in exchange for a short lead time. + +### Integration notes {#integration-notes} + +#### Integration roles {#integration-roles} + +* The batch builder service acts as an aggregator and as an executor in the MVP. +* A production system MAY separate aggregator and executor roles for isolation and security. + +#### Integration sequence for a dApp {#integration-sequence-for-a-dapp} + +* The dApp collects transfer parameters off-chain and forwards them to the batch builder. +* The dApp prompts the user to submit a TRC-20 approve transaction for the settlement contract. +* The batch builder commits a batch and executes transfers after unlock time. +* The dApp reads status via the batch builder controllers and on-chain events. +* Existence of a stable API surface for the batch builder service, including request schemas and authentication. +* Existence of an intent signature scheme or attestations for transfer authorization. + +### Edge cases {#edge-cases} + +#### Allowance and balance changes between commit and execution {#allowance-and-balance-changes-between-commit-and-execution} + +* A sender MAY reduce allowance after commit, which causes transferFrom to fail. +* A sender MAY spend tokens after commit, which reduces balance and causes transferFrom to fail. +* The executor SHOULD handle partial execution failures and report per-transfer status. + +#### Nonce collisions and replay {#nonce-collisions-and-replay} + +* A transfer leaf SHOULD carry a nonce that prevents replay. +* The settlement contract MUST reject repeated execution attempts for the same leaf or nonce, depending on implementation. +* Whether the contract tracks nonces per sender or tracks leaf hashes. +* Whether the contract rejects duplicate leaf payloads across batches. + +#### Token contract behavior {#token-contract-behavior} + +* TRC-20 tokens differ in revert patterns and return values. +* The settlement contract SHOULD handle non-standard TRC-20 behaviors if the design targets multiple tokens. +* Whether the MVP targets USDT/USDC only, or arbitrary TRC-20 tokens. +* Whether the settlement contract uses safe wrappers for token calls. + +#### Batch composition risks {#batch-composition-risks} + +* A trusted operator defines batch contents in the MVP. +* Operator errors in leaf construction cause execution failures or unintended transfers. + +### Limitations and non-goals {#limitations-and-non-goals} + +* The MVP does not implement a full rollup dispute system. +* The MVP does not minimize on-chain transfer calls, because each leaf executes separately. +* The MVP does not define a complete permissionless batch submission model in transcripts. +* The MVP does not define a decentralized whitelist update mechanism in transcripts. + +## Reference and examples {#reference-and-examples} + +### Terms {#terms} + +* Batch: a set of transfer leaves anchored by a Merkle root. +* Batch commitment: an on-chain record of a batch root and metadata. +* Transfer leaf: a payload that represents one TRC-20 transfer, hashed into the Merkle tree. +* Inclusion proof: a Merkle path that proves membership of a leaf under a root. +* Whitelist root: a Merkle root that represents eligible sender addresses (txData.from) for a gated transfer type. +* Unlock time: a time lock after commit and before execution. +* Executor: an account that submits commit and executes transactions, and pays TRON resources. + +### Examples {#examples} + +#### Transfer leaf payload {#transfer-leaf-payload} + +The MVP transcripts do not define the exact field ordering, types, and hashing rules. + +``` +```{ \ +"from": "T...sender", \ +"to": "T...recipient", \ +"token": "T...trc20Contract", \ +"amount": "1000000", \ +"nonce": 11, \ +"timestamp": 1730000000, \ +"type": "BATCH", \ +"recipientCount": 3, \ +"batchId": "0x...optional_logical_handle" \ +} +``` + +#### Example whitelist input {#example-whitelist-input} + +``` +[ +"T...addr1", \ +"T...addr2", \ +"T...addr3" \ +] +``` + + +#### Sequence diagram: whitelist root update {#sequence-diagram-whitelist-root-update} + +Actors: Root signer, Submitter, Whitelist registry contract. + + + +1. Root signer -> Off-chain tooling: Build whitelist Merkle root. +2. Root signer -> Off-chain tooling: Sign whitelist Merkle root. +3. Submitter -> Whitelist registry contract: Update root with signed root. +4. Whitelist registry contract -> On-chain state: Store new whitelist root. + + +#### Sequence diagram: batch commit and execution {#sequence-diagram-batch-commit-and-execution} + +Actors: Batch builder, Settlement contract, Whitelist registry, Fee module, TRC-20 token. + + + +1. Batch builder -> Off-chain state: Collect transfer requests. +2. Batch builder -> Off-chain tooling: Build batch Merkle root and proofs. +3. Batch builder -> Settlement contract: Commit batch root and metadata. +4. Settlement contract -> On-chain state: Store commitment and unlock time. +5. Sender -> TRC-20 token: Approve settlement contract allowance. +6. Batch builder -> Settlement contract: Call executeTransfer with leaf + proof (+ whitelist proof when required). +7. Settlement contract -> Whitelist registry: Verify whitelist membership for the sender address (txData.from) when required. +8. Settlement contract -> Fee module: Compute virtual fee. +9. Settlement contract -> TRC-20 token: Call transferFrom(from, to, amount). +10. Settlement contract -> On-chain state: Mark leaf as executed. + + +#### Pseudocode: batch boundary selection {#pseudocode-batch-boundary-selection} + +``` +state queue := [] \ +state windowStart := now() + +onTransferRequest(req): \ +enqueue(queue, req) \ +if size(queue) >= COUNT_THRESHOLD: \ +createBatchAndCommit(queue) \ +clear(queue) \ +windowStart := now() \ +return \ +if now() - windowStart >= TIME_WINDOW: \ +createBatchAndCommit(queue) \ +clear(queue) \ +windowStart := now() \ +return +``` + + +#### Pseudocode: Merkle commitment and per-leaf execution {#pseudocode-merkle-commitment-and-per-leaf-execution} + +``` +function createBatchAndCommit(queue): \ +leaves := map(queue, encodeLeafPayload) \ +root := merkleRoot(leaves) \ +metadata := buildBatchMetadata(queue) \ +sendTx(settlement.commitBatch, root, metadata) \ +batchId := readEvent(BatchSubmitted).batchId + +function executeLeaf(batchId, leafPayload, merkleProof, whitelistProofOpt): \ +assert(now() >= settlement.unlockTime(batchId)) \ +assert(settlement.verifyInclusion(leafPayload, merkleProof)) \ +if leafPayload.type == "BATCH": \ +assert(whitelistRegistry.verifyWhitelist(leafPayload.from, whitelistProofOpt)) \ +fee := feeModule.compute(leafPayload) \ +assert(trc20.allowance(leafPayload.from, settlement.address) >= leafPayload.amount) \ +sendTx(settlement.executeTransfer, leafPayload, merkleProof, whitelistProofOpt) +``` + + +### Failure classes {#failure-classes} + +* Batch not found or not committed. +* Batch locked due to unlock time. +* Merkle proof is invalid for the supplied leaf. +* Whitelist proof missing or invalid for a gated type. +* Transfer already executed (replay attempt). +* Nonce invalid or reused, depending on implementation. +* TRC-20 allowance insufficient or balance insufficient. +* TRC-20 token call failure due to non-standard behavior. \ No newline at end of file diff --git a/docs/TIP-bftch Guide.md b/docs/TIP-bftch Guide.md deleted file mode 100644 index e69de29..0000000