diff --git a/.github/workflows/rebalancer-sim-test.yml b/.github/workflows/rebalancer-sim-test.yml new file mode 100644 index 00000000000..d24d3843f7d --- /dev/null +++ b/.github/workflows/rebalancer-sim-test.yml @@ -0,0 +1,84 @@ +name: rebalancer-sim-test + +on: + push: + branches: [main] + paths: + - 'typescript/rebalancer/**' + - 'typescript/rebalancer-sim/**' + - 'solidity/contracts/mock/MockValueTransferBridge.sol' + - '.github/workflows/rebalancer-sim-test.yml' + pull_request: + paths: + - 'typescript/rebalancer/**' + - 'typescript/rebalancer-sim/**' + - 'solidity/contracts/mock/MockValueTransferBridge.sol' + - '.github/workflows/rebalancer-sim-test.yml' + workflow_dispatch: + +concurrency: + group: rebalancer-sim-${{ github.ref }} + cancel-in-progress: true + +jobs: + rebalancer-sim-test-matrix: + runs-on: depot-ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + test: + # Split full-simulation by scenario (file_grep-pattern, using _ as separator) + - full-simulation_extreme-drain + - full-simulation_extreme-accumulate + - full-simulation_large-unidirectional + - full-simulation_whale-transfers + - full-simulation_balanced-bidirectional + - full-simulation_random-with-headroom + # Other test files + - inflight-guard + - harness-setup + - unidirectional + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + submodules: recursive + + - name: pnpm-build + uses: ./.github/actions/pnpm-build-with-cache + + - name: Setup Foundry + uses: ./.github/actions/setup-foundry + + - name: Run rebalancer-sim test (${{ matrix.test }}) + working-directory: typescript/rebalancer-sim + run: | + if [[ "${{ matrix.test }}" == *"_"* ]]; then + FILE=$(echo "${{ matrix.test }}" | cut -d_ -f1) + PATTERN=$(echo "${{ matrix.test }}" | cut -d_ -f2) + pnpm mocha --config .mocharc.json "./test/**/*${FILE}*.test.ts" --grep "${PATTERN}" --exit + else + pnpm mocha --config .mocharc.json "./test/**/*${{ matrix.test }}*.test.ts" --exit + fi + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v5 + with: + name: rebalancer-sim-results-${{ matrix.test }} + path: typescript/rebalancer-sim/results/*.html + if-no-files-found: ignore + retention-days: 7 + + rebalancer-sim-test: + runs-on: ubuntu-latest + needs: [rebalancer-sim-test-matrix] + if: always() + steps: + - uses: actions/checkout@v6 + - name: Check rebalancer-sim test status + uses: ./.github/actions/check-job-status + with: + job_name: 'Rebalancer Sim Test' + result: ${{ needs.rebalancer-sim-test-matrix.result }} diff --git a/.lintstagedrc b/.lintstagedrc index 1edf348a217..097aef219d0 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -2,5 +2,6 @@ "*.js": "prettier --write", "*.ts": "prettier --write", "*.md": "prettier --write", - "*.sol": "prettier --write" + "*.sol": "prettier --write", + "*.json": "prettier --write" } diff --git a/Dockerfile b/Dockerfile index 1b6691f8e9a..d20d6cbb503 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,7 @@ COPY typescript/keyfunder/package.json ./typescript/keyfunder/ COPY typescript/provider-sdk/package.json ./typescript/provider-sdk/ COPY typescript/radix-sdk/package.json ./typescript/radix-sdk/ COPY typescript/rebalancer/package.json ./typescript/rebalancer/ +COPY typescript/rebalancer-sim/package.json ./typescript/rebalancer-sim/ COPY typescript/relayer/package.json ./typescript/relayer/ COPY typescript/sdk/package.json ./typescript/sdk/ COPY typescript/tsconfig/package.json ./typescript/tsconfig/ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d50896c8f11..56d25ae1532 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2052,6 +2052,79 @@ importers: specifier: 'catalog:' version: 5.8.3 + typescript/rebalancer-sim: + dependencies: + '@hyperlane-xyz/core': + specifier: workspace:* + version: link:../../solidity + '@hyperlane-xyz/provider-sdk': + specifier: workspace:* + version: link:../provider-sdk + '@hyperlane-xyz/rebalancer': + specifier: workspace:* + version: link:../rebalancer + '@hyperlane-xyz/registry': + specifier: 'catalog:' + version: 23.7.0 + '@hyperlane-xyz/sdk': + specifier: workspace:* + version: link:../sdk + '@hyperlane-xyz/utils': + specifier: workspace:* + version: link:../utils + ethers: + specifier: 'catalog:' + version: 5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + pino: + specifier: 'catalog:' + version: 8.21.0 + pino-pretty: + specifier: 'catalog:' + version: 13.1.2 + zod: + specifier: 'catalog:' + version: 3.25.76 + devDependencies: + '@hyperlane-xyz/tsconfig': + specifier: workspace:^ + version: link:../tsconfig + '@types/chai': + specifier: 'catalog:' + version: 4.3.20 + '@types/chai-as-promised': + specifier: 'catalog:' + version: 8.0.2 + '@types/mocha': + specifier: 'catalog:' + version: 10.0.10 + '@types/node': + specifier: 'catalog:' + version: 24.10.9 + chai: + specifier: 'catalog:' + version: 4.5.0 + chai-as-promised: + specifier: 'catalog:' + version: 8.0.2(chai@4.5.0) + eslint: + specifier: 'catalog:' + version: 9.31.0(jiti@2.6.1) + mocha: + specifier: 'catalog:' + version: 11.7.5 + prettier: + specifier: 'catalog:' + version: 3.5.3 + testcontainers: + specifier: 'catalog:' + version: 11.7.0 + tsx: + specifier: 'catalog:' + version: 4.19.1 + typescript: + specifier: 'catalog:' + version: 5.8.3 + typescript/relayer: dependencies: '@arbitrum/sdk': @@ -2596,7 +2669,7 @@ importers: version: 2.2.1(typescript@5.8.3) '@rainbow-me/rainbowkit': specifier: ^2.2.0 - version: 2.2.9(@tanstack/react-query@5.90.10(react@18.3.1))(@types/react@18.3.27)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12))(wagmi@2.19.4(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@18.3.1))(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.2.0)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.12)) + version: 2.2.9(@tanstack/react-query@5.90.10(react@18.3.1))(@types/react@18.3.27)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.19.4(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@18.3.1))(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.2.0)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76)) '@solana/wallet-adapter-react': specifier: ^0.15.32 version: 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@6.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.8.3) @@ -2614,7 +2687,7 @@ importers: version: 3.7.4(bufferutil@4.0.9)(get-starknet-core@4.0.0)(react@18.3.1)(starknet@7.6.4)(typescript@5.8.3)(utf-8-validate@5.0.10) '@wagmi/core': specifier: ^2.12.26 - version: 2.22.1(@tanstack/query-core@5.90.10)(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)) + version: 2.22.1(@tanstack/query-core@5.90.10)(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -2632,13 +2705,13 @@ importers: version: 7.6.4 starknetkit: specifier: 2.6.1 - version: 2.6.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(starknet@7.6.4)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + version: 2.6.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(starknet@7.6.4)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) viem: specifier: 'catalog:' - version: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + version: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) wagmi: specifier: ^2.12.26 - version: 2.19.4(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@18.3.1))(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.2.0)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.12) + version: 2.19.4(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@18.3.1))(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.2.0)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) yaml: specifier: 'catalog:' version: 2.4.5 @@ -15928,6 +16001,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me tdigest@0.1.2: resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} @@ -18621,16 +18695,16 @@ snapshots: '@balena/dockerignore@1.0.2': {} - '@base-org/account@2.4.0(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.12)': + '@base-org/account@2.4.0(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76)': dependencies: '@coinbase/cdp-sdk': 1.38.6(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@noble/hashes': 1.4.0 clsx: 1.2.1 eventemitter3: 5.0.1 idb-keyval: 6.2.1 - ox: 0.6.9(typescript@5.8.3)(zod@4.1.12) + ox: 0.6.9(typescript@5.8.3)(zod@3.25.76) preact: 10.24.2 - viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) zustand: 5.0.3(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)) transitivePeerDependencies: - '@types/react' @@ -18999,15 +19073,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@coinbase/wallet-sdk@4.3.6(@types/react@18.3.27)(bufferutil@4.0.9)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(zod@4.1.12)': + '@coinbase/wallet-sdk@4.3.6(@types/react@18.3.27)(bufferutil@4.0.9)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/hashes': 1.4.0 clsx: 1.2.1 eventemitter3: 5.0.1 idb-keyval: 6.2.1 - ox: 0.6.9(typescript@5.8.3)(zod@4.1.12) + ox: 0.6.9(typescript@5.8.3)(zod@3.25.76) preact: 10.24.2 - viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) zustand: 5.0.3(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)) transitivePeerDependencies: - '@types/react' @@ -20427,11 +20501,11 @@ snapshots: optionalDependencies: '@trufflesuite/bigint-buffer': 1.1.9 - '@gemini-wallet/core@0.3.2(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12))': + '@gemini-wallet/core@0.3.2(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))': dependencies: '@metamask/rpc-errors': 7.0.2 eventemitter3: 5.0.1 - viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - supports-color @@ -22381,7 +22455,7 @@ snapshots: reflect-metadata: 0.1.13 secp256k1: 5.0.0 - '@rainbow-me/rainbowkit@2.2.9(@tanstack/react-query@5.90.10(react@18.3.1))(@types/react@18.3.27)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12))(wagmi@2.19.4(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@18.3.1))(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.2.0)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.12))': + '@rainbow-me/rainbowkit@2.2.9(@tanstack/react-query@5.90.10(react@18.3.1))(@types/react@18.3.27)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.19.4(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@18.3.1))(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.2.0)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))': dependencies: '@tanstack/react-query': 5.90.10(react@18.3.1) '@vanilla-extract/css': 1.17.3(babel-plugin-macros@3.1.0) @@ -22393,8 +22467,8 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-remove-scroll: 2.6.2(@types/react@18.3.27)(react@18.3.1) ua-parser-js: 1.0.41 - viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - wagmi: 2.19.4(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@18.3.1))(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.2.0)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.12) + viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + wagmi: 2.19.4(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@18.3.1))(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.2.0)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) transitivePeerDependencies: - '@types/react' - babel-plugin-macros @@ -23451,24 +23525,24 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-common@1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@reown/appkit-common@1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: big.js: 6.2.2 dayjs: 1.11.13 - viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - bufferutil - typescript - utf-8-validate - zod - '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 1.13.2(@types/react@18.3.27)(react@18.3.1) - viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -23497,12 +23571,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@18.3.1))(zod@4.1.12) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@18.3.1))(zod@3.25.76) lit: 3.3.0 valtio: 1.13.2(@types/react@18.3.27)(react@18.3.1) transitivePeerDependencies: @@ -23537,12 +23611,12 @@ snapshots: dependencies: buffer: 6.0.3 - '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@18.3.1))(zod@4.1.12)': + '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@18.3.1))(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@18.3.1))(zod@4.1.12) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@18.3.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -23574,10 +23648,10 @@ snapshots: - valtio - zod - '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 qrcode: 1.5.3 @@ -23609,16 +23683,16 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@18.3.1))(zod@4.1.12)': + '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@18.3.1))(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 1.13.2(@types/react@18.3.27)(react@18.3.1) - viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -23658,21 +23732,21 @@ snapshots: - typescript - utf-8-validate - '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 - '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@18.3.1))(zod@4.1.12) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@18.3.1))(zod@4.1.12) + '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@18.3.1))(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@18.3.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) bs58: 6.0.0 valtio: 1.13.2(@types/react@18.3.27)(react@18.3.1) - viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -23840,9 +23914,9 @@ snapshots: - utf-8-validate - zod - '@safe-global/safe-apps-provider@0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@safe-global/safe-apps-provider@0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - bufferutil @@ -23850,10 +23924,10 @@ snapshots: - utf-8-validate - zod - '@safe-global/safe-apps-sdk@9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@safe-global/safe-apps-sdk@9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@safe-global/safe-gateway-typescript-sdk': 3.23.1 - viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - bufferutil - typescript @@ -25886,7 +25960,7 @@ snapshots: '@turnkey/webauthn-stamper': 0.6.0 '@wallet-standard/app': 1.1.0 '@wallet-standard/base': 1.1.0 - '@walletconnect/sign-client': 2.23.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/types': 2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) cross-fetch: 3.2.0 ethers: 6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) @@ -26916,19 +26990,19 @@ snapshots: loupe: 2.3.7 pretty-format: 29.7.0 - '@wagmi/connectors@6.1.4(0412c5480cb372a861c205176362b8d1)': + '@wagmi/connectors@6.1.4(3015cd4f3cc533a2a82d169692de7690)': dependencies: - '@base-org/account': 2.4.0(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.12) - '@coinbase/wallet-sdk': 4.3.6(@types/react@18.3.27)(bufferutil@4.0.9)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(zod@4.1.12) - '@gemini-wallet/core': 0.3.2(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)) + '@base-org/account': 2.4.0(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) + '@coinbase/wallet-sdk': 4.3.6(@types/react@18.3.27)(bufferutil@4.0.9)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(zod@3.25.76) + '@gemini-wallet/core': 0.3.2(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) '@metamask/sdk': 0.33.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.10)(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)) - '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.10)(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.35(52953fff89e02f75760b90db6005bc45) - viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + porto: 0.2.35(f8a5cd75b5ca2acd10c494cdcd5e46bd) + viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -26970,11 +27044,11 @@ snapshots: - ws - zod - '@wagmi/core@2.22.1(@tanstack/query-core@5.90.10)(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12))': + '@wagmi/core@2.22.1(@tanstack/query-core@5.90.10)(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))': dependencies: eventemitter3: 5.0.1 mipd: 0.0.7(typescript@5.8.3) - viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) zustand: 5.0.0(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)) optionalDependencies: '@tanstack/query-core': 5.90.10 @@ -27012,7 +27086,7 @@ snapshots: dependencies: '@wallet-standard/base': 1.1.0 - '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -27026,7 +27100,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -27056,7 +27130,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -27070,7 +27144,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -27100,51 +27174,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': - dependencies: - '@walletconnect/heartbeat': 1.2.2 - '@walletconnect/jsonrpc-provider': 1.0.14 - '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) - '@walletconnect/logger': 3.0.0 - '@walletconnect/relay-api': 1.0.11 - '@walletconnect/relay-auth': 1.1.0 - '@walletconnect/safe-json': 1.0.2 - '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(typescript@5.8.3)(zod@4.1.12) - '@walletconnect/window-getters': 1.0.1 - es-toolkit: 1.39.3 - events: 3.3.0 - uint8arrays: 3.1.1 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@netlify/blobs' - - '@planetscale/database' - - '@react-native-async-storage/async-storage' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - bufferutil - - db0 - - ioredis - - typescript - - uploadthing - - utf-8-validate - - zod - - '@walletconnect/core@2.23.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -27158,7 +27188,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.23.0(typescript@5.8.3)(zod@3.25.76) + '@walletconnect/utils': 2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(typescript@5.8.3)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.39.3 events: 3.3.0 @@ -27192,18 +27222,18 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/jsonrpc-http-connection': 1.0.8 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) - '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -27349,16 +27379,16 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -27385,52 +27415,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - events: 3.3.0 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@netlify/blobs' - - '@planetscale/database' - - '@react-native-async-storage/async-storage' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - bufferutil - - db0 - - ioredis - - typescript - - uploadthing - - utf-8-validate - - zod - - '@walletconnect/sign-client@2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': - dependencies: - '@walletconnect/core': 2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@walletconnect/events': 1.0.1 - '@walletconnect/heartbeat': 1.2.2 - '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/logger': 3.0.0 - '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(typescript@5.8.3)(zod@4.1.12) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -27457,16 +27451,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.23.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.23.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 3.0.0 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.23.0(typescript@5.8.3)(zod@3.25.76) + '@walletconnect/utils': 2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(typescript@5.8.3)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -27613,7 +27607,7 @@ snapshots: - ioredis - uploadthing - '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8 @@ -27622,9 +27616,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -27653,7 +27647,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8 @@ -27662,9 +27656,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -27693,7 +27687,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 @@ -27711,7 +27705,7 @@ snapshots: detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -27737,7 +27731,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 @@ -27755,7 +27749,7 @@ snapshots: detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -27781,52 +27775,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(typescript@5.8.3)(zod@4.1.12)': - dependencies: - '@msgpack/msgpack': 3.1.2 - '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.7 - '@noble/hashes': 1.8.0 - '@scure/base': 1.2.6 - '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) - '@walletconnect/logger': 3.0.0 - '@walletconnect/relay-api': 1.0.11 - '@walletconnect/relay-auth': 1.1.0 - '@walletconnect/safe-json': 1.0.2 - '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))) - '@walletconnect/window-getters': 1.0.1 - '@walletconnect/window-metadata': 1.0.1 - blakejs: 1.2.1 - bs58: 6.0.0 - detect-browser: 5.3.0 - ox: 0.9.3(typescript@5.8.3)(zod@4.1.12) - uint8arrays: 3.1.1 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@netlify/blobs' - - '@planetscale/database' - - '@react-native-async-storage/async-storage' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - db0 - - ioredis - - typescript - - uploadthing - - zod - - '@walletconnect/utils@2.23.0(typescript@5.8.3)(zod@3.25.76)': + '@walletconnect/utils@2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(typescript@5.8.3)(zod@3.25.76)': dependencies: '@msgpack/msgpack': 3.1.2 '@noble/ciphers': 1.3.0 @@ -28002,10 +27951,10 @@ snapshots: typescript: 5.8.3 zod: 3.25.76 - abitype@1.0.8(typescript@5.8.3)(zod@4.1.12): + abitype@1.0.8(typescript@5.8.3)(zod@3.25.76): optionalDependencies: typescript: 5.8.3 - zod: 4.1.12 + zod: 3.25.76 abitype@1.1.0(typescript@5.8.3)(zod@3.22.4): optionalDependencies: @@ -28017,11 +27966,6 @@ snapshots: typescript: 5.8.3 zod: 3.25.76 - abitype@1.1.0(typescript@5.8.3)(zod@4.1.12): - optionalDependencies: - typescript: 5.8.3 - zod: 4.1.12 - abitype@1.1.2(typescript@5.8.3)(zod@3.22.4): optionalDependencies: typescript: 5.8.3 @@ -33967,28 +33911,28 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - ox@0.6.7(typescript@5.8.3)(zod@4.1.12): + ox@0.6.7(typescript@5.8.3)(zod@3.25.76): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.1.2(typescript@5.8.3)(zod@4.1.12) + abitype: 1.1.2(typescript@5.8.3)(zod@3.25.76) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: - zod - ox@0.6.9(typescript@5.8.3)(zod@4.1.12): + ox@0.6.9(typescript@5.8.3)(zod@3.25.76): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.1.2(typescript@5.8.3)(zod@4.1.12) + abitype: 1.1.2(typescript@5.8.3)(zod@3.25.76) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.8.3 @@ -34025,21 +33969,6 @@ snapshots: transitivePeerDependencies: - zod - ox@0.9.3(typescript@5.8.3)(zod@4.1.12): - dependencies: - '@adraffy/ens-normalize': 1.11.1 - '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.1 - '@noble/hashes': 1.8.0 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 - abitype: 1.1.2(typescript@5.8.3)(zod@4.1.12) - eventemitter3: 5.0.1 - optionalDependencies: - typescript: 5.8.3 - transitivePeerDependencies: - - zod - ox@0.9.6(typescript@5.8.3)(zod@3.22.4): dependencies: '@adraffy/ens-normalize': 1.11.1 @@ -34070,21 +33999,6 @@ snapshots: transitivePeerDependencies: - zod - ox@0.9.6(typescript@5.8.3)(zod@4.1.12): - dependencies: - '@adraffy/ens-normalize': 1.11.1 - '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.1 - '@noble/hashes': 1.8.0 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 - abitype: 1.1.2(typescript@5.8.3)(zod@4.1.12) - eventemitter3: 5.0.1 - optionalDependencies: - typescript: 5.8.3 - transitivePeerDependencies: - - zod - p-cancelable@3.0.0: {} p-filter@2.1.0: @@ -34441,14 +34355,14 @@ snapshots: pony-cause@2.1.11: {} - porto@0.2.35(52953fff89e02f75760b90db6005bc45): + porto@0.2.35(f8a5cd75b5ca2acd10c494cdcd5e46bd): dependencies: - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.10)(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.10)(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) hono: 4.11.4 idb-keyval: 6.2.2 mipd: 0.0.7(typescript@5.8.3) ox: 0.9.14(typescript@5.8.3)(zod@4.1.12) - viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) zod: 4.1.12 zustand: 5.0.8(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)) optionalDependencies: @@ -34456,7 +34370,7 @@ snapshots: react: 18.3.1 react-native: 0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10) typescript: 5.8.3 - wagmi: 2.19.4(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@18.3.1))(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.2.0)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.12) + wagmi: 2.19.4(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@18.3.1))(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.2.0)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) transitivePeerDependencies: - '@types/react' - immer @@ -36172,14 +36086,14 @@ snapshots: pako: 2.1.0 ts-mixer: 6.0.4 - starknetkit@2.6.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(starknet@7.6.4)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12): + starknetkit@2.6.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(starknet@7.6.4)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76): dependencies: '@starknet-io/get-starknet': 4.0.8 '@starknet-io/get-starknet-core': 4.0.8 '@starknet-io/types-js': 0.7.10 '@trpc/client': 10.45.2(@trpc/server@10.45.2) '@trpc/server': 10.45.2 - '@walletconnect/sign-client': 2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@walletconnect/sign-client': 2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) bowser: 2.12.1 detect-browser: 5.3.0 eventemitter3: 5.0.1 @@ -37348,15 +37262,15 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 - viem@2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12): + viem@2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76): dependencies: '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@scure/bip32': 1.6.2 '@scure/bip39': 1.5.4 - abitype: 1.0.8(typescript@5.8.3)(zod@4.1.12) + abitype: 1.0.8(typescript@5.8.3)(zod@3.25.76) isows: 1.0.6(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - ox: 0.6.7(typescript@5.8.3)(zod@4.1.12) + ox: 0.6.7(typescript@5.8.3)(zod@3.25.76) ws: 8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.8.3 @@ -37399,23 +37313,6 @@ snapshots: - utf-8-validate - zod - viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12): - dependencies: - '@noble/curves': 1.9.1 - '@noble/hashes': 1.8.0 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 - abitype: 1.1.0(typescript@5.8.3)(zod@4.1.12) - isows: 1.0.7(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - ox: 0.9.6(typescript@5.8.3)(zod@4.1.12) - ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) - optionalDependencies: - typescript: 5.8.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - zod - vite-node@1.4.0(@types/node@24.10.9)(terser@5.44.1): dependencies: cac: 6.7.14 @@ -37480,14 +37377,14 @@ snapshots: vlq@1.0.1: {} - wagmi@2.19.4(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@18.3.1))(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.2.0)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.12): + wagmi@2.19.4(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@18.3.1))(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.2.0)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@18.3.27)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76): dependencies: '@tanstack/react-query': 5.90.10(react@18.3.1) - '@wagmi/connectors': 6.1.4(0412c5480cb372a861c205176362b8d1) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.10)(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)) + '@wagmi/connectors': 6.1.4(3015cd4f3cc533a2a82d169692de7690) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.10)(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) react: 18.3.1 use-sync-external-store: 1.4.0(react@18.3.1) - viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + viem: 2.39.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: diff --git a/solidity/contracts/mock/MockValueTransferBridge.sol b/solidity/contracts/mock/MockValueTransferBridge.sol index 67318f530b0..cdc779a1a5e 100644 --- a/solidity/contracts/mock/MockValueTransferBridge.sol +++ b/solidity/contracts/mock/MockValueTransferBridge.sol @@ -2,8 +2,11 @@ pragma solidity ^0.8.13; import {ITokenBridge, Quote} from "../interfaces/ITokenBridge.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; contract MockValueTransferBridge is ITokenBridge { + using SafeERC20 for IERC20; address public collateral; constructor(address _collateral) { @@ -33,6 +36,13 @@ contract MockValueTransferBridge is ITokenBridge { bytes32 _recipient, uint256 _amountOut ) external payable virtual override returns (bytes32 transferId) { + // Pull tokens from caller (warp token) - caller must have approved this bridge + IERC20(collateral).safeTransferFrom( + msg.sender, + address(this), + _amountOut + ); + emit SentTransferRemote( uint32(block.chainid), _destinationDomain, diff --git a/typescript/cli/src/tests/ethereum/warp/warp-rebalancer.e2e-test.ts b/typescript/cli/src/tests/ethereum/warp/warp-rebalancer.e2e-test.ts index 1b2c14bb6be..45c434e2496 100644 --- a/typescript/cli/src/tests/ethereum/warp/warp-rebalancer.e2e-test.ts +++ b/typescript/cli/src/tests/ethereum/warp/warp-rebalancer.e2e-test.ts @@ -6,6 +6,7 @@ import { $, type ProcessPromise } from 'zx'; import { type ERC20, + ERC20Test__factory, ERC20__factory, HypERC20Collateral__factory, MockValueTransferBridge__factory, @@ -941,10 +942,6 @@ describe('hyperlane warp rebalancer e2e tests', async function () { const originDomain = chain3Metadata.domainId; const destDomain = chain2Metadata.domainId; - // Chain names - const originName = CHAIN_NAME_3; - const destName = CHAIN_NAME_2; - // RPC URLs const originRpc = chain3Metadata.rpcUrls[0].http; const destRpc = chain2Metadata.rpcUrls[0].http; @@ -998,6 +995,15 @@ describe('hyperlane warp rebalancer e2e tests', async function () { }, }); + const originTkn = ERC20__factory.connect(originTknAddress, originProvider); + const destTkn = ERC20__factory.connect(destTknAddress, destProvider); + + // Verify initial balances before rebalancing + let originBalance = await originTkn.balanceOf(originContractAddress); + let destBalance = await destTkn.balanceOf(destContractAddress); + expect(originBalance.toString()).to.equal(toWei(10)); + expect(destBalance.toString()).to.equal(toWei(10)); + // Promise that will resolve with the event that is emitted by the bridge when the rebalance transaction is sent const listenForSentTransferRemote = new Promise<{ origin: Domain; @@ -1032,27 +1038,21 @@ describe('hyperlane warp rebalancer e2e tests', async function () { expect(sentTransferRemote.recipient).to.equal(destContractAddress); expect(sentTransferRemote.amount).to.equal(BigInt(toWei(5))); - const originTkn = ERC20__factory.connect(originTknAddress, originProvider); - const destTkn = ERC20__factory.connect(destTknAddress, destProvider); - - let originBalance = await originTkn.balanceOf(originContractAddress); - let destBalance = await destTkn.balanceOf(destContractAddress); - - // Verify that the tokens are in the right place before the transfer - expect(originBalance.toString()).to.equal(toWei(10)); - expect(destBalance.toString()).to.equal(toWei(10)); + // Verify that the bridge pulled tokens from origin (10 - 5 = 5) + originBalance = await originTkn.balanceOf(originContractAddress); + expect(originBalance.toString()).to.equal(toWei(5)); - // Simulate rebalancing by transferring tokens from destination to origin chain. - // This process locks tokens on the destination chain and unlocks them on the origin, - // effectively increasing collateral on the destination while decreasing it on the origin, - // which achieves the desired rebalancing effect. - await hyperlaneWarpSendRelay({ - origin: destName, - destination: originName, - warpCorePath: warpCoreConfigPath, - relay: true, - value: sentTransferRemote.amount.toString(), - }); + // Simulate bridge delivery by minting tokens to destination warp token + // In a real bridge, tokens would be delivered to the destination chain + const destSigner = new Wallet(ANVIL_KEY, destProvider); + const destCollateralToken = ERC20Test__factory.connect( + destTknAddress, + destSigner, + ); + await destCollateralToken.mintTo( + destContractAddress, + sentTransferRemote.amount.toString(), + ); originBalance = await originTkn.balanceOf(originContractAddress); destBalance = await destTkn.balanceOf(destContractAddress); @@ -1241,6 +1241,18 @@ describe('hyperlane warp rebalancer e2e tests', async function () { }, }); + const originTkn = ERC20__factory.connect( + originTknAddress, + originProvider, + ); + const destTkn = ERC20__factory.connect(destTknAddress, destProvider); + + // Verify initial balances before rebalancing + let originBalance = await originTkn.balanceOf(originContractAddress); + let destBalance = await destTkn.balanceOf(destContractAddress); + expect(originBalance.toString()).to.equal(toWei(10)); + expect(destBalance.toString()).to.equal(toWei(10)); + // Promise that will resolve with the event that is emitted by the bridge when the rebalance transaction is sent const listenForSentTransferRemote = new Promise<{ origin: Domain; @@ -1284,30 +1296,22 @@ describe('hyperlane warp rebalancer e2e tests', async function () { BigInt(toWei(manualRebalanceAmount)), ); - const originTkn = ERC20__factory.connect( - originTknAddress, - originProvider, + // Verify that the bridge pulled tokens from origin (10 - 5 = 5) + originBalance = await originTkn.balanceOf(originContractAddress); + expect(originBalance.toString()).to.equal( + toWei(10 - Number(manualRebalanceAmount)), ); - const destTkn = ERC20__factory.connect(destTknAddress, destProvider); - - let originBalance = await originTkn.balanceOf(originContractAddress); - let destBalance = await destTkn.balanceOf(destContractAddress); - - // Verify that the tokens are in the right place before the transfer - expect(originBalance.toString()).to.equal(toWei(10)); - expect(destBalance.toString()).to.equal(toWei(10)); - // Simulate rebalancing by transferring tokens from destination to origin chain. - // This process locks tokens on the destination chain and unlocks them on the origin, - // effectively increasing collateral on the destination while decreasing it on the origin, - // which achieves the desired rebalancing effect. - await hyperlaneWarpSendRelay({ - origin: destName, - destination: originName, - warpCorePath: warpCoreConfigPath, - relay: true, - value: sentTransferRemote.amount.toString(), - }); + // Simulate bridge delivery by minting tokens to destination warp token + const destSigner = new Wallet(ANVIL_KEY, destProvider); + const destCollateralToken = ERC20Test__factory.connect( + destTknAddress, + destSigner, + ); + await destCollateralToken.mintTo( + destContractAddress, + sentTransferRemote.amount.toString(), + ); originBalance = await originTkn.balanceOf(originContractAddress); destBalance = await destTkn.balanceOf(destContractAddress); diff --git a/typescript/rebalancer-sim/.gitignore b/typescript/rebalancer-sim/.gitignore new file mode 100644 index 00000000000..77efc615d80 --- /dev/null +++ b/typescript/rebalancer-sim/.gitignore @@ -0,0 +1,6 @@ +.env +dist +cache + +# Simulation results (generated at runtime) +results/ diff --git a/typescript/rebalancer-sim/.mocharc.json b/typescript/rebalancer-sim/.mocharc.json new file mode 100644 index 00000000000..d2a8d566849 --- /dev/null +++ b/typescript/rebalancer-sim/.mocharc.json @@ -0,0 +1,6 @@ +{ + "import": ["tsx"], + "extension": ["ts"], + "timeout": 120000, + "exit": true +} diff --git a/typescript/rebalancer-sim/README.md b/typescript/rebalancer-sim/README.md new file mode 100644 index 00000000000..dfdf48dbb08 --- /dev/null +++ b/typescript/rebalancer-sim/README.md @@ -0,0 +1,582 @@ +# Rebalancer Simulation Harness + +A fast, real-time simulation framework for testing Hyperlane warp route rebalancers against synthetic transfer scenarios. + +## Purpose + +This simulator helps answer questions like: + +- Does the rebalancer respond correctly to liquidity imbalances? +- How quickly does the rebalancer restore balance after a traffic surge? +- What happens when bridge delays cause the rebalancer to over-correct? +- How do different rebalancer strategies compare on the same traffic pattern? + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ SimulationEngine │ +│ Orchestrates scenario execution, rebalancer polling, KPI collection│ +└─────────────────────────────────────────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌────────────────┐ ┌─────────────────┐ +│ Scenario │ │ Rebalancer │ │ BridgeMock │ +│ Generator │ │ Runners │ │ Controller │ +│ │ │ │ │ │ +│ Creates │ │ SimpleRunner │ │ Simulates slow │ +│ transfer │ │ (simplified) │ │ bridge delivery │ +│ patterns │ │ Production │ │ with config- │ +│ │ │ Rebalancer │ │ urable delays │ +└───────────────┘ └────────────────┘ └─────────────────┘ + │ │ │ + └────────────────────┼────────────────────┘ + ▼ + ┌─────────────────────────────┐ + │ Multi-Domain Deployment │ + │ │ + │ Single Anvil instance │ + │ simulating N "chains" │ + │ via different domain IDs │ + │ │ + │ Each domain has: │ + │ - Mailbox (instant) │ + │ - WarpToken + Collateral │ + │ - Bridge (delayed) │ + └─────────────────────────────┘ +``` + +## Key Concepts + +### Warp Token Mechanics + +Understanding collateral flow is critical: + +``` +User sends FROM chain A TO chain B: + - Chain A: User deposits collateral → WarpToken GAINS collateral + - Chain B: Recipient withdraws → WarpToken LOSES collateral + +This is counterintuitive! Transfers TO a chain DRAIN its liquidity. +``` + +### Two Message Paths + +The simulator uses two different delivery mechanisms: + +| Path | Mechanism | Delay | Use Case | +| -------------------- | ----------------------- | -------------------------- | ----------------------------------- | +| User transfers | MockMailbox | Configurable (default 0ms) | Simulates Hyperlane message passing | +| Rebalancer transfers | MockValueTransferBridge | Configurable (e.g., 500ms) | Simulates CCTP/bridge delays | + +This separation is important because rebalancer transfers go through external bridges (CCTP, etc.) which have significant delays, while user transfers use Hyperlane's fast messaging. + +### Message Tracking + +| Component | Description | +| ---------------- | ------------------------------------------------------------ | +| `MessageTracker` | Off-chain tracking of pending Hyperlane messages with delays | +| `KPICollector` | Collects transfer/rebalance metrics and generates final KPIs | + +### Rebalancer Runners + +Two rebalancer implementations are available: + +| Runner | Description | Use Case | +| ---------------------------- | ------------------------------------------------------ | ------------------------------- | +| `SimpleRunner` | Simplified rebalancer with weighted/minAmount strategy | Fast tests, baseline comparison | +| `ProductionRebalancerRunner` | Wraps actual `@hyperlane-xyz/rebalancer` CLI service | Production behavior validation | + +## Directory Structure + +``` +typescript/rebalancer-sim/ +├── src/ +│ ├── BridgeMockController.ts # Bridge delay simulation +│ ├── KPICollector.ts # Metrics collection +│ ├── MessageTracker.ts # Message tracking +│ ├── RebalancerSimulationHarness.ts # Main entry point +│ ├── ScenarioGenerator.ts # Create synthetic scenarios +│ ├── ScenarioLoader.ts # Load from JSON files +│ ├── SimulationDeployment.ts # Anvil + contract deployment +│ ├── SimulationEngine.ts # Simulation orchestration +│ ├── types.ts # Consolidated types +│ ├── index.ts # Explicit exports +│ ├── runners/ # Rebalancer implementations +│ │ ├── SimpleRunner.ts # Simplified for testing +│ │ ├── ProductionRebalancerRunner.ts # Wraps production service +│ │ └── SimulationRegistry.ts # IRegistry impl +│ └── visualizer/ # HTML timeline generation +│ └── HtmlTimelineGenerator.ts +├── scenarios/ # Pre-generated scenario JSON files +├── results/ # Test results (gitignored) +├── scripts/ +│ └── generate-scenarios.ts +└── test/ + ├── scenarios/ # Unit tests for scenario generation + ├── utils/ # Test utilities (Anvil management) + └── integration/ # Full simulation tests +``` + +## Scenario File Format + +Each scenario JSON is self-contained with metadata, transfers, and default configurations: + +```json +{ + "name": "extreme-drain-chain1", + "description": "Tests rebalancer response when one chain is rapidly drained.", + "expectedBehavior": "95% of transfers go TO chain1, draining collateral...", + "duration": 10000, + "chains": ["chain1", "chain2", "chain3"], + "transfers": [...], + "defaultInitialCollateral": "100000000000000000000", + "defaultTiming": { + "bridgeDeliveryDelay": 500, + "rebalancerPollingFrequency": 1000, + "userTransferInterval": 100 + }, + "defaultBridgeConfig": {...}, + "defaultStrategyConfig": {...}, + "expectations": { + "minCompletionRate": 0.9, + "shouldTriggerRebalancing": true + } +} +``` + +Tests can use the defaults from JSON or override them for specific test needs. + +## Running Simulations + +### 1. Generate Scenarios (one-time) + +```bash +pnpm generate-scenarios +``` + +Creates JSON files in `scenarios/` with various traffic patterns. + +### 2. Run All Tests + +```bash +pnpm test +``` + +Tests automatically detect if Anvil is available. If not installed, integration tests are skipped. + +### 3. Select Rebalancers + +By default, tests run with both rebalancers. Use the `REBALANCERS` env var to select: + +```bash +# Run with simplified rebalancer only (faster) +REBALANCERS=simple pnpm test + +# Run with production rebalancer only +REBALANCERS=production pnpm test + +# Run with both (default) - compare behavior +REBALANCERS=simple,production pnpm test + +# Compare on specific scenario (recommended for debugging) +REBALANCERS=simple,production pnpm test --grep "extreme-drain" +``` + +### 4. View Results + +Test results are saved to `results/` directory (gitignored): + +```bash +# JSON results with KPIs +cat results/extreme-drain-chain1.json + +# HTML timeline visualization +open results/extreme-drain-chain1-HyperlaneRebalancer.html +``` + +**Note:** If Anvil is not installed, integration tests will be skipped. Install Foundry with: + +```bash +curl -L https://foundry.paradigm.xyz | bash && foundryup +``` + +## Visualization + +The simulator generates interactive HTML timelines for each test run: + +``` +Time → +═══════════════════════════════════════════════════════════════════ +chain1 │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ (balance curve) + │ ──▶ T1 ──▶ T3 ←── R1 (rebalance from chain2) +───────┼─────────────────────────────────────────────────────────── +chain2 │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + │ ──▶ T2 R1 ──▶ +═══════════════════════════════════════════════════════════════════ +``` + +Features: + +- **Transfer bars**: Horizontal bars showing transfer start → delivery (length = latency) +- **Rebalance markers**: Arrows showing rebalancer actions with direction +- **Balance curves**: Per-chain collateral over time +- **Hover tooltips**: Details on transfers, amounts, timing +- **KPI summary**: Completion rate, latencies, rebalance count + +## Scenario Types + +### Predefined Scenarios (in `scenarios/`) + +| Scenario | Description | Expected Behavior | +| -------------------------------- | ----------------------------------- | ------------------------- | +| `extreme-drain-chain1` | 95% of transfers TO chain1 | Heavy rebalancing needed | +| `extreme-accumulate-chain1` | 95% of transfers FROM chain1 | Heavy rebalancing needed | +| `large-unidirectional-to-chain1` | 5 large (20 token) transfers | Immediate imbalance | +| `whale-transfers` | 3 massive (30 token) transfers | Stress test response time | +| `balanced-bidirectional` | Uniform random traffic | Minimal rebalancing | +| `surge-to-chain1` | Traffic spike mid-scenario | Tests burst handling | +| `stress-high-volume` | 50 transfers, Poisson distribution | Load testing | +| `moderate-imbalance-chain1` | 70% of transfers to chain1 | Moderate rebalancing | +| `sustained-drain-chain3` | 30 transfers over 30s | Endurance test | +| `random-with-headroom` | Random traffic with extra liquidity | Tests steady-state | + +## Test Organization + +### Unit Tests (`test/scenarios/`) + +Test the scenario generation logic without running simulations: + +- Does `unidirectionalFlow()` create correct transfer patterns? +- Does `randomTraffic()` distribute across all chains? +- Does serialization preserve BigInt amounts? + +### Integration Tests (`test/integration/`) + +Run full simulations on Anvil: + +| Test File | Purpose | +| ------------------------- | ---------------------------------------------------- | +| `harness-setup.test.ts` | Verifies multi-domain deployment and harness setup | +| `full-simulation.test.ts` | Runs predefined scenarios, saves results | +| `inflight-guard.test.ts` | Demonstrates over-rebalancing without inflight guard | + +### Why `inflight-guard.test.ts` is Separate + +This test demonstrates a specific bug/limitation rather than testing a scenario type: + +**What it proves:** Without tracking pending (inflight) transfers, the rebalancer sends redundant transfers because each poll sees "stale" on-chain balances. + +**How it differs:** + +- Uses custom inline scenario with extreme timing (3s bridge delay vs 200ms polling) +- Asserts on specific failure behavior (expects over-rebalancing) +- Documents a bug that needs fixing, not a passing scenario + +## KPIs Collected + +```typescript +interface SimulationKPIs { + totalTransfers: number; + completedTransfers: number; + completionRate: number; // 0-1, should be >0.9 with working rebalancer + + averageLatency: number; // ms + p50Latency: number; + p95Latency: number; + p99Latency: number; + + totalRebalances: number; + rebalanceVolume: bigint; // Total tokens moved by rebalancer + + perChainMetrics: Record< + string, + { + initialBalance: bigint; + finalBalance: bigint; + transfersIn: number; + transfersOut: number; + } + >; +} +``` + +## Current Limitations + +1. **No Inflight Guard**: Neither rebalancer implementation tracks pending transfers, causing over-rebalancing when bridge delays are long relative to polling frequency. The `inflight-guard.test.ts` demonstrates this. + +2. **Single Anvil**: All "chains" run on one Anvil instance. Real cross-chain timing differences aren't simulated. + +3. **Instant User Transfers**: User transfers via MockMailbox are instant. Real Hyperlane has ~15-30 second finality. + +4. **No Gas Costs**: Gas costs aren't simulated. KPIs include rebalance count but not actual cost. + +5. **Nonce Caching**: When running both rebalancers (`REBALANCERS=simple,production`), ethers v5 nonce caching can cause timeouts on the full test suite. Run specific scenarios for comparison. + +6. **Production ActionTracker**: The `ProductionRebalancerRunner` uses a mock `ActionTracker` that does not persist state. The real production rebalancer's ActionTracker depends on external services not available in simulation. A mock ActionTracker with full in-memory tracking is planned for a future PR. + +## Design Decisions + +### Single Anvil, Multiple Domains + +All simulated "chains" run on a single Anvil instance with different domain IDs: + +```typescript +// All chains share one RPC but have unique domain IDs +const chainMetadata = { + chain1: { domainId: 1000, rpcUrls: [{ http: anvilRpc }] }, + chain2: { domainId: 2000, rpcUrls: [{ http: anvilRpc }] }, + chain3: { domainId: 3000, rpcUrls: [{ http: anvilRpc }] }, +}; + +// Each domain has its own: +// - MockMailbox (for instant user transfers) +// - HypERC20Collateral (warp token with liquidity) +// - MockValueTransferBridge (for delayed rebalancer transfers) +``` + +This approach enables fast, deterministic testing without multi-process coordination. + +### Fast Real-Time Execution + +Simulations run in "compressed" real-time: + +| Real World | Simulation Default | +| ------------- | ------------------ | +| 30s bridge | 500ms | +| 60s polling | 1000ms | +| 5min scenario | ~10s | + +Configure via `SimulationTiming`: + +```typescript +interface SimulationTiming { + bridgeDeliveryDelay: number; // ms - bridge transfer time + rebalancerPollingFrequency: number; // ms - how often rebalancer checks + userTransferInterval: number; // ms - spacing between user transfers +} +``` + +### Observation Isolation + +Rebalancers can ONLY observe state via: + +- JSON-RPC balance queries (`eth_call` to ERC20.balanceOf) +- Event logs (`eth_getLogs`) +- View functions (ISM queries, router configs) + +NOT allowed: + +- Direct contract object access +- Simulation internal state +- Bridge controller pending queue + +This ensures the simulation tests realistic rebalancer behavior. + +## Programmatic Usage + +### Basic Simulation + +```typescript +import { + RebalancerSimulationHarness, + ScenarioLoader, + SimpleRunner, +} from '@hyperlane-xyz/rebalancer-sim'; + +// Load scenario from JSON +const scenario = ScenarioLoader.loadScenario('balanced-bidirectional'); + +// Create and initialize harness (deploys contracts on anvil) +const harness = new RebalancerSimulationHarness({ + anvilRpc: 'http://localhost:8545', + initialCollateralBalance: BigInt(scenario.defaultInitialCollateral), +}); +await harness.initialize(); + +// Run simulation +const result = await harness.runSimulation(scenario, new SimpleRunner(), { + bridgeConfig: scenario.defaultBridgeConfig, + timing: scenario.defaultTiming, + strategyConfig: scenario.defaultStrategyConfig, +}); + +console.log(`Completion: ${result.kpis.completionRate * 100}%`); +console.log(`Avg Latency: ${result.kpis.averageLatency}ms`); +console.log(`Rebalances: ${result.kpis.totalRebalances}`); +``` + +### Compare Rebalancers + +```typescript +import { + ProductionRebalancerRunner, + SimpleRunner, +} from '@hyperlane-xyz/rebalancer-sim'; + +const rebalancers = [ + new SimpleRunner(), // Simplified baseline + new ProductionRebalancerRunner(), // Production rebalancer service +]; + +// compareRebalancers() handles state reset internally +const report = await harness.compareRebalancers(scenario, rebalancers, { + strategyConfig: scenario.defaultStrategyConfig, +}); + +for (const result of report.results) { + console.log(`${result.rebalancerName}: ${result.kpis.completionRate * 100}%`); +} +console.log(`Best latency: ${report.comparison.bestLatency}`); +``` + +### Generate Custom Scenarios + +```typescript +import { parseEther } from 'ethers/lib/utils'; + +import { ScenarioGenerator } from '@hyperlane-xyz/rebalancer-sim'; + +// Unidirectional flow (tests drain) +const drainScenario = ScenarioGenerator.unidirectionalFlow({ + origin: 'chain1', + destination: 'chain2', + transferCount: 100, + duration: 10000, + amount: parseEther('1'), +}); + +// Random traffic across all chains +const randomScenario = ScenarioGenerator.randomTraffic({ + chains: ['chain1', 'chain2', 'chain3'], + transferCount: 50, + duration: 5000, + amountRange: [parseEther('1'), parseEther('10')], +}); + +// Surge pattern (spike mid-scenario) +const surgeScenario = ScenarioGenerator.surgeScenario({ + chains: ['chain1', 'chain2', 'chain3'], + baselineRate: 1, // 1 tx/s baseline + surgeMultiplier: 5, // 5x during surge + surgeStart: 3000, + surgeDuration: 2000, + totalDuration: 10000, + amountRange: [parseEther('1'), parseEther('5')], +}); + +// Balanced bidirectional traffic (equal in/out per chain) +const balancedScenario = ScenarioGenerator.balancedTraffic({ + chains: ['chain1', 'chain2', 'chain3'], + pairCount: 10, // Creates 20 transfers (10 pairs of A→B, B→A) + duration: 5000, + amountRange: [parseEther('1'), parseEther('5')], +}); +``` + +## Future Work + +### Phase 9: Backtesting with Real Warp Route History + +**Goal:** Replay historical warp route traffic to backtest rebalancer strategies. + +**Planned implementation:** + +```typescript +// Load historical transfers from explorer or indexer +const historicalTransfers = await fetchWarpRouteHistory({ + warpRouteId: 'ETH/USDC-ethereum-arbitrum-optimism', + startDate: '2024-01-01', + endDate: '2024-03-01', +}); + +// Convert to scenario format +const scenario = ScenarioGenerator.fromHistoricalData(historicalTransfers); + +// Run simulation with historical traffic +const result = await harness.runSimulation(scenario, rebalancer, config); +``` + +**Benefits:** + +- Validate strategies against real-world traffic patterns +- Identify edge cases that synthetic scenarios miss +- Compare how different strategies would have performed historically + +### Phase 10: Mock Explorer API for Inflight Guard + +**Goal:** Enable testing of inflight guard functionality without real Explorer infrastructure. + +The real rebalancer uses `WithInflightGuard` wrapper that queries Hyperlane Explorer API to track pending (inflight) transfers. This prevents over-rebalancing by accounting for transfers in the bridge pipeline. + +**Planned implementation:** + +```typescript +// src/mocks/MockExplorerApi.ts +export class MockExplorerApi { + // Called by BridgeMockController when transfer initiated + registerPendingTransfer(transfer: { + messageId; + origin; + destination; + amount; + }): void; + + // Called by BridgeMockController when transfer delivered + markDelivered(messageId: string): void; + + // Called by rebalancer's inflight guard + async getInflightTransfers(origin, destination): Promise; +} +``` + +**Integration points:** + +- BridgeMockController calls `registerPendingTransfer()` on bridge initiation +- BridgeMockController calls `markDelivered()` on bridge delivery +- RealRebalancerRunner injects mock API for inflight queries + +**Expected outcome:** `inflight-guard.test.ts` should PASS (1-2 rebalances instead of 30+) once mock explorer is integrated. + +### Phase 11: Advanced Scenarios + +**Bridge Failures and Latency Variance** + +- Configure `failureRate > 0` in bridge config +- Test rebalancer recovery after partial failures +- Verify no stuck state after transient failures +- Asymmetric delays: `chain1→chain2: 500ms`, `chain2→chain1: 2000ms` +- Variable latency per route for heterogeneous bridge environments + +**Rebalancer Restart** + +- Stop rebalancer mid-scenario, restart +- Verify recovery and correct state resumption +- Test idempotency of rebalance operations + +**Scoring Based on Rebalancing Cost** + +- Mock gas prices per chain +- Track total gas cost in KPIs (already partially implemented) +- Add rebalancing cost as scoring metric: + ```typescript + const score = + completionRate * 0.5 + + (1 - normalizedLatency) * 0.3 + + (1 - normalizedCost) * 0.2; + ``` +- Compare strategies by cost-efficiency ratio + +### Phase 12: Enhanced Visualization + +**Real-time dashboard** (stretch goal): + +- WebSocket updates during simulation +- Live balance curves +- Transfer animation + +**Comparison views:** + +- Side-by-side rebalancer comparison in single HTML +- Diff highlighting for KPI differences +- Strategy effectiveness scoring diff --git a/typescript/rebalancer-sim/eslint.config.mjs b/typescript/rebalancer-sim/eslint.config.mjs new file mode 100644 index 00000000000..d50f98834ed --- /dev/null +++ b/typescript/rebalancer-sim/eslint.config.mjs @@ -0,0 +1,14 @@ +import MonorepoDefaults from '../../eslint.config.mjs'; + +export default [ + ...MonorepoDefaults, + { + files: ['./src/**/*.ts', './test/**/*.ts'], + rules: { + // Disable restricted imports for Node.js built-ins since simulation harness is Node.js-only + 'no-restricted-imports': ['off'], + // Allow console statements for simulation output + 'no-console': ['off'], + }, + }, +]; diff --git a/typescript/rebalancer-sim/package.json b/typescript/rebalancer-sim/package.json new file mode 100644 index 00000000000..26243f475aa --- /dev/null +++ b/typescript/rebalancer-sim/package.json @@ -0,0 +1,62 @@ +{ + "name": "@hyperlane-xyz/rebalancer-sim", + "version": "0.1.0", + "description": "Fast real-time simulation framework for testing Hyperlane warp route rebalancers", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist cache", + "dev": "tsc --watch", + "generate-scenarios": "tsx scripts/generate-scenarios.ts", + "lint": "eslint -c ./eslint.config.mjs ./src", + "prettier": "prettier --write ./src", + "test": "mocha --config .mocharc.json './test/**/*.test.ts' --exit", + "test:ci": "pnpm test" + }, + "dependencies": { + "@hyperlane-xyz/core": "workspace:*", + "@hyperlane-xyz/provider-sdk": "workspace:*", + "@hyperlane-xyz/registry": "catalog:", + "@hyperlane-xyz/rebalancer": "workspace:*", + "@hyperlane-xyz/sdk": "workspace:*", + "@hyperlane-xyz/utils": "workspace:*", + "ethers": "catalog:", + "pino": "catalog:", + "pino-pretty": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@hyperlane-xyz/tsconfig": "workspace:^", + "@types/chai": "catalog:", + "@types/chai-as-promised": "catalog:", + "@types/mocha": "catalog:", + "@types/node": "catalog:", + "chai": "catalog:", + "chai-as-promised": "catalog:", + "eslint": "catalog:", + "mocha": "catalog:", + "prettier": "catalog:", + "testcontainers": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:" + }, + "engines": { + "node": ">=18" + }, + "repository": "https://github.com/hyperlane-xyz/hyperlane-monorepo", + "keywords": [ + "hyperlane", + "rebalancer", + "simulation", + "testing", + "warp-route" + ], + "author": "Abacus Works, Inc.", + "license": "Apache-2.0" +} diff --git a/typescript/rebalancer-sim/scenarios/balanced-bidirectional.json b/typescript/rebalancer-sim/scenarios/balanced-bidirectional.json new file mode 100644 index 00000000000..63c9b394003 --- /dev/null +++ b/typescript/rebalancer-sim/scenarios/balanced-bidirectional.json @@ -0,0 +1,243 @@ +{ + "name": "balanced-bidirectional", + "description": "Verifies that balanced traffic does NOT trigger unnecessary rebalancing.", + "expectedBehavior": "10 balanced pairs (20 transfers total) where each A→B has a matching B→A.\nNet flow per chain is zero - no liquidity imbalance should occur.\nAll chains should stay at exactly 100 tokens (within rounding).\nRebalancer should NOT trigger at all since flows are perfectly balanced.\nThis is the \"happy path\" - balanced traffic needs no intervention.\nAll transfers should complete quickly with low latency (~100ms delivery delay).", + "duration": 10000, + "chains": ["chain1", "chain2", "chain3"], + "transfers": [ + { + "id": "bal-000000", + "timestamp": 0, + "origin": "chain1", + "destination": "chain2", + "amount": "2967704115304951291", + "user": "0xfd1568a1db7fb8bdea4d4f01ac7e82ddfbac1bb9" + }, + { + "id": "bal-000001", + "timestamp": 484, + "origin": "chain2", + "destination": "chain1", + "amount": "2967704115304951291", + "user": "0x57a07745af81f3714f3e5eea3b49edd4b5a3b5cf" + }, + { + "id": "bal-000002", + "timestamp": 900, + "origin": "chain1", + "destination": "chain3", + "amount": "2827881524332089607", + "user": "0xafcad8ecd644afa8c9f2ab9bb3e415b649902c80" + }, + { + "id": "bal-000003", + "timestamp": 911, + "origin": "chain3", + "destination": "chain1", + "amount": "2827881524332089607", + "user": "0x8223d6b7b7fab1539948aedd2c2cb8383bbdec28" + }, + { + "id": "bal-000004", + "timestamp": 1800, + "origin": "chain2", + "destination": "chain3", + "amount": "1676833675879826722", + "user": "0x4efdce56b3bcee3e350ac103c2fd55df7c98b376" + }, + { + "id": "bal-000005", + "timestamp": 1833, + "origin": "chain3", + "destination": "chain2", + "amount": "1676833675879826722", + "user": "0x0d940c2eac9c624f1657bd4846901f089b5982fb" + }, + { + "id": "bal-000006", + "timestamp": 2700, + "origin": "chain1", + "destination": "chain2", + "amount": "2188659478057355957", + "user": "0xc6535cc71582b1bd17a90d64b8e74aa09e15da7b" + }, + { + "id": "bal-000007", + "timestamp": 2831, + "origin": "chain2", + "destination": "chain1", + "amount": "2188659478057355957", + "user": "0x4e70c1681d0a394b1470e66ceedc4ddca887ca5e" + }, + { + "id": "bal-000008", + "timestamp": 3600, + "origin": "chain1", + "destination": "chain3", + "amount": "2709231175839805199", + "user": "0x04f58ce1ad455075acf720e5f9caee655000795f" + }, + { + "id": "bal-000009", + "timestamp": 3907, + "origin": "chain3", + "destination": "chain1", + "amount": "2709231175839805199", + "user": "0xd63e4f097346b504975c1a3fa517024abe9d8aee" + }, + { + "id": "bal-000010", + "timestamp": 4500, + "origin": "chain2", + "destination": "chain3", + "amount": "1126382020800316124", + "user": "0xfafca74d4d9fc721ff59529f254cc1954292b638" + }, + { + "id": "bal-000011", + "timestamp": 4708, + "origin": "chain3", + "destination": "chain2", + "amount": "1126382020800316124", + "user": "0x3247f2430ce5a4349efb7ef3b33eec9dc57b8ad9" + }, + { + "id": "bal-000012", + "timestamp": 5400, + "origin": "chain1", + "destination": "chain2", + "amount": "2551899397204316650", + "user": "0xee3a406d9ea4e10679aa9279995b8746bd7a0ca4" + }, + { + "id": "bal-000013", + "timestamp": 5661, + "origin": "chain2", + "destination": "chain1", + "amount": "2551899397204316650", + "user": "0x894dc03aa7cbe066ff62469835554cfbea359592" + }, + { + "id": "bal-000014", + "timestamp": 6300, + "origin": "chain1", + "destination": "chain3", + "amount": "2104918366727982422", + "user": "0xdb8fd74b61f74cdb78d99c24b6c07ed7fccf8e1e" + }, + { + "id": "bal-000015", + "timestamp": 6369, + "origin": "chain3", + "destination": "chain1", + "amount": "2104918366727982422", + "user": "0xfc8db3c19f9a5bc404a6533044898ddf3cf0d684" + }, + { + "id": "bal-000016", + "timestamp": 7200, + "origin": "chain2", + "destination": "chain3", + "amount": "2870590499930617984", + "user": "0xf52ec7f812cbabb03f01a52aaab4d8a92692ff06" + }, + { + "id": "bal-000017", + "timestamp": 7237, + "origin": "chain3", + "destination": "chain2", + "amount": "2870590499930617984", + "user": "0x11f28bf6c09ee3a8b35fff1c6e85fb7dc543c513" + }, + { + "id": "bal-000018", + "timestamp": 8100, + "origin": "chain1", + "destination": "chain2", + "amount": "1565692437941140325", + "user": "0x5e9484ae246c769be17258f82d52981749e385da" + }, + { + "id": "bal-000019", + "timestamp": 8326, + "origin": "chain2", + "destination": "chain1", + "amount": "1565692437941140325", + "user": "0xa1e278652b1e7a3de175634e2e4efe9ebbf09850" + } + ], + "defaultInitialCollateral": "100000000000000000000", + "defaultTiming": { + "userTransferDeliveryDelay": 100, + "rebalancerPollingFrequency": 1000, + "userTransferInterval": 100 + }, + "defaultBridgeConfig": { + "chain1": { + "chain2": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain3": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain2": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain3": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain3": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain2": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + } + }, + "defaultStrategyConfig": { + "type": "weighted", + "chains": { + "chain1": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + }, + "chain2": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + }, + "chain3": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + } + } + }, + "expectations": { + "minCompletionRate": 0.95, + "shouldTriggerRebalancing": false + } +} diff --git a/typescript/rebalancer-sim/scenarios/extreme-accumulate-chain1.json b/typescript/rebalancer-sim/scenarios/extreme-accumulate-chain1.json new file mode 100644 index 00000000000..b3dcabe8729 --- /dev/null +++ b/typescript/rebalancer-sim/scenarios/extreme-accumulate-chain1.json @@ -0,0 +1,244 @@ +{ + "name": "extreme-accumulate-chain1", + "description": "Tests rebalancer response when one chain accumulates excess liquidity from outgoing transfers.", + "expectedBehavior": "95% of transfers originate FROM chain1, causing users to deposit collateral there.\nChain1 rises to ~250 tokens (well above 115 threshold).\nChain2/chain3 get drained as recipients withdraw there.\nLower completion expected (~60%) because destination chains may run dry before rebalancer can help.\nRebalancer should still respond by moving excess from chain1.", + "duration": 10000, + "chains": ["chain1", "chain2", "chain3"], + "transfers": [ + { + "id": "imb-000000", + "timestamp": 0, + "origin": "chain1", + "destination": "chain3", + "amount": "8654905739143129475", + "user": "0x03413d0b025bf48912438e29b270abd4f5c1c7a1" + }, + { + "id": "imb-000001", + "timestamp": 500, + "origin": "chain1", + "destination": "chain2", + "amount": "7249382218863982027", + "user": "0xd10bb427d7273b04a7fbfe78f85d2b87402df3b4" + }, + { + "id": "imb-000002", + "timestamp": 1000, + "origin": "chain1", + "destination": "chain2", + "amount": "6897827590314835566", + "user": "0x2dfe03881f05563172768544ec15d31ad0587c20" + }, + { + "id": "imb-000003", + "timestamp": 1500, + "origin": "chain1", + "destination": "chain2", + "amount": "6524344024085312793", + "user": "0xb182ece7b8f09ae9145beeea12318db3a8eb1ea1" + }, + { + "id": "imb-000004", + "timestamp": 2000, + "origin": "chain1", + "destination": "chain3", + "amount": "7553981815067929485", + "user": "0x3c63fd2c957ddbeace65468986dc0dcd28ccc8cb" + }, + { + "id": "imb-000005", + "timestamp": 2500, + "origin": "chain1", + "destination": "chain2", + "amount": "6056745421202587527", + "user": "0xc5b3fca3e43331ca90a84a6fcb2abffa4c1c944b" + }, + { + "id": "imb-000006", + "timestamp": 3000, + "origin": "chain1", + "destination": "chain3", + "amount": "7579105952392950560", + "user": "0x2fc7ffdbdb6b5630caf7662237dba3bfc85b4403" + }, + { + "id": "imb-000007", + "timestamp": 3500, + "origin": "chain1", + "destination": "chain3", + "amount": "8889415793514234729", + "user": "0x53bc20dafee71a28c994602f9cd88e6d362194af" + }, + { + "id": "imb-000008", + "timestamp": 4000, + "origin": "chain1", + "destination": "chain2", + "amount": "6749742689868257844", + "user": "0x76a5ebc39176141056507850e5b4e581a6e60a2f" + }, + { + "id": "imb-000009", + "timestamp": 4500, + "origin": "chain1", + "destination": "chain2", + "amount": "6663819140670116650", + "user": "0x5a4c5cc4bd899ad9268440237f2816f25121030b" + }, + { + "id": "imb-000010", + "timestamp": 5000, + "origin": "chain1", + "destination": "chain3", + "amount": "8177913259527288168", + "user": "0x551d1846730df41aec43f2ecc02623e8d6f86871" + }, + { + "id": "imb-000011", + "timestamp": 5500, + "origin": "chain1", + "destination": "chain2", + "amount": "6454319458422137722", + "user": "0x574daaab0955bec841b427e9276ed9f0afc9e88e" + }, + { + "id": "imb-000012", + "timestamp": 6000, + "origin": "chain1", + "destination": "chain3", + "amount": "9816340992047084454", + "user": "0x72497ba940de283d4b6bae3b0daf7a4c520aa876" + }, + { + "id": "imb-000013", + "timestamp": 6500, + "origin": "chain1", + "destination": "chain3", + "amount": "9237163579185297422", + "user": "0x889fac694599f1a7becbbd9e501bf99a174a8460" + }, + { + "id": "imb-000014", + "timestamp": 7000, + "origin": "chain1", + "destination": "chain2", + "amount": "8264585334492370762", + "user": "0x875bca9049cd72393b7c9412801b9d7ea9c99624" + }, + { + "id": "imb-000015", + "timestamp": 7500, + "origin": "chain1", + "destination": "chain3", + "amount": "9584894202320494995", + "user": "0x0bb73cc9068d9c04f014e0e72d17ce1cfb540a15" + }, + { + "id": "imb-000016", + "timestamp": 8000, + "origin": "chain1", + "destination": "chain3", + "amount": "5201880155747868079", + "user": "0x95d0c8f6436bb8b1dbfb74765b4c7fdbd1cadefe" + }, + { + "id": "imb-000017", + "timestamp": 8500, + "origin": "chain1", + "destination": "chain3", + "amount": "8539520294987893140", + "user": "0x5ae4d5822a11a67834fe9ad62daeeac740aab717" + }, + { + "id": "imb-000018", + "timestamp": 9000, + "origin": "chain1", + "destination": "chain2", + "amount": "5762840394876563235", + "user": "0xfaf9680565a47042bd474aae3006c151512b57f8" + }, + { + "id": "imb-000019", + "timestamp": 9500, + "origin": "chain1", + "destination": "chain3", + "amount": "7818051919587081650", + "user": "0x2a0eb61f6e2c5e6861a7fc7dab58b6e19683e882" + } + ], + "defaultInitialCollateral": "100000000000000000000", + "defaultTiming": { + "userTransferDeliveryDelay": 100, + "rebalancerPollingFrequency": 1000, + "userTransferInterval": 100 + }, + "defaultBridgeConfig": { + "chain1": { + "chain2": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain3": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain2": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain3": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain3": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain2": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + } + }, + "defaultStrategyConfig": { + "type": "weighted", + "chains": { + "chain1": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + }, + "chain2": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + }, + "chain3": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + } + } + }, + "expectations": { + "minCompletionRate": 0.6, + "minRebalances": 1, + "shouldTriggerRebalancing": true + } +} diff --git a/typescript/rebalancer-sim/scenarios/extreme-drain-chain1.json b/typescript/rebalancer-sim/scenarios/extreme-drain-chain1.json new file mode 100644 index 00000000000..a201a2ed70a --- /dev/null +++ b/typescript/rebalancer-sim/scenarios/extreme-drain-chain1.json @@ -0,0 +1,243 @@ +{ + "name": "extreme-drain-chain1", + "description": "Tests rebalancer response when one chain is rapidly drained by incoming transfers.", + "expectedBehavior": "95% of transfers go TO chain1, draining its collateral as recipients withdraw.\nChain1 drops from 100 to potentially negative without rebalancer.\nRebalancer should detect chain1 < 85 threshold and send tokens FROM chain2/chain3.\nCompletion rate should stay >90% due to rebalancing replenishing liquidity.", + "duration": 10000, + "chains": ["chain1", "chain2", "chain3"], + "transfers": [ + { + "id": "imb-000000", + "timestamp": 0, + "origin": "chain2", + "destination": "chain1", + "amount": "8308892583413928677", + "user": "0x8b563f19033b6111597f5ae37a91b88591fa17d6" + }, + { + "id": "imb-000001", + "timestamp": 500, + "origin": "chain3", + "destination": "chain1", + "amount": "6610389539917844183", + "user": "0x53e81e13db7f8a7f1ff614b699d36b22ba85761f" + }, + { + "id": "imb-000002", + "timestamp": 1000, + "origin": "chain3", + "destination": "chain1", + "amount": "5381552686213219913", + "user": "0xe6aa46c39443ae5c696524b2ec1b39f82942cdd8" + }, + { + "id": "imb-000003", + "timestamp": 1500, + "origin": "chain2", + "destination": "chain1", + "amount": "8391490348843196628", + "user": "0x80507c3a61c9e6eede5dca9ee05d906455618ddf" + }, + { + "id": "imb-000004", + "timestamp": 2000, + "origin": "chain3", + "destination": "chain1", + "amount": "7820125081027415679", + "user": "0x3d8d6bb644bb6977ed12949c603941ceb5d403f8" + }, + { + "id": "imb-000005", + "timestamp": 2500, + "origin": "chain2", + "destination": "chain1", + "amount": "6637915543305014158", + "user": "0xde17d16540a911039920412167f61143457e7ec9" + }, + { + "id": "imb-000006", + "timestamp": 3000, + "origin": "chain2", + "destination": "chain1", + "amount": "5972675884716996338", + "user": "0x55d4d12eedc237332be4d1da439c09f0bdf040c0" + }, + { + "id": "imb-000007", + "timestamp": 3500, + "origin": "chain3", + "destination": "chain1", + "amount": "8751590282605359139", + "user": "0xd63c2a0496ade53c6aa1df0643d265ff2ab0b70c" + }, + { + "id": "imb-000008", + "timestamp": 4000, + "origin": "chain3", + "destination": "chain1", + "amount": "6944266955662390377", + "user": "0xb2f599e4bcb7ef74e1d668fbc3ba8ac3bf65b48d" + }, + { + "id": "imb-000009", + "timestamp": 4500, + "origin": "chain2", + "destination": "chain1", + "amount": "9681362420282795947", + "user": "0x6e744848f95d2c507b83ead1329debd10fd3a6cb" + }, + { + "id": "imb-000010", + "timestamp": 5000, + "origin": "chain3", + "destination": "chain1", + "amount": "6599436486229110254", + "user": "0xa9b0dc7a02417b7d7db534b4fdeec62f999cfb51" + }, + { + "id": "imb-000011", + "timestamp": 5500, + "origin": "chain2", + "destination": "chain1", + "amount": "9575602746645324541", + "user": "0x15b520940409a509fbb4fea7984120882a5c473c" + }, + { + "id": "imb-000012", + "timestamp": 6000, + "origin": "chain2", + "destination": "chain1", + "amount": "8861006917451413035", + "user": "0x3450bca5b1c2445875477bb4d60e2235d5c9f2f1" + }, + { + "id": "imb-000013", + "timestamp": 6500, + "origin": "chain3", + "destination": "chain1", + "amount": "7866123866673201326", + "user": "0x0bdeffbfe344255f71d181b9fdf01db5a999b60d" + }, + { + "id": "imb-000014", + "timestamp": 7000, + "origin": "chain3", + "destination": "chain1", + "amount": "8359968790629018367", + "user": "0x6d415c57f76dac33b497b4d96813faf9bdc13f07" + }, + { + "id": "imb-000015", + "timestamp": 7500, + "origin": "chain3", + "destination": "chain1", + "amount": "7489296443325547012", + "user": "0xb119e7563c7e18ee79e1001b831933e4a04fcca4" + }, + { + "id": "imb-000016", + "timestamp": 8000, + "origin": "chain3", + "destination": "chain1", + "amount": "6891837687725727000", + "user": "0x9d100d8ca4c5ca0789b963476cecad26a66c2d83" + }, + { + "id": "imb-000017", + "timestamp": 8500, + "origin": "chain2", + "destination": "chain1", + "amount": "6352162511843063893", + "user": "0x8232e1eb3bb3a284a66c975e257115779ce2b2ef" + }, + { + "id": "imb-000018", + "timestamp": 9000, + "origin": "chain3", + "destination": "chain1", + "amount": "7057824233556104640", + "user": "0x168b1e7696d837eadbf26eee773d5053721bfc9a" + }, + { + "id": "imb-000019", + "timestamp": 9500, + "origin": "chain2", + "destination": "chain1", + "amount": "8644271482861345182", + "user": "0xd75f7a423ba03664e3377de89d0b81c7ad16009d" + } + ], + "defaultInitialCollateral": "100000000000000000000", + "defaultTiming": { + "userTransferDeliveryDelay": 100, + "rebalancerPollingFrequency": 1000, + "userTransferInterval": 100 + }, + "defaultBridgeConfig": { + "chain1": { + "chain2": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain3": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain2": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain3": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain3": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain2": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + } + }, + "defaultStrategyConfig": { + "type": "weighted", + "chains": { + "chain1": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + }, + "chain2": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + }, + "chain3": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + } + } + }, + "expectations": { + "minCompletionRate": 0.9, + "shouldTriggerRebalancing": true + } +} diff --git a/typescript/rebalancer-sim/scenarios/inflight-guard.json b/typescript/rebalancer-sim/scenarios/inflight-guard.json new file mode 100644 index 00000000000..9361036f4ba --- /dev/null +++ b/typescript/rebalancer-sim/scenarios/inflight-guard.json @@ -0,0 +1,86 @@ +{ + "name": "inflight-guard", + "description": "Tests rebalancer behavior with slow bridge and fast polling to demonstrate inflight guard importance.", + "expectedBehavior": "With SLOW bridge (3s) and FAST polling (200ms), a rebalancer without inflight tracking will over-rebalance.\nWithout inflight guard: Multiple redundant transfers sent before first one delivers.\nWith inflight guard: Only 1-2 transfers sent, tracking pending amounts.", + "duration": 8000, + "chains": ["chain1", "chain2"], + "initialImbalance": { + "chain1": "50000000000000000000" + }, + "transfers": [ + { + "id": "keepalive-1", + "timestamp": 1000, + "origin": "chain1", + "destination": "chain2", + "amount": "1000000000000000", + "user": "0x1111111111111111111111111111111111111111" + }, + { + "id": "keepalive-2", + "timestamp": 3000, + "origin": "chain1", + "destination": "chain2", + "amount": "1000000000000000", + "user": "0x1111111111111111111111111111111111111111" + }, + { + "id": "keepalive-3", + "timestamp": 5000, + "origin": "chain1", + "destination": "chain2", + "amount": "1000000000000000", + "user": "0x1111111111111111111111111111111111111111" + }, + { + "id": "keepalive-4", + "timestamp": 7000, + "origin": "chain1", + "destination": "chain2", + "amount": "1000000000000000", + "user": "0x1111111111111111111111111111111111111111" + } + ], + "defaultInitialCollateral": "100000000000000000000", + "defaultTiming": { + "userTransferDeliveryDelay": 0, + "rebalancerPollingFrequency": 200, + "userTransferInterval": 100 + }, + "defaultBridgeConfig": { + "chain1": { + "chain2": { + "deliveryDelay": 3000, + "failureRate": 0, + "deliveryJitter": 0 + } + }, + "chain2": { + "chain1": { + "deliveryDelay": 3000, + "failureRate": 0, + "deliveryJitter": 0 + } + } + }, + "defaultStrategyConfig": { + "type": "weighted", + "chains": { + "chain1": { + "weighted": { + "weight": "0.5", + "tolerance": "0.05" + }, + "bridgeLockTime": 500 + }, + "chain2": { + "weighted": { + "weight": "0.5", + "tolerance": "0.05" + }, + "bridgeLockTime": 500 + } + } + }, + "expectations": {} +} diff --git a/typescript/rebalancer-sim/scenarios/large-unidirectional-to-chain1.json b/typescript/rebalancer-sim/scenarios/large-unidirectional-to-chain1.json new file mode 100644 index 00000000000..809ad448045 --- /dev/null +++ b/typescript/rebalancer-sim/scenarios/large-unidirectional-to-chain1.json @@ -0,0 +1,94 @@ +{ + "name": "large-unidirectional-to-chain1", + "description": "Tests rebalancer response to large individual transfers creating immediate imbalance.", + "expectedBehavior": "5 transfers of 20 tokens each, all chain2 → chain1.\nEach transfer is 20% of initial balance - immediate liquidity crisis.\nFirst 1-2 transfers succeed, then chain1 drops to ~60 tokens (below 85 threshold).\nRebalancer must respond quickly to refill chain1 for remaining transfers.\nHigh completion rate expected if rebalancer is fast enough.", + "duration": 5000, + "chains": ["chain2", "chain1"], + "transfers": [ + { + "id": "uni-000000", + "timestamp": 0, + "origin": "chain2", + "destination": "chain1", + "amount": "20000000000000000000", + "user": "0x5c30b066bbe5dd2b7f69792cd47950420805f81c" + }, + { + "id": "uni-000001", + "timestamp": 1000, + "origin": "chain2", + "destination": "chain1", + "amount": "20000000000000000000", + "user": "0x5c30b066bbe5dd2b7f69792cd47950420805f81c" + }, + { + "id": "uni-000002", + "timestamp": 2000, + "origin": "chain2", + "destination": "chain1", + "amount": "20000000000000000000", + "user": "0x5c30b066bbe5dd2b7f69792cd47950420805f81c" + }, + { + "id": "uni-000003", + "timestamp": 3000, + "origin": "chain2", + "destination": "chain1", + "amount": "20000000000000000000", + "user": "0x5c30b066bbe5dd2b7f69792cd47950420805f81c" + }, + { + "id": "uni-000004", + "timestamp": 4000, + "origin": "chain2", + "destination": "chain1", + "amount": "20000000000000000000", + "user": "0x5c30b066bbe5dd2b7f69792cd47950420805f81c" + } + ], + "defaultInitialCollateral": "100000000000000000000", + "defaultTiming": { + "userTransferDeliveryDelay": 100, + "rebalancerPollingFrequency": 1000, + "userTransferInterval": 100 + }, + "defaultBridgeConfig": { + "chain2": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain1": { + "chain2": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + } + }, + "defaultStrategyConfig": { + "type": "weighted", + "chains": { + "chain2": { + "weighted": { + "weight": "0.500", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + }, + "chain1": { + "weighted": { + "weight": "0.500", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + } + } + }, + "expectations": { + "minCompletionRate": 0.9, + "shouldTriggerRebalancing": true + } +} diff --git a/typescript/rebalancer-sim/scenarios/moderate-imbalance-chain1.json b/typescript/rebalancer-sim/scenarios/moderate-imbalance-chain1.json new file mode 100644 index 00000000000..058c30ea436 --- /dev/null +++ b/typescript/rebalancer-sim/scenarios/moderate-imbalance-chain1.json @@ -0,0 +1,203 @@ +{ + "name": "moderate-imbalance-chain1", + "description": "Tests rebalancer with moderate (not extreme) imbalance.", + "expectedBehavior": "70% of transfers go TO chain1 (moderate drain).\nShould trigger rebalancing but less aggressively than extreme scenarios.\nTests that rebalancer responds proportionally to imbalance severity.", + "duration": 8000, + "chains": ["chain1", "chain2", "chain3"], + "transfers": [ + { + "id": "imb-000000", + "timestamp": 0, + "origin": "chain3", + "destination": "chain1", + "amount": "4876626375932819419", + "user": "0x5fb89f78d45aec745094fc16f81edccad2d2cc54" + }, + { + "id": "imb-000001", + "timestamp": 533, + "origin": "chain2", + "destination": "chain1", + "amount": "4975378149125631332", + "user": "0x59651d966c38a400f420e1f5ab711f577137f65e" + }, + { + "id": "imb-000002", + "timestamp": 1066, + "origin": "chain3", + "destination": "chain1", + "amount": "3784690119843134087", + "user": "0x150b4144b07f6f74d473491d3c0bd09b6510d19c" + }, + { + "id": "imb-000003", + "timestamp": 1600, + "origin": "chain3", + "destination": "chain1", + "amount": "4038982439562478585", + "user": "0x9ac2a749feb95f433196d1e5db269bb82d963fa5" + }, + { + "id": "imb-000004", + "timestamp": 2133, + "origin": "chain2", + "destination": "chain1", + "amount": "2797992738011561731", + "user": "0x4b8f1b48143385602d13f445ae319e11f3a0052c" + }, + { + "id": "imb-000005", + "timestamp": 2666, + "origin": "chain3", + "destination": "chain1", + "amount": "3369519748849314956", + "user": "0x12b616617fe79e106055a21051f1d57f58d1a16f" + }, + { + "id": "imb-000006", + "timestamp": 3200, + "origin": "chain2", + "destination": "chain1", + "amount": "3014338742722758886", + "user": "0x67dc29575781eabc2c0d3a1e5bb53d687e41f8ca" + }, + { + "id": "imb-000007", + "timestamp": 3733, + "origin": "chain1", + "destination": "chain2", + "amount": "2984193087692001591", + "user": "0x8558fe3bde3e6ad81c00fc5e898b7281c2fd1739" + }, + { + "id": "imb-000008", + "timestamp": 4266, + "origin": "chain2", + "destination": "chain1", + "amount": "4012674214392344328", + "user": "0x282330d74285426a29be86c2331ba4862a547269" + }, + { + "id": "imb-000009", + "timestamp": 4800, + "origin": "chain2", + "destination": "chain1", + "amount": "2209705962625720147", + "user": "0x4d7c276cdc3a6dae7a19879bbf4b7a7fe5b02a19" + }, + { + "id": "imb-000010", + "timestamp": 5333, + "origin": "chain2", + "destination": "chain1", + "amount": "4004947782647718124", + "user": "0x5f04bb80af9b0fc405435fde2d746c41fcbc4ad9" + }, + { + "id": "imb-000011", + "timestamp": 5866, + "origin": "chain2", + "destination": "chain1", + "amount": "2907741869863193136", + "user": "0x997c91c4d50adef80ca2e75d116f9207d2bb10f1" + }, + { + "id": "imb-000012", + "timestamp": 6400, + "origin": "chain3", + "destination": "chain1", + "amount": "5767649977948436955", + "user": "0x9bd561a036e248066af550927c8448a7b96897b0" + }, + { + "id": "imb-000013", + "timestamp": 6933, + "origin": "chain3", + "destination": "chain1", + "amount": "4604308150821409875", + "user": "0xed87f921790dd9067bcec1f15898777e93174f0b" + }, + { + "id": "imb-000014", + "timestamp": 7466, + "origin": "chain1", + "destination": "chain2", + "amount": "5158124889782485475", + "user": "0xef68c0be53297344bc36fe118020504f022cd16f" + } + ], + "defaultInitialCollateral": "100000000000000000000", + "defaultTiming": { + "userTransferDeliveryDelay": 100, + "rebalancerPollingFrequency": 1000, + "userTransferInterval": 100 + }, + "defaultBridgeConfig": { + "chain1": { + "chain2": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain3": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain2": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain3": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain3": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain2": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + } + }, + "defaultStrategyConfig": { + "type": "weighted", + "chains": { + "chain1": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + }, + "chain2": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + }, + "chain3": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + } + } + }, + "expectations": { + "minCompletionRate": 0.85, + "shouldTriggerRebalancing": true + } +} diff --git a/typescript/rebalancer-sim/scenarios/random-with-headroom.json b/typescript/rebalancer-sim/scenarios/random-with-headroom.json new file mode 100644 index 00000000000..43a4237608c --- /dev/null +++ b/typescript/rebalancer-sim/scenarios/random-with-headroom.json @@ -0,0 +1,242 @@ +{ + "name": "random-with-headroom", + "description": "Random traffic with enough collateral that rebalancer can keep up without blocking transfers.", + "expectedBehavior": "20 truly random transfers (not balanced pairs).\nHigh collateral (500 tokens) provides large buffer for fluctuations.\nTransfers (2-8 tokens = 0.4-1.6% of balance) create small relative imbalances.\n5% tolerance triggers rebalancing on ~25 token imbalances.\nWith 500 tokens, even 50 token imbalance leaves 450+ liquidity.\nExpected: ~200ms latency, some rebalances, 100% completion.\nKey insight: enough headroom + moderate tolerance = rebalancer active but no blocking.", + "duration": 10000, + "chains": ["chain1", "chain2", "chain3"], + "transfers": [ + { + "id": "rnd-000019", + "timestamp": 652, + "origin": "chain3", + "destination": "chain1", + "amount": "7059802757931078365", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000018", + "timestamp": 983, + "origin": "chain3", + "destination": "chain1", + "amount": "2614992797386674110", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000000", + "timestamp": 1278, + "origin": "chain1", + "destination": "chain2", + "amount": "7843026516529562747", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000004", + "timestamp": 1658, + "origin": "chain2", + "destination": "chain1", + "amount": "6250249135451721068", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000016", + "timestamp": 2299, + "origin": "chain3", + "destination": "chain1", + "amount": "2558259628651060573", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000005", + "timestamp": 2796, + "origin": "chain3", + "destination": "chain1", + "amount": "2997162989242115937", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000003", + "timestamp": 3502, + "origin": "chain1", + "destination": "chain2", + "amount": "7088451681487574566", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000008", + "timestamp": 3645, + "origin": "chain3", + "destination": "chain1", + "amount": "5566711257019856215", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000009", + "timestamp": 4019, + "origin": "chain1", + "destination": "chain2", + "amount": "2343436420707221442", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000001", + "timestamp": 4302, + "origin": "chain1", + "destination": "chain3", + "amount": "2378869035059037474", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000015", + "timestamp": 4816, + "origin": "chain1", + "destination": "chain2", + "amount": "3657670741850050866", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000006", + "timestamp": 4883, + "origin": "chain3", + "destination": "chain1", + "amount": "3716256372381330759", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000007", + "timestamp": 6057, + "origin": "chain1", + "destination": "chain3", + "amount": "3669815064836248491", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000017", + "timestamp": 6527, + "origin": "chain1", + "destination": "chain2", + "amount": "3328966309209909871", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000014", + "timestamp": 7342, + "origin": "chain1", + "destination": "chain2", + "amount": "4720193466913999674", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000013", + "timestamp": 7402, + "origin": "chain1", + "destination": "chain3", + "amount": "7507075288768001725", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000002", + "timestamp": 7488, + "origin": "chain3", + "destination": "chain2", + "amount": "4314743623266446292", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000012", + "timestamp": 7612, + "origin": "chain2", + "destination": "chain1", + "amount": "4881356421275753122", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000011", + "timestamp": 8200, + "origin": "chain1", + "destination": "chain3", + "amount": "7193381271715918628", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + }, + { + "id": "rnd-000010", + "timestamp": 9989, + "origin": "chain2", + "destination": "chain1", + "amount": "3259246229789954125", + "user": "0xedb1ee8f36ee2e16f01dc11a1243b5265f58baa0" + } + ], + "defaultInitialCollateral": "500000000000000000000", + "defaultTiming": { + "userTransferDeliveryDelay": 100, + "rebalancerPollingFrequency": 1000, + "userTransferInterval": 100 + }, + "defaultBridgeConfig": { + "chain1": { + "chain2": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain3": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain2": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain3": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain3": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain2": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + } + }, + "defaultStrategyConfig": { + "type": "weighted", + "chains": { + "chain1": { + "weighted": { + "weight": "0.333", + "tolerance": "0.05" + }, + "bridgeLockTime": 500 + }, + "chain2": { + "weighted": { + "weight": "0.333", + "tolerance": "0.05" + }, + "bridgeLockTime": 500 + }, + "chain3": { + "weighted": { + "weight": "0.333", + "tolerance": "0.05" + }, + "bridgeLockTime": 500 + } + } + }, + "expectations": { + "minCompletionRate": 0.95 + } +} diff --git a/typescript/rebalancer-sim/scenarios/stress-high-volume.json b/typescript/rebalancer-sim/scenarios/stress-high-volume.json new file mode 100644 index 00000000000..996fbd42748 --- /dev/null +++ b/typescript/rebalancer-sim/scenarios/stress-high-volume.json @@ -0,0 +1,482 @@ +{ + "name": "stress-high-volume", + "description": "Load tests the simulation with high transfer volume.", + "expectedBehavior": "50 transfers over 20 seconds with Poisson distribution (~2.5 tx/sec average).\nRandom origin/destination creates unpredictable imbalances.\nTests rebalancer stability under sustained load.\nPoisson distribution creates realistic bursty traffic patterns.", + "duration": 20000, + "chains": ["chain1", "chain2", "chain3"], + "transfers": [ + { + "id": "rnd-000000", + "timestamp": 71, + "origin": "chain3", + "destination": "chain2", + "amount": "2312458793795101290", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000001", + "timestamp": 173, + "origin": "chain1", + "destination": "chain3", + "amount": "1281166152609728329", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000002", + "timestamp": 536, + "origin": "chain1", + "destination": "chain2", + "amount": "1735392188476673307", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000003", + "timestamp": 685, + "origin": "chain3", + "destination": "chain2", + "amount": "4790699375137442511", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000004", + "timestamp": 1546, + "origin": "chain3", + "destination": "chain1", + "amount": "3076729247379650982", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000005", + "timestamp": 1629, + "origin": "chain1", + "destination": "chain2", + "amount": "1884870889907824491", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000006", + "timestamp": 1647, + "origin": "chain3", + "destination": "chain1", + "amount": "1356286151532406375", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000007", + "timestamp": 2117, + "origin": "chain3", + "destination": "chain2", + "amount": "1843891641131674244", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000008", + "timestamp": 2359, + "origin": "chain1", + "destination": "chain2", + "amount": "2645910195698921961", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000009", + "timestamp": 2482, + "origin": "chain3", + "destination": "chain1", + "amount": "2973978200814945775", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000010", + "timestamp": 3879, + "origin": "chain2", + "destination": "chain1", + "amount": "2194096116977402004", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000011", + "timestamp": 4346, + "origin": "chain2", + "destination": "chain1", + "amount": "2134339985076341907", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000012", + "timestamp": 4467, + "origin": "chain1", + "destination": "chain2", + "amount": "4936073339567049113", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000013", + "timestamp": 4728, + "origin": "chain2", + "destination": "chain3", + "amount": "2402187002960999956", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000014", + "timestamp": 4880, + "origin": "chain1", + "destination": "chain2", + "amount": "1656624677420970122", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000015", + "timestamp": 5787, + "origin": "chain3", + "destination": "chain2", + "amount": "2939200205401536252", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000016", + "timestamp": 6496, + "origin": "chain3", + "destination": "chain1", + "amount": "2995438118288421133", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000017", + "timestamp": 6583, + "origin": "chain1", + "destination": "chain3", + "amount": "1169344514584632852", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000018", + "timestamp": 6873, + "origin": "chain2", + "destination": "chain3", + "amount": "2962852017137189549", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000019", + "timestamp": 6996, + "origin": "chain3", + "destination": "chain1", + "amount": "1652642471030278718", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000020", + "timestamp": 7238, + "origin": "chain1", + "destination": "chain3", + "amount": "4552176464364786215", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000021", + "timestamp": 7293, + "origin": "chain3", + "destination": "chain1", + "amount": "4629784559177946297", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000022", + "timestamp": 7466, + "origin": "chain2", + "destination": "chain3", + "amount": "3939670831652060224", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000023", + "timestamp": 7672, + "origin": "chain2", + "destination": "chain3", + "amount": "3738965437356577603", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000024", + "timestamp": 8654, + "origin": "chain2", + "destination": "chain3", + "amount": "1465731038009411898", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000025", + "timestamp": 9981, + "origin": "chain3", + "destination": "chain1", + "amount": "2844243400129382388", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000026", + "timestamp": 9994, + "origin": "chain3", + "destination": "chain2", + "amount": "2850174605122528659", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000027", + "timestamp": 10657, + "origin": "chain2", + "destination": "chain1", + "amount": "1629147187067792891", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000028", + "timestamp": 10941, + "origin": "chain2", + "destination": "chain3", + "amount": "1962675159237663030", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000029", + "timestamp": 11345, + "origin": "chain3", + "destination": "chain1", + "amount": "4508076325319861034", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000030", + "timestamp": 12037, + "origin": "chain1", + "destination": "chain2", + "amount": "3911549513060551254", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000031", + "timestamp": 13259, + "origin": "chain3", + "destination": "chain2", + "amount": "1689516495365931681", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000032", + "timestamp": 13365, + "origin": "chain2", + "destination": "chain3", + "amount": "1306508724047052265", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000033", + "timestamp": 14223, + "origin": "chain1", + "destination": "chain2", + "amount": "1640803283332415824", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000034", + "timestamp": 15341, + "origin": "chain2", + "destination": "chain1", + "amount": "4195138724006189871", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000035", + "timestamp": 15362, + "origin": "chain1", + "destination": "chain3", + "amount": "1439411941083102804", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000036", + "timestamp": 15726, + "origin": "chain3", + "destination": "chain1", + "amount": "2297885460607888381", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000037", + "timestamp": 16859, + "origin": "chain2", + "destination": "chain3", + "amount": "3880252271644700732", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000038", + "timestamp": 17309, + "origin": "chain1", + "destination": "chain3", + "amount": "4570759838293048090", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000039", + "timestamp": 17357, + "origin": "chain1", + "destination": "chain3", + "amount": "2509374797851331364", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000040", + "timestamp": 17666, + "origin": "chain2", + "destination": "chain3", + "amount": "3671290940392792489", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000041", + "timestamp": 18039, + "origin": "chain2", + "destination": "chain1", + "amount": "3519067256785541020", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000042", + "timestamp": 18263, + "origin": "chain2", + "destination": "chain1", + "amount": "4534197844118682673", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000043", + "timestamp": 18759, + "origin": "chain1", + "destination": "chain3", + "amount": "1859691603345609614", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000044", + "timestamp": 18888, + "origin": "chain3", + "destination": "chain1", + "amount": "3269811121019515037", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000045", + "timestamp": 19046, + "origin": "chain2", + "destination": "chain3", + "amount": "2549881729642206840", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000046", + "timestamp": 20000, + "origin": "chain2", + "destination": "chain1", + "amount": "2582529070238174234", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000047", + "timestamp": 20000, + "origin": "chain3", + "destination": "chain2", + "amount": "4373720453292797880", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000048", + "timestamp": 20000, + "origin": "chain3", + "destination": "chain2", + "amount": "1663006380274578703", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + }, + { + "id": "rnd-000049", + "timestamp": 20000, + "origin": "chain1", + "destination": "chain2", + "amount": "2133367747552744189", + "user": "0x79caf7532af96f0b3565f620770e29e5bcfea037" + } + ], + "defaultInitialCollateral": "100000000000000000000", + "defaultTiming": { + "userTransferDeliveryDelay": 100, + "rebalancerPollingFrequency": 1000, + "userTransferInterval": 100 + }, + "defaultBridgeConfig": { + "chain1": { + "chain2": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain3": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain2": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain3": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain3": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain2": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + } + }, + "defaultStrategyConfig": { + "type": "weighted", + "chains": { + "chain1": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + }, + "chain2": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + }, + "chain3": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + } + } + }, + "expectations": { + "minCompletionRate": 0.85 + } +} diff --git a/typescript/rebalancer-sim/scenarios/surge-to-chain1.json b/typescript/rebalancer-sim/scenarios/surge-to-chain1.json new file mode 100644 index 00000000000..dd3443f68c9 --- /dev/null +++ b/typescript/rebalancer-sim/scenarios/surge-to-chain1.json @@ -0,0 +1,363 @@ +{ + "name": "surge-to-chain1", + "description": "Tests rebalancer handling of sudden traffic spikes.", + "expectedBehavior": "Baseline: 1 tx/sec random traffic.\nSurge: 5x traffic (5 tx/sec) from 5-10 seconds.\nSurge period creates rapid imbalance that baseline wouldn't.\nRebalancer must detect and respond to burst, then stabilize.\nTests adaptive response to changing traffic patterns.", + "duration": 15000, + "chains": ["chain1", "chain2", "chain3"], + "transfers": [ + { + "id": "base-000000", + "timestamp": 0, + "origin": "chain1", + "destination": "chain3", + "amount": "4477979502549645670", + "user": "0x1ca4c4a7403a25cb1bb94d24fc29a5e9323e4575" + }, + { + "id": "base-000001", + "timestamp": 1000, + "origin": "chain2", + "destination": "chain3", + "amount": "5244947094483846432", + "user": "0x084abd573ad57363e55dff2b9add4482ed624ec0" + }, + { + "id": "base-000002", + "timestamp": 2000, + "origin": "chain1", + "destination": "chain2", + "amount": "3309858376185863034", + "user": "0xe230fb6c2b9ee39824c1df7187ffc627a76cc8ca" + }, + { + "id": "base-000003", + "timestamp": 3000, + "origin": "chain3", + "destination": "chain2", + "amount": "5573430542069684404", + "user": "0xbe6c6d86a3511ffa5edf0a89631cf5895b4fbe9d" + }, + { + "id": "base-000004", + "timestamp": 4000, + "origin": "chain2", + "destination": "chain3", + "amount": "7440066426435683986", + "user": "0x22c342693c354c76f493f1c91d3493e48072b216" + }, + { + "id": "surge-000010", + "timestamp": 5000, + "origin": "chain1", + "destination": "chain3", + "amount": "5430823731252256900", + "user": "0x7bff36f6acd45f2053ab129328d71db78e61e2d8" + }, + { + "id": "surge-000011", + "timestamp": 5200, + "origin": "chain3", + "destination": "chain1", + "amount": "7671790306949248791", + "user": "0x37d5f0c813a360ae895290a60aef41da7af41abf" + }, + { + "id": "surge-000012", + "timestamp": 5400, + "origin": "chain1", + "destination": "chain3", + "amount": "5500746842538579911", + "user": "0x52d5ec152adfd483f3b907d97640f128591def42" + }, + { + "id": "surge-000013", + "timestamp": 5600, + "origin": "chain2", + "destination": "chain3", + "amount": "6069635206302894305", + "user": "0x4f29c322606307a9eddaea6c9b6ea7f2b001074f" + }, + { + "id": "surge-000014", + "timestamp": 5800, + "origin": "chain3", + "destination": "chain2", + "amount": "5827425771259455226", + "user": "0x1bb70b2d693c24dcb3f067e12fc87ad3d284a42a" + }, + { + "id": "surge-000015", + "timestamp": 6000, + "origin": "chain3", + "destination": "chain1", + "amount": "5748647683882664483", + "user": "0x039fb5f3fa6a2c70e896ad39446ba0a88b9e6089" + }, + { + "id": "surge-000016", + "timestamp": 6200, + "origin": "chain2", + "destination": "chain3", + "amount": "5521277833242909303", + "user": "0x39688976854c445eb8b07d2621e698f0aff18044" + }, + { + "id": "surge-000017", + "timestamp": 6400, + "origin": "chain1", + "destination": "chain2", + "amount": "7965651407500674611", + "user": "0xb1783832e313d287694239a9b4f1d500a935c1bd" + }, + { + "id": "surge-000018", + "timestamp": 6600, + "origin": "chain1", + "destination": "chain3", + "amount": "5398146169312691579", + "user": "0x12b006708f69c3ccea8d3af4de2b3c584e740eeb" + }, + { + "id": "surge-000019", + "timestamp": 6800, + "origin": "chain3", + "destination": "chain1", + "amount": "6381193200235959927", + "user": "0x57feb502873c85ed7adf97fd21c2cca4a88108ce" + }, + { + "id": "surge-000020", + "timestamp": 7000, + "origin": "chain2", + "destination": "chain1", + "amount": "3031184975210256143", + "user": "0xfaba9c96ea7f558c26d7d284d7750daa58b500d5" + }, + { + "id": "surge-000021", + "timestamp": 7200, + "origin": "chain2", + "destination": "chain1", + "amount": "5076976280398188183", + "user": "0x854e9035728db492c6ad043b6884bcffcb2370c8" + }, + { + "id": "surge-000022", + "timestamp": 7400, + "origin": "chain1", + "destination": "chain3", + "amount": "3431641858573178075", + "user": "0x4688dda8ec0e67407ebd4a49d5f2ba5de2fa2732" + }, + { + "id": "surge-000023", + "timestamp": 7600, + "origin": "chain3", + "destination": "chain1", + "amount": "6558067016941632725", + "user": "0x156c3312bde08e325baa09aec0684ef0926b1a4a" + }, + { + "id": "surge-000024", + "timestamp": 7800, + "origin": "chain1", + "destination": "chain2", + "amount": "5939180964811236827", + "user": "0x57802d137b0a6d24780974e68ed8f17754d38553" + }, + { + "id": "surge-000025", + "timestamp": 8000, + "origin": "chain1", + "destination": "chain2", + "amount": "4933754466932991500", + "user": "0xe319ded1b1092c77f35502a65e225a4829dcd5aa" + }, + { + "id": "surge-000026", + "timestamp": 8200, + "origin": "chain2", + "destination": "chain1", + "amount": "3421473105292525431", + "user": "0x2398beeb199e345fab2f55cbf78c9b2b80976d37" + }, + { + "id": "surge-000027", + "timestamp": 8400, + "origin": "chain1", + "destination": "chain2", + "amount": "7228463094627958343", + "user": "0x68aeddec747bfbe959cdb06b2a30a0d23faa3ef2" + }, + { + "id": "surge-000028", + "timestamp": 8600, + "origin": "chain1", + "destination": "chain2", + "amount": "3274990495210778502", + "user": "0xdd17e5e014437314cc3f55f25ef7f5c09720d909" + }, + { + "id": "surge-000029", + "timestamp": 8800, + "origin": "chain1", + "destination": "chain2", + "amount": "5034298199105667721", + "user": "0xd46b2409d34c55c36ec1c639d78858efc9ec4731" + }, + { + "id": "surge-000030", + "timestamp": 9000, + "origin": "chain2", + "destination": "chain3", + "amount": "6043361131822162414", + "user": "0x38fb9b734502bed056df9ce66fb6f79c47c8e197" + }, + { + "id": "surge-000031", + "timestamp": 9200, + "origin": "chain1", + "destination": "chain2", + "amount": "6312811678464808069", + "user": "0xed17875cfa16cb6911dc0b5928cef61ca5bf0c90" + }, + { + "id": "surge-000032", + "timestamp": 9400, + "origin": "chain1", + "destination": "chain3", + "amount": "7630383781803936478", + "user": "0xd3d6d1971b5bd8f52f6eb84e5d425d97ab500d8e" + }, + { + "id": "surge-000033", + "timestamp": 9600, + "origin": "chain2", + "destination": "chain1", + "amount": "3154196929747018423", + "user": "0x2a43c8efdff61f8a0a6a0c762613d22a1665e4b5" + }, + { + "id": "surge-000034", + "timestamp": 9800, + "origin": "chain1", + "destination": "chain2", + "amount": "3051953925105714926", + "user": "0xce2e33e7d7262c76bdcf4ff33e706198896f10a5" + }, + { + "id": "base-000005", + "timestamp": 10000, + "origin": "chain2", + "destination": "chain3", + "amount": "7102065802339126775", + "user": "0x678ef0d3238967f0ceb5a34277ea78f892d86158" + }, + { + "id": "base-000006", + "timestamp": 11000, + "origin": "chain2", + "destination": "chain3", + "amount": "7514515977091669041", + "user": "0x1c1dc2a9f869cbfd95e78e42ced49a91485f929c" + }, + { + "id": "base-000007", + "timestamp": 12000, + "origin": "chain3", + "destination": "chain1", + "amount": "7721925455736331245", + "user": "0xd330fb274027f2d87df3bb312d4bb671ad7e4bd7" + }, + { + "id": "base-000008", + "timestamp": 13000, + "origin": "chain1", + "destination": "chain3", + "amount": "5223852509738372901", + "user": "0xe2d5c942070fa57625efd03a777b28ef5ac2e292" + }, + { + "id": "base-000009", + "timestamp": 14000, + "origin": "chain1", + "destination": "chain2", + "amount": "6283131783627882861", + "user": "0x991cac58e4b0218a03d4e65066df937a467b0976" + } + ], + "defaultInitialCollateral": "100000000000000000000", + "defaultTiming": { + "userTransferDeliveryDelay": 100, + "rebalancerPollingFrequency": 1000, + "userTransferInterval": 100 + }, + "defaultBridgeConfig": { + "chain1": { + "chain2": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain3": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain2": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain3": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain3": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + }, + "chain2": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + } + }, + "defaultStrategyConfig": { + "type": "weighted", + "chains": { + "chain1": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + }, + "chain2": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + }, + "chain3": { + "weighted": { + "weight": "0.333", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + } + } + }, + "expectations": { + "minCompletionRate": 0.8, + "shouldTriggerRebalancing": true + } +} diff --git a/typescript/rebalancer-sim/scenarios/sustained-drain-chain3.json b/typescript/rebalancer-sim/scenarios/sustained-drain-chain3.json new file mode 100644 index 00000000000..dacd0cc5cfe --- /dev/null +++ b/typescript/rebalancer-sim/scenarios/sustained-drain-chain3.json @@ -0,0 +1,294 @@ +{ + "name": "sustained-drain-chain3", + "description": "Tests rebalancer under sustained one-way flow over longer duration.", + "expectedBehavior": "30 transfers over 30 seconds, all chain3 → chain1.\nSustained pressure rather than burst - tests rebalancer endurance.\nChain1 continuously drained, chain3 continuously accumulates.\nRebalancer must keep up with ongoing imbalance, not just react once.", + "duration": 30000, + "chains": ["chain3", "chain1"], + "transfers": [ + { + "id": "uni-000000", + "timestamp": 0, + "origin": "chain3", + "destination": "chain1", + "amount": "4621054279986168783", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000001", + "timestamp": 1000, + "origin": "chain3", + "destination": "chain1", + "amount": "4913801906884253598", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000002", + "timestamp": 2000, + "origin": "chain3", + "destination": "chain1", + "amount": "4928112936055037718", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000003", + "timestamp": 3000, + "origin": "chain3", + "destination": "chain1", + "amount": "3636111663861047130", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000004", + "timestamp": 4000, + "origin": "chain3", + "destination": "chain1", + "amount": "3131758736489233277", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000005", + "timestamp": 5000, + "origin": "chain3", + "destination": "chain1", + "amount": "4397463622826304050", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000006", + "timestamp": 6000, + "origin": "chain3", + "destination": "chain1", + "amount": "2469742952142196467", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000007", + "timestamp": 7000, + "origin": "chain3", + "destination": "chain1", + "amount": "3415246726815593219", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000008", + "timestamp": 8000, + "origin": "chain3", + "destination": "chain1", + "amount": "3524082469929671618", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000009", + "timestamp": 9000, + "origin": "chain3", + "destination": "chain1", + "amount": "2919595288258247010", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000010", + "timestamp": 10000, + "origin": "chain3", + "destination": "chain1", + "amount": "3571936226953412118", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000011", + "timestamp": 11000, + "origin": "chain3", + "destination": "chain1", + "amount": "3346763735765149860", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000012", + "timestamp": 12000, + "origin": "chain3", + "destination": "chain1", + "amount": "2595842834293805243", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000013", + "timestamp": 13000, + "origin": "chain3", + "destination": "chain1", + "amount": "2167780635065647489", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000014", + "timestamp": 14000, + "origin": "chain3", + "destination": "chain1", + "amount": "3626901140189442862", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000015", + "timestamp": 15000, + "origin": "chain3", + "destination": "chain1", + "amount": "4060167859183588530", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000016", + "timestamp": 16000, + "origin": "chain3", + "destination": "chain1", + "amount": "3883023636999833401", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000017", + "timestamp": 17000, + "origin": "chain3", + "destination": "chain1", + "amount": "3922862156475014215", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000018", + "timestamp": 18000, + "origin": "chain3", + "destination": "chain1", + "amount": "2990754740458500263", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000019", + "timestamp": 19000, + "origin": "chain3", + "destination": "chain1", + "amount": "4898578890213514286", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000020", + "timestamp": 20000, + "origin": "chain3", + "destination": "chain1", + "amount": "4359052732555097902", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000021", + "timestamp": 21000, + "origin": "chain3", + "destination": "chain1", + "amount": "2920169940397925373", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000022", + "timestamp": 22000, + "origin": "chain3", + "destination": "chain1", + "amount": "3964312840708513497", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000023", + "timestamp": 23000, + "origin": "chain3", + "destination": "chain1", + "amount": "2017105048248270961", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000024", + "timestamp": 24000, + "origin": "chain3", + "destination": "chain1", + "amount": "4011461782832161268", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000025", + "timestamp": 25000, + "origin": "chain3", + "destination": "chain1", + "amount": "3918255522644520359", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000026", + "timestamp": 26000, + "origin": "chain3", + "destination": "chain1", + "amount": "3815394902986356964", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000027", + "timestamp": 27000, + "origin": "chain3", + "destination": "chain1", + "amount": "3311754775417867787", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000028", + "timestamp": 28000, + "origin": "chain3", + "destination": "chain1", + "amount": "3543335206945132871", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + }, + { + "id": "uni-000029", + "timestamp": 29000, + "origin": "chain3", + "destination": "chain1", + "amount": "3679583376356401773", + "user": "0xb63708ad79a2419617b30f100b0dfa2e558934f9" + } + ], + "defaultInitialCollateral": "100000000000000000000", + "defaultTiming": { + "userTransferDeliveryDelay": 100, + "rebalancerPollingFrequency": 1000, + "userTransferInterval": 100 + }, + "defaultBridgeConfig": { + "chain3": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain1": { + "chain3": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + } + }, + "defaultStrategyConfig": { + "type": "weighted", + "chains": { + "chain3": { + "weighted": { + "weight": "0.500", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + }, + "chain1": { + "weighted": { + "weight": "0.500", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + } + } + }, + "expectations": { + "minCompletionRate": 0.85, + "shouldTriggerRebalancing": true + } +} diff --git a/typescript/rebalancer-sim/scenarios/whale-transfers.json b/typescript/rebalancer-sim/scenarios/whale-transfers.json new file mode 100644 index 00000000000..e015bdd0d74 --- /dev/null +++ b/typescript/rebalancer-sim/scenarios/whale-transfers.json @@ -0,0 +1,78 @@ +{ + "name": "whale-transfers", + "description": "Stress tests rebalancer response time with massive single transfers that exhaust liquidity.", + "expectedBehavior": "3 transfers of 60 tokens each arriving in quick burst (first 500ms).\nTotal outflow: 180 tokens, but chain1 only has 100.\nTransfer 1: 100 → 40 remaining (succeeds immediately)\nTransfer 2: 40 - 60 = -20 → BLOCKED waiting for rebalancing\nTransfer 3: Also blocked until liquidity restored.\nRebalancer must replenish chain1 before transfers 2 & 3 can complete.\nHigh latency expected for transfers 2 & 3 as they wait for rebalancing.", + "duration": 10000, + "chains": ["chain2", "chain1"], + "transfers": [ + { + "id": "whale-1", + "timestamp": 0, + "origin": "chain2", + "destination": "chain1", + "amount": "60000000000000000000", + "user": "0x1111111111111111111111111111111111111111" + }, + { + "id": "whale-2", + "timestamp": 200, + "origin": "chain2", + "destination": "chain1", + "amount": "60000000000000000000", + "user": "0x2222222222222222222222222222222222222222" + }, + { + "id": "whale-3", + "timestamp": 400, + "origin": "chain2", + "destination": "chain1", + "amount": "60000000000000000000", + "user": "0x3333333333333333333333333333333333333333" + } + ], + "defaultInitialCollateral": "100000000000000000000", + "defaultTiming": { + "userTransferDeliveryDelay": 100, + "rebalancerPollingFrequency": 1000, + "userTransferInterval": 100 + }, + "defaultBridgeConfig": { + "chain2": { + "chain1": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + }, + "chain1": { + "chain2": { + "deliveryDelay": 500, + "failureRate": 0, + "deliveryJitter": 100 + } + } + }, + "defaultStrategyConfig": { + "type": "weighted", + "chains": { + "chain2": { + "weighted": { + "weight": "0.500", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + }, + "chain1": { + "weighted": { + "weight": "0.500", + "tolerance": "0.15" + }, + "bridgeLockTime": 500 + } + } + }, + "expectations": { + "minCompletionRate": 0.9, + "shouldTriggerRebalancing": true + } +} diff --git a/typescript/rebalancer-sim/scripts/generate-scenarios.ts b/typescript/rebalancer-sim/scripts/generate-scenarios.ts new file mode 100644 index 00000000000..090b1491f98 --- /dev/null +++ b/typescript/rebalancer-sim/scripts/generate-scenarios.ts @@ -0,0 +1,427 @@ +#!/usr/bin/env tsx +/** + * Generate and save scenarios to the scenarios/ directory. + * Run with: pnpm generate-scenarios + */ +import * as fs from 'fs'; +import * as path from 'path'; + +import { toWei } from '@hyperlane-xyz/utils'; + +import { ScenarioGenerator } from '../src/index.js'; +import type { + ScenarioExpectations, + ScenarioFile, + SerializedBridgeConfig, + SerializedStrategyConfig, + SimulationTiming, + TransferScenario, +} from '../src/index.js'; + +const SCENARIOS_DIR = path.join(import.meta.dirname, '..', 'scenarios'); + +// Ensure scenarios directory exists +if (!fs.existsSync(SCENARIOS_DIR)) { + fs.mkdirSync(SCENARIOS_DIR, { recursive: true }); +} + +// ============================================================================ +// DEFAULT CONFIGURATIONS +// ============================================================================ + +const DEFAULT_CHAINS = ['chain1', 'chain2', 'chain3']; +const DEFAULT_INITIAL_COLLATERAL = toWei(100); // 100 tokens per chain + +const DEFAULT_TIMING: SimulationTiming = { + userTransferDeliveryDelay: 100, // Small delay to simulate message passing + rebalancerPollingFrequency: 1000, + userTransferInterval: 100, +}; + +function createDefaultBridgeConfig(chains: string[]): SerializedBridgeConfig { + const config: SerializedBridgeConfig = {}; + for (const origin of chains) { + config[origin] = {}; + for (const dest of chains) { + if (origin !== dest) { + config[origin][dest] = { + deliveryDelay: 500, + failureRate: 0, + deliveryJitter: 100, + }; + } + } + } + return config; +} + +function createDefaultStrategyConfig( + chains: string[], +): SerializedStrategyConfig { + const weight = (1 / chains.length).toFixed(3); + const chainConfigs: SerializedStrategyConfig['chains'] = {}; + + for (const chain of chains) { + chainConfigs[chain] = { + weighted: { + weight, + tolerance: '0.15', // 15% tolerance + }, + bridgeLockTime: 500, + }; + } + + return { + type: 'weighted', + chains: chainConfigs, + }; +} + +// ============================================================================ +// SCENARIO BUILDER +// ============================================================================ + +interface ScenarioConfig { + name: string; + description: string; + expectedBehavior: string; + scenario: TransferScenario; + initialCollateralPerChain?: string; + timing?: Partial; + bridgeConfig?: Partial; + strategyConfig?: Partial; + expectations: ScenarioExpectations; +} + +function saveScenario(config: ScenarioConfig) { + const chains = config.scenario.chains; + + const scenarioFile: ScenarioFile = { + name: config.name, + description: config.description, + expectedBehavior: config.expectedBehavior, + duration: config.scenario.duration, + chains, + transfers: config.scenario.transfers.map((t) => ({ + id: t.id, + timestamp: t.timestamp, + origin: t.origin, + destination: t.destination, + amount: t.amount.toString(), + user: t.user, + })), + defaultInitialCollateral: + config.initialCollateralPerChain ?? DEFAULT_INITIAL_COLLATERAL, + defaultTiming: { ...DEFAULT_TIMING, ...config.timing }, + defaultBridgeConfig: + (config.bridgeConfig as SerializedBridgeConfig) ?? + createDefaultBridgeConfig(chains), + defaultStrategyConfig: + (config.strategyConfig as SerializedStrategyConfig) ?? + createDefaultStrategyConfig(chains), + expectations: config.expectations, + }; + + const filePath = path.join(SCENARIOS_DIR, `${config.name}.json`); + fs.writeFileSync(filePath, JSON.stringify(scenarioFile, null, 2)); + console.log(`Saved: ${filePath}`); + console.log(` ${config.description}`); + console.log( + ` Transfers: ${config.scenario.transfers.length}, Duration: ${config.scenario.duration}ms`, + ); +} + +console.log('Generating scenarios...\n'); + +// ============================================================================ +// EXTREME IMBALANCE SCENARIOS - These WILL trigger rebalancing +// ============================================================================ + +saveScenario({ + name: 'extreme-drain-chain1', + description: + 'Tests rebalancer response when one chain is rapidly drained by incoming transfers.', + expectedBehavior: `95% of transfers go TO chain1, draining its collateral as recipients withdraw. +Chain1 drops from 100 to potentially negative without rebalancer. +Rebalancer should detect chain1 < 85 threshold and send tokens FROM chain2/chain3. +Completion rate should stay >90% due to rebalancing replenishing liquidity.`, + scenario: ScenarioGenerator.imbalanceScenario( + DEFAULT_CHAINS, + 'chain1', + 20, + 10000, + [BigInt(toWei(5)), BigInt(toWei(10))], + 0.95, + ), + expectations: { + minCompletionRate: 0.9, + shouldTriggerRebalancing: true, + }, +}); + +saveScenario({ + name: 'extreme-accumulate-chain1', + description: + 'Tests rebalancer response when one chain accumulates excess liquidity from outgoing transfers.', + expectedBehavior: `95% of transfers originate FROM chain1, causing users to deposit collateral there. +Chain1 rises to ~250 tokens (well above 115 threshold). +Chain2/chain3 get drained as recipients withdraw there. +Lower completion expected (~60%) because destination chains may run dry before rebalancer can help. +Rebalancer should still respond by moving excess from chain1.`, + scenario: ScenarioGenerator.imbalanceScenario( + DEFAULT_CHAINS, + 'chain1', + 20, + 10000, + [BigInt(toWei(5)), BigInt(toWei(10))], + 0.05, + ), + expectations: { + minCompletionRate: 0.6, + minRebalances: 1, + shouldTriggerRebalancing: true, + }, +}); + +saveScenario({ + name: 'large-unidirectional-to-chain1', + description: + 'Tests rebalancer response to large individual transfers creating immediate imbalance.', + expectedBehavior: `5 transfers of 20 tokens each, all chain2 → chain1. +Each transfer is 20% of initial balance - immediate liquidity crisis. +First 1-2 transfers succeed, then chain1 drops to ~60 tokens (below 85 threshold). +Rebalancer must respond quickly to refill chain1 for remaining transfers. +High completion rate expected if rebalancer is fast enough.`, + scenario: ScenarioGenerator.unidirectionalFlow({ + origin: 'chain2', + destination: 'chain1', + transferCount: 5, + duration: 5000, + amount: BigInt(toWei(20)), + }), + expectations: { + minCompletionRate: 0.9, + shouldTriggerRebalancing: true, + }, +}); + +saveScenario({ + name: 'whale-transfers', + description: + 'Stress tests rebalancer response time with massive single transfers that exhaust liquidity.', + expectedBehavior: `3 transfers of 60 tokens each arriving in quick burst (first 500ms). +Total outflow: 180 tokens, but chain1 only has 100. +Transfer 1: 100 → 40 remaining (succeeds immediately) +Transfer 2: 40 - 60 = -20 → BLOCKED waiting for rebalancing +Transfer 3: Also blocked until liquidity restored. +Rebalancer must replenish chain1 before transfers 2 & 3 can complete. +High latency expected for transfers 2 & 3 as they wait for rebalancing.`, + scenario: { + name: 'whale-transfers', + duration: 10000, // Long duration to allow rebalancing + chains: ['chain2', 'chain1'], + // Burst of 3 transfers in first 500ms + transfers: [ + { + id: 'whale-1', + timestamp: 0, + origin: 'chain2', + destination: 'chain1', + amount: BigInt(toWei(60)), + user: '0x1111111111111111111111111111111111111111', + }, + { + id: 'whale-2', + timestamp: 200, + origin: 'chain2', + destination: 'chain1', + amount: BigInt(toWei(60)), + user: '0x2222222222222222222222222222222222222222', + }, + { + id: 'whale-3', + timestamp: 400, + origin: 'chain2', + destination: 'chain1', + amount: BigInt(toWei(60)), + user: '0x3333333333333333333333333333333333333333', + }, + ], + }, + expectations: { + minCompletionRate: 0.9, + shouldTriggerRebalancing: true, + }, +}); + +// ============================================================================ +// BALANCED SCENARIOS - Should NOT trigger excessive rebalancing +// ============================================================================ + +saveScenario({ + name: 'balanced-bidirectional', + description: + 'Verifies that balanced traffic does NOT trigger unnecessary rebalancing.', + expectedBehavior: `10 balanced pairs (20 transfers total) where each A→B has a matching B→A. +Net flow per chain is zero - no liquidity imbalance should occur. +All chains should stay at exactly 100 tokens (within rounding). +Rebalancer should NOT trigger at all since flows are perfectly balanced. +This is the "happy path" - balanced traffic needs no intervention. +All transfers should complete quickly with low latency (~100ms delivery delay).`, + scenario: ScenarioGenerator.balancedTraffic({ + chains: DEFAULT_CHAINS, + pairCount: 10, // 10 pairs = 20 transfers + duration: 10000, + amountRange: [BigInt(toWei(1)), BigInt(toWei(3))], + }), + expectations: { + minCompletionRate: 0.95, + shouldTriggerRebalancing: false, + }, +}); + +// ============================================================================ +// RANDOM TRAFFIC WITH HEADROOM - Rebalancer active but transfers not blocked +// ============================================================================ + +saveScenario({ + name: 'random-with-headroom', + description: + 'Random traffic with enough collateral that rebalancer can keep up without blocking transfers.', + expectedBehavior: `20 truly random transfers (not balanced pairs). +High collateral (500 tokens) provides large buffer for fluctuations. +Transfers (2-8 tokens = 0.4-1.6% of balance) create small relative imbalances. +5% tolerance triggers rebalancing on ~25 token imbalances. +With 500 tokens, even 50 token imbalance leaves 450+ liquidity. +Expected: ~200ms latency, some rebalances, 100% completion. +Key insight: enough headroom + moderate tolerance = rebalancer active but no blocking.`, + scenario: ScenarioGenerator.randomTraffic({ + chains: DEFAULT_CHAINS, + transferCount: 20, + duration: 10000, + amountRange: [BigInt(toWei(2)), BigInt(toWei(8))], + distribution: 'uniform', + }), + initialCollateralPerChain: toWei(500), // 5x normal - large buffer + strategyConfig: { + type: 'weighted', + chains: { + chain1: { + weighted: { weight: '0.333', tolerance: '0.05' }, + bridgeLockTime: 500, + }, + chain2: { + weighted: { weight: '0.333', tolerance: '0.05' }, + bridgeLockTime: 500, + }, + chain3: { + weighted: { weight: '0.333', tolerance: '0.05' }, + bridgeLockTime: 500, + }, + }, + }, + expectations: { + minCompletionRate: 0.95, + // With high collateral + 5% tolerance, rebalancer may or may not trigger + // depending on random traffic pattern - that's fine, key is low latency + }, +}); + +// ============================================================================ +// SURGE SCENARIOS - Test rebalancer response to traffic spikes +// ============================================================================ + +saveScenario({ + name: 'surge-to-chain1', + description: 'Tests rebalancer handling of sudden traffic spikes.', + expectedBehavior: `Baseline: 1 tx/sec random traffic. +Surge: 5x traffic (5 tx/sec) from 5-10 seconds. +Surge period creates rapid imbalance that baseline wouldn't. +Rebalancer must detect and respond to burst, then stabilize. +Tests adaptive response to changing traffic patterns.`, + scenario: ScenarioGenerator.surgeScenario({ + chains: DEFAULT_CHAINS, + baselineRate: 1, + surgeMultiplier: 5, + surgeStart: 5000, + surgeDuration: 5000, + totalDuration: 15000, + amountRange: [BigInt(toWei(3)), BigInt(toWei(8))], + }), + expectations: { + minCompletionRate: 0.8, + shouldTriggerRebalancing: true, + }, +}); + +// ============================================================================ +// STRESS TEST SCENARIOS +// ============================================================================ + +saveScenario({ + name: 'stress-high-volume', + description: 'Load tests the simulation with high transfer volume.', + expectedBehavior: `50 transfers over 20 seconds with Poisson distribution (~2.5 tx/sec average). +Random origin/destination creates unpredictable imbalances. +Tests rebalancer stability under sustained load. +Poisson distribution creates realistic bursty traffic patterns.`, + scenario: ScenarioGenerator.randomTraffic({ + chains: DEFAULT_CHAINS, + transferCount: 50, + duration: 20000, + amountRange: [BigInt(toWei(1)), BigInt(toWei(5))], + distribution: 'poisson', + poissonMeanInterval: 400, + }), + expectations: { + minCompletionRate: 0.85, + }, +}); + +// ============================================================================ +// MODERATE SCENARIOS +// ============================================================================ + +saveScenario({ + name: 'moderate-imbalance-chain1', + description: 'Tests rebalancer with moderate (not extreme) imbalance.', + expectedBehavior: `70% of transfers go TO chain1 (moderate drain). +Should trigger rebalancing but less aggressively than extreme scenarios. +Tests that rebalancer responds proportionally to imbalance severity.`, + scenario: ScenarioGenerator.imbalanceScenario( + DEFAULT_CHAINS, + 'chain1', + 15, + 8000, + [BigInt(toWei(2)), BigInt(toWei(6))], + 0.7, + ), + expectations: { + minCompletionRate: 0.85, + shouldTriggerRebalancing: true, + }, +}); + +saveScenario({ + name: 'sustained-drain-chain3', + description: + 'Tests rebalancer under sustained one-way flow over longer duration.', + expectedBehavior: `30 transfers over 30 seconds, all chain3 → chain1. +Sustained pressure rather than burst - tests rebalancer endurance. +Chain1 continuously drained, chain3 continuously accumulates. +Rebalancer must keep up with ongoing imbalance, not just react once.`, + scenario: ScenarioGenerator.unidirectionalFlow({ + origin: 'chain3', + destination: 'chain1', + transferCount: 30, + duration: 30000, + amount: [BigInt(toWei(2)), BigInt(toWei(5))], + }), + expectations: { + minCompletionRate: 0.85, + shouldTriggerRebalancing: true, + }, +}); + +console.log('\nDone! Generated scenarios in:', SCENARIOS_DIR); +console.log('\nRun simulations with: pnpm test'); diff --git a/typescript/rebalancer-sim/src/BridgeMockController.ts b/typescript/rebalancer-sim/src/BridgeMockController.ts new file mode 100644 index 00000000000..6f562c95ee7 --- /dev/null +++ b/typescript/rebalancer-sim/src/BridgeMockController.ts @@ -0,0 +1,391 @@ +import { ethers } from 'ethers'; +import { EventEmitter } from 'events'; + +import { + ERC20Test__factory, + MockValueTransferBridge__factory, +} from '@hyperlane-xyz/core'; +import type { Address } from '@hyperlane-xyz/utils'; +import { rootLogger } from '@hyperlane-xyz/utils'; + +import type { + BridgeEvent, + BridgeMockConfig, + BridgeRouteConfig, + DeployedDomain, + PendingTransfer, +} from './types.js'; +import { DEFAULT_BRIDGE_ROUTE_CONFIG } from './types.js'; + +const logger = rootLogger.child({ module: 'BridgeMockController' }); + +/** + * BridgeMockController manages simulated bridge transfers with configurable + * delays, failures, and fees. It intercepts SentTransferRemote events and + * schedules async delivery to simulate real bridge behavior. + */ +export class BridgeMockController extends EventEmitter { + private pendingTransfers: Map = new Map(); + private completedTransfers: PendingTransfer[] = []; + private transferCounter = 0; + private deliveryTimers: Map = new Map(); + private isRunning = false; + private eventListeners: Map = new Map(); + + // Transaction queue to prevent nonce collisions + private txQueue: Array<() => Promise> = []; + private txProcessing = false; + + constructor( + private readonly provider: ethers.providers.JsonRpcProvider, + private readonly domains: Record, + private readonly deployerKey: string, + private readonly bridgeConfig: BridgeMockConfig = {}, + ) { + super(); + } + + /** + * Queue a transaction to be executed serially (prevents nonce collisions) + */ + private async queueTransaction(fn: () => Promise): Promise { + return new Promise((resolve, reject) => { + this.txQueue.push(async () => { + try { + await fn(); + resolve(); + } catch (error) { + reject(error); + } + }); + void this.processQueue(); + }); + } + + /** + * Process queued transactions one at a time + */ + private async processQueue(): Promise { + if (this.txProcessing || this.txQueue.length === 0) return; + + this.txProcessing = true; + while (this.txQueue.length > 0) { + const fn = this.txQueue.shift(); + if (fn) { + try { + await fn(); + } catch (_error) { + // Error already handled in queueTransaction + } + } + } + this.txProcessing = false; + } + + /** + * Gets the bridge config for a specific route + */ + private getRouteConfig( + origin: string, + destination: string, + ): BridgeRouteConfig { + return ( + this.bridgeConfig[origin]?.[destination] ?? DEFAULT_BRIDGE_ROUTE_CONFIG + ); + } + + /** + * Calculates delivery delay with jitter + */ + private calculateDelay(config: BridgeRouteConfig): number { + const jitter = (Math.random() - 0.5) * 2 * config.deliveryJitter; + return Math.max(0, config.deliveryDelay + jitter); + } + + /** + * Start listening for bridge events + */ + async start(): Promise { + if (this.isRunning) return; + this.isRunning = true; + + const deployer = new ethers.Wallet(this.deployerKey, this.provider); + + // Set up event listeners for each bridge + for (const [chainName, domain] of Object.entries(this.domains)) { + const bridge = MockValueTransferBridge__factory.connect( + domain.bridge, + deployer, + ); + + // Listen for SentTransferRemote events + bridge.on( + bridge.filters.SentTransferRemote(), + (origin, destination, recipient, amount) => { + void this.onTransferInitiated( + chainName, + origin, + destination, + recipient, + amount.toBigInt(), + ); + }, + ); + + this.eventListeners.set(chainName, bridge); + } + } + + /** + * Stop listening and cancel pending deliveries + */ + async stop(): Promise { + this.isRunning = false; + + // Remove event listeners + for (const bridge of this.eventListeners.values()) { + bridge.removeAllListeners(); + } + this.eventListeners.clear(); + + // Cancel pending delivery timers + for (const timer of this.deliveryTimers.values()) { + clearTimeout(timer); + } + this.deliveryTimers.clear(); + } + + /** + * Handle transfer initiated event + */ + private async onTransferInitiated( + originChain: string, + originDomainId: number, + destinationDomainId: number, + recipientBytes32: string, + amount: bigint, + ): Promise { + // Find destination chain by domain ID + const destChain = Object.entries(this.domains).find( + ([_, d]) => d.domainId === destinationDomainId, + )?.[0]; + + if (!destChain) { + logger.error({ destinationDomainId }, 'Unknown destination domain'); + return; + } + + const config = this.getRouteConfig(originChain, destChain); + const transferId = `bridge-${this.transferCounter++}`; + const delay = this.calculateDelay(config); + + // Apply token fee if configured + let netAmount = amount; + if (config.tokenFeeBps) { + const fee = (amount * BigInt(config.tokenFeeBps)) / BigInt(10000); + netAmount = amount - fee; + } + + const recipient = ethers.utils.hexDataSlice( + recipientBytes32, + 12, + ) as Address; + + // MockValueTransferBridge pulls tokens from origin warp token. + // Bridge delivery mints to destination, preserving total warp token collateral. + + const pendingTransfer: PendingTransfer = { + id: transferId, + origin: originChain, + destination: destChain, + amount: netAmount, + recipient, + scheduledDelivery: Date.now() + delay, + failed: false, + delivered: false, + }; + + this.pendingTransfers.set(transferId, pendingTransfer); + + // Emit event + const bridgeEvent: BridgeEvent = { + type: 'transfer_initiated', + transfer: pendingTransfer, + timestamp: Date.now(), + }; + this.emit('transfer_initiated', bridgeEvent); + + // Schedule delivery + const timer = setTimeout( + () => this.executeDelivery(transferId, config), + delay, + ); + this.deliveryTimers.set(transferId, timer); + } + + /** + * Execute delivery of a pending transfer + */ + private async executeDelivery( + transferId: string, + config: BridgeRouteConfig, + ): Promise { + const transfer = this.pendingTransfers.get(transferId); + if (!transfer || transfer.delivered) return; + + this.deliveryTimers.delete(transferId); + + // Check for failure + if (Math.random() < config.failureRate) { + transfer.failed = true; + this.pendingTransfers.delete(transferId); + this.completedTransfers.push(transfer); + + const event: BridgeEvent = { + type: 'transfer_failed', + transfer, + timestamp: Date.now(), + }; + this.emit('transfer_failed', event); + return; + } + + try { + // Execute the delivery by simulating tokens arriving at destination + // In a real scenario, this would call the destination warp token's handle function + // For simulation, we directly transfer tokens to simulate bridge completion + await this.simulateBridgeDelivery(transfer); + + transfer.delivered = true; + transfer.deliveredAt = Date.now(); + this.pendingTransfers.delete(transferId); + this.completedTransfers.push(transfer); + + const event: BridgeEvent = { + type: 'transfer_delivered', + transfer, + timestamp: Date.now(), + }; + this.emit('transfer_delivered', event); + } catch (error) { + logger.error({ transferId, error }, 'Bridge delivery failed'); + transfer.failed = true; + this.pendingTransfers.delete(transferId); + this.completedTransfers.push(transfer); + + const event: BridgeEvent = { + type: 'transfer_failed', + transfer, + timestamp: Date.now(), + }; + this.emit('transfer_failed', event); + } + } + + /** + * Simulate bridge delivery by minting tokens at destination. + * Uses transaction queue to prevent nonce collisions. + */ + private async simulateBridgeDelivery( + transfer: PendingTransfer, + ): Promise { + await this.queueTransaction(async () => { + const deployer = new ethers.Wallet(this.deployerKey, this.provider); + const destDomain = this.domains[transfer.destination]; + + // Mint tokens to destination warp token to simulate tokens arriving + const destCollateralToken = ERC20Test__factory.connect( + destDomain.collateralToken, + deployer, + ); + const mintTx = await destCollateralToken.mintTo( + destDomain.warpToken, + transfer.amount.toString(), + ); + await mintTx.wait(); + }); + } + + /** + * Manually trigger delivery for a pending transfer (for testing) + */ + async forceDelivery(transferId: string): Promise { + const transfer = this.pendingTransfers.get(transferId); + if (!transfer) { + throw new Error(`Transfer not found: ${transferId}`); + } + + // Cancel scheduled delivery + const timer = this.deliveryTimers.get(transferId); + if (timer) { + clearTimeout(timer); + this.deliveryTimers.delete(transferId); + } + + // Execute immediately + await this.executeDelivery( + transferId, + this.getRouteConfig(transfer.origin, transfer.destination), + ); + } + + /** + * Check if there are pending transfers + */ + hasPendingTransfers(): boolean { + return this.pendingTransfers.size > 0; + } + + /** + * Get count of pending transfers + */ + getPendingCount(): number { + return this.pendingTransfers.size; + } + + /** + * Get all pending transfers + */ + getPendingTransfers(): PendingTransfer[] { + return Array.from(this.pendingTransfers.values()); + } + + /** + * Get completed transfers + */ + getCompletedTransfers(): PendingTransfer[] { + return [...this.completedTransfers]; + } + + /** + * Wait for all pending transfers to complete + * On timeout, marks remaining transfers as failed and clears them + */ + async waitForAllDeliveries(timeoutMs: number = 30000): Promise { + const startTime = Date.now(); + + while (this.hasPendingTransfers()) { + if (Date.now() - startTime > timeoutMs) { + const pendingCount = this.getPendingCount(); + logger.warn( + { pendingCount }, + 'Timeout waiting for bridge deliveries - marking as failed', + ); + // Mark all pending as failed, update state, and clear + for (const transfer of this.pendingTransfers.values()) { + transfer.failed = true; + this.completedTransfers.push(transfer); + const event: BridgeEvent = { + type: 'transfer_failed', + transfer, + timestamp: Date.now(), + }; + this.emit('transfer_failed', event); + } + this.pendingTransfers.clear(); + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } +} diff --git a/typescript/rebalancer-sim/src/KPICollector.ts b/typescript/rebalancer-sim/src/KPICollector.ts new file mode 100644 index 00000000000..246da946542 --- /dev/null +++ b/typescript/rebalancer-sim/src/KPICollector.ts @@ -0,0 +1,304 @@ +import type { ethers } from 'ethers'; + +import { ERC20Test__factory } from '@hyperlane-xyz/core'; + +import type { + ChainMetrics, + DeployedDomain, + RebalanceRecord, + SimulationKPIs, + TransferRecord, +} from './types.js'; + +/** + * KPICollector tracks metrics throughout a simulation run. + */ +export class KPICollector { + private transferRecords: Map = new Map(); + private rebalanceRecords: Map = new Map(); + /** Maps bridge transfer ID to rebalance ID for correlation */ + private bridgeToRebalanceMap: Map = new Map(); + private initialBalances: Record = {}; + + constructor( + private readonly provider: ethers.providers.JsonRpcProvider, + private readonly domains: Record, + ) {} + + /** + * Initialize with initial balances (passed explicitly or fetched) + */ + async initialize(initialBalances?: Record): Promise { + if (initialBalances) { + this.initialBalances = { ...initialBalances }; + } else { + for (const chainName of Object.keys(this.domains)) { + this.initialBalances[chainName] = await this.getBalance(chainName); + } + } + } + + /** + * Get current balance for a chain's warp token + */ + private async getBalance(chainName: string): Promise { + const domain = this.domains[chainName]; + const token = ERC20Test__factory.connect( + domain.collateralToken, + this.provider, + ); + const balance = await token.balanceOf(domain.warpToken); + return balance.toBigInt(); + } + + /** + * Record transfer start + */ + recordTransferStart( + id: string, + origin: string, + destination: string, + amount: bigint, + ): void { + this.transferRecords.set(id, { + id, + origin, + destination, + amount, + startTime: Date.now(), + status: 'pending', + }); + } + + /** + * Record transfer completion + */ + recordTransferComplete(id: string): void { + const record = this.transferRecords.get(id); + if (record) { + record.endTime = Date.now(); + record.latency = record.endTime - record.startTime; + record.status = 'completed'; + } + } + + /** + * Record transfer failure + */ + recordTransferFailed(id: string): void { + const record = this.transferRecords.get(id); + if (record) { + record.endTime = Date.now(); + record.status = 'failed'; + } + } + + /** + * Mark all pending transfers as complete (used after mailbox processing) + */ + markAllPendingAsComplete(): void { + const now = Date.now(); + for (const record of this.transferRecords.values()) { + if (record.status === 'pending') { + record.endTime = now; + record.latency = now - record.startTime; + record.status = 'completed'; + } + } + } + + /** + * Record a rebalance operation start (when SentTransferRemote fires) + * Returns the rebalance ID for correlation + */ + recordRebalanceStart( + origin: string, + destination: string, + amount: bigint, + gasCost: bigint, + ): string { + const id = `rebalance-${this.rebalanceRecords.size}`; + this.rebalanceRecords.set(id, { + id, + origin, + destination, + amount, + startTime: Date.now(), + gasCost, + status: 'pending', + }); + return id; + } + + /** + * Link a bridge transfer ID to a rebalance ID for delivery tracking + */ + linkBridgeTransfer(bridgeTransferId: string, rebalanceId: string): void { + this.bridgeToRebalanceMap.set(bridgeTransferId, rebalanceId); + const record = this.rebalanceRecords.get(rebalanceId); + if (record) { + record.bridgeTransferId = bridgeTransferId; + } + } + + /** + * Record rebalance completion (when bridge delivers) + */ + recordRebalanceComplete(bridgeTransferId: string): void { + const rebalanceId = this.bridgeToRebalanceMap.get(bridgeTransferId); + if (!rebalanceId) return; + + const record = this.rebalanceRecords.get(rebalanceId); + if (record && record.status === 'pending') { + record.endTime = Date.now(); + record.latency = record.endTime - record.startTime; + record.status = 'completed'; + } + } + + /** + * Record rebalance failure + */ + recordRebalanceFailed(bridgeTransferId: string): void { + const rebalanceId = this.bridgeToRebalanceMap.get(bridgeTransferId); + if (!rebalanceId) return; + + const record = this.rebalanceRecords.get(rebalanceId); + if (record) { + record.endTime = Date.now(); + record.status = 'failed'; + } + } + + /** + * Get pending rebalances count + */ + getPendingRebalancesCount(): number { + return Array.from(this.rebalanceRecords.values()).filter( + (r) => r.status === 'pending', + ).length; + } + + /** + * Calculate percentile from sorted array + */ + private percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const index = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, index)]; + } + + /** + * Generate final KPIs + */ + async generateKPIs(): Promise { + const transfers = Array.from(this.transferRecords.values()); + const completed = transfers.filter((t) => t.status === 'completed'); + const failed = transfers.filter((t) => t.status === 'failed'); + + // Calculate latencies + const latencies = completed + .filter((t) => t.latency !== undefined) + .map((t) => t.latency!) + .sort((a, b) => a - b); + + const avgLatency = + latencies.length > 0 + ? latencies.reduce((a, b) => a + b, 0) / latencies.length + : 0; + + // Calculate per-chain metrics + const perChainMetrics: Record = {}; + for (const chainName of Object.keys(this.domains)) { + const transfersIn = transfers.filter( + (t) => t.destination === chainName && t.status === 'completed', + ).length; + const transfersOut = transfers.filter( + (t) => t.origin === chainName && t.status === 'completed', + ).length; + + const allRebalances = Array.from(this.rebalanceRecords.values()); + const rebalancesIn = allRebalances.filter( + (r) => r.destination === chainName && r.status === 'completed', + ).length; + const rebalancesOut = allRebalances.filter( + (r) => r.origin === chainName && r.status === 'completed', + ).length; + + const rebalanceVolumeIn = allRebalances + .filter((r) => r.destination === chainName && r.status === 'completed') + .reduce((sum, r) => sum + r.amount, BigInt(0)); + const rebalanceVolumeOut = allRebalances + .filter((r) => r.origin === chainName && r.status === 'completed') + .reduce((sum, r) => sum + r.amount, BigInt(0)); + + const finalBalance = await this.getBalance(chainName); + + perChainMetrics[chainName] = { + chainName, + initialBalance: this.initialBalances[chainName] ?? BigInt(0), + finalBalance, + transfersIn, + transfersOut, + rebalancesIn, + rebalancesOut, + rebalanceVolumeIn, + rebalanceVolumeOut, + }; + } + + // Calculate rebalance totals + const allRebalanceRecords = Array.from(this.rebalanceRecords.values()); + const completedRebalances = allRebalanceRecords.filter( + (r) => r.status === 'completed', + ); + const totalRebalanceVolume = completedRebalances.reduce( + (sum, r) => sum + r.amount, + BigInt(0), + ); + const totalGasCost = completedRebalances.reduce( + (sum, r) => sum + r.gasCost, + BigInt(0), + ); + + return { + totalTransfers: transfers.length, + completedTransfers: completed.length, + failedTransfers: failed.length, + completionRate: + transfers.length > 0 ? completed.length / transfers.length : 1, + averageLatency: avgLatency, + p50Latency: this.percentile(latencies, 50), + p95Latency: this.percentile(latencies, 95), + p99Latency: this.percentile(latencies, 99), + totalRebalances: completedRebalances.length, + rebalanceVolume: totalRebalanceVolume, + totalGasCost, + perChainMetrics, + }; + } + + /** + * Get transfer records + */ + getTransferRecords(): TransferRecord[] { + return Array.from(this.transferRecords.values()); + } + + /** + * Get rebalance records + */ + getRebalanceRecords(): RebalanceRecord[] { + return Array.from(this.rebalanceRecords.values()); + } + + /** + * Reset collector for new simulation + */ + reset(): void { + this.transferRecords.clear(); + this.rebalanceRecords.clear(); + this.bridgeToRebalanceMap.clear(); + this.initialBalances = {}; + } +} diff --git a/typescript/rebalancer-sim/src/MessageTracker.ts b/typescript/rebalancer-sim/src/MessageTracker.ts new file mode 100644 index 00000000000..87ff3f066d5 --- /dev/null +++ b/typescript/rebalancer-sim/src/MessageTracker.ts @@ -0,0 +1,312 @@ +import { ethers } from 'ethers'; +import { EventEmitter } from 'events'; + +import { MockMailbox__factory } from '@hyperlane-xyz/core'; +import { rootLogger } from '@hyperlane-xyz/utils'; + +import type { DeployedDomain } from './types.js'; + +const logger = rootLogger.child({ module: 'MessageTracker' }); + +/** + * Tracked message for off-chain processing control + */ +export interface TrackedMessage { + id: string; + transferId: string; + origin: string; + destination: string; + /** Nonce on the destination mailbox */ + destinationNonce: number; + /** When the message was dispatched */ + dispatchedAt: number; + /** When we should attempt delivery */ + deliveryTime: number; + /** Processing status */ + status: 'pending' | 'inflight' | 'delivered' | 'failed'; + /** Number of delivery attempts */ + attempts: number; + /** Last error if failed */ + lastError?: string; +} + +/** + * MessageTracker provides off-chain tracking and selective processing + * of Hyperlane messages. Fires transactions in parallel without blocking + * on receipts, similar to how the Hyperlane relayer batches messages. + */ +export class MessageTracker extends EventEmitter { + private messages: Map = new Map(); + private messageCounter = 0; + private destinationNonces: Map = new Map(); + private signer: ethers.Wallet; + private currentNonce: number = 0; + private nonceInitialized = false; + + constructor( + private readonly provider: ethers.providers.JsonRpcProvider, + private readonly domains: Record, + signerKey: string, + ) { + super(); + this.signer = new ethers.Wallet(signerKey, provider); + } + + /** + * Initialize by fetching current nonces from all destination mailboxes + */ + async initialize(): Promise { + for (const [chainName, domain] of Object.entries(this.domains)) { + const mailbox = MockMailbox__factory.connect( + domain.mailbox, + this.provider, + ); + const nonce = await mailbox.inboundUnprocessedNonce(); + this.destinationNonces.set(chainName, Number(nonce)); + } + // Initialize signer nonce for parallel tx submission + this.currentNonce = await this.signer.getTransactionCount(); + this.nonceInitialized = true; + } + + /** + * Track a new message after a transfer is initiated. + * Call this after transferRemote() succeeds. + */ + async trackMessage( + transferId: string, + origin: string, + destination: string, + deliveryDelay: number, + ): Promise { + const destDomain = this.domains[destination]; + const mailbox = MockMailbox__factory.connect( + destDomain.mailbox, + this.provider, + ); + await mailbox.inboundUnprocessedNonce(); // Verify mailbox is accessible + + const expectedNonce = this.destinationNonces.get(destination) || 0; + this.destinationNonces.set(destination, expectedNonce + 1); + + const message: TrackedMessage = { + id: `msg-${this.messageCounter++}`, + transferId, + origin, + destination, + destinationNonce: expectedNonce, + dispatchedAt: Date.now(), + deliveryTime: Date.now() + deliveryDelay, + status: 'pending', + attempts: 0, + }; + + this.messages.set(message.id, message); + this.emit('message_tracked', message); + + return message; + } + + /** + * Get all messages ready for delivery (past their delivery time, not inflight) + */ + getReadyMessages(): TrackedMessage[] { + const now = Date.now(); + return Array.from(this.messages.values()).filter( + (m) => m.status === 'pending' && m.deliveryTime <= now, + ); + } + + /** + * Get all pending messages (including not yet ready and inflight) + */ + getPendingMessages(): TrackedMessage[] { + return Array.from(this.messages.values()).filter( + (m) => m.status === 'pending' || m.status === 'inflight', + ); + } + + /** + * Process all ready messages in parallel without blocking on receipts. + * Fires transactions and subscribes to completion asynchronously. + */ + async processReadyMessages(): Promise<{ delivered: number; failed: number }> { + const ready = this.getReadyMessages(); + if (ready.length === 0) { + return { delivered: 0, failed: 0 }; + } + + // Ensure nonce is initialized + if (!this.nonceInitialized) { + this.currentNonce = await this.signer.getTransactionCount(); + this.nonceInitialized = true; + } + + // Check which messages can actually be processed (have sufficient liquidity) + // by doing a static call first + const processable: TrackedMessage[] = []; + + const checkStartTime = Date.now(); + for (const message of ready) { + const destDomain = this.domains[message.destination]; + const mailbox = MockMailbox__factory.connect( + destDomain.mailbox, + this.signer, + ); + + const staticCallStart = Date.now(); + try { + // Static call to check if it would succeed + await mailbox.callStatic.processInboundMessage( + message.destinationNonce, + ); + const staticCallDuration = Date.now() - staticCallStart; + if (staticCallDuration > 100) { + logger.warn( + { transferId: message.transferId, staticCallDuration }, + 'Slow static call', + ); + } + processable.push(message); + // Log successful processing after retries + if (message.attempts > 0) { + const waitTime = Date.now() - message.dispatchedAt; + logger.debug( + { + transferId: message.transferId, + origin: message.origin, + destination: message.destination, + attempts: message.attempts, + waitTime, + }, + 'Message ready after retries', + ); + } + } catch (error: any) { + const staticCallDuration = Date.now() - staticCallStart; + const errorMsg = error.reason || error.message || ''; + // Check if message was already delivered (e.g., by bridge controller) + // This is a permanent state, not a temporary error + if (errorMsg.includes('already delivered')) { + message.status = 'delivered'; + this.emit('message_delivered', message); + continue; + } + // Other errors - mark attempt but keep pending for retry + message.attempts++; + message.lastError = errorMsg; + + // Log failures - every 5 attempts or on slow static calls + if (message.attempts % 5 === 0 || staticCallDuration > 100) { + const waitTime = Date.now() - message.dispatchedAt; + logger.debug( + { + transferId: message.transferId, + origin: message.origin, + destination: message.destination, + attempts: message.attempts, + waitTime, + error: errorMsg, + }, + 'Message delivery failed, will retry', + ); + } + } + } + + const totalCheckTime = Date.now() - checkStartTime; + if (totalCheckTime > 500) { + logger.warn( + { messageCount: ready.length, totalCheckTime }, + 'Slow static call checks', + ); + } + + if (processable.length === 0) { + // No messages processable yet - not a failure, they will retry + return { delivered: 0, failed: 0 }; + } + + // Fire all processable transactions in parallel + const txPromises: Array<{ + message: TrackedMessage; + txPromise: Promise; + }> = []; + + for (const message of processable) { + message.status = 'inflight'; + message.attempts++; + + const destDomain = this.domains[message.destination]; + const mailbox = MockMailbox__factory.connect( + destDomain.mailbox, + this.signer, + ); + + // Fire transaction with explicit nonce (don't wait) + const txPromise = mailbox.processInboundMessage( + message.destinationNonce, + { nonce: this.currentNonce++ }, + ); + + txPromises.push({ message, txPromise }); + } + + // Subscribe to all tx completions asynchronously + let delivered = 0; + let failed = 0; + + await Promise.all( + txPromises.map(async ({ message, txPromise }) => { + try { + const tx = await txPromise; + await tx.wait(); + + message.status = 'delivered'; + this.emit('message_delivered', message); + delivered++; + } catch (error: any) { + // Transaction failed - back to pending for retry + message.status = 'pending'; + message.lastError = error.reason || error.message; + failed++; + } + }), + ); + + return { delivered, failed }; + } + + /** + * Check if there are any pending or inflight messages + */ + hasPendingMessages(): boolean { + return this.getPendingMessages().length > 0; + } + + /** + * Get message by transfer ID + */ + getMessageByTransferId(transferId: string): TrackedMessage | undefined { + return Array.from(this.messages.values()).find( + (m) => m.transferId === transferId, + ); + } + + /** + * Get all messages + */ + getAllMessages(): TrackedMessage[] { + return Array.from(this.messages.values()); + } + + /** + * Clear all tracked messages (for reset) + */ + clear(): void { + this.messages.clear(); + this.messageCounter = 0; + this.destinationNonces.clear(); + this.nonceInitialized = false; + } +} diff --git a/typescript/rebalancer-sim/src/RebalancerSimulationHarness.ts b/typescript/rebalancer-sim/src/RebalancerSimulationHarness.ts new file mode 100644 index 00000000000..871e54cbd97 --- /dev/null +++ b/typescript/rebalancer-sim/src/RebalancerSimulationHarness.ts @@ -0,0 +1,325 @@ +import { rootLogger } from '@hyperlane-xyz/utils'; + +import { deployMultiDomainSimulation } from './SimulationDeployment.js'; +import { DEFAULT_TIMING, SimulationEngine } from './SimulationEngine.js'; +import { cleanupProductionRebalancer } from './runners/ProductionRebalancerRunner.js'; +import type { + BridgeMockConfig, + ComparisonReport, + IRebalancerRunner, + MultiDomainDeploymentOptions, + MultiDomainDeploymentResult, + RebalancerSimConfig, + SimulatedChainConfig, + SimulationResult, + SimulationTiming, + TransferScenario, +} from './types.js'; +import { + ANVIL_DEPLOYER_KEY, + DEFAULT_SIMULATED_CHAINS, + createSymmetricBridgeConfig, +} from './types.js'; + +const logger = rootLogger.child({ module: 'RebalancerSimulationHarness' }); + +/** + * Configuration for the simulation harness + */ +export interface HarnessConfig { + /** Chain configurations */ + chains?: SimulatedChainConfig[]; + /** Anvil RPC URL */ + anvilRpc?: string; + /** Deployer private key */ + deployerKey?: string; + /** Initial collateral balance per chain (in wei) */ + initialCollateralBalance?: bigint; + /** Token decimals */ + tokenDecimals?: number; +} + +/** + * Default harness configuration + */ +export const DEFAULT_HARNESS_CONFIG: Required = { + chains: DEFAULT_SIMULATED_CHAINS, + anvilRpc: 'http://localhost:8545', + deployerKey: ANVIL_DEPLOYER_KEY, + initialCollateralBalance: BigInt('100000000000000000000'), // 100 tokens + tokenDecimals: 18, +}; + +/** + * RebalancerSimulationHarness is the main entry point for running + * rebalancer simulations. It manages deployment, scenario execution, + * and result collection. + */ +export class RebalancerSimulationHarness { + private deployment?: MultiDomainDeploymentResult; + private engine?: SimulationEngine; + private config: Required; + + constructor(config: HarnessConfig = {}) { + this.config = { + ...DEFAULT_HARNESS_CONFIG, + ...config, + }; + } + + /** + * Initialize the harness by deploying the simulation environment + */ + async initialize(): Promise { + const deployOptions: MultiDomainDeploymentOptions = { + anvilRpc: this.config.anvilRpc, + deployerKey: this.config.deployerKey, + chains: this.config.chains, + initialCollateralBalance: this.config.initialCollateralBalance, + tokenDecimals: this.config.tokenDecimals, + }; + + logger.info('Deploying multi-domain simulation environment...'); + this.deployment = await deployMultiDomainSimulation(deployOptions); + logger.info('Deployment complete'); + + // Log deployed addresses + for (const [chainName, domain] of Object.entries(this.deployment.domains)) { + logger.info( + { + chainName, + domainId: domain.domainId, + mailbox: domain.mailbox, + warpToken: domain.warpToken, + collateralToken: domain.collateralToken, + bridge: domain.bridge, + }, + 'Deployed domain', + ); + } + + this.engine = new SimulationEngine(this.deployment); + } + + /** + * Run a simulation with the given scenario and rebalancer + */ + async runSimulation( + scenario: TransferScenario, + rebalancer: IRebalancerRunner, + options: { + bridgeConfig?: BridgeMockConfig; + timing?: SimulationTiming; + strategyConfig: RebalancerSimConfig['strategyConfig']; + }, + ): Promise { + if (!this.deployment || !this.engine) { + throw new Error('Harness not initialized. Call initialize() first.'); + } + + const bridgeConfig = + options.bridgeConfig ?? + createSymmetricBridgeConfig(this.config.chains.map((c) => c.chainName)); + + const timing = options.timing ?? DEFAULT_TIMING; + + logger.info( + { + scenario: scenario.name, + rebalancer: rebalancer.name, + transfers: scenario.transfers.length, + duration: scenario.duration, + }, + 'Running simulation', + ); + + const result = await this.engine.runSimulation( + scenario, + rebalancer, + bridgeConfig, + timing, + options.strategyConfig, + ); + + logger.info( + { + completionRate: `${(result.kpis.completionRate * 100).toFixed(1)}%`, + averageLatency: `${result.kpis.averageLatency.toFixed(0)}ms`, + totalRebalances: result.kpis.totalRebalances, + }, + 'Simulation complete', + ); + + return result; + } + + /** + * Compare multiple rebalancers on the same scenario + */ + async compareRebalancers( + scenario: TransferScenario, + rebalancers: IRebalancerRunner[], + options: { + bridgeConfig?: BridgeMockConfig; + timing?: SimulationTiming; + strategyConfig: RebalancerSimConfig['strategyConfig']; + }, + ): Promise { + if (!this.deployment || !this.engine) { + throw new Error('Harness not initialized. Call initialize() first.'); + } + + const results: SimulationResult[] = []; + + for (const rebalancer of rebalancers) { + // Recreate engine with fresh provider to avoid cached RPC state + // This is important because ethers v5 caches chainId, nonces, etc. + this.engine = new SimulationEngine(this.deployment); + + // Run simulation + const result = await this.runSimulation(scenario, rebalancer, options); + results.push(result); + + // Cleanup between runs to ensure fresh state + await cleanupProductionRebalancer(); + } + + // Generate comparison + const comparison = this.generateComparison(results); + + return { + scenarioName: scenario.name, + results, + comparison, + }; + } + + /** + * Generate comparison metrics from results + */ + private generateComparison( + results: SimulationResult[], + ): ComparisonReport['comparison'] { + let bestCompletionRate = ''; + let bestLatency = ''; + let lowestGasCost = ''; + + let maxCompletionRate = -1; + let minLatency = Infinity; + let minGasCost = BigInt('0xffffffffffffffffffffffffffffffff'); + + for (const result of results) { + if (result.kpis.completionRate > maxCompletionRate) { + maxCompletionRate = result.kpis.completionRate; + bestCompletionRate = result.rebalancerName; + } + + if (result.kpis.averageLatency < minLatency) { + minLatency = result.kpis.averageLatency; + bestLatency = result.rebalancerName; + } + + if (result.kpis.totalGasCost < minGasCost) { + minGasCost = result.kpis.totalGasCost; + lowestGasCost = result.rebalancerName; + } + } + + return { + bestCompletionRate, + bestLatency, + lowestGasCost, + }; + } + + /** + * Get the deployment info + */ + getDeployment(): MultiDomainDeploymentResult | undefined { + return this.deployment; + } + + /** + * Reset the simulation engine state (does not reset blockchain state) + */ + reset(): void { + if (this.engine) { + this.engine.reset(); + } + } + + /** + * Generate a markdown report from simulation results + */ + static generateMarkdownReport(result: SimulationResult): string { + const lines: string[] = [ + `# Simulation Report: ${result.scenarioName}`, + '', + `**Rebalancer:** ${result.rebalancerName}`, + `**Duration:** ${result.duration}ms`, + '', + '## KPIs', + '', + '| Metric | Value |', + '|--------|-------|', + `| Total Transfers | ${result.kpis.totalTransfers} |`, + `| Completed Transfers | ${result.kpis.completedTransfers} |`, + `| Failed Transfers | ${result.kpis.failedTransfers} |`, + `| Completion Rate | ${(result.kpis.completionRate * 100).toFixed(1)}% |`, + `| Average Latency | ${result.kpis.averageLatency.toFixed(0)}ms |`, + `| P50 Latency | ${result.kpis.p50Latency}ms |`, + `| P95 Latency | ${result.kpis.p95Latency}ms |`, + `| P99 Latency | ${result.kpis.p99Latency}ms |`, + `| Total Rebalances | ${result.kpis.totalRebalances} |`, + `| Rebalance Volume | ${result.kpis.rebalanceVolume.toString()} |`, + `| Total Gas Cost | ${result.kpis.totalGasCost.toString()} |`, + '', + '## Per-Chain Metrics', + '', + '| Chain | Initial | Final | Transfers In | Transfers Out | Rebalances In | Rebalances Out |', + '|-------|---------|-------|--------------|---------------|---------------|----------------|', + ]; + + for (const metrics of Object.values(result.kpis.perChainMetrics)) { + lines.push( + `| ${metrics.chainName} | ${metrics.initialBalance.toString()} | ${metrics.finalBalance.toString()} | ${metrics.transfersIn} | ${metrics.transfersOut} | ${metrics.rebalancesIn} | ${metrics.rebalancesOut} |`, + ); + } + + return lines.join('\n'); + } + + /** + * Generate a markdown comparison report + */ + static generateComparisonReport(report: ComparisonReport): string { + const lines: string[] = [ + `# Comparison Report: ${report.scenarioName}`, + '', + '## Summary', + '', + `- **Best Completion Rate:** ${report.comparison.bestCompletionRate}`, + `- **Best Latency:** ${report.comparison.bestLatency}`, + `- **Lowest Gas Cost:** ${report.comparison.lowestGasCost}`, + '', + '## Results by Rebalancer', + '', + ]; + + for (const result of report.results) { + lines.push(`### ${result.rebalancerName}`); + lines.push(''); + lines.push( + `- Completion Rate: ${(result.kpis.completionRate * 100).toFixed(1)}%`, + ); + lines.push( + `- Average Latency: ${result.kpis.averageLatency.toFixed(0)}ms`, + ); + lines.push(`- Total Rebalances: ${result.kpis.totalRebalances}`); + lines.push(`- Gas Cost: ${result.kpis.totalGasCost.toString()}`); + lines.push(''); + } + + return lines.join('\n'); + } +} diff --git a/typescript/rebalancer-sim/src/ScenarioGenerator.ts b/typescript/rebalancer-sim/src/ScenarioGenerator.ts new file mode 100644 index 00000000000..9068f14ab82 --- /dev/null +++ b/typescript/rebalancer-sim/src/ScenarioGenerator.ts @@ -0,0 +1,433 @@ +import { randomAddress } from '@hyperlane-xyz/sdk'; +import type { Address } from '@hyperlane-xyz/utils'; + +import type { + RandomTrafficOptions, + SerializedScenario, + SerializedTransferEvent, + SurgeScenarioOptions, + TransferEvent, + TransferScenario, + UnidirectionalFlowOptions, +} from './types.js'; + +/** + * Generates random bigint in range [min, max] (inclusive) + * Uses chunked random generation to avoid precision loss for large ranges + */ +function randomBigIntInRange(min: bigint, max: bigint): bigint { + const range = max - min + BigInt(1); // +1 to make max inclusive + + // For small ranges that fit in Number.MAX_SAFE_INTEGER, use simple approach + if (range <= BigInt(Number.MAX_SAFE_INTEGER)) { + const randomFactor = BigInt(Math.floor(Math.random() * Number(range))); + return min + randomFactor; + } + + // For large ranges, generate random bytes and mod by range + // This avoids precision loss by working with bigints throughout + const rangeHex = range.toString(16); + const bytesNeeded = Math.ceil(rangeHex.length / 2) + 1; // +1 for safety margin + + let randomBigInt = BigInt(0); + for (let i = 0; i < bytesNeeded; i++) { + randomBigInt = + (randomBigInt << BigInt(8)) | BigInt(Math.floor(Math.random() * 256)); + } + + return min + (randomBigInt % range); +} + +/** + * Generates a unique transfer ID + */ +function generateTransferId(index: number, prefix: string = 'tx'): string { + return `${prefix}-${index.toString().padStart(6, '0')}`; +} + +/** + * Generates a Poisson-distributed interval + */ +function poissonInterval(meanInterval: number): number { + // Using inverse transform sampling for exponential distribution + const u = Math.random(); + return -Math.log(1 - u) * meanInterval; +} + +/** + * ScenarioGenerator creates transfer scenarios for simulation testing. + */ +export class ScenarioGenerator { + /** + * Generates a unidirectional flow scenario where all transfers + * go from one chain to another. + */ + static unidirectionalFlow( + options: UnidirectionalFlowOptions, + ): TransferScenario { + const { + origin, + destination, + transferCount, + duration, + amount, + user = randomAddress(), + } = options; + + const interval = duration / transferCount; + const transfers: TransferEvent[] = []; + + for (let i = 0; i < transferCount; i++) { + const transferAmount = Array.isArray(amount) + ? randomBigIntInRange(amount[0], amount[1]) + : amount; + + transfers.push({ + id: generateTransferId(i, 'uni'), + timestamp: Math.floor(i * interval), + origin, + destination, + amount: transferAmount, + user: user as Address, + }); + } + + return { + name: `unidirectional-${origin}-to-${destination}-${transferCount}tx`, + duration, + transfers, + chains: [origin, destination], + }; + } + + /** + * Generates random traffic across multiple chains with configurable distribution. + */ + static randomTraffic(options: RandomTrafficOptions): TransferScenario { + const { + chains, + transferCount, + duration, + amountRange, + users = [randomAddress() as Address], + distribution = 'uniform', + poissonMeanInterval, + } = options; + + if (chains.length < 2) { + throw new Error('Random traffic requires at least 2 chains'); + } + + const transfers: TransferEvent[] = []; + let currentTime = 0; + + for (let i = 0; i < transferCount; i++) { + // Pick random origin and destination (must be different) + const originIndex = Math.floor(Math.random() * chains.length); + let destIndex = Math.floor(Math.random() * chains.length); + while (destIndex === originIndex) { + destIndex = Math.floor(Math.random() * chains.length); + } + + // Calculate timestamp based on distribution + let timestamp: number; + if (distribution === 'poisson' && poissonMeanInterval) { + currentTime += poissonInterval(poissonMeanInterval); + timestamp = Math.min(Math.floor(currentTime), duration); + } else { + timestamp = Math.floor(Math.random() * duration); + } + + transfers.push({ + id: generateTransferId(i, 'rnd'), + timestamp, + origin: chains[originIndex], + destination: chains[destIndex], + amount: randomBigIntInRange(amountRange[0], amountRange[1]), + user: users[Math.floor(Math.random() * users.length)], + }); + } + + // Sort by timestamp + transfers.sort((a, b) => a.timestamp - b.timestamp); + + return { + name: `random-${chains.length}chains-${transferCount}tx`, + duration, + transfers, + chains, + }; + } + + /** + * Generates a surge scenario with baseline traffic and a surge period. + */ + static surgeScenario(options: SurgeScenarioOptions): TransferScenario { + const { + chains, + baselineRate, + surgeMultiplier, + surgeStart, + surgeDuration, + totalDuration, + amountRange, + } = options; + + const transfers: TransferEvent[] = []; + let txIndex = 0; + + // Generate baseline traffic + const baselineInterval = 1000 / baselineRate; // ms between transfers + for (let t = 0; t < totalDuration; t += baselineInterval) { + // Skip surge period for baseline + if (t >= surgeStart && t < surgeStart + surgeDuration) { + continue; + } + + const originIndex = Math.floor(Math.random() * chains.length); + let destIndex = Math.floor(Math.random() * chains.length); + while (destIndex === originIndex) { + destIndex = Math.floor(Math.random() * chains.length); + } + + transfers.push({ + id: generateTransferId(txIndex++, 'base'), + timestamp: Math.floor(t), + origin: chains[originIndex], + destination: chains[destIndex], + amount: randomBigIntInRange(amountRange[0], amountRange[1]), + user: randomAddress() as Address, + }); + } + + // Generate surge traffic + const surgeRate = baselineRate * surgeMultiplier; + const surgeInterval = 1000 / surgeRate; + for ( + let t = surgeStart; + t < surgeStart + surgeDuration; + t += surgeInterval + ) { + const originIndex = Math.floor(Math.random() * chains.length); + let destIndex = Math.floor(Math.random() * chains.length); + while (destIndex === originIndex) { + destIndex = Math.floor(Math.random() * chains.length); + } + + transfers.push({ + id: generateTransferId(txIndex++, 'surge'), + timestamp: Math.floor(t), + origin: chains[originIndex], + destination: chains[destIndex], + amount: randomBigIntInRange(amountRange[0], amountRange[1]), + user: randomAddress() as Address, + }); + } + + // Sort by timestamp + transfers.sort((a, b) => a.timestamp - b.timestamp); + + return { + name: `surge-${chains.length}chains-${surgeMultiplier}x`, + duration: totalDuration, + transfers, + chains, + }; + } + + /** + * Generates truly balanced traffic where each chain has equal in/out flows. + * For every transfer A→B, generates a matching B→A transfer. + */ + static balancedTraffic(options: { + chains: string[]; + pairCount: number; // Number of balanced pairs + duration: number; + amountRange: [bigint, bigint]; + }): TransferScenario { + const { chains, pairCount, duration, amountRange } = options; + + if (chains.length < 2) { + throw new Error('Balanced traffic requires at least 2 chains'); + } + + const transfers: TransferEvent[] = []; + let txIndex = 0; + + // Generate chain pairs + const chainPairs: Array<[string, string]> = []; + for (let i = 0; i < chains.length; i++) { + for (let j = i + 1; j < chains.length; j++) { + chainPairs.push([chains[i], chains[j]]); + } + } + + // For each pair count, create a balanced pair of transfers + for (let i = 0; i < pairCount; i++) { + const pair = chainPairs[i % chainPairs.length]; + const amount = randomBigIntInRange(amountRange[0], amountRange[1]); + const baseTime = Math.floor((i / pairCount) * duration * 0.9); // Leave 10% buffer + + // A → B + transfers.push({ + id: generateTransferId(txIndex++, 'bal'), + timestamp: baseTime, + origin: pair[0], + destination: pair[1], + amount, + user: randomAddress() as Address, + }); + + // B → A (same amount, slightly later) + transfers.push({ + id: generateTransferId(txIndex++, 'bal'), + timestamp: baseTime + Math.floor(Math.random() * 500), // 0-500ms later + origin: pair[1], + destination: pair[0], + amount, + user: randomAddress() as Address, + }); + } + + // Sort by timestamp + transfers.sort((a, b) => a.timestamp - b.timestamp); + + return { + name: `balanced-${chains.length}chains-${pairCount * 2}tx`, + duration, + transfers, + chains, + }; + } + + /** + * Creates an imbalance scenario where one chain receives more than others. + * Useful for testing rebalancer response to imbalanced liquidity. + */ + static imbalanceScenario( + chains: string[], + heavyChain: string, + transferCount: number, + duration: number, + amountRange: [bigint, bigint], + imbalanceRatio: number = 0.8, // 80% of transfers go to heavy chain + ): TransferScenario { + const transfers: TransferEvent[] = []; + const otherChains = chains.filter((c) => c !== heavyChain); + + for (let i = 0; i < transferCount; i++) { + const timestamp = Math.floor((i / transferCount) * duration); + const goToHeavy = Math.random() < imbalanceRatio; + + let origin: string; + let destination: string; + + if (goToHeavy) { + origin = otherChains[Math.floor(Math.random() * otherChains.length)]; + destination = heavyChain; + } else { + origin = heavyChain; + destination = + otherChains[Math.floor(Math.random() * otherChains.length)]; + } + + transfers.push({ + id: generateTransferId(i, 'imb'), + timestamp, + origin, + destination, + amount: randomBigIntInRange(amountRange[0], amountRange[1]), + user: randomAddress() as Address, + }); + } + + return { + name: `imbalance-${heavyChain}-${imbalanceRatio * 100}pct`, + duration, + transfers, + chains, + }; + } + + /** + * Serializes a scenario to JSON-compatible format + */ + static serialize(scenario: TransferScenario): SerializedScenario { + return { + name: scenario.name, + duration: scenario.duration, + chains: scenario.chains, + transfers: scenario.transfers.map((t) => ({ + id: t.id, + timestamp: t.timestamp, + origin: t.origin, + destination: t.destination, + amount: t.amount.toString(), + user: t.user, + })), + }; + } + + /** + * Deserializes a scenario from JSON format + */ + static deserialize(data: SerializedScenario): TransferScenario { + return { + name: data.name, + duration: data.duration, + chains: data.chains, + transfers: data.transfers.map((t: SerializedTransferEvent) => ({ + id: t.id, + timestamp: t.timestamp, + origin: t.origin, + destination: t.destination, + amount: BigInt(t.amount), + user: t.user as Address, + })), + }; + } + + /** + * Validates a scenario for consistency + */ + static validate(scenario: TransferScenario): { + valid: boolean; + errors: string[]; + } { + const errors: string[] = []; + + // Check transfers are sorted + for (let i = 1; i < scenario.transfers.length; i++) { + if ( + scenario.transfers[i].timestamp < scenario.transfers[i - 1].timestamp + ) { + errors.push(`Transfers not sorted at index ${i}`); + } + } + + // Check all chains in transfers are in chains list + const chainSet = new Set(scenario.chains); + for (const transfer of scenario.transfers) { + if (!chainSet.has(transfer.origin)) { + errors.push(`Unknown origin chain: ${transfer.origin}`); + } + if (!chainSet.has(transfer.destination)) { + errors.push(`Unknown destination chain: ${transfer.destination}`); + } + if (transfer.origin === transfer.destination) { + errors.push(`Same origin and destination: ${transfer.origin}`); + } + } + + // Check timestamps within duration + for (const transfer of scenario.transfers) { + if (transfer.timestamp > scenario.duration) { + errors.push( + `Transfer timestamp ${transfer.timestamp} exceeds duration ${scenario.duration}`, + ); + } + } + + return { valid: errors.length === 0, errors }; + } +} diff --git a/typescript/rebalancer-sim/src/ScenarioLoader.ts b/typescript/rebalancer-sim/src/ScenarioLoader.ts new file mode 100644 index 00000000000..23f2551eb0c --- /dev/null +++ b/typescript/rebalancer-sim/src/ScenarioLoader.ts @@ -0,0 +1,73 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +import type { Address } from '@hyperlane-xyz/utils'; + +import type { ScenarioFile, TransferScenario } from './types.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SCENARIOS_DIR = path.join(__dirname, '..', 'scenarios'); + +/** + * Load a scenario file (full format with metadata and defaults) + */ +export function loadScenarioFile(name: string): ScenarioFile { + const filePath = path.join(SCENARIOS_DIR, `${name}.json`); + + if (!fs.existsSync(filePath)) { + throw new Error( + `Scenario not found: ${name}. Run 'pnpm generate-scenarios' first.`, + ); + } + + return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as ScenarioFile; +} + +/** + * Load just the transfer scenario (runtime format with bigints) + */ +export function loadScenario(name: string): TransferScenario { + const file = loadScenarioFile(name); + return deserializeTransfers(file); +} + +/** + * Convert scenario file transfers to runtime format + */ +function deserializeTransfers(file: ScenarioFile): TransferScenario { + return { + name: file.name, + duration: file.duration, + chains: file.chains, + transfers: file.transfers.map((t) => ({ + id: t.id, + timestamp: t.timestamp, + origin: t.origin, + destination: t.destination, + amount: BigInt(t.amount), + user: t.user as Address, + })), + }; +} + +/** + * List all available scenarios + */ +export function listScenarios(): string[] { + if (!fs.existsSync(SCENARIOS_DIR)) { + return []; + } + + return fs + .readdirSync(SCENARIOS_DIR) + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace('.json', '')); +} + +/** + * Get the scenarios directory path + */ +export function getScenariosDir(): string { + return SCENARIOS_DIR; +} diff --git a/typescript/rebalancer-sim/src/SimulationDeployment.ts b/typescript/rebalancer-sim/src/SimulationDeployment.ts new file mode 100644 index 00000000000..6642e423ed4 --- /dev/null +++ b/typescript/rebalancer-sim/src/SimulationDeployment.ts @@ -0,0 +1,265 @@ +import { ethers } from 'ethers'; + +import { + ERC20Test__factory, + HypERC20Collateral__factory, + MockMailbox__factory, + MockValueTransferBridge__factory, +} from '@hyperlane-xyz/core'; +import type { Address } from '@hyperlane-xyz/utils'; + +import { + ANVIL_BRIDGE_CONTROLLER_KEY, + ANVIL_MAILBOX_PROCESSOR_KEY, + ANVIL_REBALANCER_KEY, + type DeployedDomain, + type MultiDomainDeploymentOptions, + type MultiDomainDeploymentResult, + type SimulatedChainConfig, +} from './types.js'; + +// Collateral multiplication factor: 100x the initial balance +// 1x for warp liquidity, 99x for deployer to execute test transfers +const COLLATERAL_MULTIPLIER = 100; + +/** + * Deploys a multi-domain simulation environment on a single anvil instance. + * + * Creates MockMailboxes for each domain, deploys ERC20 collateral tokens, + * HypERC20Collateral warp tokens, and MockValueTransferBridge contracts. + * All domains share the same RPC endpoint but have different domain IDs. + */ +export async function deployMultiDomainSimulation( + options: MultiDomainDeploymentOptions, +): Promise { + const { + anvilRpc, + deployerKey, + rebalancerKey = ANVIL_REBALANCER_KEY, + chains, + initialCollateralBalance, + tokenDecimals = 18, + tokenSymbol = 'SIM', + tokenName = 'Simulation Token', + } = options; + + const bridgeControllerKey = + options.bridgeControllerKey || ANVIL_BRIDGE_CONTROLLER_KEY; + + const mailboxProcessorKey = + options.mailboxProcessorKey || ANVIL_MAILBOX_PROCESSOR_KEY; + + // Create fresh provider with no caching + const provider = new ethers.providers.JsonRpcProvider(anvilRpc); + // Set fast polling interval for tx.wait() - ethers defaults to 4000ms + provider.pollingInterval = 100; + // Disable automatic polling - we don't need event subscriptions during deployment + provider.polling = false; + + const deployer = new ethers.Wallet(deployerKey, provider); + const deployerAddress = await deployer.getAddress(); + const rebalancerWallet = new ethers.Wallet(rebalancerKey, provider); + const rebalancerAddress = await rebalancerWallet.getAddress(); + const bridgeControllerWallet = new ethers.Wallet( + bridgeControllerKey, + provider, + ); + const bridgeControllerAddress = await bridgeControllerWallet.getAddress(); + const mailboxProcessorWallet = new ethers.Wallet( + mailboxProcessorKey, + provider, + ); + const mailboxProcessorAddress = await mailboxProcessorWallet.getAddress(); + + // Step 1: Deploy MockMailboxes for each domain + const mailboxes: Record = {}; + for (const chain of chains) { + const mailbox = await new MockMailbox__factory(deployer).deploy( + chain.domainId, + ); + await mailbox.deployed(); + mailboxes[chain.domainId] = mailbox; + } + + // Step 2: Link mailboxes together (each knows about all others) + for (const chain of chains) { + const mailbox = mailboxes[chain.domainId]; + for (const otherChain of chains) { + if (chain.domainId !== otherChain.domainId) { + await mailbox.addRemoteMailbox( + otherChain.domainId, + mailboxes[otherChain.domainId].address, + ); + } + } + } + + // Step 3: Deploy collateral tokens for each domain + const totalMint = ethers.BigNumber.from(initialCollateralBalance).mul( + COLLATERAL_MULTIPLIER, + ); + const collateralTokens: Record = {}; + for (const chain of chains) { + const token = await new ERC20Test__factory(deployer).deploy( + tokenName, + tokenSymbol, + totalMint.toString(), + tokenDecimals, + ); + await token.deployed(); + collateralTokens[chain.domainId] = token; + } + + // Step 4: Deploy HypERC20Collateral warp tokens for each domain + const warpTokens: Record = {}; + for (const chain of chains) { + const scale = ethers.BigNumber.from(10).pow(tokenDecimals); + const warpToken = await new HypERC20Collateral__factory(deployer).deploy( + collateralTokens[chain.domainId].address, + scale, + mailboxes[chain.domainId].address, + ); + await warpToken.deployed(); + + // Initialize the warp token + await warpToken.initialize( + ethers.constants.AddressZero, // hook + ethers.constants.AddressZero, // ISM + deployerAddress, // owner + ); + + warpTokens[chain.domainId] = warpToken; + } + + // Step 5: Enroll remote routers (link warp tokens together) - batch enrollment + for (const chain of chains) { + const warpToken = warpTokens[chain.domainId]; + const remoteDomains: number[] = []; + const remoteRouters: string[] = []; + + for (const otherChain of chains) { + if (chain.domainId !== otherChain.domainId) { + remoteDomains.push(otherChain.domainId); + remoteRouters.push( + ethers.utils.hexZeroPad(warpTokens[otherChain.domainId].address, 32), + ); + } + } + + // Use batch enrollment for efficiency + await warpToken.enrollRemoteRouters(remoteDomains, remoteRouters); + } + + // Step 6: Deploy MockValueTransferBridge for each domain and add to allowed bridges + const bridges: Record = {}; + for (const chain of chains) { + const bridge = await new MockValueTransferBridge__factory(deployer).deploy( + collateralTokens[chain.domainId].address, + ); + await bridge.deployed(); + bridges[chain.domainId] = bridge; + } + + // Step 7: Add bridges to warp tokens for all destination domains + for (const chain of chains) { + const warpToken = warpTokens[chain.domainId]; + for (const otherChain of chains) { + if (chain.domainId !== otherChain.domainId) { + await warpToken.addBridge( + otherChain.domainId, + bridges[chain.domainId].address, + ); + } + } + } + + // Step 8: Add rebalancer (separate account) as allowed rebalancer on all warp tokens + for (const chain of chains) { + const warpToken = warpTokens[chain.domainId]; + await warpToken.addRebalancer(rebalancerAddress); + } + + // Step 9: Transfer collateral tokens to warp contracts + for (const chain of chains) { + const token = collateralTokens[chain.domainId]; + const warpToken = warpTokens[chain.domainId]; + const tx = await token.transfer( + warpToken.address, + initialCollateralBalance, + ); + await tx.wait(); + } + + // CRITICAL: Clean up the deployment provider to prevent accumulation + // Each deployment creates a provider with 100ms polling that was never cleaned up + // After multiple test runs, these accumulate and overwhelm anvil + provider.removeAllListeners(); + provider.polling = false; + + // Build result + const domains: Record = {}; + for (const chain of chains) { + domains[chain.chainName] = { + chainName: chain.chainName, + domainId: chain.domainId, + mailbox: mailboxes[chain.domainId].address as Address, + warpToken: warpTokens[chain.domainId].address as Address, + collateralToken: collateralTokens[chain.domainId].address as Address, + bridge: bridges[chain.domainId].address as Address, + }; + } + + return { + anvilRpc, + deployer: deployerAddress as Address, + deployerKey, + rebalancer: rebalancerAddress as Address, + rebalancerKey, + bridgeController: bridgeControllerAddress as Address, + bridgeControllerKey, + mailboxProcessor: mailboxProcessorAddress as Address, + mailboxProcessorKey, + domains, + }; +} + +/** + * Creates a MultiProvider-compatible chain metadata config for simulation + */ +export function createSimulationChainMetadata( + anvilRpc: string, + chains: SimulatedChainConfig[], +): Record { + const metadata: Record = {}; + + for (const chain of chains) { + metadata[chain.chainName] = { + name: chain.chainName, + chainId: 31337, // Anvil default + domainId: chain.domainId, + protocol: 'ethereum', + rpcUrls: [{ http: anvilRpc }], + nativeToken: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + isTestnet: true, + }; + } + + return metadata; +} + +/** + * Gets the current collateral balance for a warp token + */ +export async function getWarpTokenBalance( + provider: ethers.providers.JsonRpcProvider, + warpTokenAddress: Address, + collateralTokenAddress: Address, +): Promise { + const token = ERC20Test__factory.connect(collateralTokenAddress, provider); + const balance = await token.balanceOf(warpTokenAddress); + return balance.toBigInt(); +} diff --git a/typescript/rebalancer-sim/src/SimulationEngine.ts b/typescript/rebalancer-sim/src/SimulationEngine.ts new file mode 100644 index 00000000000..78194b8e775 --- /dev/null +++ b/typescript/rebalancer-sim/src/SimulationEngine.ts @@ -0,0 +1,432 @@ +import { ethers } from 'ethers'; + +import { + ERC20__factory, + HypERC20Collateral__factory, +} from '@hyperlane-xyz/core'; +import { TokenStandard, type WarpCoreConfig } from '@hyperlane-xyz/sdk'; +import { rootLogger } from '@hyperlane-xyz/utils'; + +import { BridgeMockController } from './BridgeMockController.js'; +import { KPICollector } from './KPICollector.js'; +import { MessageTracker } from './MessageTracker.js'; +import type { + BridgeMockConfig, + IRebalancerRunner, + MultiDomainDeploymentResult, + RebalancerSimConfig, + SimulationResult, + SimulationTiming, + TransferScenario, +} from './types.js'; + +const logger = rootLogger.child({ module: 'SimulationEngine' }); + +/** + * Default timing for fast simulations + */ +export const DEFAULT_TIMING: SimulationTiming = { + userTransferDeliveryDelay: 0, // Instant for fast tests + rebalancerPollingFrequency: 1000, + userTransferInterval: 100, +}; + +/** + * SimulationEngine orchestrates the execution of transfer scenarios + * with rebalancer monitoring and KPI collection. + */ +export class SimulationEngine { + private provider: ethers.providers.JsonRpcProvider; + private bridgeController?: BridgeMockController; + private kpiCollector?: KPICollector; + private messageTracker?: MessageTracker; + private isRunning = false; + private mailboxProcessingInterval?: NodeJS.Timeout; + private mailboxProcessingInFlight = false; + + constructor(private readonly deployment: MultiDomainDeploymentResult) { + this.provider = new ethers.providers.JsonRpcProvider(deployment.anvilRpc); + // Set fast polling interval for tx.wait() - ethers defaults to 4000ms + this.provider.pollingInterval = 100; + // Disable automatic polling (event subscriptions) but keep pollingInterval for tx.wait() + this.provider.polling = false; + } + + /** + * Run a simulation with the given scenario and rebalancer + */ + async runSimulation( + scenario: TransferScenario, + rebalancer: IRebalancerRunner, + bridgeConfig: BridgeMockConfig, + timing: SimulationTiming = DEFAULT_TIMING, + rebalancerStrategyConfig: RebalancerSimConfig['strategyConfig'], + ): Promise { + const startTime = Date.now(); + this.isRunning = true; + + try { + // Initialize components + // Use bridgeControllerKey for bridge operations to avoid nonce conflicts + this.bridgeController = new BridgeMockController( + this.provider, + this.deployment.domains, + this.deployment.bridgeControllerKey, + bridgeConfig, + ); + + this.kpiCollector = new KPICollector( + this.provider, + this.deployment.domains, + ); + + // Initialize MessageTracker for off-chain message tracking + this.messageTracker = new MessageTracker( + this.provider, + this.deployment.domains, + this.deployment.mailboxProcessorKey, + ); + + await this.kpiCollector.initialize(); + await this.messageTracker.initialize(); + await this.bridgeController.start(); + + // Wire up MessageTracker events for KPI tracking + this.messageTracker.on('message_delivered', (message) => { + this.kpiCollector!.recordTransferComplete(message.transferId); + }); + + this.messageTracker.on('message_failed', ({ message }) => { + // Don't record as failed yet - it will retry + logger.debug( + { + messageId: message.id, + attempts: message.attempts, + error: message.lastError, + }, + 'Message failed, will retry', + ); + }); + + // Set up bridge event handlers for rebalance KPI tracking + // Bridge transfers are rebalancer operations (user transfers go through warp token) + this.bridgeController.on('transfer_initiated', (event) => { + const rebalanceId = this.kpiCollector!.recordRebalanceStart( + event.transfer.origin, + event.transfer.destination, + event.transfer.amount, + BigInt(0), // Gas cost not tracked yet + ); + // Link bridge transfer ID to rebalance ID for completion tracking + this.kpiCollector!.linkBridgeTransfer(event.transfer.id, rebalanceId); + }); + + this.bridgeController.on('transfer_delivered', (event) => { + this.kpiCollector!.recordRebalanceComplete(event.transfer.id); + }); + + this.bridgeController.on('transfer_failed', (event) => { + this.kpiCollector!.recordRebalanceFailed(event.transfer.id); + }); + + // Build warp config for rebalancer + const warpConfig = this.buildWarpConfig(); + + // Initialize rebalancer + const rebalancerConfig: RebalancerSimConfig = { + pollingFrequency: timing.rebalancerPollingFrequency, + warpConfig, + strategyConfig: rebalancerStrategyConfig, + deployment: this.deployment, + }; + + await rebalancer.initialize(rebalancerConfig); + + // Start rebalancer daemon + await rebalancer.start(); + + // Start periodic mailbox processing for delayed user transfer delivery + this.startMailboxProcessing(); + + // Execute transfers according to scenario + await this.executeTransfers(scenario, timing); + + // Wait for all user transfer deliveries (respecting delay) + // Use a timeout to prevent indefinite hanging + await Promise.race([ + this.waitForUserTransferDeliveries(), + new Promise((resolve) => setTimeout(resolve, 60000)), // 60s max + ]); + + // Wait for bridge deliveries to complete (rebalancer transfers) + await this.bridgeController.waitForAllDeliveries(30000); + + // Wait for rebalancer to become idle + await rebalancer.waitForIdle(5000); + + // Generate final KPIs + const kpis = await this.kpiCollector.generateKPIs(); + const endTime = Date.now(); + + return { + scenarioName: scenario.name, + rebalancerName: rebalancer.name, + startTime, + endTime, + duration: endTime - startTime, + kpis, + transferRecords: this.kpiCollector.getTransferRecords(), + rebalanceRecords: this.kpiCollector.getRebalanceRecords(), + }; + } finally { + // Always cleanup, even if we timeout or error + this.isRunning = false; + this.stopMailboxProcessing(); + + try { + await rebalancer.stop(); + } catch { + // Ignore stop errors + } + + if (this.bridgeController) { + try { + await this.bridgeController.stop(); + } catch { + // Ignore stop errors + } + } + + if (this.messageTracker) { + this.messageTracker.removeAllListeners(); + } + + // Clean up provider to release connections + this.provider.removeAllListeners(); + // Force polling to stop + this.provider.polling = false; + } + } + + /** + * Execute transfers according to the scenario + */ + private async executeTransfers( + scenario: TransferScenario, + timing: SimulationTiming, + ): Promise { + const deployer = new ethers.Wallet( + this.deployment.deployerKey, + this.provider, + ); + const startTime = Date.now(); + + for (let i = 0; i < scenario.transfers.length; i++) { + const transfer = scenario.transfers[i]; + + // Wait until it's time for this transfer + const targetTime = startTime + transfer.timestamp; + const waitTime = targetTime - Date.now(); + if (waitTime > 0) { + await new Promise((resolve) => setTimeout(resolve, waitTime)); + } + + // Record transfer start + this.kpiCollector!.recordTransferStart( + transfer.id, + transfer.origin, + transfer.destination, + transfer.amount, + ); + + // Execute the transfer via warp token + const originDomain = this.deployment.domains[transfer.origin]; + const destDomain = this.deployment.domains[transfer.destination]; + + const warpToken = HypERC20Collateral__factory.connect( + originDomain.warpToken, + deployer, + ); + + try { + const txStartTime = Date.now(); + + // Approve collateral token for warp transfer + const collateralToken = ERC20__factory.connect( + originDomain.collateralToken, + deployer, + ); + const approveTx = await collateralToken.approve( + originDomain.warpToken, + transfer.amount, + ); + await approveTx.wait(); + + const approveTime = Date.now() - txStartTime; + + // Quote gas payment (mock mailbox should return 0) + const gasPayment = await warpToken.quoteGasPayment(destDomain.domainId); + + // Transfer remote + const recipientBytes32 = ethers.utils.hexZeroPad(transfer.user, 32); + const transferTx = await warpToken.transferRemote( + destDomain.domainId, + recipientBytes32, + transfer.amount, + { value: gasPayment }, + ); + await transferTx.wait(); + + const totalTxTime = Date.now() - txStartTime; + + // Log slow transfers (>1000ms suggests significant RPC contention) + if (totalTxTime > 1000) { + logger.warn( + { transferId: transfer.id, totalTxTime, approveTime }, + 'Slow transfer detected', + ); + } + + // Track message for delayed delivery via MessageTracker + await this.messageTracker!.trackMessage( + transfer.id, + transfer.origin, + transfer.destination, + timing.userTransferDeliveryDelay, + ); + } catch (error) { + logger.error( + { + transferId: transfer.id, + error: error instanceof Error ? error.message : String(error), + }, + 'Transfer failed', + ); + this.kpiCollector!.recordTransferFailed(transfer.id); + } + } + logger.info('All transfers executed'); + } + + /** + * Start periodic processing of mailbox messages (simulates relayer with delay) + */ + private startMailboxProcessing(): void { + // Process mailbox every 100ms to check for deliveries due + const PROCESS_INTERVAL = 100; + + this.mailboxProcessingInterval = setInterval(async () => { + // Guard against overlapping ticks to prevent nonce collisions + if (this.mailboxProcessingInFlight) return; + this.mailboxProcessingInFlight = true; + try { + await this.processReadyMailboxDeliveries(); + } finally { + this.mailboxProcessingInFlight = false; + } + }, PROCESS_INTERVAL); + } + + /** + * Stop mailbox processing + */ + private stopMailboxProcessing(): void { + if (this.mailboxProcessingInterval) { + clearInterval(this.mailboxProcessingInterval); + this.mailboxProcessingInterval = undefined; + } + } + + /** + * Process mailbox deliveries that are ready (past their delivery time) + * Uses MessageTracker for off-chain tracking with per-message control + */ + private async processReadyMailboxDeliveries(): Promise { + if (!this.messageTracker) return; + await this.messageTracker.processReadyMessages(); + } + + /** + * Wait for all pending user transfer deliveries to complete + */ + private async waitForUserTransferDeliveries( + timeout: number = 30000, + ): Promise { + if (!this.messageTracker) return; + + const startTime = Date.now(); + + while (this.messageTracker.hasPendingMessages()) { + if (Date.now() - startTime > timeout) { + const pending = this.messageTracker.getPendingMessages(); + logger.warn( + { pendingCount: pending.length }, + 'Timeout waiting for user transfer deliveries - marking as failed', + ); + // Mark pending messages as failed so KPIs reflect reality + for (const msg of pending) { + logger.warn( + { + messageId: msg.id, + origin: msg.origin, + destination: msg.destination, + status: msg.status, + attempts: msg.attempts, + error: msg.lastError || 'timeout', + }, + 'Marking pending message as failed', + ); + // Record as failed in KPI collector + this.kpiCollector?.recordTransferFailed(msg.transferId); + } + // Clear pending messages so they don't block + this.messageTracker.clear(); + break; + } + + // Interval handles processing; just wait + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + /** + * Build WarpCoreConfig from deployment + */ + private buildWarpConfig(): WarpCoreConfig { + const tokens = Object.entries(this.deployment.domains).map( + ([chainName, domain]) => ({ + chainName, + standard: TokenStandard.EvmHypCollateral, + decimals: 18, + symbol: 'SIM', + name: 'Simulation Token', + addressOrDenom: domain.warpToken, + collateralAddressOrDenom: domain.collateralToken, + connections: Object.entries(this.deployment.domains) + .filter(([name]) => name !== chainName) + .map(([name, d]) => ({ + token: `ethereum|${name}|${d.warpToken}`, + })), + }), + ); + + return { tokens }; + } + + /** + * Reset internal tracking state (does not reset blockchain state) + */ + reset(): void { + // Clear message tracker state + if (this.messageTracker) { + this.messageTracker.clear(); + } + } + + /** + * Check if simulation is currently running + */ + isSimulationRunning(): boolean { + return this.isRunning; + } +} diff --git a/typescript/rebalancer-sim/src/index.ts b/typescript/rebalancer-sim/src/index.ts new file mode 100644 index 00000000000..9daa8d598e3 --- /dev/null +++ b/typescript/rebalancer-sim/src/index.ts @@ -0,0 +1,101 @@ +/** + * Rebalancer Simulation Framework + * + * A fast, real-time simulation framework for testing Hyperlane warp route + * rebalancers against synthetic transfer scenarios. + */ + +// Core simulation classes +export { BridgeMockController } from './BridgeMockController.js'; +export { KPICollector } from './KPICollector.js'; +export { MessageTracker } from './MessageTracker.js'; +export { RebalancerSimulationHarness } from './RebalancerSimulationHarness.js'; +export { + deployMultiDomainSimulation, + getWarpTokenBalance, +} from './SimulationDeployment.js'; +export { DEFAULT_TIMING, SimulationEngine } from './SimulationEngine.js'; + +// Scenario generation and loading +export { ScenarioGenerator } from './ScenarioGenerator.js'; +export { + getScenariosDir, + listScenarios, + loadScenario, + loadScenarioFile, +} from './ScenarioLoader.js'; + +// Rebalancer runners +export { NoOpRebalancer } from './runners/NoOpRebalancer.js'; +export { + cleanupProductionRebalancer, + ProductionRebalancerRunner, +} from './runners/ProductionRebalancerRunner.js'; +export { cleanupSimpleRunner, SimpleRunner } from './runners/SimpleRunner.js'; +export { SimulationRegistry } from './runners/SimulationRegistry.js'; + +// Visualization +export { generateTimelineHtml } from './visualizer/HtmlTimelineGenerator.js'; + +// Types - explicit exports for tree-shaking +export type { + // Bridge types + BridgeEvent, + BridgeEventType, + BridgeMockConfig, + BridgeRouteConfig, + PendingTransfer, + // Deployment types + DeployedDomain, + MultiDomainDeploymentOptions, + MultiDomainDeploymentResult, + SimulatedChainConfig, + // KPI types + ChainMetrics, + ComparisonReport, + RebalanceRecord, + SimulationKPIs, + SimulationResult, + StateSnapshot, + TransferRecord, + // Rebalancer types + ChainStrategyConfig, + IRebalancerRunner, + RebalancerEvent, + RebalancerSimConfig, + RebalancerStrategyConfig, + // Scenario types + RandomTrafficOptions, + ScenarioExpectations, + ScenarioFile, + SerializedBridgeConfig, + SerializedScenario, + SerializedStrategyConfig, + SerializedTransferEvent, + SimulationTiming, + SurgeScenarioOptions, + TransferEvent, + TransferScenario, + UnidirectionalFlowOptions, + // Visualizer types + HtmlGeneratorOptions, + SimulationConfig, + TimelineEvent, + VisualizationData, +} from './types.js'; + +// Constants and utility functions +export { + ANVIL_BRIDGE_CONTROLLER_ADDRESS, + ANVIL_BRIDGE_CONTROLLER_KEY, + ANVIL_DEPLOYER_ADDRESS, + ANVIL_DEPLOYER_KEY, + ANVIL_MAILBOX_PROCESSOR_ADDRESS, + ANVIL_MAILBOX_PROCESSOR_KEY, + ANVIL_REBALANCER_ADDRESS, + ANVIL_REBALANCER_KEY, + createSymmetricBridgeConfig, + DEFAULT_BRIDGE_ROUTE_CONFIG, + DEFAULT_SIMULATED_CHAINS, + toVisualizationData, +} from './types.js'; diff --git a/typescript/rebalancer-sim/src/runners/NoOpRebalancer.ts b/typescript/rebalancer-sim/src/runners/NoOpRebalancer.ts new file mode 100644 index 00000000000..39de8fdd76d --- /dev/null +++ b/typescript/rebalancer-sim/src/runners/NoOpRebalancer.ts @@ -0,0 +1,40 @@ +import { EventEmitter } from 'events'; + +import type { + IRebalancerRunner, + RebalancerEvent, + RebalancerSimConfig, +} from '../types.js'; + +/** + * NoOpRebalancer does nothing - used as a baseline to show what happens + * when no rebalancer is running. Useful for demonstrating that transfers + * fail without active liquidity management. + */ +export class NoOpRebalancer extends EventEmitter implements IRebalancerRunner { + readonly name = 'NoOpRebalancer'; + + async initialize(_config: RebalancerSimConfig): Promise { + // No-op + } + + async start(): Promise { + // No-op + } + + async stop(): Promise { + // No-op + } + + isActive(): boolean { + return false; + } + + async waitForIdle(_timeoutMs?: number): Promise { + // Always idle + } + + on(_event: 'rebalance', _listener: (e: RebalancerEvent) => void): this { + return this; + } +} diff --git a/typescript/rebalancer-sim/src/runners/ProductionRebalancerRunner.ts b/typescript/rebalancer-sim/src/runners/ProductionRebalancerRunner.ts new file mode 100644 index 00000000000..c2d0459976e --- /dev/null +++ b/typescript/rebalancer-sim/src/runners/ProductionRebalancerRunner.ts @@ -0,0 +1,289 @@ +import { ethers } from 'ethers'; +import { EventEmitter } from 'events'; +import { pino } from 'pino'; + +import { + RebalancerConfig, + RebalancerService, + RebalancerStrategyOptions, +} from '@hyperlane-xyz/rebalancer'; +import type { StrategyConfig } from '@hyperlane-xyz/rebalancer'; +import { MultiProtocolProvider, MultiProvider } from '@hyperlane-xyz/sdk'; +import { ProtocolType, rootLogger } from '@hyperlane-xyz/utils'; + +import type { IRebalancerRunner, RebalancerSimConfig } from '../types.js'; + +import { SimulationRegistry } from './SimulationRegistry.js'; + +// Silent logger for the rebalancer service (internal) +const silentLogger = pino({ level: 'silent' }); + +// Logger for simulation harness output +const logger = rootLogger.child({ module: 'ProductionRebalancerRunner' }); + +// Track the current instance for cleanup +let currentInstance: ProductionRebalancerRunner | null = null; + +function setCurrentInstance(instance: ProductionRebalancerRunner | null): void { + currentInstance = instance; +} + +/** + * Global cleanup function - call between test runs to ensure clean state + */ +export async function cleanupProductionRebalancer(): Promise { + if (currentInstance) { + const instance = currentInstance; + currentInstance = null; + try { + await instance.stop(); + } catch (error) { + logger.debug({ error }, 'cleanupProductionRebalancer: stop failed'); + } + } + // Small delay to allow any async cleanup to complete + await new Promise((resolve) => setTimeout(resolve, 100)); +} + +function buildStrategyConfig(config: RebalancerSimConfig): StrategyConfig { + const { strategyConfig } = config; + + if (strategyConfig.type === 'weighted') { + const chains: Record = {}; + + for (const [chainName, chainConfig] of Object.entries( + strategyConfig.chains, + )) { + const weight = chainConfig.weighted?.weight + ? Math.round(parseFloat(chainConfig.weighted.weight) * 100) + : 33; + const tolerance = chainConfig.weighted?.tolerance + ? Math.round(parseFloat(chainConfig.weighted.tolerance) * 100) + : 10; + + chains[chainName] = { + bridge: chainConfig.bridge, + bridgeLockTime: Math.ceil(chainConfig.bridgeLockTime / 1000), + weighted: { + weight: BigInt(weight), + tolerance: BigInt(tolerance), + }, + }; + } + + return { + rebalanceStrategy: RebalancerStrategyOptions.Weighted, + chains, + } as StrategyConfig; + } else { + const chains: Record = {}; + + for (const [chainName, chainConfig] of Object.entries( + strategyConfig.chains, + )) { + chains[chainName] = { + bridge: chainConfig.bridge, + bridgeLockTime: Math.ceil(chainConfig.bridgeLockTime / 1000), + minAmount: { + min: chainConfig.minAmount?.min ?? '0', + target: chainConfig.minAmount?.target ?? '0', + type: chainConfig.minAmount?.type ?? 'absolute', + }, + }; + } + + return { + rebalanceStrategy: RebalancerStrategyOptions.MinAmount, + chains, + } as StrategyConfig; + } +} + +/** + * ProductionRebalancerRunner runs the actual RebalancerService in-process. + * This wraps the real CLI rebalancer for simulation testing. + */ +export class ProductionRebalancerRunner + extends EventEmitter + implements IRebalancerRunner +{ + readonly name = 'ProductionRebalancerService'; + + private config?: RebalancerSimConfig; + private service?: RebalancerService; + private running = false; + + async initialize(config: RebalancerSimConfig): Promise { + // Cleanup any previously running instance + await cleanupProductionRebalancer(); + + this.config = config; + } + + async start(): Promise { + if (!this.config) { + throw new Error('ProductionRebalancerRunner not initialized'); + } + + if (this.running) { + return; + } + + // Cleanup any previously running instance + await cleanupProductionRebalancer(); + + // Create registry + const registry = new SimulationRegistry(this.config.deployment); + + // Build chain metadata + const chainMetadata: Record = {}; + for (const [chainName, domain] of Object.entries( + this.config.deployment.domains, + )) { + chainMetadata[chainName] = { + name: chainName, + chainId: 31337, + domainId: domain.domainId, + protocol: ProtocolType.Ethereum, + rpcUrls: [{ http: this.config.deployment.anvilRpc }], + nativeToken: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + blocks: { + confirmations: 0, + estimateBlockTime: 1, + reorgPeriod: 0, // Disable historical block queries in simulation + }, + }; + } + + // Create MultiProvider (with silent logger to suppress internal logs) + const multiProvider = new MultiProvider(chainMetadata, { + logger: silentLogger, + }); + + // Create provider and wallet + const provider = new ethers.providers.JsonRpcProvider( + this.config.deployment.anvilRpc, + ); + // Set fast polling interval for tx.wait() - ethers defaults to 4000ms + provider.pollingInterval = 100; + provider.polling = false; + + const wallet = new ethers.Wallet( + this.config.deployment.rebalancerKey, + provider, + ); + multiProvider.setSharedSigner(wallet); + + // Set fast polling interval and disable automatic polling on all internal providers + for (const chainName of multiProvider.getKnownChainNames()) { + const chainProvider = multiProvider.tryGetProvider(chainName); + if (chainProvider && 'polling' in chainProvider) { + const jsonRpcProvider = + chainProvider as ethers.providers.JsonRpcProvider; + jsonRpcProvider.pollingInterval = 100; + jsonRpcProvider.polling = false; + } + } + + // Create MultiProtocolProvider + const multiProtocolProvider = + MultiProtocolProvider.fromMultiProvider(multiProvider); + + for (const chainName of multiProtocolProvider.getKnownChainNames()) { + try { + const mppProvider = multiProtocolProvider.getProvider(chainName); + if (mppProvider && 'polling' in mppProvider) { + (mppProvider as unknown as ethers.providers.JsonRpcProvider).polling = + false; + } + } catch (error) { + logger.debug( + { chainName, error }, + 'Failed to disable polling for chain', + ); + } + } + + // Build strategy config + const strategyConfig = buildStrategyConfig(this.config); + + // Create RebalancerConfig + // Need explicit cast due to discriminated union type narrowing + const rebalancerConfig = new RebalancerConfig(registry.getWarpRouteId(), [ + strategyConfig, + ] as StrategyConfig[]); + + // Create service + this.service = new RebalancerService( + multiProvider, + multiProtocolProvider, + registry, + rebalancerConfig, + { + mode: 'daemon', + checkFrequency: this.config.pollingFrequency, + monitorOnly: false, + withMetrics: false, + logger: silentLogger, + }, + ); + + // Mark as running after service creation to avoid inconsistent state + this.running = true; + setCurrentInstance(this); + + // Start service in the background (don't await - it runs forever in daemon mode) + let startupError: Error | undefined; + this.service.start().catch((error) => { + startupError = error; + logger.error({ error }, 'RebalancerService error'); + }); + + // Wait a bit for the service to initialize + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Surface any startup errors + if (startupError) { + throw startupError; + } + } + + async stop(): Promise { + if (!this.running) { + return; + } + + this.running = false; + + // Clear global reference + if (currentInstance === this) { + currentInstance = null; + } + + if (this.service) { + try { + await this.service.stop(); + } catch (error) { + logger.debug({ error }, 'service.stop() failed'); + } + this.service = undefined; + } + + this.config = undefined; + this.removeAllListeners(); + } + + isActive(): boolean { + return this.running; + } + + async waitForIdle(timeoutMs: number = 10000): Promise { + // Wait for a reasonable settle time + const settleTime = Math.min(timeoutMs, 2000); + await new Promise((resolve) => setTimeout(resolve, settleTime)); + } +} diff --git a/typescript/rebalancer-sim/src/runners/SimpleRunner.ts b/typescript/rebalancer-sim/src/runners/SimpleRunner.ts new file mode 100644 index 00000000000..c398f8fcb6f --- /dev/null +++ b/typescript/rebalancer-sim/src/runners/SimpleRunner.ts @@ -0,0 +1,382 @@ +import { ethers } from 'ethers'; +import { EventEmitter } from 'events'; +import { pino } from 'pino'; + +import { + ERC20Test__factory, + HypERC20Collateral__factory, +} from '@hyperlane-xyz/core'; + +import type { + DeployedDomain, + IRebalancerRunner, + RebalancerSimConfig, +} from '../types.js'; + +// Track the current SimpleRunner instance for cleanup +let currentSimpleRunner: SimpleRunner | null = null; +let currentSimpleProvider: ethers.providers.JsonRpcProvider | null = null; + +/** + * Global cleanup function - call between test runs to ensure clean state + */ +export async function cleanupSimpleRunner(): Promise { + if (currentSimpleRunner) { + const runner = currentSimpleRunner; + currentSimpleRunner = null; + try { + await runner.stop(); + } catch { + // Ignore errors + } + } + + if (currentSimpleProvider) { + currentSimpleProvider.removeAllListeners(); + currentSimpleProvider = null; + } + + // Small delay to allow any async cleanup to complete + await new Promise((resolve) => setTimeout(resolve, 50)); +} + +/** + * SimpleRunner is a simplified rebalancer implementation for simulation testing. + * It monitors balances and triggers rebalances when imbalances exceed thresholds. + */ +export class SimpleRunner extends EventEmitter implements IRebalancerRunner { + readonly name = 'SimpleRebalancer'; + + private config?: RebalancerSimConfig; + private logger = pino({ level: 'warn' }); + private running = false; + private activeOperations = 0; + private pollingTimer?: NodeJS.Timeout; + private provider?: ethers.providers.JsonRpcProvider; + private deployer?: ethers.Wallet; + + async initialize(config: RebalancerSimConfig): Promise { + // Cleanup any previously running instance + await cleanupSimpleRunner(); + + this.config = config; + this.provider = new ethers.providers.JsonRpcProvider( + config.deployment.anvilRpc, + ); + // Set fast polling interval for tx.wait() - ethers defaults to 4000ms + this.provider.pollingInterval = 100; + // Disable automatic polling to reduce RPC contention in simulation + this.provider.polling = false; + // Track for cleanup + currentSimpleProvider = this.provider; + + // Use separate rebalancer key to avoid nonce conflicts with transfer execution + this.deployer = new ethers.Wallet( + config.deployment.rebalancerKey, + this.provider, + ); + } + + async start(): Promise { + if (!this.config) { + throw new Error('Rebalancer not initialized'); + } + + if (this.running) { + return; + } + + this.running = true; + // eslint-disable-next-line @typescript-eslint/no-this-alias + currentSimpleRunner = this; + this.logger.info('Starting rebalancer daemon'); + + // Start polling loop + this.scheduleNextPoll(); + } + + private scheduleNextPoll(): void { + if (!this.running || !this.config) return; + + this.pollingTimer = setTimeout(async () => { + await this.pollAndRebalance(); + this.scheduleNextPoll(); + }, this.config.pollingFrequency); + } + + private async pollAndRebalance(): Promise { + if (!this.config || !this.provider || !this.deployer) return; + + try { + this.activeOperations++; + + // Get current balances + const balances: Record = {}; + const domains = this.config.deployment.domains; + + for (const [chainName, domain] of Object.entries(domains)) { + const token = ERC20Test__factory.connect( + domain.collateralToken, + this.provider, + ); + const balance = await token.balanceOf(domain.warpToken); + balances[chainName] = balance.toBigInt(); + } + + // Calculate total and target balances per strategy + const { strategyConfig } = this.config; + if (strategyConfig.type === 'weighted') { + await this.executeWeightedRebalance(balances, domains); + } else if (strategyConfig.type === 'minAmount') { + await this.executeMinAmountRebalance(balances, domains); + } + } catch (error) { + this.logger.error({ error }, 'Error during rebalance poll'); + } finally { + this.activeOperations--; + } + } + + private async executeWeightedRebalance( + balances: Record, + domains: Record, + ): Promise { + if (!this.config || !this.deployer) return; + + const { strategyConfig } = this.config; + const chainNames = Object.keys(balances); + + // Calculate total balance + let totalBalance = BigInt(0); + for (const balance of Object.values(balances)) { + totalBalance += balance; + } + + if (totalBalance === BigInt(0)) return; + + // Calculate weight sum + let totalWeight = 0; + for (const chainName of chainNames) { + const chainConfig = strategyConfig.chains[chainName]; + const weight = chainConfig?.weighted?.weight + ? parseFloat(chainConfig.weighted.weight) + : 1 / chainNames.length; + totalWeight += weight; + } + + // Find chains with excess and deficit + const excess: { chain: string; amount: bigint }[] = []; + const deficit: { chain: string; amount: bigint }[] = []; + + for (const chainName of chainNames) { + const chainConfig = strategyConfig.chains[chainName]; + const weight = chainConfig?.weighted?.weight + ? parseFloat(chainConfig.weighted.weight) + : 1 / chainNames.length; + const tolerance = chainConfig?.weighted?.tolerance + ? parseFloat(chainConfig.weighted.tolerance) + : 0.1; + + const targetBalance = + (totalBalance * BigInt(Math.floor(weight * 10000))) / + BigInt(Math.floor(totalWeight * 10000)); + const currentBalance = balances[chainName]; + + const minBalance = + (targetBalance * BigInt(Math.floor((1 - tolerance) * 10000))) / + BigInt(10000); + const maxBalance = + (targetBalance * BigInt(Math.floor((1 + tolerance) * 10000))) / + BigInt(10000); + + if (currentBalance > maxBalance) { + excess.push({ + chain: chainName, + amount: currentBalance - targetBalance, + }); + } else if (currentBalance < minBalance) { + deficit.push({ + chain: chainName, + amount: targetBalance - currentBalance, + }); + } + } + + // Execute rebalances - track remaining amounts to avoid over-rebalancing + const remainingExcess = new Map(excess.map((e) => [e.chain, e.amount])); + const remainingDeficit = new Map(deficit.map((d) => [d.chain, d.amount])); + + for (const { chain: fromChain } of excess) { + for (const { chain: toChain } of deficit) { + const currentExcess = remainingExcess.get(fromChain) ?? BigInt(0); + const currentDeficit = remainingDeficit.get(toChain) ?? BigInt(0); + if (currentExcess <= BigInt(0) || currentDeficit <= BigInt(0)) continue; + + const rebalanceAmount = + currentExcess < currentDeficit ? currentExcess : currentDeficit; + if (rebalanceAmount > BigInt(0)) { + await this.executeRebalance( + fromChain, + toChain, + rebalanceAmount, + domains, + ); + remainingExcess.set(fromChain, currentExcess - rebalanceAmount); + remainingDeficit.set(toChain, currentDeficit - rebalanceAmount); + } + } + } + } + + private async executeMinAmountRebalance( + balances: Record, + domains: Record, + ): Promise { + if (!this.config) return; + + const { strategyConfig } = this.config; + + // Find chains below minimum + const belowMin: { chain: string; deficit: bigint; target: bigint }[] = []; + const aboveTarget: { chain: string; excess: bigint }[] = []; + + for (const [chainName, balance] of Object.entries(balances)) { + const chainConfig = strategyConfig.chains[chainName]; + if (!chainConfig?.minAmount) continue; + + const min = BigInt(chainConfig.minAmount.min); + const target = BigInt(chainConfig.minAmount.target); + + if (balance < min) { + belowMin.push({ chain: chainName, deficit: target - balance, target }); + } else if (balance > target * BigInt(2)) { + aboveTarget.push({ chain: chainName, excess: balance - target }); + } + } + + // Rebalance from excess to deficit - track remaining amounts to avoid over-rebalancing + const remainingDeficit = new Map(belowMin.map((d) => [d.chain, d.deficit])); + const remainingExcess = new Map( + aboveTarget.map((e) => [e.chain, e.excess]), + ); + + for (const { chain: toChain } of belowMin) { + for (const { chain: fromChain } of aboveTarget) { + const currentDeficit = remainingDeficit.get(toChain) ?? BigInt(0); + const currentExcess = remainingExcess.get(fromChain) ?? BigInt(0); + if (currentDeficit <= BigInt(0) || currentExcess <= BigInt(0)) continue; + + const amount = + currentDeficit < currentExcess ? currentDeficit : currentExcess; + if (amount > BigInt(0)) { + await this.executeRebalance(fromChain, toChain, amount, domains); + remainingDeficit.set(toChain, currentDeficit - amount); + remainingExcess.set(fromChain, currentExcess - amount); + } + } + } + } + + private async executeRebalance( + fromChain: string, + toChain: string, + amount: bigint, + domains: Record, + ): Promise { + if (!this.deployer) return; + + try { + const fromDomain = domains[fromChain]; + const toDomain = domains[toChain]; + + this.logger.info( + { fromChain, toChain, amount: amount.toString() }, + 'Executing rebalance', + ); + + const warpToken = HypERC20Collateral__factory.connect( + fromDomain.warpToken, + this.deployer, + ); + + // Use the bridge to rebalance + // Call rebalance through the warp token + const tx = await warpToken.rebalance( + toDomain.domainId, + amount, + fromDomain.bridge, + ); + await tx.wait(); + + this.emit('rebalance', { + type: 'rebalance_completed', + timestamp: Date.now(), + origin: fromChain, + destination: toChain, + amount, + }); + + this.logger.info( + { fromChain, toChain, amount: amount.toString(), txHash: tx.hash }, + 'Rebalance completed', + ); + } catch (error) { + this.logger.error({ error, fromChain, toChain }, 'Rebalance failed'); + this.emit('rebalance', { + type: 'rebalance_failed', + timestamp: Date.now(), + origin: fromChain, + destination: toChain, + error: String(error), + }); + } + } + + async stop(): Promise { + if (!this.running) { + return; + } + + this.running = false; + + if (this.pollingTimer) { + clearTimeout(this.pollingTimer); + this.pollingTimer = undefined; + } + + // Clear global reference + if (currentSimpleRunner === this) { + currentSimpleRunner = null; + } + + // Clean up provider + if (this.provider) { + this.provider.removeAllListeners(); + if (currentSimpleProvider === this.provider) { + currentSimpleProvider = null; + } + this.provider = undefined; + } + + this.deployer = undefined; + this.config = undefined; + this.removeAllListeners(); + + this.logger.info('Rebalancer stopped'); + } + + isActive(): boolean { + return this.running && this.activeOperations > 0; + } + + async waitForIdle(timeoutMs: number = 10000): Promise { + const startTime = Date.now(); + + while (this.isActive()) { + if (Date.now() - startTime > timeoutMs) { + throw new Error('Timeout waiting for rebalancer to become idle'); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } +} diff --git a/typescript/rebalancer-sim/src/runners/SimulationRegistry.ts b/typescript/rebalancer-sim/src/runners/SimulationRegistry.ts new file mode 100644 index 00000000000..cbbedd932c8 --- /dev/null +++ b/typescript/rebalancer-sim/src/runners/SimulationRegistry.ts @@ -0,0 +1,215 @@ +import type { + ChainFiles, + IRegistry, + RegistryContent, + RegistryType, + UpdateChainParams, + WarpRouteConfigMap, + WarpRouteFilterParams, +} from '@hyperlane-xyz/registry'; +import { + type ChainMetadata, + type ChainName, + TokenStandard, + type WarpCoreConfig, + type WarpRouteDeployConfig, +} from '@hyperlane-xyz/sdk'; +import { ProtocolType } from '@hyperlane-xyz/utils'; + +import type { MultiDomainDeploymentResult } from '../types.js'; + +/** + * A mock registry that provides chain metadata and warp route config + * for the simulation environment. + */ +export class SimulationRegistry implements IRegistry { + readonly type: RegistryType = 'partial' as RegistryType; + readonly uri: string = 'simulation://local'; + private readonly warpRouteId = 'SIM/simulation'; + private readonly chainMetadata: Record; + private readonly warpCoreConfig: WarpCoreConfig; + + constructor(private readonly deployment: MultiDomainDeploymentResult) { + // Build chain metadata + this.chainMetadata = this.buildChainMetadata(); + // Build warp core config + this.warpCoreConfig = this.buildWarpCoreConfig(); + } + + private buildChainMetadata(): Record { + const metadata: Record = {}; + + for (const [chainName, domain] of Object.entries(this.deployment.domains)) { + metadata[chainName] = { + name: chainName, + chainId: 31337, // Anvil's actual chainId (not domainId) + domainId: domain.domainId, + protocol: ProtocolType.Ethereum, + rpcUrls: [{ http: this.deployment.anvilRpc }], + nativeToken: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + blocks: { + confirmations: 0, + estimateBlockTime: 1, + reorgPeriod: 0, // Disable historical block queries in simulation + }, + }; + } + + return metadata; + } + + private buildWarpCoreConfig(): WarpCoreConfig { + const tokens: WarpCoreConfig['tokens'] = []; + + for (const [chainName, domain] of Object.entries(this.deployment.domains)) { + tokens.push({ + chainName, + standard: TokenStandard.EvmHypCollateral, + decimals: 18, + symbol: 'SIM', + name: 'Simulation Token', + addressOrDenom: domain.warpToken, + collateralAddressOrDenom: domain.collateralToken, + connections: Object.entries(this.deployment.domains) + .filter(([name]) => name !== chainName) + .map(([name, d]) => ({ + token: `ethereum|${name}|${d.warpToken}`, + })), + }); + } + + return { tokens }; + } + + // IRegistry implementation + + getUri(itemPath?: string): string { + return itemPath ? `${this.uri}/${itemPath}` : this.uri; + } + + async listRegistryContent(): Promise { + const chains: Record = {}; + for (const chainName of Object.keys(this.deployment.domains)) { + chains[chainName] = { + metadata: `chains/${chainName}/metadata.yaml`, + addresses: `chains/${chainName}/addresses.yaml`, + }; + } + return { + chains, + deployments: { + warpRoutes: { + [this.warpRouteId]: + `deployments/warp_routes/${this.warpRouteId}.yaml`, + }, + warpDeployConfig: {}, + }, + }; + } + + async getChains(): Promise { + return Object.keys(this.deployment.domains); + } + + async getMetadata(): Promise> { + return this.chainMetadata; + } + + async getChainMetadata(chainName: ChainName): Promise { + return this.chainMetadata[chainName] || null; + } + + async getAddresses(): Promise>> { + const addresses: Record> = {}; + + for (const [chainName, domain] of Object.entries(this.deployment.domains)) { + addresses[chainName] = { + mailbox: domain.mailbox, + warpToken: domain.warpToken, + bridge: domain.bridge, + }; + } + + return addresses; + } + + async getChainAddresses( + chainName: ChainName, + ): Promise | null> { + const addresses = await this.getAddresses(); + return addresses[chainName] || null; + } + + async getWarpRoute(routeId: string): Promise { + if (routeId === this.warpRouteId) { + return this.warpCoreConfig; + } + return null; + } + + async getWarpRoutes( + _filter?: WarpRouteFilterParams, + ): Promise { + return { + [this.warpRouteId]: this.warpCoreConfig, + }; + } + + async getWarpDeployConfig( + _routeId: string, + ): Promise { + // Not needed for simulation + return null; + } + + async getWarpDeployConfigs( + _filter?: WarpRouteFilterParams, + ): Promise> { + // Not needed for simulation + return {}; + } + + async getChainLogoUri(_chainName: ChainName): Promise { + // Not needed for simulation + return null; + } + + async addWarpRoute( + _config: WarpCoreConfig, + _options?: { symbol?: string } | { warpRouteId?: string }, + ): Promise { + throw new Error('Not supported in simulation'); + } + + async addWarpRouteConfig( + _config: WarpRouteDeployConfig, + _options: { symbol?: string } | { warpRouteId?: string }, + ): Promise { + throw new Error('Not supported in simulation'); + } + + // Methods not needed for simulation + async addChain(_chain: UpdateChainParams): Promise { + throw new Error('Not supported in simulation'); + } + + async updateChain(_chain: UpdateChainParams): Promise { + throw new Error('Not supported in simulation'); + } + + async removeChain(_chain: ChainName): Promise { + throw new Error('Not supported in simulation'); + } + + merge(_otherRegistry: IRegistry): IRegistry { + throw new Error('Not supported in simulation'); + } + + getWarpRouteId(): string { + return this.warpRouteId; + } +} diff --git a/typescript/rebalancer-sim/src/types.ts b/typescript/rebalancer-sim/src/types.ts new file mode 100644 index 00000000000..de896f9c7f2 --- /dev/null +++ b/typescript/rebalancer-sim/src/types.ts @@ -0,0 +1,878 @@ +/** + * Consolidated types for rebalancer-sim + * + * This file contains all type definitions for the simulation framework, + * organized by domain. + */ +import type { WarpCoreConfig } from '@hyperlane-xyz/sdk'; +import type { Address } from '@hyperlane-xyz/utils'; + +// ============================================================================= +// BRIDGE TYPES +// ============================================================================= + +/** + * Bridge mock configuration for REBALANCER transfers. + * + * This configures the simulated bridge delays for when the rebalancer moves + * funds between chains. In production, these bridges (CCTP, etc.) can have + * delays ranging from ~10 seconds to 7 days depending on the bridge type. + * + * NOTE: This is separate from user transfer delivery, which goes through + * Hyperlane/Mailbox and is configured via SimulationTiming.userTransferDeliveryDelay. + */ +export interface BridgeMockConfig { + [origin: string]: { + [dest: string]: BridgeRouteConfig; + }; +} + +/** + * Configuration for a single bridge route + */ +export interface BridgeRouteConfig { + /** Delivery delay in milliseconds (e.g., 500ms for fast simulation) */ + deliveryDelay: number; + /** Failure rate as decimal 0-1 (e.g., 0.01 for 1%) */ + failureRate: number; + /** Jitter in milliseconds (± variance) */ + deliveryJitter: number; + /** Optional token fee as basis points (e.g., 10 = 0.1%) */ + tokenFeeBps?: number; +} + +/** + * Pending transfer in bridge controller + */ +export interface PendingTransfer { + id: string; + origin: string; + destination: string; + amount: bigint; + recipient: Address; + scheduledDelivery: number; + failed: boolean; + delivered: boolean; + deliveredAt?: number; +} + +/** + * Bridge event types + */ +export type BridgeEventType = + | 'transfer_initiated' + | 'transfer_delivered' + | 'transfer_failed'; + +/** + * Bridge event for tracking + */ +export interface BridgeEvent { + type: BridgeEventType; + transfer: PendingTransfer; + timestamp: number; +} + +/** + * Default bridge config for testing + */ +export const DEFAULT_BRIDGE_ROUTE_CONFIG: BridgeRouteConfig = { + deliveryDelay: 500, + failureRate: 0, + deliveryJitter: 100, +}; + +/** + * Creates a symmetric bridge config for all chain pairs + */ +export function createSymmetricBridgeConfig( + chains: string[], + config: BridgeRouteConfig = DEFAULT_BRIDGE_ROUTE_CONFIG, +): BridgeMockConfig { + const bridgeConfig: BridgeMockConfig = {}; + + for (const origin of chains) { + bridgeConfig[origin] = {}; + for (const dest of chains) { + if (origin !== dest) { + bridgeConfig[origin][dest] = { ...config }; + } + } + } + + return bridgeConfig; +} + +// ============================================================================= +// DEPLOYMENT TYPES +// ============================================================================= + +/** + * Configuration for a simulated chain domain + */ +export interface SimulatedChainConfig { + chainName: string; + domainId: number; +} + +/** + * Deployed addresses for a single domain + */ +export interface DeployedDomain { + chainName: string; + domainId: number; + mailbox: Address; + warpToken: Address; + collateralToken: Address; + bridge: Address; +} + +/** + * Complete multi-domain deployment result + */ +export interface MultiDomainDeploymentResult { + anvilRpc: string; + deployer: Address; + deployerKey: string; + /** Separate key for rebalancer (different nonce) */ + rebalancerKey: string; + rebalancer: Address; + /** Separate key for bridge controller (different nonce) */ + bridgeControllerKey: string; + bridgeController: Address; + /** Separate key for mailbox processor (different nonce) */ + mailboxProcessorKey: string; + mailboxProcessor: Address; + domains: Record; +} + +/** + * Options for multi-domain deployment + */ +export interface MultiDomainDeploymentOptions { + /** RPC URL for anvil instance */ + anvilRpc: string; + /** Deployer private key */ + deployerKey: string; + /** Rebalancer private key (separate nonce from deployer) */ + rebalancerKey?: string; + /** Bridge controller private key (separate nonce from deployer and rebalancer) */ + bridgeControllerKey?: string; + /** Mailbox processor private key (separate nonce for processing mailbox messages) */ + mailboxProcessorKey?: string; + /** Chain configurations to deploy */ + chains: SimulatedChainConfig[]; + /** Initial collateral balance per chain (in wei) */ + initialCollateralBalance: bigint; + /** Token decimals */ + tokenDecimals?: number; + /** Token symbol */ + tokenSymbol?: string; + /** Token name */ + tokenName?: string; +} + +/** + * Default simulated chains for testing + */ +export const DEFAULT_SIMULATED_CHAINS: SimulatedChainConfig[] = [ + { chainName: 'chain1', domainId: 1000 }, + { chainName: 'chain2', domainId: 2000 }, + { chainName: 'chain3', domainId: 3000 }, +]; + +/** + * Default anvil deployer key (first account) + */ +export const ANVIL_DEPLOYER_KEY = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + +/** + * Default anvil deployer address + */ +export const ANVIL_DEPLOYER_ADDRESS = + '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; + +/** + * Second anvil account key (for rebalancer - separate nonce) + */ +export const ANVIL_REBALANCER_KEY = + '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; + +/** + * Second anvil account address + */ +export const ANVIL_REBALANCER_ADDRESS = + '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'; + +/** + * Third anvil account key (for bridge controller - separate nonce) + */ +export const ANVIL_BRIDGE_CONTROLLER_KEY = + '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a'; + +/** + * Third anvil account address + */ +export const ANVIL_BRIDGE_CONTROLLER_ADDRESS = + '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC'; + +/** + * Fourth anvil account key (for mailbox processor - separate nonce) + */ +export const ANVIL_MAILBOX_PROCESSOR_KEY = + '0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6'; + +/** + * Fourth anvil account address + */ +export const ANVIL_MAILBOX_PROCESSOR_ADDRESS = + '0x90F79bf6EB2c4f870365E785982E1f101E93b906'; + +// ============================================================================= +// KPI TYPES +// ============================================================================= + +/** + * Per-chain metrics + */ +export interface ChainMetrics { + chainName: string; + initialBalance: bigint; + finalBalance: bigint; + transfersIn: number; + transfersOut: number; + rebalancesIn: number; + rebalancesOut: number; + rebalanceVolumeIn: bigint; + rebalanceVolumeOut: bigint; +} + +/** + * KPIs collected during simulation + */ +export interface SimulationKPIs { + totalTransfers: number; + completedTransfers: number; + failedTransfers: number; + completionRate: number; + averageLatency: number; + p50Latency: number; + p95Latency: number; + p99Latency: number; + totalRebalances: number; + rebalanceVolume: bigint; + totalGasCost: bigint; + perChainMetrics: Record; +} + +/** + * State snapshot at a point in time + */ +export interface StateSnapshot { + timestamp: number; + balances: Record; + pendingTransfers: number; + pendingRebalances: number; +} + +/** + * Transfer tracking record + */ +export interface TransferRecord { + id: string; + origin: string; + destination: string; + amount: bigint; + startTime: number; + endTime?: number; + latency?: number; + status: 'pending' | 'completed' | 'failed'; +} + +/** + * Rebalance tracking record + */ +export interface RebalanceRecord { + id: string; + /** Bridge transfer ID for correlation */ + bridgeTransferId?: string; + origin: string; + destination: string; + amount: bigint; + startTime: number; + endTime?: number; + latency?: number; + gasCost: bigint; + status: 'pending' | 'completed' | 'failed'; +} + +/** + * Complete simulation result + */ +export interface SimulationResult { + scenarioName: string; + rebalancerName: string; + startTime: number; + endTime: number; + duration: number; + kpis: SimulationKPIs; + transferRecords: TransferRecord[]; + rebalanceRecords: RebalanceRecord[]; +} + +/** + * Comparison report for multiple rebalancers + */ +export interface ComparisonReport { + scenarioName: string; + results: SimulationResult[]; + comparison: { + bestCompletionRate: string; + bestLatency: string; + lowestGasCost: string; + }; +} + +// ============================================================================= +// REBALANCER TYPES +// ============================================================================= + +/** + * Rebalancer configuration for simulation + */ +export interface RebalancerSimConfig { + /** Polling frequency in milliseconds */ + pollingFrequency: number; + /** Warp core configuration */ + warpConfig: WarpCoreConfig; + /** Strategy-specific configuration */ + strategyConfig: RebalancerStrategyConfig; + /** Deployment info */ + deployment: MultiDomainDeploymentResult; +} + +/** + * Strategy configuration for rebalancer + */ +export interface RebalancerStrategyConfig { + type: 'weighted' | 'minAmount'; + chains: Record; +} + +/** + * Per-chain strategy configuration + */ +export interface ChainStrategyConfig { + weighted?: { + weight: string; + tolerance: string; + }; + minAmount?: { + min: string; + target: string; + type: 'absolute' | 'relative'; + }; + bridge: string; + bridgeLockTime: number; +} + +/** + * Interface for rebalancer runners in simulation + */ +export interface IRebalancerRunner { + /** Name of the rebalancer implementation */ + readonly name: string; + + /** + * Initialize the rebalancer with configuration + */ + initialize(config: RebalancerSimConfig): Promise; + + /** + * Start the rebalancer daemon + */ + start(): Promise; + + /** + * Stop the rebalancer daemon + */ + stop(): Promise; + + /** + * Check if the rebalancer is currently active (has pending operations) + */ + isActive(): boolean; + + /** + * Wait for the rebalancer to complete current operations + */ + waitForIdle(timeoutMs?: number): Promise; + + /** + * Subscribe to rebalancer events + */ + on(event: 'rebalance', listener: (e: RebalancerEvent) => void): this; +} + +/** + * Event emitted when rebalancer performs an action + */ +export interface RebalancerEvent { + type: + | 'rebalance_initiated' + | 'rebalance_completed' + | 'rebalance_failed' + | 'cycle_completed'; + timestamp: number; + origin?: string; + destination?: string; + amount?: bigint; + error?: string; +} + +// ============================================================================= +// SCENARIO TYPES +// ============================================================================= + +/** + * Complete scenario file format - includes metadata, transfers, and default configs + */ +export interface ScenarioFile { + /** Scenario name for identification */ + name: string; + + /** Human-readable description of what this scenario tests */ + description: string; + + /** Explanation of expected behavior and why */ + expectedBehavior: string; + + /** Total simulated duration in milliseconds */ + duration: number; + + /** Chain names involved in this scenario */ + chains: string[]; + + /** Optional extra tokens to mint per chain after deployment (for creating imbalanced initial state) */ + initialImbalance?: Record; + + /** Ordered list of transfer events */ + transfers: SerializedTransferEvent[]; + + /** Default initial collateral balance in wei, applied to all chains (as string for JSON) */ + defaultInitialCollateral: string; + + /** Default timing configuration */ + defaultTiming: SimulationTiming; + + /** Default bridge mock configuration */ + defaultBridgeConfig: SerializedBridgeConfig; + + /** Default rebalancer strategy configuration (without bridge addresses) */ + defaultStrategyConfig: SerializedStrategyConfig; + + /** Expected outcomes for assertions */ + expectations: ScenarioExpectations; +} + +/** + * Timing configuration for simulation execution + */ +export interface SimulationTiming { + /** + * Delay for user transfers via Hyperlane/Mailbox (ms). + * Simulates real Hyperlane finality (~10-15s in production). + * Set to 0 for instant delivery in fast tests. + */ + userTransferDeliveryDelay: number; + /** How often rebalancer polls for imbalances (ms) */ + rebalancerPollingFrequency: number; + /** Minimum spacing between user transfer executions (ms) */ + userTransferInterval: number; +} + +/** + * Serialized bridge config for JSON storage + */ +export interface SerializedBridgeConfig { + [origin: string]: { + [dest: string]: { + /** Delivery delay in milliseconds */ + deliveryDelay: number; + /** Failure rate as decimal 0-1 */ + failureRate: number; + /** Jitter in milliseconds (± variance) */ + deliveryJitter: number; + }; + }; +} + +/** + * Serialized strategy config for JSON storage (bridge addresses added at runtime) + */ +export interface SerializedStrategyConfig { + type: 'weighted' | 'minAmount'; + chains: { + [chain: string]: { + weighted?: { + /** Weight as decimal string (e.g., "0.333") */ + weight: string; + /** Tolerance as decimal string (e.g., "0.15" for 15%) */ + tolerance: string; + }; + minAmount?: { + /** Minimum balance in tokens (as string) */ + min: string; + /** Target balance in tokens (as string) */ + target: string; + }; + /** Time bridge locks funds before delivery (ms) - used for semaphore */ + bridgeLockTime: number; + }; + }; +} + +/** + * Expected outcomes for test assertions + */ +export interface ScenarioExpectations { + /** Minimum completion rate (0-1), e.g., 0.9 for 90% */ + minCompletionRate?: number; + /** Minimum number of rebalances expected */ + minRebalances?: number; + /** Maximum number of rebalances expected */ + maxRebalances?: number; + /** Whether rebalancing should be triggered at all */ + shouldTriggerRebalancing?: boolean; +} + +/** + * Transfer scenario definition for simulation (runtime format) + */ +export interface TransferScenario { + /** Scenario name for identification */ + name: string; + /** Total simulated duration in milliseconds */ + duration: number; + /** Ordered list of transfer events */ + transfers: TransferEvent[]; + /** Chain names involved in this scenario */ + chains: string[]; +} + +/** + * Individual transfer event within a scenario + */ +export interface TransferEvent { + /** Unique identifier for this transfer */ + id: string; + /** Timestamp offset from scenario start in milliseconds */ + timestamp: number; + /** Origin chain name */ + origin: string; + /** Destination chain name */ + destination: string; + /** Transfer amount in wei */ + amount: bigint; + /** User address initiating the transfer */ + user: Address; +} + +/** + * Options for generating unidirectional flow scenarios + */ +export interface UnidirectionalFlowOptions { + /** Origin chain name */ + origin: string; + /** Destination chain name */ + destination: string; + /** Number of transfers */ + transferCount: number; + /** Total duration in milliseconds */ + duration: number; + /** Fixed or range of transfer amounts in wei */ + amount: bigint | [bigint, bigint]; + /** User address (optional, will be generated if not provided) */ + user?: Address; +} + +/** + * Options for generating random traffic scenarios + */ +export interface RandomTrafficOptions { + /** Chain names to use */ + chains: string[]; + /** Number of transfers */ + transferCount: number; + /** Total duration in milliseconds */ + duration: number; + /** Range of transfer amounts in wei [min, max] */ + amountRange: [bigint, bigint]; + /** User addresses (optional, will be generated if not provided) */ + users?: Address[]; + /** Distribution type */ + distribution?: 'uniform' | 'poisson'; + /** Mean interval for Poisson distribution in ms */ + poissonMeanInterval?: number; +} + +/** + * Options for generating surge scenarios + */ +export interface SurgeScenarioOptions { + /** Chain names */ + chains: string[]; + /** Baseline transfers per second */ + baselineRate: number; + /** Surge multiplier */ + surgeMultiplier: number; + /** Surge start time (ms from start) */ + surgeStart: number; + /** Surge duration (ms) */ + surgeDuration: number; + /** Total duration (ms) */ + totalDuration: number; + /** Amount range */ + amountRange: [bigint, bigint]; +} + +/** + * Serialized transfer event for JSON storage + */ +export interface SerializedTransferEvent { + id: string; + timestamp: number; + origin: string; + destination: string; + /** Amount as string for JSON compatibility */ + amount: string; + user: string; +} + +/** + * Serialized scenario for JSON storage (legacy format, transfers only) + */ +export interface SerializedScenario { + name: string; + duration: number; + chains: string[]; + transfers: SerializedTransferEvent[]; +} + +// ============================================================================= +// VISUALIZER TYPES +// ============================================================================= + +/** + * Unified timeline event for visualization + */ +export type TimelineEvent = + | { + type: 'transfer_start'; + timestamp: number; + data: TransferRecord; + } + | { + type: 'transfer_complete'; + timestamp: number; + data: TransferRecord; + } + | { + type: 'transfer_failed'; + timestamp: number; + data: TransferRecord; + } + | { + type: 'rebalance_start'; + timestamp: number; + data: RebalanceRecord; + } + | { + type: 'rebalance_complete'; + timestamp: number; + data: RebalanceRecord; + } + | { + type: 'rebalance_failed'; + timestamp: number; + data: RebalanceRecord; + }; + +/** + * Simulation config for display + */ +export interface SimulationConfig { + /** Scenario name */ + scenarioName?: string; + /** Scenario description */ + description?: string; + /** Expected behavior explanation */ + expectedBehavior?: string; + /** Per-chain target weights (percentage) */ + targetWeights?: Record; + /** Per-chain tolerance (percentage) */ + tolerances?: Record; + /** User transfer delivery delay in ms (Hyperlane finality) */ + userTransferDelay?: number; + /** Rebalancer bridge delivery delay in ms */ + bridgeDeliveryDelay?: number; + /** Rebalancer polling frequency in ms */ + rebalancerPollingFrequency?: number; + /** Initial collateral per chain */ + initialCollateral?: Record; + /** Transfer count */ + transferCount?: number; + /** Simulation duration in ms */ + duration?: number; +} + +/** + * Processed data ready for visualization + */ +export interface VisualizationData { + scenario: string; + rebalancerName: string; + startTime: number; + endTime: number; + duration: number; + chains: string[]; + events: TimelineEvent[]; + transfers: TransferRecord[]; + rebalances: RebalanceRecord[]; + kpis: SimulationResult['kpis']; + config?: SimulationConfig; + /** Balance timeline for rendering balance curves */ + balanceTimeline: Array<{ + timestamp: number; + balances: Record; + }>; +} + +/** + * Options for HTML generation + */ +export interface HtmlGeneratorOptions { + /** Width of the timeline in pixels */ + width?: number; + /** Height per chain row in pixels */ + rowHeight?: number; + /** Whether to show balance curves */ + showBalances?: boolean; + /** Whether to show rebalance markers */ + showRebalances?: boolean; + /** Title override */ + title?: string; +} + +/** + * Convert SimulationResult to VisualizationData + */ +export function toVisualizationData( + result: SimulationResult, + config?: SimulationConfig, +): VisualizationData { + const events: TimelineEvent[] = []; + + // Collect all chains from transfers and rebalances + const chainSet = new Set(); + for (const t of result.transferRecords) { + chainSet.add(t.origin); + chainSet.add(t.destination); + } + for (const r of result.rebalanceRecords) { + chainSet.add(r.origin); + chainSet.add(r.destination); + } + + // Add transfer events + for (const transfer of result.transferRecords) { + events.push({ + type: 'transfer_start', + timestamp: transfer.startTime, + data: transfer, + }); + + if (transfer.endTime) { + events.push({ + type: + transfer.status === 'failed' + ? 'transfer_failed' + : 'transfer_complete', + timestamp: transfer.endTime, + data: transfer, + }); + } + } + + // Add rebalance events (start and complete/fail) + for (const rebalance of result.rebalanceRecords) { + // Rebalance start + events.push({ + type: 'rebalance_start', + timestamp: rebalance.startTime, + data: rebalance, + }); + // Rebalance complete/fail + if (rebalance.endTime) { + events.push({ + type: + rebalance.status === 'failed' + ? 'rebalance_failed' + : 'rebalance_complete', + timestamp: rebalance.endTime, + data: rebalance, + }); + } + } + + // Sort events by timestamp + events.sort((a, b) => a.timestamp - b.timestamp); + + // Build balance timeline from config's initial collateral + const chains = Array.from(chainSet).sort(); + const balanceTimeline: Array<{ + timestamp: number; + balances: Record; + }> = []; + + // Add initial snapshot if config has initial collateral + if (config?.initialCollateral) { + const initialBalances: Record = {}; + for (const chain of chains) { + // Convert to wei string (config values are in ether as strings like "100" or "150") + const value = config.initialCollateral[chain]; + if (value) { + // If already in wei format (18+ digits), use as-is; otherwise convert from ether + const numValue = parseFloat(value); + if (numValue > 1e15) { + initialBalances[chain] = value; + } else { + // Convert ether to wei + initialBalances[chain] = ( + BigInt(Math.floor(numValue)) * BigInt(1e18) + ).toString(); + } + } + } + balanceTimeline.push({ + timestamp: result.startTime, + balances: initialBalances, + }); + } + + return { + scenario: result.scenarioName, + rebalancerName: result.rebalancerName, + startTime: result.startTime, + endTime: result.endTime, + duration: result.duration, + chains, + events, + transfers: result.transferRecords, + rebalances: result.rebalanceRecords, + kpis: result.kpis, + config, + balanceTimeline, + }; +} diff --git a/typescript/rebalancer-sim/src/visualizer/HtmlTimelineGenerator.ts b/typescript/rebalancer-sim/src/visualizer/HtmlTimelineGenerator.ts new file mode 100644 index 00000000000..86a5f28496f --- /dev/null +++ b/typescript/rebalancer-sim/src/visualizer/HtmlTimelineGenerator.ts @@ -0,0 +1,1321 @@ +import type { + HtmlGeneratorOptions, + SimulationConfig, + SimulationResult, +} from '../types.js'; +import { toVisualizationData } from '../types.js'; + +const DEFAULT_OPTIONS: Required = { + width: 1200, + rowHeight: 150, + showBalances: true, + showRebalances: true, + title: '', +}; + +/** + * Generate a standalone HTML timeline visualization + */ +export function generateTimelineHtml( + results: SimulationResult[], + options: HtmlGeneratorOptions = {}, + config?: SimulationConfig, +): string { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const visualizations = results.map((r) => toVisualizationData(r, config)); + const title = + opts.title || `Simulation: ${visualizations[0]?.scenario || 'Unknown'}`; + + // Serialize data for embedding (handle BigInt) + const serializedData = JSON.stringify( + visualizations, + (_, value) => (typeof value === 'bigint' ? value.toString() : value), + 2, + ); + + return ` + + + + + ${escapeHtml(title)} + + + +
+

${escapeHtml(title)}

+
+
+
+
+
+ + + +`; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function getStyles(opts: Required): string { + return ` + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #1a1a2e; + color: #e0e0e0; + padding: 20px; + line-height: 1.6; + } + + .container { + max-width: ${opts.width + 150}px; + margin: 0 auto; + } + + h1 { + margin-bottom: 20px; + color: #fff; + font-size: 1.5rem; + } + + .rebalancer-section { + margin-bottom: 40px; + background: #252542; + border-radius: 8px; + padding: 20px; + } + + .rebalancer-title { + font-size: 1.2rem; + margin-bottom: 15px; + color: #fff; + display: flex; + align-items: center; + gap: 10px; + } + + .rebalancer-badge { + font-size: 0.7rem; + padding: 3px 8px; + background: #4ecdc4; + color: #1a1a2e; + border-radius: 4px; + } + + .kpi-row { + display: flex; + gap: 15px; + margin-bottom: 15px; + flex-wrap: wrap; + } + + .kpi-card { + background: #1e1e30; + padding: 12px 16px; + border-radius: 6px; + min-width: 120px; + } + + .kpi-card .label { + font-size: 0.75rem; + color: #888; + text-transform: uppercase; + } + + .kpi-card .value { + font-size: 1.3rem; + font-weight: bold; + color: #4ecdc4; + } + + .kpi-card.warning .value { + color: #f9c74f; + } + + .timeline-wrapper { + position: relative; + margin-top: 20px; + } + + .timeline-svg { + background: #1e1e30; + border-radius: 8px; + display: block; + } + + .chain-label { + font-size: 12px; + fill: #aaa; + font-family: monospace; + font-weight: bold; + } + + .balance-label { + font-size: 9px; + fill: #666; + font-family: monospace; + } + + .time-axis-label { + font-size: 10px; + fill: #666; + } + + .transfer-group { + cursor: pointer; + } + + .transfer-group:hover .transfer-bar { + filter: brightness(1.2); + } + + .transfer-bar { + transition: filter 0.2s; + } + + .transfer-label { + font-size: 10px; + fill: #fff; + font-weight: bold; + pointer-events: none; + } + + .transfer-time-label { + font-size: 8px; + fill: #888; + font-family: monospace; + } + + .start-marker { + fill: #fff; + stroke: none; + } + + .end-marker { + stroke-width: 2; + } + + .rebalance-marker { + cursor: pointer; + } + + .rebalance-arrow { + stroke-width: 2; + stroke-dasharray: 4,2; + } + + .balance-line { + fill: none; + stroke-width: 2; + opacity: 0.7; + } + + .balance-hover-area { + fill: transparent; + stroke: transparent; + stroke-width: 20; + cursor: crosshair; + } + + #config-panel { + display: flex; + gap: 30px; + margin-bottom: 20px; + padding: 15px; + background: #252542; + border-radius: 8px; + flex-wrap: wrap; + } + + .config-section { + display: flex; + flex-direction: column; + gap: 6px; + } + + .config-title { + font-size: 0.75rem; + color: #888; + text-transform: uppercase; + margin-bottom: 4px; + } + + .config-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + } + + .config-label { + color: #888; + min-width: 80px; + } + + .config-value { + color: #fff; + font-family: monospace; + } + + #legend { + display: flex; + gap: 25px; + margin-top: 20px; + flex-wrap: wrap; + padding: 15px; + background: #252542; + border-radius: 8px; + } + + .legend-section { + display: flex; + flex-direction: column; + gap: 8px; + } + + .legend-title { + font-size: 0.75rem; + color: #888; + text-transform: uppercase; + margin-bottom: 4px; + } + + .legend-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + } + + .legend-color { + width: 24px; + height: 12px; + border-radius: 2px; + } + + .legend-line { + width: 24px; + height: 3px; + border-radius: 1px; + } + + .legend-marker { + width: 10px; + height: 10px; + border-radius: 50%; + } + + #details-panel { + margin-top: 20px; + padding: 15px; + background: #252542; + border-radius: 8px; + display: none; + } + + #details-panel.visible { + display: block; + } + + #details-panel h3 { + margin-bottom: 10px; + color: #fff; + } + + #details-panel .detail-row { + display: flex; + gap: 10px; + margin: 5px 0; + } + + #details-panel .detail-label { + color: #888; + min-width: 100px; + } + + #details-panel .detail-value { + color: #fff; + font-family: monospace; + } + + .tooltip { + position: absolute; + background: rgba(30, 30, 48, 0.95); + color: #fff; + padding: 10px 14px; + border-radius: 6px; + font-size: 12px; + pointer-events: none; + z-index: 1000; + max-width: 300px; + border: 1px solid #444; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + } + + .tooltip strong { + color: #4ecdc4; + } + `; +} + +function getScript(opts: Required): string { + return ` +const WIDTH = ${opts.width}; +const ROW_HEIGHT = ${opts.rowHeight}; +const SHOW_BALANCES = ${opts.showBalances}; +const SHOW_REBALANCES = ${opts.showRebalances}; +const MARGIN = { top: 50, right: 30, bottom: 40, left: 100 }; + +// Distinct colors for transfers (T1, T2, T3, etc.) +const TRANSFER_COLORS = [ + '#00b4d8', // cyan + '#06d6a0', // green + '#ffd166', // yellow + '#ef476f', // pink + '#118ab2', // blue + '#073b4c', // dark blue + '#e76f51', // orange + '#2a9d8f', // teal +]; + +// Colors for balance curves per chain +const CHAIN_COLORS = { + chain1: '#f9c74f', // yellow/gold + chain2: '#4ecdc4', // teal + chain3: '#f94144', // red + chain4: '#90be6d', // green + chain5: '#577590', // blue-gray +}; + +const REBALANCE_COLOR = '#9b59b6'; // purple + +function renderVisualization(data) { + const container = document.getElementById('timeline-container'); + const legend = document.getElementById('legend'); + const configPanel = document.getElementById('config-panel'); + + // Render config panel (from first viz) + if (data[0]?.config) { + renderConfig(configPanel, data[0].config, data[0].chains); + } + + // Render each rebalancer's results + data.forEach((viz, index) => { + const section = document.createElement('div'); + section.className = 'rebalancer-section'; + + // Title + const titleDiv = document.createElement('div'); + titleDiv.className = 'rebalancer-title'; + titleDiv.innerHTML = '' + viz.rebalancerName + '' + + 'Rebalancer ' + (index + 1) + ''; + section.appendChild(titleDiv); + + // KPIs + section.appendChild(renderKPIs(viz)); + + // Timeline SVG + section.appendChild(renderTimeline(viz, index)); + + container.appendChild(section); + }); + + // Legend + renderLegend(legend, data[0]); +} + +function renderKPIs(viz) { + const kpis = viz.kpis; + const div = document.createElement('div'); + div.className = 'kpi-row'; + + const completionClass = kpis.completionRate < 0.95 ? 'warning' : ''; + const latencyClass = kpis.p95Latency > 1000 ? 'warning' : ''; + + div.innerHTML = \` +
+
Completion
+
\${(kpis.completionRate * 100).toFixed(1)}%
+
+
+
Transfers
+
\${kpis.completedTransfers}/\${kpis.totalTransfers}
+
+
+
Avg Latency
+
\${kpis.averageLatency.toFixed(0)}ms
+
+
+
P95 Latency
+
\${kpis.p95Latency.toFixed(0)}ms
+
+
+
Rebalances
+
\${kpis.totalRebalances}
+
+ \`; + return div; +} + +function renderTimeline(viz, vizIndex) { + const chains = viz.chains; + const height = MARGIN.top + chains.length * ROW_HEIGHT + MARGIN.bottom; + const innerWidth = WIDTH - MARGIN.left - MARGIN.right; + + // Time scale + const timeExtent = [viz.startTime, viz.endTime]; + const duration = timeExtent[1] - timeExtent[0]; + const xScale = (t) => MARGIN.left + ((t - timeExtent[0]) / duration) * innerWidth; + + // Create SVG + const wrapper = document.createElement('div'); + wrapper.className = 'timeline-wrapper'; + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'timeline-svg'); + svg.setAttribute('width', WIDTH); + svg.setAttribute('height', height); + + // Defs for markers + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + defs.innerHTML = \` + + + + \`; + svg.appendChild(defs); + + // Background and chain rows + chains.forEach((chain, i) => { + const y = MARGIN.top + i * ROW_HEIGHT; + + // Row background + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rect.setAttribute('x', MARGIN.left); + rect.setAttribute('y', y); + rect.setAttribute('width', innerWidth); + rect.setAttribute('height', ROW_HEIGHT); + rect.setAttribute('fill', i % 2 === 0 ? '#1a1a2e' : '#1e1e35'); + svg.appendChild(rect); + + // Chain label + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + text.setAttribute('class', 'chain-label'); + text.setAttribute('x', MARGIN.left - 15); + text.setAttribute('y', y + ROW_HEIGHT / 2 + 4); + text.setAttribute('text-anchor', 'end'); + text.textContent = chain; + svg.appendChild(text); + + // Horizontal line at center of row + const centerLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + centerLine.setAttribute('x1', MARGIN.left); + centerLine.setAttribute('y1', y + ROW_HEIGHT / 2); + centerLine.setAttribute('x2', MARGIN.left + innerWidth); + centerLine.setAttribute('y2', y + ROW_HEIGHT / 2); + centerLine.setAttribute('stroke', '#333'); + centerLine.setAttribute('stroke-width', '1'); + svg.appendChild(centerLine); + }); + + // Time axis + const tickCount = Math.min(10, Math.ceil(duration / 500)); + for (let i = 0; i <= tickCount; i++) { + const t = timeExtent[0] + (i / tickCount) * duration; + const x = xScale(t); + + // Vertical grid line + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', x); + line.setAttribute('y1', MARGIN.top); + line.setAttribute('x2', x); + line.setAttribute('y2', height - MARGIN.bottom); + line.setAttribute('stroke', '#2a2a45'); + line.setAttribute('stroke-width', '1'); + svg.appendChild(line); + + // Time label + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + text.setAttribute('class', 'time-axis-label'); + text.setAttribute('x', x); + text.setAttribute('y', height - 15); + text.setAttribute('text-anchor', 'middle'); + text.textContent = ((t - timeExtent[0]) / 1000).toFixed(1) + 's'; + svg.appendChild(text); + } + + // Balance curves (render first, behind transfers) + if (SHOW_BALANCES && viz.balanceTimeline.length > 0) { + renderBalanceCurves(svg, viz, xScale, chains); + } + + // Group transfers by origin chain for vertical stacking + const transfersByChain = {}; + chains.forEach(c => transfersByChain[c] = []); + viz.transfers.forEach((t, i) => { + t._index = i; // Store original index for coloring + if (transfersByChain[t.origin]) { + transfersByChain[t.origin].push(t); + } + }); + + // Render transfers with distinct colors and labels + chains.forEach((chain, chainIndex) => { + const chainY = MARGIN.top + chainIndex * ROW_HEIGHT; + const transfers = transfersByChain[chain] || []; + const barHeight = 16; + const barSpacing = 20; + + // Calculate usable vertical space for transfer bars (leave room for balance curves and rebalances) + const minY = chainY + 20; // Top margin within row + const maxY = chainY + ROW_HEIGHT / 2 + 10; // Stop above center + rebalance area + const usableHeight = maxY - minY; + const maxBarsPerColumn = Math.max(1, Math.floor(usableHeight / barSpacing)); + + // Start from top of usable area + const startY = minY + barHeight / 2; + + transfers.forEach((transfer, stackIndex) => { + const color = TRANSFER_COLORS[transfer._index % TRANSFER_COLORS.length]; + // Wrap stackIndex to stay within row boundaries + const wrappedIndex = stackIndex % maxBarsPerColumn; + const y = startY + wrappedIndex * barSpacing; + const startX = xScale(transfer.startTime); + const endX = transfer.endTime ? xScale(transfer.endTime) : xScale(viz.endTime); + const width = Math.max(endX - startX, 20); + + const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + g.setAttribute('class', 'transfer-group'); + + // Transfer bar + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rect.setAttribute('class', 'transfer-bar'); + rect.setAttribute('x', startX); + rect.setAttribute('y', y - barHeight / 2); + rect.setAttribute('width', width); + rect.setAttribute('height', barHeight); + rect.setAttribute('rx', 3); + rect.setAttribute('fill', color); + if (transfer.status === 'failed') { + rect.setAttribute('fill', '#f94144'); + rect.setAttribute('opacity', '0.7'); + } else if (transfer.status === 'pending') { + rect.setAttribute('fill', color); + rect.setAttribute('opacity', '0.5'); + rect.setAttribute('stroke', color); + rect.setAttribute('stroke-width', '2'); + rect.setAttribute('stroke-dasharray', '4,2'); + } + g.appendChild(rect); + + // Start marker (circle) + const startCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + startCircle.setAttribute('class', 'start-marker'); + startCircle.setAttribute('cx', startX); + startCircle.setAttribute('cy', y); + startCircle.setAttribute('r', 4); + startCircle.setAttribute('fill', '#fff'); + g.appendChild(startCircle); + + // End marker (diamond) if completed + if (transfer.status === 'completed' && transfer.endTime) { + const endMarker = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + endMarker.setAttribute('class', 'end-marker'); + const ex = endX; + const ey = y; + const s = 5; + endMarker.setAttribute('points', \`\${ex},\${ey-s} \${ex+s},\${ey} \${ex},\${ey+s} \${ex-s},\${ey}\`); + endMarker.setAttribute('fill', color); + endMarker.setAttribute('stroke', '#fff'); + g.appendChild(endMarker); + } + + // Transfer ID label inside bar + const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + label.setAttribute('class', 'transfer-label'); + label.setAttribute('x', startX + 6); + label.setAttribute('y', y + 4); + label.textContent = 'T' + (transfer._index + 1); + g.appendChild(label); + + // Latency label above bar + if (transfer.latency) { + const latencyLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + latencyLabel.setAttribute('class', 'transfer-time-label'); + latencyLabel.setAttribute('x', startX + width / 2); + latencyLabel.setAttribute('y', y - barHeight / 2 - 3); + latencyLabel.setAttribute('text-anchor', 'middle'); + latencyLabel.textContent = transfer.latency + 'ms'; + g.appendChild(latencyLabel); + } + + // Arrow to destination + if (transfer.status === 'completed' && transfer.endTime) { + const destChainIndex = chains.indexOf(transfer.destination); + if (destChainIndex !== -1 && destChainIndex !== chainIndex) { + const destY = MARGIN.top + destChainIndex * ROW_HEIGHT + ROW_HEIGHT / 2; + const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + arrow.setAttribute('x1', endX); + arrow.setAttribute('y1', y); + arrow.setAttribute('x2', endX); + arrow.setAttribute('y2', destY > y ? destY - 10 : destY + 10); + arrow.setAttribute('stroke', color); + arrow.setAttribute('stroke-width', '2'); + arrow.setAttribute('stroke-dasharray', '4,3'); + arrow.setAttribute('opacity', '0.6'); + g.appendChild(arrow); + + // Arrow head at destination + const arrowHead = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + const ay = destY > y ? destY - 10 : destY + 10; + const dir = destY > y ? 1 : -1; + arrowHead.setAttribute('points', \`\${endX-4},\${ay} \${endX+4},\${ay} \${endX},\${ay + dir * 8}\`); + arrowHead.setAttribute('fill', color); + arrowHead.setAttribute('opacity', '0.6'); + g.appendChild(arrowHead); + } + } + + // Event handlers + g.addEventListener('mouseenter', (e) => showTooltip(e, transfer, 'transfer')); + g.addEventListener('mouseleave', hideTooltip); + g.addEventListener('click', () => showDetails(transfer, 'transfer')); + + svg.appendChild(g); + }); + }); + + // Rebalance bars (similar to transfers but with distinct styling) + if (SHOW_REBALANCES) { + // Group rebalances by origin chain for vertical stacking + const rebalancesByChain = {}; + chains.forEach(c => rebalancesByChain[c] = []); + viz.rebalances.forEach((r, i) => { + r._index = i; + if (rebalancesByChain[r.origin]) { + rebalancesByChain[r.origin].push(r); + } + }); + + chains.forEach((chain, chainIndex) => { + const chainY = MARGIN.top + chainIndex * ROW_HEIGHT; + const rebalances = rebalancesByChain[chain] || []; + const barHeight = 12; + const barSpacing = 16; + + // Calculate usable vertical space for rebalance bars (below center line) + const minY = chainY + ROW_HEIGHT / 2 + 15; // Start below center + const maxY = chainY + ROW_HEIGHT - 20; // Bottom margin within row + const usableHeight = maxY - minY; + const maxBarsPerColumn = Math.max(1, Math.floor(usableHeight / barSpacing)); + + // Start from top of rebalance area + const startY = minY + barHeight / 2; + + rebalances.forEach((rebalance, stackIndex) => { + // Wrap stackIndex to stay within row boundaries + const wrappedIndex = stackIndex % maxBarsPerColumn; + const y = startY + wrappedIndex * barSpacing; + const startX = xScale(rebalance.startTime); + const endX = rebalance.endTime ? xScale(rebalance.endTime) : xScale(viz.endTime); + const width = Math.max(endX - startX, 20); + + const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + g.setAttribute('class', 'rebalance-marker'); + + // Rebalance bar + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rect.setAttribute('x', startX); + rect.setAttribute('y', y - barHeight / 2); + rect.setAttribute('width', width); + rect.setAttribute('height', barHeight); + rect.setAttribute('rx', 2); + rect.setAttribute('fill', REBALANCE_COLOR); + if (rebalance.status === 'failed') { + rect.setAttribute('fill', '#f94144'); + rect.setAttribute('opacity', '0.7'); + } else if (rebalance.status === 'pending') { + rect.setAttribute('opacity', '0.5'); + rect.setAttribute('stroke', REBALANCE_COLOR); + rect.setAttribute('stroke-width', '2'); + rect.setAttribute('stroke-dasharray', '4,2'); + } + g.appendChild(rect); + + // Start marker (small circle) + const startCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + startCircle.setAttribute('cx', startX); + startCircle.setAttribute('cy', y); + startCircle.setAttribute('r', 3); + startCircle.setAttribute('fill', '#fff'); + g.appendChild(startCircle); + + // End marker (diamond) if completed + if (rebalance.status === 'completed' && rebalance.endTime) { + const endMarker = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + const ex = endX; + const ey = y; + const s = 4; + endMarker.setAttribute('points', \`\${ex},\${ey-s} \${ex+s},\${ey} \${ex},\${ey+s} \${ex-s},\${ey}\`); + endMarker.setAttribute('fill', REBALANCE_COLOR); + endMarker.setAttribute('stroke', '#fff'); + g.appendChild(endMarker); + } + + // R label inside bar + const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + label.setAttribute('x', startX + 5); + label.setAttribute('y', y + 3); + label.setAttribute('fill', '#fff'); + label.setAttribute('font-size', '8'); + label.setAttribute('font-weight', 'bold'); + label.textContent = 'R' + (rebalance._index + 1); + g.appendChild(label); + + // Latency label above bar + if (rebalance.latency) { + const latencyLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + latencyLabel.setAttribute('class', 'transfer-time-label'); + latencyLabel.setAttribute('x', startX + width / 2); + latencyLabel.setAttribute('y', y - barHeight / 2 - 3); + latencyLabel.setAttribute('text-anchor', 'middle'); + latencyLabel.textContent = rebalance.latency + 'ms'; + g.appendChild(latencyLabel); + } + + // Arrow to destination chain + const destChainIndex = chains.indexOf(rebalance.destination); + if (destChainIndex !== -1 && destChainIndex !== chainIndex && rebalance.endTime) { + const destY = MARGIN.top + destChainIndex * ROW_HEIGHT + ROW_HEIGHT / 2 + 20; + const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + arrow.setAttribute('class', 'rebalance-arrow'); + arrow.setAttribute('x1', endX); + arrow.setAttribute('y1', y); + arrow.setAttribute('x2', endX); + arrow.setAttribute('y2', destY > y ? destY - 10 : destY + 10); + arrow.setAttribute('stroke', REBALANCE_COLOR); + arrow.setAttribute('marker-end', 'url(#rebalance-arrow-' + vizIndex + ')'); + g.appendChild(arrow); + } + + g.addEventListener('mouseenter', (e) => showTooltip(e, rebalance, 'rebalance')); + g.addEventListener('mouseleave', hideTooltip); + g.addEventListener('click', () => showDetails(rebalance, 'rebalance')); + + svg.appendChild(g); + }); + }); + } + + // Setup balance hover listeners after all elements are added + setupBalanceHoverListeners(svg, xScale, viz.startTime); + + wrapper.appendChild(svg); + return wrapper; +} + +function setupBalanceHoverListeners(svg, xScale, startTime) { + const hoverAreas = svg.querySelectorAll('.balance-hover-area'); + hoverAreas.forEach(hoverPath => { + hoverPath.addEventListener('mousemove', (e) => { + const chain = hoverPath.getAttribute('data-chain'); + const timeline = JSON.parse(hoverPath.getAttribute('data-timeline')); + const totalBalance = BigInt(hoverPath.getAttribute('data-total-balance')); + const simStartTime = parseInt(hoverPath.getAttribute('data-start-time')); + + // Get mouse X position relative to SVG + const svgRect = svg.getBoundingClientRect(); + const mouseX = e.clientX - svgRect.left; + + // Find the timestamp at this X position (inverse of xScale) + const innerWidth = svg.clientWidth - MARGIN.left - MARGIN.right; + const duration = timeline[timeline.length - 1].timestamp - simStartTime; + const timestamp = simStartTime + ((mouseX - MARGIN.left) / innerWidth) * duration; + + // Find the balance at this timestamp (step function - find last point before timestamp) + let balance = BigInt(timeline[0].balance); + for (let i = 0; i < timeline.length; i++) { + if (timeline[i].timestamp <= timestamp) { + balance = BigInt(timeline[i].balance); + } else { + break; + } + } + + // Calculate percentage of total + const percentage = totalBalance > 0n + ? (Number(balance) / Number(totalBalance) * 100).toFixed(1) + : '0.0'; + + showTooltip(e, { + chain, + balance, + totalBalance, + timestamp, + startTime: simStartTime, + percentage + }, 'balance'); + }); + + hoverPath.addEventListener('mouseleave', hideTooltip); + }); +} + +function renderBalanceCurves(svg, viz, xScale, chains) { + // Compute balance curve from transfer/rebalance events for instant visual feedback + // Transfer start: origin +amount (user deposits collateral) + // Transfer complete: destination -amount (collateral released to recipient) + // Rebalance start: origin +amount (rebalancer deposits) + // Rebalance complete: destination -amount (collateral released) + + // Get initial balances from first snapshot + const initialSnapshot = viz.balanceTimeline[0]; + if (!initialSnapshot) return; + + // Build event-driven balance timeline + const balanceEvents = []; + + // Process transfers - instant balance changes at start and end + viz.transfers.forEach(t => { + const amount = BigInt(t.amount); + // Transfer START: origin receives deposit (+amount) + balanceEvents.push({ + timestamp: t.startTime, + chain: t.origin, + delta: amount, + }); + // Transfer COMPLETE: destination releases to recipient (-amount) + if (t.endTime && t.status === 'completed') { + balanceEvents.push({ + timestamp: t.endTime, + chain: t.destination, + delta: -amount, + }); + } + }); + + // Process rebalances - INVERSE of transfers (moving liquidity) + viz.rebalances.forEach(r => { + const amount = BigInt(r.amount); + // Rebalance START: origin SENDS funds away (-amount) + balanceEvents.push({ + timestamp: r.startTime, + chain: r.origin, + delta: -amount, + }); + // Rebalance COMPLETE: destination RECEIVES funds (+amount) + if (r.endTime && r.status === 'completed') { + balanceEvents.push({ + timestamp: r.endTime, + chain: r.destination, + delta: amount, + }); + } + }); + + // Sort events by timestamp + balanceEvents.sort((a, b) => a.timestamp - b.timestamp); + + // Build per-chain balance timelines + const chainBalances = {}; + const chainTimelines = {}; + chains.forEach(chain => { + chainBalances[chain] = BigInt(initialSnapshot.balances[chain] || '0'); + chainTimelines[chain] = [{ timestamp: viz.startTime, balance: chainBalances[chain] }]; + }); + + // Apply deltas to build timeline + balanceEvents.forEach(event => { + if (event.delta !== undefined) { + chainBalances[event.chain] += event.delta; + chainTimelines[event.chain].push({ + timestamp: event.timestamp, + balance: chainBalances[event.chain], + }); + } + }); + + // Add final state + chains.forEach(chain => { + chainTimelines[chain].push({ + timestamp: viz.endTime, + balance: chainBalances[chain], + }); + }); + + // Find global min/max for scaling + let minBalance = BigInt('999999999999999999999999999'); + let maxBalance = 0n; + chains.forEach(chain => { + chainTimelines[chain].forEach(pt => { + if (pt.balance > maxBalance) maxBalance = pt.balance; + if (pt.balance < minBalance) minBalance = pt.balance; + }); + }); + + if (maxBalance === 0n) return; + const balanceRange = maxBalance - minBalance || 1n; + + chains.forEach((chain, chainIndex) => { + const chainY = MARGIN.top + chainIndex * ROW_HEIGHT; + const curveTop = chainY + 15; + const curveBottom = chainY + ROW_HEIGHT - 15; + const curveHeight = curveBottom - curveTop; + const color = CHAIN_COLORS[chain] || TRANSFER_COLORS[chainIndex % TRANSFER_COLORS.length]; + + // Build path data from event-driven timeline + const timeline = chainTimelines[chain]; + const points = timeline.map(pt => { + const x = xScale(pt.timestamp); + const balance = pt.balance; + // Scale: high balance = top (low y), low balance = bottom (high y) + const normalizedY = balanceRange > 0n + ? Number((balance - minBalance) * BigInt(Math.floor(curveHeight * 100)) / balanceRange) / 100 + : curveHeight / 2; + const y = curveBottom - normalizedY; + return { x, y, balance }; + }); + + // Line path (step function for clearer visualization) + let pathD = 'M' + points[0].x + ',' + points[0].y; + for (let i = 1; i < points.length; i++) { + // Horizontal then vertical for step effect + pathD += ' L' + points[i].x + ',' + points[i-1].y; + pathD += ' L' + points[i].x + ',' + points[i].y; + } + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('class', 'balance-line'); + path.setAttribute('d', pathD); + path.setAttribute('stroke', color); + svg.appendChild(path); + + // Invisible hover area for tooltips (wider stroke for easier hovering) + const hoverPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + hoverPath.setAttribute('class', 'balance-hover-area'); + hoverPath.setAttribute('d', pathD); + hoverPath.setAttribute('data-chain', chain); + hoverPath.setAttribute('data-timeline', JSON.stringify(timeline.map(pt => ({ + timestamp: pt.timestamp, + balance: pt.balance.toString() + })))); + hoverPath.setAttribute('data-total-balance', maxBalance.toString()); + hoverPath.setAttribute('data-start-time', viz.startTime.toString()); + svg.appendChild(hoverPath); + + // Balance labels (start and end values) + const startBal = points[0].balance; + const endBal = points[points.length - 1].balance; + + const startLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + startLabel.setAttribute('class', 'balance-label'); + startLabel.setAttribute('x', points[0].x + 3); + startLabel.setAttribute('y', points[0].y - 3); + startLabel.textContent = formatBalanceShort(startBal); + startLabel.setAttribute('fill', color); + svg.appendChild(startLabel); + + const endLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + endLabel.setAttribute('class', 'balance-label'); + endLabel.setAttribute('x', points[points.length-1].x - 3); + endLabel.setAttribute('y', points[points.length-1].y - 3); + endLabel.setAttribute('text-anchor', 'end'); + endLabel.textContent = formatBalanceShort(endBal); + endLabel.setAttribute('fill', color); + svg.appendChild(endLabel); + }); +} + +function renderLegend(container, viz) { + const transferCount = viz.transfers.length; + + let transferItems = ''; + for (let i = 0; i < transferCount; i++) { + const t = viz.transfers[i]; + const color = TRANSFER_COLORS[i % TRANSFER_COLORS.length]; + transferItems += \` +
+
+ T\${i + 1}: \${t.origin} → \${t.destination} (\${formatBalanceShort(BigInt(t.amount))}) +
+ \`; + } + + let chainItems = ''; + viz.chains.forEach(chain => { + const color = CHAIN_COLORS[chain] || '#888'; + chainItems += \` +
+
+ \${chain} collateral balance +
+ \`; + }); + + container.innerHTML = \` +
+
Transfers
+ \${transferItems} +
+
+
Markers
+
+
+ Transfer start +
+
+
+ Transfer delivered +
+
+
+ Rebalance (R) +
+
+
+
Balance Curves
+ \${chainItems} +
+ \`; +} + +function renderConfig(container, config, chains) { + if (!config) { + container.style.display = 'none'; + return; + } + + // Scenario metadata section + let scenarioHtml = ''; + if (config.description) { + scenarioHtml += \` +
+ Description: + \${config.description} +
+ \`; + } + if (config.expectedBehavior) { + scenarioHtml += \` +
+ Expected Behavior: + \${config.expectedBehavior} +
+ \`; + } + if (config.transferCount !== undefined || config.duration !== undefined) { + scenarioHtml += \` +
+ \${config.transferCount !== undefined ? \`\${config.transferCount} transfers\` : ''} + \${config.duration !== undefined ? \`\${(config.duration / 1000).toFixed(1)}s duration\` : ''} +
+ \`; + } + + let targetHtml = ''; + if (config.targetWeights) { + chains.forEach(chain => { + const weight = config.targetWeights[chain] || 0; + const tolerance = config.tolerances?.[chain] || 0; + targetHtml += \` +
+ \${chain}: + \${weight}% ± \${tolerance}% +
+ \`; + }); + } + + let timingHtml = ''; + if (config.userTransferDelay !== undefined) { + timingHtml += \` +
+ User xfer: + \${config.userTransferDelay}ms +
+ \`; + } + if (config.bridgeDeliveryDelay !== undefined) { + timingHtml += \` +
+ Rebal bridge: + \${config.bridgeDeliveryDelay}ms +
+ \`; + } + if (config.rebalancerPollingFrequency !== undefined) { + timingHtml += \` +
+ Rebal poll: + \${config.rebalancerPollingFrequency}ms +
+ \`; + } + + let initialHtml = ''; + if (config.initialCollateral) { + chains.forEach(chain => { + const initial = config.initialCollateral[chain]; + if (initial) { + const formatted = formatBalanceShort(BigInt(initial)); + initialHtml += \` +
+ \${chain}: + \${formatted} tokens +
+ \`; + } + }); + } + + container.innerHTML = \` + \${scenarioHtml ? \` +
+
Scenario
+ \${scenarioHtml} +
+ \` : ''} + \${targetHtml ? \` +
+
Target Weights
+ \${targetHtml} +
+ \` : ''} + \${timingHtml ? \` +
+
Timing
+ \${timingHtml} +
+ \` : ''} + \${initialHtml ? \` +
+
Initial Collateral
+ \${initialHtml} +
+ \` : ''} + \`; +} + +let tooltipEl = null; + +function showTooltip(event, data, type) { + if (!tooltipEl) { + tooltipEl = document.createElement('div'); + tooltipEl.className = 'tooltip'; + document.body.appendChild(tooltipEl); + } + + let content = ''; + if (type === 'transfer') { + const status = data.status === 'completed' ? '✓ Delivered' : + data.status === 'failed' ? '✗ Failed' : '⏳ Pending'; + content = \` + Transfer T\${data._index + 1}
+ Route: \${data.origin} → \${data.destination}
+ Amount: \${formatAmount(data.amount)}
+ Latency: \${data.latency ? data.latency + 'ms' : 'N/A'}
+ Status: \${status} + \`; + } else if (type === 'rebalance') { + const status = data.status === 'completed' ? '✓ Delivered' : + data.status === 'failed' ? '✗ Failed' : '⏳ Pending'; + content = \` + Rebalance R\${data._index + 1}
+ Route: \${data.origin} → \${data.destination}
+ Amount: \${formatAmount(data.amount)}
+ Latency: \${data.latency ? data.latency + 'ms' : 'N/A'}
+ Status: \${status} + \`; + } else if (type === 'balance') { + content = \` + \${data.chain} Collateral
+ Balance: \${formatBalanceShort(data.balance)} tokens
+ Share: \${data.percentage}% of total
+ Time: \${((data.timestamp - data.startTime) / 1000).toFixed(2)}s + \`; + } + + tooltipEl.innerHTML = content; + tooltipEl.style.left = (event.pageX + 15) + 'px'; + tooltipEl.style.top = (event.pageY + 15) + 'px'; + tooltipEl.style.display = 'block'; +} + +function hideTooltip() { + if (tooltipEl) { + tooltipEl.style.display = 'none'; + } +} + +function showDetails(data, type) { + const panel = document.getElementById('details-panel'); + panel.classList.add('visible'); + + let html = ''; + if (type === 'transfer') { + html = \` +

Transfer T\${data._index + 1} Details

+
ID:\${data.id}
+
Route:\${data.origin} → \${data.destination}
+
Amount:\${formatAmount(data.amount)}
+
Start:\${new Date(data.startTime).toISOString()}
+
End:\${data.endTime ? new Date(data.endTime).toISOString() : 'N/A'}
+
Latency:\${data.latency ? data.latency + 'ms' : 'N/A'}
+
Status:\${data.status}
+ \`; + } else { + html = \` +

Rebalance R\${data._index + 1} Details

+
ID:\${data.id}
+
Route:\${data.origin} → \${data.destination}
+
Amount:\${formatAmount(data.amount)}
+
Start:\${new Date(data.startTime).toISOString()}
+
End:\${data.endTime ? new Date(data.endTime).toISOString() : 'N/A'}
+
Latency:\${data.latency ? data.latency + 'ms' : 'N/A'}
+
Gas Cost:\${formatAmount(data.gasCost)}
+
Status:\${data.status}
+ \`; + } + + panel.innerHTML = html; +} + +function formatAmount(amount) { + const val = BigInt(amount); + const eth = Number(val) / 1e18; + if (eth >= 1) return eth.toFixed(2) + ' tokens'; + if (eth >= 0.001) return (eth * 1000).toFixed(2) + ' mTokens'; + return val.toString() + ' wei'; +} + +function formatBalanceShort(balance) { + const eth = Number(balance) / 1e18; + if (eth >= 1000) return (eth / 1000).toFixed(1) + 'k'; + if (eth >= 1) return eth.toFixed(0); + return eth.toFixed(2); +} + `; +} diff --git a/typescript/rebalancer-sim/src/visualizer/index.ts b/typescript/rebalancer-sim/src/visualizer/index.ts new file mode 100644 index 00000000000..e0838ae61ff --- /dev/null +++ b/typescript/rebalancer-sim/src/visualizer/index.ts @@ -0,0 +1,7 @@ +export { generateTimelineHtml } from './HtmlTimelineGenerator.js'; +export type { + HtmlGeneratorOptions, + TimelineEvent, + VisualizationData, +} from '../types.js'; +export { toVisualizationData } from '../types.js'; diff --git a/typescript/rebalancer-sim/test/integration/full-simulation.test.ts b/typescript/rebalancer-sim/test/integration/full-simulation.test.ts new file mode 100644 index 00000000000..41cbaa11340 --- /dev/null +++ b/typescript/rebalancer-sim/test/integration/full-simulation.test.ts @@ -0,0 +1,241 @@ +/** + * REBALANCER SIMULATION TEST SUITE + * ================================ + * + * Single entry point for all rebalancer simulation testing. + * Supports both single rebalancer tests and multi-rebalancer comparisons. + * + * Configuration: + * - Set REBALANCERS env var to specify which rebalancers to test + * e.g., REBALANCERS=simple pnpm test (for single rebalancer) + * - Default: runs both SimpleRunner and ProductionRebalancerRunner + * + * Each scenario JSON includes: + * - description: What the scenario tests + * - expectedBehavior: Why it should behave a certain way + * - transfers: The traffic pattern + * - defaultTiming, defaultBridgeConfig, defaultStrategyConfig: Default configs + * - expectations: Assertions (minCompletionRate, shouldTriggerRebalancing, etc.) + */ +import { expect } from 'chai'; + +import { listScenarios } from '../../src/index.js'; +import { setupAnvilTestSuite } from '../utils/anvil.js'; +import { + cleanupRebalancers, + ensureResultsDir, + getEnabledRebalancers, + runScenarioWithRebalancers, +} from '../utils/simulation-helpers.js'; + +describe('Rebalancer Simulation', function () { + const anvilPort = 8545; + const anvil = setupAnvilTestSuite(this, anvilPort); + + before(async function () { + ensureResultsDir(); + + const scenarios = listScenarios(); + if (scenarios.length === 0) { + console.log('No scenarios found. Run: pnpm generate-scenarios'); + this.skip(); + } + console.log(`Found ${scenarios.length} scenarios: ${scenarios.join(', ')}`); + console.log( + `Testing rebalancers: ${getEnabledRebalancers().join(', ')} (set REBALANCERS env to change)`, + ); + }); + + // Cleanup rebalancers between tests (anvil restarts automatically via setupAnvilTestSuite) + afterEach(async function () { + await cleanupRebalancers(); + }); + + // ============================================================================ + // EXTREME IMBALANCE SCENARIOS + // ============================================================================ + + it('extreme-drain-chain1: should trigger rebalancing', async function () { + const { results, file } = await runScenarioWithRebalancers( + 'extreme-drain-chain1', + { anvilRpc: anvil.rpc }, + ); + + for (const result of results) { + // Skip assertions for NoOpRebalancer (baseline only) + if (result.rebalancerName === 'NoOpRebalancer') continue; + + if (file.expectations.minCompletionRate) { + expect(result.kpis.completionRate).to.be.greaterThanOrEqual( + file.expectations.minCompletionRate, + `${result.rebalancerName} should have min completion rate`, + ); + } + if (file.expectations.shouldTriggerRebalancing) { + expect(result.kpis.totalRebalances).to.be.greaterThan( + 0, + `${result.rebalancerName} should trigger rebalancing`, + ); + } + } + }); + + it('extreme-accumulate-chain1: should trigger rebalancing', async function () { + const { results, file } = await runScenarioWithRebalancers( + 'extreme-accumulate-chain1', + { anvilRpc: anvil.rpc }, + ); + + for (const result of results) { + // Skip assertions for NoOpRebalancer (baseline only) + if (result.rebalancerName === 'NoOpRebalancer') continue; + + if (file.expectations.minCompletionRate) { + expect(result.kpis.completionRate).to.be.greaterThanOrEqual( + file.expectations.minCompletionRate, + `${result.rebalancerName} should have min completion rate`, + ); + } + if (file.expectations.minRebalances) { + expect(result.kpis.totalRebalances).to.be.greaterThanOrEqual( + file.expectations.minRebalances, + `${result.rebalancerName} should trigger min rebalances`, + ); + } + } + }); + + it('large-unidirectional-to-chain1: large transfers', async function () { + const { results, file } = await runScenarioWithRebalancers( + 'large-unidirectional-to-chain1', + { anvilRpc: anvil.rpc }, + ); + + for (const result of results) { + // Skip assertions for NoOpRebalancer (baseline only) + if (result.rebalancerName === 'NoOpRebalancer') continue; + + if (file.expectations.minCompletionRate) { + expect(result.kpis.completionRate).to.be.greaterThanOrEqual( + file.expectations.minCompletionRate, + `${result.rebalancerName} should have min completion rate`, + ); + } + } + }); + + it('whale-transfers: massive single transfers', async function () { + const { results, file } = await runScenarioWithRebalancers( + 'whale-transfers', + { anvilRpc: anvil.rpc }, + ); + + for (const result of results) { + // Skip assertions for NoOpRebalancer (baseline only) + if (result.rebalancerName === 'NoOpRebalancer') continue; + + if (file.expectations.minCompletionRate) { + expect(result.kpis.completionRate).to.be.greaterThanOrEqual( + file.expectations.minCompletionRate, + `${result.rebalancerName} should have min completion rate`, + ); + } + } + }); + + // ============================================================================ + // BALANCED SCENARIOS + // ============================================================================ + + it('balanced-bidirectional: minimal rebalancing needed', async function () { + const { results, file } = await runScenarioWithRebalancers( + 'balanced-bidirectional', + { anvilRpc: anvil.rpc }, + ); + + // Filter out NoOpRebalancer for assertions + const activeResults = results.filter( + (r) => r.rebalancerName !== 'NoOpRebalancer', + ); + + for (const result of activeResults) { + if (file.expectations.minCompletionRate) { + expect(result.kpis.completionRate).to.be.greaterThanOrEqual( + file.expectations.minCompletionRate, + `${result.rebalancerName} should have min completion rate`, + ); + } + } + + // When comparing, completion rates should be similar + if (activeResults.length > 1) { + const completionDiff = Math.abs( + activeResults[0].kpis.completionRate - + activeResults[1].kpis.completionRate, + ); + expect(completionDiff).to.be.lessThan( + 0.1, + 'Completion rates should be within 10% of each other', + ); + } + }); + + // ============================================================================ + // RANDOM WITH HEADROOM + // ============================================================================ + + it('random-with-headroom: low latency with random traffic', async function () { + const { results, file } = await runScenarioWithRebalancers( + 'random-with-headroom', + { anvilRpc: anvil.rpc }, + ); + + for (const result of results) { + // Skip assertions for NoOpRebalancer (baseline only) + if (result.rebalancerName === 'NoOpRebalancer') continue; + + if (file.expectations.minCompletionRate) { + expect(result.kpis.completionRate).to.be.greaterThanOrEqual( + file.expectations.minCompletionRate, + `${result.rebalancerName} should have min completion rate`, + ); + } + // Key: p50 latency should be low with enough headroom + // Only assert for SimpleRunner - the CLI rebalancer may have different + // behavior due to more aggressive rebalancing strategies + if (result.rebalancerName === 'SimpleRebalancer') { + expect(result.kpis.p50Latency).to.be.lessThan( + 500, + `${result.rebalancerName} should have low p50 latency`, + ); + } + } + }); + + // ============================================================================ + // BASELINE (NO REBALANCER) + // ============================================================================ + + it('no-rebalancer baseline: shows transfer failures without rebalancing (report only)', async function () { + // Run with NoOpRebalancer to demonstrate what happens without active rebalancing + // This is report-only - no assertions, just generates visualization + const { results } = await runScenarioWithRebalancers( + 'extreme-drain-chain1', + { + anvilRpc: anvil.rpc, + rebalancerTypes: ['noop'], + }, + ); + + // Report only - log results but don't assert + const result = results[0]; + console.log(`\n NO-REBALANCER BASELINE (report only):`); + console.log( + ` Without rebalancing, completion rate: ${(result.kpis.completionRate * 100).toFixed(1)}%`, + ); + console.log( + ` Failed transfers: ${result.kpis.totalTransfers - result.kpis.completedTransfers}`, + ); + console.log(` Rebalances: ${result.kpis.totalRebalances} (expected: 0)`); + }); +}); diff --git a/typescript/rebalancer-sim/test/integration/harness-setup.test.ts b/typescript/rebalancer-sim/test/integration/harness-setup.test.ts new file mode 100644 index 00000000000..66edf883f5d --- /dev/null +++ b/typescript/rebalancer-sim/test/integration/harness-setup.test.ts @@ -0,0 +1,92 @@ +/** + * MULTI-DOMAIN DEPLOYMENT TEST SUITE + * =================================== + * + * These tests verify the simulation deployment infrastructure works correctly. + * + * ARCHITECTURE: + * - Single Anvil instance simulates multiple "chains" via domain IDs + * - Each domain has its own: Mailbox, WarpToken, CollateralToken, Bridge + * - All domains share the same RPC endpoint (http://localhost:PORT) + * - Domain IDs (1000, 2000, 3000) distinguish chains, not separate processes + * + * WHY SINGLE ANVIL? + * - Faster test execution (no multi-process coordination) + * - Simpler state management (single blockchain state) + * - Sufficient for testing rebalancer logic (doesn't need real cross-chain) + * + * DEPLOYMENT COMPONENTS PER DOMAIN: + * - CollateralToken: ERC20 that users deposit to send cross-chain + * - WarpToken: HypERC20Collateral that holds collateral and mints/burns + * - Mailbox: MockMailbox for instant message delivery (user transfers) + * - Bridge: MockValueTransferBridge for delayed delivery (rebalancer transfers) + */ +import { expect } from 'chai'; +import { ethers } from 'ethers'; + +import { toWei } from '@hyperlane-xyz/utils'; + +import { + deployMultiDomainSimulation, + getWarpTokenBalance, +} from '../../src/index.js'; +import { + ANVIL_DEPLOYER_KEY, + DEFAULT_SIMULATED_CHAINS, +} from '../../src/types.js'; +import { setupAnvilTestSuite } from '../utils/anvil.js'; + +describe('Multi-Domain Deployment', function () { + const anvilPort = 8546; // Use different port to avoid conflict with other tests + const anvil = setupAnvilTestSuite(this, anvilPort); + let provider: ethers.providers.JsonRpcProvider; + + before(async () => { + provider = new ethers.providers.JsonRpcProvider(anvil.rpc); + }); + + /** + * TEST: Multi-domain deployment + * ============================= + * + * WHAT IT TESTS: + * Verifies that deployMultiDomainSimulation correctly deploys all + * required contracts for each simulated chain. + * + * VERIFICATION: + * - 3 domains created (chain1, chain2, chain3) + * - Each domain has valid addresses for all contracts + * - Each warp token has correct initial collateral balance (100 tokens) + * + * WHY IT MATTERS: + * This is the foundation for all other tests. If deployment fails, + * no simulation can run. + */ + it('should deploy multi-domain simulation', async () => { + const result = await deployMultiDomainSimulation({ + anvilRpc: anvil.rpc, + deployerKey: ANVIL_DEPLOYER_KEY, + chains: DEFAULT_SIMULATED_CHAINS, + initialCollateralBalance: BigInt(toWei(100)), + }); + + // Verify all domains deployed + expect(Object.keys(result.domains).length).to.equal(3); + + for (const [chainName, domain] of Object.entries(result.domains)) { + expect(domain.chainName).to.equal(chainName); + expect(domain.mailbox).to.match(/^0x[a-fA-F0-9]{40}$/); + expect(domain.warpToken).to.match(/^0x[a-fA-F0-9]{40}$/); + expect(domain.collateralToken).to.match(/^0x[a-fA-F0-9]{40}$/); + expect(domain.bridge).to.match(/^0x[a-fA-F0-9]{40}$/); + + // Verify balances + const balance = await getWarpTokenBalance( + provider, + domain.warpToken, + domain.collateralToken, + ); + expect(balance.toString()).to.equal(toWei(100)); + } + }); +}); diff --git a/typescript/rebalancer-sim/test/integration/inflight-guard.test.ts b/typescript/rebalancer-sim/test/integration/inflight-guard.test.ts new file mode 100644 index 00000000000..3294cc1aff7 --- /dev/null +++ b/typescript/rebalancer-sim/test/integration/inflight-guard.test.ts @@ -0,0 +1,94 @@ +/** + * INFLIGHT GUARD TEST SUITE + * ========================= + * + * This test suite demonstrates the "inflight guard" problem in rebalancing systems. + * + * THE PROBLEM: + * When a rebalancer sends tokens via a bridge, there's a delay before delivery. + * During this delay, the rebalancer's next poll still sees the OLD balances + * (not accounting for pending transfers). Without tracking "inflight" transfers, + * the rebalancer may send redundant transfers, causing over-correction. + * + * EXAMPLE TIMELINE (without inflight guard): + * ``` + * Time 0ms: Rebalancer polls. chain1=150, chain2=100. Sends 25 tokens. + * Time 200ms: Rebalancer polls. chain1=125, chain2=100. Still imbalanced! Sends 25 more. + * Time 400ms: Rebalancer polls. chain1=100, chain2=100. Sends 25 more. + * Time 600ms: Rebalancer polls. chain1=75, chain2=100. NOW chain1 is low! + * ... + * Time 3000ms: First transfer finally delivers. chain2 receives 25 tokens. + * ... + * Final state: chain2 has 300+ tokens instead of target 125. + * ``` + * + * THE SOLUTION (inflight guard): + * Track pending transfers and include them in balance calculations: + * ``` + * effectiveBalance = onChainBalance + inflightIncoming - inflightOutgoing + * ``` + * + * This test PROVES the problem exists by demonstrating over-rebalancing + * when the inflight guard is not implemented. + */ +import { setupAnvilTestSuite } from '../utils/anvil.js'; +import { + cleanupRebalancers, + ensureResultsDir, + getEnabledRebalancers, + runScenarioWithRebalancers, +} from '../utils/simulation-helpers.js'; + +describe('Inflight Guard Behavior', function () { + const anvilPort = 8547; + const anvil = setupAnvilTestSuite(this, anvilPort); + + before(function () { + ensureResultsDir(); + console.log( + `Testing rebalancers: ${getEnabledRebalancers().join(', ')} (set REBALANCERS env to change)`, + ); + }); + + afterEach(async function () { + await cleanupRebalancers(); + }); + + /** + * TEST: Rebalancer behavior with slow bridge and fast polling + * =========================================================== + * + * WHAT IT TESTS: + * Demonstrates rebalancer behavior when bridge delay >> polling interval. + * Without inflight tracking, multiple redundant transfers are sent. + * With inflight tracking, only necessary transfers are sent. + * + * TEST SETUP: + * - 2 chains: chain1=150 tokens, chain2=100 tokens (imbalanced) + * - Target balance: 125 tokens each (total 250 / 2) + * - Required correction: Send 25 tokens from chain1 → chain2 + * - Bridge delay: 3000ms (intentionally slow) + * - Rebalancer polling: 200ms (intentionally fast) + * - Ratio: 15 polls happen before first delivery + * + * WHY THESE TIMINGS MATTER: + * - Bridge delay >> polling interval creates the race condition + * - Each poll sees "stale" on-chain balances + * - Without inflight tracking, each poll thinks correction is still needed + * + * EXPECTED BEHAVIOR: + * - SimpleRunner (no inflight guard): Multiple rebalances, over-correction + * - ProductionRebalancerRunner (has ActionTracker): 1-2 rebalances, correct behavior + */ + it('inflight-guard: demonstrates slow bridge + fast polling behavior', async function () { + // This test takes longer due to 3s bridge delays + this.timeout(60000); + + await runScenarioWithRebalancers('inflight-guard', { + anvilRpc: anvil.rpc, + }); + + // No assertions for now - just generating reports + // The HTML timeline and comparison table show the behavioral difference + }); +}); diff --git a/typescript/rebalancer-sim/test/scenarios/unidirectional.test.ts b/typescript/rebalancer-sim/test/scenarios/unidirectional.test.ts new file mode 100644 index 00000000000..0fc6ae9a606 --- /dev/null +++ b/typescript/rebalancer-sim/test/scenarios/unidirectional.test.ts @@ -0,0 +1,303 @@ +/** + * SCENARIO GENERATOR TEST SUITE + * ============================= + * + * These tests verify the ScenarioGenerator creates valid transfer scenarios + * for simulation testing. + * + * SCENARIO TYPES: + * + * 1. unidirectionalFlow: + * All transfers go from one chain to another. + * Use case: Testing sustained liquidity drain on destination chain. + * Example: All users sending from Ethereum to Arbitrum. + * + * 2. randomTraffic: + * Transfers randomly distributed across all chain pairs. + * Use case: Testing balanced/organic traffic patterns. + * Distributions: uniform (equal probability) or poisson (realistic bursts). + * + * 3. imbalanceScenario: + * Weighted traffic favoring one chain as destination. + * Use case: Testing rebalancer response to skewed traffic. + * Example: 90% of transfers going TO one popular chain. + * + * 4. surgeScenario: + * Baseline traffic with sudden spike in volume. + * Use case: Testing rebalancer under traffic bursts. + * Example: NFT mint causing sudden transfer surge. + * + * SCENARIO STRUCTURE: + * ```typescript + * interface TransferScenario { + * name: string; // Descriptive name + * duration: number; // Total scenario duration (ms) + * chains: string[]; // Participating chains + * transfers: Transfer[]; // Ordered list of transfers + * } + * + * interface Transfer { + * id: string; // Unique identifier + * timestamp: number; // When to execute (ms from start) + * origin: string; // Source chain + * destination: string; // Target chain + * amount: bigint; // Transfer amount in wei + * user: string; // User address (for tracking) + * } + * ``` + */ +import { expect } from 'chai'; + +import { toWei } from '@hyperlane-xyz/utils'; + +import { ScenarioGenerator } from '../../src/index.js'; + +describe('ScenarioGenerator', () => { + /** + * UNIDIRECTIONAL FLOW TESTS + * ------------------------- + * Tests for scenarios where all transfers go in one direction. + * This is the simplest scenario type and creates maximum imbalance. + */ + describe('unidirectionalFlow', () => { + it('should generate correct number of transfers', () => { + const scenario = ScenarioGenerator.unidirectionalFlow({ + origin: 'chain1', + destination: 'chain2', + transferCount: 100, + duration: 60000, + amount: BigInt(toWei(1)), + }); + + expect(scenario.transfers.length).to.equal(100); + expect(scenario.chains).to.deep.equal(['chain1', 'chain2']); + }); + + it('should have transfers in chronological order', () => { + const scenario = ScenarioGenerator.unidirectionalFlow({ + origin: 'chain1', + destination: 'chain2', + transferCount: 50, + duration: 30000, + amount: BigInt(toWei(1)), + }); + + for (let i = 1; i < scenario.transfers.length; i++) { + expect(scenario.transfers[i].timestamp).to.be.at.least( + scenario.transfers[i - 1].timestamp, + ); + } + }); + + it('should use amount range correctly', () => { + const minAmount = BigInt(toWei(1)); + const maxAmount = BigInt(toWei(10)); + + const scenario = ScenarioGenerator.unidirectionalFlow({ + origin: 'chain1', + destination: 'chain2', + transferCount: 100, + duration: 60000, + amount: [minAmount, maxAmount], + }); + + for (const transfer of scenario.transfers) { + expect(transfer.amount >= minAmount).to.be.true; + expect(transfer.amount <= maxAmount).to.be.true; + } + }); + + it('should set all transfers to same origin/destination', () => { + const scenario = ScenarioGenerator.unidirectionalFlow({ + origin: 'chainA', + destination: 'chainB', + transferCount: 20, + duration: 10000, + amount: BigInt(toWei(1)), + }); + + for (const transfer of scenario.transfers) { + expect(transfer.origin).to.equal('chainA'); + expect(transfer.destination).to.equal('chainB'); + } + }); + }); + + /** + * RANDOM TRAFFIC TESTS + * -------------------- + * Tests for scenarios with randomly distributed traffic. + * Should naturally balance out over large sample sizes. + * Tests both uniform and poisson distributions. + */ + describe('randomTraffic', () => { + it('should generate correct number of transfers', () => { + const scenario = ScenarioGenerator.randomTraffic({ + chains: ['chain1', 'chain2', 'chain3'], + transferCount: 100, + duration: 60000, + amountRange: [BigInt(toWei(1)), BigInt(toWei(10))], + }); + + expect(scenario.transfers.length).to.equal(100); + expect(scenario.chains).to.deep.equal(['chain1', 'chain2', 'chain3']); + }); + + it('should use all chains', () => { + const chains = ['chain1', 'chain2', 'chain3']; + const scenario = ScenarioGenerator.randomTraffic({ + chains, + transferCount: 1000, + duration: 60000, + amountRange: [BigInt(toWei(1)), BigInt(toWei(10))], + }); + + const usedOrigins = new Set(scenario.transfers.map((t) => t.origin)); + const usedDestinations = new Set( + scenario.transfers.map((t) => t.destination), + ); + + // With 1000 transfers across 3 chains, all should be used + for (const chain of chains) { + expect(usedOrigins.has(chain)).to.be.true; + expect(usedDestinations.has(chain)).to.be.true; + } + }); + + it('should never have same origin and destination', () => { + const scenario = ScenarioGenerator.randomTraffic({ + chains: ['chain1', 'chain2', 'chain3'], + transferCount: 500, + duration: 60000, + amountRange: [BigInt(toWei(1)), BigInt(toWei(10))], + }); + + for (const transfer of scenario.transfers) { + expect(transfer.origin).to.not.equal(transfer.destination); + } + }); + + it('should throw for single chain', () => { + expect(() => { + ScenarioGenerator.randomTraffic({ + chains: ['chain1'], + transferCount: 10, + duration: 10000, + amountRange: [BigInt(1), BigInt(10)], + }); + }).to.throw('Random traffic requires at least 2 chains'); + }); + }); + + /** + * IMBALANCE SCENARIO TESTS + * ------------------------ + * Tests for scenarios that deliberately create imbalanced traffic. + * Used to verify rebalancer triggers at correct thresholds. + * The 'heavyRatio' parameter controls what % of transfers go TO the heavy chain. + */ + describe('imbalanceScenario', () => { + it('should create imbalanced traffic', () => { + const scenario = ScenarioGenerator.imbalanceScenario( + ['chain1', 'chain2', 'chain3'], + 'chain1', // heavy chain + 1000, + 60000, + [BigInt(toWei(1)), BigInt(toWei(5))], + 0.9, // 90% to heavy chain + ); + + const toHeavy = scenario.transfers.filter( + (t) => t.destination === 'chain1', + ).length; + const ratio = toHeavy / scenario.transfers.length; + + // Should be close to 90% (allow some variance due to randomness) + expect(ratio).to.be.greaterThan(0.85); + expect(ratio).to.be.lessThan(0.95); + }); + }); + + /** + * SERIALIZATION TESTS + * ------------------- + * Tests for saving/loading scenarios to/from JSON files. + * This enables: + * - Sharing scenarios across test runs + * - Storing historic scenarios fetched from explorers + * - Reproducible testing with identical scenarios + * + * IMPORTANT: BigInt amounts are serialized as strings to preserve precision. + */ + describe('serialization', () => { + it('should serialize and deserialize correctly', () => { + const original = ScenarioGenerator.unidirectionalFlow({ + origin: 'chain1', + destination: 'chain2', + transferCount: 10, + duration: 10000, + amount: BigInt(toWei(5)), + }); + + const serialized = ScenarioGenerator.serialize(original); + const deserialized = ScenarioGenerator.deserialize(serialized); + + expect(deserialized.name).to.equal(original.name); + expect(deserialized.duration).to.equal(original.duration); + expect(deserialized.chains).to.deep.equal(original.chains); + expect(deserialized.transfers.length).to.equal(original.transfers.length); + + for (let i = 0; i < original.transfers.length; i++) { + expect(deserialized.transfers[i].id).to.equal(original.transfers[i].id); + expect(deserialized.transfers[i].amount.toString()).to.equal( + original.transfers[i].amount.toString(), + ); + } + }); + }); + + /** + * VALIDATION TESTS + * ---------------- + * Tests for scenario validation logic. + * Validation catches: + * - Unknown chains in transfers + * - Invalid amounts (zero, negative) + * - Out-of-order timestamps + * - Same origin/destination + * + * Run validation before simulation to catch scenario bugs early. + */ + describe('validate', () => { + it('should validate correct scenario', () => { + const scenario = ScenarioGenerator.randomTraffic({ + chains: ['chain1', 'chain2'], + transferCount: 10, + duration: 10000, + amountRange: [BigInt(1), BigInt(10)], + }); + + const result = ScenarioGenerator.validate(scenario); + expect(result.valid).to.be.true; + expect(result.errors).to.be.empty; + }); + + it('should detect unknown chains', () => { + const scenario = ScenarioGenerator.unidirectionalFlow({ + origin: 'chain1', + destination: 'chain2', + transferCount: 5, + duration: 5000, + amount: BigInt(1), + }); + + // Manually corrupt the chains list + scenario.chains = ['chain1']; + + const result = ScenarioGenerator.validate(scenario); + expect(result.valid).to.be.false; + expect(result.errors.some((e) => e.includes('Unknown destination chain'))) + .to.be.true; + }); + }); +}); diff --git a/typescript/rebalancer-sim/test/utils/anvil.ts b/typescript/rebalancer-sim/test/utils/anvil.ts new file mode 100644 index 00000000000..5495086f7ec --- /dev/null +++ b/typescript/rebalancer-sim/test/utils/anvil.ts @@ -0,0 +1,96 @@ +import { + GenericContainer, + type StartedTestContainer, + Wait, +} from 'testcontainers'; + +import { retryAsync } from '@hyperlane-xyz/utils'; + +const DEFAULT_ANVIL_PORT = 8545; +const DEFAULT_CHAIN_ID = 31337; + +/** + * Start an Anvil container using testcontainers. + * Uses the same pattern as CLI e2e tests for consistency. + */ +export async function startAnvilContainer( + port: number = DEFAULT_ANVIL_PORT, + chainId: number = DEFAULT_CHAIN_ID, +): Promise { + return retryAsync( + () => + new GenericContainer('ghcr.io/foundry-rs/foundry:latest') + .withEntrypoint([ + 'anvil', + '--host', + '0.0.0.0', + '-p', + port.toString(), + '--chain-id', + chainId.toString(), + ]) + .withExposedPorts({ + container: port, + host: port, + }) + .withWaitStrategy(Wait.forLogMessage(/Listening on/)) + .start(), + 3, // maxRetries + 5000, // baseRetryMs + ); +} + +/** + * Setup function for Mocha tests that require Anvil. + * Starts a fresh Anvil container for EACH TEST to ensure complete isolation. + * + * Uses testcontainers for: + * - No local anvil installation required + * - Automatic container cleanup (even on crashes) + * - Retry logic for CI reliability + * - Consistent behavior across local/CI environments + * + * Usage: + * ```typescript + * describe('My Tests', function() { + * const anvil = setupAnvilTestSuite(this, 8545); + * + * it('test case', async () => { + * const rpc = anvil.rpc; // http://127.0.0.1:8545 + * }); + * }); + * ``` + */ +export function setupAnvilTestSuite( + suite: Mocha.Suite, + port: number = DEFAULT_ANVIL_PORT, + chainId: number = DEFAULT_CHAIN_ID, +): { rpc: string } { + const state: { rpc: string; container: StartedTestContainer | null } = { + rpc: `http://127.0.0.1:${port}`, + container: null, + }; + + suite.timeout(180000); // 3 minutes per test + + // Start fresh anvil container before EACH test + suite.beforeEach(async function () { + // Stop any existing container + if (state.container) { + await state.container.stop(); + state.container = null; + } + + state.container = await startAnvilContainer(port, chainId); + }); + + // Stop container after EACH test for clean slate + suite.afterEach(async function () { + if (state.container) { + await state.container.stop(); + state.container = null; + } + }); + + return state; +} diff --git a/typescript/rebalancer-sim/test/utils/simulation-helpers.ts b/typescript/rebalancer-sim/test/utils/simulation-helpers.ts new file mode 100644 index 00000000000..9324d3b757e --- /dev/null +++ b/typescript/rebalancer-sim/test/utils/simulation-helpers.ts @@ -0,0 +1,410 @@ +import { expect } from 'chai'; +import { ethers } from 'ethers'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +import { + NoOpRebalancer, + ProductionRebalancerRunner, + SimpleRunner, + SimulationEngine, + cleanupProductionRebalancer, + cleanupSimpleRunner, + deployMultiDomainSimulation, + generateTimelineHtml, + getWarpTokenBalance, + loadScenario, + loadScenarioFile, +} from '../../src/index.js'; +import type { + ChainStrategyConfig, + IRebalancerRunner, + ScenarioFile, + SimulationResult, +} from '../../src/index.js'; +import { ANVIL_DEPLOYER_KEY } from '../../src/types.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const RESULTS_DIR = path.join(__dirname, '..', '..', 'results'); + +export type RebalancerType = 'simple' | 'production' | 'noop'; + +export function getEnabledRebalancers(): RebalancerType[] { + const REBALANCER_ENV = process.env.REBALANCERS || 'simple,production'; + const enabled = REBALANCER_ENV.split(',') + .map((r) => r.trim().toLowerCase()) + .filter( + (r): r is RebalancerType => + r === 'simple' || r === 'production' || r === 'noop', + ); + + if (enabled.length === 0) { + throw new Error( + `No valid rebalancers in REBALANCERS="${REBALANCER_ENV}". Use "simple", "production", "noop", or combinations.`, + ); + } + return enabled; +} + +export function createRebalancer(type: RebalancerType): IRebalancerRunner { + switch (type) { + case 'simple': + return new SimpleRunner(); + case 'production': + return new ProductionRebalancerRunner(); + case 'noop': + return new NoOpRebalancer(); + } +} + +export async function cleanupRebalancers(): Promise { + await cleanupSimpleRunner(); + await cleanupProductionRebalancer(); +} + +export function ensureResultsDir(): void { + if (!fs.existsSync(RESULTS_DIR)) { + fs.mkdirSync(RESULTS_DIR, { recursive: true }); + } +} + +export interface ScenarioRunOptions { + anvilRpc: string; + rebalancerTypes?: RebalancerType[]; +} + +export interface ScenarioRunResult { + results: SimulationResult[]; + file: ScenarioFile; + comparison?: { + bestCompletionRate: string; + bestLatency: string; + }; +} + +/** + * Run a scenario with specified rebalancers. + * If multiple rebalancers, runs each and compares results. + */ +export async function runScenarioWithRebalancers( + scenarioName: string, + options: ScenarioRunOptions, +): Promise { + const rebalancerTypes = options.rebalancerTypes ?? getEnabledRebalancers(); + const file = loadScenarioFile(scenarioName); + const scenario = loadScenario(scenarioName); + + console.log(`\n${'='.repeat(60)}`); + console.log(`SCENARIO: ${file.name}`); + console.log(`${'='.repeat(60)}`); + console.log(` ${file.description}`); + console.log(` Transfers: ${scenario.transfers.length}`); + console.log(` Chains: ${scenario.chains.join(', ')}`); + console.log(` Rebalancers: ${rebalancerTypes.join(', ')}`); + + const chainConfigs = file.chains.map((chainName, index) => ({ + chainName, + domainId: 1000 + index * 1000, + })); + + const results: SimulationResult[] = []; + + for (const rebalancerType of rebalancerTypes) { + const rebalancer = createRebalancer(rebalancerType); + + if (rebalancerTypes.length > 1) { + console.log(`\n${'─'.repeat(50)}`); + console.log(`Running with: ${rebalancer.name}`); + console.log(`${'─'.repeat(50)}`); + } + + // Deploy fresh contracts for each rebalancer run + const deployment = await deployMultiDomainSimulation({ + anvilRpc: options.anvilRpc, + deployerKey: ANVIL_DEPLOYER_KEY, + chains: chainConfigs, + initialCollateralBalance: BigInt(file.defaultInitialCollateral), + }); + + // Apply initial imbalance if specified + if (file.initialImbalance) { + const { ERC20Test__factory } = await import('@hyperlane-xyz/core'); + const provider = new ethers.providers.JsonRpcProvider(options.anvilRpc); + const deployer = new ethers.Wallet(ANVIL_DEPLOYER_KEY, provider); + + for (const [chainName, extraAmount] of Object.entries( + file.initialImbalance, + )) { + const domain = deployment.domains[chainName]; + if (domain) { + const token = ERC20Test__factory.connect( + domain.collateralToken, + deployer, + ); + await token.mintTo(domain.warpToken, extraAmount); + console.log( + ` Applied imbalance: +${ethers.utils.formatEther(extraAmount)} tokens to ${chainName}`, + ); + } + } + // Cleanup provider after applying imbalance + provider.removeAllListeners(); + provider.polling = false; + } + + const strategyConfig: { + type: 'weighted' | 'minAmount'; + chains: Record; + } = { + type: file.defaultStrategyConfig.type, + chains: {}, + }; + for (const [chainName, chainConfig] of Object.entries( + file.defaultStrategyConfig.chains, + )) { + strategyConfig.chains[chainName] = { + ...chainConfig, + bridge: deployment.domains[chainName].bridge, + }; + } + + const engine = new SimulationEngine(deployment); + const result = await engine.runSimulation( + scenario, + rebalancer, + file.defaultBridgeConfig, + file.defaultTiming, + strategyConfig, + ); + + results.push(result); + + // Collect final balances + const balanceProvider = new ethers.providers.JsonRpcProvider( + options.anvilRpc, + ); + const finalBalances: Record = {}; + for (const [name, domain] of Object.entries(deployment.domains)) { + const balance = await getWarpTokenBalance( + balanceProvider, + domain.warpToken, + domain.collateralToken, + ); + finalBalances[name] = ethers.utils.formatEther(balance.toString()); + } + // Clean up provider + balanceProvider.removeAllListeners(); + balanceProvider.polling = false; + + printResults(result, finalBalances, file); + } + + // Generate comparison if multiple rebalancers + let comparison: + | { bestCompletionRate: string; bestLatency: string } + | undefined; + if (results.length > 1) { + comparison = printComparison(results); + } + + // Save results + saveResults(scenarioName, file, results, comparison); + + return { results, file, comparison }; +} + +export function printResults( + result: SimulationResult, + finalBalances: Record, + file: ScenarioFile, +): void { + console.log(`\n Results for ${result.rebalancerName}:`); + console.log( + ` Completion: ${result.kpis.completedTransfers}/${result.kpis.totalTransfers} (${(result.kpis.completionRate * 100).toFixed(1)}%)`, + ); + console.log( + ` Latency: avg=${result.kpis.averageLatency.toFixed(0)}ms, p50=${result.kpis.p50Latency}ms, p95=${result.kpis.p95Latency}ms`, + ); + console.log( + ` Rebalances: ${result.kpis.totalRebalances} (${ethers.utils.formatEther(result.kpis.rebalanceVolume.toString())} tokens)`, + ); + + console.log(` Final Balances:`); + const initialCollateral = ethers.utils.formatEther( + file.defaultInitialCollateral, + ); + for (const [name, balance] of Object.entries(finalBalances)) { + const extraFromImbalance = file.initialImbalance?.[name] + ? parseFloat(ethers.utils.formatEther(file.initialImbalance[name])) + : 0; + const initialForChain = parseFloat(initialCollateral) + extraFromImbalance; + const change = parseFloat(balance) - initialForChain; + const changeStr = change >= 0 ? `+${change.toFixed(2)}` : change.toFixed(2); + console.log(` ${name}: ${balance} (${changeStr})`); + } +} + +export function printComparison(results: SimulationResult[]): { + bestCompletionRate: string; + bestLatency: string; +} { + console.log(`\n${'='.repeat(60)}`); + console.log('COMPARISON RESULTS'); + console.log(`${'='.repeat(60)}`); + + // Print table header + const headers = ['Metric', ...results.map((r) => r.rebalancerName)]; + const colWidths = headers.map((h) => Math.max(h.length, 15)); + + console.log( + '\n| ' + headers.map((h, i) => h.padEnd(colWidths[i])).join(' | ') + ' |', + ); + console.log('|' + colWidths.map((w) => '-'.repeat(w + 2)).join('|') + '|'); + + // Print rows + const rows = [ + [ + 'Completion %', + ...results.map((r) => `${(r.kpis.completionRate * 100).toFixed(1)}%`), + ], + [ + 'Avg Latency', + ...results.map((r) => `${r.kpis.averageLatency.toFixed(0)}ms`), + ], + ['P50 Latency', ...results.map((r) => `${r.kpis.p50Latency}ms`)], + ['P95 Latency', ...results.map((r) => `${r.kpis.p95Latency}ms`)], + ['Rebalances', ...results.map((r) => String(r.kpis.totalRebalances))], + [ + 'Rebal Volume', + ...results.map((r) => + ethers.utils.formatEther(r.kpis.rebalanceVolume.toString()), + ), + ], + ]; + + for (const row of rows) { + console.log( + '| ' + row.map((cell, i) => cell.padEnd(colWidths[i])).join(' | ') + ' |', + ); + } + + // Determine winners + const bestCompletion = results.reduce((best, r) => + r.kpis.completionRate > best.kpis.completionRate ? r : best, + ); + const bestLatency = results.reduce((best, r) => + r.kpis.averageLatency < best.kpis.averageLatency ? r : best, + ); + + console.log('\nWinners:'); + console.log(` Best Completion: ${bestCompletion.rebalancerName}`); + console.log(` Best Latency: ${bestLatency.rebalancerName}`); + + return { + bestCompletionRate: bestCompletion.rebalancerName, + bestLatency: bestLatency.rebalancerName, + }; +} + +export function saveResults( + scenarioName: string, + file: ScenarioFile, + results: SimulationResult[], + comparison?: { bestCompletionRate: string; bestLatency: string }, +): void { + ensureResultsDir(); + + const output: any = { + scenario: scenarioName, + timestamp: new Date().toISOString(), + description: file.description, + expectedBehavior: file.expectedBehavior, + expectations: file.expectations, + results: results.map((r) => ({ + rebalancerName: r.rebalancerName, + kpis: { + totalTransfers: r.kpis.totalTransfers, + completedTransfers: r.kpis.completedTransfers, + completionRate: r.kpis.completionRate, + averageLatency: r.kpis.averageLatency, + p50Latency: r.kpis.p50Latency, + p95Latency: r.kpis.p95Latency, + p99Latency: r.kpis.p99Latency, + totalRebalances: r.kpis.totalRebalances, + rebalanceVolume: r.kpis.rebalanceVolume.toString(), + }, + })), + config: { + timing: file.defaultTiming, + initialCollateral: file.defaultInitialCollateral, + initialImbalance: file.initialImbalance, + }, + }; + + if (comparison) { + output.comparison = comparison; + } + + // Save JSON results + const jsonPath = path.join(RESULTS_DIR, `${scenarioName}.json`); + fs.writeFileSync(jsonPath, JSON.stringify(output, null, 2)); + + // Generate HTML timeline visualization + const firstOrigin = Object.keys(file.defaultBridgeConfig)[0]; + const firstDest = firstOrigin + ? Object.keys(file.defaultBridgeConfig[firstOrigin])[0] + : undefined; + const bridgeDelay = + firstOrigin && firstDest + ? file.defaultBridgeConfig[firstOrigin][firstDest].deliveryDelay + : 0; + + const vizConfig: Record = { + scenarioName: file.name, + description: file.description, + expectedBehavior: file.expectedBehavior, + transferCount: file.transfers.length, + duration: file.duration, + bridgeDeliveryDelay: bridgeDelay, + rebalancerPollingFrequency: file.defaultTiming.rebalancerPollingFrequency, + userTransferDelay: file.defaultTiming.userTransferDeliveryDelay, + }; + + if (file.defaultStrategyConfig.type === 'weighted') { + vizConfig.targetWeights = {}; + vizConfig.tolerances = {}; + for (const [chain, chainConfig] of Object.entries( + file.defaultStrategyConfig.chains, + )) { + if (chainConfig.weighted) { + vizConfig.targetWeights[chain] = Math.round( + parseFloat(chainConfig.weighted.weight) * 100, + ); + vizConfig.tolerances[chain] = Math.round( + parseFloat(chainConfig.weighted.tolerance) * 100, + ); + } + } + } + + vizConfig.initialCollateral = {}; + for (const chain of file.chains) { + const base = parseFloat( + ethers.utils.formatEther(file.defaultInitialCollateral), + ); + const extra = file.initialImbalance?.[chain] + ? parseFloat(ethers.utils.formatEther(file.initialImbalance[chain])) + : 0; + vizConfig.initialCollateral[chain] = (base + extra).toString(); + } + + const html = generateTimelineHtml( + results, + { title: `${file.name}: ${file.description}` }, + vizConfig, + ); + const htmlPath = path.join(RESULTS_DIR, `${scenarioName}.html`); + fs.writeFileSync(htmlPath, html); + console.log(` Timeline saved to: ${htmlPath}`); +} diff --git a/typescript/rebalancer-sim/tsconfig.json b/typescript/rebalancer-sim/tsconfig.json new file mode 100644 index 00000000000..9005365a9b7 --- /dev/null +++ b/typescript/rebalancer-sim/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@hyperlane-xyz/tsconfig/tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./src/**/*"] +}