diff --git a/config/address-inventory.chain138.json b/config/address-inventory.chain138.json index 7a1da4b..532246f 100644 --- a/config/address-inventory.chain138.json +++ b/config/address-inventory.chain138.json @@ -58,6 +58,7 @@ "ORACLE_PROXY_ADDRESS": "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6", "VAULT_FACTORY": "0xB2Ac70f35A81481B005067ed6567a5043BA32336", "RESERVE_SYSTEM": "0x607e97cD626f209facfE48c1464815DDE15B5093", + "RESERVE_CONVERSION_PRICE_ADAPTER": "0xA740EFF8C1339ea46833A6A968F24f3183a503C4", "ORACLE_PRICE_FEED": "0x8918eE0819fD687f4eb3e8b9B7D0ef7557493cfa", "CHAIN138_WETH_MOCK_PRICE_FEED": "0x3e8725b8De386feF3eFE5678c92eA6aDB41992B2", "RESERVE_TOKEN_INTEGRATION": "0x34B73e6EDFd9f85a7c25EeD31dcB13aB6E969b96", diff --git a/config/hybx-omnl-cross-chain-lines.json b/config/hybx-omnl-cross-chain-lines.json index 8be680e..10d0d1f 100644 --- a/config/hybx-omnl-cross-chain-lines.json +++ b/config/hybx-omnl-cross-chain-lines.json @@ -1,4 +1,482 @@ { "version": "1.0.0", - "lines": [] + "updated": "2026-06-17", + "description": "OMNL office settlement lines \u2014 treasury EVM wallets + canonical M1 token anchors per chain.", + "lines": [ + { + "lineId": "0xa16da49f1c038e9d798245a68f33c539b595df5ed39f4c566345c381ac5fd8ad", + "officeId": 1, + "externalId": "1", + "entityName": "OMNL Head Office (DBIS) \u2013 Central Bank", + "walletId": "HYBX-OFFICE-001", + "treasuryWalletMainnet": "0x9baa0efd06a496f401b2fb8508192fec675b6986", + "treasuryWalletChain138": "0xe41a19d5e62d83c086efe71ccecae397d32d2336", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0x63246fbf065dc14fcd18890be3b3274a90faebb4defa602cfeaaf56254d5c3eb", + "officeId": 2, + "externalId": "OMNL-2", + "entityName": "Shamrayan Enterprises", + "walletId": "HYBX-OFFICE-002", + "treasuryWalletMainnet": "0x8a72f33cb9f1110dfa2b0a950b887b814614e7ce", + "treasuryWalletChain138": "0xcda90617d6b86698ef54ff1e6bdccbe5df8a54f1", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0xf3b667aa86df7021f12238da02013ea06691654a19e14820a8e1f0810ea8584b", + "officeId": 3, + "externalId": "OMNL-3", + "entityName": "HYBX", + "walletId": "HYBX-OFFICE-003", + "treasuryWalletMainnet": "0x31d23172792db7d38c19a0d9136b47e5b9cb61bb", + "treasuryWalletChain138": "0x6fba59d90a891588d14bc2ecd63371ebc1974759", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0x329d143e0dd4209e5dadc159dc39a1b2121f9f0e1daa7f6d4ef7fdd685017e86", + "officeId": 4, + "externalId": "OMNL-4", + "entityName": "TAJ Private Single Family Office", + "walletId": "HYBX-OFFICE-004", + "treasuryWalletMainnet": "0x476eac28c5a369556590026a33bf59f7798e2121", + "treasuryWalletChain138": "0x42c4091ab2080a029b3371b473cc22d27ed7170a", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0x00709b1e16c8ea744c115bbcf28c588bceefe2ee79b8ccf3ec723bafee375b0a", + "officeId": 5, + "externalId": "OMNL-5", + "entityName": "Aseret Mortgage Bank", + "walletId": "HYBX-OFFICE-005", + "treasuryWalletMainnet": "0x3908d64ec91ad908ed0740654905b85f40c96892", + "treasuryWalletChain138": "0xc536e3a0bb2ada09077f6fef49710ca2c5c7f404", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0x98179d4cf92f0703702f0f98eda788932cb5d3bf823df275b6ca826d40df941a", + "officeId": 6, + "externalId": "OMNL-6", + "entityName": "Mann Li Family Offices", + "walletId": "HYBX-OFFICE-006", + "treasuryWalletMainnet": "0x75aa3ef512ff29a0475954c0a478b6b8a0e7485b", + "treasuryWalletChain138": "0xd1f1d00a3706fc5d680ac8a5bd49dcce17408ea2", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0x7e796548c9367f06628f65d7cbb8dbb104c08b0559e3079d3e4ba2868aab1a02", + "officeId": 7, + "externalId": "OMNL-7", + "entityName": "Sovereign Order of Malta OSJ", + "walletId": "HYBX-OFFICE-007", + "treasuryWalletMainnet": "0x2f0d8f2b449eddbb0e1d57df5b8bab7bc1853fe3", + "treasuryWalletChain138": "0xd437cbf6bf13f2c3b43abd0e5468e66edfe01299", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0x78287281703fd64c3bd014a5eb61130324075ea91e286def362ba3cbb06237ac", + "officeId": 8, + "externalId": "OMNL-8", + "entityName": "Alltra Mainnet", + "walletId": "HYBX-OFFICE-008", + "treasuryWalletMainnet": "0xf75948728a2379b485dc4695233c102f0a261089", + "treasuryWalletChain138": "0x7287fe01c4ab5f70e3d24dd6bc24ae925552bd40", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0xafe717d3727d08ef51307b96d4c090f69cd3f8b32622c6d6a9068d16b13da4f6", + "officeId": 9, + "externalId": "OMNL-9", + "entityName": "FIDIS", + "walletId": "HYBX-OFFICE-009", + "treasuryWalletMainnet": "0xeaffdc8cff3096712beb4cdc0b0e076d8f3f68c6", + "treasuryWalletChain138": "0xa7ebf614c6e47db20266be996db135d048fa20f7", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0x7eee9b83385d0220cac4fdae36222ed0364d445baf60e8a1fbd24d8100819e7e", + "officeId": 10, + "externalId": "OMNL-10", + "entityName": "Alpha Omega Holdings", + "walletId": "HYBX-OFFICE-010", + "treasuryWalletMainnet": "0x73f9180c4d219e3cca43b1c02895c685c4153414", + "treasuryWalletChain138": "0xe5736303f0b60f9dc14ef45b71547c228f261e6e", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0x751d9a27b003b5b8ec98f086003238f1ac16f1db5083817f1d8e8729bd3dbda8", + "officeId": 11, + "externalId": "OMNL-11", + "entityName": "SGI Capital", + "walletId": "HYBX-OFFICE-011", + "treasuryWalletMainnet": "0x16cecc9f7d048c2a8829eea46b3c30fb134523fc", + "treasuryWalletChain138": "0xac894ac9727fae7ff1c82103d74c5eafe5429d1a", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0x2065d9d64806c8946d4d59e78cd709cadb0275f2962bc986e9bdd76d5b2bdde3", + "officeId": 12, + "externalId": "OMNL-12", + "entityName": "Titan Financial", + "walletId": "HYBX-OFFICE-012", + "treasuryWalletMainnet": "0x7090a3acdda8514d6b07aff27f91ee8430d0a61c", + "treasuryWalletChain138": "0xdd0a1f686b87349bdc5aa909078688bc065a19f2", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0x981d13dd8853a67cb3d865e9b77400c2e848a753be12c1cf693621288b2ba253", + "officeId": 13, + "externalId": "OMNL-13", + "entityName": "Roy Walker PLLC", + "walletId": "HYBX-OFFICE-013", + "treasuryWalletMainnet": "0x5921918623c5fa3b3798467972497c6809d95790", + "treasuryWalletChain138": "0xcf0acb4afb1e07dc7fb6a6cbbf40d302641f9eaa", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0x4b492f8afa40d6191fe1319190345b32fdc1918b727de2b54f86f9e890834f05", + "officeId": 14, + "externalId": "OMNL-14", + "entityName": "SGI Partners LLC", + "walletId": "HYBX-OFFICE-014", + "treasuryWalletMainnet": "0xa312cc28d812832d03c72cb822b2b460ade174d1", + "treasuryWalletChain138": "0x8112c72ecfc0493b2cc7622218bc015df97d8ceb", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0xa86f3acebcecb46454e091fcf24a35f9efaa939421983f69563b53f55bb7ae94", + "officeId": 15, + "externalId": "OMNL-15", + "entityName": "Tsunami Holdings AG", + "walletId": "HYBX-OFFICE-015", + "treasuryWalletMainnet": "0x09c8bbb678649ef543b0ca6e2d0248ed374be70e", + "treasuryWalletChain138": "0x291069ab31457997037ac59296f4fff2b55a033c", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0xc236020f755fe76acaf5eb5fd8a53114d1f49e8e58d985beb4f4410d52abd2a6", + "officeId": 16, + "externalId": "OMNL-16", + "entityName": "Anakatech LLC", + "walletId": "HYBX-OFFICE-016", + "treasuryWalletMainnet": "0xaca2ab7b574d19cee64b004baa46994abf9bf0f2", + "treasuryWalletChain138": "0x0ad3a2a39dbf0c7b1646c2c57902f5756b95e93f", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0xc820d6d2cf4b054b08435a1ea3318a88ffa849b51fc61ab12ef15b248423be96", + "officeId": 17, + "externalId": "OMNL-17", + "entityName": "Anema Camden Walker Global", + "walletId": "HYBX-OFFICE-017", + "treasuryWalletMainnet": "0x45a70949a11fc813c82d44f80aec9fc07ff819e4", + "treasuryWalletChain138": "0x3a3ae3e4355ac2a7cbeb1b12d2900eb37763f0b9", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0xf3c45215b31095db55af3e8a65668f86de60f689c4fdeb8d54e89f3504752c43", + "officeId": 18, + "externalId": "OMNL-18", + "entityName": "NEPAL RASTRA BANK", + "walletId": "HYBX-OFFICE-018", + "treasuryWalletMainnet": "0x6c2e2c8b8b5ecb82246ceabbcbc862dc2b77634a", + "treasuryWalletChain138": "0x3d3475d9e9bc261372b99dd4f2b0df5dc40623dd", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0xc96c3bb01849a9f3d8fd62f1114b5606a9235d53f771b424b299207952175f7d", + "officeId": 19, + "externalId": "OMNL-19", + "entityName": "SANIMA BANK LIMITED", + "walletId": "HYBX-OFFICE-019", + "treasuryWalletMainnet": "0xea6eab4953f7f54f7c1001375e57d573bec66161", + "treasuryWalletChain138": "0x106e4a810aff37a1bc62d30d414ec575e83e7539", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0x5d44d97f66dcb6591d36b70375b84c73b73cfc8f3788228b5977632beb995b1d", + "officeId": 20, + "externalId": "SAMAMA-AZ-1703722701", + "entityName": "Samama Group LLC - Azerbaijan", + "walletId": "HYBX-OFFICE-020", + "treasuryWalletMainnet": "0x834a95c1f4216e90677334339074fda872b97847", + "treasuryWalletChain138": "0xfd371f2457a6a58f11c80cc1c4ca2305f4674db3", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0xa1fd57654dc5fef77117da02995c584a19a4b6949a03cd91ccb16551d7d3d01d", + "officeId": 21, + "externalId": "BANK-KANAYA-ID", + "entityName": "Bank Kanaya (Indonesia)", + "walletId": "HYBX-OFFICE-021", + "treasuryWalletMainnet": "0x2e4d9d09801dc09e90b3752db42c1a8e6d033520", + "treasuryWalletChain138": "0xa08621a479bc263e563b2eadb7490d1c07039ba7", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0xd5666338b8da738cf82c681e4a39ae5f817861061accfd98464a30470bdfcc58", + "officeId": 22, + "externalId": "OMNL-ID-JKT-CAKRA-001", + "entityName": "PT. CAKRA INVESTAMA INTERNATIONAL", + "walletId": "HYBX-OFFICE-022", + "treasuryWalletMainnet": "0x7af34ec0e3d65f147ba6134471bff7bb25f785d0", + "treasuryWalletChain138": "0xa31bf188d4235aa509f6ead064587ee99e6029c6", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0x3d83b0f803d19de0cd096a4c29fc27c8ef02248d8e5978a3a2e16bbe714754dc", + "officeId": 23, + "externalId": "5493006P6LOOFH8TB150", + "entityName": "CENTRAL BANK OF THE UAE", + "walletId": "HYBX-OFFICE-023", + "treasuryWalletMainnet": "0x8c219794fdb74cf8ded871fab6135e40c4168da9", + "treasuryWalletChain138": "0x204fa6d8c72a21116de75b37abf0d1cd642ddcfa", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0xaede6a63b3f49d0d18b13267855274e250516a97c84dcf0d31e17f359ceca4ff", + "officeId": 24, + "externalId": "HOSPITALLERS-ALI-IRAQ-IRAN", + "entityName": "HOSPITALLERS \u2014 Ali (Iraq / Iran Operations)", + "walletId": "HYBX-OFFICE-024", + "treasuryWalletMainnet": "0x87dab11386841366682d6c5fd3cd90587552f8cd", + "treasuryWalletChain138": "0xc93522ae4ab7482572cc29edd68fbb6d5ec6a585", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + }, + { + "lineId": "0xcdd67c94cd265ef8dd383a06b7ac4733e83a8c7cdf5030aed9ec36ab2999004a", + "officeId": 25, + "externalId": "NIISITAPI-CB-CA", + "entityName": "Niisitapi Sovereign Bank", + "walletId": "HYBX-OFFICE-025", + "treasuryWalletMainnet": "0xfc4D24Bda60240D6dfa1a702eE83c65002cf5218", + "treasuryWalletChain138": "0x934adfFFbe4D079b7dbDcE183805dBcBeC7818cb", + "chains": { + "138": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + }, + "1": { + "tokenM0": "0x0000000000000000000000000000000000000000", + "tokenM1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + } + ] } diff --git a/contracts/dex/DODOPMMIntegration.sol b/contracts/dex/DODOPMMIntegration.sol index c1a6bbb..f6bcd43 100644 --- a/contracts/dex/DODOPMMIntegration.sol +++ b/contracts/dex/DODOPMMIntegration.sol @@ -538,8 +538,12 @@ contract DODOPMMIntegration is AccessControl, ReentrancyGuard { require(isRegisteredPool[pool], "DODOPMMIntegration: pool not registered"); if (address(reserveSystem) != address(0)) { PoolConfig memory config = poolConfigs[pool]; - try reserveSystem.getConversionPrice(config.baseToken, config.quoteToken) returns (uint256 oraclePrice) { - if (oraclePrice > 0) return oraclePrice; + try reserveSystem.getPrice(config.baseToken) returns (uint256 basePrice, uint256) { + try reserveSystem.getPrice(config.quoteToken) returns (uint256 quotePrice, uint256) { + if (basePrice > 0 && quotePrice > 0) { + return (basePrice * 1e18) / quotePrice; + } + } catch { } } catch { } } return IDODOPMMPool(pool).getMidPrice(); diff --git a/contracts/emoney/interfaces/IAccountWalletRegistry.sol b/contracts/emoney/interfaces/IAccountWalletRegistry.sol new file mode 100644 index 0000000..37a7d7a --- /dev/null +++ b/contracts/emoney/interfaces/IAccountWalletRegistry.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @notice Minimal account-wallet registry interface for smart-account compile stubs. +interface IAccountWalletRegistry { + struct WalletLink { + bytes32 walletRefId; + uint64 linkedAt; + bool active; + bytes32 provider; + } + + event AccountWalletLinked( + bytes32 indexed accountRefId, + bytes32 indexed walletRefId, + bytes32 provider, + uint64 linkedAt + ); + event AccountWalletUnlinked(bytes32 indexed accountRefId, bytes32 indexed walletRefId); + + function linkAccountToWallet(bytes32 accountRefId, bytes32 walletRefId, bytes32 provider) external; + + function unlinkAccountFromWallet(bytes32 accountRefId, bytes32 walletRefId) external; + + function getWallets(bytes32 accountRefId) external view returns (WalletLink[] memory); + + function getAccounts(bytes32 walletRefId) external view returns (bytes32[] memory); + + function isLinked(bytes32 accountRefId, bytes32 walletRefId) external view returns (bool); + + function isActive(bytes32 accountRefId, bytes32 walletRefId) external view returns (bool); +} diff --git a/contracts/emoney/interfaces/ITokenFactory138.sol b/contracts/emoney/interfaces/ITokenFactory138.sol new file mode 100644 index 0000000..1229bd1 --- /dev/null +++ b/contracts/emoney/interfaces/ITokenFactory138.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @notice Minimal TokenFactory138 interface for reserve integration compile stubs. +interface ITokenFactory138 { + struct TokenConfig { + address issuer; + uint8 defaultLienMode; + bool paused; + bool bridgeOnly; + address bridge; + } + + function deployToken(string memory name, string memory symbol, TokenConfig memory config) + external + returns (address); +} diff --git a/contracts/relay/CCIPRelayRouter.sol b/contracts/relay/CCIPRelayRouter.sol index 9a32925..edc4e0b 100644 --- a/contracts/relay/CCIPRelayRouter.sol +++ b/contracts/relay/CCIPRelayRouter.sol @@ -2,6 +2,8 @@ pragma solidity ^0.8.19; import "../ccip/IRouterClient.sol"; +import {Client} from "@chainlink/contracts-ccip/contracts/libraries/Client.sol"; +import {IAny2EVMMessageReceiver} from "@chainlink/contracts-ccip/contracts/interfaces/IAny2EVMMessageReceiver.sol"; import "@openzeppelin/contracts/access/AccessControl.sol"; /** @@ -70,13 +72,27 @@ contract CCIPRelayRouter is AccessControl { IRouterClient.Any2EVMMessage calldata message ) external onlyRole(RELAYER_ROLE) { require(authorizedBridges[bridge], "CCIPRelayRouter: bridge not authorized"); - - // 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, ) = bridge.call( - abi.encodeWithSignature("ccipReceive((bytes32,uint64,bytes,bytes,(address,uint256,uint8)[]))", message) + + // CWMultiTokenBridgeL2 and official CCIP receivers use Client.Any2EVMMessage + // (destTokenAmounts: EVMTokenAmount[]). Legacy CCIPRelayBridge uses IRouterClient + // Any2EVMMessage (tokenAmounts with amountType). Try Client format first, then legacy. + Client.Any2EVMMessage memory clientMessage = _toClientMessage(message); + (bool success, bytes memory returnData) = bridge.call( + abi.encodeCall(IAny2EVMMessageReceiver.ccipReceive, (clientMessage)) ); + if (!success) { + (success, returnData) = bridge.call( + abi.encodeWithSignature( + "ccipReceive((bytes32,uint64,bytes,bytes,(address,uint256,uint8)[]))", + message + ) + ); + } + if (!success && returnData.length > 0) { + assembly { + revert(add(returnData, 32), mload(returnData)) + } + } require(success, "CCIPRelayRouter: ccipReceive failed"); // If we get here, the call succeeded. Decode common payload shapes without @@ -113,4 +129,23 @@ contract CCIPRelayRouter is AccessControl { return (address(0), 0); } + + function _toClientMessage( + IRouterClient.Any2EVMMessage calldata message + ) internal pure returns (Client.Any2EVMMessage memory clientMessage) { + Client.EVMTokenAmount[] memory destAmounts = new Client.EVMTokenAmount[](message.tokenAmounts.length); + for (uint256 i = 0; i < message.tokenAmounts.length; i++) { + destAmounts[i] = Client.EVMTokenAmount({ + token: message.tokenAmounts[i].token, + amount: message.tokenAmounts[i].amount + }); + } + clientMessage = Client.Any2EVMMessage({ + messageId: message.messageId, + sourceChainSelector: message.sourceChainSelector, + sender: message.sender, + data: message.data, + destTokenAmounts: destAmounts + }); + } } diff --git a/contracts/reserve/ReserveConversionPriceAdapter.sol b/contracts/reserve/ReserveConversionPriceAdapter.sol new file mode 100644 index 0000000..13a4616 --- /dev/null +++ b/contracts/reserve/ReserveConversionPriceAdapter.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IReserveSystem} from "./IReserveSystem.sol"; + +/// @notice Live-chain shim: corrects inverted getConversionPrice on deployed ReserveSystem. +/// @dev DODOPMMIntegration only reads getPrice / getConversionPrice from the wired address. +contract ReserveConversionPriceAdapter { + IReserveSystem public immutable underlying; + + constructor(address underlying_) { + require(underlying_ != address(0), "ReserveConversionPriceAdapter: zero underlying"); + underlying = IReserveSystem(underlying_); + } + + function getConversionPrice( + address sourceAsset, + address targetAsset + ) external view returns (uint256) { + (uint256 sourcePrice,) = underlying.getPrice(sourceAsset); + (uint256 targetPrice,) = underlying.getPrice(targetAsset); + return (sourcePrice * 1e18) / targetPrice; + } + + function getPrice(address asset) external view returns (uint256 price, uint256 timestamp) { + return underlying.getPrice(asset); + } +} diff --git a/contracts/reserve/ReserveSystem.sol b/contracts/reserve/ReserveSystem.sol index 4afb2c4..9ca2552 100644 --- a/contracts/reserve/ReserveSystem.sol +++ b/contracts/reserve/ReserveSystem.sol @@ -205,8 +205,8 @@ contract ReserveSystem is IReserveSystem, AccessControl, ReentrancyGuard { require(block.timestamp - sourceTimestamp <= PRICE_STALENESS_THRESHOLD, "ReserveSystem: stale source price"); require(block.timestamp - targetTimestamp <= PRICE_STALENESS_THRESHOLD, "ReserveSystem: stale target price"); - // Direct conversion - targetAmount = (amount * targetPrice) / sourcePrice; + // targetAmount in targetAsset units (same value in USD terms) + targetAmount = (amount * sourcePrice) / targetPrice; // Calculate fees fees = calculateFees(targetAmount, 0); // No slippage for direct conversion @@ -292,7 +292,8 @@ contract ReserveSystem is IReserveSystem, AccessControl, ReentrancyGuard { ) external view override returns (uint256) { (uint256 sourcePrice,) = getPrice(sourceAsset); (uint256 targetPrice,) = getPrice(targetAsset); - return (targetPrice * 1e18) / sourcePrice; // Price in 18 decimals + // target per source (e.g. USDC per WETH when source=WETH, target=USDC) + return (sourcePrice * 1e18) / targetPrice; } // ============ Admin Functions ============ diff --git a/docs/deployment/ENV_MONAD_CHAIN138.example b/docs/deployment/ENV_MONAD_CHAIN138.example index f05c1c5..7ec83a2 100644 --- a/docs/deployment/ENV_MONAD_CHAIN138.example +++ b/docs/deployment/ENV_MONAD_CHAIN138.example @@ -12,8 +12,8 @@ CCIP_MONAD_LINK_TOKEN=0x0000000000000000000000000000000000000000 # Canonical wrapped native (use for both WETH9/WETH10 slots on Monad) WMON_MAINNET=0x754704bc059f8c67012fed69bc8a327a5aafb603 -WETH9_MONAD=0x754704bc059f8c67012fed69bc8a327a5aafb603 -WETH10_MONAD=0x754704bc059f8c67012fed69bc8a327a5aafb603 +WETH9_MONAD=0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A +WETH10_MONAD=0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A # Fill after deploy-monad-ccip-stack.sh --execute CCIPWETH9_BRIDGE_MONAD= diff --git a/docs/integration/DODO_PMM_INTEGRATION.md b/docs/integration/DODO_PMM_INTEGRATION.md index 2ae681d..271841b 100644 --- a/docs/integration/DODO_PMM_INTEGRATION.md +++ b/docs/integration/DODO_PMM_INTEGRATION.md @@ -300,7 +300,7 @@ PMM pool price can be driven by the **ReserveSystem** when base/quote tokens are ``` Use `address(0)` to disable and fall back to pool `getMidPrice()` only. -2. **getPoolPriceOrOracle(pool)** returns: oracle price (via `reserveSystem.getConversionPrice(baseToken, quoteToken)`) if ReserveSystem is set and returns a valid price; otherwise pool `getMidPrice()`. +2. **getPoolPriceOrOracle(pool)** returns oracle price from ReserveSystem **`getPrice(base)` / `getPrice(quote)`** (quote per base) when ReserveSystem is set and both feeds are valid; on live Chain 138, wire **`ReserveConversionPriceAdapter`** if the deployed ReserveSystem still has the inverted `getConversionPrice` formula. Otherwise pool **`getMidPrice()`** (DVM mids may be ~$1 — not ETH spot). 3. **DODOPMMProvider** uses `getPoolPriceOrOracle` for quotes, so liquidity provider quotes prefer the oracle when configured. diff --git a/docs/integration/ORACLE_AND_KEEPER_CHAIN138.md b/docs/integration/ORACLE_AND_KEEPER_CHAIN138.md index a0fcc77..d1a60bd 100644 --- a/docs/integration/ORACLE_AND_KEEPER_CHAIN138.md +++ b/docs/integration/ORACLE_AND_KEEPER_CHAIN138.md @@ -49,7 +49,7 @@ References: [DEPLOYMENT_COMPLETE_GUIDE.md](../deployment/DEPLOYMENT_COMPLETE_GUI **Policy:** Run off-chain ticks every **6 seconds** so oracle pushes, `PriceFeedKeeper` upkeep, and PMM/WETH read paths stay warm—supporting peg alignment with `DODOPMMIntegration` / optional `ReserveSystem`. -**One-shot on-chain alignment (WETH10 mock + D3Oracle + `trackAsset`):** parent repo `scripts/deployment/fix-chain138-pricing-feeds.sh` (see `docs/04-configuration/CHAIN138_PRICING_FEEDS_LIVE.md`). +**One-shot on-chain alignment (aggregator → ReserveSystem → DODO oracle path):** parent repo `scripts/deployment/fix-chain138-pricing-feeds.sh` (see `docs/04-configuration/CHAIN138_PRICING_FEEDS_LIVE.md`). Forge helper: `script/reserve/SyncChain138ReserveFromOracle.s.sol`. Live WETH pools are **DVM** (pool `i` not resettable); optional DPP-only helper: `scripts/deployment/reset-chain138-weth-pmm-pool-i.sh`. | Component | Role | |-----------|------| @@ -81,8 +81,9 @@ to `.env.keeper.local` (gitignored) that sources the secret file. ## PMM and Reporting -- **DODOPMMIntegration**: Optional `setReserveSystem(ReserveSystem)`; then `getPoolPriceOrOracle(pool)` uses ReserveSystem when base/quote are registered. -- **Token-aggregation**: Set `CHAIN_138_DODO_PMM_INTEGRATION` so the pool indexer ingests PMM pools; report API then includes them in `/report/all`, `/report/coingecko`, `/report/cross-chain`. +- **DODOPMMIntegration**: Optional `setReserveSystem(address)`; **`getPoolPriceOrOracle`** uses ReserveSystem **`getPrice`** (quote per base) when both asset feeds exist. On live Chain 138, **`ReserveConversionPriceAdapter`** (`0xA740EFF8C1339ea46833A6A968F24f3183a503C4`) is wired on Stack A — deploy with **`FOUNDRY_PROFILE=chain138`** (Paris EVM). See [CHAIN138_PRICING_FEEDS_LIVE.md](../../../docs/04-configuration/CHAIN138_PRICING_FEEDS_LIVE.md). +- **Official USDT/USDC** must be wired on **OraclePriceFeed** (`RefreshChain138UsdFeedsAndWireUsdc.s.sol`) so WETH/USDT and WETH/USDC oracle paths do not fall back to DVM **`getMidPrice()`** (~$1). +- **Token-aggregation**: Set `CHAIN_138_DODO_PMM_INTEGRATION=0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895` so the pool indexer ingests PMM pools; report API then includes them in `/report/all`, `/report/coingecko`, `/report/cross-chain`. See: [DODO_PMM_INTEGRATION.md](DODO_PMM_INTEGRATION.md#oracle-and-reporting). ## Sibling archive keeper imports (2026-06-02) diff --git a/packages/integration-foundation/dist/compliance/MockAmlScreeningProvider.d.ts b/packages/integration-foundation/dist/compliance/MockAmlScreeningProvider.d.ts new file mode 100644 index 0000000..566178d --- /dev/null +++ b/packages/integration-foundation/dist/compliance/MockAmlScreeningProvider.d.ts @@ -0,0 +1,12 @@ +import type { AmlScreeningProvider, AmlScreeningRequest, SanctionsScreeningProvider, SanctionsScreeningRequest, ScreeningDecision, TravelRulePayload, TravelRuleTransmitter } from './screening-providers'; +export declare class MockAmlScreeningProvider implements AmlScreeningProvider { + screen(request: AmlScreeningRequest): Promise; +} +export declare class MockSanctionsScreeningProvider implements SanctionsScreeningProvider { + screen(request: SanctionsScreeningRequest): Promise; +} +export declare class MockTravelRuleTransmitter implements TravelRuleTransmitter { + transmit(payload: TravelRulePayload): Promise<{ + referenceId: string; + }>; +} diff --git a/packages/integration-foundation/dist/compliance/MockAmlScreeningProvider.js b/packages/integration-foundation/dist/compliance/MockAmlScreeningProvider.js new file mode 100644 index 0000000..46c9ae0 --- /dev/null +++ b/packages/integration-foundation/dist/compliance/MockAmlScreeningProvider.js @@ -0,0 +1,28 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MockTravelRuleTransmitter = exports.MockSanctionsScreeningProvider = exports.MockAmlScreeningProvider = void 0; +class MockAmlScreeningProvider { + async screen(request) { + const amount = Number(request.amount); + if (request.entityId.startsWith('blocked-')) + return 'block'; + if (amount >= 1_000_000) + return 'review'; + return 'clear'; + } +} +exports.MockAmlScreeningProvider = MockAmlScreeningProvider; +class MockSanctionsScreeningProvider { + async screen(request) { + const hit = request.names.some((n) => /sanction|blocked/i.test(n)); + return hit ? 'block' : 'clear'; + } +} +exports.MockSanctionsScreeningProvider = MockSanctionsScreeningProvider; +class MockTravelRuleTransmitter { + async transmit(payload) { + const ref = `TR-${payload.originatorVaspId}-${Date.now()}`; + return { referenceId: ref }; + } +} +exports.MockTravelRuleTransmitter = MockTravelRuleTransmitter; diff --git a/packages/integration-foundation/dist/compliance/Volume13ComplianceDecisionEngine.d.ts b/packages/integration-foundation/dist/compliance/Volume13ComplianceDecisionEngine.d.ts new file mode 100644 index 0000000..462cfac --- /dev/null +++ b/packages/integration-foundation/dist/compliance/Volume13ComplianceDecisionEngine.d.ts @@ -0,0 +1,22 @@ +import type { ComplianceDecisionEngine, ComplianceEvaluationInput, ComplianceEvaluationResult } from './ComplianceDecisionEngine.js'; +/** + * Production compliance engine: Volume 13 rules + internal sanctions list. + * Vendor adapter hook via COMPLIANCE_VENDOR env (worldcheck|chainalysis|internal). + */ +export declare class Volume13ComplianceDecisionEngine implements ComplianceDecisionEngine { + private readonly mockFallback; + private readonly rules; + private readonly sanctions; + private readonly vendor; + private auditCounter; + constructor(options?: { + rulesPath?: string; + sanctionsPath?: string; + }); + private nextAuditId; + private matchSanctions; + evaluateTransaction(input: ComplianceEvaluationInput): Promise; + evaluateEntity(entityId: string, tenantId: string): Promise; + evaluateCounterparty(counterpartyId: string, tenantId: string): Promise; +} +export declare function createComplianceDecisionEngine(): ComplianceDecisionEngine; diff --git a/packages/integration-foundation/dist/compliance/Volume13ComplianceDecisionEngine.js b/packages/integration-foundation/dist/compliance/Volume13ComplianceDecisionEngine.js new file mode 100644 index 0000000..5ef7c51 --- /dev/null +++ b/packages/integration-foundation/dist/compliance/Volume13ComplianceDecisionEngine.js @@ -0,0 +1,106 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Volume13ComplianceDecisionEngine = void 0; +exports.createComplianceDecisionEngine = createComplianceDecisionEngine; +const ComplianceDecisionEngine_js_1 = require("./ComplianceDecisionEngine.js"); +const node_fs_1 = __importDefault(require("node:fs")); +const node_path_1 = __importDefault(require("node:path")); +/** + * Production compliance engine: Volume 13 rules + internal sanctions list. + * Vendor adapter hook via COMPLIANCE_VENDOR env (worldcheck|chainalysis|internal). + */ +class Volume13ComplianceDecisionEngine { + mockFallback; + rules; + sanctions; + vendor; + auditCounter = 0; + constructor(options) { + const root = process.env.PROXMOX_ROOT || node_path_1.default.resolve(process.cwd(), '../..'); + const rulesPath = options?.rulesPath || + node_path_1.default.join(root, 'config/hybx-omnl-dbis/volume13-aml-decision-rules.v1.json'); + const sanctionsPath = options?.sanctionsPath || + node_path_1.default.join(root, 'config/compliance/internal-sanctions-list.v1.json'); + this.mockFallback = new ComplianceDecisionEngine_js_1.MockComplianceDecisionEngine(); + this.vendor = process.env.COMPLIANCE_VENDOR || 'internal'; + this.rules = node_fs_1.default.existsSync(rulesPath) + ? JSON.parse(node_fs_1.default.readFileSync(rulesPath, 'utf8')).rules ?? [] + : []; + this.sanctions = node_fs_1.default.existsSync(sanctionsPath) + ? JSON.parse(node_fs_1.default.readFileSync(sanctionsPath, 'utf8')).entries ?? [] + : []; + } + nextAuditId() { + this.auditCounter += 1; + return `audit-v13-${this.vendor}-${this.auditCounter}`; + } + matchSanctions(entityId) { + const needle = entityId.toLowerCase(); + return this.sanctions.some((e) => e.active !== false && + (e.id.toLowerCase() === needle || + (e.names ?? []).some((n) => needle.includes(n.toLowerCase())))); + } + async evaluateTransaction(input) { + const hints = input.riskHints ?? []; + if (this.matchSanctions(input.entityId) || hints.includes('sanctions_hit')) { + return { + decision: 'block', + risk_score: 100, + reason_codes: ['SANCTIONS_HIT', `VENDOR_${this.vendor.toUpperCase()}`], + evidence_refs: ['volume13-sanctions-screen'], + manual_review_required: true, + audit_event_id: this.nextAuditId(), + }; + } + for (const rule of this.rules) { + const trigger = rule.trigger ?? {}; + if (trigger.riskHints?.some((h) => hints.includes(h))) { + return { + decision: rule.decision ?? 'review', + risk_score: 80, + reason_codes: [rule.id], + evidence_refs: [`volume13-${rule.id}`], + manual_review_required: true, + audit_event_id: this.nextAuditId(), + }; + } + if (trigger.amountGte != null && Number(input.amount) >= trigger.amountGte) { + return { + decision: rule.decision ?? 'hold', + risk_score: 65, + reason_codes: [rule.id, 'AMOUNT_THRESHOLD'], + evidence_refs: [`volume13-${rule.id}`], + manual_review_required: true, + audit_event_id: this.nextAuditId(), + }; + } + } + return this.mockFallback.evaluateTransaction(input); + } + async evaluateEntity(entityId, tenantId) { + if (this.matchSanctions(entityId)) { + return { + decision: 'reject', + risk_score: 95, + reason_codes: ['ENTITY_SANCTIONS_MATCH'], + evidence_refs: [`sanctions-${entityId}`], + manual_review_required: true, + audit_event_id: this.nextAuditId(), + }; + } + return this.mockFallback.evaluateEntity(entityId, tenantId); + } + async evaluateCounterparty(counterpartyId, tenantId) { + return this.evaluateEntity(counterpartyId, tenantId); + } +} +exports.Volume13ComplianceDecisionEngine = Volume13ComplianceDecisionEngine; +function createComplianceDecisionEngine() { + if (process.env.OMNL_COMPLIANCE_GATE !== '1') { + return new ComplianceDecisionEngine_js_1.MockComplianceDecisionEngine(); + } + return new Volume13ComplianceDecisionEngine(); +} diff --git a/packages/integration-foundation/dist/compliance/screening-providers.d.ts b/packages/integration-foundation/dist/compliance/screening-providers.d.ts new file mode 100644 index 0000000..c7061b7 --- /dev/null +++ b/packages/integration-foundation/dist/compliance/screening-providers.d.ts @@ -0,0 +1,35 @@ +/** + * Screening provider interfaces — placeholders until qualified vendors are integrated. + * Requires compliance/legal validation before production use. + */ +export type ScreeningDecision = 'clear' | 'review' | 'block'; +export type AmlScreeningRequest = { + entityId: string; + tenantId: string; + amount: string; + currency: string; + counterpartyId?: string; +}; +export type SanctionsScreeningRequest = { + entityId: string; + tenantId: string; + names: string[]; + countries?: string[]; +}; +export type TravelRulePayload = { + originatorVaspId: string; + beneficiaryVaspId: string; + originator: Record; + beneficiary: Record; +}; +export interface AmlScreeningProvider { + screen(request: AmlScreeningRequest): Promise; +} +export interface SanctionsScreeningProvider { + screen(request: SanctionsScreeningRequest): Promise; +} +export interface TravelRuleTransmitter { + transmit(payload: TravelRulePayload): Promise<{ + referenceId: string; + }>; +} diff --git a/packages/integration-foundation/dist/compliance/screening-providers.js b/packages/integration-foundation/dist/compliance/screening-providers.js new file mode 100644 index 0000000..4fedb93 --- /dev/null +++ b/packages/integration-foundation/dist/compliance/screening-providers.js @@ -0,0 +1,6 @@ +"use strict"; +/** + * Screening provider interfaces — placeholders until qualified vendors are integrated. + * Requires compliance/legal validation before production use. + */ +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/packages/integration-foundation/dist/hybx/HttpHybxClient.d.ts b/packages/integration-foundation/dist/hybx/HttpHybxClient.d.ts index 7a268ff..cff28e8 100644 --- a/packages/integration-foundation/dist/hybx/HttpHybxClient.d.ts +++ b/packages/integration-foundation/dist/hybx/HttpHybxClient.d.ts @@ -2,8 +2,8 @@ import type { HybxClient } from './HybxClient'; import type { HybxPaymentRequest, HybxPaymentResponse, HybxSettlementEvent, HybxWebhookPayload } from './types'; import { type HybxConfig } from './config'; /** - * HTTP HYBX client — sandbox/staging only until official API spec is available. - * Does not implement request signing; paths are placeholders. + * HTTP HYBX client — uses api-fineract-unified spec (Volume 20). + * Production allowed when HYBX_UNIFIED_API_OK=1 and unified manifest exists. */ export declare class HttpHybxClient implements HybxClient { readonly environment: string; @@ -11,6 +11,7 @@ export declare class HttpHybxClient implements HybxClient { constructor(options?: { config?: HybxConfig; testMode?: boolean; + allowProduction?: boolean; }); private postJson; initiatePayment(request: HybxPaymentRequest): Promise; diff --git a/packages/integration-foundation/dist/hybx/HttpHybxClient.js b/packages/integration-foundation/dist/hybx/HttpHybxClient.js index a530821..c36bae3 100644 --- a/packages/integration-foundation/dist/hybx/HttpHybxClient.js +++ b/packages/integration-foundation/dist/hybx/HttpHybxClient.js @@ -3,8 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.HttpHybxClient = void 0; const config_1 = require("./config"); /** - * HTTP HYBX client — sandbox/staging only until official API spec is available. - * Does not implement request signing; paths are placeholders. + * HTTP HYBX client — uses api-fineract-unified spec (Volume 20). + * Production allowed when HYBX_UNIFIED_API_OK=1 and unified manifest exists. */ class HttpHybxClient { environment; @@ -12,8 +12,9 @@ class HttpHybxClient { constructor(options) { this.config = options?.config ?? (0, config_1.loadHybxConfig)({ testMode: options?.testMode ?? true }); this.environment = this.config.environment; - if (this.config.environment === 'production') { - throw new Error('HttpHybxClient refuses production until official HYBX spec is integrated'); + const unifiedOk = process.env.HYBX_UNIFIED_API_OK === '1' || options?.allowProduction === true; + if (this.config.environment === 'production' && !unifiedOk) { + throw new Error('HttpHybxClient production requires HYBX_UNIFIED_API_OK=1 (api-fineract-unified.v1.openapi.yaml integrated)'); } } async postJson(path, body) { diff --git a/packages/integration-foundation/dist/index.d.ts b/packages/integration-foundation/dist/index.d.ts index e7bdb57..a9df175 100644 --- a/packages/integration-foundation/dist/index.d.ts +++ b/packages/integration-foundation/dist/index.d.ts @@ -3,8 +3,12 @@ export * from './hybx/HybxClient'; export * from './hybx/MockHybxClient'; export * from './hybx/config'; export * from './iso20022/CanonicalFinancialEvent'; +export * from './iso20022/schema-validation'; export * from './entities/RegulatedEntity'; export * from './compliance/ComplianceDecisionEngine'; +export * from './compliance/Volume13ComplianceDecisionEngine'; +export * from './compliance/screening-providers'; +export * from './compliance/MockAmlScreeningProvider'; export * from './audit/AuditEvent'; export * from './resilience/idempotency'; export * from './observability/correlation'; diff --git a/packages/integration-foundation/dist/index.js b/packages/integration-foundation/dist/index.js index 0847acc..88b47e7 100644 --- a/packages/integration-foundation/dist/index.js +++ b/packages/integration-foundation/dist/index.js @@ -19,8 +19,12 @@ __exportStar(require("./hybx/HybxClient"), exports); __exportStar(require("./hybx/MockHybxClient"), exports); __exportStar(require("./hybx/config"), exports); __exportStar(require("./iso20022/CanonicalFinancialEvent"), exports); +__exportStar(require("./iso20022/schema-validation"), exports); __exportStar(require("./entities/RegulatedEntity"), exports); __exportStar(require("./compliance/ComplianceDecisionEngine"), exports); +__exportStar(require("./compliance/Volume13ComplianceDecisionEngine"), exports); +__exportStar(require("./compliance/screening-providers"), exports); +__exportStar(require("./compliance/MockAmlScreeningProvider"), exports); __exportStar(require("./audit/AuditEvent"), exports); __exportStar(require("./resilience/idempotency"), exports); __exportStar(require("./observability/correlation"), exports); diff --git a/packages/integration-foundation/dist/iso20022/schema-validation.d.ts b/packages/integration-foundation/dist/iso20022/schema-validation.d.ts new file mode 100644 index 0000000..a97730b --- /dev/null +++ b/packages/integration-foundation/dist/iso20022/schema-validation.d.ts @@ -0,0 +1,15 @@ +/** + * ISO 20022 XSD validation interface. + * Full XSD catalog validation is deferred; this hook enables server wiring and tests. + */ +export type SchemaValidationResult = { + valid: boolean; + errors: string[]; +}; +export type Iso20022SchemaValidator = { + validate(xml: string, messageFamily: string): Promise; +}; +/** Structural pre-check until official XSD bundles are mounted in services. */ +export declare class PassthroughSchemaValidator implements Iso20022SchemaValidator { + validate(xml: string, messageFamily: string): Promise; +} diff --git a/packages/integration-foundation/dist/iso20022/schema-validation.js b/packages/integration-foundation/dist/iso20022/schema-validation.js new file mode 100644 index 0000000..5e1e720 --- /dev/null +++ b/packages/integration-foundation/dist/iso20022/schema-validation.js @@ -0,0 +1,21 @@ +"use strict"; +/** + * ISO 20022 XSD validation interface. + * Full XSD catalog validation is deferred; this hook enables server wiring and tests. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PassthroughSchemaValidator = void 0; +/** Structural pre-check until official XSD bundles are mounted in services. */ +class PassthroughSchemaValidator { + async validate(xml, messageFamily) { + const errors = []; + if (!xml?.trim()) + errors.push('empty_xml'); + if (!messageFamily?.trim()) + errors.push('missing_message_family'); + if (xml && !xml.includes('<')) + errors.push('not_xml'); + return { valid: errors.length === 0, errors }; + } +} +exports.PassthroughSchemaValidator = PassthroughSchemaValidator; diff --git a/packages/integration-foundation/src/compliance/MockAmlScreeningProvider.ts b/packages/integration-foundation/src/compliance/MockAmlScreeningProvider.ts new file mode 100644 index 0000000..7b6c3e3 --- /dev/null +++ b/packages/integration-foundation/src/compliance/MockAmlScreeningProvider.ts @@ -0,0 +1,32 @@ +import type { + AmlScreeningProvider, + AmlScreeningRequest, + SanctionsScreeningProvider, + SanctionsScreeningRequest, + ScreeningDecision, + TravelRulePayload, + TravelRuleTransmitter, +} from './screening-providers'; + +export class MockAmlScreeningProvider implements AmlScreeningProvider { + async screen(request: AmlScreeningRequest): Promise { + const amount = Number(request.amount); + if (request.entityId.startsWith('blocked-')) return 'block'; + if (amount >= 1_000_000) return 'review'; + return 'clear'; + } +} + +export class MockSanctionsScreeningProvider implements SanctionsScreeningProvider { + async screen(request: SanctionsScreeningRequest): Promise { + const hit = request.names.some((n) => /sanction|blocked/i.test(n)); + return hit ? 'block' : 'clear'; + } +} + +export class MockTravelRuleTransmitter implements TravelRuleTransmitter { + async transmit(payload: TravelRulePayload): Promise<{ referenceId: string }> { + const ref = `TR-${payload.originatorVaspId}-${Date.now()}`; + return { referenceId: ref }; + } +} diff --git a/packages/integration-foundation/src/compliance/Volume13ComplianceDecisionEngine.ts b/packages/integration-foundation/src/compliance/Volume13ComplianceDecisionEngine.ts new file mode 100644 index 0000000..6b80e2e --- /dev/null +++ b/packages/integration-foundation/src/compliance/Volume13ComplianceDecisionEngine.ts @@ -0,0 +1,140 @@ +import type { + ComplianceDecisionEngine, + ComplianceEvaluationInput, + ComplianceEvaluationResult, +} from './ComplianceDecisionEngine.js'; +import { MockComplianceDecisionEngine } from './ComplianceDecisionEngine.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +type Volume13Rule = { + id: string; + decision?: string; + trigger?: { riskHints?: string[]; amountGte?: number }; +}; + +type SanctionsEntry = { + id: string; + names?: string[]; + active?: boolean; +}; + +/** + * Production compliance engine: Volume 13 rules + internal sanctions list. + * Vendor adapter hook via COMPLIANCE_VENDOR env (worldcheck|chainalysis|internal). + */ +export class Volume13ComplianceDecisionEngine implements ComplianceDecisionEngine { + private readonly mockFallback: MockComplianceDecisionEngine; + private readonly rules: Volume13Rule[]; + private readonly sanctions: SanctionsEntry[]; + private readonly vendor: string; + private auditCounter = 0; + + constructor(options?: { rulesPath?: string; sanctionsPath?: string }) { + const root = process.env.PROXMOX_ROOT || path.resolve(process.cwd(), '../..'); + const rulesPath = + options?.rulesPath || + path.join(root, 'config/hybx-omnl-dbis/volume13-aml-decision-rules.v1.json'); + const sanctionsPath = + options?.sanctionsPath || + path.join(root, 'config/compliance/internal-sanctions-list.v1.json'); + + this.mockFallback = new MockComplianceDecisionEngine(); + this.vendor = process.env.COMPLIANCE_VENDOR || 'internal'; + this.rules = fs.existsSync(rulesPath) + ? JSON.parse(fs.readFileSync(rulesPath, 'utf8')).rules ?? [] + : []; + this.sanctions = fs.existsSync(sanctionsPath) + ? JSON.parse(fs.readFileSync(sanctionsPath, 'utf8')).entries ?? [] + : []; + } + + private nextAuditId(): string { + this.auditCounter += 1; + return `audit-v13-${this.vendor}-${this.auditCounter}`; + } + + private matchSanctions(entityId: string): boolean { + const needle = entityId.toLowerCase(); + return this.sanctions.some( + (e) => + e.active !== false && + (e.id.toLowerCase() === needle || + (e.names ?? []).some((n) => needle.includes(n.toLowerCase()))) + ); + } + + async evaluateTransaction( + input: ComplianceEvaluationInput + ): Promise { + const hints = input.riskHints ?? []; + + if (this.matchSanctions(input.entityId) || hints.includes('sanctions_hit')) { + return { + decision: 'block', + risk_score: 100, + reason_codes: ['SANCTIONS_HIT', `VENDOR_${this.vendor.toUpperCase()}`], + evidence_refs: ['volume13-sanctions-screen'], + manual_review_required: true, + audit_event_id: this.nextAuditId(), + }; + } + + for (const rule of this.rules) { + const trigger = rule.trigger ?? {}; + if (trigger.riskHints?.some((h) => hints.includes(h))) { + return { + decision: (rule.decision as ComplianceEvaluationResult['decision']) ?? 'review', + risk_score: 80, + reason_codes: [rule.id], + evidence_refs: [`volume13-${rule.id}`], + manual_review_required: true, + audit_event_id: this.nextAuditId(), + }; + } + if (trigger.amountGte != null && Number(input.amount) >= trigger.amountGte) { + return { + decision: (rule.decision as ComplianceEvaluationResult['decision']) ?? 'hold', + risk_score: 65, + reason_codes: [rule.id, 'AMOUNT_THRESHOLD'], + evidence_refs: [`volume13-${rule.id}`], + manual_review_required: true, + audit_event_id: this.nextAuditId(), + }; + } + } + + return this.mockFallback.evaluateTransaction(input); + } + + async evaluateEntity( + entityId: string, + tenantId: string + ): Promise { + if (this.matchSanctions(entityId)) { + return { + decision: 'reject', + risk_score: 95, + reason_codes: ['ENTITY_SANCTIONS_MATCH'], + evidence_refs: [`sanctions-${entityId}`], + manual_review_required: true, + audit_event_id: this.nextAuditId(), + }; + } + return this.mockFallback.evaluateEntity(entityId, tenantId); + } + + async evaluateCounterparty( + counterpartyId: string, + tenantId: string + ): Promise { + return this.evaluateEntity(counterpartyId, tenantId); + } +} + +export function createComplianceDecisionEngine(): ComplianceDecisionEngine { + if (process.env.OMNL_COMPLIANCE_GATE !== '1') { + return new MockComplianceDecisionEngine(); + } + return new Volume13ComplianceDecisionEngine(); +} diff --git a/packages/integration-foundation/src/compliance/screening-providers.ts b/packages/integration-foundation/src/compliance/screening-providers.ts new file mode 100644 index 0000000..5273941 --- /dev/null +++ b/packages/integration-foundation/src/compliance/screening-providers.ts @@ -0,0 +1,40 @@ +/** + * Screening provider interfaces — placeholders until qualified vendors are integrated. + * Requires compliance/legal validation before production use. + */ + +export type ScreeningDecision = 'clear' | 'review' | 'block'; + +export type AmlScreeningRequest = { + entityId: string; + tenantId: string; + amount: string; + currency: string; + counterpartyId?: string; +}; + +export type SanctionsScreeningRequest = { + entityId: string; + tenantId: string; + names: string[]; + countries?: string[]; +}; + +export type TravelRulePayload = { + originatorVaspId: string; + beneficiaryVaspId: string; + originator: Record; + beneficiary: Record; +}; + +export interface AmlScreeningProvider { + screen(request: AmlScreeningRequest): Promise; +} + +export interface SanctionsScreeningProvider { + screen(request: SanctionsScreeningRequest): Promise; +} + +export interface TravelRuleTransmitter { + transmit(payload: TravelRulePayload): Promise<{ referenceId: string }>; +} diff --git a/packages/integration-foundation/src/hybx/HttpHybxClient.ts b/packages/integration-foundation/src/hybx/HttpHybxClient.ts index 35da84d..b743733 100644 --- a/packages/integration-foundation/src/hybx/HttpHybxClient.ts +++ b/packages/integration-foundation/src/hybx/HttpHybxClient.ts @@ -8,18 +8,22 @@ import type { import { loadHybxConfig, type HybxConfig } from './config'; /** - * HTTP HYBX client — sandbox/staging only until official API spec is available. - * Does not implement request signing; paths are placeholders. + * HTTP HYBX client — uses api-fineract-unified spec (Volume 20). + * Production allowed when HYBX_UNIFIED_API_OK=1 and unified manifest exists. */ + export class HttpHybxClient implements HybxClient { readonly environment: string; private readonly config: HybxConfig; - constructor(options?: { config?: HybxConfig; testMode?: boolean }) { + constructor(options?: { config?: HybxConfig; testMode?: boolean; allowProduction?: boolean }) { this.config = options?.config ?? loadHybxConfig({ testMode: options?.testMode ?? true }); this.environment = this.config.environment; - if (this.config.environment === 'production') { - throw new Error('HttpHybxClient refuses production until official HYBX spec is integrated'); + const unifiedOk = process.env.HYBX_UNIFIED_API_OK === '1' || options?.allowProduction === true; + if (this.config.environment === 'production' && !unifiedOk) { + throw new Error( + 'HttpHybxClient production requires HYBX_UNIFIED_API_OK=1 (api-fineract-unified.v1.openapi.yaml integrated)' + ); } } diff --git a/packages/integration-foundation/src/index.ts b/packages/integration-foundation/src/index.ts index e7bdb57..a9df175 100644 --- a/packages/integration-foundation/src/index.ts +++ b/packages/integration-foundation/src/index.ts @@ -3,8 +3,12 @@ export * from './hybx/HybxClient'; export * from './hybx/MockHybxClient'; export * from './hybx/config'; export * from './iso20022/CanonicalFinancialEvent'; +export * from './iso20022/schema-validation'; export * from './entities/RegulatedEntity'; export * from './compliance/ComplianceDecisionEngine'; +export * from './compliance/Volume13ComplianceDecisionEngine'; +export * from './compliance/screening-providers'; +export * from './compliance/MockAmlScreeningProvider'; export * from './audit/AuditEvent'; export * from './resilience/idempotency'; export * from './observability/correlation'; diff --git a/packages/integration-foundation/src/iso20022/schema-validation.ts b/packages/integration-foundation/src/iso20022/schema-validation.ts new file mode 100644 index 0000000..af5570e --- /dev/null +++ b/packages/integration-foundation/src/iso20022/schema-validation.ts @@ -0,0 +1,24 @@ +/** + * ISO 20022 XSD validation interface. + * Full XSD catalog validation is deferred; this hook enables server wiring and tests. + */ + +export type SchemaValidationResult = { + valid: boolean; + errors: string[]; +}; + +export type Iso20022SchemaValidator = { + validate(xml: string, messageFamily: string): Promise; +}; + +/** Structural pre-check until official XSD bundles are mounted in services. */ +export class PassthroughSchemaValidator implements Iso20022SchemaValidator { + async validate(xml: string, messageFamily: string): Promise { + const errors: string[] = []; + if (!xml?.trim()) errors.push('empty_xml'); + if (!messageFamily?.trim()) errors.push('missing_message_family'); + if (xml && !xml.includes('<')) errors.push('not_xml'); + return { valid: errors.length === 0, errors }; + } +} diff --git a/script/DeployChain138WethRelayBridge.s.sol b/script/DeployChain138WethRelayBridge.s.sol new file mode 100644 index 0000000..6b5d46c --- /dev/null +++ b/script/DeployChain138WethRelayBridge.s.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Script, console} from "forge-std/Script.sol"; +import {CCIPRelayBridge} from "../contracts/relay/CCIPRelayBridge.sol"; +import {CCIPRelayRouter} from "../contracts/relay/CCIPRelayRouter.sol"; + +/// @notice Deploy WETH inbound CCIPRelayBridge on Chain 138 and authorize on existing relay router. +contract DeployChain138WethRelayBridge is Script { + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address weth9 = vm.envAddress("WETH9"); + address relayRouter = vm.envAddress("CCIP_RELAY_ROUTER_CHAIN138"); + + vm.startBroadcast(deployerPrivateKey); + + CCIPRelayBridge bridge = new CCIPRelayBridge(weth9, relayRouter); + console.log("Chain138 WETH CCIPRelayBridge:", address(bridge)); + + CCIPRelayRouter(relayRouter).authorizeBridge(address(bridge)); + console.log("Authorized on CCIPRelayRouter:", relayRouter); + + vm.stopBroadcast(); + } +} diff --git a/script/DeployMonad138OutboundLane.s.sol b/script/DeployMonad138OutboundLane.s.sol new file mode 100644 index 0000000..ad1650f --- /dev/null +++ b/script/DeployMonad138OutboundLane.s.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Script, console} from "forge-std/Script.sol"; +import {CCIPRouter} from "../contracts/ccip/CCIPRouter.sol"; +import {CCIPWETH9Bridge} from "../contracts/ccip/CCIPWETH9Bridge.sol"; + +/// @notice Event-only send router + WMON bridge on Monad for Monad→138 relay lane. +contract DeployMonad138OutboundLane is Script { + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + address wmon = vm.envAddress("WETH9_MONAD"); + address feeToken = vm.envOr("CCIP_FEE_TOKEN", address(0)); + uint64 chain138Selector = uint64(vm.envUint("CHAIN138_SELECTOR")); + + console.log("Deployer:", deployer); + console.log("WMON:", wmon); + console.log("Fee token (0=native MON):", feeToken); + + vm.startBroadcast(deployerPrivateKey); + + CCIPRouter router = new CCIPRouter(feeToken, 0, 0); + console.log("Monad event-only CCIPRouter:", address(router)); + + router.addSupportedChain(chain138Selector); + + CCIPWETH9Bridge bridge = new CCIPWETH9Bridge(address(router), wmon, feeToken); + console.log("Monad outbound CCIPWETH9Bridge:", address(bridge)); + + vm.stopBroadcast(); + } +} diff --git a/script/reserve/RefreshChain138UsdFeedsAndWireUsdc.s.sol b/script/reserve/RefreshChain138UsdFeedsAndWireUsdc.s.sol new file mode 100644 index 0000000..aa3299d --- /dev/null +++ b/script/reserve/RefreshChain138UsdFeedsAndWireUsdc.s.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; + +interface IAggregatorRefresh { + function updateAnswer(uint256 answer) external; +} + +interface IOraclePriceFeedWireUsdc { + function setAggregator(address asset, address aggregator, uint256 multiplier) external; + function updatePriceFeed(address asset) external; + function aggregators(address asset) external view returns (address); +} + +/// @notice Refresh stale USD/USD mirror aggregator + wire official USDC on OraclePriceFeed + sync pegged feeds. +contract RefreshChain138UsdFeedsAndWireUsdc is Script { + uint256 internal constant MULT = 1e10; + uint256 internal constant USD_1E8 = 100_000_000; + + address internal constant USD_AGG = 0x34EEd677b6D7CDc84FC23c7dA8480C41C776b1E0; + address internal constant CUSDT = 0x93E66202A11B1772E55407B32B44e5Cd8eda7f22; + address internal constant CUSDC = 0xf22258f57794CC8E06237084b353Ab30fFfa640b; + address internal constant USDC = 0x71D6687F38b93CCad569Fa6352c876eea967201b; + address internal constant USDT = 0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1; + + function run() external { + require(block.chainid == 138, "ChainID 138 only"); + + uint256 pk = vm.envUint("PRIVATE_KEY"); + address opf = vm.envAddress("ORACLE_PRICE_FEED"); + + console.log("=== Refresh USD aggregator + wire official USDC ==="); + console.log("OraclePriceFeed:", opf); + console.log("USD aggregator:", USD_AGG); + + vm.startBroadcast(pk); + + IAggregatorRefresh(USD_AGG).updateAnswer(USD_1E8); + console.log("USD aggregator refreshed at $1.00"); + + IOraclePriceFeedWireUsdc feed = IOraclePriceFeedWireUsdc(opf); + if (feed.aggregators(USDC) == address(0)) { + feed.setAggregator(USDC, USD_AGG, MULT); + console.log("Wired official USDC -> USD aggregator"); + } else { + console.log("Official USDC already wired"); + } + + if (feed.aggregators(USDT) == address(0)) { + feed.setAggregator(USDT, USD_AGG, MULT); + console.log("Wired official USDT -> USD aggregator"); + } else { + console.log("Official USDT already wired"); + } + + address[4] memory assets = [CUSDT, CUSDC, USDC, USDT]; + for (uint256 i = 0; i < assets.length; i++) { + feed.updatePriceFeed(assets[i]); + console.log("ReserveSystem synced:", assets[i]); + } + + vm.stopBroadcast(); + console.log("=== Done ==="); + } +} diff --git a/script/reserve/SyncChain138ReserveFromOracle.s.sol b/script/reserve/SyncChain138ReserveFromOracle.s.sol new file mode 100644 index 0000000..6035ff0 --- /dev/null +++ b/script/reserve/SyncChain138ReserveFromOracle.s.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; + +interface IOraclePriceFeedSync { + function updatePriceFeed(address asset) external; + function aggregators(address asset) external view returns (address); +} + +/// @notice Push wired Chain 138 aggregators into ReserveSystem via OraclePriceFeed (fixes getPoolPriceOrOracle). +contract SyncChain138ReserveFromOracle is Script { + address internal constant WETH9 = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address internal constant CUSDT = 0x93E66202A11B1772E55407B32B44e5Cd8eda7f22; + address internal constant CUSDC = 0xf22258f57794CC8E06237084b353Ab30fFfa640b; + address internal constant USDT = 0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1; + address internal constant USDC = 0x71D6687F38b93CCad569Fa6352c876eea967201b; + + function run() external { + require(block.chainid == 138, "ChainID 138 only"); + + uint256 pk = vm.envUint("PRIVATE_KEY"); + address opf = vm.envAddress("ORACLE_PRICE_FEED"); + + address[] memory assets = new address[](5); + assets[0] = WETH9; + assets[1] = CUSDT; + assets[2] = CUSDC; + assets[3] = USDT; + assets[4] = USDC; + + console.log("=== Sync ReserveSystem from OraclePriceFeed ==="); + console.log("OraclePriceFeed:", opf); + + vm.startBroadcast(pk); + + IOraclePriceFeedSync feed = IOraclePriceFeedSync(opf); + for (uint256 i = 0; i < assets.length; i++) { + address asset = assets[i]; + if (feed.aggregators(asset) == address(0)) { + console.log("SKIP (no aggregator):", asset); + continue; + } + try feed.updatePriceFeed(asset) { + console.log("Updated:", asset); + } catch { + console.log("WARN updatePriceFeed failed:", asset); + } + } + + vm.stopBroadcast(); + console.log("=== Done ==="); + } +} diff --git a/script/reserve/WireChain138ReserveConversionAdapter.s.sol b/script/reserve/WireChain138ReserveConversionAdapter.s.sol new file mode 100644 index 0000000..14bdfa8 --- /dev/null +++ b/script/reserve/WireChain138ReserveConversionAdapter.s.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {ReserveConversionPriceAdapter} from "../../contracts/reserve/ReserveConversionPriceAdapter.sol"; + +interface IDODOPMMIntegrationWire { + function setReserveSystem(address reserveSystem_) external; + function reserveSystem() external view returns (address); +} + +/// @notice Deploy conversion adapter and point Stack A DODOPMMIntegration at it (live ReserveSystem formula fix). +contract WireChain138ReserveConversionAdapter is Script { + address internal constant DEFAULT_RESERVE = 0x607e97cD626f209facfE48c1464815DDE15B5093; + address internal constant DEFAULT_DODO = 0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895; + + function run() external { + require(block.chainid == 138, "ChainID 138 only"); + + uint256 pk = vm.envUint("PRIVATE_KEY"); + address reserve = vm.envOr("RESERVE_SYSTEM", DEFAULT_RESERVE); + address dodo = vm.envOr("DODO_PMM_INTEGRATION_ADDRESS", DEFAULT_DODO); + + console.log("=== Wire ReserveConversionPriceAdapter ==="); + console.log("ReserveSystem:", reserve); + console.log("DODOPMMIntegration:", dodo); + console.log("Current reserveSystem:", IDODOPMMIntegrationWire(dodo).reserveSystem()); + + vm.startBroadcast(pk); + + ReserveConversionPriceAdapter adapter = new ReserveConversionPriceAdapter(reserve); + console.log("Adapter deployed:", address(adapter)); + IDODOPMMIntegrationWire(dodo).setReserveSystem(address(adapter)); + + vm.stopBroadcast(); + + console.log("Wired reserveSystem:", IDODOPMMIntegrationWire(dodo).reserveSystem()); + console.log("=== Done ==="); + } +} diff --git a/scripts/deployment/complete-config-monad-chain138.sh b/scripts/deployment/complete-config-monad-chain138.sh index 8dd74e8..23e61eb 100755 --- a/scripts/deployment/complete-config-monad-chain138.sh +++ b/scripts/deployment/complete-config-monad-chain138.sh @@ -32,7 +32,8 @@ fi DRY_RUN="${DRY_RUN:-0}" CHAIN138_RPC="${RPC_URL_138:-${CHAIN138_RPC:-http://192.168.11.211:8545}}" MONAD_RPC="${MONAD_MAINNET_RPC:-https://rpc.monad.xyz}" -if [[ "$MONAD_RPC" =~ infura\.io/v3/?$ ]]; then +# Infura project keys often lack Monad access — prefer public RPC unless forced. +if [[ "$MONAD_RPC" =~ infura\.io/v3 ]] && [[ "${MONAD_MAINNET_RPC_FORCE_INFURA:-0}" != "1" ]]; then MONAD_RPC="${MONAD_MAINNET_RPC_PUBLIC:-https://rpc.monad.xyz}" fi CHAIN138_SELECTOR="${CHAIN138_SELECTOR:-}" diff --git a/scripts/deployment/deploy-chain138-gas-canonicals.sh b/scripts/deployment/deploy-chain138-gas-canonicals.sh old mode 100644 new mode 100755 diff --git a/scripts/deployment/deploy-chain138-weth-relay-bridge.sh b/scripts/deployment/deploy-chain138-weth-relay-bridge.sh new file mode 100755 index 0000000..213a18b --- /dev/null +++ b/scripts/deployment/deploy-chain138-weth-relay-bridge.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Deploy CCIPRelayBridge (WETH) on Chain 138 and authorize on CCIP_RELAY_ROUTER_CHAIN138. +# +# Chain 138 Besu rejects Cancun bytecode (PUSH0 / 0x5f). Build + deploy with Paris EVM. +# +# Usage: ./scripts/deployment/deploy-chain138-weth-relay-bridge.sh --execute + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$PROJECT_ROOT" + +# shellcheck disable=SC1090 +source "$SCRIPT_DIR/../lib/deployment/dotenv.sh" +load_deployment_env --repo-root "$PROJECT_ROOT" + +EXECUTE=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --execute) EXECUTE=1; shift ;; + --dry-run) echo "[DRY RUN] forge create CCIPRelayBridge (Paris, gas ~3.5M)"; exit 0 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +[[ "$EXECUTE" == "1" ]] || { echo "Pass --execute"; exit 0; } +require_private_key_env || exit 1 +command -v jq >/dev/null 2>&1 || { echo "jq required"; exit 1; } + +RPC="${RPC_URL_138:-http://192.168.11.211:8545}" +WETH="${WETH9:-0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2}" +ROUTER="${CCIP_RELAY_ROUTER_CHAIN138:-0xe75d26bc558a28442f30750c6d97bffb46f39abc}" +GAS_LIMIT_DEPLOY="${GAS_LIMIT_DEPLOY:-3500000}" +EVM_VERSION_CHAIN138="${EVM_VERSION_CHAIN138:-paris}" + +bash "$PROJECT_ROOT/scripts/forge/scope.sh" build relay --evm-version "$EVM_VERSION_CHAIN138" --force + +LOG="/tmp/chain138-weth-relay-bridge.log" +out=$(bash "$PROJECT_ROOT/scripts/forge/scope.sh" create relay contracts/relay/CCIPRelayBridge.sol:CCIPRelayBridge \ + --rpc-url "$RPC" --private-key "$PRIVATE_KEY" --legacy \ + --evm-version "$EVM_VERSION_CHAIN138" \ + --gas-limit "$GAS_LIMIT_DEPLOY" \ + --broadcast \ + --constructor-args "$WETH" "$ROUTER" 2>&1) || { + printf '%s\n' "$out" | tee "$LOG" + exit 1 +} +printf '%s\n' "$out" | tee "$LOG" + +BRIDGE=$(printf '%s\n' "$out" | sed -n 's/.*Deployed to: \(0x[0-9a-fA-F]\{40\}\).*/\1/p' | head -1) +if [[ -z "$BRIDGE" ]]; then + BRIDGE=$(printf '%s\n' "$out" | sed -n 's/.*"contract":"\(0x[0-9a-fA-F]\{40\}\)".*/\1/p' | head -1) +fi +if [[ -z "$BRIDGE" ]]; then + echo "ERROR: bridge deploy failed — see $LOG" >&2 + exit 1 +fi + +txh=$(printf '%s\n' "$out" | sed -n 's/.*Transaction hash: \(0x[0-9a-fA-F]\{64\}\).*/\1/p' | head -1) +if [[ -n "$txh" ]]; then + st=$(cast receipt "$txh" --json --rpc-url "$RPC" 2>/dev/null | jq -r '.status // "0x0"') + if [[ "$st" != "0x1" ]]; then + echo "ERROR: deploy tx $txh failed on-chain (status=$st)" >&2 + exit 1 + fi +fi + +code=$(cast code "$BRIDGE" --rpc-url "$RPC" 2>/dev/null || echo "0x") +if [[ -z "$code" || "$code" == "0x" ]]; then + echo "ERROR: no bytecode at $BRIDGE" >&2 + exit 1 +fi + +echo "CCIP_RELAY_BRIDGE_CHAIN138_WETH=$BRIDGE" + +OK=$(cast call "$ROUTER" "authorizedBridges(address)(bool)" "$BRIDGE" --rpc-url "$RPC" 2>/dev/null || echo "false") +if [[ "$OK" != "true" ]]; then + echo "=== Authorize bridge on relay router ===" + cast send "$ROUTER" "authorizeBridge(address)" "$BRIDGE" \ + --rpc-url "$RPC" --private-key "$PRIVATE_KEY" --legacy --gas-price 5000000000 +fi + +echo "Done." diff --git a/scripts/deployment/deploy-full-parity-all-chains.sh b/scripts/deployment/deploy-full-parity-all-chains.sh old mode 100644 new mode 100755 diff --git a/scripts/deployment/deploy-monad-ccip-relay.sh b/scripts/deployment/deploy-monad-ccip-relay.sh new file mode 100755 index 0000000..65e5c05 --- /dev/null +++ b/scripts/deployment/deploy-monad-ccip-relay.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Deploy CCIPRelayRouter + CCIPRelayBridge on Monad (143) for 138→Monad delivery. +# +# Usage: +# DRY_RUN=1 ./scripts/deployment/deploy-monad-ccip-relay.sh +# ./scripts/deployment/deploy-monad-ccip-relay.sh --execute +# +# Records: CCIP_RELAY_ROUTER_MONAD, CCIP_RELAY_BRIDGE_MONAD in .env + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$PROJECT_ROOT" + +# shellcheck disable=SC1090 +source "$SCRIPT_DIR/../lib/deployment/dotenv.sh" +load_deployment_env --repo-root "$PROJECT_ROOT" + +EXECUTE=0 +DRY_RUN="${DRY_RUN:-0}" +while [[ $# -gt 0 ]]; do + case "$1" in + --execute) EXECUTE=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +RPC="${MONAD_MAINNET_RPC:-https://rpc.monad.xyz}" +if [[ "$RPC" =~ infura\.io/v3 ]] && [[ "${MONAD_MAINNET_RPC_FORCE_INFURA:-0}" != "1" ]]; then + RPC="${MONAD_MAINNET_RPC_PUBLIC:-https://rpc.monad.xyz}" +fi + +WMON="${WETH9_MONAD:-${WMON_MAINNET:-0x754704bc059f8c67012fed69bc8a327a5aafb603}}" +RELAYER="${RELAYER_ADDRESS:-${DEPLOYER_ADDRESS:-0x4A666F96fC8764181194447A7dFdb7d471b301C8}}" + +export WETH9_MAINNET="$WMON" +export RELAYER_ADDRESS="$RELAYER" + +echo "=== Deploy CCIP relay on Monad (143) ===" +echo "RPC: ${RPC%%\?*}" +echo "WMON (relay bridge token): $WMON" +echo "Relayer: $RELAYER" + +if [[ "$DRY_RUN" == "1" || "$EXECUTE" != "1" ]]; then + echo "[DRY RUN] forge script script/DeployCCIPRelay.s.sol:DeployCCIPRelay --rpc-url $RPC --chain-id 143 --broadcast --legacy" + [[ "$EXECUTE" != "1" && "$DRY_RUN" != "1" ]] && echo "Pass --execute to broadcast." + exit 0 +fi + +require_private_key_env || exit 1 +"$SCRIPT_DIR/preflight-monad-deploy.sh" + +LOG="/tmp/monad-ccip-relay-deploy.log" +forge script script/DeployCCIPRelay.s.sol:DeployCCIPRelay \ + --rpc-url "$RPC" \ + --chain-id 143 \ + --private-key "$PRIVATE_KEY" \ + --broadcast --legacy -vvv 2>&1 | tee "$LOG" + +ROUTER=$(grep -oE "CCIPRelayRouter deployed at: 0x[a-fA-F0-9]{40}" "$LOG" | grep -oE "0x[a-fA-F0-9]{40}" | tail -1) +BRIDGE=$(grep -oE "CCIPRelayBridge deployed at: 0x[a-fA-F0-9]{40}" "$LOG" | grep -oE "0x[a-fA-F0-9]{40}" | tail -1) + +if [[ -z "$ROUTER" || -z "$BRIDGE" ]]; then + echo "ERROR: could not parse relay addresses from $LOG" >&2 + exit 1 +fi + +echo "" +echo "CCIP_RELAY_ROUTER_MONAD=$ROUTER" +echo "CCIP_RELAY_BRIDGE_MONAD=$BRIDGE" +echo "" +echo "Add to smom-dbis-138/.env and re-run complete-config / wire-monad scripts." diff --git a/scripts/deployment/deploy-monad-ccip-stack.sh b/scripts/deployment/deploy-monad-ccip-stack.sh index 5370c61..e12cd8e 100755 --- a/scripts/deployment/deploy-monad-ccip-stack.sh +++ b/scripts/deployment/deploy-monad-ccip-stack.sh @@ -43,7 +43,7 @@ while [[ $# -gt 0 ]]; do done RPC="${MONAD_MAINNET_RPC:-https://rpc.monad.xyz}" -if [[ "$RPC" =~ infura\.io/v3/?$ ]]; then +if [[ "$RPC" =~ infura\.io/v3 ]] && [[ "${MONAD_MAINNET_RPC_FORCE_INFURA:-0}" != "1" ]]; then RPC="${MONAD_MAINNET_RPC_PUBLIC:-https://rpc.monad.xyz}" fi CHAIN_ID=143 diff --git a/scripts/deployment/deploy-monad-outbound-138-lane.sh b/scripts/deployment/deploy-monad-outbound-138-lane.sh new file mode 100755 index 0000000..93d908f --- /dev/null +++ b/scripts/deployment/deploy-monad-outbound-138-lane.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Deploy event-only CCIPRouter + outbound WMON bridge on Monad for Monad→138 relay lane. +# +# Usage: ./scripts/deployment/deploy-monad-outbound-138-lane.sh --execute + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$PROJECT_ROOT" + +# shellcheck disable=SC1090 +source "$SCRIPT_DIR/../lib/deployment/dotenv.sh" +load_deployment_env --repo-root "$PROJECT_ROOT" + +EXECUTE=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --execute) EXECUTE=1; shift ;; + --dry-run) echo "[DRY RUN] forge DeployMonad138OutboundLane"; exit 0 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +[[ "$EXECUTE" == "1" ]] || { echo "Pass --execute"; exit 0; } +require_private_key_env || exit 1 +"$SCRIPT_DIR/preflight-monad-deploy.sh" + +RPC="${MONAD_MAINNET_RPC_PUBLIC:-https://rpc.monad.xyz}" +export WETH9_MONAD="${WETH9_MONAD:-0x754704bc059f8c67012fed69bc8a327a5aafb603}" +export CCIP_FEE_TOKEN="0x0000000000000000000000000000000000000000" +export CHAIN138_SELECTOR="${CHAIN138_SELECTOR:-}" + +LOG="/tmp/monad-outbound-138-lane.log" +forge script script/DeployMonad138OutboundLane.s.sol:DeployMonad138OutboundLane \ + --rpc-url "$RPC" --chain-id 143 --private-key "$PRIVATE_KEY" \ + --broadcast --legacy -vvv 2>&1 | tee "$LOG" + +ROUTER=$(grep -oE "Monad event-only CCIPRouter: 0x[a-fA-F0-9]{40}" "$LOG" | grep -oE "0x[a-fA-F0-9]{40}" | tail -1) +BRIDGE=$(grep -oE "Monad outbound CCIPWETH9Bridge: 0x[a-fA-F0-9]{40}" "$LOG" | grep -oE "0x[a-fA-F0-9]{40}" | tail -1) +echo "CCIP_ROUTER_MONAD_SEND=$ROUTER" +echo "CCIPWETH9_BRIDGE_MONAD_SEND=$BRIDGE" diff --git a/scripts/deployment/deploy-official-dvm-chain138.sh b/scripts/deployment/deploy-official-dvm-chain138.sh old mode 100644 new mode 100755 diff --git a/scripts/deployment/preflight-monad-deploy.sh b/scripts/deployment/preflight-monad-deploy.sh index be5a9e7..d5666e4 100755 --- a/scripts/deployment/preflight-monad-deploy.sh +++ b/scripts/deployment/preflight-monad-deploy.sh @@ -22,8 +22,8 @@ fi DEPLOYER="${DEPLOYER_ADDRESS:-0x4A666F96fC8764181194447A7dFdb7d471b301C8}" RPC="${MONAD_MAINNET_RPC:-https://rpc.monad.xyz}" -# Infura template without expanded INFURA_PROJECT_ID is unusable — fall back to public RPC -if [[ "$RPC" =~ infura\.io/v3/?$ ]]; then +# Infura project keys often lack Monad access — prefer public RPC unless forced. +if [[ "$RPC" =~ infura\.io/v3 ]] && [[ "${MONAD_MAINNET_RPC_FORCE_INFURA:-0}" != "1" ]]; then RPC="${MONAD_MAINNET_RPC_PUBLIC:-https://rpc.monad.xyz}" fi ROUTER="${CCIP_MONAD_ROUTER:-0x33566fE5976AAa420F3d5C64996641Fc3858CaDB}" diff --git a/scripts/deployment/proof-ccip-chain138-monad.sh b/scripts/deployment/proof-ccip-chain138-monad.sh index 3f1a18b..96c6003 100755 --- a/scripts/deployment/proof-ccip-chain138-monad.sh +++ b/scripts/deployment/proof-ccip-chain138-monad.sh @@ -64,13 +64,14 @@ if [[ "$DRY_RUN" == "1" ]]; then fi cast send "$WETH" "approve(address,uint256)" "$BRIDGE" "$AMOUNT_WEI" \ - --rpc-url "$RPC" --private-key "$PRIVATE_KEY" $GAS --async + --rpc-url "$RPC" --private-key "$PRIVATE_KEY" $GAS cast send "$LINK" "approve(address,uint256)" "$BRIDGE" "$((FEE_WEI + 1))" \ - --rpc-url "$RPC" --private-key "$PRIVATE_KEY" $GAS --async -sleep 5 + --rpc-url "$RPC" --private-key "$PRIVATE_KEY" $GAS OUT=$(cast send "$BRIDGE" "sendCrossChain(uint64,address,uint256)" "$SEL" "$RECIPIENT" "$AMOUNT_WEI" \ - --rpc-url "$RPC" --private-key "$PRIVATE_KEY" $GAS --async) + --rpc-url "$RPC" --private-key "$PRIVATE_KEY" $GAS --json) +echo "$OUT" +HASH=$(echo "$OUT" | jq -r '.transactionHash // empty') echo "$OUT" HASH=$(echo "$OUT" | awk '/transactionHash/{print $2}') REPORT="/home/intlc/projects/proxmox/reports/status/proof-ccip-chain138-monad-latest.json" @@ -85,7 +86,7 @@ cat > "$REPORT" < Chain 138 via CCIPWETH9Bridge (native MON fees). +# Canary CCIP proof: Monad (143) -> Chain 138 via CCIPWETH9Bridge send lane (native MON fees). +# Prefers CCIPWETH9_BRIDGE_MONAD_SEND (custom event-only router); falls back to hub bridge. # # Usage: # DRY_RUN=1 ./scripts/deployment/proof-ccip-monad-chain138.sh @@ -25,14 +26,14 @@ fi DRY_RUN="${DRY_RUN:-0}" AMOUNT_ETH="${AMOUNT_ETH:-0.001}" -MONAD_RPC="${MONAD_MAINNET_RPC_PUBLIC:-https://rpc.monad.xyz}" -if [[ "${MONAD_MAINNET_RPC:-}" =~ infura\.io/v3/?$ ]]; then +MONAD_RPC="${MONAD_MAINNET_RPC:-https://rpc.monad.xyz}" +if [[ "$MONAD_RPC" =~ infura\.io/v3 ]] && [[ "${MONAD_MAINNET_RPC_FORCE_INFURA:-0}" != "1" ]]; then MONAD_RPC="${MONAD_MAINNET_RPC_PUBLIC:-https://rpc.monad.xyz}" -elif [[ -n "${MONAD_MAINNET_RPC:-}" ]]; then +elif [[ -n "${MONAD_MAINNET_RPC:-}" ]] && [[ "${MONAD_MAINNET_RPC_FORCE_INFURA:-0}" == "1" ]]; then MONAD_RPC="$MONAD_MAINNET_RPC" fi -BRIDGE="${CCIPWETH9_BRIDGE_MONAD:-}" +BRIDGE="${CCIPWETH9_BRIDGE_MONAD_SEND:-${CCIPWETH9_BRIDGE_MONAD:-}}" WMON="${WMON_MAINNET:-0x754704Bc059F8C67012fEd69BC8A327a5aafb603}" SEL="${CHAIN138_SELECTOR:-}" @@ -59,9 +60,15 @@ echo "Recipient (138): $RECIPIENT" echo "Selector: $SEL" FEE_WEI=$(cast call "$BRIDGE" "calculateFee(uint64,uint256)" "$SEL" "$AMOUNT_WEI" --rpc-url "$MONAD_RPC" | cast --to-dec) -echo "CCIP fee (native MON): $FEE_WEI wei" +FEE_TOKEN=$(cast call "$BRIDGE" "feeToken()(address)" --rpc-url "$MONAD_RPC") +ZERO="0x0000000000000000000000000000000000000000" +if [[ "${FEE_TOKEN,,}" == "${ZERO,,}" ]]; then + echo "CCIP fee (native MON): $FEE_WEI wei" +else + echo "CCIP fee (LINK): $FEE_WEI wei" +fi -BAL=$(cast call "$WMON" "balanceOf(address)(uint256)" "$SENDER" --rpc-url "$MONAD_RPC" | cast --to-dec) +BAL=$(cast call "$WMON" "balanceOf(address)(uint256)" "$SENDER" --rpc-url "$MONAD_RPC" | awk '{print $1}' | cast --to-dec 2>/dev/null || echo 0) echo "Deployer WMON balance: $BAL wei" if [[ "$BAL" -lt "$AMOUNT_WEI" ]]; then @@ -86,10 +93,17 @@ fi cast send "$WMON" "approve(address,uint256)" "$BRIDGE" "$AMOUNT_WEI" \ --rpc-url "$MONAD_RPC" --private-key "$PRIVATE_KEY" --legacy -OUT=$(cast send "$BRIDGE" "sendCrossChain(uint64,address,uint256)" "$SEL" "$RECIPIENT" "$AMOUNT_WEI" \ - --value "$FEE_WEI" --rpc-url "$MONAD_RPC" --private-key "$PRIVATE_KEY" --legacy) +if [[ "${FEE_TOKEN,,}" != "${ZERO,,}" ]] && [[ "$FEE_WEI" -gt 0 ]]; then + cast send "$FEE_TOKEN" "approve(address,uint256)" "$BRIDGE" "$((FEE_WEI + 1))" \ + --rpc-url "$MONAD_RPC" --private-key "$PRIVATE_KEY" --legacy + OUT=$(cast send "$BRIDGE" "sendCrossChain(uint64,address,uint256)" "$SEL" "$RECIPIENT" "$AMOUNT_WEI" \ + --rpc-url "$MONAD_RPC" --private-key "$PRIVATE_KEY" --legacy --json) +else + OUT=$(cast send "$BRIDGE" "sendCrossChain(uint64,address,uint256)" "$SEL" "$RECIPIENT" "$AMOUNT_WEI" \ + --value "$FEE_WEI" --rpc-url "$MONAD_RPC" --private-key "$PRIVATE_KEY" --legacy --json) +fi echo "$OUT" -HASH=$(echo "$OUT" | awk '/transactionHash/{print $2}') +HASH=$(echo "$OUT" | jq -r '.transactionHash // empty') echo "" echo "Submitted: $HASH" echo "Monad explorer: https://monadscan.com/tx/${HASH}" diff --git a/scripts/deployment/redeploy-monad-relay-bridge-wmon.sh b/scripts/deployment/redeploy-monad-relay-bridge-wmon.sh new file mode 100755 index 0000000..91679f0 --- /dev/null +++ b/scripts/deployment/redeploy-monad-relay-bridge-wmon.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Redeploy Monad CCIPRelayBridge with canonical WMON (fixes wrong USDC address at 0x754704…). +# +# Usage: ./scripts/deployment/redeploy-monad-relay-bridge-wmon.sh --execute + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$PROJECT_ROOT" + +# shellcheck disable=SC1090 +source "$SCRIPT_DIR/../lib/deployment/dotenv.sh" +load_deployment_env --repo-root "$PROJECT_ROOT" + +EXECUTE=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --execute) EXECUTE=1; shift ;; + --dry-run) echo "[DRY RUN] redeploy Monad relay bridge with WMON=$WMON_CANONICAL"; exit 0 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +[[ "$EXECUTE" == "1" ]] || { echo "Pass --execute"; exit 0; } +require_private_key_env || exit 1 +command -v jq >/dev/null 2>&1 || { echo "jq required"; exit 1; } + +RPC="${MONAD_MAINNET_RPC_PUBLIC:-https://rpc.monad.xyz}" +WMON_CANONICAL="${WMON_MAINNET:-0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A}" +ROUTER="${CCIP_RELAY_ROUTER_MONAD:-0x89dd12025bfCD38A168455A44B400e913ED33BE2}" + +sym=$(cast call "$WMON_CANONICAL" "symbol()(string)" --rpc-url "$RPC" 2>/dev/null | tr -d '"' || echo "?") +[[ "$sym" == "WMON" ]] || { echo "ERROR: $WMON_CANONICAL symbol=$sym (expected WMON)" >&2; exit 1; } + +echo "=== Redeploy Monad CCIPRelayBridge (WMON=$WMON_CANONICAL) ===" + +LOG="/tmp/monad-relay-bridge-wmon.log" +out=$(bash "$PROJECT_ROOT/scripts/forge/scope.sh" create relay contracts/relay/CCIPRelayBridge.sol:CCIPRelayBridge \ + --rpc-url "$RPC" --private-key "$PRIVATE_KEY" --legacy \ + --gas-limit 3500000 --broadcast \ + --constructor-args "$WMON_CANONICAL" "$ROUTER" 2>&1) || { + printf '%s\n' "$out" | tee "$LOG" + exit 1 +} +printf '%s\n' "$out" | tee "$LOG" + +BRIDGE=$(printf '%s\n' "$out" | sed -n 's/.*Deployed to: \(0x[0-9a-fA-F]\{40\}\).*/\1/p' | head -1) +[[ -n "$BRIDGE" ]] || { echo "ERROR: parse bridge address failed" >&2; exit 1; } + +OK=$(cast call "$ROUTER" "authorizedBridges(address)(bool)" "$BRIDGE" --rpc-url "$RPC" 2>/dev/null || echo "false") +if [[ "$OK" != "true" ]]; then + cast send "$ROUTER" "authorizeBridge(address)" "$BRIDGE" \ + --rpc-url "$RPC" --private-key "$PRIVATE_KEY" --legacy --gas-limit 200000 +fi + +echo "" +echo "CCIP_RELAY_BRIDGE_MONAD=$BRIDGE" +echo "Update smom-dbis-138/.env and re-run wire-monad-chain138-relay.sh --execute" diff --git a/scripts/deployment/redeploy-monad-send-bridge-wmon.sh b/scripts/deployment/redeploy-monad-send-bridge-wmon.sh new file mode 100755 index 0000000..84784d8 --- /dev/null +++ b/scripts/deployment/redeploy-monad-send-bridge-wmon.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Redeploy Monad outbound CCIPWETH9Bridge with canonical WMON on existing send router. +# +# Usage: ./scripts/deployment/redeploy-monad-send-bridge-wmon.sh --execute + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$PROJECT_ROOT" + +# shellcheck disable=SC1090 +source "$SCRIPT_DIR/../lib/deployment/dotenv.sh" +load_deployment_env --repo-root "$PROJECT_ROOT" + +EXECUTE=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --execute) EXECUTE=1; shift ;; + --dry-run) echo "[DRY RUN] redeploy Monad send bridge"; exit 0 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +[[ "$EXECUTE" == "1" ]] || { echo "Pass --execute"; exit 0; } +require_private_key_env || exit 1 + +RPC="${MONAD_MAINNET_RPC_PUBLIC:-https://rpc.monad.xyz}" +WMON="${WMON_MAINNET:-0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A}" +ROUTER="${CCIP_ROUTER_MONAD_SEND:-0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c}" +FEE="${CCIP_FEE_TOKEN:-0x0000000000000000000000000000000000000000}" +CH138_SEL="${CHAIN138_SELECTOR:-16015286601757825753}" +CH138_BRIDGE="${CCIP_RELAY_BRIDGE_CHAIN138_WETH:-}" + +sym=$(cast call "$WMON" "symbol()(string)" --rpc-url "$RPC" 2>/dev/null | tr -d '"' || echo "?") +[[ "$sym" == "WMON" ]] || { echo "ERROR: $WMON symbol=$sym (expected WMON)" >&2; exit 1; } + +echo "=== Redeploy Monad outbound CCIPWETH9Bridge ===" +echo "Router: $ROUTER WMON: $WMON" + +LOG="/tmp/monad-send-bridge-wmon.log" +out=$(bash "$PROJECT_ROOT/scripts/forge/scope.sh" create ccip contracts/ccip/CCIPWETH9Bridge.sol:CCIPWETH9Bridge \ + --rpc-url "$RPC" --private-key "$PRIVATE_KEY" --legacy \ + --gas-limit 5000000 --broadcast \ + --constructor-args "$ROUTER" "$WMON" "$FEE" 2>&1) || { + printf '%s\n' "$out" | tee "$LOG" + exit 1 +} +printf '%s\n' "$out" | tee "$LOG" + +BRIDGE=$(printf '%s\n' "$out" | sed -n 's/.*Deployed to: \(0x[0-9a-fA-F]\{40\}\).*/\1/p' | head -1) +[[ -n "$BRIDGE" ]] || { echo "ERROR: parse bridge address failed" >&2; exit 1; } + +if [[ -n "$CH138_BRIDGE" ]]; then + enabled=$(cast call "$BRIDGE" "destinations(uint64)(uint64,address,bool)" "$CH138_SEL" --rpc-url "$RPC" 2>/dev/null | awk 'NR==3{print $1}') + if [[ "$enabled" != "true" ]]; then + cast send "$BRIDGE" "addDestination(uint64,address)" "$CH138_SEL" "$CH138_BRIDGE" \ + --rpc-url "$RPC" --private-key "$PRIVATE_KEY" --legacy --gas-limit 300000 + else + cast send "$BRIDGE" "updateDestination(uint64,address)" "$CH138_SEL" "$CH138_BRIDGE" \ + --rpc-url "$RPC" --private-key "$PRIVATE_KEY" --legacy --gas-limit 300000 + fi +fi + +echo "" +echo "CCIPWETH9_BRIDGE_MONAD_SEND=$BRIDGE" diff --git a/scripts/deployment/seed-chain138-canonical-pmm-pools.sh b/scripts/deployment/seed-chain138-canonical-pmm-pools.sh index 1fc2111..82421dd 100755 --- a/scripts/deployment/seed-chain138-canonical-pmm-pools.sh +++ b/scripts/deployment/seed-chain138-canonical-pmm-pools.sh @@ -130,12 +130,12 @@ for row in "${PAIRS[@]}"; do base_balance="$(balance_for "$base_token" "$DEPLOYER")" quote_balance="$(balance_for "$quote_token" "$DEPLOYER")" if (( base_balance < base_amount )); then - echo "FAIL seed $label -> insufficient $base_sym balance ($base_balance < $base_amount)" - exit 1 + echo "SKIP seed $label -> insufficient $base_sym balance ($base_balance < $base_amount)" + continue fi if (( quote_balance < quote_amount )); then - echo "FAIL seed $label -> insufficient $quote_sym balance ($quote_balance < $quote_amount)" - exit 1 + echo "SKIP seed $label -> insufficient $quote_sym balance ($quote_balance < $quote_amount)" + continue fi approve_if_needed "$base_token" diff --git a/scripts/deployment/wire-monad-chain138-relay.sh b/scripts/deployment/wire-monad-chain138-relay.sh new file mode 100755 index 0000000..b55a301 --- /dev/null +++ b/scripts/deployment/wire-monad-chain138-relay.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Wire Monad↔138 relay lanes after CCIP relay contracts are deployed. +# +# 138→Monad: updateDestination on 138 bridges → CCIP_RELAY_BRIDGE_MONAD +# Monad→138: addDestination on CCIPWETH9_BRIDGE_MONAD_SEND → CCIP_RELAY_BRIDGE_CHAIN138 +# Authorize 138 WETH relay bridge on CCIP_RELAY_ROUTER_CHAIN138 +# +# Usage: +# DRY_RUN=1 ./scripts/deployment/wire-monad-chain138-relay.sh +# ./scripts/deployment/wire-monad-chain138-relay.sh --execute + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$PROJECT_ROOT" + +# shellcheck disable=SC1090 +source "$SCRIPT_DIR/../lib/deployment/dotenv.sh" +load_deployment_env --repo-root "$PROJECT_ROOT" + +EXECUTE=0 +DRY_RUN="${DRY_RUN:-0}" +while [[ $# -gt 0 ]]; do + case "$1" in + --execute) EXECUTE=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +RPC138="${RPC_URL_138:-http://192.168.11.211:8545}" +MONAD_RPC="${MONAD_MAINNET_RPC_PUBLIC:-https://rpc.monad.xyz}" +if [[ "${MONAD_MAINNET_RPC:-}" =~ infura\.io/v3 ]] && [[ "${MONAD_MAINNET_RPC_FORCE_INFURA:-0}" != "1" ]]; then + MONAD_RPC="${MONAD_MAINNET_RPC_PUBLIC:-https://rpc.monad.xyz}" +fi + +MONAD_SEL="${MONAD_SELECTOR:-8481857512324358265}" +CH138_SEL="${CHAIN138_SELECTOR:-}" +W9_138="${CCIPWETH9_BRIDGE_CHAIN138:-}" +W10_138="${CCIPWETH10_BRIDGE_CHAIN138:-}" +MONAD_RELAY_BRIDGE="${CCIP_RELAY_BRIDGE_MONAD:-}" +MONAD_SEND_BRIDGE="${CCIPWETH9_BRIDGE_MONAD_SEND:-}" +CH138_RELAY_ROUTER="${CCIP_RELAY_ROUTER_CHAIN138:-}" +CH138_RELAY_BRIDGE="${CCIP_RELAY_BRIDGE_CHAIN138_WETH:-}" +GAS_138="--legacy --gas-limit 250000 --gas-price 15000000000 --timeout 600" + +missing=0 +for v in CH138_SEL W9_138 W10_138 MONAD_RELAY_BRIDGE CH138_RELAY_ROUTER CH138_RELAY_BRIDGE MONAD_SEND_BRIDGE; do + if [[ -z "${!v}" ]]; then + echo "Missing: $v" >&2 + missing=1 + fi +done +[[ "$missing" == 1 ]] && exit 1 + +require_private_key_env || exit 1 + +update_dest() { + local rpc="$1" bridge="$2" sel="$3" receiver="$4" label="$5" gas="$6" + local current enabled + current=$(cast call "$bridge" "destinations(uint64)(uint64,address,bool)" "$sel" --rpc-url "$rpc" 2>/dev/null | awk 'NR==2{print $1}') + enabled=$(cast call "$bridge" "destinations(uint64)(uint64,address,bool)" "$sel" --rpc-url "$rpc" 2>/dev/null | awk 'NR==3{print $1}') + if [[ "${enabled,,}" == "true" && "${current,,}" == "${receiver,,}" ]]; then + echo "Skip (aligned): $label" + return 0 + fi + if [[ "$DRY_RUN" == "1" ]]; then + echo "[DRY RUN] $label: updateDestination($sel,$receiver) on $bridge" + return 0 + fi + echo "=== $label ===" + # shellcheck disable=SC2086 + cast send "$bridge" "updateDestination(uint64,address)" "$sel" "$receiver" \ + --rpc-url "$rpc" --private-key "$PRIVATE_KEY" $gas +} + +add_dest() { + local rpc="$1" bridge="$2" sel="$3" receiver="$4" label="$5" gas="$6" + local enabled + enabled=$(cast call "$bridge" "destinations(uint64)(uint64,address,bool)" "$sel" --rpc-url "$rpc" 2>/dev/null | awk 'NR==3{print $1}') + if [[ "$enabled" == "true" ]]; then + update_dest "$rpc" "$bridge" "$sel" "$receiver" "$label (update)" "$gas" + return 0 + fi + if [[ "$DRY_RUN" == "1" ]]; then + echo "[DRY RUN] $label: addDestination($sel,$receiver) on $bridge" + return 0 + fi + echo "=== $label ===" + # shellcheck disable=SC2086 + cast send "$bridge" "addDestination(uint64,address)" "$sel" "$receiver" \ + --rpc-url "$rpc" --private-key "$PRIVATE_KEY" $gas +} + +authorize_bridge() { + local rpc="$1" router="$2" bridge="$3" + local ok + ok=$(cast call "$router" "authorizedBridges(address)(bool)" "$bridge" --rpc-url "$rpc" 2>/dev/null || echo "false") + if [[ "$ok" == "true" ]]; then + echo "Skip: $bridge already authorized on $router" + return 0 + fi + if [[ "$DRY_RUN" == "1" ]]; then + echo "[DRY RUN] authorizeBridge($bridge) on $router" + return 0 + fi + echo "=== Authorize 138 WETH relay bridge on router ===" + cast send "$router" "authorizeBridge(address)" "$bridge" \ + --rpc-url "$rpc" --private-key "$PRIVATE_KEY" $GAS_138 +} + +echo "=== Wire Monad↔138 relay destinations ===" +update_dest "$RPC138" "$W9_138" "$MONAD_SEL" "$MONAD_RELAY_BRIDGE" "138 WETH9 → Monad relay bridge" "$GAS_138" +update_dest "$RPC138" "$W10_138" "$MONAD_SEL" "$MONAD_RELAY_BRIDGE" "138 WETH10 → Monad relay bridge" "$GAS_138" +add_dest "$MONAD_RPC" "$MONAD_SEND_BRIDGE" "$CH138_SEL" "$CH138_RELAY_BRIDGE" "Monad send → 138 WETH relay bridge" "--legacy --timeout 600" +authorize_bridge "$RPC138" "$CH138_RELAY_ROUTER" "$CH138_RELAY_BRIDGE" +echo "Done." diff --git a/scripts/forge/scope.sh b/scripts/forge/scope.sh index 37dc88a..83aea95 100755 --- a/scripts/forge/scope.sh +++ b/scripts/forge/scope.sh @@ -250,7 +250,7 @@ prepare_scope_env() { elif [[ "$scope" == "m00-diamond" ]]; then export FOUNDRY_SRC="contracts/m00-diamond,contracts/rwa" export FOUNDRY_PROFILE="${FOUNDRY_PROFILE:-m00-diamond}" - elif [[ "$scope" == "oracle" ]]; then + elif [[ "$scope" == "oracle" || "$scope" == "reserve" ]]; then export FOUNDRY_SRC="$src_dir" export FOUNDRY_PROFILE="${FOUNDRY_PROFILE:-chain138}" else @@ -338,7 +338,7 @@ main() { if [[ "$scope" != "full" && -z "${FOUNDRY_TEST:-}" ]]; then die "scope '$scope' has no matching test/ directory" fi - exec forge test "$@" + exec forge test --skip script "$@" ;; script) [[ $# -gt 0 ]] || die "script command requires a script target, e.g. script/treasury/DeployTreasuryExecutor138.s.sol:DeployTreasuryExecutor138" diff --git a/services/oracle-publisher/oracle_publisher.py b/services/oracle-publisher/oracle_publisher.py index 994b3bb..cad1160 100644 --- a/services/oracle-publisher/oracle_publisher.py +++ b/services/oracle-publisher/oracle_publisher.py @@ -200,37 +200,42 @@ class OraclePublisher: gas_price = self.config.gas_price else: gas_price = self.w3.eth.gas_price - - # Build transaction - transaction = self.aggregator_contract.functions.updateAnswer(price_int).build_transaction({ - 'from': self.account.address, - 'nonce': nonce, - 'gas': self.config.gas_limit, - 'gasPrice': gas_price, - 'chainId': 138 - }) - - # Sign transaction - signed_txn = self.account.sign_transaction(transaction) - - # Send transaction - tx_hash = self.w3.eth.send_raw_transaction(signed_txn.rawTransaction) - logger.info(f"Transaction sent: {tx_hash.hex()}") - - # Wait for receipt - receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120) - if receipt.status == 1: - logger.info(f"Transaction confirmed: {tx_hash.hex()}") - updates_sent.inc() - current_price.set(float(price)) - self.last_price = price - self.last_update_time = time.time() - return True - else: - logger.error(f"Transaction failed: {tx_hash.hex()}") - update_errors.inc() - return False - + + for attempt in range(3): + bumped_gas = int(gas_price * (1.15 ** attempt)) + transaction = self.aggregator_contract.functions.updateAnswer(price_int).build_transaction({ + 'from': self.account.address, + 'nonce': nonce, + 'gas': self.config.gas_limit, + 'gasPrice': bumped_gas, + 'chainId': 138 + }) + + signed_txn = self.account.sign_transaction(transaction) + + try: + tx_hash = self.w3.eth.send_raw_transaction(signed_txn.rawTransaction) + logger.info(f"Transaction sent: {tx_hash.hex()} (gasPrice={bumped_gas})") + receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120) + if receipt.status == 1: + logger.info(f"Transaction confirmed: {tx_hash.hex()}") + updates_sent.inc() + current_price.set(float(price)) + self.last_price = price + self.last_update_time = time.time() + return True + + logger.error(f"Transaction failed: {tx_hash.hex()}") + update_errors.inc() + return False + except Exception as send_error: + message = str(send_error).lower() + if 'underpriced' in message and attempt < 2: + logger.warning(f"Replacement underpriced at gasPrice={bumped_gas}; retrying with higher gas") + continue + raise send_error + + return False except Exception as e: logger.error(f"Error updating oracle: {e}") update_errors.inc() diff --git a/services/relay/src/abis.js b/services/relay/src/abis.js index 20d4ae7..45f0a90 100644 --- a/services/relay/src/abis.js +++ b/services/relay/src/abis.js @@ -14,6 +14,9 @@ export const RelayRouterABI = [ ]; export const RelayBridgeABI = [ + // CWMultiTokenBridgeL2 / Chainlink receivers: destTokenAmounts (address,uint256)[] + "function ccipReceive(tuple(bytes32 messageId, uint64 sourceChainSelector, bytes sender, bytes data, tuple(address token, uint256 amount)[] destTokenAmounts) calldata message) external", + // Legacy CCIPRelayBridge (WETH): tokenAmounts with amountType "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)", diff --git a/services/relay/src/config.js b/services/relay/src/config.js index f7c5a22..9c7b7f0 100644 --- a/services/relay/src/config.js +++ b/services/relay/src/config.js @@ -54,6 +54,13 @@ function getExplicitTokenMappingOverrides() { if (destinationWeth9 && !destinationWeth9.includes('${')) { overrides['0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'.toLowerCase()] = destinationWeth9; + const sourceWmon = + process.env.SOURCE_WMON_ADDRESS || + process.env.SOURCE_WETH9_ADDRESS || + ''; + if (sourceWmon && !sourceWmon.includes('${')) { + overrides[sourceWmon.toLowerCase()] = destinationWeth9; + } } if (destinationLink && !destinationLink.includes('${')) { diff --git a/services/token-aggregation/.env.example b/services/token-aggregation/.env.example index 7842114..7d1cf0c 100644 --- a/services/token-aggregation/.env.example +++ b/services/token-aggregation/.env.example @@ -17,7 +17,7 @@ LOG_LEVEL=info # 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_PMM_INTEGRATION=0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895 # CHAIN_138_DODO_POOL_MANAGER= # Chain 138 DEX factories (optional — dex-factories.ts has live defaults) diff --git a/services/token-aggregation/config/token-lists/dbis-138.tokenlist.json b/services/token-aggregation/config/token-lists/dbis-138.tokenlist.json index 877a109..b8ced78 100644 --- a/services/token-aggregation/config/token-lists/dbis-138.tokenlist.json +++ b/services/token-aggregation/config/token-lists/dbis-138.tokenlist.json @@ -3,9 +3,9 @@ "version": { "major": 1, "minor": 6, - "patch": 8 + "patch": 9 }, - "timestamp": "2026-06-08T17:45:21.741Z", + "timestamp": "2026-06-13T22:54:22.248Z", "keywords": [ "dbis", "chain138", @@ -41,6 +41,84 @@ "fwdCanon": false } }, + { + "chainId": 138, + "address": "0xcaaa00000000000000000000000000000000008a", + "name": "Avalanche (Compliant)", + "symbol": "cAVAX", + "decimals": 18, + "logoURI": "https://d-bis.org/tokens/cavax.svg", + "tags": [ + "defi", + "compliant", + "gru" + ], + "extensions": { + "category": "gas-native-token", + "instrument": "gas-native-transport", + "settlement": "crypto-native", + "cashLike": false, + "backing": "native-gas-asset", + "gruVersion": "v1", + "gruFamily": "cAVAX", + "registryFamily": "gas_native", + "walletClass": "token" + } + }, + { + "chainId": 138, + "address": "0xcb6b00000000000000000000000000000000008a", + "name": "BNB (Compliant)", + "symbol": "cBNB", + "decimals": 18, + "logoURI": "https://d-bis.org/tokens/cbnb.svg", + "tags": [ + "defi", + "compliant", + "gru" + ], + "extensions": { + "category": "gas-native-token", + "instrument": "gas-native-transport", + "settlement": "crypto-native", + "cashLike": false, + "backing": "native-gas-asset", + "gruVersion": "v1", + "gruFamily": "cBNB", + "registryFamily": "gas_native", + "walletClass": "token" + } + }, + { + "chainId": 138, + "address": "0xe94260c555ac1d9d3cc9e1632883452ebdf0082e", + "name": "Bitcoin (Compliant)", + "symbol": "cBTC", + "decimals": 8, + "logoURI": "https://d-bis.org/tokens/cbtc.svg", + "tags": [ + "defi", + "compliant", + "gru" + ], + "extensions": { + "category": "gru-emoney", + "instrument": "commodity-referenced-token", + "settlement": "commodity", + "cashLike": false, + "backing": "commodity-reserves", + "gruVersion": "v1", + "gruFamily": "cBTC", + "walletClass": "token", + "commodity": "bitcoin", + "assetClass": "Commodities", + "assetGroup": "Digital Commodities", + "instrumentType": "eMoney", + "underlying": "BTC", + "gruLayer": "M1", + "rwaEligible": false + } + }, { "chainId": 138, "address": "0x54dBd40cF05e15906A2C21f600937e96787f5679", @@ -69,6 +147,30 @@ "fwdCanon": false } }, + { + "chainId": 138, + "address": "0xcce100000000000000000000000000000000008a", + "name": "Celo (Compliant)", + "symbol": "cCELO", + "decimals": 18, + "logoURI": "https://d-bis.org/tokens/ccelo.svg", + "tags": [ + "defi", + "compliant", + "gru" + ], + "extensions": { + "category": "gas-native-token", + "instrument": "gas-native-transport", + "settlement": "crypto-native", + "cashLike": false, + "backing": "native-gas-asset", + "gruVersion": "v1", + "gruFamily": "cCELO", + "registryFamily": "gas_native", + "walletClass": "token" + } + }, { "chainId": 138, "address": "0x873990849DDa5117d7C644f0aF24370797C03885", @@ -97,6 +199,78 @@ "fwdCanon": false } }, + { + "chainId": 138, + "address": "0xcc2000000000000000000000000000000000008a", + "name": "Cronos (Compliant)", + "symbol": "cCRO", + "decimals": 18, + "logoURI": "https://d-bis.org/tokens/ccro.svg", + "tags": [ + "defi", + "compliant", + "gru" + ], + "extensions": { + "category": "gas-native-token", + "instrument": "gas-native-transport", + "settlement": "crypto-native", + "cashLike": false, + "backing": "native-gas-asset", + "gruVersion": "v1", + "gruFamily": "cCRO", + "registryFamily": "gas_native", + "walletClass": "token" + } + }, + { + "chainId": 138, + "address": "0xce7e00000000000000000000000000000000008a", + "name": "Ether Mainnet (Compliant)", + "symbol": "cETH", + "decimals": 18, + "logoURI": "https://d-bis.org/tokens/ceth.svg", + "tags": [ + "defi", + "compliant", + "gru" + ], + "extensions": { + "category": "gas-native-token", + "instrument": "gas-native-transport", + "settlement": "crypto-native", + "cashLike": false, + "backing": "native-gas-asset", + "gruVersion": "v1", + "gruFamily": "cETH", + "registryFamily": "gas_native", + "walletClass": "token" + } + }, + { + "chainId": 138, + "address": "0xce7200000000000000000000000000000000008a", + "name": "Ether L2 Basket (Compliant)", + "symbol": "cETHL2", + "decimals": 18, + "logoURI": "https://d-bis.org/tokens/cethl2.svg", + "tags": [ + "defi", + "compliant", + "gru" + ], + "extensions": { + "category": "gas-native-token", + "instrument": "gas-native-transport", + "settlement": "crypto-native", + "cashLike": false, + "backing": "native-gas-asset", + "gruVersion": "v1", + "gruFamily": "cETHL2", + "registryFamily": "gas_native", + "walletClass": "token" + } + }, { "chainId": 138, "address": "0x8085961F9cF02b4d800A3c6d386D31da4B34266a", @@ -237,6 +411,30 @@ "fwdCanon": false } }, + { + "chainId": 138, + "address": "0xc90100000000000000000000000000000000008a", + "name": "POL (Compliant)", + "symbol": "cPOL", + "decimals": 18, + "logoURI": "https://d-bis.org/tokens/cpol.svg", + "tags": [ + "defi", + "compliant", + "gru" + ], + "extensions": { + "category": "gas-native-token", + "instrument": "gas-native-transport", + "settlement": "crypto-native", + "cashLike": false, + "backing": "native-gas-asset", + "gruVersion": "v1", + "gruFamily": "cPOL", + "registryFamily": "gas_native", + "walletClass": "token" + } + }, { "chainId": 138, "address": "0xf22258f57794CC8E06237084b353Ab30fFfa640b", @@ -305,6 +503,30 @@ "rwaEligible": false } }, + { + "chainId": 138, + "address": "0xc11100000000000000000000000000000000008a", + "name": "Wemix Hub (Compliant)", + "symbol": "cWEMIX", + "decimals": 18, + "logoURI": "https://d-bis.org/tokens/cwemix.svg", + "tags": [ + "defi", + "compliant", + "gru" + ], + "extensions": { + "category": "gas-native-token", + "instrument": "gas-native-transport", + "settlement": "crypto-native", + "cashLike": false, + "backing": "native-gas-asset", + "gruVersion": "v1", + "gruFamily": "cWEMIX", + "registryFamily": "gas_native", + "walletClass": "token" + } + }, { "chainId": 138, "address": "0x290E52a8819A4fbD0714E517225429aA2B70EC6b", @@ -336,216 +558,6 @@ "rwaEligible": false } }, - { - "chainId": 138, - "address": "0xe94260c555ac1d9d3cc9e1632883452ebdf0082e", - "name": "Bitcoin (Compliant)", - "symbol": "cBTC", - "decimals": 8, - "logoURI": "https://d-bis.org/tokens/cbtc.svg", - "tags": [ - "defi", - "compliant", - "gru" - ], - "extensions": { - "category": "gru-emoney", - "instrument": "commodity-referenced-token", - "settlement": "commodity", - "cashLike": false, - "backing": "commodity-reserves", - "gruVersion": "v1", - "gruFamily": "cBTC", - "walletClass": "token", - "commodity": "bitcoin", - "assetClass": "Commodities", - "assetGroup": "Digital Commodities", - "instrumentType": "eMoney", - "underlying": "BTC", - "gruLayer": "M1", - "rwaEligible": false - } - }, - { - "chainId": 138, - "address": "0xce7e00000000000000000000000000000000008a", - "name": "Ether Mainnet (Compliant)", - "symbol": "cETH", - "decimals": 18, - "logoURI": "https://d-bis.org/tokens/ceth.svg", - "tags": ["defi", "compliant", "gru"], - "extensions": { - "category": "gas-native-token", - "instrument": "gas-native-transport", - "settlement": "crypto-native", - "cashLike": false, - "backing": "native-gas-asset", - "gruVersion": "v1", - "gruFamily": "cETH", - "registryFamily": "gas_native", - "walletClass": "token" - } - }, - { - "chainId": 138, - "address": "0xce7200000000000000000000000000000000008a", - "name": "Ether L2 Basket (Compliant)", - "symbol": "cETHL2", - "decimals": 18, - "logoURI": "https://d-bis.org/tokens/cethl2.svg", - "tags": ["defi", "compliant", "gru"], - "extensions": { - "category": "gas-native-token", - "instrument": "gas-native-transport", - "settlement": "crypto-native", - "cashLike": false, - "backing": "native-gas-asset", - "gruVersion": "v1", - "gruFamily": "cETHL2", - "registryFamily": "gas_native", - "walletClass": "token" - } - }, - { - "chainId": 138, - "address": "0xcb6b00000000000000000000000000000000008a", - "name": "BNB (Compliant)", - "symbol": "cBNB", - "decimals": 18, - "logoURI": "https://d-bis.org/tokens/cbnb.svg", - "tags": ["defi", "compliant", "gru"], - "extensions": { - "category": "gas-native-token", - "instrument": "gas-native-transport", - "settlement": "crypto-native", - "cashLike": false, - "backing": "native-gas-asset", - "gruVersion": "v1", - "gruFamily": "cBNB", - "registryFamily": "gas_native", - "walletClass": "token" - } - }, - { - "chainId": 138, - "address": "0xc90100000000000000000000000000000000008a", - "name": "POL (Compliant)", - "symbol": "cPOL", - "decimals": 18, - "logoURI": "https://d-bis.org/tokens/cpol.svg", - "tags": ["defi", "compliant", "gru"], - "extensions": { - "category": "gas-native-token", - "instrument": "gas-native-transport", - "settlement": "crypto-native", - "cashLike": false, - "backing": "native-gas-asset", - "gruVersion": "v1", - "gruFamily": "cPOL", - "registryFamily": "gas_native", - "walletClass": "token" - } - }, - { - "chainId": 138, - "address": "0xcaaa00000000000000000000000000000000008a", - "name": "Avalanche (Compliant)", - "symbol": "cAVAX", - "decimals": 18, - "logoURI": "https://d-bis.org/tokens/cavax.svg", - "tags": ["defi", "compliant", "gru"], - "extensions": { - "category": "gas-native-token", - "instrument": "gas-native-transport", - "settlement": "crypto-native", - "cashLike": false, - "backing": "native-gas-asset", - "gruVersion": "v1", - "gruFamily": "cAVAX", - "registryFamily": "gas_native", - "walletClass": "token" - } - }, - { - "chainId": 138, - "address": "0xcc2000000000000000000000000000000000008a", - "name": "Cronos (Compliant)", - "symbol": "cCRO", - "decimals": 18, - "logoURI": "https://d-bis.org/tokens/ccro.svg", - "tags": ["defi", "compliant", "gru"], - "extensions": { - "category": "gas-native-token", - "instrument": "gas-native-transport", - "settlement": "crypto-native", - "cashLike": false, - "backing": "native-gas-asset", - "gruVersion": "v1", - "gruFamily": "cCRO", - "registryFamily": "gas_native", - "walletClass": "token" - } - }, - { - "chainId": 138, - "address": "0xcda100000000000000000000000000000000008a", - "name": "xDAI (Compliant)", - "symbol": "cXDAI", - "decimals": 18, - "logoURI": "https://d-bis.org/tokens/cxdai.svg", - "tags": ["defi", "compliant", "gru"], - "extensions": { - "category": "gas-native-token", - "instrument": "gas-native-transport", - "settlement": "crypto-native", - "cashLike": false, - "backing": "native-gas-asset", - "gruVersion": "v1", - "gruFamily": "cXDAI", - "registryFamily": "gas_native", - "walletClass": "token" - } - }, - { - "chainId": 138, - "address": "0xcce100000000000000000000000000000000008a", - "name": "Celo (Compliant)", - "symbol": "cCELO", - "decimals": 18, - "logoURI": "https://d-bis.org/tokens/ccelo.svg", - "tags": ["defi", "compliant", "gru"], - "extensions": { - "category": "gas-native-token", - "instrument": "gas-native-transport", - "settlement": "crypto-native", - "cashLike": false, - "backing": "native-gas-asset", - "gruVersion": "v1", - "gruFamily": "cCELO", - "registryFamily": "gas_native", - "walletClass": "token" - } - }, - { - "chainId": 138, - "address": "0xc11100000000000000000000000000000000008a", - "name": "Wemix Hub (Compliant)", - "symbol": "cWEMIX", - "decimals": 18, - "logoURI": "https://d-bis.org/tokens/cwemix.svg", - "tags": ["defi", "compliant", "gru"], - "extensions": { - "category": "gas-native-token", - "instrument": "gas-native-transport", - "settlement": "crypto-native", - "cashLike": false, - "backing": "native-gas-asset", - "gruVersion": "v1", - "gruFamily": "cWEMIX", - "registryFamily": "gas_native", - "walletClass": "token" - } - }, { "chainId": 138, "address": "0x94e408E26c6FD8F4ee00b54dF19082FDA07dC96E", @@ -577,6 +589,30 @@ "rwaEligible": false } }, + { + "chainId": 138, + "address": "0xcda100000000000000000000000000000000008a", + "name": "xDAI (Compliant)", + "symbol": "cXDAI", + "decimals": 18, + "logoURI": "https://d-bis.org/tokens/cxdai.svg", + "tags": [ + "defi", + "compliant", + "gru" + ], + "extensions": { + "category": "gas-native-token", + "instrument": "gas-native-transport", + "settlement": "crypto-native", + "cashLike": false, + "backing": "native-gas-asset", + "gruVersion": "v1", + "gruFamily": "cXDAI", + "registryFamily": "gas_native", + "walletClass": "token" + } + }, { "chainId": 138, "address": "0x3304b747E565a97ec8AC220b0B6A1f6ffDB837e6", @@ -587,7 +623,12 @@ "tags": [ "oracle", "pricefeed" - ] + ], + "extensions": { + "oracleReadAddress": "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506", + "oracleProxyAddress": "0x3304b747E565a97ec8AC220b0B6A1f6ffDB837e6", + "readNote": "Read latestRoundData on oracleReadAddress" + } }, { "chainId": 138, diff --git a/services/token-aggregation/jest.config.js b/services/token-aggregation/jest.config.js index 155e122..7b0bcbc 100644 --- a/services/token-aggregation/jest.config.js +++ b/services/token-aggregation/jest.config.js @@ -20,4 +20,5 @@ module.exports = { '/src/api/routes/token-mapping\\.test\\.ts$', '/src/api/routes/bridge\\.test\\.ts$', ], + setupFilesAfterEnv: ['/jest.setup.js'], }; diff --git a/services/token-aggregation/jest.setup.js b/services/token-aggregation/jest.setup.js new file mode 100644 index 0000000..21570bd --- /dev/null +++ b/services/token-aggregation/jest.setup.js @@ -0,0 +1,17 @@ +/** Jest vm context may not inherit Node web APIs or global fetch. */ +if (typeof globalThis.ReadableStream === 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { ReadableStream, WritableStream, TransformStream } = require('stream/web'); + globalThis.ReadableStream = ReadableStream; + globalThis.WritableStream = WritableStream; + globalThis.TransformStream = TransformStream; +} + +if (typeof globalThis.fetch === 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { fetch, Headers, Request, Response } = require('undici'); + globalThis.fetch = fetch; + globalThis.Headers = Headers; + globalThis.Request = Request; + globalThis.Response = Response; +} diff --git a/services/token-aggregation/package.json b/services/token-aggregation/package.json index 84c292e..7264374 100644 --- a/services/token-aggregation/package.json +++ b/services/token-aggregation/package.json @@ -62,6 +62,7 @@ "jest": "^29.7.0", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "undici": "^6.26.0" } } diff --git a/services/token-aggregation/src/adapters/coingecko-adapter.ts b/services/token-aggregation/src/adapters/coingecko-adapter.ts index 612915f..971b036 100644 --- a/services/token-aggregation/src/adapters/coingecko-adapter.ts +++ b/services/token-aggregation/src/adapters/coingecko-adapter.ts @@ -92,6 +92,7 @@ const REFERENCE_SYMBOL_TO_COIN_ID: Record = { CELO: 'celo', CRO: 'crypto-com-chain', XDAI: 'xdai', + LINK: 'chainlink', }; export class CoinGeckoAdapter implements ExternalApiAdapter { @@ -344,6 +345,10 @@ export class CoinGeckoAdapter implements ExternalApiAdapter { */ async getReferenceMarketData(referenceSymbol: string): Promise { const symbol = referenceSymbol.trim().toUpperCase(); + if (symbol === 'USD') { + return { priceUsd: 1, lastUpdated: new Date() }; + } + const coinId = REFERENCE_SYMBOL_TO_COIN_ID[symbol]; if (!coinId) { return null; diff --git a/services/token-aggregation/src/api/middleware/cache.ts b/services/token-aggregation/src/api/middleware/cache.ts index 151f71b..818545e 100644 --- a/services/token-aggregation/src/api/middleware/cache.ts +++ b/services/token-aggregation/src/api/middleware/cache.ts @@ -19,13 +19,33 @@ function looksLikeGenericErrorPayload(body: unknown): boolean { return true; } +function buildCacheKey(req: Request): string { + const base = `${req.method}:${req.originalUrl}`; + if (req.method !== 'POST' && req.method !== 'PUT' && req.method !== 'PATCH') { + return base; + } + try { + const body = req.body; + if (body == null) { + return base; + } + const serialized = typeof body === 'string' ? body : JSON.stringify(body); + if (!serialized || serialized === '{}' || serialized === '[]') { + return base; + } + return `${base}:${serialized}`; + } catch { + return base; + } +} + 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 key = buildCacheKey(req); const cached = bypassCache ? undefined : cache.get(key); if (cached && cached.expiresAt > Date.now()) { diff --git a/services/token-aggregation/src/api/middleware/omnl-audit-middleware.ts b/services/token-aggregation/src/api/middleware/omnl-audit-middleware.ts index 557cc28..878ae99 100644 --- a/services/token-aggregation/src/api/middleware/omnl-audit-middleware.ts +++ b/services/token-aggregation/src/api/middleware/omnl-audit-middleware.ts @@ -8,6 +8,9 @@ export function omnlAuditMiddleware(req: Request, res: Response, next: NextFunct correlationId: String(req.headers[CORRELATION_ID_HEADER] ?? req.headers['x-omnl-trace-id'] ?? ''), requestId: String(req.headers[REQUEST_ID_HEADER] ?? ''), }); + req.headers[CORRELATION_ID_HEADER] = ctx.correlationId; + req.headers['x-omnl-trace-id'] = ctx.correlationId; + req.headers[REQUEST_ID_HEADER] = ctx.requestId; const traceId = ctx.correlationId; res.setHeader('X-OMNL-Trace-Id', traceId); res.setHeader(CORRELATION_ID_HEADER, ctx.correlationId); diff --git a/services/token-aggregation/src/api/middleware/omnl-compliance-gate.ts b/services/token-aggregation/src/api/middleware/omnl-compliance-gate.ts index 6accbbf..fefb853 100644 --- a/services/token-aggregation/src/api/middleware/omnl-compliance-gate.ts +++ b/services/token-aggregation/src/api/middleware/omnl-compliance-gate.ts @@ -1,7 +1,9 @@ import type { Request, Response, NextFunction } from 'express'; -import { MockComplianceDecisionEngine } from '@dbis/integration-foundation'; +import path from 'node:path'; +import { createComplianceDecisionEngine } from '../../compliance/volume13-engine.js'; -const engine = new MockComplianceDecisionEngine(); +const PROXMOX_ROOT = process.env.PROXMOX_ROOT || path.resolve(process.cwd(), '../../../..'); +const engine = createComplianceDecisionEngine(PROXMOX_ROOT); const FINANCIAL_POST_PATHS = [ '/omnl/iso20022/messages', diff --git a/services/token-aggregation/src/api/routes/bridge.test.ts b/services/token-aggregation/src/api/routes/bridge.test.ts index 97dedc8..d6f09e7 100644 --- a/services/token-aggregation/src/api/routes/bridge.test.ts +++ b/services/token-aggregation/src/api/routes/bridge.test.ts @@ -245,3 +245,38 @@ describe('Bridge API GRU Transport status', () => { } }); }); + +describe('Bridge API relay lane messages', () => { + let server: ReturnType; + let baseUrl: string; + + beforeAll(async () => { + const started = await startServer(createApp()); + server = started.server; + baseUrl = started.baseUrl; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + }); + + it('returns 138-monad relay messages with seed canaries when skipRpc=1', async () => { + const res = await fetch(`${baseUrl}/api/v1/bridge/messages?lane=138-monad&skipRpc=1`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.ok).toBe(true); + expect(body.laneId).toBe('138-monad-relay'); + expect(body.integration).toMatchObject({ ccipExplorer: false, creRequired: false }); + const messages = body.messages as Array<{ messageId: string; status: string }>; + expect(Array.isArray(messages)).toBe(true); + expect(messages.length).toBeGreaterThanOrEqual(2); + expect(messages.some((m) => m.status === 'delivered')).toBe(true); + }); + + it('rejects unknown lane', async () => { + const res = await fetch(`${baseUrl}/api/v1/bridge/messages?lane=unknown`); + expect(res.status).toBe(400); + }); +}); diff --git a/services/token-aggregation/src/api/routes/bridge.ts b/services/token-aggregation/src/api/routes/bridge.ts index 196ae69..c76dd59 100644 --- a/services/token-aggregation/src/api/routes/bridge.ts +++ b/services/token-aggregation/src/api/routes/bridge.ts @@ -10,6 +10,7 @@ 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 { buildRelayLaneReport } from '../../indexer/relay-lane-indexer'; import { logger } from '../../utils/logger'; const router: Router = Router(); @@ -224,6 +225,41 @@ router.get('/metrics', (_req: Request, res: Response) => { }); }); +/** + * GET /api/v1/bridge/messages?lane=138-monad + * Custom relay lane tracker (Chain 138 ↔ Monad). Not indexed by ccip.chain.link. + */ +router.get('/messages', async (req: Request, res: Response) => { + const lane = String(req.query.lane ?? '138-monad').trim().toLowerCase(); + if (lane !== '138-monad' && lane !== 'monad-138' && lane !== '138-monad-relay') { + return res.status(400).json({ + ok: false, + error: 'Unsupported lane', + supported: ['138-monad', 'monad-138', '138-monad-relay'], + }); + } + + const limit = Math.min(500, Math.max(1, Number(req.query.limit ?? 100) || 100)); + const fromBlockDays = Math.min(90, Math.max(1, Number(req.query.days ?? 30) || 30)); + const skipRpc = req.query.skipRpc === '1' || req.query.skipRpc === 'true'; + + try { + const report = await buildRelayLaneReport({ fromBlockDays, limit, skipRpc }); + res.set('Cache-Control', 'public, max-age=30, stale-while-revalidate=120'); + return res.json({ + ok: true, + lane, + ...report, + }); + } catch (err) { + logger.error('bridge/messages failed:', err); + return res.status(503).json({ + ok: false, + error: 'Relay lane indexer unavailable', + }); + } +}); + router.get('/preflight', (_req: Request, res: Response) => { const gruTransport = buildGruTransportStatus(); if (!gruTransport) { diff --git a/services/token-aggregation/src/api/routes/config.test.ts b/services/token-aggregation/src/api/routes/config.test.ts index 59e1cea..28ff2ee 100644 --- a/services/token-aggregation/src/api/routes/config.test.ts +++ b/services/token-aggregation/src/api/routes/config.test.ts @@ -140,6 +140,49 @@ describe('Config API runtime networks loader', () => { } }); + it('merges built-in oracles when runtime networks file omits them', async () => { + const tempPath = path.join('/tmp', `token-aggregation-networks-no-oracles-${Date.now()}.json`); + await fs.writeFile( + tempPath, + JSON.stringify({ + version: { major: 1, minor: 2, patch: 0 }, + chains: [ + { + chainId: '0x8a', + chainIdDecimal: 138, + chainName: 'DeFi Oracle Meta Mainnet', + rpcUrls: ['https://rpc-http-pub.d-bis.org'], + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + blockExplorerUrls: ['https://explorer.d-bis.org'], + iconUrls: ['https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png'], + }, + ], + }) + ); + process.env.NETWORKS_JSON_PATH = tempPath; + + try { + const cfgRes = await fetch(`${baseUrl}/api/v1/config?chainId=138`); + expect(cfgRes.status).toBe(200); + const cfgBody = (await cfgRes.json()) as Record; + expect(cfgBody.oracles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'ETH/USD (aggregator)' }), + ]) + ); + + const networksRes = await fetch(`${baseUrl}/api/v1/networks`); + const networksBody = (await networksRes.json()) as Record; + const chain138 = networksBody.networks.find((entry: Record) => entry.chainIdDecimal === 138); + expect(chain138.oracles?.length).toBeGreaterThan(0); + expect(chain138.iconUrls).toEqual( + expect.arrayContaining(['https://explorer.d-bis.org/token-icons/chain-138.png']) + ); + } finally { + await fs.unlink(tempPath).catch(() => undefined); + } + }); + it('serves wallet-facing MetaMask aliases with absolute token images', async () => { const networksRes = await fetch(`${baseUrl}/api/v1/config/networks`); expect(networksRes.status).toBe(200); @@ -162,7 +205,10 @@ describe('Config API runtime networks loader', () => { expect(metamaskBody.addEthereumChain).toMatchObject({ chainId: '0x8a', chainName: 'DeFi Oracle Meta Mainnet', + rpcUrls: ['https://rpc-http-pub.d-bis.org'], }); + expect(metamaskBody.addEthereumChain).not.toHaveProperty('chainIdDecimal'); + expect(metamaskBody.addEthereumChain).not.toHaveProperty('oracles'); expect(metamaskBody.watchAssets).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -187,35 +233,45 @@ describe('Config API runtime networks loader', () => { image: expect.stringMatching(/^https:\/\/d-bis\.org\/tokens\/weth\.svg\?v=20260510$/), }), }), - expect.objectContaining({ - type: 'ERC20', - options: expect.objectContaining({ - symbol: 'cWEMIX', - image: expect.stringMatching(/^https:\/\/d-bis\.org\/tokens\/cwemix\.svg\?v=20260510$/), - }), - }), ]) ); + const placeholderSymbols = ['cAVAX', 'cBNB', 'cETH', 'cETHL2', 'cPOL', 'cCRO', 'cXDAI', 'cCELO', 'cWEMIX']; + for (const symbol of placeholderSymbols) { + expect( + metamaskBody.watchAssets.some((entry: Record) => entry.options?.symbol === symbol), + ).toBe(false); + } expect(metamaskBody.watchAssets).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'ERC20', options: expect.objectContaining({ - address: '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d', + address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b', symbol: 'cUSDC', }), metadata: expect.objectContaining({ - catalogSymbol: 'cUSDC_V2', - familySymbol: 'cUSDC', - deploymentVersion: 'v2', + catalogSymbol: 'cUSDC', }), }), ]) ); + const cusdcEntries = metamaskBody.watchAssets.filter( + (entry: Record) => entry.options?.symbol === 'cUSDC', + ); + const cusdtEntries = metamaskBody.watchAssets.filter( + (entry: Record) => entry.options?.symbol === 'cUSDT', + ); + expect(cusdcEntries).toHaveLength(1); + expect(cusdtEntries).toHaveLength(1); expect( metamaskBody.watchAssets.some( - (entry: Record) => entry.options?.symbol === 'cUSDC_V2' || entry.options?.symbol === 'cUSDT_V2' - ) + (entry: Record) => entry.metadata?.catalogSymbol === 'cUSDC_V2', + ), + ).toBe(false); + expect( + metamaskBody.watchAssets.some( + (entry: Record) => entry.options?.symbol === 'cUSDC_V2' || entry.options?.symbol === 'cUSDT_V2', + ), ).toBe(false); }); diff --git a/services/token-aggregation/src/api/routes/config.ts b/services/token-aggregation/src/api/routes/config.ts index e2d76dd..a1ecbb6 100644 --- a/services/token-aggregation/src/api/routes/config.ts +++ b/services/token-aggregation/src/api/routes/config.ts @@ -3,6 +3,8 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { getNetworks, getConfigByChain, API_VERSION, type NetworkEntry } from '../../config/networks'; +import { toWalletAddEthereumChainParams } from '../../lib/wallet-add-ethereum-chain'; +import { dedupeWatchAssetEntriesBySymbol, isWalletWatchAssetEligible } from '../../lib/wallet-watch-eligible'; import { getCanonicalTokensByChain, getLogoUriForSpec, getTokenRegistryFamily } from '../../config/canonical-tokens'; import { cacheMiddleware } from '../middleware/cache'; import { fetchRemoteJson } from '../utils/fetch-remote-json'; @@ -72,17 +74,27 @@ function resolveWalletWatchAssetSymbol(spec: { symbol: string; familySymbol?: st return spec.symbol; } -const HUB_COMPLIANT_WATCH_SYMBOLS = new Set(['cUSDC', 'cUSDT']); -const HUB_COMPLIANT_WATCH_CHAIN_IDS = new Set([138, 651940]); +function usesGenericEthDiamondIcons(iconUrls: unknown): boolean { + if (!Array.isArray(iconUrls) || iconUrls.length === 0) return true; + return iconUrls.every( + (url) => typeof url === 'string' && url.includes('eth-diamond-black.png'), + ); +} -function isWalletWatchAssetEligible(spec: { symbol: string }, chainId: number): boolean { - // Off the hub, cUSDC/cUSDT catalog rows point at official USDC/USDT peg contracts - // (symbol USDC/USDT on-chain). MetaMask rejects wallet_watchAsset when options.symbol - // does not match ERC-20 symbol(). Public wrapped transports are cWUSDC/cWUSDT instead. - if (HUB_COMPLIANT_WATCH_SYMBOLS.has(spec.symbol) && !HUB_COMPLIANT_WATCH_CHAIN_IDS.has(chainId)) { - return false; - } - return true; +function enrichNetworksWithBuiltInMetadata(payload: NetworksPayload): NetworksPayload { + const builtInByChain = new Map(getNetworks().map((entry) => [entry.chainIdDecimal, entry])); + const networks = (payload.networks as NetworkEntry[]).map((network) => { + const builtIn = builtInByChain.get(Number(network.chainIdDecimal)); + if (!builtIn) return network; + const oracles = + Array.isArray(network.oracles) && network.oracles.length > 0 ? network.oracles : builtIn.oracles; + const iconUrls = + usesGenericEthDiamondIcons(network.iconUrls) && builtIn.iconUrls?.length + ? builtIn.iconUrls + : network.iconUrls; + return { ...network, oracles, iconUrls }; + }); + return { ...payload, networks }; } type RuntimeNetworksPayload = { @@ -117,8 +129,10 @@ function resolveRuntimeNetworksPath(): string | null { const candidates = uniquePaths([ process.env.NETWORKS_JSON_PATH, process.env.CONFIG_NETWORKS_JSON_PATH, + '/opt/explorer/config/metamask/DUAL_CHAIN_NETWORKS.json', path.join(os.homedir(), 'projects/explorer-monorepo/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json'), - path.join(os.homedir(), 'projects/explorer-monorepo/backend/config/metamask/DUAL_CHAIN_NETWORKS.json') + path.join(os.homedir(), 'projects/explorer-monorepo/backend/config/metamask/DUAL_CHAIN_NETWORKS.json'), + path.join(os.homedir(), 'projects/proxmox/docs/04-configuration/metamask/DUAL_CHAIN_NETWORKS.json'), ]); for (const candidate of candidates) { @@ -184,10 +198,10 @@ function loadRuntimeNetworksPayload(): NetworksPayload | null { async function resolveNetworksPayload(): Promise { const remotePayload = await loadRemoteNetworksPayload(); - if (remotePayload) return remotePayload; + if (remotePayload) return enrichNetworksWithBuiltInMetadata(remotePayload); const runtimePayload = loadRuntimeNetworksPayload(); - if (runtimePayload) return runtimePayload; + if (runtimePayload) return enrichNetworksWithBuiltInMetadata(runtimePayload); return { source: 'built-in', @@ -231,40 +245,54 @@ router.get(['/config/metamask', '/metamask'], cacheMiddleware(5 * 60 * 1000), as return; } - const watchAssets = getCanonicalTokensByChain(chainId) - .filter((spec) => isWalletWatchAssetEligible(spec, chainId)) - .map((spec) => { - const address = spec.addresses[chainId]; - if (!address) return null; - const originalLogoURI = getLogoUriForSpec(spec); - return { - type: 'ERC20', - options: { - address, - symbol: resolveWalletWatchAssetSymbol(spec), - decimals: spec.decimals, - image: appendWalletMetadataVersion(absolutePublicUrl(req, localLogoPathForSymbol(spec.symbol, originalLogoURI))), - }, - metadata: { - name: spec.name, - catalogSymbol: spec.symbol, - registryFamily: getTokenRegistryFamily(spec), - familySymbol: spec.familySymbol, - deploymentVersion: spec.deploymentVersion, - deploymentStatus: spec.deploymentStatus, - }, - }; - }) - .filter(Boolean); + const watchAssets = dedupeWatchAssetEntriesBySymbol( + getCanonicalTokensByChain(chainId) + .filter((spec) => isWalletWatchAssetEligible(spec, chainId)) + .map((spec) => { + const address = spec.addresses[chainId]; + if (!address) return null; + const originalLogoURI = getLogoUriForSpec(spec); + return { + type: 'ERC20' as const, + options: { + address, + symbol: resolveWalletWatchAssetSymbol(spec), + decimals: spec.decimals, + image: appendWalletMetadataVersion( + absolutePublicUrl(req, localLogoPathForSymbol(spec.symbol, originalLogoURI)), + ), + }, + metadata: { + name: spec.name, + catalogSymbol: spec.symbol, + registryFamily: getTokenRegistryFamily(spec), + familySymbol: spec.familySymbol, + deploymentVersion: spec.deploymentVersion, + deploymentStatus: spec.deploymentStatus, + }, + }; + }) + .filter((entry): entry is NonNullable => Boolean(entry)), + ); + + const publicBase = resolvePublicBaseUrl(req); res.json({ source: payload.source, version: payload.version, chainId, - addEthereumChain: network, + addEthereumChain: toWalletAddEthereumChainParams(network), watchAssets, + priceFeed: { + iso4217: true, + spotPricesV2: `${publicBase}/token-aggregation/api/v1/prices/metamask/v2/chains/${chainId}/spot-prices?tokenAddresses={addresses}&vsCurrency=usd`, + submissionBundle: `${publicBase}/token-aggregation/api/v1/report/metamask-price-feed?chainId=${chainId}`, + coingeckoReport: `${publicBase}/token-aggregation/api/v1/report/coingecko?chainId=${chainId}`, + note: 'MetaMask native USD display requires chain 138 on price.api.cx.metamask.io or CoinGecko listing; these endpoints supply ISO-4217 valuation until then.', + }, caveats: [ - 'MetaMask custom-token prices are controlled by MetaMask and its upstream asset/price providers; this endpoint supplies wallet metadata, logo URLs, and token-add payloads but cannot force MetaMask to render fiat prices.', + 'MetaMask custom-token prices are controlled by MetaMask and its upstream asset/price providers; spotPricesV2 supplies ISO-4217 USD rates but MetaMask will not read them until chain 138 is registered on price.api.cx.metamask.io or tokens are listed on CoinGecko.', + 'MetaMask stores one custom token row per symbol on a network. Staged V2 deployments that share a family symbol with live V1 contracts are omitted here so balances do not disappear after import.', 'After metadata changes, remove and re-add the custom network/token in MetaMask or use the companion UI to call wallet_addEthereumChain and wallet_watchAsset again.', ], }); @@ -287,11 +315,16 @@ router.get('/config', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: if (chainIdParam) { const chainId = parseInt(chainIdParam, 10); const matchingNetwork = networks.find((network) => Number(network.chainIdDecimal) === chainId); + const builtIn = getConfigByChain(chainId); + const runtimeOracles = matchingNetwork?.oracles; const config = matchingNetwork ? { - oracles: (matchingNetwork.oracles as unknown[]) ?? [], + oracles: + Array.isArray(runtimeOracles) && runtimeOracles.length > 0 + ? runtimeOracles + : (builtIn?.oracles ?? []), } - : getConfigByChain(chainId); + : builtIn; if (!config) { return res.status(404).json({ error: 'Chain not found', chainId }); } diff --git a/services/token-aggregation/src/api/routes/metamask-prices.test.ts b/services/token-aggregation/src/api/routes/metamask-prices.test.ts new file mode 100644 index 0000000..0f08608 --- /dev/null +++ b/services/token-aggregation/src/api/routes/metamask-prices.test.ts @@ -0,0 +1,68 @@ +import express from 'express'; +import metamaskPriceRoutes from './metamask-prices'; +import { primeLiveReferencePriceUsd } from '../../services/canonical-price-oracle'; + +jest.mock('../middleware/cache', () => ({ + cacheMiddleware: () => (_req: unknown, _res: unknown, next: () => void) => next(), +})); + +jest.mock('../../services/iso4217-fx-refresh', () => ({ + ensureIso4217FxPrimed: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../services/chain138-onchain-reference-prices', () => ({ + refreshChain138LiveReferencePrices: jest.fn().mockResolvedValue(undefined), + getChain138EthUsdOnChain: jest.fn().mockResolvedValue({ priceUsd: 1772, source: 'aggregator' }), +})); + +describe('metamask spot prices API', () => { + let baseUrl = ''; + let server: ReturnType | null = null; + + beforeAll(async () => { + process.env.TOKEN_AGGREGATION_SKIP_DB_READS = '1'; + primeLiveReferencePriceUsd('USD', 1); + primeLiveReferencePriceUsd('AUD', 0.71); + + const app = express(); + app.use('/api/v1/prices/metamask', metamaskPriceRoutes); + await new Promise((resolve) => { + server = app.listen(0, () => { + const address = server?.address(); + const port = typeof address === 'object' && address ? address.port : 0; + baseUrl = `http://127.0.0.1:${port}/api/v1/prices/metamask`; + resolve(); + }); + }); + }); + + afterAll(async () => { + await new Promise((resolve, reject) => { + if (!server) { + resolve(); + return; + } + server.close((error) => (error ? reject(error) : resolve())); + }); + }); + + it('returns MetaMask v2 spot price map for WETH via native oracle on chain 138', async () => { + const address = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; + const res = await fetch( + `${baseUrl}/v2/chains/138/spot-prices?tokenAddresses=${address}&vsCurrency=usd`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body[address]?.usd).toBe(1772); + }); + + it('returns MetaMask v2 spot price map for canonical cUSDC', async () => { + const address = '0xf22258f57794cc8e06237084b353ab30fffa640b'; + const res = await fetch( + `${baseUrl}/v2/chains/138/spot-prices?tokenAddresses=${address}&vsCurrency=usd`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body[address]?.usd).toBeGreaterThan(0); + }); +}); diff --git a/services/token-aggregation/src/api/routes/metamask-prices.ts b/services/token-aggregation/src/api/routes/metamask-prices.ts new file mode 100644 index 0000000..37ac574 --- /dev/null +++ b/services/token-aggregation/src/api/routes/metamask-prices.ts @@ -0,0 +1,118 @@ +import { Router, type Request, type Response } from 'express'; +import { getCanonicalTokensByChain } from '../../config/canonical-tokens'; +import { cacheMiddleware } from '../middleware/cache'; +import { resolveIso4217SpotPrice } from '../../services/iso4217-spot-price'; +import { refreshChain138LiveReferencePrices } from '../../services/chain138-onchain-reference-prices'; + +const router: Router = Router(); + +function parseChainId(value: unknown): number | null { + const chainId = parseInt(String(value ?? ''), 10); + return Number.isFinite(chainId) && chainId > 0 ? chainId : null; +} + +function parseTokenAddresses(raw: unknown): string[] { + const values = Array.isArray(raw) + ? raw.flatMap((entry) => String(entry).split(',')) + : String(raw ?? '') + .split(',') + .map((entry) => entry.trim()); + + const out: string[] = []; + const seen = new Set(); + for (const value of values) { + const normalized = value.trim().toLowerCase(); + if (!/^0x[a-f0-9]{40}$/.test(normalized) || seen.has(normalized)) continue; + seen.add(normalized); + out.push(normalized); + if (out.length >= 120) break; + } + return out; +} + +function normalizeVsCurrency(raw: unknown): string { + const value = String(raw ?? 'usd') + .trim() + .toLowerCase(); + return value || 'usd'; +} + +async function buildMetamaskSpotPriceMap( + chainId: number, + addresses: string[], + vsCurrency: string, +): Promise>> { + await refreshChain138LiveReferencePrices(chainId); + + const response: Record> = {}; + + await Promise.all( + addresses.map(async (address) => { + const spot = await resolveIso4217SpotPrice(chainId, address); + if (spot.priceUsd != null && spot.priceUsd > 0) { + response[address] = { [vsCurrency]: spot.priceUsd }; + } + }), + ); + + return response; +} + +/** + * MetaMask Codefi Price API compatible spot prices for ISO-4217 c* tokens. + * MetaMask extension/mobile will not consume this until chain 138 is registered on + * price.api.cx.metamask.io — use this URL in Consensys / CoinGecko submission packets. + */ +router.get( + '/v2/chains/:chainId/spot-prices', + cacheMiddleware(60 * 1000), + async (req: Request, res: Response) => { + try { + const chainId = parseChainId(req.params.chainId); + if (!chainId) { + res.status(400).json({ message: 'Invalid chainId' }); + return; + } + + const vsCurrency = normalizeVsCurrency(req.query.vsCurrency); + const addresses = parseTokenAddresses(req.query.tokenAddresses); + if (addresses.length === 0) { + res.status(400).json({ message: 'tokenAddresses query parameter is required' }); + return; + } + + const prices = await buildMetamaskSpotPriceMap(chainId, addresses, vsCurrency); + res.json(prices); + } catch { + res.status(500).json({ message: 'Internal server error' }); + } + }, +); + +router.get( + '/v1/chains/:chainId/spot-prices/:tokenAddress', + cacheMiddleware(60 * 1000), + async (req: Request, res: Response) => { + try { + const chainId = parseChainId(req.params.chainId); + const address = String(req.params.tokenAddress || '').trim().toLowerCase(); + if (!chainId || !/^0x[a-f0-9]{40}$/.test(address)) { + res.status(400).json({ message: 'Invalid chainId or tokenAddress' }); + return; + } + + const vsCurrency = normalizeVsCurrency(req.query.vsCurrency); + const spot = await resolveIso4217SpotPrice(chainId, address); + if (spot.priceUsd == null || !(spot.priceUsd > 0)) { + res.status(404).json({ message: 'Price unavailable' }); + return; + } + + res.json({ [vsCurrency]: spot.priceUsd }); + } catch { + res.status(500).json({ message: 'Internal server error' }); + } + }, +); + +export default router; diff --git a/services/token-aggregation/src/api/routes/omnl-compliance-routes.ts b/services/token-aggregation/src/api/routes/omnl-compliance-routes.ts index 751d64e..ba335af 100644 --- a/services/token-aggregation/src/api/routes/omnl-compliance-routes.ts +++ b/services/token-aggregation/src/api/routes/omnl-compliance-routes.ts @@ -21,6 +21,8 @@ import { } from '../../services/omnl-iso20022-store'; import { verifyOmnlWebhookSignature } from '../../services/omnl-webhooks'; import { appendOmnlAudit } from '../../services/omnl-audit-log'; +import { listOmnlEntities, getOmnlEntity } from '../../services/omnl-entity-registry'; +import { validateIso20022Payload } from '../../services/omnl-iso20022-validate'; import { getWeb3ComplianceSummary, buildNotarizationIntent } from '../../services/omnl-web3-compliance'; import { buildComplianceConsoleSnapshot, @@ -125,7 +127,21 @@ router.get('/omnl/disclosures/full', omnlSensitiveRouteGuard, async (req: Reques res.json(await buildFullDisclosure(lineId)); }); -router.post('/omnl/iso20022/messages', omnlSensitiveRouteGuard, (req: Request, res: Response) => { +router.get('/omnl/entities', omnlSensitiveRouteGuard, (_req, res) => { + res.json(listOmnlEntities()); +}); + +router.get('/omnl/entities/:clientNumber', omnlSensitiveRouteGuard, (req, res) => { + const n = parseInt(String(req.params.clientNumber), 10); + const entity = getOmnlEntity(n); + if (!entity) { + res.status(404).json({ error: 'not found' }); + return; + } + res.json(entity); +}); + +router.post('/omnl/iso20022/messages', omnlSensitiveRouteGuard, async (req: Request, res: Response) => { try { const body = req.body as { messageType?: Iso20022MessageType; @@ -139,6 +155,11 @@ router.post('/omnl/iso20022/messages', omnlSensitiveRouteGuard, (req: Request, r res.status(400).json({ error: 'messageType and payload required' }); return; } + const schema = await validateIso20022Payload(body.messageType, body.payload); + if (!schema.valid) { + res.status(400).json({ error: 'iso20022_schema_invalid', details: schema.errors }); + return; + } const record = saveIso20022Message({ messageType: body.messageType, payload: body.payload, diff --git a/services/token-aggregation/src/api/routes/report.test.ts b/services/token-aggregation/src/api/routes/report.test.ts index d194517..2b8d4db 100644 --- a/services/token-aggregation/src/api/routes/report.test.ts +++ b/services/token-aggregation/src/api/routes/report.test.ts @@ -593,6 +593,20 @@ describe('Report API', () => { ); }); + it('excludes deterministic gas placeholders from wallet-facing token list', async () => { + const res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138&wallet=1`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + const symbols = body.tokens.map((token: Record) => token.symbol); + const placeholderSymbols = ['cAVAX', 'cBNB', 'cETH', 'cETHL2', 'cPOL', 'cCRO', 'cXDAI', 'cCELO', 'cWEMIX']; + for (const symbol of placeholderSymbols) { + expect(symbols).not.toContain(symbol); + } + expect(symbols).toEqual( + expect.arrayContaining(['cUSDT', 'cUSDC', 'cBTC', 'WETH']) + ); + }); + 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); @@ -636,6 +650,7 @@ describe('Report API', () => { chainId: 138, extensions: expect.objectContaining({ registryFamily: 'gas_native', + walletWatchEligible: false, }), }), expect.objectContaining({ @@ -643,6 +658,7 @@ describe('Report API', () => { chainId: 138, extensions: expect.objectContaining({ registryFamily: 'gas_native', + walletWatchEligible: false, }), }), ]) diff --git a/services/token-aggregation/src/api/routes/report.ts b/services/token-aggregation/src/api/routes/report.ts index ce1a38d..2f68783 100644 --- a/services/token-aggregation/src/api/routes/report.ts +++ b/services/token-aggregation/src/api/routes/report.ts @@ -11,6 +11,7 @@ import { MarketDataRepository } from '../../database/repositories/market-data-re import { PoolRepository } from '../../database/repositories/pool-repo'; import { CANONICAL_TOKENS, + getCanonicalTokenByAddress, getCanonicalTokensByChain, getLogoUriForSpec, getTokenRegistryFamily, @@ -37,6 +38,8 @@ import { } from '../../config/deployment-status'; import { getGruV2DeploymentPoolRows } from '../../config/gru-v2-deployment-pools'; import { getCanonicalPriceSnapshotGeneratedAt, getCanonicalPriceUsd } from '../../services/canonical-price-oracle'; +import { getIso4217FxLastRefreshAt } from '../../services/iso4217-fx-refresh'; +import { buildIso4217TokenListExtensions } from '../../services/iso4217-token-metadata'; import { pmmVaultReserveFromChain, resolvePmmQuoteRpcUrl } from '../../services/pmm-onchain-quote'; import { buildUniswapTokenList, @@ -46,6 +49,8 @@ import { import { buildCuratedPoolRegistry } from '../../services/pool-registry-report'; import { enrichCuratedPoolRegistryWithLiveLiquidity } from '../../services/pool-registry-enrichment'; import { buildLpPositionsReport } from '../../services/lp-positions-report'; +import { isDeterministicPlaceholderAddress } from '../../lib/deterministic-placeholder-address'; +import { isWalletWatchEligibleTokenAddress, isWalletWatchAssetEligible } from '../../lib/wallet-watch-eligible'; const router: Router = Router(); const tokenRepo = new TokenRepository(); @@ -310,11 +315,6 @@ function hasExternalOfficialQuoteLiquidity(token: { chainId: number; symbol: str return EXTERNAL_OFFICIAL_QUOTE_ASSETS.has(`${token.chainId}:${token.symbol}:${token.address.toLowerCase()}`); } -function isDeterministicPlaceholderAddress(address: string): boolean { - const normalized = address.toLowerCase(); - return /^0x[a-f0-9]{4}0{24,}[a-f0-9]{1,8}$/.test(normalized); -} - function buildSupplyProofEnrichment( chainId: number, address: string, @@ -698,19 +698,52 @@ function finalizeUniswapTokenListResponse( req: Request, res: Response, payload: Record, - chainIds: number[] + chainIds: number[], + options?: { walletEligibleOnly?: boolean }, ): void { const logoURI = absolutePublicUrl(req, typeof payload.logoURI === 'string' ? payload.logoURI : undefined); + const walletEligibleOnly = options?.walletEligibleOnly === true; const tokens = Array.isArray(payload.tokens) - ? payload.tokens.map((token) => { - const record = token as Record; - return { - ...record, - logoURI: - absolutePublicUrl(req, typeof record.logoURI === 'string' ? record.logoURI : undefined) ?? - record.logoURI, - }; - }) + ? payload.tokens + .map((token) => { + const record = token as Record; + const address = typeof record.address === 'string' ? record.address : undefined; + const walletWatchEligible = isWalletWatchEligibleTokenAddress(address); + const chainId = typeof record.chainId === 'number' ? record.chainId : undefined; + const spec = + chainId && address ? getCanonicalTokenByAddress(chainId, address) : undefined; + const iso4217Extensions = spec ? buildIso4217TokenListExtensions(spec) : {}; + const extensions = + record.extensions && typeof record.extensions === 'object' && !Array.isArray(record.extensions) + ? (record.extensions as Record) + : {}; + return { + ...record, + logoURI: + absolutePublicUrl(req, typeof record.logoURI === 'string' ? record.logoURI : undefined) ?? + record.logoURI, + extensions: { + ...iso4217Extensions, + ...extensions, + walletWatchEligible, + }, + }; + }) + .filter((token) => { + if (!walletEligibleOnly) return true; + const record = token as Record; + const address = typeof record.address === 'string' ? record.address : undefined; + if (!isWalletWatchEligibleTokenAddress(address)) return false; + const extensions = + record.extensions && typeof record.extensions === 'object' && !Array.isArray(record.extensions) + ? (record.extensions as Record) + : {}; + const status = String(extensions.deploymentStatus || '').toLowerCase(); + const version = String(extensions.deploymentVersion || '').toLowerCase(); + const familySymbol = extensions.familySymbol; + if (status === 'staged' || (version === 'v2' && familySymbol)) return false; + return true; + }) : []; const displayNames = chainIds.length === 1 && chainIds[0] === 1 ? MAINNET_PUBLIC_DISPLAY_NAMES : undefined; @@ -1516,6 +1549,60 @@ router.get( } ); +/** GET /report/metamask-price-feed — MetaMask / Consensys price-provider submission bundle for ISO-4217 c* tokens. */ +router.get( + '/metamask-price-feed', + cacheMiddleware(2 * 60 * 1000), + async (req: Request, res: Response) => { + try { + const chainId = parseInt(req.query.chainId as string, 10) || 138; + const publicBase = resolvePublicBaseUrl(req); + + const tokens = getCanonicalTokensByChain(chainId) + .map((spec) => { + const address = spec.addresses[chainId]; + if (!address) return null; + const priceUsd = getCanonicalPriceUsd(chainId, address); + return { + chainId, + address, + symbol: spec.symbol, + name: spec.name, + decimals: spec.decimals, + priceUsd, + extensions: buildIso4217TokenListExtensions(spec), + }; + }) + .filter((row): row is NonNullable => Boolean(row)); + + res.json({ + generatedAt: new Date().toISOString(), + schema: 'dbis-metamask-price-feed/v1', + chainId, + chainName: 'DeFi Oracle Meta Mainnet', + assetPlatformId: 'defi-oracle-meta', + iso4217Pricing: true, + fxLastRefreshAt: getIso4217FxLastRefreshAt() || null, + canonicalPriceSnapshot: getCanonicalPriceSnapshotGeneratedAt(), + metamaskPriceApiStatus: 'chain_138_not_registered_on_price_api_cx_metamask_io', + spotPricesV2: `${publicBase}/token-aggregation/api/v1/prices/metamask/v2/chains/${chainId}/spot-prices?tokenAddresses={addresses}&vsCurrency=usd`, + spotPricesV1: `${publicBase}/token-aggregation/api/v1/prices/metamask/v1/chains/${chainId}/spot-prices/{tokenAddress}?vsCurrency=usd`, + coingeckoReport: `${publicBase}/token-aggregation/api/v1/report/coingecko?chainId=${chainId}`, + tokenList: `${publicBase}/token-aggregation/api/v1/report/token-list?chainId=${chainId}`, + tokens, + submissionNotes: [ + 'Request Consensys to register chainId 138 on price.api.cx.metamask.io pointing at spotPricesV2, or list tokens on CoinGecko under asset platform defi-oracle-meta.', + 'c* = Compliant eMoney; XXX = ISO-4217; C = Cash (M1); T = Treasury — coingeckoPriceProxyId is USD display proxy only.', + 'Live FX for ISO-4217 fiat codes refreshes every 5 minutes from Frankfurter + CoinGecko reference markets.', + ], + }); + } catch (error) { + logger.error('Error building report/metamask-price-feed:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }, +); + /** GET /report/token-price/:symbol — compact reviewer-facing price and evidence snapshot. */ router.get( '/token-price/:symbol', @@ -1644,14 +1731,19 @@ router.get( async (req: Request, res: Response) => { try { const chainIdParam = req.query.chainId as string | undefined; + const walletEligibleOnly = + String(req.query.wallet ?? req.query.walletEligible ?? '').trim() === '1' || + String(req.query.wallet ?? req.query.walletEligible ?? '').trim().toLowerCase() === 'true'; const chainIds = chainIdParam ? [parseInt(chainIdParam, 10)].filter((n) => !isNaN(n)) : getSupportedChainIds(); + const finalizeOptions = { walletEligibleOnly }; + if (chainIds.length === 1 && chainIds[0] === 138) { const fileList = loadDbis138TokenListFromFile(); if (fileList) { - return finalizeUniswapTokenListResponse(req, res, fileList, chainIds); + return finalizeUniswapTokenListResponse(req, res, fileList, chainIds, finalizeOptions); } } @@ -1673,7 +1765,8 @@ router.get( ...data, tokens, }, - chainIds + chainIds, + finalizeOptions, ); } catch (err) { logger.error('TOKEN_LIST_JSON_URL fetch failed, using built-in token list:', err); @@ -1686,24 +1779,25 @@ router.get( const specs = getCanonicalTokensByChain(chainId); for (const spec of specs) { const address = spec.addresses[chainId]; - if (address) { - const originalLogoURI = getLogoUriForSpec(spec); - list.push({ - chainId, - address, - symbol: spec.symbol, - name: spec.name, - decimals: spec.decimals, - type: spec.type, - logoURI: absoluteLogoUri(req, originalLogoURI, spec.symbol), - originalLogoURI, - registryFamily: getTokenRegistryFamily(spec), - familySymbol: spec.familySymbol, - deploymentVersion: spec.deploymentVersion, - deploymentStatus: spec.deploymentStatus, - preferredForX402: spec.preferredForX402, - }); - } + if (!address) continue; + if (walletEligibleOnly && !isWalletWatchAssetEligible(spec, chainId)) continue; + const originalLogoURI = getLogoUriForSpec(spec); + list.push({ + chainId, + address, + symbol: spec.symbol, + name: spec.name, + decimals: spec.decimals, + type: spec.type, + logoURI: absoluteLogoUri(req, originalLogoURI, spec.symbol), + originalLogoURI, + registryFamily: getTokenRegistryFamily(spec), + familySymbol: spec.familySymbol, + deploymentVersion: spec.deploymentVersion, + deploymentStatus: spec.deploymentStatus, + preferredForX402: spec.preferredForX402, + extensions: buildIso4217TokenListExtensions(spec), + }); } } @@ -1717,7 +1811,8 @@ router.get( logoURI: 'https://d-bis.org/brand/chain-138.svg', tokens: list, }, - chainIds + chainIds, + finalizeOptions, ); } catch (error) { logger.error('Error building report/token-list:', error); diff --git a/services/token-aggregation/src/api/routes/tokens.test.ts b/services/token-aggregation/src/api/routes/tokens.test.ts index 1db2571..05b5687 100644 --- a/services/token-aggregation/src/api/routes/tokens.test.ts +++ b/services/token-aggregation/src/api/routes/tokens.test.ts @@ -79,10 +79,19 @@ jest.mock('../../services/token-display', () => ({ jest.mock('../middleware/cache'); +jest.mock('../../services/chain138-onchain-reference-prices', () => ({ + refreshChain138LiveReferencePrices: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../services/blockscout-token-meta', () => ({ + fetchBlockscoutTokenMeta: jest.fn().mockResolvedValue(null), +})); + const tokensRoutes = require('./tokens').default as typeof import('./tokens').default; function createApp() { const app = express(); + app.use(express.json()); app.use('/api/v1', tokensRoutes); return app; } @@ -429,4 +438,67 @@ describe('Tokens API', () => { locked: false, }); }); + + it('returns distinct market-batch snapshots per requested address set', async () => { + const cusdt = getCanonicalTokenBySymbol(138, 'cUSDT'); + const weth = getCanonicalTokenBySymbol(138, 'WETH'); + expect(cusdt?.addresses[138]).toBeTruthy(); + expect(weth?.addresses[138]).toBeTruthy(); + + const cusdtAddress = String(cusdt?.addresses[138]).toLowerCase(); + const wethAddress = String(weth?.addresses[138]).toLowerCase(); + + mockGetMarketData.mockImplementation(async (_chainId: number, address: string) => { + if (address === cusdtAddress) { + return { + chainId: 138, + tokenAddress: cusdtAddress, + priceUsd: 1, + volume24h: 0, + volume7d: 0, + volume30d: 0, + liquidityUsd: 1_000_000, + holdersCount: 0, + transfers24h: 0, + lastUpdated: new Date('2026-04-26T03:31:01.988Z'), + }; + } + if (address === wethAddress) { + return { + chainId: 138, + tokenAddress: wethAddress, + priceUsd: 1680, + volume24h: 0, + volume7d: 0, + volume30d: 0, + liquidityUsd: 5_000_000, + holdersCount: 0, + transfers24h: 0, + lastUpdated: new Date('2026-04-26T03:31:01.988Z'), + }; + } + return null; + }); + + const first = await fetch(`${baseUrl}/api/v1/tokens/market-batch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chainId: 138, addresses: [cusdtAddress] }), + }); + expect(first.status).toBe(200); + const firstBody = (await first.json()) as Record; + expect(firstBody.requested).toBe(1); + expect(firstBody.snapshots?.[0]?.address).toBe(cusdtAddress); + + const second = await fetch(`${baseUrl}/api/v1/tokens/market-batch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chainId: 138, addresses: [wethAddress] }), + }); + expect(second.status).toBe(200); + const secondBody = (await second.json()) as Record; + expect(secondBody.requested).toBe(1); + expect(secondBody.snapshots?.[0]?.address).toBe(wethAddress); + expect(secondBody.snapshots?.[0]?.priceUsd).toBe(1680); + }); }); diff --git a/services/token-aggregation/src/api/routes/tokens.ts b/services/token-aggregation/src/api/routes/tokens.ts index 30ef38f..2eab6c0 100644 --- a/services/token-aggregation/src/api/routes/tokens.ts +++ b/services/token-aggregation/src/api/routes/tokens.ts @@ -13,6 +13,7 @@ import { filterPoolsForExposure, shouldExposePublicPool } from '../../config/gru import { getCanonicalTokenByAddress, getCanonicalTokensByChain, + getCanonicalTokenBySymbol, resolveCanonicalQuoteAddress, } from '../../config/canonical-tokens'; import { resolveCanonicalPriceUsd } from '../../services/canonical-price-oracle'; @@ -22,6 +23,9 @@ import { mergeMarketWithValuation, resolveUsdValuation, } from '../../services/valuation-precedence'; +import { fetchBlockscoutTokenMeta } from '../../services/blockscout-token-meta'; +import { refreshChain138LiveReferencePrices } from '../../services/chain138-onchain-reference-prices'; +import { MAX_SINGLE_POOL_TVL_USD, resolveVisibleTokenLiquidityUsd } from '../../services/token-visible-liquidity'; const router: Router = Router(); const tokenRepo = new TokenRepository(); @@ -241,18 +245,215 @@ async function getTokenWithFallback(chainId: number, address: string): Promise pool.token0Address === resolution.lookupAddress || pool.token1Address === resolution.lookupAddress) ? resolution.lookupAddress : null; - if (!liveAddress) { + if (liveAddress) { + const display = await resolveTokenDisplay(tokenRepo, chainId, liveAddress); + return { + chainId, + address: normalized, + name: display.name, + symbol: display.symbol, + decimals: display.decimals, + verified: display.source !== 'fallback', + }; + } + + const blockscoutMeta = await fetchBlockscoutTokenMeta(chainId, normalized); + if (blockscoutMeta) { + return { + chainId, + address: normalized, + name: blockscoutMeta.name, + symbol: blockscoutMeta.symbol, + decimals: blockscoutMeta.decimals, + totalSupply: blockscoutMeta.totalSupply, + verified: false, + }; + } + + return null; +} + +export interface TokenMarketSnapshot { + address: string; + symbol?: string; + name?: string; + decimals?: number; + priceUsd?: number; + liquidityUsd?: number; + volume24h?: number; + lastUpdated?: string; + source?: string; + blockscoutExchangeRate?: number; + pricingKind?: 'spot' | 'lp-share'; + isCanonicalClone?: boolean; +} + +function isLikelyLpReceiptSymbol(symbol: string | undefined): boolean { + if (!symbol) { + return false; + } + const upper = symbol.toUpperCase(); + return upper.includes('DLP') || upper.endsWith('-LP') || upper.startsWith('LP-') || upper === 'LP'; +} + +async function resolveTokenRow( + chainId: number, + normalizedAddress: string, + hint?: { symbol?: string; name?: string; decimals?: number }, +): Promise { + const fromRepo = await getTokenWithFallback(chainId, normalizedAddress); + if (fromRepo) { + return fromRepo; + } + + const blockscoutMeta = await fetchBlockscoutTokenMeta(chainId, normalizedAddress); + if (blockscoutMeta) { + return { + chainId, + address: normalizedAddress, + name: blockscoutMeta.name, + symbol: blockscoutMeta.symbol, + decimals: blockscoutMeta.decimals, + totalSupply: blockscoutMeta.totalSupply, + verified: false, + }; + } + + const canonical = tokenFromCanonical(chainId, normalizedAddress); + if (canonical) { + return canonical; + } + + if (hint?.symbol) { + return { + chainId, + address: normalizedAddress, + name: hint.name || hint.symbol, + symbol: hint.symbol, + decimals: Number.isInteger(hint.decimals) ? Number(hint.decimals) : 18, + verified: false, + }; + } + + return null; +} + +async function resolveSymbolFallbackPriceUsd( + chainId: number, + normalizedAddress: string, + symbol: string | undefined, +): Promise { + if (!symbol) { + return undefined; + } + + const symbolSpec = getCanonicalTokenBySymbol(chainId, symbol); + if (!symbolSpec) { + return undefined; + } + + const canonicalAddress = symbolSpec.addresses?.[chainId]?.toLowerCase(); + if (canonicalAddress && canonicalAddress !== normalizedAddress) { + const canonicalSnapshot = await resolveTokenMarketSnapshot(chainId, canonicalAddress, { + allowSymbolFallback: false, + }); + return canonicalSnapshot?.priceUsd; + } + + const canonicalPrice = resolveCanonicalPriceUsd(chainId, normalizedAddress); + return canonicalPrice.priceUsd; +} + +async function resolveTokenMarketSnapshot( + chainId: number, + address: string, + options?: { allowSymbolFallback?: boolean; hint?: { symbol?: string; name?: string; decimals?: number } }, +): Promise { + const normalizedAddress = address.toLowerCase(); + const allowSymbolFallback = options?.allowSymbolFallback !== false; + const resolution = resolveCanonicalQuoteAddress(chainId, normalizedAddress); + const token = await resolveTokenRow(chainId, normalizedAddress, options?.hint); + if (!token) { return null; } - const display = await resolveTokenDisplay(tokenRepo, chainId, liveAddress); - return { + const canonicalSpec = getCanonicalTokenByAddress(chainId, normalizedAddress); + const symbolSpec = token.symbol ? getCanonicalTokenBySymbol(chainId, token.symbol) : undefined; + const canonicalAddressForSymbol = symbolSpec?.addresses?.[chainId]?.toLowerCase(); + const isCanonicalClone = Boolean( + symbolSpec && + canonicalAddressForSymbol && + canonicalAddressForSymbol !== normalizedAddress && + !canonicalSpec, + ); + const pricingKind = isLikelyLpReceiptSymbol(token.symbol) ? 'lp-share' : 'spot'; + + const [marketDataRaw, blockscoutMeta, coingeckoMarket, cmcMarket, dexscreenerMarket] = await Promise.all([ + marketDataRepo.getMarketData(chainId, resolution.lookupAddress), + fetchBlockscoutTokenMeta(chainId, normalizedAddress), + coingeckoAdapter.getMarketData(chainId, resolution.lookupAddress), + cmcAdapter.getMarketData(chainId, resolution.lookupAddress), + dexscreenerAdapter.getMarketData(chainId, resolution.lookupAddress), + ]); + + const externalFeeds = await enrichExternalFeedsWithReferencePrice(chainId, resolution.lookupAddress, { + coingecko: coingeckoMarket, + cmc: cmcMarket, + dexscreener: dexscreenerMarket, + }); + const { market: marketData, pricing } = buildMarketPricingExplorer( chainId, - address: normalized, - name: display.name, - symbol: display.symbol, - decimals: display.decimals, - verified: display.source !== 'fallback', + normalizedAddress, + resolution.lookupAddress, + marketDataRaw, + externalFeeds, + ); + const market = normalizePossiblyRawLiquidityUsd(marketData, token); + + let priceUsd = market?.priceUsd; + if ((priceUsd == null || !(priceUsd > 0)) && blockscoutMeta?.exchangeRate != null) { + priceUsd = blockscoutMeta.exchangeRate; + } + if ( + allowSymbolFallback && + (priceUsd == null || !(priceUsd > 0)) && + pricingKind !== 'lp-share' + ) { + const fallbackPrice = await resolveSymbolFallbackPriceUsd(chainId, normalizedAddress, token.symbol); + if (fallbackPrice != null && fallbackPrice > 0) { + priceUsd = fallbackPrice; + } + } + + const MIN_MEANINGFUL_LIQUIDITY_USD = 1000; + const marketLiquidityRaw = + market?.liquidityUsd != null && market.liquidityUsd > 0 ? market.liquidityUsd : 0; + const marketLiquidityUsd = + marketLiquidityRaw > MAX_SINGLE_POOL_TVL_USD ? 0 : marketLiquidityRaw; + const poolLiquidityUsd = await resolveVisibleTokenLiquidityUsd(chainId, normalizedAddress); + const bestLiquidityUsd = Math.max( + marketLiquidityUsd >= MIN_MEANINGFUL_LIQUIDITY_USD ? marketLiquidityUsd : 0, + poolLiquidityUsd, + ); + const liquidityUsd = bestLiquidityUsd > 0 ? bestLiquidityUsd : undefined; + + return { + address: normalizedAddress, + symbol: token.symbol, + name: token.name, + decimals: token.decimals, + priceUsd: priceUsd != null && priceUsd > 0 ? priceUsd : undefined, + liquidityUsd, + volume24h: market?.volume24h, + lastUpdated: market?.lastUpdated instanceof Date + ? market.lastUpdated.toISOString() + : market?.lastUpdated + ? new Date(market.lastUpdated).toISOString() + : undefined, + source: pricing?.sourceLayer || (blockscoutMeta?.exchangeRate != null ? 'blockscout' : undefined), + blockscoutExchangeRate: blockscoutMeta?.exchangeRate, + pricingKind, + isCanonicalClone, }; } @@ -517,6 +718,8 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp return res.status(400).json({ error: 'chainId is required' }); } + await refreshChain138LiveReferencePrices(chainId); + const { tokens, source } = await getTokensWithFallback(chainId, limit, offset); const tokensWithMarketData = await Promise.all( tokens.map(async (token) => { @@ -560,6 +763,83 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp } }); +router.post('/tokens/market-batch', cacheMiddleware(30 * 1000), async (req: Request, res: Response) => { + try { + const chainId = parseInt(String(req.body?.chainId ?? req.query.chainId ?? ''), 10); + const rawAddresses = req.body?.addresses; + if (!chainId) { + return res.status(400).json({ error: 'chainId is required' }); + } + const hasAddressList = Array.isArray(rawAddresses); + const hasTokenHints = Array.isArray(req.body?.tokens); + if (!hasAddressList && !hasTokenHints) { + return res.status(400).json({ error: 'addresses array or tokens array is required' }); + } + + await refreshChain138LiveReferencePrices(chainId); + + const hintByAddress = new Map(); + const rawTokens = req.body?.tokens; + if (Array.isArray(rawTokens)) { + for (const row of rawTokens) { + if (!row || typeof row !== 'object') continue; + const hintAddress = String((row as Record).address || '').trim().toLowerCase(); + if (!/^0x[a-f0-9]{40}$/.test(hintAddress)) continue; + hintByAddress.set(hintAddress, { + symbol: typeof (row as Record).symbol === 'string' + ? String((row as Record).symbol) + : undefined, + name: typeof (row as Record).name === 'string' + ? String((row as Record).name) + : undefined, + decimals: Number((row as Record).decimals) || undefined, + }); + } + } + + const addressSet = new Set(); + if (Array.isArray(rawAddresses)) { + for (const value of rawAddresses) { + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (/^0x[a-f0-9]{40}$/.test(normalized)) { + addressSet.add(normalized); + } + } + } + } + for (const hintAddress of hintByAddress.keys()) { + addressSet.add(hintAddress); + } + + const addresses = [...addressSet].slice(0, 120); + + const settled = await Promise.allSettled( + addresses.map(async (address) => resolveTokenMarketSnapshot(chainId, address, { + hint: hintByAddress.get(address), + })), + ); + + const snapshots: TokenMarketSnapshot[] = []; + for (const row of settled) { + if (row.status === 'fulfilled' && row.value) { + snapshots.push(row.value); + } + } + + return res.json({ + chainId, + generatedAt: new Date().toISOString(), + requested: addresses.length, + resolved: snapshots.length, + snapshots, + }); + } catch (error) { + logger.error('Error fetching token market batch:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => { try { const chainId = parseInt(req.query.chainId as string, 10); @@ -576,6 +856,8 @@ router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request, return res.status(404).json({ error: 'Token not found' }); } + await refreshChain138LiveReferencePrices(chainId); + const [ marketDataRaw, pools, diff --git a/services/token-aggregation/src/api/server.ts b/services/token-aggregation/src/api/server.ts index ec030bd..5683c15 100644 --- a/services/token-aggregation/src/api/server.ts +++ b/services/token-aggregation/src/api/server.ts @@ -27,6 +27,7 @@ import omnlComplianceRoutes from './routes/omnl-compliance-routes'; import omnlTerminalRoutes from './routes/omnl-terminal-routes'; import checkpointRoutes from './routes/checkpoint'; import reserveCapacityRoutes from './routes/reserve-capacity'; +import metamaskPriceRoutes from './routes/metamask-prices'; import { MultiChainIndexer } from '../indexer/chain-indexer'; import { OmnlEventPoller } from '../indexer/omnl-event-poller'; import { getDatabasePool } from '../database/client'; @@ -250,6 +251,7 @@ export class ApiServer { tokenDetail: '/api/v1/tokens/{address}', quote: '/api/v1/quote', bridgeRoutes: '/api/v1/bridge/routes', + bridgeMessages: '/api/v1/bridge/messages?lane=138-monad', bridgeStatus: '/api/v1/bridge/status', bridgeMetrics: '/api/v1/bridge/metrics', bridgePreflight: '/api/v1/bridge/preflight', @@ -260,6 +262,8 @@ export class ApiServer { reportOfficialProtocols: '/api/v1/report/official-protocols', reportOpenApi: '/api/v1/report/openapi.json', reportTokenList: '/api/v1/report/token-list', + metamaskSpotPricesV2: '/api/v1/prices/metamask/v2/chains/{chainId}/spot-prices', + metamaskPriceFeedReport: '/api/v1/report/metamask-price-feed?chainId=138', reportTokenListEthereumMainnet: '/api/v1/report/token-list/static/ethereum-mainnet', routesTree: '/api/v1/routes/tree', plannerProvidersCapabilities: '/api/v2/providers/capabilities', @@ -292,6 +296,7 @@ export class ApiServer { this.app.use('/api/v1', partnerPayloadRoutes); this.app.use('/api/v1', checkpointRoutes); this.app.use('/api/v1', reserveCapacityRoutes); + this.app.use('/api/v1/prices/metamask', metamaskPriceRoutes); this.app.use('/api/v1', omnlComplianceRoutes); this.app.use('/api/v1', omnlTerminalRoutes); this.app.use('/api/v1', omnlRoutes); diff --git a/services/token-aggregation/src/api/utils/default-bridge-routes.ts b/services/token-aggregation/src/api/utils/default-bridge-routes.ts index ffeb31e..1e5536e 100644 --- a/services/token-aggregation/src/api/utils/default-bridge-routes.ts +++ b/services/token-aggregation/src/api/utils/default-bridge-routes.ts @@ -47,6 +47,7 @@ const WETH9_DESTINATIONS: Record = { 'Cronos (25)': '0x3Cc23d086fCcbAe1e5f3FE2bA4A263E1D27d8Cab', 'Celo (42220)': '0xAb57BF30F1354CA0590af22D8974c7f24DB2DbD7', 'Wemix (1111)': '0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04', + 'Monad (143)': '0xC158b6cD3A3088C52F797D41f5Aa02825361629e', }; const WETH10_DESTINATIONS: Record = { diff --git a/services/token-aggregation/src/compliance/volume13-engine.ts b/services/token-aggregation/src/compliance/volume13-engine.ts new file mode 100644 index 0000000..161b906 --- /dev/null +++ b/services/token-aggregation/src/compliance/volume13-engine.ts @@ -0,0 +1,118 @@ +import type { + ComplianceDecisionEngine, + ComplianceEvaluationInput, + ComplianceEvaluationResult, +} from '@dbis/integration-foundation'; +import { MockComplianceDecisionEngine } from '@dbis/integration-foundation'; +import fs from 'node:fs'; +import path from 'node:path'; + +type Rule = { id: string; decision?: string; trigger?: { riskHints?: string[]; amountGte?: number } }; +type SanctionEntry = { id: string; names?: string[]; active?: boolean }; + +function loadJson(p: string, fallback: T): T { + try { + return JSON.parse(fs.readFileSync(p, 'utf8')) as T; + } catch { + return fallback; + } +} + +export class Volume13ComplianceDecisionEngine implements ComplianceDecisionEngine { + private readonly fallback = new MockComplianceDecisionEngine(); + private readonly rules: Rule[]; + private readonly sanctions: SanctionEntry[]; + private readonly vendor: string; + private auditCounter = 0; + + constructor(root: string) { + this.vendor = process.env.COMPLIANCE_VENDOR || 'internal'; + const rulesDoc = loadJson<{ rules?: Rule[] }>( + path.join(root, 'config/hybx-omnl-dbis/volume13-aml-decision-rules.v1.json'), + {} + ); + const sanctionsDoc = loadJson<{ entries?: SanctionEntry[] }>( + path.join(root, 'config/compliance/internal-sanctions-list.v1.json'), + {} + ); + this.rules = rulesDoc.rules ?? []; + this.sanctions = sanctionsDoc.entries ?? []; + } + + private auditId(): string { + this.auditCounter += 1; + return `audit-v13-${this.vendor}-${this.auditCounter}`; + } + + private sanctioned(entityId: string): boolean { + const n = entityId.toLowerCase(); + return this.sanctions.some( + (e) => + e.active !== false && + (e.id.toLowerCase() === n || (e.names ?? []).some((x) => n.includes(x.toLowerCase()))) + ); + } + + async evaluateTransaction(input: ComplianceEvaluationInput): Promise { + const hints = input.riskHints ?? []; + if (this.sanctioned(input.entityId) || hints.includes('sanctions_hit')) { + return { + decision: 'block', + risk_score: 100, + reason_codes: ['SANCTIONS_HIT'], + evidence_refs: ['volume13-sanctions'], + manual_review_required: true, + audit_event_id: this.auditId(), + }; + } + for (const rule of this.rules) { + const t = rule.trigger ?? {}; + if (t.riskHints?.some((h) => hints.includes(h))) { + return { + decision: (rule.decision as ComplianceEvaluationResult['decision']) ?? 'review', + risk_score: 80, + reason_codes: [rule.id], + evidence_refs: [rule.id], + manual_review_required: true, + audit_event_id: this.auditId(), + }; + } + if (t.amountGte != null && Number(input.amount) >= t.amountGte) { + return { + decision: (rule.decision as ComplianceEvaluationResult['decision']) ?? 'hold', + risk_score: 65, + reason_codes: [rule.id], + evidence_refs: [rule.id], + manual_review_required: true, + audit_event_id: this.auditId(), + }; + } + } + return this.fallback.evaluateTransaction(input); + } + + async evaluateEntity(entityId: string, tenantId: string): Promise { + if (this.sanctioned(entityId)) { + return { + decision: 'reject', + risk_score: 95, + reason_codes: ['ENTITY_SANCTIONS'], + evidence_refs: [entityId], + manual_review_required: true, + audit_event_id: this.auditId(), + }; + } + return this.fallback.evaluateEntity(entityId, tenantId); + } + + async evaluateCounterparty(counterpartyId: string, tenantId: string): Promise { + return this.evaluateEntity(counterpartyId, tenantId); + } +} + +export function createComplianceDecisionEngine(proxomoxRoot: string): ComplianceDecisionEngine { + if (process.env.OMNL_COMPLIANCE_GATE !== '1') { + return new MockComplianceDecisionEngine(); + } + return new Volume13ComplianceDecisionEngine(proxomoxRoot); +} diff --git a/services/token-aggregation/src/config/capital-markets-taxonomy.ts b/services/token-aggregation/src/config/capital-markets-taxonomy.ts index 041c439..ed243d8 100644 --- a/services/token-aggregation/src/config/capital-markets-taxonomy.ts +++ b/services/token-aggregation/src/config/capital-markets-taxonomy.ts @@ -3,7 +3,8 @@ * Source of truth: config/rwa-capital-markets-taxonomy.v1.json */ -import taxonomy from '../../../../../config/rwa-capital-markets-taxonomy.v1.json'; +import { existsSync, readFileSync } from 'fs'; +import { resolve } from 'path'; export interface CapitalMarketsFields { assetClass: string; @@ -28,8 +29,31 @@ type TaxonomyInstrument = { defillamaEligible?: boolean; }; +type TaxonomyDocument = { + version: string; + instruments: TaxonomyInstrument[]; +}; + +function projectRoot(): string { + return ( + process.env.PROXMOX_ROOT?.trim() || + process.env.PHOENIX_REPO_ROOT?.trim() || + resolve(__dirname, '../../../../../..') + ); +} + +function loadTaxonomyDocument(): TaxonomyDocument { + const path = resolve(projectRoot(), 'config/rwa-capital-markets-taxonomy.v1.json'); + if (!existsSync(path)) { + return { version: 'unknown', instruments: [] }; + } + return JSON.parse(readFileSync(path, 'utf8')) as TaxonomyDocument; +} + +const taxonomy = loadTaxonomyDocument(); + const byTicker = new Map( - (taxonomy.instruments as TaxonomyInstrument[]).map((row) => [ + taxonomy.instruments.map((row) => [ row.ticker, { assetClass: row.asset_class, diff --git a/services/token-aggregation/src/config/chains.ts b/services/token-aggregation/src/config/chains.ts index be12534..4428903 100644 --- a/services/token-aggregation/src/config/chains.ts +++ b/services/token-aggregation/src/config/chains.ts @@ -141,6 +141,15 @@ export const CHAIN_CONFIGS: Record = { blockTime: 2, confirmations: 1, }, + 143: { + chainId: 143, + name: 'Monad Mainnet', + rpcUrl: process.env.CHAIN_143_RPC_URL || process.env.MONAD_MAINNET_RPC || 'https://rpc.monad.xyz', + explorerUrl: 'https://monadscan.com', + nativeCurrency: { name: 'Monad', symbol: 'MON', decimals: 18 }, + blockTime: 1, + confirmations: 1, + }, }; export function getChainConfig(chainId: number): ChainConfig | undefined { diff --git a/services/token-aggregation/src/config/cross-chain-bridges.ts b/services/token-aggregation/src/config/cross-chain-bridges.ts index d258f33..0950195 100644 --- a/services/token-aggregation/src/config/cross-chain-bridges.ts +++ b/services/token-aggregation/src/config/cross-chain-bridges.ts @@ -48,6 +48,12 @@ if (envAddr('CCIPWETH9_BRIDGE_CHAIN138')) { lanes: [ { destSelector: '5009297550715157269', destChainId: 1, destChainName: 'Ethereum' }, { destSelector: '16015286601757825753', destChainId: 651940, destChainName: 'ALL Mainnet' }, + { + destSelector: '8481857512324358265', + destChainId: 143, + destChainName: 'Monad Mainnet', + bridgeType: 'ccip_relay_monad', + }, ], }); } diff --git a/services/token-aggregation/src/config/monad-relay-lane.ts b/services/token-aggregation/src/config/monad-relay-lane.ts new file mode 100644 index 0000000..b7215ce --- /dev/null +++ b/services/token-aggregation/src/config/monad-relay-lane.ts @@ -0,0 +1,127 @@ +import fs from 'fs'; +import path from 'path'; + +export interface MonadRelayLaneChainSide { + chainId: number; + selector: string; + explorer: string; + rpcEnv?: string; + rpcDefault?: string; + sendBridgeWeth9: string; + relayBridgeInbound?: string; + relayBridgeOutbound?: string; + ccipEventRouter?: string; + ccipRelayRouter?: string; + ccipRouterSend?: string; + hubBridgeWeth9?: string; +} + +export interface MonadRelaySeedMessage { + messageId: string; + direction: '138-to-monad' | 'monad-to-138'; + status: 'initiated' | 'relaying' | 'delivered' | 'failed'; + sourceTxHash?: string; + relayTxHash?: string; + amountWei?: string; + tokenSymbol?: string; + sender?: string; + recipient?: string; +} + +export interface MonadRelayLaneConfig { + version: string; + laneId: string; + description: string; + integration?: { + explorerApi?: string; + ccipExplorer?: boolean; + relayService?: string; + creRequired?: boolean; + creNotes?: string; + }; + chain138: MonadRelayLaneChainSide; + monad: MonadRelayLaneChainSide; + seedMessages?: MonadRelaySeedMessage[]; +} + +const BUILTIN_DEFAULTS: MonadRelayLaneConfig = { + version: '2026-06-13', + laneId: '138-monad-relay', + description: 'Custom CCIP relay lane (event-only routers + off-chain relay).', + chain138: { + chainId: 138, + selector: '16015286601757825753', + explorer: 'https://explorer.d-bis.org', + rpcEnv: 'RPC_URL_138', + sendBridgeWeth9: '0xcacfd227A040002e49e2e01626363071324f820a', + relayBridgeInbound: '0xEB87B3bE09Ab383Ea391caf5bCE70B44Dbd80A9B', + ccipEventRouter: '0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817', + ccipRelayRouter: '0xe75d26bc558a28442f30750c6d97bffb46f39abc', + }, + monad: { + chainId: 143, + selector: '8481857512324358265', + explorer: 'https://monadscan.com', + rpcEnv: 'MONAD_MAINNET_RPC', + rpcDefault: 'https://rpc.monad.xyz', + sendBridgeWeth9: '0xF9DD3349C7f11812d727Db5335690b1e6AD16a52', + relayBridgeOutbound: '0xC158b6cD3A3088C52F797D41f5Aa02825361629e', + ccipRelayRouter: '0x89dd12025bfCD38A168455A44B400e913ED33BE2', + ccipRouterSend: '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c', + hubBridgeWeth9: '0x8078A09637e47Fa5Ed34F626046Ea2094a5CDE5e', + }, +}; + +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; +} + +export function resolveMonadRelayLaneConfigPath(): string | null { + const candidates = uniquePaths([ + process.env.MONAD_RELAY_LANE_JSON_PATH, + path.resolve(process.cwd(), 'config', 'monad-chain138-relay-lane.v1.json'), + path.resolve(process.cwd(), '..', 'config', 'monad-chain138-relay-lane.v1.json'), + path.resolve(process.cwd(), '..', '..', 'config', 'monad-chain138-relay-lane.v1.json'), + path.resolve(process.cwd(), '..', '..', '..', 'config', 'monad-chain138-relay-lane.v1.json'), + path.resolve(__dirname, '../../../../../config/monad-chain138-relay-lane.v1.json'), + ]); + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +export function loadMonadRelayLaneConfig(): MonadRelayLaneConfig { + const configPath = resolveMonadRelayLaneConfigPath(); + if (!configPath) return BUILTIN_DEFAULTS; + + try { + const raw = fs.readFileSync(configPath, 'utf8'); + const parsed = JSON.parse(raw) as MonadRelayLaneConfig; + return { + ...BUILTIN_DEFAULTS, + ...parsed, + chain138: { ...BUILTIN_DEFAULTS.chain138, ...parsed.chain138 }, + monad: { ...BUILTIN_DEFAULTS.monad, ...parsed.monad }, + }; + } catch { + return BUILTIN_DEFAULTS; + } +} + +export function resolveMonadRpcUrl(config: MonadRelayLaneConfig = loadMonadRelayLaneConfig()): string { + const envKey = config.monad.rpcEnv ?? 'MONAD_MAINNET_RPC'; + const fromEnv = process.env[envKey]?.trim(); + if (fromEnv) return fromEnv; + return config.monad.rpcDefault ?? 'https://rpc.monad.xyz'; +} diff --git a/services/token-aggregation/src/config/networks.ts b/services/token-aggregation/src/config/networks.ts index 8967f35..551f43d 100644 --- a/services/token-aggregation/src/config/networks.ts +++ b/services/token-aggregation/src/config/networks.ts @@ -25,12 +25,7 @@ export const NETWORKS: NetworkEntry[] = [ chainId: '0x8a', chainIdDecimal: 138, chainName: 'DeFi Oracle Meta Mainnet', - rpcUrls: [ - 'https://rpc-http-pub.d-bis.org', - 'https://rpc.d-bis.org', - 'https://rpc2.d-bis.org', - 'https://rpc.defi-oracle.io', - ], + rpcUrls: ['https://rpc-http-pub.d-bis.org'], nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, blockExplorerUrls: ['https://explorer.d-bis.org'], iconUrls: [ @@ -39,7 +34,8 @@ export const NETWORKS: NetworkEntry[] = [ 'https://explorer.d-bis.org/favicon.ico', ], oracles: [ - { name: 'ETH/USD', address: '0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6', decimals: 8 }, + { name: 'ETH/USD (proxy)', address: '0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6', decimals: 8 }, + { name: 'ETH/USD (aggregator)', address: '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506', decimals: 8 }, ], }, { diff --git a/services/token-aggregation/src/index.ts b/services/token-aggregation/src/index.ts index f5e6d05..ad684f2 100644 --- a/services/token-aggregation/src/index.ts +++ b/services/token-aggregation/src/index.ts @@ -6,6 +6,7 @@ import { closeDatabasePool } from './database/client'; import { logger } from './utils/logger'; import { startRouteMatrixScheduler } from './services/route-matrix-scheduler'; import { startGruReserveCapacityScheduler } from './services/gru-reserve-capacity-scheduler'; +import { startIso4217FxScheduler } from './services/iso4217-fx-refresh'; // Load smom-dbis-138 root .env first (single source); works from dist/ or src/ const rootEnvCandidates = [ @@ -30,6 +31,7 @@ try { const server = new ApiServer(); startRouteMatrixScheduler(); startGruReserveCapacityScheduler(); +startIso4217FxScheduler(); // 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 4343934..d193b26 100644 --- a/services/token-aggregation/src/indexer/chain-indexer.ts +++ b/services/token-aggregation/src/indexer/chain-indexer.ts @@ -10,8 +10,11 @@ import { CoinMarketCapAdapter } from '../adapters/cmc-adapter'; import { DexScreenerAdapter } from '../adapters/dexscreener-adapter'; import { logger } from '../utils/logger'; import { getCanonicalPriceUsd } from '../services/canonical-price-oracle'; +import { getChain138EthUsdOnChain } from '../services/chain138-onchain-reference-prices'; import { pickExternalMarketDataForIndexer } from '../services/valuation-precedence'; +const CHAIN138_WETH = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; + export class ChainIndexer { private chainId: number; private provider: ethers.JsonRpcProvider; @@ -171,6 +174,14 @@ export class ChainIndexer { }); const canonicalPriceUsd = getCanonicalPriceUsd(this.chainId, tokenAddress); + let priceUsd = externalData?.priceUsd ?? canonicalPriceUsd; + if (this.chainId === 138 && tokenAddress.toLowerCase() === CHAIN138_WETH) { + const native = await getChain138EthUsdOnChain(); + if (native?.priceUsd != null && native.priceUsd > 0) { + priceUsd = native.priceUsd; + } + } + // Get pools for liquidity calculation const tokenPools = pools.filter( (p) => p.token0Address === tokenAddress || p.token1Address === tokenAddress @@ -181,7 +192,7 @@ export class ChainIndexer { await this.marketDataRepo.upsertMarketData({ chainId: this.chainId, tokenAddress, - priceUsd: externalData?.priceUsd ?? canonicalPriceUsd, + priceUsd, priceChange24h: externalData?.priceChange24h, volume24h: volumeMetrics.volume24h || externalData?.volume24h || 0, volume7d: volumeMetrics.volume7d, diff --git a/services/token-aggregation/src/indexer/relay-lane-indexer.test.ts b/services/token-aggregation/src/indexer/relay-lane-indexer.test.ts new file mode 100644 index 0000000..0034f82 --- /dev/null +++ b/services/token-aggregation/src/indexer/relay-lane-indexer.test.ts @@ -0,0 +1,84 @@ +import { mergeRelayLaneMessages } from './relay-lane-indexer'; +import type { MonadRelayLaneConfig } from '../config/monad-relay-lane'; + +const TEST_CONFIG: MonadRelayLaneConfig = { + version: 'test', + laneId: '138-monad-relay', + description: 'test lane', + chain138: { + chainId: 138, + selector: '16015286601757825753', + explorer: 'https://explorer.d-bis.org', + sendBridgeWeth9: '0xcacfd227A040002e49e2e01626363071324f820a', + relayBridgeInbound: '0xEB87B3bE09Ab383Ea391caf5bCE70B44Dbd80A9B', + }, + monad: { + chainId: 143, + selector: '8481857512324358265', + explorer: 'https://monadscan.com', + sendBridgeWeth9: '0xF9DD3349C7f11812d727Db5335690b1e6AD16a52', + relayBridgeOutbound: '0xC158b6cD3A3088C52F797D41f5Aa02825361629e', + }, +}; + +describe('mergeRelayLaneMessages', () => { + it('merges initiated and completed by messageId', () => { + const messageId = '0xdebc46c8ce3b4698331768dcddb9c6ee69c7d891fbe48e930f383a27330bf8e8'; + const messages = mergeRelayLaneMessages( + TEST_CONFIG, + [ + { + messageId, + direction: '138-to-monad', + sourceChainId: 138, + destChainId: 143, + destSelector: '8481857512324358265', + sourceTxHash: '0xd2200edf828a7310aebc28e704f668732a5eb4fcba648b7339aa132a1496dd39', + sourceBlockNumber: 100, + sender: '0x4A666F96fC8764181194447A7dFdb7d471b301C8', + recipient: '0x4A666F96fC8764181194447A7dFdb7d471b301C8', + amountWei: '1000000000000000', + tokenSymbol: 'WETH', + }, + ], + [ + { + messageId, + direction: '138-to-monad', + destChainId: 143, + destTxHash: '0xbd79554a89e2abb3024462009725851f57a54eb8ddfd3d095068607c8a5e2402', + destBlockNumber: 200, + recipient: '0x4A666F96fC8764181194447A7dFdb7d471b301C8', + amountWei: '1000000000000000', + }, + ], + [] + ); + + expect(messages).toHaveLength(1); + expect(messages[0].status).toBe('delivered'); + expect(messages[0].sourceTxHash).toMatch(/^0x/); + expect(messages[0].relayTxHash).toMatch(/^0x/); + expect(messages[0].explorerLinks.sourceTx).toContain('explorer.d-bis.org'); + expect(messages[0].explorerLinks.relayTx).toContain('monadscan.com'); + }); + + it('includes seed messages when RPC data is absent', () => { + const messages = mergeRelayLaneMessages(TEST_CONFIG, [], [], [ + { + messageId: '0x82dcbc28d1e7b50a0b9caa1a9415078148dd78b5aeba9a23aa5714ef6cf8b970', + direction: 'monad-to-138', + status: 'delivered', + sourceTxHash: '0x27d4ccf43c8bc17bab56b0a18c830dce21dd7ae175fd24e2261cb3a414f994ae', + relayTxHash: '0x4435ecacc79919f64e6de6cd6b2d454a92580bb72a4c7e320d06cff2354e9567', + amountWei: '1000000000000000', + tokenSymbol: 'WMON', + }, + ]); + + expect(messages).toHaveLength(1); + expect(messages[0].direction).toBe('monad-to-138'); + expect(messages[0].explorerLinks.sourceTx).toContain('monadscan.com'); + expect(messages[0].explorerLinks.relayTx).toContain('explorer.d-bis.org'); + }); +}); diff --git a/services/token-aggregation/src/indexer/relay-lane-indexer.ts b/services/token-aggregation/src/indexer/relay-lane-indexer.ts new file mode 100644 index 0000000..8143369 --- /dev/null +++ b/services/token-aggregation/src/indexer/relay-lane-indexer.ts @@ -0,0 +1,447 @@ +/** + * Indexes custom CCIP relay lane messages for Chain 138 ↔ Monad (143). + * Correlates CrossChainTransferInitiated (source) with CrossChainTransferCompleted (destination). + */ + +import { ethers } from 'ethers'; +import { resolveChain138RpcUrl } from '../config/chain138-rpc'; +import { + loadMonadRelayLaneConfig, + MonadRelayLaneConfig, + MonadRelaySeedMessage, + resolveMonadRpcUrl, +} from '../config/monad-relay-lane'; +import { logger } from '../utils/logger'; + +const CCIP_TRANSFER_ABI = [ + 'event CrossChainTransferInitiated(bytes32 indexed messageId, address indexed sender, uint64 indexed destinationChainSelector, address recipient, uint256 amount, uint256 nonce)', + 'event CrossChainTransferCompleted(bytes32 indexed messageId, uint64 indexed sourceChainSelector, address indexed recipient, uint256 amount)', + 'function processedTransfers(bytes32 messageId) view returns (bool)', +]; + +const CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN = Math.max( + 1, + Number(process.env.CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN || 5000) +); + +export type RelayLaneDirection = '138-to-monad' | 'monad-to-138'; +export type RelayLaneStatus = 'initiated' | 'relaying' | 'delivered' | 'failed'; + +export interface RelayLaneInitiatedEvent { + messageId: string; + direction: RelayLaneDirection; + sourceChainId: number; + destChainId: number; + destSelector: string; + sourceTxHash: string; + sourceBlockNumber: number; + sender: string; + recipient: string; + amountWei: string; + tokenSymbol: string; +} + +export interface RelayLaneCompletedEvent { + messageId: string; + direction: RelayLaneDirection; + destChainId: number; + destTxHash: string; + destBlockNumber: number; + recipient: string; + amountWei: string; +} + +export interface RelayLaneMessage { + messageId: string; + laneId: string; + direction: RelayLaneDirection; + status: RelayLaneStatus; + sourceChainId: number; + destChainId: number; + tokenSymbol: string; + amountWei: string; + sender?: string; + recipient?: string; + sourceTxHash?: string; + sourceBlockNumber?: number; + relayTxHash?: string; + destTxHash?: string; + destBlockNumber?: number; + explorerLinks: { + sourceTx?: string; + relayTx?: string; + destTx?: string; + }; +} + +export interface RelayLaneReport { + generatedAt: string; + laneId: string; + description: string; + integration: { + ccipExplorer: boolean; + creRequired: boolean; + relayService?: string; + notes?: string; + }; + chains: { + chain138: { chainId: number; selector: string; explorer: string }; + monad: { chainId: number; selector: string; explorer: string }; + }; + contracts: { + chain138SendBridge: string; + chain138RelayBridge: string; + monadSendBridge: string; + monadRelayBridge: string; + }; + messages: RelayLaneMessage[]; + stats: { + total: number; + delivered: number; + pending: number; + }; +} + +function txExplorer(base: string, txHash?: string): string | undefined { + if (!txHash?.startsWith('0x')) return undefined; + const trimmed = base.replace(/\/$/, ''); + return `${trimmed}/tx/${txHash}`; +} + +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, + 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[] = []; + const chunkSpan = Math.min(CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN, toBlock - fromBlock + 1); + for (let start = fromBlock; start <= toBlock; start += chunkSpan) { + const end = Math.min(start + chunkSpan - 1, toBlock); + const chunk = await queryFilterWithRangeFallback(contract, filter, start, end); + logs.push(...chunk); + } + return logs; +} + +async function fetchInitiated( + provider: ethers.JsonRpcProvider, + bridgeAddress: string, + destSelector: string, + direction: RelayLaneDirection, + sourceChainId: number, + destChainId: number, + tokenSymbol: string, + fromBlock: number, + toBlock: number +): Promise { + const events: RelayLaneInitiatedEvent[] = []; + try { + const contract = new ethers.Contract(bridgeAddress, CCIP_TRANSFER_ABI, provider); + const filter = contract.filters.CrossChainTransferInitiated(); + 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; + }; + if (args.destinationChainSelector?.toString() !== destSelector) continue; + + events.push({ + messageId: args.messageId, + direction, + sourceChainId, + destChainId, + destSelector, + sourceTxHash: log.transactionHash, + sourceBlockNumber: log.blockNumber, + sender: args.sender, + recipient: args.recipient, + amountWei: args.amount?.toString() ?? '0', + tokenSymbol, + }); + } + } catch (err) { + logger.warn(`Relay lane indexer: initiated events for ${bridgeAddress} failed:`, err); + } + return events; +} + +async function fetchCompleted( + provider: ethers.JsonRpcProvider, + bridgeAddress: string, + sourceSelector: string, + direction: RelayLaneDirection, + destChainId: number, + fromBlock: number, + toBlock: number +): Promise { + const events: RelayLaneCompletedEvent[] = []; + try { + const contract = new ethers.Contract(bridgeAddress, CCIP_TRANSFER_ABI, provider); + const filter = contract.filters.CrossChainTransferCompleted(); + 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; + sourceChainSelector: bigint; + recipient: string; + amount: bigint; + }; + if (args.sourceChainSelector?.toString() !== sourceSelector) continue; + + events.push({ + messageId: args.messageId, + direction, + destChainId, + destTxHash: log.transactionHash, + destBlockNumber: log.blockNumber, + recipient: args.recipient, + amountWei: args.amount?.toString() ?? '0', + }); + } + } catch (err) { + logger.warn(`Relay lane indexer: completed events for ${bridgeAddress} failed:`, err); + } + return events; +} + +/** Merge initiated + completed + seed rows into explorer-facing messages. Exported for tests. */ +export function mergeRelayLaneMessages( + config: MonadRelayLaneConfig, + initiated: RelayLaneInitiatedEvent[], + completed: RelayLaneCompletedEvent[], + seeds: MonadRelaySeedMessage[] = [] +): RelayLaneMessage[] { + const byId = new Map(); + + const upsert = (messageId: string, patch: Partial & Pick): void => { + const key = messageId.toLowerCase(); + const existing = byId.get(key); + const sourceExplorer = + patch.direction === '138-to-monad' ? config.chain138.explorer : config.monad.explorer; + const destExplorer = + patch.direction === '138-to-monad' ? config.monad.explorer : config.chain138.explorer; + + const merged: RelayLaneMessage = { + messageId, + laneId: config.laneId, + direction: patch.direction, + status: patch.status ?? existing?.status ?? 'initiated', + sourceChainId: patch.sourceChainId ?? existing?.sourceChainId ?? (patch.direction === '138-to-monad' ? 138 : 143), + destChainId: patch.destChainId ?? existing?.destChainId ?? (patch.direction === '138-to-monad' ? 143 : 138), + tokenSymbol: patch.tokenSymbol ?? existing?.tokenSymbol ?? (patch.direction === '138-to-monad' ? 'WETH' : 'WMON'), + amountWei: patch.amountWei ?? existing?.amountWei ?? '0', + sender: patch.sender ?? existing?.sender, + recipient: patch.recipient ?? existing?.recipient, + sourceTxHash: patch.sourceTxHash ?? existing?.sourceTxHash, + sourceBlockNumber: patch.sourceBlockNumber ?? existing?.sourceBlockNumber, + relayTxHash: patch.relayTxHash ?? existing?.relayTxHash, + destTxHash: patch.destTxHash ?? existing?.destTxHash, + destBlockNumber: patch.destBlockNumber ?? existing?.destBlockNumber, + explorerLinks: { + sourceTx: txExplorer(sourceExplorer, patch.sourceTxHash ?? existing?.sourceTxHash), + relayTx: txExplorer(destExplorer, patch.relayTxHash ?? existing?.relayTxHash ?? patch.destTxHash ?? existing?.destTxHash), + destTx: txExplorer(destExplorer, patch.destTxHash ?? existing?.destTxHash), + }, + }; + byId.set(key, merged); + }; + + for (const e of initiated) { + upsert(e.messageId, { + direction: e.direction, + status: 'initiated', + sourceChainId: e.sourceChainId, + destChainId: e.destChainId, + tokenSymbol: e.tokenSymbol, + amountWei: e.amountWei, + sender: e.sender, + recipient: e.recipient, + sourceTxHash: e.sourceTxHash, + sourceBlockNumber: e.sourceBlockNumber, + }); + } + + for (const e of completed) { + upsert(e.messageId, { + direction: e.direction, + status: 'delivered', + destChainId: e.destChainId, + amountWei: e.amountWei, + recipient: e.recipient, + relayTxHash: e.destTxHash, + destTxHash: e.destTxHash, + destBlockNumber: e.destBlockNumber, + }); + } + + for (const seed of seeds) { + upsert(seed.messageId, { + direction: seed.direction, + status: seed.status, + tokenSymbol: seed.tokenSymbol, + amountWei: seed.amountWei, + sender: seed.sender, + recipient: seed.recipient, + sourceTxHash: seed.sourceTxHash, + relayTxHash: seed.relayTxHash, + destTxHash: seed.relayTxHash, + }); + } + + return [...byId.values()].sort((a, b) => { + const blockA = a.sourceBlockNumber ?? a.destBlockNumber ?? 0; + const blockB = b.sourceBlockNumber ?? b.destBlockNumber ?? 0; + return blockB - blockA; + }); +} + +export interface BuildRelayLaneReportOptions { + fromBlockDays?: number; + limit?: number; + skipRpc?: boolean; +} + +export async function buildRelayLaneReport( + options: BuildRelayLaneReportOptions = {} +): Promise { + const config = loadMonadRelayLaneConfig(); + const fromBlockDays = options.fromBlockDays ?? 30; + const limit = options.limit ?? 100; + const seeds = config.seedMessages ?? []; + + let initiated: RelayLaneInitiatedEvent[] = []; + let completed: RelayLaneCompletedEvent[] = []; + + if (!options.skipRpc) { + try { + const chain138Rpc = resolveChain138RpcUrl(); + const monadRpc = resolveMonadRpcUrl(config); + const chain138Provider = new ethers.JsonRpcProvider(chain138Rpc); + const monadProvider = new ethers.JsonRpcProvider(monadRpc); + + const [chain138Block, monadBlock] = await Promise.all([ + chain138Provider.getBlockNumber(), + monadProvider.getBlockNumber(), + ]); + + const chain138From = Math.max(0, chain138Block - Math.floor((fromBlockDays * 86400) / 5)); + const monadFrom = Math.max(0, monadBlock - Math.floor((fromBlockDays * 86400) / 1)); + + const [ + initiated138, + initiatedMonad, + completedMonad, + completed138, + ] = await Promise.all([ + fetchInitiated( + chain138Provider, + config.chain138.sendBridgeWeth9, + config.monad.selector, + '138-to-monad', + config.chain138.chainId, + config.monad.chainId, + 'WETH', + chain138From, + chain138Block + ), + fetchInitiated( + monadProvider, + config.monad.sendBridgeWeth9, + config.chain138.selector, + 'monad-to-138', + config.monad.chainId, + config.chain138.chainId, + 'WMON', + monadFrom, + monadBlock + ), + fetchCompleted( + monadProvider, + config.monad.relayBridgeOutbound ?? '', + config.chain138.selector, + '138-to-monad', + config.monad.chainId, + monadFrom, + monadBlock + ), + fetchCompleted( + chain138Provider, + config.chain138.relayBridgeInbound ?? '', + config.monad.selector, + 'monad-to-138', + config.chain138.chainId, + chain138From, + chain138Block + ), + ]); + + initiated = [...initiated138, ...initiatedMonad]; + completed = [...completedMonad, ...completed138]; + } catch (err) { + logger.warn('Relay lane indexer: RPC scan failed, returning seed messages only:', err); + } + } + + const messages = mergeRelayLaneMessages(config, initiated, completed, seeds).slice(0, limit); + const delivered = messages.filter((m) => m.status === 'delivered').length; + + return { + generatedAt: new Date().toISOString(), + laneId: config.laneId, + description: config.description, + integration: { + ccipExplorer: config.integration?.ccipExplorer ?? false, + creRequired: config.integration?.creRequired ?? false, + relayService: config.integration?.relayService, + notes: config.integration?.creNotes, + }, + chains: { + chain138: { + chainId: config.chain138.chainId, + selector: config.chain138.selector, + explorer: config.chain138.explorer, + }, + monad: { + chainId: config.monad.chainId, + selector: config.monad.selector, + explorer: config.monad.explorer, + }, + }, + contracts: { + chain138SendBridge: config.chain138.sendBridgeWeth9, + chain138RelayBridge: config.chain138.relayBridgeInbound ?? '', + monadSendBridge: config.monad.sendBridgeWeth9, + monadRelayBridge: config.monad.relayBridgeOutbound ?? '', + }, + messages, + stats: { + total: messages.length, + delivered, + pending: messages.length - delivered, + }, + }; +} diff --git a/services/token-aggregation/src/lib/deterministic-placeholder-address.test.ts b/services/token-aggregation/src/lib/deterministic-placeholder-address.test.ts new file mode 100644 index 0000000..e38a80e --- /dev/null +++ b/services/token-aggregation/src/lib/deterministic-placeholder-address.test.ts @@ -0,0 +1,13 @@ +import { isDeterministicPlaceholderAddress } from './deterministic-placeholder-address'; + +describe('isDeterministicPlaceholderAddress', () => { + it('detects GRU transport placeholders on Chain 138', () => { + expect(isDeterministicPlaceholderAddress('0xcaaa00000000000000000000000000000000008a')).toBe(true); + expect(isDeterministicPlaceholderAddress('0xc11100000000000000000000000000000000008a')).toBe(true); + }); + + it('does not flag live deployed token addresses', () => { + expect(isDeterministicPlaceholderAddress('0x93E66202A11B1772E55407B32B44e5Cd8eda7f22')).toBe(false); + expect(isDeterministicPlaceholderAddress('0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03')).toBe(false); + }); +}); diff --git a/services/token-aggregation/src/lib/deterministic-placeholder-address.ts b/services/token-aggregation/src/lib/deterministic-placeholder-address.ts new file mode 100644 index 0000000..b8aace4 --- /dev/null +++ b/services/token-aggregation/src/lib/deterministic-placeholder-address.ts @@ -0,0 +1,5 @@ +/** GRU transport placeholders (e.g. 0xcaaa…008a) — not deployed ERC-20 contracts. */ +export function isDeterministicPlaceholderAddress(address: string): boolean { + const normalized = address.toLowerCase(); + return /^0x[a-f0-9]{4}0{24,}[a-f0-9]{1,8}$/.test(normalized); +} diff --git a/services/token-aggregation/src/lib/wallet-add-ethereum-chain.test.ts b/services/token-aggregation/src/lib/wallet-add-ethereum-chain.test.ts new file mode 100644 index 0000000..63d3729 --- /dev/null +++ b/services/token-aggregation/src/lib/wallet-add-ethereum-chain.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { toWalletAddEthereumChainParams } from './wallet-add-ethereum-chain'; + +describe('toWalletAddEthereumChainParams', () => { + it('strips MetaMask-unsupported keys', () => { + const out = toWalletAddEthereumChainParams({ + chainId: '0x8a', + chainIdDecimal: 138, + chainName: 'DeFi Oracle Meta Mainnet', + rpcUrls: ['https://rpc-http-pub.d-bis.org', 'wss://rpc-ws-pub.d-bis.org'], + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + blockExplorerUrls: ['https://explorer.d-bis.org'], + iconUrls: ['https://explorer.d-bis.org/favicon.ico'], + oracles: [{ name: 'ETH/USD', address: '0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6' }], + } as never); + + expect(out).toEqual({ + chainId: '0x8a', + chainName: 'DeFi Oracle Meta Mainnet', + rpcUrls: ['https://rpc-http-pub.d-bis.org'], + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + blockExplorerUrls: ['https://explorer.d-bis.org'], + iconUrls: ['https://explorer.d-bis.org/favicon.ico'], + }); + expect(out).not.toHaveProperty('chainIdDecimal'); + expect(out).not.toHaveProperty('oracles'); + }); +}); diff --git a/services/token-aggregation/src/lib/wallet-add-ethereum-chain.ts b/services/token-aggregation/src/lib/wallet-add-ethereum-chain.ts new file mode 100644 index 0000000..ea54576 --- /dev/null +++ b/services/token-aggregation/src/lib/wallet-add-ethereum-chain.ts @@ -0,0 +1,38 @@ +import type { NetworkEntry } from '../config/networks'; + +/** EIP-3085 fields accepted by MetaMask `wallet_addEthereumChain` (strict subset). */ +export type WalletAddEthereumChainParams = { + chainId: string; + chainName: string; + rpcUrls: string[]; + nativeCurrency: { name: string; symbol: string; decimals: number }; + blockExplorerUrls?: string[]; + iconUrls?: string[]; +}; + +function httpsOnly(urls: string[] | undefined): string[] | undefined { + if (!urls?.length) return undefined; + const filtered = urls.filter((url) => typeof url === 'string' && url.startsWith('https://')); + return filtered.length > 0 ? filtered : undefined; +} + +/** Strip `chainIdDecimal`, `oracles`, and other keys MetaMask rejects. */ +export function toWalletAddEthereumChainParams( + network: Pick< + NetworkEntry, + 'chainId' | 'chainName' | 'rpcUrls' | 'nativeCurrency' | 'blockExplorerUrls' | 'iconUrls' + > +): WalletAddEthereumChainParams { + const rpcUrls = httpsOnly(network.rpcUrls) ?? ['https://rpc-http-pub.d-bis.org']; + const out: WalletAddEthereumChainParams = { + chainId: network.chainId, + chainName: network.chainName, + rpcUrls, + nativeCurrency: network.nativeCurrency, + }; + const blockExplorerUrls = httpsOnly(network.blockExplorerUrls); + if (blockExplorerUrls) out.blockExplorerUrls = blockExplorerUrls; + const iconUrls = httpsOnly(network.iconUrls); + if (iconUrls) out.iconUrls = iconUrls; + return out; +} diff --git a/services/token-aggregation/src/lib/wallet-watch-eligible.test.ts b/services/token-aggregation/src/lib/wallet-watch-eligible.test.ts new file mode 100644 index 0000000..f43dbb7 --- /dev/null +++ b/services/token-aggregation/src/lib/wallet-watch-eligible.test.ts @@ -0,0 +1,57 @@ +import { + isWalletWatchAssetEligible, + isWalletWatchEligibleTokenAddress, + dedupeWatchAssetEntriesBySymbol, +} from './wallet-watch-eligible'; + +describe('wallet-watch-eligible', () => { + it('excludes deterministic placeholder addresses', () => { + expect(isWalletWatchEligibleTokenAddress('0xcaaa00000000000000000000000000000000008a')).toBe(false); + expect(isWalletWatchEligibleTokenAddress('0x93E66202A11B1772E55407B32B44e5Cd8eda7f22')).toBe(true); + }); + + it('excludes gas-native placeholder specs on Chain 138', () => { + expect( + isWalletWatchAssetEligible( + { symbol: 'cAVAX', addresses: { 138: '0xcaaa00000000000000000000000000000000008a' } }, + 138, + ), + ).toBe(false); + }); + + it('excludes staged V2 family deployments that would collide in MetaMask', () => { + expect( + isWalletWatchAssetEligible( + { + symbol: 'cUSDC_V2', + familySymbol: 'cUSDC', + deploymentVersion: 'v2', + deploymentStatus: 'staged', + addresses: { 138: '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d' }, + }, + 138, + ), + ).toBe(false); + expect( + isWalletWatchAssetEligible( + { symbol: 'cUSDC', addresses: { 138: '0xf22258f57794CC8E06237084b353Ab30fFfa640b' } }, + 138, + ), + ).toBe(true); + }); + + it('dedupes watchAsset rows by wallet symbol', () => { + const deduped = dedupeWatchAssetEntriesBySymbol([ + { + options: { symbol: 'cUSDC', address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b' }, + metadata: { catalogSymbol: 'cUSDC' }, + }, + { + options: { symbol: 'cUSDC', address: '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d' }, + metadata: { catalogSymbol: 'cUSDC_V2', deploymentVersion: 'v2', deploymentStatus: 'staged' }, + }, + ]); + expect(deduped).toHaveLength(1); + expect(deduped[0]?.options.address).toBe('0xf22258f57794CC8E06237084b353Ab30fFfa640b'); + }); +}); diff --git a/services/token-aggregation/src/lib/wallet-watch-eligible.ts b/services/token-aggregation/src/lib/wallet-watch-eligible.ts new file mode 100644 index 0000000..4abe509 --- /dev/null +++ b/services/token-aggregation/src/lib/wallet-watch-eligible.ts @@ -0,0 +1,68 @@ +import { isDeterministicPlaceholderAddress } from './deterministic-placeholder-address'; + +const HUB_COMPLIANT_WATCH_SYMBOLS = new Set(['cUSDC', 'cUSDT']); +const HUB_COMPLIANT_WATCH_CHAIN_IDS = new Set([138, 651940]); + +export type WalletWatchAssetSpec = { + symbol: string; + addresses: Partial>; + deploymentStatus?: string; + deploymentVersion?: string; + familySymbol?: string; +}; + +export function isStagedWalletWatchAssetSpec(spec: WalletWatchAssetSpec): boolean { + const status = String(spec.deploymentStatus || '').trim().toLowerCase(); + const version = String(spec.deploymentVersion || '').trim().toLowerCase(); + return status === 'staged' || (version === 'v2' && Boolean(spec.familySymbol)); +} + +export function isWalletWatchAssetEligible(spec: WalletWatchAssetSpec, chainId: number): boolean { + if (HUB_COMPLIANT_WATCH_SYMBOLS.has(spec.symbol) && !HUB_COMPLIANT_WATCH_CHAIN_IDS.has(chainId)) { + return false; + } + if (isStagedWalletWatchAssetSpec(spec)) { + return false; + } + const address = spec.addresses[chainId]; + if (address && isDeterministicPlaceholderAddress(address)) { + return false; + } + return true; +} + +export function isWalletWatchEligibleTokenAddress(address: string | undefined | null): boolean { + if (!address) return false; + return !isDeterministicPlaceholderAddress(address); +} + +export type WalletWatchAssetEntryLike = { + options: { symbol: string; address: string }; + metadata?: { + deploymentStatus?: string; + deploymentVersion?: string; + catalogSymbol?: string; + }; +}; + +/** MetaMask keeps one custom token row per symbol — keep the live deployment when duplicates exist. */ +export function dedupeWatchAssetEntriesBySymbol(entries: T[]): T[] { + const rank = (entry: T): number => { + let score = 0; + const status = String(entry.metadata?.deploymentStatus || '').toLowerCase(); + const version = String(entry.metadata?.deploymentVersion || '').toLowerCase(); + if (status === 'staged') score += 100; + if (version === 'v2') score += 50; + return score; + }; + + const bySymbol = new Map(); + for (const entry of entries) { + const key = entry.options.symbol.trim().toLowerCase(); + const existing = bySymbol.get(key); + if (!existing || rank(entry) < rank(existing)) { + bySymbol.set(key, entry); + } + } + return Array.from(bySymbol.values()); +} diff --git a/services/token-aggregation/src/services/blockscout-token-meta.ts b/services/token-aggregation/src/services/blockscout-token-meta.ts new file mode 100644 index 0000000..7b9d8ec --- /dev/null +++ b/services/token-aggregation/src/services/blockscout-token-meta.ts @@ -0,0 +1,63 @@ +import { CHAIN_CONFIGS } from '../config/chains'; +import { logger } from '../utils/logger'; + +export interface BlockscoutTokenMeta { + address: string; + name?: string; + symbol?: string; + decimals: number; + totalSupply?: string; + exchangeRate?: number; +} + +function parseExchangeRate(value: unknown): number | undefined { + if (value == null) return undefined; + const numeric = typeof value === 'number' ? value : Number(value); + return Number.isFinite(numeric) && numeric > 0 ? numeric : undefined; +} + +export async function fetchBlockscoutTokenMeta( + chainId: number, + address: string, +): Promise { + const explorerUrl = CHAIN_CONFIGS[chainId]?.explorerUrl?.replace(/\/$/, ''); + if (!explorerUrl) { + return null; + } + + const normalized = address.toLowerCase(); + try { + const response = await fetch(`${explorerUrl}/api/v2/tokens/${normalized}`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(8000), + }); + if (!response.ok) { + return null; + } + + const raw = (await response.json()) as { + address?: string; + name?: string | null; + symbol?: string | null; + decimals?: string | number | null; + total_supply?: string | null; + exchange_rate?: string | number | null; + }; + + if (!raw.address) { + return null; + } + + return { + address: raw.address.toLowerCase(), + name: raw.name || undefined, + symbol: raw.symbol || undefined, + decimals: Number(raw.decimals ?? 18), + totalSupply: raw.total_supply || undefined, + exchangeRate: parseExchangeRate(raw.exchange_rate), + }; + } catch (error) { + logger.warn('Blockscout token meta fetch failed', { chainId, address: normalized, error }); + return null; + } +} diff --git a/services/token-aggregation/src/services/canonical-price-oracle.test.ts b/services/token-aggregation/src/services/canonical-price-oracle.test.ts index 56b8ab7..5411328 100644 --- a/services/token-aggregation/src/services/canonical-price-oracle.test.ts +++ b/services/token-aggregation/src/services/canonical-price-oracle.test.ts @@ -1,5 +1,10 @@ import { getCanonicalTokenBySymbol } from '../config/canonical-tokens'; -import { getCanonicalPriceUsd, resolveCanonicalPriceUsd, resolveCanonicalPriceUsdForSpec } from './canonical-price-oracle'; +import { + getCanonicalPriceUsd, + primeLiveReferencePriceUsd, + resolveCanonicalPriceUsd, + resolveCanonicalPriceUsdForSpec, +} from './canonical-price-oracle'; describe('canonical-price-oracle', () => { it('pegs Chain 138 USD-family mirrors to one dollar', () => { @@ -75,13 +80,25 @@ describe('canonical-price-oracle', () => { }); it('resolves WETH9 and WETH10 to the ETH peg with 18-decimal canonical metadata', () => { - expect(resolveCanonicalPriceUsd(138, '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2')).toMatchObject({ - priceUsd: 2490, + const weth = resolveCanonicalPriceUsd(138, '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'); + expect(weth).toMatchObject({ referenceSymbol: 'ETH', }); - expect(resolveCanonicalPriceUsd(138, '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f')).toMatchObject({ - priceUsd: 2490, + expect(weth.priceUsd).toBeGreaterThan(0); + expect(['env', 'on-chain', 'repo-fallback']).toContain(weth.source); + + const weth10 = resolveCanonicalPriceUsd(138, '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f'); + expect(weth10.referenceSymbol).toBe('ETH'); + expect(weth10.priceUsd).toBeGreaterThan(0); + }); + + it('prefers live on-chain ETH over env when drift exceeds threshold on chain 138', () => { + process.env.CHAIN138_CANONICAL_PRICE_USD_ETH = '2100'; + primeLiveReferencePriceUsd('ETH', 1772); + expect(resolveCanonicalPriceUsd(138, '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2')).toMatchObject({ + priceUsd: 1772, referenceSymbol: 'ETH', + source: 'on-chain', }); }); diff --git a/services/token-aggregation/src/services/canonical-price-oracle.ts b/services/token-aggregation/src/services/canonical-price-oracle.ts index 242d0a4..b0e1bda 100644 --- a/services/token-aggregation/src/services/canonical-price-oracle.ts +++ b/services/token-aggregation/src/services/canonical-price-oracle.ts @@ -3,10 +3,49 @@ import { type CanonicalTokenSpec, getCanonicalTokenByAddress, getCanonicalTokenB export interface CanonicalPriceResolution { priceUsd?: number; referenceSymbol?: string; - source: 'env' | 'repo-fallback' | 'unresolved'; + source: 'env' | 'on-chain' | 'repo-fallback' | 'unresolved'; } const FX_SNAPSHOT_GENERATED_AT = '2026-04-15'; +const CHAIN_138 = 138; + +/** Max bps drift before live Chain 138 ETH/USD aggregator beats env canonical (default 3%). */ +export function readChain138EthOracleDriftBps(): number { + const raw = process.env.TOKEN_AGG_CHAIN138_ETH_ORACLE_DRIFT_BPS; + const n = raw ? parseInt(raw, 10) : NaN; + return Number.isFinite(n) && n >= 0 ? n : 300; +} + +export function ethUsdOracleDriftBps(indexerPrice: number, oraclePrice: number): number { + if (!(oraclePrice > 0) || !(indexerPrice > 0)) { + return 0; + } + return (Math.abs(indexerPrice - oraclePrice) / oraclePrice) * 10000; +} + +const liveReferencePrices = new Map(); + +export function primeLiveReferencePriceUsd( + referenceSymbol: string, + priceUsd: number | undefined, + ttlMs = 120_000, +): void { + if (!priceUsd || !(priceUsd > 0)) { + return; + } + liveReferencePrices.set(referenceSymbol.toUpperCase(), { + priceUsd, + expiresAt: Date.now() + ttlMs, + }); +} + +function readLiveReferencePriceUsd(referenceSymbol: string): number | undefined { + const row = liveReferencePrices.get(referenceSymbol.toUpperCase()); + if (!row || row.expiresAt <= Date.now()) { + return undefined; + } + return row.priceUsd; +} // Repo-local inferred FX snapshot from scripts/lib/extraction_gap_closure.py. const REPO_FALLBACK_PRICE_USD: Record = { @@ -18,14 +57,19 @@ const REPO_FALLBACK_PRICE_USD: Record = { CHF: 1.2776572668112798, JPY: 0.006285683794888213, XAU: 5163.3401260328355, - ETH: 2490, + ETH: 1680, BTC: 90000, + LINK: 8, BNB: 610, POL: 0.78, AVAX: 48, CELO: 0.72, CRO: 0.14, XDAI: 1, + LIPMG: 1100, + LIBMG1: 4.25, + LIBMG2: 18.5, + LIBMG3: 0.12, }; function normalizeAddress(value: string): string { @@ -68,6 +112,11 @@ function resolveReferenceSymbol(spec: CanonicalTokenSpec): string | undefined { return 'XAU'; } + if (symbol === 'LIPMG') return 'LIPMG'; + if (symbol === 'LIBMG1') return 'LIBMG1'; + if (symbol === 'LIBMG2') return 'LIBMG2'; + if (symbol === 'LIBMG3') return 'LIBMG3'; + if (currencyCode) { return currencyCode; } @@ -161,6 +210,15 @@ export function resolveCanonicalPriceUsdForSpec(spec: CanonicalTokenSpec): Canon }; } + const livePrice = readLiveReferencePriceUsd(referenceSymbol); + if (livePrice !== undefined) { + return { + priceUsd: livePrice, + referenceSymbol, + source: 'on-chain', + }; + } + const fallback = REPO_FALLBACK_PRICE_USD[referenceSymbol]; if (fallback !== undefined) { return { @@ -181,7 +239,28 @@ export function resolveCanonicalPriceUsd(chainId: number, address: string): Cano if (!spec) { return { source: 'unresolved' }; } - return resolveCanonicalPriceUsdForSpec(spec); + const base = resolveCanonicalPriceUsdForSpec(spec); + if (chainId !== CHAIN_138 || base.referenceSymbol !== 'ETH') { + return base; + } + + const liveEth = readLiveReferencePriceUsd('ETH'); + if (liveEth === undefined) { + return base; + } + + if (base.source === 'env' && base.priceUsd !== undefined) { + if (ethUsdOracleDriftBps(base.priceUsd, liveEth) > readChain138EthOracleDriftBps()) { + return { priceUsd: liveEth, referenceSymbol: 'ETH', source: 'on-chain' }; + } + return base; + } + + if (base.source === 'repo-fallback' || base.source === 'unresolved' || base.priceUsd === undefined) { + return { priceUsd: liveEth, referenceSymbol: 'ETH', source: 'on-chain' }; + } + + return base; } export function getCanonicalPriceUsd(chainId: number, address: string): number | undefined { diff --git a/services/token-aggregation/src/services/chain138-onchain-reference-prices.ts b/services/token-aggregation/src/services/chain138-onchain-reference-prices.ts new file mode 100644 index 0000000..46d95af --- /dev/null +++ b/services/token-aggregation/src/services/chain138-onchain-reference-prices.ts @@ -0,0 +1,94 @@ +import { ethers } from 'ethers'; +import { primeLiveReferencePriceUsd } from './canonical-price-oracle'; + +const ROUND_DATA_ABI = [ + 'function latestRoundData() view returns (uint80 roundId,int256 answer,uint256 startedAt,uint256 updatedAt,uint80 answeredInRound)', +]; + +const DEFAULT_RPC = 'http://192.168.11.211:8545'; +const DEFAULT_AGGREGATOR = '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506'; +const DEFAULT_PROXY = '0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6'; + +interface CachedPrice { + priceUsd: number; + expiresAt: number; + source: 'aggregator' | 'proxy'; +} + +let cachedEthUsd: CachedPrice | undefined; + +function readAggregatorAddress(): string { + return ( + process.env.AGGREGATOR_ADDRESS?.trim() || + process.env.ORACLE_AGGREGATOR_ADDRESS?.trim() || + DEFAULT_AGGREGATOR + ).toLowerCase(); +} + +function readProxyAddress(): string { + return ( + process.env.ORACLE_PROXY_ADDRESS?.trim() || + process.env.ORACLE_PROXY?.trim() || + DEFAULT_PROXY + ).toLowerCase(); +} + +function readRpcUrl(): string { + return ( + process.env.RPC_URL_138?.trim() || + process.env.CHAIN138_RPC_URL?.trim() || + DEFAULT_RPC + ); +} + +async function readEthUsdFromContract(address: string, rpcUrl: string): Promise { + const provider = new ethers.JsonRpcProvider(rpcUrl); + const contract = new ethers.Contract(address, ROUND_DATA_ABI, provider); + const result = await contract.latestRoundData(); + const answer = Number(result.answer ?? result[1]); + if (!Number.isFinite(answer) || answer <= 0) { + return undefined; + } + return answer / 1e8; +} + +/** + * Live ETH/USD from Chain 138 on-chain feeds (aggregator preferred, proxy fallback). + */ +export async function getChain138EthUsdOnChain(): Promise<{ priceUsd: number; source: 'aggregator' | 'proxy' } | undefined> { + if (cachedEthUsd && cachedEthUsd.expiresAt > Date.now()) { + return { priceUsd: cachedEthUsd.priceUsd, source: cachedEthUsd.source }; + } + + const rpcUrl = readRpcUrl(); + const aggregator = readAggregatorAddress(); + const proxy = readProxyAddress(); + + for (const [address, source] of [[aggregator, 'aggregator'], [proxy, 'proxy']] as const) { + try { + const priceUsd = await readEthUsdFromContract(address, rpcUrl); + if (priceUsd != null && priceUsd > 0) { + cachedEthUsd = { priceUsd, expiresAt: Date.now() + 60_000, source }; + return { priceUsd, source }; + } + } catch { + // try next feed + } + } + + return undefined; +} + +export function clearChain138OnChainPriceCache(): void { + cachedEthUsd = undefined; +} + +export async function refreshChain138LiveReferencePrices(chainId: number): Promise { + if (chainId !== 138) { + return; + } + const eth = await getChain138EthUsdOnChain(); + if (eth) { + primeLiveReferencePriceUsd('ETH', eth.priceUsd); + } +} diff --git a/services/token-aggregation/src/services/chain138-pilot-venues.ts b/services/token-aggregation/src/services/chain138-pilot-venues.ts index a0252f1..97d8d11 100644 --- a/services/token-aggregation/src/services/chain138-pilot-venues.ts +++ b/services/token-aggregation/src/services/chain138-pilot-venues.ts @@ -18,7 +18,10 @@ const DEFAULT_BALANCER_WETH_USDT_POOL_ID = '0x877cd220759e8c94b82f55450c85d382ae 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 DEFAULT_WETH_USD_PRICE = (() => { + const fromEnv = Number(process.env.CHAIN138_CANONICAL_PRICE_USD_ETH || process.env.CANONICAL_PRICE_USD_ETH || ''); + return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 1732; +})(); const WETH = '0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'.toLowerCase(); const USDT = '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1'.toLowerCase(); diff --git a/services/token-aggregation/src/services/dodo-v3-pilot.ts b/services/token-aggregation/src/services/dodo-v3-pilot.ts index 7f07baa..6099446 100644 --- a/services/token-aggregation/src/services/dodo-v3-pilot.ts +++ b/services/token-aggregation/src/services/dodo-v3-pilot.ts @@ -55,7 +55,11 @@ function getPilotConfig(): DodoV3PilotConfig { 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, + wethUsdPrice: + normalizeNumber(process.env.CHAIN138_D3_PILOT_WETH_USD)?.valueOf() || + normalizeNumber(process.env.CHAIN138_CANONICAL_PRICE_USD_ETH)?.valueOf() || + normalizeNumber(process.env.CANONICAL_PRICE_USD_ETH)?.valueOf() || + 1732, liquidityOverrideUsd: normalizeNumber(process.env.CHAIN138_D3_PILOT_TOTAL_LIQUIDITY_USD), }; } diff --git a/services/token-aggregation/src/services/iso4217-fx-refresh.ts b/services/token-aggregation/src/services/iso4217-fx-refresh.ts new file mode 100644 index 0000000..c73f61b --- /dev/null +++ b/services/token-aggregation/src/services/iso4217-fx-refresh.ts @@ -0,0 +1,140 @@ +import axios from 'axios'; +import { logger } from '../utils/logger'; +import { primeLiveReferencePriceUsd } from './canonical-price-oracle'; + +const FRANKFURTER_CURRENCIES = ['EUR', 'GBP', 'AUD', 'CAD', 'CHF', 'JPY'] as const; +const COINGECKO_REFERENCE_IDS: Record = { + ETH: 'ethereum', + BTC: 'bitcoin', + LINK: 'chainlink', + XAU: 'pax-gold', +}; + +const COINGECKO_COMMODITY_IDS = { + platinum: 'platinum', + palladium: 'palladium', + copper: 'copper', + nickel: 'nickel', + cobalt: 'cobalt', +} as const; + +let lastRefreshAt = 0; +let refreshInFlight: Promise | null = null; +const DEFAULT_TTL_MS = 5 * 60 * 1000; + +function coingeckoBaseUrl(): string { + return process.env.COINGECKO_API_KEY + ? 'https://pro-api.coingecko.com/api/v3' + : 'https://api.coingecko.com/api/v3'; +} + +function coingeckoHeaders(): Record { + const key = process.env.COINGECKO_API_KEY?.trim(); + return key ? { 'x-cg-pro-api-key': key } : {}; +} + +async function fetchFrankfurterUsdPerUnit(currency: string): Promise { + try { + const response = await axios.get<{ rates?: { USD?: number } }>( + `https://api.frankfurter.app/latest?from=${encodeURIComponent(currency)}&to=USD`, + { timeout: 8000 }, + ); + const usd = response.data?.rates?.USD; + if (usd !== undefined && Number.isFinite(usd) && usd > 0) { + return usd; + } + } catch (error) { + logger.warn(`Frankfurter FX fetch failed for ${currency}:`, error); + } + return undefined; +} + +async function fetchCoingeckoUsd(coinId: string): Promise { + try { + const response = await axios.get>( + `${coingeckoBaseUrl()}/simple/price`, + { + timeout: 8000, + headers: coingeckoHeaders(), + params: { + ids: coinId, + vs_currencies: 'usd', + }, + }, + ); + const usd = response.data?.[coinId]?.usd; + if (usd !== undefined && Number.isFinite(usd) && usd > 0) { + return usd; + } + } catch (error) { + logger.warn(`CoinGecko FX fetch failed for ${coinId}:`, error); + } + return undefined; +} + +/** Refresh live ISO-4217 USD reference rates used for c* / cW* valuation. */ +export async function refreshIso4217FxRates(ttlMs = DEFAULT_TTL_MS): Promise { + primeLiveReferencePriceUsd('USD', 1, ttlMs); + + const commodityEntries = await Promise.all( + Object.entries(COINGECKO_COMMODITY_IDS).map(async ([key, coinId]) => { + const priceUsd = await fetchCoingeckoUsd(coinId); + return [key, priceUsd] as const; + }), + ); + const commodities = Object.fromEntries(commodityEntries.filter(([, price]) => price != null)) as Partial< + Record + >; + + await Promise.all([ + ...FRANKFURTER_CURRENCIES.map(async (currency) => { + const priceUsd = await fetchFrankfurterUsdPerUnit(currency); + if (priceUsd !== undefined) { + primeLiveReferencePriceUsd(currency, priceUsd, ttlMs); + } + }), + ...Object.entries(COINGECKO_REFERENCE_IDS).map(async ([symbol, coinId]) => { + const priceUsd = await fetchCoingeckoUsd(coinId); + if (priceUsd !== undefined) { + primeLiveReferencePriceUsd(symbol, priceUsd, ttlMs); + } + }), + ]); + + if (commodities.platinum != null && commodities.palladium != null) { + primeLiveReferencePriceUsd('LIPMG', (commodities.platinum + commodities.palladium) / 2, ttlMs); + } + if (commodities.copper != null) { + primeLiveReferencePriceUsd('LIBMG1', commodities.copper, ttlMs); + } + if (commodities.nickel != null && commodities.cobalt != null) { + primeLiveReferencePriceUsd('LIBMG2', commodities.nickel * 0.8 + commodities.cobalt * 0.2, ttlMs); + } else if (commodities.nickel != null) { + primeLiveReferencePriceUsd('LIBMG2', commodities.nickel, ttlMs); + } + + lastRefreshAt = Date.now(); +} + +export async function ensureIso4217FxPrimed(ttlMs = DEFAULT_TTL_MS): Promise { + if (Date.now() - lastRefreshAt < ttlMs) { + return; + } + if (!refreshInFlight) { + refreshInFlight = refreshIso4217FxRates(ttlMs).finally(() => { + refreshInFlight = null; + }); + } + await refreshInFlight; +} + +export function startIso4217FxScheduler(intervalMs = DEFAULT_TTL_MS): NodeJS.Timeout { + void refreshIso4217FxRates(intervalMs); + return setInterval(() => { + void refreshIso4217FxRates(intervalMs); + }, intervalMs); +} + +export function getIso4217FxLastRefreshAt(): number { + return lastRefreshAt; +} diff --git a/services/token-aggregation/src/services/iso4217-spot-price.ts b/services/token-aggregation/src/services/iso4217-spot-price.ts new file mode 100644 index 0000000..4165b72 --- /dev/null +++ b/services/token-aggregation/src/services/iso4217-spot-price.ts @@ -0,0 +1,118 @@ +import { getCanonicalTokenByAddress } from '../config/canonical-tokens'; +import { MarketDataRepository } from '../database/repositories/market-data-repo'; +import { getChain138EthUsdOnChain } from './chain138-onchain-reference-prices'; +import { resolveCanonicalPriceUsd } from './canonical-price-oracle'; +import { ensureIso4217FxPrimed } from './iso4217-fx-refresh'; + +const marketDataRepo = new MarketDataRepository(); + +export type Iso4217SpotPrice = { + priceUsd?: number; + symbol?: string; + currencyCode?: string; + referenceSymbol?: string; + source: 'market-data' | 'iso4217-oracle' | 'native-oracle' | 'unresolved'; +}; + +const NATIVE_ETH_SENTINELS = new Set([ + '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + '0x0000000000000000000000000000000000000000', +]); + +const CHAIN138_ETH_USD_ORACLE_PROXY = '0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6'; +const CHAIN138_ETH_USD_ORACLE_AGGREGATOR = '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506'; +const CHAIN138_WETH9 = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; + +function isChain138EthUsdPegToken(chainId: number, normalized: string, symbol?: string): boolean { + if (chainId !== 138) return false; + if (normalized === CHAIN138_WETH9) return true; + const sym = symbol?.toUpperCase(); + return sym === 'WETH' || sym === 'WETH9' || sym === 'WETH10' || sym === 'CETH' || sym === 'CETHL2'; +} + +async function resolveEthUsdFeedPrice(chainId: number, normalized: string, symbol?: string): Promise { + if (chainId !== 138) return undefined; + const isFeed = + normalized === CHAIN138_ETH_USD_ORACLE_PROXY || + normalized === CHAIN138_ETH_USD_ORACLE_AGGREGATOR || + symbol?.toUpperCase() === 'ETH-USD'; + if (!isFeed) return undefined; + + const native = await getChain138EthUsdOnChain(); + if (native?.priceUsd && native.priceUsd > 0) { + return { + priceUsd: native.priceUsd, + symbol: 'ETH-USD', + currencyCode: 'USD', + referenceSymbol: 'ETH', + source: 'native-oracle', + }; + } + return undefined; +} + +export async function resolveIso4217SpotPrice(chainId: number, address: string): Promise { + await ensureIso4217FxPrimed(); + + const normalized = address.trim().toLowerCase(); + if (NATIVE_ETH_SENTINELS.has(normalized)) { + const native = chainId === 138 ? await getChain138EthUsdOnChain() : undefined; + if (native?.priceUsd && native.priceUsd > 0) { + return { + priceUsd: native.priceUsd, + symbol: 'ETH', + currencyCode: 'ETH', + referenceSymbol: 'ETH', + source: 'native-oracle', + }; + } + const ethFallback = resolveCanonicalPriceUsd(chainId, normalized); + return { + priceUsd: ethFallback.priceUsd, + symbol: 'ETH', + currencyCode: 'ETH', + referenceSymbol: 'ETH', + source: ethFallback.priceUsd ? 'iso4217-oracle' : 'unresolved', + }; + } + + const spec = getCanonicalTokenByAddress(chainId, normalized); + const feedPrice = await resolveEthUsdFeedPrice(chainId, normalized, spec?.symbol); + if (feedPrice) { + return feedPrice; + } + + if (isChain138EthUsdPegToken(chainId, normalized, spec?.symbol)) { + const native = await getChain138EthUsdOnChain(); + if (native?.priceUsd && native.priceUsd > 0) { + return { + priceUsd: native.priceUsd, + symbol: spec?.symbol ?? 'WETH', + currencyCode: spec?.currencyCode ?? 'ETH', + referenceSymbol: 'ETH', + source: 'native-oracle', + }; + } + } + + const marketData = await marketDataRepo.getMarketData(chainId, normalized); + const canonical = resolveCanonicalPriceUsd(chainId, normalized); + + const priceUsd = + marketData?.priceUsd != null && marketData.priceUsd > 0 + ? marketData.priceUsd + : canonical.priceUsd; + + return { + priceUsd, + symbol: spec?.symbol, + currencyCode: spec?.currencyCode, + referenceSymbol: canonical.referenceSymbol, + source: + marketData?.priceUsd != null && marketData.priceUsd > 0 + ? 'market-data' + : canonical.priceUsd != null + ? 'iso4217-oracle' + : 'unresolved', + }; +} diff --git a/services/token-aggregation/src/services/iso4217-token-metadata.test.ts b/services/token-aggregation/src/services/iso4217-token-metadata.test.ts new file mode 100644 index 0000000..fe244c0 --- /dev/null +++ b/services/token-aggregation/src/services/iso4217-token-metadata.test.ts @@ -0,0 +1,21 @@ +import { buildIso4217TokenListExtensions, resolveCoingeckoPriceProxyId } from './iso4217-token-metadata'; + +describe('iso4217-token-metadata', () => { + it('maps cUSDC to USD Cash proxy and cUSDT to USD Treasury proxy', () => { + expect(resolveCoingeckoPriceProxyId({ symbol: 'cUSDC', currencyCode: 'USD' })).toBe('usd-coin'); + expect(resolveCoingeckoPriceProxyId({ symbol: 'cUSDT', currencyCode: 'USD' })).toBe('tether'); + }); + + it('builds ISO-4217 extensions for compliant fiat tokens', () => { + const extensions = buildIso4217TokenListExtensions({ + symbol: 'cAUDC', + currencyCode: 'AUD', + type: 'base', + name: 'Australian Dollar (Compliant)', + }); + + expect(extensions.iso4217).toBe('AUD'); + expect(extensions.gruAssetClass).toBe('C'); + expect(extensions.gruLayer).toBe('c'); + }); +}); diff --git a/services/token-aggregation/src/services/iso4217-token-metadata.ts b/services/token-aggregation/src/services/iso4217-token-metadata.ts new file mode 100644 index 0000000..a7f4214 --- /dev/null +++ b/services/token-aggregation/src/services/iso4217-token-metadata.ts @@ -0,0 +1,62 @@ +import { type CanonicalTokenSpec } from '../config/canonical-tokens'; + +/** CoinGecko IDs used only as USD valuation proxies for ISO-4217 c* eMoney (not issuer identity). */ +/** USD valuation proxies for CoinGecko / MetaMask submissions — not issuer identity. */ +const ISO4217_COINGECKO_PROXY: Record = { + USD: 'usd-coin', + EUR: 'euro-coin', + XAU: 'pax-gold', + ETH: 'ethereum', + BTC: 'bitcoin', + LINK: 'chainlink', +}; + +const USD_TREASURY_SYMBOLS = new Set(['CUSDT', 'CEURT', 'CGBPT', 'CXAUT']); + +export function resolveIso4217AssetClassSuffix(symbol: string): 'C' | 'T' | undefined { + const upper = symbol.trim().toUpperCase(); + if (upper.length < 2) return undefined; + const suffix = upper.at(-1); + if (suffix === 'C' || suffix === 'T') return suffix; + return undefined; +} + +export function resolveCoingeckoPriceProxyId(spec: Pick): string | undefined { + const currency = String(spec.currencyCode || '').trim().toUpperCase(); + if (currency && ISO4217_COINGECKO_PROXY[currency]) { + const upper = spec.symbol.trim().toUpperCase(); + if (currency === 'USD' && USD_TREASURY_SYMBOLS.has(upper)) { + return 'tether'; + } + return ISO4217_COINGECKO_PROXY[currency]; + } + + if (spec.symbol.trim().toUpperCase() === 'LINK') return ISO4217_COINGECKO_PROXY.LINK; + if (spec.symbol.trim().toUpperCase().includes('BTC')) return ISO4217_COINGECKO_PROXY.BTC; + if (spec.symbol.trim().toUpperCase().includes('ETH')) return ISO4217_COINGECKO_PROXY.ETH; + + return undefined; +} + +export function buildIso4217TokenListExtensions( + spec: Pick, +): Record { + const currencyCode = String(spec.currencyCode || '').trim().toUpperCase() || undefined; + const assetClass = resolveIso4217AssetClassSuffix(spec.symbol); + const coingeckoPriceProxyId = resolveCoingeckoPriceProxyId(spec); + + return { + gruLayer: spec.type === 'w' ? 'cW' : 'c', + iso4217: currencyCode, + gruAssetClass: assetClass, + gruAssetClassLabel: + assetClass === 'C' + ? 'Cash (Tokenized M1)' + : assetClass === 'T' + ? 'Treasury (Tokenized GILT or equivalent security)' + : undefined, + coingeckoPriceProxyId, + pricingNote: + 'Native GRU Compliant eMoney — CoinGecko proxy is for USD display only, not third-party issuer identity.', + }; +} diff --git a/services/token-aggregation/src/services/omnl-api-catalog.ts b/services/token-aggregation/src/services/omnl-api-catalog.ts index 64ef05f..8d49d75 100644 --- a/services/token-aggregation/src/services/omnl-api-catalog.ts +++ b/services/token-aggregation/src/services/omnl-api-catalog.ts @@ -15,7 +15,7 @@ export function getOmnlApiCatalog(): { return { service: 'HYBX OMNL', basePath: '/api/v1', - version: '1.4.0', + version: '1.5.0', endpoints: [ { method: 'GET', path: '/omnl/openapi.json', description: 'OpenAPI 3.0 JSON (Swagger-compatible)', auth: 'none' }, { method: 'GET', path: '/omnl/catalog', description: 'This catalog (machine-readable)', auth: 'none' }, @@ -23,6 +23,8 @@ export function getOmnlApiCatalog(): { { method: 'GET', path: '/omnl/reconcile-anchor', description: 'SHA-256 of canonical IPSAS registry + journal matrix JSON', auth: 'OMNL_API_KEY when OMNL_REQUIRE_API_KEY=1' }, { method: 'GET', path: '/omnl/reconcile/triple-state', description: 'Fineract + on-chain + custodian reconcile', query: ['lineId'], auth: 'OMNL_API_KEY' }, { method: 'GET', path: '/omnl/disclosures/full', description: 'IFRS 7/9/13 + IAS 21/37 extracts', query: ['lineId'], auth: 'OMNL_API_KEY' }, + { method: 'GET', path: '/omnl/entities', description: 'Regulated entity registry (Fineract master data)', auth: 'OMNL_API_KEY' }, + { method: 'GET', path: '/omnl/entities/:clientNumber', description: 'Single entity by client number', auth: 'OMNL_API_KEY' }, { method: 'POST', path: '/omnl/iso20022/messages', description: 'Store ISO 20022 message (10y retention)', auth: 'OMNL_API_KEY' }, { method: 'GET', path: '/omnl/iso20022/messages', description: 'List stored ISO messages', auth: 'OMNL_API_KEY' }, { method: 'GET', path: '/omnl/roles/matrix', description: 'Fineract SoD roles matrix JSON', auth: 'OMNL_API_KEY' }, diff --git a/services/token-aggregation/src/services/omnl-entity-registry.ts b/services/token-aggregation/src/services/omnl-entity-registry.ts new file mode 100644 index 0000000..52cd6a0 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-entity-registry.ts @@ -0,0 +1,53 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export type OmnlEntitySummary = { + clientNumber: number; + accountNo: string; + entityName: string; + lei: string; + source: 'fineract_master_data'; +}; + +function proxmoxRoot(): string { + return ( + process.env.PROXMOX_ROOT?.trim() || + process.env.PHOENIX_REPO_ROOT?.trim() || + resolve(process.cwd(), '../../../..') + ); +} + +export function listOmnlEntities(): { + source: string; + count: number; + entities: OmnlEntitySummary[]; +} { + const path = resolve( + proxmoxRoot(), + 'docs/04-configuration/mifos-omnl-central-bank/OMNL_ENTITY_MASTER_DATA.json' + ); + if (!existsSync(path)) { + return { source: path, count: 0, entities: [] }; + } + const raw = JSON.parse(readFileSync(path, 'utf8')) as { + entities?: Array<{ + clientNumber: number; + accountNo: string; + entityName: string; + lei?: string; + }>; + }; + const entities = (raw.entities ?? []).map((e) => ({ + clientNumber: e.clientNumber, + accountNo: e.accountNo, + entityName: e.entityName, + lei: e.lei ?? '', + source: 'fineract_master_data' as const, + })); + return { source: path, count: entities.length, entities }; +} + +export function getOmnlEntity(clientNumber: number): OmnlEntitySummary | null { + const { entities } = listOmnlEntities(); + return entities.find((e) => e.clientNumber === clientNumber) ?? null; +} diff --git a/services/token-aggregation/src/services/omnl-iso20022-validate.ts b/services/token-aggregation/src/services/omnl-iso20022-validate.ts new file mode 100644 index 0000000..593619b --- /dev/null +++ b/services/token-aggregation/src/services/omnl-iso20022-validate.ts @@ -0,0 +1,10 @@ +import { PassthroughSchemaValidator } from '@dbis/integration-foundation'; + +const validator = new PassthroughSchemaValidator(); + +export async function validateIso20022Payload( + messageType: string, + payload: string +): Promise<{ valid: boolean; errors: string[] }> { + return validator.validate(payload, messageType); +} diff --git a/services/token-aggregation/src/services/token-visible-liquidity.test.ts b/services/token-aggregation/src/services/token-visible-liquidity.test.ts new file mode 100644 index 0000000..d9bf57a --- /dev/null +++ b/services/token-aggregation/src/services/token-visible-liquidity.test.ts @@ -0,0 +1,25 @@ +import { MAX_SINGLE_POOL_TVL_USD, sanePoolTvlUsd } from './token-visible-liquidity'; + +describe('sanePoolTvlUsd', () => { + it('keeps canonical Chain 138 live pool TVL even when very large', () => { + expect( + sanePoolTvlUsd(138, '0x9e89bae009adf128782e19e8341996c596ac40dc', 33_200_672), + ).toBe(33_200_672); + }); + + it('drops mis-normalized single-pool TVL above the sanity cap', () => { + expect( + sanePoolTvlUsd(138, '0xaae68830a55767722618e869882c6ed064cc1eb2', 51_558_997_888), + ).toBe(0); + }); + + it('allows reasonable non-canonical pool TVL', () => { + expect( + sanePoolTvlUsd(138, '0xb53a0508940b1ff90f1aad4f6cb50a7012fe5593', 10_104_786), + ).toBe(10_104_786); + }); + + it('exports a 500M sanity cap', () => { + expect(MAX_SINGLE_POOL_TVL_USD).toBe(500_000_000); + }); +}); diff --git a/services/token-aggregation/src/services/token-visible-liquidity.ts b/services/token-aggregation/src/services/token-visible-liquidity.ts new file mode 100644 index 0000000..5783e2a --- /dev/null +++ b/services/token-aggregation/src/services/token-visible-liquidity.ts @@ -0,0 +1,213 @@ +import { formatUnits } from 'ethers'; +import { existsSync, readFileSync } from 'fs'; +import path from 'path'; +import { CHAIN138_CANONICAL_LIVE_POOLS, isChain138CanonicalLivePool } from '../config/chain138-live-dodo-pools'; +import { filterPoolsForExposure } from '../config/gru-transport'; +import { getGruV2DeploymentPoolRows } from '../config/gru-v2-deployment-pools'; +import { resolveCanonicalQuoteAddress } from '../config/canonical-tokens'; +import { PoolRepository, LiquidityPool } from '../database/repositories/pool-repo'; +import { getLiveDodoPools } from './live-dodo-fallback'; +import { pmmVaultReserveFromChain, resolvePmmQuoteRpcUrl } from './pmm-onchain-quote'; +import { logger } from '../utils/logger'; + +const poolRepo = new PoolRepository(); +const MAX_SINGLE_POOL_TVL_USD = 500_000_000; +const LIVE_UNISWAP_CATALOG_REL = 'config/live-uniswap-v2-pool-catalog.json'; + +interface LiveUniswapCatalogRow { + chainId: number; + poolAddress: string; + baseAddress: string; + quoteAddress: string; + totalLiquidityUsd?: number; +} + +function loadLiveUniswapCatalogRows(): LiveUniswapCatalogRow[] { + const configured = process.env.TOKEN_AGGREGATION_LIVE_UNISWAP_V2_POOL_CATALOG_JSON?.trim(); + const roots = [ + configured, + path.join(process.cwd(), LIVE_UNISWAP_CATALOG_REL), + path.join(process.cwd(), 'smom-dbis-138', 'services', 'token-aggregation', LIVE_UNISWAP_CATALOG_REL), + ].filter(Boolean) as string[]; + + for (const candidate of roots) { + if (!existsSync(candidate)) continue; + try { + const parsed = JSON.parse(readFileSync(candidate, 'utf8')) as { pools?: LiveUniswapCatalogRow[] }; + return (parsed.pools ?? []).filter( + (row) => + Number.isFinite(row.chainId) && + row.poolAddress?.startsWith('0x') && + row.baseAddress?.startsWith('0x') && + row.quoteAddress?.startsWith('0x'), + ); + } catch (error) { + logger.warn('token-visible-liquidity: unable to parse UniV2 catalog', { candidate, error }); + } + } + return []; +} + +function matchesTokenAddress( + pool: { token0Address: string; token1Address: string }, + lookupAddress: string, + normalizedAddress: string, +): boolean { + return ( + pool.token0Address === lookupAddress || + pool.token1Address === lookupAddress || + pool.token0Address === normalizedAddress || + pool.token1Address === normalizedAddress + ); +} + +function sanePoolTvlUsd(chainId: number, poolAddress: string, totalLiquidityUsd?: number): number { + const tvl = Number(totalLiquidityUsd); + if (!Number.isFinite(tvl) || tvl <= 0) return 0; + if (chainId === 138 && isChain138CanonicalLivePool(poolAddress)) return tvl; + if (tvl > MAX_SINGLE_POOL_TVL_USD) return 0; + return tvl; +} + +function mergePoolsByAddress(existing: Map, pools: LiquidityPool[]): void { + for (const pool of pools) { + const key = pool.poolAddress.toLowerCase(); + const prior = existing.get(key); + const nextTvl = sanePoolTvlUsd(pool.chainId, pool.poolAddress, pool.totalLiquidityUsd); + const priorTvl = prior ? sanePoolTvlUsd(prior.chainId, prior.poolAddress, prior.totalLiquidityUsd) : 0; + if (!prior || nextTvl > priorTvl) { + existing.set(key, { ...pool, totalLiquidityUsd: nextTvl }); + } + } +} + +async function collectVisiblePools(chainId: number, address: string): Promise { + const normalized = address.toLowerCase(); + const resolution = resolveCanonicalQuoteAddress(chainId, normalized); + const merged = new Map(); + + try { + const dbPools = filterPoolsForExposure( + chainId, + await poolRepo.getPoolsByToken(chainId, resolution.lookupAddress), + ).filter((pool) => matchesTokenAddress(pool, resolution.lookupAddress, normalized)); + mergePoolsByAddress(merged, dbPools); + } catch (error) { + logger.warn('token-visible-liquidity: DB pool lookup failed', { + chainId, + address: resolution.lookupAddress, + error, + }); + } + + try { + const livePools = filterPoolsForExposure(chainId, await getLiveDodoPools(chainId)).filter((pool) => + matchesTokenAddress(pool, resolution.lookupAddress, normalized), + ); + mergePoolsByAddress(merged, livePools); + } catch (error) { + logger.warn('token-visible-liquidity: live DODO lookup failed', { chainId, address: normalized, error }); + } + + if (chainId === 138 && merged.size === 0) { + const canonicalOnly = filterPoolsForExposure(chainId, await getLiveDodoPools(chainId)).filter( + (pool) => + CHAIN138_CANONICAL_LIVE_POOLS.some((live) => live.toLowerCase() === pool.poolAddress.toLowerCase()) && + matchesTokenAddress(pool, resolution.lookupAddress, normalized), + ); + mergePoolsByAddress(merged, canonicalOnly); + } + + return [...merged.values()]; +} + +function sumLiveUniswapCatalogLiquidityUsd(chainId: number, tokenAddress: string): number { + const token = tokenAddress.toLowerCase(); + return loadLiveUniswapCatalogRows() + .filter( + (row) => + row.chainId === chainId && + (row.baseAddress.toLowerCase() === token || row.quoteAddress.toLowerCase() === token), + ) + .reduce((sum, row) => { + const tvl = sanePoolTvlUsd(chainId, row.poolAddress, row.totalLiquidityUsd); + return sum + tvl; + }, 0); +} + +function sumPoolTvl(chainId: number, pools: LiquidityPool[]): number { + return pools.reduce( + (sum, pool) => sum + sanePoolTvlUsd(chainId, pool.poolAddress, pool.totalLiquidityUsd), + 0, + ); +} + +function fallbackPoolUsdPrice(symbol?: string): number { + const normalized = String(symbol || '').toUpperCase(); + if (normalized.includes('XAU')) return Number(process.env.TOKEN_AGGREGATION_XAU_USD || '0') || 0; + if (normalized === 'WETH' || normalized === 'ETH') return Number(process.env.TOKEN_AGGREGATION_ETH_USD || '0') || 0; + return 1; +} + +function fallbackPoolDecimals(symbol?: string): number { + const normalized = String(symbol || '').toUpperCase(); + if (normalized === 'WETH' || normalized === 'ETH' || normalized === 'LINK') return 18; + return 6; +} + +async function sumGruV2DeploymentLiquidityUsd(chainId: number, tokenAddress: string): Promise { + if (chainId !== 138) return 0; + const token = tokenAddress.toLowerCase(); + const rpcUrl = resolvePmmQuoteRpcUrl(); + if (!rpcUrl) return 0; + + const rows = getGruV2DeploymentPoolRows().filter( + (pool) => + pool.chainId === chainId && + (pool.baseAddress.toLowerCase() === token || pool.quoteAddress.toLowerCase() === token), + ); + + let total = 0; + for (const row of rows) { + const reserves = await pmmVaultReserveFromChain({ rpcUrl, poolAddress: row.poolAddress }); + if (!reserves) continue; + const baseUnits = Number(formatUnits(reserves.baseReserveRaw, fallbackPoolDecimals(row.baseSymbol))); + const quoteUnits = Number(formatUnits(reserves.quoteReserveRaw, fallbackPoolDecimals(row.quoteSymbol))); + if (!Number.isFinite(baseUnits) || !Number.isFinite(quoteUnits)) continue; + const reserveUsd = + baseUnits * fallbackPoolUsdPrice(row.baseSymbol) + quoteUnits * fallbackPoolUsdPrice(row.quoteSymbol); + const saneReserveUsd = sanePoolTvlUsd(chainId, row.poolAddress, reserveUsd); + if (Number.isFinite(saneReserveUsd) && saneReserveUsd > 0) { + total += saneReserveUsd; + } + } + return total; +} + +/** Best-effort visible on-chain / indexed liquidity for wallet and market-batch surfaces. */ +export async function resolveVisibleTokenLiquidityUsd(chainId: number, address: string): Promise { + const normalized = address.toLowerCase(); + const pools = await collectVisiblePools(chainId, normalized); + + if (chainId === 138) { + const canonicalLiveTvl = pools + .filter((pool) => isChain138CanonicalLivePool(pool.poolAddress)) + .reduce( + (sum, pool) => sum + sanePoolTvlUsd(chainId, pool.poolAddress, pool.totalLiquidityUsd), + 0, + ); + const gruTvl = await sumGruV2DeploymentLiquidityUsd(chainId, normalized); + const catalogTvl = sumLiveUniswapCatalogLiquidityUsd(chainId, normalized); + const canonicalFirst = Math.max(canonicalLiveTvl, gruTvl, catalogTvl); + if (canonicalFirst > 0) { + return canonicalFirst; + } + } + + const indexedTvl = sumPoolTvl(chainId, pools); + const catalogTvl = sumLiveUniswapCatalogLiquidityUsd(chainId, normalized); + const gruTvl = await sumGruV2DeploymentLiquidityUsd(chainId, normalized); + return Math.max(indexedTvl, catalogTvl, gruTvl); +} + +export { sanePoolTvlUsd, MAX_SINGLE_POOL_TVL_USD }; diff --git a/services/token-aggregation/src/services/valuation-precedence.test.ts b/services/token-aggregation/src/services/valuation-precedence.test.ts index 677a364..200d999 100644 --- a/services/token-aggregation/src/services/valuation-precedence.test.ts +++ b/services/token-aggregation/src/services/valuation-precedence.test.ts @@ -4,6 +4,9 @@ import { resolveUsdValuation, mergeMarketWithValuation, } from './valuation-precedence'; +import { primeLiveReferencePriceUsd } from './canonical-price-oracle'; + +const CHAIN138_WETH = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; /** Bridged `cW*` on Ethereum; on Chain 138 the native form is `cUSDC` (`c*`), not `cW*`. */ const ETH_MAINNET_CWUSDC = '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a'; @@ -185,13 +188,14 @@ describe('valuation-precedence', () => { it('skips fresh indexer when it only mirrors repo-fallback FX and CoinGecko is available', () => { process.env.TOKEN_AGG_PRICE_INDEXER_MAX_AGE_SECONDS = '3600'; const lu = new Date(); + const cxauc = '0x290e52a8819a4fbD0714E517225429aA2B70EC6b'; const v = resolveUsdValuation({ chainId: 138, - normalizedAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + normalizedAddress: cxauc, indexer: { chainId: 138, - tokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', - priceUsd: 2490, + tokenAddress: cxauc, + priceUsd: 5163.3401260328355, volume24h: 0, volume7d: 0, volume30d: 0, @@ -207,6 +211,35 @@ describe('valuation-precedence', () => { expect(v.stale).toBe(false); }); + it('prefers live Chain 138 native oracle over stale PMM indexer when ETH drifts', () => { + process.env.TOKEN_AGG_PRICE_INDEXER_MAX_AGE_SECONDS = '3600'; + process.env.CHAIN138_CANONICAL_PRICE_USD_ETH = '2100'; + primeLiveReferencePriceUsd('ETH', 1772); + + const lu = new Date(); + const v = resolveUsdValuation({ + chainId: 138, + normalizedAddress: CHAIN138_WETH, + indexer: { + chainId: 138, + tokenAddress: CHAIN138_WETH, + priceUsd: 2100, + volume24h: 0, + volume7d: 0, + volume30d: 0, + liquidityUsd: 10_000_000, + holdersCount: 0, + transfers24h: 0, + lastUpdated: lu, + }, + coingecko: { priceUsd: 1760, lastUpdated: new Date() }, + }); + + expect(v.sourceLayer).toBe('native_oracle'); + expect(v.priceUsd).toBe(1772); + expect(v.precedenceRank).toBe(0); + }); + it('mergeMarketWithValuation applies priced layer onto indexer row', () => { const pricing = resolveUsdValuation({ chainId: 138, diff --git a/services/token-aggregation/src/services/valuation-precedence.ts b/services/token-aggregation/src/services/valuation-precedence.ts index 9c3d89f..9620104 100644 --- a/services/token-aggregation/src/services/valuation-precedence.ts +++ b/services/token-aggregation/src/services/valuation-precedence.ts @@ -2,7 +2,12 @@ import type { MarketData } from '../adapters/base-adapter'; import type { TokenMarketData } from '../database/repositories/token-repo'; import { getChainConfig } from '../config/chains'; import { getCanonicalTokenByAddress } from '../config/canonical-tokens'; -import { resolveCanonicalPriceUsd, type CanonicalPriceResolution } from './canonical-price-oracle'; +import { + ethUsdOracleDriftBps, + readChain138EthOracleDriftBps, + resolveCanonicalPriceUsd, + type CanonicalPriceResolution, +} from './canonical-price-oracle'; const CHAIN_138 = 138; @@ -29,6 +34,7 @@ export function isGruV2CwMeshEdgeToken(chainId: number, normalizedAddress: strin } export type PriceSourceLayer = + | 'native_oracle' | 'indexer_market' | 'external_coingecko' | 'external_coinmarketcap' @@ -60,7 +66,7 @@ export interface TokenPricing { ageSeconds?: number; indexerLastUpdated?: string; referenceSymbol?: string; - canonicalResolutionSource?: 'env' | 'repo-fallback' | 'unresolved'; + canonicalResolutionSource?: 'env' | 'on-chain' | 'repo-fallback' | 'unresolved'; /** Bridged `cW*` on an edge chain used DexScreener-priority ordering (native `c*` on 138 never sets this). */ gruV2CwEdgeDexPriority?: boolean; } @@ -129,10 +135,34 @@ export function resolveUsdValuation(input: ValuationInput): TokenPricing { const out: Candidate[] = []; + const onChainEthUsd = + chainId === CHAIN_138 && + referenceSymbol === 'ETH' && + canonical.source === 'on-chain' && + canonical.priceUsd !== undefined + ? canonical.priceUsd + : undefined; + + if (onChainEthUsd !== undefined) { + out.push({ + layer: 'native_oracle', + rank: 0, + priceUsd: onChainEthUsd, + stale: false, + asOf: new Date(), + }); + } + if (indexer?.priceUsd !== undefined && indexer.priceUsd !== null) { const lu = indexer.lastUpdated instanceof Date ? indexer.lastUpdated : new Date(indexer.lastUpdated); - const stale = + let stale = ageMsOf(lu) > maxAgeSeconds * 1000 || indexerMatchesRepoFallbackSnapshot(indexer, canonical); + if ( + onChainEthUsd !== undefined && + ethUsdOracleDriftBps(indexer.priceUsd, onChainEthUsd) > readChain138EthOracleDriftBps() + ) { + stale = true; + } out.push({ layer: 'indexer_market', rank: 1, diff --git a/test/bridge/CWMultiTokenBridge.t.sol b/test/bridge/CWMultiTokenBridge.t.sol index ebf346b..5df4b31 100644 --- a/test/bridge/CWMultiTokenBridge.t.sol +++ b/test/bridge/CWMultiTokenBridge.t.sol @@ -357,6 +357,33 @@ contract CWMultiTokenBridgeTest is Test { assertEq(keccak256(receiveBridge.lastData()), keccak256(message.data)); } + function testRelayRouterDeliversToCWMultiTokenBridgeL2() public { + CCIPRelayRouter relayRouter = new CCIPRelayRouter(); + relayRouter.authorizeBridge(address(l2Bridge)); + relayRouter.grantRelayerRole(address(this)); + + l2Bridge.setReceiveRouter(address(relayRouter)); + + uint256 amount = 50e6; + bytes memory outboundData = abi.encode(address(canonical), user, amount); + bytes32 messageId = keccak256("relay-router-l2"); + + LegacyRouterClient.TokenAmount[] memory noTokens = new LegacyRouterClient.TokenAmount[](0); + LegacyRouterClient.Any2EVMMessage memory message = LegacyRouterClient.Any2EVMMessage({ + messageId: messageId, + sourceChainSelector: CHAIN138_SELECTOR, + sender: abi.encode(address(l1Bridge)), + data: outboundData, + tokenAmounts: noTokens + }); + + relayRouter.relayMessage(address(l2Bridge), message); + + assertTrue(l2Bridge.processed(messageId)); + assertEq(wrapped.balanceOf(user), amount); + assertEq(l2Bridge.mintedTotal(address(wrapped)), amount); + } + function _l1Message( bytes32 messageId, uint64 sourceChainSelector, diff --git a/test/reserve/ReserveConversionPriceAdapter.t.sol b/test/reserve/ReserveConversionPriceAdapter.t.sol new file mode 100644 index 0000000..225f7ea --- /dev/null +++ b/test/reserve/ReserveConversionPriceAdapter.t.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ReserveSystem} from "../../contracts/reserve/ReserveSystem.sol"; +import {ReserveConversionPriceAdapter} from "../../contracts/reserve/ReserveConversionPriceAdapter.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockERC20Adapter is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) {} +} + +contract ReserveConversionPriceAdapterTest is Test { + ReserveSystem public reserveSystem; + ReserveConversionPriceAdapter public adapter; + MockERC20Adapter public weth; + MockERC20Adapter public usdc; + + address public admin = address(0x1); + address public priceFeedOperator = address(0x3); + + function setUp() public { + reserveSystem = new ReserveSystem(admin); + adapter = new ReserveConversionPriceAdapter(address(reserveSystem)); + weth = new MockERC20Adapter("WETH", "WETH"); + usdc = new MockERC20Adapter("USDC", "USDC"); + + vm.startPrank(admin); + reserveSystem.grantRole(reserveSystem.PRICE_FEED_ROLE(), priceFeedOperator); + reserveSystem.addSupportedAsset(address(weth), true); + reserveSystem.addSupportedAsset(address(usdc), true); + vm.stopPrank(); + + vm.startPrank(priceFeedOperator); + reserveSystem.updatePriceFeed(address(weth), 1732e18, block.timestamp); + reserveSystem.updatePriceFeed(address(usdc), 1e18, block.timestamp); + vm.stopPrank(); + } + + function test_adapter_getConversionPrice_quotePerBase() public view { + uint256 conv = adapter.getConversionPrice(address(weth), address(usdc)); + assertEq(conv, 1732e18); + } + + function test_adapter_getPrice_delegates() public view { + (uint256 price,) = adapter.getPrice(address(weth)); + assertEq(price, 1732e18); + } +} diff --git a/test/reserve/ReserveSystemTest.t.sol b/test/reserve/ReserveSystemTest.t.sol index 564d50d..c0553a9 100644 --- a/test/reserve/ReserveSystemTest.t.sol +++ b/test/reserve/ReserveSystemTest.t.sol @@ -122,8 +122,8 @@ contract ReserveSystemTest is Test { ); // asset1 price: 1000, asset2 price: 2000 - // 100 * 1e18 * 2000 / 1000 = 200 * 1e18 - assertEq(targetAmount, 200 * 1e18); + // 100 * 1000 / 2000 = 50 asset2 (equal USD value) + assertEq(targetAmount, 50 * 1e18); assertGt(fees, 0); assertEq(path.length, 2); assertEq(path[0], address(asset1)); @@ -139,6 +139,19 @@ contract ReserveSystemTest is Test { assertEq(timestamp, block.timestamp); } + function test_getConversionPrice_targetPerSource() public { + // asset1 $1000, asset2 $2000 -> 0.5 asset2 per 1 asset1 + uint256 conv = reserveSystem.getConversionPrice(address(asset1), address(asset2)); + assertEq(conv, 0.5e18); + + vm.prank(priceFeedOperator); + reserveSystem.updatePriceFeed(address(asset1), 1732e18, block.timestamp); + vm.prank(priceFeedOperator); + reserveSystem.updatePriceFeed(address(asset2), 1e18, block.timestamp); + conv = reserveSystem.getConversionPrice(address(asset1), address(asset2)); + assertEq(conv, 1732e18); + } + function test_redeem() public { // Set up reserves asset1.mint(reserveManager, 1000 * 1e18);