From 76aa419320cc1f255a8ec7235d82fae388eb99ef Mon Sep 17 00:00:00 2001 From: defiQUG Date: Tue, 7 Apr 2026 23:40:52 -0700 Subject: [PATCH] feat: bridges, PMM, flash workflow, token-aggregation, and deployment docs - CCIP/trustless bridge contracts, GRU tokens, DEX/PMM tests, reserve vault. - Token-aggregation service routes, planner, chain config, relay env templates. - Config snapshots and multi-chain deployment markdown updates. - gitignore services/btc-intake/dist/ (tsc output); do not track dist. Run forge build && forge test before deploy (large solc graph). Made-with: Cursor --- .gitignore | 3 + COMPLETE_MULTI_CHAIN_DEPLOYMENT.md | 7 +- MULTI_CHAIN_DEPLOYMENT_COMPLETE.md | 15 +- MULTI_CHAIN_INTEGRATION_STATUS.md | 7 +- atomicproof/foundry.toml | 21 + config/address-inventory.chain138.json | 50 +- config/config-rpc-thirdweb-admin-core.toml | 50 + config/runtime-env.chain138.json | 17 +- contracts/bridge/BridgeOrchestrator.sol | 2 +- contracts/bridge/CWMultiTokenBridgeL1.sol | 358 +++++++ contracts/bridge/CWMultiTokenBridgeL2.sol | 304 ++++++ contracts/bridge/UniversalCCIPBridge.sol | 4 +- .../bridge/atomic/AtomicBridgeCoordinator.sol | 324 ++++++ contracts/bridge/atomic/AtomicFeePolicy.sol | 82 ++ .../bridge/atomic/AtomicFulfillerRegistry.sol | 133 +++ .../bridge/atomic/AtomicLiquidityVault.sol | 151 +++ .../bridge/atomic/AtomicObligationEscrow.sol | 79 ++ contracts/bridge/atomic/AtomicQuoteEngine.sol | 81 ++ .../bridge/atomic/AtomicSettlementRouter.sol | 59 ++ .../bridge/atomic/AtomicSlashingManager.sol | 27 + contracts/bridge/atomic/AtomicTypes.sol | 92 ++ .../UniversalCcipAtomicSettlementAdapter.sol | 74 ++ .../interfaces/IAtomicBridgeCoordinator.sol | 21 + .../interfaces/IAtomicFulfillerRegistry.sol | 20 + .../interfaces/IAtomicLiquidityVault.sol | 23 + .../atomic/interfaces/IAtomicQuoteEngine.sol | 13 + .../interfaces/IAtomicSettlementAdapter.sol | 12 + .../interfaces/IAtomicUnwindAdapter.sol | 13 + .../integration/CWAssetReserveVerifier.sol | 247 +++++ .../bridge/integration/CWReserveVerifier.sol | 314 ++++++ .../bridge/integration/ICWReserveVerifier.sol | 21 + .../integration/USDWPublicWrapVault.sol | 189 ++++ .../bridge/modules/BridgeModuleRegistry.sol | 2 +- .../bridge/trustless/EnhancedSwapRouterV2.sol | 457 +++++++++ .../trustless/IntentBridgeCoordinatorV2.sol | 126 +++ contracts/bridge/trustless/RouteTypesV2.sol | 65 ++ .../adapters/BalancerRouteExecutorAdapter.sol | 77 ++ .../adapters/CurveRouteExecutorAdapter.sol | 53 + .../adapters/DodoRouteExecutorAdapter.sol | 61 ++ .../adapters/DodoV3RouteExecutorAdapter.sol | 108 ++ .../adapters/OneInchRouteExecutorAdapter.sol | 59 ++ .../UniswapV3RouteExecutorAdapter.sol | 97 ++ .../trustless/integration/Stabilizer.sol | 8 +- .../interfaces/IBridgeIntentExecutor.sol | 20 + .../interfaces/IEnhancedSwapRouterV2.sol | 31 + .../interfaces/IRouteExecutorAdapter.sol | 18 + .../pilot/Chain138PilotDexVenues.sol | 596 +++++++++++ contracts/config/ConfigurationRegistry.sol | 2 +- contracts/dex/DODOPMMIntegration.sol | 103 +- contracts/dex/MockDVMPool.sol | 76 +- .../flash/AaveQuotePushFlashReceiver.sol | 218 ++++ contracts/flash/CrossChainFlashBorrower.sol | 73 ++ .../flash/CrossChainFlashRepayReceiver.sol | 57 ++ .../CrossChainFlashVaultCreditReceiver.sol | 54 + .../flash/DODOIntegrationExternalUnwinder.sol | 44 + ...ODOToUniswapV3MultiHopExternalUnwinder.sol | 62 ++ .../flash/MinimalERC3156FlashBorrower.sol | 33 + .../flash/QuotePushFlashWorkflowBorrower.sol | 101 ++ contracts/flash/SimpleERC3156FlashVault.sol | 140 +++ contracts/flash/SwapFlashWorkflowBorrower.sol | 72 ++ contracts/flash/UniswapV3ExternalUnwinder.sol | 64 ++ .../flash/UniversalCCIPFlashBridgeAdapter.sol | 70 ++ .../interfaces/ICrossChainFlashBridge.sol | 20 + contracts/iso4217w/ISO4217WToken.sol | 4 +- contracts/liquidity/LiquidityManager.sol | 4 +- contracts/liquidity/PoolManager.sol | 2 +- .../liquidity/providers/DODOPMMProvider.sol | 59 +- contracts/plugins/PluginRegistry.sol | 2 +- contracts/registry/ChainRegistry.sol | 2 +- contracts/relay/CCIPRelayRouter.sol | 36 +- contracts/reserve/StablecoinReserveVault.sol | 94 +- contracts/sync/TokenlistGovernanceSync.sol | 2 +- contracts/tokens/CompliantBTC.sol | 28 + .../tokens/CompliantMonetaryUnitToken.sol | 86 ++ contracts/tokens/CompliantUSDCTokenV2.sol | 29 + contracts/tokens/CompliantUSDTTokenV2.sol | 29 + contracts/vault/tokens/DebtToken.sol | 2 +- contracts/vault/tokens/DepositToken.sol | 2 +- docs/ADDRESS_MAPPING.md | 2 + .../ALL_MAINNETS_DEPLOYMENT_RUNBOOK.md | 4 +- .../deployment/DEPLOYED_CONTRACTS_OVERVIEW.md | 7 +- docs/deployment/DVM_DEPLOYMENT_CHECK.md | 31 +- .../MULTI_CHAIN_DEPLOYMENT_GUIDE.md | 25 +- docs/deployment/NEXT_STEPS_COMPLETION.md | 4 +- docs/integration/DODO_PMM_INTEGRATION.md | 11 + .../integration/ORACLE_AND_KEEPER_CHAIN138.md | 8 +- env.additions.example | 49 +- forkproof/foundry.toml | 20 + forkproof/src/AaveQuotePushFlashReceiver.sol | 218 ++++ .../src/DODOIntegrationExternalUnwinder.sol | 44 + ...ODOToUniswapV3MultiHopExternalUnwinder.sol | 65 ++ forkproof/src/UniswapV3ExternalUnwinder.sol | 74 ++ ...aveQuotePushFlashReceiverMainnetFork.t.sol | 391 ++++++++ ...tegrationExternalUnwinderMainnetFork.t.sol | 34 + ...3MultiHopExternalUnwinderMainnetFork.t.sol | 39 + ...UniswapV3ExternalUnwinderMainnetFork.t.sol | 47 + foundry.toml | 6 + hardhat.config.js | 45 +- package.json | 11 +- script/DeployCCIPRelayRouterOnly.s.sol | 27 + script/DeployCWAssetReserveVerifier.s.sol | 61 ++ script/DeployCWMultiTokenBridgeL1.s.sol | 20 + script/DeployCWMultiTokenBridgeL2.s.sol | 20 + script/DeployCWReserveVerifier.s.sol | 99 ++ .../DeployChain138PilotDexVenues.s.sol | 141 +++ .../trustless/DeployEnhancedSwapRouter.s.sol | 24 +- .../DeployEnhancedSwapRouterV2.s.sol | 219 ++++ .../DeployAaveQuotePushFlashReceiver.s.sol | 36 + ...ndStageCompliantFiatTokensV2ForChain.s.sol | 146 +++ ...eGenericCompliantFiatTokenV2ForChain.s.sol | 126 +++ script/deploy/DeployCAUSDT.s.sol | 53 + script/deploy/DeployCWTokens.s.sol | 64 +- .../DeployCompliantFiatTokensV2ForChain.s.sol | 56 ++ .../DeployCrossChainFlashInfrastructure.s.sol | 77 ++ script/deploy/DeployGasCanonicalTokens.s.sol | 85 ++ ...DeployQuotePushFlashWorkflowBorrower.s.sol | 34 + .../DeploySimpleERC3156FlashVault.s.sol | 59 ++ script/deploy/DeploySingleCWToken.s.sol | 44 + script/deploy/DeployUSDWPublicWrapVault.s.sol | 73 ++ .../deploy/RegisterGRUCompliantTokens.s.sol | 1 + .../deploy/RegisterGRUCompliantTokensV2.s.sol | 50 + ...AddLiquidityCUSDWCUSDCV2PoolChain138.s.sol | 56 ++ script/dex/CreateCUSDCUSDCPool.s.sol | 20 +- script/dex/CreateCUSDTCUSDCPool.s.sol | 20 +- script/dex/CreateCUSDTUSDTPool.s.sol | 20 +- script/dex/CreateCUSDWCUSDCV2Pool.s.sol | 63 ++ script/flash/TestOneUSDTFlash.s.sol | 64 ++ script/flash/TestScaledFlash.s.sol | 69 ++ scripts/bridge/fund-mainnet-relay-bridge.sh | 2 + scripts/ccip/ccip-send.sh | 30 +- .../audit-funding-bootstrap-routes.sh | 321 ++++++ scripts/deployment/check-env-required.sh | 24 +- .../complete-nonprefunded-avax-cutover.sh | 937 ++++++++++++++++++ .../deploy-cw-stablecoin-vault-and-wire.sh | 274 +++++ .../deploy-official-dvm-chain138.sh | 111 ++- scripts/deployment/deploy-pmm-all-l2s.sh | 36 +- ...kens-and-weth-all-chains-skip-canonical.sh | 16 +- .../dry-run-enhanced-swap-router-chain138.sh | 55 +- ...ry-run-enhanced-swap-router-v2-chain138.sh | 196 ++++ scripts/deployment/generate-all-adapters.sh | 8 +- ...int-chain138-public-chain-unload-routes.sh | 572 +++++++++++ .../print-gnosis-bootstrap-cast-sequence.sh | 38 + .../run-pmm-full-parity-all-phases.sh | 35 +- .../sync-chain138-pmm-pools-from-json.sh | 12 +- scripts/lib/deployment/dotenv.sh | 13 + scripts/mint-c-star-v2-wave1-138.sh | 59 ++ .../mint-ceurt-transfer-cxau-recipient-138.sh | 67 ++ scripts/mint-cw-on-chain.sh | 13 +- scripts/mint-for-liquidity.sh | 6 +- scripts/reserve/pmm-mesh-6s-automation.sh | 107 +- .../transfer-fiat-c-star-to-recipient-138.sh | 70 ++ scripts/update-oracle-price.sh | 8 +- services/btc-intake/package.json | 17 + services/btc-intake/src/custody-adapter.ts | 36 + services/btc-intake/src/index.ts | 3 + .../src/native-bitcoin-watcher.test.ts | 267 +++++ .../btc-intake/src/native-bitcoin-watcher.ts | 250 +++++ services/btc-intake/src/types.ts | 59 ++ services/btc-intake/tsconfig.json | 20 + services/relay/.env.avax | 25 + services/relay/.env.avax-cw | 24 + services/relay/.env.avax-to-138 | 28 + services/relay/.env.mainnet-cw | 25 + services/relay/.env.mainnet-weth | 34 + services/relay/DEPLOYMENT_GUIDE.md | 6 +- services/relay/README.md | 62 ++ services/relay/index.js | 10 +- services/relay/src/RelayService.js | 356 ++++++- services/relay/src/abis.js | 2 +- services/relay/src/config.js | 49 +- services/relay/src/healthServer.js | 76 ++ services/relay/start-relay.sh | 67 +- services/relay/test.js | 66 ++ services/token-aggregation/.env.example | 137 +++ .../token-aggregation/PROXMOX_DEPLOYMENT.md | 2 +- services/token-aggregation/QUICK_START.md | 4 +- services/token-aggregation/README.md | 20 +- .../docs/CMC_COINGECKO_REPORTING.md | 14 +- services/token-aggregation/docs/DEPLOYMENT.md | 9 +- .../docs/REST_API_REFERENCE.md | 60 +- services/token-aggregation/package-lock.json | 78 +- services/token-aggregation/package.json | 2 + .../scripts/apply-lightweight-schema.sh | 19 + .../scripts/bootstrap-lightweight-schema.sql | 181 ++++ .../scripts/generate-route-matrix-v2.ts | 41 + .../verify-dodo-v3-planner-visibility.ts | 142 +++ .../src/api/middleware/cache.ts | 19 +- .../src/api/routes/bridge.test.ts | 247 +++++ .../src/api/routes/bridge.ts | 210 +++- .../src/api/routes/config.test.ts | 142 +++ .../src/api/routes/config.ts | 166 +++- .../src/api/routes/heatmap.ts | 7 +- .../src/api/routes/partner-payloads.test.ts | 112 +++ .../src/api/routes/partner-payloads.ts | 153 ++- .../src/api/routes/planner-v2.test.ts | 154 +++ .../src/api/routes/planner-v2.ts | 141 +++ .../src/api/routes/quote.test.ts | 142 +++ .../token-aggregation/src/api/routes/quote.ts | 88 +- .../src/api/routes/report.test.ts | 364 ++++++- .../src/api/routes/report.ts | 212 +++- .../src/api/routes/token-mapping.test.ts | 215 ++++ .../src/api/routes/token-mapping.ts | 151 ++- .../src/api/routes/tokens.ts | 164 ++- services/token-aggregation/src/api/server.ts | 47 +- .../src/api/utils/default-bridge-routes.ts | 84 ++ .../src/config/canonical-tokens.test.ts | 258 +++++ .../src/config/canonical-tokens.ts | 567 ++++++++++- .../src/config/chain138-rpc.ts | 13 + .../token-aggregation/src/config/chains.ts | 4 +- .../src/config/cross-chain-bridges.ts | 92 +- .../src/config/deployment-status.ts | 201 ++++ .../src/config/dex-factories.ts | 7 +- .../src/config/gru-transport.test.ts | 435 ++++++++ .../src/config/gru-transport.ts | 338 +++++++ .../src/config/heatmap-chains.ts | 8 +- .../monetary-unit-symbol-registry.test.ts | 23 + .../config/monetary-unit-symbol-registry.ts | 132 +++ .../src/config/provider-capabilities.ts | 563 +++++++++++ .../src/config/repo-config-loader.ts | 53 + .../src/config/routing-assets.ts | 130 +++ .../src/config/routing-policies.ts | 68 ++ .../repositories/planner-metrics-repo.test.ts | 47 + .../repositories/planner-metrics-repo.ts | 183 ++++ services/token-aggregation/src/index.ts | 2 + .../src/indexer/chain-indexer.ts | 8 +- .../src/indexer/cross-chain-indexer.ts | 58 +- .../src/indexer/ohlcv-generator.test.ts | 61 ++ .../src/indexer/ohlcv-generator.ts | 266 +++-- .../src/indexer/pool-indexer.ts | 71 +- .../src/indexer/volume-calculator.test.ts | 63 ++ .../src/indexer/volume-calculator.ts | 186 ++-- .../aggregator-route-matrix-generator.ts | 295 ++++++ .../src/services/arbitrage-scanner.ts | 14 +- .../services/best-execution-planner.test.ts | 190 ++++ .../src/services/best-execution-planner.ts | 783 +++++++++++++++ .../services/chain138-dodo-liquidity.test.ts | 58 ++ .../src/services/chain138-dodo-liquidity.ts | 113 +++ .../src/services/chain138-pilot-venues.ts | 562 +++++++++++ .../src/services/dodo-v3-pilot.ts | 204 ++++ .../services/internal-execution-plan-v2.ts | 106 ++ .../src/services/internal-execution-plan.ts | 2 +- .../src/services/live-dodo-fallback.ts | 178 ++++ .../src/services/planner-v2-types.ts | 187 ++++ .../src/services/pmm-onchain-quote.ts | 58 ++ .../src/services/route-decision-tree.test.ts | 76 ++ .../src/services/route-decision-tree.ts | 150 ++- .../src/services/route-graph-builder.test.ts | 92 ++ .../src/services/route-graph-builder.ts | 172 ++++ .../src/services/route-matrix-scheduler.ts | 36 + test/api/packages/asyncapi/asyncapi.yaml | 37 + test/api/packages/openapi/v1/openapi.yaml | 139 +++ test/bridge/CWAssetReserveVerifier.t.sol | 180 ++++ test/bridge/CWMultiTokenBridge.t.sol | 339 +++++++ test/bridge/CWMultiTokenBridgeBTC.t.sol | 135 +++ test/bridge/CWReserveVerifier.t.sol | 262 +++++ test/bridge/CWReserveVerifierBTC.t.sol | 155 +++ .../CWReserveVerifierVaultIntegration.t.sol | 98 ++ .../CWReserveVerifierVaultV2Integration.t.sol | 195 ++++ test/bridge/USDWPublicWrapVault.t.sol | 124 +++ .../atomic/AtomicBridgeCoordinator.t.sol | 271 +++++ .../trustless/Chain138PilotDexVenues.t.sol | 230 +++++ .../trustless/EnhancedSwapRouterV2.t.sol | 686 +++++++++++++ .../trustless/integration/Stabilizer.t.sol | 4 +- test/ccip-integration/CCIPIntegration.test.js | 117 ++- test/compliance/CompliantFiatTokenV2.t.sol | 109 ++ .../CompliantMonetaryUnitTokenTest.t.sol | 54 + .../CompliantWrappedTokenTest.t.sol | 20 + test/dex/DODOPMMIntegration.t.sol | 238 +++-- test/dex/DODOPMMProvider.t.sol | 301 ++++++ test/emoney/api/README.md | 34 +- .../contract/event-schema-validation.test.ts | 3 +- test/emoney/api/integration/graphql.test.ts | 41 +- test/emoney/api/integration/rest-api.test.ts | 8 +- test/emoney/api/package.json | 7 +- test/emoney/api/tsconfig.json | 18 + ...aveQuotePushFlashReceiverMainnetFork.t.sol | 115 +++ test/flash/CrossChainFlashBorrower.t.sol | 79 ++ test/flash/CrossChainFlashRepayReceiver.t.sol | 63 ++ .../CrossChainFlashVaultCreditReceiver.t.sol | 58 ++ ...tegrationExternalUnwinderMainnetFork.t.sol | 51 + test/flash/MinimalERC3156FlashBorrower.t.sol | 54 + .../QuotePushFlashWorkflowBorrower.t.sol | 155 +++ ...PushFlashWorkflowBorrowerMainnetFork.t.sol | 114 +++ test/flash/SimpleERC3156FlashVault.t.sol | 120 +++ test/flash/SwapFlashWorkflowBorrower.t.sol | 87 ++ .../flash/UniswapV3ExternalUnwinderFork.t.sol | 57 ++ .../UniversalCCIPFlashBridgeAdapter.t.sol | 67 ++ .../GRUCompliantTokensRegistry.t.sol | 14 + test/reserve/StablecoinReserveVault.t.sol | 97 ++ 289 files changed, 28367 insertions(+), 824 deletions(-) create mode 100644 atomicproof/foundry.toml create mode 100644 config/config-rpc-thirdweb-admin-core.toml create mode 100644 contracts/bridge/CWMultiTokenBridgeL1.sol create mode 100644 contracts/bridge/CWMultiTokenBridgeL2.sol create mode 100644 contracts/bridge/atomic/AtomicBridgeCoordinator.sol create mode 100644 contracts/bridge/atomic/AtomicFeePolicy.sol create mode 100644 contracts/bridge/atomic/AtomicFulfillerRegistry.sol create mode 100644 contracts/bridge/atomic/AtomicLiquidityVault.sol create mode 100644 contracts/bridge/atomic/AtomicObligationEscrow.sol create mode 100644 contracts/bridge/atomic/AtomicQuoteEngine.sol create mode 100644 contracts/bridge/atomic/AtomicSettlementRouter.sol create mode 100644 contracts/bridge/atomic/AtomicSlashingManager.sol create mode 100644 contracts/bridge/atomic/AtomicTypes.sol create mode 100644 contracts/bridge/atomic/adapters/UniversalCcipAtomicSettlementAdapter.sol create mode 100644 contracts/bridge/atomic/interfaces/IAtomicBridgeCoordinator.sol create mode 100644 contracts/bridge/atomic/interfaces/IAtomicFulfillerRegistry.sol create mode 100644 contracts/bridge/atomic/interfaces/IAtomicLiquidityVault.sol create mode 100644 contracts/bridge/atomic/interfaces/IAtomicQuoteEngine.sol create mode 100644 contracts/bridge/atomic/interfaces/IAtomicSettlementAdapter.sol create mode 100644 contracts/bridge/atomic/interfaces/IAtomicUnwindAdapter.sol create mode 100644 contracts/bridge/integration/CWAssetReserveVerifier.sol create mode 100644 contracts/bridge/integration/CWReserveVerifier.sol create mode 100644 contracts/bridge/integration/ICWReserveVerifier.sol create mode 100644 contracts/bridge/integration/USDWPublicWrapVault.sol create mode 100644 contracts/bridge/trustless/EnhancedSwapRouterV2.sol create mode 100644 contracts/bridge/trustless/IntentBridgeCoordinatorV2.sol create mode 100644 contracts/bridge/trustless/RouteTypesV2.sol create mode 100644 contracts/bridge/trustless/adapters/BalancerRouteExecutorAdapter.sol create mode 100644 contracts/bridge/trustless/adapters/CurveRouteExecutorAdapter.sol create mode 100644 contracts/bridge/trustless/adapters/DodoRouteExecutorAdapter.sol create mode 100644 contracts/bridge/trustless/adapters/DodoV3RouteExecutorAdapter.sol create mode 100644 contracts/bridge/trustless/adapters/OneInchRouteExecutorAdapter.sol create mode 100644 contracts/bridge/trustless/adapters/UniswapV3RouteExecutorAdapter.sol create mode 100644 contracts/bridge/trustless/interfaces/IBridgeIntentExecutor.sol create mode 100644 contracts/bridge/trustless/interfaces/IEnhancedSwapRouterV2.sol create mode 100644 contracts/bridge/trustless/interfaces/IRouteExecutorAdapter.sol create mode 100644 contracts/bridge/trustless/pilot/Chain138PilotDexVenues.sol create mode 100644 contracts/flash/AaveQuotePushFlashReceiver.sol create mode 100644 contracts/flash/CrossChainFlashBorrower.sol create mode 100644 contracts/flash/CrossChainFlashRepayReceiver.sol create mode 100644 contracts/flash/CrossChainFlashVaultCreditReceiver.sol create mode 100644 contracts/flash/DODOIntegrationExternalUnwinder.sol create mode 100644 contracts/flash/DODOToUniswapV3MultiHopExternalUnwinder.sol create mode 100644 contracts/flash/MinimalERC3156FlashBorrower.sol create mode 100644 contracts/flash/QuotePushFlashWorkflowBorrower.sol create mode 100644 contracts/flash/SimpleERC3156FlashVault.sol create mode 100644 contracts/flash/SwapFlashWorkflowBorrower.sol create mode 100644 contracts/flash/UniswapV3ExternalUnwinder.sol create mode 100644 contracts/flash/UniversalCCIPFlashBridgeAdapter.sol create mode 100644 contracts/flash/interfaces/ICrossChainFlashBridge.sol create mode 100644 contracts/tokens/CompliantBTC.sol create mode 100644 contracts/tokens/CompliantMonetaryUnitToken.sol create mode 100644 contracts/tokens/CompliantUSDCTokenV2.sol create mode 100644 contracts/tokens/CompliantUSDTTokenV2.sol create mode 100644 forkproof/foundry.toml create mode 100644 forkproof/src/AaveQuotePushFlashReceiver.sol create mode 100644 forkproof/src/DODOIntegrationExternalUnwinder.sol create mode 100644 forkproof/src/DODOToUniswapV3MultiHopExternalUnwinder.sol create mode 100644 forkproof/src/UniswapV3ExternalUnwinder.sol create mode 100644 forkproof/test/AaveQuotePushFlashReceiverMainnetFork.t.sol create mode 100644 forkproof/test/DODOIntegrationExternalUnwinderMainnetFork.t.sol create mode 100644 forkproof/test/DODOToUniswapV3MultiHopExternalUnwinderMainnetFork.t.sol create mode 100644 forkproof/test/UniswapV3ExternalUnwinderMainnetFork.t.sol create mode 100644 script/DeployCCIPRelayRouterOnly.s.sol create mode 100644 script/DeployCWAssetReserveVerifier.s.sol create mode 100644 script/DeployCWMultiTokenBridgeL1.s.sol create mode 100644 script/DeployCWMultiTokenBridgeL2.s.sol create mode 100644 script/DeployCWReserveVerifier.s.sol create mode 100644 script/bridge/trustless/DeployChain138PilotDexVenues.s.sol create mode 100644 script/bridge/trustless/DeployEnhancedSwapRouterV2.s.sol create mode 100644 script/deploy/DeployAaveQuotePushFlashReceiver.s.sol create mode 100644 script/deploy/DeployAndStageCompliantFiatTokensV2ForChain.s.sol create mode 100644 script/deploy/DeployAndStageGenericCompliantFiatTokenV2ForChain.s.sol create mode 100644 script/deploy/DeployCAUSDT.s.sol create mode 100644 script/deploy/DeployCompliantFiatTokensV2ForChain.s.sol create mode 100644 script/deploy/DeployCrossChainFlashInfrastructure.s.sol create mode 100644 script/deploy/DeployGasCanonicalTokens.s.sol create mode 100644 script/deploy/DeployQuotePushFlashWorkflowBorrower.s.sol create mode 100644 script/deploy/DeploySimpleERC3156FlashVault.s.sol create mode 100644 script/deploy/DeploySingleCWToken.s.sol create mode 100644 script/deploy/DeployUSDWPublicWrapVault.s.sol create mode 100644 script/deploy/RegisterGRUCompliantTokensV2.s.sol create mode 100644 script/dex/AddLiquidityCUSDWCUSDCV2PoolChain138.s.sol create mode 100644 script/dex/CreateCUSDWCUSDCV2Pool.s.sol create mode 100644 script/flash/TestOneUSDTFlash.s.sol create mode 100644 script/flash/TestScaledFlash.s.sol create mode 100755 scripts/deployment/audit-funding-bootstrap-routes.sh create mode 100755 scripts/deployment/complete-nonprefunded-avax-cutover.sh create mode 100755 scripts/deployment/deploy-cw-stablecoin-vault-and-wire.sh create mode 100644 scripts/deployment/dry-run-enhanced-swap-router-v2-chain138.sh create mode 100755 scripts/deployment/print-chain138-public-chain-unload-routes.sh create mode 100755 scripts/deployment/print-gnosis-bootstrap-cast-sequence.sh create mode 100755 scripts/mint-c-star-v2-wave1-138.sh create mode 100755 scripts/mint-ceurt-transfer-cxau-recipient-138.sh create mode 100755 scripts/transfer-fiat-c-star-to-recipient-138.sh create mode 100644 services/btc-intake/package.json create mode 100644 services/btc-intake/src/custody-adapter.ts create mode 100644 services/btc-intake/src/index.ts create mode 100644 services/btc-intake/src/native-bitcoin-watcher.test.ts create mode 100644 services/btc-intake/src/native-bitcoin-watcher.ts create mode 100644 services/btc-intake/src/types.ts create mode 100644 services/btc-intake/tsconfig.json create mode 100644 services/relay/.env.avax create mode 100644 services/relay/.env.avax-cw create mode 100644 services/relay/.env.avax-to-138 create mode 100644 services/relay/.env.mainnet-cw create mode 100644 services/relay/.env.mainnet-weth create mode 100644 services/relay/src/healthServer.js create mode 100644 services/token-aggregation/.env.example create mode 100755 services/token-aggregation/scripts/apply-lightweight-schema.sh create mode 100644 services/token-aggregation/scripts/bootstrap-lightweight-schema.sql create mode 100644 services/token-aggregation/scripts/generate-route-matrix-v2.ts create mode 100644 services/token-aggregation/scripts/verify-dodo-v3-planner-visibility.ts create mode 100644 services/token-aggregation/src/api/routes/bridge.test.ts create mode 100644 services/token-aggregation/src/api/routes/config.test.ts create mode 100644 services/token-aggregation/src/api/routes/partner-payloads.test.ts create mode 100644 services/token-aggregation/src/api/routes/planner-v2.test.ts create mode 100644 services/token-aggregation/src/api/routes/planner-v2.ts create mode 100644 services/token-aggregation/src/api/routes/quote.test.ts create mode 100644 services/token-aggregation/src/api/routes/token-mapping.test.ts create mode 100644 services/token-aggregation/src/api/utils/default-bridge-routes.ts create mode 100644 services/token-aggregation/src/config/canonical-tokens.test.ts create mode 100644 services/token-aggregation/src/config/chain138-rpc.ts create mode 100644 services/token-aggregation/src/config/deployment-status.ts create mode 100644 services/token-aggregation/src/config/gru-transport.test.ts create mode 100644 services/token-aggregation/src/config/gru-transport.ts create mode 100644 services/token-aggregation/src/config/monetary-unit-symbol-registry.test.ts create mode 100644 services/token-aggregation/src/config/monetary-unit-symbol-registry.ts create mode 100644 services/token-aggregation/src/config/provider-capabilities.ts create mode 100644 services/token-aggregation/src/config/repo-config-loader.ts create mode 100644 services/token-aggregation/src/config/routing-assets.ts create mode 100644 services/token-aggregation/src/config/routing-policies.ts create mode 100644 services/token-aggregation/src/database/repositories/planner-metrics-repo.test.ts create mode 100644 services/token-aggregation/src/database/repositories/planner-metrics-repo.ts create mode 100644 services/token-aggregation/src/indexer/ohlcv-generator.test.ts create mode 100644 services/token-aggregation/src/indexer/volume-calculator.test.ts create mode 100644 services/token-aggregation/src/services/aggregator-route-matrix-generator.ts create mode 100644 services/token-aggregation/src/services/best-execution-planner.test.ts create mode 100644 services/token-aggregation/src/services/best-execution-planner.ts create mode 100644 services/token-aggregation/src/services/chain138-dodo-liquidity.test.ts create mode 100644 services/token-aggregation/src/services/chain138-dodo-liquidity.ts create mode 100644 services/token-aggregation/src/services/chain138-pilot-venues.ts create mode 100644 services/token-aggregation/src/services/dodo-v3-pilot.ts create mode 100644 services/token-aggregation/src/services/internal-execution-plan-v2.ts create mode 100644 services/token-aggregation/src/services/live-dodo-fallback.ts create mode 100644 services/token-aggregation/src/services/planner-v2-types.ts create mode 100644 services/token-aggregation/src/services/pmm-onchain-quote.ts create mode 100644 services/token-aggregation/src/services/route-decision-tree.test.ts create mode 100644 services/token-aggregation/src/services/route-graph-builder.test.ts create mode 100644 services/token-aggregation/src/services/route-graph-builder.ts create mode 100644 services/token-aggregation/src/services/route-matrix-scheduler.ts create mode 100644 test/api/packages/asyncapi/asyncapi.yaml create mode 100644 test/api/packages/openapi/v1/openapi.yaml create mode 100644 test/bridge/CWAssetReserveVerifier.t.sol create mode 100644 test/bridge/CWMultiTokenBridge.t.sol create mode 100644 test/bridge/CWMultiTokenBridgeBTC.t.sol create mode 100644 test/bridge/CWReserveVerifier.t.sol create mode 100644 test/bridge/CWReserveVerifierBTC.t.sol create mode 100644 test/bridge/CWReserveVerifierVaultIntegration.t.sol create mode 100644 test/bridge/CWReserveVerifierVaultV2Integration.t.sol create mode 100644 test/bridge/USDWPublicWrapVault.t.sol create mode 100644 test/bridge/atomic/AtomicBridgeCoordinator.t.sol create mode 100644 test/bridge/trustless/Chain138PilotDexVenues.t.sol create mode 100644 test/bridge/trustless/EnhancedSwapRouterV2.t.sol create mode 100644 test/compliance/CompliantMonetaryUnitTokenTest.t.sol create mode 100644 test/dex/DODOPMMProvider.t.sol create mode 100644 test/emoney/api/tsconfig.json create mode 100644 test/flash/AaveQuotePushFlashReceiverMainnetFork.t.sol create mode 100644 test/flash/CrossChainFlashBorrower.t.sol create mode 100644 test/flash/CrossChainFlashRepayReceiver.t.sol create mode 100644 test/flash/CrossChainFlashVaultCreditReceiver.t.sol create mode 100644 test/flash/DODOIntegrationExternalUnwinderMainnetFork.t.sol create mode 100644 test/flash/MinimalERC3156FlashBorrower.t.sol create mode 100644 test/flash/QuotePushFlashWorkflowBorrower.t.sol create mode 100644 test/flash/QuotePushFlashWorkflowBorrowerMainnetFork.t.sol create mode 100644 test/flash/SimpleERC3156FlashVault.t.sol create mode 100644 test/flash/SwapFlashWorkflowBorrower.t.sol create mode 100644 test/flash/UniswapV3ExternalUnwinderFork.t.sol create mode 100644 test/flash/UniversalCCIPFlashBridgeAdapter.t.sol create mode 100644 test/reserve/StablecoinReserveVault.t.sol diff --git a/.gitignore b/.gitignore index a7b1c64..3b7203b 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,9 @@ temp/ # Node modules (if any) node_modules/ +# TypeScript service emit (build in-tree) +services/btc-intake/dist/ + # Python __pycache__/ *.pyc diff --git a/COMPLETE_MULTI_CHAIN_DEPLOYMENT.md b/COMPLETE_MULTI_CHAIN_DEPLOYMENT.md index a874dcd..bb6701f 100644 --- a/COMPLETE_MULTI_CHAIN_DEPLOYMENT.md +++ b/COMPLETE_MULTI_CHAIN_DEPLOYMENT.md @@ -139,11 +139,12 @@ forge script script/deploy/chains/DeployAllAdapters.s.sol:DeployAllAdapters \ ### **3. Configure Services** ```bash -# Firefly (VMID 6202) -ssh root@192.168.11.175 +# FireFly is currently validated only on VMID 6200. +# 6201 is retired / standby, and 6202 / 6203 are not deployed. +ssh root@192.168.11.35 ff init alltra-bridge --multiparty -# Cacti (VMID 5201) +# Cacti primary (VMID 5200) # Configure Cacti API server with Besu connector ``` diff --git a/MULTI_CHAIN_DEPLOYMENT_COMPLETE.md b/MULTI_CHAIN_DEPLOYMENT_COMPLETE.md index e808b38..955fa8c 100644 --- a/MULTI_CHAIN_DEPLOYMENT_COMPLETE.md +++ b/MULTI_CHAIN_DEPLOYMENT_COMPLETE.md @@ -78,10 +78,10 @@ | Framework | Type | Adapter | Status | Infrastructure | |-----------|------|---------|--------|----------------| -| Firefly | Orchestration | FireflyAdapter | ✅ Created | ✅ VMIDs 6202, 6203 | -| Cacti | Interoperability | CactiAdapter | 🔨 Plan | ✅ VMID 5201 | -| Fabric | Permissioned | FabricAdapter | 🔨 Plan | 🔨 Deploy network | -| Indy | Identity | IndyVerifier | 🔨 Plan | 🔨 Deploy network | +| FireFly | Orchestration | FireflyAdapter | ✅ Created | `6200` live primary; `6201` retired / standby; `6202` and `6203` are not deployed | +| Cacti | Interoperability | CactiAdapter | 🔨 Plan | `5200` live primary; `5201` / `5202` live Alltra/HYBX public Cacti surfaces with local `:4000` APIs | +| Fabric | Permissioned | FabricAdapter | 🔨 Plan | `6000` live sample network; `6001` / `6002` are placeholders | +| Indy | Identity | IndyVerifier | 🔨 Plan | `6400` live validator pool; `6401` / `6402` are placeholders | --- @@ -172,11 +172,12 @@ chainRegistry.registerNonEVMChain( ); ``` -### **4. Configure Firefly** +### **4. Configure FireFly** ```bash -# SSH to Firefly node (VMID 6202) -ssh root@192.168.11.175 +# FireFly automation is validated only on the primary node today (VMID 6200) +# Secondary FireFly nodes are not deployed; 6201 is retired / standby metadata. +ssh root@192.168.11.35 # Initialize Firefly namespace ff init alltra-bridge --multiparty diff --git a/MULTI_CHAIN_INTEGRATION_STATUS.md b/MULTI_CHAIN_INTEGRATION_STATUS.md index 73b30ea..f2914f5 100644 --- a/MULTI_CHAIN_INTEGRATION_STATUS.md +++ b/MULTI_CHAIN_INTEGRATION_STATUS.md @@ -192,11 +192,12 @@ chainRegistry.registerEVMChain(50, xdcAdapter, "https://explorer.xdc.network", 1 chainRegistry.registerNonEVMChain("XRPL-Mainnet", ChainType.XRPL, xrplAdapter, "https://xrpscan.com", 1, 4, true, ""); ``` -### **4. Configure Firefly** +### **4. Configure FireFly** ```bash -# On VMID 6202 -ssh root@192.168.11.175 +# FireFly is currently validated only on VMID 6200. +# VMID 6201 is retired / standby, and 6202 / 6203 are not deployed. +ssh root@192.168.11.35 ff init alltra-bridge --multiparty ff accounts create --key /path/to/besu/key.json ``` diff --git a/atomicproof/foundry.toml b/atomicproof/foundry.toml new file mode 100644 index 0000000..fd1e2e5 --- /dev/null +++ b/atomicproof/foundry.toml @@ -0,0 +1,21 @@ +[profile.default] +src = "../contracts/bridge/atomic" +test = "../test/bridge/atomic" +out = "out" +libs = ["../lib"] +solc = "0.8.20" +optimizer = true +optimizer_runs = 200 +via_ir = true +evm_version = "london" +remappings = [ + "@openzeppelin/contracts/=../lib/openzeppelin-contracts/contracts/", + "@openzeppelin/contracts-upgradeable/=../lib/openzeppelin-contracts-upgradeable/contracts/", + "forge-std/=../lib/forge-std/src/", + "ds-test/=../lib/forge-std/lib/ds-test/src/", + "@emoney=../contracts/emoney/", + "@emoney-scripts=../script/emoney/", + "erc4626-tests/=../lib/openzeppelin-contracts/lib/erc4626-tests/", + "openzeppelin-contracts-upgradeable/=../lib/openzeppelin-contracts-upgradeable/", + "openzeppelin-contracts/=../lib/openzeppelin-contracts/" +] diff --git a/config/address-inventory.chain138.json b/config/address-inventory.chain138.json index 383588f..952c0fb 100644 --- a/config/address-inventory.chain138.json +++ b/config/address-inventory.chain138.json @@ -1,6 +1,6 @@ { "description": "Reference inventory moved out of smom-dbis-138/.env during dotenv cleanup. These are deployed/inventory addresses, not the minimal runtime env surface.", - "updated": "2026-03-27", + "updated": "2026-04-03", "chain138Inventory": { "COMPLIANCE_REGISTRY": "0xbc54fe2b6fda157c59d59826bcfdbcc654ec9ea1", "COMPLIANCE_REGISTRY_ADDRESS": "0xbc54fe2b6fda157c59d59826bcfdbcc654ec9ea1", @@ -11,13 +11,48 @@ "TOKEN_IMPLEMENTATION": "0x0059e237973179146237aB49f1322E8197c22b21", "TOKEN_REGISTRY_ADDRESS": "0x91Efe92229dbf7C5B38D422621300956B55870Fa", "FEE_COLLECTOR_ADDRESS": "0xF78246eB94c6CB14018E507E60661314E5f4C53f", + "COMPLIANT_USDT_V2": "0x9FBfab33882Efe0038DAa608185718b772EE5660", + "COMPLIANT_USDC_V2": "0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d", + "CUSDT_V2_ADDRESS_138": "0x9FBfab33882Efe0038DAa608185718b772EE5660", + "CUSDC_V2_ADDRESS_138": "0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d", "UNIVERSAL_ASSET_REGISTRY": "0xAEE4b7fBe82E1F8295951584CBc772b8BBD68575", "GOVERNANCE_CONTROLLER": "0xA6891D5229f2181a34D4FF1B515c3Aa37dd90E0e", "BRIDGE_ORCHESTRATOR": "0x89aB428c437f23bAB9781ff8Db8D3848e27EeD6c", + "ENHANCED_SWAP_ROUTER_V2_ADDRESS": "0xF1c93F54A5C2fc0d7766Ccb0Ad8f157DFB4C99Ce", + "INTENT_BRIDGE_COORDINATOR_V2_ADDRESS": "0x7D0022B7e8360172fd9C0bB6778113b7Ea3674E7", + "DODO_ROUTE_EXECUTOR_ADAPTER": "0x88495B3dccEA93b0633390fDE71992683121Fa62", + "DODO_V3_ROUTE_EXECUTOR_ADAPTER": "0x9Cb97adD29c52e3B81989BcA2E33D46074B530eF", + "UNISWAP_V3_ROUTE_EXECUTOR_ADAPTER": "0x960D6db4E78705f82995690548556fb2266308EA", + "BALANCER_ROUTE_EXECUTOR_ADAPTER": "0x4E1B71B69188Ab45021c797039b4887a4924157A", + "CURVE_ROUTE_EXECUTOR_ADAPTER": "0x5f0E07071c41ACcD2A1b1032D3bd49b323b9ADE6", + "ONEINCH_ROUTE_EXECUTOR_ADAPTER": "0x8168083d29b3293F215392A49D16e7FeF4a02600", + "UNISWAP_V3_ROUTER": "0xde9cD8ee2811E6E64a41D5F68Be315d33995975E", + "UNISWAP_QUOTER_ADDRESS": "0x6abbB1CEb2468e748a03A00CD6aA9BFE893AFa1f", + "CHAIN_138_UNISWAP_V3_FACTORY": "0x2f7219276e3ce367dB9ec74C1196a8ecEe67841C", + "CHAIN_138_UNISWAP_V3_ROUTER": "0xde9cD8ee2811E6E64a41D5F68Be315d33995975E", + "UNISWAP_V3_WETH_USDT_POOL": "0xa893add35aEfe6A6d858EB01828bE4592f12C9F5", + "UNISWAP_V3_WETH_USDC_POOL": "0xEC745bfb6b3cd32f102d594E5F432d8d85B19391", + "CHAIN138_UNISWAP_V3_NATIVE_FACTORY": "0x2f7219276e3ce367dB9ec74C1196a8ecEe67841C", + "CHAIN138_UNISWAP_V3_NATIVE_NFT_DESCRIPTOR_LIBRARY": "0x6F5fdE32DD2aC66B27e296EC9D6F4E79A3dE2947", + "CHAIN138_UNISWAP_V3_NATIVE_TOKEN_DESCRIPTOR": "0xca66DCAC4633555033F6fDDBE4234B6913c7ff51", + "CHAIN138_UNISWAP_V3_NATIVE_POSITION_MANAGER": "0x31b68BE5af4Df565Ce261dfe53D529005D947B48", + "CHAIN138_UNISWAP_V3_NATIVE_SWAP_ROUTER": "0xde9cD8ee2811E6E64a41D5F68Be315d33995975E", + "CHAIN138_UNISWAP_V3_NATIVE_QUOTER_V2": "0x6abbB1CEb2468e748a03A00CD6aA9BFE893AFa1f", + "CHAIN138_UNISWAP_V3_NATIVE_WETH_USDT_POOL": "0xa893add35aEfe6A6d858EB01828bE4592f12C9F5", + "CHAIN138_UNISWAP_V3_NATIVE_WETH_USDC_POOL": "0xEC745bfb6b3cd32f102d594E5F432d8d85B19391", + "BALANCER_VAULT": "0x96423d7C1727698D8a25EbFB88131e9422d1a3C3", + "BALANCER_WETH_USDT_POOL_ID": "0x877cd220759e8c94b82f55450c85d382ae06856c426b56d93092a420facbc324", + "BALANCER_WETH_USDC_POOL_ID": "0xd8dfb18a6baf9b29d8c2dbd74639db87ac558af120df5261dab8e2a5de69013b", + "CURVE_3POOL": "0xE440Ec15805BE4C7BabCD17A63B8C8A08a492e0f", + "ONEINCH_ROUTER": "0x500B84b1Bc6F59C1898a5Fe538eA20A758757A4F", + "CROSS_CHAIN_FLASH_BRIDGE_ADAPTER": "0xBe9e0B2d4cF6A3b2994d6f2f0904D2B165eB8ffC", + "CROSS_CHAIN_FLASH_REPAY_RECEIVER": "0xD084b68cB4B1ef2cBA09CF99FB1B6552fd9b4859", + "CROSS_CHAIN_FLASH_VAULT_CREDIT_RECEIVER": "0x89F7a1fcbBe104BeE96Da4b4b6b7d3AF85f7E661", "TRANSACTION_MIRROR_ADDRESS": "0x7131F887DBEEb2e44c1Ed267D2A68b5b83285afc", "PAYMENT_CHANNEL_MANAGER": "0x302aF72966aFd21C599051277a48DAa7f01a5f54", "GENERIC_STATE_CHANNEL_MANAGER": "0xe5e3bB424c8a0259FDE23F0A58F7e36f73B90aBd", - "ADDRESS_MAPPER": "0xe48E3f248698610e18Db865457fcd935Bb3da856", + "ADDRESS_MAPPER": "0x439Fcb2d2ab2f890DCcAE50461Fa7d978F9Ffe1A", + "ADDRESS_MAPPER_LEGACY_DUPLICATE": "0xe48E3f248698610e18Db865457fcd935Bb3da856", "MIRROR_MANAGER": "0x6eD905A30c552a6e003061A38FD52A5A427beE56", "ORACLE_AGGREGATOR_ADDRESS": "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506", "ORACLE_PROXY_ADDRESS": "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6", @@ -35,9 +70,14 @@ "UNIVERSAL_CCIP_BRIDGE_DETERMINISTIC": "0x532DE218b94993446Be30eC894442f911499f6a3", "MIRROR_REGISTRY": "0x6427F9739e6B6c3dDb4E94fEfeBcdF35549549d8", "ALLTRA_ADAPTER": "0x66FEBA2fC9a0B47F26DD4284DAd24F970436B8Dc", - "POOL_CUSDTCUSDC": "0xff8d3b8fDF7B112759F076B69f4271D4209C0849", - "POOL_CUSDTUSDT": "0x6fc60DEDc92a2047062294488539992710b99D71", - "POOL_CUSDCUSDC": "0x0309178ae30302D83c76d6Dd402a684eF3160eec", + "DODO_DVM_FACTORY": "0xc93870594C7f83A0aE076c2e30b494Efc526b68E", + "DODO_VENDING_MACHINE_ADDRESS": "0xb6D9EF3575bc48De3f011C310DC24d87bEC6087C", + "DODO_PMM_INTEGRATION": "0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895", + "DODO_PMM_PROVIDER": "0x3f729632E9553EBacCdE2e9b4c8F2B285b014F2e", + "CAUSDT_ADDRESS_138": "0x5fdDF65733e3d590463F68f93Cf16E8c04081271", + "POOL_CUSDTCUSDC": "0x9e89bAe009adf128782E19e8341996c596ac40dC", + "POOL_CUSDTUSDT": "0x866Cb44b59303d8dc5f4F9E3E7A8e8b0bf238d66", + "POOL_CUSDCUSDC": "0xc39B7D0F40838cbFb54649d327f49a6DAC964062", "CHAIN_REGISTRY_ADDRESS_138": "0x6949137625CA923A4e9C80D5bc7DF673f9bbb84F", "TRUTH_ADAPTER_ADDRESS_138": "0x7880Ef14887a0567807AcF785eC92553D014930f", "XRPL_ADAPTER_CHAIN138": "0x351f207F2DE66bF166ec730a0133613A10691439", diff --git a/config/config-rpc-thirdweb-admin-core.toml b/config/config-rpc-thirdweb-admin-core.toml new file mode 100644 index 0000000..00cb80f --- /dev/null +++ b/config/config-rpc-thirdweb-admin-core.toml @@ -0,0 +1,50 @@ +# Besu — VMID 2103 besu-rpc-core-thirdweb (Thirdweb admin / core lane) +# LAN: 192.168.11.217:8545 / :8546 — NPM: rpc.tw-core.d-bis.org / wss.tw-core.d-bis.org +# Same RPC API surface as config-rpc-core.toml (ADMIN, TXPOOL, QBFT, DEBUG, TRACE); +# CORS is open for browser / Thirdweb tooling behind NPM TLS. +data-path="/data/besu" +genesis-file="/genesis/genesis.json" + +network-id=138 +p2p-host="0.0.0.0" +p2p-port=30303 + +sync-mode="FULL" +data-storage-format="FOREST" + +rpc-http-enabled=true +rpc-http-host="0.0.0.0" +rpc-http-port=8545 +rpc-http-api=["ETH","NET","WEB3","TXPOOL","QBFT","ADMIN","DEBUG","TRACE"] +rpc-http-cors-origins=["*"] +rpc-ws-enabled=true +rpc-ws-host="0.0.0.0" +rpc-ws-port=8546 +rpc-ws-api=["ETH","NET","WEB3","TXPOOL","QBFT","ADMIN"] +rpc-ws-origins=["*"] + +metrics-enabled=true +metrics-port=9545 +metrics-host="0.0.0.0" +metrics-push-enabled=false + +logging="WARN" + +permissions-nodes-config-file-enabled=true +permissions-nodes-config-file="/var/lib/besu/permissions/permissions-nodes.toml" +permissions-accounts-config-file-enabled=true +permissions-accounts-config-file="/permissions/permissions-accounts.toml" + +tx-pool-max-size=8192 +tx-pool-limit-by-account-percentage=0.5 +tx-pool-price-bump=10 + +bootnodes=[] + +static-nodes-file="/var/lib/besu/static-nodes.json" + +discovery-enabled=false + +max-peers=32 + +rpc-http-timeout=120 diff --git a/config/runtime-env.chain138.json b/config/runtime-env.chain138.json index 3601ba3..207d492 100644 --- a/config/runtime-env.chain138.json +++ b/config/runtime-env.chain138.json @@ -1,22 +1,29 @@ { "description": "Canonical runtime-used Chain 138 env surface for token-aggregation and PMM-aware services.", - "updated": "2026-03-27", + "updated": "2026-04-03", "runtimeUsed": { - "DODO_PMM_INTEGRATION": "0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d", - "DODO_PMM_INTEGRATION_ADDRESS": "0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d", + "DODO_PMM_INTEGRATION": "0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895", + "DODO_PMM_INTEGRATION_ADDRESS": "0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895", "OFFICIAL_USDT_ADDRESS": "0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1", "OFFICIAL_USDC_ADDRESS": "0x71D6687F38b93CCad569Fa6352c876eea967201b", "CCIPWETH9_BRIDGE_CHAIN138": "0xcacfd227A040002e49e2e01626363071324f820a", "CCIPWETH10_BRIDGE_CHAIN138": "0xe0E93247376aa097dB308B92e6Ba36bA015535D0", - "CHAIN_138_DODO_PMM_INTEGRATION": "0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d", - "CHAIN_138_DODO_VENDING_MACHINE": "0xB16c3D48A111714B1795E58341FeFDd643Ab01ab", + "CHAIN_138_DODO_PMM_INTEGRATION": "0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895", + "CHAIN_138_DODO_VENDING_MACHINE": "0xb6D9EF3575bc48De3f011C310DC24d87bEC6087C", "UNIVERSAL_CCIP_BRIDGE": "0xCd42e8eD79Dc50599535d1de48d3dAFa0BE156F8", + "UNISWAP_V3_ROUTER": "0xde9cD8ee2811E6E64a41D5F68Be315d33995975E", + "UNISWAP_QUOTER_ADDRESS": "0x6abbB1CEb2468e748a03A00CD6aA9BFE893AFa1f", + "CHAIN_138_UNISWAP_V3_FACTORY": "0x2f7219276e3ce367dB9ec74C1196a8ecEe67841C", + "CHAIN_138_UNISWAP_V3_ROUTER": "0xde9cD8ee2811E6E64a41D5F68Be315d33995975E", + "UNISWAP_V3_WETH_USDT_POOL": "0xa893add35aEfe6A6d858EB01828bE4592f12C9F5", + "UNISWAP_V3_WETH_USDC_POOL": "0xEC745bfb6b3cd32f102d594E5F432d8d85B19391", "RPC_URL_138": "http://192.168.11.211:8545", "CHAIN138_RPC_URL": "http://192.168.11.211:8545", "RPC_URL_138_PUBLIC": "https://rpc.public-0138.defi-oracle.io", "CHAIN138_RPC_URL_PUBLIC": "https://rpc.public-0138.defi-oracle.io", "CUSDT_ADDRESS_138": "0x93E66202A11B1772E55407B32B44e5Cd8eda7f22", "CUSDC_ADDRESS_138": "0xf22258f57794CC8E06237084b353Ab30fFfa640b", + "CAUSDT_ADDRESS_138": "0x5fdDF65733e3d590463F68f93Cf16E8c04081271", "CEURC_ADDRESS_138": "0x8085961F9cF02b4d800A3c6d386D31da4B34266a", "CEURT_ADDRESS_138": "0xdf4b71c61E5912712C1Bdd451416B9aC26949d72", "CGBPC_ADDRESS_138": "0x003960f16D9d34F2e98d62723B6721Fb92074aD2", diff --git a/contracts/bridge/BridgeOrchestrator.sol b/contracts/bridge/BridgeOrchestrator.sol index b15300e..d5fe112 100644 --- a/contracts/bridge/BridgeOrchestrator.sol +++ b/contracts/bridge/BridgeOrchestrator.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "../vendor/openzeppelin/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "../registry/UniversalAssetRegistry.sol"; import "./UniversalCCIPBridge.sol"; diff --git a/contracts/bridge/CWMultiTokenBridgeL1.sol b/contracts/bridge/CWMultiTokenBridgeL1.sol new file mode 100644 index 0000000..9a40350 --- /dev/null +++ b/contracts/bridge/CWMultiTokenBridgeL1.sol @@ -0,0 +1,358 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import "../ccip/IRouterClient.sol"; +import "./integration/ICWReserveVerifier.sol"; + +/** + * @title CWMultiTokenBridgeL1 + * @notice Chain 138 side escrow bridge for non-prefunded c* -> cW* routing. + * @dev Locks canonical c* tokens on send and releases them on return messages. + */ +contract CWMultiTokenBridgeL1 { + using SafeERC20 for IERC20; + + IRouterClient public sendRouter; + address public receiveRouter; + address public feeToken; + address public admin; + address public reserveVerifier; + + struct DestinationConfig { + address receiverBridge; + bool enabled; + } + + mapping(address => mapping(uint64 => DestinationConfig)) public destinations; + mapping(bytes32 => bool) public processed; + mapping(address => bool) public supportedCanonicalToken; + mapping(address => bool) public paused; + mapping(address => uint256) public lockedBalance; + mapping(address => uint256) public totalOutstanding; + mapping(address => mapping(uint64 => uint256)) public outstandingMinted; + mapping(address => mapping(uint64 => uint256)) public maxOutstanding; + + event Locked(address indexed canonicalToken, address indexed user, uint256 amount); + event Released(address indexed canonicalToken, address indexed recipient, uint256 amount); + event MessageSent( + bytes32 indexed messageId, + address indexed canonicalToken, + uint64 indexed destinationChainSelector, + address recipient, + uint256 amount + ); + event DestinationConfigured(address indexed canonicalToken, uint64 indexed chainSelector, address receiverBridge, bool enabled); + event SupportedCanonicalTokenConfigured(address indexed canonicalToken, bool enabled); + event TokenPauseUpdated(address indexed canonicalToken, bool paused); + event MaxOutstandingUpdated(address indexed canonicalToken, uint64 indexed chainSelector, uint256 maxOutstandingAmount); + event OutstandingUpdated( + address indexed canonicalToken, + uint64 indexed chainSelector, + uint256 chainOutstanding, + uint256 totalOutstandingAmount, + uint256 lockedBalanceAmount + ); + event SendRouterUpdated(address indexed newRouter); + event ReceiveRouterUpdated(address indexed newRouter); + event FeeTokenUpdated(address indexed newFeeToken); + event AdminChanged(address indexed newAdmin); + event ReserveVerifierUpdated(address indexed newVerifier); + + modifier onlyAdmin() { + require(msg.sender == admin, "CWMultiTokenBridgeL1: only admin"); + _; + } + + modifier onlyReceiveRouter() { + require(msg.sender == receiveRouter, "CWMultiTokenBridgeL1: only receive router"); + _; + } + + constructor(address _sendRouter, address _receiveRouter, address _feeToken) { + require(_sendRouter != address(0), "CWMultiTokenBridgeL1: zero send router"); + require(_receiveRouter != address(0), "CWMultiTokenBridgeL1: zero receive router"); + sendRouter = IRouterClient(_sendRouter); + receiveRouter = _receiveRouter; + feeToken = _feeToken; + admin = msg.sender; + } + + function configureDestination( + address canonicalToken, + uint64 chainSelector, + address receiverBridge, + bool enabled + ) external onlyAdmin { + require(canonicalToken != address(0), "CWMultiTokenBridgeL1: zero token"); + require(receiverBridge != address(0), "CWMultiTokenBridgeL1: zero bridge"); + destinations[canonicalToken][chainSelector] = DestinationConfig({ + receiverBridge: receiverBridge, + enabled: enabled + }); + emit DestinationConfigured(canonicalToken, chainSelector, receiverBridge, enabled); + } + + function configureSupportedCanonicalToken(address canonicalToken, bool enabled) external onlyAdmin { + require(canonicalToken != address(0), "CWMultiTokenBridgeL1: zero token"); + if (!enabled) { + require(lockedBalance[canonicalToken] == 0, "CWMultiTokenBridgeL1: token still locked"); + require(totalOutstanding[canonicalToken] == 0, "CWMultiTokenBridgeL1: token still outstanding"); + } + supportedCanonicalToken[canonicalToken] = enabled; + emit SupportedCanonicalTokenConfigured(canonicalToken, enabled); + } + + function setTokenPaused(address canonicalToken, bool isPaused) external onlyAdmin { + require(canonicalToken != address(0), "CWMultiTokenBridgeL1: zero token"); + paused[canonicalToken] = isPaused; + emit TokenPauseUpdated(canonicalToken, isPaused); + } + + function setMaxOutstanding(address canonicalToken, uint64 chainSelector, uint256 amount) external onlyAdmin { + require(canonicalToken != address(0), "CWMultiTokenBridgeL1: zero token"); + maxOutstanding[canonicalToken][chainSelector] = amount; + emit MaxOutstandingUpdated(canonicalToken, chainSelector, amount); + } + + function setSendRouter(address newRouter) external onlyAdmin { + require(newRouter != address(0), "CWMultiTokenBridgeL1: zero router"); + sendRouter = IRouterClient(newRouter); + emit SendRouterUpdated(newRouter); + } + + function setReceiveRouter(address newRouter) external onlyAdmin { + require(newRouter != address(0), "CWMultiTokenBridgeL1: zero router"); + receiveRouter = newRouter; + emit ReceiveRouterUpdated(newRouter); + } + + function setFeeToken(address newFeeToken) external onlyAdmin { + feeToken = newFeeToken; + emit FeeTokenUpdated(newFeeToken); + } + + function changeAdmin(address newAdmin) external onlyAdmin { + require(newAdmin != address(0), "CWMultiTokenBridgeL1: zero admin"); + admin = newAdmin; + emit AdminChanged(newAdmin); + } + + function setReserveVerifier(address newVerifier) external onlyAdmin { + reserveVerifier = newVerifier; + emit ReserveVerifierUpdated(newVerifier); + } + + function calculateFee( + address canonicalToken, + uint64 destinationChainSelector, + address recipient, + uint256 amount + ) external view returns (uint256) { + DestinationConfig memory dest = destinations[canonicalToken][destinationChainSelector]; + require(dest.enabled, "CWMultiTokenBridgeL1: destination disabled"); + return sendRouter.getFee(destinationChainSelector, _buildMessage(dest.receiverBridge, canonicalToken, recipient, amount)); + } + + function availableToMint( + address canonicalToken, + uint64 destinationChainSelector + ) public view returns (uint256) { + return _availableToMint( + canonicalToken, + destinationChainSelector, + lockedBalance[canonicalToken], + totalOutstanding[canonicalToken], + outstandingMinted[canonicalToken][destinationChainSelector] + ); + } + + function lockAndSend( + address canonicalToken, + uint64 destinationChainSelector, + address recipient, + uint256 amount + ) external payable returns (bytes32 messageId) { + require(canonicalToken != address(0), "CWMultiTokenBridgeL1: zero token"); + require(recipient != address(0), "CWMultiTokenBridgeL1: zero recipient"); + require(amount > 0, "CWMultiTokenBridgeL1: zero amount"); + require(supportedCanonicalToken[canonicalToken], "CWMultiTokenBridgeL1: unsupported token"); + require(!paused[canonicalToken], "CWMultiTokenBridgeL1: token paused"); + + DestinationConfig memory dest = destinations[canonicalToken][destinationChainSelector]; + require(dest.enabled, "CWMultiTokenBridgeL1: destination disabled"); + + IERC20(canonicalToken).safeTransferFrom(msg.sender, address(this), amount); + uint256 nextLockedBalance = lockedBalance[canonicalToken] + amount; + uint256 currentTotalOutstanding = totalOutstanding[canonicalToken]; + uint256 currentChainOutstanding = outstandingMinted[canonicalToken][destinationChainSelector]; + require( + amount <= _availableToMint( + canonicalToken, + destinationChainSelector, + nextLockedBalance, + currentTotalOutstanding, + currentChainOutstanding + ), + "CWMultiTokenBridgeL1: exceeds escrow capacity" + ); + + lockedBalance[canonicalToken] = nextLockedBalance; + totalOutstanding[canonicalToken] = currentTotalOutstanding + amount; + outstandingMinted[canonicalToken][destinationChainSelector] = currentChainOutstanding + amount; + + if (reserveVerifier != address(0) && !ICWReserveVerifier(reserveVerifier).verifyLock(canonicalToken, destinationChainSelector, amount)) { + revert("CWMultiTokenBridgeL1: reserve verification failed"); + } + + emit Locked(canonicalToken, msg.sender, amount); + emit OutstandingUpdated( + canonicalToken, + destinationChainSelector, + outstandingMinted[canonicalToken][destinationChainSelector], + totalOutstanding[canonicalToken], + lockedBalance[canonicalToken] + ); + + IRouterClient.EVM2AnyMessage memory message = + _buildMessage(dest.receiverBridge, canonicalToken, recipient, amount); + uint256 fee = sendRouter.getFee(destinationChainSelector, message); + + _collectAndApproveFee(fee); + + (messageId, ) = feeToken == address(0) + ? sendRouter.ccipSend{value: fee}(destinationChainSelector, message) + : sendRouter.ccipSend(destinationChainSelector, message); + + emit MessageSent(messageId, canonicalToken, destinationChainSelector, recipient, amount); + _refundNativeExcess(fee); + return messageId; + } + + function ccipReceive(IRouterClient.Any2EVMMessage calldata message) external onlyReceiveRouter { + require(!processed[message.messageId], "CWMultiTokenBridgeL1: replayed"); + + (address canonicalToken, address recipient, uint256 amount) = + abi.decode(message.data, (address, address, uint256)); + require(canonicalToken != address(0), "CWMultiTokenBridgeL1: zero token"); + require(recipient != address(0), "CWMultiTokenBridgeL1: zero recipient"); + require(amount > 0, "CWMultiTokenBridgeL1: zero amount"); + require(supportedCanonicalToken[canonicalToken], "CWMultiTokenBridgeL1: unsupported token"); + require(!paused[canonicalToken], "CWMultiTokenBridgeL1: token paused"); + + DestinationConfig memory peer = destinations[canonicalToken][message.sourceChainSelector]; + require(peer.enabled, "CWMultiTokenBridgeL1: peer disabled"); + require(_decodeSender(message.sender) == peer.receiverBridge, "CWMultiTokenBridgeL1: peer mismatch"); + require(outstandingMinted[canonicalToken][message.sourceChainSelector] >= amount, "CWMultiTokenBridgeL1: outstanding underflow"); + require(totalOutstanding[canonicalToken] >= amount, "CWMultiTokenBridgeL1: total outstanding underflow"); + require(lockedBalance[canonicalToken] >= amount, "CWMultiTokenBridgeL1: locked underflow"); + + processed[message.messageId] = true; + outstandingMinted[canonicalToken][message.sourceChainSelector] -= amount; + totalOutstanding[canonicalToken] -= amount; + lockedBalance[canonicalToken] -= amount; + + IERC20(canonicalToken).safeTransfer(recipient, amount); + emit Released(canonicalToken, recipient, amount); + emit OutstandingUpdated( + canonicalToken, + message.sourceChainSelector, + outstandingMinted[canonicalToken][message.sourceChainSelector], + totalOutstanding[canonicalToken], + lockedBalance[canonicalToken] + ); + } + + function withdrawToken(address token, address to, uint256 amount) external onlyAdmin { + if (supportedCanonicalToken[token] || lockedBalance[token] > 0) { + uint256 currentBalance = IERC20(token).balanceOf(address(this)); + require(currentBalance >= lockedBalance[token], "CWMultiTokenBridgeL1: escrow invariant broken"); + require(amount <= currentBalance - lockedBalance[token], "CWMultiTokenBridgeL1: amount locked"); + } + IERC20(token).safeTransfer(to, amount); + } + + function withdrawNative(address payable to, uint256 amount) external onlyAdmin { + (bool ok, ) = to.call{value: amount}(""); + require(ok, "CWMultiTokenBridgeL1: native transfer failed"); + } + + receive() external payable {} + + function _buildMessage( + address receiverBridge, + address canonicalToken, + address recipient, + uint256 amount + ) internal view returns (IRouterClient.EVM2AnyMessage memory message) { + message = IRouterClient.EVM2AnyMessage({ + receiver: abi.encode(receiverBridge), + data: abi.encode(canonicalToken, recipient, amount), + tokenAmounts: new IRouterClient.TokenAmount[](0), + feeToken: feeToken, + extraArgs: "" + }); + } + + function _collectAndApproveFee(uint256 fee) internal { + if (fee == 0) { + return; + } + + if (feeToken == address(0)) { + require(msg.value >= fee, "CWMultiTokenBridgeL1: insufficient native fee"); + return; + } + + IERC20 feeErc20 = IERC20(feeToken); + feeErc20.safeTransferFrom(msg.sender, address(this), fee); + feeErc20.forceApprove(address(sendRouter), fee); + } + + function _refundNativeExcess(uint256 feeUsed) internal { + if (feeToken != address(0) || msg.value <= feeUsed) { + return; + } + + uint256 refund = msg.value - feeUsed; + (bool ok, ) = payable(msg.sender).call{value: refund}(""); + require(ok, "CWMultiTokenBridgeL1: refund failed"); + } + + function _decodeSender(bytes memory senderData) internal pure returns (address senderAddress) { + if (senderData.length == 0) { + return address(0); + } + senderAddress = abi.decode(senderData, (address)); + } + + function _availableToMint( + address canonicalToken, + uint64 destinationChainSelector, + uint256 lockedBalanceAmount, + uint256 totalOutstandingAmount, + uint256 chainOutstanding + ) internal view returns (uint256) { + if (!supportedCanonicalToken[canonicalToken]) { + return 0; + } + + uint256 globalAvailable = lockedBalanceAmount > totalOutstandingAmount + ? lockedBalanceAmount - totalOutstandingAmount + : 0; + + uint256 chainCap = maxOutstanding[canonicalToken][destinationChainSelector]; + if (chainCap == 0) { + return globalAvailable; + } + + if (chainOutstanding >= chainCap) { + return 0; + } + + uint256 chainAvailable = chainCap - chainOutstanding; + return globalAvailable < chainAvailable ? globalAvailable : chainAvailable; + } +} diff --git a/contracts/bridge/CWMultiTokenBridgeL2.sol b/contracts/bridge/CWMultiTokenBridgeL2.sol new file mode 100644 index 0000000..de818d6 --- /dev/null +++ b/contracts/bridge/CWMultiTokenBridgeL2.sol @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import "../ccip/IRouterClient.sol"; + +interface ICWMintBurnToken is IERC20 { + function mint(address to, uint256 amount) external; + function burnFrom(address from, uint256 amount) external; +} + +/** + * @title CWMultiTokenBridgeL2 + * @notice Destination/public-chain bridge for minting and burning cW* assets without prefunding. + * @dev Supports multiple canonical -> mirrored token pairs behind one bridge address. + */ +contract CWMultiTokenBridgeL2 { + using SafeERC20 for IERC20; + + IRouterClient public sendRouter; + address public receiveRouter; + address public feeToken; + address public admin; + + struct DestinationConfig { + address receiverBridge; + bool enabled; + } + + mapping(uint64 => DestinationConfig) public destinations; + mapping(address => address) public canonicalToMirrored; + mapping(address => address) public mirroredToCanonical; + mapping(bytes32 => bool) public processed; + mapping(address => bool) public tokenPairFrozen; + mapping(uint64 => bool) public destinationFrozen; + mapping(address => bool) public paused; + mapping(address => uint256) public mintedTotal; + mapping(address => uint256) public burnedTotal; + + event TokenPairConfigured(address indexed canonicalToken, address indexed mirroredToken); + event DestinationConfigured(uint64 indexed chainSelector, address receiverBridge, bool enabled); + event Minted(address indexed canonicalToken, address indexed mirroredToken, address indexed recipient, uint256 amount); + event Burned(address indexed canonicalToken, address indexed mirroredToken, address indexed user, uint256 amount); + event MessageSent( + bytes32 indexed messageId, + address indexed canonicalToken, + address indexed mirroredToken, + uint64 destinationChainSelector, + address recipient, + uint256 amount + ); + event TokenPairFrozen(address indexed canonicalToken, address indexed mirroredToken); + event DestinationFrozen(uint64 indexed chainSelector, address receiverBridge); + event MirroredTokenPauseUpdated(address indexed mirroredToken, bool paused); + event SupplyAccountingUpdated( + address indexed canonicalToken, + address indexed mirroredToken, + uint256 mintedTotalAmount, + uint256 burnedTotalAmount, + uint256 liveSupply + ); + event SendRouterUpdated(address indexed newRouter); + event ReceiveRouterUpdated(address indexed newRouter); + event FeeTokenUpdated(address indexed newFeeToken); + event AdminChanged(address indexed newAdmin); + + modifier onlyAdmin() { + require(msg.sender == admin, "CWMultiTokenBridgeL2: only admin"); + _; + } + + modifier onlyReceiveRouter() { + require(msg.sender == receiveRouter, "CWMultiTokenBridgeL2: only receive router"); + _; + } + + constructor(address _sendRouter, address _receiveRouter, address _feeToken) { + require(_sendRouter != address(0), "CWMultiTokenBridgeL2: zero send router"); + require(_receiveRouter != address(0), "CWMultiTokenBridgeL2: zero receive router"); + sendRouter = IRouterClient(_sendRouter); + receiveRouter = _receiveRouter; + feeToken = _feeToken; + admin = msg.sender; + } + + function configureTokenPair(address canonicalToken, address mirroredToken) external onlyAdmin { + require(canonicalToken != address(0), "CWMultiTokenBridgeL2: zero canonical"); + require(mirroredToken != address(0), "CWMultiTokenBridgeL2: zero mirrored"); + require(!tokenPairFrozen[canonicalToken], "CWMultiTokenBridgeL2: token pair frozen"); + + address previousCanonical = mirroredToCanonical[mirroredToken]; + if (previousCanonical != address(0) && previousCanonical != canonicalToken) { + require(!tokenPairFrozen[previousCanonical], "CWMultiTokenBridgeL2: token pair frozen"); + delete canonicalToMirrored[previousCanonical]; + } + + canonicalToMirrored[canonicalToken] = mirroredToken; + mirroredToCanonical[mirroredToken] = canonicalToken; + emit TokenPairConfigured(canonicalToken, mirroredToken); + } + + function configureDestination(uint64 chainSelector, address receiverBridge, bool enabled) external onlyAdmin { + require(receiverBridge != address(0), "CWMultiTokenBridgeL2: zero bridge"); + require(!destinationFrozen[chainSelector], "CWMultiTokenBridgeL2: destination frozen"); + destinations[chainSelector] = DestinationConfig({ + receiverBridge: receiverBridge, + enabled: enabled + }); + emit DestinationConfigured(chainSelector, receiverBridge, enabled); + } + + function freezeTokenPair(address canonicalToken) external onlyAdmin { + address mirroredToken = canonicalToMirrored[canonicalToken]; + require(mirroredToken != address(0), "CWMultiTokenBridgeL2: token not configured"); + tokenPairFrozen[canonicalToken] = true; + emit TokenPairFrozen(canonicalToken, mirroredToken); + } + + function freezeDestination(uint64 chainSelector) external onlyAdmin { + address receiverBridge = destinations[chainSelector].receiverBridge; + require(receiverBridge != address(0), "CWMultiTokenBridgeL2: destination not configured"); + destinationFrozen[chainSelector] = true; + emit DestinationFrozen(chainSelector, receiverBridge); + } + + function setTokenPaused(address mirroredToken, bool isPaused) external onlyAdmin { + require(mirroredToCanonical[mirroredToken] != address(0), "CWMultiTokenBridgeL2: token not configured"); + paused[mirroredToken] = isPaused; + emit MirroredTokenPauseUpdated(mirroredToken, isPaused); + } + + function setSendRouter(address newRouter) external onlyAdmin { + require(newRouter != address(0), "CWMultiTokenBridgeL2: zero router"); + sendRouter = IRouterClient(newRouter); + emit SendRouterUpdated(newRouter); + } + + function setReceiveRouter(address newRouter) external onlyAdmin { + require(newRouter != address(0), "CWMultiTokenBridgeL2: zero router"); + receiveRouter = newRouter; + emit ReceiveRouterUpdated(newRouter); + } + + function setFeeToken(address newFeeToken) external onlyAdmin { + feeToken = newFeeToken; + emit FeeTokenUpdated(newFeeToken); + } + + function changeAdmin(address newAdmin) external onlyAdmin { + require(newAdmin != address(0), "CWMultiTokenBridgeL2: zero admin"); + admin = newAdmin; + emit AdminChanged(newAdmin); + } + + function calculateFee( + address mirroredToken, + uint64 destinationChainSelector, + address recipient, + uint256 amount + ) external view returns (uint256) { + address canonicalToken = mirroredToCanonical[mirroredToken]; + require(canonicalToken != address(0), "CWMultiTokenBridgeL2: token not configured"); + DestinationConfig memory dest = destinations[destinationChainSelector]; + require(dest.enabled, "CWMultiTokenBridgeL2: destination disabled"); + return sendRouter.getFee(destinationChainSelector, _buildMessage(dest.receiverBridge, canonicalToken, recipient, amount)); + } + + function circulatingSupply(address mirroredToken) external view returns (uint256) { + uint256 totalMinted = mintedTotal[mirroredToken]; + uint256 totalBurned = burnedTotal[mirroredToken]; + return totalMinted > totalBurned ? totalMinted - totalBurned : 0; + } + + function ccipReceive(IRouterClient.Any2EVMMessage calldata message) external onlyReceiveRouter { + require(!processed[message.messageId], "CWMultiTokenBridgeL2: replayed"); + + (address canonicalToken, address recipient, uint256 amount) = + abi.decode(message.data, (address, address, uint256)); + require(recipient != address(0), "CWMultiTokenBridgeL2: zero recipient"); + require(amount > 0, "CWMultiTokenBridgeL2: zero amount"); + + address mirroredToken = canonicalToMirrored[canonicalToken]; + require(mirroredToken != address(0), "CWMultiTokenBridgeL2: token not configured"); + require(!paused[mirroredToken], "CWMultiTokenBridgeL2: token paused"); + + DestinationConfig memory peer = destinations[message.sourceChainSelector]; + require(peer.enabled, "CWMultiTokenBridgeL2: peer disabled"); + require(_decodeSender(message.sender) == peer.receiverBridge, "CWMultiTokenBridgeL2: peer mismatch"); + + processed[message.messageId] = true; + ICWMintBurnToken(mirroredToken).mint(recipient, amount); + mintedTotal[mirroredToken] += amount; + emit Minted(canonicalToken, mirroredToken, recipient, amount); + emit SupplyAccountingUpdated( + canonicalToken, + mirroredToken, + mintedTotal[mirroredToken], + burnedTotal[mirroredToken], + IERC20(mirroredToken).totalSupply() + ); + } + + function burnAndSend( + address mirroredToken, + uint64 destinationChainSelector, + address recipient, + uint256 amount + ) external payable returns (bytes32 messageId) { + require(recipient != address(0), "CWMultiTokenBridgeL2: zero recipient"); + require(amount > 0, "CWMultiTokenBridgeL2: zero amount"); + + address canonicalToken = mirroredToCanonical[mirroredToken]; + require(canonicalToken != address(0), "CWMultiTokenBridgeL2: token not configured"); + require(!paused[mirroredToken], "CWMultiTokenBridgeL2: token paused"); + + DestinationConfig memory dest = destinations[destinationChainSelector]; + require(dest.enabled, "CWMultiTokenBridgeL2: destination disabled"); + + ICWMintBurnToken(mirroredToken).burnFrom(msg.sender, amount); + burnedTotal[mirroredToken] += amount; + emit Burned(canonicalToken, mirroredToken, msg.sender, amount); + emit SupplyAccountingUpdated( + canonicalToken, + mirroredToken, + mintedTotal[mirroredToken], + burnedTotal[mirroredToken], + IERC20(mirroredToken).totalSupply() + ); + + IRouterClient.EVM2AnyMessage memory message = + _buildMessage(dest.receiverBridge, canonicalToken, recipient, amount); + uint256 fee = sendRouter.getFee(destinationChainSelector, message); + + _collectAndApproveFee(fee); + + (messageId, ) = feeToken == address(0) + ? sendRouter.ccipSend{value: fee}(destinationChainSelector, message) + : sendRouter.ccipSend(destinationChainSelector, message); + + emit MessageSent(messageId, canonicalToken, mirroredToken, destinationChainSelector, recipient, amount); + _refundNativeExcess(fee); + return messageId; + } + + function withdrawToken(address token, address to, uint256 amount) external onlyAdmin { + IERC20(token).safeTransfer(to, amount); + } + + function withdrawNative(address payable to, uint256 amount) external onlyAdmin { + (bool ok, ) = to.call{value: amount}(""); + require(ok, "CWMultiTokenBridgeL2: native transfer failed"); + } + + receive() external payable {} + + function _buildMessage( + address receiverBridge, + address canonicalToken, + address recipient, + uint256 amount + ) internal view returns (IRouterClient.EVM2AnyMessage memory message) { + message = IRouterClient.EVM2AnyMessage({ + receiver: abi.encode(receiverBridge), + data: abi.encode(canonicalToken, recipient, amount), + tokenAmounts: new IRouterClient.TokenAmount[](0), + feeToken: feeToken, + extraArgs: "" + }); + } + + function _collectAndApproveFee(uint256 fee) internal { + if (fee == 0) { + return; + } + + if (feeToken == address(0)) { + require(msg.value >= fee, "CWMultiTokenBridgeL2: insufficient native fee"); + return; + } + + IERC20 feeErc20 = IERC20(feeToken); + feeErc20.safeTransferFrom(msg.sender, address(this), fee); + feeErc20.forceApprove(address(sendRouter), fee); + } + + function _refundNativeExcess(uint256 feeUsed) internal { + if (feeToken != address(0) || msg.value <= feeUsed) { + return; + } + + uint256 refund = msg.value - feeUsed; + (bool ok, ) = payable(msg.sender).call{value: refund}(""); + require(ok, "CWMultiTokenBridgeL2: refund failed"); + } + + function _decodeSender(bytes memory senderData) internal pure returns (address senderAddress) { + if (senderData.length == 0) { + return address(0); + } + senderAddress = abi.decode(senderData, (address)); + } +} diff --git a/contracts/bridge/UniversalCCIPBridge.sol b/contracts/bridge/UniversalCCIPBridge.sol index 12e71c3..1350547 100644 --- a/contracts/bridge/UniversalCCIPBridge.sol +++ b/contracts/bridge/UniversalCCIPBridge.sol @@ -2,11 +2,11 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "../vendor/openzeppelin/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "../vendor/openzeppelin/ReentrancyGuardUpgradeable.sol"; import "../registry/UniversalAssetRegistry.sol"; import "../ccip/IRouterClient.sol"; diff --git a/contracts/bridge/atomic/AtomicBridgeCoordinator.sol b/contracts/bridge/atomic/AtomicBridgeCoordinator.sol new file mode 100644 index 0000000..c417963 --- /dev/null +++ b/contracts/bridge/atomic/AtomicBridgeCoordinator.sol @@ -0,0 +1,324 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {AtomicTypes} from "./AtomicTypes.sol"; +import {IAtomicBridgeCoordinator} from "./interfaces/IAtomicBridgeCoordinator.sol"; +import {IAtomicLiquidityVault} from "./interfaces/IAtomicLiquidityVault.sol"; +import {IAtomicFulfillerRegistry} from "./interfaces/IAtomicFulfillerRegistry.sol"; +import {AtomicObligationEscrow} from "./AtomicObligationEscrow.sol"; +import {AtomicSettlementRouter} from "./AtomicSettlementRouter.sol"; +import {AtomicFeePolicy} from "./AtomicFeePolicy.sol"; +import {AtomicSlashingManager} from "./AtomicSlashingManager.sol"; + +contract AtomicBridgeCoordinator is AccessControl, ReentrancyGuard, IAtomicBridgeCoordinator { + bytes32 public constant CORRIDOR_MANAGER_ROLE = keccak256("CORRIDOR_MANAGER_ROLE"); + bytes32 public constant SETTLEMENT_MANAGER_ROLE = keccak256("SETTLEMENT_MANAGER_ROLE"); + + IAtomicLiquidityVault public immutable liquidityVault; + IAtomicFulfillerRegistry public immutable fulfillerRegistry; + AtomicObligationEscrow public immutable obligationEscrow; + AtomicSettlementRouter public immutable settlementRouter; + AtomicFeePolicy public immutable feePolicy; + AtomicSlashingManager public immutable slashingManager; + address public immutable protocolTreasury; + + struct CreateIntentParams { + uint64 sourceChain; + uint64 destinationChain; + address assetIn; + address assetOut; + uint256 amountIn; + uint256 minAmountOut; + address recipient; + uint256 deadline; + bytes32 routeId; + } + + uint256 public intentNonce; + mapping(bytes32 => AtomicTypes.CorridorConfig) private _corridors; + mapping(bytes32 => AtomicTypes.AtomicIntent) private _intents; + mapping(bytes32 => AtomicTypes.AtomicCommitment) private _commitments; + mapping(bytes32 => AtomicTypes.AtomicObligation) private _obligations; + + event CorridorConfigured(bytes32 indexed corridorId, address indexed assetIn, address indexed assetOut); + event IntentCreated(bytes32 indexed obligationId, bytes32 indexed corridorId, bytes32 indexed intentId, address sender); + event CommitmentAccepted(bytes32 indexed obligationId, address indexed fulfiller, uint256 bondAmount); + event SettlementInitiated(bytes32 indexed obligationId, bytes32 indexed settlementId, bytes32 indexed settlementMode); + event SettlementConfirmed(bytes32 indexed obligationId, uint256 replenishAmount); + event IntentRefunded(bytes32 indexed obligationId, uint256 refundedAmount); + event CorridorDegraded(bytes32 indexed corridorId, bytes32 indexed obligationId, bytes32 reason); + + error CorridorDisabled(); + error CorridorDegradedError(); + error InvalidCorridor(); + error InvalidDeadline(); + error MaxNotionalExceeded(); + error ReservedLiquidityLimitExceeded(); + error SettlementBacklogExceeded(); + error InvalidStatus(); + error DeadlineNotReached(); + error SettlementTimeoutNotReached(); + error MinimumReplenishNotMet(); + + constructor( + address liquidityVault_, + address fulfillerRegistry_, + address obligationEscrow_, + address settlementRouter_, + address feePolicy_, + address slashingManager_, + address protocolTreasury_, + address admin + ) { + liquidityVault = IAtomicLiquidityVault(liquidityVault_); + fulfillerRegistry = IAtomicFulfillerRegistry(fulfillerRegistry_); + obligationEscrow = AtomicObligationEscrow(obligationEscrow_); + settlementRouter = AtomicSettlementRouter(settlementRouter_); + feePolicy = AtomicFeePolicy(feePolicy_); + slashingManager = AtomicSlashingManager(slashingManager_); + protocolTreasury = protocolTreasury_; + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(CORRIDOR_MANAGER_ROLE, admin); + _grantRole(SETTLEMENT_MANAGER_ROLE, admin); + } + + function getCorridorId( + uint64 sourceChain, + uint64 destinationChain, + address assetIn, + address assetOut + ) public pure returns (bytes32) { + return keccak256(abi.encode(sourceChain, destinationChain, assetIn, assetOut)); + } + + function configureCorridor(AtomicTypes.CorridorConfig calldata cfg) external onlyRole(CORRIDOR_MANAGER_ROLE) { + bytes32 corridorId = getCorridorId(cfg.sourceChain, cfg.destinationChain, cfg.assetIn, cfg.assetOut); + _corridors[corridorId] = cfg; + emit CorridorConfigured(corridorId, cfg.assetIn, cfg.assetOut); + } + + function setCorridorDegraded(bytes32 corridorId, bool degraded) external onlyRole(CORRIDOR_MANAGER_ROLE) { + _corridors[corridorId].degraded = degraded; + } + + function createIntent(CreateIntentParams calldata p) external nonReentrant returns (bytes32 obligationId) { + bytes32 corridorId = getCorridorId(p.sourceChain, p.destinationChain, p.assetIn, p.assetOut); + AtomicTypes.CorridorConfig memory cfg = _corridors[corridorId]; + if (!cfg.enabled) revert CorridorDisabled(); + if (cfg.degraded) revert CorridorDegradedError(); + if (cfg.assetIn != p.assetIn || cfg.assetOut != p.assetOut) revert InvalidCorridor(); + if (p.deadline <= block.timestamp || p.deadline > block.timestamp + cfg.fulfilmentTimeout) revert InvalidDeadline(); + if (p.amountIn > cfg.maxNotional) revert MaxNotionalExceeded(); + + AtomicTypes.CorridorLiquidityState memory state = liquidityVault.getCorridorLiquidityState(corridorId, p.assetOut); + if (p.minAmountOut > state.freeLiquidity) revert ReservedLiquidityLimitExceeded(); + if (state.totalLiquidity > 0) { + uint256 nextReserved = state.reservedLiquidity + p.minAmountOut; + if ((nextReserved * 10_000) / state.totalLiquidity > cfg.maxReservedBps) { + revert ReservedLiquidityLimitExceeded(); + } + } + if (state.settlementBacklog > cfg.maxSettlementBacklog) revert SettlementBacklogExceeded(); + + bytes32 intentId = keccak256( + abi.encode( + block.chainid, + msg.sender, + ++intentNonce, + corridorId, + p.amountIn, + p.minAmountOut, + p.deadline, + p.routeId + ) + ); + obligationId = keccak256(abi.encode(intentId, p.recipient)); + + _intents[obligationId] = AtomicTypes.AtomicIntent({ + sourceChain: p.sourceChain, + destinationChain: p.destinationChain, + assetIn: p.assetIn, + assetOut: p.assetOut, + amountIn: p.amountIn, + minAmountOut: p.minAmountOut, + recipient: p.recipient, + deadline: p.deadline, + routeId: p.routeId, + intentId: intentId + }); + _obligations[obligationId] = AtomicTypes.AtomicObligation({ + obligationId: obligationId, + intentId: intentId, + sourceEscrow: p.amountIn, + destinationReserve: p.minAmountOut, + fulfiller: address(0), + status: AtomicTypes.ObligationStatus.IntentCreated, + settlementInitiatedAt: 0 + }); + + obligationEscrow.escrowFunds(obligationId, p.assetIn, msg.sender, p.amountIn); + liquidityVault.reserveLiquidity(corridorId, p.assetOut, obligationId, p.minAmountOut); + + emit IntentCreated(obligationId, corridorId, intentId, msg.sender); + } + + function submitCommitment(bytes32 obligationId, bytes32 settlementMode) external nonReentrant { + AtomicTypes.AtomicIntent memory intent = _intents[obligationId]; + AtomicTypes.AtomicObligation storage obligation = _obligations[obligationId]; + if (obligation.status != AtomicTypes.ObligationStatus.IntentCreated) revert InvalidStatus(); + if (block.timestamp > intent.deadline) revert DeadlineNotReached(); + + bytes32 corridorId = getCorridorId(intent.sourceChain, intent.destinationChain, intent.assetIn, intent.assetOut); + bytes32 mode = settlementMode == bytes32(0) ? _corridors[corridorId].defaultSettlementMode : settlementMode; + uint256 requiredBond = feePolicy.requiredBond(corridorId, obligation.destinationReserve); + fulfillerRegistry.lockBond(obligationId, msg.sender, corridorId, requiredBond); + liquidityVault.fulfillReservedLiquidity(obligationId, intent.recipient); + + _commitments[obligationId] = AtomicTypes.AtomicCommitment({ + intentId: intent.intentId, + fulfiller: msg.sender, + reservedLiquidity: obligation.destinationReserve, + bondAmount: requiredBond, + expiry: block.timestamp + _corridors[corridorId].settlementTimeout, + settlementMode: mode + }); + obligation.fulfiller = msg.sender; + obligation.status = AtomicTypes.ObligationStatus.Fulfilled; + + emit CommitmentAccepted(obligationId, msg.sender, requiredBond); + } + + function initiateSettlement(bytes32 obligationId, bytes calldata settlementData) + external + payable + nonReentrant + onlyRole(SETTLEMENT_MANAGER_ROLE) + returns (bytes32 settlementId) + { + AtomicTypes.AtomicIntent memory intent = _intents[obligationId]; + AtomicTypes.AtomicCommitment memory commitment = _commitments[obligationId]; + AtomicTypes.AtomicObligation storage obligation = _obligations[obligationId]; + if (obligation.status != AtomicTypes.ObligationStatus.Fulfilled) revert InvalidStatus(); + + bytes32 corridorId = getCorridorId(intent.sourceChain, intent.destinationChain, intent.assetIn, intent.assetOut); + uint256 settlementAmount = _releaseEscrowForSettlement( + obligationId, + corridorId, + intent.amountIn, + commitment.fulfiller + ); + settlementId = _executeSettlement( + obligationId, + commitment.settlementMode, + intent.assetIn, + settlementAmount, + intent.recipient, + settlementData + ); + + obligation.status = AtomicTypes.ObligationStatus.SettlementPending; + obligation.settlementInitiatedAt = block.timestamp; + emit SettlementInitiated(obligationId, settlementId, commitment.settlementMode); + } + + function _releaseEscrowForSettlement( + bytes32 obligationId, + bytes32 corridorId, + uint256 amountIn, + address fulfiller + ) internal returns (uint256 settlementAmount) { + (uint256 fulfillerFee, uint256 protocolFee) = feePolicy.quoteFees(corridorId, amountIn); + settlementAmount = amountIn - fulfillerFee - protocolFee; + + if (protocolFee > 0) { + obligationEscrow.release(obligationId, protocolTreasury, protocolFee); + } + if (fulfillerFee > 0) { + obligationEscrow.release(obligationId, fulfiller, fulfillerFee); + } + obligationEscrow.release(obligationId, address(settlementRouter), settlementAmount); + } + + function _executeSettlement( + bytes32 obligationId, + bytes32 settlementMode, + address assetIn, + uint256 settlementAmount, + address recipient, + bytes calldata settlementData + ) internal returns (bytes32 settlementId) { + settlementId = settlementRouter.executeSettlement{value: msg.value}( + obligationId, + settlementMode, + assetIn, + settlementAmount, + recipient, + settlementData + ); + } + + function confirmSettlement(bytes32 obligationId, uint256 replenishAmount) + external + nonReentrant + onlyRole(SETTLEMENT_MANAGER_ROLE) + { + AtomicTypes.AtomicIntent memory intent = _intents[obligationId]; + AtomicTypes.AtomicObligation storage obligation = _obligations[obligationId]; + if (obligation.status != AtomicTypes.ObligationStatus.SettlementPending) revert InvalidStatus(); + if (replenishAmount < obligation.destinationReserve) revert MinimumReplenishNotMet(); + + bytes32 corridorId = getCorridorId(intent.sourceChain, intent.destinationChain, intent.assetIn, intent.assetOut); + liquidityVault.reconcileSettlement(corridorId, intent.assetOut, replenishAmount, msg.sender); + fulfillerRegistry.releaseBond(obligationId); + obligation.status = AtomicTypes.ObligationStatus.Settled; + emit SettlementConfirmed(obligationId, replenishAmount); + } + + function refundExpiredIntent(bytes32 obligationId) external nonReentrant { + AtomicTypes.AtomicIntent memory intent = _intents[obligationId]; + AtomicTypes.AtomicObligation storage obligation = _obligations[obligationId]; + if (obligation.status != AtomicTypes.ObligationStatus.IntentCreated) revert InvalidStatus(); + if (block.timestamp <= intent.deadline) revert DeadlineNotReached(); + + bytes32 corridorId = getCorridorId(intent.sourceChain, intent.destinationChain, intent.assetIn, intent.assetOut); + (, address payer,,,) = obligationEscrow.escrows(obligationId); + liquidityVault.releaseReservation(obligationId); + uint256 refunded = obligationEscrow.refundRemaining(obligationId, payer); + _corridors[corridorId].degraded = true; + obligation.status = AtomicTypes.ObligationStatus.Refunded; + emit IntentRefunded(obligationId, refunded); + emit CorridorDegraded(corridorId, obligationId, keccak256("FULFILMENT_TIMEOUT")); + } + + function handleSettlementTimeout(bytes32 obligationId) external nonReentrant { + AtomicTypes.AtomicIntent memory intent = _intents[obligationId]; + AtomicTypes.AtomicCommitment memory commitment = _commitments[obligationId]; + AtomicTypes.AtomicObligation storage obligation = _obligations[obligationId]; + if (obligation.status != AtomicTypes.ObligationStatus.SettlementPending) revert InvalidStatus(); + if (block.timestamp <= commitment.expiry) revert SettlementTimeoutNotReached(); + + bytes32 corridorId = getCorridorId(intent.sourceChain, intent.destinationChain, intent.assetIn, intent.assetOut); + _corridors[corridorId].degraded = true; + slashingManager.slash(obligationId, protocolTreasury, keccak256("SETTLEMENT_TIMEOUT")); + obligation.status = AtomicTypes.ObligationStatus.Slashed; + emit CorridorDegraded(corridorId, obligationId, keccak256("SETTLEMENT_TIMEOUT")); + } + + function getCorridorConfig(bytes32 corridorId) external view returns (AtomicTypes.CorridorConfig memory) { + return _corridors[corridorId]; + } + + function getIntent(bytes32 obligationId) external view returns (AtomicTypes.AtomicIntent memory) { + return _intents[obligationId]; + } + + function getCommitment(bytes32 obligationId) external view returns (AtomicTypes.AtomicCommitment memory) { + return _commitments[obligationId]; + } + + function getObligation(bytes32 obligationId) external view returns (AtomicTypes.AtomicObligation memory) { + return _obligations[obligationId]; + } +} diff --git a/contracts/bridge/atomic/AtomicFeePolicy.sol b/contracts/bridge/atomic/AtomicFeePolicy.sol new file mode 100644 index 0000000..af1889e --- /dev/null +++ b/contracts/bridge/atomic/AtomicFeePolicy.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; + +contract AtomicFeePolicy is AccessControl { + bytes32 public constant POLICY_MANAGER_ROLE = keccak256("POLICY_MANAGER_ROLE"); + + struct CorridorPolicy { + uint16 fulfillerFeeBps; + uint16 protocolFeeBps; + uint16 bondBps; + uint16 slashPenaltyBps; + uint256 maxFulfilmentDelay; + uint256 maxSettlementDelay; + } + + mapping(bytes32 => CorridorPolicy) public corridorPolicies; + + event CorridorPolicySet( + bytes32 indexed corridorId, + uint16 fulfillerFeeBps, + uint16 protocolFeeBps, + uint16 bondBps, + uint16 slashPenaltyBps, + uint256 maxFulfilmentDelay, + uint256 maxSettlementDelay + ); + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(POLICY_MANAGER_ROLE, admin); + } + + function setCorridorPolicy( + bytes32 corridorId, + uint16 fulfillerFeeBps, + uint16 protocolFeeBps, + uint16 bondBps, + uint16 slashPenaltyBps, + uint256 maxFulfilmentDelay, + uint256 maxSettlementDelay + ) external onlyRole(POLICY_MANAGER_ROLE) { + corridorPolicies[corridorId] = CorridorPolicy({ + fulfillerFeeBps: fulfillerFeeBps, + protocolFeeBps: protocolFeeBps, + bondBps: bondBps, + slashPenaltyBps: slashPenaltyBps, + maxFulfilmentDelay: maxFulfilmentDelay, + maxSettlementDelay: maxSettlementDelay + }); + emit CorridorPolicySet( + corridorId, + fulfillerFeeBps, + protocolFeeBps, + bondBps, + slashPenaltyBps, + maxFulfilmentDelay, + maxSettlementDelay + ); + } + + function quoteFees(bytes32 corridorId, uint256 amountIn) + external + view + returns (uint256 fulfillerFee, uint256 protocolFee) + { + CorridorPolicy memory policy = corridorPolicies[corridorId]; + fulfillerFee = (amountIn * policy.fulfillerFeeBps) / 10_000; + protocolFee = (amountIn * policy.protocolFeeBps) / 10_000; + } + + function requiredBond(bytes32 corridorId, uint256 reservedLiquidity) external view returns (uint256) { + CorridorPolicy memory policy = corridorPolicies[corridorId]; + uint256 effectiveBps = policy.bondBps; + uint256 slashFloor = 10_000 + policy.slashPenaltyBps; + if (effectiveBps < slashFloor) { + effectiveBps = slashFloor; + } + return (reservedLiquidity * effectiveBps) / 10_000; + } +} diff --git a/contracts/bridge/atomic/AtomicFulfillerRegistry.sol b/contracts/bridge/atomic/AtomicFulfillerRegistry.sol new file mode 100644 index 0000000..d0c90f6 --- /dev/null +++ b/contracts/bridge/atomic/AtomicFulfillerRegistry.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IAtomicFulfillerRegistry} from "./interfaces/IAtomicFulfillerRegistry.sol"; + +contract AtomicFulfillerRegistry is AccessControl, IAtomicFulfillerRegistry { + using SafeERC20 for IERC20; + + bytes32 public constant COORDINATOR_ROLE = keccak256("COORDINATOR_ROLE"); + bytes32 public constant SLASHER_ROLE = keccak256("SLASHER_ROLE"); + bytes32 public constant AUTH_MANAGER_ROLE = keccak256("AUTH_MANAGER_ROLE"); + + struct LockedBond { + address fulfiller; + bytes32 corridorId; + uint256 amount; + bool active; + } + + IERC20 public immutable bondToken; + + mapping(address => uint256) public totalBond; + mapping(address => uint256) public lockedBond; + mapping(address => bool) public fulfillerActive; + mapping(address => mapping(bytes32 => bool)) public corridorAuthorization; + mapping(bytes32 => LockedBond) public lockedBonds; + + event BondDeposited(address indexed fulfiller, uint256 amount); + event BondWithdrawn(address indexed fulfiller, address indexed recipient, uint256 amount); + event FulfillerActivationSet(address indexed fulfiller, bool active); + event CorridorAuthorizationSet(address indexed fulfiller, bytes32 indexed corridorId, bool authorized); + event BondLocked(bytes32 indexed obligationId, address indexed fulfiller, bytes32 indexed corridorId, uint256 amount); + event BondReleased(bytes32 indexed obligationId, address indexed fulfiller, uint256 amount); + event BondSlashed(bytes32 indexed obligationId, address indexed fulfiller, address indexed recipient, uint256 amount); + + error InsufficientAvailableBond(); + error UnauthorizedFulfiller(); + error BondAlreadyLocked(); + error BondMissing(); + + constructor(address bondToken_, address admin) { + bondToken = IERC20(bondToken_); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(AUTH_MANAGER_ROLE, admin); + } + + function depositBond(uint256 amount) external { + bondToken.safeTransferFrom(msg.sender, address(this), amount); + totalBond[msg.sender] += amount; + emit BondDeposited(msg.sender, amount); + } + + function withdrawBond(uint256 amount, address recipient) external { + if (availableBond(msg.sender) < amount) revert InsufficientAvailableBond(); + totalBond[msg.sender] -= amount; + bondToken.safeTransfer(recipient, amount); + emit BondWithdrawn(msg.sender, recipient, amount); + } + + function setFulfillerActive(address fulfiller, bool active) external onlyRole(AUTH_MANAGER_ROLE) { + fulfillerActive[fulfiller] = active; + emit FulfillerActivationSet(fulfiller, active); + } + + function setCorridorAuthorization(address fulfiller, bytes32 corridorId, bool authorized) + external + onlyRole(AUTH_MANAGER_ROLE) + { + corridorAuthorization[fulfiller][corridorId] = authorized; + emit CorridorAuthorizationSet(fulfiller, corridorId, authorized); + } + + function lockBond(bytes32 obligationId, address fulfiller, bytes32 corridorId, uint256 amount) + external + onlyRole(COORDINATOR_ROLE) + { + if (lockedBonds[obligationId].active) revert BondAlreadyLocked(); + if (!isFulfillerAuthorized(fulfiller, corridorId)) revert UnauthorizedFulfiller(); + if (availableBond(fulfiller) < amount) revert InsufficientAvailableBond(); + + lockedBond[fulfiller] += amount; + lockedBonds[obligationId] = LockedBond({ + fulfiller: fulfiller, + corridorId: corridorId, + amount: amount, + active: true + }); + emit BondLocked(obligationId, fulfiller, corridorId, amount); + } + + function releaseBond(bytes32 obligationId) + external + onlyRole(COORDINATOR_ROLE) + returns (uint256 amount) + { + LockedBond memory entry = lockedBonds[obligationId]; + if (!entry.active) revert BondMissing(); + lockedBond[entry.fulfiller] -= entry.amount; + delete lockedBonds[obligationId]; + emit BondReleased(obligationId, entry.fulfiller, entry.amount); + return entry.amount; + } + + function slashBond(bytes32 obligationId, address recipient) + external + onlyRole(SLASHER_ROLE) + returns (uint256 amount) + { + LockedBond memory entry = lockedBonds[obligationId]; + if (!entry.active) revert BondMissing(); + lockedBond[entry.fulfiller] -= entry.amount; + totalBond[entry.fulfiller] -= entry.amount; + delete lockedBonds[obligationId]; + bondToken.safeTransfer(recipient, entry.amount); + emit BondSlashed(obligationId, entry.fulfiller, recipient, entry.amount); + return entry.amount; + } + + function availableBond(address fulfiller) public view returns (uint256) { + return totalBond[fulfiller] - lockedBond[fulfiller]; + } + + function isFulfillerAuthorized(address fulfiller, bytes32 corridorId) public view returns (bool) { + return fulfillerActive[fulfiller] && corridorAuthorization[fulfiller][corridorId]; + } + + function canCover(address fulfiller, uint256 amount) external view returns (bool) { + return availableBond(fulfiller) >= amount; + } +} diff --git a/contracts/bridge/atomic/AtomicLiquidityVault.sol b/contracts/bridge/atomic/AtomicLiquidityVault.sol new file mode 100644 index 0000000..7b32046 --- /dev/null +++ b/contracts/bridge/atomic/AtomicLiquidityVault.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IAtomicLiquidityVault} from "./interfaces/IAtomicLiquidityVault.sol"; +import {AtomicTypes} from "./AtomicTypes.sol"; + +contract AtomicLiquidityVault is AccessControl, IAtomicLiquidityVault { + using SafeERC20 for IERC20; + + bytes32 public constant COORDINATOR_ROLE = keccak256("COORDINATOR_ROLE"); + bytes32 public constant RECONCILER_ROLE = keccak256("RECONCILER_ROLE"); + bytes32 public constant BUFFER_MANAGER_ROLE = keccak256("BUFFER_MANAGER_ROLE"); + + struct StoredCorridorState { + uint256 totalLiquidity; + uint256 reservedLiquidity; + uint256 targetBuffer; + uint256 settlementBacklog; + } + + struct Reservation { + bytes32 corridorId; + address token; + uint256 amount; + bool exists; + bool fulfilled; + } + + mapping(bytes32 => mapping(address => StoredCorridorState)) private _corridorState; + mapping(bytes32 => Reservation) public reservations; + + event CorridorFunded(bytes32 indexed corridorId, address indexed token, address indexed funder, uint256 amount); + event LiquidityReserved(bytes32 indexed obligationId, bytes32 indexed corridorId, address indexed token, uint256 amount); + event ReservedLiquidityFulfilled(bytes32 indexed obligationId, address indexed recipient, uint256 amount); + event ReservationReleased(bytes32 indexed obligationId, uint256 amount); + event SettlementReconciled(bytes32 indexed corridorId, address indexed token, uint256 amount); + event TargetBufferSet(bytes32 indexed corridorId, address indexed token, uint256 targetBuffer); + + error ReservationExists(); + error ReservationMissing(); + error ReservationAlreadyFulfilled(); + error InsufficientFreeLiquidity(); + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(RECONCILER_ROLE, admin); + _grantRole(BUFFER_MANAGER_ROLE, admin); + } + + function fundCorridor(bytes32 corridorId, address token, uint256 amount) external { + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + _corridorState[corridorId][token].totalLiquidity += amount; + emit CorridorFunded(corridorId, token, msg.sender, amount); + } + + function setTargetBuffer(bytes32 corridorId, address token, uint256 targetBuffer) + external + onlyRole(BUFFER_MANAGER_ROLE) + { + _corridorState[corridorId][token].targetBuffer = targetBuffer; + emit TargetBufferSet(corridorId, token, targetBuffer); + } + + function reserveLiquidity(bytes32 corridorId, address token, bytes32 obligationId, uint256 amount) + external + onlyRole(COORDINATOR_ROLE) + { + if (reservations[obligationId].exists) revert ReservationExists(); + if (freeLiquidity(corridorId, token) < amount) revert InsufficientFreeLiquidity(); + + reservations[obligationId] = Reservation({ + corridorId: corridorId, + token: token, + amount: amount, + exists: true, + fulfilled: false + }); + _corridorState[corridorId][token].reservedLiquidity += amount; + emit LiquidityReserved(obligationId, corridorId, token, amount); + } + + function fulfillReservedLiquidity(bytes32 obligationId, address recipient) + external + onlyRole(COORDINATOR_ROLE) + returns (uint256 amount) + { + Reservation storage reservation = reservations[obligationId]; + if (!reservation.exists) revert ReservationMissing(); + if (reservation.fulfilled) revert ReservationAlreadyFulfilled(); + + reservation.fulfilled = true; + StoredCorridorState storage state = _corridorState[reservation.corridorId][reservation.token]; + state.reservedLiquidity -= reservation.amount; + state.totalLiquidity -= reservation.amount; + state.settlementBacklog += reservation.amount; + + IERC20(reservation.token).safeTransfer(recipient, reservation.amount); + emit ReservedLiquidityFulfilled(obligationId, recipient, reservation.amount); + return reservation.amount; + } + + function releaseReservation(bytes32 obligationId) + external + onlyRole(COORDINATOR_ROLE) + returns (uint256 amount) + { + Reservation storage reservation = reservations[obligationId]; + if (!reservation.exists) revert ReservationMissing(); + if (reservation.fulfilled) revert ReservationAlreadyFulfilled(); + + amount = reservation.amount; + _corridorState[reservation.corridorId][reservation.token].reservedLiquidity -= amount; + delete reservations[obligationId]; + emit ReservationReleased(obligationId, amount); + } + + function reconcileSettlement(bytes32 corridorId, address token, uint256 amount, address from) + external + onlyRole(RECONCILER_ROLE) + { + IERC20(token).safeTransferFrom(from, address(this), amount); + StoredCorridorState storage state = _corridorState[corridorId][token]; + state.totalLiquidity += amount; + uint256 backlog = state.settlementBacklog; + state.settlementBacklog = amount >= backlog ? 0 : backlog - amount; + emit SettlementReconciled(corridorId, token, amount); + } + + function getCorridorLiquidityState(bytes32 corridorId, address token) + external + view + returns (AtomicTypes.CorridorLiquidityState memory state) + { + StoredCorridorState memory stored = _corridorState[corridorId][token]; + state = AtomicTypes.CorridorLiquidityState({ + totalLiquidity: stored.totalLiquidity, + reservedLiquidity: stored.reservedLiquidity, + freeLiquidity: stored.totalLiquidity - stored.reservedLiquidity, + targetBuffer: stored.targetBuffer, + settlementBacklog: stored.settlementBacklog + }); + } + + function freeLiquidity(bytes32 corridorId, address token) public view returns (uint256) { + StoredCorridorState memory state = _corridorState[corridorId][token]; + return state.totalLiquidity - state.reservedLiquidity; + } +} diff --git a/contracts/bridge/atomic/AtomicObligationEscrow.sol b/contracts/bridge/atomic/AtomicObligationEscrow.sol new file mode 100644 index 0000000..bdfd3ae --- /dev/null +++ b/contracts/bridge/atomic/AtomicObligationEscrow.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract AtomicObligationEscrow is AccessControl { + using SafeERC20 for IERC20; + + bytes32 public constant COORDINATOR_ROLE = keccak256("COORDINATOR_ROLE"); + + struct EscrowRecord { + address token; + address payer; + uint256 totalAmount; + uint256 releasedAmount; + bool exists; + } + + mapping(bytes32 => EscrowRecord) public escrows; + + event EscrowFunded(bytes32 indexed obligationId, address indexed token, address indexed payer, uint256 amount); + event EscrowReleased(bytes32 indexed obligationId, address indexed to, uint256 amount); + event EscrowRefunded(bytes32 indexed obligationId, address indexed to, uint256 amount); + + error EscrowExists(); + error EscrowMissing(); + error InsufficientEscrow(); + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + } + + function escrowFunds(bytes32 obligationId, address token, address from, uint256 amount) + external + onlyRole(COORDINATOR_ROLE) + { + if (escrows[obligationId].exists) revert EscrowExists(); + escrows[obligationId] = EscrowRecord({ + token: token, + payer: from, + totalAmount: amount, + releasedAmount: 0, + exists: true + }); + IERC20(token).safeTransferFrom(from, address(this), amount); + emit EscrowFunded(obligationId, token, from, amount); + } + + function release(bytes32 obligationId, address to, uint256 amount) external onlyRole(COORDINATOR_ROLE) { + EscrowRecord storage record = escrows[obligationId]; + if (!record.exists) revert EscrowMissing(); + uint256 availableAmount = record.totalAmount - record.releasedAmount; + if (availableAmount < amount) revert InsufficientEscrow(); + record.releasedAmount += amount; + IERC20(record.token).safeTransfer(to, amount); + emit EscrowReleased(obligationId, to, amount); + } + + function refundRemaining(bytes32 obligationId, address to) + external + onlyRole(COORDINATOR_ROLE) + returns (uint256 refunded) + { + EscrowRecord storage record = escrows[obligationId]; + if (!record.exists) revert EscrowMissing(); + refunded = record.totalAmount - record.releasedAmount; + record.releasedAmount = record.totalAmount; + IERC20(record.token).safeTransfer(to, refunded); + emit EscrowRefunded(obligationId, to, refunded); + } + + function remaining(bytes32 obligationId) external view returns (uint256) { + EscrowRecord memory record = escrows[obligationId]; + if (!record.exists) revert EscrowMissing(); + return record.totalAmount - record.releasedAmount; + } +} diff --git a/contracts/bridge/atomic/AtomicQuoteEngine.sol b/contracts/bridge/atomic/AtomicQuoteEngine.sol new file mode 100644 index 0000000..4baff13 --- /dev/null +++ b/contracts/bridge/atomic/AtomicQuoteEngine.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IAtomicQuoteEngine} from "./interfaces/IAtomicQuoteEngine.sol"; +import {IAtomicBridgeCoordinator} from "./interfaces/IAtomicBridgeCoordinator.sol"; +import {IAtomicLiquidityVault} from "./interfaces/IAtomicLiquidityVault.sol"; +import {IAtomicFulfillerRegistry} from "./interfaces/IAtomicFulfillerRegistry.sol"; +import {AtomicFeePolicy} from "./AtomicFeePolicy.sol"; +import {AtomicTypes} from "./AtomicTypes.sol"; + +contract AtomicQuoteEngine is IAtomicQuoteEngine { + IAtomicBridgeCoordinator public immutable coordinator; + IAtomicLiquidityVault public immutable vault; + IAtomicFulfillerRegistry public immutable fulfillerRegistry; + AtomicFeePolicy public immutable feePolicy; + + constructor( + address coordinator_, + address vault_, + address fulfillerRegistry_, + address feePolicy_ + ) { + coordinator = IAtomicBridgeCoordinator(coordinator_); + vault = IAtomicLiquidityVault(vault_); + fulfillerRegistry = IAtomicFulfillerRegistry(fulfillerRegistry_); + feePolicy = AtomicFeePolicy(feePolicy_); + } + + function quote( + bytes32 corridorId, + uint256 amountIn, + uint256 minAmountOut, + address fulfiller + ) external view returns (AtomicTypes.AtomicQuote memory q) { + AtomicTypes.CorridorConfig memory cfg = coordinator.getCorridorConfig(corridorId); + AtomicTypes.CorridorLiquidityState memory state = vault.getCorridorLiquidityState(corridorId, cfg.assetOut); + (uint256 fulfillerFee, uint256 protocolFee) = feePolicy.quoteFees(corridorId, amountIn); + uint256 requiredBond = feePolicy.requiredBond(corridorId, minAmountOut); + bool authorized = fulfillerRegistry.isFulfillerAuthorized(fulfiller, corridorId); + bool bondSufficient = fulfillerRegistry.canCover(fulfiller, requiredBond); + + q = AtomicTypes.AtomicQuote({ + routeClass: _routeClass(cfg, state, amountIn, minAmountOut, authorized, bondSufficient), + availableLiquidity: state.freeLiquidity > state.targetBuffer ? state.freeLiquidity - state.targetBuffer : 0, + freeLiquidity: state.freeLiquidity, + fulfillerFee: fulfillerFee, + protocolFee: protocolFee, + requiredBond: requiredBond, + settlementBacklog: state.settlementBacklog, + deadlineWindow: cfg.fulfilmentTimeout, + corridorEnabled: cfg.enabled, + corridorDegraded: cfg.degraded, + fulfillerAuthorized: authorized, + fulfillerBondSufficient: bondSufficient + }); + } + + function _routeClass( + AtomicTypes.CorridorConfig memory cfg, + AtomicTypes.CorridorLiquidityState memory state, + uint256 amountIn, + uint256 minAmountOut, + bool authorized, + bool bondSufficient + ) internal pure returns (AtomicTypes.RouteClass) { + if (!cfg.enabled || cfg.degraded) { + return AtomicTypes.RouteClass.Blocked; + } + if ( + amountIn > cfg.maxNotional || + minAmountOut > state.freeLiquidity || + state.settlementBacklog > cfg.maxSettlementBacklog + ) { + return AtomicTypes.RouteClass.Blocked; + } + if (!authorized || !bondSufficient) { + return AtomicTypes.RouteClass.Planning; + } + return AtomicTypes.RouteClass.ExecutionReady; + } +} diff --git a/contracts/bridge/atomic/AtomicSettlementRouter.sol b/contracts/bridge/atomic/AtomicSettlementRouter.sol new file mode 100644 index 0000000..e3860e9 --- /dev/null +++ b/contracts/bridge/atomic/AtomicSettlementRouter.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IAtomicSettlementAdapter} from "./interfaces/IAtomicSettlementAdapter.sol"; + +contract AtomicSettlementRouter is AccessControl { + using SafeERC20 for IERC20; + + bytes32 public constant COORDINATOR_ROLE = keccak256("COORDINATOR_ROLE"); + bytes32 public constant ADAPTER_MANAGER_ROLE = keccak256("ADAPTER_MANAGER_ROLE"); + + mapping(bytes32 => address) public adapters; + + event AdapterSet(bytes32 indexed settlementMode, address indexed adapter); + event SettlementExecuted( + bytes32 indexed obligationId, + bytes32 indexed settlementMode, + address indexed token, + uint256 amount, + bytes32 settlementId + ); + + error MissingAdapter(); + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(ADAPTER_MANAGER_ROLE, admin); + } + + function setAdapter(bytes32 settlementMode, address adapter) external onlyRole(ADAPTER_MANAGER_ROLE) { + adapters[settlementMode] = adapter; + emit AdapterSet(settlementMode, adapter); + } + + function executeSettlement( + bytes32 obligationId, + bytes32 settlementMode, + address token, + uint256 amount, + address recipient, + bytes calldata data + ) external payable onlyRole(COORDINATOR_ROLE) returns (bytes32 settlementId) { + address adapter = adapters[settlementMode]; + if (adapter == address(0)) revert MissingAdapter(); + + IERC20(token).safeTransfer(adapter, amount); + settlementId = IAtomicSettlementAdapter(adapter).executeSettlement{value: msg.value}( + obligationId, + token, + amount, + recipient, + data + ); + emit SettlementExecuted(obligationId, settlementMode, token, amount, settlementId); + } +} diff --git a/contracts/bridge/atomic/AtomicSlashingManager.sol b/contracts/bridge/atomic/AtomicSlashingManager.sol new file mode 100644 index 0000000..22e1d45 --- /dev/null +++ b/contracts/bridge/atomic/AtomicSlashingManager.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import {IAtomicFulfillerRegistry} from "./interfaces/IAtomicFulfillerRegistry.sol"; + +contract AtomicSlashingManager is AccessControl { + bytes32 public constant COORDINATOR_ROLE = keccak256("COORDINATOR_ROLE"); + + IAtomicFulfillerRegistry public immutable fulfillerRegistry; + + event ObligationSlashed(bytes32 indexed obligationId, address indexed recipient, bytes32 indexed reason, uint256 amount); + + constructor(address fulfillerRegistry_, address admin) { + fulfillerRegistry = IAtomicFulfillerRegistry(fulfillerRegistry_); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + } + + function slash(bytes32 obligationId, address recipient, bytes32 reason) + external + onlyRole(COORDINATOR_ROLE) + returns (uint256 amount) + { + amount = fulfillerRegistry.slashBond(obligationId, recipient); + emit ObligationSlashed(obligationId, recipient, reason, amount); + } +} diff --git a/contracts/bridge/atomic/AtomicTypes.sol b/contracts/bridge/atomic/AtomicTypes.sol new file mode 100644 index 0000000..bde2227 --- /dev/null +++ b/contracts/bridge/atomic/AtomicTypes.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +library AtomicTypes { + enum ObligationStatus { + None, + IntentCreated, + Fulfilled, + SettlementPending, + Settled, + Refunded, + Slashed + } + + enum RouteClass { + Blocked, + Planning, + ExecutionReady + } + + struct AtomicIntent { + uint64 sourceChain; + uint64 destinationChain; + address assetIn; + address assetOut; + uint256 amountIn; + uint256 minAmountOut; + address recipient; + uint256 deadline; + bytes32 routeId; + bytes32 intentId; + } + + struct AtomicCommitment { + bytes32 intentId; + address fulfiller; + uint256 reservedLiquidity; + uint256 bondAmount; + uint256 expiry; + bytes32 settlementMode; + } + + struct AtomicObligation { + bytes32 obligationId; + bytes32 intentId; + uint256 sourceEscrow; + uint256 destinationReserve; + address fulfiller; + ObligationStatus status; + uint256 settlementInitiatedAt; + } + + struct CorridorLiquidityState { + uint256 totalLiquidity; + uint256 reservedLiquidity; + uint256 freeLiquidity; + uint256 targetBuffer; + uint256 settlementBacklog; + } + + struct CorridorConfig { + bool enabled; + bool degraded; + uint64 sourceChain; + uint64 destinationChain; + address assetIn; + address assetOut; + uint256 maxNotional; + uint16 maxReservedBps; + uint256 targetBuffer; + uint256 maxSettlementBacklog; + uint16 maxOracleDriftBps; + uint256 fulfilmentTimeout; + uint256 settlementTimeout; + bytes32 defaultSettlementMode; + } + + struct AtomicQuote { + RouteClass routeClass; + uint256 availableLiquidity; + uint256 freeLiquidity; + uint256 fulfillerFee; + uint256 protocolFee; + uint256 requiredBond; + uint256 settlementBacklog; + uint256 deadlineWindow; + bool corridorEnabled; + bool corridorDegraded; + bool fulfillerAuthorized; + bool fulfillerBondSufficient; + } +} diff --git a/contracts/bridge/atomic/adapters/UniversalCcipAtomicSettlementAdapter.sol b/contracts/bridge/atomic/adapters/UniversalCcipAtomicSettlementAdapter.sol new file mode 100644 index 0000000..5247294 --- /dev/null +++ b/contracts/bridge/atomic/adapters/UniversalCcipAtomicSettlementAdapter.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IAtomicSettlementAdapter} from "../interfaces/IAtomicSettlementAdapter.sol"; +import {UniversalCCIPBridge} from "../../UniversalCCIPBridge.sol"; + +contract UniversalCcipAtomicSettlementAdapter is AccessControl, IAtomicSettlementAdapter { + using SafeERC20 for IERC20; + + bytes32 public constant ROUTER_ROLE = keccak256("ROUTER_ROLE"); + + UniversalCCIPBridge public immutable universalBridge; + + struct SettlementParams { + uint64 destinationChain; + address destinationRecipient; + bytes32 assetType; + bool usePMM; + bool useVault; + bytes complianceProof; + bytes vaultInstructions; + } + + event AtomicSettlementBridged( + bytes32 indexed obligationId, + bytes32 indexed messageId, + address indexed token, + uint256 amount, + uint64 destinationChain, + address recipient + ); + + constructor(address universalBridge_, address admin) { + universalBridge = UniversalCCIPBridge(payable(universalBridge_)); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + } + + function executeSettlement( + bytes32 obligationId, + address token, + uint256 amount, + address recipient, + bytes calldata data + ) external payable onlyRole(ROUTER_ROLE) returns (bytes32 settlementId) { + SettlementParams memory params = abi.decode(data, (SettlementParams)); + address finalRecipient = params.destinationRecipient == address(0) ? recipient : params.destinationRecipient; + IERC20(token).forceApprove(address(universalBridge), amount); + settlementId = universalBridge.bridge{value: msg.value}( + UniversalCCIPBridge.BridgeOperation({ + token: token, + amount: amount, + destinationChain: params.destinationChain, + recipient: finalRecipient, + assetType: params.assetType, + usePMM: params.usePMM, + useVault: params.useVault, + complianceProof: params.complianceProof, + vaultInstructions: params.vaultInstructions + }) + ); + IERC20(token).forceApprove(address(universalBridge), 0); + emit AtomicSettlementBridged( + obligationId, + settlementId, + token, + amount, + params.destinationChain, + finalRecipient + ); + } +} diff --git a/contracts/bridge/atomic/interfaces/IAtomicBridgeCoordinator.sol b/contracts/bridge/atomic/interfaces/IAtomicBridgeCoordinator.sol new file mode 100644 index 0000000..10caaff --- /dev/null +++ b/contracts/bridge/atomic/interfaces/IAtomicBridgeCoordinator.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {AtomicTypes} from "../AtomicTypes.sol"; + +interface IAtomicBridgeCoordinator { + function getCorridorId( + uint64 sourceChain, + uint64 destinationChain, + address assetIn, + address assetOut + ) external pure returns (bytes32); + + function getCorridorConfig(bytes32 corridorId) external view returns (AtomicTypes.CorridorConfig memory); + + function getIntent(bytes32 obligationId) external view returns (AtomicTypes.AtomicIntent memory); + + function getCommitment(bytes32 obligationId) external view returns (AtomicTypes.AtomicCommitment memory); + + function getObligation(bytes32 obligationId) external view returns (AtomicTypes.AtomicObligation memory); +} diff --git a/contracts/bridge/atomic/interfaces/IAtomicFulfillerRegistry.sol b/contracts/bridge/atomic/interfaces/IAtomicFulfillerRegistry.sol new file mode 100644 index 0000000..6bebcc3 --- /dev/null +++ b/contracts/bridge/atomic/interfaces/IAtomicFulfillerRegistry.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IAtomicFulfillerRegistry { + function depositBond(uint256 amount) external; + + function withdrawBond(uint256 amount, address recipient) external; + + function lockBond(bytes32 obligationId, address fulfiller, bytes32 corridorId, uint256 amount) external; + + function releaseBond(bytes32 obligationId) external returns (uint256 amount); + + function slashBond(bytes32 obligationId, address recipient) external returns (uint256 amount); + + function availableBond(address fulfiller) external view returns (uint256); + + function isFulfillerAuthorized(address fulfiller, bytes32 corridorId) external view returns (bool); + + function canCover(address fulfiller, uint256 amount) external view returns (bool); +} diff --git a/contracts/bridge/atomic/interfaces/IAtomicLiquidityVault.sol b/contracts/bridge/atomic/interfaces/IAtomicLiquidityVault.sol new file mode 100644 index 0000000..2db1f17 --- /dev/null +++ b/contracts/bridge/atomic/interfaces/IAtomicLiquidityVault.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {AtomicTypes} from "../AtomicTypes.sol"; + +interface IAtomicLiquidityVault { + function fundCorridor(bytes32 corridorId, address token, uint256 amount) external; + + function reserveLiquidity(bytes32 corridorId, address token, bytes32 obligationId, uint256 amount) external; + + function fulfillReservedLiquidity(bytes32 obligationId, address recipient) external returns (uint256 amount); + + function releaseReservation(bytes32 obligationId) external returns (uint256 amount); + + function reconcileSettlement(bytes32 corridorId, address token, uint256 amount, address from) external; + + function getCorridorLiquidityState(bytes32 corridorId, address token) + external + view + returns (AtomicTypes.CorridorLiquidityState memory); + + function freeLiquidity(bytes32 corridorId, address token) external view returns (uint256); +} diff --git a/contracts/bridge/atomic/interfaces/IAtomicQuoteEngine.sol b/contracts/bridge/atomic/interfaces/IAtomicQuoteEngine.sol new file mode 100644 index 0000000..fa6d15b --- /dev/null +++ b/contracts/bridge/atomic/interfaces/IAtomicQuoteEngine.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {AtomicTypes} from "../AtomicTypes.sol"; + +interface IAtomicQuoteEngine { + function quote( + bytes32 corridorId, + uint256 amountIn, + uint256 minAmountOut, + address fulfiller + ) external view returns (AtomicTypes.AtomicQuote memory); +} diff --git a/contracts/bridge/atomic/interfaces/IAtomicSettlementAdapter.sol b/contracts/bridge/atomic/interfaces/IAtomicSettlementAdapter.sol new file mode 100644 index 0000000..4b92036 --- /dev/null +++ b/contracts/bridge/atomic/interfaces/IAtomicSettlementAdapter.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IAtomicSettlementAdapter { + function executeSettlement( + bytes32 obligationId, + address token, + uint256 amount, + address recipient, + bytes calldata data + ) external payable returns (bytes32 settlementId); +} diff --git a/contracts/bridge/atomic/interfaces/IAtomicUnwindAdapter.sol b/contracts/bridge/atomic/interfaces/IAtomicUnwindAdapter.sol new file mode 100644 index 0000000..896f3c2 --- /dev/null +++ b/contracts/bridge/atomic/interfaces/IAtomicUnwindAdapter.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IAtomicUnwindAdapter { + function executeUnwind( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + address recipient, + bytes calldata data + ) external returns (uint256 amountOut); +} diff --git a/contracts/bridge/integration/CWAssetReserveVerifier.sol b/contracts/bridge/integration/CWAssetReserveVerifier.sol new file mode 100644 index 0000000..ad75d83 --- /dev/null +++ b/contracts/bridge/integration/CWAssetReserveVerifier.sol @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "../../reserve/IReserveSystem.sol"; +import "./ICWReserveVerifier.sol"; + +interface ICWMultiTokenBridgeL1AccountingV2 { + function supportedCanonicalToken(address token) external view returns (bool); + function paused(address token) external view returns (bool); + function lockedBalance(address token) external view returns (uint256); + function totalOutstanding(address token) external view returns (uint256); + function outstandingMinted(address token, uint64 destinationChainSelector) external view returns (uint256); + function maxOutstanding(address token, uint64 destinationChainSelector) external view returns (uint256); +} + +interface IOwnableLikeV2 { + function owner() external view returns (address); +} + +interface IBalanceOfAsset { + function balanceOf(address account) external view returns (uint256); +} + +/** + * @title CWAssetReserveVerifier + * @notice Generic reserve verifier for canonical c* -> mirrored cW* bridge lanes. + * @dev Uses bridge accounting plus optional vault and reserve-system checks so one verifier + * can cover stable, monetary-unit, and gas-native families behind a single L1 bridge. + */ +contract CWAssetReserveVerifier is AccessControl, ICWReserveVerifier { + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + uint256 public constant HARD_THRESHOLD_BPS = 10_000; + + struct TokenConfig { + bool enabled; + address reserveAsset; + bool requireVaultBacking; + bool requireReserveSystemBalance; + bool requireTokenOwnerMatchVault; + } + + struct VerificationStatus { + bool supportedCanonicalToken; + bool bridgePaused; + bool escrowSufficient; + bool chainCapSufficient; + bool reserveSystemAdequate; + bool vaultAdequate; + bool tokenOwnerMatchesVault; + bool passes; + uint256 lockedBalanceAmount; + uint256 totalOutstandingAmount; + uint256 chainOutstandingAmount; + uint256 maxOutstandingAmount; + uint256 canonicalTotalSupply; + uint256 reserveSystemBalance; + uint256 vaultReserveBalance; + uint256 vaultBackingRatio; + } + + ICWMultiTokenBridgeL1AccountingV2 public bridge; + address public assetVault; + IReserveSystem public reserveSystem; + + mapping(address => TokenConfig) public tokenConfigs; + + event BridgeUpdated(address indexed newBridge); + event AssetVaultUpdated(address indexed newVault); + event ReserveSystemUpdated(address indexed newReserveSystem); + event TokenConfigured( + address indexed canonicalToken, + address indexed reserveAsset, + bool requireVaultBacking, + bool requireReserveSystemBalance, + bool requireTokenOwnerMatchVault + ); + event TokenDisabled(address indexed canonicalToken); + + error ZeroAddress(); + error TokenNotConfigured(); + error VaultRequired(); + error ReserveSystemRequired(); + error ReserveAssetRequired(); + error UnsupportedCanonicalToken(); + error BridgeTokenPaused(); + error EscrowInvariantViolation(); + error ChainOutstandingCapExceeded(); + error TokenOwnerMismatch(); + error VaultBackingInsufficient(); + error ReserveSystemBackingInsufficient(); + + constructor( + address admin, + address bridge_, + address assetVault_, + address reserveSystem_ + ) { + if (admin == address(0) || bridge_ == address(0)) revert ZeroAddress(); + + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(OPERATOR_ROLE, admin); + + bridge = ICWMultiTokenBridgeL1AccountingV2(bridge_); + assetVault = assetVault_; + reserveSystem = IReserveSystem(reserveSystem_); + } + + function setBridge(address bridge_) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (bridge_ == address(0)) revert ZeroAddress(); + bridge = ICWMultiTokenBridgeL1AccountingV2(bridge_); + emit BridgeUpdated(bridge_); + } + + function setAssetVault(address assetVault_) external onlyRole(DEFAULT_ADMIN_ROLE) { + assetVault = assetVault_; + emit AssetVaultUpdated(assetVault_); + } + + function setReserveSystem(address reserveSystem_) external onlyRole(DEFAULT_ADMIN_ROLE) { + reserveSystem = IReserveSystem(reserveSystem_); + emit ReserveSystemUpdated(reserveSystem_); + } + + function configureToken( + address canonicalToken, + address reserveAsset, + bool requireVaultBacking, + bool requireReserveSystemBalance, + bool requireTokenOwnerMatchVault + ) external onlyRole(OPERATOR_ROLE) { + if (canonicalToken == address(0)) revert ZeroAddress(); + if ((requireVaultBacking || requireTokenOwnerMatchVault) && assetVault == address(0)) revert VaultRequired(); + if (requireReserveSystemBalance && address(reserveSystem) == address(0)) revert ReserveSystemRequired(); + if ((requireVaultBacking || requireReserveSystemBalance) && reserveAsset == address(0)) revert ReserveAssetRequired(); + + tokenConfigs[canonicalToken] = TokenConfig({ + enabled: true, + reserveAsset: reserveAsset, + requireVaultBacking: requireVaultBacking, + requireReserveSystemBalance: requireReserveSystemBalance, + requireTokenOwnerMatchVault: requireTokenOwnerMatchVault + }); + + emit TokenConfigured( + canonicalToken, + reserveAsset, + requireVaultBacking, + requireReserveSystemBalance, + requireTokenOwnerMatchVault + ); + } + + function disableToken(address canonicalToken) external onlyRole(OPERATOR_ROLE) { + delete tokenConfigs[canonicalToken]; + emit TokenDisabled(canonicalToken); + } + + function verifyLock( + address canonicalToken, + uint64 destinationChainSelector, + uint256 + ) external view override returns (bool verified) { + TokenConfig memory config = tokenConfigs[canonicalToken]; + if (!config.enabled) revert TokenNotConfigured(); + + VerificationStatus memory status = _buildStatus(canonicalToken, destinationChainSelector, config); + + if (!status.supportedCanonicalToken) revert UnsupportedCanonicalToken(); + if (status.bridgePaused) revert BridgeTokenPaused(); + if (!status.escrowSufficient) revert EscrowInvariantViolation(); + if (!status.chainCapSufficient) revert ChainOutstandingCapExceeded(); + if (config.requireTokenOwnerMatchVault && !status.tokenOwnerMatchesVault) revert TokenOwnerMismatch(); + if (config.requireVaultBacking && !status.vaultAdequate) revert VaultBackingInsufficient(); + if (config.requireReserveSystemBalance && !status.reserveSystemAdequate) revert ReserveSystemBackingInsufficient(); + + return true; + } + + function getVerificationStatus( + address canonicalToken, + uint64 destinationChainSelector + ) external view returns (VerificationStatus memory status) { + TokenConfig memory config = tokenConfigs[canonicalToken]; + if (!config.enabled) revert TokenNotConfigured(); + return _buildStatus(canonicalToken, destinationChainSelector, config); + } + + function _buildStatus( + address canonicalToken, + uint64 destinationChainSelector, + TokenConfig memory config + ) internal view returns (VerificationStatus memory status) { + status.supportedCanonicalToken = bridge.supportedCanonicalToken(canonicalToken); + status.bridgePaused = bridge.paused(canonicalToken); + status.lockedBalanceAmount = bridge.lockedBalance(canonicalToken); + status.totalOutstandingAmount = bridge.totalOutstanding(canonicalToken); + status.chainOutstandingAmount = bridge.outstandingMinted(canonicalToken, destinationChainSelector); + status.maxOutstandingAmount = bridge.maxOutstanding(canonicalToken, destinationChainSelector); + status.escrowSufficient = status.lockedBalanceAmount >= status.totalOutstandingAmount; + status.chainCapSufficient = + status.maxOutstandingAmount == 0 || status.chainOutstandingAmount <= status.maxOutstandingAmount; + status.canonicalTotalSupply = IERC20(canonicalToken).totalSupply(); + + status.reserveSystemAdequate = !config.requireReserveSystemBalance; + if (config.requireReserveSystemBalance) { + status.reserveSystemBalance = reserveSystem.getReserveBalance(config.reserveAsset); + status.reserveSystemAdequate = status.reserveSystemBalance >= status.canonicalTotalSupply; + } + + status.vaultAdequate = !config.requireVaultBacking; + status.tokenOwnerMatchesVault = !config.requireTokenOwnerMatchVault; + + if (config.requireTokenOwnerMatchVault) { + try IOwnableLikeV2(canonicalToken).owner() returns (address tokenOwner) { + status.tokenOwnerMatchesVault = tokenOwner == assetVault; + } catch { + status.tokenOwnerMatchesVault = false; + } + } + + if (config.requireVaultBacking) { + try IBalanceOfAsset(config.reserveAsset).balanceOf(assetVault) returns (uint256 reserveBalance) { + status.vaultReserveBalance = reserveBalance; + if (status.canonicalTotalSupply > 0) { + status.vaultBackingRatio = (reserveBalance * HARD_THRESHOLD_BPS) / status.canonicalTotalSupply; + } else { + status.vaultBackingRatio = HARD_THRESHOLD_BPS; + } + status.vaultAdequate = + status.vaultBackingRatio >= HARD_THRESHOLD_BPS && reserveBalance >= status.canonicalTotalSupply; + } catch { + status.vaultAdequate = false; + } + } + + status.passes = + status.supportedCanonicalToken && + !status.bridgePaused && + status.escrowSufficient && + status.chainCapSufficient && + status.tokenOwnerMatchesVault && + status.vaultAdequate && + status.reserveSystemAdequate; + } +} diff --git a/contracts/bridge/integration/CWReserveVerifier.sol b/contracts/bridge/integration/CWReserveVerifier.sol new file mode 100644 index 0000000..7c141f8 --- /dev/null +++ b/contracts/bridge/integration/CWReserveVerifier.sol @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "../../reserve/IReserveSystem.sol"; +import "./ICWReserveVerifier.sol"; + +interface ICWMultiTokenBridgeL1Accounting { + function supportedCanonicalToken(address token) external view returns (bool); + function paused(address token) external view returns (bool); + function lockedBalance(address token) external view returns (uint256); + function totalOutstanding(address token) external view returns (uint256); + function outstandingMinted(address token, uint64 destinationChainSelector) external view returns (uint256); + function maxOutstanding(address token, uint64 destinationChainSelector) external view returns (uint256); +} + +interface IOwnableLike { + function owner() external view returns (address); +} + +interface IStablecoinReserveVaultLike { + function compliantUSDT() external view returns (address); + function compliantUSDC() external view returns (address); + function getBackingRatio(address token) external view returns ( + uint256 reserveBalance, + uint256 tokenSupply, + uint256 backingRatio + ); + function checkReserveAdequacy() external view returns (bool usdtAdequate, bool usdcAdequate); +} + +/** + * @title CWReserveVerifier + * @notice Verifies bridge escrow and canonical reserve backing before allowing new cW minting. + * @dev Intended for cUSDC/cUSDT -> cWUSDC/cWUSDT hard-peg flows on Chain 138. + */ +contract CWReserveVerifier is AccessControl, ICWReserveVerifier { + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + uint256 public constant HARD_THRESHOLD_BPS = 10000; + + struct TokenConfig { + bool enabled; + address reserveAsset; + bool requireVaultBacking; + bool requireReserveSystemBalance; + bool requireTokenOwnerMatchVault; + } + + struct VerificationStatus { + bool supportedCanonicalToken; + bool bridgePaused; + bool escrowSufficient; + bool chainCapSufficient; + bool reserveSystemAdequate; + bool vaultAdequate; + bool tokenOwnerMatchesVault; + bool passes; + uint256 lockedBalanceAmount; + uint256 totalOutstandingAmount; + uint256 chainOutstandingAmount; + uint256 maxOutstandingAmount; + uint256 canonicalTotalSupply; + uint256 reserveSystemBalance; + uint256 vaultReserveBalance; + uint256 vaultBackingRatio; + } + + ICWMultiTokenBridgeL1Accounting public bridge; + IStablecoinReserveVaultLike public stablecoinReserveVault; + IReserveSystem public reserveSystem; + + mapping(address => TokenConfig) public tokenConfigs; + + event BridgeUpdated(address indexed newBridge); + event StablecoinReserveVaultUpdated(address indexed newVault); + event ReserveSystemUpdated(address indexed newReserveSystem); + event TokenConfigured( + address indexed canonicalToken, + address indexed reserveAsset, + bool requireVaultBacking, + bool requireReserveSystemBalance, + bool requireTokenOwnerMatchVault + ); + event TokenDisabled(address indexed canonicalToken); + + error ZeroAddress(); + error TokenNotConfigured(); + error VaultRequired(); + error ReserveSystemRequired(); + error UnsupportedCanonicalToken(); + error BridgeTokenPaused(); + error EscrowInvariantViolation(); + error ChainOutstandingCapExceeded(); + error TokenOwnerMismatch(); + error VaultBackingInsufficient(); + error ReserveSystemBackingInsufficient(); + + constructor( + address admin, + address bridge_, + address stablecoinReserveVault_, + address reserveSystem_ + ) { + if (admin == address(0) || bridge_ == address(0)) { + revert ZeroAddress(); + } + + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(OPERATOR_ROLE, admin); + + bridge = ICWMultiTokenBridgeL1Accounting(bridge_); + stablecoinReserveVault = IStablecoinReserveVaultLike(stablecoinReserveVault_); + reserveSystem = IReserveSystem(reserveSystem_); + } + + function setBridge(address bridge_) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (bridge_ == address(0)) { + revert ZeroAddress(); + } + bridge = ICWMultiTokenBridgeL1Accounting(bridge_); + emit BridgeUpdated(bridge_); + } + + function setStablecoinReserveVault(address stablecoinReserveVault_) external onlyRole(DEFAULT_ADMIN_ROLE) { + stablecoinReserveVault = IStablecoinReserveVaultLike(stablecoinReserveVault_); + emit StablecoinReserveVaultUpdated(stablecoinReserveVault_); + } + + function setReserveSystem(address reserveSystem_) external onlyRole(DEFAULT_ADMIN_ROLE) { + reserveSystem = IReserveSystem(reserveSystem_); + emit ReserveSystemUpdated(reserveSystem_); + } + + function configureToken( + address canonicalToken, + address reserveAsset, + bool requireVaultBacking, + bool requireReserveSystemBalance, + bool requireTokenOwnerMatchVault + ) external onlyRole(OPERATOR_ROLE) { + if (canonicalToken == address(0)) { + revert ZeroAddress(); + } + if ((requireVaultBacking || requireTokenOwnerMatchVault) && address(stablecoinReserveVault) == address(0)) { + revert VaultRequired(); + } + if (requireReserveSystemBalance && address(reserveSystem) == address(0)) { + revert ReserveSystemRequired(); + } + if (requireReserveSystemBalance && reserveAsset == address(0)) { + revert ZeroAddress(); + } + + tokenConfigs[canonicalToken] = TokenConfig({ + enabled: true, + reserveAsset: reserveAsset, + requireVaultBacking: requireVaultBacking, + requireReserveSystemBalance: requireReserveSystemBalance, + requireTokenOwnerMatchVault: requireTokenOwnerMatchVault + }); + + emit TokenConfigured( + canonicalToken, + reserveAsset, + requireVaultBacking, + requireReserveSystemBalance, + requireTokenOwnerMatchVault + ); + } + + function disableToken(address canonicalToken) external onlyRole(OPERATOR_ROLE) { + delete tokenConfigs[canonicalToken]; + emit TokenDisabled(canonicalToken); + } + + function verifyLock( + address canonicalToken, + uint64 destinationChainSelector, + uint256 + ) external view override returns (bool verified) { + TokenConfig memory config = tokenConfigs[canonicalToken]; + if (!config.enabled) { + revert TokenNotConfigured(); + } + + VerificationStatus memory status = _buildStatus(canonicalToken, destinationChainSelector, config); + + if (!status.supportedCanonicalToken) { + revert UnsupportedCanonicalToken(); + } + if (status.bridgePaused) { + revert BridgeTokenPaused(); + } + if (!status.escrowSufficient) { + revert EscrowInvariantViolation(); + } + if (!status.chainCapSufficient) { + revert ChainOutstandingCapExceeded(); + } + if (config.requireTokenOwnerMatchVault && !status.tokenOwnerMatchesVault) { + revert TokenOwnerMismatch(); + } + if (config.requireVaultBacking && !status.vaultAdequate) { + revert VaultBackingInsufficient(); + } + if (config.requireReserveSystemBalance && !status.reserveSystemAdequate) { + revert ReserveSystemBackingInsufficient(); + } + + return true; + } + + function getVerificationStatus( + address canonicalToken, + uint64 destinationChainSelector + ) external view returns (VerificationStatus memory status) { + TokenConfig memory config = tokenConfigs[canonicalToken]; + if (!config.enabled) { + revert TokenNotConfigured(); + } + return _buildStatus(canonicalToken, destinationChainSelector, config); + } + + function _buildStatus( + address canonicalToken, + uint64 destinationChainSelector, + TokenConfig memory config + ) internal view returns (VerificationStatus memory status) { + status.supportedCanonicalToken = bridge.supportedCanonicalToken(canonicalToken); + status.bridgePaused = bridge.paused(canonicalToken); + status.lockedBalanceAmount = bridge.lockedBalance(canonicalToken); + status.totalOutstandingAmount = bridge.totalOutstanding(canonicalToken); + status.chainOutstandingAmount = bridge.outstandingMinted(canonicalToken, destinationChainSelector); + status.maxOutstandingAmount = bridge.maxOutstanding(canonicalToken, destinationChainSelector); + status.escrowSufficient = status.lockedBalanceAmount >= status.totalOutstandingAmount; + status.chainCapSufficient = status.maxOutstandingAmount == 0 + || status.chainOutstandingAmount <= status.maxOutstandingAmount; + + status.canonicalTotalSupply = IERC20(canonicalToken).totalSupply(); + + status.reserveSystemAdequate = !config.requireReserveSystemBalance; + if (config.requireReserveSystemBalance) { + status.reserveSystemBalance = reserveSystem.getReserveBalance(config.reserveAsset); + status.reserveSystemAdequate = status.reserveSystemBalance >= status.canonicalTotalSupply; + } + + status.vaultAdequate = !config.requireVaultBacking; + status.tokenOwnerMatchesVault = !config.requireTokenOwnerMatchVault; + if (config.requireVaultBacking || config.requireTokenOwnerMatchVault) { + address vaultAddress = address(stablecoinReserveVault); + + if (config.requireTokenOwnerMatchVault) { + try IOwnableLike(canonicalToken).owner() returns (address tokenOwner) { + status.tokenOwnerMatchesVault = tokenOwner == vaultAddress; + } catch { + status.tokenOwnerMatchesVault = false; + } + } + + if (config.requireVaultBacking) { + bool tokenTrackedByVault; + bool tokenAdequate; + + try stablecoinReserveVault.compliantUSDT() returns (address compliantUSDT) { + if (canonicalToken == compliantUSDT) { + tokenTrackedByVault = true; + try stablecoinReserveVault.checkReserveAdequacy() returns (bool usdtAdequate, bool) { + tokenAdequate = usdtAdequate; + } catch {} + } + } catch {} + + if (!tokenTrackedByVault) { + try stablecoinReserveVault.compliantUSDC() returns (address compliantUSDC) { + if (canonicalToken == compliantUSDC) { + tokenTrackedByVault = true; + try stablecoinReserveVault.checkReserveAdequacy() returns (bool, bool usdcAdequate) { + tokenAdequate = usdcAdequate; + } catch {} + } + } catch {} + } + + if (tokenTrackedByVault) { + try stablecoinReserveVault.getBackingRatio(canonicalToken) returns ( + uint256 reserveBalance, + uint256, + uint256 backingRatio + ) { + status.vaultReserveBalance = reserveBalance; + status.vaultBackingRatio = backingRatio; + status.vaultAdequate = tokenAdequate + && backingRatio >= HARD_THRESHOLD_BPS + && reserveBalance >= status.canonicalTotalSupply; + } catch { + status.vaultAdequate = false; + } + } else { + status.vaultAdequate = false; + } + } + } + + status.passes = + status.supportedCanonicalToken + && !status.bridgePaused + && status.escrowSufficient + && status.chainCapSufficient + && status.tokenOwnerMatchesVault + && status.vaultAdequate + && status.reserveSystemAdequate; + } +} diff --git a/contracts/bridge/integration/ICWReserveVerifier.sol b/contracts/bridge/integration/ICWReserveVerifier.sol new file mode 100644 index 0000000..60a3c9c --- /dev/null +++ b/contracts/bridge/integration/ICWReserveVerifier.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title ICWReserveVerifier + * @notice Interface for canonical reserve verification used by the cW hard-peg bridge path. + */ +interface ICWReserveVerifier { + /** + * @notice Verify that a new outbound c* -> cW* lock is still safe after bridge accounting is applied. + * @param canonicalToken Canonical token locked on Chain 138 + * @param destinationChainSelector Destination chain selector for the wrapped mint + * @param amount Amount being wrapped + * @return verified True when the outbound lock satisfies all configured reserve checks + */ + function verifyLock( + address canonicalToken, + uint64 destinationChainSelector, + uint256 amount + ) external view returns (bool verified); +} diff --git a/contracts/bridge/integration/USDWPublicWrapVault.sol b/contracts/bridge/integration/USDWPublicWrapVault.sol new file mode 100644 index 0000000..fd0e24f --- /dev/null +++ b/contracts/bridge/integration/USDWPublicWrapVault.sol @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/utils/Pausable.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +interface ICWMintBurnMetadata is IERC20Metadata { + function mint(address to, uint256 amount) external; + function burnFrom(address from, uint256 amount) external; +} + +/** + * @title USDWPublicWrapVault + * @notice Locks native public-chain USDW and mints the shared cWUSDW mirror 1:1 after decimals normalization. + * @dev The same cWUSDW supply can be minted by both this vault and the GRU bridge. This contract deliberately + * does not expose an admin path to withdraw the underlying USDW reserve because that requires a fuller + * liability model across wrap-originated and bridge-originated cWUSDW supply. + */ +contract USDWPublicWrapVault is AccessControl, Pausable, ReentrancyGuard { + using SafeERC20 for IERC20; + + bytes32 public constant RESERVE_OPERATOR_ROLE = keccak256("RESERVE_OPERATOR_ROLE"); + bytes32 public constant EMERGENCY_ADMIN_ROLE = keccak256("EMERGENCY_ADMIN_ROLE"); + + IERC20 public immutable nativeUsdw; + ICWMintBurnMetadata public immutable wrappedUsdw; + uint8 public immutable nativeDecimals; + uint8 public immutable wrappedDecimals; + + event LiquiditySeeded(address indexed operator, uint256 nativeAmount, uint256 wrappedEquivalent); + event Wrapped( + address indexed caller, + address indexed recipient, + uint256 nativeAmount, + uint256 wrappedAmount + ); + event Unwrapped( + address indexed caller, + address indexed recipient, + uint256 wrappedAmount, + uint256 nativeAmount + ); + event NonUnderlyingTokenRecovered(address indexed token, address indexed recipient, uint256 amount); + + error InsufficientUnderlyingLiquidity(uint256 requested, uint256 available); + error NonCanonicalAmount(uint256 amount); + error UnderlyingTokenProtected(); + error UnsupportedDecimals(uint8 nativeDecimals, uint8 wrappedDecimals); + error ZeroAddress(); + error ZeroAmount(); + error ZeroRecipient(); + + constructor(address admin, address nativeUsdw_, address wrappedUsdw_) { + if (admin == address(0) || nativeUsdw_ == address(0) || wrappedUsdw_ == address(0)) revert ZeroAddress(); + + nativeUsdw = IERC20(nativeUsdw_); + wrappedUsdw = ICWMintBurnMetadata(wrappedUsdw_); + nativeDecimals = IERC20Metadata(nativeUsdw_).decimals(); + wrappedDecimals = IERC20Metadata(wrappedUsdw_).decimals(); + + uint8 diff = nativeDecimals > wrappedDecimals + ? nativeDecimals - wrappedDecimals + : wrappedDecimals - nativeDecimals; + if (diff > 77) revert UnsupportedDecimals(nativeDecimals, wrappedDecimals); + + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(RESERVE_OPERATOR_ROLE, admin); + _grantRole(EMERGENCY_ADMIN_ROLE, admin); + } + + function seedLiquidity(uint256 nativeAmount) external onlyRole(RESERVE_OPERATOR_ROLE) nonReentrant { + if (nativeAmount == 0) revert ZeroAmount(); + + nativeUsdw.safeTransferFrom(msg.sender, address(this), nativeAmount); + emit LiquiditySeeded(msg.sender, nativeAmount, _toWrappedFloor(nativeAmount)); + } + + function wrap(uint256 nativeAmount, address recipient) external whenNotPaused nonReentrant returns (uint256 wrappedAmount) { + if (nativeAmount == 0) revert ZeroAmount(); + if (recipient == address(0)) revert ZeroRecipient(); + + wrappedAmount = _toWrappedExact(nativeAmount); + nativeUsdw.safeTransferFrom(msg.sender, address(this), nativeAmount); + wrappedUsdw.mint(recipient, wrappedAmount); + + emit Wrapped(msg.sender, recipient, nativeAmount, wrappedAmount); + } + + function unwrap(uint256 wrappedAmount, address recipient) external whenNotPaused nonReentrant returns (uint256 nativeAmount) { + if (wrappedAmount == 0) revert ZeroAmount(); + if (recipient == address(0)) revert ZeroRecipient(); + + nativeAmount = _toNativeExact(wrappedAmount); + uint256 available = nativeUsdw.balanceOf(address(this)); + if (available < nativeAmount) revert InsufficientUnderlyingLiquidity(nativeAmount, available); + + wrappedUsdw.burnFrom(msg.sender, wrappedAmount); + nativeUsdw.safeTransfer(recipient, nativeAmount); + + emit Unwrapped(msg.sender, recipient, wrappedAmount, nativeAmount); + } + + function pause() external onlyRole(EMERGENCY_ADMIN_ROLE) { + _pause(); + } + + function unpause() external onlyRole(EMERGENCY_ADMIN_ROLE) { + _unpause(); + } + + function recoverNonUnderlyingToken( + address token, + address recipient, + uint256 amount + ) external onlyRole(EMERGENCY_ADMIN_ROLE) nonReentrant { + if (token == address(nativeUsdw)) revert UnderlyingTokenProtected(); + if (recipient == address(0)) revert ZeroRecipient(); + IERC20(token).safeTransfer(recipient, amount); + emit NonUnderlyingTokenRecovered(token, recipient, amount); + } + + function availableUnderlyingLiquidity() external view returns (uint256) { + return nativeUsdw.balanceOf(address(this)); + } + + function availableWrappedLiquidity() external view returns (uint256) { + return _toWrappedFloor(nativeUsdw.balanceOf(address(this))); + } + + function wrappedSupply() external view returns (uint256) { + return wrappedUsdw.totalSupply(); + } + + function liquidityCoverageBps() external view returns (uint256) { + uint256 supply = wrappedUsdw.totalSupply(); + if (supply == 0) { + return 0; + } + return (_toWrappedFloor(nativeUsdw.balanceOf(address(this))) * 10_000) / supply; + } + + function previewWrap(uint256 nativeAmount) external view returns (uint256) { + if (nativeAmount == 0) revert ZeroAmount(); + return _toWrappedExact(nativeAmount); + } + + function previewUnwrap(uint256 wrappedAmount) external view returns (uint256) { + if (wrappedAmount == 0) revert ZeroAmount(); + return _toNativeExact(wrappedAmount); + } + + function _toWrappedExact(uint256 nativeAmount) internal view returns (uint256) { + if (nativeDecimals == wrappedDecimals) { + return nativeAmount; + } + if (nativeDecimals > wrappedDecimals) { + uint256 divisor = 10 ** (nativeDecimals - wrappedDecimals); + if (nativeAmount % divisor != 0) revert NonCanonicalAmount(nativeAmount); + return nativeAmount / divisor; + } + return nativeAmount * (10 ** (wrappedDecimals - nativeDecimals)); + } + + function _toNativeExact(uint256 wrappedAmount) internal view returns (uint256) { + if (nativeDecimals == wrappedDecimals) { + return wrappedAmount; + } + if (wrappedDecimals > nativeDecimals) { + uint256 divisor = 10 ** (wrappedDecimals - nativeDecimals); + if (wrappedAmount % divisor != 0) revert NonCanonicalAmount(wrappedAmount); + return wrappedAmount / divisor; + } + return wrappedAmount * (10 ** (nativeDecimals - wrappedDecimals)); + } + + function _toWrappedFloor(uint256 nativeAmount) internal view returns (uint256) { + if (nativeDecimals == wrappedDecimals) { + return nativeAmount; + } + if (nativeDecimals > wrappedDecimals) { + return nativeAmount / (10 ** (nativeDecimals - wrappedDecimals)); + } + return nativeAmount * (10 ** (wrappedDecimals - nativeDecimals)); + } +} diff --git a/contracts/bridge/modules/BridgeModuleRegistry.sol b/contracts/bridge/modules/BridgeModuleRegistry.sol index b009115..7be506f 100644 --- a/contracts/bridge/modules/BridgeModuleRegistry.sol +++ b/contracts/bridge/modules/BridgeModuleRegistry.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "../../vendor/openzeppelin/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; /** diff --git a/contracts/bridge/trustless/EnhancedSwapRouterV2.sol b/contracts/bridge/trustless/EnhancedSwapRouterV2.sol new file mode 100644 index 0000000..b4920ef --- /dev/null +++ b/contracts/bridge/trustless/EnhancedSwapRouterV2.sol @@ -0,0 +1,457 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "./LiquidityPoolETH.sol"; +import "./RouteTypesV2.sol"; +import "./interfaces/IRouteExecutorAdapter.sol"; +import "./interfaces/IWETH.sol"; + +contract EnhancedSwapRouterV2 is AccessControl, ReentrancyGuard { + using SafeERC20 for IERC20; + + bytes32 public constant ROUTING_MANAGER_ROLE = keccak256("ROUTING_MANAGER_ROLE"); + bytes32 public constant COORDINATOR_ROLE = keccak256("COORDINATOR_ROLE"); + + struct ProviderRouteConfig { + address target; + bytes providerData; + bool enabled; + } + + address public immutable weth; + address public immutable usdt; + address public immutable usdc; + address public immutable dai; + + mapping(uint8 => address) public providerAdapters; + mapping(uint8 => bool) public providerEnabled; + mapping(address => mapping(address => mapping(uint8 => ProviderRouteConfig))) private providerRoutes; + mapping(uint256 => RouteTypesV2.Provider[]) private sizeBasedRouting; + + event ProviderAdapterSet(RouteTypesV2.Provider indexed provider, address indexed adapter); + event ProviderRouteSet( + address indexed tokenIn, + address indexed tokenOut, + RouteTypesV2.Provider indexed provider, + address target, + bool enabled + ); + event ProviderToggled(RouteTypesV2.Provider indexed provider, bool enabled); + event RoutingConfigUpdated(uint256 indexed sizeCategory, RouteTypesV2.Provider[] providers); + event RouteExecuted( + bytes32 indexed routeHash, + address indexed caller, + address indexed recipient, + address inputToken, + address outputToken, + uint256 amountIn, + uint256 amountOut + ); + + error ZeroAddress(); + error ZeroAmount(); + error InvalidPlan(); + error InvalidStablecoin(); + error ProviderDisabled(); + error ProviderAdapterNotConfigured(); + error RouteNotConfigured(); + error RouteValidationFailed(string reason); + error InsufficientOutput(); + + constructor(address _weth, address _usdt, address _usdc, address _dai) { + if (_weth == address(0) || _usdt == address(0) || _usdc == address(0) || _dai == address(0)) { + revert ZeroAddress(); + } + + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + _grantRole(ROUTING_MANAGER_ROLE, msg.sender); + + weth = _weth; + usdt = _usdt; + usdc = _usdc; + dai = _dai; + + providerEnabled[uint8(RouteTypesV2.Provider.Dodo)] = true; + providerEnabled[uint8(RouteTypesV2.Provider.UniswapV3)] = true; + providerEnabled[uint8(RouteTypesV2.Provider.Balancer)] = true; + providerEnabled[uint8(RouteTypesV2.Provider.Curve)] = true; + providerEnabled[uint8(RouteTypesV2.Provider.OneInch)] = false; + providerEnabled[uint8(RouteTypesV2.Provider.Partner)] = false; + providerEnabled[uint8(RouteTypesV2.Provider.DodoV3)] = true; + + _initializeDefaultRouting(); + } + + function setProviderAdapter( + RouteTypesV2.Provider provider, + address adapter + ) external onlyRole(ROUTING_MANAGER_ROLE) { + if (adapter == address(0)) revert ZeroAddress(); + providerAdapters[uint8(provider)] = adapter; + emit ProviderAdapterSet(provider, adapter); + } + + function setProviderRoute( + address tokenIn, + address tokenOut, + RouteTypesV2.Provider provider, + address target, + bytes calldata providerData, + bool enabled + ) external onlyRole(ROUTING_MANAGER_ROLE) { + if (tokenIn == address(0) || tokenOut == address(0) || target == address(0)) revert ZeroAddress(); + providerRoutes[tokenIn][tokenOut][uint8(provider)] = ProviderRouteConfig({ + target: target, + providerData: providerData, + enabled: enabled + }); + emit ProviderRouteSet(tokenIn, tokenOut, provider, target, enabled); + } + + function getProviderRoute( + address tokenIn, + address tokenOut, + RouteTypesV2.Provider provider + ) external view returns (address target, bytes memory providerData, bool enabled) { + ProviderRouteConfig storage config = providerRoutes[tokenIn][tokenOut][uint8(provider)]; + return (config.target, config.providerData, config.enabled); + } + + function setProviderEnabled( + RouteTypesV2.Provider provider, + bool enabled + ) external onlyRole(ROUTING_MANAGER_ROLE) { + providerEnabled[uint8(provider)] = enabled; + emit ProviderToggled(provider, enabled); + } + + function setRoutingConfig( + uint256 sizeCategory, + RouteTypesV2.Provider[] calldata providers + ) external onlyRole(ROUTING_MANAGER_ROLE) { + require(sizeCategory < 3, "EnhancedSwapRouterV2: invalid size category"); + require(providers.length > 0, "EnhancedSwapRouterV2: empty providers"); + + delete sizeBasedRouting[sizeCategory]; + for (uint256 i = 0; i < providers.length; i++) { + sizeBasedRouting[sizeCategory].push(providers[i]); + } + + emit RoutingConfigUpdated(sizeCategory, providers); + } + + function executeRoute( + RouteTypesV2.RoutePlan calldata plan + ) external payable nonReentrant returns (uint256 amountOut) { + RouteTypesV2.RoutePlan memory memoryPlan = _copyPlan(plan); + return _executeRoute(memoryPlan, msg.sender, msg.value); + } + + function quoteConfiguredProviders( + address tokenIn, + address tokenOut, + uint256 amountIn + ) external view returns (RouteTypesV2.ProviderQuote[] memory quotes) { + RouteTypesV2.ProviderQuote[] memory temp = new RouteTypesV2.ProviderQuote[](uint256(type(RouteTypesV2.Provider).max) + 1); + uint256 count = 0; + + for (uint8 providerId = 0; providerId <= uint8(type(RouteTypesV2.Provider).max); providerId++) { + if (!providerEnabled[providerId]) { + continue; + } + address adapter = providerAdapters[providerId]; + if (adapter == address(0)) { + continue; + } + + ProviderRouteConfig storage routeConfig = providerRoutes[tokenIn][tokenOut][providerId]; + if (!routeConfig.enabled || routeConfig.target == address(0)) { + continue; + } + + RouteTypesV2.RouteLeg memory leg = RouteTypesV2.RouteLeg({ + provider: RouteTypesV2.Provider(providerId), + tokenIn: tokenIn, + tokenOut: tokenOut, + amountSource: RouteTypesV2.AmountSource.UserInput, + minAmountOut: 0, + target: routeConfig.target, + providerData: routeConfig.providerData + }); + + (bool ok,) = IRouteExecutorAdapter(adapter).validate(leg); + if (!ok) { + continue; + } + + (uint256 amountOut, uint256 estimatedGas) = IRouteExecutorAdapter(adapter).quote(leg, amountIn); + temp[count] = RouteTypesV2.ProviderQuote({ + provider: RouteTypesV2.Provider(providerId), + target: routeConfig.target, + amountOut: amountOut, + estimatedGas: estimatedGas, + executable: amountOut > 0, + providerData: routeConfig.providerData + }); + count++; + } + + quotes = new RouteTypesV2.ProviderQuote[](count); + for (uint256 i = 0; i < count; i++) { + quotes[i] = temp[i]; + } + } + + function swapTokenToToken( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOutMin + ) external returns (uint256 amountOut) { + return swapTokenToToken(tokenIn, tokenOut, amountIn, amountOutMin, _defaultPreferredProvider()); + } + + function swapTokenToToken( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOutMin, + RouteTypesV2.Provider preferredProvider + ) public returns (uint256 amountOut) { + if (amountIn == 0) revert ZeroAmount(); + ProviderRouteConfig memory routeConfig = _getRouteConfig(tokenIn, tokenOut, preferredProvider); + RouteTypesV2.RouteLeg[] memory legs = new RouteTypesV2.RouteLeg[](1); + legs[0] = RouteTypesV2.RouteLeg({ + provider: preferredProvider, + tokenIn: tokenIn, + tokenOut: tokenOut, + amountSource: RouteTypesV2.AmountSource.UserInput, + minAmountOut: amountOutMin, + target: routeConfig.target, + providerData: routeConfig.providerData + }); + + RouteTypesV2.RoutePlan memory plan = RouteTypesV2.RoutePlan({ + chainId: block.chainid, + inputToken: tokenIn, + outputToken: tokenOut, + amountIn: amountIn, + minAmountOut: amountOutMin, + recipient: msg.sender, + deadline: block.timestamp + 300, + legs: legs + }); + + return _executeRoute(plan, msg.sender, 0); + } + + function swapToStablecoin( + LiquidityPoolETH.AssetType inputAsset, + address stablecoinToken, + uint256 amountIn, + uint256 amountOutMin + ) external payable returns (uint256 amountOut) { + return swapToStablecoin(inputAsset, stablecoinToken, amountIn, amountOutMin, _defaultPreferredProvider()); + } + + function swapToStablecoin( + LiquidityPoolETH.AssetType inputAsset, + address stablecoinToken, + uint256 amountIn, + uint256 amountOutMin, + RouteTypesV2.Provider preferredProvider + ) public payable returns (uint256 amountOut) { + if (amountIn == 0) revert ZeroAmount(); + if (!_isValidStablecoin(stablecoinToken)) revert InvalidStablecoin(); + + address inputToken = inputAsset == LiquidityPoolETH.AssetType.ETH ? address(0) : weth; + ProviderRouteConfig memory routeConfig = _getRouteConfig(weth, stablecoinToken, preferredProvider); + RouteTypesV2.RouteLeg[] memory legs = new RouteTypesV2.RouteLeg[](1); + legs[0] = RouteTypesV2.RouteLeg({ + provider: preferredProvider, + tokenIn: weth, + tokenOut: stablecoinToken, + amountSource: RouteTypesV2.AmountSource.UserInput, + minAmountOut: amountOutMin, + target: routeConfig.target, + providerData: routeConfig.providerData + }); + + RouteTypesV2.RoutePlan memory plan = RouteTypesV2.RoutePlan({ + chainId: block.chainid, + inputToken: inputToken, + outputToken: stablecoinToken, + amountIn: amountIn, + minAmountOut: amountOutMin, + recipient: msg.sender, + deadline: block.timestamp + 300, + legs: legs + }); + + return _executeRoute(plan, msg.sender, msg.value); + } + + function _executeRoute( + RouteTypesV2.RoutePlan memory plan, + address payer, + uint256 nativeValue + ) internal returns (uint256 amountOut) { + _validatePlan(plan); + + address currentToken = plan.inputToken; + uint256 currentAmount = plan.amountIn; + if (currentToken == address(0)) { + require(nativeValue == currentAmount, "EnhancedSwapRouterV2: native amount mismatch"); + IWETH(weth).deposit{value: currentAmount}(); + currentToken = weth; + } else { + IERC20(currentToken).safeTransferFrom(payer, address(this), currentAmount); + } + + for (uint256 i = 0; i < plan.legs.length; i++) { + RouteTypesV2.RouteLeg memory leg = plan.legs[i]; + if (leg.tokenIn != currentToken) revert InvalidPlan(); + if (i == 0 && leg.amountSource != RouteTypesV2.AmountSource.UserInput) revert InvalidPlan(); + if (i > 0 && leg.amountSource != RouteTypesV2.AmountSource.PreviousLeg) revert InvalidPlan(); + + uint8 providerId = uint8(leg.provider); + if (!providerEnabled[providerId]) revert ProviderDisabled(); + + address adapter = providerAdapters[providerId]; + if (adapter == address(0)) revert ProviderAdapterNotConfigured(); + + (bool ok, string memory reason) = IRouteExecutorAdapter(adapter).validate(leg); + if (!ok) revert RouteValidationFailed(reason); + + IERC20(currentToken).safeTransfer(adapter, currentAmount); + uint256 balanceBefore = IERC20(leg.tokenOut).balanceOf(address(this)); + uint256 reportedAmountOut = IRouteExecutorAdapter(adapter).execute(leg, currentAmount); + uint256 balanceAfter = IERC20(leg.tokenOut).balanceOf(address(this)); + uint256 actualAmountOut = balanceAfter - balanceBefore; + if (actualAmountOut == 0) { + actualAmountOut = reportedAmountOut; + } + if (actualAmountOut < leg.minAmountOut) revert InsufficientOutput(); + + currentToken = leg.tokenOut; + currentAmount = actualAmountOut; + } + + if (currentToken != plan.outputToken) revert InvalidPlan(); + if (currentAmount < plan.minAmountOut) revert InsufficientOutput(); + + address recipient = plan.recipient == address(0) ? payer : plan.recipient; + IERC20(currentToken).safeTransfer(recipient, currentAmount); + + emit RouteExecuted( + keccak256(abi.encode(plan.chainId, plan.inputToken, plan.outputToken, plan.amountIn, plan.deadline)), + payer, + recipient, + plan.inputToken == address(0) ? weth : plan.inputToken, + currentToken, + plan.amountIn, + currentAmount + ); + + return currentAmount; + } + + function _getRouteConfig( + address tokenIn, + address tokenOut, + RouteTypesV2.Provider preferredProvider + ) internal view returns (ProviderRouteConfig memory) { + RouteTypesV2.Provider[] memory providers = _getRoutingProviders(preferredProvider); + for (uint256 i = 0; i < providers.length; i++) { + uint8 providerId = uint8(providers[i]); + ProviderRouteConfig storage config = providerRoutes[tokenIn][tokenOut][providerId]; + if (config.enabled && config.target != address(0) && providerEnabled[providerId]) { + return ProviderRouteConfig({ + target: config.target, + providerData: config.providerData, + enabled: config.enabled + }); + } + } + revert RouteNotConfigured(); + } + + function _getRoutingProviders( + RouteTypesV2.Provider preferredProvider + ) internal view returns (RouteTypesV2.Provider[] memory providers) { + if (providerEnabled[uint8(preferredProvider)]) { + providers = new RouteTypesV2.Provider[](1); + providers[0] = preferredProvider; + return providers; + } + + providers = sizeBasedRouting[0]; + if (providers.length == 0) { + providers = new RouteTypesV2.Provider[](5); + providers[0] = RouteTypesV2.Provider.Dodo; + providers[1] = RouteTypesV2.Provider.UniswapV3; + providers[2] = RouteTypesV2.Provider.Balancer; + providers[3] = RouteTypesV2.Provider.Curve; + providers[4] = RouteTypesV2.Provider.OneInch; + } + } + + function _validatePlan(RouteTypesV2.RoutePlan memory plan) internal view { + if (plan.amountIn == 0 || plan.legs.length == 0) revert InvalidPlan(); + if (plan.outputToken == address(0)) revert InvalidPlan(); + if (plan.chainId != block.chainid) revert InvalidPlan(); + if (plan.deadline < block.timestamp) revert InvalidPlan(); + } + + function _initializeDefaultRouting() internal { + sizeBasedRouting[0].push(RouteTypesV2.Provider.Dodo); + sizeBasedRouting[0].push(RouteTypesV2.Provider.UniswapV3); + + sizeBasedRouting[1].push(RouteTypesV2.Provider.Dodo); + sizeBasedRouting[1].push(RouteTypesV2.Provider.Balancer); + sizeBasedRouting[1].push(RouteTypesV2.Provider.UniswapV3); + + sizeBasedRouting[2].push(RouteTypesV2.Provider.Dodo); + sizeBasedRouting[2].push(RouteTypesV2.Provider.Curve); + sizeBasedRouting[2].push(RouteTypesV2.Provider.Balancer); + } + + function _copyPlan( + RouteTypesV2.RoutePlan calldata plan + ) internal pure returns (RouteTypesV2.RoutePlan memory copied) { + copied.chainId = plan.chainId; + copied.inputToken = plan.inputToken; + copied.outputToken = plan.outputToken; + copied.amountIn = plan.amountIn; + copied.minAmountOut = plan.minAmountOut; + copied.recipient = plan.recipient; + copied.deadline = plan.deadline; + copied.legs = new RouteTypesV2.RouteLeg[](plan.legs.length); + + for (uint256 i = 0; i < plan.legs.length; i++) { + copied.legs[i] = RouteTypesV2.RouteLeg({ + provider: plan.legs[i].provider, + tokenIn: plan.legs[i].tokenIn, + tokenOut: plan.legs[i].tokenOut, + amountSource: plan.legs[i].amountSource, + minAmountOut: plan.legs[i].minAmountOut, + target: plan.legs[i].target, + providerData: plan.legs[i].providerData + }); + } + } + + function _defaultPreferredProvider() internal pure returns (RouteTypesV2.Provider) { + return RouteTypesV2.Provider.Dodo; + } + + function _isValidStablecoin(address token) internal view returns (bool) { + return token == usdt || token == usdc || token == dai; + } + + receive() external payable {} +} diff --git a/contracts/bridge/trustless/IntentBridgeCoordinatorV2.sol b/contracts/bridge/trustless/IntentBridgeCoordinatorV2.sol new file mode 100644 index 0000000..7ba0aca --- /dev/null +++ b/contracts/bridge/trustless/IntentBridgeCoordinatorV2.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "./RouteTypesV2.sol"; +import "./interfaces/IEnhancedSwapRouterV2.sol"; +import "./interfaces/IBridgeIntentExecutor.sol"; + +contract IntentBridgeCoordinatorV2 is AccessControl, ReentrancyGuard { + using SafeERC20 for IERC20; + + bytes32 public constant EXECUTOR_MANAGER_ROLE = keccak256("EXECUTOR_MANAGER_ROLE"); + + IEnhancedSwapRouterV2 public immutable swapRouter; + mapping(bytes32 => address) public bridgeExecutors; + + event BridgeExecutorSet(bytes32 indexed bridgeType, address indexed executor); + event IntentExecuted( + bytes32 indexed intentHash, + bytes32 indexed bridgeReference, + bytes32 indexed destinationPlanHash, + address inputToken, + address bridgeToken, + uint256 amountIn, + uint256 bridgedAmount, + address recipient + ); + + error ZeroAddress(); + error InvalidIntent(); + error BridgeExecutorNotConfigured(); + error BridgeValidationFailed(string reason); + + constructor(address _swapRouter) { + if (_swapRouter == address(0)) revert ZeroAddress(); + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + _grantRole(EXECUTOR_MANAGER_ROLE, msg.sender); + swapRouter = IEnhancedSwapRouterV2(_swapRouter); + } + + function setBridgeExecutor( + bytes32 bridgeType, + address executor + ) external onlyRole(EXECUTOR_MANAGER_ROLE) { + if (executor == address(0)) revert ZeroAddress(); + bridgeExecutors[bridgeType] = executor; + emit BridgeExecutorSet(bridgeType, executor); + } + + function executeIntent( + RouteTypesV2.BridgeIntentPlan calldata intent + ) external payable nonReentrant returns (bytes32 bridgeReference, bytes32 destinationPlanHash) { + if (intent.deadline < block.timestamp) revert InvalidIntent(); + if (intent.recipient == address(0)) revert InvalidIntent(); + if (intent.sourcePlan.deadline < block.timestamp) revert InvalidIntent(); + if (intent.sourcePlan.recipient != address(this)) revert InvalidIntent(); + + address bridgeExecutor = bridgeExecutors[intent.bridgeType]; + if (bridgeExecutor == address(0)) revert BridgeExecutorNotConfigured(); + + uint256 bridgedAmount; + address bridgeToken; + if (intent.sourcePlan.inputToken == address(0)) { + bridgedAmount = swapRouter.executeRoute{value: intent.sourcePlan.amountIn}(intent.sourcePlan); + bridgeToken = intent.sourcePlan.outputToken; + } else { + IERC20(intent.sourcePlan.inputToken).safeTransferFrom( + msg.sender, + address(this), + intent.sourcePlan.amountIn + ); + IERC20(intent.sourcePlan.inputToken).forceApprove(address(swapRouter), 0); + IERC20(intent.sourcePlan.inputToken).forceApprove(address(swapRouter), intent.sourcePlan.amountIn); + bridgedAmount = swapRouter.executeRoute(intent.sourcePlan); + bridgeToken = intent.sourcePlan.outputToken; + } + + (bool ok, string memory reason) = IBridgeIntentExecutor(bridgeExecutor).validateBridge( + intent.bridgeType, + intent.bridgeData, + bridgeToken, + bridgedAmount, + intent.recipient + ); + if (!ok) revert BridgeValidationFailed(reason); + + IERC20(bridgeToken).forceApprove(bridgeExecutor, 0); + IERC20(bridgeToken).forceApprove(bridgeExecutor, bridgedAmount); + + bridgeReference = IBridgeIntentExecutor(bridgeExecutor).executeBridge( + intent.bridgeType, + intent.bridgeData, + bridgeToken, + bridgedAmount, + intent.recipient + ); + destinationPlanHash = keccak256( + abi.encode( + intent.destinationPlan.chainId, + intent.destinationPlan.inputToken, + intent.destinationPlan.outputToken, + intent.destinationPlan.amountIn, + intent.destinationPlan.minAmountOut, + intent.destinationPlan.recipient, + intent.destinationPlan.deadline, + intent.destinationPlan.legs + ) + ); + + emit IntentExecuted( + keccak256(abi.encode(intent.bridgeType, intent.recipient, intent.deadline)), + bridgeReference, + destinationPlanHash, + intent.sourcePlan.inputToken == address(0) ? address(0) : intent.sourcePlan.inputToken, + bridgeToken, + intent.sourcePlan.amountIn, + bridgedAmount, + intent.recipient + ); + } + + receive() external payable {} +} diff --git a/contracts/bridge/trustless/RouteTypesV2.sol b/contracts/bridge/trustless/RouteTypesV2.sol new file mode 100644 index 0000000..e6bb6e3 --- /dev/null +++ b/contracts/bridge/trustless/RouteTypesV2.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +library RouteTypesV2 { + enum Provider { + Dodo, + UniswapV3, + Balancer, + Curve, + OneInch, + Partner, + DodoV3 + } + + enum AmountSource { + UserInput, + PreviousLeg + } + + struct RouteLeg { + Provider provider; + address tokenIn; + address tokenOut; + AmountSource amountSource; + uint256 minAmountOut; + address target; + bytes providerData; + } + + struct RoutePlan { + uint256 chainId; + address inputToken; + address outputToken; + uint256 amountIn; + uint256 minAmountOut; + address recipient; + uint256 deadline; + RouteLeg[] legs; + } + + struct ExecutionConstraints { + uint256 maxSlippageBps; + Provider[] allowedProviders; + uint256 maxLegs; + bytes32 complianceProfile; + } + + struct ProviderQuote { + Provider provider; + address target; + uint256 amountOut; + uint256 estimatedGas; + bool executable; + bytes providerData; + } + + struct BridgeIntentPlan { + RoutePlan sourcePlan; + bytes32 bridgeType; + bytes bridgeData; + RoutePlan destinationPlan; + address recipient; + uint256 deadline; + } +} diff --git a/contracts/bridge/trustless/adapters/BalancerRouteExecutorAdapter.sol b/contracts/bridge/trustless/adapters/BalancerRouteExecutorAdapter.sol new file mode 100644 index 0000000..4e6d9c1 --- /dev/null +++ b/contracts/bridge/trustless/adapters/BalancerRouteExecutorAdapter.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "../interfaces/IBalancerVault.sol"; +import "../RouteTypesV2.sol"; +import "../interfaces/IRouteExecutorAdapter.sol"; + +contract BalancerRouteExecutorAdapter is IRouteExecutorAdapter { + using SafeERC20 for IERC20; + + function validate( + RouteTypesV2.RouteLeg calldata leg + ) external pure override returns (bool ok, string memory reason) { + if (leg.provider != RouteTypesV2.Provider.Balancer) { + return (false, "BalancerRouteExecutorAdapter: invalid provider"); + } + if (leg.target == address(0)) { + return (false, "BalancerRouteExecutorAdapter: zero target"); + } + if (leg.providerData.length != 32) { + return (false, "BalancerRouteExecutorAdapter: invalid providerData"); + } + return (true, ""); + } + + function quote( + RouteTypesV2.RouteLeg calldata leg, + uint256 amountIn + ) external view override returns (uint256 amountOut, uint256 gasEstimate) { + bytes32 poolId = abi.decode(leg.providerData, (bytes32)); + (address[] memory tokens, uint256[] memory balances,) = IBalancerVault(leg.target).getPoolTokens(poolId); + uint256 inIndex = type(uint256).max; + uint256 outIndex = type(uint256).max; + + for (uint256 i = 0; i < tokens.length; i++) { + if (tokens[i] == leg.tokenIn) inIndex = i; + if (tokens[i] == leg.tokenOut) outIndex = i; + } + + if (inIndex != type(uint256).max && outIndex != type(uint256).max && balances[inIndex] > 0) { + uint256 grossOut = (amountIn * balances[outIndex]) / balances[inIndex]; + amountOut = (grossOut * 9950) / 10000; + } + + gasEstimate = 230000; + } + + function execute( + RouteTypesV2.RouteLeg calldata leg, + uint256 amountIn + ) external override returns (uint256 amountOut) { + bytes32 poolId = abi.decode(leg.providerData, (bytes32)); + IERC20(leg.tokenIn).forceApprove(leg.target, 0); + IERC20(leg.tokenIn).forceApprove(leg.target, amountIn); + + amountOut = IBalancerVault(leg.target).swap( + IBalancerVault.SingleSwap({ + poolId: poolId, + kind: IBalancerVault.SwapKind.GIVEN_IN, + assetIn: leg.tokenIn, + assetOut: leg.tokenOut, + amount: amountIn, + userData: "" + }), + IBalancerVault.FundManagement({ + sender: address(this), + fromInternalBalance: false, + recipient: payable(msg.sender), + toInternalBalance: false + }), + leg.minAmountOut, + block.timestamp + 300 + ); + } +} diff --git a/contracts/bridge/trustless/adapters/CurveRouteExecutorAdapter.sol b/contracts/bridge/trustless/adapters/CurveRouteExecutorAdapter.sol new file mode 100644 index 0000000..3bceee4 --- /dev/null +++ b/contracts/bridge/trustless/adapters/CurveRouteExecutorAdapter.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "../interfaces/ICurvePool.sol"; +import "../RouteTypesV2.sol"; +import "../interfaces/IRouteExecutorAdapter.sol"; + +contract CurveRouteExecutorAdapter is IRouteExecutorAdapter { + using SafeERC20 for IERC20; + + function validate( + RouteTypesV2.RouteLeg calldata leg + ) external pure override returns (bool ok, string memory reason) { + if (leg.provider != RouteTypesV2.Provider.Curve) { + return (false, "CurveRouteExecutorAdapter: invalid provider"); + } + if (leg.target == address(0)) { + return (false, "CurveRouteExecutorAdapter: zero target"); + } + if (leg.providerData.length == 0) { + return (false, "CurveRouteExecutorAdapter: missing providerData"); + } + return (true, ""); + } + + function quote( + RouteTypesV2.RouteLeg calldata leg, + uint256 amountIn + ) external view override returns (uint256 amountOut, uint256 gasEstimate) { + (int128 i, int128 j,) = abi.decode(leg.providerData, (int128, int128, bool)); + amountOut = ICurvePool(leg.target).get_dy(i, j, amountIn); + gasEstimate = 190000; + } + + function execute( + RouteTypesV2.RouteLeg calldata leg, + uint256 amountIn + ) external override returns (uint256 amountOut) { + (int128 i, int128 j, bool useUnderlying) = abi.decode(leg.providerData, (int128, int128, bool)); + IERC20(leg.tokenIn).forceApprove(leg.target, 0); + IERC20(leg.tokenIn).forceApprove(leg.target, amountIn); + + if (useUnderlying) { + amountOut = ICurvePool(leg.target).exchange_underlying(i, j, amountIn, leg.minAmountOut); + } else { + amountOut = ICurvePool(leg.target).exchange(i, j, amountIn, leg.minAmountOut); + } + + IERC20(leg.tokenOut).safeTransfer(msg.sender, amountOut); + } +} diff --git a/contracts/bridge/trustless/adapters/DodoRouteExecutorAdapter.sol b/contracts/bridge/trustless/adapters/DodoRouteExecutorAdapter.sol new file mode 100644 index 0000000..0071a5c --- /dev/null +++ b/contracts/bridge/trustless/adapters/DodoRouteExecutorAdapter.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "../../../liquidity/interfaces/ILiquidityProvider.sol"; +import "../RouteTypesV2.sol"; +import "../interfaces/IRouteExecutorAdapter.sol"; + +contract DodoRouteExecutorAdapter is IRouteExecutorAdapter { + using SafeERC20 for IERC20; + + function validate( + RouteTypesV2.RouteLeg calldata leg + ) external view override returns (bool ok, string memory reason) { + if (leg.provider != RouteTypesV2.Provider.Dodo) { + return (false, "DodoRouteExecutorAdapter: invalid provider"); + } + if (leg.target == address(0)) { + return (false, "DodoRouteExecutorAdapter: zero target"); + } + if (leg.tokenIn == address(0) || leg.tokenOut == address(0)) { + return (false, "DodoRouteExecutorAdapter: zero token"); + } + if (leg.providerData.length != 32) { + return (false, "DodoRouteExecutorAdapter: invalid providerData"); + } + + address poolAddress = abi.decode(leg.providerData, (address)); + if (poolAddress == address(0)) { + return (false, "DodoRouteExecutorAdapter: zero pool"); + } + if (!ILiquidityProvider(leg.target).supportsTokenPair(leg.tokenIn, leg.tokenOut)) { + return (false, "DodoRouteExecutorAdapter: unsupported pair"); + } + return (true, ""); + } + + function quote( + RouteTypesV2.RouteLeg calldata leg, + uint256 amountIn + ) external view override returns (uint256 amountOut, uint256 gasEstimate) { + (amountOut,) = ILiquidityProvider(leg.target).getQuote(leg.tokenIn, leg.tokenOut, amountIn); + gasEstimate = ILiquidityProvider(leg.target).estimateGas(leg.tokenIn, leg.tokenOut, amountIn); + } + + function execute( + RouteTypesV2.RouteLeg calldata leg, + uint256 amountIn + ) external override returns (uint256 amountOut) { + IERC20(leg.tokenIn).forceApprove(leg.target, 0); + IERC20(leg.tokenIn).forceApprove(leg.target, amountIn); + amountOut = ILiquidityProvider(leg.target).executeSwap( + leg.tokenIn, + leg.tokenOut, + amountIn, + leg.minAmountOut + ); + IERC20(leg.tokenOut).safeTransfer(msg.sender, amountOut); + } +} diff --git a/contracts/bridge/trustless/adapters/DodoV3RouteExecutorAdapter.sol b/contracts/bridge/trustless/adapters/DodoV3RouteExecutorAdapter.sol new file mode 100644 index 0000000..ca9c700 --- /dev/null +++ b/contracts/bridge/trustless/adapters/DodoV3RouteExecutorAdapter.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "../RouteTypesV2.sol"; +import "../interfaces/IRouteExecutorAdapter.sol"; + +interface ID3ProxyView { + function _DODO_APPROVE_PROXY_() external view returns (address); + + function sellTokens( + address pool, + address to, + address fromToken, + address toToken, + uint256 fromAmount, + uint256 minReceiveAmount, + bytes calldata data, + uint256 deadLine + ) external payable returns (uint256 receiveToAmount); +} + +interface IDODOApproveProxyView { + function _DODO_APPROVE_() external view returns (address); +} + +interface ID3MMQuoter { + function querySellTokens( + address fromToken, + address toToken, + uint256 fromAmount + ) external view returns (uint256 payFromAmount, uint256 receiveToAmount, uint256 vusdAmount, uint256 swapFee, uint256 mtFee); +} + +contract DodoV3RouteExecutorAdapter is IRouteExecutorAdapter { + using SafeERC20 for IERC20; + + uint256 internal constant DEFAULT_GAS_ESTIMATE = 330000; + + function validate( + RouteTypesV2.RouteLeg calldata leg + ) external view override returns (bool ok, string memory reason) { + if (leg.provider != RouteTypesV2.Provider.DodoV3) { + return (false, "DodoV3RouteExecutorAdapter: invalid provider"); + } + if (leg.target == address(0)) { + return (false, "DodoV3RouteExecutorAdapter: zero target"); + } + if (leg.tokenIn == address(0) || leg.tokenOut == address(0)) { + return (false, "DodoV3RouteExecutorAdapter: zero token"); + } + if (leg.providerData.length != 32) { + return (false, "DodoV3RouteExecutorAdapter: invalid providerData"); + } + + address poolAddress = abi.decode(leg.providerData, (address)); + if (poolAddress == address(0)) { + return (false, "DodoV3RouteExecutorAdapter: zero pool"); + } + + address approveProxy = ID3ProxyView(leg.target)._DODO_APPROVE_PROXY_(); + if (approveProxy == address(0)) { + return (false, "DodoV3RouteExecutorAdapter: zero approve proxy"); + } + address approve = IDODOApproveProxyView(approveProxy)._DODO_APPROVE_(); + if (approve == address(0)) { + return (false, "DodoV3RouteExecutorAdapter: zero approve"); + } + + return (true, ""); + } + + function quote( + RouteTypesV2.RouteLeg calldata leg, + uint256 amountIn + ) external view override returns (uint256 amountOut, uint256 gasEstimate) { + address poolAddress = abi.decode(leg.providerData, (address)); + (, amountOut,,,) = ID3MMQuoter(poolAddress).querySellTokens(leg.tokenIn, leg.tokenOut, amountIn); + gasEstimate = DEFAULT_GAS_ESTIMATE; + } + + function execute( + RouteTypesV2.RouteLeg calldata leg, + uint256 amountIn + ) external override returns (uint256 amountOut) { + address poolAddress = abi.decode(leg.providerData, (address)); + address approveProxy = ID3ProxyView(leg.target)._DODO_APPROVE_PROXY_(); + address approve = IDODOApproveProxyView(approveProxy)._DODO_APPROVE_(); + + IERC20(leg.tokenIn).forceApprove(approve, 0); + IERC20(leg.tokenIn).forceApprove(approve, amountIn); + + amountOut = ID3ProxyView(leg.target).sellTokens( + poolAddress, + address(this), + leg.tokenIn, + leg.tokenOut, + amountIn, + leg.minAmountOut, + bytes(""), + block.timestamp + 300 + ); + + IERC20(leg.tokenIn).forceApprove(approve, 0); + IERC20(leg.tokenOut).safeTransfer(msg.sender, amountOut); + } +} diff --git a/contracts/bridge/trustless/adapters/OneInchRouteExecutorAdapter.sol b/contracts/bridge/trustless/adapters/OneInchRouteExecutorAdapter.sol new file mode 100644 index 0000000..5de18ca --- /dev/null +++ b/contracts/bridge/trustless/adapters/OneInchRouteExecutorAdapter.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "../interfaces/IAggregationRouter.sol"; +import "../RouteTypesV2.sol"; +import "../interfaces/IRouteExecutorAdapter.sol"; + +contract OneInchRouteExecutorAdapter is IRouteExecutorAdapter { + using SafeERC20 for IERC20; + + function validate( + RouteTypesV2.RouteLeg calldata leg + ) external pure override returns (bool ok, string memory reason) { + if (leg.provider != RouteTypesV2.Provider.OneInch) { + return (false, "OneInchRouteExecutorAdapter: invalid provider"); + } + if (leg.target == address(0)) { + return (false, "OneInchRouteExecutorAdapter: zero target"); + } + if (leg.providerData.length == 0) { + return (false, "OneInchRouteExecutorAdapter: missing providerData"); + } + return (true, ""); + } + + function quote( + RouteTypesV2.RouteLeg calldata, + uint256 + ) external pure override returns (uint256 amountOut, uint256 gasEstimate) { + return (0, 320000); + } + + function execute( + RouteTypesV2.RouteLeg calldata leg, + uint256 amountIn + ) external override returns (uint256 amountOut) { + (address executor, address allowanceTarget, bytes memory data) = + abi.decode(leg.providerData, (address, address, bytes)); + address spender = allowanceTarget == address(0) ? leg.target : allowanceTarget; + + IERC20(leg.tokenIn).forceApprove(spender, 0); + IERC20(leg.tokenIn).forceApprove(spender, amountIn); + + IAggregationRouter.SwapDescription memory desc = IAggregationRouter.SwapDescription({ + srcToken: leg.tokenIn, + dstToken: leg.tokenOut, + srcReceiver: address(this), + dstReceiver: msg.sender, + amount: amountIn, + minReturnAmount: leg.minAmountOut, + flags: 0, + permit: "" + }); + + (amountOut,) = IAggregationRouter(leg.target).swap(executor, desc, "", data); + } +} diff --git a/contracts/bridge/trustless/adapters/UniswapV3RouteExecutorAdapter.sol b/contracts/bridge/trustless/adapters/UniswapV3RouteExecutorAdapter.sol new file mode 100644 index 0000000..91777bd --- /dev/null +++ b/contracts/bridge/trustless/adapters/UniswapV3RouteExecutorAdapter.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "../interfaces/ISwapRouter.sol"; +import "../RouteTypesV2.sol"; +import "../interfaces/IRouteExecutorAdapter.sol"; + +contract UniswapV3RouteExecutorAdapter is IRouteExecutorAdapter { + using SafeERC20 for IERC20; + + function validate( + RouteTypesV2.RouteLeg calldata leg + ) external pure override returns (bool ok, string memory reason) { + if (leg.provider != RouteTypesV2.Provider.UniswapV3) { + return (false, "UniswapV3RouteExecutorAdapter: invalid provider"); + } + if (leg.target == address(0)) { + return (false, "UniswapV3RouteExecutorAdapter: zero target"); + } + if (leg.providerData.length == 0) { + return (false, "UniswapV3RouteExecutorAdapter: missing providerData"); + } + return (true, ""); + } + + function quote( + RouteTypesV2.RouteLeg calldata leg, + uint256 amountIn + ) external view override returns (uint256 amountOut, uint256 gasEstimate) { + (bytes memory path, uint24 fee, address quoter, bool usePath) = + abi.decode(leg.providerData, (bytes, uint24, address, bool)); + + if (quoter != address(0)) { + bytes memory data; + bool ok; + if (usePath) { + (ok, data) = quoter.staticcall( + abi.encodeWithSignature("quoteExactInput(bytes,uint256)", path, amountIn) + ); + } else { + (ok, data) = quoter.staticcall( + abi.encodeWithSignature( + "quoteExactInputSingle(address,address,uint24,uint256,uint160)", + leg.tokenIn, + leg.tokenOut, + fee, + amountIn, + uint160(0) + ) + ); + } + if (ok && data.length >= 32) { + amountOut = abi.decode(data, (uint256)); + } + } + + gasEstimate = usePath ? 220000 : 175000; + } + + function execute( + RouteTypesV2.RouteLeg calldata leg, + uint256 amountIn + ) external override returns (uint256 amountOut) { + (bytes memory path, uint24 fee,, bool usePath) = + abi.decode(leg.providerData, (bytes, uint24, address, bool)); + + IERC20(leg.tokenIn).forceApprove(leg.target, 0); + IERC20(leg.tokenIn).forceApprove(leg.target, amountIn); + + if (usePath) { + amountOut = ISwapRouter(leg.target).exactInput( + ISwapRouter.ExactInputParams({ + path: path, + recipient: msg.sender, + deadline: block.timestamp + 300, + amountIn: amountIn, + amountOutMinimum: leg.minAmountOut + }) + ); + } else { + amountOut = ISwapRouter(leg.target).exactInputSingle( + ISwapRouter.ExactInputSingleParams({ + tokenIn: leg.tokenIn, + tokenOut: leg.tokenOut, + fee: fee, + recipient: msg.sender, + deadline: block.timestamp + 300, + amountIn: amountIn, + amountOutMinimum: leg.minAmountOut, + sqrtPriceLimitX96: 0 + }) + ); + } + } +} diff --git a/contracts/bridge/trustless/integration/Stabilizer.sol b/contracts/bridge/trustless/integration/Stabilizer.sol index e108b77..742ca56 100644 --- a/contracts/bridge/trustless/integration/Stabilizer.sol +++ b/contracts/bridge/trustless/integration/Stabilizer.sol @@ -15,8 +15,8 @@ import "./ICommodityPegManager.sol"; interface IDODOPMMPoolStabilizer { function _BASE_TOKEN_() external view returns (address); function _QUOTE_TOKEN_() external view returns (address); - function sellBase(uint256 amount) external returns (uint256); - function sellQuote(uint256 amount) external returns (uint256); + function sellBase(address to) external returns (uint256); + function sellQuote(address to) external returns (uint256); function getMidPrice() external view returns (uint256); } @@ -190,8 +190,8 @@ contract Stabilizer is AccessControl, ReentrancyGuard { if (IERC20(tokenIn).balanceOf(address(this)) < tradeSize) revert InsufficientBalance(); IERC20(tokenIn).safeTransfer(pool, tradeSize); amountOut = tokenIn == base - ? IDODOPMMPoolStabilizer(pool).sellBase(tradeSize) - : IDODOPMMPoolStabilizer(pool).sellQuote(tradeSize); + ? IDODOPMMPoolStabilizer(pool).sellBase(address(this)) + : IDODOPMMPoolStabilizer(pool).sellQuote(address(this)); if (amountOut < minAmountOut) revert SlippageExceeded(); emit PrivateSwapExecuted(tokenIn, tokenOut, tradeSize, amountOut); diff --git a/contracts/bridge/trustless/interfaces/IBridgeIntentExecutor.sol b/contracts/bridge/trustless/interfaces/IBridgeIntentExecutor.sol new file mode 100644 index 0000000..a0516d0 --- /dev/null +++ b/contracts/bridge/trustless/interfaces/IBridgeIntentExecutor.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +interface IBridgeIntentExecutor { + function validateBridge( + bytes32 bridgeType, + bytes calldata bridgeData, + address token, + uint256 amount, + address recipient + ) external view returns (bool ok, string memory reason); + + function executeBridge( + bytes32 bridgeType, + bytes calldata bridgeData, + address token, + uint256 amount, + address recipient + ) external payable returns (bytes32 referenceId); +} diff --git a/contracts/bridge/trustless/interfaces/IEnhancedSwapRouterV2.sol b/contracts/bridge/trustless/interfaces/IEnhancedSwapRouterV2.sol new file mode 100644 index 0000000..9271d68 --- /dev/null +++ b/contracts/bridge/trustless/interfaces/IEnhancedSwapRouterV2.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "../RouteTypesV2.sol"; +import "../LiquidityPoolETH.sol"; + +interface IEnhancedSwapRouterV2 { + function executeRoute( + RouteTypesV2.RoutePlan calldata plan + ) external payable returns (uint256 amountOut); + + function quoteConfiguredProviders( + address tokenIn, + address tokenOut, + uint256 amountIn + ) external view returns (RouteTypesV2.ProviderQuote[] memory quotes); + + function swapTokenToToken( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOutMin + ) external returns (uint256 amountOut); + + function swapToStablecoin( + LiquidityPoolETH.AssetType inputAsset, + address stablecoinToken, + uint256 amountIn, + uint256 amountOutMin + ) external payable returns (uint256 amountOut); +} diff --git a/contracts/bridge/trustless/interfaces/IRouteExecutorAdapter.sol b/contracts/bridge/trustless/interfaces/IRouteExecutorAdapter.sol new file mode 100644 index 0000000..b16acd5 --- /dev/null +++ b/contracts/bridge/trustless/interfaces/IRouteExecutorAdapter.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "../RouteTypesV2.sol"; + +interface IRouteExecutorAdapter { + function validate(RouteTypesV2.RouteLeg calldata leg) external view returns (bool ok, string memory reason); + + function quote( + RouteTypesV2.RouteLeg calldata leg, + uint256 amountIn + ) external view returns (uint256 amountOut, uint256 gasEstimate); + + function execute( + RouteTypesV2.RouteLeg calldata leg, + uint256 amountIn + ) external returns (uint256 amountOut); +} diff --git a/contracts/bridge/trustless/pilot/Chain138PilotDexVenues.sol b/contracts/bridge/trustless/pilot/Chain138PilotDexVenues.sol new file mode 100644 index 0000000..f6f5ffc --- /dev/null +++ b/contracts/bridge/trustless/pilot/Chain138PilotDexVenues.sol @@ -0,0 +1,596 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "../interfaces/IAggregationRouter.sol"; +import "../interfaces/IBalancerVault.sol"; +import "../interfaces/ICurvePool.sol"; +import "../interfaces/ISwapRouter.sol"; + +library Chain138PilotVenueMath { + function sortTokens(address tokenA, address tokenB) internal pure returns (address token0, address token1) { + require(tokenA != address(0) && tokenB != address(0) && tokenA != tokenB, "Chain138PilotVenueMath: invalid tokens"); + (token0, token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA); + } + + function pairKey(address tokenA, address tokenB, uint24 fee) internal pure returns (bytes32) { + (address token0, address token1) = sortTokens(tokenA, tokenB); + return keccak256(abi.encodePacked(token0, token1, fee)); + } + + function pairKey(address tokenA, address tokenB) internal pure returns (bytes32) { + (address token0, address token1) = sortTokens(tokenA, tokenB); + return keccak256(abi.encodePacked(token0, token1)); + } + + function constantProductQuote( + uint256 reserveIn, + uint256 reserveOut, + uint256 amountIn, + uint256 feeBps + ) internal pure returns (uint256 amountOut) { + if (reserveIn == 0 || reserveOut == 0 || amountIn == 0 || feeBps >= 10_000) { + return 0; + } + uint256 amountInWithFee = amountIn * (10_000 - feeBps); + return (reserveOut * amountInWithFee) / (reserveIn * 10_000 + amountInWithFee); + } +} + +contract Chain138PilotOwned { + address public owner; + + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + modifier onlyOwner() { + require(msg.sender == owner, "Chain138PilotOwned: not owner"); + _; + } + + constructor() { + owner = msg.sender; + emit OwnershipTransferred(address(0), msg.sender); + } + + function transferOwnership(address newOwner) external onlyOwner { + require(newOwner != address(0), "Chain138PilotOwned: zero owner"); + emit OwnershipTransferred(owner, newOwner); + owner = newOwner; + } +} + +contract Chain138PilotUniswapV3Router is Chain138PilotOwned, ISwapRouter { + using SafeERC20 for IERC20; + + struct Pool { + address token0; + address token1; + uint24 fee; + uint256 reserve0; + uint256 reserve1; + bool exists; + } + + mapping(bytes32 => Pool) private pools; + + event PairSeeded(bytes32 indexed poolKey, address indexed token0, address indexed token1, uint24 fee, uint256 amount0, uint256 amount1); + event PairFunded(bytes32 indexed poolKey, uint256 amount0, uint256 amount1); + event Swapped(bytes32 indexed poolKey, address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut); + + function seedPair( + address tokenA, + address tokenB, + uint24 fee, + uint256 amountA, + uint256 amountB + ) external onlyOwner returns (bytes32 poolKey) { + require(amountA > 0 && amountB > 0, "Chain138PilotUniswapV3Router: zero seed"); + poolKey = Chain138PilotVenueMath.pairKey(tokenA, tokenB, fee); + Pool storage pool = pools[poolKey]; + (address token0, address token1) = Chain138PilotVenueMath.sortTokens(tokenA, tokenB); + + if (!pool.exists) { + pool.token0 = token0; + pool.token1 = token1; + pool.fee = fee; + pool.exists = true; + emit PairSeeded( + poolKey, + token0, + token1, + fee, + tokenA == token0 ? amountA : amountB, + tokenA == token0 ? amountB : amountA + ); + } + + _fundPool(poolKey, tokenA, tokenB, amountA, amountB); + } + + function fundPair( + address tokenA, + address tokenB, + uint24 fee, + uint256 amountA, + uint256 amountB + ) external onlyOwner { + bytes32 poolKey = Chain138PilotVenueMath.pairKey(tokenA, tokenB, fee); + require(pools[poolKey].exists, "Chain138PilotUniswapV3Router: pair missing"); + _fundPool(poolKey, tokenA, tokenB, amountA, amountB); + } + + function getPairReserves( + address tokenA, + address tokenB, + uint24 fee + ) external view returns (uint256 reserveIn, uint256 reserveOut, bool exists) { + bytes32 poolKey = Chain138PilotVenueMath.pairKey(tokenA, tokenB, fee); + Pool storage pool = pools[poolKey]; + if (!pool.exists) { + return (0, 0, false); + } + if (tokenA == pool.token0) { + return (pool.reserve0, pool.reserve1, true); + } + return (pool.reserve1, pool.reserve0, true); + } + + function quoteExactInputSingle( + address tokenIn, + address tokenOut, + uint24 fee, + uint256 amountIn, + uint160 + ) external view returns (uint256 amountOut) { + (uint256 reserveIn, uint256 reserveOut, bool exists) = this.getPairReserves(tokenIn, tokenOut, fee); + require(exists, "Chain138PilotUniswapV3Router: pair missing"); + return Chain138PilotVenueMath.constantProductQuote(reserveIn, reserveOut, amountIn, fee / 100); + } + + function quoteExactInput(bytes calldata path, uint256 amountIn) external view returns (uint256 amountOut) { + (address tokenIn, address tokenOut, uint24 fee) = _decodeSingleHopPath(path); + return this.quoteExactInputSingle(tokenIn, tokenOut, fee, amountIn, 0); + } + + function exactInputSingle( + ExactInputSingleParams calldata params + ) external payable override returns (uint256 amountOut) { + amountOut = _swap( + params.tokenIn, + params.tokenOut, + params.fee, + params.amountIn, + params.amountOutMinimum, + params.recipient + ); + } + + function exactInput( + ExactInputParams calldata params + ) external payable override returns (uint256 amountOut) { + (address tokenIn, address tokenOut, uint24 fee) = _decodeSingleHopPath(params.path); + amountOut = _swap( + tokenIn, + tokenOut, + fee, + params.amountIn, + params.amountOutMinimum, + params.recipient + ); + } + + function _fundPool( + bytes32 poolKey, + address tokenA, + address tokenB, + uint256 amountA, + uint256 amountB + ) internal { + Pool storage pool = pools[poolKey]; + IERC20(tokenA).safeTransferFrom(msg.sender, address(this), amountA); + IERC20(tokenB).safeTransferFrom(msg.sender, address(this), amountB); + + if (tokenA == pool.token0) { + pool.reserve0 += amountA; + pool.reserve1 += amountB; + emit PairFunded(poolKey, amountA, amountB); + } else { + pool.reserve0 += amountB; + pool.reserve1 += amountA; + emit PairFunded(poolKey, amountB, amountA); + } + } + + function _swap( + address tokenIn, + address tokenOut, + uint24 fee, + uint256 amountIn, + uint256 minAmountOut, + address recipient + ) internal returns (uint256 amountOut) { + bytes32 poolKey = Chain138PilotVenueMath.pairKey(tokenIn, tokenOut, fee); + Pool storage pool = pools[poolKey]; + require(pool.exists, "Chain138PilotUniswapV3Router: pair missing"); + + bool inputIsToken0 = tokenIn == pool.token0; + uint256 reserveIn = inputIsToken0 ? pool.reserve0 : pool.reserve1; + uint256 reserveOut = inputIsToken0 ? pool.reserve1 : pool.reserve0; + + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + amountOut = Chain138PilotVenueMath.constantProductQuote(reserveIn, reserveOut, amountIn, fee / 100); + require(amountOut >= minAmountOut, "Chain138PilotUniswapV3Router: insufficient output"); + + if (inputIsToken0) { + pool.reserve0 += amountIn; + pool.reserve1 -= amountOut; + } else { + pool.reserve1 += amountIn; + pool.reserve0 -= amountOut; + } + + IERC20(tokenOut).safeTransfer(recipient, amountOut); + emit Swapped(poolKey, tokenIn, tokenOut, amountIn, amountOut); + } + + function _decodeSingleHopPath(bytes memory path) internal pure returns (address tokenIn, address tokenOut, uint24 fee) { + require(path.length == 43, "Chain138PilotUniswapV3Router: single hop only"); + assembly { + tokenIn := shr(96, mload(add(path, 32))) + fee := shr(232, mload(add(path, 52))) + tokenOut := shr(96, mload(add(path, 55))) + } + } +} + +contract Chain138PilotBalancerVault is Chain138PilotOwned, IBalancerVault { + using SafeERC20 for IERC20; + + struct Pool { + address token0; + address token1; + uint256 reserve0; + uint256 reserve1; + uint256 feeBps; + bool exists; + } + + mapping(bytes32 => Pool) private pools; + + event PoolSeeded(bytes32 indexed poolId, address indexed token0, address indexed token1, uint256 amount0, uint256 amount1, uint256 feeBps); + event PoolFunded(bytes32 indexed poolId, uint256 amount0, uint256 amount1); + event Swapped(bytes32 indexed poolId, address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut); + + function seedPool( + bytes32 poolId, + address token0, + address token1, + uint256 amount0, + uint256 amount1, + uint256 feeBps + ) external onlyOwner { + require(poolId != bytes32(0), "Chain138PilotBalancerVault: zero poolId"); + require(amount0 > 0 && amount1 > 0, "Chain138PilotBalancerVault: zero seed"); + Pool storage pool = pools[poolId]; + if (!pool.exists) { + pool.token0 = token0; + pool.token1 = token1; + pool.feeBps = feeBps; + pool.exists = true; + emit PoolSeeded(poolId, token0, token1, amount0, amount1, feeBps); + } + _fund(poolId, amount0, amount1); + } + + function fundPool(bytes32 poolId, uint256 amount0, uint256 amount1) external onlyOwner { + require(pools[poolId].exists, "Chain138PilotBalancerVault: pool missing"); + _fund(poolId, amount0, amount1); + } + + function swap( + SingleSwap memory singleSwap, + FundManagement memory funds, + uint256 limit, + uint256 deadline + ) external payable override returns (uint256 amountCalculated) { + require(deadline >= block.timestamp, "Chain138PilotBalancerVault: expired"); + Pool storage pool = pools[singleSwap.poolId]; + require(pool.exists, "Chain138PilotBalancerVault: pool missing"); + require(singleSwap.kind == SwapKind.GIVEN_IN, "Chain138PilotBalancerVault: GIVEN_IN only"); + + bool inputIsToken0; + if (singleSwap.assetIn == pool.token0 && singleSwap.assetOut == pool.token1) { + inputIsToken0 = true; + } else if (singleSwap.assetIn == pool.token1 && singleSwap.assetOut == pool.token0) { + inputIsToken0 = false; + } else { + revert("Chain138PilotBalancerVault: bad assets"); + } + + uint256 reserveIn = inputIsToken0 ? pool.reserve0 : pool.reserve1; + uint256 reserveOut = inputIsToken0 ? pool.reserve1 : pool.reserve0; + IERC20(singleSwap.assetIn).safeTransferFrom(funds.sender, address(this), singleSwap.amount); + amountCalculated = Chain138PilotVenueMath.constantProductQuote(reserveIn, reserveOut, singleSwap.amount, pool.feeBps); + require(amountCalculated >= limit, "Chain138PilotBalancerVault: below limit"); + + if (inputIsToken0) { + pool.reserve0 += singleSwap.amount; + pool.reserve1 -= amountCalculated; + } else { + pool.reserve1 += singleSwap.amount; + pool.reserve0 -= amountCalculated; + } + + IERC20(singleSwap.assetOut).safeTransfer(funds.recipient, amountCalculated); + emit Swapped(singleSwap.poolId, singleSwap.assetIn, singleSwap.assetOut, singleSwap.amount, amountCalculated); + } + + function getPool(bytes32 poolId) external view override returns (address poolAddress, uint8 specialization) { + require(pools[poolId].exists, "Chain138PilotBalancerVault: pool missing"); + return (address(this), 0); + } + + function queryBatchSwap( + SwapKind kind, + SingleSwap[] memory swaps, + address[] memory + ) external view override returns (int256[] memory assetDeltas) { + require(kind == SwapKind.GIVEN_IN, "Chain138PilotBalancerVault: GIVEN_IN only"); + assetDeltas = new int256[](swaps.length > 0 ? 2 : 0); + if (swaps.length == 0) { + return assetDeltas; + } + Pool storage pool = pools[swaps[0].poolId]; + bool inputIsToken0 = swaps[0].assetIn == pool.token0; + uint256 reserveIn = inputIsToken0 ? pool.reserve0 : pool.reserve1; + uint256 reserveOut = inputIsToken0 ? pool.reserve1 : pool.reserve0; + uint256 amountOut = Chain138PilotVenueMath.constantProductQuote(reserveIn, reserveOut, swaps[0].amount, pool.feeBps); + assetDeltas[0] = int256(swaps[0].amount); + assetDeltas[1] = -int256(amountOut); + } + + function getPoolTokens( + bytes32 poolId + ) external view override returns (address[] memory tokens, uint256[] memory balances, uint256 lastChangeBlock) { + Pool storage pool = pools[poolId]; + require(pool.exists, "Chain138PilotBalancerVault: pool missing"); + tokens = new address[](2); + balances = new uint256[](2); + tokens[0] = pool.token0; + tokens[1] = pool.token1; + balances[0] = pool.reserve0; + balances[1] = pool.reserve1; + return (tokens, balances, block.number); + } + + function _fund(bytes32 poolId, uint256 amount0, uint256 amount1) internal { + Pool storage pool = pools[poolId]; + IERC20(pool.token0).safeTransferFrom(msg.sender, address(this), amount0); + IERC20(pool.token1).safeTransferFrom(msg.sender, address(this), amount1); + pool.reserve0 += amount0; + pool.reserve1 += amount1; + emit PoolFunded(poolId, amount0, amount1); + } +} + +contract Chain138PilotCurve3Pool is Chain138PilotOwned, ICurvePool { + using SafeERC20 for IERC20; + + address[3] private _coins; + uint256[3] private _reserves; + uint256 public immutable feeBps; + + event Funded(uint256 amount0, uint256 amount1, uint256 amount2); + event Exchanged(int128 indexed i, int128 indexed j, uint256 dx, uint256 dy); + + constructor(address token0, address token1, address token2, uint256 feeBps_) { + require(token0 != address(0) && token1 != address(0), "Chain138PilotCurve3Pool: invalid tokens"); + _coins[0] = token0; + _coins[1] = token1; + _coins[2] = token2; + feeBps = feeBps_; + } + + function fund(uint256 amount0, uint256 amount1, uint256 amount2) external onlyOwner { + if (amount0 > 0) IERC20(_coins[0]).safeTransferFrom(msg.sender, address(this), amount0); + if (amount1 > 0) IERC20(_coins[1]).safeTransferFrom(msg.sender, address(this), amount1); + if (_coins[2] != address(0) && amount2 > 0) IERC20(_coins[2]).safeTransferFrom(msg.sender, address(this), amount2); + _reserves[0] += amount0; + _reserves[1] += amount1; + _reserves[2] += amount2; + emit Funded(amount0, amount1, amount2); + } + + function reserves(uint256 index) external view returns (uint256) { + return _reserves[index]; + } + + function exchange( + int128 i, + int128 j, + uint256 dx, + uint256 min_dy + ) external payable override returns (uint256 dy) { + dy = _exchange(i, j, dx, min_dy, false); + } + + function exchange_underlying( + int128 i, + int128 j, + uint256 dx, + uint256 min_dy + ) external payable override returns (uint256 dy) { + dy = _exchange(i, j, dx, min_dy, true); + } + + function get_dy( + int128 i, + int128 j, + uint256 dx + ) public view override returns (uint256 dy) { + uint256 ui = uint256(uint128(i)); + uint256 uj = uint256(uint128(j)); + require(ui < 3 && uj < 3 && ui != uj, "Chain138PilotCurve3Pool: bad indexes"); + require(_coins[ui] != address(0) && _coins[uj] != address(0), "Chain138PilotCurve3Pool: token missing"); + + uint256 reserveIn = _reserves[ui]; + uint256 reserveOut = _reserves[uj]; + require(reserveIn > 0 && reserveOut > 0, "Chain138PilotCurve3Pool: empty reserves"); + + uint256 grossOut = (dx * reserveOut) / reserveIn; + dy = (grossOut * (10_000 - feeBps)) / 10_000; + if (dy > reserveOut) { + dy = reserveOut; + } + } + + function coins(uint256 i) external view override returns (address) { + return _coins[i]; + } + + function _exchange( + int128 i, + int128 j, + uint256 dx, + uint256 min_dy, + bool + ) internal returns (uint256 dy) { + uint256 ui = uint256(uint128(i)); + uint256 uj = uint256(uint128(j)); + dy = get_dy(i, j, dx); + require(dy >= min_dy, "Chain138PilotCurve3Pool: insufficient output"); + + IERC20(_coins[ui]).safeTransferFrom(msg.sender, address(this), dx); + _reserves[ui] += dx; + _reserves[uj] -= dy; + IERC20(_coins[uj]).safeTransfer(msg.sender, dy); + emit Exchanged(i, j, dx, dy); + } +} + +contract Chain138PilotOneInchAggregationRouter is Chain138PilotOwned, IAggregationRouter { + using SafeERC20 for IERC20; + + struct Route { + address token0; + address token1; + uint256 reserve0; + uint256 reserve1; + uint256 feeBps; + bool exists; + } + + mapping(bytes32 => Route) private routes; + + event RouteSeeded(address indexed token0, address indexed token1, uint256 amount0, uint256 amount1, uint256 feeBps); + event RouteFunded(address indexed token0, address indexed token1, uint256 amount0, uint256 amount1); + event Swapped(address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut, address indexed recipient); + + function seedRoute( + address tokenA, + address tokenB, + uint256 amountA, + uint256 amountB, + uint256 feeBps + ) external onlyOwner { + require(amountA > 0 && amountB > 0, "Chain138PilotOneInchAggregationRouter: zero seed"); + bytes32 key = Chain138PilotVenueMath.pairKey(tokenA, tokenB); + Route storage route = routes[key]; + (address token0, address token1) = Chain138PilotVenueMath.sortTokens(tokenA, tokenB); + if (!route.exists) { + route.token0 = token0; + route.token1 = token1; + route.feeBps = feeBps; + route.exists = true; + emit RouteSeeded( + token0, + token1, + tokenA == token0 ? amountA : amountB, + tokenA == token0 ? amountB : amountA, + feeBps + ); + } + _fundRoute(key, tokenA, tokenB, amountA, amountB); + } + + function fundRoute(address tokenA, address tokenB, uint256 amountA, uint256 amountB) external onlyOwner { + bytes32 key = Chain138PilotVenueMath.pairKey(tokenA, tokenB); + require(routes[key].exists, "Chain138PilotOneInchAggregationRouter: route missing"); + _fundRoute(key, tokenA, tokenB, amountA, amountB); + } + + function getRouteReserves( + address tokenA, + address tokenB + ) external view returns (uint256 reserveIn, uint256 reserveOut, bool exists) { + bytes32 key = Chain138PilotVenueMath.pairKey(tokenA, tokenB); + Route storage route = routes[key]; + if (!route.exists) { + return (0, 0, false); + } + if (tokenA == route.token0) { + return (route.reserve0, route.reserve1, true); + } + return (route.reserve1, route.reserve0, true); + } + + function quote(address tokenIn, address tokenOut, uint256 amountIn) external view returns (uint256 amountOut) { + (uint256 reserveIn, uint256 reserveOut, bool exists) = this.getRouteReserves(tokenIn, tokenOut); + require(exists, "Chain138PilotOneInchAggregationRouter: route missing"); + return Chain138PilotVenueMath.constantProductQuote(reserveIn, reserveOut, amountIn, routes[Chain138PilotVenueMath.pairKey(tokenIn, tokenOut)].feeBps); + } + + function swap( + address, + SwapDescription calldata desc, + bytes calldata, + bytes calldata + ) external payable override returns (uint256 returnAmount, uint256 spentAmount) { + bytes32 key = Chain138PilotVenueMath.pairKey(desc.srcToken, desc.dstToken); + Route storage route = routes[key]; + require(route.exists, "Chain138PilotOneInchAggregationRouter: route missing"); + + bool inputIsToken0 = desc.srcToken == route.token0; + uint256 reserveIn = inputIsToken0 ? route.reserve0 : route.reserve1; + uint256 reserveOut = inputIsToken0 ? route.reserve1 : route.reserve0; + + IERC20(desc.srcToken).safeTransferFrom(msg.sender, address(this), desc.amount); + returnAmount = Chain138PilotVenueMath.constantProductQuote(reserveIn, reserveOut, desc.amount, route.feeBps); + require(returnAmount >= desc.minReturnAmount, "Chain138PilotOneInchAggregationRouter: insufficient output"); + + if (inputIsToken0) { + route.reserve0 += desc.amount; + route.reserve1 -= returnAmount; + } else { + route.reserve1 += desc.amount; + route.reserve0 -= returnAmount; + } + + IERC20(desc.dstToken).safeTransfer(desc.dstReceiver, returnAmount); + emit Swapped(desc.srcToken, desc.dstToken, desc.amount, returnAmount, desc.dstReceiver); + return (returnAmount, desc.amount); + } + + function _fundRoute( + bytes32 key, + address tokenA, + address tokenB, + uint256 amountA, + uint256 amountB + ) internal { + Route storage route = routes[key]; + IERC20(tokenA).safeTransferFrom(msg.sender, address(this), amountA); + IERC20(tokenB).safeTransferFrom(msg.sender, address(this), amountB); + + if (tokenA == route.token0) { + route.reserve0 += amountA; + route.reserve1 += amountB; + emit RouteFunded(route.token0, route.token1, amountA, amountB); + } else { + route.reserve0 += amountB; + route.reserve1 += amountA; + emit RouteFunded(route.token0, route.token1, amountB, amountA); + } + } +} diff --git a/contracts/config/ConfigurationRegistry.sol b/contracts/config/ConfigurationRegistry.sol index 47771a5..13ea9c0 100644 --- a/contracts/config/ConfigurationRegistry.sol +++ b/contracts/config/ConfigurationRegistry.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "../vendor/openzeppelin/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; /** diff --git a/contracts/dex/DODOPMMIntegration.sol b/contracts/dex/DODOPMMIntegration.sol index 8ee82f2..13c406d 100644 --- a/contracts/dex/DODOPMMIntegration.sol +++ b/contracts/dex/DODOPMMIntegration.sol @@ -15,8 +15,12 @@ import "../reserve/IReserveSystem.sol"; interface IDODOPMMPool { function _BASE_TOKEN_() external view returns (address); function _QUOTE_TOKEN_() external view returns (address); - function sellBase(uint256 amount) external returns (uint256); - function sellQuote(uint256 amount) external returns (uint256); + function querySellBase(address trader, uint256 payBaseAmount) external view returns (uint256 receiveQuoteAmount, uint256 mtFee); + function querySellQuote(address trader, uint256 payQuoteAmount) external view returns (uint256 receiveBaseAmount, uint256 mtFee); + /// @notice Official DODO DVM: excess base in pool vs reserve is sold; `to` receives quote. + function sellBase(address to) external returns (uint256 receiveQuoteAmount); + /// @notice Official DODO DVM: excess quote in pool vs reserve is sold; `to` receives base. + function sellQuote(address to) external returns (uint256 receiveBaseAmount); function buyShares(address to) external returns (uint256 baseShare, uint256 quoteShare, uint256 lpShare); function getVaultReserve() external view returns (uint256 baseReserve, uint256 quoteReserve); function getMidPrice() external view returns (uint256); @@ -54,6 +58,7 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { bytes32 public constant POOL_MANAGER_ROLE = keccak256("POOL_MANAGER_ROLE"); bytes32 public constant SWAP_OPERATOR_ROLE = keccak256("SWAP_OPERATOR_ROLE"); + uint256 private constant POOL_SURFACE_SAMPLE_AMOUNT = 1; // DODO contracts address public immutable dodoVendingMachine; @@ -68,6 +73,7 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { // Pool mappings mapping(address => mapping(address => address)) public pools; // token0 => token1 => pool mapping(address => bool) public isRegisteredPool; + mapping(address => bool) public hasStandardPoolSurface; // Pool configuration struct PoolConfig { @@ -116,6 +122,7 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { address indexed quoteToken, address importer ); + event PoolSurfaceValidated(address indexed pool, bool standardSurface); constructor( address admin, @@ -170,6 +177,7 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { isOpenTWAP // Enable TWAP ); + _requireBasicPoolSurface(pool, compliantUSDT, officialUSDT); _recordPool(pool, compliantUSDT, officialUSDT, lpFeeRate, initialPrice, k, isOpenTWAP); emit PoolCreated(pool, compliantUSDT, officialUSDT, msg.sender); @@ -199,6 +207,7 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { isOpenTWAP ); + _requireBasicPoolSurface(pool, compliantUSDC, officialUSDC); _recordPool(pool, compliantUSDC, officialUSDC, lpFeeRate, initialPrice, k, isOpenTWAP); emit PoolCreated(pool, compliantUSDC, officialUSDC, msg.sender); @@ -228,6 +237,7 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { isOpenTWAP ); + _requireBasicPoolSurface(pool, compliantUSDT, compliantUSDC); _recordPool(pool, compliantUSDT, compliantUSDC, lpFeeRate, initialPrice, k, isOpenTWAP); emit PoolCreated(pool, compliantUSDT, compliantUSDC, msg.sender); @@ -264,6 +274,7 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { isOpenTWAP ); + _requireBasicPoolSurface(pool, baseToken, quoteToken); _recordPool(pool, baseToken, quoteToken, lpFeeRate, initialPrice, k, isOpenTWAP); emit PoolCreated(pool, baseToken, quoteToken, msg.sender); @@ -300,6 +311,7 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { (baseToken, quoteToken) = (quoteToken, baseToken); } + _requireBasicPoolSurface(pool, baseToken, quoteToken); _recordPool(pool, baseToken, quoteToken, lpFeeRate, initialPrice, k, isOpenTWAP); emit PoolImported(pool, baseToken, quoteToken, msg.sender); } @@ -326,10 +338,22 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { // Call buyShares on DODO pool to add liquidity (baseShare, quoteShare, lpShare) = IDODOPMMPool(pool).buyShares(msg.sender); + _setStandardPoolSurface(pool, config.baseToken, config.quoteToken); emit LiquidityAdded(pool, msg.sender, baseAmount, quoteAmount, lpShare); } + /** + * @notice Re-run full standard-surface validation for an already-registered pool. + * @dev Useful during migrations when a replacement pool has been seeded and should + * now qualify as a standard DODO venue for routers and indexers. + */ + function refreshPoolSurface(address pool) external onlyRole(POOL_MANAGER_ROLE) returns (bool standardSurface) { + require(isRegisteredPool[pool], "DODOPMMIntegration: pool not registered"); + PoolConfig memory config = poolConfigs[pool]; + standardSurface = _setStandardPoolSurface(pool, config.baseToken, config.quoteToken); + } + /** * @notice Swap cUSDT for official USDT via DODO PMM * @param pool Pool address @@ -348,8 +372,8 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { // Transfer cUSDT to pool IERC20(compliantUSDT).safeTransferFrom(msg.sender, pool, amountIn); - // Execute swap (sell base token) - amountOut = IDODOPMMPool(pool).sellBase(amountIn); + // Execute swap (sell base token) — DVM sends quote to msg.sender + amountOut = IDODOPMMPool(pool).sellBase(msg.sender); require(amountOut >= minAmountOut, "DODOPMMIntegration: insufficient output"); @@ -374,8 +398,8 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { // Transfer USDT to pool IERC20(officialUSDT).safeTransferFrom(msg.sender, pool, amountIn); - // Execute swap (sell quote token) - amountOut = IDODOPMMPool(pool).sellQuote(amountIn); + // Execute swap (sell quote token) — DVM sends base to msg.sender + amountOut = IDODOPMMPool(pool).sellQuote(msg.sender); require(amountOut >= minAmountOut, "DODOPMMIntegration: insufficient output"); @@ -398,7 +422,7 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { require(amountIn > 0, "DODOPMMIntegration: zero amount"); IERC20(compliantUSDC).safeTransferFrom(msg.sender, pool, amountIn); - amountOut = IDODOPMMPool(pool).sellBase(amountIn); + amountOut = IDODOPMMPool(pool).sellBase(msg.sender); require(amountOut >= minAmountOut, "DODOPMMIntegration: insufficient output"); @@ -421,7 +445,7 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { require(amountIn > 0, "DODOPMMIntegration: zero amount"); IERC20(officialUSDC).safeTransferFrom(msg.sender, pool, amountIn); - amountOut = IDODOPMMPool(pool).sellQuote(amountIn); + amountOut = IDODOPMMPool(pool).sellQuote(msg.sender); require(amountOut >= minAmountOut, "DODOPMMIntegration: insufficient output"); @@ -441,9 +465,8 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { require(amountIn > 0, "DODOPMMIntegration: zero amount"); IERC20(compliantUSDT).safeTransferFrom(msg.sender, pool, amountIn); - amountOut = IDODOPMMPool(pool).sellBase(amountIn); + amountOut = IDODOPMMPool(pool).sellBase(msg.sender); require(amountOut >= minAmountOut, "DODOPMMIntegration: insufficient output"); - IERC20(compliantUSDC).safeTransfer(msg.sender, amountOut); emit SwapExecuted(pool, compliantUSDT, compliantUSDC, amountIn, amountOut, msg.sender); } @@ -460,9 +483,8 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { require(amountIn > 0, "DODOPMMIntegration: zero amount"); IERC20(compliantUSDC).safeTransferFrom(msg.sender, pool, amountIn); - amountOut = IDODOPMMPool(pool).sellQuote(amountIn); + amountOut = IDODOPMMPool(pool).sellQuote(msg.sender); require(amountOut >= minAmountOut, "DODOPMMIntegration: insufficient output"); - IERC20(compliantUSDT).safeTransfer(msg.sender, amountOut); emit SwapExecuted(pool, compliantUSDC, compliantUSDT, amountIn, amountOut, msg.sender); } @@ -486,16 +508,15 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { if (tokenIn == config.baseToken) { tokenOut = config.quoteToken; IERC20(tokenIn).safeTransferFrom(msg.sender, pool, amountIn); - amountOut = IDODOPMMPool(pool).sellBase(amountIn); + amountOut = IDODOPMMPool(pool).sellBase(msg.sender); } else if (tokenIn == config.quoteToken) { tokenOut = config.baseToken; IERC20(tokenIn).safeTransferFrom(msg.sender, pool, amountIn); - amountOut = IDODOPMMPool(pool).sellQuote(amountIn); + amountOut = IDODOPMMPool(pool).sellQuote(msg.sender); } else { revert("DODOPMMIntegration: token not in pool"); } require(amountOut >= minAmountOut, "DODOPMMIntegration: insufficient output"); - IERC20(tokenOut).safeTransfer(msg.sender, amountOut); emit SwapExecuted(pool, tokenIn, tokenOut, amountIn, amountOut, msg.sender); } @@ -574,6 +595,7 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { pools[config.baseToken][config.quoteToken] = address(0); pools[config.quoteToken][config.baseToken] = address(0); isRegisteredPool[pool] = false; + hasStandardPoolSurface[pool] = false; delete poolConfigs[pool]; @@ -605,4 +627,55 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { createdAt: block.timestamp }); } + + function _requireBasicPoolSurface( + address pool, + address expectedBaseToken, + address expectedQuoteToken + ) internal view { + require(pool != address(0), "DODOPMMIntegration: zero pool"); + require(IDODOPMMPool(pool)._BASE_TOKEN_() == expectedBaseToken, "DODOPMMIntegration: unexpected base token"); + require(IDODOPMMPool(pool)._QUOTE_TOKEN_() == expectedQuoteToken, "DODOPMMIntegration: unexpected quote token"); + + IDODOPMMPool(pool).getVaultReserve(); + IDODOPMMPool(pool).getMidPrice(); + IDODOPMMPool(pool)._BASE_RESERVE_(); + IDODOPMMPool(pool)._QUOTE_RESERVE_(); + } + + function _setStandardPoolSurface( + address pool, + address expectedBaseToken, + address expectedQuoteToken + ) internal returns (bool standardSurface) { + standardSurface = _hasStandardPoolSurface(pool, expectedBaseToken, expectedQuoteToken); + require(standardSurface, "DODOPMMIntegration: pool missing standard DODO surface"); + hasStandardPoolSurface[pool] = true; + emit PoolSurfaceValidated(pool, true); + } + + function _hasStandardPoolSurface( + address pool, + address expectedBaseToken, + address expectedQuoteToken + ) internal view returns (bool) { + _requireBasicPoolSurface(pool, expectedBaseToken, expectedQuoteToken); + + (uint256 baseReserve, uint256 quoteReserve) = IDODOPMMPool(pool).getVaultReserve(); + if (baseReserve == 0 || quoteReserve == 0) { + return false; + } + + try IDODOPMMPool(pool).querySellBase(address(this), POOL_SURFACE_SAMPLE_AMOUNT) returns (uint256, uint256) { + } catch { + return false; + } + + try IDODOPMMPool(pool).querySellQuote(address(this), POOL_SURFACE_SAMPLE_AMOUNT) returns (uint256, uint256) { + } catch { + return false; + } + + return true; + } } diff --git a/contracts/dex/MockDVMPool.sol b/contracts/dex/MockDVMPool.sol index 7dd5eca..d0e76a7 100644 --- a/contracts/dex/MockDVMPool.sol +++ b/contracts/dex/MockDVMPool.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + /** * @title MockDVMPool * @notice Minimal mock of a DODO PMM pool so DODOPMMIntegration can deploy and create pools on Chain 138 @@ -12,6 +14,8 @@ contract MockDVMPool { uint256 public baseReserve; uint256 public quoteReserve; uint256 public midPrice; + uint256 public totalSupply; + mapping(address => uint256) private _shares; uint256 public constant _K_ = 0.5e18; // 50% slippage factor (DODO convention) uint256 public constant _LP_FEE_RATE_ = 3; // 0.03% in basis points @@ -47,6 +51,10 @@ contract MockDVMPool { return (baseReserve, quoteReserve); } + function balanceOf(address owner) external view returns (uint256) { + return _shares[owner]; + } + function getMidPrice() external view returns (uint256) { return midPrice; } @@ -56,15 +64,71 @@ contract MockDVMPool { return midPrice; } - function sellBase(uint256) external returns (uint256) { - return 0; + function querySellBase(address, uint256 amount) external view returns (uint256, uint256) { + return ((amount * midPrice) / 1e18, 0); } - function sellQuote(uint256) external returns (uint256) { - return 0; + function querySellQuote(address, uint256 amount) external view returns (uint256, uint256) { + if (midPrice == 0) { + return (0, 0); + } + return ((amount * 1e18) / midPrice, 0); } - function buyShares(address) external returns (uint256, uint256, uint256) { - return (0, 0, 0); + function sellBase(address to) external returns (uint256 receiveQuoteAmount) { + uint256 baseBal = IERC20(baseToken).balanceOf(address(this)); + require(baseBal >= baseReserve, "MockDVMPool: base"); + uint256 baseInput = baseBal - baseReserve; + if (baseInput == 0) return 0; + (receiveQuoteAmount,) = this.querySellBase(address(0), baseInput); + require(IERC20(quoteToken).transfer(to, receiveQuoteAmount), "MockDVMPool: q"); + baseReserve = IERC20(baseToken).balanceOf(address(this)); + quoteReserve = IERC20(quoteToken).balanceOf(address(this)); + } + + function sellQuote(address to) external returns (uint256 receiveBaseAmount) { + uint256 quoteBal = IERC20(quoteToken).balanceOf(address(this)); + require(quoteBal >= quoteReserve, "MockDVMPool: quote"); + uint256 quoteInput = quoteBal - quoteReserve; + if (quoteInput == 0) return 0; + (receiveBaseAmount,) = this.querySellQuote(address(0), quoteInput); + require(IERC20(baseToken).transfer(to, receiveBaseAmount), "MockDVMPool: b"); + baseReserve = IERC20(baseToken).balanceOf(address(this)); + quoteReserve = IERC20(quoteToken).balanceOf(address(this)); + } + + function buyShares(address to) external returns (uint256, uint256, uint256) { + uint256 baseBal = IERC20(baseToken).balanceOf(address(this)); + uint256 quoteBal = IERC20(quoteToken).balanceOf(address(this)); + require(baseBal >= baseReserve && quoteBal >= quoteReserve, "MockDVMPool: reserve"); + + uint256 baseInput = baseBal - baseReserve; + uint256 quoteInput = quoteBal - quoteReserve; + require(baseInput > 0 && quoteInput > 0, "MockDVMPool: no input"); + + uint256 lpShare; + if (totalSupply == 0) { + lpShare = baseBal; + require(lpShare > 2001, "MockDVMPool: MINT_AMOUNT_NOT_ENOUGH"); + totalSupply = lpShare; + _shares[address(0)] = 1001; + lpShare -= 1001; + _shares[to] += lpShare; + } else { + uint256 baseShares = (baseInput * totalSupply) / baseReserve; + uint256 quoteShares = (quoteInput * totalSupply) / quoteReserve; + lpShare = baseShares < quoteShares ? baseShares : quoteShares; + totalSupply += lpShare; + _shares[to] += lpShare; + } + + baseReserve = baseBal; + quoteReserve = quoteBal; + return (baseInput, quoteInput, lpShare); + } + + function sync() external { + baseReserve = IERC20(baseToken).balanceOf(address(this)); + quoteReserve = IERC20(quoteToken).balanceOf(address(this)); } } diff --git a/contracts/flash/AaveQuotePushFlashReceiver.sol b/contracts/flash/AaveQuotePushFlashReceiver.sol new file mode 100644 index 0000000..232a3f2 --- /dev/null +++ b/contracts/flash/AaveQuotePushFlashReceiver.sol @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +interface IAavePoolLike { + function flashLoanSimple( + address receiverAddress, + address asset, + uint256 amount, + bytes calldata params, + uint16 referralCode + ) external; +} + +interface IAaveFlashLoanSimpleReceiver { + function executeOperation( + address asset, + uint256 amount, + uint256 premium, + address initiator, + bytes calldata params + ) external returns (bool); +} + +interface IAaveDODOQuotePushSwapExactIn { + function swapExactIn(address pool, address tokenIn, uint256 amountIn, uint256 minAmountOut) + external + returns (uint256 amountOut); +} + +interface IAaveExternalUnwinder { + function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata data) + external + returns (uint256 amountOut); +} + +interface IAaveAtomicBridgeCoordinator { + struct CreateIntentParams { + uint64 sourceChain; + uint64 destinationChain; + address assetIn; + address assetOut; + uint256 amountIn; + uint256 minAmountOut; + address recipient; + uint256 deadline; + bytes32 routeId; + } + + function createIntent(CreateIntentParams calldata p) external returns (bytes32 obligationId); + + function submitCommitment(bytes32 obligationId, bytes32 settlementMode) external; + + function obligationEscrow() external view returns (address); +} + +/** + * @title AaveQuotePushFlashReceiver + * @notice Aave V3 flashLoanSimple receiver for the quote-push workflow: + * flash borrow quote -> buy PMM base -> unwind base externally -> repay lender, retaining any surplus. + */ +contract AaveQuotePushFlashReceiver is IAaveFlashLoanSimpleReceiver { + using SafeERC20 for IERC20; + + address public immutable pool; + + struct QuotePushParams { + address integration; + address pmmPool; + address baseToken; + address externalUnwinder; + uint256 minOutPmm; + uint256 minOutUnwind; + bytes unwindData; + AtomicBridgeParams atomicBridge; + } + + struct AtomicBridgeParams { + address coordinator; + uint64 sourceChain; + uint64 destinationChain; + address destinationAsset; + uint256 bridgeAmount; + uint256 minDestinationAmount; + address destinationRecipient; + uint256 destinationDeadline; + bytes32 routeId; + bytes32 settlementMode; + bool submitCommitment; + } + + error UntrustedPool(); + error UntrustedInitiator(); + error BadParams(); + error InsufficientToRepay(); + error InvalidAtomicBridge(); + + event QuotePushExecuted( + address indexed quoteToken, + address indexed baseToken, + uint256 borrowedAmount, + uint256 premium, + uint256 baseOut, + uint256 unwindOut, + uint256 surplus + ); + event AtomicBridgeTriggered( + bytes32 indexed obligationId, + address indexed coordinator, + address indexed destinationRecipient, + uint256 bridgeAmount, + uint256 minDestinationAmount + ); + + constructor(address pool_) { + pool = pool_; + } + + function flashQuotePush(address asset, uint256 amount, QuotePushParams calldata params) external { + IAavePoolLike(pool).flashLoanSimple(address(this), asset, amount, abi.encode(address(this), params), 0); + } + + function executeOperation( + address asset, + uint256 amount, + uint256 premium, + address initiator, + bytes calldata params + ) external returns (bool) { + if (msg.sender != pool) revert UntrustedPool(); + (address expectedInitiator, QuotePushParams memory p) = abi.decode(params, (address, QuotePushParams)); + if (initiator != expectedInitiator) revert UntrustedInitiator(); + if ( + p.integration == address(0) || p.pmmPool == address(0) || p.baseToken == address(0) + || p.externalUnwinder == address(0) + ) revert BadParams(); + if (p.baseToken == asset) revert BadParams(); + + uint256 baseOut = _swapQuoteForBase(asset, amount, p.integration, p.pmmPool, p.minOutPmm); + + uint256 baseBal = IERC20(p.baseToken).balanceOf(address(this)); + if (p.atomicBridge.coordinator != address(0)) { + _triggerAtomicBridge(p.baseToken, baseBal, p.atomicBridge); + } + + uint256 unwindOut = _unwindBaseIntoQuote(p.baseToken, asset, p.externalUnwinder, p.minOutUnwind, p.unwindData); + uint256 surplus = _approveRepayment(asset, amount + premium); + emit QuotePushExecuted(asset, p.baseToken, amount, premium, baseOut, unwindOut, surplus); + return true; + } + + function _swapQuoteForBase(address asset, uint256 amount, address integration, address pmmPool, uint256 minOutPmm) + internal + returns (uint256 baseOut) + { + IERC20(asset).forceApprove(integration, amount); + baseOut = IAaveDODOQuotePushSwapExactIn(integration).swapExactIn(pmmPool, asset, amount, minOutPmm); + } + + function _unwindBaseIntoQuote( + address baseToken, + address quoteToken, + address externalUnwinder, + uint256 minOutUnwind, + bytes memory unwindData + ) internal returns (uint256 unwindOut) { + uint256 remainingBase = IERC20(baseToken).balanceOf(address(this)); + IERC20(baseToken).forceApprove(externalUnwinder, remainingBase); + unwindOut = + IAaveExternalUnwinder(externalUnwinder).unwind(baseToken, quoteToken, remainingBase, minOutUnwind, unwindData); + } + + function _approveRepayment(address quoteToken, uint256 need) internal returns (uint256 surplus) { + IERC20 quote = IERC20(quoteToken); + uint256 quoteBal = quote.balanceOf(address(this)); + if (quoteBal < need) revert InsufficientToRepay(); + surplus = quoteBal - need; + quote.forceApprove(pool, need); + } + + function _triggerAtomicBridge(address baseToken, uint256 baseBal, AtomicBridgeParams memory atomicBridge) internal { + if ( + atomicBridge.destinationAsset == address(0) || atomicBridge.destinationRecipient == address(0) + || atomicBridge.bridgeAmount == 0 || atomicBridge.bridgeAmount > baseBal + || atomicBridge.destinationDeadline <= block.timestamp + ) revert InvalidAtomicBridge(); + + address escrowAddress = IAaveAtomicBridgeCoordinator(atomicBridge.coordinator).obligationEscrow(); + IERC20(baseToken).forceApprove(escrowAddress, atomicBridge.bridgeAmount); + bytes32 obligationId = IAaveAtomicBridgeCoordinator(atomicBridge.coordinator).createIntent( + IAaveAtomicBridgeCoordinator.CreateIntentParams({ + sourceChain: atomicBridge.sourceChain, + destinationChain: atomicBridge.destinationChain, + assetIn: baseToken, + assetOut: atomicBridge.destinationAsset, + amountIn: atomicBridge.bridgeAmount, + minAmountOut: atomicBridge.minDestinationAmount, + recipient: atomicBridge.destinationRecipient, + deadline: atomicBridge.destinationDeadline, + routeId: atomicBridge.routeId + }) + ); + if (atomicBridge.submitCommitment) { + IAaveAtomicBridgeCoordinator(atomicBridge.coordinator).submitCommitment( + obligationId, atomicBridge.settlementMode + ); + } + emit AtomicBridgeTriggered( + obligationId, + atomicBridge.coordinator, + atomicBridge.destinationRecipient, + atomicBridge.bridgeAmount, + atomicBridge.minDestinationAmount + ); + } +} diff --git a/contracts/flash/CrossChainFlashBorrower.sol b/contracts/flash/CrossChainFlashBorrower.sol new file mode 100644 index 0000000..ab189a7 --- /dev/null +++ b/contracts/flash/CrossChainFlashBorrower.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ICrossChainFlashBridge} from "./interfaces/ICrossChainFlashBridge.sol"; + +/** + * @title CrossChainFlashBorrower + * @notice ERC-3156 borrower: flash `token` on **this** chain → bridge `bridgeAmount` out → repay `amount + fee` to the flash vault from **on-hand** balance. + * @dev **Atomicity is single-chain only.** CCIP / bridge finality is asynchronous; destination delivery cannot complete inside this callback. + * **Prefunding:** before `flashLoan`, this contract should hold at least `bridgeAmount + fee` of `token` + * (or `amount + fee` if `bridgeAmount == amount`) so repayment succeeds after the bridge pulls `bridgeAmount`. + * Encode `CrossChainFlashParams` in `data`. + */ +contract CrossChainFlashBorrower is IERC3156FlashBorrower { + using SafeERC20 for IERC20; + + bytes32 private constant _RETURN_VALUE = keccak256("ERC3156FlashBorrower.onFlashLoan"); + + address public immutable trustedLender; + + struct CrossChainFlashParams { + address bridge; + uint256 bridgeAmount; + uint64 destinationChainSelector; + address recipientOnDestination; + bytes bridgeExtraData; + uint256 nativeBridgeFee; + } + + error UntrustedLender(); + error BadParams(); + error InsufficientToRepay(); + + constructor(address trustedLender_) { + trustedLender = trustedLender_; + } + + function onFlashLoan( + address, + address token, + uint256 amount, + uint256 fee, + bytes calldata data + ) external override returns (bytes32) { + if (msg.sender != trustedLender) revert UntrustedLender(); + _run(abi.decode(data, (CrossChainFlashParams)), token, amount, fee); + return _RETURN_VALUE; + } + + function _run(CrossChainFlashParams memory p, address token, uint256 amount, uint256 fee) internal { + if (p.bridge == address(0) || p.recipientOnDestination == address(0)) revert BadParams(); + if (p.bridgeAmount == 0 || p.bridgeAmount > amount) revert BadParams(); + + IERC20 t = IERC20(token); + t.forceApprove(p.bridge, p.bridgeAmount); + ICrossChainFlashBridge(p.bridge).bridgeTokensFrom{value: p.nativeBridgeFee}( + token, + p.bridgeAmount, + p.destinationChainSelector, + p.recipientOnDestination, + p.bridgeExtraData + ); + + uint256 need = amount + fee; + if (t.balanceOf(address(this)) < need) revert InsufficientToRepay(); + t.safeTransfer(msg.sender, need); + } + + receive() external payable {} +} diff --git a/contracts/flash/CrossChainFlashRepayReceiver.sol b/contracts/flash/CrossChainFlashRepayReceiver.sol new file mode 100644 index 0000000..af78577 --- /dev/null +++ b/contracts/flash/CrossChainFlashRepayReceiver.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IRouterClient} from "../ccip/IRouterClient.sol"; + +/** + * @title CrossChainFlashRepayReceiver + * @notice **Destination-chain** CCIP receiver: forwards received ERC-20s to a recipient encoded in `message.data`. + * @dev Intended pairing with `CrossChainFlashBorrower` / bridge senders. `msg.sender` must be the CCIP router. + * `data` encoding: `abi.encode(address recipient, bytes32 obligationId)` where `obligationId` is opaque (indexing / ops). + * **Repaying the source-chain flash vault** requires a **separate** flow (second tx / bridge back); this contract does not pull debt on source. + */ +contract CrossChainFlashRepayReceiver { + using SafeERC20 for IERC20; + + IRouterClient public immutable ccipRouter; + + event CrossChainDelivered( + bytes32 indexed messageId, + uint64 indexed sourceChainSelector, + address indexed recipient, + address token, + uint256 amount, + bytes32 obligationId + ); + + error OnlyRouter(); + error NoTokens(); + error BadData(); + + constructor(address ccipRouter_) { + require(ccipRouter_ != address(0), "CrossChainFlashRepayReceiver: zero router"); + ccipRouter = IRouterClient(ccipRouter_); + } + + modifier onlyRouter() { + if (msg.sender != address(ccipRouter)) revert OnlyRouter(); + _; + } + + function ccipReceive(IRouterClient.Any2EVMMessage calldata message) external onlyRouter { + _deliver(message); + } + + function _deliver(IRouterClient.Any2EVMMessage calldata m) private { + if (m.tokenAmounts.length == 0) revert NoTokens(); + IRouterClient.TokenAmount calldata ta = m.tokenAmounts[0]; + if (ta.token == address(0) || ta.amount == 0) revert NoTokens(); + (address recipient, bytes32 obligationId) = abi.decode(m.data, (address, bytes32)); + if (recipient == address(0)) revert BadData(); + IERC20(ta.token).safeTransfer(recipient, ta.amount); + emit CrossChainDelivered(m.messageId, m.sourceChainSelector, recipient, ta.token, ta.amount, obligationId); + } +} + diff --git a/contracts/flash/CrossChainFlashVaultCreditReceiver.sol b/contracts/flash/CrossChainFlashVaultCreditReceiver.sol new file mode 100644 index 0000000..d86156e --- /dev/null +++ b/contracts/flash/CrossChainFlashVaultCreditReceiver.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IRouterClient} from "../ccip/IRouterClient.sol"; + +/** + * @title CrossChainFlashVaultCreditReceiver + * @notice **Source-chain** (or same-chain) CCIP receiver: forwards the first ERC-20 leg to a vault address encoded in `message.data`. + * @dev Use to **restore flash vault balance** after off-chain or destination-chain settlement. This is not an ERC-3156 repayment + * (that must still happen in the flash callback on the borrow chain). `data` = `abi.encode(address vault)`. + */ +contract CrossChainFlashVaultCreditReceiver { + using SafeERC20 for IERC20; + + IRouterClient public immutable ccipRouter; + + event LiquidityCredited( + bytes32 indexed messageId, + uint64 indexed sourceChainSelector, + address indexed vault, + address token, + uint256 amount + ); + + error OnlyRouter(); + error NoTokens(); + error BadData(); + + constructor(address ccipRouter_) { + require(ccipRouter_ != address(0), "CrossChainFlashVaultCreditReceiver: zero router"); + ccipRouter = IRouterClient(ccipRouter_); + } + + modifier onlyRouter() { + if (msg.sender != address(ccipRouter)) revert OnlyRouter(); + _; + } + + function ccipReceive(IRouterClient.Any2EVMMessage calldata message) external onlyRouter { + _credit(message); + } + + function _credit(IRouterClient.Any2EVMMessage calldata m) private { + if (m.tokenAmounts.length == 0) revert NoTokens(); + IRouterClient.TokenAmount calldata ta = m.tokenAmounts[0]; + if (ta.token == address(0) || ta.amount == 0) revert NoTokens(); + address vault = abi.decode(m.data, (address)); + if (vault == address(0)) revert BadData(); + IERC20(ta.token).safeTransfer(vault, ta.amount); + emit LiquidityCredited(m.messageId, m.sourceChainSelector, vault, ta.token, ta.amount); + } +} diff --git a/contracts/flash/DODOIntegrationExternalUnwinder.sol b/contracts/flash/DODOIntegrationExternalUnwinder.sol new file mode 100644 index 0000000..f05be6c --- /dev/null +++ b/contracts/flash/DODOIntegrationExternalUnwinder.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +interface IDODOIntegrationSwapExactIn { + function swapExactIn(address pool, address tokenIn, uint256 amountIn, uint256 minAmountOut) + external + returns (uint256 amountOut); +} + +/** + * @title DODOIntegrationExternalUnwinder + * @notice Unwinds base -> quote through a DODO PMM integration. + * @dev `data` must be `abi.encode(address pool)` selecting the registered pool to use. + */ +contract DODOIntegrationExternalUnwinder { + using SafeERC20 for IERC20; + + address public immutable integration; + + error BadParams(); + + constructor(address integration_) { + integration = integration_; + } + + function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata data) + external + returns (uint256 amountOut) + { + if (tokenIn == address(0) || tokenOut == address(0) || tokenIn == tokenOut || amountIn == 0) revert BadParams(); + if (data.length != 32) revert BadParams(); + + address pool = abi.decode(data, (address)); + if (pool == address(0)) revert BadParams(); + + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + IERC20(tokenIn).forceApprove(integration, amountIn); + amountOut = IDODOIntegrationSwapExactIn(integration).swapExactIn(pool, tokenIn, amountIn, minAmountOut); + IERC20(tokenOut).safeTransfer(msg.sender, amountOut); + } +} diff --git a/contracts/flash/DODOToUniswapV3MultiHopExternalUnwinder.sol b/contracts/flash/DODOToUniswapV3MultiHopExternalUnwinder.sol new file mode 100644 index 0000000..b1453fc --- /dev/null +++ b/contracts/flash/DODOToUniswapV3MultiHopExternalUnwinder.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ISwapRouter} from "../bridge/trustless/interfaces/ISwapRouter.sol"; + +interface IDODOMultiHopSwapExactIn { + function swapExactIn(address pool, address tokenIn, uint256 amountIn, uint256 minAmountOut) + external + returns (uint256 amountOut); +} + +/** + * @title DODOToUniswapV3MultiHopExternalUnwinder + * @notice Unwinds through a DODO PMM first hop and a Uniswap V3 final hop. + * @dev `data` must be abi.encode(address dodoPool, address intermediateToken, uint256 minIntermediateOut, bytes uniswapPath). + * `uniswapPath` is the standard exactInput path beginning with `intermediateToken` and ending with `tokenOut`. + */ +contract DODOToUniswapV3MultiHopExternalUnwinder { + using SafeERC20 for IERC20; + + address public immutable integration; + address public immutable router; + + error BadParams(); + + constructor(address integration_, address router_) { + integration = integration_; + router = router_; + } + + function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata data) + external + returns (uint256 amountOut) + { + if (tokenIn == address(0) || tokenOut == address(0) || tokenIn == tokenOut || amountIn == 0) revert BadParams(); + + (address dodoPool, address intermediateToken, uint256 minIntermediateOut, bytes memory uniswapPath) = + abi.decode(data, (address, address, uint256, bytes)); + if (dodoPool == address(0) || intermediateToken == address(0) || intermediateToken == tokenIn || intermediateToken == tokenOut) { + revert BadParams(); + } + if (uniswapPath.length < 43) revert BadParams(); + + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + IERC20(tokenIn).forceApprove(integration, amountIn); + uint256 intermediateOut = + IDODOMultiHopSwapExactIn(integration).swapExactIn(dodoPool, tokenIn, amountIn, minIntermediateOut); + + IERC20(intermediateToken).forceApprove(router, intermediateOut); + amountOut = ISwapRouter(router).exactInput( + ISwapRouter.ExactInputParams({ + path: uniswapPath, + recipient: msg.sender, + deadline: block.timestamp + 300, + amountIn: intermediateOut, + amountOutMinimum: minAmountOut + }) + ); + } +} diff --git a/contracts/flash/MinimalERC3156FlashBorrower.sol b/contracts/flash/MinimalERC3156FlashBorrower.sol new file mode 100644 index 0000000..e62e80d --- /dev/null +++ b/contracts/flash/MinimalERC3156FlashBorrower.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @title MinimalERC3156FlashBorrower + * @notice Repays vault via ERC-20 transfer in `onFlashLoan`. Pre-fund this contract with at least `flashFee` of the loan token before calling `flashLoan` (vault sends `amount` only before the callback). + */ +contract MinimalERC3156FlashBorrower is IERC3156FlashBorrower { + bytes32 private constant _RETURN_VALUE = keccak256("ERC3156FlashBorrower.onFlashLoan"); + + address public immutable trustedLender; + + error UntrustedLender(); + + constructor(address trustedLender_) { + trustedLender = trustedLender_; + } + + function onFlashLoan( + address, + address token, + uint256 amount, + uint256 fee, + bytes calldata + ) external override returns (bytes32) { + if (msg.sender != trustedLender) revert UntrustedLender(); + IERC20(token).transfer(msg.sender, amount + fee); + return _RETURN_VALUE; + } +} diff --git a/contracts/flash/QuotePushFlashWorkflowBorrower.sol b/contracts/flash/QuotePushFlashWorkflowBorrower.sol new file mode 100644 index 0000000..01fc23f --- /dev/null +++ b/contracts/flash/QuotePushFlashWorkflowBorrower.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/// @dev Matches `DODOPMMIntegration.swapExactIn` surface (any registered pool). +interface IDODOQuotePushSwapExactIn { + function swapExactIn(address pool, address tokenIn, uint256 amountIn, uint256 minAmountOut) + external + returns (uint256 amountOut); +} + +/// @dev Minimal external unwind interface for converting PMM base back into flash-borrowed quote. +interface IExternalUnwinder { + function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata data) + external + returns (uint256 amountOut); +} + +/** + * @title QuotePushFlashWorkflowBorrower + * @notice ERC-3156 borrower for a quote-push loop: + * flash `quoteToken` -> buy `baseToken` from a DODO-style PMM -> unwind externally back into `quoteToken` + * -> repay `amount + fee`, leaving any quote surplus on this contract. + * @dev `data` must be `abi.encode(QuotePushParams)`. The caller is responsible for choosing trusted integrations, + * setting conservative minimums, and sweeping any retained surplus from this contract after execution. + */ +contract QuotePushFlashWorkflowBorrower is IERC3156FlashBorrower { + using SafeERC20 for IERC20; + + bytes32 private constant _RETURN_VALUE = keccak256("ERC3156FlashBorrower.onFlashLoan"); + + address public immutable trustedLender; + + struct QuotePushParams { + address integration; + address pool; + address baseToken; + address externalUnwinder; + uint256 minOutPmm; + uint256 minOutUnwind; + bytes unwindData; + } + + error UntrustedLender(); + error BadParams(); + error InsufficientToRepay(); + + event QuotePushExecuted( + address indexed quoteToken, + address indexed baseToken, + uint256 borrowedAmount, + uint256 fee, + uint256 baseOut, + uint256 unwindOut, + uint256 surplus + ); + + constructor(address trustedLender_) { + trustedLender = trustedLender_; + } + + function onFlashLoan( + address, + address quoteToken, + uint256 amount, + uint256 fee, + bytes calldata data + ) external override returns (bytes32) { + if (msg.sender != trustedLender) revert UntrustedLender(); + QuotePushParams memory p = abi.decode(data, (QuotePushParams)); + if ( + p.integration == address(0) || p.pool == address(0) || p.baseToken == address(0) + || p.externalUnwinder == address(0) + ) revert BadParams(); + if (p.baseToken == quoteToken) revert BadParams(); + + IERC20 borrowed = IERC20(quoteToken); + IERC20 base = IERC20(p.baseToken); + + borrowed.forceApprove(p.integration, amount); + uint256 baseOut = IDODOQuotePushSwapExactIn(p.integration).swapExactIn(p.pool, quoteToken, amount, p.minOutPmm); + + uint256 baseBal = base.balanceOf(address(this)); + base.forceApprove(p.externalUnwinder, baseBal); + uint256 unwindOut = + IExternalUnwinder(p.externalUnwinder).unwind(p.baseToken, quoteToken, baseBal, p.minOutUnwind, p.unwindData); + + uint256 need = amount + fee; + uint256 quoteBal = borrowed.balanceOf(address(this)); + if (quoteBal < need) revert InsufficientToRepay(); + + uint256 surplus = quoteBal - need; + borrowed.safeTransfer(msg.sender, need); + emit QuotePushExecuted(quoteToken, p.baseToken, amount, fee, baseOut, unwindOut, surplus); + + return _RETURN_VALUE; + } +} diff --git a/contracts/flash/SimpleERC3156FlashVault.sol b/contracts/flash/SimpleERC3156FlashVault.sol new file mode 100644 index 0000000..2905775 --- /dev/null +++ b/contracts/flash/SimpleERC3156FlashVault.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC3156FlashLender} from "@openzeppelin/contracts/interfaces/IERC3156FlashLender.sol"; +import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +/** + * @title SimpleERC3156FlashVault + * @notice Minimal ERC-3156 flash lender: whitelist per token, flat bps fee, same-block repayment invariant. + * @dev Intended for Chain 138 / internal atomic workflows when no external flash source exists. + */ +contract SimpleERC3156FlashVault is IERC3156FlashLender, Ownable, ReentrancyGuard { + using SafeERC20 for IERC20; + + bytes32 private constant _RETURN_VALUE = keccak256("ERC3156FlashBorrower.onFlashLoan"); + + /// @notice Maximum owner-configurable fee (10%). Prevents griefing at 100% fee. + uint256 public constant MAX_FEE_BPS = 1000; + + /// @notice Fee on borrowed amount, basis points (10_000 = 100%). + uint256 public feeBps; + + mapping(address token => bool) public supportedToken; + + /// @notice When true, only `approvedBorrower[receiver]` may be used as the flash callback contract. + bool public borrowerAllowlistEnabled; + + mapping(address borrower => bool) public approvedBorrower; + + /// @notice Cumulative fees retained by the vault per token (for ops / accounting). + mapping(address token => uint256) public totalFeesCollected; + + /// @notice initiator = flashLoan caller; receiver = IERC3156FlashBorrower callback target. + event FlashLoan(address indexed initiator, IERC3156FlashBorrower indexed receiver, address indexed token, uint256 amount, uint256 fee); + event FeeBpsUpdated(uint256 feeBps); + event TokenSupportUpdated(address indexed token, bool supported); + event TokensRescued(address indexed token, address indexed to, uint256 amount); + event BorrowerAllowlistEnabledUpdated(bool enabled); + event BorrowerApprovalUpdated(address indexed borrower, bool approved); + + error UnsupportedToken(); + error ZeroAmount(); + error FeeTooHigh(); + error InvalidCallback(); + error RepaymentFailed(); + error ZeroRescue(); + error ZeroRecipient(); + error BorrowerNotApproved(); + + constructor(address initialOwner, uint256 initialFeeBps) Ownable(initialOwner) { + _setFeeBps(initialFeeBps); + } + + function setFeeBps(uint256 newFeeBps) external onlyOwner { + _setFeeBps(newFeeBps); + } + + function setTokenSupported(address token, bool supported) external onlyOwner { + supportedToken[token] = supported; + emit TokenSupportUpdated(token, supported); + } + + function setBorrowerAllowlistEnabled(bool enabled) external onlyOwner { + borrowerAllowlistEnabled = enabled; + emit BorrowerAllowlistEnabledUpdated(enabled); + } + + function setBorrowerApproved(address borrower, bool approved) external onlyOwner { + approvedBorrower[borrower] = approved; + emit BorrowerApprovalUpdated(borrower, approved); + } + + /// @notice Operator alias for `flashFee` (same revert rules). + function previewFlashFee(address token, uint256 amount) external view returns (uint256) { + return flashFee(token, amount); + } + + /// @notice Owner-only recovery (mis-seeded asset, deprecated token, migration). Does not bypass flash invariants on active loans in the same tx. + function rescueTokens(address token, uint256 amount, address to) external onlyOwner { + if (amount == 0) revert ZeroRescue(); + if (to == address(0)) revert ZeroRecipient(); + IERC20(token).safeTransfer(to, amount); + emit TokensRescued(token, to, amount); + } + + function maxFlashLoan(address token) external view override returns (uint256) { + if (!supportedToken[token]) { + return 0; + } + return IERC20(token).balanceOf(address(this)); + } + + function flashFee(address token, uint256 amount) public view override returns (uint256) { + if (!supportedToken[token]) { + revert UnsupportedToken(); + } + return (amount * feeBps) / 10_000; + } + + function flashLoan( + IERC3156FlashBorrower receiver, + address token, + uint256 amount, + bytes calldata data + ) external override nonReentrant returns (bool) { + if (!supportedToken[token]) revert UnsupportedToken(); + if (amount == 0) revert ZeroAmount(); + if (borrowerAllowlistEnabled && !approvedBorrower[address(receiver)]) { + revert BorrowerNotApproved(); + } + + uint256 fee = flashFee(token, amount); + uint256 balanceBefore = IERC20(token).balanceOf(address(this)); + if (balanceBefore < amount) revert RepaymentFailed(); + + IERC20(token).safeTransfer(address(receiver), amount); + + bytes32 retval = receiver.onFlashLoan(msg.sender, token, amount, fee, data); + if (retval != _RETURN_VALUE) revert InvalidCallback(); + + if (IERC20(token).balanceOf(address(this)) < balanceBefore + fee) { + revert RepaymentFailed(); + } + + totalFeesCollected[token] += fee; + + emit FlashLoan(msg.sender, receiver, token, amount, fee); + return true; + } + + function _setFeeBps(uint256 newFeeBps) internal { + if (newFeeBps > MAX_FEE_BPS) revert FeeTooHigh(); + feeBps = newFeeBps; + emit FeeBpsUpdated(newFeeBps); + } +} diff --git a/contracts/flash/SwapFlashWorkflowBorrower.sol b/contracts/flash/SwapFlashWorkflowBorrower.sol new file mode 100644 index 0000000..0ee7835 --- /dev/null +++ b/contracts/flash/SwapFlashWorkflowBorrower.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/// @dev Matches `DODOPMMIntegration.swapExactIn` surface (any registered pool). +interface IDODOStyleSwapExactIn { + function swapExactIn(address pool, address tokenIn, uint256 amountIn, uint256 minAmountOut) + external + returns (uint256 amountOut); +} + +/** + * @title SwapFlashWorkflowBorrower + * @notice ERC-3156 borrower: flash `token` → swap to `midToken` → swap back to `token` → repay `amount + fee`. + * @dev `data` must be `abi.encode(SwapFlashParams)`. The caller chooses `integration` — use only trusted DODO/PMM routers. + * `pool` is typically the same for both legs (e.g. cUSDT/USDT round-trip). Set mins from off-chain quotes. + */ +contract SwapFlashWorkflowBorrower is IERC3156FlashBorrower { + using SafeERC20 for IERC20; + + bytes32 private constant _RETURN_VALUE = keccak256("ERC3156FlashBorrower.onFlashLoan"); + + address public immutable trustedLender; + + struct SwapFlashParams { + address integration; + address pool; + address midToken; + uint256 minOutFirst; + uint256 minOutSecond; + } + + error UntrustedLender(); + error BadParams(); + error InsufficientToRepay(); + + constructor(address trustedLender_) { + trustedLender = trustedLender_; + } + + function onFlashLoan( + address, + address token, + uint256 amount, + uint256 fee, + bytes calldata data + ) external override returns (bytes32) { + if (msg.sender != trustedLender) revert UntrustedLender(); + SwapFlashParams memory p = abi.decode(data, (SwapFlashParams)); + if (p.integration == address(0) || p.pool == address(0) || p.midToken == address(0)) revert BadParams(); + if (p.midToken == token) revert BadParams(); + + IERC20 borrowed = IERC20(token); + IERC20 mid = IERC20(p.midToken); + + borrowed.forceApprove(p.integration, amount); + IDODOStyleSwapExactIn(p.integration).swapExactIn(p.pool, token, amount, p.minOutFirst); + + uint256 midBal = mid.balanceOf(address(this)); + mid.forceApprove(p.integration, midBal); + IDODOStyleSwapExactIn(p.integration).swapExactIn(p.pool, p.midToken, midBal, p.minOutSecond); + + uint256 need = amount + fee; + if (borrowed.balanceOf(address(this)) < need) revert InsufficientToRepay(); + borrowed.safeTransfer(msg.sender, need); + + return _RETURN_VALUE; + } +} diff --git a/contracts/flash/UniswapV3ExternalUnwinder.sol b/contracts/flash/UniswapV3ExternalUnwinder.sol new file mode 100644 index 0000000..b0193ad --- /dev/null +++ b/contracts/flash/UniswapV3ExternalUnwinder.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ISwapRouter} from "../bridge/trustless/interfaces/ISwapRouter.sol"; + +/** + * @title UniswapV3ExternalUnwinder + * @notice External unwind adapter for quote-push workflows using the Uniswap V3 SwapRouter. + * @dev `data` is optional: + * - `abi.encode(uint24 fee)` for exactInputSingle + * - `abi.encode(bytes path)` for exactInput multi-hop path + */ +contract UniswapV3ExternalUnwinder { + using SafeERC20 for IERC20; + + address public immutable router; + + error BadParams(); + + constructor(address router_) { + router = router_; + } + + function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata data) + external + returns (uint256 amountOut) + { + if (tokenIn == address(0) || tokenOut == address(0) || tokenIn == tokenOut) revert BadParams(); + if (amountIn == 0) revert BadParams(); + + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + IERC20(tokenIn).forceApprove(router, amountIn); + + if (data.length == 32) { + uint24 fee = abi.decode(data, (uint24)); + amountOut = ISwapRouter(router).exactInputSingle( + ISwapRouter.ExactInputSingleParams({ + tokenIn: tokenIn, + tokenOut: tokenOut, + fee: fee, + recipient: msg.sender, + deadline: block.timestamp + 300, + amountIn: amountIn, + amountOutMinimum: minAmountOut, + sqrtPriceLimitX96: 0 + }) + ); + return amountOut; + } + + bytes memory path = abi.decode(data, (bytes)); + amountOut = ISwapRouter(router).exactInput( + ISwapRouter.ExactInputParams({ + path: path, + recipient: msg.sender, + deadline: block.timestamp + 300, + amountIn: amountIn, + amountOutMinimum: minAmountOut + }) + ); + } +} diff --git a/contracts/flash/UniversalCCIPFlashBridgeAdapter.sol b/contracts/flash/UniversalCCIPFlashBridgeAdapter.sol new file mode 100644 index 0000000..ff13fc4 --- /dev/null +++ b/contracts/flash/UniversalCCIPFlashBridgeAdapter.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {UniversalCCIPBridge} from "../bridge/UniversalCCIPBridge.sol"; +import {ICrossChainFlashBridge} from "./interfaces/ICrossChainFlashBridge.sol"; + +/** + * @title UniversalCCIPFlashBridgeAdapter + * @notice Pulls `token` from the caller (e.g. `CrossChainFlashBorrower`) and forwards a `UniversalCCIPBridge.bridge` call. + * @dev `extraData` (see `ICrossChainFlashBridge`): if empty, uses `assetType = 0`, `usePMM/useVault = false`, empty proofs. + * If non-empty: `abi.encode(bytes32 assetType, bool usePMM, bool useVault, bytes complianceProof, bytes vaultInstructions)`. + * Native value is forwarded for CCIP fees on the underlying bridge. + */ +contract UniversalCCIPFlashBridgeAdapter is ICrossChainFlashBridge { + using SafeERC20 for IERC20; + + UniversalCCIPBridge public immutable universalBridge; + + constructor(address universalBridge_) { + require(universalBridge_ != address(0), "UniversalCCIPFlashBridgeAdapter: zero bridge"); + universalBridge = UniversalCCIPBridge(payable(universalBridge_)); + } + + function bridgeTokensFrom( + address token, + uint256 amount, + uint64 destinationChainSelector, + address recipientOnDestination, + bytes calldata extraData + ) external payable override returns (bytes32 messageId) { + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + IERC20(token).forceApprove(address(universalBridge), amount); + + bytes32 assetType; + bool usePMM; + bool useVault; + bytes memory complianceProof; + bytes memory vaultInstructions; + + if (extraData.length == 0) { + assetType = bytes32(0); + usePMM = false; + useVault = false; + complianceProof = ""; + vaultInstructions = ""; + } else { + (assetType, usePMM, useVault, complianceProof, vaultInstructions) = + abi.decode(extraData, (bytes32, bool, bool, bytes, bytes)); + } + + UniversalCCIPBridge.BridgeOperation memory op = UniversalCCIPBridge.BridgeOperation({ + token: token, + amount: amount, + destinationChain: destinationChainSelector, + recipient: recipientOnDestination, + assetType: assetType, + usePMM: usePMM, + useVault: useVault, + complianceProof: complianceProof, + vaultInstructions: vaultInstructions + }); + + messageId = universalBridge.bridge{value: msg.value}(op); + IERC20(token).forceApprove(address(universalBridge), 0); + } + + receive() external payable {} +} diff --git a/contracts/flash/interfaces/ICrossChainFlashBridge.sol b/contracts/flash/interfaces/ICrossChainFlashBridge.sol new file mode 100644 index 0000000..92dab13 --- /dev/null +++ b/contracts/flash/interfaces/ICrossChainFlashBridge.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title ICrossChainFlashBridge + * @notice Minimal surface for “pull `token` from msg.sender then initiate CCIP / bridge”. + * @dev Wire to `UniversalCCIPFlashBridgeAdapter` (→ `UniversalCCIPBridge.bridge`), a dedicated adapter, or a mock in tests. + * Implementations MUST pull from `msg.sender` (e.g. `transferFrom`) up to `amount` after allowance. + * For `UniversalCCIPFlashBridgeAdapter`, optional `extraData` encodes + * `(bytes32 assetType, bool usePMM, bool useVault, bytes complianceProof, bytes vaultInstructions)`; empty uses zeros/false. + */ +interface ICrossChainFlashBridge { + function bridgeTokensFrom( + address token, + uint256 amount, + uint64 destinationChainSelector, + address recipientOnDestination, + bytes calldata extraData + ) external payable returns (bytes32 messageId); +} diff --git a/contracts/iso4217w/ISO4217WToken.sol b/contracts/iso4217w/ISO4217WToken.sol index 777641c..c790641 100644 --- a/contracts/iso4217w/ISO4217WToken.sol +++ b/contracts/iso4217w/ISO4217WToken.sol @@ -3,9 +3,9 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "../vendor/openzeppelin/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "../vendor/openzeppelin/ReentrancyGuardUpgradeable.sol"; import "./interfaces/IISO4217WToken.sol"; import "./libraries/ISO4217WCompliance.sol"; diff --git a/contracts/liquidity/LiquidityManager.sol b/contracts/liquidity/LiquidityManager.sol index 0536b49..3e0179a 100644 --- a/contracts/liquidity/LiquidityManager.sol +++ b/contracts/liquidity/LiquidityManager.sol @@ -2,11 +2,11 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "../vendor/openzeppelin/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "../vendor/openzeppelin/ReentrancyGuardUpgradeable.sol"; import "./interfaces/ILiquidityProvider.sol"; /** diff --git a/contracts/liquidity/PoolManager.sol b/contracts/liquidity/PoolManager.sol index b3ae078..cb2d943 100644 --- a/contracts/liquidity/PoolManager.sol +++ b/contracts/liquidity/PoolManager.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "../vendor/openzeppelin/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "../registry/UniversalAssetRegistry.sol"; diff --git a/contracts/liquidity/providers/DODOPMMProvider.sol b/contracts/liquidity/providers/DODOPMMProvider.sol index 1a10561..5264a3c 100644 --- a/contracts/liquidity/providers/DODOPMMProvider.sol +++ b/contracts/liquidity/providers/DODOPMMProvider.sol @@ -44,15 +44,62 @@ contract DODOPMMProvider is ILiquidityProvider, AccessControl { uint256 amountIn ) external view override returns (uint256 amountOut, uint256 slippageBps) { address pool = pools[tokenIn][tokenOut]; - + if (pool == address(0)) { return (0, 10000); // No pool, 100% slippage } - - try dodoIntegration.getPoolPriceOrOracle(pool) returns (uint256 price) { - // Simple calculation (in production, would use actual DODO math) - amountOut = (amountIn * price) / 1e18; - slippageBps = 30; // 0.3% typical for stablecoin pools + + try dodoIntegration.getPoolConfig(pool) returns (DODOPMMIntegration.PoolConfig memory config) { + if (tokenIn == config.baseToken && tokenOut == config.quoteToken) { + try IDODOPMMPool(pool).querySellBase(address(this), amountIn) returns (uint256 quoteAmount, uint256) { + return (quoteAmount, 30); + } catch { + return _fallbackReserveQuote(pool, config, true, amountIn); + } + } + + if (tokenIn == config.quoteToken && tokenOut == config.baseToken) { + try IDODOPMMPool(pool).querySellQuote(address(this), amountIn) returns (uint256 baseAmount, uint256) { + return (baseAmount, 30); + } catch { + return _fallbackReserveQuote(pool, config, false, amountIn); + } + } + + return (0, 10000); + } catch { + return (0, 10000); + } + } + + function _fallbackReserveQuote( + address pool, + DODOPMMIntegration.PoolConfig memory config, + bool sellBase, + uint256 amountIn + ) internal view returns (uint256 amountOut, uint256 slippageBps) { + try IDODOPMMPool(pool).getVaultReserve() returns (uint256 baseReserve, uint256 quoteReserve) { + if (baseReserve == 0 || quoteReserve == 0 || amountIn == 0) { + return (0, 10000); + } + + uint256 netAmountIn = amountIn; + if (config.lpFeeRate < 10_000) { + netAmountIn = (amountIn * (10_000 - config.lpFeeRate)) / 10_000; + } + + if (netAmountIn == 0) { + return (0, 10000); + } + + if (sellBase) { + amountOut = (netAmountIn * quoteReserve) / (baseReserve + netAmountIn); + } else { + amountOut = (netAmountIn * baseReserve) / (quoteReserve + netAmountIn); + } + + // Treat fallback quotes as conservative / read-only approximations. + slippageBps = 100; return (amountOut, slippageBps); } catch { return (0, 10000); diff --git a/contracts/plugins/PluginRegistry.sol b/contracts/plugins/PluginRegistry.sol index 4c09fa5..365eca5 100644 --- a/contracts/plugins/PluginRegistry.sol +++ b/contracts/plugins/PluginRegistry.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "../vendor/openzeppelin/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; /** diff --git a/contracts/registry/ChainRegistry.sol b/contracts/registry/ChainRegistry.sol index b543dac..f5816bd 100644 --- a/contracts/registry/ChainRegistry.sol +++ b/contracts/registry/ChainRegistry.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "../vendor/openzeppelin/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; /** diff --git a/contracts/relay/CCIPRelayRouter.sol b/contracts/relay/CCIPRelayRouter.sol index 9b1ce8b..9a32925 100644 --- a/contracts/relay/CCIPRelayRouter.sol +++ b/contracts/relay/CCIPRelayRouter.sol @@ -25,7 +25,7 @@ contract CCIPRelayRouter is AccessControl { event BridgeAuthorized(address indexed bridge); event BridgeRevoked(address indexed bridge); - + constructor() { _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); } @@ -74,17 +74,14 @@ contract CCIPRelayRouter is AccessControl { // Call bridge's ccipReceive function using low-level call // This ensures proper ABI encoding for the struct parameter // The call will revert with the actual error if it fails - (bool success, bytes memory returnData) = bridge.call( + (bool success, ) = bridge.call( abi.encodeWithSignature("ccipReceive((bytes32,uint64,bytes,bytes,(address,uint256,uint8)[]))", message) ); require(success, "CCIPRelayRouter: ccipReceive failed"); - // If we get here, the call succeeded - // Decode recipient and amount from message data - (address recipient, uint256 amount, , ) = abi.decode( - message.data, - (address, uint256, address, uint256) - ); + // If we get here, the call succeeded. Decode common payload shapes without + // reverting the full relay transaction on non-WETH bridge payloads. + (address recipient, uint256 amount) = _decodeRecipientAndAmount(message.data); emit MessageRelayed( message.messageId, @@ -94,5 +91,26 @@ contract CCIPRelayRouter is AccessControl { amount ); } -} + function _decodeRecipientAndAmount(bytes calldata data) + internal + pure + returns (address recipient, uint256 amount) + { + if (data.length == 64) { + return abi.decode(data, (address, uint256)); + } + + if (data.length == 96) { + (, recipient, amount) = abi.decode(data, (address, address, uint256)); + return (recipient, amount); + } + + if (data.length == 128) { + (recipient, amount, , ) = abi.decode(data, (address, uint256, address, uint256)); + return (recipient, amount); + } + + return (address(0), 0); + } +} diff --git a/contracts/reserve/StablecoinReserveVault.sol b/contracts/reserve/StablecoinReserveVault.sol index 5cc3f1e..e99c49d 100644 --- a/contracts/reserve/StablecoinReserveVault.sol +++ b/contracts/reserve/StablecoinReserveVault.sol @@ -5,8 +5,16 @@ import "@openzeppelin/contracts/access/AccessControl.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; -import "../tokens/CompliantUSDT.sol"; -import "../tokens/CompliantUSDC.sol"; + +interface ICompliantTokenOwnerControl { + function owner() external view returns (address); + function pause() external; + function unpause() external; + function transferOwnership(address newOwner) external; + function mint(address to, uint256 amount) external; + function burn(uint256 amount) external; + function totalSupply() external view returns (uint256); +} /** * @title StablecoinReserveVault @@ -29,8 +37,8 @@ contract StablecoinReserveVault is AccessControl, ReentrancyGuard { address public immutable officialUSDC; // Compliant token contracts (on Chain 138 or same network) - CompliantUSDT public immutable compliantUSDT; - CompliantUSDC public immutable compliantUSDC; + ICompliantTokenOwnerControl public immutable compliantUSDT; + ICompliantTokenOwnerControl public immutable compliantUSDC; // Reserve tracking uint256 public usdtReserveBalance; @@ -47,6 +55,9 @@ contract StablecoinReserveVault is AccessControl, ReentrancyGuard { event ReserveWithdrawn(address indexed token, uint256 amount, address indexed recipient); event CompliantTokensMinted(address indexed token, uint256 amount, address indexed recipient); event CompliantTokensBurned(address indexed token, uint256 amount, address indexed redeemer); + event CompliantTokenPaused(address indexed token, address indexed operator); + event CompliantTokenUnpaused(address indexed token, address indexed operator); + event CompliantTokenOwnershipTransferred(address indexed token, address indexed newOwner); event Paused(address indexed account); event Unpaused(address indexed account); @@ -82,8 +93,8 @@ contract StablecoinReserveVault is AccessControl, ReentrancyGuard { officialUSDT = officialUSDT_; officialUSDC = officialUSDC_; - compliantUSDT = CompliantUSDT(compliantUSDT_); - compliantUSDC = CompliantUSDC(compliantUSDC_); + compliantUSDT = ICompliantTokenOwnerControl(compliantUSDT_); + compliantUSDC = ICompliantTokenOwnerControl(compliantUSDC_); } /** @@ -108,6 +119,19 @@ contract StablecoinReserveVault is AccessControl, ReentrancyGuard { emit CompliantTokensMinted(address(compliantUSDT), amount, msg.sender); } + /** + * @notice Seed official USDT reserves without minting new cUSDT + * @dev Used to retrofit backing for pre-existing canonical supply + */ + function seedUSDTReserve(uint256 amount) external onlyRole(RESERVE_OPERATOR_ROLE) nonReentrant { + require(amount > 0, "StablecoinReserveVault: zero amount"); + + IERC20(officialUSDT).safeTransferFrom(msg.sender, address(this), amount); + usdtReserveBalance += amount; + + emit ReserveDeposited(officialUSDT, amount, msg.sender); + } + /** * @notice Deposit official USDC and mint cUSDC 1:1 * @dev Transfers USDC from caller, mints cUSDC to caller @@ -130,6 +154,19 @@ contract StablecoinReserveVault is AccessControl, ReentrancyGuard { emit CompliantTokensMinted(address(compliantUSDC), amount, msg.sender); } + /** + * @notice Seed official USDC reserves without minting new cUSDC + * @dev Used to retrofit backing for pre-existing canonical supply + */ + function seedUSDCReserve(uint256 amount) external onlyRole(RESERVE_OPERATOR_ROLE) nonReentrant { + require(amount > 0, "StablecoinReserveVault: zero amount"); + + IERC20(officialUSDC).safeTransferFrom(msg.sender, address(this), amount); + usdcReserveBalance += amount; + + emit ReserveDeposited(officialUSDC, amount, msg.sender); + } + /** * @notice Redeem cUSDT for official USDT 1:1 * @dev Burns cUSDT from caller, transfers USDT to caller @@ -139,7 +176,8 @@ contract StablecoinReserveVault is AccessControl, ReentrancyGuard { require(amount > 0, "StablecoinReserveVault: zero amount"); require(usdtReserveBalance >= amount, "StablecoinReserveVault: insufficient reserve"); - // Burn cUSDT from caller + // Pull cUSDT from the redeemer, then burn from the vault balance as token owner. + IERC20(address(compliantUSDT)).safeTransferFrom(msg.sender, address(this), amount); compliantUSDT.burn(amount); // Update reserve @@ -162,7 +200,8 @@ contract StablecoinReserveVault is AccessControl, ReentrancyGuard { require(amount > 0, "StablecoinReserveVault: zero amount"); require(usdcReserveBalance >= amount, "StablecoinReserveVault: insufficient reserve"); - // Burn cUSDC from caller + // Pull cUSDC from the redeemer, then burn from the vault balance as token owner. + IERC20(address(compliantUSDC)).safeTransferFrom(msg.sender, address(this), amount); compliantUSDC.burn(amount); // Update reserve @@ -232,6 +271,37 @@ contract StablecoinReserveVault is AccessControl, ReentrancyGuard { emit Unpaused(msg.sender); } + /** + * @notice Pause one of the compliant tokens currently owned by the vault + */ + function pauseCompliantToken(address token) external onlyRole(DEFAULT_ADMIN_ROLE) { + _requireSupportedCompliantToken(token); + ICompliantTokenOwnerControl(token).pause(); + emit CompliantTokenPaused(token, msg.sender); + } + + /** + * @notice Unpause one of the compliant tokens currently owned by the vault + */ + function unpauseCompliantToken(address token) external onlyRole(DEFAULT_ADMIN_ROLE) { + _requireSupportedCompliantToken(token); + ICompliantTokenOwnerControl(token).unpause(); + emit CompliantTokenUnpaused(token, msg.sender); + } + + /** + * @notice Move compliant token ownership away from the vault if governance needs to recover control + */ + function transferCompliantTokenOwnership( + address token, + address newOwner + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(newOwner != address(0), "StablecoinReserveVault: zero new owner"); + _requireSupportedCompliantToken(token); + ICompliantTokenOwnerControl(token).transferOwnership(newOwner); + emit CompliantTokenOwnershipTransferred(token, newOwner); + } + /** * @notice Emergency withdrawal (admin only, after pause) * @dev Can be used to recover funds in emergency situations @@ -249,5 +319,11 @@ contract StablecoinReserveVault is AccessControl, ReentrancyGuard { require(paused, "StablecoinReserveVault: not paused"); _; } -} + function _requireSupportedCompliantToken(address token) internal view { + require( + token == address(compliantUSDT) || token == address(compliantUSDC), + "StablecoinReserveVault: unsupported compliant token" + ); + } +} diff --git a/contracts/sync/TokenlistGovernanceSync.sol b/contracts/sync/TokenlistGovernanceSync.sol index fe64611..3988b88 100644 --- a/contracts/sync/TokenlistGovernanceSync.sol +++ b/contracts/sync/TokenlistGovernanceSync.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "../vendor/openzeppelin/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "../registry/UniversalAssetRegistry.sol"; diff --git a/contracts/tokens/CompliantBTC.sol b/contracts/tokens/CompliantBTC.sol new file mode 100644 index 0000000..1d1c379 --- /dev/null +++ b/contracts/tokens/CompliantBTC.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "./CompliantMonetaryUnitToken.sol"; + +/** + * @title CompliantBTC + * @notice Canonical Chain 138 Bitcoin monetary-unit token. + */ +contract CompliantBTC is CompliantMonetaryUnitToken { + uint8 public constant SATOSHI_DECIMALS = 8; + + constructor( + address initialOwner, + address admin, + uint256 initialSupply + ) + CompliantMonetaryUnitToken( + "Bitcoin (Compliant)", + "cBTC", + SATOSHI_DECIMALS, + "BTC", + initialOwner, + admin, + initialSupply + ) + {} +} diff --git a/contracts/tokens/CompliantMonetaryUnitToken.sol b/contracts/tokens/CompliantMonetaryUnitToken.sol new file mode 100644 index 0000000..ed7c937 --- /dev/null +++ b/contracts/tokens/CompliantMonetaryUnitToken.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/utils/Pausable.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "../compliance/LegallyCompliantBase.sol"; + +/** + * @title CompliantMonetaryUnitToken + * @notice Generic GRU monetary-unit token for non-ISO units such as BTC. + * @dev Mirrors the compliant fiat token controls while keeping monetary-unit metadata separate + * from ISO-4217 and commodity classifications. + */ +contract CompliantMonetaryUnitToken is ERC20, Pausable, Ownable, LegallyCompliantBase { + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + uint8 private immutable _decimalsStorage; + string private _unitCode; + + constructor( + string memory name_, + string memory symbol_, + uint8 decimals_, + string memory unitCode_, + address initialOwner, + address admin, + uint256 initialSupply + ) + ERC20(name_, symbol_) + Ownable(initialOwner) + LegallyCompliantBase(admin) + { + _decimalsStorage = decimals_; + _unitCode = unitCode_; + _grantRole(MINTER_ROLE, initialOwner); + if (initialSupply > 0) { + _mint(msg.sender, initialSupply); + } + } + + function decimals() public view override returns (uint8) { + return _decimalsStorage; + } + + function unitCode() external view returns (string memory) { + return _unitCode; + } + + function isMonetaryUnit() external pure returns (bool) { + return true; + } + + function _update( + address from, + address to, + uint256 amount + ) internal override whenNotPaused { + super._update(from, to, amount); + if (from != address(0) && to != address(0)) { + bytes32 legalRefHash = _generateLegalReferenceHash( + from, + to, + amount, + abi.encodePacked(symbol(), " Transfer") + ); + emit ValueTransferDeclared(from, to, amount, legalRefHash); + } + } + + function pause() public onlyOwner { + _pause(); + } + + function unpause() public onlyOwner { + _unpause(); + } + + function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) { + _mint(to, amount); + } + + function burn(uint256 amount) public { + _burn(msg.sender, amount); + } +} diff --git a/contracts/tokens/CompliantUSDCTokenV2.sol b/contracts/tokens/CompliantUSDCTokenV2.sol new file mode 100644 index 0000000..6c31ff6 --- /dev/null +++ b/contracts/tokens/CompliantUSDCTokenV2.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "./CompliantFiatTokenV2.sol"; + +/** + * @title CompliantUSDCTokenV2 + * @notice Thin cUSDC V2 specialization over the shared compliant fiat token base. + */ +contract CompliantUSDCTokenV2 is CompliantFiatTokenV2 { + constructor( + address initialOperator, + address admin, + uint256 initialSupply, + bool forwardCanonical_ + ) + CompliantFiatTokenV2( + "USD Coin (Compliant V2)", + "cUSDC", + 6, + "USD", + "2", + initialOperator, + admin, + initialSupply, + forwardCanonical_ + ) + {} +} diff --git a/contracts/tokens/CompliantUSDTTokenV2.sol b/contracts/tokens/CompliantUSDTTokenV2.sol new file mode 100644 index 0000000..a927467 --- /dev/null +++ b/contracts/tokens/CompliantUSDTTokenV2.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "./CompliantFiatTokenV2.sol"; + +/** + * @title CompliantUSDTTokenV2 + * @notice Thin cUSDT V2 specialization over the shared compliant fiat token base. + */ +contract CompliantUSDTTokenV2 is CompliantFiatTokenV2 { + constructor( + address initialOperator, + address admin, + uint256 initialSupply, + bool forwardCanonical_ + ) + CompliantFiatTokenV2( + "Tether USD (Compliant V2)", + "cUSDT", + 6, + "USD", + "2", + initialOperator, + admin, + initialSupply, + forwardCanonical_ + ) + {} +} diff --git a/contracts/vault/tokens/DebtToken.sol b/contracts/vault/tokens/DebtToken.sol index 2d70779..40512f7 100644 --- a/contracts/vault/tokens/DebtToken.sol +++ b/contracts/vault/tokens/DebtToken.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "../../vendor/openzeppelin/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "../../emoney/interfaces/IeMoneyToken.sol"; diff --git a/contracts/vault/tokens/DepositToken.sol b/contracts/vault/tokens/DepositToken.sol index 064416d..c9fb212 100644 --- a/contracts/vault/tokens/DepositToken.sol +++ b/contracts/vault/tokens/DepositToken.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "../../vendor/openzeppelin/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "../../emoney/interfaces/IeMoneyToken.sol"; diff --git a/docs/ADDRESS_MAPPING.md b/docs/ADDRESS_MAPPING.md index c6aa97e..e1b4690 100644 --- a/docs/ADDRESS_MAPPING.md +++ b/docs/ADDRESS_MAPPING.md @@ -112,6 +112,8 @@ After deployment, add the AddressMapper address to `.env`: ADDRESS_MAPPER=0x... ``` +On **Chain 138**, the canonical mapper is documented in the parent repo at `docs/11-references/ADDRESS_MATRIX_AND_STATUS.md` (`0x439Fcb2d2ab2f890DCcAE50461Fa7d978F9Ffe1A`); inventory also lists `ADDRESS_MAPPER_LEGACY_DUPLICATE` in `config/address-inventory.chain138.json`. Prefer the canonical address in `.env`. + ## Best Practices 1. **Always use deployed addresses** for contract interactions diff --git a/docs/deployment/ALL_MAINNETS_DEPLOYMENT_RUNBOOK.md b/docs/deployment/ALL_MAINNETS_DEPLOYMENT_RUNBOOK.md index 6ca4841..50e2a33 100644 --- a/docs/deployment/ALL_MAINNETS_DEPLOYMENT_RUNBOOK.md +++ b/docs/deployment/ALL_MAINNETS_DEPLOYMENT_RUNBOOK.md @@ -156,7 +156,7 @@ forge script script/dex/DeployPrivatePoolRegistryAndPools.s.sol:DeployPrivatePoo ### PMM (Chain 138) completion checklist -1. Set **DODO_VENDING_MACHINE_ADDRESS** in `.env` to the DODO DVM factory address on Chain 138. **Chain 138 is not in DODO’s official list** — see [DVM_DEPLOYMENT_CHECK.md](DVM_DEPLOYMENT_CHECK.md). Deploy the factory yourself from [DODOEX](https://github.com/DODOEX) or use an existing deployment if available. +1. Set **DODO_VENDING_MACHINE_ADDRESS** in `.env` to the DODO DVM factory adapter address on Chain 138. **Chain 138 is not in DODO’s official list** — see [DVM_DEPLOYMENT_CHECK.md](DVM_DEPLOYMENT_CHECK.md). Use `scripts/deployment/deploy-official-dvm-chain138.sh` to deploy the official DVM `0.6.9` stack from the vendored DODO tree and then deploy the local adapter. 2. Set **OFFICIAL_USDT_ADDRESS** and **OFFICIAL_USDC_ADDRESS** to the live Chain 138 quote-side mirror stables: - `OFFICIAL_USDT_ADDRESS=0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1` - `OFFICIAL_USDC_ADDRESS=0x71D6687F38b93CCad569Fa6352c876eea967201b` @@ -169,7 +169,7 @@ forge script script/dex/DeployPrivatePoolRegistryAndPools.s.sol:DeployPrivatePoo | Chain ID | Network | Mapper (AddressMapperEmpty) | |----------|-----------|----------------------------------| -| 138 | Chain 138 | 0xe48E3f248698610e18Db865457fcd935Bb3da856 (AddressMapper) | +| 138 | Chain 138 | 0x439Fcb2d2ab2f890DCcAE50461Fa7d978F9Ffe1A (AddressMapper; legacy dup `0xe48E3f248698610e18Db865457fcd935Bb3da856`) | | 1 | Ethereum | 0x0ea68F5B5A8427bB58e54ECcee941F543Dc538c5 | | 56 | BSC | 0x6e94e53F73893b2a6784Df663920D31043A6dE07 | | 137 | Polygon | 0xb689c1C69DAa08DEb5D8feA2aBF0F64bFD409727 | diff --git a/docs/deployment/DEPLOYED_CONTRACTS_OVERVIEW.md b/docs/deployment/DEPLOYED_CONTRACTS_OVERVIEW.md index bc5d3d7..979438b 100644 --- a/docs/deployment/DEPLOYED_CONTRACTS_OVERVIEW.md +++ b/docs/deployment/DEPLOYED_CONTRACTS_OVERVIEW.md @@ -1,6 +1,6 @@ # Deployed Smart Contracts — Cross-Network Overview -> Historical note (2026-03-26): this overview includes superseded PMM stack references from earlier deployment phases. The current canonical Chain 138 PMM stack is `DODOPMMIntegration=0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d` and `DODOPMMProvider=0x5CAe6Ce155b7f08D3a956F5Dc82fC9945f29B381`. +> Historical note (2026-04-01): this overview preserves earlier deployment phases, but the live canonical Chain 138 PMM stable stack is now `DODOPMMIntegration=0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895` and `DODOPMMProvider=0x3f729632E9553EBacCdE2e9b4c8F2B285b014F2e`. **Last updated:** 2026-02-20 **Source:** `smom-dbis-138/.env` and deployment runbooks. @@ -64,8 +64,9 @@ flowchart TB | | CCIPWETH10 Bridge | `0xe0E93247376aa097dB308B92e6Ba36bA015535D0` | WETH10 cross-chain | | | CCIP Router | `0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817` | CCIP router (on 138) | | | CCIPTxReporter | `0x3F88b662F04d9B1413BA8d65bFC229e830D7d077` | Report txs to mainnet logger | -| **PMM** | Mock DVM Factory | `0xB16c3D48A111714B1795E58341FeFDd643Ab01ab` | Create mock pools | -| | DODOPMMIntegration | `0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d` | Canonical corrected PMM integration | +| **PMM** | DODO V2 DVMFactory | `0xc93870594C7f83A0aE076c2e30b494Efc526b68E` | Create canonical DVM-backed stable pools | +| | DODOPMMIntegration | `0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895` | Canonical official-DVM-backed PMM integration | +| | DODOPMMProvider | `0x3f729632E9553EBacCdE2e9b4c8F2B285b014F2e` | Canonical stable-pool routing provider | | **Core eMoney** | Compliance Registry, Token Factory, Bridge Vault, Debt Registry, Policy Manager, Token Impl | (see .env) | Core system | | **Channels** | Payment Channel Manager, Address Mapper, Mirror Manager | (see .env) | State channels | | **Oracle** | Oracle Aggregator, Oracle Proxy | (see .env) | Price feeds | diff --git a/docs/deployment/DVM_DEPLOYMENT_CHECK.md b/docs/deployment/DVM_DEPLOYMENT_CHECK.md index 7fd2a40..7a5e81c 100644 --- a/docs/deployment/DVM_DEPLOYMENT_CHECK.md +++ b/docs/deployment/DVM_DEPLOYMENT_CHECK.md @@ -1,30 +1,41 @@ # DVM (DODO Vending Machine) Deployment Check — Chain 138 -**Date:** 2026-02-20 +**Date:** 2026-04-02 ## Summary -**There is no official DODO DVM factory deployment on Chain 138.** +**Chain 138 now has a self-deployed official DODO V2 DVM stack, but it is not DODO-listed.** -- DODO’s official contract list ([api.dodoex.io/dodo-contract/list?version=v1,v2](https://api.dodoex.io/dodo-contract/list?version=v1,v2)) includes chains such as **1** (Ethereum), **5** (Goerli), **10** (Optimism), **56** (BSC), **133** (Syscoin), **137** (Polygon), **8453** (Base), etc. -- **Chain ID 138 is not in that list**, so there is no canonical DVM factory address for Chain 138 from DODO. +- DODO’s official contract list ([api.dodoex.io/dodo-contract/list?version=v1,v2](https://api.dodoex.io/dodo-contract/list?version=v1,v2)) includes chains such as **1** (Ethereum), **5** (Goerli), **10** (Optimism), **56** (BSC), **133** (Syscoin), **137** (Polygon), **8453** (Base), etc. +- **Chain ID 138 is still not in that list**, so there is no DODO-published canonical DVM factory address for Chain 138. +- This repo now operates a **self-deployed official DODO V2 DVM stack** on Chain 138: + - **DVMFactory:** `0xc93870594C7f83A0aE076c2e30b494Efc526b68E` + - **DVMFactoryAdapter / DODO_VENDING_MACHINE_ADDRESS:** `0xb6D9EF3575bc48De3f011C310DC24d87bEC6087C` + - **DODOPMMIntegration:** `0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895` + - **DODOPMMProvider:** `0x3f729632E9553EBacCdE2e9b4c8F2B285b014F2e` + - **Stable pools (canonical corrected stack):** + - `cUSDT/cUSDC`: `0x9e89bAe009adf128782E19e8341996c596ac40dC` + - `cUSDT/USDT`: `0x866Cb44b59303d8dc5f4F9E3E7A8e8b0bf238d66` + - `cUSDC/USDC`: `0xc39B7D0F40838cbFb54649d327f49a6DAC964062` ## Implications -- **`DODO_VENDING_MACHINE_ADDRESS`** in `.env` must point to a DVM factory **you deploy or obtain** on Chain 138; it will not be an “official” DODO-listed address. -- Until that address is set, **PMM (run-pmm-and-pools.sh)** and **DODOPMMIntegration** cannot be used on Chain 138. +- **`DODO_VENDING_MACHINE_ADDRESS`** in `.env` must point to a Chain 138 factory adapter **you deploy or obtain**; it is not a DODO-listed public address. +- The canonical Chain 138 PMM path is now the **official DODO V2 DVMFactoryAdapter** above, not the earlier mock DVM path. ## Options -0. **Use mock DVM (this repo)** +0. **Use mock DVM (this repo, fallback only)** - A minimal `MockDVMFactory` (and `MockDVMPool`) is provided so PMM can run on Chain 138 without official DODO. Deploy with: - `forge script script/dex/DeployMockDVMFactory.s.sol:DeployMockDVMFactory --rpc-url "$RPC_URL_138" --broadcast --private-key "$PRIVATE_KEY" --legacy` - Set `DODO_VENDING_MACHINE_ADDRESS` in `.env` to the deployed address, then run `scripts/deployment/run-pmm-and-pools.sh`. Pools created are mocks (no real AMM); swap views return stubs. + - **Do not treat this as canonical anymore** unless you are intentionally testing without the official DVM stack. 1. **Deploy official DODO DVM on Chain 138 (this repo)** - Submodule **lib/dodo-contractV2** (DODOEX/contractV2) is used to deploy the official DVM stack via Truffle. Our **DVMFactoryAdapter** wraps `createDODOVendingMachine` as `createDVM` so `DODOPMMIntegration` works unchanged. - - **Blocker:** DODO contractV2 mixes Solidity 0.6.9 (DVM, Factory) and 0.8.16 (DODOGasSavingPool, SmartRoute). Truffle compiles the whole repo with one solc version, so `truffle compile` fails. Until DODO adds multi-compiler support or we vendor only the DVM 0.6.9 tree, use the **mock DVM** (option 0) or deploy from a DODO fork that compiles. - - **One-shot (when Truffle compiles):** `scripts/deployment/deploy-official-dvm-chain138.sh` + - The repo deployment helper now runs the vendored DODO tree in a **DVM-only compile mode** by temporarily hiding unrelated Solidity `0.8.x` contracts during `truffle compile` / `truffle migrate`, then restoring them on exit. That allows the official DVM `0.6.9` stack to compile without forking the upstream repo. + - **One-shot:** `scripts/deployment/deploy-official-dvm-chain138.sh` + - To verify compile before broadcast: `scripts/deployment/deploy-official-dvm-chain138.sh --compile-only` - Requires `lib/dodo-contractV2` deps: `cd lib/dodo-contractV2 && npm install` (if npm hits a registry/cert error, use `npm install --registry https://registry.npmjs.org/ --no-package-lock`). - **Manual:** 1) In `lib/dodo-contractV2`: set `privKey` and `RPC_URL_138` (e.g. from parent `.env`), then `npx truffle migrate -f 1 --to 1 --network chain138` and `npx truffle migrate -f 9 --to 9 --network chain138`. @@ -32,6 +43,8 @@ `forge script script/dex/DeployDVMFactoryAdapter.s.sol:DeployDVMFactoryAdapter --rpc-url "$RPC_URL_138" --broadcast --private-key "$PRIVATE_KEY" --legacy` 3) Set `DODO_VENDING_MACHINE_ADDRESS` in `.env` to the **DVMFactoryAdapter** address (not the DVMFactory address). - Then run `scripts/deployment/run-pmm-and-pools.sh` (you may need to re-deploy DODOPMMIntegration if it was previously deployed with the mock DVM). + - **Current live Chain 138 result:** the repo has already deployed this path and currently uses the addresses listed in the Summary section above. + - **Stable-pair calibration note:** for equal-reserve stable pairs on the official DODO V2 DVM stack, the current canonical Chain 138 pools use `i = 1e18`, `k = 0`, `lpFeeRate = 10 bps`, `isOpenTWAP = false`. Earlier `k = 0.5e18` pools were removed because they priced above parity. 2. **Deploy DVM factory yourself from DODO source (outside this repo)** - DODO’s contracts are open source: [DODOEX GitHub](https://github.com/DODOEX) (contractV2). diff --git a/docs/deployment/MULTI_CHAIN_DEPLOYMENT_GUIDE.md b/docs/deployment/MULTI_CHAIN_DEPLOYMENT_GUIDE.md index 6827a6d..75ff75b 100644 --- a/docs/deployment/MULTI_CHAIN_DEPLOYMENT_GUIDE.md +++ b/docs/deployment/MULTI_CHAIN_DEPLOYMENT_GUIDE.md @@ -197,17 +197,17 @@ chainRegistry.registerEVMChain( --- -### **Hyperledger Firefly** +### **Hyperledger FireFly** **Status**: ✅ Adapter + Service Client Created -**Infrastructure**: ✅ VMIDs 6202, 6203 deployed +**Infrastructure**: `6200` is the only validated FireFly runtime, `6201` is retired / standby, and `6202` / `6203` are not deployed in the current cluster. **Deployment Steps**: -1. Initialize Firefly on VMID 6202: +1. Initialize FireFly only after confirming `6200` is healthy and intentionally in scope: ```bash -ssh root@192.168.11.175 +ssh root@192.168.11.35 ff init alltra-bridge --multiparty ``` @@ -237,21 +237,26 @@ namespaces: - erc20_erc721 ``` +For current runtime truth, prefer [docs/03-deployment/DBIS_HYPERLEDGER_RUNTIME_STATUS.md](../../../docs/03-deployment/DBIS_HYPERLEDGER_RUNTIME_STATUS.md). + --- ## 🔧 **Hyperledger Integration** -### **Cacti (VMID 5201)** +### **Cacti (VMIDs 5200 / 5201 / 5202)** **Status**: ⚠️ Needs Adapter +**Infrastructure**: `5200` is the primary Cacti / Besu connector node. `5201` and `5202` are the live Alltra/HYBX public Cacti surfaces and each also exposes a local Hyperledger Cacti API on `:4000`. + **Deployment Steps**: -1. Install Cacti API server on VMID 5201 -2. Configure Besu connector plugin -3. Configure Fabric connector plugin (if needed) -4. Deploy CactiAdapter contract -5. Create Cacti client service +1. Maintain the core connector deployment on VMID `5200`. +2. Keep VMIDs `5201` and `5202` healthy for the public Alltra/HYBX Cacti surfaces and local `:4000` APIs. +3. Configure the Besu connector plugin. +4. Configure the Fabric connector plugin if needed. +5. Deploy the `CactiAdapter` contract. +6. Create the Cacti client service. **Cacti Configuration**: ```json diff --git a/docs/deployment/NEXT_STEPS_COMPLETION.md b/docs/deployment/NEXT_STEPS_COMPLETION.md index 44955b6..f14b2f0 100644 --- a/docs/deployment/NEXT_STEPS_COMPLETION.md +++ b/docs/deployment/NEXT_STEPS_COMPLETION.md @@ -1,6 +1,6 @@ # Next Steps Completion Summary -> Historical note (2026-03-26): this completion summary preserves an earlier PMM deployment phase. The current canonical Chain 138 PMM stack is `DODOPMMIntegration=0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d` and `DODOPMMProvider=0x5CAe6Ce155b7f08D3a956F5Dc82fC9945f29B381`. +> Historical note (2026-04-01): this completion summary preserves an earlier PMM deployment phase. The live canonical Chain 138 PMM stable stack is now `DODOPMMIntegration=0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895` and `DODOPMMProvider=0x3f729632E9553EBacCdE2e9b4c8F2B285b014F2e`. **Date:** 2026-02-20 @@ -21,7 +21,7 @@ This document summarizes what was completed in the "next steps" pass and what re | **Verify script** | Optional checks (CCIPTxReporter, genesis) are warnings; log_* fallbacks; safe unset-var handling. | | **Hardhat / Forge** | @emoney/interfaces replaced with relative imports; Hardhat viaIR for 0.8.22; Forge build available. | | **Docs** | WARNINGS_AND_OPTIONAL_TASKS.md, DVM_DEPLOYMENT_CHECK.md, ALL_MAINNETS_DEPLOYMENT_RUNBOOK (PMM step → DVM doc), TODO_TASK_LIST_MASTER §10b updated. | -| **PMM (Chain 138)** | Earlier Mock DVM phase completed; current canonical PMM integration is `0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d` and supersedes the initial stack. `DODO_VENDING_MACHINE_ADDRESS` and `DODO_PMM_INTEGRATION` were set in .env during the first deployment phase. | +| **PMM (Chain 138)** | Earlier mock-DVM phase completed; the live canonical stable stack is now `0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895` with provider `0x3f729632E9553EBacCdE2e9b4c8F2B285b014F2e`. `DODO_VENDING_MACHINE_ADDRESS` and `DODO_PMM_INTEGRATION` should follow the current DVM-backed deployment in `.env`. | --- diff --git a/docs/integration/DODO_PMM_INTEGRATION.md b/docs/integration/DODO_PMM_INTEGRATION.md index 5c2cbe3..d0b1794 100644 --- a/docs/integration/DODO_PMM_INTEGRATION.md +++ b/docs/integration/DODO_PMM_INTEGRATION.md @@ -47,6 +47,17 @@ The Master Plan specifies four **public** liquidity pool types for user routing For cUSDT/XAU, cUSDC/XAU, and cEURT/XAU: use the deployed Chain 138 XAU anchor token (`cXAUC` at `0x290E52a8819A4fbD0714E517225429aA2B70EC6b` or `cXAUT` at `0x94e408E26c6FD8F4ee00b54dF19082FDA07dC96E`), then call `createPool(baseToken, quoteToken, lpFeeRate, initialPrice, k, isOpenTWAP)` with `POOL_MANAGER_ROLE`. **Unit:** 1 full XAU token = **1 troy ounce** of gold (`10^6` base units with 6 decimals). Public pools serve user routing, price discovery, and flash loan access; they do not serve as the primary stabilization engine (see Master Plan private mesh). +### Planned `cUSDW / cUSDC_V2` hub pool + +For the repo-native D-WIN stabilization path on Chain 138, the generic `createPool` flow is also the correct path for `cUSDW / cUSDC_V2`. + +- `baseToken`: `cUSDW` (`0xcA6BFa614935f1AB71c9aB106bAA6FBB6057095e`) +- `quoteToken`: `cUSDC_V2` (`0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d`) +- decimals: both `6` +- script support: `script/dex/CreateCUSDWCUSDCV2Pool.s.sol` and `script/dex/AddLiquidityCUSDWCUSDCV2PoolChain138.s.sol` + +See [docs/03-deployment/CUSDW_CUSDC_V2_HUB_POOL_RUNBOOK.md](../../../docs/03-deployment/CUSDW_CUSDC_V2_HUB_POOL_RUNBOOK.md) for the full operator sequence. + --- ## DODO PMM Overview diff --git a/docs/integration/ORACLE_AND_KEEPER_CHAIN138.md b/docs/integration/ORACLE_AND_KEEPER_CHAIN138.md index 3f710c4..ad1fbe4 100644 --- a/docs/integration/ORACLE_AND_KEEPER_CHAIN138.md +++ b/docs/integration/ORACLE_AND_KEEPER_CHAIN138.md @@ -57,11 +57,17 @@ References: [DEPLOYMENT_COMPLETE_GUIDE.md](../deployment/DEPLOYMENT_COMPLETE_GUI | `KEEPER_PRIVATE_KEY` | Wallet with **KEEPER_ROLE** on `PriceFeedKeeper` for `performUpkeep` txs. | | `MESH_WETH_WRAP_WEI` | Optional tiny `WETH.deposit{value}` on a throttled cadence (`MESH_WETH_WRAP_EVERY_N`); costs gas—default **0** (off). | +**Dedicated keeper signer:** generate and store a separate keeper key with +`scripts/deployment/generate-chain138-keeper-key.sh`. +By default it writes the secret to `~/.secure-secrets/chain138-keeper.env` and a local helper +to `.env.keeper.local` (gitignored) that sources the secret file. + **systemd:** `config/systemd/chain138-pmm-mesh-automation.service.example` in the Proxmox repo—copy, fix paths, `enable --now`. **Bring-up checklist (operator):** 1. `PRICE_FEED_KEEPER_ADDRESS` in `smom-dbis-138/.env` (Chain 138: `0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04` per explorer docs—verify live). -2. `KEEPER_PRIVATE_KEY` optional if same as `PRIVATE_KEY` (must have `KEEPER_ROLE` on the keeper). +2. `KEEPER_PRIVATE_KEY` should be a dedicated keeper signer with `KEEPER_ROLE` on the keeper. + The mesh now skips keeper writes cleanly when no dedicated keeper key is present. 3. `./scripts/reserve/set-price-feed-keeper-interval.sh 6` once if the keeper still used a 30s interval. 4. Oracle: `scripts/update-oracle-price.sh` needs Besu-safe paths (eth_call + explicit `--gas-limit`); set `AGGREGATOR_ADDRESS` / transmitter as deployed. 5. If the keeper uses a **WETH MockPriceFeed** (`CHAIN138_WETH_MOCK_PRICE_FEED`), run `scripts/reserve/sync-weth-mock-price.sh` on a schedule (or from the same cron as the mesh) so ReserveSystem WETH tracks the market. diff --git a/env.additions.example b/env.additions.example index db0dbbc..ad45046 100644 --- a/env.additions.example +++ b/env.additions.example @@ -5,24 +5,55 @@ # Or copy the needed lines into .env manually. Do NOT commit .env. # DODO PMM Provider (deployed); required for liquidity/quoting scripts -DODO_PMM_PROVIDER_ADDRESS=0x5CAe6Ce155b7f08D3a956F5Dc82fC9945f29B381 +DODO_PMM_PROVIDER_ADDRESS=0x3f729632E9553EBacCdE2e9b4c8F2B285b014F2e -# DODO PMM Integration (Chain 138) -DODO_PMM_INTEGRATION_ADDRESS=0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d +# Official DODO V2 DVM stack (Chain 138) +DODO_DVM_FACTORY=0xc93870594C7f83A0aE076c2e30b494Efc526b68E +DODO_VENDING_MACHINE_ADDRESS=0xb6D9EF3575bc48De3f011C310DC24d87bEC6087C +DODO_PMM_INTEGRATION_ADDRESS=0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895 # Local quote-side mirror stables (Chain 138); optional — see deploy runbooks OFFICIAL_USDT_ADDRESS=0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1 OFFICIAL_USDC_ADDRESS=0x71D6687F38b93CCad569Fa6352c876eea967201b # PMM pool addresses (optional; RegisterDODOPools reads from integration if unset) -# Canonical current cUSDT/USDT pool is the funded pool returned by the live -# Chain 138 integration/provider mapping. -POOL_CUSDTCUSDC=0x9fcB06Aa1FD5215DC0E91Fd098aeff4B62fEa5C8 -POOL_CUSDTUSDT=0x6fc60DEDc92a2047062294488539992710b99D71 -POOL_CUSDCUSDC=0x90bd9Bf18Daa26Af3e814ea224032d015db58Ea5 +# Canonical current stable pools were recalibrated on 2026-04-02 with i=1e18 and k=0 +# so equal-reserve stable pairs quote at true 1:1 on the official DODO V2 DVM stack. +POOL_CUSDTCUSDC=0x9e89bAe009adf128782E19e8341996c596ac40dC +POOL_CUSDTUSDT=0x866Cb44b59303d8dc5f4F9E3E7A8e8b0bf238d66 +POOL_CUSDCUSDC=0xc39B7D0F40838cbFb54649d327f49a6DAC964062 + +# Canonical stable-pool creation parameters for Chain 138 official DVM +DODO_LP_FEE_BPS=10 +DODO_INITIAL_PRICE_1E18=1000000000000000000 +DODO_K_FACTOR_1E18=0 +DODO_ENABLE_TWAP=false # Token-aggregation (quote API): same integration address so indexer can index DODO pools -CHAIN_138_DODO_PMM_INTEGRATION=0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d +CHAIN_138_DODO_PMM_INTEGRATION=0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895 + +# Optional USDW public-wrap surfaces (BSC / Polygon) for cWUSDW rollout +CWUSDW_ADDRESS_56=0xC2FA05F12a75Ac84ea778AF9D6935cA807275E55 +# CWUSDW_ADDRESS_137=0x... +USDW_NATIVE_ADDRESS_56=0xed75ad08f416d4e53e4d45dd5140a4c8b84f39fb +USDW_NATIVE_ADDRESS_137=0x3deb0c60f0be9d9b99da83a2b6b2ee790f5af37a +# USDW_WRAP_VAULT_56=0x... +# USDW_WRAP_VAULT_137=0x... + +# Planned ALL Mainnet AUSDT -> cWAUSDT -> cAUSDT corridor +AUSDT_ADDRESS_651940=0x015B1897Ed5279930bC2Be46F661894d219292A6 +CAUSDT_ADDRESS_138=0x5fdDF65733e3d590463F68f93Cf16E8c04081271 +CWAUSDT_ADDRESS_56=0xe1a51Bc037a79AB36767561B147eb41780124934 +CWAUSDT_ADDRESS_137=0xf12e262F85107df26741726b074606CaFa24AAe7 +CWAUSDT_ADDRESS_43114=0xff3084410A732231472Ee9f93F5855dA89CC5254 +CWAUSDT_ADDRESS_42220=0xC158b6cD3A3088C52F797D41f5Aa02825361629e + +# Planned ALL Mainnet gold corridor +# 138 cXAUC/cXAUT -> source-leg cWXAUC/cWXAUT -> 651940 cWAXAUC/cWAXAUT -> 651940 cAXAUC/cAXAUT +# CAXAUC_ADDRESS_651940=0x... +# CAXAUT_ADDRESS_651940=0x... +# CWAXAUC_ADDRESS_651940=0x... +# CWAXAUT_ADDRESS_651940=0x... # Add liquidity (run-pmm-full-parity or AddLiquidityPMMPoolsChain138.s.sol); amounts in token units (6 decimals, e.g. 1000000e6 = 1M) # ADD_LIQUIDITY_BASE_AMOUNT=1000000e6 diff --git a/forkproof/foundry.toml b/forkproof/foundry.toml new file mode 100644 index 0000000..fa31bae --- /dev/null +++ b/forkproof/foundry.toml @@ -0,0 +1,20 @@ +[profile.default] +src = "src" +test = "test" +out = "out" +libs = ["../lib"] +solc = "0.8.20" +optimizer = true +optimizer_runs = 1 +via_ir = true +evm_version = "cancun" +allow_paths = [".."] +fs_permissions = [ + { access = "read", path = "../config" } +] +remappings = [ + "@openzeppelin/contracts/=../lib/openzeppelin-contracts/contracts/", + "forge-std/=../lib/forge-std/src/", + "atomic/=../contracts/bridge/atomic/", + "repo-test/=../test/" +] diff --git a/forkproof/src/AaveQuotePushFlashReceiver.sol b/forkproof/src/AaveQuotePushFlashReceiver.sol new file mode 100644 index 0000000..232a3f2 --- /dev/null +++ b/forkproof/src/AaveQuotePushFlashReceiver.sol @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +interface IAavePoolLike { + function flashLoanSimple( + address receiverAddress, + address asset, + uint256 amount, + bytes calldata params, + uint16 referralCode + ) external; +} + +interface IAaveFlashLoanSimpleReceiver { + function executeOperation( + address asset, + uint256 amount, + uint256 premium, + address initiator, + bytes calldata params + ) external returns (bool); +} + +interface IAaveDODOQuotePushSwapExactIn { + function swapExactIn(address pool, address tokenIn, uint256 amountIn, uint256 minAmountOut) + external + returns (uint256 amountOut); +} + +interface IAaveExternalUnwinder { + function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata data) + external + returns (uint256 amountOut); +} + +interface IAaveAtomicBridgeCoordinator { + struct CreateIntentParams { + uint64 sourceChain; + uint64 destinationChain; + address assetIn; + address assetOut; + uint256 amountIn; + uint256 minAmountOut; + address recipient; + uint256 deadline; + bytes32 routeId; + } + + function createIntent(CreateIntentParams calldata p) external returns (bytes32 obligationId); + + function submitCommitment(bytes32 obligationId, bytes32 settlementMode) external; + + function obligationEscrow() external view returns (address); +} + +/** + * @title AaveQuotePushFlashReceiver + * @notice Aave V3 flashLoanSimple receiver for the quote-push workflow: + * flash borrow quote -> buy PMM base -> unwind base externally -> repay lender, retaining any surplus. + */ +contract AaveQuotePushFlashReceiver is IAaveFlashLoanSimpleReceiver { + using SafeERC20 for IERC20; + + address public immutable pool; + + struct QuotePushParams { + address integration; + address pmmPool; + address baseToken; + address externalUnwinder; + uint256 minOutPmm; + uint256 minOutUnwind; + bytes unwindData; + AtomicBridgeParams atomicBridge; + } + + struct AtomicBridgeParams { + address coordinator; + uint64 sourceChain; + uint64 destinationChain; + address destinationAsset; + uint256 bridgeAmount; + uint256 minDestinationAmount; + address destinationRecipient; + uint256 destinationDeadline; + bytes32 routeId; + bytes32 settlementMode; + bool submitCommitment; + } + + error UntrustedPool(); + error UntrustedInitiator(); + error BadParams(); + error InsufficientToRepay(); + error InvalidAtomicBridge(); + + event QuotePushExecuted( + address indexed quoteToken, + address indexed baseToken, + uint256 borrowedAmount, + uint256 premium, + uint256 baseOut, + uint256 unwindOut, + uint256 surplus + ); + event AtomicBridgeTriggered( + bytes32 indexed obligationId, + address indexed coordinator, + address indexed destinationRecipient, + uint256 bridgeAmount, + uint256 minDestinationAmount + ); + + constructor(address pool_) { + pool = pool_; + } + + function flashQuotePush(address asset, uint256 amount, QuotePushParams calldata params) external { + IAavePoolLike(pool).flashLoanSimple(address(this), asset, amount, abi.encode(address(this), params), 0); + } + + function executeOperation( + address asset, + uint256 amount, + uint256 premium, + address initiator, + bytes calldata params + ) external returns (bool) { + if (msg.sender != pool) revert UntrustedPool(); + (address expectedInitiator, QuotePushParams memory p) = abi.decode(params, (address, QuotePushParams)); + if (initiator != expectedInitiator) revert UntrustedInitiator(); + if ( + p.integration == address(0) || p.pmmPool == address(0) || p.baseToken == address(0) + || p.externalUnwinder == address(0) + ) revert BadParams(); + if (p.baseToken == asset) revert BadParams(); + + uint256 baseOut = _swapQuoteForBase(asset, amount, p.integration, p.pmmPool, p.minOutPmm); + + uint256 baseBal = IERC20(p.baseToken).balanceOf(address(this)); + if (p.atomicBridge.coordinator != address(0)) { + _triggerAtomicBridge(p.baseToken, baseBal, p.atomicBridge); + } + + uint256 unwindOut = _unwindBaseIntoQuote(p.baseToken, asset, p.externalUnwinder, p.minOutUnwind, p.unwindData); + uint256 surplus = _approveRepayment(asset, amount + premium); + emit QuotePushExecuted(asset, p.baseToken, amount, premium, baseOut, unwindOut, surplus); + return true; + } + + function _swapQuoteForBase(address asset, uint256 amount, address integration, address pmmPool, uint256 minOutPmm) + internal + returns (uint256 baseOut) + { + IERC20(asset).forceApprove(integration, amount); + baseOut = IAaveDODOQuotePushSwapExactIn(integration).swapExactIn(pmmPool, asset, amount, minOutPmm); + } + + function _unwindBaseIntoQuote( + address baseToken, + address quoteToken, + address externalUnwinder, + uint256 minOutUnwind, + bytes memory unwindData + ) internal returns (uint256 unwindOut) { + uint256 remainingBase = IERC20(baseToken).balanceOf(address(this)); + IERC20(baseToken).forceApprove(externalUnwinder, remainingBase); + unwindOut = + IAaveExternalUnwinder(externalUnwinder).unwind(baseToken, quoteToken, remainingBase, minOutUnwind, unwindData); + } + + function _approveRepayment(address quoteToken, uint256 need) internal returns (uint256 surplus) { + IERC20 quote = IERC20(quoteToken); + uint256 quoteBal = quote.balanceOf(address(this)); + if (quoteBal < need) revert InsufficientToRepay(); + surplus = quoteBal - need; + quote.forceApprove(pool, need); + } + + function _triggerAtomicBridge(address baseToken, uint256 baseBal, AtomicBridgeParams memory atomicBridge) internal { + if ( + atomicBridge.destinationAsset == address(0) || atomicBridge.destinationRecipient == address(0) + || atomicBridge.bridgeAmount == 0 || atomicBridge.bridgeAmount > baseBal + || atomicBridge.destinationDeadline <= block.timestamp + ) revert InvalidAtomicBridge(); + + address escrowAddress = IAaveAtomicBridgeCoordinator(atomicBridge.coordinator).obligationEscrow(); + IERC20(baseToken).forceApprove(escrowAddress, atomicBridge.bridgeAmount); + bytes32 obligationId = IAaveAtomicBridgeCoordinator(atomicBridge.coordinator).createIntent( + IAaveAtomicBridgeCoordinator.CreateIntentParams({ + sourceChain: atomicBridge.sourceChain, + destinationChain: atomicBridge.destinationChain, + assetIn: baseToken, + assetOut: atomicBridge.destinationAsset, + amountIn: atomicBridge.bridgeAmount, + minAmountOut: atomicBridge.minDestinationAmount, + recipient: atomicBridge.destinationRecipient, + deadline: atomicBridge.destinationDeadline, + routeId: atomicBridge.routeId + }) + ); + if (atomicBridge.submitCommitment) { + IAaveAtomicBridgeCoordinator(atomicBridge.coordinator).submitCommitment( + obligationId, atomicBridge.settlementMode + ); + } + emit AtomicBridgeTriggered( + obligationId, + atomicBridge.coordinator, + atomicBridge.destinationRecipient, + atomicBridge.bridgeAmount, + atomicBridge.minDestinationAmount + ); + } +} diff --git a/forkproof/src/DODOIntegrationExternalUnwinder.sol b/forkproof/src/DODOIntegrationExternalUnwinder.sol new file mode 100644 index 0000000..f05be6c --- /dev/null +++ b/forkproof/src/DODOIntegrationExternalUnwinder.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +interface IDODOIntegrationSwapExactIn { + function swapExactIn(address pool, address tokenIn, uint256 amountIn, uint256 minAmountOut) + external + returns (uint256 amountOut); +} + +/** + * @title DODOIntegrationExternalUnwinder + * @notice Unwinds base -> quote through a DODO PMM integration. + * @dev `data` must be `abi.encode(address pool)` selecting the registered pool to use. + */ +contract DODOIntegrationExternalUnwinder { + using SafeERC20 for IERC20; + + address public immutable integration; + + error BadParams(); + + constructor(address integration_) { + integration = integration_; + } + + function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata data) + external + returns (uint256 amountOut) + { + if (tokenIn == address(0) || tokenOut == address(0) || tokenIn == tokenOut || amountIn == 0) revert BadParams(); + if (data.length != 32) revert BadParams(); + + address pool = abi.decode(data, (address)); + if (pool == address(0)) revert BadParams(); + + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + IERC20(tokenIn).forceApprove(integration, amountIn); + amountOut = IDODOIntegrationSwapExactIn(integration).swapExactIn(pool, tokenIn, amountIn, minAmountOut); + IERC20(tokenOut).safeTransfer(msg.sender, amountOut); + } +} diff --git a/forkproof/src/DODOToUniswapV3MultiHopExternalUnwinder.sol b/forkproof/src/DODOToUniswapV3MultiHopExternalUnwinder.sol new file mode 100644 index 0000000..31d4e59 --- /dev/null +++ b/forkproof/src/DODOToUniswapV3MultiHopExternalUnwinder.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +interface IDODOMultiHopSwapExactInLike { + function swapExactIn(address pool, address tokenIn, uint256 amountIn, uint256 minAmountOut) + external + returns (uint256 amountOut); +} + +interface ISwapRouterLikeMultiHop { + struct ExactInputParams { + bytes path; + address recipient; + uint256 amountIn; + uint256 amountOutMinimum; + } + + function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut); +} + +contract DODOToUniswapV3MultiHopExternalUnwinder { + using SafeERC20 for IERC20; + + address public immutable integration; + address public immutable router; + + error BadParams(); + + constructor(address integration_, address router_) { + integration = integration_; + router = router_; + } + + function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata data) + external + returns (uint256 amountOut) + { + if (tokenIn == address(0) || tokenOut == address(0) || tokenIn == tokenOut || amountIn == 0) revert BadParams(); + + (address dodoPool, address intermediateToken, uint256 minIntermediateOut, bytes memory uniswapPath) = + abi.decode(data, (address, address, uint256, bytes)); + if (dodoPool == address(0) || intermediateToken == address(0) || intermediateToken == tokenIn || intermediateToken == tokenOut) { + revert BadParams(); + } + if (uniswapPath.length < 43) revert BadParams(); + + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + IERC20(tokenIn).forceApprove(integration, amountIn); + uint256 intermediateOut = + IDODOMultiHopSwapExactInLike(integration).swapExactIn(dodoPool, tokenIn, amountIn, minIntermediateOut); + + IERC20(intermediateToken).forceApprove(router, intermediateOut); + amountOut = ISwapRouterLikeMultiHop(router).exactInput( + ISwapRouterLikeMultiHop.ExactInputParams({ + path: uniswapPath, + recipient: msg.sender, + amountIn: intermediateOut, + amountOutMinimum: minAmountOut + }) + ); + } +} diff --git a/forkproof/src/UniswapV3ExternalUnwinder.sol b/forkproof/src/UniswapV3ExternalUnwinder.sol new file mode 100644 index 0000000..f6fe8fc --- /dev/null +++ b/forkproof/src/UniswapV3ExternalUnwinder.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +interface ISwapRouterLike { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + struct ExactInputParams { + bytes path; + address recipient; + uint256 amountIn; + uint256 amountOutMinimum; + } + + function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); + function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut); +} + +contract UniswapV3ExternalUnwinder { + using SafeERC20 for IERC20; + + address public immutable router; + + error BadParams(); + + constructor(address router_) { + router = router_; + } + + function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata data) + external + returns (uint256 amountOut) + { + if (tokenIn == address(0) || tokenOut == address(0) || tokenIn == tokenOut || amountIn == 0) revert BadParams(); + + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + IERC20(tokenIn).forceApprove(router, amountIn); + + if (data.length == 32) { + uint24 fee = abi.decode(data, (uint24)); + return ISwapRouterLike(router).exactInputSingle( + ISwapRouterLike.ExactInputSingleParams({ + tokenIn: tokenIn, + tokenOut: tokenOut, + fee: fee, + recipient: msg.sender, + amountIn: amountIn, + amountOutMinimum: minAmountOut, + sqrtPriceLimitX96: 0 + }) + ); + } + + bytes memory path = abi.decode(data, (bytes)); + return ISwapRouterLike(router).exactInput( + ISwapRouterLike.ExactInputParams({ + path: path, + recipient: msg.sender, + amountIn: amountIn, + amountOutMinimum: minAmountOut + }) + ); + } +} diff --git a/forkproof/test/AaveQuotePushFlashReceiverMainnetFork.t.sol b/forkproof/test/AaveQuotePushFlashReceiverMainnetFork.t.sol new file mode 100644 index 0000000..cfec8dd --- /dev/null +++ b/forkproof/test/AaveQuotePushFlashReceiverMainnetFork.t.sol @@ -0,0 +1,391 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { + AaveQuotePushFlashReceiver, + IAaveExternalUnwinder +} from "../src/AaveQuotePushFlashReceiver.sol"; +import {AtomicBridgeCoordinator} from "atomic/AtomicBridgeCoordinator.sol"; +import {AtomicFeePolicy} from "atomic/AtomicFeePolicy.sol"; +import {AtomicFulfillerRegistry} from "atomic/AtomicFulfillerRegistry.sol"; +import {AtomicLiquidityVault} from "atomic/AtomicLiquidityVault.sol"; +import {AtomicObligationEscrow} from "atomic/AtomicObligationEscrow.sol"; +import {AtomicSettlementRouter} from "atomic/AtomicSettlementRouter.sol"; +import {AtomicSlashingManager} from "atomic/AtomicSlashingManager.sol"; +import {AtomicTypes} from "atomic/AtomicTypes.sol"; +import {IAtomicSettlementAdapter} from "atomic/interfaces/IAtomicSettlementAdapter.sol"; +import {MockMintableToken} from "repo-test/dbis/MockMintableToken.sol"; + +contract AaveForkMockExternalUnwinder is IAaveExternalUnwinder { + IERC20 public immutable base; + IERC20 public immutable quote; + uint256 public immutable numerator; + uint256 public immutable denominator; + + constructor(IERC20 base_, IERC20 quote_, uint256 numerator_, uint256 denominator_) { + base = base_; + quote = quote_; + numerator = numerator_; + denominator = denominator_; + } + + function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata) + external + override + returns (uint256 amountOut) + { + require(tokenIn == address(base), "base only"); + require(tokenOut == address(quote), "quote only"); + IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); + amountOut = amountIn * numerator / denominator; + require(amountOut >= minAmountOut, "min unwind"); + IERC20(address(quote)).transfer(msg.sender, amountOut); + } +} + +contract MockAtomicSettlementAdapter is IAtomicSettlementAdapter { + address public lastToken; + uint256 public lastAmount; + address public lastRecipient; + bytes32 public lastObligationId; + + function executeSettlement( + bytes32 obligationId, + address token, + uint256 amount, + address recipient, + bytes calldata + ) external payable returns (bytes32 settlementId) { + lastObligationId = obligationId; + lastToken = token; + lastAmount = amount; + lastRecipient = recipient; + settlementId = keccak256(abi.encode(obligationId, token, amount, recipient, block.timestamp)); + } +} + +contract AaveQuotePushFlashReceiverMainnetForkTest is Test { + address constant AAVE_POOL_MAINNET = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2; + address constant DODO_PMM_INTEGRATION_MAINNET = 0xa9F284eD010f4F7d7F8F201742b49b9f58e29b84; + address constant POOL_CWUSDC_USDC = 0x69776fc607e9edA8042e320e7e43f54d06c68f0E; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant CWUSDC = 0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a; + bytes32 constant MOCK_SETTLEMENT_MODE = keccak256("MOCK_SETTLEMENT_MODE"); + + AaveQuotePushFlashReceiver internal receiver; + AaveForkMockExternalUnwinder internal unwinder; + MockMintableToken internal cusdc138; + MockMintableToken internal bondToken; + AtomicLiquidityVault internal atomicVault; + AtomicFulfillerRegistry internal atomicRegistry; + AtomicFeePolicy internal atomicFeePolicy; + AtomicObligationEscrow internal atomicEscrow; + AtomicSettlementRouter internal atomicRouter; + AtomicSlashingManager internal atomicSlashingManager; + AtomicBridgeCoordinator internal atomicCoordinator; + MockAtomicSettlementAdapter internal mockSettlementAdapter; + bytes32 internal atomicCorridorId; + address internal destinationRecipient = address(0x138138); + + function setUp() public { + string memory rpcUrl = vm.envString("ETHEREUM_MAINNET_RPC"); + vm.createSelectFork(rpcUrl); + + receiver = new AaveQuotePushFlashReceiver(AAVE_POOL_MAINNET); + unwinder = new AaveForkMockExternalUnwinder(IERC20(CWUSDC), IERC20(USDC), 130, 100); + deal(USDC, address(unwinder), 100_000_000); + + cusdc138 = new MockMintableToken("Chain 138 USDC", "cUSDC", 6, address(this)); + bondToken = new MockMintableToken("Atomic Bond", "aBOND", 6, address(this)); + atomicVault = new AtomicLiquidityVault(address(this)); + atomicRegistry = new AtomicFulfillerRegistry(address(bondToken), address(this)); + atomicFeePolicy = new AtomicFeePolicy(address(this)); + atomicEscrow = new AtomicObligationEscrow(address(this)); + atomicRouter = new AtomicSettlementRouter(address(this)); + atomicSlashingManager = new AtomicSlashingManager(address(atomicRegistry), address(this)); + mockSettlementAdapter = new MockAtomicSettlementAdapter(); + atomicCoordinator = new AtomicBridgeCoordinator( + address(atomicVault), + address(atomicRegistry), + address(atomicEscrow), + address(atomicRouter), + address(atomicFeePolicy), + address(atomicSlashingManager), + address(this), + address(this) + ); + + atomicVault.grantRole(atomicVault.COORDINATOR_ROLE(), address(atomicCoordinator)); + atomicVault.grantRole(atomicVault.RECONCILER_ROLE(), address(atomicCoordinator)); + atomicRegistry.grantRole(atomicRegistry.COORDINATOR_ROLE(), address(atomicCoordinator)); + atomicRegistry.grantRole(atomicRegistry.SLASHER_ROLE(), address(atomicSlashingManager)); + atomicEscrow.grantRole(atomicEscrow.COORDINATOR_ROLE(), address(atomicCoordinator)); + atomicRouter.grantRole(atomicRouter.COORDINATOR_ROLE(), address(atomicCoordinator)); + atomicSlashingManager.grantRole(atomicSlashingManager.COORDINATOR_ROLE(), address(atomicCoordinator)); + atomicRouter.setAdapter(MOCK_SETTLEMENT_MODE, address(mockSettlementAdapter)); + + atomicCorridorId = atomicCoordinator.getCorridorId(1, 138, CWUSDC, address(cusdc138)); + atomicCoordinator.configureCorridor( + AtomicTypes.CorridorConfig({ + enabled: true, + degraded: false, + sourceChain: 1, + destinationChain: 138, + assetIn: CWUSDC, + assetOut: address(cusdc138), + maxNotional: 10_000_000, + maxReservedBps: 8_000, + targetBuffer: 100_000, + maxSettlementBacklog: 5_000_000, + maxOracleDriftBps: 500, + fulfilmentTimeout: 1 days, + settlementTimeout: 2 days, + defaultSettlementMode: MOCK_SETTLEMENT_MODE + }) + ); + atomicFeePolicy.setCorridorPolicy(atomicCorridorId, 25, 10, 12_000, 500, 1 days, 2 days); + atomicVault.setTargetBuffer(atomicCorridorId, address(cusdc138), 100_000); + cusdc138.mint(address(this), 5_000_000); + cusdc138.approve(address(atomicVault), type(uint256).max); + atomicVault.fundCorridor(atomicCorridorId, address(cusdc138), 5_000_000); + + bondToken.mint(address(receiver), 5_000_000); + vm.startPrank(address(receiver)); + bondToken.approve(address(atomicRegistry), type(uint256).max); + atomicRegistry.depositBond(3_000_000); + vm.stopPrank(); + atomicRegistry.setFulfillerActive(address(receiver), true); + atomicRegistry.setCorridorAuthorization(address(receiver), atomicCorridorId, true); + } + + function testFork_aaveQuotePush_usesRealAaveAndRealMainnetPmm() public { + uint256 amount = 2_964_298; + uint256 receiverQuoteBefore = IERC20(USDC).balanceOf(address(receiver)); + uint256 poolBaseBefore = IERC20(CWUSDC).balanceOf(POOL_CWUSDC_USDC); + uint256 poolQuoteBefore = IERC20(USDC).balanceOf(POOL_CWUSDC_USDC); + + AaveQuotePushFlashReceiver.QuotePushParams memory p = AaveQuotePushFlashReceiver.QuotePushParams({ + integration: DODO_PMM_INTEGRATION_MAINNET, + pmmPool: POOL_CWUSDC_USDC, + baseToken: CWUSDC, + externalUnwinder: address(unwinder), + minOutPmm: 2_800_000, + minOutUnwind: amount + 1_483, + unwindData: bytes(""), + atomicBridge: AaveQuotePushFlashReceiver.AtomicBridgeParams({ + coordinator: address(0), + sourceChain: 0, + destinationChain: 0, + destinationAsset: address(0), + bridgeAmount: 0, + minDestinationAmount: 0, + destinationRecipient: address(0), + destinationDeadline: 0, + routeId: bytes32(0), + settlementMode: bytes32(0), + submitCommitment: false + }) + }); + + receiver.flashQuotePush(USDC, amount, p); + + uint256 receiverQuoteAfter = IERC20(USDC).balanceOf(address(receiver)); + uint256 poolBaseAfter = IERC20(CWUSDC).balanceOf(POOL_CWUSDC_USDC); + uint256 poolQuoteAfter = IERC20(USDC).balanceOf(POOL_CWUSDC_USDC); + uint256 actualBaseOut = poolBaseBefore - poolBaseAfter; + uint256 actualQuoteIntoPool = poolQuoteAfter - poolQuoteBefore; + uint256 actualSurplus = receiverQuoteAfter - receiverQuoteBefore; + uint256 premium = _aavePremium(amount); + (uint256 predictedBaseOut, uint256 predictedUnwindOut, uint256 predictedSurplus) = + _predictQuotePush(poolBaseBefore, poolQuoteBefore, amount, 3, 130, 100, 0, premium); + uint256 predictedNetQuoteIn = _netQuoteIn(amount, 3); + + assertGt(receiverQuoteAfter, receiverQuoteBefore, "receiver retains surplus"); + assertEq(IERC20(CWUSDC).balanceOf(address(receiver)), 0, "base fully unwound"); + assertLt(poolBaseAfter, poolBaseBefore, "pool base decreased"); + assertGt(poolQuoteAfter, poolQuoteBefore, "pool quote increased"); + _assertWithinOnePercent(actualBaseOut, predictedBaseOut, "baseOut"); + _assertWithinOnePercent(actualQuoteIntoPool, predictedNetQuoteIn, "netQuoteIn"); + _assertWithinOnePercent(actualSurplus, predictedSurplus, "surplus"); + assertApproxEqAbs(predictedUnwindOut, actualSurplus + amount + premium, 2, "unwindOut"); + } + + function testFork_aaveQuotePush_atomicCorridorFulfillment_1_to_138_cwusdc_to_cusdc() public { + uint256 amount = 2_964_298; + uint256 bridgeAmount = 500_000; + uint256 destinationRecipientBefore = cusdc138.balanceOf(destinationRecipient); + uint256 poolBaseBefore = IERC20(CWUSDC).balanceOf(POOL_CWUSDC_USDC); + uint256 poolQuoteBefore = IERC20(USDC).balanceOf(POOL_CWUSDC_USDC); + + AaveQuotePushFlashReceiver.QuotePushParams memory p = AaveQuotePushFlashReceiver.QuotePushParams({ + integration: DODO_PMM_INTEGRATION_MAINNET, + pmmPool: POOL_CWUSDC_USDC, + baseToken: CWUSDC, + externalUnwinder: address(unwinder), + minOutPmm: 2_800_000, + minOutUnwind: amount + 1_483, + unwindData: bytes(""), + atomicBridge: AaveQuotePushFlashReceiver.AtomicBridgeParams({ + coordinator: address(atomicCoordinator), + sourceChain: 1, + destinationChain: 138, + destinationAsset: address(cusdc138), + bridgeAmount: bridgeAmount, + minDestinationAmount: bridgeAmount, + destinationRecipient: destinationRecipient, + destinationDeadline: block.timestamp + 1 hours, + routeId: atomicCorridorId, + settlementMode: bytes32(0), + submitCommitment: true + }) + }); + + uint256 receiverQuoteBefore = IERC20(USDC).balanceOf(address(receiver)); + receiver.flashQuotePush(USDC, amount, p); + + uint256 receiverQuoteAfter = IERC20(USDC).balanceOf(address(receiver)); + uint256 poolBaseAfter = IERC20(CWUSDC).balanceOf(POOL_CWUSDC_USDC); + uint256 poolQuoteAfter = IERC20(USDC).balanceOf(POOL_CWUSDC_USDC); + uint256 actualBaseOut = poolBaseBefore - poolBaseAfter; + uint256 actualQuoteIntoPool = poolQuoteAfter - poolQuoteBefore; + uint256 actualSurplus = receiverQuoteAfter - receiverQuoteBefore; + uint256 premium = _aavePremium(amount); + (uint256 predictedBaseOut, uint256 predictedUnwindOut, uint256 predictedSurplus) = + _predictQuotePush(poolBaseBefore, poolQuoteBefore, amount, 3, 130, 100, bridgeAmount, premium); + uint256 predictedNetQuoteIn = _netQuoteIn(amount, 3); + assertGt(receiverQuoteAfter, receiverQuoteBefore, "receiver still retains quote surplus"); + assertEq(cusdc138.balanceOf(destinationRecipient), destinationRecipientBefore + bridgeAmount, "destination funded"); + assertEq(IERC20(CWUSDC).balanceOf(address(receiver)), 0, "remaining base fully unwound"); + _assertWithinOnePercent(actualBaseOut, predictedBaseOut, "atomic baseOut"); + _assertWithinOnePercent(actualQuoteIntoPool, predictedNetQuoteIn, "atomic netQuoteIn"); + _assertWithinOnePercent(actualSurplus, predictedSurplus, "atomic surplus"); + assertApproxEqAbs(predictedUnwindOut, actualSurplus + amount + premium, 2, "atomic unwindOut"); + + AtomicTypes.CorridorLiquidityState memory state = + atomicVault.getCorridorLiquidityState(atomicCorridorId, address(cusdc138)); + assertEq(state.settlementBacklog, bridgeAmount, "backlog increased by delivered amount"); + assertEq(state.totalLiquidity, 5_000_000 - bridgeAmount, "vault liquidity debited on immediate fulfillment"); + } + + function testFork_aaveQuotePush_atomicCorridorSettlementConfirmation_1_to_138() public { + uint256 amount = 2_964_298; + uint256 bridgeAmount = 500_000; + uint256 deadline = block.timestamp + 1 hours; + uint256 receiverBondBefore = atomicRegistry.availableBond(address(receiver)); + uint256 treasuryBaseBefore = IERC20(CWUSDC).balanceOf(address(this)); + uint256 receiverBaseBefore = IERC20(CWUSDC).balanceOf(address(receiver)); + + AaveQuotePushFlashReceiver.QuotePushParams memory p = AaveQuotePushFlashReceiver.QuotePushParams({ + integration: DODO_PMM_INTEGRATION_MAINNET, + pmmPool: POOL_CWUSDC_USDC, + baseToken: CWUSDC, + externalUnwinder: address(unwinder), + minOutPmm: 2_800_000, + minOutUnwind: amount + 1_483, + unwindData: bytes(""), + atomicBridge: AaveQuotePushFlashReceiver.AtomicBridgeParams({ + coordinator: address(atomicCoordinator), + sourceChain: 1, + destinationChain: 138, + destinationAsset: address(cusdc138), + bridgeAmount: bridgeAmount, + minDestinationAmount: bridgeAmount, + destinationRecipient: destinationRecipient, + destinationDeadline: deadline, + routeId: atomicCorridorId, + settlementMode: bytes32(0), + submitCommitment: true + }) + }); + + receiver.flashQuotePush(USDC, amount, p); + bytes32 obligationId = _deriveObligationId(bridgeAmount, deadline); + + AtomicTypes.AtomicObligation memory fulfilled = atomicCoordinator.getObligation(obligationId); + assertEq(uint8(fulfilled.status), uint8(AtomicTypes.ObligationStatus.Fulfilled), "obligation fulfilled"); + + atomicCoordinator.initiateSettlement(obligationId, abi.encodePacked(bytes32(uint256(138)))); + + AtomicTypes.AtomicObligation memory pending = atomicCoordinator.getObligation(obligationId); + assertEq(uint8(pending.status), uint8(AtomicTypes.ObligationStatus.SettlementPending), "obligation pending"); + assertEq(mockSettlementAdapter.lastObligationId(), obligationId, "adapter saw obligation"); + assertEq(mockSettlementAdapter.lastToken(), CWUSDC, "adapter token"); + assertEq(mockSettlementAdapter.lastRecipient(), destinationRecipient, "adapter recipient"); + + uint256 expectedFulfillerFee = (bridgeAmount * 25) / 10_000; + uint256 expectedProtocolFee = (bridgeAmount * 10) / 10_000; + uint256 expectedSettlementAmount = bridgeAmount - expectedFulfillerFee - expectedProtocolFee; + assertEq(mockSettlementAdapter.lastAmount(), expectedSettlementAmount, "adapter amount"); + assertEq(IERC20(CWUSDC).balanceOf(address(this)), treasuryBaseBefore + expectedProtocolFee, "protocol fee received"); + assertEq( + IERC20(CWUSDC).balanceOf(address(receiver)), + receiverBaseBefore + expectedFulfillerFee, + "fulfiller fee received" + ); + + cusdc138.mint(address(this), bridgeAmount); + cusdc138.approve(address(atomicVault), bridgeAmount); + atomicCoordinator.confirmSettlement(obligationId, bridgeAmount); + + AtomicTypes.AtomicObligation memory settled = atomicCoordinator.getObligation(obligationId); + assertEq(uint8(settled.status), uint8(AtomicTypes.ObligationStatus.Settled), "obligation settled"); + + AtomicTypes.CorridorLiquidityState memory state = + atomicVault.getCorridorLiquidityState(atomicCorridorId, address(cusdc138)); + assertEq(state.settlementBacklog, 0, "backlog cleared"); + assertEq(state.totalLiquidity, 5_000_000, "vault replenished"); + assertEq(atomicRegistry.availableBond(address(receiver)), receiverBondBefore, "bond released"); + } + + function _deriveObligationId(uint256 bridgeAmount, uint256 deadline) internal view returns (bytes32) { + bytes32 intentId = keccak256( + abi.encode( + block.chainid, + address(receiver), + uint256(1), + atomicCorridorId, + bridgeAmount, + bridgeAmount, + deadline, + atomicCorridorId + ) + ); + return keccak256(abi.encode(intentId, destinationRecipient)); + } + + function _predictQuotePush( + uint256 baseReserve, + uint256 quoteReserve, + uint256 grossQuoteIn, + uint256 lpFeeBps, + uint256 unwindNumerator, + uint256 unwindDenominator, + uint256 bridgeAmount, + uint256 premium + ) internal pure returns (uint256 predictedBaseOut, uint256 predictedUnwindOut, uint256 predictedSurplus) { + uint256 netQuoteIn = (grossQuoteIn * (10_000 - lpFeeBps)) / 10_000; + predictedBaseOut = (netQuoteIn * baseReserve) / (quoteReserve + netQuoteIn); + uint256 remainingBase = predictedBaseOut - bridgeAmount; + predictedUnwindOut = (remainingBase * unwindNumerator) / unwindDenominator; + predictedSurplus = predictedUnwindOut - grossQuoteIn - premium; + } + + function _netQuoteIn(uint256 grossQuoteIn, uint256 lpFeeBps) internal pure returns (uint256) { + return (grossQuoteIn * (10_000 - lpFeeBps)) / 10_000; + } + + function _assertWithinOnePercent(uint256 actual, uint256 expected, string memory label) internal pure { + if (expected == 0) { + require(actual == 0, label); + return; + } + uint256 diff = actual > expected ? actual - expected : expected - actual; + require(diff * 10_000 <= expected * 100, label); + } + + function _aavePremium(uint256 amount) internal pure returns (uint256) { + return (amount * 5) / 10_000; + } +} diff --git a/forkproof/test/DODOIntegrationExternalUnwinderMainnetFork.t.sol b/forkproof/test/DODOIntegrationExternalUnwinderMainnetFork.t.sol new file mode 100644 index 0000000..49755ec --- /dev/null +++ b/forkproof/test/DODOIntegrationExternalUnwinderMainnetFork.t.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {DODOIntegrationExternalUnwinder} from "../src/DODOIntegrationExternalUnwinder.sol"; + +contract DODOIntegrationExternalUnwinderMainnetForkTest is Test { + address constant DODO_PMM_INTEGRATION_MAINNET = 0xa9F284eD010f4F7d7F8F201742b49b9f58e29b84; + address constant POOL_CWUSDC_USDC = 0x69776fc607e9edA8042e320e7e43f54d06c68f0E; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant CWUSDC = 0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a; + + DODOIntegrationExternalUnwinder internal unwinder; + + function setUp() public { + string memory rpcUrl = vm.envString("ETHEREUM_MAINNET_RPC"); + vm.createSelectFork(rpcUrl); + unwinder = new DODOIntegrationExternalUnwinder(DODO_PMM_INTEGRATION_MAINNET); + } + + function testFork_cWUSDCToUSDC_unwindsThroughMainnetDodoIntegration() public { + uint256 amountIn = 1_000_000; + deal(CWUSDC, address(this), amountIn); + IERC20(CWUSDC).approve(address(unwinder), amountIn); + + uint256 before = IERC20(USDC).balanceOf(address(this)); + uint256 amountOut = unwinder.unwind(CWUSDC, USDC, amountIn, 1, abi.encode(POOL_CWUSDC_USDC)); + uint256 afterBal = IERC20(USDC).balanceOf(address(this)); + + assertGt(amountOut, 0, "amountOut > 0"); + assertEq(afterBal - before, amountOut, "USDC received"); + } +} diff --git a/forkproof/test/DODOToUniswapV3MultiHopExternalUnwinderMainnetFork.t.sol b/forkproof/test/DODOToUniswapV3MultiHopExternalUnwinderMainnetFork.t.sol new file mode 100644 index 0000000..2484ed7 --- /dev/null +++ b/forkproof/test/DODOToUniswapV3MultiHopExternalUnwinderMainnetFork.t.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {DODOToUniswapV3MultiHopExternalUnwinder} from "../src/DODOToUniswapV3MultiHopExternalUnwinder.sol"; + +contract DODOToUniswapV3MultiHopExternalUnwinderMainnetForkTest is Test { + address constant DODO_PMM_INTEGRATION_MAINNET = 0xa9F284eD010f4F7d7F8F201742b49b9f58e29b84; + address constant UNISWAP_V3_ROUTER = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45; + address constant POOL_CWUSDC_USDT = 0xCC0fd27A40775c9AfcD2BBd3f7c902b0192c247A; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + address constant CWUSDC = 0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a; + + DODOToUniswapV3MultiHopExternalUnwinder internal unwinder; + + function setUp() public { + string memory rpcUrl = vm.envString("ETHEREUM_MAINNET_RPC"); + vm.createSelectFork(rpcUrl); + unwinder = new DODOToUniswapV3MultiHopExternalUnwinder(DODO_PMM_INTEGRATION_MAINNET, UNISWAP_V3_ROUTER); + } + + function testFork_cWUSDCToUSDC_multihopViaUSDT_works() public { + uint256 amountIn = 1_000_000; + deal(CWUSDC, address(this), amountIn); + IERC20(CWUSDC).approve(address(unwinder), amountIn); + + bytes memory path = abi.encodePacked(USDT, uint24(100), USDC); + bytes memory data = abi.encode(POOL_CWUSDC_USDT, USDT, uint256(1), path); + + uint256 before = IERC20(USDC).balanceOf(address(this)); + uint256 amountOut = unwinder.unwind(CWUSDC, USDC, amountIn, 1, data); + uint256 afterBal = IERC20(USDC).balanceOf(address(this)); + + assertGt(amountOut, 0, "amountOut > 0"); + assertEq(afterBal - before, amountOut, "USDC received"); + } +} diff --git a/forkproof/test/UniswapV3ExternalUnwinderMainnetFork.t.sol b/forkproof/test/UniswapV3ExternalUnwinderMainnetFork.t.sol new file mode 100644 index 0000000..acede48 --- /dev/null +++ b/forkproof/test/UniswapV3ExternalUnwinderMainnetFork.t.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {UniswapV3ExternalUnwinder} from "../src/UniswapV3ExternalUnwinder.sol"; + +interface IWETHFork { + function deposit() external payable; + function transfer(address to, uint256 value) external returns (bool); +} + +contract UniswapV3ExternalUnwinderMainnetForkTest is Test { + address constant UNISWAP_V3_ROUTER = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45; + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant CWUSDC = 0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a; + + UniswapV3ExternalUnwinder internal unwinder; + + function setUp() public { + string memory rpcUrl = vm.envString("ETHEREUM_MAINNET_RPC"); + vm.createSelectFork(rpcUrl); + unwinder = new UniswapV3ExternalUnwinder(UNISWAP_V3_ROUTER); + } + + function testFork_knownRoute_WETHToUSDC_singleHopWorks() public { + vm.deal(address(this), 1 ether); + IWETHFork(WETH).deposit{value: 1 ether}(); + IERC20(WETH).approve(address(unwinder), 1 ether); + uint256 before = IERC20(USDC).balanceOf(address(this)); + + uint256 amountOut = unwinder.unwind(WETH, USDC, 1 ether, 1, abi.encode(uint24(3000))); + + uint256 afterBal = IERC20(USDC).balanceOf(address(this)); + assertGt(amountOut, 0, "amountOut > 0"); + assertEq(afterBal - before, amountOut, "USDC received"); + } + + function testFork_cWUSDCToUSDC_routeUnavailableOnUniswapV3() public { + deal(CWUSDC, address(this), 1_000_000); + IERC20(CWUSDC).approve(address(unwinder), 1_000_000); + + vm.expectRevert(); + unwinder.unwind(CWUSDC, USDC, 1_000_000, 1, abi.encode(uint24(3000))); + } +} diff --git a/foundry.toml b/foundry.toml index a670802..c4bd174 100644 --- a/foundry.toml +++ b/foundry.toml @@ -38,6 +38,12 @@ optimizer = true optimizer_runs = 1 via_ir = false +[profile.fork] +optimizer = true +optimizer_runs = 1 +via_ir = true +evm_version = "cancun" + [profile.deploy] optimizer = true optimizer_runs = 100 diff --git a/hardhat.config.js b/hardhat.config.js index e0a65da..699406d 100644 --- a/hardhat.config.js +++ b/hardhat.config.js @@ -1,6 +1,29 @@ require("@nomicfoundation/hardhat-toolbox"); require("dotenv").config(); +function resolveAccounts() { + const rawPrivateKey = (process.env.PRIVATE_KEY || "").trim(); + + if (!rawPrivateKey) { + return []; + } + + const normalizedPrivateKey = rawPrivateKey.startsWith("0x") + ? rawPrivateKey + : `0x${rawPrivateKey}`; + + if (/^0x[0-9a-fA-F]{64}$/.test(normalizedPrivateKey)) { + return [normalizedPrivateKey]; + } + + console.warn( + "Ignoring PRIVATE_KEY in Hardhat config because it is not a valid 32-byte hex key." + ); + return []; +} + +const sharedAccounts = resolveAccounts(); + /** @type import('hardhat/config').HardhatUserConfig */ module.exports = { solidity: { @@ -15,57 +38,57 @@ module.exports = { }, mainnet: { url: process.env.ETHEREUM_MAINNET_RPC || "https://eth.llamarpc.com", - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + accounts: sharedAccounts, chainId: 1, }, chain138: { url: process.env.CHAIN138_RPC_URL || "https://rpc.d-bis.org", - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + accounts: sharedAccounts, chainId: 138, }, sepolia: { url: process.env.SEPOLIA_RPC_URL || "https://rpc.sepolia.org", - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + accounts: sharedAccounts, chainId: 11155111, }, bsc: { url: process.env.BSC_MAINNET_RPC || process.env.BSC_RPC_URL || "https://bsc-dataseed.binance.org", - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + accounts: sharedAccounts, chainId: 56, }, polygon: { url: process.env.POLYGON_MAINNET_RPC || "https://polygon-rpc.com", - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + accounts: sharedAccounts, chainId: 137, }, gnosis: { url: process.env.GNOSIS_RPC_URL || "https://rpc.gnosischain.com", - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + accounts: sharedAccounts, chainId: 100, }, cronos: { url: process.env.CRONOS_RPC_URL || "https://evm.cronos.org", - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + accounts: sharedAccounts, chainId: 25, }, optimism: { url: process.env.OPTIMISM_MAINNET_RPC || "https://mainnet.optimism.io", - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + accounts: sharedAccounts, chainId: 10, }, base: { url: process.env.BASE_MAINNET_RPC || "https://mainnet.base.org", - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + accounts: sharedAccounts, chainId: 8453, }, arbitrum: { url: process.env.ARBITRUM_MAINNET_RPC || "https://arb1.arbitrum.io/rpc", - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + accounts: sharedAccounts, chainId: 42161, }, avalanche: { url: process.env.AVALANCHE_RPC_URL || process.env.AVALANCHE_MAINNET_RPC || "https://api.avax.network/ext/bc/C/rpc", - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + accounts: sharedAccounts, chainId: 43114, }, }, diff --git a/package.json b/package.json index 69afaf5..407ca1a 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,16 @@ "description": "Production-grade CCIP integration for Chain-138 to Ethereum Mainnet", "scripts": { "compile": "hardhat compile", - "test": "hardhat test", + "test": "pnpm run test:ci", + "test:ci": "pnpm run test:contracts:ci && pnpm run test:services:ci", + "test:contracts:ci": "pnpm run test:contracts:transport && pnpm run test:contracts:ccip-smoke", + "test:contracts:transport": "bash ../scripts/verify/check-cstar-v2-transport-stack.sh", + "test:contracts:ccip-smoke": "npx hardhat test --no-compile test/ccip-integration/CCIPIntegration.test.js", + "test:services:ci": "pnpm run test:services:token-aggregation && pnpm run test:services:emoney-api", + "test:services:token-aggregation": "pnpm --dir services/token-aggregation run test:ci", + "test:services:emoney-api": "pnpm --dir test/emoney/api run test:ci", + "test:hardhat:full": "hardhat test", + "test:forge:full": "forge test", "forge:build": "forge build", "forge:test": "forge test", "forge:test:vault": "forge test --match-path 'test/vault/Ledger.t.sol'", diff --git a/script/DeployCCIPRelayRouterOnly.s.sol b/script/DeployCCIPRelayRouterOnly.s.sol new file mode 100644 index 0000000..7c6dcad --- /dev/null +++ b/script/DeployCCIPRelayRouterOnly.s.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Script, console} from "forge-std/Script.sol"; +import {CCIPRelayRouter} from "../contracts/relay/CCIPRelayRouter.sol"; + +contract DeployCCIPRelayRouterOnly is Script { + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + address relayer = vm.envAddress("RELAYER_ADDRESS"); + + console.log("Deploying CCIPRelayRouter with deployer:", deployer); + console.log("Relayer:", relayer); + + vm.startBroadcast(deployerPrivateKey); + + CCIPRelayRouter relayRouter = new CCIPRelayRouter(); + if (relayer != address(0)) { + relayRouter.grantRelayerRole(relayer); + } + + vm.stopBroadcast(); + + console.log("CCIPRelayRouter deployed at:", address(relayRouter)); + } +} diff --git a/script/DeployCWAssetReserveVerifier.s.sol b/script/DeployCWAssetReserveVerifier.s.sol new file mode 100644 index 0000000..60ff674 --- /dev/null +++ b/script/DeployCWAssetReserveVerifier.s.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {CWAssetReserveVerifier} from "../contracts/bridge/integration/CWAssetReserveVerifier.sol"; + +interface ICWMultiTokenBridgeL1AdminV2 { + function setReserveVerifier(address newVerifier) external; +} + +/** + * @title DeployCWAssetReserveVerifier + * @notice Deploy a generic reserve verifier for stable, monetary-unit, or gas-native c* lanes. + * + * Env: + * PRIVATE_KEY (required) + * CW_L1_BRIDGE or CHAIN138_L1_BRIDGE (required) + * CW_ASSET_RESERVE_VAULT (optional) + * CW_ASSET_RESERVE_SYSTEM (optional) + * CW_ATTACH_VERIFIER_TO_L1=1 (optional, default 0) + */ +contract DeployCWAssetReserveVerifier is Script { + function run() external { + uint256 privateKey = vm.envUint("PRIVATE_KEY"); + + address l1Bridge = vm.envOr("CW_L1_BRIDGE", address(0)); + if (l1Bridge == address(0)) { + l1Bridge = vm.envOr("CHAIN138_L1_BRIDGE", address(0)); + } + if (l1Bridge == address(0)) { + l1Bridge = vm.envOr("CW_L1_BRIDGE_CHAIN138", address(0)); + } + + address assetReserveVault = vm.envOr("CW_ASSET_RESERVE_VAULT", address(0)); + address reserveSystem = vm.envOr("CW_ASSET_RESERVE_SYSTEM", address(0)); + bool attachVerifierToBridge = vm.envOr("CW_ATTACH_VERIFIER_TO_L1", uint256(0)) == 1; + + require(l1Bridge != address(0), "CW_L1_BRIDGE or CHAIN138_L1_BRIDGE required"); + + vm.startBroadcast(privateKey); + + CWAssetReserveVerifier verifier = new CWAssetReserveVerifier( + vm.addr(privateKey), + l1Bridge, + assetReserveVault, + reserveSystem + ); + + if (attachVerifierToBridge) { + ICWMultiTokenBridgeL1AdminV2(l1Bridge).setReserveVerifier(address(verifier)); + } + + vm.stopBroadcast(); + + console.log("CWAssetReserveVerifier:", address(verifier)); + console.log(" L1 bridge:", l1Bridge); + console.log(" attached to bridge:", attachVerifierToBridge); + console.log(" asset reserve vault:", assetReserveVault); + console.log(" reserve system:", reserveSystem); + } +} diff --git a/script/DeployCWMultiTokenBridgeL1.s.sol b/script/DeployCWMultiTokenBridgeL1.s.sol new file mode 100644 index 0000000..9fb6d85 --- /dev/null +++ b/script/DeployCWMultiTokenBridgeL1.s.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Script, console} from "forge-std/Script.sol"; +import {CWMultiTokenBridgeL1} from "../contracts/bridge/CWMultiTokenBridgeL1.sol"; + +contract DeployCWMultiTokenBridgeL1 is Script { + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address sendRouter = vm.envAddress("CW_SEND_ROUTER"); + address receiveRouter = vm.envAddress("CW_RECEIVE_ROUTER"); + address feeToken = vm.envOr("CW_FEE_TOKEN", address(0)); + + vm.startBroadcast(deployerPrivateKey); + CWMultiTokenBridgeL1 bridge = new CWMultiTokenBridgeL1(sendRouter, receiveRouter, feeToken); + vm.stopBroadcast(); + + console.log("CWMultiTokenBridgeL1:", address(bridge)); + } +} diff --git a/script/DeployCWMultiTokenBridgeL2.s.sol b/script/DeployCWMultiTokenBridgeL2.s.sol new file mode 100644 index 0000000..a486f97 --- /dev/null +++ b/script/DeployCWMultiTokenBridgeL2.s.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Script, console} from "forge-std/Script.sol"; +import {CWMultiTokenBridgeL2} from "../contracts/bridge/CWMultiTokenBridgeL2.sol"; + +contract DeployCWMultiTokenBridgeL2 is Script { + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address sendRouter = vm.envAddress("CW_SEND_ROUTER"); + address receiveRouter = vm.envAddress("CW_RECEIVE_ROUTER"); + address feeToken = vm.envOr("CW_FEE_TOKEN", address(0)); + + vm.startBroadcast(deployerPrivateKey); + CWMultiTokenBridgeL2 bridge = new CWMultiTokenBridgeL2(sendRouter, receiveRouter, feeToken); + vm.stopBroadcast(); + + console.log("CWMultiTokenBridgeL2:", address(bridge)); + } +} diff --git a/script/DeployCWReserveVerifier.s.sol b/script/DeployCWReserveVerifier.s.sol new file mode 100644 index 0000000..52a6d3c --- /dev/null +++ b/script/DeployCWReserveVerifier.s.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {CWReserveVerifier} from "../contracts/bridge/integration/CWReserveVerifier.sol"; + +interface ICWMultiTokenBridgeL1Admin { + function setReserveVerifier(address newVerifier) external; +} + +/** + * @title DeployCWReserveVerifier + * @notice Deploy and optionally attach/configure the cW canonical reserve verifier. + * + * Env: + * PRIVATE_KEY (required) + * CW_L1_BRIDGE (required) + * CW_STABLECOIN_RESERVE_VAULT (optional) + * CW_RESERVE_SYSTEM (optional) + * CW_ATTACH_VERIFIER_TO_L1=1 (optional, default 1) + * CW_CANONICAL_USDT / CW_CANONICAL_USDC (optional) + * CW_USDT_RESERVE_ASSET / CW_USDC_RESERVE_ASSET (optional, used for reserve-system balance checks) + * CW_REQUIRE_VAULT_BACKING=1 (optional, defaults to 1 when vault address is set) + * CW_REQUIRE_RESERVE_SYSTEM_BALANCE=1 (optional, defaults to 1 when reserve system address is set) + * CW_REQUIRE_TOKEN_OWNER_MATCH_VAULT=1 (optional, defaults to 1 when vault address is set) + */ +contract DeployCWReserveVerifier is Script { + function run() external { + uint256 privateKey = vm.envUint("PRIVATE_KEY"); + address admin = vm.addr(privateKey); + + address l1Bridge = vm.envAddress("CW_L1_BRIDGE"); + address stablecoinReserveVault = vm.envOr("CW_STABLECOIN_RESERVE_VAULT", address(0)); + address reserveSystem = vm.envOr("CW_RESERVE_SYSTEM", address(0)); + + bool attachVerifierToBridge = vm.envOr("CW_ATTACH_VERIFIER_TO_L1", uint256(1)) == 1; + + bool defaultRequireVaultBacking = stablecoinReserveVault != address(0); + bool defaultRequireReserveSystemBalance = reserveSystem != address(0); + bool defaultRequireTokenOwnerMatchVault = stablecoinReserveVault != address(0); + + bool requireVaultBacking = vm.envOr("CW_REQUIRE_VAULT_BACKING", defaultRequireVaultBacking ? uint256(1) : uint256(0)) == 1; + bool requireReserveSystemBalance = + vm.envOr("CW_REQUIRE_RESERVE_SYSTEM_BALANCE", defaultRequireReserveSystemBalance ? uint256(1) : uint256(0)) == 1; + bool requireTokenOwnerMatchVault = + vm.envOr("CW_REQUIRE_TOKEN_OWNER_MATCH_VAULT", defaultRequireTokenOwnerMatchVault ? uint256(1) : uint256(0)) == 1; + + address canonicalUSDT = vm.envOr("CW_CANONICAL_USDT", address(0)); + address canonicalUSDC = vm.envOr("CW_CANONICAL_USDC", address(0)); + address usdtReserveAsset = vm.envOr("CW_USDT_RESERVE_ASSET", address(0)); + address usdcReserveAsset = vm.envOr("CW_USDC_RESERVE_ASSET", address(0)); + + vm.startBroadcast(privateKey); + + CWReserveVerifier verifier = new CWReserveVerifier( + admin, + l1Bridge, + stablecoinReserveVault, + reserveSystem + ); + + if (attachVerifierToBridge) { + ICWMultiTokenBridgeL1Admin(l1Bridge).setReserveVerifier(address(verifier)); + } + + if (canonicalUSDT != address(0)) { + verifier.configureToken( + canonicalUSDT, + usdtReserveAsset, + requireVaultBacking, + requireReserveSystemBalance && usdtReserveAsset != address(0), + requireTokenOwnerMatchVault + ); + } + + if (canonicalUSDC != address(0)) { + verifier.configureToken( + canonicalUSDC, + usdcReserveAsset, + requireVaultBacking, + requireReserveSystemBalance && usdcReserveAsset != address(0), + requireTokenOwnerMatchVault + ); + } + + vm.stopBroadcast(); + + console.log("CWReserveVerifier:", address(verifier)); + console.log(" L1 bridge:", l1Bridge); + console.log(" attached to bridge:", attachVerifierToBridge); + console.log(" stablecoin reserve vault:", stablecoinReserveVault); + console.log(" reserve system:", reserveSystem); + console.log(" requireVaultBacking:", requireVaultBacking); + console.log(" requireReserveSystemBalance:", requireReserveSystemBalance); + console.log(" requireTokenOwnerMatchVault:", requireTokenOwnerMatchVault); + console.log(" configured cUSDT:", canonicalUSDT); + console.log(" configured cUSDC:", canonicalUSDC); + } +} diff --git a/script/bridge/trustless/DeployChain138PilotDexVenues.s.sol b/script/bridge/trustless/DeployChain138PilotDexVenues.s.sol new file mode 100644 index 0000000..abc3d0c --- /dev/null +++ b/script/bridge/trustless/DeployChain138PilotDexVenues.s.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Script, console} from "forge-std/Script.sol"; +import "../../../contracts/bridge/trustless/EnhancedSwapRouterV2.sol"; +import "../../../contracts/bridge/trustless/RouteTypesV2.sol"; +import "../../../contracts/bridge/trustless/pilot/Chain138PilotDexVenues.sol"; + +contract DeployChain138PilotDexVenues is Script { + address constant CHAIN138_WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant CHAIN138_USDT = 0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1; + address constant CHAIN138_USDC = 0x71D6687F38b93CCad569Fa6352c876eea967201b; + address constant LIVE_ROUTER_V2 = 0xF1c93F54A5C2fc0d7766Ccb0Ad8f157DFB4C99Ce; + + uint24 constant UNISWAP_FEE = 3000; + uint256 constant BALANCER_FEE_BPS = 30; + uint256 constant CURVE_FEE_BPS = 4; + uint256 constant ONE_INCH_FEE_BPS = 35; + + bytes32 constant BALANCER_WETH_USDT_POOL_ID = keccak256("chain138-pilot-balancer-weth-usdt"); + bytes32 constant BALANCER_WETH_USDC_POOL_ID = keccak256("chain138-pilot-balancer-weth-usdc"); + + uint256 constant WETH_PAIR_WETH_AMOUNT = 100 ether; + uint256 constant WETH_PAIR_STABLE_AMOUNT = 210_000 * 1e6; + uint256 constant CURVE_STABLE_AMOUNT = 500_000 * 1e6; + + function run() external { + require(block.chainid == 138, "DeployChain138PilotDexVenues: Chain 138 only"); + + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + address payable routerV2 = payable(vm.envOr("ENHANCED_SWAP_ROUTER_V2_ADDRESS", LIVE_ROUTER_V2)); + + vm.startBroadcast(deployerPrivateKey); + + Chain138PilotUniswapV3Router uniswapRouter = new Chain138PilotUniswapV3Router(); + Chain138PilotBalancerVault balancerVault = new Chain138PilotBalancerVault(); + Chain138PilotCurve3Pool curve3Pool = new Chain138PilotCurve3Pool(CHAIN138_USDT, CHAIN138_USDC, address(0), CURVE_FEE_BPS); + Chain138PilotOneInchAggregationRouter oneInchRouter = new Chain138PilotOneInchAggregationRouter(); + + uint256 totalUsdtRequired = (WETH_PAIR_STABLE_AMOUNT * 3) + CURVE_STABLE_AMOUNT; + uint256 totalUsdcRequired = (WETH_PAIR_STABLE_AMOUNT * 3) + CURVE_STABLE_AMOUNT; + uint256 totalWethRequired = WETH_PAIR_WETH_AMOUNT * 6; + + _ensureMintedStable(CHAIN138_USDT, deployer, totalUsdtRequired); + _ensureMintedStable(CHAIN138_USDC, deployer, totalUsdcRequired); + _ensureWrappedWeth(deployer, totalWethRequired); + + _approveToken(CHAIN138_WETH, address(uniswapRouter), WETH_PAIR_WETH_AMOUNT * 2); + _approveToken(CHAIN138_USDT, address(uniswapRouter), WETH_PAIR_STABLE_AMOUNT); + _approveToken(CHAIN138_USDC, address(uniswapRouter), WETH_PAIR_STABLE_AMOUNT); + uniswapRouter.seedPair(CHAIN138_WETH, CHAIN138_USDT, UNISWAP_FEE, WETH_PAIR_WETH_AMOUNT, WETH_PAIR_STABLE_AMOUNT); + uniswapRouter.seedPair(CHAIN138_WETH, CHAIN138_USDC, UNISWAP_FEE, WETH_PAIR_WETH_AMOUNT, WETH_PAIR_STABLE_AMOUNT); + + _approveToken(CHAIN138_WETH, address(balancerVault), WETH_PAIR_WETH_AMOUNT * 2); + _approveToken(CHAIN138_USDT, address(balancerVault), WETH_PAIR_STABLE_AMOUNT); + _approveToken(CHAIN138_USDC, address(balancerVault), WETH_PAIR_STABLE_AMOUNT); + balancerVault.seedPool(BALANCER_WETH_USDT_POOL_ID, CHAIN138_WETH, CHAIN138_USDT, WETH_PAIR_WETH_AMOUNT, WETH_PAIR_STABLE_AMOUNT, BALANCER_FEE_BPS); + balancerVault.seedPool(BALANCER_WETH_USDC_POOL_ID, CHAIN138_WETH, CHAIN138_USDC, WETH_PAIR_WETH_AMOUNT, WETH_PAIR_STABLE_AMOUNT, BALANCER_FEE_BPS); + + _approveToken(CHAIN138_USDT, address(curve3Pool), CURVE_STABLE_AMOUNT); + _approveToken(CHAIN138_USDC, address(curve3Pool), CURVE_STABLE_AMOUNT); + curve3Pool.fund(CURVE_STABLE_AMOUNT, CURVE_STABLE_AMOUNT, 0); + + _approveToken(CHAIN138_WETH, address(oneInchRouter), WETH_PAIR_WETH_AMOUNT * 2); + _approveToken(CHAIN138_USDT, address(oneInchRouter), WETH_PAIR_STABLE_AMOUNT); + _approveToken(CHAIN138_USDC, address(oneInchRouter), WETH_PAIR_STABLE_AMOUNT); + oneInchRouter.seedRoute(CHAIN138_WETH, CHAIN138_USDT, WETH_PAIR_WETH_AMOUNT, WETH_PAIR_STABLE_AMOUNT, ONE_INCH_FEE_BPS); + oneInchRouter.seedRoute(CHAIN138_WETH, CHAIN138_USDC, WETH_PAIR_WETH_AMOUNT, WETH_PAIR_STABLE_AMOUNT, ONE_INCH_FEE_BPS); + + if (routerV2 != address(0)) { + EnhancedSwapRouterV2 router = EnhancedSwapRouterV2(routerV2); + bytes memory uniswapProviderData = abi.encode(bytes(""), UNISWAP_FEE, address(uniswapRouter), false); + bytes memory balancerUsdtProviderData = abi.encode(BALANCER_WETH_USDT_POOL_ID); + bytes memory balancerUsdcProviderData = abi.encode(BALANCER_WETH_USDC_POOL_ID); + bytes memory curveProviderData = abi.encode(int128(0), int128(1), false); + bytes memory oneInchProviderData = abi.encode(address(oneInchRouter), address(oneInchRouter), bytes("")); + + _setBidirectionalRoute(router, CHAIN138_WETH, CHAIN138_USDT, RouteTypesV2.Provider.UniswapV3, address(uniswapRouter), uniswapProviderData); + _setBidirectionalRoute(router, CHAIN138_WETH, CHAIN138_USDC, RouteTypesV2.Provider.UniswapV3, address(uniswapRouter), uniswapProviderData); + + _setBidirectionalRoute(router, CHAIN138_WETH, CHAIN138_USDT, RouteTypesV2.Provider.Balancer, address(balancerVault), balancerUsdtProviderData); + _setBidirectionalRoute(router, CHAIN138_WETH, CHAIN138_USDC, RouteTypesV2.Provider.Balancer, address(balancerVault), balancerUsdcProviderData); + + _setBidirectionalRoute(router, CHAIN138_USDT, CHAIN138_USDC, RouteTypesV2.Provider.Curve, address(curve3Pool), curveProviderData); + + _setBidirectionalRoute(router, CHAIN138_WETH, CHAIN138_USDT, RouteTypesV2.Provider.OneInch, address(oneInchRouter), oneInchProviderData); + _setBidirectionalRoute(router, CHAIN138_WETH, CHAIN138_USDC, RouteTypesV2.Provider.OneInch, address(oneInchRouter), oneInchProviderData); + router.setProviderEnabled(RouteTypesV2.Provider.OneInch, true); + } + + vm.stopBroadcast(); + + console.log("CHAIN138_PILOT_UNISWAP_V3_ROUTER:", address(uniswapRouter)); + console.log("CHAIN138_PILOT_UNISWAP_V3_QUOTER:", address(uniswapRouter)); + console.log("CHAIN138_PILOT_BALANCER_VAULT:", address(balancerVault)); + console.log("CHAIN138_PILOT_BALANCER_WETH_USDT_POOL_ID:"); + console.logBytes32(BALANCER_WETH_USDT_POOL_ID); + console.log("CHAIN138_PILOT_BALANCER_WETH_USDC_POOL_ID:"); + console.logBytes32(BALANCER_WETH_USDC_POOL_ID); + console.log("CHAIN138_PILOT_CURVE_3POOL:", address(curve3Pool)); + console.log("CHAIN138_PILOT_ONEINCH_ROUTER:", address(oneInchRouter)); + } + + function _ensureMintedStable(address token, address deployer, uint256 requiredBalance) internal { + uint256 currentBalance = IERC20(token).balanceOf(deployer); + if (currentBalance >= requiredBalance) { + return; + } + uint256 delta = requiredBalance - currentBalance; + (bool ok,) = token.call(abi.encodeWithSignature("mint(address,uint256)", deployer, delta)); + require(ok, "DeployChain138PilotDexVenues: stable mint failed"); + } + + function _ensureWrappedWeth(address deployer, uint256 requiredBalance) internal { + uint256 currentBalance = IERC20(CHAIN138_WETH).balanceOf(deployer); + if (currentBalance >= requiredBalance) { + return; + } + uint256 delta = requiredBalance - currentBalance; + (bool ok,) = CHAIN138_WETH.call{value: delta}(abi.encodeWithSignature("deposit()")); + require(ok, "DeployChain138PilotDexVenues: WETH deposit failed"); + } + + function _approveToken(address token, address spender, uint256 amount) internal { + IERC20(token).approve(spender, 0); + IERC20(token).approve(spender, amount); + } + + function _setBidirectionalRoute( + EnhancedSwapRouterV2 router, + address tokenA, + address tokenB, + RouteTypesV2.Provider provider, + address target, + bytes memory providerData + ) internal { + router.setProviderRoute(tokenA, tokenB, provider, target, providerData, true); + router.setProviderRoute(tokenB, tokenA, provider, target, providerData, true); + } +} diff --git a/script/bridge/trustless/DeployEnhancedSwapRouter.s.sol b/script/bridge/trustless/DeployEnhancedSwapRouter.s.sol index 2425065..22a2c53 100644 --- a/script/bridge/trustless/DeployEnhancedSwapRouter.s.sol +++ b/script/bridge/trustless/DeployEnhancedSwapRouter.s.sol @@ -29,14 +29,14 @@ contract DeployEnhancedSwapRouter is Script { address constant CHAIN138_USDC = 0x71D6687F38b93CCad569Fa6352c876eea967201b; address constant CHAIN138_DAI_PLACEHOLDER = 0x6B175474E89094C44Da98b954EedeAC495271d0F; - // Chain 138 live DODO pool map (2026-03-26) + // Chain 138 live DODO pool map (2026-04-02 corrected stable stack) address constant CHAIN138_cUSDT = 0x93E66202A11B1772E55407B32B44e5Cd8eda7f22; address constant CHAIN138_cUSDC = 0xf22258f57794CC8E06237084b353Ab30fFfa640b; address constant CHAIN138_cEURT = 0xdf4b71c61E5912712C1Bdd451416B9aC26949d72; address constant CHAIN138_cXAUC = 0x290E52a8819A4fbD0714E517225429aA2B70EC6b; - address constant CHAIN138_POOL_CUSDTCUSDC = 0xff8d3b8fDF7B112759F076B69f4271D4209C0849; - address constant CHAIN138_POOL_CUSDTUSDT = 0x6fc60DEDc92a2047062294488539992710b99D71; - address constant CHAIN138_POOL_CUSDCUSDC = 0x0309178Ae30302D83C76d6DD402a684ef3160eeC; + address constant CHAIN138_POOL_CUSDTCUSDC = 0x9e89bAe009adf128782E19e8341996c596ac40dC; + address constant CHAIN138_POOL_CUSDTUSDT = 0x866Cb44b59303d8dc5f4F9E3E7A8e8b0bf238d66; + address constant CHAIN138_POOL_CUSDCUSDC = 0xc39B7D0F40838cbFb54649d327f49a6DAC964062; address constant CHAIN138_POOL_CUSDT_XAU_PUBLIC = 0x1AA55E2001E5651349aFf5a63FD7a7ae44f0f1b0; address constant CHAIN138_POOL_CUSDC_XAU_PUBLIC = 0xEa9AC6357CaCB42a83b9082B870610363b177CbA; address constant CHAIN138_POOL_CEURT_XAU_PUBLIC = 0xba99bc1eAac164569d5aca96c806934dDaf970CF; @@ -171,6 +171,8 @@ contract DeployEnhancedSwapRouter is Script { _registerPair(router, CHAIN138_cUSDT, CHAIN138_cXAUC, CHAIN138_POOL_CUSDT_XAU_PUBLIC); _registerPair(router, CHAIN138_cUSDC, CHAIN138_cXAUC, CHAIN138_POOL_CUSDC_XAU_PUBLIC); _registerPair(router, CHAIN138_cEURT, CHAIN138_cXAUC, CHAIN138_POOL_CEURT_XAU_PUBLIC); + _registerOptionalEnvPool(router, "CHAIN138_POOL_WETH_USDT", CHAIN138_WETH, CHAIN138_USDT); + _registerOptionalEnvPool(router, "CHAIN138_POOL_WETH_USDC", CHAIN138_WETH, CHAIN138_USDC); if (dodoPmmProvider != address(0)) { router.setDodoLiquidityProvider(dodoPmmProvider); @@ -205,6 +207,20 @@ contract DeployEnhancedSwapRouter is Script { console.log("WARNING: current Chain 138 DODO initialization is primarily for token-to-token pair mappings via swapTokenToToken()."); } + function _registerOptionalEnvPool( + EnhancedSwapRouter router, + string memory envKey, + address tokenA, + address tokenB + ) internal { + address pool = vm.envOr(envKey, address(0)); + if (pool == address(0)) { + console.log("Optional pool env not set:", envKey); + return; + } + _registerPair(router, tokenA, tokenB, pool); + } + function _registerPair( EnhancedSwapRouter router, address tokenA, diff --git a/script/bridge/trustless/DeployEnhancedSwapRouterV2.s.sol b/script/bridge/trustless/DeployEnhancedSwapRouterV2.s.sol new file mode 100644 index 0000000..ff7e473 --- /dev/null +++ b/script/bridge/trustless/DeployEnhancedSwapRouterV2.s.sol @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Script, console} from "forge-std/Script.sol"; +import "../../../contracts/bridge/trustless/EnhancedSwapRouterV2.sol"; +import "../../../contracts/bridge/trustless/IntentBridgeCoordinatorV2.sol"; +import "../../../contracts/bridge/trustless/RouteTypesV2.sol"; +import "../../../contracts/bridge/trustless/adapters/DodoRouteExecutorAdapter.sol"; +import "../../../contracts/bridge/trustless/adapters/DodoV3RouteExecutorAdapter.sol"; +import "../../../contracts/bridge/trustless/adapters/UniswapV3RouteExecutorAdapter.sol"; +import "../../../contracts/bridge/trustless/adapters/BalancerRouteExecutorAdapter.sol"; +import "../../../contracts/bridge/trustless/adapters/CurveRouteExecutorAdapter.sol"; +import "../../../contracts/bridge/trustless/adapters/OneInchRouteExecutorAdapter.sol"; + +contract DeployEnhancedSwapRouterV2 is Script { + address constant CHAIN138_WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant CHAIN138_WETH10 = 0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9F; + address constant CHAIN138_USDT = 0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1; + address constant CHAIN138_USDC = 0x71D6687F38b93CCad569Fa6352c876eea967201b; + address constant CHAIN138_DAI_PLACEHOLDER = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + + address constant CHAIN138_cUSDT = 0x93E66202A11B1772E55407B32B44e5Cd8eda7f22; + address constant CHAIN138_cUSDC = 0xf22258f57794CC8E06237084b353Ab30fFfa640b; + address constant CHAIN138_cEURT = 0xdf4b71c61E5912712C1Bdd451416B9aC26949d72; + address constant CHAIN138_cXAUC = 0x290E52a8819A4fbD0714E517225429aA2B70EC6b; + + address constant CHAIN138_POOL_CUSDTCUSDC = 0x9e89bAe009adf128782E19e8341996c596ac40dC; + address constant CHAIN138_POOL_CUSDTUSDT = 0x866Cb44b59303d8dc5f4F9E3E7A8e8b0bf238d66; + address constant CHAIN138_POOL_CUSDCUSDC = 0xc39B7D0F40838cbFb54649d327f49a6DAC964062; + address constant CHAIN138_POOL_CUSDT_XAU_PUBLIC = 0x1AA55E2001E5651349aFf5a63FD7a7ae44f0f1b0; + address constant CHAIN138_POOL_CUSDC_XAU_PUBLIC = 0xEa9AC6357CaCB42a83b9082B870610363b177CbA; + address constant CHAIN138_POOL_CEURT_XAU_PUBLIC = 0xba99bc1eAac164569d5aca96c806934dDaf970CF; + address constant CHAIN138_D3_PROXY = 0xc9a11abB7C63d88546Be24D58a6d95e3762cB843; + address constant CHAIN138_D3_MM = 0x6550A3a59070061a262a893A1D6F3F490afFDBDA; + + function run() external { + require(block.chainid == 138, "DeployEnhancedSwapRouterV2: Chain 138 only"); + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + + address dodoProvider = vm.envOr("DODO_PMM_PROVIDER_ADDRESS", address(0)); + if (dodoProvider == address(0)) { + dodoProvider = vm.envOr("DODO_PMM_PROVIDER", address(0)); + } + require(dodoProvider != address(0), "DeployEnhancedSwapRouterV2: DODO PMM provider required"); + + address uniswapRouter = vm.envOr("UNISWAP_V3_ROUTER", address(0)); + address uniswapQuoter = vm.envOr("UNISWAP_QUOTER_ADDRESS", address(0)); + address balancerVault = vm.envOr("BALANCER_VAULT", address(0)); + address curvePool = vm.envOr("CURVE_3POOL", address(0)); + address oneInchRouter = vm.envOr("ONEINCH_ROUTER", address(0)); + address wethUsdtPool = vm.envOr("CHAIN138_POOL_WETH_USDT", address(0)); + address wethUsdcPool = vm.envOr("CHAIN138_POOL_WETH_USDC", address(0)); + uint24 uniswapWethUsdtFee = uint24(vm.envOr("UNISWAP_V3_WETH_USDT_FEE", uint256(3000))); + uint24 uniswapWethUsdcFee = uint24(vm.envOr("UNISWAP_V3_WETH_USDC_FEE", uint256(3000))); + bytes32 balancerWethUsdtPoolId = vm.envOr("BALANCER_WETH_USDT_POOL_ID", bytes32(0)); + bytes32 balancerWethUsdcPoolId = vm.envOr("BALANCER_WETH_USDC_POOL_ID", bytes32(0)); + address d3Proxy = vm.envOr("CHAIN138_D3_PROXY_ADDRESS", CHAIN138_D3_PROXY); + address d3Pool = vm.envOr("CHAIN138_D3_MM_ADDRESS", CHAIN138_D3_MM); + + console.log("=== EnhancedSwapRouterV2 Deployment ==="); + console.log("Deployer:", deployer); + console.log("DODO PMM provider:", dodoProvider); + console.log("UNISWAP_V3_ROUTER:", uniswapRouter); + console.log("UNISWAP_QUOTER_ADDRESS:", uniswapQuoter); + console.log("BALANCER_VAULT:", balancerVault); + console.log("CURVE_3POOL:", curvePool); + console.log("ONEINCH_ROUTER:", oneInchRouter); + console.log("CHAIN138_POOL_WETH_USDT:", wethUsdtPool); + console.log("CHAIN138_POOL_WETH_USDC:", wethUsdcPool); + console.log("CHAIN138_D3_PROXY_ADDRESS:", d3Proxy); + console.log("CHAIN138_D3_MM_ADDRESS:", d3Pool); + + vm.startBroadcast(deployerPrivateKey); + + DodoRouteExecutorAdapter dodoAdapter = new DodoRouteExecutorAdapter(); + DodoV3RouteExecutorAdapter dodoV3Adapter = new DodoV3RouteExecutorAdapter(); + UniswapV3RouteExecutorAdapter uniswapAdapter = new UniswapV3RouteExecutorAdapter(); + BalancerRouteExecutorAdapter balancerAdapter = new BalancerRouteExecutorAdapter(); + CurveRouteExecutorAdapter curveAdapter = new CurveRouteExecutorAdapter(); + OneInchRouteExecutorAdapter oneInchAdapter = new OneInchRouteExecutorAdapter(); + + EnhancedSwapRouterV2 router = new EnhancedSwapRouterV2( + CHAIN138_WETH, + CHAIN138_USDT, + CHAIN138_USDC, + CHAIN138_DAI_PLACEHOLDER + ); + IntentBridgeCoordinatorV2 coordinator = new IntentBridgeCoordinatorV2(address(router)); + + router.setProviderAdapter(RouteTypesV2.Provider.Dodo, address(dodoAdapter)); + router.setProviderAdapter(RouteTypesV2.Provider.DodoV3, address(dodoV3Adapter)); + router.setProviderAdapter(RouteTypesV2.Provider.UniswapV3, address(uniswapAdapter)); + router.setProviderAdapter(RouteTypesV2.Provider.Balancer, address(balancerAdapter)); + router.setProviderAdapter(RouteTypesV2.Provider.Curve, address(curveAdapter)); + router.setProviderAdapter(RouteTypesV2.Provider.OneInch, address(oneInchAdapter)); + + _setDodoPair(router, CHAIN138_cUSDT, CHAIN138_cUSDC, dodoProvider, CHAIN138_POOL_CUSDTCUSDC); + _setDodoPair(router, CHAIN138_cUSDT, CHAIN138_USDT, dodoProvider, CHAIN138_POOL_CUSDTUSDT); + _setDodoPair(router, CHAIN138_cUSDC, CHAIN138_USDC, dodoProvider, CHAIN138_POOL_CUSDCUSDC); + _setDodoPair(router, CHAIN138_cUSDT, CHAIN138_cXAUC, dodoProvider, CHAIN138_POOL_CUSDT_XAU_PUBLIC); + _setDodoPair(router, CHAIN138_cUSDC, CHAIN138_cXAUC, dodoProvider, CHAIN138_POOL_CUSDC_XAU_PUBLIC); + _setDodoPair(router, CHAIN138_cEURT, CHAIN138_cXAUC, dodoProvider, CHAIN138_POOL_CEURT_XAU_PUBLIC); + + if (wethUsdtPool != address(0)) { + _setDodoPair(router, CHAIN138_WETH, CHAIN138_USDT, dodoProvider, wethUsdtPool); + } + if (wethUsdcPool != address(0)) { + _setDodoPair(router, CHAIN138_WETH, CHAIN138_USDC, dodoProvider, wethUsdcPool); + } + + if (d3Proxy != address(0) && d3Pool != address(0)) { + _setDodoV3Pair(router, CHAIN138_WETH10, CHAIN138_USDT, d3Proxy, d3Pool); + } else { + router.setProviderEnabled(RouteTypesV2.Provider.DodoV3, false); + } + + if (uniswapRouter != address(0) && uniswapQuoter != address(0)) { + _setUniswapPair(router, CHAIN138_WETH, CHAIN138_USDT, uniswapRouter, uniswapQuoter, uniswapWethUsdtFee); + _setUniswapPair(router, CHAIN138_WETH, CHAIN138_USDC, uniswapRouter, uniswapQuoter, uniswapWethUsdcFee); + } else { + router.setProviderEnabled(RouteTypesV2.Provider.UniswapV3, false); + } + + if (balancerVault != address(0) && balancerWethUsdtPoolId != bytes32(0)) { + _setBalancerPair(router, CHAIN138_WETH, CHAIN138_USDT, balancerVault, balancerWethUsdtPoolId); + } + if (balancerVault != address(0) && balancerWethUsdcPoolId != bytes32(0)) { + _setBalancerPair(router, CHAIN138_WETH, CHAIN138_USDC, balancerVault, balancerWethUsdcPoolId); + } + if (balancerVault == address(0) || (balancerWethUsdtPoolId == bytes32(0) && balancerWethUsdcPoolId == bytes32(0))) { + router.setProviderEnabled(RouteTypesV2.Provider.Balancer, false); + } + + if (curvePool != address(0)) { + _setCurvePair(router, CHAIN138_USDT, CHAIN138_USDC, curvePool, 0, 1, false); + } else { + router.setProviderEnabled(RouteTypesV2.Provider.Curve, false); + } + + if (oneInchRouter == address(0)) { + router.setProviderEnabled(RouteTypesV2.Provider.OneInch, false); + } + + vm.stopBroadcast(); + + console.log("EnhancedSwapRouterV2:", address(router)); + console.log("IntentBridgeCoordinatorV2:", address(coordinator)); + console.log("DodoRouteExecutorAdapter:", address(dodoAdapter)); + console.log("DodoV3RouteExecutorAdapter:", address(dodoV3Adapter)); + console.log("UniswapV3RouteExecutorAdapter:", address(uniswapAdapter)); + console.log("BalancerRouteExecutorAdapter:", address(balancerAdapter)); + console.log("CurveRouteExecutorAdapter:", address(curveAdapter)); + console.log("OneInchRouteExecutorAdapter:", address(oneInchAdapter)); + } + + function _setDodoPair( + EnhancedSwapRouterV2 router, + address tokenA, + address tokenB, + address target, + address pool + ) internal { + bytes memory providerData = abi.encode(pool); + router.setProviderRoute(tokenA, tokenB, RouteTypesV2.Provider.Dodo, target, providerData, true); + router.setProviderRoute(tokenB, tokenA, RouteTypesV2.Provider.Dodo, target, providerData, true); + } + + function _setUniswapPair( + EnhancedSwapRouterV2 router, + address tokenA, + address tokenB, + address target, + address quoter, + uint24 fee + ) internal { + bytes memory providerData = abi.encode(bytes(""), fee, quoter, false); + router.setProviderRoute(tokenA, tokenB, RouteTypesV2.Provider.UniswapV3, target, providerData, true); + router.setProviderRoute(tokenB, tokenA, RouteTypesV2.Provider.UniswapV3, target, providerData, true); + } + + function _setDodoV3Pair( + EnhancedSwapRouterV2 router, + address tokenA, + address tokenB, + address target, + address pool + ) internal { + bytes memory providerData = abi.encode(pool); + router.setProviderRoute(tokenA, tokenB, RouteTypesV2.Provider.DodoV3, target, providerData, true); + router.setProviderRoute(tokenB, tokenA, RouteTypesV2.Provider.DodoV3, target, providerData, true); + } + + function _setBalancerPair( + EnhancedSwapRouterV2 router, + address tokenA, + address tokenB, + address target, + bytes32 poolId + ) internal { + bytes memory providerData = abi.encode(poolId); + router.setProviderRoute(tokenA, tokenB, RouteTypesV2.Provider.Balancer, target, providerData, true); + router.setProviderRoute(tokenB, tokenA, RouteTypesV2.Provider.Balancer, target, providerData, true); + } + + function _setCurvePair( + EnhancedSwapRouterV2 router, + address tokenA, + address tokenB, + address target, + int128 i, + int128 j, + bool useUnderlying + ) internal { + bytes memory providerData = abi.encode(i, j, useUnderlying); + router.setProviderRoute(tokenA, tokenB, RouteTypesV2.Provider.Curve, target, providerData, true); + router.setProviderRoute(tokenB, tokenA, RouteTypesV2.Provider.Curve, target, providerData, true); + } +} diff --git a/script/deploy/DeployAaveQuotePushFlashReceiver.s.sol b/script/deploy/DeployAaveQuotePushFlashReceiver.s.sol new file mode 100644 index 0000000..cae66f3 --- /dev/null +++ b/script/deploy/DeployAaveQuotePushFlashReceiver.s.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {AaveQuotePushFlashReceiver} from "../../contracts/flash/AaveQuotePushFlashReceiver.sol"; + +/** + * @title DeployAaveQuotePushFlashReceiver + * @notice Deploy the Aave V3 quote-push flash receiver. + * + * Env: + * PRIVATE_KEY required + * AAVE_POOL_ADDRESS optional; defaults to Aave V3 mainnet Pool + * + * Usage: + * forge script script/deploy/DeployAaveQuotePushFlashReceiver.s.sol:DeployAaveQuotePushFlashReceiver \ + * --rpc-url $ETHEREUM_MAINNET_RPC --broadcast -vvvv + */ +contract DeployAaveQuotePushFlashReceiver is Script { + address internal constant DEFAULT_AAVE_POOL_MAINNET = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2; + + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address pool = vm.envOr("AAVE_POOL_ADDRESS", DEFAULT_AAVE_POOL_MAINNET); + address deployer = vm.addr(pk); + + console.log("Deployer:", deployer); + console.log("Aave Pool:", pool); + + vm.startBroadcast(pk); + AaveQuotePushFlashReceiver receiver = new AaveQuotePushFlashReceiver(pool); + vm.stopBroadcast(); + + console.log("AaveQuotePushFlashReceiver:", address(receiver)); + } +} diff --git a/script/deploy/DeployAndStageCompliantFiatTokensV2ForChain.s.sol b/script/deploy/DeployAndStageCompliantFiatTokensV2ForChain.s.sol new file mode 100644 index 0000000..7769922 --- /dev/null +++ b/script/deploy/DeployAndStageCompliantFiatTokensV2ForChain.s.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {CompliantUSDTTokenV2} from "../../contracts/tokens/CompliantUSDTTokenV2.sol"; +import {CompliantUSDCTokenV2} from "../../contracts/tokens/CompliantUSDCTokenV2.sol"; +import {UniversalAssetRegistry} from "../../contracts/registry/UniversalAssetRegistry.sol"; + +/** + * @title DeployAndStageCompliantFiatTokensV2ForChain + * @notice Deploy fresh source-aligned cUSDT V2 / cUSDC V2 contracts, optionally wire governance/disclosure metadata, + * and stage them in UniversalAssetRegistry as version-aware GRU assets. + * + * Env: + * PRIVATE_KEY (required) + * INITIAL_OPERATOR (optional; default deployer) + * ADMIN (optional; default deployer / OWNER alias) + * OWNER (optional alias for ADMIN when ADMIN unset) + * INITIAL_SUPPLY (optional; default 0 for safe promotion) + * FORWARD_CANONICAL (optional; default true for promotion flow) + * GOVERNANCE_CONTROLLER (optional; when set, calls setGovernanceController on fresh deployments) + * UNIVERSAL_ASSET_REGISTRY (optional; when set and REGISTER_IN_GRU != 0, registers V2 assets) + * REGISTER_IN_GRU (optional; default 1) + * TOKEN_URI (optional generic fallback) + * REGULATORY_DISCLOSURE_URI (optional) + * REPORTING_URI (optional) + * CUSDT_V2_TOKEN_URI / CUSDC_V2_TOKEN_URI (optional; per-token overrides) + * CUSDT_V2_REGULATORY_DISCLOSURE_URI / CUSDC_V2_REGULATORY_DISCLOSURE_URI (optional; per-token overrides) + * CUSDT_V2_REPORTING_URI / CUSDC_V2_REPORTING_URI (optional; per-token overrides) + * DEPLOY_CUSDT_V2 / DEPLOY_CUSDC_V2 (optional; default both 1) + */ +contract DeployAndStageCompliantFiatTokensV2ForChain is Script { + uint256 internal constant DEFAULT_INITIAL_SUPPLY = 0; + + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + address initialOperator = vm.envOr("INITIAL_OPERATOR", deployer); + address ownerAlias = vm.envOr("OWNER", deployer); + address admin = vm.envOr("ADMIN", ownerAlias); + uint256 initialSupply = vm.envOr("INITIAL_SUPPLY", DEFAULT_INITIAL_SUPPLY); + bool forwardCanonical = vm.envOr("FORWARD_CANONICAL", true); + address governanceController = vm.envOr("GOVERNANCE_CONTROLLER", address(0)); + address registryAddr = vm.envOr("UNIVERSAL_ASSET_REGISTRY", address(0)); + bool registerInGru = vm.envOr("REGISTER_IN_GRU", uint256(1)) != 0; + string memory genericTokenURI = vm.envOr("TOKEN_URI", string("")); + string memory disclosureURI = vm.envOr("REGULATORY_DISCLOSURE_URI", string("")); + string memory reportingURI = vm.envOr("REPORTING_URI", string("")); + + vm.startBroadcast(pk); + + if (vm.envOr("DEPLOY_CUSDT_V2", uint256(1)) != 0) { + CompliantUSDTTokenV2 cusdtV2 = + new CompliantUSDTTokenV2(initialOperator, admin, initialSupply, forwardCanonical); + _postDeploy( + address(cusdtV2), + vm.envOr("CUSDT_V2_TOKEN_URI", genericTokenURI), + "cUSDT", + governanceController, + vm.envOr("CUSDT_V2_REGULATORY_DISCLOSURE_URI", disclosureURI), + vm.envOr("CUSDT_V2_REPORTING_URI", reportingURI), + registryAddr, + registerInGru, + "Tether USD (Compliant V2)", + "cUSDT.v2" + ); + console.log("cUSDT_V2", address(cusdtV2)); + } + + if (vm.envOr("DEPLOY_CUSDC_V2", uint256(1)) != 0) { + CompliantUSDCTokenV2 cusdcV2 = + new CompliantUSDCTokenV2(initialOperator, admin, initialSupply, forwardCanonical); + _postDeploy( + address(cusdcV2), + vm.envOr("CUSDC_V2_TOKEN_URI", genericTokenURI), + "cUSDC", + governanceController, + vm.envOr("CUSDC_V2_REGULATORY_DISCLOSURE_URI", disclosureURI), + vm.envOr("CUSDC_V2_REPORTING_URI", reportingURI), + registryAddr, + registerInGru, + "USD Coin (Compliant V2)", + "cUSDC.v2" + ); + console.log("cUSDC_V2", address(cusdcV2)); + } + + vm.stopBroadcast(); + } + + function _postDeploy( + address token, + string memory tokenURI, + string memory symbolDisplay, + address governanceController, + string memory disclosureURI, + string memory reportingURI, + address registryAddr, + bool registerInGru, + string memory name, + string memory versionedSymbol + ) internal { + if (bytes(tokenURI).length > 0 || bytes(symbolDisplay).length > 0) { + _setPresentationMetadata(token, tokenURI, symbolDisplay); + } + if (governanceController != address(0)) { + _setGovernanceController(token, governanceController); + } + if (bytes(disclosureURI).length > 0 || bytes(reportingURI).length > 0) { + _setDisclosureMetadata(token, disclosureURI, reportingURI); + } + if (registerInGru && registryAddr != address(0)) { + UniversalAssetRegistry(registryAddr).registerGRUCompliantAsset(token, name, versionedSymbol, 6, "International"); + } + } + + function _setPresentationMetadata(address token, string memory tokenURI, string memory symbolDisplay) internal { + (bool ok,) = token.call( + abi.encodeWithSignature( + "emergencySetPresentationMetadata(bool,string,string)", + true, + tokenURI, + symbolDisplay + ) + ); + require(ok, "emergencySetPresentationMetadata failed"); + } + + function _setGovernanceController(address token, address governanceController) internal { + (bool ok,) = token.call( + abi.encodeWithSignature("setGovernanceController(address)", governanceController) + ); + require(ok, "setGovernanceController failed"); + } + + function _setDisclosureMetadata(address token, string memory disclosureURI, string memory reportingURI) internal { + (bool ok,) = token.call( + abi.encodeWithSignature( + "emergencySetDisclosureMetadata(string,string)", + disclosureURI, + reportingURI + ) + ); + require(ok, "emergencySetDisclosureMetadata failed"); + } +} diff --git a/script/deploy/DeployAndStageGenericCompliantFiatTokenV2ForChain.s.sol b/script/deploy/DeployAndStageGenericCompliantFiatTokenV2ForChain.s.sol new file mode 100644 index 0000000..331cf90 --- /dev/null +++ b/script/deploy/DeployAndStageGenericCompliantFiatTokenV2ForChain.s.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {CompliantFiatTokenV2} from "../../contracts/tokens/CompliantFiatTokenV2.sol"; +import {UniversalAssetRegistry} from "../../contracts/registry/UniversalAssetRegistry.sol"; + +/** + * @title DeployAndStageGenericCompliantFiatTokenV2ForChain + * @notice Deploy a generic GRU c* V2 asset, optionally wire governance / disclosure metadata, + * and stage it in UniversalAssetRegistry. + * + * Env: + * PRIVATE_KEY (required) + * TOKEN_NAME (required) + * TOKEN_SYMBOL (required) + * CURRENCY_CODE (required) + * TOKEN_DECIMALS (optional; default 6) + * VERSION_TAG (optional; default "2") + * INITIAL_OPERATOR (optional; default deployer) + * ADMIN / OWNER (optional; default deployer) + * INITIAL_SUPPLY (optional; default 0) + * FORWARD_CANONICAL (optional; default true) + * GOVERNANCE_CONTROLLER (optional) + * UNIVERSAL_ASSET_REGISTRY (optional; when set and REGISTER_IN_GRU != 0, registers the asset) + * REGISTER_IN_GRU (optional; default 1) + * REGISTRY_NAME (optional; default TOKEN_NAME) + * REGISTRY_SYMBOL (optional; default TOKEN_SYMBOL.VERSION_TAG lower-suffix style is not enforced) + * TOKEN_URI / REGULATORY_DISCLOSURE_URI / REPORTING_URI (optional) + */ +contract DeployAndStageGenericCompliantFiatTokenV2ForChain is Script { + uint256 internal constant DEFAULT_INITIAL_SUPPLY = 0; + + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + address initialOperator = vm.envOr("INITIAL_OPERATOR", deployer); + address ownerAlias = vm.envOr("OWNER", deployer); + address admin = vm.envOr("ADMIN", ownerAlias); + uint256 initialSupply = vm.envOr("INITIAL_SUPPLY", DEFAULT_INITIAL_SUPPLY); + bool forwardCanonical = vm.envOr("FORWARD_CANONICAL", true); + address governanceController = vm.envOr("GOVERNANCE_CONTROLLER", address(0)); + address registryAddr = vm.envOr("UNIVERSAL_ASSET_REGISTRY", address(0)); + bool registerInGru = vm.envOr("REGISTER_IN_GRU", uint256(1)) != 0; + + string memory tokenName = vm.envString("TOKEN_NAME"); + string memory tokenSymbol = vm.envString("TOKEN_SYMBOL"); + string memory currencyCode = vm.envString("CURRENCY_CODE"); + uint8 tokenDecimals = uint8(vm.envOr("TOKEN_DECIMALS", uint256(6))); + string memory versionTag = vm.envOr("VERSION_TAG", string("2")); + string memory registryName = vm.envOr("REGISTRY_NAME", tokenName); + string memory registrySymbol = vm.envOr("REGISTRY_SYMBOL", tokenSymbol); + string memory tokenURI = vm.envOr("TOKEN_URI", string("")); + string memory disclosureURI = vm.envOr("REGULATORY_DISCLOSURE_URI", string("")); + string memory reportingURI = vm.envOr("REPORTING_URI", string("")); + + vm.startBroadcast(pk); + + CompliantFiatTokenV2 token = new CompliantFiatTokenV2( + tokenName, + tokenSymbol, + tokenDecimals, + currencyCode, + versionTag, + initialOperator, + admin, + initialSupply, + forwardCanonical + ); + + if (bytes(tokenURI).length > 0 || bytes(tokenSymbol).length > 0) { + _setPresentationMetadata(address(token), tokenURI, tokenSymbol); + } + if (governanceController != address(0)) { + _setGovernanceController(address(token), governanceController); + } + if (bytes(disclosureURI).length > 0 || bytes(reportingURI).length > 0) { + _setDisclosureMetadata(address(token), disclosureURI, reportingURI); + } + if (registerInGru && registryAddr != address(0)) { + UniversalAssetRegistry(registryAddr).registerGRUCompliantAsset( + address(token), + registryName, + registrySymbol, + tokenDecimals, + "International" + ); + } + + console.log("generic_cstar_v2", address(token)); + console.log("token_symbol", tokenSymbol); + console.log("currency_code", currencyCode); + + vm.stopBroadcast(); + } + + function _setPresentationMetadata(address token, string memory tokenURI, string memory symbolDisplay) internal { + (bool ok,) = token.call( + abi.encodeWithSignature( + "emergencySetPresentationMetadata(bool,string,string)", + true, + tokenURI, + symbolDisplay + ) + ); + require(ok, "emergencySetPresentationMetadata failed"); + } + + function _setGovernanceController(address token, address governanceController) internal { + (bool ok,) = token.call( + abi.encodeWithSignature("setGovernanceController(address)", governanceController) + ); + require(ok, "setGovernanceController failed"); + } + + function _setDisclosureMetadata(address token, string memory disclosureURI, string memory reportingURI) internal { + (bool ok,) = token.call( + abi.encodeWithSignature( + "emergencySetDisclosureMetadata(string,string)", + disclosureURI, + reportingURI + ) + ); + require(ok, "emergencySetDisclosureMetadata failed"); + } +} diff --git a/script/deploy/DeployCAUSDT.s.sol b/script/deploy/DeployCAUSDT.s.sol new file mode 100644 index 0000000..fa32284 --- /dev/null +++ b/script/deploy/DeployCAUSDT.s.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import {CREATE2Factory} from "../../contracts/utils/CREATE2Factory.sol"; +import {CompliantFiatToken} from "../../contracts/tokens/CompliantFiatToken.sol"; + +/** + * @title DeployCAUSDT + * @notice Deterministically deploy the Chain 138 cAUSDT contract via CREATE2. + * + * Env: + * PRIVATE_KEY (required) + * CREATE2_FACTORY_ADDRESS (required; CREATE2_FACTORY accepted as fallback) + * OWNER / ADMIN (optional; default deployer) + * INITIAL_SUPPLY_CAUSDT (optional; defaults to 1_000_000e6) + */ +contract DeployCAUSDT is Script { + uint8 constant DECIMALS = 6; + string constant SYMBOL = "cAUSDT"; + string constant NAME = "Alltra USD Token (Compliant)"; + string constant CURRENCY_CODE = "USD"; + + function run() external returns (address deployed) { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + address owner = vm.envOr("OWNER", deployer); + address admin = vm.envOr("ADMIN", deployer); + uint256 initialSupply = vm.envOr("INITIAL_SUPPLY_CAUSDT", uint256(1_000_000 * 10**6)); + + address factoryAddr = vm.envOr("CREATE2_FACTORY_ADDRESS", vm.envAddress("CREATE2_FACTORY")); + require(factoryAddr != address(0), "CREATE2 factory required"); + CREATE2Factory factory = CREATE2Factory(factoryAddr); + + uint256 salt = uint256(keccak256(abi.encodePacked("CompliantFiatToken.", SYMBOL))); + bytes memory bytecode = abi.encodePacked( + type(CompliantFiatToken).creationCode, + abi.encode(NAME, SYMBOL, uint8(DECIMALS), CURRENCY_CODE, owner, admin, initialSupply) + ); + + deployed = factory.computeAddress(bytecode, salt); + if (deployed.code.length > 0) { + console.log("cAUSDT already deployed", deployed); + return deployed; + } + + vm.startBroadcast(pk); + deployed = factory.deploy(bytecode, salt); + vm.stopBroadcast(); + + console.log("cAUSDT", deployed); + } +} diff --git a/script/deploy/DeployCWTokens.s.sol b/script/deploy/DeployCWTokens.s.sol index 238a23d..69625a8 100644 --- a/script/deploy/DeployCWTokens.s.sol +++ b/script/deploy/DeployCWTokens.s.sol @@ -12,7 +12,10 @@ import {CompliantWrappedToken} from "../../contracts/tokens/CompliantWrappedToke * Env: * PRIVATE_KEY (required) * CW_BRIDGE_ADDRESS (required) — address that can mint/burn (e.g. CCIP receiver or custom bridge) - * DEPLOY_CWUSDT=1, DEPLOY_CWUSDC=1, DEPLOY_CWEURC=1, ... (default all 1; set 0 to skip a token) + * CW_STRICT_MODE=1 (optional) — revoke deployer MINTER/BURNER after bridge grant + * CW_GOVERNANCE_ADMIN=0x... (optional) — grant DEFAULT_ADMIN_ROLE to governance; if strict, revoke deployer admin when governance is set + * CW_FREEZE_OPERATIONAL_ROLES=1 (optional) — freeze future MINTER/BURNER changes after setup + * DEPLOY_CWUSDT=1, DEPLOY_CWUSDC=1, DEPLOY_CWUSDW=1, DEPLOY_CWEURC=1, ... (default all 1; set 0 to skip a token) */ contract DeployCWTokens is Script { uint8 constant DECIMALS = 6; @@ -21,22 +24,27 @@ contract DeployCWTokens is Script { uint256 pk = vm.envUint("PRIVATE_KEY"); address deployer = vm.addr(pk); address bridge = vm.envAddress("CW_BRIDGE_ADDRESS"); + bool strictMode = vm.envOr("CW_STRICT_MODE", uint256(0)) == 1; + bool freezeOperationalRoles = vm.envOr("CW_FREEZE_OPERATIONAL_ROLES", uint256(0)) == 1; + address governanceAdmin = vm.envOr("CW_GOVERNANCE_ADMIN", address(0)); require(bridge != address(0), "CW_BRIDGE_ADDRESS required"); vm.startBroadcast(pk); - _deployOne(deployer, "Wrapped cUSDT", "cWUSDT", "DEPLOY_CWUSDT", bridge); - _deployOne(deployer, "Wrapped cUSDC", "cWUSDC", "DEPLOY_CWUSDC", bridge); - _deployOne(deployer, "Wrapped cEURC", "cWEURC", "DEPLOY_CWEURC", bridge); - _deployOne(deployer, "Wrapped cEURT", "cWEURT", "DEPLOY_CWEURT", bridge); - _deployOne(deployer, "Wrapped cGBPC", "cWGBPC", "DEPLOY_CWGBPC", bridge); - _deployOne(deployer, "Wrapped cGBPT", "cWGBPT", "DEPLOY_CWGBPT", bridge); - _deployOne(deployer, "Wrapped cAUDC", "cWAUDC", "DEPLOY_CWAUDC", bridge); - _deployOne(deployer, "Wrapped cJPYC", "cWJPYC", "DEPLOY_CWJPYC", bridge); - _deployOne(deployer, "Wrapped cCHFC", "cWCHFC", "DEPLOY_CWCHFC", bridge); - _deployOne(deployer, "Wrapped cCADC", "cWCADC", "DEPLOY_CWCADC", bridge); - _deployOne(deployer, "Wrapped cXAUC", "cWXAUC", "DEPLOY_CWXAUC", bridge); - _deployOne(deployer, "Wrapped cXAUT", "cWXAUT", "DEPLOY_CWXAUT", bridge); + _deployOne(deployer, "Wrapped cUSDT", "cWUSDT", "DEPLOY_CWUSDT", bridge, strictMode, governanceAdmin, freezeOperationalRoles); + _deployOne(deployer, "Wrapped cUSDC", "cWUSDC", "DEPLOY_CWUSDC", bridge, strictMode, governanceAdmin, freezeOperationalRoles); + _deployOne(deployer, "Wrapped cAUSDT", "cWAUSDT", "DEPLOY_CWAUSDT", bridge, strictMode, governanceAdmin, freezeOperationalRoles); + _deployOne(deployer, "Wrapped cUSDW", "cWUSDW", "DEPLOY_CWUSDW", bridge, strictMode, governanceAdmin, freezeOperationalRoles); + _deployOne(deployer, "Wrapped cEURC", "cWEURC", "DEPLOY_CWEURC", bridge, strictMode, governanceAdmin, freezeOperationalRoles); + _deployOne(deployer, "Wrapped cEURT", "cWEURT", "DEPLOY_CWEURT", bridge, strictMode, governanceAdmin, freezeOperationalRoles); + _deployOne(deployer, "Wrapped cGBPC", "cWGBPC", "DEPLOY_CWGBPC", bridge, strictMode, governanceAdmin, freezeOperationalRoles); + _deployOne(deployer, "Wrapped cGBPT", "cWGBPT", "DEPLOY_CWGBPT", bridge, strictMode, governanceAdmin, freezeOperationalRoles); + _deployOne(deployer, "Wrapped cAUDC", "cWAUDC", "DEPLOY_CWAUDC", bridge, strictMode, governanceAdmin, freezeOperationalRoles); + _deployOne(deployer, "Wrapped cJPYC", "cWJPYC", "DEPLOY_CWJPYC", bridge, strictMode, governanceAdmin, freezeOperationalRoles); + _deployOne(deployer, "Wrapped cCHFC", "cWCHFC", "DEPLOY_CWCHFC", bridge, strictMode, governanceAdmin, freezeOperationalRoles); + _deployOne(deployer, "Wrapped cCADC", "cWCADC", "DEPLOY_CWCADC", bridge, strictMode, governanceAdmin, freezeOperationalRoles); + _deployOne(deployer, "Wrapped cXAUC", "cWXAUC", "DEPLOY_CWXAUC", bridge, strictMode, governanceAdmin, freezeOperationalRoles); + _deployOne(deployer, "Wrapped cXAUT", "cWXAUT", "DEPLOY_CWXAUT", bridge, strictMode, governanceAdmin, freezeOperationalRoles); vm.stopBroadcast(); } @@ -46,12 +54,40 @@ contract DeployCWTokens is Script { string memory name, string memory symbol, string memory envKey, - address bridge + address bridge, + bool strictMode, + address governanceAdmin, + bool freezeOperationalRoles ) internal { if (vm.envOr(envKey, uint256(1)) == 0) return; CompliantWrappedToken t = new CompliantWrappedToken(name, symbol, DECIMALS, admin); t.grantRole(t.MINTER_ROLE(), bridge); t.grantRole(t.BURNER_ROLE(), bridge); + + if (strictMode) { + t.revokeRole(t.MINTER_ROLE(), admin); + t.revokeRole(t.BURNER_ROLE(), admin); + } + + if (governanceAdmin != address(0) && governanceAdmin != admin) { + t.grantRole(t.DEFAULT_ADMIN_ROLE(), governanceAdmin); + } + + if (freezeOperationalRoles) { + t.freezeOperationalRoles(); + } + + if (strictMode && governanceAdmin != address(0) && governanceAdmin != admin) { + t.revokeRole(t.DEFAULT_ADMIN_ROLE(), admin); + } + console.log(symbol, address(t)); + console.log(" strictMode", strictMode); + console.log(" governanceAdmin", governanceAdmin); + console.log(" operationalRolesFrozen", t.operationalRolesFrozen()); + console.log(" deployerHasMinter", t.hasRole(t.MINTER_ROLE(), admin)); + console.log(" deployerHasBurner", t.hasRole(t.BURNER_ROLE(), admin)); + console.log(" bridgeHasMinter", t.hasRole(t.MINTER_ROLE(), bridge)); + console.log(" bridgeHasBurner", t.hasRole(t.BURNER_ROLE(), bridge)); } } diff --git a/script/deploy/DeployCompliantFiatTokensV2ForChain.s.sol b/script/deploy/DeployCompliantFiatTokensV2ForChain.s.sol new file mode 100644 index 0000000..a59adaa --- /dev/null +++ b/script/deploy/DeployCompliantFiatTokensV2ForChain.s.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {CompliantUSDTTokenV2} from "../../contracts/tokens/CompliantUSDTTokenV2.sol"; +import {CompliantUSDCTokenV2} from "../../contracts/tokens/CompliantUSDCTokenV2.sol"; + +/** + * @title DeployCompliantFiatTokensV2ForChain + * @notice Deploy canonical cUSDT V2 / cUSDC V2 contracts to the current chain. + * @dev Defaults to safe pre-cutover posture: new addresses with forwardCanonical disabled unless env overrides it. + * + * Env: + * PRIVATE_KEY (required) + * INITIAL_OPERATOR (optional; default deployer) + * ADMIN (optional; default deployer) + * OWNER (optional alias for ADMIN when ADMIN unset) + * INITIAL_SUPPLY (optional; default 1_000_000e6) + * FORWARD_CANONICAL=1 to mark deployed V2 as forward canonical immediately + * DEPLOY_CUSDT_V2=1 / DEPLOY_CUSDC_V2=1 (default both 1) + */ +contract DeployCompliantFiatTokensV2ForChain is Script { + uint256 internal constant DEFAULT_INITIAL_SUPPLY = 1_000_000 * 10 ** 6; + + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + address initialOperator = vm.envOr("INITIAL_OPERATOR", deployer); + address ownerAlias = vm.envOr("OWNER", deployer); + address admin = vm.envOr("ADMIN", ownerAlias); + uint256 initialSupply = vm.envOr("INITIAL_SUPPLY", DEFAULT_INITIAL_SUPPLY); + bool forwardCanonical = vm.envOr("FORWARD_CANONICAL", false); + + vm.startBroadcast(pk); + + if (vm.envOr("DEPLOY_CUSDT_V2", uint256(1)) != 0) { + CompliantUSDTTokenV2 cusdtV2 = + new CompliantUSDTTokenV2(initialOperator, admin, initialSupply, forwardCanonical); + console.log("cUSDT_V2", address(cusdtV2)); + console.log("cUSDT_V2_admin", admin); + console.log("cUSDT_V2_initialOperator", initialOperator); + console.log("cUSDT_V2_forwardCanonical", forwardCanonical); + } + + if (vm.envOr("DEPLOY_CUSDC_V2", uint256(1)) != 0) { + CompliantUSDCTokenV2 cusdcV2 = + new CompliantUSDCTokenV2(initialOperator, admin, initialSupply, forwardCanonical); + console.log("cUSDC_V2", address(cusdcV2)); + console.log("cUSDC_V2_admin", admin); + console.log("cUSDC_V2_initialOperator", initialOperator); + console.log("cUSDC_V2_forwardCanonical", forwardCanonical); + } + + vm.stopBroadcast(); + } +} diff --git a/script/deploy/DeployCrossChainFlashInfrastructure.s.sol b/script/deploy/DeployCrossChainFlashInfrastructure.s.sol new file mode 100644 index 0000000..69e0c4b --- /dev/null +++ b/script/deploy/DeployCrossChainFlashInfrastructure.s.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {UniversalCCIPFlashBridgeAdapter} from "../../contracts/flash/UniversalCCIPFlashBridgeAdapter.sol"; +import {CrossChainFlashRepayReceiver} from "../../contracts/flash/CrossChainFlashRepayReceiver.sol"; +import {CrossChainFlashVaultCreditReceiver} from "../../contracts/flash/CrossChainFlashVaultCreditReceiver.sol"; + +/** + * @title DeployCrossChainFlashInfrastructure + * @notice Deploys the Chain 138 cross-chain flash adapter plus both CCIP receivers. + * + * Env: + * PRIVATE_KEY required + * FLASH_UNIVERSAL_CCIP_BRIDGE optional; fallback UNIVERSAL_CCIP_BRIDGE + * FLASH_CCIP_ROUTER optional default router for both receivers + * FLASH_REPAY_RECEIVER_ROUTER optional; fallback FLASH_CCIP_ROUTER / CCIP_ROUTER* + * FLASH_VAULT_CREDIT_ROUTER optional; fallback FLASH_CCIP_ROUTER / CCIP_ROUTER* + * CCIP_ROUTER optional fallback + * CCIP_ROUTER_ADDRESS optional fallback + * CCIP_ROUTER_CHAIN138 optional fallback + * + * Usage: + * forge script script/deploy/DeployCrossChainFlashInfrastructure.s.sol:DeployCrossChainFlashInfrastructure \ + * --rpc-url $RPC_URL_138 --broadcast --with-gas-price 1000000000 -vvvv + */ +contract DeployCrossChainFlashInfrastructure is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + + address universalBridge = vm.envOr("FLASH_UNIVERSAL_CCIP_BRIDGE", address(0)); + if (universalBridge == address(0)) { + universalBridge = vm.envOr("UNIVERSAL_CCIP_BRIDGE", address(0)); + } + require(universalBridge != address(0), "FLASH_UNIVERSAL_CCIP_BRIDGE or UNIVERSAL_CCIP_BRIDGE not set"); + + address router = vm.envOr("FLASH_CCIP_ROUTER", address(0)); + if (router == address(0)) { + router = vm.envOr("CCIP_ROUTER", address(0)); + } + if (router == address(0)) { + router = vm.envOr("CCIP_ROUTER_ADDRESS", address(0)); + } + if (router == address(0)) { + router = vm.envOr("CCIP_ROUTER_CHAIN138", address(0)); + } + require(router != address(0), "FLASH_CCIP_ROUTER or CCIP_ROUTER* not set"); + + address repayReceiverRouter = vm.envOr("FLASH_REPAY_RECEIVER_ROUTER", router); + address vaultCreditRouter = vm.envOr("FLASH_VAULT_CREDIT_ROUTER", router); + + console.log("Deployer:", deployer); + console.log("UniversalCCIPBridge:", universalBridge); + console.log("Repay receiver router:", repayReceiverRouter); + console.log("Vault credit receiver router:", vaultCreditRouter); + + vm.startBroadcast(pk); + + UniversalCCIPFlashBridgeAdapter adapter = new UniversalCCIPFlashBridgeAdapter(universalBridge); + CrossChainFlashRepayReceiver repayReceiver = new CrossChainFlashRepayReceiver(repayReceiverRouter); + CrossChainFlashVaultCreditReceiver vaultCreditReceiver = + new CrossChainFlashVaultCreditReceiver(vaultCreditRouter); + + vm.stopBroadcast(); + + console.log("UniversalCCIPFlashBridgeAdapter:", address(adapter)); + console.log("CrossChainFlashRepayReceiver:", address(repayReceiver)); + console.log("CrossChainFlashVaultCreditReceiver:", address(vaultCreditReceiver)); + console.log("Export: CROSS_CHAIN_FLASH_BRIDGE_ADAPTER=%s", vm.toString(address(adapter))); + console.log("Export: CROSS_CHAIN_FLASH_REPAY_RECEIVER=%s", vm.toString(address(repayReceiver))); + console.log( + "Export: CROSS_CHAIN_FLASH_VAULT_CREDIT_RECEIVER=%s", + vm.toString(address(vaultCreditReceiver)) + ); + } +} diff --git a/script/deploy/DeployGasCanonicalTokens.s.sol b/script/deploy/DeployGasCanonicalTokens.s.sol new file mode 100644 index 0000000..ea39447 --- /dev/null +++ b/script/deploy/DeployGasCanonicalTokens.s.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import {CompliantFiatToken} from "../../contracts/tokens/CompliantFiatToken.sol"; + +/** + * @title DeployGasCanonicalTokens + * @notice Deploy Wave 1 gas-native canonical c* tokens on Chain 138. + * + * Env: + * PRIVATE_KEY (required) + * GAS_FAMILY (optional) - deploy one family only: eth_mainnet, eth_l2, bnb, pol, avax, cro, xdai, celo, wemix + * GAS_INITIAL_OWNER (optional, defaults to deployer) + * GAS_ADMIN (optional, defaults to deployer) + * GAS_INITIAL_SUPPLY (optional, defaults to 0) + * DEPLOY_GAS_=0 to skip a family when GAS_FAMILY is unset + */ +contract DeployGasCanonicalTokens is Script { + uint8 internal constant DECIMALS = 18; + + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + address owner = vm.envOr("GAS_INITIAL_OWNER", deployer); + address admin = vm.envOr("GAS_ADMIN", deployer); + uint256 initialSupply = vm.envOr("GAS_INITIAL_SUPPLY", uint256(0)); + string memory targetFamily = vm.envOr("GAS_FAMILY", string("")); + + vm.startBroadcast(pk); + + _deployOne(owner, admin, initialSupply, targetFamily, "eth_mainnet", "DEPLOY_GAS_ETH_MAINNET", "Ethereum Mainnet Gas (Compliant)", "cETH", "ETH"); + _deployOne(owner, admin, initialSupply, targetFamily, "eth_l2", "DEPLOY_GAS_ETH_L2", "Ethereum L2 Gas (Compliant)", "cETHL2", "ETH"); + _deployOne(owner, admin, initialSupply, targetFamily, "bnb", "DEPLOY_GAS_BNB", "BNB Gas (Compliant)", "cBNB", "BNB"); + _deployOne(owner, admin, initialSupply, targetFamily, "pol", "DEPLOY_GAS_POL", "Polygon Gas (Compliant)", "cPOL", "POL"); + _deployOne(owner, admin, initialSupply, targetFamily, "avax", "DEPLOY_GAS_AVAX", "Avalanche Gas (Compliant)", "cAVAX", "AVAX"); + _deployOne(owner, admin, initialSupply, targetFamily, "cro", "DEPLOY_GAS_CRO", "Cronos Gas (Compliant)", "cCRO", "CRO"); + _deployOne(owner, admin, initialSupply, targetFamily, "xdai", "DEPLOY_GAS_XDAI", "Gnosis Gas (Compliant)", "cXDAI", "XDAI"); + _deployOne(owner, admin, initialSupply, targetFamily, "celo", "DEPLOY_GAS_CELO", "Celo Gas (Compliant)", "cCELO", "CELO"); + _deployOne(owner, admin, initialSupply, targetFamily, "wemix", "DEPLOY_GAS_WEMIX", "Wemix Gas (Compliant)", "cWEMIX", "WEMIX"); + + vm.stopBroadcast(); + } + + function _deployOne( + address owner, + address admin, + uint256 initialSupply, + string memory targetFamily, + string memory familyKey, + string memory envFlag, + string memory name, + string memory symbol, + string memory currencyCode + ) internal { + if (!_shouldDeploy(targetFamily, familyKey, envFlag)) return; + + CompliantFiatToken token = new CompliantFiatToken( + name, + symbol, + DECIMALS, + currencyCode, + owner, + admin, + initialSupply + ); + + console.log(symbol, address(token)); + console.log(" familyKey", familyKey); + console.log(" owner", owner); + console.log(" admin", admin); + console.log(" initialSupply", initialSupply); + } + + function _shouldDeploy( + string memory targetFamily, + string memory familyKey, + string memory envFlag + ) internal view returns (bool) { + if (bytes(targetFamily).length != 0) { + return keccak256(bytes(targetFamily)) == keccak256(bytes(familyKey)); + } + return vm.envOr(envFlag, uint256(1)) != 0; + } +} diff --git a/script/deploy/DeployQuotePushFlashWorkflowBorrower.s.sol b/script/deploy/DeployQuotePushFlashWorkflowBorrower.s.sol new file mode 100644 index 0000000..579e617 --- /dev/null +++ b/script/deploy/DeployQuotePushFlashWorkflowBorrower.s.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {QuotePushFlashWorkflowBorrower} from "../../contracts/flash/QuotePushFlashWorkflowBorrower.sol"; + +/** + * @title DeployQuotePushFlashWorkflowBorrower + * @notice Deploy the ERC-3156 quote-push borrower against a trusted flash lender. + * + * Env: + * PRIVATE_KEY required + * QUOTE_PUSH_FLASH_LENDER required + * + * Usage: + * forge script script/deploy/DeployQuotePushFlashWorkflowBorrower.s.sol:DeployQuotePushFlashWorkflowBorrower \ + * --rpc-url --broadcast -vvvv + */ +contract DeployQuotePushFlashWorkflowBorrower is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address lender = vm.envAddress("QUOTE_PUSH_FLASH_LENDER"); + address deployer = vm.addr(pk); + + console.log("Deployer:", deployer); + console.log("Trusted lender:", lender); + + vm.startBroadcast(pk); + QuotePushFlashWorkflowBorrower borrower = new QuotePushFlashWorkflowBorrower(lender); + vm.stopBroadcast(); + + console.log("QuotePushFlashWorkflowBorrower:", address(borrower)); + } +} diff --git a/script/deploy/DeploySimpleERC3156FlashVault.s.sol b/script/deploy/DeploySimpleERC3156FlashVault.s.sol new file mode 100644 index 0000000..2e9686b --- /dev/null +++ b/script/deploy/DeploySimpleERC3156FlashVault.s.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SimpleERC3156FlashVault} from "../../contracts/flash/SimpleERC3156FlashVault.sol"; + +/** + * @title DeploySimpleERC3156FlashVault + * @notice Deploy ERC-3156 flash vault for Chain 138; optional USDT whitelist + seed transfer from deployer. + * + * Env (broadcast): + * PRIVATE_KEY — required + * FLASH_VAULT_OWNER — optional; default: deployer + * FLASH_VAULT_FEE_BPS — optional; default: 5 (0.05%) + * FLASH_VAULT_TOKEN — optional; token to whitelist (default: official USDT Chain 138) + * FLASH_VAULT_SEED_AMOUNT — optional; raw token units to transfer from deployer into vault after deploy (0 = skip) + * + * Usage: + * forge script script/deploy/DeploySimpleERC3156FlashVault.s.sol:DeploySimpleERC3156FlashVault \ + * --rpc-url $RPC_URL_138 --broadcast -vvvv + */ +contract DeploySimpleERC3156FlashVault is Script { + /// @dev Canonical official USDT (Chain 138) per project config / explorer. + address internal constant DEFAULT_USDT_138 = 0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1; + + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + address vaultOwner = vm.envOr("FLASH_VAULT_OWNER", deployer); + uint256 feeBps = vm.envOr("FLASH_VAULT_FEE_BPS", uint256(5)); + address token = vm.envOr("FLASH_VAULT_TOKEN", DEFAULT_USDT_138); + uint256 seedAmount = vm.envOr("FLASH_VAULT_SEED_AMOUNT", uint256(0)); + + console.log("Deployer:", deployer); + console.log("Vault owner:", vaultOwner); + console.log("feeBps:", feeBps); + console.log("Whitelist token:", token); + + vm.startBroadcast(pk); + + SimpleERC3156FlashVault vault = new SimpleERC3156FlashVault(vaultOwner, feeBps); + console.log("SimpleERC3156FlashVault:", address(vault)); + + if (vaultOwner == deployer) { + vault.setTokenSupported(token, true); + console.log("setTokenSupported: true"); + if (seedAmount > 0) { + IERC20(token).transfer(address(vault), seedAmount); + console.log("Seeded vault (raw units):", seedAmount); + console.log("Vault balance:", IERC20(token).balanceOf(address(vault))); + } + } else { + console.log("Owner != deployer: owner must call setTokenSupported + seed separately."); + } + + vm.stopBroadcast(); + } +} diff --git a/script/deploy/DeploySingleCWToken.s.sol b/script/deploy/DeploySingleCWToken.s.sol new file mode 100644 index 0000000..cdf5503 --- /dev/null +++ b/script/deploy/DeploySingleCWToken.s.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import {CompliantWrappedToken} from "../../contracts/tokens/CompliantWrappedToken.sol"; + +/** + * @title DeploySingleCWToken + * @notice Deploy exactly one CompliantWrappedToken and grant MINTER/BURNER to the bridge. + * + * Env: + * PRIVATE_KEY (required) + * CW_BRIDGE_ADDRESS (required) + * CW_TOKEN_NAME (required) + * CW_TOKEN_SYMBOL (required) + * CW_TOKEN_DECIMALS (optional, default 6) + */ +contract DeploySingleCWToken is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address admin = vm.addr(pk); + address bridge = vm.envAddress("CW_BRIDGE_ADDRESS"); + string memory tokenName = vm.envString("CW_TOKEN_NAME"); + string memory tokenSymbol = vm.envString("CW_TOKEN_SYMBOL"); + uint8 decimals_ = uint8(vm.envOr("CW_TOKEN_DECIMALS", uint256(6))); + + require(bridge != address(0), "CW_BRIDGE_ADDRESS required"); + require(bytes(tokenName).length != 0, "CW_TOKEN_NAME required"); + require(bytes(tokenSymbol).length != 0, "CW_TOKEN_SYMBOL required"); + + vm.startBroadcast(pk); + + CompliantWrappedToken token = new CompliantWrappedToken(tokenName, tokenSymbol, decimals_, admin); + token.grantRole(token.MINTER_ROLE(), bridge); + token.grantRole(token.BURNER_ROLE(), bridge); + + vm.stopBroadcast(); + + console.log(tokenSymbol, address(token)); + console.log(" bridge", bridge); + console.log(" bridgeHasMinter", token.hasRole(token.MINTER_ROLE(), bridge)); + console.log(" bridgeHasBurner", token.hasRole(token.BURNER_ROLE(), bridge)); + } +} diff --git a/script/deploy/DeployUSDWPublicWrapVault.s.sol b/script/deploy/DeployUSDWPublicWrapVault.s.sol new file mode 100644 index 0000000..a1e8127 --- /dev/null +++ b/script/deploy/DeployUSDWPublicWrapVault.s.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {CompliantWrappedToken} from "../../contracts/tokens/CompliantWrappedToken.sol"; +import {USDWPublicWrapVault} from "../../contracts/bridge/integration/USDWPublicWrapVault.sol"; + +/** + * @title DeployUSDWPublicWrapVault + * @notice Deploy the native USDW <-> cWUSDW wrap vault for a public chain. + * @dev Use with an existing cWUSDW deployment on BSC or a newly deployed cWUSDW on Polygon. + * + * Env: + * PRIVATE_KEY (required) + * USDW_NATIVE_ADDRESS (required) // e.g. dwinUsdWinPublic.chains.56.usdwCurrent + * CWUSDW_ADDRESS (required) // cWUSDW contract on the current public chain + * USDW_WRAP_ADMIN (optional) // additional admin to grant after deployment + * USDW_WRAP_OPERATOR (optional) // reserve seeding operator; default admin/deployer + * USDW_WRAP_EMERGENCY_ADMIN (optional) + * USDW_WRAP_GRANT_TOKEN_ROLES=1 // grant MINTER_ROLE and BURNER_ROLE on cWUSDW to the vault + * USDW_WRAP_STRICT_ADMIN=1 // revoke deployer DEFAULT_ADMIN_ROLE after additional grants + */ +contract DeployUSDWPublicWrapVault is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + address nativeUsdw = vm.envAddress("USDW_NATIVE_ADDRESS"); + address wrappedUsdw = vm.envAddress("CWUSDW_ADDRESS"); + address admin = vm.envOr("USDW_WRAP_ADMIN", deployer); + address reserveOperator = vm.envOr("USDW_WRAP_OPERATOR", admin); + address emergencyAdmin = vm.envOr("USDW_WRAP_EMERGENCY_ADMIN", admin); + bool grantTokenRoles = vm.envOr("USDW_WRAP_GRANT_TOKEN_ROLES", uint256(0)) == 1; + bool strictAdmin = vm.envOr("USDW_WRAP_STRICT_ADMIN", uint256(0)) == 1; + + vm.startBroadcast(pk); + + USDWPublicWrapVault vault = new USDWPublicWrapVault(deployer, nativeUsdw, wrappedUsdw); + + if (admin != deployer) { + vault.grantRole(vault.DEFAULT_ADMIN_ROLE(), admin); + vault.grantRole(vault.RESERVE_OPERATOR_ROLE(), admin); + vault.grantRole(vault.EMERGENCY_ADMIN_ROLE(), admin); + } + if (reserveOperator != admin && reserveOperator != deployer) { + vault.grantRole(vault.RESERVE_OPERATOR_ROLE(), reserveOperator); + } + if (emergencyAdmin != admin && emergencyAdmin != deployer) { + vault.grantRole(vault.EMERGENCY_ADMIN_ROLE(), emergencyAdmin); + } + + if (grantTokenRoles) { + CompliantWrappedToken token = CompliantWrappedToken(wrappedUsdw); + token.grantRole(token.MINTER_ROLE(), address(vault)); + token.grantRole(token.BURNER_ROLE(), address(vault)); + } + + if (strictAdmin && admin != deployer) { + vault.revokeRole(vault.DEFAULT_ADMIN_ROLE(), deployer); + } + + console.log("USDWPublicWrapVault", address(vault)); + console.log(" nativeUsdw", nativeUsdw); + console.log(" wrappedUsdw", wrappedUsdw); + console.log(" deployer", deployer); + console.log(" admin", admin); + console.log(" reserveOperator", reserveOperator); + console.log(" emergencyAdmin", emergencyAdmin); + console.log(" grantTokenRoles", grantTokenRoles); + console.log(" strictAdmin", strictAdmin); + + vm.stopBroadcast(); + } +} diff --git a/script/deploy/RegisterGRUCompliantTokens.s.sol b/script/deploy/RegisterGRUCompliantTokens.s.sol index 3655071..c1417e8 100644 --- a/script/deploy/RegisterGRUCompliantTokens.s.sol +++ b/script/deploy/RegisterGRUCompliantTokens.s.sol @@ -18,6 +18,7 @@ contract RegisterGRUCompliantTokens is Script { _register(registry, vm.envOr("CUSDT_ADDRESS_138", address(0)), "Tether USD (Compliant)", "cUSDT"); _register(registry, vm.envOr("CUSDC_ADDRESS_138", address(0)), "USD Coin (Compliant)", "cUSDC"); + _register(registry, vm.envOr("CAUSDT_ADDRESS_138", address(0)), "Alltra USD Token (Compliant)", "cAUSDT"); _register(registry, vm.envOr("CEURC_ADDRESS_138", address(0)), "Euro Coin (Compliant)", "cEURC"); _register(registry, vm.envOr("CEURT_ADDRESS_138", address(0)), "Tether EUR (Compliant)", "cEURT"); _register(registry, vm.envOr("CGBPC_ADDRESS_138", address(0)), "Pound Sterling (Compliant)", "cGBPC"); diff --git a/script/deploy/RegisterGRUCompliantTokensV2.s.sol b/script/deploy/RegisterGRUCompliantTokensV2.s.sol new file mode 100644 index 0000000..23fe223 --- /dev/null +++ b/script/deploy/RegisterGRUCompliantTokensV2.s.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import {UniversalAssetRegistry} from "../../contracts/registry/UniversalAssetRegistry.sol"; + +/** + * @title RegisterGRUCompliantTokensV2 + * @notice Stage deployed c* V2 contracts in UniversalAssetRegistry using version-aware symbols. + * @dev Keeps live V1 symbols untouched while allowing indexers/operators to discover V2 addresses. + * Env: UNIVERSAL_ASSET_REGISTRY; optional CUSDT_V2_ADDRESS_138, CUSDC_V2_ADDRESS_138. + */ +contract RegisterGRUCompliantTokensV2 is Script { + function run() external { + address registryAddr = vm.envAddress("UNIVERSAL_ASSET_REGISTRY"); + UniversalAssetRegistry registry = UniversalAssetRegistry(registryAddr); + uint256 pk = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(pk); + + _register( + registry, + vm.envOr("CUSDT_V2_ADDRESS_138", address(0)), + "Tether USD (Compliant V2)", + "cUSDT.v2" + ); + _register( + registry, + vm.envOr("CUSDC_V2_ADDRESS_138", address(0)), + "USD Coin (Compliant V2)", + "cUSDC.v2" + ); + + vm.stopBroadcast(); + } + + function _register( + UniversalAssetRegistry registry, + address tokenAddr, + string memory name, + string memory symbol + ) internal { + if (tokenAddr == address(0)) return; + if (registry.isAssetActive(tokenAddr)) { + console.log("Skip (already registered):", symbol, vm.toString(tokenAddr)); + return; + } + registry.registerGRUCompliantAsset(tokenAddr, name, symbol, 6, "International"); + console.log("Registered GRU V2:", symbol, vm.toString(tokenAddr)); + } +} diff --git a/script/dex/AddLiquidityCUSDWCUSDCV2PoolChain138.s.sol b/script/dex/AddLiquidityCUSDWCUSDCV2PoolChain138.s.sol new file mode 100644 index 0000000..041f6f7 --- /dev/null +++ b/script/dex/AddLiquidityCUSDWCUSDCV2PoolChain138.s.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Script, console} from "forge-std/Script.sol"; +import {DODOPMMIntegration} from "../../contracts/dex/DODOPMMIntegration.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @title AddLiquidityCUSDWCUSDCV2PoolChain138 + * @notice Add liquidity to the Chain 138 cUSDW / cUSDC_V2 DODO PMM pool. + * @dev Env: PRIVATE_KEY, DODO_PMM_INTEGRATION or DODO_PMM_INTEGRATION_ADDRESS, + * CUSDW_ADDRESS_138, CUSDC_V2_ADDRESS_138, POOL_CUSDWCUSDCV2, + * ADD_LIQUIDITY_CUSDWCUSDCV2_BASE, ADD_LIQUIDITY_CUSDWCUSDCV2_QUOTE. + * Optional fallback amounts: + * - ADD_LIQUIDITY_BASE_AMOUNT + * - ADD_LIQUIDITY_QUOTE_AMOUNT + * Optional: NEXT_NONCE. + */ +contract AddLiquidityCUSDWCUSDCV2PoolChain138 is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address integrationAddr = vm.envAddress("DODO_PMM_INTEGRATION"); + if (integrationAddr == address(0)) { + integrationAddr = vm.envAddress("DODO_PMM_INTEGRATION_ADDRESS"); + } + require(integrationAddr != address(0), "DODO_PMM_INTEGRATION not set"); + + address cusdw = vm.envAddress("CUSDW_ADDRESS_138"); + address cusdcV2 = vm.envAddress("CUSDC_V2_ADDRESS_138"); + address pool = vm.envAddress("POOL_CUSDWCUSDCV2"); + require(cusdw != address(0), "CUSDW_ADDRESS_138 not set"); + require(cusdcV2 != address(0), "CUSDC_V2_ADDRESS_138 not set"); + require(pool != address(0), "POOL_CUSDWCUSDCV2 not set"); + + uint256 defaultBase = vm.envOr("ADD_LIQUIDITY_BASE_AMOUNT", uint256(0)); + uint256 defaultQuote = vm.envOr("ADD_LIQUIDITY_QUOTE_AMOUNT", uint256(0)); + uint256 baseAmount = vm.envOr("ADD_LIQUIDITY_CUSDWCUSDCV2_BASE", defaultBase); + uint256 quoteAmount = vm.envOr("ADD_LIQUIDITY_CUSDWCUSDCV2_QUOTE", defaultQuote); + require(baseAmount > 0, "ADD_LIQUIDITY_CUSDWCUSDCV2_BASE not set"); + require(quoteAmount > 0, "ADD_LIQUIDITY_CUSDWCUSDCV2_QUOTE not set"); + + address deployer = vm.addr(pk); + uint64 nextNonce = uint64(vm.envOr("NEXT_NONCE", uint256(0))); + if (nextNonce > 0) { + vm.setNonce(deployer, nextNonce); + } + + DODOPMMIntegration integration = DODOPMMIntegration(integrationAddr); + vm.startBroadcast(pk); + IERC20(cusdw).approve(address(integration), type(uint256).max); + IERC20(cusdcV2).approve(address(integration), type(uint256).max); + integration.addLiquidity(pool, baseAmount, quoteAmount); + console.log("Added liquidity to cUSDW/cUSDC_V2 pool:", pool); + vm.stopBroadcast(); + } +} diff --git a/script/dex/CreateCUSDCUSDCPool.s.sol b/script/dex/CreateCUSDCUSDCPool.s.sol index 0ca68d0..7eccc25 100644 --- a/script/dex/CreateCUSDCUSDCPool.s.sol +++ b/script/dex/CreateCUSDCUSDCPool.s.sol @@ -10,10 +10,10 @@ import {DODOPMMIntegration} from "../../contracts/dex/DODOPMMIntegration.sol"; * @dev Requires caller to have POOL_MANAGER_ROLE. Run with --broadcast. */ contract CreateCUSDCUSDCPool is Script { - uint256 constant LP_FEE_BPS = 3; - uint256 constant INITIAL_PRICE_1E18 = 1e18; - uint256 constant K_50PCT = 0.5e18; - bool constant USE_TWAP = true; + uint256 constant DEFAULT_LP_FEE_BPS = 10; + uint256 constant DEFAULT_INITIAL_PRICE_1E18 = 1e18; + uint256 constant DEFAULT_K_0PCT = 0; + bool constant DEFAULT_USE_TWAP = false; function run() external { uint256 pk = vm.envUint("PRIVATE_KEY"); @@ -30,12 +30,16 @@ contract CreateCUSDCUSDCPool is Script { } DODOPMMIntegration integration = DODOPMMIntegration(integrationAddr); + uint256 lpFeeBps = vm.envOr("DODO_LP_FEE_BPS", DEFAULT_LP_FEE_BPS); + uint256 initialPrice = vm.envOr("DODO_INITIAL_PRICE_1E18", DEFAULT_INITIAL_PRICE_1E18); + uint256 k = vm.envOr("DODO_K_FACTOR_1E18", DEFAULT_K_0PCT); + bool useTwap = vm.envOr("DODO_ENABLE_TWAP", DEFAULT_USE_TWAP); vm.startBroadcast(pk); address pool = integration.createCUSDCUSDCPool( - LP_FEE_BPS, - INITIAL_PRICE_1E18, - K_50PCT, - USE_TWAP + lpFeeBps, + initialPrice, + k, + useTwap ); console.log("cUSDC/USDC pool created at:", pool); vm.stopBroadcast(); diff --git a/script/dex/CreateCUSDTCUSDCPool.s.sol b/script/dex/CreateCUSDTCUSDCPool.s.sol index 398419d..8f2f8d8 100644 --- a/script/dex/CreateCUSDTCUSDCPool.s.sol +++ b/script/dex/CreateCUSDTCUSDCPool.s.sol @@ -10,10 +10,10 @@ import {DODOPMMIntegration} from "../../contracts/dex/DODOPMMIntegration.sol"; * @dev Requires caller to have POOL_MANAGER_ROLE. Run with --broadcast. */ contract CreateCUSDTCUSDCPool is Script { - uint256 constant LP_FEE_BPS = 3; - uint256 constant INITIAL_PRICE_1E18 = 1e18; - uint256 constant K_50PCT = 0.5e18; - bool constant USE_TWAP = true; + uint256 constant DEFAULT_LP_FEE_BPS = 10; + uint256 constant DEFAULT_INITIAL_PRICE_1E18 = 1e18; + uint256 constant DEFAULT_K_0PCT = 0; + bool constant DEFAULT_USE_TWAP = false; function run() external { uint256 pk = vm.envUint("PRIVATE_KEY"); @@ -30,12 +30,16 @@ contract CreateCUSDTCUSDCPool is Script { } DODOPMMIntegration integration = DODOPMMIntegration(integrationAddr); + uint256 lpFeeBps = vm.envOr("DODO_LP_FEE_BPS", DEFAULT_LP_FEE_BPS); + uint256 initialPrice = vm.envOr("DODO_INITIAL_PRICE_1E18", DEFAULT_INITIAL_PRICE_1E18); + uint256 k = vm.envOr("DODO_K_FACTOR_1E18", DEFAULT_K_0PCT); + bool useTwap = vm.envOr("DODO_ENABLE_TWAP", DEFAULT_USE_TWAP); vm.startBroadcast(pk); address pool = integration.createCUSDTCUSDCPool( - LP_FEE_BPS, - INITIAL_PRICE_1E18, - K_50PCT, - USE_TWAP + lpFeeBps, + initialPrice, + k, + useTwap ); console.log("cUSDT/cUSDC pool created at:", pool); vm.stopBroadcast(); diff --git a/script/dex/CreateCUSDTUSDTPool.s.sol b/script/dex/CreateCUSDTUSDTPool.s.sol index 6cd6022..d07a313 100644 --- a/script/dex/CreateCUSDTUSDTPool.s.sol +++ b/script/dex/CreateCUSDTUSDTPool.s.sol @@ -10,10 +10,10 @@ import {DODOPMMIntegration} from "../../contracts/dex/DODOPMMIntegration.sol"; * @dev Requires caller to have POOL_MANAGER_ROLE. Run with --broadcast. */ contract CreateCUSDTUSDTPool is Script { - uint256 constant LP_FEE_BPS = 3; - uint256 constant INITIAL_PRICE_1E18 = 1e18; - uint256 constant K_50PCT = 0.5e18; - bool constant USE_TWAP = true; + uint256 constant DEFAULT_LP_FEE_BPS = 10; + uint256 constant DEFAULT_INITIAL_PRICE_1E18 = 1e18; + uint256 constant DEFAULT_K_0PCT = 0; + bool constant DEFAULT_USE_TWAP = false; function run() external { uint256 pk = vm.envUint("PRIVATE_KEY"); @@ -30,12 +30,16 @@ contract CreateCUSDTUSDTPool is Script { } DODOPMMIntegration integration = DODOPMMIntegration(integrationAddr); + uint256 lpFeeBps = vm.envOr("DODO_LP_FEE_BPS", DEFAULT_LP_FEE_BPS); + uint256 initialPrice = vm.envOr("DODO_INITIAL_PRICE_1E18", DEFAULT_INITIAL_PRICE_1E18); + uint256 k = vm.envOr("DODO_K_FACTOR_1E18", DEFAULT_K_0PCT); + bool useTwap = vm.envOr("DODO_ENABLE_TWAP", DEFAULT_USE_TWAP); vm.startBroadcast(pk); address pool = integration.createCUSDTUSDTPool( - LP_FEE_BPS, - INITIAL_PRICE_1E18, - K_50PCT, - USE_TWAP + lpFeeBps, + initialPrice, + k, + useTwap ); console.log("cUSDT/USDT pool created at:", pool); vm.stopBroadcast(); diff --git a/script/dex/CreateCUSDWCUSDCV2Pool.s.sol b/script/dex/CreateCUSDWCUSDCV2Pool.s.sol new file mode 100644 index 0000000..40117ee --- /dev/null +++ b/script/dex/CreateCUSDWCUSDCV2Pool.s.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Script, console} from "forge-std/Script.sol"; +import {DODOPMMIntegration} from "../../contracts/dex/DODOPMMIntegration.sol"; + +/** + * @title CreateCUSDWCUSDCV2Pool + * @notice Create a Chain 138 DODO PMM pool for cUSDW / cUSDC_V2 using the generic createPool path. + * @dev Assumes repo-native D-WIN-aligned cUSDW on Chain 138 and staged cUSDC V2 as the quote leg. + * Env: PRIVATE_KEY, DODO_PMM_INTEGRATION or DODO_PMM_INTEGRATION_ADDRESS, + * CUSDW_ADDRESS_138, CUSDC_V2_ADDRESS_138. + * Optional params: + * - DODO_LP_FEE_BPS (default 3) + * - DODO_INITIAL_PRICE_1E18 (default 1e18) + * - DODO_K_FACTOR_1E18 (default 0.5e18) + * - DODO_ENABLE_TWAP (default true) + * - NEXT_NONCE + */ +contract CreateCUSDWCUSDCV2Pool is Script { + uint256 constant DEFAULT_LP_FEE_BPS = 3; + uint256 constant DEFAULT_INITIAL_PRICE_1E18 = 1e18; + uint256 constant DEFAULT_K_50PCT = 0.5e18; + bool constant DEFAULT_USE_TWAP = true; + + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address integrationAddr = vm.envAddress("DODO_PMM_INTEGRATION"); + if (integrationAddr == address(0)) { + integrationAddr = vm.envAddress("DODO_PMM_INTEGRATION_ADDRESS"); + } + require(integrationAddr != address(0), "DODO_PMM_INTEGRATION not set"); + + address cusdw = vm.envAddress("CUSDW_ADDRESS_138"); + address cusdcV2 = vm.envAddress("CUSDC_V2_ADDRESS_138"); + require(cusdw != address(0), "CUSDW_ADDRESS_138 not set"); + require(cusdcV2 != address(0), "CUSDC_V2_ADDRESS_138 not set"); + + uint256 lpFeeBps = vm.envOr("DODO_LP_FEE_BPS", DEFAULT_LP_FEE_BPS); + uint256 initialPrice = vm.envOr("DODO_INITIAL_PRICE_1E18", DEFAULT_INITIAL_PRICE_1E18); + uint256 kFactor = vm.envOr("DODO_K_FACTOR_1E18", DEFAULT_K_50PCT); + bool useTwap = vm.envOr("DODO_ENABLE_TWAP", DEFAULT_USE_TWAP); + + address deployer = vm.addr(pk); + uint64 nextNonce = uint64(vm.envOr("NEXT_NONCE", uint256(0))); + if (nextNonce > 0) { + vm.setNonce(deployer, nextNonce); + } + + DODOPMMIntegration integration = DODOPMMIntegration(integrationAddr); + vm.startBroadcast(pk); + address pool = integration.createPool( + cusdw, + cusdcV2, + lpFeeBps, + initialPrice, + kFactor, + useTwap + ); + console.log("cUSDW/cUSDC_V2 pool created at:", pool); + vm.stopBroadcast(); + } +} diff --git a/script/flash/TestOneUSDTFlash.s.sol b/script/flash/TestOneUSDTFlash.s.sol new file mode 100644 index 0000000..3bf2215 --- /dev/null +++ b/script/flash/TestOneUSDTFlash.s.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol"; +import {SimpleERC3156FlashVault} from "../../contracts/flash/SimpleERC3156FlashVault.sol"; +import {MinimalERC3156FlashBorrower} from "../../contracts/flash/MinimalERC3156FlashBorrower.sol"; + +/** + * @title TestOneUSDTFlash + * @notice Live / fork: deploy minimal borrower, pre-fund fee, execute 1 USDT flash against an existing vault. + * + * Env: + * PRIVATE_KEY — deployer (pays gas; seeds fee to borrower) + * FLASH_VAULT — deployed SimpleERC3156FlashVault + * FLASH_VAULT_TOKEN — optional; default official USDT Chain 138 + * FLASH_TEST_AMOUNT — optional raw amount (default 1e6 = 1 USDT with 6 decimals) + * + * Usage: + * forge script script/flash/TestOneUSDTFlash.s.sol:TestOneUSDTFlash --rpc-url $RPC_URL_138 --broadcast -vvvv + */ +contract TestOneUSDTFlash is Script { + address internal constant DEFAULT_USDT_138 = 0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1; + + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + address vaultAddr = vm.envAddress("FLASH_VAULT"); + address token = vm.envOr("FLASH_VAULT_TOKEN", DEFAULT_USDT_138); + uint256 amount = vm.envOr("FLASH_TEST_AMOUNT", uint256(1_000_000)); // 1 USDT (6 dp) + + SimpleERC3156FlashVault vault = SimpleERC3156FlashVault(vaultAddr); + require(!vault.borrowerAllowlistEnabled() || vault.owner() == deployer, "allowlist on: deployer must be vault owner"); + + uint256 fee = vault.previewFlashFee(token, amount); + uint256 vaultBal = IERC20(token).balanceOf(vaultAddr); + + console.log("Deployer:", deployer); + console.log("Vault:", vaultAddr); + console.log("Token:", token); + console.log("Amount (raw):", amount); + console.log("Fee (raw):", fee); + console.log("Vault token balance (raw):", vaultBal); + require(vaultBal >= amount, "vault liquidity < amount"); + + vm.startBroadcast(pk); + + MinimalERC3156FlashBorrower borrower = new MinimalERC3156FlashBorrower(vaultAddr); + console.log("MinimalERC3156FlashBorrower:", address(borrower)); + + if (vault.borrowerAllowlistEnabled()) { + vault.setBorrowerApproved(address(borrower), true); + } + + // Callback must return amount+fee; vault only credits `amount` before callback — need `fee` pre-funded on borrower. + IERC20(token).transfer(address(borrower), fee); + vault.flashLoan(IERC3156FlashBorrower(address(borrower)), token, amount, ""); + + vm.stopBroadcast(); + + console.log("Done. Vault balance after (raw):", IERC20(token).balanceOf(vaultAddr)); + } +} diff --git a/script/flash/TestScaledFlash.s.sol b/script/flash/TestScaledFlash.s.sol new file mode 100644 index 0000000..3f5cd2d --- /dev/null +++ b/script/flash/TestScaledFlash.s.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol"; +import {SimpleERC3156FlashVault} from "../../contracts/flash/SimpleERC3156FlashVault.sol"; +import {MinimalERC3156FlashBorrower} from "../../contracts/flash/MinimalERC3156FlashBorrower.sol"; + +/** + * @title TestScaledFlash + * @notice Sequential flashes: 1, 10, 100, 1000 USDT (6 decimals) against FLASH_VAULT. One borrower, fee prefunded per step. + * + * Env: PRIVATE_KEY, FLASH_VAULT; optional FLASH_VAULT_TOKEN (default official USDT 138). + * + * If vault.borrowerAllowlistEnabled(), deployer must be vault owner so the script can setBorrowerApproved. + * + * Usage: + * forge script script/flash/TestScaledFlash.s.sol:TestScaledFlash --rpc-url $RPC_URL_138 --broadcast -vvvv + */ +contract TestScaledFlash is Script { + address internal constant DEFAULT_USDT_138 = 0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1; + + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + address vaultAddr = vm.envAddress("FLASH_VAULT"); + address token = vm.envOr("FLASH_VAULT_TOKEN", DEFAULT_USDT_138); + + SimpleERC3156FlashVault vault = SimpleERC3156FlashVault(vaultAddr); + require(!vault.borrowerAllowlistEnabled() || vault.owner() == deployer, "allowlist on: deployer must be vault owner"); + + uint256[4] memory amounts = [ + uint256(1_000_000), + uint256(10_000_000), + uint256(100_000_000), + uint256(1_000_000_000) + ]; + + vm.startBroadcast(pk); + + MinimalERC3156FlashBorrower borrower = new MinimalERC3156FlashBorrower(vaultAddr); + console.log("Borrower:", address(borrower)); + + if (vault.borrowerAllowlistEnabled()) { + vault.setBorrowerApproved(address(borrower), true); + } + + for (uint256 i = 0; i < amounts.length; i++) { + uint256 amount = amounts[i]; + uint256 fee = vault.previewFlashFee(token, amount); + uint256 vb = IERC20(token).balanceOf(vaultAddr); + require(vb >= amount, "vault liquidity < amount"); + + console.log("--- step", i + 1); + console.log("amount (raw):", amount); + console.log("fee (raw):", fee); + console.log("vault before (raw):", vb); + + IERC20(token).transfer(address(borrower), fee); + vault.flashLoan(IERC3156FlashBorrower(address(borrower)), token, amount, ""); + + console.log("vault after (raw):", IERC20(token).balanceOf(vaultAddr)); + } + + vm.stopBroadcast(); + console.log("Scaled ladder complete."); + } +} diff --git a/scripts/bridge/fund-mainnet-relay-bridge.sh b/scripts/bridge/fund-mainnet-relay-bridge.sh index 8a2e4bf..6d2e79a 100755 --- a/scripts/bridge/fund-mainnet-relay-bridge.sh +++ b/scripts/bridge/fund-mainnet-relay-bridge.sh @@ -49,6 +49,8 @@ if [[ -z "$AMOUNT_WEI" ]]; then AMOUNT_WEI="$(cast call "$WETH_ADDRESS" "balanceOf(address)(uint256)" "$DEPLOYER" --rpc-url "$RPC_URL")" fi +AMOUNT_WEI="$(echo "$AMOUNT_WEI" | awk '{print $1}')" + if [[ -z "$AMOUNT_WEI" || "$AMOUNT_WEI" == "0" ]]; then if [[ "$DRY_RUN" == "1" ]]; then AMOUNT_WEI="1000000000000000" diff --git a/scripts/ccip/ccip-send.sh b/scripts/ccip/ccip-send.sh index e3f1d09..8e3f66c 100755 --- a/scripts/ccip/ccip-send.sh +++ b/scripts/ccip/ccip-send.sh @@ -35,17 +35,25 @@ fi case "$TOKEN" in weth9) - BRIDGE_ADDR="${CCIPWETH9_BRIDGE_MAINNET:?set in .env}" + BRIDGE_ADDR="${MAINNET_CCIP_WETH9_BRIDGE:-${CCIPWETH9_BRIDGE_MAINNET:-}}" TOKEN_ADDR="${WETH9_ADDRESS:?set in .env}" ;; weth10) - BRIDGE_ADDR="${CCIPWETH10_BRIDGE_MAINNET:?set in .env}" + BRIDGE_ADDR="${MAINNET_CCIP_WETH10_BRIDGE:-${CCIPWETH10_BRIDGE_MAINNET:-}}" TOKEN_ADDR="${WETH10_ADDRESS:?set in .env}" ;; *) log_error "Invalid --token: $TOKEN"; exit 1;; esac -FEE_TOKEN="${CCIP_FEE_TOKEN:?LINK address in .env}" +if [[ -z "$BRIDGE_ADDR" ]]; then + log_error "Mainnet bridge address is not set in .env" + exit 1 +fi + +if ! FEE_TOKEN="$(cast call "$BRIDGE_ADDR" 'feeToken()(address)' --rpc-url "$RPC_URL" 2>/dev/null | tail -n1)"; then + log_error "Could not resolve fee token from bridge $BRIDGE_ADDR" + exit 1 +fi log_section "CCIP SEND" log_info "Bridge: $BRIDGE_ADDR" @@ -57,6 +65,22 @@ log_info "Amount: $AMOUNT" DRY_RUN="${DRY_RUN:-0}" run() { if [ "$DRY_RUN" = "1" ]; then echo "[DRY RUN] $*"; else "$@"; fi } +trim_word() { echo "$1" | awk '{print $1}'; } + +calculate_fee_or_die() { + local out + if ! out="$(cast call "$BRIDGE_ADDR" "calculateFee(uint64,uint256)(uint256)" "$SELECTOR" "$AMOUNT" --rpc-url "$RPC_URL" 2>&1)"; then + log_error "Fee quote failed on source bridge $BRIDGE_ADDR for selector $SELECTOR." + log_error "This source bridge/router path is currently blocked until the quote/send path is repaired." + log_error "$(echo "$out" | tail -n 1)" + exit 1 + fi + trim_word "$out" +} + +log_subsection "Preflighting source fee quote" +FEE_QUOTE="$(calculate_fee_or_die)" +log_info "Quoted fee: $FEE_QUOTE" # Approve LINK fee to router via bridge (bridge approves downstream internally; here user approves bridge to pull fee) log_subsection "Approving LINK fee allowance to bridge" diff --git a/scripts/deployment/audit-funding-bootstrap-routes.sh b/scripts/deployment/audit-funding-bootstrap-routes.sh new file mode 100755 index 0000000..8731a18 --- /dev/null +++ b/scripts/deployment/audit-funding-bootstrap-routes.sh @@ -0,0 +1,321 @@ +#!/usr/bin/env bash +# Audit live cross-network funding routes for Chain 138 WETH9. +# +# This script distinguishes: +# - configured destination mappings on Chain 138 +# - practical first-hop routes that actually work with the current relay model +# - mainnet-hub destinations that become useful only after mainnet is funded +# +# Important: +# - Chain 138 uses a custom router that emits MessageSent events but does not +# natively deliver into public-chain bridges. +# - A native-mapped destination on Chain 138 is therefore configuration signal, +# not proof of a live direct route. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$PROJECT_ROOT" + +if [[ -f "$SCRIPT_DIR/../lib/deployment/dotenv.sh" ]]; then + # shellcheck disable=SC1090 + source "$SCRIPT_DIR/../lib/deployment/dotenv.sh" + load_deployment_env --repo-root "$PROJECT_ROOT" +elif [[ -f "$PROJECT_ROOT/.env" ]]; then + set -a + # shellcheck disable=SC1090 + source "$PROJECT_ROOT/.env" + set +a +fi + +need_var() { + local name="$1" + [[ -n "${!name:-}" ]] || { + echo "Error: $name is required" >&2 + exit 1 + } +} + +need_var PRIVATE_KEY +need_var RPC_URL_138 +need_var ETHEREUM_MAINNET_RPC +need_var CCIPWETH9_BRIDGE_CHAIN138 +need_var MAINNET_CCIP_WETH9_BRIDGE +need_var ETH_MAINNET_SELECTOR + +DEPLOYER="$(cast wallet address "$PRIVATE_KEY" 2>/dev/null || true)" +[[ -n "$DEPLOYER" ]] || { + echo "Error: could not derive deployer from PRIVATE_KEY" >&2 + exit 1 +} + +CHAIN138_SELECTOR_VALUE="${CHAIN138_SELECTOR:-138}" +CURRENT_138_WETH9="${CCIPWETH9_BRIDGE_CHAIN138,,}" +LEGACY_138_WETH9="${CCIPWETH9_BRIDGE_DIRECT_LEGACY:-0x971cD9D156f193df8051E48043C476e53ECd4693}" +LEGACY_138_WETH9="${LEGACY_138_WETH9,,}" +MAINNET_WETH9="$(cast call "$MAINNET_CCIP_WETH9_BRIDGE" 'weth9()(address)' --rpc-url "$ETHEREUM_MAINNET_RPC" | tail -n1)" +MAINNET_FEE_TOKEN="$(cast call "$MAINNET_CCIP_WETH9_BRIDGE" 'feeToken()(address)' --rpc-url "$ETHEREUM_MAINNET_RPC" | tail -n1)" + +fmt_ether() { + local raw="${1:-0}" + raw="$(echo "$raw" | awk '{print $1}')" + cast from-wei "$raw" ether 2>/dev/null || echo "$raw" +} + +contains_true() { + [[ "$1" == *", true)"* ]] +} + +extract_addr() { + echo "$1" | grep -oE '0x[a-fA-F0-9]{40}' | head -n1 | tr '[:upper:]' '[:lower:]' +} + +extract_enabled_addr() { + local raw="$1" + local addr + addr="$(extract_addr "$raw")" + if contains_true "$raw" && [[ -n "$addr" ]]; then + echo "$addr" + else + echo "" + fi +} + +query_dest() { + local rpc="$1" + local bridge="$2" + local selector="$3" + if [[ -z "$rpc" || -z "$bridge" || -z "$selector" ]]; then + echo "UNSET" + return 0 + fi + cast call "$bridge" 'destinations(uint64)((uint64,address,bool))' "$selector" --rpc-url "$rpc" 2>/dev/null || echo "ERROR" +} + +quote_fee_state() { + local rpc="$1" + local bridge="$2" + local selector="$3" + if [[ -z "$rpc" || -z "$bridge" || -z "$selector" ]]; then + echo "n/a" + return 0 + fi + if cast call "$bridge" 'calculateFee(uint64,uint256)(uint256)' "$selector" 4000000000000000 --rpc-url "$rpc" >/dev/null 2>&1; then + echo "quoted" + else + echo "blocked" + fi +} + +balance_of() { + local rpc="$1" + local token="$2" + local wallet="$3" + if [[ -z "$rpc" || -z "$token" || -z "$wallet" ]]; then + echo "0" + return 0 + fi + cast call "$token" 'balanceOf(address)(uint256)' "$wallet" --rpc-url "$rpc" 2>/dev/null | awk '{print $1}' || echo "0" +} + +native_balance() { + local rpc="$1" + if [[ -z "$rpc" ]]; then + echo "0" + return 0 + fi + cast balance "$DEPLOYER" --rpc-url "$rpc" 2>/dev/null || echo "0" +} + +configured_class() { + local source_addr="$1" + local expected_bridge="$2" + if [[ -z "${expected_bridge,,}" ]]; then + echo "deploy-first" + elif [[ "$source_addr" == "${expected_bridge,,}" ]]; then + echo "native-mapped" + elif [[ -n "$source_addr" ]]; then + echo "relay-or-other" + else + echo "disabled" + fi +} + +practical_route() { + local name="$1" + local hub_quote="$2" + case "$name" in + mainnet|bsc|avalanche) echo "relay-backed-first-hop" ;; + wemix) echo "deploy-first" ;; + *) + if [[ "$hub_quote" == "blocked" ]]; then + echo "mainnet-hub-blocked" + else + echo "via-mainnet-hub" + fi + ;; + esac +} + +relay_inventory_state() { + local balance="$1" + if [[ "$(echo "$balance > 0" | bc)" == "1" ]]; then + echo "present" + else + echo "empty" + fi +} + +detail_line() { + local name="$1" + local configured="$2" + local mainnet_to_remote="$3" + local mainnet_quote="$4" + local remote_to_138="$5" + local relay_balance="$6" + local source_addr="$7" + + local parts=() + case "$configured" in + native-mapped) parts+=("138 mapping matches native bridge") ;; + relay-or-other) parts+=("138 mapping points at non-native receiver $source_addr") ;; + deploy-first) parts+=("bridge not deployed in env") ;; + *) parts+=("138 mapping missing or disabled") ;; + esac + + if contains_true "$mainnet_to_remote"; then + if [[ "$mainnet_quote" == "blocked" ]]; then + parts+=("mainnet hub mapping enabled but source fee quote reverts") + else + parts+=("mainnet hub mapping enabled") + fi + else + parts+=("mainnet hub mapping missing or disabled") + fi + + local back_addr + back_addr="$(extract_enabled_addr "$remote_to_138")" + if [[ -n "$back_addr" ]]; then + if [[ "$back_addr" == "$CURRENT_138_WETH9" ]]; then + parts+=("return path points at current 138 bridge") + elif [[ "$back_addr" == "$LEGACY_138_WETH9" ]]; then + parts+=("return path still points at legacy 138 bridge") + else + parts+=("return path points at unexpected 138 bridge $back_addr") + fi + fi + + case "$(practical_route "$name" "$mainnet_quote")" in + relay-backed-first-hop) + parts+=("practical first hop") + if [[ -n "$relay_balance" ]]; then + parts+=("relay inventory $(relay_inventory_state "$relay_balance")") + fi + ;; + via-mainnet-hub) + parts+=("use after mainnet bootstrap") + ;; + mainnet-hub-blocked) + parts+=("mainnet WETH9 public fan-out currently blocked at source bridge/router") + ;; + deploy-first) + parts+=("deploy and seed before use") + ;; + esac + + local IFS='; ' + echo "${parts[*]}" +} + +echo "=== Chain 138 Funding Route Audit (WETH9 rail) ===" +echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" +echo "Deployer: $DEPLOYER" +echo "Current Chain 138 WETH9 bridge: $CURRENT_138_WETH9" +echo "Legacy Chain 138 WETH9 bridge: $LEGACY_138_WETH9" +echo "Mainnet WETH9 bridge: ${MAINNET_CCIP_WETH9_BRIDGE,,}" +echo "" + +printf '%-10s | %-14s | %-14s | %-14s | %-16s | %-16s | %-22s | %-14s\n' \ + "Chain" "Native" "Wrapped" "LINK" "138 mapping" "Mainnet hub" "Practical route" "Relay inv." +printf '%s\n' "--------------------------------------------------------------------------------------------------------------------------------------------" + +CHAINS=( + "mainnet|$ETHEREUM_MAINNET_RPC|$ETH_MAINNET_SELECTOR|$MAINNET_CCIP_WETH9_BRIDGE|$MAINNET_WETH9|$MAINNET_FEE_TOKEN|${CCIP_RELAY_BRIDGE_MAINNET:-}" + "bsc|$BSC_MAINNET_RPC|$BSC_SELECTOR|$CCIPWETH9_BRIDGE_BSC|$WETH9_BSC|$LINK_TOKEN_BSC|0x886C6A4ABC064dbf74E7caEc460b7eeC31F1b78C" + "avalanche|$AVALANCHE_MAINNET_RPC|$AVALANCHE_SELECTOR|$CCIPWETH9_BRIDGE_AVALANCHE|$WETH9_AVALANCHE|$LINK_TOKEN_AVALANCHE|0x3f8C409C6072a2B6a4Ff17071927bA70F80c725F" + "gnosis|$GNOSIS_RPC|$GNOSIS_SELECTOR|$CCIPWETH9_BRIDGE_GNOSIS|$WETH9_GNOSIS|$LINK_TOKEN_GNOSIS|" + "cronos|$CRONOS_RPC|$CRONOS_SELECTOR|$CCIPWETH9_BRIDGE_CRONOS|$WETH9_CRONOS|$LINK_TOKEN_CRONOS|" + "celo|$CELO_RPC|${CELO_SELECTOR:-1346049177634351622}|$CCIPWETH9_BRIDGE_CELO|$WETH9_CELO|$LINK_TOKEN_CELO|" + "polygon|$POLYGON_MAINNET_RPC|$POLYGON_SELECTOR|$CCIPWETH9_BRIDGE_POLYGON|$WETH9_POLYGON|$LINK_TOKEN_POLYGON|" + "arbitrum|$ARBITRUM_MAINNET_RPC|$ARBITRUM_SELECTOR|$CCIPWETH9_BRIDGE_ARBITRUM|$WETH9_ARBITRUM|$LINK_TOKEN_ARBITRUM|" + "optimism|$OPTIMISM_MAINNET_RPC|$OPTIMISM_SELECTOR|$CCIPWETH9_BRIDGE_OPTIMISM|$WETH9_OPTIMISM|$LINK_TOKEN_OPTIMISM|" + "base|$BASE_MAINNET_RPC|$BASE_SELECTOR|$CCIPWETH9_BRIDGE_BASE|$WETH9_BASE|$LINK_TOKEN_BASE|" + "wemix|$WEMIX_RPC|${WEMIX_SELECTOR:-5142893604156789321}|$CCIPWETH9_BRIDGE_WEMIX|$WETH9_WEMIX|$LINK_TOKEN_WEMIX|" +) + +for entry in "${CHAINS[@]}"; do + IFS='|' read -r name rpc selector expected_bridge wrapped_token link_token relay_bridge <<< "$entry" + + native_raw="$(native_balance "$rpc")" + wrapped_raw="$(balance_of "$rpc" "$wrapped_token" "$DEPLOYER")" + link_raw="$(balance_of "$rpc" "$link_token" "$DEPLOYER")" + + source_dest="$(query_dest "$RPC_URL_138" "$CCIPWETH9_BRIDGE_CHAIN138" "$selector")" + source_addr="$(extract_enabled_addr "$source_dest")" + mainnet_to_remote="$(query_dest "$ETHEREUM_MAINNET_RPC" "$MAINNET_CCIP_WETH9_BRIDGE" "$selector")" + mainnet_quote="n/a" + if [[ "$name" != "mainnet" ]]; then + mainnet_quote="$(quote_fee_state "$ETHEREUM_MAINNET_RPC" "$MAINNET_CCIP_WETH9_BRIDGE" "$selector")" + fi + mainnet_hub_flag="disabled" + if [[ "$name" == "mainnet" ]]; then + mainnet_hub_flag="n/a" + elif contains_true "$mainnet_to_remote"; then + mainnet_hub_flag="enabled/$mainnet_quote" + fi + + relay_inv="n/a" + if [[ -n "$relay_bridge" && -n "$wrapped_token" ]]; then + relay_inv="$(fmt_ether "$(balance_of "$rpc" "$wrapped_token" "$relay_bridge")")" + fi + + printf '%-10s | %-14s | %-14s | %-14s | %-16s | %-16s | %-22s | %-14s\n' \ + "$name" \ + "$(fmt_ether "$native_raw")" \ + "$(fmt_ether "$wrapped_raw")" \ + "$(fmt_ether "$link_raw")" \ + "$(configured_class "$source_addr" "$expected_bridge")" \ + "$mainnet_hub_flag" \ + "$(practical_route "$name" "$mainnet_quote")" \ + "$relay_inv" +done + +echo "" +echo "Detail Notes" +echo "------------" + +for entry in "${CHAINS[@]}"; do + IFS='|' read -r name rpc selector expected_bridge wrapped_token _link_token relay_bridge <<< "$entry" + source_dest="$(query_dest "$RPC_URL_138" "$CCIPWETH9_BRIDGE_CHAIN138" "$selector")" + source_addr="$(extract_enabled_addr "$source_dest")" + mainnet_to_remote="$(query_dest "$ETHEREUM_MAINNET_RPC" "$MAINNET_CCIP_WETH9_BRIDGE" "$selector")" + mainnet_quote="n/a" + if [[ "$name" != "mainnet" ]]; then + mainnet_quote="$(quote_fee_state "$ETHEREUM_MAINNET_RPC" "$MAINNET_CCIP_WETH9_BRIDGE" "$selector")" + fi + remote_to_138="$(query_dest "$rpc" "$expected_bridge" "$CHAIN138_SELECTOR_VALUE")" + relay_balance="" + if [[ -n "$relay_bridge" && -n "$wrapped_token" ]]; then + relay_balance="$(balance_of "$rpc" "$wrapped_token" "$relay_bridge")" + fi + echo "- $name: $(detail_line "$name" "$(configured_class "$source_addr" "$expected_bridge")" "$mainnet_to_remote" "$mainnet_quote" "$remote_to_138" "$relay_balance" "${source_addr:-disabled}")" +done + +echo "" +echo "Important" +echo "---------" +echo "- This audit intentionally uses the WETH9 rail. Live WETH10 destination mappings are currently drifted from env and the mainnet WETH10 bridge is not wired to the public-chain destinations." +echo "- 'native-mapped' means the live Chain 138 destination address matches the expected native bridge address." +echo "- Because Chain 138 uses a custom event-emitting router, 'native-mapped' is configuration signal only, not proof of a direct live first-hop route." +echo "- Practical first hops today are the relay-backed lanes. The other public chains are best treated as mainnet-hub destinations unless a relay is added for them." diff --git a/scripts/deployment/check-env-required.sh b/scripts/deployment/check-env-required.sh index 4a07171..316b79f 100755 --- a/scripts/deployment/check-env-required.sh +++ b/scripts/deployment/check-env-required.sh @@ -50,10 +50,28 @@ for k in PRIVATE_KEY RPC_URL RPC_URL_138; do check "$k" && echo " OK $k" || echo " MISS $k" done -# PRIVATE_KEY format: 64 hex chars (no value printed) +# PRIVATE_KEY format: 64 hex chars (no value printed). Use last assignment (dotenv override). Full value after first '='. if check "PRIVATE_KEY"; then - len=$(awk -F= '/^PRIVATE_KEY=/ { v=$2; gsub(/^0x/,"",v); print length(v) }' "$ENV_FILE" 2>/dev/null || echo "0") - [ "$len" = "64" ] && echo " PRIVATE_KEY format: 64-char hex" || echo " PRIVATE_KEY format: WARN (length=$len, expected 64)" + v="$(awk ' + /^PRIVATE_KEY=/ { v=$0; sub(/^PRIVATE_KEY=/, "", v); last=v } + /^export[ \t]+PRIVATE_KEY=/ { v=$0; sub(/^export[ \t]+PRIVATE_KEY=/, "", v); last=v } + END { print last } + ' "$ENV_FILE" 2>/dev/null || true)" + v="${v//$'\r'/}" + v="${v#\"}" + v="${v%\"}" + v="${v#\'}" + v="${v%\'}" + v="$(printf '%s' "$v" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + v="${v#0x}" + len=${#v} + if printf '%s' "$v" | grep -q '\${'; then + echo " PRIVATE_KEY format: SKIP (value references another var in file; length=$len — check resolved key after source)" + elif [ "$len" = "64" ] && printf '%s' "$v" | grep -qE '^[0-9a-fA-F]{64}$'; then + echo " PRIVATE_KEY format: 64-char hex" + else + echo " PRIVATE_KEY format: WARN (length=$len, expected 64 hex chars after optional 0x)" + fi fi echo "" diff --git a/scripts/deployment/complete-nonprefunded-avax-cutover.sh b/scripts/deployment/complete-nonprefunded-avax-cutover.sh new file mode 100755 index 0000000..2080eda --- /dev/null +++ b/scripts/deployment/complete-nonprefunded-avax-cutover.sh @@ -0,0 +1,937 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SMOM_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +PROJECT_ROOT="$(cd "$SMOM_ROOT/.." && pwd)" + +DRY_RUN=false +START_RELAYS=false + +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=true ;; + --start-relays) START_RELAYS=true ;; + *) + echo "Unknown option: $arg" >&2 + echo "Usage: $0 [--dry-run] [--start-relays]" >&2 + exit 1 + ;; + esac +done + +source "$SMOM_ROOT/scripts/load-env.sh" >/dev/null 2>&1 + +command -v cast >/dev/null 2>&1 || { echo "cast is required" >&2; exit 1; } +command -v forge >/dev/null 2>&1 || { echo "forge is required" >&2; exit 1; } +command -v jq >/dev/null 2>&1 || { echo "jq is required" >&2; exit 1; } + +DEPLOYER_ADDRESS="$(cast wallet address "$PRIVATE_KEY")" +CHAIN138_SEND_ROUTER="${CCIP_ROUTER_CHAIN138:-0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817}" +CHAIN138_RELAY_ROUTER="${CCIP_RELAY_ROUTER_CHAIN138:-}" +AVAX_SEND_ROUTER="${CCIP_AVALANCHE_ROUTER:-}" +AVAX_WETH_RELAY_ROUTER="${CCIP_RELAY_ROUTER_AVALANCHE:-0x2a0023Ad5ce1Ac6072B454575996DfFb1BB11b16}" +AVAX_RELAY_ROUTER="${CCIP_RELAY_ROUTER_AVALANCHE_CW:-${CCIP_RELAY_ROUTER_AVALANCHE:-}}" +AVAX_WETH_RELAY_BRIDGE="0x3f8C409C6072a2B6a4Ff17071927bA70F80c725F" +CHAIN138_L1_BRIDGE="${CW_L1_BRIDGE_CHAIN138:-}" +AVAX_CW_BRIDGE="${CW_BRIDGE_AVALANCHE:-}" +CW_RESERVE_VERIFIER="${CW_RESERVE_VERIFIER_CHAIN138:-}" +CW_STABLECOIN_RESERVE_VAULT_ADDR="${CW_STABLECOIN_RESERVE_VAULT:-${STABLECOIN_RESERVE_VAULT:-}}" +CW_RESERVE_SYSTEM_ADDR="${CW_RESERVE_SYSTEM:-${RESERVE_SYSTEM:-}}" +CW_CANONICAL_USDT_ADDR="${CW_CANONICAL_USDT:-${COMPLIANT_USDT_ADDRESS:-${CUSDT_ADDRESS_138:-}}}" +CW_CANONICAL_USDC_ADDR="${CW_CANONICAL_USDC:-${COMPLIANT_USDC_ADDRESS:-${CUSDC_ADDRESS_138:-}}}" +CW_USDT_RESERVE_ASSET_ADDR="${CW_USDT_RESERVE_ASSET:-${OFFICIAL_USDT_ADDRESS:-}}" +CW_USDC_RESERVE_ASSET_ADDR="${CW_USDC_RESERVE_ASSET:-${OFFICIAL_USDC_ADDRESS:-}}" +CW_MAX_OUTSTANDING_USDT_AVALANCHE="${CW_MAX_OUTSTANDING_USDT_AVALANCHE:-}" +CW_MAX_OUTSTANDING_USDC_AVALANCHE="${CW_MAX_OUTSTANDING_USDC_AVALANCHE:-}" +DRY_RUN_AVAX_SEND_ROUTER="0x0000000000000000000000000000000000004311" +DRY_RUN_AVAX_RELAY_ROUTER="0x0000000000000000000000000000000000004313" +DRY_RUN_CHAIN138_RELAY_ROUTER="0x0000000000000000000000000000000000000138" +DRY_RUN_CHAIN138_L1_BRIDGE="0x0000000000000000000000000000000000001138" +DRY_RUN_AVAX_CW_BRIDGE="0x0000000000000000000000000000000000004312" +DRY_RUN_CHAIN138_RESERVE_VERIFIER="0x0000000000000000000000000000000000001139" +LOCAL_RELAY_ROUTER_BYTECODE="" +AVAX_WETH9_ADDRESS="${WETH9_AVALANCHE:-0xa4B9DD039565AeD9641D45b57061f99d9cA6Df08}" +ZERO_ADDRESS="0x0000000000000000000000000000000000000000" + +log() { + printf '[avax-cutover] %s\n' "$*" +} + +redact_secrets() { + local text="$1" + if [[ -n "${PRIVATE_KEY:-}" ]]; then + text="${text//${PRIVATE_KEY}/\$PRIVATE_KEY}" + fi + printf '%s\n' "$text" +} + +format_command() { + printf '%q ' "$@" + printf '\n' +} + +run_or_echo() { + local cmd="$*" + if $DRY_RUN; then + redact_secrets "$cmd" + else + eval "$cmd" + fi +} + +run_command() { + if $DRY_RUN; then + redact_secrets "$(format_command "$@")" + else + "$@" + fi +} + +is_truthy() { + case "${1:-}" in + 1|true|TRUE|yes|YES|on|ON) return 0 ;; + *) return 1 ;; + esac +} + +resolve_bool() { + local value="${1:-}" + local fallback="${2:-false}" + if [[ -z "$value" ]]; then + printf '%s\n' "$fallback" + return + fi + if is_truthy "$value"; then + printf 'true\n' + else + printf 'false\n' + fi +} + +bool_to_uint() { + if [[ "${1:-false}" == "true" ]]; then + printf '1\n' + else + printf '0\n' + fi +} + +DEFAULT_REQUIRE_VAULT_BACKING=false +if [[ -n "$CW_STABLECOIN_RESERVE_VAULT_ADDR" ]]; then + DEFAULT_REQUIRE_VAULT_BACKING=true +fi + +DEFAULT_REQUIRE_RESERVE_SYSTEM_BALANCE=false +if [[ -n "$CW_RESERVE_SYSTEM_ADDR" ]]; then + DEFAULT_REQUIRE_RESERVE_SYSTEM_BALANCE=true +fi + +DEFAULT_REQUIRE_TOKEN_OWNER_MATCH_VAULT=false +if [[ -n "$CW_STABLECOIN_RESERVE_VAULT_ADDR" ]]; then + DEFAULT_REQUIRE_TOKEN_OWNER_MATCH_VAULT=true +fi + +CW_ATTACH_VERIFIER_TO_L1_BOOL="$(resolve_bool "${CW_ATTACH_VERIFIER_TO_L1:-}" "true")" +CW_REQUIRE_VAULT_BACKING_BOOL="$(resolve_bool "${CW_REQUIRE_VAULT_BACKING:-}" "$DEFAULT_REQUIRE_VAULT_BACKING")" +CW_REQUIRE_RESERVE_SYSTEM_BALANCE_BOOL="$(resolve_bool "${CW_REQUIRE_RESERVE_SYSTEM_BALANCE:-}" "$DEFAULT_REQUIRE_RESERVE_SYSTEM_BALANCE")" +CW_REQUIRE_TOKEN_OWNER_MATCH_VAULT_BOOL="$(resolve_bool "${CW_REQUIRE_TOKEN_OWNER_MATCH_VAULT:-}" "$DEFAULT_REQUIRE_TOKEN_OWNER_MATCH_VAULT")" +CW_FREEZE_AVAX_L2_CONFIG_BOOL="$(resolve_bool "${CW_FREEZE_AVAX_L2_CONFIG:-}" "false")" + +has_code() { + local address="$1" + local rpc="$2" + [[ -n "$address" ]] || return 1 + local code + code="$(cast code "$address" --rpc-url "$rpc" 2>/dev/null || true)" + [[ -n "$code" && "$code" != "0x" ]] +} + +contract_call_succeeds() { + local address="$1" + local rpc="$2" + shift 2 + cast call "$address" "$@" --rpc-url "$rpc" >/dev/null 2>&1 +} + +is_ccip_router_contract() { + local address="$1" + local rpc="$2" + has_code "$address" "$rpc" || return 1 + contract_call_succeeds "$address" "$rpc" "feeToken()(address)" && + contract_call_succeeds "$address" "$rpc" "admin()(address)" && + contract_call_succeeds "$address" "$rpc" "supportedChains(uint64)(bool)" 138 +} + +is_relay_router_contract() { + local address="$1" + local rpc="$2" + has_code "$address" "$rpc" || return 1 + contract_call_succeeds "$address" "$rpc" "RELAYER_ROLE()(bytes32)" && + contract_call_succeeds "$address" "$rpc" "authorizedBridges(address)(bool)" "$AVAX_WETH_RELAY_BRIDGE" +} + +get_local_relay_router_bytecode() { + if [[ -n "$LOCAL_RELAY_ROUTER_BYTECODE" ]]; then + printf '%s\n' "$LOCAL_RELAY_ROUTER_BYTECODE" + return + fi + + LOCAL_RELAY_ROUTER_BYTECODE="$( + cd "$SMOM_ROOT" && + forge inspect contracts/relay/CCIPRelayRouter.sol:CCIPRelayRouter deployedBytecode 2>/dev/null | tr '[:upper:]' '[:lower:]' + )" + printf '%s\n' "$LOCAL_RELAY_ROUTER_BYTECODE" +} + +is_current_relay_router_contract() { + local address="$1" + local rpc="$2" + is_relay_router_contract "$address" "$rpc" || return 1 + + local local_code deployed_code + local_code="$(get_local_relay_router_bytecode)" + deployed_code="$(cast code "$address" --rpc-url "$rpc" 2>/dev/null | tr '[:upper:]' '[:lower:]')" + + [[ -n "$local_code" && -n "$deployed_code" && "$local_code" == "$deployed_code" ]] +} + +is_cw_l1_bridge() { + local address="$1" + local rpc="$2" + has_code "$address" "$rpc" || return 1 + contract_call_succeeds "$address" "$rpc" "sendRouter()(address)" && + contract_call_succeeds "$address" "$rpc" "receiveRouter()(address)" && + contract_call_succeeds "$address" "$rpc" "feeToken()(address)" && + contract_call_succeeds "$address" "$rpc" "admin()(address)" && + contract_call_succeeds "$address" "$rpc" "supportedCanonicalToken(address)(bool)" "$COMPLIANT_USDT_ADDRESS" && + contract_call_succeeds "$address" "$rpc" "maxOutstanding(address,uint64)(uint256)" "$COMPLIANT_USDT_ADDRESS" "$AVALANCHE_SELECTOR" && + contract_call_succeeds "$address" "$rpc" "reserveVerifier()(address)" && + contract_call_succeeds "$address" "$rpc" "destinations(address,uint64)((address,bool))" "$COMPLIANT_USDT_ADDRESS" "$AVALANCHE_SELECTOR" && + ! contract_call_succeeds "$address" "$rpc" "canonicalToMirrored(address)(address)" "$COMPLIANT_USDT_ADDRESS" +} + +is_cw_l2_bridge() { + local address="$1" + local rpc="$2" + has_code "$address" "$rpc" || return 1 + contract_call_succeeds "$address" "$rpc" "sendRouter()(address)" && + contract_call_succeeds "$address" "$rpc" "receiveRouter()(address)" && + contract_call_succeeds "$address" "$rpc" "feeToken()(address)" && + contract_call_succeeds "$address" "$rpc" "admin()(address)" && + contract_call_succeeds "$address" "$rpc" "tokenPairFrozen(address)(bool)" "$COMPLIANT_USDT_ADDRESS" && + contract_call_succeeds "$address" "$rpc" "destinationFrozen(uint64)(bool)" 138 && + contract_call_succeeds "$address" "$rpc" "destinations(uint64)((address,bool))" 138 && + contract_call_succeeds "$address" "$rpc" "canonicalToMirrored(address)(address)" "$COMPLIANT_USDT_ADDRESS" +} + +is_reserve_verifier_contract() { + local address="$1" + local rpc="$2" + has_code "$address" "$rpc" || return 1 + contract_call_succeeds "$address" "$rpc" "bridge()(address)" && + contract_call_succeeds "$address" "$rpc" "OPERATOR_ROLE()(bytes32)" +} + +send_cast() { + local rpc="$1" + local to="$2" + local signature="$3" + shift 3 + + local cmd=(cast send) + local gas_price + gas_price="$(cast gas-price --rpc-url "$rpc" 2>/dev/null || true)" + if [[ "$gas_price" =~ ^[0-9]+$ && "$gas_price" -gt 0 ]]; then + gas_price=$((gas_price + gas_price / 5 + 1000000)) + cmd+=(--gas-price "$gas_price") + fi + + cmd+=(--rpc-url "$rpc" --private-key "$PRIVATE_KEY" --legacy "$to" "$signature") + if [[ "$#" -gt 0 ]]; then + cmd+=("$@") + fi + + run_command "${cmd[@]}" +} + +set_env_value() { + local file="$1" + local key="$2" + local value="$3" + local escaped_value + escaped_value="$(printf '%s' "$value" | sed -e 's/[\\/&]/\\&/g')" + if grep -q "^${key}=" "$file"; then + sed -i "s/^${key}=.*/${key}=${escaped_value}/" "$file" + else + printf '%s=%s\n' "$key" "$value" >>"$file" + fi +} + +deploy_from_broadcast() { + local script_file="$1" + local script_contract="$2" + local rpc="$3" + local chain_id="$4" + local extra_env="$5" + local broadcast_path="$SMOM_ROOT/broadcast/${script_file}/${chain_id}/run-latest.json" + + if $DRY_RUN; then + redact_secrets "$extra_env forge script script/${script_file}:${script_contract} --rpc-url \"$rpc\" --broadcast --private-key \"$PRIVATE_KEY\" --legacy -vvv" + return 0 + fi + + ( + cd "$SMOM_ROOT" + eval "$extra_env forge script script/${script_file}:${script_contract} --rpc-url \"$rpc\" --broadcast --private-key \"\$PRIVATE_KEY\" --legacy -vvv >/tmp/${script_contract}.log" + ) + + jq -r '.transactions[] | select(.transactionType == "CREATE") | .contractAddress' "$broadcast_path" | tail -1 +} + +ensure_avax_router() { + if is_ccip_router_contract "$AVAX_SEND_ROUTER" "$AVALANCHE_RPC_URL"; then + log "Using existing AVAX send router $AVAX_SEND_ROUTER" + return + fi + + log "Deploying AVAX send router" + if $DRY_RUN; then + deploy_from_broadcast \ + "DeployCCIPRouter.s.sol" \ + "DeployCCIPRouter" \ + "$AVALANCHE_RPC_URL" \ + "43114" \ + "CCIP_FEE_TOKEN=$LINK_TOKEN_AVALANCHE CCIP_BASE_FEE=0 CCIP_DATA_FEE_PER_BYTE=0" + AVAX_SEND_ROUTER="$DRY_RUN_AVAX_SEND_ROUTER" + return + fi + + local addr + addr="$( + deploy_from_broadcast \ + "DeployCCIPRouter.s.sol" \ + "DeployCCIPRouter" \ + "$AVALANCHE_RPC_URL" \ + "43114" \ + "CCIP_FEE_TOKEN=$LINK_TOKEN_AVALANCHE CCIP_BASE_FEE=0 CCIP_DATA_FEE_PER_BYTE=0" + )" + if ! $DRY_RUN; then + AVAX_SEND_ROUTER="$addr" + set_env_value "$SMOM_ROOT/.env" "CCIP_AVALANCHE_ROUTER" "$AVAX_SEND_ROUTER" + fi +} + +ensure_avax_relay_router() { + if is_current_relay_router_contract "$AVAX_RELAY_ROUTER" "$AVALANCHE_RPC_URL"; then + log "Using existing AVAX cW relay router $AVAX_RELAY_ROUTER" + return + fi + + log "Deploying AVAX cW relay router" + if $DRY_RUN; then + deploy_from_broadcast \ + "DeployCCIPRelayRouterOnly.s.sol" \ + "DeployCCIPRelayRouterOnly" \ + "$AVALANCHE_RPC_URL" \ + "43114" \ + "RELAYER_ADDRESS=$RELAYER_ADDRESS" + AVAX_RELAY_ROUTER="$DRY_RUN_AVAX_RELAY_ROUTER" + return + fi + + local addr + addr="$( + deploy_from_broadcast \ + "DeployCCIPRelayRouterOnly.s.sol" \ + "DeployCCIPRelayRouterOnly" \ + "$AVALANCHE_RPC_URL" \ + "43114" \ + "RELAYER_ADDRESS=$RELAYER_ADDRESS" + )" + AVAX_RELAY_ROUTER="$addr" + set_env_value "$SMOM_ROOT/.env" "CCIP_RELAY_ROUTER_AVALANCHE_CW" "$AVAX_RELAY_ROUTER" +} + +ensure_chain138_relay_router() { + if is_relay_router_contract "$CHAIN138_RELAY_ROUTER" "$RPC_URL_138"; then + log "Using existing Chain 138 relay router $CHAIN138_RELAY_ROUTER" + return + fi + + log "Deploying Chain 138 relay router" + if $DRY_RUN; then + deploy_from_broadcast \ + "DeployCCIPRelayRouterOnly.s.sol" \ + "DeployCCIPRelayRouterOnly" \ + "$RPC_URL_138" \ + "138" \ + "RELAYER_ADDRESS=$RELAYER_ADDRESS" + CHAIN138_RELAY_ROUTER="$DRY_RUN_CHAIN138_RELAY_ROUTER" + return + fi + + local addr + addr="$( + deploy_from_broadcast \ + "DeployCCIPRelayRouterOnly.s.sol" \ + "DeployCCIPRelayRouterOnly" \ + "$RPC_URL_138" \ + "138" \ + "RELAYER_ADDRESS=$RELAYER_ADDRESS" + )" + if ! $DRY_RUN; then + CHAIN138_RELAY_ROUTER="$addr" + set_env_value "$SMOM_ROOT/.env" "CCIP_RELAY_ROUTER_CHAIN138" "$CHAIN138_RELAY_ROUTER" + fi +} + +ensure_l1_bridge() { + if is_cw_l1_bridge "$CHAIN138_L1_BRIDGE" "$RPC_URL_138"; then + log "Using existing Chain 138 cW L1 bridge $CHAIN138_L1_BRIDGE" + return + fi + + log "Deploying Chain 138 cW L1 bridge" + if $DRY_RUN; then + deploy_from_broadcast \ + "DeployCWMultiTokenBridgeL1.s.sol" \ + "DeployCWMultiTokenBridgeL1" \ + "$RPC_URL_138" \ + "138" \ + "CW_SEND_ROUTER=$CHAIN138_SEND_ROUTER CW_RECEIVE_ROUTER=$CHAIN138_RELAY_ROUTER CW_FEE_TOKEN=$LINK_TOKEN_CHAIN138" + CHAIN138_L1_BRIDGE="$DRY_RUN_CHAIN138_L1_BRIDGE" + return + fi + + local addr + addr="$( + deploy_from_broadcast \ + "DeployCWMultiTokenBridgeL1.s.sol" \ + "DeployCWMultiTokenBridgeL1" \ + "$RPC_URL_138" \ + "138" \ + "CW_SEND_ROUTER=$CHAIN138_SEND_ROUTER CW_RECEIVE_ROUTER=$CHAIN138_RELAY_ROUTER CW_FEE_TOKEN=$LINK_TOKEN_CHAIN138" + )" + if ! $DRY_RUN; then + CHAIN138_L1_BRIDGE="$addr" + set_env_value "$SMOM_ROOT/.env" "CW_L1_BRIDGE_CHAIN138" "$CHAIN138_L1_BRIDGE" + fi +} + +ensure_l2_bridge() { + if is_cw_l2_bridge "$AVAX_CW_BRIDGE" "$AVALANCHE_RPC_URL"; then + log "Using existing AVAX cW bridge $AVAX_CW_BRIDGE" + return + fi + + log "Deploying AVAX cW bridge" + if $DRY_RUN; then + deploy_from_broadcast \ + "DeployCWMultiTokenBridgeL2.s.sol" \ + "DeployCWMultiTokenBridgeL2" \ + "$AVALANCHE_RPC_URL" \ + "43114" \ + "CW_SEND_ROUTER=$AVAX_SEND_ROUTER CW_RECEIVE_ROUTER=$AVAX_RELAY_ROUTER CW_FEE_TOKEN=$LINK_TOKEN_AVALANCHE" + AVAX_CW_BRIDGE="$DRY_RUN_AVAX_CW_BRIDGE" + return + fi + + local addr + addr="$( + deploy_from_broadcast \ + "DeployCWMultiTokenBridgeL2.s.sol" \ + "DeployCWMultiTokenBridgeL2" \ + "$AVALANCHE_RPC_URL" \ + "43114" \ + "CW_SEND_ROUTER=$AVAX_SEND_ROUTER CW_RECEIVE_ROUTER=$AVAX_RELAY_ROUTER CW_FEE_TOKEN=$LINK_TOKEN_AVALANCHE" + )" + if ! $DRY_RUN; then + AVAX_CW_BRIDGE="$addr" + set_env_value "$SMOM_ROOT/.env" "CW_BRIDGE_AVALANCHE" "$AVAX_CW_BRIDGE" + fi +} + +ensure_l2_receive_router() { + local current + current="$(cast call "$AVAX_CW_BRIDGE" "receiveRouter()(address)" --rpc-url "$AVALANCHE_RPC_URL" 2>/dev/null || true)" + if [[ "${current,,}" == "${AVAX_RELAY_ROUTER,,}" ]]; then + log "AVAX cW bridge already points at relay router $AVAX_RELAY_ROUTER" + return + fi + send_cast "$AVALANCHE_RPC_URL" "$AVAX_CW_BRIDGE" "setReceiveRouter(address)" "$AVAX_RELAY_ROUTER" +} + +ensure_supported_chain() { + local rpc="$1" + local router="$2" + local selector="$3" + local label="$4" + local current + current="$(cast call "$router" "supportedChains(uint64)(bool)" "$selector" --rpc-url "$rpc" 2>/dev/null || echo "false")" + if [[ "$current" == "true" ]]; then + log "$label already supports selector $selector" + return + fi + send_cast "$rpc" "$router" "addSupportedChain(uint64)" "$selector" +} + +ensure_authorized_bridge() { + local rpc="$1" + local router="$2" + local bridge="$3" + local current + current="$(cast call "$router" "authorizedBridges(address)(bool)" "$bridge" --rpc-url "$rpc" 2>/dev/null || echo "false")" + if [[ "$current" == "true" ]]; then + log "Bridge $bridge already authorized on $router" + return + fi + send_cast "$rpc" "$router" "authorizeBridge(address)" "$bridge" +} + +ensure_relayer_role() { + local rpc="$1" + local router="$2" + local role + role="$(cast keccak "RELAYER_ROLE")" + local current + current="$(cast call "$router" "hasRole(bytes32,address)(bool)" "$role" "$RELAYER_ADDRESS" --rpc-url "$rpc" 2>/dev/null || echo "false")" + if [[ "$current" == "true" ]]; then + log "Relayer role already granted on $router" + return + fi + send_cast "$rpc" "$router" "grantRelayerRole(address)" "$RELAYER_ADDRESS" +} + +ensure_l2_token_pair() { + local canonical="$1" + local mirrored="$2" + local current + current="$(cast call "$AVAX_CW_BRIDGE" "canonicalToMirrored(address)(address)" "$canonical" --rpc-url "$AVALANCHE_RPC_URL" 2>/dev/null || echo "0x0")" + if [[ "${current,,}" == "${mirrored,,}" ]]; then + log "Token pair $canonical -> $mirrored already configured" + return + fi + send_cast "$AVALANCHE_RPC_URL" "$AVAX_CW_BRIDGE" "configureTokenPair(address,address)" "$canonical" "$mirrored" +} + +ensure_destination_l1() { + local token="$1" + local encoded + encoded="$(cast call "$CHAIN138_L1_BRIDGE" "destinations(address,uint64)((address,bool))" "$token" "$AVALANCHE_SELECTOR" --rpc-url "$RPC_URL_138" 2>/dev/null || true)" + encoded="${encoded,,}" + if [[ "$encoded" == *"${AVAX_CW_BRIDGE,,}"* && "$encoded" == *"true"* ]]; then + log "L1 destination already configured for $token" + return + fi + send_cast "$RPC_URL_138" "$CHAIN138_L1_BRIDGE" "configureDestination(address,uint64,address,bool)" "$token" "$AVALANCHE_SELECTOR" "$AVAX_CW_BRIDGE" true +} + +ensure_destination_l2() { + local encoded + encoded="$(cast call "$AVAX_CW_BRIDGE" "destinations(uint64)((address,bool))" 138 --rpc-url "$AVALANCHE_RPC_URL" 2>/dev/null || true)" + encoded="${encoded,,}" + if [[ "$encoded" == *"${CHAIN138_L1_BRIDGE,,}"* && "$encoded" == *"true"* ]]; then + log "L2 destination back to Chain 138 already configured" + return + fi + send_cast "$AVALANCHE_RPC_URL" "$AVAX_CW_BRIDGE" "configureDestination(uint64,address,bool)" 138 "$CHAIN138_L1_BRIDGE" true +} + +ensure_supported_canonical_token() { + local token="$1" + local label="$2" + local current + current="$(cast call "$CHAIN138_L1_BRIDGE" "supportedCanonicalToken(address)(bool)" "$token" --rpc-url "$RPC_URL_138" 2>/dev/null || echo "false")" + if [[ "$current" == "true" ]]; then + log "$label already allowlisted on Chain 138 cW bridge" + return + fi + send_cast "$RPC_URL_138" "$CHAIN138_L1_BRIDGE" "configureSupportedCanonicalToken(address,bool)" "$token" true +} + +ensure_max_outstanding() { + local token="$1" + local amount="$2" + local label="$3" + if [[ -z "$amount" ]]; then + log "Skipping maxOutstanding for $label: CW_MAX_OUTSTANDING not set" + return + fi + + local current + current="$(cast call "$CHAIN138_L1_BRIDGE" "maxOutstanding(address,uint64)(uint256)" "$token" "$AVALANCHE_SELECTOR" --rpc-url "$RPC_URL_138" 2>/dev/null || true)" + if [[ "$current" == "$amount" ]]; then + log "maxOutstanding already set for $label: $amount" + return + fi + send_cast "$RPC_URL_138" "$CHAIN138_L1_BRIDGE" "setMaxOutstanding(address,uint64,uint256)" "$token" "$AVALANCHE_SELECTOR" "$amount" +} + +build_verifier_deploy_env() { + local -a env_parts=() + env_parts+=("CW_L1_BRIDGE=$CHAIN138_L1_BRIDGE") + env_parts+=("CW_ATTACH_VERIFIER_TO_L1=$(bool_to_uint "$CW_ATTACH_VERIFIER_TO_L1_BOOL")") + env_parts+=("CW_CANONICAL_USDT=$CW_CANONICAL_USDT_ADDR") + env_parts+=("CW_CANONICAL_USDC=$CW_CANONICAL_USDC_ADDR") + env_parts+=("CW_USDT_RESERVE_ASSET=${CW_USDT_RESERVE_ASSET_ADDR:-$ZERO_ADDRESS}") + env_parts+=("CW_USDC_RESERVE_ASSET=${CW_USDC_RESERVE_ASSET_ADDR:-$ZERO_ADDRESS}") + env_parts+=("CW_REQUIRE_VAULT_BACKING=$(bool_to_uint "$CW_REQUIRE_VAULT_BACKING_BOOL")") + env_parts+=("CW_REQUIRE_RESERVE_SYSTEM_BALANCE=$(bool_to_uint "$CW_REQUIRE_RESERVE_SYSTEM_BALANCE_BOOL")") + env_parts+=("CW_REQUIRE_TOKEN_OWNER_MATCH_VAULT=$(bool_to_uint "$CW_REQUIRE_TOKEN_OWNER_MATCH_VAULT_BOOL")") + + if [[ -n "$CW_STABLECOIN_RESERVE_VAULT_ADDR" ]]; then + env_parts+=("CW_STABLECOIN_RESERVE_VAULT=$CW_STABLECOIN_RESERVE_VAULT_ADDR") + fi + if [[ -n "$CW_RESERVE_SYSTEM_ADDR" ]]; then + env_parts+=("CW_RESERVE_SYSTEM=$CW_RESERVE_SYSTEM_ADDR") + fi + + printf '%s ' "${env_parts[@]}" +} + +ensure_verifier_bridge_binding() { + local current + current="$(cast call "$CW_RESERVE_VERIFIER" "bridge()(address)" --rpc-url "$RPC_URL_138" 2>/dev/null || true)" + if [[ "${current,,}" == "${CHAIN138_L1_BRIDGE,,}" ]]; then + return + fi + send_cast "$RPC_URL_138" "$CW_RESERVE_VERIFIER" "setBridge(address)" "$CHAIN138_L1_BRIDGE" +} + +ensure_verifier_stablecoin_reserve_vault() { + [[ -n "$CW_STABLECOIN_RESERVE_VAULT_ADDR" ]] || return + local current + current="$(cast call "$CW_RESERVE_VERIFIER" "stablecoinReserveVault()(address)" --rpc-url "$RPC_URL_138" 2>/dev/null || true)" + if [[ "${current,,}" == "${CW_STABLECOIN_RESERVE_VAULT_ADDR,,}" ]]; then + return + fi + send_cast "$RPC_URL_138" "$CW_RESERVE_VERIFIER" "setStablecoinReserveVault(address)" "$CW_STABLECOIN_RESERVE_VAULT_ADDR" +} + +ensure_verifier_reserve_system() { + [[ -n "$CW_RESERVE_SYSTEM_ADDR" ]] || return + local current + current="$(cast call "$CW_RESERVE_VERIFIER" "reserveSystem()(address)" --rpc-url "$RPC_URL_138" 2>/dev/null || true)" + if [[ "${current,,}" == "${CW_RESERVE_SYSTEM_ADDR,,}" ]]; then + return + fi + send_cast "$RPC_URL_138" "$CW_RESERVE_VERIFIER" "setReserveSystem(address)" "$CW_RESERVE_SYSTEM_ADDR" +} + +ensure_verifier_token_config() { + local canonical="$1" + local reserve_asset="${2:-$ZERO_ADDRESS}" + local label="$3" + [[ -n "$canonical" ]] || { + log "Skipping verifier config for $label: canonical token missing" + return + } + + if [[ "$CW_REQUIRE_RESERVE_SYSTEM_BALANCE_BOOL" == "true" && "$reserve_asset" == "$ZERO_ADDRESS" ]]; then + log "Skipping verifier config for $label: reserve asset missing while reserve-system balance checks are enabled" + return + fi + + local current normalized + current="$(cast call "$CW_RESERVE_VERIFIER" "tokenConfigs(address)(bool,address,bool,bool,bool)" "$canonical" --rpc-url "$RPC_URL_138" 2>/dev/null || true)" + normalized="$(printf '%s' "$current" | tr '\n' ' ' | xargs 2>/dev/null || true)" + + local enabled current_reserve current_require_vault current_require_reserve current_require_owner + read -r enabled current_reserve current_require_vault current_require_reserve current_require_owner <<<"$normalized" + + if [[ "$enabled" == "true" && + "${current_reserve,,}" == "${reserve_asset,,}" && + "$current_require_vault" == "$CW_REQUIRE_VAULT_BACKING_BOOL" && + "$current_require_reserve" == "$CW_REQUIRE_RESERVE_SYSTEM_BALANCE_BOOL" && + "$current_require_owner" == "$CW_REQUIRE_TOKEN_OWNER_MATCH_VAULT_BOOL" ]]; then + log "Reserve verifier already configured for $label" + return + fi + + send_cast \ + "$RPC_URL_138" \ + "$CW_RESERVE_VERIFIER" \ + "configureToken(address,address,bool,bool,bool)" \ + "$canonical" \ + "$reserve_asset" \ + "$CW_REQUIRE_VAULT_BACKING_BOOL" \ + "$CW_REQUIRE_RESERVE_SYSTEM_BALANCE_BOOL" \ + "$CW_REQUIRE_TOKEN_OWNER_MATCH_VAULT_BOOL" +} + +ensure_reserve_verifier() { + local current + current="$(cast call "$CHAIN138_L1_BRIDGE" "reserveVerifier()(address)" --rpc-url "$RPC_URL_138" 2>/dev/null || true)" + + if is_reserve_verifier_contract "$current" "$RPC_URL_138"; then + CW_RESERVE_VERIFIER="$current" + log "Using existing Chain 138 reserve verifier $CW_RESERVE_VERIFIER" + elif is_reserve_verifier_contract "$CW_RESERVE_VERIFIER" "$RPC_URL_138"; then + log "Using configured Chain 138 reserve verifier $CW_RESERVE_VERIFIER" + else + log "Deploying Chain 138 reserve verifier" + if $DRY_RUN; then + deploy_from_broadcast \ + "DeployCWReserveVerifier.s.sol" \ + "DeployCWReserveVerifier" \ + "$RPC_URL_138" \ + "138" \ + "$(build_verifier_deploy_env)" + CW_RESERVE_VERIFIER="$DRY_RUN_CHAIN138_RESERVE_VERIFIER" + return + fi + + local addr + addr="$( + deploy_from_broadcast \ + "DeployCWReserveVerifier.s.sol" \ + "DeployCWReserveVerifier" \ + "$RPC_URL_138" \ + "138" \ + "$(build_verifier_deploy_env)" + )" + CW_RESERVE_VERIFIER="$addr" + fi + + if ! $DRY_RUN; then + set_env_value "$SMOM_ROOT/.env" "CW_RESERVE_VERIFIER_CHAIN138" "$CW_RESERVE_VERIFIER" + fi + + if [[ "$CW_ATTACH_VERIFIER_TO_L1_BOOL" == "true" && "${current,,}" != "${CW_RESERVE_VERIFIER,,}" ]]; then + send_cast "$RPC_URL_138" "$CHAIN138_L1_BRIDGE" "setReserveVerifier(address)" "$CW_RESERVE_VERIFIER" + fi + + ensure_verifier_bridge_binding + ensure_verifier_stablecoin_reserve_vault + ensure_verifier_reserve_system + ensure_verifier_token_config "$CW_CANONICAL_USDT_ADDR" "${CW_USDT_RESERVE_ASSET_ADDR:-$ZERO_ADDRESS}" "cUSDT" + ensure_verifier_token_config "$CW_CANONICAL_USDC_ADDR" "${CW_USDC_RESERVE_ASSET_ADDR:-$ZERO_ADDRESS}" "cUSDC" +} + +ensure_l2_token_pair_frozen() { + local canonical="$1" + local label="$2" + local current + current="$(cast call "$AVAX_CW_BRIDGE" "tokenPairFrozen(address)(bool)" "$canonical" --rpc-url "$AVALANCHE_RPC_URL" 2>/dev/null || echo "false")" + if [[ "$current" == "true" ]]; then + log "$label token pair already frozen on Avalanche" + return + fi + send_cast "$AVALANCHE_RPC_URL" "$AVAX_CW_BRIDGE" "freezeTokenPair(address)" "$canonical" +} + +ensure_l2_destination_frozen() { + local current + current="$(cast call "$AVAX_CW_BRIDGE" "destinationFrozen(uint64)(bool)" 138 --rpc-url "$AVALANCHE_RPC_URL" 2>/dev/null || echo "false")" + if [[ "$current" == "true" ]]; then + log "Avalanche destination back to Chain 138 already frozen" + return + fi + send_cast "$AVALANCHE_RPC_URL" "$AVAX_CW_BRIDGE" "freezeDestination(uint64)" 138 +} + +ensure_role() { + local rpc="$1" + local token="$2" + local role_name="$3" + local grantee="$4" + local role + role="$(cast keccak "$role_name")" + local current + current="$(cast call "$token" "hasRole(bytes32,address)(bool)" "$role" "$grantee" --rpc-url "$rpc" 2>/dev/null || echo "false")" + if [[ "$current" == "true" ]]; then + log "$role_name already granted on $token" + return + fi + send_cast "$rpc" "$token" "grantRole(bytes32,address)" "$role" "$grantee" +} + +write_relay_profiles() { + local avax_profile="$SMOM_ROOT/services/relay/.env.avax" + local avax_cw_profile="$SMOM_ROOT/services/relay/.env.avax-cw" + local reverse_profile="$SMOM_ROOT/services/relay/.env.avax-to-138" + + if $DRY_RUN; then + cat <"$avax_cw_profile" <"$reverse_profile" </tmp/relay-avax.log 2>&1 &) + (cd "$SMOM_ROOT/services/relay" && nohup env RELAY_SKIP_ENV_LOCAL=1 ./start-relay.sh avax-cw >/tmp/relay-avax-cw.log 2>&1 &) + (cd "$SMOM_ROOT/services/relay" && nohup env RELAY_SKIP_ENV_LOCAL=1 ./start-relay.sh avax-to-138 >/tmp/relay-avax-to-138.log 2>&1 &) +} + +log "Deployer: $DEPLOYER_ADDRESS" +log "Dry run: $DRY_RUN" + +ensure_avax_router +ensure_avax_relay_router +ensure_chain138_relay_router +ensure_l1_bridge +ensure_l2_bridge + +if ! $DRY_RUN; then + ensure_supported_chain "$AVALANCHE_RPC_URL" "$AVAX_SEND_ROUTER" 138 "AVAX router" +fi + +ensure_l2_receive_router +ensure_authorized_bridge "$AVALANCHE_RPC_URL" "$AVAX_RELAY_ROUTER" "$AVAX_CW_BRIDGE" +ensure_relayer_role "$AVALANCHE_RPC_URL" "$AVAX_RELAY_ROUTER" +ensure_authorized_bridge "$RPC_URL_138" "$CHAIN138_RELAY_ROUTER" "$CHAIN138_L1_BRIDGE" +ensure_relayer_role "$RPC_URL_138" "$CHAIN138_RELAY_ROUTER" + +ensure_l2_token_pair "$COMPLIANT_USDT_ADDRESS" "$CWUSDT_AVALANCHE" +ensure_l2_token_pair "$COMPLIANT_USDC_ADDRESS" "$CWUSDC_AVALANCHE" + +ensure_role "$AVALANCHE_RPC_URL" "$CWUSDT_AVALANCHE" "MINTER_ROLE" "$AVAX_CW_BRIDGE" +ensure_role "$AVALANCHE_RPC_URL" "$CWUSDT_AVALANCHE" "BURNER_ROLE" "$AVAX_CW_BRIDGE" +ensure_role "$AVALANCHE_RPC_URL" "$CWUSDC_AVALANCHE" "MINTER_ROLE" "$AVAX_CW_BRIDGE" +ensure_role "$AVALANCHE_RPC_URL" "$CWUSDC_AVALANCHE" "BURNER_ROLE" "$AVAX_CW_BRIDGE" + +ensure_supported_canonical_token "$CW_CANONICAL_USDT_ADDR" "cUSDT" +ensure_supported_canonical_token "$CW_CANONICAL_USDC_ADDR" "cUSDC" +ensure_max_outstanding "$CW_CANONICAL_USDT_ADDR" "$CW_MAX_OUTSTANDING_USDT_AVALANCHE" "cUSDT -> Avalanche" +ensure_max_outstanding "$CW_CANONICAL_USDC_ADDR" "$CW_MAX_OUTSTANDING_USDC_AVALANCHE" "cUSDC -> Avalanche" + +ensure_destination_l1 "$COMPLIANT_USDT_ADDRESS" +ensure_destination_l1 "$COMPLIANT_USDC_ADDRESS" +ensure_destination_l2 +ensure_reserve_verifier + +if [[ "$CW_FREEZE_AVAX_L2_CONFIG_BOOL" == "true" ]]; then + ensure_l2_token_pair_frozen "$CW_CANONICAL_USDT_ADDR" "cUSDT" + ensure_l2_token_pair_frozen "$CW_CANONICAL_USDC_ADDR" "cUSDC" + ensure_l2_destination_frozen +else + log "Skipping Avalanche L2 freeze; set CW_FREEZE_AVAX_L2_CONFIG=1 to lock token pairs and destination" +fi + +write_relay_profiles +start_relays + +log "Summary:" +log " AVAX send router: ${AVAX_SEND_ROUTER:-}" +log " AVAX cW relay router: ${AVAX_RELAY_ROUTER:-}" +log " Chain 138 relay router:${CHAIN138_RELAY_ROUTER:-}" +log " Chain 138 L1 bridge: ${CHAIN138_L1_BRIDGE:-}" +log " AVAX cW bridge: ${AVAX_CW_BRIDGE:-}" +log " Chain 138 verifier: ${CW_RESERVE_VERIFIER:-}" +log " Freeze AVAX L2 config: ${CW_FREEZE_AVAX_L2_CONFIG_BOOL}" diff --git a/scripts/deployment/deploy-cw-stablecoin-vault-and-wire.sh b/scripts/deployment/deploy-cw-stablecoin-vault-and-wire.sh new file mode 100755 index 0000000..97be3c3 --- /dev/null +++ b/scripts/deployment/deploy-cw-stablecoin-vault-and-wire.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SMOM_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +WITH_CUTOVER=false +DRY_RUN=false + +for arg in "$@"; do + case "$arg" in + --with-cutover) WITH_CUTOVER=true ;; + --dry-run) DRY_RUN=true ;; + *) + echo "Unknown option: $arg" >&2 + echo "Usage: $0 [--dry-run] [--with-cutover]" >&2 + exit 1 + ;; + esac +done + +source "$SMOM_ROOT/scripts/load-env.sh" >/dev/null 2>&1 + +command -v cast >/dev/null 2>&1 || { echo "cast is required" >&2; exit 1; } +command -v forge >/dev/null 2>&1 || { echo "forge is required" >&2; exit 1; } +command -v jq >/dev/null 2>&1 || { echo "jq is required" >&2; exit 1; } + +ENV_FILE="$SMOM_ROOT/.env" +DEPLOYER_ADDRESS="$(cast wallet address "$PRIVATE_KEY")" +RPC="$RPC_URL_138" +CW_VAULT="${CW_STABLECOIN_RESERVE_VAULT:-}" +TARGET_USDT_BACKING="${CW_VAULT_SEED_USDT_AMOUNT:-}" +TARGET_USDC_BACKING="${CW_VAULT_SEED_USDC_AMOUNT:-}" + +log() { + printf '[cw-vault] %s\n' "$*" +} + +redact_secrets() { + local text="$1" + if [[ -n "${PRIVATE_KEY:-}" ]]; then + text="${text//${PRIVATE_KEY}/\$PRIVATE_KEY}" + fi + printf '%s\n' "$text" +} + +format_command() { + printf '%q ' "$@" + printf '\n' +} + +run_command() { + if $DRY_RUN; then + redact_secrets "$(format_command "$@")" + else + "$@" + fi +} + +has_code() { + local address="$1" + [[ -n "$address" ]] || return 1 + local code + code="$(cast code "$address" --rpc-url "$RPC" 2>/dev/null || true)" + [[ -n "$code" && "$code" != "0x" ]] +} + +send_cast() { + local to="$1" + local signature="$2" + shift 2 + + local cmd=(cast send) + local gas_price + gas_price="$(cast gas-price --rpc-url "$RPC" 2>/dev/null || true)" + if [[ "$gas_price" =~ ^[0-9]+$ && "$gas_price" -gt 0 ]]; then + gas_price=$((gas_price + gas_price / 5 + 1000000)) + cmd+=(--gas-price "$gas_price") + fi + + cmd+=(--rpc-url "$RPC" --private-key "$PRIVATE_KEY" --legacy "$to" "$signature") + if [[ "$#" -gt 0 ]]; then + cmd+=("$@") + fi + + run_command "${cmd[@]}" +} + +set_env_value() { + local file="$1" + local key="$2" + local value="$3" + local escaped_value + escaped_value="$(printf '%s' "$value" | sed -e 's/[\\/&]/\\&/g')" + if grep -q "^${key}=" "$file"; then + sed -i "s/^${key}=.*/${key}=${escaped_value}/" "$file" + else + printf '%s=%s\n' "$key" "$value" >>"$file" + fi +} + +deploy_from_broadcast() { + local script_file="$1" + local script_contract="$2" + local extra_env="$3" + local broadcast_path="$SMOM_ROOT/broadcast/${script_file}/138/run-latest.json" + + if $DRY_RUN; then + redact_secrets "$extra_env forge script script/reserve/${script_file}:${script_contract} --rpc-url \"$RPC\" --broadcast --private-key \"$PRIVATE_KEY\" --legacy -vvv" + return 0 + fi + + ( + cd "$SMOM_ROOT" + eval "$extra_env forge script script/reserve/${script_file}:${script_contract} --rpc-url \"$RPC\" --broadcast --private-key \"\$PRIVATE_KEY\" --legacy -vvv >/tmp/${script_contract}.log" + ) + + jq -r '.transactions[] | select(.transactionType == "CREATE") | .contractAddress' "$broadcast_path" | tail -1 +} + +call_uint() { + cast call "$1" "$2" "${@:3}" --rpc-url "$RPC" +} + +call_addr() { + cast call "$1" "$2" --rpc-url "$RPC" +} + +normalize_uint() { + printf '%s\n' "$1" | awk '{print $1}' +} + +ensure_vault() { + if has_code "$CW_VAULT"; then + log "Using existing stablecoin reserve vault $CW_VAULT" + return + fi + + log "Deploying stablecoin reserve vault" + if $DRY_RUN; then + deploy_from_broadcast "DeployStablecoinReserveVault.s.sol" "DeployStablecoinReserveVault" "" + CW_VAULT="0x0000000000000000000000000000000000000140" + return + fi + + CW_VAULT="$( + deploy_from_broadcast "DeployStablecoinReserveVault.s.sol" "DeployStablecoinReserveVault" "" + )" + set_env_value "$ENV_FILE" "CW_STABLECOIN_RESERVE_VAULT" "$CW_VAULT" +} + +ensure_official_balance() { + local token="$1" + local amount_needed="$2" + local label="$3" + local owner balance shortfall + + owner="$(cast call "$token" "owner()(address)" --rpc-url "$RPC")" + balance="$(normalize_uint "$(cast call "$token" "balanceOf(address)(uint256)" "$DEPLOYER_ADDRESS" --rpc-url "$RPC")")" + + if [[ "$balance" =~ ^[0-9]+$ ]] && (( balance >= amount_needed )); then + log "$label deployer balance already sufficient: $balance" + return + fi + + shortfall=$((amount_needed - balance)) + if [[ "${owner,,}" != "${DEPLOYER_ADDRESS,,}" ]]; then + echo "Deployer is not owner of $label token $token; cannot mint shortfall $shortfall" >&2 + exit 1 + fi + + if (( shortfall > 0 )); then + send_cast "$token" "mint(address,uint256)" "$DEPLOYER_ADDRESS" "$shortfall" + fi +} + +ensure_seeded_backing() { + local official_token="$1" + local reserve_getter="$2" + local seed_signature="$3" + local target_amount="$4" + local label="$5" + + local current_reserve shortfall + if $DRY_RUN; then + current_reserve=0 + else + current_reserve="$(normalize_uint "$(cast call "$CW_VAULT" "$reserve_getter" --rpc-url "$RPC")")" + fi + if [[ -z "$target_amount" ]]; then + target_amount="$(normalize_uint "$(cast call "$official_token" "totalSupply()(uint256)" --rpc-url "$RPC")")" + else + target_amount="$(normalize_uint "$target_amount")" + fi + + if (( current_reserve >= target_amount )); then + log "$label reserve already seeded: $current_reserve" + return + fi + + shortfall=$((target_amount - current_reserve)) + ensure_official_balance "$official_token" "$shortfall" "$label" + send_cast "$official_token" "approve(address,uint256)" "$CW_VAULT" "$shortfall" + send_cast "$CW_VAULT" "$seed_signature" "$shortfall" +} + +ensure_compliant_owner() { + local token="$1" + local label="$2" + local owner + if $DRY_RUN; then + owner="$DEPLOYER_ADDRESS" + else + owner="$(cast call "$token" "owner()(address)" --rpc-url "$RPC")" + fi + if [[ "${owner,,}" == "${CW_VAULT,,}" ]]; then + log "$label ownership already transferred to vault" + return + fi + if [[ "${owner,,}" != "${DEPLOYER_ADDRESS,,}" ]]; then + echo "$label owner is $owner, expected deployer or vault" >&2 + exit 1 + fi + send_cast "$token" "transferOwnership(address)" "$CW_VAULT" +} + +verify_vault_state() { + local usdt_owner usdc_owner usdt_ratio usdc_ratio + usdt_owner="$(cast call "$COMPLIANT_USDT_ADDRESS" "owner()(address)" --rpc-url "$RPC")" + usdc_owner="$(cast call "$COMPLIANT_USDC_ADDRESS" "owner()(address)" --rpc-url "$RPC")" + usdt_ratio="$(cast call "$CW_VAULT" "getBackingRatio(address)(uint256,uint256,uint256)" "$COMPLIANT_USDT_ADDRESS" --rpc-url "$RPC" | tr '\n' ' ' | xargs)" + usdc_ratio="$(cast call "$CW_VAULT" "getBackingRatio(address)(uint256,uint256,uint256)" "$COMPLIANT_USDC_ADDRESS" --rpc-url "$RPC" | tr '\n' ' ' | xargs)" + log "cUSDT owner: $usdt_owner" + log "cUSDC owner: $usdc_owner" + log "cUSDT backing tuple: $usdt_ratio" + log "cUSDC backing tuple: $usdc_ratio" +} + +log "Deployer: $DEPLOYER_ADDRESS" +log "Dry run: $DRY_RUN" + +if [[ -z "$TARGET_USDT_BACKING" ]]; then + TARGET_USDT_BACKING="$(normalize_uint "$(cast call "$COMPLIANT_USDT_ADDRESS" "totalSupply()(uint256)" --rpc-url "$RPC")")" +fi +if [[ -z "$TARGET_USDC_BACKING" ]]; then + TARGET_USDC_BACKING="$(normalize_uint "$(cast call "$COMPLIANT_USDC_ADDRESS" "totalSupply()(uint256)" --rpc-url "$RPC")")" +fi + +ensure_vault + +if ! $DRY_RUN; then + set_env_value "$ENV_FILE" "CW_STABLECOIN_RESERVE_VAULT" "$CW_VAULT" + set_env_value "$ENV_FILE" "CW_REQUIRE_VAULT_BACKING" "1" + set_env_value "$ENV_FILE" "CW_REQUIRE_TOKEN_OWNER_MATCH_VAULT" "1" + set_env_value "$ENV_FILE" "CW_REQUIRE_RESERVE_SYSTEM_BALANCE" "0" +fi + +ensure_seeded_backing "$OFFICIAL_USDT_ADDRESS" "usdtReserveBalance()(uint256)" "seedUSDTReserve(uint256)" "$TARGET_USDT_BACKING" "USDT" +ensure_seeded_backing "$OFFICIAL_USDC_ADDRESS" "usdcReserveBalance()(uint256)" "seedUSDCReserve(uint256)" "$TARGET_USDC_BACKING" "USDC" + +ensure_compliant_owner "$COMPLIANT_USDT_ADDRESS" "cUSDT" +ensure_compliant_owner "$COMPLIANT_USDC_ADDRESS" "cUSDC" + +if ! $DRY_RUN; then + verify_vault_state +fi + +if $WITH_CUTOVER; then + if $DRY_RUN; then + echo "(cd \"$SMOM_ROOT\" && ./scripts/deployment/complete-nonprefunded-avax-cutover.sh --dry-run)" + else + (cd "$SMOM_ROOT" && ./scripts/deployment/complete-nonprefunded-avax-cutover.sh) + fi +fi diff --git a/scripts/deployment/deploy-official-dvm-chain138.sh b/scripts/deployment/deploy-official-dvm-chain138.sh index b1d6698..58ca663 100644 --- a/scripts/deployment/deploy-official-dvm-chain138.sh +++ b/scripts/deployment/deploy-official-dvm-chain138.sh @@ -2,10 +2,89 @@ # Deploy official DODO DVM (DVMFactory + deps) to Chain 138 via DODOEX/contractV2 Truffle, # then deploy DVMFactoryAdapter (createDVM -> createDODOVendingMachine) via Forge and update .env. # Requires: smom-dbis-138/.env with PRIVATE_KEY, RPC_URL_138 +# +# Notes: +# - The vendored DODO V2 tree contains both Solidity 0.6.9 and 0.8.x sources. +# Truffle uses a single configured compiler, so this script temporarily hides the +# unrelated 0.8.x contracts during compile/migrate, then restores them on exit. +# - Use --compile-only to verify the official DVM stack compiles cleanly before broadcast. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" DODO_DIR="$PROJECT_ROOT/lib/dodo-contractV2" +COMPILE_ONLY=false +TRUFFLE_RESET=false +HIDDEN_ROOT="$DODO_DIR/.chain138-dvm-solc-excludes" +declare -a HIDDEN_SOL_FILES=() + +usage() { + cat <<'EOF' +Usage: deploy-official-dvm-chain138.sh [--compile-only] [--reset] + +Options: + --compile-only Compile the official DODO V2 DVM stack in DVM-only mode, then exit. + --reset Pass --reset to truffle migrate. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --compile-only) + COMPILE_ONLY=true + shift + ;; + --reset) + TRUFFLE_RESET=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +prepare_dvm_only_sources() { + local rel_path + rm -rf "$HIDDEN_ROOT" + mkdir -p "$HIDDEN_ROOT" + + while IFS= read -r rel_path; do + [[ -z "$rel_path" ]] && continue + mkdir -p "$HIDDEN_ROOT/$(dirname "$rel_path")" + mv "$DODO_DIR/$rel_path" "$HIDDEN_ROOT/$rel_path" + HIDDEN_SOL_FILES+=("$rel_path") + done < <( + cd "$DODO_DIR" && + find contracts -type f -name '*.sol' -print0 | + xargs -0 grep -El 'pragma solidity (\^?0\.8|>=0\.8)' | + sort + ) + + if [[ ${#HIDDEN_SOL_FILES[@]} -gt 0 ]]; then + echo "Temporarily hiding ${#HIDDEN_SOL_FILES[@]} Solidity 0.8.x sources for Chain 138 DVM-only compile..." + fi +} + +restore_hidden_sources() { + local rel_path + if [[ -d "$HIDDEN_ROOT" ]]; then + while IFS= read -r rel_path; do + [[ -z "$rel_path" ]] && continue + mkdir -p "$DODO_DIR/$(dirname "$rel_path")" + mv "$HIDDEN_ROOT/$rel_path" "$DODO_DIR/$rel_path" + done < <(cd "$HIDDEN_ROOT" && find contracts -type f -name '*.sol' | sort) + rm -rf "$HIDDEN_ROOT" + fi +} + +trap restore_hidden_sources EXIT + cd "$PROJECT_ROOT" # Load .env via dotenv (RPC CR/LF trim). Fallback: raw source. if [[ -f "$SCRIPT_DIR/../lib/deployment/dotenv.sh" ]]; then @@ -52,12 +131,27 @@ export privKey="${PRIVATE_KEY#0x}" export RPC_URL_138 export GAS_PRICE_138="${GAS_PRICE_138:-1000000000}" +prepare_dvm_only_sources + +echo "=== Compiling official DODO V2 DVM-only subset for Chain 138 ===" +(cd "$DODO_DIR" && rm -rf build/contracts && npx truffle compile) + +if [[ "$COMPILE_ONLY" == "true" ]]; then + echo "DODO V2 DVM-only compile completed successfully." + exit 0 +fi + +TRUFFLE_RESET_ARGS=() +if [[ "$TRUFFLE_RESET" == "true" ]]; then + TRUFFLE_RESET_ARGS+=(--reset) +fi + # Deploy Migrations (required for Truffle), then DVM stack only echo "=== Running Truffle migration 1 (Migrations) on Chain 138 ===" -(cd "$DODO_DIR" && npx truffle migrate -f 1 --to 1 --network chain138) || true +(cd "$DODO_DIR" && npx truffle migrate -f 1 --to 1 --network chain138 "${TRUFFLE_RESET_ARGS[@]}") || true echo "=== Running Truffle migration 9 (DVM stack) on Chain 138 ===" -(cd "$DODO_DIR" && npx truffle migrate -f 9 --to 9 --network chain138) +(cd "$DODO_DIR" && npx truffle migrate -f 9 --to 9 --network chain138 "${TRUFFLE_RESET_ARGS[@]}") # Parse DVMFactory address from Truffle build (network id 138 as string or number) DVM_FACTORY_ADDRESS=$(cd "$DODO_DIR" && node -e " @@ -84,7 +178,18 @@ forge script script/dex/DeployDVMFactoryAdapter.s.sol:DeployDVMFactoryAdapter \ --rpc-url "$RPC_URL_138" --broadcast --private-key "$PRIVATE_KEY" --legacy # Extract adapter address from broadcast (or script output) -ADAPTER_ADDRESS=$(grep -o '"contractAddress":"0x[^"]*"' "$PROJECT_ROOT/broadcast/DeployDVMFactoryAdapter.s.sol/138/"*run-latest.json 2>/dev/null | tail -1 | sed 's/.*"0x/0x/;s/".*//') || true +ADAPTER_BROADCAST="$PROJECT_ROOT/broadcast/DeployDVMFactoryAdapter.s.sol/138/run-latest.json" +ADAPTER_ADDRESS=$( + node -e " +const fs = require('fs'); +const path = process.argv[1]; +if (!fs.existsSync(path)) process.exit(1); +const j = JSON.parse(fs.readFileSync(path, 'utf8')); +const tx = (j.transactions || []).find((entry) => entry.contractName === 'DVMFactoryAdapter' && entry.contractAddress); +if (!tx) process.exit(1); +console.log(tx.contractAddress); +" "$ADAPTER_BROADCAST" 2>/dev/null +) || true if [[ -z "$ADAPTER_ADDRESS" ]]; then echo "Set DODO_VENDING_MACHINE_ADDRESS to the DVMFactoryAdapter address printed above." exit 0 diff --git a/scripts/deployment/deploy-pmm-all-l2s.sh b/scripts/deployment/deploy-pmm-all-l2s.sh index 5312c56..230181e 100755 --- a/scripts/deployment/deploy-pmm-all-l2s.sh +++ b/scripts/deployment/deploy-pmm-all-l2s.sh @@ -11,6 +11,17 @@ source "$SCRIPT_DIR/../lib/deployment/dotenv.sh" source "$SCRIPT_DIR/../lib/deployment/prompts.sh" load_deployment_env +first_set_env() { + local key + for key in "$@"; do + if [[ -n "${!key:-}" ]]; then + printf '%s' "${!key}" + return 0 + fi + done + return 1 +} + parse_chain_filter "$@" if [[ ${#CHAIN_FILTER[@]} -eq 0 && -n "${DEPLOY_PMM_L2S_FILTER:-}" ]]; then CHAIN_FILTER=() @@ -42,22 +53,35 @@ for entry in "${CHAINS[@]}"; do dvm_var="${name}_DODO_VENDING_MACHINE_ADDRESS" usdt_var="${name}_OFFICIAL_USDT_ADDRESS" usdc_var="${name}_OFFICIAL_USDC_ADDRESS" + usdt_var_alt="OFFICIAL_USDT_${name}" + usdc_var_alt="OFFICIAL_USDC_${name}" cusdt_var="${name}_COMPLIANT_USDT_ADDRESS" cusdc_var="${name}_COMPLIANT_USDC_ADDRESS" + cusdt_var_alt="COMPLIANT_USDT_${name}" + cusdc_var_alt="COMPLIANT_USDC_${name}" # Per-chain cUSDT/cUSDC (optional): CUSDT_ADDRESS_ / CUSDC_ADDRESS_ or POLYGON_COMPLIANT_USDT_ADDRESS etc. cusdt_chain="CUSDT_ADDRESS_${chain_id}" cusdc_chain="CUSDC_ADDRESS_${chain_id}" - dvm="${!dvm_var:-$DODO_VENDING_MACHINE_ADDRESS}" - usdt="${!usdt_var:-$OFFICIAL_USDT_ADDRESS}" - usdc="${!usdc_var:-$OFFICIAL_USDC_ADDRESS}" - compliant_usdt="${!cusdt_var:-${!cusdt_chain:-$usdt}}" - compliant_usdc="${!cusdc_var:-${!cusdc_chain:-$usdc}}" + dvm="$(first_set_env "$dvm_var" "DODO_VENDING_MACHINE_ADDRESS" || true)" + usdt="$(first_set_env "$usdt_var" "$usdt_var_alt" "OFFICIAL_USDT_ADDRESS" || true)" + usdc="$(first_set_env "$usdc_var" "$usdc_var_alt" "OFFICIAL_USDC_ADDRESS" || true)" + compliant_usdt="$(first_set_env "$cusdt_var" "$cusdt_var_alt" "$cusdt_chain" || true)" + compliant_usdc="$(first_set_env "$cusdc_var" "$cusdc_var_alt" "$cusdc_chain" || true)" + compliant_usdt="${compliant_usdt:-$usdt}" + compliant_usdc="${compliant_usdc:-$usdc}" if [[ -z "$dvm" ]] || [[ -z "$usdt" ]] || [[ -z "$usdc" ]]; then - echo "Skip $name: set ${dvm_var} (or DODO_VENDING_MACHINE_ADDRESS), ${usdt_var}, ${usdc_var} (or OFFICIAL_USDT/USDC_ADDRESS)" + echo "Skip $name: set ${dvm_var} (or DODO_VENDING_MACHINE_ADDRESS), ${usdt_var}/${usdt_var_alt}, ${usdc_var}/${usdc_var_alt} (or OFFICIAL_USDT/USDC_ADDRESS)" continue fi + if [[ -z "${!usdt_var:-}" && -z "${!usdt_var_alt:-}" ]]; then + echo "WARN $name: using global OFFICIAL_USDT_ADDRESS fallback; set ${usdt_var} or ${usdt_var_alt} for chain-specific safety" + fi + if [[ -z "${!usdc_var:-}" && -z "${!usdc_var_alt:-}" ]]; then + echo "WARN $name: using global OFFICIAL_USDC_ADDRESS fallback; set ${usdc_var} or ${usdc_var_alt} for chain-specific safety" + fi + echo "=== Deploying DODOPMMIntegration on $name (chain $chain_id) ===" DODO_VENDING_MACHINE_ADDRESS="$dvm" \ OFFICIAL_USDT_ADDRESS="$usdt" \ diff --git a/scripts/deployment/deploy-tokens-and-weth-all-chains-skip-canonical.sh b/scripts/deployment/deploy-tokens-and-weth-all-chains-skip-canonical.sh index 034ba86..dbf013d 100755 --- a/scripts/deployment/deploy-tokens-and-weth-all-chains-skip-canonical.sh +++ b/scripts/deployment/deploy-tokens-and-weth-all-chains-skip-canonical.sh @@ -77,7 +77,9 @@ done # Chain ID : Name : RPC env var (primary) # DeployAll.s.sol supports: 1, 25, 56, 137, 100, 43114, 8453, 42161, 10. 651940 = env validation only. -ALL_CHAINS="1:Mainnet:ETH_MAINNET_RPC_URL 25:Cronos:CRONOS_RPC_URL 56:BSC:BSC_RPC_URL 137:Polygon:POLYGON_RPC_URL 100:Gnosis:GNOSIS_RPC_URL 43114:Avalanche:AVALANCHE_RPC_URL 8453:Base:BASE_RPC_URL 42161:Arbitrum:ARBITRUM_RPC_URL 10:Optimism:OPTIMISM_RPC_URL 651940:ALL:CHAIN_651940_RPC" +# Celo / Wemix are included here for c* / cW* deployment even though their WETH/bridge deploy +# flow is handled separately via deploy-bridges-config-ready-chains.sh. +ALL_CHAINS="1:Mainnet:ETH_MAINNET_RPC_URL 25:Cronos:CRONOS_RPC_URL 56:BSC:BSC_RPC_URL 137:Polygon:POLYGON_RPC_URL 100:Gnosis:GNOSIS_RPC_URL 43114:Avalanche:AVALANCHE_RPC_URL 8453:Base:BASE_RPC_URL 42161:Arbitrum:ARBITRUM_RPC_URL 10:Optimism:OPTIMISM_RPC_URL 42220:Celo:CELO_RPC 1111:Wemix:WEMIX_RPC 651940:ALL:CHAIN_651940_RPC" # Fallback RPC env names (some scripts use different names) fallback_rpc() { @@ -95,6 +97,8 @@ fallback_rpc() { 8453) rpc="${BASE_MAINNET_RPC:-}";; 42161) rpc="${ARBITRUM_MAINNET_RPC:-}";; 10) rpc="${OPTIMISM_MAINNET_RPC:-}";; + 42220) rpc="${CELO_RPC:-${CELO_MAINNET_RPC:-}}";; + 1111) rpc="${WEMIX_RPC:-${WEMIX_MAINNET_RPC:-}}";; 651940) rpc="${CHAIN_651940_RPC:-${ALL_MAINNET_RPC:-}}";; esac fi @@ -130,6 +134,10 @@ run_deploy_all() { echo " Skip $name (chain 651940): DeployAll not supported; env validation only." return 0 fi + if [[ "$chain_id" == "42220" || "$chain_id" == "1111" ]]; then + echo " Skip $name (chain $chain_id): WETH/bridge deploy handled by deploy-bridges-config-ready-chains.sh; c*/cW* phases may still run." + return 0 + fi local rpc rpc=$(fallback_rpc "$chain_id" "$rpc_var") if [[ -z "$rpc" ]]; then @@ -230,9 +238,13 @@ run_deploy_cw() { rpc=$(fallback_rpc "$chain_id" "$rpc_var") if [[ -z "$rpc" ]]; then return 0; fi local bridge_var="CW_BRIDGE_${name^^}" + local ccip_bridge_var="CCIPWETH9_BRIDGE_${name^^}" local bridge="${!bridge_var:-${CW_BRIDGE_ADDRESS:-}}" + if [[ -z "$bridge" || "$bridge" == "0x0000000000000000000000000000000000000000" ]]; then + bridge="${!ccip_bridge_var:-}" + fi if [[ -z "$bridge" || "$bridge" == "0x"*"0000000000000000000000000000000000000000" ]]; then - echo " Skip cW* on $name (chain $chain_id): set CW_BRIDGE_ADDRESS or CW_BRIDGE_${name^^} in .env." + echo " Skip cW* on $name (chain $chain_id): set CW_BRIDGE_ADDRESS / CW_BRIDGE_${name^^} or deploy/configure CCIPWETH9_BRIDGE_${name^^}." return 0 fi local cw_var="CWUSDT_${name^^}" diff --git a/scripts/deployment/dry-run-enhanced-swap-router-chain138.sh b/scripts/deployment/dry-run-enhanced-swap-router-chain138.sh index 010af9f..a5af45b 100644 --- a/scripts/deployment/dry-run-enhanced-swap-router-chain138.sh +++ b/scripts/deployment/dry-run-enhanced-swap-router-chain138.sh @@ -3,7 +3,10 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SMOM_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +PROJECT_ROOT="$(cd "$SMOM_ROOT/.." && pwd)" +PROJECT_ENV_LOADER="$PROJECT_ROOT/scripts/lib/load-project-env.sh" ENV_FILE="$SMOM_ROOT/.env" +ENV_SOURCE="" RUN_FORGE_DRY_RUN=0 RUN_TIMEOUT_SECONDS="${RUN_TIMEOUT_SECONDS:-90}" VERBOSITY="${VERBOSITY:--vv}" @@ -35,11 +38,17 @@ CHAIN138_USDT_DEFAULT="0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1" CHAIN138_USDC_DEFAULT="0x71D6687F38b93CCad569Fa6352c876eea967201b" DAI_PLACEHOLDER_DEFAULT="0x6B175474E89094C44Da98b954EedeAC495271d0F" -if [[ -f "$ENV_FILE" ]]; then +if [[ -f "$PROJECT_ENV_LOADER" ]]; then + export PROJECT_ROOT + # shellcheck disable=SC1090 + source "$PROJECT_ENV_LOADER" + ENV_SOURCE="$PROJECT_ENV_LOADER" +elif [[ -f "$ENV_FILE" ]]; then set -a # shellcheck disable=SC1090 source "$ENV_FILE" set +a + ENV_SOURCE="$ENV_FILE" fi RPC_URL_138="${RPC_URL_138:-http://192.168.11.211:8545}" @@ -97,22 +106,25 @@ run_forge_dry_run() { ) local exit_code=0 - echo "Running targeted build warm-up" - forge build contracts/bridge/trustless/EnhancedSwapRouter.sol script/bridge/trustless/DeployEnhancedSwapRouter.s.sol - echo "" - echo "Running sourced non-broadcast forge script" - echo "Timeout: ${RUN_TIMEOUT_SECONDS}s" - echo "Verbosity: $VERBOSITY" - echo "" - set +e - if command -v timeout >/dev/null 2>&1; then - timeout "${RUN_TIMEOUT_SECONDS}s" "${forge_cmd[@]}" - exit_code=$? - else - "${forge_cmd[@]}" - exit_code=$? - fi + ( + cd "$SMOM_ROOT" || exit 1 + echo "Running targeted build warm-up" + forge build contracts/bridge/trustless/EnhancedSwapRouter.sol script/bridge/trustless/DeployEnhancedSwapRouter.s.sol + echo "" + echo "Running sourced non-broadcast forge script" + echo "Working directory: $SMOM_ROOT" + echo "Timeout: ${RUN_TIMEOUT_SECONDS}s" + echo "Verbosity: $VERBOSITY" + echo "" + + if command -v timeout >/dev/null 2>&1; then + timeout "${RUN_TIMEOUT_SECONDS}s" "${forge_cmd[@]}" + else + "${forge_cmd[@]}" + fi + ) + exit_code=$? set -e if (( exit_code == 124 )); then @@ -136,7 +148,10 @@ run_forge_dry_run() { echo "=== EnhancedSwapRouter Chain 138 Dry Run ===" echo "Project root: $SMOM_ROOT" +echo "Repository root: $PROJECT_ROOT" +echo "Project env loader: $PROJECT_ENV_LOADER" echo "Env file: $ENV_FILE" +echo "Env source: $ENV_SOURCE" echo "" if (( ${#missing[@]} > 0 )); then @@ -145,7 +160,7 @@ if (( ${#missing[@]} > 0 )); then echo " - $item" done echo "" - echo "Set them in $ENV_FILE or export them in your shell, then rerun." + echo "Set them in $ENV_FILE, the repo root env loaded by $PROJECT_ENV_LOADER, or export them in your shell, then rerun." exit 1 fi @@ -215,7 +230,11 @@ echo " - cEURT <-> cXAUC" echo "" echo "Exact dry-run command" -echo "cd \"$SMOM_ROOT\" && source .env && forge script script/bridge/trustless/DeployEnhancedSwapRouter.s.sol:DeployEnhancedSwapRouter --rpc-url \"$RPC_URL_138\" --private-key \"\$PRIVATE_KEY\"" +if [[ -f "$PROJECT_ENV_LOADER" ]]; then + echo "cd \"$PROJECT_ROOT\" && source scripts/lib/load-project-env.sh && cd smom-dbis-138 && forge script script/bridge/trustless/DeployEnhancedSwapRouter.s.sol:DeployEnhancedSwapRouter --rpc-url \"$RPC_URL_138\" --private-key \"\$PRIVATE_KEY\"" +else + echo "cd \"$SMOM_ROOT\" && source .env && forge script script/bridge/trustless/DeployEnhancedSwapRouter.s.sol:DeployEnhancedSwapRouter --rpc-url \"$RPC_URL_138\" --private-key \"\$PRIVATE_KEY\"" +fi echo "" echo "Example minimal exports before dry-run" cat <&2 + echo "Usage: $0 [--run] [--timeout-seconds ] [--verbosity <-v|-vv|-vvv|...>]" >&2 + exit 1 + ;; + esac +done + +if [[ -f "$PROJECT_ENV_LOADER" ]]; then + export PROJECT_ROOT + # shellcheck disable=SC1090 + source "$PROJECT_ENV_LOADER" + ENV_SOURCE="$PROJECT_ENV_LOADER" +elif [[ -f "$ENV_FILE" ]]; then + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a + ENV_SOURCE="$ENV_FILE" +fi + +RPC_URL_138="${RPC_URL_138:-http://192.168.11.211:8545}" +WETH="${WETH:-0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2}" +OFFICIAL_USDT_ADDRESS="${OFFICIAL_USDT_ADDRESS:-0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1}" +OFFICIAL_USDC_ADDRESS="${OFFICIAL_USDC_ADDRESS:-0x71D6687F38b93CCad569Fa6352c876eea967201b}" +DODO_PMM_PROVIDER_ADDRESS="${DODO_PMM_PROVIDER_ADDRESS:-${DODO_PMM_PROVIDER:-}}" + +show_var() { + local name="$1" value="$2" note="${3:-}" + printf ' %-28s %s' "$name" "$value" + [[ -n "$note" ]] && printf ' (%s)' "$note" + printf '\n' +} + +show_secret_var() { + local name="$1" value="${2:-}" note="${3:-}" + local display="" + if [[ -n "$value" ]]; then + display="" + fi + printf ' %-28s %s' "$name" "$display" + [[ -n "$note" ]] && printf ' (%s)' "$note" + printf '\n' +} + +probe_support() { + local token_in="$1" token_out="$2" provider="$3" label="$4" + local result + result="$(cast call "$provider" "supportsTokenPair(address,address)(bool)" "$token_in" "$token_out" --rpc-url "$RPC_URL_138" 2>/dev/null || true)" + if [[ "$result" == "true" ]]; then + echo " OK: $label" + return 0 + fi + echo " MISSING: $label" + return 1 +} + +probe_quote() { + local token_in="$1" token_out="$2" provider="$3" amount="$4" label="$5" + local result amount_out + result="$(cast call "$provider" "getQuote(address,address,uint256)(uint256,uint256)" "$token_in" "$token_out" "$amount" --rpc-url "$RPC_URL_138" 2>/dev/null || true)" + amount_out="$(awk 'NR==1 {print $1}' <<<"$result")" + if [[ -n "$amount_out" && "$amount_out" != "0" ]]; then + echo " OK: $label => $amount_out" + return 0 + fi + echo " ZERO-QUOTE: $label" + return 1 +} + +run_forge_dry_run() { + local forge_cmd=( + forge script script/bridge/trustless/DeployEnhancedSwapRouterV2.s.sol:DeployEnhancedSwapRouterV2 + --skip test + --non-interactive + --rpc-url "$RPC_URL_138" + --private-key "$PRIVATE_KEY" + "$VERBOSITY" + ) + ( + cd "$SMOM_ROOT" + forge build contracts/bridge/trustless/EnhancedSwapRouterV2.sol script/bridge/trustless/DeployEnhancedSwapRouterV2.s.sol + if command -v timeout >/dev/null 2>&1; then + timeout "${RUN_TIMEOUT_SECONDS}s" "${forge_cmd[@]}" + else + "${forge_cmd[@]}" + fi + ) +} + +echo "=== EnhancedSwapRouterV2 Chain 138 Dry Run ===" +echo "Project root: $SMOM_ROOT" +echo "Repository root: $PROJECT_ROOT" +echo "Env source: $ENV_SOURCE" +echo "" + +show_var "RPC_URL_138" "$RPC_URL_138" "Core RPC only" +show_secret_var "PRIVATE_KEY" "${PRIVATE_KEY:-}" "not printed when present" +show_var "DODO_PMM_PROVIDER_ADDRESS" "${DODO_PMM_PROVIDER_ADDRESS:-}" "required" +show_var "CHAIN138_POOL_WETH_USDT" "${CHAIN138_POOL_WETH_USDT:-}" "required for swapToStablecoin readiness" +show_var "CHAIN138_POOL_WETH_USDC" "${CHAIN138_POOL_WETH_USDC:-}" "required for swapToStablecoin readiness" +show_var "CHAIN138_D3_PROXY_ADDRESS" "${CHAIN138_D3_PROXY_ADDRESS:-0xc9a11abB7C63d88546Be24D58a6d95e3762cB843}" "optional DODO v3 execution" +show_var "CHAIN138_D3_MM_ADDRESS" "${CHAIN138_D3_MM_ADDRESS:-0x6550A3a59070061a262a893A1D6F3F490afFDBDA}" "optional DODO v3 execution" +show_var "UNISWAP_V3_ROUTER" "${UNISWAP_V3_ROUTER:-}" "optional" +show_var "UNISWAP_QUOTER_ADDRESS" "${UNISWAP_QUOTER_ADDRESS:-}" "optional" +show_var "BALANCER_VAULT" "${BALANCER_VAULT:-}" "optional" +show_var "CURVE_3POOL" "${CURVE_3POOL:-}" "optional" +echo "" + +if [[ -z "${PRIVATE_KEY:-}" ]]; then + echo "PRIVATE_KEY is required" + exit 1 +fi + +if [[ -z "$DODO_PMM_PROVIDER_ADDRESS" ]]; then + echo "DODO_PMM_PROVIDER_ADDRESS is required" + exit 1 +fi + +readiness_fail=0 +echo "Provider route probe" +if [[ -n "${CHAIN138_POOL_WETH_USDT:-}" ]]; then + if ! probe_support "$WETH" "$OFFICIAL_USDT_ADDRESS" "$DODO_PMM_PROVIDER_ADDRESS" "WETH -> USDT"; then + readiness_fail=1 + fi +else + echo " SKIP: WETH -> USDT (CHAIN138_POOL_WETH_USDT unset)" +fi +if [[ -n "${CHAIN138_POOL_WETH_USDC:-}" ]]; then + if ! probe_support "$WETH" "$OFFICIAL_USDC_ADDRESS" "$DODO_PMM_PROVIDER_ADDRESS" "WETH -> USDC"; then + readiness_fail=1 + fi +else + echo " SKIP: WETH -> USDC (CHAIN138_POOL_WETH_USDC unset)" +fi +echo "" + +echo "Quote readiness probe" +for amount in "1000000000000000000:1" "5000000000000000000:5" "25000000000000000000:25"; do + raw_amount="${amount%%:*}" + human_amount="${amount##*:}" + if [[ -n "${CHAIN138_POOL_WETH_USDT:-}" ]]; then + if ! probe_quote "$WETH" "$OFFICIAL_USDT_ADDRESS" "$DODO_PMM_PROVIDER_ADDRESS" "$raw_amount" "WETH -> USDT @ ${human_amount} WETH"; then + readiness_fail=1 + fi + fi + if [[ -n "${CHAIN138_POOL_WETH_USDC:-}" ]]; then + if ! probe_quote "$WETH" "$OFFICIAL_USDC_ADDRESS" "$DODO_PMM_PROVIDER_ADDRESS" "$raw_amount" "WETH -> USDC @ ${human_amount} WETH"; then + readiness_fail=1 + fi + fi +done +echo "" + +if [[ -z "${CHAIN138_POOL_WETH_USDT:-}" || -z "${CHAIN138_POOL_WETH_USDC:-}" ]]; then + echo "Readiness note: canonical WETH -> stable DODO PMM routes are still incomplete, so swapToStablecoin readiness remains partial." + echo "The router-v2 deployment can still proceed for explicit executeRoute calldata, including DODO v3 pilot execution." +elif (( readiness_fail == 1 )); then + echo "Readiness failed: Chain 138 still lacks at least one required WETH -> stable DODO route or non-zero quote." + echo "Set CHAIN138_POOL_WETH_USDT / CHAIN138_POOL_WETH_USDC after creating and registering the canonical pools, then rerun." + exit 1 +else + echo "Readiness passed: required WETH -> stable DODO routes are present with non-zero quotes for 1/5/25 WETH." +fi + +if (( RUN_FORGE_DRY_RUN == 1 )); then + echo "" + run_forge_dry_run +fi diff --git a/scripts/deployment/generate-all-adapters.sh b/scripts/deployment/generate-all-adapters.sh index 2373272..e008ffb 100755 --- a/scripts/deployment/generate-all-adapters.sh +++ b/scripts/deployment/generate-all-adapters.sh @@ -73,10 +73,10 @@ cat > "$CONFIG_DIR/SUPPORTED_CHAINS.md" << 'EOF' | Framework | Type | Status | Adapter | Nodes | |-----------|------|--------|---------|-------| -| Firefly | Orchestration | ✅ Deployed | FireflyAdapter | VMID 6202, 6203 | -| Cacti | Interoperability | ✅ Deployed | CactiAdapter | VMID 5201 | -| Fabric | Permissioned | 🔨 Plan | FabricAdapter | TBD | -| Indy | Identity | 🔨 Plan | IndyVerifier | TBD | +| FireFly | Orchestration | ⚠️ Adapter/service client only | FireflyAdapter | `6200` live primary; `6201` retired / standby; `6202` / `6203` not deployed | +| Cacti | Interoperability | ⚠️ Active surfaces, adapter still pending | CactiAdapter | `5200` live primary; `5201` / `5202` live public Cacti surfaces with local `:4000` APIs | +| Fabric | Permissioned | ⚠️ Primary sample network only | FabricAdapter | `6000` live sample network; `6001` / `6002` placeholders | +| Indy | Identity | ⚠️ Primary validator pool only | IndyVerifier | `6400` live validator pool; `6401` / `6402` placeholders | ## Legend - ✅ Live: Fully deployed and operational diff --git a/scripts/deployment/print-chain138-public-chain-unload-routes.sh b/scripts/deployment/print-chain138-public-chain-unload-routes.sh new file mode 100755 index 0000000..d007945 --- /dev/null +++ b/scripts/deployment/print-chain138-public-chain-unload-routes.sh @@ -0,0 +1,572 @@ +#!/usr/bin/env bash +# Print the live unload routes from Chain 138 to reachable public chains. +# +# This helper does not send transactions. It reviews the current WETH9 bridge +# topology, classifies each public-chain lane, and prints exact cast command +# packs for the current amount/recipient. +# +# Usage: +# ./scripts/deployment/print-chain138-public-chain-unload-routes.sh +# +# Optional env: +# TARGET_CHAIN=gnosis +# UNLOAD_AMOUNT_WEI=10000000000000000 +# RECIPIENT=0x... +# OPTIONAL_UNWRAP_WEI=2000000000000000 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$PROJECT_ROOT" + +if [[ -f "$SCRIPT_DIR/../lib/deployment/dotenv.sh" ]]; then + # shellcheck disable=SC1090 + source "$SCRIPT_DIR/../lib/deployment/dotenv.sh" + load_deployment_env --repo-root "$PROJECT_ROOT" +elif [[ -f "$PROJECT_ROOT/.env" ]]; then + set -a + # shellcheck disable=SC1090 + source "$PROJECT_ROOT/.env" + set +a +fi + +need_var() { + local name="$1" + [[ -n "${!name:-}" ]] || { + echo "Error: $name is required" >&2 + exit 1 + } +} + +trim_word() { + echo "$1" | awk '{print $1}' +} + +fmt_ether() { + local raw="${1:-0}" + raw="$(trim_word "$raw")" + cast from-wei "$raw" ether 2>/dev/null || echo "$raw" +} + +contains_true() { + [[ "$1" == *", true)"* ]] +} + +extract_addr() { + echo "$1" | grep -oE '0x[a-fA-F0-9]{40}' | head -n1 | tr '[:upper:]' '[:lower:]' +} + +extract_enabled_addr() { + local raw="$1" + local addr + addr="$(extract_addr "$raw")" + if contains_true "$raw" && [[ -n "$addr" ]]; then + echo "$addr" + else + echo "" + fi +} + +query_dest() { + local rpc="$1" + local bridge="$2" + local selector="$3" + if [[ -z "$rpc" || -z "$bridge" || -z "$selector" ]]; then + echo "UNSET" + return 0 + fi + cast call "$bridge" 'destinations(uint64)((uint64,address,bool))' "$selector" --rpc-url "$rpc" 2>/dev/null || echo "ERROR" +} + +quote_fee_state() { + local rpc="$1" + local bridge="$2" + local selector="$3" + if [[ -z "$rpc" || -z "$bridge" || -z "$selector" ]]; then + echo "n/a" + return 0 + fi + if cast call "$bridge" 'calculateFee(uint64,uint256)(uint256)' "$selector" 4000000000000000 --rpc-url "$rpc" >/dev/null 2>&1; then + echo "quoted" + else + echo "blocked" + fi +} + +balance_of() { + local rpc="$1" + local token="$2" + local wallet="$3" + if [[ -z "$rpc" || -z "$token" || -z "$wallet" ]]; then + echo "0" + return 0 + fi + cast call "$token" 'balanceOf(address)(uint256)' "$wallet" --rpc-url "$rpc" 2>/dev/null | awk '{print $1}' || echo "0" +} + +native_balance() { + local rpc="$1" + if [[ -z "$rpc" ]]; then + echo "0" + return 0 + fi + cast balance "$DEPLOYER" --rpc-url "$rpc" 2>/dev/null || echo "0" +} + +env_value_from_file() { + local file="$1" + local key="$2" + [[ -f "$file" ]] || { + echo "" + return 0 + } + grep -E "^${key}=" "$file" | head -n1 | cut -d'=' -f2- | tr -d ' "\r\n' || true +} + +relay_inventory_note() { + local inventory_raw="$1" + if [[ "$(echo "$inventory_raw >= $UNLOAD_AMOUNT_WEI" | bc)" == "1" ]]; then + echo "enough-for-request" + elif [[ "$(echo "$inventory_raw > 0" | bc)" == "1" ]]; then + echo "small-only" + else + echo "empty" + fi +} + +practical_route_label() { + local name="$1" + local mainnet_quote_state="$2" + case "$name" in + mainnet|bsc|avalanche) echo "relay-backed-direct" ;; + wemix) echo "blocked" ;; + *) + if [[ "$mainnet_quote_state" == "blocked" ]]; then + echo "mainnet-hub-blocked" + else + echo "via-mainnet-hub" + fi + ;; + esac +} + +prereq_text() { + local name="$1" + local practical_route="$2" + local inventory_state="$3" + local mainnet_dest_enabled="$4" + + case "$practical_route" in + relay-backed-direct) + case "$inventory_state" in + enough-for-request) echo "inventory ready" ;; + small-only) echo "inventory only covers tiny unloads" ;; + empty) + if [[ "$name" == "mainnet" ]]; then + echo "seed mainnet relay bridge WETH" + else + echo "seed relay bridge WETH" + fi + ;; + esac + ;; + via-mainnet-hub) + if [[ "$mainnet_dest_enabled" == "enabled" ]]; then + echo "bootstrap mainnet first" + else + echo "fix mainnet destination mapping first" + fi + ;; + mainnet-hub-blocked) + echo "repair Mainnet WETH9 source bridge quote/send path first" + ;; + *) + echo "deploy bridge and seed gas" + ;; + esac +} + +best_use_text() { + local name="$1" + local practical_route="$2" + local inventory_state="$3" + local mainnet_dest_enabled="$4" + + case "$practical_route" in + relay-backed-direct) + case "$name" in + mainnet) + if [[ "$inventory_state" == "enough-for-request" ]]; then + echo "direct 138 -> mainnet unload" + else + echo "bootstrap mainnet through the proven BSC recovery path, then keep relay inventory topped up" + fi + ;; + bsc) + if [[ "$inventory_state" == "enough-for-request" ]]; then + echo "direct 138 -> BSC unload" + elif [[ "$inventory_state" == "small-only" ]]; then + echo "tiny direct 138 -> BSC unloads; use mainnet hub for larger funding" + else + echo "seed BSC relay inventory or fund BSC from mainnet hub" + fi + ;; + avalanche) + if [[ "$inventory_state" == "enough-for-request" ]]; then + echo "direct 138 -> Avalanche unload" + else + echo "seed Avalanche relay inventory or fund Avalanche from mainnet hub" + fi + ;; + esac + ;; + via-mainnet-hub) + if [[ "$mainnet_dest_enabled" == "enabled" ]]; then + echo "bootstrap mainnet, then send mainnet -> $name" + else + echo "repair mainnet -> $name mapping before using the hub" + fi + ;; + mainnet-hub-blocked) + echo "blocked until the Mainnet WETH9 source bridge/router quote path is repaired" + ;; + *) + echo "deploy and wire WEMIX first" + ;; + esac +} + +print_source_send_commands() { + local name="$1" + local selector="$2" + local upper + upper="$(echo "$name" | tr '[:lower:]-' '[:upper:]_')" + + cat </dev/null || true)" +[[ -n "$DEPLOYER" ]] || { + echo "Error: could not derive deployer from PRIVATE_KEY" >&2 + exit 1 +} + +WETH9_138="$(cast call "$CCIPWETH9_BRIDGE_CHAIN138" 'weth9()(address)' --rpc-url "$RPC_URL_138" | tail -n1)" +LINK_138="$(cast call "$CCIPWETH9_BRIDGE_CHAIN138" 'feeToken()(address)' --rpc-url "$RPC_URL_138" | tail -n1)" +MAINNET_WETH9="$(cast call "$MAINNET_CCIP_WETH9_BRIDGE" 'weth9()(address)' --rpc-url "$ETHEREUM_MAINNET_RPC" | tail -n1)" +MAINNET_FEE_TOKEN="$(cast call "$MAINNET_CCIP_WETH9_BRIDGE" 'feeToken()(address)' --rpc-url "$ETHEREUM_MAINNET_RPC" | tail -n1)" + +ZERO_ADDRESS="0x0000000000000000000000000000000000000000" +CHAIN138_SELECTOR_VALUE="${CHAIN138_SELECTOR:-138}" +CURRENT_138_WETH9="${CCIPWETH9_BRIDGE_CHAIN138,,}" +LEGACY_138_WETH9="${CCIPWETH9_BRIDGE_DIRECT_LEGACY:-0x971cD9D156f193df8051E48043C476e53ECd4693}" +LEGACY_138_WETH9="${LEGACY_138_WETH9,,}" +TARGET_CHAIN="${TARGET_CHAIN:-all}" +UNLOAD_AMOUNT_WEI="${UNLOAD_AMOUNT_WEI:-10000000000000000}" +OPTIONAL_UNWRAP_WEI="${OPTIONAL_UNWRAP_WEI:-2000000000000000}" +RECIPIENT="${RECIPIENT:-$DEPLOYER}" + +RELAY_DIR="$PROJECT_ROOT/services/relay" +BSC_RELAY_PROFILE="$RELAY_DIR/.env.bsc" +AVAX_RELAY_PROFILE="$RELAY_DIR/.env.avax" + +CHAINS=( + "mainnet|${ETH_MAINNET_SELECTOR:-}|${ETHEREUM_MAINNET_RPC:-}|${MAINNET_CCIP_WETH9_BRIDGE:-}|$MAINNET_WETH9|${CCIP_RELAY_BRIDGE_MAINNET:-}|$RELAY_DIR/.env" + "bsc|${BSC_SELECTOR:-}|${BSC_MAINNET_RPC:-}|${CCIPWETH9_BRIDGE_BSC:-}|${WETH9_BSC:-}|0x886C6A4ABC064dbf74E7caEc460b7eeC31F1b78C|$BSC_RELAY_PROFILE" + "avalanche|${AVALANCHE_SELECTOR:-}|${AVALANCHE_MAINNET_RPC:-}|${CCIPWETH9_BRIDGE_AVALANCHE:-}|${WETH9_AVALANCHE:-}|0x3f8C409C6072a2B6a4Ff17071927bA70F80c725F|$AVAX_RELAY_PROFILE" + "gnosis|${GNOSIS_SELECTOR:-}|${GNOSIS_RPC:-}|${CCIPWETH9_BRIDGE_GNOSIS:-}|${WETH9_GNOSIS:-}||" + "cronos|${CRONOS_SELECTOR:-}|${CRONOS_RPC:-}|${CCIPWETH9_BRIDGE_CRONOS:-}|${WETH9_CRONOS:-}||" + "celo|${CELO_SELECTOR:-1346049177634351622}|${CELO_RPC:-}|${CCIPWETH9_BRIDGE_CELO:-}|${WETH9_CELO:-}||" + "polygon|${POLYGON_SELECTOR:-}|${POLYGON_MAINNET_RPC:-}|${CCIPWETH9_BRIDGE_POLYGON:-}|${WETH9_POLYGON:-}||" + "arbitrum|${ARBITRUM_SELECTOR:-}|${ARBITRUM_MAINNET_RPC:-}|${CCIPWETH9_BRIDGE_ARBITRUM:-}|${WETH9_ARBITRUM:-}||" + "optimism|${OPTIMISM_SELECTOR:-}|${OPTIMISM_MAINNET_RPC:-}|${CCIPWETH9_BRIDGE_OPTIMISM:-}|${WETH9_OPTIMISM:-}||" + "base|${BASE_SELECTOR:-}|${BASE_MAINNET_RPC:-}|${CCIPWETH9_BRIDGE_BASE:-}|${WETH9_BASE:-}||" + "wemix|${WEMIX_SELECTOR:-5142893604156789321}|${WEMIX_RPC:-}|${CCIPWETH9_BRIDGE_WEMIX:-}|${WETH9_WEMIX:-}||" +) + +echo "# Chain 138 Public-Chain Unload Routes" +echo "# Generated: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" +echo "# Deployer: $DEPLOYER" +echo "# Recipient: $RECIPIENT" +echo "# Unload amount: $UNLOAD_AMOUNT_WEI ($(fmt_ether "$UNLOAD_AMOUNT_WEI"))" +echo "# Optional unwrap amount: $OPTIONAL_UNWRAP_WEI ($(fmt_ether "$OPTIONAL_UNWRAP_WEI"))" +echo "# WETH9 rail only. WETH10 remains drifted and is intentionally excluded." +echo "" + +printf '%-10s | %-22s | %-28s | %-34s\n' "Chain" "Practical Route" "Live Prerequisite" "Best Use" +printf '%s\n' "------------------------------------------------------------------------------------------------------------------------" + +for entry in "${CHAINS[@]}"; do + IFS='|' read -r name selector rpc native_bridge wrapped_token relay_bridge relay_profile <<< "$entry" + if [[ "$TARGET_CHAIN" != "all" && "$TARGET_CHAIN" != "$name" ]]; then + continue + fi + + source_dest="$(query_dest "$RPC_URL_138" "$CCIPWETH9_BRIDGE_CHAIN138" "$selector")" + source_addr="$(extract_enabled_addr "$source_dest")" + route_type="disabled" + if [[ -n "$source_addr" && -n "$native_bridge" && "$source_addr" == "${native_bridge,,}" ]]; then + route_type="direct-native" + elif [[ -n "$source_addr" && -n "$relay_bridge" && "$source_addr" == "${relay_bridge,,}" ]]; then + route_type="relay-backed" + elif [[ -n "$source_addr" ]]; then + route_type="other:$source_addr" + elif [[ -z "$native_bridge" ]]; then + route_type="deploy-first" + fi + + inventory_raw="0" + if [[ "$route_type" == "relay-backed" && -n "$wrapped_token" && -n "$relay_bridge" && -n "$rpc" ]]; then + inventory_raw="$(balance_of "$rpc" "$wrapped_token" "$relay_bridge")" + fi + inventory_state="$(relay_inventory_note "$inventory_raw")" + mainnet_route_to_target="$(query_dest "$ETHEREUM_MAINNET_RPC" "$MAINNET_CCIP_WETH9_BRIDGE" "$selector")" + mainnet_dest_enabled="disabled" + mainnet_quote_state="n/a" + if [[ "$name" != "mainnet" ]]; then + mainnet_quote_state="$(quote_fee_state "$ETHEREUM_MAINNET_RPC" "$MAINNET_CCIP_WETH9_BRIDGE" "$selector")" + fi + if contains_true "$mainnet_route_to_target"; then + mainnet_dest_enabled="enabled" + fi + practical_route="$(practical_route_label "$name" "$mainnet_quote_state")" + prereq="$(prereq_text "$name" "$practical_route" "$inventory_state" "$mainnet_dest_enabled")" + best_use="$(best_use_text "$name" "$practical_route" "$inventory_state" "$mainnet_dest_enabled")" + printf '%-10s | %-22s | %-28s | %-34s\n' "$name" "$practical_route" "$prereq" "$best_use" +done + +echo "" + +for entry in "${CHAINS[@]}"; do + IFS='|' read -r name selector rpc native_bridge wrapped_token relay_bridge relay_profile <<< "$entry" + if [[ "$TARGET_CHAIN" != "all" && "$TARGET_CHAIN" != "$name" ]]; then + continue + fi + + source_dest="$(query_dest "$RPC_URL_138" "$CCIPWETH9_BRIDGE_CHAIN138" "$selector")" + source_addr="$(extract_enabled_addr "$source_dest")" + route_type="disabled" + if [[ -n "$source_addr" && -n "$native_bridge" && "$source_addr" == "${native_bridge,,}" ]]; then + route_type="direct-native" + elif [[ -n "$source_addr" && -n "$relay_bridge" && "$source_addr" == "${relay_bridge,,}" ]]; then + route_type="relay-backed" + elif [[ -n "$source_addr" ]]; then + route_type="other:$source_addr" + elif [[ -z "$native_bridge" ]]; then + route_type="deploy-first" + fi + + inventory_raw="0" + inventory_state="empty" + if [[ "$route_type" == "relay-backed" && -n "$wrapped_token" && -n "$relay_bridge" && -n "$rpc" ]]; then + inventory_raw="$(balance_of "$rpc" "$wrapped_token" "$relay_bridge")" + inventory_state="$(relay_inventory_note "$inventory_raw")" + fi + + mainnet_route_to_target="$(query_dest "$ETHEREUM_MAINNET_RPC" "$MAINNET_CCIP_WETH9_BRIDGE" "$selector")" + mainnet_dest_enabled="disabled" + mainnet_dest_addr="$(extract_enabled_addr "$mainnet_route_to_target")" + mainnet_quote_state="n/a" + if [[ "$name" != "mainnet" ]]; then + mainnet_quote_state="$(quote_fee_state "$ETHEREUM_MAINNET_RPC" "$MAINNET_CCIP_WETH9_BRIDGE" "$selector")" + fi + if [[ "$name" == "mainnet" ]]; then + mainnet_dest_enabled="n/a" + mainnet_dest_addr="n/a" + elif contains_true "$mainnet_route_to_target"; then + mainnet_dest_enabled="enabled" + fi + practical_route="$(practical_route_label "$name" "$mainnet_quote_state")" + + remote_to_mainnet="$(query_dest "$rpc" "$native_bridge" "$ETH_MAINNET_SELECTOR")" + remote_to_138="$(query_dest "$rpc" "$native_bridge" "$CHAIN138_SELECTOR_VALUE")" + mainnet_enabled="disabled" + back_addr="$(extract_enabled_addr "$remote_to_138")" + return_state="missing" + if contains_true "$remote_to_mainnet"; then + mainnet_enabled="enabled" + fi + if [[ -n "$back_addr" ]]; then + if [[ "$back_addr" == "$CURRENT_138_WETH9" ]]; then + return_state="current" + elif [[ "$back_addr" == "$LEGACY_138_WETH9" ]]; then + return_state="legacy" + else + return_state="$back_addr" + fi + fi + + echo "## $name" + echo "- Chain 138 mapping: $route_type" + echo "- Chain 138 destination: ${source_addr:-disabled}" + echo "- Practical route today: $practical_route" + if [[ -n "$wrapped_token" ]]; then + echo "- Destination wrapped asset: $wrapped_token" + fi + if [[ -n "$rpc" ]]; then + echo "- Deployer native gas on destination: $(fmt_ether "$(native_balance "$rpc")")" + fi + if [[ "$route_type" == "relay-backed" ]]; then + echo "- Relay bridge inventory: $(fmt_ether "$inventory_raw")" + echo "- Request coverage at current amount: $(relay_inventory_note "$inventory_raw")" + fi + if [[ "$mainnet_enabled" == "enabled" ]]; then + echo "- Mainnet fan-out from this chain: enabled" + elif [[ "$name" != "mainnet" && -n "$native_bridge" ]]; then + echo "- Mainnet fan-out from this chain: disabled" + fi + if [[ "$name" != "mainnet" && -n "$native_bridge" ]]; then + echo "- Return path to Chain 138: $return_state" + fi + echo "- Mainnet hub destination: ${mainnet_dest_addr:-disabled} ($mainnet_dest_enabled)" + if [[ "$name" != "mainnet" ]]; then + echo "- Mainnet hub quote preflight: $mainnet_quote_state" + fi + echo "- Best use today: $(best_use_text "$name" "$practical_route" "$inventory_state" "$mainnet_dest_enabled")" + echo "" + + if [[ "$practical_route" == "relay-backed-direct" ]]; then + echo "Exact Chain 138 send commands:" + print_source_send_commands "$name" "$selector" + echo "" + if [[ -n "$relay_profile" && -f "$relay_profile" ]]; then + relay_name="$(basename "$relay_profile")" + if [[ "$name" == "mainnet" ]]; then + cat < Gnosis native-bridge attempt failed for exactly this reason." + echo "" + if [[ "$mainnet_dest_enabled" == "enabled" && "$mainnet_quote_state" == "quoted" ]]; then + echo "Exact mainnet hub commands:" + echo "# First bootstrap mainnet. If the mainnet relay bridge is empty, reuse the proven BSC relay plus external bridge recovery flow." + print_mainnet_send_commands "$name" "$selector" "$wrapped_token" "$rpc" + elif [[ "$mainnet_dest_enabled" == "enabled" ]]; then + echo "Mainnet exposes an enabled destination for $name, but the current Mainnet WETH9 source bridge quote path is blocked." + echo "Do not broadcast mainnet fan-out sends until the source bridge/router path is repaired or replaced." + else + echo "Mainnet does not currently expose an enabled destination for $name, so the hub route must be repaired first." + fi + fi + fi + + echo "" +done diff --git a/scripts/deployment/print-gnosis-bootstrap-cast-sequence.sh b/scripts/deployment/print-gnosis-bootstrap-cast-sequence.sh new file mode 100755 index 0000000..1225eb8 --- /dev/null +++ b/scripts/deployment/print-gnosis-bootstrap-cast-sequence.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Deprecated helper kept as a guardrail for historical references. +# +# Why deprecated: +# - Chain 138 emits through a custom router that only creates MessageSent events. +# - Native destination mappings like 138 -> Gnosis do not by themselves create +# a live delivery path into public-chain CCIP bridges. +# - A real 138 -> Gnosis test already failed for this reason. +# +# Use instead: +# ./scripts/deployment/print-chain138-public-chain-unload-routes.sh + +set -euo pipefail + +cat <<'EOF' +print-gnosis-bootstrap-cast-sequence.sh is deprecated. + +Reason: +- `138 -> Gnosis` is not a practical first-hop route with the current live architecture. +- Chain 138 uses a custom event-emitting router, so a native Gnosis bridge mapping is not enough to deliver the message. +- The earlier live `138 -> Gnosis` attempt failed for exactly that reason. + +Use this instead: + + cd /home/intlc/projects/proxmox/smom-dbis-138 + ./scripts/deployment/print-chain138-public-chain-unload-routes.sh + +Useful examples: + + TARGET_CHAIN=mainnet ./scripts/deployment/print-chain138-public-chain-unload-routes.sh + TARGET_CHAIN=bsc ./scripts/deployment/print-chain138-public-chain-unload-routes.sh + TARGET_CHAIN=gnosis ./scripts/deployment/print-chain138-public-chain-unload-routes.sh + +Current practical model: +- first-hop relay-backed lanes: Mainnet, BSC, Avalanche +- hub-distribution lanes after mainnet bootstrap: Gnosis, Cronos, Celo, Polygon, Arbitrum, Optimism, Base +- WEMIX: deploy and wire first +EOF diff --git a/scripts/deployment/run-pmm-full-parity-all-phases.sh b/scripts/deployment/run-pmm-full-parity-all-phases.sh index c698141..7b9fc39 100755 --- a/scripts/deployment/run-pmm-full-parity-all-phases.sh +++ b/scripts/deployment/run-pmm-full-parity-all-phases.sh @@ -19,11 +19,14 @@ RUN_PHASE2="${RUN_PHASE2:-1}" DRY_RUN="${DRY_RUN:-}" RPC_138="${RPC_URL_138:-${RPC_URL:-http://192.168.11.211:8545}}" GAS_PRICE="${GAS_PRICE_138:-${GAS_PRICE:-1000000000}}" -INTEGRATION="${DODO_PMM_INTEGRATION_ADDRESS:-${DODO_PMM_INTEGRATION:-0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d}}" -POOL_CUSDTCUSDC="${POOL_CUSDTCUSDC:-0x9fcB06Aa1FD5215DC0E91Fd098aeff4B62fEa5C8}" -# Keep the default aligned with the live canonical integration/provider mapping. -POOL_CUSDTUSDT="${POOL_CUSDTUSDT:-0x6fc60DEDc92a2047062294488539992710b99D71}" -POOL_CUSDCUSDC="${POOL_CUSDCUSDC:-0x90bd9Bf18Daa26Af3e814ea224032d015db58Ea5}" +INTEGRATION="${DODO_PMM_INTEGRATION_ADDRESS:-${DODO_PMM_INTEGRATION:-0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895}}" +POOL_CUSDTCUSDC="${POOL_CUSDTCUSDC:-0x9e89bAe009adf128782E19e8341996c596ac40dC}" +POOL_CUSDTUSDT="${POOL_CUSDTUSDT:-0x866Cb44b59303d8dc5f4F9E3E7A8e8b0bf238d66}" +POOL_CUSDCUSDC="${POOL_CUSDCUSDC:-0xc39B7D0F40838cbFb54649d327f49a6DAC964062}" +export DODO_LP_FEE_BPS="${DODO_LP_FEE_BPS:-10}" +export DODO_INITIAL_PRICE_1E18="${DODO_INITIAL_PRICE_1E18:-1000000000000000000}" +export DODO_K_FACTOR_1E18="${DODO_K_FACTOR_1E18:-0}" +export DODO_ENABLE_TWAP="${DODO_ENABLE_TWAP:-false}" export RPC_URL_138="$RPC_138" export DODO_PMM_INTEGRATION_ADDRESS="$INTEGRATION" @@ -46,21 +49,15 @@ phase1() { return 0 fi - # 1a) Create all 3 pools in parallel (if not already created — scripts will fail if pool exists) - log "Creating PMM pools (parallel)..." + # 1a) Create all 3 pools sequentially (single deployer nonce lane; canonical stable params are i=1e18, k=0) + log "Creating PMM pools (sequential)..." if [[ -z "$DRY_RUN" ]]; then - ( forge script script/dex/CreateCUSDTCUSDCPool.s.sol:CreateCUSDTCUSDCPool \ - --rpc-url "$RPC_138" --broadcast --private-key "$PRIVATE_KEY" --with-gas-price "$GAS_PRICE" -vv 2>&1 | tee /tmp/pmm-create-cusdt-cusdc.log ) & - PID1=$! - ( forge script script/dex/CreateCUSDTUSDTPool.s.sol:CreateCUSDTUSDTPool \ - --rpc-url "$RPC_138" --broadcast --private-key "$PRIVATE_KEY" --with-gas-price "$GAS_PRICE" -vv 2>&1 | tee /tmp/pmm-create-cusdt-usdt.log ) & - PID2=$! - ( forge script script/dex/CreateCUSDCUSDCPool.s.sol:CreateCUSDCUSDCPool \ - --rpc-url "$RPC_138" --broadcast --private-key "$PRIVATE_KEY" --with-gas-price "$GAS_PRICE" -vv 2>&1 | tee /tmp/pmm-create-cusdc-usdc.log ) & - PID3=$! - wait $PID1 2>/dev/null || true - wait $PID2 2>/dev/null || true - wait $PID3 2>/dev/null || true + forge script script/dex/CreateCUSDTCUSDCPool.s.sol:CreateCUSDTCUSDCPool \ + --rpc-url "$RPC_138" --broadcast --private-key "$PRIVATE_KEY" --with-gas-price "$GAS_PRICE" -vv 2>&1 | tee /tmp/pmm-create-cusdt-cusdc.log || true + forge script script/dex/CreateCUSDTUSDTPool.s.sol:CreateCUSDTUSDTPool \ + --rpc-url "$RPC_138" --broadcast --private-key "$PRIVATE_KEY" --with-gas-price "$GAS_PRICE" -vv 2>&1 | tee /tmp/pmm-create-cusdt-usdt.log || true + forge script script/dex/CreateCUSDCUSDCPool.s.sol:CreateCUSDCUSDCPool \ + --rpc-url "$RPC_138" --broadcast --private-key "$PRIVATE_KEY" --with-gas-price "$GAS_PRICE" -vv 2>&1 | tee /tmp/pmm-create-cusdc-usdc.log || true else log "[DRY RUN] forge script CreateCUSDTCUSDCPool ..." log "[DRY RUN] forge script CreateCUSDTUSDTPool ..." diff --git a/scripts/deployment/sync-chain138-pmm-pools-from-json.sh b/scripts/deployment/sync-chain138-pmm-pools-from-json.sh index d295a9c..b8e7842 100755 --- a/scripts/deployment/sync-chain138-pmm-pools-from-json.sh +++ b/scripts/deployment/sync-chain138-pmm-pools-from-json.sh @@ -18,11 +18,21 @@ ORIG_TX_TIMEOUT_SECONDS="${TX_TIMEOUT_SECONDS-}" ORIG_POST_CREATE_POLL_SECONDS="${POST_CREATE_POLL_SECONDS-}" ORIG_POST_CREATE_POLL_INTERVAL="${POST_CREATE_POLL_INTERVAL-}" -if [[ -f "$ENV_FILE" ]]; then +if [[ -f "$SMOM_ROOT/scripts/lib/deployment/dotenv.sh" ]]; then + # shellcheck disable=SC1091 + source "$SMOM_ROOT/scripts/lib/deployment/dotenv.sh" + load_deployment_env --repo-root "$SMOM_ROOT" +elif [[ -f "$ENV_FILE" ]]; then + had_nounset=0 + if [[ $- == *u* ]]; then + had_nounset=1 + set +u + fi set -a # shellcheck disable=SC1090 source "$ENV_FILE" set +a + (( had_nounset )) && set -u fi [[ -n "$ORIG_RPC_URL_138" ]] && RPC_URL_138="$ORIG_RPC_URL_138" diff --git a/scripts/lib/deployment/dotenv.sh b/scripts/lib/deployment/dotenv.sh index 54db294..b907f36 100644 --- a/scripts/lib/deployment/dotenv.sh +++ b/scripts/lib/deployment/dotenv.sh @@ -48,6 +48,19 @@ load_deployment_env() { set +a (( had_nounset_secure )) && set -u fi + local keeper_secret_file="${KEEPER_SECRET_FILE:-$HOME/.secure-secrets/chain138-keeper.env}" + if [[ -z "${KEEPER_PRIVATE_KEY:-}" && -f "$keeper_secret_file" ]]; then + local had_nounset_keeper=0 + if [[ $- == *u* ]]; then + had_nounset_keeper=1 + set +u + fi + set -a + # shellcheck disable=SC1090 + source "$keeper_secret_file" + set +a + (( had_nounset_keeper )) && set -u + fi if [[ -z "${PRIVATE_KEY:-}" && -n "${DEPLOYER_PRIVATE_KEY:-}" ]]; then export PRIVATE_KEY="$DEPLOYER_PRIVATE_KEY" fi diff --git a/scripts/mint-c-star-v2-wave1-138.sh b/scripts/mint-c-star-v2-wave1-138.sh new file mode 100755 index 0000000..3428a31 --- /dev/null +++ b/scripts/mint-c-star-v2-wave1-138.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Mint GRU V2 fiat + XAU tokens (wave-1 list) on Chain 138 to the deployer. +# Excludes cUSDT_V2 / cUSDC_V2; add those addresses to the loop if needed. +# +# Usage: ./scripts/mint-c-star-v2-wave1-138.sh [amount_human] +# Default amount_human = 25000. 6 decimals for all listed tokens. +# cXAUC_V2 / cXAUT_V2: amount_human is troy ounces (1 token = 1 troy oz Au). +# Requires: PRIVATE_KEY, RPC_URL_138 in .env. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SMOM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PROXMOX_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$SMOM_ROOT" +[ -f .env ] && set -a && source .env && set +a +if [[ -z "${PRIVATE_KEY:-}" && -f "${PROXMOX_ROOT}/scripts/lib/load-project-env.sh" ]]; then + # shellcheck source=../../scripts/lib/load-project-env.sh + PROJECT_ROOT="$PROXMOX_ROOT" source "${PROXMOX_ROOT}/scripts/lib/load-project-env.sh" + cd "$SMOM_ROOT" +fi + +RPC="${RPC_URL_138:-${RPC_URL:-http://192.168.11.211:8545}}" +# CompliantFiatTokenV2 mint uses ~210k+ gas (compliance + capacity); 100k OOGs. +GAS_LIMIT_MINT="${GAS_LIMIT_MINT:-450000}" +AMOUNT_HUMAN="${1:-25000}" +BASE_UNITS=$((AMOUNT_HUMAN * 1000000)) + +[ -n "${PRIVATE_KEY:-}" ] || { echo "PRIVATE_KEY not set"; exit 1; } +DEPLOYER=$(cast wallet address "$PRIVATE_KEY" 2>/dev/null) || exit 1 + +echo "=== Mint c* V2 (wave-1) on Chain 138 ===" +echo " Deployer: $DEPLOYER Amount: $AMOUNT_HUMAN tokens each ($BASE_UNITS base)" +echo "" + +for pair in \ + "cEURC_V2:0x243e6581Dc8a98d98B92265858b322b193555C81" \ + "cEURT_V2:0x2bAFA83d8fF8BaE9505511998987D0659791605B" \ + "cGBPC_V2:0x707508D223103f5D2d9EFBc656302c9d48878b29" \ + "cGBPT_V2:0xee17c18E10E55ce23F7457D018aAa2Fb1E64B281" \ + "cAUDC_V2:0xfb37aFd415B70C5cEDc9bA58a72D517207b769Bb" \ + "cJPYC_V2:0x2c751bBE4f299b989b3A8c333E0A966cdcA6Fd98" \ + "cCHFC_V2:0x60B7FB8e0DD0Be8595AD12Fe80AE832861Be747c" \ + "cCADC_V2:0xe799033c87fE0CE316DAECcefBE3134CC74b76a9" \ + "cXAUC_V2:0xF0F0F81bE3D033D8586bAfd2293e37eE2f615647" \ + "cXAUT_V2:0x89477E982847023aaB5C3492082cd1bB4b1b9Ef1"; do + sym="${pair%%:*}" + addr="${pair#*:}" + echo -n "Minting $sym... " + out=$(cast send "$addr" "mint(address,uint256)" "$DEPLOYER" "$BASE_UNITS" \ + --rpc-url "$RPC" --private-key "$PRIVATE_KEY" --legacy --gas-limit "$GAS_LIMIT_MINT" 2>&1) || { echo "FAIL (send)"; echo "$out"; continue; } + echo "$out" + txhash=$(echo "$out" | awk '/transactionHash/ {print $2; exit}') + if [[ -n "$txhash" ]]; then + st=$(cast receipt "$txhash" status --rpc-url "$RPC" 2>/dev/null | awk '{print $1}' || echo "") + [[ "$st" == "1" ]] && echo " receipt: success" || echo " receipt: check $txhash (parsed status='$st')" + fi +done +echo "Done." diff --git a/scripts/mint-ceurt-transfer-cxau-recipient-138.sh b/scripts/mint-ceurt-transfer-cxau-recipient-138.sh new file mode 100755 index 0000000..54129ac --- /dev/null +++ b/scripts/mint-ceurt-transfer-cxau-recipient-138.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Mint cEURT to the deployer (minter); transfer fixed cXAUC/cXAUT (troy oz) to a recipient. +# +# Usage: +# ./scripts/mint-ceurt-transfer-cxau-recipient-138.sh [cEURT_mint_human] +# RECIPIENT=0x... XAU_OZ=500 CEURT_MINT_HUMAN=100000 ./scripts/mint-ceurt-transfer-cxau-recipient-138.sh +# DRY_RUN=1 ./scripts/mint-ceurt-transfer-cxau-recipient-138.sh +# +# Defaults: RECIPIENT=0xCD386a74ab9E6378C8B5B44299CF595119647989, XAU_OZ=500, +# cEURT mint = first arg or CEURT_MINT_HUMAN or 100000 (6 decimals). +# +# Requires: PRIVATE_KEY, RPC_URL_138 (or RPC_URL) in smom-dbis-138/.env or repo .env +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_ROOT" +[ -f .env ] && set -a && source .env && set +a +[ -f "$PROJECT_ROOT/../.env" ] && set -a && source "$PROJECT_ROOT/../.env" && set +a + +RPC="${RPC_URL_138:-${RPC_URL:-http://192.168.11.211:8545}}" +RECIPIENT="${RECIPIENT:-0xCD386a74ab9E6378C8B5B44299CF595119647989}" +XAU_OZ="${XAU_OZ:-500}" +CEURT_ADDR="0xdf4b71c61E5912712C1Bdd451416B9aC26949d72" +CXAUC="0x290E52a8819A4fbD0714E517225429aA2B70EC6b" +CXAUT="0x94e408E26c6FD8F4ee00b54dF19082FDA07dC96E" + +CEURT_MINT_HUMAN="${1:-${CEURT_MINT_HUMAN:-100000}}" +if [ -n "${PRIVATE_KEY:-}" ]; then + DEPLOYER="$(cast wallet address "$PRIVATE_KEY")" || exit 1 +else + DEPLOYER="(set PRIVATE_KEY)" +fi + +if [ -z "${PRIVATE_KEY:-}" ] && [ -z "${DRY_RUN:-}" ]; then + echo "PRIVATE_KEY not set (use DRY_RUN=1 to print intended commands only)" + exit 1 +fi +CEURT_BASE=$((CEURT_MINT_HUMAN * 1000000)) +XAU_BASE=$((XAU_OZ * 1000000)) + +echo "=== Chain 138: mint cEURT + transfer cXAUC/cXAUT ===" +echo " RPC: $RPC" +echo " Deployer: $DEPLOYER" +echo " Recipient: $RECIPIENT" +echo " Mint cEURT to deployer: $CEURT_MINT_HUMAN ($CEURT_BASE base)" +echo " Transfer each XAU token: $XAU_OZ troy oz ($XAU_BASE base)" +echo "" + +if [ -n "${DRY_RUN:-}" ]; then + echo "[dry-run] cast send $CEURT_ADDR mint $DEPLOYER $CEURT_BASE" + echo "[dry-run] cast send $CXAUC transfer $RECIPIENT $XAU_BASE" + echo "[dry-run] cast send $CXAUT transfer $RECIPIENT $XAU_BASE" + exit 0 +fi + +cast send "$CEURT_ADDR" "mint(address,uint256)" "$DEPLOYER" "$CEURT_BASE" \ + --rpc-url "$RPC" --private-key "$PRIVATE_KEY" --legacy --gas-limit 200000 + +cast send "$CXAUC" "transfer(address,uint256)" "$RECIPIENT" "$XAU_BASE" \ + --rpc-url "$RPC" --private-key "$PRIVATE_KEY" --legacy --gas-limit 200000 + +cast send "$CXAUT" "transfer(address,uint256)" "$RECIPIENT" "$XAU_BASE" \ + --rpc-url "$RPC" --private-key "$PRIVATE_KEY" --legacy --gas-limit 200000 + +echo "Done." diff --git a/scripts/mint-cw-on-chain.sh b/scripts/mint-cw-on-chain.sh index 8d3b9e3..5c3df6e 100755 --- a/scripts/mint-cw-on-chain.sh +++ b/scripts/mint-cw-on-chain.sh @@ -10,7 +10,18 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$PROJECT_ROOT" -[[ -f .env ]] && set -a && source .env && set +a +if [[ -f "$SCRIPT_DIR/lib/deployment/dotenv.sh" ]]; then + # shellcheck disable=SC1090 + source "$SCRIPT_DIR/lib/deployment/dotenv.sh" + load_deployment_env --repo-root "$PROJECT_ROOT" +elif [[ -f .env ]]; then + set -a + source .env + set +a + if [[ -z "${PRIVATE_KEY:-}" && -n "${DEPLOYER_PRIVATE_KEY:-}" ]]; then + export PRIVATE_KEY="$DEPLOYER_PRIVATE_KEY" + fi +fi CHAIN_NAME="${1:-}" AMOUNT_HUMAN="${2:-1000000}" diff --git a/scripts/mint-for-liquidity.sh b/scripts/mint-for-liquidity.sh index cbdf9b6..571f7f6 100755 --- a/scripts/mint-for-liquidity.sh +++ b/scripts/mint-for-liquidity.sh @@ -93,9 +93,9 @@ if [[ "$RUN_ADD_LIQUIDITY" == true ]]; then fi export ADD_LIQUIDITY_BASE_AMOUNT ADD_LIQUIDITY_QUOTE_AMOUNT # Default pool addresses (Chain 138) if not in .env - export POOL_CUSDTCUSDC="${POOL_CUSDTCUSDC:-0x9fcB06Aa1FD5215DC0E91Fd098aeff4B62fEa5C8}" - export POOL_CUSDTUSDT="${POOL_CUSDTUSDT:-0x6fc60DEDc92a2047062294488539992710b99D71}" - export POOL_CUSDCUSDC="${POOL_CUSDCUSDC:-0x90bd9Bf18Daa26Af3e814ea224032d015db58Ea5}" + export POOL_CUSDTCUSDC="${POOL_CUSDTCUSDC:-0x9e89bAe009adf128782E19e8341996c596ac40dC}" + export POOL_CUSDTUSDT="${POOL_CUSDTUSDT:-0x866Cb44b59303d8dc5f4F9E3E7A8e8b0bf238d66}" + export POOL_CUSDCUSDC="${POOL_CUSDCUSDC:-0xc39B7D0F40838cbFb54649d327f49a6DAC964062}" if [[ -n "${DODO_PMM_INTEGRATION:-}" || -n "${DODO_PMM_INTEGRATION_ADDRESS:-}" ]]; then # Use pending nonce so broadcast does not get -32001 "Nonce too low" (mints just used N and N+1) NEXT_NONCE=$(cast nonce "$DEPLOYER" --rpc-url "$RPC" --block pending 2>/dev/null || true) diff --git a/scripts/reserve/pmm-mesh-6s-automation.sh b/scripts/reserve/pmm-mesh-6s-automation.sh index 739cc9f..b3af700 100755 --- a/scripts/reserve/pmm-mesh-6s-automation.sh +++ b/scripts/reserve/pmm-mesh-6s-automation.sh @@ -8,7 +8,7 @@ # RPC_URL_138 / RPC_URL # PRIVATE_KEY or DEPLOYER_PRIVATE_KEY — ETH/USD oracle push (update-oracle-price.sh) # KEEPER_PRIVATE_KEY + PRICE_FEED_KEEPER_ADDRESS — on-chain keeper performUpkeep when needed -# DODO_PMM_INTEGRATION_ADDRESS — default 0x5BDc… (current canonical integration) +# DODO_PMM_INTEGRATION_ADDRESS — default 0x86AD… (current canonical integration) # PMM_MESH_POLL_POOLS — space-separated pool addresses (default: cUSDT/cUSDC PMM pool) # ENABLE_MESH_ORACLE_TICK=1 Run scripts/update-oracle-price.sh each tick (skips on-chain if <1% move) # ENABLE_MESH_KEEPER_TICK=1 Run keeper when checkUpkeep is true @@ -16,6 +16,9 @@ # ENABLE_MESH_WETH_READS=1 eth_call WETH9/WETH10 totalSupply (ETH mesh signal) # MESH_WETH_WRAP_WEI=0 If >0 and KEEPER_PRIVATE_KEY set: WETH9.deposit{value} (costs gas; rare) # MESH_WETH_WRAP_EVERY_N=60 Only wrap every N ticks when MESH_WETH_WRAP_WEI>0 +# MESH_TX_BACKOFF_SEC=30 Cooldown after "known tx" / "replacement underpriced" send errors +# KEEPER_SECRET_FILE Dedicated keeper env file (default: /root/.secure-secrets/chain138-keeper.env) +# ALLOW_ORACLE_KEY_FOR_KEEPER=0 Set to 1 to let keeper reuse PRIVATE_KEY when no dedicated keeper key exists # MAX_TICKS= Exit after N ticks (empty = run forever) # DRY_RUN=1 Log only, no txs # @@ -25,6 +28,22 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SMOM_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" cd "$SMOM_ROOT" +ORACLE_PUBLISHER_ENV="${ORACLE_PUBLISHER_ENV:-/opt/oracle-publisher/.env}" +KEEPER_SECRET_FILE="${KEEPER_SECRET_FILE:-/root/.secure-secrets/chain138-keeper.env}" +if [ -f "$ORACLE_PUBLISHER_ENV" ]; then + set -a + # shellcheck source=/dev/null + source "$ORACLE_PUBLISHER_ENV" + set +a +fi + +if [ -f "$KEEPER_SECRET_FILE" ]; then + set -a + # shellcheck source=/dev/null + source "$KEEPER_SECRET_FILE" + set +a +fi + if [ -f "$SMOM_ROOT/.env" ]; then set -a # shellcheck source=/dev/null @@ -43,9 +62,9 @@ fi RPC="${RPC_URL_138:-${RPC_URL:-http://192.168.11.211:8545}}" INTERVAL="${PMM_MESH_INTERVAL_SEC:-6}" -DODO="${DODO_PMM_INTEGRATION_ADDRESS:-${DODO_PMM_INTEGRATION:-0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d}}" +DODO="${DODO_PMM_INTEGRATION_ADDRESS:-${DODO_PMM_INTEGRATION:-0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895}}" # Canonical cUSDT/cUSDC PMM pool on Chain 138 (current integration) -DEFAULT_POOLS="0xff8d3b8fDF7B112759F076B69f4271D4209C0849" +DEFAULT_POOLS="0x9e89bAe009adf128782E19e8341996c596ac40dC" POOLS="${PMM_MESH_POLL_POOLS:-$DEFAULT_POOLS}" WETH9="${WETH9_ADDRESS:-0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2}" WETH10="${WETH10_ADDRESS:-0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f}" @@ -56,14 +75,45 @@ ENABLE_PMM_READS="${ENABLE_MESH_PMM_READS:-1}" ENABLE_WETH_READS="${ENABLE_MESH_WETH_READS:-1}" WRAP_WEI="${MESH_WETH_WRAP_WEI:-0}" WRAP_EVERY_N="${MESH_WETH_WRAP_EVERY_N:-60}" +MESH_TX_BACKOFF_SEC="${MESH_TX_BACKOFF_SEC:-30}" +ALLOW_ORACLE_KEY_FOR_KEEPER="${ALLOW_ORACLE_KEY_FOR_KEEPER:-0}" # Besu often needs an explicit gas price for replacement / mempool policy. MESH_CAST_GAS_PRICE="${MESH_CAST_GAS_PRICE:-2gwei}" ORACLE_PK="${PRIVATE_KEY:-${DEPLOYER_PRIVATE_KEY:-}}" -KEEPER_PK="${KEEPER_PRIVATE_KEY:-${PRIVATE_KEY:-}}" +KEEPER_PK="${KEEPER_PRIVATE_KEY:-}" +if [ -z "$KEEPER_PK" ] && [ "$ALLOW_ORACLE_KEY_FOR_KEEPER" = "1" ]; then + KEEPER_PK="${PRIVATE_KEY:-}" +fi +ORACLE_ADDR="" +KEEPER_ADDR="" +if [ -n "$ORACLE_PK" ]; then + ORACLE_ADDR="$(cast wallet address --private-key "$ORACLE_PK" 2>/dev/null || true)" +fi +if [ -n "$KEEPER_PK" ]; then + KEEPER_ADDR="$(cast wallet address --private-key "$KEEPER_PK" 2>/dev/null || true)" +fi + +NEXT_KEEPER_TX_AT=0 +NEXT_ORACLE_TX_AT=0 log() { echo "[$(date -Iseconds)] $*"; } +is_retryable_tx_error() { + local text="${1:-}" + grep -qiE 'Known transaction|Replacement transaction underpriced' <<<"$text" +} + +account_has_pending_nonce_gap() { + local addr="${1:-}" + [ -n "$addr" ] || return 1 + local latest pending + latest="$(cast nonce "$addr" --rpc-url "$RPC" --block latest 2>/dev/null || true)" + pending="$(cast nonce "$addr" --rpc-url "$RPC" --block pending 2>/dev/null || true)" + [[ "$latest" =~ ^[0-9]+$ && "$pending" =~ ^[0-9]+$ ]] || return 1 + (( pending > latest )) +} + eth_call_price() { local to="$1" data="$2" cast rpc eth_call "{\"to\":\"$to\",\"data\":\"$data\"}" latest --rpc-url "$RPC" 2>/dev/null | tr -d '\n\"' || true @@ -95,8 +145,19 @@ tick_weth_reads() { tick_keeper() { [ "$ENABLE_KEEPER" = "1" ] || return 0 + local now + now="$(date +%s)" + if (( now < NEXT_KEEPER_TX_AT )); then + log "keeper tick cooling down until $(date -d "@$NEXT_KEEPER_TX_AT" -Iseconds)" + return 0 + fi local k="${PRICE_FEED_KEEPER_ADDRESS:-}" [ -n "$k" ] && [ -n "$KEEPER_PK" ] || { log "keeper tick skipped (set PRICE_FEED_KEEPER_ADDRESS + KEEPER_PRIVATE_KEY)"; return 0; } + if account_has_pending_nonce_gap "$KEEPER_ADDR"; then + NEXT_KEEPER_TX_AT=$(( now + MESH_TX_BACKOFF_SEC )) + log "keeper tx already pending for $KEEPER_ADDR; backoff ${MESH_TX_BACKOFF_SEC}s" + return 0 + fi local raw dec first raw="$(cast rpc eth_call "{\"to\":\"$k\",\"data\":\"$(cast calldata "checkUpkeep()")\"}" latest --rpc-url "$RPC" 2>/dev/null | tr -d '\n\"')" || return 0 dec="$(cast abi-decode "checkUpkeep()(bool,address[])" "$raw" 2>/dev/null)" || return 0 @@ -107,9 +168,20 @@ tick_keeper() { log "[dry-run] cast send performUpkeep" return 0 fi - if ! cast send "$k" "performUpkeep()" --rpc-url "$RPC" --private-key "$KEEPER_PK" \ - --legacy --gas-limit 500000 --gas-price "$MESH_CAST_GAS_PRICE"; then - log "WARN: performUpkeep failed (RPC / gas / nonce); next tick in ${INTERVAL}s" + local out rc + set +e + out="$(cast send "$k" "performUpkeep()" --rpc-url "$RPC" --private-key "$KEEPER_PK" \ + --legacy --gas-limit 500000 --gas-price "$MESH_CAST_GAS_PRICE" 2>&1)" + rc=$? + set -e + if [ "$rc" -ne 0 ]; then + echo "$out" + if is_retryable_tx_error "$out"; then + NEXT_KEEPER_TX_AT=$(( now + MESH_TX_BACKOFF_SEC )) + log "WARN: performUpkeep pending/underpriced; keeper backoff ${MESH_TX_BACKOFF_SEC}s" + else + log "WARN: performUpkeep failed (RPC / gas / nonce); next tick in ${INTERVAL}s" + fi fi else log "keeper checkUpkeep=false (no tx)" @@ -118,14 +190,31 @@ tick_keeper() { tick_oracle() { [ "$ENABLE_ORACLE" = "1" ] || return 0 + local now + now="$(date +%s)" + if (( now < NEXT_ORACLE_TX_AT )); then + log "oracle tick cooling down until $(date -d "@$NEXT_ORACLE_TX_AT" -Iseconds)" + return 0 + fi [ -n "$ORACLE_PK" ] || { log "oracle tick skipped (set PRIVATE_KEY)"; return 0; } if [ -n "${DRY_RUN:-}" ]; then log "[dry-run] update-oracle-price.sh" return 0 fi # Exits 0 when price unchanged (<1%) or on success; failures must not stop the loop - if ! bash "$SMOM_ROOT/scripts/update-oracle-price.sh" "$RPC"; then - log "WARN: update-oracle-price.sh failed (rate limit / RPC / gas); next tick in ${INTERVAL}s" + local out rc + set +e + out="$(bash "$SMOM_ROOT/scripts/update-oracle-price.sh" "$RPC" 2>&1)" + rc=$? + set -e + if [ "$rc" -ne 0 ]; then + echo "$out" + if is_retryable_tx_error "$out"; then + NEXT_ORACLE_TX_AT=$(( now + MESH_TX_BACKOFF_SEC )) + log "WARN: oracle tx pending/underpriced; oracle backoff ${MESH_TX_BACKOFF_SEC}s" + else + log "WARN: update-oracle-price.sh failed (rate limit / RPC / gas); next tick in ${INTERVAL}s" + fi fi } diff --git a/scripts/transfer-fiat-c-star-to-recipient-138.sh b/scripts/transfer-fiat-c-star-to-recipient-138.sh new file mode 100755 index 0000000..a9491a5 --- /dev/null +++ b/scripts/transfer-fiat-c-star-to-recipient-138.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Transfer 10,000 (6 decimals) of each fiat c* from deployer to recipient. Excludes cXAUC/cXAUT. +# +# Usage: +# source ../scripts/lib/load-project-env.sh # from repo root, or ensure PRIVATE_KEY + RPC_URL_138 +# ./scripts/transfer-fiat-c-star-to-recipient-138.sh +# RECIPIENT=0x... AMOUNT_HUMAN=10000 ./scripts/transfer-fiat-c-star-to-recipient-138.sh +# DRY_RUN=1 ./scripts/transfer-fiat-c-star-to-recipient-138.sh +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_ROOT" +[ -f .env ] && set -a && source .env && set +a +[ -f "$PROJECT_ROOT/../.env" ] && set -a && source "$PROJECT_ROOT/../.env" && set +a + +RPC="${RPC_URL_138:-${RPC_URL:-http://192.168.11.211:8545}}" +RECIPIENT="${RECIPIENT:-0xCD386a74ab9E6378C8B5B44299CF595119647989}" +AMOUNT_HUMAN="${AMOUNT_HUMAN:-10000}" +BASE_UNITS=$((AMOUNT_HUMAN * 1000000)) + +if [ -n "${PRIVATE_KEY:-}" ]; then + DEPLOYER="$(cast wallet address "$PRIVATE_KEY")" || exit 1 +else + DEPLOYER="(set PRIVATE_KEY)" +fi +if [ -z "${PRIVATE_KEY:-}" ] && [ -z "${DRY_RUN:-}" ]; then + echo "PRIVATE_KEY not set (use DRY_RUN=1 to print commands only)" + exit 1 +fi + +# Fiat compliant c* only (canonical Chain 138) — not XAU +declare -a TOKENS=( + "cUSDT:0x93E66202A11B1772E55407B32B44e5Cd8eda7f22" + "cUSDC:0xf22258f57794CC8E06237084b353Ab30fFfa640b" + "cEURC:0x8085961F9cF02b4d800A3c6d386D31da4B34266a" + "cEURT:0xdf4b71c61E5912712C1Bdd451416B9aC26949d72" + "cGBPC:0x003960f16D9d34F2e98d62723B6721Fb92074aD2" + "cGBPT:0x350f54e4D23795f86A9c03988c7135357CCaD97c" + "cAUDC:0xD51482e567c03899eecE3CAe8a058161FD56069D" + "cJPYC:0xEe269e1226a334182aace90056EE4ee5Cc8A6770" + "cCHFC:0x873990849DDa5117d7C644f0aF24370797C03885" + "cCADC:0x54dBd40cF05e15906A2C21f600937e96787f5679" +) + +echo "=== Transfer fiat c* (Chain 138) ===" +echo " RPC: $RPC" +echo " Deployer: $DEPLOYER" +echo " Recipient: $RECIPIENT" +echo " Amount: $AMOUNT_HUMAN tokens each ($BASE_UNITS base)" +echo "" + +for pair in "${TOKENS[@]}"; do + sym="${pair%%:*}" + addr="${pair#*:}" + if [ -n "${DRY_RUN:-}" ]; then + echo "[dry-run] cast send $addr transfer $RECIPIENT $BASE_UNITS # $sym" + continue + fi + echo -n "Transfer $sym... " + if cast send "$addr" "transfer(address,uint256)" "$RECIPIENT" "$BASE_UNITS" \ + --rpc-url "$RPC" --private-key "$PRIVATE_KEY" --legacy --gas-limit 200000; then + echo OK + else + echo FAIL + exit 1 + fi +done +echo "Done." diff --git a/scripts/update-oracle-price.sh b/scripts/update-oracle-price.sh index cd74147..3213246 100755 --- a/scripts/update-oracle-price.sh +++ b/scripts/update-oracle-price.sh @@ -20,6 +20,13 @@ log_error() { echo -e "${RED}[ERROR]${NC} $1"; } # Load .env if available SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR/.." || exit 1 +ORACLE_PUBLISHER_ENV="${ORACLE_PUBLISHER_ENV:-/opt/oracle-publisher/.env}" +if [ -f "$ORACLE_PUBLISHER_ENV" ]; then + set -a + # shellcheck source=/dev/null + source "$ORACLE_PUBLISHER_ENV" + set +a +fi if [ -f .env ]; then set -a source .env @@ -314,4 +321,3 @@ log_success "=========================================" log_success "Oracle Price Update Complete!" log_success "=========================================" echo "" - diff --git a/services/btc-intake/package.json b/services/btc-intake/package.json new file mode 100644 index 0000000..539bace --- /dev/null +++ b/services/btc-intake/package.json @@ -0,0 +1,17 @@ +{ + "name": "btc-intake-service", + "version": "1.0.0", + "description": "BTC intake watcher for Chain 138 cBTC mint workflows", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": {}, + "devDependencies": { + "@types/node": "^20.19.33", + "typescript": "^5.9.3", + "vitest": "^1.6.1" + } +} diff --git a/services/btc-intake/src/custody-adapter.ts b/services/btc-intake/src/custody-adapter.ts new file mode 100644 index 0000000..7caa30c --- /dev/null +++ b/services/btc-intake/src/custody-adapter.ts @@ -0,0 +1,36 @@ +import type { BitcoinDepositEvent } from './types'; + +export interface CustodyAdapter { + allocateDepositAddress(input: { instructionId: string; clientId: string }): Promise; + listDepositEvents(cursor?: string): Promise<{ events: BitcoinDepositEvent[]; cursor?: string }>; + getConfirmedReserveBalanceSats(): Promise; +} + +export interface MintJobSink { + enqueue(job: { + id: string; + instructionId: string; + basketMandateId: string; + chain138VaultAddress: string; + canonicalSymbol: 'cBTC'; + amountSats: number; + status: 'queued'; + createdAt: string; + }): Promise; +} + +export interface AuditPublisher { + publish(record: { + id: string; + instructionId: string; + category: 'deposit_instruction' | 'confirmation' | 'mint_job' | 'freeze'; + message: string; + metadata?: Record; + createdAt: string; + }): Promise; +} + +export interface OutstandingPolicy { + getCurrentOutstandingSats(): Promise; + getMaxOutstandingSats(): Promise; +} diff --git a/services/btc-intake/src/index.ts b/services/btc-intake/src/index.ts new file mode 100644 index 0000000..b288eb3 --- /dev/null +++ b/services/btc-intake/src/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './custody-adapter'; +export * from './native-bitcoin-watcher'; diff --git a/services/btc-intake/src/native-bitcoin-watcher.test.ts b/services/btc-intake/src/native-bitcoin-watcher.test.ts new file mode 100644 index 0000000..36a53a3 --- /dev/null +++ b/services/btc-intake/src/native-bitcoin-watcher.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from 'vitest'; +import type { + AuditPublisher, + CustodyAdapter, + MintJobSink, + OutstandingPolicy, +} from './custody-adapter'; +import { NativeBitcoinWatcher } from './native-bitcoin-watcher'; +import type { AuditRecord, BitcoinDepositEvent, MintJob } from './types'; + +class FakeCustodyAdapter implements CustodyAdapter { + reserveBalanceSats = 0; + private events: BitcoinDepositEvent[] = []; + private cursor = 0; + + async allocateDepositAddress(input: { instructionId: string }): Promise { + return `bc1q${input.instructionId.replace(/-/g, '').slice(0, 20)}`; + } + + queueEvent(event: BitcoinDepositEvent): void { + this.events.push(event); + } + + async listDepositEvents(cursor?: string): Promise<{ events: BitcoinDepositEvent[]; cursor?: string }> { + const start = cursor ? Number(cursor) : this.cursor; + const nextEvents = this.events.slice(start); + const nextCursor = String(this.events.length); + this.cursor = this.events.length; + return { events: nextEvents, cursor: nextCursor }; + } + + async getConfirmedReserveBalanceSats(): Promise { + return this.reserveBalanceSats; + } +} + +class InMemoryMintSink implements MintJobSink { + readonly jobs: MintJob[] = []; + + async enqueue(job: MintJob): Promise { + this.jobs.push(job); + } +} + +class InMemoryAuditPublisher implements AuditPublisher { + readonly records: AuditRecord[] = []; + + async publish(record: AuditRecord): Promise { + this.records.push(record); + } +} + +class StaticOutstandingPolicy implements OutstandingPolicy { + constructor( + private readonly currentOutstandingSats: number, + private readonly maxOutstandingSats: number, + ) {} + + async getCurrentOutstandingSats(): Promise { + return this.currentOutstandingSats; + } + + async getMaxOutstandingSats(): Promise { + return this.maxOutstandingSats; + } +} + +describe('NativeBitcoinWatcher', () => { + it('allocates unique deposit addresses', async () => { + const adapter = new FakeCustodyAdapter(); + const mintSink = new InMemoryMintSink(); + const audit = new InMemoryAuditPublisher(); + const watcher = new NativeBitcoinWatcher(adapter, mintSink, audit); + + const instruction = await watcher.createDepositInstruction({ + basketMandate: { + id: 'basket-1', + clientId: 'client-1', + chain138VaultAddress: '0x1111111111111111111111111111111111111111', + allocations: [{ symbol: 'cBTC', targetWeightBps: 10_000 }], + }, + }); + + expect(instruction.depositAddress.startsWith('bc1q')).toBe(true); + expect(audit.records[0]?.category).toBe('deposit_instruction'); + }); + + it('auto-mints after 6 confirmations and emits one mint job', async () => { + const adapter = new FakeCustodyAdapter(); + adapter.reserveBalanceSats = 300_000_000; + const mintSink = new InMemoryMintSink(); + const audit = new InMemoryAuditPublisher(); + const watcher = new NativeBitcoinWatcher(adapter, mintSink, audit); + + const instruction = await watcher.createDepositInstruction({ + basketMandate: { + id: 'basket-2', + clientId: 'client-2', + chain138VaultAddress: '0x2222222222222222222222222222222222222222', + allocations: [{ symbol: 'cBTC', targetWeightBps: 10_000 }], + }, + expectedAmountSats: 250_000_000, + }); + + adapter.queueEvent({ + instructionId: instruction.id, + txId: 'tx-1', + confirmations: 6, + amountSats: 250_000_000, + type: 'deposit', + }); + + await watcher.sync(); + + expect(watcher.getInstruction(instruction.id)?.status).toBe('minted'); + expect(mintSink.jobs).toHaveLength(1); + expect(mintSink.jobs[0]).toMatchObject({ + instructionId: instruction.id, + amountSats: 250_000_000, + canonicalSymbol: 'cBTC', + }); + }); + + it('ignores duplicate finality events', async () => { + const adapter = new FakeCustodyAdapter(); + adapter.reserveBalanceSats = 150_000_000; + const mintSink = new InMemoryMintSink(); + const audit = new InMemoryAuditPublisher(); + const watcher = new NativeBitcoinWatcher(adapter, mintSink, audit); + + const instruction = await watcher.createDepositInstruction({ + basketMandate: { + id: 'basket-3', + clientId: 'client-3', + chain138VaultAddress: '0x3333333333333333333333333333333333333333', + allocations: [{ symbol: 'cBTC', targetWeightBps: 10_000 }], + }, + }); + + adapter.queueEvent({ + instructionId: instruction.id, + txId: 'tx-dup', + confirmations: 6, + amountSats: 125_000_000, + type: 'deposit', + }); + adapter.queueEvent({ + instructionId: instruction.id, + txId: 'tx-dup', + confirmations: 6, + amountSats: 125_000_000, + type: 'deposit', + }); + + await watcher.sync(); + + expect(mintSink.jobs).toHaveLength(1); + expect(watcher.getInstruction(instruction.id)?.status).toBe('minted'); + }); + + it('handles reorgs before finality without minting early', async () => { + const adapter = new FakeCustodyAdapter(); + adapter.reserveBalanceSats = 500_000_000; + const mintSink = new InMemoryMintSink(); + const audit = new InMemoryAuditPublisher(); + const watcher = new NativeBitcoinWatcher(adapter, mintSink, audit); + + const instruction = await watcher.createDepositInstruction({ + basketMandate: { + id: 'basket-4', + clientId: 'client-4', + chain138VaultAddress: '0x4444444444444444444444444444444444444444', + allocations: [{ symbol: 'cBTC', targetWeightBps: 10_000 }], + }, + }); + + adapter.queueEvent({ + instructionId: instruction.id, + txId: 'tx-reorg', + confirmations: 5, + amountSats: 100_000_000, + type: 'deposit', + }); + adapter.queueEvent({ + instructionId: instruction.id, + txId: 'tx-reorg', + confirmations: 2, + amountSats: 100_000_000, + type: 'reorg', + }); + adapter.queueEvent({ + instructionId: instruction.id, + txId: 'tx-reorg', + confirmations: 6, + amountSats: 100_000_000, + type: 'deposit', + }); + + await watcher.sync(); + + expect(mintSink.jobs).toHaveLength(1); + expect(watcher.getInstruction(instruction.id)?.status).toBe('minted'); + }); + + it('freezes when reserve reconciliation fails', async () => { + const adapter = new FakeCustodyAdapter(); + adapter.reserveBalanceSats = 99_999_999; + const mintSink = new InMemoryMintSink(); + const audit = new InMemoryAuditPublisher(); + const watcher = new NativeBitcoinWatcher(adapter, mintSink, audit); + + const instruction = await watcher.createDepositInstruction({ + basketMandate: { + id: 'basket-5', + clientId: 'client-5', + chain138VaultAddress: '0x5555555555555555555555555555555555555555', + allocations: [{ symbol: 'cBTC', targetWeightBps: 10_000 }], + }, + }); + + adapter.queueEvent({ + instructionId: instruction.id, + txId: 'tx-deficit', + confirmations: 6, + amountSats: 100_000_000, + type: 'deposit', + }); + + await watcher.sync(); + + expect(mintSink.jobs).toHaveLength(0); + expect(watcher.getInstruction(instruction.id)?.status).toBe('frozen'); + expect(audit.records[audit.records.length - 1]?.category).toBe('freeze'); + }); + + it('freezes when outstanding bridge capacity would be exceeded', async () => { + const adapter = new FakeCustodyAdapter(); + adapter.reserveBalanceSats = 500_000_000; + const mintSink = new InMemoryMintSink(); + const audit = new InMemoryAuditPublisher(); + const outstanding = new StaticOutstandingPolicy(450_000_000, 500_000_000); + const watcher = new NativeBitcoinWatcher(adapter, mintSink, audit, outstanding); + + const instruction = await watcher.createDepositInstruction({ + basketMandate: { + id: 'basket-6', + clientId: 'client-6', + chain138VaultAddress: '0x6666666666666666666666666666666666666666', + allocations: [{ symbol: 'cBTC', targetWeightBps: 10_000 }], + }, + }); + + adapter.queueEvent({ + instructionId: instruction.id, + txId: 'tx-cap', + confirmations: 6, + amountSats: 75_000_000, + type: 'deposit', + }); + + await watcher.sync(); + + expect(mintSink.jobs).toHaveLength(0); + expect(watcher.getInstruction(instruction.id)?.status).toBe('frozen'); + expect(watcher.getInstruction(instruction.id)?.freezeReason).toContain('Bridge outstanding limit'); + }); +}); diff --git a/services/btc-intake/src/native-bitcoin-watcher.ts b/services/btc-intake/src/native-bitcoin-watcher.ts new file mode 100644 index 0000000..a536742 --- /dev/null +++ b/services/btc-intake/src/native-bitcoin-watcher.ts @@ -0,0 +1,250 @@ +import { randomUUID } from 'crypto'; +import type { + AuditPublisher, + CustodyAdapter, + MintJobSink, + OutstandingPolicy, +} from './custody-adapter'; +import type { + AuditRecord, + BasketMandateSnapshot, + BitcoinDepositEvent, + DepositInstruction, + MintJob, +} from './types'; + +const DEFAULT_CONFIRMATIONS_REQUIRED = 6; + +function nowIso(): string { + return new Date().toISOString(); +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export class NativeBitcoinWatcher { + private readonly instructions = new Map(); + private readonly mintJobs = new Map(); + private readonly auditRecords: AuditRecord[] = []; + private cursor?: string; + + constructor( + private readonly adapter: CustodyAdapter, + private readonly mintSink: MintJobSink, + private readonly auditPublisher: AuditPublisher, + private readonly outstandingPolicy?: OutstandingPolicy, + ) {} + + async createDepositInstruction(input: { + basketMandate: BasketMandateSnapshot; + expectedAmountSats?: number; + }): Promise { + const instructionId = randomUUID(); + const depositAddress = await this.adapter.allocateDepositAddress({ + instructionId, + clientId: input.basketMandate.clientId, + }); + const timestamp = nowIso(); + + const instruction: DepositInstruction = { + id: instructionId, + basketMandate: clone(input.basketMandate), + depositAddress, + expectedAmountSats: input.expectedAmountSats, + currentConfirmations: 0, + confirmationsRequired: DEFAULT_CONFIRMATIONS_REQUIRED, + status: 'instruction_created', + createdAt: timestamp, + updatedAt: timestamp, + }; + + this.instructions.set(instruction.id, instruction); + await this.publishAudit({ + id: randomUUID(), + instructionId, + category: 'deposit_instruction', + message: 'Allocated BTC deposit address for Chain 138 cBTC mint flow', + metadata: { + depositAddress, + basketMandateId: input.basketMandate.id, + }, + createdAt: timestamp, + }); + + return clone(instruction); + } + + getInstruction(id: string): DepositInstruction | null { + const instruction = this.instructions.get(id); + return instruction ? clone(instruction) : null; + } + + getAuditRecords(): AuditRecord[] { + return clone(this.auditRecords); + } + + getMintJobs(): MintJob[] { + return Array.from(this.mintJobs.values()).map((job) => clone(job)); + } + + async sync(): Promise { + const { events, cursor } = await this.adapter.listDepositEvents(this.cursor); + for (const event of events) { + await this.applyEvent(event); + } + this.cursor = cursor; + return Array.from(this.instructions.values()).map((instruction) => clone(instruction)); + } + + private async applyEvent(event: BitcoinDepositEvent): Promise { + const instruction = this.instructions.get(event.instructionId); + if (!instruction) { + return; + } + + if ( + instruction.observedTxId === event.txId && + instruction.currentConfirmations >= event.confirmations && + instruction.status !== 'frozen' + ) { + return; + } + + instruction.observedTxId = event.txId; + instruction.currentConfirmations = Math.max(0, event.confirmations); + instruction.expectedAmountSats = event.amountSats; + instruction.updatedAt = event.observedAt || nowIso(); + + if (event.type === 'reorg' || event.confirmations < instruction.confirmationsRequired) { + instruction.status = 'pending_confirmations'; + await this.publishAudit({ + id: randomUUID(), + instructionId: instruction.id, + category: 'confirmation', + message: 'Deposit observed but waiting for final BTC confirmations', + metadata: { + txId: event.txId, + confirmations: event.confirmations, + type: event.type, + }, + createdAt: instruction.updatedAt, + }); + return; + } + + instruction.status = 'confirmed'; + await this.publishAudit({ + id: randomUUID(), + instructionId: instruction.id, + category: 'confirmation', + message: 'Deposit reached 6 confirmations and is eligible for minting', + metadata: { + txId: event.txId, + confirmations: event.confirmations, + amountSats: event.amountSats, + }, + createdAt: instruction.updatedAt, + }); + + await this.maybeQueueMint(instruction, event.amountSats); + } + + private async maybeQueueMint(instruction: DepositInstruction, amountSats: number): Promise { + if (this.mintJobs.has(instruction.id)) { + instruction.status = 'minted'; + return; + } + + const reserveBalanceSats = await this.adapter.getConfirmedReserveBalanceSats(); + const alreadyMintedSats = Array.from(this.mintJobs.values()).reduce( + (total, job) => total + job.amountSats, + 0, + ); + + if (reserveBalanceSats < alreadyMintedSats + amountSats) { + await this.freezeInstruction( + instruction, + 'Reserve deficit detected during cBTC reconciliation', + { + reserveBalanceSats, + alreadyMintedSats, + requestedMintSats: amountSats, + }, + ); + return; + } + + if (this.outstandingPolicy) { + const currentOutstandingSats = await this.outstandingPolicy.getCurrentOutstandingSats(); + const maxOutstandingSats = await this.outstandingPolicy.getMaxOutstandingSats(); + + if (maxOutstandingSats > 0 && currentOutstandingSats + amountSats > maxOutstandingSats) { + await this.freezeInstruction( + instruction, + 'Bridge outstanding limit would be exceeded by mint request', + { + currentOutstandingSats, + maxOutstandingSats, + requestedMintSats: amountSats, + }, + ); + return; + } + } + + const timestamp = nowIso(); + const mintJob: MintJob = { + id: randomUUID(), + instructionId: instruction.id, + basketMandateId: instruction.basketMandate.id, + chain138VaultAddress: instruction.basketMandate.chain138VaultAddress, + canonicalSymbol: 'cBTC', + amountSats, + status: 'queued', + createdAt: timestamp, + }; + + await this.mintSink.enqueue(mintJob); + this.mintJobs.set(instruction.id, mintJob); + instruction.status = 'minted'; + instruction.updatedAt = timestamp; + + await this.publishAudit({ + id: randomUUID(), + instructionId: instruction.id, + category: 'mint_job', + message: 'Queued auto-mint job for confirmed BTC deposit', + metadata: { + mintJobId: mintJob.id, + chain138VaultAddress: mintJob.chain138VaultAddress, + amountSats, + }, + createdAt: timestamp, + }); + } + + private async freezeInstruction( + instruction: DepositInstruction, + reason: string, + metadata?: Record, + ): Promise { + instruction.status = 'frozen'; + instruction.freezeReason = reason; + instruction.updatedAt = nowIso(); + + await this.publishAudit({ + id: randomUUID(), + instructionId: instruction.id, + category: 'freeze', + message: reason, + metadata, + createdAt: instruction.updatedAt, + }); + } + + private async publishAudit(record: AuditRecord): Promise { + this.auditRecords.push(record); + await this.auditPublisher.publish(record); + } +} diff --git a/services/btc-intake/src/types.ts b/services/btc-intake/src/types.ts new file mode 100644 index 0000000..add87c2 --- /dev/null +++ b/services/btc-intake/src/types.ts @@ -0,0 +1,59 @@ +export type DepositInstructionStatus = + | 'instruction_created' + | 'pending_confirmations' + | 'confirmed' + | 'minted' + | 'frozen'; + +export interface BasketMandateSnapshot { + id: string; + clientId: string; + chain138VaultAddress: string; + allocations: Array<{ + symbol: string; + targetWeightBps: number; + }>; +} + +export interface DepositInstruction { + id: string; + basketMandate: BasketMandateSnapshot; + depositAddress: string; + expectedAmountSats?: number; + currentConfirmations: number; + confirmationsRequired: number; + status: DepositInstructionStatus; + observedTxId?: string; + freezeReason?: string; + createdAt: string; + updatedAt: string; +} + +export interface BitcoinDepositEvent { + instructionId: string; + txId: string; + confirmations: number; + amountSats: number; + type: 'deposit' | 'reorg'; + observedAt?: string; +} + +export interface MintJob { + id: string; + instructionId: string; + basketMandateId: string; + chain138VaultAddress: string; + canonicalSymbol: 'cBTC'; + amountSats: number; + status: 'queued'; + createdAt: string; +} + +export interface AuditRecord { + id: string; + instructionId: string; + category: 'deposit_instruction' | 'confirmation' | 'mint_job' | 'freeze'; + message: string; + metadata?: Record; + createdAt: string; +} diff --git a/services/btc-intake/tsconfig.json b/services/btc-intake/tsconfig.json new file mode 100644 index 0000000..e8ca50b --- /dev/null +++ b/services/btc-intake/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/services/relay/.env.avax b/services/relay/.env.avax new file mode 100644 index 0000000..163f285 --- /dev/null +++ b/services/relay/.env.avax @@ -0,0 +1,25 @@ +# Copy to .env.avax and adjust if you need a different AVAX RPC. +# start-relay.sh avax loads this profile before .env.local / .env. +RPC_URL_138=http://192.168.11.211:8545 +CCIP_ROUTER_CHAIN138=0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817 +CCIPWETH9_BRIDGE_CHAIN138=0xcacfd227A040002e49e2e01626363071324f820a +SOURCE_CHAIN_SELECTOR=138 + +DEST_CHAIN_NAME=Avalanche +DEST_CHAIN_ID=43114 +DEST_RPC_URL=https://avalanche-c-chain.publicnode.com +DEST_CHAIN_SELECTOR=6433500567565415381 +DEST_RELAY_ROUTER=0x2a0023Ad5ce1Ac6072B454575996DfFb1BB11b16 +DEST_RELAY_BRIDGE=0x3f8C409C6072a2B6a4Ff17071927bA70F80c725F +DEST_WETH9_ADDRESS=0xa4B9DD039565AeD9641D45b57061f99d9cA6Df08 + +RELAYER_PRIVATE_KEY=${PRIVATE_KEY} +RELAYER_ADDRESS=0x4A666F96fC8764181194447A7dFdb7d471b301C8 + +START_BLOCK=latest +POLL_INTERVAL=5000 +CONFIRMATION_BLOCKS=1 +MAX_RETRIES=3 +RETRY_DELAY=5000 +LOG_LEVEL=info +DEST_RELAY_BRIDGE_ALLOWLIST=0x3f8C409C6072a2B6a4Ff17071927bA70F80c725F diff --git a/services/relay/.env.avax-cw b/services/relay/.env.avax-cw new file mode 100644 index 0000000..f1ed3b5 --- /dev/null +++ b/services/relay/.env.avax-cw @@ -0,0 +1,24 @@ +# Forward relay profile for non-prefunded AVAX cW minting. +RPC_URL_138=http://192.168.11.211:8545 +CCIP_ROUTER_CHAIN138=0x42dab7b888dd382bd5adcf9e038dbf1fd03b4817 +SOURCE_BRIDGE_ADDRESS=0x152ed3e9912161b76bdfd368d0c84b7c31c10de7 +SOURCE_CHAIN_SELECTOR=138 + +DEST_CHAIN_NAME=Avalanche +DEST_CHAIN_ID=43114 +DEST_RPC_URL=https://avalanche-c-chain.publicnode.com +DEST_CHAIN_SELECTOR=6433500567565415381 +DEST_RELAY_ROUTER=0xc9158759a7e3621f6bb191bf5d77605d6e25b410 +DEST_RELAY_BRIDGE=0x635002c5fb227160cd2eac926d1baa61847f3c75 +DEST_WETH9_ADDRESS=0xa4b9dd039565aed9641d45b57061f99d9ca6df08 + +RELAYER_PRIVATE_KEY=${PRIVATE_KEY} +RELAYER_ADDRESS=0x4A666F96fC8764181194447A7dFdb7d471b301C8 + +START_BLOCK=3411398 +POLL_INTERVAL=5000 +CONFIRMATION_BLOCKS=1 +MAX_RETRIES=3 +RETRY_DELAY=5000 +LOG_LEVEL=info +DEST_RELAY_BRIDGE_ALLOWLIST=0x635002c5Fb227160Cd2eAC926d1BaA61847f3C75 diff --git a/services/relay/.env.avax-to-138 b/services/relay/.env.avax-to-138 new file mode 100644 index 0000000..a5c58c7 --- /dev/null +++ b/services/relay/.env.avax-to-138 @@ -0,0 +1,28 @@ +# Reverse relay profile for AVAX cW burns back to Chain 138. +SOURCE_CHAIN_NAME=Avalanche +SOURCE_CHAIN_ID=43114 +SOURCE_CHAIN_SELECTOR=6433500567565415381 +SOURCE_RPC_URL=https://api.avax.network/ext/bc/C/rpc +SOURCE_ROUTER_ADDRESS=0x1773125b280d296354f4f4b958a7cfc4e5975b60 +SOURCE_BRIDGE_ADDRESS=0x635002c5fb227160cd2eac926d1baa61847f3c75 + +DEST_CHAIN_NAME=Chain 138 +DEST_CHAIN_ID=138 +DEST_CHAIN_SELECTOR=138 +DEST_RPC_URL=http://192.168.11.211:8545 +DEST_RELAY_ROUTER=0xe75d26bc558a28442f30750c6d97bffb46f39abc +DEST_RELAY_BRIDGE=0x152ed3e9912161b76bdfd368d0c84b7c31c10de7 +DEST_RELAY_BRIDGE_ALLOWLIST=0x152ed3e9912161b76bdfd368d0c84b7c31c10de7 + +RELAYER_PRIVATE_KEY=${PRIVATE_KEY} +RELAYER_ADDRESS=0x4A666F96fC8764181194447A7dFdb7d471b301C8 + +START_BLOCK=latest +POLL_INTERVAL=5000 +CONFIRMATION_BLOCKS=1 +MAX_RETRIES=3 +RETRY_DELAY=5000 +LOG_LEVEL=info +RELAY_DEST_LEGACY_TX=1 +RELAY_DEST_GAS_PRICE_BUMP_PCT=20 +RELAY_DEST_GAS_PRICE_BUMP_WEI=1000000 diff --git a/services/relay/.env.mainnet-cw b/services/relay/.env.mainnet-cw new file mode 100644 index 0000000..8062d48 --- /dev/null +++ b/services/relay/.env.mainnet-cw @@ -0,0 +1,25 @@ +# Explicit Mainnet cW mint worker. +# This profile watches the dedicated Chain 138 cW sender bridge and only delivers to the Mainnet cW receiver. + +RPC_URL_138=https://rpc-http-pub.d-bis.org +CCIP_ROUTER_CHAIN138=0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817 +SOURCE_BRIDGE_ADDRESS=0x152ed3e9912161b76bdfd368d0c84b7c31c10de7 +SOURCE_CHAIN_SELECTOR=138 + +RPC_URL_MAINNET=https://mainnet.infura.io/v3/43b945b33d58463a9246cf5ca8aa6286 +DEST_CHAIN_NAME=Ethereum Mainnet +DEST_CHAIN_ID=1 +DEST_CHAIN_SELECTOR=5009297550715157269 +DEST_RELAY_ROUTER=0x416564Ab73Ad5710855E98dC7bC7Bff7387285BA +DEST_RELAY_BRIDGE=0x2bF74583206A49Be07E0E8A94197C12987AbD7B5 +DEST_RELAY_BRIDGE_ALLOWLIST=0x2bF74583206A49Be07E0E8A94197C12987AbD7B5 + +RELAYER_PRIVATE_KEY=${PRIVATE_KEY} +RELAYER_ADDRESS=0x4A666F96fC8764181194447A7dFdb7d471b301C8 + +START_BLOCK=2706088 +POLL_INTERVAL=5000 +CONFIRMATION_BLOCKS=1 +MAX_RETRIES=3 +RETRY_DELAY=5000 +LOG_LEVEL=info diff --git a/services/relay/.env.mainnet-weth b/services/relay/.env.mainnet-weth new file mode 100644 index 0000000..16b93db --- /dev/null +++ b/services/relay/.env.mainnet-weth @@ -0,0 +1,34 @@ +# Explicit Mainnet WETH release worker. +# This profile skips `.env.local` in systemd so WETH behavior does not inherit ad hoc cW overrides. + +RPC_URL_138=https://rpc-http-pub.d-bis.org +CCIP_ROUTER_CHAIN138=0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817 +SOURCE_BRIDGE_ADDRESS=0xcacfd227A040002e49e2e01626363071324f820a +SOURCE_CHAIN_SELECTOR=138 + +RPC_URL_MAINNET=https://mainnet.infura.io/v3/43b945b33d58463a9246cf5ca8aa6286 +DEST_CHAIN_NAME=Ethereum Mainnet +DEST_CHAIN_ID=1 +DEST_CHAIN_SELECTOR=5009297550715157269 +DEST_RELAY_ROUTER=0x416564Ab73Ad5710855E98dC7bC7Bff7387285BA +DEST_RELAY_BRIDGE=0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939 +DEST_RELAY_BRIDGE_ALLOWLIST=0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939 + +RELAYER_PRIVATE_KEY=${PRIVATE_KEY} +RELAYER_ADDRESS=0x4A666F96fC8764181194447A7dFdb7d471b301C8 + +START_BLOCK=2706088 +POLL_INTERVAL=5000 +CONFIRMATION_BLOCKS=1 +MAX_RETRIES=3 +RETRY_DELAY=5000 + +# Keep the WETH lane observably alive but safe until the Mainnet release bridge is funded again. +RELAY_SHEDDING=1 + +# Park the known oversized WETH release message until the Mainnet bridge inventory is restored. +# 2026-04-05 purge: drop the historical WETH backlog that reloaded into the paused queue so +# the worker reflects current forward-only monitoring instead of retaining undeliverable legacy sends. +RELAY_SKIP_MESSAGE_IDS=0xf718c9895c0a5442349996383184d017d2fa041af7aaeb9f0c0675d3ceed756b,0x19656fe758fc0e36ce5ce16ad9101e76c9eae19e5ed6bea08335dfb664215edc,0x249042e74fc322b2a8dc9fabe63b18094df11aaaed86b149287a6feea1b94157,0x263fa601b709c1c71a78004936eb195b43ed9da4dce23cf12dcfd24d40880375,0x300d38035aebd97bfbfa13737dc60ed23dba91991348259fd01ea1bc3109b260,0x3d3e6978c9e796b23fb8709fff4102131648825728ad0dd4197b98c6ba7a46cc,0x42fad60f851a43c6a52a216d211679d6fb786130f34dc5f26e7ddad350e7c83e,0x47b36fc517e7055efbc7408b17fca08f5fa41dbeea24d72e02f4995e22a4601f,0x4a4cab9082800ddb10ac60cae94dc2c5a6509134e6c8f915dc0ff636752b449d,0x523f8a202f069644488747dbd2a221cafdfac3f0a0fc7271685f5b23736fe8eb,0x63e56db9e3d6f2864e284b32c84ffa7118c65bee0559567cddf3288d812ef3cc,0x6ae76d1ec258666a1e6a95e63d911f4178f34ec312dcb88e3f237ba1288e6f79,0x75881681e8b2c793a8386f471cad44768c4e6f125e3f888978cc4c14d74049cf,0x770e246987c22c32fd2c9627c37e28316ec3390b33fb9cb9e9c3f21670af5ba3,0x779894438af9602eee92bc6c9c02475d6659ab9ed4bdd7250e6d0d331e628366,0x781eb1072c501efc10f92be6dc3355cf95d2f6f0c992468275d69fc5ded52a30,0xa447bdb1962882920ca8e966d7e8ba0cc016b80252bff5d5741317f0484a74fd,0xb11ca230b35d706eb0a43dc99c8806647aaeef29cdfa14762fa2a397bcbe82ae,0xc076289b0120a9b010e7851c4b00566ecdc8f46f2108d87ebebd042a005fb250,0xcc5ec02070b51ff927e540c62b2aa0c4b4f237efc8b34bbd6a5e8827f57f0a0b,0xd606e745392b1385870bcf5c7a1177833d2872a4e1a0beb33319e5b645be5b12 + +LOG_LEVEL=info diff --git a/services/relay/DEPLOYMENT_GUIDE.md b/services/relay/DEPLOYMENT_GUIDE.md index be2d42d..ea5c0e8 100644 --- a/services/relay/DEPLOYMENT_GUIDE.md +++ b/services/relay/DEPLOYMENT_GUIDE.md @@ -21,8 +21,9 @@ This guide walks through deploying and configuring the custom CCIP relay mechani ### Deployed Contracts (Ethereum Mainnet) -- **Relay Router**: `0xAd9A228CcEB4cbB612cD165FFB72fE090ff10Afb` +- **Relay Router**: `0x416564Ab73Ad5710855E98dC7bC7Bff7387285BA` - **Relay Bridge**: `0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939` +- **Additional Mainnet cW bridge**: `0x2bF74583206A49Be07E0E8A94197C12987AbD7B5` - **WETH9**: `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` ### Source Chain (Chain 138) @@ -123,8 +124,9 @@ CCIPWETH9_BRIDGE_CHAIN138=0xBBb4a9202716eAAB3644120001cC46096913a3C8 # Destination Chain (Ethereum Mainnet) RPC_URL_MAINNET=https://eth.llamarpc.com -CCIP_RELAY_ROUTER_MAINNET=0xAd9A228CcEB4cbB612cD165FFB72fE090ff10Afb +CCIP_RELAY_ROUTER_MAINNET=0x416564Ab73Ad5710855E98dC7bC7Bff7387285BA CCIP_RELAY_BRIDGE_MAINNET=0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939 +DEST_RELAY_BRIDGE_ALLOWLIST=0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939,0x2bF74583206A49Be07E0E8A94197C12987AbD7B5 # Relayer Configuration PRIVATE_KEY=0x... # Your private key (or use RELAYER_PRIVATE_KEY) diff --git a/services/relay/README.md b/services/relay/README.md index a3facff..e63609a 100644 --- a/services/relay/README.md +++ b/services/relay/README.md @@ -14,12 +14,21 @@ Destinations: - AVAX relay router: `0x2a0023Ad5ce1Ac6072B454575996DfFb1BB11b16` - AVAX relay bridge: `0x3f8C409C6072a2B6a4Ff17071927bA70F80c725F` +Direct first-hop support from Chain 138 is intentionally narrow today: +- Mainnet: supported with the default `.env` profile +- BSC: supported with `.env.bsc` +- Avalanche: supported with `.env.avax` +- Gnosis / Cronos / Celo / Polygon / Arbitrum / Optimism / Base: treat as `via Mainnet hub` unless a dedicated relay router + relay profile are added and proven live + +Important: on 2026-04-04, a direct `138 -> Arbitrum` WETH send produced a real source `MessageSent` event but no destination delivery because the live relay worker was running a Mainnet-only destination profile. There is currently no tracked `.env.arbitrum` profile in this folder. + ## Env Profiles Use the prebuilt env files in this folder: - `.env.bsc` (template: `.env.bsc.example`) - `.env.avax` - `.env` (default/fallback) +- `.env.local` only for local overrides that should beat the tracked profiles Each profile sets destination RPC, selector, relay router/bridge, and destination WETH token. @@ -64,6 +73,23 @@ When **no** 138→Mainnet (or configured destination) relay deliveries are neede **Behavior:** Source `MessageSent` logs are still ingested and messages **queue in memory**. When you set `RELAY_SHEDDING=0` (and `RELAY_DELIVERY_ENABLED=1`) and **restart** the service, pending messages are delivered as usual. For production, plan shedding around low bridge traffic so the queue stays small (in-memory queue is lost on process crash). +## Skip specific message IDs + +Use `RELAY_SKIP_MESSAGE_IDS` as a comma-separated list of source `MessageSent.messageId` values that the relay should intentionally ignore. + +This is the safest operational way to park an already-confirmed source message when: +- destination relay inventory is below the requested release amount +- you do not want the relay to keep retrying it after service restarts +- there is no on-chain cancel / refund path on the source bridge + +Example: + +```bash +RELAY_SKIP_MESSAGE_IDS=0xf718c9895c0a5442349996383184d017d2fa041af7aaeb9f0c0675d3ceed756b +``` + +The relay checks this list during live event ingestion, historical replay, and queue processing. + ### On-chain pause (`CCIPRelayRouter`) The destination **CCIPRelayRouter** inherits OpenZeppelin **`Pausable`**: admins with `DEFAULT_ADMIN_ROLE` may call **`pause()`** / **`unpause()`**. While paused, **`relayMessage` reverts** (no delivery through the router). @@ -97,10 +123,46 @@ npm install If parent project `.env` defines `PRIVATE_KEY`, `${PRIVATE_KEY}` references in relay env files are expanded. +## Relay Health Endpoint + +The relay now exposes a lightweight JSON status endpoint for explorer / mission-control monitoring. + +- Default listen address: `0.0.0.0` +- Default port: `9860` +- Endpoints: `GET /healthz`, `GET /health`, `GET /status` + +Optional env overrides: + +```bash +RELAY_HEALTH_ENABLED=1 +RELAY_HEALTH_HOST=0.0.0.0 +RELAY_HEALTH_PORT=9860 +``` + +Example from another LAN host: + +```bash +curl http://192.168.11.11:9860/healthz | jq . +``` + +Example explorer backend wiring: + +```bash +CCIP_RELAY_HEALTH_URL=http://192.168.11.11:9860/healthz +CCIP_RELAY_HEALTH_URLS=mainnet=http://192.168.11.11:9860/healthz,bsc=http://192.168.11.11:9861/healthz,avax=http://192.168.11.11:9862/healthz +``` + +Recommended systemd ports when running multiple relay workers on the same host: + +- Mainnet: `9860` +- BSC: `9861` +- Avalanche: `9862` + ## Critical Requirements - Relayer key must hold native gas on destination chain. - Destination relay bridge must hold enough WETH for payouts. +- Explicit profile token overrides like `DEST_WETH9_ADDRESS` win over the generic multichain token map. This keeps relay-backed destinations pointed at their bridge-managed wrapped token instead of a public native wrapped asset. - Source bridge destination mapping must point to the correct destination relay bridge. - Source router `feeToken()` must be a deployed ERC20 with sufficient deployer balance. diff --git a/services/relay/index.js b/services/relay/index.js index d962745..066f133 100644 --- a/services/relay/index.js +++ b/services/relay/index.js @@ -10,6 +10,7 @@ import dotenv from 'dotenv'; import winston from 'winston'; import { RelayService } from './src/RelayService.js'; import { config } from './src/config.js'; +import { startRelayHealthServer } from './src/healthServer.js'; // Env is loaded in config.js before use dotenv.config(); @@ -46,21 +47,29 @@ async function main() { try { const relayService = new RelayService(config, logger); + let healthServer = null; // Start monitoring await relayService.start(); + healthServer = await startRelayHealthServer(relayService, logger); logger.info('Relay service started successfully'); // Handle graceful shutdown process.on('SIGINT', async () => { logger.info('Received SIGINT, shutting down gracefully...'); + if (healthServer) { + healthServer.close(); + } await relayService.stop(); process.exit(0); }); process.on('SIGTERM', async () => { logger.info('Received SIGTERM, shutting down gracefully...'); + if (healthServer) { + healthServer.close(); + } await relayService.stop(); process.exit(0); }); @@ -75,4 +84,3 @@ main().catch((error) => { logger.error('Unhandled error:', error); process.exit(1); }); - diff --git a/services/relay/src/RelayService.js b/services/relay/src/RelayService.js index 5475e83..e43d1f0 100644 --- a/services/relay/src/RelayService.js +++ b/services/relay/src/RelayService.js @@ -31,6 +31,215 @@ export class RelayService { this.messageSentInterface = new ethers.Interface(MessageSentABI); /** @type {number} throttle for shedding warning logs */ this._lastSheddingLogTs = 0; + this.startedAt = new Date().toISOString(); + this.startEpochMs = Date.now(); + this.relayerAddress = ''; + this.lastSourcePoll = null; + this.lastSeenMessage = null; + this.lastRelayAttempt = null; + this.lastRelaySuccess = null; + this.lastError = null; + } + + normalizeAddress(value) { + if (!value) return ''; + try { + return ethers.getAddress(value); + } catch (_) { + return ''; + } + } + + getConfiguredSourceBridge() { + return this.normalizeAddress(this.config.sourceChain.bridgeAddress); + } + + getDestinationBridgeAllowlist() { + return (this.config.destinationChain.relayBridgeAllowlist || []) + .map((value) => String(value || '').trim().toLowerCase()) + .filter(Boolean); + } + + resolveTargetBridge(receiver) { + let targetBridge = this.normalizeAddress(this.config.destinationChain.relayBridgeAddress); + try { + if (receiver) { + const decoded = ethers.AbiCoder.defaultAbiCoder().decode(['address'], receiver); + if (decoded && decoded[0]) { + targetBridge = this.normalizeAddress(decoded[0]) || targetBridge; + } + } + } catch (_) { + // Keep the configured fallback bridge when receiver decode fails. + } + return targetBridge; + } + + evaluateMessageScope(messageData) { + const sourceBridge = this.getConfiguredSourceBridge(); + const sender = this.normalizeAddress(messageData.sender); + const targetBridge = this.resolveTargetBridge(messageData.receiver); + const allowlist = this.getDestinationBridgeAllowlist(); + + if (sourceBridge && sender && sender.toLowerCase() !== sourceBridge.toLowerCase()) { + return { + inScope: false, + reason: `sender ${sender} does not match worker source bridge ${sourceBridge}`, + sourceBridge, + sender, + targetBridge + }; + } + + if (!targetBridge) { + return { + inScope: false, + reason: 'destination bridge could not be resolved from receiver or config', + sourceBridge, + sender, + targetBridge: '' + }; + } + + if (allowlist.length > 0 && !allowlist.includes(targetBridge.toLowerCase())) { + return { + inScope: false, + reason: `destination bridge ${targetBridge} not in worker allowlist`, + sourceBridge, + sender, + targetBridge + }; + } + + return { + inScope: true, + reason: '', + sourceBridge, + sender, + targetBridge + }; + } + + static summarizeError(error) { + if (!error) return ''; + if (typeof error === 'string') return error; + return String(error.shortMessage || error.message || error); + } + + recordError(scope, error, extra = {}) { + this.lastError = { + at: new Date().toISOString(), + scope, + message: RelayService.summarizeError(error), + ...extra + }; + } + + getHealthStatus() { + if (!this.isRunning) { + return 'stopped'; + } + if (isRelayShedding()) { + return 'paused'; + } + + if (this.lastSourcePoll && this.lastSourcePoll.ok === false) { + return 'degraded'; + } + + if (!this.lastSourcePoll || !this.lastSourcePoll.at) { + return 'starting'; + } + + const lastPollMs = Date.parse(this.lastSourcePoll.at); + const staleAfterMs = Math.max(this.getSourcePollIntervalMs() * 3, 15000); + if (Number.isFinite(lastPollMs) && Date.now() - lastPollMs > staleAfterMs) { + return 'stale'; + } + + const lastErrorMs = this.lastError && this.lastError.at ? Date.parse(this.lastError.at) : 0; + const lastSuccessMs = this.lastRelaySuccess && this.lastRelaySuccess.at ? Date.parse(this.lastRelaySuccess.at) : 0; + if ( + this.lastError && + this.lastError.scope === 'relay_message' && + Number.isFinite(lastErrorMs) && + lastErrorMs > 0 && + lastErrorMs >= lastSuccessMs + ) { + return 'degraded'; + } + + return 'operational'; + } + + getHealthSnapshot() { + const queueStats = this.messageQueue.getStats(); + const status = this.getHealthStatus(); + const sourceBridge = this.getConfiguredSourceBridge(); + const allowlist = this.getDestinationBridgeAllowlist(); + const defaultTargetBridge = this.normalizeAddress(this.config.destinationChain.relayBridgeAddress); + + return { + ok: status === 'operational' || status === 'paused' || status === 'starting', + status, + service: { + name: 'ccip-relay', + running: this.isRunning, + pid: process.pid, + profile: process.env.RELAY_PROFILE || 'default', + started_at: this.startedAt, + uptime_sec: Math.max(0, Math.floor((Date.now() - this.startEpochMs) / 1000)), + relayer_address: this.relayerAddress || this.config.relayer.address || '' + }, + source: { + chain_name: this.config.sourceChain.name, + chain_id: this.config.sourceChain.chainId, + chain_selector: this.config.sourceChainSelector.toString(), + router_address: this.config.sourceChain.routerAddress, + bridge_address: this.config.sourceChain.bridgeAddress, + bridge_filter: sourceBridge || '' + }, + destination: { + chain_name: this.config.destinationChain.name, + chain_id: this.config.destinationChain.chainId, + chain_selector: this.config.destinationChain.chainSelector.toString(), + relay_router: this.config.destinationChain.relayRouterAddress, + relay_bridge: this.config.destinationChain.relayBridgeAddress, + relay_bridge_default: defaultTargetBridge || '', + relay_bridge_allowlist: allowlist, + delivery_mode: this.config.destinationChain.deliveryMode + }, + scope: { + source_bridge: sourceBridge || '', + destination_bridge_default: defaultTargetBridge || '', + destination_bridge_allowlist: allowlist + }, + monitoring: { + start_block: String(this.config.monitoring.startBlock), + poll_interval_ms: this.config.monitoring.pollInterval, + effective_source_poll_interval_ms: this.getSourcePollIntervalMs(), + confirmation_blocks: this.config.monitoring.confirmationBlocks, + finality_delay_blocks: this.config.monitoring.finalityDelayBlocks || 0, + replay_window_blocks: this.config.monitoring.replayWindowBlocks || 0, + shedding: isRelayShedding(), + delivery_enabled: !isRelayShedding() + }, + queue: { + size: queueStats.queueSize, + processed: queueStats.processed, + failed: queueStats.failed + }, + last_source_poll: this.lastSourcePoll, + last_seen_message: this.lastSeenMessage, + last_relay_attempt: this.lastRelayAttempt, + last_relay_success: this.lastRelaySuccess, + last_error: this.lastError + }; + } + + shouldSkipMessageId(messageId) { + const normalized = String(messageId || '').toLowerCase(); + return normalized && this.config.skipMessageIds && this.config.skipMessageIds.has(normalized); } /** Polling interval for source router logs (longer while shedding to cut RPC churn). */ @@ -40,6 +249,41 @@ export class RelayService { } return this.config.monitoring.pollInterval; } + + async getDestinationTxOptions() { + const txOptions = { + gasLimit: BigInt(process.env.RELAY_DEST_GAS_LIMIT || '1000000') + }; + + if (process.env.RELAY_DEST_LEGACY_TX !== '1') { + return txOptions; + } + + let gasPrice = process.env.RELAY_DEST_GAS_PRICE_WEI ? BigInt(process.env.RELAY_DEST_GAS_PRICE_WEI) : null; + if (gasPrice === null) { + try { + const feeData = await this.destinationProvider.getFeeData(); + if (feeData.gasPrice) { + gasPrice = feeData.gasPrice; + } + } catch (_) { + // Fall through to raw RPC fallback below. + } + } + + if (gasPrice === null) { + const rawGasPrice = await this.destinationProvider.send('eth_gasPrice', []); + gasPrice = BigInt(rawGasPrice); + } + + const bumpPct = BigInt(process.env.RELAY_DEST_GAS_PRICE_BUMP_PCT || '20'); + const bumpWei = BigInt(process.env.RELAY_DEST_GAS_PRICE_BUMP_WEI || '1000000'); + gasPrice = gasPrice + (gasPrice * bumpPct) / 100n + bumpWei; + + txOptions.type = 0; + txOptions.gasPrice = gasPrice; + return txOptions; + } async start() { this.logger.info('Initializing relay service...'); @@ -55,8 +299,9 @@ export class RelayService { } this.sourceSigner = new ethers.Wallet(this.config.relayer.privateKey, this.sourceProvider); this.destinationSigner = new ethers.Wallet(this.config.relayer.privateKey, this.destinationProvider); + this.relayerAddress = String(this.destinationSigner.address); - this.logger.info('Relayer address: %s', String(this.destinationSigner.address)); + this.logger.info('Relayer address: %s', this.relayerAddress); // Validate relay router address (bridge can be dynamic from message receiver) if (!this.config.destinationChain.relayRouterAddress || @@ -213,16 +458,28 @@ export class RelayService { try { const destSelector = destinationChainSelector.toString(); const expectedSelector = this.config.destinationChain.chainSelector.toString(); + + if (this.shouldSkipMessageId(messageId)) { + this.logger.warn(`Skipping MessageSent ${messageId}; message id is in RELAY_SKIP_MESSAGE_IDS`); + return; + } if (destSelector !== expectedSelector) { this.logger.debug(`Ignoring message for different chain: ${destSelector}`); return; } + + const scope = this.evaluateMessageScope({ messageId, sender, receiver }); + if (!scope.inScope) { + this.logger.debug(`Ignoring message ${messageId}; ${scope.reason}`); + return; + } this.logger.info('MessageSent event detected:', { messageId: messageId, destinationChainSelector: destinationChainSelector.toString(), sender: sender, + targetBridge: scope.targetBridge, blockNumber: event.blockNumber, transactionHash: event.transactionHash }); @@ -245,6 +502,7 @@ export class RelayService { destinationChainSelector, sender, receiver, + targetBridge: scope.targetBridge, data, tokenAmounts: formattedTokenAmounts, feeToken, @@ -275,6 +533,16 @@ export class RelayService { this.logger.info(`Polling events from block ${startBlock} to ${toBlock}`); const logs = await this.fetchSourceLogsChunked(startBlock, toBlock); + this.lastSourcePoll = { + at: new Date().toISOString(), + ok: true, + from_block: startBlock, + to_block: toBlock, + logs_fetched: logs.length + }; + if (this.lastError && this.lastError.scope === 'source_poll') { + this.lastError = null; + } this.logger.info(`Fetched ${logs.length} MessageSent log(s) from source router`); @@ -292,23 +560,44 @@ export class RelayService { const destSelector = destinationChainSelector.toString(); const expectedSelector = this.config.destinationChain.chainSelector.toString(); + if (this.shouldSkipMessageId(messageId)) { + this.logger.warn(`Skipping historical MessageSent ${messageId}; message id is in RELAY_SKIP_MESSAGE_IDS`); + continue; + } + if (destSelector !== expectedSelector) { continue; } + const scope = this.evaluateMessageScope({ messageId, sender, receiver }); + if (!scope.inScope) { + this.logger.debug(`Ignoring historical message ${messageId}; ${scope.reason}`); + continue; + } + this.logger.info('Historical MessageSent detected:', { messageId, destinationChainSelector: destSelector, sender, + targetBridge: scope.targetBridge, blockNumber: log.blockNumber, transactionHash: log.transactionHash }); + this.lastSeenMessage = { + at: new Date().toISOString(), + message_id: messageId, + destination_chain_selector: destSelector, + sender, + block_number: log.blockNumber, + transaction_hash: log.transactionHash + }; await this.messageQueue.add({ messageId, destinationChainSelector, sender, receiver, + targetBridge: scope.targetBridge, data, tokenAmounts: tokenAmounts.map((ta) => ({ token: ta.token, @@ -330,6 +619,15 @@ export class RelayService { } catch (error) { this.logger.error('Error polling historical events:', error); + this.lastSourcePoll = { + at: new Date().toISOString(), + ok: false, + from_block: startBlock, + error: RelayService.summarizeError(error) + }; + this.recordError('source_poll', error, { + from_block: startBlock + }); await new Promise(resolve => setTimeout(resolve, this.getSourcePollIntervalMs())); } } @@ -373,6 +671,19 @@ export class RelayService { async relayMessage(messageData) { const { messageId, destinationChainSelector, sender, receiver, data, tokenAmounts } = messageData; + + if (this.shouldSkipMessageId(messageId)) { + this.logger.warn(`Skipping queued message ${messageId}; message id is in RELAY_SKIP_MESSAGE_IDS`); + await this.messageQueue.markProcessed(messageId); + return null; + } + + const scope = this.evaluateMessageScope(messageData); + if (!scope.inScope) { + this.logger.info(`Skipping queued message ${messageId}; ${scope.reason}`); + await this.messageQueue.markProcessed(messageId); + return null; + } this.logger.info(`Relaying message ${messageId} to destination chain...`); @@ -395,23 +706,26 @@ export class RelayService { } // Route to bridge encoded in MessageSent.receiver (bytes). Fallback to static env bridge. - let targetBridge = this.config.destinationChain.relayBridgeAddress; - try { - if (receiver) { - const decoded = ethers.AbiCoder.defaultAbiCoder().decode(['address'], receiver); - if (decoded && decoded[0]) targetBridge = ethers.getAddress(decoded[0]); - } - } catch (_) { - // keep fallback targetBridge - } + const targetBridge = scope.targetBridge || this.resolveTargetBridge(receiver); if (!targetBridge) { throw new Error(`No destination bridge for message ${messageId}: receiver decode failed and DEST_RELAY_BRIDGE not set`); } + this.lastRelayAttempt = { + at: new Date().toISOString(), + message_id: messageId, + destination_chain_selector: destinationChainSelector ? destinationChainSelector.toString() : '', + target_bridge: targetBridge, + token_count: tokenAmounts.length + }; // Optional allowlist hardening. - const allowlist = this.config.destinationChain.relayBridgeAllowlist || []; + const allowlist = this.getDestinationBridgeAllowlist(); if (allowlist.length > 0 && !allowlist.includes(String(targetBridge).toLowerCase())) { - throw new Error(`Bridge ${targetBridge} not in DEST_RELAY_BRIDGE_ALLOWLIST`); + this.logger.info( + `Skipping message ${messageId} for bridge ${targetBridge}; not in this worker's DEST_RELAY_BRIDGE_ALLOWLIST` + ); + await this.messageQueue.markProcessed(messageId); + return null; } let targetBridgeContract = this.destinationBridgeContracts.get(targetBridge.toLowerCase()); @@ -494,6 +808,7 @@ export class RelayService { tokenAmountsCount: any2EVMMessage.tokenAmounts.length }); + const txOptions = await this.getDestinationTxOptions(); let tx; if (this.config.destinationChain.deliveryMode === 'direct') { this.logger.info(`Direct-delivery mode: calling bridge ${targetBridge} without relay router`); @@ -502,13 +817,13 @@ export class RelayService { RelayBridgeABI, this.destinationSigner ); - tx = await directBridge.ccipReceive(any2EVMMessage, { gasLimit: 1000000 }); + tx = await directBridge.ccipReceive(any2EVMMessage, txOptions); } else { // Call relay router with properly formatted struct tx = await this.destinationRelayRouter.relayMessage( targetBridge, any2EVMMessage, - { gasLimit: 1000000 } + txOptions ); } @@ -518,6 +833,16 @@ export class RelayService { const receipt = await tx.wait(); this.logger.info(`Message ${messageId} relayed successfully. Transaction: ${receipt.hash}`); + this.lastRelaySuccess = { + at: new Date().toISOString(), + message_id: messageId, + destination_chain_selector: destinationChainSelector ? destinationChainSelector.toString() : '', + target_bridge: targetBridge, + tx_hash: receipt.hash + }; + if (this.lastError && this.lastError.scope === 'relay_message') { + this.lastError = null; + } // Mark message as processed await this.messageQueue.markProcessed(messageId); @@ -526,6 +851,9 @@ export class RelayService { } catch (error) { this.logger.error(`Error relaying message ${messageId}:`, error); + this.recordError('relay_message', error, { + message_id: messageId + }); // Retry logic const retryCount = await this.messageQueue.getRetryCount(messageId); diff --git a/services/relay/src/abis.js b/services/relay/src/abis.js index cac5642..f172d1f 100644 --- a/services/relay/src/abis.js +++ b/services/relay/src/abis.js @@ -16,6 +16,6 @@ export const RelayRouterABI = [ export const RelayBridgeABI = [ "function ccipReceive(tuple(bytes32 messageId, uint64 sourceChainSelector, bytes sender, bytes data, tuple(address token, uint256 amount, uint8 amountType)[] tokenAmounts) calldata message) external", "event CrossChainTransferCompleted(bytes32 indexed messageId, uint64 indexed sourceChainSelector, address indexed recipient, uint256 amount)", + "function processed(bytes32) view returns (bool)", "function processedTransfers(bytes32) view returns (bool)" ]; - diff --git a/services/relay/src/config.js b/services/relay/src/config.js index 2ba15e8..dd3a1a2 100644 --- a/services/relay/src/config.js +++ b/services/relay/src/config.js @@ -43,6 +43,32 @@ try { } } catch (_) { /* run from smom-dbis-138 only: loader not found */ } +function getExplicitTokenMappingOverrides() { + const overrides = {}; + const destinationWeth9 = + process.env.DEST_WETH9_ADDRESS || + process.env.DEST_WETH_ADDRESS || + ''; + const destinationLink = process.env.DEST_LINK_ADDRESS || ''; + + if (destinationWeth9 && !destinationWeth9.includes('${')) { + overrides['0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'.toLowerCase()] = destinationWeth9; + } + + if (destinationLink && !destinationLink.includes('${')) { + overrides['0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03'.toLowerCase()] = destinationLink; + } + + return overrides; +} + +function withExplicitTokenMappingOverrides(mapping) { + return { + ...(mapping || {}), + ...getExplicitTokenMappingOverrides() + }; +} + // Token mapping for the active source/destination pair: prefer multichain mapping when available. function getTokenMapping() { const sourceChainId = Number(process.env.SOURCE_CHAIN_ID || '138'); @@ -52,19 +78,19 @@ function getTokenMapping() { const { getTokenMappingForPair, getRelayTokenMapping } = require(tokenMappingLoaderPath); const pair = getTokenMappingForPair && getTokenMappingForPair(sourceChainId, destinationChainId); if (pair && pair.addressMapFromTo && Object.keys(pair.addressMapFromTo).length > 0) { - return pair.addressMapFromTo; + return withExplicitTokenMappingOverrides(pair.addressMapFromTo); } const fromFile = getRelayTokenMapping && getRelayTokenMapping(); if (sourceChainId === 138 && destinationChainId === 1 && fromFile && Object.keys(fromFile).length > 0) { - return fromFile; + return withExplicitTokenMappingOverrides(fromFile); } } catch (_) { /* config not available */ } const destinationWeth9 = process.env.DEST_WETH9_ADDRESS || process.env.DEST_WETH_ADDRESS || '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; // Fallback keeps WETH and LINK mapping for legacy relay profiles. - return { + return withExplicitTokenMappingOverrides({ '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2': destinationWeth9, '0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03': process.env.DEST_LINK_ADDRESS || '0x514910771AF9Ca656af840dff83E8264EcF986CA' - }; + }); } // If PRIVATE_KEY still missing, try cwd-relative paths (e.g. run from repo root or relay dir) if (!process.env.PRIVATE_KEY || process.env.PRIVATE_KEY.includes('${')) { @@ -121,6 +147,15 @@ function getDestinationRelayBridgeAddress() { ); } +function getSkipMessageIds() { + return new Set( + String(process.env.RELAY_SKIP_MESSAGE_IDS || '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean) + ); +} + export const config = { // Source chain: defaults to Chain 138 but can be overridden for reverse relay profiles. sourceChain: { @@ -150,7 +185,7 @@ export const config = { process.env.DEST_RELAY_ROUTER || process.env.CCIP_RELAY_ROUTER_MAINNET || process.env.RELAY_ROUTER_MAINNET || - '0xAd9A228CcEB4cbB612cD165FFB72fE090ff10Afb', + '0x416564Ab73Ad5710855E98dC7bC7Bff7387285BA', relayBridgeAddress: getDestinationRelayBridgeAddress(), deliveryMode: process.env.DEST_DELIVERY_MODE || 'router', @@ -198,7 +233,9 @@ export const config = { retry: { maxRetries: process.env.MAX_RETRIES ? parseInt(process.env.MAX_RETRIES) : 3, retryDelay: process.env.RETRY_DELAY ? parseInt(process.env.RETRY_DELAY) : 5000 // 5 seconds - } + }, + + skipMessageIds: getSkipMessageIds() }; /** diff --git a/services/relay/src/healthServer.js b/services/relay/src/healthServer.js new file mode 100644 index 0000000..83ed57b --- /dev/null +++ b/services/relay/src/healthServer.js @@ -0,0 +1,76 @@ +import http from 'http'; + +function parseEnabled(value, defaultValue = true) { + if (value === undefined || value === null || value === '') { + return defaultValue; + } + const normalized = String(value).trim().toLowerCase(); + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + return defaultValue; +} + +function getHost() { + return String(process.env.RELAY_HEALTH_HOST || '0.0.0.0').trim() || '0.0.0.0'; +} + +function getPort() { + const raw = parseInt(process.env.RELAY_HEALTH_PORT || '9860', 10); + return Number.isFinite(raw) && raw > 0 ? raw : 9860; +} + +export async function startRelayHealthServer(relayService, logger) { + if (!parseEnabled(process.env.RELAY_HEALTH_ENABLED, true)) { + logger.info('Relay health server disabled by RELAY_HEALTH_ENABLED'); + return null; + } + + const host = getHost(); + const port = getPort(); + const server = http.createServer((req, res) => { + const path = String(req.url || '').split('?')[0]; + + if (req.method !== 'GET') { + res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ ok: false, error: 'method_not_allowed' })); + return; + } + + if (path !== '/health' && path !== '/healthz' && path !== '/status') { + res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ ok: false, error: 'not_found' })); + return; + } + + const payload = relayService.getHealthSnapshot(); + res.writeHead(200, { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8' + }); + res.end(JSON.stringify(payload)); + }); + + return await new Promise((resolve) => { + let resolved = false; + const settle = (value) => { + if (!resolved) { + resolved = true; + resolve(value); + } + }; + + server.on('error', (error) => { + logger.error('Relay health server error:', error); + settle(null); + }); + + server.listen(port, host, () => { + logger.info(`Relay health server listening on http://${host}:${port}/healthz`); + settle(server); + }); + }); +} diff --git a/services/relay/start-relay.sh b/services/relay/start-relay.sh index 6b6bf76..f86b5d8 100755 --- a/services/relay/start-relay.sh +++ b/services/relay/start-relay.sh @@ -3,6 +3,14 @@ cd "$(dirname "$0")" PROJECT_ROOT="$(cd ../.. && pwd)" +PROFILE="${1:-}" +SKIP_ENV_LOCAL="${RELAY_SKIP_ENV_LOCAL:-0}" + +declare -A ORIGINAL_ENV_VARS=() +while IFS='=' read -r key _; do + [ -n "$key" ] || continue + ORIGINAL_ENV_VARS["$key"]=1 +done < <(env) # Try to load NVM if available if [ -f "$HOME/.nvm/nvm.sh" ]; then @@ -13,25 +21,54 @@ elif [ -f "/usr/local/nvm/nvm.sh" ]; then nvm use node 2>/dev/null || nvm use --lts 2>/dev/null || true fi -# Load parent .env file first -if [ -f "$PROJECT_ROOT/.env" ]; then - source "$PROJECT_ROOT/.env" -fi - -# Use .env.local if it exists (with expanded values), otherwise use .env -if [ -f .env.local ]; then - export $(cat .env.local | grep -v '^#' | grep -v '^$' | xargs) -elif [ -f .env ]; then - # Expand ${PRIVATE_KEY} from parent .env if present +load_env_file() { + local env_file="$1" + [ -f "$env_file" ] || return 0 while IFS= read -r line || [ -n "$line" ]; do [[ "$line" =~ ^#.*$ ]] && continue [[ -z "$line" ]] && continue - if [[ "$line" =~ \$\{PRIVATE_KEY\} ]] && [ -n "$PRIVATE_KEY" ]; then - export "${line//\$\{PRIVATE_KEY\}/$PRIVATE_KEY}" - else - export "$line" + local key="${line%%=*}" + local value="${line#*=}" + if [[ -n "${ORIGINAL_ENV_VARS[$key]:-}" ]]; then + continue fi - done < .env + if [[ "$value" == *'${PRIVATE_KEY'* ]] && [ -n "${PRIVATE_KEY:-}" ] && [[ "$key" == "PRIVATE_KEY" || "$key" == "RELAYER_PRIVATE_KEY" ]]; then + export "$key=$PRIVATE_KEY" + continue + fi + if [[ "$line" =~ \$\{PRIVATE_KEY\} ]] && [ -n "${PRIVATE_KEY:-}" ]; then + export "${line//\$\{PRIVATE_KEY\}/$PRIVATE_KEY}" + continue + fi + export "$line" + done < "$env_file" +} + +# Load project env through the shared loader so secure-secrets fallbacks and RPC cleanup +# behave the same way they do in deployment scripts. +if [ -f "$PROJECT_ROOT/scripts/load-env.sh" ]; then + # shellcheck disable=SC1090 + source "$PROJECT_ROOT/scripts/load-env.sh" >/dev/null 2>&1 +elif [ -f "$PROJECT_ROOT/.env" ]; then + source "$PROJECT_ROOT/.env" +fi + +if [ -f .env ]; then + load_env_file .env +fi + +# Profile-specific env should win over repo defaults, but .env.local remains the +# highest-precedence operator override for ad hoc experiments. +if [ -n "$PROFILE" ] && [ -f ".env.$PROFILE" ]; then + load_env_file ".env.$PROFILE" +fi + +if [ "$SKIP_ENV_LOCAL" != "1" ] && [ -f .env.local ]; then + load_env_file .env.local +fi + +if [ -n "$PROFILE" ]; then + export RELAY_PROFILE="$PROFILE" fi # Ensure PRIVATE_KEY is exported diff --git a/services/relay/test.js b/services/relay/test.js index e8fcf48..06ffc6f 100644 --- a/services/relay/test.js +++ b/services/relay/test.js @@ -10,6 +10,8 @@ import { existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; +import { ethers } from 'ethers'; +import { RelayService } from './src/RelayService.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -24,7 +26,71 @@ console.log('Relay service structure tests...'); assert(existsSync(join(__dirname, 'src', 'config.js')), 'src/config.js should exist'); assert(existsSync(join(__dirname, 'src', 'RelayService.js')), 'src/RelayService.js should exist'); +assert(existsSync(join(__dirname, 'src', 'healthServer.js')), 'src/healthServer.js should exist'); assert(existsSync(join(__dirname, 'index.js')), 'index.js should exist'); +const logger = { + info() {}, + warn() {}, + error() {}, + debug() {}, +}; + +const relay = new RelayService({ + sourceChain: { + name: 'Chain 138', + chainId: 138, + rpcUrl: 'http://example.invalid', + routerAddress: '0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817', + bridgeAddress: '0x152ed3e9912161b76bdfd368d0c84b7c31c10de7', + }, + destinationChain: { + name: 'Ethereum Mainnet', + chainId: 1, + rpcUrl: 'http://example.invalid', + relayRouterAddress: '0x416564Ab73Ad5710855E98dC7bC7Bff7387285BA', + relayBridgeAddress: '0x2bF74583206A49Be07E0E8A94197C12987AbD7B5', + relayBridgeAllowlist: ['0x2bF74583206A49Be07E0E8A94197C12987AbD7B5'], + chainSelector: 5009297550715157269n, + deliveryMode: 'router', + }, + tokenMapping: {}, + sourceChainSelector: 138n, + relayer: { privateKey: '', address: '' }, + monitoring: { + startBlock: 'latest', + pollInterval: 5000, + confirmationBlocks: 1, + finalityDelayBlocks: 2, + replayWindowBlocks: 32, + }, + retry: { maxRetries: 3, retryDelay: 5000 }, + skipMessageIds: new Set(), +}, logger); + +const cwReceiver = ethers.AbiCoder.defaultAbiCoder().encode( + ['address'], + ['0x2bF74583206A49Be07E0E8A94197C12987AbD7B5'] +); +const scoped = relay.evaluateMessageScope({ + sender: '0x152ed3e9912161b76bdfd368d0c84b7c31c10de7', + receiver: cwReceiver, +}); +assert(scoped.inScope === true, 'cW message should match the configured worker scope'); +assert( + scoped.targetBridge === '0x2bF74583206A49Be07E0E8A94197C12987AbD7B5', + 'cW target bridge should decode from receiver bytes' +); + +const wethReceiver = ethers.AbiCoder.defaultAbiCoder().encode( + ['address'], + ['0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939'] +); +const rejected = relay.evaluateMessageScope({ + sender: '0xcacfd227A040002e49e2e01626363071324f820a', + receiver: wethReceiver, +}); +assert(rejected.inScope === false, 'WETH message should be rejected by the cW worker scope'); + console.log('OK: relay service structure valid'); process.exit(0); diff --git a/services/token-aggregation/.env.example b/services/token-aggregation/.env.example new file mode 100644 index 0000000..aa53b48 --- /dev/null +++ b/services/token-aggregation/.env.example @@ -0,0 +1,137 @@ +# Token-aggregation service — copy to .env and adjust. +# See docs/04-configuration/TOKEN_AGGREGATION_REPORT_API_RUNBOOK.md + +PORT=3000 +LOG_LEVEL=info + +# Chain 138 RPC: +# - explorer / token-aggregation / Blockscout on the LAN should use the public RPC node directly: +# CHAIN_138_RPC_URL=http://192.168.11.221:8545 +# - external/public clients should use the public FQDN: +# CHAIN_138_RPC_URL=https://rpc-http-pub.d-bis.org +# - do not point explorer/read services at the operator core RPC 192.168.11.211:8545 + +# GET /api/v1/quote on Chain 138 + DODO: optional on-chain PMM quote (querySellBase/Quote). +# Precedence: TOKEN_AGGREGATION_PMM_RPC_URL → TOKEN_AGGREGATION_CHAIN138_RPC_URL → RPC_URL_138. +# TOKEN_AGGREGATION_PMM_RPC_URL=http://192.168.11.211:8545 +# TOKEN_AGGREGATION_PMM_QUERY_TRADER=0x4A666F96fC8764181194447A7dFdb7d471b301C8 + +# PMM pools: canonical integration is defaulted in dex-factories.ts if unset. +# CHAIN_138_DODO_PMM_INTEGRATION=0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d +# CHAIN_138_DODO_POOL_MANAGER= + +# Minimum token report addresses (V1 = PMM / liquidity canonical on Chain 138) +CUSDT_ADDRESS_138=0x93E66202A11B1772E55407B32B44e5Cd8eda7f22 +CUSDC_ADDRESS_138=0xf22258f57794CC8E06237084b353Ab30fFfa640b + +# Compliant USD V2 (ERC-2612 / ERC-3009) — x402 / GRU transport. +# Reference: docs/04-configuration/CHAIN138_X402_TOKEN_SUPPORT.md +CUSDT_V2_ADDRESS_138=0x8d342d321DdEe97D0c5011DAF8ca0B59DA617D29 +CUSDC_V2_ADDRESS_138=0x1ac3F4942a71E86A9682D91837E1E71b7BACdF99 + +# Live ALL Mainnet AUSDT compliant landing surface on Chain 138. +CAUSDT_ADDRESS_138=0x5fdDF65733e3d590463F68f93Cf16E8c04081271 + +# Planned ALL Mainnet gold corridor surfaces. +# These remain env-gated until the 651940 wrapped + unwrapped gold contracts are deployed. +# CAXAUC_ADDRESS_651940=0x... +# CAXAUT_ADDRESS_651940=0x... +# CWAXAUC_ADDRESS_651940=0x... +# CWAXAUT_ADDRESS_651940=0x... + +# Repo-native D-WIN-aligned USDW PMM / transport surface. +CUSDW_ADDRESS_138=0xcA6BFa614935f1AB71c9aB106bAA6FBB6057095e +# Existing public cWAUSDT mirrors for the live AUSDT -> cWAUSDT -> cAUSDT path: +CWAUSDT_ADDRESS_56=0xe1a51Bc037a79AB36767561B147eb41780124934 +CWAUSDT_ADDRESS_137=0xf12e262F85107df26741726b074606CaFa24AAe7 +CWAUSDT_ADDRESS_43114=0xff3084410A732231472Ee9f93F5855dA89CC5254 +CWAUSDT_ADDRESS_42220=0xC158b6cD3A3088C52F797D41f5Aa02825361629e +# Preferred canonical env names for wrapped cWUSDW edge visibility: +CWUSDW_ADDRESS_56=0xC2FA05F12a75Ac84ea778AF9D6935cA807275E55 +# CWUSDW_ADDRESS_137=0x... # Activate only after Polygon cWUSDW is deployed and approved. +CWUSDW_ADDRESS_43114=0xcfdCe5E660FC2C8052BDfa7aEa1865DD753411Ae +# Legacy aliases still understood by canonical-tokens.ts: +# CWUSDW_BSC=0xC2FA05F12a75Ac84ea778AF9D6935cA807275E55 +# CWUSDW_AVALANCHE=0xcfdCe5E660FC2C8052BDfa7aEa1865DD753411Ae + +# PostgreSQL (required for persistent index / reports) +# DATABASE_URL=postgresql://user:pass@localhost:5432/token_aggregation + +# Indexer tick (ms) +# INDEXING_INTERVAL=5000 + +# Set to false on public read-only deployments that should not run the +# background multi-chain indexer in-process. +# ENABLE_INDEXER=true + +# Optional: override built-in bridge/routes JSON (fetched every 5m) +# BRIDGE_LIST_JSON_URL=https://example.com/bridge-list.json + +# CCIP / Trustless overrides for /api/v1/bridge/routes defaults +# CCIPWETH9_BRIDGE_CHAIN138= +# CCIPWETH10_BRIDGE_CHAIN138= +# LOCKBOX_138= +# INBOX_ETH= + +# GRU Monetary Transport Layer runtime refs +# Set these when exposing GRU transport readiness from token-aggregation. +# CHAIN138_L1_BRIDGE=0x152ed3e9912161b76bdfd368d0c84b7c31c10de7 +# CW_BRIDGE_MAINNET= +# CW_BRIDGE_BSC= +# CW_BRIDGE_POLYGON= +# CW_BRIDGE_AVALANCHE= +# CW_BRIDGE_CELO= +# CW_BRIDGE_ARBITRUM= +# CW_BRIDGE_BASE= +# CW_BRIDGE_OPTIMISM= +# CW_BRIDGE_GNOSIS= +# CW_RESERVE_VERIFIER_CHAIN138= +# CW_STABLECOIN_RESERVE_VAULT= +# CW_RESERVE_SYSTEM= +# CW_MAX_OUTSTANDING_USDT_MAINNET= +# CW_MAX_OUTSTANDING_USDC_MAINNET= +# CW_MAX_OUTSTANDING_USDT_BSC= +# CW_MAX_OUTSTANDING_USDC_BSC= +# CW_MAX_OUTSTANDING_USDT_POLYGON= +# CW_MAX_OUTSTANDING_USDC_POLYGON= +# CW_MAX_OUTSTANDING_USDT_AVALANCHE= +# CW_MAX_OUTSTANDING_USDC_AVALANCHE= +# CW_MAX_OUTSTANDING_USDT_ARBITRUM= +# CW_MAX_OUTSTANDING_USDC_ARBITRUM= +# CW_MAX_OUTSTANDING_USDT_BASE= +# CW_MAX_OUTSTANDING_USDC_BASE= +# CW_MAX_OUTSTANDING_USDT_OPTIMISM= +# CW_MAX_OUTSTANDING_USDC_OPTIMISM= +# CW_MAX_OUTSTANDING_USDT_GNOSIS= +# CW_MAX_OUTSTANDING_USDC_GNOSIS= +# CW_MAX_OUTSTANDING_USDT_CELO= +# CW_MAX_OUTSTANDING_USDC_CELO= +# CW_MAX_OUTSTANDING_AUSDT_CELO= +# Gas-native rollout refs (Wave 1) +# Deployed but not active-by-default: generic gas verifier on Chain 138. +# Keep the active gas verifier envs below blank until the live L1 bridge is explicitly wired to it. +CW_ASSET_RESERVE_VERIFIER_DEPLOYED_CHAIN138=0xbf26a679586663f87f3bf3f52c79479b8aa8d854 +# CW_BRIDGE_WEMIX= +# CW_GAS_STRICT_ESCROW_VERIFIER_CHAIN138= +# CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138= +# CW_GAS_ESCROW_VAULT_CHAIN138= +# CW_GAS_TREASURY_SYSTEM= +# CW_MAX_OUTSTANDING_ETH_MAINNET_MAINNET= +# CW_GAS_OUTSTANDING_ETH_MAINNET_MAINNET= +# CW_GAS_ESCROWED_ETH_MAINNET_MAINNET= +# CW_GAS_TREASURY_BACKED_ETH_MAINNET_MAINNET= +# CW_GAS_TREASURY_CAP_ETH_MAINNET_MAINNET= +# CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM= +# CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM= +# CW_GAS_ESCROWED_ETH_L2_OPTIMISM= +# CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM= +# CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM= +# CW_MAX_OUTSTANDING_ETH_L2_ARBITRUM= +# CW_MAX_OUTSTANDING_ETH_L2_BASE= +# CW_MAX_OUTSTANDING_BNB_BSC= +# CW_MAX_OUTSTANDING_POL_POLYGON= +# CW_MAX_OUTSTANDING_AVAX_AVALANCHE= +# CW_MAX_OUTSTANDING_CRO_CRONOS= +# CW_MAX_OUTSTANDING_XDAI_GNOSIS= +# CW_MAX_OUTSTANDING_CELO_CELO= +# CW_MAX_OUTSTANDING_WEMIX_WEMIX= diff --git a/services/token-aggregation/PROXMOX_DEPLOYMENT.md b/services/token-aggregation/PROXMOX_DEPLOYMENT.md index a9a3aeb..19d7eb5 100644 --- a/services/token-aggregation/PROXMOX_DEPLOYMENT.md +++ b/services/token-aggregation/PROXMOX_DEPLOYMENT.md @@ -81,7 +81,7 @@ pct exec $VMID -- nano /opt/token-aggregation/.env Required variables: - `DATABASE_URL` -- `CHAIN_138_RPC_URL` +- `CHAIN_138_RPC_URL` (`http://192.168.11.221:8545` for LAN/explorer deployments; not the operator core RPC `http://192.168.11.211:8545`) - `CHAIN_651940_RPC_URL` ### Database Migration diff --git a/services/token-aggregation/QUICK_START.md b/services/token-aggregation/QUICK_START.md index c492221..0930042 100644 --- a/services/token-aggregation/QUICK_START.md +++ b/services/token-aggregation/QUICK_START.md @@ -23,10 +23,12 @@ cp .env.example .env Minimum required in `.env`: ```bash DATABASE_URL=postgresql://user:password@localhost:5432/explorer_db -CHAIN_138_RPC_URL=https://rpc-http-pub.d-bis.org +CHAIN_138_RPC_URL=http://192.168.11.221:8545 CHAIN_651940_RPC_URL=https://mainnet-rpc.alltra.global ``` +Use `https://rpc-http-pub.d-bis.org` only for external/public-only deployments. Do not point explorer/read services at the operator core RPC `http://192.168.11.211:8545`. + ### 3. Run Database Migration ```bash # Navigate to explorer backend and run migration diff --git a/services/token-aggregation/README.md b/services/token-aggregation/README.md index 94d5aef..8c253e9 100644 --- a/services/token-aggregation/README.md +++ b/services/token-aggregation/README.md @@ -11,6 +11,7 @@ A comprehensive token aggregation service that indexes token info, volume, liqui - **Chain-Native Indexing**: Indexes tokens, DEX pools, and swap events directly from blockchain - **External API Enrichment**: Enriches data with CoinGecko, CoinMarketCap, and DexScreener - **Multi-DEX Support**: Supports UniswapV2, UniswapV3, and DODO PMM protocols +- **Chain 138 PMM quotes**: `GET /api/v1/quote` runs on-chain `querySellBase` / `querySellQuote` when RPC is configured (`RPC_URL_138` or `TOKEN_AGGREGATION_*` in `.env.example`); JSON includes `quoteEngine` (`pmm-onchain` vs `constant-product`) - **OHLCV Data**: Generates Open, High, Low, Close, Volume data for price charts - **Volume Analytics**: Calculates 5m, 1h, 24h, 7d, 30d volume metrics - **REST API**: Unified REST API for all token data @@ -50,7 +51,9 @@ cp .env.example .env 4. Configure environment variables in `.env` (see `.env.example` for full list): ```bash # Chain RPCs -CHAIN_138_RPC_URL=https://rpc-http-pub.d-bis.org +# Explorer-side/LAN deployment: use the public RPC node directly. +CHAIN_138_RPC_URL=http://192.168.11.221:8545 +# External-only deployments may use: https://rpc-http-pub.d-bis.org CHAIN_651940_RPC_URL=https://mainnet-rpc.alltra.global # Database @@ -62,7 +65,12 @@ COINMARKETCAP_API_KEY=your_key_here DEXSCREENER_API_KEY=your_key_here ``` -**Canonical token addresses (report API):** Set per-chain env vars for tokens you want in `/api/v1/report/*`. Required/minimal for report: `CUSDC_ADDRESS_138`, `CUSDT_ADDRESS_138` (Chain 138); optionally `CUSDC_ADDRESS_651940`, `CUSDT_ADDRESS_651940` for Chain 651940. **Chain 138 compliant fiat** (cEURC, cEURT, cGBPC, cGBPT, cAUDC, cJPYC, cCHFC, cCADC, cXAUC, cXAUT) have **fallback addresses** in `src/config/canonical-tokens.ts` (DeployCompliantFiatTokens 2026-02-27); they are included in the report without env. Override with `CEURC_ADDRESS_138`, etc. if needed. Other symbols (USDW, acUSDC, vdcUSDC, sdcUSDC, etc.) — see `.env.example`. Unset tokens (with no fallback) are omitted from the report. +For Chain 138 there is an important split: +- Explorer, Blockscout, token-aggregation, and other read-mostly services should use the public RPC node on `192.168.11.221:8545` when they run on the LAN. +- External wallets and dApps should use `https://rpc-http-pub.d-bis.org`. +- Operator/deploy workflows use the core RPC `http://192.168.11.211:8545`, but that core endpoint is not for explorer/read traffic. + +**Canonical token addresses (report API):** Set per-chain env vars for tokens you want in `/api/v1/report/*`. Required/minimal for report: `CUSDC_ADDRESS_138`, `CUSDT_ADDRESS_138` (Chain 138); optionally `CUSDC_ADDRESS_651940`, `CUSDT_ADDRESS_651940` for Chain 651940. **Chain 138 compliant fiat** (cEURC, cEURT, cGBPC, cGBPT, cAUDC, cJPYC, cCHFC, cCADC, cXAUC, cXAUT) have **fallback addresses** in `src/config/canonical-tokens.ts` (DeployCompliantFiatTokens 2026-02-27); they are included in the report without env. The live **ALL Mainnet AUSDT corridor** is also surfaced there as `cAUSDT` on Chain 138 plus `cWAUSDT` mirrors on BSC, Polygon, Avalanche, and Celo. The planned **ALL Mainnet gold corridor** is env-gated under `CAXAUC_ADDRESS_651940`, `CAXAUT_ADDRESS_651940`, `CWAXAUC_ADDRESS_651940`, and `CWAXAUT_ADDRESS_651940`. Other symbols (USDW, acUSDC, vdcUSDC, sdcUSDC, etc.) — see `.env.example`. Unset tokens (with no fallback) are omitted from the report. ### Required environment variables (canonical tokens — Blitzkrieg Step 1/9) @@ -70,8 +78,8 @@ The canonical token list is defined in `src/config/canonical-tokens.ts` and is t | Purpose | Env var pattern | Example | |--------|------------------|--------| -| Chain 138 | `{SYMBOL}_ADDRESS_138` (symbol with `-` → `_`, uppercase) | `CUSDC_ADDRESS_138`, `CUSDT_ADDRESS_138`, `USDW_ADDRESS_138`, `ACUSDC_ADDRESS_138`, `VDCUSDC_ADDRESS_138`, `SDCUSDC_ADDRESS_138` | -| Chain 651940 (ALL Mainnet) | `{SYMBOL}_ADDRESS_651940` | `CUSDC_ADDRESS_651940`, `CUSDT_ADDRESS_651940` | +| Chain 138 | `{SYMBOL}_ADDRESS_138` (symbol with `-` → `_`, uppercase) | `CUSDC_ADDRESS_138`, `CUSDT_ADDRESS_138`, `CAUSDT_ADDRESS_138`, `USDW_ADDRESS_138`, `ACUSDC_ADDRESS_138`, `VDCUSDC_ADDRESS_138`, `SDCUSDC_ADDRESS_138` | +| Chain 651940 (ALL Mainnet) | `{SYMBOL}_ADDRESS_651940` | `CUSDC_ADDRESS_651940`, `CUSDT_ADDRESS_651940`, `AUSDT_ADDRESS_651940`, `CAXAUC_ADDRESS_651940`, `CWAXAUC_ADDRESS_651940` | **Minimum for report API:** `CUSDC_ADDRESS_138`, `CUSDT_ADDRESS_138`. For full GRU M1 + W-tokens + ac*/vdc*/sdc* coverage, set the corresponding `*_ADDRESS_138` and `*_ADDRESS_651940` vars. See `.env.example` for the full commented list. Refs: [BLITZKRIEG_SUPER_PRO_MAX_MASTER_PLAN](../../../docs/00-meta/BLITZKRIEG_SUPER_PRO_MAX_MASTER_PLAN.md) §2–§3, [PLACEHOLDERS_AND_COMPLETION_MASTER_LIST](../../../docs/00-meta/PLACEHOLDERS_AND_COMPLETION_MASTER_LIST.md). @@ -198,8 +206,8 @@ Full API for all coins, tokens, liquidity, and reportable data in CMC/CoinGecko- | `GET /api/v1/report/all` | All tokens, pools, liquidity, volume, and summary by chain | | `GET /api/v1/report/coingecko?chainId=138` | Token list in CoinGecko submission format | | `GET /api/v1/report/cmc?chainId=138` | Token list and DEX pairs in CoinMarketCap format | -| `GET /api/v1/report/token-list` | Flat canonical token list (all chains or `?chainId=138`) | -| `GET /api/v1/report/canonical` | Raw canonical token spec (symbol, name, type, decimals, addresses) | +| `GET /api/v1/report/token-list` | Flat canonical token list, including explicit Chain 138 V1/V2 GRU deployments where available | +| `GET /api/v1/report/canonical` | Raw canonical token spec with version/family metadata and addresses | See [docs/CMC_COINGECKO_REPORTING.md](docs/CMC_COINGECKO_REPORTING.md) for usage and listing submission. diff --git a/services/token-aggregation/docs/CMC_COINGECKO_REPORTING.md b/services/token-aggregation/docs/CMC_COINGECKO_REPORTING.md index be551e7..e63243c 100644 --- a/services/token-aggregation/docs/CMC_COINGECKO_REPORTING.md +++ b/services/token-aggregation/docs/CMC_COINGECKO_REPORTING.md @@ -99,7 +99,8 @@ Raw canonical token definitions (no DB merge): symbol, name, type, decimals, cur The report uses the **canonical token list** in `src/config/canonical-tokens.ts`. It includes: -- **Base (GRU-M1):** cUSDC, cUSDT, cEURC, cEURT, cGBPC, cGBPT, cAUDC, cJPYC, cCHFC, cCADC, cXAUC, cXAUT +- **Base (GRU-M1):** cUSDC, cUSDT, cAUSDT, cEURC, cEURT, cGBPC, cGBPT, cAUDC, cJPYC, cCHFC, cCADC, cXAUC, cXAUT, plus env-gated ALL Mainnet gold landings `cAXAUC` / `cAXAUT` +- **Public transport mirrors (cW*):** cWUSDC, cWUSDT, cWUSDW, live cWAUSDT mirrors on BSC, Polygon, Avalanche, and Celo, plus env-gated ALL Mainnet gold wrappers `cWAXAUC` / `cWAXAUT` - **W-tokens (ISO-4217):** USDW, EURW, GBPW, AUDW, JPYW, CHFW, CADW - **Asset (ac*):** acUSDC, acUSDT, acEURC, acGBPC, acAUDC, acJPYC, acCHFC, acCADC, acXAUC - **Debt (vdc* / sdc*):** vdcUSDC, sdcUSDC, vdcEURC, sdcEURC, vdcGBPC, sdcGBPC, vdcAUDC, sdcAUDC, vdcJPYC, sdcJPYC, vdcCHFC, sdcCHFC, vdcCADC, sdcCADC, vdcXAUC, sdcXAUC @@ -112,6 +113,17 @@ Addresses per chain can be: Only tokens with a non-empty address for the requested chain appear in `/report/coingecko`, `/report/cmc`, and in the per-chain sections of `/report/all` and `/report/token-list`. +For the live **ALL Mainnet AUSDT -> cWAUSDT -> cAUSDT** corridor, that means: + +- `cWAUSDT` appears on BSC, Polygon, Avalanche, and Celo +- `cAUSDT` appears on Chain 138 via the live deployed address + +For the planned **ALL Mainnet gold** corridor, the report remains intentionally env-gated until the destination contracts are live: + +- `cAXAUC` / `cAXAUT` only appear on chain `651940` when `CAXAUC_ADDRESS_651940` / `CAXAUT_ADDRESS_651940` are set +- `cWAXAUC` / `cWAXAUT` only appear on chain `651940` when `CWAXAUC_ADDRESS_651940` / `CWAXAUT_ADDRESS_651940` are set +- generic `cXAUC` / `cXAUT` do **not** appear on `651940`; those remain canonical Chain 138 symbols + ## ERC-20 and DEX Compatibility All canonical tokens are designed to be **ERC-20 compliant** and usable in DEX liquidity pools (see `smom-dbis-138/docs/tokenization/TOKEN_SCOPE_GRU.md`). Base and asset tokens are fully transferable; debt tokens can be deployed with optional transferability. The report API does not enforce on-chain checks; it reports whatever is in the canonical list and DB (addresses, market data, pools). diff --git a/services/token-aggregation/docs/DEPLOYMENT.md b/services/token-aggregation/docs/DEPLOYMENT.md index 4a7435c..5709423 100644 --- a/services/token-aggregation/docs/DEPLOYMENT.md +++ b/services/token-aggregation/docs/DEPLOYMENT.md @@ -37,7 +37,7 @@ cp .env.example .env 2. Configure required variables: ```bash # Required -CHAIN_138_RPC_URL=https://rpc-http-pub.d-bis.org +CHAIN_138_RPC_URL=http://192.168.11.221:8545 CHAIN_651940_RPC_URL=https://mainnet-rpc.alltra.global DATABASE_URL=postgresql://user:password@localhost:5432/explorer_db @@ -47,6 +47,8 @@ COINMARKETCAP_API_KEY=your_key_here DEXSCREENER_API_KEY=your_key_here ``` +For explorer/LAN deployments, `CHAIN_138_RPC_URL` should point to the public Chain 138 RPC node directly at `http://192.168.11.221:8545`. Use `https://rpc-http-pub.d-bis.org` for external-only consumers. Do not point explorer/read services at the operator core RPC `http://192.168.11.211:8545`. + ## Local Deployment ### Using npm @@ -105,12 +107,15 @@ kind: ConfigMap metadata: name: token-aggregation-config data: - CHAIN_138_RPC_URL: "https://rpc-http-pub.d-bis.org" + CHAIN_138_RPC_URL: "http://192.168.11.221:8545" CHAIN_651940_RPC_URL: "https://mainnet-rpc.alltra.global" INDEXING_INTERVAL: "5000" + ENABLE_INDEXER: "true" LOG_LEVEL: "info" ``` +Set `ENABLE_INDEXER` to `"false"` for public read-only explorer deployments that should serve API traffic without running the in-process multi-chain indexer. + 2. Create a Secret for sensitive data: ```yaml apiVersion: v1 diff --git a/services/token-aggregation/docs/REST_API_REFERENCE.md b/services/token-aggregation/docs/REST_API_REFERENCE.md index c6d697a..b2f32bc 100644 --- a/services/token-aggregation/docs/REST_API_REFERENCE.md +++ b/services/token-aggregation/docs/REST_API_REFERENCE.md @@ -16,9 +16,15 @@ Returns supported chains. ### GET /api/v1/networks -Full EIP-3085 chain params for `wallet_addEthereumChain` (Chain 138, Ethereum Mainnet 1, ALL Mainnet 651940). Includes RPC URLs, block explorer URLs, native currency, and oracles per chain. Used by the MetaMask Snap to serve dynamic network and oracle data. If **NETWORKS_JSON_URL** is set (e.g. GitHub raw URL), the service fetches that URL and returns `{ version, networks }`; otherwise uses built-in networks. +Full EIP-3085 chain params for `wallet_addEthereumChain` (Chain 138, Ethereum Mainnet 1, ALL Mainnet 651940). Includes RPC URLs, block explorer URLs, native currency, and oracles per chain. Used by the MetaMask Snap to serve dynamic network and oracle data. Source priority is: -**Response:** `{ version: string, networks: NetworkEntry[] }` +1. **NETWORKS_JSON_URL** when configured +2. **NETWORKS_JSON_PATH** / **CONFIG_NETWORKS_JSON_PATH** runtime local JSON +3. built-in network config + +Responses include `source: "remote-url" | "runtime-file" | "built-in"` and send `Cache-Control: public, max-age=0, must-revalidate`. + +**Response:** `{ source, version: string, networks: NetworkEntry[], lastModified?: string }` Each `NetworkEntry` has: `chainId` (hex), `chainIdDecimal`, `chainName`, `rpcUrls`, `nativeCurrency`, `blockExplorerUrls`, `iconUrls` (chain-specific logos; optional), `oracles: [{ name, address, decimals? }]`. Chain 138 and ALL Mainnet include explorer favicons; Ethereum includes standard ETH logo. ### GET /api/v1/config @@ -27,14 +33,18 @@ Oracles (and config) per chain. Used by the Snap for USD price feeds (e.g. ETH/U **Query:** `chainId` (optional) — if provided, returns config for that chain only. -**Response (no query):** `{ version: string, chains: [{ chainId, oracles: [{ name, address }] }] }` -**Response (chainId=138):** `{ version, chainId: 138, oracles: [{ name, address }] }` +`/api/v1/config` follows the same source priority as `/api/v1/networks`, so both endpoints read from the same freshest source rather than drifting. + +**Response (no query):** `{ source, version: string, chains: [{ chainId, oracles: [{ name, address }] }] }` +**Response (chainId=138):** `{ source, version, chainId: 138, oracles: [{ name, address }] }` --- ## Token list (report) -**GET /api/v1/report/token-list** returns a Uniswap-style token list with **logoURI** per token and a list-level **logoURI**. Each token has: `chainId`, `address`, `symbol`, `name`, `decimals`, `type`, `logoURI`. Use for MetaMask token list URL or Snap `get_token_list`. Optional query `?chainId=138` filters by chain. If **TOKEN_LIST_JSON_URL** is set (e.g. GitHub raw URL), the service fetches that JSON and returns it (with optional chainId filter); otherwise uses the built-in canonical token list. +**GET /api/v1/report/token-list** returns a Uniswap-style token list with **logoURI** per token and a list-level **logoURI**. Each token has: `chainId`, `address`, `symbol`, `name`, `decimals`, `type`, `logoURI`. Chain 138 also exposes staged GRU x402 deployments such as `cUSDT_V2` and `cUSDC_V2` explicitly, with optional metadata fields like `familySymbol`, `deploymentVersion`, and `preferredForX402`. Use for MetaMask token list URL or Snap `get_token_list`. Optional query `?chainId=138` filters by chain. If **TOKEN_LIST_JSON_URL** is set (e.g. GitHub raw URL), the service fetches that JSON and returns it (with optional chainId filter); otherwise uses the built-in canonical token list. + +**GET /api/v1/report/cw-registry** returns the public-chain `cW*` registry grouped by chain. When `DEPLOYMENT_STATUS_JSON_PATH` / `CW_REGISTRY_JSON_PATH` points to `cross-chain-pmm-lps/config/deployment-status.json`, the endpoint reads that file at request time so explorer surfaces do not need a rebuild after registry edits. If the file is unavailable, it falls back to the built-in canonical `cW*` subset. --- @@ -42,7 +52,7 @@ Oracles (and config) per chain. Used by the Snap for USD price feeds (e.g. ETH/U ### GET /api/v1/quote -Returns an estimated swap output amount (constant-product from first available pool for the token pair). +Returns an estimated swap output amount (constant-product from first available pool for the token pair). When a staged Chain 138 deployment such as `cUSDT_V2` or `cUSDC_V2` is requested before pool cutover, the response includes `canonicalLiquidity` showing whether the quote was resolved through the current active liquidity deployment. **Query:** @@ -62,12 +72,41 @@ If no pool is found, `amountOut` is `null` and `error` describes. Used by the Me ### GET /api/v1/bridge/routes -Returns CCIP bridge routes for WETH9 and WETH10 (Chain 138 and Ethereum Mainnet). Used by the MetaMask Snap and dApps for bridge discovery. If **BRIDGE_LIST_JSON_URL** is set (e.g. GitHub raw URL), the service fetches that JSON and returns `{ routes, chain138Bridges }`; otherwise uses built-in routes. +Returns CCIP bridge routes for WETH9 and WETH10 (Chain 138 and Ethereum Mainnet). Used by the MetaMask Snap and dApps for bridge discovery. Source priority is: -**Response:** `{ routes, chain138Bridges, tokenMappingApi? }` +1. **BRIDGE_LIST_JSON_URL** when configured +2. **BRIDGE_LIST_JSON_PATH** / **BRIDGE_ROUTES_JSON_PATH** runtime local JSON +3. built-in routes + +If the remote URL is configured but fails, the route falls back to the runtime file or built-in payload rather than returning stale hard failure data. Responses include `source` and send `Cache-Control: public, max-age=0, must-revalidate`. + +**Response:** `{ source, routes, chain138Bridges, tokenMappingApi?, lastModified? }` - `routes`: `{ weth9: Record, weth10: Record }` — destination chain name → bridge address. - `chain138Bridges`: `{ weth9: string, weth10: string }` — Chain 138 bridge addresses. - `tokenMappingApi`: When the service runs from the monorepo, `{ basePath, pairs, resolve, note }` — use the same host and these paths for **cross-chain token address resolution** (138↔651940, 651940↔other chains). Bridge UIs should call `GET /api/v1/token-mapping?fromChain=&toChain=` or `GET /api/v1/token-mapping/resolve?fromChain=&toChain=&address=` when resolving token addresses on destination chains. +- `gruTransport.summary`: when GRU Transport overlay is available, includes counts such as `transportPairs`, `eligibleTransportPairs`, and `runtimeReadyTransportPairs`. + +### GET /api/v1/bridge/status + +Returns GRU Monetary Transport Layer pair status, including structural `eligible`, operational `runtimeReady`, and per-pair blockers. + +**Response:** `{ ok, bridges, gruTransport, message }` +- `gruTransport.summary`: overlay counts +- `gruTransport.pairs[]`: `{ key, canonicalSymbol, mirroredSymbol, destinationChainId, eligible, runtimeReady, eligibilityBlockers[], runtimeMissingRequirements[] }` + +### GET /api/v1/bridge/metrics + +Returns GRU Transport summary counts suitable for dashboards. + +**Response:** `{ ok, lanes, gruTransport: { system, summary }, message }` + +### GET /api/v1/bridge/preflight + +Returns a preflight view of GRU Transport readiness. Use this before deploy, restart, or route enablement. + +**Response:** `{ ok, generatedAt, gruTransport: { system, summary, blockedPairs[], readyPairs[] } }` +- `blockedPairs[]`: pairs that are not structurally eligible or not runtime-ready, with `eligibilityBlockers[]` and `runtimeMissingRequirements[]` +- `readyPairs[]`: minimal list of pairs fully ready to carry live traffic --- @@ -79,8 +118,11 @@ When run from the monorepo (with `config/token-mapping-multichain.json` availabl |----------|-------------| | `GET /api/v1/token-mapping?fromChain=138&toChain=651940` | Token mapping for a chain pair (tokens, addressMapFromTo, addressMapToFrom). | | `GET /api/v1/token-mapping/pairs` | All defined chain pairs. | +| `GET /api/v1/token-mapping/transport/active` | Full GRU Transport overlay view, including counts and active transport pair metadata. | | `GET /api/v1/token-mapping/resolve?fromChain=&toChain=&address=` | Resolve token address on target chain. | +`GET /api/v1/token-mapping/resolve` also returns `activeTransportEligible`, `gruTransportRuntimeReady`, and `gruTransportPairKey` when the address maps into an active GRU pair. + Use these for bridge UIs and cross-chain address resolution. See token-aggregation README § Token mapping. --- @@ -187,7 +229,7 @@ Returns service and database health. Response: `{ status: 'healthy'|'unhealthy', ## Caching and rate limiting -- Endpoints use short-lived cache (e.g. 1–5 minutes). Use `Cache-Control` headers from responses. +- Endpoints use short-lived cache (e.g. 1–5 minutes). Send `?refresh=1` to bypass the in-process cache when an explorer or operator view needs the latest read immediately. - `/api/v1` is rate-limited; see server configuration for limits. --- diff --git a/services/token-aggregation/package-lock.json b/services/token-aggregation/package-lock.json index afd5fe6..9d5ed33 100644 --- a/services/token-aggregation/package-lock.json +++ b/services/token-aggregation/package-lock.json @@ -8,37 +8,37 @@ "name": "token-aggregation-service", "version": "1.0.0", "dependencies": { - "axios": "^1.6.2", + "axios": "^1.13.5", "bcrypt": "^5.1.1", - "compression": "^1.7.4", - "cors": "^2.8.5", - "dotenv": "^16.3.1", - "ethers": "^6.8.0", - "express": "^4.18.2", - "express-rate-limit": "^7.1.5", - "jsonwebtoken": "^9.0.2", + "compression": "^1.8.1", + "cors": "^2.8.6", + "dotenv": "^16.6.1", + "ethers": "^6.16.0", + "express": "^4.22.1", + "express-rate-limit": "^7.5.1", + "jsonwebtoken": "^9.0.3", "node-cron": "^3.0.3", - "pg": "^8.11.3", - "winston": "^3.11.0" + "pg": "^8.18.0", + "winston": "^3.19.0" }, "devDependencies": { "@types/bcrypt": "^5.0.2", - "@types/compression": "^1.7.5", - "@types/cookie-parser": "^1.4.6", - "@types/cors": "^2.8.17", - "@types/express": "^4.17.17", - "@types/jest": "^29.5.11", - "@types/jsonwebtoken": "^9.0.5", - "@types/node": "^20.5.0", + "@types/compression": "^1.8.1", + "@types/cookie-parser": "^1.4.10", + "@types/cors": "^2.8.19", + "@types/express": "^4.17.25", + "@types/jest": "^29.5.14", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^20.19.33", "@types/node-cron": "^3.0.11", - "@types/pg": "^8.10.9", - "@typescript-eslint/eslint-plugin": "^6.15.0", - "@typescript-eslint/parser": "^6.15.0", - "eslint": "^8.56.0", + "@types/pg": "^8.16.0", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.57.1", "jest": "^29.7.0", - "ts-jest": "^29.1.1", - "ts-node": "^10.9.1", - "typescript": "^5.1.6" + "ts-jest": "^29.4.6", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" } }, "node_modules/@adraffy/ens-normalize": { @@ -78,6 +78,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1584,9 +1585,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", - "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "dev": true, "license": "MIT", "peer": true, @@ -2134,14 +2135,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", - "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-jest": { @@ -6221,10 +6222,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", diff --git a/services/token-aggregation/package.json b/services/token-aggregation/package.json index c427bbe..daff3f1 100644 --- a/services/token-aggregation/package.json +++ b/services/token-aggregation/package.json @@ -9,7 +9,9 @@ "start": "node dist/index.js", "dev": "ts-node src/index.ts", "test": "jest", + "test:ci": "jest --runInBand", "lint": "eslint src --ext .ts", + "generate:route-matrix:v2": "ts-node scripts/generate-route-matrix-v2.ts", "migrate": "node -r dotenv/config dist/database/migrations.js", "example:partner-payloads": "node scripts/resolve-partner-payloads-example.mjs" }, diff --git a/services/token-aggregation/scripts/apply-lightweight-schema.sh b/services/token-aggregation/scripts/apply-lightweight-schema.sh new file mode 100755 index 0000000..86ab037 --- /dev/null +++ b/services/token-aggregation/scripts/apply-lightweight-schema.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVICE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +SCHEMA_FILE="${SCRIPT_DIR}/bootstrap-lightweight-schema.sql" + +if [ -f "${SERVICE_DIR}/.env" ]; then + # shellcheck disable=SC1090 + set -a && source "${SERVICE_DIR}/.env" && set +a +fi + +if [ -z "${DATABASE_URL:-}" ]; then + echo "DATABASE_URL is required. Set it in ${SERVICE_DIR}/.env or export it before running." >&2 + exit 1 +fi + +echo "Applying lightweight token-aggregation schema using ${SCHEMA_FILE}" +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${SCHEMA_FILE}" diff --git a/services/token-aggregation/scripts/bootstrap-lightweight-schema.sql b/services/token-aggregation/scripts/bootstrap-lightweight-schema.sql new file mode 100644 index 0000000..ddf0d6c --- /dev/null +++ b/services/token-aggregation/scripts/bootstrap-lightweight-schema.sql @@ -0,0 +1,181 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS tokens ( + id BIGSERIAL PRIMARY KEY, + chain_id INTEGER NOT NULL, + address TEXT NOT NULL, + name TEXT, + symbol TEXT, + decimals INTEGER, + total_supply NUMERIC(78, 0), + logo_url TEXT, + website_url TEXT, + description TEXT, + verified BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (chain_id, address) +); + +CREATE INDEX IF NOT EXISTS idx_tokens_chain_id ON tokens (chain_id); +CREATE INDEX IF NOT EXISTS idx_tokens_chain_symbol ON tokens (chain_id, symbol); +CREATE INDEX IF NOT EXISTS idx_tokens_chain_name ON tokens (chain_id, name); + +CREATE TABLE IF NOT EXISTS token_market_data ( + chain_id INTEGER NOT NULL, + token_address TEXT NOT NULL, + price_usd NUMERIC(38, 18), + price_change_24h NUMERIC(38, 18), + volume_24h NUMERIC(38, 18) NOT NULL DEFAULT 0, + volume_7d NUMERIC(38, 18) NOT NULL DEFAULT 0, + volume_30d NUMERIC(38, 18) NOT NULL DEFAULT 0, + market_cap_usd NUMERIC(38, 18), + liquidity_usd NUMERIC(38, 18) NOT NULL DEFAULT 0, + holders_count INTEGER NOT NULL DEFAULT 0, + transfers_24h INTEGER NOT NULL DEFAULT 0, + last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (chain_id, token_address) +); + +CREATE INDEX IF NOT EXISTS idx_token_market_data_volume_24h + ON token_market_data (chain_id, volume_24h DESC); +CREATE INDEX IF NOT EXISTS idx_token_market_data_liquidity + ON token_market_data (chain_id, liquidity_usd DESC); + +CREATE TABLE IF NOT EXISTS liquidity_pools ( + id BIGSERIAL PRIMARY KEY, + chain_id INTEGER NOT NULL, + pool_address TEXT NOT NULL, + token0_address TEXT NOT NULL, + token1_address TEXT NOT NULL, + dex_type TEXT NOT NULL, + factory_address TEXT, + router_address TEXT, + reserve0 NUMERIC(78, 0) NOT NULL DEFAULT 0, + reserve1 NUMERIC(78, 0) NOT NULL DEFAULT 0, + reserve0_usd NUMERIC(38, 18) NOT NULL DEFAULT 0, + reserve1_usd NUMERIC(38, 18) NOT NULL DEFAULT 0, + total_liquidity_usd NUMERIC(38, 18) NOT NULL DEFAULT 0, + volume_24h NUMERIC(38, 18) NOT NULL DEFAULT 0, + fee_tier INTEGER, + created_at_block BIGINT, + created_at_timestamp TIMESTAMPTZ, + last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (chain_id, pool_address) +); + +CREATE INDEX IF NOT EXISTS idx_liquidity_pools_chain_liquidity + ON liquidity_pools (chain_id, total_liquidity_usd DESC); +CREATE INDEX IF NOT EXISTS idx_liquidity_pools_chain_token0 + ON liquidity_pools (chain_id, token0_address); +CREATE INDEX IF NOT EXISTS idx_liquidity_pools_chain_token1 + ON liquidity_pools (chain_id, token1_address); + +CREATE TABLE IF NOT EXISTS pool_reserves_history ( + id BIGSERIAL PRIMARY KEY, + chain_id INTEGER NOT NULL, + pool_address TEXT NOT NULL, + reserve0 NUMERIC(78, 0) NOT NULL, + reserve1 NUMERIC(78, 0) NOT NULL, + reserve0_usd NUMERIC(38, 18), + reserve1_usd NUMERIC(38, 18), + total_liquidity_usd NUMERIC(38, 18), + block_number BIGINT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_pool_reserves_history_lookup + ON pool_reserves_history (chain_id, pool_address, timestamp DESC); + +CREATE TABLE IF NOT EXISTS swap_events ( + id BIGSERIAL PRIMARY KEY, + chain_id INTEGER NOT NULL, + pool_address TEXT NOT NULL, + token0_address TEXT NOT NULL, + token1_address TEXT NOT NULL, + amount_usd NUMERIC(38, 18) NOT NULL DEFAULT 0, + price_usd NUMERIC(38, 18), + transaction_hash TEXT, + log_index INTEGER, + block_number BIGINT, + timestamp TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_swap_events_pool_time + ON swap_events (chain_id, pool_address, timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_swap_events_token_time + ON swap_events (chain_id, token0_address, token1_address, timestamp DESC); +CREATE UNIQUE INDEX IF NOT EXISTS idx_swap_events_unique_log + ON swap_events ( + chain_id, + pool_address, + COALESCE(transaction_hash, ''), + COALESCE(log_index, -1) + ); + +CREATE TABLE IF NOT EXISTS token_ohlcv ( + id BIGSERIAL PRIMARY KEY, + chain_id INTEGER NOT NULL, + token_address TEXT NOT NULL, + pool_address TEXT NOT NULL DEFAULT '', + interval_type TEXT NOT NULL, + open_price NUMERIC(38, 18) NOT NULL, + high_price NUMERIC(38, 18) NOT NULL, + low_price NUMERIC(38, 18) NOT NULL, + close_price NUMERIC(38, 18) NOT NULL, + volume NUMERIC(38, 18) NOT NULL DEFAULT 0, + volume_usd NUMERIC(38, 18) NOT NULL DEFAULT 0, + timestamp TIMESTAMPTZ NOT NULL, + UNIQUE (chain_id, token_address, pool_address, interval_type, timestamp) +); + +CREATE INDEX IF NOT EXISTS idx_token_ohlcv_lookup + ON token_ohlcv (chain_id, token_address, interval_type, timestamp DESC); + +CREATE TABLE IF NOT EXISTS provider_health_snapshots ( + id BIGSERIAL PRIMARY KEY, + chain_id INTEGER NOT NULL, + provider TEXT NOT NULL, + status TEXT NOT NULL, + supports_execution BOOLEAN NOT NULL DEFAULT FALSE, + supports_quote BOOLEAN NOT NULL DEFAULT FALSE, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_provider_health_snapshots_lookup + ON provider_health_snapshots (chain_id, provider, captured_at DESC); + +CREATE TABLE IF NOT EXISTS route_plan_cache ( + plan_id TEXT PRIMARY KEY, + request_hash TEXT NOT NULL, + chain_id INTEGER NOT NULL, + destination_chain_id INTEGER NOT NULL, + decision TEXT NOT NULL, + response_json JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_route_plan_cache_lookup + ON route_plan_cache (request_hash, expires_at DESC); + +CREATE TABLE IF NOT EXISTS route_execution_metrics ( + id BIGSERIAL PRIMARY KEY, + plan_id TEXT NOT NULL, + chain_id INTEGER NOT NULL, + provider TEXT NOT NULL, + hop_index INTEGER NOT NULL, + token_in_address TEXT NOT NULL, + token_out_address TEXT NOT NULL, + estimated_amount_out NUMERIC(78, 0), + actual_amount_out NUMERIC(78, 0), + status TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_route_execution_metrics_lookup + ON route_execution_metrics (plan_id, hop_index, created_at DESC); + +COMMIT; diff --git a/services/token-aggregation/scripts/generate-route-matrix-v2.ts b/services/token-aggregation/scripts/generate-route-matrix-v2.ts new file mode 100644 index 0000000..8f61374 --- /dev/null +++ b/services/token-aggregation/scripts/generate-route-matrix-v2.ts @@ -0,0 +1,41 @@ +import path from 'path'; +import { existsSync } from 'fs'; +import * as dotenv from 'dotenv'; +import { AggregatorRouteMatrixGenerator } from '../src/services/aggregator-route-matrix-generator'; +import { closeDatabasePool } from '../src/database/client'; + +const rootEnvCandidates = [ + path.resolve(__dirname, '../../.env'), + path.resolve(__dirname, '../../../.env'), +]; + +for (const candidate of rootEnvCandidates) { + if (existsSync(candidate)) { + dotenv.config({ path: candidate }); + break; + } +} +dotenv.config(); + +async function main() { + const outputPath = process.argv[2] + ? path.resolve(process.cwd(), process.argv[2]) + : path.resolve(__dirname, '../../../../config/aggregator-route-matrix.json'); + + const generator = new AggregatorRouteMatrixGenerator(); + try { + const writtenPath = await generator.writeToFile(outputPath, 138); + process.stdout.write(`${writtenPath}\n`); + } finally { + await closeDatabasePool(); + } +} + +main() + .then(() => { + process.exit(0); + }) + .catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`); + process.exit(1); + }); diff --git a/services/token-aggregation/scripts/verify-dodo-v3-planner-visibility.ts b/services/token-aggregation/scripts/verify-dodo-v3-planner-visibility.ts new file mode 100644 index 0000000..0a62125 --- /dev/null +++ b/services/token-aggregation/scripts/verify-dodo-v3-planner-visibility.ts @@ -0,0 +1,142 @@ +import fs from 'fs'; +import path from 'path'; +import { getProviderCapabilities } from '../src/config/provider-capabilities'; +import { InternalExecutionPlanV2Builder } from '../src/services/internal-execution-plan-v2'; +import { BestExecutionPlanner } from '../src/services/best-execution-planner'; + +const CHAIN_ID = 138; +const WETH10 = '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f'; +const USDT = '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1'; +const D3_PROXY = '0xc9a11abB7C63d88546Be24D58a6d95e3762cB843'; +const D3_POOL = '0x6550A3a59070061a262a893A1D6F3F490afFDBDA'; +const ROUTER_V2 = '0xF1c93F54A5C2fc0d7766Ccb0Ad8f157DFB4C99Ce'; +const AMOUNT_IN = '100000000000000000'; + +class MockPlannerMetricsRepository { + async getCachedPlan(): Promise { + return null; + } + + async recordProviderSnapshots(): Promise {} + + async cachePlan(): Promise {} + + async recordPlannedRouteMetrics(): Promise {} +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +function loadMatrix(): Record { + const root = path.resolve(__dirname, '../../../../'); + const matrixPath = path.resolve(root, 'config/aggregator-route-matrix.json'); + const raw = fs.readFileSync(matrixPath, 'utf8'); + return JSON.parse(raw) as Record; +} + +async function main(): Promise { + const capabilities = getProviderCapabilities(CHAIN_ID); + const dodoV3 = capabilities.find((capability) => capability.provider === 'dodo_v3'); + assert(dodoV3, 'Missing dodo_v3 provider capability for Chain 138.'); + assert(dodoV3.live === true, 'Expected dodo_v3 capability to be live.'); + assert(dodoV3.quoteLive === true, 'Expected dodo_v3 quoteLive=true.'); + assert(dodoV3.executionLive === true, 'Expected dodo_v3 executionLive=true with the live router-v2 D3 adapter.'); + + const pair = dodoV3.pairs.find( + (entry) => + entry.status === 'live' && + entry.tokenInAddress === WETH10.toLowerCase() && + entry.tokenOutAddress === USDT.toLowerCase() + ); + assert(pair, 'Missing live WETH10 -> USDT dodo_v3 capability.'); + assert(pair.target === D3_PROXY.toLowerCase(), `Expected D3 proxy target ${D3_PROXY}, got ${pair.target}.`); + + const planner = new BestExecutionPlanner(undefined, new MockPlannerMetricsRepository() as never); + const request = { + sourceChainId: CHAIN_ID, + destinationChainId: CHAIN_ID, + tokenIn: WETH10, + tokenOut: USDT, + amountIn: AMOUNT_IN, + }; + + const plan = await planner.plan(request); + assert(plan.decision === 'direct-pool', `Expected direct-pool decision, got ${plan.decision}.`); + assert(plan.legs.length === 1, `Expected 1 planner leg, got ${plan.legs.length}.`); + assert(plan.legs[0].provider === 'dodo_v3', `Expected dodo_v3 provider, got ${plan.legs[0].provider}.`); + assert(plan.routePlan !== undefined, 'Expected executable routePlan for dodo_v3 pilot routes.'); + assert( + plan.riskFlags.includes('pilot-venue') && !plan.riskFlags.includes('manual-execution-only'), + `Expected pilot-only risk flag set, got ${plan.riskFlags.join(', ')}.` + ); + assert(BigInt(plan.estimatedAmountOut) > 0n, 'Expected positive DODO v3 quote output.'); + + const executionBuilder = new InternalExecutionPlanV2Builder(planner); + const internalExecution = await executionBuilder.build(request); + assert(!internalExecution.error, `Expected executable internal plan, got ${internalExecution.error || 'none'}.`); + assert(internalExecution.execution?.kind === 'route', `Expected route execution plan, got ${internalExecution.execution?.kind || 'none'}.`); + assert( + internalExecution.execution?.contractAddress === ROUTER_V2.toLowerCase(), + `Expected router-v2 contract ${ROUTER_V2}, got ${internalExecution.execution?.contractAddress || 'none'}.` + ); + assert( + internalExecution.execution?.encodedCalldata?.startsWith('0x434180a2'), + 'Expected executeRoute calldata for dodo_v3 pilot route.' + ); + + const matrix = loadMatrix(); + const liveSwapRoutes = Array.isArray(matrix.liveSwapRoutes) ? matrix.liveSwapRoutes : []; + const dodoV3Routes = liveSwapRoutes.filter((route) => { + if (!route || typeof route !== 'object') return false; + const legs = Array.isArray((route as { legs?: unknown[] }).legs) ? (route as { legs: unknown[] }).legs : []; + return legs.some((leg) => leg && typeof leg === 'object' && (leg as { protocol?: string }).protocol === 'dodo_v3'); + }) as Array>; + + assert(dodoV3Routes.length === 2, `Expected 2 dodo_v3 routes in route matrix, got ${dodoV3Routes.length}.`); + for (const route of dodoV3Routes) { + const legs = Array.isArray(route.legs) ? route.legs : []; + const leg = legs[0] as Record; + assert(leg.poolAddress === D3_POOL.toLowerCase(), `Expected canonical D3 pool ${D3_POOL}, got ${String(leg.poolAddress || '')}.`); + assert(leg.executorAddress === D3_PROXY.toLowerCase(), `Expected canonical D3 proxy ${D3_PROXY}, got ${String(leg.executorAddress || '')}.`); + } + + console.log( + JSON.stringify( + { + verifiedAt: new Date().toISOString(), + chainId: CHAIN_ID, + capability: { + provider: dodoV3.provider, + live: dodoV3.live, + quoteLive: dodoV3.quoteLive, + executionLive: dodoV3.executionLive, + }, + planner: { + decision: plan.decision, + provider: plan.legs[0].provider, + estimatedAmountOut: plan.estimatedAmountOut, + riskFlags: plan.riskFlags, + routePlanPresent: plan.routePlan !== undefined, + }, + internalExecutionPlan: { + kind: internalExecution.execution?.kind, + contractAddress: internalExecution.execution?.contractAddress, + error: internalExecution.error, + }, + routeMatrix: { + dodoV3RouteIds: dodoV3Routes.map((route) => String(route.routeId || '')), + }, + }, + null, + 2 + ) + ); +} + +main().catch((error) => { + console.error(`[fail] ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/services/token-aggregation/src/api/middleware/cache.ts b/services/token-aggregation/src/api/middleware/cache.ts index f68425a..2d0b76a 100644 --- a/services/token-aggregation/src/api/middleware/cache.ts +++ b/services/token-aggregation/src/api/middleware/cache.ts @@ -10,22 +10,31 @@ const DEFAULT_TTL = 60 * 1000; // 1 minute export function cacheMiddleware(ttl: number = DEFAULT_TTL) { return (req: Request, res: Response, next: NextFunction) => { + const bypassCache = + req.query.refresh === '1' || + req.query.noCache === '1' || + /\bno-cache\b|\bno-store\b/i.test(req.header('cache-control') || ''); const key = `${req.method}:${req.originalUrl}`; - const cached = cache.get(key); + const cached = bypassCache ? undefined : cache.get(key); if (cached && cached.expiresAt > Date.now()) { + res.setHeader('X-Token-Aggregation-Cache', 'hit'); return res.json(cached.data); } + res.setHeader('X-Token-Aggregation-Cache', bypassCache ? 'bypass' : 'miss'); + // Store original json method const originalJson = res.json.bind(res); // Override json method to cache response res.json = function (body: unknown) { - cache.set(key, { - data: body, - expiresAt: Date.now() + ttl, - }); + if (!bypassCache) { + cache.set(key, { + data: body, + expiresAt: Date.now() + ttl, + }); + } return originalJson(body); }; diff --git a/services/token-aggregation/src/api/routes/bridge.test.ts b/services/token-aggregation/src/api/routes/bridge.test.ts new file mode 100644 index 0000000..97dedc8 --- /dev/null +++ b/services/token-aggregation/src/api/routes/bridge.test.ts @@ -0,0 +1,247 @@ +import { createServer } from 'http'; +import express from 'express'; +import fs from 'fs/promises'; +import path from 'path'; +import bridgeRoutes from './bridge'; + +jest.mock('../middleware/cache'); + +function createApp() { + const app = express(); + app.use('/api/v1/bridge', bridgeRoutes); + return app; +} + +async function startServer(app: express.Application): Promise<{ server: ReturnType; baseUrl: string }> { + const server = createServer(app); + await new Promise((resolve) => server.listen(0, () => resolve())); + const port = (server.address() as { port: number }).port; + return { server, baseUrl: `http://127.0.0.1:${port}` }; +} + +describe('Bridge API GRU Transport status', () => { + let server: ReturnType; + let baseUrl: string; + const originalChain138Bridge = process.env.CHAIN138_L1_BRIDGE; + const originalBscBridge = process.env.CW_BRIDGE_BSC; + const originalReserveVerifier = process.env.CW_RESERVE_VERIFIER_CHAIN138; + const originalReserveVault = process.env.CW_STABLECOIN_RESERVE_VAULT; + const originalReserveSystem = process.env.CW_RESERVE_SYSTEM; + const originalMaxOutstanding = process.env.CW_MAX_OUTSTANDING_USDT_BSC; + const originalBridgeListPath = process.env.BRIDGE_LIST_JSON_PATH; + const originalBridgeListUrl = process.env.BRIDGE_LIST_JSON_URL; + const originalCwL1Bridge = process.env.CW_L1_BRIDGE; + const originalCwL1BridgeChain138 = process.env.CW_L1_BRIDGE_CHAIN138; + + beforeAll(async () => { + const started = await startServer(createApp()); + server = started.server; + baseUrl = started.baseUrl; + }); + + afterEach(() => { + if (originalChain138Bridge === undefined) { + delete process.env.CHAIN138_L1_BRIDGE; + } else { + process.env.CHAIN138_L1_BRIDGE = originalChain138Bridge; + } + if (originalBscBridge === undefined) { + delete process.env.CW_BRIDGE_BSC; + } else { + process.env.CW_BRIDGE_BSC = originalBscBridge; + } + if (originalReserveVerifier === undefined) { + delete process.env.CW_RESERVE_VERIFIER_CHAIN138; + } else { + process.env.CW_RESERVE_VERIFIER_CHAIN138 = originalReserveVerifier; + } + if (originalReserveVault === undefined) { + delete process.env.CW_STABLECOIN_RESERVE_VAULT; + } else { + process.env.CW_STABLECOIN_RESERVE_VAULT = originalReserveVault; + } + if (originalReserveSystem === undefined) { + delete process.env.CW_RESERVE_SYSTEM; + } else { + process.env.CW_RESERVE_SYSTEM = originalReserveSystem; + } + if (originalMaxOutstanding === undefined) { + delete process.env.CW_MAX_OUTSTANDING_USDT_BSC; + } else { + process.env.CW_MAX_OUTSTANDING_USDT_BSC = originalMaxOutstanding; + } + if (originalBridgeListPath === undefined) { + delete process.env.BRIDGE_LIST_JSON_PATH; + } else { + process.env.BRIDGE_LIST_JSON_PATH = originalBridgeListPath; + } + if (originalBridgeListUrl === undefined) { + delete process.env.BRIDGE_LIST_JSON_URL; + } else { + process.env.BRIDGE_LIST_JSON_URL = originalBridgeListUrl; + } + if (originalCwL1Bridge === undefined) { + delete process.env.CW_L1_BRIDGE; + } else { + process.env.CW_L1_BRIDGE = originalCwL1Bridge; + } + if (originalCwL1BridgeChain138 === undefined) { + delete process.env.CW_L1_BRIDGE_CHAIN138; + } else { + process.env.CW_L1_BRIDGE_CHAIN138 = originalCwL1BridgeChain138; + } + }); + + afterAll((done) => { + server.close(done); + }); + + it('returns GRU transport pair runtime readiness on /status', async () => { + process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333'; + process.env.CW_BRIDGE_BSC = '0x4444444444444444444444444444444444444444'; + process.env.CW_RESERVE_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555'; + process.env.CW_STABLECOIN_RESERVE_VAULT = '0x6666666666666666666666666666666666666666'; + process.env.CW_RESERVE_SYSTEM = '0x7777777777777777777777777777777777777777'; + process.env.CW_MAX_OUTSTANDING_USDT_BSC = '1000000'; + + const res = await fetch(`${baseUrl}/api/v1/bridge/status`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.gruTransport?.summary?.runtimeReadyTransportPairs).toEqual(expect.any(Number)); + expect(body.gruTransport?.pairs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: '138-56-cUSDT-cWUSDT', + runtimeReady: true, + }), + expect.objectContaining({ + key: '138-10-cETHL2-cWETHL2', + assetClass: 'gas_native', + familyKey: 'eth_l2', + backingMode: 'hybrid_cap', + }), + ]) + ); + expect(body.gruTransport?.gasAssetFamilies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + familyKey: 'eth_mainnet', + backingMode: 'strict_escrow', + }), + ]) + ); + }); + + it('returns GRU transport summary counts on /metrics', async () => { + const res = await fetch(`${baseUrl}/api/v1/bridge/metrics`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.gruTransport?.summary).toMatchObject({ + transportPairs: expect.any(Number), + runtimeReadyTransportPairs: expect.any(Number), + }); + }); + + it('returns blocked pairs with missing requirements on /preflight', async () => { + delete process.env.CHAIN138_L1_BRIDGE; + delete process.env.CW_L1_BRIDGE; + delete process.env.CW_L1_BRIDGE_CHAIN138; + delete process.env.CW_BRIDGE_BSC; + delete process.env.CW_RESERVE_VERIFIER_CHAIN138; + delete process.env.CW_STABLECOIN_RESERVE_VAULT; + delete process.env.CW_RESERVE_SYSTEM; + delete process.env.CW_MAX_OUTSTANDING_USDT_BSC; + + const res = await fetch(`${baseUrl}/api/v1/bridge/preflight`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.ok).toBe(false); + expect(body.gruTransport?.blockedPairs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: '138-56-cUSDT-cWUSDT', + runtimeReady: false, + runtimeMissingRequirements: expect.arrayContaining([ + 'bridge:l1Bridge', + 'bridge:l2Bridge', + ]), + }), + expect.objectContaining({ + key: '138-10-cETHL2-cWETHL2', + backingMode: 'hybrid_cap', + runtimeMissingRequirements: expect.arrayContaining([ + 'supplyAccounting:outstanding', + 'supplyAccounting:treasuryCap', + ]), + }), + ]) + ); + }); + + it('reads runtime bridge routes from a local json file when configured', async () => { + const tempPath = path.join('/tmp', `bridge-routes-${Date.now()}.json`); + await fs.writeFile( + tempPath, + JSON.stringify({ + routes: { + weth9: { + 'Dynamic Ethereum Mainnet (1)': '0x9999999999999999999999999999999999999999', + }, + weth10: {}, + }, + chain138Bridges: { + weth9: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + weth10: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }, + }) + ); + process.env.BRIDGE_LIST_JSON_PATH = tempPath; + + try { + const res = await fetch(`${baseUrl}/api/v1/bridge/routes`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.source).toBe('runtime-file'); + expect(body.routes?.weth9).toMatchObject({ + 'Dynamic Ethereum Mainnet (1)': '0x9999999999999999999999999999999999999999', + }); + expect(body.chain138Bridges?.weth9).toBe('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + } finally { + await fs.unlink(tempPath).catch(() => undefined); + } + }); + + it('falls back to a runtime bridge routes file when BRIDGE_LIST_JSON_URL fails', async () => { + const tempPath = path.join('/tmp', `bridge-routes-fallback-${Date.now()}.json`); + await fs.writeFile( + tempPath, + JSON.stringify({ + routes: { + weth9: { + 'Fallback Ethereum Mainnet (1)': '0x1234512345123451234512345123451234512345', + }, + weth10: {}, + }, + chain138Bridges: { + weth9: '0xcccccccccccccccccccccccccccccccccccccccc', + weth10: '0xdddddddddddddddddddddddddddddddddddddddd', + }, + }) + ); + process.env.BRIDGE_LIST_JSON_URL = 'http://127.0.0.1:1/bridge-routes.json'; + process.env.BRIDGE_LIST_JSON_PATH = tempPath; + + try { + const res = await fetch(`${baseUrl}/api/v1/bridge/routes`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.source).toBe('runtime-file'); + expect(body.routes?.weth9).toMatchObject({ + 'Fallback Ethereum Mainnet (1)': '0x1234512345123451234512345123451234512345', + }); + expect(body.chain138Bridges?.weth9).toBe('0xcccccccccccccccccccccccccccccccccccccccc'); + } finally { + await fs.unlink(tempPath).catch(() => undefined); + } + }); +}); diff --git a/services/token-aggregation/src/api/routes/bridge.ts b/services/token-aggregation/src/api/routes/bridge.ts index 52f9447..bfb6762 100644 --- a/services/token-aggregation/src/api/routes/bridge.ts +++ b/services/token-aggregation/src/api/routes/bridge.ts @@ -1,25 +1,229 @@ /** * Bridge API: cross-chain bridge status and metrics. - * GET /api/v1/bridge/status, /api/v1/bridge/metrics — stubbed or delegated to cross-chain report. + * GET /api/v1/bridge/routes — CCIP WETH9/WETH10 + Trustless (Snap / dApps). + * GET /api/v1/bridge/status, /api/v1/bridge/metrics, /api/v1/bridge/preflight — GRU Transport readiness + cross-chain guidance. */ import { Router, Request, Response } from 'express'; +import fs from 'fs'; +import path from 'path'; +import { fetchRemoteJson } from '../utils/fetch-remote-json'; +import { buildDefaultBridgeRoutes } from '../utils/default-bridge-routes'; +import { getActivePublicPools, getActiveTransportPairs, getGruTransportMetadata } from '../../config/gru-transport'; +import { logger } from '../../utils/logger'; const router: Router = Router(); +function buildGruTransportStatus() { + const metadata = getGruTransportMetadata(); + const transportPairs = getActiveTransportPairs(); + const publicPools = getActivePublicPools(); + + if (!metadata) return null; + + return { + system: metadata.system, + terminology: metadata.terminology, + summary: metadata.counts, + gasAssetFamilies: metadata.gasAssetFamilies ?? [], + gasRedeemGroups: metadata.gasRedeemGroups ?? [], + gasProtocolExposure: metadata.gasProtocolExposure ?? [], + pairs: transportPairs.map((pair) => ({ + key: pair.key, + canonicalChainId: pair.canonicalChainId, + destinationChainId: pair.destinationChainId, + canonicalSymbol: pair.canonicalSymbol, + mirroredSymbol: pair.mirroredSymbol, + assetClass: pair.assetClass ?? null, + familyKey: pair.familyKey ?? null, + laneGroup: pair.laneGroup ?? null, + backingMode: pair.backingMode ?? null, + redeemPolicy: pair.redeemPolicy ?? null, + wrappedNativeQuoteSymbol: pair.wrappedNativeQuoteSymbol ?? null, + stableQuoteSymbol: pair.stableQuoteSymbol ?? null, + referenceVenue: pair.referenceVenue ?? null, + eligible: pair.eligible === true, + runtimeReady: pair.runtimeReady === true, + runtimeBridgeReady: pair.runtimeBridgeReady === true, + runtimeReserveVerifierReady: pair.runtimeReserveVerifierReady === true, + runtimeMaxOutstandingReady: pair.runtimeMaxOutstandingReady === true, + runtimeSupplyAccountingReady: pair.runtimeSupplyAccountingReady ?? null, + supplyInvariantSatisfied: pair.supplyInvariantSatisfied ?? null, + runtimeOutstandingValue: pair.runtimeOutstandingValue ?? null, + runtimeEscrowedValue: pair.runtimeEscrowedValue ?? null, + runtimeTreasuryBackedValue: pair.runtimeTreasuryBackedValue ?? null, + runtimeTreasuryCapValue: pair.runtimeTreasuryCapValue ?? null, + bridgeAvailable: pair.bridgeAvailable ?? null, + protocolExposure: pair.protocolExposure ?? null, + eligibilityBlockers: Array.isArray(pair.eligibilityBlockers) ? pair.eligibilityBlockers : [], + runtimeMissingRequirements: Array.isArray(pair.runtimeMissingRequirements) ? pair.runtimeMissingRequirements : [], + activePublicPoolKeys: Array.isArray(pair.publicPoolKeys) ? pair.publicPoolKeys : [], + })), + publicPools, + }; +} + +function uniquePaths(paths: Array): string[] { + const seen = new Set(); + const out: string[] = []; + + for (const candidate of paths) { + if (typeof candidate !== 'string') continue; + const trimmed = candidate.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + } + + return out; +} + +function resolveBridgeRoutesPath(): string | null { + const candidates = uniquePaths([ + process.env.BRIDGE_LIST_JSON_PATH, + process.env.BRIDGE_ROUTES_JSON_PATH, + path.resolve(process.cwd(), 'config/bridge-routes-chain138-default.json'), + path.resolve(process.cwd(), '../config/bridge-routes-chain138-default.json'), + path.resolve(process.cwd(), '../../config/bridge-routes-chain138-default.json'), + path.resolve(__dirname, '../../../../../config/bridge-routes-chain138-default.json'), + ]); + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +function loadRuntimeBridgeRoutes(): { payload: Record; lastModified?: string } | null { + const filePath = resolveBridgeRoutesPath(); + if (!filePath) return null; + + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const stat = fs.statSync(filePath); + return { + payload: JSON.parse(raw) as Record, + lastModified: stat.mtime.toISOString(), + }; + } catch { + return null; + } +} + +/** + * GET /api/v1/bridge/routes + * Optional BRIDGE_LIST_JSON_URL — remote JSON replaces entire payload (5m cache). + */ +router.get('/routes', async (_req: Request, res: Response) => { + const gruTransportMetadata = getGruTransportMetadata(); + res.set('Cache-Control', 'public, max-age=0, must-revalidate'); + const url = process.env.BRIDGE_LIST_JSON_URL?.trim(); + if (url) { + try { + const data = await fetchRemoteJson(url); + const basePayload = data && typeof data === 'object' ? data : { data }; + res.json({ + source: 'remote-url', + ...basePayload, + gruTransport: gruTransportMetadata + ? { + system: gruTransportMetadata.system, + summary: gruTransportMetadata.counts, + activeTransportPairs: getActiveTransportPairs(), + activePublicPools: getActivePublicPools(), + } + : undefined, + }); + return; + } catch (e) { + logger.error('BRIDGE_LIST_JSON_URL fetch failed, trying runtime file/built-in routes:', e); + } + } + + const runtimePayload = loadRuntimeBridgeRoutes(); + if (runtimePayload) { + res.json({ + source: 'runtime-file', + lastModified: runtimePayload.lastModified, + ...runtimePayload.payload, + gruTransport: gruTransportMetadata + ? { + system: gruTransportMetadata.system, + summary: gruTransportMetadata.counts, + activeTransportPairs: getActiveTransportPairs(), + activePublicPools: getActivePublicPools(), + } + : undefined, + }); + return; + } + + res.json({ + source: 'built-in', + ...buildDefaultBridgeRoutes(), + }); +}); + router.get('/status', (_req: Request, res: Response) => { + const gruTransport = buildGruTransportStatus(); res.json({ ok: true, bridges: [], - message: 'Bridge status: use /api/v1/report/cross-chain for volume/lanes.', + gruTransport, + message: 'Bridge status includes GRU Transport runtime readiness. Use /api/v1/bridge/preflight for missing refs and /api/v1/report/cross-chain for volume/lanes.', }); }); router.get('/metrics', (_req: Request, res: Response) => { + const gruTransport = buildGruTransportStatus(); res.json({ ok: true, lanes: [], - message: 'Bridge metrics: use /api/v1/report/cross-chain for aggregated data.', + gruTransport: gruTransport + ? { + system: gruTransport.system, + summary: gruTransport.summary, + } + : null, + message: 'Bridge metrics include GRU Transport summary counts. Use /api/v1/report/cross-chain for aggregated data.', + }); +}); + +router.get('/preflight', (_req: Request, res: Response) => { + const gruTransport = buildGruTransportStatus(); + if (!gruTransport) { + return res.status(503).json({ + ok: false, + error: 'GRU transport config not available', + }); + } + + const blockedPairs = gruTransport.pairs.filter( + (pair) => pair.eligible !== true || pair.runtimeReady !== true + ); + const readyPairs = gruTransport.pairs.filter( + (pair) => pair.eligible === true && pair.runtimeReady === true + ); + + return res.json({ + ok: blockedPairs.length === 0, + generatedAt: new Date().toISOString(), + gruTransport: { + system: gruTransport.system, + summary: gruTransport.summary, + blockedPairs, + readyPairs: readyPairs.map((pair) => ({ + key: pair.key, + canonicalSymbol: pair.canonicalSymbol, + mirroredSymbol: pair.mirroredSymbol, + destinationChainId: pair.destinationChainId, + assetClass: pair.assetClass ?? null, + familyKey: pair.familyKey ?? null, + backingMode: pair.backingMode ?? null, + redeemPolicy: pair.redeemPolicy ?? null, + supplyInvariantSatisfied: pair.supplyInvariantSatisfied ?? null, + })), + }, }); }); diff --git a/services/token-aggregation/src/api/routes/config.test.ts b/services/token-aggregation/src/api/routes/config.test.ts new file mode 100644 index 0000000..8d6b311 --- /dev/null +++ b/services/token-aggregation/src/api/routes/config.test.ts @@ -0,0 +1,142 @@ +import { createServer } from 'http'; +import express from 'express'; +import fs from 'fs/promises'; +import path from 'path'; +import configRoutes from './config'; + +jest.mock('../middleware/cache'); + +function createApp() { + const app = express(); + app.use('/api/v1', configRoutes); + return app; +} + +async function startServer(app: express.Application): Promise<{ server: ReturnType; baseUrl: string }> { + const server = createServer(app); + await new Promise((resolve) => server.listen(0, () => resolve())); + const port = (server.address() as { port: number }).port; + return { server, baseUrl: `http://127.0.0.1:${port}` }; +} + +describe('Config API runtime networks loader', () => { + let server: ReturnType; + let baseUrl: string; + const originalNetworksPath = process.env.NETWORKS_JSON_PATH; + const originalNetworksUrl = process.env.NETWORKS_JSON_URL; + + beforeAll(async () => { + const started = await startServer(createApp()); + server = started.server; + baseUrl = started.baseUrl; + }); + + afterEach(() => { + if (originalNetworksPath === undefined) { + delete process.env.NETWORKS_JSON_PATH; + } else { + process.env.NETWORKS_JSON_PATH = originalNetworksPath; + } + if (originalNetworksUrl === undefined) { + delete process.env.NETWORKS_JSON_URL; + } else { + process.env.NETWORKS_JSON_URL = originalNetworksUrl; + } + }); + + afterAll((done) => { + server.close(done); + }); + + it('serves networks from a runtime file when configured', async () => { + const tempPath = path.join('/tmp', `token-aggregation-networks-${Date.now()}.json`); + await fs.writeFile( + tempPath, + JSON.stringify({ + version: { major: 9, minor: 9, patch: 9 }, + chains: [ + { + chainId: '0x8a', + chainIdDecimal: 138, + chainName: 'Dynamic Chain 138', + rpcUrls: ['https://dynamic-rpc.example'], + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + blockExplorerUrls: ['https://dynamic-explorer.example'], + oracles: [{ name: 'ETH/USD', address: '0x1111111111111111111111111111111111111111' }], + }, + ], + }) + ); + process.env.NETWORKS_JSON_PATH = tempPath; + + try { + const res = await fetch(`${baseUrl}/api/v1/networks`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.source).toBe('runtime-file'); + expect(body.version).toBe('9.9.9'); + expect(body.networks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + chainName: 'Dynamic Chain 138', + }), + ]) + ); + + const cfgRes = await fetch(`${baseUrl}/api/v1/config?chainId=138`); + expect(cfgRes.status).toBe(200); + const cfgBody = (await cfgRes.json()) as Record; + expect(cfgBody.source).toBe('runtime-file'); + expect(cfgBody.oracles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'ETH/USD', + }), + ]) + ); + } finally { + await fs.unlink(tempPath).catch(() => undefined); + } + }); + + it('uses the remote networks source for config when NETWORKS_JSON_URL is set', async () => { + const remotePayload = { + version: '7.7.7', + networks: [ + { + chainId: '0x8a', + chainIdDecimal: 138, + chainName: 'Remote Chain 138', + rpcUrls: ['https://remote-rpc.example'], + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + blockExplorerUrls: ['https://remote-explorer.example'], + oracles: [{ name: 'BTC/USD', address: '0x2222222222222222222222222222222222222222' }], + }, + ], + }; + const remoteServer = createServer((_req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(remotePayload)); + }); + await new Promise((resolve) => remoteServer.listen(0, () => resolve())); + const remotePort = (remoteServer.address() as { port: number }).port; + process.env.NETWORKS_JSON_URL = `http://127.0.0.1:${remotePort}/networks.json`; + + try { + const res = await fetch(`${baseUrl}/api/v1/config?chainId=138`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.source).toBe('remote-url'); + expect(body.version).toBe('7.7.7'); + expect(body.oracles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'BTC/USD', + }), + ]) + ); + } finally { + await new Promise((resolve, reject) => remoteServer.close((err) => (err ? reject(err) : resolve()))); + } + }); +}); diff --git a/services/token-aggregation/src/api/routes/config.ts b/services/token-aggregation/src/api/routes/config.ts index 9b4e013..8ad088a 100644 --- a/services/token-aggregation/src/api/routes/config.ts +++ b/services/token-aggregation/src/api/routes/config.ts @@ -1,4 +1,6 @@ import { Router, Request, Response } from 'express'; +import fs from 'fs'; +import path from 'path'; import { getNetworks, getConfigByChain, API_VERSION } from '../../config/networks'; import { cacheMiddleware } from '../middleware/cache'; import { fetchRemoteJson } from '../utils/fetch-remote-json'; @@ -6,6 +8,122 @@ import { logger } from '../../utils/logger'; const router: Router = Router(); +type RuntimeNetworksPayload = { + version?: string | { major?: number; minor?: number; patch?: number }; + networks?: unknown[]; + chains?: unknown[]; +}; + +type NetworksPayload = { + source: 'remote-url' | 'runtime-file' | 'built-in'; + version: string; + networks: unknown[]; + lastModified?: string; +}; + +function uniquePaths(paths: Array): string[] { + const seen = new Set(); + const out: string[] = []; + + for (const candidate of paths) { + if (typeof candidate !== 'string') continue; + const trimmed = candidate.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + } + + return out; +} + +function resolveRuntimeNetworksPath(): string | null { + const candidates = uniquePaths([ + process.env.NETWORKS_JSON_PATH, + process.env.CONFIG_NETWORKS_JSON_PATH, + path.resolve(process.cwd(), 'explorer-monorepo/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json'), + path.resolve(process.cwd(), '../explorer-monorepo/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json'), + path.resolve(process.cwd(), '../../explorer-monorepo/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json'), + path.resolve(process.cwd(), 'explorer-monorepo/backend/config/metamask/DUAL_CHAIN_NETWORKS.json'), + path.resolve(process.cwd(), '../explorer-monorepo/backend/config/metamask/DUAL_CHAIN_NETWORKS.json'), + path.resolve(process.cwd(), '../../explorer-monorepo/backend/config/metamask/DUAL_CHAIN_NETWORKS.json'), + path.resolve(__dirname, '../../../../../../explorer-monorepo/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json'), + ]); + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +function normalizeVersion(value: RuntimeNetworksPayload['version']): string { + if (typeof value === 'string' && value.trim() !== '') return value.trim(); + if (value && typeof value === 'object') { + const major = 'major' in value ? Number(value.major ?? 0) : 0; + const minor = 'minor' in value ? Number(value.minor ?? 0) : 0; + const patch = 'patch' in value ? Number(value.patch ?? 0) : 0; + return `${major}.${minor}.${patch}`; + } + return API_VERSION; +} + +function extractNetworks(payload: RuntimeNetworksPayload | { version?: string; networks?: unknown[] }): unknown[] { + if (Array.isArray(payload.networks)) return payload.networks; + if ('chains' in payload && Array.isArray(payload.chains)) return payload.chains; + return []; +} + +async function loadRemoteNetworksPayload(): Promise { + const networksJsonUrl = process.env.NETWORKS_JSON_URL?.trim(); + if (!networksJsonUrl) return null; + + try { + const data = (await fetchRemoteJson(networksJsonUrl)) as RuntimeNetworksPayload; + return { + source: 'remote-url', + version: normalizeVersion(data.version), + networks: extractNetworks(data), + }; + } catch (error) { + logger.error('NETWORKS_JSON_URL fetch failed, trying runtime file/built-in networks:', error); + return null; + } +} + +function loadRuntimeNetworksPayload(): NetworksPayload | null { + const filePath = resolveRuntimeNetworksPath(); + if (!filePath) return null; + + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const stat = fs.statSync(filePath); + const parsed = JSON.parse(raw) as RuntimeNetworksPayload; + + return { + source: 'runtime-file', + version: normalizeVersion(parsed.version), + networks: extractNetworks(parsed), + lastModified: stat.mtime.toISOString(), + }; + } catch (error) { + logger.error('NETWORKS_JSON_PATH read failed, using built-in networks:', error); + return null; + } +} + +async function resolveNetworksPayload(): Promise { + const remotePayload = await loadRemoteNetworksPayload(); + if (remotePayload) return remotePayload; + + const runtimePayload = loadRuntimeNetworksPayload(); + if (runtimePayload) return runtimePayload; + + return { + source: 'built-in', + version: API_VERSION, + networks: getNetworks(), + }; +} + /** * GET /api/v1/networks * Full EIP-3085 chain params for wallet_addEthereumChain (Chain 138, 1, 651940). @@ -13,23 +131,9 @@ const router: Router = Router(); */ router.get('/networks', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => { try { - const networksJsonUrl = process.env.NETWORKS_JSON_URL?.trim(); - if (networksJsonUrl) { - try { - const data = (await fetchRemoteJson(networksJsonUrl)) as { version?: string; networks?: unknown[] }; - return res.json({ - version: data.version ?? API_VERSION, - networks: data.networks ?? [], - }); - } catch (err) { - logger.error('NETWORKS_JSON_URL fetch failed, using built-in networks:', err); - } - } - const networks = getNetworks(); - res.json({ - version: API_VERSION, - networks, - }); + res.set('Cache-Control', 'public, max-age=0, must-revalidate'); + const payload = await resolveNetworksPayload(); + res.json(payload); } catch (error) { res.status(500).json({ error: 'Internal server error' }); } @@ -42,21 +146,37 @@ router.get('/networks', cacheMiddleware(5 * 60 * 1000), async (req: Request, res */ router.get('/config', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => { try { + res.set('Cache-Control', 'public, max-age=0, must-revalidate'); const chainIdParam = req.query.chainId as string | undefined; - const networks = getNetworks(); + const payload = await resolveNetworksPayload(); + const networks = payload.networks as Array<{ chainIdDecimal?: number; oracles?: unknown[] }>; if (chainIdParam) { const chainId = parseInt(chainIdParam, 10); - const config = getConfigByChain(chainId); + const matchingNetwork = networks.find((network) => Number(network.chainIdDecimal) === chainId); + const config = matchingNetwork + ? { + oracles: (matchingNetwork.oracles as unknown[]) ?? [], + } + : getConfigByChain(chainId); if (!config) { return res.status(404).json({ error: 'Chain not found', chainId }); } - return res.json({ version: API_VERSION, chainId, ...config }); + return res.json({ + source: payload.source, + version: payload.version, + chainId, + ...config, + }); } const chains = networks.map((n) => ({ - chainId: n.chainIdDecimal, - oracles: n.oracles, + chainId: Number(n.chainIdDecimal), + oracles: Array.isArray(n.oracles) ? n.oracles : [], })); - res.json({ version: API_VERSION, chains }); + res.json({ + source: payload.source, + version: payload.version, + chains, + }); } catch (error) { res.status(500).json({ error: 'Internal server error' }); } diff --git a/services/token-aggregation/src/api/routes/heatmap.ts b/services/token-aggregation/src/api/routes/heatmap.ts index 7101e89..caa7d8e 100644 --- a/services/token-aggregation/src/api/routes/heatmap.ts +++ b/services/token-aggregation/src/api/routes/heatmap.ts @@ -9,6 +9,7 @@ import { DEFAULT_HEATMAP_ASSETS, } from '../../config/heatmap-chains'; import { cacheMiddleware } from '../middleware/cache'; +import { filterPoolsForExposure, isPublicPoolRoutable } from '../../config/gru-transport'; const router = Router(); const poolRepo = new PoolRepository(); @@ -32,7 +33,7 @@ router.get('/heatmap', cacheMiddleware(60 * 1000), async (req: Request, res: Res const matrix: number[][] = []; for (const chainId of chainIds) { const row: number[] = []; - const pools = await poolRepo.getPoolsByChain(chainId, 500); + const pools = filterPoolsForExposure(chainId, await poolRepo.getPoolsByChain(chainId, 500)); const symbolToTvl: Record = {}; for (const sym of assets) symbolToTvl[sym] = 0; for (const pool of pools) { @@ -72,7 +73,7 @@ router.get('/pools', cacheMiddleware(60 * 1000), async (req: Request, res: Respo if (!chainId || isNaN(chainId)) { return res.status(400).json({ error: 'chainId is required' }); } - const pools = await poolRepo.getPoolsByChain(chainId, 500); + const pools = filterPoolsForExposure(chainId, await poolRepo.getPoolsByChain(chainId, 500)); const list = await Promise.all( pools.map(async (p) => { const { token0, token1 } = await resolvePoolTokenDisplays(tokenRepo, chainId, p.token0Address, p.token1Address); @@ -89,7 +90,7 @@ router.get('/pools', cacheMiddleware(60 * 1000), async (req: Request, res: Respo reserve1: p.reserve1, }, isDeployed: true, - isRoutable: true, + isRoutable: isPublicPoolRoutable(chainId, p.poolAddress), }; }) ); diff --git a/services/token-aggregation/src/api/routes/partner-payloads.test.ts b/services/token-aggregation/src/api/routes/partner-payloads.test.ts new file mode 100644 index 0000000..fd00599 --- /dev/null +++ b/services/token-aggregation/src/api/routes/partner-payloads.test.ts @@ -0,0 +1,112 @@ +import { createServer } from 'http'; +import express from 'express'; + +let partnerPayloadRoutes: any; +const mockPlan = jest.fn(); + +jest.mock('../../services/best-execution-planner', () => ({ + __esModule: true, + BestExecutionPlanner: jest.fn().mockImplementation(() => ({ + plan: mockPlan, + })), +})); + +jest.mock('../../config/aggregator-route-matrix', () => ({ + __esModule: true, + loadAggregatorRouteMatrix: jest.fn(() => ({ + version: '2.0.0', + updated: '2026-04-02T00:00:00.000Z', + homeChainId: 138, + liveSwapRoutes: [], + liveBridgeRoutes: [], + blockedOrPlannedRoutes: [], + })), + filterLiveAggregatorRoutes: jest.fn(() => []), +})); + +jest.mock('../middleware/cache'); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use('/api/v1', partnerPayloadRoutes); + return app; +} + +async function startServer(app: express.Application): Promise<{ server: ReturnType; baseUrl: string }> { + const server = createServer(app); + await new Promise((resolve) => server.listen(0, () => resolve())); + const port = (server.address() as { port: number }).port; + return { server, baseUrl: `http://127.0.0.1:${port}` }; +} + +describe('Partner payload API', () => { + let server: ReturnType; + let baseUrl: string; + + beforeAll(async () => { + partnerPayloadRoutes = require('./partner-payloads').default; + const started = await startServer(createApp()); + server = started.server; + baseUrl = started.baseUrl; + }); + + beforeEach(() => { + mockPlan.mockReset(); + }); + + afterAll((done) => { + server.close(done); + }); + + it('prefers planner-v2 routes when concrete route inputs are provided', async () => { + mockPlan.mockResolvedValue({ + planId: 'planner-route-1', + generatedAt: new Date().toISOString(), + decision: 'direct-pool', + sourceChainId: 138, + destinationChainId: 138, + tokenIn: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + tokenOut: '0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1', + estimatedAmountOut: '1800000000', + minAmountOut: '1782000000', + estimatedGasUsd: 0.22, + confidenceScore: 0.91, + riskFlags: [], + selectedRouteReason: 'Selected deepest eligible dodo pool.', + rejectedAlternatives: [], + staleness: { maxFreshnessSeconds: 15, hasStaleLeg: false }, + alternatives: [], + legs: [ + { + kind: 'swap', + provider: 'dodo', + sourceChainId: 138, + destinationChainId: 138, + tokenInAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + tokenOutAddress: '0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1', + tokenInSymbol: 'WETH', + tokenOutSymbol: 'USDT', + estimatedAmountIn: '1000000000000000000', + estimatedAmountOut: '1800000000', + minAmountOut: '1782000000', + target: '0x86ada6ef91a3b450f89f2b751e93b1b7a3218895', + poolAddress: '0x1111111111111111111111111111111111111111', + providerData: { poolAddress: '0x1111111111111111111111111111111111111111' }, + providerDataHex: '0x', + }, + ], + }); + + const res = await fetch( + `${baseUrl}/api/v1/routes/partner-payloads?partner=0x&amount=1000000000000000000&fromChainId=138&tokenIn=0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2&tokenOut=0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1&includeUnsupported=true` + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.routeSource).toBe('planner-v2-preferred'); + expect(body.count).toBe(1); + expect(body.payloads[0].routeId).toBe('planner-route-1'); + expect(mockPlan).toHaveBeenCalled(); + }); +}); diff --git a/services/token-aggregation/src/api/routes/partner-payloads.ts b/services/token-aggregation/src/api/routes/partner-payloads.ts index d06f958..85cda7f 100644 --- a/services/token-aggregation/src/api/routes/partner-payloads.ts +++ b/services/token-aggregation/src/api/routes/partner-payloads.ts @@ -1,6 +1,8 @@ import { Router, Request, Response } from 'express'; import { cacheMiddleware } from '../middleware/cache'; import { + AggregatorRouteLeg, + LiveAggregatorRoute, filterLiveAggregatorRoutes, loadAggregatorRouteMatrix, } from '../../config/aggregator-route-matrix'; @@ -10,8 +12,11 @@ import { } from '../../services/partner-payload-adapters'; import { dispatchPartnerPayload } from '../../services/partner-payload-dispatcher'; import { buildInternalExecutionPlan } from '../../services/internal-execution-plan'; +import { BestExecutionPlanner } from '../../services/best-execution-planner'; +import { routeFromPlannerLegs } from '../../services/aggregator-route-matrix-generator'; const router: Router = Router(); +const planner = new BestExecutionPlanner(); interface PartnerPayloadRequestBody { partner?: string; @@ -55,9 +60,117 @@ function buildPayloads(args: { slippagePercent?: string; slippageBps?: string; includeUnsupported?: boolean; + routeId?: string; +}) { + return buildPayloadsAsync(args); +} + +function dedupeRoutes(routes: LiveAggregatorRoute[]): LiveAggregatorRoute[] { + const byId = new Map(); + for (const route of routes) { + byId.set(route.routeId, route); + } + return Array.from(byId.values()); +} + +function plannerLegsToAggregatorLegs(routeLegs: Array<{ + kind: string; + provider: string; + target?: string; + poolAddress?: string; + bridgeAddress?: string; + tokenInAddress: string; + tokenOutAddress: string; +}>): AggregatorRouteLeg[] { + return routeLegs.map((leg) => ({ + kind: leg.kind, + protocol: leg.provider, + executor: leg.provider, + executorAddress: leg.target, + poolAddress: leg.poolAddress || leg.bridgeAddress, + tokenInAddress: leg.tokenInAddress, + tokenOutAddress: leg.tokenOutAddress, + })); +} + +async function resolvePlannerDerivedRoutes(args: { + amount: string; + fromChainId?: number; + toChainId?: number; + routeType?: string; + tokenIn?: string; + tokenOut?: string; + recipient?: string; + slippageBps?: string; +}): Promise { + if (!args.fromChainId || !args.tokenIn || !args.tokenOut) { + return []; + } + + const wantsBridge = Boolean(args.toChainId && args.toChainId !== args.fromChainId); + if (args.routeType === 'swap' && wantsBridge) { + return []; + } + + const response = await planner.plan({ + sourceChainId: args.fromChainId, + destinationChainId: wantsBridge ? args.toChainId : args.fromChainId, + tokenIn: args.tokenIn, + tokenOut: args.tokenOut, + amountIn: args.amount, + recipient: args.recipient, + constraints: { + allowBridge: wantsBridge || args.routeType === 'bridge' || args.routeType === 'swap-bridge-swap', + maxSlippageBps: args.slippageBps ? Number(args.slippageBps) : undefined, + }, + }); + + if (response.decision === 'unresolved' || response.legs.length === 0) { + return []; + } + + const aggregatorLegs = plannerLegsToAggregatorLegs(response.legs); + const bridgeLeg = response.legs.find((leg) => leg.kind === 'bridge'); + const routeType = bridgeLeg ? 'bridge' : 'swap'; + + return [ + routeFromPlannerLegs({ + routeId: response.planId, + fromChainId: response.sourceChainId, + toChainId: response.destinationChainId, + tokenInAddress: response.legs[0]?.tokenInAddress || args.tokenIn, + tokenOutAddress: response.legs[response.legs.length - 1]?.tokenOutAddress || args.tokenOut, + assetAddress: bridgeLeg?.tokenInAddress, + assetSymbol: bridgeLeg?.tokenInSymbol, + routeType, + bridgeType: bridgeLeg?.bridgeType, + bridgeAddress: bridgeLeg?.bridgeAddress, + label: response.selectedRouteReason, + legs: aggregatorLegs, + notes: response.riskFlags, + }), + ]; +} + +async function buildPayloadsAsync(args: { + partner: PartnerName; + amount: string; + fromChainId?: number; + toChainId?: number; + routeType?: string; + tokenIn?: string; + tokenOut?: string; + takerAddress?: string; + fromAddress?: string; + toAddress?: string; + recipient?: string; + slippagePercent?: string; + slippageBps?: string; + includeUnsupported?: boolean; + routeId?: string; }) { const matrix = loadAggregatorRouteMatrix(); - if (!matrix) { + if (!matrix && !args.fromChainId) { return { error: { status: 503, @@ -68,15 +181,21 @@ function buildPayloads(args: { }; } - const liveRoutes = filterLiveAggregatorRoutes( - [...matrix.liveSwapRoutes, ...matrix.liveBridgeRoutes], - { - fromChainId: args.fromChainId, - toChainId: args.toChainId, - routeType: args.routeType, - tokenIn: args.tokenIn, - tokenOut: args.tokenOut, - } + const plannerRoutes = await resolvePlannerDerivedRoutes(args); + const matrixRoutes = matrix + ? filterLiveAggregatorRoutes( + [...matrix.liveSwapRoutes, ...matrix.liveBridgeRoutes], + { + fromChainId: args.fromChainId, + toChainId: args.toChainId, + routeType: args.routeType, + tokenIn: args.tokenIn, + tokenOut: args.tokenOut, + } + ) + : []; + const liveRoutes = dedupeRoutes([...plannerRoutes, ...matrixRoutes]).filter((route) => + args.routeId ? route.routeId === args.routeId : true ); const payloads = liveRoutes.map((route) => @@ -99,6 +218,7 @@ function buildPayloads(args: { format: 'partner-payload-templates-v1', partner: args.partner, amount: args.amount, + routeSource: plannerRoutes.length > 0 ? 'planner-v2-preferred' : 'matrix-fallback', count: filteredPayloads.length, supportedCount: payloads.filter((payload) => payload.supported).length, payloads: filteredPayloads, @@ -111,7 +231,7 @@ function buildPayloads(args: { * Returns partner-specific request payload templates generated from live ingestion routes. * By default returns only supported payloads; pass includeUnsupported=true to inspect all templates. */ -router.get('/routes/partner-payloads', cacheMiddleware(30 * 1000), (req: Request, res: Response) => { +router.get('/routes/partner-payloads', cacheMiddleware(30 * 1000), async (req: Request, res: Response) => { const partner = normalizePartner(req.query.partner ? String(req.query.partner) : undefined); if (!partner) { return res.status(400).json({ @@ -128,7 +248,7 @@ router.get('/routes/partner-payloads', cacheMiddleware(30 * 1000), (req: Request }); } - const response = buildPayloads({ + const response = await buildPayloads({ partner, amount, fromChainId: req.query.fromChainId ? parseInt(String(req.query.fromChainId), 10) : undefined, @@ -143,6 +263,7 @@ router.get('/routes/partner-payloads', cacheMiddleware(30 * 1000), (req: Request slippagePercent: req.query.slippagePercent ? String(req.query.slippagePercent) : undefined, slippageBps: req.query.slippageBps ? String(req.query.slippageBps) : undefined, includeUnsupported: String(req.query.includeUnsupported ?? 'false').toLowerCase() === 'true', + routeId: req.query.routeId ? String(req.query.routeId) : undefined, }); if (response.error) { @@ -156,7 +277,7 @@ router.get('/routes/partner-payloads', cacheMiddleware(30 * 1000), (req: Request * POST /api/v1/routes/partner-payloads/resolve * Accepts JSON body and returns only supported partner payloads by default. */ -router.post('/routes/partner-payloads/resolve', cacheMiddleware(30 * 1000), (req: Request, res: Response) => { +router.post('/routes/partner-payloads/resolve', cacheMiddleware(30 * 1000), async (req: Request, res: Response) => { const body = (req.body ?? {}) as PartnerPayloadRequestBody; const partner = normalizePartner(body.partner); @@ -178,7 +299,7 @@ router.post('/routes/partner-payloads/resolve', cacheMiddleware(30 * 1000), (req }); } - const response = buildPayloads({ + const response = await buildPayloads({ partner, amount: String(body.amount), fromChainId: typeof body.fromChainId === 'number' ? body.fromChainId : undefined, @@ -193,6 +314,7 @@ router.post('/routes/partner-payloads/resolve', cacheMiddleware(30 * 1000), (req slippagePercent: body.slippagePercent, slippageBps: body.slippageBps, includeUnsupported: body.includeUnsupported === true, + routeId: body.routeId, }); if (response.error) { @@ -222,7 +344,7 @@ router.post('/routes/partner-payloads/dispatch', async (req: Request, res: Respo }); } - const response = buildPayloads({ + const response = await buildPayloads({ partner, amount: String(body.amount), fromChainId: typeof body.fromChainId === 'number' ? body.fromChainId : undefined, @@ -237,6 +359,7 @@ router.post('/routes/partner-payloads/dispatch', async (req: Request, res: Respo slippagePercent: body.slippagePercent, slippageBps: body.slippageBps, includeUnsupported: true, + routeId: body.routeId, }); if (response.error) { diff --git a/services/token-aggregation/src/api/routes/planner-v2.test.ts b/services/token-aggregation/src/api/routes/planner-v2.test.ts new file mode 100644 index 0000000..16b3e1f --- /dev/null +++ b/services/token-aggregation/src/api/routes/planner-v2.test.ts @@ -0,0 +1,154 @@ +import { createServer } from 'http'; +import express from 'express'; +let plannerV2Routes: any; +const mockPlan = jest.fn(); +const mockCapabilities = jest.fn(); +const mockBuild = jest.fn(); + +jest.mock('../../services/best-execution-planner', () => ({ + __esModule: true, + BestExecutionPlanner: jest.fn().mockImplementation(() => ({ + plan: mockPlan, + getCapabilities: mockCapabilities, + })), +})); + +jest.mock('../../services/internal-execution-plan-v2', () => ({ + __esModule: true, + InternalExecutionPlanV2Builder: jest.fn().mockImplementation(() => ({ + build: mockBuild, + })), +})); + +jest.mock('../middleware/cache'); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use('/api/v2', plannerV2Routes); + return app; +} + +async function startServer(app: express.Application): Promise<{ server: ReturnType; baseUrl: string }> { + const server = createServer(app); + await new Promise((resolve) => server.listen(0, () => resolve())); + const port = (server.address() as { port: number }).port; + return { server, baseUrl: `http://127.0.0.1:${port}` }; +} + +describe('Planner V2 API', () => { + let server: ReturnType; + let baseUrl: string; + + beforeAll(async () => { + plannerV2Routes = require('./planner-v2').default; + const started = await startServer(createApp()); + server = started.server; + baseUrl = started.baseUrl; + }); + + beforeEach(() => { + mockPlan.mockReset(); + mockCapabilities.mockReset(); + mockBuild.mockReset(); + }); + + afterAll((done) => { + server.close(done); + }); + + it('returns provider capabilities', async () => { + mockCapabilities.mockReturnValue([{ provider: 'dodo', live: true }]); + + const res = await fetch(`${baseUrl}/api/v2/providers/capabilities?chainId=138`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.providers).toEqual([{ provider: 'dodo', live: true }]); + }); + + it('returns a planner response for one-chain route planning', async () => { + mockPlan.mockResolvedValue({ + planId: 'test-plan', + decision: 'direct-pool', + estimatedAmountOut: '1000', + minAmountOut: '990', + estimatedGasUsd: 0.22, + legs: [], + alternatives: [], + confidenceScore: 0.9, + riskFlags: [], + selectedRouteReason: 'selected', + rejectedAlternatives: [], + staleness: { maxFreshnessSeconds: 12, hasStaleLeg: false }, + generatedAt: new Date().toISOString(), + sourceChainId: 138, + destinationChainId: 138, + tokenIn: '0x1', + tokenOut: '0x2', + }); + + const res = await fetch(`${baseUrl}/api/v2/routes/plan`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + sourceChainId: 138, + tokenIn: '0x1', + tokenOut: '0x2', + amountIn: '1000', + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.planId).toBe('test-plan'); + expect(mockPlan).toHaveBeenCalled(); + }); + + it('returns an internal execution plan when the planner can encode calldata', async () => { + mockBuild.mockResolvedValue({ + generatedAt: new Date().toISOString(), + plannerResponse: { + planId: 'test-plan', + }, + execution: { + kind: 'route', + contractAddress: '0xrouter', + functionName: 'executeRoute', + signature: 'executeRoute((...))', + args: [], + encodedCalldata: '0xdeadbeef', + }, + }); + + const res = await fetch(`${baseUrl}/api/v2/routes/internal-execution-plan`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + sourceChainId: 138, + tokenIn: '0x1', + tokenOut: '0x2', + amountIn: '1000', + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.execution.encodedCalldata).toBe('0xdeadbeef'); + }); + + it('returns a JSON 500 instead of crashing when internal plan generation rejects', async () => { + mockBuild.mockRejectedValue(new Error('planner exploded')); + + const res = await fetch(`${baseUrl}/api/v2/routes/internal-execution-plan`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + sourceChainId: 138, + tokenIn: '0x1', + tokenOut: '0x2', + amountIn: '1000', + }), + }); + + expect(res.status).toBe(500); + await expect(res.json()).resolves.toEqual({ error: 'Internal server error' }); + }); +}); diff --git a/services/token-aggregation/src/api/routes/planner-v2.ts b/services/token-aggregation/src/api/routes/planner-v2.ts new file mode 100644 index 0000000..5de0eda --- /dev/null +++ b/services/token-aggregation/src/api/routes/planner-v2.ts @@ -0,0 +1,141 @@ +import { Router, Request, Response } from 'express'; +import { cacheMiddleware } from '../middleware/cache'; +import { BestExecutionPlanner } from '../../services/best-execution-planner'; +import { InternalExecutionPlanV2Builder } from '../../services/internal-execution-plan-v2'; +import { PlannerRequest } from '../../services/planner-v2-types'; +import { logger } from '../../utils/logger'; + +const router = Router(); +const planner = new BestExecutionPlanner(); +const executionPlanBuilder = new InternalExecutionPlanV2Builder(planner); + +function parsePlannerRequest(body: Record): PlannerRequest { + const constraintsBody = body.constraints && typeof body.constraints === 'object' + ? (body.constraints as Record) + : null; + + return { + sourceChainId: Number(body.sourceChainId), + destinationChainId: body.destinationChainId !== undefined ? Number(body.destinationChainId) : undefined, + tokenIn: String(body.tokenIn || ''), + tokenOut: String(body.tokenOut || ''), + amountIn: String(body.amountIn || ''), + recipient: body.recipient ? String(body.recipient) : undefined, + constraints: constraintsBody + ? { + maxSlippageBps: typeof constraintsBody.maxSlippageBps === 'number' + ? Number(constraintsBody.maxSlippageBps) + : undefined, + allowedProviders: Array.isArray(constraintsBody.allowedProviders) + ? (constraintsBody.allowedProviders as string[]) + .map((value) => String(value)) as NonNullable['allowedProviders'] + : undefined, + maxLegs: typeof constraintsBody.maxLegs === 'number' + ? Number(constraintsBody.maxLegs) + : undefined, + allowedIntermediates: Array.isArray(constraintsBody.allowedIntermediates) + ? (constraintsBody.allowedIntermediates as string[]).map((value) => String(value)) + : undefined, + complianceProfile: typeof constraintsBody.complianceProfile === 'string' + ? String(constraintsBody.complianceProfile) as NonNullable['complianceProfile'] + : undefined, + allowBridge: typeof constraintsBody.allowBridge === 'boolean' + ? Boolean(constraintsBody.allowBridge) + : undefined, + preferredBridges: Array.isArray(constraintsBody.preferredBridges) + ? (constraintsBody.preferredBridges as string[]).map((value) => String(value)) + : undefined, + allowCommodityIntermediates: typeof constraintsBody.allowCommodityIntermediates === 'boolean' + ? Boolean(constraintsBody.allowCommodityIntermediates) + : undefined, + } + : undefined, + }; +} + +function validatePlannerRequest(request: PlannerRequest): string | null { + if (!request.sourceChainId || Number.isNaN(request.sourceChainId)) return 'sourceChainId is required'; + if (!request.tokenIn) return 'tokenIn is required'; + if (!request.tokenOut) return 'tokenOut is required'; + if (!request.amountIn) return 'amountIn is required'; + try { + BigInt(request.amountIn); + } catch { + return 'amountIn must be an integer string'; + } + return null; +} + +function handlePlannerFailure(res: Response, action: string, error: unknown) { + logger.error(`Planner v2 ${action} failed`, error); + return res.status(500).json({ error: 'Internal server error' }); +} + +router.get('/providers/capabilities', cacheMiddleware(15 * 1000), async (req: Request, res: Response) => { + try { + const chainId = Number(req.query.chainId || '138'); + return res.json({ + generatedAt: new Date().toISOString(), + chainId, + providers: planner.getCapabilities(chainId), + }); + } catch (error) { + return handlePlannerFailure(res, 'provider capability lookup', error); + } +}); + +router.post('/routes/plan', async (req: Request, res: Response) => { + try { + const request = parsePlannerRequest((req.body || {}) as Record); + const error = validatePlannerRequest(request); + if (error) { + return res.status(400).json({ error }); + } + + const response = await planner.plan({ + ...request, + destinationChainId: request.destinationChainId || request.sourceChainId, + }); + return res.json(response); + } catch (error) { + return handlePlannerFailure(res, 'route planning', error); + } +}); + +router.post('/intents/plan', async (req: Request, res: Response) => { + try { + const request = parsePlannerRequest((req.body || {}) as Record); + const error = validatePlannerRequest(request); + if (error) { + return res.status(400).json({ error }); + } + if (!request.destinationChainId || request.destinationChainId === request.sourceChainId) { + return res.status(400).json({ error: 'destinationChainId must be different from sourceChainId for intent planning' }); + } + + const response = await planner.plan(request); + return res.json(response); + } catch (error) { + return handlePlannerFailure(res, 'intent planning', error); + } +}); + +router.post('/routes/internal-execution-plan', async (req: Request, res: Response) => { + try { + const request = parsePlannerRequest((req.body || {}) as Record); + const error = validatePlannerRequest(request); + if (error) { + return res.status(400).json({ error }); + } + + const result = await executionPlanBuilder.build(request); + if (result.error) { + return res.status(400).json(result); + } + return res.json(result); + } catch (error) { + return handlePlannerFailure(res, 'internal execution plan build', error); + } +}); + +export default router; diff --git a/services/token-aggregation/src/api/routes/quote.test.ts b/services/token-aggregation/src/api/routes/quote.test.ts new file mode 100644 index 0000000..928b9fa --- /dev/null +++ b/services/token-aggregation/src/api/routes/quote.test.ts @@ -0,0 +1,142 @@ +import { createServer } from 'http'; +import express from 'express'; +import quoteRoutes from './quote'; +import { getCanonicalTokenBySymbol } from '../../config/canonical-tokens'; + +var mockGetPoolsByToken: jest.Mock; +var mockGetLiveDodoPools: jest.Mock; + +jest.mock('../../database/repositories/pool-repo', () => { + mockGetPoolsByToken = jest.fn(); + return { + __esModule: true, + PoolRepository: jest.fn().mockImplementation(() => ({ + getPoolsByToken: mockGetPoolsByToken, + })), + }; +}); +jest.mock('../../services/live-dodo-fallback', () => { + mockGetLiveDodoPools = jest.fn(); + return { + __esModule: true, + getLiveDodoPools: mockGetLiveDodoPools, + }; +}); +/** Real GRU loader filters pools by on-file routable addresses; synthetic test pools would be dropped. */ +jest.mock('../../config/gru-transport', () => ({ + filterPoolsForRouting: (_chainId: number, pools: T[]) => pools, +})); +jest.mock('../middleware/cache'); + +function createApp() { + const app = express(); + app.use('/api/v1', quoteRoutes); + return app; +} + +async function startServer(app: express.Application): Promise<{ server: ReturnType; baseUrl: string }> { + const server = createServer(app); + await new Promise((resolve) => server.listen(0, () => resolve())); + const port = (server.address() as { port: number }).port; + return { server, baseUrl: `http://127.0.0.1:${port}` }; +} + +describe('Quote API', () => { + let server: ReturnType; + let baseUrl: string; + + beforeAll(async () => { + const started = await startServer(createApp()); + server = started.server; + baseUrl = started.baseUrl; + }); + + beforeEach(() => { + mockGetPoolsByToken.mockReset(); + mockGetLiveDodoPools.mockReset(); + }); + + afterAll((done) => { + server.close(done); + }); + + it('quotes staged cUSDT_V2 against active cUSDT liquidity on Chain 138', async () => { + const cusdtV2 = getCanonicalTokenBySymbol(138, 'cUSDT_V2'); + const cusdtV1 = getCanonicalTokenBySymbol(138, 'cUSDT'); + if (!cusdtV2?.addresses[138] || !cusdtV1?.addresses[138]) { + throw new Error('cUSDT_V2 / cUSDT Chain 138 addresses required for this test'); + } + const tokenInV2 = String(cusdtV2.addresses[138]); + const cusdtV1Lookup = String(cusdtV1.addresses[138]).toLowerCase(); + + mockGetPoolsByToken.mockImplementation(async (_chainId: number, tokenAddress: string) => { + if (tokenAddress.toLowerCase() === cusdtV1Lookup) { + return [ + { + chainId: 138, + poolAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + token0Address: cusdtV1Lookup, + token1Address: '0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1', + dexType: 'dodo', + reserve0: '1000000', + reserve1: '1000000', + reserve0Usd: 1000000, + reserve1Usd: 1000000, + totalLiquidityUsd: 2000000, + volume24h: 10000, + lastUpdated: new Date(), + }, + ]; + } + return []; + }); + + const res = await fetch( + `${baseUrl}/api/v1/quote?chainId=138&tokenIn=${encodeURIComponent(tokenInV2)}&tokenOut=0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1&amountIn=1000` + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.amountOut).toBeTruthy(); + expect(['constant-product', 'pmm-onchain']).toContain(body.quoteEngine); + expect(body.poolAddress).toBe('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + expect(body.canonicalLiquidity).toMatchObject({ + requestedTokenInSymbol: 'cUSDT_V2', + lookupTokenInSymbol: 'cUSDT', + lookupTokenInAddress: cusdtV1Lookup, + usedFallback: true, + }); + expect(mockGetPoolsByToken).toHaveBeenCalledWith(138, cusdtV1Lookup); + }); + + it('falls back to live DODO pools when indexed liquidity is empty', async () => { + mockGetPoolsByToken.mockResolvedValue([]); + mockGetLiveDodoPools.mockResolvedValue([ + { + chainId: 138, + poolAddress: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + token0Address: '0x93e66202a11b1772e55407b32b44e5cd8eda7f22', + token1Address: '0xf22258f57794cc8e06237084b353ab30fffa640b', + dexType: 'dodo', + factoryAddress: '0x86ada6ef91a3b450f89f2b751e93b1b7a3218895', + reserve0: '1000000', + reserve1: '1000000', + reserve0Usd: 1000000, + reserve1Usd: 1000000, + totalLiquidityUsd: 2000000, + volume24h: 0, + lastUpdated: new Date(), + }, + ]); + + const res = await fetch( + `${baseUrl}/api/v1/quote?chainId=138&tokenIn=0x93E66202A11B1772E55407B32B44e5Cd8eda7f22&tokenOut=0xf22258f57794CC8E06237084b353Ab30fFfa640b&amountIn=1000` + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.amountOut).toBeTruthy(); + expect(['constant-product', 'pmm-onchain']).toContain(body.quoteEngine); + expect(body.poolAddress).toBe('0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); + expect(body.executorAddress).toBe('0x86ada6ef91a3b450f89f2b751e93b1b7a3218895'); + expect(mockGetLiveDodoPools).toHaveBeenCalledWith(138); + }); +}); diff --git a/services/token-aggregation/src/api/routes/quote.ts b/services/token-aggregation/src/api/routes/quote.ts index 3ec0fcc..63dba25 100644 --- a/services/token-aggregation/src/api/routes/quote.ts +++ b/services/token-aggregation/src/api/routes/quote.ts @@ -2,12 +2,24 @@ import { Router, Request, Response } from 'express'; import { PoolRepository } from '../../database/repositories/pool-repo'; import { cacheMiddleware } from '../middleware/cache'; import { logger } from '../../utils/logger'; +import { filterPoolsForRouting } from '../../config/gru-transport'; +import { resolveCanonicalQuoteAddress } from '../../config/canonical-tokens'; +import { getLiveDodoPools } from '../../services/live-dodo-fallback'; +import { + pmmQuoteAmountOutFromChain, + resolvePmmQuoteRpcUrl, + resolvePmmQuoteTrader, +} from '../../services/pmm-onchain-quote'; const router: Router = Router(); const poolRepo = new PoolRepository(); /** * Uniswap V2-style constant-product quote: amountOut = (reserveOut * amountIn * 997) / (reserveIn * 1000 + amountIn * 997) + * + * Note: DODO PMM pools do not follow this curve; `amountOut` for dexType `dodo` can diverge from + * on-chain `querySellBase` / `querySellQuote`. Clients that set swap minOut from this endpoint alone + * may revert (wallets show failed gas estimation). Prefer on-chain PMM quotes for execution bounds. */ function quoteAmountOut( amountIn: bigint, @@ -42,6 +54,7 @@ router.get( return res.status(400).json({ error: 'Missing required query: chainId, tokenIn, tokenOut, amountIn', amountOut: null, + quoteEngine: null, }); } @@ -52,18 +65,37 @@ router.get( return res.status(400).json({ error: 'Invalid amountIn (must be integer string)', amountOut: null, + quoteEngine: null, }); } if (tokenIn === tokenOut) { - return res.json({ amountOut: amountInRaw, poolAddress: null }); + return res.json({ + amountOut: amountInRaw, + poolAddress: null, + quoteEngine: 'identity', + }); } - const pools = await poolRepo.getPoolsByToken(chainId, tokenIn); - const pairPools = pools.filter( + const tokenInResolution = resolveCanonicalQuoteAddress(chainId, tokenIn); + const tokenOutResolution = resolveCanonicalQuoteAddress(chainId, tokenOut); + const indexedPoolsRaw = await poolRepo.getPoolsByToken( + chainId, + tokenInResolution.lookupAddress + ); + const indexedPools = filterPoolsForRouting(chainId, indexedPoolsRaw ?? []); + const livePools = + indexedPools.length > 0 + ? [] + : filterPoolsForRouting(chainId, (await getLiveDodoPools(chainId)) ?? []).filter( + (pool) => + pool.token0Address.toLowerCase() === tokenInResolution.lookupAddress || + pool.token1Address.toLowerCase() === tokenInResolution.lookupAddress + ); + const pairPools = [...indexedPools, ...livePools].filter( (p) => - p.token0Address.toLowerCase() === tokenOut || - p.token1Address.toLowerCase() === tokenOut + p.token0Address.toLowerCase() === tokenOutResolution.lookupAddress || + p.token1Address.toLowerCase() === tokenOutResolution.lookupAddress ); if (pairPools.length === 0) { @@ -71,6 +103,18 @@ router.get( amountOut: null, error: 'No pool found for this token pair', poolAddress: null, + quoteEngine: null, + canonicalLiquidity: { + requestedTokenInAddress: tokenInResolution.requestedAddress, + requestedTokenOutAddress: tokenOutResolution.requestedAddress, + requestedTokenInSymbol: tokenInResolution.requestedSymbol, + requestedTokenOutSymbol: tokenOutResolution.requestedSymbol, + lookupTokenInAddress: tokenInResolution.lookupAddress, + lookupTokenOutAddress: tokenOutResolution.lookupAddress, + lookupTokenInSymbol: tokenInResolution.lookupSymbol, + lookupTokenOutSymbol: tokenOutResolution.lookupSymbol, + usedFallback: tokenInResolution.usedFallback || tokenOutResolution.usedFallback, + }, }); } @@ -79,11 +123,11 @@ router.get( for (const pool of pairPools) { const reserveIn = - pool.token0Address.toLowerCase() === tokenIn + pool.token0Address.toLowerCase() === tokenInResolution.lookupAddress ? BigInt(pool.reserve0) : BigInt(pool.reserve1); const reserveOut = - pool.token0Address.toLowerCase() === tokenOut + pool.token0Address.toLowerCase() === tokenOutResolution.lookupAddress ? BigInt(pool.reserve0) : BigInt(pool.reserve1); const out = quoteAmountOut(amountIn, reserveIn, reserveOut); @@ -93,16 +137,46 @@ router.get( } } + let quoteEngine: 'constant-product' | 'pmm-onchain' = 'constant-product'; + const pmmRpc = resolvePmmQuoteRpcUrl(); + if (chainId === 138 && bestPool.dexType === 'dodo' && pmmRpc) { + const onChainOut = await pmmQuoteAmountOutFromChain({ + rpcUrl: pmmRpc, + poolAddress: bestPool.poolAddress, + tokenInLookup: tokenInResolution.lookupAddress, + amountIn, + traderForView: resolvePmmQuoteTrader(), + }); + if (onChainOut !== null && onChainOut > BigInt(0)) { + bestAmountOut = onChainOut; + quoteEngine = 'pmm-onchain'; + } + } + res.json({ amountOut: bestAmountOut.toString(), poolAddress: bestPool.poolAddress, dexType: bestPool.dexType, + executorAddress: bestPool.factoryAddress || null, + quoteEngine, + canonicalLiquidity: { + requestedTokenInAddress: tokenInResolution.requestedAddress, + requestedTokenOutAddress: tokenOutResolution.requestedAddress, + requestedTokenInSymbol: tokenInResolution.requestedSymbol, + requestedTokenOutSymbol: tokenOutResolution.requestedSymbol, + lookupTokenInAddress: tokenInResolution.lookupAddress, + lookupTokenOutAddress: tokenOutResolution.lookupAddress, + lookupTokenInSymbol: tokenInResolution.lookupSymbol, + lookupTokenOutSymbol: tokenOutResolution.lookupSymbol, + usedFallback: tokenInResolution.usedFallback || tokenOutResolution.usedFallback, + }, }); } catch (error) { logger.error('Quote error:', error); res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error', amountOut: null, + quoteEngine: null, }); } } diff --git a/services/token-aggregation/src/api/routes/report.test.ts b/services/token-aggregation/src/api/routes/report.test.ts index 14e7d8a..f519577 100644 --- a/services/token-aggregation/src/api/routes/report.test.ts +++ b/services/token-aggregation/src/api/routes/report.test.ts @@ -23,6 +23,17 @@ jest.mock('../../database/repositories/pool-repo', () => ({ getPoolsByChain: jest.fn().mockResolvedValue([]), })), })); +jest.mock('../../indexer/cross-chain-indexer', () => ({ + buildCrossChainReport: jest.fn().mockResolvedValue({ + generatedAt: '2026-03-30T00:00:00.000Z', + chainId: 138, + crossChainPools: [], + volumeByLane: [], + atomicSwapVolume24h: 0, + bridgeVolume24hTotal: 0, + events: [], + }), +})); jest.mock('../middleware/cache'); function createApp() { @@ -49,8 +60,16 @@ describe('Report API', () => { baseUrl = started.baseUrl; }); - afterAll((done) => { - server.close(done); + afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); }); describe('GET /api/v1/report/cmc', () => { @@ -85,4 +104,345 @@ describe('Report API', () => { expect(Array.isArray(body.tokens)).toBe(true); }); }); + + describe('GET /api/v1/report/all', () => { + it('includes GRU transport summary for operator visibility', async () => { + const res = await fetch(`${baseUrl}/api/v1/report/all?chainId=138`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.gruTransport?.system?.name).toBe('GRU Monetary Transport Layer'); + expect(body.gruTransport?.summary).toMatchObject({ + transportPairs: expect.any(Number), + runtimeReadyTransportPairs: expect.any(Number), + }); + expect(body.gruTransport?.gasAssetFamilies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + familyKey: 'eth_l2', + backingMode: 'hybrid_cap', + }), + ]) + ); + }); + }); + + describe('GET /api/v1/report/gas-registry', () => { + it('returns both chain summaries and runtime pairs for gas rollout consumers', async () => { + const res = await fetch(`${baseUrl}/api/v1/report/gas-registry`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.gasAssetFamilies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + familyKey: 'eth_mainnet', + }), + ]) + ); + expect(body.runtimePairs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: '138-1-cETH-cWETH', + familyKey: 'eth_mainnet', + destinationChainId: 1, + destinationChainName: 'Ethereum Mainnet', + wrappedNativeQuoteSymbol: 'WETH', + stableQuoteSymbol: 'USDC', + }), + ]) + ); + expect(body.chains).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + chainId: 1, + }), + ]) + ); + }); + }); + + describe('GET /api/v1/report/token-list', () => { + it('surfaces both V1 and V2 Chain 138 canonical GRU deployments explicitly', async () => { + const res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.tokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'cUSDT', + chainId: 138, + }), + expect.objectContaining({ + symbol: 'cUSDT_V2', + chainId: 138, + familySymbol: 'cUSDT', + deploymentVersion: 'v2', + preferredForX402: true, + }), + expect.objectContaining({ + symbol: 'cUSDC', + chainId: 138, + }), + expect.objectContaining({ + symbol: 'cUSDC_V2', + chainId: 138, + familySymbol: 'cUSDC', + deploymentVersion: 'v2', + preferredForX402: true, + }), + ]) + ); + }); + + it('surfaces the cUSDW hub asset on Chain 138 and cWUSDW on active edge chains', async () => { + const chain138Res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138`); + expect(chain138Res.status).toBe(200); + const chain138Body = (await chain138Res.json()) as Record; + expect(chain138Body.tokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'cUSDW', + chainId: 138, + }), + ]) + ); + + const bscRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=56`); + expect(bscRes.status).toBe(200); + const bscBody = (await bscRes.json()) as Record; + expect(bscBody.tokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'cWUSDW', + chainId: 56, + }), + ]) + ); + }); + + it('surfaces cAUSDT on Chain 138 when configured and cWAUSDT on active edge chains', async () => { + const chain138Res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138`); + expect(chain138Res.status).toBe(200); + const chain138Body = (await chain138Res.json()) as Record; + expect(chain138Body.tokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'cAUSDT', + chainId: 138, + }), + ]) + ); + + const bscRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=56`); + expect(bscRes.status).toBe(200); + const bscBody = (await bscRes.json()) as Record; + expect(bscBody.tokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'cWAUSDT', + chainId: 56, + }), + ]) + ); + + const polygonRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=137`); + expect(polygonRes.status).toBe(200); + const polygonBody = (await polygonRes.json()) as Record; + expect(polygonBody.tokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'cWAUSDT', + chainId: 137, + }), + ]) + ); + + const avalancheRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=43114`); + expect(avalancheRes.status).toBe(200); + const avalancheBody = (await avalancheRes.json()) as Record; + expect(avalancheBody.tokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'cWAUSDT', + chainId: 43114, + }), + ]) + ); + + const celoRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=42220`); + expect(celoRes.status).toBe(200); + const celoBody = (await celoRes.json()) as Record; + expect(celoBody.tokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'cWAUSDT', + chainId: 42220, + }), + ]) + ); + }); + + it('surfaces cBTC on Chain 138 and cWBTC on the staged public mesh with monetary-unit metadata', async () => { + const chain138Res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138`); + expect(chain138Res.status).toBe(200); + const chain138Body = (await chain138Res.json()) as Record; + expect(chain138Body.tokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'cBTC', + chainId: 138, + registryFamily: 'monetary_unit', + }), + ]) + ); + + const mainnetRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=1`); + expect(mainnetRes.status).toBe(200); + const mainnetBody = (await mainnetRes.json()) as Record; + expect(mainnetBody.tokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'cWBTC', + chainId: 1, + registryFamily: 'monetary_unit', + }), + ]) + ); + }); + + it('surfaces gas-native canonicals on Chain 138 and mirrored cW gas tokens on their public lanes', async () => { + const chain138Res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138`); + expect(chain138Res.status).toBe(200); + const chain138Body = (await chain138Res.json()) as Record; + expect(chain138Body.tokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'cETH', + chainId: 138, + registryFamily: 'gas_native', + }), + expect.objectContaining({ + symbol: 'cETHL2', + chainId: 138, + registryFamily: 'gas_native', + }), + ]) + ); + + const optimismRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=10`); + expect(optimismRes.status).toBe(200); + const optimismBody = (await optimismRes.json()) as Record; + expect(optimismBody.tokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'cWETHL2', + chainId: 10, + registryFamily: 'gas_native', + }), + ]) + ); + + const mainnetRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=1`); + expect(mainnetRes.status).toBe(200); + const mainnetBody = (await mainnetRes.json()) as Record; + expect(mainnetBody.tokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'cWETH', + chainId: 1, + registryFamily: 'gas_native', + }), + ]) + ); + }); + }); + + describe('GET /api/v1/report/cw-registry', () => { + it('reads the live cW registry from deployment-status json when available', async () => { + const previousPath = process.env.DEPLOYMENT_STATUS_JSON_PATH; + const tempPath = `/tmp/token-aggregation-cw-registry-${Date.now()}.json`; + + process.env.DEPLOYMENT_STATUS_JSON_PATH = tempPath; + await import('fs/promises').then((fs) => + fs.writeFile( + tempPath, + JSON.stringify( + { + version: 'test-1', + updated: '2026-04-04', + chains: { + '56': { + name: 'BSC', + cwTokens: { + cWAUSDT: '0xe1a51Bc037a79AB36767561B147eb41780124934', + }, + }, + }, + }, + null, + 2 + ) + ) + ); + + try { + const res = await fetch(`${baseUrl}/api/v1/report/cw-registry?chainId=56`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.source).toBe('deployment-status-file'); + expect(body.complete).toBe(true); + expect(body.version).toBe('test-1'); + expect(body.chains).toEqual([ + expect.objectContaining({ + chainId: 56, + name: 'BSC', + tokens: [ + expect.objectContaining({ + symbol: 'cWAUSDT', + }), + ], + }), + ]); + } finally { + await import('fs/promises').then((fs) => fs.unlink(tempPath).catch(() => undefined)); + if (previousPath === undefined) { + delete process.env.DEPLOYMENT_STATUS_JSON_PATH; + } else { + process.env.DEPLOYMENT_STATUS_JSON_PATH = previousPath; + } + } + }); + }); + + describe('GET /api/v1/report/gas-registry', () => { + it('reads the live gas rollout registry from deployment-status json when available', async () => { + const res = await fetch(`${baseUrl}/api/v1/report/gas-registry?chainId=10`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.source).toBe('deployment-status-file'); + expect(body.gasAssetFamilies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + familyKey: 'eth_l2', + backingMode: 'hybrid_cap', + }), + ]) + ); + expect(body.chains).toEqual([ + expect.objectContaining({ + chainId: 10, + families: [ + expect.objectContaining({ + familyKey: 'eth_l2', + mirroredSymbol: 'cWETHL2', + dodoPmm: expect.arrayContaining([ + expect.objectContaining({ + quote: 'WETH', + }), + ]), + }), + ], + }), + ]); + }); + }); }); diff --git a/services/token-aggregation/src/api/routes/report.ts b/services/token-aggregation/src/api/routes/report.ts index df457a9..9e6b238 100644 --- a/services/token-aggregation/src/api/routes/report.ts +++ b/services/token-aggregation/src/api/routes/report.ts @@ -11,6 +11,7 @@ import { CANONICAL_TOKENS, getCanonicalTokensByChain, getLogoUriForSpec, + getTokenRegistryFamily, } from '../../config/canonical-tokens'; import { resolvePoolTokenDisplays } from '../../services/token-display'; import { getSupportedChainIds } from '../../config/chains'; @@ -18,6 +19,13 @@ import { cacheMiddleware } from '../middleware/cache'; import { fetchRemoteJson } from '../utils/fetch-remote-json'; import { buildCrossChainReport } from '../../indexer/cross-chain-indexer'; import { logger } from '../../utils/logger'; +import { filterPoolsForExposure, getActiveTransportPairs, getGruTransportMetadata } from '../../config/gru-transport'; +import { + buildCwRegistryChains, + buildGasRegistryChains, + loadDeploymentStatusFile, + type CwRegistryChain, +} from '../../config/deployment-status'; const router: Router = Router(); const tokenRepo = new TokenRepository(); @@ -35,6 +43,12 @@ async function buildTokenReport(chainId: number) { type: string; decimals: number; currencyCode?: string; + registryFamily?: string; + familySymbol?: string; + deploymentVersion?: string; + deploymentStatus?: string; + preferredForX402?: boolean; + liquiditySourceSymbol?: string; market?: { priceUsd?: number; volume24h: number; @@ -64,9 +78,10 @@ async function buildTokenReport(chainId: number) { marketDataRepo.getMarketData(chainId, address), poolRepo.getPoolsByToken(chainId, address), ]); + const exposedPools = filterPoolsForExposure(chainId, pools); const resolvedPools = await Promise.all( - pools.map(async (p) => { + exposedPools.map(async (p) => { const { token0, token1 } = await resolvePoolTokenDisplays(tokenRepo, chainId, p.token0Address, p.token1Address); return { poolAddress: p.poolAddress, @@ -87,6 +102,12 @@ async function buildTokenReport(chainId: number) { type: spec.type, decimals: spec.decimals, currencyCode: spec.currencyCode, + registryFamily: getTokenRegistryFamily(spec), + familySymbol: spec.familySymbol, + deploymentVersion: spec.deploymentVersion, + deploymentStatus: spec.deploymentStatus, + preferredForX402: spec.preferredForX402, + liquiditySourceSymbol: spec.liquiditySourceSymbol, market: marketData ? { priceUsd: marketData.priceUsd, @@ -115,6 +136,87 @@ async function buildTokenReport(chainId: number) { return out; } +function describeToken(spec: { currencyCode?: string; registryFamily?: string }): string | undefined { + const family = String(spec.registryFamily || '').trim(); + const code = String(spec.currencyCode || '').trim().toUpperCase(); + if (!code) return undefined; + if (family === 'gas_native') { + return `Governance-approved gas-native ${code} compliant token`; + } + if (family === 'monetary_unit') { + return `GRU monetary-unit ${code} compliant token`; + } + if (family === 'commodity') { + return `Governance-approved commodity ${code} compliant token`; + } + return `ISO-4217 ${code} compliant token`; +} + +function buildGruTransportOverview() { + const gruTransportMetadata = getGruTransportMetadata(); + if (!gruTransportMetadata) return undefined; + + return { + system: gruTransportMetadata.system, + summary: gruTransportMetadata.counts, + gasAssetFamilies: gruTransportMetadata.gasAssetFamilies ?? [], + gasRedeemGroups: gruTransportMetadata.gasRedeemGroups ?? [], + gasProtocolExposure: gruTransportMetadata.gasProtocolExposure ?? [], + activeTransportPairs: getActiveTransportPairs().map((pair) => ({ + key: pair.key, + canonicalSymbol: pair.canonicalSymbol, + mirroredSymbol: pair.mirroredSymbol, + destinationChainId: pair.destinationChainId, + destinationChainName: pair.destinationChainName ?? null, + assetClass: pair.assetClass, + familyKey: pair.familyKey, + backingMode: pair.backingMode, + redeemPolicy: pair.redeemPolicy, + wrappedNativeQuoteSymbol: pair.wrappedNativeQuoteSymbol ?? null, + stableQuoteSymbol: pair.stableQuoteSymbol ?? null, + eligible: pair.eligible === true, + runtimeReady: pair.runtimeReady === true, + supplyInvariantSatisfied: pair.supplyInvariantSatisfied ?? null, + eligibilityBlockers: Array.isArray(pair.eligibilityBlockers) + ? pair.eligibilityBlockers + : [], + runtimeMissingRequirements: Array.isArray(pair.runtimeMissingRequirements) + ? pair.runtimeMissingRequirements + : [], + })), + }; +} + +function buildCanonicalCwFallback(chainIdFilter?: number | null): CwRegistryChain[] { + const grouped = new Map(); + + for (const spec of CANONICAL_TOKENS) { + if (spec.type !== 'w') continue; + for (const [chainIdText, address] of Object.entries(spec.addresses)) { + const chainId = Number(chainIdText); + if (!address || Number.isNaN(chainId)) continue; + if (chainIdFilter && chainId !== chainIdFilter) continue; + + const existing = grouped.get(chainId) ?? { + chainId, + chainIdText, + name: `Chain ${chainIdText}`, + tokens: [], + }; + + existing.tokens.push({ symbol: spec.symbol, address }); + grouped.set(chainId, existing); + } + } + + return Array.from(grouped.values()) + .map((row) => ({ + ...row, + tokens: row.tokens.sort((a, b) => a.symbol.localeCompare(b.symbol)), + })) + .sort((a, b) => a.chainId - b.chainId); +} + /** GET /report/cross-chain — cross-chain pools, bridge volume, atomic swaps (Chain 138, ALL Mainnet) */ router.get( '/cross-chain', @@ -158,10 +260,11 @@ router.get( for (const chainId of chainIds) { tokensByChain[chainId] = await buildTokenReport(chainId); - poolsByChain[chainId] = await poolRepo.getPoolsByChain(chainId); + poolsByChain[chainId] = filterPoolsForExposure(chainId, await poolRepo.getPoolsByChain(chainId)); } const crossChainReport = await buildCrossChainReport(138).catch(() => null); + const gruTransport = buildGruTransportOverview(); const totalLiquidityByChain: Record = {}; const totalVolume24hByChain: Record = {}; @@ -196,6 +299,7 @@ router.get( bridgeVolume24hTotal: crossChainReport.bridgeVolume24hTotal, } : undefined, + gruTransport, }); } catch (error) { logger.error('Error building report/all:', error); @@ -221,7 +325,7 @@ router.get( name: t.name, asset_platform_id: chainId === 138 ? 'defi-oracle-meta' : chainId === 651940 ? 'all-mainnet' : `chain-${chainId}`, decimals: t.decimals, - description: t.currencyCode ? `ISO-4217 ${t.currencyCode} compliant token` : undefined, + description: describeToken(t), market_data: t.market ? { current_price: { usd: t.market.priceUsd }, @@ -364,6 +468,11 @@ router.get( decimals: number; type: string; logoURI: string; + registryFamily?: string; + familySymbol?: string; + deploymentVersion?: string; + deploymentStatus?: string; + preferredForX402?: boolean; }> = []; for (const chainId of chainIds) { @@ -379,6 +488,11 @@ router.get( decimals: spec.decimals, type: spec.type, logoURI: getLogoUriForSpec(spec), + registryFamily: getTokenRegistryFamily(spec), + familySymbol: spec.familySymbol, + deploymentVersion: spec.deploymentVersion, + deploymentStatus: spec.deploymentStatus, + preferredForX402: spec.preferredForX402, }); } } @@ -398,20 +512,112 @@ router.get( } ); +/** GET /report/cw-registry — live cW* registry from deployment-status.json when available. */ +router.get('/cw-registry', async (req: Request, res: Response) => { + try { + const chainIdParam = req.query.chainId as string | undefined; + const chainIdFilter = chainIdParam ? parseInt(chainIdParam, 10) : null; + const fileBackedRegistry = loadDeploymentStatusFile(); + + let chains = fileBackedRegistry + ? buildCwRegistryChains(fileBackedRegistry.data) + : buildCanonicalCwFallback(chainIdFilter); + + if (chainIdFilter && !Number.isNaN(chainIdFilter)) { + chains = chains.filter((row) => row.chainId === chainIdFilter); + } + + res.set('Cache-Control', 'public, max-age=0, must-revalidate'); + res.json({ + generatedAt: new Date().toISOString(), + source: fileBackedRegistry ? 'deployment-status-file' : 'canonical-fallback', + complete: !!fileBackedRegistry, + version: fileBackedRegistry?.data.version, + updated: fileBackedRegistry?.data.updated, + lastModified: fileBackedRegistry?.lastModified, + chains, + }); + } catch (error) { + logger.error('Error building report/cw-registry:', error); + res.status(500).json({ error: 'Internal server error', chains: [] }); + } +}); + +/** GET /report/gas-registry — live gas-family rollout registry from deployment-status.json plus GRU transport metadata. */ +router.get('/gas-registry', async (req: Request, res: Response) => { + try { + const chainIdParam = req.query.chainId as string | undefined; + const chainIdFilter = chainIdParam ? parseInt(chainIdParam, 10) : null; + const fileBackedRegistry = loadDeploymentStatusFile(); + const gruTransport = buildGruTransportOverview(); + const runtimeGasPairs = getActiveTransportPairs() + .filter((pair) => pair.assetClass === 'gas_native') + .map((pair) => ({ + key: pair.key, + destinationChainId: pair.destinationChainId, + destinationChainName: pair.destinationChainName ?? null, + familyKey: pair.familyKey ?? null, + canonicalSymbol: pair.canonicalSymbol, + mirroredSymbol: pair.mirroredSymbol, + wrappedNativeQuoteSymbol: pair.wrappedNativeQuoteSymbol ?? null, + stableQuoteSymbol: pair.stableQuoteSymbol ?? null, + backingMode: pair.backingMode ?? null, + redeemPolicy: pair.redeemPolicy ?? null, + runtimeReady: pair.runtimeReady === true, + supplyInvariantSatisfied: pair.supplyInvariantSatisfied ?? null, + eligibilityBlockers: Array.isArray(pair.eligibilityBlockers) ? pair.eligibilityBlockers : [], + runtimeMissingRequirements: Array.isArray(pair.runtimeMissingRequirements) + ? pair.runtimeMissingRequirements + : [], + })); + + let chains = fileBackedRegistry ? buildGasRegistryChains(fileBackedRegistry.data) : []; + if (chainIdFilter && !Number.isNaN(chainIdFilter)) { + chains = chains.filter((row) => row.chainId === chainIdFilter); + } + + res.set('Cache-Control', 'public, max-age=0, must-revalidate'); + res.json({ + generatedAt: new Date().toISOString(), + source: fileBackedRegistry ? 'deployment-status-file' : 'transport-config-only', + complete: !!fileBackedRegistry, + version: fileBackedRegistry?.data.version, + updated: fileBackedRegistry?.data.updated, + lastModified: fileBackedRegistry?.lastModified, + gasAssetFamilies: gruTransport?.gasAssetFamilies ?? [], + gasRedeemGroups: gruTransport?.gasRedeemGroups ?? [], + gasProtocolExposure: gruTransport?.gasProtocolExposure ?? [], + runtimePairs: runtimeGasPairs, + chains, + }); + } catch (error) { + logger.error('Error building report/gas-registry:', error); + res.status(500).json({ error: 'Internal server error', chains: [] }); + } +}); + /** GET /report/canonical — raw canonical spec list (no DB merge) */ router.get( '/canonical', cacheMiddleware(10 * 60 * 1000), async (req: Request, res: Response) => { try { + const gruTransport = buildGruTransportOverview(); res.json({ generatedAt: new Date().toISOString(), + gruTransport, tokens: CANONICAL_TOKENS.map((t) => ({ symbol: t.symbol, name: t.name, type: t.type, decimals: t.decimals, currencyCode: t.currencyCode, + registryFamily: getTokenRegistryFamily(t), + familySymbol: t.familySymbol, + deploymentVersion: t.deploymentVersion, + deploymentStatus: t.deploymentStatus, + preferredForX402: t.preferredForX402, + liquiditySourceSymbol: t.liquiditySourceSymbol, addresses: t.addresses, })), }); diff --git a/services/token-aggregation/src/api/routes/token-mapping.test.ts b/services/token-aggregation/src/api/routes/token-mapping.test.ts new file mode 100644 index 0000000..17dc7ef --- /dev/null +++ b/services/token-aggregation/src/api/routes/token-mapping.test.ts @@ -0,0 +1,215 @@ +import { createServer } from 'http'; +import express from 'express'; +import tokenMappingRoutes from './token-mapping'; + +jest.mock('../middleware/cache'); + +function createApp() { + const app = express(); + app.use('/api/v1/token-mapping', tokenMappingRoutes); + return app; +} + +async function startServer(app: express.Application): Promise<{ server: ReturnType; baseUrl: string }> { + const server = createServer(app); + await new Promise((resolve) => server.listen(0, () => resolve())); + const port = (server.address() as { port: number }).port; + return { server, baseUrl: `http://127.0.0.1:${port}` }; +} + +describe('Token mapping API with GRU Transport overlay', () => { + let server: ReturnType; + let baseUrl: string; + const originalChain138Bridge = process.env.CHAIN138_L1_BRIDGE; + const originalBscBridge = process.env.CW_BRIDGE_BSC; + const originalReserveVerifier = process.env.CW_RESERVE_VERIFIER_CHAIN138; + const originalReserveVault = process.env.CW_STABLECOIN_RESERVE_VAULT; + const originalReserveSystem = process.env.CW_RESERVE_SYSTEM; + const originalMaxOutstanding = process.env.CW_MAX_OUTSTANDING_USDT_BSC; + const originalMainnetBridge = process.env.CW_BRIDGE_MAINNET; + const originalBtcMainnetOutstanding = process.env.CW_MAX_OUTSTANDING_BTC_MAINNET; + const originalOptimismBridge = process.env.CW_BRIDGE_OPTIMISM; + const originalGasHybridVerifier = process.env.CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138; + const originalGasEscrowVault = process.env.CW_GAS_ESCROW_VAULT_CHAIN138; + const originalGasTreasurySystem = process.env.CW_GAS_TREASURY_SYSTEM; + const originalEthL2Outstanding = process.env.CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM; + const originalEthL2Supply = process.env.CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM; + const originalEthL2Escrowed = process.env.CW_GAS_ESCROWED_ETH_L2_OPTIMISM; + const originalEthL2Treasury = process.env.CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM; + const originalEthL2Cap = process.env.CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM; + const originalCwL1Bridge = process.env.CW_L1_BRIDGE; + const originalCwL1BridgeChain138 = process.env.CW_L1_BRIDGE_CHAIN138; + + beforeAll(async () => { + const started = await startServer(createApp()); + server = started.server; + baseUrl = started.baseUrl; + }); + + afterEach(() => { + if (originalChain138Bridge === undefined) { + delete process.env.CHAIN138_L1_BRIDGE; + } else { + process.env.CHAIN138_L1_BRIDGE = originalChain138Bridge; + } + if (originalBscBridge === undefined) { + delete process.env.CW_BRIDGE_BSC; + } else { + process.env.CW_BRIDGE_BSC = originalBscBridge; + } + if (originalReserveVerifier === undefined) { + delete process.env.CW_RESERVE_VERIFIER_CHAIN138; + } else { + process.env.CW_RESERVE_VERIFIER_CHAIN138 = originalReserveVerifier; + } + if (originalReserveVault === undefined) { + delete process.env.CW_STABLECOIN_RESERVE_VAULT; + } else { + process.env.CW_STABLECOIN_RESERVE_VAULT = originalReserveVault; + } + if (originalReserveSystem === undefined) { + delete process.env.CW_RESERVE_SYSTEM; + } else { + process.env.CW_RESERVE_SYSTEM = originalReserveSystem; + } + if (originalMaxOutstanding === undefined) { + delete process.env.CW_MAX_OUTSTANDING_USDT_BSC; + } else { + process.env.CW_MAX_OUTSTANDING_USDT_BSC = originalMaxOutstanding; + } + if (originalMainnetBridge === undefined) { + delete process.env.CW_BRIDGE_MAINNET; + } else { + process.env.CW_BRIDGE_MAINNET = originalMainnetBridge; + } + if (originalBtcMainnetOutstanding === undefined) { + delete process.env.CW_MAX_OUTSTANDING_BTC_MAINNET; + } else { + process.env.CW_MAX_OUTSTANDING_BTC_MAINNET = originalBtcMainnetOutstanding; + } + for (const [key, value] of Object.entries({ + CW_BRIDGE_OPTIMISM: originalOptimismBridge, + CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138: originalGasHybridVerifier, + CW_GAS_ESCROW_VAULT_CHAIN138: originalGasEscrowVault, + CW_GAS_TREASURY_SYSTEM: originalGasTreasurySystem, + CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM: originalEthL2Outstanding, + CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM: originalEthL2Supply, + CW_GAS_ESCROWED_ETH_L2_OPTIMISM: originalEthL2Escrowed, + CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM: originalEthL2Treasury, + CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM: originalEthL2Cap, + })) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + if (originalCwL1Bridge === undefined) { + delete process.env.CW_L1_BRIDGE; + } else { + process.env.CW_L1_BRIDGE = originalCwL1Bridge; + } + if (originalCwL1BridgeChain138 === undefined) { + delete process.env.CW_L1_BRIDGE_CHAIN138; + } else { + process.env.CW_L1_BRIDGE_CHAIN138 = originalCwL1BridgeChain138; + } + }); + + afterAll((done) => { + server.close(done); + }); + + it('returns the active GRU transport overlay', async () => { + const res = await fetch(`${baseUrl}/api/v1/token-mapping/transport/active`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.system).toMatchObject({ + name: 'GRU Monetary Transport Layer', + shortName: 'GRU Transport', + }); + expect(Array.isArray(body.transportPairs)).toBe(true); + expect((body.transportPairs as unknown[]).length).toBeGreaterThan(0); + expect(body.counts).toMatchObject({ + transportPairs: expect.any(Number), + runtimeReadyTransportPairs: expect.any(Number), + }); + }); + + it('resolves active cUSDT transport from Chain 138 to BSC', async () => { + process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333'; + process.env.CW_BRIDGE_BSC = '0x4444444444444444444444444444444444444444'; + process.env.CW_RESERVE_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555'; + process.env.CW_STABLECOIN_RESERVE_VAULT = '0x6666666666666666666666666666666666666666'; + process.env.CW_RESERVE_SYSTEM = '0x7777777777777777777777777777777777777777'; + process.env.CW_MAX_OUTSTANDING_USDT_BSC = '1000000'; + + const source = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'; + const res = await fetch( + `${baseUrl}/api/v1/token-mapping/resolve?fromChain=138&toChain=56&address=${source}` + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.addressOnTarget).toBe('0x9a1D0dBEE997929ED02fD19E0E199704d20914dB'); + expect(body.activeTransportEligible).toBe(true); + expect(body.gruTransportRuntimeReady).toBe(true); + expect(body.gruTransportPairKey).toBe('138-56-cUSDT-cWUSDT'); + expect(body.gruTransportCanonicalToken).toMatchObject({ + symbol: 'cUSDT', + activeVersion: 'v1', + x402PreferredVersion: 'v2', + }); + }); + + it('resolves active cBTC transport from Chain 138 to Ethereum mainnet', async () => { + process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333'; + process.env.CW_BRIDGE_MAINNET = '0x4444444444444444444444444444444444444444'; + process.env.CW_RESERVE_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555'; + process.env.CW_STABLECOIN_RESERVE_VAULT = '0x6666666666666666666666666666666666666666'; + process.env.CW_RESERVE_SYSTEM = '0x7777777777777777777777777777777777777777'; + process.env.CW_MAX_OUTSTANDING_BTC_MAINNET = '2100000000000000'; + + const source = '0xcb7c000000000000000000000000000000000138'; + const res = await fetch( + `${baseUrl}/api/v1/token-mapping/resolve?fromChain=138&toChain=1&address=${source}` + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.addressOnTarget).toBe('0xcb7c000000000000000000000000000000000001'); + expect(body.activeTransportEligible).toBe(true); + expect(body.gruTransportRuntimeReady).toBe(true); + expect(body.gruTransportPairKey).toBe('138-1-cBTC-cWBTC'); + }); + + it('resolves gas-family transport metadata for the shared ETH L2 lane', async () => { + process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333'; + process.env.CW_BRIDGE_OPTIMISM = '0x4444444444444444444444444444444444444444'; + process.env.CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555'; + process.env.CW_GAS_ESCROW_VAULT_CHAIN138 = '0x6666666666666666666666666666666666666666'; + process.env.CW_GAS_TREASURY_SYSTEM = '0x7777777777777777777777777777777777777777'; + process.env.CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM = '125'; + process.env.CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM = '125'; + process.env.CW_GAS_ESCROWED_ETH_L2_OPTIMISM = '100'; + process.env.CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM = '25'; + process.env.CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM = '25'; + + /** Matches token-mapping-multichain.json 138→10 Compliant_ETH_L2_cW (not FALLBACK cETHL2 placeholder). */ + const source = '0x18a6b163d255cc0cb32b99697843b487d059907d'; + const res = await fetch( + `${baseUrl}/api/v1/token-mapping/resolve?fromChain=138&toChain=10&address=${source}` + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.addressOnTarget).toBe('0x95007ec50d0766162f77848edf7bdc4eba147fb4'); + expect(body.activeTransportEligible).toBe(true); + expect(body.gruTransportRuntimeReady).toBe(true); + expect(body.gruTransportPairKey).toBe('138-10-cETHL2-cWETHL2'); + expect(body.gruTransportAssetClass).toBe('gas_native'); + expect(body.gruTransportFamilyKey).toBe('eth_l2'); + expect(body.gruTransportBackingMode).toBe('hybrid_cap'); + expect(body.gruTransportRedeemPolicy).toBe('family_fungible_inventory_gated'); + }); +}); diff --git a/services/token-aggregation/src/api/routes/token-mapping.ts b/services/token-aggregation/src/api/routes/token-mapping.ts index 2152751..b76f0fc 100644 --- a/services/token-aggregation/src/api/routes/token-mapping.ts +++ b/services/token-aggregation/src/api/routes/token-mapping.ts @@ -5,29 +5,47 @@ */ import { Router, Request, Response } from 'express'; -import path from 'path'; -import { createRequire } from 'module'; import { cacheMiddleware } from '../middleware/cache'; +import { loadTokenMappingLoader } from '../../config/repo-config-loader'; const router: Router = Router(); -/** Repo root (proxmox): when run from token-aggregation cwd, 2 levels up to smom-dbis-138, 1 more to proxmox */ -const PROXMOX_ROOT = path.resolve(process.cwd(), '../../..'); -const LOADER_PATH = path.join(PROXMOX_ROOT, 'config', 'token-mapping-loader.cjs'); -const requireLoader = createRequire(path.join(PROXMOX_ROOT, 'package.json')); - function loadMultichainLoader(): { getTokenMappingForPair: (from: number, to: number) => { tokens: unknown[]; addressMapFromTo: Record; addressMapToFrom: Record } | null; getAllMultichainPairs: () => Array<{ fromChainId: number; toChainId: number; notes?: string }>; getMappedAddress: (from: number, to: number, addr: string) => string | undefined; + getGruTransportMetadata?: () => { + system: Record | null; + terminology: Record; + enabledCanonicalTokens: Array>; + enabledDestinationChains: Array>; + counts: Record; + } | null; + getActiveTransportPairs?: () => Array>; + getActiveTransportPair?: (from: number, to: number, criteria?: Record) => Record | null; + getActivePublicPools?: () => Array>; + getEnabledCanonicalToken?: (identifier: string) => Record | null; + isGasRedemptionPathAllowed?: (from: number, to: number, identifier: string) => boolean; } | null { - try { - const loader = requireLoader(LOADER_PATH); - if (loader?.getTokenMappingForPair && loader?.getAllMultichainPairs && loader?.getMappedAddress) { - return loader; - } - } catch { - // config not available when run outside monorepo + const loader = loadTokenMappingLoader<{ + getTokenMappingForPair: (from: number, to: number) => { tokens: unknown[]; addressMapFromTo: Record; addressMapToFrom: Record } | null; + getAllMultichainPairs: () => Array<{ fromChainId: number; toChainId: number; notes?: string }>; + getMappedAddress: (from: number, to: number, addr: string) => string | undefined; + getGruTransportMetadata?: () => { + system: Record | null; + terminology: Record; + enabledCanonicalTokens: Array>; + enabledDestinationChains: Array>; + counts: Record; + } | null; + getActiveTransportPairs?: () => Array>; + getActiveTransportPair?: (from: number, to: number, criteria?: Record) => Record | null; + getActivePublicPools?: () => Array>; + getEnabledCanonicalToken?: (identifier: string) => Record | null; + isGasRedemptionPathAllowed?: (from: number, to: number, identifier: string) => boolean; + }>(); + if (loader) { + return loader; } return null; } @@ -65,12 +83,31 @@ router.get( }); } + const activePairs = loader.getActiveTransportPairs + ? loader + .getActiveTransportPairs() + .filter((pair) => { + const canonicalChainId = Number(pair.canonicalChainId); + const destinationChainId = Number(pair.destinationChainId); + return ( + (canonicalChainId === fromChain && destinationChainId === toChain) || + (canonicalChainId === toChain && destinationChainId === fromChain) + ); + }) + : []; + return res.json({ fromChainId: fromChain, toChainId: toChain, tokens: result.tokens, addressMapFromTo: result.addressMapFromTo, addressMapToFrom: result.addressMapToFrom, + gruTransport: loader.getGruTransportMetadata + ? { + system: loader.getGruTransportMetadata()?.system ?? null, + activePairs, + } + : undefined, }); } ); @@ -90,7 +127,38 @@ router.get( }); } const pairs = loader.getAllMultichainPairs(); - return res.json({ pairs }); + return res.json({ + pairs, + gruTransport: loader.getGruTransportMetadata + ? { + system: loader.getGruTransportMetadata()?.system ?? null, + activePairs: loader.getActiveTransportPairs ? loader.getActiveTransportPairs() : [], + } + : undefined, + }); + } +); + +/** + * GET /api/v1/token-mapping/transport/active + * Returns the GRU Monetary Transport Layer overlay as seen by token-aggregation. + */ +router.get( + '/transport/active', + cacheMiddleware(5 * 60 * 1000), + (_req: Request, res: Response) => { + const loader = loadMultichainLoader(); + if (!loader || !loader.getGruTransportMetadata || !loader.getActiveTransportPairs) { + return res.status(503).json({ + error: 'GRU transport config not available (run from monorepo with config/gru-transport-active.json)', + }); + } + + return res.json({ + ...loader.getGruTransportMetadata(), + transportPairs: loader.getActiveTransportPairs(), + publicPools: loader.getActivePublicPools ? loader.getActivePublicPools() : [], + }); } ); @@ -120,11 +188,64 @@ router.get( } const mapped = loader.getMappedAddress(fromChain, toChain, address); + const activeTransportPair = loader.getActiveTransportPair + ? loader.getActiveTransportPair(fromChain, toChain, { + address, + targetTokenAddress: mapped ?? null, + }) + : null; + const canonicalTokenIdentifier = + (activeTransportPair && typeof activeTransportPair.canonicalSymbol === 'string' + ? activeTransportPair.canonicalSymbol + : null) ?? + address; + const canonicalToken = loader.getEnabledCanonicalToken + ? loader.getEnabledCanonicalToken(canonicalTokenIdentifier) + : null; return res.json({ fromChainId: fromChain, toChainId: toChain, addressOnSource: address, addressOnTarget: mapped ?? null, + activeTransportEligible: !!activeTransportPair && activeTransportPair.eligible === true, + gruTransportRuntimeReady: !!activeTransportPair && activeTransportPair.runtimeReady === true, + gruTransportPairKey: + activeTransportPair && typeof activeTransportPair.key === 'string' ? activeTransportPair.key : null, + gruTransportAssetClass: + activeTransportPair && typeof activeTransportPair.assetClass === 'string' + ? activeTransportPair.assetClass + : null, + gruTransportFamilyKey: + activeTransportPair && typeof activeTransportPair.familyKey === 'string' + ? activeTransportPair.familyKey + : null, + gruTransportBackingMode: + activeTransportPair && typeof activeTransportPair.backingMode === 'string' + ? activeTransportPair.backingMode + : null, + gruTransportRedeemPolicy: + activeTransportPair && typeof activeTransportPair.redeemPolicy === 'string' + ? activeTransportPair.redeemPolicy + : null, + gruTransportWrappedNativeQuoteSymbol: + activeTransportPair && typeof activeTransportPair.wrappedNativeQuoteSymbol === 'string' + ? activeTransportPair.wrappedNativeQuoteSymbol + : null, + gruTransportStableQuoteSymbol: + activeTransportPair && typeof activeTransportPair.stableQuoteSymbol === 'string' + ? activeTransportPair.stableQuoteSymbol + : null, + gruTransportReferenceVenue: + activeTransportPair && typeof activeTransportPair.referenceVenue === 'string' + ? activeTransportPair.referenceVenue + : null, + gruGasRedemptionPathAllowed: + activeTransportPair && + typeof activeTransportPair.familyKey === 'string' && + loader.isGasRedemptionPathAllowed + ? loader.isGasRedemptionPathAllowed(fromChain, toChain, activeTransportPair.familyKey) + : null, + gruTransportCanonicalToken: canonicalToken, }); } ); diff --git a/services/token-aggregation/src/api/routes/tokens.ts b/services/token-aggregation/src/api/routes/tokens.ts index c46f933..af74ebe 100644 --- a/services/token-aggregation/src/api/routes/tokens.ts +++ b/services/token-aggregation/src/api/routes/tokens.ts @@ -1,14 +1,21 @@ import { Router, Request, Response } from 'express'; -import { TokenRepository } from '../../database/repositories/token-repo'; +import { TokenRepository, Token } from '../../database/repositories/token-repo'; import { MarketDataRepository } from '../../database/repositories/market-data-repo'; -import { PoolRepository } from '../../database/repositories/pool-repo'; +import { PoolRepository, LiquidityPool } from '../../database/repositories/pool-repo'; import { OHLCVGenerator } from '../../indexer/ohlcv-generator'; import { CoinGeckoAdapter } from '../../adapters/coingecko-adapter'; import { CoinMarketCapAdapter } from '../../adapters/cmc-adapter'; import { DexScreenerAdapter } from '../../adapters/dexscreener-adapter'; import { cacheMiddleware } from '../middleware/cache'; -import { resolvePoolTokenDisplays } from '../../services/token-display'; +import { resolvePoolTokenDisplays, resolveTokenDisplay } from '../../services/token-display'; import { logger } from '../../utils/logger'; +import { filterPoolsForExposure, shouldExposePublicPool } from '../../config/gru-transport'; +import { + getCanonicalTokenByAddress, + getCanonicalTokensByChain, + resolveCanonicalQuoteAddress, +} from '../../config/canonical-tokens'; +import { getLiveDodoPools } from '../../services/live-dodo-fallback'; const router: Router = Router(); const tokenRepo = new TokenRepository(); @@ -19,6 +26,122 @@ const coingeckoAdapter = new CoinGeckoAdapter(); const cmcAdapter = new CoinMarketCapAdapter(); const dexscreenerAdapter = new DexScreenerAdapter(); +function tokenFromCanonical(chainId: number, address: string): Token | null { + const spec = getCanonicalTokenByAddress(chainId, address.toLowerCase()); + if (!spec) { + return null; + } + + return { + chainId, + address: address.toLowerCase(), + name: spec.name, + symbol: spec.symbol, + decimals: spec.decimals, + verified: true, + }; +} + +async function getPoolsByTokenWithFallback(chainId: number, address: string): Promise { + const normalized = address.toLowerCase(); + const resolution = resolveCanonicalQuoteAddress(chainId, normalized); + const dbPools = filterPoolsForExposure( + chainId, + await poolRepo.getPoolsByToken(chainId, resolution.lookupAddress) + ); + if (dbPools.length > 0) { + return dbPools; + } + + const livePools = filterPoolsForExposure(chainId, await getLiveDodoPools(chainId)); + return livePools.filter( + (pool) => + pool.token0Address === resolution.lookupAddress || + pool.token1Address === resolution.lookupAddress || + pool.token0Address === normalized || + pool.token1Address === normalized + ); +} + +async function getTokenWithFallback(chainId: number, address: string): Promise { + const normalized = address.toLowerCase(); + const token = await tokenRepo.getToken(chainId, normalized); + if (token) { + return token; + } + + const canonical = tokenFromCanonical(chainId, normalized); + if (canonical) { + return canonical; + } + + const resolution = resolveCanonicalQuoteAddress(chainId, normalized); + const livePools = await getLiveDodoPools(chainId); + const liveAddress = + livePools.find((pool) => pool.token0Address === resolution.lookupAddress || pool.token1Address === resolution.lookupAddress) + ? resolution.lookupAddress + : null; + if (!liveAddress) { + return null; + } + + const display = await resolveTokenDisplay(tokenRepo, chainId, liveAddress); + return { + chainId, + address: normalized, + name: display.name, + symbol: display.symbol, + decimals: display.decimals, + verified: display.source !== 'fallback', + }; +} + +async function getTokensWithFallback( + chainId: number, + limit: number, + offset: number +): Promise<{ tokens: Token[]; source: 'db' | 'live-dodo' | 'canonical' }> { + const dbTokens = await tokenRepo.getTokens(chainId, limit, offset); + if (dbTokens.length > 0) { + return { tokens: dbTokens, source: 'db' }; + } + + const livePools = await getLiveDodoPools(chainId); + if (livePools.length > 0) { + const tokenAddresses = [...new Set(livePools.flatMap((pool) => [pool.token0Address, pool.token1Address]))]; + const liveTokens = await Promise.all( + tokenAddresses.map(async (address) => { + const display = await resolveTokenDisplay(tokenRepo, chainId, address); + return { + chainId, + address: display.address, + name: display.name, + symbol: display.symbol, + decimals: display.decimals, + verified: display.source !== 'fallback', + } as Token; + }) + ); + const sorted = liveTokens.sort((a, b) => + `${a.symbol || ''}${a.address}`.localeCompare(`${b.symbol || ''}${b.address}`) + ); + return { tokens: sorted.slice(offset, offset + limit), source: 'live-dodo' }; + } + + const canonicalTokens = getCanonicalTokensByChain(chainId) + .map((spec) => ({ + chainId, + address: String(spec.addresses[chainId]).toLowerCase(), + name: spec.name, + symbol: spec.symbol, + decimals: spec.decimals, + verified: true, + }) as Token) + .sort((a, b) => `${a.symbol || ''}${a.address}`.localeCompare(`${b.symbol || ''}${b.address}`)); + + return { tokens: canonicalTokens.slice(offset, offset + limit), source: 'canonical' }; +} + router.get('/chains', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => { try { res.json({ @@ -51,7 +174,7 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp return res.status(400).json({ error: 'chainId is required' }); } - const tokens = await tokenRepo.getTokens(chainId, limit, offset); + const { tokens, source } = await getTokensWithFallback(chainId, limit, offset); const tokensWithMarketData = await Promise.all( tokens.map(async (token) => { const marketData = await marketDataRepo.getMarketData(chainId, token.address); @@ -60,7 +183,7 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp market: marketData || undefined, }; if (includeDodoPool) { - const pools = await poolRepo.getPoolsByToken(chainId, token.address); + const pools = await getPoolsByTokenWithFallback(chainId, token.address); const dodoPool = pools.find((p) => (p.dexType || '').toLowerCase() === 'dodo'); out.hasDodoPool = !!dodoPool; out.pmmPool = dodoPool?.poolAddress || undefined; @@ -76,6 +199,7 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp offset, count: tokensWithMarketData.length, }, + source, }); } catch (error) { logger.error('Error fetching tokens:', error); @@ -92,17 +216,19 @@ router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request, return res.status(400).json({ error: 'chainId is required' }); } - const token = await tokenRepo.getToken(chainId, address); + const normalizedAddress = address.toLowerCase(); + const resolution = resolveCanonicalQuoteAddress(chainId, normalizedAddress); + const token = await getTokenWithFallback(chainId, normalizedAddress); if (!token) { return res.status(404).json({ error: 'Token not found' }); } const [marketData, pools, coingeckoData, cmcData, dexscreenerData] = await Promise.all([ - marketDataRepo.getMarketData(chainId, address), - poolRepo.getPoolsByToken(chainId, address), - coingeckoAdapter.getTokenByContract(chainId, address), - cmcAdapter.getTokenByContract(chainId, address), - dexscreenerAdapter.getTokenByContract(chainId, address), + marketDataRepo.getMarketData(chainId, resolution.lookupAddress), + getPoolsByTokenWithFallback(chainId, normalizedAddress), + coingeckoAdapter.getTokenByContract(chainId, resolution.lookupAddress), + cmcAdapter.getTokenByContract(chainId, resolution.lookupAddress), + dexscreenerAdapter.getTokenByContract(chainId, resolution.lookupAddress), ]); res.json({ @@ -131,6 +257,15 @@ router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request, })), hasDodoPool: pools.some((p) => (p.dexType || '').toLowerCase() === 'dodo'), pmmPool: pools.find((p) => (p.dexType || '').toLowerCase() === 'dodo')?.poolAddress || undefined, + canonicalLiquidity: + resolution.usedFallback + ? { + requestedAddress: normalizedAddress, + lookupAddress: resolution.lookupAddress, + requestedSymbol: resolution.requestedSymbol, + lookupSymbol: resolution.lookupSymbol, + } + : undefined, }, }); } catch (error) { @@ -148,7 +283,7 @@ router.get('/tokens/:address/pools', cacheMiddleware(60 * 1000), async (req: Req return res.status(400).json({ error: 'chainId is required' }); } - const pools = await poolRepo.getPoolsByToken(chainId, address); + const pools = await getPoolsByTokenWithFallback(chainId, address); res.json({ pools: await Promise.all( @@ -280,7 +415,10 @@ router.get('/pools/:poolAddress', cacheMiddleware(60 * 1000), async (req: Reques } const pool = await poolRepo.getPool(chainId, poolAddress); - if (!pool) { + if ( + !pool || + !shouldExposePublicPool(chainId, pool.poolAddress, pool.token0Address, pool.token1Address) + ) { return res.status(404).json({ error: 'Pool not found' }); } diff --git a/services/token-aggregation/src/api/server.ts b/services/token-aggregation/src/api/server.ts index 335b896..5912cc6 100644 --- a/services/token-aggregation/src/api/server.ts +++ b/services/token-aggregation/src/api/server.ts @@ -14,6 +14,7 @@ import heatmapRoutes from './routes/heatmap'; import arbitrageRoutes from './routes/arbitrage'; import aggregatorRouteMatrixRoutes from './routes/aggregator-routes'; import partnerPayloadRoutes from './routes/partner-payloads'; +import plannerV2Routes from './routes/planner-v2'; import { MultiChainIndexer } from '../indexer/chain-indexer'; import { getDatabasePool } from '../database/client'; import winston from 'winston'; @@ -39,19 +40,42 @@ const logger = winston.createLogger({ export class ApiServer { private app: Express; private port: number; - private indexer: MultiChainIndexer; + private indexerEnabled: boolean; + private indexer: MultiChainIndexer | null; + + private resolveTrustProxySetting(): boolean | number | string { + const raw = (process.env.EXPRESS_TRUST_PROXY ?? process.env.TRUST_PROXY ?? '1').trim(); + const normalized = raw.toLowerCase(); + + if (normalized === 'true') return true; + if (normalized === 'false') return false; + if (/^\d+$/.test(raw)) return parseInt(raw, 10); + return raw; + } constructor() { this.app = express(); this.port = parseInt(process.env.PORT || '3000', 10); - this.indexer = new MultiChainIndexer(); + this.indexerEnabled = this.resolveFeatureFlag('ENABLE_INDEXER', true); + this.indexer = this.indexerEnabled ? new MultiChainIndexer() : null; this.setupMiddleware(); this.setupRoutes(); this.setupErrorHandling(); } + private resolveFeatureFlag(name: string, fallback: boolean): boolean { + const raw = (process.env[name] || '').trim().toLowerCase(); + if (!raw) return fallback; + if (['1', 'true', 'yes', 'on'].includes(raw)) return true; + if (['0', 'false', 'no', 'off'].includes(raw)) return false; + return fallback; + } + private setupMiddleware(): void { + const trustProxy = this.resolveTrustProxySetting(); + this.app.set('trust proxy', trustProxy); + // CORS this.app.use(cors()); @@ -69,6 +93,8 @@ export class ApiServer { this.app.use((req: Request, res: Response, next: NextFunction) => { logger.info(`${req.method} ${req.path}`, { ip: req.ip, + forwardedFor: req.get('x-forwarded-for'), + trustProxy, userAgent: req.get('user-agent'), }); next(); @@ -88,7 +114,7 @@ export class ApiServer { timestamp: new Date().toISOString(), services: { database: 'connected', - indexer: 'running', + indexer: this.indexerEnabled ? 'running' : 'disabled', }, }); } catch (error) { @@ -112,6 +138,7 @@ export class ApiServer { this.app.use('/api/v1', arbitrageRoutes); this.app.use('/api/v1', aggregatorRouteMatrixRoutes); this.app.use('/api/v1', partnerPayloadRoutes); + this.app.use('/api/v2', plannerV2Routes); // Admin routes (stricter rate limit) this.app.use('/api/v1/admin', strictRateLimiter, adminRoutes); @@ -124,6 +151,7 @@ export class ApiServer { endpoints: { health: '/health', api: '/api/v1', + apiV2: '/api/v2', }, }); }); @@ -148,11 +176,12 @@ export class ApiServer { async start(): Promise { try { - // Initialize indexer - await this.indexer.initialize(); - - // Start indexing - await this.indexer.startAll(); + if (this.indexer) { + await this.indexer.initialize(); + await this.indexer.startAll(); + } else { + logger.info('Token aggregation indexer disabled by ENABLE_INDEXER flag'); + } // Start server this.app.listen(this.port, () => { @@ -167,7 +196,7 @@ export class ApiServer { } async stop(): Promise { - this.indexer.stopAll(); + this.indexer?.stopAll(); logger.info('Server stopped'); } } diff --git a/services/token-aggregation/src/api/utils/default-bridge-routes.ts b/services/token-aggregation/src/api/utils/default-bridge-routes.ts new file mode 100644 index 0000000..b06c352 --- /dev/null +++ b/services/token-aggregation/src/api/utils/default-bridge-routes.ts @@ -0,0 +1,84 @@ +/** + * Built-in CCIP / Trustless bridge route payload when BRIDGE_LIST_JSON_URL is unset. + * Aligns with MetaMask Snap expectations and docs/07-ccip/CCIP_BRIDGE_MAINNET_CONNECTION.md. + */ + +import { getActivePublicPools, getActiveTransportPairs, getGruTransportMetadata } from '../../config/gru-transport'; + +export interface BridgeRoutesPayload { + routes: { + weth9: Record; + weth10: Record; + trustless?: Record; + }; + chain138Bridges: { + weth9: string; + weth10: string; + trustless?: string; + }; + tokenMappingApi: { + basePath: string; + pairs: string; + resolve: string; + note: string; + }; + gruTransport?: { + system: unknown; + summary?: Record; + activeTransportPairs: unknown[]; + activePublicPools: unknown[]; + }; +} + +const DEFAULT_WETH9_138 = '0xcacfd227A040002e49e2e01626363071324f820a'; +const DEFAULT_WETH10_138 = '0xe0E93247376aa097dB308B92e6Ba36bA015535D0'; +const DEFAULT_LOCKBOX_138 = '0xFce6f50B312B3D936Ea9693C5C9531CF92a3324c'; + +/** Destination-side WETH9 receivers (relay-backed where noted in CCIP docs). */ +const WETH9_DESTINATIONS: Record = { + 'Ethereum Mainnet (1)': '0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939', + 'BNB Chain (56)': '0x886C6A4ABC064dbf74E7caEc460b7eeC31F1b78C', + 'Avalanche C-Chain (43114)': '0x3f8C409C6072a2B6a4Ff17071927bA70F80c725F', +}; + +function envAddr(key: string, fallback: string): string { + const v = process.env[key]; + return typeof v === 'string' && v.startsWith('0x') ? v : fallback; +} + +export function buildDefaultBridgeRoutes(): BridgeRoutesPayload { + const inboxEth = process.env.INBOX_ETH?.trim(); + const trustlessRoutes: Record = {}; + if (inboxEth?.startsWith('0x')) { + trustlessRoutes['Ethereum Mainnet (1)'] = inboxEth; + } + + const gruTransportMetadata = getGruTransportMetadata(); + + return { + routes: { + weth9: { ...WETH9_DESTINATIONS }, + weth10: { ...WETH9_DESTINATIONS }, + ...(Object.keys(trustlessRoutes).length > 0 ? { trustless: trustlessRoutes } : {}), + }, + chain138Bridges: { + weth9: envAddr('CCIPWETH9_BRIDGE_CHAIN138', DEFAULT_WETH9_138), + weth10: envAddr('CCIPWETH10_BRIDGE_CHAIN138', DEFAULT_WETH10_138), + trustless: envAddr('LOCKBOX_138', DEFAULT_LOCKBOX_138), + }, + tokenMappingApi: { + basePath: '/api/v1/token-mapping', + pairs: '/api/v1/token-mapping/pairs', + resolve: '/api/v1/token-mapping/resolve', + note: 'Resolve bridged token addresses between chains; requires monorepo config/token-mapping-multichain.json on server.', + }, + gruTransport: gruTransportMetadata + ? { + system: gruTransportMetadata.system, + summary: gruTransportMetadata.counts, + activeTransportPairs: getActiveTransportPairs(), + activePublicPools: getActivePublicPools(), + } + : undefined, + }; +} diff --git a/services/token-aggregation/src/config/canonical-tokens.test.ts b/services/token-aggregation/src/config/canonical-tokens.test.ts new file mode 100644 index 0000000..8b13402 --- /dev/null +++ b/services/token-aggregation/src/config/canonical-tokens.test.ts @@ -0,0 +1,258 @@ +import { + getCanonicalTokenByAddress, + getCanonicalTokenBySymbol, + getTokenRegistryFamily, + resolveCanonicalQuoteAddress, +} from './canonical-tokens'; + +describe('canonical cW token catalog', () => { + it('models cWUSDT, cWUSDC, cWAUSDT, and cWUSDW as first-class wrapped GRU transport assets', () => { + const cwUsdt = getCanonicalTokenBySymbol(56, 'cWUSDT'); + expect(cwUsdt).toMatchObject({ + symbol: 'cWUSDT', + type: 'w', + currencyCode: 'USD', + }); + expect(cwUsdt?.addresses[56]).toBe('0x9a1D0dBEE997929ED02fD19E0E199704d20914dB'); + expect(getCanonicalTokenByAddress(56, '0x9a1D0dBEE997929ED02fD19E0E199704d20914dB')?.symbol).toBe('cWUSDT'); + + const cwUsdc = getCanonicalTokenBySymbol(8453, 'cWUSDC'); + expect(cwUsdc).toMatchObject({ + symbol: 'cWUSDC', + type: 'w', + currencyCode: 'USD', + }); + expect(cwUsdc?.addresses[8453]).toBe('0x377a5FaA3162b3Fc6f4e267301A3c817bAd18105'); + expect(getCanonicalTokenByAddress(8453, '0x377a5FaA3162b3Fc6f4e267301A3c817bAd18105')?.symbol).toBe('cWUSDC'); + + const cwAusdt = getCanonicalTokenBySymbol(56, 'cWAUSDT'); + expect(cwAusdt).toMatchObject({ + symbol: 'cWAUSDT', + type: 'w', + currencyCode: 'USD', + }); + expect(cwAusdt?.addresses[56]).toBe('0xe1a51Bc037a79AB36767561B147eb41780124934'); + expect(getCanonicalTokenByAddress(56, '0xe1a51Bc037a79AB36767561B147eb41780124934')?.symbol).toBe('cWAUSDT'); + + const cwUsdw = getCanonicalTokenBySymbol(56, 'cWUSDW'); + expect(cwUsdw).toMatchObject({ + symbol: 'cWUSDW', + type: 'w', + currencyCode: 'USD', + }); + expect(cwUsdw?.addresses[56]).toBe('0xC2FA05F12a75Ac84ea778AF9D6935cA807275E55'); + expect(getCanonicalTokenByAddress(56, '0xC2FA05F12a75Ac84ea778AF9D6935cA807275E55')?.symbol).toBe('cWUSDW'); + }); + + it('surfaces cUSDW on Chain 138 as the repo-native USDW hub asset', () => { + const cusdw = getCanonicalTokenBySymbol(138, 'cUSDW'); + expect(cusdw).toMatchObject({ + symbol: 'cUSDW', + type: 'base', + currencyCode: 'USD', + decimals: 6, + }); + expect(cusdw?.addresses[138]).toBe('0xcA6BFa614935f1AB71c9aB106bAA6FBB6057095e'); + }); + + it('models cBTC and cWBTC as GRU monetary-unit assets with satoshi precision', () => { + const cbtc = getCanonicalTokenBySymbol(138, 'cBTC'); + expect(cbtc).toMatchObject({ + symbol: 'cBTC', + type: 'base', + currencyCode: 'BTC', + decimals: 8, + }); + expect(cbtc?.addresses[138]).toBe('0xcb7c000000000000000000000000000000000138'); + expect(getTokenRegistryFamily(cbtc!)).toBe('monetary_unit'); + + const cwbtc = getCanonicalTokenBySymbol(1, 'cWBTC'); + expect(cwbtc).toMatchObject({ + symbol: 'cWBTC', + type: 'w', + currencyCode: 'BTC', + decimals: 8, + }); + expect(cwbtc?.addresses[1]).toBe('0xcb7c000000000000000000000000000000000001'); + expect(getCanonicalTokenByAddress(1, '0xcb7c000000000000000000000000000000000001')?.symbol).toBe('cWBTC'); + expect(getTokenRegistryFamily(cwbtc!)).toBe('monetary_unit'); + }); + + it('models gas-native families on Chain 138 and their public cW mirrors with gas-native metadata', () => { + const ceth = getCanonicalTokenBySymbol(138, 'cETH'); + expect(ceth).toMatchObject({ + symbol: 'cETH', + type: 'base', + currencyCode: 'ETH', + decimals: 18, + }); + expect(ceth?.addresses[138]).toBe('0xce7e00000000000000000000000000000000008a'); + expect(getTokenRegistryFamily(ceth!)).toBe('gas_native'); + + const cethL2 = getCanonicalTokenBySymbol(138, 'cETHL2'); + expect(cethL2).toMatchObject({ + symbol: 'cETHL2', + type: 'base', + currencyCode: 'ETH', + decimals: 18, + }); + expect(cethL2?.addresses[138]).toBe('0xce7200000000000000000000000000000000008a'); + + const cweth = getCanonicalTokenBySymbol(1, 'cWETH'); + expect(cweth).toMatchObject({ + symbol: 'cWETH', + type: 'w', + currencyCode: 'ETH', + }); + expect(cweth?.addresses[1]).toBe('0xce7e000000000000000000000000000000000001'); + + const cwethL2 = getCanonicalTokenBySymbol(10, 'cWETHL2'); + expect(cwethL2).toMatchObject({ + symbol: 'cWETHL2', + type: 'w', + currencyCode: 'ETH', + }); + expect(cwethL2?.addresses[10]).toBe('0xce7200000000000000000000000000000000000a'); + expect(getCanonicalTokenByAddress(10, '0xce7200000000000000000000000000000000000a')?.symbol).toBe('cWETHL2'); + expect(getTokenRegistryFamily(cwethL2!)).toBe('gas_native'); + }); + + it('surfaces cAUSDT on Chain 138 from env and keeps cWAUSDT fallback mirrors on active public chains', () => { + const previousBase = process.env.CAUSDT_ADDRESS_138; + process.env.CAUSDT_ADDRESS_138 = '0x2222222222222222222222222222222222222222'; + jest.resetModules(); + + const reloaded = require('./canonical-tokens') as typeof import('./canonical-tokens'); + const causdt = reloaded.getCanonicalTokenBySymbol(138, 'cAUSDT'); + const polygonCwAusdt = reloaded.getCanonicalTokenBySymbol(137, 'cWAUSDT'); + const avalancheCwAusdt = reloaded.getCanonicalTokenBySymbol(43114, 'cWAUSDT'); + const celoCwAusdt = reloaded.getCanonicalTokenBySymbol(42220, 'cWAUSDT'); + + expect(causdt).toMatchObject({ + symbol: 'cAUSDT', + type: 'base', + currencyCode: 'USD', + decimals: 6, + }); + expect(causdt?.addresses[138]).toBe('0x2222222222222222222222222222222222222222'); + expect(reloaded.getCanonicalTokenByAddress(138, '0x2222222222222222222222222222222222222222')?.symbol).toBe('cAUSDT'); + + expect(polygonCwAusdt).toMatchObject({ + symbol: 'cWAUSDT', + type: 'w', + currencyCode: 'USD', + }); + expect(polygonCwAusdt?.addresses[137]).toBe('0xf12e262F85107df26741726b074606CaFa24AAe7'); + + expect(avalancheCwAusdt).toMatchObject({ + symbol: 'cWAUSDT', + type: 'w', + currencyCode: 'USD', + }); + expect(avalancheCwAusdt?.addresses[43114]).toBe('0xff3084410A732231472Ee9f93F5855dA89CC5254'); + + expect(celoCwAusdt).toMatchObject({ + symbol: 'cWAUSDT', + type: 'w', + currencyCode: 'USD', + }); + expect(celoCwAusdt?.addresses[42220]).toBe('0xC158b6cD3A3088C52F797D41f5Aa02825361629e'); + + if (previousBase === undefined) { + delete process.env.CAUSDT_ADDRESS_138; + } else { + process.env.CAUSDT_ADDRESS_138 = previousBase; + } + jest.resetModules(); + }); + + it('picks up Polygon cWUSDW from env as soon as the wrapped mirror is deployed', () => { + const previous = process.env.CWUSDW_ADDRESS_137; + process.env.CWUSDW_ADDRESS_137 = '0x1111111111111111111111111111111111111111'; + jest.resetModules(); + + const reloaded = require('./canonical-tokens') as typeof import('./canonical-tokens'); + const polygonCwUsdw = reloaded.getCanonicalTokenBySymbol(137, 'cWUSDW'); + + expect(polygonCwUsdw).toMatchObject({ + symbol: 'cWUSDW', + type: 'w', + currencyCode: 'USD', + }); + expect(polygonCwUsdw?.addresses[137]).toBe('0x1111111111111111111111111111111111111111'); + expect(reloaded.getCanonicalTokenByAddress(137, '0x1111111111111111111111111111111111111111')?.symbol).toBe('cWUSDW'); + + if (previous === undefined) { + delete process.env.CWUSDW_ADDRESS_137; + } else { + process.env.CWUSDW_ADDRESS_137 = previous; + } + jest.resetModules(); + }); + + it('models the Alltra gold exception as env-gated cAXAUC/cAXAUT and cWAXAUC/cWAXAUT on chain 651940', () => { + const previousCaxauc = process.env.CAXAUC_ADDRESS_651940; + const previousCwaxauc = process.env.CWAXAUC_ADDRESS_651940; + process.env.CAXAUC_ADDRESS_651940 = '0x3333333333333333333333333333333333333333'; + process.env.CWAXAUC_ADDRESS_651940 = '0x4444444444444444444444444444444444444444'; + jest.resetModules(); + + const reloaded = require('./canonical-tokens') as typeof import('./canonical-tokens'); + const caxauc = reloaded.getCanonicalTokenBySymbol(651940, 'cAXAUC'); + const cwaxauc = reloaded.getCanonicalTokenBySymbol(651940, 'cWAXAUC'); + const cxaucOnAlltra = reloaded.getCanonicalTokenBySymbol(651940, 'cXAUC'); + + expect(caxauc).toMatchObject({ + symbol: 'cAXAUC', + type: 'base', + currencyCode: 'XAU', + }); + expect(caxauc?.addresses[651940]).toBe('0x3333333333333333333333333333333333333333'); + expect(reloaded.getCanonicalTokenByAddress(651940, '0x3333333333333333333333333333333333333333')?.symbol).toBe('cAXAUC'); + + expect(cwaxauc).toMatchObject({ + symbol: 'cWAXAUC', + type: 'w', + currencyCode: 'XAU', + }); + expect(cwaxauc?.addresses[651940]).toBe('0x4444444444444444444444444444444444444444'); + expect(reloaded.getCanonicalTokenByAddress(651940, '0x4444444444444444444444444444444444444444')?.symbol).toBe('cWAXAUC'); + + expect(cxaucOnAlltra).toBeUndefined(); + + if (previousCaxauc === undefined) { + delete process.env.CAXAUC_ADDRESS_651940; + } else { + process.env.CAXAUC_ADDRESS_651940 = previousCaxauc; + } + if (previousCwaxauc === undefined) { + delete process.env.CWAXAUC_ADDRESS_651940; + } else { + process.env.CWAXAUC_ADDRESS_651940 = previousCwaxauc; + } + jest.resetModules(); + }); + + it('surfaces Chain 138 V2 x402 deployments explicitly and resolves quote fallback to V1 liquidity', () => { + const cusdtV1 = getCanonicalTokenBySymbol(138, 'cUSDT'); + const cusdtV2 = getCanonicalTokenBySymbol(138, 'cUSDT_V2'); + const v2Addr = cusdtV2?.addresses[138]; + expect(cusdtV2).toMatchObject({ + symbol: 'cUSDT_V2', + familySymbol: 'cUSDT', + deploymentVersion: 'v2', + preferredForX402: true, + liquiditySourceSymbol: 'cUSDT', + }); + expect(v2Addr && String(v2Addr).trim()).toBeTruthy(); + expect(getCanonicalTokenByAddress(138, String(v2Addr))?.symbol).toBe('cUSDT_V2'); + + const quoteResolution = resolveCanonicalQuoteAddress(138, String(v2Addr)); + expect(quoteResolution).toMatchObject({ + requestedSymbol: 'cUSDT_V2', + lookupSymbol: 'cUSDT', + usedFallback: true, + }); + expect(quoteResolution.lookupAddress).toBe(String(cusdtV1?.addresses[138] || '').toLowerCase()); + }); +}); diff --git a/services/token-aggregation/src/config/canonical-tokens.ts b/services/token-aggregation/src/config/canonical-tokens.ts index ba982ae..f015d00 100644 --- a/services/token-aggregation/src/config/canonical-tokens.ts +++ b/services/token-aggregation/src/config/canonical-tokens.ts @@ -4,14 +4,20 @@ * Addresses can be overridden via env (e.g. CUSDC_ADDRESS_138) or filled by indexer. */ +import { isISO4217Supported } from './iso4217-symbol-registry'; +import { isMonetaryUnitSupported } from './monetary-unit-symbol-registry'; +import { loadTokenMappingLoader } from './repo-config-loader'; + export type TokenType = 'base' | 'w' | 'asset' | 'debt'; +export type TokenRegistryFamily = 'iso4217' | 'commodity' | 'monetary_unit' | 'gas_native' | 'unclassified'; export interface CanonicalTokenSpec { symbol: string; name: string; type: TokenType; decimals: number; - currencyCode?: string; // ISO-4217 for base/w + currencyCode?: string; + registryFamily?: TokenRegistryFamily; /** ChainId -> contract address (placeholder or from env) */ addresses: Partial>; description?: string; @@ -21,14 +27,60 @@ export interface CanonicalTokenSpec { v1Symbol?: string; /** v0 symbol alias; on ChainID 138 tokens use v0 only (cUSDC, cUSDT), no chain designator */ v0Alias?: string; + /** Shared family symbol when multiple on-chain deployments exist for the same monetary unit. */ + familySymbol?: string; + /** Deployment version when a family has multiple contract surfaces. */ + deploymentVersion?: string; + /** Deployment lifecycle status (for example active or staged). */ + deploymentStatus?: string; + /** Whether this deployment is the preferred x402 / permit-capable surface. */ + preferredForX402?: boolean; + /** Symbol whose current liquidity should be used for quote fallback until cutover. */ + liquiditySourceSymbol?: string; +} + +interface GruTransportDeployment { + version?: string; + address?: string; + status?: string; +} + +interface GruTransportCanonicalToken { + symbol?: string; + activeVersion?: string; + activeAddress?: string; + x402PreferredVersion?: string; + x402PreferredAddress?: string; + deployments?: GruTransportDeployment[]; } const CHAIN_138 = 138; const CHAIN_25 = 25; // Cronos const CHAIN_651940 = 651940; +const LEGACY_CHAIN_ENV_SUFFIX: Partial> = { + 1: 'MAINNET', + 10: 'OPTIMISM', + 25: 'CRONOS', + 56: 'BSC', + 100: 'GNOSIS', + 137: 'POLYGON', + 42161: 'ARBITRUM', + 43114: 'AVALANCHE', + 8453: 'BASE', +}; /** L2/mainnet chain IDs for cUSDT/cUSDC multichain (env: CUSDT_ADDRESS_56, CUSDC_ADDRESS_137, etc.) */ const L2_CHAIN_IDS = [1, 56, 137, 10, 42161, 8453, 43114, 25, 100, 42220, 1111] as const; - +const GRU_CW_CHAIN_IDS = [1, 56, 137, 10, 42161, 8453, 43114, 100, 42220] as const; +const BTC_CW_CHAIN_IDS = [1, 10, 25, 56, 100, 137, 42161, 42220, 43114, 8453, 1111] as const; +const ETH_MAINNET_CW_CHAIN_IDS = [1] as const; +const ETH_L2_CW_CHAIN_IDS = [10, 42161, 8453] as const; +const BNB_CW_CHAIN_IDS = [56] as const; +const POL_CW_CHAIN_IDS = [137] as const; +const AVAX_CW_CHAIN_IDS = [43114] as const; +const CRO_CW_CHAIN_IDS = [25] as const; +const XDAI_CW_CHAIN_IDS = [100] as const; +const CELO_CW_CHAIN_IDS = [42220] as const; +const WEMIX_CW_CHAIN_IDS = [1111] as const; /** Verified addresses from CHAIN138_TOKEN_ADDRESSES, .env, and deployment summaries */ const FALLBACK_ADDRESSES: Record>> = { USDC: { @@ -42,7 +94,7 @@ const FALLBACK_ADDRESSES: Record>> = { [CHAIN_651940]: '0xa95EeD79f84E6A0151eaEb9d441F9Ffd50e8e881', // AUSDC on ALL Mainnet [1]: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Ethereum USDC [56]: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', // BSC USDC - [137]: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c1369', // Polygon USDC + [137]: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', // Polygon USDC [100]: '0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83', // Gnosis USDC [10]: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // Optimism USDC [42161]: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // Arbitrum USDC @@ -67,6 +119,119 @@ const FALLBACK_ADDRESSES: Record>> = { [42220]: '0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e', // Celo USDT [1111]: '0xA649325Aa7C5093d12D6F98EB4378deAe68CE23F', // Wemix USDT }, + cUSDC_V2: { + [CHAIN_138]: '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d', + }, + cUSDT_V2: { + [CHAIN_138]: '0x9FBfab33882Efe0038DAa608185718b772EE5660', + }, + cUSDW: { + [CHAIN_138]: '0xcA6BFa614935f1AB71c9aB106bAA6FBB6057095e', + }, + cBTC: { + [CHAIN_138]: '0xcb7c000000000000000000000000000000000138', + }, + cETH: { + [CHAIN_138]: '0xce7e00000000000000000000000000000000008a', + }, + cETHL2: { + [CHAIN_138]: '0xce7200000000000000000000000000000000008a', + }, + cBNB: { + [CHAIN_138]: '0xcb6b00000000000000000000000000000000008a', + }, + cPOL: { + [CHAIN_138]: '0xc90100000000000000000000000000000000008a', + }, + cAVAX: { + [CHAIN_138]: '0xcaaa00000000000000000000000000000000008a', + }, + cCRO: { + [CHAIN_138]: '0xcc2000000000000000000000000000000000008a', + }, + cXDAI: { + [CHAIN_138]: '0xcda100000000000000000000000000000000008a', + }, + cCELO: { + [CHAIN_138]: '0xcce100000000000000000000000000000000008a', + }, + cWEMIX: { + [CHAIN_138]: '0xc11100000000000000000000000000000000008a', + }, + cWAUSDT: { + [56]: '0xe1a51Bc037a79AB36767561B147eb41780124934', + [137]: '0xf12e262F85107df26741726b074606CaFa24AAe7', + [43114]: '0xff3084410A732231472Ee9f93F5855dA89CC5254', + [42220]: '0xC158b6cD3A3088C52F797D41f5Aa02825361629e', + }, + cWUSDC: { + [1]: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a', + [56]: '0x5355148C4740fcc3D7a96F05EdD89AB14851206b', + [137]: '0xd6969bC19b53f866C64f2148aE271B2Dae0C58E4', + [100]: '0xd6969bC19b53f866C64f2148aE271B2Dae0C58E4', + [10]: '0x377a5FaA3162b3Fc6f4e267301A3c817bAd18105', + [42161]: '0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF', + [8453]: '0x377a5FaA3162b3Fc6f4e267301A3c817bAd18105', + [43114]: '0x0C242b513008Cd49C89078F5aFb237A3112251EB', + [42220]: '0x4C38F9A5ed68A04cd28a72E8c68C459Ec34576f3', + }, + cWUSDT: { + [1]: '0xaF5017d0163ecb99D9B5D94e3b4D7b09Af44D8AE', + [56]: '0x9a1D0dBEE997929ED02fD19E0E199704d20914dB', + [137]: '0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF', + [100]: '0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF', + [10]: '0x04B2AE3c3bb3d70Df506FAd8717b0FBFC78ED7E6', + [42161]: '0x73ADaF7dBa95221c080db5631466d2bC54f6a76B', + [8453]: '0x04B2AE3c3bb3d70Df506FAd8717b0FBFC78ED7E6', + [43114]: '0x8142BA530B08f3950128601F00DaaA678213DFdf', + [42220]: '0x73376eB92c16977B126dB9112936A20Fa0De3442', + }, + cWUSDW: { + [56]: '0xC2FA05F12a75Ac84ea778AF9D6935cA807275E55', + [43114]: '0xcfdCe5E660FC2C8052BDfa7aEa1865DD753411Ae', + }, + cWBTC: { + [1]: '0xcb7c000000000000000000000000000000000001', + [10]: '0xcb7c00000000000000000000000000000000000a', + [25]: '0xcb7c000000000000000000000000000000000019', + [56]: '0xcb7c000000000000000000000000000000000038', + [100]: '0xcb7c000000000000000000000000000000000064', + [137]: '0xcb7c000000000000000000000000000000000089', + [1111]: '0xcb7c000000000000000000000000000000000457', + [8453]: '0xcb7c000000000000000000000000000000002105', + [42161]: '0xcb7c00000000000000000000000000000000a4b1', + [42220]: '0xcb7c00000000000000000000000000000000a4ec', + [43114]: '0xcb7c00000000000000000000000000000000a86a', + }, + cWETH: { + [1]: '0xce7e000000000000000000000000000000000001', + }, + cWETHL2: { + [10]: '0xce7200000000000000000000000000000000000a', + [42161]: '0xce7200000000000000000000000000000000a4b1', + [8453]: '0xce72000000000000000000000000000000002105', + }, + cWBNB: { + [56]: '0xcb6b000000000000000000000000000000000038', + }, + cWPOL: { + [137]: '0xc901000000000000000000000000000000000089', + }, + cWAVAX: { + [43114]: '0xcaaa00000000000000000000000000000000a86a', + }, + cWCRO: { + [25]: '0xcc20000000000000000000000000000000000019', + }, + cWXDAI: { + [100]: '0xcda1000000000000000000000000000000000064', + }, + cWCELO: { + [42220]: '0xcce100000000000000000000000000000000a4ec', + }, + cWWEMIX: { + [1111]: '0xc111000000000000000000000000000000000457', + }, // Compliant Fiat on Chain 138 — from DeployCompliantFiatTokens (2026-02-27) cEURC: { [CHAIN_138]: '0x8085961F9cF02b4d800A3c6d386D31da4B34266a' }, cEURT: { [CHAIN_138]: '0xdf4b71c61E5912712C1Bdd451416B9aC26949d72' }, @@ -88,6 +253,50 @@ const FALLBACK_ADDRESSES: Record>> = { CADW: { [CHAIN_25]: '0x328Cd365Bb35524297E68ED28c6fF2C9557d1363' }, }; +function normalizeAddress(address?: string | null): string { + return typeof address === 'string' ? address.trim().toLowerCase() : ''; +} + +function getGruTransportCanonicalToken(symbol: string): GruTransportCanonicalToken | null { + const loader = loadTokenMappingLoader<{ + getEnabledCanonicalToken?: (identifier: string) => GruTransportCanonicalToken | null; + }>(); + return loader?.getEnabledCanonicalToken?.(symbol) ?? null; +} + +function getTransportLookup(symbol: string): { baseSymbol: string; version: 'v1' | 'v2' } | null { + if (symbol === 'cUSDT' || symbol === 'cUSDC') { + return { baseSymbol: symbol, version: 'v1' }; + } + if (symbol === 'cUSDT_V2') { + return { baseSymbol: 'cUSDT', version: 'v2' }; + } + if (symbol === 'cUSDC_V2') { + return { baseSymbol: 'cUSDC', version: 'v2' }; + } + return null; +} + +function getTransportDeploymentAddress(symbol: string, version: 'v1' | 'v2'): string | undefined { + const token = getGruTransportCanonicalToken(symbol); + if (!token) return undefined; + + const deploymentMatch = Array.isArray(token.deployments) + ? token.deployments.find((deployment) => String(deployment.version || '').trim().toLowerCase() === version) + : null; + if (deploymentMatch?.address) return deploymentMatch.address; + + if (version === 'v1') { + if (String(token.activeVersion || '').trim().toLowerCase() === 'v1' && token.activeAddress) { + return token.activeAddress; + } + } else if (String(token.x402PreferredVersion || '').trim().toLowerCase() === 'v2' && token.x402PreferredAddress) { + return token.x402PreferredAddress; + } + + return undefined; +} + function addr(symbol: string, chainId: number): string | undefined { if (chainId === CHAIN_138 && symbol === 'USDT') { return process.env.USDT_ADDRESS_138 || process.env.OFFICIAL_USDT_ADDRESS || FALLBACK_ADDRESSES[symbol]?.[chainId]; @@ -95,9 +304,27 @@ function addr(symbol: string, chainId: number): string | undefined { if (chainId === CHAIN_138 && symbol === 'USDC') { return process.env.USDC_ADDRESS_138 || process.env.OFFICIAL_USDC_ADDRESS || FALLBACK_ADDRESSES[symbol]?.[chainId]; } + if (chainId === CHAIN_138) { + const transportLookup = getTransportLookup(symbol); + if (transportLookup) { + const envKey = `${symbol.replace(/-/g, '_').toUpperCase()}_ADDRESS_${chainId}`; + const envVal = process.env[envKey]; + if (envVal && envVal.trim() !== '') return envVal; + return ( + getTransportDeploymentAddress(transportLookup.baseSymbol, transportLookup.version) || + FALLBACK_ADDRESSES[symbol]?.[chainId] + ); + } + } const key = `${symbol.replace(/-/g, '_').toUpperCase()}_ADDRESS_${chainId}`; const envVal = process.env[key]; if (envVal && envVal.trim() !== '') return envVal; + const legacySuffix = LEGACY_CHAIN_ENV_SUFFIX[chainId]; + if (legacySuffix) { + const legacyKey = `${symbol.replace(/-/g, '_').toUpperCase()}_${legacySuffix}`; + const legacyVal = process.env[legacyKey]; + if (legacyVal && legacyVal.trim() !== '') return legacyVal; + } return FALLBACK_ADDRESSES[symbol]?.[chainId]; } @@ -108,8 +335,217 @@ export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [ { symbol: 'USDT', name: 'Tether USD (Official Mirror)', type: 'base', decimals: 6, currencyCode: 'USD', addresses: { [CHAIN_138]: addr('USDT', CHAIN_138) || '' } }, // Chain 138 v0 only (no X): cUSDC on 138; cXUSDC used only for bridged/origin reference elsewhere. See ISO4217_COMPLIANT_TOKEN_MATRIX.md { symbol: 'cUSDC', name: 'USD Coin (Compliant)', type: 'base', decimals: 6, currencyCode: 'USD', v0Alias: 'cUSDC', addresses: { [CHAIN_138]: addr('cUSDC', CHAIN_138) || '', [CHAIN_651940]: addr('cUSDC', CHAIN_651940) || '', ...Object.fromEntries(L2_CHAIN_IDS.map((id) => [id, addr('cUSDC', id)])) } }, + { symbol: 'cUSDC_V2', name: 'USD Coin (Compliant V2)', type: 'base', decimals: 6, currencyCode: 'USD', familySymbol: 'cUSDC', deploymentVersion: 'v2', deploymentStatus: 'staged', preferredForX402: true, liquiditySourceSymbol: 'cUSDC', description: 'Chain 138 x402 / permit-capable V2 deployment. Liquidity and PMM routing remain on cUSDC until cutover.', addresses: { [CHAIN_138]: addr('cUSDC_V2', CHAIN_138) || '' } }, // Chain 138 v0 only (no X): cUSDT on 138; cXUSDT used only for bridged/origin reference elsewhere. See ISO4217_COMPLIANT_TOKEN_MATRIX.md { symbol: 'cUSDT', name: 'Tether USD (Compliant)', type: 'base', decimals: 6, currencyCode: 'USD', v0Alias: 'cUSDT', addresses: { [CHAIN_138]: addr('cUSDT', CHAIN_138) || '', [CHAIN_651940]: addr('cUSDT', CHAIN_651940) || '', ...Object.fromEntries(L2_CHAIN_IDS.map((id) => [id, addr('cUSDT', id)])) } }, + { symbol: 'cUSDT_V2', name: 'Tether USD (Compliant V2)', type: 'base', decimals: 6, currencyCode: 'USD', familySymbol: 'cUSDT', deploymentVersion: 'v2', deploymentStatus: 'staged', preferredForX402: true, liquiditySourceSymbol: 'cUSDT', description: 'Chain 138 x402 / permit-capable V2 deployment. Liquidity and PMM routing remain on cUSDT until cutover.', addresses: { [CHAIN_138]: addr('cUSDT_V2', CHAIN_138) || '' } }, + { symbol: 'cAUSDT', name: 'Alltra USD Token (Compliant)', type: 'base', decimals: 6, currencyCode: 'USD', description: 'Live Chain 138 compliant landing asset for the ALL Mainnet AUSDT corridor.', addresses: { [CHAIN_138]: addr('cAUSDT', CHAIN_138) || '' } }, + { symbol: 'cUSDW', name: 'USD W (Compliant)', type: 'base', decimals: 6, currencyCode: 'USD', description: 'Chain 138 repo-native cUSDW hub asset for D-WIN-aligned PMM and cWUSDW transport planning.', addresses: { [CHAIN_138]: addr('cUSDW', CHAIN_138) || '' } }, + { + symbol: 'cBTC', + name: 'Bitcoin (Compliant)', + type: 'base', + decimals: 8, + currencyCode: 'BTC', + registryFamily: 'monetary_unit', + description: 'Canonical Chain 138 compliant Bitcoin monetary unit with satoshi-precision accounting and custody-backed mint controls.', + addresses: { [CHAIN_138]: addr('cBTC', CHAIN_138) || '' }, + }, + { + symbol: 'cETH', + name: 'Ether Mainnet (Compliant)', + type: 'base', + decimals: 18, + currencyCode: 'ETH', + registryFamily: 'gas_native', + description: 'Canonical Chain 138 representation of Ethereum mainnet gas inventory. This family remains isolated from the shared ETH L2 family.', + addresses: { [CHAIN_138]: addr('cETH', CHAIN_138) || '' }, + }, + { + symbol: 'cETHL2', + name: 'Ether L2 Basket (Compliant)', + type: 'base', + decimals: 18, + currencyCode: 'ETH', + registryFamily: 'gas_native', + description: 'Canonical Chain 138 representation of the shared ETH L2 family across Optimism, Arbitrum, and Base.', + addresses: { [CHAIN_138]: addr('cETHL2', CHAIN_138) || '' }, + }, + { + symbol: 'cBNB', + name: 'BNB (Compliant)', + type: 'base', + decimals: 18, + currencyCode: 'BNB', + registryFamily: 'gas_native', + description: 'Canonical Chain 138 representation of BNB gas inventory.', + addresses: { [CHAIN_138]: addr('cBNB', CHAIN_138) || '' }, + }, + { + symbol: 'cPOL', + name: 'POL (Compliant)', + type: 'base', + decimals: 18, + currencyCode: 'POL', + registryFamily: 'gas_native', + description: 'Canonical Chain 138 representation of Polygon gas inventory.', + addresses: { [CHAIN_138]: addr('cPOL', CHAIN_138) || '' }, + }, + { + symbol: 'cAVAX', + name: 'Avalanche (Compliant)', + type: 'base', + decimals: 18, + currencyCode: 'AVAX', + registryFamily: 'gas_native', + description: 'Canonical Chain 138 representation of Avalanche gas inventory.', + addresses: { [CHAIN_138]: addr('cAVAX', CHAIN_138) || '' }, + }, + { + symbol: 'cCRO', + name: 'Cronos (Compliant)', + type: 'base', + decimals: 18, + currencyCode: 'CRO', + registryFamily: 'gas_native', + description: 'Canonical Chain 138 representation of Cronos gas inventory.', + addresses: { [CHAIN_138]: addr('cCRO', CHAIN_138) || '' }, + }, + { + symbol: 'cXDAI', + name: 'xDAI (Compliant)', + type: 'base', + decimals: 18, + currencyCode: 'XDAI', + registryFamily: 'gas_native', + description: 'Canonical Chain 138 representation of Gnosis Chain xDAI gas inventory.', + addresses: { [CHAIN_138]: addr('cXDAI', CHAIN_138) || '' }, + }, + { + symbol: 'cCELO', + name: 'Celo (Compliant)', + type: 'base', + decimals: 18, + currencyCode: 'CELO', + registryFamily: 'gas_native', + description: 'Canonical Chain 138 representation of Celo gas inventory.', + addresses: { [CHAIN_138]: addr('cCELO', CHAIN_138) || '' }, + }, + { + symbol: 'cWEMIX', + name: 'Wemix Hub (Compliant)', + type: 'base', + decimals: 18, + currencyCode: 'WEMIX', + registryFamily: 'gas_native', + description: 'Canonical Chain 138 representation of Wemix gas inventory. The public mirror keeps the distinct cWWEMIX symbol to avoid naming collisions.', + addresses: { [CHAIN_138]: addr('cWEMIX', CHAIN_138) || '' }, + }, + // Public-network transport mirrors for canonical Chain 138 c* assets. + { symbol: 'cWAUSDT', name: 'Alltra USD Token (Compliant Wrapped ISO-4217 M1)', type: 'w', decimals: 6, currencyCode: 'USD', description: 'Public-network mirrored transport form for the live Chain 138 cAUSDT surface.', addresses: { ...Object.fromEntries(GRU_CW_CHAIN_IDS.map((id) => [id, addr('cWAUSDT', id)])) } }, + { symbol: 'cWUSDC', name: 'USD Coin (Compliant Wrapped ISO-4217 M1)', type: 'w', decimals: 6, currencyCode: 'USD', description: 'Public-network mirrored transport form of canonical Chain 138 cUSDC.', addresses: { ...Object.fromEntries(GRU_CW_CHAIN_IDS.map((id) => [id, addr('cWUSDC', id)])) } }, + { symbol: 'cWUSDT', name: 'Tether USD (Compliant Wrapped ISO-4217 M1)', type: 'w', decimals: 6, currencyCode: 'USD', description: 'Public-network mirrored transport form of canonical Chain 138 cUSDT.', addresses: { ...Object.fromEntries(GRU_CW_CHAIN_IDS.map((id) => [id, addr('cWUSDT', id)])) } }, + { symbol: 'cWUSDW', name: 'USD W (Compliant Wrapped ISO-4217 M1)', type: 'w', decimals: 6, currencyCode: 'USD', description: 'Public-network mirrored transport form of canonical Chain 138 cUSDW.', addresses: { ...Object.fromEntries(GRU_CW_CHAIN_IDS.map((id) => [id, addr('cWUSDW', id)])) } }, + { + symbol: 'cWBTC', + name: 'Bitcoin (Compliant Wrapped Monetary Unit)', + type: 'w', + decimals: 8, + currencyCode: 'BTC', + registryFamily: 'monetary_unit', + description: 'Public-network mirrored transport form of canonical Chain 138 cBTC. Distinct from Ethereum WBTC and other third-party wrapped BTC products.', + addresses: { ...Object.fromEntries(BTC_CW_CHAIN_IDS.map((id) => [id, addr('cWBTC', id)])) }, + }, + { + symbol: 'cWETH', + name: 'Ether Mainnet (Compliant Wrapped)', + type: 'w', + decimals: 18, + currencyCode: 'ETH', + registryFamily: 'gas_native', + description: 'Public-network mirrored transport form of canonical Chain 138 cETH for Ethereum mainnet only.', + addresses: { ...Object.fromEntries(ETH_MAINNET_CW_CHAIN_IDS.map((id) => [id, addr('cWETH', id)])) }, + }, + { + symbol: 'cWETHL2', + name: 'Ether L2 Basket (Compliant Wrapped)', + type: 'w', + decimals: 18, + currencyCode: 'ETH', + registryFamily: 'gas_native', + description: 'Public-network mirrored transport form of canonical Chain 138 cETHL2 across the approved ETH L2 family.', + addresses: { ...Object.fromEntries(ETH_L2_CW_CHAIN_IDS.map((id) => [id, addr('cWETHL2', id)])) }, + }, + { + symbol: 'cWBNB', + name: 'BNB (Compliant Wrapped)', + type: 'w', + decimals: 18, + currencyCode: 'BNB', + registryFamily: 'gas_native', + description: 'Public-network mirrored transport form of canonical Chain 138 cBNB.', + addresses: { ...Object.fromEntries(BNB_CW_CHAIN_IDS.map((id) => [id, addr('cWBNB', id)])) }, + }, + { + symbol: 'cWPOL', + name: 'POL (Compliant Wrapped)', + type: 'w', + decimals: 18, + currencyCode: 'POL', + registryFamily: 'gas_native', + description: 'Public-network mirrored transport form of canonical Chain 138 cPOL.', + addresses: { ...Object.fromEntries(POL_CW_CHAIN_IDS.map((id) => [id, addr('cWPOL', id)])) }, + }, + { + symbol: 'cWAVAX', + name: 'Avalanche (Compliant Wrapped)', + type: 'w', + decimals: 18, + currencyCode: 'AVAX', + registryFamily: 'gas_native', + description: 'Public-network mirrored transport form of canonical Chain 138 cAVAX.', + addresses: { ...Object.fromEntries(AVAX_CW_CHAIN_IDS.map((id) => [id, addr('cWAVAX', id)])) }, + }, + { + symbol: 'cWCRO', + name: 'Cronos (Compliant Wrapped)', + type: 'w', + decimals: 18, + currencyCode: 'CRO', + registryFamily: 'gas_native', + description: 'Public-network mirrored transport form of canonical Chain 138 cCRO.', + addresses: { ...Object.fromEntries(CRO_CW_CHAIN_IDS.map((id) => [id, addr('cWCRO', id)])) }, + }, + { + symbol: 'cWXDAI', + name: 'xDAI (Compliant Wrapped)', + type: 'w', + decimals: 18, + currencyCode: 'XDAI', + registryFamily: 'gas_native', + description: 'Public-network mirrored transport form of canonical Chain 138 cXDAI.', + addresses: { ...Object.fromEntries(XDAI_CW_CHAIN_IDS.map((id) => [id, addr('cWXDAI', id)])) }, + }, + { + symbol: 'cWCELO', + name: 'Celo (Compliant Wrapped)', + type: 'w', + decimals: 18, + currencyCode: 'CELO', + registryFamily: 'gas_native', + description: 'Public-network mirrored transport form of canonical Chain 138 cCELO.', + addresses: { ...Object.fromEntries(CELO_CW_CHAIN_IDS.map((id) => [id, addr('cWCELO', id)])) }, + }, + { + symbol: 'cWWEMIX', + name: 'Wemix (Compliant Wrapped)', + type: 'w', + decimals: 18, + currencyCode: 'WEMIX', + registryFamily: 'gas_native', + description: 'Public-network mirrored transport form of canonical Chain 138 cWEMIX. The doubled W preserves the cW* naming discipline and keeps the canonical hub symbol distinct.', + addresses: { ...Object.fromEntries(WEMIX_CW_CHAIN_IDS.map((id) => [id, addr('cWWEMIX', id)])) }, + }, { symbol: 'cEURC', name: 'Euro Coin (Compliant)', type: 'base', decimals: 6, currencyCode: 'EUR', addresses: { [CHAIN_138]: addr('cEURC', CHAIN_138), [CHAIN_651940]: addr('cEURC', CHAIN_651940) } }, { symbol: 'cEURT', name: 'Tether EUR (Compliant)', type: 'base', decimals: 6, currencyCode: 'EUR', addresses: { [CHAIN_138]: addr('cEURT', CHAIN_138), [CHAIN_651940]: addr('cEURT', CHAIN_651940) } }, { symbol: 'cGBPC', name: 'Pound Sterling (Compliant)', type: 'base', decimals: 6, currencyCode: 'GBP', addresses: { [CHAIN_138]: addr('cGBPC', CHAIN_138), [CHAIN_651940]: addr('cGBPC', CHAIN_651940) } }, @@ -125,7 +561,7 @@ export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [ decimals: 6, currencyCode: 'XAU', description: '1 full token = 1 troy ounce fine gold (10^6 base units = 1 oz).', - addresses: { [CHAIN_138]: addr('cXAUC', CHAIN_138), [CHAIN_651940]: addr('cXAUC', CHAIN_651940) }, + addresses: { [CHAIN_138]: addr('cXAUC', CHAIN_138) }, }, { symbol: 'cXAUT', @@ -134,7 +570,43 @@ export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [ decimals: 6, currencyCode: 'XAU', description: '1 full token = 1 troy ounce fine gold (10^6 base units = 1 oz).', - addresses: { [CHAIN_138]: addr('cXAUT', CHAIN_138), [CHAIN_651940]: addr('cXAUT', CHAIN_651940) }, + addresses: { [CHAIN_138]: addr('cXAUT', CHAIN_138) }, + }, + { + symbol: 'cAXAUC', + name: 'Alltra Gold Coin', + type: 'base', + decimals: 6, + currencyCode: 'XAU', + description: 'Planned ALL Mainnet native-unwrapped gold landing asset for the 138 -> 651940 corridor.', + addresses: { [CHAIN_651940]: addr('cAXAUC', CHAIN_651940) || '' }, + }, + { + symbol: 'cAXAUT', + name: 'Alltra Tether XAU', + type: 'base', + decimals: 6, + currencyCode: 'XAU', + description: 'Planned ALL Mainnet native-unwrapped XAU token for the 138 -> 651940 corridor.', + addresses: { [CHAIN_651940]: addr('cAXAUT', CHAIN_651940) || '' }, + }, + { + symbol: 'cWAXAUC', + name: 'Alltra Wrapped Gold Coin', + type: 'w', + decimals: 6, + currencyCode: 'XAU', + description: 'Planned ALL Mainnet bridge-minted wrapped gold representation for inbound Chain 138 cXAUC transport.', + addresses: { [CHAIN_651940]: addr('cWAXAUC', CHAIN_651940) || '' }, + }, + { + symbol: 'cWAXAUT', + name: 'Alltra Wrapped Tether XAU', + type: 'w', + decimals: 6, + currencyCode: 'XAU', + description: 'Planned ALL Mainnet bridge-minted wrapped XAU representation for inbound Chain 138 cXAUT transport.', + addresses: { [CHAIN_651940]: addr('cWAXAUT', CHAIN_651940) || '' }, }, { symbol: 'LiXAU', name: 'XAU Liquidity-adjusted', type: 'base', decimals: 6, currencyCode: 'XAU', addresses: { [CHAIN_138]: addr('LiXAU', CHAIN_138), [CHAIN_651940]: addr('LiXAU', CHAIN_651940) } }, // --- ISO-4217 W --- @@ -183,7 +655,7 @@ export function getCanonicalTokensByChain(chainId: number): CanonicalTokenSpec[] } export function getCanonicalTokenByAddress(chainId: number, address: string): CanonicalTokenSpec | undefined { - const lower = address.toLowerCase(); + const lower = normalizeAddress(address); return CANONICAL_TOKENS.find((t) => t.addresses[chainId]?.toLowerCase() === lower); } @@ -194,29 +666,78 @@ export function getCanonicalTokenBySymbol(chainId: number, symbol: string): Cano ); } +export interface CanonicalQuoteAddressResolution { + requestedAddress: string; + requestedSymbol?: string; + lookupAddress: string; + lookupSymbol?: string; + usedFallback: boolean; +} + +export function resolveCanonicalQuoteAddress(chainId: number, address: string): CanonicalQuoteAddressResolution { + const requestedAddress = normalizeAddress(address); + const requestedSpec = getCanonicalTokenByAddress(chainId, requestedAddress); + if (!requestedSpec) { + return { + requestedAddress, + lookupAddress: requestedAddress, + usedFallback: false, + }; + } + + const liquiditySourceSymbol = requestedSpec.liquiditySourceSymbol || requestedSpec.symbol; + const liquiditySpec = getCanonicalTokenBySymbol(chainId, liquiditySourceSymbol) || requestedSpec; + const lookupAddress = normalizeAddress(liquiditySpec.addresses[chainId] || requestedAddress); + + return { + requestedAddress, + requestedSymbol: requestedSpec.symbol, + lookupAddress, + lookupSymbol: liquiditySpec.symbol, + usedFallback: lookupAddress !== requestedAddress, + }; +} + /** IPFS-hosted logo URLs (Pinata) for Uniswap token list (logoURI). * Every token must have logoURI for MetaMask to display icons. getLogoUriForSpec resolves * ac-tokens from base (c*), vdc/sdc from base; unknown symbols fall back to ETH_LOGO. */ const IPFS_GATEWAY = 'https://ipfs.io/ipfs'; +const GRU_LOGO_BASE = + 'https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru'; const ETH_LOGO = `${IPFS_GATEWAY}/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong`; -const USDC_LOGO = `${IPFS_GATEWAY}/QmNPq4D5JXzurmi9jAhogVMzhAQRk1PZ1r9H3qQUV9gjDm`; -const USDT_LOGO = `${IPFS_GATEWAY}/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP`; +const USDC_LOGO = `${GRU_LOGO_BASE}/cUSDC.svg`; +const USDT_LOGO = `${GRU_LOGO_BASE}/cUSDT.svg`; +const BTC_LOGO = 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/bitcoin/info/logo.png'; const LOGO_BY_SYMBOL: Record = { USDC: USDC_LOGO, USDT: USDT_LOGO, cUSDC: USDC_LOGO, cUSDT: USDT_LOGO, - cEURC: USDC_LOGO, - cEURT: USDT_LOGO, - cGBPC: `${IPFS_GATEWAY}/QmNQF73WjxU6FwTXNH8PXoDRFaSFKTYQWL7d4Q1kdRVJ4o`, - cGBPT: `${IPFS_GATEWAY}/QmV4frsJmDTWzLdxdj1z81uMqVXcbGpHZLzwkpj6GvEX4k`, - cAUDC: `${IPFS_GATEWAY}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`, - cJPYC: `${IPFS_GATEWAY}/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K`, - cCHFC: `${IPFS_GATEWAY}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`, - cCADC: `${IPFS_GATEWAY}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`, - cXAUC: ETH_LOGO, - cXAUT: ETH_LOGO, + cUSDC_V2: USDC_LOGO, + cUSDT_V2: USDT_LOGO, + cAUSDT: USDT_LOGO, + cUSDW: USDC_LOGO, + cBTC: BTC_LOGO, + cWAUSDT: USDT_LOGO, + cWBTC: BTC_LOGO, + cWUSDC: USDC_LOGO, + cWUSDT: USDT_LOGO, + cWUSDW: USDC_LOGO, + cEURC: `${GRU_LOGO_BASE}/cEURC.svg`, + cEURT: `${GRU_LOGO_BASE}/cEURT.svg`, + cGBPC: `${GRU_LOGO_BASE}/cGBPC.svg`, + cGBPT: `${GRU_LOGO_BASE}/cGBPT.svg`, + cAUDC: `${GRU_LOGO_BASE}/cAUDC.svg`, + cJPYC: `${GRU_LOGO_BASE}/cJPYC.svg`, + cCHFC: `${GRU_LOGO_BASE}/cCHFC.svg`, + cCADC: `${GRU_LOGO_BASE}/cCADC.svg`, + cXAUC: `${GRU_LOGO_BASE}/cXAUC.svg`, + cXAUT: `${GRU_LOGO_BASE}/cXAUT.svg`, + cAXAUC: `${GRU_LOGO_BASE}/cXAUC.svg`, + cAXAUT: `${GRU_LOGO_BASE}/cXAUT.svg`, + cWAXAUC: `${GRU_LOGO_BASE}/cXAUC.svg`, + cWAXAUT: `${GRU_LOGO_BASE}/cXAUT.svg`, LiXAU: `${IPFS_GATEWAY}/QmUVY5trUM5N1UnS4abReb66fNzGw7kenjU9AjL7TgR3M1`, USDW: USDC_LOGO, EURW: `${IPFS_GATEWAY}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`, @@ -239,3 +760,13 @@ export function getLogoUriForSpec(spec: CanonicalTokenSpec): string { } return ETH_LOGO; } + +export function getTokenRegistryFamily(spec: Pick): TokenRegistryFamily { + if (spec.registryFamily) return spec.registryFamily; + const code = String(spec.currencyCode || '').trim().toUpperCase(); + if (!code) return 'unclassified'; + if (code === 'XAU') return 'commodity'; + if (isISO4217Supported(code)) return 'iso4217'; + if (isMonetaryUnitSupported(code)) return 'monetary_unit'; + return 'unclassified'; +} diff --git a/services/token-aggregation/src/config/chain138-rpc.ts b/services/token-aggregation/src/config/chain138-rpc.ts new file mode 100644 index 0000000..00c20e4 --- /dev/null +++ b/services/token-aggregation/src/config/chain138-rpc.ts @@ -0,0 +1,13 @@ +const DEFAULT_CHAIN138_RPC_URL = 'https://rpc-http-pub.d-bis.org'; + +export function resolveChain138RpcUrl(): string { + return String( + process.env.CHAIN_138_RPC_URL || + process.env.RPC_URL_138 || + process.env.RPC_URL_138_PUBLIC || + process.env.RPC_HTTP_PUB_URL || + DEFAULT_CHAIN138_RPC_URL + ).trim(); +} + +export { DEFAULT_CHAIN138_RPC_URL }; diff --git a/services/token-aggregation/src/config/chains.ts b/services/token-aggregation/src/config/chains.ts index 110a71a..c82958c 100644 --- a/services/token-aggregation/src/config/chains.ts +++ b/services/token-aggregation/src/config/chains.ts @@ -1,3 +1,5 @@ +import { resolveChain138RpcUrl } from './chain138-rpc'; + export interface ChainConfig { chainId: number; name: string; @@ -16,7 +18,7 @@ export const CHAIN_CONFIGS: Record = { 138: { chainId: 138, name: 'DeFi Oracle Meta Mainnet', - rpcUrl: process.env.CHAIN_138_RPC_URL || process.env.RPC_URL_138_PUBLIC || process.env.RPC_URL_138 || 'https://rpc-http-pub.d-bis.org', + rpcUrl: resolveChain138RpcUrl(), explorerUrl: 'https://explorer.d-bis.org', nativeCurrency: { name: 'Ether', diff --git a/services/token-aggregation/src/config/cross-chain-bridges.ts b/services/token-aggregation/src/config/cross-chain-bridges.ts index f55d51a..2074290 100644 --- a/services/token-aggregation/src/config/cross-chain-bridges.ts +++ b/services/token-aggregation/src/config/cross-chain-bridges.ts @@ -3,6 +3,8 @@ * Used by cross-chain-indexer for CCIP/Alltra/UniversalCCIP event aggregation. */ +import { loadTokenMappingLoader } from './repo-config-loader'; + export interface BridgeLane { destSelector: string; destChainId: number; @@ -114,6 +116,45 @@ const CCIP_WETH9_138 = envAddr('CCIPWETH9_BRIDGE_CHAIN138') || '0x971cD9D156f193 const CCIP_STABLE_138 = envAnyAddr('CCIP_STABLE_BRIDGE_CHAIN138', 'CCIP_STABLECOIN_BRIDGE_CHAIN138'); const UNIVERSAL_CCIP_138 = envAnyAddr('UNIVERSAL_CCIP_BRIDGE_ADDRESS', 'UNIVERSAL_CCIP_BRIDGE'); +interface RepoConfigLoader { + getRoutingRegistryRoutes?: () => RoutingRegistryEntry[]; + getActiveTransportPair?: ( + fromChainId: number, + toChainId: number, + criteria?: Record + ) => (RoutingRegistryEntry & { + canonicalSymbol?: string; + peer?: { + l1Bridge?: { address?: string; env?: string }; + l2Bridge?: { address?: string; env?: string }; + }; + eligible?: boolean; + }) | null; + resolveConfigRef?: (ref?: { address?: string; env?: string }) => string; +} + +function loadRepoConfigLoader(): RepoConfigLoader | null { + return loadTokenMappingLoader(); +} + +function normalizeTransportAsset(asset: string): string { + const normalized = asset.trim().toLowerCase().replace(/[\s_-]/g, ''); + if (normalized.startsWith('cw')) { + return `c${normalized.slice(2)}`; + } + return normalized; +} + +function resolvePeerBridgeAddress( + loader: RepoConfigLoader | null, + pair: NonNullable>>, + sourceChainId: number +): string { + const ref = sourceChainId === chainId138 ? pair.peer?.l1Bridge : pair.peer?.l2Bridge; + const resolved = loader?.resolveConfigRef?.(ref); + return resolved || ''; +} + /** * Get routing registry entry for (fromChain, toChain, asset). * Used by UI and indexer to choose ALT vs CCIP and to fill routing in activity_events. @@ -125,6 +166,51 @@ export function getRouteFromRegistry( asset: string = 'WETH', ): RoutingRegistryEntry | null { if (fromChain === toChain) return null; + const loader = loadRepoConfigLoader(); + const normalizedAsset = normalizeTransportAsset(asset); + + const activeTransportPair = loader?.getActiveTransportPair?.(fromChain, toChain, { symbol: normalizedAsset }); + if (activeTransportPair) { + if (activeTransportPair.eligible) { + const bridgeAddress = resolvePeerBridgeAddress(loader, activeTransportPair, fromChain); + if (bridgeAddress) { + return { + pathType: 'CCIP', + bridgeAddress, + bridgeChainId: fromChain === chainId138 ? chainId138 : fromChain, + label: 'GRUTransport', + fromChain, + toChain, + asset: activeTransportPair.canonicalSymbol || asset, + }; + } + } + + // Active GRU transport assets must not silently escape into legacy bridge paths. + return null; + } + + const registryRoutes = loader?.getRoutingRegistryRoutes?.() || []; + const routeMatch = + registryRoutes.find( + (route) => + route.fromChain === fromChain && + route.toChain === toChain && + typeof route.asset === 'string' && + route.asset.trim().toLowerCase() === asset.trim().toLowerCase() + ) || + registryRoutes.find( + (route) => + route.fromChain === fromChain && + route.toChain === toChain && + typeof route.asset === 'string' && + route.asset.trim().toLowerCase() === normalizedAsset + ); + + if (routeMatch) { + return routeMatch; + } + const is138To651940 = fromChain === 138 && toChain === 651940; const is651940To138 = fromChain === 651940 && toChain === 138; if (is138To651940 || is651940To138) { @@ -139,8 +225,8 @@ export function getRouteFromRegistry( }; } if (fromChain === 138 || toChain === 138) { - const normalizedAsset = asset.trim().toUpperCase(); - const isStableAsset = STABLE_ASSET_SYMBOLS.has(normalizedAsset); + const legacyNormalizedAsset = asset.trim().toUpperCase(); + const isStableAsset = STABLE_ASSET_SYMBOLS.has(legacyNormalizedAsset); if (isStableAsset) { if (CCIP_STABLE_138) { @@ -170,7 +256,7 @@ export function getRouteFromRegistry( return null; } - if (normalizedAsset !== 'WETH' && UNIVERSAL_CCIP_138) { + if (legacyNormalizedAsset !== 'WETH' && UNIVERSAL_CCIP_138) { return { pathType: 'CCIP', bridgeAddress: UNIVERSAL_CCIP_138, diff --git a/services/token-aggregation/src/config/deployment-status.ts b/services/token-aggregation/src/config/deployment-status.ts new file mode 100644 index 0000000..3de3873 --- /dev/null +++ b/services/token-aggregation/src/config/deployment-status.ts @@ -0,0 +1,201 @@ +import fs from 'fs'; +import path from 'path'; + +export interface DeploymentStatusFile { + version?: string; + updated?: string; + chains?: Record< + string, + { + name?: string; + cwTokens?: Record; + gasMirrors?: Record; + gasQuoteAddresses?: Record; + gasPmmPools?: Array>; + gasReferenceVenues?: Array>; + [key: string]: unknown; + } + >; + [key: string]: unknown; +} + +export interface LoadedDeploymentStatus { + data: DeploymentStatusFile; + lastModified?: string; +} + +export interface CwRegistryChain { + chainId: number; + chainIdText: string; + name: string; + tokens: Array<{ + symbol: string; + address: string; + assetClass?: string; + familyKey?: string; + }>; +} + +export interface GasRegistryChain { + chainId: number; + chainIdText: string; + name: string; + families: Array<{ + familyKey: string; + mirroredSymbol: string; + mirrorAddress?: string; + dodoPmm: Array>; + referenceVenues: Array>; + }>; +} + +function uniquePaths(paths: Array): string[] { + const seen = new Set(); + const out: string[] = []; + + for (const candidate of paths) { + if (typeof candidate !== 'string') continue; + const trimmed = candidate.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + } + + return out; +} + +function buildDeploymentStatusCandidates(): string[] { + return uniquePaths([ + process.env.DEPLOYMENT_STATUS_JSON_PATH, + process.env.CW_REGISTRY_JSON_PATH, + process.env.CROSS_CHAIN_PMM_DEPLOYMENT_STATUS_PATH, + path.resolve(process.cwd(), 'cross-chain-pmm-lps/config/deployment-status.json'), + path.resolve(process.cwd(), '..', 'cross-chain-pmm-lps/config/deployment-status.json'), + path.resolve(process.cwd(), '..', '..', 'cross-chain-pmm-lps/config/deployment-status.json'), + path.resolve(__dirname, '../../../../../cross-chain-pmm-lps/config/deployment-status.json'), + ]); +} + +export function resolveDeploymentStatusPath(): string | null { + for (const candidate of buildDeploymentStatusCandidates()) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; +} + +export function loadDeploymentStatusFile(): LoadedDeploymentStatus | null { + const filePath = resolveDeploymentStatusPath(); + if (!filePath) return null; + + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const stat = fs.statSync(filePath); + return { + data: JSON.parse(raw) as DeploymentStatusFile, + lastModified: stat.mtime.toISOString(), + }; + } catch { + return null; + } +} + +export function buildCwRegistryChains(data: DeploymentStatusFile): CwRegistryChain[] { + const chains = data.chains ?? {}; + const rows: CwRegistryChain[] = []; + + for (const [chainIdText, chain] of Object.entries(chains)) { + const gasFamilyByMirror = new Map(); + for (const pool of chain.gasPmmPools ?? []) { + const familyKey = typeof pool.familyKey === 'string' ? pool.familyKey : ''; + const base = typeof pool.base === 'string' ? pool.base : ''; + if (familyKey && base) { + gasFamilyByMirror.set(base, familyKey); + } + } + + const tokens = [ + ...Object.entries(chain.cwTokens ?? {}) + .filter(([, address]) => typeof address === 'string' && address.trim() !== '') + .map(([symbol, address]) => ({ symbol, address })), + ...Object.entries(chain.gasMirrors ?? {}) + .filter(([, address]) => typeof address === 'string' && address.trim() !== '') + .map(([symbol, address]) => ({ + symbol, + address, + assetClass: 'gas_native', + familyKey: gasFamilyByMirror.get(symbol), + })), + ]; + + if (tokens.length === 0) continue; + + rows.push({ + chainId: Number(chainIdText), + chainIdText, + name: chain.name || `Chain ${chainIdText}`, + tokens: tokens.sort((a, b) => a.symbol.localeCompare(b.symbol)), + }); + } + + return rows.sort((a, b) => a.chainId - b.chainId); +} + +export function buildGasRegistryChains(data: DeploymentStatusFile): GasRegistryChain[] { + const rows: GasRegistryChain[] = []; + + for (const [chainIdText, chain] of Object.entries(data.chains ?? {})) { + const familyMap = new Map< + string, + { + familyKey: string; + mirroredSymbol: string; + mirrorAddress?: string; + dodoPmm: Array>; + referenceVenues: Array>; + } + >(); + + for (const pool of chain.gasPmmPools ?? []) { + const familyKey = typeof pool.familyKey === 'string' ? pool.familyKey : ''; + const mirroredSymbol = typeof pool.base === 'string' ? pool.base : ''; + if (!familyKey || !mirroredSymbol) continue; + const existing = familyMap.get(familyKey) ?? { + familyKey, + mirroredSymbol, + mirrorAddress: chain.gasMirrors?.[mirroredSymbol], + dodoPmm: [], + referenceVenues: [], + }; + existing.dodoPmm.push(pool); + familyMap.set(familyKey, existing); + } + + for (const venue of chain.gasReferenceVenues ?? []) { + const familyKey = typeof venue.familyKey === 'string' ? venue.familyKey : ''; + if (!familyKey) continue; + const existing = familyMap.get(familyKey) ?? { + familyKey, + mirroredSymbol: typeof venue.base === 'string' ? venue.base : '', + mirrorAddress: typeof venue.base === 'string' ? chain.gasMirrors?.[venue.base] : undefined, + dodoPmm: [], + referenceVenues: [], + }; + existing.referenceVenues.push(venue); + familyMap.set(familyKey, existing); + } + + const families = Array.from(familyMap.values()).sort((a, b) => a.familyKey.localeCompare(b.familyKey)); + if (families.length === 0) continue; + + rows.push({ + chainId: Number(chainIdText), + chainIdText, + name: chain.name || `Chain ${chainIdText}`, + families, + }); + } + + return rows.sort((a, b) => a.chainId - b.chainId); +} diff --git a/services/token-aggregation/src/config/dex-factories.ts b/services/token-aggregation/src/config/dex-factories.ts index 08b818f..8d41a82 100644 --- a/services/token-aggregation/src/config/dex-factories.ts +++ b/services/token-aggregation/src/config/dex-factories.ts @@ -34,13 +34,18 @@ export interface DexFactoryConfig { custom?: CustomDexConfig[]; } +/** Canonical DODOPMMIntegration on Chain 138 — see docs/11-references/CONTRACT_ADDRESSES_REFERENCE.md */ +const CANONICAL_CHAIN138_DODO_PMM_INTEGRATION = + '0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895'; + export const DEX_FACTORIES: Record = { 138: { // DODO PMM Integration - index from DODOPMMIntegration or PoolManager dodo: [ { poolManager: process.env.CHAIN_138_DODO_POOL_MANAGER || '', - dodoPmmIntegration: process.env.CHAIN_138_DODO_PMM_INTEGRATION || '', + dodoPmmIntegration: + process.env.CHAIN_138_DODO_PMM_INTEGRATION || CANONICAL_CHAIN138_DODO_PMM_INTEGRATION, dodoVendingMachine: process.env.CHAIN_138_DODO_VENDING_MACHINE || '', startBlock: 0, }, diff --git a/services/token-aggregation/src/config/gru-transport.test.ts b/services/token-aggregation/src/config/gru-transport.test.ts new file mode 100644 index 0000000..4226f6d --- /dev/null +++ b/services/token-aggregation/src/config/gru-transport.test.ts @@ -0,0 +1,435 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { resolveTokenMappingLoaderPath } from './repo-config-loader'; +import { getRouteFromRegistry } from './cross-chain-bridges'; +import { + filterPoolsForExposure, + filterPoolsForRouting, + getActiveTransportPairs, + getGasAssetFamilies, + getGasProtocolExposure, + getGasRedeemGroups, + getGruTransportMetadata, + isGasRedemptionPathAllowed, + isPublicPoolActive, + isPublicPoolRoutable, +} from './gru-transport'; + +describe('GRU Transport overlay', () => { + const originalChain138Bridge = process.env.CHAIN138_L1_BRIDGE; + const originalBscBridge = process.env.CW_BRIDGE_BSC; + const originalStableBridge = process.env.CCIP_STABLE_BRIDGE_CHAIN138; + const originalStablecoinBridge = process.env.CCIP_STABLECOIN_BRIDGE_CHAIN138; + const originalUniversalBridge = process.env.UNIVERSAL_CCIP_BRIDGE_ADDRESS; + const originalUniversalBridgeAlt = process.env.UNIVERSAL_CCIP_BRIDGE; + const originalReserveVerifier = process.env.CW_RESERVE_VERIFIER_CHAIN138; + const originalReserveVault = process.env.CW_STABLECOIN_RESERVE_VAULT; + const originalReserveSystem = process.env.CW_RESERVE_SYSTEM; + const originalMaxOutstanding = process.env.CW_MAX_OUTSTANDING_USDT_BSC; + const originalGasStrictVerifier = process.env.CW_GAS_STRICT_ESCROW_VERIFIER_CHAIN138; + const originalGasHybridVerifier = process.env.CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138; + const originalGasEscrowVault = process.env.CW_GAS_ESCROW_VAULT_CHAIN138; + const originalGasTreasurySystem = process.env.CW_GAS_TREASURY_SYSTEM; + const originalMainnetBridge = process.env.CW_BRIDGE_MAINNET; + const originalOptimismBridge = process.env.CW_BRIDGE_OPTIMISM; + const originalEthMainnetOutstanding = process.env.CW_MAX_OUTSTANDING_ETH_MAINNET_MAINNET; + const originalEthMainnetSupply = process.env.CW_GAS_OUTSTANDING_ETH_MAINNET_MAINNET; + const originalEthMainnetEscrowed = process.env.CW_GAS_ESCROWED_ETH_MAINNET_MAINNET; + const originalEthMainnetTreasury = process.env.CW_GAS_TREASURY_BACKED_ETH_MAINNET_MAINNET; + const originalEthL2Outstanding = process.env.CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM; + const originalEthL2Supply = process.env.CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM; + const originalEthL2Escrowed = process.env.CW_GAS_ESCROWED_ETH_L2_OPTIMISM; + const originalEthL2Treasury = process.env.CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM; + const originalEthL2Cap = process.env.CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM; + const originalTokenMappingLoaderPath = process.env.TOKEN_MAPPING_LOADER_PATH; + const originalCwL1Bridge = process.env.CW_L1_BRIDGE; + const originalCwL1BridgeChain138 = process.env.CW_L1_BRIDGE_CHAIN138; + + afterEach(() => { + if (originalChain138Bridge === undefined) { + delete process.env.CHAIN138_L1_BRIDGE; + } else { + process.env.CHAIN138_L1_BRIDGE = originalChain138Bridge; + } + + if (originalCwL1Bridge === undefined) { + delete process.env.CW_L1_BRIDGE; + } else { + process.env.CW_L1_BRIDGE = originalCwL1Bridge; + } + if (originalCwL1BridgeChain138 === undefined) { + delete process.env.CW_L1_BRIDGE_CHAIN138; + } else { + process.env.CW_L1_BRIDGE_CHAIN138 = originalCwL1BridgeChain138; + } + + if (originalBscBridge === undefined) { + delete process.env.CW_BRIDGE_BSC; + } else { + process.env.CW_BRIDGE_BSC = originalBscBridge; + } + + if (originalStableBridge === undefined) { + delete process.env.CCIP_STABLE_BRIDGE_CHAIN138; + } else { + process.env.CCIP_STABLE_BRIDGE_CHAIN138 = originalStableBridge; + } + + if (originalStablecoinBridge === undefined) { + delete process.env.CCIP_STABLECOIN_BRIDGE_CHAIN138; + } else { + process.env.CCIP_STABLECOIN_BRIDGE_CHAIN138 = originalStablecoinBridge; + } + + if (originalUniversalBridge === undefined) { + delete process.env.UNIVERSAL_CCIP_BRIDGE_ADDRESS; + } else { + process.env.UNIVERSAL_CCIP_BRIDGE_ADDRESS = originalUniversalBridge; + } + + if (originalUniversalBridgeAlt === undefined) { + delete process.env.UNIVERSAL_CCIP_BRIDGE; + } else { + process.env.UNIVERSAL_CCIP_BRIDGE = originalUniversalBridgeAlt; + } + + if (originalReserveVerifier === undefined) { + delete process.env.CW_RESERVE_VERIFIER_CHAIN138; + } else { + process.env.CW_RESERVE_VERIFIER_CHAIN138 = originalReserveVerifier; + } + + if (originalReserveVault === undefined) { + delete process.env.CW_STABLECOIN_RESERVE_VAULT; + } else { + process.env.CW_STABLECOIN_RESERVE_VAULT = originalReserveVault; + } + + if (originalReserveSystem === undefined) { + delete process.env.CW_RESERVE_SYSTEM; + } else { + process.env.CW_RESERVE_SYSTEM = originalReserveSystem; + } + + if (originalMaxOutstanding === undefined) { + delete process.env.CW_MAX_OUTSTANDING_USDT_BSC; + } else { + process.env.CW_MAX_OUTSTANDING_USDT_BSC = originalMaxOutstanding; + } + + for (const [key, value] of Object.entries({ + CW_GAS_STRICT_ESCROW_VERIFIER_CHAIN138: originalGasStrictVerifier, + CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138: originalGasHybridVerifier, + CW_GAS_ESCROW_VAULT_CHAIN138: originalGasEscrowVault, + CW_GAS_TREASURY_SYSTEM: originalGasTreasurySystem, + CW_BRIDGE_MAINNET: originalMainnetBridge, + CW_BRIDGE_OPTIMISM: originalOptimismBridge, + CW_MAX_OUTSTANDING_ETH_MAINNET_MAINNET: originalEthMainnetOutstanding, + CW_GAS_OUTSTANDING_ETH_MAINNET_MAINNET: originalEthMainnetSupply, + CW_GAS_ESCROWED_ETH_MAINNET_MAINNET: originalEthMainnetEscrowed, + CW_GAS_TREASURY_BACKED_ETH_MAINNET_MAINNET: originalEthMainnetTreasury, + CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM: originalEthL2Outstanding, + CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM: originalEthL2Supply, + CW_GAS_ESCROWED_ETH_L2_OPTIMISM: originalEthL2Escrowed, + CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM: originalEthL2Treasury, + CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM: originalEthL2Cap, + })) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + if (originalTokenMappingLoaderPath === undefined) { + delete process.env.TOKEN_MAPPING_LOADER_PATH; + } else { + process.env.TOKEN_MAPPING_LOADER_PATH = originalTokenMappingLoaderPath; + } + }); + + it('loads GRU Monetary Transport Layer metadata and active transport pairs', () => { + const metadata = getGruTransportMetadata(); + expect(metadata).not.toBeNull(); + expect(metadata?.system?.name).toBe('GRU Monetary Transport Layer'); + expect(metadata?.system?.shortName).toBe('GRU Transport'); + + const pairs = getActiveTransportPairs(); + expect(pairs.length).toBe(44); + expect(pairs.every((pair) => pair.eligible)).toBe(true); + expect(pairs.every((pair) => typeof pair.runtimeReady === 'boolean')).toBe(true); + expect(pairs.some((pair) => pair.key === '138-25-cUSDT-cWUSDT')).toBe(true); + expect(pairs.some((pair) => pair.key === '138-1-cBTC-cWBTC')).toBe(true); + expect(pairs.some((pair) => pair.key === '138-10-cETHL2-cWETHL2')).toBe(true); + expect(pairs.some((pair) => pair.key === '138-1111-cWEMIX-cWWEMIX')).toBe(false); + const bscUsdt = pairs.find((pair) => pair.key === '138-56-cUSDT-cWUSDT'); + const mainnetEth = pairs.find((pair) => pair.key === '138-1-cETH-cWETH'); + expect(bscUsdt?.bridgeCanonicalAssetVersion).toBe('v1'); + expect(bscUsdt?.bridgeMirroredAssetVersion).toBe('v1'); + expect(bscUsdt?.destinationChainName).toBe('BSC'); + expect(bscUsdt?.destinationChainSelector).toBe('11344663589394136015'); + expect(mainnetEth?.destinationChainSelector).toBe('5009297550715157269'); + expect(metadata?.counts.enabledDestinationChains).toBe(10); + expect(metadata?.counts.configuredTransportPairs).toBe(45); + expect(metadata?.counts.deferredTransportPairs).toBe(1); + expect(metadata?.counts.gasAssetFamilies).toBe(9); + expect(metadata?.counts.gasTransportPairs).toBe(10); + }); + + it('publishes gas-family metadata and enforces the ETH split via redeem groups', () => { + const gasFamilies = getGasAssetFamilies(); + const redeemGroups = getGasRedeemGroups(); + const protocolExposure = getGasProtocolExposure(); + + expect(gasFamilies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + familyKey: 'eth_mainnet', + backingMode: 'strict_escrow', + mirroredSymbol: 'cWETH', + }), + expect.objectContaining({ + familyKey: 'eth_l2', + backingMode: 'hybrid_cap', + mirroredSymbol: 'cWETHL2', + }), + ]) + ); + expect(redeemGroups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + familyKey: 'eth_l2', + allowedChains: [10, 42161, 8453], + }), + ]) + ); + expect(protocolExposure).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: '10-eth_l2', + chainId: 10, + familyKey: 'eth_l2', + }), + expect.objectContaining({ + key: '1111-wemix', + active: false, + status: 'deferred', + }), + ]) + ); + expect(gasFamilies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + familyKey: 'wemix', + active: false, + status: 'deferred', + }), + ]) + ); + expect(isGasRedemptionPathAllowed(10, 42161, 'eth_l2')).toBe(true); + expect(isGasRedemptionPathAllowed(1, 10, 'eth_mainnet')).toBe(false); + }); + + it('keeps Chain 138 pools visible but hides inactive public cW pools', () => { + const pools = [ + { + poolAddress: '0x1111111111111111111111111111111111111111', + token0Address: '0x9a1D0dBEE997929ED02fD19E0E199704d20914dB', + token1Address: '0x55d398326f99059fF775485246999027B3197955', + }, + { + poolAddress: '0x2222222222222222222222222222222222222222', + token0Address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22', + token1Address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + }, + ]; + + expect(filterPoolsForExposure(56, [pools[0]])).toEqual([]); + expect(filterPoolsForRouting(56, [pools[0]])).toEqual([]); + expect(filterPoolsForExposure(138, [pools[1]])).toEqual([pools[1]]); + expect(filterPoolsForRouting(138, [pools[1]])).toEqual([pools[1]]); + }); + + it('marks public cW pools inactive and non-routable until explicitly enabled', () => { + expect(isPublicPoolActive(56, '0x1111111111111111111111111111111111111111')).toBe(false); + expect(isPublicPoolRoutable(56, '0x1111111111111111111111111111111111111111')).toBe(false); + }); + + it('routes active c* transport through GRU Transport while keeping WETH on legacy lanes', () => { + process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333'; + process.env.CW_BRIDGE_BSC = '0x4444444444444444444444444444444444444444'; + process.env.CW_RESERVE_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555'; + process.env.CW_STABLECOIN_RESERVE_VAULT = '0x6666666666666666666666666666666666666666'; + process.env.CW_RESERVE_SYSTEM = '0x7777777777777777777777777777777777777777'; + process.env.CW_MAX_OUTSTANDING_USDT_BSC = '1000000'; + + const gruRoute = getRouteFromRegistry(138, 56, 'cUSDT'); + expect(gruRoute).not.toBeNull(); + expect(gruRoute?.label).toBe('GRUTransport'); + expect(gruRoute?.bridgeAddress).toBe('0x3333333333333333333333333333333333333333'); + + const wethRoute = getRouteFromRegistry(138, 56, 'WETH'); + expect(wethRoute).not.toBeNull(); + expect(wethRoute?.label).toBe('CCIPWETH9Bridge'); + }); + + it('reports runtime-ready transport pairs when bridge and reserve refs resolve', () => { + process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333'; + process.env.CW_BRIDGE_BSC = '0x4444444444444444444444444444444444444444'; + process.env.CW_RESERVE_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555'; + process.env.CW_STABLECOIN_RESERVE_VAULT = '0x6666666666666666666666666666666666666666'; + process.env.CW_RESERVE_SYSTEM = '0x7777777777777777777777777777777777777777'; + process.env.CW_MAX_OUTSTANDING_USDT_BSC = '1000000'; + + const pairs = getActiveTransportPairs(); + const bscUsdtPair = pairs.find((pair) => pair.key === '138-56-cUSDT-cWUSDT'); + expect(bscUsdtPair?.runtimeBridgeReady).toBe(true); + expect(bscUsdtPair?.runtimeReserveVerifierReady).toBe(true); + expect(bscUsdtPair?.runtimeMaxOutstandingReady).toBe(true); + expect(bscUsdtPair?.runtimeReady).toBe(true); + + const metadata = getGruTransportMetadata(); + expect(metadata?.counts.runtimeReadyTransportPairs).toBeGreaterThan(0); + }); + + it('reports missing runtime requirements when bridge and reserve refs are absent', () => { + delete process.env.CHAIN138_L1_BRIDGE; + delete process.env.CW_L1_BRIDGE; + delete process.env.CW_L1_BRIDGE_CHAIN138; + delete process.env.CW_BRIDGE_BSC; + delete process.env.CW_RESERVE_VERIFIER_CHAIN138; + delete process.env.CW_STABLECOIN_RESERVE_VAULT; + delete process.env.CW_RESERVE_SYSTEM; + delete process.env.CW_MAX_OUTSTANDING_USDT_BSC; + + const pairs = getActiveTransportPairs(); + const bscUsdtPair = pairs.find((pair) => pair.key === '138-56-cUSDT-cWUSDT'); + expect(bscUsdtPair?.eligible).toBe(true); + expect(bscUsdtPair?.runtimeReady).toBe(false); + expect(bscUsdtPair?.runtimeMissingRequirements).toEqual( + expect.arrayContaining([ + 'bridge:l1Bridge', + 'bridge:l2Bridge', + 'policy:maxOutstanding', + 'reserveVerifier:bridgeRef', + 'reserveVerifier:vaultRef', + 'reserveVerifier:reserveSystemRef', + ]) + ); + }); + + it('evaluates gas-lane supply accounting separately for strict and hybrid backing', () => { + process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333'; + process.env.CW_BRIDGE_MAINNET = '0x4444444444444444444444444444444444444444'; + process.env.CW_GAS_STRICT_ESCROW_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555'; + process.env.CW_GAS_ESCROW_VAULT_CHAIN138 = '0x6666666666666666666666666666666666666666'; + process.env.CW_MAX_OUTSTANDING_ETH_MAINNET_MAINNET = '100'; + process.env.CW_GAS_OUTSTANDING_ETH_MAINNET_MAINNET = '100'; + process.env.CW_GAS_ESCROWED_ETH_MAINNET_MAINNET = '100'; + process.env.CW_GAS_TREASURY_BACKED_ETH_MAINNET_MAINNET = '0'; + + process.env.CW_BRIDGE_OPTIMISM = '0x7777777777777777777777777777777777777777'; + process.env.CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138 = '0x8888888888888888888888888888888888888888'; + process.env.CW_GAS_TREASURY_SYSTEM = '0x9999999999999999999999999999999999999999'; + process.env.CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM = '125'; + process.env.CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM = '125'; + process.env.CW_GAS_ESCROWED_ETH_L2_OPTIMISM = '100'; + process.env.CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM = '25'; + process.env.CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM = '25'; + + const pairs = getActiveTransportPairs(); + const strictPair = pairs.find((pair) => pair.key === '138-1-cETH-cWETH'); + const hybridPair = pairs.find((pair) => pair.key === '138-10-cETHL2-cWETHL2'); + + expect(strictPair?.runtimeSupplyAccountingReady).toBe(true); + expect(strictPair?.supplyInvariantSatisfied).toBe(true); + expect(strictPair?.runtimeReady).toBe(true); + + expect(hybridPair?.runtimeSupplyAccountingReady).toBe(true); + expect(hybridPair?.supplyInvariantSatisfied).toBe(true); + expect(hybridPair?.runtimeReady).toBe(true); + }); + + it('prefers the active GRU mirrored address even when raw pair ordering is ambiguous', () => { + const loader = require(path.join(process.cwd(), '../../..', 'config', 'token-mapping-loader.cjs')) as { + getMappedAddress: (fromChainId: number, toChainId: number, tokenAddressOnSource: string, jsonPath?: string) => string | undefined; + }; + const fixturePath = path.join(os.tmpdir(), `gru-transport-mapping-${Date.now()}.json`); + const canonicalAddress = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'; + const mirroredAddress = '0x9a1D0dBEE997929ED02fD19E0E199704d20914dB'; + const nativeAddress = '0x55d398326f99059fF775485246999027B3197955'; + + fs.writeFileSync( + fixturePath, + JSON.stringify( + { + pairs: [ + { + fromChainId: 138, + toChainId: 56, + tokens: [ + { + key: 'Compliant_USDT_cW', + name: 'cUSDT->cWUSDT', + addressFrom: canonicalAddress, + addressTo: mirroredAddress, + }, + { + key: 'Compliant_USDT', + name: 'cUSDT', + addressFrom: canonicalAddress, + addressTo: nativeAddress, + }, + ], + }, + ], + }, + null, + 2 + ) + ); + + try { + expect(loader.getMappedAddress(138, 56, canonicalAddress, fixturePath)).toBe(mirroredAddress); + } finally { + fs.unlinkSync(fixturePath); + } + }); + + it('does not fall back to legacy stable bridges for active GRU assets when peer bridges are missing', () => { + delete process.env.CHAIN138_L1_BRIDGE; + delete process.env.CW_L1_BRIDGE; + delete process.env.CW_L1_BRIDGE_CHAIN138; + delete process.env.CW_BRIDGE_BSC; + process.env.CCIP_STABLE_BRIDGE_CHAIN138 = '0x5555555555555555555555555555555555555555'; + process.env.UNIVERSAL_CCIP_BRIDGE_ADDRESS = '0x6666666666666666666666666666666666666666'; + + jest.resetModules(); + const { getRouteFromRegistry: getFreshRouteFromRegistry } = require('./cross-chain-bridges') as typeof import('./cross-chain-bridges'); + + expect(getFreshRouteFromRegistry(138, 56, 'cUSDT')).toBeNull(); + }); + + it('resolves the token mapping loader from an explicit deployed-layout env override', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'token-mapping-loader-')); + const configDir = path.join(tempRoot, 'config'); + fs.mkdirSync(configDir, { recursive: true }); + const loaderPath = path.join(configDir, 'token-mapping-loader.cjs'); + + fs.writeFileSync( + loaderPath, + 'module.exports = { getGruTransportMetadata: () => ({ system: { shortName: "Test" }, counts: { transportPairs: 0 } }), getActiveTransportPairs: () => [] };' + ); + + process.env.TOKEN_MAPPING_LOADER_PATH = loaderPath; + + try { + expect(resolveTokenMappingLoaderPath()).toBe(loaderPath); + expect(getGruTransportMetadata()?.system?.shortName).toBe('Test'); + expect(getActiveTransportPairs()).toEqual([]); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/services/token-aggregation/src/config/gru-transport.ts b/services/token-aggregation/src/config/gru-transport.ts new file mode 100644 index 0000000..932321d --- /dev/null +++ b/services/token-aggregation/src/config/gru-transport.ts @@ -0,0 +1,338 @@ +import { loadTokenMappingLoader } from './repo-config-loader'; + +export interface ConfigRef { + address?: string | null; + env?: string | null; +} + +export interface GruTransportSystemMetadata { + name: string; + shortName: string; + canonicalChainId: number; + canonicalChainName?: string; + transportClass?: string; + publicPoolModel?: string; + hardPegTruth?: string; + wethTransportSeparated?: boolean; + notes?: string; +} + +export interface GruTransportMetadata { + system: GruTransportSystemMetadata | null; + terminology: Record; + enabledCanonicalTokens: Array>; + enabledDestinationChains: Array>; + gasAssetFamilies?: GruTransportGasAssetFamily[]; + gasRedeemGroups?: GruTransportGasRedeemGroup[]; + gasProtocolExposure?: GruTransportGasProtocolExposure[]; + counts: { + enabledCanonicalTokens: number; + enabledDestinationChains: number; + approvedBridgePeers: number; + transportPairs: number; + configuredTransportPairs?: number; + deferredTransportPairs?: number; + gasAssetFamilies?: number; + gasRedeemGroups?: number; + gasProtocolExposure?: number; + gasTransportPairs?: number; + strictEscrowTransportPairs?: number; + hybridCapTransportPairs?: number; + eligibleTransportPairs?: number; + runtimeReadyTransportPairs?: number; + publicPools: number; + activePublicPools?: number; + routablePublicPools?: number; + mcpVisiblePublicPools?: number; + }; +} + +export interface GruTransportGasAssetFamily { + familyKey: string; + active?: boolean; + status?: string; + canonicalSymbol138: string; + mirroredSymbol: string; + assetClass: string; + originChains: number[]; + laneGroup: string; + backingMode: string; + redeemPolicy: string; + wrappedNativeQuoteSymbol: string; + stableQuoteSymbol: string; + referenceVenue: string; + perLaneCaps?: Record; + displayAliases?: Record; + hubRebalance?: Record; +} + +export interface GruTransportGasRedeemGroup { + key: string; + familyKey: string; + allowedChains: number[]; + redeemPolicy: string; + description?: string; +} + +export interface GruTransportGasProtocolExposure { + key: string; + chainId: number; + active?: boolean; + status?: string; + familyKey: string; + mirroredSymbol: string; + backingMode: string; + dodoPmm?: Record; + uniswapV3?: Record; + balancer?: Record; + curve?: Record; + oneInch?: Record; +} + +export interface GruTransportBridgePeer { + key: string; + chainId: number; + chainName: string; + ccipChainSelector?: string; + active?: boolean; + status?: string; + bridgeKind: string; + l1Bridge?: ConfigRef; + l2Bridge?: ConfigRef; + freezeTokenPairRequired?: boolean; + freezeDestinationRequired?: boolean; +} + +export interface GruTransportPair { + key: string; + canonicalChainId: number; + destinationChainId: number; + destinationChainName?: string | null; + destinationChainSelector?: string | null; + active?: boolean; + status?: string; + canonicalSymbol: string; + mirroredSymbol: string; + /** From gru-transport-active enabledCanonicalTokens[].bridge.canonicalAssetVersion */ + bridgeCanonicalAssetVersion?: string; + /** From gru-transport-active enabledCanonicalTokens[].bridge.mirroredAssetVersion */ + bridgeMirroredAssetVersion?: string; + mappingKey: string; + peerKey: string; + phase?: string; + routeDiscoveryEnabled?: boolean; + mcpVisible?: boolean; + reserveVerifierKey?: string; + maxOutstanding?: { + required?: boolean; + amount?: string; + env?: string; + }; + publicPoolKeys?: string[]; + assetClass?: string; + familyKey?: string; + laneGroup?: string; + backingMode?: string; + redeemPolicy?: string; + wrappedNativeQuoteSymbol?: string; + stableQuoteSymbol?: string; + referenceVenue?: string; + protocolExposureKey?: string; + supplyAccounting?: Record; + canonicalAddress?: string | null; + mirroredAddress?: string | null; + mirrorDeploymentAddress?: string | null; + peer?: GruTransportBridgePeer | null; + mappingFound?: boolean; + mirrorDeployed?: boolean; + canonicalEnabled?: boolean; + destinationEnabled?: boolean; + bridgeAvailable?: boolean | null; + bridgePeerConfigured?: boolean; + maxOutstandingConfigured?: boolean; + reserveVerifierConfigured?: boolean; + runtimeL1BridgeAddress?: string | null; + runtimeL2BridgeAddress?: string | null; + runtimeBridgeReady?: boolean; + runtimeMaxOutstandingValue?: string | null; + runtimeMaxOutstandingReady?: boolean; + runtimeReserveVerifierBridgeAddress?: string | null; + runtimeReserveVerifierAddress?: string | null; + runtimeReserveVaultAddress?: string | null; + runtimeReserveSystemAddress?: string | null; + runtimeReserveVerifierReady?: boolean; + runtimeOutstandingValue?: string | null; + runtimeEscrowedValue?: string | null; + runtimeTreasuryBackedValue?: string | null; + runtimeTreasuryCapValue?: string | null; + runtimeSupplyAccountingReady?: boolean | null; + supplyInvariantSatisfied?: boolean | null; + protocolExposure?: GruTransportGasProtocolExposure | null; + runtimeMissingRequirements?: string[]; + eligibilityBlockers?: string[]; + runtimeReady?: boolean; + eligible?: boolean; +} + +export interface GruTransportPublicPool { + key: string; + chainId: number; + baseSymbol: string; + quoteSymbol: string; + poolAddress?: string | null; + active?: boolean; + routingEnabled?: boolean; + mcpVisible?: boolean; + phase?: string; +} + +export interface PublicPoolLike { + poolAddress: string; + token0Address: string; + token1Address: string; +} + +interface GruTransportLoader { + getGruTransportMetadata?: () => GruTransportMetadata | null; + getGasAssetFamilies?: () => GruTransportGasAssetFamily[]; + getGasRedeemGroups?: () => GruTransportGasRedeemGroup[]; + getGasProtocolExposure?: () => GruTransportGasProtocolExposure[]; + isGasRedemptionPathAllowed?: (fromChainId: number, toChainId: number, identifier: string) => boolean; + getActiveTransportPairs?: () => GruTransportPair[]; + getActiveTransportPair?: ( + fromChainId: number, + toChainId: number, + criteria?: Record + ) => GruTransportPair | null; + getApprovedBridgePeer?: (chainId: number) => GruTransportBridgePeer | null; + getActivePublicPools?: () => GruTransportPublicPool[]; + isPublicPoolActive?: (chainId: number, poolAddress: string) => boolean; + isPublicPoolRoutable?: (chainId: number, poolAddress: string) => boolean; + isPublicPoolMcpVisible?: (chainId: number, poolAddress: string) => boolean; + shouldExposePublicPool?: ( + chainId: number, + poolAddress: string, + token0Address: string, + token1Address: string + ) => boolean; + shouldUsePublicPoolForRouting?: ( + chainId: number, + poolAddress: string, + token0Address: string, + token1Address: string + ) => boolean; + resolveConfigRef?: (ref: ConfigRef | undefined) => string; +} + +function loadGruTransportLoader(): GruTransportLoader | null { + const loader = loadTokenMappingLoader(); + if (loader?.getGruTransportMetadata && loader?.getActiveTransportPairs) { + return loader; + } + return null; +} + +export function getGruTransportMetadata(): GruTransportMetadata | null { + const loader = loadGruTransportLoader(); + return loader?.getGruTransportMetadata?.() ?? null; +} + +export function getGasAssetFamilies(): GruTransportGasAssetFamily[] { + const loader = loadGruTransportLoader(); + return loader?.getGasAssetFamilies?.() ?? getGruTransportMetadata()?.gasAssetFamilies ?? []; +} + +export function getGasRedeemGroups(): GruTransportGasRedeemGroup[] { + const loader = loadGruTransportLoader(); + return loader?.getGasRedeemGroups?.() ?? getGruTransportMetadata()?.gasRedeemGroups ?? []; +} + +export function getGasProtocolExposure(): GruTransportGasProtocolExposure[] { + const loader = loadGruTransportLoader(); + return loader?.getGasProtocolExposure?.() ?? getGruTransportMetadata()?.gasProtocolExposure ?? []; +} + +export function isGasRedemptionPathAllowed( + fromChainId: number, + toChainId: number, + identifier: string +): boolean { + const loader = loadGruTransportLoader(); + return loader?.isGasRedemptionPathAllowed?.(fromChainId, toChainId, identifier) ?? false; +} + +export function getActiveTransportPairs(): GruTransportPair[] { + const loader = loadGruTransportLoader(); + return loader?.getActiveTransportPairs?.() ?? []; +} + +export function getActiveTransportPairBySymbol( + fromChainId: number, + toChainId: number, + symbol: string +): GruTransportPair | null { + const loader = loadGruTransportLoader(); + return loader?.getActiveTransportPair?.(fromChainId, toChainId, { symbol }) ?? null; +} + +export function getApprovedBridgePeerByChain(chainId: number): GruTransportBridgePeer | null { + const loader = loadGruTransportLoader(); + return loader?.getApprovedBridgePeer?.(chainId) ?? null; +} + +export function resolveConfigRef(ref: ConfigRef | undefined): string { + const loader = loadGruTransportLoader(); + return loader?.resolveConfigRef?.(ref) ?? ''; +} + +export function getActivePublicPools(): GruTransportPublicPool[] { + const loader = loadGruTransportLoader(); + return loader?.getActivePublicPools?.() ?? []; +} + +export function isPublicPoolActive(chainId: number, poolAddress: string): boolean { + const loader = loadGruTransportLoader(); + return loader?.isPublicPoolActive?.(chainId, poolAddress) ?? true; +} + +export function isPublicPoolRoutable(chainId: number, poolAddress: string): boolean { + const loader = loadGruTransportLoader(); + return loader?.isPublicPoolRoutable?.(chainId, poolAddress) ?? true; +} + +export function isPublicPoolMcpVisible(chainId: number, poolAddress: string): boolean { + const loader = loadGruTransportLoader(); + return loader?.isPublicPoolMcpVisible?.(chainId, poolAddress) ?? false; +} + +export function shouldExposePublicPool( + chainId: number, + poolAddress: string, + token0Address: string, + token1Address: string +): boolean { + const loader = loadGruTransportLoader(); + return loader?.shouldExposePublicPool?.(chainId, poolAddress, token0Address, token1Address) ?? true; +} + +export function shouldUsePublicPoolForRouting( + chainId: number, + poolAddress: string, + token0Address: string, + token1Address: string +): boolean { + const loader = loadGruTransportLoader(); + return loader?.shouldUsePublicPoolForRouting?.(chainId, poolAddress, token0Address, token1Address) ?? true; +} + +export function filterPoolsForExposure(chainId: number, pools: T[]): T[] { + return pools.filter((pool) => + shouldExposePublicPool(chainId, pool.poolAddress, pool.token0Address, pool.token1Address) + ); +} + +export function filterPoolsForRouting(chainId: number, pools: T[]): T[] { + return pools.filter((pool) => + shouldUsePublicPoolForRouting(chainId, pool.poolAddress, pool.token0Address, pool.token1Address) + ); +} diff --git a/services/token-aggregation/src/config/heatmap-chains.ts b/services/token-aggregation/src/config/heatmap-chains.ts index d676886..82004f6 100644 --- a/services/token-aggregation/src/config/heatmap-chains.ts +++ b/services/token-aggregation/src/config/heatmap-chains.ts @@ -3,6 +3,8 @@ * Aligns with real-robinhood project_plans and ultra_advanced_global_arbitrage_engine_blueprint. */ +import { resolveChain138RpcUrl } from './chain138-rpc'; + export type ChainGroup = 'hub' | 'edge' | 'althub' | 'external'; export interface HeatmapChain { @@ -57,9 +59,9 @@ export const DEFAULT_HEATMAP_ASSETS = [ function buildChains(): HeatmapChain[] { const rpc = (cid: number) => - process.env[`CHAIN_${cid}_RPC_URL`] || - process.env[`RPC_URL_138`] || - 'https://rpc.d-bis.org'; + cid === 138 + ? resolveChain138RpcUrl() + : process.env[`CHAIN_${cid}_RPC_URL`] || 'https://rpc.d-bis.org'; const explorer = (cid: number) => { const urls: Record = { 138: 'https://explorer.d-bis.org', diff --git a/services/token-aggregation/src/config/monetary-unit-symbol-registry.test.ts b/services/token-aggregation/src/config/monetary-unit-symbol-registry.test.ts new file mode 100644 index 0000000..9221429 --- /dev/null +++ b/services/token-aggregation/src/config/monetary-unit-symbol-registry.test.ts @@ -0,0 +1,23 @@ +import { + getMonetaryUnitByCode, + getMonetaryUnitBySymbol, + isMonetaryUnitSupported, +} from './monetary-unit-symbol-registry'; + +describe('monetary-unit symbol registry', () => { + it('tracks BTC as a non-ISO GRU monetary unit family', () => { + expect(isMonetaryUnitSupported('BTC')).toBe(true); + expect(getMonetaryUnitByCode('BTC')).toMatchObject({ + code: 'BTC', + canonicalSymbol: 'cBTC', + wrappedSymbol: 'cWBTC', + mappingKey: 'Compliant_BTC_cW', + decimals: 8, + }); + expect(getMonetaryUnitBySymbol('cWBTC')).toMatchObject({ + code: 'BTC', + canonicalSymbol: 'cBTC', + wrappedSymbol: 'cWBTC', + }); + }); +}); diff --git a/services/token-aggregation/src/config/monetary-unit-symbol-registry.ts b/services/token-aggregation/src/config/monetary-unit-symbol-registry.ts new file mode 100644 index 0000000..4a2b5c7 --- /dev/null +++ b/services/token-aggregation/src/config/monetary-unit-symbol-registry.ts @@ -0,0 +1,132 @@ +import fs from 'fs'; +import path from 'path'; + +export interface MonetaryUnitSymbolIdentity { + code: string; + canonicalSymbol: string; + wrappedSymbol: string; + mappingKey: string; + decimals: number; +} + +interface MonetaryUnitManifestEntry { + code: string; + canonicalSymbol: string; + wrappedSymbol: string; + mappingKey: string; + decimals: number; +} + +interface MonetaryUnitManifestFile { + monetaryUnits?: MonetaryUnitManifestEntry[]; +} + +function uniquePaths(paths: Array): string[] { + const seen = new Set(); + const out: string[] = []; + + for (const candidate of paths) { + if (typeof candidate !== 'string') continue; + const trimmed = candidate.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + } + + return out; +} + +function resolveMonetaryUnitManifestPath(): string | null { + const candidates = uniquePaths([ + process.env.GRU_MONETARY_UNIT_MANIFEST_PATH, + process.env.MONETARY_UNIT_MANIFEST_JSON_PATH, + path.resolve(process.cwd(), 'config/gru-monetary-unit-manifest.json'), + path.resolve(process.cwd(), '../config/gru-monetary-unit-manifest.json'), + path.resolve(process.cwd(), '../../config/gru-monetary-unit-manifest.json'), + path.resolve('/config/gru-monetary-unit-manifest.json'), + path.resolve(__dirname, '../../../../../config/gru-monetary-unit-manifest.json'), + ]); + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + + return null; +} + +function loadMonetaryUnitManifest(): MonetaryUnitManifestFile { + const filePath = resolveMonetaryUnitManifestPath(); + if (!filePath) return {}; + + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) as MonetaryUnitManifestFile; + } catch { + return {}; + } +} + +const monetaryUnitManifest = loadMonetaryUnitManifest(); + +const MONETARY_UNIT_ENTRIES = ( + Array.isArray(monetaryUnitManifest.monetaryUnits) + ? monetaryUnitManifest.monetaryUnits + : [] +).map((entry) => ({ + code: String(entry.code || '').trim().toUpperCase(), + canonicalSymbol: String(entry.canonicalSymbol || '').trim(), + wrappedSymbol: String(entry.wrappedSymbol || '').trim(), + mappingKey: String(entry.mappingKey || '').trim(), + decimals: Number(entry.decimals || 0), +})); + +export const MONETARY_UNIT_SUPPORTED = MONETARY_UNIT_ENTRIES.map((entry) => entry.code) as string[]; + +export const MONETARY_UNIT_BY_CODE: Record = Object.fromEntries( + MONETARY_UNIT_ENTRIES.map((entry) => [ + entry.code, + { + code: entry.code, + canonicalSymbol: entry.canonicalSymbol, + wrappedSymbol: entry.wrappedSymbol, + mappingKey: entry.mappingKey, + decimals: entry.decimals, + }, + ]) +); + +export const MONETARY_UNIT_BY_SYMBOL: Record = Object.fromEntries( + MONETARY_UNIT_ENTRIES.flatMap((entry) => [ + [ + entry.canonicalSymbol, + { + code: entry.code, + canonicalSymbol: entry.canonicalSymbol, + wrappedSymbol: entry.wrappedSymbol, + mappingKey: entry.mappingKey, + decimals: entry.decimals, + }, + ], + [ + entry.wrappedSymbol, + { + code: entry.code, + canonicalSymbol: entry.canonicalSymbol, + wrappedSymbol: entry.wrappedSymbol, + mappingKey: entry.mappingKey, + decimals: entry.decimals, + }, + ], + ]) +); + +export function isMonetaryUnitSupported(code: string): boolean { + return Boolean(MONETARY_UNIT_BY_CODE[String(code || '').trim().toUpperCase()]); +} + +export function getMonetaryUnitByCode(code: string): MonetaryUnitSymbolIdentity | undefined { + return MONETARY_UNIT_BY_CODE[String(code || '').trim().toUpperCase()]; +} + +export function getMonetaryUnitBySymbol(symbol: string): MonetaryUnitSymbolIdentity | undefined { + return MONETARY_UNIT_BY_SYMBOL[String(symbol || '').trim()]; +} diff --git a/services/token-aggregation/src/config/provider-capabilities.ts b/services/token-aggregation/src/config/provider-capabilities.ts new file mode 100644 index 0000000..764f9ab --- /dev/null +++ b/services/token-aggregation/src/config/provider-capabilities.ts @@ -0,0 +1,563 @@ +import { AbiCoder } from 'ethers'; +import { + PlannerProvider, + ProviderCapabilityRecord, + ProviderPairCapability, +} from '../services/planner-v2-types'; +import { encodeChain138DodoV3ProviderData, isChain138DodoV3ExecutionLive } from '../services/dodo-v3-pilot'; +import { getChain138PilotVenueEdges } from '../services/chain138-pilot-venues'; +import { getChain138RoutingAssets } from './routing-assets'; + +const abiCoder = AbiCoder.defaultAbiCoder(); +const CHAIN_138 = 138; +const CHAIN138_UNISWAP_V3_ROUTER = '0xde9cd8ee2811e6e64a41d5f68be315d33995975e'; +const CHAIN138_UNISWAP_V3_QUOTER = '0x6abbb1ceb2468e748a03a00cd6aa9bfe893afa1f'; +const CHAIN138_PILOT_BALANCER_VAULT = '0x96423d7c1727698d8a25ebfb88131e9422d1a3c3'; +const CHAIN138_PILOT_CURVE_3POOL = '0xe440ec15805be4c7babcd17a63b8c8a08a492e0f'; +const CHAIN138_PILOT_ONEINCH_ROUTER = '0x500b84b1bc6f59c1898a5fe538ea20a758757a4f'; +const CHAIN138_PILOT_BALANCER_WETH_USDT_POOL_ID = '0x877cd220759e8c94b82f55450c85d382ae06856c426b56d93092a420facbc324'; +const CHAIN138_PILOT_BALANCER_WETH_USDC_POOL_ID = '0xd8dfb18a6baf9b29d8c2dbd74639db87ac558af120df5261dab8e2a5de69013b'; + +function normalizeAddress(value?: string): string { + return String(value || '').trim().toLowerCase(); +} + +function liveOrPlannedAddress(value?: string): 'live' | 'planned' { + return normalizeAddress(value) ? 'live' : 'planned'; +} + +function bidirectionalPair(args: { + chainId: number; + provider: PlannerProvider; + tokenASymbol: string; + tokenAAddress: string; + tokenBSymbol: string; + tokenBAddress: string; + status: 'live' | 'planned' | 'blocked'; + target?: string; + providerData?: Record; + providerDataHex?: string; + notes?: string[]; + reason?: string; +}): ProviderPairCapability[] { + return [ + { + chainId: args.chainId, + provider: args.provider, + legType: 'swap', + status: args.status, + tokenInSymbol: args.tokenASymbol, + tokenInAddress: normalizeAddress(args.tokenAAddress), + tokenOutSymbol: args.tokenBSymbol, + tokenOutAddress: normalizeAddress(args.tokenBAddress), + target: normalizeAddress(args.target), + providerData: args.providerData, + providerDataHex: args.providerDataHex, + notes: args.notes, + reason: args.reason, + }, + { + chainId: args.chainId, + provider: args.provider, + legType: 'swap', + status: args.status, + tokenInSymbol: args.tokenBSymbol, + tokenInAddress: normalizeAddress(args.tokenBAddress), + tokenOutSymbol: args.tokenASymbol, + tokenOutAddress: normalizeAddress(args.tokenAAddress), + target: normalizeAddress(args.target), + providerData: args.providerData, + providerDataHex: args.providerDataHex, + notes: args.notes, + reason: args.reason, + }, + ]; +} + +function encodeDodoPool(poolAddress: string): string { + return abiCoder.encode(['address'], [poolAddress]); +} + +function encodeUniswapRoute(fee: number, quoter: string): string { + return abiCoder.encode(['bytes', 'uint24', 'address', 'bool'], ['0x', fee, quoter, false]); +} + +function encodeBalancerRoute(poolId: string): string { + return abiCoder.encode(['bytes32'], [poolId]); +} + +function encodeCurveRoute(i: number, j: number, useUnderlying: boolean): string { + return abiCoder.encode(['int128', 'int128', 'bool'], [i, j, useUnderlying]); +} + +function encodeOneInchRoute(router: string): string { + return abiCoder.encode(['address', 'address', 'bytes'], [router, router, '0x']); +} + +function chain138DodoCapabilities(): ProviderCapabilityRecord { + const assets = getChain138RoutingAssets(); + const dodoProvider = + normalizeAddress(process.env.DODO_PMM_PROVIDER_ADDRESS) || + normalizeAddress(process.env.DODO_PMM_PROVIDER) || + '0x3f729632e9553ebaccde2e9b4c8f2b285b014f2e'; + + const stablePool = '0x9e89bae009adf128782e19e8341996c596ac40dc'; + const cusdtUsdtPool = '0x866cb44b59303d8dc5f4f9e3e7a8e8b0bf238d66'; + const cusdcUsdcPool = '0xc39b7d0f40838cbfb54649d327f49a6dac964062'; + const cusdtXaucPool = '0x1aa55e2001e5651349aff5a63fd7a7ae44f0f1b0'; + const cusdcXaucPool = '0xea9ac6357cacb42a83b9082b870610363b177cba'; + const ceurtXaucPool = '0xba99bc1eaac164569d5aca96c806934ddaf970cf'; + const cbtcCusdtPool = normalizeAddress(process.env.CHAIN138_POOL_CBTC_CUSDT); + const cbtcCusdcPool = normalizeAddress(process.env.CHAIN138_POOL_CBTC_CUSDC); + const cbtcXaucPool = normalizeAddress(process.env.CHAIN138_POOL_CBTC_CXAUC); + const wethUsdtPool = normalizeAddress(process.env.CHAIN138_POOL_WETH_USDT); + const wethUsdcPool = normalizeAddress(process.env.CHAIN138_POOL_WETH_USDC); + + const pairs: ProviderPairCapability[] = [ + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'dodo', + tokenASymbol: 'cUSDT', + tokenAAddress: assets.cUSDT.address, + tokenBSymbol: 'cUSDC', + tokenBAddress: assets.cUSDC.address, + status: 'live', + target: dodoProvider, + providerData: { poolAddress: stablePool }, + providerDataHex: encodeDodoPool(stablePool), + notes: ['Canonical stable pool on the official DODO V2 DVM-backed stack.'], + }), + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'dodo', + tokenASymbol: 'cUSDT', + tokenAAddress: assets.cUSDT.address, + tokenBSymbol: 'USDT', + tokenBAddress: assets.USDT.address, + status: 'live', + target: dodoProvider, + providerData: { poolAddress: cusdtUsdtPool }, + providerDataHex: encodeDodoPool(cusdtUsdtPool), + notes: ['Canonical stable pool on the official DODO V2 DVM-backed stack.'], + }), + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'dodo', + tokenASymbol: 'cUSDC', + tokenAAddress: assets.cUSDC.address, + tokenBSymbol: 'USDC', + tokenBAddress: assets.USDC.address, + status: 'live', + target: dodoProvider, + providerData: { poolAddress: cusdcUsdcPool }, + providerDataHex: encodeDodoPool(cusdcUsdcPool), + notes: ['Canonical stable pool on the official DODO V2 DVM-backed stack.'], + }), + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'dodo', + tokenASymbol: 'cUSDT', + tokenAAddress: assets.cUSDT.address, + tokenBSymbol: 'cXAUC', + tokenBAddress: assets.cXAUC.address, + status: 'live', + target: dodoProvider, + providerData: { poolAddress: cusdtXaucPool }, + providerDataHex: encodeDodoPool(cusdtXaucPool), + notes: ['Commodity route; excluded unless policy allows commodity intermediates.'], + }), + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'dodo', + tokenASymbol: 'cUSDC', + tokenAAddress: assets.cUSDC.address, + tokenBSymbol: 'cXAUC', + tokenBAddress: assets.cXAUC.address, + status: 'live', + target: dodoProvider, + providerData: { poolAddress: cusdcXaucPool }, + providerDataHex: encodeDodoPool(cusdcXaucPool), + notes: ['Commodity route; excluded unless policy allows commodity intermediates.'], + }), + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'dodo', + tokenASymbol: 'cEURT', + tokenAAddress: assets.cEURT.address, + tokenBSymbol: 'cXAUC', + tokenBAddress: assets.cXAUC.address, + status: 'live', + target: dodoProvider, + providerData: { poolAddress: ceurtXaucPool }, + providerDataHex: encodeDodoPool(ceurtXaucPool), + notes: ['Commodity route; excluded unless policy allows commodity intermediates.'], + }), + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'dodo', + tokenASymbol: 'cBTC', + tokenAAddress: assets.cBTC.address, + tokenBSymbol: 'cUSDT', + tokenBAddress: assets.cUSDT.address, + status: liveOrPlannedAddress(cbtcCusdtPool), + target: dodoProvider, + providerData: cbtcCusdtPool ? { poolAddress: cbtcCusdtPool } : undefined, + providerDataHex: cbtcCusdtPool ? encodeDodoPool(cbtcCusdtPool) : undefined, + notes: ['Bitcoin monetary-unit route for the jewelry-box program.'], + reason: cbtcCusdtPool ? undefined : 'Set CHAIN138_POOL_CBTC_CUSDT after the canonical cBTC/cUSDT PMM pool is created and funded.', + }), + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'dodo', + tokenASymbol: 'cBTC', + tokenAAddress: assets.cBTC.address, + tokenBSymbol: 'cUSDC', + tokenBAddress: assets.cUSDC.address, + status: liveOrPlannedAddress(cbtcCusdcPool), + target: dodoProvider, + providerData: cbtcCusdcPool ? { poolAddress: cbtcCusdcPool } : undefined, + providerDataHex: cbtcCusdcPool ? encodeDodoPool(cbtcCusdcPool) : undefined, + notes: ['Bitcoin monetary-unit route for the jewelry-box program.'], + reason: cbtcCusdcPool ? undefined : 'Set CHAIN138_POOL_CBTC_CUSDC after the canonical cBTC/cUSDC PMM pool is created and funded.', + }), + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'dodo', + tokenASymbol: 'cBTC', + tokenAAddress: assets.cBTC.address, + tokenBSymbol: 'cXAUC', + tokenBAddress: assets.cXAUC.address, + status: liveOrPlannedAddress(cbtcXaucPool), + target: dodoProvider, + providerData: cbtcXaucPool ? { poolAddress: cbtcXaucPool } : undefined, + providerDataHex: cbtcXaucPool ? encodeDodoPool(cbtcXaucPool) : undefined, + notes: ['Bitcoin-to-gold route for jewelry-box rebalances; excluded unless policy allows commodity intermediates.'], + reason: cbtcXaucPool ? undefined : 'Set CHAIN138_POOL_CBTC_CXAUC after the canonical cBTC/cXAUC PMM pool is created and funded.', + }), + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'dodo', + tokenASymbol: 'WETH', + tokenAAddress: assets.WETH.address, + tokenBSymbol: 'USDT', + tokenBAddress: assets.USDT.address, + status: wethUsdtPool ? 'live' : 'planned', + target: dodoProvider, + providerData: wethUsdtPool ? { poolAddress: wethUsdtPool } : undefined, + providerDataHex: wethUsdtPool ? encodeDodoPool(wethUsdtPool) : undefined, + notes: ['Phase 1 WETH lane for router-v2 stable execution.'], + reason: wethUsdtPool ? undefined : 'Set CHAIN138_POOL_WETH_USDT after the canonical WETH/USDT pool is created and funded.', + }), + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'dodo', + tokenASymbol: 'WETH', + tokenAAddress: assets.WETH.address, + tokenBSymbol: 'USDC', + tokenBAddress: assets.USDC.address, + status: wethUsdcPool ? 'live' : 'planned', + target: dodoProvider, + providerData: wethUsdcPool ? { poolAddress: wethUsdcPool } : undefined, + providerDataHex: wethUsdcPool ? encodeDodoPool(wethUsdcPool) : undefined, + notes: ['Phase 1 WETH lane for router-v2 stable execution.'], + reason: wethUsdcPool ? undefined : 'Set CHAIN138_POOL_WETH_USDC after the canonical WETH/USDC pool is created and funded.', + }), + ]; + + const livePairs = pairs.filter((pair) => pair.status === 'live'); + return { + chainId: CHAIN_138, + provider: 'dodo', + executionMode: 'onchain', + live: livePairs.length > 0, + quoteLive: livePairs.length > 0, + executionLive: livePairs.length > 0, + supportedLegTypes: ['swap'], + pairs, + notes: ['DODO is the first production executor for Chain 138 router-v2 rollout.'], + }; +} + +function chain138DodoV3Capabilities(): ProviderCapabilityRecord { + const assets = getChain138RoutingAssets(); + const enabled = process.env.CHAIN138_ENABLE_DODO_V3_ROUTING !== '0'; + const proxy = normalizeAddress(process.env.CHAIN138_D3_PROXY_ADDRESS) || '0xc9a11abb7c63d88546be24d58a6d95e3762cb843'; + const pool = normalizeAddress(process.env.CHAIN138_D3_MM_ADDRESS) || '0x6550a3a59070061a262a893a1d6f3f490affdbda'; + const status = enabled && proxy && pool ? 'live' : 'planned'; + const executionLive = status === 'live' && isChain138DodoV3ExecutionLive(); + + const pairs = [ + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'dodo_v3', + tokenASymbol: 'WETH10', + tokenAAddress: assets.WETH10.address, + tokenBSymbol: 'USDT', + tokenBAddress: assets.USDT.address, + status, + target: proxy, + providerData: status === 'live' + ? { poolAddress: pool, proxyAddress: proxy, quoteMethod: 'querySellTokens' } + : undefined, + providerDataHex: executionLive ? encodeChain138DodoV3ProviderData(pool) : undefined, + notes: [ + 'Canonical Chain 138 DODO v3 / D3MM pilot route.', + executionLive + ? 'Planner visibility, quote selection, and EnhancedSwapRouterV2 execution are live for the canonical pilot pair.' + : 'Planner visibility and quote selection are live; EnhancedSwapRouterV2 adapter support is still pending.', + ], + reason: status === 'planned' + ? 'Set CHAIN138_ENABLE_DODO_V3_ROUTING=1 and ensure CHAIN138_D3_MM_ADDRESS / CHAIN138_D3_PROXY_ADDRESS are configured to expose the pilot venue.' + : undefined, + }), + ]; + + return { + chainId: CHAIN_138, + provider: 'dodo_v3', + executionMode: 'onchain', + live: status === 'live', + quoteLive: status === 'live', + executionLive, + supportedLegTypes: ['swap'], + pairs, + notes: [ + executionLive + ? 'Private DODO v3 / D3MM Chain 138 pilot is live in planner-v2 visibility and internal execution-plan calldata.' + : 'Private DODO v3 / D3MM Chain 138 pilot promoted into planner-v2 visibility.', + executionLive + ? 'Route discovery and execution-plan generation are live for the canonical pilot pair.' + : 'Route discovery is live, but internal execution-plan calldata is intentionally withheld until a dedicated D3 route executor adapter exists.', + ], + }; +} + +function chain138UniswapCapabilities(): ProviderCapabilityRecord { + const assets = getChain138RoutingAssets(); + const router = normalizeAddress(process.env.UNISWAP_V3_ROUTER || CHAIN138_UNISWAP_V3_ROUTER); + const quoter = normalizeAddress(process.env.UNISWAP_QUOTER_ADDRESS || process.env.UNISWAP_QUOTER || CHAIN138_UNISWAP_V3_QUOTER); + const wethUsdtFee = Number(process.env.UNISWAP_V3_WETH_USDT_FEE || '500'); + const wethUsdcFee = Number(process.env.UNISWAP_V3_WETH_USDC_FEE || '500'); + const status = router && quoter ? 'live' : 'planned'; + + const pairs = [ + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'uniswap_v3', + tokenASymbol: 'WETH', + tokenAAddress: assets.WETH.address, + tokenBSymbol: 'USDT', + tokenBAddress: assets.USDT.address, + status, + target: router, + providerData: status === 'live' ? { fee: wethUsdtFee, quoter } : undefined, + providerDataHex: status === 'live' ? encodeUniswapRoute(wethUsdtFee, quoter) : undefined, + notes: ['Canonical Chain 138 upstream-native Uniswap v3 WETH/USDT venue.'], + reason: status === 'planned' ? 'Configure UNISWAP_V3_ROUTER and UNISWAP_QUOTER_ADDRESS after Chain 138 native venue deployment.' : undefined, + }), + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'uniswap_v3', + tokenASymbol: 'WETH', + tokenAAddress: assets.WETH.address, + tokenBSymbol: 'USDC', + tokenBAddress: assets.USDC.address, + status, + target: router, + providerData: status === 'live' ? { fee: wethUsdcFee, quoter } : undefined, + providerDataHex: status === 'live' ? encodeUniswapRoute(wethUsdcFee, quoter) : undefined, + notes: ['Canonical Chain 138 upstream-native Uniswap v3 WETH/USDC venue.'], + reason: status === 'planned' ? 'Configure UNISWAP_V3_ROUTER and UNISWAP_QUOTER_ADDRESS after Chain 138 native venue deployment.' : undefined, + }), + ]; + + return { + chainId: CHAIN_138, + provider: 'uniswap_v3', + executionMode: 'onchain', + live: status === 'live', + quoteLive: status === 'live', + executionLive: status === 'live', + supportedLegTypes: ['swap'], + pairs, + notes: ['Canonical Chain 138 upstream-native Uniswap v3 router/quoter path.'], + }; +} + +function chain138BalancerCapabilities(): ProviderCapabilityRecord { + const assets = getChain138RoutingAssets(); + const vault = normalizeAddress(process.env.BALANCER_VAULT || CHAIN138_PILOT_BALANCER_VAULT); + const wethUsdtPoolId = process.env.BALANCER_WETH_USDT_POOL_ID || CHAIN138_PILOT_BALANCER_WETH_USDT_POOL_ID; + const wethUsdcPoolId = process.env.BALANCER_WETH_USDC_POOL_ID || CHAIN138_PILOT_BALANCER_WETH_USDC_POOL_ID; + + const pairs = [ + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'balancer', + tokenASymbol: 'WETH', + tokenAAddress: assets.WETH.address, + tokenBSymbol: 'USDT', + tokenBAddress: assets.USDT.address, + status: vault && wethUsdtPoolId ? 'live' : 'planned', + target: vault, + providerData: vault && wethUsdtPoolId ? { poolId: wethUsdtPoolId } : undefined, + providerDataHex: vault && wethUsdtPoolId ? encodeBalancerRoute(wethUsdtPoolId) : undefined, + notes: ['Enabled only after Chain 138 Balancer pool IDs are set.'], + reason: vault && wethUsdtPoolId ? undefined : 'Configure BALANCER_VAULT and BALANCER_WETH_USDT_POOL_ID once the pool exists.', + }), + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'balancer', + tokenASymbol: 'WETH', + tokenAAddress: assets.WETH.address, + tokenBSymbol: 'USDC', + tokenBAddress: assets.USDC.address, + status: vault && wethUsdcPoolId ? 'live' : 'planned', + target: vault, + providerData: vault && wethUsdcPoolId ? { poolId: wethUsdcPoolId } : undefined, + providerDataHex: vault && wethUsdcPoolId ? encodeBalancerRoute(wethUsdcPoolId) : undefined, + notes: ['Enabled only after Chain 138 Balancer pool IDs are set.'], + reason: vault && wethUsdcPoolId ? undefined : 'Configure BALANCER_VAULT and BALANCER_WETH_USDC_POOL_ID once the pool exists.', + }), + ]; + + return { + chainId: CHAIN_138, + provider: 'balancer', + executionMode: 'onchain', + live: pairs.some((pair) => pair.status === 'live'), + quoteLive: pairs.some((pair) => pair.status === 'live'), + executionLive: pairs.some((pair) => pair.status === 'live'), + supportedLegTypes: ['swap'], + pairs, + notes: ['Balancer stays disabled until the minimum viable Chain 138 venue set exists.'], + }; +} + +function chain138CurveCapabilities(): ProviderCapabilityRecord { + const assets = getChain138RoutingAssets(); + const curvePool = normalizeAddress(process.env.CURVE_3POOL || CHAIN138_PILOT_CURVE_3POOL); + const status = liveOrPlannedAddress(curvePool); + + const pairs = [ + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'curve', + tokenASymbol: 'USDT', + tokenAAddress: assets.USDT.address, + tokenBSymbol: 'USDC', + tokenBAddress: assets.USDC.address, + status, + target: curvePool, + providerData: status === 'live' ? { i: 0, j: 1, useUnderlying: false } : undefined, + providerDataHex: status === 'live' ? encodeCurveRoute(0, 1, false) : undefined, + notes: ['Curve is reserved for stable-stable legs; no direct WETH path is configured.'], + reason: status === 'planned' ? 'Configure CURVE_3POOL once the Chain 138 stable-stable venue is live.' : undefined, + }), + ]; + + return { + chainId: CHAIN_138, + provider: 'curve', + executionMode: 'onchain', + live: status === 'live', + quoteLive: status === 'live', + executionLive: status === 'live', + supportedLegTypes: ['swap'], + pairs, + notes: ['Curve is intentionally constrained to stable-stable execution for router-v2.'], + }; +} + +function chain138PartnerCapabilities(): ProviderCapabilityRecord { + return { + chainId: CHAIN_138, + provider: 'partner', + executionMode: 'partner', + live: false, + quoteLive: false, + executionLive: false, + supportedLegTypes: ['swap', 'bridge'], + pairs: [], + notes: ['1inch, 0x, and LiFi remain partner payload adapters until explicit Chain 138 live support is verified.'], + }; +} + +function chain138OneInchCapabilities(): ProviderCapabilityRecord { + const assets = getChain138RoutingAssets(); + const router = normalizeAddress(process.env.ONEINCH_ROUTER || CHAIN138_PILOT_ONEINCH_ROUTER); + const status = router ? 'live' : 'planned'; + + const pairs = [ + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'one_inch', + tokenASymbol: 'WETH', + tokenAAddress: assets.WETH.address, + tokenBSymbol: 'USDT', + tokenBAddress: assets.USDT.address, + status, + target: router, + providerData: status === 'live' ? { executor: router, allowanceTarget: router } : undefined, + providerDataHex: status === 'live' ? encodeOneInchRoute(router) : undefined, + notes: ['Enabled after the Chain 138 pilot-compatible 1inch router is deployed and funded.'], + reason: status === 'planned' ? 'Configure ONEINCH_ROUTER once the Chain 138 pilot-compatible router is live.' : undefined, + }), + ...bidirectionalPair({ + chainId: CHAIN_138, + provider: 'one_inch', + tokenASymbol: 'WETH', + tokenAAddress: assets.WETH.address, + tokenBSymbol: 'USDC', + tokenBAddress: assets.USDC.address, + status, + target: router, + providerData: status === 'live' ? { executor: router, allowanceTarget: router } : undefined, + providerDataHex: status === 'live' ? encodeOneInchRoute(router) : undefined, + notes: ['Enabled after the Chain 138 pilot-compatible 1inch router is deployed and funded.'], + reason: status === 'planned' ? 'Configure ONEINCH_ROUTER once the Chain 138 pilot-compatible router is live.' : undefined, + }), + ]; + + return { + chainId: CHAIN_138, + provider: 'one_inch', + executionMode: 'onchain', + live: status === 'live', + quoteLive: status === 'live', + executionLive: status === 'live', + supportedLegTypes: ['swap'], + pairs, + notes: ['1inch is promoted from partner-only placeholder to an executable Chain 138 pilot-compatible router when configured.'], + }; +} + +export function getProviderCapabilities(chainId: number): ProviderCapabilityRecord[] { + if (chainId !== CHAIN_138) return []; + return [ + chain138DodoCapabilities(), + chain138DodoV3Capabilities(), + chain138UniswapCapabilities(), + chain138BalancerCapabilities(), + chain138CurveCapabilities(), + chain138OneInchCapabilities(), + chain138PartnerCapabilities(), + ]; +} + +export function findProviderPairCapability( + chainId: number, + provider: PlannerProvider, + tokenInAddress: string, + tokenOutAddress: string +): ProviderPairCapability | undefined { + const normalizedIn = normalizeAddress(tokenInAddress); + const normalizedOut = normalizeAddress(tokenOutAddress); + return getProviderCapabilities(chainId) + .find((record) => record.provider === provider) + ?.pairs.find( + (pair) => + pair.tokenInAddress === normalizedIn && + pair.tokenOutAddress === normalizedOut + ); +} diff --git a/services/token-aggregation/src/config/repo-config-loader.ts b/services/token-aggregation/src/config/repo-config-loader.ts new file mode 100644 index 0000000..8f02306 --- /dev/null +++ b/services/token-aggregation/src/config/repo-config-loader.ts @@ -0,0 +1,53 @@ +import fs from 'fs'; +import path from 'path'; +import { createRequire } from 'module'; + +const requireHere = createRequire(__filename); + +function uniquePaths(paths: Array): string[] { + const seen = new Set(); + const out: string[] = []; + + for (const candidate of paths) { + if (typeof candidate !== 'string') continue; + const trimmed = candidate.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + } + + return out; +} + +function buildTokenMappingLoaderCandidates(): string[] { + return uniquePaths([ + process.env.TOKEN_MAPPING_LOADER_PATH, + process.env.GRU_TRANSPORT_LOADER_PATH, + process.env.PROXMOX_TOKEN_MAPPING_LOADER_PATH, + path.resolve(process.cwd(), 'config', 'token-mapping-loader.cjs'), + path.resolve(process.cwd(), '..', 'config', 'token-mapping-loader.cjs'), + path.resolve(process.cwd(), '..', '..', 'config', 'token-mapping-loader.cjs'), + path.resolve(process.cwd(), '..', '..', '..', 'config', 'token-mapping-loader.cjs'), + path.resolve(__dirname, '../../../../../config/token-mapping-loader.cjs'), + ]); +} + +export function resolveTokenMappingLoaderPath(): string | null { + for (const candidate of buildTokenMappingLoaderCandidates()) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; +} + +export function loadTokenMappingLoader(): T | null { + const loaderPath = resolveTokenMappingLoaderPath(); + if (!loaderPath) return null; + + try { + return requireHere(loaderPath) as T; + } catch { + return null; + } +} diff --git a/services/token-aggregation/src/config/routing-assets.ts b/services/token-aggregation/src/config/routing-assets.ts new file mode 100644 index 0000000..849dc47 --- /dev/null +++ b/services/token-aggregation/src/config/routing-assets.ts @@ -0,0 +1,130 @@ +import { getCanonicalTokenByAddress, getCanonicalTokenBySymbol } from './canonical-tokens'; + +const CHAIN_138 = 138; +const CHAIN_138_WETH = ( + process.env.WETH || + process.env.WETH9 || + process.env.WETH_ADDRESS_138 || + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' +).toLowerCase(); +const CHAIN_138_WETH10 = ( + process.env.WETH10 || + process.env.WETH10_ADDRESS || + process.env.WETH10_ADDRESS_138 || + '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f' +).toLowerCase(); + +export interface RoutingAssetSpec { + symbol: string; + address: string; + decimals: number; + kind: 'wrapped' | 'stable' | 'compliant' | 'commodity' | 'monetary_unit'; +} + +function requireSymbolAddress(chainId: number, symbol: string, fallback?: string): string { + const canonical = getCanonicalTokenBySymbol(chainId, symbol)?.addresses?.[chainId]; + const value = String(canonical || fallback || '').trim().toLowerCase(); + return value; +} + +export function getChain138RoutingAssets(): Record { + return { + WETH: { + symbol: 'WETH', + address: CHAIN_138_WETH, + decimals: 18, + kind: 'wrapped', + }, + WETH10: { + symbol: 'WETH10', + address: CHAIN_138_WETH10, + decimals: 18, + kind: 'wrapped', + }, + USDT: { + symbol: 'USDT', + address: requireSymbolAddress(CHAIN_138, 'USDT', '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1'), + decimals: 6, + kind: 'stable', + }, + USDC: { + symbol: 'USDC', + address: requireSymbolAddress(CHAIN_138, 'USDC', '0x71D6687F38b93CCad569Fa6352c876eea967201b'), + decimals: 6, + kind: 'stable', + }, + cUSDT: { + symbol: 'cUSDT', + address: requireSymbolAddress(CHAIN_138, 'cUSDT', '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'), + decimals: 6, + kind: 'compliant', + }, + cUSDC: { + symbol: 'cUSDC', + address: requireSymbolAddress(CHAIN_138, 'cUSDC', '0xf22258f57794CC8E06237084b353Ab30fFfa640b'), + decimals: 6, + kind: 'compliant', + }, + cBTC: { + symbol: 'cBTC', + address: requireSymbolAddress(CHAIN_138, 'cBTC', '0xcb7c000000000000000000000000000000000138'), + decimals: 8, + kind: 'monetary_unit', + }, + cEURT: { + symbol: 'cEURT', + address: requireSymbolAddress(CHAIN_138, 'cEURT', '0xdf4b71c61E5912712C1Bdd451416B9aC26949d72'), + decimals: 6, + kind: 'compliant', + }, + cXAUC: { + symbol: 'cXAUC', + address: requireSymbolAddress(CHAIN_138, 'cXAUC', '0x290E52a8819A4fbD0714E517225429aA2B70EC6b'), + decimals: 6, + kind: 'commodity', + }, + }; +} + +export function getDefaultIntermediateAddresses(chainId: number): string[] { + if (chainId !== CHAIN_138) return []; + const assets = getChain138RoutingAssets(); + return [ + assets.WETH.address, + assets.USDT.address, + assets.USDC.address, + assets.cUSDT.address, + assets.cUSDC.address, + assets.cBTC.address, + ]; +} + +export function getCommodityIntermediateAddresses(chainId: number): string[] { + if (chainId !== CHAIN_138) return []; + return [getChain138RoutingAssets().cXAUC.address]; +} + +export function getRoutingSymbolForAddress(chainId: number, address: string): string | undefined { + const normalized = address.trim().toLowerCase(); + if (chainId === CHAIN_138) { + const assets = Object.values(getChain138RoutingAssets()); + const asset = assets.find((entry) => entry.address === normalized); + if (asset) return asset.symbol; + } + + return getCanonicalTokenByAddress(chainId, normalized)?.symbol; +} + +export function getRoutingAddressForSymbol(chainId: number, symbol: string): string | undefined { + if (chainId === CHAIN_138) { + const assets = getChain138RoutingAssets(); + const normalized = symbol.trim().toUpperCase(); + if (normalized === 'WETH') { + return assets.WETH.address; + } + if (normalized === 'WETH10') { + return assets.WETH10.address; + } + } + return getCanonicalTokenBySymbol(chainId, symbol)?.addresses?.[chainId]?.toLowerCase(); +} diff --git a/services/token-aggregation/src/config/routing-policies.ts b/services/token-aggregation/src/config/routing-policies.ts new file mode 100644 index 0000000..e2e5833 --- /dev/null +++ b/services/token-aggregation/src/config/routing-policies.ts @@ -0,0 +1,68 @@ +import { PlannerConstraints, RoutingPolicy } from '../services/planner-v2-types'; +import { + getCommodityIntermediateAddresses, + getDefaultIntermediateAddresses, +} from './routing-assets'; + +const CHAIN_138 = 138; + +export function resolveRoutingPolicy( + chainId: number, + constraints: PlannerConstraints = {} +): RoutingPolicy { + const requestedProfile = constraints.complianceProfile || 'standard'; + const defaultIntermediates = getDefaultIntermediateAddresses(chainId); + const commodityIntermediates = getCommodityIntermediateAddresses(chainId); + + const baseStandard: RoutingPolicy = { + profile: 'standard', + allowedProviders: ['dodo', 'dodo_v3', 'uniswap_v3', 'balancer', 'curve', 'one_inch'], + defaultIntermediateAddresses: defaultIntermediates, + allowBridge: constraints.allowBridge !== false, + allowedBridgeLabels: ['GRUTransport', 'CCIPStableBridge', 'CCIPWETH9Bridge', 'UniversalCCIPBridge', 'AlltraAdapter'], + maxLegs: Math.min(Math.max(constraints.maxLegs || 3, 1), 3), + allowCommodityIntermediates: constraints.allowCommodityIntermediates === true, + notes: ['Standard policy allows live Chain 138 venues, including the DODO v3 pilot, but still requires explicit opt-in for commodity intermediates.'], + }; + + const baseInstitutional: RoutingPolicy = { + profile: 'institutional', + allowedProviders: ['dodo'], + defaultIntermediateAddresses: defaultIntermediates, + allowBridge: constraints.allowBridge !== false, + allowedBridgeLabels: ['GRUTransport', 'CCIPStableBridge', 'UniversalCCIPBridge', 'AlltraAdapter'], + maxLegs: Math.min(Math.max(constraints.maxLegs || 3, 1), 3), + allowCommodityIntermediates: false, + notes: ['Institutional policy restricts execution to canonical compliant assets and approved bridge rails.'], + }; + + const basePolicy = requestedProfile === 'institutional' ? baseInstitutional : baseStandard; + const allowedProviders = constraints.allowedProviders?.length + ? basePolicy.allowedProviders.filter((provider) => constraints.allowedProviders?.includes(provider)) + : basePolicy.allowedProviders; + + const allowedIntermediates = constraints.allowedIntermediates?.length + ? constraints.allowedIntermediates.map((value) => value.toLowerCase()) + : basePolicy.defaultIntermediateAddresses.slice(); + + if (basePolicy.allowCommodityIntermediates) { + for (const commodity of commodityIntermediates) { + if (!allowedIntermediates.includes(commodity)) { + allowedIntermediates.push(commodity); + } + } + } + + return { + ...basePolicy, + allowedProviders, + defaultIntermediateAddresses: allowedIntermediates, + allowedBridgeLabels: constraints.preferredBridges?.length + ? basePolicy.allowedBridgeLabels.filter((bridge) => constraints.preferredBridges?.includes(bridge)) + : basePolicy.allowedBridgeLabels, + notes: [ + ...basePolicy.notes, + ...(chainId === CHAIN_138 ? ['Chain 138 routing defaults to WETH, USDT, USDC, cUSDT, and cUSDC intermediates.'] : []), + ], + }; +} diff --git a/services/token-aggregation/src/database/repositories/planner-metrics-repo.test.ts b/services/token-aggregation/src/database/repositories/planner-metrics-repo.test.ts new file mode 100644 index 0000000..a454a43 --- /dev/null +++ b/services/token-aggregation/src/database/repositories/planner-metrics-repo.test.ts @@ -0,0 +1,47 @@ +import { PlannerMetricsRepository } from './planner-metrics-repo'; + +describe('PlannerMetricsRepository', () => { + function makeRepoWithRejectedQuery(error: Error & { code?: string }): PlannerMetricsRepository { + const repo = new PlannerMetricsRepository(); + (repo as any).pool = { + query: jest.fn().mockRejectedValue(error), + }; + return repo; + } + + it('treats transient connection errors as cache misses', async () => { + const repo = makeRepoWithRejectedQuery( + Object.assign(new Error('connect ECONNREFUSED 172.18.0.3:5432'), { code: 'ECONNREFUSED' }) + ); + + await expect(repo.getCachedPlan('request-hash')).resolves.toBeNull(); + }); + + it('skips cache writes when the planner metrics database is temporarily unreachable', async () => { + const repo = makeRepoWithRejectedQuery( + Object.assign(new Error('connect EHOSTUNREACH 76.53.10.36:443'), { code: 'EHOSTUNREACH' }) + ); + + await expect( + repo.cachePlan('request-hash', { + planId: 'plan-1', + generatedAt: new Date().toISOString(), + decision: 'direct-pool', + sourceChainId: 138, + destinationChainId: 138, + tokenIn: '0x1', + tokenOut: '0x2', + estimatedAmountOut: '1000', + minAmountOut: '990', + estimatedGasUsd: 0.1, + legs: [], + alternatives: [], + confidenceScore: 0.9, + riskFlags: [], + selectedRouteReason: 'selected', + rejectedAlternatives: [], + staleness: { maxFreshnessSeconds: 0, hasStaleLeg: false }, + }) + ).resolves.toBeUndefined(); + }); +}); diff --git a/services/token-aggregation/src/database/repositories/planner-metrics-repo.ts b/services/token-aggregation/src/database/repositories/planner-metrics-repo.ts new file mode 100644 index 0000000..302ab9c --- /dev/null +++ b/services/token-aggregation/src/database/repositories/planner-metrics-repo.ts @@ -0,0 +1,183 @@ +import { Pool } from 'pg'; +import { getDatabasePool } from '../client'; +import { ProviderCapabilityRecord, PlannerResponse } from '../../services/planner-v2-types'; +import { logger } from '../../utils/logger'; + +interface CachedPlanRow { + plan_id: string; + response_json: PlannerResponse; +} + +export class PlannerMetricsRepository { + private pool: Pool; + + constructor() { + this.pool = getDatabasePool(); + } + + private isMissingRelationError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + + const code = (error as { code?: string }).code; + const message = (error as { message?: string }).message || ''; + return code === '42P01' || (message.includes('relation "') && message.includes('" does not exist')); + } + + private isTransientConnectionError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + + const code = String((error as { code?: string }).code || ''); + const message = String((error as { message?: string }).message || ''); + return ( + ['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT', '57P01'].includes(code) || + message.includes('connect ECONNREFUSED') || + message.includes('connect EHOSTUNREACH') || + message.includes('Connection terminated unexpectedly') + ); + } + + private isNonFatalError(error: unknown): boolean { + return this.isMissingRelationError(error) || this.isTransientConnectionError(error); + } + + private logSuppressedError(action: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Planner metrics ${action} skipped: ${message}`); + } + + async getCachedPlan(requestHash: string): Promise { + try { + const result = await this.pool.query( + `SELECT plan_id, response_json + FROM route_plan_cache + WHERE request_hash = $1 AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY created_at DESC + LIMIT 1`, + [requestHash] + ); + + if (result.rows.length === 0) { + return null; + } + + return result.rows[0].response_json; + } catch (error) { + if (this.isNonFatalError(error)) { + this.logSuppressedError('cache lookup', error); + return null; + } + throw error; + } + } + + async cachePlan(requestHash: string, response: PlannerResponse, ttlSeconds: number = 30): Promise { + try { + await this.pool.query( + `INSERT INTO route_plan_cache ( + plan_id, request_hash, chain_id, destination_chain_id, decision, response_json, expires_at + ) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW() + ($7::text || ' seconds')::interval) + ON CONFLICT (plan_id) DO UPDATE SET + request_hash = EXCLUDED.request_hash, + chain_id = EXCLUDED.chain_id, + destination_chain_id = EXCLUDED.destination_chain_id, + decision = EXCLUDED.decision, + response_json = EXCLUDED.response_json, + expires_at = EXCLUDED.expires_at, + created_at = NOW()`, + [ + response.planId, + requestHash, + response.sourceChainId, + response.destinationChainId, + response.decision, + JSON.stringify(response), + String(ttlSeconds), + ] + ); + } catch (error) { + if (this.isNonFatalError(error)) { + this.logSuppressedError('cache write', error); + return; + } + throw error; + } + } + + async recordProviderSnapshots(chainId: number, records: ProviderCapabilityRecord[]): Promise { + try { + for (const record of records) { + await this.pool.query( + `INSERT INTO provider_health_snapshots ( + chain_id, provider, status, supports_execution, supports_quote, metadata + ) + VALUES ($1, $2, $3, $4, $5, $6::jsonb)`, + [ + chainId, + record.provider, + record.live ? 'live' : 'planned', + record.executionLive, + record.quoteLive, + JSON.stringify({ + executionMode: record.executionMode, + supportedLegTypes: record.supportedLegTypes, + pairs: record.pairs, + notes: record.notes || [], + }), + ] + ); + } + } catch (error) { + if (this.isNonFatalError(error)) { + this.logSuppressedError('provider snapshot write', error); + return; + } + throw error; + } + } + + async recordPlannedRouteMetrics(response: PlannerResponse): Promise { + try { + for (const [index, leg] of response.legs.entries()) { + await this.pool.query( + `INSERT INTO route_execution_metrics ( + plan_id, chain_id, provider, hop_index, token_in_address, token_out_address, + estimated_amount_out, status, metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`, + [ + response.planId, + leg.sourceChainId, + leg.provider, + index, + leg.tokenInAddress, + leg.tokenOutAddress, + leg.estimatedAmountOut, + 'planned', + JSON.stringify({ + kind: leg.kind, + target: leg.target, + poolAddress: leg.poolAddress, + providerData: leg.providerData || {}, + bridgeType: leg.bridgeType, + bridgeAddress: leg.bridgeAddress, + gasEstimate: leg.gasEstimate, + freshnessSeconds: leg.freshnessSeconds, + notes: leg.notes || [], + }), + ] + ); + } + } catch (error) { + if (this.isNonFatalError(error)) { + this.logSuppressedError('route metrics write', error); + return; + } + throw error; + } + } +} diff --git a/services/token-aggregation/src/index.ts b/services/token-aggregation/src/index.ts index 2de22b7..f2b1fb4 100644 --- a/services/token-aggregation/src/index.ts +++ b/services/token-aggregation/src/index.ts @@ -4,6 +4,7 @@ import { existsSync } from 'fs'; import { ApiServer } from './api/server'; import { closeDatabasePool } from './database/client'; import { logger } from './utils/logger'; +import { startRouteMatrixScheduler } from './services/route-matrix-scheduler'; // Load smom-dbis-138 root .env first (single source); works from dist/ or src/ const rootEnvCandidates = [ @@ -26,6 +27,7 @@ try { } catch (_) { /* optional when run outside proxmox repo */ } const server = new ApiServer(); +startRouteMatrixScheduler(); // Start server server.start().catch((error) => { diff --git a/services/token-aggregation/src/indexer/chain-indexer.ts b/services/token-aggregation/src/indexer/chain-indexer.ts index ce9038e..a46a310 100644 --- a/services/token-aggregation/src/indexer/chain-indexer.ts +++ b/services/token-aggregation/src/indexer/chain-indexer.ts @@ -95,11 +95,10 @@ export class ChainIndexer { try { // 1. Index pools logger.info(`Indexing pools for chain ${this.chainId}...`); - await this.poolIndexer.indexAllPools(); + const pools = await this.poolIndexer.indexAllPools(); // 2. Discover and index tokens from pools logger.info(`Discovering tokens for chain ${this.chainId}...`); - const pools = await this.poolIndexer.indexAllPools(); const tokenAddresses = new Set(); pools.forEach((pool) => { tokenAddresses.add(pool.token0Address); @@ -110,7 +109,7 @@ export class ChainIndexer { // 3. Calculate volumes and update market data logger.info(`Calculating volumes for chain ${this.chainId}...`); for (const tokenAddress of tokenAddresses) { - await this.updateMarketData(tokenAddress); + await this.updateMarketData(tokenAddress, pools); } // 4. Generate OHLCV data @@ -139,7 +138,7 @@ export class ChainIndexer { /** * Update market data for a token */ - private async updateMarketData(tokenAddress: string): Promise { + private async updateMarketData(tokenAddress: string, pools: Awaited>): Promise { try { // Calculate on-chain volume const volumeMetrics = await this.volumeCalculator.calculateTokenVolume( @@ -158,7 +157,6 @@ export class ChainIndexer { const externalData = coingeckoData || dexscreenerData || cmcData; // Get pools for liquidity calculation - const pools = await this.poolIndexer.indexAllPools(); const tokenPools = pools.filter( (p) => p.token0Address === tokenAddress || p.token1Address === tokenAddress ); diff --git a/services/token-aggregation/src/indexer/cross-chain-indexer.ts b/services/token-aggregation/src/indexer/cross-chain-indexer.ts index 34a68f6..bc28135 100644 --- a/services/token-aggregation/src/indexer/cross-chain-indexer.ts +++ b/services/token-aggregation/src/indexer/cross-chain-indexer.ts @@ -82,6 +82,11 @@ const UNIVERAL_CCIP_ABI = [ 'event MessageReceived(bytes32 indexed messageId, uint64 indexed sourceChainSelector, address sender, address token, uint256 amount)', ]; +const CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN = Math.max( + 1, + Number(process.env.CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN || 5000) +); + function nowSec(): number { return Math.floor(Date.now() / 1000); } @@ -90,6 +95,47 @@ function msAgo(hours: number): number { return nowSec() - hours * 3600; } +function isRpcRangeLimitError(error: unknown): boolean { + const parts = [ + typeof error === 'object' && error ? (error as { message?: string }).message : '', + typeof error === 'object' && error ? (error as { shortMessage?: string }).shortMessage : '', + typeof error === 'object' && error + ? ((error as { error?: { message?: string } }).error?.message ?? '') + : '', + ]; + + return parts.some((part) => + typeof part === 'string' && /range limit|exceeds maximum rpc range/i.test(part) + ); +} + +async function queryFilterWithRangeFallback( + contract: ethers.Contract, + filter: ReturnType | { address: string }, + fromBlock: number, + toBlock: number +): Promise { + try { + return (await contract.queryFilter(filter as never, fromBlock, toBlock)) as ethers.EventLog[]; + } catch (error) { + if (!isRpcRangeLimitError(error) || fromBlock >= toBlock) { + throw error; + } + } + + const logs: ethers.EventLog[] = []; + for ( + let start = fromBlock; + start <= toBlock; + start += CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN + ) { + const end = Math.min(start + CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN - 1, toBlock); + const chunk = (await contract.queryFilter(filter as never, start, end)) as ethers.EventLog[]; + logs.push(...chunk); + } + return logs; +} + /** Fetch CrossChainTransferInitiated events from CCIP WETH bridges */ async function fetchCCIPEvents( provider: ethers.JsonRpcProvider, @@ -101,7 +147,7 @@ async function fetchCCIPEvents( try { const contract = new ethers.Contract(bridge.address, CCIP_TRANSFER_ABI, provider); const filter = contract.filters.CrossChainTransferInitiated(); - const logs = await contract.queryFilter(filter, fromBlock, toBlock); + const logs = await queryFilterWithRangeFallback(contract, filter, fromBlock, toBlock); for (const log of logs) { const args = (log as ethers.EventLog).args as unknown as { messageId: string; sender: string; destinationChainSelector: bigint; recipient: string; amount: bigint }; @@ -143,7 +189,7 @@ async function fetchSwapBridgeEvents( try { const contract = new ethers.Contract(address, SWAP_BRIDGE_ABI, provider); const filter = contract.filters.SwapAndBridgeExecuted(); - const logs = await contract.queryFilter(filter, fromBlock, toBlock); + const logs = await queryFilterWithRangeFallback(contract, filter, fromBlock, toBlock); for (const log of logs) { const args = (log as ethers.EventLog).args as unknown as { sourceToken: string; bridgeableToken: string; amountIn: bigint; amountToBridge: bigint; destinationChainSelector: bigint; recipient: string; messageId: string }; @@ -184,7 +230,8 @@ async function fetchAlltraEvents( try { const lockContract = new ethers.Contract(bridge.address, ALLTRA_LOCK_ABI, provider); - const lockLogs = await lockContract.queryFilter( + const lockLogs = await queryFilterWithRangeFallback( + lockContract, lockContract.filters.LockForAlltra(), fromBlock, toBlock @@ -211,7 +258,8 @@ async function fetchAlltraEvents( try { const adapterContract = new ethers.Contract(bridge.address, ALLTRA_ADAPTER_ABI, provider); - const initLogs = await adapterContract.queryFilter( + const initLogs = await queryFilterWithRangeFallback( + adapterContract, adapterContract.filters.AlltraBridgeInitiated(), fromBlock, toBlock @@ -249,7 +297,7 @@ async function fetchUniversalCCIPEvents( try { const contract = new ethers.Contract(bridge.address, UNIVERAL_CCIP_ABI, provider); const filter = contract.filters.BridgeExecuted?.() ?? { address: bridge.address }; - const logs = await contract.queryFilter(filter, fromBlock, toBlock); + const logs = await queryFilterWithRangeFallback(contract, filter, fromBlock, toBlock); for (const log of logs) { const args = (log as ethers.EventLog).args as unknown as { messageId: string; token: string; sender: string; amount: bigint; destinationChain: bigint; recipient: string }; diff --git a/services/token-aggregation/src/indexer/ohlcv-generator.test.ts b/services/token-aggregation/src/indexer/ohlcv-generator.test.ts new file mode 100644 index 0000000..8cdc986 --- /dev/null +++ b/services/token-aggregation/src/indexer/ohlcv-generator.test.ts @@ -0,0 +1,61 @@ +const mockQuery = jest.fn(); +const mockLoggerInfo = jest.fn(); + +jest.mock('../database/client', () => ({ + getDatabasePool: () => ({ query: mockQuery }), +})); + +jest.mock('../utils/logger', () => ({ + logger: { + info: mockLoggerInfo, + warn: jest.fn(), + error: jest.fn(), + }, +})); + +import { OHLCVGenerator } from './ohlcv-generator'; + +describe('OHLCVGenerator', () => { + beforeEach(() => { + mockQuery.mockReset(); + mockLoggerInfo.mockReset(); + }); + + it('returns empty data and logs once when swap tables are not provisioned yet', async () => { + mockQuery.mockRejectedValue({ + code: '42P01', + message: 'relation "swap_events" does not exist', + }); + + const generator = new OHLCVGenerator(); + + await expect( + generator.generateOHLCV( + 138, + '0xToken', + '1h', + new Date('2026-03-01T00:00:00.000Z'), + new Date('2026-03-02T00:00:00.000Z') + ) + ).resolves.toEqual([]); + + await expect( + generator.generateOHLCV( + 138, + '0xToken', + '1h', + new Date('2026-03-01T00:00:00.000Z'), + new Date('2026-03-02T00:00:00.000Z') + ) + ).resolves.toEqual([]); + + expect(mockLoggerInfo).toHaveBeenCalledTimes(1); + expect(mockLoggerInfo).toHaveBeenCalledWith( + 'Skipping OHLCV generation; relation "swap_events" is not available yet', + expect.objectContaining({ + operation: 'OHLCV generation', + relation: 'swap_events', + }) + ); + }); +}); diff --git a/services/token-aggregation/src/indexer/ohlcv-generator.ts b/services/token-aggregation/src/indexer/ohlcv-generator.ts index c263a8f..4af3be3 100644 --- a/services/token-aggregation/src/indexer/ohlcv-generator.ts +++ b/services/token-aggregation/src/indexer/ohlcv-generator.ts @@ -1,5 +1,6 @@ import { Pool } from 'pg'; import { getDatabasePool } from '../database/client'; +import { logger } from '../utils/logger'; export type OHLCVInterval = '5m' | '15m' | '1h' | '4h' | '24h'; @@ -15,11 +16,48 @@ export interface OHLCVData { export class OHLCVGenerator { private pool: Pool; + private static readonly missingRelationWarnings = new Set(); constructor() { this.pool = getDatabasePool(); } + private normalizePoolAddress(poolAddress?: string): string { + return poolAddress?.toLowerCase() || ''; + } + + private isMissingRelationError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + + const code = (error as { code?: string }).code; + const message = (error as { message?: string }).message || ''; + return code === '42P01' || (message.includes('relation "') && message.includes('" does not exist')); + } + + private getMissingRelationName(error: unknown): string { + const message = typeof error === 'object' && error && 'message' in error + ? String((error as { message?: string }).message || '') + : ''; + const match = message.match(/relation "([^"]+)"/); + return match?.[1] || 'unknown relation'; + } + + private logMissingRelationOnce(operation: string, error: unknown): void { + const relation = this.getMissingRelationName(error); + const key = `${operation}:${relation}`; + if (OHLCVGenerator.missingRelationWarnings.has(key)) { + return; + } + + OHLCVGenerator.missingRelationWarnings.add(key); + logger.info(`Skipping ${operation}; relation "${relation}" is not available yet`, { + operation, + relation, + }); + } + /** * Generate OHLCV data for a token */ @@ -31,63 +69,71 @@ export class OHLCVGenerator { to: Date, poolAddress?: string ): Promise { - const intervalMs = this.getIntervalMs(interval); + try { + const intervalMs = this.getIntervalMs(interval); - // Get swap events for the time range - let query = ` - SELECT timestamp, amount_usd, price_usd - FROM swap_events - WHERE chain_id = $1 - AND (token0_address = $2 OR token1_address = $2) - AND timestamp >= $3 - AND timestamp <= $4 - `; - const params: (string | number | Date)[] = [chainId, tokenAddress.toLowerCase(), from, to]; + // Get swap events for the time range + let query = ` + SELECT timestamp, amount_usd, price_usd + FROM swap_events + WHERE chain_id = $1 + AND (token0_address = $2 OR token1_address = $2) + AND timestamp >= $3 + AND timestamp <= $4 + `; + const params: (string | number | Date)[] = [chainId, tokenAddress.toLowerCase(), from, to]; - if (poolAddress) { - query += ` AND pool_address = $5`; - params.push(poolAddress.toLowerCase()); - } - - query += ` ORDER BY timestamp ASC`; - - const result = await this.pool.query(query, params); - - if (result.rows.length === 0) { - return []; - } - - // Group swaps by interval - const intervals = new Map(); - - result.rows.forEach((row) => { - const timestamp = new Date(row.timestamp); - const intervalStart = Math.floor(timestamp.getTime() / intervalMs) * intervalMs; - const price = parseFloat(row.price_usd || '0'); - const volume = parseFloat(row.amount_usd || '0'); - - if (!intervals.has(intervalStart)) { - intervals.set(intervalStart, { - timestamp: new Date(intervalStart), - open: price, - high: price, - low: price, - close: price, - volume: 0, - volumeUsd: 0, - }); + if (poolAddress) { + query += ` AND pool_address = $5`; + params.push(poolAddress.toLowerCase()); } - const ohlcv = intervals.get(intervalStart)!; - ohlcv.high = Math.max(ohlcv.high, price); - ohlcv.low = Math.min(ohlcv.low, price); - ohlcv.close = price; - ohlcv.volume += 1; - ohlcv.volumeUsd += volume; - }); + query += ` ORDER BY timestamp ASC`; - // Convert map to array and sort by timestamp - return Array.from(intervals.values()).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + const result = await this.pool.query(query, params); + + if (result.rows.length === 0) { + return []; + } + + // Group swaps by interval + const intervals = new Map(); + + result.rows.forEach((row) => { + const timestamp = new Date(row.timestamp); + const intervalStart = Math.floor(timestamp.getTime() / intervalMs) * intervalMs; + const price = parseFloat(row.price_usd || '0'); + const volume = parseFloat(row.amount_usd || '0'); + + if (!intervals.has(intervalStart)) { + intervals.set(intervalStart, { + timestamp: new Date(intervalStart), + open: price, + high: price, + low: price, + close: price, + volume: 0, + volumeUsd: 0, + }); + } + + const ohlcv = intervals.get(intervalStart)!; + ohlcv.high = Math.max(ohlcv.high, price); + ohlcv.low = Math.min(ohlcv.low, price); + ohlcv.close = price; + ohlcv.volume += 1; + ohlcv.volumeUsd += volume; + }); + + // Convert map to array and sort by timestamp + return Array.from(intervals.values()).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + } catch (error) { + if (this.isMissingRelationError(error)) { + this.logMissingRelationOnce('OHLCV generation', error); + return []; + } + throw error; + } } /** @@ -102,17 +148,19 @@ export class OHLCVGenerator { ): Promise { if (data.length === 0) return; - const values = data.map((d, i) => { - const base = i * 8; - return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8})`; + const normalizedPoolAddress = this.normalizePoolAddress(poolAddress); + + const values = data.map((_, i) => { + const base = i * 11; + return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11})`; }); - const params: (string | number | Date | null)[] = []; + const params: (string | number | Date)[] = []; data.forEach((d) => { params.push( chainId, tokenAddress.toLowerCase(), - poolAddress?.toLowerCase() || null, + normalizedPoolAddress, interval, d.open, d.high, @@ -124,21 +172,29 @@ export class OHLCVGenerator { ); }); - await this.pool.query( - `INSERT INTO token_ohlcv ( - chain_id, token_address, pool_address, interval_type, - open_price, high_price, low_price, close_price, volume, volume_usd, timestamp - ) - VALUES ${values.join(', ')} - ON CONFLICT (chain_id, token_address, pool_address, interval_type, timestamp) DO UPDATE SET - open_price = EXCLUDED.open_price, - high_price = EXCLUDED.high_price, - low_price = EXCLUDED.low_price, - close_price = EXCLUDED.close_price, - volume = EXCLUDED.volume, - volume_usd = EXCLUDED.volume_usd`, - params - ); + try { + await this.pool.query( + `INSERT INTO token_ohlcv ( + chain_id, token_address, pool_address, interval_type, + open_price, high_price, low_price, close_price, volume, volume_usd, timestamp + ) + VALUES ${values.join(', ')} + ON CONFLICT (chain_id, token_address, pool_address, interval_type, timestamp) DO UPDATE SET + open_price = EXCLUDED.open_price, + high_price = EXCLUDED.high_price, + low_price = EXCLUDED.low_price, + close_price = EXCLUDED.close_price, + volume = EXCLUDED.volume, + volume_usd = EXCLUDED.volume_usd`, + params + ); + } catch (error) { + if (this.isMissingRelationError(error)) { + this.logMissingRelationOnce('OHLCV storage', error); + return; + } + throw error; + } } /** @@ -152,37 +208,45 @@ export class OHLCVGenerator { to: Date, poolAddress?: string ): Promise { - let query = ` - SELECT timestamp, open_price, high_price, low_price, close_price, volume, volume_usd - FROM token_ohlcv - WHERE chain_id = $1 - AND token_address = $2 - AND interval_type = $3 - AND timestamp >= $4 - AND timestamp <= $5 - `; - const params: (string | number | Date)[] = [chainId, tokenAddress.toLowerCase(), interval, from, to]; + try { + let query = ` + SELECT timestamp, open_price, high_price, low_price, close_price, volume, volume_usd + FROM token_ohlcv + WHERE chain_id = $1 + AND token_address = $2 + AND interval_type = $3 + AND timestamp >= $4 + AND timestamp <= $5 + `; + const params: (string | number | Date)[] = [chainId, tokenAddress.toLowerCase(), interval, from, to]; - if (poolAddress) { - query += ` AND pool_address = $6`; - params.push(poolAddress.toLowerCase()); - } else { - query += ` AND pool_address IS NULL`; + if (poolAddress) { + query += ` AND pool_address = $6`; + params.push(this.normalizePoolAddress(poolAddress)); + } else { + query += ` AND pool_address = ''`; + } + + query += ` ORDER BY timestamp ASC`; + + const result = await this.pool.query(query, params); + + return result.rows.map((row) => ({ + timestamp: row.timestamp, + open: parseFloat(row.open_price), + high: parseFloat(row.high_price), + low: parseFloat(row.low_price), + close: parseFloat(row.close_price), + volume: parseFloat(row.volume || '0'), + volumeUsd: parseFloat(row.volume_usd || '0'), + })); + } catch (error) { + if (this.isMissingRelationError(error)) { + this.logMissingRelationOnce('OHLCV lookup', error); + return []; + } + throw error; } - - query += ` ORDER BY timestamp ASC`; - - const result = await this.pool.query(query, params); - - return result.rows.map((row) => ({ - timestamp: row.timestamp, - open: parseFloat(row.open_price), - high: parseFloat(row.high_price), - low: parseFloat(row.low_price), - close: parseFloat(row.close_price), - volume: parseFloat(row.volume || '0'), - volumeUsd: parseFloat(row.volume_usd || '0'), - })); } /** diff --git a/services/token-aggregation/src/indexer/pool-indexer.ts b/services/token-aggregation/src/indexer/pool-indexer.ts index 07e0f6e..e3847be 100644 --- a/services/token-aggregation/src/indexer/pool-indexer.ts +++ b/services/token-aggregation/src/indexer/pool-indexer.ts @@ -2,6 +2,8 @@ import { ethers } from 'ethers'; import { PoolRepository, LiquidityPool, DexType } from '../database/repositories/pool-repo'; import { getDexFactories, UniswapV2Config, UniswapV3Config, DodoConfig } from '../config/dex-factories'; import { logger } from '../utils/logger'; +import { shouldExposePublicPool } from '../config/gru-transport'; +import { estimateChain138DodoLiquidityUsd } from '../services/chain138-dodo-liquidity'; // UniswapV2 Factory ABI const UNISWAP_V2_FACTORY_ABI = [ @@ -49,6 +51,8 @@ const DODO_PMM_INTEGRATION_ABI = [ ]; export class PoolIndexer { + private static missingDexConfigLogged = new Set(); + private static staleDodoPoolsLogged = new Set(); private provider: ethers.JsonRpcProvider; private poolRepo: PoolRepository; private chainId: number; @@ -59,13 +63,35 @@ export class PoolIndexer { this.poolRepo = new PoolRepository(); } + private async persistPoolIfExposed(pool: LiquidityPool, pools: LiquidityPool[]): Promise { + if (!shouldExposePublicPool(this.chainId, pool.poolAddress, pool.token0Address, pool.token1Address)) { + logger.info( + `Skipping inactive GRU public pool ${pool.poolAddress} on chain ${this.chainId} until gru-transport-active.json marks it active` + ); + return; + } + + await this.poolRepo.upsertPool(pool); + pools.push(pool); + } + /** * Index all pools for configured DEX types */ async indexAllPools(): Promise { const dexConfig = getDexFactories(this.chainId); - if (!dexConfig) { - logger.warn(`No DEX configuration found for chain ${this.chainId}`); + const hasDexConfig = + !!dexConfig && + (dexConfig.uniswap_v2?.length || + dexConfig.uniswap_v3?.length || + dexConfig.dodo?.length || + dexConfig.custom?.length); + + if (!hasDexConfig) { + if (!PoolIndexer.missingDexConfigLogged.has(this.chainId)) { + logger.info(`Skipping pool indexing for chain ${this.chainId}; no DEX configuration is active yet`); + PoolIndexer.missingDexConfigLogged.add(this.chainId); + } return []; } @@ -133,12 +159,15 @@ export class PoolIndexer { const quoteReserve = (reservesResult as [bigint, bigint])[1]; const price = typeof priceResult === 'bigint' ? priceResult : BigInt(0); - // totalLiquidityUsd: baseReserve * price (quote per base) + quoteReserve, in 18 decimals then scale - let totalLiquidityUsd = 0; - if (price > 0n) { - const baseValue = (baseReserve * price) / BigInt(1e18); - totalLiquidityUsd = parseFloat(ethers.formatEther(baseValue + quoteReserve)); - } + const liquidityUsd = this.chainId === 138 + ? estimateChain138DodoLiquidityUsd({ + token0Address: baseToken, + token1Address: quoteToken, + reserve0: baseReserve, + reserve1: quoteReserve, + price, + }) + : { reserve0Usd: 0, reserve1Usd: 0, totalLiquidityUsd: 0 }; const pool: LiquidityPool = { chainId: this.chainId, @@ -149,19 +178,24 @@ export class PoolIndexer { factoryAddress: integrationAddress.toLowerCase(), reserve0: baseReserve.toString(), reserve1: quoteReserve.toString(), - reserve0Usd: 0, - reserve1Usd: 0, - totalLiquidityUsd, + reserve0Usd: liquidityUsd.reserve0Usd, + reserve1Usd: liquidityUsd.reserve1Usd, + totalLiquidityUsd: liquidityUsd.totalLiquidityUsd, volume24h: 0, // No 24h volume from contract; requires event indexer createdAtBlock: 0, createdAtTimestamp: createdAt ? new Date(Number(createdAt) * 1000) : new Date(), lastUpdated: new Date(), }; - await this.poolRepo.upsertPool(pool); - pools.push(pool); + await this.persistPoolIfExposed(pool, pools); } catch (err) { - logger.warn(`Skipping DODO PMM pool ${poolAddress}; it may have been removed from integration state.`, err); + const stalePoolKey = `${this.chainId}:${poolAddress.toLowerCase()}`; + if (!PoolIndexer.staleDodoPoolsLogged.has(stalePoolKey)) { + logger.info( + `Skipping stale DODO PMM pool ${poolAddress} on chain ${this.chainId}; it is no longer registered in DODOPMMIntegration` + ); + PoolIndexer.staleDodoPoolsLogged.add(stalePoolKey); + } } } } catch (error) { @@ -217,8 +251,7 @@ export class PoolIndexer { lastUpdated: new Date(), }; - await this.poolRepo.upsertPool(pool); - pools.push(pool); + await this.persistPoolIfExposed(pool, pools); } } } catch (error) { @@ -273,8 +306,7 @@ export class PoolIndexer { lastUpdated: new Date(), }; - await this.poolRepo.upsertPool(pool); - pools.push(pool); + await this.persistPoolIfExposed(pool, pools); } } } catch (error) { @@ -335,8 +367,7 @@ export class PoolIndexer { lastUpdated: new Date(), }; - await this.poolRepo.upsertPool(pool); - pools.push(pool); + await this.persistPoolIfExposed(pool, pools); } catch (error) { logger.error(`Error indexing DODO pool ${poolAddress}:`, error); } diff --git a/services/token-aggregation/src/indexer/volume-calculator.test.ts b/services/token-aggregation/src/indexer/volume-calculator.test.ts new file mode 100644 index 0000000..c10a691 --- /dev/null +++ b/services/token-aggregation/src/indexer/volume-calculator.test.ts @@ -0,0 +1,63 @@ +const mockQuery = jest.fn(); +const mockLoggerInfo = jest.fn(); + +jest.mock('../database/client', () => ({ + getDatabasePool: () => ({ query: mockQuery }), +})); + +jest.mock('../utils/logger', () => ({ + logger: { + info: mockLoggerInfo, + warn: jest.fn(), + error: jest.fn(), + }, +})); + +import { VolumeCalculator } from './volume-calculator'; + +describe('VolumeCalculator', () => { + beforeEach(() => { + mockQuery.mockReset(); + mockLoggerInfo.mockReset(); + }); + + it('returns zero metrics and logs once when market-data tables are not provisioned yet', async () => { + mockQuery.mockRejectedValue({ + code: '42P01', + message: 'relation "liquidity_pools" does not exist', + }); + + const calculator = new VolumeCalculator(); + + await expect(calculator.calculateTokenVolume(138, '0xToken')).resolves.toEqual({ + volume5m: 0, + volume1h: 0, + volume24h: 0, + volume7d: 0, + volume30d: 0, + txCount5m: 0, + txCount1h: 0, + txCount24h: 0, + }); + + await expect(calculator.calculateTokenVolume(138, '0xToken')).resolves.toEqual({ + volume5m: 0, + volume1h: 0, + volume24h: 0, + volume7d: 0, + volume30d: 0, + txCount5m: 0, + txCount1h: 0, + txCount24h: 0, + }); + + expect(mockLoggerInfo).toHaveBeenCalledTimes(1); + expect(mockLoggerInfo).toHaveBeenCalledWith( + 'Skipping volume calculation; relation "liquidity_pools" is not available yet', + expect.objectContaining({ + operation: 'volume calculation', + relation: 'liquidity_pools', + }) + ); + }); +}); diff --git a/services/token-aggregation/src/indexer/volume-calculator.ts b/services/token-aggregation/src/indexer/volume-calculator.ts index 11cc65d..5cb296a 100644 --- a/services/token-aggregation/src/indexer/volume-calculator.ts +++ b/services/token-aggregation/src/indexer/volume-calculator.ts @@ -1,5 +1,6 @@ import { Pool } from 'pg'; import { getDatabasePool } from '../database/client'; +import { logger } from '../utils/logger'; export interface VolumeMetrics { volume5m: number; @@ -14,11 +15,44 @@ export interface VolumeMetrics { export class VolumeCalculator { private pool: Pool; + private static readonly missingRelationWarnings = new Set(); constructor() { this.pool = getDatabasePool(); } + private isMissingRelationError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + + const code = (error as { code?: string }).code; + const message = (error as { message?: string }).message || ''; + return code === '42P01' || (message.includes('relation "') && message.includes('" does not exist')); + } + + private getMissingRelationName(error: unknown): string { + const message = typeof error === 'object' && error && 'message' in error + ? String((error as { message?: string }).message || '') + : ''; + const match = message.match(/relation "([^"]+)"/); + return match?.[1] || 'unknown relation'; + } + + private logMissingRelationOnce(operation: string, error: unknown): void { + const relation = this.getMissingRelationName(error); + const key = `${operation}:${relation}`; + if (VolumeCalculator.missingRelationWarnings.has(key)) { + return; + } + + VolumeCalculator.missingRelationWarnings.add(key); + logger.info(`Skipping ${operation}; relation "${relation}" is not available yet`, { + operation, + relation, + }); + } + /** * Calculate volume metrics for a token across all pools */ @@ -27,56 +61,55 @@ export class VolumeCalculator { tokenAddress: string, now: Date = new Date() ): Promise { - const intervals = { - '5m': new Date(now.getTime() - 5 * 60 * 1000), - '1h': new Date(now.getTime() - 60 * 60 * 1000), - '24h': new Date(now.getTime() - 24 * 60 * 60 * 1000), - '7d': new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), - '30d': new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), - }; - - // Get all pools for this token - const poolsResult = await this.pool.query( - `SELECT pool_address FROM liquidity_pools - WHERE chain_id = $1 AND (token0_address = $2 OR token1_address = $2)`, - [chainId, tokenAddress.toLowerCase()] - ); - - const poolAddresses = poolsResult.rows.map((row) => row.pool_address); - - if (poolAddresses.length === 0) { - return { - volume5m: 0, - volume1h: 0, - volume24h: 0, - volume7d: 0, - volume30d: 0, - txCount5m: 0, - txCount1h: 0, - txCount24h: 0, + try { + const intervals = { + '5m': new Date(now.getTime() - 5 * 60 * 1000), + '1h': new Date(now.getTime() - 60 * 60 * 1000), + '24h': new Date(now.getTime() - 24 * 60 * 60 * 1000), + '7d': new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), + '30d': new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), }; + + // Get all pools for this token + const poolsResult = await this.pool.query( + `SELECT pool_address FROM liquidity_pools + WHERE chain_id = $1 AND (token0_address = $2 OR token1_address = $2)`, + [chainId, tokenAddress.toLowerCase()] + ); + + const poolAddresses = poolsResult.rows.map((row) => row.pool_address); + + if (poolAddresses.length === 0) { + return this.zeroMetrics(); + } + + // Calculate volume for each interval + const [volume5m, volume1h, volume24h, volume7d, volume30d, txCounts] = await Promise.all([ + this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['5m'], now), + this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['1h'], now), + this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['24h'], now), + this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['7d'], now), + this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['30d'], now), + this.calculateTxCounts(chainId, poolAddresses, intervals, now), + ]); + + return { + volume5m, + volume1h, + volume24h, + volume7d, + volume30d, + txCount5m: txCounts['5m'], + txCount1h: txCounts['1h'], + txCount24h: txCounts['24h'], + }; + } catch (error) { + if (this.isMissingRelationError(error)) { + this.logMissingRelationOnce('volume calculation', error); + return this.zeroMetrics(); + } + throw error; } - - // Calculate volume for each interval - const [volume5m, volume1h, volume24h, volume7d, volume30d, txCounts] = await Promise.all([ - this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['5m'], now), - this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['1h'], now), - this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['24h'], now), - this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['7d'], now), - this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['30d'], now), - this.calculateTxCounts(chainId, poolAddresses, intervals, now), - ]); - - return { - volume5m, - volume1h, - volume24h, - volume7d, - volume30d, - txCount5m: txCounts['5m'], - txCount1h: txCounts['1h'], - txCount24h: txCounts['24h'], - }; } /** @@ -164,24 +197,45 @@ export class VolumeCalculator { interval: '5m' | '1h' | '24h' | '7d' | '30d', now: Date = new Date() ): Promise { - const intervals = { - '5m': new Date(now.getTime() - 5 * 60 * 1000), - '1h': new Date(now.getTime() - 60 * 60 * 1000), - '24h': new Date(now.getTime() - 24 * 60 * 60 * 1000), - '7d': new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), - '30d': new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), + try { + const intervals = { + '5m': new Date(now.getTime() - 5 * 60 * 1000), + '1h': new Date(now.getTime() - 60 * 60 * 1000), + '24h': new Date(now.getTime() - 24 * 60 * 60 * 1000), + '7d': new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), + '30d': new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), + }; + + const result = await this.pool.query( + `SELECT COALESCE(SUM(amount_usd), 0) as total_volume + FROM swap_events + WHERE chain_id = $1 + AND pool_address = $2 + AND timestamp >= $3 + AND timestamp <= $4`, + [chainId, poolAddress.toLowerCase(), intervals[interval], now] + ); + + return parseFloat(result.rows[0]?.total_volume || '0'); + } catch (error) { + if (this.isMissingRelationError(error)) { + this.logMissingRelationOnce('pool volume calculation', error); + return 0; + } + throw error; + } + } + + private zeroMetrics(): VolumeMetrics { + return { + volume5m: 0, + volume1h: 0, + volume24h: 0, + volume7d: 0, + volume30d: 0, + txCount5m: 0, + txCount1h: 0, + txCount24h: 0, }; - - const result = await this.pool.query( - `SELECT COALESCE(SUM(amount_usd), 0) as total_volume - FROM swap_events - WHERE chain_id = $1 - AND pool_address = $2 - AND timestamp >= $3 - AND timestamp <= $4`, - [chainId, poolAddress.toLowerCase(), intervals[interval], now] - ); - - return parseFloat(result.rows[0]?.total_volume || '0'); } } diff --git a/services/token-aggregation/src/services/aggregator-route-matrix-generator.ts b/services/token-aggregation/src/services/aggregator-route-matrix-generator.ts new file mode 100644 index 0000000..8643d38 --- /dev/null +++ b/services/token-aggregation/src/services/aggregator-route-matrix-generator.ts @@ -0,0 +1,295 @@ +import fs from 'fs'; +import path from 'path'; +import { + AggregatorFamily, + AggregatorRouteMatrix, + AggregatorRouteLeg, + LiveAggregatorRoute, + NonLiveAggregatorRoute, +} from '../config/aggregator-route-matrix'; +import { getProviderCapabilities } from '../config/provider-capabilities'; +import { resolveChain138RpcUrl } from '../config/chain138-rpc'; +import { getChain138RoutingAssets, getRoutingSymbolForAddress } from '../config/routing-assets'; +import { RouteGraphBuilder } from './route-graph-builder'; +import { PlannerProvider, SwapGraphEdge } from './planner-v2-types'; + +const PARTNER_FAMILIES: AggregatorFamily[] = ['1inch', '0x', 'LiFi']; + +function providerProtocol(provider: PlannerProvider): string { + switch (provider) { + case 'dodo': + return 'dodo_pmm'; + case 'dodo_v3': + return 'dodo_v3'; + case 'uniswap_v3': + return 'uniswap_v3'; + case 'balancer': + return 'balancer'; + case 'curve': + return 'curve'; + case 'one_inch': + return 'one_inch'; + case 'partner': + return 'partner'; + } +} + +function providerLabel(provider: PlannerProvider): string { + switch (provider) { + case 'dodo': + return 'DODO PMM'; + case 'dodo_v3': + return 'DODO V3 / D3MM'; + case 'uniswap_v3': + return 'Uniswap V3'; + case 'balancer': + return 'Balancer'; + case 'curve': + return 'Curve'; + case 'one_inch': + return '1inch'; + case 'partner': + return 'Partner'; + } +} + +function normalizeAddress(value?: string): string | undefined { + return value?.trim().toLowerCase() || undefined; +} + +function makeRouteId(prefix: string, parts: Array): string { + return [prefix, ...parts] + .filter((part): part is string | number => part !== undefined && part !== null && String(part).trim().length > 0) + .map((part) => String(part).trim().toLowerCase().replace(/[^a-z0-9]+/g, '-')) + .join('-'); +} + +function dedupeRoutes(routes: T[]): T[] { + const byId = new Map(); + for (const route of routes) { + byId.set(route.routeId, route); + } + return Array.from(byId.values()); +} + +function toSwapLeg(edge: SwapGraphEdge): AggregatorRouteLeg { + return { + kind: 'swap', + protocol: providerProtocol(edge.provider), + executor: providerLabel(edge.provider), + executorAddress: normalizeAddress(edge.target), + poolAddress: normalizeAddress(edge.poolAddress), + tokenInAddress: normalizeAddress(edge.tokenInAddress), + tokenOutAddress: normalizeAddress(edge.tokenOutAddress), + reserves: { + reserveIn: edge.reserveIn, + reserveOut: edge.reserveOut, + }, + }; +} + +function buildSwapRoute(homeChainId: number, edge: SwapGraphEdge): LiveAggregatorRoute { + return { + routeId: makeRouteId(`chain-${homeChainId}-swap`, [ + edge.provider, + edge.tokenInSymbol || edge.tokenInAddress, + edge.tokenOutSymbol || edge.tokenOutAddress, + edge.poolAddress?.slice(0, 10), + ]), + status: 'live', + aggregatorFamilies: PARTNER_FAMILIES, + fromChainId: homeChainId, + toChainId: homeChainId, + tokenInSymbol: edge.tokenInSymbol, + tokenInAddress: normalizeAddress(edge.tokenInAddress), + tokenOutSymbol: edge.tokenOutSymbol, + tokenOutAddress: normalizeAddress(edge.tokenOutAddress), + routeType: 'swap', + hopCount: 1, + label: `${providerLabel(edge.provider)} ${edge.tokenInSymbol || edge.tokenInAddress} -> ${edge.tokenOutSymbol || edge.tokenOutAddress}`, + intermediateSymbols: [], + legs: [toSwapLeg(edge)], + tags: ['planner-v2-generated', edge.provider], + notes: [ + 'Generated from live planner route graph.', + ...(edge.notes || []), + ], + }; +} + +function buildBridgeRoute(args: { + fromChainId: number; + toChainId: number; + assetSymbol: string; + assetAddress?: string; + bridgeType: string; + bridgeAddress: string; + label: string; + notes?: string[]; +}): LiveAggregatorRoute { + return { + routeId: makeRouteId('bridge', [ + args.fromChainId, + args.toChainId, + args.assetSymbol, + args.label, + ]), + status: 'live', + aggregatorFamilies: PARTNER_FAMILIES, + fromChainId: args.fromChainId, + toChainId: args.toChainId, + assetSymbol: args.assetSymbol, + assetAddress: normalizeAddress(args.assetAddress), + routeType: 'bridge', + bridgeType: args.bridgeType, + bridgeAddress: normalizeAddress(args.bridgeAddress), + label: `${args.label} ${args.assetSymbol} ${args.fromChainId} -> ${args.toChainId}`, + tags: ['planner-v2-generated', 'bridge'], + notes: [ + 'Generated from bridge registry and planner visibility.', + ...(args.notes || []), + ], + }; +} + +function resolveDefaultOutputPath(): string { + return path.resolve(process.cwd(), '../../../config/aggregator-route-matrix.json'); +} + +export class AggregatorRouteMatrixGenerator { + private graphBuilder: RouteGraphBuilder; + + constructor(graphBuilder = new RouteGraphBuilder()) { + this.graphBuilder = graphBuilder; + } + + async generate(homeChainId: number = 138): Promise { + const edges = await this.graphBuilder.buildSwapEdges(homeChainId); + const liveSwapRoutes = dedupeRoutes(edges.map((edge) => buildSwapRoute(homeChainId, edge))); + + const routingAssets = Object.values(getChain138RoutingAssets()); + const bridgeTargets = [1, 651940]; + const liveBridgeRoutes = dedupeRoutes( + bridgeTargets.flatMap((destinationChainId) => + this.graphBuilder + .buildBridgeCandidates( + homeChainId, + destinationChainId, + routingAssets.map((asset) => asset.symbol) + ) + .map((candidate) => + buildBridgeRoute({ + fromChainId: candidate.fromChainId, + toChainId: candidate.toChainId, + assetSymbol: candidate.assetSymbol, + assetAddress: candidate.sourceTokenAddress, + bridgeType: candidate.bridgeType, + bridgeAddress: candidate.bridgeAddress, + label: candidate.bridgeLabel, + notes: candidate.notes, + }) + ) + ) + ); + + const blockedOrPlannedRoutes: NonLiveAggregatorRoute[] = getProviderCapabilities(homeChainId) + .flatMap((record) => + record.pairs + .filter((pair): pair is typeof pair & { status: 'planned' | 'blocked' } => pair.status !== 'live') + .map((pair) => ({ + routeId: makeRouteId('chain-138-capability', [ + record.provider, + pair.status, + pair.tokenInSymbol, + pair.tokenOutSymbol, + ]), + status: pair.status, + fromChainId: pair.chainId, + toChainId: pair.chainId, + routeType: pair.legType === 'bridge' ? 'bridge' : 'swap', + reason: pair.reason || pair.notes?.join(' ') || `${record.provider} ${pair.status}`, + tokenInSymbols: [pair.tokenInSymbol, pair.tokenOutSymbol], + })) + ); + + return { + $schema: 'https://json-schema.org/draft/2020-12/schema', + description: 'Planner-v2-generated aggregator route visibility matrix for Chain 138 and approved bridge lanes.', + version: '2.0.0', + updated: new Date().toISOString(), + homeChainId, + metadata: { + generatedFrom: [ + 'services/token-aggregation/src/services/route-graph-builder.ts', + 'services/token-aggregation/src/config/provider-capabilities.ts', + 'services/token-aggregation/src/config/cross-chain-bridges.ts', + ], + verification: { + verifiedAt: new Date().toISOString(), + verifiedBy: 'services/token-aggregation planner-v2 generator', + rpc: resolveChain138RpcUrl(), + }, + adapterNotes: [ + 'This file is generated from planner-v2 graph and provider capability truth.', + 'Partner payload generation should prefer planner-v2 outputs over this visibility artifact when route inputs are available.', + 'Only live routes should be considered executable candidates.', + ], + }, + chains: { + '138': { name: 'Chain 138' }, + '1': { name: 'Ethereum Mainnet' }, + '651940': { name: 'ALL Mainnet' }, + }, + tokens: Object.fromEntries( + routingAssets.map((asset) => [asset.symbol, { address: asset.address, decimals: asset.decimals, kind: asset.kind }]) + ), + liveSwapRoutes, + liveBridgeRoutes, + blockedOrPlannedRoutes: dedupeRoutes(blockedOrPlannedRoutes), + }; + } + + async writeToFile(outputPath: string = resolveDefaultOutputPath(), homeChainId: number = 138): Promise { + const matrix = await this.generate(homeChainId); + fs.writeFileSync(outputPath, `${JSON.stringify(matrix, null, 2)}\n`, 'utf8'); + return outputPath; + } +} + +export function routeFromPlannerLegs(args: { + routeId: string; + fromChainId: number; + toChainId: number; + tokenInAddress?: string; + tokenOutAddress?: string; + assetAddress?: string; + assetSymbol?: string; + routeType: 'swap' | 'bridge'; + bridgeType?: string; + bridgeAddress?: string; + label: string; + legs: AggregatorRouteLeg[]; + notes?: string[]; +}): LiveAggregatorRoute { + return { + routeId: args.routeId, + status: 'live', + aggregatorFamilies: PARTNER_FAMILIES, + fromChainId: args.fromChainId, + toChainId: args.toChainId, + tokenInAddress: normalizeAddress(args.tokenInAddress), + tokenOutAddress: normalizeAddress(args.tokenOutAddress), + tokenInSymbol: args.tokenInAddress ? getRoutingSymbolForAddress(args.fromChainId, args.tokenInAddress) : undefined, + tokenOutSymbol: args.tokenOutAddress ? getRoutingSymbolForAddress(args.toChainId, args.tokenOutAddress) : undefined, + assetAddress: normalizeAddress(args.assetAddress), + assetSymbol: args.assetSymbol, + routeType: args.routeType, + bridgeType: args.bridgeType, + bridgeAddress: normalizeAddress(args.bridgeAddress), + label: args.label, + hopCount: args.routeType === 'swap' ? args.legs.length : undefined, + legs: args.legs, + tags: ['planner-v2-generated'], + notes: args.notes || [], + }; +} diff --git a/services/token-aggregation/src/services/arbitrage-scanner.ts b/services/token-aggregation/src/services/arbitrage-scanner.ts index ced0137..394e278 100644 --- a/services/token-aggregation/src/services/arbitrage-scanner.ts +++ b/services/token-aggregation/src/services/arbitrage-scanner.ts @@ -7,6 +7,7 @@ import { PoolRepository } from '../database/repositories/pool-repo'; import { TokenRepository } from '../database/repositories/token-repo'; import { getRoutesList, getChainIds } from '../config/heatmap-chains'; +import { filterPoolsForRouting, getActiveTransportPairs } from '../config/gru-transport'; export interface ArbitrageOpportunity { cycleId: string; @@ -21,12 +22,18 @@ const poolRepo = new PoolRepository(); const tokenRepo = new TokenRepository(); const HUB_CHAIN = 138; -const EDGE_CHAINS = getChainIds().filter((c) => c !== HUB_CHAIN && c !== 651940); +const EDGE_CHAINS = Array.from( + new Set( + getActiveTransportPairs() + .map((pair) => pair.destinationChainId) + .filter((chainId) => chainId !== HUB_CHAIN && chainId !== 651940) + ) +); /** Same-chain triangle on 138: e.g. cUSDT -> cUSDC -> cUSDT via two pools. */ async function getSameChainCycles(): Promise { const out: ArbitrageOpportunity[] = []; - const pools = await poolRepo.getPoolsByChain(HUB_CHAIN, 100); + const pools = filterPoolsForRouting(HUB_CHAIN, await poolRepo.getPoolsByChain(HUB_CHAIN, 100)); for (const p of pools) { const t0 = await tokenRepo.getToken(HUB_CHAIN, p.token0Address); const t1 = await tokenRepo.getToken(HUB_CHAIN, p.token1Address); @@ -53,7 +60,8 @@ async function getSameChainCycles(): Promise { /** Hub-edge-hub: 138 -> edge -> 138 (SBS). */ function getHubEdgeHubCycles(): ArbitrageOpportunity[] { const out: ArbitrageOpportunity[] = []; - const routes = getRoutesList(); + const activeDestinations = new Set(EDGE_CHAINS); + const routes = getRoutesList().filter((route) => activeDestinations.has(route.toChainId)); const hubOut = routes.filter((r) => r.fromChainId === HUB_CHAIN && r.toChainId !== HUB_CHAIN); for (const r of hubOut.slice(0, 5)) { out.push({ diff --git a/services/token-aggregation/src/services/best-execution-planner.test.ts b/services/token-aggregation/src/services/best-execution-planner.test.ts new file mode 100644 index 0000000..fee59ac --- /dev/null +++ b/services/token-aggregation/src/services/best-execution-planner.test.ts @@ -0,0 +1,190 @@ +import { BestExecutionPlanner } from './best-execution-planner'; +import { PlannerMetricsRepository } from '../database/repositories/planner-metrics-repo'; +import { RouteGraphBuilder } from './route-graph-builder'; +import type { BridgeRouteCandidate, PlannerResponse, SwapGraphEdge } from './planner-v2-types'; + +const mockDodoV3Quote = jest.fn(); + +jest.mock('./dodo-v3-pilot', () => ({ + __esModule: true, + quoteChain138DodoV3AmountOut: (...args: unknown[]) => mockDodoV3Quote(...args), + encodeChain138DodoV3ProviderData: (poolAddress: string) => + `0x000000000000000000000000${String(poolAddress).replace(/^0x/, '').toLowerCase()}`, + isChain138DodoV3ExecutionLive: () => true, +})); + +class MockPlannerMetricsRepository { + async getCachedPlan(): Promise { + return null; + } + async cachePlan(): Promise {} + async recordProviderSnapshots(): Promise {} + async recordPlannedRouteMetrics(): Promise {} +} + +class MockGraphBuilder { + private swapEdges: SwapGraphEdge[]; + private bridgeCandidates: BridgeRouteCandidate[]; + + constructor(swapEdges: SwapGraphEdge[] = [], bridgeCandidates: BridgeRouteCandidate[] = []) { + this.swapEdges = swapEdges; + this.bridgeCandidates = bridgeCandidates; + } + + async buildSwapEdges(): Promise { + return this.swapEdges; + } + + buildBridgeCandidates(): BridgeRouteCandidate[] { + return this.bridgeCandidates; + } +} + +const WETH = '0xc02aaA39b223fe8d0a0e5c4f27ead9083c756cc2'.toLowerCase(); +const USDT = '0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1'.toLowerCase(); +const USDC = '0x71d6687f38b93ccad569fa6352c876eea967201b'.toLowerCase(); +const CUSDT = '0x93e66202a11b1772e55407b32b44e5cd8eda7f22'.toLowerCase(); +const CEURT = '0xdf4b71c61e5912712c1bdd451416b9ac26949d72'.toLowerCase(); +const CXAUC = '0x290e52a8819a4fbd0714e517225429aa2b70ec6b'.toLowerCase(); +const WETH10 = '0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f'.toLowerCase(); + +function swapEdge( + tokenInAddress: string, + tokenOutAddress: string, + reserveIn: string, + reserveOut: string, + tokenInSymbol: string, + tokenOutSymbol: string, + provider: SwapGraphEdge['provider'] = 'dodo' +): SwapGraphEdge { + return { + kind: 'swap', + provider, + chainId: 138, + tokenInAddress, + tokenOutAddress, + tokenInSymbol, + tokenOutSymbol, + reserveIn, + reserveOut, + target: '0x3f729632e9553ebaccde2e9b4c8f2b285b014f2e', + poolAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + providerData: { poolAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + providerDataHex: '0x000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + totalLiquidityUsd: 2_000_000, + freshnessSeconds: 60, + notes: [], + }; +} + +describe('BestExecutionPlanner', () => { + beforeEach(() => { + mockDodoV3Quote.mockReset(); + }); + + it('prefers the direct route when it scores better than multi-hop', async () => { + const graphBuilder = new MockGraphBuilder([ + swapEdge(WETH, USDT, '100000000000000000000', '2000000000000', 'WETH', 'USDT'), + swapEdge(WETH, CUSDT, '100000000000000000000', '1900000000000', 'WETH', 'cUSDT'), + swapEdge(CUSDT, USDT, '1900000000000', '1850000000000', 'cUSDT', 'USDT'), + ]) as unknown as RouteGraphBuilder; + + const planner = new BestExecutionPlanner(graphBuilder, new MockPlannerMetricsRepository() as unknown as PlannerMetricsRepository); + const response = await planner.plan({ + sourceChainId: 138, + tokenIn: WETH, + tokenOut: USDT, + amountIn: '1000000000000000000', + }); + + expect(response.decision).toBe('direct-pool'); + expect(response.legs).toHaveLength(1); + expect(response.legs[0].tokenOutAddress).toBe(USDT); + }); + + it('chooses multi-hop when no direct route exists', async () => { + const graphBuilder = new MockGraphBuilder([ + swapEdge(WETH, CUSDT, '100000000000000000000', '1900000000000', 'WETH', 'cUSDT'), + swapEdge(CUSDT, USDC, '1900000000000', '1880000000000', 'cUSDT', 'USDC'), + ]) as unknown as RouteGraphBuilder; + + const planner = new BestExecutionPlanner(graphBuilder, new MockPlannerMetricsRepository() as unknown as PlannerMetricsRepository); + const response = await planner.plan({ + sourceChainId: 138, + tokenIn: WETH, + tokenOut: USDC, + amountIn: '1000000000000000000', + }); + + expect(response.decision).toBe('multi-hop'); + expect(response.legs).toHaveLength(2); + expect(response.legs[0].tokenOutAddress).toBe(CUSDT); + expect(response.legs[1].tokenOutAddress).toBe(USDC); + }); + + it('blocks commodity intermediates under institutional policy unless explicitly enabled', async () => { + const graphBuilder = new MockGraphBuilder([ + swapEdge(CEURT, CXAUC, '1000000000000', '1000000000000', 'cEURT', 'cXAUC'), + swapEdge(CXAUC, CUSDT, '1000000000000', '1000000000000', 'cXAUC', 'cUSDT'), + ]) as unknown as RouteGraphBuilder; + + const planner = new BestExecutionPlanner(graphBuilder, new MockPlannerMetricsRepository() as unknown as PlannerMetricsRepository); + const response = await planner.plan({ + sourceChainId: 138, + tokenIn: CEURT, + tokenOut: CUSDT, + amountIn: '1000000', + constraints: { + complianceProfile: 'institutional', + }, + }); + + expect(response.decision).toBe('unresolved'); + expect(response.riskFlags).toContain('no-route'); + }); + + it('returns deterministic plan ids for identical requests', async () => { + const graphBuilder = new MockGraphBuilder([ + swapEdge(WETH, USDT, '100000000000000000000', '2000000000000', 'WETH', 'USDT'), + ]) as unknown as RouteGraphBuilder; + + const planner = new BestExecutionPlanner(graphBuilder, new MockPlannerMetricsRepository() as unknown as PlannerMetricsRepository); + const request = { + sourceChainId: 138, + tokenIn: WETH, + tokenOut: USDT, + amountIn: '1000000000000000000', + }; + + const first = await planner.plan(request); + const second = await planner.plan(request); + + expect(first.planId).toBe(second.planId); + expect(first.legs).toEqual(second.legs); + }); + + it('returns a live planner route and executable router-v2 calldata for the DODO v3 pilot when execution is enabled', async () => { + mockDodoV3Quote.mockResolvedValue(211660490n); + + const graphBuilder = new MockGraphBuilder([ + swapEdge(WETH10, USDT, '2010000000000000000', '4978833460', 'WETH10', 'USDT', 'dodo_v3'), + ]) as unknown as RouteGraphBuilder; + + const planner = new BestExecutionPlanner(graphBuilder, new MockPlannerMetricsRepository() as unknown as PlannerMetricsRepository); + const response = await planner.plan({ + sourceChainId: 138, + tokenIn: WETH10, + tokenOut: USDT, + amountIn: '100000000000000000', + }); + + expect(response.decision).toBe('direct-pool'); + expect(response.legs).toHaveLength(1); + expect(response.legs[0].provider).toBe('dodo_v3'); + expect(response.estimatedAmountOut).toBe('211660490'); + expect(response.routePlan).toBeDefined(); + expect(response.routePlan?.legs[0]?.provider).toBe(6); + expect(response.riskFlags).toContain('pilot-venue'); + expect(response.riskFlags).not.toContain('manual-execution-only'); + }); +}); diff --git a/services/token-aggregation/src/services/best-execution-planner.ts b/services/token-aggregation/src/services/best-execution-planner.ts new file mode 100644 index 0000000..4a6c424 --- /dev/null +++ b/services/token-aggregation/src/services/best-execution-planner.ts @@ -0,0 +1,783 @@ +import { AbiCoder, Contract, JsonRpcProvider, ZeroAddress, formatUnits } from 'ethers'; +import { getCanonicalTokenByAddress, resolveCanonicalQuoteAddress } from '../config/canonical-tokens'; +import { resolveChain138RpcUrl } from '../config/chain138-rpc'; +import { getProviderCapabilities } from '../config/provider-capabilities'; +import { resolveRoutingPolicy } from '../config/routing-policies'; +import { + getCommodityIntermediateAddresses, + getDefaultIntermediateAddresses, + getRoutingAddressForSymbol, + getRoutingSymbolForAddress, +} from '../config/routing-assets'; +import { PlannerMetricsRepository } from '../database/repositories/planner-metrics-repo'; +import { RouteGraphBuilder } from './route-graph-builder'; +import { quoteChain138DodoV3AmountOut } from './dodo-v3-pilot'; +import { + BridgeRouteCandidate, + EncodedBridgeIntentPlan, + EncodedPlannerRoutePlan, + PlannerAlternative, + PlannerDecision, + PlannerLeg, + PlannerProvider, + PlannerRequest, + PlannerResponse, + RoutingPolicy, + SwapGraphEdge, +} from './planner-v2-types'; + +const abiCoder = AbiCoder.defaultAbiCoder(); +const ROUTER_V2_RECIPIENT_PLACEHOLDER = ZeroAddress; +const DEFAULT_INTENT_BRIDGE_COORDINATOR_V2 = normalizeAddress('0x7D0022B7e8360172fd9C0bB6778113b7Ea3674E7'); +const PROVIDER_PRIORITY: PlannerProvider[] = ['dodo', 'dodo_v3', 'uniswap_v3', 'balancer', 'curve', 'one_inch', 'partner']; +const PROVIDER_ENUM: Partial> = { + dodo: 0, + uniswap_v3: 1, + balancer: 2, + curve: 3, + one_inch: 4, + partner: 5, + dodo_v3: 6, +}; +const PROVIDER_GAS_USD: Record = { + dodo: 0.22, + dodo_v3: 0.3, + uniswap_v3: 0.28, + balancer: 0.34, + curve: 0.29, + one_inch: 0.48, + partner: 0.55, +}; +const uniswapQuoterAbi = [ + 'function quoteExactInputSingle((address,address,uint256,uint24,uint160) params) view returns (uint256,uint160,uint32,uint256)', + 'function quoteExactInputSingle(address tokenIn,address tokenOut,uint24 fee,uint256 amountIn,uint160 sqrtPriceLimitX96) view returns (uint256)', +] as const; + +interface RouteCandidate { + decision: PlannerDecision; + estimatedAmountOut: bigint; + estimatedGasUsd: number; + score: number; + legs: PlannerLeg[]; + selectedRouteReason: string; + rejectedAlternatives: string[]; + riskFlags: string[]; +} + +function normalizeAddress(value: string): string { + return value.trim().toLowerCase(); +} + +let sharedProvider: JsonRpcProvider | null = null; +let sharedProviderUrl = ''; + +function getProvider(): JsonRpcProvider { + const rpcUrl = resolveChain138RpcUrl(); + if (!sharedProvider || sharedProviderUrl !== rpcUrl) { + sharedProvider = new JsonRpcProvider(rpcUrl); + sharedProviderUrl = rpcUrl; + } + return sharedProvider; +} + +function safeBigInt(value: string): bigint { + return BigInt(value); +} + +function quoteAmountOut(amountIn: bigint, reserveIn: bigint, reserveOut: bigint): bigint { + if (amountIn <= 0n || reserveIn <= 0n || reserveOut <= 0n) { + return 0n; + } + const amountInWithFee = amountIn * 997n; + return (reserveOut * amountInWithFee) / (reserveIn * 1000n + amountInWithFee); +} + +function sortEdges(edges: SwapGraphEdge[]): SwapGraphEdge[] { + return edges.slice().sort((a, b) => { + const providerDiff = PROVIDER_PRIORITY.indexOf(a.provider) - PROVIDER_PRIORITY.indexOf(b.provider); + if (providerDiff !== 0) return providerDiff; + const liquidityDiff = (b.totalLiquidityUsd || 0) - (a.totalLiquidityUsd || 0); + if (liquidityDiff !== 0) return liquidityDiff; + return a.tokenOutAddress.localeCompare(b.tokenOutAddress); + }); +} + +function decimalsForAddress(chainId: number, address: string): number { + const spec = getCanonicalTokenByAddress(chainId, address); + if (spec?.decimals) return Number(spec.decimals); + const symbol = getRoutingSymbolForAddress(chainId, address); + if (symbol === 'WETH' || symbol === 'WETH10') return 18; + return 6; +} + +function normalizedOutput(chainId: number, address: string, amount: bigint): number { + const decimals = decimalsForAddress(chainId, address); + return Number(formatUnits(amount, decimals)); +} + +function providerDataHexForEdge(edge: SwapGraphEdge): string { + if (edge.providerDataHex) { + return edge.providerDataHex; + } + if (edge.provider === 'dodo' && edge.poolAddress) { + return abiCoder.encode(['address'], [edge.poolAddress]); + } + return '0x'; +} + +function providerDataHexForLeg(leg: PlannerLeg): string | undefined { + if (leg.providerDataHex && leg.providerDataHex !== '0x') { + return leg.providerDataHex; + } + if (leg.provider === 'dodo' && leg.poolAddress) { + return abiCoder.encode(['address'], [leg.poolAddress]); + } + return undefined; +} + +function buildMinAmountOut(amountOut: bigint, maxSlippageBps: number): bigint { + return (amountOut * BigInt(10_000 - maxSlippageBps)) / 10_000n; +} + +function computeConfidence(legs: PlannerLeg[]): number { + const stalePenalty = legs.some((leg) => (leg.freshnessSeconds || 0) > 1800) ? 0.15 : 0; + const hopPenalty = Math.max(0, legs.length - 1) * 0.07; + return Math.max(0.1, Math.min(0.99, 0.92 - stalePenalty - hopPenalty)); +} + +function computeRiskFlags(legs: PlannerLeg[], policy: RoutingPolicy): string[] { + const flags = new Set(); + if (legs.length > 1) { + flags.add('multi-hop'); + } + if (legs.some((leg) => leg.kind === 'bridge')) { + flags.add('cross-chain'); + } + if (legs.some((leg) => (leg.freshnessSeconds || 0) > 1800)) { + flags.add('stale-liquidity'); + } + if (!policy.allowCommodityIntermediates && legs.some((leg) => leg.tokenInSymbol === 'cXAUC' || leg.tokenOutSymbol === 'cXAUC')) { + flags.add('commodity-path-blocked'); + } + if (legs.some((leg) => leg.provider === 'dodo_v3')) { + flags.add('pilot-venue'); + if (!legs.every((leg) => leg.provider !== 'dodo_v3' || Boolean(providerDataHexForLeg(leg)))) { + flags.add('manual-execution-only'); + } + } + return Array.from(flags); +} + +async function quoteUniswapV3AmountOut(edge: SwapGraphEdge, amountIn: bigint): Promise { + const providerData = (edge.providerData || {}) as { quoter?: string; fee?: number }; + const quoter = normalizeAddress(String(providerData.quoter || '')); + const fee = Number(providerData.fee || 3000); + if (!quoter) { + return 0n; + } + + const contract = new Contract(quoter, uniswapQuoterAbi, getProvider()); + try { + const result = await contract.quoteExactInputSingle([ + edge.tokenInAddress, + edge.tokenOutAddress, + amountIn, + fee, + 0, + ]); + return BigInt(String(Array.isArray(result) ? result[0] : result)); + } catch { + try { + const result = await contract['quoteExactInputSingle(address,address,uint24,uint256,uint160)']( + edge.tokenInAddress, + edge.tokenOutAddress, + fee, + amountIn, + 0 + ); + return BigInt(String(result)); + } catch { + return 0n; + } + } +} + +export class BestExecutionPlanner { + private graphBuilder: RouteGraphBuilder; + private plannerRepo: PlannerMetricsRepository; + + constructor( + graphBuilder = new RouteGraphBuilder(), + plannerRepo = new PlannerMetricsRepository() + ) { + this.graphBuilder = graphBuilder; + this.plannerRepo = plannerRepo; + } + + async plan(request: PlannerRequest): Promise { + const normalizedRequest = { + ...request, + tokenIn: normalizeAddress(request.tokenIn), + tokenOut: normalizeAddress(request.tokenOut), + destinationChainId: request.destinationChainId || request.sourceChainId, + }; + const policy = resolveRoutingPolicy(normalizedRequest.sourceChainId, normalizedRequest.constraints || {}); + const requestHash = this.requestHash(normalizedRequest, policy); + const cached = await this.plannerRepo.getCachedPlan(requestHash); + if (cached) { + return cached; + } + + const capabilities = getProviderCapabilities(normalizedRequest.sourceChainId); + await this.plannerRepo.recordProviderSnapshots(normalizedRequest.sourceChainId, capabilities); + + const response = + normalizedRequest.destinationChainId === normalizedRequest.sourceChainId + ? await this.planOneChain(normalizedRequest, policy) + : await this.planCrossChain(normalizedRequest, policy); + + await this.plannerRepo.cachePlan(requestHash, response); + await this.plannerRepo.recordPlannedRouteMetrics(response); + return response; + } + + getCapabilities(chainId: number) { + return getProviderCapabilities(chainId); + } + + private requestHash(request: PlannerRequest, policy: RoutingPolicy): string { + return JSON.stringify({ + request, + policy, + }); + } + + private async quoteEdgeAmountOut( + edge: SwapGraphEdge, + amountIn: bigint, + quoteCache: Map + ): Promise { + if (edge.provider === 'uniswap_v3') { + const cacheKey = [edge.provider, edge.target, edge.tokenInAddress, edge.tokenOutAddress, amountIn.toString()].join(':'); + const cached = quoteCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const quoted = await quoteUniswapV3AmountOut(edge, amountIn); + if (quoted > 0n) { + quoteCache.set(cacheKey, quoted); + return quoted; + } + const fallback = quoteAmountOut(amountIn, safeBigInt(edge.reserveIn), safeBigInt(edge.reserveOut)); + quoteCache.set(cacheKey, fallback); + return fallback; + } + + if (edge.provider !== 'dodo_v3') { + return quoteAmountOut(amountIn, safeBigInt(edge.reserveIn), safeBigInt(edge.reserveOut)); + } + + const cacheKey = [edge.provider, edge.poolAddress, edge.tokenInAddress, edge.tokenOutAddress, amountIn.toString()].join(':'); + const cached = quoteCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + try { + const quoted = await quoteChain138DodoV3AmountOut({ + tokenInAddress: edge.tokenInAddress, + tokenOutAddress: edge.tokenOutAddress, + amountIn, + }); + quoteCache.set(cacheKey, quoted); + return quoted; + } catch { + quoteCache.set(cacheKey, 0n); + return 0n; + } + } + + private async planOneChain(request: PlannerRequest, policy: RoutingPolicy): Promise { + const sourceResolution = resolveCanonicalQuoteAddress(request.sourceChainId, request.tokenIn); + const targetResolution = resolveCanonicalQuoteAddress(request.sourceChainId, request.tokenOut); + const amountIn = safeBigInt(request.amountIn); + const maxSlippageBps = request.constraints?.maxSlippageBps || 100; + + const edges = await this.graphBuilder.buildSwapEdges(request.sourceChainId); + const allowedProviders = new Set(policy.allowedProviders); + const allowedTokens = new Set([ + sourceResolution.lookupAddress, + targetResolution.lookupAddress, + ...policy.defaultIntermediateAddresses, + ...(policy.allowCommodityIntermediates ? getCommodityIntermediateAddresses(request.sourceChainId) : []), + ]); + const eligibleEdges = sortEdges( + edges.filter((edge) => { + if (!allowedProviders.has(edge.provider)) return false; + if (!edge.target || !providerDataHexForEdge(edge)) return false; + if (edge.tokenOutSymbol === 'cXAUC' || edge.tokenInSymbol === 'cXAUC') { + return policy.allowCommodityIntermediates; + } + return allowedTokens.has(edge.tokenOutAddress) || edge.tokenOutAddress === targetResolution.lookupAddress; + }) + ); + + const byTokenIn = new Map(); + for (const edge of eligibleEdges) { + const key = edge.tokenInAddress; + const list = byTokenIn.get(key) || []; + list.push(edge); + byTokenIn.set(key, list); + } + + const candidates: RouteCandidate[] = []; + const visited = new Set([sourceResolution.lookupAddress]); + const quoteCache = new Map(); + const dfs = async ( + currentToken: string, + currentAmount: bigint, + path: PlannerLeg[], + depth: number + ): Promise => { + if (currentToken === targetResolution.lookupAddress && path.length > 0) { + candidates.push(this.toCandidate(path, currentAmount, request.sourceChainId, targetResolution.lookupAddress, policy)); + return; + } + + if (depth >= policy.maxLegs) { + return; + } + + for (const edge of byTokenIn.get(currentToken) || []) { + if (visited.has(edge.tokenOutAddress)) continue; + const quotedAmount = await this.quoteEdgeAmountOut(edge, currentAmount, quoteCache); + if (quotedAmount <= 0n) continue; + + const leg: PlannerLeg = { + kind: 'swap', + provider: edge.provider, + sourceChainId: request.sourceChainId, + destinationChainId: request.sourceChainId, + tokenInAddress: edge.tokenInAddress, + tokenOutAddress: edge.tokenOutAddress, + tokenInSymbol: edge.tokenInSymbol, + tokenOutSymbol: edge.tokenOutSymbol, + estimatedAmountIn: currentAmount.toString(), + estimatedAmountOut: quotedAmount.toString(), + minAmountOut: buildMinAmountOut(quotedAmount, maxSlippageBps).toString(), + target: edge.target, + poolAddress: edge.poolAddress, + providerData: edge.providerData, + providerDataHex: providerDataHexForEdge(edge), + gasEstimate: Math.round((PROVIDER_GAS_USD[edge.provider] / 2500) * 1_000_000), + freshnessSeconds: edge.freshnessSeconds, + totalLiquidityUsd: edge.totalLiquidityUsd, + notes: edge.notes, + }; + + visited.add(edge.tokenOutAddress); + await dfs(edge.tokenOutAddress, quotedAmount, [...path, leg], depth + 1); + visited.delete(edge.tokenOutAddress); + } + }; + + await dfs(sourceResolution.lookupAddress, amountIn, [], 0); + const sortedCandidates = candidates.sort((a, b) => b.score - a.score); + const best = sortedCandidates[0]; + + if (!best) { + return this.emptyResponse(request, 'unresolved', 'No eligible one-chain route found with the current policy and provider capabilities.'); + } + + const routePlan = this.buildEncodedRoutePlan( + request, + best.legs, + sourceResolution.lookupAddress, + targetResolution.lookupAddress + ); + return this.toResponse( + request, + best, + sortedCandidates.slice(1, 4), + 'One-chain best execution selected from live pool graph.', + routePlan + ); + } + + private async planCrossChain(request: PlannerRequest, policy: RoutingPolicy): Promise { + const sourceResolution = resolveCanonicalQuoteAddress(request.sourceChainId, request.tokenIn); + const destinationResolution = resolveCanonicalQuoteAddress(request.destinationChainId || request.sourceChainId, request.tokenOut); + const amountIn = safeBigInt(request.amountIn); + + const candidateBridgeSymbols = Array.from( + new Set( + policy.defaultIntermediateAddresses + .map((address) => getRoutingSymbolForAddress(request.sourceChainId, address)) + .filter((value): value is string => Boolean(value)) + ) + ); + const bridgeCandidates = this.graphBuilder.buildBridgeCandidates( + request.sourceChainId, + request.destinationChainId || request.sourceChainId, + candidateBridgeSymbols + ).filter((candidate) => policy.allowedBridgeLabels.includes(candidate.bridgeLabel)); + + const combined: RouteCandidate[] = []; + for (const bridgeCandidate of bridgeCandidates) { + const sourceCandidate = await this.planSegment( + request.sourceChainId, + sourceResolution.lookupAddress, + bridgeCandidate.sourceTokenAddress, + amountIn, + policy, + Math.min(policy.maxLegs, 2) + ); + if (!sourceCandidate) continue; + + const bridgeFeeBps = bridgeCandidate.bridgeType === 'CCIP' ? 20 : 25; + const bridgedAmount = buildMinAmountOut(sourceCandidate.estimatedAmountOut, bridgeFeeBps); + + const destinationCandidate = await this.planSegment( + request.destinationChainId || request.sourceChainId, + bridgeCandidate.destinationTokenAddress, + destinationResolution.lookupAddress, + bridgedAmount, + policy, + Math.min(policy.maxLegs, 2) + ); + + const bridgeLeg: PlannerLeg = { + kind: 'bridge', + provider: 'partner', + sourceChainId: request.sourceChainId, + destinationChainId: request.destinationChainId || request.sourceChainId, + tokenInAddress: bridgeCandidate.sourceTokenAddress, + tokenOutAddress: bridgeCandidate.destinationTokenAddress, + tokenInSymbol: bridgeCandidate.assetSymbol, + tokenOutSymbol: bridgeCandidate.assetSymbol, + estimatedAmountIn: sourceCandidate.estimatedAmountOut.toString(), + estimatedAmountOut: bridgedAmount.toString(), + minAmountOut: bridgedAmount.toString(), + bridgeType: bridgeCandidate.bridgeType, + bridgeAddress: bridgeCandidate.bridgeAddress, + gasEstimate: 250000, + notes: bridgeCandidate.notes, + }; + + const destinationLegs = destinationCandidate?.legs || []; + const estimatedAmountOut = destinationCandidate?.estimatedAmountOut || bridgedAmount; + const legs = [...sourceCandidate.legs, bridgeLeg, ...destinationLegs]; + combined.push({ + decision: sourceCandidate.legs.length > 0 || destinationLegs.length > 0 ? 'swap-bridge-swap' : 'bridge-only', + estimatedAmountOut, + estimatedGasUsd: sourceCandidate.estimatedGasUsd + 0.45 + (destinationCandidate?.estimatedGasUsd || 0), + score: sourceCandidate.score + (destinationCandidate?.score || normalizedOutput(request.destinationChainId || request.sourceChainId, bridgeCandidate.destinationTokenAddress, bridgedAmount)) - 0.45, + legs, + selectedRouteReason: `Bridge asset ${bridgeCandidate.assetSymbol} selected via ${bridgeCandidate.bridgeLabel}.`, + rejectedAlternatives: [], + riskFlags: ['cross-chain'], + }); + } + + const sortedCandidates = combined.sort((a, b) => b.score - a.score); + const best = sortedCandidates[0]; + if (!best) { + return this.emptyResponse(request, 'unresolved', 'No eligible cross-chain route found with the current policy and bridge registry.'); + } + + const sourceSwapLegs = best.legs.filter( + (leg) => leg.kind === 'swap' && leg.sourceChainId === request.sourceChainId + ); + const destinationSwapLegs = best.legs.filter( + (leg) => leg.kind === 'swap' && leg.sourceChainId === (request.destinationChainId || request.sourceChainId) + ); + const bridgeLeg = best.legs.find((leg) => leg.kind === 'bridge'); + const intentCoordinator = normalizeAddress( + process.env.INTENT_BRIDGE_COORDINATOR_V2_ADDRESS || + (request.sourceChainId === 138 ? DEFAULT_INTENT_BRIDGE_COORDINATOR_V2 : '') + ); + + const sourceRoutePlan = sourceSwapLegs.length > 0 + ? this.buildEncodedRoutePlan( + request, + sourceSwapLegs, + sourceResolution.lookupAddress, + bridgeLeg?.tokenInAddress || sourceResolution.lookupAddress, + intentCoordinator || ROUTER_V2_RECIPIENT_PLACEHOLDER + ) + : undefined; + const destinationRoutePlan = destinationSwapLegs.length > 0 + ? this.buildEncodedRoutePlan( + { + ...request, + sourceChainId: request.destinationChainId || request.sourceChainId, + recipient: request.recipient, + }, + destinationSwapLegs, + bridgeLeg?.tokenOutAddress || destinationResolution.lookupAddress, + destinationResolution.lookupAddress + ) || this.emptyEncodedRoutePlan( + request.destinationChainId || request.sourceChainId, + bridgeLeg?.tokenOutAddress || destinationResolution.lookupAddress, + destinationResolution.lookupAddress, + destinationSwapLegs[destinationSwapLegs.length - 1]?.estimatedAmountOut || bridgeLeg?.estimatedAmountOut || request.amountIn, + request.recipient || ROUTER_V2_RECIPIENT_PLACEHOLDER + ) + : this.emptyEncodedRoutePlan( + request.destinationChainId || request.sourceChainId, + bridgeLeg?.tokenOutAddress || destinationResolution.lookupAddress, + destinationResolution.lookupAddress, + bridgeLeg?.estimatedAmountOut || request.amountIn, + request.recipient || ROUTER_V2_RECIPIENT_PLACEHOLDER + ); + + const bridgeIntentPlan: EncodedBridgeIntentPlan | undefined = + bridgeLeg && sourceRoutePlan && intentCoordinator + ? { + sourcePlan: { + ...sourceRoutePlan, + recipient: intentCoordinator, + }, + bridgeType: bridgeLeg.bridgeType || 'CCIP', + bridgeData: abiCoder.encode( + ['address', 'string'], + [bridgeLeg.bridgeAddress || ZeroAddress, bridgeLeg.bridgeType || 'CCIP'] + ), + destinationPlan: destinationRoutePlan, + recipient: normalizeAddress(request.recipient || ROUTER_V2_RECIPIENT_PLACEHOLDER), + deadline: String(Math.floor(Date.now() / 1000) + 300), + } + : undefined; + + return this.toResponse( + request, + best, + sortedCandidates.slice(1, 4), + 'Cross-chain best execution selected from source swap, bridge registry, and destination swap candidates.', + undefined, + bridgeIntentPlan + ); + } + + private async planSegment( + chainId: number, + tokenIn: string, + tokenOut: string, + amountIn: bigint, + policy: RoutingPolicy, + maxLegs: number + ): Promise { + if (tokenIn === tokenOut) { + return { + decision: 'direct-pool', + estimatedAmountOut: amountIn, + estimatedGasUsd: 0, + score: normalizedOutput(chainId, tokenOut, amountIn), + legs: [], + selectedRouteReason: 'Token already matches bridge asset or destination asset.', + rejectedAlternatives: [], + riskFlags: [], + }; + } + + const request: PlannerRequest = { + sourceChainId: chainId, + tokenIn, + tokenOut, + amountIn: amountIn.toString(), + constraints: { + complianceProfile: policy.profile, + allowBridge: false, + maxLegs, + allowedProviders: policy.allowedProviders, + allowedIntermediates: policy.defaultIntermediateAddresses, + allowCommodityIntermediates: policy.allowCommodityIntermediates, + }, + }; + const response = await this.planOneChain(request, { + ...policy, + maxLegs, + }); + if (!response.estimatedAmountOut || response.legs.length === 0) { + return null; + } + return { + decision: response.decision, + estimatedAmountOut: safeBigInt(response.estimatedAmountOut), + estimatedGasUsd: response.estimatedGasUsd, + score: normalizedOutput(chainId, tokenOut, safeBigInt(response.estimatedAmountOut)) - response.estimatedGasUsd, + legs: response.legs, + selectedRouteReason: response.selectedRouteReason, + rejectedAlternatives: response.rejectedAlternatives, + riskFlags: response.riskFlags, + }; + } + + private toCandidate( + legs: PlannerLeg[], + estimatedAmountOut: bigint, + chainId: number, + outputToken: string, + policy: RoutingPolicy + ): RouteCandidate { + const estimatedGasUsd = legs.reduce((total, leg) => total + PROVIDER_GAS_USD[leg.provider], 0); + const stalePenalty = legs.reduce((total, leg) => { + const freshness = leg.freshnessSeconds || 0; + return total + (freshness > 1800 ? 0.15 : freshness > 300 ? 0.05 : 0); + }, 0); + const hopPenalty = Math.max(0, legs.length - 1) * 0.03; + const score = normalizedOutput(chainId, outputToken, estimatedAmountOut) - estimatedGasUsd - stalePenalty - hopPenalty; + + return { + decision: legs.length === 1 ? 'direct-pool' : 'multi-hop', + estimatedAmountOut, + estimatedGasUsd, + score, + legs, + selectedRouteReason: legs.length === 1 + ? legs[0].provider === 'dodo_v3' + ? 'Selected live DODO v3 / D3MM pilot quote for the requested direct pair.' + : `Selected deepest eligible ${legs[0].provider} pool for the requested pair.` + : `Selected multi-hop path through ${legs.map((leg) => leg.tokenOutSymbol || leg.tokenOutAddress).join(' -> ')}.`, + rejectedAlternatives: [], + riskFlags: computeRiskFlags(legs, policy), + }; + } + + private toResponse( + request: PlannerRequest, + best: RouteCandidate, + alternatives: RouteCandidate[], + selectedRouteReason: string, + routePlan?: EncodedPlannerRoutePlan, + bridgeIntentPlan?: EncodedBridgeIntentPlan + ): PlannerResponse { + const planId = Buffer.from(this.requestHash(request, resolveRoutingPolicy(request.sourceChainId, request.constraints || {}))).toString('base64url'); + const alternativePayloads: PlannerAlternative[] = alternatives.map((candidate, index) => ({ + routeId: `${planId}-alt-${index + 1}`, + decision: candidate.decision, + estimatedAmountOut: candidate.estimatedAmountOut.toString(), + estimatedGasUsd: Number(candidate.estimatedGasUsd.toFixed(4)), + providerPath: candidate.legs.map((leg) => leg.provider), + legCount: candidate.legs.length, + score: Number(candidate.score.toFixed(6)), + notes: [candidate.selectedRouteReason, ...candidate.riskFlags], + })); + const maxFreshness = best.legs.reduce((current, leg) => { + if (leg.freshnessSeconds === null || leg.freshnessSeconds === undefined) return current; + if (current === null) return leg.freshnessSeconds; + return Math.max(current, leg.freshnessSeconds); + }, null); + + return { + planId, + generatedAt: new Date().toISOString(), + decision: best.decision, + sourceChainId: request.sourceChainId, + destinationChainId: request.destinationChainId || request.sourceChainId, + tokenIn: request.tokenIn, + tokenOut: request.tokenOut, + estimatedAmountOut: best.estimatedAmountOut.toString(), + minAmountOut: best.legs.length > 0 ? best.legs[best.legs.length - 1].minAmountOut : request.amountIn, + estimatedGasUsd: Number(best.estimatedGasUsd.toFixed(4)), + legs: best.legs, + alternatives: alternativePayloads, + confidenceScore: Number(computeConfidence(best.legs).toFixed(4)), + riskFlags: best.riskFlags, + selectedRouteReason, + rejectedAlternatives: best.rejectedAlternatives, + staleness: { + maxFreshnessSeconds: maxFreshness, + hasStaleLeg: best.legs.some((leg) => (leg.freshnessSeconds || 0) > 300), + }, + routePlan, + bridgeIntentPlan, + }; + } + + private emptyResponse( + request: PlannerRequest, + decision: PlannerDecision, + reason: string + ): PlannerResponse { + const planId = Buffer.from(this.requestHash(request, resolveRoutingPolicy(request.sourceChainId, request.constraints || {}))).toString('base64url'); + return { + planId, + generatedAt: new Date().toISOString(), + decision, + sourceChainId: request.sourceChainId, + destinationChainId: request.destinationChainId || request.sourceChainId, + tokenIn: request.tokenIn, + tokenOut: request.tokenOut, + estimatedAmountOut: null, + minAmountOut: null, + estimatedGasUsd: 0, + legs: [], + alternatives: [], + confidenceScore: 0, + riskFlags: ['no-route'], + selectedRouteReason: reason, + rejectedAlternatives: [], + staleness: { + maxFreshnessSeconds: null, + hasStaleLeg: false, + }, + }; + } + + private buildEncodedRoutePlan( + request: PlannerRequest, + legs: PlannerLeg[], + inputToken: string, + outputToken: string, + recipientOverride?: string + ): EncodedPlannerRoutePlan | undefined { + if (!legs.every((leg) => PROVIDER_ENUM[leg.provider] !== undefined && Boolean(providerDataHexForLeg(leg)))) { + return undefined; + } + + const recipient = normalizeAddress(recipientOverride || request.recipient || ROUTER_V2_RECIPIENT_PLACEHOLDER); + const deadline = String(Math.floor(Date.now() / 1000) + 300); + const amountIn = legs[0]?.estimatedAmountIn || request.amountIn; + const minAmountOut = legs[legs.length - 1]?.minAmountOut || request.amountIn; + + return { + chainId: request.sourceChainId, + inputToken, + outputToken, + amountIn, + minAmountOut, + recipient, + deadline, + legs: legs.map((leg, index) => ({ + provider: PROVIDER_ENUM[leg.provider] as number, + tokenIn: leg.tokenInAddress, + tokenOut: leg.tokenOutAddress, + amountSource: index === 0 ? 0 : 1, + minAmountOut: leg.minAmountOut, + target: leg.target || ZeroAddress, + providerData: providerDataHexForLeg(leg) as string, + })), + }; + } + + private emptyEncodedRoutePlan( + chainId: number, + inputToken: string, + outputToken: string, + amountIn: string, + recipient: string + ): EncodedPlannerRoutePlan { + return { + chainId, + inputToken, + outputToken, + amountIn, + minAmountOut: amountIn, + recipient: normalizeAddress(recipient), + deadline: String(Math.floor(Date.now() / 1000) + 300), + legs: [], + }; + } +} diff --git a/services/token-aggregation/src/services/chain138-dodo-liquidity.test.ts b/services/token-aggregation/src/services/chain138-dodo-liquidity.test.ts new file mode 100644 index 0000000..8d72292 --- /dev/null +++ b/services/token-aggregation/src/services/chain138-dodo-liquidity.test.ts @@ -0,0 +1,58 @@ +import { estimateChain138DodoLiquidityUsd } from './chain138-dodo-liquidity'; + +describe('estimateChain138DodoLiquidityUsd', () => { + it('values Chain 138 stable-to-stable DODO pools with token decimals', () => { + const result = estimateChain138DodoLiquidityUsd({ + token0Address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b', + token1Address: '0x71D6687F38b93CCad569Fa6352c876eea967201b', + reserve0: 999_999_997_998n, + reserve1: 999_999_997_998n, + }); + + expect(result.reserve0Usd).toBeCloseTo(999_999.997998, 6); + expect(result.reserve1Usd).toBeCloseTo(999_999.997998, 6); + expect(result.totalLiquidityUsd).toBeCloseTo(1_999_999.995996, 6); + }); + + it('values WETH/stable DODO pools with stable decimals and oracle price', () => { + const result = estimateChain138DodoLiquidityUsd({ + token0Address: '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f', + token1Address: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1', + reserve0: 50n * 10n ** 18n, + reserve1: 105_830n * 10n ** 6n, + price: 2_100n * 10n ** 18n, + }); + + expect(result.reserve0Usd).toBe(105_000); + expect(result.reserve1Usd).toBe(105_830); + expect(result.totalLiquidityUsd).toBe(210_830); + }); + + it('keeps non-USD pairs at zero without a usable USD side', () => { + const result = estimateChain138DodoLiquidityUsd({ + token0Address: '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f', + token1Address: '0x290E52a8819A4fbD0714E517225429aA2B70EC6b', + reserve0: 10n * 10n ** 18n, + reserve1: 5n * 10n ** 6n, + }); + + expect(result).toEqual({ + reserve0Usd: 0, + reserve1Usd: 0, + totalLiquidityUsd: 0, + }); + }); + + it('values cBTC/stable DODO pools using satoshi precision and the BTC fallback price', () => { + const result = estimateChain138DodoLiquidityUsd({ + token0Address: '0xcb7c000000000000000000000000000000000138', + token1Address: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1', + reserve0: 2n * 10n ** 8n, + reserve1: 181_000n * 10n ** 6n, + }); + + expect(result.reserve0Usd).toBe(180_000); + expect(result.reserve1Usd).toBe(181_000); + expect(result.totalLiquidityUsd).toBe(361_000); + }); +}); diff --git a/services/token-aggregation/src/services/chain138-dodo-liquidity.ts b/services/token-aggregation/src/services/chain138-dodo-liquidity.ts new file mode 100644 index 0000000..407adb8 --- /dev/null +++ b/services/token-aggregation/src/services/chain138-dodo-liquidity.ts @@ -0,0 +1,113 @@ +import { formatUnits } from 'ethers'; +import { getCanonicalTokenByAddress } from '../config/canonical-tokens'; + +const CHAIN_138 = 138; +const DEFAULT_WETH_USD_PRICE = 2100; +const DEFAULT_BTC_USD_PRICE = 90000; + +export interface Chain138DodoLiquidityUsd { + reserve0Usd: number; + reserve1Usd: number; + totalLiquidityUsd: number; +} + +function normalizeAddress(value?: string): string { + return String(value || '').trim().toLowerCase(); +} + +function decimalsForAddress(address: string): number { + const spec = getCanonicalTokenByAddress(CHAIN_138, normalizeAddress(address)); + return Number(spec?.decimals ?? 18); +} + +function isUsdAddress(address: string): boolean { + const spec = getCanonicalTokenByAddress(CHAIN_138, normalizeAddress(address)); + return spec?.currencyCode === 'USD'; +} + +function isWethLikeAddress(address: string): boolean { + const symbol = getCanonicalTokenByAddress(CHAIN_138, normalizeAddress(address))?.symbol?.toUpperCase(); + return symbol === 'WETH' || symbol === 'WETH10'; +} + +function isBtcLikeAddress(address: string): boolean { + const symbol = getCanonicalTokenByAddress(CHAIN_138, normalizeAddress(address))?.symbol?.toUpperCase(); + return symbol === 'CBTC'; +} + +function parseAmount(value: bigint, decimals: number): number { + if (value <= 0n) return 0; + const parsed = Number(formatUnits(value, decimals)); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +function parsePrice(price?: bigint): number { + if (!price || price <= 0n) return 0; + const parsed = Number(formatUnits(price, 18)); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +export function estimateChain138DodoLiquidityUsd(args: { + token0Address: string; + token1Address: string; + reserve0: bigint; + reserve1: bigint; + price?: bigint; +}): Chain138DodoLiquidityUsd { + const token0Address = normalizeAddress(args.token0Address); + const token1Address = normalizeAddress(args.token1Address); + const reserve0Amount = parseAmount(args.reserve0, decimalsForAddress(token0Address)); + const reserve1Amount = parseAmount(args.reserve1, decimalsForAddress(token1Address)); + const price = parsePrice(args.price); + + const token0IsUsd = isUsdAddress(token0Address); + const token1IsUsd = isUsdAddress(token1Address); + + if (reserve0Amount <= 0 || reserve1Amount <= 0) { + return { reserve0Usd: 0, reserve1Usd: 0, totalLiquidityUsd: 0 }; + } + + if (token0IsUsd && token1IsUsd) { + return { + reserve0Usd: reserve0Amount, + reserve1Usd: reserve1Amount, + totalLiquidityUsd: reserve0Amount + reserve1Amount, + }; + } + + if (token1IsUsd) { + const reserve0Usd = + price > 0 + ? reserve0Amount * price + : isWethLikeAddress(token0Address) + ? reserve0Amount * DEFAULT_WETH_USD_PRICE + : isBtcLikeAddress(token0Address) + ? reserve0Amount * DEFAULT_BTC_USD_PRICE + : 0; + + return { + reserve0Usd, + reserve1Usd: reserve1Amount, + totalLiquidityUsd: reserve0Usd > 0 ? reserve0Usd + reserve1Amount : 0, + }; + } + + if (token0IsUsd) { + const reserve1Usd = + price > 0 + ? reserve1Amount / price + : isWethLikeAddress(token1Address) + ? reserve1Amount * DEFAULT_WETH_USD_PRICE + : isBtcLikeAddress(token1Address) + ? reserve1Amount * DEFAULT_BTC_USD_PRICE + : 0; + + return { + reserve0Usd: reserve0Amount, + reserve1Usd, + totalLiquidityUsd: reserve1Usd > 0 ? reserve0Amount + reserve1Usd : 0, + }; + } + + return { reserve0Usd: 0, reserve1Usd: 0, totalLiquidityUsd: 0 }; +} diff --git a/services/token-aggregation/src/services/chain138-pilot-venues.ts b/services/token-aggregation/src/services/chain138-pilot-venues.ts new file mode 100644 index 0000000..a0252f1 --- /dev/null +++ b/services/token-aggregation/src/services/chain138-pilot-venues.ts @@ -0,0 +1,562 @@ +import { AbiCoder, Contract, JsonRpcProvider, formatUnits } from 'ethers'; +import { resolveChain138RpcUrl } from '../config/chain138-rpc'; +import { SwapGraphEdge } from './planner-v2-types'; + +const CHAIN_138 = 138; +const abiCoder = AbiCoder.defaultAbiCoder(); + +const DEFAULT_PILOT_UNISWAP_ROUTER = '0xd164d9ccfacf5d9f91698f296ae0cd245d964384'; +const DEFAULT_NATIVE_UNISWAP_FACTORY = '0x2f7219276e3ce367db9ec74c1196a8ecee67841c'; +const DEFAULT_NATIVE_UNISWAP_ROUTER = '0xde9cd8ee2811e6e64a41d5f68be315d33995975e'; +const DEFAULT_NATIVE_UNISWAP_QUOTER = '0x6abbb1ceb2468e748a03a00cd6aa9bfe893afa1f'; +const DEFAULT_NATIVE_UNISWAP_WETH_USDT_POOL = '0xa893add35aefe6a6d858eb01828be4592f12c9f5'; +const DEFAULT_NATIVE_UNISWAP_WETH_USDC_POOL = '0xec745bfb6b3cd32f102d594e5f432d8d85b19391'; +const DEFAULT_BALANCER_VAULT = '0x96423d7c1727698d8a25ebfb88131e9422d1a3c3'; +const DEFAULT_CURVE_3POOL = '0xe440ec15805be4c7babcd17a63b8c8a08a492e0f'; +const DEFAULT_ONEINCH_ROUTER = '0x500b84b1bc6f59c1898a5fe538ea20a758757a4f'; +const DEFAULT_BALANCER_WETH_USDT_POOL_ID = '0x877cd220759e8c94b82f55450c85d382ae06856c426b56d93092a420facbc324'; +const DEFAULT_BALANCER_WETH_USDC_POOL_ID = '0xd8dfb18a6baf9b29d8c2dbd74639db87ac558af120df5261dab8e2a5de69013b'; +const DEFAULT_PILOT_UNISWAP_FEE = 3000; +const DEFAULT_NATIVE_UNISWAP_FEE = 500; +const DEFAULT_WETH_USD_PRICE = 2100; + +const WETH = '0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'.toLowerCase(); +const USDT = '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1'.toLowerCase(); +const USDC = '0x71D6687F38b93CCad569Fa6352c876eea967201b'.toLowerCase(); + +const uniswapAbi = [ + 'function getPairReserves(address tokenA,address tokenB,uint24 fee) view returns (uint256 reserveIn,uint256 reserveOut,bool exists)', +]; + +const nativeUniswapFactoryAbi = [ + 'function getPool(address tokenA,address tokenB,uint24 fee) view returns (address)', +]; + +const erc20Abi = [ + 'function balanceOf(address account) view returns (uint256)', +]; + +const balancerAbi = [ + 'function getPoolTokens(bytes32 poolId) view returns (address[] tokens, uint256[] balances, uint256 lastChangeBlock)', +]; + +const curveAbi = [ + 'function reserves(uint256 index) view returns (uint256)', +]; + +const oneInchAbi = [ + 'function getRouteReserves(address tokenA,address tokenB) view returns (uint256 reserveIn,uint256 reserveOut,bool exists)', +]; + +function normalizeAddress(value?: string): string { + return String(value || '').trim().toLowerCase(); +} + +function providerDataHexForUniswap(router: string, quoter: string, fee: number): string { + return abiCoder.encode(['bytes', 'uint24', 'address', 'bool'], ['0x', fee, quoter, false]); +} + +function providerDataHexForBalancer(poolId: string): string { + return abiCoder.encode(['bytes32'], [poolId]); +} + +function providerDataHexForCurve(): string { + return abiCoder.encode(['int128', 'int128', 'bool'], [0, 1, false]); +} + +function providerDataHexForOneInch(router: string): string { + return abiCoder.encode(['address', 'address', 'bytes'], [router, router, '0x']); +} + +let sharedProvider: JsonRpcProvider | null = null; +let sharedProviderUrl = ''; + +function getProvider(rpcUrl: string): JsonRpcProvider { + if (!sharedProvider || sharedProviderUrl !== rpcUrl) { + sharedProvider = new JsonRpcProvider(rpcUrl); + sharedProviderUrl = rpcUrl; + } + return sharedProvider; +} + +function totalLiquidityUsdForWethPair(reserveWeth: bigint, reserveStable: bigint): number { + return Number(formatUnits(reserveWeth, 18)) * DEFAULT_WETH_USD_PRICE + Number(formatUnits(reserveStable, 6)); +} + +function totalLiquidityUsdForStablePair(reserveA: bigint, reserveB: bigint): number { + return Number(formatUnits(reserveA, 6)) + Number(formatUnits(reserveB, 6)); +} + +function isNativeUniswapConfigured(router: string, quoter: string): boolean { + return Boolean( + process.env.CHAIN138_UNISWAP_V3_NATIVE_FACTORY || + process.env.CHAIN_138_UNISWAP_V3_FACTORY || + process.env.CHAIN138_UNISWAP_V3_NATIVE_WETH_USDT_POOL || + process.env.CHAIN138_UNISWAP_V3_NATIVE_WETH_USDC_POOL || + router === DEFAULT_NATIVE_UNISWAP_ROUTER || + quoter === DEFAULT_NATIVE_UNISWAP_QUOTER || + (router && router !== DEFAULT_PILOT_UNISWAP_ROUTER) + ); +} + +async function resolveNativeUniswapPoolAddress(args: { + factory: string; + configuredPool: string; + tokenA: string; + tokenB: string; + fee: number; + rpcUrl: string; +}): Promise { + if (args.configuredPool) { + return args.configuredPool; + } + if (!args.factory) { + return ''; + } + + try { + const factory = new Contract(args.factory, nativeUniswapFactoryAbi, getProvider(args.rpcUrl)); + return normalizeAddress(await factory.getPool(args.tokenA, args.tokenB, args.fee)); + } catch { + return ''; + } +} + +async function erc20Balance(token: string, account: string, rpcUrl: string): Promise { + const contract = new Contract(token, erc20Abi, getProvider(rpcUrl)); + const balance = await contract.balanceOf(account); + return BigInt(String(balance)); +} + +function buildUniswapEdges(args: { + router: string; + quoter: string; + fee: number; + reserveWeth: bigint; + reserveStable: bigint; + stableAddress: string; + stableSymbol: 'USDT' | 'USDC'; + notes: string[]; +}): SwapGraphEdge[] { + const edges: SwapGraphEdge[] = []; + if (args.reserveWeth <= 0n || args.reserveStable <= 0n) { + return edges; + } + const providerDataHex = providerDataHexForUniswap(args.router, args.quoter, args.fee); + const liquidity = totalLiquidityUsdForWethPair(args.reserveWeth, args.reserveStable); + edges.push( + { + kind: 'swap', + provider: 'uniswap_v3', + chainId: CHAIN_138, + tokenInAddress: WETH, + tokenOutAddress: args.stableAddress, + tokenInSymbol: 'WETH', + tokenOutSymbol: args.stableSymbol, + reserveIn: args.reserveWeth.toString(), + reserveOut: args.reserveStable.toString(), + target: args.router, + providerData: { fee: args.fee, quoter: args.quoter }, + providerDataHex, + totalLiquidityUsd: liquidity, + freshnessSeconds: 0, + notes: args.notes, + }, + { + kind: 'swap', + provider: 'uniswap_v3', + chainId: CHAIN_138, + tokenInAddress: args.stableAddress, + tokenOutAddress: WETH, + tokenInSymbol: args.stableSymbol, + tokenOutSymbol: 'WETH', + reserveIn: args.reserveStable.toString(), + reserveOut: args.reserveWeth.toString(), + target: args.router, + providerData: { fee: args.fee, quoter: args.quoter }, + providerDataHex, + totalLiquidityUsd: liquidity, + freshnessSeconds: 0, + notes: args.notes, + } + ); + return edges; +} + +async function getNativeUniswapEdges(args: { + rpcUrl: string; + router: string; + quoter: string; + feeUsdt: number; + feeUsdc: number; +}): Promise { + const factory = normalizeAddress( + process.env.CHAIN_138_UNISWAP_V3_FACTORY || + process.env.CHAIN138_UNISWAP_V3_NATIVE_FACTORY || + DEFAULT_NATIVE_UNISWAP_FACTORY + ); + const configuredWethUsdtPool = normalizeAddress( + process.env.UNISWAP_V3_WETH_USDT_POOL || + process.env.CHAIN138_UNISWAP_V3_NATIVE_WETH_USDT_POOL || + DEFAULT_NATIVE_UNISWAP_WETH_USDT_POOL + ); + const configuredWethUsdcPool = normalizeAddress( + process.env.UNISWAP_V3_WETH_USDC_POOL || + process.env.CHAIN138_UNISWAP_V3_NATIVE_WETH_USDC_POOL || + DEFAULT_NATIVE_UNISWAP_WETH_USDC_POOL + ); + + const [wethUsdtPool, wethUsdcPool] = await Promise.all([ + resolveNativeUniswapPoolAddress({ + factory, + configuredPool: configuredWethUsdtPool, + tokenA: WETH, + tokenB: USDT, + fee: args.feeUsdt, + rpcUrl: args.rpcUrl, + }), + resolveNativeUniswapPoolAddress({ + factory, + configuredPool: configuredWethUsdcPool, + tokenA: WETH, + tokenB: USDC, + fee: args.feeUsdc, + rpcUrl: args.rpcUrl, + }), + ]); + + const [wethUsdtBalances, wethUsdcBalances] = await Promise.all([ + wethUsdtPool + ? Promise.all([ + erc20Balance(WETH, wethUsdtPool, args.rpcUrl), + erc20Balance(USDT, wethUsdtPool, args.rpcUrl), + ]) + : Promise.resolve<[bigint, bigint]>([0n, 0n]), + wethUsdcPool + ? Promise.all([ + erc20Balance(WETH, wethUsdcPool, args.rpcUrl), + erc20Balance(USDC, wethUsdcPool, args.rpcUrl), + ]) + : Promise.resolve<[bigint, bigint]>([0n, 0n]), + ]); + + return [ + ...buildUniswapEdges({ + router: args.router, + quoter: args.quoter, + fee: args.feeUsdt, + reserveWeth: wethUsdtBalances[0], + reserveStable: wethUsdtBalances[1], + stableAddress: USDT, + stableSymbol: 'USDT', + notes: ['Chain 138 upstream-native Uniswap v3 WETH/USDT venue.'], + }), + ...buildUniswapEdges({ + router: args.router, + quoter: args.quoter, + fee: args.feeUsdc, + reserveWeth: wethUsdcBalances[0], + reserveStable: wethUsdcBalances[1], + stableAddress: USDC, + stableSymbol: 'USDC', + notes: ['Chain 138 upstream-native Uniswap v3 WETH/USDC venue.'], + }), + ]; +} + +async function getPilotUniswapEdges(args: { + rpcUrl: string; + router: string; + quoter: string; + feeUsdt: number; + feeUsdc: number; +}): Promise { + const contract = new Contract(args.router, uniswapAbi, getProvider(args.rpcUrl)); + const [wethUsdt, wethUsdc] = await Promise.all([ + contract.getPairReserves(WETH, USDT, args.feeUsdt), + contract.getPairReserves(WETH, USDC, args.feeUsdc), + ]); + + return [ + ...(Boolean(wethUsdt[2]) + ? buildUniswapEdges({ + router: args.router, + quoter: args.quoter, + fee: args.feeUsdt, + reserveWeth: BigInt(String(wethUsdt[0])), + reserveStable: BigInt(String(wethUsdt[1])), + stableAddress: USDT, + stableSymbol: 'USDT', + notes: ['Chain 138 pilot-compatible Uniswap v3 WETH/USDT venue.'], + }) + : []), + ...(Boolean(wethUsdc[2]) + ? buildUniswapEdges({ + router: args.router, + quoter: args.quoter, + fee: args.feeUsdc, + reserveWeth: BigInt(String(wethUsdc[0])), + reserveStable: BigInt(String(wethUsdc[1])), + stableAddress: USDC, + stableSymbol: 'USDC', + notes: ['Chain 138 pilot-compatible Uniswap v3 WETH/USDC venue.'], + }) + : []), + ]; +} + +async function getUniswapEdges(rpcUrl: string): Promise { + const router = normalizeAddress(process.env.UNISWAP_V3_ROUTER || DEFAULT_NATIVE_UNISWAP_ROUTER); + const quoter = normalizeAddress( + process.env.UNISWAP_QUOTER_ADDRESS || + process.env.UNISWAP_QUOTER || + (router === DEFAULT_PILOT_UNISWAP_ROUTER ? router : DEFAULT_NATIVE_UNISWAP_QUOTER) + ); + if (!router || !quoter) { + return []; + } + + if (isNativeUniswapConfigured(router, quoter)) { + return getNativeUniswapEdges({ + rpcUrl, + router, + quoter, + feeUsdt: Number(process.env.UNISWAP_V3_WETH_USDT_FEE || DEFAULT_NATIVE_UNISWAP_FEE), + feeUsdc: Number(process.env.UNISWAP_V3_WETH_USDC_FEE || DEFAULT_NATIVE_UNISWAP_FEE), + }); + } + + return getPilotUniswapEdges({ + rpcUrl, + router, + quoter, + feeUsdt: Number(process.env.UNISWAP_V3_WETH_USDT_FEE || DEFAULT_PILOT_UNISWAP_FEE), + feeUsdc: Number(process.env.UNISWAP_V3_WETH_USDC_FEE || DEFAULT_PILOT_UNISWAP_FEE), + }); +} + +async function getBalancerEdges(rpcUrl: string): Promise { + const vault = normalizeAddress(process.env.BALANCER_VAULT || DEFAULT_BALANCER_VAULT); + const wethUsdtPoolId = process.env.BALANCER_WETH_USDT_POOL_ID || DEFAULT_BALANCER_WETH_USDT_POOL_ID; + const wethUsdcPoolId = process.env.BALANCER_WETH_USDC_POOL_ID || DEFAULT_BALANCER_WETH_USDC_POOL_ID; + if (!vault) { + return []; + } + + const contract = new Contract(vault, balancerAbi, getProvider(rpcUrl)); + const edges: SwapGraphEdge[] = []; + + for (const pair of [ + { poolId: wethUsdtPoolId, stable: USDT, stableSymbol: 'USDT' }, + { poolId: wethUsdcPoolId, stable: USDC, stableSymbol: 'USDC' }, + ]) { + if (!pair.poolId) continue; + const result = await contract.getPoolTokens(pair.poolId); + const tokens = (result[0] as string[]).map(normalizeAddress); + const balances = (result[1] as bigint[]).map((value) => BigInt(String(value))); + const wethIndex = tokens.indexOf(WETH); + const stableIndex = tokens.indexOf(pair.stable); + if (wethIndex === -1 || stableIndex === -1) continue; + const reserveWeth = balances[wethIndex]; + const reserveStable = balances[stableIndex]; + const providerDataHex = providerDataHexForBalancer(pair.poolId); + const liquidity = totalLiquidityUsdForWethPair(reserveWeth, reserveStable); + edges.push( + { + kind: 'swap', + provider: 'balancer', + chainId: CHAIN_138, + tokenInAddress: WETH, + tokenOutAddress: pair.stable, + tokenInSymbol: 'WETH', + tokenOutSymbol: pair.stableSymbol, + reserveIn: reserveWeth.toString(), + reserveOut: reserveStable.toString(), + target: vault, + providerData: { poolId: pair.poolId }, + providerDataHex, + totalLiquidityUsd: liquidity, + freshnessSeconds: 0, + notes: [`Chain 138 pilot-compatible Balancer ${pair.stableSymbol}/WETH venue.`], + }, + { + kind: 'swap', + provider: 'balancer', + chainId: CHAIN_138, + tokenInAddress: pair.stable, + tokenOutAddress: WETH, + tokenInSymbol: pair.stableSymbol, + tokenOutSymbol: 'WETH', + reserveIn: reserveStable.toString(), + reserveOut: reserveWeth.toString(), + target: vault, + providerData: { poolId: pair.poolId }, + providerDataHex, + totalLiquidityUsd: liquidity, + freshnessSeconds: 0, + notes: [`Chain 138 pilot-compatible Balancer ${pair.stableSymbol}/WETH venue.`], + } + ); + } + + return edges; +} + +async function getCurveEdges(rpcUrl: string): Promise { + const curvePool = normalizeAddress(process.env.CURVE_3POOL || DEFAULT_CURVE_3POOL); + if (!curvePool) { + return []; + } + const contract = new Contract(curvePool, curveAbi, getProvider(rpcUrl)); + const [reserveUsdtRaw, reserveUsdcRaw] = await Promise.all([contract.reserves(0), contract.reserves(1)]); + const reserveUsdt = BigInt(String(reserveUsdtRaw)); + const reserveUsdc = BigInt(String(reserveUsdcRaw)); + const providerDataHex = providerDataHexForCurve(); + const liquidity = totalLiquidityUsdForStablePair(reserveUsdt, reserveUsdc); + + return [ + { + kind: 'swap', + provider: 'curve', + chainId: CHAIN_138, + tokenInAddress: USDT, + tokenOutAddress: USDC, + tokenInSymbol: 'USDT', + tokenOutSymbol: 'USDC', + reserveIn: reserveUsdt.toString(), + reserveOut: reserveUsdc.toString(), + target: curvePool, + providerData: { i: 0, j: 1, useUnderlying: false }, + providerDataHex, + totalLiquidityUsd: liquidity, + freshnessSeconds: 0, + notes: ['Chain 138 pilot-compatible Curve 3Pool stable/stable venue.'], + }, + { + kind: 'swap', + provider: 'curve', + chainId: CHAIN_138, + tokenInAddress: USDC, + tokenOutAddress: USDT, + tokenInSymbol: 'USDC', + tokenOutSymbol: 'USDT', + reserveIn: reserveUsdc.toString(), + reserveOut: reserveUsdt.toString(), + target: curvePool, + providerData: { i: 0, j: 1, useUnderlying: false }, + providerDataHex, + totalLiquidityUsd: liquidity, + freshnessSeconds: 0, + notes: ['Chain 138 pilot-compatible Curve 3Pool stable/stable venue.'], + }, + ]; +} + +async function getOneInchEdges(rpcUrl: string): Promise { + const router = normalizeAddress(process.env.ONEINCH_ROUTER || DEFAULT_ONEINCH_ROUTER); + if (!router) { + return []; + } + const contract = new Contract(router, oneInchAbi, getProvider(rpcUrl)); + const [wethUsdt, wethUsdc] = await Promise.all([ + contract.getRouteReserves(WETH, USDT), + contract.getRouteReserves(WETH, USDC), + ]); + + const providerDataHex = providerDataHexForOneInch(router); + const edges: SwapGraphEdge[] = []; + if (Boolean(wethUsdt[2])) { + const reserveWeth = BigInt(String(wethUsdt[0])); + const reserveUsdt = BigInt(String(wethUsdt[1])); + const liquidity = totalLiquidityUsdForWethPair(reserveWeth, reserveUsdt); + edges.push( + { + kind: 'swap', + provider: 'one_inch', + chainId: CHAIN_138, + tokenInAddress: WETH, + tokenOutAddress: USDT, + tokenInSymbol: 'WETH', + tokenOutSymbol: 'USDT', + reserveIn: reserveWeth.toString(), + reserveOut: reserveUsdt.toString(), + target: router, + providerData: { executor: router, allowanceTarget: router }, + providerDataHex, + totalLiquidityUsd: liquidity, + freshnessSeconds: 0, + notes: ['Chain 138 pilot-compatible 1inch router lane.'], + }, + { + kind: 'swap', + provider: 'one_inch', + chainId: CHAIN_138, + tokenInAddress: USDT, + tokenOutAddress: WETH, + tokenInSymbol: 'USDT', + tokenOutSymbol: 'WETH', + reserveIn: reserveUsdt.toString(), + reserveOut: reserveWeth.toString(), + target: router, + providerData: { executor: router, allowanceTarget: router }, + providerDataHex, + totalLiquidityUsd: liquidity, + freshnessSeconds: 0, + notes: ['Chain 138 pilot-compatible 1inch router lane.'], + } + ); + } + if (Boolean(wethUsdc[2])) { + const reserveWeth = BigInt(String(wethUsdc[0])); + const reserveUsdc = BigInt(String(wethUsdc[1])); + const liquidity = totalLiquidityUsdForWethPair(reserveWeth, reserveUsdc); + edges.push( + { + kind: 'swap', + provider: 'one_inch', + chainId: CHAIN_138, + tokenInAddress: WETH, + tokenOutAddress: USDC, + tokenInSymbol: 'WETH', + tokenOutSymbol: 'USDC', + reserveIn: reserveWeth.toString(), + reserveOut: reserveUsdc.toString(), + target: router, + providerData: { executor: router, allowanceTarget: router }, + providerDataHex, + totalLiquidityUsd: liquidity, + freshnessSeconds: 0, + notes: ['Chain 138 pilot-compatible 1inch router lane.'], + }, + { + kind: 'swap', + provider: 'one_inch', + chainId: CHAIN_138, + tokenInAddress: USDC, + tokenOutAddress: WETH, + tokenInSymbol: 'USDC', + tokenOutSymbol: 'WETH', + reserveIn: reserveUsdc.toString(), + reserveOut: reserveWeth.toString(), + target: router, + providerData: { executor: router, allowanceTarget: router }, + providerDataHex, + totalLiquidityUsd: liquidity, + freshnessSeconds: 0, + notes: ['Chain 138 pilot-compatible 1inch router lane.'], + } + ); + } + return edges; +} + +export async function getChain138PilotVenueEdges(): Promise { + const rpcUrl = resolveChain138RpcUrl(); + const [uniswapEdges, balancerEdges, curveEdges, oneInchEdges] = await Promise.all([ + getUniswapEdges(rpcUrl), + getBalancerEdges(rpcUrl), + getCurveEdges(rpcUrl), + getOneInchEdges(rpcUrl), + ]); + return [...uniswapEdges, ...balancerEdges, ...curveEdges, ...oneInchEdges]; +} diff --git a/services/token-aggregation/src/services/dodo-v3-pilot.ts b/services/token-aggregation/src/services/dodo-v3-pilot.ts new file mode 100644 index 0000000..7f07baa --- /dev/null +++ b/services/token-aggregation/src/services/dodo-v3-pilot.ts @@ -0,0 +1,204 @@ +import { AbiCoder, Contract, JsonRpcProvider, formatUnits } from 'ethers'; +import { resolveChain138RpcUrl } from '../config/chain138-rpc'; +import { SwapGraphEdge } from './planner-v2-types'; + +const CHAIN_138 = 138; +const DEFAULT_D3_POOL = '0x6550A3a59070061a262a893A1D6F3F490afFDBDA'; +const DEFAULT_D3_PROXY = '0xc9a11abB7C63d88546Be24D58a6d95e3762cB843'; +const DEFAULT_ROUTER_V2 = '0xF1c93F54A5C2fc0d7766Ccb0Ad8f157DFB4C99Ce'; +const DEFAULT_WETH10 = '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f'; +const DEFAULT_USDT = '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1'; +const abiCoder = AbiCoder.defaultAbiCoder(); + +const d3MmAbi = [ + 'function getTokenReserve(address token) view returns (uint256)', + 'function querySellTokens(address fromToken,address toToken,uint256 fromAmount) view returns (uint256,uint256,uint256,uint256,uint256)', +] as const; + +export interface DodoV3PilotConfig { + enabled: boolean; + chainId: number; + rpcUrl: string; + poolAddress: string; + proxyAddress: string; + weth10Address: string; + usdtAddress: string; + wethUsdPrice: number; + liquidityOverrideUsd?: number; +} + +function normalizeAddress(value?: string): string { + return String(value || '').trim().toLowerCase(); +} + +function normalizeNumber(value?: string): number | undefined { + const parsed = Number(value || ''); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +export function encodeChain138DodoV3ProviderData(poolAddress: string): string { + return abiCoder.encode(['address'], [normalizeAddress(poolAddress)]); +} + +export function isChain138DodoV3ExecutionLive(): boolean { + const enabled = process.env.CHAIN138_ENABLE_DODO_V3_EXECUTION !== '0'; + const router = normalizeAddress(process.env.ENHANCED_SWAP_ROUTER_V2_ADDRESS || DEFAULT_ROUTER_V2); + return enabled && Boolean(router); +} + +function getPilotConfig(): DodoV3PilotConfig { + return { + enabled: process.env.CHAIN138_ENABLE_DODO_V3_ROUTING !== '0', + chainId: CHAIN_138, + rpcUrl: resolveChain138RpcUrl(), + poolAddress: normalizeAddress(process.env.CHAIN138_D3_MM_ADDRESS || DEFAULT_D3_POOL), + proxyAddress: normalizeAddress(process.env.CHAIN138_D3_PROXY_ADDRESS || DEFAULT_D3_PROXY), + weth10Address: normalizeAddress(process.env.CHAIN138_D3_WETH10_ADDRESS || process.env.WETH10_ADDRESS || DEFAULT_WETH10), + usdtAddress: normalizeAddress(process.env.CHAIN138_D3_USDT_ADDRESS || process.env.USDT_ADDRESS_138 || DEFAULT_USDT), + wethUsdPrice: normalizeNumber(process.env.CHAIN138_D3_PILOT_WETH_USD)?.valueOf() || 2100, + liquidityOverrideUsd: normalizeNumber(process.env.CHAIN138_D3_PILOT_TOTAL_LIQUIDITY_USD), + }; +} + +let sharedProvider: JsonRpcProvider | null = null; +let sharedProviderUrl = ''; + +function getProvider(rpcUrl: string): JsonRpcProvider { + if (!sharedProvider || sharedProviderUrl !== rpcUrl) { + sharedProvider = new JsonRpcProvider(rpcUrl); + sharedProviderUrl = rpcUrl; + } + return sharedProvider; +} + +function buildNotes(poolAddress: string, proxyAddress: string, executionLive: boolean): string[] { + return [ + 'DODO v3 / D3MM Chain 138 pilot venue.', + `Canonical private pilot pool ${poolAddress} executes through D3Proxy ${proxyAddress}.`, + executionLive + ? 'Planner-v2 exposure and EnhancedSwapRouterV2 internal execution-plan calldata are live for the canonical pilot pair.' + : 'Planner-v2 exposure is live, but EnhancedSwapRouterV2 execution is disabled in the local environment.', + ]; +} + +function approximateLiquidityUsd(args: { + wethReserve: bigint; + usdtReserve: bigint; + wethUsdPrice: number; + liquidityOverrideUsd?: number; +}): number { + if (args.liquidityOverrideUsd && Number.isFinite(args.liquidityOverrideUsd)) { + return args.liquidityOverrideUsd; + } + + const wethSideUsd = Number(formatUnits(args.wethReserve, 18)) * args.wethUsdPrice; + const usdtSideUsd = Number(formatUnits(args.usdtReserve, 6)); + return Number((wethSideUsd + usdtSideUsd).toFixed(2)); +} + +function isCanonicalPilotPair(config: DodoV3PilotConfig, tokenInAddress: string, tokenOutAddress: string): boolean { + const tokenIn = normalizeAddress(tokenInAddress); + const tokenOut = normalizeAddress(tokenOutAddress); + return ( + (tokenIn === config.weth10Address && tokenOut === config.usdtAddress) || + (tokenIn === config.usdtAddress && tokenOut === config.weth10Address) + ); +} + +export async function quoteChain138DodoV3AmountOut(args: { + tokenInAddress: string; + tokenOutAddress: string; + amountIn: bigint | string; +}): Promise { + const config = getPilotConfig(); + if (!config.enabled || !config.poolAddress || !config.proxyAddress) { + return 0n; + } + if (!isCanonicalPilotPair(config, args.tokenInAddress, args.tokenOutAddress)) { + return 0n; + } + + const pool = new Contract(config.poolAddress, d3MmAbi, getProvider(config.rpcUrl)); + const quote = await pool.querySellTokens( + normalizeAddress(args.tokenInAddress), + normalizeAddress(args.tokenOutAddress), + BigInt(args.amountIn) + ); + + return BigInt(String(quote[1])); +} + +export async function getChain138DodoV3PilotEdges(): Promise { + const config = getPilotConfig(); + if (!config.enabled || !config.poolAddress || !config.proxyAddress || !config.weth10Address || !config.usdtAddress) { + return []; + } + const executionLive = isChain138DodoV3ExecutionLive(); + + const pool = new Contract(config.poolAddress, d3MmAbi, getProvider(config.rpcUrl)); + const [wethReserveRaw, usdtReserveRaw] = await Promise.all([ + pool.getTokenReserve(config.weth10Address), + pool.getTokenReserve(config.usdtAddress), + ]); + + const wethReserve = BigInt(String(wethReserveRaw)); + const usdtReserve = BigInt(String(usdtReserveRaw)); + if (wethReserve <= 0n || usdtReserve <= 0n) { + return []; + } + + const totalLiquidityUsd = approximateLiquidityUsd({ + wethReserve, + usdtReserve, + wethUsdPrice: config.wethUsdPrice, + liquidityOverrideUsd: config.liquidityOverrideUsd, + }); + const notes = buildNotes(config.poolAddress, config.proxyAddress, executionLive); + + return [ + { + kind: 'swap', + provider: 'dodo_v3', + chainId: CHAIN_138, + tokenInAddress: config.weth10Address, + tokenOutAddress: config.usdtAddress, + tokenInSymbol: 'WETH10', + tokenOutSymbol: 'USDT', + reserveIn: wethReserve.toString(), + reserveOut: usdtReserve.toString(), + target: config.proxyAddress, + poolAddress: config.poolAddress, + providerData: { + poolAddress: config.poolAddress, + quoteMethod: 'querySellTokens', + executionTarget: config.proxyAddress, + }, + providerDataHex: executionLive ? encodeChain138DodoV3ProviderData(config.poolAddress) : undefined, + totalLiquidityUsd, + freshnessSeconds: 0, + notes, + }, + { + kind: 'swap', + provider: 'dodo_v3', + chainId: CHAIN_138, + tokenInAddress: config.usdtAddress, + tokenOutAddress: config.weth10Address, + tokenInSymbol: 'USDT', + tokenOutSymbol: 'WETH10', + reserveIn: usdtReserve.toString(), + reserveOut: wethReserve.toString(), + target: config.proxyAddress, + poolAddress: config.poolAddress, + providerData: { + poolAddress: config.poolAddress, + quoteMethod: 'querySellTokens', + executionTarget: config.proxyAddress, + }, + providerDataHex: executionLive ? encodeChain138DodoV3ProviderData(config.poolAddress) : undefined, + totalLiquidityUsd, + freshnessSeconds: 0, + notes, + }, + ]; +} diff --git a/services/token-aggregation/src/services/internal-execution-plan-v2.ts b/services/token-aggregation/src/services/internal-execution-plan-v2.ts new file mode 100644 index 0000000..47e90d1 --- /dev/null +++ b/services/token-aggregation/src/services/internal-execution-plan-v2.ts @@ -0,0 +1,106 @@ +import { Interface, ZeroAddress } from 'ethers'; +import { BestExecutionPlanner } from './best-execution-planner'; +import { EncodedBridgeIntentPlan, EncodedPlannerRoutePlan, PlannerRequest } from './planner-v2-types'; + +const CHAIN_138 = 138; +const DEFAULT_ROUTER_V2_ADDRESS = '0xf1c93f54a5c2fc0d7766ccb0ad8f157dfb4c99ce'; +const DEFAULT_INTENT_BRIDGE_COORDINATOR_V2_ADDRESS = '0x7d0022b7e8360172fd9c0bb6778113b7ea3674e7'; + +const routeInterface = new Interface([ + 'function executeRoute((uint256 chainId,address inputToken,address outputToken,uint256 amountIn,uint256 minAmountOut,address recipient,uint256 deadline,(uint8 provider,address tokenIn,address tokenOut,uint8 amountSource,uint256 minAmountOut,address target,bytes providerData)[] legs) plan)', +]); + +const intentInterface = new Interface([ + 'function executeIntent((((uint256 chainId,address inputToken,address outputToken,uint256 amountIn,uint256 minAmountOut,address recipient,uint256 deadline,(uint8 provider,address tokenIn,address tokenOut,uint8 amountSource,uint256 minAmountOut,address target,bytes providerData)[] legs),bytes32 bridgeType,bytes bridgeData,(uint256 chainId,address inputToken,address outputToken,uint256 amountIn,uint256 minAmountOut,address recipient,uint256 deadline,(uint8 provider,address tokenIn,address tokenOut,uint8 amountSource,uint256 minAmountOut,address target,bytes providerData)[] legs),address recipient,uint256 deadline)) intent)', +]); + +export interface InternalExecutionPlanV2Result { + generatedAt: string; + plannerResponse: Awaited>; + execution?: { + kind: 'route' | 'bridge-intent'; + contractAddress: string; + functionName: string; + signature: string; + args: [EncodedPlannerRoutePlan] | [EncodedBridgeIntentPlan]; + encodedCalldata: string; + }; + error?: string; +} + +export class InternalExecutionPlanV2Builder { + private planner: BestExecutionPlanner; + + constructor(planner = new BestExecutionPlanner()) { + this.planner = planner; + } + + async build(request: PlannerRequest): Promise { + const plannerResponse = await this.planner.plan(request); + const generatedAt = new Date().toISOString(); + + if (plannerResponse.bridgeIntentPlan) { + const contractAddress = ( + process.env.INTENT_BRIDGE_COORDINATOR_V2_ADDRESS || + (plannerResponse.sourceChainId === CHAIN_138 ? DEFAULT_INTENT_BRIDGE_COORDINATOR_V2_ADDRESS : '') + ).trim().toLowerCase(); + if (!contractAddress) { + return { + generatedAt, + plannerResponse, + error: 'INTENT_BRIDGE_COORDINATOR_V2_ADDRESS is not configured', + }; + } + + return { + generatedAt, + plannerResponse, + execution: { + kind: 'bridge-intent', + contractAddress, + functionName: 'executeIntent', + signature: 'executeIntent(((uint256,address,address,uint256,uint256,address,uint256,(uint8,address,address,uint8,uint256,address,bytes)[]),bytes32,bytes,(uint256,address,address,uint256,uint256,address,uint256,(uint8,address,address,uint8,uint256,address,bytes)[]),address,uint256))', + args: [plannerResponse.bridgeIntentPlan], + encodedCalldata: intentInterface.encodeFunctionData('executeIntent', [plannerResponse.bridgeIntentPlan]), + }, + }; + } + + if (plannerResponse.routePlan && plannerResponse.routePlan.legs.length > 0) { + const contractAddress = ( + process.env.ENHANCED_SWAP_ROUTER_V2_ADDRESS || + (plannerResponse.sourceChainId === CHAIN_138 ? DEFAULT_ROUTER_V2_ADDRESS : '') + ).trim().toLowerCase(); + if (!contractAddress) { + return { + generatedAt, + plannerResponse, + error: 'ENHANCED_SWAP_ROUTER_V2_ADDRESS is not configured', + }; + } + + return { + generatedAt, + plannerResponse, + execution: { + kind: 'route', + contractAddress, + functionName: 'executeRoute', + signature: 'executeRoute((uint256,address,address,uint256,uint256,address,uint256,(uint8,address,address,uint8,uint256,address,bytes)[]))', + args: [plannerResponse.routePlan], + encodedCalldata: routeInterface.encodeFunctionData('executeRoute', [plannerResponse.routePlan]), + }, + }; + } + + return { + generatedAt, + plannerResponse, + error: plannerResponse.decision === 'bridge-only' + ? 'Bridge-only planner result does not require EnhancedSwapRouterV2 calldata' + : plannerResponse.legs.length > 0 + ? 'Planner route includes one or more providers that are not yet executable through EnhancedSwapRouterV2' + : 'No executable planner route found', + }; + } +} diff --git a/services/token-aggregation/src/services/internal-execution-plan.ts b/services/token-aggregation/src/services/internal-execution-plan.ts index 9e83669..275958a 100644 --- a/services/token-aggregation/src/services/internal-execution-plan.ts +++ b/services/token-aggregation/src/services/internal-execution-plan.ts @@ -143,7 +143,7 @@ export function buildInternalExecutionPlan( const reserveMap = buildPoolReserveMap(matrix.liveSwapRoutes); const amountIn = BigInt(request.amountIn); const slippageBps = request.slippageBps || '100'; - const executorAddress = route.legs?.[0]?.executorAddress || '0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d'; + const executorAddress = route.legs?.[0]?.executorAddress || '0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895'; const steps: InternalExecutionStep[] = []; let currentAmount = amountIn; diff --git a/services/token-aggregation/src/services/live-dodo-fallback.ts b/services/token-aggregation/src/services/live-dodo-fallback.ts new file mode 100644 index 0000000..681cc0e --- /dev/null +++ b/services/token-aggregation/src/services/live-dodo-fallback.ts @@ -0,0 +1,178 @@ +import { ethers } from 'ethers'; +import type { LiquidityPool } from '../database/repositories/pool-repo'; +import { getChainConfig } from '../config/chains'; +import { getDexFactories } from '../config/dex-factories'; +import { shouldExposePublicPool } from '../config/gru-transport'; +import { logger } from '../utils/logger'; +import { estimateChain138DodoLiquidityUsd } from './chain138-dodo-liquidity'; + +const DODO_PMM_INTEGRATION_ABI = [ + 'function getAllPools() view returns (address[])', + 'function getPoolConfig(address) view returns (tuple(address pool, address baseToken, address quoteToken, uint256 lpFeeRate, uint256 i, uint256 k, bool isOpenTWAP, uint256 createdAt))', + 'function getPoolReserves(address) view returns (uint256 baseReserve, uint256 quoteReserve)', + 'function getPoolPriceOrOracle(address) view returns (uint256 price)', +]; + +const DODO_DVM_POOL_ABI = [ + 'function _BASE_TOKEN_() view returns (address)', + 'function _QUOTE_TOKEN_() view returns (address)', + 'function getVaultReserve() view returns (uint256 baseReserve, uint256 quoteReserve)', + 'function getMidPrice() view returns (uint256)', +]; + +const CACHE_TTL_MS = 15_000; +const livePoolCache = new Map(); + +interface LivePoolSnapshot { + token0Address: string; + token1Address: string; + reserve0: bigint; + reserve1: bigint; + price: bigint; + createdAt: Date; +} + +function configuredIntegrations(chainId: number): string[] { + const dodoConfigs = getDexFactories(chainId)?.dodo || []; + return [...new Set( + dodoConfigs + .map((config) => config.dodoPmmIntegration?.trim().toLowerCase()) + .filter((value): value is string => Boolean(value)) + )]; +} + +async function readPoolViaIntegration( + integration: ethers.Contract, + poolAddressRaw: string +): Promise { + const [configResult, reservesResult, priceResult] = await Promise.all([ + integration.getPoolConfig(poolAddressRaw), + integration.getPoolReserves(poolAddressRaw), + integration.getPoolPriceOrOracle(poolAddressRaw).catch(() => 0n), + ]); + + const cfg = configResult as unknown as [ + string, + string, + string, + bigint, + bigint, + bigint, + boolean, + bigint, + ]; + const [reserve0, reserve1] = reservesResult as [bigint, bigint]; + + return { + token0Address: cfg[1].toLowerCase(), + token1Address: cfg[2].toLowerCase(), + reserve0, + reserve1, + price: typeof priceResult === 'bigint' ? priceResult : 0n, + createdAt: cfg[7] ? new Date(Number(cfg[7]) * 1000) : new Date(), + }; +} + +async function readPoolDirectly( + provider: ethers.JsonRpcProvider, + poolAddressRaw: string +): Promise { + const pool = new ethers.Contract(poolAddressRaw, DODO_DVM_POOL_ABI, provider); + const [baseToken, quoteToken, reservesResult, midPriceResult] = await Promise.all([ + pool._BASE_TOKEN_(), + pool._QUOTE_TOKEN_(), + pool.getVaultReserve(), + pool.getMidPrice().catch(() => 0n), + ]); + const [reserve0, reserve1] = reservesResult as [bigint, bigint]; + + return { + token0Address: String(baseToken).toLowerCase(), + token1Address: String(quoteToken).toLowerCase(), + reserve0, + reserve1, + price: typeof midPriceResult === 'bigint' ? midPriceResult : 0n, + createdAt: new Date(), + }; +} + +export async function getLiveDodoPools(chainId: number): Promise { + const now = Date.now(); + const cached = livePoolCache.get(chainId); + if (cached && cached.expiresAt > now) { + return cached.pools; + } + + const chainConfig = getChainConfig(chainId); + const integrations = configuredIntegrations(chainId); + if (!chainConfig || integrations.length === 0) { + return []; + } + + const provider = new ethers.JsonRpcProvider(chainConfig.rpcUrl); + const poolsByAddress = new Map(); + + for (const integrationAddress of integrations) { + try { + const integration = new ethers.Contract( + integrationAddress, + DODO_PMM_INTEGRATION_ABI, + provider + ); + const poolAddresses = (await integration.getAllPools()) as string[]; + + for (const poolAddressRaw of poolAddresses) { + try { + const poolAddress = poolAddressRaw.toLowerCase(); + const snapshot = + await readPoolViaIntegration(integration, poolAddressRaw).catch(async () => + readPoolDirectly(provider, poolAddressRaw) + ); + const { token0Address, token1Address, reserve0, reserve1, price, createdAt } = snapshot; + + if (!shouldExposePublicPool(chainId, poolAddress, token0Address, token1Address)) { + continue; + } + + const liquidityUsd = chainId === 138 + ? estimateChain138DodoLiquidityUsd({ + token0Address, + token1Address, + reserve0, + reserve1, + price, + }) + : { reserve0Usd: 0, reserve1Usd: 0, totalLiquidityUsd: 0 }; + + poolsByAddress.set(poolAddress, { + chainId, + poolAddress, + token0Address, + token1Address, + dexType: 'dodo', + factoryAddress: integrationAddress, + reserve0: reserve0.toString(), + reserve1: reserve1.toString(), + reserve0Usd: liquidityUsd.reserve0Usd, + reserve1Usd: liquidityUsd.reserve1Usd, + totalLiquidityUsd: liquidityUsd.totalLiquidityUsd, + volume24h: 0, + createdAtBlock: 0, + createdAtTimestamp: createdAt, + lastUpdated: new Date(), + }); + } catch (error) { + logger.warn(`Skipping unreadable live DODO pool ${poolAddressRaw} on chain ${chainId}: ${String(error)}`); + } + } + } catch (error) { + logger.warn(`Live DODO fallback failed for chain ${chainId} integration ${integrationAddress}: ${String(error)}`); + } + } + + const pools = Array.from(poolsByAddress.values()).sort( + (a, b) => b.totalLiquidityUsd - a.totalLiquidityUsd + ); + livePoolCache.set(chainId, { expiresAt: now + CACHE_TTL_MS, pools }); + return pools; +} diff --git a/services/token-aggregation/src/services/planner-v2-types.ts b/services/token-aggregation/src/services/planner-v2-types.ts new file mode 100644 index 0000000..693dddc --- /dev/null +++ b/services/token-aggregation/src/services/planner-v2-types.ts @@ -0,0 +1,187 @@ +export type PlannerProvider = 'dodo' | 'dodo_v3' | 'uniswap_v3' | 'balancer' | 'curve' | 'one_inch' | 'partner'; +export type PlannerLegKind = 'swap' | 'bridge'; +export type PlannerDecision = 'direct-pool' | 'multi-hop' | 'swap-bridge-swap' | 'bridge-only' | 'unresolved'; +export type ComplianceProfile = 'standard' | 'institutional'; + +export interface PlannerConstraints { + maxSlippageBps?: number; + allowedProviders?: PlannerProvider[]; + maxLegs?: number; + allowedIntermediates?: string[]; + complianceProfile?: ComplianceProfile; + allowBridge?: boolean; + preferredBridges?: string[]; + allowCommodityIntermediates?: boolean; +} + +export interface PlannerRequest { + sourceChainId: number; + destinationChainId?: number; + tokenIn: string; + tokenOut: string; + amountIn: string; + recipient?: string; + constraints?: PlannerConstraints; +} + +export interface EncodedPlannerRouteLeg { + provider: number; + tokenIn: string; + tokenOut: string; + amountSource: number; + minAmountOut: string; + target: string; + providerData: string; +} + +export interface EncodedPlannerRoutePlan { + chainId: number; + inputToken: string; + outputToken: string; + amountIn: string; + minAmountOut: string; + recipient: string; + deadline: string; + legs: EncodedPlannerRouteLeg[]; +} + +export interface EncodedBridgeIntentPlan { + sourcePlan: EncodedPlannerRoutePlan; + bridgeType: string; + bridgeData: string; + destinationPlan: EncodedPlannerRoutePlan; + recipient: string; + deadline: string; +} + +export interface PlannerLeg { + kind: PlannerLegKind; + provider: PlannerProvider; + sourceChainId: number; + destinationChainId: number; + tokenInAddress: string; + tokenOutAddress: string; + tokenInSymbol?: string; + tokenOutSymbol?: string; + estimatedAmountIn: string; + estimatedAmountOut: string; + minAmountOut: string; + target?: string; + poolAddress?: string; + providerData?: Record; + providerDataHex?: string; + bridgeType?: string; + bridgeAddress?: string; + gasEstimate?: number; + freshnessSeconds?: number | null; + totalLiquidityUsd?: number; + notes?: string[]; +} + +export interface PlannerAlternative { + routeId: string; + decision: PlannerDecision; + estimatedAmountOut: string; + estimatedGasUsd: number; + providerPath: PlannerProvider[]; + legCount: number; + score: number; + notes: string[]; +} + +export interface PlannerStaleness { + maxFreshnessSeconds: number | null; + hasStaleLeg: boolean; +} + +export interface PlannerResponse { + planId: string; + generatedAt: string; + decision: PlannerDecision; + sourceChainId: number; + destinationChainId: number; + tokenIn: string; + tokenOut: string; + estimatedAmountOut: string | null; + minAmountOut: string | null; + estimatedGasUsd: number; + legs: PlannerLeg[]; + alternatives: PlannerAlternative[]; + confidenceScore: number; + riskFlags: string[]; + selectedRouteReason: string; + rejectedAlternatives: string[]; + staleness: PlannerStaleness; + routePlan?: EncodedPlannerRoutePlan; + bridgeIntentPlan?: EncodedBridgeIntentPlan; +} + +export interface ProviderPairCapability { + chainId: number; + provider: PlannerProvider; + legType: PlannerLegKind; + status: 'live' | 'planned' | 'blocked'; + tokenInSymbol: string; + tokenInAddress: string; + tokenOutSymbol: string; + tokenOutAddress: string; + target?: string; + providerData?: Record; + providerDataHex?: string; + notes?: string[]; + reason?: string; +} + +export interface ProviderCapabilityRecord { + chainId: number; + provider: PlannerProvider; + executionMode: 'onchain' | 'partner'; + live: boolean; + quoteLive: boolean; + executionLive: boolean; + supportedLegTypes: PlannerLegKind[]; + pairs: ProviderPairCapability[]; + notes?: string[]; +} + +export interface RoutingPolicy { + profile: ComplianceProfile; + allowedProviders: PlannerProvider[]; + defaultIntermediateAddresses: string[]; + allowBridge: boolean; + allowedBridgeLabels: string[]; + maxLegs: number; + allowCommodityIntermediates: boolean; + notes: string[]; +} + +export interface SwapGraphEdge { + kind: 'swap'; + provider: PlannerProvider; + chainId: number; + tokenInAddress: string; + tokenOutAddress: string; + tokenInSymbol?: string; + tokenOutSymbol?: string; + reserveIn: string; + reserveOut: string; + target?: string; + poolAddress?: string; + providerData?: Record; + providerDataHex?: string; + totalLiquidityUsd: number; + freshnessSeconds: number | null; + notes: string[]; +} + +export interface BridgeRouteCandidate { + bridgeType: string; + bridgeAddress: string; + bridgeLabel: string; + assetSymbol: string; + sourceTokenAddress: string; + destinationTokenAddress: string; + fromChainId: number; + toChainId: number; + notes: string[]; +} diff --git a/services/token-aggregation/src/services/pmm-onchain-quote.ts b/services/token-aggregation/src/services/pmm-onchain-quote.ts new file mode 100644 index 0000000..ff20b15 --- /dev/null +++ b/services/token-aggregation/src/services/pmm-onchain-quote.ts @@ -0,0 +1,58 @@ +import { Contract, JsonRpcProvider } from 'ethers'; + +const POOL_ABI = [ + 'function _BASE_TOKEN_() view returns (address)', + 'function _QUOTE_TOKEN_() view returns (address)', + 'function querySellBase(address,uint256) view returns (uint256,uint256)', + 'function querySellQuote(address,uint256) view returns (uint256,uint256)', +]; + +/** + * PMM / DVM on-chain output for tokenIn amount. Matches DODOPMMIntegration.swapExactIn semantics. + * Returns null if RPC fails, pool is not a DVM, or tokenIn is not base/quote. + */ +export async function pmmQuoteAmountOutFromChain(params: { + rpcUrl: string; + poolAddress: string; + tokenInLookup: string; + amountIn: bigint; + /** Passed to querySell* (MT fee view); default deployer is typical for operator tooling. */ + traderForView: string; +}): Promise { + const { rpcUrl, poolAddress, tokenInLookup, amountIn, traderForView } = params; + try { + const provider = new JsonRpcProvider(rpcUrl); + const pool = new Contract(poolAddress, POOL_ABI, provider); + const base = (await pool._BASE_TOKEN_()).toString().toLowerCase(); + const quote = (await pool._QUOTE_TOKEN_()).toString().toLowerCase(); + const ti = tokenInLookup.toLowerCase(); + if (ti === base) { + const [out] = await pool.querySellBase(traderForView, amountIn); + return BigInt(out.toString()); + } + if (ti === quote) { + const [out] = await pool.querySellQuote(traderForView, amountIn); + return BigInt(out.toString()); + } + return null; + } catch { + return null; + } +} + +/** RPC for PMM eth_call quotes on Chain 138 (optional; unset = skip on-chain override). */ +export function resolvePmmQuoteRpcUrl(): string { + return ( + process.env.TOKEN_AGGREGATION_PMM_RPC_URL || + process.env.TOKEN_AGGREGATION_CHAIN138_RPC_URL || + process.env.RPC_URL_138 || + '' + ).trim(); +} + +export function resolvePmmQuoteTrader(): string { + return ( + process.env.TOKEN_AGGREGATION_PMM_QUERY_TRADER || + '0x4A666F96fC8764181194447A7dFdb7d471b301C8' + ).trim(); +} diff --git a/services/token-aggregation/src/services/route-decision-tree.test.ts b/services/token-aggregation/src/services/route-decision-tree.test.ts new file mode 100644 index 0000000..d43437a --- /dev/null +++ b/services/token-aggregation/src/services/route-decision-tree.test.ts @@ -0,0 +1,76 @@ +import type { ResolvedTokenDisplay } from './token-display'; +import { classifyPoolDepthStatus, estimateChain138FallbackDepthUsd } from './route-decision-tree'; + +function token(address: string, symbol: string, decimals: number): ResolvedTokenDisplay { + return { + address, + symbol, + name: symbol, + decimals, + source: 'canonical', + }; +} + +describe('estimateChain138FallbackDepthUsd', () => { + it('derives tvl from the stable side for funded mixed pairs', () => { + const weth10 = token('0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f', 'WETH10', 18); + const usdt = token('0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1', 'USDT', 6); + + const result = estimateChain138FallbackDepthUsd( + weth10, + 50n * 10n ** 18n, + usdt, + 200_000n * 10n ** 6n + ); + + expect(result.tvlUsd).toBe(400_000); + expect(result.estimatedTradeCapacityUsd).toBe(80_000); + }); + + it('adds both sides for funded stable-to-stable pools', () => { + const cusdcV2 = token('0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d', 'cUSDC_V2', 6); + const usdc = token('0x71D6687F38b93CCad569Fa6352c876eea967201b', 'USDC', 6); + + const result = estimateChain138FallbackDepthUsd( + cusdcV2, + 1_000_000n * 10n ** 6n, + usdc, + 1_000_000n * 10n ** 6n + ); + + expect(result.tvlUsd).toBe(2_000_000); + expect(result.estimatedTradeCapacityUsd).toBe(400_000); + }); + + it('keeps zero-dollar metrics for non-stable or partially funded pools', () => { + const weth = token('0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f', 'WETH10', 18); + const cxauc = token('0x290E52a8819A4fbD0714E517225429aA2B70EC6b', 'cXAUC', 18); + + expect( + estimateChain138FallbackDepthUsd(weth, 10n * 10n ** 18n, cxauc, 5n * 10n ** 18n) + ).toEqual({ + tvlUsd: 0, + estimatedTradeCapacityUsd: 0, + }); + + expect( + estimateChain138FallbackDepthUsd(weth, 10n * 10n ** 18n, token('0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1', 'USDT', 6), 0n) + ).toEqual({ + tvlUsd: 0, + estimatedTradeCapacityUsd: 0, + }); + }); +}); + +describe('classifyPoolDepthStatus', () => { + it('marks zero-liquidity pools unavailable even if freshness is recent', () => { + expect(classifyPoolDepthStatus(0, 0, 0)).toBe('unavailable'); + expect(classifyPoolDepthStatus(1000, 0, 120)).toBe('unavailable'); + }); + + it('keeps funded pools live or stale based on freshness', () => { + expect(classifyPoolDepthStatus(1000, 200, 60)).toBe('live'); + expect(classifyPoolDepthStatus(1000, 200, 900)).toBe('stale'); + expect(classifyPoolDepthStatus(1000, 200, 3600)).toBe('unavailable'); + }); +}); diff --git a/services/token-aggregation/src/services/route-decision-tree.ts b/services/token-aggregation/src/services/route-decision-tree.ts index cb892fe..93cab46 100644 --- a/services/token-aggregation/src/services/route-decision-tree.ts +++ b/services/token-aggregation/src/services/route-decision-tree.ts @@ -2,16 +2,18 @@ import { TokenRepository } from '../database/repositories/token-repo'; import { PoolRepository, LiquidityPool, DexType } from '../database/repositories/pool-repo'; import { getChainConfig } from '../config/chains'; import { ResolvedTokenDisplay, resolvePoolTokenDisplays, resolveTokenDisplay } from './token-display'; -import { Contract, JsonRpcProvider } from 'ethers'; +import { Contract, JsonRpcProvider, formatUnits } from 'ethers'; import { getCanonicalTokenByAddress, getCanonicalTokenBySymbol } from '../config/canonical-tokens'; import { getRouteFromRegistry } from '../config/cross-chain-bridges'; +import { filterPoolsForRouting } from '../config/gru-transport'; +import { estimateChain138DodoLiquidityUsd } from './chain138-dodo-liquidity'; const CHAIN_138 = 138; const CHAIN_138_PMM_INTEGRATION = process.env.CHAIN_138_DODO_PMM_INTEGRATION || process.env.DODO_PMM_INTEGRATION_ADDRESS || process.env.DODO_PMM_INTEGRATION || - '0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d'; + '0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895'; const PMM_ABI = [ 'function pools(address,address) view returns (address)', @@ -21,6 +23,8 @@ const ERC20_ABI = [ 'function balanceOf(address) view returns (uint256)', ]; +const CHAIN_138_FALLBACK_CAPACITY_RATIO = 0.2; + export type RouteNodeKind = | 'direct-pool' | 'bridge' @@ -97,8 +101,24 @@ export interface RouteDecisionTreeResponse { missingQuoteTokenPools: MissingQuoteTokenPool[]; } +function normalizedTvlUsd(pool: LiquidityPool): number { + let tvl = Math.max(0, pool.totalLiquidityUsd || 0); + if (pool.chainId === CHAIN_138 && pool.dexType === 'dodo') { + const estimated = estimateChain138DodoLiquidityUsd({ + token0Address: pool.token0Address, + token1Address: pool.token1Address, + reserve0: BigInt(pool.reserve0 || '0'), + reserve1: BigInt(pool.reserve1 || '0'), + }).totalLiquidityUsd; + if (estimated > 0) { + tvl = Math.max(tvl, estimated); + } + } + return tvl; +} + function estimateTradeCapacityUsd(pool: LiquidityPool): number { - const tvl = Math.max(0, pool.totalLiquidityUsd || 0); + const tvl = normalizedTvlUsd(pool); if (tvl === 0) return 0; const freshnessBoost = pool.lastUpdated ? Math.max(0.25, 1 - (Date.now() - pool.lastUpdated.getTime()) / (60 * 60 * 1000)) @@ -107,26 +127,101 @@ function estimateTradeCapacityUsd(pool: LiquidityPool): number { return Math.max(0, Math.min(tvl, capacity)); } +export function classifyPoolDepthStatus( + tvlUsd: number, + estimatedTradeCapacityUsd: number, + freshnessSeconds: number | null +): RouteDepthMetrics['status'] { + if (!(tvlUsd > 0) || !(estimatedTradeCapacityUsd > 0)) { + return 'unavailable'; + } + if (freshnessSeconds === null) { + return 'unavailable'; + } + if (freshnessSeconds < 300) { + return 'live'; + } + if (freshnessSeconds < 1800) { + return 'stale'; + } + return 'unavailable'; +} + function buildDepth(pool: LiquidityPool): RouteDepthMetrics { const freshnessSeconds = pool.lastUpdated ? Math.max(0, Math.floor((Date.now() - pool.lastUpdated.getTime()) / 1000)) : null; - const status = freshnessSeconds === null - ? 'unavailable' - : freshnessSeconds < 300 - ? 'live' - : freshnessSeconds < 1800 - ? 'stale' - : 'unavailable'; + const tvlUsd = normalizedTvlUsd(pool); + const estimatedTradeCapacityUsd = estimateTradeCapacityUsd(pool); + const status = classifyPoolDepthStatus(tvlUsd, estimatedTradeCapacityUsd, freshnessSeconds); return { - tvlUsd: pool.totalLiquidityUsd || 0, + tvlUsd, reserve0: pool.reserve0, reserve1: pool.reserve1, - estimatedTradeCapacityUsd: estimateTradeCapacityUsd(pool), + estimatedTradeCapacityUsd, freshnessSeconds, status, }; } +function isChain138UsdFallbackToken(token: ResolvedTokenDisplay): boolean { + if (!token?.address) return false; + const spec = getCanonicalTokenByAddress(CHAIN_138, token.address); + return spec?.currencyCode === 'USD'; +} + +function getChain138FallbackTokenDecimals(token: ResolvedTokenDisplay): number { + const spec = token?.address ? getCanonicalTokenByAddress(CHAIN_138, token.address) : undefined; + return Number(token?.decimals ?? spec?.decimals ?? 18); +} + +function parseFallbackTokenAmount(value: bigint, decimals: number): number { + if (value <= 0n) return 0; + const parsed = Number(formatUnits(value, decimals)); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +export function estimateChain138FallbackDepthUsd( + token0: ResolvedTokenDisplay, + reserve0: bigint, + token1: ResolvedTokenDisplay, + reserve1: bigint +): Pick { + if (reserve0 <= 0n || reserve1 <= 0n) { + return { + tvlUsd: 0, + estimatedTradeCapacityUsd: 0, + }; + } + + const reserve0Usd = isChain138UsdFallbackToken(token0) + ? parseFallbackTokenAmount(reserve0, getChain138FallbackTokenDecimals(token0)) + : 0; + const reserve1Usd = isChain138UsdFallbackToken(token1) + ? parseFallbackTokenAmount(reserve1, getChain138FallbackTokenDecimals(token1)) + : 0; + + let tvlUsd = 0; + if (reserve0Usd > 0 && reserve1Usd > 0) { + tvlUsd = reserve0Usd + reserve1Usd; + } else if (reserve0Usd > 0) { + tvlUsd = reserve0Usd * 2; + } else if (reserve1Usd > 0) { + tvlUsd = reserve1Usd * 2; + } + + if (tvlUsd <= 0) { + return { + tvlUsd: 0, + estimatedTradeCapacityUsd: 0, + }; + } + + return { + tvlUsd, + estimatedTradeCapacityUsd: Math.max(0, Math.min(tvlUsd, tvlUsd * CHAIN_138_FALLBACK_CAPACITY_RATIO)), + }; +} + function deriveDecision( sourceChainId: number, destinationChainId: number | undefined, @@ -183,7 +278,10 @@ export class RouteDecisionTreeService { if (request.tokenOut) acceptableTokenOutAddresses.add(request.tokenOut.toLowerCase()); if (bridgeResolution?.localQuoteAddress) acceptableTokenOutAddresses.add(bridgeResolution.localQuoteAddress.toLowerCase()); - const pools = await this.poolRepo.getPoolsByToken(request.chainId, request.tokenIn); + const pools = filterPoolsForRouting( + request.chainId, + await this.poolRepo.getPoolsByToken(request.chainId, request.tokenIn) + ); const directPools = request.tokenOut ? pools.filter((pool) => acceptableTokenOutAddresses.has(pool.token0Address.toLowerCase()) || @@ -193,7 +291,10 @@ export class RouteDecisionTreeService { const destinationPools = request.tokenOut && destinationChainId !== request.chainId - ? await this.poolRepo.getPoolsByToken(destinationChainId, request.tokenOut) + ? filterPoolsForRouting( + destinationChainId, + await this.poolRepo.getPoolsByToken(destinationChainId, request.tokenOut) + ) : []; let resolvedPools = await Promise.all( @@ -238,10 +339,19 @@ export class RouteDecisionTreeService { }) ); - if (request.chainId === CHAIN_138 && destinationChainId === CHAIN_138 && request.tokenOut && resolvedPools.length === 0) { + const shouldTryLiveFallback = + request.chainId === CHAIN_138 && + destinationChainId === CHAIN_138 && + request.tokenOut && + (resolvedPools.length === 0 || resolvedPools.every((pool) => pool.depth.status === 'unavailable')); + + if (shouldTryLiveFallback) { const liveFallbackPool = await this.findLiveDirectPoolFallback(request, sourceTokenIn, sourceTokenOut); if (liveFallbackPool) { - resolvedPools = [liveFallbackPool, ...resolvedPools]; + resolvedPools = [ + liveFallbackPool, + ...resolvedPools.filter((pool) => pool.poolAddress.toLowerCase() !== liveFallbackPool.poolAddress.toLowerCase()), + ]; } } @@ -403,17 +513,19 @@ export class RouteDecisionTreeService { tokenOutContract.balanceOf(poolAddress), ]); const live = reserve0 > 0n && reserve1 > 0n; + const token1 = sourceTokenOut || await resolveTokenDisplay(this.tokenRepo, request.chainId, request.tokenOut); + const depthUsd = estimateChain138FallbackDepthUsd(sourceTokenIn, reserve0, token1, reserve1); return { poolAddress, dexType: 'dodo', token0: sourceTokenIn, - token1: sourceTokenOut || await resolveTokenDisplay(this.tokenRepo, request.chainId, request.tokenOut), + token1, depth: { - tvlUsd: 0, + tvlUsd: depthUsd.tvlUsd, reserve0: reserve0.toString(), reserve1: reserve1.toString(), - estimatedTradeCapacityUsd: 0, + estimatedTradeCapacityUsd: depthUsd.estimatedTradeCapacityUsd, freshnessSeconds: 0, status: live ? 'live' : 'stale', }, diff --git a/services/token-aggregation/src/services/route-graph-builder.test.ts b/services/token-aggregation/src/services/route-graph-builder.test.ts new file mode 100644 index 0000000..d28b523 --- /dev/null +++ b/services/token-aggregation/src/services/route-graph-builder.test.ts @@ -0,0 +1,92 @@ +import type { LiquidityPool, PoolRepository } from '../database/repositories/pool-repo'; +import { RouteGraphBuilder } from './route-graph-builder'; + +jest.mock('./dodo-v3-pilot', () => ({ + __esModule: true, + getChain138DodoV3PilotEdges: jest.fn().mockResolvedValue([]), + isChain138DodoV3ExecutionLive: jest.fn(() => false), +})); + +jest.mock('./chain138-pilot-venues', () => ({ + __esModule: true, + getChain138PilotVenueEdges: jest.fn().mockResolvedValue([]), +})); + +jest.mock('./live-dodo-fallback', () => ({ + __esModule: true, + getLiveDodoPools: jest.fn().mockResolvedValue([]), +})); + +function pool(overrides: Partial): LiquidityPool { + return { + chainId: 138, + poolAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + token0Address: '0x93e66202a11b1772e55407b32b44e5cd8eda7f22', + token1Address: '0xf22258f57794cc8e06237084b353ab30fffa640b', + dexType: 'dodo', + reserve0: '1000000', + reserve1: '1000000', + reserve0Usd: 0, + reserve1Usd: 0, + totalLiquidityUsd: 0, + volume24h: 0, + lastUpdated: new Date('2026-04-05T00:00:00Z'), + ...overrides, + }; +} + +class MockPoolRepo { + constructor(private readonly pools: LiquidityPool[]) {} + + async getPoolsByChain(): Promise { + return this.pools; + } +} + +describe('RouteGraphBuilder', () => { + it('filters dust Chain 138 DODO pools from planner visibility', async () => { + const builder = new RouteGraphBuilder( + new MockPoolRepo([ + pool({ + poolAddress: '0x1111111111111111111111111111111111111111', + token0Address: '0xf22258f57794cc8e06237084b353ab30fffa640b', + token1Address: '0x71d6687f38b93ccad569fa6352c876eea967201b', + reserve0: '999999997998', + reserve1: '999999997998', + }), + pool({ + poolAddress: '0x2222222222222222222222222222222222222222', + reserve0: '1001', + reserve1: '1001', + }), + ]) as unknown as PoolRepository + ); + + const edges = await builder.buildSwapEdges(138); + + expect(edges).toHaveLength(2); + expect(edges.every((edge) => edge.poolAddress === '0x1111111111111111111111111111111111111111')).toBe(true); + }); + + it('keeps non-DODO venues visible even when they are small', async () => { + const builder = new RouteGraphBuilder( + new MockPoolRepo([ + pool({ + poolAddress: '0x3333333333333333333333333333333333333333', + dexType: 'uniswap_v3', + token0Address: '0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + token1Address: '0x71D6687F38b93CCad569Fa6352c876eea967201b', + reserve0: '10000000000000000', + reserve1: '10000', + totalLiquidityUsd: 1, + }), + ]) as unknown as PoolRepository + ); + + const edges = await builder.buildSwapEdges(138); + + expect(edges).toHaveLength(2); + expect(edges[0].provider).toBe('uniswap_v3'); + expect(edges[1].provider).toBe('uniswap_v3'); + }); +}); diff --git a/services/token-aggregation/src/services/route-graph-builder.ts b/services/token-aggregation/src/services/route-graph-builder.ts new file mode 100644 index 0000000..e10894e --- /dev/null +++ b/services/token-aggregation/src/services/route-graph-builder.ts @@ -0,0 +1,172 @@ +import { AbiCoder } from 'ethers'; +import { PoolRepository } from '../database/repositories/pool-repo'; +import type { LiquidityPool } from '../database/repositories/pool-repo'; +import { filterPoolsForRouting } from '../config/gru-transport'; +import { findProviderPairCapability } from '../config/provider-capabilities'; +import { getRouteFromRegistry } from '../config/cross-chain-bridges'; +import { + getRoutingAddressForSymbol, + getRoutingSymbolForAddress, +} from '../config/routing-assets'; +import { getChain138DodoV3PilotEdges } from './dodo-v3-pilot'; +import { getChain138PilotVenueEdges } from './chain138-pilot-venues'; +import { getLiveDodoPools } from './live-dodo-fallback'; +import { estimateChain138DodoLiquidityUsd } from './chain138-dodo-liquidity'; +import { BridgeRouteCandidate, PlannerProvider, SwapGraphEdge } from './planner-v2-types'; + +const abiCoder = AbiCoder.defaultAbiCoder(); +const CHAIN_138 = 138; +const CHAIN_138_MIN_LIVE_DODO_LIQUIDITY_USD = 100; + +function normalizeAddress(value: string): string { + return value.trim().toLowerCase(); +} + +function providerFromDexType(dexType: string): PlannerProvider | null { + switch (dexType) { + case 'dodo': + return 'dodo'; + case 'dodo_v3': + return 'dodo_v3'; + case 'uniswap_v3': + return 'uniswap_v3'; + default: + return null; + } +} + +function dedupePools(items: T[]): T[] { + const byAddress = new Map(); + for (const item of items) { + byAddress.set(item.poolAddress.toLowerCase(), item); + } + return Array.from(byAddress.values()); +} + +function isMeaningfullyFundedPool(chainId: number, pool: LiquidityPool): boolean { + if (chainId !== CHAIN_138 || pool.dexType !== 'dodo') { + return true; + } + const reserve0 = BigInt(pool.reserve0 || '0'); + const reserve1 = BigInt(pool.reserve1 || '0'); + const estimatedUsd = estimateChain138DodoLiquidityUsd({ + token0Address: pool.token0Address, + token1Address: pool.token1Address, + reserve0, + reserve1, + }).totalLiquidityUsd; + const totalLiquidityUsd = Math.max(pool.totalLiquidityUsd || 0, estimatedUsd); + return totalLiquidityUsd >= CHAIN_138_MIN_LIVE_DODO_LIQUIDITY_USD; +} + +export class RouteGraphBuilder { + private poolRepo: PoolRepository; + + constructor(poolRepo = new PoolRepository()) { + this.poolRepo = poolRepo; + } + + async buildSwapEdges(chainId: number): Promise { + let indexedPools: LiquidityPool[] = []; + try { + indexedPools = filterPoolsForRouting(chainId, await this.poolRepo.getPoolsByChain(chainId, 500)); + } catch { + indexedPools = []; + } + const liveDodoPools = chainId === 138 + ? filterPoolsForRouting(chainId, await getLiveDodoPools(chainId)) + : []; + const liveDodoV3PilotEdges = chainId === 138 + ? await getChain138DodoV3PilotEdges() + : []; + const livePilotVenueEdges = chainId === 138 + ? await getChain138PilotVenueEdges() + : []; + const pools = dedupePools([...indexedPools, ...liveDodoPools]).filter((pool) => + isMeaningfullyFundedPool(chainId, pool) + ); + + const poolEdges = pools.flatMap((pool) => { + const provider = providerFromDexType(pool.dexType); + if (!provider) return []; + + const token0 = normalizeAddress(pool.token0Address); + const token1 = normalizeAddress(pool.token1Address); + const token0Symbol = getRoutingSymbolForAddress(chainId, token0); + const token1Symbol = getRoutingSymbolForAddress(chainId, token1); + const capability = findProviderPairCapability(chainId, provider, token0, token1) + || findProviderPairCapability(chainId, provider, token1, token0); + + const target = capability?.target || (provider === 'dodo' ? normalizeAddress(process.env.DODO_PMM_PROVIDER_ADDRESS || process.env.DODO_PMM_PROVIDER || '0x3f729632E9553EBacCdE2e9b4c8F2B285b014F2e') : undefined); + const providerData = capability?.providerData || (provider === 'dodo' ? { poolAddress: pool.poolAddress.toLowerCase() } : undefined); + const providerDataHex = capability?.providerDataHex || (provider === 'dodo' + ? abiCoder.encode(['address'], [pool.poolAddress.toLowerCase()]) + : undefined); + + const baseEdge = { + kind: 'swap' as const, + provider, + chainId, + target, + poolAddress: pool.poolAddress.toLowerCase(), + providerData, + providerDataHex, + totalLiquidityUsd: pool.totalLiquidityUsd || 0, + freshnessSeconds: pool.lastUpdated + ? Math.max(0, Math.floor((Date.now() - pool.lastUpdated.getTime()) / 1000)) + : null, + notes: [], + }; + + return [ + { + ...baseEdge, + tokenInAddress: token0, + tokenOutAddress: token1, + tokenInSymbol: token0Symbol, + tokenOutSymbol: token1Symbol, + reserveIn: String(pool.reserve0), + reserveOut: String(pool.reserve1), + }, + { + ...baseEdge, + tokenInAddress: token1, + tokenOutAddress: token0, + tokenInSymbol: token1Symbol, + tokenOutSymbol: token0Symbol, + reserveIn: String(pool.reserve1), + reserveOut: String(pool.reserve0), + }, + ]; + }); + + return [...poolEdges, ...liveDodoV3PilotEdges, ...livePilotVenueEdges]; + } + + buildBridgeCandidates( + fromChainId: number, + toChainId: number, + assetSymbols: string[] + ): BridgeRouteCandidate[] { + return assetSymbols.flatMap((symbol) => { + const route = getRouteFromRegistry(fromChainId, toChainId, symbol); + if (!route) return []; + + const sourceTokenAddress = getRoutingAddressForSymbol(fromChainId, symbol); + const destinationTokenAddress = getRoutingAddressForSymbol(toChainId, symbol); + if (!sourceTokenAddress || !destinationTokenAddress) return []; + + return [{ + bridgeType: route.pathType, + bridgeAddress: route.bridgeAddress.toLowerCase(), + bridgeLabel: route.label, + assetSymbol: route.asset || symbol, + sourceTokenAddress: sourceTokenAddress.toLowerCase(), + destinationTokenAddress: destinationTokenAddress.toLowerCase(), + fromChainId, + toChainId, + notes: [`Registry route ${route.label}`], + }]; + }); + } +} diff --git a/services/token-aggregation/src/services/route-matrix-scheduler.ts b/services/token-aggregation/src/services/route-matrix-scheduler.ts new file mode 100644 index 0000000..062c6de --- /dev/null +++ b/services/token-aggregation/src/services/route-matrix-scheduler.ts @@ -0,0 +1,36 @@ +import cron from 'node-cron'; +import { AggregatorRouteMatrixGenerator } from './aggregator-route-matrix-generator'; +import { logger } from '../utils/logger'; + +export function startRouteMatrixScheduler(): void { + const cronSpec = (process.env.ROUTE_MATRIX_CRON || '').trim(); + const generateOnStart = String(process.env.GENERATE_ROUTE_MATRIX_ON_START || 'false').toLowerCase() === 'true'; + + if (!cronSpec && !generateOnStart) { + return; + } + + const generator = new AggregatorRouteMatrixGenerator(); + + const runGeneration = async () => { + try { + const outputPath = await generator.writeToFile(); + logger.info('Planner-v2 route matrix generated', { outputPath }); + } catch (error) { + logger.warn('Planner-v2 route matrix generation failed', { + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + if (generateOnStart) { + void runGeneration(); + } + + if (cronSpec) { + cron.schedule(cronSpec, () => { + void runGeneration(); + }); + logger.info('Planner-v2 route matrix scheduler enabled', { cronSpec }); + } +} diff --git a/test/api/packages/asyncapi/asyncapi.yaml b/test/api/packages/asyncapi/asyncapi.yaml new file mode 100644 index 0000000..645a9f8 --- /dev/null +++ b/test/api/packages/asyncapi/asyncapi.yaml @@ -0,0 +1,37 @@ +asyncapi: 3.0.0 +info: + title: eMoney Control Plane Event Contract + version: 1.0.0 + description: Reference event envelope and channel set for the eMoney control-plane API surface. +channels: + triggers.created: {} + triggers.state.updated: {} + liens.placed: {} + liens.reduced: {} + liens.released: {} + packets.generated: {} + packets.dispatched: {} + packets.acknowledged: {} + bridge.locked: {} + bridge.unlocked: {} + compliance.updated: {} + policy.updated: {} +components: + schemas: + EventEnvelope: + type: object + required: + - eventId + - eventType + - occurredAt + - payload + properties: + eventId: + type: string + eventType: + type: string + occurredAt: + type: string + format: date-time + payload: + type: object diff --git a/test/api/packages/openapi/v1/openapi.yaml b/test/api/packages/openapi/v1/openapi.yaml new file mode 100644 index 0000000..a24b56f --- /dev/null +++ b/test/api/packages/openapi/v1/openapi.yaml @@ -0,0 +1,139 @@ +openapi: 3.0.3 +info: + title: eMoney Token Factory Control Plane API + version: 1.0.0 + description: Reference contract for token issuance, lien management, compliance, ISO messaging, and bridge operations. +x-idempotency: + - POST /tokens + - POST /liens + - POST /iso/outbound + - POST /bridge/lock + - POST /bridge/unlock +paths: + /tokens: + get: + summary: List tokens + responses: + '200': + description: Token list returned + post: + summary: Deploy token + responses: + '201': + description: Token deployed + security: + - oauth2: [] + - mtls: [] + /tokens/{code}: + get: + summary: Get token by code + parameters: + - name: code + in: path + required: true + schema: + type: string + responses: + '200': + description: Token returned + /liens: + post: + summary: Place lien + responses: + '201': + description: Lien created + security: + - oauth2: [] + - mtls: [] + /liens/{lienId}: + get: + summary: Get lien by identifier + parameters: + - name: lienId + in: path + required: true + schema: + type: string + responses: + '200': + description: Lien returned + /compliance/accounts/{accountRefId}: + get: + summary: Get account compliance state + parameters: + - name: accountRefId + in: path + required: true + schema: + type: string + responses: + '200': + description: Compliance record returned + /triggers: + get: + summary: List triggers + responses: + '200': + description: Trigger list returned + /triggers/{triggerId}: + get: + summary: Get trigger by identifier + parameters: + - name: triggerId + in: path + required: true + schema: + type: string + responses: + '200': + description: Trigger returned + /iso/inbound: + post: + summary: Receive inbound ISO 20022 message + responses: + '202': + description: Message accepted + /iso/outbound: + post: + summary: Submit outbound ISO 20022 message + responses: + '201': + description: Outbound trigger created + security: + - oauth2: [] + - mtls: [] + /packets: + get: + summary: List payment packets + responses: + '200': + description: Packet list returned + /bridge/lock: + post: + summary: Lock assets for bridge dispatch + responses: + '201': + description: Bridge lock recorded + security: + - oauth2: [] + - mtls: [] + /bridge/unlock: + post: + summary: Unlock assets after settlement + responses: + '201': + description: Bridge unlock recorded + security: + - oauth2: [] + - mtls: [] +components: + securitySchemes: + oauth2: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://auth.example.invalid/oauth/token + scopes: + control-plane: Access the control plane API + mtls: + type: mutualTLS diff --git a/test/bridge/CWAssetReserveVerifier.t.sol b/test/bridge/CWAssetReserveVerifier.t.sol new file mode 100644 index 0000000..fc08dff --- /dev/null +++ b/test/bridge/CWAssetReserveVerifier.t.sol @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {IRouterClient} from "../../contracts/ccip/IRouterClient.sol"; +import {CWMultiTokenBridgeL1} from "../../contracts/bridge/CWMultiTokenBridgeL1.sol"; +import {CWAssetReserveVerifier} from "../../contracts/bridge/integration/CWAssetReserveVerifier.sol"; + +contract MockCanonicalAssetToken is ERC20 { + address internal _owner; + + constructor(string memory name_, string memory symbol_, address initialOwner) ERC20(name_, symbol_) { + _owner = initialOwner; + } + + function decimals() public pure override returns (uint8) { + return 18; + } + + function owner() external view returns (address) { + return _owner; + } + + function setOwner(address newOwner) external { + _owner = newOwner; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract MockReserveAsset is ERC20 { + constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {} + + function decimals() public pure override returns (uint8) { + return 18; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function burn(address from, uint256 amount) external { + _burn(from, amount); + } +} + +contract MockRouterAssetVerifier is IRouterClient { + function ccipSend( + uint64, + EVM2AnyMessage memory + ) external payable returns (bytes32 messageId, uint256 fees) { + return (bytes32(0), fees); + } + + function getFee(uint64, EVM2AnyMessage memory) external pure returns (uint256) { + return 0; + } + + function getSupportedTokens(uint64) external pure returns (address[] memory tokens) { + tokens = new address[](0); + } +} + +contract MockReserveSystemAssetVerifier { + mapping(address => uint256) internal reserveBalances; + + function setReserveBalance(address asset, uint256 amount) external { + reserveBalances[asset] = amount; + } + + function getReserveBalance(address asset) external view returns (uint256) { + return reserveBalances[asset]; + } +} + +contract CWAssetReserveVerifierTest is Test { + uint64 internal constant MAINNET_SELECTOR = 5009297550715157269; + uint64 internal constant BSC_SELECTOR = 11344663589394136015; + + address internal user = address(0xBEEF); + address internal receiveRouter = address(0x138138); + address internal strictPeerBridge = address(0x010101); + address internal hybridPeerBridge = address(0x020202); + address internal assetVault = address(0xCAFE); + + MockRouterAssetVerifier internal router; + MockCanonicalAssetToken internal strictCanonical; + MockCanonicalAssetToken internal hybridCanonical; + MockReserveAsset internal strictReserveAsset; + MockReserveAsset internal hybridReserveAsset; + MockReserveSystemAssetVerifier internal reserveSystem; + CWMultiTokenBridgeL1 internal l1Bridge; + CWAssetReserveVerifier internal verifier; + + function setUp() public { + router = new MockRouterAssetVerifier(); + strictCanonical = new MockCanonicalAssetToken("Ethereum Mainnet Gas (Compliant)", "cETH", assetVault); + hybridCanonical = new MockCanonicalAssetToken("BNB Gas (Compliant)", "cBNB", address(this)); + strictReserveAsset = new MockReserveAsset("Wrapped Ether", "WETH"); + hybridReserveAsset = new MockReserveAsset("Wrapped BNB", "WBNB"); + reserveSystem = new MockReserveSystemAssetVerifier(); + l1Bridge = new CWMultiTokenBridgeL1(address(router), receiveRouter, address(0)); + verifier = new CWAssetReserveVerifier(address(this), address(l1Bridge), assetVault, address(reserveSystem)); + + l1Bridge.configureSupportedCanonicalToken(address(strictCanonical), true); + l1Bridge.configureDestination(address(strictCanonical), MAINNET_SELECTOR, strictPeerBridge, true); + l1Bridge.configureSupportedCanonicalToken(address(hybridCanonical), true); + l1Bridge.configureDestination(address(hybridCanonical), BSC_SELECTOR, hybridPeerBridge, true); + l1Bridge.setReserveVerifier(address(verifier)); + + verifier.configureToken(address(strictCanonical), address(strictReserveAsset), true, false, true); + verifier.configureToken(address(hybridCanonical), address(hybridReserveAsset), false, true, false); + + strictCanonical.mint(user, 100e18); + hybridCanonical.mint(user, 75e18); + strictReserveAsset.mint(assetVault, strictCanonical.totalSupply()); + reserveSystem.setReserveBalance(address(hybridReserveAsset), hybridCanonical.totalSupply()); + } + + function testStrictVerifierAllowsLockWhenVaultBalanceMatchesSupply() public { + uint256 amount = 10e18; + + vm.startPrank(user); + strictCanonical.approve(address(l1Bridge), amount); + l1Bridge.lockAndSend(address(strictCanonical), MAINNET_SELECTOR, user, amount); + vm.stopPrank(); + + assertEq(l1Bridge.lockedBalance(address(strictCanonical)), amount); + assertEq(l1Bridge.totalOutstanding(address(strictCanonical)), amount); + } + + function testStrictVerifierBlocksLockWhenVaultBalanceFallsShort() public { + uint256 amount = 10e18; + strictReserveAsset.burn(assetVault, 1); + + vm.startPrank(user); + strictCanonical.approve(address(l1Bridge), amount); + vm.expectRevert(CWAssetReserveVerifier.VaultBackingInsufficient.selector); + l1Bridge.lockAndSend(address(strictCanonical), MAINNET_SELECTOR, user, amount); + vm.stopPrank(); + } + + function testStrictVerifierBlocksLockWhenTokenOwnerDoesNotMatchVault() public { + uint256 amount = 10e18; + strictCanonical.setOwner(address(0xDEAD)); + + vm.startPrank(user); + strictCanonical.approve(address(l1Bridge), amount); + vm.expectRevert(CWAssetReserveVerifier.TokenOwnerMismatch.selector); + l1Bridge.lockAndSend(address(strictCanonical), MAINNET_SELECTOR, user, amount); + vm.stopPrank(); + } + + function testHybridVerifierAllowsLockWhenReserveSystemBalanceMatchesSupply() public { + uint256 amount = 10e18; + + vm.startPrank(user); + hybridCanonical.approve(address(l1Bridge), amount); + l1Bridge.lockAndSend(address(hybridCanonical), BSC_SELECTOR, user, amount); + vm.stopPrank(); + + assertEq(l1Bridge.lockedBalance(address(hybridCanonical)), amount); + assertEq(l1Bridge.totalOutstanding(address(hybridCanonical)), amount); + } + + function testHybridVerifierBlocksLockWhenReserveSystemFallsShort() public { + uint256 amount = 10e18; + reserveSystem.setReserveBalance(address(hybridReserveAsset), hybridCanonical.totalSupply() - 1); + + vm.startPrank(user); + hybridCanonical.approve(address(l1Bridge), amount); + vm.expectRevert(CWAssetReserveVerifier.ReserveSystemBackingInsufficient.selector); + l1Bridge.lockAndSend(address(hybridCanonical), BSC_SELECTOR, user, amount); + vm.stopPrank(); + } +} diff --git a/test/bridge/CWMultiTokenBridge.t.sol b/test/bridge/CWMultiTokenBridge.t.sol new file mode 100644 index 0000000..2437be3 --- /dev/null +++ b/test/bridge/CWMultiTokenBridge.t.sol @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {IRouterClient} from "../../contracts/ccip/IRouterClient.sol"; +import {CWMultiTokenBridgeL1} from "../../contracts/bridge/CWMultiTokenBridgeL1.sol"; +import {CWMultiTokenBridgeL2} from "../../contracts/bridge/CWMultiTokenBridgeL2.sol"; +import {CompliantWrappedToken} from "../../contracts/tokens/CompliantWrappedToken.sol"; +import {CCIPRelayRouter} from "../../contracts/relay/CCIPRelayRouter.sol"; + +contract MockCanonicalToken is ERC20 { + constructor() ERC20("Mock Canonical", "MCAN") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract MockRouter is IRouterClient { + uint256 public fee; + bytes32 public nextMessageId = keccak256("message"); + EVM2AnyMessage internal _lastMessage; + uint64 public lastDestinationChainSelector; + + function setFee(uint256 newFee) external { + fee = newFee; + } + + function ccipSend( + uint64 destinationChainSelector, + EVM2AnyMessage memory message + ) external payable returns (bytes32 messageId, uint256 fees) { + fees = fee; + if (message.feeToken == address(0)) { + require(msg.value >= fees, "native fee"); + } + + lastDestinationChainSelector = destinationChainSelector; + _lastMessage = message; + + emit MessageSent( + nextMessageId, + destinationChainSelector, + msg.sender, + message.receiver, + message.data, + message.tokenAmounts, + message.feeToken, + message.extraArgs + ); + + return (nextMessageId, fees); + } + + function getFee(uint64, EVM2AnyMessage memory) external view returns (uint256) { + return fee; + } + + function getSupportedTokens(uint64) external pure returns (address[] memory tokens) { + tokens = new address[](0); + } + + function lastMessage() + external + view + returns (bytes memory receiver, bytes memory data, address feeToken, bytes memory extraArgs) + { + return (_lastMessage.receiver, _lastMessage.data, _lastMessage.feeToken, _lastMessage.extraArgs); + } +} + +contract MockReceiveBridge { + bytes public lastData; + + function ccipReceive(IRouterClient.Any2EVMMessage calldata message) external { + lastData = message.data; + } +} + +contract CWMultiTokenBridgeTest is Test { + uint64 internal constant CHAIN138_SELECTOR = 138; + uint64 internal constant AVALANCHE_SELECTOR = 6433500567565415381; + + address internal user = address(0xBEEF); + + MockRouter internal router138; + MockRouter internal routerAvax; + MockCanonicalToken internal canonical; + CompliantWrappedToken internal wrapped; + CWMultiTokenBridgeL1 internal l1Bridge; + CWMultiTokenBridgeL2 internal l2Bridge; + + function setUp() public { + router138 = new MockRouter(); + routerAvax = new MockRouter(); + canonical = new MockCanonicalToken(); + wrapped = new CompliantWrappedToken("Wrapped Canonical", "cWMOCK", 6, address(this)); + + l1Bridge = new CWMultiTokenBridgeL1(address(router138), address(0x138138), address(0)); + l2Bridge = new CWMultiTokenBridgeL2(address(routerAvax), address(0x4311443114), address(0)); + + l1Bridge.configureSupportedCanonicalToken(address(canonical), true); + l1Bridge.configureDestination(address(canonical), AVALANCHE_SELECTOR, address(l2Bridge), true); + l2Bridge.configureDestination(CHAIN138_SELECTOR, address(l1Bridge), true); + l2Bridge.configureTokenPair(address(canonical), address(wrapped)); + + wrapped.grantRole(wrapped.MINTER_ROLE(), address(l2Bridge)); + wrapped.grantRole(wrapped.BURNER_ROLE(), address(l2Bridge)); + + canonical.mint(user, 1_000_000e18); + } + + function testRoundTripLockMintBurnRelease() public { + uint256 amount = 125e6; + + vm.startPrank(user); + canonical.approve(address(l1Bridge), amount); + bytes32 outboundMessageId = + l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + + assertEq(canonical.balanceOf(address(l1Bridge)), amount); + assertEq(canonical.balanceOf(user), 1_000_000e18 - amount); + assertEq(l1Bridge.lockedBalance(address(canonical)), amount); + assertEq(l1Bridge.totalOutstanding(address(canonical)), amount); + assertEq(l1Bridge.outstandingMinted(address(canonical), AVALANCHE_SELECTOR), amount); + + (bytes memory receiverData, bytes memory outboundData,,) = router138.lastMessage(); + assertEq(abi.decode(receiverData, (address)), address(l2Bridge)); + assertEq(outboundMessageId, router138.nextMessageId()); + + vm.prank(address(0x4311443114)); + l2Bridge.ccipReceive(_message(outboundMessageId, CHAIN138_SELECTOR, address(l1Bridge), outboundData)); + assertEq(wrapped.balanceOf(user), amount); + assertEq(wrapped.totalSupply(), amount); + assertEq(l2Bridge.mintedTotal(address(wrapped)), amount); + assertEq(l2Bridge.burnedTotal(address(wrapped)), 0); + + vm.prank(user); + bytes32 returnMessageId = l2Bridge.burnAndSend(address(wrapped), CHAIN138_SELECTOR, user, amount); + assertEq(wrapped.balanceOf(user), 0); + assertEq(wrapped.totalSupply(), 0); + assertEq(l2Bridge.mintedTotal(address(wrapped)), amount); + assertEq(l2Bridge.burnedTotal(address(wrapped)), amount); + + (, bytes memory returnData,,) = routerAvax.lastMessage(); + vm.prank(address(0x138138)); + l1Bridge.ccipReceive(_message(returnMessageId, AVALANCHE_SELECTOR, address(l2Bridge), returnData)); + + assertEq(canonical.balanceOf(user), 1_000_000e18); + assertEq(canonical.balanceOf(address(l1Bridge)), 0); + assertEq(l1Bridge.lockedBalance(address(canonical)), 0); + assertEq(l1Bridge.totalOutstanding(address(canonical)), 0); + assertEq(l1Bridge.outstandingMinted(address(canonical), AVALANCHE_SELECTOR), 0); + } + + function testLockAndSendRespectsMaxOutstanding() public { + uint256 amount = 125e6; + + l1Bridge.setMaxOutstanding(address(canonical), AVALANCHE_SELECTOR, 100e6); + + vm.startPrank(user); + canonical.approve(address(l1Bridge), amount); + vm.expectRevert(bytes("CWMultiTokenBridgeL1: exceeds escrow capacity")); + l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + } + + function testSupportedCanonicalTokenCannotBeDisabledWhileFundsOutstanding() public { + uint256 amount = 50e6; + + vm.startPrank(user); + canonical.approve(address(l1Bridge), amount); + l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + + vm.expectRevert(bytes("CWMultiTokenBridgeL1: token still locked")); + l1Bridge.configureSupportedCanonicalToken(address(canonical), false); + } + + function testWithdrawTokenCannotDrainLockedCanonicalEscrow() public { + uint256 amount = 50e6; + + vm.startPrank(user); + canonical.approve(address(l1Bridge), amount); + l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + + vm.expectRevert(bytes("CWMultiTokenBridgeL1: amount locked")); + l1Bridge.withdrawToken(address(canonical), user, 1); + + canonical.mint(address(l1Bridge), 10e6); + l1Bridge.withdrawToken(address(canonical), user, 10e6); + assertEq(canonical.balanceOf(user), 1_000_000e18 - amount + 10e6); + assertEq(canonical.balanceOf(address(l1Bridge)), amount); + } + + function testReleaseRequiresOutstandingBalance() public { + bytes memory returnData = abi.encode(address(canonical), user, uint256(1)); + + vm.expectRevert(bytes("CWMultiTokenBridgeL1: outstanding underflow")); + vm.prank(address(0x138138)); + l1Bridge.ccipReceive(_message(keccak256("no-lock"), AVALANCHE_SELECTOR, address(l2Bridge), returnData)); + } + + function testL2PauseBlocksMintAndBurn() public { + uint256 amount = 20e6; + + vm.startPrank(user); + canonical.approve(address(l1Bridge), amount); + bytes32 outboundMessageId = + l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + + (, bytes memory outboundData,,) = router138.lastMessage(); + + l2Bridge.setTokenPaused(address(wrapped), true); + vm.expectRevert(bytes("CWMultiTokenBridgeL2: token paused")); + vm.prank(address(0x4311443114)); + l2Bridge.ccipReceive(_message(outboundMessageId, CHAIN138_SELECTOR, address(l1Bridge), outboundData)); + + l2Bridge.setTokenPaused(address(wrapped), false); + vm.prank(address(0x4311443114)); + l2Bridge.ccipReceive(_message(outboundMessageId, CHAIN138_SELECTOR, address(l1Bridge), outboundData)); + + l2Bridge.setTokenPaused(address(wrapped), true); + vm.expectRevert(bytes("CWMultiTokenBridgeL2: token paused")); + vm.prank(user); + l2Bridge.burnAndSend(address(wrapped), CHAIN138_SELECTOR, user, amount); + } + + function testL2CirculatingSupplyTracksMintMinusBurn() public { + uint256 amount = 35e6; + + vm.startPrank(user); + canonical.approve(address(l1Bridge), amount); + bytes32 outboundMessageId = + l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + + (, bytes memory outboundData,,) = router138.lastMessage(); + + vm.prank(address(0x4311443114)); + l2Bridge.ccipReceive(_message(outboundMessageId, CHAIN138_SELECTOR, address(l1Bridge), outboundData)); + assertEq(l2Bridge.circulatingSupply(address(wrapped)), amount); + + vm.prank(user); + l2Bridge.burnAndSend(address(wrapped), CHAIN138_SELECTOR, user, 10e6); + assertEq(l2Bridge.circulatingSupply(address(wrapped)), amount - 10e6); + } + + function testL2RejectsReplayOfInboundMintMessage() public { + uint256 amount = 15e6; + + vm.startPrank(user); + canonical.approve(address(l1Bridge), amount); + bytes32 outboundMessageId = + l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + + (, bytes memory outboundData,,) = router138.lastMessage(); + + vm.prank(address(0x4311443114)); + l2Bridge.ccipReceive(_message(outboundMessageId, CHAIN138_SELECTOR, address(l1Bridge), outboundData)); + + vm.expectRevert(bytes("CWMultiTokenBridgeL2: replayed")); + vm.prank(address(0x4311443114)); + l2Bridge.ccipReceive(_message(outboundMessageId, CHAIN138_SELECTOR, address(l1Bridge), outboundData)); + } + + function testFrozenL2ConfigCannotBeRewritten() public { + CompliantWrappedToken wrapped2 = new CompliantWrappedToken("Wrapped Canonical 2", "cWMOCK2", 6, address(this)); + + l2Bridge.freezeTokenPair(address(canonical)); + vm.expectRevert(bytes("CWMultiTokenBridgeL2: token pair frozen")); + l2Bridge.configureTokenPair(address(canonical), address(wrapped2)); + + l2Bridge.freezeDestination(CHAIN138_SELECTOR); + vm.expectRevert(bytes("CWMultiTokenBridgeL2: destination frozen")); + l2Bridge.configureDestination(CHAIN138_SELECTOR, address(0x1234), true); + } + + function testL1RejectsReplayOfReturnMessage() public { + uint256 amount = 25e6; + + vm.startPrank(user); + canonical.approve(address(l1Bridge), amount); + l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + + bytes32 returnMessageId = keccak256("return-replay"); + bytes memory returnData = abi.encode(address(canonical), user, amount); + + vm.prank(address(0x138138)); + l1Bridge.ccipReceive(_message(returnMessageId, AVALANCHE_SELECTOR, address(l2Bridge), returnData)); + + vm.expectRevert(bytes("CWMultiTokenBridgeL1: replayed")); + vm.prank(address(0x138138)); + l1Bridge.ccipReceive(_message(returnMessageId, AVALANCHE_SELECTOR, address(l2Bridge), returnData)); + } + + function testRelayRouterAcceptsThreeFieldPayload() public { + CCIPRelayRouter relayRouter = new CCIPRelayRouter(); + MockReceiveBridge receiveBridge = new MockReceiveBridge(); + + relayRouter.authorizeBridge(address(receiveBridge)); + relayRouter.grantRelayerRole(address(this)); + + IRouterClient.TokenAmount[] memory noTokens = new IRouterClient.TokenAmount[](0); + IRouterClient.Any2EVMMessage memory message = IRouterClient.Any2EVMMessage({ + messageId: keccak256("three-field"), + sourceChainSelector: CHAIN138_SELECTOR, + sender: abi.encode(address(0xCAFE)), + data: abi.encode(address(canonical), user, uint256(42)), + tokenAmounts: noTokens + }); + + relayRouter.relayMessage(address(receiveBridge), message); + assertEq(keccak256(receiveBridge.lastData()), keccak256(message.data)); + } + + function _message( + bytes32 messageId, + uint64 sourceChainSelector, + address sender, + bytes memory data + ) internal pure returns (IRouterClient.Any2EVMMessage memory message) { + IRouterClient.TokenAmount[] memory noTokens = new IRouterClient.TokenAmount[](0); + message = IRouterClient.Any2EVMMessage({ + messageId: messageId, + sourceChainSelector: sourceChainSelector, + sender: abi.encode(sender), + data: data, + tokenAmounts: noTokens + }); + } +} diff --git a/test/bridge/CWMultiTokenBridgeBTC.t.sol b/test/bridge/CWMultiTokenBridgeBTC.t.sol new file mode 100644 index 0000000..fc6ccf5 --- /dev/null +++ b/test/bridge/CWMultiTokenBridgeBTC.t.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {IRouterClient} from "../../contracts/ccip/IRouterClient.sol"; +import {CWMultiTokenBridgeL1} from "../../contracts/bridge/CWMultiTokenBridgeL1.sol"; +import {CWMultiTokenBridgeL2} from "../../contracts/bridge/CWMultiTokenBridgeL2.sol"; +import {CompliantWrappedToken} from "../../contracts/tokens/CompliantWrappedToken.sol"; + +contract MockCanonicalBTC is ERC20 { + constructor() ERC20("Bitcoin (Compliant)", "cBTC") {} + + function decimals() public pure override returns (uint8) { + return 8; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract MockRouterBTC is IRouterClient { + bytes32 public nextMessageId = keccak256("btc-message"); + EVM2AnyMessage internal _lastMessage; + + function ccipSend( + uint64 destinationChainSelector, + EVM2AnyMessage memory message + ) external payable returns (bytes32 messageId, uint256 fees) { + destinationChainSelector; + _lastMessage = message; + return (nextMessageId, fees); + } + + function getFee(uint64, EVM2AnyMessage memory) external pure returns (uint256) { + return 0; + } + + function getSupportedTokens(uint64) external pure returns (address[] memory tokens) { + tokens = new address[](0); + } + + function lastMessage() external view returns (bytes memory receiver, bytes memory data) { + return (_lastMessage.receiver, _lastMessage.data); + } +} + +contract CWMultiTokenBridgeBTCTest is Test { + uint64 internal constant CHAIN138_SELECTOR = 138; + uint64 internal constant ETHEREUM_SELECTOR = 5009297550715157269; + + address internal user = address(0xBEEF); + + MockRouterBTC internal router138; + MockRouterBTC internal routerEth; + MockCanonicalBTC internal canonical; + CompliantWrappedToken internal wrapped; + CWMultiTokenBridgeL1 internal l1Bridge; + CWMultiTokenBridgeL2 internal l2Bridge; + + function setUp() public { + router138 = new MockRouterBTC(); + routerEth = new MockRouterBTC(); + canonical = new MockCanonicalBTC(); + wrapped = new CompliantWrappedToken("Wrapped cBTC", "cWBTC", 8, address(this)); + + l1Bridge = new CWMultiTokenBridgeL1(address(router138), address(0x138138), address(0)); + l2Bridge = new CWMultiTokenBridgeL2(address(routerEth), address(0x010101), address(0)); + + l1Bridge.configureSupportedCanonicalToken(address(canonical), true); + l1Bridge.configureDestination(address(canonical), ETHEREUM_SELECTOR, address(l2Bridge), true); + l2Bridge.configureDestination(CHAIN138_SELECTOR, address(l1Bridge), true); + l2Bridge.configureTokenPair(address(canonical), address(wrapped)); + + wrapped.grantRole(wrapped.MINTER_ROLE(), address(l2Bridge)); + wrapped.grantRole(wrapped.BURNER_ROLE(), address(l2Bridge)); + + canonical.mint(user, 2_100_000_000_000_000); + } + + function testRoundTripPreservesSatoshiPrecision() public { + uint256 amount = 125_000_000; + + vm.startPrank(user); + canonical.approve(address(l1Bridge), amount); + bytes32 outboundMessageId = l1Bridge.lockAndSend(address(canonical), ETHEREUM_SELECTOR, user, amount); + vm.stopPrank(); + + (, bytes memory outboundData) = router138.lastMessage(); + vm.prank(address(0x010101)); + l2Bridge.ccipReceive(_message(outboundMessageId, CHAIN138_SELECTOR, address(l1Bridge), outboundData)); + + assertEq(wrapped.balanceOf(user), amount); + assertEq(wrapped.totalSupply(), amount); + + vm.prank(user); + bytes32 returnMessageId = l2Bridge.burnAndSend(address(wrapped), CHAIN138_SELECTOR, user, amount); + + (, bytes memory returnData) = routerEth.lastMessage(); + vm.prank(address(0x138138)); + l1Bridge.ccipReceive(_message(returnMessageId, ETHEREUM_SELECTOR, address(l2Bridge), returnData)); + + assertEq(canonical.balanceOf(user), 2_100_000_000_000_000); + assertEq(wrapped.totalSupply(), 0); + assertEq(l1Bridge.totalOutstanding(address(canonical)), 0); + } + + function testPerDestinationOutstandingCapStillAppliesForBTCRoute() public { + l1Bridge.setMaxOutstanding(address(canonical), ETHEREUM_SELECTOR, 100_000_000); + + vm.startPrank(user); + canonical.approve(address(l1Bridge), 125_000_000); + vm.expectRevert(bytes("CWMultiTokenBridgeL1: exceeds escrow capacity")); + l1Bridge.lockAndSend(address(canonical), ETHEREUM_SELECTOR, user, 125_000_000); + vm.stopPrank(); + } + + function _message( + bytes32 messageId, + uint64 sourceChainSelector, + address sender, + bytes memory data + ) internal pure returns (IRouterClient.Any2EVMMessage memory message) { + IRouterClient.TokenAmount[] memory noTokens = new IRouterClient.TokenAmount[](0); + message = IRouterClient.Any2EVMMessage({ + messageId: messageId, + sourceChainSelector: sourceChainSelector, + sender: abi.encode(sender), + data: data, + tokenAmounts: noTokens + }); + } +} diff --git a/test/bridge/CWReserveVerifier.t.sol b/test/bridge/CWReserveVerifier.t.sol new file mode 100644 index 0000000..51c97aa --- /dev/null +++ b/test/bridge/CWReserveVerifier.t.sol @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {IRouterClient} from "../../contracts/ccip/IRouterClient.sol"; +import {CWMultiTokenBridgeL1} from "../../contracts/bridge/CWMultiTokenBridgeL1.sol"; +import {CWReserveVerifier} from "../../contracts/bridge/integration/CWReserveVerifier.sol"; + +contract MockCanonicalOwnableToken is ERC20 { + address internal _owner; + + constructor(address initialOwner) ERC20("Mock Canonical Stable", "MCS") { + _owner = initialOwner; + } + + function owner() external view returns (address) { + return _owner; + } + + function setOwner(address newOwner) external { + _owner = newOwner; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract MockRouterVerifier is IRouterClient { + uint256 public fee; + bytes32 public nextMessageId = keccak256("cw-reserve-message"); + EVM2AnyMessage internal _lastMessage; + + function ccipSend( + uint64, + EVM2AnyMessage memory message + ) external payable returns (bytes32 messageId, uint256 fees) { + fees = fee; + _lastMessage = message; + return (nextMessageId, fees); + } + + function getFee(uint64, EVM2AnyMessage memory) external view returns (uint256) { + return fee; + } + + function getSupportedTokens(uint64) external pure returns (address[] memory tokens) { + tokens = new address[](0); + } + + function lastMessage() + external + view + returns (bytes memory receiver, bytes memory data, address feeToken, bytes memory extraArgs) + { + return (_lastMessage.receiver, _lastMessage.data, _lastMessage.feeToken, _lastMessage.extraArgs); + } +} + +contract MockStablecoinReserveVaultForCW { + address public compliantUSDT; + address public compliantUSDC; + + mapping(address => uint256) public reserveBalance; + mapping(address => uint256) public backingRatio; + + bool public usdtAdequate = true; + bool public usdcAdequate = true; + + constructor(address compliantUSDT_, address compliantUSDC_) { + compliantUSDT = compliantUSDT_; + compliantUSDC = compliantUSDC_; + } + + function setBacking(address token, uint256 reserveBalance_, uint256 backingRatio_) external { + reserveBalance[token] = reserveBalance_; + backingRatio[token] = backingRatio_; + } + + function setAdequacy(bool usdtAdequate_, bool usdcAdequate_) external { + usdtAdequate = usdtAdequate_; + usdcAdequate = usdcAdequate_; + } + + function getBackingRatio(address token) external view returns ( + uint256 reserveBalance_, + uint256 tokenSupply, + uint256 backingRatio_ + ) { + reserveBalance_ = reserveBalance[token]; + tokenSupply = ERC20(token).totalSupply(); + backingRatio_ = backingRatio[token]; + } + + function checkReserveAdequacy() external view returns (bool, bool) { + return (usdtAdequate, usdcAdequate); + } +} + +contract MockReserveSystemForCW { + mapping(address => uint256) internal reserveBalances; + + function setReserveBalance(address asset, uint256 amount) external { + reserveBalances[asset] = amount; + } + + function getReserveBalance(address asset) external view returns (uint256) { + return reserveBalances[asset]; + } +} + +contract CWReserveVerifierTest is Test { + uint64 internal constant AVALANCHE_SELECTOR = 6433500567565415381; + + address internal user = address(0xBEEF); + address internal receiveRouter = address(0x138138); + address internal peerBridge = address(0x4311443114); + address internal officialReserveAsset = address(0xA0b8); + + MockRouterVerifier internal router; + MockCanonicalOwnableToken internal canonical; + CWMultiTokenBridgeL1 internal l1Bridge; + MockStablecoinReserveVaultForCW internal reserveVault; + MockReserveSystemForCW internal reserveSystem; + CWReserveVerifier internal verifier; + + function setUp() public { + router = new MockRouterVerifier(); + canonical = new MockCanonicalOwnableToken(address(this)); + l1Bridge = new CWMultiTokenBridgeL1(address(router), receiveRouter, address(0)); + reserveVault = new MockStablecoinReserveVaultForCW(address(0), address(canonical)); + reserveSystem = new MockReserveSystemForCW(); + verifier = new CWReserveVerifier(address(this), address(l1Bridge), address(reserveVault), address(reserveSystem)); + + canonical.setOwner(address(reserveVault)); + + l1Bridge.configureSupportedCanonicalToken(address(canonical), true); + l1Bridge.configureDestination(address(canonical), AVALANCHE_SELECTOR, peerBridge, true); + l1Bridge.setReserveVerifier(address(verifier)); + + verifier.configureToken( + address(canonical), + officialReserveAsset, + true, + true, + true + ); + + canonical.mint(user, 1_000_000e6); + + uint256 canonicalSupply = canonical.totalSupply(); + reserveVault.setBacking(address(canonical), canonicalSupply, 10000); + reserveVault.setAdequacy(true, true); + reserveSystem.setReserveBalance(officialReserveAsset, canonicalSupply); + } + + function testVerifierAllowsLockWhenCanonicalReservesHealthy() public { + uint256 amount = 125e6; + + vm.startPrank(user); + canonical.approve(address(l1Bridge), amount); + l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + + assertEq(l1Bridge.lockedBalance(address(canonical)), amount); + assertEq(l1Bridge.totalOutstanding(address(canonical)), amount); + assertEq(l1Bridge.outstandingMinted(address(canonical), AVALANCHE_SELECTOR), amount); + } + + function testVerifierBlocksLockWhenVaultBackingFallsBelowPar() public { + uint256 amount = 25e6; + + reserveVault.setBacking(address(canonical), canonical.totalSupply() - 1, 9999); + reserveVault.setAdequacy(true, false); + + vm.startPrank(user); + canonical.approve(address(l1Bridge), amount); + vm.expectRevert(CWReserveVerifier.VaultBackingInsufficient.selector); + l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + } + + function testVerifierBlocksLockWhenReserveSystemBackingFallsShort() public { + uint256 amount = 25e6; + + reserveSystem.setReserveBalance(officialReserveAsset, canonical.totalSupply() - 1); + + vm.startPrank(user); + canonical.approve(address(l1Bridge), amount); + vm.expectRevert(CWReserveVerifier.ReserveSystemBackingInsufficient.selector); + l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + } + + function testVerifierRevertLeavesEscrowAccountingUnchanged() public { + uint256 amount = 25e6; + + reserveSystem.setReserveBalance(officialReserveAsset, canonical.totalSupply() - 1); + + vm.startPrank(user); + canonical.approve(address(l1Bridge), amount); + vm.expectRevert(CWReserveVerifier.ReserveSystemBackingInsufficient.selector); + l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + + assertEq(l1Bridge.lockedBalance(address(canonical)), 0); + assertEq(l1Bridge.totalOutstanding(address(canonical)), 0); + assertEq(l1Bridge.outstandingMinted(address(canonical), AVALANCHE_SELECTOR), 0); + assertEq(canonical.balanceOf(user), 1_000_000e6); + } + + function testVerifierBlocksLockWhenTokenOwnerDoesNotMatchVault() public { + uint256 amount = 25e6; + + canonical.setOwner(address(0xCAFE)); + + vm.startPrank(user); + canonical.approve(address(l1Bridge), amount); + vm.expectRevert(CWReserveVerifier.TokenOwnerMismatch.selector); + l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + } + + function testReleaseRemainsAvailableAfterVerifierWouldLaterFail() public { + uint256 amount = 60e6; + + vm.startPrank(user); + canonical.approve(address(l1Bridge), amount); + l1Bridge.lockAndSend(address(canonical), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + + reserveVault.setBacking(address(canonical), canonical.totalSupply() - 10, 9990); + reserveVault.setAdequacy(true, false); + reserveSystem.setReserveBalance(officialReserveAsset, canonical.totalSupply() - 10); + + bytes memory returnData = abi.encode(address(canonical), user, amount); + vm.prank(receiveRouter); + l1Bridge.ccipReceive(_message(keccak256("return"), AVALANCHE_SELECTOR, peerBridge, returnData)); + + assertEq(l1Bridge.lockedBalance(address(canonical)), 0); + assertEq(l1Bridge.totalOutstanding(address(canonical)), 0); + assertEq(canonical.balanceOf(user), 1_000_000e6); + } + + function _message( + bytes32 messageId, + uint64 sourceChainSelector, + address sender, + bytes memory data + ) internal pure returns (IRouterClient.Any2EVMMessage memory message) { + IRouterClient.TokenAmount[] memory noTokens = new IRouterClient.TokenAmount[](0); + message = IRouterClient.Any2EVMMessage({ + messageId: messageId, + sourceChainSelector: sourceChainSelector, + sender: abi.encode(sender), + data: data, + tokenAmounts: noTokens + }); + } +} diff --git a/test/bridge/CWReserveVerifierBTC.t.sol b/test/bridge/CWReserveVerifierBTC.t.sol new file mode 100644 index 0000000..39dae1f --- /dev/null +++ b/test/bridge/CWReserveVerifierBTC.t.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {IRouterClient} from "../../contracts/ccip/IRouterClient.sol"; +import {CWMultiTokenBridgeL1} from "../../contracts/bridge/CWMultiTokenBridgeL1.sol"; +import {CWReserveVerifier} from "../../contracts/bridge/integration/CWReserveVerifier.sol"; + +contract MockCanonicalBTCWithOwner is ERC20 { + address internal _owner; + + constructor(address initialOwner) ERC20("Bitcoin (Compliant)", "cBTC") { + _owner = initialOwner; + } + + function decimals() public pure override returns (uint8) { + return 8; + } + + function owner() external view returns (address) { + return _owner; + } + + function setOwner(address newOwner) external { + _owner = newOwner; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract MockRouterVerifierBTC is IRouterClient { + function ccipSend( + uint64, + EVM2AnyMessage memory + ) external payable returns (bytes32 messageId, uint256 fees) { + return (bytes32(0), fees); + } + + function getFee(uint64, EVM2AnyMessage memory) external pure returns (uint256) { + return 0; + } + + function getSupportedTokens(uint64) external pure returns (address[] memory tokens) { + tokens = new address[](0); + } +} + +contract MockReserveVaultBTC { + address internal trackedToken; + mapping(address => uint256) public reserveBalance; + mapping(address => uint256) public backingRatio; + bool public tokenAdequate = true; + + constructor(address trackedToken_) { + trackedToken = trackedToken_; + } + + function compliantUSDT() external view returns (address) { + return trackedToken; + } + + function compliantUSDC() external pure returns (address) { + return address(0xCAFE); + } + + function setAdequacy(bool tokenAdequate_) external { + tokenAdequate = tokenAdequate_; + } + + function setBacking(address token, uint256 reserveBalance_, uint256 backingRatio_) external { + reserveBalance[token] = reserveBalance_; + backingRatio[token] = backingRatio_; + } + + function getBackingRatio(address token) external view returns (uint256, uint256, uint256) { + return (reserveBalance[token], ERC20(token).totalSupply(), backingRatio[token]); + } + + function checkReserveAdequacy() external view returns (bool, bool) { + return (tokenAdequate, true); + } +} + +contract MockReserveSystemBTC { + mapping(address => uint256) internal reserveBalances; + + function setReserveBalance(address asset, uint256 amount) external { + reserveBalances[asset] = amount; + } + + function getReserveBalance(address asset) external view returns (uint256) { + return reserveBalances[asset]; + } +} + +contract CWReserveVerifierBTCTest is Test { + uint64 internal constant ETHEREUM_SELECTOR = 5009297550715157269; + + address internal user = address(0xBEEF); + address internal receiveRouter = address(0x138138); + address internal peerBridge = address(0x010101); + address internal reserveAsset = address(0x0B7C); + + MockRouterVerifierBTC internal router; + MockCanonicalBTCWithOwner internal canonical; + CWMultiTokenBridgeL1 internal l1Bridge; + MockReserveVaultBTC internal reserveVault; + MockReserveSystemBTC internal reserveSystem; + CWReserveVerifier internal verifier; + + function setUp() public { + router = new MockRouterVerifierBTC(); + canonical = new MockCanonicalBTCWithOwner(address(this)); + l1Bridge = new CWMultiTokenBridgeL1(address(router), receiveRouter, address(0)); + reserveVault = new MockReserveVaultBTC(address(canonical)); + reserveSystem = new MockReserveSystemBTC(); + verifier = new CWReserveVerifier(address(this), address(l1Bridge), address(reserveVault), address(reserveSystem)); + + canonical.setOwner(address(reserveVault)); + l1Bridge.configureSupportedCanonicalToken(address(canonical), true); + l1Bridge.configureDestination(address(canonical), ETHEREUM_SELECTOR, peerBridge, true); + l1Bridge.setReserveVerifier(address(verifier)); + + verifier.configureToken(address(canonical), reserveAsset, true, true, true); + + canonical.mint(user, 10_000_000_000); + reserveVault.setBacking(address(canonical), canonical.totalSupply(), 10_000); + reserveVault.setAdequacy(true); + reserveSystem.setReserveBalance(reserveAsset, canonical.totalSupply()); + } + + function testVerifierAllowsBTCLockAtEightDecimals() public { + vm.startPrank(user); + canonical.approve(address(l1Bridge), 250_000_000); + l1Bridge.lockAndSend(address(canonical), ETHEREUM_SELECTOR, user, 250_000_000); + vm.stopPrank(); + + assertEq(l1Bridge.totalOutstanding(address(canonical)), 250_000_000); + assertEq(l1Bridge.lockedBalance(address(canonical)), 250_000_000); + } + + function testVerifierBlocksBTCLockWhenReserveFallsShort() public { + reserveSystem.setReserveBalance(reserveAsset, canonical.totalSupply() - 1); + + vm.startPrank(user); + canonical.approve(address(l1Bridge), 100_000_000); + vm.expectRevert(CWReserveVerifier.ReserveSystemBackingInsufficient.selector); + l1Bridge.lockAndSend(address(canonical), ETHEREUM_SELECTOR, user, 100_000_000); + vm.stopPrank(); + } +} diff --git a/test/bridge/CWReserveVerifierVaultIntegration.t.sol b/test/bridge/CWReserveVerifierVaultIntegration.t.sol new file mode 100644 index 0000000..c15f644 --- /dev/null +++ b/test/bridge/CWReserveVerifierVaultIntegration.t.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; + +import {IRouterClient} from "../../contracts/ccip/IRouterClient.sol"; +import {OfficialStableMirrorToken} from "../../contracts/tokens/OfficialStableMirrorToken.sol"; +import {CompliantUSDT} from "../../contracts/tokens/CompliantUSDT.sol"; +import {CompliantUSDC} from "../../contracts/tokens/CompliantUSDC.sol"; +import {StablecoinReserveVault} from "../../contracts/reserve/StablecoinReserveVault.sol"; +import {CWMultiTokenBridgeL1} from "../../contracts/bridge/CWMultiTokenBridgeL1.sol"; +import {CWReserveVerifier} from "../../contracts/bridge/integration/CWReserveVerifier.sol"; + +contract MockRouterVaultVerifier is IRouterClient { + uint256 public fee; + bytes32 public nextMessageId = keccak256("cw-reserve-vault-message"); + + function ccipSend( + uint64, + EVM2AnyMessage memory + ) external payable returns (bytes32 messageId, uint256 fees) { + return (nextMessageId, fee); + } + + function getFee(uint64, EVM2AnyMessage memory) external view returns (uint256) { + return fee; + } + + function getSupportedTokens(uint64) external pure returns (address[] memory tokens) { + tokens = new address[](0); + } +} + +contract CWReserveVerifierVaultIntegrationTest is Test { + uint64 internal constant AVALANCHE_SELECTOR = 6433500567565415381; + + address internal user = address(0xBEEF); + address internal receiveRouter = address(0x138138); + address internal peerBridge = address(0x4311443114); + + MockRouterVaultVerifier internal router; + OfficialStableMirrorToken internal officialUsdt; + OfficialStableMirrorToken internal officialUsdc; + CompliantUSDT internal compliantUsdt; + CompliantUSDC internal compliantUsdc; + StablecoinReserveVault internal vault; + CWMultiTokenBridgeL1 internal l1Bridge; + CWReserveVerifier internal verifier; + + function setUp() public { + router = new MockRouterVaultVerifier(); + officialUsdt = new OfficialStableMirrorToken("Tether USD (Chain 138)", "USDT", 6, address(this), 0); + officialUsdc = new OfficialStableMirrorToken("USD Coin (Chain 138)", "USDC", 6, address(this), 0); + compliantUsdt = new CompliantUSDT(address(this), address(this)); + compliantUsdc = new CompliantUSDC(address(this), address(this)); + vault = new StablecoinReserveVault( + address(this), + address(officialUsdt), + address(officialUsdc), + address(compliantUsdt), + address(compliantUsdc) + ); + l1Bridge = new CWMultiTokenBridgeL1(address(router), receiveRouter, address(0)); + verifier = new CWReserveVerifier(address(this), address(l1Bridge), address(vault), address(0)); + + uint256 canonicalSupply = compliantUsdc.totalSupply(); + officialUsdc.mint(address(this), canonicalSupply); + officialUsdc.approve(address(vault), canonicalSupply); + vault.seedUSDCReserve(canonicalSupply); + compliantUsdc.transferOwnership(address(vault)); + compliantUsdc.transfer(user, 250e6); + + l1Bridge.configureSupportedCanonicalToken(address(compliantUsdc), true); + l1Bridge.configureDestination(address(compliantUsdc), AVALANCHE_SELECTOR, peerBridge, true); + l1Bridge.setReserveVerifier(address(verifier)); + + verifier.configureToken( + address(compliantUsdc), + address(0), + true, + false, + true + ); + } + + function testVerifierAllowsLockWhenRealVaultBacksExistingSupply() public { + uint256 amount = 25e6; + + vm.startPrank(user); + compliantUsdc.approve(address(l1Bridge), amount); + l1Bridge.lockAndSend(address(compliantUsdc), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + + assertEq(l1Bridge.lockedBalance(address(compliantUsdc)), amount); + assertEq(l1Bridge.totalOutstanding(address(compliantUsdc)), amount); + assertEq(l1Bridge.outstandingMinted(address(compliantUsdc), AVALANCHE_SELECTOR), amount); + } +} diff --git a/test/bridge/CWReserveVerifierVaultV2Integration.t.sol b/test/bridge/CWReserveVerifierVaultV2Integration.t.sol new file mode 100644 index 0000000..3347d3b --- /dev/null +++ b/test/bridge/CWReserveVerifierVaultV2Integration.t.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; + +import {IRouterClient} from "../../contracts/ccip/IRouterClient.sol"; +import {OfficialStableMirrorToken} from "../../contracts/tokens/OfficialStableMirrorToken.sol"; +import {CompliantUSDCTokenV2} from "../../contracts/tokens/CompliantUSDCTokenV2.sol"; +import {CompliantUSDTTokenV2} from "../../contracts/tokens/CompliantUSDTTokenV2.sol"; +import {StablecoinReserveVault} from "../../contracts/reserve/StablecoinReserveVault.sol"; +import {CWMultiTokenBridgeL1} from "../../contracts/bridge/CWMultiTokenBridgeL1.sol"; +import {CWMultiTokenBridgeL2} from "../../contracts/bridge/CWMultiTokenBridgeL2.sol"; +import {CWReserveVerifier} from "../../contracts/bridge/integration/CWReserveVerifier.sol"; +import {CompliantWrappedToken} from "../../contracts/tokens/CompliantWrappedToken.sol"; + +contract MockRouterVaultVerifierV2 is IRouterClient { + uint256 public fee; + bytes32 public nextMessageId = keccak256("cw-reserve-vault-v2-message"); + EVM2AnyMessage internal _lastMessage; + uint64 public lastDestinationChainSelector; + + function ccipSend( + uint64 destinationChainSelector, + EVM2AnyMessage memory message + ) external payable returns (bytes32 messageId, uint256 fees) { + lastDestinationChainSelector = destinationChainSelector; + _lastMessage = message; + return (nextMessageId, fee); + } + + function getFee(uint64, EVM2AnyMessage memory) external view returns (uint256) { + return fee; + } + + function getSupportedTokens(uint64) external pure returns (address[] memory tokens) { + tokens = new address[](0); + } + + function lastMessage() + external + view + returns (bytes memory receiver, bytes memory data, address feeToken, bytes memory extraArgs) + { + return (_lastMessage.receiver, _lastMessage.data, _lastMessage.feeToken, _lastMessage.extraArgs); + } +} + +contract CWReserveVerifierVaultV2IntegrationTest is Test { + uint64 internal constant AVALANCHE_SELECTOR = 6433500567565415381; + + address internal admin = address(0xABCD); + address internal user = address(0xBEEF); + address internal receiveRouterL1 = address(0x138138); + address internal receiveRouterL2 = address(0x4311443114); + address internal peerBridge = address(0x4311443114); + + MockRouterVaultVerifierV2 internal router; + OfficialStableMirrorToken internal officialUsdt; + OfficialStableMirrorToken internal officialUsdc; + CompliantUSDTTokenV2 internal compliantUsdtV2; + CompliantUSDCTokenV2 internal compliantUsdcV2; + StablecoinReserveVault internal vault; + CWMultiTokenBridgeL1 internal l1Bridge; + CWMultiTokenBridgeL2 internal l2Bridge; + CWReserveVerifier internal verifier; + CompliantWrappedToken internal wrappedUsdc; + + function setUp() public { + router = new MockRouterVaultVerifierV2(); + officialUsdt = new OfficialStableMirrorToken("Tether USD (Chain 138)", "USDT", 6, address(this), 0); + officialUsdc = new OfficialStableMirrorToken("USD Coin (Chain 138)", "USDC", 6, address(this), 0); + compliantUsdtV2 = new CompliantUSDTTokenV2(address(this), admin, 1_000_000 * 10 ** 6, true); + compliantUsdcV2 = new CompliantUSDCTokenV2(address(this), admin, 1_000_000 * 10 ** 6, true); + + vault = new StablecoinReserveVault( + admin, + address(officialUsdt), + address(officialUsdc), + address(compliantUsdtV2), + address(compliantUsdcV2) + ); + + l1Bridge = new CWMultiTokenBridgeL1(address(router), receiveRouterL1, address(0)); + l2Bridge = new CWMultiTokenBridgeL2(address(router), receiveRouterL2, address(0)); + verifier = new CWReserveVerifier(admin, address(l1Bridge), address(vault), address(0)); + wrappedUsdc = new CompliantWrappedToken("Wrapped cUSDC", "cWUSDC", 6, address(this)); + + uint256 canonicalSupply = compliantUsdcV2.totalSupply(); + officialUsdc.mint(admin, canonicalSupply); + + vm.prank(admin); + officialUsdc.approve(address(vault), canonicalSupply); + + vm.prank(admin); + vault.seedUSDCReserve(canonicalSupply); + + vm.startPrank(admin); + compliantUsdtV2.grantRole(compliantUsdtV2.MINTER_ROLE(), address(vault)); + compliantUsdtV2.grantRole(compliantUsdtV2.PAUSER_ROLE(), address(vault)); + compliantUsdtV2.transferOwnership(address(vault)); + compliantUsdcV2.grantRole(compliantUsdcV2.MINTER_ROLE(), address(vault)); + compliantUsdcV2.grantRole(compliantUsdcV2.PAUSER_ROLE(), address(vault)); + compliantUsdcV2.transferOwnership(address(vault)); + vm.stopPrank(); + + vm.prank(address(this)); + compliantUsdcV2.transfer(user, 250e6); + + l1Bridge.configureSupportedCanonicalToken(address(compliantUsdcV2), true); + l1Bridge.configureDestination(address(compliantUsdcV2), AVALANCHE_SELECTOR, address(l2Bridge), true); + l1Bridge.setReserveVerifier(address(verifier)); + l2Bridge.configureDestination(138, address(l1Bridge), true); + l2Bridge.configureTokenPair(address(compliantUsdcV2), address(wrappedUsdc)); + wrappedUsdc.grantRole(wrappedUsdc.MINTER_ROLE(), address(l2Bridge)); + wrappedUsdc.grantRole(wrappedUsdc.BURNER_ROLE(), address(l2Bridge)); + + vm.prank(admin); + verifier.configureToken( + address(compliantUsdcV2), + address(0), + true, + false, + true + ); + } + + function testVerifierAllowsLockForV2CanonicalTokenBackedByVault() public { + uint256 amount = 25e6; + + vm.startPrank(user); + compliantUsdcV2.approve(address(l1Bridge), amount); + l1Bridge.lockAndSend(address(compliantUsdcV2), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + + assertEq(l1Bridge.lockedBalance(address(compliantUsdcV2)), amount); + assertEq(l1Bridge.totalOutstanding(address(compliantUsdcV2)), amount); + assertEq(l1Bridge.outstandingMinted(address(compliantUsdcV2), AVALANCHE_SELECTOR), amount); + } + + function testV2CanonicalTokenCompletesFullTransportRoundTrip() public { + uint256 amount = 25e6; + + vm.startPrank(user); + compliantUsdcV2.approve(address(l1Bridge), amount); + bytes32 outboundMessageId = + l1Bridge.lockAndSend(address(compliantUsdcV2), AVALANCHE_SELECTOR, user, amount); + vm.stopPrank(); + + (bytes memory receiverData, bytes memory outboundData,,) = router.lastMessage(); + assertEq(abi.decode(receiverData, (address)), address(l2Bridge)); + + vm.prank(receiveRouterL2); + l2Bridge.ccipReceive(_message(outboundMessageId, 138, address(l1Bridge), outboundData)); + + assertEq(wrappedUsdc.balanceOf(user), amount); + assertEq(wrappedUsdc.totalSupply(), amount); + assertEq(l2Bridge.mintedTotal(address(wrappedUsdc)), amount); + assertEq(l2Bridge.burnedTotal(address(wrappedUsdc)), 0); + + vm.prank(user); + bytes32 returnMessageId = l2Bridge.burnAndSend(address(wrappedUsdc), 138, user, amount); + + assertEq(wrappedUsdc.balanceOf(user), 0); + assertEq(wrappedUsdc.totalSupply(), 0); + assertEq(l2Bridge.mintedTotal(address(wrappedUsdc)), amount); + assertEq(l2Bridge.burnedTotal(address(wrappedUsdc)), amount); + + (, bytes memory returnData,,) = router.lastMessage(); + + vm.prank(receiveRouterL1); + l1Bridge.ccipReceive(_message(returnMessageId, AVALANCHE_SELECTOR, address(l2Bridge), returnData)); + + assertEq(compliantUsdcV2.balanceOf(user), 250e6); + assertEq(compliantUsdcV2.balanceOf(address(l1Bridge)), 0); + assertEq(l1Bridge.lockedBalance(address(compliantUsdcV2)), 0); + assertEq(l1Bridge.totalOutstanding(address(compliantUsdcV2)), 0); + assertEq(l1Bridge.outstandingMinted(address(compliantUsdcV2), AVALANCHE_SELECTOR), 0); + } + + function _message( + bytes32 messageId, + uint64 sourceChainSelector, + address sender, + bytes memory data + ) internal pure returns (IRouterClient.Any2EVMMessage memory message) { + IRouterClient.TokenAmount[] memory noTokens = new IRouterClient.TokenAmount[](0); + message = IRouterClient.Any2EVMMessage({ + messageId: messageId, + sourceChainSelector: sourceChainSelector, + sender: abi.encode(sender), + data: data, + tokenAmounts: noTokens + }); + } +} diff --git a/test/bridge/USDWPublicWrapVault.t.sol b/test/bridge/USDWPublicWrapVault.t.sol new file mode 100644 index 0000000..8deb5c7 --- /dev/null +++ b/test/bridge/USDWPublicWrapVault.t.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {USDWPublicWrapVault} from "../../contracts/bridge/integration/USDWPublicWrapVault.sol"; +import {CompliantWrappedToken} from "../../contracts/tokens/CompliantWrappedToken.sol"; + +contract MockNativeUSDW is ERC20 { + uint8 private immutable _decimalsValue; + + constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) { + _decimalsValue = decimals_; + } + + function decimals() public view override returns (uint8) { + return _decimalsValue; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract USDWPublicWrapVaultTest is Test { + address internal admin = address(0xA11CE); + address internal operator = address(0xB0B); + address internal user = address(0xCAFE); + address internal bridgeUser = address(0xD00D); + + MockNativeUSDW internal nativeUsdw; + MockNativeUSDW internal strayToken; + CompliantWrappedToken internal wrappedUsdw; + USDWPublicWrapVault internal vault; + + function setUp() public { + vm.startPrank(admin); + nativeUsdw = new MockNativeUSDW("USD DWIN", "USDW", 18); + strayToken = new MockNativeUSDW("Stray", "STRAY", 18); + wrappedUsdw = new CompliantWrappedToken("Wrapped cUSDW", "cWUSDW", 6, admin); + vault = new USDWPublicWrapVault(admin, address(nativeUsdw), address(wrappedUsdw)); + vault.grantRole(vault.RESERVE_OPERATOR_ROLE(), operator); + wrappedUsdw.grantRole(wrappedUsdw.MINTER_ROLE(), address(vault)); + wrappedUsdw.grantRole(wrappedUsdw.BURNER_ROLE(), address(vault)); + vm.stopPrank(); + + nativeUsdw.mint(user, 100e18); + nativeUsdw.mint(operator, 100e18); + strayToken.mint(address(vault), 5e18); + } + + function testWrapNormalizesNativeAmountToWrappedDecimals() public { + vm.startPrank(user); + nativeUsdw.approve(address(vault), 5e18); + uint256 wrappedAmount = vault.wrap(5e18, user); + vm.stopPrank(); + + assertEq(wrappedAmount, 5e6); + assertEq(wrappedUsdw.balanceOf(user), 5e6); + assertEq(nativeUsdw.balanceOf(address(vault)), 5e18); + assertEq(nativeUsdw.balanceOf(user), 95e18); + } + + function testUnwrapBurnsWrappedAndReleasesNativeUsd() public { + vm.startPrank(user); + nativeUsdw.approve(address(vault), 7e18); + vault.wrap(7e18, user); + uint256 unwrapped = vault.unwrap(2e6, user); + vm.stopPrank(); + + assertEq(unwrapped, 2e18); + assertEq(wrappedUsdw.balanceOf(user), 5e6); + assertEq(nativeUsdw.balanceOf(user), 95e18); + assertEq(nativeUsdw.balanceOf(address(vault)), 5e18); + } + + function testBridgeMintedSupplyCanUnwrapAgainstSeededLiquidity() public { + vm.prank(operator); + nativeUsdw.approve(address(vault), 20e18); + vm.prank(operator); + vault.seedLiquidity(20e18); + + vm.prank(admin); + wrappedUsdw.mint(bridgeUser, 3e6); + + vm.prank(bridgeUser); + uint256 released = vault.unwrap(3e6, bridgeUser); + + assertEq(released, 3e18); + assertEq(nativeUsdw.balanceOf(bridgeUser), 3e18); + assertEq(nativeUsdw.balanceOf(address(vault)), 17e18); + assertEq(wrappedUsdw.balanceOf(bridgeUser), 0); + } + + function testWrapRejectsNonCanonicalNativeAmount() public { + vm.startPrank(user); + nativeUsdw.approve(address(vault), 1e18 + 1); + vm.expectRevert(abi.encodeWithSelector(USDWPublicWrapVault.NonCanonicalAmount.selector, 1e18 + 1)); + vault.wrap(1e18 + 1, user); + vm.stopPrank(); + } + + function testPauseBlocksWrapAndUnwrap() public { + vm.prank(admin); + vault.pause(); + + vm.startPrank(user); + nativeUsdw.approve(address(vault), 1e18); + vm.expectRevert(); + vault.wrap(1e18, user); + vm.stopPrank(); + } + + function testRecoverNonUnderlyingTokenButProtectNativeReserve() public { + vm.prank(admin); + vault.recoverNonUnderlyingToken(address(strayToken), admin, 5e18); + assertEq(strayToken.balanceOf(admin), 5e18); + + vm.prank(admin); + vm.expectRevert(USDWPublicWrapVault.UnderlyingTokenProtected.selector); + vault.recoverNonUnderlyingToken(address(nativeUsdw), admin, 1); + } +} diff --git a/test/bridge/atomic/AtomicBridgeCoordinator.t.sol b/test/bridge/atomic/AtomicBridgeCoordinator.t.sol new file mode 100644 index 0000000..52fccc1 --- /dev/null +++ b/test/bridge/atomic/AtomicBridgeCoordinator.t.sol @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; + +import "../../../contracts/bridge/atomic/AtomicBridgeCoordinator.sol"; +import "../../../contracts/bridge/atomic/AtomicFeePolicy.sol"; +import "../../../contracts/bridge/atomic/AtomicFulfillerRegistry.sol"; +import "../../../contracts/bridge/atomic/AtomicLiquidityVault.sol"; +import "../../../contracts/bridge/atomic/AtomicObligationEscrow.sol"; +import "../../../contracts/bridge/atomic/AtomicQuoteEngine.sol"; +import "../../../contracts/bridge/atomic/AtomicSettlementRouter.sol"; +import "../../../contracts/bridge/atomic/AtomicSlashingManager.sol"; +import "../../../contracts/bridge/atomic/AtomicTypes.sol"; +import "../../../contracts/bridge/atomic/interfaces/IAtomicSettlementAdapter.sol"; +import "../../dbis/MockMintableToken.sol"; + +contract MockAtomicSettlementAdapter is IAtomicSettlementAdapter { + address public lastToken; + uint256 public lastAmount; + address public lastRecipient; + bytes public lastData; + + function executeSettlement( + bytes32 obligationId, + address token, + uint256 amount, + address recipient, + bytes calldata data + ) external payable returns (bytes32 settlementId) { + lastToken = token; + lastAmount = amount; + lastRecipient = recipient; + lastData = data; + settlementId = keccak256(abi.encode(obligationId, token, amount, recipient, data, block.timestamp)); + } +} + +contract AtomicBridgeCoordinatorTest is Test { + bytes32 internal constant MOCK_SETTLEMENT_MODE = keccak256("MOCK_SETTLEMENT_MODE"); + + MockMintableToken internal sourceToken; + MockMintableToken internal destinationToken; + MockMintableToken internal bondToken; + + AtomicLiquidityVault internal vault; + AtomicFulfillerRegistry internal registry; + AtomicFeePolicy internal feePolicy; + AtomicObligationEscrow internal escrow; + AtomicSettlementRouter internal router; + AtomicSlashingManager internal slashingManager; + AtomicBridgeCoordinator internal coordinator; + AtomicQuoteEngine internal quoteEngine; + MockAtomicSettlementAdapter internal settlementAdapter; + + address internal user = address(0x1111); + address internal fulfiller = address(0x2222); + address internal protocolTreasury = address(0x3333); + bytes32 internal corridorId; + + function setUp() public { + sourceToken = new MockMintableToken("Chain 138 USD", "cUSDC", 6, address(this)); + destinationToken = new MockMintableToken("Mainnet Wrapped USD", "cWUSDC", 6, address(this)); + bondToken = new MockMintableToken("Bond USDC", "bUSDC", 6, address(this)); + + vault = new AtomicLiquidityVault(address(this)); + registry = new AtomicFulfillerRegistry(address(bondToken), address(this)); + feePolicy = new AtomicFeePolicy(address(this)); + escrow = new AtomicObligationEscrow(address(this)); + router = new AtomicSettlementRouter(address(this)); + slashingManager = new AtomicSlashingManager(address(registry), address(this)); + coordinator = new AtomicBridgeCoordinator( + address(vault), + address(registry), + address(escrow), + address(router), + address(feePolicy), + address(slashingManager), + protocolTreasury, + address(this) + ); + quoteEngine = new AtomicQuoteEngine( + address(coordinator), + address(vault), + address(registry), + address(feePolicy) + ); + settlementAdapter = new MockAtomicSettlementAdapter(); + + vault.grantRole(vault.COORDINATOR_ROLE(), address(coordinator)); + vault.grantRole(vault.RECONCILER_ROLE(), address(coordinator)); + registry.grantRole(registry.COORDINATOR_ROLE(), address(coordinator)); + registry.grantRole(registry.SLASHER_ROLE(), address(slashingManager)); + escrow.grantRole(escrow.COORDINATOR_ROLE(), address(coordinator)); + router.grantRole(router.COORDINATOR_ROLE(), address(coordinator)); + slashingManager.grantRole(slashingManager.COORDINATOR_ROLE(), address(coordinator)); + router.setAdapter(MOCK_SETTLEMENT_MODE, address(settlementAdapter)); + + corridorId = coordinator.getCorridorId(138, 1, address(sourceToken), address(destinationToken)); + coordinator.configureCorridor( + AtomicTypes.CorridorConfig({ + enabled: true, + degraded: false, + sourceChain: 138, + destinationChain: 1, + assetIn: address(sourceToken), + assetOut: address(destinationToken), + maxNotional: 500_000e6, + maxReservedBps: 5_000, + targetBuffer: 10_000e6, + maxSettlementBacklog: 250_000e6, + maxOracleDriftBps: 500, + fulfilmentTimeout: 1 days, + settlementTimeout: 2 days, + defaultSettlementMode: MOCK_SETTLEMENT_MODE + }) + ); + feePolicy.setCorridorPolicy(corridorId, 100, 50, 12_000, 1_000, 1 days, 2 days); + vault.setTargetBuffer(corridorId, address(destinationToken), 10_000e6); + + registry.setFulfillerActive(fulfiller, true); + registry.setCorridorAuthorization(fulfiller, corridorId, true); + + destinationToken.mint(address(this), 100_000e6); + destinationToken.approve(address(vault), type(uint256).max); + vault.fundCorridor(corridorId, address(destinationToken), 100_000e6); + + bondToken.mint(fulfiller, 100_000e6); + vm.startPrank(fulfiller); + bondToken.approve(address(registry), type(uint256).max); + registry.depositBond(50_000e6); + vm.stopPrank(); + + sourceToken.mint(user, 50_000e6); + vm.prank(user); + sourceToken.approve(address(escrow), type(uint256).max); + } + + function testCreateIntentReservesDestinationLiquidity() public { + bytes32 obligationId = _createIntent(1_000e6, 900e6, block.timestamp + 1 hours); + AtomicTypes.AtomicObligation memory obligation = coordinator.getObligation(obligationId); + assertEq(uint8(obligation.status), uint8(AtomicTypes.ObligationStatus.IntentCreated)); + assertEq(obligation.sourceEscrow, 1_000e6); + assertEq(obligation.destinationReserve, 900e6); + + AtomicTypes.CorridorLiquidityState memory state = + vault.getCorridorLiquidityState(corridorId, address(destinationToken)); + assertEq(state.totalLiquidity, 100_000e6); + assertEq(state.reservedLiquidity, 900e6); + assertEq(state.freeLiquidity, 99_100e6); + } + + function testQuoteReflectsExecutionReadyPath() public { + AtomicTypes.AtomicQuote memory q = quoteEngine.quote(corridorId, 1_000e6, 900e6, fulfiller); + assertEq(uint8(q.routeClass), uint8(AtomicTypes.RouteClass.ExecutionReady)); + assertTrue(q.fulfillerAuthorized); + assertTrue(q.fulfillerBondSufficient); + assertEq(q.protocolFee, 5e6); + assertEq(q.fulfillerFee, 10e6); + assertEq(q.requiredBond, 1_080e6); + } + + function testCommitFailsWhenBondIsInsufficient() public { + address weakFulfiller = address(0x4444); + registry.setFulfillerActive(weakFulfiller, true); + registry.setCorridorAuthorization(weakFulfiller, corridorId, true); + bondToken.mint(weakFulfiller, 100e6); + vm.startPrank(weakFulfiller); + bondToken.approve(address(registry), type(uint256).max); + registry.depositBond(100e6); + vm.stopPrank(); + + bytes32 obligationId = _createIntent(1_000e6, 900e6, block.timestamp + 1 hours); + vm.prank(weakFulfiller); + vm.expectRevert(AtomicFulfillerRegistry.InsufficientAvailableBond.selector); + coordinator.submitCommitment(obligationId, bytes32(0)); + } + + function testSuccessfulSettlementReleasesBondAndReplenishesLiquidity() public { + bytes32 obligationId = _createIntent(1_000e6, 900e6, block.timestamp + 1 hours); + + vm.prank(fulfiller); + coordinator.submitCommitment(obligationId, bytes32(0)); + + assertEq(destinationToken.balanceOf(user), 900e6); + AtomicTypes.CorridorLiquidityState memory postFulfill = + vault.getCorridorLiquidityState(corridorId, address(destinationToken)); + assertEq(postFulfill.totalLiquidity, 99_100e6); + assertEq(postFulfill.settlementBacklog, 900e6); + + bytes memory settlementData = abi.encodePacked(bytes32(uint256(123))); + bytes32 settlementId = coordinator.initiateSettlement(obligationId, settlementData); + assertTrue(settlementId != bytes32(0)); + assertEq(settlementAdapter.lastToken(), address(sourceToken)); + assertEq(settlementAdapter.lastAmount(), 985e6); + assertEq(settlementAdapter.lastRecipient(), user); + assertEq(sourceToken.balanceOf(protocolTreasury), 5e6); + assertEq(sourceToken.balanceOf(fulfiller), 10e6); + + destinationToken.mint(address(this), 900e6); + destinationToken.approve(address(vault), 900e6); + coordinator.confirmSettlement(obligationId, 900e6); + + AtomicTypes.AtomicObligation memory obligation = coordinator.getObligation(obligationId); + assertEq(uint8(obligation.status), uint8(AtomicTypes.ObligationStatus.Settled)); + assertEq(registry.lockedBond(fulfiller), 0); + + AtomicTypes.CorridorLiquidityState memory postSettlement = + vault.getCorridorLiquidityState(corridorId, address(destinationToken)); + assertEq(postSettlement.totalLiquidity, 100_000e6); + assertEq(postSettlement.settlementBacklog, 0); + } + + function testExpiredIntentRefundsAndReleasesReservation() public { + uint256 payerBalanceBefore = sourceToken.balanceOf(user); + bytes32 obligationId = _createIntent(1_000e6, 900e6, block.timestamp + 30 minutes); + + vm.warp(block.timestamp + 31 minutes); + coordinator.refundExpiredIntent(obligationId); + + AtomicTypes.AtomicObligation memory obligation = coordinator.getObligation(obligationId); + assertEq(uint8(obligation.status), uint8(AtomicTypes.ObligationStatus.Refunded)); + assertEq(sourceToken.balanceOf(user), payerBalanceBefore); + + AtomicTypes.CorridorLiquidityState memory state = + vault.getCorridorLiquidityState(corridorId, address(destinationToken)); + assertEq(state.reservedLiquidity, 0); + } + + function testSettlementTimeoutSlashesBondAndDegradesCorridor() public { + bytes32 obligationId = _createIntent(1_000e6, 900e6, block.timestamp + 1 hours); + vm.prank(fulfiller); + coordinator.submitCommitment(obligationId, bytes32(0)); + coordinator.initiateSettlement(obligationId, abi.encodePacked(bytes32(uint256(456)))); + + uint256 treasuryBondBefore = bondToken.balanceOf(protocolTreasury); + vm.warp(block.timestamp + 3 days); + coordinator.handleSettlementTimeout(obligationId); + + AtomicTypes.AtomicObligation memory obligation = coordinator.getObligation(obligationId); + assertEq(uint8(obligation.status), uint8(AtomicTypes.ObligationStatus.Slashed)); + AtomicTypes.CorridorConfig memory cfg = coordinator.getCorridorConfig(corridorId); + assertTrue(cfg.degraded); + assertEq(bondToken.balanceOf(protocolTreasury), treasuryBondBefore + 1_080e6); + } + + function testConcurrentReservationsDoNotOverReserveLiquidity() public { + _createIntent(10_000e6, 40_000e6, block.timestamp + 1 hours); + _createIntent(10_000e6, 10_000e6, block.timestamp + 1 hours); + + vm.expectRevert(AtomicBridgeCoordinator.ReservedLiquidityLimitExceeded.selector); + _createIntent(10_000e6, 5_000e6, block.timestamp + 1 hours); + } + + function _createIntent(uint256 amountIn, uint256 minAmountOut, uint256 deadline) internal returns (bytes32) { + vm.prank(user); + return coordinator.createIntent( + AtomicBridgeCoordinator.CreateIntentParams({ + sourceChain: 138, + destinationChain: 1, + assetIn: address(sourceToken), + assetOut: address(destinationToken), + amountIn: amountIn, + minAmountOut: minAmountOut, + recipient: user, + deadline: deadline, + routeId: corridorId + }) + ); + } +} diff --git a/test/bridge/trustless/Chain138PilotDexVenues.t.sol b/test/bridge/trustless/Chain138PilotDexVenues.t.sol new file mode 100644 index 0000000..d202f48 --- /dev/null +++ b/test/bridge/trustless/Chain138PilotDexVenues.t.sol @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Test} from "forge-std/Test.sol"; +import "../../../contracts/bridge/trustless/EnhancedSwapRouterV2.sol"; +import "../../../contracts/bridge/trustless/RouteTypesV2.sol"; +import "../../../contracts/bridge/trustless/adapters/UniswapV3RouteExecutorAdapter.sol"; +import "../../../contracts/bridge/trustless/adapters/BalancerRouteExecutorAdapter.sol"; +import "../../../contracts/bridge/trustless/adapters/CurveRouteExecutorAdapter.sol"; +import "../../../contracts/bridge/trustless/adapters/OneInchRouteExecutorAdapter.sol"; +import "../../../contracts/bridge/trustless/pilot/Chain138PilotDexVenues.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract PilotVenueToken is ERC20 { + constructor(string memory name_, string memory symbol_, uint256 supply) ERC20(name_, symbol_) { + _mint(msg.sender, supply); + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract Chain138PilotDexVenuesTest is Test { + PilotVenueToken internal weth; + PilotVenueToken internal usdt; + PilotVenueToken internal usdc; + PilotVenueToken internal dai; + + Chain138PilotUniswapV3Router internal uniswapRouter; + Chain138PilotBalancerVault internal balancerVault; + Chain138PilotCurve3Pool internal curvePool; + Chain138PilotOneInchAggregationRouter internal oneInchRouter; + EnhancedSwapRouterV2 internal router; + + address internal user = address(0x1234); + bytes32 internal constant BALANCER_POOL_ID = keccak256("chain138-pilot-balancer-weth-usdc"); + + function setUp() public { + weth = new PilotVenueToken("Wrapped Ether", "WETH", 10_000 ether); + usdt = new PilotVenueToken("Tether", "USDT", 50_000_000 * 1e6); + usdc = new PilotVenueToken("USD Coin", "USDC", 50_000_000 * 1e6); + dai = new PilotVenueToken("Dai", "DAI", 10_000_000 ether); + + uniswapRouter = new Chain138PilotUniswapV3Router(); + balancerVault = new Chain138PilotBalancerVault(); + curvePool = new Chain138PilotCurve3Pool(address(usdt), address(usdc), address(0), 4); + oneInchRouter = new Chain138PilotOneInchAggregationRouter(); + + router = new EnhancedSwapRouterV2(address(weth), address(usdt), address(usdc), address(dai)); + router.setProviderAdapter(RouteTypesV2.Provider.UniswapV3, address(new UniswapV3RouteExecutorAdapter())); + router.setProviderAdapter(RouteTypesV2.Provider.Balancer, address(new BalancerRouteExecutorAdapter())); + router.setProviderAdapter(RouteTypesV2.Provider.Curve, address(new CurveRouteExecutorAdapter())); + router.setProviderAdapter(RouteTypesV2.Provider.OneInch, address(new OneInchRouteExecutorAdapter())); + router.setProviderEnabled(RouteTypesV2.Provider.OneInch, true); + + weth.approve(address(uniswapRouter), type(uint256).max); + usdt.approve(address(uniswapRouter), type(uint256).max); + usdc.approve(address(uniswapRouter), type(uint256).max); + weth.approve(address(balancerVault), type(uint256).max); + usdt.approve(address(balancerVault), type(uint256).max); + usdc.approve(address(balancerVault), type(uint256).max); + usdt.approve(address(curvePool), type(uint256).max); + usdc.approve(address(curvePool), type(uint256).max); + weth.approve(address(oneInchRouter), type(uint256).max); + usdt.approve(address(oneInchRouter), type(uint256).max); + usdc.approve(address(oneInchRouter), type(uint256).max); + + uniswapRouter.seedPair(address(weth), address(usdt), 3000, 100 ether, 210_000 * 1e6); + uniswapRouter.seedPair(address(weth), address(usdc), 3000, 100 ether, 210_000 * 1e6); + balancerVault.seedPool(BALANCER_POOL_ID, address(weth), address(usdc), 100 ether, 210_000 * 1e6, 30); + curvePool.fund(500_000 * 1e6, 500_000 * 1e6, 0); + oneInchRouter.seedRoute(address(weth), address(usdt), 100 ether, 210_000 * 1e6, 35); + + router.setProviderRoute( + address(weth), + address(usdt), + RouteTypesV2.Provider.UniswapV3, + address(uniswapRouter), + abi.encode(bytes(""), uint24(3000), address(uniswapRouter), false), + true + ); + router.setProviderRoute( + address(weth), + address(usdc), + RouteTypesV2.Provider.Balancer, + address(balancerVault), + abi.encode(BALANCER_POOL_ID), + true + ); + router.setProviderRoute( + address(usdt), + address(usdc), + RouteTypesV2.Provider.Curve, + address(curvePool), + abi.encode(int128(0), int128(1), false), + true + ); + router.setProviderRoute( + address(weth), + address(usdt), + RouteTypesV2.Provider.OneInch, + address(oneInchRouter), + abi.encode(address(oneInchRouter), address(oneInchRouter), bytes("")), + true + ); + + weth.mint(user, 10 ether); + usdt.mint(user, 50_000 * 1e6); + } + + function testUniswapPilotQuotesAndExecutes() public { + uint256 quote = uniswapRouter.quoteExactInputSingle(address(weth), address(usdt), 3000, 1 ether, 0); + assertGt(quote, 0); + + vm.startPrank(user); + weth.approve(address(router), 1 ether); + RouteTypesV2.RouteLeg[] memory legs = new RouteTypesV2.RouteLeg[](1); + legs[0] = RouteTypesV2.RouteLeg({ + provider: RouteTypesV2.Provider.UniswapV3, + tokenIn: address(weth), + tokenOut: address(usdt), + amountSource: RouteTypesV2.AmountSource.UserInput, + minAmountOut: quote - 1, + target: address(uniswapRouter), + providerData: abi.encode(bytes(""), uint24(3000), address(uniswapRouter), false) + }); + RouteTypesV2.RoutePlan memory plan = RouteTypesV2.RoutePlan({ + chainId: block.chainid, + inputToken: address(weth), + outputToken: address(usdt), + amountIn: 1 ether, + minAmountOut: quote - 1, + recipient: user, + deadline: block.timestamp + 300, + legs: legs + }); + uint256 amountOut = router.executeRoute(plan); + vm.stopPrank(); + + assertEq(amountOut, quote); + } + + function testBalancerPilotExecutes() public { + vm.startPrank(user); + weth.approve(address(router), 1 ether); + RouteTypesV2.RouteLeg[] memory legs = new RouteTypesV2.RouteLeg[](1); + legs[0] = RouteTypesV2.RouteLeg({ + provider: RouteTypesV2.Provider.Balancer, + tokenIn: address(weth), + tokenOut: address(usdc), + amountSource: RouteTypesV2.AmountSource.UserInput, + minAmountOut: 1_000 * 1e6, + target: address(balancerVault), + providerData: abi.encode(BALANCER_POOL_ID) + }); + RouteTypesV2.RoutePlan memory plan = RouteTypesV2.RoutePlan({ + chainId: block.chainid, + inputToken: address(weth), + outputToken: address(usdc), + amountIn: 1 ether, + minAmountOut: 1_000 * 1e6, + recipient: user, + deadline: block.timestamp + 300, + legs: legs + }); + uint256 amountOut = router.executeRoute(plan); + vm.stopPrank(); + + assertGt(amountOut, 0); + } + + function testCurvePilotExecutes() public { + vm.startPrank(user); + usdt.approve(address(router), 10_000 * 1e6); + RouteTypesV2.RouteLeg[] memory legs = new RouteTypesV2.RouteLeg[](1); + legs[0] = RouteTypesV2.RouteLeg({ + provider: RouteTypesV2.Provider.Curve, + tokenIn: address(usdt), + tokenOut: address(usdc), + amountSource: RouteTypesV2.AmountSource.UserInput, + minAmountOut: 9_900 * 1e6, + target: address(curvePool), + providerData: abi.encode(int128(0), int128(1), false) + }); + RouteTypesV2.RoutePlan memory plan = RouteTypesV2.RoutePlan({ + chainId: block.chainid, + inputToken: address(usdt), + outputToken: address(usdc), + amountIn: 10_000 * 1e6, + minAmountOut: 9_900 * 1e6, + recipient: user, + deadline: block.timestamp + 300, + legs: legs + }); + uint256 amountOut = router.executeRoute(plan); + vm.stopPrank(); + + assertGt(amountOut, 0); + } + + function testOneInchPilotExecutes() public { + vm.startPrank(user); + weth.approve(address(router), 1 ether); + RouteTypesV2.RouteLeg[] memory legs = new RouteTypesV2.RouteLeg[](1); + legs[0] = RouteTypesV2.RouteLeg({ + provider: RouteTypesV2.Provider.OneInch, + tokenIn: address(weth), + tokenOut: address(usdt), + amountSource: RouteTypesV2.AmountSource.UserInput, + minAmountOut: 1_000 * 1e6, + target: address(oneInchRouter), + providerData: abi.encode(address(oneInchRouter), address(oneInchRouter), bytes("")) + }); + RouteTypesV2.RoutePlan memory plan = RouteTypesV2.RoutePlan({ + chainId: block.chainid, + inputToken: address(weth), + outputToken: address(usdt), + amountIn: 1 ether, + minAmountOut: 1_000 * 1e6, + recipient: user, + deadline: block.timestamp + 300, + legs: legs + }); + uint256 amountOut = router.executeRoute(plan); + vm.stopPrank(); + + assertGt(amountOut, 0); + } +} diff --git a/test/bridge/trustless/EnhancedSwapRouterV2.t.sol b/test/bridge/trustless/EnhancedSwapRouterV2.t.sol new file mode 100644 index 0000000..19d2287 --- /dev/null +++ b/test/bridge/trustless/EnhancedSwapRouterV2.t.sol @@ -0,0 +1,686 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Test} from "forge-std/Test.sol"; +import "../../../contracts/bridge/trustless/EnhancedSwapRouterV2.sol"; +import "../../../contracts/bridge/trustless/IntentBridgeCoordinatorV2.sol"; +import "../../../contracts/bridge/trustless/RouteTypesV2.sol"; +import "../../../contracts/bridge/trustless/adapters/DodoRouteExecutorAdapter.sol"; +import "../../../contracts/bridge/trustless/adapters/DodoV3RouteExecutorAdapter.sol"; +import "../../../contracts/bridge/trustless/adapters/UniswapV3RouteExecutorAdapter.sol"; +import "../../../contracts/bridge/trustless/adapters/BalancerRouteExecutorAdapter.sol"; +import "../../../contracts/bridge/trustless/adapters/CurveRouteExecutorAdapter.sol"; +import "../../../contracts/bridge/trustless/interfaces/IBalancerVault.sol"; +import "../../../contracts/bridge/trustless/interfaces/IBridgeIntentExecutor.sol"; +import "../../../contracts/bridge/trustless/interfaces/ISwapRouter.sol"; +import "../../../contracts/liquidity/interfaces/ILiquidityProvider.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract MockERC20V2 is ERC20 { + constructor(string memory name_, string memory symbol_, uint256 supply) ERC20(name_, symbol_) { + _mint(msg.sender, supply); + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract MockDodoProviderV2 is ILiquidityProvider { + mapping(address => mapping(address => bool)) public supported; + mapping(address => mapping(address => uint256)) public quoteAmount; + + function setSupport(address tokenIn, address tokenOut, bool isSupported) external { + supported[tokenIn][tokenOut] = isSupported; + } + + function setQuote(address tokenIn, address tokenOut, uint256 amountOut) external { + quoteAmount[tokenIn][tokenOut] = amountOut; + } + + function getQuote(address tokenIn, address tokenOut, uint256) external view returns (uint256 amountOut, uint256 slippageBps) { + if (!supported[tokenIn][tokenOut]) return (0, 10000); + return (quoteAmount[tokenIn][tokenOut], 30); + } + + function executeSwap(address tokenIn, address tokenOut, uint256 amountIn, uint256) external returns (uint256 amountOut) { + require(supported[tokenIn][tokenOut], "unsupported"); + amountOut = quoteAmount[tokenIn][tokenOut]; + ERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); + ERC20(tokenOut).transfer(msg.sender, amountOut); + } + + function supportsTokenPair(address tokenIn, address tokenOut) external view returns (bool) { + return supported[tokenIn][tokenOut]; + } + + function providerName() external pure returns (string memory) { + return "MockDodo"; + } + + function estimateGas(address, address, uint256) external pure returns (uint256) { + return 140000; + } +} + +contract MockUniswapQuoterV2 { + mapping(bytes32 => uint256) public quotes; + + function setQuote(address tokenIn, address tokenOut, uint24 fee, uint256 amountOut) external { + quotes[keccak256(abi.encode(tokenIn, tokenOut, fee))] = amountOut; + } + + function quoteExactInputSingle( + address tokenIn, + address tokenOut, + uint24 fee, + uint256, + uint160 + ) external view returns (uint256) { + return quotes[keccak256(abi.encode(tokenIn, tokenOut, fee))]; + } + + function quoteExactInput(bytes calldata, uint256) external pure returns (uint256) { + return 0; + } +} + +contract MockUniswapRouterV2 { + mapping(bytes32 => uint256) public quotes; + + function setQuote(address tokenIn, address tokenOut, uint24 fee, uint256 amountOut) external { + quotes[keccak256(abi.encode(tokenIn, tokenOut, fee))] = amountOut; + } + + function exactInputSingle( + ISwapRouter.ExactInputSingleParams calldata params + ) external returns (uint256 amountOut) { + amountOut = quotes[keccak256(abi.encode(params.tokenIn, params.tokenOut, params.fee))]; + ERC20(params.tokenIn).transferFrom(msg.sender, address(this), params.amountIn); + ERC20(params.tokenOut).transfer(params.recipient, amountOut); + } + + function exactInput( + ISwapRouter.ExactInputParams calldata + ) external pure returns (uint256) { + revert("path unsupported in mock"); + } +} + +contract MockBalancerVaultV2 { + bytes32 public poolId; + address[] public tokens; + uint256[] public balances; + + function setPool(bytes32 newPoolId, address tokenA, address tokenB, uint256 balanceA, uint256 balanceB) external { + poolId = newPoolId; + tokens = [tokenA, tokenB]; + balances = [balanceA, balanceB]; + } + + function swap( + IBalancerVault.SingleSwap memory singleSwap, + IBalancerVault.FundManagement memory funds, + uint256, + uint256 + ) external returns (uint256 amountCalculated) { + require(singleSwap.poolId == poolId, "bad pool"); + ERC20(singleSwap.assetIn).transferFrom(msg.sender, address(this), singleSwap.amount); + amountCalculated = (singleSwap.amount * balances[1]) / balances[0]; + amountCalculated = (amountCalculated * 9950) / 10000; + ERC20(singleSwap.assetOut).transfer(funds.recipient, amountCalculated); + } + + function getPool(bytes32) external pure returns (address poolAddress, uint8 specialization) { + return (address(0xBEEF), 0); + } + + function queryBatchSwap( + IBalancerVault.SwapKind, + IBalancerVault.SingleSwap[] memory, + address[] memory + ) external pure returns (int256[] memory assetDeltas) { + assetDeltas = new int256[](0); + } + + function getPoolTokens(bytes32 requestedPoolId) + external + view + returns ( + address[] memory, + uint256[] memory, + uint256 + ) + { + require(requestedPoolId == poolId, "bad pool"); + return (tokens, balances, block.number); + } +} + +contract MockCurvePoolV2 { + mapping(bytes32 => uint256) public quotes; + + function setQuote(int128 i, int128 j, uint256 amountOut) external { + quotes[keccak256(abi.encode(i, j))] = amountOut; + } + + function exchange(int128 i, int128 j, uint256 dx, uint256) external returns (uint256) { + uint256 amountOut = quotes[keccak256(abi.encode(i, j))]; + dx; + return amountOut; + } + + function exchange_underlying(int128 i, int128 j, uint256 dx, uint256) external returns (uint256) { + uint256 amountOut = quotes[keccak256(abi.encode(i, j))]; + dx; + return amountOut; + } + + function get_dy(int128 i, int128 j, uint256) external view returns (uint256) { + return quotes[keccak256(abi.encode(i, j))]; + } + + function coins(uint256) external pure returns (address) { + return address(0); + } +} + +contract MockBridgeIntentExecutorV2 is IBridgeIntentExecutor { + event Bridged(bytes32 indexed bridgeType, address indexed token, uint256 amount, address recipient); + + function validateBridge( + bytes32, + bytes calldata, + address token, + uint256 amount, + address recipient + ) external pure returns (bool ok, string memory reason) { + if (token == address(0) || amount == 0 || recipient == address(0)) { + return (false, "invalid bridge request"); + } + return (true, ""); + } + + function executeBridge( + bytes32 bridgeType, + bytes calldata, + address token, + uint256 amount, + address recipient + ) external payable returns (bytes32 referenceId) { + ERC20(token).transferFrom(msg.sender, address(this), amount); + emit Bridged(bridgeType, token, amount, recipient); + return keccak256(abi.encode(bridgeType, token, amount, recipient)); + } +} + +contract MockD3ApproveV2 { + function claimTokens(address token, address who, address dest, uint256 amount) external { + IERC20(token).transferFrom(who, dest, amount); + } +} + +contract MockD3ApproveProxyV2 { + address public immutable _DODO_APPROVE_; + mapping(address => bool) public allowedProxy; + + constructor(address approve) { + _DODO_APPROVE_ = approve; + } + + function setAllowedProxy(address proxy, bool allowed) external { + allowedProxy[proxy] = allowed; + } + + function claimTokens(address token, address who, address dest, uint256 amount) external { + require(allowedProxy[msg.sender], "NOT_ALLOWED_PROXY"); + MockD3ApproveV2(_DODO_APPROVE_).claimTokens(token, who, dest, amount); + } +} + +interface IMockD3SwapCallbackV2 { + function d3MMSwapCallBack(address token, uint256 value, bytes calldata data) external; +} + +contract MockD3MMV2 { + mapping(address => mapping(address => uint256)) public quoteAmount; + + function setQuote(address tokenIn, address tokenOut, uint256 amountOut) external { + quoteAmount[tokenIn][tokenOut] = amountOut; + } + + function querySellTokens( + address fromToken, + address toToken, + uint256 fromAmount + ) external view returns (uint256 payFromAmount, uint256 receiveToAmount, uint256 vusdAmount, uint256 swapFee, uint256 mtFee) { + payFromAmount = fromAmount; + receiveToAmount = quoteAmount[fromToken][toToken]; + vusdAmount = 0; + swapFee = 0; + mtFee = 0; + } + + function sellToken( + address to, + address fromToken, + address toToken, + uint256 fromAmount, + uint256 minReceiveAmount, + bytes calldata data + ) external returns (uint256 receiveToAmount) { + receiveToAmount = quoteAmount[fromToken][toToken]; + require(receiveToAmount >= minReceiveAmount, "insufficient output"); + IMockD3SwapCallbackV2(msg.sender).d3MMSwapCallBack(fromToken, fromAmount, data); + IERC20(toToken).transfer(to, receiveToAmount); + } +} + +contract MockD3ProxyV2 { + address public immutable _DODO_APPROVE_PROXY_; + + struct SwapCallbackData { + bytes data; + address payer; + } + + constructor(address approveProxy) { + _DODO_APPROVE_PROXY_ = approveProxy; + } + + function sellTokens( + address pool, + address to, + address fromToken, + address toToken, + uint256 fromAmount, + uint256 minReceiveAmount, + bytes calldata data, + uint256 deadLine + ) external payable returns (uint256 receiveToAmount) { + require(deadLine >= block.timestamp, "expired"); + SwapCallbackData memory swapData = SwapCallbackData({data: data, payer: msg.sender}); + receiveToAmount = MockD3MMV2(pool).sellToken( + to, + fromToken, + toToken, + fromAmount, + minReceiveAmount, + abi.encode(swapData) + ); + } + + function d3MMSwapCallBack(address token, uint256 value, bytes calldata callbackData) external { + SwapCallbackData memory decoded = abi.decode(callbackData, (SwapCallbackData)); + MockD3ApproveProxyV2(_DODO_APPROVE_PROXY_).claimTokens(token, decoded.payer, msg.sender, value); + } +} + +contract EnhancedSwapRouterV2Test is Test { + EnhancedSwapRouterV2 internal router; + IntentBridgeCoordinatorV2 internal coordinator; + DodoRouteExecutorAdapter internal dodoAdapter; + DodoV3RouteExecutorAdapter internal dodoV3Adapter; + UniswapV3RouteExecutorAdapter internal uniswapAdapter; + BalancerRouteExecutorAdapter internal balancerAdapter; + CurveRouteExecutorAdapter internal curveAdapter; + MockBridgeIntentExecutorV2 internal bridgeExecutor; + + MockERC20V2 internal weth; + MockERC20V2 internal usdt; + MockERC20V2 internal usdc; + MockERC20V2 internal dai; + + MockDodoProviderV2 internal dodoProvider; + MockUniswapQuoterV2 internal uniswapQuoter; + MockUniswapRouterV2 internal uniswapRouter; + MockBalancerVaultV2 internal balancerVault; + MockCurvePoolV2 internal curvePool; + MockD3ApproveV2 internal d3Approve; + MockD3ApproveProxyV2 internal d3ApproveProxy; + MockD3MMV2 internal d3Pool; + MockD3ProxyV2 internal d3Proxy; + + address internal user = address(0x1111); + + function setUp() public { + weth = new MockERC20V2("Wrapped Ether", "WETH", 1_000_000 ether); + usdt = new MockERC20V2("Tether", "USDT", 1_000_000 ether); + usdc = new MockERC20V2("USD Coin", "USDC", 1_000_000 ether); + dai = new MockERC20V2("Dai", "DAI", 1_000_000 ether); + + dodoProvider = new MockDodoProviderV2(); + uniswapQuoter = new MockUniswapQuoterV2(); + uniswapRouter = new MockUniswapRouterV2(); + balancerVault = new MockBalancerVaultV2(); + curvePool = new MockCurvePoolV2(); + + dodoAdapter = new DodoRouteExecutorAdapter(); + dodoV3Adapter = new DodoV3RouteExecutorAdapter(); + uniswapAdapter = new UniswapV3RouteExecutorAdapter(); + balancerAdapter = new BalancerRouteExecutorAdapter(); + curveAdapter = new CurveRouteExecutorAdapter(); + bridgeExecutor = new MockBridgeIntentExecutorV2(); + d3Approve = new MockD3ApproveV2(); + d3ApproveProxy = new MockD3ApproveProxyV2(address(d3Approve)); + d3Pool = new MockD3MMV2(); + d3Proxy = new MockD3ProxyV2(address(d3ApproveProxy)); + + router = new EnhancedSwapRouterV2(address(weth), address(usdt), address(usdc), address(dai)); + coordinator = new IntentBridgeCoordinatorV2(address(router)); + + router.setProviderAdapter(RouteTypesV2.Provider.Dodo, address(dodoAdapter)); + router.setProviderAdapter(RouteTypesV2.Provider.DodoV3, address(dodoV3Adapter)); + router.setProviderAdapter(RouteTypesV2.Provider.UniswapV3, address(uniswapAdapter)); + router.setProviderAdapter(RouteTypesV2.Provider.Balancer, address(balancerAdapter)); + router.setProviderAdapter(RouteTypesV2.Provider.Curve, address(curveAdapter)); + + coordinator.setBridgeExecutor(bytes32("CCIP"), address(bridgeExecutor)); + + weth.mint(user, 100 ether); + usdt.mint(address(dodoProvider), 1_000_000 ether); + usdc.mint(address(dodoProvider), 1_000_000 ether); + usdc.mint(address(uniswapRouter), 1_000_000 ether); + usdc.mint(address(balancerVault), 1_000_000 ether); + usdc.mint(address(curveAdapter), 1_000_000 ether); + usdt.mint(address(d3Pool), 1_000_000 ether); + + dodoProvider.setSupport(address(weth), address(usdt), true); + dodoProvider.setQuote(address(weth), address(usdt), 1_800 ether); + dodoProvider.setSupport(address(usdt), address(usdc), true); + dodoProvider.setQuote(address(usdt), address(usdc), 1_790 ether); + d3Pool.setQuote(address(weth), address(usdt), 2_100 ether); + d3Pool.setQuote(address(usdt), address(weth), 0.095 ether); + d3ApproveProxy.setAllowedProxy(address(d3Proxy), true); + + router.setProviderRoute( + address(weth), + address(usdt), + RouteTypesV2.Provider.Dodo, + address(dodoProvider), + abi.encode(address(0xA11CE)), + true + ); + router.setProviderRoute( + address(usdt), + address(usdc), + RouteTypesV2.Provider.Dodo, + address(dodoProvider), + abi.encode(address(0xB0B)), + true + ); + + uniswapQuoter.setQuote(address(weth), address(usdc), 3000, 1_700 ether); + uniswapRouter.setQuote(address(weth), address(usdc), 3000, 1_700 ether); + router.setProviderRoute( + address(weth), + address(usdc), + RouteTypesV2.Provider.UniswapV3, + address(uniswapRouter), + abi.encode(bytes(""), uint24(3000), address(uniswapQuoter), false), + true + ); + + bytes32 poolId = keccak256("balancer-weth-usdc"); + balancerVault.setPool(poolId, address(weth), address(usdc), 100 ether, 160_000 ether); + router.setProviderRoute( + address(weth), + address(usdc), + RouteTypesV2.Provider.Balancer, + address(balancerVault), + abi.encode(poolId), + true + ); + + curvePool.setQuote(0, 1, 1_000 ether); + router.setProviderRoute( + address(usdt), + address(usdc), + RouteTypesV2.Provider.Curve, + address(curvePool), + abi.encode(int128(0), int128(1), false), + true + ); + } + + function _singleLegPlan( + RouteTypesV2.Provider provider, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + address target, + bytes memory providerData + ) internal view returns (RouteTypesV2.RoutePlan memory plan) { + RouteTypesV2.RouteLeg[] memory legs = new RouteTypesV2.RouteLeg[](1); + legs[0] = RouteTypesV2.RouteLeg({ + provider: provider, + tokenIn: tokenIn, + tokenOut: tokenOut, + amountSource: RouteTypesV2.AmountSource.UserInput, + minAmountOut: minAmountOut, + target: target, + providerData: providerData + }); + + plan = RouteTypesV2.RoutePlan({ + chainId: block.chainid, + inputToken: tokenIn, + outputToken: tokenOut, + amountIn: amountIn, + minAmountOut: minAmountOut, + recipient: user, + deadline: block.timestamp + 1 hours, + legs: legs + }); + } + + function testDodoDirectExecution() public { + vm.startPrank(user); + weth.approve(address(router), 1 ether); + RouteTypesV2.RoutePlan memory plan = _singleLegPlan( + RouteTypesV2.Provider.Dodo, + address(weth), + address(usdt), + 1 ether, + 1_700 ether, + address(dodoProvider), + abi.encode(address(0xA11CE)) + ); + uint256 amountOut = router.executeRoute(plan); + vm.stopPrank(); + + assertEq(amountOut, 1_800 ether); + assertEq(usdt.balanceOf(user), 1_800 ether); + } + + function testUniswapV3Execution() public { + vm.startPrank(user); + weth.approve(address(router), 1 ether); + RouteTypesV2.RoutePlan memory plan = _singleLegPlan( + RouteTypesV2.Provider.UniswapV3, + address(weth), + address(usdc), + 1 ether, + 1_650 ether, + address(uniswapRouter), + abi.encode(bytes(""), uint24(3000), address(uniswapQuoter), false) + ); + uint256 amountOut = router.executeRoute(plan); + vm.stopPrank(); + + assertEq(amountOut, 1_700 ether); + assertEq(usdc.balanceOf(user), 1_700 ether); + } + + function testDodoV3DirectExecution() public { + vm.startPrank(user); + weth.approve(address(router), 1 ether); + RouteTypesV2.RoutePlan memory plan = _singleLegPlan( + RouteTypesV2.Provider.DodoV3, + address(weth), + address(usdt), + 1 ether, + 2_000 ether, + address(d3Proxy), + abi.encode(address(d3Pool)) + ); + uint256 amountOut = router.executeRoute(plan); + vm.stopPrank(); + + assertEq(amountOut, 2_100 ether); + assertEq(usdt.balanceOf(user), 2_100 ether); + } + + function testBalancerExecution() public { + vm.startPrank(user); + weth.approve(address(router), 1 ether); + RouteTypesV2.RoutePlan memory plan = _singleLegPlan( + RouteTypesV2.Provider.Balancer, + address(weth), + address(usdc), + 1 ether, + 1_500 ether, + address(balancerVault), + abi.encode(keccak256("balancer-weth-usdc")) + ); + uint256 amountOut = router.executeRoute(plan); + vm.stopPrank(); + + assertGt(amountOut, 0); + assertEq(usdc.balanceOf(user), amountOut); + } + + function testCurveStableStableExecutionOnly() public { + usdt.mint(user, 2_000 ether); + vm.startPrank(user); + usdt.approve(address(router), 1_100 ether); + RouteTypesV2.RoutePlan memory plan = _singleLegPlan( + RouteTypesV2.Provider.Curve, + address(usdt), + address(usdc), + 1_100 ether, + 900 ether, + address(curvePool), + abi.encode(int128(0), int128(1), false) + ); + uint256 amountOut = router.executeRoute(plan); + vm.stopPrank(); + + assertEq(amountOut, 1_000 ether); + assertEq(usdc.balanceOf(user), amountOut); + } + + function testInvalidProviderDataRejected() public { + vm.startPrank(user); + weth.approve(address(router), 1 ether); + RouteTypesV2.RoutePlan memory plan = _singleLegPlan( + RouteTypesV2.Provider.Dodo, + address(weth), + address(usdt), + 1 ether, + 1_700 ether, + address(dodoProvider), + hex"1234" + ); + vm.expectRevert(); + router.executeRoute(plan); + vm.stopPrank(); + } + + function testMultiLegExecutionUsesPreviousLegOutput() public { + vm.startPrank(user); + weth.approve(address(router), 1 ether); + + RouteTypesV2.RouteLeg[] memory legs = new RouteTypesV2.RouteLeg[](2); + legs[0] = RouteTypesV2.RouteLeg({ + provider: RouteTypesV2.Provider.Dodo, + tokenIn: address(weth), + tokenOut: address(usdt), + amountSource: RouteTypesV2.AmountSource.UserInput, + minAmountOut: 1_700 ether, + target: address(dodoProvider), + providerData: abi.encode(address(0xA11CE)) + }); + legs[1] = RouteTypesV2.RouteLeg({ + provider: RouteTypesV2.Provider.Dodo, + tokenIn: address(usdt), + tokenOut: address(usdc), + amountSource: RouteTypesV2.AmountSource.PreviousLeg, + minAmountOut: 1_750 ether, + target: address(dodoProvider), + providerData: abi.encode(address(0xB0B)) + }); + + RouteTypesV2.RoutePlan memory plan = RouteTypesV2.RoutePlan({ + chainId: block.chainid, + inputToken: address(weth), + outputToken: address(usdc), + amountIn: 1 ether, + minAmountOut: 1_750 ether, + recipient: user, + deadline: block.timestamp + 1 hours, + legs: legs + }); + + uint256 amountOut = router.executeRoute(plan); + vm.stopPrank(); + + assertEq(amountOut, 1_790 ether); + assertEq(usdc.balanceOf(user), 1_790 ether); + } + + function testIntentBridgeCoordinatorExecutesSourcePlanAndBridge() public { + vm.startPrank(user); + weth.approve(address(coordinator), 1 ether); + + RouteTypesV2.RouteLeg[] memory sourceLegs = new RouteTypesV2.RouteLeg[](1); + sourceLegs[0] = RouteTypesV2.RouteLeg({ + provider: RouteTypesV2.Provider.Dodo, + tokenIn: address(weth), + tokenOut: address(usdt), + amountSource: RouteTypesV2.AmountSource.UserInput, + minAmountOut: 1_700 ether, + target: address(dodoProvider), + providerData: abi.encode(address(0xA11CE)) + }); + + RouteTypesV2.RoutePlan memory sourcePlan = RouteTypesV2.RoutePlan({ + chainId: block.chainid, + inputToken: address(weth), + outputToken: address(usdt), + amountIn: 1 ether, + minAmountOut: 1_700 ether, + recipient: address(coordinator), + deadline: block.timestamp + 1 hours, + legs: sourceLegs + }); + + RouteTypesV2.RoutePlan memory destinationPlan = RouteTypesV2.RoutePlan({ + chainId: block.chainid + 1, + inputToken: address(usdt), + outputToken: address(usdt), + amountIn: 1_800 ether, + minAmountOut: 1_800 ether, + recipient: user, + deadline: block.timestamp + 2 hours, + legs: new RouteTypesV2.RouteLeg[](0) + }); + + RouteTypesV2.BridgeIntentPlan memory intent = RouteTypesV2.BridgeIntentPlan({ + sourcePlan: sourcePlan, + bridgeType: bytes32("CCIP"), + bridgeData: abi.encode(address(bridgeExecutor), "CCIP"), + destinationPlan: destinationPlan, + recipient: user, + deadline: block.timestamp + 1 hours + }); + + (bytes32 bridgeReference,) = coordinator.executeIntent(intent); + vm.stopPrank(); + + assertTrue(bridgeReference != bytes32(0)); + assertEq(usdt.balanceOf(address(bridgeExecutor)), 1_800 ether); + } +} diff --git a/test/bridge/trustless/integration/Stabilizer.t.sol b/test/bridge/trustless/integration/Stabilizer.t.sol index f18256a..29d7f2e 100644 --- a/test/bridge/trustless/integration/Stabilizer.t.sol +++ b/test/bridge/trustless/integration/Stabilizer.t.sol @@ -51,11 +51,11 @@ contract MockDODOPool { function _QUOTE_TOKEN_() external view returns (address) { return quoteToken; } function getMidPrice() external view returns (uint256) { return midPrice; } - function sellBase(uint256 amount) external returns (uint256) { + function sellBase(address) external returns (uint256) { return sellOutAmount; } - function sellQuote(uint256 amount) external returns (uint256) { + function sellQuote(address) external returns (uint256) { return sellOutAmount; } diff --git a/test/ccip-integration/CCIPIntegration.test.js b/test/ccip-integration/CCIPIntegration.test.js index 710de99..5d6b61d 100644 --- a/test/ccip-integration/CCIPIntegration.test.js +++ b/test/ccip-integration/CCIPIntegration.test.js @@ -1,5 +1,25 @@ const { expect } = require("chai"); -const { ethers } = require("hardhat"); +const { ethers, network } = require("hardhat"); +const path = require("path"); + +const mockRouterArtifact = require(path.join( + __dirname, + "../../out/CCIPWETH9Bridge.t.sol/MockCCIPRouter.json" +)); + +async function expectEvent(txPromise, contract, eventName) { + const tx = await txPromise; + const receipt = await tx.wait(); + const foundEvent = receipt.logs.some((log) => { + try { + return contract.interface.parseLog(log)?.name === eventName; + } catch (error) { + return false; + } + }); + + expect(foundEvent).to.equal(true); +} describe("CCIP Integration", function () { let ccipLogger, ccipReporter; @@ -9,9 +29,13 @@ describe("CCIP Integration", function () { beforeEach(async function () { [owner, relayer] = await ethers.getSigners(); - // Deploy mock routers for testing - // In production, these would be the actual Chainlink CCIP routers - const MockRouter = await ethers.getContractFactory("MockCCIPRouter"); + // Reuse the Foundry-built mock router so this suite doesn't depend on a + // separate Hardhat-only mock artifact. + const MockRouter = new ethers.ContractFactory( + mockRouterArtifact.abi, + mockRouterArtifact.bytecode.object, + owner + ); mockRouter = await MockRouter.deploy(); await mockRouter.waitForDeployment(); @@ -44,11 +68,13 @@ describe("CCIP Integration", function () { const toAddr = relayer.address; const value = ethers.parseEther("1.0"); - await expect( + await expectEvent( ccipReporter.reportTx(txHash, fromAddr, toAddr, value, "0x", { value: ethers.parseEther("0.01"), - }) - ).to.emit(ccipReporter, "SingleTxReported"); + }), + ccipReporter, + "SingleTxReported" + ); }); it("Should report a batch of transactions", async function () { @@ -61,11 +87,13 @@ describe("CCIP Integration", function () { const tos = [relayer.address, owner.address]; const values = [ethers.parseEther("1.0"), ethers.parseEther("2.0")]; - await expect( + await expectEvent( ccipReporter.reportBatch(batchId, txHashes, froms, tos, values, "0x", { value: ethers.parseEther("0.01"), - }) - ).to.emit(ccipReporter, "BatchReported"); + }), + ccipReporter, + "BatchReported" + ); }); it("Should estimate fee correctly", async function () { @@ -78,11 +106,10 @@ describe("CCIP Integration", function () { txHashes, froms, tos, - values, - "0x" + values ); - expect(fee).to.be.gt(0); + expect(fee > 0n).to.equal(true); }); }); @@ -103,14 +130,33 @@ describe("CCIP Integration", function () { const message = { messageId: ethers.keccak256(ethers.toUtf8Bytes("test-message")), sourceChainSelector: "0x000000000000008a", - sender: await ccipReporter.getAddress(), + sender: ethers.zeroPadValue(await ccipReporter.getAddress(), 32), data: payload, - destTokenAmounts: [], + tokenAmounts: [], }; - await expect( - mockRouter.deliverMessage(await ccipLogger.getAddress(), message) - ).to.emit(ccipLogger, "RemoteBatchLogged"); + const routerAddress = await mockRouter.getAddress(); + await network.provider.request({ + method: "hardhat_impersonateAccount", + params: [routerAddress], + }); + await network.provider.send("hardhat_setBalance", [ + routerAddress, + "0x56BC75E2D63100000", + ]); + + const routerSigner = await ethers.getSigner(routerAddress); + + await expectEvent( + ccipLogger.connect(routerSigner).ccipReceive(message), + ccipLogger, + "RemoteBatchLogged" + ); + + await network.provider.request({ + method: "hardhat_stopImpersonatingAccount", + params: [routerAddress], + }); }); it("Should prevent replay attacks", async function () { @@ -128,13 +174,25 @@ describe("CCIP Integration", function () { const message = { messageId: ethers.keccak256(ethers.toUtf8Bytes("test-message-1")), sourceChainSelector: "0x000000000000008a", - sender: await ccipReporter.getAddress(), + sender: ethers.zeroPadValue(await ccipReporter.getAddress(), 32), data: payload, - destTokenAmounts: [], + tokenAmounts: [], }; + const routerAddress = await mockRouter.getAddress(); + await network.provider.request({ + method: "hardhat_impersonateAccount", + params: [routerAddress], + }); + await network.provider.send("hardhat_setBalance", [ + routerAddress, + "0x56BC75E2D63100000", + ]); + + const routerSigner = await ethers.getSigner(routerAddress); + // First delivery should succeed - await mockRouter.deliverMessage(await ccipLogger.getAddress(), message); + await ccipLogger.connect(routerSigner).ccipReceive(message); // Second delivery with same batchId should fail const message2 = { @@ -142,10 +200,19 @@ describe("CCIP Integration", function () { messageId: ethers.keccak256(ethers.toUtf8Bytes("test-message-2")), }; - await expect( - mockRouter.deliverMessage(await ccipLogger.getAddress(), message2) - ).to.be.revertedWith("CCIPLogger: batch already processed"); + let reverted = false; + try { + await ccipLogger.connect(routerSigner).ccipReceive(message2); + } catch (error) { + reverted = error.message.includes("CCIPLogger: batch already processed"); + } + + expect(reverted).to.equal(true); + + await network.provider.request({ + method: "hardhat_stopImpersonatingAccount", + params: [routerAddress], + }); }); }); }); - diff --git a/test/compliance/CompliantFiatTokenV2.t.sol b/test/compliance/CompliantFiatTokenV2.t.sol index 95a4807..b02184f 100644 --- a/test/compliance/CompliantFiatTokenV2.t.sol +++ b/test/compliance/CompliantFiatTokenV2.t.sol @@ -14,6 +14,12 @@ contract CompliantFiatTokenV2Test is Test { keccak256( "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" ); + bytes32 private constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = + keccak256( + "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" + ); + bytes32 private constant CANCEL_AUTHORIZATION_TYPEHASH = + keccak256("CancelAuthorization(address authorizer,bytes32 nonce)"); event CompliantOperationDeclared( bytes32 indexed operationType, @@ -117,6 +123,89 @@ contract CompliantFiatTokenV2Test is Test { assertTrue(token.authorizationState(owner, nonce)); } + function testReceiveWithAuthorizationRequiresPayeeCallerAndWorks() public { + uint256 value = 7_500 * 10 ** 6; + uint256 validAfter = block.timestamp - 1; + uint256 validBefore = block.timestamp + 1 days; + bytes32 nonce = keccak256("receive-auth-1"); + + bytes32 structHash = keccak256( + abi.encode( + RECEIVE_WITH_AUTHORIZATION_TYPEHASH, + owner, + recipient, + value, + validAfter, + validBefore, + nonce + ) + ); + bytes32 digest = keccak256( + abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, digest); + + vm.prank(spender); + vm.expectRevert( + abi.encodeWithSelector( + CompliantFiatTokenV2.AuthorizationMustBeUsedByPayee.selector, + recipient, + spender + ) + ); + token.receiveWithAuthorization(owner, recipient, value, validAfter, validBefore, nonce, v, r, s); + + vm.prank(recipient); + token.receiveWithAuthorization(owner, recipient, value, validAfter, validBefore, nonce, v, r, s); + + assertEq(token.balanceOf(recipient), value); + assertTrue(token.authorizationState(owner, nonce)); + } + + function testCancelAuthorizationBlocksFutureUse() public { + uint256 value = 1_000 * 10 ** 6; + uint256 validAfter = block.timestamp - 1; + uint256 validBefore = block.timestamp + 1 days; + bytes32 nonce = keccak256("cancel-auth-1"); + + bytes32 cancelStructHash = keccak256( + abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, owner, nonce) + ); + bytes32 cancelDigest = keccak256( + abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), cancelStructHash) + ); + (uint8 cancelV, bytes32 cancelR, bytes32 cancelS) = vm.sign(ownerPk, cancelDigest); + + token.cancelAuthorization(owner, nonce, cancelV, cancelR, cancelS); + assertTrue(token.authorizationState(owner, nonce)); + + bytes32 transferStructHash = keccak256( + abi.encode( + TRANSFER_WITH_AUTHORIZATION_TYPEHASH, + owner, + recipient, + value, + validAfter, + validBefore, + nonce + ) + ); + bytes32 transferDigest = keccak256( + abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), transferStructHash) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, transferDigest); + + vm.prank(spender); + vm.expectRevert( + abi.encodeWithSelector( + CompliantFiatTokenV2.AuthorizationAlreadyUsed.selector, + owner, + nonce + ) + ); + token.transferWithAuthorization(owner, recipient, value, validAfter, validBefore, nonce, v, r, s); + } + function testMintAndBurnReasonHashesEmitStructuredEvents() public { bytes32 mintReason = keccak256("mint-reason"); bytes32 burnReason = keccak256("burn-reason"); @@ -245,6 +334,26 @@ contract CompliantFiatTokenV2Test is Test { assertFalse(token.wrappedTransport()); } + function testEip5267DomainIntrospectionMatchesTokenMetadata() public view { + ( + bytes1 fields, + string memory name, + string memory version, + uint256 chainId, + address verifyingContract, + bytes32 salt, + uint256[] memory extensions + ) = token.eip712Domain(); + + assertEq(uint8(fields), uint8(0x0f)); + assertEq(name, "Euro Coin (Compliant V2)"); + assertEq(version, "2"); + assertEq(chainId, block.chainid); + assertEq(verifyingContract, address(token)); + assertEq(salt, bytes32(0)); + assertEq(extensions.length, 0); + } + function testEmergencyMetadataOverridesRemainAvailableOutsideGovernance() public { vm.prank(admin); token.emergencySetPresentationMetadata(true, "ipfs://emergency-token", "cEURC-emergency"); diff --git a/test/compliance/CompliantMonetaryUnitTokenTest.t.sol b/test/compliance/CompliantMonetaryUnitTokenTest.t.sol new file mode 100644 index 0000000..23e9d38 --- /dev/null +++ b/test/compliance/CompliantMonetaryUnitTokenTest.t.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {CompliantBTC} from "../../contracts/tokens/CompliantBTC.sol"; + +contract CompliantMonetaryUnitTokenTest is Test { + CompliantBTC public token; + address public owner; + address public admin; + address public user1; + + function setUp() public { + owner = address(this); + admin = address(this); + user1 = address(0xB0B); + token = new CompliantBTC(owner, admin, 21_000_000 * 10**8); + } + + function testDecimals() public view { + assertEq(token.decimals(), 8); + } + + function testUnitCode() public view { + assertEq(token.unitCode(), "BTC"); + assertTrue(token.isMonetaryUnit()); + } + + function testInitialSupplyUsesSatoshiPrecision() public view { + assertEq(token.totalSupply(), 21_000_000 * 10**8); + assertEq(token.balanceOf(owner), 21_000_000 * 10**8); + } + + function testTransferUsesEightDecimals() public { + uint256 amount = 12_500_000; + token.transfer(user1, amount); + assertEq(token.balanceOf(user1), amount); + assertEq(token.balanceOf(owner), 21_000_000 * 10**8 - amount); + } + + function testPauseBlocksTransfers() public { + token.pause(); + assertTrue(token.paused()); + vm.expectRevert(); + token.transfer(user1, 100_000_000); + } + + function testMintAndBurnUseSatoshiPrecision() public { + token.mint(user1, 250_000_000); + assertEq(token.balanceOf(user1), 250_000_000); + token.burn(100_000_000); + assertEq(token.balanceOf(owner), 21_000_000 * 10**8 - 100_000_000); + } +} diff --git a/test/compliance/CompliantWrappedTokenTest.t.sol b/test/compliance/CompliantWrappedTokenTest.t.sol index 530e287..8f00095 100644 --- a/test/compliance/CompliantWrappedTokenTest.t.sol +++ b/test/compliance/CompliantWrappedTokenTest.t.sol @@ -134,4 +134,24 @@ contract CompliantWrappedTokenTest is Test { assertEq(token.reportingURI(), "ipfs://cw-emergency-reporting"); assertEq(token.minimumUpgradeNoticePeriod(), 30 days); } + + function testWrappedBTCCanUseEightDecimalsAndFrozenBridgeRoles() public { + CompliantWrappedToken wrappedBtc = new CompliantWrappedToken("Wrapped cBTC", "cWBTC", 8, admin); + wrappedBtc.grantRole(MINTER_ROLE, bridge); + wrappedBtc.grantRole(BURNER_ROLE, bridge); + + vm.prank(bridge); + wrappedBtc.mint(user1, 125_000_000); + assertEq(wrappedBtc.decimals(), 8); + assertEq(wrappedBtc.balanceOf(user1), 125_000_000); + + wrappedBtc.freezeOperationalRoles(); + + vm.prank(bridge); + wrappedBtc.burnFrom(user1, 25_000_000); + assertEq(wrappedBtc.balanceOf(user1), 100_000_000); + + vm.expectRevert(); + wrappedBtc.grantRole(MINTER_ROLE, address(0xCAFE)); + } } diff --git a/test/dex/DODOPMMIntegration.t.sol b/test/dex/DODOPMMIntegration.t.sol index e753d75..40b14fd 100644 --- a/test/dex/DODOPMMIntegration.t.sol +++ b/test/dex/DODOPMMIntegration.t.sol @@ -1,17 +1,36 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; -import {Test, console} from "forge-std/Test.sol"; +import {Test} from "forge-std/Test.sol"; import "../../contracts/dex/DODOPMMIntegration.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract MockERC20 is ERC20 { constructor(string memory name, string memory symbol) ERC20(name, symbol) { - _mint(msg.sender, 1000000 ether); + _mint(msg.sender, 1_000_000 ether); } } -contract MockDodoPool { +contract MockPoolFactory { + address public nextPool; + + function setNextPool(address pool) external { + nextPool = pool; + } + + function createDVM( + address, + address, + uint256, + uint256, + uint256, + bool + ) external view returns (address dvm) { + return nextPool; + } +} + +contract MockBasicDodoPool { address public immutable baseToken; address public immutable quoteToken; @@ -27,11 +46,75 @@ contract MockDodoPool { function _QUOTE_TOKEN_() external view returns (address) { return quoteToken; } + + function getVaultReserve() external pure returns (uint256, uint256) { + return (1_000_000, 1_000_000); + } + + function getMidPrice() external pure returns (uint256) { + return 1e18; + } + + function _QUOTE_RESERVE_() external pure returns (uint256) { + return 1_000_000; + } + + function _BASE_RESERVE_() external pure returns (uint256) { + return 1_000_000; + } +} + +contract MockStandardDodoPool is MockBasicDodoPool { + constructor(address baseToken_, address quoteToken_) MockBasicDodoPool(baseToken_, quoteToken_) {} + + function querySellBase(address, uint256 payBaseAmount) external pure returns (uint256, uint256) { + return (payBaseAmount, 0); + } + + function querySellQuote(address, uint256 payQuoteAmount) external pure returns (uint256, uint256) { + return (payQuoteAmount, 0); + } + + function buyShares(address) external pure returns (uint256, uint256, uint256) { + return (0, 0, 1); + } + + function sellBase(address) external pure returns (uint256) { + return 0; + } + + function sellQuote(address) external pure returns (uint256) { + return 0; + } +} + +contract MockPartialDodoPool is MockBasicDodoPool { + constructor(address baseToken_, address quoteToken_) MockBasicDodoPool(baseToken_, quoteToken_) {} + + function querySellBase(address, uint256) external pure returns (uint256, uint256) { + revert("query disabled"); + } + + function querySellQuote(address, uint256) external pure returns (uint256, uint256) { + revert("query disabled"); + } + + function buyShares(address) external pure returns (uint256, uint256, uint256) { + return (0, 0, 1); + } + + function sellBase(address) external pure returns (uint256) { + return 0; + } + + function sellQuote(address) external pure returns (uint256) { + return 0; + } } contract DODOPMMIntegrationTest is Test { DODOPMMIntegration public integration; - address public dvm = address(0xdEaD); + MockPoolFactory public dvm; address public dodoApprove = address(0xD0D0); MockERC20 public officialUSDT; MockERC20 public officialUSDC; @@ -44,86 +127,67 @@ contract DODOPMMIntegrationTest is Test { officialUSDC = new MockERC20("USDC", "USDC"); compliantUSDT = new MockERC20("cUSDT", "cUSDT"); compliantUSDC = new MockERC20("cUSDC", "cUSDC"); - vm.mockCall( - dvm, - abi.encodeWithSelector( - DODOPMMIntegration.createPool.selector - ), - abi.encode(address(0x1001)) - ); + dvm = new MockPoolFactory(); + + officialUSDT.transfer(admin, 1_000 ether); + officialUSDC.transfer(admin, 1_000 ether); + compliantUSDT.transfer(admin, 1_000 ether); + compliantUSDC.transfer(admin, 1_000 ether); + integration = new DODOPMMIntegration( admin, - dvm, + address(dvm), dodoApprove, address(officialUSDT), address(officialUSDC), address(compliantUSDT), address(compliantUSDC) ); - // admin already has POOL_MANAGER_ROLE from constructor } function testCreatePoolGeneric() public { - address baseToken = address(0xB1); - address quoteToken = address(0xB2); - address mockPoolAddr = address(0xBeef); - vm.mockCall(dvm, bytes(""), abi.encode(mockPoolAddr)); + address baseToken = address(compliantUSDT); + address quoteToken = address(officialUSDC); + MockStandardDodoPool poolContract = new MockStandardDodoPool(baseToken, quoteToken); + dvm.setNextPool(address(poolContract)); + vm.prank(admin); - address pool = integration.createPool( - baseToken, - quoteToken, - 3, - 1e18, - 0.5e18, - true - ); - assertEq(pool, mockPoolAddr); - assertEq(integration.pools(baseToken, quoteToken), mockPoolAddr); - assertEq(integration.pools(quoteToken, baseToken), mockPoolAddr); - assertTrue(integration.isRegisteredPool(mockPoolAddr)); + address pool = integration.createPool(baseToken, quoteToken, 3, 1e18, 0.5e18, true); + + assertEq(pool, address(poolContract)); + assertEq(integration.pools(baseToken, quoteToken), address(poolContract)); + assertEq(integration.pools(quoteToken, baseToken), address(poolContract)); + assertTrue(integration.isRegisteredPool(address(poolContract))); + assertFalse(integration.hasStandardPoolSurface(address(poolContract))); } function testCreatePoolRevertsSameToken() public { vm.prank(admin); vm.expectRevert("DODOPMMIntegration: same token"); - integration.createPool( - address(officialUSDT), - address(officialUSDT), - 3, - 1e18, - 0.5e18, - true - ); + integration.createPool(address(officialUSDT), address(officialUSDT), 3, 1e18, 0.5e18, true); } function testCreatePoolRevertsZeroBase() public { vm.prank(admin); vm.expectRevert("DODOPMMIntegration: zero base"); - integration.createPool( - address(0), - address(officialUSDT), - 3, - 1e18, - 0.5e18, - true - ); + integration.createPool(address(0), address(officialUSDT), 3, 1e18, 0.5e18, true); + } + + function testCreatePoolRevertsWithoutBasicSurface() public { + dvm.setNextPool(address(0xBEEF)); + + vm.prank(admin); + vm.expectRevert(); + integration.createPool(address(compliantUSDT), address(officialUSDC), 3, 1e18, 0.5e18, false); } function testImportExistingPoolRecordsMappings() public { address baseToken = address(officialUSDT); address quoteToken = address(compliantUSDC); - MockDodoPool pool = new MockDodoPool(baseToken, quoteToken); + MockStandardDodoPool pool = new MockStandardDodoPool(baseToken, quoteToken); vm.prank(admin); - integration.importExistingPool( - address(pool), - baseToken, - quoteToken, - 3, - 1e18, - 0.5e18, - false - ); + integration.importExistingPool(address(pool), baseToken, quoteToken, 3, 1e18, 0.5e18, false); assertEq(integration.pools(baseToken, quoteToken), address(pool)); assertEq(integration.pools(quoteToken, baseToken), address(pool)); @@ -137,18 +201,10 @@ contract DODOPMMIntegrationTest is Test { function testImportExistingPoolAcceptsReverseHintAndNormalizes() public { address baseToken = address(officialUSDT); address quoteToken = address(compliantUSDC); - MockDodoPool pool = new MockDodoPool(baseToken, quoteToken); + MockStandardDodoPool pool = new MockStandardDodoPool(baseToken, quoteToken); vm.prank(admin); - integration.importExistingPool( - address(pool), - quoteToken, - baseToken, - 3, - 1e18, - 0.5e18, - false - ); + integration.importExistingPool(address(pool), quoteToken, baseToken, 3, 1e18, 0.5e18, false); DODOPMMIntegration.PoolConfig memory config = integration.getPoolConfig(address(pool)); assertEq(config.baseToken, baseToken); @@ -156,18 +212,50 @@ contract DODOPMMIntegrationTest is Test { } function testImportExistingPoolRevertsOnMismatch() public { - MockDodoPool pool = new MockDodoPool(address(officialUSDT), address(compliantUSDC)); + MockStandardDodoPool pool = new MockStandardDodoPool(address(officialUSDT), address(compliantUSDC)); vm.prank(admin); vm.expectRevert("DODOPMMIntegration: pool token mismatch"); - integration.importExistingPool( - address(pool), - address(officialUSDT), - address(compliantUSDT), - 3, - 1e18, - 0.5e18, - false - ); + integration.importExistingPool(address(pool), address(officialUSDT), address(compliantUSDT), 3, 1e18, 0.5e18, false); + } + + function testAddLiquidityMarksStandardSurface() public { + MockStandardDodoPool pool = new MockStandardDodoPool(address(compliantUSDT), address(officialUSDC)); + dvm.setNextPool(address(pool)); + + vm.startPrank(admin); + address createdPool = integration.createPool(address(compliantUSDT), address(officialUSDC), 3, 1e18, 0.5e18, false); + compliantUSDT.approve(address(integration), 100); + officialUSDC.approve(address(integration), 100); + integration.addLiquidity(createdPool, 100, 100); + vm.stopPrank(); + + assertTrue(integration.hasStandardPoolSurface(createdPool)); + } + + function testAddLiquidityRevertsForPartialSurfacePool() public { + MockPartialDodoPool pool = new MockPartialDodoPool(address(compliantUSDT), address(officialUSDC)); + dvm.setNextPool(address(pool)); + + vm.startPrank(admin); + address createdPool = integration.createPool(address(compliantUSDT), address(officialUSDC), 3, 1e18, 0.5e18, false); + compliantUSDT.approve(address(integration), 100); + officialUSDC.approve(address(integration), 100); + vm.expectRevert("DODOPMMIntegration: pool missing standard DODO surface"); + integration.addLiquidity(createdPool, 100, 100); + vm.stopPrank(); + } + + function testRefreshPoolSurfaceMarksImportedStandardPool() public { + MockStandardDodoPool pool = new MockStandardDodoPool(address(compliantUSDT), address(compliantUSDC)); + + vm.prank(admin); + integration.importExistingPool(address(pool), address(compliantUSDT), address(compliantUSDC), 3, 1e18, 0.5e18, false); + + vm.prank(admin); + bool standardSurface = integration.refreshPoolSurface(address(pool)); + + assertTrue(standardSurface); + assertTrue(integration.hasStandardPoolSurface(address(pool))); } } diff --git a/test/dex/DODOPMMProvider.t.sol b/test/dex/DODOPMMProvider.t.sol new file mode 100644 index 0000000..16635f0 --- /dev/null +++ b/test/dex/DODOPMMProvider.t.sol @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Test} from "forge-std/Test.sol"; +import "../../contracts/dex/DODOPMMIntegration.sol"; +import "../../contracts/liquidity/providers/DODOPMMProvider.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockProviderERC20 is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) { + _mint(msg.sender, 1_000_000 ether); + } +} + +contract MockQuotePool { + address public immutable baseToken; + address public immutable quoteToken; + uint256 public immutable midPrice; + uint256 public immutable baseToQuoteOut; + uint256 public immutable quoteToBaseOut; + + constructor( + address baseToken_, + address quoteToken_, + uint256 midPrice_, + uint256 baseToQuoteOut_, + uint256 quoteToBaseOut_ + ) { + baseToken = baseToken_; + quoteToken = quoteToken_; + midPrice = midPrice_; + baseToQuoteOut = baseToQuoteOut_; + quoteToBaseOut = quoteToBaseOut_; + } + + function _BASE_TOKEN_() external view returns (address) { + return baseToken; + } + + function _QUOTE_TOKEN_() external view returns (address) { + return quoteToken; + } + + function querySellBase(address, uint256) external view returns (uint256, uint256) { + return (baseToQuoteOut, 0); + } + + function querySellQuote(address, uint256) external view returns (uint256, uint256) { + return (quoteToBaseOut, 0); + } + + function sellBase(address) external pure returns (uint256) { + return 0; + } + + function sellQuote(address) external pure returns (uint256) { + return 0; + } + + function buyShares(address) external pure returns (uint256, uint256, uint256) { + return (0, 0, 0); + } + + function getVaultReserve() external pure returns (uint256, uint256) { + return (1_000_000, 1_000_000); + } + + function getMidPrice() external view returns (uint256) { + return midPrice; + } + + function _QUOTE_RESERVE_() external pure returns (uint256) { + return 1_000_000; + } + + function _BASE_RESERVE_() external pure returns (uint256) { + return 1_000_000; + } +} + +contract MockFallbackQuotePool { + address public immutable baseToken; + address public immutable quoteToken; + uint256 public immutable baseReserve; + uint256 public immutable quoteReserve; + + constructor( + address baseToken_, + address quoteToken_, + uint256 baseReserve_, + uint256 quoteReserve_ + ) { + baseToken = baseToken_; + quoteToken = quoteToken_; + baseReserve = baseReserve_; + quoteReserve = quoteReserve_; + } + + function _BASE_TOKEN_() external view returns (address) { + return baseToken; + } + + function _QUOTE_TOKEN_() external view returns (address) { + return quoteToken; + } + + function querySellBase(address, uint256) external pure returns (uint256, uint256) { + revert("base quote disabled"); + } + + function querySellQuote(address, uint256) external pure returns (uint256, uint256) { + revert("quote quote disabled"); + } + + function sellBase(address) external pure returns (uint256) { + return 0; + } + + function sellQuote(address) external pure returns (uint256) { + return 0; + } + + function buyShares(address) external pure returns (uint256, uint256, uint256) { + return (0, 0, 0); + } + + function getVaultReserve() external view returns (uint256, uint256) { + return (baseReserve, quoteReserve); + } + + function getMidPrice() external pure returns (uint256) { + return 1e18; + } + + function _QUOTE_RESERVE_() external view returns (uint256) { + return quoteReserve; + } + + function _BASE_RESERVE_() external view returns (uint256) { + return baseReserve; + } +} + +contract DODOPMMProviderTest is Test { + DODOPMMIntegration internal integration; + DODOPMMProvider internal provider; + MockProviderERC20 internal officialUSDT; + MockProviderERC20 internal officialUSDC; + MockProviderERC20 internal compliantUSDT; + MockProviderERC20 internal compliantUSDC; + + address internal constant ADMIN = address(0xAD); + address internal constant DVM = address(0xD0D0); + address internal constant DODO_APPROVE = address(0xA11CE); + + function setUp() public { + officialUSDT = new MockProviderERC20("USDT", "USDT"); + officialUSDC = new MockProviderERC20("USDC", "USDC"); + compliantUSDT = new MockProviderERC20("cUSDT", "cUSDT"); + compliantUSDC = new MockProviderERC20("cUSDC", "cUSDC"); + + integration = new DODOPMMIntegration( + ADMIN, + DVM, + DODO_APPROVE, + address(officialUSDT), + address(officialUSDC), + address(compliantUSDT), + address(compliantUSDC) + ); + + provider = new DODOPMMProvider(address(integration), ADMIN); + } + + function testGetQuoteUsesPoolQueryForBaseSell() public { + MockQuotePool pool = new MockQuotePool( + address(compliantUSDT), + address(compliantUSDC), + 2e18, + 990_000, + 995_000 + ); + + vm.prank(ADMIN); + integration.importExistingPool(address(pool), address(compliantUSDT), address(compliantUSDC), 3, 1e18, 0.5e18, false); + + vm.startPrank(ADMIN); + provider.registerPool(address(compliantUSDT), address(compliantUSDC), address(pool)); + provider.registerPool(address(compliantUSDC), address(compliantUSDT), address(pool)); + vm.stopPrank(); + + (uint256 amountOut, uint256 slippageBps) = provider.getQuote( + address(compliantUSDT), + address(compliantUSDC), + 1_000_000 + ); + + assertEq(amountOut, 990_000); + assertEq(slippageBps, 30); + } + + function testGetQuoteUsesPoolQueryForQuoteSell() public { + MockQuotePool pool = new MockQuotePool( + address(compliantUSDT), + address(compliantUSDC), + 2e18, + 990_000, + 995_000 + ); + + vm.prank(ADMIN); + integration.importExistingPool(address(pool), address(compliantUSDT), address(compliantUSDC), 3, 1e18, 0.5e18, false); + + vm.startPrank(ADMIN); + provider.registerPool(address(compliantUSDT), address(compliantUSDC), address(pool)); + provider.registerPool(address(compliantUSDC), address(compliantUSDT), address(pool)); + vm.stopPrank(); + + (uint256 amountOut, uint256 slippageBps) = provider.getQuote( + address(compliantUSDC), + address(compliantUSDT), + 1_000_000 + ); + + assertEq(amountOut, 995_000); + assertEq(slippageBps, 30); + } + + function testGetQuoteReturnsZeroForUnsupportedPair() public view { + (uint256 amountOut, uint256 slippageBps) = provider.getQuote( + address(compliantUSDT), + address(officialUSDC), + 1_000_000 + ); + + assertEq(amountOut, 0); + assertEq(slippageBps, 10000); + } + + function testGetQuoteFallsBackToReservesForBaseSell() public { + uint256 amountIn = 100_000; + uint256 lpFeeRate = 30; + MockFallbackQuotePool pool = new MockFallbackQuotePool( + address(compliantUSDT), + address(compliantUSDC), + 1_000_000, + 2_000_000 + ); + + vm.prank(ADMIN); + integration.importExistingPool(address(pool), address(compliantUSDT), address(compliantUSDC), lpFeeRate, 1e18, 0.5e18, false); + + vm.startPrank(ADMIN); + provider.registerPool(address(compliantUSDT), address(compliantUSDC), address(pool)); + provider.registerPool(address(compliantUSDC), address(compliantUSDT), address(pool)); + vm.stopPrank(); + + uint256 netAmountIn = (amountIn * (10_000 - lpFeeRate)) / 10_000; + uint256 expectedOut = (netAmountIn * 2_000_000) / (1_000_000 + netAmountIn); + + (uint256 amountOut, uint256 slippageBps) = provider.getQuote( + address(compliantUSDT), + address(compliantUSDC), + amountIn + ); + + assertEq(amountOut, expectedOut); + assertEq(slippageBps, 100); + } + + function testGetQuoteFallsBackToReservesForQuoteSell() public { + uint256 amountIn = 100_000; + uint256 lpFeeRate = 25; + MockFallbackQuotePool pool = new MockFallbackQuotePool( + address(compliantUSDT), + address(compliantUSDC), + 2_500_000, + 1_500_000 + ); + + vm.prank(ADMIN); + integration.importExistingPool(address(pool), address(compliantUSDT), address(compliantUSDC), lpFeeRate, 1e18, 0.5e18, false); + + vm.startPrank(ADMIN); + provider.registerPool(address(compliantUSDT), address(compliantUSDC), address(pool)); + provider.registerPool(address(compliantUSDC), address(compliantUSDT), address(pool)); + vm.stopPrank(); + + uint256 netAmountIn = (amountIn * (10_000 - lpFeeRate)) / 10_000; + uint256 expectedOut = (netAmountIn * 2_500_000) / (1_500_000 + netAmountIn); + + (uint256 amountOut, uint256 slippageBps) = provider.getQuote( + address(compliantUSDC), + address(compliantUSDT), + amountIn + ); + + assertEq(amountOut, expectedOut); + assertEq(slippageBps, 100); + } +} diff --git a/test/emoney/api/README.md b/test/emoney/api/README.md index c246bd9..943ecc4 100644 --- a/test/emoney/api/README.md +++ b/test/emoney/api/README.md @@ -1,12 +1,14 @@ # API Tests -This directory contains integration and contract tests for the eMoney Token Factory API. +This directory contains reference contract tests and opt-in integration smoke tests for the eMoney control-plane API surface. + +The default repo validation pass does not start an external REST or GraphQL control-plane service, so the integration suites are intentionally opt-in. ## Test Structure ``` -test/api/ -├── integration/ # Integration tests +test/emoney/api/ +├── integration/ # Optional smoke tests against an external API service │ ├── rest-api.test.ts │ └── graphql.test.ts └── contract/ # Contract validation tests @@ -14,6 +16,8 @@ test/api/ └── event-schema-validation.test.ts ``` +Reference API specs used by the contract tests live under `test/api/packages/`. + ## Running Tests ```bash @@ -39,6 +43,13 @@ Test actual API endpoints against running services: - GraphQL queries and mutations - End-to-end flows +Enable them explicitly: + +```bash +export RUN_EMONEY_API_INTEGRATION=1 +export RUN_EMONEY_GRAPHQL_INTEGRATION=1 +``` + ### Contract Tests Validate that implementations conform to specifications: @@ -48,25 +59,10 @@ Validate that implementations conform to specifications: ## Mock Servers -Use mock servers for testing without requiring full infrastructure: - -```bash -# Start all mock servers -cd api/tools/mock-server -pnpm run start:all - -# Or start individually -pnpm run start:rest # REST API mock (port 4010) -pnpm run start:graphql # GraphQL mock (port 4020) -``` - -## Test Environment - -Set environment variables: +Mock server tooling is not checked into this monorepo checkout. If you have an external mock or implementation, point the integration tests at it with environment variables. ```bash export API_URL=http://localhost:3000 export GRAPHQL_URL=http://localhost:4000/graphql export ACCESS_TOKEN=your-test-token ``` - diff --git a/test/emoney/api/contract/event-schema-validation.test.ts b/test/emoney/api/contract/event-schema-validation.test.ts index a80993a..a3f8c11 100644 --- a/test/emoney/api/contract/event-schema-validation.test.ts +++ b/test/emoney/api/contract/event-schema-validation.test.ts @@ -46,7 +46,7 @@ describe('AsyncAPI Event Schema Validation', () => { ]; requiredChannels.forEach((channel) => { - expect(asyncapiSpec.channels).toHaveProperty(channel); + expect(asyncapiSpec.channels[channel]).toBeDefined(); }); }); @@ -61,4 +61,3 @@ describe('AsyncAPI Event Schema Validation', () => { expect(envelopeSchema.required).toContain('payload'); }); }); - diff --git a/test/emoney/api/integration/graphql.test.ts b/test/emoney/api/integration/graphql.test.ts index e9d6d3e..80d2904 100644 --- a/test/emoney/api/integration/graphql.test.ts +++ b/test/emoney/api/integration/graphql.test.ts @@ -6,8 +6,40 @@ import { describe, it, expect, beforeAll } from '@jest/globals'; import { GraphQLClient } from 'graphql-request'; const GRAPHQL_URL = process.env.GRAPHQL_URL || 'http://localhost:4000/graphql'; +const runIntegration = process.env.RUN_EMONEY_GRAPHQL_INTEGRATION === '1'; +const describeIfConfigured = runIntegration ? describe : describe.skip; -describe('GraphQL API Integration Tests', () => { +type TokenQueryResult = { + token: { + code: string; + address: string; + name: string; + symbol: string; + policy: { + lienMode: string; + }; + }; +}; + +type TriggersQueryResult = { + triggers: { + items: Array<{ + triggerId: string; + rail: string; + state: string; + }>; + total: number; + }; +}; + +type DeployTokenMutationResult = { + deployToken: { + code: string; + address: string; + }; +}; + +describeIfConfigured('GraphQL API Integration Tests', () => { let client: GraphQLClient; beforeAll(() => { @@ -34,7 +66,7 @@ describe('GraphQL API Integration Tests', () => { } `; - const data = await client.request(query, { code: 'USDW' }); + const data = await client.request(query, { code: 'USDW' }); expect(data).toHaveProperty('token'); expect(data.token).toHaveProperty('code'); }); @@ -53,7 +85,7 @@ describe('GraphQL API Integration Tests', () => { } `; - const data = await client.request(query, { + const data = await client.request(query, { filter: { state: 'PENDING' }, paging: { limit: 10, offset: 0 }, }); @@ -74,7 +106,7 @@ describe('GraphQL API Integration Tests', () => { } `; - const data = await client.request(mutation, { + const data = await client.request(mutation, { input: { name: 'Test Token', symbol: 'TEST', @@ -88,4 +120,3 @@ describe('GraphQL API Integration Tests', () => { }); }); }); - diff --git a/test/emoney/api/integration/rest-api.test.ts b/test/emoney/api/integration/rest-api.test.ts index 5f714dd..8c36272 100644 --- a/test/emoney/api/integration/rest-api.test.ts +++ b/test/emoney/api/integration/rest-api.test.ts @@ -7,13 +7,14 @@ import axios from 'axios'; const BASE_URL = process.env.API_URL || 'http://localhost:3000'; const API_KEY = process.env.API_KEY || 'test-key'; +const runIntegration = process.env.RUN_EMONEY_API_INTEGRATION === '1'; +const describeIfConfigured = runIntegration ? describe : describe.skip; -describe('REST API Integration Tests', () => { +describeIfConfigured('REST API Integration Tests', () => { let accessToken: string; beforeAll(async () => { - // TODO: Get OAuth2 token - // accessToken = await getAccessToken(); + accessToken = process.env.ACCESS_TOKEN || 'test-token'; }); describe('Token Operations', () => { @@ -102,4 +103,3 @@ describe('REST API Integration Tests', () => { }); }); }); - diff --git a/test/emoney/api/package.json b/test/emoney/api/package.json index 215a1ac..155018c 100644 --- a/test/emoney/api/package.json +++ b/test/emoney/api/package.json @@ -4,6 +4,7 @@ "description": "API integration and contract tests", "scripts": { "test": "jest", + "test:ci": "jest --runInBand", "test:integration": "jest --testPathPattern=integration", "test:contract": "jest --testPathPattern=contract", "test:watch": "jest --watch" @@ -28,7 +29,10 @@ "preset": "ts-jest", "testEnvironment": "node", "testMatch": [ - "**/test/**/*.test.ts" + "**/*.test.ts" + ], + "testPathIgnorePatterns": [ + "/node_modules/" ], "collectCoverageFrom": [ "api/services/**/*.ts", @@ -37,4 +41,3 @@ ] } } - diff --git a/test/emoney/api/tsconfig.json b/test/emoney/api/tsconfig.json new file mode 100644 index 0000000..84e456c --- /dev/null +++ b/test/emoney/api/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": true, + "resolveJsonModule": true, + "strict": false, + "skipLibCheck": true, + "types": [ + "node", + "jest" + ] + }, + "include": [ + "./**/*.ts" + ] +} diff --git a/test/flash/AaveQuotePushFlashReceiverMainnetFork.t.sol b/test/flash/AaveQuotePushFlashReceiverMainnetFork.t.sol new file mode 100644 index 0000000..121d689 --- /dev/null +++ b/test/flash/AaveQuotePushFlashReceiverMainnetFork.t.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { + AaveQuotePushFlashReceiver, + IAaveExternalUnwinder +} from "../../contracts/flash/AaveQuotePushFlashReceiver.sol"; + +contract AaveForkMockExternalUnwinder is IAaveExternalUnwinder { + IERC20 public immutable base; + IERC20 public immutable quote; + uint256 public immutable numerator; + uint256 public immutable denominator; + + constructor(IERC20 base_, IERC20 quote_, uint256 numerator_, uint256 denominator_) { + base = base_; + quote = quote_; + numerator = numerator_; + denominator = denominator_; + } + + function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata) + external + override + returns (uint256 amountOut) + { + require(tokenIn == address(base), "base only"); + require(tokenOut == address(quote), "quote only"); + IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); + amountOut = amountIn * numerator / denominator; + require(amountOut >= minAmountOut, "min unwind"); + IERC20(address(quote)).transfer(msg.sender, amountOut); + } +} + +contract AaveQuotePushFlashReceiverMainnetForkTest is Test { + address constant AAVE_POOL_MAINNET = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2; + address constant DODO_PMM_INTEGRATION_MAINNET = 0xa9F284eD010f4F7d7F8F201742b49b9f58e29b84; + address constant POOL_CWUSDC_USDC = 0x69776fc607e9edA8042e320e7e43f54d06c68f0E; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant CWUSDC = 0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a; + + bool public forkAvailable; + AaveQuotePushFlashReceiver internal receiver; + AaveForkMockExternalUnwinder internal unwinder; + + modifier skipIfNoFork() { + if (!forkAvailable) { + return; + } + _; + } + + function setUp() public { + string memory rpcUrl = vm.envOr("ETHEREUM_MAINNET_RPC", string("")); + if (bytes(rpcUrl).length == 0) { + forkAvailable = false; + return; + } + + try vm.createSelectFork(rpcUrl) { + forkAvailable = true; + } catch { + forkAvailable = false; + return; + } + + receiver = new AaveQuotePushFlashReceiver(AAVE_POOL_MAINNET); + unwinder = new AaveForkMockExternalUnwinder(IERC20(CWUSDC), IERC20(USDC), 112, 100); + deal(USDC, address(unwinder), 100_000_000); // 100 USDC quote inventory for unwind payouts + } + + function testFork_aaveQuotePush_usesRealAaveAndRealMainnetPmm() public skipIfNoFork { + uint256 amount = 2_964_298; // current safe tranche at 120/120 + uint256 receiverQuoteBefore = IERC20(USDC).balanceOf(address(receiver)); + uint256 poolBaseBefore = IERC20(CWUSDC).balanceOf(POOL_CWUSDC_USDC); + uint256 poolQuoteBefore = IERC20(USDC).balanceOf(POOL_CWUSDC_USDC); + + AaveQuotePushFlashReceiver.QuotePushParams memory p = AaveQuotePushFlashReceiver.QuotePushParams({ + integration: DODO_PMM_INTEGRATION_MAINNET, + pmmPool: POOL_CWUSDC_USDC, + baseToken: CWUSDC, + externalUnwinder: address(unwinder), + minOutPmm: 2_800_000, + minOutUnwind: amount + 1_483, // 5 bps Aave premium + unwindData: bytes(""), + atomicBridge: AaveQuotePushFlashReceiver.AtomicBridgeParams({ + coordinator: address(0), + sourceChain: 0, + destinationChain: 0, + destinationAsset: address(0), + bridgeAmount: 0, + minDestinationAmount: 0, + destinationRecipient: address(0), + destinationDeadline: 0, + routeId: bytes32(0), + settlementMode: bytes32(0), + submitCommitment: false + }) + }); + + receiver.flashQuotePush(USDC, amount, p); + + uint256 receiverQuoteAfter = IERC20(USDC).balanceOf(address(receiver)); + uint256 poolBaseAfter = IERC20(CWUSDC).balanceOf(POOL_CWUSDC_USDC); + uint256 poolQuoteAfter = IERC20(USDC).balanceOf(POOL_CWUSDC_USDC); + + assertGt(receiverQuoteAfter, receiverQuoteBefore, "receiver retains surplus"); + assertEq(IERC20(CWUSDC).balanceOf(address(receiver)), 0, "base fully unwound"); + assertLt(poolBaseAfter, poolBaseBefore, "pool base decreased via quote push"); + assertGt(poolQuoteAfter, poolQuoteBefore, "pool quote increased via quote push"); + } +} diff --git a/test/flash/CrossChainFlashBorrower.t.sol b/test/flash/CrossChainFlashBorrower.t.sol new file mode 100644 index 0000000..9abd880 --- /dev/null +++ b/test/flash/CrossChainFlashBorrower.t.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol"; +import {SimpleERC3156FlashVault} from "../../contracts/flash/SimpleERC3156FlashVault.sol"; +import {CrossChainFlashBorrower} from "../../contracts/flash/CrossChainFlashBorrower.sol"; +import {ICrossChainFlashBridge} from "../../contracts/flash/interfaces/ICrossChainFlashBridge.sol"; + +contract MockCrossChainBridge is ICrossChainFlashBridge { + event BridgeCalled(address token, uint256 amount, uint64 dest, address recipient, bytes extra, uint256 value); + + function bridgeTokensFrom( + address token, + uint256 amount, + uint64 destinationChainSelector, + address recipientOnDestination, + bytes calldata extraData + ) external payable override returns (bytes32 messageId) { + IERC20(token).transferFrom(msg.sender, address(this), amount); + emit BridgeCalled(token, amount, destinationChainSelector, recipientOnDestination, extraData, msg.value); + messageId = keccak256(abi.encodePacked(block.number, token, amount)); + } +} + +contract MockERC20Mint is ERC20 { + constructor() ERC20("T", "T") {} + + function mint(address to, uint256 v) external { + _mint(to, v); + } +} + +contract CrossChainFlashBorrowerTest is Test { + SimpleERC3156FlashVault internal vault; + MockERC20Mint internal token; + MockCrossChainBridge internal bridge; + CrossChainFlashBorrower internal borrower; + + address internal owner = address(0xA11); + address internal user = address(0xB22); + + function setUp() public { + vm.startPrank(owner); + vault = new SimpleERC3156FlashVault(owner, 5); + token = new MockERC20Mint(); + token.mint(address(vault), 1_000_000e18); + vault.setTokenSupported(address(token), true); + vm.stopPrank(); + + bridge = new MockCrossChainBridge(); + borrower = new CrossChainFlashBorrower(address(vault)); + } + + function test_flashBridge_out_repaysFromPrefund() public { + uint256 amount = 40_000e18; + uint256 fee = vault.flashFee(address(token), amount); + uint256 bridgeAmount = amount; + + token.mint(address(borrower), bridgeAmount + fee); + + CrossChainFlashBorrower.CrossChainFlashParams memory p = CrossChainFlashBorrower.CrossChainFlashParams({ + bridge: address(bridge), + bridgeAmount: bridgeAmount, + destinationChainSelector: 123, + recipientOnDestination: address(0xbeef), + bridgeExtraData: hex"abcd", + nativeBridgeFee: 0 + }); + + vm.prank(user); + vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(token), amount, abi.encode(p)); + + assertEq(token.balanceOf(address(bridge)), bridgeAmount); + assertEq(vault.totalFeesCollected(address(token)), fee); + } +} diff --git a/test/flash/CrossChainFlashRepayReceiver.t.sol b/test/flash/CrossChainFlashRepayReceiver.t.sol new file mode 100644 index 0000000..caf2851 --- /dev/null +++ b/test/flash/CrossChainFlashRepayReceiver.t.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {CrossChainFlashRepayReceiver} from "../../contracts/flash/CrossChainFlashRepayReceiver.sol"; +import {IRouterClient} from "../../contracts/ccip/IRouterClient.sol"; + +contract MockToken is ERC20 { + constructor() ERC20("X", "X") {} + + function mint(address to, uint256 v) external { + _mint(to, v); + } +} + +contract CrossChainFlashRepayReceiverTest is Test { + CrossChainFlashRepayReceiver internal recv; + MockToken internal token; + address internal router; + + function setUp() public { + router = makeAddr("ccipRouter"); + recv = new CrossChainFlashRepayReceiver(router); + token = new MockToken(); + } + + function _msg(address beneficiary, bytes32 obligation, uint256 amt, address tok) + internal + pure + returns (IRouterClient.Any2EVMMessage memory m) + { + IRouterClient.TokenAmount[] memory amounts = new IRouterClient.TokenAmount[](1); + amounts[0] = IRouterClient.TokenAmount({token: tok, amount: amt, amountType: IRouterClient.TokenAmountType.Fiat}); + m = IRouterClient.Any2EVMMessage({ + messageId: keccak256("mid"), + sourceChainSelector: 138, + sender: abi.encode(address(0x111)), + data: abi.encode(beneficiary, obligation), + tokenAmounts: amounts + }); + } + + function test_ccipReceive_forwardsToRecipient() public { + address beneficiary = address(0xB0B); + bytes32 obligation = keccak256("obligation-1"); + uint256 amt = 777e18; + token.mint(address(recv), amt); + + IRouterClient.Any2EVMMessage memory message = _msg(beneficiary, obligation, amt, address(token)); + + vm.prank(router); + recv.ccipReceive(message); + + assertEq(token.balanceOf(beneficiary), amt); + } + + function test_ccipReceive_revert_notRouter() public { + IRouterClient.Any2EVMMessage memory message = _msg(address(0x1), bytes32(0), 1, address(token)); + vm.expectRevert(CrossChainFlashRepayReceiver.OnlyRouter.selector); + recv.ccipReceive(message); + } +} diff --git a/test/flash/CrossChainFlashVaultCreditReceiver.t.sol b/test/flash/CrossChainFlashVaultCreditReceiver.t.sol new file mode 100644 index 0000000..376542a --- /dev/null +++ b/test/flash/CrossChainFlashVaultCreditReceiver.t.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {CrossChainFlashVaultCreditReceiver} from "../../contracts/flash/CrossChainFlashVaultCreditReceiver.sol"; +import {IRouterClient} from "../../contracts/ccip/IRouterClient.sol"; + +contract CreditMockToken is ERC20 { + constructor() ERC20("Y", "Y") {} + + function mint(address to, uint256 v) external { + _mint(to, v); + } +} + +contract CrossChainFlashVaultCreditReceiverTest is Test { + CrossChainFlashVaultCreditReceiver internal recv; + CreditMockToken internal token; + address internal router; + + function setUp() public { + router = makeAddr("ccipRouterCredit"); + recv = new CrossChainFlashVaultCreditReceiver(router); + token = new CreditMockToken(); + } + + function _msg(address vault, uint256 amt, address tok) internal pure returns (IRouterClient.Any2EVMMessage memory m) { + IRouterClient.TokenAmount[] memory amounts = new IRouterClient.TokenAmount[](1); + amounts[0] = IRouterClient.TokenAmount({token: tok, amount: amt, amountType: IRouterClient.TokenAmountType.Fiat}); + m = IRouterClient.Any2EVMMessage({ + messageId: keccak256("credit-mid"), + sourceChainSelector: 50, + sender: abi.encode(address(0x222)), + data: abi.encode(vault), + tokenAmounts: amounts + }); + } + + function test_ccipReceive_creditsVault() public { + address vaultAddr = address(0xF1A5); + uint256 amt = 333e18; + token.mint(address(recv), amt); + + IRouterClient.Any2EVMMessage memory message = _msg(vaultAddr, amt, address(token)); + + vm.prank(router); + recv.ccipReceive(message); + + assertEq(token.balanceOf(vaultAddr), amt); + } + + function test_ccipReceive_revert_notRouter() public { + IRouterClient.Any2EVMMessage memory message = _msg(address(0x1), 1, address(token)); + vm.expectRevert(CrossChainFlashVaultCreditReceiver.OnlyRouter.selector); + recv.ccipReceive(message); + } +} diff --git a/test/flash/DODOIntegrationExternalUnwinderMainnetFork.t.sol b/test/flash/DODOIntegrationExternalUnwinderMainnetFork.t.sol new file mode 100644 index 0000000..0d726c5 --- /dev/null +++ b/test/flash/DODOIntegrationExternalUnwinderMainnetFork.t.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {DODOIntegrationExternalUnwinder} from "../../contracts/flash/DODOIntegrationExternalUnwinder.sol"; + +contract DODOIntegrationExternalUnwinderMainnetForkTest is Test { + address constant DODO_PMM_INTEGRATION_MAINNET = 0xa9F284eD010f4F7d7F8F201742b49b9f58e29b84; + address constant POOL_CWUSDC_USDC = 0x69776fc607e9edA8042e320e7e43f54d06c68f0E; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant CWUSDC = 0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a; + + bool public forkAvailable; + DODOIntegrationExternalUnwinder internal unwinder; + + modifier skipIfNoFork() { + if (!forkAvailable) { + return; + } + _; + } + + function setUp() public { + string memory rpcUrl = vm.envOr("ETHEREUM_MAINNET_RPC", string("")); + if (bytes(rpcUrl).length == 0) { + forkAvailable = false; + return; + } + try vm.createSelectFork(rpcUrl) { + forkAvailable = true; + } catch { + forkAvailable = false; + return; + } + + unwinder = new DODOIntegrationExternalUnwinder(DODO_PMM_INTEGRATION_MAINNET); + } + + function testFork_cWUSDCToUSDC_unwindsThroughMainnetDodoIntegration() public skipIfNoFork { + uint256 amountIn = 1_000_000; // 1 cWUSDC + deal(CWUSDC, address(unwinder), amountIn); + + uint256 before = IERC20(USDC).balanceOf(address(this)); + uint256 amountOut = unwinder.unwind(CWUSDC, USDC, amountIn, 1, abi.encode(POOL_CWUSDC_USDC)); + uint256 afterBal = IERC20(USDC).balanceOf(address(this)); + + assertGt(amountOut, 0, "amountOut > 0"); + assertEq(afterBal - before, amountOut, "USDC received"); + } +} diff --git a/test/flash/MinimalERC3156FlashBorrower.t.sol b/test/flash/MinimalERC3156FlashBorrower.t.sol new file mode 100644 index 0000000..d461517 --- /dev/null +++ b/test/flash/MinimalERC3156FlashBorrower.t.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SimpleERC3156FlashVault} from "../../contracts/flash/SimpleERC3156FlashVault.sol"; +import {MinimalERC3156FlashBorrower} from "../../contracts/flash/MinimalERC3156FlashBorrower.sol"; + +contract MockERC20Mint is ERC20 { + constructor() ERC20("Mock", "MCK") {} + + function mint(address to, uint256 v) external { + _mint(to, v); + } +} + +contract MinimalERC3156FlashBorrowerTest is Test { + SimpleERC3156FlashVault internal vault; + MockERC20Mint internal token; + MinimalERC3156FlashBorrower internal borrower; + + address internal owner = address(0xA11); + address internal user = address(0xB22); + + function setUp() public { + vm.startPrank(owner); + vault = new SimpleERC3156FlashVault(owner, 5); + token = new MockERC20Mint(); + token.mint(address(vault), 1_000_000e18); + vault.setTokenSupported(address(token), true); + vm.stopPrank(); + + borrower = new MinimalERC3156FlashBorrower(address(vault)); + } + + function test_oneUnitFlash_repayWithPrefundedFee() public { + uint256 amount = 1000e18; + uint256 fee = vault.flashFee(address(token), amount); + token.mint(address(borrower), fee); + + uint256 vaultBefore = token.balanceOf(address(vault)); + vm.prank(user); + vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(token), amount, ""); + + assertEq(token.balanceOf(address(vault)), vaultBefore + fee); + } + + function test_onFlashLoan_revert_wrongLender() public { + vm.expectRevert(MinimalERC3156FlashBorrower.UntrustedLender.selector); + borrower.onFlashLoan(user, address(token), 1, 0, ""); + } +} diff --git a/test/flash/QuotePushFlashWorkflowBorrower.t.sol b/test/flash/QuotePushFlashWorkflowBorrower.t.sol new file mode 100644 index 0000000..485b5c7 --- /dev/null +++ b/test/flash/QuotePushFlashWorkflowBorrower.t.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol"; +import {SimpleERC3156FlashVault} from "../../contracts/flash/SimpleERC3156FlashVault.sol"; +import { + QuotePushFlashWorkflowBorrower, + IDODOQuotePushSwapExactIn, + IExternalUnwinder +} from "../../contracts/flash/QuotePushFlashWorkflowBorrower.sol"; + +contract MockQuoteToken is ERC20 { + constructor(string memory n, string memory s) ERC20(n, s) {} + + function mint(address to, uint256 v) external { + _mint(to, v); + } +} + +/// @notice Simple PMM-like mock: quote in, base out at a fixed ratio. +contract MockQuotePushIntegration is IDODOQuotePushSwapExactIn { + IERC20 public immutable quote; + IERC20 public immutable base; + uint256 public immutable numerator; + uint256 public immutable denominator; + + constructor(IERC20 quote_, IERC20 base_, uint256 numerator_, uint256 denominator_) { + quote = quote_; + base = base_; + numerator = numerator_; + denominator = denominator_; + } + + function swapExactIn(address, address tokenIn, uint256 amountIn, uint256 minAmountOut) + external + override + returns (uint256 amountOut) + { + require(tokenIn == address(quote), "quote only"); + IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); + amountOut = amountIn * numerator / denominator; + require(amountOut >= minAmountOut, "min pmm"); + IERC20(address(base)).transfer(msg.sender, amountOut); + } +} + +/// @notice Simple external unwinder: base in, quote out at a fixed ratio. +contract MockExternalUnwinder is IExternalUnwinder { + IERC20 public immutable quote; + IERC20 public immutable base; + uint256 public immutable numerator; + uint256 public immutable denominator; + + constructor(IERC20 base_, IERC20 quote_, uint256 numerator_, uint256 denominator_) { + base = base_; + quote = quote_; + numerator = numerator_; + denominator = denominator_; + } + + function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata) + external + override + returns (uint256 amountOut) + { + require(tokenIn == address(base), "base only"); + require(tokenOut == address(quote), "quote only"); + IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); + amountOut = amountIn * numerator / denominator; + require(amountOut >= minAmountOut, "min unwind"); + IERC20(address(quote)).transfer(msg.sender, amountOut); + } +} + +contract QuotePushFlashWorkflowBorrowerTest is Test { + SimpleERC3156FlashVault internal vault; + MockQuoteToken internal usdc; + MockQuoteToken internal cwusdc; + MockQuotePushIntegration internal pmm; + MockExternalUnwinder internal unwinder; + QuotePushFlashWorkflowBorrower internal borrower; + + address internal owner = address(0xA11); + address internal user = address(0xB22); + + function setUp() public { + vm.startPrank(owner); + vault = new SimpleERC3156FlashVault(owner, 5); + usdc = new MockQuoteToken("USDC", "USDC"); + cwusdc = new MockQuoteToken("cWUSDC", "cWUSDC"); + usdc.mint(address(vault), 1_000_000e6); + vault.setTokenSupported(address(usdc), true); + vm.stopPrank(); + + // PMM leg: slightly under 1:1, like a fee-bearing quote->base purchase. + pmm = new MockQuotePushIntegration(usdc, cwusdc, 9997, 10000); + // External unwind: profitable 1.12x unwind. + unwinder = new MockExternalUnwinder(cwusdc, usdc, 112, 100); + borrower = new QuotePushFlashWorkflowBorrower(address(vault)); + + cwusdc.mint(address(pmm), 10_000_000e6); + usdc.mint(address(unwinder), 10_000_000e6); + } + + function test_quotePushRoundTrip_repayAndRetainSurplus() public { + uint256 amount = 4_145_894; // 4.145894 USDC + uint256 fee = vault.flashFee(address(usdc), amount); + + QuotePushFlashWorkflowBorrower.QuotePushParams memory p = QuotePushFlashWorkflowBorrower.QuotePushParams({ + integration: address(pmm), + pool: address(0x69776fc607e9edA8042e320e7e43f54d06c68f0E), + baseToken: address(cwusdc), + externalUnwinder: address(unwinder), + minOutPmm: 4_100_000, + minOutUnwind: amount + fee, + unwindData: bytes("") + }); + + uint256 feesBefore = vault.totalFeesCollected(address(usdc)); + vm.prank(user); + vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(usdc), amount, abi.encode(p)); + + uint256 feesAfter = vault.totalFeesCollected(address(usdc)); + uint256 borrowerSurplus = usdc.balanceOf(address(borrower)); + + assertEq(feesAfter - feesBefore, fee, "flash fee collected"); + assertGt(borrowerSurplus, 0, "surplus retained"); + assertEq(cwusdc.balanceOf(address(borrower)), 0, "base fully unwound"); + } + + function test_quotePushRoundTrip_revert_whenUnwindCannotRepay() public { + uint256 amount = 4_145_894; // 4.145894 USDC + + // Replace profitable unwinder with a losing one. + MockExternalUnwinder badUnwinder = new MockExternalUnwinder(cwusdc, usdc, 95, 100); + usdc.mint(address(badUnwinder), 10_000_000e6); + + QuotePushFlashWorkflowBorrower.QuotePushParams memory p = QuotePushFlashWorkflowBorrower.QuotePushParams({ + integration: address(pmm), + pool: address(0x69776fc607e9edA8042e320e7e43f54d06c68f0E), + baseToken: address(cwusdc), + externalUnwinder: address(badUnwinder), + minOutPmm: 4_100_000, + minOutUnwind: 1, + unwindData: bytes("") + }); + + vm.expectRevert(QuotePushFlashWorkflowBorrower.InsufficientToRepay.selector); + vm.prank(user); + vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(usdc), amount, abi.encode(p)); + } +} diff --git a/test/flash/QuotePushFlashWorkflowBorrowerMainnetFork.t.sol b/test/flash/QuotePushFlashWorkflowBorrowerMainnetFork.t.sol new file mode 100644 index 0000000..2e80dce --- /dev/null +++ b/test/flash/QuotePushFlashWorkflowBorrowerMainnetFork.t.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol"; +import {SimpleERC3156FlashVault} from "../../contracts/flash/SimpleERC3156FlashVault.sol"; +import { + QuotePushFlashWorkflowBorrower, + IExternalUnwinder +} from "../../contracts/flash/QuotePushFlashWorkflowBorrower.sol"; + +contract ForkMockExternalUnwinder is IExternalUnwinder { + IERC20 public immutable base; + IERC20 public immutable quote; + uint256 public immutable numerator; + uint256 public immutable denominator; + + constructor(IERC20 base_, IERC20 quote_, uint256 numerator_, uint256 denominator_) { + base = base_; + quote = quote_; + numerator = numerator_; + denominator = denominator_; + } + + function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata) + external + override + returns (uint256 amountOut) + { + require(tokenIn == address(base), "base only"); + require(tokenOut == address(quote), "quote only"); + IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); + amountOut = amountIn * numerator / denominator; + require(amountOut >= minAmountOut, "min unwind"); + IERC20(address(quote)).transfer(msg.sender, amountOut); + } +} + +contract QuotePushFlashWorkflowBorrowerMainnetForkTest is Test { + address constant DODO_PMM_INTEGRATION_MAINNET = 0xa9F284eD010f4F7d7F8F201742b49b9f58e29b84; + address constant POOL_CWUSDC_USDC = 0x69776fc607e9edA8042e320e7e43f54d06c68f0E; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant CWUSDC = 0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a; + + bool public forkAvailable; + SimpleERC3156FlashVault internal vault; + QuotePushFlashWorkflowBorrower internal borrower; + ForkMockExternalUnwinder internal unwinder; + + modifier skipIfNoFork() { + if (!forkAvailable) { + return; + } + _; + } + + function setUp() public { + string memory rpcUrl = vm.envOr("ETHEREUM_MAINNET_RPC", string("")); + if (bytes(rpcUrl).length == 0) { + forkAvailable = false; + return; + } + + try vm.createSelectFork(rpcUrl) { + forkAvailable = true; + } catch { + forkAvailable = false; + return; + } + + vault = new SimpleERC3156FlashVault(address(this), 5); + vault.setTokenSupported(USDC, true); + + borrower = new QuotePushFlashWorkflowBorrower(address(vault)); + unwinder = new ForkMockExternalUnwinder(IERC20(CWUSDC), IERC20(USDC), 112, 100); + + // Seed the local lender and unwind venue with enough quote on the fork. + deal(USDC, address(vault), 100_000_000); // 100 USDC + deal(USDC, address(unwinder), 100_000_000); // 100 USDC + } + + function testFork_quotePush_usesLiveMainnetPmmLegAndRepays() public skipIfNoFork { + uint256 amount = 2_964_298; // live safe tranche from 120/120 under 500 bps cap + uint256 fee = vault.flashFee(USDC, amount); + + QuotePushFlashWorkflowBorrower.QuotePushParams memory p = QuotePushFlashWorkflowBorrower.QuotePushParams({ + integration: DODO_PMM_INTEGRATION_MAINNET, + pool: POOL_CWUSDC_USDC, + baseToken: CWUSDC, + externalUnwinder: address(unwinder), + minOutPmm: 2_800_000, + minOutUnwind: amount + fee, + unwindData: bytes("") + }); + + uint256 vaultBefore = IERC20(USDC).balanceOf(address(vault)); + uint256 poolBaseBefore = IERC20(CWUSDC).balanceOf(POOL_CWUSDC_USDC); + uint256 poolQuoteBefore = IERC20(USDC).balanceOf(POOL_CWUSDC_USDC); + + vault.flashLoan(IERC3156FlashBorrower(address(borrower)), USDC, amount, abi.encode(p)); + + uint256 vaultAfter = IERC20(USDC).balanceOf(address(vault)); + uint256 borrowerSurplus = IERC20(USDC).balanceOf(address(borrower)); + uint256 poolBaseAfter = IERC20(CWUSDC).balanceOf(POOL_CWUSDC_USDC); + uint256 poolQuoteAfter = IERC20(USDC).balanceOf(POOL_CWUSDC_USDC); + + assertEq(vaultAfter, vaultBefore + fee, "vault repaid plus fee"); + assertGt(borrowerSurplus, 0, "borrower retains quote surplus"); + assertEq(IERC20(CWUSDC).balanceOf(address(borrower)), 0, "all base unwound"); + assertLt(poolBaseAfter, poolBaseBefore, "pool base decreased via quote push"); + assertGt(poolQuoteAfter, poolQuoteBefore, "pool quote increased via quote push"); + } +} diff --git a/test/flash/SimpleERC3156FlashVault.t.sol b/test/flash/SimpleERC3156FlashVault.t.sol new file mode 100644 index 0000000..2dff2df --- /dev/null +++ b/test/flash/SimpleERC3156FlashVault.t.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SimpleERC3156FlashVault} from "../../contracts/flash/SimpleERC3156FlashVault.sol"; + +contract MockBorrower is IERC3156FlashBorrower { + bytes32 private constant _RETURN_VALUE = keccak256("ERC3156FlashBorrower.onFlashLoan"); + + function onFlashLoan(address, address token, uint256 amount, uint256 fee, bytes calldata) + external + override + returns (bytes32) + { + IERC20(token).transfer(msg.sender, amount + fee); + return _RETURN_VALUE; + } +} + +contract MockERC20Mint is ERC20 { + constructor() ERC20("Mock", "MCK") {} + + function mint(address to, uint256 v) external { + _mint(to, v); + } +} + +contract SimpleERC3156FlashVaultTest is Test { + SimpleERC3156FlashVault internal vault; + MockERC20Mint internal token; + MockBorrower internal borrower; + + address internal owner = address(0xA11); + address internal user = address(0xB22); + + function setUp() public { + vm.startPrank(owner); + vault = new SimpleERC3156FlashVault(owner, 5); // 0.05% + token = new MockERC20Mint(); + token.mint(address(vault), 1_000_000e18); + vault.setTokenSupported(address(token), true); + vm.stopPrank(); + + borrower = new MockBorrower(); + token.mint(address(borrower), 100e18); + vm.prank(address(borrower)); + token.approve(address(vault), type(uint256).max); + } + + function test_maxFlashLoan() public view { + assertEq(vault.maxFlashLoan(address(token)), 1_000_000e18); + } + + function test_flashFee() public view { + assertEq(vault.flashFee(address(token), 100_000e18), (100_000e18 * 5) / 10_000); + } + + function test_previewFlashFee_matches_flashFee() public view { + uint256 a = 123_456e18; + assertEq(vault.previewFlashFee(address(token), a), vault.flashFee(address(token), a)); + } + + function test_flashLoan_happyPath() public { + uint256 amount = 100_000e18; + uint256 fee = vault.flashFee(address(token), amount); + uint256 beforeBal = token.balanceOf(address(vault)); + + vm.prank(user); + vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(token), amount, ""); + + assertEq(token.balanceOf(address(vault)), beforeBal + fee); + assertEq(vault.totalFeesCollected(address(token)), fee); + } + + function test_flashLoan_revert_unsupported() public { + MockERC20Mint other = new MockERC20Mint(); + vm.expectRevert(SimpleERC3156FlashVault.UnsupportedToken.selector); + vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(other), 1, ""); + } + + function test_setFeeBps_revert_above_max() public { + vm.prank(owner); + vm.expectRevert(SimpleERC3156FlashVault.FeeTooHigh.selector); + vault.setFeeBps(1001); + } + + function test_rescueTokens() public { + vm.prank(owner); + vault.rescueTokens(address(token), 10e18, owner); + assertEq(token.balanceOf(owner), 10e18); + } + + function test_borrowerAllowlist_revert_unapproved() public { + vm.prank(owner); + vault.setBorrowerAllowlistEnabled(true); + + vm.expectRevert(SimpleERC3156FlashVault.BorrowerNotApproved.selector); + vm.prank(user); + vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(token), 1e18, ""); + } + + function test_borrowerAllowlist_allows_approved() public { + vm.startPrank(owner); + vault.setBorrowerAllowlistEnabled(true); + vault.setBorrowerApproved(address(borrower), true); + vm.stopPrank(); + + uint256 amount = 1000e18; + uint256 fee = vault.flashFee(address(token), amount); + uint256 beforeBal = token.balanceOf(address(vault)); + + vm.prank(user); + vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(token), amount, ""); + + assertEq(token.balanceOf(address(vault)), beforeBal + fee); + } +} diff --git a/test/flash/SwapFlashWorkflowBorrower.t.sol b/test/flash/SwapFlashWorkflowBorrower.t.sol new file mode 100644 index 0000000..5652047 --- /dev/null +++ b/test/flash/SwapFlashWorkflowBorrower.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol"; +import {SimpleERC3156FlashVault} from "../../contracts/flash/SimpleERC3156FlashVault.sol"; +import {SwapFlashWorkflowBorrower, IDODOStyleSwapExactIn} from "../../contracts/flash/SwapFlashWorkflowBorrower.sol"; + +/// @notice 1:1 swap router for tests (ignores pool). +contract MockSwapRouter1to1 is IDODOStyleSwapExactIn { + ERC20 public immutable tokenA; + ERC20 public immutable tokenB; + + constructor(ERC20 a, ERC20 b) { + tokenA = a; + tokenB = b; + } + + function swapExactIn(address, address tokenIn, uint256 amountIn, uint256 minAmountOut) + external + override + returns (uint256 amountOut) + { + IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); + address tokenOut = tokenIn == address(tokenA) ? address(tokenB) : address(tokenA); + amountOut = amountIn; + require(amountOut >= minAmountOut, "min"); + IERC20(tokenOut).transfer(msg.sender, amountOut); + } +} + +contract MockERC20Mint is ERC20 { + constructor(string memory n, string memory s) ERC20(n, s) {} + + function mint(address to, uint256 v) external { + _mint(to, v); + } +} + +contract SwapFlashWorkflowBorrowerTest is Test { + SimpleERC3156FlashVault internal vault; + MockERC20Mint internal tokenA; + MockERC20Mint internal tokenB; + MockSwapRouter1to1 internal router; + SwapFlashWorkflowBorrower internal borrower; + + address internal owner = address(0xA11); + address internal user = address(0xB22); + + function setUp() public { + vm.startPrank(owner); + vault = new SimpleERC3156FlashVault(owner, 5); + tokenA = new MockERC20Mint("A", "A"); + tokenB = new MockERC20Mint("B", "B"); + tokenA.mint(address(vault), 1_000_000e18); + vault.setTokenSupported(address(tokenA), true); + vm.stopPrank(); + + router = new MockSwapRouter1to1(tokenA, tokenB); + tokenA.mint(address(router), 10_000_000e18); + tokenB.mint(address(router), 10_000_000e18); + + borrower = new SwapFlashWorkflowBorrower(address(vault)); + } + + function test_roundTripSwap_prefundFeeInBorrowedToken() public { + uint256 amount = 50_000e18; + uint256 fee = vault.flashFee(address(tokenA), amount); + tokenA.mint(address(borrower), fee); + + SwapFlashWorkflowBorrower.SwapFlashParams memory p = SwapFlashWorkflowBorrower.SwapFlashParams({ + integration: address(router), + pool: address(0xdead), + midToken: address(tokenB), + minOutFirst: amount, + minOutSecond: amount + }); + bytes memory data = abi.encode(p); + + vm.prank(user); + vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(tokenA), amount, data); + + assertEq(vault.totalFeesCollected(address(tokenA)), fee); + } +} diff --git a/test/flash/UniswapV3ExternalUnwinderFork.t.sol b/test/flash/UniswapV3ExternalUnwinderFork.t.sol new file mode 100644 index 0000000..9165730 --- /dev/null +++ b/test/flash/UniswapV3ExternalUnwinderFork.t.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {UniswapV3ExternalUnwinder} from "../../contracts/flash/UniswapV3ExternalUnwinder.sol"; + +contract UniswapV3ExternalUnwinderForkTest is Test { + address constant UNISWAP_V3_ROUTER = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45; + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant CWUSDC = 0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a; + + bool public forkAvailable; + UniswapV3ExternalUnwinder internal unwinder; + + function setUp() public { + string memory rpcUrl = vm.envOr("ETHEREUM_MAINNET_RPC", string("")); + if (bytes(rpcUrl).length == 0) { + forkAvailable = false; + return; + } + try vm.createSelectFork(rpcUrl) { + forkAvailable = true; + } catch { + forkAvailable = false; + return; + } + + unwinder = new UniswapV3ExternalUnwinder(UNISWAP_V3_ROUTER); + } + + modifier skipIfNoFork() { + if (!forkAvailable) { + return; + } + _; + } + + function testFork_knownRoute_WETHToUSDC_singleHopWorks() public skipIfNoFork { + deal(WETH, address(unwinder), 1 ether); + uint256 before = IERC20(USDC).balanceOf(address(this)); + + uint256 amountOut = unwinder.unwind(WETH, USDC, 1 ether, 1, abi.encode(uint24(3000))); + + uint256 afterBal = IERC20(USDC).balanceOf(address(this)); + assertGt(amountOut, 0, "amountOut > 0"); + assertEq(afterBal - before, amountOut, "USDC received"); + } + + function testFork_cWUSDCToUSDC_routeUnavailableOnUniswapV3() public skipIfNoFork { + deal(CWUSDC, address(unwinder), 1_000_000); + + vm.expectRevert(); + unwinder.unwind(CWUSDC, USDC, 1_000_000, 1, abi.encode(uint24(3000))); + } +} diff --git a/test/flash/UniversalCCIPFlashBridgeAdapter.t.sol b/test/flash/UniversalCCIPFlashBridgeAdapter.t.sol new file mode 100644 index 0000000..e4259cc --- /dev/null +++ b/test/flash/UniversalCCIPFlashBridgeAdapter.t.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {UniversalCCIPBridge} from "../../contracts/bridge/UniversalCCIPBridge.sol"; +import {UniversalCCIPFlashBridgeAdapter} from "../../contracts/flash/UniversalCCIPFlashBridgeAdapter.sol"; + +/// @dev Minimal stand-in for `UniversalCCIPBridge.bridge` (pulls token, records op). +contract FlashBridgeAdapterTestMock { + event BridgeCalled(address token, uint256 amount, uint64 dest, address recipient, bytes32 assetType, bool pmm, bool vault); + + function bridge(UniversalCCIPBridge.BridgeOperation calldata op) external payable returns (bytes32) { + IERC20(op.token).transferFrom(msg.sender, address(this), op.amount); + emit BridgeCalled( + op.token, op.amount, op.destinationChain, op.recipient, op.assetType, op.usePMM, op.useVault + ); + return keccak256(abi.encodePacked("mock", op.token, op.amount)); + } + + receive() external payable {} +} + +contract MockMintERC20 is ERC20 { + constructor() ERC20("A", "A") {} + + function mint(address to, uint256 v) external { + _mint(to, v); + } +} + +contract UniversalCCIPFlashBridgeAdapterTest is Test { + FlashBridgeAdapterTestMock internal uni; + UniversalCCIPFlashBridgeAdapter internal adapter; + MockMintERC20 internal token; + + address internal alice = address(0xA11CE); + + function setUp() public { + uni = new FlashBridgeAdapterTestMock(); + adapter = new UniversalCCIPFlashBridgeAdapter(address(uni)); + token = new MockMintERC20(); + token.mint(alice, 500e18); + vm.deal(alice, 10 ether); + } + + function test_adapter_pullsAndCallsBridge_emptyExtraData() public { + vm.startPrank(alice); + token.approve(address(adapter), 100e18); + bytes32 mid = adapter.bridgeTokensFrom{value: 1 wei}(address(token), 100e18, 7, address(0xBEEF), ""); + vm.stopPrank(); + + assertTrue(mid != bytes32(0)); + assertEq(token.balanceOf(address(uni)), 100e18); + } + + function test_adapter_decodesExtraData() public { + bytes memory extra = abi.encode(bytes32(uint256(1)), true, false, bytes("p"), bytes("v")); + vm.startPrank(alice); + token.approve(address(adapter), 50e18); + adapter.bridgeTokensFrom(address(token), 50e18, 99, address(0xCAFE), extra); + vm.stopPrank(); + + assertEq(token.balanceOf(address(uni)), 50e18); + } +} diff --git a/test/integration/GRUCompliantTokensRegistry.t.sol b/test/integration/GRUCompliantTokensRegistry.t.sol index 66fc263..ebf9f37 100644 --- a/test/integration/GRUCompliantTokensRegistry.t.sol +++ b/test/integration/GRUCompliantTokensRegistry.t.sol @@ -7,6 +7,7 @@ import "../../contracts/registry/GRUAssetRegistryFacet.sol"; import "../../contracts/tokens/CompliantUSDT.sol"; import "../../contracts/tokens/CompliantUSDC.sol"; import "../../contracts/tokens/CompliantFiatToken.sol"; +import "../../contracts/tokens/CompliantBTC.sol"; import "../../contracts/bridge/GRUCCIPBridge.sol"; import "../../contracts/bridge/UniversalCCIPBridge.sol"; import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; @@ -21,6 +22,7 @@ contract GRUCompliantTokensRegistryTest is Test { CompliantUSDT public cusdt; CompliantUSDC public cusdc; CompliantFiatToken public ceurc; + CompliantBTC public cbtc; address public admin; @@ -44,6 +46,7 @@ contract GRUCompliantTokensRegistryTest is Test { admin, 1_000_000 * 10**6 ); + cbtc = new CompliantBTC(admin, admin, 21_000_000 * 10**8); facet = new GRUAssetRegistryFacet(); facet.setRegistry(address(registry)); @@ -94,6 +97,17 @@ contract GRUCompliantTokensRegistryTest is Test { assertEq(viaFacet.symbol, "cUSDT"); } + function test_registerGRUMonetaryUnitAsset_cBTC() public { + vm.prank(admin); + registry.registerGRUCompliantAsset(address(cbtc), "Bitcoin (Compliant)", "cBTC", 8, "International"); + + assertTrue(registry.isAssetActive(address(cbtc))); + assertEq(uint256(registry.getAssetType(address(cbtc))), uint256(UniversalAssetRegistry.AssetType.GRU)); + UniversalAssetRegistry.UniversalAsset memory asset = registry.getAsset(address(cbtc)); + assertEq(asset.symbol, "cBTC"); + assertEq(asset.decimals, 8); + } + function test_GRUCCIPBridge_accepts_registered_GRU_asset() public { address mockRouter = makeAddr("router"); vm.startPrank(admin); diff --git a/test/reserve/StablecoinReserveVault.t.sol b/test/reserve/StablecoinReserveVault.t.sol new file mode 100644 index 0000000..ed75117 --- /dev/null +++ b/test/reserve/StablecoinReserveVault.t.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; + +import {OfficialStableMirrorToken} from "../../contracts/tokens/OfficialStableMirrorToken.sol"; +import {CompliantUSDT} from "../../contracts/tokens/CompliantUSDT.sol"; +import {CompliantUSDC} from "../../contracts/tokens/CompliantUSDC.sol"; +import {StablecoinReserveVault} from "../../contracts/reserve/StablecoinReserveVault.sol"; + +contract StablecoinReserveVaultTest is Test { + address internal user = address(0xBEEF); + + OfficialStableMirrorToken internal officialUsdt; + OfficialStableMirrorToken internal officialUsdc; + CompliantUSDT internal compliantUsdt; + CompliantUSDC internal compliantUsdc; + StablecoinReserveVault internal vault; + + function setUp() public { + officialUsdt = new OfficialStableMirrorToken("Tether USD (Chain 138)", "USDT", 6, address(this), 0); + officialUsdc = new OfficialStableMirrorToken("USD Coin (Chain 138)", "USDC", 6, address(this), 0); + compliantUsdt = new CompliantUSDT(address(this), address(this)); + compliantUsdc = new CompliantUSDC(address(this), address(this)); + vault = new StablecoinReserveVault( + address(this), + address(officialUsdt), + address(officialUsdc), + address(compliantUsdt), + address(compliantUsdc) + ); + } + + function testSeedReserveBootstrapsBackingForExistingCanonicalSupply() public { + uint256 existingCusdtSupply = compliantUsdt.totalSupply(); + uint256 existingCusdcSupply = compliantUsdc.totalSupply(); + + officialUsdt.mint(address(this), existingCusdtSupply); + officialUsdc.mint(address(this), existingCusdcSupply); + + officialUsdt.approve(address(vault), existingCusdtSupply); + officialUsdc.approve(address(vault), existingCusdcSupply); + + vault.seedUSDTReserve(existingCusdtSupply); + vault.seedUSDCReserve(existingCusdcSupply); + + (uint256 usdtReserve, uint256 usdtSupply, uint256 usdtBackingRatio) = + vault.getBackingRatio(address(compliantUsdt)); + (uint256 usdcReserve, uint256 usdcSupply, uint256 usdcBackingRatio) = + vault.getBackingRatio(address(compliantUsdc)); + + assertEq(usdtReserve, existingCusdtSupply); + assertEq(usdtSupply, existingCusdtSupply); + assertEq(usdtBackingRatio, 10000); + + assertEq(usdcReserve, existingCusdcSupply); + assertEq(usdcSupply, existingCusdcSupply); + assertEq(usdcBackingRatio, 10000); + + (bool usdtAdequate, bool usdcAdequate) = vault.checkReserveAdequacy(); + assertTrue(usdtAdequate); + assertTrue(usdcAdequate); + } + + function testDepositAndRedeemWorkAfterVaultTakesCompliantOwnership() public { + uint256 depositAmount = 100e6; + uint256 redeemAmount = 40e6; + + compliantUsdt.transferOwnership(address(vault)); + + officialUsdt.mint(user, depositAmount); + + vm.startPrank(user); + officialUsdt.approve(address(vault), depositAmount); + vault.depositUSDT(depositAmount); + assertEq(compliantUsdt.balanceOf(user), depositAmount); + assertEq(officialUsdt.balanceOf(user), 0); + + compliantUsdt.approve(address(vault), redeemAmount); + vault.redeemUSDT(redeemAmount); + vm.stopPrank(); + + assertEq(compliantUsdt.balanceOf(user), depositAmount - redeemAmount); + assertEq(officialUsdt.balanceOf(user), redeemAmount); + assertEq(vault.usdtReserveBalance(), depositAmount - redeemAmount); + } + + function testVaultAdminCanRecoverCompliantTokenOwnership() public { + address recoveryOwner = address(0xCAFE); + + compliantUsdc.transferOwnership(address(vault)); + assertEq(compliantUsdc.owner(), address(vault)); + + vault.transferCompliantTokenOwnership(address(compliantUsdc), recoveryOwner); + assertEq(compliantUsdc.owner(), recoveryOwner); + } +}