From c336809676800f5716b74760dbea566e36b3361c Mon Sep 17 00:00:00 2001 From: defiQUG Date: Mon, 25 May 2026 00:30:45 -0700 Subject: [PATCH] Add mainnet checkpoint stack: ISO attestation, participant Etherscan surface, and services. Ship AddressActivityRegistry V1/V2, ISO20022IntakeGateway, Chain138ParticipantSurface, checkpoint hub contracts, checkpoint-core package, aggregator/indexer/sdk services, relay profile guards, M00 diamond bridge facet, and OMNL compliance contracts. Co-authored-by: Cursor --- config/address-inventory.chain138.json | 55 +- config/deployment-omnl.example.env | 4 + config/omnl-ipsas-gl-registry.json | 120 ++- contracts/emoney/BridgeVault138.sol | 14 - contracts/emoney/ComplianceRegistry.sol | 18 - contracts/emoney/PolicyManager.sol | 22 - contracts/emoney/TokenFactory138.sol | 14 - .../interfaces/IAccountWalletRegistry.sol | 25 - .../emoney/interfaces/ITokenFactory138.sol | 9 - contracts/emoney/interfaces/IeMoneyToken.sol | 9 +- .../hybx-omnl/OMNLComplianceMultisig.sol | 199 ++++ .../OMNLJurisdictionPolicyRegistry.sol | 90 ++ contracts/hybx-omnl/OMNLNotaryRegistry.sol | 206 ++++ .../hybx-omnl/ReserveCommitmentStore.sol | 31 + .../interfaces/IOMNLNotaryRegistry.sol | 9 + contracts/m00-diamond/M00BridgeStorage.sol | 31 + contracts/m00-diamond/M00DiamondInit.sol | 30 + .../facets/M00MainnetBridgeFacet.sol | 145 +++ .../IChain138BatchEmitterBridge.sol | 17 + .../interfaces/ILegacyMainnetMirror.sol | 42 + .../interfaces/IM00MainnetBridgeFacet.sol | 45 + .../AddressActivityRegistry.sol | 159 +++ .../AddressActivityRegistryV2.sol | 144 +++ .../Chain138MainnetCheckpoint.sol | 549 ++++++++++ .../Chain138ParticipantSurface.sol | 137 +++ .../ISO20022IntakeGateway.sol | 73 ++ .../LegacyCheckpointAdapter.sol | 76 ++ contracts/mainnet-checkpoint/README.md | 98 ++ .../chain138/Chain138BatchEmitter.sol | 121 +++ .../extensions/AttestationURIExtension.sol | 32 + .../extensions/BlockHeaderOracleExtension.sol | 61 ++ .../extensions/CheckpointExtensionBase.sol | 21 + .../extensions/CwTransportLinkExtension.sol | 35 + .../extensions/L2OracleAdapterExtension.sol | 43 + .../extensions/MetricsExtension.sol | 47 + .../extensions/MinPaymentValueExtension.sol | 42 + .../extensions/MirrorDetailExtension.sol | 81 ++ .../extensions/PaymasterHintExtension.sol | 32 + .../extensions/SubmitRateLimitExtension.sol | 42 + .../extensions/TimelockSubmitExtension.sol | 52 + .../TokenTransferFilterExtension.sol | 75 ++ .../ValidatorSigVerifierExtension.sol | 71 ++ .../ZkStateRootVerifierExtension.sol | 46 + .../interfaces/IChain138BatchEmitter.sol | 34 + .../interfaces/IChain138MainnetCheckpoint.sol | 76 ++ .../interfaces/ICheckpointExtension.sol | 30 + .../libraries/BatchEmitterConfig.sol | 18 + .../libraries/CheckpointEIP712.sol | 51 + .../libraries/CheckpointErrors.sol | 38 + .../libraries/CheckpointFlags.sol | 20 + .../libraries/CheckpointHubConfig.sol | 41 + .../libraries/CheckpointLeaf.sol | 105 ++ .../libraries/CheckpointPaymentsLib.sol | 42 + .../libraries/ExtensionIds.sol | 19 + .../storage/CheckpointStorage.sol | 73 ++ contracts/ops/CWMirrorMeshBatch.sol | 130 +++ contracts/registry/UniversalAssetRegistry.sol | 27 + contracts/relay/CCIPRelayBridgeLINK.sol | 68 ++ contracts/rwa/IRWAToken.sol | 30 + contracts/rwa/IRWATokenFactory.sol | 40 + contracts/rwa/IRWATokenRegistry.sol | 33 + contracts/rwa/IUniversalAssetRegistryRWA.sol | 18 + contracts/rwa/RWAEIP712.sol | 45 + contracts/rwa/RWAToken.sol | 188 ++++ contracts/rwa/RWATokenFactory.sol | 93 ++ contracts/rwa/RWATokenInterfaces.sol | 22 + contracts/rwa/RWATokenRegistry.sol | 82 ++ contracts/rwa/diamond/RWAStorage.sol | 69 ++ .../rwa/diamond/facets/RWADocumentFacet.sol | 65 ++ .../rwa/diamond/facets/RWAInstrumentFacet.sol | 57 + .../facets/RWAStandardsRegistryFacet.sol | 48 + .../diamond/interfaces/IRWADocumentFacet.sol | 23 + .../interfaces/IRWAInstrumentFacet.sol | 27 + .../interfaces/IRWAStandardsRegistryFacet.sol | 18 + .../rwa/diamond/libraries/RWAUriCodec.sol | 40 + contracts/rwa/libraries/RWATaxonomy.sol | 54 + contracts/vault/GRUEntityIbanRegistry.sol | 91 ++ contracts/vault/GRUVaultIndex.sol | 131 +++ contracts/vault/Ledger.sol | 9 + contracts/vault/VaultFactory.sol | 79 +- contracts/vault/interfaces/ILedger.sol | 6 + ...IP_BRIDGE_DESTINATIONS_AND_LINK_FUNDING.md | 2 + docs/hybx-omnl/OPERATIONAL_COMPLIANCE.md | 7 +- foundry.toml | 32 +- frontend-dapp/src/config/bridge.ts | 2 +- frontend-dapp/src/config/contracts.ts | 3 +- lib/deploy-detail-v2.0.txt | 70 ++ lib/deploy-nft.txt | 14 + lib/gru-contracts | 1 + package.json | 4 + packages/checkpoint-core/dist/index.d.ts | 5 + packages/checkpoint-core/dist/index.js | 21 + .../checkpoint-core/dist/iso20022/hashes.d.ts | 5 + .../checkpoint-core/dist/iso20022/hashes.js | 27 + .../checkpoint-core/dist/iso20022/index.d.ts | 3 + .../checkpoint-core/dist/iso20022/index.js | 19 + .../dist/iso20022/mapFromPaymentLeaf.d.ts | 5 + .../dist/iso20022/mapFromPaymentLeaf.js | 95 ++ .../checkpoint-core/dist/iso20022/types.d.ts | 53 + .../checkpoint-core/dist/iso20022/types.js | 11 + packages/checkpoint-core/dist/leaf.d.ts | 28 + packages/checkpoint-core/dist/leaf.js | 71 ++ packages/checkpoint-core/dist/merkle.d.ts | 6 + packages/checkpoint-core/dist/merkle.js | 47 + .../checkpoint-core/dist/merkle.test.d.ts | 1 + packages/checkpoint-core/dist/merkle.test.js | 22 + .../checkpoint-core/dist/tokenTransfers.d.ts | 40 + .../checkpoint-core/dist/tokenTransfers.js | 87 ++ packages/checkpoint-core/dist/usdPricing.d.ts | 52 + packages/checkpoint-core/dist/usdPricing.js | 345 ++++++ .../checkpoint-core/dist/usdPricing.test.d.ts | 1 + .../checkpoint-core/dist/usdPricing.test.js | 12 + packages/checkpoint-core/package.json | 20 + packages/checkpoint-core/pnpm-lock.yaml | 114 ++ packages/checkpoint-core/src/index.ts | 5 + .../checkpoint-core/src/iso20022/hashes.ts | 24 + .../checkpoint-core/src/iso20022/index.ts | 3 + .../src/iso20022/mapFromPaymentLeaf.ts | 107 ++ .../checkpoint-core/src/iso20022/types.ts | 56 + packages/checkpoint-core/src/leaf.ts | 100 ++ packages/checkpoint-core/src/merkle.test.ts | 18 + packages/checkpoint-core/src/merkle.ts | 44 + .../checkpoint-core/src/tokenTransfers.ts | 115 ++ .../checkpoint-core/src/usdPricing.test.ts | 8 + packages/checkpoint-core/src/usdPricing.ts | 391 +++++++ packages/checkpoint-core/tsconfig.json | 13 + script/DeployCCIPRelayBridgeLINK.s.sol | 26 + script/FundBridgeLinkViaCcip138.s.sol | 45 + script/FundBridgeLinkViaCcipMainnet.s.sol | 42 + .../DeployChain138StabilizerFinish.s.sol | 49 + .../DeployChain138StabilizerStack.s.sol | 57 + .../deploy/rwa/DeployRWATokenFactory138.s.sol | 230 ++++ .../rwa/RegisterRWAIndicesInUAR138.s.sol | 68 ++ .../WireRWATokenFactoryWeb3Controls138.s.sol | 70 ++ .../deploy/vault/DeployAcVdcSdcVaults.s.sol | 62 +- .../vault/DeployAcVdcSdcVaults651940.s.sol | 56 + .../vault/DeployAcVdcSdcVaultsGRU.s.sol | 85 ++ .../vault/DeployCREATE2FactoryIfNeeded.s.sol | 21 + .../deploy/vault/DeployGRUVaultProtocol.s.sol | 34 + .../DeployGRUVaultRegistriesCreate2.s.sol | 50 + .../vault/DeployNewGRUVaultIndex138.s.sol | 17 + .../vault/DeployVaultFactory651940.s.sol | 50 + script/deploy/vault/DeployVaultSystem.s.sol | 6 +- .../vault/DeployVaultSystem651940Core.s.sol | 71 ++ .../vault/DeployVaultSystemFinish.s.sol | 94 ++ .../vault/ImportGruVaultRecord138.s.sol | 66 ++ .../vault/MigrateGRUVaultIndex138.s.sol | 82 ++ .../vault/RegisterGRUEntityAndIban.s.sol | 44 + .../vault/RegisterGruVaultLedgerAssets.s.sol | 44 + .../vault/WireGRUVaultFactoryToIndex.s.sol | 22 + .../DeployOMNLComplianceCoreV2.s.sol | 24 + script/hybx-omnl/DeployOMNLStack.s.sol | 16 +- .../hybx-omnl/DeployOMNLWeb3Compliance.s.sol | 27 + .../hybx-omnl/MigrateOMNLReserveStoreV2.s.sol | 42 + .../PublishJurisdictionPolicies.s.sol | 27 + .../hybx-omnl/RefreshReserveAttestation.s.sol | 32 + script/hybx-omnl/RemediateOmnlM1Cap.s.sol | 47 + script/hybx-omnl/WireOMNLWeb3Compliance.s.sol | 33 + .../m00-diamond/DeployM00DiamondHub138.s.sol | 151 +++ .../m00-diamond/UpgradeM00DiamondAcl138.s.sol | 33 + .../CheckpointConfigLib.s.sol | 35 + .../ConfigureCheckpointExtensions.s.sol | 81 ++ .../ConfigureCheckpointHub.s.sol | 26 + .../DeployAddressActivityRegistry.s.sol | 15 + .../DeployAddressActivityRegistryV2.s.sol | 15 + .../DeployAllCheckpointExtensions.s.sol | 49 + .../DeployChain138BatchEmitter.s.sol | 28 + .../DeployChain138MainnetCheckpoint.s.sol | 28 + .../DeployChain138ParticipantSurface.s.sol | 15 + .../DeployISO20022IntakeGateway.s.sol | 15 + .../ReenableCheckpointExtensions.s.sol | 21 + .../RegisterCheckpointExtensions.s.sol | 42 + .../ReplaceMirrorDetailExtension.s.sol | 27 + .../ReplaceTokenTransferFilterExtension.s.sol | 40 + .../SimulateSubmitFromCalldata.s.sol | 22 + .../UpgradeChain138MainnetCheckpointV3.s.sol | 21 + .../UpgradeChain138MainnetCheckpointV4.s.sol | 20 + script/ops/DeployCWMirrorMeshBatch.s.sol | 15 + .../PublishCommercialEmoneyM1Profile.s.sol | 21 + .../deployment/create-uniswap-v3-gas-pool.sh | 153 ++- .../deployment/fund-ccip-bridges-with-link.sh | 21 +- .../deployment/fund-uniswap-v3-gas-pool.sh | 145 ++- .../sync-chain138-pmm-pools-from-json.sh | 4 +- scripts/forge/scope.sh | 15 +- scripts/lib/deployment/prompts.sh | 6 +- services/checkpoint-aggregator/README.md | 25 + .../dist/activityRegistry.js | 48 + .../dist/activityRegistryV2.js | 57 + .../checkpoint-aggregator/dist/blockscout.js | 70 ++ services/checkpoint-aggregator/dist/config.js | 114 ++ .../checkpoint-aggregator/dist/dualMirror.js | 30 + services/checkpoint-aggregator/dist/eip712.js | 47 + services/checkpoint-aggregator/dist/index.js | 372 +++++++ .../dist/ingress/adminIngress.js | 74 ++ .../dist/ingress/types.js | 2 + services/checkpoint-aggregator/dist/ipfs.js | 69 ++ .../dist/iso20022Enrich.js | 12 + .../dist/iso20022LocalStore.js | 49 + .../checkpoint-aggregator/dist/leafCodec.js | 60 ++ .../dist/participantSurface.js | 99 ++ .../dist/receiptsRoot.js | 33 + .../dist/recordBlockActivity.js | 116 ++ .../checkpoint-aggregator/dist/scanState.js | 58 + .../dist/tokenTransfers.js | 42 + .../checkpoint-aggregator/dist/usdEnrich.js | 79 ++ services/checkpoint-aggregator/package.json | 21 + services/checkpoint-aggregator/pnpm-lock.yaml | 136 +++ .../src/activityRegistry.ts | 50 + .../src/activityRegistryV2.ts | 62 ++ .../checkpoint-aggregator/src/blockscout.ts | 94 ++ services/checkpoint-aggregator/src/config.ts | 102 ++ .../checkpoint-aggregator/src/dualMirror.ts | 42 + services/checkpoint-aggregator/src/eip712.ts | 66 ++ services/checkpoint-aggregator/src/index.ts | 406 +++++++ .../src/ingress/adminIngress.ts | 108 ++ .../src/ingress/types.ts | 50 + services/checkpoint-aggregator/src/ipfs.ts | 47 + .../src/iso20022Enrich.ts | 9 + .../src/iso20022LocalStore.ts | 55 + .../checkpoint-aggregator/src/leafCodec.ts | 83 ++ .../src/participantSurface.ts | 136 +++ .../checkpoint-aggregator/src/receiptsRoot.ts | 51 + .../src/recordBlockActivity.ts | 140 +++ .../checkpoint-aggregator/src/scanState.ts | 27 + .../checkpoint-aggregator/src/usdEnrich.ts | 87 ++ services/checkpoint-aggregator/tsconfig.json | 12 + services/checkpoint-indexer/README.md | 48 + .../checkpoint-indexer/dist/addressIndex.js | 145 +++ .../dist/chain138Explorer.js | 63 ++ services/checkpoint-indexer/dist/config.js | 67 ++ .../dist/etherscanV2Shim.js | 312 ++++++ .../dist/iso20022LogDecode.js | 36 + services/checkpoint-indexer/dist/merkle.js | 22 + .../dist/participantSurfaceLogDecode.js | 45 + services/checkpoint-indexer/dist/serialize.js | 18 + services/checkpoint-indexer/dist/server.js | 342 ++++++ .../checkpoint-indexer/dist/tokenEnrich.js | 39 + .../checkpoint-indexer/dist/tokenTransfers.js | 41 + services/checkpoint-indexer/dist/usdEnrich.js | 14 + services/checkpoint-indexer/package.json | 23 + services/checkpoint-indexer/pnpm-lock.yaml | 783 ++++++++++++++ .../checkpoint-indexer/src/addressIndex.ts | 150 +++ .../src/chain138Explorer.ts | 81 ++ services/checkpoint-indexer/src/config.ts | 40 + .../checkpoint-indexer/src/etherscanV2Shim.ts | 396 +++++++ .../src/iso20022LogDecode.ts | 55 + services/checkpoint-indexer/src/merkle.ts | 39 + .../src/participantSurfaceLogDecode.ts | 66 ++ services/checkpoint-indexer/src/serialize.ts | 13 + services/checkpoint-indexer/src/server.ts | 320 ++++++ .../checkpoint-indexer/src/tokenEnrich.ts | 50 + services/checkpoint-indexer/src/usdEnrich.ts | 25 + services/checkpoint-indexer/tsconfig.json | 12 + services/checkpoint-sdk/package.json | 19 + services/checkpoint-sdk/src/index.ts | 64 ++ services/checkpoint-sdk/tsconfig.json | 13 + services/relay/.env.bsc.example | 3 +- services/relay/.env.local.example | 13 + services/relay/.env.mainnet-cw | 7 +- services/relay/.env.mainnet-weth | 4 +- services/relay/README.md | 35 +- services/relay/data/queue-state.json | 999 ++++++++++++++++++ services/relay/src/MessageQueue.js | 18 +- services/relay/src/RelayService.js | 146 ++- services/relay/src/config.js | 37 +- services/relay/start-relay.sh | 8 +- services/relay/test.js | 57 +- services/state-anchoring-service/src/index.ts | 5 +- services/token-aggregation/.env.example | 8 + .../src/adapters/coingecko-adapter.ts | 48 + .../api/middleware/omnl-audit-middleware.ts | 26 + .../src/api/middleware/omnl-guards.ts | 50 +- .../src/api/middleware/rate-limit.ts | 20 +- .../src/api/routes/checkpoint.ts | 152 +++ .../src/api/routes/heatmap.ts | 90 +- .../src/api/routes/omnl-compliance-routes.ts | 166 +++ .../src/api/routes/omnl-ipsas.ts | 16 +- .../token-aggregation/src/api/routes/omnl.ts | 11 +- .../token-aggregation/src/api/routes/quote.ts | 68 +- .../src/api/routes/report.test.ts | 20 + .../src/api/routes/report.ts | 85 ++ .../src/api/routes/tokens.ts | 38 +- services/token-aggregation/src/api/server.ts | 4 + .../src/config/canonical-tokens.ts | 46 +- .../src/config/capital-markets-taxonomy.ts | 51 + .../src/config/chain138-live-dodo-pools.ts | 17 + .../src/config/cross-chain-bridges.ts | 2 +- .../src/config/gru-transport.ts | 3 + .../src/indexer/omnl-event-poller.ts | 3 +- .../src/services/live-dodo-fallback.ts | 60 +- .../src/services/omnl-api-catalog.ts | 11 +- .../src/services/omnl-audit-log.ts | 37 + .../src/services/omnl-chain138-addresses.ts | 30 + .../src/services/omnl-compliance-pack.ts | 150 +++ .../src/services/omnl-compliance.ts | 7 +- .../src/services/omnl-fineract-client.ts | 64 ++ .../src/services/omnl-ifrs-disclosures.ts | 176 +++ .../src/services/omnl-integration-status.ts | 7 +- .../src/services/omnl-iso20022-store.ts | 100 ++ .../src/services/omnl-triple-reconcile.ts | 226 ++++ .../src/services/omnl-web3-compliance.ts | 95 ++ .../src/services/omnl-webhooks.ts | 15 + .../src/services/valuation-precedence.test.ts | 25 + .../src/services/valuation-precedence.ts | 19 +- .../dist/index.js | 37 +- .../src/index.ts | 24 +- test/hybx-omnl/NotaryAndMultisig.t.sol | 104 ++ test/hybx-omnl/ReserveNotaryGate.t.sol | 60 ++ test/m00-diamond/M00MainnetBridgeFacet.t.sol | 32 + .../AddressActivityRegistry.t.sol | 111 ++ .../AddressActivityRegistryV2.t.sol | 80 ++ .../BlockHeaderOracle.t.sol | 103 ++ test/mainnet-checkpoint/CcipReceive.t.sol | 88 ++ .../Chain138MainnetCheckpoint.t.sol | 90 ++ .../Chain138ParticipantSurface.t.sol | 33 + .../CheckpointHubConfig.t.sol | 60 ++ test/mainnet-checkpoint/CheckpointLeaf.t.sol | 37 + .../ExtensionHookWiring.t.sol | 135 +++ .../ExtensionRegistry.t.sol | 38 + test/mainnet-checkpoint/MinPaymentValue.t.sol | 67 ++ .../MirrorDetailV2Decode.t.sol | 53 + .../PaymentLeafV2Submit.t.sol | 28 + .../mainnet-checkpoint/SubmitWithLeaves.t.sol | 108 ++ .../TokenTransferFilterV1.t.sol | 33 + test/rwa/RWADiamondFacets.t.sol | 76 ++ test/rwa/RWATokenFactory.t.sol | 162 +++ 326 files changed, 21108 insertions(+), 334 deletions(-) delete mode 100644 contracts/emoney/BridgeVault138.sol delete mode 100644 contracts/emoney/ComplianceRegistry.sol delete mode 100644 contracts/emoney/PolicyManager.sol delete mode 100644 contracts/emoney/TokenFactory138.sol delete mode 100644 contracts/emoney/interfaces/IAccountWalletRegistry.sol delete mode 100644 contracts/emoney/interfaces/ITokenFactory138.sol create mode 100644 contracts/hybx-omnl/OMNLComplianceMultisig.sol create mode 100644 contracts/hybx-omnl/OMNLJurisdictionPolicyRegistry.sol create mode 100644 contracts/hybx-omnl/OMNLNotaryRegistry.sol create mode 100644 contracts/hybx-omnl/interfaces/IOMNLNotaryRegistry.sol create mode 100644 contracts/m00-diamond/M00BridgeStorage.sol create mode 100644 contracts/m00-diamond/M00DiamondInit.sol create mode 100644 contracts/m00-diamond/facets/M00MainnetBridgeFacet.sol create mode 100644 contracts/m00-diamond/interfaces/IChain138BatchEmitterBridge.sol create mode 100644 contracts/m00-diamond/interfaces/ILegacyMainnetMirror.sol create mode 100644 contracts/m00-diamond/interfaces/IM00MainnetBridgeFacet.sol create mode 100644 contracts/mainnet-checkpoint/AddressActivityRegistry.sol create mode 100644 contracts/mainnet-checkpoint/AddressActivityRegistryV2.sol create mode 100644 contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol create mode 100644 contracts/mainnet-checkpoint/Chain138ParticipantSurface.sol create mode 100644 contracts/mainnet-checkpoint/ISO20022IntakeGateway.sol create mode 100644 contracts/mainnet-checkpoint/LegacyCheckpointAdapter.sol create mode 100644 contracts/mainnet-checkpoint/README.md create mode 100644 contracts/mainnet-checkpoint/chain138/Chain138BatchEmitter.sol create mode 100644 contracts/mainnet-checkpoint/extensions/AttestationURIExtension.sol create mode 100644 contracts/mainnet-checkpoint/extensions/BlockHeaderOracleExtension.sol create mode 100644 contracts/mainnet-checkpoint/extensions/CheckpointExtensionBase.sol create mode 100644 contracts/mainnet-checkpoint/extensions/CwTransportLinkExtension.sol create mode 100644 contracts/mainnet-checkpoint/extensions/L2OracleAdapterExtension.sol create mode 100644 contracts/mainnet-checkpoint/extensions/MetricsExtension.sol create mode 100644 contracts/mainnet-checkpoint/extensions/MinPaymentValueExtension.sol create mode 100644 contracts/mainnet-checkpoint/extensions/MirrorDetailExtension.sol create mode 100644 contracts/mainnet-checkpoint/extensions/PaymasterHintExtension.sol create mode 100644 contracts/mainnet-checkpoint/extensions/SubmitRateLimitExtension.sol create mode 100644 contracts/mainnet-checkpoint/extensions/TimelockSubmitExtension.sol create mode 100644 contracts/mainnet-checkpoint/extensions/TokenTransferFilterExtension.sol create mode 100644 contracts/mainnet-checkpoint/extensions/ValidatorSigVerifierExtension.sol create mode 100644 contracts/mainnet-checkpoint/extensions/ZkStateRootVerifierExtension.sol create mode 100644 contracts/mainnet-checkpoint/interfaces/IChain138BatchEmitter.sol create mode 100644 contracts/mainnet-checkpoint/interfaces/IChain138MainnetCheckpoint.sol create mode 100644 contracts/mainnet-checkpoint/interfaces/ICheckpointExtension.sol create mode 100644 contracts/mainnet-checkpoint/libraries/BatchEmitterConfig.sol create mode 100644 contracts/mainnet-checkpoint/libraries/CheckpointEIP712.sol create mode 100644 contracts/mainnet-checkpoint/libraries/CheckpointErrors.sol create mode 100644 contracts/mainnet-checkpoint/libraries/CheckpointFlags.sol create mode 100644 contracts/mainnet-checkpoint/libraries/CheckpointHubConfig.sol create mode 100644 contracts/mainnet-checkpoint/libraries/CheckpointLeaf.sol create mode 100644 contracts/mainnet-checkpoint/libraries/CheckpointPaymentsLib.sol create mode 100644 contracts/mainnet-checkpoint/libraries/ExtensionIds.sol create mode 100644 contracts/mainnet-checkpoint/storage/CheckpointStorage.sol create mode 100644 contracts/ops/CWMirrorMeshBatch.sol create mode 100644 contracts/relay/CCIPRelayBridgeLINK.sol create mode 100644 contracts/rwa/IRWAToken.sol create mode 100644 contracts/rwa/IRWATokenFactory.sol create mode 100644 contracts/rwa/IRWATokenRegistry.sol create mode 100644 contracts/rwa/IUniversalAssetRegistryRWA.sol create mode 100644 contracts/rwa/RWAEIP712.sol create mode 100644 contracts/rwa/RWAToken.sol create mode 100644 contracts/rwa/RWATokenFactory.sol create mode 100644 contracts/rwa/RWATokenInterfaces.sol create mode 100644 contracts/rwa/RWATokenRegistry.sol create mode 100644 contracts/rwa/diamond/RWAStorage.sol create mode 100644 contracts/rwa/diamond/facets/RWADocumentFacet.sol create mode 100644 contracts/rwa/diamond/facets/RWAInstrumentFacet.sol create mode 100644 contracts/rwa/diamond/facets/RWAStandardsRegistryFacet.sol create mode 100644 contracts/rwa/diamond/interfaces/IRWADocumentFacet.sol create mode 100644 contracts/rwa/diamond/interfaces/IRWAInstrumentFacet.sol create mode 100644 contracts/rwa/diamond/interfaces/IRWAStandardsRegistryFacet.sol create mode 100644 contracts/rwa/diamond/libraries/RWAUriCodec.sol create mode 100644 contracts/rwa/libraries/RWATaxonomy.sol create mode 100644 contracts/vault/GRUEntityIbanRegistry.sol create mode 100644 contracts/vault/GRUVaultIndex.sol create mode 100644 lib/deploy-detail-v2.0.txt create mode 100644 lib/deploy-nft.txt create mode 120000 lib/gru-contracts create mode 100644 packages/checkpoint-core/dist/index.d.ts create mode 100644 packages/checkpoint-core/dist/index.js create mode 100644 packages/checkpoint-core/dist/iso20022/hashes.d.ts create mode 100644 packages/checkpoint-core/dist/iso20022/hashes.js create mode 100644 packages/checkpoint-core/dist/iso20022/index.d.ts create mode 100644 packages/checkpoint-core/dist/iso20022/index.js create mode 100644 packages/checkpoint-core/dist/iso20022/mapFromPaymentLeaf.d.ts create mode 100644 packages/checkpoint-core/dist/iso20022/mapFromPaymentLeaf.js create mode 100644 packages/checkpoint-core/dist/iso20022/types.d.ts create mode 100644 packages/checkpoint-core/dist/iso20022/types.js create mode 100644 packages/checkpoint-core/dist/leaf.d.ts create mode 100644 packages/checkpoint-core/dist/leaf.js create mode 100644 packages/checkpoint-core/dist/merkle.d.ts create mode 100644 packages/checkpoint-core/dist/merkle.js create mode 100644 packages/checkpoint-core/dist/merkle.test.d.ts create mode 100644 packages/checkpoint-core/dist/merkle.test.js create mode 100644 packages/checkpoint-core/dist/tokenTransfers.d.ts create mode 100644 packages/checkpoint-core/dist/tokenTransfers.js create mode 100644 packages/checkpoint-core/dist/usdPricing.d.ts create mode 100644 packages/checkpoint-core/dist/usdPricing.js create mode 100644 packages/checkpoint-core/dist/usdPricing.test.d.ts create mode 100644 packages/checkpoint-core/dist/usdPricing.test.js create mode 100644 packages/checkpoint-core/package.json create mode 100644 packages/checkpoint-core/pnpm-lock.yaml create mode 100644 packages/checkpoint-core/src/index.ts create mode 100644 packages/checkpoint-core/src/iso20022/hashes.ts create mode 100644 packages/checkpoint-core/src/iso20022/index.ts create mode 100644 packages/checkpoint-core/src/iso20022/mapFromPaymentLeaf.ts create mode 100644 packages/checkpoint-core/src/iso20022/types.ts create mode 100644 packages/checkpoint-core/src/leaf.ts create mode 100644 packages/checkpoint-core/src/merkle.test.ts create mode 100644 packages/checkpoint-core/src/merkle.ts create mode 100644 packages/checkpoint-core/src/tokenTransfers.ts create mode 100644 packages/checkpoint-core/src/usdPricing.test.ts create mode 100644 packages/checkpoint-core/src/usdPricing.ts create mode 100644 packages/checkpoint-core/tsconfig.json create mode 100644 script/DeployCCIPRelayBridgeLINK.s.sol create mode 100644 script/FundBridgeLinkViaCcip138.s.sol create mode 100644 script/FundBridgeLinkViaCcipMainnet.s.sol create mode 100644 script/bridge/trustless/DeployChain138StabilizerFinish.s.sol create mode 100644 script/bridge/trustless/DeployChain138StabilizerStack.s.sol create mode 100644 script/deploy/rwa/DeployRWATokenFactory138.s.sol create mode 100644 script/deploy/rwa/RegisterRWAIndicesInUAR138.s.sol create mode 100644 script/deploy/rwa/WireRWATokenFactoryWeb3Controls138.s.sol create mode 100644 script/deploy/vault/DeployAcVdcSdcVaults651940.s.sol create mode 100644 script/deploy/vault/DeployAcVdcSdcVaultsGRU.s.sol create mode 100644 script/deploy/vault/DeployCREATE2FactoryIfNeeded.s.sol create mode 100644 script/deploy/vault/DeployGRUVaultProtocol.s.sol create mode 100644 script/deploy/vault/DeployGRUVaultRegistriesCreate2.s.sol create mode 100644 script/deploy/vault/DeployNewGRUVaultIndex138.s.sol create mode 100644 script/deploy/vault/DeployVaultFactory651940.s.sol create mode 100644 script/deploy/vault/DeployVaultSystem651940Core.s.sol create mode 100644 script/deploy/vault/DeployVaultSystemFinish.s.sol create mode 100644 script/deploy/vault/ImportGruVaultRecord138.s.sol create mode 100644 script/deploy/vault/MigrateGRUVaultIndex138.s.sol create mode 100644 script/deploy/vault/RegisterGRUEntityAndIban.s.sol create mode 100644 script/deploy/vault/RegisterGruVaultLedgerAssets.s.sol create mode 100644 script/deploy/vault/WireGRUVaultFactoryToIndex.s.sol create mode 100644 script/hybx-omnl/DeployOMNLComplianceCoreV2.s.sol create mode 100644 script/hybx-omnl/DeployOMNLWeb3Compliance.s.sol create mode 100644 script/hybx-omnl/MigrateOMNLReserveStoreV2.s.sol create mode 100644 script/hybx-omnl/PublishJurisdictionPolicies.s.sol create mode 100644 script/hybx-omnl/RefreshReserveAttestation.s.sol create mode 100644 script/hybx-omnl/RemediateOmnlM1Cap.s.sol create mode 100644 script/hybx-omnl/WireOMNLWeb3Compliance.s.sol create mode 100644 script/m00-diamond/DeployM00DiamondHub138.s.sol create mode 100644 script/m00-diamond/UpgradeM00DiamondAcl138.s.sol create mode 100644 script/mainnet-checkpoint/CheckpointConfigLib.s.sol create mode 100644 script/mainnet-checkpoint/ConfigureCheckpointExtensions.s.sol create mode 100644 script/mainnet-checkpoint/ConfigureCheckpointHub.s.sol create mode 100644 script/mainnet-checkpoint/DeployAddressActivityRegistry.s.sol create mode 100644 script/mainnet-checkpoint/DeployAddressActivityRegistryV2.s.sol create mode 100644 script/mainnet-checkpoint/DeployAllCheckpointExtensions.s.sol create mode 100644 script/mainnet-checkpoint/DeployChain138BatchEmitter.s.sol create mode 100644 script/mainnet-checkpoint/DeployChain138MainnetCheckpoint.s.sol create mode 100644 script/mainnet-checkpoint/DeployChain138ParticipantSurface.s.sol create mode 100644 script/mainnet-checkpoint/DeployISO20022IntakeGateway.s.sol create mode 100644 script/mainnet-checkpoint/ReenableCheckpointExtensions.s.sol create mode 100644 script/mainnet-checkpoint/RegisterCheckpointExtensions.s.sol create mode 100644 script/mainnet-checkpoint/ReplaceMirrorDetailExtension.s.sol create mode 100644 script/mainnet-checkpoint/ReplaceTokenTransferFilterExtension.s.sol create mode 100644 script/mainnet-checkpoint/SimulateSubmitFromCalldata.s.sol create mode 100644 script/mainnet-checkpoint/UpgradeChain138MainnetCheckpointV3.s.sol create mode 100644 script/mainnet-checkpoint/UpgradeChain138MainnetCheckpointV4.s.sol create mode 100644 script/ops/DeployCWMirrorMeshBatch.s.sol create mode 100644 script/universal-resource/PublishCommercialEmoneyM1Profile.s.sol create mode 100644 services/checkpoint-aggregator/README.md create mode 100644 services/checkpoint-aggregator/dist/activityRegistry.js create mode 100644 services/checkpoint-aggregator/dist/activityRegistryV2.js create mode 100644 services/checkpoint-aggregator/dist/blockscout.js create mode 100644 services/checkpoint-aggregator/dist/config.js create mode 100644 services/checkpoint-aggregator/dist/dualMirror.js create mode 100644 services/checkpoint-aggregator/dist/eip712.js create mode 100644 services/checkpoint-aggregator/dist/index.js create mode 100644 services/checkpoint-aggregator/dist/ingress/adminIngress.js create mode 100644 services/checkpoint-aggregator/dist/ingress/types.js create mode 100644 services/checkpoint-aggregator/dist/ipfs.js create mode 100644 services/checkpoint-aggregator/dist/iso20022Enrich.js create mode 100644 services/checkpoint-aggregator/dist/iso20022LocalStore.js create mode 100644 services/checkpoint-aggregator/dist/leafCodec.js create mode 100644 services/checkpoint-aggregator/dist/participantSurface.js create mode 100644 services/checkpoint-aggregator/dist/receiptsRoot.js create mode 100644 services/checkpoint-aggregator/dist/recordBlockActivity.js create mode 100644 services/checkpoint-aggregator/dist/scanState.js create mode 100644 services/checkpoint-aggregator/dist/tokenTransfers.js create mode 100644 services/checkpoint-aggregator/dist/usdEnrich.js create mode 100644 services/checkpoint-aggregator/package.json create mode 100644 services/checkpoint-aggregator/pnpm-lock.yaml create mode 100644 services/checkpoint-aggregator/src/activityRegistry.ts create mode 100644 services/checkpoint-aggregator/src/activityRegistryV2.ts create mode 100644 services/checkpoint-aggregator/src/blockscout.ts create mode 100644 services/checkpoint-aggregator/src/config.ts create mode 100644 services/checkpoint-aggregator/src/dualMirror.ts create mode 100644 services/checkpoint-aggregator/src/eip712.ts create mode 100644 services/checkpoint-aggregator/src/index.ts create mode 100644 services/checkpoint-aggregator/src/ingress/adminIngress.ts create mode 100644 services/checkpoint-aggregator/src/ingress/types.ts create mode 100644 services/checkpoint-aggregator/src/ipfs.ts create mode 100644 services/checkpoint-aggregator/src/iso20022Enrich.ts create mode 100644 services/checkpoint-aggregator/src/iso20022LocalStore.ts create mode 100644 services/checkpoint-aggregator/src/leafCodec.ts create mode 100644 services/checkpoint-aggregator/src/participantSurface.ts create mode 100644 services/checkpoint-aggregator/src/receiptsRoot.ts create mode 100644 services/checkpoint-aggregator/src/recordBlockActivity.ts create mode 100644 services/checkpoint-aggregator/src/scanState.ts create mode 100644 services/checkpoint-aggregator/src/usdEnrich.ts create mode 100644 services/checkpoint-aggregator/tsconfig.json create mode 100644 services/checkpoint-indexer/README.md create mode 100644 services/checkpoint-indexer/dist/addressIndex.js create mode 100644 services/checkpoint-indexer/dist/chain138Explorer.js create mode 100644 services/checkpoint-indexer/dist/config.js create mode 100644 services/checkpoint-indexer/dist/etherscanV2Shim.js create mode 100644 services/checkpoint-indexer/dist/iso20022LogDecode.js create mode 100644 services/checkpoint-indexer/dist/merkle.js create mode 100644 services/checkpoint-indexer/dist/participantSurfaceLogDecode.js create mode 100644 services/checkpoint-indexer/dist/serialize.js create mode 100644 services/checkpoint-indexer/dist/server.js create mode 100644 services/checkpoint-indexer/dist/tokenEnrich.js create mode 100644 services/checkpoint-indexer/dist/tokenTransfers.js create mode 100644 services/checkpoint-indexer/dist/usdEnrich.js create mode 100644 services/checkpoint-indexer/package.json create mode 100644 services/checkpoint-indexer/pnpm-lock.yaml create mode 100644 services/checkpoint-indexer/src/addressIndex.ts create mode 100644 services/checkpoint-indexer/src/chain138Explorer.ts create mode 100644 services/checkpoint-indexer/src/config.ts create mode 100644 services/checkpoint-indexer/src/etherscanV2Shim.ts create mode 100644 services/checkpoint-indexer/src/iso20022LogDecode.ts create mode 100644 services/checkpoint-indexer/src/merkle.ts create mode 100644 services/checkpoint-indexer/src/participantSurfaceLogDecode.ts create mode 100644 services/checkpoint-indexer/src/serialize.ts create mode 100644 services/checkpoint-indexer/src/server.ts create mode 100644 services/checkpoint-indexer/src/tokenEnrich.ts create mode 100644 services/checkpoint-indexer/src/usdEnrich.ts create mode 100644 services/checkpoint-indexer/tsconfig.json create mode 100644 services/checkpoint-sdk/package.json create mode 100644 services/checkpoint-sdk/src/index.ts create mode 100644 services/checkpoint-sdk/tsconfig.json create mode 100644 services/relay/.env.local.example create mode 100644 services/relay/data/queue-state.json create mode 100644 services/token-aggregation/src/api/middleware/omnl-audit-middleware.ts create mode 100644 services/token-aggregation/src/api/routes/checkpoint.ts create mode 100644 services/token-aggregation/src/api/routes/omnl-compliance-routes.ts create mode 100644 services/token-aggregation/src/config/capital-markets-taxonomy.ts create mode 100644 services/token-aggregation/src/config/chain138-live-dodo-pools.ts create mode 100644 services/token-aggregation/src/services/omnl-audit-log.ts create mode 100644 services/token-aggregation/src/services/omnl-chain138-addresses.ts create mode 100644 services/token-aggregation/src/services/omnl-compliance-pack.ts create mode 100644 services/token-aggregation/src/services/omnl-fineract-client.ts create mode 100644 services/token-aggregation/src/services/omnl-ifrs-disclosures.ts create mode 100644 services/token-aggregation/src/services/omnl-iso20022-store.ts create mode 100644 services/token-aggregation/src/services/omnl-triple-reconcile.ts create mode 100644 services/token-aggregation/src/services/omnl-web3-compliance.ts create mode 100644 test/hybx-omnl/NotaryAndMultisig.t.sol create mode 100644 test/hybx-omnl/ReserveNotaryGate.t.sol create mode 100644 test/m00-diamond/M00MainnetBridgeFacet.t.sol create mode 100644 test/mainnet-checkpoint/AddressActivityRegistry.t.sol create mode 100644 test/mainnet-checkpoint/AddressActivityRegistryV2.t.sol create mode 100644 test/mainnet-checkpoint/BlockHeaderOracle.t.sol create mode 100644 test/mainnet-checkpoint/CcipReceive.t.sol create mode 100644 test/mainnet-checkpoint/Chain138MainnetCheckpoint.t.sol create mode 100644 test/mainnet-checkpoint/Chain138ParticipantSurface.t.sol create mode 100644 test/mainnet-checkpoint/CheckpointHubConfig.t.sol create mode 100644 test/mainnet-checkpoint/CheckpointLeaf.t.sol create mode 100644 test/mainnet-checkpoint/ExtensionHookWiring.t.sol create mode 100644 test/mainnet-checkpoint/ExtensionRegistry.t.sol create mode 100644 test/mainnet-checkpoint/MinPaymentValue.t.sol create mode 100644 test/mainnet-checkpoint/MirrorDetailV2Decode.t.sol create mode 100644 test/mainnet-checkpoint/PaymentLeafV2Submit.t.sol create mode 100644 test/mainnet-checkpoint/SubmitWithLeaves.t.sol create mode 100644 test/mainnet-checkpoint/TokenTransferFilterV1.t.sol create mode 100644 test/rwa/RWADiamondFacets.t.sol create mode 100644 test/rwa/RWATokenFactory.t.sol diff --git a/config/address-inventory.chain138.json b/config/address-inventory.chain138.json index 0235bdb..3da7761 100644 --- a/config/address-inventory.chain138.json +++ b/config/address-inventory.chain138.json @@ -1,6 +1,6 @@ { "description": "Reference inventory moved out of smom-dbis-138/.env during dotenv cleanup. These are deployed/inventory addresses, not the minimal runtime env surface.", - "updated": "2026-04-29", + "updated": "2026-05-19", "chain138Inventory": { "COMPLIANCE_REGISTRY": "0xbc54fe2b6fda157c59d59826bcfdbcc654ec9ea1", "COMPLIANCE_REGISTRY_ADDRESS": "0xbc54fe2b6fda157c59d59826bcfdbcc654ec9ea1", @@ -71,7 +71,55 @@ "MIRROR_REGISTRY": "0x6427F9739e6B6c3dDb4E94fEfeBcdF35549549d8", "ALLTRA_ADAPTER": "0x66FEBA2fC9a0B47F26DD4284DAd24F970436B8Dc", "DODO_DVM_FACTORY": "0xc93870594C7f83A0aE076c2e30b494Efc526b68E", + "DODO_CLONE_FACTORY": "0xB7255935aa1771096F200e845f0806A3cC5Ba59B", + "DODO_FEE_RATE_MODEL": "0xdD402De2C5a1c18Ac85f650530d3441CCa5A286A", + "DODO_FEE_RATE_DIP3": "0xc2374871bb63c58f1942bdb0c3b6c34cd66cab10", + "DODO_PERMISSION_MANAGER": "0x2039dbf470cc7f8d4f4f94401dd234c4f1967ffa", + "DODO_DVM_TEMPLATE": "0x793f4423402D63cD00eB48DF5659268fB8deBd8D", + "DODO_APPROVE": "0xEA5Be91d0A1EdA6a2efc80f7211c30584508D56D", + "DODO_APPROVE_PROXY": "0xa861198650005969990bF6223bACb2085C180313", + "DODO_V2_PROXY": "0xEF6E6F41A522896a9EE1C580C87C05E409193F8d", + "DODO_V2_ROUTE_HELPER": "0x6A0009C5a331a40f8F1B12e8bA800D32066df8b5", + "DODO_V2_ADAPTER": "0xf8043e9e524C24c27f534E49E6A8Bdd951fdecd2", + "DODO_MULTICALL": "0xbdfe8bf69ee8e49f1f922b21d5de40ae54f361cf", + "DODO_MULTICALL_WITH_VALID": "0xdc25282b1df417e22776e9f22dd8fca80e5e0c1b", + "DODO_SELL_HELPER": "0x08e38131097017468865dfa8d3b6f6365b2fe001", + "DODO_CALLEE_HELPER": "0x54eb34c84616ce3f89b2d8e6821f8026816ae382", + "DODO_V1_PMM_HELPER": "0x1318881ba65aa9d52e1025df6258e52902307603", + "DODO_SWAP_CALC_HELPER": "0x37aa18e210d239a6d1f4c2890f5ca2fa0573ff14", + "DODO_ERC20_HELPER": "0x63edb74ead6e086f08b037c245a9602ddca35b90", + "DODO_DSP_TEMPLATE": "0x0835Ba617e03EA2bE9825A160435AA45eF7E1ecA", + "DODO_DPP_FACTORY": "0x1623719Bf795317643D629Fe2114776e9F3B2541", + "DODO_DSP_FACTORY": "0xD5d83c48a03d6F8155deD564c3ED0205d75dF31e", + "DODO_CP_FACTORY": "0x0c30b4b04ac745977A4cB1960774CDa5f2A5c135", + "DODO_ERC20_V3_FACTORY": "0x8Df0298a9CB839e89eA7d32918076a70467FBACE", + "DODO_MINE_V2_FACTORY": "0x5eCc900AbB637d6d0448ED53A089805B787c9Ca7", + "DODO_MINE_V3_REGISTRY": "0x5EDE2a76341C966F12919b71310821873312DaBc", + "DODO_DSP_PROXY": "0xC63E8EC3687d162ec5BC7E0ec84479a6010aC6b9", + "DODO_CP_PROXY": "0xb6857e746436464d091F1D690337C467E5fB2861", + "DODO_DPP_PROXY": "0x5aaC65657B05D1651231b670aB4e613E57A726c8", + "DODO_MINE_V3_PROXY": "0xf2d18847bBB0CE47CB06AcA80235329652DD9300", + "DODO_V2_PROXY02": "0xEF6E6F41A522896a9EE1C580C87C05E409193F8d", + "DODO_V1_ADAPTER": "0x8456d12369E7BB6E643A88DF3111c59F8e3A131E", + "DODO_UNI_ADAPTER": "0x6E77AD3FB0d9007C996DcBB8FD1631B6ECAD11C7", + "DODO_TOKEN": "0x929D0Fa4EBfd3b8b9e46c86BcB4202C744adDF4b", + "DODO_INCENTIVE": "0x06e253A9ACB6Fd1Ca2FF9456AA496b049bEd63f1", + "DODO_NFT_REGISTRY": "0xcA3932D629a24E530667E50A8bD86A6e5b5DA7F2", + "DODO_NFT_PROXY": "0x1E84eE365a323421765d68a0Db9b01fc67ea5Af7", + "DODO_NFT_ROUTE_HELPER": "0xBDbE10A5E6334e74f5F4B91802219a2c1a233151", + "DODO_BUYOUT_MODEL": "0xd0c09Aeb180856765cBEcf4DA30CEb4275AfEB15", + "DODO_FRAGMENT": "0xB0Ec60Cd8471c8D2E78ec5266D2d96C9c39c94ED", + "DODO_NFT_COLLATERAL_VAULT": "0x511c8ED44C4890c58a68A1eC7CcFb680DF117B2A", + "DODO_DSP_PROXY_WITHOUT_GSP": "0xC63E8EC3687d162ec5BC7E0ec84479a6010aC6b9", + "DODO_CP_PROXY_WITHOUT_GLOBAL_QUOTA": "0xb6857e746436464d091F1D690337C467E5fB2861", + "DODO_MINE_V3_PROXY_WITHOUT_PLATFORM": "0xf2d18847bBB0CE47CB06AcA80235329652DD9300", + "UNISWAP_V3_FACTORY_CHAIN138_DODO": "0x2f7219276e3ce367dB9ec74C1196a8ecEe67841C", + "NONFUNGIBLE_POSITION_MANAGER_CHAIN138_DODO": "0x31b68BE5af4Df565Ce261dfe53D529005D947B48", + "UNISWAP_V3_ROUTER_CHAIN138_DODO": "0xde9cD8ee2811E6E64a41D5F68Be315d33995975E", + "DODO_TEAM_MULTISIG": "0x4A666F96fC8764181194447A7dFdb7d471b301C8", + "DODO_OPTIONAL_NOT_DEPLOYED": "GSPFactory,FeeRouteProxy1/2,LimitOrder,D3,DODOStarterProxy,DODONFTPoolProxy — see docs/04-configuration/dodo/DODO_CHAIN138_OPTIONAL_DEFERRED.md", "DODO_VENDING_MACHINE_ADDRESS": "0xB16c3D48A111714B1795E58341FeFDd643Ab01ab", + "DODO_VENDING_MACHINE_NOTE": "Legacy DBIS route-executor stub (~1kB), not DODOV2Proxy02 — run deploy-dodo-full-stack-chain138.sh for native proxy", "DODO_PMM_INTEGRATION": "0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895", "DODO_PMM_PROVIDER": "0x3f729632E9553EBacCdE2e9b4c8F2B285b014F2e", "CAUSDT_ADDRESS_138": "0x5fdDF65733e3d590463F68f93Cf16E8c04081271", @@ -97,5 +145,10 @@ "CHAIN138_POOL_WETH_USDT": "0xe227f6c0520c0c6e8786fe56fa76c4914f861533", "CHAIN138_POOL_WETH_USDC": "0xb53a0508940b1ff90f1aad4f6cb50a7012fe5593", "POOL_WETH_CUSDC": "0xaae68830a55767722618e869882c6ed064cc1eb2" + }, + "mainnetAttestation": { + "CHAIN138_MAINNET_CHECKPOINT_PROXY": "0xe2D6B908FE2535C39C79257FAAa2A52457673ba9", + "TRANSACTION_MIRROR_MAINNET": "0x4CF42c4F1dBa748601b8938be3E7ABD732E87cE9", + "ADDRESS_ACTIVITY_REGISTRY_MAINNET": "0x034816178E1865f54250B08ce3024F6810b50ABd" } } diff --git a/config/deployment-omnl.example.env b/config/deployment-omnl.example.env index 4726dbb..de6cc7e 100644 --- a/config/deployment-omnl.example.env +++ b/config/deployment-omnl.example.env @@ -8,6 +8,10 @@ OMNL_MIRROR_RPC_URL=https:// OMNL_INSTRUMENT_REGISTRY= OMNL_RESERVE_COMMITMENT_STORE= OMNL_COMPLIANCE_CORE= +# v2 stack (notary-gated reserve + ComplianceCore) — see config/compliance/omnl-stack-v2-chain138.json +OMNL_RESERVE_STORE_V2_138= +OMNL_COMPLIANCE_CORE_V2_138= +OMNL_GNOSIS_SAFE_ADMIN= OMNL_CIRCUIT_BREAKER= OMNL_MIRROR_RECEIVER= OMNL_MIRROR_COORDINATOR= diff --git a/config/omnl-ipsas-gl-registry.json b/config/omnl-ipsas-gl-registry.json index 96daaf3..475bf9a 100644 --- a/config/omnl-ipsas-gl-registry.json +++ b/config/omnl-ipsas-gl-registry.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "IPSAS-aligned GL codes for OMNL Hybx (Fineract). Pairs must match omnl-journal-matrix.json postings. Source: docs/04-configuration/mifos-omnl-central-bank/OMNL_JOURNAL_LEDGER_MATRIX.md", - "version": "1.0.0", + "description": "IPSAS-aligned GL codes for OMNL Hybx (Fineract). Includes core M0/M1, FX/XAU (IAS 21), fees, and IAS 37 provisions.", + "version": "1.1.0", "currencyCode": "USD", "accounts": [ { @@ -10,6 +10,7 @@ "fineractType": "ASSET", "usage": "Cash and cash equivalents (IPSAS 2); settlement balances", "ipsasStandards": ["IPSAS 2", "IPSAS 28", "IPSAS 29"], + "ifrsRefs": ["IAS 1", "IFRS 7"], "roles": ["settlement", "cash_equivalent"] }, { @@ -18,23 +19,80 @@ "fineractType": "ASSET", "usage": "Reserve backing M1 capacity; financial asset", "ipsasStandards": ["IPSAS 28", "IPSAS 29"], + "ifrsRefs": ["IAS 32", "IFRS 9"], "roles": ["m0_reserve", "treasury_reserve"] }, + { + "glCode": "12010", + "name": "FX reserves — USD", + "fineractType": "ASSET", + "usage": "Foreign currency reserves — USD", + "ipsasStandards": ["IPSAS 29"], + "ifrsRefs": ["IAS 21"], + "roles": ["fx_reserve"] + }, + { + "glCode": "12020", + "name": "FX reserves — EUR", + "fineractType": "ASSET", + "usage": "Foreign currency reserves — EUR (SEPA settlement)", + "ipsasStandards": ["IPSAS 29"], + "ifrsRefs": ["IAS 21"], + "roles": ["fx_reserve", "eur_settlement"] + }, + { + "glCode": "12090", + "name": "FX reserves — other", + "fineractType": "ASSET", + "usage": "Other ISO-4217 and special units including XAU-linked", + "ipsasStandards": ["IPSAS 29"], + "ifrsRefs": ["IAS 21", "IFRS 13"], + "roles": ["fx_reserve", "xau_collateral"] + }, + { + "glCode": "13010", + "name": "FX settlement — nostro", + "fineractType": "ASSET", + "usage": "Settlement balances with correspondent banks", + "ipsasStandards": ["IPSAS 2", "IPSAS 28"], + "ifrsRefs": ["IFRS 7"], + "roles": ["nostro", "correspondent"] + }, { "glCode": "2000", "name": "USD Central Deposits (M1)", "fineractType": "LIABILITY", "usage": "Demand deposits; financial liability", "ipsasStandards": ["IPSAS 28", "IPSAS 29"], + "ifrsRefs": ["IAS 32", "IFRS 7"], "roles": ["m1_liability", "demand_deposit"] }, { "glCode": "2100", "name": "USD Restricted / Escrow (M1)", "fineractType": "LIABILITY", - "usage": "Restricted liabilities; escrow", + "usage": "Restricted liabilities; escrow / fiduciary", "ipsasStandards": ["IPSAS 28", "IPSAS 29"], - "roles": ["m1_restricted", "escrow"] + "ifrsRefs": ["IAS 32"], + "roles": ["m1_restricted", "escrow", "fiduciary"] + }, + { + "glCode": "21010", + "name": "M00 — Bank reserves (control)", + "fineractType": "LIABILITY", + "usage": "GRU M00 base reserve control (gold-referenced)", + "ipsasStandards": ["IPSAS 28", "IPSAS 29"], + "ifrsRefs": ["IAS 32"], + "roles": ["m00_liability", "gru_base"] + }, + { + "glCode": "23010", + "name": "Provisions — contingent liabilities (IAS 37)", + "fineractType": "LIABILITY", + "usage": "Recognized provisions", + "ipsasStandards": ["IPSAS 19"], + "ifrsRefs": ["IAS 37"], + "roles": ["provision"] }, { "glCode": "3000", @@ -42,19 +100,63 @@ "fineractType": "EQUITY", "usage": "Migration control; equity", "ipsasStandards": ["IPSAS 1", "IPSAS 3"], + "ifrsRefs": ["IAS 1"], "roles": ["equity", "migration_control"] + }, + { + "glCode": "42000", + "name": "FX gains (realized)", + "fineractType": "INCOME", + "usage": "Realized foreign exchange gains", + "ipsasStandards": ["IPSAS 9"], + "ifrsRefs": ["IAS 21"], + "roles": ["fx_gain_realized"] + }, + { + "glCode": "42100", + "name": "Unrealized FX gain (P&L)", + "fineractType": "INCOME", + "usage": "Unrealized FX gain on revaluation", + "ipsasStandards": ["IPSAS 9"], + "ifrsRefs": ["IAS 21", "IFRS 9"], + "roles": ["fx_gain_unrealized"] + }, + { + "glCode": "51000", + "name": "FX losses (realized)", + "fineractType": "EXPENSE", + "usage": "Realized foreign exchange losses", + "ipsasStandards": ["IPSAS 9"], + "ifrsRefs": ["IAS 21"], + "roles": ["fx_loss_realized"] + }, + { + "glCode": "52100", + "name": "Unrealized FX loss (P&L)", + "fineractType": "EXPENSE", + "usage": "Unrealized FX loss / ECL expense bucket", + "ipsasStandards": ["IPSAS 9"], + "ifrsRefs": ["IAS 21", "IFRS 9"], + "roles": ["fx_loss_unrealized", "ecl_expense"] } ], "allowedJournalPairs": [ - { "debitGlCode": "1000", "creditGlCode": "2000", "ipsasRef": "IPSAS 3, 28", "memo": "T-001" }, - { "debitGlCode": "1050", "creditGlCode": "2000", "ipsasRef": "IPSAS 28, 29", "memo": "T-001B" }, + { "debitGlCode": "1000", "creditGlCode": "2000", "ipsasRef": "IPSAS 3, 28", "memo": "T-001", "ifrsRef": "IAS 32 issuance" }, + { "debitGlCode": "1050", "creditGlCode": "2000", "ipsasRef": "IPSAS 28, 29", "memo": "T-001B", "ifrsRef": "IAS 32 M0 reserve" }, { "debitGlCode": "2000", "creditGlCode": "2000", "ipsasRef": "IPSAS 28", "memo": "T-002A" }, - { "debitGlCode": "2000", "creditGlCode": "2100", "ipsasRef": "IPSAS 28", "memo": "T-002B" } + { "debitGlCode": "2000", "creditGlCode": "2100", "ipsasRef": "IPSAS 28", "memo": "T-002B" }, + { "debitGlCode": "12020", "creditGlCode": "42100", "ipsasRef": "IPSAS 29", "memo": "FX-REVAL-GAIN", "ifrsRef": "IAS 21 revaluation" }, + { "debitGlCode": "52100", "creditGlCode": "12020", "ipsasRef": "IPSAS 29", "memo": "FX-REVAL-LOSS", "ifrsRef": "IAS 21 revaluation" }, + { "debitGlCode": "52100", "creditGlCode": "23010", "ipsasRef": "IPSAS 19", "memo": "IAS37-PROVISION", "ifrsRef": "IAS 37" }, + { "debitGlCode": "13010", "creditGlCode": "2000", "ipsasRef": "IPSAS 28", "memo": "SETTLE-NOSTRO", "ifrsRef": "IFRS 7 settlement" } ], "monetaryLayerHints": { "m0_reserve": { "primaryGlCodes": ["1050"], "ipsasNarrative": "Treasury / M0 reserve assets (IPSAS 28, 29)" }, "m1_liability": { "primaryGlCodes": ["2000", "2100"], "ipsasNarrative": "Financial liabilities — deposits (IPSAS 28, 29)" }, - "settlement": { "primaryGlCodes": ["1000"], "ipsasNarrative": "Cash and cash equivalents (IPSAS 2)" }, - "equity": { "primaryGlCodes": ["3000"], "ipsasNarrative": "Equity / control (IPSAS 1)" } + "m00_gru": { "primaryGlCodes": ["21010"], "ipsasNarrative": "GRU M00 base reserve (gold-referenced)" }, + "settlement": { "primaryGlCodes": ["1000", "13010"], "ipsasNarrative": "Cash and cash equivalents (IPSAS 2)" }, + "equity": { "primaryGlCodes": ["3000"], "ipsasNarrative": "Equity / control (IPSAS 1)" }, + "fx_xau": { "primaryGlCodes": ["12010", "12020", "12090"], "ipsasNarrative": "FX and XAU reserves (IPSAS 29, IAS 21)" }, + "provisions": { "primaryGlCodes": ["23010"], "ipsasNarrative": "Provisions (IPSAS 19, IAS 37)" } } } diff --git a/contracts/emoney/BridgeVault138.sol b/contracts/emoney/BridgeVault138.sol deleted file mode 100644 index 652effa..0000000 --- a/contracts/emoney/BridgeVault138.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import "@openzeppelin/contracts/access/AccessControl.sol"; - -/** - * @title BridgeVault138 - * @notice Stub for build; full implementation when emoney module is restored - */ -contract BridgeVault138 is AccessControl { - constructor(address admin, address, address) { - _grantRole(DEFAULT_ADMIN_ROLE, admin); - } -} diff --git a/contracts/emoney/ComplianceRegistry.sol b/contracts/emoney/ComplianceRegistry.sol deleted file mode 100644 index e481974..0000000 --- a/contracts/emoney/ComplianceRegistry.sol +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import "@openzeppelin/contracts/access/AccessControl.sol"; - -/** - * @title ComplianceRegistry - * @notice Stub for build; full implementation when emoney module is restored - */ -contract ComplianceRegistry is AccessControl { - constructor(address admin) { - _grantRole(DEFAULT_ADMIN_ROLE, admin); - } - - function canTransfer(address, address, address, uint256) external pure returns (bool) { - return true; - } -} diff --git a/contracts/emoney/PolicyManager.sol b/contracts/emoney/PolicyManager.sol deleted file mode 100644 index 92e48a6..0000000 --- a/contracts/emoney/PolicyManager.sol +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import "@openzeppelin/contracts/access/AccessControl.sol"; - -/** - * @title PolicyManager - * @notice Stub for build; full implementation when emoney module is restored - */ -contract PolicyManager is AccessControl { - constructor(address admin) { - _grantRole(DEFAULT_ADMIN_ROLE, admin); - } - - function canTransfer(address, address, address, uint256) external pure returns (bool isAuthorized, bytes32 reasonCode) { - return (true, bytes32(0)); - } - - function canTransferWithContext(address, address, address, uint256, bytes memory) external pure returns (bool isAuthorized, bytes32 reasonCode) { - return (true, bytes32(0)); - } -} diff --git a/contracts/emoney/TokenFactory138.sol b/contracts/emoney/TokenFactory138.sol deleted file mode 100644 index dc92292..0000000 --- a/contracts/emoney/TokenFactory138.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import "@openzeppelin/contracts/access/AccessControl.sol"; - -/** - * @title TokenFactory138 - * @notice Stub for build; full implementation when emoney module is restored - */ -contract TokenFactory138 is AccessControl { - constructor(address admin) { - _grantRole(DEFAULT_ADMIN_ROLE, admin); - } -} diff --git a/contracts/emoney/interfaces/IAccountWalletRegistry.sol b/contracts/emoney/interfaces/IAccountWalletRegistry.sol deleted file mode 100644 index 0fa383f..0000000 --- a/contracts/emoney/interfaces/IAccountWalletRegistry.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/** - * @title IAccountWalletRegistry - * @notice Registry linking account refs to wallet refs - */ -struct WalletLink { - bytes32 walletRefId; - uint64 linkedAt; - bool active; - bytes32 provider; -} - -interface IAccountWalletRegistry { - 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 deleted file mode 100644 index 019c012..0000000 --- a/contracts/emoney/interfaces/ITokenFactory138.sol +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/** - * @title ITokenFactory138 - * @notice Minimal interface for TokenFactory138 (stub for build) - */ -interface ITokenFactory138 { -} diff --git a/contracts/emoney/interfaces/IeMoneyToken.sol b/contracts/emoney/interfaces/IeMoneyToken.sol index b5a9b99..8a58ef4 100644 --- a/contracts/emoney/interfaces/IeMoneyToken.sol +++ b/contracts/emoney/interfaces/IeMoneyToken.sol @@ -1,11 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -/** - * @title IeMoneyToken - * @notice Minimal interface for eMoney tokens (mint/burn with reason) - */ +/// @notice Minimal eMoney token surface for vault adapters (full eMoney tree is optional). interface IeMoneyToken { - function mint(address to, uint256 amount, bytes32 reasonHash) external; - function burn(address from, uint256 amount, bytes32 reasonHash) external; + function mint(address to, uint256 amount, bytes32 reason) external; + function burn(address from, uint256 amount, bytes32 reason) external; } diff --git a/contracts/hybx-omnl/OMNLComplianceMultisig.sol b/contracts/hybx-omnl/OMNLComplianceMultisig.sol new file mode 100644 index 0000000..cbe4010 --- /dev/null +++ b/contracts/hybx-omnl/OMNLComplianceMultisig.sol @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {OMNLNotaryRegistry} from "./OMNLNotaryRegistry.sol"; + +/** + * @title OMNLComplianceMultisig + * @notice Web3 multisig for OMNL admin operations: arbitrary calls + notarization-gated executions. + * Supports threshold ECDSA (HSM/cold signers) and on-wallet confirm/execute flow. + * @dev Production: prefer Gnosis Safe as owner of DEFAULT_ADMIN roles; this contract coordinates OMNL-specific gates. + */ +contract OMNLComplianceMultisig is AccessControl, ReentrancyGuard { + bytes32 public constant ADMIN_SIGNER_ROLE = keccak256("ADMIN_SIGNER_ROLE"); + bytes32 public constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE"); + + bytes32 public constant EXECUTION_TYPEHASH = keccak256( + "OMNLMultisigExecution(uint256 chainId,address multisig,bytes32 jurisdictionId,uint256 nonce,address target,uint256 value,bytes32 calldataHash)" + ); + + OMNLNotaryRegistry public immutable notaryRegistry; + + uint256 public adminThreshold; + uint256 public executionNonce; + + struct PendingExecution { + bytes32 jurisdictionId; + address target; + uint256 value; + bytes data; + bytes32 requiredContentHash; + bytes32 requiredMatrixControlId; + bool requireNotarization; + bool executed; + uint256 submittedAt; + } + + mapping(uint256 executionId => PendingExecution) public pendingExecutions; + mapping(uint256 executionId => mapping(address => bool)) public executionConfirmations; + mapping(uint256 executionId => uint256) public executionConfirmationCount; + uint256 public pendingCount; + + event ExecutionSubmitted( + uint256 indexed executionId, + bytes32 indexed jurisdictionId, + address target, + bool requireNotarization, + bytes32 requiredContentHash + ); + event ExecutionConfirmed(uint256 indexed executionId, address indexed signer); + event ExecutionRevoked(uint256 indexed executionId, address indexed signer); + event ExecutionCompleted(uint256 indexed executionId, bool success); + event AdminThresholdUpdated(uint256 threshold); + + constructor(address admin, address notaryRegistry_) { + require(admin != address(0), "OMNLComplianceMultisig: zero admin"); + require(notaryRegistry_ != address(0), "OMNLComplianceMultisig: zero notary"); + notaryRegistry = OMNLNotaryRegistry(notaryRegistry_); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(ADMIN_SIGNER_ROLE, admin); + _grantRole(PROPOSER_ROLE, admin); + adminThreshold = 1; + } + + function setAdminThreshold(uint256 threshold) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(threshold > 0, "OMNLComplianceMultisig: threshold"); + adminThreshold = threshold; + emit AdminThresholdUpdated(threshold); + } + + function submitExecution( + bytes32 jurisdictionId, + address target, + uint256 value, + bytes calldata data, + bool requireNotarization, + bytes32 requiredContentHash, + bytes32 requiredMatrixControlId + ) external onlyRole(PROPOSER_ROLE) returns (uint256 executionId) { + require(target != address(0), "OMNLComplianceMultisig: zero target"); + executionId = pendingCount++; + pendingExecutions[executionId] = PendingExecution({ + jurisdictionId: jurisdictionId, + target: target, + value: value, + data: data, + requiredContentHash: requiredContentHash, + requiredMatrixControlId: requiredMatrixControlId, + requireNotarization: requireNotarization, + executed: false, + submittedAt: block.timestamp + }); + emit ExecutionSubmitted(executionId, jurisdictionId, target, requireNotarization, requiredContentHash); + _confirmExecution(executionId, msg.sender); + } + + function confirmExecution(uint256 executionId) external onlyRole(ADMIN_SIGNER_ROLE) { + _confirmExecution(executionId, msg.sender); + } + + function revokeExecutionConfirmation(uint256 executionId) external onlyRole(ADMIN_SIGNER_ROLE) { + require(!pendingExecutions[executionId].executed, "OMNLComplianceMultisig: executed"); + require(executionConfirmations[executionId][msg.sender], "OMNLComplianceMultisig: not confirmed"); + executionConfirmations[executionId][msg.sender] = false; + executionConfirmationCount[executionId]--; + emit ExecutionRevoked(executionId, msg.sender); + } + + function executePending(uint256 executionId) external nonReentrant onlyRole(ADMIN_SIGNER_ROLE) { + PendingExecution storage ex = pendingExecutions[executionId]; + require(!ex.executed, "OMNLComplianceMultisig: executed"); + require(executionConfirmationCount[executionId] >= adminThreshold, "OMNLComplianceMultisig: confirmations"); + + if (ex.requireNotarization) { + require( + notaryRegistry.isNotarized(ex.jurisdictionId, ex.requiredMatrixControlId, ex.requiredContentHash), + "OMNLComplianceMultisig: not notarized" + ); + } + + ex.executed = true; + (bool ok,) = ex.target.call{value: ex.value}(ex.data); + emit ExecutionCompleted(executionId, ok); + require(ok, "OMNLComplianceMultisig: call failed"); + } + + /// @notice Threshold ECDSA path for cold/HSM signers (no prior on-chain confirm). + function executeAttested( + bytes32 jurisdictionId, + address target, + uint256 value, + bytes calldata data, + bool requireNotarization, + bytes32 requiredContentHash, + bytes32 requiredMatrixControlId, + uint256 nonce, + bytes[] calldata signatures + ) external nonReentrant { + require(target != address(0), "OMNLComplianceMultisig: zero target"); + require(nonce == executionNonce, "OMNLComplianceMultisig: nonce"); + + bytes32 calldataHash = keccak256(data); + bytes32 digest = keccak256( + abi.encode( + EXECUTION_TYPEHASH, + block.chainid, + address(this), + jurisdictionId, + nonce, + target, + value, + calldataHash + ) + ); + bytes32 ethSignedMessageHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); + _verifyAdminSignatures(ethSignedMessageHash, signatures); + + if (requireNotarization) { + require( + notaryRegistry.isNotarized(jurisdictionId, requiredMatrixControlId, requiredContentHash), + "OMNLComplianceMultisig: not notarized" + ); + } + + executionNonce = nonce + 1; + (bool ok,) = target.call{value: value}(data); + emit ExecutionCompleted(type(uint256).max, ok); + require(ok, "OMNLComplianceMultisig: call failed"); + } + + function _confirmExecution(uint256 executionId, address signer) internal { + PendingExecution storage ex = pendingExecutions[executionId]; + require(!ex.executed, "OMNLComplianceMultisig: executed"); + require(!executionConfirmations[executionId][signer], "OMNLComplianceMultisig: confirmed"); + executionConfirmations[executionId][signer] = true; + executionConfirmationCount[executionId]++; + emit ExecutionConfirmed(executionId, signer); + } + + function _verifyAdminSignatures(bytes32 ethSignedMessageHash, bytes[] calldata signatures) internal view { + uint256 n = signatures.length; + require(n >= adminThreshold, "OMNLComplianceMultisig: sig count"); + address[] memory seen = new address[](n); + uint256 uniq = 0; + for (uint256 i = 0; i < n; i++) { + address recovered = ECDSA.recover(ethSignedMessageHash, signatures[i]); + require(hasRole(ADMIN_SIGNER_ROLE, recovered), "OMNLComplianceMultisig: not signer"); + for (uint256 j = 0; j < uniq; j++) { + require(seen[j] != recovered, "OMNLComplianceMultisig: duplicate"); + } + seen[uniq++] = recovered; + } + require(uniq >= adminThreshold, "OMNLComplianceMultisig: threshold"); + } + + receive() external payable {} +} diff --git a/contracts/hybx-omnl/OMNLJurisdictionPolicyRegistry.sol b/contracts/hybx-omnl/OMNLJurisdictionPolicyRegistry.sol new file mode 100644 index 0000000..48cdfea --- /dev/null +++ b/contracts/hybx-omnl/OMNLJurisdictionPolicyRegistry.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; + +/** + * @title OMNLJurisdictionPolicyRegistry + * @notice On-chain anchor for jurisdiction compliance matrices and Web3 control thresholds. + * Off-chain source: config/jurisdictions/catalog.v1.json + jurisdiction-web3-controls.v1.json + */ +contract OMNLJurisdictionPolicyRegistry is AccessControl { + bytes32 public constant POLICY_PUBLISHER_ROLE = keccak256("POLICY_PUBLISHER_ROLE"); + + struct JurisdictionPolicy { + bytes32 jurisdictionId; + bytes32 policyContentHash; + uint8 notaryThreshold; + uint8 reserveAttestationThreshold; + uint8 adminMultisigThreshold; + bool productionReady; + uint256 publishedAt; + uint256 version; + } + + mapping(bytes32 jurisdictionId => JurisdictionPolicy) private _policies; + mapping(bytes32 jurisdictionId => uint256) public policyVersion; + + event JurisdictionPolicyPublished( + bytes32 indexed jurisdictionId, + bytes32 policyContentHash, + uint8 notaryThreshold, + uint8 reserveAttestationThreshold, + uint8 adminMultisigThreshold, + bool productionReady, + uint256 version + ); + + constructor(address admin) { + require(admin != address(0), "OMNLJurisdictionPolicyRegistry: zero admin"); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(POLICY_PUBLISHER_ROLE, admin); + } + + function publishPolicy( + bytes32 jurisdictionId, + bytes32 policyContentHash, + uint8 notaryThreshold, + uint8 reserveAttestationThreshold, + uint8 adminMultisigThreshold, + bool productionReady + ) external onlyRole(POLICY_PUBLISHER_ROLE) { + require(jurisdictionId != bytes32(0), "OMNLJurisdictionPolicyRegistry: zero id"); + require(policyContentHash != bytes32(0), "OMNLJurisdictionPolicyRegistry: zero hash"); + require(notaryThreshold > 0, "OMNLJurisdictionPolicyRegistry: notary threshold"); + require(reserveAttestationThreshold > 0, "OMNLJurisdictionPolicyRegistry: reserve threshold"); + require(adminMultisigThreshold > 0, "OMNLJurisdictionPolicyRegistry: admin threshold"); + + uint256 nextVersion = policyVersion[jurisdictionId] + 1; + policyVersion[jurisdictionId] = nextVersion; + + _policies[jurisdictionId] = JurisdictionPolicy({ + jurisdictionId: jurisdictionId, + policyContentHash: policyContentHash, + notaryThreshold: notaryThreshold, + reserveAttestationThreshold: reserveAttestationThreshold, + adminMultisigThreshold: adminMultisigThreshold, + productionReady: productionReady, + publishedAt: block.timestamp, + version: nextVersion + }); + + emit JurisdictionPolicyPublished( + jurisdictionId, + policyContentHash, + notaryThreshold, + reserveAttestationThreshold, + adminMultisigThreshold, + productionReady, + nextVersion + ); + } + + function getPolicy(bytes32 jurisdictionId) external view returns (JurisdictionPolicy memory) { + return _policies[jurisdictionId]; + } + + function isProductionReady(bytes32 jurisdictionId) external view returns (bool) { + return _policies[jurisdictionId].productionReady; + } +} diff --git a/contracts/hybx-omnl/OMNLNotaryRegistry.sol b/contracts/hybx-omnl/OMNLNotaryRegistry.sol new file mode 100644 index 0000000..0a39175 --- /dev/null +++ b/contracts/hybx-omnl/OMNLNotaryRegistry.sol @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {OMNLJurisdictionPolicyRegistry} from "./OMNLJurisdictionPolicyRegistry.sol"; + +/** + * @title OMNLNotaryRegistry + * @notice On-chain notarization of compliance evidence hashes (packages, matrices, journal refs) + * with jurisdiction-scoped threshold ECDSA multisig. + */ +contract OMNLNotaryRegistry is AccessControl { + bytes32 public constant NOTARY_ADMIN_ROLE = keccak256("NOTARY_ADMIN_ROLE"); + + bytes32 public constant NOTARIZATION_TYPEHASH = keccak256( + "OMNLNotarization(uint256 chainId,address registry,bytes32 jurisdictionId,bytes32 matrixControlId,bytes32 contentHash,bytes32 merkleRoot,bytes32 metadataHash,uint256 nonce)" + ); + + enum NotarizationKind { + EvidencePackage, + ComplianceMatrix, + JournalEntry, + Iso20022Message, + CustodianAttestation, + PolicyProfile, + Other + } + + struct NotarizationRecord { + bytes32 jurisdictionId; + bytes32 matrixControlId; + bytes32 contentHash; + bytes32 merkleRoot; + bytes32 metadataHash; + NotarizationKind kind; + uint256 notarizedAt; + address submitter; + uint256 version; + bool revoked; + } + + OMNLJurisdictionPolicyRegistry public immutable jurisdictionRegistry; + + mapping(bytes32 notarizationKey => NotarizationRecord) private _records; + mapping(bytes32 notarizationKey => uint256) public notarizationNonce; + + mapping(address => bool) public isNotarySigner; + uint256 public globalNotaryThreshold; + + event NotarySignerSet(address indexed signer, bool active); + event NotaryThresholdUpdated(uint256 threshold); + event Notarized( + bytes32 indexed notarizationKey, + bytes32 indexed jurisdictionId, + bytes32 indexed matrixControlId, + bytes32 contentHash, + bytes32 merkleRoot, + NotarizationKind kind, + uint256 version, + address submitter + ); + event NotarizationRevoked(bytes32 indexed notarizationKey, address indexed by); + + constructor(address admin, address jurisdictionRegistry_) { + require(admin != address(0), "OMNLNotaryRegistry: zero admin"); + require(jurisdictionRegistry_ != address(0), "OMNLNotaryRegistry: zero registry"); + jurisdictionRegistry = OMNLJurisdictionPolicyRegistry(jurisdictionRegistry_); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(NOTARY_ADMIN_ROLE, admin); + } + + function setNotarySigner(address signer, bool active) external onlyRole(NOTARY_ADMIN_ROLE) { + isNotarySigner[signer] = active; + emit NotarySignerSet(signer, active); + } + + function setGlobalNotaryThreshold(uint256 threshold) external onlyRole(NOTARY_ADMIN_ROLE) { + globalNotaryThreshold = threshold; + emit NotaryThresholdUpdated(threshold); + } + + function notarizationKey( + bytes32 jurisdictionId, + bytes32 matrixControlId, + bytes32 contentHash + ) public pure returns (bytes32) { + return keccak256(abi.encode(jurisdictionId, matrixControlId, contentHash)); + } + + function notarizeAttested( + bytes32 jurisdictionId, + bytes32 matrixControlId, + bytes32 contentHash, + bytes32 merkleRoot, + bytes32 metadataHash, + NotarizationKind kind, + uint256 nonce, + bytes[] calldata signatures + ) external returns (bytes32 key) { + key = notarizationKey(jurisdictionId, matrixControlId, contentHash); + require( + _records[key].contentHash == bytes32(0) || _records[key].revoked, + "OMNLNotaryRegistry: active notarization exists" + ); + require(nonce == notarizationNonce[key], "OMNLNotaryRegistry: nonce"); + + uint256 threshold = _effectiveNotaryThreshold(jurisdictionId); + require(threshold > 0, "OMNLNotaryRegistry: threshold off"); + _verifyThresholdSignatures( + jurisdictionId, + matrixControlId, + contentHash, + merkleRoot, + metadataHash, + nonce, + signatures, + threshold + ); + + uint256 nextVersion = (_records[key].version) + 1; + notarizationNonce[key] = nonce + 1; + + _records[key] = NotarizationRecord({ + jurisdictionId: jurisdictionId, + matrixControlId: matrixControlId, + contentHash: contentHash, + merkleRoot: merkleRoot, + metadataHash: metadataHash, + kind: kind, + notarizedAt: block.timestamp, + submitter: msg.sender, + version: nextVersion, + revoked: false + }); + + emit Notarized(key, jurisdictionId, matrixControlId, contentHash, merkleRoot, kind, nextVersion, msg.sender); + } + + function revokeNotarization(bytes32 key) external onlyRole(NOTARY_ADMIN_ROLE) { + require(_records[key].contentHash != bytes32(0), "OMNLNotaryRegistry: missing"); + _records[key].revoked = true; + emit NotarizationRevoked(key, msg.sender); + } + + function getNotarization(bytes32 key) external view returns (NotarizationRecord memory) { + return _records[key]; + } + + function isNotarized(bytes32 jurisdictionId, bytes32 matrixControlId, bytes32 contentHash) + external + view + returns (bool) + { + bytes32 key = notarizationKey(jurisdictionId, matrixControlId, contentHash); + NotarizationRecord memory r = _records[key]; + return r.contentHash != bytes32(0) && !r.revoked; + } + + function _effectiveNotaryThreshold(bytes32 jurisdictionId) internal view returns (uint256) { + OMNLJurisdictionPolicyRegistry.JurisdictionPolicy memory p = jurisdictionRegistry.getPolicy(jurisdictionId); + if (p.notaryThreshold > 0) return uint256(p.notaryThreshold); + return globalNotaryThreshold; + } + + function _verifyThresholdSignatures( + bytes32 jurisdictionId, + bytes32 matrixControlId, + bytes32 contentHash, + bytes32 merkleRoot, + bytes32 metadataHash, + uint256 nonce, + bytes[] calldata signatures, + uint256 threshold + ) internal view { + bytes32 digest = keccak256( + abi.encode( + NOTARIZATION_TYPEHASH, + block.chainid, + address(this), + jurisdictionId, + matrixControlId, + contentHash, + merkleRoot, + metadataHash, + nonce + ) + ); + bytes32 ethSignedMessageHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); + + uint256 n = signatures.length; + require(n >= threshold, "OMNLNotaryRegistry: sig count"); + address[] memory seen = new address[](n); + uint256 uniq = 0; + + for (uint256 i = 0; i < n; i++) { + address recovered = ECDSA.recover(ethSignedMessageHash, signatures[i]); + require(isNotarySigner[recovered], "OMNLNotaryRegistry: not signer"); + for (uint256 j = 0; j < uniq; j++) { + require(seen[j] != recovered, "OMNLNotaryRegistry: duplicate signer"); + } + seen[uniq++] = recovered; + } + require(uniq >= threshold, "OMNLNotaryRegistry: threshold"); + } +} diff --git a/contracts/hybx-omnl/ReserveCommitmentStore.sol b/contracts/hybx-omnl/ReserveCommitmentStore.sol index c12cb93..67bb9bb 100644 --- a/contracts/hybx-omnl/ReserveCommitmentStore.sol +++ b/contracts/hybx-omnl/ReserveCommitmentStore.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.20; import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {IOMNLNotaryRegistry} from "./interfaces/IOMNLNotaryRegistry.sol"; /** * @title ReserveCommitmentStore @@ -26,6 +27,10 @@ contract ReserveCommitmentStore is AccessControl { } address public mirrorReceiver; + IOMNLNotaryRegistry public notaryRegistry; + bool public requireNotarizedEvidence; + bytes32 public defaultJurisdictionId; + bytes32 public defaultMatrixControlId; mapping(bytes32 lineId => Commitment) private _commitments; mapping(bytes32 lineId => uint256) public lineAttestationNonce; @@ -45,6 +50,12 @@ contract ReserveCommitmentStore is AccessControl { event MirrorReceiverUpdated(address indexed oldReceiver, address indexed newReceiver); event AttestationSignersUpdated(uint256 threshold); event AttestationSignerSet(address indexed signer, bool active); + event NotaryGateConfigured( + address indexed notaryRegistry, + bool requireNotarizedEvidence, + bytes32 defaultJurisdictionId, + bytes32 defaultMatrixControlId + ); constructor(address admin) { require(admin != address(0), "ReserveCommitmentStore: zero admin"); @@ -69,6 +80,19 @@ contract ReserveCommitmentStore is AccessControl { emit AttestationSignersUpdated(threshold); } + function configureNotaryGate( + address registry, + bool required, + bytes32 jurisdictionId, + bytes32 matrixControlId + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + notaryRegistry = IOMNLNotaryRegistry(registry); + requireNotarizedEvidence = required; + defaultJurisdictionId = jurisdictionId; + defaultMatrixControlId = matrixControlId; + emit NotaryGateConfigured(registry, required, jurisdictionId, matrixControlId); + } + /// @notice Primary chain (or designated committer) updates reserves. function commitReserve( bytes32 lineId, @@ -138,6 +162,13 @@ contract ReserveCommitmentStore is AccessControl { bytes32 merkleRoot, address by ) internal { + if (requireNotarizedEvidence) { + require(address(notaryRegistry) != address(0), "ReserveCommitmentStore: notary unset"); + require( + notaryRegistry.isNotarized(defaultJurisdictionId, defaultMatrixControlId, evidenceHash), + "ReserveCommitmentStore: evidence not notarized" + ); + } Commitment storage c = _commitments[lineId]; unchecked { c.version += 1; diff --git a/contracts/hybx-omnl/interfaces/IOMNLNotaryRegistry.sol b/contracts/hybx-omnl/interfaces/IOMNLNotaryRegistry.sol new file mode 100644 index 0000000..4d591ce --- /dev/null +++ b/contracts/hybx-omnl/interfaces/IOMNLNotaryRegistry.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IOMNLNotaryRegistry { + function isNotarized(bytes32 jurisdictionId, bytes32 matrixControlId, bytes32 contentHash) + external + view + returns (bool); +} diff --git a/contracts/m00-diamond/M00BridgeStorage.sol b/contracts/m00-diamond/M00BridgeStorage.sol new file mode 100644 index 0000000..6a93f04 --- /dev/null +++ b/contracts/m00-diamond/M00BridgeStorage.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title M00BridgeStorage + * @notice Namespaced wiring for Chain 138 ↔ Ethereum Mainnet mirror / tether / checkpoint v2. + */ +library M00BridgeStorage { + /// @custom:storage-location erc7201:dbis.storage.M00Bridge + bytes32 private constant SLOT = + 0x4d30304272696467650000000000000000000000000000000000000000000000; + + struct Layout { + address chain138BatchEmitter; + address chain138Mirror; + address mainnetCheckpoint; + address mainnetMirror; + address mainnetTether; + address rwaTokenFactory; + address rwaTokenRegistry; + uint64 lastCommittedBatchId; + uint256 lastAnchoredBlock138; + } + + function layout() internal pure returns (Layout storage l) { + bytes32 slot = SLOT; + assembly { + l.slot := slot + } + } +} diff --git a/contracts/m00-diamond/M00DiamondInit.sol b/contracts/m00-diamond/M00DiamondInit.sol new file mode 100644 index 0000000..73117b7 --- /dev/null +++ b/contracts/m00-diamond/M00DiamondInit.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "./interfaces/IM00MainnetBridgeFacet.sol"; + +/** + * @title M00DiamondInit + * @notice diamondCut _init delegatecall: bootstrap bridge ACL on diamond storage, then wire config. + */ +contract M00DiamondInit is AccessControl { + bytes32 public constant BRIDGE_OPERATOR_ROLE = keccak256("BRIDGE_OPERATOR_ROLE"); + bytes32 public constant MIRROR_RELAYER_ROLE = keccak256("MIRROR_RELAYER_ROLE"); + bytes32 public constant INSTRUMENT_ADMIN_ROLE = keccak256("INSTRUMENT_ADMIN_ROLE"); + bytes32 public constant DOCUMENT_ADMIN_ROLE = keccak256("DOCUMENT_ADMIN_ROLE"); + bytes32 public constant STANDARDS_ADMIN_ROLE = keccak256("STANDARDS_ADMIN_ROLE"); + + function init(IM00MainnetBridgeFacet.BridgeConfig calldata cfg, address governance) external { + address grantee = governance == address(0) ? msg.sender : governance; + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + _grantRole(DEFAULT_ADMIN_ROLE, grantee); + _grantRole(DEFAULT_ADMIN_ROLE, address(this)); + _grantRole(BRIDGE_OPERATOR_ROLE, grantee); + _grantRole(MIRROR_RELAYER_ROLE, grantee); + _grantRole(INSTRUMENT_ADMIN_ROLE, grantee); + _grantRole(DOCUMENT_ADMIN_ROLE, grantee); + _grantRole(STANDARDS_ADMIN_ROLE, grantee); + IM00MainnetBridgeFacet(address(this)).wireMainnetBridge(cfg); + } +} diff --git a/contracts/m00-diamond/facets/M00MainnetBridgeFacet.sol b/contracts/m00-diamond/facets/M00MainnetBridgeFacet.sol new file mode 100644 index 0000000..13cb339 --- /dev/null +++ b/contracts/m00-diamond/facets/M00MainnetBridgeFacet.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "../M00BridgeStorage.sol"; +import "../interfaces/IM00MainnetBridgeFacet.sol"; +import "../interfaces/IChain138BatchEmitterBridge.sol"; +import "../interfaces/ILegacyMainnetMirror.sol"; + +/** + * @title M00MainnetBridgeFacet + * @notice ERC-2535 facet: two-way Chain 138 ↔ Mainnet attestation path. + * @dev Outbound: Chain138BatchEmitter (CCIP) + optional direct v1 mirror/tether relay on mainnet. + * Inbound: ackInboundCheckpoint after CCIP delivery (relayer/emitter calls on 138). + * Full tx mirror on mainnet is executed by operator/aggregator with MAINNET mirror admin — hub schedules + correlates. + */ +contract M00MainnetBridgeFacet is AccessControl, IM00MainnetBridgeFacet { + bytes32 public constant BRIDGE_OPERATOR_ROLE = keccak256("BRIDGE_OPERATOR_ROLE"); + bytes32 public constant MIRROR_RELAYER_ROLE = keccak256("MIRROR_RELAYER_ROLE"); + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(BRIDGE_OPERATOR_ROLE, admin); + _grantRole(MIRROR_RELAYER_ROLE, admin); + } + + function wireMainnetBridge(BridgeConfig calldata cfg) external onlyRole(DEFAULT_ADMIN_ROLE) { + M00BridgeStorage.Layout storage l = M00BridgeStorage.layout(); + l.chain138BatchEmitter = cfg.chain138BatchEmitter; + l.chain138Mirror = cfg.chain138Mirror; + l.mainnetCheckpoint = cfg.mainnetCheckpoint; + l.mainnetMirror = cfg.mainnetMirror; + l.mainnetTether = cfg.mainnetTether; + l.rwaTokenFactory = cfg.rwaTokenFactory; + l.rwaTokenRegistry = cfg.rwaTokenRegistry; + emit MainnetBridgeWired(cfg); + } + + function getMainnetBridgeConfig() external view returns (BridgeConfig memory) { + M00BridgeStorage.Layout storage l = M00BridgeStorage.layout(); + return BridgeConfig({ + chain138BatchEmitter: l.chain138BatchEmitter, + chain138Mirror: l.chain138Mirror, + mainnetCheckpoint: l.mainnetCheckpoint, + mainnetMirror: l.mainnetMirror, + mainnetTether: l.mainnetTether, + rwaTokenFactory: l.rwaTokenFactory, + rwaTokenRegistry: l.rwaTokenRegistry + }); + } + + function commitBatchOn138( + uint64 batchId, + bytes32 paymentsRoot, + uint256 checkpointBlock, + uint256 startBlock, + uint16 txCount, + bytes32[] calldata txHashes + ) external onlyRole(BRIDGE_OPERATOR_ROLE) { + address emitter = M00BridgeStorage.layout().chain138BatchEmitter; + require(emitter != address(0), "M00Bridge: emitter"); + IChain138BatchEmitterBridge(emitter).commitBatch( + batchId, paymentsRoot, checkpointBlock, startBlock, txCount, txHashes + ); + M00BridgeStorage.layout().lastCommittedBatchId = batchId; + M00BridgeStorage.layout().lastAnchoredBlock138 = checkpointBlock; + emit BatchCommittedOnHub(batchId, paymentsRoot, checkpointBlock); + } + + function sendBatchToMainnet(uint64 batchId, bytes calldata encodedCheckpointPayload, uint256 linkFee) + external + onlyRole(BRIDGE_OPERATOR_ROLE) + returns (bytes32 ccipMessageId) + { + address emitter = M00BridgeStorage.layout().chain138BatchEmitter; + require(emitter != address(0), "M00Bridge: emitter"); + return IChain138BatchEmitterBridge(emitter).sendBatchToMainnet(batchId, encodedCheckpointPayload, linkFee); + } + + /// @notice Record mirror intent; relayer with MIRROR_RELAYER_ROLE calls mirrorOnMainnet on mainnet. + function scheduleMirrorToMainnet( + bytes32 txHash, + address from, + address to, + uint256 value, + uint256 blockNumber, + uint256 blockTimestamp, + uint256 gasUsed, + bool success, + bytes calldata data + ) external onlyRole(BRIDGE_OPERATOR_ROLE) { + txHash; + from; + to; + value; + blockTimestamp; + gasUsed; + success; + data; + emit OutboundMirrorScheduled(txHash, blockNumber); + } + + /// @notice Relayer executes v1 TransactionMirror.mirrorTransaction on Ethereum mainnet (separate chain). + function mirrorOnMainnet( + bytes32 txHash, + address from, + address to, + uint256 value, + uint256 blockNumber, + uint256 blockTimestamp, + uint256 gasUsed, + bool success, + bytes calldata data + ) external onlyRole(MIRROR_RELAYER_ROLE) { + address mirror = M00BridgeStorage.layout().mainnetMirror; + require(mirror != address(0), "M00Bridge: mirror"); + ILegacyMainnetMirror(mirror).mirrorTransaction( + txHash, from, to, value, blockNumber, blockTimestamp, gasUsed, success, data + ); + } + + function anchorStateProofOnMainnet( + uint256 blockNumber, + bytes32 blockHash, + bytes32 stateRoot, + bytes32 previousBlockHash, + uint256 timestamp, + bytes calldata signatures, + uint256 validatorCount + ) external onlyRole(MIRROR_RELAYER_ROLE) { + address tether = M00BridgeStorage.layout().mainnetTether; + require(tether != address(0), "M00Bridge: tether"); + ILegacyMainnetTether(tether).anchorStateProof( + blockNumber, blockHash, stateRoot, previousBlockHash, timestamp, signatures, validatorCount + ); + M00BridgeStorage.layout().lastAnchoredBlock138 = blockNumber; + } + + function ackInboundCheckpoint(uint64 batchId, bytes32 paymentsRoot) + external + onlyRole(BRIDGE_OPERATOR_ROLE) + { + emit InboundCheckpointAck(batchId, paymentsRoot); + } +} diff --git a/contracts/m00-diamond/interfaces/IChain138BatchEmitterBridge.sol b/contracts/m00-diamond/interfaces/IChain138BatchEmitterBridge.sol new file mode 100644 index 0000000..59138d8 --- /dev/null +++ b/contracts/m00-diamond/interfaces/IChain138BatchEmitterBridge.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IChain138BatchEmitterBridge { + function commitBatch( + uint64 batchId, + bytes32 paymentsRoot, + uint256 checkpointBlock, + uint256 startBlock, + uint16 txCount, + bytes32[] calldata txHashes + ) external; + + function sendBatchToMainnet(uint64 batchId, bytes calldata encodedCheckpointPayload, uint256 linkFee) + external + returns (bytes32 ccipMessageId); +} diff --git a/contracts/m00-diamond/interfaces/ILegacyMainnetMirror.sol b/contracts/m00-diamond/interfaces/ILegacyMainnetMirror.sol new file mode 100644 index 0000000..76d6cd0 --- /dev/null +++ b/contracts/m00-diamond/interfaces/ILegacyMainnetMirror.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @notice Minimal v1 TransactionMirror (mainnet). +interface ILegacyMainnetMirror { + function mirrorTransaction( + bytes32 txHash, + address from, + address to, + uint256 value, + uint256 blockNumber, + uint256 blockTimestamp, + uint256 gasUsed, + bool success, + bytes calldata data + ) external; + + function mirrorBatchTransactions( + bytes32[] calldata txHashes, + address[] calldata froms, + address[] calldata tos, + uint256[] calldata values, + uint256[] calldata blockNumbers, + uint256[] calldata blockTimestamps, + uint256[] calldata gasUseds, + bool[] calldata successes, + bytes[] calldata datas + ) external; +} + +/// @notice Minimal v1 MainnetTether (mainnet). +interface ILegacyMainnetTether { + function anchorStateProof( + uint256 blockNumber, + bytes32 blockHash, + bytes32 stateRoot, + bytes32 previousBlockHash, + uint256 timestamp, + bytes calldata signatures, + uint256 validatorCount + ) external; +} diff --git a/contracts/m00-diamond/interfaces/IM00MainnetBridgeFacet.sol b/contracts/m00-diamond/interfaces/IM00MainnetBridgeFacet.sol new file mode 100644 index 0000000..6ab8faf --- /dev/null +++ b/contracts/m00-diamond/interfaces/IM00MainnetBridgeFacet.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IM00MainnetBridgeFacet { + struct BridgeConfig { + address chain138BatchEmitter; + address chain138Mirror; + address mainnetCheckpoint; + address mainnetMirror; + address mainnetTether; + address rwaTokenFactory; + address rwaTokenRegistry; + } + + event MainnetBridgeWired(BridgeConfig config); + event BatchCommittedOnHub(uint64 indexed batchId, bytes32 paymentsRoot, uint256 checkpointBlock); + event OutboundMirrorScheduled(bytes32 indexed txHash, uint256 blockNumber); + event InboundCheckpointAck(uint64 indexed batchId, bytes32 paymentsRoot); + + function wireMainnetBridge(BridgeConfig calldata cfg) external; + function getMainnetBridgeConfig() external view returns (BridgeConfig memory); + function commitBatchOn138( + uint64 batchId, + bytes32 paymentsRoot, + uint256 checkpointBlock, + uint256 startBlock, + uint16 txCount, + bytes32[] calldata txHashes + ) external; + function sendBatchToMainnet(uint64 batchId, bytes calldata encodedCheckpointPayload, uint256 linkFee) + external + returns (bytes32 ccipMessageId); + function scheduleMirrorToMainnet( + bytes32 txHash, + address from, + address to, + uint256 value, + uint256 blockNumber, + uint256 blockTimestamp, + uint256 gasUsed, + bool success, + bytes calldata data + ) external; + function ackInboundCheckpoint(uint64 batchId, bytes32 paymentsRoot) external; +} diff --git a/contracts/mainnet-checkpoint/AddressActivityRegistry.sol b/contracts/mainnet-checkpoint/AddressActivityRegistry.sol new file mode 100644 index 0000000..8086455 --- /dev/null +++ b/contracts/mainnet-checkpoint/AddressActivityRegistry.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title AddressActivityRegistry + * @notice Emits Etherscan-indexed Chain 138 payment activity tied to participant addresses (ETH wei + USD e8). + * @dev Called by checkpoint aggregator after each batch submit and optional v1 TransactionMirror dual-write. + * Does not move mainnet ETH — attestation / visibility only. + */ +contract AddressActivityRegistry { + uint64 public constant CHAIN_ID = 138; + uint8 public constant USD_DECIMALS = 8; + + address public admin; + bool public paused; + + struct ActivityRecord { + bytes32 txHash; + address from; + address to; + uint256 valueWei; + uint256 blockNumber138; + uint64 blockTimestamp138; + uint64 valueUsdE8; + uint32 logCount; + bytes32 receiptHash; + } + + mapping(bytes32 => bool) public recorded; + mapping(address => bytes32[]) public participantTxHashes; + mapping(bytes32 => ActivityRecord) public activities; + uint256 public totalRecorded; + + event AdminChanged(address indexed newAdmin); + event Paused(); + event Unpaused(); + + /// @notice Indexed for Etherscan lookup by participant address (topic2 = participant). + /// @param chain138TxHash Chain 138 transaction hash (view at explorer.d-bis.org/tx/0x…) + event ParticipantDebited( + bytes32 indexed chain138TxHash, + address indexed counterparty, + address indexed participant, + uint64 batchId, + uint256 valueWei, + uint64 valueUsdE8, + uint32 logCount, + bytes32 receiptHash, + uint256 blockNumber138 + ); + + /// @param chain138TxHash Chain 138 transaction hash (view at explorer.d-bis.org/tx/0x…) + event ParticipantCredited( + bytes32 indexed chain138TxHash, + address indexed participant, + address indexed counterparty, + uint64 batchId, + uint256 valueWei, + uint64 valueUsdE8, + uint32 logCount, + bytes32 receiptHash, + uint256 blockNumber138 + ); + + event BatchActivityRecorded(uint64 indexed batchId, uint256 count, uint256 totalValueWei, uint64 totalUsdE8); + + modifier onlyAdmin() { + require(msg.sender == admin, "only admin"); + _; + } + + modifier whenNotPaused() { + require(!paused, "paused"); + _; + } + + constructor(address _admin) { + require(_admin != address(0), "zero admin"); + admin = _admin; + } + + function setAdmin(address newAdmin) external onlyAdmin { + require(newAdmin != address(0), "zero admin"); + admin = newAdmin; + emit AdminChanged(newAdmin); + } + + function pause() external onlyAdmin { + paused = true; + emit Paused(); + } + + function unpause() external onlyAdmin { + paused = false; + emit Unpaused(); + } + + /** + * @notice Record Chain 138 activity for a checkpoint batch (one mainnet tx per batch). + * @param batchId Checkpoint hub batch id + * @param records Parallel activity rows (from 138 txs + off-chain USD enrichment) + */ + function recordBatch(uint64 batchId, ActivityRecord[] calldata records) external onlyAdmin whenNotPaused { + uint256 batchValueWei; + uint64 batchUsdE8; + uint256 n = records.length; + for (uint256 i = 0; i < n; i++) { + ActivityRecord calldata r = records[i]; + require(r.txHash != bytes32(0), "invalid hash"); + require(!recorded[r.txHash], "already recorded"); + + recorded[r.txHash] = true; + activities[r.txHash] = r; + totalRecorded++; + + batchValueWei += r.valueWei; + batchUsdE8 += r.valueUsdE8; + + participantTxHashes[r.from].push(r.txHash); + participantTxHashes[r.to].push(r.txHash); + + emit ParticipantDebited( + r.txHash, + r.to, + r.from, + batchId, + r.valueWei, + r.valueUsdE8, + r.logCount, + r.receiptHash, + r.blockNumber138 + ); + emit ParticipantCredited( + r.txHash, + r.to, + r.from, + batchId, + r.valueWei, + r.valueUsdE8, + r.logCount, + r.receiptHash, + r.blockNumber138 + ); + } + emit BatchActivityRecorded(batchId, n, batchValueWei, batchUsdE8); + } + + function getParticipantTxCount(address participant) external view returns (uint256) { + return participantTxHashes[participant].length; + } + + function getParticipantTxHash(address participant, uint256 index) external view returns (bytes32) { + return participantTxHashes[participant][index]; + } + + function getActivity(bytes32 txHash) external view returns (ActivityRecord memory) { + return activities[txHash]; + } +} diff --git a/contracts/mainnet-checkpoint/AddressActivityRegistryV2.sol b/contracts/mainnet-checkpoint/AddressActivityRegistryV2.sol new file mode 100644 index 0000000..6afb189 --- /dev/null +++ b/contracts/mainnet-checkpoint/AddressActivityRegistryV2.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title AddressActivityRegistryV2 + * @notice MT-103 / pacs.008-equivalent attestation on mainnet (indexed instructionId + UETR + payloadHash). + * @dev Complements V1 registry; does not move mainnet ETH. + */ +contract AddressActivityRegistryV2 { + uint64 public constant CHAIN_ID = 138; + uint8 public constant MSG_MT103 = 1; + uint8 public constant MSG_PACS008 = 2; + uint8 public constant MSG_PAIN001 = 3; + uint8 public constant MSG_CHAIN138_SYNTH = 4; + + address public admin; + bool public paused; + + struct IsoAttestationRecord { + bytes32 txHash; + address from; + address to; + uint256 valueWei; + uint256 blockNumber138; + uint64 blockTimestamp138; + uint64 valueUsdE8; + uint32 logCount; + bytes32 receiptHash; + bytes32 instructionId; + bytes32 endToEndIdHash; + bytes32 uetr; + bytes32 payloadHash; + uint8 msgTypeCode; + bytes32 debtorRefHash; + bytes32 creditorRefHash; + bytes32 purposeHash; + } + + mapping(bytes32 => bool) public recorded; + mapping(bytes32 => IsoAttestationRecord) public attestations; + mapping(address => bytes32[]) public participantTxHashes; + mapping(bytes32 => bool) public processedInstructions; + uint256 public totalRecorded; + + event AdminChanged(address indexed newAdmin); + event Paused(); + event Unpaused(); + + /// @param chain138TxHash Chain 138 tx (topic1 on Etherscan) + /// @param instructionId MT :20 / pacs.008 InstrId (hashed if needed) + /// @param uetr SWIFT UETR (bytes32; zero if N/A) + event PaymentAttested( + bytes32 indexed chain138TxHash, + bytes32 indexed instructionId, + address indexed creditor, + address debtor, + bytes32 uetr, + uint64 batchId, + uint8 msgTypeCode, + uint256 valueWei, + uint64 valueUsdE8, + bytes32 payloadHash, + bytes32 endToEndIdHash, + bytes32 debtorRefHash, + bytes32 creditorRefHash, + bytes32 purposeHash, + bytes32 receiptHash, + uint256 blockNumber138 + ); + + event BatchIsoAttestationRecorded(uint64 indexed batchId, uint256 count, bytes32 batchPayloadRoot); + + modifier onlyAdmin() { + require(msg.sender == admin, "only admin"); + _; + } + + modifier whenNotPaused() { + require(!paused, "paused"); + _; + } + + constructor(address _admin) { + require(_admin != address(0), "zero admin"); + admin = _admin; + } + + function setAdmin(address newAdmin) external onlyAdmin { + require(newAdmin != address(0), "zero admin"); + admin = newAdmin; + emit AdminChanged(newAdmin); + } + + function pause() external onlyAdmin { + paused = true; + emit Paused(); + } + + function unpause() external onlyAdmin { + paused = false; + emit Unpaused(); + } + + function recordBatch(uint64 batchId, IsoAttestationRecord[] calldata records) external onlyAdmin whenNotPaused { + uint256 n = records.length; + bytes32 batchRoot; + for (uint256 i = 0; i < n; i++) { + IsoAttestationRecord calldata r = records[i]; + require(r.txHash != bytes32(0), "invalid hash"); + require(!recorded[r.txHash], "already recorded"); + require(!processedInstructions[r.instructionId], "instruction used"); + recorded[r.txHash] = true; + processedInstructions[r.instructionId] = true; + attestations[r.txHash] = r; + totalRecorded++; + participantTxHashes[r.from].push(r.txHash); + participantTxHashes[r.to].push(r.txHash); + batchRoot = keccak256(abi.encodePacked(batchRoot, r.payloadHash)); + emit PaymentAttested( + r.txHash, + r.instructionId, + r.to, + r.from, + r.uetr, + batchId, + r.msgTypeCode, + r.valueWei, + r.valueUsdE8, + r.payloadHash, + r.endToEndIdHash, + r.debtorRefHash, + r.creditorRefHash, + r.purposeHash, + r.receiptHash, + r.blockNumber138 + ); + } + emit BatchIsoAttestationRecorded(batchId, n, batchRoot); + } + + function getAttestation(bytes32 txHash) external view returns (IsoAttestationRecord memory) { + return attestations[txHash]; + } +} diff --git a/contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol b/contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol new file mode 100644 index 0000000..1d02762 --- /dev/null +++ b/contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol @@ -0,0 +1,549 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import "../vendor/openzeppelin/UUPSUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "../vendor/openzeppelin/ReentrancyGuardUpgradeable.sol"; +import "../ccip/IRouterClient.sol"; + +import {CheckpointStorage} from "./storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "./libraries/CheckpointLeaf.sol"; +import {CheckpointFlags} from "./libraries/CheckpointFlags.sol"; +import {ICheckpointExtension} from "./interfaces/ICheckpointExtension.sol"; +import {IChain138MainnetCheckpoint} from "./interfaces/IChain138MainnetCheckpoint.sol"; +import {CheckpointEIP712} from "./libraries/CheckpointEIP712.sol"; +import {CheckpointHubConfig} from "./libraries/CheckpointHubConfig.sol"; +import {CheckpointErrors} from "./libraries/CheckpointErrors.sol"; +import {CheckpointPaymentsLib} from "./libraries/CheckpointPaymentsLib.sol"; +import {ExtensionIds} from "./libraries/ExtensionIds.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/** + * @title Chain138MainnetCheckpoint + * @notice Upgradeable hub: state + payment batches from Chain 138 (default 10 txs). + * @dev UUPS + EIP-7201 storage + extension registry. cW mint remains on CWMultiTokenBridge. + */ +contract Chain138MainnetCheckpoint is + Initializable, + AccessControlUpgradeable, + ReentrancyGuardUpgradeable, + UUPSUpgradeable, + IChain138MainnetCheckpoint +{ + using CheckpointStorage for CheckpointStorage.CheckpointStorageStruct; + + uint256 public constant IMPLEMENTATION_VERSION = 4; + uint64 public constant CHAIN_138 = 138; + + bytes32 public constant SUBMITTER_ROLE = keccak256("SUBMITTER_ROLE"); + bytes32 public constant RELAYER_ROLE = keccak256("RELAYER_ROLE"); + bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); + bytes32 public constant EXTENSION_ADMIN_ROLE = keccak256("EXTENSION_ADMIN_ROLE"); + bytes32 public constant EMERGENCY_ROLE = keccak256("EMERGENCY_ROLE"); + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + + bytes32 public constant CHECKPOINT_ATTEST_TYPEHASH = + keccak256("BatchAttestation(uint64 chainId,uint64 batchId,uint256 checkpointBlock,bytes32 blockHash,bytes32 stateRoot,bytes32 paymentsRoot,uint64 previousBatchId)"); + + uint32 public constant HOOK_BEFORE_SUBMIT = 1 << 0; + uint32 public constant HOOK_AFTER_SUBMIT = 1 << 1; + uint32 public constant HOOK_ON_CCIP = 1 << 2; + uint32 public constant HOOK_VERIFY_LEAF = 1 << 3; + + event HubConfigApplied(CheckpointHubConfig.HubConfig config); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize( + address admin, + address ccipRouter, + uint64 sourceChainSelector, + address batchEmitterOnSource + ) external initializer { + __AccessControl_init(); + __ReentrancyGuard_init(); + __UUPSUpgradeable_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(UPGRADER_ROLE, admin); + _grantRole(SUBMITTER_ROLE, admin); + _grantRole(EXTENSION_ADMIN_ROLE, admin); + _grantRole(PAUSER_ROLE, admin); + _grantRole(EMERGENCY_ROLE, admin); + + CheckpointStorage.CheckpointStorageStruct storage $ = CheckpointStorage.get(); + $.chainId = CHAIN_138; + $.batchSize = 10; + $.maxBatchWaitSeconds = 300; + $.requireValidatorSigs = true; + $.allowCalldataOnlySubmit = true; + $.allowCCIPIngress = true; + $.enforcePreviousBatchId = true; + $.ccipRouter = ccipRouter; + $.expectedSourceChainSelector = sourceChainSelector; + $.batchEmitterOnSource = batchEmitterOnSource; + } + + function initializeV2(address legacyMirror, address legacyTether, address attestationSigner) external reinitializer(2) { + CheckpointStorage.CheckpointStorageStruct storage $ = CheckpointStorage.get(); + $.legacyMirrorV1 = legacyMirror; + $.legacyTetherV1 = legacyTether; + $.submitterAttestationSigner = attestationSigner; + } + + // --- views --- + + function getConfig() + external + view + returns ( + uint16 batchSize, + uint32 maxBatchWaitSeconds, + uint256 minPaymentValueWei, + bool requireValidatorSigs, + bool allowCalldataOnlySubmit, + bool allowCCIPIngress + ) + { + CheckpointStorage.CheckpointStorageStruct storage $ = CheckpointStorage.get(); + return ( + $.batchSize, + $.maxBatchWaitSeconds, + $.minPaymentValueWei, + $.requireValidatorSigs, + $.allowCalldataOnlySubmit, + $.allowCCIPIngress + ); + } + + function getFullConfig() external view returns (CheckpointHubConfig.HubConfig memory cfg) { + CheckpointStorage.CheckpointStorageStruct storage $ = CheckpointStorage.get(); + cfg.chainId = $.chainId; + cfg.batchSize = $.batchSize; + cfg.maxBatchWaitSeconds = $.maxBatchWaitSeconds; + cfg.minPaymentValueWei = $.minPaymentValueWei; + cfg.requireValidatorSigs = $.requireValidatorSigs; + cfg.allowCalldataOnlySubmit = $.allowCalldataOnlySubmit; + cfg.allowCCIPIngress = $.allowCCIPIngress; + cfg.enforcePreviousBatchId = $.enforcePreviousBatchId; + cfg.ccipRouter = $.ccipRouter; + cfg.sourceChainSelector = $.expectedSourceChainSelector; + cfg.batchEmitterOnSource = $.batchEmitterOnSource; + cfg.legacyMirrorV1 = $.legacyMirrorV1; + cfg.legacyTetherV1 = $.legacyTetherV1; + cfg.submitterAttestationSigner = $.submitterAttestationSigner; + } + + function applyConfig(CheckpointHubConfig.HubConfig calldata cfg) external onlyRole(DEFAULT_ADMIN_ROLE) { + CheckpointHubConfig.validate(cfg); + _applyHubConfig(cfg); + emit HubConfigApplied(cfg); + } + + function extensionCount() external view returns (uint256) { + return CheckpointStorage.get().extensionList.length; + } + + function getExtension(bytes32 extensionId) + external + view + returns (address module, uint32 hooks, bool active) + { + CheckpointStorage.ExtensionConfig storage cfg = CheckpointStorage.get().extensions[extensionId]; + return (cfg.module, cfg.hooks, cfg.active); + } + + function getExtensionList() external view returns (bytes32[] memory) { + return CheckpointStorage.get().extensionList; + } + + function getLatestCheckpoint() external view returns (CheckpointStorage.CheckpointHeader memory) { + CheckpointStorage.CheckpointStorageStruct storage $ = CheckpointStorage.get(); + return $.checkpoints[$.latestBatchId]; + } + + function getCheckpoint(uint64 batchId) external view returns (CheckpointStorage.CheckpointHeader memory) { + return CheckpointStorage.get().checkpoints[batchId]; + } + + function getLatestBatchId() external view returns (uint64) { + return CheckpointStorage.get().latestBatchId; + } + + function latestCheckpointBlock() external view returns (uint256) { + return CheckpointStorage.get().latestCheckpointBlock; + } + + function verifyPaymentInBatch( + uint64 batchId, + CheckpointLeaf.PaymentLeafV1 calldata leaf, + bytes32[] calldata proof + ) external view returns (bool) { + CheckpointStorage.CheckpointHeader storage h = CheckpointStorage.get().checkpoints[batchId]; + if (h.batchId == 0) revert CheckpointErrors.UnknownBatch(); + bytes32 leafHash = CheckpointLeaf.paymentLeafV1(h.chainId, leaf); + CheckpointStorage.CheckpointHeader memory hm = h; + if (_extensionVerifyLeaf(hm, leaf, proof)) return true; + return CheckpointLeaf.verifyMerkle(h.paymentsRoot, leafHash, proof); + } + + function isTxIncluded(bytes32 txHash) external view returns (bool included, uint64 batchId) { + batchId = CheckpointStorage.get().txHashToBatchId[txHash]; + included = batchId != 0; + } + + // --- submit --- + + function paused() external view returns (bool) { + return CheckpointStorage.get().paused; + } + + function pause() external onlyRole(PAUSER_ROLE) { + CheckpointStorage.get().paused = true; + } + + function unpause() external onlyRole(PAUSER_ROLE) { + CheckpointStorage.get().paused = false; + } + + function submitCheckpoint( + CheckpointStorage.CheckpointHeader calldata header, + bytes calldata validatorSignatures, + bytes32[] calldata txHashes, + bytes calldata extensionData + ) external onlyRole(SUBMITTER_ROLE) nonReentrant { + _requireNotPaused(); + _runBeforeSubmitExtensions(header, validatorSignatures, extensionData); + _submit(header, validatorSignatures, txHashes, bytes32(0), false, false, extensionData); + _runExtensions(HOOK_AFTER_SUBMIT, header, extensionData); + } + + function submitCheckpointByRelayer( + CheckpointStorage.CheckpointHeader calldata header, + bytes calldata validatorSignatures, + bytes32[] calldata txHashes, + bytes calldata extensionData, + bytes calldata submitterSignature + ) external onlyRole(RELAYER_ROLE) nonReentrant { + _requireNotPaused(); + _verifySubmitterAttestation(header, submitterSignature); + CheckpointStorage.CheckpointHeader memory h = header; + h.flags |= CheckpointFlags.RELAYER_SUBMIT; + _runBeforeSubmitExtensions(h, validatorSignatures, extensionData); + _submit(h, validatorSignatures, txHashes, bytes32(0), false, false, extensionData); + _runExtensions(HOOK_AFTER_SUBMIT, h, extensionData); + } + + function submitCheckpointWithLeaves( + CheckpointStorage.CheckpointHeader calldata header, + bytes calldata validatorSignatures, + bytes32[] calldata txHashes, + CheckpointLeaf.PaymentLeafV1[] calldata leaves + ) external onlyRole(SUBMITTER_ROLE) nonReentrant { + _requireNotPaused(); + CheckpointPaymentsLib.assertPaymentsRootV1(header.chainId, header.paymentsRoot, leaves); + bytes memory leafPayload = abi.encode(leaves); + _runBeforeSubmitExtensions(header, validatorSignatures, leafPayload); + _submit(header, validatorSignatures, txHashes, bytes32(0), false, false, leafPayload); + _runExtensions(HOOK_AFTER_SUBMIT, header, leafPayload); + } + + function forceCheckpoint( + CheckpointStorage.CheckpointHeader calldata header, + bytes calldata validatorSignatures, + bytes32[] calldata txHashes, + bytes calldata extensionData + ) external onlyRole(EMERGENCY_ROLE) nonReentrant { + CheckpointStorage.CheckpointHeader memory h = header; + h.flags |= CheckpointFlags.EMERGENCY; + _runBeforeSubmitExtensions(h, validatorSignatures, extensionData); + _submit(h, validatorSignatures, txHashes, bytes32(0), false, true, extensionData); + _runExtensions(HOOK_AFTER_SUBMIT, h, extensionData); + } + + function submitCheckpointCommitment( + CheckpointStorage.CheckpointHeader calldata header, + bytes calldata validatorSignatures, + bytes32 contentURI, + bytes calldata extensionData + ) external onlyRole(SUBMITTER_ROLE) nonReentrant { + _requireNotPaused(); + CheckpointStorage.CheckpointStorageStruct storage $ = CheckpointStorage.get(); + if (!$.allowCalldataOnlySubmit) revert CheckpointErrors.CalldataOnlyDisabled(); + _runBeforeSubmitExtensions(header, validatorSignatures, extensionData); + _submit(header, validatorSignatures, new bytes32[](0), contentURI, true, false, extensionData); + _runExtensions(HOOK_AFTER_SUBMIT, header, extensionData); + } + + function ccipReceive(IRouterClient.Any2EVMMessage calldata message) external nonReentrant { + _requireNotPaused(); + CheckpointStorage.CheckpointStorageStruct storage $ = CheckpointStorage.get(); + if (!$.allowCCIPIngress) revert CheckpointErrors.CcipDisabled(); + if (msg.sender != $.ccipRouter) revert CheckpointErrors.OnlyRouter(); + if (message.sourceChainSelector != $.expectedSourceChainSelector) revert CheckpointErrors.BadSelector(); + + address sender = message.sender.length >= 32 + ? address(uint160(uint256(bytes32(message.sender)))) + : address(bytes20(message.sender)); + if (sender != $.batchEmitterOnSource) revert CheckpointErrors.BadEmitter(); + + ( + CheckpointStorage.CheckpointHeader memory header, + bytes memory validatorSignatures, + bytes32[] memory txHashes, + bytes32 contentURI, + bytes memory extensionData + ) = abi.decode( + message.data, + (CheckpointStorage.CheckpointHeader, bytes, bytes32[], bytes32, bytes) + ); + + header.flags |= CheckpointFlags.CCIP_INGRESS; + _runExtensions(HOOK_ON_CCIP, header, message.data); + _runBeforeSubmitExtensions(header, validatorSignatures, extensionData); + _submit( + header, + validatorSignatures, + txHashes, + contentURI, + CheckpointFlags.has(header.flags, CheckpointFlags.CALLDATA_ONLY), + false, + extensionData + ); + _runExtensions(HOOK_AFTER_SUBMIT, header, extensionData); + } + + // --- extensions --- + + function registerExtension(bytes32 extensionId, address module, uint32 hooks) external onlyRole(EXTENSION_ADMIN_ROLE) { + if (module == address(0)) revert CheckpointErrors.ZeroModule(); + CheckpointStorage.CheckpointStorageStruct storage $ = CheckpointStorage.get(); + if ($.extensions[extensionId].active) revert CheckpointErrors.ExtensionExists(); + $.extensions[extensionId] = CheckpointStorage.ExtensionConfig({module: module, hooks: hooks, active: true}); + $.extensionList.push(extensionId); + emit ExtensionRegistered(extensionId, module, hooks); + } + + function revokeExtension(bytes32 extensionId) external onlyRole(EXTENSION_ADMIN_ROLE) { + CheckpointStorage.CheckpointStorageStruct storage $ = CheckpointStorage.get(); + if (!$.extensions[extensionId].active) revert CheckpointErrors.ExtensionMissing(); + delete $.extensions[extensionId]; + bytes32[] storage list = $.extensionList; + for (uint256 i = 0; i < list.length; i++) { + if (list[i] == extensionId) { + list[i] = list[list.length - 1]; + list.pop(); + break; + } + } + emit ExtensionRevoked(extensionId); + } + + function setConfig( + uint16 batchSize, + uint32 maxBatchWaitSeconds, + uint256 minPaymentValueWei, + bool requireValidatorSigs, + bool allowCalldataOnlySubmit, + bool allowCCIPIngress + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (batchSize == 0 || batchSize > 256) revert CheckpointErrors.BatchSize(); + CheckpointStorage.CheckpointStorageStruct storage $ = CheckpointStorage.get(); + $.batchSize = batchSize; + $.maxBatchWaitSeconds = maxBatchWaitSeconds; + $.minPaymentValueWei = minPaymentValueWei; + $.requireValidatorSigs = requireValidatorSigs; + $.allowCalldataOnlySubmit = allowCalldataOnlySubmit; + $.allowCCIPIngress = allowCCIPIngress; + } + + function setExtensionActive(bytes32 extensionId, bool active) external onlyRole(EXTENSION_ADMIN_ROLE) { + CheckpointStorage.ExtensionConfig storage cfg = CheckpointStorage.get().extensions[extensionId]; + if (cfg.module == address(0)) revert CheckpointErrors.ExtensionMissing(); + cfg.active = active; + } + + function updateExtensionHooks(bytes32 extensionId, uint32 hooks) external onlyRole(EXTENSION_ADMIN_ROLE) { + CheckpointStorage.ExtensionConfig storage cfg = CheckpointStorage.get().extensions[extensionId]; + if (cfg.module == address(0)) revert CheckpointErrors.ExtensionMissing(); + cfg.hooks = hooks; + } + + function _authorizeUpgrade(address) internal override onlyRole(UPGRADER_ROLE) {} + + function _applyHubConfig(CheckpointHubConfig.HubConfig calldata cfg) internal { + CheckpointStorage.CheckpointStorageStruct storage $ = CheckpointStorage.get(); + $.chainId = cfg.chainId; + $.batchSize = cfg.batchSize; + $.maxBatchWaitSeconds = cfg.maxBatchWaitSeconds; + $.minPaymentValueWei = cfg.minPaymentValueWei; + $.requireValidatorSigs = cfg.requireValidatorSigs; + $.allowCalldataOnlySubmit = cfg.allowCalldataOnlySubmit; + $.allowCCIPIngress = cfg.allowCCIPIngress; + $.enforcePreviousBatchId = cfg.enforcePreviousBatchId; + if (cfg.ccipRouter != address(0)) $.ccipRouter = cfg.ccipRouter; + if (cfg.sourceChainSelector != 0) $.expectedSourceChainSelector = cfg.sourceChainSelector; + if (cfg.batchEmitterOnSource != address(0)) $.batchEmitterOnSource = cfg.batchEmitterOnSource; + if (cfg.legacyMirrorV1 != address(0)) $.legacyMirrorV1 = cfg.legacyMirrorV1; + if (cfg.legacyTetherV1 != address(0)) $.legacyTetherV1 = cfg.legacyTetherV1; + if (cfg.submitterAttestationSigner != address(0)) { + $.submitterAttestationSigner = cfg.submitterAttestationSigner; + } + } + + // --- internal --- + + function _requireNotPaused() internal view { + if (CheckpointStorage.get().paused) revert CheckpointErrors.Paused(); + } + + function _submit( + CheckpointStorage.CheckpointHeader memory header, + bytes memory validatorSignatures, + bytes32[] memory txHashes, + bytes32 contentURI, + bool calldataOnly, + bool emergency, + bytes memory extensionData + ) internal { + CheckpointStorage.CheckpointStorageStruct storage $ = CheckpointStorage.get(); + if ($.processedBatchIds[header.batchId]) revert CheckpointErrors.BatchDone(); + if (header.chainId != $.chainId) revert CheckpointErrors.BadChain(); + if (header.batchId <= $.latestBatchId) revert CheckpointErrors.BatchOrder(); + if ($.enforcePreviousBatchId && $.latestBatchId > 0 && header.previousBatchId != $.latestBatchId) { + revert CheckpointErrors.PrevBatch(); + } + if (header.endBlock > 0 && header.endBlock < header.startBlock) revert CheckpointErrors.BadBlocks(); + if (header.txCount == 0 || header.txCount > $.batchSize) revert CheckpointErrors.TxCount(); + if (!emergency && !CheckpointFlags.has(header.flags, CheckpointFlags.PARTIAL_BATCH) && header.txCount != $.batchSize) { + revert CheckpointErrors.IncompleteBatch(); + } + if (header.paymentsRoot == bytes32(0)) revert CheckpointErrors.PaymentsRoot(); + if (header.stateRoot == bytes32(0)) revert CheckpointErrors.StateRoot(); + if (header.blockHash == bytes32(0)) revert CheckpointErrors.BlockHash(); + + if ($.requireValidatorSigs && validatorSignatures.length == 0) revert CheckpointErrors.Signatures(); + if ($.minPaymentValueWei > 0 && extensionData.length > 0) { + CheckpointPaymentsLib.enforceMinPaymentValueV1(extensionData, $.minPaymentValueWei); + } + + bytes32 proofHash = keccak256( + abi.encodePacked( + header.batchId, + header.paymentsRoot, + header.stateRoot, + header.checkpointBlock, + validatorSignatures + ) + ); + if ($.processedProofHashes[proofHash]) revert CheckpointErrors.Replay(); + $.processedProofHashes[proofHash] = true; + + if (calldataOnly) { + header.flags |= CheckpointFlags.CALLDATA_ONLY; + } + if (contentURI != bytes32(0)) { + header.flags |= CheckpointFlags.HAS_CONTENT_URI; + header.contentURI = contentURI; + } + header.submittedAt = uint64(block.timestamp); + header.submitter = msg.sender; + + for (uint256 i = 0; i < txHashes.length; i++) { + if (txHashes[i] == bytes32(0)) revert CheckpointErrors.ZeroTx(); + if ($.txHashToBatchId[txHashes[i]] != 0) revert CheckpointErrors.TxSeen(); + $.txHashToBatchId[txHashes[i]] = header.batchId; + } + + $.checkpoints[header.batchId] = header; + $.processedBatchIds[header.batchId] = true; + $.latestBatchId = header.batchId; + $.latestCheckpointBlock = header.checkpointBlock; + $.latestPaymentsRoot = header.paymentsRoot; + + emit CheckpointSubmitted( + header.batchId, + header.checkpointBlock, + header.paymentsRoot, + header.txCount, + header.flags, + header.contentURI + ); + } + + /// @dev Validator extension expects ECDSA bytes; other extensions expect leaf/extension payload. + function _runBeforeSubmitExtensions( + CheckpointStorage.CheckpointHeader memory header, + bytes memory validatorSignatures, + bytes memory extensionData + ) internal { + CheckpointStorage.CheckpointStorageStruct storage $ = CheckpointStorage.get(); + bytes32[] storage list = $.extensionList; + bytes32 validatorId = ExtensionIds.VALIDATOR_SIG; + CheckpointStorage.ExtensionConfig storage validatorCfg = $.extensions[validatorId]; + if (validatorCfg.active && (validatorCfg.hooks & HOOK_BEFORE_SUBMIT) != 0) { + ICheckpointExtension(validatorCfg.module).beforeSubmit(header, validatorSignatures); + } + for (uint256 i = 0; i < list.length; i++) { + bytes32 id = list[i]; + if (id == validatorId) continue; + CheckpointStorage.ExtensionConfig storage cfg = $.extensions[id]; + if (!cfg.active || (cfg.hooks & HOOK_BEFORE_SUBMIT) == 0) continue; + ICheckpointExtension(cfg.module).beforeSubmit(header, extensionData); + } + } + + function _runExtensions( + uint32 hook, + CheckpointStorage.CheckpointHeader memory header, + bytes memory data + ) internal { + CheckpointStorage.CheckpointStorageStruct storage $ = CheckpointStorage.get(); + bytes32[] storage list = $.extensionList; + for (uint256 i = 0; i < list.length; i++) { + CheckpointStorage.ExtensionConfig storage cfg = $.extensions[list[i]]; + if (!cfg.active || (cfg.hooks & hook) == 0) continue; + if (hook == HOOK_BEFORE_SUBMIT) { + ICheckpointExtension(cfg.module).beforeSubmit(header, data); + } else if (hook == HOOK_AFTER_SUBMIT) { + ICheckpointExtension(cfg.module).afterSubmit(header, data); + } else if (hook == HOOK_ON_CCIP) { + ICheckpointExtension(cfg.module).onCCIPReceive(data); + } + } + } + + function _verifySubmitterAttestation( + CheckpointStorage.CheckpointHeader calldata header, + bytes calldata submitterSignature + ) internal view { + if (submitterSignature.length != 65) revert CheckpointErrors.SubmitterSigLen(); + bytes32 digest = CheckpointEIP712.digest(address(this), block.chainid, header); + address signer = ECDSA.recover(digest, submitterSignature); + CheckpointStorage.CheckpointStorageStruct storage $ = CheckpointStorage.get(); + if ($.submitterAttestationSigner != address(0)) { + if (signer != $.submitterAttestationSigner) revert CheckpointErrors.AttestSigner(); + return; + } + if (!hasRole(SUBMITTER_ROLE, signer)) revert CheckpointErrors.SubmitterRole(); + } + + function _extensionVerifyLeaf( + CheckpointStorage.CheckpointHeader memory h, + CheckpointLeaf.PaymentLeafV1 calldata leaf, + bytes32[] calldata proof + ) internal view returns (bool) { + CheckpointStorage.CheckpointStorageStruct storage $ = CheckpointStorage.get(); + bytes32[] storage list = $.extensionList; + for (uint256 i = 0; i < list.length; i++) { + CheckpointStorage.ExtensionConfig storage cfg = $.extensions[list[i]]; + if (!cfg.active || (cfg.hooks & HOOK_VERIFY_LEAF) == 0) continue; + if (ICheckpointExtension(cfg.module).verifyLeaf(h, leaf, proof)) return true; + } + return false; + } +} diff --git a/contracts/mainnet-checkpoint/Chain138ParticipantSurface.sol b/contracts/mainnet-checkpoint/Chain138ParticipantSurface.sol new file mode 100644 index 0000000..f81d88a --- /dev/null +++ b/contracts/mainnet-checkpoint/Chain138ParticipantSurface.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title Chain138ParticipantSurface + * @notice Surfaces Chain 138 payment activity on each participant's mainnet address history. + * @dev Emits indexed events (Etherscan Events tab on participant via log filters) and optional + * zero-value wallet touch (Internal Transactions tab). Top-level 0 ETH txs are sent + * separately by the relayer when CHECKPOINT_SURFACE_TOPLEVEL_ZERO_ETH=1 (aggregator). + */ +contract Chain138ParticipantSurface { + uint8 public constant DIRECTION_CREDITED = 0; + uint8 public constant DIRECTION_DEBITED = 1; + + address public admin; + bool public paused; + bool public walletTouchEnabled; + + struct Notification { + address participant; + bytes32 chain138TxHash; + bytes32 instructionId; + uint8 direction; + uint64 batchId; + uint256 valueWei; + uint64 valueUsdE8; + } + + mapping(bytes32 => bool) public notified; + + event AdminChanged(address indexed newAdmin); + event WalletTouchEnabled(bool enabled); + event Paused(); + event Unpaused(); + + /// @notice Indexed for Etherscan log lookup on participant address (topic1). + event Chain138ActivityNotified( + address indexed participant, + bytes32 indexed chain138TxHash, + bytes32 indexed instructionId, + uint8 direction, + uint64 batchId, + uint256 valueWei, + uint64 valueUsdE8, + bool walletTouched + ); + + event BatchNotificationsSurfaced(uint64 indexed batchId, uint256 count); + + modifier onlyAdmin() { + require(msg.sender == admin, "only admin"); + _; + } + + modifier whenNotPaused() { + require(!paused, "paused"); + _; + } + + constructor(address _admin) { + require(_admin != address(0), "zero admin"); + admin = _admin; + walletTouchEnabled = true; + } + + function setAdmin(address newAdmin) external onlyAdmin { + require(newAdmin != address(0), "zero admin"); + admin = newAdmin; + emit AdminChanged(newAdmin); + } + + function setWalletTouchEnabled(bool enabled) external onlyAdmin { + walletTouchEnabled = enabled; + emit WalletTouchEnabled(enabled); + } + + function pause() external onlyAdmin { + paused = true; + emit Paused(); + } + + function unpause() external onlyAdmin { + paused = false; + emit Unpaused(); + } + + function notificationKey( + address participant, + bytes32 chain138TxHash, + uint8 direction + ) public pure returns (bytes32) { + return keccak256(abi.encodePacked(participant, chain138TxHash, direction)); + } + + /** + * @notice Record Chain 138 activity for each participant role (credit + debit) in one mainnet tx. + * @param batchId Checkpoint batch id (for correlation) + * @param items Parallel notifications (typically 2 per Chain 138 tx: debited from, credited to) + */ + function notifyBatch(uint64 batchId, Notification[] calldata items) external onlyAdmin whenNotPaused { + uint256 n = items.length; + for (uint256 i = 0; i < n; i++) { + Notification calldata item = items[i]; + require(item.participant != address(0), "zero participant"); + require(item.chain138TxHash != bytes32(0), "zero hash"); + + bytes32 key = notificationKey(item.participant, item.chain138TxHash, item.direction); + if (notified[key]) continue; + notified[key] = true; + + bool touched = false; + if (walletTouchEnabled) { + (bool ok,) = item.participant.call{value: 0}( + abi.encodeWithSignature( + "chain138Attestation(bytes32,uint8,uint64)", + item.chain138TxHash, + item.direction, + batchId + ) + ); + touched = ok; + } + + emit Chain138ActivityNotified( + item.participant, + item.chain138TxHash, + item.instructionId, + item.direction, + batchId, + item.valueWei, + item.valueUsdE8, + touched + ); + } + emit BatchNotificationsSurfaced(batchId, n); + } +} diff --git a/contracts/mainnet-checkpoint/ISO20022IntakeGateway.sol b/contracts/mainnet-checkpoint/ISO20022IntakeGateway.sol new file mode 100644 index 0000000..e899ab4 --- /dev/null +++ b/contracts/mainnet-checkpoint/ISO20022IntakeGateway.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; + +/** + * @title ISO20022IntakeGateway + * @notice On-chain entry for canonical ISO 20022 / MT-103 mapped instructions (idempotent by instructionId). + * @dev Full MX/MT payloads remain off-chain; keyed by payloadHash. See SMART_CONTRACTS_ISO20022_FIN_METHODOLOGY.md + */ +contract ISO20022IntakeGateway is AccessControl { + bytes32 public constant RELAYER_ROLE = keccak256("RELAYER_ROLE"); + + struct CanonicalMessage { + bytes32 instructionId; + bytes32 endToEndIdHash; + bytes32 uetr; + bytes32 payloadHash; + bytes32 debtorRefHash; + bytes32 creditorRefHash; + bytes32 purposeHash; + uint8 msgTypeCode; + address token; + uint256 amount; + bytes3 currencyCode; + } + + mapping(bytes32 => bool) public processedInstructions; + mapping(bytes32 => CanonicalMessage) public inboundMessages; + + event InboundAccepted( + bytes32 indexed instructionId, + bytes32 indexed uetr, + bytes32 payloadHash, + uint8 msgTypeCode, + address token, + uint256 amount, + bytes3 currencyCode, + address indexed relayer + ); + + event CCIPRouterUpdated(address router); + + address public ccipRouter; + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(RELAYER_ROLE, admin); + } + + function setCCIPRouter(address router) external onlyRole(DEFAULT_ADMIN_ROLE) { + ccipRouter = router; + emit CCIPRouterUpdated(router); + } + + function submitInbound(CanonicalMessage calldata m) external onlyRole(RELAYER_ROLE) { + require(m.instructionId != bytes32(0), "instructionId"); + require(m.payloadHash != bytes32(0), "payloadHash"); + require(!processedInstructions[m.instructionId], "duplicate instruction"); + processedInstructions[m.instructionId] = true; + inboundMessages[m.instructionId] = m; + emit InboundAccepted( + m.instructionId, + m.uetr, + m.payloadHash, + m.msgTypeCode, + m.token, + m.amount, + m.currencyCode, + msg.sender + ); + } +} diff --git a/contracts/mainnet-checkpoint/LegacyCheckpointAdapter.sol b/contracts/mainnet-checkpoint/LegacyCheckpointAdapter.sol new file mode 100644 index 0000000..0a90cc6 --- /dev/null +++ b/contracts/mainnet-checkpoint/LegacyCheckpointAdapter.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointStorage} from "./storage/CheckpointStorage.sol"; +import {IChain138MainnetCheckpoint} from "./interfaces/IChain138MainnetCheckpoint.sol"; + +interface ILegacyTransactionMirror { + function getMirroredTransactionCount() external view returns (uint256); + function transactions(bytes32 txHash) + external + view + returns ( + bytes32, + address, + address, + uint256, + uint256, + uint256, + uint256, + bool, + bytes memory, + bytes32 + ); +} + +interface ILegacyMainnetTether { + function stateProofs(uint256 blockNumber) + external + view + returns ( + uint256, + bytes32, + bytes32, + bytes32, + uint256, + bytes memory, + uint256, + bytes32 + ); +} + +/// @notice Read-only unified view over v1 mirror/tether + v2 checkpoint hub. +contract LegacyCheckpointAdapter { + IChain138MainnetCheckpoint public immutable checkpointV2; + ILegacyTransactionMirror public immutable mirrorV1; + ILegacyMainnetTether public immutable tetherV1; + + constructor(address checkpoint, address mirror, address tether) { + checkpointV2 = IChain138MainnetCheckpoint(checkpoint); + mirrorV1 = ILegacyTransactionMirror(mirror); + tetherV1 = ILegacyMainnetTether(tether); + } + + function latestAttestationBlock() external view returns (uint256 blockNumber, uint64 batchId, bool fromV2) { + batchId = checkpointV2.getLatestBatchId(); + if (batchId > 0) { + CheckpointStorage.CheckpointHeader memory h = checkpointV2.getLatestCheckpoint(); + return (h.checkpointBlock, batchId, true); + } + return (0, 0, false); + } + + function isTxMirroredV1(bytes32 txHash) external view returns (bool) { + (, , , , uint256 blockNumber, , , , , ) = mirrorV1.transactions(txHash); + return blockNumber > 0; + } + + function getTetherStateRoot(uint256 blockNumber) external view returns (bytes32 stateRoot, bool found) { + (, , stateRoot, , , , , ) = tetherV1.stateProofs(blockNumber); + found = stateRoot != bytes32(0); + } + + function v1MirrorCount() external view returns (uint256) { + return mirrorV1.getMirroredTransactionCount(); + } +} diff --git a/contracts/mainnet-checkpoint/README.md b/contracts/mainnet-checkpoint/README.md new file mode 100644 index 0000000..041b425 --- /dev/null +++ b/contracts/mainnet-checkpoint/README.md @@ -0,0 +1,98 @@ +# Chain 138 → Mainnet checkpoint (v2 maximum) + +Upgradeable attestation hub replacing standalone `MainnetTether` + `TransactionMirror`. + +**Architecture:** `docs/07-ccip/MAINNET_CHECKPOINT_MAXIMUM_ARCHITECTURE.md` +**Roadmap:** `docs/07-ccip/MAINNET_CHECKPOINT_RECOMMENDATIONS_AND_ROADMAP.md` + +## Contract map + +| File | Role | +|------|------| +| `Chain138MainnetCheckpoint.sol` | UUPS hub: submit, CCIP, extensions, pause, relayer EIP-712 | +| `chain138/Chain138BatchEmitter.sol` | 138 commit + CCIP to mainnet | +| `LegacyCheckpointAdapter.sol` | Read-only v1 mirror + tether + v2 unified views | +| `AddressActivityRegistry.sol` | Mainnet participant credit/debit events (ETH wei + USD e8) for Etherscan log indexing | +| `AddressActivityRegistryV2.sol` | ISO 20022 / MT-103+ attestation (`PaymentAttested`: instructionId, UETR, payloadHash) | +| `ISO20022IntakeGateway.sol` | Relayer `submitInbound(CanonicalMessage)` for institutional ISO intake | +| `libraries/ExtensionIds.sol` | Canonical `registerExtension` ids | +| `libraries/CheckpointEIP712.sol` | Typed data for relayer / validators | +| `libraries/CheckpointLeaf.sol` | V1/V2 leaves + Merkle build/verify | +| `extensions/*` | Optional pluggable modules (11 shipped) | + +## Extensions (all optional) + +| ID (`ExtensionIds`) | Contract | Hooks | +|---------------------|----------|-------| +| `MIRROR_DETAIL` | `MirrorDetailExtension` | afterSubmit | +| `VALIDATOR_SIG` | `ValidatorSigVerifierExtension` | beforeSubmit (ECDSA) | +| `CONTENT_URI` | `AttestationURIExtension` | afterSubmit | +| `RATE_LIMIT` | `SubmitRateLimitExtension` | before/after | +| `TOKEN_FILTER` | `TokenTransferFilterExtension` | beforeSubmit (V2 ERC-20) | +| `CW_LINK` | `CwTransportLinkExtension` | afterSubmit (hints only) | +| `GOV_TIMELOCK` | `TimelockSubmitExtension` | beforeSubmit | +| `METRICS` | `MetricsExtension` | afterSubmit | +| `ZK_STATE_ROOT` | `ZkStateRootVerifierExtension` | beforeSubmit (stub) | +| `PAYMASTER_HINT` | `PaymasterHintExtension` | afterSubmit | +| `L2_ORACLE` | `L2OracleAdapterExtension` | afterSubmit | + +## Hub API (highlights) + +- `submitCheckpoint` / `submitCheckpointCommitment` / `submitCheckpointWithLeaves` +- `submitCheckpointByRelayer(header, …, submitterSignature)` — EIP-712 attestation +- `forceCheckpoint` — `EMERGENCY_ROLE` +- `getConfig()` / `getFullConfig()` / `getExtensionList()` (use per-batch `getCheckpoint` off-chain for ranges) +- `verifyPaymentInBatch` — core Merkle + extension `verifyLeaf` + +## Deploy + +Use scoped forge (`FORGE_SCOPE=mainnet-checkpoint`, profile `mainnet-checkpoint`) via repo wrappers: + +```bash +# From proxmox repo root (gate + env loaded automatically) +bash scripts/deployment/deploy-checkpoint-mainnet-hub.sh # fund deployer >= 0.003 ETH +bash scripts/deployment/configure-checkpoint-mainnet-hub.sh +bash scripts/deployment/deploy-checkpoint-extensions-mainnet.sh +bash scripts/deployment/register-checkpoint-extensions-mainnet.sh +bash scripts/deployment/deploy-checkpoint-batch-emitter-138.sh +bash scripts/deployment/deploy-address-activity-registry.sh # mainnet AddressActivityRegistry (Etherscan participant logs) +bash scripts/deployment/deploy-address-activity-registry-v2.sh # ISO MT-103 / pacs.008 attestation +bash scripts/deployment/deploy-iso20022-intake-gateway.sh # optional ISO intake gateway +pnpm etherscan-v2:sync-touchpoints # after registry env is set +``` + +Simulate without broadcast: `CHECKPOINT_DRY_RUN=1` on any wrapper above. + +**AddressActivityRegistry** is deployed separately (not via `deploy-checkpoint-extensions-mainnet.sh`) to avoid duplicate registry addresses. See `docs/04-configuration/etherscan/CHAIN138_ETHERSCAN_V2_UNSUPPORTED_NETWORK_FRAMEWORK.md`. + +## Services + +| Service | Path | +|---------|------| +| Aggregator (batch=10) | `services/checkpoint-aggregator/` | +| Indexer REST | `services/checkpoint-indexer/` (`GET /v1/checkpoint/latest`, `/v2/api` Etherscan shim) | + +## Tests & CI (run before every deploy) + +```bash +# Full gate: compile + all tests + storage layout (+ optional env profile) +pnpm checkpoint:predeploy +pnpm checkpoint:predeploy:hub # includes mainnet-hub env validation + +# Deploy wrappers (gate runs automatically) +bash ../scripts/deployment/deploy-checkpoint-mainnet-hub.sh +bash ../scripts/deployment/configure-checkpoint-mainnet-hub.sh +bash ../scripts/deployment/deploy-checkpoint-batch-emitter-138.sh +``` + +Granular env reference: `config/checkpoint-hub.example.env` + +## On-chain configuration + +| API | Purpose | +|-----|---------| +| `applyConfig(HubConfig)` | Atomically set all hub tunables (preferred; per-field setters removed for bytecode size) | +| `getFullConfig()` | Read full `HubConfig` struct | +| `setConfig(...)` | Batch size / wait / flags only (subset) | +| `setExtensionActive` / `updateExtensionHooks` | Extension registry tuning | +| `applyEmitterConfig` (138) | Batch emitter CCIP destination | diff --git a/contracts/mainnet-checkpoint/chain138/Chain138BatchEmitter.sol b/contracts/mainnet-checkpoint/chain138/Chain138BatchEmitter.sol new file mode 100644 index 0000000..1aaf635 --- /dev/null +++ b/contracts/mainnet-checkpoint/chain138/Chain138BatchEmitter.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import "../../vendor/openzeppelin/UUPSUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "../../ccip/IRouterClient.sol"; +import {IChain138BatchEmitter} from "../interfaces/IChain138BatchEmitter.sol"; +import {BatchEmitterConfig} from "../libraries/BatchEmitterConfig.sol"; + +/** + * @title Chain138BatchEmitter + * @notice Chain 138: commit batch + CCIP to mainnet Chain138MainnetCheckpoint. + */ +contract Chain138BatchEmitter is Initializable, AccessControlUpgradeable, UUPSUpgradeable, IChain138BatchEmitter { + bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); + bytes32 public constant COMMITTER_ROLE = keccak256("COMMITTER_ROLE"); + + IRouterClient public ccipRouter; + address public mainnetCheckpoint; + address public linkToken; + uint64 public mainnetChainSelector; + mapping(uint64 => bool) public committed; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize( + address admin, + address _ccipRouter, + address _linkToken, + uint64 _mainnetChainSelector, + address _mainnetCheckpoint + ) external initializer { + __AccessControl_init(); + __UUPSUpgradeable_init(); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(UPGRADER_ROLE, admin); + _grantRole(COMMITTER_ROLE, admin); + ccipRouter = IRouterClient(_ccipRouter); + linkToken = _linkToken; + mainnetChainSelector = _mainnetChainSelector; + mainnetCheckpoint = _mainnetCheckpoint; + } + + function commitBatch( + uint64 batchId, + bytes32 paymentsRoot, + uint256 checkpointBlock, + uint256 startBlock, + uint16 txCount, + bytes32[] calldata txHashes + ) external onlyRole(COMMITTER_ROLE) { + require(!committed[batchId], "committed"); + require(paymentsRoot != bytes32(0), "root"); + committed[batchId] = true; + emit BatchCommittedOn138(batchId, paymentsRoot, checkpointBlock, txCount); + // txHashes emitted off-chain or via separate event in production + startBlock; + txHashes; + } + + function sendBatchToMainnet( + uint64 batchId, + bytes calldata encodedCheckpointPayload, + uint256 linkFee + ) external onlyRole(COMMITTER_ROLE) returns (bytes32 ccipMessageId) { + require(committed[batchId], "not committed"); + require(mainnetCheckpoint != address(0), "no dest"); + + IRouterClient.EVM2AnyMessage memory message = IRouterClient.EVM2AnyMessage({ + receiver: abi.encode(mainnetCheckpoint), + data: encodedCheckpointPayload, + tokenAmounts: new IRouterClient.TokenAmount[](0), + feeToken: linkToken, + extraArgs: "" + }); + + (ccipMessageId,) = ccipRouter.ccipSend(mainnetChainSelector, message); + linkFee; + emit BatchSentToMainnet(batchId, ccipMessageId); + } + + function setMainnetCheckpoint(address _mainnetCheckpoint) external onlyRole(DEFAULT_ADMIN_ROLE) { + mainnetCheckpoint = _mainnetCheckpoint; + } + + function setCcipRouter(address _ccipRouter) external onlyRole(DEFAULT_ADMIN_ROLE) { + ccipRouter = IRouterClient(_ccipRouter); + } + + function setLinkToken(address _linkToken) external onlyRole(DEFAULT_ADMIN_ROLE) { + linkToken = _linkToken; + } + + function setMainnetChainSelector(uint64 selector) external onlyRole(DEFAULT_ADMIN_ROLE) { + mainnetChainSelector = selector; + } + + function getEmitterConfig() external view returns (BatchEmitterConfig.EmitterConfig memory cfg) { + cfg.ccipRouter = address(ccipRouter); + cfg.linkToken = linkToken; + cfg.mainnetChainSelector = mainnetChainSelector; + cfg.mainnetCheckpoint = mainnetCheckpoint; + } + + function applyEmitterConfig(BatchEmitterConfig.EmitterConfig calldata cfg) + external + onlyRole(DEFAULT_ADMIN_ROLE) + { + BatchEmitterConfig.validate(cfg); + ccipRouter = IRouterClient(cfg.ccipRouter); + linkToken = cfg.linkToken; + mainnetChainSelector = cfg.mainnetChainSelector; + mainnetCheckpoint = cfg.mainnetCheckpoint; + } + + function _authorizeUpgrade(address) internal override onlyRole(UPGRADER_ROLE) {} +} diff --git a/contracts/mainnet-checkpoint/extensions/AttestationURIExtension.sol b/contracts/mainnet-checkpoint/extensions/AttestationURIExtension.sol new file mode 100644 index 0000000..b78a372 --- /dev/null +++ b/contracts/mainnet-checkpoint/extensions/AttestationURIExtension.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointExtensionBase} from "./CheckpointExtensionBase.sol"; +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; + +/// @notice Emits rich URI events for IPFS/Arweave batch payloads. +contract AttestationURIExtension is CheckpointExtensionBase { + event AttestationURI(uint64 indexed batchId, bytes32 contentURI, bytes32 payloadHash); + + function HOOK_BEFORE_SUBMIT() external pure override returns (uint32) { + return 0; + } + + function HOOK_AFTER_SUBMIT() external pure override returns (uint32) { + return 1 << 1; + } + + function HOOK_ON_CCIP() external pure override returns (uint32) { + return 0; + } + + function beforeSubmit(CheckpointStorage.CheckpointHeader calldata, bytes calldata) external pure override {} + + function afterSubmit(CheckpointStorage.CheckpointHeader calldata header, bytes calldata data) external { + if (header.contentURI == bytes32(0)) return; + bytes32 payloadHash = keccak256(data); + emit AttestationURI(header.batchId, header.contentURI, payloadHash); + } + + function onCCIPReceive(bytes calldata) external pure override {} +} diff --git a/contracts/mainnet-checkpoint/extensions/BlockHeaderOracleExtension.sol b/contracts/mainnet-checkpoint/extensions/BlockHeaderOracleExtension.sol new file mode 100644 index 0000000..ba6b12e --- /dev/null +++ b/contracts/mainnet-checkpoint/extensions/BlockHeaderOracleExtension.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointExtensionBase} from "./CheckpointExtensionBase.sol"; +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; + +/// @notice On-chain registry of Chain 138 blockHash/stateRoot per height (keeper-updated). +contract BlockHeaderOracleExtension is CheckpointExtensionBase, AccessControl { + bytes32 public constant ORACLE_UPDATER_ROLE = keccak256("ORACLE_UPDATER_ROLE"); + + mapping(uint256 => bytes32) public blockHashes; + mapping(uint256 => bytes32) public stateRoots; + bool public requireOracleRecord; + + event BlockHeaderRecorded(uint256 indexed blockNumber, bytes32 blockHash, bytes32 stateRoot); + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(ORACLE_UPDATER_ROLE, admin); + } + + function setRequireOracleRecord(bool required) external onlyRole(DEFAULT_ADMIN_ROLE) { + requireOracleRecord = required; + } + + function setBlockHeader(uint256 blockNumber, bytes32 blockHash, bytes32 stateRoot) + external + onlyRole(ORACLE_UPDATER_ROLE) + { + require(blockHash != bytes32(0) && stateRoot != bytes32(0), "zero"); + blockHashes[blockNumber] = blockHash; + stateRoots[blockNumber] = stateRoot; + emit BlockHeaderRecorded(blockNumber, blockHash, stateRoot); + } + + function HOOK_BEFORE_SUBMIT() external pure override returns (uint32) { + return 1 << 0; + } + + function HOOK_AFTER_SUBMIT() external pure override returns (uint32) { + return 0; + } + + function HOOK_ON_CCIP() external pure override returns (uint32) { + return 0; + } + + function beforeSubmit(CheckpointStorage.CheckpointHeader calldata header, bytes calldata) external view { + if (!requireOracleRecord) return; + bytes32 expectedHash = blockHashes[header.checkpointBlock]; + bytes32 expectedRoot = stateRoots[header.checkpointBlock]; + require(expectedHash != bytes32(0) && expectedRoot != bytes32(0), "oracle missing"); + require(header.blockHash == expectedHash, "blockHash"); + require(header.stateRoot == expectedRoot, "stateRoot"); + } + + function afterSubmit(CheckpointStorage.CheckpointHeader calldata, bytes calldata) external pure override {} + + function onCCIPReceive(bytes calldata) external pure override {} +} diff --git a/contracts/mainnet-checkpoint/extensions/CheckpointExtensionBase.sol b/contracts/mainnet-checkpoint/extensions/CheckpointExtensionBase.sol new file mode 100644 index 0000000..79e7be9 --- /dev/null +++ b/contracts/mainnet-checkpoint/extensions/CheckpointExtensionBase.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ICheckpointExtension} from "../interfaces/ICheckpointExtension.sol"; +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "../libraries/CheckpointLeaf.sol"; + +/// @notice Default no-op verifyLeaf; override in modules that need custom proofs. +abstract contract CheckpointExtensionBase is ICheckpointExtension { + function HOOK_VERIFY_LEAF() external pure virtual returns (uint32) { + return 0; + } + + function verifyLeaf( + CheckpointStorage.CheckpointHeader calldata, + CheckpointLeaf.PaymentLeafV1 calldata, + bytes32[] calldata + ) external pure virtual returns (bool) { + return false; + } +} diff --git a/contracts/mainnet-checkpoint/extensions/CwTransportLinkExtension.sol b/contracts/mainnet-checkpoint/extensions/CwTransportLinkExtension.sol new file mode 100644 index 0000000..871ee46 --- /dev/null +++ b/contracts/mainnet-checkpoint/extensions/CwTransportLinkExtension.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointExtensionBase} from "./CheckpointExtensionBase.sol"; +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "../libraries/CheckpointLeaf.sol"; + +/// @notice Emits bridge hints only — does not mint cW tokens. +contract CwTransportLinkExtension is CheckpointExtensionBase { + event BridgeHint(uint64 indexed batchId, address indexed recipient, address token, uint256 amount); + + function HOOK_BEFORE_SUBMIT() external pure override returns (uint32) { + return 0; + } + + function HOOK_AFTER_SUBMIT() external pure override returns (uint32) { + return 1 << 1; + } + + function HOOK_ON_CCIP() external pure override returns (uint32) { + return 0; + } + + function beforeSubmit(CheckpointStorage.CheckpointHeader calldata, bytes calldata) external pure override {} + + function afterSubmit(CheckpointStorage.CheckpointHeader calldata header, bytes calldata data) external { + if (data.length == 0) return; + CheckpointLeaf.PaymentLeafV1[] memory leaves = abi.decode(data, (CheckpointLeaf.PaymentLeafV1[])); + for (uint256 i = 0; i < leaves.length; i++) { + emit BridgeHint(header.batchId, leaves[i].to, address(0), leaves[i].value); + } + } + + function onCCIPReceive(bytes calldata) external pure override {} +} diff --git a/contracts/mainnet-checkpoint/extensions/L2OracleAdapterExtension.sol b/contracts/mainnet-checkpoint/extensions/L2OracleAdapterExtension.sol new file mode 100644 index 0000000..0513ed7 --- /dev/null +++ b/contracts/mainnet-checkpoint/extensions/L2OracleAdapterExtension.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointExtensionBase} from "./CheckpointExtensionBase.sol"; +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; + +/// @notice Records L2 oracle feed snapshots alongside batches (extensible to Chainlink streams). +contract L2OracleAdapterExtension is CheckpointExtensionBase { + struct OracleSnapshot { + bytes32 feedId; + int256 answer; + uint256 updatedAt; + } + + mapping(uint64 => OracleSnapshot[]) public batchOracles; + + event OracleSnapshotRecorded(uint64 indexed batchId, bytes32 feedId, int256 answer); + + function HOOK_BEFORE_SUBMIT() external pure override returns (uint32) { + return 0; + } + + function HOOK_AFTER_SUBMIT() external pure override returns (uint32) { + return 1 << 1; + } + + function HOOK_ON_CCIP() external pure override returns (uint32) { + return 0; + } + + function beforeSubmit(CheckpointStorage.CheckpointHeader calldata, bytes calldata) external pure override {} + + function afterSubmit(CheckpointStorage.CheckpointHeader calldata header, bytes calldata data) external { + if (data.length == 0) return; + OracleSnapshot[] memory snaps = abi.decode(data, (OracleSnapshot[])); + for (uint256 i = 0; i < snaps.length; i++) { + batchOracles[header.batchId].push(snaps[i]); + emit OracleSnapshotRecorded(header.batchId, snaps[i].feedId, snaps[i].answer); + } + } + + function onCCIPReceive(bytes calldata) external pure override {} +} diff --git a/contracts/mainnet-checkpoint/extensions/MetricsExtension.sol b/contracts/mainnet-checkpoint/extensions/MetricsExtension.sol new file mode 100644 index 0000000..b060d48 --- /dev/null +++ b/contracts/mainnet-checkpoint/extensions/MetricsExtension.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointExtensionBase} from "./CheckpointExtensionBase.sol"; +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "../libraries/CheckpointLeaf.sol"; + +/// @notice On-chain counters for explorers / DefiLlama activity metrics (not TVL). +contract MetricsExtension is CheckpointExtensionBase { + uint256 public totalBatches; + uint256 public totalPaymentsAttested; + uint256 public totalValueWei; + + function HOOK_BEFORE_SUBMIT() external pure override returns (uint32) { + return 0; + } + + function HOOK_AFTER_SUBMIT() external pure override returns (uint32) { + return 1 << 1; + } + + function HOOK_ON_CCIP() external pure override returns (uint32) { + return 0; + } + + function beforeSubmit(CheckpointStorage.CheckpointHeader calldata, bytes calldata) external pure override {} + + function afterSubmit(CheckpointStorage.CheckpointHeader calldata header, bytes calldata data) external { + totalBatches++; + totalPaymentsAttested += header.txCount; + if (data.length == 0) return; + if (data.length > 0 && data[0] == bytes1(0x02)) { + (, CheckpointLeaf.PaymentLeafV2[] memory v2Leaves) = + abi.decode(data, (bytes1, CheckpointLeaf.PaymentLeafV2[])); + for (uint256 i = 0; i < v2Leaves.length; i++) { + totalValueWei += v2Leaves[i].value; + } + return; + } + CheckpointLeaf.PaymentLeafV1[] memory leaves = abi.decode(data, (CheckpointLeaf.PaymentLeafV1[])); + for (uint256 i = 0; i < leaves.length; i++) { + totalValueWei += leaves[i].value; + } + } + + function onCCIPReceive(bytes calldata) external pure override {} +} diff --git a/contracts/mainnet-checkpoint/extensions/MinPaymentValueExtension.sol b/contracts/mainnet-checkpoint/extensions/MinPaymentValueExtension.sol new file mode 100644 index 0000000..b71a695 --- /dev/null +++ b/contracts/mainnet-checkpoint/extensions/MinPaymentValueExtension.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointExtensionBase} from "./CheckpointExtensionBase.sol"; +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "../libraries/CheckpointLeaf.sol"; +import {IChain138MainnetCheckpoint} from "../interfaces/IChain138MainnetCheckpoint.sol"; + +/// @notice Enforces hub minPaymentValueWei against payment leaves in extensionData. +contract MinPaymentValueExtension is CheckpointExtensionBase { + IChain138MainnetCheckpoint public hub; + + function setHub(address hubAddress) external { + hub = IChain138MainnetCheckpoint(hubAddress); + } + + function HOOK_BEFORE_SUBMIT() external pure override returns (uint32) { + return 1 << 0; + } + + function HOOK_AFTER_SUBMIT() external pure override returns (uint32) { + return 0; + } + + function HOOK_ON_CCIP() external pure override returns (uint32) { + return 0; + } + + function beforeSubmit(CheckpointStorage.CheckpointHeader calldata header, bytes calldata data) external view { + (, , uint256 minWei,,,) = hub.getConfig(); + if (minWei == 0 || data.length == 0) return; + CheckpointLeaf.PaymentLeafV1[] memory leaves = abi.decode(data, (CheckpointLeaf.PaymentLeafV1[])); + for (uint256 i = 0; i < leaves.length; i++) { + require(leaves[i].value >= minWei, "below min"); + } + header; + } + + function afterSubmit(CheckpointStorage.CheckpointHeader calldata, bytes calldata) external pure override {} + + function onCCIPReceive(bytes calldata) external pure override {} +} diff --git a/contracts/mainnet-checkpoint/extensions/MirrorDetailExtension.sol b/contracts/mainnet-checkpoint/extensions/MirrorDetailExtension.sol new file mode 100644 index 0000000..5953a7b --- /dev/null +++ b/contracts/mainnet-checkpoint/extensions/MirrorDetailExtension.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointExtensionBase} from "./CheckpointExtensionBase.sol"; +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "../libraries/CheckpointLeaf.sol"; + +/// @notice Per-tx detail storage (v1 TransactionMirror compat + V2 ERC-20 amounts). +contract MirrorDetailExtension is CheckpointExtensionBase { + struct MirroredTx { + bytes32 txHash; + address from; + address to; + uint256 value; + uint256 blockNumber; + uint64 blockTimestamp; + uint256 gasUsed; + bool success; + } + + mapping(bytes32 => MirroredTx) public transactions; + mapping(uint64 => bytes32[]) public batchTxHashes; + mapping(bytes32 => address) public txToken; + mapping(bytes32 => uint32) public txLogIndex; + + function HOOK_BEFORE_SUBMIT() external pure override returns (uint32) { + return 0; + } + + function HOOK_AFTER_SUBMIT() external pure override returns (uint32) { + return 1 << 1; + } + + function HOOK_ON_CCIP() external pure override returns (uint32) { + return 0; + } + + function beforeSubmit(CheckpointStorage.CheckpointHeader calldata, bytes calldata) external pure override {} + + function afterSubmit(CheckpointStorage.CheckpointHeader calldata header, bytes calldata data) external { + if (data.length == 0) return; + if (data.length > 0 && data[0] == bytes1(0x02)) { + (, CheckpointLeaf.PaymentLeafV2[] memory v2Leaves) = + abi.decode(data, (bytes1, CheckpointLeaf.PaymentLeafV2[])); + for (uint256 i = 0; i < v2Leaves.length; i++) { + CheckpointLeaf.PaymentLeafV2 memory leaf = v2Leaves[i]; + transactions[leaf.txHash] = MirroredTx({ + txHash: leaf.txHash, + from: leaf.from, + to: leaf.to, + value: leaf.value, + blockNumber: leaf.blockNumber, + blockTimestamp: leaf.blockTimestamp, + gasUsed: leaf.gasUsed, + success: leaf.success + }); + txToken[leaf.txHash] = leaf.token; + txLogIndex[leaf.txHash] = leaf.logIndex; + batchTxHashes[header.batchId].push(leaf.txHash); + } + return; + } + CheckpointLeaf.PaymentLeafV1[] memory leaves = abi.decode(data, (CheckpointLeaf.PaymentLeafV1[])); + for (uint256 i = 0; i < leaves.length; i++) { + CheckpointLeaf.PaymentLeafV1 memory leaf = leaves[i]; + transactions[leaf.txHash] = MirroredTx({ + txHash: leaf.txHash, + from: leaf.from, + to: leaf.to, + value: leaf.value, + blockNumber: leaf.blockNumber, + blockTimestamp: leaf.blockTimestamp, + gasUsed: leaf.gasUsed, + success: leaf.success + }); + batchTxHashes[header.batchId].push(leaf.txHash); + } + } + + function onCCIPReceive(bytes calldata) external pure override {} +} diff --git a/contracts/mainnet-checkpoint/extensions/PaymasterHintExtension.sol b/contracts/mainnet-checkpoint/extensions/PaymasterHintExtension.sol new file mode 100644 index 0000000..93c4b15 --- /dev/null +++ b/contracts/mainnet-checkpoint/extensions/PaymasterHintExtension.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointExtensionBase} from "./CheckpointExtensionBase.sol"; +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; + +/// @notice ERC-4337 paymaster routing hints (no execution on-chain). +contract PaymasterHintExtension is CheckpointExtensionBase { + event PaymasterHint(uint64 indexed batchId, address indexed account, address paymaster, uint256 maxCost); + + function HOOK_BEFORE_SUBMIT() external pure override returns (uint32) { + return 0; + } + + function HOOK_AFTER_SUBMIT() external pure override returns (uint32) { + return 1 << 1; + } + + function HOOK_ON_CCIP() external pure override returns (uint32) { + return 0; + } + + function beforeSubmit(CheckpointStorage.CheckpointHeader calldata, bytes calldata) external pure override {} + + function afterSubmit(CheckpointStorage.CheckpointHeader calldata header, bytes calldata data) external { + if (data.length == 0) return; + (address account, address paymaster, uint256 maxCost) = abi.decode(data, (address, address, uint256)); + emit PaymasterHint(header.batchId, account, paymaster, maxCost); + } + + function onCCIPReceive(bytes calldata) external pure override {} +} diff --git a/contracts/mainnet-checkpoint/extensions/SubmitRateLimitExtension.sol b/contracts/mainnet-checkpoint/extensions/SubmitRateLimitExtension.sol new file mode 100644 index 0000000..5f74489 --- /dev/null +++ b/contracts/mainnet-checkpoint/extensions/SubmitRateLimitExtension.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointExtensionBase} from "./CheckpointExtensionBase.sol"; +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; + +/// @notice Limits batches per rolling hour window. +contract SubmitRateLimitExtension is CheckpointExtensionBase { + uint256 public maxBatchesPerHour = 120; + uint256 public windowStart; + uint256 public batchesInWindow; + + function HOOK_BEFORE_SUBMIT() external pure override returns (uint32) { + return 1 << 0; + } + + function HOOK_AFTER_SUBMIT() external pure override returns (uint32) { + return 1 << 1; + } + + function HOOK_ON_CCIP() external pure override returns (uint32) { + return 0; + } + + function setMaxBatchesPerHour(uint256 max_) external { + maxBatchesPerHour = max_; + } + + function beforeSubmit(CheckpointStorage.CheckpointHeader calldata, bytes calldata) external { + if (block.timestamp >= windowStart + 3600) { + windowStart = block.timestamp; + batchesInWindow = 0; + } + require(batchesInWindow < maxBatchesPerHour, "rate limit"); + } + + function afterSubmit(CheckpointStorage.CheckpointHeader calldata, bytes calldata) external { + batchesInWindow++; + } + + function onCCIPReceive(bytes calldata) external pure override {} +} diff --git a/contracts/mainnet-checkpoint/extensions/TimelockSubmitExtension.sol b/contracts/mainnet-checkpoint/extensions/TimelockSubmitExtension.sol new file mode 100644 index 0000000..2031148 --- /dev/null +++ b/contracts/mainnet-checkpoint/extensions/TimelockSubmitExtension.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointExtensionBase} from "./CheckpointExtensionBase.sol"; +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; +import {CheckpointFlags} from "../libraries/CheckpointFlags.sol"; + +/// @notice Queues non-emergency checkpoints until delay elapses (governance timelock-lite). +contract TimelockSubmitExtension is CheckpointExtensionBase { + uint256 public delaySeconds = 48 hours; + mapping(bytes32 => uint256) public readyAt; + + event CheckpointQueued(uint64 indexed batchId, bytes32 queueId, uint256 readyAt); + + function HOOK_BEFORE_SUBMIT() external pure override returns (uint32) { + return 1 << 0; + } + + function HOOK_AFTER_SUBMIT() external pure override returns (uint32) { + return 0; + } + + function HOOK_ON_CCIP() external pure override returns (uint32) { + return 0; + } + + function setDelay(uint256 seconds_) external { + delaySeconds = seconds_; + } + + function queueCheckpoint(CheckpointStorage.CheckpointHeader calldata header) external returns (bytes32 queueId) { + require(!CheckpointFlags.has(header.flags, CheckpointFlags.EMERGENCY), "emergency"); + queueId = keccak256(abi.encode(header.batchId, header.paymentsRoot, header.checkpointBlock)); + readyAt[queueId] = block.timestamp + delaySeconds; + emit CheckpointQueued(header.batchId, queueId, readyAt[queueId]); + } + + function beforeSubmit(CheckpointStorage.CheckpointHeader calldata header, bytes calldata) external view { + if (CheckpointFlags.has(header.flags, CheckpointFlags.EMERGENCY)) return; + bytes32 queueId = keccak256(abi.encode(header.batchId, header.paymentsRoot, header.checkpointBlock)); + uint256 ready = readyAt[queueId]; + if (ready == 0) return; + require(block.timestamp >= ready, "timelock"); + } + + function afterSubmit(CheckpointStorage.CheckpointHeader calldata header, bytes calldata) external { + bytes32 queueId = keccak256(abi.encode(header.batchId, header.paymentsRoot, header.checkpointBlock)); + delete readyAt[queueId]; + } + + function onCCIPReceive(bytes calldata) external pure override {} +} diff --git a/contracts/mainnet-checkpoint/extensions/TokenTransferFilterExtension.sol b/contracts/mainnet-checkpoint/extensions/TokenTransferFilterExtension.sol new file mode 100644 index 0000000..a2b9738 --- /dev/null +++ b/contracts/mainnet-checkpoint/extensions/TokenTransferFilterExtension.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointExtensionBase} from "./CheckpointExtensionBase.sol"; +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "../libraries/CheckpointLeaf.sol"; + +/// @notice Rejects batches whose leaves include disallowed tokens or native below min. +contract TokenTransferFilterExtension is CheckpointExtensionBase { + mapping(address => bool) public allowedTokens; + bool public allowNative = true; + uint256 public minNativeWei; + + event TokenAllowlistUpdated(address indexed token, bool allowed); + + function HOOK_BEFORE_SUBMIT() external pure override returns (uint32) { + return 1 << 0; + } + + function HOOK_AFTER_SUBMIT() external pure override returns (uint32) { + return 0; + } + + function HOOK_ON_CCIP() external pure override returns (uint32) { + return 0; + } + + function setAllowedToken(address token, bool allowed) external { + allowedTokens[token] = allowed; + emit TokenAllowlistUpdated(token, allowed); + } + + function setAllowNative(bool allowed, uint256 minWei) external { + allowNative = allowed; + minNativeWei = minWei; + } + + function beforeSubmit(CheckpointStorage.CheckpointHeader calldata, bytes calldata data) external view { + if (data.length == 0) return; + (CheckpointLeaf.PaymentLeafV2[] memory v2Leaves, bool isV2) = _decodeLeaves(data); + if (isV2) { + for (uint256 i = 0; i < v2Leaves.length; i++) { + CheckpointLeaf.PaymentLeafV2 memory leaf = v2Leaves[i]; + if (leaf.token == address(0)) { + require(allowNative, "native disabled"); + require(leaf.value >= minNativeWei, "native min"); + } else { + require(allowedTokens[leaf.token], "token"); + } + } + return; + } + CheckpointLeaf.PaymentLeafV1[] memory leaves = abi.decode(data, (CheckpointLeaf.PaymentLeafV1[])); + for (uint256 i = 0; i < leaves.length; i++) { + require(allowNative, "native only v1"); + require(leaves[i].value >= minNativeWei, "native min"); + } + } + + function afterSubmit(CheckpointStorage.CheckpointHeader calldata, bytes calldata) external pure override {} + + function onCCIPReceive(bytes calldata) external pure override {} + + function _decodeLeaves(bytes calldata data) + internal + pure + returns (CheckpointLeaf.PaymentLeafV2[] memory v2Leaves, bool isV2) + { + if (data.length > 0 && data[0] == bytes1(0x02)) { + bytes1 version; + (version, v2Leaves) = abi.decode(data, (bytes1, CheckpointLeaf.PaymentLeafV2[])); + isV2 = true; + } + } +} diff --git a/contracts/mainnet-checkpoint/extensions/ValidatorSigVerifierExtension.sol b/contracts/mainnet-checkpoint/extensions/ValidatorSigVerifierExtension.sol new file mode 100644 index 0000000..dcaff85 --- /dev/null +++ b/contracts/mainnet-checkpoint/extensions/ValidatorSigVerifierExtension.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointExtensionBase} from "./CheckpointExtensionBase.sol"; +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; +import {CheckpointEIP712} from "../libraries/CheckpointEIP712.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/// @notice Validator registry + ECDSA recovery for batch attestations and 65-byte sig bundles. +contract ValidatorSigVerifierExtension is CheckpointExtensionBase { + using ECDSA for bytes32; + + mapping(address => bool) public validators; + uint256 public threshold; + address public verifyingContract; + + event ValidatorSetUpdated(address[] validators, uint256 threshold); + + function HOOK_BEFORE_SUBMIT() external pure override returns (uint32) { + return 1 << 0; + } + + function HOOK_AFTER_SUBMIT() external pure override returns (uint32) { + return 0; + } + + function HOOK_ON_CCIP() external pure override returns (uint32) { + return 0; + } + + function setVerifyingContract(address hub) external { + verifyingContract = hub; + } + + function setValidators(address[] calldata addrs, uint256 newThreshold) external { + for (uint256 i = 0; i < addrs.length; i++) { + validators[addrs[i]] = true; + } + threshold = newThreshold; + emit ValidatorSetUpdated(addrs, newThreshold); + } + + function beforeSubmit(CheckpointStorage.CheckpointHeader calldata header, bytes calldata validatorSignatures) + external + view + { + if (threshold == 0) return; + require(verifyingContract != address(0), "verifier unset"); + bytes32 digest = CheckpointEIP712.digest(verifyingContract, block.chainid, header); + if (validatorSignatures.length == 65) { + address signer = ECDSA.recover(digest, validatorSignatures); + require(validators[signer], "attest signer"); + return; + } + require(validatorSignatures.length >= threshold * 65, "sig length"); + uint256 valid; + for (uint256 i = 0; i + 65 <= validatorSignatures.length; i += 65) { + bytes memory sig = new bytes(65); + for (uint256 j = 0; j < 65; j++) { + sig[j] = validatorSignatures[i + j]; + } + address signer = ECDSA.recover(digest, sig); + if (validators[signer]) valid++; + } + require(valid >= threshold, "threshold"); + } + + function afterSubmit(CheckpointStorage.CheckpointHeader calldata, bytes calldata) external pure override {} + + function onCCIPReceive(bytes calldata) external pure override {} +} diff --git a/contracts/mainnet-checkpoint/extensions/ZkStateRootVerifierExtension.sol b/contracts/mainnet-checkpoint/extensions/ZkStateRootVerifierExtension.sol new file mode 100644 index 0000000..e35391c --- /dev/null +++ b/contracts/mainnet-checkpoint/extensions/ZkStateRootVerifierExtension.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointExtensionBase} from "./CheckpointExtensionBase.sol"; +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; + +/// @notice Stub for future SNARK verifier — currently checks optional proof hash commitment. +contract ZkStateRootVerifierExtension is CheckpointExtensionBase { + mapping(bytes32 => bool) public acceptedProofHashes; + bool public requireZkProof; + + event ZkProofRegistered(bytes32 indexed proofHash); + + function HOOK_BEFORE_SUBMIT() external pure override returns (uint32) { + return 1 << 0; + } + + function HOOK_AFTER_SUBMIT() external pure override returns (uint32) { + return 0; + } + + function HOOK_ON_CCIP() external pure override returns (uint32) { + return 0; + } + + function registerProofHash(bytes32 proofHash) external { + acceptedProofHashes[proofHash] = true; + emit ZkProofRegistered(proofHash); + } + + function setRequireZkProof(bool required) external { + requireZkProof = required; + } + + function beforeSubmit(CheckpointStorage.CheckpointHeader calldata header, bytes calldata data) external view { + if (!requireZkProof) return; + bytes32 proofHash = abi.decode(data, (bytes32)); + require(acceptedProofHashes[proofHash], "zk proof"); + require(proofHash != bytes32(0), "zero proof"); + require(header.stateRoot != bytes32(0), "state root"); + } + + function afterSubmit(CheckpointStorage.CheckpointHeader calldata, bytes calldata) external pure override {} + + function onCCIPReceive(bytes calldata) external pure override {} +} diff --git a/contracts/mainnet-checkpoint/interfaces/IChain138BatchEmitter.sol b/contracts/mainnet-checkpoint/interfaces/IChain138BatchEmitter.sol new file mode 100644 index 0000000..444f115 --- /dev/null +++ b/contracts/mainnet-checkpoint/interfaces/IChain138BatchEmitter.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title IChain138BatchEmitter + * @notice Chain 138 companion: commit batch locally and optionally CCIP to mainnet hub. + */ +interface IChain138BatchEmitter { + event BatchCommittedOn138( + uint64 indexed batchId, + bytes32 indexed paymentsRoot, + uint256 checkpointBlock, + uint16 txCount + ); + + event BatchSentToMainnet(uint64 indexed batchId, bytes32 indexed messageId); + + function commitBatch( + uint64 batchId, + bytes32 paymentsRoot, + uint256 checkpointBlock, + uint256 startBlock, + uint16 txCount, + bytes32[] calldata txHashes + ) external; + + function sendBatchToMainnet( + uint64 batchId, + bytes calldata encodedCheckpointPayload, + uint256 linkFee + ) external returns (bytes32 messageId); + + function setMainnetCheckpoint(address mainnetCheckpoint) external; +} diff --git a/contracts/mainnet-checkpoint/interfaces/IChain138MainnetCheckpoint.sol b/contracts/mainnet-checkpoint/interfaces/IChain138MainnetCheckpoint.sol new file mode 100644 index 0000000..873133e --- /dev/null +++ b/contracts/mainnet-checkpoint/interfaces/IChain138MainnetCheckpoint.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "../libraries/CheckpointLeaf.sol"; + +/** + * @title IChain138MainnetCheckpoint + * @notice Public API for wallets, indexers, and integrators. + */ +interface IChain138MainnetCheckpoint { + event CheckpointSubmitted( + uint64 indexed batchId, + uint256 indexed checkpointBlock, + bytes32 indexed paymentsRoot, + uint16 txCount, + uint32 flags, + bytes32 contentURI + ); + + event ExtensionRegistered(bytes32 indexed extensionId, address module, uint32 hooks); + event ExtensionRevoked(bytes32 indexed extensionId); + + function getLatestCheckpoint() external view returns (CheckpointStorage.CheckpointHeader memory); + + function getCheckpoint(uint64 batchId) external view returns (CheckpointStorage.CheckpointHeader memory); + + function getLatestBatchId() external view returns (uint64); + + function latestCheckpointBlock() external view returns (uint256); + + function getConfig() + external + view + returns ( + uint16 batchSize, + uint32 maxBatchWaitSeconds, + uint256 minPaymentValueWei, + bool requireValidatorSigs, + bool allowCalldataOnlySubmit, + bool allowCCIPIngress + ); + + function verifyPaymentInBatch( + uint64 batchId, + CheckpointLeaf.PaymentLeafV1 calldata leaf, + bytes32[] calldata proof + ) external view returns (bool); + + function isTxIncluded(bytes32 txHash) external view returns (bool included, uint64 batchId); + + function submitCheckpoint( + CheckpointStorage.CheckpointHeader calldata header, + bytes calldata validatorSignatures, + bytes32[] calldata txHashes, + bytes calldata extensionData + ) external; + + function submitCheckpointCommitment( + CheckpointStorage.CheckpointHeader calldata header, + bytes calldata validatorSignatures, + bytes32 contentURI, + bytes calldata extensionData + ) external; + + function registerExtension(bytes32 extensionId, address module, uint32 hooks) external; + + function setConfig( + uint16 batchSize, + uint32 maxBatchWaitSeconds, + uint256 minPaymentValueWei, + bool requireValidatorSigs, + bool allowCalldataOnlySubmit, + bool allowCCIPIngress + ) external; +} diff --git a/contracts/mainnet-checkpoint/interfaces/ICheckpointExtension.sol b/contracts/mainnet-checkpoint/interfaces/ICheckpointExtension.sol new file mode 100644 index 0000000..a3812b3 --- /dev/null +++ b/contracts/mainnet-checkpoint/interfaces/ICheckpointExtension.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "../libraries/CheckpointLeaf.sol"; + +/** + * @title ICheckpointExtension + * @notice Pluggable modules for unlimited extensibility (hub invokes via call; delegatecall optional per module). + */ +interface ICheckpointExtension { + /// @dev Hook bitmasks — new hooks require implementation upgrade + gap only on hub + function HOOK_BEFORE_SUBMIT() external pure returns (uint32); + function HOOK_AFTER_SUBMIT() external pure returns (uint32); + function HOOK_ON_CCIP() external pure returns (uint32); + function HOOK_VERIFY_LEAF() external pure returns (uint32); + + function beforeSubmit(CheckpointStorage.CheckpointHeader calldata header, bytes calldata data) external; + + function afterSubmit(CheckpointStorage.CheckpointHeader calldata header, bytes calldata data) external; + + function onCCIPReceive(bytes calldata messageData) external; + + /// @notice Optional custom leaf verification (return true to accept, false to defer to core Merkle) + function verifyLeaf( + CheckpointStorage.CheckpointHeader calldata header, + CheckpointLeaf.PaymentLeafV1 calldata leaf, + bytes32[] calldata proof + ) external view returns (bool); +} diff --git a/contracts/mainnet-checkpoint/libraries/BatchEmitterConfig.sol b/contracts/mainnet-checkpoint/libraries/BatchEmitterConfig.sol new file mode 100644 index 0000000..9ac2db9 --- /dev/null +++ b/contracts/mainnet-checkpoint/libraries/BatchEmitterConfig.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +library BatchEmitterConfig { + struct EmitterConfig { + address ccipRouter; + address linkToken; + uint64 mainnetChainSelector; + address mainnetCheckpoint; + } + + function validate(EmitterConfig memory c) internal pure { + require(c.ccipRouter != address(0), "router"); + require(c.linkToken != address(0), "link"); + require(c.mainnetChainSelector != 0, "selector"); + require(c.mainnetCheckpoint != address(0), "checkpoint"); + } +} diff --git a/contracts/mainnet-checkpoint/libraries/CheckpointEIP712.sol b/contracts/mainnet-checkpoint/libraries/CheckpointEIP712.sol new file mode 100644 index 0000000..642376b --- /dev/null +++ b/contracts/mainnet-checkpoint/libraries/CheckpointEIP712.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointStorage} from "../storage/CheckpointStorage.sol"; + +/// @title CheckpointEIP712 — typed data for relayer + validator attestations +library CheckpointEIP712 { + bytes32 public constant DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + + bytes32 public constant BATCH_ATTESTATION_TYPEHASH = keccak256( + "BatchAttestation(uint64 chainId,uint64 batchId,uint256 checkpointBlock,bytes32 blockHash,bytes32 stateRoot,bytes32 paymentsRoot,uint64 previousBatchId)" + ); + + function domainSeparator(address verifyingContract, uint256 chainId) internal pure returns (bytes32) { + return keccak256( + abi.encode( + DOMAIN_TYPEHASH, + keccak256("Chain138MainnetCheckpoint"), + keccak256("2"), + chainId, + verifyingContract + ) + ); + } + + function hashBatchAttestation(CheckpointStorage.CheckpointHeader memory header) internal pure returns (bytes32) { + return keccak256( + abi.encode( + BATCH_ATTESTATION_TYPEHASH, + header.chainId, + header.batchId, + header.checkpointBlock, + header.blockHash, + header.stateRoot, + header.paymentsRoot, + header.previousBatchId + ) + ); + } + + function digest( + address verifyingContract, + uint256 chainId, + CheckpointStorage.CheckpointHeader memory header + ) internal pure returns (bytes32) { + bytes32 structHash = hashBatchAttestation(header); + bytes32 domain = domainSeparator(verifyingContract, chainId); + return keccak256(abi.encodePacked("\x19\x01", domain, structHash)); + } +} diff --git a/contracts/mainnet-checkpoint/libraries/CheckpointErrors.sol b/contracts/mainnet-checkpoint/libraries/CheckpointErrors.sol new file mode 100644 index 0000000..0d7dd0f --- /dev/null +++ b/contracts/mainnet-checkpoint/libraries/CheckpointErrors.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @dev Custom errors shrink hub runtime bytecode vs string reverts (EIP-170). +library CheckpointErrors { + error Paused(); + error BadRange(); + error UnknownBatch(); + error ZeroModule(); + error ExtensionExists(); + error ExtensionMissing(); + error BatchSize(); + error CcipDisabled(); + error OnlyRouter(); + error BadSelector(); + error BadEmitter(); + error BatchDone(); + error BadChain(); + error BatchOrder(); + error PrevBatch(); + error BadBlocks(); + error TxCount(); + error IncompleteBatch(); + error PaymentsRoot(); + error StateRoot(); + error BlockHash(); + error Signatures(); + error Replay(); + error ZeroTx(); + error TxSeen(); + error SubmitterSigLen(); + error AttestSigner(); + error SubmitterRole(); + error BelowMinPayment(); + error LeavesEmpty(); + error RootMismatch(); + error CalldataOnlyDisabled(); +} diff --git a/contracts/mainnet-checkpoint/libraries/CheckpointFlags.sol b/contracts/mainnet-checkpoint/libraries/CheckpointFlags.sol new file mode 100644 index 0000000..1b24f81 --- /dev/null +++ b/contracts/mainnet-checkpoint/libraries/CheckpointFlags.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title CheckpointFlags + * @notice Bitfield for CheckpointHeader.flags — extensible without upgrades when bits are reserved. + */ +library CheckpointFlags { + uint32 internal constant PARTIAL_BATCH = 1 << 0; + uint32 internal constant CALLDATA_ONLY = 1 << 1; + uint32 internal constant CCIP_INGRESS = 1 << 2; + uint32 internal constant RELAYER_SUBMIT = 1 << 3; + uint32 internal constant HAS_CONTENT_URI = 1 << 4; + uint32 internal constant HAS_RECEIPTS_ROOT = 1 << 5; + uint32 internal constant EMERGENCY = 1 << 6; + + function has(uint32 flags, uint32 bit) internal pure returns (bool) { + return flags & bit != 0; + } +} diff --git a/contracts/mainnet-checkpoint/libraries/CheckpointHubConfig.sol b/contracts/mainnet-checkpoint/libraries/CheckpointHubConfig.sol new file mode 100644 index 0000000..9502aab --- /dev/null +++ b/contracts/mainnet-checkpoint/libraries/CheckpointHubConfig.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title CheckpointHubConfig + * @notice Granular hub configuration — used at init, applyConfig, and off-chain deploy manifests. + */ +library CheckpointHubConfig { + struct HubConfig { + uint64 chainId; + uint16 batchSize; + uint32 maxBatchWaitSeconds; + uint256 minPaymentValueWei; + bool requireValidatorSigs; + bool allowCalldataOnlySubmit; + bool allowCCIPIngress; + bool enforcePreviousBatchId; + address ccipRouter; + uint64 sourceChainSelector; + address batchEmitterOnSource; + address legacyMirrorV1; + address legacyTetherV1; + address submitterAttestationSigner; + } + + function mainnetDefaults() internal pure returns (HubConfig memory c) { + c.chainId = 138; + c.batchSize = 10; + c.maxBatchWaitSeconds = 300; + c.requireValidatorSigs = true; + c.allowCalldataOnlySubmit = true; + c.allowCCIPIngress = true; + c.enforcePreviousBatchId = true; + } + + function validate(HubConfig memory c) internal pure { + require(c.chainId == 138, "chainId"); + require(c.batchSize > 0 && c.batchSize <= 256, "batchSize"); + require(c.maxBatchWaitSeconds > 0, "maxWait"); + } +} diff --git a/contracts/mainnet-checkpoint/libraries/CheckpointLeaf.sol b/contracts/mainnet-checkpoint/libraries/CheckpointLeaf.sol new file mode 100644 index 0000000..769536d --- /dev/null +++ b/contracts/mainnet-checkpoint/libraries/CheckpointLeaf.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title CheckpointLeaf + * @notice Canonical payment leaves for Merkle proofs (versioned). + */ +library CheckpointLeaf { + bytes1 internal constant PAYMENT_LEAF_V1 = 0x01; + bytes1 internal constant PAYMENT_LEAF_V2 = 0x02; + + struct PaymentLeafV1 { + bytes32 txHash; + address from; + address to; + uint256 value; + uint256 blockNumber; + uint64 blockTimestamp; + uint256 gasUsed; + bool success; + } + + /// @notice V2 adds optional ERC-20 token + log index for transfer-only batches + struct PaymentLeafV2 { + bytes32 txHash; + address from; + address to; + address token; + uint256 value; + uint256 blockNumber; + uint64 blockTimestamp; + uint256 gasUsed; + bool success; + uint32 logIndex; + } + + function paymentLeafV1( + uint64 chainId, + PaymentLeafV1 memory leaf + ) internal pure returns (bytes32) { + return keccak256( + abi.encode( + PAYMENT_LEAF_V1, + chainId, + leaf.txHash, + leaf.from, + leaf.to, + leaf.value, + leaf.blockNumber, + leaf.blockTimestamp, + leaf.gasUsed, + leaf.success + ) + ); + } + + function paymentLeafV2(uint64 chainId, PaymentLeafV2 memory leaf) internal pure returns (bytes32) { + return keccak256( + abi.encode( + PAYMENT_LEAF_V2, + chainId, + leaf.txHash, + leaf.from, + leaf.to, + leaf.token, + leaf.value, + leaf.blockNumber, + leaf.blockTimestamp, + leaf.gasUsed, + leaf.success, + leaf.logIndex + ) + ); + } + + function buildMerkleRoot(bytes32[] memory leaves) internal pure returns (bytes32) { + require(leaves.length > 0, "empty"); + if (leaves.length == 1) return leaves[0]; + bytes32[] memory layer = leaves; + while (layer.length > 1) { + uint256 nextLen = (layer.length + 1) / 2; + bytes32[] memory next = new bytes32[](nextLen); + for (uint256 i = 0; i < layer.length; i += 2) { + bytes32 a = layer[i]; + bytes32 b = i + 1 < layer.length ? layer[i + 1] : a; + next[i / 2] = _hashPair(a, b); + } + layer = next; + } + return layer[0]; + } + + function _hashPair(bytes32 a, bytes32 b) private pure returns (bytes32) { + return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); + } + + function verifyMerkle(bytes32 root, bytes32 leaf, bytes32[] memory proof) internal pure returns (bool) { + bytes32 computed = leaf; + for (uint256 i = 0; i < proof.length; i++) { + bytes32 p = proof[i]; + computed = computed < p ? keccak256(abi.encodePacked(computed, p)) : keccak256(abi.encodePacked(p, computed)); + } + return computed == root; + } +} diff --git a/contracts/mainnet-checkpoint/libraries/CheckpointPaymentsLib.sol b/contracts/mainnet-checkpoint/libraries/CheckpointPaymentsLib.sol new file mode 100644 index 0000000..05d4038 --- /dev/null +++ b/contracts/mainnet-checkpoint/libraries/CheckpointPaymentsLib.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointLeaf} from "./CheckpointLeaf.sol"; +import {CheckpointErrors} from "./CheckpointErrors.sol"; + +/// @notice External library to keep hub implementation under 24KB (EIP-170). +library CheckpointPaymentsLib { + function assertPaymentsRootV1( + uint64 chainId, + bytes32 paymentsRoot, + CheckpointLeaf.PaymentLeafV1[] calldata leaves + ) external pure { + if (leaves.length == 0) revert CheckpointErrors.LeavesEmpty(); + bytes32[] memory hashes = new bytes32[](leaves.length); + for (uint256 i = 0; i < leaves.length; i++) { + hashes[i] = CheckpointLeaf.paymentLeafV1(chainId, leaves[i]); + } + if (CheckpointLeaf.buildMerkleRoot(hashes) != paymentsRoot) revert CheckpointErrors.RootMismatch(); + } + + function assertPaymentsRootV2( + uint64 chainId, + bytes32 paymentsRoot, + CheckpointLeaf.PaymentLeafV2[] calldata leaves + ) external pure { + if (leaves.length == 0) revert CheckpointErrors.LeavesEmpty(); + bytes32[] memory hashes = new bytes32[](leaves.length); + for (uint256 i = 0; i < leaves.length; i++) { + hashes[i] = CheckpointLeaf.paymentLeafV2(chainId, leaves[i]); + } + if (CheckpointLeaf.buildMerkleRoot(hashes) != paymentsRoot) revert CheckpointErrors.RootMismatch(); + } + + function enforceMinPaymentValueV1(bytes calldata extensionData, uint256 minWei) external pure { + if (minWei == 0 || extensionData.length == 0) return; + CheckpointLeaf.PaymentLeafV1[] memory leaves = abi.decode(extensionData, (CheckpointLeaf.PaymentLeafV1[])); + for (uint256 i = 0; i < leaves.length; i++) { + if (leaves[i].value < minWei) revert CheckpointErrors.BelowMinPayment(); + } + } +} diff --git a/contracts/mainnet-checkpoint/libraries/ExtensionIds.sol b/contracts/mainnet-checkpoint/libraries/ExtensionIds.sol new file mode 100644 index 0000000..a71a9f4 --- /dev/null +++ b/contracts/mainnet-checkpoint/libraries/ExtensionIds.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title ExtensionIds — canonical keccak256 ids for registerExtension +library ExtensionIds { + bytes32 internal constant MIRROR_DETAIL = keccak256("MIRROR_DETAIL"); + bytes32 internal constant VALIDATOR_SIG = keccak256("VALIDATOR_SIG"); + bytes32 internal constant CONTENT_URI = keccak256("CONTENT_URI"); + bytes32 internal constant RATE_LIMIT = keccak256("RATE_LIMIT"); + bytes32 internal constant TOKEN_FILTER = keccak256("TOKEN_FILTER"); + bytes32 internal constant CW_LINK = keccak256("CW_LINK"); + bytes32 internal constant GOV_TIMELOCK = keccak256("GOV_TIMELOCK"); + bytes32 internal constant METRICS = keccak256("METRICS"); + bytes32 internal constant ZK_STATE_ROOT = keccak256("ZK_STATE_ROOT"); + bytes32 internal constant PAYMASTER_HINT = keccak256("PAYMASTER_HINT"); + bytes32 internal constant L2_ORACLE = keccak256("L2_ORACLE"); + bytes32 internal constant BLOCK_ORACLE = keccak256("BLOCK_ORACLE"); + bytes32 internal constant MIN_PAYMENT = keccak256("MIN_PAYMENT"); +} diff --git a/contracts/mainnet-checkpoint/storage/CheckpointStorage.sol b/contracts/mainnet-checkpoint/storage/CheckpointStorage.sol new file mode 100644 index 0000000..aaafe91 --- /dev/null +++ b/contracts/mainnet-checkpoint/storage/CheckpointStorage.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CheckpointLeaf} from "../libraries/CheckpointLeaf.sol"; + +/** + * @title CheckpointStorage + * @notice EIP-7201 namespaced storage for upgradeable checkpoint hub. + */ +library CheckpointStorage { + /// @custom:storage-location erc7201:dbis.storage.Chain138MainnetCheckpoint + struct CheckpointStorageStruct { + uint64 latestBatchId; + uint256 latestCheckpointBlock; + bytes32 latestPaymentsRoot; + uint64 chainId; + uint16 batchSize; + uint32 maxBatchWaitSeconds; + uint256 minPaymentValueWei; + bool requireValidatorSigs; + bool allowCalldataOnlySubmit; + bool allowCCIPIngress; + bool enforcePreviousBatchId; + bool paused; + uint64 expectedSourceChainSelector; + address batchEmitterOnSource; + address ccipRouter; + address legacyMirrorV1; + address legacyTetherV1; + address submitterAttestationSigner; + mapping(uint64 => CheckpointHeader) checkpoints; + mapping(bytes32 => bool) processedProofHashes; + mapping(bytes32 => uint64) txHashToBatchId; + mapping(uint64 => bool) processedBatchIds; + mapping(bytes32 => ExtensionConfig) extensions; + bytes32[] extensionList; + uint256[37] __gap; + } + + struct CheckpointHeader { + uint64 batchId; + uint64 previousBatchId; + uint64 chainId; + uint256 checkpointBlock; + uint256 startBlock; + uint256 endBlock; + bytes32 blockHash; + bytes32 stateRoot; + bytes32 paymentsRoot; + bytes32 receiptsRoot; + uint16 txCount; + uint32 flags; + uint64 submittedAt; + address submitter; + bytes32 contentURI; + } + + struct ExtensionConfig { + address module; + uint32 hooks; + bool active; + } + + // keccak256(abi.encode(uint256(keccak256("dbis.storage.Chain138MainnetCheckpoint")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant CHECKPOINT_STORAGE_LOCATION = + 0x94db105fe8bb9354ca3efbce04a4b0527821f7a844cf050ca0ad49fe0d3aad00; + + function get() internal pure returns (CheckpointStorageStruct storage $) { + assembly { + $.slot := CHECKPOINT_STORAGE_LOCATION + } + } +} diff --git a/contracts/ops/CWMirrorMeshBatch.sol b/contracts/ops/CWMirrorMeshBatch.sol new file mode 100644 index 0000000..7629095 --- /dev/null +++ b/contracts/ops/CWMirrorMeshBatch.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +interface IMintable { + function mint(address to, uint256 amount) external; +} + +interface ICWMultiTokenBridgeL1 { + function lockAndSend( + address canonicalToken, + uint64 destinationChainSelector, + address recipient, + uint256 amount + ) external payable returns (bytes32); + + function calculateFee( + address canonicalToken, + uint64 destinationChainSelector, + address recipient, + uint256 amount + ) external view returns (uint256); +} + +/** + * @notice Batch mint + bridge for c* mirror mesh prep on Chain 138. + * @dev Called by the operator EOA. Grant IMintable.MINTER_ROLE to this contract on each c* token. + * Multicall3 cannot replace this: subcalls would see msg.sender = Multicall3, not the operator. + */ +contract CWMirrorMeshBatch { + using SafeERC20 for IERC20; + + address public immutable bridge; + address public immutable link; + + event MintMany(address indexed operator, uint256 callCount); + event BridgeMany(address indexed operator, address indexed token, uint256 chunkCount, uint256 totalAmount); + + constructor(address bridge_, address link_) { + require(bridge_ != address(0), "zero bridge"); + require(link_ != address(0), "zero link"); + bridge = bridge_; + link = link_; + } + + /// @dev Mint each amount to msg.sender (operator) in one transaction. + function mintMany(address[] calldata tokens, uint256[] calldata amounts) external { + uint256 n = tokens.length; + require(n == amounts.length, "length mismatch"); + for (uint256 i; i < n; ) { + IMintable(tokens[i]).mint(msg.sender, amounts[i]); + unchecked { + ++i; + } + } + emit MintMany(msg.sender, n); + } + + /// @dev Mint one token repeatedly to msg.sender (same token, many chunks) in one transaction. + function mintChunks(address token, uint256[] calldata amounts) external { + uint256 n = amounts.length; + for (uint256 i; i < n; ) { + IMintable(token).mint(msg.sender, amounts[i]); + unchecked { + ++i; + } + } + emit MintMany(msg.sender, n); + } + + /** + * @dev Bridge one canonical token in multiple lockAndSend chunks from msg.sender. + * Pulls total token + total LINK fee upfront, then loops lockAndSend. + */ + function bridgeTokenChunks( + address canonicalToken, + uint64 destinationChainSelector, + address recipient, + uint256[] calldata amounts + ) external payable { + uint256 n = amounts.length; + require(n > 0, "empty amounts"); + + uint256 totalAmount; + uint256 totalFee; + for (uint256 i; i < n; ) { + totalAmount += amounts[i]; + totalFee += ICWMultiTokenBridgeL1(bridge).calculateFee( + canonicalToken, + destinationChainSelector, + recipient, + amounts[i] + ); + unchecked { + ++i; + } + } + + IERC20(canonicalToken).safeTransferFrom(msg.sender, address(this), totalAmount); + IERC20(link).safeTransferFrom(msg.sender, address(this), totalFee); + + for (uint256 i; i < n; ) { + uint256 amount = amounts[i]; + uint256 fee = ICWMultiTokenBridgeL1(bridge).calculateFee( + canonicalToken, + destinationChainSelector, + recipient, + amount + ); + + IERC20(canonicalToken).forceApprove(bridge, amount); + IERC20(link).forceApprove(bridge, fee); + + ICWMultiTokenBridgeL1(bridge).lockAndSend( + canonicalToken, + destinationChainSelector, + recipient, + amount + ); + + unchecked { + ++i; + } + } + + emit BridgeMany(msg.sender, canonicalToken, n, totalAmount); + } +} diff --git a/contracts/registry/UniversalAssetRegistry.sol b/contracts/registry/UniversalAssetRegistry.sol index 39e7e8b..15f60b5 100644 --- a/contracts/registry/UniversalAssetRegistry.sol +++ b/contracts/registry/UniversalAssetRegistry.sol @@ -391,6 +391,33 @@ contract UniversalAssetRegistry is ); } + /** + * @notice Register an M00 Li* RWA index token (LiXAU, LiPMG, LiBMG*) — not c* eMoney. + * @dev Only REGISTRAR_ROLE. AssetType.RealWorldAsset for bridge/routing policy separation from GRU/c*. + */ + function registerRWAIndexAsset( + address tokenAddress, + string calldata name, + string calldata symbol, + uint8 decimals, + string calldata jurisdiction + ) external onlyRole(REGISTRAR_ROLE) { + require(tokenAddress != address(0), "Zero address"); + require(!assets[tokenAddress].isActive, "Already registered"); + _registerAssetDirect( + tokenAddress, + AssetType.RealWorldAsset, + ComplianceLevel.Institutional, + name, + symbol, + decimals, + jurisdiction, + 50, + 1, + type(uint256).max / 2 + ); + } + /** * @notice Internal asset registration from proposal */ diff --git a/contracts/relay/CCIPRelayBridgeLINK.sol b/contracts/relay/CCIPRelayBridgeLINK.sol new file mode 100644 index 0000000..96b162a --- /dev/null +++ b/contracts/relay/CCIPRelayBridgeLINK.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "../ccip/IRouterClient.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/access/AccessControl.sol"; + +/** + * @title CCIP Relay Bridge (LINK) + * @notice Accepts relayed CCIP messages carrying canonical LINK on the destination chain. + * @dev Companion to CCIPRelayBridge (WETH9-only). Grant ROUTER_ROLE to the same CCIPRelayRouter. + */ +contract CCIPRelayBridgeLINK is AccessControl { + bytes32 public constant ROUTER_ROLE = keccak256("ROUTER_ROLE"); + + address public immutable link; + address public relayRouter; + + mapping(bytes32 => bool) public processedTransfers; + + event CrossChainTransferCompleted( + bytes32 indexed messageId, + uint64 indexed sourceChainSelector, + address indexed recipient, + uint256 amount + ); + + constructor(address _link, address _relayRouter) { + require(_link != address(0), "CCIPRelayBridgeLINK: zero LINK"); + require(_relayRouter != address(0), "CCIPRelayBridgeLINK: zero router"); + link = _link; + relayRouter = _relayRouter; + _grantRole(ROUTER_ROLE, _relayRouter); + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + + function updateRelayRouter(address newRelayRouter) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(newRelayRouter != address(0), "CCIPRelayBridgeLINK: zero address"); + _revokeRole(ROUTER_ROLE, relayRouter); + relayRouter = newRelayRouter; + _grantRole(ROUTER_ROLE, newRelayRouter); + } + + function ccipReceive(IRouterClient.Any2EVMMessage calldata message) external onlyRole(ROUTER_ROLE) { + require(!processedTransfers[message.messageId], "CCIPRelayBridgeLINK: already processed"); + processedTransfers[message.messageId] = true; + + require(message.tokenAmounts.length > 0, "CCIPRelayBridgeLINK: no tokens"); + require(message.tokenAmounts[0].token == link, "CCIPRelayBridgeLINK: invalid token"); + + uint256 amount = message.tokenAmounts[0].amount; + require(amount > 0, "CCIPRelayBridgeLINK: invalid amount"); + + address recipient; + if (message.data.length == 64) { + (recipient, ) = abi.decode(message.data, (address, uint256)); + } else if (message.data.length >= 128) { + (recipient, , , ) = abi.decode(message.data, (address, uint256, address, uint256)); + } else { + revert("CCIPRelayBridgeLINK: invalid data"); + } + require(recipient != address(0), "CCIPRelayBridgeLINK: zero recipient"); + + require(IERC20(link).transfer(recipient, amount), "CCIPRelayBridgeLINK: transfer failed"); + + emit CrossChainTransferCompleted(message.messageId, message.sourceChainSelector, recipient, amount); + } +} diff --git a/contracts/rwa/IRWAToken.sol b/contracts/rwa/IRWAToken.sol new file mode 100644 index 0000000..a84159a --- /dev/null +++ b/contracts/rwa/IRWAToken.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title IRWAToken + * @notice M00 GRU commodity index token (Li*) — institutional RWA index, not M1 eMoney. + */ +interface IRWAToken { + struct Classification { + bytes32 indexTicker; + bytes32 assetClass; + bytes32 assetGroup; + bytes32 instrumentType; + bytes32 underlyingAsset; + bytes32 gruLayer; + } + + function indexTicker() external view returns (string memory); + function classification() external view returns (Classification memory); + function indexValue() external view returns (uint256); + function indexValueDecimals() external view returns (uint8); + function indexUpdatedAt() external view returns (uint256); + function methodologyDocumentHash() external view returns (bytes32); + function isRwaIndex() external pure returns (bool); + function isEmoney() external pure returns (bool); + + function updateIndexValue(uint256 newValue) external; + + event IndexValueUpdated(uint256 oldValue, uint256 newValue, address indexed publisher); +} diff --git a/contracts/rwa/IRWATokenFactory.sol b/contracts/rwa/IRWATokenFactory.sol new file mode 100644 index 0000000..879f02b --- /dev/null +++ b/contracts/rwa/IRWATokenFactory.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title IRWATokenFactory + * @notice Deploy and register M00 Li* RWA index tokens. + */ +interface IRWATokenFactory { + struct RWAProductConfig { + string indexTicker; + string name; + string symbol; + uint8 decimals; + string assetClass; + string assetGroup; + string instrumentType; + string underlyingAsset; + string gruLayer; + string jurisdiction; + address initialOwner; + address complianceAdmin; + address indexPublisher; + uint256 initialIndexValue; + uint256 initialSupply; + bytes32 methodologyDocumentHash; + bool registerInUniversalAssetRegistry; + } + + function deployRWAIndex(RWAProductConfig calldata config) external returns (address token); + + function rwaTokenRegistry() external view returns (address); + function universalAssetRegistry() external view returns (address); + + event RWAIndexDeployed( + string indexed indexTicker, + address indexed token, + string symbol, + address indexed owner + ); +} diff --git a/contracts/rwa/IRWATokenRegistry.sol b/contracts/rwa/IRWATokenRegistry.sol new file mode 100644 index 0000000..bd17f9a --- /dev/null +++ b/contracts/rwa/IRWATokenRegistry.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "./IRWAToken.sol"; + +/** + * @title IRWATokenRegistry + * @notice Canonical registry for deployed Li* RWA index tokens on a chain. + */ +interface IRWATokenRegistry { + struct IndexRecord { + address token; + string indexTicker; + string symbol; + IRWAToken.Classification classification; + bool isActive; + uint256 registeredAt; + } + + function registerIndex( + string calldata indexTicker, + address token, + string calldata symbol + ) external; + + function getToken(string calldata indexTicker) external view returns (address); + function getRecord(string calldata indexTicker) external view returns (IndexRecord memory); + function isRegistered(string calldata indexTicker) external view returns (bool); + function deactivateIndex(string calldata indexTicker) external; + + event IndexRegistered(string indexed indexTicker, address indexed token, string symbol); + event IndexDeactivated(string indexed indexTicker, address indexed token); +} diff --git a/contracts/rwa/IUniversalAssetRegistryRWA.sol b/contracts/rwa/IUniversalAssetRegistryRWA.sol new file mode 100644 index 0000000..e217b0a --- /dev/null +++ b/contracts/rwa/IUniversalAssetRegistryRWA.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title IUniversalAssetRegistryRWA + * @notice Minimal UAR surface for RWATokenFactory registration (avoids pulling full registry compile graph). + */ +interface IUniversalAssetRegistryRWA { + function REGISTRAR_ROLE() external view returns (bytes32); + + function registerRWAIndexAsset( + address tokenAddress, + string calldata name, + string calldata symbol, + uint8 decimals, + string calldata jurisdiction + ) external; +} diff --git a/contracts/rwa/RWAEIP712.sol b/contracts/rwa/RWAEIP712.sol new file mode 100644 index 0000000..fbe0e73 --- /dev/null +++ b/contracts/rwa/RWAEIP712.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title RWAEIP712 + * @notice EIP-712 domain and type hashes for index publisher attestations (off-chain or on-chain verify). + */ +library RWAEIP712 { + bytes32 public constant INDEX_VALUE_TYPEHASH = keccak256( + "IndexValueAttestation(string indexTicker,uint256 newValue,uint256 nonce,uint256 deadline)" + ); + + function domainSeparator( + string memory name, + string memory version, + uint256 chainId, + address verifyingContract + ) internal pure returns (bytes32) { + bytes32 typeHash = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + return keccak256( + abi.encode( + typeHash, + keccak256(bytes(name)), + keccak256(bytes(version)), + chainId, + verifyingContract + ) + ); + } + + function hashIndexAttestation( + bytes32 domain, + string memory indexTicker, + uint256 newValue, + uint256 nonce, + uint256 deadline + ) internal pure returns (bytes32) { + bytes32 structHash = keccak256( + abi.encode(INDEX_VALUE_TYPEHASH, keccak256(bytes(indexTicker)), newValue, nonce, deadline) + ); + return keccak256(abi.encodePacked("\x19\x01", domain, structHash)); + } +} diff --git a/contracts/rwa/RWAToken.sol b/contracts/rwa/RWAToken.sol new file mode 100644 index 0000000..2c8b68a --- /dev/null +++ b/contracts/rwa/RWAToken.sol @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import "@openzeppelin/contracts/utils/Pausable.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import "../compliance/LegallyCompliantBase.sol"; +import "./IRWAToken.sol"; +import "./RWATokenInterfaces.sol"; +import "./RWAEIP712.sol"; +import "./libraries/RWATaxonomy.sol"; + +/** + * @title RWAToken + * @notice ERC-20 representing an M00 GRU liquidity index (LiXAU, LiPMG, LiBMG*). + * @dev Not CompliantFiatToken / c* eMoney. Supply and indexValue are governed off-chain policy; + * on-chain indexValue is an attested level (6 decimals) for integrations and oracles. + */ +contract RWAToken is ERC20, Pausable, Ownable, LegallyCompliantBase, IRWAToken { + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + bytes32 public constant INDEX_PUBLISHER_ROLE = keccak256("INDEX_PUBLISHER_ROLE"); + string public constant EIP712_VERSION = "1"; + + uint8 public constant INDEX_VALUE_DECIMALS = 6; + + uint8 private immutable _decimalsStorage; + bytes32 private immutable _methodologyDocumentHash; + string private _indexTicker; + Classification private _classification; + uint256 private _indexValue; + uint256 private _indexUpdatedAt; + + constructor( + string memory name_, + string memory symbol_, + uint8 decimals_, + string memory indexTicker_, + string memory assetClass_, + string memory assetGroup_, + string memory instrumentType_, + string memory underlyingAsset_, + string memory gruLayer_, + address initialOwner, + address complianceAdmin, + address indexPublisher, + uint256 initialIndexValue_, + uint256 initialSupply_, + bytes32 methodologyDocumentHash_ + ) ERC20(name_, symbol_) Ownable(initialOwner) LegallyCompliantBase(complianceAdmin) { + require(decimals_ <= 18, "RWAToken: decimals"); + _decimalsStorage = decimals_; + require(methodologyDocumentHash_ != bytes32(0), "RWAToken: methodology hash"); + _methodologyDocumentHash = methodologyDocumentHash_; + bytes32 tickerHash = RWATaxonomy.validateProduct(indexTicker_, assetClass_, gruLayer_); + + _indexTicker = indexTicker_; + _classification = Classification({ + indexTicker: tickerHash, + assetClass: RWATaxonomy.hashString(assetClass_), + assetGroup: RWATaxonomy.hashString(assetGroup_), + instrumentType: RWATaxonomy.hashString(instrumentType_), + underlyingAsset: RWATaxonomy.hashString(underlyingAsset_), + gruLayer: RWATaxonomy.hashString(gruLayer_) + }); + + _grantRole(MINTER_ROLE, initialOwner); + _grantRole(INDEX_PUBLISHER_ROLE, indexPublisher); + + _setIndexValue(initialIndexValue_); + + if (initialSupply_ > 0) { + _mint(msg.sender, initialSupply_); + } + } + + function decimals() public view override(ERC20) returns (uint8) { + return _decimalsStorage; + } + + function indexTicker() external view override returns (string memory) { + return _indexTicker; + } + + function classification() external view override returns (Classification memory) { + return _classification; + } + + function indexValue() external view override returns (uint256) { + return _indexValue; + } + + function indexValueDecimals() external pure override returns (uint8) { + return INDEX_VALUE_DECIMALS; + } + + function indexUpdatedAt() external view override returns (uint256) { + return _indexUpdatedAt; + } + + function methodologyDocumentHash() external view override returns (bytes32) { + return _methodologyDocumentHash; + } + + function isRwaIndex() external pure override returns (bool) { + return true; + } + + function isEmoney() external pure override returns (bool) { + return false; + } + + function eip20Version() external pure returns (string memory) { + return "1.0.0"; + } + + /// @notice EIP-712 domain for off-chain `IndexValueAttestation` signatures (publisher role). + function eip712DomainSeparator() external view returns (bytes32) { + return _eip712Domain(); + } + + function hashIndexValueAttestation( + string calldata ticker, + uint256 newValue, + uint256 nonce, + uint256 deadline + ) external view returns (bytes32) { + return RWAEIP712.hashIndexAttestation(_eip712Domain(), ticker, newValue, nonce, deadline); + } + + function _eip712Domain() internal view returns (bytes32) { + return RWAEIP712.domainSeparator(name(), EIP712_VERSION, block.chainid, address(this)); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(AccessControl) + returns (bool) + { + return interfaceId == type(IRWAToken).interfaceId + || interfaceId == type(IRWAToken165).interfaceId + || interfaceId == type(IERC20).interfaceId + || interfaceId == type(IERC20Metadata).interfaceId + || super.supportsInterface(interfaceId); + } + + function updateIndexValue(uint256 newValue) external override onlyRole(INDEX_PUBLISHER_ROLE) { + uint256 old = _indexValue; + _setIndexValue(newValue); + emit IndexValueUpdated(old, newValue, msg.sender); + } + + function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) { + _mint(to, amount); + } + + function burn(uint256 amount) external { + _burn(msg.sender, amount); + } + + function pause() external onlyOwner { + _pause(); + } + + function unpause() external onlyOwner { + _unpause(); + } + + function _setIndexValue(uint256 newValue) internal { + _indexValue = newValue; + _indexUpdatedAt = block.timestamp; + } + + function _update(address from, address to, uint256 amount) internal override whenNotPaused { + super._update(from, to, amount); + if (from != address(0) && to != address(0)) { + bytes32 legalRefHash = _generateLegalReferenceHash( + from, + to, + amount, + abi.encodePacked(symbol(), " RWA Index Transfer") + ); + emit ValueTransferDeclared(from, to, amount, legalRefHash); + } + } +} diff --git a/contracts/rwa/RWATokenFactory.sol b/contracts/rwa/RWATokenFactory.sol new file mode 100644 index 0000000..fbaab41 --- /dev/null +++ b/contracts/rwa/RWATokenFactory.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "./IRWATokenFactory.sol"; +import "./RWAToken.sol"; +import "./IRWATokenRegistry.sol"; +import "./libraries/RWATaxonomy.sol"; +import "./IUniversalAssetRegistryRWA.sol"; + +/** + * @title RWATokenFactory + * @notice Canonical factory for Li* M00 commodity index tokens (RWA indices, not c* eMoney). + * @dev One active deployment per indexTicker per chain. Taxonomy aligned with + * config/rwa-capital-markets-taxonomy.v1.json (off-chain source of product metadata). + */ +contract RWATokenFactory is AccessControl, ReentrancyGuard, IRWATokenFactory { + bytes32 public constant DEPLOYER_ROLE = keccak256("DEPLOYER_ROLE"); + + IRWATokenRegistry private immutable _rwaTokenRegistry; + address private immutable _universalAssetRegistry; + + constructor( + address admin, + address rwaTokenRegistry_, + address universalAssetRegistry_ + ) { + require(rwaTokenRegistry_ != address(0), "RWATokenFactory: registry"); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(DEPLOYER_ROLE, admin); + _rwaTokenRegistry = IRWATokenRegistry(rwaTokenRegistry_); + _universalAssetRegistry = universalAssetRegistry_; + } + + function rwaTokenRegistry() external view returns (address) { + return address(_rwaTokenRegistry); + } + + function universalAssetRegistry() external view returns (address) { + return _universalAssetRegistry; + } + + function deployRWAIndex(RWAProductConfig calldata config) + external + onlyRole(DEPLOYER_ROLE) + nonReentrant + returns (address token) + { + RWATaxonomy.validateProduct(config.indexTicker, config.assetClass, config.gruLayer); + require( + !_rwaTokenRegistry.isRegistered(config.indexTicker), + "RWATokenFactory: already registered" + ); + require(config.initialOwner != address(0), "RWATokenFactory: owner"); + require(config.complianceAdmin != address(0), "RWATokenFactory: compliance"); + require(config.indexPublisher != address(0), "RWATokenFactory: publisher"); + require(config.methodologyDocumentHash != bytes32(0), "RWATokenFactory: methodology"); + + RWAToken deployed = new RWAToken( + config.name, + config.symbol, + config.decimals, + config.indexTicker, + config.assetClass, + config.assetGroup, + config.instrumentType, + config.underlyingAsset, + config.gruLayer, + config.initialOwner, + config.complianceAdmin, + config.indexPublisher, + config.initialIndexValue, + config.initialSupply, + config.methodologyDocumentHash + ); + token = address(deployed); + + _rwaTokenRegistry.registerIndex(config.indexTicker, token, config.symbol); + + if (config.registerInUniversalAssetRegistry && _universalAssetRegistry != address(0)) { + IUniversalAssetRegistryRWA(_universalAssetRegistry).registerRWAIndexAsset( + token, + config.name, + config.symbol, + config.decimals, + config.jurisdiction + ); + } + + emit RWAIndexDeployed(config.indexTicker, token, config.symbol, config.initialOwner); + } +} diff --git a/contracts/rwa/RWATokenInterfaces.sol b/contracts/rwa/RWATokenInterfaces.sol new file mode 100644 index 0000000..f704ccc --- /dev/null +++ b/contracts/rwa/RWATokenInterfaces.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import "./IRWAToken.sol"; + +/** + * @title IRWAToken165 + * @notice EIP-165 aggregate for RWAToken integrations. + */ +interface IRWAToken165 is IERC165, IERC20, IERC20Metadata, IRWAToken { + function eip20Version() external pure returns (string memory); + function eip712DomainSeparator() external view returns (bytes32); + function hashIndexValueAttestation( + string calldata ticker, + uint256 newValue, + uint256 nonce, + uint256 deadline + ) external view returns (bytes32); +} diff --git a/contracts/rwa/RWATokenRegistry.sol b/contracts/rwa/RWATokenRegistry.sol new file mode 100644 index 0000000..88a9849 --- /dev/null +++ b/contracts/rwa/RWATokenRegistry.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "./IRWATokenRegistry.sol"; +import "./IRWAToken.sol"; +import "./libraries/RWATaxonomy.sol"; + +/** + * @title RWATokenRegistry + * @notice Single source of on-chain truth for Li* index ticker → token address. + */ +contract RWATokenRegistry is AccessControl, IRWATokenRegistry { + bytes32 public constant REGISTRAR_ROLE = keccak256("REGISTRAR_ROLE"); + + mapping(bytes32 => IndexRecord) private _records; + bytes32[] private _tickers; + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(REGISTRAR_ROLE, admin); + } + + function registerIndex( + string calldata indexTicker, + address token, + string calldata symbol + ) external onlyRole(REGISTRAR_ROLE) { + require(token != address(0), "RWATokenRegistry: zero token"); + bytes32 key = RWATaxonomy.validateProduct(indexTicker, "Commodities", "M00"); + require(!_records[key].isActive, "RWATokenRegistry: exists"); + + IRWAToken rwa = IRWAToken(token); + require(rwa.isRwaIndex(), "RWATokenRegistry: not RWA"); + require(!rwa.isEmoney(), "RWATokenRegistry: emoney forbidden"); + require( + keccak256(bytes(rwa.indexTicker())) == key, + "RWATokenRegistry: ticker mismatch" + ); + + _records[key] = IndexRecord({ + token: token, + indexTicker: indexTicker, + symbol: symbol, + classification: rwa.classification(), + isActive: true, + registeredAt: block.timestamp + }); + _tickers.push(key); + + emit IndexRegistered(indexTicker, token, symbol); + } + + function getToken(string calldata indexTicker) external view returns (address) { + bytes32 key = RWATaxonomy.hashString(indexTicker); + require(_records[key].isActive, "RWATokenRegistry: unknown"); + return _records[key].token; + } + + function getRecord(string calldata indexTicker) external view returns (IndexRecord memory) { + bytes32 key = RWATaxonomy.hashString(indexTicker); + require(_records[key].isActive, "RWATokenRegistry: unknown"); + return _records[key]; + } + + function isRegistered(string calldata indexTicker) external view returns (bool) { + bytes32 key = RWATaxonomy.hashString(indexTicker); + return _records[key].isActive; + } + + function deactivateIndex(string calldata indexTicker) external onlyRole(DEFAULT_ADMIN_ROLE) { + bytes32 key = RWATaxonomy.hashString(indexTicker); + IndexRecord storage rec = _records[key]; + require(rec.isActive, "RWATokenRegistry: inactive"); + rec.isActive = false; + emit IndexDeactivated(indexTicker, rec.token); + } + + function tickerCount() external view returns (uint256) { + return _tickers.length; + } +} diff --git a/contracts/rwa/diamond/RWAStorage.sol b/contracts/rwa/diamond/RWAStorage.sol new file mode 100644 index 0000000..ece412f --- /dev/null +++ b/contracts/rwa/diamond/RWAStorage.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title RWAStorage + * @notice ERC-7201 namespaced app storage for RWA ERC-2535 facets (upgrade-safe, expandable). + * @dev Attach facets to M00 Diamond or a dedicated RWAM00Diamond; do not collide with GRUStorage slots. + */ +library RWAStorage { + /// @custom:storage-location erc7201:dbis.storage.RWA + bytes32 private constant RWA_STORAGE_LOCATION = + 0x8a3c9e1f4b2d7065c8e0a1f3d9b4e7c2a5f8d1b6e3c9a0f4d7b2e5c8a1f3d900; + + /// @notice How the instrument is issued — not a public market security offering by default. + enum IssuanceMode { + Unset, + ThirdPartyTokenization, + PrivateClosedLoop, + CommodityIndex, + InternalLedgerOnly + } + + /// @notice Off-chain payload scheme (URI stored off-chain; on-chain: hash + scheme id). + enum UriScheme { + None, + IPFS, + Filecoin, + Arweave, + HTTPS, + InternalDB + } + + struct DocumentRef { + bytes32 contentHash; + bytes32 uriHash; + UriScheme scheme; + uint64 updatedAt; + } + + struct InstrumentIdentity { + bytes32 isinHash; + bytes32 cusipHash; + bytes32 issuerLeiHash; + bytes32 underlyingIsinHash; + } + + struct AssetRecord { + address tokenPointer; + IssuanceMode issuanceMode; + InstrumentIdentity identity; + bytes32 primaryContentHash; + DocumentRef[] documents; + mapping(bytes32 standardId => address) standardFacets; + } + + struct Layout { + mapping(bytes32 assetId => AssetRecord) assets; + mapping(bytes32 standardId => bool) enabledStandards; + address governance; + address documentAdmin; + } + + function layout() internal pure returns (Layout storage l) { + bytes32 slot = RWA_STORAGE_LOCATION; + assembly { + l.slot := slot + } + } +} diff --git a/contracts/rwa/diamond/facets/RWADocumentFacet.sol b/contracts/rwa/diamond/facets/RWADocumentFacet.sol new file mode 100644 index 0000000..26e12d3 --- /dev/null +++ b/contracts/rwa/diamond/facets/RWADocumentFacet.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "../RWAStorage.sol"; +import "../libraries/RWAUriCodec.sol"; +import "../interfaces/IRWADocumentFacet.sol"; + +/** + * @title RWADocumentFacet + * @notice Anchor document URIs (IPFS, Filecoin, Arweave, HTTPS, internal) with content hashes. + */ +contract RWADocumentFacet is AccessControl, IRWADocumentFacet { + bytes32 public constant DOCUMENT_ADMIN_ROLE = keccak256("DOCUMENT_ADMIN_ROLE"); + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(DOCUMENT_ADMIN_ROLE, admin); + RWAStorage.layout().documentAdmin = admin; + } + + function anchorDocument(bytes32 assetId, string calldata uri, bytes32 contentHash) + external + onlyRole(DOCUMENT_ADMIN_ROLE) + returns (uint256 docIndex) + { + require(contentHash != bytes32(0), "RWADocument: hash"); + RWAStorage.UriScheme scheme = RWAUriCodec.detectScheme(uri); + require(scheme != RWAStorage.UriScheme.None, "RWADocument: scheme"); + + RWAStorage.DocumentRef memory doc = RWAStorage.DocumentRef({ + contentHash: contentHash, + uriHash: RWAUriCodec.uriHash(uri), + scheme: scheme, + updatedAt: uint64(block.timestamp) + }); + + RWAStorage.Layout storage l = RWAStorage.layout(); + l.assets[assetId].documents.push(doc); + docIndex = l.assets[assetId].documents.length - 1; + emit DocumentAnchored(assetId, contentHash, doc.uriHash, scheme, docIndex); + } + + function setPrimaryContentHash(bytes32 assetId, bytes32 contentHash) + external + onlyRole(DOCUMENT_ADMIN_ROLE) + { + require(contentHash != bytes32(0), "RWADocument: hash"); + RWAStorage.layout().assets[assetId].primaryContentHash = contentHash; + emit PrimaryContentHashSet(assetId, contentHash); + } + + function documentCount(bytes32 assetId) external view returns (uint256) { + return RWAStorage.layout().assets[assetId].documents.length; + } + + function getDocument(bytes32 assetId, uint256 index) + external + view + returns (bytes32 contentHash, bytes32 uriHash, RWAStorage.UriScheme scheme, uint64 updatedAt) + { + RWAStorage.DocumentRef storage doc = RWAStorage.layout().assets[assetId].documents[index]; + return (doc.contentHash, doc.uriHash, doc.scheme, doc.updatedAt); + } +} diff --git a/contracts/rwa/diamond/facets/RWAInstrumentFacet.sol b/contracts/rwa/diamond/facets/RWAInstrumentFacet.sol new file mode 100644 index 0000000..242c512 --- /dev/null +++ b/contracts/rwa/diamond/facets/RWAInstrumentFacet.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "../RWAStorage.sol"; +import "../interfaces/IRWAInstrumentFacet.sol"; + +/** + * @title RWAInstrumentFacet + * @notice Issuance mode + ISIN/CUSIP identity hashes + pointer to external ERC-20 (tokenization). + * @dev Private closed-loop: ISIN/CUSIP are internal accounting anchors (hashed on-chain; full strings in IPFS/Filecoin). + */ +contract RWAInstrumentFacet is AccessControl, IRWAInstrumentFacet { + bytes32 public constant INSTRUMENT_ADMIN_ROLE = keccak256("INSTRUMENT_ADMIN_ROLE"); + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(INSTRUMENT_ADMIN_ROLE, admin); + RWAStorage.layout().governance = admin; + } + + function setIssuanceMode(bytes32 assetId, RWAStorage.IssuanceMode mode) external onlyRole(INSTRUMENT_ADMIN_ROLE) { + require(mode != RWAStorage.IssuanceMode.Unset, "RWAInstrument: mode"); + RWAStorage.Layout storage l = RWAStorage.layout(); + l.assets[assetId].issuanceMode = mode; + emit IssuanceModeSet(assetId, mode); + } + + function setInstrumentIdentity( + bytes32 assetId, + bytes32 isinHash, + bytes32 cusipHash, + bytes32 issuerLeiHash, + bytes32 underlyingIsinHash + ) external onlyRole(INSTRUMENT_ADMIN_ROLE) { + RWAStorage.InstrumentIdentity storage id = RWAStorage.layout().assets[assetId].identity; + id.isinHash = isinHash; + id.cusipHash = cusipHash; + id.issuerLeiHash = issuerLeiHash; + id.underlyingIsinHash = underlyingIsinHash; + emit InstrumentIdentitySet(assetId, isinHash, cusipHash, issuerLeiHash); + } + + function setTokenPointer(bytes32 assetId, address token) external onlyRole(INSTRUMENT_ADMIN_ROLE) { + require(token != address(0), "RWAInstrument: token"); + RWAStorage.layout().assets[assetId].tokenPointer = token; + emit TokenPointerSet(assetId, token); + } + + function getIssuanceMode(bytes32 assetId) external view returns (RWAStorage.IssuanceMode) { + return RWAStorage.layout().assets[assetId].issuanceMode; + } + + function getTokenPointer(bytes32 assetId) external view returns (address) { + return RWAStorage.layout().assets[assetId].tokenPointer; + } +} diff --git a/contracts/rwa/diamond/facets/RWAStandardsRegistryFacet.sol b/contracts/rwa/diamond/facets/RWAStandardsRegistryFacet.sol new file mode 100644 index 0000000..2b06df5 --- /dev/null +++ b/contracts/rwa/diamond/facets/RWAStandardsRegistryFacet.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "../RWAStorage.sol"; +import "../interfaces/IRWAStandardsRegistryFacet.sol"; + +/** + * @title RWAStandardsRegistryFacet + * @notice Enable future EIP/ERC facets (e.g. ERC-3643 partition facet) via diamond cut without hub redeploy. + */ +contract RWAStandardsRegistryFacet is AccessControl, IRWAStandardsRegistryFacet { + bytes32 public constant STANDARDS_ADMIN_ROLE = keccak256("STANDARDS_ADMIN_ROLE"); + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(STANDARDS_ADMIN_ROLE, admin); + } + + function enableStandard(bytes32 standardId, address facet) external onlyRole(STANDARDS_ADMIN_ROLE) { + require(facet != address(0), "RWAStandards: facet"); + RWAStorage.layout().enabledStandards[standardId] = true; + emit StandardEnabled(standardId, facet); + } + + function disableStandard(bytes32 standardId) external onlyRole(STANDARDS_ADMIN_ROLE) { + RWAStorage.layout().enabledStandards[standardId] = false; + emit StandardDisabled(standardId); + } + + function bindAssetStandardFacet(bytes32 assetId, bytes32 standardId, address facet) + external + onlyRole(STANDARDS_ADMIN_ROLE) + { + require(RWAStorage.layout().enabledStandards[standardId], "RWAStandards: disabled"); + require(facet != address(0), "RWAStandards: facet"); + RWAStorage.layout().assets[assetId].standardFacets[standardId] = facet; + emit AssetStandardFacetBound(assetId, standardId, facet); + } + + function isStandardEnabled(bytes32 standardId) external view returns (bool) { + return RWAStorage.layout().enabledStandards[standardId]; + } + + function assetStandardFacet(bytes32 assetId, bytes32 standardId) external view returns (address) { + return RWAStorage.layout().assets[assetId].standardFacets[standardId]; + } +} diff --git a/contracts/rwa/diamond/interfaces/IRWADocumentFacet.sol b/contracts/rwa/diamond/interfaces/IRWADocumentFacet.sol new file mode 100644 index 0000000..149ee14 --- /dev/null +++ b/contracts/rwa/diamond/interfaces/IRWADocumentFacet.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "../RWAStorage.sol"; + +interface IRWADocumentFacet { + event DocumentAnchored( + bytes32 indexed assetId, + bytes32 contentHash, + bytes32 uriHash, + RWAStorage.UriScheme scheme, + uint256 docIndex + ); + event PrimaryContentHashSet(bytes32 indexed assetId, bytes32 contentHash); + + function anchorDocument(bytes32 assetId, string calldata uri, bytes32 contentHash) external returns (uint256 docIndex); + function setPrimaryContentHash(bytes32 assetId, bytes32 contentHash) external; + function documentCount(bytes32 assetId) external view returns (uint256); + function getDocument(bytes32 assetId, uint256 index) + external + view + returns (bytes32 contentHash, bytes32 uriHash, RWAStorage.UriScheme scheme, uint64 updatedAt); +} diff --git a/contracts/rwa/diamond/interfaces/IRWAInstrumentFacet.sol b/contracts/rwa/diamond/interfaces/IRWAInstrumentFacet.sol new file mode 100644 index 0000000..76e3a85 --- /dev/null +++ b/contracts/rwa/diamond/interfaces/IRWAInstrumentFacet.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "../RWAStorage.sol"; + +interface IRWAInstrumentFacet { + event IssuanceModeSet(bytes32 indexed assetId, RWAStorage.IssuanceMode mode); + event InstrumentIdentitySet( + bytes32 indexed assetId, + bytes32 isinHash, + bytes32 cusipHash, + bytes32 issuerLeiHash + ); + event TokenPointerSet(bytes32 indexed assetId, address token); + + function setIssuanceMode(bytes32 assetId, RWAStorage.IssuanceMode mode) external; + function setInstrumentIdentity( + bytes32 assetId, + bytes32 isinHash, + bytes32 cusipHash, + bytes32 issuerLeiHash, + bytes32 underlyingIsinHash + ) external; + function setTokenPointer(bytes32 assetId, address token) external; + function getIssuanceMode(bytes32 assetId) external view returns (RWAStorage.IssuanceMode); + function getTokenPointer(bytes32 assetId) external view returns (address); +} diff --git a/contracts/rwa/diamond/interfaces/IRWAStandardsRegistryFacet.sol b/contracts/rwa/diamond/interfaces/IRWAStandardsRegistryFacet.sol new file mode 100644 index 0000000..c1fecbb --- /dev/null +++ b/contracts/rwa/diamond/interfaces/IRWAStandardsRegistryFacet.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title IRWAStandardsRegistryFacet + * @notice Register future EIP/ERC facet extensions without redeploying the diamond hub. + */ +interface IRWAStandardsRegistryFacet { + event StandardEnabled(bytes32 indexed standardId, address facet); + event StandardDisabled(bytes32 indexed standardId); + event AssetStandardFacetBound(bytes32 indexed assetId, bytes32 indexed standardId, address facet); + + function enableStandard(bytes32 standardId, address facet) external; + function disableStandard(bytes32 standardId) external; + function bindAssetStandardFacet(bytes32 assetId, bytes32 standardId, address facet) external; + function isStandardEnabled(bytes32 standardId) external view returns (bool); + function assetStandardFacet(bytes32 assetId, bytes32 standardId) external view returns (address); +} diff --git a/contracts/rwa/diamond/libraries/RWAUriCodec.sol b/contracts/rwa/diamond/libraries/RWAUriCodec.sol new file mode 100644 index 0000000..7cfda47 --- /dev/null +++ b/contracts/rwa/diamond/libraries/RWAUriCodec.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "../RWAStorage.sol"; + +/** + * @title RWAUriCodec + * @notice Canonical URI hashing for IPFS, Filecoin, Arweave, HTTPS, internal refs. + */ +library RWAUriCodec { + error RWAUriCodec_invalidUri(string uri); + + function uriHash(string memory uri) internal pure returns (bytes32) { + bytes memory b = bytes(uri); + if (b.length == 0) revert RWAUriCodec_invalidUri(uri); + return keccak256(b); + } + + function detectScheme(string memory uri) internal pure returns (RWAStorage.UriScheme) { + bytes memory b = bytes(uri); + if (b.length < 7) return RWAStorage.UriScheme.None; + if (_startsWith(b, "ipfs://")) return RWAStorage.UriScheme.IPFS; + if (_startsWith(b, "filecoin://")) return RWAStorage.UriScheme.Filecoin; + if (_startsWith(b, "ar://") || _startsWith(b, "arweave://")) { + return RWAStorage.UriScheme.Arweave; + } + if (_startsWith(b, "https://")) return RWAStorage.UriScheme.HTTPS; + if (_startsWith(b, "dbis://")) return RWAStorage.UriScheme.InternalDB; + return RWAStorage.UriScheme.None; + } + + function _startsWith(bytes memory data, string memory prefix) private pure returns (bool) { + bytes memory p = bytes(prefix); + if (data.length < p.length) return false; + for (uint256 i = 0; i < p.length; i++) { + if (data[i] != p[i]) return false; + } + return true; + } +} diff --git a/contracts/rwa/libraries/RWATaxonomy.sol b/contracts/rwa/libraries/RWATaxonomy.sol new file mode 100644 index 0000000..2ed673c --- /dev/null +++ b/contracts/rwa/libraries/RWATaxonomy.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title RWATaxonomy + * @notice On-chain validation helpers for M00 Li* index instruments (not M1 c* eMoney). + */ +library RWATaxonomy { + bytes32 public constant GRU_LAYER_M00 = keccak256("M00"); + bytes32 public constant ASSET_CLASS_COMMODITIES = keccak256("Commodities"); + + bytes32 public constant TICKER_LIXAU = keccak256("LiXAU"); + bytes32 public constant TICKER_LIPMG = keccak256("LiPMG"); + bytes32 public constant TICKER_LIBMG1 = keccak256("LiBMG1"); + bytes32 public constant TICKER_LIBMG2 = keccak256("LiBMG2"); + bytes32 public constant TICKER_LIBMG3 = keccak256("LiBMG3"); + + error InvalidIndexTicker(); + error InvalidAssetClass(); + error InvalidGruLayer(); + error EmptyString(); + + function hashString(string memory value) internal pure returns (bytes32) { + return keccak256(bytes(value)); + } + + function isAllowedIndexTicker(bytes32 tickerHash) internal pure returns (bool) { + return tickerHash == TICKER_LIXAU + || tickerHash == TICKER_LIPMG + || tickerHash == TICKER_LIBMG1 + || tickerHash == TICKER_LIBMG2 + || tickerHash == TICKER_LIBMG3; + } + + function validateProduct( + string memory indexTicker, + string memory assetClass, + string memory gruLayer + ) internal pure returns (bytes32 tickerHash) { + if (bytes(indexTicker).length == 0 || bytes(assetClass).length == 0) { + revert EmptyString(); + } + tickerHash = hashString(indexTicker); + if (!isAllowedIndexTicker(tickerHash)) { + revert InvalidIndexTicker(); + } + if (hashString(assetClass) != ASSET_CLASS_COMMODITIES) { + revert InvalidAssetClass(); + } + if (hashString(gruLayer) != GRU_LAYER_M00) { + revert InvalidGruLayer(); + } + } +} diff --git a/contracts/vault/GRUEntityIbanRegistry.sol b/contracts/vault/GRUEntityIbanRegistry.sol new file mode 100644 index 0000000..430b61c --- /dev/null +++ b/contracts/vault/GRUEntityIbanRegistry.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; + +/** + * @title GRUEntityIbanRegistry + * @notice Binds normalized IBAN hashes to regulated entity addresses for GRU vault operations. + * @dev Off-chain normalization: uppercase, strip spaces. Hash = keccak256(bytes(ibanNormalized)). + * Integrates with web3-eth-iban Direct IBAN addresses via `directIbanAddress` field. + */ +contract GRUEntityIbanRegistry is AccessControl { + bytes32 public constant REGISTRAR_ROLE = keccak256("REGISTRAR_ROLE"); + + struct Record { + address entity; + address directIbanAddress; + bytes32 jurisdictionHash; + bool active; + } + + mapping(bytes32 => Record) private _byIbanHash; + mapping(address => bytes32) public primaryIbanHashByEntity; + + event IbanRegistered( + bytes32 indexed ibanHash, + address indexed entity, + address directIbanAddress, + bytes32 jurisdictionHash + ); + event IbanRevoked(bytes32 indexed ibanHash, address indexed entity); + event DirectIbanAddressUpdated(bytes32 indexed ibanHash, address directIbanAddress); + + constructor(address admin) { + require(admin != address(0), "GRUEntityIbanRegistry: zero admin"); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(REGISTRAR_ROLE, admin); + } + + function registerIban( + bytes32 ibanHash, + address entity, + bytes32 jurisdictionHash, + address directIbanAddress + ) external onlyRole(REGISTRAR_ROLE) { + require(ibanHash != bytes32(0), "GRUEntityIbanRegistry: zero hash"); + require(entity != address(0), "GRUEntityIbanRegistry: zero entity"); + require(!_byIbanHash[ibanHash].active, "GRUEntityIbanRegistry: exists"); + + _byIbanHash[ibanHash] = Record({ + entity: entity, + directIbanAddress: directIbanAddress, + jurisdictionHash: jurisdictionHash, + active: true + }); + primaryIbanHashByEntity[entity] = ibanHash; + + emit IbanRegistered(ibanHash, entity, directIbanAddress, jurisdictionHash); + } + + function setDirectIbanAddress(bytes32 ibanHash, address directIbanAddress) external onlyRole(REGISTRAR_ROLE) { + require(_byIbanHash[ibanHash].active, "GRUEntityIbanRegistry: inactive"); + _byIbanHash[ibanHash].directIbanAddress = directIbanAddress; + emit DirectIbanAddressUpdated(ibanHash, directIbanAddress); + } + + function revokeIban(bytes32 ibanHash) external onlyRole(REGISTRAR_ROLE) { + Record storage rec = _byIbanHash[ibanHash]; + require(rec.active, "GRUEntityIbanRegistry: inactive"); + address entity = rec.entity; + rec.active = false; + if (primaryIbanHashByEntity[entity] == ibanHash) { + delete primaryIbanHashByEntity[entity]; + } + emit IbanRevoked(ibanHash, entity); + } + + function resolveEntity(bytes32 ibanHash) external view returns (address entity, bool active) { + Record storage rec = _byIbanHash[ibanHash]; + return (rec.entity, rec.active); + } + + function getRecord(bytes32 ibanHash) + external + view + returns (address entity, address directIbanAddress, bytes32 jurisdictionHash, bool active) + { + Record storage rec = _byIbanHash[ibanHash]; + return (rec.entity, rec.directIbanAddress, rec.jurisdictionHash, rec.active); + } +} diff --git a/contracts/vault/GRUVaultIndex.sol b/contracts/vault/GRUVaultIndex.sol new file mode 100644 index 0000000..ee44312 --- /dev/null +++ b/contracts/vault/GRUVaultIndex.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; + +/** + * @title GRUVaultIndex + * @notice Canonical index of GRU vault instances across tiers, tokens, IBAN, and URA policy profiles. + * @dev Written by VaultFactory (FACTORY_ROLE) or registrar during vault creation. + */ +contract GRUVaultIndex is AccessControl { + bytes32 public constant FACTORY_ROLE = keccak256("FACTORY_ROLE"); + bytes32 public constant REGISTRAR_ROLE = keccak256("REGISTRAR_ROLE"); + + /// @dev GRU monetary tier: 0=M00, 1=M0, 2=M1 + struct VaultRecord { + address entity; + address baseToken; + address depositToken; + address debtToken; + uint8 gruTier; + bytes32 ibanHash; + bytes32 policyProfileKey; + uint256 recordedAt; + bool active; + } + + mapping(address => VaultRecord) public vaults; + address[] public allVaults; + + event VaultRecorded( + address indexed vault, + address indexed entity, + address baseToken, + address depositToken, + address debtToken, + uint8 gruTier, + bytes32 ibanHash, + bytes32 policyProfileKey + ); + + event PolicyProfileKeyPatched(address indexed vault, bytes32 policyProfileKey); + + event VaultImported( + address indexed vault, + address indexed entity, + address baseToken, + bytes32 policyProfileKey + ); + + constructor(address admin) { + require(admin != address(0), "GRUVaultIndex: zero admin"); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(REGISTRAR_ROLE, admin); + } + + function grantFactoryRole(address factory) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(factory != address(0), "GRUVaultIndex: zero factory"); + _grantRole(FACTORY_ROLE, factory); + } + + function recordVault( + address vault, + address entity, + address baseToken, + address depositToken, + address debtToken, + uint8 gruTier, + bytes32 ibanHash, + bytes32 policyProfileKey + ) external onlyRole(FACTORY_ROLE) { + require(vault != address(0), "GRUVaultIndex: zero vault"); + require(!vaults[vault].active, "GRUVaultIndex: exists"); + + vaults[vault] = VaultRecord({ + entity: entity, + baseToken: baseToken, + depositToken: depositToken, + debtToken: debtToken, + gruTier: gruTier, + ibanHash: ibanHash, + policyProfileKey: policyProfileKey, + recordedAt: block.timestamp, + active: true + }); + allVaults.push(vault); + + emit VaultRecorded(vault, entity, baseToken, depositToken, debtToken, gruTier, ibanHash, policyProfileKey); + } + + /// @notice Admin backfill when vaults were indexed before policy profile publish. + function patchPolicyProfileKey(address vault, bytes32 policyProfileKey) + external + onlyRole(DEFAULT_ADMIN_ROLE) + { + require(vaults[vault].active, "GRUVaultIndex: unknown"); + vaults[vault].policyProfileKey = policyProfileKey; + emit PolicyProfileKeyPatched(vault, policyProfileKey); + } + + /// @notice Registrar migration import (e.g. re-index with policy keys from a prior GRUVaultIndex). + function importVault( + address vault, + address entity, + address baseToken, + address depositToken, + address debtToken, + uint8 gruTier, + bytes32 ibanHash, + bytes32 policyProfileKey, + uint256 recordedAt + ) external onlyRole(REGISTRAR_ROLE) { + require(vault != address(0), "GRUVaultIndex: zero vault"); + require(!vaults[vault].active, "GRUVaultIndex: exists"); + + vaults[vault] = VaultRecord({ + entity: entity, + baseToken: baseToken, + depositToken: depositToken, + debtToken: debtToken, + gruTier: gruTier, + ibanHash: ibanHash, + policyProfileKey: policyProfileKey, + recordedAt: recordedAt, + active: true + }); + allVaults.push(vault); + + emit VaultImported(vault, entity, baseToken, policyProfileKey); + } +} diff --git a/contracts/vault/Ledger.sol b/contracts/vault/Ledger.sol index 6cae36c..3259d1b 100644 --- a/contracts/vault/Ledger.sol +++ b/contracts/vault/Ledger.sol @@ -20,6 +20,7 @@ import "./interfaces/IRateAccrual.sol"; */ contract Ledger is ILedger, AccessControl { bytes32 public constant VAULT_ROLE = keccak256("VAULT_ROLE"); + bytes32 public constant VAULT_FACTORY_ROLE = keccak256("VAULT_FACTORY_ROLE"); bytes32 public constant PARAM_MANAGER_ROLE = keccak256("PARAM_MANAGER_ROLE"); // Collateral balances: vault => asset => amount @@ -299,4 +300,12 @@ contract Ledger is ILedger, AccessControl { function grantVaultRole(address account) external onlyRole(DEFAULT_ADMIN_ROLE) { _grantRole(VAULT_ROLE, account); } + + /** + * @notice Register a vault created by VaultFactory (factory holds VAULT_FACTORY_ROLE) + */ + function registerVault(address vault) external onlyRole(VAULT_FACTORY_ROLE) { + require(vault != address(0), "Ledger: zero vault"); + _grantRole(VAULT_ROLE, vault); + } } diff --git a/contracts/vault/VaultFactory.sol b/contracts/vault/VaultFactory.sol index 887c00f..1bb45df 100644 --- a/contracts/vault/VaultFactory.sol +++ b/contracts/vault/VaultFactory.sol @@ -24,6 +24,7 @@ contract VaultFactory is AccessControl { address public entityRegistry; address public collateralAdapter; address public eMoneyJoin; + address public gruVaultIndex; mapping(address => address[]) public vaultsByEntity; // entity => vaults[] mapping(address => address) public vaultToEntity; // vault => entity @@ -58,6 +59,41 @@ contract VaultFactory is AccessControl { eMoneyJoin = eMoneyJoin_; } + /** + * @notice Wire GRU vault index (optional). Grants FACTORY_ROLE on index to this factory. + */ + function setGruVaultIndex(address index) external onlyRole(DEFAULT_ADMIN_ROLE) { + gruVaultIndex = index; + } + + function _recordGruVault( + address vault, + address entity, + address baseToken, + address depositToken, + address debtToken, + uint8 gruTier, + bytes32 ibanHash, + bytes32 policyProfileKey + ) internal { + if (gruVaultIndex == address(0)) return; + (bool ok, bytes memory data) = gruVaultIndex.call( + abi.encodeWithSignature( + "recordVault(address,address,address,address,address,uint8,bytes32,bytes32)", + vault, + entity, + baseToken, + depositToken, + debtToken, + gruTier, + ibanHash, + policyProfileKey + ) + ); + ok; + data; + } + /** * @notice Create a new vault for a regulated entity * @param owner Vault owner address @@ -132,7 +168,7 @@ contract VaultFactory is AccessControl { Vault(payable(vault)).setDebtToken(currency, debtToken); // Grant vault role in ledger - ledger.grantVaultRole(vault); + ledger.registerVault(vault); // Track vault vaultsByEntity[entity].push(vault); @@ -159,6 +195,24 @@ contract VaultFactory is AccessControl { address vault, address depositToken, address debtToken + ) { + return _deployVaultWithDecimals( + owner, entity, asset, currency, depositDecimals, debtDecimals, debtTransferable + ); + } + + function _deployVaultWithDecimals( + address owner, + address entity, + address asset, + address currency, + uint8 depositDecimals, + uint8 debtDecimals, + bool debtTransferable + ) internal returns ( + address vault, + address depositToken, + address debtToken ) { require(owner != address(0), "VaultFactory: zero owner"); require(entity != address(0), "VaultFactory: zero entity"); @@ -211,7 +265,7 @@ contract VaultFactory is AccessControl { Vault(payable(vault)).setDepositToken(asset, depositToken); Vault(payable(vault)).setDebtToken(currency, debtToken); - ledger.grantVaultRole(vault); + ledger.registerVault(vault); vaultsByEntity[entity].push(vault); vaultToEntity[vault] = entity; @@ -219,6 +273,27 @@ contract VaultFactory is AccessControl { emit VaultCreated(vault, entity, owner, depositToken, debtToken); } + /** + * @notice Create vault with explicit GRU tier, IBAN hash, and policy profile key. + */ + function createVaultWithDecimalsGRU( + address owner, + address entity, + address asset, + address currency, + uint8 depositDecimals, + uint8 debtDecimals, + bool debtTransferable, + uint8 gruTier, + bytes32 ibanHash, + bytes32 policyProfileKey + ) external onlyRole(VAULT_DEPLOYER_ROLE) returns (address vault, address depositToken, address debtToken) { + (vault, depositToken, debtToken) = _deployVaultWithDecimals( + owner, entity, asset, currency, depositDecimals, debtDecimals, debtTransferable + ); + _recordGruVault(vault, entity, asset, depositToken, debtToken, gruTier, ibanHash, policyProfileKey); + } + /** * @notice Get asset symbol (helper) * @param asset Asset address diff --git a/contracts/vault/interfaces/ILedger.sol b/contracts/vault/interfaces/ILedger.sol index 413c8f6..6547bdb 100644 --- a/contracts/vault/interfaces/ILedger.sol +++ b/contracts/vault/interfaces/ILedger.sol @@ -113,6 +113,12 @@ interface ILedger { */ function grantVaultRole(address account) external; + /** + * @notice Register a newly created vault (callable by VAULT_FACTORY_ROLE) + * @param vault Vault contract address + */ + function registerVault(address vault) external; + event CollateralModified(address indexed vault, address indexed asset, int256 delta); event DebtModified(address indexed vault, address indexed currency, int256 delta); event RiskParametersSet(address indexed asset, uint256 debtCeiling, uint256 liquidationRatio, uint256 creditMultiplier); diff --git a/docs/deployment/CCIP_BRIDGE_DESTINATIONS_AND_LINK_FUNDING.md b/docs/deployment/CCIP_BRIDGE_DESTINATIONS_AND_LINK_FUNDING.md index c72ac2e..16220f4 100644 --- a/docs/deployment/CCIP_BRIDGE_DESTINATIONS_AND_LINK_FUNDING.md +++ b/docs/deployment/CCIP_BRIDGE_DESTINATIONS_AND_LINK_FUNDING.md @@ -87,6 +87,8 @@ Repeat for each destination chain (BSC, Polygon, Base, Optimism, Avalanche, Cron 2. Transfer LINK to the **bridge contract address** (the same address you use for `addDestination`), or use any approved mechanism (e.g. admin top-up function if the contract has one). - **LINK token addresses** per chain are in `.env` (e.g. `CCIP_GNOSIS_LINK_TOKEN`, `CCIP_ETH_LINK_TOKEN`, etc.) and on [Chainlink CCIP Supported Networks](https://docs.chain.link/ccip/supported-networks). - **Script:** Run `scripts/deployment/fund-ccip-bridges-with-link.sh` to send LINK to all CCIP bridges (sources `.env`). Flags: `--link ` (default 10 LINK), `--dry-run` (print `cast` only), **`--cap-to-deployer`** (per chain: each bridge gets `min(--link, deployer_balance // bridges_on_chain)` so you can use only deployer LINK without matching 10 LINK on every chain). See script header in `scripts/deployment/fund-ccip-bridges-with-link.sh`. +- **Operator ladder (recommended):** `./scripts/deployment/run-bridge-lane-funding-ladder.sh --execute` — Gnosis LI.FI, mainnet acquire, mainnet CCIP (Celo/Wemix), direct `fund-ccip` transfers. +- **Chain 138 custom CCIP router:** `CCIPRouter.sol` on Chain 138 is **event-only** — `ccipSend` records `MessageSent` and collects LINK fees but **does not deliver** `tokenAmounts` to destination chains. Do **not** use `FundBridgeLinkViaCcip138` / `bridge-link-via-chain138-*.sh` for operator bridge funding until a full token-delivery router is deployed. Preflight: `scripts/verify/verify-chain138-ccip-router-token-delivery.sh`. --- diff --git a/docs/hybx-omnl/OPERATIONAL_COMPLIANCE.md b/docs/hybx-omnl/OPERATIONAL_COMPLIANCE.md index 51f0275..2f4c70d 100644 --- a/docs/hybx-omnl/OPERATIONAL_COMPLIANCE.md +++ b/docs/hybx-omnl/OPERATIONAL_COMPLIANCE.md @@ -9,7 +9,12 @@ ## API hardening -- **`OMNL_API_KEY`**: when set, `GET /api/v1/omnl/ipsas/fineract-compare` and `.../compliance-context/:lineId` require `Authorization: Bearer ` or `?access_token=`. +- **`OMNL_REQUIRE_API_KEY=1`** (or `NODE_ENV=production`): all `/api/v1/omnl/*` routes require `OMNL_API_KEY` except `/omnl/openapi.json`, `/omnl/catalog`, `/omnl/integration-status`. +- **`OMNL_API_KEY`**: when set, sensitive routes require `Authorization: Bearer ` or `?access_token=`. +- **Audit log**: `OMNL_AUDIT_LOG_PATH` (default `reports/audit/omnl-audit.jsonl`) — append-only JSONL for API, Fineract, webhooks, ISO 20022. +- **Triple reconcile**: `GET /api/v1/omnl/reconcile/triple-state?lineId=0x...` — Fineract GL + on-chain + `config/omnl-custodian-snapshot.json`. +- **IFRS disclosures**: `GET /api/v1/omnl/disclosures/full` (requires accountant review). +- **ISO 20022 store**: `POST /api/v1/omnl/iso20022/messages` — see `config/iso20022-omnl/README.md`. - **`OMNL_DASHBOARD_TOKEN`**: when set, `GET /omnl/dashboard` requires the same token via `?access_token=` or header `X-OMNL-Dashboard-Token`. For Fineract compare in the embedded page, open **`/omnl/dashboard?access_token=`** so the script can call protected routes. - **OMNL rate limit**: `OMNL_RATE_LIMIT_MAX` / `OMNL_RATE_LIMIT_WINDOW_MS` (default 30/min per IP on `/api/v1/omnl/*`, in addition to the global API limiter). diff --git a/foundry.toml b/foundry.toml index d49c331..7026486 100644 --- a/foundry.toml +++ b/foundry.toml @@ -34,7 +34,8 @@ remappings = [ "@emoney-scripts/=script/emoney/", "erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/", "openzeppelin-contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/", - "openzeppelin-contracts/=lib/openzeppelin-contracts/" + "openzeppelin-contracts/=lib/openzeppelin-contracts/", + "@gru/=lib/gru-contracts/" ] [profile.ci] @@ -71,6 +72,26 @@ via_ir = true # (PUSH0/MCOPY) is rejected on-chain, so all Chain 138 deploys must target Paris. evm_version = "paris" +[profile.m00-diamond] +solc = "0.8.21" +optimizer = true +optimizer_runs = 100 +via_ir = true +evm_version = "paris" +fs_permissions = [ + { access = "read", path = "./config" }, + { access = "read", path = "../config" } +] +remappings = [ + "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/", + "@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/", + "forge-std/=lib/forge-std/src/", + "@gru/=lib/gru-contracts/", + "@emoney/=contracts/emoney/", + "openzeppelin-contracts/=lib/openzeppelin-contracts/" +] + + [profile.cronos_legacy] optimizer = true optimizer_runs = 100 @@ -85,6 +106,15 @@ via_ir = true # Backwards-compatible alias for older scripts; prefer profile.chain138. evm_version = "paris" +# Mainnet checkpoint hub — minimize runtime bytecode (EIP-170 24 KiB). +[profile.mainnet-checkpoint] +optimizer = true +optimizer_runs = 1 +via_ir = true +evm_version = "cancun" +bytecode_hash = "none" +cbor_metadata = false + # RPC endpoints — use: forge create ... --rpc-url chain138 # Prevents default localhost:8545 when ETH_RPC_URL not set [rpc_endpoints] diff --git a/frontend-dapp/src/config/bridge.ts b/frontend-dapp/src/config/bridge.ts index 4c40c76..02f58a1 100644 --- a/frontend-dapp/src/config/bridge.ts +++ b/frontend-dapp/src/config/bridge.ts @@ -9,7 +9,7 @@ export const CONTRACTS = { WETH9: (import.meta.env.VITE_WETH9_CHAIN138 || '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2') as `0x${string}`, - WETH9_BRIDGE: (import.meta.env.VITE_CCIPWETH9_BRIDGE_CHAIN138 || '0x971cD9D156f193df8051E48043C476e53ECd4693') as `0x${string}`, + WETH9_BRIDGE: (import.meta.env.VITE_CCIPWETH9_BRIDGE_CHAIN138 || '0xcacfd227A040002e49e2e01626363071324f820a') as `0x${string}`, LINK_TOKEN: (import.meta.env.VITE_LINK_TOKEN_CHAIN138 || '0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03') as `0x${string}`, } as const; diff --git a/frontend-dapp/src/config/contracts.ts b/frontend-dapp/src/config/contracts.ts index dd01482..fea1b63 100644 --- a/frontend-dapp/src/config/contracts.ts +++ b/frontend-dapp/src/config/contracts.ts @@ -19,7 +19,8 @@ export const CONTRACT_ADDRESSES = { CHALLENGE_MANAGER: TRUSTLESS.mainnet.CHALLENGE_MANAGER as Address, }, chain138: { - TRANSACTION_MIRROR: '0xE362aa10D3Af1A16880A799b78D18F923403B55a' as Address, + // Chain 138 local mirror (PMM/DODO); mainnet Etherscan mirror is mainnet.TRANSACTION_MIRROR + TRANSACTION_MIRROR: '0x7131F887DBEEb2e44c1Ed267D2A68b5b83285afc' as Address, PAYMENT_CHANNEL_MANAGER: undefined as Address | undefined, GENERIC_STATE_CHANNEL_MANAGER: undefined as Address | undefined, TWOWAY_BRIDGE_L2: undefined as Address | undefined, diff --git a/lib/deploy-detail-v2.0.txt b/lib/deploy-detail-v2.0.txt new file mode 100644 index 0000000..77a2771 --- /dev/null +++ b/lib/deploy-detail-v2.0.txt @@ -0,0 +1,70 @@ +==================================================== +network type: chain138 +Deploy time: 5/23/2026, 4:00:08 AM +Deploy type: V2 +multiSigAddress: 0x4A666F96fC8764181194447A7dFdb7d471b301C8 +MultiCallAddress: 0xBdfe8bf69ee8E49F1f922B21d5de40Ae54f361cF +MultiCallWithValidAddress: 0xDC25282b1Df417E22776E9F22dd8fca80e5E0C1B +DODOSellHelper Address: 0x08e38131097017468865Dfa8D3B6f6365b2Fe001 +DODOSwapCalcHelper Address: 0x37AA18E210D239A6d1f4c2890F5ca2FA0573fF14 +ERC20Helper Address: 0x63EDb74EaD6E086f08B037c245A9602Ddca35b90 +DODOCalleeHelperAddress: 0x54EB34C84616ce3f89B2d8e6821F8026816Ae382 +DODOV1RouterHelper Address: 0x1318881Ba65aa9D52e1025df6258e52902307603 +DspTemplateAddress: 0x338D2C1e83505B76d5B045426AA36bD116e7D6c3 +DppAdvancedTemplateAddress: 0xcdA79880b79702de766B1e852E989428337354D8 +DppAdvancedAdminTemplateAddress: 0x34a5Ff6D30Cdb1E3273cDdEB5Ba2da0C2Db41C79 +CpTemplateAddress: 0x0C8E8246bfDD91Dd8CD5A6fBE12b063963E7407D +ERC20TemplateAddress: 0x2D05FAc655F11235B0707B2b906aB18C6FcAE181 +CustomERC20TemplateAddress: 0xC6411eC3ec3d2104fA2e99ec923FD1a5965Aa26c +CustomMintableERC20TemplateAddress: 0xfb5777d85bBDFF9bd2137c66E966536B091EB47F +MineV2TemplateAddress: 0x8d6E3d465B789c77A11f5aB80d17e0a2067F387F +MineV3TemplateAddress: 0xF9E83ddBD1AF7D412a54DF61ed24B8be15aF845E +DODOApprove Address: 0xEA5Be91d0A1EdA6a2efc80f7211c30584508D56D +DODOApproveProxy Address: 0xa861198650005969990bF6223bACb2085C180313 +ERC20V3FactoryAddress: 0x8Df0298a9CB839e89eA7d32918076a70467FBACE +Init ERC20V2Factory Tx: 0x882cc451ae817824c147c6d20a67f3c40a4009cefac6aa92498d734a6137be4a +DppFactoryAddress: 0x1623719Bf795317643D629Fe2114776e9F3B2541 +Init DppFactory Tx: 0xf29dd1e6a9b740aa92daa70ed7ab853cdc720f3cef8677588b05b3ecd8ccb142 +CpV2FactoryAddress: 0x0c30b4b04ac745977A4cB1960774CDa5f2A5c135 +Init CpFactory Tx: 0x86def3a8f39410b150e42fbad527c4b56943afd7ee1d63c928de319aaefb4f4e +DspFactoryAddress: 0xD5d83c48a03d6F8155deD564c3ED0205d75dF31e +Init DspFactory Tx: 0xd3e06d496b82c721f06e76015a1860886f60ac8e8c01b61379a71d08d198bf8e +DODOMineV2FactoryAddress: 0x5eCc900AbB637d6d0448ED53A089805B787c9Ca7 +DODOMineV3RegistryAddress: 0x5EDE2a76341C966F12919b71310821873312DaBc +Init DODOMineV3Registry Tx: 0xbc835aa9aa4b997631ba5bf2c4f7ed9e1aed7dcbab739f3f104cdf439d68bfd3 +DODOV2RouteHelper Address: 0x6A0009C5a331a40f8F1B12e8bA800D32066df8b5 +DODOV2Adapter Address: 0xf8043e9e524C24c27f534E49E6A8Bdd951fdecd2 +DODOV2Proxy02 Address: 0xEF6E6F41A522896a9EE1C580C87C05E409193F8d +Init DODOProxyV2 Tx: 0xe09a22dbcb959b93ae60722ee90cd97ea494c3e44f6ed12700790696bef6e08a +DODODspProxy Address: 0xC63E8EC3687d162ec5BC7E0ec84479a6010aC6b9 +==================================================== +network type: chain138 +Deploy time: 5/23/2026, 4:05:30 AM +Deploy type: V2 +multiSigAddress: 0x4A666F96fC8764181194447A7dFdb7d471b301C8 +DspTemplateAddress: 0x0835Ba617e03EA2bE9825A160435AA45eF7E1ecA +CpProxy address: 0xb6857e746436464d091F1D690337C467E5fB2861 +DPPProxy address: 0x5aaC65657B05D1651231b670aB4e613E57A726c8 +DODOMineV3ProxyAddress: 0xf2d18847bBB0CE47CB06AcA80235329652DD9300 +Init DODOMineV3Proxy Tx: 0xbfb90144a5ad335fcc9d4da3678beb25066abb327d374f1cfd405fc422395fcc +==================================================== +network type: chain138 +Deploy time: 5/23/2026, 4:08:53 AM +Deploy type: V2 +multiSigAddress: 0x4A666F96fC8764181194447A7dFdb7d471b301C8 +DODOApproveProxy Init tx: 0xa47bbf04b3d2d136f6d13350be05102fc43a5c811db3242704f184a6e6101f7a +DODOApprove Init tx: 0x0e49445126bcaa5df43fec29bf87e160d7157485027a0dad49c1021e2d4b9d8f +Init DODOMineV2Factory Tx: 0xf5d5621259f64ed089c95a0b44e6613d53a252cda2c2b387b9ebadddb207e519 +Set FeeRateImpl tx: 0x2fe548394f4e84bbb9b37eacb9c2ccca66deccf22070048b73fd1903b106c8a7 +==================================================== +network type: chain138 +Deploy time: 5/23/2026, 4:11:56 AM +Deploy type: V2 +multiSigAddress: 0x4A666F96fC8764181194447A7dFdb7d471b301C8 +==================================================== +network type: chain138 +Deploy time: 5/23/2026, 11:11:22 AM +Deploy type: V2 +multiSigAddress: 0x4A666F96fC8764181194447A7dFdb7d471b301C8 +DODOApproveProxy already initialized, owner: 0x4A666F96fC8764181194447A7dFdb7d471b301C8 +DODOApprove already initialized, owner: 0x4A666F96fC8764181194447A7dFdb7d471b301C8 diff --git a/lib/deploy-nft.txt b/lib/deploy-nft.txt new file mode 100644 index 0000000..6859a52 --- /dev/null +++ b/lib/deploy-nft.txt @@ -0,0 +1,14 @@ +==================================================== +network type: chain138 +Deploy time: 5/23/2026, 11:14:24 AM +Deploy type: NFT +multiSigAddress: 0x4A666F96fC8764181194447A7dFdb7d471b301C8 +DODONFTRegistryAddress: 0xcA3932D629a24E530667E50A8bD86A6e5b5DA7F2 +Init DODONFTRegistryAddress Tx: 0xd0ea95ce0d3b8868617943848f388b547c19a14fb5c95e35a2f3447b03d39229 +DODONFTRouteHelperAddress: 0xBDbE10A5E6334e74f5F4B91802219a2c1a233151 +BuyoutModelAddress: 0xd0c09Aeb180856765cBEcf4DA30CEb4275AfEB15 +Init BuyoutModelAddress Tx: 0xab3eed3c3b89b242050fe65aa9fe619f3d6095dae6763667504cdc713c73d387 +NFTCollateralVaultAddress: 0x511c8ED44C4890c58a68A1eC7CcFb680DF117B2A +FragmentAddress: 0xB0Ec60Cd8471c8D2E78ec5266D2d96C9c39c94ED +DODONFTProxyAddress: 0x1E84eE365a323421765d68a0Db9b01fc67ea5Af7 +Init DODONFTProxyAddress Tx: 0xc0a9b990417ac18fe548059760b4d6eb30025c93a4a2ad8a518a1fb915e1093a diff --git a/lib/gru-contracts b/lib/gru-contracts new file mode 120000 index 0000000..f83304b --- /dev/null +++ b/lib/gru-contracts @@ -0,0 +1 @@ +../../gru-docs/contracts \ No newline at end of file diff --git a/package.json b/package.json index 04cb48d..c4f3439 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,10 @@ "forge:test:iso": "FORGE_SCOPE=iso4217w bash scripts/forge/scope.sh test --match-path 'test/iso4217w/*.t.sol'", "forge:test:quick": "FORGE_SCOPE=vault bash scripts/forge/scope.sh test --match-contract LedgerTest", "forge:test:omnl": "bash scripts/forge/scope.sh test hybx-omnl", + "forge:test:checkpoint": "FORGE_SCOPE=mainnet-checkpoint bash scripts/forge/scope.sh test mainnet-checkpoint", + "checkpoint:predeploy": "bash ../scripts/verify/run-checkpoint-predeploy-gate.sh", + "checkpoint:predeploy:hub": "bash ../scripts/verify/run-checkpoint-predeploy-gate.sh mainnet-hub", + "checkpoint:storage-layout": "bash ../scripts/verify/checkpoint-storage-layout.sh", "omnl:verify": "bash scripts/hybx-omnl/verify-deployment.sh", "omnl:reconcile:artifact": "bash scripts/hybx-omnl/omnl-reconcile-artifact.sh", "omnl:validate-cross-chain": "node scripts/hybx-omnl/validate-cross-chain-config.mjs", diff --git a/packages/checkpoint-core/dist/index.d.ts b/packages/checkpoint-core/dist/index.d.ts new file mode 100644 index 0000000..5060177 --- /dev/null +++ b/packages/checkpoint-core/dist/index.d.ts @@ -0,0 +1,5 @@ +export * from './leaf'; +export * from './merkle'; +export * from './usdPricing'; +export * from './tokenTransfers'; +export * from './iso20022'; diff --git a/packages/checkpoint-core/dist/index.js b/packages/checkpoint-core/dist/index.js new file mode 100644 index 0000000..1301771 --- /dev/null +++ b/packages/checkpoint-core/dist/index.js @@ -0,0 +1,21 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +__exportStar(require("./leaf"), exports); +__exportStar(require("./merkle"), exports); +__exportStar(require("./usdPricing"), exports); +__exportStar(require("./tokenTransfers"), exports); +__exportStar(require("./iso20022"), exports); diff --git a/packages/checkpoint-core/dist/iso20022/hashes.d.ts b/packages/checkpoint-core/dist/iso20022/hashes.d.ts new file mode 100644 index 0000000..9c5e697 --- /dev/null +++ b/packages/checkpoint-core/dist/iso20022/hashes.d.ts @@ -0,0 +1,5 @@ +export declare function bytes32FromUtf8(label: string, value: string): string; +export declare function instructionIdFromTxHash(txHash: string): string; +export declare function uetrFromTxHash(txHash: string): string; +export declare function uetrBytes32FromTxHash(txHash: string): string; +export declare function hashShortUtf8(value: string): string; diff --git a/packages/checkpoint-core/dist/iso20022/hashes.js b/packages/checkpoint-core/dist/iso20022/hashes.js new file mode 100644 index 0000000..a6b9600 --- /dev/null +++ b/packages/checkpoint-core/dist/iso20022/hashes.js @@ -0,0 +1,27 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.bytes32FromUtf8 = bytes32FromUtf8; +exports.instructionIdFromTxHash = instructionIdFromTxHash; +exports.uetrFromTxHash = uetrFromTxHash; +exports.uetrBytes32FromTxHash = uetrBytes32FromTxHash; +exports.hashShortUtf8 = hashShortUtf8; +const ethers_1 = require("ethers"); +function bytes32FromUtf8(label, value) { + return ethers_1.ethers.keccak256(ethers_1.ethers.toUtf8Bytes(`${label}:${value}`)); +} +function instructionIdFromTxHash(txHash) { + return bytes32FromUtf8('INSTR', txHash); +} +function uetrFromTxHash(txHash) { + const h = ethers_1.ethers.keccak256(ethers_1.ethers.toUtf8Bytes(`UETR:${txHash}`)); + const hex = h.slice(2); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} +function uetrBytes32FromTxHash(txHash) { + return ethers_1.ethers.keccak256(ethers_1.ethers.toUtf8Bytes(uetrFromTxHash(txHash))); +} +function hashShortUtf8(value) { + if (!value) + return ethers_1.ethers.ZeroHash; + return ethers_1.ethers.keccak256(ethers_1.ethers.toUtf8Bytes(value)); +} diff --git a/packages/checkpoint-core/dist/iso20022/index.d.ts b/packages/checkpoint-core/dist/iso20022/index.d.ts new file mode 100644 index 0000000..18137dc --- /dev/null +++ b/packages/checkpoint-core/dist/iso20022/index.d.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './hashes'; +export * from './mapFromPaymentLeaf'; diff --git a/packages/checkpoint-core/dist/iso20022/index.js b/packages/checkpoint-core/dist/iso20022/index.js new file mode 100644 index 0000000..2e8bb4d --- /dev/null +++ b/packages/checkpoint-core/dist/iso20022/index.js @@ -0,0 +1,19 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +__exportStar(require("./types"), exports); +__exportStar(require("./hashes"), exports); +__exportStar(require("./mapFromPaymentLeaf"), exports); diff --git a/packages/checkpoint-core/dist/iso20022/mapFromPaymentLeaf.d.ts b/packages/checkpoint-core/dist/iso20022/mapFromPaymentLeaf.d.ts new file mode 100644 index 0000000..8b9ff91 --- /dev/null +++ b/packages/checkpoint-core/dist/iso20022/mapFromPaymentLeaf.d.ts @@ -0,0 +1,5 @@ +import type { CanonicalPaymentMessage, PaymentLeafIsoInput } from './types'; +/** Map Chain 138 payment leaf → MT-103 / pacs.008 equivalent canonical message. */ +export declare function mapPaymentLeafToCanonical(leaf: PaymentLeafIsoInput): CanonicalPaymentMessage; +/** Minimal pacs.008.001 XML for OMNL store / audit (not full MX validation). */ +export declare function canonicalToPacs008Xml(c: CanonicalPaymentMessage): string; diff --git a/packages/checkpoint-core/dist/iso20022/mapFromPaymentLeaf.js b/packages/checkpoint-core/dist/iso20022/mapFromPaymentLeaf.js new file mode 100644 index 0000000..f3c1288 --- /dev/null +++ b/packages/checkpoint-core/dist/iso20022/mapFromPaymentLeaf.js @@ -0,0 +1,95 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.mapPaymentLeafToCanonical = mapPaymentLeafToCanonical; +exports.canonicalToPacs008Xml = canonicalToPacs008Xml; +const ethers_1 = require("ethers"); +const types_1 = require("./types"); +const hashes_1 = require("./hashes"); +/** Map Chain 138 payment leaf → MT-103 / pacs.008 equivalent canonical message. */ +function mapPaymentLeafToCanonical(leaf) { + const txHash = leaf.txHash; + const instrBytes32 = (0, hashes_1.instructionIdFromTxHash)(txHash); + const instrDisplay = `CHAIN138-${txHash.slice(2, 34)}`; + const e2e = `E2E-${txHash.slice(2, 18)}`; + const uetr = (0, hashes_1.uetrFromTxHash)(txHash); + const usd = leaf.totalTransfersUsd ?? leaf.valueUsd ?? leaf.nativeValueUsd ?? '0'; + const currencyCode = leaf.tokenSymbol === 'ETH' || !leaf.token ? 'ETH' : 'USD'; + const purpose = `Chain138 settlement batch attestation; native value ${ethers_1.ethers.formatEther(leaf.value)} ETH; USD ref ${usd}`; + const canonical = { + msgType: 'chain138.synthetic', + msgTypeCode: types_1.MSG_TYPE.CHAIN138_SYNTH, + instructionId: instrDisplay, + instructionIdBytes32: instrBytes32, + endToEndId: e2e, + endToEndIdHash: (0, hashes_1.hashShortUtf8)(e2e), + msgId: `MSG-${txHash.slice(2, 14)}`, + uetr, + uetrBytes32: (0, hashes_1.uetrBytes32FromTxHash)(txHash), + accountRefId: leaf.from, + counterpartyRefId: leaf.to, + debtorId: leaf.from, + creditorId: leaf.to, + purpose, + settlementMethod: 'CLRG', + categoryPurpose: 'CBFF', + currencyCode, + amountRaw: ethers_1.ethers.formatEther(leaf.value), + amountSmallestUnit: leaf.value.toString(), + tokenAddress: leaf.token, + debtorRefHash: (0, hashes_1.hashShortUtf8)(`${leaf.from}|${leaf.from}`), + creditorRefHash: (0, hashes_1.hashShortUtf8)(`${leaf.to}|${leaf.to}`), + purposeHash: (0, hashes_1.hashShortUtf8)(purpose), + chain138TxHash: txHash, + valueDateIso: new Date(leaf.blockTimestamp * 1000).toISOString().slice(0, 10), + }; + const payloadHash = ethers_1.ethers.keccak256(ethers_1.ethers.toUtf8Bytes(JSON.stringify({ + ...canonical, + blockNumber: leaf.blockNumber, + blockTimestamp: leaf.blockTimestamp, + receiptHash: leaf.receiptHash ?? null, + logCount: leaf.logCount ?? 0, + }))); + return { ...canonical, payloadHash }; +} +/** Minimal pacs.008.001 XML for OMNL store / audit (not full MX validation). */ +function canonicalToPacs008Xml(c) { + const cre = new Date().toISOString(); + return ` + + + + ${escapeXml(c.msgId)} + ${escapeXml(cre)} + 1 + + + + ${escapeXml(c.instructionId)} + ${escapeXml(c.endToEndId)} + ${escapeXml(c.chain138TxHash)} + + ${escapeXml(c.amountRaw)} + ${escapeXml(c.debtorId)} + ${escapeXml(c.accountRefId)} + ${escapeXml(c.creditorId)} + ${escapeXml(c.counterpartyRefId)} + ${escapeXml(c.purpose)} + + Chain138Attestation + + ${escapeXml(c.uetr)} + ${escapeXml(c.payloadHash)} + ${c.msgTypeCode} + + + + +`; +} +function escapeXml(s) { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/packages/checkpoint-core/dist/iso20022/types.d.ts b/packages/checkpoint-core/dist/iso20022/types.d.ts new file mode 100644 index 0000000..ed79f83 --- /dev/null +++ b/packages/checkpoint-core/dist/iso20022/types.d.ts @@ -0,0 +1,53 @@ +/** ISO 20022 / SWIFT MT-103+ canonical message (off-chain full payload; on-chain hashes). */ +export type IsoMessageTypeCode = 0 | 1 | 2 | 3 | 4; +/** 0=unknown, 1=MT103, 2=pacs.008, 3=pain.001, 4=chain138 synthetic (from native tx) */ +export declare const MSG_TYPE: { + readonly UNKNOWN: IsoMessageTypeCode; + readonly MT103: IsoMessageTypeCode; + readonly PACS008: IsoMessageTypeCode; + readonly PAIN001: IsoMessageTypeCode; + readonly CHAIN138_SYNTH: IsoMessageTypeCode; +}; +export type CanonicalPaymentMessage = { + msgType: 'MT103' | 'pacs.008' | 'pain.001' | 'chain138.synthetic'; + msgTypeCode: IsoMessageTypeCode; + instructionId: string; + instructionIdBytes32: string; + endToEndId: string; + endToEndIdHash: string; + msgId: string; + uetr: string; + uetrBytes32: string; + accountRefId: string; + counterpartyRefId: string; + debtorId: string; + creditorId: string; + purpose: string; + settlementMethod: string; + categoryPurpose: string; + currencyCode: string; + amountRaw: string; + amountSmallestUnit: string; + tokenAddress?: string; + payloadHash: string; + debtorRefHash: string; + creditorRefHash: string; + purposeHash: string; + chain138TxHash: string; + valueDateIso?: string; +}; +export type PaymentLeafIsoInput = { + txHash: string; + from: string; + to: string; + value: bigint; + blockNumber: number; + blockTimestamp: number; + valueUsd?: string; + nativeValueUsd?: string; + totalTransfersUsd?: string; + receiptHash?: string; + logCount?: number; + token?: string; + tokenSymbol?: string; +}; diff --git a/packages/checkpoint-core/dist/iso20022/types.js b/packages/checkpoint-core/dist/iso20022/types.js new file mode 100644 index 0000000..67e993a --- /dev/null +++ b/packages/checkpoint-core/dist/iso20022/types.js @@ -0,0 +1,11 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MSG_TYPE = void 0; +/** 0=unknown, 1=MT103, 2=pacs.008, 3=pain.001, 4=chain138 synthetic (from native tx) */ +exports.MSG_TYPE = { + UNKNOWN: 0, + MT103: 1, + PACS008: 2, + PAIN001: 3, + CHAIN138_SYNTH: 4, +}; diff --git a/packages/checkpoint-core/dist/leaf.d.ts b/packages/checkpoint-core/dist/leaf.d.ts new file mode 100644 index 0000000..899a72e --- /dev/null +++ b/packages/checkpoint-core/dist/leaf.d.ts @@ -0,0 +1,28 @@ +export declare const PAYMENT_LEAF_V1 = 1; +export declare const PAYMENT_LEAF_V2 = 2; +export type PaymentLeafV1Input = { + txHash: string; + from: string; + to: string; + /** Wei used in Merkle / on-chain leaf (native or effective token amount). */ + value: bigint | string | number; + blockNumber: bigint | string | number; + blockTimestamp: bigint | string | number; + gasUsed: bigint | string | number; + success: boolean; +}; +export type PaymentLeafV2Input = PaymentLeafV1Input & { + token: string; + logIndex?: bigint | string | number; +}; +/** Matches CheckpointLeaf.paymentLeafV1 */ +export declare function paymentLeafV1Hash(chainId: number | bigint, leaf: PaymentLeafV1Input): string; +/** Matches CheckpointLeaf.paymentLeafV2 */ +export declare function paymentLeafV2Hash(chainId: number | bigint, leaf: PaymentLeafV2Input): string; +/** Historical batches 1–50: verify with native/on-chain wei only. */ +export declare function merkleVerifyValueWei(record: Record): bigint; +export declare function effectiveTokenOrNativeWei(record: { + value?: unknown; + token?: unknown; + tokenValue?: unknown; +}): bigint; diff --git a/packages/checkpoint-core/dist/leaf.js b/packages/checkpoint-core/dist/leaf.js new file mode 100644 index 0000000..a3826f2 --- /dev/null +++ b/packages/checkpoint-core/dist/leaf.js @@ -0,0 +1,71 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PAYMENT_LEAF_V2 = exports.PAYMENT_LEAF_V1 = void 0; +exports.paymentLeafV1Hash = paymentLeafV1Hash; +exports.paymentLeafV2Hash = paymentLeafV2Hash; +exports.merkleVerifyValueWei = merkleVerifyValueWei; +exports.effectiveTokenOrNativeWei = effectiveTokenOrNativeWei; +const ethers_1 = require("ethers"); +exports.PAYMENT_LEAF_V1 = 0x01; +exports.PAYMENT_LEAF_V2 = 0x02; +/** Matches CheckpointLeaf.paymentLeafV1 */ +function paymentLeafV1Hash(chainId, leaf) { + const version = ethers_1.ethers.hexlify(new Uint8Array([exports.PAYMENT_LEAF_V1])); + return ethers_1.ethers.keccak256(ethers_1.ethers.AbiCoder.defaultAbiCoder().encode(['bytes1', 'uint64', 'bytes32', 'address', 'address', 'uint256', 'uint256', 'uint64', 'uint256', 'bool'], [ + version, + BigInt(chainId), + leaf.txHash, + leaf.from, + leaf.to, + BigInt(leaf.value ?? 0), + BigInt(leaf.blockNumber), + BigInt(leaf.blockTimestamp), + BigInt(leaf.gasUsed ?? 0), + leaf.success, + ])); +} +/** Matches CheckpointLeaf.paymentLeafV2 */ +function paymentLeafV2Hash(chainId, leaf) { + const version = ethers_1.ethers.hexlify(new Uint8Array([exports.PAYMENT_LEAF_V2])); + return ethers_1.ethers.keccak256(ethers_1.ethers.AbiCoder.defaultAbiCoder().encode([ + 'bytes1', + 'uint64', + 'bytes32', + 'address', + 'address', + 'address', + 'uint256', + 'uint256', + 'uint64', + 'uint256', + 'bool', + 'uint32', + ], [ + version, + BigInt(chainId), + leaf.txHash, + leaf.from, + leaf.to, + leaf.token, + BigInt(leaf.value ?? 0), + BigInt(leaf.blockNumber), + BigInt(leaf.blockTimestamp), + BigInt(leaf.gasUsed ?? 0), + leaf.success, + Number(leaf.logIndex ?? 0), + ])); +} +/** Historical batches 1–50: verify with native/on-chain wei only. */ +function merkleVerifyValueWei(record) { + if (record.onChainValueWei != null && String(record.onChainValueWei) !== '') { + return BigInt(String(record.onChainValueWei)); + } + return BigInt(String(record.nativeValueWei ?? record.value ?? '0')); +} +function effectiveTokenOrNativeWei(record) { + const native = BigInt(String(record.value ?? '0')); + const tokenVal = record.token != null && record.tokenValue != null ? BigInt(String(record.tokenValue)) : 0n; + if (tokenVal > 0n) + return tokenVal; + return native; +} diff --git a/packages/checkpoint-core/dist/merkle.d.ts b/packages/checkpoint-core/dist/merkle.d.ts new file mode 100644 index 0000000..1427b5a --- /dev/null +++ b/packages/checkpoint-core/dist/merkle.d.ts @@ -0,0 +1,6 @@ +/** Matches CheckpointLeaf.buildMerkleRoot (pair sort, odd duplicate). */ +export declare function buildMerkleRoot(hashes: string[]): string; +export declare function merkleProofs(hashes: string[]): { + root: string; + proofs: string[][]; +}; diff --git a/packages/checkpoint-core/dist/merkle.js b/packages/checkpoint-core/dist/merkle.js new file mode 100644 index 0000000..7fe7b94 --- /dev/null +++ b/packages/checkpoint-core/dist/merkle.js @@ -0,0 +1,47 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.buildMerkleRoot = buildMerkleRoot; +exports.merkleProofs = merkleProofs; +const ethers_1 = require("ethers"); +/** Matches CheckpointLeaf.buildMerkleRoot (pair sort, odd duplicate). */ +function buildMerkleRoot(hashes) { + if (hashes.length === 0) + return ethers_1.ethers.ZeroHash; + let layer = [...hashes]; + while (layer.length > 1) { + const next = []; + for (let i = 0; i < layer.length; i += 2) { + const a = layer[i]; + const b = i + 1 < layer.length ? layer[i + 1] : a; + const [left, right] = a.toLowerCase() < b.toLowerCase() ? [a, b] : [b, a]; + next.push(ethers_1.ethers.keccak256(ethers_1.ethers.concat([left, right]))); + } + layer = next; + } + return layer[0]; +} +function merkleProofs(hashes) { + const root = buildMerkleRoot(hashes); + const proofs = hashes.map((_, index) => buildProof(hashes, index)); + return { root, proofs }; +} +function buildProof(leaves, index) { + const proof = []; + let idx = index; + let layer = [...leaves]; + while (layer.length > 1) { + const next = []; + for (let i = 0; i < layer.length; i += 2) { + const a = layer[i]; + const b = i + 1 < layer.length ? layer[i + 1] : a; + const [left, right] = a.toLowerCase() < b.toLowerCase() ? [a, b] : [b, a]; + next.push(ethers_1.ethers.keccak256(ethers_1.ethers.concat([left, right]))); + } + const siblingIdx = idx % 2 === 0 ? idx + 1 : idx - 1; + if (siblingIdx < layer.length) + proof.push(layer[siblingIdx]); + idx = Math.floor(idx / 2); + layer = next; + } + return proof; +} diff --git a/packages/checkpoint-core/dist/merkle.test.d.ts b/packages/checkpoint-core/dist/merkle.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/checkpoint-core/dist/merkle.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/checkpoint-core/dist/merkle.test.js b/packages/checkpoint-core/dist/merkle.test.js new file mode 100644 index 0000000..00a17a9 --- /dev/null +++ b/packages/checkpoint-core/dist/merkle.test.js @@ -0,0 +1,22 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const strict_1 = __importDefault(require("node:assert/strict")); +const index_1 = require("./index"); +const leaves = [ + { + txHash: '0x' + '11'.repeat(32), + from: '0x' + '22'.repeat(20), + to: '0x' + '33'.repeat(20), + value: 0n, + blockNumber: 1n, + blockTimestamp: 2n, + gasUsed: 3n, + success: true, + }, +]; +const h = (0, index_1.paymentLeafV1Hash)(138, leaves[0]); +strict_1.default.equal((0, index_1.buildMerkleRoot)([h]), h); +console.log('checkpoint-core merkle ok'); diff --git a/packages/checkpoint-core/dist/tokenTransfers.d.ts b/packages/checkpoint-core/dist/tokenTransfers.d.ts new file mode 100644 index 0000000..600e983 --- /dev/null +++ b/packages/checkpoint-core/dist/tokenTransfers.d.ts @@ -0,0 +1,40 @@ +/** Blockscout ERC-20 transfer parsing (shared by aggregator + indexer). */ +export interface TokenTransferSummary { + token: string; + tokenSymbol: string; + tokenDecimals: number; + from: string; + to: string; + value: bigint; + logIndex: number; +} +interface TokenTransferItem { + from?: { + hash?: string; + }; + to?: { + hash?: string; + }; + token?: { + address?: string; + symbol?: string; + decimals?: string; + }; + total?: { + value?: string; + decimals?: string; + }; + value?: string | null; + type?: string; + log_index?: number; +} +/** Largest ERC-20 transfer in a tx (legacy primary payment). */ +export declare function pickPrimaryTokenTransfer(items: TokenTransferItem[]): TokenTransferSummary | null; +/** All non-zero ERC-20 transfers in a tx. */ +export declare function parseAllTokenTransfers(items: TokenTransferItem[]): TokenTransferSummary[]; +export declare function fetchTokenTransferItemsForTx(apiBase: string, txHash: string): Promise; +export declare function pickPrimaryFromSummaries(items: TokenTransferSummary[]): TokenTransferSummary | null; +export declare function applyPrimaryTransferToLeaf(leaf: Record, primary: TokenTransferSummary | null): void; +export declare function fetchAllTokenTransfersForTx(apiBase: string, txHash: string): Promise; +export declare function fetchPrimaryTokenTransferForTx(apiBase: string, txHash: string): Promise; +export {}; diff --git a/packages/checkpoint-core/dist/tokenTransfers.js b/packages/checkpoint-core/dist/tokenTransfers.js new file mode 100644 index 0000000..e604f2d --- /dev/null +++ b/packages/checkpoint-core/dist/tokenTransfers.js @@ -0,0 +1,87 @@ +"use strict"; +/** Blockscout ERC-20 transfer parsing (shared by aggregator + indexer). */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.pickPrimaryTokenTransfer = pickPrimaryTokenTransfer; +exports.parseAllTokenTransfers = parseAllTokenTransfers; +exports.fetchTokenTransferItemsForTx = fetchTokenTransferItemsForTx; +exports.pickPrimaryFromSummaries = pickPrimaryFromSummaries; +exports.applyPrimaryTransferToLeaf = applyPrimaryTransferToLeaf; +exports.fetchAllTokenTransfersForTx = fetchAllTokenTransfersForTx; +exports.fetchPrimaryTokenTransferForTx = fetchPrimaryTokenTransferForTx; +function parseItem(item) { + const token = item.token?.address; + const raw = item.total?.value ?? + (item.value != null && item.value !== '' ? item.value : undefined); + if (!token || raw === undefined) + return null; + const value = BigInt(raw); + if (value === 0n) + return null; + const decimals = parseInt(item.token?.decimals || item.total?.decimals || '18', 10); + return { + token, + tokenSymbol: item.token?.symbol || '', + tokenDecimals: Number.isFinite(decimals) ? decimals : 18, + from: item.from?.hash || '', + to: item.to?.hash || '', + value, + logIndex: item.log_index ?? 0, + }; +} +/** Largest ERC-20 transfer in a tx (legacy primary payment). */ +function pickPrimaryTokenTransfer(items) { + let best = null; + for (const item of items) { + const summary = parseItem(item); + if (!summary) + continue; + if (!best || summary.value > best.value) + best = summary; + } + return best; +} +/** All non-zero ERC-20 transfers in a tx. */ +function parseAllTokenTransfers(items) { + const out = []; + for (const item of items) { + const summary = parseItem(item); + if (summary) + out.push(summary); + } + return out.sort((a, b) => (a.logIndex < b.logIndex ? -1 : a.logIndex > b.logIndex ? 1 : 0)); +} +async function fetchTokenTransferItemsForTx(apiBase, txHash) { + const base = apiBase.replace(/\/$/, ''); + const res = await fetch(`${base}/transactions/${txHash}/token-transfers`); + if (!res.ok) + return []; + const body = (await res.json()); + return body.items ?? []; +} +function pickPrimaryFromSummaries(items) { + let best = null; + for (const s of items) { + if (!best || s.value > best.value) + best = s; + } + return best; +} +function applyPrimaryTransferToLeaf(leaf, primary) { + if (!primary) + return; + leaf.token = primary.token; + leaf.tokenSymbol = primary.tokenSymbol; + leaf.tokenDecimals = primary.tokenDecimals; + leaf.tokenValue = primary.value.toString(); + leaf.tokenLogIndex = primary.logIndex; + if (leaf.nativeValueWei == null) + leaf.nativeValueWei = String(leaf.value ?? '0'); +} +async function fetchAllTokenTransfersForTx(apiBase, txHash) { + const items = await fetchTokenTransferItemsForTx(apiBase, txHash); + return parseAllTokenTransfers(items); +} +async function fetchPrimaryTokenTransferForTx(apiBase, txHash) { + const items = await fetchTokenTransferItemsForTx(apiBase, txHash); + return pickPrimaryTokenTransfer(items); +} diff --git a/packages/checkpoint-core/dist/usdPricing.d.ts b/packages/checkpoint-core/dist/usdPricing.d.ts new file mode 100644 index 0000000..4311824 --- /dev/null +++ b/packages/checkpoint-core/dist/usdPricing.d.ts @@ -0,0 +1,52 @@ +/** Transfer-time USD for Chain 138 checkpoint leaves (off-chain metadata only). */ +export declare const CHAIN138_NATIVE_PRICING_ADDRESS = "0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; +/** Canonical Chain 138 stables — $1 peg when price-at API is unavailable. */ +export declare const CHAIN138_STABLE_USD_PEG: Record; +/** Known Chain 138 assets (override via env CHECKPOINT_USD_PEG_). */ +export declare const CHAIN138_KNOWN_TOKEN_USD: Record; +export type HistoricalPriceSnapshot = { + chainId: number; + tokenAddress: string; + requestedTimestamp: string; + effectiveTimestamp: string; + priceUsd: number; + source: string; +}; +export type TransferUsdLine = { + kind: 'native' | 'erc20'; + token?: string; + tokenSymbol?: string; + tokenDecimals?: number; + from?: string; + to?: string; + amountRaw: string; + logIndex?: number; + priceUsd?: number; + priceSource?: string; + priceEffectiveTimestamp?: string; + valueUsd?: string; +}; +/** Convert decimal USD string (e.g. batch JSON) to 8-decimal fixed point for on-chain events. */ +export declare function usdStringToE8(usd: string | undefined | null): bigint; +export type UsdPricingConfig = { + apiBaseUrl: string; + chainId?: number; + enabled?: boolean; + requestDelayMs?: number; +}; +/** USD string with `outputScale` fractional digits (default 6). */ +export declare function formatAmountUsd(rawAmount: string, decimals: number, priceUsd: number, priceScale?: number, outputScale?: number): string | undefined; +export declare function blockTimestampToIso(blockTimestamp: number): string; +export declare function fetchHistoricalPriceUsd(cfg: UsdPricingConfig, tokenAddress: string, blockTimestamp: number): Promise; +export declare function priceTransferLine(cfg: UsdPricingConfig, line: TransferUsdLine, blockTimestamp: number): Promise; +/** Sum USD strings (6 decimal places) without floating-point drift. */ +export declare function sumUsdStrings(values: (string | undefined)[]): string | undefined; +export declare function enrichLeafUsdFields(cfg: UsdPricingConfig, leaf: Record, allErc20: Array<{ + token: string; + tokenSymbol: string; + tokenDecimals: number; + from: string; + to: string; + value: bigint; + logIndex: number; +}>): Promise>; diff --git a/packages/checkpoint-core/dist/usdPricing.js b/packages/checkpoint-core/dist/usdPricing.js new file mode 100644 index 0000000..ea0c1e7 --- /dev/null +++ b/packages/checkpoint-core/dist/usdPricing.js @@ -0,0 +1,345 @@ +"use strict"; +/** Transfer-time USD for Chain 138 checkpoint leaves (off-chain metadata only). */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CHAIN138_KNOWN_TOKEN_USD = exports.CHAIN138_STABLE_USD_PEG = exports.CHAIN138_NATIVE_PRICING_ADDRESS = void 0; +exports.usdStringToE8 = usdStringToE8; +exports.formatAmountUsd = formatAmountUsd; +exports.blockTimestampToIso = blockTimestampToIso; +exports.fetchHistoricalPriceUsd = fetchHistoricalPriceUsd; +exports.priceTransferLine = priceTransferLine; +exports.sumUsdStrings = sumUsdStrings; +exports.enrichLeafUsdFields = enrichLeafUsdFields; +exports.CHAIN138_NATIVE_PRICING_ADDRESS = '0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; +/** Canonical Chain 138 stables — $1 peg when price-at API is unavailable. */ +exports.CHAIN138_STABLE_USD_PEG = { + '0x93e66202a11b1772e55407b32b44e5cd8eda7f22': 1, // cUSDT + '0xf22258f57794cc8e06237084b353ab30ffa640b': 1, // cUSDC +}; +/** Known Chain 138 assets (override via env CHECKPOINT_USD_PEG_). */ +exports.CHAIN138_KNOWN_TOKEN_USD = { + ...exports.CHAIN138_STABLE_USD_PEG, + '0xb7721dd53a8c629d9f1ba31a5819afe250002b03': 15, // LINK (set CHECKPOINT_USD_PEG_LINK) + '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e': 90000, // cBTC + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': 2490, // WETH pricing proxy +}; +const SYMBOL_USD_ESTIMATE = { + CUSDT: 1, + CUSDC: 1, + USDT: 1, + USDC: 1, + CJPYC: 0.006285683794888213, + CGBPC: 1.3550353712543854, + CEURC: 1.178, + CAUDC: 0.7136366390016357, + CCADC: 0.7255928549430243, + CCHFC: 1.2776572668112798, + LINK: 15, + CBTC: 90000, + WETH: 2490, + ETH: 2490, +}; +function knownTokenUsd(address, symbol) { + const addr = address.toLowerCase(); + const envKey = symbol ? `CHECKPOINT_USD_PEG_${symbol.toUpperCase()}` : ''; + if (symbol && envKey && process.env[envKey]) { + const n = Number(process.env[envKey]); + if (Number.isFinite(n) && n > 0) + return n; + } + const fromAddr = exports.CHAIN138_KNOWN_TOKEN_USD[addr]; + if (fromAddr != null && fromAddr > 0) + return fromAddr; + if (symbol) { + const fromSym = SYMBOL_USD_ESTIMATE[symbol.toUpperCase()]; + if (fromSym != null && fromSym > 0) + return fromSym; + } + return undefined; +} +/** Convert decimal USD string (e.g. batch JSON) to 8-decimal fixed point for on-chain events. */ +function usdStringToE8(usd) { + if (usd == null || usd === '') + return 0n; + const n = Number(usd); + if (!Number.isFinite(n) || n < 0) + return 0n; + return BigInt(Math.round(n * 1e8)); +} +function decimalToScaledInteger(value, scale) { + if (!Number.isFinite(value)) + return null; + const normalized = value.toFixed(scale); + const negative = normalized.startsWith('-'); + const unsigned = negative ? normalized.slice(1) : normalized; + const [whole, fraction = ''] = unsigned.split('.'); + try { + const scaled = BigInt(whole + fraction.padEnd(scale, '0')); + return { scaled: negative ? -scaled : scaled, scale: 10n ** BigInt(scale) }; + } + catch { + return null; + } +} +/** USD string with `outputScale` fractional digits (default 6). */ +function formatAmountUsd(rawAmount, decimals, priceUsd, priceScale = 8, outputScale = 6) { + if (!rawAmount || !Number.isFinite(priceUsd) || decimals < 0) + return undefined; + try { + const amount = BigInt(rawAmount); + const parsedPrice = decimalToScaledInteger(priceUsd, priceScale); + if (!parsedPrice) + return undefined; + const numerator = amount * parsedPrice.scaled * (10n ** BigInt(outputScale)); + const denominator = (10n ** BigInt(decimals)) * parsedPrice.scale; + const rounded = (numerator + denominator / 2n) / denominator; + const divisor = 10n ** BigInt(outputScale); + const whole = rounded / divisor; + const fraction = (rounded % divisor).toString().padStart(outputScale, '0').replace(/0+$/, ''); + return fraction ? `${whole.toString()}.${fraction}` : whole.toString(); + } + catch { + return undefined; + } +} +function blockTimestampToIso(blockTimestamp) { + const sec = blockTimestamp > 1e12 ? Math.floor(blockTimestamp / 1000) : blockTimestamp; + return new Date(sec * 1000).toISOString(); +} +const priceCache = new Map(); +async function fetchHistoricalPriceUsd(cfg, tokenAddress, blockTimestamp) { + if (cfg.enabled === false) + return null; + const chainId = cfg.chainId ?? 138; + const iso = blockTimestampToIso(blockTimestamp); + const key = `${chainId}:${tokenAddress.toLowerCase()}:${iso}`; + if (priceCache.has(key)) + return priceCache.get(key) ?? null; + const base = cfg.apiBaseUrl.replace(/\/$/, ''); + const url = `${base}/tokens/${tokenAddress}/price-at?chainId=${chainId}×tamp=${encodeURIComponent(iso)}`; + try { + let res = null; + for (let attempt = 0; attempt < 4; attempt++) { + res = await fetch(url); + if (res.ok || res.status < 500) + break; + await new Promise((r) => setTimeout(r, 200 * (attempt + 1))); + } + if (!res) { + priceCache.set(key, null); + return null; + } + if (!res.ok) { + const pegFail = knownTokenUsd(tokenAddress); + if (pegFail != null) { + const snap = { + chainId, + tokenAddress: tokenAddress.toLowerCase(), + requestedTimestamp: iso, + effectiveTimestamp: iso, + priceUsd: pegFail, + source: 'canonical_token_peg', + }; + priceCache.set(key, snap); + return snap; + } + priceCache.set(key, null); + return null; + } + const body = (await res.json()); + if (body?.error || body?.priceUsd == null || !Number.isFinite(body.priceUsd)) { + const peg = knownTokenUsd(tokenAddress); + if (peg != null) { + const snap = { + chainId, + tokenAddress: tokenAddress.toLowerCase(), + requestedTimestamp: iso, + effectiveTimestamp: iso, + priceUsd: peg, + source: 'canonical_token_peg', + }; + priceCache.set(key, snap); + return snap; + } + priceCache.set(key, null); + return null; + } + priceCache.set(key, body); + return body; + } + catch { + const peg = knownTokenUsd(tokenAddress); + if (peg != null) { + const snap = { + chainId, + tokenAddress: tokenAddress.toLowerCase(), + requestedTimestamp: iso, + effectiveTimestamp: iso, + priceUsd: peg, + source: 'canonical_token_peg', + }; + priceCache.set(key, snap); + return snap; + } + priceCache.set(key, null); + return null; + } +} +async function priceTransferLine(cfg, line, blockTimestamp) { + const amount = BigInt(line.amountRaw || '0'); + if (amount === 0n) { + return { ...line, valueUsd: '0.000000', priceUsd: line.priceUsd, priceSource: line.priceSource ?? 'zero_amount' }; + } + const pricingAddress = line.kind === 'native' ? exports.CHAIN138_NATIVE_PRICING_ADDRESS : (line.token || '').trim(); + if (!pricingAddress) { + return { ...line, valueUsd: undefined, priceSource: 'unavailable' }; + } + let snap = await fetchHistoricalPriceUsd(cfg, pricingAddress, blockTimestamp); + if (!snap) { + const peg = knownTokenUsd(pricingAddress, line.tokenSymbol); + if (peg != null) { + snap = { + chainId: cfg.chainId ?? 138, + tokenAddress: pricingAddress.toLowerCase(), + requestedTimestamp: blockTimestampToIso(blockTimestamp), + effectiveTimestamp: blockTimestampToIso(blockTimestamp), + priceUsd: peg, + source: 'canonical_token_peg', + }; + } + } + if (!snap) { + return { ...line, valueUsd: undefined, priceSource: 'unavailable' }; + } + const decimals = line.kind === 'native' ? 18 : (line.tokenDecimals ?? 18); + const valueUsd = formatAmountUsd(line.amountRaw, decimals, snap.priceUsd); + return { + ...line, + priceUsd: snap.priceUsd, + priceSource: snap.source, + priceEffectiveTimestamp: snap.effectiveTimestamp, + valueUsd, + }; +} +/** Sum USD strings (6 decimal places) without floating-point drift. */ +function sumUsdStrings(values) { + const scale = 6n; + const factor = 10n ** scale; + let sumMicro = 0n; + let any = false; + for (const v of values) { + if (v == null || v === '') + continue; + const neg = v.startsWith('-'); + const parts = (neg ? v.slice(1) : v).split('.'); + const whole = BigInt(parts[0] || '0'); + const frac = (parts[1] || '').padEnd(Number(scale), '0').slice(0, Number(scale)); + const micro = whole * factor + BigInt(frac || '0'); + sumMicro += neg ? -micro : micro; + any = true; + } + if (!any) + return undefined; + const negOut = sumMicro < 0n; + const abs = negOut ? -sumMicro : sumMicro; + const whole = abs / factor; + const frac = (abs % factor).toString().padStart(Number(scale), '0').replace(/0+$/, ''); + const body = frac ? `${whole}.${frac}` : whole.toString(); + return negOut ? `-${body}` : body; +} +async function enrichLeafUsdFields(cfg, leaf, allErc20) { + const out = { ...leaf }; + for (const k of [ + 'valueUsd', + 'nativeValueUsd', + 'tokenValueUsd', + 'nativePriceUsd', + 'tokenPriceUsd', + 'priceSource', + 'priceEffectiveTimestamp', + 'totalTransfersUsd', + 'transfers', + 'usdEnrichedAt', + ]) { + delete out[k]; + } + const blockTimestamp = Number(out.blockTimestamp ?? 0); + const nativeWei = BigInt(String(out.nativeValueWei ?? out.value ?? '0')); + const lines = []; + if (nativeWei > 0n) { + lines.push({ + kind: 'native', + tokenSymbol: 'ETH', + tokenDecimals: 18, + from: String(out.from ?? ''), + to: String(out.to ?? ''), + amountRaw: nativeWei.toString(), + }); + } + for (const t of allErc20) { + lines.push({ + kind: 'erc20', + token: t.token, + tokenSymbol: t.tokenSymbol, + tokenDecimals: t.tokenDecimals, + from: t.from, + to: t.to, + amountRaw: t.value.toString(), + logIndex: t.logIndex, + }); + } + const priced = []; + for (const line of lines) { + priced.push(await priceTransferLine(cfg, line, blockTimestamp)); + if (cfg.requestDelayMs && cfg.requestDelayMs > 0) { + await new Promise((r) => setTimeout(r, cfg.requestDelayMs)); + } + } + out.transfers = priced; + const nativeLine = priced.find((l) => l.kind === 'native'); + const tokenLines = priced.filter((l) => l.kind === 'erc20'); + const primaryToken = tokenLines.reduce((best, cur) => { + if (!best) + return cur; + try { + return BigInt(cur.amountRaw) > BigInt(best.amountRaw) ? cur : best; + } + catch { + return best; + } + }, undefined); + if (nativeLine?.valueUsd != null) + out.nativeValueUsd = nativeLine.valueUsd; + if (nativeLine?.priceUsd != null) + out.nativePriceUsd = nativeLine.priceUsd; + if (primaryToken) { + if (primaryToken.valueUsd != null) + out.tokenValueUsd = primaryToken.valueUsd; + if (primaryToken.priceUsd != null) + out.tokenPriceUsd = primaryToken.priceUsd; + if (primaryToken.priceSource) + out.priceSource = primaryToken.priceSource; + if (primaryToken.priceEffectiveTimestamp) { + out.priceEffectiveTimestamp = primaryToken.priceEffectiveTimestamp; + } + } + const effectiveToken = BigInt(String(out.tokenValue ?? '0')); + if (effectiveToken > 0n && primaryToken?.valueUsd != null) { + out.valueUsd = primaryToken.valueUsd; + } + else if (nativeLine?.valueUsd != null) { + out.valueUsd = nativeLine.valueUsd; + } + else if (priced.length === 0) { + out.valueUsd = '0.000000'; + out.priceSource = 'no_transfer_value'; + } + else { + out.valueUsd = sumUsdStrings(priced.map((l) => l.valueUsd)); + out.priceSource = priced.some((l) => l.priceSource && l.priceSource !== 'unavailable') + ? 'multi_transfer_sum' + : 'unavailable'; + } + out.totalTransfersUsd = sumUsdStrings(priced.map((l) => l.valueUsd)); + out.usdEnrichedAt = new Date().toISOString(); + return out; +} diff --git a/packages/checkpoint-core/dist/usdPricing.test.d.ts b/packages/checkpoint-core/dist/usdPricing.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/checkpoint-core/dist/usdPricing.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/checkpoint-core/dist/usdPricing.test.js b/packages/checkpoint-core/dist/usdPricing.test.js new file mode 100644 index 0000000..2d0d940 --- /dev/null +++ b/packages/checkpoint-core/dist/usdPricing.test.js @@ -0,0 +1,12 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const strict_1 = __importDefault(require("node:assert/strict")); +const usdPricing_1 = require("./usdPricing"); +strict_1.default.equal((0, usdPricing_1.formatAmountUsd)('1000000', 6, 1), '1'); +strict_1.default.equal((0, usdPricing_1.formatAmountUsd)('1000000000000000000', 18, 2000), '2000'); +strict_1.default.equal((0, usdPricing_1.sumUsdStrings)(['1.5', '2.25']), '3.75'); +strict_1.default.equal((0, usdPricing_1.sumUsdStrings)(['0.000001', '0.000002']), '0.000003'); +console.log('checkpoint-core usdPricing ok'); diff --git a/packages/checkpoint-core/package.json b/packages/checkpoint-core/package.json new file mode 100644 index 0000000..251e3df --- /dev/null +++ b/packages/checkpoint-core/package.json @@ -0,0 +1,20 @@ +{ + "name": "@dbis/checkpoint-core", + "version": "0.1.0", + "private": true, + "description": "Canonical PaymentLeaf V1/V2 + Merkle (matches CheckpointLeaf.sol)", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": ["dist"], + "scripts": { + "build": "tsc", + "test": "node --test dist/merkle.test.js dist/usdPricing.test.js" + }, + "dependencies": { + "ethers": "^6.13.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.4.0" + } +} diff --git a/packages/checkpoint-core/pnpm-lock.yaml b/packages/checkpoint-core/pnpm-lock.yaml new file mode 100644 index 0000000..6e45dab --- /dev/null +++ b/packages/checkpoint-core/pnpm-lock.yaml @@ -0,0 +1,114 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + ethers: + specifier: ^6.13.0 + version: 6.16.0 + devDependencies: + '@types/node': + specifier: ^20.11.0 + version: 20.19.41 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + +packages: + + '@adraffy/ens-normalize@1.10.1': + resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} + + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + + '@types/node@20.19.41': + resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + + '@types/node@22.7.5': + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + + aes-js@4.0.0-beta.5: + resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + + ethers@6.16.0: + resolution: {integrity: sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==} + engines: {node: '>=14.0.0'} + + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@adraffy/ens-normalize@1.10.1': {} + + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + + '@noble/hashes@1.3.2': {} + + '@types/node@20.19.41': + dependencies: + undici-types: 6.21.0 + + '@types/node@22.7.5': + dependencies: + undici-types: 6.19.8 + + aes-js@4.0.0-beta.5: {} + + ethers@6.16.0: + dependencies: + '@adraffy/ens-normalize': 1.10.1 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@types/node': 22.7.5 + aes-js: 4.0.0-beta.5 + tslib: 2.7.0 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + tslib@2.7.0: {} + + typescript@5.9.3: {} + + undici-types@6.19.8: {} + + undici-types@6.21.0: {} + + ws@8.17.1: {} diff --git a/packages/checkpoint-core/src/index.ts b/packages/checkpoint-core/src/index.ts new file mode 100644 index 0000000..5060177 --- /dev/null +++ b/packages/checkpoint-core/src/index.ts @@ -0,0 +1,5 @@ +export * from './leaf'; +export * from './merkle'; +export * from './usdPricing'; +export * from './tokenTransfers'; +export * from './iso20022'; diff --git a/packages/checkpoint-core/src/iso20022/hashes.ts b/packages/checkpoint-core/src/iso20022/hashes.ts new file mode 100644 index 0000000..bb4b791 --- /dev/null +++ b/packages/checkpoint-core/src/iso20022/hashes.ts @@ -0,0 +1,24 @@ +import { ethers } from 'ethers'; + +export function bytes32FromUtf8(label: string, value: string): string { + return ethers.keccak256(ethers.toUtf8Bytes(`${label}:${value}`)); +} + +export function instructionIdFromTxHash(txHash: string): string { + return bytes32FromUtf8('INSTR', txHash); +} + +export function uetrFromTxHash(txHash: string): string { + const h = ethers.keccak256(ethers.toUtf8Bytes(`UETR:${txHash}`)); + const hex = h.slice(2); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +export function uetrBytes32FromTxHash(txHash: string): string { + return ethers.keccak256(ethers.toUtf8Bytes(uetrFromTxHash(txHash))); +} + +export function hashShortUtf8(value: string): string { + if (!value) return ethers.ZeroHash; + return ethers.keccak256(ethers.toUtf8Bytes(value)); +} diff --git a/packages/checkpoint-core/src/iso20022/index.ts b/packages/checkpoint-core/src/iso20022/index.ts new file mode 100644 index 0000000..18137dc --- /dev/null +++ b/packages/checkpoint-core/src/iso20022/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './hashes'; +export * from './mapFromPaymentLeaf'; diff --git a/packages/checkpoint-core/src/iso20022/mapFromPaymentLeaf.ts b/packages/checkpoint-core/src/iso20022/mapFromPaymentLeaf.ts new file mode 100644 index 0000000..0943902 --- /dev/null +++ b/packages/checkpoint-core/src/iso20022/mapFromPaymentLeaf.ts @@ -0,0 +1,107 @@ +import { ethers } from 'ethers'; +import type { CanonicalPaymentMessage, PaymentLeafIsoInput } from './types'; +import { MSG_TYPE } from './types'; +import { + hashShortUtf8, + instructionIdFromTxHash, + uetrBytes32FromTxHash, + uetrFromTxHash, +} from './hashes'; + +/** Map Chain 138 payment leaf → MT-103 / pacs.008 equivalent canonical message. */ +export function mapPaymentLeafToCanonical(leaf: PaymentLeafIsoInput): CanonicalPaymentMessage { + const txHash = leaf.txHash; + const instrBytes32 = instructionIdFromTxHash(txHash); + const instrDisplay = `CHAIN138-${txHash.slice(2, 34)}`; + const e2e = `E2E-${txHash.slice(2, 18)}`; + const uetr = uetrFromTxHash(txHash); + const usd = leaf.totalTransfersUsd ?? leaf.valueUsd ?? leaf.nativeValueUsd ?? '0'; + const currencyCode = leaf.tokenSymbol === 'ETH' || !leaf.token ? 'ETH' : 'USD'; + const purpose = `Chain138 settlement batch attestation; native value ${ethers.formatEther(leaf.value)} ETH; USD ref ${usd}`; + + const canonical: Omit = { + msgType: 'chain138.synthetic', + msgTypeCode: MSG_TYPE.CHAIN138_SYNTH, + instructionId: instrDisplay, + instructionIdBytes32: instrBytes32, + endToEndId: e2e, + endToEndIdHash: hashShortUtf8(e2e), + msgId: `MSG-${txHash.slice(2, 14)}`, + uetr, + uetrBytes32: uetrBytes32FromTxHash(txHash), + accountRefId: leaf.from, + counterpartyRefId: leaf.to, + debtorId: leaf.from, + creditorId: leaf.to, + purpose, + settlementMethod: 'CLRG', + categoryPurpose: 'CBFF', + currencyCode, + amountRaw: ethers.formatEther(leaf.value), + amountSmallestUnit: leaf.value.toString(), + tokenAddress: leaf.token, + debtorRefHash: hashShortUtf8(`${leaf.from}|${leaf.from}`), + creditorRefHash: hashShortUtf8(`${leaf.to}|${leaf.to}`), + purposeHash: hashShortUtf8(purpose), + chain138TxHash: txHash, + valueDateIso: new Date(leaf.blockTimestamp * 1000).toISOString().slice(0, 10), + }; + + const payloadHash = ethers.keccak256( + ethers.toUtf8Bytes( + JSON.stringify({ + ...canonical, + blockNumber: leaf.blockNumber, + blockTimestamp: leaf.blockTimestamp, + receiptHash: leaf.receiptHash ?? null, + logCount: leaf.logCount ?? 0, + }) + ) + ); + + return { ...canonical, payloadHash }; +} + +/** Minimal pacs.008.001 XML for OMNL store / audit (not full MX validation). */ +export function canonicalToPacs008Xml(c: CanonicalPaymentMessage): string { + const cre = new Date().toISOString(); + return ` + + + + ${escapeXml(c.msgId)} + ${escapeXml(cre)} + 1 + + + + ${escapeXml(c.instructionId)} + ${escapeXml(c.endToEndId)} + ${escapeXml(c.chain138TxHash)} + + ${escapeXml(c.amountRaw)} + ${escapeXml(c.debtorId)} + ${escapeXml(c.accountRefId)} + ${escapeXml(c.creditorId)} + ${escapeXml(c.counterpartyRefId)} + ${escapeXml(c.purpose)} + + Chain138Attestation + + ${escapeXml(c.uetr)} + ${escapeXml(c.payloadHash)} + ${c.msgTypeCode} + + + + +`; +} + +function escapeXml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/packages/checkpoint-core/src/iso20022/types.ts b/packages/checkpoint-core/src/iso20022/types.ts new file mode 100644 index 0000000..27f3843 --- /dev/null +++ b/packages/checkpoint-core/src/iso20022/types.ts @@ -0,0 +1,56 @@ +/** ISO 20022 / SWIFT MT-103+ canonical message (off-chain full payload; on-chain hashes). */ +export type IsoMessageTypeCode = 0 | 1 | 2 | 3 | 4; + +/** 0=unknown, 1=MT103, 2=pacs.008, 3=pain.001, 4=chain138 synthetic (from native tx) */ +export const MSG_TYPE = { + UNKNOWN: 0 as IsoMessageTypeCode, + MT103: 1 as IsoMessageTypeCode, + PACS008: 2 as IsoMessageTypeCode, + PAIN001: 3 as IsoMessageTypeCode, + CHAIN138_SYNTH: 4 as IsoMessageTypeCode, +} as const; + +export type CanonicalPaymentMessage = { + msgType: 'MT103' | 'pacs.008' | 'pain.001' | 'chain138.synthetic'; + msgTypeCode: IsoMessageTypeCode; + instructionId: string; + instructionIdBytes32: string; + endToEndId: string; + endToEndIdHash: string; + msgId: string; + uetr: string; + uetrBytes32: string; + accountRefId: string; + counterpartyRefId: string; + debtorId: string; + creditorId: string; + purpose: string; + settlementMethod: string; + categoryPurpose: string; + currencyCode: string; + amountRaw: string; + amountSmallestUnit: string; + tokenAddress?: string; + payloadHash: string; + debtorRefHash: string; + creditorRefHash: string; + purposeHash: string; + chain138TxHash: string; + valueDateIso?: string; +}; + +export type PaymentLeafIsoInput = { + txHash: string; + from: string; + to: string; + value: bigint; + blockNumber: number; + blockTimestamp: number; + valueUsd?: string; + nativeValueUsd?: string; + totalTransfersUsd?: string; + receiptHash?: string; + logCount?: number; + token?: string; + tokenSymbol?: string; +}; diff --git a/packages/checkpoint-core/src/leaf.ts b/packages/checkpoint-core/src/leaf.ts new file mode 100644 index 0000000..276591c --- /dev/null +++ b/packages/checkpoint-core/src/leaf.ts @@ -0,0 +1,100 @@ +import { ethers } from 'ethers'; + +export const PAYMENT_LEAF_V1 = 0x01; +export const PAYMENT_LEAF_V2 = 0x02; + +export type PaymentLeafV1Input = { + txHash: string; + from: string; + to: string; + /** Wei used in Merkle / on-chain leaf (native or effective token amount). */ + value: bigint | string | number; + blockNumber: bigint | string | number; + blockTimestamp: bigint | string | number; + gasUsed: bigint | string | number; + success: boolean; +}; + +export type PaymentLeafV2Input = PaymentLeafV1Input & { + token: string; + logIndex?: bigint | string | number; +}; + +/** Matches CheckpointLeaf.paymentLeafV1 */ +export function paymentLeafV1Hash(chainId: number | bigint, leaf: PaymentLeafV1Input): string { + const version = ethers.hexlify(new Uint8Array([PAYMENT_LEAF_V1])); + return ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes1', 'uint64', 'bytes32', 'address', 'address', 'uint256', 'uint256', 'uint64', 'uint256', 'bool'], + [ + version, + BigInt(chainId), + leaf.txHash, + leaf.from, + leaf.to, + BigInt(leaf.value ?? 0), + BigInt(leaf.blockNumber), + BigInt(leaf.blockTimestamp), + BigInt(leaf.gasUsed ?? 0), + leaf.success, + ] + ) + ); +} + +/** Matches CheckpointLeaf.paymentLeafV2 */ +export function paymentLeafV2Hash(chainId: number | bigint, leaf: PaymentLeafV2Input): string { + const version = ethers.hexlify(new Uint8Array([PAYMENT_LEAF_V2])); + return ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode( + [ + 'bytes1', + 'uint64', + 'bytes32', + 'address', + 'address', + 'address', + 'uint256', + 'uint256', + 'uint64', + 'uint256', + 'bool', + 'uint32', + ], + [ + version, + BigInt(chainId), + leaf.txHash, + leaf.from, + leaf.to, + leaf.token, + BigInt(leaf.value ?? 0), + BigInt(leaf.blockNumber), + BigInt(leaf.blockTimestamp), + BigInt(leaf.gasUsed ?? 0), + leaf.success, + Number(leaf.logIndex ?? 0), + ] + ) + ); +} + +/** Historical batches 1–50: verify with native/on-chain wei only. */ +export function merkleVerifyValueWei(record: Record): bigint { + if (record.onChainValueWei != null && String(record.onChainValueWei) !== '') { + return BigInt(String(record.onChainValueWei)); + } + return BigInt(String(record.nativeValueWei ?? record.value ?? '0')); +} + +export function effectiveTokenOrNativeWei(record: { + value?: unknown; + token?: unknown; + tokenValue?: unknown; +}): bigint { + const native = BigInt(String(record.value ?? '0')); + const tokenVal = + record.token != null && record.tokenValue != null ? BigInt(String(record.tokenValue)) : 0n; + if (tokenVal > 0n) return tokenVal; + return native; +} diff --git a/packages/checkpoint-core/src/merkle.test.ts b/packages/checkpoint-core/src/merkle.test.ts new file mode 100644 index 0000000..f8aa68e --- /dev/null +++ b/packages/checkpoint-core/src/merkle.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { buildMerkleRoot, paymentLeafV1Hash } from './index'; + +const leaves = [ + { + txHash: '0x' + '11'.repeat(32), + from: '0x' + '22'.repeat(20), + to: '0x' + '33'.repeat(20), + value: 0n, + blockNumber: 1n, + blockTimestamp: 2n, + gasUsed: 3n, + success: true, + }, +]; +const h = paymentLeafV1Hash(138, leaves[0]); +assert.equal(buildMerkleRoot([h]), h); +console.log('checkpoint-core merkle ok'); diff --git a/packages/checkpoint-core/src/merkle.ts b/packages/checkpoint-core/src/merkle.ts new file mode 100644 index 0000000..f313196 --- /dev/null +++ b/packages/checkpoint-core/src/merkle.ts @@ -0,0 +1,44 @@ +import { ethers } from 'ethers'; + +/** Matches CheckpointLeaf.buildMerkleRoot (pair sort, odd duplicate). */ +export function buildMerkleRoot(hashes: string[]): string { + if (hashes.length === 0) return ethers.ZeroHash; + let layer = [...hashes]; + while (layer.length > 1) { + const next: string[] = []; + for (let i = 0; i < layer.length; i += 2) { + const a = layer[i]; + const b = i + 1 < layer.length ? layer[i + 1] : a; + const [left, right] = a.toLowerCase() < b.toLowerCase() ? [a, b] : [b, a]; + next.push(ethers.keccak256(ethers.concat([left, right]))); + } + layer = next; + } + return layer[0]; +} + +export function merkleProofs(hashes: string[]): { root: string; proofs: string[][] } { + const root = buildMerkleRoot(hashes); + const proofs = hashes.map((_, index) => buildProof(hashes, index)); + return { root, proofs }; +} + +function buildProof(leaves: string[], index: number): string[] { + const proof: string[] = []; + let idx = index; + let layer = [...leaves]; + while (layer.length > 1) { + const next: string[] = []; + for (let i = 0; i < layer.length; i += 2) { + const a = layer[i]; + const b = i + 1 < layer.length ? layer[i + 1] : a; + const [left, right] = a.toLowerCase() < b.toLowerCase() ? [a, b] : [b, a]; + next.push(ethers.keccak256(ethers.concat([left, right]))); + } + const siblingIdx = idx % 2 === 0 ? idx + 1 : idx - 1; + if (siblingIdx < layer.length) proof.push(layer[siblingIdx]); + idx = Math.floor(idx / 2); + layer = next; + } + return proof; +} diff --git a/packages/checkpoint-core/src/tokenTransfers.ts b/packages/checkpoint-core/src/tokenTransfers.ts new file mode 100644 index 0000000..000e8c7 --- /dev/null +++ b/packages/checkpoint-core/src/tokenTransfers.ts @@ -0,0 +1,115 @@ +/** Blockscout ERC-20 transfer parsing (shared by aggregator + indexer). */ + +export interface TokenTransferSummary { + token: string; + tokenSymbol: string; + tokenDecimals: number; + from: string; + to: string; + value: bigint; + logIndex: number; +} + +interface TokenTransferItem { + from?: { hash?: string }; + to?: { hash?: string }; + token?: { address?: string; symbol?: string; decimals?: string }; + total?: { value?: string; decimals?: string }; + value?: string | null; + type?: string; + log_index?: number; +} + +function parseItem(item: TokenTransferItem): TokenTransferSummary | null { + const token = item.token?.address; + const raw = + item.total?.value ?? + (item.value != null && item.value !== '' ? item.value : undefined); + if (!token || raw === undefined) return null; + const value = BigInt(raw); + if (value === 0n) return null; + const decimals = parseInt( + item.token?.decimals || item.total?.decimals || '18', + 10 + ); + return { + token, + tokenSymbol: item.token?.symbol || '', + tokenDecimals: Number.isFinite(decimals) ? decimals : 18, + from: item.from?.hash || '', + to: item.to?.hash || '', + value, + logIndex: item.log_index ?? 0, + }; +} + +/** Largest ERC-20 transfer in a tx (legacy primary payment). */ +export function pickPrimaryTokenTransfer(items: TokenTransferItem[]): TokenTransferSummary | null { + let best: TokenTransferSummary | null = null; + for (const item of items) { + const summary = parseItem(item); + if (!summary) continue; + if (!best || summary.value > best.value) best = summary; + } + return best; +} + +/** All non-zero ERC-20 transfers in a tx. */ +export function parseAllTokenTransfers(items: TokenTransferItem[]): TokenTransferSummary[] { + const out: TokenTransferSummary[] = []; + for (const item of items) { + const summary = parseItem(item); + if (summary) out.push(summary); + } + return out.sort((a, b) => (a.logIndex < b.logIndex ? -1 : a.logIndex > b.logIndex ? 1 : 0)); +} + +export async function fetchTokenTransferItemsForTx( + apiBase: string, + txHash: string +): Promise { + const base = apiBase.replace(/\/$/, ''); + const res = await fetch(`${base}/transactions/${txHash}/token-transfers`); + if (!res.ok) return []; + const body = (await res.json()) as { items?: TokenTransferItem[] }; + return body.items ?? []; +} + +export function pickPrimaryFromSummaries( + items: TokenTransferSummary[] +): TokenTransferSummary | null { + let best: TokenTransferSummary | null = null; + for (const s of items) { + if (!best || s.value > best.value) best = s; + } + return best; +} + +export function applyPrimaryTransferToLeaf( + leaf: Record, + primary: TokenTransferSummary | null +): void { + if (!primary) return; + leaf.token = primary.token; + leaf.tokenSymbol = primary.tokenSymbol; + leaf.tokenDecimals = primary.tokenDecimals; + leaf.tokenValue = primary.value.toString(); + leaf.tokenLogIndex = primary.logIndex; + if (leaf.nativeValueWei == null) leaf.nativeValueWei = String(leaf.value ?? '0'); +} + +export async function fetchAllTokenTransfersForTx( + apiBase: string, + txHash: string +): Promise { + const items = await fetchTokenTransferItemsForTx(apiBase, txHash); + return parseAllTokenTransfers(items); +} + +export async function fetchPrimaryTokenTransferForTx( + apiBase: string, + txHash: string +): Promise { + const items = await fetchTokenTransferItemsForTx(apiBase, txHash); + return pickPrimaryTokenTransfer(items); +} diff --git a/packages/checkpoint-core/src/usdPricing.test.ts b/packages/checkpoint-core/src/usdPricing.test.ts new file mode 100644 index 0000000..d7f6857 --- /dev/null +++ b/packages/checkpoint-core/src/usdPricing.test.ts @@ -0,0 +1,8 @@ +import assert from 'node:assert/strict'; +import { formatAmountUsd, sumUsdStrings } from './usdPricing'; + +assert.equal(formatAmountUsd('1000000', 6, 1), '1'); +assert.equal(formatAmountUsd('1000000000000000000', 18, 2000), '2000'); +assert.equal(sumUsdStrings(['1.5', '2.25']), '3.75'); +assert.equal(sumUsdStrings(['0.000001', '0.000002']), '0.000003'); +console.log('checkpoint-core usdPricing ok'); diff --git a/packages/checkpoint-core/src/usdPricing.ts b/packages/checkpoint-core/src/usdPricing.ts new file mode 100644 index 0000000..0706974 --- /dev/null +++ b/packages/checkpoint-core/src/usdPricing.ts @@ -0,0 +1,391 @@ +/** Transfer-time USD for Chain 138 checkpoint leaves (off-chain metadata only). */ + +export const CHAIN138_NATIVE_PRICING_ADDRESS = + '0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; + +/** Canonical Chain 138 stables — $1 peg when price-at API is unavailable. */ +export const CHAIN138_STABLE_USD_PEG: Record = { + '0x93e66202a11b1772e55407b32b44e5cd8eda7f22': 1, // cUSDT + '0xf22258f57794cc8e06237084b353ab30ffa640b': 1, // cUSDC +}; + +/** Known Chain 138 assets (override via env CHECKPOINT_USD_PEG_). */ +export const CHAIN138_KNOWN_TOKEN_USD: Record = { + ...CHAIN138_STABLE_USD_PEG, + '0xb7721dd53a8c629d9f1ba31a5819afe250002b03': 15, // LINK (set CHECKPOINT_USD_PEG_LINK) + '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e': 90000, // cBTC + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': 2490, // WETH pricing proxy +}; + +const SYMBOL_USD_ESTIMATE: Record = { + CUSDT: 1, + CUSDC: 1, + USDT: 1, + USDC: 1, + CJPYC: 0.006285683794888213, + CGBPC: 1.3550353712543854, + CEURC: 1.178, + CAUDC: 0.7136366390016357, + CCADC: 0.7255928549430243, + CCHFC: 1.2776572668112798, + LINK: 15, + CBTC: 90000, + WETH: 2490, + ETH: 2490, +}; + +function knownTokenUsd(address: string, symbol?: string): number | undefined { + const addr = address.toLowerCase(); + const envKey = symbol ? `CHECKPOINT_USD_PEG_${symbol.toUpperCase()}` : ''; + if (symbol && envKey && process.env[envKey]) { + const n = Number(process.env[envKey]); + if (Number.isFinite(n) && n > 0) return n; + } + const fromAddr = CHAIN138_KNOWN_TOKEN_USD[addr]; + if (fromAddr != null && fromAddr > 0) return fromAddr; + if (symbol) { + const fromSym = SYMBOL_USD_ESTIMATE[symbol.toUpperCase()]; + if (fromSym != null && fromSym > 0) return fromSym; + } + return undefined; +} + +export type HistoricalPriceSnapshot = { + chainId: number; + tokenAddress: string; + requestedTimestamp: string; + effectiveTimestamp: string; + priceUsd: number; + source: string; +}; + +export type TransferUsdLine = { + kind: 'native' | 'erc20'; + token?: string; + tokenSymbol?: string; + tokenDecimals?: number; + from?: string; + to?: string; + amountRaw: string; + logIndex?: number; + priceUsd?: number; + priceSource?: string; + priceEffectiveTimestamp?: string; + valueUsd?: string; +}; + +/** Convert decimal USD string (e.g. batch JSON) to 8-decimal fixed point for on-chain events. */ +export function usdStringToE8(usd: string | undefined | null): bigint { + if (usd == null || usd === '') return 0n; + const n = Number(usd); + if (!Number.isFinite(n) || n < 0) return 0n; + return BigInt(Math.round(n * 1e8)); +} + +export type UsdPricingConfig = { + apiBaseUrl: string; + chainId?: number; + enabled?: boolean; + requestDelayMs?: number; +}; + +function decimalToScaledInteger(value: number, scale: number): { scaled: bigint; scale: bigint } | null { + if (!Number.isFinite(value)) return null; + const normalized = value.toFixed(scale); + const negative = normalized.startsWith('-'); + const unsigned = negative ? normalized.slice(1) : normalized; + const [whole, fraction = ''] = unsigned.split('.'); + try { + const scaled = BigInt(whole + fraction.padEnd(scale, '0')); + return { scaled: negative ? -scaled : scaled, scale: 10n ** BigInt(scale) }; + } catch { + return null; + } +} + +/** USD string with `outputScale` fractional digits (default 6). */ +export function formatAmountUsd( + rawAmount: string, + decimals: number, + priceUsd: number, + priceScale = 8, + outputScale = 6 +): string | undefined { + if (!rawAmount || !Number.isFinite(priceUsd) || decimals < 0) return undefined; + try { + const amount = BigInt(rawAmount); + const parsedPrice = decimalToScaledInteger(priceUsd, priceScale); + if (!parsedPrice) return undefined; + const numerator = amount * parsedPrice.scaled * (10n ** BigInt(outputScale)); + const denominator = (10n ** BigInt(decimals)) * parsedPrice.scale; + const rounded = (numerator + denominator / 2n) / denominator; + const divisor = 10n ** BigInt(outputScale); + const whole = rounded / divisor; + const fraction = (rounded % divisor).toString().padStart(outputScale, '0').replace(/0+$/, ''); + return fraction ? `${whole.toString()}.${fraction}` : whole.toString(); + } catch { + return undefined; + } +} + +export function blockTimestampToIso(blockTimestamp: number): string { + const sec = blockTimestamp > 1e12 ? Math.floor(blockTimestamp / 1000) : blockTimestamp; + return new Date(sec * 1000).toISOString(); +} + +const priceCache = new Map(); + +export async function fetchHistoricalPriceUsd( + cfg: UsdPricingConfig, + tokenAddress: string, + blockTimestamp: number +): Promise { + if (cfg.enabled === false) return null; + const chainId = cfg.chainId ?? 138; + const iso = blockTimestampToIso(blockTimestamp); + const key = `${chainId}:${tokenAddress.toLowerCase()}:${iso}`; + if (priceCache.has(key)) return priceCache.get(key) ?? null; + + const base = cfg.apiBaseUrl.replace(/\/$/, ''); + const url = `${base}/tokens/${tokenAddress}/price-at?chainId=${chainId}×tamp=${encodeURIComponent(iso)}`; + try { + let res: Response | null = null; + for (let attempt = 0; attempt < 4; attempt++) { + res = await fetch(url); + if (res.ok || res.status < 500) break; + await new Promise((r) => setTimeout(r, 200 * (attempt + 1))); + } + if (!res) { + priceCache.set(key, null); + return null; + } + if (!res.ok) { + const pegFail = knownTokenUsd(tokenAddress); + if (pegFail != null) { + const snap: HistoricalPriceSnapshot = { + chainId, + tokenAddress: tokenAddress.toLowerCase(), + requestedTimestamp: iso, + effectiveTimestamp: iso, + priceUsd: pegFail, + source: 'canonical_token_peg', + }; + priceCache.set(key, snap); + return snap; + } + priceCache.set(key, null); + return null; + } + const body = (await res.json()) as HistoricalPriceSnapshot & { error?: string }; + if (body?.error || body?.priceUsd == null || !Number.isFinite(body.priceUsd)) { + const peg = knownTokenUsd(tokenAddress); + if (peg != null) { + const snap: HistoricalPriceSnapshot = { + chainId, + tokenAddress: tokenAddress.toLowerCase(), + requestedTimestamp: iso, + effectiveTimestamp: iso, + priceUsd: peg, + source: 'canonical_token_peg', + }; + priceCache.set(key, snap); + return snap; + } + priceCache.set(key, null); + return null; + } + priceCache.set(key, body); + return body; + } catch { + const peg = knownTokenUsd(tokenAddress); + if (peg != null) { + const snap: HistoricalPriceSnapshot = { + chainId, + tokenAddress: tokenAddress.toLowerCase(), + requestedTimestamp: iso, + effectiveTimestamp: iso, + priceUsd: peg, + source: 'canonical_token_peg', + }; + priceCache.set(key, snap); + return snap; + } + priceCache.set(key, null); + return null; + } +} + +export async function priceTransferLine( + cfg: UsdPricingConfig, + line: TransferUsdLine, + blockTimestamp: number +): Promise { + const amount = BigInt(line.amountRaw || '0'); + if (amount === 0n) { + return { ...line, valueUsd: '0.000000', priceUsd: line.priceUsd, priceSource: line.priceSource ?? 'zero_amount' }; + } + const pricingAddress = + line.kind === 'native' ? CHAIN138_NATIVE_PRICING_ADDRESS : (line.token || '').trim(); + if (!pricingAddress) { + return { ...line, valueUsd: undefined, priceSource: 'unavailable' }; + } + let snap = await fetchHistoricalPriceUsd(cfg, pricingAddress, blockTimestamp); + if (!snap) { + const peg = knownTokenUsd(pricingAddress, line.tokenSymbol); + if (peg != null) { + snap = { + chainId: cfg.chainId ?? 138, + tokenAddress: pricingAddress.toLowerCase(), + requestedTimestamp: blockTimestampToIso(blockTimestamp), + effectiveTimestamp: blockTimestampToIso(blockTimestamp), + priceUsd: peg, + source: 'canonical_token_peg', + }; + } + } + if (!snap) { + return { ...line, valueUsd: undefined, priceSource: 'unavailable' }; + } + const decimals = line.kind === 'native' ? 18 : (line.tokenDecimals ?? 18); + const valueUsd = formatAmountUsd(line.amountRaw, decimals, snap.priceUsd); + return { + ...line, + priceUsd: snap.priceUsd, + priceSource: snap.source, + priceEffectiveTimestamp: snap.effectiveTimestamp, + valueUsd, + }; +} + +/** Sum USD strings (6 decimal places) without floating-point drift. */ +export function sumUsdStrings(values: (string | undefined)[]): string | undefined { + const scale = 6n; + const factor = 10n ** scale; + let sumMicro = 0n; + let any = false; + for (const v of values) { + if (v == null || v === '') continue; + const neg = v.startsWith('-'); + const parts = (neg ? v.slice(1) : v).split('.'); + const whole = BigInt(parts[0] || '0'); + const frac = (parts[1] || '').padEnd(Number(scale), '0').slice(0, Number(scale)); + const micro = whole * factor + BigInt(frac || '0'); + sumMicro += neg ? -micro : micro; + any = true; + } + if (!any) return undefined; + const negOut = sumMicro < 0n; + const abs = negOut ? -sumMicro : sumMicro; + const whole = abs / factor; + const frac = (abs % factor).toString().padStart(Number(scale), '0').replace(/0+$/, ''); + const body = frac ? `${whole}.${frac}` : whole.toString(); + return negOut ? `-${body}` : body; +} + +export async function enrichLeafUsdFields( + cfg: UsdPricingConfig, + leaf: Record, + allErc20: Array<{ + token: string; + tokenSymbol: string; + tokenDecimals: number; + from: string; + to: string; + value: bigint; + logIndex: number; + }> +): Promise> { + const out = { ...leaf }; + for (const k of [ + 'valueUsd', + 'nativeValueUsd', + 'tokenValueUsd', + 'nativePriceUsd', + 'tokenPriceUsd', + 'priceSource', + 'priceEffectiveTimestamp', + 'totalTransfersUsd', + 'transfers', + 'usdEnrichedAt', + ]) { + delete out[k]; + } + const blockTimestamp = Number(out.blockTimestamp ?? 0); + const nativeWei = BigInt(String(out.nativeValueWei ?? out.value ?? '0')); + const lines: TransferUsdLine[] = []; + + if (nativeWei > 0n) { + lines.push({ + kind: 'native', + tokenSymbol: 'ETH', + tokenDecimals: 18, + from: String(out.from ?? ''), + to: String(out.to ?? ''), + amountRaw: nativeWei.toString(), + }); + } + + for (const t of allErc20) { + lines.push({ + kind: 'erc20', + token: t.token, + tokenSymbol: t.tokenSymbol, + tokenDecimals: t.tokenDecimals, + from: t.from, + to: t.to, + amountRaw: t.value.toString(), + logIndex: t.logIndex, + }); + } + + const priced: TransferUsdLine[] = []; + for (const line of lines) { + priced.push(await priceTransferLine(cfg, line, blockTimestamp)); + if (cfg.requestDelayMs && cfg.requestDelayMs > 0) { + await new Promise((r) => setTimeout(r, cfg.requestDelayMs)); + } + } + + out.transfers = priced; + + const nativeLine = priced.find((l) => l.kind === 'native'); + const tokenLines = priced.filter((l) => l.kind === 'erc20'); + const primaryToken = tokenLines.reduce((best, cur) => { + if (!best) return cur; + try { + return BigInt(cur.amountRaw) > BigInt(best.amountRaw) ? cur : best; + } catch { + return best; + } + }, undefined); + + if (nativeLine?.valueUsd != null) out.nativeValueUsd = nativeLine.valueUsd; + if (nativeLine?.priceUsd != null) out.nativePriceUsd = nativeLine.priceUsd; + if (primaryToken) { + if (primaryToken.valueUsd != null) out.tokenValueUsd = primaryToken.valueUsd; + if (primaryToken.priceUsd != null) out.tokenPriceUsd = primaryToken.priceUsd; + if (primaryToken.priceSource) out.priceSource = primaryToken.priceSource; + if (primaryToken.priceEffectiveTimestamp) { + out.priceEffectiveTimestamp = primaryToken.priceEffectiveTimestamp; + } + } + + const effectiveToken = BigInt(String(out.tokenValue ?? '0')); + if (effectiveToken > 0n && primaryToken?.valueUsd != null) { + out.valueUsd = primaryToken.valueUsd; + } else if (nativeLine?.valueUsd != null) { + out.valueUsd = nativeLine.valueUsd; + } else if (priced.length === 0) { + out.valueUsd = '0.000000'; + out.priceSource = 'no_transfer_value'; + } else { + out.valueUsd = sumUsdStrings(priced.map((l) => l.valueUsd)); + out.priceSource = priced.some((l) => l.priceSource && l.priceSource !== 'unavailable') + ? 'multi_transfer_sum' + : 'unavailable'; + } + + out.totalTransfersUsd = sumUsdStrings(priced.map((l) => l.valueUsd)); + out.usdEnrichedAt = new Date().toISOString(); + return out; +} diff --git a/packages/checkpoint-core/tsconfig.json b/packages/checkpoint-core/tsconfig.json new file mode 100644 index 0000000..3cacd25 --- /dev/null +++ b/packages/checkpoint-core/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/script/DeployCCIPRelayBridgeLINK.s.sol b/script/DeployCCIPRelayBridgeLINK.s.sol new file mode 100644 index 0000000..90243a1 --- /dev/null +++ b/script/DeployCCIPRelayBridgeLINK.s.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Script, console} from "forge-std/Script.sol"; +import {CCIPRelayRouter} from "../contracts/relay/CCIPRelayRouter.sol"; +import {CCIPRelayBridgeLINK} from "../contracts/relay/CCIPRelayBridgeLINK.sol"; + +/// @notice Deploy CCIPRelayBridgeLINK on destination chain and authorize on existing CCIPRelayRouter. +/// Env: PRIVATE_KEY, LINK_TOKEN_MAINNET (or DEST_LINK_ADDRESS), CCIP_RELAY_ROUTER_MAINNET +contract DeployCCIPRelayBridgeLINK is Script { + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address link = vm.envAddress("LINK_TOKEN_MAINNET"); + address relayRouter = vm.envAddress("CCIP_RELAY_ROUTER_MAINNET"); + + vm.startBroadcast(deployerPrivateKey); + + CCIPRelayBridgeLINK linkBridge = new CCIPRelayBridgeLINK(link, relayRouter); + console.log("CCIPRelayBridgeLINK:", address(linkBridge)); + + CCIPRelayRouter(relayRouter).authorizeBridge(address(linkBridge)); + console.log("Authorized on CCIPRelayRouter:", relayRouter); + + vm.stopBroadcast(); + } +} diff --git a/script/FundBridgeLinkViaCcip138.s.sol b/script/FundBridgeLinkViaCcip138.s.sol new file mode 100644 index 0000000..89ce7ce --- /dev/null +++ b/script/FundBridgeLinkViaCcip138.s.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Script.sol"; +import {IRouterClient} from "../contracts/ccip/IRouterClient.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @notice CCIP-send Chain 138 LINK to a destination-chain bridge contract (operator funding). +/// Env: PRIVATE_KEY, CCIP_ROUTER_CHAIN138, LINK_TOKEN_CHAIN138, DEST_SELECTOR, DEST_BRIDGE, LINK_AMOUNT_WEI +contract FundBridgeLinkViaCcip138 is Script { + function run() external { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address router = vm.envAddress("CCIP_ROUTER_CHAIN138"); + address link = vm.envAddress("LINK_TOKEN_CHAIN138"); + uint64 destSelector = uint64(vm.envUint("DEST_SELECTOR")); + address destBridge = vm.envAddress("DEST_BRIDGE"); + uint256 amount = vm.envUint("LINK_AMOUNT_WEI"); + + IRouterClient.TokenAmount[] memory tokenAmounts = new IRouterClient.TokenAmount[](1); + tokenAmounts[0] = IRouterClient.TokenAmount({ + token: link, + amount: amount, + amountType: IRouterClient.TokenAmountType.Fiat + }); + + IRouterClient.EVM2AnyMessage memory message = IRouterClient.EVM2AnyMessage({ + receiver: abi.encode(destBridge), + data: "", + tokenAmounts: tokenAmounts, + feeToken: link, + extraArgs: "" + }); + + uint256 fee = IRouterClient(router).getFee(destSelector, message); + console2.log("destBridge", destBridge); + console2.log("amount", amount); + console2.log("fee", fee); + + vm.startBroadcast(deployerKey); + IERC20(link).approve(router, amount + fee); + (bytes32 messageId,) = IRouterClient(router).ccipSend(destSelector, message); + console2.logBytes32(messageId); + vm.stopBroadcast(); + } +} diff --git a/script/FundBridgeLinkViaCcipMainnet.s.sol b/script/FundBridgeLinkViaCcipMainnet.s.sol new file mode 100644 index 0000000..165017f --- /dev/null +++ b/script/FundBridgeLinkViaCcipMainnet.s.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Script.sol"; +import {IRouterClient} from "@chainlink/contracts-ccip/contracts/interfaces/IRouterClient.sol"; +import {Client} from "@chainlink/contracts-ccip/contracts/libraries/Client.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @notice CCIP-send canonical mainnet LINK to a destination-chain bridge contract. +/// Env: PRIVATE_KEY, CCIP_ROUTER_MAINNET, LINK_TOKEN_MAINNET, DEST_SELECTOR, DEST_BRIDGE, LINK_AMOUNT_WEI +contract FundBridgeLinkViaCcipMainnet is Script { + function run() external { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address router = vm.envAddress("CCIP_ROUTER_MAINNET"); + address link = vm.envAddress("LINK_TOKEN_MAINNET"); + uint64 destSelector = uint64(vm.envUint("DEST_SELECTOR")); + address destBridge = vm.envAddress("DEST_BRIDGE"); + uint256 amount = vm.envUint("LINK_AMOUNT_WEI"); + + Client.EVMTokenAmount[] memory tokenAmounts = new Client.EVMTokenAmount[](1); + tokenAmounts[0] = Client.EVMTokenAmount({token: link, amount: amount}); + + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(destBridge), + data: "", + tokenAmounts: tokenAmounts, + feeToken: link, + extraArgs: Client._argsToBytes(Client.EVMExtraArgsV1({gasLimit: 200_000})) + }); + + uint256 fee = IRouterClient(router).getFee(destSelector, message); + console2.log("destBridge", destBridge); + console2.log("amount", amount); + console2.log("fee", fee); + + vm.startBroadcast(deployerKey); + IERC20(link).approve(router, amount + fee); + bytes32 messageId = IRouterClient(router).ccipSend(destSelector, message); + console2.logBytes32(messageId); + vm.stopBroadcast(); + } +} diff --git a/script/bridge/trustless/DeployChain138StabilizerFinish.s.sol b/script/bridge/trustless/DeployChain138StabilizerFinish.s.sol new file mode 100644 index 0000000..34d2519 --- /dev/null +++ b/script/bridge/trustless/DeployChain138StabilizerFinish.s.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Script, console2} from "forge-std/Script.sol"; +import {Stabilizer} from "../../../contracts/bridge/trustless/integration/Stabilizer.sol"; +import {StablecoinPegManager} from "../../../contracts/bridge/trustless/integration/StablecoinPegManager.sol"; +import {CommodityPegManager} from "../../../contracts/bridge/trustless/integration/CommodityPegManager.sol"; + +/// @notice Finish Chain 138 Stabilizer stack after peg managers are already deployed. +/// @dev Env: STABLECOIN_PEG_MANAGER, COMMODITY_PEG_MANAGER, PRIVATE_POOL_REGISTRY, PRIVATE_KEY +contract DeployChain138StabilizerFinish is Script { + function run() external { + require(block.chainid == 138, "chain138 only"); + + uint256 pk = vm.envUint("PRIVATE_KEY"); + address admin = vm.envOr("STABILIZER_ADMIN", vm.addr(pk)); + address privatePoolRegistry = vm.envAddress("PRIVATE_POOL_REGISTRY"); + address stablecoinPeg = vm.envAddress("STABLECOIN_PEG_MANAGER"); + address commodityPeg = vm.envAddress("COMMODITY_PEG_MANAGER"); + + address cUsdc = vm.envOr("COMPLIANT_USDC_ADDRESS", vm.envOr("CUSDC_ADDRESS_138", address(0))); + address cUsdt = vm.envOr("COMPLIANT_USDT_ADDRESS", vm.envOr("CUSDT_ADDRESS_138", address(0))); + address weth = vm.envOr("WETH9", address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2)); + address xau = vm.envOr("XAU_ADDRESS_138", vm.envOr("cXAUC_ADDRESS_138", address(0x290E52a8819A4fbD0714E517225429aA2B70EC6b))); + + StablecoinPegManager spm = StablecoinPegManager(stablecoinPeg); + CommodityPegManager cpm = CommodityPegManager(commodityPeg); + + vm.startBroadcast(pk); + + spm.registerWETH(weth); + if (xau != address(0)) { + cpm.setXAUAddress(xau); + cpm.registerCommodity(xau, "XAU", 1e18); + } + + Stabilizer stabilizer = new Stabilizer(admin, privatePoolRegistry); + stabilizer.setStablecoinPegSource(stablecoinPeg, cUsdc != address(0) ? cUsdc : cUsdt); + if (xau != address(0)) { + stabilizer.setCommodityPegSource(commodityPeg, xau); + } + + vm.stopBroadcast(); + + console2.log("Stabilizer", address(stabilizer)); + console2.log("StablecoinPegManager", stablecoinPeg); + console2.log("CommodityPegManager", commodityPeg); + } +} diff --git a/script/bridge/trustless/DeployChain138StabilizerStack.s.sol b/script/bridge/trustless/DeployChain138StabilizerStack.s.sol new file mode 100644 index 0000000..6da1e22 --- /dev/null +++ b/script/bridge/trustless/DeployChain138StabilizerStack.s.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Script, console2} from "forge-std/Script.sol"; +import "../../../contracts/bridge/trustless/integration/Stabilizer.sol"; +import "../../../contracts/bridge/trustless/integration/StablecoinPegManager.sol"; +import "../../../contracts/bridge/trustless/integration/CommodityPegManager.sol"; + +/// @notice Deploy peg managers + Stabilizer on Chain 138 (Defi Oracle Meta). +/// @dev Requires PRIVATE_KEY, RESERVE_SYSTEM, PRIVATE_POOL_REGISTRY; optional STABILIZER_ADMIN, XAU peg asset. +contract DeployChain138StabilizerStack is Script { + function run() external { + require(block.chainid == 138, "chain138 only"); + + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + address admin = vm.envOr("STABILIZER_ADMIN", deployer); + address reserveSystem = vm.envAddress("RESERVE_SYSTEM"); + address privatePoolRegistry = vm.envAddress("PRIVATE_POOL_REGISTRY"); + + vm.startBroadcast(pk); + + StablecoinPegManager stablecoinPeg = new StablecoinPegManager(reserveSystem); + CommodityPegManager commodityPeg = new CommodityPegManager(reserveSystem); + + address cUsdc = vm.envOr("COMPLIANT_USDC_ADDRESS", vm.envOr("CUSDC_ADDRESS_138", address(0))); + address cUsdt = vm.envOr("COMPLIANT_USDT_ADDRESS", vm.envOr("CUSDT_ADDRESS_138", address(0))); + address weth = vm.envOr("WETH9", address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2)); + address xau = vm.envOr("XAU_ADDRESS_138", vm.envOr("cXAUC_ADDRESS_138", address(0x290E52a8819A4fbD0714E517225429aA2B70EC6b))); + + if (cUsdc != address(0)) { + stablecoinPeg.registerUSDStablecoin(cUsdc); + } + if (cUsdt != address(0)) { + stablecoinPeg.registerUSDStablecoin(cUsdt); + } + stablecoinPeg.registerWETH(weth); + + if (xau != address(0)) { + commodityPeg.setXAUAddress(xau); + commodityPeg.registerCommodity(xau, "XAU", 1e18); + } + + Stabilizer stabilizer = new Stabilizer(admin, privatePoolRegistry); + stabilizer.setStablecoinPegSource(address(stablecoinPeg), cUsdc != address(0) ? cUsdc : cUsdt); + if (xau != address(0)) { + stabilizer.setCommodityPegSource(address(commodityPeg), xau); + } + + vm.stopBroadcast(); + + console2.log("StablecoinPegManager", address(stablecoinPeg)); + console2.log("CommodityPegManager", address(commodityPeg)); + console2.log("Stabilizer", address(stabilizer)); + console2.log("admin", admin); + } +} diff --git a/script/deploy/rwa/DeployRWATokenFactory138.s.sol b/script/deploy/rwa/DeployRWATokenFactory138.s.sol new file mode 100644 index 0000000..a94905e --- /dev/null +++ b/script/deploy/rwa/DeployRWATokenFactory138.s.sol @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import {RWATokenRegistry} from "../../../contracts/rwa/RWATokenRegistry.sol"; +import {RWATokenFactory} from "../../../contracts/rwa/RWATokenFactory.sol"; +import {IRWATokenFactory} from "../../../contracts/rwa/IRWATokenFactory.sol"; +import {RWAToken} from "../../../contracts/rwa/RWAToken.sol"; +import {UniversalAssetRegistry} from "../../../contracts/registry/UniversalAssetRegistry.sol"; + +/** + * @title DeployRWATokenFactory138 + * @notice Deploy RWATokenRegistry + RWATokenFactory and optionally all five Li* M00 indices on Chain 138. + * @dev Taxonomy: config/rwa-capital-markets-taxonomy.v1.json. Not for c* eMoney (use DeployCompliantFiatTokens). + * + * Env: + * PRIVATE_KEY, OWNER, ADMIN, COMPLIANCE_ADMIN, INDEX_PUBLISHER + * UNIVERSAL_ASSET_REGISTRY (optional; pass address for factory wiring only) + * REGISTER_IN_UAR — default 0; use RegisterRWAIndicesInUAR138.s.sol after REGISTRAR_ROLE grant + * DEPLOY_LI_INDICES=1 to deploy LiXAU, LiPMG, LiBMG1–3 in one run + * RWA_INITIAL_INDEX_VALUE (default 1e6 = 1.0 at 6 decimals) + * RWA_INITIAL_SUPPLY (default 0 — mint via policy after index committee attestation) + * RWA_METHODOLOGY_HASH (required for DEPLOY_LI_INDICES; default from m00-li-index-methodology-hash.v1.json via cast) + * MULTISIG_ADMIN (optional) — grant factory DEPLOYER + registry/UAR REGISTRAR to multisig + */ +contract DeployRWATokenFactory138 is Script { + uint8 constant DECIMALS = 6; + uint256 constant DEFAULT_INDEX_VALUE = 1_000_000; + + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + address owner = vm.envOr("OWNER", deployer); + address compliance = vm.envOr("COMPLIANCE_ADMIN", deployer); + address publisher = vm.envOr("INDEX_PUBLISHER", deployer); + address uar = vm.envOr("UNIVERSAL_ASSET_REGISTRY", address(0)); + // UAR registration reverts if factory lacks REGISTRAR_ROLE — keep off during one-shot deploy. + bool registerInUar = vm.envOr("REGISTER_IN_UAR", uint256(0)) != 0; + require( + !registerInUar || uar != address(0), + "REGISTER_IN_UAR requires UNIVERSAL_ASSET_REGISTRY" + ); + uint256 initialIndexValue = vm.envOr("RWA_INITIAL_INDEX_VALUE", DEFAULT_INDEX_VALUE); + uint256 initialSupply = vm.envOr("RWA_INITIAL_SUPPLY", uint256(0)); + address multisig = vm.envOr("MULTISIG_ADMIN", address(0)); + address governance = vm.envOr("GOVERNANCE_CONTROLLER", address(0)); + + vm.startBroadcast(pk); + + // Deployer holds DEFAULT_ADMIN during bootstrap so grants succeed in one broadcast. + RWATokenRegistry registry = new RWATokenRegistry(deployer); + RWATokenFactory factory = new RWATokenFactory(deployer, address(registry), uar); + registry.grantRole(registry.REGISTRAR_ROLE(), address(factory)); + + if (registerInUar && uar != address(0)) { + UniversalAssetRegistry(uar).grantRole( + UniversalAssetRegistry(uar).REGISTRAR_ROLE(), + address(factory) + ); + } + + if (multisig != address(0)) { + factory.grantRole(factory.DEPLOYER_ROLE(), multisig); + registry.grantRole(registry.REGISTRAR_ROLE(), multisig); + } + if (governance != address(0)) { + factory.grantRole(factory.DEFAULT_ADMIN_ROLE(), governance); + registry.grantRole(registry.DEFAULT_ADMIN_ROLE(), governance); + } + + console.log("RWATokenRegistry", address(registry)); + console.log("RWATokenFactory", address(factory)); + + if (vm.envOr("DEPLOY_LI_INDICES", uint256(0)) != 0) { + bytes32 methodologyHash = _resolveMethodologyHash(); + _deployAllLi( + factory, + owner, + compliance, + publisher, + initialIndexValue, + initialSupply, + registerInUar, + methodologyHash + ); + } + + vm.stopBroadcast(); + } + + function _resolveMethodologyHash() internal view returns (bytes32) { + string memory envHash = vm.envOr("RWA_METHODOLOGY_HASH", string("")); + if (bytes(envHash).length > 0) { + return vm.parseBytes32(envHash); + } + revert("RWA_METHODOLOGY_HASH required when DEPLOY_LI_INDICES=1"); + } + + function _deployAllLi( + RWATokenFactory factory, + address owner, + address compliance, + address publisher, + uint256 initialIndexValue, + uint256 initialSupply, + bool registerInUar, + bytes32 methodologyHash + ) internal { + _deployLi( + factory, + owner, + compliance, + publisher, + "LiXAU", + "XAU Liquidity Index (M00)", + "LiXAU", + "Precious Metals", + "Commodity Index", + "Gold", + initialIndexValue, + initialSupply, + registerInUar, + methodologyHash + ); + _deployLi( + factory, + owner, + compliance, + publisher, + "LiPMG", + "Precious Metals Group Index (M00)", + "LiPMG", + "Precious Metals", + "Basket Index", + "Precious Metals", + initialIndexValue, + initialSupply, + registerInUar, + methodologyHash + ); + _deployLi( + factory, + owner, + compliance, + publisher, + "LiBMG1", + "Base Metals Group Index 1 (M00)", + "LiBMG1", + "Industrial Metals", + "Basket Index", + "Base Metals", + initialIndexValue, + initialSupply, + registerInUar, + methodologyHash + ); + _deployLi( + factory, + owner, + compliance, + publisher, + "LiBMG2", + "Base Metals Group Index 2 (M00)", + "LiBMG2", + "Industrial Metals", + "Basket Index", + "Battery Materials", + initialIndexValue, + initialSupply, + registerInUar, + methodologyHash + ); + _deployLi( + factory, + owner, + compliance, + publisher, + "LiBMG3", + "Base Metals Group Index 3 (M00)", + "LiBMG3", + "Industrial Metals", + "Basket Index", + "Building Metals", + initialIndexValue, + initialSupply, + registerInUar, + methodologyHash + ); + } + + function _deployLi( + RWATokenFactory factory, + address owner, + address compliance, + address publisher, + string memory ticker, + string memory name, + string memory symbol, + string memory assetGroup, + string memory instrumentType, + string memory underlying, + uint256 initialIndexValue, + uint256 initialSupply, + bool registerInUar, + bytes32 methodologyHash + ) internal { + address token = factory.deployRWAIndex( + IRWATokenFactory.RWAProductConfig({ + indexTicker: ticker, + name: name, + symbol: symbol, + decimals: DECIMALS, + assetClass: "Commodities", + assetGroup: assetGroup, + instrumentType: instrumentType, + underlyingAsset: underlying, + gruLayer: "M00", + jurisdiction: "International", + initialOwner: owner, + complianceAdmin: compliance, + indexPublisher: publisher, + initialIndexValue: initialIndexValue, + initialSupply: initialSupply, + methodologyDocumentHash: methodologyHash, + registerInUniversalAssetRegistry: registerInUar + }) + ); + console.log(ticker, token); + } +} diff --git a/script/deploy/rwa/RegisterRWAIndicesInUAR138.s.sol b/script/deploy/rwa/RegisterRWAIndicesInUAR138.s.sol new file mode 100644 index 0000000..e8ef435 --- /dev/null +++ b/script/deploy/rwa/RegisterRWAIndicesInUAR138.s.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import {RWATokenRegistry} from "../../../contracts/rwa/RWATokenRegistry.sol"; +import {UniversalAssetRegistry} from "../../../contracts/registry/UniversalAssetRegistry.sol"; +import {IRWAToken} from "../../../contracts/rwa/IRWAToken.sol"; + +/** + * @title RegisterRWAIndicesInUAR138 + * @notice Register deployed Li* tokens in UniversalAssetRegistry (run after factory deploy + REGISTRAR_ROLE grant). + * @dev Requires RWA_TOKEN_REGISTRY and UNIVERSAL_ASSET_REGISTRY. Broadcaster must hold UAR REGISTRAR_ROLE + * or factory must have been granted REGISTRAR_ROLE and this script calls via factory (not implemented — direct UAR calls). + * + * Env: PRIVATE_KEY, RWA_TOKEN_REGISTRY, UNIVERSAL_ASSET_REGISTRY + * Optional: LI_TICKERS=LiXAU,LiPMG,LiBMG1,LiBMG2,LiBMG3 (default all five) + */ +contract RegisterRWAIndicesInUAR138 is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address registryAddr = vm.envAddress("RWA_TOKEN_REGISTRY"); + address uarAddr = vm.envAddress("UNIVERSAL_ASSET_REGISTRY"); + + RWATokenRegistry registry = RWATokenRegistry(registryAddr); + UniversalAssetRegistry uar = UniversalAssetRegistry(uarAddr); + + vm.startBroadcast(pk); + + string memory tickersEnv = vm.envOr("LI_TICKERS", string("")); + if (bytes(tickersEnv).length > 0) { + string[] memory parts = vm.split(tickersEnv, ","); + for (uint256 i = 0; i < parts.length; i++) { + _registerOne(registry, uar, parts[i]); + } + } else { + _registerOne(registry, uar, "LiXAU"); + _registerOne(registry, uar, "LiPMG"); + _registerOne(registry, uar, "LiBMG1"); + _registerOne(registry, uar, "LiBMG2"); + _registerOne(registry, uar, "LiBMG3"); + } + + vm.stopBroadcast(); + } + + function _registerOne(RWATokenRegistry registry, UniversalAssetRegistry uar, string memory ticker) internal { + address token = registry.getToken(ticker); + require(token != address(0), "token missing"); + IRWAToken rwa = IRWAToken(token); + uar.registerRWAIndexAsset( + token, + _nameFor(ticker), + ticker, + 6, + "International" + ); + console.log("UAR registered", ticker, token); + } + + function _nameFor(string memory ticker) internal pure returns (string memory) { + if (keccak256(bytes(ticker)) == keccak256("LiXAU")) return "XAU Liquidity Index (M00)"; + if (keccak256(bytes(ticker)) == keccak256("LiPMG")) return "Precious Metals Group Index (M00)"; + if (keccak256(bytes(ticker)) == keccak256("LiBMG1")) return "Base Metals Group Index 1 (M00)"; + if (keccak256(bytes(ticker)) == keccak256("LiBMG2")) return "Base Metals Group Index 2 (M00)"; + if (keccak256(bytes(ticker)) == keccak256("LiBMG3")) return "Base Metals Group Index 3 (M00)"; + return ticker; + } +} diff --git a/script/deploy/rwa/WireRWATokenFactoryWeb3Controls138.s.sol b/script/deploy/rwa/WireRWATokenFactoryWeb3Controls138.s.sol new file mode 100644 index 0000000..1d9447c --- /dev/null +++ b/script/deploy/rwa/WireRWATokenFactoryWeb3Controls138.s.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import {RWATokenFactory} from "../../../contracts/rwa/RWATokenFactory.sol"; +import {RWATokenRegistry} from "../../../contracts/rwa/RWATokenRegistry.sol"; +import {PolicyProfileRegistry} from "../../../contracts/universal-resource/PolicyProfileRegistry.sol"; +import {OMNLJurisdictionPolicyRegistry} from "../../../contracts/hybx-omnl/OMNLJurisdictionPolicyRegistry.sol"; + +/** + * @title WireRWATokenFactoryWeb3Controls138 + * @notice Post-deploy: C-W3-01 jurisdiction policy, C-W3-08 profile hash, C-W3-04 multisig roles, C-W3-07 governance admin. + * Env: RWA_TOKEN_FACTORY, RWA_TOKEN_REGISTRY, POLICY_PROFILE_REGISTRY, OMNL_JURISDICTION_REGISTRY, + * ID_JURISDICTION_POLICY_HASH, M00_PROFILE_CONTENT_HASH, OMNL_COMPLIANCE_MULTISIG, GOVERNANCE_CONTROLLER + */ +contract WireRWATokenFactoryWeb3Controls138 is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address factoryAddr = vm.envAddress("RWA_TOKEN_FACTORY"); + address multisig = vm.envOr("OMNL_COMPLIANCE_MULTISIG", vm.envOr("MULTISIG_ADMIN", address(0))); + address governance = vm.envOr("GOVERNANCE_CONTROLLER", address(0)); + + vm.startBroadcast(pk); + + RWATokenFactory factory = RWATokenFactory(factoryAddr); + + if (multisig != address(0)) { + if (!factory.hasRole(factory.DEPLOYER_ROLE(), multisig)) { + factory.grantRole(factory.DEPLOYER_ROLE(), multisig); + } + console.log("DEPLOYER_ROLE ->", multisig); + } + + if (governance != address(0)) { + if (!factory.hasRole(factory.DEFAULT_ADMIN_ROLE(), governance)) { + factory.grantRole(factory.DEFAULT_ADMIN_ROLE(), governance); + } + console.log("DEFAULT_ADMIN_ROLE ->", governance); + } + + address pprAddr = vm.envOr("POLICY_PROFILE_REGISTRY", address(0)); + if (pprAddr != address(0)) { + bytes32 profileHash = vm.envBytes32("M00_PROFILE_CONTENT_HASH"); + PolicyProfileRegistry(pprAddr).publishProfile( + "m00_commodity_index_v1", + profileHash, + 1, + block.timestamp + ); + console.log("Published m00_commodity_index_v1 on PolicyProfileRegistry"); + } + + address jurAddr = vm.envOr("OMNL_JURISDICTION_REGISTRY", address(0)); + if (jurAddr != address(0)) { + bytes32 idPolicyHash = vm.envBytes32("ID_JURISDICTION_POLICY_HASH"); + bytes32 jurisdictionId = keccak256("ID"); + OMNLJurisdictionPolicyRegistry(jurAddr).publishPolicy( + jurisdictionId, + idPolicyHash, + 3, + 2, + 2, + true + ); + console.log("Published ID jurisdiction policy"); + } + + vm.stopBroadcast(); + } +} diff --git a/script/deploy/vault/DeployAcVdcSdcVaults.s.sol b/script/deploy/vault/DeployAcVdcSdcVaults.s.sol index a8d29ac..759994c 100644 --- a/script/deploy/vault/DeployAcVdcSdcVaults.s.sol +++ b/script/deploy/vault/DeployAcVdcSdcVaults.s.sol @@ -18,54 +18,68 @@ import {VaultFactory} from "../../../contracts/vault/VaultFactory.sol"; * CUSDC_ADDRESS_138 - (optional) Compliant USDC; if set, creates vault → acUSDC + vdcUSDC. * CUSDT_ADDRESS_138 - (optional) Compliant USDT; if set, creates vault → acUSDT + vdcUSDT. * COMPLIANT_USDC_ADDRESS, COMPLIANT_USDT_ADDRESS - Fallback env names if CUSDC/CUSDT_138 unset. + * cEURC_ADDRESS_138, cEURT_ADDRESS_138, cGBPC_ADDRESS_138, cGBPT_ADDRESS_138, + * cAUDC_ADDRESS_138, cJPYC_ADDRESS_138, cCHFC_ADDRESS_138, cCADC_ADDRESS_138, + * cXAUC_ADDRESS_138, cXAUT_ADDRESS_138 - Optional additional base tokens (6 decimals). */ contract DeployAcVdcSdcVaults is Script { uint8 constant DECIMALS = 6; bool constant DEBT_TRANSFERABLE = true; + struct TokenSpec { + string envPrimary; + string envAlt; + string label; + } + + TokenSpec[] internal tokens; + + function _initTokens() internal { + tokens.push(TokenSpec("CUSDC_ADDRESS_138", "COMPLIANT_USDC_ADDRESS", "USDC")); + tokens.push(TokenSpec("CUSDT_ADDRESS_138", "COMPLIANT_USDT_ADDRESS", "USDT")); + tokens.push(TokenSpec("cEURC_ADDRESS_138", "", "EURC")); + tokens.push(TokenSpec("cEURT_ADDRESS_138", "", "EURT")); + tokens.push(TokenSpec("cGBPC_ADDRESS_138", "", "GBPC")); + tokens.push(TokenSpec("cGBPT_ADDRESS_138", "", "GBPT")); + tokens.push(TokenSpec("cAUDC_ADDRESS_138", "", "AUDC")); + tokens.push(TokenSpec("cJPYC_ADDRESS_138", "", "JPYC")); + tokens.push(TokenSpec("cCHFC_ADDRESS_138", "", "CHFC")); + tokens.push(TokenSpec("cCADC_ADDRESS_138", "", "CADC")); + tokens.push(TokenSpec("cXAUC_ADDRESS_138", "", "XAUC")); + tokens.push(TokenSpec("cXAUT_ADDRESS_138", "", "XAUT")); + } + function run() external { + _initTokens(); uint256 pk = vm.envUint("PRIVATE_KEY"); address deployer = vm.addr(pk); address owner = vm.envOr("OWNER", deployer); address entity = vm.envOr("ENTITY", deployer); address factoryAddr = vm.envAddress("VAULT_FACTORY_ADDRESS"); + if (factoryAddr == address(0)) { + factoryAddr = vm.envOr("VAULT_FACTORY", address(0)); + } require(factoryAddr != address(0), "VAULT_FACTORY_ADDRESS required"); VaultFactory factory = VaultFactory(factoryAddr); - address cUsdc = _getToken("CUSDC_ADDRESS_138", "COMPLIANT_USDC_ADDRESS"); - address cUsdt = _getToken("CUSDT_ADDRESS_138", "COMPLIANT_USDT_ADDRESS"); - vm.startBroadcast(pk); - if (cUsdc != address(0)) { + for (uint256 i = 0; i < tokens.length; i++) { + address base = _getToken(tokens[i].envPrimary, tokens[i].envAlt); + if (base == address(0)) continue; (address vault, address depositToken, address debtToken) = factory.createVaultWithDecimals( owner, entity, - cUsdc, - cUsdc, + base, + base, DECIMALS, DECIMALS, DEBT_TRANSFERABLE ); - console.log("USDC vault:", vault); - console.log("acUSDC (deposit):", depositToken); - console.log("vdcUSDC (debt):", debtToken); - } - - if (cUsdt != address(0)) { - (address vault, address depositToken, address debtToken) = factory.createVaultWithDecimals( - owner, - entity, - cUsdt, - cUsdt, - DECIMALS, - DECIMALS, - DEBT_TRANSFERABLE - ); - console.log("USDT vault:", vault); - console.log("acUSDT (deposit):", depositToken); - console.log("vdcUSDT (debt):", debtToken); + console.log(string.concat(tokens[i].label, " vault:"), vault); + console.log(string.concat("ac", tokens[i].label, " (deposit):"), depositToken); + console.log(string.concat("vdc", tokens[i].label, " (debt):"), debtToken); } vm.stopBroadcast(); diff --git a/script/deploy/vault/DeployAcVdcSdcVaults651940.s.sol b/script/deploy/vault/DeployAcVdcSdcVaults651940.s.sol new file mode 100644 index 0000000..7ee8079 --- /dev/null +++ b/script/deploy/vault/DeployAcVdcSdcVaults651940.s.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {VaultFactory} from "../../../contracts/vault/VaultFactory.sol"; + +/// @notice M1 vault basket for ALL Mainnet (651940): AUSDC, AUSDT, WALL. +contract DeployAcVdcSdcVaults651940 is Script { + uint8 constant DECIMALS = 6; + bool constant DEBT_TRANSFERABLE = true; + uint8 constant GRU_TIER_M1 = 2; + + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + address owner = vm.envOr("OWNER", deployer); + address entity = vm.envOr("ENTITY", vm.envOr("GRU_OMNL_ENTITY_ADDRESS", deployer)); + address factoryAddr = vm.envAddress("VAULT_FACTORY_ADDRESS"); + VaultFactory factory = VaultFactory(factoryAddr); + + bytes32 ibanHash = vm.envBytes32("GRU_IBAN_HASH"); + bytes32 policyProfileKey = vm.envOr("GRU_POLICY_PROFILE_KEY", bytes32(0)); + + address[] memory bases = new address[](3); + string[] memory labels = new string[](3); + bases[0] = vm.envAddress("CUSDC_ADDRESS_651940"); + labels[0] = "AUSDC"; + bases[1] = vm.envAddress("AUSDT_ADDRESS_651940"); + labels[1] = "AUSDT"; + bases[2] = vm.envAddress("WALL_ADDRESS_651940"); + labels[2] = "WALL"; + + uint256 startIdx = vm.envOr("GRU_VAULT_START_INDEX", uint256(0)); + uint256 endIdx = vm.envOr("GRU_VAULT_END_INDEX", bases.length); + + vm.startBroadcast(pk); + for (uint256 i = startIdx; i < endIdx && i < bases.length; i++) { + (address vault, address depositToken, address debtToken) = factory.createVaultWithDecimalsGRU( + owner, + entity, + bases[i], + bases[i], + DECIMALS, + DECIMALS, + DEBT_TRANSFERABLE, + GRU_TIER_M1, + ibanHash, + policyProfileKey + ); + console.log(string.concat(labels[i], " vault:"), vault); + console.log(string.concat("ac", labels[i], ":"), depositToken); + console.log(string.concat("vdc", labels[i], ":"), debtToken); + } + vm.stopBroadcast(); + } +} diff --git a/script/deploy/vault/DeployAcVdcSdcVaultsGRU.s.sol b/script/deploy/vault/DeployAcVdcSdcVaultsGRU.s.sol new file mode 100644 index 0000000..1335aed --- /dev/null +++ b/script/deploy/vault/DeployAcVdcSdcVaultsGRU.s.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {VaultFactory} from "../../../contracts/vault/VaultFactory.sol"; + +/** + * @title DeployAcVdcSdcVaultsGRU + * @notice Create M1 ac/vdc vault pairs with GRU tier + IBAN + policy profile anchors. + */ +contract DeployAcVdcSdcVaultsGRU is Script { + uint8 constant DECIMALS = 6; + bool constant DEBT_TRANSFERABLE = true; + uint8 constant GRU_TIER_M1 = 2; + + struct TokenSpec { + string envPrimary; + string envAlt; + string label; + } + + TokenSpec[] internal tokens; + + function _initTokens() internal { + tokens.push(TokenSpec("CUSDC_ADDRESS_138", "COMPLIANT_USDC_ADDRESS", "USDC")); + tokens.push(TokenSpec("CUSDT_ADDRESS_138", "COMPLIANT_USDT_ADDRESS", "USDT")); + tokens.push(TokenSpec("cEURC_ADDRESS_138", "", "EURC")); + tokens.push(TokenSpec("cEURT_ADDRESS_138", "", "EURT")); + tokens.push(TokenSpec("cGBPC_ADDRESS_138", "", "GBPC")); + tokens.push(TokenSpec("cGBPT_ADDRESS_138", "", "GBPT")); + tokens.push(TokenSpec("cAUDC_ADDRESS_138", "", "AUDC")); + tokens.push(TokenSpec("cJPYC_ADDRESS_138", "", "JPYC")); + tokens.push(TokenSpec("cCHFC_ADDRESS_138", "", "CHFC")); + tokens.push(TokenSpec("cCADC_ADDRESS_138", "", "CADC")); + tokens.push(TokenSpec("cXAUC_ADDRESS_138", "", "XAUC")); + tokens.push(TokenSpec("cXAUT_ADDRESS_138", "", "XAUT")); + } + + function run() external { + _initTokens(); + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + address owner = vm.envOr("OWNER", deployer); + address entity = vm.envOr("ENTITY", vm.envOr("GRU_OMNL_ENTITY_ADDRESS", deployer)); + + address factoryAddr = vm.envAddress("VAULT_FACTORY_ADDRESS"); + VaultFactory factory = VaultFactory(factoryAddr); + + bytes32 ibanHash = vm.envBytes32("GRU_IBAN_HASH"); + bytes32 policyProfileKey = vm.envOr("GRU_POLICY_PROFILE_KEY", bytes32(0)); + uint256 startIdx = vm.envOr("GRU_VAULT_START_INDEX", uint256(0)); + uint256 endIdx = vm.envOr("GRU_VAULT_END_INDEX", tokens.length); + + vm.startBroadcast(pk); + + for (uint256 i = startIdx; i < endIdx && i < tokens.length; i++) { + address base = _getToken(tokens[i].envPrimary, tokens[i].envAlt); + if (base == address(0)) continue; + (address vault, address depositToken, address debtToken) = factory.createVaultWithDecimalsGRU( + owner, + entity, + base, + base, + DECIMALS, + DECIMALS, + DEBT_TRANSFERABLE, + GRU_TIER_M1, + ibanHash, + policyProfileKey + ); + console.log(string.concat(tokens[i].label, " vault:"), vault); + console.log(string.concat("ac", tokens[i].label, ":"), depositToken); + console.log(string.concat("vdc", tokens[i].label, ":"), debtToken); + } + + vm.stopBroadcast(); + } + + function _getToken(string memory primary, string memory altKey) internal view returns (address) { + address a = vm.envOr(primary, address(0)); + if (a != address(0)) return a; + if (bytes(altKey).length == 0) return address(0); + return vm.envOr(altKey, address(0)); + } +} diff --git a/script/deploy/vault/DeployCREATE2FactoryIfNeeded.s.sol b/script/deploy/vault/DeployCREATE2FactoryIfNeeded.s.sol new file mode 100644 index 0000000..91ed326 --- /dev/null +++ b/script/deploy/vault/DeployCREATE2FactoryIfNeeded.s.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {CREATE2Factory} from "../../../contracts/utils/CREATE2Factory.sol"; + +/// @notice Deploy CREATE2Factory when missing on target chain (one-time CREATE). +contract DeployCREATE2FactoryIfNeeded is Script { + function run() external { + address existing = vm.envOr("CREATE2_FACTORY_ADDRESS", address(0)); + if (existing != address(0) && existing.code.length > 0) { + console2.log("CREATE2Factory already live:", existing); + return; + } + + vm.startBroadcast(vm.envUint("PRIVATE_KEY")); + CREATE2Factory factory = new CREATE2Factory(); + vm.stopBroadcast(); + console2.log("CREATE2Factory deployed:", address(factory)); + } +} diff --git a/script/deploy/vault/DeployGRUVaultProtocol.s.sol b/script/deploy/vault/DeployGRUVaultProtocol.s.sol new file mode 100644 index 0000000..e92d156 --- /dev/null +++ b/script/deploy/vault/DeployGRUVaultProtocol.s.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {GRUEntityIbanRegistry} from "../../../contracts/vault/GRUEntityIbanRegistry.sol"; +import {GRUVaultIndex} from "../../../contracts/vault/GRUVaultIndex.sol"; +import {VaultFactory} from "../../../contracts/vault/VaultFactory.sol"; + +/// @notice Deploy GRU protocol registries and wire an existing or new VaultFactory. +/// @dev Run after DeployVaultSystem or pass VAULT_FACTORY_ADDRESS to wire index on live factory. +contract DeployGRUVaultProtocol is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address admin = vm.envOr("GRU_VAULT_ADMIN", vm.addr(pk)); + + vm.startBroadcast(pk); + + GRUEntityIbanRegistry ibanRegistry = new GRUEntityIbanRegistry(admin); + GRUVaultIndex vaultIndex = new GRUVaultIndex(admin); + + address factoryAddr = vm.envOr("VAULT_FACTORY_ADDRESS", address(0)); + if (factoryAddr != address(0)) { + VaultFactory(factoryAddr).setGruVaultIndex(address(vaultIndex)); + vaultIndex.grantFactoryRole(factoryAddr); + console2.log("Wired VaultFactory", factoryAddr); + } + + vm.stopBroadcast(); + + console2.log("GRUEntityIbanRegistry", address(ibanRegistry)); + console2.log("GRUVaultIndex", address(vaultIndex)); + console2.log("admin", admin); + } +} diff --git a/script/deploy/vault/DeployGRUVaultRegistriesCreate2.s.sol b/script/deploy/vault/DeployGRUVaultRegistriesCreate2.s.sol new file mode 100644 index 0000000..dd3f407 --- /dev/null +++ b/script/deploy/vault/DeployGRUVaultRegistriesCreate2.s.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {CREATE2Factory} from "../../../contracts/utils/CREATE2Factory.sol"; +import {GRUEntityIbanRegistry} from "../../../contracts/vault/GRUEntityIbanRegistry.sol"; +import {GRUVaultIndex} from "../../../contracts/vault/GRUVaultIndex.sol"; +import {VaultFactory} from "../../../contracts/vault/VaultFactory.sol"; + +/// @notice Deterministic GRU registry deploy via CREATE2Factory (cross-chain parity). +contract DeployGRUVaultRegistriesCreate2 is Script { + uint256 constant SALT_IBAN = uint256(keccak256("GRUEntityIbanRegistry")); + uint256 constant SALT_INDEX = uint256(keccak256("GRUVaultIndex")); + + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address admin = vm.envOr("GRU_VAULT_ADMIN", vm.addr(pk)); + address factoryAddr = vm.envAddress("CREATE2_FACTORY_ADDRESS"); + + CREATE2Factory factory = CREATE2Factory(factoryAddr); + + bytes memory ibanInit = abi.encodePacked(type(GRUEntityIbanRegistry).creationCode, abi.encode(admin)); + bytes memory indexInit = abi.encodePacked(type(GRUVaultIndex).creationCode, abi.encode(admin)); + + address predictedIban = factory.computeAddress(ibanInit, SALT_IBAN); + address predictedIndex = factory.computeAddress(indexInit, SALT_INDEX); + + vm.startBroadcast(pk); + + address ibanReg = predictedIban.code.length > 0 + ? predictedIban + : factory.deploy(ibanInit, SALT_IBAN); + address vaultIndex = predictedIndex.code.length > 0 + ? predictedIndex + : factory.deploy(indexInit, SALT_INDEX); + + address factoryAddrVault = vm.envOr("VAULT_FACTORY_ADDRESS", address(0)); + if (factoryAddrVault != address(0) && factoryAddrVault.code.length > 0) { + VaultFactory(factoryAddrVault).setGruVaultIndex(vaultIndex); + GRUVaultIndex(vaultIndex).grantFactoryRole(factoryAddrVault); + console2.log("Wired VaultFactory", factoryAddrVault); + } + + vm.stopBroadcast(); + + console2.log("CREATE2Factory", factoryAddr); + console2.log("GRUEntityIbanRegistry", ibanReg); + console2.log("GRUVaultIndex", vaultIndex); + } +} diff --git a/script/deploy/vault/DeployNewGRUVaultIndex138.s.sol b/script/deploy/vault/DeployNewGRUVaultIndex138.s.sol new file mode 100644 index 0000000..431c4fc --- /dev/null +++ b/script/deploy/vault/DeployNewGRUVaultIndex138.s.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {GRUVaultIndex} from "../../../contracts/vault/GRUVaultIndex.sol"; + +/// @notice Deploy GRUVaultIndex v2 (patch + import support) on hub 138. +contract DeployNewGRUVaultIndex138 is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address admin = vm.addr(pk); + vm.startBroadcast(pk); + GRUVaultIndex idx = new GRUVaultIndex(admin); + vm.stopBroadcast(); + console2.log("GRUVaultIndex:", address(idx)); + } +} diff --git a/script/deploy/vault/DeployVaultFactory651940.s.sol b/script/deploy/vault/DeployVaultFactory651940.s.sol new file mode 100644 index 0000000..c114f18 --- /dev/null +++ b/script/deploy/vault/DeployVaultFactory651940.s.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {Vault} from "../../../contracts/vault/Vault.sol"; +import {VaultFactory} from "../../../contracts/vault/VaultFactory.sol"; +import {DepositToken} from "../../../contracts/vault/tokens/DepositToken.sol"; +import {DebtToken} from "../../../contracts/vault/tokens/DebtToken.sol"; +import {Ledger} from "../../../contracts/vault/Ledger.sol"; +import {RegulatedEntityRegistry} from "../../../contracts/vault/RegulatedEntityRegistry.sol"; + +/// @notice Deploy VaultFactory on 651940 after core system addresses are live. +contract DeployVaultFactory651940 is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address admin = vm.addr(pk); + + RegulatedEntityRegistry entityRegistry = + RegulatedEntityRegistry(vm.envAddress("REGULATED_ENTITY_REGISTRY")); + Ledger ledger = Ledger(vm.envAddress("LEDGER_ADDRESS")); + address collateralAdapter = vm.envAddress("COLLATERAL_ADAPTER_ADDRESS"); + address eMoneyJoinAddr = vm.envAddress("EMONEY_JOIN_ADDRESS"); + + vm.startBroadcast(pk); + + Vault vaultImpl = new Vault( + admin, admin, address(ledger), address(entityRegistry), collateralAdapter, eMoneyJoinAddr + ); + DepositToken depositTokenImpl = new DepositToken(); + DebtToken debtTokenImpl = new DebtToken(); + + VaultFactory vaultFactory = new VaultFactory( + admin, + address(vaultImpl), + address(depositTokenImpl), + address(debtTokenImpl), + address(ledger), + address(entityRegistry), + collateralAdapter, + eMoneyJoinAddr + ); + console.log("Vault Factory:", address(vaultFactory)); + + ledger.grantRole(ledger.VAULT_FACTORY_ROLE(), address(vaultFactory)); + entityRegistry.grantRole(entityRegistry.REGISTRAR_ROLE(), address(vaultFactory)); + + vm.stopBroadcast(); + console.log("=== [651940] VaultFactory wired ==="); + } +} diff --git a/script/deploy/vault/DeployVaultSystem.s.sol b/script/deploy/vault/DeployVaultSystem.s.sol index 554e4f6..7bdb638 100644 --- a/script/deploy/vault/DeployVaultSystem.s.sol +++ b/script/deploy/vault/DeployVaultSystem.s.sol @@ -126,8 +126,8 @@ contract DeployVaultSystem is Script { // Step 10: Grant roles and configure console.log("\n10. Configuring roles and parameters..."); - // Grant factory vault role - ledger.grantVaultRole(address(vaultFactory)); + // Grant factory permission to register vaults on ledger + ledger.grantRole(ledger.VAULT_FACTORY_ROLE(), address(vaultFactory)); // Set risk parameters (registers assets and sets params) ledger.setRiskParameters( @@ -146,7 +146,7 @@ contract DeployVaultSystem is Script { } // Grant factory role to create vaults - entityRegistry.grantRole(keccak256("ENTITY_REGISTRAR_ROLE"), address(vaultFactory)); + entityRegistry.grantRole(entityRegistry.REGISTRAR_ROLE(), address(vaultFactory)); vm.stopBroadcast(); diff --git a/script/deploy/vault/DeployVaultSystem651940Core.s.sol b/script/deploy/vault/DeployVaultSystem651940Core.s.sol new file mode 100644 index 0000000..db41fc7 --- /dev/null +++ b/script/deploy/vault/DeployVaultSystem651940Core.s.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {RegulatedEntityRegistry} from "../../../contracts/vault/RegulatedEntityRegistry.sol"; +import {XAUOracle} from "../../../contracts/vault/XAUOracle.sol"; +import {RateAccrual} from "../../../contracts/vault/RateAccrual.sol"; +import {Ledger} from "../../../contracts/vault/Ledger.sol"; +import {Liquidation} from "../../../contracts/vault/Liquidation.sol"; +import {CollateralAdapter} from "../../../contracts/vault/adapters/CollateralAdapter.sol"; +import {eMoneyJoin} from "../../../contracts/vault/adapters/eMoneyJoin.sol"; +import {Aggregator} from "../../../contracts/oracle/Aggregator.sol"; + +/// @notice Deploy vault system core on ALL Mainnet (651940) without VaultFactory (forge broadcast workaround). +contract DeployVaultSystem651940Core is Script { + uint256 public constant DEFAULT_LIQUIDATION_RATIO = 10000; + uint256 public constant DEFAULT_CREDIT_MULTIPLIER = 50000; + uint256 public constant DEFAULT_DEBT_CEILING = 1000000e18; + + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address admin = vm.addr(pk); + address treasury = vm.envOr("TREASURY_ADDRESS", admin); + address ethAddress = address(0); + + vm.startBroadcast(pk); + + console.log("=== [651940] Vault system core ==="); + RegulatedEntityRegistry entityRegistry = new RegulatedEntityRegistry(admin); + console.log("RegulatedEntityRegistry:", address(entityRegistry)); + + Aggregator ethPriceFeed = new Aggregator("ETH/XAU", admin, 3600, 50); + ethPriceFeed.addTransmitter(admin); + ethPriceFeed.updateAnswer(0.05e18); + console.log("ETH Price Feed:", address(ethPriceFeed)); + + Aggregator btcPriceFeed = new Aggregator("BTC/XAU", admin, 3600, 50); + btcPriceFeed.addTransmitter(admin); + btcPriceFeed.updateAnswer(0.5e18); + console.log("BTC Price Feed:", address(btcPriceFeed)); + + XAUOracle xauOracle = new XAUOracle(admin); + xauOracle.addPriceFeed(address(ethPriceFeed), 10000); + xauOracle.addPriceFeed(address(btcPriceFeed), 10000); + xauOracle.updatePrice(); + console.log("XAU Oracle:", address(xauOracle)); + + RateAccrual rateAccrual = new RateAccrual(admin); + rateAccrual.setInterestRate(address(0), 500); + console.log("Rate Accrual:", address(rateAccrual)); + + Ledger ledger = new Ledger(admin, address(xauOracle), address(rateAccrual)); + console.log("Ledger:", address(ledger)); + + Liquidation liquidation = new Liquidation(admin, address(ledger), address(xauOracle), treasury); + console.log("Liquidation:", address(liquidation)); + + CollateralAdapter collateralAdapter = new CollateralAdapter(admin, address(ledger)); + console.log("Collateral Adapter:", address(collateralAdapter)); + + eMoneyJoin eMoneyJoinContract = new eMoneyJoin(admin); + console.log("eMoney Join:", address(eMoneyJoinContract)); + + ledger.setRiskParameters( + ethAddress, DEFAULT_DEBT_CEILING, DEFAULT_LIQUIDATION_RATIO, DEFAULT_CREDIT_MULTIPLIER + ); + + vm.stopBroadcast(); + console.log("=== [651940] core complete (VaultFactory separate) ==="); + } +} diff --git a/script/deploy/vault/DeployVaultSystemFinish.s.sol b/script/deploy/vault/DeployVaultSystemFinish.s.sol new file mode 100644 index 0000000..311263e --- /dev/null +++ b/script/deploy/vault/DeployVaultSystemFinish.s.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {Ledger} from "../../../contracts/vault/Ledger.sol"; +import {RegulatedEntityRegistry} from "../../../contracts/vault/RegulatedEntityRegistry.sol"; +import {XAUOracle} from "../../../contracts/vault/XAUOracle.sol"; +import {RateAccrual} from "../../../contracts/vault/RateAccrual.sol"; +import {Liquidation} from "../../../contracts/vault/Liquidation.sol"; +import {CollateralAdapter} from "../../../contracts/vault/adapters/CollateralAdapter.sol"; +import {eMoneyJoin} from "../../../contracts/vault/adapters/eMoneyJoin.sol"; +import {Vault} from "../../../contracts/vault/Vault.sol"; +import {VaultFactory} from "../../../contracts/vault/VaultFactory.sol"; +import {DepositToken} from "../../../contracts/vault/tokens/DepositToken.sol"; +import {DebtToken} from "../../../contracts/vault/tokens/DebtToken.sol"; +import {Aggregator} from "../../../contracts/oracle/Aggregator.sol"; + +/** + * @title DeployVaultSystemFinish + * @notice Resume vault system deploy when DeployVaultSystem broadcast stalled mid-flight. + * @dev Requires env addresses for components already on-chain (see run-gru-vault-protocol-operator.sh). + */ +contract DeployVaultSystemFinish is Script { + uint256 public constant DEFAULT_LIQUIDATION_RATIO = 10000; + uint256 public constant DEFAULT_CREDIT_MULTIPLIER = 50000; + uint256 public constant DEFAULT_DEBT_CEILING = 1000000e18; + + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address admin = vm.addr(pk); + address treasury = vm.envOr("TREASURY_ADDRESS", admin); + address ethAddress = address(0); + + RegulatedEntityRegistry entityRegistry = + RegulatedEntityRegistry(vm.envAddress("REGULATED_ENTITY_REGISTRY")); + XAUOracle xauOracle = XAUOracle(vm.envAddress("XAU_ORACLE_ADDRESS")); + address ethPriceFeed = vm.envAddress("ETH_PRICE_FEED_ADDRESS"); + address btcPriceFeed = vm.envAddress("BTC_PRICE_FEED_ADDRESS"); + + vm.startBroadcast(pk); + + console.log("=== Finishing Vault System (from XAUOracle config) ==="); + + xauOracle.addPriceFeed(ethPriceFeed, 10000); + xauOracle.addPriceFeed(btcPriceFeed, 10000); + Aggregator(ethPriceFeed).updateAnswer(0.05e18); + Aggregator(btcPriceFeed).updateAnswer(0.5e18); + xauOracle.updatePrice(); + console.log("XAUOracle configured"); + + RateAccrual rateAccrual = new RateAccrual(admin); + rateAccrual.setInterestRate(address(0), 500); + console.log("RateAccrual:", address(rateAccrual)); + + Ledger ledger = new Ledger(admin, address(xauOracle), address(rateAccrual)); + console.log("Ledger:", address(ledger)); + + Liquidation liquidation = new Liquidation(admin, address(ledger), address(xauOracle), treasury); + console.log("Liquidation:", address(liquidation)); + + CollateralAdapter collateralAdapter = new CollateralAdapter(admin, address(ledger)); + console.log("Collateral Adapter:", address(collateralAdapter)); + + eMoneyJoin eMoneyJoinContract = new eMoneyJoin(admin); + console.log("eMoney Join:", address(eMoneyJoinContract)); + + Vault vaultImpl = new Vault( + admin, admin, address(ledger), address(entityRegistry), address(collateralAdapter), address(eMoneyJoinContract) + ); + DepositToken depositTokenImpl = new DepositToken(); + DebtToken debtTokenImpl = new DebtToken(); + + VaultFactory vaultFactory = new VaultFactory( + admin, + address(vaultImpl), + address(depositTokenImpl), + address(debtTokenImpl), + address(ledger), + address(entityRegistry), + address(collateralAdapter), + address(eMoneyJoinContract) + ); + console.log("Vault Factory:", address(vaultFactory)); + + ledger.grantRole(ledger.VAULT_FACTORY_ROLE(), address(vaultFactory)); + ledger.setRiskParameters(ethAddress, DEFAULT_DEBT_CEILING, DEFAULT_LIQUIDATION_RATIO, DEFAULT_CREDIT_MULTIPLIER); + + entityRegistry.grantRole(entityRegistry.REGISTRAR_ROLE(), address(vaultFactory)); + + vm.stopBroadcast(); + + console.log("\n=== Vault System Finish Complete ==="); + } +} diff --git a/script/deploy/vault/ImportGruVaultRecord138.s.sol b/script/deploy/vault/ImportGruVaultRecord138.s.sol new file mode 100644 index 0000000..32c6c95 --- /dev/null +++ b/script/deploy/vault/ImportGruVaultRecord138.s.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {GRUVaultIndex} from "../../../contracts/vault/GRUVaultIndex.sol"; + +interface IGRUVaultIndexLegacy { + function allVaults(uint256 index) external view returns (address); + function vaults(address vault) + external + view + returns ( + address entity, + address baseToken, + address depositToken, + address debtToken, + uint8 gruTier, + bytes32 ibanHash, + bytes32 policyProfileKey, + uint256 recordedAt, + bool active + ); +} + +/// @notice Import one legacy vault record into new GRUVaultIndex (use GRU_MIGRATE_INDEX). +contract ImportGruVaultRecord138 is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address oldIndex = vm.envAddress("OLD_GRU_VAULT_INDEX"); + address newIndex = vm.envAddress("NEW_GRU_VAULT_INDEX"); + bytes32 policyKey = vm.envBytes32("GRU_POLICY_PROFILE_KEY"); + uint256 idx = vm.envUint("GRU_MIGRATE_INDEX"); + + IGRUVaultIndexLegacy legacy = IGRUVaultIndexLegacy(oldIndex); + GRUVaultIndex target = GRUVaultIndex(newIndex); + + address vault; + try legacy.allVaults(idx) returns (address v) { + vault = v; + } catch { + revert("ImportGruVaultRecord138: index out of range"); + } + + ( + address entity, + address baseToken, + address depositToken, + address debtToken, + uint8 gruTier, + bytes32 ibanHash, + bytes32 existingKey, + uint256 recordedAt, + bool active + ) = legacy.vaults(vault); + require(active, "ImportGruVaultRecord138: inactive"); + + bytes32 key = existingKey == bytes32(0) ? policyKey : existingKey; + + vm.startBroadcast(pk); + target.importVault( + vault, entity, baseToken, depositToken, debtToken, gruTier, ibanHash, key, recordedAt + ); + vm.stopBroadcast(); + console2.log("Imported", vault, "policy", vm.toString(key)); + } +} diff --git a/script/deploy/vault/MigrateGRUVaultIndex138.s.sol b/script/deploy/vault/MigrateGRUVaultIndex138.s.sol new file mode 100644 index 0000000..589781d --- /dev/null +++ b/script/deploy/vault/MigrateGRUVaultIndex138.s.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {GRUVaultIndex} from "../../../contracts/vault/GRUVaultIndex.sol"; +import {VaultFactory} from "../../../contracts/vault/VaultFactory.sol"; + +interface IGRUVaultIndexLegacy { + function allVaults(uint256 index) external view returns (address); + function vaults(address vault) + external + view + returns ( + address entity, + address baseToken, + address depositToken, + address debtToken, + uint8 gruTier, + bytes32 ibanHash, + bytes32 policyProfileKey, + uint256 recordedAt, + bool active + ); +} + +/// @notice Deploy GRUVaultIndex v2 and migrate records from legacy index with policy profile backfill. +contract MigrateGRUVaultIndex138 is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address admin = vm.addr(pk); + address oldIndex = vm.envAddress("OLD_GRU_VAULT_INDEX"); + address factory = vm.envAddress("VAULT_FACTORY_ADDRESS"); + bytes32 policyKey = vm.envBytes32("GRU_POLICY_PROFILE_KEY"); + + vm.startBroadcast(pk); + + GRUVaultIndex newIndex = new GRUVaultIndex(admin); + console2.log("New GRUVaultIndex:", address(newIndex)); + + IGRUVaultIndexLegacy legacy = IGRUVaultIndexLegacy(oldIndex); + uint256 imported; + + for (uint256 i = 0; i < 32; i++) { + address vault; + try legacy.allVaults(i) returns (address v) { + vault = v; + } catch { + break; + } + if (vault == address(0)) break; + + ( + address entity, + address baseToken, + address depositToken, + address debtToken, + uint8 gruTier, + bytes32 ibanHash, + bytes32 existingKey, + uint256 recordedAt, + bool active + ) = legacy.vaults(vault); + + if (!active) continue; + + bytes32 key = existingKey == bytes32(0) ? policyKey : existingKey; + newIndex.importVault( + vault, entity, baseToken, depositToken, debtToken, gruTier, ibanHash, key, recordedAt + ); + imported++; + console2.log("Imported vault", vault); + } + + newIndex.grantFactoryRole(factory); + VaultFactory(factory).setGruVaultIndex(address(newIndex)); + + vm.stopBroadcast(); + + console2.log("Migrated vault count:", imported); + console2.log("Update GRU_VAULT_INDEX to", address(newIndex)); + } +} diff --git a/script/deploy/vault/RegisterGRUEntityAndIban.s.sol b/script/deploy/vault/RegisterGRUEntityAndIban.s.sol new file mode 100644 index 0000000..7f3dcb3 --- /dev/null +++ b/script/deploy/vault/RegisterGRUEntityAndIban.s.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {RegulatedEntityRegistry} from "../../../contracts/vault/RegulatedEntityRegistry.sol"; +import {GRUEntityIbanRegistry} from "../../../contracts/vault/GRUEntityIbanRegistry.sol"; + +/// @notice Register OMNL/DBIS entity + IBAN binding for GRU vault operations. +contract RegisterGRUEntityAndIban is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address entity = vm.envOr("GRU_OMNL_ENTITY_ADDRESS", vm.addr(pk)); + address entityRegistryAddr = vm.envAddress("REGULATED_ENTITY_REGISTRY"); + address ibanRegistryAddr = vm.envAddress("GRU_ENTITY_IBAN_REGISTRY"); + + bytes32 jurisdictionHash = vm.envOr("GRU_JURISDICTION_HASH", keccak256("OMNL")); + bytes32 ibanHash = vm.envBytes32("GRU_IBAN_HASH"); + address directIban = vm.envOr("GRU_DIRECT_IBAN_ADDRESS", entity); + + RegulatedEntityRegistry entityRegistry = RegulatedEntityRegistry(entityRegistryAddr); + GRUEntityIbanRegistry ibanRegistry = GRUEntityIbanRegistry(ibanRegistryAddr); + + vm.startBroadcast(pk); + + if (!entityRegistry.isEligible(entity)) { + address[] memory wallets = new address[](1); + wallets[0] = entity; + entityRegistry.registerEntity(entity, jurisdictionHash, wallets); + console2.log("Registered entity", entity); + } else { + console2.log("Entity already registered", entity); + } + + (address existing,,, bool active) = ibanRegistry.getRecord(ibanHash); + if (!active) { + ibanRegistry.registerIban(ibanHash, entity, jurisdictionHash, directIban); + console2.log("Registered IBAN hash", vm.toString(ibanHash)); + } else { + console2.log("IBAN already active for entity", existing); + } + + vm.stopBroadcast(); + } +} diff --git a/script/deploy/vault/RegisterGruVaultLedgerAssets.s.sol b/script/deploy/vault/RegisterGruVaultLedgerAssets.s.sol new file mode 100644 index 0000000..a058781 --- /dev/null +++ b/script/deploy/vault/RegisterGruVaultLedgerAssets.s.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {Ledger} from "../../../contracts/vault/Ledger.sol"; + +/// @notice Register M1 c* tokens as ledger assets with default GRU vault risk parameters. +contract RegisterGruVaultLedgerAssets is Script { + uint256 constant DEFAULT_DEBT_CEILING = 1_000_000e18; + uint256 constant DEFAULT_LIQUIDATION_RATIO = 10_000; + uint256 constant DEFAULT_CREDIT_MULTIPLIER = 50_000; + + function run() external { + address ledgerAddr = vm.envAddress("LEDGER_ADDRESS"); + Ledger ledger = Ledger(ledgerAddr); + + vm.startBroadcast(vm.envUint("PRIVATE_KEY")); + + _registerIfSet(ledger, vm.envOr("CUSDC_ADDRESS_138", vm.envOr("COMPLIANT_USDC_ADDRESS", address(0)))); + _registerIfSet(ledger, vm.envOr("CUSDT_ADDRESS_138", vm.envOr("COMPLIANT_USDT_ADDRESS", address(0)))); + _registerIfSet(ledger, vm.envOr("cEURC_ADDRESS_138", address(0))); + _registerIfSet(ledger, vm.envOr("cEURT_ADDRESS_138", address(0))); + _registerIfSet(ledger, vm.envOr("cGBPC_ADDRESS_138", address(0))); + _registerIfSet(ledger, vm.envOr("cGBPT_ADDRESS_138", address(0))); + _registerIfSet(ledger, vm.envOr("cAUDC_ADDRESS_138", address(0))); + _registerIfSet(ledger, vm.envOr("cJPYC_ADDRESS_138", address(0))); + _registerIfSet(ledger, vm.envOr("cCHFC_ADDRESS_138", address(0))); + _registerIfSet(ledger, vm.envOr("cCADC_ADDRESS_138", address(0))); + _registerIfSet(ledger, vm.envOr("cXAUC_ADDRESS_138", address(0))); + _registerIfSet(ledger, vm.envOr("cXAUT_ADDRESS_138", address(0))); + + _registerIfSet(ledger, vm.envOr("CUSDC_ADDRESS_651940", vm.envOr("AUSDC_ADDRESS_651940", address(0)))); + _registerIfSet(ledger, vm.envOr("AUSDT_ADDRESS_651940", vm.envOr("CUSDT_ADDRESS_651940", address(0)))); + _registerIfSet(ledger, vm.envOr("WALL_ADDRESS_651940", address(0))); + + vm.stopBroadcast(); + } + + function _registerIfSet(Ledger ledger, address asset) internal { + if (asset == address(0)) return; + ledger.setRiskParameters(asset, DEFAULT_DEBT_CEILING, DEFAULT_LIQUIDATION_RATIO, DEFAULT_CREDIT_MULTIPLIER); + console2.log("Registered ledger asset", asset); + } +} diff --git a/script/deploy/vault/WireGRUVaultFactoryToIndex.s.sol b/script/deploy/vault/WireGRUVaultFactoryToIndex.s.sol new file mode 100644 index 0000000..2b91bf3 --- /dev/null +++ b/script/deploy/vault/WireGRUVaultFactoryToIndex.s.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {VaultFactory} from "../../../contracts/vault/VaultFactory.sol"; +import {GRUVaultIndex} from "../../../contracts/vault/GRUVaultIndex.sol"; + +/// @notice Wire an existing VaultFactory to an existing GRUVaultIndex (post-deploy on any chain). +contract WireGRUVaultFactoryToIndex is Script { + function run() external { + address factoryAddr = vm.envAddress("VAULT_FACTORY_ADDRESS"); + address indexAddr = vm.envAddress("GRU_VAULT_INDEX"); + + vm.startBroadcast(vm.envUint("PRIVATE_KEY")); + VaultFactory(factoryAddr).setGruVaultIndex(indexAddr); + GRUVaultIndex(indexAddr).grantFactoryRole(factoryAddr); + vm.stopBroadcast(); + + console2.log("Wired VaultFactory", factoryAddr); + console2.log("GRUVaultIndex", indexAddr); + } +} diff --git a/script/hybx-omnl/DeployOMNLComplianceCoreV2.s.sol b/script/hybx-omnl/DeployOMNLComplianceCoreV2.s.sol new file mode 100644 index 0000000..288c76d --- /dev/null +++ b/script/hybx-omnl/DeployOMNLComplianceCoreV2.s.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {ComplianceCore} from "../../contracts/hybx-omnl/ComplianceCore.sol"; + +/// @notice Deploy ComplianceCore pointing at ReserveCommitmentStore v2 (immutable reserves pointer). +contract DeployOMNLComplianceCoreV2 is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address registry = vm.envAddress("OMNL_INSTRUMENT_REGISTRY_138"); + address reserves = vm.envAddress("OMNL_RESERVE_STORE_V2_138"); + address breakers = vm.envAddress("OMNL_CIRCUIT_BREAKER_138"); + + vm.startBroadcast(pk); + ComplianceCore coreV2 = new ComplianceCore(registry, reserves, breakers); + vm.stopBroadcast(); + + console2.log("ComplianceCoreV2", address(coreV2)); + console2.log("registry", registry); + console2.log("reservesV2", reserves); + console2.log("breakers", breakers); + } +} diff --git a/script/hybx-omnl/DeployOMNLStack.s.sol b/script/hybx-omnl/DeployOMNLStack.s.sol index b24d9ae..13f1d46 100644 --- a/script/hybx-omnl/DeployOMNLStack.s.sol +++ b/script/hybx-omnl/DeployOMNLStack.s.sol @@ -6,8 +6,11 @@ import {InstrumentRegistry} from "../../contracts/hybx-omnl/InstrumentRegistry.s import {ReserveCommitmentStore} from "../../contracts/hybx-omnl/ReserveCommitmentStore.sol"; import {OMNLCircuitBreaker} from "../../contracts/hybx-omnl/OMNLCircuitBreaker.sol"; import {ComplianceCore} from "../../contracts/hybx-omnl/ComplianceCore.sol"; +import {OMNLJurisdictionPolicyRegistry} from "../../contracts/hybx-omnl/OMNLJurisdictionPolicyRegistry.sol"; +import {OMNLNotaryRegistry} from "../../contracts/hybx-omnl/OMNLNotaryRegistry.sol"; +import {OMNLComplianceMultisig} from "../../contracts/hybx-omnl/OMNLComplianceMultisig.sol"; -/// @notice Deploy core OMNL stack (registry, reserves, breakers, compliance). Mirror receiver is chain-specific. +/// @notice Deploy core OMNL stack + Web3 compliance (registry, reserves, breakers, compliance, notary, multisig). contract DeployOMNLStack is Script { function run() external { uint256 pk = vm.envUint("PRIVATE_KEY"); @@ -20,11 +23,22 @@ contract DeployOMNLStack is Script { OMNLCircuitBreaker breakers = new OMNLCircuitBreaker(admin); ComplianceCore core = new ComplianceCore(address(registry), address(reserves), address(breakers)); + OMNLJurisdictionPolicyRegistry jurisdiction = new OMNLJurisdictionPolicyRegistry(admin); + OMNLNotaryRegistry notary = new OMNLNotaryRegistry(admin, address(jurisdiction)); + OMNLComplianceMultisig multisig = new OMNLComplianceMultisig(admin, address(notary)); + + bytes32 jurId = keccak256("ID"); + bytes32 matrixId = keccak256("ID-OMNL-001"); + reserves.configureNotaryGate(address(notary), false, jurId, matrixId); + vm.stopBroadcast(); console2.log("InstrumentRegistry", address(registry)); console2.log("ReserveCommitmentStore", address(reserves)); console2.log("OMNLCircuitBreaker", address(breakers)); console2.log("ComplianceCore", address(core)); + console2.log("OMNLJurisdictionPolicyRegistry", address(jurisdiction)); + console2.log("OMNLNotaryRegistry", address(notary)); + console2.log("OMNLComplianceMultisig", address(multisig)); } } diff --git a/script/hybx-omnl/DeployOMNLWeb3Compliance.s.sol b/script/hybx-omnl/DeployOMNLWeb3Compliance.s.sol new file mode 100644 index 0000000..f31b729 --- /dev/null +++ b/script/hybx-omnl/DeployOMNLWeb3Compliance.s.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {OMNLJurisdictionPolicyRegistry} from "../../contracts/hybx-omnl/OMNLJurisdictionPolicyRegistry.sol"; +import {OMNLNotaryRegistry} from "../../contracts/hybx-omnl/OMNLNotaryRegistry.sol"; +import {OMNLComplianceMultisig} from "../../contracts/hybx-omnl/OMNLComplianceMultisig.sol"; + +/// @notice Deploy Web3 compliance layer: jurisdiction policy registry, notary, multisig. +contract DeployOMNLWeb3Compliance is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address admin = vm.envOr("OMNL_WEB3_ADMIN", vm.addr(pk)); + + vm.startBroadcast(pk); + + OMNLJurisdictionPolicyRegistry jurisdiction = new OMNLJurisdictionPolicyRegistry(admin); + OMNLNotaryRegistry notary = new OMNLNotaryRegistry(admin, address(jurisdiction)); + OMNLComplianceMultisig multisig = new OMNLComplianceMultisig(admin, address(notary)); + + vm.stopBroadcast(); + + console2.log("OMNLJurisdictionPolicyRegistry", address(jurisdiction)); + console2.log("OMNLNotaryRegistry", address(notary)); + console2.log("OMNLComplianceMultisig", address(multisig)); + } +} diff --git a/script/hybx-omnl/MigrateOMNLReserveStoreV2.s.sol b/script/hybx-omnl/MigrateOMNLReserveStoreV2.s.sol new file mode 100644 index 0000000..6d182e8 --- /dev/null +++ b/script/hybx-omnl/MigrateOMNLReserveStoreV2.s.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {ReserveCommitmentStore} from "../../contracts/hybx-omnl/ReserveCommitmentStore.sol"; + +/// @notice Deploy ReserveCommitmentStore v2 (notary gate ABI), migrate commitment from legacy store. +contract MigrateOMNLReserveStoreV2 is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address admin = vm.envOr("OMNL_WEB3_ADMIN", vm.addr(pk)); + address legacyStore = vm.envAddress("OMNL_RESERVE_STORE_138"); + address notary = vm.envAddress("OMNL_NOTARY_REGISTRY"); + bytes32 lineId = vm.envBytes32("OMNL_HEALTH_LINE_ID"); + + bytes32 jurId = keccak256(bytes(vm.envOr("OMNL_JURISDICTION_ID", string("ID")))); + bytes32 matrixId = keccak256(bytes(vm.envOr("OMNL_MATRIX_CONTROL_ID", string("ID-OMNL-001")))); + bool requireNotary = vm.envOr("OMNL_REQUIRE_NOTARIZED_RESERVE", false); + uint256 attTh = vm.envOr("OMNL_RESERVE_ATTESTATION_THRESHOLD", uint256(3)); + + ReserveCommitmentStore legacy = ReserveCommitmentStore(legacyStore); + ReserveCommitmentStore.Commitment memory c = legacy.getCommitment(lineId); + address mirror = legacy.mirrorReceiver(); + + vm.startBroadcast(pk); + + ReserveCommitmentStore storeV2 = new ReserveCommitmentStore(admin); + storeV2.configureNotaryGate(notary, requireNotary, jurId, matrixId); + storeV2.setAttestationThreshold(attTh); + if (mirror != address(0)) { + storeV2.setMirrorReceiver(mirror); + } + storeV2.commitReserve(lineId, c.R, c.validUntil, c.evidenceHash, c.merkleRoot); + + vm.stopBroadcast(); + + console2.log("ReserveCommitmentStoreV2", address(storeV2)); + console2.log("legacyStore", legacyStore); + console2.log("notaryGate", notary); + console2.log("migratedVersion", storeV2.getCommitment(lineId).version); + } +} diff --git a/script/hybx-omnl/PublishJurisdictionPolicies.s.sol b/script/hybx-omnl/PublishJurisdictionPolicies.s.sol new file mode 100644 index 0000000..5dde235 --- /dev/null +++ b/script/hybx-omnl/PublishJurisdictionPolicies.s.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {OMNLJurisdictionPolicyRegistry} from "../../contracts/hybx-omnl/OMNLJurisdictionPolicyRegistry.sol"; + +/// @notice Publish Indonesia pilot policy (extend via env for more jurisdictions). +contract PublishJurisdictionPolicies is Script { + function run() external { + address registry = vm.envAddress("OMNL_JURISDICTION_REGISTRY"); + bytes32 policyHash = vm.envBytes32("OMNL_JURISDICTION_POLICY_HASH"); + uint256 pk = vm.envUint("PRIVATE_KEY"); + + bytes32 jurId = keccak256(bytes(vm.envOr("OMNL_JURISDICTION_ID", string("ID")))); + uint8 notaryTh = uint8(vm.envOr("OMNL_NOTARY_THRESHOLD", uint256(3))); + uint8 reserveTh = uint8(vm.envOr("OMNL_RESERVE_ATTESTATION_THRESHOLD", uint256(3))); + uint8 adminTh = uint8(vm.envOr("OMNL_ADMIN_MULTISIG_THRESHOLD", uint256(3))); + bool prod = vm.envOr("OMNL_JURISDICTION_PRODUCTION_READY", true); + + vm.startBroadcast(pk); + OMNLJurisdictionPolicyRegistry(registry).publishPolicy( + jurId, policyHash, notaryTh, reserveTh, adminTh, prod + ); + vm.stopBroadcast(); + console2.log("Published policy for jurisdiction", vm.toString(jurId)); + } +} diff --git a/script/hybx-omnl/RefreshReserveAttestation.s.sol b/script/hybx-omnl/RefreshReserveAttestation.s.sol new file mode 100644 index 0000000..695345c --- /dev/null +++ b/script/hybx-omnl/RefreshReserveAttestation.s.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {ReserveCommitmentStore} from "../../contracts/hybx-omnl/ReserveCommitmentStore.sol"; + +/// @notice Refresh reserve TTL and optionally bump R / evidence hashes. +contract RefreshReserveAttestation is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address storeAddr = vm.envAddress("OMNL_RESERVE_STORE_138"); + bytes32 lineId = vm.envBytes32("OMNL_HEALTH_LINE_ID"); + + uint256 ttlDays = vm.envOr("OMNL_RESERVE_TTL_DAYS", uint256(90)); + uint256 validUntil = block.timestamp + ttlDays * 1 days; + + ReserveCommitmentStore store = ReserveCommitmentStore(storeAddr); + ReserveCommitmentStore.Commitment memory c = store.getCommitment(lineId); + + uint256 R = vm.envOr("OMNL_RESERVE_R", c.R); + bytes32 evidence = vm.envOr("OMNL_RESERVE_EVIDENCE_HASH", c.evidenceHash); + bytes32 merkle = vm.envOr("OMNL_RESERVE_MERKLE_ROOT", c.merkleRoot); + + vm.startBroadcast(pk); + store.commitReserve(lineId, R, validUntil, evidence, merkle); + vm.stopBroadcast(); + + console2.log("Reserve refreshed lineId", vm.toString(lineId)); + console2.log("validUntil", validUntil); + console2.log("R", R); + } +} diff --git a/script/hybx-omnl/RemediateOmnlM1Cap.s.sol b/script/hybx-omnl/RemediateOmnlM1Cap.s.sol new file mode 100644 index 0000000..e7b9419 --- /dev/null +++ b/script/hybx-omnl/RemediateOmnlM1Cap.s.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IMintableM0 { + function mint(address to, uint256 amount) external; + function mint(address to, uint256 amount, bytes32 reasonHash) external; +} + +/// @notice Mint M0 (cUSDT) so that S1 <= 5 * S0 for the health line (policy cap remediation). +contract RemediateOmnlM1Cap is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address tokenM0 = vm.envAddress("OMNL_TOKEN_M0"); + address tokenM1 = vm.envAddress("OMNL_TOKEN_M1"); + address mintTo = vm.envOr("OMNL_M1_REMEDIATION_MINT_TO", vm.addr(pk)); + + uint256 s0 = IERC20(tokenM0).totalSupply(); + uint256 s1 = IERC20(tokenM1).totalSupply(); + + uint256 requiredS0 = (s1 + 4) / 5; + if (s0 >= requiredS0) { + console2.log("No mint required; s0", s0, "requiredS0", requiredS0); + return; + } + uint256 delta = requiredS0 - s0; + bytes32 reason = keccak256("OMNL-M1-CAP-REMEDIATION"); + + vm.startBroadcast(pk); + IMintableM0 m0 = IMintableM0(tokenM0); + try m0.mint(mintTo, delta, reason) { + } catch { + m0.mint(mintTo, delta); + } + vm.stopBroadcast(); + + uint256 s0After = IERC20(tokenM0).totalSupply(); + uint256 s1After = IERC20(tokenM1).totalSupply(); + bool m1OkAfter = s1After <= s0After * 5; + console2.log("Minted delta", delta, "to", mintTo); + console2.log("s0After", s0After); + console2.log("s1After", s1After); + console2.log("m1OkAfter", m1OkAfter); + } +} diff --git a/script/hybx-omnl/WireOMNLWeb3Compliance.s.sol b/script/hybx-omnl/WireOMNLWeb3Compliance.s.sol new file mode 100644 index 0000000..a89691f --- /dev/null +++ b/script/hybx-omnl/WireOMNLWeb3Compliance.s.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {ReserveCommitmentStore} from "../../contracts/hybx-omnl/ReserveCommitmentStore.sol"; +import {OMNLNotaryRegistry} from "../../contracts/hybx-omnl/OMNLNotaryRegistry.sol"; +import {OMNLComplianceMultisig} from "../../contracts/hybx-omnl/OMNLComplianceMultisig.sol"; + +/// @notice Wire reserve store notary gate + align attestation threshold with jurisdiction policy. +contract WireOMNLWeb3Compliance is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address reserves = vm.envAddress("OMNL_RESERVE_COMMITMENT_STORE"); + address notary = vm.envAddress("OMNL_NOTARY_REGISTRY"); + OMNLComplianceMultisig multisig = OMNLComplianceMultisig(payable(vm.envAddress("OMNL_COMPLIANCE_MULTISIG"))); + + bytes32 jurId = keccak256(bytes(vm.envOr("OMNL_JURISDICTION_ID", string("ID")))); + bytes32 matrixId = keccak256(bytes(vm.envOr("OMNL_MATRIX_CONTROL_ID", string("ID-OMNL-001")))); + bool requireNotary = vm.envOr("OMNL_REQUIRE_NOTARIZED_RESERVE", true); + uint256 attTh = vm.envOr("OMNL_RESERVE_ATTESTATION_THRESHOLD", uint256(3)); + + vm.startBroadcast(pk); + ReserveCommitmentStore(reserves).configureNotaryGate(notary, requireNotary, jurId, matrixId); + ReserveCommitmentStore(reserves).setAttestationThreshold(attTh); + + address admin = vm.addr(pk); + OMNLNotaryRegistry(notary).grantRole(OMNLNotaryRegistry(notary).NOTARY_ADMIN_ROLE(), address(multisig)); + multisig.grantRole(multisig.PROPOSER_ROLE(), admin); + vm.stopBroadcast(); + + console2.log("Wired notary gate on ReserveCommitmentStore", reserves); + } +} diff --git a/script/m00-diamond/DeployM00DiamondHub138.s.sol b/script/m00-diamond/DeployM00DiamondHub138.s.sol new file mode 100644 index 0000000..4efc370 --- /dev/null +++ b/script/m00-diamond/DeployM00DiamondHub138.s.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import {GrcDiamond} from "@gru/GrcDiamond.sol"; +import {IDiamondCut} from "@gru/interfaces/IDiamondCut.sol"; +import {PauseFacet} from "@gru/facets/PauseFacet.sol"; +import {AccessFacet} from "@gru/facets/AccessFacet.sol"; +import {M00MainnetBridgeFacet} from "../../contracts/m00-diamond/facets/M00MainnetBridgeFacet.sol"; +import {M00DiamondInit} from "../../contracts/m00-diamond/M00DiamondInit.sol"; +import {RWAInstrumentFacet} from "../../contracts/rwa/diamond/facets/RWAInstrumentFacet.sol"; +import {RWADocumentFacet} from "../../contracts/rwa/diamond/facets/RWADocumentFacet.sol"; +import {RWAStandardsRegistryFacet} from "../../contracts/rwa/diamond/facets/RWAStandardsRegistryFacet.sol"; +import {IM00MainnetBridgeFacet} from "../../contracts/m00-diamond/interfaces/IM00MainnetBridgeFacet.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; + +/** + * @title DeployM00DiamondHub138 + * @notice Deploy GRC-2535 M00 Diamond hub on Chain 138 with RWA + mainnet mirror/tether/checkpoint bridge facet. + * @dev Paris profile mandatory. Wire env from project .env (load-project-env before forge). + * + * Env: PRIVATE_KEY, GOVERNANCE_CONTROLLER or OWNER + * CHAIN138_BATCH_EMITTER, TRANSACTION_MIRROR_ADDRESS (138 local), CHAIN138_MAINNET_CHECKPOINT_PROXY (mainnet) + * TRANSACTION_MIRROR_MAINNET, MAINNET_TETHER_ADDRESS + * RWA_TOKEN_FACTORY, RWA_TOKEN_REGISTRY + */ +contract DeployM00DiamondHub138 is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + address governance = vm.envOr("GOVERNANCE_CONTROLLER", vm.envOr("OWNER", deployer)); + + vm.startBroadcast(pk); + + // Diamond owner must be broadcaster for initial diamondCut (ErrUpgradeRole otherwise). + GrcDiamond diamond = new GrcDiamond(deployer, "M00.1.0"); + PauseFacet pause = new PauseFacet(); + AccessFacet access = new AccessFacet(); + M00MainnetBridgeFacet bridge = new M00MainnetBridgeFacet(governance); + RWAInstrumentFacet rwaInst = new RWAInstrumentFacet(governance); + RWADocumentFacet rwaDoc = new RWADocumentFacet(governance); + RWAStandardsRegistryFacet rwaStd = new RWAStandardsRegistryFacet(governance); + M00DiamondInit init = new M00DiamondInit(); + + IDiamondCut.FacetCut[] memory cut = _buildCuts( + address(pause), address(access), address(bridge), address(rwaInst), address(rwaDoc), address(rwaStd) + ); + + IM00MainnetBridgeFacet.BridgeConfig memory cfg = IM00MainnetBridgeFacet.BridgeConfig({ + chain138BatchEmitter: vm.envOr("CHAIN138_BATCH_EMITTER", address(0)), + chain138Mirror: vm.envOr("TRANSACTION_MIRROR_ADDRESS", address(0)), + mainnetCheckpoint: vm.envOr("CHAIN138_MAINNET_CHECKPOINT_PROXY", address(0)), + mainnetMirror: vm.envOr("TRANSACTION_MIRROR_MAINNET", address(0)), + mainnetTether: vm.envOr("MAINNET_TETHER_ADDRESS", address(0)), + rwaTokenFactory: vm.envOr("RWA_TOKEN_FACTORY", address(0)), + rwaTokenRegistry: vm.envOr("RWA_TOKEN_REGISTRY", address(0)) + }); + + diamond.diamondCut(cut, address(init), abi.encodeCall(M00DiamondInit.init, (cfg, governance))); + + console.log("M00Diamond", address(diamond)); + console.log("M00MainnetBridgeFacet", address(bridge)); + console.log("RWAInstrumentFacet", address(rwaInst)); + + vm.stopBroadcast(); + } + + function _buildCuts( + address pause, + address access, + address bridge, + address rwaInst, + address rwaDoc, + address rwaStd + ) internal pure returns (IDiamondCut.FacetCut[] memory cut) { + cut = new IDiamondCut.FacetCut[](6); + cut[0] = _add(pause, _pauseSelectors()); + cut[1] = _add(access, _accessSelectors()); + cut[2] = _add(bridge, _bridgeSelectors()); + cut[3] = _add(rwaInst, _rwaInstSelectors()); + cut[4] = _add(rwaDoc, _rwaDocSelectors()); + cut[5] = _add(rwaStd, _rwaStdSelectors()); + } + + function _add(address facet, bytes4[] memory sels) + internal + pure + returns (IDiamondCut.FacetCut memory) + { + return IDiamondCut.FacetCut({ + facetAddress: facet, + action: IDiamondCut.FacetCutAction.Add, + functionSelectors: sels + }); + } + + function _pauseSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](4); + s[0] = PauseFacet.setGlobalPause.selector; + s[1] = PauseFacet.setFunctionPause.selector; + s[2] = PauseFacet.isPaused.selector; + s[3] = PauseFacet.isGlobalPaused.selector; + } + + function _accessSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](6); + s[0] = AccessFacet.grantRoles.selector; + s[1] = AccessFacet.revokeRoles.selector; + s[2] = AccessFacet.ROLE_UPGRADE_BIT.selector; + s[3] = AccessFacet.ROLE_GOVERNANCE_BIT.selector; + s[4] = AccessFacet.ROLE_INDEX_BIT.selector; + s[5] = AccessFacet.ROLE_MONETARY_BIT.selector; + } + + function _bridgeSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](11); + s[0] = M00MainnetBridgeFacet.wireMainnetBridge.selector; + s[1] = M00MainnetBridgeFacet.getMainnetBridgeConfig.selector; + s[2] = M00MainnetBridgeFacet.commitBatchOn138.selector; + s[3] = M00MainnetBridgeFacet.sendBatchToMainnet.selector; + s[4] = M00MainnetBridgeFacet.scheduleMirrorToMainnet.selector; + s[5] = M00MainnetBridgeFacet.mirrorOnMainnet.selector; + s[6] = M00MainnetBridgeFacet.anchorStateProofOnMainnet.selector; + s[7] = M00MainnetBridgeFacet.ackInboundCheckpoint.selector; + s[8] = IAccessControl.grantRole.selector; + s[9] = IAccessControl.revokeRole.selector; + s[10] = IAccessControl.hasRole.selector; + } + + function _rwaInstSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](5); + s[0] = RWAInstrumentFacet.setIssuanceMode.selector; + s[1] = RWAInstrumentFacet.setInstrumentIdentity.selector; + s[2] = RWAInstrumentFacet.setTokenPointer.selector; + s[3] = RWAInstrumentFacet.getIssuanceMode.selector; + s[4] = RWAInstrumentFacet.getTokenPointer.selector; + } + + function _rwaDocSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](2); + s[0] = RWADocumentFacet.anchorDocument.selector; + s[1] = RWADocumentFacet.setPrimaryContentHash.selector; + } + + function _rwaStdSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](3); + s[0] = RWAStandardsRegistryFacet.enableStandard.selector; + s[1] = RWAStandardsRegistryFacet.disableStandard.selector; + s[2] = RWAStandardsRegistryFacet.bindAssetStandardFacet.selector; + } +} diff --git a/script/m00-diamond/UpgradeM00DiamondAcl138.s.sol b/script/m00-diamond/UpgradeM00DiamondAcl138.s.sol new file mode 100644 index 0000000..55853e7 --- /dev/null +++ b/script/m00-diamond/UpgradeM00DiamondAcl138.s.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import {IDiamondCut} from "@gru/interfaces/IDiamondCut.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; + +/** + * @notice Add OZ AccessControl admin selectors to existing M00MainnetBridgeFacet on live hub. + */ +contract UpgradeM00DiamondAcl138 is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address diamond = vm.envAddress("M00_DIAMOND_HUB"); + address bridgeFacet = vm.envAddress("M00_MAINNET_BRIDGE_FACET"); + + bytes4[] memory sels = new bytes4[](3); + sels[0] = IAccessControl.grantRole.selector; + sels[1] = IAccessControl.revokeRole.selector; + sels[2] = IAccessControl.hasRole.selector; + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](1); + cut[0] = IDiamondCut.FacetCut({ + facetAddress: bridgeFacet, + action: IDiamondCut.FacetCutAction.Add, + functionSelectors: sels + }); + + vm.startBroadcast(pk); + IDiamondCut(diamond).diamondCut(cut, address(0), ""); + vm.stopBroadcast(); + } +} diff --git a/script/mainnet-checkpoint/CheckpointConfigLib.s.sol b/script/mainnet-checkpoint/CheckpointConfigLib.s.sol new file mode 100644 index 0000000..52b768a --- /dev/null +++ b/script/mainnet-checkpoint/CheckpointConfigLib.s.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script} from "forge-std/Script.sol"; +import {CheckpointHubConfig} from "../../contracts/mainnet-checkpoint/libraries/CheckpointHubConfig.sol"; +import {BatchEmitterConfig} from "../../contracts/mainnet-checkpoint/libraries/BatchEmitterConfig.sol"; + +/// @notice Shared env → config parsing for deploy/configure scripts. +abstract contract CheckpointConfigScript is Script { + function hubConfigFromEnv() internal view returns (CheckpointHubConfig.HubConfig memory cfg) { + cfg = CheckpointHubConfig.mainnetDefaults(); + cfg.batchSize = uint16(vm.envOr("CHECKPOINT_BATCH_SIZE", uint256(cfg.batchSize))); + cfg.maxBatchWaitSeconds = uint32(vm.envOr("CHECKPOINT_MAX_WAIT_SECONDS", uint256(cfg.maxBatchWaitSeconds))); + cfg.minPaymentValueWei = vm.envOr("CHECKPOINT_MIN_PAYMENT_VALUE_WEI", uint256(0)); + cfg.requireValidatorSigs = vm.envOr("CHECKPOINT_REQUIRE_VALIDATOR_SIGS", cfg.requireValidatorSigs); + cfg.allowCalldataOnlySubmit = vm.envOr("CHECKPOINT_ALLOW_CALLDATA_ONLY", cfg.allowCalldataOnlySubmit); + cfg.allowCCIPIngress = vm.envOr("CHECKPOINT_ALLOW_CCIP_INGRESS", cfg.allowCCIPIngress); + cfg.enforcePreviousBatchId = vm.envOr("CHECKPOINT_ENFORCE_PREVIOUS_BATCH_ID", cfg.enforcePreviousBatchId); + cfg.ccipRouter = vm.envOr("CCIP_ROUTER_MAINNET", address(0)); + cfg.sourceChainSelector = uint64(vm.envOr("CCIP_CHAIN_SELECTOR_138", uint256(0))); + cfg.batchEmitterOnSource = vm.envOr("CHAIN138_BATCH_EMITTER", address(0)); + cfg.legacyMirrorV1 = vm.envOr("TRANSACTION_MIRROR_MAINNET", address(0)); + cfg.legacyTetherV1 = vm.envOr("MAINNET_TETHER_ADDRESS", address(0)); + cfg.submitterAttestationSigner = vm.envOr("CHECKPOINT_ATTESTATION_SIGNER", address(0)); + CheckpointHubConfig.validate(cfg); + } + + function emitterConfigFromEnv() internal view returns (BatchEmitterConfig.EmitterConfig memory cfg) { + cfg.ccipRouter = vm.envAddress("CCIP_ROUTER_CHAIN138"); + cfg.linkToken = vm.envAddress("LINK_TOKEN_CHAIN138"); + cfg.mainnetChainSelector = uint64(vm.envUint("CCIP_CHAIN_SELECTOR_MAINNET")); + cfg.mainnetCheckpoint = vm.envAddress("CHAIN138_MAINNET_CHECKPOINT_PROXY"); + BatchEmitterConfig.validate(cfg); + } +} diff --git a/script/mainnet-checkpoint/ConfigureCheckpointExtensions.s.sol b/script/mainnet-checkpoint/ConfigureCheckpointExtensions.s.sol new file mode 100644 index 0000000..65d8938 --- /dev/null +++ b/script/mainnet-checkpoint/ConfigureCheckpointExtensions.s.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {SubmitRateLimitExtension} from "../../contracts/mainnet-checkpoint/extensions/SubmitRateLimitExtension.sol"; +import {TimelockSubmitExtension} from "../../contracts/mainnet-checkpoint/extensions/TimelockSubmitExtension.sol"; +import {ValidatorSigVerifierExtension} from "../../contracts/mainnet-checkpoint/extensions/ValidatorSigVerifierExtension.sol"; +import {TokenTransferFilterExtension} from "../../contracts/mainnet-checkpoint/extensions/TokenTransferFilterExtension.sol"; +import {ZkStateRootVerifierExtension} from "../../contracts/mainnet-checkpoint/extensions/ZkStateRootVerifierExtension.sol"; +import {BlockHeaderOracleExtension} from "../../contracts/mainnet-checkpoint/extensions/BlockHeaderOracleExtension.sol"; +import {MinPaymentValueExtension} from "../../contracts/mainnet-checkpoint/extensions/MinPaymentValueExtension.sol"; + +/// @notice Per-extension granular tuning from env (post-deploy). +contract ConfigureCheckpointExtensions is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(pk); + + address rateLimit = vm.envOr("EXT_RATE_LIMIT", address(0)); + if (rateLimit != address(0)) { + uint256 maxPerHour = vm.envOr("CHECKPOINT_RATE_LIMIT_MAX_PER_HOUR", uint256(120)); + SubmitRateLimitExtension(rateLimit).setMaxBatchesPerHour(maxPerHour); + console.log("RateLimit max/hour", maxPerHour); + } + + address timelock = vm.envOr("EXT_TIMELOCK", address(0)); + if (timelock != address(0)) { + uint256 delay = vm.envOr("CHECKPOINT_TIMELOCK_DELAY_SECONDS", uint256(48 hours)); + TimelockSubmitExtension(timelock).setDelay(delay); + console.log("Timelock delay", delay); + } + + address validator = vm.envOr("EXT_VALIDATOR_SIG", address(0)); + if (validator != address(0)) { + address hub = vm.envAddress("CHAIN138_MAINNET_CHECKPOINT_PROXY"); + ValidatorSigVerifierExtension(validator).setVerifyingContract(hub); + uint256 threshold = vm.envOr("CHECKPOINT_VALIDATOR_THRESHOLD", uint256(1)); + address[] memory addrs = new address[](1); + addrs[0] = vm.envOr("CHECKPOINT_VALIDATOR_ADDRESS", vm.addr(pk)); + ValidatorSigVerifierExtension(validator).setValidators(addrs, threshold); + } + + address tokenFilter = vm.envOr("EXT_TOKEN_FILTER", address(0)); + if (tokenFilter != address(0)) { + TokenTransferFilterExtension filter = TokenTransferFilterExtension(tokenFilter); + bool allowNative = vm.envOr("CHECKPOINT_TOKEN_FILTER_ALLOW_NATIVE", true); + uint256 minNative = vm.envOr("CHECKPOINT_TOKEN_FILTER_MIN_NATIVE_WEI", uint256(0)); + filter.setAllowNative(allowNative, minNative); + _allowToken(filter, vm.envOr("CUSDT_CHAIN138", address(0))); + _allowToken(filter, vm.envOr("CUSDC_CHAIN138", address(0))); + } + + address zk = vm.envOr("EXT_ZK_STATE_ROOT", address(0)); + if (zk != address(0)) { + bool requireZk = vm.envOr("CHECKPOINT_REQUIRE_ZK_PROOF", false); + ZkStateRootVerifierExtension(zk).setRequireZkProof(requireZk); + } + + address blockOracle = vm.envOr("EXT_BLOCK_ORACLE", address(0)); + if (blockOracle != address(0)) { + BlockHeaderOracleExtension oracle = BlockHeaderOracleExtension(blockOracle); + oracle.setRequireOracleRecord(vm.envOr("CHECKPOINT_BLOCK_ORACLE_REQUIRED", true)); + address updater = vm.envOr("CHECKPOINT_ORACLE_UPDATER", vm.addr(pk)); + oracle.grantRole(keccak256("ORACLE_UPDATER_ROLE"), updater); + } + + address minPay = vm.envOr("EXT_MIN_PAYMENT", address(0)); + address hubProxy = vm.envOr("CHAIN138_MAINNET_CHECKPOINT_PROXY", address(0)); + if (minPay != address(0) && hubProxy != address(0)) { + MinPaymentValueExtension(minPay).setHub(hubProxy); + } + + vm.stopBroadcast(); + } + + function _allowToken(TokenTransferFilterExtension filter, address token) internal { + if (token == address(0)) return; + filter.setAllowedToken(token, true); + console.log("Allowed token", token); + } +} diff --git a/script/mainnet-checkpoint/ConfigureCheckpointHub.s.sol b/script/mainnet-checkpoint/ConfigureCheckpointHub.s.sol new file mode 100644 index 0000000..660f787 --- /dev/null +++ b/script/mainnet-checkpoint/ConfigureCheckpointHub.s.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; +import {CheckpointHubConfig} from "../../contracts/mainnet-checkpoint/libraries/CheckpointHubConfig.sol"; +import {CheckpointConfigScript} from "./CheckpointConfigLib.s.sol"; + +/// @notice Apply granular hub config from env (post-deploy). Run predeploy gate first. +contract ConfigureCheckpointHub is CheckpointConfigScript { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address proxy = vm.envAddress("CHAIN138_MAINNET_CHECKPOINT_PROXY"); + Chain138MainnetCheckpoint hub = Chain138MainnetCheckpoint(proxy); + CheckpointHubConfig.HubConfig memory cfg = hubConfigFromEnv(); + + vm.startBroadcast(pk); + hub.applyConfig(cfg); + vm.stopBroadcast(); + + CheckpointHubConfig.HubConfig memory onChain = hub.getFullConfig(); + console.log("batchSize", onChain.batchSize); + console.log("maxBatchWaitSeconds", onChain.maxBatchWaitSeconds); + console.log("allowCCIPIngress", onChain.allowCCIPIngress); + } +} diff --git a/script/mainnet-checkpoint/DeployAddressActivityRegistry.s.sol b/script/mainnet-checkpoint/DeployAddressActivityRegistry.s.sol new file mode 100644 index 0000000..f4b7202 --- /dev/null +++ b/script/mainnet-checkpoint/DeployAddressActivityRegistry.s.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {AddressActivityRegistry} from "../../contracts/mainnet-checkpoint/AddressActivityRegistry.sol"; + +contract DeployAddressActivityRegistry is Script { + function run() external { + address admin = vm.envOr("CHECKPOINT_ADMIN", msg.sender); + vm.startBroadcast(); + AddressActivityRegistry registry = new AddressActivityRegistry(admin); + console.log("AddressActivityRegistry:", address(registry)); + vm.stopBroadcast(); + } +} diff --git a/script/mainnet-checkpoint/DeployAddressActivityRegistryV2.s.sol b/script/mainnet-checkpoint/DeployAddressActivityRegistryV2.s.sol new file mode 100644 index 0000000..80d665f --- /dev/null +++ b/script/mainnet-checkpoint/DeployAddressActivityRegistryV2.s.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {AddressActivityRegistryV2} from "../../contracts/mainnet-checkpoint/AddressActivityRegistryV2.sol"; + +contract DeployAddressActivityRegistryV2 is Script { + function run() external { + address admin = vm.envOr("CHECKPOINT_ADMIN", msg.sender); + vm.startBroadcast(); + AddressActivityRegistryV2 registry = new AddressActivityRegistryV2(admin); + console.log("AddressActivityRegistryV2:", address(registry)); + vm.stopBroadcast(); + } +} diff --git a/script/mainnet-checkpoint/DeployAllCheckpointExtensions.s.sol b/script/mainnet-checkpoint/DeployAllCheckpointExtensions.s.sol new file mode 100644 index 0000000..153625a --- /dev/null +++ b/script/mainnet-checkpoint/DeployAllCheckpointExtensions.s.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {MirrorDetailExtension} from "../../contracts/mainnet-checkpoint/extensions/MirrorDetailExtension.sol"; +import {ValidatorSigVerifierExtension} from "../../contracts/mainnet-checkpoint/extensions/ValidatorSigVerifierExtension.sol"; +import {AttestationURIExtension} from "../../contracts/mainnet-checkpoint/extensions/AttestationURIExtension.sol"; +import {SubmitRateLimitExtension} from "../../contracts/mainnet-checkpoint/extensions/SubmitRateLimitExtension.sol"; +import {TokenTransferFilterExtension} from "../../contracts/mainnet-checkpoint/extensions/TokenTransferFilterExtension.sol"; +import {CwTransportLinkExtension} from "../../contracts/mainnet-checkpoint/extensions/CwTransportLinkExtension.sol"; +import {TimelockSubmitExtension} from "../../contracts/mainnet-checkpoint/extensions/TimelockSubmitExtension.sol"; +import {MetricsExtension} from "../../contracts/mainnet-checkpoint/extensions/MetricsExtension.sol"; +import {ZkStateRootVerifierExtension} from "../../contracts/mainnet-checkpoint/extensions/ZkStateRootVerifierExtension.sol"; +import {PaymasterHintExtension} from "../../contracts/mainnet-checkpoint/extensions/PaymasterHintExtension.sol"; +import {L2OracleAdapterExtension} from "../../contracts/mainnet-checkpoint/extensions/L2OracleAdapterExtension.sol"; +import {BlockHeaderOracleExtension} from "../../contracts/mainnet-checkpoint/extensions/BlockHeaderOracleExtension.sol"; +import {MinPaymentValueExtension} from "../../contracts/mainnet-checkpoint/extensions/MinPaymentValueExtension.sol"; +import {LegacyCheckpointAdapter} from "../../contracts/mainnet-checkpoint/LegacyCheckpointAdapter.sol"; + +/// @notice Deploy all optional extension modules + legacy read adapter (non-proxy). +contract DeployAllCheckpointExtensions is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address admin = vm.envOr("CHECKPOINT_ADMIN", vm.addr(pk)); + address checkpoint = vm.envAddress("CHAIN138_MAINNET_CHECKPOINT_PROXY"); + address mirror = vm.envOr("TRANSACTION_MIRROR_MAINNET", address(0)); + address tether = vm.envOr("MAINNET_TETHER_ADDRESS", address(0)); + + vm.startBroadcast(pk); + console.log("MirrorDetail:", address(new MirrorDetailExtension())); + console.log("ValidatorSig:", address(new ValidatorSigVerifierExtension())); + console.log("AttestationURI:", address(new AttestationURIExtension())); + console.log("RateLimit:", address(new SubmitRateLimitExtension())); + console.log("TokenFilter:", address(new TokenTransferFilterExtension())); + console.log("CwLink:", address(new CwTransportLinkExtension())); + console.log("Timelock:", address(new TimelockSubmitExtension())); + console.log("Metrics:", address(new MetricsExtension())); + console.log("ZkStateRoot:", address(new ZkStateRootVerifierExtension())); + console.log("PaymasterHint:", address(new PaymasterHintExtension())); + console.log("L2Oracle:", address(new L2OracleAdapterExtension())); + console.log("BlockOracle:", address(new BlockHeaderOracleExtension(admin))); + console.log("MinPayment:", address(new MinPaymentValueExtension())); + console.log("AddressActivityRegistry: deploy separately via scripts/deployment/deploy-address-activity-registry.sh"); + if (mirror != address(0) && tether != address(0)) { + console.log("LegacyAdapter:", address(new LegacyCheckpointAdapter(checkpoint, mirror, tether))); + } + vm.stopBroadcast(); + } +} diff --git a/script/mainnet-checkpoint/DeployChain138BatchEmitter.s.sol b/script/mainnet-checkpoint/DeployChain138BatchEmitter.s.sol new file mode 100644 index 0000000..a6d864b --- /dev/null +++ b/script/mainnet-checkpoint/DeployChain138BatchEmitter.s.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Chain138BatchEmitter} from "../../contracts/mainnet-checkpoint/chain138/Chain138BatchEmitter.sol"; + +contract DeployChain138BatchEmitter is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address admin = vm.envOr("CHECKPOINT_ADMIN", vm.addr(pk)); + address ccipRouter = vm.envAddress("CCIP_ROUTER_CHAIN138"); + address linkToken = vm.envAddress("LINK_TOKEN_CHAIN138"); + uint64 mainnetSelector = uint64(vm.envUint("CCIP_CHAIN_SELECTOR_MAINNET")); + address mainnetCheckpoint = vm.envAddress("CHAIN138_MAINNET_CHECKPOINT_PROXY"); + + vm.startBroadcast(pk); + Chain138BatchEmitter impl = new Chain138BatchEmitter(); + bytes memory initData = abi.encodeCall( + Chain138BatchEmitter.initialize, + (admin, ccipRouter, linkToken, mainnetSelector, mainnetCheckpoint) + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + console.log("Chain138BatchEmitter impl:", address(impl)); + console.log("Chain138BatchEmitter proxy:", address(proxy)); + vm.stopBroadcast(); + } +} diff --git a/script/mainnet-checkpoint/DeployChain138MainnetCheckpoint.s.sol b/script/mainnet-checkpoint/DeployChain138MainnetCheckpoint.s.sol new file mode 100644 index 0000000..aa53cd0 --- /dev/null +++ b/script/mainnet-checkpoint/DeployChain138MainnetCheckpoint.s.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; + +contract DeployChain138MainnetCheckpoint is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address admin = vm.envOr("CHECKPOINT_ADMIN", vm.addr(pk)); + address ccipRouter = vm.envAddress("CCIP_ROUTER_MAINNET"); + uint64 sourceSelector = uint64(vm.envUint("CCIP_CHAIN_SELECTOR_138")); + address batchEmitter = vm.envOr("CHAIN138_BATCH_EMITTER", address(0)); + + vm.startBroadcast(pk); + Chain138MainnetCheckpoint impl = new Chain138MainnetCheckpoint(); + bytes memory initData = abi.encodeCall( + Chain138MainnetCheckpoint.initialize, + (admin, ccipRouter, sourceSelector, batchEmitter) + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + console.log("Chain138MainnetCheckpoint impl:", address(impl)); + console.log("Chain138MainnetCheckpoint proxy:", address(proxy)); + console.log("Admin:", admin); + vm.stopBroadcast(); + } +} diff --git a/script/mainnet-checkpoint/DeployChain138ParticipantSurface.s.sol b/script/mainnet-checkpoint/DeployChain138ParticipantSurface.s.sol new file mode 100644 index 0000000..aec17db --- /dev/null +++ b/script/mainnet-checkpoint/DeployChain138ParticipantSurface.s.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {Chain138ParticipantSurface} from "../../contracts/mainnet-checkpoint/Chain138ParticipantSurface.sol"; + +contract DeployChain138ParticipantSurface is Script { + function run() external { + address admin = vm.envOr("CHECKPOINT_ADMIN", msg.sender); + vm.startBroadcast(); + Chain138ParticipantSurface surface = new Chain138ParticipantSurface(admin); + console.log("Chain138ParticipantSurface:", address(surface)); + vm.stopBroadcast(); + } +} diff --git a/script/mainnet-checkpoint/DeployISO20022IntakeGateway.s.sol b/script/mainnet-checkpoint/DeployISO20022IntakeGateway.s.sol new file mode 100644 index 0000000..69ec69a --- /dev/null +++ b/script/mainnet-checkpoint/DeployISO20022IntakeGateway.s.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {ISO20022IntakeGateway} from "../../contracts/mainnet-checkpoint/ISO20022IntakeGateway.sol"; + +contract DeployISO20022IntakeGateway is Script { + function run() external { + address admin = vm.envOr("CHECKPOINT_ADMIN", msg.sender); + vm.startBroadcast(); + ISO20022IntakeGateway gateway = new ISO20022IntakeGateway(admin); + console.log("ISO20022IntakeGateway:", address(gateway)); + vm.stopBroadcast(); + } +} diff --git a/script/mainnet-checkpoint/ReenableCheckpointExtensions.s.sol b/script/mainnet-checkpoint/ReenableCheckpointExtensions.s.sol new file mode 100644 index 0000000..a902b04 --- /dev/null +++ b/script/mainnet-checkpoint/ReenableCheckpointExtensions.s.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; +import {ExtensionIds} from "../../contracts/mainnet-checkpoint/libraries/ExtensionIds.sol"; + +/// @notice Re-enable extensions after hub v3 + token filter replace (env: EXT_VALIDATOR_SIG optional sanity). +contract ReenableCheckpointExtensions is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + Chain138MainnetCheckpoint hub = Chain138MainnetCheckpoint(vm.envAddress("CHAIN138_MAINNET_CHECKPOINT_PROXY")); + + vm.startBroadcast(pk); + hub.setExtensionActive(ExtensionIds.VALIDATOR_SIG, true); + console.log("VALIDATOR_SIG active"); + hub.setExtensionActive(ExtensionIds.TOKEN_FILTER, true); + console.log("TOKEN_FILTER active"); + vm.stopBroadcast(); + } +} diff --git a/script/mainnet-checkpoint/RegisterCheckpointExtensions.s.sol b/script/mainnet-checkpoint/RegisterCheckpointExtensions.s.sol new file mode 100644 index 0000000..fb13aa8 --- /dev/null +++ b/script/mainnet-checkpoint/RegisterCheckpointExtensions.s.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; +import {ExtensionIds} from "../../contracts/mainnet-checkpoint/libraries/ExtensionIds.sol"; +import {ICheckpointExtension} from "../../contracts/mainnet-checkpoint/interfaces/ICheckpointExtension.sol"; + +/// @notice Register deployed extensions on the checkpoint hub (env: EXT_* addresses). +contract RegisterCheckpointExtensions is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address proxy = vm.envAddress("CHAIN138_MAINNET_CHECKPOINT_PROXY"); + Chain138MainnetCheckpoint hub = Chain138MainnetCheckpoint(proxy); + + vm.startBroadcast(pk); + _register(hub, ExtensionIds.METRICS, vm.envAddress("EXT_METRICS")); + _register(hub, ExtensionIds.CONTENT_URI, vm.envAddress("EXT_ATTESTATION_URI")); + _register(hub, ExtensionIds.RATE_LIMIT, vm.envAddress("EXT_RATE_LIMIT")); + _register(hub, ExtensionIds.VALIDATOR_SIG, vm.envAddress("EXT_VALIDATOR_SIG")); + _register(hub, ExtensionIds.TOKEN_FILTER, vm.envAddress("EXT_TOKEN_FILTER")); + _register(hub, ExtensionIds.CW_LINK, vm.envAddress("EXT_CW_LINK")); + _register(hub, ExtensionIds.GOV_TIMELOCK, vm.envAddress("EXT_TIMELOCK")); + _register(hub, ExtensionIds.MIRROR_DETAIL, vm.envAddress("EXT_MIRROR_DETAIL")); + _register(hub, ExtensionIds.ZK_STATE_ROOT, vm.envAddress("EXT_ZK_STATE_ROOT")); + _register(hub, ExtensionIds.PAYMASTER_HINT, vm.envAddress("EXT_PAYMASTER_HINT")); + _register(hub, ExtensionIds.L2_ORACLE, vm.envAddress("EXT_L2_ORACLE")); + _register(hub, ExtensionIds.BLOCK_ORACLE, vm.envAddress("EXT_BLOCK_ORACLE")); + _register(hub, ExtensionIds.MIN_PAYMENT, vm.envAddress("EXT_MIN_PAYMENT")); + vm.stopBroadcast(); + } + + function _register(Chain138MainnetCheckpoint hub, bytes32 id, address module) internal { + if (module == address(0)) return; + uint32 hooks = ICheckpointExtension(module).HOOK_BEFORE_SUBMIT() + | ICheckpointExtension(module).HOOK_AFTER_SUBMIT() + | ICheckpointExtension(module).HOOK_ON_CCIP() + | ICheckpointExtension(module).HOOK_VERIFY_LEAF(); + hub.registerExtension(id, module, hooks); + console.log("Registered", vm.toString(id), module); + } +} diff --git a/script/mainnet-checkpoint/ReplaceMirrorDetailExtension.s.sol b/script/mainnet-checkpoint/ReplaceMirrorDetailExtension.s.sol new file mode 100644 index 0000000..591f19c --- /dev/null +++ b/script/mainnet-checkpoint/ReplaceMirrorDetailExtension.s.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; +import {MirrorDetailExtension} from "../../contracts/mainnet-checkpoint/extensions/MirrorDetailExtension.sol"; +import {ExtensionIds} from "../../contracts/mainnet-checkpoint/libraries/ExtensionIds.sol"; + +contract ReplaceMirrorDetailExtension is Script { + function run() external { + address proxy = vm.envAddress("CHAIN138_MAINNET_CHECKPOINT_PROXY"); + address oldMirror = vm.envAddress("EXT_MIRROR_DETAIL"); + uint256 pk = vm.envUint("PRIVATE_KEY"); + + vm.startBroadcast(pk); + MirrorDetailExtension mirror = new MirrorDetailExtension(); + console.log("New MirrorDetail:", address(mirror)); + + Chain138MainnetCheckpoint hub = Chain138MainnetCheckpoint(proxy); + hub.setExtensionActive(ExtensionIds.MIRROR_DETAIL, true); + hub.revokeExtension(ExtensionIds.MIRROR_DETAIL); + hub.registerExtension(ExtensionIds.MIRROR_DETAIL, address(mirror), mirror.HOOK_AFTER_SUBMIT()); + hub.setExtensionActive(ExtensionIds.MIRROR_DETAIL, true); + console.log("Replaced mirror (old was):", oldMirror); + vm.stopBroadcast(); + } +} diff --git a/script/mainnet-checkpoint/ReplaceTokenTransferFilterExtension.s.sol b/script/mainnet-checkpoint/ReplaceTokenTransferFilterExtension.s.sol new file mode 100644 index 0000000..3af71a4 --- /dev/null +++ b/script/mainnet-checkpoint/ReplaceTokenTransferFilterExtension.s.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; +import {ExtensionIds} from "../../contracts/mainnet-checkpoint/libraries/ExtensionIds.sol"; +import {TokenTransferFilterExtension} from "../../contracts/mainnet-checkpoint/extensions/TokenTransferFilterExtension.sol"; +import {ICheckpointExtension} from "../../contracts/mainnet-checkpoint/interfaces/ICheckpointExtension.sol"; + +/// @notice Deploy fixed TokenTransferFilterExtension and swap on hub (revoke + register). +contract ReplaceTokenTransferFilterExtension is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address hubAddr = vm.envAddress("CHAIN138_MAINNET_CHECKPOINT_PROXY"); + Chain138MainnetCheckpoint hub = Chain138MainnetCheckpoint(hubAddr); + + vm.startBroadcast(pk); + TokenTransferFilterExtension filter = new TokenTransferFilterExtension(); + console.log("New TokenTransferFilter:", address(filter)); + + bool allowNative = vm.envOr("CHECKPOINT_TOKEN_FILTER_ALLOW_NATIVE", true); + uint256 minNative = vm.envOr("CHECKPOINT_TOKEN_FILTER_MIN_NATIVE_WEI", uint256(0)); + filter.setAllowNative(allowNative, minNative); + + bytes32 id = ExtensionIds.TOKEN_FILTER; + (, , bool wasActive) = hub.getExtension(id); + if (!wasActive) { + hub.setExtensionActive(id, true); + } + hub.revokeExtension(id); + uint32 hooks = ICheckpointExtension(address(filter)).HOOK_BEFORE_SUBMIT() + | ICheckpointExtension(address(filter)).HOOK_AFTER_SUBMIT() + | ICheckpointExtension(address(filter)).HOOK_ON_CCIP() + | ICheckpointExtension(address(filter)).HOOK_VERIFY_LEAF(); + hub.registerExtension(id, address(filter), hooks); + hub.setExtensionActive(id, true); + console.log("TOKEN_FILTER registered and active"); + vm.stopBroadcast(); + } +} diff --git a/script/mainnet-checkpoint/SimulateSubmitFromCalldata.s.sol b/script/mainnet-checkpoint/SimulateSubmitFromCalldata.s.sol new file mode 100644 index 0000000..7b17760 --- /dev/null +++ b/script/mainnet-checkpoint/SimulateSubmitFromCalldata.s.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; + +/// @notice Replay aggregator calldata on a mainnet fork to surface revert reason. +contract SimulateSubmitFromCalldata is Script { + function run() external { + address hub = vm.envAddress("CHAIN138_MAINNET_CHECKPOINT_PROXY"); + bytes memory data = vm.envBytes("CHECKPOINT_SUBMIT_CALLDATA"); + vm.prank(vm.envAddress("CHECKPOINT_SUBMIT_FROM")); + (bool ok, bytes memory ret) = hub.call(data); + if (!ok) { + if (ret.length > 0) { + console.logBytes(ret); + } + revert("submit simulation failed"); + } + console.log("ok, latest", Chain138MainnetCheckpoint(hub).getLatestBatchId()); + } +} diff --git a/script/mainnet-checkpoint/UpgradeChain138MainnetCheckpointV3.s.sol b/script/mainnet-checkpoint/UpgradeChain138MainnetCheckpointV3.s.sol new file mode 100644 index 0000000..1542fbf --- /dev/null +++ b/script/mainnet-checkpoint/UpgradeChain138MainnetCheckpointV3.s.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; + +/// @notice UUPS upgrade: extension hook wiring (validator sigs vs leaf payload). IMPLEMENTATION_VERSION 3. +contract UpgradeChain138MainnetCheckpointV3 is Script { + function run() external { + address proxy = vm.envAddress("CHAIN138_MAINNET_CHECKPOINT_PROXY"); + uint256 pk = vm.envUint("PRIVATE_KEY"); + + vm.startBroadcast(pk); + Chain138MainnetCheckpoint newImpl = new Chain138MainnetCheckpoint(); + console.log("New impl:", address(newImpl)); + Chain138MainnetCheckpoint(proxy).upgradeToAndCall(address(newImpl), ""); + console.log("Upgraded proxy:", proxy); + require(Chain138MainnetCheckpoint(proxy).IMPLEMENTATION_VERSION() == 3, "version"); + vm.stopBroadcast(); + } +} diff --git a/script/mainnet-checkpoint/UpgradeChain138MainnetCheckpointV4.s.sol b/script/mainnet-checkpoint/UpgradeChain138MainnetCheckpointV4.s.sol new file mode 100644 index 0000000..1b418c0 --- /dev/null +++ b/script/mainnet-checkpoint/UpgradeChain138MainnetCheckpointV4.s.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; + +/// @notice UUPS upgrade: PaymentLeafV2 submit + V2 extension payloads. IMPLEMENTATION_VERSION 4. +contract UpgradeChain138MainnetCheckpointV4 is Script { + function run() external { + address proxy = vm.envAddress("CHAIN138_MAINNET_CHECKPOINT_PROXY"); + uint256 pk = vm.envUint("PRIVATE_KEY"); + + vm.startBroadcast(pk); + Chain138MainnetCheckpoint newImpl = new Chain138MainnetCheckpoint(); + console.log("New impl v4:", address(newImpl)); + Chain138MainnetCheckpoint(proxy).upgradeToAndCall(address(newImpl), ""); + require(Chain138MainnetCheckpoint(proxy).IMPLEMENTATION_VERSION() == 4, "version"); + vm.stopBroadcast(); + } +} diff --git a/script/ops/DeployCWMirrorMeshBatch.s.sol b/script/ops/DeployCWMirrorMeshBatch.s.sol new file mode 100644 index 0000000..f40f87a --- /dev/null +++ b/script/ops/DeployCWMirrorMeshBatch.s.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Script.sol"; +import {CWMirrorMeshBatch} from "../../contracts/ops/CWMirrorMeshBatch.sol"; + +contract DeployCWMirrorMeshBatch is Script { + function run() external returns (CWMirrorMeshBatch batch) { + address bridge = vm.envAddress("CW_L1_BRIDGE_CHAIN138"); + address link = vm.envAddress("LINK_TOKEN_CHAIN138"); + vm.startBroadcast(); + batch = new CWMirrorMeshBatch(bridge, link); + vm.stopBroadcast(); + } +} diff --git a/script/universal-resource/PublishCommercialEmoneyM1Profile.s.sol b/script/universal-resource/PublishCommercialEmoneyM1Profile.s.sol new file mode 100644 index 0000000..ab7181f --- /dev/null +++ b/script/universal-resource/PublishCommercialEmoneyM1Profile.s.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {PolicyProfileRegistry} from "../../../contracts/universal-resource/PolicyProfileRegistry.sol"; + +/// @notice Publish commercial_emoney_m1_v1 profile anchor on live PolicyProfileRegistry. +contract PublishCommercialEmoneyM1Profile is Script { + function run() external { + PolicyProfileRegistry reg = PolicyProfileRegistry(vm.envAddress("POLICY_PROFILE_REGISTRY_ADDRESS")); + bytes32 contentHash = vm.envBytes32("COMMERCIAL_EMONEY_M1_CONTENT_HASH"); + + vm.startBroadcast(vm.envUint("PRIVATE_KEY")); + reg.publishProfile("commercial_emoney_m1_v1", contentHash, 1, block.timestamp); + vm.stopBroadcast(); + + bytes32 key = reg.profileKey("commercial_emoney_m1_v1"); + console2.log("Published commercial_emoney_m1_v1"); + console2.logBytes32(key); + } +} diff --git a/scripts/deployment/create-uniswap-v3-gas-pool.sh b/scripts/deployment/create-uniswap-v3-gas-pool.sh index 909ef10..6328d26 100755 --- a/scripts/deployment/create-uniswap-v3-gas-pool.sh +++ b/scripts/deployment/create-uniswap-v3-gas-pool.sh @@ -4,19 +4,29 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +CALLER_FACTORY="${FACTORY:-}" +CALLER_RPC_URL="${RPC_URL:-}" +CALLER_TOKEN_A="${TOKEN_A:-}" +CALLER_TOKEN_B="${TOKEN_B:-}" +CALLER_FEE="${FEE:-}" +CALLER_EXECUTE="${EXECUTE:-}" +CALLER_EXPECTED_CHAIN_ID="${EXPECTED_CHAIN_ID:-}" +CALLER_SQRT_PRICE_X96="${SQRT_PRICE_X96:-}" + if [[ -f "$SCRIPT_DIR/../lib/deployment/dotenv.sh" ]]; then # shellcheck disable=SC1090 source "$SCRIPT_DIR/../lib/deployment/dotenv.sh" load_deployment_env --repo-root "$REPO_ROOT" fi -FACTORY="${FACTORY:-${UNISWAP_V3_FACTORY:-}}" -RPC_URL="${RPC_URL:-}" -TOKEN_A="${TOKEN_A:-}" -TOKEN_B="${TOKEN_B:-}" -FEE="${FEE:-500}" -EXECUTE="${EXECUTE:-0}" -SQRT_PRICE_X96="${SQRT_PRICE_X96:-79228162514264337593543950336}" +FACTORY="${CALLER_FACTORY:-${FACTORY:-${UNISWAP_V3_FACTORY:-}}}" +RPC_URL="${CALLER_RPC_URL:-${ETHEREUM_MAINNET_RPC:-${ETH_MAINNET_RPC_URL:-${RPC_URL:-}}}}" +TOKEN_A="${CALLER_TOKEN_A:-${TOKEN_A:-}}" +TOKEN_B="${CALLER_TOKEN_B:-${TOKEN_B:-}}" +FEE="${CALLER_FEE:-${FEE:-500}}" +EXECUTE="${CALLER_EXECUTE:-${EXECUTE:-0}}" +EXPECTED_CHAIN_ID="${CALLER_EXPECTED_CHAIN_ID:-${EXPECTED_CHAIN_ID:-}}" +SQRT_PRICE_X96="${CALLER_SQRT_PRICE_X96:-${SQRT_PRICE_X96:-79228162514264337593543950336}}" PRIVATE_KEY="${PRIVATE_KEY:-}" if [[ -z "$FACTORY" || -z "$RPC_URL" || -z "$TOKEN_A" || -z "$TOKEN_B" ]]; then @@ -24,11 +34,67 @@ if [[ -z "$FACTORY" || -z "$RPC_URL" || -z "$TOKEN_A" || -z "$TOKEN_B" ]]; then exit 1 fi +if [[ -n "$EXPECTED_CHAIN_ID" ]]; then + chain_id="$(cast chain-id --rpc-url "$RPC_URL")" + if [[ "$chain_id" != "$EXPECTED_CHAIN_ID" ]]; then + echo "Refusing to continue: RPC chain-id is $chain_id, expected $EXPECTED_CHAIN_ID" >&2 + exit 1 + fi +fi + +factory_code="$(cast code "$FACTORY" --rpc-url "$RPC_URL" 2>/dev/null || true)" +factory_code="${factory_code//$'\n'/}" +if [[ -z "$factory_code" || "$factory_code" == "0x" ]]; then + echo "Refusing to continue: FACTORY has no contract code at $FACTORY on the selected RPC" >&2 + exit 1 +fi + +PROXMOX_ROOT="$(cd "$REPO_ROOT/.." && pwd)" +if [[ -f "$PROXMOX_ROOT/scripts/lib/mainnet_gas_api.sh" ]]; then + # shellcheck source=/dev/null + source "$PROXMOX_ROOT/scripts/lib/mainnet_gas_api.sh" + export_mesh_mainnet_gas +fi + get_pool() { cast call "$FACTORY" \ "getPool(address,address,uint24)(address)" \ "$TOKEN_A" "$TOKEN_B" "$FEE" \ - --rpc-url "$RPC_URL" 2>/dev/null || true + --rpc-url "$RPC_URL" +} + +cast_send_wait() { + local to="$1" + local sig="$2" + shift 2 + local -a extra=() + local from nonce + from="$(cast wallet address --private-key "$PRIVATE_KEY")" + nonce="$(cast nonce "$from" --rpc-url "$RPC_URL" --block pending)" + extra+=(--nonce "$nonce") + if [[ "${CAST_USE_LEGACY:-1}" == "1" ]]; then + extra+=(--legacy) + if [[ -n "${CAST_GAS_PRICE:-}" ]]; then + extra+=(--gas-price "$CAST_GAS_PRICE") + fi + else + if [[ -n "${CAST_MAX_FEE_PER_GAS:-}" ]]; then + extra+=(--max-fee-per-gas "$CAST_MAX_FEE_PER_GAS") + fi + if [[ -n "${CAST_MAX_PRIORITY_FEE_PER_GAS:-}" ]]; then + extra+=(--priority-gas-price "$CAST_MAX_PRIORITY_FEE_PER_GAS") + fi + fi + local out tx + out="$(cast send "$to" "$sig" "$@" \ + --rpc-url "$RPC_URL" \ + --private-key "$PRIVATE_KEY" \ + --timeout "${CAST_SEND_TIMEOUT:-1800}" \ + "${extra[@]}")" + tx="$(printf '%s\n' "$out" | grep -oE '0x[a-fA-F0-9]{64}' | head -1)" + [[ -n "$tx" ]] || { echo "cast send did not return tx hash: $out" >&2; return 1; } + echo " tx submitted: $tx (nonce $nonce)" + wait_for_mainnet_receipt "$tx" "$RPC_URL" || return 1 } pool="$(get_pool)" @@ -44,38 +110,55 @@ if [[ -z "$pool" || "$pool" == "0x0000000000000000000000000000000000000000" ]]; echo "PRIVATE_KEY is required when EXECUTE=1" >&2 exit 1 fi - cast send "$FACTORY" \ + if ! cast_send_wait "$FACTORY" \ "createPool(address,address,uint24)" \ - "$TOKEN_A" "$TOKEN_B" "$FEE" \ - --rpc-url "$RPC_URL" \ - --private-key "$PRIVATE_KEY" \ - -vv + "$TOKEN_A" "$TOKEN_B" "$FEE"; then + echo " WARN: createPool receipt wait failed; re-checking getPool..." >&2 + fi pool="$(get_pool)" pool="${pool//$'\n'/}" fi +if [[ ! "$pool" =~ ^0x[0-9a-fA-F]{40}$ || "$pool" == "0x0000000000000000000000000000000000000000" ]]; then + echo "Refusing to initialize: factory getPool returned invalid pool address: '$pool'" >&2 + exit 1 +fi + echo "Pool address: $pool" -slot0="$(cast call "$pool" "slot0()((uint160,int24,uint16,uint16,uint16,uint8,bool))" --rpc-url "$RPC_URL" 2>/dev/null || true)" -slot0_no_ws="$(printf '%s' "$slot0" | tr -d '[:space:]')" - -if [[ "$slot0_no_ws" == "(0,0,0,0,0,0,false)" || -z "$slot0_no_ws" ]]; then - echo "Pool is not initialized" - if [[ "$EXECUTE" != "1" ]]; then - echo "Dry run: set EXECUTE=1 to initialize at sqrtPriceX96=$SQRT_PRICE_X96" - exit 0 - fi - if [[ -z "$PRIVATE_KEY" ]]; then - echo "PRIVATE_KEY is required when EXECUTE=1" >&2 - exit 1 - fi - cast send "$pool" \ - "initialize(uint160)" \ - "$SQRT_PRICE_X96" \ - --rpc-url "$RPC_URL" \ - --private-key "$PRIVATE_KEY" \ - -vv - echo "Initialized pool at sqrtPriceX96=$SQRT_PRICE_X96" -else - echo "Pool already initialized: $slot0" +PROXMOX_MESH_OPS="$(cd "$REPO_ROOT/.." && pwd)/scripts/lib/cw_mesh_pool_ops.sh" +if [[ -f "$PROXMOX_MESH_OPS" ]]; then + # shellcheck source=/dev/null + source "$PROXMOX_MESH_OPS" + export RPC="$RPC_URL" +fi + +needs_init=1 +if [[ -f "$PROXMOX_MESH_OPS" ]] && mesh_pool_initialized "$pool" "$RPC_URL"; then + needs_init=0 +fi + +slot0="$(cast call "$pool" "slot0()((uint160,int24,uint16,uint16,uint16,uint8,bool))" --rpc-url "$RPC_URL" 2>/dev/null || true)" +if [[ "$needs_init" -eq 0 ]]; then + echo "Pool already initialized: $slot0" + exit 0 +fi + +echo "Pool is not initialized" +if [[ "$EXECUTE" != "1" ]]; then + echo "Dry run: set EXECUTE=1 to initialize at sqrtPriceX96=$SQRT_PRICE_X96" + exit 0 +fi +if [[ -z "$PRIVATE_KEY" ]]; then + echo "PRIVATE_KEY is required when EXECUTE=1" >&2 + exit 1 +fi +if cast_send_wait "$pool" \ + "initialize(uint160)" \ + "$SQRT_PRICE_X96"; then + echo "Initialized pool at sqrtPriceX96=$SQRT_PRICE_X96" +elif [[ -f "$PROXMOX_MESH_OPS" ]] && mesh_pool_initialized "$pool" "$RPC_URL"; then + echo "Initialize receipt wait timed out but pool is initialized on-chain." +else + exit 1 fi diff --git a/scripts/deployment/fund-ccip-bridges-with-link.sh b/scripts/deployment/fund-ccip-bridges-with-link.sh index e60fac2..628fbf1 100755 --- a/scripts/deployment/fund-ccip-bridges-with-link.sh +++ b/scripts/deployment/fund-ccip-bridges-with-link.sh @@ -2,7 +2,8 @@ # Fund all CCIP WETH9/WETH10 bridge contracts with LINK on each chain. # Amount via tag (not .env): --link (default 10 LINK), --dry-run to print commands only. # --cap-to-deployer: per chain, each bridge gets min(--link, deployer_LINK_balance // bridges_on_chain). -# Usage: ./scripts/deployment/fund-ccip-bridges-with-link.sh [--link 10] [--cap-to-deployer] [--dry-run] +# --chain gnosis [celo ...]: limit to named chains (138, eth, gnosis, celo, wemix, cronos, ...). +# Usage: ./scripts/deployment/fund-ccip-bridges-with-link.sh [--link 10] [--cap-to-deployer] [--chain gnosis] [--dry-run] set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -12,7 +13,18 @@ cd "$PROJECT_ROOT" source "$SCRIPT_DIR/../lib/deployment/dotenv.sh" source "$SCRIPT_DIR/../lib/deployment/prompts.sh" load_deployment_env -parse_link_tags "$@" +parse_chain_filter "$@" +parse_link_tags "${PARSE_CHAIN_FILTER_REMAINING[@]}" + +chain_selected() { + local want="$1" + [[ ${#CHAIN_FILTER[@]} -eq 0 ]] && return 0 + local c + for c in "${CHAIN_FILTER[@]}"; do + [[ "$c" == "$want" ]] && return 0 + done + return 1 +} [[ -f "$SCRIPT_DIR/../lib/infura.sh" ]] && source "$SCRIPT_DIR/../lib/infura.sh" 2>/dev/null || true [[ -n "${PRIVATE_KEY:-}" && ! "$PRIVATE_KEY" =~ ^0x ]] && PRIVATE_KEY="0x$PRIVATE_KEY" @@ -135,7 +147,7 @@ echo "Deployer: $DEPLOYER_ADDR" echo "" # Chain 138 (Besu: gas estimation often fails; use explicit legacy gas like manual cast) -if [[ -n "${RPC_URL_138:-}" && -n "${LINK_TOKEN_CHAIN138:-${LINK_TOKEN:-}}" ]]; then +if chain_selected CHAIN138 && [[ -n "${RPC_URL_138:-}" && -n "${LINK_TOKEN_CHAIN138:-${LINK_TOKEN:-}}" ]]; then link="${LINK_TOKEN_CHAIN138:-$LINK_TOKEN}" link="${link,,}" rpc=$(ensure_rpc "$RPC_URL_138") @@ -171,7 +183,7 @@ if [[ -n "${RPC_URL_138:-}" && -n "${LINK_TOKEN_CHAIN138:-${LINK_TOKEN:-}}" ]]; fi # Ethereum -if [[ -n "${ETHEREUM_MAINNET_RPC:-}" && -n "${MAINNET_LINK_TOKEN:-${CCIP_ETH_LINK_TOKEN:-}}" ]]; then +if chain_selected ETH && [[ -n "${ETHEREUM_MAINNET_RPC:-}" && -n "${MAINNET_LINK_TOKEN:-${CCIP_ETH_LINK_TOKEN:-}}" ]]; then link="${MAINNET_LINK_TOKEN:-$CCIP_ETH_LINK_TOKEN}" rpc=$(ensure_rpc "$ETHEREUM_MAINNET_RPC") echo "Ethereum Mainnet" @@ -207,6 +219,7 @@ fi # BSC, Polygon, Base, Optimism, Arbitrum, Avalanche, Cronos, Gnosis, Celo, Wemix (matches check-link-balance-config-ready-chains.sh) for label in BSC POLYGON BASE OPTIMISM ARBITRUM AVALANCHE CRONOS GNOSIS CELO WEMIX; do + chain_selected "$label" || continue case "$label" in BSC) rpc_var="BSC_RPC_URL"; link_var="CCIP_BSC_LINK_TOKEN"; ;; POLYGON) rpc_var="POLYGON_MAINNET_RPC"; link_var="CCIP_POLYGON_LINK_TOKEN"; ;; diff --git a/scripts/deployment/fund-uniswap-v3-gas-pool.sh b/scripts/deployment/fund-uniswap-v3-gas-pool.sh index 2ebec51..4847d16 100755 --- a/scripts/deployment/fund-uniswap-v3-gas-pool.sh +++ b/scripts/deployment/fund-uniswap-v3-gas-pool.sh @@ -4,23 +4,34 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +CALLER_POSITION_MANAGER="${POSITION_MANAGER:-}" +CALLER_RPC_URL="${RPC_URL:-}" +CALLER_TOKEN_A="${TOKEN_A:-}" +CALLER_TOKEN_B="${TOKEN_B:-}" +CALLER_AMOUNT_A="${AMOUNT_A:-}" +CALLER_AMOUNT_B="${AMOUNT_B:-}" +CALLER_FEE="${FEE:-}" +CALLER_EXECUTE="${EXECUTE:-}" +CALLER_RECIPIENT="${RECIPIENT:-}" +CALLER_PRIVATE_KEY="${PRIVATE_KEY:-}" + if [[ -f "$SCRIPT_DIR/../lib/deployment/dotenv.sh" ]]; then # shellcheck disable=SC1090 source "$SCRIPT_DIR/../lib/deployment/dotenv.sh" load_deployment_env --repo-root "$REPO_ROOT" fi -POSITION_MANAGER="${POSITION_MANAGER:-}" -RPC_URL="${RPC_URL:-}" -TOKEN_A="${TOKEN_A:-}" -TOKEN_B="${TOKEN_B:-}" -AMOUNT_A="${AMOUNT_A:-}" -AMOUNT_B="${AMOUNT_B:-}" -FEE="${FEE:-500}" -EXECUTE="${EXECUTE:-0}" +POSITION_MANAGER="${CALLER_POSITION_MANAGER:-${POSITION_MANAGER:-}}" +RPC_URL="${CALLER_RPC_URL:-${ETHEREUM_MAINNET_RPC:-${ETH_MAINNET_RPC_URL:-${RPC_URL:-}}}}" +TOKEN_A="${CALLER_TOKEN_A:-${TOKEN_A:-}}" +TOKEN_B="${CALLER_TOKEN_B:-${TOKEN_B:-}}" +AMOUNT_A="${CALLER_AMOUNT_A:-${AMOUNT_A:-}}" +AMOUNT_B="${CALLER_AMOUNT_B:-${AMOUNT_B:-}}" +FEE="${CALLER_FEE:-${FEE:-500}}" +EXECUTE="${CALLER_EXECUTE:-${EXECUTE:-0}}" DEADLINE_SECONDS="${DEADLINE_SECONDS:-3600}" -RECIPIENT="${RECIPIENT:-}" -PRIVATE_KEY="${PRIVATE_KEY:-}" +RECIPIENT="${CALLER_RECIPIENT:-${RECIPIENT:-}}" +PRIVATE_KEY="${CALLER_PRIVATE_KEY:-${PRIVATE_KEY:-}}" if [[ -z "$POSITION_MANAGER" || -z "$RPC_URL" || -z "$TOKEN_A" || -z "$TOKEN_B" || -z "$AMOUNT_A" || -z "$AMOUNT_B" ]]; then echo "Required: POSITION_MANAGER RPC_URL TOKEN_A TOKEN_B AMOUNT_A AMOUNT_B" >&2 @@ -88,25 +99,101 @@ if [[ -z "$PRIVATE_KEY" ]]; then exit 1 fi -cast send "$token0" \ - "approve(address,uint256)" \ - "$POSITION_MANAGER" \ - "$amount0" \ - --rpc-url "$RPC_URL" \ - --private-key "$PRIVATE_KEY" \ - -q +cast_send_wait() { + local to="$1" + local sig="$2" + local use_gas_limit="${CAST_GAS_LIMIT:-}" + shift 2 + local -a extra=() + local from nonce + from="$(cast wallet address --private-key "$PRIVATE_KEY")" + nonce="$(cast nonce "$from" --rpc-url "$RPC_URL" --block pending)" + extra+=(--nonce "$nonce") + if [[ -n "$use_gas_limit" ]]; then + extra+=(--gas-limit "$use_gas_limit") + fi + if [[ "${CAST_USE_LEGACY:-1}" == "1" ]]; then + extra+=(--legacy) + if [[ -n "${CAST_GAS_PRICE:-}" ]]; then + extra+=(--gas-price "$CAST_GAS_PRICE") + fi + elif [[ -n "${CAST_MAX_PRIORITY_FEE_PER_GAS:-}" ]]; then + extra+=(--priority-gas-price "$CAST_MAX_PRIORITY_FEE_PER_GAS") + fi + local out tx + out="$(cast send "$to" "$sig" "$@" \ + --rpc-url "$RPC_URL" \ + --private-key "$PRIVATE_KEY" \ + --async \ + --timeout "${CAST_SEND_TIMEOUT:-1800}" \ + "${extra[@]}")" + tx="$(printf '%s\n' "$out" | grep -oE '0x[a-fA-F0-9]{64}' | head -1)" + [[ -n "$tx" ]] || { echo "cast send did not return tx hash: $out" >&2; return 1; } + echo " tx: $tx (nonce $nonce)" + if wait_for_mainnet_receipt "$tx" "$RPC_URL"; then + return 0 + fi + if [[ -n "${MESH_FUND_VERIFY_POOL:-}" && -n "${MESH_FUND_VERIFY_TOKEN:-}" && -n "${MESH_FUND_VERIFY_MIN:-}" ]]; then + local vb + vb="$(cast call "${MESH_FUND_VERIFY_TOKEN}" "balanceOf(address)(uint256)" "${MESH_FUND_VERIFY_POOL}" --rpc-url "$RPC_URL" | awk '{print $1}')" + if python3 -c "import sys; vb=int('$vb'); m=int('${MESH_FUND_VERIFY_MIN}'); sys.exit(0 if vb >= m - 10**12 else 1)"; then + echo " receipt timeout; on-chain pool leg OK (balance=$vb)" + return 0 + fi + fi + return 1 +} -cast send "$token1" \ - "approve(address,uint256)" \ - "$POSITION_MANAGER" \ - "$amount1" \ - --rpc-url "$RPC_URL" \ - --private-key "$PRIVATE_KEY" \ - -q +allowance_of() { + cast call "$1" "allowance(address,address)(uint256)" "$RECIPIENT" "$POSITION_MANAGER" --rpc-url "$RPC_URL" | awk '{print $1}' +} -cast send "$POSITION_MANAGER" \ +allowance_lt() { + python3 - "$1" "$2" <<'PY' +import sys +print("yes" if int(sys.argv[1]) < int(sys.argv[2]) else "no") +PY +} + +PROXMOX_ROOT="$(cd "$REPO_ROOT/.." && pwd)" +if [[ -f "$PROXMOX_ROOT/scripts/lib/mainnet_gas_api.sh" ]]; then + # shellcheck source=/dev/null + source "$PROXMOX_ROOT/scripts/lib/mainnet_gas_api.sh" + export_mesh_mainnet_gas +fi + +need0="$(allowance_of "$token0")" +if [[ "$(allowance_lt "$need0" "$amount0")" == yes ]]; then + CAST_GAS_LIMIT="" cast_send_wait "$token0" \ + "approve(address,uint256)" \ + "$POSITION_MANAGER" \ + "$amount0" +else + echo " skip approve token0 (allowance sufficient)" +fi + +need1="$(allowance_of "$token1")" +if [[ "$(allowance_lt "$need1" "$amount1")" == yes ]]; then + CAST_GAS_LIMIT="" cast_send_wait "$token1" \ + "approve(address,uint256)" \ + "$POSITION_MANAGER" \ + "$amount1" +else + echo " skip approve token1 (allowance sufficient)" +fi + +if [[ "${CAST_GAS_ESTIMATE_MINT:-1}" == "1" ]]; then + mint_est="$(cast estimate "$POSITION_MANAGER" \ + "mint((address,address,uint24,int24,int24,uint256,uint256,uint256,uint256,address,uint256))" \ + "($token0,$token1,$FEE,$tick_lower,$tick_upper,$amount0,$amount1,0,0,$RECIPIENT,$deadline)" \ + --rpc-url "$RPC_URL" \ + --from "$RECIPIENT" 2>/dev/null | awk '{print $1}')" || true + if [[ -n "$mint_est" && "$mint_est" -gt 0 ]]; then + export CAST_GAS_LIMIT="$(( mint_est * 130 / 100 ))" + echo " mint gas estimate: $mint_est → limit $CAST_GAS_LIMIT" + fi +fi + +cast_send_wait "$POSITION_MANAGER" \ "mint((address,address,uint24,int24,int24,uint256,uint256,uint256,uint256,address,uint256))" \ - "($token0,$token1,$FEE,$tick_lower,$tick_upper,$amount0,$amount1,0,0,$RECIPIENT,$deadline)" \ - --rpc-url "$RPC_URL" \ - --private-key "$PRIVATE_KEY" \ - -vv + "($token0,$token1,$FEE,$tick_lower,$tick_upper,$amount0,$amount1,0,0,$RECIPIENT,$deadline)" diff --git a/scripts/deployment/sync-chain138-pmm-pools-from-json.sh b/scripts/deployment/sync-chain138-pmm-pools-from-json.sh index 3895421..6e1e4b0 100755 --- a/scripts/deployment/sync-chain138-pmm-pools-from-json.sh +++ b/scripts/deployment/sync-chain138-pmm-pools-from-json.sh @@ -56,12 +56,12 @@ RPC_URL_138="${RPC_URL_138:-${RPC_URL:-http://192.168.11.211:8545}}" if [[ -n "$ORIG_DODO_PMM_INTEGRATION_ADDRESS" ]]; then DODO_PMM_INTEGRATION_ADDRESS="$ORIG_DODO_PMM_INTEGRATION_ADDRESS" else - DODO_PMM_INTEGRATION_ADDRESS="${CHAIN_138_DODO_PMM_INTEGRATION:-0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d}" + DODO_PMM_INTEGRATION_ADDRESS="${CHAIN_138_DODO_PMM_INTEGRATION:-0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895}" fi if [[ -n "$ORIG_DODO_PMM_PROVIDER_ADDRESS" ]]; then DODO_PMM_PROVIDER_ADDRESS="$ORIG_DODO_PMM_PROVIDER_ADDRESS" else - DODO_PMM_PROVIDER_ADDRESS="${CHAIN_138_DODO_PMM_PROVIDER:-0x5CAe6Ce155b7f08D3a956F5Dc82fC9945f29B381}" + DODO_PMM_PROVIDER_ADDRESS="${CHAIN_138_DODO_PMM_PROVIDER:-0x3f729632E9553EBacCdE2e9b4c8F2B285b014F2e}" fi DRY_RUN="${DRY_RUN:-0}" diff --git a/scripts/forge/scope.sh b/scripts/forge/scope.sh index 89d377e..7ddac47 100755 --- a/scripts/forge/scope.sh +++ b/scripts/forge/scope.sh @@ -37,6 +37,12 @@ declare -A ROOT_SCRIPT_SCOPE_ALIASES=( ["DeployWETH.s.sol"]="tokens" ["DeployWETH10.s.sol"]="tokens" ["DeployWETHWithCCIP.s.sol"]="full" + ["DeployRWATokenFactory138.s.sol"]="rwa" + ["DeployM00DiamondHub138.s.sol"]="m00-diamond" + ["UpgradeM00DiamondAcl138.s.sol"]="m00-diamond" + ["FundBridgeLinkViaCcip138.s.sol"]="ccip" + ["FundBridgeLinkViaCcipMainnet.s.sol"]="ccip" + ["DeployCCIPRelayBridgeLINK.s.sol"]="relay" ) usage() { @@ -232,7 +238,14 @@ prepare_scope_env() { local label label=$(scope_label "$scope") - export FOUNDRY_SRC="$src_dir" + if [[ "$scope" == "rwa" ]]; then + export FOUNDRY_SRC="contracts/rwa,contracts/compliance" + elif [[ "$scope" == "m00-diamond" ]]; then + export FOUNDRY_SRC="contracts/m00-diamond,contracts/rwa" + export FOUNDRY_PROFILE="${FOUNDRY_PROFILE:-m00-diamond}" + else + export FOUNDRY_SRC="$src_dir" + fi export FOUNDRY_OUT="out/scopes/$label" export FOUNDRY_CACHE_PATH="cache/scopes/$label" export FOUNDRY_SPARSE_MODE="${FOUNDRY_SPARSE_MODE:-true}" diff --git a/scripts/lib/deployment/prompts.sh b/scripts/lib/deployment/prompts.sh index 87eb233..76bbe83 100644 --- a/scripts/lib/deployment/prompts.sh +++ b/scripts/lib/deployment/prompts.sh @@ -116,7 +116,7 @@ parse_phase_tags() { } # Canonical L2 list for deploy-pmm, deploy-trustless, fund-ccip, etc. -L2_CHAIN_NAMES=( BSC POLYGON BASE OPTIMISM ARBITRUM AVALANCHE CRONOS GNOSIS ) +L2_CHAIN_NAMES=( BSC POLYGON BASE OPTIMISM ARBITRUM AVALANCHE CRONOS GNOSIS CELO WEMIX ) # Parse --chain [ ...] from "$@". Names case-insensitive (bsc, BSC, Polygon, etc.). # Sets CHAIN_FILTER=() (empty = all) or CHAIN_FILTER=( BSC POLYGON ... ). Unconsumed in PARSE_CHAIN_FILTER_REMAINING. @@ -130,6 +130,10 @@ normalize_chain_name() { AVALANCHE) echo AVALANCHE ;; CRONOS) echo CRONOS ;; GNOSIS) echo GNOSIS ;; + CELO) echo CELO ;; + WEMIX) echo WEMIX ;; + ETH|ETHEREUM|MAINNET) echo ETH ;; + 138|CHAIN138|CHAIN_138) echo CHAIN138 ;; *) echo "" ;; esac } diff --git a/services/checkpoint-aggregator/README.md b/services/checkpoint-aggregator/README.md new file mode 100644 index 0000000..47585e1 --- /dev/null +++ b/services/checkpoint-aggregator/README.md @@ -0,0 +1,25 @@ +# Checkpoint aggregator (v2) + +Buffers qualifying Chain 138 payments and submits **one mainnet checkpoint per batch** (default **10** txs, partial flush after **5 min**). + +## Env + +| Variable | Default | +|----------|---------| +| `PRIVATE_KEY` | required | +| `CHAIN138_MAINNET_CHECKPOINT_PROXY` | required (UUPS proxy) | +| `CHAIN138_RPC_URL` | `http://192.168.11.211:8545` | +| `MAINNET_RPC_URL` | public mainnet RPC | +| `CHECKPOINT_BATCH_SIZE` | `10` | +| `CHECKPOINT_MAX_WAIT_MS` | `300000` | +| `CHECKPOINT_MIN_VALUE_WEI` | `0` | + +## Run + +```bash +cd smom-dbis-138/services/checkpoint-aggregator +pnpm install && pnpm build +CHAIN138_MAINNET_CHECKPOINT_PROXY=0x… pnpm start +``` + +Indexer REST (`GET /v1/checkpoint/latest`) is a separate deployable; see `docs/07-ccip/MAINNET_CHECKPOINT_MAXIMUM_ARCHITECTURE.md`. diff --git a/services/checkpoint-aggregator/dist/activityRegistry.js b/services/checkpoint-aggregator/dist/activityRegistry.js new file mode 100644 index 0000000..ddb148f --- /dev/null +++ b/services/checkpoint-aggregator/dist/activityRegistry.js @@ -0,0 +1,48 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.recordActivityBatch = recordActivityBatch; +const ethers_1 = require("ethers"); +const checkpoint_core_1 = require("@dbis/checkpoint-core"); +const leafCodec_1 = require("./leafCodec"); +const REGISTRY_ABI = [ + 'function recordBatch(uint64 batchId, tuple(bytes32 txHash,address from,address to,uint256 valueWei,uint256 blockNumber138,uint64 blockTimestamp138,uint64 valueUsdE8,uint32 logCount,bytes32 receiptHash)[] records) external', + 'function recorded(bytes32) view returns (bool)', +]; +function activityRecord(leaf) { + const usd = leaf.totalTransfersUsd ?? leaf.valueUsd ?? leaf.nativeValueUsd ?? '0'; + return { + txHash: leaf.txHash, + from: leaf.from, + to: leaf.to, + valueWei: (0, leafCodec_1.effectiveValue)(leaf), + blockNumber138: BigInt(leaf.blockNumber), + blockTimestamp138: BigInt(leaf.blockTimestamp), + valueUsdE8: (0, checkpoint_core_1.usdStringToE8)(usd), + logCount: leaf.logCount ?? 0, + receiptHash: leaf.receiptHash ?? ethers_1.ethers.ZeroHash, + }; +} +async function recordActivityBatch(wallet, registryAddress, batchId, leaves) { + if (!registryAddress || leaves.length === 0) + return null; + const registry = new ethers_1.ethers.Contract(registryAddress, REGISTRY_ABI, wallet); + const pending = []; + for (const leaf of leaves) { + if (!leaf.txHash) + continue; + try { + if (await registry.recorded(leaf.txHash)) + continue; + } + catch { + /* continue */ + } + pending.push(leaf); + } + if (pending.length === 0) + return null; + const records = pending.map(activityRecord); + const tx = await registry.recordBatch(batchId, records, { gasLimit: 2500000n }); + await tx.wait(); + return tx.hash; +} diff --git a/services/checkpoint-aggregator/dist/activityRegistryV2.js b/services/checkpoint-aggregator/dist/activityRegistryV2.js new file mode 100644 index 0000000..4420284 --- /dev/null +++ b/services/checkpoint-aggregator/dist/activityRegistryV2.js @@ -0,0 +1,57 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.recordIsoAttestationBatch = recordIsoAttestationBatch; +const ethers_1 = require("ethers"); +const checkpoint_core_1 = require("@dbis/checkpoint-core"); +const leafCodec_1 = require("./leafCodec"); +const REGISTRY_V2_ABI = [ + 'function recordBatch(uint64 batchId, tuple(bytes32 txHash,address from,address to,uint256 valueWei,uint256 blockNumber138,uint64 blockTimestamp138,uint64 valueUsdE8,uint32 logCount,bytes32 receiptHash,bytes32 instructionId,bytes32 endToEndIdHash,bytes32 uetr,bytes32 payloadHash,uint8 msgTypeCode,bytes32 debtorRefHash,bytes32 creditorRefHash,bytes32 purposeHash)[] records) external', + 'function recorded(bytes32) view returns (bool)', +]; +function isoRecord(leaf, iso) { + const usd = leaf.totalTransfersUsd ?? leaf.valueUsd ?? leaf.nativeValueUsd ?? '0'; + return { + txHash: leaf.txHash, + from: leaf.from, + to: leaf.to, + valueWei: (0, leafCodec_1.effectiveValue)(leaf), + blockNumber138: BigInt(leaf.blockNumber), + blockTimestamp138: BigInt(leaf.blockTimestamp), + valueUsdE8: (0, checkpoint_core_1.usdStringToE8)(usd), + logCount: leaf.logCount ?? 0, + receiptHash: leaf.receiptHash ?? ethers_1.ethers.ZeroHash, + instructionId: iso.instructionIdBytes32, + endToEndIdHash: iso.endToEndIdHash, + uetr: iso.uetrBytes32, + payloadHash: iso.payloadHash, + msgTypeCode: iso.msgTypeCode, + debtorRefHash: iso.debtorRefHash, + creditorRefHash: iso.creditorRefHash, + purposeHash: iso.purposeHash, + }; +} +async function recordIsoAttestationBatch(wallet, registryV2Address, batchId, leaves) { + if (!registryV2Address || leaves.length === 0) + return null; + const registry = new ethers_1.ethers.Contract(registryV2Address, REGISTRY_V2_ABI, wallet); + const pending = []; + for (const leaf of leaves) { + if (!leaf.txHash) + continue; + try { + if (await registry.recorded(leaf.txHash)) + continue; + } + catch { + /* continue */ + } + const iso = leaf.iso20022 ?? (0, checkpoint_core_1.mapPaymentLeafToCanonical)(leaf); + pending.push({ leaf, iso }); + } + if (pending.length === 0) + return null; + const records = pending.map(({ leaf, iso }) => isoRecord(leaf, iso)); + const tx = await registry.recordBatch(batchId, records, { gasLimit: 3500000n }); + await tx.wait(); + return tx.hash; +} diff --git a/services/checkpoint-aggregator/dist/blockscout.js b/services/checkpoint-aggregator/dist/blockscout.js new file mode 100644 index 0000000..dcedee8 --- /dev/null +++ b/services/checkpoint-aggregator/dist/blockscout.js @@ -0,0 +1,70 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.fetchTransactionsInBlockRange = fetchTransactionsInBlockRange; +function parseItem(item) { + if (!item.hash || !item.from?.hash) + return null; + const ts = Math.floor(new Date(item.timestamp).getTime() / 1000); + return { + hash: item.hash, + from: item.from.hash, + to: item.to?.hash ?? '0x0000000000000000000000000000000000000000', + value: BigInt(item.value || '0'), + blockNumber: item.block_number, + blockTimestamp: Number.isFinite(ts) ? ts : 0, + }; +} +/** + * Fetch all validated txs with block_number in [fromBlock, toBlock] (inclusive). + * Walks newest-first from Blockscout until block_number < fromBlock. + */ +async function fetchTransactionsInBlockRange(apiBase, fromBlock, toBlock) { + const base = apiBase.replace(/\/$/, ''); + const out = []; + const seen = new Set(); + let url = `${base}/transactions?filter=validated`; + let pages = 0; + const maxPages = parseInt(process.env.CHECKPOINT_BLOCKSCOUT_MAX_PAGES || '5000', 10); + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + while (url && pages < maxPages) { + pages++; + let res = await fetch(url); + for (let attempt = 0; res.status === 429 && attempt < 5; attempt++) { + await sleep(2000 * (attempt + 1)); + res = await fetch(url); + } + if (!res.ok) { + throw new Error(`Blockscout ${res.status} ${url}`); + } + await sleep(parseInt(process.env.CHECKPOINT_BLOCKSCOUT_PAGE_DELAY_MS || '80', 10)); + const body = (await res.json()); + let stop = false; + for (const item of body.items ?? []) { + const bn = item.block_number; + if (bn < fromBlock) { + stop = true; + break; + } + if (bn > toBlock) + continue; + const tx = parseItem(item); + if (!tx || seen.has(tx.hash.toLowerCase())) + continue; + seen.add(tx.hash.toLowerCase()); + out.push(tx); + } + if (stop) + break; + const next = body.next_page_params; + if (!next) + break; + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(next)) { + if (v !== null && v !== undefined) + q.set(k, String(v)); + } + url = `${base}/transactions?${q.toString()}`; + } + out.sort((a, b) => a.blockNumber - b.blockNumber || a.blockTimestamp - b.blockTimestamp); + return out; +} diff --git a/services/checkpoint-aggregator/dist/config.js b/services/checkpoint-aggregator/dist/config.js new file mode 100644 index 0000000..a5bff0a --- /dev/null +++ b/services/checkpoint-aggregator/dist/config.js @@ -0,0 +1,114 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.checkpointAggregatorConfig = void 0; +exports.assertAggregatorConfig = assertAggregatorConfig; +const dotenv = __importStar(require("dotenv")); +const path = __importStar(require("path")); +dotenv.config(); +function int(name, fallback) { + const v = process.env[name]; + return v !== undefined && v !== '' ? parseInt(v, 10) : fallback; +} +function bigint(name, fallback) { + const v = process.env[name]; + return BigInt(v !== undefined && v !== '' ? v : fallback); +} +const repoRoot = process.env.PROXMOX_ROOT || path.resolve(__dirname, '../../../../..'); +exports.checkpointAggregatorConfig = { + chain138Rpc: process.env.CHAIN138_RPC_URL || 'http://192.168.11.211:8545', + mainnetRpc: process.env.MAINNET_RPC_URL || + process.env.ETHEREUM_MAINNET_RPC || + 'https://ethereum-rpc.publicnode.com', + checkpointProxy: process.env.CHAIN138_MAINNET_CHECKPOINT_PROXY || '', + blockOracleExtension: process.env.EXT_BLOCK_ORACLE || '', + batchSize: int('CHECKPOINT_BATCH_SIZE', 10), + maxWaitMs: int('CHECKPOINT_MAX_WAIT_MS', 300_000), + confirmationBlocks: int('CHECKPOINT_CONFIRMATION_BLOCKS', 2), + minValueWei: bigint('CHECKPOINT_MIN_VALUE_WEI', '0'), + blockscoutApi: process.env.CHECKPOINT_BLOCKSCOUT_API || 'https://explorer.d-bis.org/api/v2', + useBlockscout: process.env.CHECKPOINT_USE_BLOCKSCOUT !== '0', + scanFromBlock: int('CHECKPOINT_SCAN_FROM_BLOCK', 0), + scanChunkBlocks: int('CHECKPOINT_SCAN_CHUNK_BLOCKS', 100), + scanPollMs: int('CHECKPOINT_SCAN_POLL_MS', 15_000), + scanStatePath: process.env.CHECKPOINT_SCAN_STATE_PATH || + path.join(repoRoot, 'reports/status/checkpoint-aggregator-scan-state.json'), + ipfsApiUrl: process.env.CHECKPOINT_IPFS_API_URL || '', + payloadPublicUrl: process.env.CHECKPOINT_PAYLOAD_PUBLIC_URL || '', + batchPayloadDir: process.env.CHECKPOINT_BATCH_PAYLOAD_DIR || + path.join(repoRoot, 'reports/checkpoint-indexer/batches'), + updateBlockOracle: process.env.CHECKPOINT_UPDATE_BLOCK_ORACLE !== '0', + requireValidatorSigs: process.env.CHECKPOINT_REQUIRE_VALIDATOR_SIGS !== 'false', + /** hashes (default) | commitment | leaves | leaves-v2 — official hub on 0xe2D6… */ + submitMode: (process.env.CHECKPOINT_SUBMIT_MODE || 'hashes'), + batchEmitter138: process.env.CHAIN138_BATCH_EMITTER || '', + ccipIngressEnabled: process.env.CHECKPOINT_CCIP_INGRESS === '1', + usdEnrichEnabled: process.env.CHECKPOINT_USD_ENRICH !== '0', + tokenAggregationApiUrl: process.env.CHECKPOINT_TOKEN_AGGREGATION_URL || + process.env.TOKEN_AGGREGATION_API_URL || + 'https://explorer.d-bis.org/api/v1', + usdRequestDelayMs: int('CHECKPOINT_USD_REQUEST_DELAY_MS', 80), + dualWriteV1Mirror: process.env.CHECKPOINT_DUAL_WRITE_V1_MIRROR !== '0', + recordAddressActivity: process.env.CHECKPOINT_RECORD_ADDRESS_ACTIVITY !== '0', + addressActivityRegistry: process.env.ADDRESS_ACTIVITY_REGISTRY_MAINNET || '', + transactionMirrorMainnet: process.env.TRANSACTION_MIRROR_MAINNET || + '0x4CF42c4F1dBa748601b8938be3E7ABD732E87cE9', + attachReceiptMeta: process.env.CHECKPOINT_ATTACH_RECEIPT_META !== '0', + isoEnrichEnabled: process.env.CHECKPOINT_ISO_ENRICH !== '0', + recordIsoAttestation: process.env.CHECKPOINT_RECORD_ISO_ATTESTATION !== '0', + addressActivityRegistryV2: process.env.ADDRESS_ACTIVITY_REGISTRY_V2_MAINNET || '', + participantSurface: process.env.CHAIN138_PARTICIPANT_SURFACE_MAINNET || '', + surfaceParticipants: process.env.CHECKPOINT_SURFACE_PARTICIPANTS !== '0', + surfaceTopLevelZeroEth: process.env.CHECKPOINT_SURFACE_TOPLEVEL_ZERO_ETH === '1', +}; +function assertAggregatorConfig() { + if (!process.env.PRIVATE_KEY) + throw new Error('PRIVATE_KEY required'); + if (!exports.checkpointAggregatorConfig.checkpointProxy) { + throw new Error('CHAIN138_MAINNET_CHECKPOINT_PROXY required'); + } + if (process.env.CHECKPOINT_RECORD_ADDRESS_ACTIVITY === '1' && + !exports.checkpointAggregatorConfig.addressActivityRegistry) { + throw new Error('CHECKPOINT_RECORD_ADDRESS_ACTIVITY=1 requires ADDRESS_ACTIVITY_REGISTRY_MAINNET (deploy: bash scripts/deployment/deploy-address-activity-registry.sh)'); + } + if (exports.checkpointAggregatorConfig.dualWriteV1Mirror && + !exports.checkpointAggregatorConfig.transactionMirrorMainnet) { + throw new Error('CHECKPOINT_DUAL_WRITE_V1_MIRROR=1 requires TRANSACTION_MIRROR_MAINNET'); + } + if (process.env.CHECKPOINT_RECORD_ISO_ATTESTATION === '1' && + !exports.checkpointAggregatorConfig.addressActivityRegistryV2) { + throw new Error('CHECKPOINT_RECORD_ISO_ATTESTATION=1 requires ADDRESS_ACTIVITY_REGISTRY_V2_MAINNET'); + } +} diff --git a/services/checkpoint-aggregator/dist/dualMirror.js b/services/checkpoint-aggregator/dist/dualMirror.js new file mode 100644 index 0000000..a9ccdf0 --- /dev/null +++ b/services/checkpoint-aggregator/dist/dualMirror.js @@ -0,0 +1,30 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.dualWriteV1Mirror = dualWriteV1Mirror; +const ethers_1 = require("ethers"); +const leafCodec_1 = require("./leafCodec"); +const MIRROR_ABI = [ + 'function mirrorBatchTransactions(bytes32[] calldata txHashes, address[] calldata froms, address[] calldata tos, uint256[] calldata values, uint256[] calldata blockNumbers, uint256[] calldata blockTimestamps, uint256[] calldata gasUseds, bool[] calldata successes, bytes[] calldata datas) external', + 'function processed(bytes32) view returns (bool)', +]; +async function dualWriteV1Mirror(wallet, mirrorAddress, leaves) { + if (leaves.length === 0) + return null; + const mirror = new ethers_1.ethers.Contract(mirrorAddress, MIRROR_ABI, wallet); + const pending = []; + for (const leaf of leaves) { + try { + if (await mirror.processed(leaf.txHash)) + continue; + } + catch { + /* continue */ + } + pending.push(leaf); + } + if (pending.length === 0) + return null; + const tx = await mirror.mirrorBatchTransactions(pending.map((l) => l.txHash), pending.map((l) => l.from), pending.map((l) => l.to), pending.map((l) => (0, leafCodec_1.effectiveValue)(l)), pending.map((l) => l.blockNumber), pending.map((l) => l.blockTimestamp), pending.map((l) => l.gasUsed), pending.map((l) => l.success), pending.map((l) => '0x')); + await tx.wait(); + return tx.hash; +} diff --git a/services/checkpoint-aggregator/dist/eip712.js b/services/checkpoint-aggregator/dist/eip712.js new file mode 100644 index 0000000..ae68f11 --- /dev/null +++ b/services/checkpoint-aggregator/dist/eip712.js @@ -0,0 +1,47 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.signBatchAttestation = signBatchAttestation; +exports.normalizeEcdsaSignature = normalizeEcdsaSignature; +const ethers_1 = require("ethers"); +const BATCH_ATTESTATION_TYPES = { + BatchAttestation: [ + { name: 'chainId', type: 'uint64' }, + { name: 'batchId', type: 'uint64' }, + { name: 'checkpointBlock', type: 'uint256' }, + { name: 'blockHash', type: 'bytes32' }, + { name: 'stateRoot', type: 'bytes32' }, + { name: 'paymentsRoot', type: 'bytes32' }, + { name: 'previousBatchId', type: 'uint64' }, + ], +}; +/** Matches CheckpointEIP712.digest / ValidatorSigVerifierExtension.beforeSubmit. */ +async function signBatchAttestation(wallet, verifyingContract, header) { + const chainId = Number((await wallet.provider.getNetwork()).chainId); + const domain = { + name: 'Chain138MainnetCheckpoint', + version: '2', + chainId, + verifyingContract, + }; + const message = { + chainId: header.chainId, + batchId: header.batchId, + checkpointBlock: header.checkpointBlock, + blockHash: header.blockHash, + stateRoot: header.stateRoot, + paymentsRoot: header.paymentsRoot, + previousBatchId: header.previousBatchId, + }; + const sig = await wallet.signTypedData(domain, BATCH_ATTESTATION_TYPES, message); + return normalizeEcdsaSignature(sig); +} +/** OpenZeppelin ECDSA.recover expects v=27|28; ethers v6 may return 0|1. */ +function normalizeEcdsaSignature(sig) { + const bytes = ethers_1.ethers.getBytes(sig); + if (bytes.length !== 65) + return sig; + const v = bytes[64]; + if (v < 27) + bytes[64] = v + 27; + return ethers_1.ethers.hexlify(bytes); +} diff --git a/services/checkpoint-aggregator/dist/index.js b/services/checkpoint-aggregator/dist/index.js new file mode 100644 index 0000000..d4f0191 --- /dev/null +++ b/services/checkpoint-aggregator/dist/index.js @@ -0,0 +1,372 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const ethers_1 = require("ethers"); +const config_1 = require("./config"); +const eip712_1 = require("./eip712"); +const ipfs_1 = require("./ipfs"); +const blockscout_1 = require("./blockscout"); +const usdEnrich_1 = require("./usdEnrich"); +const scanState_1 = require("./scanState"); +const checkpoint_core_1 = require("@dbis/checkpoint-core"); +const leafCodec_1 = require("./leafCodec"); +const adminIngress_1 = require("./ingress/adminIngress"); +const dualMirror_1 = require("./dualMirror"); +const activityRegistry_1 = require("./activityRegistry"); +const activityRegistryV2_1 = require("./activityRegistryV2"); +const iso20022Enrich_1 = require("./iso20022Enrich"); +const iso20022LocalStore_1 = require("./iso20022LocalStore"); +const participantSurface_1 = require("./participantSurface"); +const receiptsRoot_1 = require("./receiptsRoot"); +(0, config_1.assertAggregatorConfig)(); +const BLOCK_ORACLE_ABI = ['function setBlockHeader(uint256,bytes32,bytes32) external']; +class CheckpointAggregator { + chain138; + mainnetWallet; + checkpoint; + ingress; + blockOracle; + buffer = []; + nextBatchId = 1n; + hubImplVersion = 3n; + flushTimer; + scanning = false; + includedTxCache = new Map(); + constructor() { + const mainnet = new ethers_1.ethers.JsonRpcProvider(config_1.checkpointAggregatorConfig.mainnetRpc); + this.mainnetWallet = new ethers_1.ethers.Wallet(process.env.PRIVATE_KEY, mainnet); + this.checkpoint = (0, adminIngress_1.buildCheckpointContract)(this.mainnetWallet, config_1.checkpointAggregatorConfig.checkpointProxy, config_1.checkpointAggregatorConfig.submitMode); + this.ingress = new adminIngress_1.AdminCheckpointIngress(this.checkpoint, config_1.checkpointAggregatorConfig.submitMode); + this.chain138 = new ethers_1.ethers.JsonRpcProvider(config_1.checkpointAggregatorConfig.chain138Rpc); + if (config_1.checkpointAggregatorConfig.blockOracleExtension) { + this.blockOracle = new ethers_1.ethers.Contract(config_1.checkpointAggregatorConfig.blockOracleExtension, BLOCK_ORACLE_ABI, this.mainnetWallet); + } + } + async start() { + await this.syncBatchId(); + const state = (0, scanState_1.loadScanState)(config_1.checkpointAggregatorConfig.scanStatePath, config_1.checkpointAggregatorConfig.scanFromBlock); + console.log(`Checkpoint aggregator mode=${config_1.checkpointAggregatorConfig.submitMode} batch=${config_1.checkpointAggregatorConfig.batchSize} nextBatchId=${this.nextBatchId} impl=${this.hubImplVersion} blockscout=${config_1.checkpointAggregatorConfig.useBlockscout} emitter=${config_1.checkpointAggregatorConfig.batchEmitter138 || 'n/a'}`); + this.schedulePartialFlush(); + await this.runScanLoop(state); + } + async syncBatchId() { + const latest = await this.checkpoint.getLatestBatchId(); + this.nextBatchId = latest > 0n ? BigInt(latest) + 1n : 1n; + try { + this.hubImplVersion = await this.checkpoint.IMPLEMENTATION_VERSION(); + } + catch { + this.hubImplVersion = 3n; + } + } + schedulePartialFlush() { + if (this.flushTimer) + clearTimeout(this.flushTimer); + this.flushTimer = setTimeout(() => this.flush(true), config_1.checkpointAggregatorConfig.maxWaitMs); + } + async runScanLoop(initialState) { + const state = { ...initialState }; + for (;;) { + try { + await this.scanCatchUp(state); + } + catch (e) { + console.error('scanCatchUp', e); + } + await new Promise((r) => setTimeout(r, config_1.checkpointAggregatorConfig.scanPollMs)); + } + } + async scanCatchUp(state) { + if (this.scanning) + return; + this.scanning = true; + try { + const head = await this.chain138.getBlockNumber(); + const safeHead = head - config_1.checkpointAggregatorConfig.confirmationBlocks; + let from = state.lastScannedBlock + 1; + while (from <= safeHead) { + const to = Math.min(from + config_1.checkpointAggregatorConfig.scanChunkBlocks - 1, safeHead); + let added = 0; + if (config_1.checkpointAggregatorConfig.useBlockscout) { + added = await this.scanBlockscoutRange(from, to); + } + else { + for (let b = from; b <= to; b++) + added += await this.scanBlockRpc(b); + } + state.lastScannedBlock = to; + (0, scanState_1.saveScanState)(config_1.checkpointAggregatorConfig.scanStatePath, state); + console.log(`Scanned blocks ${from}-${to} (+${added} txs) buffer=${this.buffer.length}`); + from = to + 1; + } + } + finally { + this.scanning = false; + } + } + async scanBlockscoutRange(from, to) { + const txs = await (0, blockscout_1.fetchTransactionsInBlockRange)(config_1.checkpointAggregatorConfig.blockscoutApi, from, to); + let added = 0; + for (const tx of txs) { + if (await this.txAlreadyIncluded(tx.hash)) + continue; + const receipt = await this.chain138.getTransactionReceipt(tx.hash); + const leaf = { + txHash: tx.hash, + from: tx.from, + to: tx.to, + value: tx.value, + blockNumber: tx.blockNumber, + blockTimestamp: tx.blockTimestamp, + gasUsed: receipt?.gasUsed ?? 0n, + success: receipt?.status === 1, + }; + await this.enrichLeafFromBlockscout(leaf); + if ((0, leafCodec_1.effectiveValue)(leaf) < config_1.checkpointAggregatorConfig.minValueWei) + continue; + this.buffer.push(leaf); + added++; + if (this.buffer.length >= config_1.checkpointAggregatorConfig.batchSize) + await this.flush(false); + } + return added; + } + async scanBlockRpc(blockNumber) { + const block = await this.chain138.getBlock(blockNumber, true); + if (!block) + return 0; + let added = 0; + const entries = block.prefetchedTransactions?.length + ? block.prefetchedTransactions + : block.transactions ?? []; + for (const entry of entries) { + const tx = typeof entry === 'string' ? await this.chain138.getTransaction(entry) : entry; + if (!tx || typeof tx === 'string') + continue; + const value = tx.value ?? 0n; + if (await this.txAlreadyIncluded(tx.hash)) + continue; + const receipt = await this.chain138.getTransactionReceipt(tx.hash); + const leaf = { + txHash: tx.hash, + from: tx.from, + to: tx.to ?? ethers_1.ethers.ZeroAddress, + value, + blockNumber, + blockTimestamp: block.timestamp, + gasUsed: receipt?.gasUsed ?? 0n, + success: receipt?.status === 1, + }; + await this.enrichLeafFromBlockscout(leaf); + if ((0, leafCodec_1.effectiveValue)(leaf) < config_1.checkpointAggregatorConfig.minValueWei) + continue; + this.buffer.push(leaf); + added++; + if (this.buffer.length >= config_1.checkpointAggregatorConfig.batchSize) + await this.flush(false); + } + return added; + } + async enrichLeafFromBlockscout(leaf) { + const { allErc20 } = await (0, usdEnrich_1.enrichLeafFromBlockscoutApi)(leaf, config_1.checkpointAggregatorConfig.blockscoutApi, config_1.checkpointAggregatorConfig.useBlockscout); + await (0, usdEnrich_1.enrichLeafUsd)(leaf, { + apiBaseUrl: config_1.checkpointAggregatorConfig.tokenAggregationApiUrl, + chainId: 138, + enabled: config_1.checkpointAggregatorConfig.usdEnrichEnabled, + requestDelayMs: config_1.checkpointAggregatorConfig.usdRequestDelayMs, + blockscoutApi: config_1.checkpointAggregatorConfig.blockscoutApi, + useBlockscout: config_1.checkpointAggregatorConfig.useBlockscout, + }, allErc20); + } + async txAlreadyIncluded(txHash) { + const cached = this.includedTxCache.get(txHash); + if (cached !== undefined) + return cached; + try { + const [included] = await this.checkpoint.isTxIncluded(txHash); + this.includedTxCache.set(txHash, included); + return included; + } + catch { + return false; + } + } + async flush(partial) { + if (this.buffer.length === 0) + return; + const leaves = this.buffer.splice(0, config_1.checkpointAggregatorConfig.batchSize); + if (config_1.checkpointAggregatorConfig.attachReceiptMeta) { + await (0, receiptsRoot_1.attachReceiptMeta)(this.chain138, leaves); + } + else { + for (const leaf of leaves) { + const meta = await (0, receiptsRoot_1.fetchReceiptMeta)(this.chain138, leaf.txHash); + leaf.logCount = meta.logCount; + leaf.receiptHash = meta.receiptHash; + } + } + (0, iso20022Enrich_1.attachIso20022ToLeaves)(leaves); + const root = (0, leafCodec_1.paymentsRoot)(138, leaves); + const receiptMetas = leaves.map((l) => ({ + logCount: l.logCount ?? 0, + receiptHash: l.receiptHash ?? ethers_1.ethers.ZeroHash, + })); + const receiptsRootHash = (0, receiptsRoot_1.receiptsRoot)(leaves.map((l) => l.txHash), receiptMetas); + const checkpointBlock = Math.max(...leaves.map((l) => l.blockNumber)); + const block = await this.chain138.getBlock(checkpointBlock); + const blockHash = block?.hash ?? ethers_1.ethers.ZeroHash; + const stateRoot = block?.stateRoot ?? ethers_1.ethers.ZeroHash; + if (config_1.checkpointAggregatorConfig.updateBlockOracle && this.blockOracle) { + const tx = await this.blockOracle.setBlockHeader(checkpointBlock, blockHash, stateRoot); + await tx.wait(); + } + const header = { + batchId: this.nextBatchId, + previousBatchId: this.nextBatchId > 1n ? this.nextBatchId - 1n : 0n, + chainId: 138n, + checkpointBlock: BigInt(checkpointBlock), + startBlock: BigInt(Math.min(...leaves.map((l) => l.blockNumber))), + endBlock: BigInt(checkpointBlock), + blockHash, + stateRoot, + paymentsRoot: root, + receiptsRoot: receiptsRootHash, + txCount: partial && leaves.length < config_1.checkpointAggregatorConfig.batchSize ? leaves.length : config_1.checkpointAggregatorConfig.batchSize, + flags: partial && leaves.length < config_1.checkpointAggregatorConfig.batchSize ? 1 : 0, + submittedAt: 0n, + submitter: ethers_1.ethers.ZeroAddress, + contentURI: ethers_1.ethers.ZeroHash, + }; + const payload = { + batchId: this.nextBatchId.toString(), + chainId: 138, + checkpointBlock, + paymentsRoot: root, + leaves: leaves.map((l) => ({ + txHash: l.txHash, + from: l.from, + to: l.to, + value: l.value.toString(), + nativeValueWei: l.value.toString(), + onChainValueWei: (0, leafCodec_1.effectiveValue)(l).toString(), + blockNumber: l.blockNumber, + blockTimestamp: l.blockTimestamp, + gasUsed: l.gasUsed.toString(), + success: l.success, + ...(l.token + ? { + token: l.token, + tokenSymbol: l.tokenSymbol, + tokenDecimals: l.tokenDecimals, + tokenValue: l.tokenValue?.toString(), + tokenLogIndex: l.tokenLogIndex, + } + : {}), + ...(l.valueUsd ? { valueUsd: l.valueUsd } : {}), + ...(l.nativeValueUsd ? { nativeValueUsd: l.nativeValueUsd } : {}), + ...(l.tokenValueUsd ? { tokenValueUsd: l.tokenValueUsd } : {}), + ...(l.nativePriceUsd != null ? { nativePriceUsd: l.nativePriceUsd } : {}), + ...(l.tokenPriceUsd != null ? { tokenPriceUsd: l.tokenPriceUsd } : {}), + ...(l.priceSource ? { priceSource: l.priceSource } : {}), + ...(l.priceEffectiveTimestamp ? { priceEffectiveTimestamp: l.priceEffectiveTimestamp } : {}), + ...(l.totalTransfersUsd ? { totalTransfersUsd: l.totalTransfersUsd } : {}), + ...(l.transfers?.length ? { transfers: l.transfers } : {}), + ...(l.logCount != null ? { logCount: l.logCount } : {}), + ...(l.receiptHash ? { receiptHash: l.receiptHash } : {}), + ...(l.iso20022 ? { iso20022: l.iso20022 } : {}), + })), + submittedAt: new Date().toISOString(), + ...(config_1.checkpointAggregatorConfig.isoEnrichEnabled + ? { iso20022Schema: 'canonical-v1', isoMsgTypeDefault: 'chain138.synthetic' } + : {}), + ...(config_1.checkpointAggregatorConfig.usdEnrichEnabled + ? { + batchTotalUsd: (0, checkpoint_core_1.sumUsdStrings)(leaves.map((l) => l.totalTransfersUsd ?? l.valueUsd)) ?? '0.000000', + usdEnrichedAt: new Date().toISOString(), + } + : {}), + }; + const contentURI = await (0, ipfs_1.publishBatchPayload)(this.nextBatchId, payload); + header.contentURI = contentURI; + const validatorSignatures = config_1.checkpointAggregatorConfig.requireValidatorSigs + ? await (0, eip712_1.signBatchAttestation)(this.mainnetWallet, config_1.checkpointAggregatorConfig.checkpointProxy, header) + : '0x01'; + const txHashes = config_1.checkpointAggregatorConfig.submitMode === 'commitment' ? [] : leaves.map((l) => l.txHash); + const extensionData = adminIngress_1.AdminCheckpointIngress.extensionDataForMode(config_1.checkpointAggregatorConfig.submitMode, leaves); + const { hash } = await this.ingress.submit({ + header, + leaves, + txHashes, + extensionData, + contentURI, + validatorSignatures, + }); + const batchIdSubmitted = this.nextBatchId; + console.log(`Submitted batch ${batchIdSubmitted} (${leaves.length} txs) mode=${config_1.checkpointAggregatorConfig.submitMode} tx=${hash}`); + if (config_1.checkpointAggregatorConfig.dualWriteV1Mirror) { + try { + const mirrorTx = await (0, dualMirror_1.dualWriteV1Mirror)(this.mainnetWallet, config_1.checkpointAggregatorConfig.transactionMirrorMainnet, leaves); + if (mirrorTx) + console.log(` v1 TransactionMirror dual-write tx=${mirrorTx}`); + } + catch (e) { + console.error(' v1 TransactionMirror dual-write failed', e); + } + } + if (config_1.checkpointAggregatorConfig.recordAddressActivity && config_1.checkpointAggregatorConfig.addressActivityRegistry) { + try { + const activityTx = await (0, activityRegistry_1.recordActivityBatch)(this.mainnetWallet, config_1.checkpointAggregatorConfig.addressActivityRegistry, batchIdSubmitted, leaves); + if (activityTx) + console.log(` AddressActivityRegistry tx=${activityTx}`); + } + catch (e) { + console.error(' AddressActivityRegistry record failed', e); + } + } + if (config_1.checkpointAggregatorConfig.recordIsoAttestation && config_1.checkpointAggregatorConfig.addressActivityRegistryV2) { + try { + const isoTx = await (0, activityRegistryV2_1.recordIsoAttestationBatch)(this.mainnetWallet, config_1.checkpointAggregatorConfig.addressActivityRegistryV2, batchIdSubmitted, leaves); + if (isoTx) + console.log(` AddressActivityRegistryV2 tx=${isoTx}`); + } + catch (e) { + console.error(' AddressActivityRegistryV2 record failed', e); + } + } + if (config_1.checkpointAggregatorConfig.isoEnrichEnabled) { + try { + const stored = (0, iso20022LocalStore_1.persistIso20022ForLeaves)(leaves, batchIdSubmitted); + if (stored.length) + console.log(` ISO20022 OMNL store: ${stored.length} message(s)`); + } + catch (e) { + console.error(' ISO20022 local store failed', e); + } + } + if (config_1.checkpointAggregatorConfig.surfaceParticipants && config_1.checkpointAggregatorConfig.participantSurface) { + try { + const surfaceTx = await (0, participantSurface_1.surfaceParticipantsOnMainnet)(this.mainnetWallet, config_1.checkpointAggregatorConfig.participantSurface, batchIdSubmitted, leaves); + if (surfaceTx) + console.log(` Chain138ParticipantSurface tx=${surfaceTx}`); + } + catch (e) { + console.error(' Chain138ParticipantSurface failed', e); + } + } + if (config_1.checkpointAggregatorConfig.surfaceTopLevelZeroEth) { + try { + const n = await (0, participantSurface_1.surfaceTopLevelZeroEth)(this.mainnetWallet, leaves); + if (n > 0) + console.log(` Top-level 0 ETH surface txs=${n} (Etherscan Transactions tab)`); + } + catch (e) { + console.error(' Top-level 0 ETH surface failed', e); + } + } + this.nextBatchId += 1n; + this.schedulePartialFlush(); + } +} +new CheckpointAggregator().start().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/services/checkpoint-aggregator/dist/ingress/adminIngress.js b/services/checkpoint-aggregator/dist/ingress/adminIngress.js new file mode 100644 index 0000000..99f4c2e --- /dev/null +++ b/services/checkpoint-aggregator/dist/ingress/adminIngress.js @@ -0,0 +1,74 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AdminCheckpointIngress = void 0; +exports.buildCheckpointContract = buildCheckpointContract; +const ethers_1 = require("ethers"); +const leafCodec_1 = require("../leafCodec"); +const CHECKPOINT_HEADER_TUPLE = 'tuple(uint64 batchId,uint64 previousBatchId,uint64 chainId,uint256 checkpointBlock,uint256 startBlock,uint256 endBlock,bytes32 blockHash,bytes32 stateRoot,bytes32 paymentsRoot,bytes32 receiptsRoot,uint16 txCount,uint32 flags,uint64 submittedAt,address submitter,bytes32 contentURI)'; +const PAYMENT_LEAF_TUPLE = 'tuple(bytes32,address,address,uint256,uint256,uint64,uint256,bool)'; +const PAYMENT_LEAF_V2_TUPLE = 'tuple(bytes32,address,address,address,uint256,uint256,uint64,uint256,bool,uint32)'; +function headerToTuple(h) { + return [ + h.batchId, + h.previousBatchId, + h.chainId, + h.checkpointBlock, + h.startBlock, + h.endBlock, + h.blockHash, + h.stateRoot, + h.paymentsRoot, + h.receiptsRoot, + h.txCount, + h.flags, + h.submittedAt, + h.submitter, + h.contentURI, + ]; +} +class AdminCheckpointIngress { + checkpoint; + mode; + constructor(checkpoint, mode) { + this.checkpoint = checkpoint; + this.mode = mode; + } + async submit(ctx) { + const header = headerToTuple(ctx.header); + const validatorSignatures = ctx.validatorSignatures ?? '0x01'; + switch (this.mode) { + case 'commitment': { + const tx = await this.checkpoint.submitCheckpointCommitment(header, validatorSignatures, ctx.contentURI, ctx.extensionData); + return { hash: tx.hash }; + } + case 'leaves': { + const tx = await this.checkpoint.submitCheckpointWithLeaves(header, validatorSignatures, ctx.txHashes, (0, leafCodec_1.leavesToTuples)(ctx.leaves)); + return { hash: tx.hash }; + } + case 'leaves-v2': + throw new Error('leaves-v2 requires hub submitCheckpointWithLeavesV2 (deploy separate module or use hashes)'); + case 'hashes': + default: { + const tx = await this.checkpoint.submitCheckpoint(header, validatorSignatures, ctx.txHashes, ctx.extensionData); + return { hash: tx.hash }; + } + } + } + static extensionDataForMode(mode, leaves) { + if (mode === 'leaves-v2') + return (0, leafCodec_1.encodeExtensionLeavesV2)(leaves); + return (0, leafCodec_1.encodeExtensionLeavesV1)(leaves); + } +} +exports.AdminCheckpointIngress = AdminCheckpointIngress; +function buildCheckpointContract(wallet, proxy, mode) { + const fns = [ + `function submitCheckpoint(${CHECKPOINT_HEADER_TUPLE} header, bytes validatorSignatures, bytes32[] txHashes, bytes extensionData) external`, + `function submitCheckpointCommitment(${CHECKPOINT_HEADER_TUPLE} header, bytes validatorSignatures, bytes32 contentURI, bytes extensionData) external`, + `function submitCheckpointWithLeaves(${CHECKPOINT_HEADER_TUPLE} header, bytes validatorSignatures, bytes32[] txHashes, ${PAYMENT_LEAF_TUPLE}[] leaves) external`, + 'function getLatestBatchId() view returns (uint64)', + 'function IMPLEMENTATION_VERSION() view returns (uint256)', + 'function isTxIncluded(bytes32 txHash) view returns (bool included, uint64 batchId)', + ]; + return new ethers_1.ethers.Contract(proxy, fns, wallet); +} diff --git a/services/checkpoint-aggregator/dist/ingress/types.js b/services/checkpoint-aggregator/dist/ingress/types.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/services/checkpoint-aggregator/dist/ingress/types.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/services/checkpoint-aggregator/dist/ipfs.js b/services/checkpoint-aggregator/dist/ipfs.js new file mode 100644 index 0000000..e68a947 --- /dev/null +++ b/services/checkpoint-aggregator/dist/ipfs.js @@ -0,0 +1,69 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.publishBatchPayload = publishBatchPayload; +const ethers_1 = require("ethers"); +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const config_1 = require("./config"); +function jsonReplacer(_key, value) { + return typeof value === 'bigint' ? value.toString() : value; +} +async function publishBatchPayload(batchId, payload) { + const json = JSON.stringify(payload, jsonReplacer, 2); + const localDir = config_1.checkpointAggregatorConfig.batchPayloadDir; + fs.mkdirSync(localDir, { recursive: true }); + const localPath = path.join(localDir, `batch-${batchId}.json`); + fs.writeFileSync(localPath, json); + if (!config_1.checkpointAggregatorConfig.ipfsApiUrl) { + if (config_1.checkpointAggregatorConfig.payloadPublicUrl) { + const url = `${config_1.checkpointAggregatorConfig.payloadPublicUrl.replace(/\/$/, '')}/batch-${batchId}.json`; + return ethers_1.ethers.keccak256(ethers_1.ethers.toUtf8Bytes(url)); + } + return ethers_1.ethers.keccak256(ethers_1.ethers.toUtf8Bytes(`file://${localPath}`)); + } + const form = new FormData(); + form.append('file', new Blob([json], { type: 'application/json' }), `batch-${batchId}.json`); + const res = await fetch(`${config_1.checkpointAggregatorConfig.ipfsApiUrl.replace(/\/$/, '')}/api/v0/add`, { method: 'POST', body: form }); + if (!res.ok) { + throw new Error(`IPFS add failed: ${res.status} ${await res.text()}`); + } + const text = await res.text(); + const line = text.trim().split('\n').pop() || '{}'; + const parsed = JSON.parse(line); + if (!parsed.Hash) + throw new Error('IPFS response missing Hash'); + return ethers_1.ethers.keccak256(ethers_1.ethers.toUtf8Bytes(`ipfs://${parsed.Hash}`)); +} diff --git a/services/checkpoint-aggregator/dist/iso20022Enrich.js b/services/checkpoint-aggregator/dist/iso20022Enrich.js new file mode 100644 index 0000000..a266ce1 --- /dev/null +++ b/services/checkpoint-aggregator/dist/iso20022Enrich.js @@ -0,0 +1,12 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.attachIso20022ToLeaves = attachIso20022ToLeaves; +const checkpoint_core_1 = require("@dbis/checkpoint-core"); +function attachIso20022ToLeaves(leaves) { + if (process.env.CHECKPOINT_ISO_ENRICH === '0') + return; + for (const leaf of leaves) { + if (!leaf.iso20022) + leaf.iso20022 = (0, checkpoint_core_1.mapPaymentLeafToCanonical)(leaf); + } +} diff --git a/services/checkpoint-aggregator/dist/iso20022LocalStore.js b/services/checkpoint-aggregator/dist/iso20022LocalStore.js new file mode 100644 index 0000000..254669f --- /dev/null +++ b/services/checkpoint-aggregator/dist/iso20022LocalStore.js @@ -0,0 +1,49 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.persistIso20022ForLeaves = persistIso20022ForLeaves; +const crypto_1 = require("crypto"); +const fs_1 = require("fs"); +const path_1 = require("path"); +const checkpoint_core_1 = require("@dbis/checkpoint-core"); +function storeDir() { + const raw = process.env.OMNL_ISO20022_STORE_DIR?.trim(); + if (raw) + return (0, path_1.resolve)(raw); + const root = process.env.PROXMOX_ROOT || process.cwd(); + return (0, path_1.resolve)(root, 'config/iso20022-omnl/messages'); +} +function retentionUntil(fromIso) { + const y = parseInt(process.env.OMNL_ISO20022_RETENTION_YEARS || '10', 10); + const d = new Date(fromIso); + d.setFullYear(d.getFullYear() + (Number.isFinite(y) && y > 0 ? y : 10)); + return d.toISOString(); +} +function persistIso20022ForLeaves(leaves, batchId) { + if (process.env.CHECKPOINT_ISO_OMNL_STORE === '0') + return []; + const dir = storeDir(); + (0, fs_1.mkdirSync)(dir, { recursive: true }); + const out = []; + for (const leaf of leaves) { + const iso = leaf.iso20022 ?? (0, checkpoint_core_1.mapPaymentLeafToCanonical)(leaf); + const xml = (0, checkpoint_core_1.canonicalToPacs008Xml)(iso); + const id = (0, crypto_1.randomUUID)(); + const receivedAt = new Date().toISOString(); + const record = { + id, + messageType: 'pacs.008', + receivedAt, + retentionUntil: retentionUntil(receivedAt), + uetr: iso.uetr, + instructionId: iso.instructionId, + settlementOrChainRef: leaf.txHash, + accountingRef: `checkpoint-batch-${batchId.toString()}`, + payloadSha256: (0, crypto_1.createHash)('sha256').update(xml, 'utf8').digest('hex'), + payload: xml, + canonical: iso, + }; + (0, fs_1.writeFileSync)((0, path_1.join)(dir, `${id}.json`), JSON.stringify(record, null, 2), 'utf8'); + out.push({ id, chain138TxHash: leaf.txHash, instructionId: iso.instructionId }); + } + return out; +} diff --git a/services/checkpoint-aggregator/dist/leafCodec.js b/services/checkpoint-aggregator/dist/leafCodec.js new file mode 100644 index 0000000..b555687 --- /dev/null +++ b/services/checkpoint-aggregator/dist/leafCodec.js @@ -0,0 +1,60 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.effectiveValue = effectiveValue; +exports.leafHashes = leafHashes; +exports.paymentsRoot = paymentsRoot; +exports.leavesToTuples = leavesToTuples; +exports.encodeExtensionLeavesV1 = encodeExtensionLeavesV1; +exports.encodeExtensionLeavesV2 = encodeExtensionLeavesV2; +const ethers_1 = require("ethers"); +const checkpoint_core_1 = require("@dbis/checkpoint-core"); +function effectiveValue(leaf) { + return (0, checkpoint_core_1.effectiveTokenOrNativeWei)(leaf); +} +function leafHashes(chainId, leaves) { + return leaves.map((l) => (0, checkpoint_core_1.paymentLeafV1Hash)(chainId, { + txHash: l.txHash, + from: l.from, + to: l.to, + value: effectiveValue(l), + blockNumber: l.blockNumber, + blockTimestamp: l.blockTimestamp, + gasUsed: l.gasUsed, + success: l.success, + })); +} +function paymentsRoot(chainId, leaves) { + return (0, checkpoint_core_1.buildMerkleRoot)(leafHashes(chainId, leaves)); +} +const PAYMENT_LEAF_TUPLE = 'tuple(bytes32,address,address,uint256,uint256,uint64,uint256,bool)'; +function leavesToTuples(leaves) { + return leaves.map((l) => [ + l.txHash, + l.from, + l.to, + effectiveValue(l), + BigInt(l.blockNumber), + BigInt(l.blockTimestamp), + l.gasUsed, + l.success, + ]); +} +function encodeExtensionLeavesV1(leaves) { + return ethers_1.ethers.AbiCoder.defaultAbiCoder().encode([PAYMENT_LEAF_TUPLE + '[]'], [leavesToTuples(leaves)]); +} +function encodeExtensionLeavesV2(leaves) { + const PAYMENT_LEAF_V2_TUPLE = 'tuple(bytes32,address,address,address,uint256,uint256,uint64,uint256,bool,uint32)'; + const tuples = leaves.map((l) => [ + l.txHash, + l.from, + l.to, + l.token ?? ethers_1.ethers.ZeroAddress, + effectiveValue(l), + BigInt(l.blockNumber), + BigInt(l.blockTimestamp), + l.gasUsed, + l.success, + l.tokenLogIndex ?? 0, + ]); + return ethers_1.ethers.AbiCoder.defaultAbiCoder().encode(['bytes1', PAYMENT_LEAF_V2_TUPLE + '[]'], [ethers_1.ethers.toBeHex(0x02, 1), tuples]); +} diff --git a/services/checkpoint-aggregator/dist/participantSurface.js b/services/checkpoint-aggregator/dist/participantSurface.js new file mode 100644 index 0000000..a1001f1 --- /dev/null +++ b/services/checkpoint-aggregator/dist/participantSurface.js @@ -0,0 +1,99 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.surfaceParticipantsOnMainnet = surfaceParticipantsOnMainnet; +exports.surfaceTopLevelZeroEth = surfaceTopLevelZeroEth; +const ethers_1 = require("ethers"); +const checkpoint_core_1 = require("@dbis/checkpoint-core"); +const leafCodec_1 = require("./leafCodec"); +const SURFACE_ABI = [ + 'function notifyBatch(uint64 batchId, tuple(address participant,bytes32 chain138TxHash,bytes32 instructionId,uint8 direction,uint64 batchId,uint256 valueWei,uint64 valueUsdE8)[] items) external', + 'function notified(bytes32) view returns (bool)', + 'function notificationKey(address participant, bytes32 chain138TxHash, uint8 direction) view returns (bytes32)', +]; +const DIRECTION_CREDITED = 0; +const DIRECTION_DEBITED = 1; +function buildNotifications(leaves, batchId) { + const items = []; + for (const leaf of leaves) { + const iso = leaf.iso20022 ?? (0, checkpoint_core_1.mapPaymentLeafToCanonical)(leaf); + const usd = leaf.totalTransfersUsd ?? leaf.valueUsd ?? leaf.nativeValueUsd ?? '0'; + const valueWei = (0, leafCodec_1.effectiveValue)(leaf); + const valueUsdE8 = (0, checkpoint_core_1.usdStringToE8)(usd); + items.push({ + participant: leaf.from, + chain138TxHash: leaf.txHash, + instructionId: iso.instructionIdBytes32, + direction: DIRECTION_DEBITED, + batchId, + valueWei, + valueUsdE8, + }); + items.push({ + participant: leaf.to, + chain138TxHash: leaf.txHash, + instructionId: iso.instructionIdBytes32, + direction: DIRECTION_CREDITED, + batchId, + valueWei, + valueUsdE8, + }); + } + return items; +} +async function surfaceParticipantsOnMainnet(wallet, surfaceAddress, batchId, leaves) { + if (!surfaceAddress || leaves.length === 0) + return null; + const surface = new ethers_1.ethers.Contract(surfaceAddress, SURFACE_ABI, wallet); + const all = buildNotifications(leaves, batchId); + const pending = []; + for (const item of all) { + const key = await surface.notificationKey(item.participant, item.chain138TxHash, item.direction); + try { + if (await surface.notified(key)) + continue; + } + catch { + /* continue */ + } + pending.push(item); + } + if (pending.length === 0) + return null; + const tx = await surface.notifyBatch(batchId, pending.map((p) => ({ + participant: p.participant, + chain138TxHash: p.chain138TxHash, + instructionId: p.instructionId, + direction: p.direction, + batchId: p.batchId, + valueWei: p.valueWei, + valueUsdE8: p.valueUsdE8, + })), { gasLimit: 5000000n }); + await tx.wait(); + return tx.hash; +} +/** + * Optional: one top-level 0 ETH tx TO each participant (shows on Etherscan Transactions tab). + * Cost: ~21k gas per participant notification. + */ +async function surfaceTopLevelZeroEth(wallet, leaves) { + const seen = new Set(); + let count = 0; + for (const leaf of leaves) { + for (const addr of [leaf.from, leaf.to]) { + const key = addr.toLowerCase(); + if (seen.has(key)) + continue; + seen.add(key); + const data = ethers_1.ethers.AbiCoder.defaultAbiCoder().encode(['bytes32', 'uint256'], [leaf.txHash, leaf.blockNumber]); + const tx = await wallet.sendTransaction({ + to: addr, + value: 0n, + data, + gasLimit: 50000n, + }); + await tx.wait(); + count++; + } + } + return count; +} diff --git a/services/checkpoint-aggregator/dist/receiptsRoot.js b/services/checkpoint-aggregator/dist/receiptsRoot.js new file mode 100644 index 0000000..56003fa --- /dev/null +++ b/services/checkpoint-aggregator/dist/receiptsRoot.js @@ -0,0 +1,33 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.receiptLeafHash = receiptLeafHash; +exports.receiptsRoot = receiptsRoot; +exports.fetchReceiptMeta = fetchReceiptMeta; +exports.attachReceiptMeta = attachReceiptMeta; +const ethers_1 = require("ethers"); +const checkpoint_core_1 = require("@dbis/checkpoint-core"); +function receiptLeafHash(txHash, meta) { + return ethers_1.ethers.keccak256(ethers_1.ethers.AbiCoder.defaultAbiCoder().encode(['bytes32', 'bytes32', 'uint32'], [txHash, meta.receiptHash, meta.logCount])); +} +function receiptsRoot(txHashes, metas) { + if (txHashes.length === 0) + return ethers_1.ethers.ZeroHash; + const hashes = txHashes.map((h, i) => receiptLeafHash(h, metas[i])); + return (0, checkpoint_core_1.buildMerkleRoot)(hashes); +} +async function fetchReceiptMeta(provider, txHash) { + const receipt = await provider.getTransactionReceipt(txHash); + if (!receipt) { + return { logCount: 0, receiptHash: ethers_1.ethers.ZeroHash }; + } + const logCount = receipt.logs.length; + const receiptHash = ethers_1.ethers.keccak256(ethers_1.ethers.AbiCoder.defaultAbiCoder().encode(['bytes32', 'uint256', 'uint8', 'uint32'], [receipt.blockHash, receipt.gasUsed, receipt.status ?? 0, logCount])); + return { logCount, receiptHash }; +} +async function attachReceiptMeta(provider, leaves) { + for (const leaf of leaves) { + const meta = await fetchReceiptMeta(provider, leaf.txHash); + leaf.logCount = meta.logCount; + leaf.receiptHash = meta.receiptHash; + } +} diff --git a/services/checkpoint-aggregator/dist/recordBlockActivity.js b/services/checkpoint-aggregator/dist/recordBlockActivity.js new file mode 100644 index 0000000..31b8157 --- /dev/null +++ b/services/checkpoint-aggregator/dist/recordBlockActivity.js @@ -0,0 +1,116 @@ +#!/usr/bin/env node +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +/** + * One-shot: record all txs in a Chain 138 block on AddressActivityRegistry (+ optional v1 mirror). + */ +const ethers_1 = require("ethers"); +const checkpoint_core_1 = require("@dbis/checkpoint-core"); +const dualMirror_1 = require("./dualMirror"); +const activityRegistry_1 = require("./activityRegistry"); +const activityRegistryV2_1 = require("./activityRegistryV2"); +const iso20022Enrich_1 = require("./iso20022Enrich"); +const receiptsRoot_1 = require("./receiptsRoot"); +const usdEnrich_1 = require("./usdEnrich"); +function arg(name) { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : undefined; +} +async function main() { + const block = parseInt(arg('block') || '0', 10); + const batchId = BigInt(arg('batch-id') || '0'); + const registry = arg('registry') || ''; + const rpc138 = arg('rpc138') || 'http://192.168.11.211:8545'; + const rpc1 = arg('rpc1') || 'https://ethereum-rpc.publicnode.com'; + const mirror = arg('mirror') || ''; + const dualMirror = process.argv.includes('--dual-mirror'); + const dryRun = process.argv.includes('--dry-run'); + const pk = process.env.PRIVATE_KEY; + if (!pk && !dryRun) + throw new Error('PRIVATE_KEY required (or pass --dry-run)'); + if (!registry && !dryRun) + throw new Error('--registry required'); + const chain138 = new ethers_1.ethers.JsonRpcProvider(rpc138); + const blk = await chain138.getBlock(block, true); + if (!blk) + throw new Error(`block ${block} not found`); + const leaves = []; + const entries = blk.prefetchedTransactions?.length + ? blk.prefetchedTransactions + : blk.transactions ?? []; + for (const entry of entries) { + const tx = typeof entry === 'string' ? await chain138.getTransaction(entry) : entry; + if (!tx || typeof tx === 'string') + continue; + const receipt = await chain138.getTransactionReceipt(tx.hash); + const meta = await (0, receiptsRoot_1.fetchReceiptMeta)(chain138, tx.hash); + const leaf = { + txHash: tx.hash, + from: tx.from, + to: tx.to ?? ethers_1.ethers.ZeroAddress, + value: tx.value, + blockNumber: block, + blockTimestamp: Number(blk.timestamp), + gasUsed: receipt?.gasUsed ?? 0n, + success: receipt?.status === 1, + logCount: meta.logCount, + receiptHash: meta.receiptHash, + }; + const blockscoutApi = process.env.CHECKPOINT_BLOCKSCOUT_API || 'https://explorer.d-bis.org/api/v2'; + const { allErc20 } = await (0, usdEnrich_1.enrichLeafFromBlockscoutApi)(leaf, blockscoutApi, true); + await (0, usdEnrich_1.enrichLeafUsd)(leaf, { + apiBaseUrl: process.env.CHECKPOINT_TOKEN_AGGREGATION_URL || + process.env.TOKEN_AGGREGATION_API_URL || + 'https://explorer.d-bis.org/api/v1', + chainId: 138, + enabled: process.env.CHECKPOINT_USD_ENRICH !== '0', + requestDelayMs: 80, + blockscoutApi, + useBlockscout: true, + }, allErc20); + leaves.push(leaf); + if (!dryRun) { + console.log('leaf', tx.hash, 'wei', leaf.value.toString(), 'usdE8', (0, checkpoint_core_1.usdStringToE8)(leaf.totalTransfersUsd ?? leaf.valueUsd).toString()); + } + } + if (leaves.length === 0) { + console.log('No transactions in block', block); + return; + } + if (dryRun) { + console.log(JSON.stringify({ + dryRun: true, + block, + batchId: batchId.toString(), + registry: registry || null, + txCount: leaves.length, + leaves: leaves.map((l) => ({ + txHash: l.txHash, + from: l.from, + to: l.to, + valueWei: l.value?.toString(), + valueUsd: l.valueUsd, + totalTransfersUsd: l.totalTransfersUsd, + usdE8: (0, checkpoint_core_1.usdStringToE8)(l.totalTransfersUsd ?? l.valueUsd).toString(), + })), + }, null, 2)); + return; + } + const wallet = new ethers_1.ethers.Wallet(pk, new ethers_1.ethers.JsonRpcProvider(rpc1)); + (0, iso20022Enrich_1.attachIso20022ToLeaves)(leaves); + const activityTx = await (0, activityRegistry_1.recordActivityBatch)(wallet, registry, batchId, leaves); + console.log('AddressActivityRegistry tx', activityTx); + const registryV2 = process.env.ADDRESS_ACTIVITY_REGISTRY_V2_MAINNET || ''; + if (registryV2) { + const isoTx = await (0, activityRegistryV2_1.recordIsoAttestationBatch)(wallet, registryV2, batchId, leaves); + console.log('AddressActivityRegistryV2 tx', isoTx); + } + if (dualMirror && mirror) { + const mirrorTx = await (0, dualMirror_1.dualWriteV1Mirror)(wallet, mirror, leaves); + console.log('TransactionMirror tx', mirrorTx); + } +} +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/services/checkpoint-aggregator/dist/scanState.js b/services/checkpoint-aggregator/dist/scanState.js new file mode 100644 index 0000000..7a4da8d --- /dev/null +++ b/services/checkpoint-aggregator/dist/scanState.js @@ -0,0 +1,58 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.loadScanState = loadScanState; +exports.saveScanState = saveScanState; +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +function loadScanState(filePath, defaultFrom) { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + if (typeof parsed.lastScannedBlock === 'number' && parsed.lastScannedBlock >= 0) { + return parsed; + } + } + catch { + // fresh + } + return { lastScannedBlock: defaultFrom - 1, updatedAt: new Date().toISOString() }; +} +function saveScanState(filePath, state) { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + state.updatedAt = new Date().toISOString(); + fs.writeFileSync(filePath, JSON.stringify(state, null, 2)); +} diff --git a/services/checkpoint-aggregator/dist/tokenTransfers.js b/services/checkpoint-aggregator/dist/tokenTransfers.js new file mode 100644 index 0000000..7a705a7 --- /dev/null +++ b/services/checkpoint-aggregator/dist/tokenTransfers.js @@ -0,0 +1,42 @@ +"use strict"; +/** + * Blockscout token-transfer enrichment — Chain 138 txs are mostly ERC-20; native `value` is often 0. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.pickPrimaryTokenTransfer = pickPrimaryTokenTransfer; +exports.fetchTokenTransfersForTx = fetchTokenTransfersForTx; +/** Pick the largest ERC-20 transfer in a tx (typical payment). */ +function pickPrimaryTokenTransfer(items) { + let best = null; + for (const item of items) { + const token = item.token?.address; + const raw = item.total?.value; + if (!token || !raw) + continue; + const value = BigInt(raw); + if (value === 0n) + continue; + const decimals = parseInt(item.token?.decimals || '18', 10); + const summary = { + token, + tokenSymbol: item.token?.symbol || '', + tokenDecimals: Number.isFinite(decimals) ? decimals : 18, + from: item.from?.hash || '', + to: item.to?.hash || '', + value, + logIndex: item.log_index ?? 0, + }; + if (!best || summary.value > best.value) + best = summary; + } + return best; +} +async function fetchTokenTransfersForTx(apiBase, txHash) { + const base = apiBase.replace(/\/$/, ''); + const url = `${base}/transactions/${txHash}/token-transfers`; + const res = await fetch(url); + if (!res.ok) + return null; + const body = (await res.json()); + return pickPrimaryTokenTransfer(body.items ?? []); +} diff --git a/services/checkpoint-aggregator/dist/usdEnrich.js b/services/checkpoint-aggregator/dist/usdEnrich.js new file mode 100644 index 0000000..0e505b3 --- /dev/null +++ b/services/checkpoint-aggregator/dist/usdEnrich.js @@ -0,0 +1,79 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.enrichLeafFromBlockscoutApi = enrichLeafFromBlockscoutApi; +exports.enrichLeafUsd = enrichLeafUsd; +const checkpoint_core_1 = require("@dbis/checkpoint-core"); +/** Single Blockscout fetch: all transfers + primary token fields on leaf. */ +async function enrichLeafFromBlockscoutApi(leaf, blockscoutApi, useBlockscout) { + if (!useBlockscout) + return { allErc20: [] }; + const allErc20 = await (0, checkpoint_core_1.fetchAllTokenTransfersForTx)(blockscoutApi, leaf.txHash); + const primary = (0, checkpoint_core_1.pickPrimaryFromSummaries)(allErc20); + if (primary) { + leaf.token = primary.token; + leaf.tokenSymbol = primary.tokenSymbol; + leaf.tokenDecimals = primary.tokenDecimals; + leaf.tokenValue = primary.value; + leaf.tokenLogIndex = primary.logIndex; + } + return { allErc20 }; +} +async function enrichLeafUsd(leaf, cfg, preloaded) { + if (cfg.enabled === false) + return; + let allErc20 = preloaded ?? []; + if (!preloaded?.length && cfg.useBlockscout) { + allErc20 = await (0, checkpoint_core_1.fetchAllTokenTransfersForTx)(cfg.blockscoutApi, leaf.txHash); + } + else if (!preloaded?.length && leaf.token && leaf.tokenValue != null && leaf.tokenValue > 0n) { + allErc20 = [ + { + token: leaf.token, + tokenSymbol: leaf.tokenSymbol || '', + tokenDecimals: leaf.tokenDecimals ?? 18, + from: leaf.from, + to: leaf.to, + value: leaf.tokenValue, + logIndex: leaf.tokenLogIndex ?? 0, + }, + ]; + } + const record = { + txHash: leaf.txHash, + from: leaf.from, + to: leaf.to, + value: leaf.value.toString(), + nativeValueWei: leaf.value.toString(), + blockNumber: leaf.blockNumber, + blockTimestamp: leaf.blockTimestamp, + gasUsed: leaf.gasUsed.toString(), + success: leaf.success, + token: leaf.token, + tokenSymbol: leaf.tokenSymbol, + tokenDecimals: leaf.tokenDecimals, + tokenValue: leaf.tokenValue?.toString(), + tokenLogIndex: leaf.tokenLogIndex, + }; + const enriched = await (0, checkpoint_core_1.enrichLeafUsdFields)(cfg, record, allErc20); + if (enriched.valueUsd != null) + leaf.valueUsd = String(enriched.valueUsd); + if (enriched.nativeValueUsd != null) + leaf.nativeValueUsd = String(enriched.nativeValueUsd); + if (enriched.tokenValueUsd != null) + leaf.tokenValueUsd = String(enriched.tokenValueUsd); + if (enriched.nativePriceUsd != null) + leaf.nativePriceUsd = Number(enriched.nativePriceUsd); + if (enriched.tokenPriceUsd != null) + leaf.tokenPriceUsd = Number(enriched.tokenPriceUsd); + if (enriched.priceSource != null) + leaf.priceSource = String(enriched.priceSource); + if (enriched.priceEffectiveTimestamp != null) { + leaf.priceEffectiveTimestamp = String(enriched.priceEffectiveTimestamp); + } + if (enriched.totalTransfersUsd != null) + leaf.totalTransfersUsd = String(enriched.totalTransfersUsd); + if (Array.isArray(enriched.transfers)) + leaf.transfers = enriched.transfers; + if (enriched.usdEnrichedAt != null) + leaf.usdEnrichedAt = String(enriched.usdEnrichedAt); +} diff --git a/services/checkpoint-aggregator/package.json b/services/checkpoint-aggregator/package.json new file mode 100644 index 0000000..e19aa2f --- /dev/null +++ b/services/checkpoint-aggregator/package.json @@ -0,0 +1,21 @@ +{ + "name": "checkpoint-aggregator", + "version": "0.1.0", + "private": true, + "description": "Chain 138 → mainnet checkpoint batches (default 10 txs)", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts" + }, + "dependencies": { + "@dbis/checkpoint-core": "workspace:*", + "dotenv": "^16.4.5", + "ethers": "^6.13.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.4.0" + } +} diff --git a/services/checkpoint-aggregator/pnpm-lock.yaml b/services/checkpoint-aggregator/pnpm-lock.yaml new file mode 100644 index 0000000..80bafbd --- /dev/null +++ b/services/checkpoint-aggregator/pnpm-lock.yaml @@ -0,0 +1,136 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@dbis/checkpoint-core': + specifier: file:../../packages/checkpoint-core + version: file:../../packages/checkpoint-core + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + ethers: + specifier: ^6.13.0 + version: 6.16.0 + devDependencies: + '@types/node': + specifier: ^20.11.0 + version: 20.19.41 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + +packages: + + '@adraffy/ens-normalize@1.10.1': + resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} + + '@dbis/checkpoint-core@file:../../packages/checkpoint-core': + resolution: {directory: ../../packages/checkpoint-core, type: directory} + + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + + '@types/node@20.19.41': + resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + + '@types/node@22.7.5': + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + + aes-js@4.0.0-beta.5: + resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + ethers@6.16.0: + resolution: {integrity: sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==} + engines: {node: '>=14.0.0'} + + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@adraffy/ens-normalize@1.10.1': {} + + '@dbis/checkpoint-core@file:../../packages/checkpoint-core': + dependencies: + ethers: 6.16.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + + '@noble/hashes@1.3.2': {} + + '@types/node@20.19.41': + dependencies: + undici-types: 6.21.0 + + '@types/node@22.7.5': + dependencies: + undici-types: 6.19.8 + + aes-js@4.0.0-beta.5: {} + + dotenv@16.6.1: {} + + ethers@6.16.0: + dependencies: + '@adraffy/ens-normalize': 1.10.1 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@types/node': 22.7.5 + aes-js: 4.0.0-beta.5 + tslib: 2.7.0 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + tslib@2.7.0: {} + + typescript@5.9.3: {} + + undici-types@6.19.8: {} + + undici-types@6.21.0: {} + + ws@8.17.1: {} diff --git a/services/checkpoint-aggregator/src/activityRegistry.ts b/services/checkpoint-aggregator/src/activityRegistry.ts new file mode 100644 index 0000000..7f7acfa --- /dev/null +++ b/services/checkpoint-aggregator/src/activityRegistry.ts @@ -0,0 +1,50 @@ +import { ethers } from 'ethers'; +import { usdStringToE8 } from '@dbis/checkpoint-core'; +import type { PaymentLeaf } from './ingress/types'; +import { effectiveValue } from './leafCodec'; + +const REGISTRY_ABI = [ + 'function recordBatch(uint64 batchId, tuple(bytes32 txHash,address from,address to,uint256 valueWei,uint256 blockNumber138,uint64 blockTimestamp138,uint64 valueUsdE8,uint32 logCount,bytes32 receiptHash)[] records) external', + 'function recorded(bytes32) view returns (bool)', +]; + +function activityRecord(leaf: PaymentLeaf) { + const usd = leaf.totalTransfersUsd ?? leaf.valueUsd ?? leaf.nativeValueUsd ?? '0'; + return { + txHash: leaf.txHash, + from: leaf.from, + to: leaf.to, + valueWei: effectiveValue(leaf), + blockNumber138: BigInt(leaf.blockNumber), + blockTimestamp138: BigInt(leaf.blockTimestamp), + valueUsdE8: usdStringToE8(usd), + logCount: leaf.logCount ?? 0, + receiptHash: leaf.receiptHash ?? ethers.ZeroHash, + }; +} + +export async function recordActivityBatch( + wallet: ethers.Wallet, + registryAddress: string, + batchId: bigint, + leaves: PaymentLeaf[] +): Promise { + if (!registryAddress || leaves.length === 0) return null; + const registry = new ethers.Contract(registryAddress, REGISTRY_ABI, wallet); + const pending: PaymentLeaf[] = []; + for (const leaf of leaves) { + if (!leaf.txHash) continue; + try { + if (await registry.recorded(leaf.txHash)) continue; + } catch { + /* continue */ + } + pending.push(leaf); + } + if (pending.length === 0) return null; + + const records = pending.map(activityRecord); + const tx = await registry.recordBatch(batchId, records, { gasLimit: 2_500_000n }); + await tx.wait(); + return tx.hash as string; +} diff --git a/services/checkpoint-aggregator/src/activityRegistryV2.ts b/services/checkpoint-aggregator/src/activityRegistryV2.ts new file mode 100644 index 0000000..36bcb36 --- /dev/null +++ b/services/checkpoint-aggregator/src/activityRegistryV2.ts @@ -0,0 +1,62 @@ +import { ethers } from 'ethers'; +import { + usdStringToE8, + mapPaymentLeafToCanonical, + type CanonicalPaymentMessage, +} from '@dbis/checkpoint-core'; +import type { PaymentLeaf } from './ingress/types'; +import { effectiveValue } from './leafCodec'; + +const REGISTRY_V2_ABI = [ + 'function recordBatch(uint64 batchId, tuple(bytes32 txHash,address from,address to,uint256 valueWei,uint256 blockNumber138,uint64 blockTimestamp138,uint64 valueUsdE8,uint32 logCount,bytes32 receiptHash,bytes32 instructionId,bytes32 endToEndIdHash,bytes32 uetr,bytes32 payloadHash,uint8 msgTypeCode,bytes32 debtorRefHash,bytes32 creditorRefHash,bytes32 purposeHash)[] records) external', + 'function recorded(bytes32) view returns (bool)', +]; + +function isoRecord(leaf: PaymentLeaf, iso: CanonicalPaymentMessage) { + const usd = leaf.totalTransfersUsd ?? leaf.valueUsd ?? leaf.nativeValueUsd ?? '0'; + return { + txHash: leaf.txHash, + from: leaf.from, + to: leaf.to, + valueWei: effectiveValue(leaf), + blockNumber138: BigInt(leaf.blockNumber), + blockTimestamp138: BigInt(leaf.blockTimestamp), + valueUsdE8: usdStringToE8(usd), + logCount: leaf.logCount ?? 0, + receiptHash: leaf.receiptHash ?? ethers.ZeroHash, + instructionId: iso.instructionIdBytes32, + endToEndIdHash: iso.endToEndIdHash, + uetr: iso.uetrBytes32, + payloadHash: iso.payloadHash, + msgTypeCode: iso.msgTypeCode, + debtorRefHash: iso.debtorRefHash, + creditorRefHash: iso.creditorRefHash, + purposeHash: iso.purposeHash, + }; +} + +export async function recordIsoAttestationBatch( + wallet: ethers.Wallet, + registryV2Address: string, + batchId: bigint, + leaves: PaymentLeaf[] +): Promise { + if (!registryV2Address || leaves.length === 0) return null; + const registry = new ethers.Contract(registryV2Address, REGISTRY_V2_ABI, wallet); + const pending: Array<{ leaf: PaymentLeaf; iso: CanonicalPaymentMessage }> = []; + for (const leaf of leaves) { + if (!leaf.txHash) continue; + try { + if (await registry.recorded(leaf.txHash)) continue; + } catch { + /* continue */ + } + const iso = leaf.iso20022 ?? mapPaymentLeafToCanonical(leaf); + pending.push({ leaf, iso }); + } + if (pending.length === 0) return null; + const records = pending.map(({ leaf, iso }) => isoRecord(leaf, iso)); + const tx = await registry.recordBatch(batchId, records, { gasLimit: 3_500_000n }); + await tx.wait(); + return tx.hash as string; +} diff --git a/services/checkpoint-aggregator/src/blockscout.ts b/services/checkpoint-aggregator/src/blockscout.ts new file mode 100644 index 0000000..af9222e --- /dev/null +++ b/services/checkpoint-aggregator/src/blockscout.ts @@ -0,0 +1,94 @@ +/** + * Blockscout — Chain 138 Besu RPC often returns empty tx lists in eth_getBlockByNumber. + * Uses global /transactions pagination (fast) instead of per-block queries. + */ +export interface BlockscoutTx { + hash: string; + from: string; + to: string; + value: bigint; + blockNumber: number; + blockTimestamp: number; +} + +interface BlockscoutItem { + hash: string; + value: string; + block_number: number; + timestamp: string; + from: { hash: string }; + to: { hash: string } | null; +} + +function parseItem(item: BlockscoutItem): BlockscoutTx | null { + if (!item.hash || !item.from?.hash) return null; + const ts = Math.floor(new Date(item.timestamp).getTime() / 1000); + return { + hash: item.hash, + from: item.from.hash, + to: item.to?.hash ?? '0x0000000000000000000000000000000000000000', + value: BigInt(item.value || '0'), + blockNumber: item.block_number, + blockTimestamp: Number.isFinite(ts) ? ts : 0, + }; +} + +/** + * Fetch all validated txs with block_number in [fromBlock, toBlock] (inclusive). + * Walks newest-first from Blockscout until block_number < fromBlock. + */ +export async function fetchTransactionsInBlockRange( + apiBase: string, + fromBlock: number, + toBlock: number +): Promise { + const base = apiBase.replace(/\/$/, ''); + const out: BlockscoutTx[] = []; + const seen = new Set(); + let url: string | null = `${base}/transactions?filter=validated`; + let pages = 0; + const maxPages = parseInt(process.env.CHECKPOINT_BLOCKSCOUT_MAX_PAGES || '5000', 10); + + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + + while (url && pages < maxPages) { + pages++; + let res = await fetch(url); + for (let attempt = 0; res.status === 429 && attempt < 5; attempt++) { + await sleep(2000 * (attempt + 1)); + res = await fetch(url); + } + if (!res.ok) { + throw new Error(`Blockscout ${res.status} ${url}`); + } + await sleep(parseInt(process.env.CHECKPOINT_BLOCKSCOUT_PAGE_DELAY_MS || '80', 10)); + const body = (await res.json()) as { + items: BlockscoutItem[]; + next_page_params?: Record; + }; + let stop = false; + for (const item of body.items ?? []) { + const bn = item.block_number; + if (bn < fromBlock) { + stop = true; + break; + } + if (bn > toBlock) continue; + const tx = parseItem(item); + if (!tx || seen.has(tx.hash.toLowerCase())) continue; + seen.add(tx.hash.toLowerCase()); + out.push(tx); + } + if (stop) break; + const next = body.next_page_params; + if (!next) break; + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(next)) { + if (v !== null && v !== undefined) q.set(k, String(v)); + } + url = `${base}/transactions?${q.toString()}`; + } + + out.sort((a, b) => a.blockNumber - b.blockNumber || a.blockTimestamp - b.blockTimestamp); + return out; +} diff --git a/services/checkpoint-aggregator/src/config.ts b/services/checkpoint-aggregator/src/config.ts new file mode 100644 index 0000000..7d711e3 --- /dev/null +++ b/services/checkpoint-aggregator/src/config.ts @@ -0,0 +1,102 @@ +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config(); + +function int(name: string, fallback: number): number { + const v = process.env[name]; + return v !== undefined && v !== '' ? parseInt(v, 10) : fallback; +} + +function bigint(name: string, fallback: string): bigint { + const v = process.env[name]; + return BigInt(v !== undefined && v !== '' ? v : fallback); +} + +const repoRoot = process.env.PROXMOX_ROOT || path.resolve(__dirname, '../../../../..'); + +export const checkpointAggregatorConfig = { + chain138Rpc: process.env.CHAIN138_RPC_URL || 'http://192.168.11.211:8545', + mainnetRpc: + process.env.MAINNET_RPC_URL || + process.env.ETHEREUM_MAINNET_RPC || + 'https://ethereum-rpc.publicnode.com', + checkpointProxy: process.env.CHAIN138_MAINNET_CHECKPOINT_PROXY || '', + blockOracleExtension: process.env.EXT_BLOCK_ORACLE || '', + batchSize: int('CHECKPOINT_BATCH_SIZE', 10), + maxWaitMs: int('CHECKPOINT_MAX_WAIT_MS', 300_000), + confirmationBlocks: int('CHECKPOINT_CONFIRMATION_BLOCKS', 2), + minValueWei: bigint('CHECKPOINT_MIN_VALUE_WEI', '0'), + blockscoutApi: + process.env.CHECKPOINT_BLOCKSCOUT_API || 'https://explorer.d-bis.org/api/v2', + useBlockscout: process.env.CHECKPOINT_USE_BLOCKSCOUT !== '0', + scanFromBlock: int('CHECKPOINT_SCAN_FROM_BLOCK', 0), + scanChunkBlocks: int('CHECKPOINT_SCAN_CHUNK_BLOCKS', 100), + scanPollMs: int('CHECKPOINT_SCAN_POLL_MS', 15_000), + scanStatePath: + process.env.CHECKPOINT_SCAN_STATE_PATH || + path.join(repoRoot, 'reports/status/checkpoint-aggregator-scan-state.json'), + ipfsApiUrl: process.env.CHECKPOINT_IPFS_API_URL || '', + payloadPublicUrl: process.env.CHECKPOINT_PAYLOAD_PUBLIC_URL || '', + batchPayloadDir: + process.env.CHECKPOINT_BATCH_PAYLOAD_DIR || + path.join(repoRoot, 'reports/checkpoint-indexer/batches'), + updateBlockOracle: process.env.CHECKPOINT_UPDATE_BLOCK_ORACLE !== '0', + requireValidatorSigs: process.env.CHECKPOINT_REQUIRE_VALIDATOR_SIGS !== 'false', + /** hashes (default) | commitment | leaves | leaves-v2 — official hub on 0xe2D6… */ + submitMode: (process.env.CHECKPOINT_SUBMIT_MODE || 'hashes') as + | 'hashes' + | 'commitment' + | 'leaves' + | 'leaves-v2', + batchEmitter138: process.env.CHAIN138_BATCH_EMITTER || '', + ccipIngressEnabled: process.env.CHECKPOINT_CCIP_INGRESS === '1', + usdEnrichEnabled: process.env.CHECKPOINT_USD_ENRICH !== '0', + tokenAggregationApiUrl: + process.env.CHECKPOINT_TOKEN_AGGREGATION_URL || + process.env.TOKEN_AGGREGATION_API_URL || + 'https://explorer.d-bis.org/api/v1', + usdRequestDelayMs: int('CHECKPOINT_USD_REQUEST_DELAY_MS', 80), + dualWriteV1Mirror: process.env.CHECKPOINT_DUAL_WRITE_V1_MIRROR !== '0', + recordAddressActivity: + process.env.CHECKPOINT_RECORD_ADDRESS_ACTIVITY !== '0', + addressActivityRegistry: + process.env.ADDRESS_ACTIVITY_REGISTRY_MAINNET || '', + transactionMirrorMainnet: + process.env.TRANSACTION_MIRROR_MAINNET || + '0x4CF42c4F1dBa748601b8938be3E7ABD732E87cE9', + attachReceiptMeta: process.env.CHECKPOINT_ATTACH_RECEIPT_META !== '0', + isoEnrichEnabled: process.env.CHECKPOINT_ISO_ENRICH !== '0', + recordIsoAttestation: process.env.CHECKPOINT_RECORD_ISO_ATTESTATION !== '0', + addressActivityRegistryV2: process.env.ADDRESS_ACTIVITY_REGISTRY_V2_MAINNET || '', + participantSurface: process.env.CHAIN138_PARTICIPANT_SURFACE_MAINNET || '', + surfaceParticipants: process.env.CHECKPOINT_SURFACE_PARTICIPANTS !== '0', + surfaceTopLevelZeroEth: process.env.CHECKPOINT_SURFACE_TOPLEVEL_ZERO_ETH === '1', +} as const; + +export function assertAggregatorConfig(): void { + if (!process.env.PRIVATE_KEY) throw new Error('PRIVATE_KEY required'); + if (!checkpointAggregatorConfig.checkpointProxy) { + throw new Error('CHAIN138_MAINNET_CHECKPOINT_PROXY required'); + } + if (process.env.CHECKPOINT_RECORD_ADDRESS_ACTIVITY === '1' && + !checkpointAggregatorConfig.addressActivityRegistry) { + throw new Error( + 'CHECKPOINT_RECORD_ADDRESS_ACTIVITY=1 requires ADDRESS_ACTIVITY_REGISTRY_MAINNET (deploy: bash scripts/deployment/deploy-address-activity-registry.sh)' + ); + } + if ( + checkpointAggregatorConfig.dualWriteV1Mirror && + !checkpointAggregatorConfig.transactionMirrorMainnet + ) { + throw new Error('CHECKPOINT_DUAL_WRITE_V1_MIRROR=1 requires TRANSACTION_MIRROR_MAINNET'); + } + if ( + process.env.CHECKPOINT_RECORD_ISO_ATTESTATION === '1' && + !checkpointAggregatorConfig.addressActivityRegistryV2 + ) { + throw new Error( + 'CHECKPOINT_RECORD_ISO_ATTESTATION=1 requires ADDRESS_ACTIVITY_REGISTRY_V2_MAINNET' + ); + } +} diff --git a/services/checkpoint-aggregator/src/dualMirror.ts b/services/checkpoint-aggregator/src/dualMirror.ts new file mode 100644 index 0000000..51cbd2e --- /dev/null +++ b/services/checkpoint-aggregator/src/dualMirror.ts @@ -0,0 +1,42 @@ +import { ethers } from 'ethers'; +import type { PaymentLeaf } from './ingress/types'; +import { effectiveValue } from './leafCodec'; + +const MIRROR_ABI = [ + 'function mirrorBatchTransactions(bytes32[] calldata txHashes, address[] calldata froms, address[] calldata tos, uint256[] calldata values, uint256[] calldata blockNumbers, uint256[] calldata blockTimestamps, uint256[] calldata gasUseds, bool[] calldata successes, bytes[] calldata datas) external', + 'function processed(bytes32) view returns (bool)', +]; + +export async function dualWriteV1Mirror( + wallet: ethers.Wallet, + mirrorAddress: string, + leaves: PaymentLeaf[] +): Promise { + if (leaves.length === 0) return null; + const mirror = new ethers.Contract(mirrorAddress, MIRROR_ABI, wallet); + + const pending: PaymentLeaf[] = []; + for (const leaf of leaves) { + try { + if (await mirror.processed(leaf.txHash)) continue; + } catch { + /* continue */ + } + pending.push(leaf); + } + if (pending.length === 0) return null; + + const tx = await mirror.mirrorBatchTransactions( + pending.map((l) => l.txHash), + pending.map((l) => l.from), + pending.map((l) => l.to), + pending.map((l) => effectiveValue(l)), + pending.map((l) => l.blockNumber), + pending.map((l) => l.blockTimestamp), + pending.map((l) => l.gasUsed), + pending.map((l) => l.success), + pending.map((l) => '0x') + ); + await tx.wait(); + return tx.hash as string; +} diff --git a/services/checkpoint-aggregator/src/eip712.ts b/services/checkpoint-aggregator/src/eip712.ts new file mode 100644 index 0000000..96c6020 --- /dev/null +++ b/services/checkpoint-aggregator/src/eip712.ts @@ -0,0 +1,66 @@ +import { ethers } from 'ethers'; + +export type CheckpointHeaderStruct = { + batchId: bigint; + previousBatchId: bigint; + chainId: bigint; + checkpointBlock: bigint; + startBlock: bigint; + endBlock: bigint; + blockHash: string; + stateRoot: string; + paymentsRoot: string; + receiptsRoot: string; + txCount: number; + flags: number; + submittedAt: bigint; + submitter: string; + contentURI: string; +}; + +const BATCH_ATTESTATION_TYPES: Record = { + BatchAttestation: [ + { name: 'chainId', type: 'uint64' }, + { name: 'batchId', type: 'uint64' }, + { name: 'checkpointBlock', type: 'uint256' }, + { name: 'blockHash', type: 'bytes32' }, + { name: 'stateRoot', type: 'bytes32' }, + { name: 'paymentsRoot', type: 'bytes32' }, + { name: 'previousBatchId', type: 'uint64' }, + ], +}; + +/** Matches CheckpointEIP712.digest / ValidatorSigVerifierExtension.beforeSubmit. */ +export async function signBatchAttestation( + wallet: ethers.Wallet, + verifyingContract: string, + header: CheckpointHeaderStruct +): Promise { + const chainId = Number((await wallet.provider!.getNetwork()).chainId); + const domain = { + name: 'Chain138MainnetCheckpoint', + version: '2', + chainId, + verifyingContract, + }; + const message = { + chainId: header.chainId, + batchId: header.batchId, + checkpointBlock: header.checkpointBlock, + blockHash: header.blockHash, + stateRoot: header.stateRoot, + paymentsRoot: header.paymentsRoot, + previousBatchId: header.previousBatchId, + }; + const sig = await wallet.signTypedData(domain, BATCH_ATTESTATION_TYPES, message); + return normalizeEcdsaSignature(sig); +} + +/** OpenZeppelin ECDSA.recover expects v=27|28; ethers v6 may return 0|1. */ +export function normalizeEcdsaSignature(sig: string): string { + const bytes = ethers.getBytes(sig); + if (bytes.length !== 65) return sig; + const v = bytes[64]; + if (v < 27) bytes[64] = v + 27; + return ethers.hexlify(bytes); +} diff --git a/services/checkpoint-aggregator/src/index.ts b/services/checkpoint-aggregator/src/index.ts new file mode 100644 index 0000000..9907ed2 --- /dev/null +++ b/services/checkpoint-aggregator/src/index.ts @@ -0,0 +1,406 @@ +import { ethers } from 'ethers'; +import { assertAggregatorConfig, checkpointAggregatorConfig as cfg } from './config'; +import { signBatchAttestation, CheckpointHeaderStruct } from './eip712'; +import { publishBatchPayload, BatchPayload } from './ipfs'; +import { fetchTransactionsInBlockRange } from './blockscout'; +import { enrichLeafFromBlockscoutApi, enrichLeafUsd } from './usdEnrich'; +import { loadScanState, saveScanState, type ScanState } from './scanState'; +import { sumUsdStrings } from '@dbis/checkpoint-core'; +import { effectiveValue, paymentsRoot } from './leafCodec'; +import { AdminCheckpointIngress, buildCheckpointContract } from './ingress/adminIngress'; +import type { PaymentLeaf } from './ingress/types'; +import { dualWriteV1Mirror } from './dualMirror'; +import { recordActivityBatch } from './activityRegistry'; +import { recordIsoAttestationBatch } from './activityRegistryV2'; +import { attachIso20022ToLeaves } from './iso20022Enrich'; +import { persistIso20022ForLeaves } from './iso20022LocalStore'; +import { surfaceParticipantsOnMainnet, surfaceTopLevelZeroEth } from './participantSurface'; +import { attachReceiptMeta, fetchReceiptMeta, receiptsRoot } from './receiptsRoot'; + +assertAggregatorConfig(); + +const BLOCK_ORACLE_ABI = ['function setBlockHeader(uint256,bytes32,bytes32) external']; + +class CheckpointAggregator { + private chain138: ethers.JsonRpcProvider; + private mainnetWallet: ethers.Wallet; + private checkpoint: ethers.Contract; + private ingress: AdminCheckpointIngress; + private blockOracle?: ethers.Contract; + private buffer: PaymentLeaf[] = []; + private nextBatchId = 1n; + private hubImplVersion = 3n; + private flushTimer?: NodeJS.Timeout; + private scanning = false; + private readonly includedTxCache = new Map(); + + constructor() { + const mainnet = new ethers.JsonRpcProvider(cfg.mainnetRpc); + this.mainnetWallet = new ethers.Wallet(process.env.PRIVATE_KEY!, mainnet); + this.checkpoint = buildCheckpointContract( + this.mainnetWallet, + cfg.checkpointProxy, + cfg.submitMode + ); + this.ingress = new AdminCheckpointIngress(this.checkpoint, cfg.submitMode); + this.chain138 = new ethers.JsonRpcProvider(cfg.chain138Rpc); + if (cfg.blockOracleExtension) { + this.blockOracle = new ethers.Contract(cfg.blockOracleExtension, BLOCK_ORACLE_ABI, this.mainnetWallet); + } + } + + async start() { + await this.syncBatchId(); + const state = loadScanState(cfg.scanStatePath, cfg.scanFromBlock); + console.log( + `Checkpoint aggregator mode=${cfg.submitMode} batch=${cfg.batchSize} nextBatchId=${this.nextBatchId} impl=${this.hubImplVersion} blockscout=${cfg.useBlockscout} emitter=${cfg.batchEmitter138 || 'n/a'}` + ); + this.schedulePartialFlush(); + await this.runScanLoop(state); + } + + private async syncBatchId() { + const latest = await this.checkpoint.getLatestBatchId(); + this.nextBatchId = latest > 0n ? BigInt(latest) + 1n : 1n; + try { + this.hubImplVersion = await this.checkpoint.IMPLEMENTATION_VERSION(); + } catch { + this.hubImplVersion = 3n; + } + } + + private schedulePartialFlush() { + if (this.flushTimer) clearTimeout(this.flushTimer); + this.flushTimer = setTimeout(() => this.flush(true), cfg.maxWaitMs); + } + + private async runScanLoop(initialState: ScanState) { + const state: ScanState = { ...initialState }; + for (;;) { + try { + await this.scanCatchUp(state); + } catch (e) { + console.error('scanCatchUp', e); + } + await new Promise((r) => setTimeout(r, cfg.scanPollMs)); + } + } + + private async scanCatchUp(state: ScanState) { + if (this.scanning) return; + this.scanning = true; + try { + const head = await this.chain138.getBlockNumber(); + const safeHead = head - cfg.confirmationBlocks; + let from = state.lastScannedBlock + 1; + while (from <= safeHead) { + const to = Math.min(from + cfg.scanChunkBlocks - 1, safeHead); + let added = 0; + if (cfg.useBlockscout) { + added = await this.scanBlockscoutRange(from, to); + } else { + for (let b = from; b <= to; b++) added += await this.scanBlockRpc(b); + } + state.lastScannedBlock = to; + saveScanState(cfg.scanStatePath, state); + console.log(`Scanned blocks ${from}-${to} (+${added} txs) buffer=${this.buffer.length}`); + from = to + 1; + } + } finally { + this.scanning = false; + } + } + + private async scanBlockscoutRange(from: number, to: number): Promise { + const txs = await fetchTransactionsInBlockRange(cfg.blockscoutApi, from, to); + let added = 0; + for (const tx of txs) { + if (await this.txAlreadyIncluded(tx.hash)) continue; + const receipt = await this.chain138.getTransactionReceipt(tx.hash); + const leaf: PaymentLeaf = { + txHash: tx.hash, + from: tx.from, + to: tx.to, + value: tx.value, + blockNumber: tx.blockNumber, + blockTimestamp: tx.blockTimestamp, + gasUsed: receipt?.gasUsed ?? 0n, + success: receipt?.status === 1, + }; + await this.enrichLeafFromBlockscout(leaf); + if (effectiveValue(leaf) < cfg.minValueWei) continue; + this.buffer.push(leaf); + added++; + if (this.buffer.length >= cfg.batchSize) await this.flush(false); + } + return added; + } + + private async scanBlockRpc(blockNumber: number): Promise { + const block = await this.chain138.getBlock(blockNumber, true); + if (!block) return 0; + let added = 0; + const entries = block.prefetchedTransactions?.length + ? block.prefetchedTransactions + : block.transactions ?? []; + for (const entry of entries) { + const tx = + typeof entry === 'string' ? await this.chain138.getTransaction(entry) : entry; + if (!tx || typeof tx === 'string') continue; + const value = tx.value ?? 0n; + if (await this.txAlreadyIncluded(tx.hash)) continue; + const receipt = await this.chain138.getTransactionReceipt(tx.hash); + const leaf: PaymentLeaf = { + txHash: tx.hash, + from: tx.from, + to: tx.to ?? ethers.ZeroAddress, + value, + blockNumber, + blockTimestamp: block.timestamp, + gasUsed: receipt?.gasUsed ?? 0n, + success: receipt?.status === 1, + }; + await this.enrichLeafFromBlockscout(leaf); + if (effectiveValue(leaf) < cfg.minValueWei) continue; + this.buffer.push(leaf); + added++; + if (this.buffer.length >= cfg.batchSize) await this.flush(false); + } + return added; + } + + private async enrichLeafFromBlockscout(leaf: PaymentLeaf) { + const { allErc20 } = await enrichLeafFromBlockscoutApi(leaf, cfg.blockscoutApi, cfg.useBlockscout); + await enrichLeafUsd( + leaf, + { + apiBaseUrl: cfg.tokenAggregationApiUrl, + chainId: 138, + enabled: cfg.usdEnrichEnabled, + requestDelayMs: cfg.usdRequestDelayMs, + blockscoutApi: cfg.blockscoutApi, + useBlockscout: cfg.useBlockscout, + }, + allErc20 + ); + } + + private async txAlreadyIncluded(txHash: string): Promise { + const cached = this.includedTxCache.get(txHash); + if (cached !== undefined) return cached; + try { + const [included] = await this.checkpoint.isTxIncluded(txHash); + this.includedTxCache.set(txHash, included); + return included; + } catch { + return false; + } + } + + private async flush(partial: boolean) { + if (this.buffer.length === 0) return; + const leaves = this.buffer.splice(0, cfg.batchSize); + + if (cfg.attachReceiptMeta) { + await attachReceiptMeta(this.chain138, leaves); + } else { + for (const leaf of leaves) { + const meta = await fetchReceiptMeta(this.chain138, leaf.txHash); + leaf.logCount = meta.logCount; + leaf.receiptHash = meta.receiptHash; + } + } + + attachIso20022ToLeaves(leaves); + + const root = paymentsRoot(138, leaves); + const receiptMetas = leaves.map((l) => ({ + logCount: l.logCount ?? 0, + receiptHash: l.receiptHash ?? ethers.ZeroHash, + })); + const receiptsRootHash = receiptsRoot( + leaves.map((l) => l.txHash), + receiptMetas + ); + const checkpointBlock = Math.max(...leaves.map((l) => l.blockNumber)); + const block = await this.chain138.getBlock(checkpointBlock); + const blockHash = block?.hash ?? ethers.ZeroHash; + const stateRoot = block?.stateRoot ?? ethers.ZeroHash; + + if (cfg.updateBlockOracle && this.blockOracle) { + const tx = await this.blockOracle.setBlockHeader(checkpointBlock, blockHash, stateRoot); + await tx.wait(); + } + + const header: CheckpointHeaderStruct = { + batchId: this.nextBatchId, + previousBatchId: this.nextBatchId > 1n ? this.nextBatchId - 1n : 0n, + chainId: 138n, + checkpointBlock: BigInt(checkpointBlock), + startBlock: BigInt(Math.min(...leaves.map((l) => l.blockNumber))), + endBlock: BigInt(checkpointBlock), + blockHash, + stateRoot, + paymentsRoot: root, + receiptsRoot: receiptsRootHash, + txCount: partial && leaves.length < cfg.batchSize ? leaves.length : cfg.batchSize, + flags: partial && leaves.length < cfg.batchSize ? 1 : 0, + submittedAt: 0n, + submitter: ethers.ZeroAddress, + contentURI: ethers.ZeroHash, + }; + + const payload: BatchPayload = { + batchId: this.nextBatchId.toString(), + chainId: 138, + checkpointBlock, + paymentsRoot: root, + leaves: leaves.map((l) => ({ + txHash: l.txHash, + from: l.from, + to: l.to, + value: l.value.toString(), + nativeValueWei: l.value.toString(), + onChainValueWei: effectiveValue(l).toString(), + blockNumber: l.blockNumber, + blockTimestamp: l.blockTimestamp, + gasUsed: l.gasUsed.toString(), + success: l.success, + ...(l.token + ? { + token: l.token, + tokenSymbol: l.tokenSymbol, + tokenDecimals: l.tokenDecimals, + tokenValue: l.tokenValue?.toString(), + tokenLogIndex: l.tokenLogIndex, + } + : {}), + ...(l.valueUsd ? { valueUsd: l.valueUsd } : {}), + ...(l.nativeValueUsd ? { nativeValueUsd: l.nativeValueUsd } : {}), + ...(l.tokenValueUsd ? { tokenValueUsd: l.tokenValueUsd } : {}), + ...(l.nativePriceUsd != null ? { nativePriceUsd: l.nativePriceUsd } : {}), + ...(l.tokenPriceUsd != null ? { tokenPriceUsd: l.tokenPriceUsd } : {}), + ...(l.priceSource ? { priceSource: l.priceSource } : {}), + ...(l.priceEffectiveTimestamp ? { priceEffectiveTimestamp: l.priceEffectiveTimestamp } : {}), + ...(l.totalTransfersUsd ? { totalTransfersUsd: l.totalTransfersUsd } : {}), + ...(l.transfers?.length ? { transfers: l.transfers } : {}), + ...(l.logCount != null ? { logCount: l.logCount } : {}), + ...(l.receiptHash ? { receiptHash: l.receiptHash } : {}), + ...(l.iso20022 ? { iso20022: l.iso20022 } : {}), + })), + submittedAt: new Date().toISOString(), + ...(cfg.isoEnrichEnabled + ? { iso20022Schema: 'canonical-v1', isoMsgTypeDefault: 'chain138.synthetic' } + : {}), + ...(cfg.usdEnrichEnabled + ? { + batchTotalUsd: + sumUsdStrings(leaves.map((l) => l.totalTransfersUsd ?? l.valueUsd)) ?? '0.000000', + usdEnrichedAt: new Date().toISOString(), + } + : {}), + }; + const contentURI = await publishBatchPayload(this.nextBatchId, payload); + header.contentURI = contentURI; + + const validatorSignatures = cfg.requireValidatorSigs + ? await signBatchAttestation(this.mainnetWallet, cfg.checkpointProxy, header) + : '0x01'; + + const txHashes = cfg.submitMode === 'commitment' ? [] : leaves.map((l) => l.txHash); + const extensionData = AdminCheckpointIngress.extensionDataForMode(cfg.submitMode, leaves); + + const { hash } = await this.ingress.submit({ + header, + leaves, + txHashes, + extensionData, + contentURI, + validatorSignatures, + }); + + const batchIdSubmitted = this.nextBatchId; + console.log(`Submitted batch ${batchIdSubmitted} (${leaves.length} txs) mode=${cfg.submitMode} tx=${hash}`); + + if (cfg.dualWriteV1Mirror) { + try { + const mirrorTx = await dualWriteV1Mirror( + this.mainnetWallet, + cfg.transactionMirrorMainnet, + leaves + ); + if (mirrorTx) console.log(` v1 TransactionMirror dual-write tx=${mirrorTx}`); + } catch (e) { + console.error(' v1 TransactionMirror dual-write failed', e); + } + } + + if (cfg.recordAddressActivity && cfg.addressActivityRegistry) { + try { + const activityTx = await recordActivityBatch( + this.mainnetWallet, + cfg.addressActivityRegistry, + batchIdSubmitted, + leaves + ); + if (activityTx) console.log(` AddressActivityRegistry tx=${activityTx}`); + } catch (e) { + console.error(' AddressActivityRegistry record failed', e); + } + } + + if (cfg.recordIsoAttestation && cfg.addressActivityRegistryV2) { + try { + const isoTx = await recordIsoAttestationBatch( + this.mainnetWallet, + cfg.addressActivityRegistryV2, + batchIdSubmitted, + leaves + ); + if (isoTx) console.log(` AddressActivityRegistryV2 tx=${isoTx}`); + } catch (e) { + console.error(' AddressActivityRegistryV2 record failed', e); + } + } + + if (cfg.isoEnrichEnabled) { + try { + const stored = persistIso20022ForLeaves(leaves, batchIdSubmitted); + if (stored.length) console.log(` ISO20022 OMNL store: ${stored.length} message(s)`); + } catch (e) { + console.error(' ISO20022 local store failed', e); + } + } + + if (cfg.surfaceParticipants && cfg.participantSurface) { + try { + const surfaceTx = await surfaceParticipantsOnMainnet( + this.mainnetWallet, + cfg.participantSurface, + batchIdSubmitted, + leaves + ); + if (surfaceTx) console.log(` Chain138ParticipantSurface tx=${surfaceTx}`); + } catch (e) { + console.error(' Chain138ParticipantSurface failed', e); + } + } + + if (cfg.surfaceTopLevelZeroEth) { + try { + const n = await surfaceTopLevelZeroEth(this.mainnetWallet, leaves); + if (n > 0) console.log(` Top-level 0 ETH surface txs=${n} (Etherscan Transactions tab)`); + } catch (e) { + console.error(' Top-level 0 ETH surface failed', e); + } + } + + this.nextBatchId += 1n; + this.schedulePartialFlush(); + } +} + +new CheckpointAggregator().start().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/services/checkpoint-aggregator/src/ingress/adminIngress.ts b/services/checkpoint-aggregator/src/ingress/adminIngress.ts new file mode 100644 index 0000000..03a497a --- /dev/null +++ b/services/checkpoint-aggregator/src/ingress/adminIngress.ts @@ -0,0 +1,108 @@ +import { ethers } from 'ethers'; +import type { CheckpointHeaderStruct } from '../eip712'; +import { + encodeExtensionLeavesV1, + encodeExtensionLeavesV2, + effectiveValue, + leavesToTuples, +} from '../leafCodec'; +import type { CheckpointIngress, FlushContext, SubmitMode } from './types'; + +const CHECKPOINT_HEADER_TUPLE = + 'tuple(uint64 batchId,uint64 previousBatchId,uint64 chainId,uint256 checkpointBlock,uint256 startBlock,uint256 endBlock,bytes32 blockHash,bytes32 stateRoot,bytes32 paymentsRoot,bytes32 receiptsRoot,uint16 txCount,uint32 flags,uint64 submittedAt,address submitter,bytes32 contentURI)'; + +const PAYMENT_LEAF_TUPLE = + 'tuple(bytes32,address,address,uint256,uint256,uint64,uint256,bool)'; + +const PAYMENT_LEAF_V2_TUPLE = + 'tuple(bytes32,address,address,address,uint256,uint256,uint64,uint256,bool,uint32)'; + +function headerToTuple(h: CheckpointHeaderStruct) { + return [ + h.batchId, + h.previousBatchId, + h.chainId, + h.checkpointBlock, + h.startBlock, + h.endBlock, + h.blockHash, + h.stateRoot, + h.paymentsRoot, + h.receiptsRoot, + h.txCount, + h.flags, + h.submittedAt, + h.submitter, + h.contentURI, + ]; +} + +export class AdminCheckpointIngress implements CheckpointIngress { + readonly mode: SubmitMode; + + constructor( + private readonly checkpoint: ethers.Contract, + mode: SubmitMode + ) { + this.mode = mode; + } + + async submit(ctx: FlushContext): Promise<{ hash: string }> { + const header = headerToTuple(ctx.header); + const validatorSignatures = ctx.validatorSignatures ?? '0x01'; + + switch (this.mode) { + case 'commitment': { + const tx = await this.checkpoint.submitCheckpointCommitment( + header, + validatorSignatures, + ctx.contentURI, + ctx.extensionData + ); + return { hash: tx.hash }; + } + case 'leaves': { + const tx = await this.checkpoint.submitCheckpointWithLeaves( + header, + validatorSignatures, + ctx.txHashes, + leavesToTuples(ctx.leaves) + ); + return { hash: tx.hash }; + } + case 'leaves-v2': + throw new Error('leaves-v2 requires hub submitCheckpointWithLeavesV2 (deploy separate module or use hashes)'); + case 'hashes': + default: { + const tx = await this.checkpoint.submitCheckpoint( + header, + validatorSignatures, + ctx.txHashes, + ctx.extensionData + ); + return { hash: tx.hash }; + } + } + } + + static extensionDataForMode(mode: SubmitMode, leaves: FlushContext['leaves']): string { + if (mode === 'leaves-v2') return encodeExtensionLeavesV2(leaves); + return encodeExtensionLeavesV1(leaves); + } +} + +export function buildCheckpointContract( + wallet: ethers.Wallet, + proxy: string, + mode: SubmitMode +): ethers.Contract { + const fns = [ + `function submitCheckpoint(${CHECKPOINT_HEADER_TUPLE} header, bytes validatorSignatures, bytes32[] txHashes, bytes extensionData) external`, + `function submitCheckpointCommitment(${CHECKPOINT_HEADER_TUPLE} header, bytes validatorSignatures, bytes32 contentURI, bytes extensionData) external`, + `function submitCheckpointWithLeaves(${CHECKPOINT_HEADER_TUPLE} header, bytes validatorSignatures, bytes32[] txHashes, ${PAYMENT_LEAF_TUPLE}[] leaves) external`, + 'function getLatestBatchId() view returns (uint64)', + 'function IMPLEMENTATION_VERSION() view returns (uint256)', + 'function isTxIncluded(bytes32 txHash) view returns (bool included, uint64 batchId)', + ]; + return new ethers.Contract(proxy, fns, wallet); +} diff --git a/services/checkpoint-aggregator/src/ingress/types.ts b/services/checkpoint-aggregator/src/ingress/types.ts new file mode 100644 index 0000000..a5e3cde --- /dev/null +++ b/services/checkpoint-aggregator/src/ingress/types.ts @@ -0,0 +1,50 @@ +import type { CheckpointHeaderStruct } from '../eip712'; +import type { CanonicalPaymentMessage } from '@dbis/checkpoint-core'; + +export type PaymentLeaf = { + txHash: string; + from: string; + to: string; + value: bigint; + blockNumber: number; + blockTimestamp: number; + gasUsed: bigint; + success: boolean; + token?: string; + tokenSymbol?: string; + tokenDecimals?: number; + tokenValue?: bigint; + tokenLogIndex?: number; + /** Off-chain USD metadata (not in Merkle leaf). */ + valueUsd?: string; + nativeValueUsd?: string; + tokenValueUsd?: string; + nativePriceUsd?: number; + tokenPriceUsd?: number; + priceSource?: string; + priceEffectiveTimestamp?: string; + totalTransfersUsd?: string; + transfers?: Array>; + usdEnrichedAt?: string; + logCount?: number; + receiptHash?: string; + /** MT-103 / pacs.008 equivalent (canonical); attached before batch publish. */ + iso20022?: CanonicalPaymentMessage; +}; + +/** Production default: hashes + IPFS (official submitCheckpoint). */ +export type SubmitMode = 'hashes' | 'commitment' | 'leaves' | 'leaves-v2'; + +export type FlushContext = { + header: CheckpointHeaderStruct; + leaves: PaymentLeaf[]; + txHashes: string[]; + extensionData: string; + contentURI: string; + validatorSignatures?: string; +}; + +export interface CheckpointIngress { + readonly mode: SubmitMode; + submit(ctx: FlushContext): Promise<{ hash: string }>; +} diff --git a/services/checkpoint-aggregator/src/ipfs.ts b/services/checkpoint-aggregator/src/ipfs.ts new file mode 100644 index 0000000..a6692ed --- /dev/null +++ b/services/checkpoint-aggregator/src/ipfs.ts @@ -0,0 +1,47 @@ +import { ethers } from 'ethers'; +import * as fs from 'fs'; +import * as path from 'path'; +import { checkpointAggregatorConfig as cfg } from './config'; + +export type BatchPayload = { + batchId: string; + chainId: number; + checkpointBlock: number; + paymentsRoot: string; + leaves: unknown[]; + submittedAt: string; + batchTotalUsd?: string; + usdEnrichedAt?: string; +}; + +function jsonReplacer(_key: string, value: unknown): unknown { + return typeof value === 'bigint' ? value.toString() : value; +} + +export async function publishBatchPayload(batchId: bigint, payload: BatchPayload): Promise { + const json = JSON.stringify(payload, jsonReplacer, 2); + const localDir = cfg.batchPayloadDir; + fs.mkdirSync(localDir, { recursive: true }); + const localPath = path.join(localDir, `batch-${batchId}.json`); + fs.writeFileSync(localPath, json); + + if (!cfg.ipfsApiUrl) { + if (cfg.payloadPublicUrl) { + const url = `${cfg.payloadPublicUrl.replace(/\/$/, '')}/batch-${batchId}.json`; + return ethers.keccak256(ethers.toUtf8Bytes(url)); + } + return ethers.keccak256(ethers.toUtf8Bytes(`file://${localPath}`)); + } + + const form = new FormData(); + form.append('file', new Blob([json], { type: 'application/json' }), `batch-${batchId}.json`); + const res = await fetch(`${cfg.ipfsApiUrl.replace(/\/$/, '')}/api/v0/add`, { method: 'POST', body: form }); + if (!res.ok) { + throw new Error(`IPFS add failed: ${res.status} ${await res.text()}`); + } + const text = await res.text(); + const line = text.trim().split('\n').pop() || '{}'; + const parsed = JSON.parse(line) as { Hash?: string }; + if (!parsed.Hash) throw new Error('IPFS response missing Hash'); + return ethers.keccak256(ethers.toUtf8Bytes(`ipfs://${parsed.Hash}`)); +} diff --git a/services/checkpoint-aggregator/src/iso20022Enrich.ts b/services/checkpoint-aggregator/src/iso20022Enrich.ts new file mode 100644 index 0000000..f0e0395 --- /dev/null +++ b/services/checkpoint-aggregator/src/iso20022Enrich.ts @@ -0,0 +1,9 @@ +import { mapPaymentLeafToCanonical } from '@dbis/checkpoint-core'; +import type { PaymentLeaf } from './ingress/types'; + +export function attachIso20022ToLeaves(leaves: PaymentLeaf[]): void { + if (process.env.CHECKPOINT_ISO_ENRICH === '0') return; + for (const leaf of leaves) { + if (!leaf.iso20022) leaf.iso20022 = mapPaymentLeafToCanonical(leaf); + } +} diff --git a/services/checkpoint-aggregator/src/iso20022LocalStore.ts b/services/checkpoint-aggregator/src/iso20022LocalStore.ts new file mode 100644 index 0000000..ec56d23 --- /dev/null +++ b/services/checkpoint-aggregator/src/iso20022LocalStore.ts @@ -0,0 +1,55 @@ +import { createHash, randomUUID } from 'crypto'; +import { mkdirSync, writeFileSync } from 'fs'; +import { join, resolve } from 'path'; +import { + canonicalToPacs008Xml, + mapPaymentLeafToCanonical, + type CanonicalPaymentMessage, +} from '@dbis/checkpoint-core'; +import type { PaymentLeaf } from './ingress/types'; + +function storeDir(): string { + const raw = process.env.OMNL_ISO20022_STORE_DIR?.trim(); + if (raw) return resolve(raw); + const root = process.env.PROXMOX_ROOT || process.cwd(); + return resolve(root, 'config/iso20022-omnl/messages'); +} + +function retentionUntil(fromIso: string): string { + const y = parseInt(process.env.OMNL_ISO20022_RETENTION_YEARS || '10', 10); + const d = new Date(fromIso); + d.setFullYear(d.getFullYear() + (Number.isFinite(y) && y > 0 ? y : 10)); + return d.toISOString(); +} + +export function persistIso20022ForLeaves( + leaves: PaymentLeaf[], + batchId: bigint +): Array<{ id: string; chain138TxHash: string; instructionId: string }> { + if (process.env.CHECKPOINT_ISO_OMNL_STORE === '0') return []; + const dir = storeDir(); + mkdirSync(dir, { recursive: true }); + const out: Array<{ id: string; chain138TxHash: string; instructionId: string }> = []; + for (const leaf of leaves) { + const iso = leaf.iso20022 ?? mapPaymentLeafToCanonical(leaf); + const xml = canonicalToPacs008Xml(iso); + const id = randomUUID(); + const receivedAt = new Date().toISOString(); + const record = { + id, + messageType: 'pacs.008' as const, + receivedAt, + retentionUntil: retentionUntil(receivedAt), + uetr: iso.uetr, + instructionId: iso.instructionId, + settlementOrChainRef: leaf.txHash, + accountingRef: `checkpoint-batch-${batchId.toString()}`, + payloadSha256: createHash('sha256').update(xml, 'utf8').digest('hex'), + payload: xml, + canonical: iso, + }; + writeFileSync(join(dir, `${id}.json`), JSON.stringify(record, null, 2), 'utf8'); + out.push({ id, chain138TxHash: leaf.txHash, instructionId: iso.instructionId }); + } + return out; +} diff --git a/services/checkpoint-aggregator/src/leafCodec.ts b/services/checkpoint-aggregator/src/leafCodec.ts new file mode 100644 index 0000000..505a76f --- /dev/null +++ b/services/checkpoint-aggregator/src/leafCodec.ts @@ -0,0 +1,83 @@ +import { ethers } from 'ethers'; +import { + buildMerkleRoot, + effectiveTokenOrNativeWei, + paymentLeafV1Hash, +} from '@dbis/checkpoint-core'; +import type { PaymentLeaf } from './ingress/types'; + +export function effectiveValue(leaf: PaymentLeaf): bigint { + return effectiveTokenOrNativeWei(leaf); +} + +export function leafHashes(chainId: number, leaves: PaymentLeaf[]): string[] { + return leaves.map((l) => + paymentLeafV1Hash(chainId, { + txHash: l.txHash, + from: l.from, + to: l.to, + value: effectiveValue(l), + blockNumber: l.blockNumber, + blockTimestamp: l.blockTimestamp, + gasUsed: l.gasUsed, + success: l.success, + }) + ); +} + +export function paymentsRoot(chainId: number, leaves: PaymentLeaf[]): string { + return buildMerkleRoot(leafHashes(chainId, leaves)); +} + +const PAYMENT_LEAF_TUPLE = + 'tuple(bytes32,address,address,uint256,uint256,uint64,uint256,bool)'; + +export function leavesToTuples(leaves: PaymentLeaf[]): [ + string, + string, + string, + bigint, + bigint, + bigint, + bigint, + boolean, +][] { + return leaves.map((l) => [ + l.txHash, + l.from, + l.to, + effectiveValue(l), + BigInt(l.blockNumber), + BigInt(l.blockTimestamp), + l.gasUsed, + l.success, + ]); +} + +export function encodeExtensionLeavesV1(leaves: PaymentLeaf[]): string { + return ethers.AbiCoder.defaultAbiCoder().encode( + [PAYMENT_LEAF_TUPLE + '[]'], + [leavesToTuples(leaves)] + ); +} + +export function encodeExtensionLeavesV2(leaves: PaymentLeaf[]): string { + const PAYMENT_LEAF_V2_TUPLE = + 'tuple(bytes32,address,address,address,uint256,uint256,uint64,uint256,bool,uint32)'; + const tuples = leaves.map((l) => [ + l.txHash, + l.from, + l.to, + l.token ?? ethers.ZeroAddress, + effectiveValue(l), + BigInt(l.blockNumber), + BigInt(l.blockTimestamp), + l.gasUsed, + l.success, + l.tokenLogIndex ?? 0, + ]); + return ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes1', PAYMENT_LEAF_V2_TUPLE + '[]'], + [ethers.toBeHex(0x02, 1), tuples] + ); +} diff --git a/services/checkpoint-aggregator/src/participantSurface.ts b/services/checkpoint-aggregator/src/participantSurface.ts new file mode 100644 index 0000000..35c3df8 --- /dev/null +++ b/services/checkpoint-aggregator/src/participantSurface.ts @@ -0,0 +1,136 @@ +import { ethers } from 'ethers'; +import { mapPaymentLeafToCanonical, usdStringToE8 } from '@dbis/checkpoint-core'; +import type { PaymentLeaf } from './ingress/types'; +import { effectiveValue } from './leafCodec'; + +const SURFACE_ABI = [ + 'function notifyBatch(uint64 batchId, tuple(address participant,bytes32 chain138TxHash,bytes32 instructionId,uint8 direction,uint64 batchId,uint256 valueWei,uint64 valueUsdE8)[] items) external', + 'function notified(bytes32) view returns (bool)', + 'function notificationKey(address participant, bytes32 chain138TxHash, uint8 direction) view returns (bytes32)', +]; + +const DIRECTION_CREDITED = 0; +const DIRECTION_DEBITED = 1; + +function buildNotifications( + leaves: PaymentLeaf[], + batchId: bigint +): Array<{ + participant: string; + chain138TxHash: string; + instructionId: string; + direction: number; + batchId: bigint; + valueWei: bigint; + valueUsdE8: bigint; +}> { + const items: Array<{ + participant: string; + chain138TxHash: string; + instructionId: string; + direction: number; + batchId: bigint; + valueWei: bigint; + valueUsdE8: bigint; + }> = []; + + for (const leaf of leaves) { + const iso = leaf.iso20022 ?? mapPaymentLeafToCanonical(leaf); + const usd = leaf.totalTransfersUsd ?? leaf.valueUsd ?? leaf.nativeValueUsd ?? '0'; + const valueWei = effectiveValue(leaf); + const valueUsdE8 = usdStringToE8(usd); + + items.push({ + participant: leaf.from, + chain138TxHash: leaf.txHash, + instructionId: iso.instructionIdBytes32, + direction: DIRECTION_DEBITED, + batchId, + valueWei, + valueUsdE8, + }); + items.push({ + participant: leaf.to, + chain138TxHash: leaf.txHash, + instructionId: iso.instructionIdBytes32, + direction: DIRECTION_CREDITED, + batchId, + valueWei, + valueUsdE8, + }); + } + return items; +} + +export async function surfaceParticipantsOnMainnet( + wallet: ethers.Wallet, + surfaceAddress: string, + batchId: bigint, + leaves: PaymentLeaf[] +): Promise { + if (!surfaceAddress || leaves.length === 0) return null; + + const surface = new ethers.Contract(surfaceAddress, SURFACE_ABI, wallet); + const all = buildNotifications(leaves, batchId); + const pending: typeof all = []; + + for (const item of all) { + const key = await surface.notificationKey(item.participant, item.chain138TxHash, item.direction); + try { + if (await surface.notified(key)) continue; + } catch { + /* continue */ + } + pending.push(item); + } + + if (pending.length === 0) return null; + + const tx = await surface.notifyBatch( + batchId, + pending.map((p) => ({ + participant: p.participant, + chain138TxHash: p.chain138TxHash, + instructionId: p.instructionId, + direction: p.direction, + batchId: p.batchId, + valueWei: p.valueWei, + valueUsdE8: p.valueUsdE8, + })), + { gasLimit: 5_000_000n } + ); + await tx.wait(); + return tx.hash as string; +} + +/** + * Optional: one top-level 0 ETH tx TO each participant (shows on Etherscan Transactions tab). + * Cost: ~21k gas per participant notification. + */ +export async function surfaceTopLevelZeroEth( + wallet: ethers.Wallet, + leaves: PaymentLeaf[] +): Promise { + const seen = new Set(); + let count = 0; + for (const leaf of leaves) { + for (const addr of [leaf.from, leaf.to]) { + const key = addr.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'uint256'], + [leaf.txHash, leaf.blockNumber] + ); + const tx = await wallet.sendTransaction({ + to: addr, + value: 0n, + data, + gasLimit: 50_000n, + }); + await tx.wait(); + count++; + } + } + return count; +} diff --git a/services/checkpoint-aggregator/src/receiptsRoot.ts b/services/checkpoint-aggregator/src/receiptsRoot.ts new file mode 100644 index 0000000..cbd842c --- /dev/null +++ b/services/checkpoint-aggregator/src/receiptsRoot.ts @@ -0,0 +1,51 @@ +import { ethers } from 'ethers'; +import { buildMerkleRoot } from '@dbis/checkpoint-core'; + +export type ReceiptMeta = { + logCount: number; + receiptHash: string; +}; + +export function receiptLeafHash(txHash: string, meta: ReceiptMeta): string { + return ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'bytes32', 'uint32'], + [txHash, meta.receiptHash, meta.logCount] + ) + ); +} + +export function receiptsRoot(txHashes: string[], metas: ReceiptMeta[]): string { + if (txHashes.length === 0) return ethers.ZeroHash; + const hashes = txHashes.map((h, i) => receiptLeafHash(h, metas[i]!)); + return buildMerkleRoot(hashes); +} + +export async function fetchReceiptMeta( + provider: ethers.JsonRpcProvider, + txHash: string +): Promise { + const receipt = await provider.getTransactionReceipt(txHash); + if (!receipt) { + return { logCount: 0, receiptHash: ethers.ZeroHash }; + } + const logCount = receipt.logs.length; + const receiptHash = ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'uint256', 'uint8', 'uint32'], + [receipt.blockHash, receipt.gasUsed, receipt.status ?? 0, logCount] + ) + ); + return { logCount, receiptHash }; +} + +export async function attachReceiptMeta( + provider: ethers.JsonRpcProvider, + leaves: Array<{ txHash: string; logCount?: number; receiptHash?: string }> +): Promise { + for (const leaf of leaves) { + const meta = await fetchReceiptMeta(provider, leaf.txHash); + leaf.logCount = meta.logCount; + leaf.receiptHash = meta.receiptHash; + } +} diff --git a/services/checkpoint-aggregator/src/recordBlockActivity.ts b/services/checkpoint-aggregator/src/recordBlockActivity.ts new file mode 100644 index 0000000..37b9519 --- /dev/null +++ b/services/checkpoint-aggregator/src/recordBlockActivity.ts @@ -0,0 +1,140 @@ +#!/usr/bin/env node +/** + * One-shot: record all txs in a Chain 138 block on AddressActivityRegistry (+ optional v1 mirror). + */ +import { ethers } from 'ethers'; +import { usdStringToE8 } from '@dbis/checkpoint-core'; +import { dualWriteV1Mirror } from './dualMirror'; +import { recordActivityBatch } from './activityRegistry'; +import { recordIsoAttestationBatch } from './activityRegistryV2'; +import { attachIso20022ToLeaves } from './iso20022Enrich'; +import { fetchReceiptMeta } from './receiptsRoot'; +import { enrichLeafFromBlockscoutApi, enrichLeafUsd } from './usdEnrich'; +import type { PaymentLeaf } from './ingress/types.js'; + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +async function main() { + const block = parseInt(arg('block') || '0', 10); + const batchId = BigInt(arg('batch-id') || '0'); + const registry = arg('registry') || ''; + const rpc138 = arg('rpc138') || 'http://192.168.11.211:8545'; + const rpc1 = arg('rpc1') || 'https://ethereum-rpc.publicnode.com'; + const mirror = arg('mirror') || ''; + const dualMirror = process.argv.includes('--dual-mirror'); + const dryRun = process.argv.includes('--dry-run'); + const pk = process.env.PRIVATE_KEY; + if (!pk && !dryRun) throw new Error('PRIVATE_KEY required (or pass --dry-run)'); + if (!registry && !dryRun) throw new Error('--registry required'); + + const chain138 = new ethers.JsonRpcProvider(rpc138); + const blk = await chain138.getBlock(block, true); + if (!blk) throw new Error(`block ${block} not found`); + + const leaves: PaymentLeaf[] = []; + const entries = blk.prefetchedTransactions?.length + ? blk.prefetchedTransactions + : blk.transactions ?? []; + + for (const entry of entries) { + const tx = typeof entry === 'string' ? await chain138.getTransaction(entry) : entry; + if (!tx || typeof tx === 'string') continue; + const receipt = await chain138.getTransactionReceipt(tx.hash); + const meta = await fetchReceiptMeta(chain138, tx.hash); + const leaf: PaymentLeaf = { + txHash: tx.hash, + from: tx.from, + to: tx.to ?? ethers.ZeroAddress, + value: tx.value, + blockNumber: block, + blockTimestamp: Number(blk.timestamp), + gasUsed: receipt?.gasUsed ?? 0n, + success: receipt?.status === 1, + logCount: meta.logCount, + receiptHash: meta.receiptHash, + }; + const blockscoutApi = process.env.CHECKPOINT_BLOCKSCOUT_API || 'https://explorer.d-bis.org/api/v2'; + const { allErc20 } = await enrichLeafFromBlockscoutApi(leaf, blockscoutApi, true); + await enrichLeafUsd( + leaf, + { + apiBaseUrl: + process.env.CHECKPOINT_TOKEN_AGGREGATION_URL || + process.env.TOKEN_AGGREGATION_API_URL || + 'https://explorer.d-bis.org/api/v1', + chainId: 138, + enabled: process.env.CHECKPOINT_USD_ENRICH !== '0', + requestDelayMs: 80, + blockscoutApi, + useBlockscout: true, + }, + allErc20 + ); + leaves.push(leaf); + if (!dryRun) { + console.log( + 'leaf', + tx.hash, + 'wei', + leaf.value.toString(), + 'usdE8', + usdStringToE8(leaf.totalTransfersUsd ?? leaf.valueUsd).toString() + ); + } + } + + if (leaves.length === 0) { + console.log('No transactions in block', block); + return; + } + + if (dryRun) { + console.log( + JSON.stringify( + { + dryRun: true, + block, + batchId: batchId.toString(), + registry: registry || null, + txCount: leaves.length, + leaves: leaves.map((l) => ({ + txHash: l.txHash, + from: l.from, + to: l.to, + valueWei: l.value?.toString(), + valueUsd: l.valueUsd, + totalTransfersUsd: l.totalTransfersUsd, + usdE8: usdStringToE8(l.totalTransfersUsd ?? l.valueUsd).toString(), + })), + }, + null, + 2 + ) + ); + return; + } + + const wallet = new ethers.Wallet(pk!, new ethers.JsonRpcProvider(rpc1)); + attachIso20022ToLeaves(leaves); + const activityTx = await recordActivityBatch(wallet, registry, batchId, leaves); + console.log('AddressActivityRegistry tx', activityTx); + + const registryV2 = process.env.ADDRESS_ACTIVITY_REGISTRY_V2_MAINNET || ''; + if (registryV2) { + const isoTx = await recordIsoAttestationBatch(wallet, registryV2, batchId, leaves); + console.log('AddressActivityRegistryV2 tx', isoTx); + } + + if (dualMirror && mirror) { + const mirrorTx = await dualWriteV1Mirror(wallet, mirror, leaves); + console.log('TransactionMirror tx', mirrorTx); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/services/checkpoint-aggregator/src/scanState.ts b/services/checkpoint-aggregator/src/scanState.ts new file mode 100644 index 0000000..9aeb01e --- /dev/null +++ b/services/checkpoint-aggregator/src/scanState.ts @@ -0,0 +1,27 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface ScanState { + lastScannedBlock: number; + updatedAt: string; +} + +export function loadScanState(filePath: string, defaultFrom: number): ScanState { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw) as ScanState; + if (typeof parsed.lastScannedBlock === 'number' && parsed.lastScannedBlock >= 0) { + return parsed; + } + } catch { + // fresh + } + return { lastScannedBlock: defaultFrom - 1, updatedAt: new Date().toISOString() }; +} + +export function saveScanState(filePath: string, state: ScanState): void { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + state.updatedAt = new Date().toISOString(); + fs.writeFileSync(filePath, JSON.stringify(state, null, 2)); +} diff --git a/services/checkpoint-aggregator/src/usdEnrich.ts b/services/checkpoint-aggregator/src/usdEnrich.ts new file mode 100644 index 0000000..7889836 --- /dev/null +++ b/services/checkpoint-aggregator/src/usdEnrich.ts @@ -0,0 +1,87 @@ +import { + enrichLeafUsdFields, + fetchAllTokenTransfersForTx, + pickPrimaryFromSummaries, + type TokenTransferSummary, + type UsdPricingConfig, +} from '@dbis/checkpoint-core'; +import type { PaymentLeaf } from './ingress/types'; + +export type BlockscoutEnrichResult = { + allErc20: TokenTransferSummary[]; +}; + +/** Single Blockscout fetch: all transfers + primary token fields on leaf. */ +export async function enrichLeafFromBlockscoutApi( + leaf: PaymentLeaf, + blockscoutApi: string, + useBlockscout: boolean +): Promise { + if (!useBlockscout) return { allErc20: [] }; + const allErc20 = await fetchAllTokenTransfersForTx(blockscoutApi, leaf.txHash); + const primary = pickPrimaryFromSummaries(allErc20); + if (primary) { + leaf.token = primary.token; + leaf.tokenSymbol = primary.tokenSymbol; + leaf.tokenDecimals = primary.tokenDecimals; + leaf.tokenValue = primary.value; + leaf.tokenLogIndex = primary.logIndex; + } + return { allErc20 }; +} + +export async function enrichLeafUsd( + leaf: PaymentLeaf, + cfg: UsdPricingConfig & { blockscoutApi: string; useBlockscout: boolean }, + preloaded?: TokenTransferSummary[] +): Promise { + if (cfg.enabled === false) return; + + let allErc20 = preloaded ?? []; + if (!preloaded?.length && cfg.useBlockscout) { + allErc20 = await fetchAllTokenTransfersForTx(cfg.blockscoutApi, leaf.txHash); + } else if (!preloaded?.length && leaf.token && leaf.tokenValue != null && leaf.tokenValue > 0n) { + allErc20 = [ + { + token: leaf.token, + tokenSymbol: leaf.tokenSymbol || '', + tokenDecimals: leaf.tokenDecimals ?? 18, + from: leaf.from, + to: leaf.to, + value: leaf.tokenValue, + logIndex: leaf.tokenLogIndex ?? 0, + }, + ]; + } + + const record: Record = { + txHash: leaf.txHash, + from: leaf.from, + to: leaf.to, + value: leaf.value.toString(), + nativeValueWei: leaf.value.toString(), + blockNumber: leaf.blockNumber, + blockTimestamp: leaf.blockTimestamp, + gasUsed: leaf.gasUsed.toString(), + success: leaf.success, + token: leaf.token, + tokenSymbol: leaf.tokenSymbol, + tokenDecimals: leaf.tokenDecimals, + tokenValue: leaf.tokenValue?.toString(), + tokenLogIndex: leaf.tokenLogIndex, + }; + + const enriched = await enrichLeafUsdFields(cfg, record, allErc20); + if (enriched.valueUsd != null) leaf.valueUsd = String(enriched.valueUsd); + if (enriched.nativeValueUsd != null) leaf.nativeValueUsd = String(enriched.nativeValueUsd); + if (enriched.tokenValueUsd != null) leaf.tokenValueUsd = String(enriched.tokenValueUsd); + if (enriched.nativePriceUsd != null) leaf.nativePriceUsd = Number(enriched.nativePriceUsd); + if (enriched.tokenPriceUsd != null) leaf.tokenPriceUsd = Number(enriched.tokenPriceUsd); + if (enriched.priceSource != null) leaf.priceSource = String(enriched.priceSource); + if (enriched.priceEffectiveTimestamp != null) { + leaf.priceEffectiveTimestamp = String(enriched.priceEffectiveTimestamp); + } + if (enriched.totalTransfersUsd != null) leaf.totalTransfersUsd = String(enriched.totalTransfersUsd); + if (Array.isArray(enriched.transfers)) leaf.transfers = enriched.transfers as Array>; + if (enriched.usdEnrichedAt != null) leaf.usdEnrichedAt = String(enriched.usdEnrichedAt); +} diff --git a/services/checkpoint-aggregator/tsconfig.json b/services/checkpoint-aggregator/tsconfig.json new file mode 100644 index 0000000..fd9eab6 --- /dev/null +++ b/services/checkpoint-aggregator/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/services/checkpoint-indexer/README.md b/services/checkpoint-indexer/README.md new file mode 100644 index 0000000..d27c2de --- /dev/null +++ b/services/checkpoint-indexer/README.md @@ -0,0 +1,48 @@ +# Checkpoint indexer REST API + +Exposes v2 hub reads for wallets and explorers, plus an **Etherscan V2-shaped shim** for Chain 138 (unsupported on [official Etherscan V2 chainlist](https://api.etherscan.io/v2/chainlist)). + +See: `docs/04-configuration/etherscan/CHAIN138_ETHERSCAN_V2_UNSUPPORTED_NETWORK_FRAMEWORK.md` + +## Endpoints (v1) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/health` | Liveness | +| GET | `/v1/checkpoint/latest` | Latest batch header | +| GET | `/v1/checkpoint/:batchId` | Historical batch | +| GET | `/v1/tx/:txHash/attestation` | Inclusion in batch | +| GET | `/v1/account/:address` | Account summary + attested activity | +| GET | `/v1/account/:address/transactions` | Batch-indexed txs for address | +| GET | `/v1/account/:address/activity` | Attested activity rows | +| GET | `/v1/tx/:txHash/logs` | Receipt logs from batch payload | + +## Endpoints (v2 — Etherscan supplement) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/v2/chainlist-supplement` | Supplemental chainlist (chain 138 + mainnet attestation layer) | +| GET | `/v2/api?chainid=138&module=account&action=balance` | Chain 138 balance (RPC) | +| GET | `/v2/api?chainid=138&module=account&action=txlist` | Blockscout tx list proxy | +| GET | `/v2/api?chainid=138&module=chain138&action=participantactivity` | Attested activity from batch index | +| GET | `/v2/api?chainid=138&module=chain138&action=attestation&txhash=0x…` | Per-tx attestation | +| GET | `/v2/api?chainid=138&module=proxy&action=eth_blockNumber` | Chain 138 block number | +| GET | `/v2/api?chainid=1&module=chain138mirror&action=contracts` | Mainnet mirror/registry/hub map | +| GET | `/v2/api?chainid=1&module=chain138mirror&action=participantlogs` | Etherscan V2 log proxy (credited topic2, debited topic3) | +| GET | `/v2/api?chainid=1&module=chain138mirror&action=mirrorlogs` | v1 TransactionMirror logs | + +Env: `ADDRESS_ACTIVITY_REGISTRY_MAINNET`, `ETHERSCAN_API_KEY` (for mainnet log proxy). + +## Run + +```bash +cd smom-dbis-138/services/checkpoint-indexer +pnpm install && pnpm build +CHAIN138_MAINNET_CHECKPOINT_PROXY=0x… pnpm start +``` + +From repo root: `bash scripts/deployment/start-checkpoint-services.sh` (builds aggregator + indexer). + +Default port: `3099` (`CHECKPOINT_INDEXER_PORT`). + +Public NPM path (when configured): `https://explorer.d-bis.org/checkpoint/v2/...` → indexer. diff --git a/services/checkpoint-indexer/dist/addressIndex.js b/services/checkpoint-indexer/dist/addressIndex.js new file mode 100644 index 0000000..81dccc0 --- /dev/null +++ b/services/checkpoint-indexer/dist/addressIndex.js @@ -0,0 +1,145 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.buildAddressIndex = buildAddressIndex; +exports.getAddressTransactions = getAddressTransactions; +exports.summarizeAddressActivity = summarizeAddressActivity; +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +function normalizeAddr(a) { + return a.toLowerCase(); +} +function leafToRows(batchId, leaf) { + const txHash = String(leaf.txHash || ''); + const from = String(leaf.from || ''); + const to = String(leaf.to || ''); + const base = { + txHash, + batchId, + valueWei: String(leaf.onChainValueWei ?? leaf.value ?? leaf.nativeValueWei ?? '0'), + valueUsd: leaf.valueUsd != null ? String(leaf.valueUsd) : undefined, + nativeValueUsd: leaf.nativeValueUsd != null ? String(leaf.nativeValueUsd) : undefined, + totalTransfersUsd: leaf.totalTransfersUsd != null ? String(leaf.totalTransfersUsd) : undefined, + blockNumber: Number(leaf.blockNumber ?? 0), + blockTimestamp: Number(leaf.blockTimestamp ?? 0), + logCount: leaf.logCount != null ? Number(leaf.logCount) : undefined, + receiptHash: leaf.receiptHash != null ? String(leaf.receiptHash) : undefined, + success: Boolean(leaf.success), + transfers: Array.isArray(leaf.transfers) + ? leaf.transfers + : undefined, + }; + const rows = []; + if (/^0x[a-fA-F0-9]{40}$/.test(from)) { + rows.push({ ...base, role: 'from', counterparty: to }); + } + if (/^0x[a-fA-F0-9]{40}$/.test(to)) { + rows.push({ ...base, role: 'to', counterparty: from }); + } + return rows; +} +function buildAddressIndex(batchPayloadDir) { + const byAddress = {}; + let batchCount = 0; + let txCount = 0; + if (!fs.existsSync(batchPayloadDir)) { + return { builtAt: new Date().toISOString(), batchCount: 0, txCount: 0, byAddress }; + } + const files = fs + .readdirSync(batchPayloadDir) + .filter((f) => /^batch-\d+\.json$/.test(f)) + .sort((a, b) => { + const na = parseInt(a.replace(/\D/g, ''), 10); + const nb = parseInt(b.replace(/\D/g, ''), 10); + return na - nb; + }); + for (const file of files) { + const batchId = file.replace('batch-', '').replace('.json', ''); + const raw = JSON.parse(fs.readFileSync(path.join(batchPayloadDir, file), 'utf8')); + if (!raw.leaves?.length) + continue; + batchCount++; + for (const leaf of raw.leaves) { + txCount++; + for (const row of leafToRows(batchId, leaf)) { + const key = normalizeAddr(row.role === 'from' ? String(leaf.from) : String(leaf.to)); + if (!byAddress[key]) + byAddress[key] = []; + byAddress[key].push(row); + } + } + } + for (const key of Object.keys(byAddress)) { + byAddress[key].sort((a, b) => b.blockNumber - a.blockNumber || b.batchId.localeCompare(a.batchId)); + } + return { + builtAt: new Date().toISOString(), + batchCount, + txCount, + byAddress, + }; +} +function getAddressTransactions(index, address, limit = 50, offset = 0) { + const key = normalizeAddr(address); + const all = index.byAddress[key] ?? []; + return { + total: all.length, + items: all.slice(offset, offset + limit), + }; +} +function summarizeAddressActivity(rows) { + let totalWei = 0n; + let totalUsd = 0; + const seen = new Set(); + for (const row of rows) { + if (seen.has(row.txHash)) + continue; + seen.add(row.txHash); + try { + totalWei += BigInt(row.valueWei); + } + catch { + /* skip */ + } + const usd = Number(row.totalTransfersUsd ?? row.valueUsd ?? '0'); + if (Number.isFinite(usd)) + totalUsd += usd; + } + return { + txCount: seen.size, + totalValueWei: totalWei.toString(), + totalUsd: totalUsd.toFixed(6), + }; +} diff --git a/services/checkpoint-indexer/dist/chain138Explorer.js b/services/checkpoint-indexer/dist/chain138Explorer.js new file mode 100644 index 0000000..487d160 --- /dev/null +++ b/services/checkpoint-indexer/dist/chain138Explorer.js @@ -0,0 +1,63 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.chain138ExplorerBase = chain138ExplorerBase; +exports.chain138ExplorerTxUrl = chain138ExplorerTxUrl; +exports.chain138TxHashFromLogTopic = chain138TxHashFromLogTopic; +exports.enrichRegistryLogsEnvelope = enrichRegistryLogsEnvelope; +const ethers_1 = require("ethers"); +/** Base URL for Chain 138 Blockscout (no trailing slash). */ +function chain138ExplorerBase() { + const fromEnv = process.env.CHAIN138_EXPLORER_URL || process.env.CHECKPOINT_CHAIN138_EXPLORER_URL; + if (fromEnv) + return fromEnv.replace(/\/$/, ''); + const api = process.env.CHECKPOINT_BLOCKSCOUT_API || 'https://explorer.d-bis.org/api/v2'; + try { + const u = new URL(api); + return `${u.protocol}//${u.host}`; + } + catch { + return 'https://explorer.d-bis.org'; + } +} +/** Full explorer link for a Chain 138 transaction hash. */ +function chain138ExplorerTxUrl(txHash, base) { + const root = (base ?? chain138ExplorerBase()).replace(/\/$/, ''); + const hash = txHash.startsWith('0x') ? txHash : `0x${txHash}`; + return `${root}/tx/${hash}`; +} +/** topic1 on ParticipantCredited/Debited is bytes32 chain138TxHash. */ +function chain138TxHashFromLogTopic(topic) { + if (!topic) + return null; + try { + return ethers_1.ethers.hexlify(topic); + } + catch { + return null; + } +} +function enrichRegistryLogsEnvelope(envelope, explorerBase) { + const base = explorerBase ?? chain138ExplorerBase(); + if (!envelope || typeof envelope !== 'object') { + return { raw: envelope, decoded: [] }; + } + const e = envelope; + if (e.status !== '1' || !Array.isArray(e.result)) { + return { raw: envelope, decoded: [] }; + } + const decoded = []; + for (const log of e.result) { + const chain138TxHash = chain138TxHashFromLogTopic(log.topics?.[1]); + if (!chain138TxHash || !log.transactionHash) + continue; + decoded.push({ + chain138TxHash, + chain138ExplorerUrl: chain138ExplorerTxUrl(chain138TxHash, base), + mainnetTxHash: log.transactionHash, + mainnetExplorerUrl: `https://etherscan.io/tx/${log.transactionHash}`, + blockNumber: String(log.blockNumber ?? ''), + logIndex: String(log.logIndex ?? ''), + }); + } + return { raw: envelope, decoded }; +} diff --git a/services/checkpoint-indexer/dist/config.js b/services/checkpoint-indexer/dist/config.js new file mode 100644 index 0000000..d166d31 --- /dev/null +++ b/services/checkpoint-indexer/dist/config.js @@ -0,0 +1,67 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.checkpointIndexerConfig = void 0; +const dotenv = __importStar(require("dotenv")); +const path = __importStar(require("path")); +dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); +dotenv.config(); +const repoRoot = process.env.PROXMOX_ROOT || path.resolve(__dirname, '../../../../..'); +function mainnetRpc() { + return (process.env.MAINNET_RPC_URL || + process.env.ETHEREUM_MAINNET_RPC || + 'https://ethereum-rpc.publicnode.com'); +} +exports.checkpointIndexerConfig = { + port: parseInt(process.env.CHECKPOINT_INDEXER_PORT || '3099', 10), + mainnetRpc: mainnetRpc(), + chain138Rpc: process.env.CHAIN138_RPC_URL || 'http://192.168.11.211:8545', + checkpointProxy: process.env.CHAIN138_MAINNET_CHECKPOINT_PROXY || '', + legacyMirror: process.env.TRANSACTION_MIRROR_MAINNET || '0x4CF42c4F1dBa748601b8938be3E7ABD732E87cE9', + legacyTether: process.env.MAINNET_TETHER_ADDRESS || '0x15DF1D5BFDD8Aa4b380445D4e3E9B38d34283619', + batchPayloadDir: process.env.CHECKPOINT_BATCH_PAYLOAD_DIR || + path.join(repoRoot, 'reports/checkpoint-indexer/batches'), + blockscoutApi: process.env.CHECKPOINT_BLOCKSCOUT_API || 'https://explorer.d-bis.org/api/v2', + enrichTokenTransfers: process.env.CHECKPOINT_ENRICH_TOKEN_TRANSFERS !== '0', + usdEnrichEnabled: process.env.CHECKPOINT_USD_ENRICH !== '0', + tokenAggregationApiUrl: process.env.CHECKPOINT_TOKEN_AGGREGATION_URL || + process.env.TOKEN_AGGREGATION_API_URL || + 'https://explorer.d-bis.org/api/v1', + usdRequestDelayMs: parseInt(process.env.CHECKPOINT_USD_REQUEST_DELAY_MS || '80', 10), + addressActivityRegistry: process.env.ADDRESS_ACTIVITY_REGISTRY_MAINNET || '', + addressActivityRegistryV2: process.env.ADDRESS_ACTIVITY_REGISTRY_V2_MAINNET || '', + iso20022IntakeGateway: process.env.ISO20022_INTAKE_GATEWAY_MAINNET || '', + participantSurface: process.env.CHAIN138_PARTICIPANT_SURFACE_MAINNET || '', +}; diff --git a/services/checkpoint-indexer/dist/etherscanV2Shim.js b/services/checkpoint-indexer/dist/etherscanV2Shim.js new file mode 100644 index 0000000..13945a8 --- /dev/null +++ b/services/checkpoint-indexer/dist/etherscanV2Shim.js @@ -0,0 +1,312 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.chainlistSupplement = chainlistSupplement; +exports.etherscanV2Api = etherscanV2Api; +const ethers_1 = require("ethers"); +const config_1 = require("./config"); +const addressIndex_1 = require("./addressIndex"); +const chain138Explorer_1 = require("./chain138Explorer"); +const iso20022LogDecode_1 = require("./iso20022LogDecode"); +const participantSurfaceLogDecode_1 = require("./participantSurfaceLogDecode"); +/** Etherscan V2-shaped envelope for tooling compatibility. */ +function ok(result, message = 'OK') { + return { status: '1', message, result }; +} +function err(message, result = null) { + return { status: '0', message, result }; +} +function normalizeAddress(address) { + try { + return ethers_1.ethers.getAddress(address); + } + catch { + return ethers_1.ethers.getAddress(address.toLowerCase()); + } +} +function padTopicAddress(address) { + return ethers_1.ethers.zeroPadValue(normalizeAddress(address), 32); +} +async function blockscoutTxList(address, page, offset) { + const base = config_1.checkpointIndexerConfig.blockscoutApi.replace(/\/$/, ''); + const url = `${base}/addresses/${normalizeAddress(address)}/transactions?page=${page}&offset=${offset}`; + const res = await fetch(url); + if (!res.ok) + throw new Error(`blockscout ${res.status}`); + const body = (await res.json()); + return (body.items ?? []).map((tx) => ({ + blockNumber: String(tx.block_number ?? tx.blockNumber ?? ''), + timeStamp: tx.timestamp + ? String(Math.floor(new Date(String(tx.timestamp)).getTime() / 1000)) + : '', + hash: tx.hash, + from: tx.from?.hash ?? tx.from, + to: tx.to?.hash ?? tx.to, + value: String(tx.value ?? '0'), + gas: String(tx.fee?.gas_used ?? tx.gas_used ?? ''), + gasPrice: String(tx.gas_price ?? tx.gasPrice ?? '0'), + isError: tx.status === 'ok' || tx.status === 1 || tx.status === '0x1' ? '0' : '1', + txreceipt_status: tx.status === 'ok' || tx.status === 1 ? '1' : '0', + input: tx.raw_input ?? tx.input ?? '0x', + contractAddress: '', + cumulativeGasUsed: '', + confirmations: String(tx.confirmations ?? ''), + })); +} +async function etherscanV2GetLogs(params, apiKey) { + const q = new URLSearchParams(params); + q.set('chainid', '1'); + q.set('module', 'logs'); + q.set('action', 'getLogs'); + if (apiKey) + q.set('apikey', apiKey); + const url = `https://api.etherscan.io/v2/api?${q.toString()}`; + const res = await fetch(url); + return res.json(); +} +function chainlistSupplement(_req, res) { + const registry = config_1.checkpointIndexerConfig.addressActivityRegistry || null; + res.json({ + comments: 'Supplemental chainlist for networks absent from official Etherscan V2 chainlist. Not maintained by Etherscan.', + totalcount: 2, + result: [ + { + chainname: 'Defi Oracle Meta Mainnet', + chainid: '138', + blockexplorer: 'https://explorer.d-bis.org/', + apiurl: `${publicBase(_req)}/v2/api?chainid=138`, + status: 1, + comment: 'Blockscout native + project attestation shim', + }, + { + chainname: 'Ethereum Mainnet (Chain 138 attestation layer)', + chainid: '1', + attestationLayer: true, + blockexplorer: 'https://etherscan.io/', + apiurl: `${publicBase(_req)}/v2/api?chainid=1&module=chain138mirror`, + status: 1, + comment: 'Query mirror/registry logs via chain138mirror module on this shim (not native Etherscan V2 module=account)', + contracts: { + transactionMirror: config_1.checkpointIndexerConfig.legacyMirror, + addressActivityRegistry: registry, + addressActivityRegistryV2: config_1.checkpointIndexerConfig.addressActivityRegistryV2 || null, + iso20022IntakeGateway: config_1.checkpointIndexerConfig.iso20022IntakeGateway || null, + checkpointHub: config_1.checkpointIndexerConfig.checkpointProxy || null, + }, + }, + ], + }); +} +function publicBase(req) { + const proto = req.headers['x-forwarded-proto'] || req.protocol; + const host = req.headers['x-forwarded-host'] || req.headers.host || `127.0.0.1:${config_1.checkpointIndexerConfig.port}`; + return `${proto}://${host}`; +} +async function etherscanV2Api(req, res) { + const q = req.query; + const chainid = String(q.chainid ?? ''); + const module = String(q.module ?? ''); + const action = String(q.action ?? ''); + const apiKey = String(q.apikey ?? process.env.ETHERSCAN_API_KEY ?? ''); + if (!chainid) { + return res.json(err('Missing or unsupported chainid parameter (required for v2 api), please see chainlist-supplement for Chain 138')); + } + try { + if (chainid === '138') { + return res.json(await handleChain138(module, action, q)); + } + if (chainid === '1' && module === 'chain138mirror') { + return res.json(await handleMainnetAttestation(action, q, apiKey)); + } + if (chainid === '1' && module === 'proxy') { + return res.json(err('Use official https://api.etherscan.io/v2/api for chainid=1 proxy module')); + } + return res.json(err(`Chain ${chainid} is not served by this shim. Chain 138 native: chainid=138. Chain 138 on mainnet attestation: chainid=1&module=chain138mirror`)); + } + catch (e) { + return res.json(err(String(e))); + } +} +async function handleChain138(module, action, q) { + const address = String(q.address ?? ''); + if (module === 'account' && action === 'balance') { + if (!/^0x[a-fA-F0-9]{40}$/.test(address)) + return err('Invalid address'); + const provider = new ethers_1.ethers.JsonRpcProvider(config_1.checkpointIndexerConfig.chain138Rpc); + const bal = await provider.getBalance(normalizeAddress(address)); + return ok(bal.toString()); + } + if (module === 'account' && action === 'txlist') { + if (!/^0x[a-fA-F0-9]{40}$/.test(address)) + return err('Invalid address'); + const page = parseInt(String(q.page ?? '1'), 10) || 1; + const offset = Math.min(parseInt(String(q.offset ?? '10'), 10) || 10, 100); + const items = await blockscoutTxList(normalizeAddress(address), page, offset); + return ok(items); + } + if (module === 'chain138' && action === 'attestation') { + const txHash = String(q.txhash ?? q.txHash ?? ''); + if (!/^0x[a-fA-F0-9]{64}$/.test(txHash)) + return err('Invalid txhash'); + const base = publicBaseFromQuery(q); + const r = await fetch(`${base}/v1/tx/${txHash}/attestation`); + if (!r.ok) + return err('attestation not found'); + return ok(await r.json()); + } + if (module === 'chain138' && action === 'participantactivity') { + if (!/^0x[a-fA-F0-9]{40}$/.test(address)) + return err('Invalid address'); + const normalized = normalizeAddress(address); + const index = (0, addressIndex_1.buildAddressIndex)(config_1.checkpointIndexerConfig.batchPayloadDir); + const { total, items } = (0, addressIndex_1.getAddressTransactions)(index, normalized, Math.min(parseInt(String(q.offset ?? '50'), 10) || 50, 200), parseInt(String(q.page ?? '0'), 10) || 0); + const summary = (0, addressIndex_1.summarizeAddressActivity)(index.byAddress[normalized.toLowerCase()] ?? []); + const onChainHint = total === 0 && config_1.checkpointIndexerConfig.addressActivityRegistry + ? { + registry: config_1.checkpointIndexerConfig.addressActivityRegistry, + query: 'GET /v2/api?chainid=1&module=chain138mirror&action=participantlogs&address=' + + normalized, + } + : null; + return ok({ total, summary, items, onChainHint }); + } + if (module === 'proxy' && action === 'eth_blockNumber') { + const provider = new ethers_1.ethers.JsonRpcProvider(config_1.checkpointIndexerConfig.chain138Rpc); + const block = await provider.getBlockNumber(); + return ok(`0x${block.toString(16)}`); + } + return err(`Unsupported module/action for chainid=138: ${module}/${action}`); +} +function publicBaseFromQuery(q) { + const override = String(q.indexerBase ?? process.env.CHECKPOINT_PAYLOAD_PUBLIC_URL ?? ''); + if (override.startsWith('http')) + return override.replace(/\/v1\/payload\/?$/, '').replace(/\/$/, ''); + return `http://127.0.0.1:${config_1.checkpointIndexerConfig.port}`; +} +async function handleMainnetAttestation(action, q, apiKey) { + const registry = config_1.checkpointIndexerConfig.addressActivityRegistry; + const mirror = config_1.checkpointIndexerConfig.legacyMirror; + const address = String(q.address ?? ''); + if (action === 'contracts') { + const explorerBase = (0, chain138Explorer_1.chain138ExplorerBase)(); + return ok({ + transactionMirror: mirror, + addressActivityRegistry: registry || null, + checkpointHub: config_1.checkpointIndexerConfig.checkpointProxy || null, + chain138Explorer: { + base: explorerBase, + txUrlTemplate: `${explorerBase}/tx/{txHash}`, + }, + eventTopics: { + TransactionMirrored: '0xc25ce5062c7e42c68fa21fe088d21e609cc0c61b8bec3180681363bb5cf02a9e', + ParticipantCredited: '0x853ebad0824f583553bcb4360a0df947d39654e86aba8d7c0625499cbf09f0d5', + ParticipantDebited: '0xf122cad7c8ada37ddd6b6ddca0982b9f7be689cde15eedfd767cc207fa7dc2c8', + }, + }); + } + if (action === 'participantlogs') { + if (!registry && !config_1.checkpointIndexerConfig.addressActivityRegistryV2) { + return err('ADDRESS_ACTIVITY_REGISTRY_MAINNET or V2 not configured'); + } + if (!/^0x[a-fA-F0-9]{40}$/.test(address)) + return err('Invalid address'); + if (!apiKey) + return err('ETHERSCAN_API_KEY required for mainnet log proxy'); + const topic = padTopicAddress(address); + const params = new URLSearchParams({ + fromBlock: String(q.fromBlock ?? '0'), + toBlock: String(q.toBlock ?? 'latest'), + }); + let credited = { status: '0', message: 'No records', result: [] }; + let debited = { status: '0', message: 'No records', result: [] }; + if (registry) { + const regParams = new URLSearchParams([...params.entries(), ['address', registry]]); + credited = await etherscanV2GetLogs(new URLSearchParams([ + ...regParams.entries(), + ['topic0', '0x853ebad0824f583553bcb4360a0df947d39654e86aba8d7c0625499cbf09f0d5'], + ['topic2', topic], + ]), apiKey); + debited = await etherscanV2GetLogs(new URLSearchParams([ + ...regParams.entries(), + ['topic0', '0xf122cad7c8ada37ddd6b6ddca0982b9f7be689cde15eedfd767cc207fa7dc2c8'], + ['topic3', topic], + ]), apiKey); + } + let surfaceNotify = null; + if (config_1.checkpointIndexerConfig.participantSurface) { + const surfaceRaw = await etherscanV2GetLogs(new URLSearchParams([ + ...params.entries(), + ['address', config_1.checkpointIndexerConfig.participantSurface], + ['topic0', participantSurfaceLogDecode_1.CHAIN138_ACTIVITY_NOTIFIED_TOPIC0], + ['topic1', topic], + ]), apiKey); + const surfaceEnv = surfaceRaw; + surfaceNotify = { + status: surfaceEnv.status, + result: surfaceEnv.result ?? [], + decoded: (0, participantSurfaceLogDecode_1.decodeChain138ActivityNotifiedLogs)(surfaceEnv, (0, chain138Explorer_1.chain138ExplorerBase)()), + }; + } + let isoV2 = null; + if (config_1.checkpointIndexerConfig.addressActivityRegistryV2) { + const v2raw = await etherscanV2GetLogs(new URLSearchParams([ + ...params.entries(), + ['address', config_1.checkpointIndexerConfig.addressActivityRegistryV2], + ['topic0', iso20022LogDecode_1.PAYMENT_ATTESTED_TOPIC0], + ['topic3', topic], + ]), apiKey); + const v2env = v2raw; + isoV2 = { + status: v2env.status, + result: v2env.result ?? [], + decoded: (0, iso20022LogDecode_1.decodePaymentAttestedLogs)(v2env, (0, chain138Explorer_1.chain138ExplorerBase)()), + }; + } + return ok({ + participant: normalizeAddress(address), + registry: registry || null, + registryV2: config_1.checkpointIndexerConfig.addressActivityRegistryV2 || null, + participantSurface: config_1.checkpointIndexerConfig.participantSurface || null, + credited: (0, chain138Explorer_1.enrichRegistryLogsEnvelope)(credited), + debited: (0, chain138Explorer_1.enrichRegistryLogsEnvelope)(debited), + surfaceNotifications: surfaceNotify, + iso20022V2: isoV2, + chain138Explorer: { + base: (0, chain138Explorer_1.chain138ExplorerBase)(), + txUrlTemplate: `${(0, chain138Explorer_1.chain138ExplorerBase)()}/tx/{txHash}`, + }, + topicIndex: { + ParticipantCredited: { chain138TxHash: 'topic1', participant: 'topic2', counterparty: 'topic3' }, + ParticipantDebited: { chain138TxHash: 'topic1', counterparty: 'topic2', participant: 'topic3' }, + PaymentAttested: { chain138TxHash: 'topic1', instructionId: 'topic2', creditor: 'topic3' }, + Chain138ActivityNotified: { + participant: 'topic1', + chain138TxHash: 'topic2', + instructionId: 'topic3', + }, + }, + etherscanVisibility: { + transactionsTab: 'Set CHECKPOINT_SURFACE_TOPLEVEL_ZERO_ETH=1 on aggregator for incoming 0 ETH txs to participant', + internalTxnsTab: 'Chain138ParticipantSurface wallet touch (walletTouchEnabled)', + eventsOnSurfaceContract: 'surfaceNotifications.decoded[] with chain138ExplorerUrl', + }, + note: 'credited/debited.decoded[], surfaceNotifications.decoded[], and iso20022V2.decoded[] include chain138ExplorerUrl. Full pacs.008 XML in batch payloads and config/iso20022-omnl/messages.', + }); + } + if (action === 'mirrorlogs') { + if (!/^0x[a-fA-F0-9]{64}$/.test(String(q.txhash ?? ''))) + return err('Invalid txhash'); + if (!apiKey) + return err('ETHERSCAN_API_KEY required'); + const txHash = String(q.txhash); + const params = new URLSearchParams({ + address: mirror, + fromBlock: String(q.fromBlock ?? '0'), + toBlock: String(q.toBlock ?? 'latest'), + topic0: '0xc25ce5062c7e42c68fa21fe088d21e609cc0c61b8bec3180681363bb5cf02a9e', + topic1: txHash, + }); + const logs = await etherscanV2GetLogs(params, apiKey); + return ok(logs); + } + return err(`Unsupported chain138mirror action: ${action}`); +} diff --git a/services/checkpoint-indexer/dist/iso20022LogDecode.js b/services/checkpoint-indexer/dist/iso20022LogDecode.js new file mode 100644 index 0000000..035135c --- /dev/null +++ b/services/checkpoint-indexer/dist/iso20022LogDecode.js @@ -0,0 +1,36 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PAYMENT_ATTESTED_TOPIC0 = void 0; +exports.decodePaymentAttestedLogs = decodePaymentAttestedLogs; +const ethers_1 = require("ethers"); +const chain138Explorer_1 = require("./chain138Explorer"); +/** topic0 = PaymentAttested(bytes32,bytes32,address,address,bytes32,...) — matches cast sig-event */ +exports.PAYMENT_ATTESTED_TOPIC0 = '0x827a09f9798dc544d2acc52ecdad1fe0f5fa859be89a74a39dd7cb986b4cb340'; +function decodePaymentAttestedLogs(envelope, explorerBase) { + if (envelope.status !== '1' || !Array.isArray(envelope.result)) + return []; + const out = []; + for (const log of envelope.result) { + const t = log.topics ?? []; + if (t.length < 4 || !log.transactionHash) + continue; + const chain138TxHash = (0, chain138Explorer_1.chain138TxHashFromLogTopic)(t[1]); + if (!chain138TxHash) + continue; + const instructionId = t[2] ?? ''; + const creditor = t[3] ? ethers_1.ethers.getAddress('0x' + t[3].slice(-40)) : ''; + out.push({ + chain138TxHash, + chain138ExplorerUrl: (0, chain138Explorer_1.chain138ExplorerTxUrl)(chain138TxHash, explorerBase), + instructionId, + uetr: '', + creditor, + debtor: '', + mainnetTxHash: log.transactionHash, + mainnetExplorerUrl: `https://etherscan.io/tx/${log.transactionHash}`, + blockNumber: String(log.blockNumber ?? ''), + logIndex: String(log.logIndex ?? ''), + }); + } + return out; +} diff --git a/services/checkpoint-indexer/dist/merkle.js b/services/checkpoint-indexer/dist/merkle.js new file mode 100644 index 0000000..1ed2ce7 --- /dev/null +++ b/services/checkpoint-indexer/dist/merkle.js @@ -0,0 +1,22 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.buildMerkleRoot = void 0; +exports.paymentLeafHash = paymentLeafHash; +exports.merkleProofs = merkleProofs; +const checkpoint_core_1 = require("@dbis/checkpoint-core"); +Object.defineProperty(exports, "buildMerkleRoot", { enumerable: true, get: function () { return checkpoint_core_1.buildMerkleRoot; } }); +function paymentLeafHash(chainId, leaf) { + return (0, checkpoint_core_1.paymentLeafV1Hash)(chainId, { + txHash: String(leaf.txHash), + from: String(leaf.from), + to: String(leaf.to), + value: (0, checkpoint_core_1.merkleVerifyValueWei)(leaf), + blockNumber: leaf.blockNumber, + blockTimestamp: leaf.blockTimestamp, + gasUsed: leaf.gasUsed, + success: leaf.success, + }); +} +function merkleProofs(hashes) { + return (0, checkpoint_core_1.merkleProofs)(hashes); +} diff --git a/services/checkpoint-indexer/dist/participantSurfaceLogDecode.js b/services/checkpoint-indexer/dist/participantSurfaceLogDecode.js new file mode 100644 index 0000000..b417505 --- /dev/null +++ b/services/checkpoint-indexer/dist/participantSurfaceLogDecode.js @@ -0,0 +1,45 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CHAIN138_ACTIVITY_NOTIFIED_TOPIC0 = void 0; +exports.decodeChain138ActivityNotifiedLogs = decodeChain138ActivityNotifiedLogs; +const ethers_1 = require("ethers"); +const chain138Explorer_1 = require("./chain138Explorer"); +/** topic0 = Chain138ActivityNotified(address,bytes32,bytes32,uint8,uint64,uint256,uint64,bool) */ +exports.CHAIN138_ACTIVITY_NOTIFIED_TOPIC0 = '0x8dfc8206f97c75e58117c233ab5193e49d50c17bd4d035cdafebcf6794168ffe'; +function decodeChain138ActivityNotifiedLogs(envelope, explorerBase) { + if (envelope.status !== '1' || !Array.isArray(envelope.result)) + return []; + const out = []; + for (const log of envelope.result) { + const t = log.topics ?? []; + if (t.length < 4 || !log.transactionHash) + continue; + const participant = t[1] ? ethers_1.ethers.getAddress('0x' + t[1].slice(-40)) : ''; + const chain138TxHash = (0, chain138Explorer_1.chain138TxHashFromLogTopic)(t[2]); + if (!chain138TxHash) + continue; + const instructionId = t[3] ?? ''; + let direction = 0; + if (log.data && log.data.length >= 66) { + try { + const decoded = ethers_1.ethers.AbiCoder.defaultAbiCoder().decode(['uint8', 'uint64', 'uint256', 'uint64', 'bool'], log.data); + direction = Number(decoded[0]); + } + catch { + /* non-fatal */ + } + } + out.push({ + participant, + chain138TxHash, + chain138ExplorerUrl: (0, chain138Explorer_1.chain138ExplorerTxUrl)(chain138TxHash, explorerBase), + instructionId, + direction, + mainnetTxHash: log.transactionHash, + mainnetExplorerUrl: `https://etherscan.io/tx/${log.transactionHash}`, + blockNumber: String(log.blockNumber ?? ''), + logIndex: String(log.logIndex ?? ''), + }); + } + return out; +} diff --git a/services/checkpoint-indexer/dist/serialize.js b/services/checkpoint-indexer/dist/serialize.js new file mode 100644 index 0000000..bf394c3 --- /dev/null +++ b/services/checkpoint-indexer/dist/serialize.js @@ -0,0 +1,18 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.jsonSafe = jsonSafe; +/** JSON-safe hub header / nested values (ethers returns bigint). */ +function jsonSafe(value) { + if (typeof value === 'bigint') + return value.toString(); + if (Array.isArray(value)) + return value.map(jsonSafe); + if (value !== null && typeof value === 'object') { + const out = {}; + for (const [k, v] of Object.entries(value)) { + out[k] = jsonSafe(v); + } + return out; + } + return value; +} diff --git a/services/checkpoint-indexer/dist/server.js b/services/checkpoint-indexer/dist/server.js new file mode 100644 index 0000000..4b74cbf --- /dev/null +++ b/services/checkpoint-indexer/dist/server.js @@ -0,0 +1,342 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = __importDefault(require("express")); +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const ethers_1 = require("ethers"); +const config_1 = require("./config"); +const tokenEnrich_1 = require("./tokenEnrich"); +const checkpoint_core_1 = require("@dbis/checkpoint-core"); +const merkle_1 = require("./merkle"); +const serialize_1 = require("./serialize"); +const addressIndex_1 = require("./addressIndex"); +const etherscanV2Shim_1 = require("./etherscanV2Shim"); +const chain138Explorer_1 = require("./chain138Explorer"); +const CHECKPOINT_ABI = [ + 'function getLatestBatchId() view returns (uint64)', + 'function latestCheckpointBlock() view returns (uint256)', + 'function getLatestCheckpoint() view returns (tuple(uint64,uint64,uint64,uint256,uint256,uint256,bytes32,bytes32,bytes32,bytes32,uint16,uint32,uint64,address,bytes32))', + 'function getCheckpoint(uint64) view returns (tuple(uint64,uint64,uint64,uint256,uint256,uint256,bytes32,bytes32,bytes32,bytes32,uint16,uint32,uint64,address,bytes32))', + 'function isTxIncluded(bytes32) view returns (bool,uint64)', +]; +const MIRROR_ABI = ['function getMirroredTransactionCount() view returns (uint256)']; +const app = (0, express_1.default)(); +const mainnet = new ethers_1.ethers.JsonRpcProvider(config_1.checkpointIndexerConfig.mainnetRpc); +const chain138 = new ethers_1.ethers.JsonRpcProvider(config_1.checkpointIndexerConfig.chain138Rpc); +const hub = config_1.checkpointIndexerConfig.checkpointProxy ? new ethers_1.ethers.Contract(config_1.checkpointIndexerConfig.checkpointProxy, CHECKPOINT_ABI, mainnet) : null; +const mirror = new ethers_1.ethers.Contract(config_1.checkpointIndexerConfig.legacyMirror, MIRROR_ABI, mainnet); +let addressIndexCache = null; +let addressIndexBuiltAt = 0; +const ADDRESS_INDEX_TTL_MS = 60_000; +function getAddressIndex() { + const now = Date.now(); + if (!addressIndexCache || now - addressIndexBuiltAt > ADDRESS_INDEX_TTL_MS) { + addressIndexCache = (0, addressIndex_1.buildAddressIndex)(config_1.checkpointIndexerConfig.batchPayloadDir); + addressIndexBuiltAt = now; + } + return addressIndexCache; +} +function loadBatchPayload(batchId) { + const p = path.join(config_1.checkpointIndexerConfig.batchPayloadDir, `batch-${batchId}.json`); + if (!fs.existsSync(p)) + return null; + return JSON.parse(fs.readFileSync(p, 'utf8')); +} +async function payloadWithEnrichedLeaves(batchId, raw) { + if (!raw?.leaves?.length) + return raw; + const chainId = raw.chainId ?? 138; + let leaves = raw.leaves; + if (config_1.checkpointIndexerConfig.enrichTokenTransfers || config_1.checkpointIndexerConfig.usdEnrichEnabled) { + leaves = await (0, tokenEnrich_1.enrichLeaves)(leaves, config_1.checkpointIndexerConfig.blockscoutApi); + } + const hashes = leaves.map((l) => (0, merkle_1.paymentLeafHash)(chainId, { + txHash: String(l.txHash), + from: String(l.from), + to: String(l.to), + value: String(l.value ?? '0'), + nativeValueWei: l.nativeValueWei != null ? String(l.nativeValueWei) : undefined, + onChainValueWei: l.onChainValueWei != null ? String(l.onChainValueWei) : undefined, + tokenValue: l.tokenValue != null ? String(l.tokenValue) : undefined, + blockNumber: Number(l.blockNumber), + blockTimestamp: Number(l.blockTimestamp), + gasUsed: String(l.gasUsed ?? '0'), + success: Boolean(l.success), + })); + const { root, proofs } = (0, merkle_1.merkleProofs)(hashes); + const leavesWithProofs = leaves.map((l, i) => ({ + ...l, + leafHash: hashes[i], + merkleProof: proofs[i], + })); + return { + ...raw, + batchId: raw.batchId ?? batchId, + leaves: leavesWithProofs, + merkleRootComputed: root, + merkleRootMatches: raw.paymentsRoot ? root.toLowerCase() === String(raw.paymentsRoot).toLowerCase() : null, + walletAttestationCopy: 'Balances and payments as of Chain 138 checkpoint block in this batch, attested on Ethereum mainnet.', + }; +} +app.get('/health', (_req, res) => { + res.json({ ok: true, proxy: config_1.checkpointIndexerConfig.checkpointProxy || null, batchDir: config_1.checkpointIndexerConfig.batchPayloadDir }); +}); +/** Etherscan V2 supplement for Chain 138 (unsupported on official chainlist). */ +app.get('/v2/chainlist-supplement', etherscanV2Shim_1.chainlistSupplement); +app.get('/v2/api', (req, res) => { + void (0, etherscanV2Shim_1.etherscanV2Api)(req, res); +}); +/** Public batch JSON for contentURI resolution (CHECKPOINT_PAYLOAD_PUBLIC_URL). */ +app.get('/v1/payload/batch-:batchId.json', (req, res) => { + const raw = loadBatchPayload(req.params.batchId); + if (!raw) + return res.status(404).json({ error: 'batch not found' }); + res.type('json').send(JSON.stringify(raw, null, 2)); +}); +app.get('/v1/checkpoint/latest', async (_req, res) => { + if (!hub) + return res.status(503).json({ error: 'CHAIN138_MAINNET_CHECKPOINT_PROXY not set' }); + try { + const batchId = await hub.getLatestBatchId(); + const header = await hub.getLatestCheckpoint(); + let v1Count = 0n; + try { + v1Count = await mirror.getMirroredTransactionCount(); + } + catch { + v1Count = 0n; + } + const raw = batchId > 0n ? loadBatchPayload(batchId.toString()) : null; + const payload = raw ? await payloadWithEnrichedLeaves(batchId.toString(), raw) : null; + res.json({ + source: 'v2', + batchId: batchId.toString(), + header: (0, serialize_1.jsonSafe)(header), + legacy: { mirrorCount: v1Count.toString(), tether: config_1.checkpointIndexerConfig.legacyTether }, + payload, + }); + } + catch (e) { + res.status(500).json({ error: String(e) }); + } +}); +app.get('/v1/checkpoint/:batchId', async (req, res) => { + if (!hub) + return res.status(503).json({ error: 'proxy not set' }); + try { + const batchId = req.params.batchId; + const header = await hub.getCheckpoint(BigInt(batchId)); + const raw = loadBatchPayload(batchId); + const payload = raw ? await payloadWithEnrichedLeaves(batchId, raw) : null; + res.json({ batchId, header: (0, serialize_1.jsonSafe)(header), payload }); + } + catch (e) { + res.status(500).json({ error: String(e) }); + } +}); +app.get('/v1/tx/:txHash/attestation', async (req, res) => { + if (!hub) + return res.status(503).json({ error: 'proxy not set' }); + try { + const txHash = req.params.txHash; + const [included, batchId] = await hub.isTxIncluded(txHash); + const raw = included && batchId > 0n ? loadBatchPayload(batchId.toString()) : null; + const payload = raw ? await payloadWithEnrichedLeaves(batchId.toString(), raw) : null; + res.json({ txHash, included, batchId: batchId.toString(), payload }); + } + catch (e) { + res.status(500).json({ error: String(e) }); + } +}); +/** Wallet helper: verify leaf against on-chain hub (client may also use @dbis/checkpoint-sdk). */ +app.get('/v1/checkpoint/:batchId/verify/:leafIndex', async (req, res) => { + if (!hub) + return res.status(503).json({ error: 'proxy not set' }); + try { + const batchId = req.params.batchId; + const leafIndex = parseInt(req.params.leafIndex, 10); + const raw = loadBatchPayload(batchId); + if (!raw?.leaves?.length || leafIndex < 0 || leafIndex >= raw.leaves.length) { + return res.status(404).json({ error: 'leaf not found' }); + } + const payload = await payloadWithEnrichedLeaves(batchId, raw); + const leaves = payload.leaves; + const leaf = leaves?.[leafIndex]; + if (!leaf || !leaf.merkleProof) { + return res.status(404).json({ error: 'proof missing' }); + } + const VERIFY_ABI = [ + 'function verifyPaymentInBatch(uint64 batchId, tuple(bytes32,address,address,uint256,uint256,uint64,uint256,bool) leaf, bytes32[] proof) view returns (bool)', + ]; + const verifyHub = new ethers_1.ethers.Contract(config_1.checkpointIndexerConfig.checkpointProxy, VERIFY_ABI, mainnet); + const l = leaf; + const ok = await verifyHub.verifyPaymentInBatch(BigInt(batchId), [ + l.txHash, + l.from, + l.to, + (0, checkpoint_core_1.merkleVerifyValueWei)(l), + BigInt(String(l.blockNumber)), + BigInt(String(l.blockTimestamp)), + BigInt(String(l.gasUsed ?? 0)), + Boolean(l.success), + ], l.merkleProof); + res.json({ batchId, leafIndex, verified: ok, leaf }); + } + catch (e) { + res.status(500).json({ error: String(e) }); + } +}); +app.get('/v1/account/:address', async (req, res) => { + try { + const addr = req.params.address; + let blockTag = 'latest'; + if (req.query.atBatch) { + if (!hub) + return res.status(503).json({ error: 'proxy not set' }); + const h = await hub.getCheckpoint(BigInt(String(req.query.atBatch))); + blockTag = Number(h.checkpointBlock); + } + else if (hub) { + blockTag = Number(await hub.latestCheckpointBlock()); + } + const balance = await chain138.getBalance(addr, blockTag); + const index = getAddressIndex(); + const rows = index.byAddress[addr.toLowerCase()] ?? []; + const activity = (0, addressIndex_1.summarizeAddressActivity)(rows); + res.json({ + address: addr, + chainId: 138, + blockTag, + balanceWei: balance.toString(), + attestedActivity: activity, + mainnetSurfaces: { + transactionMirror: config_1.checkpointIndexerConfig.legacyMirror, + addressActivityRegistry: config_1.checkpointIndexerConfig.addressActivityRegistry || null, + checkpointHub: config_1.checkpointIndexerConfig.checkpointProxy || null, + }, + walletAttestationCopy: 'Native balance as of Chain 138 checkpoint block. Attested payments (ETH + USD) via checkpoint batches on Ethereum mainnet.', + }); + } + catch (e) { + res.status(500).json({ error: String(e) }); + } +}); +app.get('/v1/account/:address/transactions', async (req, res) => { + try { + const addr = req.params.address; + const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 200); + const offset = parseInt(String(req.query.offset ?? '0'), 10) || 0; + const index = getAddressIndex(); + const { total, items } = (0, addressIndex_1.getAddressTransactions)(index, addr, limit, offset); + res.json({ + address: addr, + chainId: 138, + total, + limit, + offset, + items, + indexMeta: { + builtAt: index.builtAt, + batchCount: index.batchCount, + txCount: index.txCount, + }, + }); + } + catch (e) { + res.status(500).json({ error: String(e) }); + } +}); +app.get('/v1/account/:address/activity', async (req, res) => { + try { + const addr = req.params.address; + const index = getAddressIndex(); + const rows = index.byAddress[addr.toLowerCase()] ?? []; + const summary = (0, addressIndex_1.summarizeAddressActivity)(rows); + res.json({ + address: addr, + chainId: 138, + summary, + recent: rows.slice(0, 25).map((r) => ({ + ...r, + chain138ExplorerUrl: (0, chain138Explorer_1.chain138ExplorerTxUrl)(r.txHash), + })), + chain138Explorer: { + base: (0, chain138Explorer_1.chain138ExplorerBase)(), + txUrlTemplate: `${(0, chain138Explorer_1.chain138ExplorerBase)()}/tx/{txHash}`, + }, + etherscan: { + transactionMirrorEvents: `https://etherscan.io/address/${config_1.checkpointIndexerConfig.legacyMirror}#events`, + addressActivityEvents: config_1.checkpointIndexerConfig.addressActivityRegistry + ? `https://etherscan.io/address/${config_1.checkpointIndexerConfig.addressActivityRegistry}#events` + : null, + }, + }); + } + catch (e) { + res.status(500).json({ error: String(e) }); + } +}); +app.get('/v1/tx/:txHash/logs', async (req, res) => { + try { + const txHash = req.params.txHash; + const receipt = await chain138.getTransactionReceipt(txHash); + if (!receipt) + return res.status(404).json({ error: 'receipt not found on chain 138' }); + res.json({ + txHash, + chainId: 138, + blockNumber: receipt.blockNumber, + status: receipt.status, + gasUsed: receipt.gasUsed.toString(), + logCount: receipt.logs.length, + logs: receipt.logs.map((log, i) => ({ + index: i, + address: log.address, + topics: log.topics, + data: log.data, + })), + }); + } + catch (e) { + res.status(500).json({ error: String(e) }); + } +}); +app.listen(config_1.checkpointIndexerConfig.port, () => { + console.log(`checkpoint-indexer listening on :${config_1.checkpointIndexerConfig.port}`); +}); diff --git a/services/checkpoint-indexer/dist/tokenEnrich.js b/services/checkpoint-indexer/dist/tokenEnrich.js new file mode 100644 index 0000000..b80b611 --- /dev/null +++ b/services/checkpoint-indexer/dist/tokenEnrich.js @@ -0,0 +1,39 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.enrichLeaves = enrichLeaves; +const checkpoint_core_1 = require("@dbis/checkpoint-core"); +const config_1 = require("./config"); +const usdEnrich_1 = require("./usdEnrich"); +async function enrichLeaves(leaves, blockscoutApi) { + const api = blockscoutApi.replace(/\/$/, ''); + const out = []; + for (const leaf of leaves) { + const copy = { ...leaf }; + const txHash = String(leaf.txHash || ''); + const needsToken = config_1.checkpointIndexerConfig.enrichTokenTransfers && + /^0x[a-fA-F0-9]{64}$/.test(txHash) && + !copy.token && + BigInt(String(copy.value ?? copy.nativeValueWei ?? '0')) === 0n; + let allErc20 = []; + if (needsToken || (config_1.checkpointIndexerConfig.usdEnrichEnabled && /^0x[a-fA-F0-9]{64}$/.test(txHash))) { + allErc20 = await (0, checkpoint_core_1.fetchAllTokenTransfersForTx)(api, txHash); + if (needsToken) { + (0, checkpoint_core_1.applyPrimaryTransferToLeaf)(copy, (0, checkpoint_core_1.pickPrimaryFromSummaries)(allErc20)); + } + } + const withUsd = await (0, usdEnrich_1.enrichLeafUsdRecord)(copy, { + apiBaseUrl: config_1.checkpointIndexerConfig.tokenAggregationApiUrl, + chainId: 138, + enabled: config_1.checkpointIndexerConfig.usdEnrichEnabled, + requestDelayMs: config_1.checkpointIndexerConfig.usdRequestDelayMs, + blockscoutApi: api, + preloadedErc20: allErc20, + }); + out.push(withUsd); + await sleep(60); + } + return out; +} +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/services/checkpoint-indexer/dist/tokenTransfers.js b/services/checkpoint-indexer/dist/tokenTransfers.js new file mode 100644 index 0000000..d9f8dbd --- /dev/null +++ b/services/checkpoint-indexer/dist/tokenTransfers.js @@ -0,0 +1,41 @@ +"use strict"; +/** + * Blockscout token-transfer enrichment — Chain 138 txs are mostly ERC-20; native `value` is often 0. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.pickPrimaryTokenTransfer = pickPrimaryTokenTransfer; +exports.fetchTokenTransfersForTx = fetchTokenTransfersForTx; +function pickPrimaryTokenTransfer(items) { + let best = null; + for (const item of items) { + const token = item.token?.address; + const raw = item.total?.value; + if (!token || !raw) + continue; + const value = BigInt(raw); + if (value === 0n) + continue; + const decimals = parseInt(item.token?.decimals || '18', 10); + const summary = { + token, + tokenSymbol: item.token?.symbol || '', + tokenDecimals: Number.isFinite(decimals) ? decimals : 18, + from: item.from?.hash || '', + to: item.to?.hash || '', + value, + logIndex: item.log_index ?? 0, + }; + if (!best || summary.value > best.value) + best = summary; + } + return best; +} +async function fetchTokenTransfersForTx(apiBase, txHash) { + const base = apiBase.replace(/\/$/, ''); + const url = `${base}/transactions/${txHash}/token-transfers`; + const res = await fetch(url); + if (!res.ok) + return null; + const body = (await res.json()); + return pickPrimaryTokenTransfer(body.items ?? []); +} diff --git a/services/checkpoint-indexer/dist/usdEnrich.js b/services/checkpoint-indexer/dist/usdEnrich.js new file mode 100644 index 0000000..78f7e73 --- /dev/null +++ b/services/checkpoint-indexer/dist/usdEnrich.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.enrichLeafUsdRecord = enrichLeafUsdRecord; +const checkpoint_core_1 = require("@dbis/checkpoint-core"); +async function enrichLeafUsdRecord(leaf, cfg) { + if (cfg.enabled === false) + return leaf; + if (leaf.usdEnrichedAt && leaf.transfers != null && leaf.valueUsd != null) { + return leaf; + } + const allErc20 = cfg.preloadedErc20 ?? + (await (0, checkpoint_core_1.fetchAllTokenTransfersForTx)(cfg.blockscoutApi, String(leaf.txHash))); + return (await (0, checkpoint_core_1.enrichLeafUsdFields)(cfg, leaf, allErc20)); +} diff --git a/services/checkpoint-indexer/package.json b/services/checkpoint-indexer/package.json new file mode 100644 index 0000000..6b62582 --- /dev/null +++ b/services/checkpoint-indexer/package.json @@ -0,0 +1,23 @@ +{ + "name": "checkpoint-indexer", + "version": "0.1.0", + "private": true, + "description": "REST API for Chain 138 mainnet checkpoint batches", + "main": "dist/server.js", + "scripts": { + "build": "tsc", + "start": "node dist/server.js", + "dev": "ts-node src/server.ts" + }, + "dependencies": { + "@dbis/checkpoint-core": "workspace:*", + "dotenv": "^16.4.5", + "ethers": "^6.13.0", + "express": "^4.19.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.11.0", + "typescript": "^5.4.0" + } +} diff --git a/services/checkpoint-indexer/pnpm-lock.yaml b/services/checkpoint-indexer/pnpm-lock.yaml new file mode 100644 index 0000000..8000cde --- /dev/null +++ b/services/checkpoint-indexer/pnpm-lock.yaml @@ -0,0 +1,783 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@dbis/checkpoint-core': + specifier: file:../../packages/checkpoint-core + version: file:../../packages/checkpoint-core + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + ethers: + specifier: ^6.13.0 + version: 6.16.0 + express: + specifier: ^4.19.2 + version: 4.22.2 + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/node': + specifier: ^20.11.0 + version: 20.19.41 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + +packages: + + '@adraffy/ens-normalize@1.10.1': + resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} + + '@dbis/checkpoint-core@file:../../packages/checkpoint-core': + resolution: {directory: ../../packages/checkpoint-core, type: directory} + + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/express-serve-static-core@4.19.8': + resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} + + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/node@20.19.41': + resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + + '@types/node@22.7.5': + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + aes-js@4.0.0-beta.5: + resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + body-parser@1.20.5: + resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + ethers@6.16.0: + resolution: {integrity: sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==} + engines: {node: '>=14.0.0'} + + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@adraffy/ens-normalize@1.10.1': {} + + '@dbis/checkpoint-core@file:../../packages/checkpoint-core': + dependencies: + ethers: 6.16.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + + '@noble/hashes@1.3.2': {} + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 20.19.41 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 20.19.41 + + '@types/express-serve-static-core@4.19.8': + dependencies: + '@types/node': 20.19.41 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.8 + '@types/qs': 6.15.1 + '@types/serve-static': 1.15.10 + + '@types/http-errors@2.0.5': {} + + '@types/mime@1.3.5': {} + + '@types/node@20.19.41': + dependencies: + undici-types: 6.21.0 + + '@types/node@22.7.5': + dependencies: + undici-types: 6.19.8 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 20.19.41 + + '@types/send@1.2.1': + dependencies: + '@types/node': 20.19.41 + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 20.19.41 + '@types/send': 0.17.6 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + aes-js@4.0.0-beta.5: {} + + array-flatten@1.1.1: {} + + body-parser@1.20.5: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + depd@2.0.0: {} + + destroy@1.2.0: {} + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + ethers@6.16.0: + dependencies: + '@adraffy/ens-normalize': 1.10.1 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@types/node': 22.7.5 + aes-js: 4.0.0-beta.5 + tslib: 2.7.0 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.5 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + negotiator@0.6.3: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + parseurl@1.3.3: {} + + path-to-regexp@0.1.13: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + tslib@2.7.0: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typescript@5.9.3: {} + + undici-types@6.19.8: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + utils-merge@1.0.1: {} + + vary@1.1.2: {} + + ws@8.17.1: {} diff --git a/services/checkpoint-indexer/src/addressIndex.ts b/services/checkpoint-indexer/src/addressIndex.ts new file mode 100644 index 0000000..e738b62 --- /dev/null +++ b/services/checkpoint-indexer/src/addressIndex.ts @@ -0,0 +1,150 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export type AddressActivityRow = { + txHash: string; + batchId: string; + role: 'from' | 'to'; + counterparty: string; + valueWei: string; + valueUsd?: string; + nativeValueUsd?: string; + totalTransfersUsd?: string; + blockNumber: number; + blockTimestamp: number; + logCount?: number; + receiptHash?: string; + success: boolean; + transfers?: Array>; +}; + +export type AddressIndex = { + builtAt: string; + batchCount: number; + txCount: number; + byAddress: Record; +}; + +type LeafRecord = Record; + +function normalizeAddr(a: string): string { + return a.toLowerCase(); +} + +function leafToRows(batchId: string, leaf: LeafRecord): AddressActivityRow[] { + const txHash = String(leaf.txHash || ''); + const from = String(leaf.from || ''); + const to = String(leaf.to || ''); + const base = { + txHash, + batchId, + valueWei: String(leaf.onChainValueWei ?? leaf.value ?? leaf.nativeValueWei ?? '0'), + valueUsd: leaf.valueUsd != null ? String(leaf.valueUsd) : undefined, + nativeValueUsd: leaf.nativeValueUsd != null ? String(leaf.nativeValueUsd) : undefined, + totalTransfersUsd: + leaf.totalTransfersUsd != null ? String(leaf.totalTransfersUsd) : undefined, + blockNumber: Number(leaf.blockNumber ?? 0), + blockTimestamp: Number(leaf.blockTimestamp ?? 0), + logCount: leaf.logCount != null ? Number(leaf.logCount) : undefined, + receiptHash: leaf.receiptHash != null ? String(leaf.receiptHash) : undefined, + success: Boolean(leaf.success), + transfers: Array.isArray(leaf.transfers) + ? (leaf.transfers as Array>) + : undefined, + }; + const rows: AddressActivityRow[] = []; + if (/^0x[a-fA-F0-9]{40}$/.test(from)) { + rows.push({ ...base, role: 'from', counterparty: to }); + } + if (/^0x[a-fA-F0-9]{40}$/.test(to)) { + rows.push({ ...base, role: 'to', counterparty: from }); + } + return rows; +} + +export function buildAddressIndex(batchPayloadDir: string): AddressIndex { + const byAddress: Record = {}; + let batchCount = 0; + let txCount = 0; + + if (!fs.existsSync(batchPayloadDir)) { + return { builtAt: new Date().toISOString(), batchCount: 0, txCount: 0, byAddress }; + } + + const files = fs + .readdirSync(batchPayloadDir) + .filter((f) => /^batch-\d+\.json$/.test(f)) + .sort((a, b) => { + const na = parseInt(a.replace(/\D/g, ''), 10); + const nb = parseInt(b.replace(/\D/g, ''), 10); + return na - nb; + }); + + for (const file of files) { + const batchId = file.replace('batch-', '').replace('.json', ''); + const raw = JSON.parse(fs.readFileSync(path.join(batchPayloadDir, file), 'utf8')) as { + leaves?: LeafRecord[]; + }; + if (!raw.leaves?.length) continue; + batchCount++; + for (const leaf of raw.leaves) { + txCount++; + for (const row of leafToRows(batchId, leaf)) { + const key = normalizeAddr(row.role === 'from' ? String(leaf.from) : String(leaf.to)); + if (!byAddress[key]) byAddress[key] = []; + byAddress[key].push(row); + } + } + } + + for (const key of Object.keys(byAddress)) { + byAddress[key].sort((a, b) => b.blockNumber - a.blockNumber || b.batchId.localeCompare(a.batchId)); + } + + return { + builtAt: new Date().toISOString(), + batchCount, + txCount, + byAddress, + }; +} + +export function getAddressTransactions( + index: AddressIndex, + address: string, + limit = 50, + offset = 0 +): { total: number; items: AddressActivityRow[] } { + const key = normalizeAddr(address); + const all = index.byAddress[key] ?? []; + return { + total: all.length, + items: all.slice(offset, offset + limit), + }; +} + +export function summarizeAddressActivity(rows: AddressActivityRow[]): { + txCount: number; + totalValueWei: string; + totalUsd: string; +} { + let totalWei = 0n; + let totalUsd = 0; + const seen = new Set(); + for (const row of rows) { + if (seen.has(row.txHash)) continue; + seen.add(row.txHash); + try { + totalWei += BigInt(row.valueWei); + } catch { + /* skip */ + } + const usd = Number(row.totalTransfersUsd ?? row.valueUsd ?? '0'); + if (Number.isFinite(usd)) totalUsd += usd; + } + return { + txCount: seen.size, + totalValueWei: totalWei.toString(), + totalUsd: totalUsd.toFixed(6), + }; +} diff --git a/services/checkpoint-indexer/src/chain138Explorer.ts b/services/checkpoint-indexer/src/chain138Explorer.ts new file mode 100644 index 0000000..6973099 --- /dev/null +++ b/services/checkpoint-indexer/src/chain138Explorer.ts @@ -0,0 +1,81 @@ +import { ethers } from 'ethers'; + +/** Base URL for Chain 138 Blockscout (no trailing slash). */ +export function chain138ExplorerBase(): string { + const fromEnv = process.env.CHAIN138_EXPLORER_URL || process.env.CHECKPOINT_CHAIN138_EXPLORER_URL; + if (fromEnv) return fromEnv.replace(/\/$/, ''); + const api = process.env.CHECKPOINT_BLOCKSCOUT_API || 'https://explorer.d-bis.org/api/v2'; + try { + const u = new URL(api); + return `${u.protocol}//${u.host}`; + } catch { + return 'https://explorer.d-bis.org'; + } +} + +/** Full explorer link for a Chain 138 transaction hash. */ +export function chain138ExplorerTxUrl(txHash: string, base?: string): string { + const root = (base ?? chain138ExplorerBase()).replace(/\/$/, ''); + const hash = txHash.startsWith('0x') ? txHash : `0x${txHash}`; + return `${root}/tx/${hash}`; +} + +/** topic1 on ParticipantCredited/Debited is bytes32 chain138TxHash. */ +export function chain138TxHashFromLogTopic(topic: string | undefined): string | null { + if (!topic) return null; + try { + return ethers.hexlify(topic); + } catch { + return null; + } +} + +export type DecodedRegistryLogLink = { + chain138TxHash: string; + chain138ExplorerUrl: string; + mainnetTxHash: string; + mainnetExplorerUrl: string; + blockNumber: string; + logIndex: string; +}; + +type EtherscanLogRow = { + topics?: string[]; + transactionHash?: string; + blockNumber?: string; + logIndex?: string; +}; + +type EtherscanLogsEnvelope = { + status: string; + message: string; + result: EtherscanLogRow[]; +}; + +export function enrichRegistryLogsEnvelope( + envelope: unknown, + explorerBase?: string +): { raw: unknown; decoded: DecodedRegistryLogLink[] } { + const base = explorerBase ?? chain138ExplorerBase(); + if (!envelope || typeof envelope !== 'object') { + return { raw: envelope, decoded: [] }; + } + const e = envelope as EtherscanLogsEnvelope; + if (e.status !== '1' || !Array.isArray(e.result)) { + return { raw: envelope, decoded: [] }; + } + const decoded: DecodedRegistryLogLink[] = []; + for (const log of e.result) { + const chain138TxHash = chain138TxHashFromLogTopic(log.topics?.[1]); + if (!chain138TxHash || !log.transactionHash) continue; + decoded.push({ + chain138TxHash, + chain138ExplorerUrl: chain138ExplorerTxUrl(chain138TxHash, base), + mainnetTxHash: log.transactionHash, + mainnetExplorerUrl: `https://etherscan.io/tx/${log.transactionHash}`, + blockNumber: String(log.blockNumber ?? ''), + logIndex: String(log.logIndex ?? ''), + }); + } + return { raw: envelope, decoded }; +} diff --git a/services/checkpoint-indexer/src/config.ts b/services/checkpoint-indexer/src/config.ts new file mode 100644 index 0000000..3851aa7 --- /dev/null +++ b/services/checkpoint-indexer/src/config.ts @@ -0,0 +1,40 @@ +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); +dotenv.config(); + +const repoRoot = process.env.PROXMOX_ROOT || path.resolve(__dirname, '../../../../..'); + +function mainnetRpc(): string { + return ( + process.env.MAINNET_RPC_URL || + process.env.ETHEREUM_MAINNET_RPC || + 'https://ethereum-rpc.publicnode.com' + ); +} + +export const checkpointIndexerConfig = { + port: parseInt(process.env.CHECKPOINT_INDEXER_PORT || '3099', 10), + mainnetRpc: mainnetRpc(), + chain138Rpc: process.env.CHAIN138_RPC_URL || 'http://192.168.11.211:8545', + checkpointProxy: process.env.CHAIN138_MAINNET_CHECKPOINT_PROXY || '', + legacyMirror: process.env.TRANSACTION_MIRROR_MAINNET || '0x4CF42c4F1dBa748601b8938be3E7ABD732E87cE9', + legacyTether: process.env.MAINNET_TETHER_ADDRESS || '0x15DF1D5BFDD8Aa4b380445D4e3E9B38d34283619', + batchPayloadDir: + process.env.CHECKPOINT_BATCH_PAYLOAD_DIR || + path.join(repoRoot, 'reports/checkpoint-indexer/batches'), + blockscoutApi: + process.env.CHECKPOINT_BLOCKSCOUT_API || 'https://explorer.d-bis.org/api/v2', + enrichTokenTransfers: process.env.CHECKPOINT_ENRICH_TOKEN_TRANSFERS !== '0', + usdEnrichEnabled: process.env.CHECKPOINT_USD_ENRICH !== '0', + tokenAggregationApiUrl: + process.env.CHECKPOINT_TOKEN_AGGREGATION_URL || + process.env.TOKEN_AGGREGATION_API_URL || + 'https://explorer.d-bis.org/api/v1', + usdRequestDelayMs: parseInt(process.env.CHECKPOINT_USD_REQUEST_DELAY_MS || '80', 10), + addressActivityRegistry: process.env.ADDRESS_ACTIVITY_REGISTRY_MAINNET || '', + addressActivityRegistryV2: process.env.ADDRESS_ACTIVITY_REGISTRY_V2_MAINNET || '', + iso20022IntakeGateway: process.env.ISO20022_INTAKE_GATEWAY_MAINNET || '', + participantSurface: process.env.CHAIN138_PARTICIPANT_SURFACE_MAINNET || '', +} as const; diff --git a/services/checkpoint-indexer/src/etherscanV2Shim.ts b/services/checkpoint-indexer/src/etherscanV2Shim.ts new file mode 100644 index 0000000..b18ffdf --- /dev/null +++ b/services/checkpoint-indexer/src/etherscanV2Shim.ts @@ -0,0 +1,396 @@ +import { ethers } from 'ethers'; +import type { Request, Response } from 'express'; +import { checkpointIndexerConfig as cfg } from './config'; +import { + buildAddressIndex, + getAddressTransactions, + summarizeAddressActivity, +} from './addressIndex'; +import { + chain138ExplorerBase, + chain138ExplorerTxUrl, + enrichRegistryLogsEnvelope, +} from './chain138Explorer'; +import { decodePaymentAttestedLogs, PAYMENT_ATTESTED_TOPIC0 } from './iso20022LogDecode'; +import { + CHAIN138_ACTIVITY_NOTIFIED_TOPIC0, + decodeChain138ActivityNotifiedLogs, +} from './participantSurfaceLogDecode'; + +/** Etherscan V2-shaped envelope for tooling compatibility. */ +function ok(result: unknown, message = 'OK') { + return { status: '1', message, result }; +} + +function err(message: string, result: unknown = null) { + return { status: '0', message, result }; +} + +function normalizeAddress(address: string): string { + try { + return ethers.getAddress(address); + } catch { + return ethers.getAddress(address.toLowerCase()); + } +} + +function padTopicAddress(address: string): string { + return ethers.zeroPadValue(normalizeAddress(address), 32); +} + +async function blockscoutTxList(address: string, page: number, offset: number) { + const base = cfg.blockscoutApi.replace(/\/$/, ''); + const url = `${base}/addresses/${normalizeAddress(address)}/transactions?page=${page}&offset=${offset}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`blockscout ${res.status}`); + const body = (await res.json()) as { items?: Array> }; + return (body.items ?? []).map((tx) => ({ + blockNumber: String(tx.block_number ?? tx.blockNumber ?? ''), + timeStamp: tx.timestamp + ? String(Math.floor(new Date(String(tx.timestamp)).getTime() / 1000)) + : '', + hash: tx.hash, + from: (tx.from as { hash?: string })?.hash ?? tx.from, + to: (tx.to as { hash?: string })?.hash ?? tx.to, + value: String(tx.value ?? '0'), + gas: String((tx.fee as { gas_used?: string })?.gas_used ?? tx.gas_used ?? ''), + gasPrice: String(tx.gas_price ?? tx.gasPrice ?? '0'), + isError: tx.status === 'ok' || tx.status === 1 || tx.status === '0x1' ? '0' : '1', + txreceipt_status: tx.status === 'ok' || tx.status === 1 ? '1' : '0', + input: tx.raw_input ?? tx.input ?? '0x', + contractAddress: '', + cumulativeGasUsed: '', + confirmations: String(tx.confirmations ?? ''), + })); +} + +async function etherscanV2GetLogs(params: URLSearchParams, apiKey: string) { + const q = new URLSearchParams(params); + q.set('chainid', '1'); + q.set('module', 'logs'); + q.set('action', 'getLogs'); + if (apiKey) q.set('apikey', apiKey); + const url = `https://api.etherscan.io/v2/api?${q.toString()}`; + const res = await fetch(url); + return res.json(); +} + +export function chainlistSupplement(_req: Request, res: Response) { + const registry = cfg.addressActivityRegistry || null; + res.json({ + comments: + 'Supplemental chainlist for networks absent from official Etherscan V2 chainlist. Not maintained by Etherscan.', + totalcount: 2, + result: [ + { + chainname: 'Defi Oracle Meta Mainnet', + chainid: '138', + blockexplorer: 'https://explorer.d-bis.org/', + apiurl: `${publicBase(_req)}/v2/api?chainid=138`, + status: 1, + comment: 'Blockscout native + project attestation shim', + }, + { + chainname: 'Ethereum Mainnet (Chain 138 attestation layer)', + chainid: '1', + attestationLayer: true, + blockexplorer: 'https://etherscan.io/', + apiurl: `${publicBase(_req)}/v2/api?chainid=1&module=chain138mirror`, + status: 1, + comment: 'Query mirror/registry logs via chain138mirror module on this shim (not native Etherscan V2 module=account)', + contracts: { + transactionMirror: cfg.legacyMirror, + addressActivityRegistry: registry, + addressActivityRegistryV2: cfg.addressActivityRegistryV2 || null, + iso20022IntakeGateway: cfg.iso20022IntakeGateway || null, + checkpointHub: cfg.checkpointProxy || null, + }, + }, + ], + }); +} + +function publicBase(req: Request): string { + const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol; + const host = req.headers['x-forwarded-host'] || req.headers.host || `127.0.0.1:${cfg.port}`; + return `${proto}://${host}`; +} + +export async function etherscanV2Api(req: Request, res: Response) { + const q = req.query; + const chainid = String(q.chainid ?? ''); + const module = String(q.module ?? ''); + const action = String(q.action ?? ''); + const apiKey = String(q.apikey ?? process.env.ETHERSCAN_API_KEY ?? ''); + + if (!chainid) { + return res.json( + err( + 'Missing or unsupported chainid parameter (required for v2 api), please see chainlist-supplement for Chain 138' + ) + ); + } + + try { + if (chainid === '138') { + return res.json(await handleChain138(module, action, q)); + } + if (chainid === '1' && module === 'chain138mirror') { + return res.json(await handleMainnetAttestation(action, q, apiKey)); + } + if (chainid === '1' && module === 'proxy') { + return res.json( + err('Use official https://api.etherscan.io/v2/api for chainid=1 proxy module') + ); + } + return res.json( + err( + `Chain ${chainid} is not served by this shim. Chain 138 native: chainid=138. Chain 138 on mainnet attestation: chainid=1&module=chain138mirror` + ) + ); + } catch (e) { + return res.json(err(String(e))); + } +} + +async function handleChain138( + module: string, + action: string, + q: Request['query'] +): Promise<{ status: string; message: string; result: unknown }> { + const address = String(q.address ?? ''); + + if (module === 'account' && action === 'balance') { + if (!/^0x[a-fA-F0-9]{40}$/.test(address)) return err('Invalid address'); + const provider = new ethers.JsonRpcProvider(cfg.chain138Rpc); + const bal = await provider.getBalance(normalizeAddress(address)); + return ok(bal.toString()); + } + + if (module === 'account' && action === 'txlist') { + if (!/^0x[a-fA-F0-9]{40}$/.test(address)) return err('Invalid address'); + const page = parseInt(String(q.page ?? '1'), 10) || 1; + const offset = Math.min(parseInt(String(q.offset ?? '10'), 10) || 10, 100); + const items = await blockscoutTxList(normalizeAddress(address), page, offset); + return ok(items); + } + + if (module === 'chain138' && action === 'attestation') { + const txHash = String(q.txhash ?? q.txHash ?? ''); + if (!/^0x[a-fA-F0-9]{64}$/.test(txHash)) return err('Invalid txhash'); + const base = publicBaseFromQuery(q); + const r = await fetch(`${base}/v1/tx/${txHash}/attestation`); + if (!r.ok) return err('attestation not found'); + return ok(await r.json()); + } + + if (module === 'chain138' && action === 'participantactivity') { + if (!/^0x[a-fA-F0-9]{40}$/.test(address)) return err('Invalid address'); + const normalized = normalizeAddress(address); + const index = buildAddressIndex(cfg.batchPayloadDir); + const { total, items } = getAddressTransactions( + index, + normalized, + Math.min(parseInt(String(q.offset ?? '50'), 10) || 50, 200), + parseInt(String(q.page ?? '0'), 10) || 0 + ); + const summary = summarizeAddressActivity(index.byAddress[normalized.toLowerCase()] ?? []); + const onChainHint = + total === 0 && cfg.addressActivityRegistry + ? { + registry: cfg.addressActivityRegistry, + query: + 'GET /v2/api?chainid=1&module=chain138mirror&action=participantlogs&address=' + + normalized, + } + : null; + return ok({ total, summary, items, onChainHint }); + } + + if (module === 'proxy' && action === 'eth_blockNumber') { + const provider = new ethers.JsonRpcProvider(cfg.chain138Rpc); + const block = await provider.getBlockNumber(); + return ok(`0x${block.toString(16)}`); + } + + return err(`Unsupported module/action for chainid=138: ${module}/${action}`); +} + +function publicBaseFromQuery(q: Request['query']): string { + const override = String(q.indexerBase ?? process.env.CHECKPOINT_PAYLOAD_PUBLIC_URL ?? ''); + if (override.startsWith('http')) return override.replace(/\/v1\/payload\/?$/, '').replace(/\/$/, ''); + return `http://127.0.0.1:${cfg.port}`; +} + +async function handleMainnetAttestation( + action: string, + q: Request['query'], + apiKey: string +): Promise<{ status: string; message: string; result: unknown }> { + const registry = cfg.addressActivityRegistry; + const mirror = cfg.legacyMirror; + const address = String(q.address ?? ''); + + if (action === 'contracts') { + const explorerBase = chain138ExplorerBase(); + return ok({ + transactionMirror: mirror, + addressActivityRegistry: registry || null, + checkpointHub: cfg.checkpointProxy || null, + chain138Explorer: { + base: explorerBase, + txUrlTemplate: `${explorerBase}/tx/{txHash}`, + }, + eventTopics: { + TransactionMirrored: '0xc25ce5062c7e42c68fa21fe088d21e609cc0c61b8bec3180681363bb5cf02a9e', + ParticipantCredited: '0x853ebad0824f583553bcb4360a0df947d39654e86aba8d7c0625499cbf09f0d5', + ParticipantDebited: '0xf122cad7c8ada37ddd6b6ddca0982b9f7be689cde15eedfd767cc207fa7dc2c8', + }, + }); + } + + if (action === 'participantlogs') { + if (!registry && !cfg.addressActivityRegistryV2) { + return err('ADDRESS_ACTIVITY_REGISTRY_MAINNET or V2 not configured'); + } + if (!/^0x[a-fA-F0-9]{40}$/.test(address)) return err('Invalid address'); + if (!apiKey) return err('ETHERSCAN_API_KEY required for mainnet log proxy'); + + const topic = padTopicAddress(address); + const params = new URLSearchParams({ + fromBlock: String(q.fromBlock ?? '0'), + toBlock: String(q.toBlock ?? 'latest'), + }); + + let credited = { status: '0', message: 'No records', result: [] as unknown[] }; + let debited = { status: '0', message: 'No records', result: [] as unknown[] }; + + if (registry) { + const regParams = new URLSearchParams([...params.entries(), ['address', registry]]); + credited = await etherscanV2GetLogs( + new URLSearchParams([ + ...regParams.entries(), + ['topic0', '0x853ebad0824f583553bcb4360a0df947d39654e86aba8d7c0625499cbf09f0d5'], + ['topic2', topic], + ]), + apiKey + ); + debited = await etherscanV2GetLogs( + new URLSearchParams([ + ...regParams.entries(), + ['topic0', '0xf122cad7c8ada37ddd6b6ddca0982b9f7be689cde15eedfd767cc207fa7dc2c8'], + ['topic3', topic], + ]), + apiKey + ); + } + + let surfaceNotify: { + status: string; + result: unknown[]; + decoded: ReturnType; + } | null = null; + if (cfg.participantSurface) { + const surfaceRaw = await etherscanV2GetLogs( + new URLSearchParams([ + ...params.entries(), + ['address', cfg.participantSurface], + ['topic0', CHAIN138_ACTIVITY_NOTIFIED_TOPIC0], + ['topic1', topic], + ]), + apiKey + ); + const surfaceEnv = surfaceRaw as { status: string; result: unknown[] }; + surfaceNotify = { + status: surfaceEnv.status, + result: surfaceEnv.result ?? [], + decoded: decodeChain138ActivityNotifiedLogs( + surfaceEnv as { + status: string; + result: Array<{ + topics?: string[]; + transactionHash?: string; + blockNumber?: string; + logIndex?: string; + data?: string; + }>; + }, + chain138ExplorerBase() + ), + }; + } + + let isoV2: { status: string; result: unknown[]; decoded: ReturnType } | null = + null; + if (cfg.addressActivityRegistryV2) { + const v2raw = await etherscanV2GetLogs( + new URLSearchParams([ + ...params.entries(), + ['address', cfg.addressActivityRegistryV2], + ['topic0', PAYMENT_ATTESTED_TOPIC0], + ['topic3', topic], + ]), + apiKey + ); + const v2env = v2raw as { status: string; result: unknown[] }; + isoV2 = { + status: v2env.status, + result: v2env.result ?? [], + decoded: decodePaymentAttestedLogs( + v2env as { status: string; result: Array<{ topics?: string[]; transactionHash?: string; blockNumber?: string; logIndex?: string }> }, + chain138ExplorerBase() + ), + }; + } + + return ok({ + participant: normalizeAddress(address), + registry: registry || null, + registryV2: cfg.addressActivityRegistryV2 || null, + participantSurface: cfg.participantSurface || null, + credited: enrichRegistryLogsEnvelope(credited), + debited: enrichRegistryLogsEnvelope(debited), + surfaceNotifications: surfaceNotify, + iso20022V2: isoV2, + chain138Explorer: { + base: chain138ExplorerBase(), + txUrlTemplate: `${chain138ExplorerBase()}/tx/{txHash}`, + }, + topicIndex: { + ParticipantCredited: { chain138TxHash: 'topic1', participant: 'topic2', counterparty: 'topic3' }, + ParticipantDebited: { chain138TxHash: 'topic1', counterparty: 'topic2', participant: 'topic3' }, + PaymentAttested: { chain138TxHash: 'topic1', instructionId: 'topic2', creditor: 'topic3' }, + Chain138ActivityNotified: { + participant: 'topic1', + chain138TxHash: 'topic2', + instructionId: 'topic3', + }, + }, + etherscanVisibility: { + transactionsTab: + 'Set CHECKPOINT_SURFACE_TOPLEVEL_ZERO_ETH=1 on aggregator for incoming 0 ETH txs to participant', + internalTxnsTab: 'Chain138ParticipantSurface wallet touch (walletTouchEnabled)', + eventsOnSurfaceContract: 'surfaceNotifications.decoded[] with chain138ExplorerUrl', + }, + note: + 'credited/debited.decoded[], surfaceNotifications.decoded[], and iso20022V2.decoded[] include chain138ExplorerUrl. Full pacs.008 XML in batch payloads and config/iso20022-omnl/messages.', + }); + } + + if (action === 'mirrorlogs') { + if (!/^0x[a-fA-F0-9]{64}$/.test(String(q.txhash ?? ''))) return err('Invalid txhash'); + if (!apiKey) return err('ETHERSCAN_API_KEY required'); + const txHash = String(q.txhash); + const params = new URLSearchParams({ + address: mirror, + fromBlock: String(q.fromBlock ?? '0'), + toBlock: String(q.toBlock ?? 'latest'), + topic0: '0xc25ce5062c7e42c68fa21fe088d21e609cc0c61b8bec3180681363bb5cf02a9e', + topic1: txHash, + }); + const logs = await etherscanV2GetLogs(params, apiKey); + return ok(logs); + } + + return err(`Unsupported chain138mirror action: ${action}`); +} diff --git a/services/checkpoint-indexer/src/iso20022LogDecode.ts b/services/checkpoint-indexer/src/iso20022LogDecode.ts new file mode 100644 index 0000000..c90495a --- /dev/null +++ b/services/checkpoint-indexer/src/iso20022LogDecode.ts @@ -0,0 +1,55 @@ +import { ethers } from 'ethers'; +import { chain138ExplorerTxUrl, chain138TxHashFromLogTopic } from './chain138Explorer'; + +export type DecodedIsoAttestationLog = { + chain138TxHash: string; + chain138ExplorerUrl: string; + instructionId: string; + uetr: string; + creditor: string; + debtor: string; + mainnetTxHash: string; + mainnetExplorerUrl: string; + blockNumber: string; + logIndex: string; +}; + +type EtherscanLogRow = { + topics?: string[]; + transactionHash?: string; + blockNumber?: string; + logIndex?: string; +}; + +/** topic0 = PaymentAttested(bytes32,bytes32,address,address,bytes32,...) — matches cast sig-event */ +export const PAYMENT_ATTESTED_TOPIC0 = + '0x827a09f9798dc544d2acc52ecdad1fe0f5fa859be89a74a39dd7cb986b4cb340'; + +export function decodePaymentAttestedLogs( + envelope: { status: string; result: EtherscanLogRow[] }, + explorerBase?: string +): DecodedIsoAttestationLog[] { + if (envelope.status !== '1' || !Array.isArray(envelope.result)) return []; + const out: DecodedIsoAttestationLog[] = []; + for (const log of envelope.result) { + const t = log.topics ?? []; + if (t.length < 4 || !log.transactionHash) continue; + const chain138TxHash = chain138TxHashFromLogTopic(t[1]); + if (!chain138TxHash) continue; + const instructionId = t[2] ?? ''; + const creditor = t[3] ? ethers.getAddress('0x' + t[3].slice(-40)) : ''; + out.push({ + chain138TxHash, + chain138ExplorerUrl: chain138ExplorerTxUrl(chain138TxHash, explorerBase), + instructionId, + uetr: '', + creditor, + debtor: '', + mainnetTxHash: log.transactionHash, + mainnetExplorerUrl: `https://etherscan.io/tx/${log.transactionHash}`, + blockNumber: String(log.blockNumber ?? ''), + logIndex: String(log.logIndex ?? ''), + }); + } + return out; +} diff --git a/services/checkpoint-indexer/src/merkle.ts b/services/checkpoint-indexer/src/merkle.ts new file mode 100644 index 0000000..84333b0 --- /dev/null +++ b/services/checkpoint-indexer/src/merkle.ts @@ -0,0 +1,39 @@ +import { + buildMerkleRoot, + merkleProofs as coreProofs, + merkleVerifyValueWei, + paymentLeafV1Hash, +} from '@dbis/checkpoint-core'; + +export type LeafInput = { + txHash: string; + from: string; + to: string; + value: string | bigint; + nativeValueWei?: string | bigint; + onChainValueWei?: string | bigint; + tokenValue?: string | bigint; + blockNumber: number | string; + blockTimestamp: number | string; + gasUsed: string | bigint; + success: boolean; +}; + +export function paymentLeafHash(chainId: number, leaf: LeafInput): string { + return paymentLeafV1Hash(chainId, { + txHash: String(leaf.txHash), + from: String(leaf.from), + to: String(leaf.to), + value: merkleVerifyValueWei(leaf as Record), + blockNumber: leaf.blockNumber, + blockTimestamp: leaf.blockTimestamp, + gasUsed: leaf.gasUsed, + success: leaf.success, + }); +} + +export { buildMerkleRoot }; + +export function merkleProofs(hashes: string[]): { root: string; proofs: string[][] } { + return coreProofs(hashes); +} diff --git a/services/checkpoint-indexer/src/participantSurfaceLogDecode.ts b/services/checkpoint-indexer/src/participantSurfaceLogDecode.ts new file mode 100644 index 0000000..9bbf421 --- /dev/null +++ b/services/checkpoint-indexer/src/participantSurfaceLogDecode.ts @@ -0,0 +1,66 @@ +import { ethers } from 'ethers'; +import { chain138ExplorerTxUrl, chain138TxHashFromLogTopic } from './chain138Explorer'; + +export type DecodedSurfaceNotificationLog = { + participant: string; + chain138TxHash: string; + chain138ExplorerUrl: string; + instructionId: string; + direction: number; + mainnetTxHash: string; + mainnetExplorerUrl: string; + blockNumber: string; + logIndex: string; +}; + +type EtherscanLogRow = { + topics?: string[]; + transactionHash?: string; + blockNumber?: string; + logIndex?: string; + data?: string; +}; + +/** topic0 = Chain138ActivityNotified(address,bytes32,bytes32,uint8,uint64,uint256,uint64,bool) */ +export const CHAIN138_ACTIVITY_NOTIFIED_TOPIC0 = + '0x8dfc8206f97c75e58117c233ab5193e49d50c17bd4d035cdafebcf6794168ffe'; + +export function decodeChain138ActivityNotifiedLogs( + envelope: { status: string; result: EtherscanLogRow[] }, + explorerBase?: string +): DecodedSurfaceNotificationLog[] { + if (envelope.status !== '1' || !Array.isArray(envelope.result)) return []; + const out: DecodedSurfaceNotificationLog[] = []; + for (const log of envelope.result) { + const t = log.topics ?? []; + if (t.length < 4 || !log.transactionHash) continue; + const participant = t[1] ? ethers.getAddress('0x' + t[1].slice(-40)) : ''; + const chain138TxHash = chain138TxHashFromLogTopic(t[2]); + if (!chain138TxHash) continue; + const instructionId = t[3] ?? ''; + let direction = 0; + if (log.data && log.data.length >= 66) { + try { + const decoded = ethers.AbiCoder.defaultAbiCoder().decode( + ['uint8', 'uint64', 'uint256', 'uint64', 'bool'], + log.data + ); + direction = Number(decoded[0]); + } catch { + /* non-fatal */ + } + } + out.push({ + participant, + chain138TxHash, + chain138ExplorerUrl: chain138ExplorerTxUrl(chain138TxHash, explorerBase), + instructionId, + direction, + mainnetTxHash: log.transactionHash, + mainnetExplorerUrl: `https://etherscan.io/tx/${log.transactionHash}`, + blockNumber: String(log.blockNumber ?? ''), + logIndex: String(log.logIndex ?? ''), + }); + } + return out; +} diff --git a/services/checkpoint-indexer/src/serialize.ts b/services/checkpoint-indexer/src/serialize.ts new file mode 100644 index 0000000..2d45b2d --- /dev/null +++ b/services/checkpoint-indexer/src/serialize.ts @@ -0,0 +1,13 @@ +/** JSON-safe hub header / nested values (ethers returns bigint). */ +export function jsonSafe(value: unknown): unknown { + if (typeof value === 'bigint') return value.toString(); + if (Array.isArray(value)) return value.map(jsonSafe); + if (value !== null && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = jsonSafe(v); + } + return out; + } + return value; +} diff --git a/services/checkpoint-indexer/src/server.ts b/services/checkpoint-indexer/src/server.ts new file mode 100644 index 0000000..e579c3c --- /dev/null +++ b/services/checkpoint-indexer/src/server.ts @@ -0,0 +1,320 @@ +import express from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import { ethers } from 'ethers'; +import { checkpointIndexerConfig as cfg } from './config'; +import { enrichLeaves, type LeafRecord } from './tokenEnrich'; +import { merkleVerifyValueWei } from '@dbis/checkpoint-core'; +import { merkleProofs, paymentLeafHash } from './merkle'; +import { jsonSafe } from './serialize'; +import { + buildAddressIndex, + getAddressTransactions, + summarizeAddressActivity, + type AddressIndex, +} from './addressIndex'; +import { chainlistSupplement, etherscanV2Api } from './etherscanV2Shim'; +import { chain138ExplorerBase, chain138ExplorerTxUrl } from './chain138Explorer'; + +const CHECKPOINT_ABI = [ + 'function getLatestBatchId() view returns (uint64)', + 'function latestCheckpointBlock() view returns (uint256)', + 'function getLatestCheckpoint() view returns (tuple(uint64,uint64,uint64,uint256,uint256,uint256,bytes32,bytes32,bytes32,bytes32,uint16,uint32,uint64,address,bytes32))', + 'function getCheckpoint(uint64) view returns (tuple(uint64,uint64,uint64,uint256,uint256,uint256,bytes32,bytes32,bytes32,bytes32,uint16,uint32,uint64,address,bytes32))', + 'function isTxIncluded(bytes32) view returns (bool,uint64)', +]; + +const MIRROR_ABI = ['function getMirroredTransactionCount() view returns (uint256)']; + +const app = express(); +const mainnet = new ethers.JsonRpcProvider(cfg.mainnetRpc); +const chain138 = new ethers.JsonRpcProvider(cfg.chain138Rpc); +const hub = cfg.checkpointProxy ? new ethers.Contract(cfg.checkpointProxy, CHECKPOINT_ABI, mainnet) : null; +const mirror = new ethers.Contract(cfg.legacyMirror, MIRROR_ABI, mainnet); + +let addressIndexCache: AddressIndex | null = null; +let addressIndexBuiltAt = 0; +const ADDRESS_INDEX_TTL_MS = 60_000; + +function getAddressIndex(): AddressIndex { + const now = Date.now(); + if (!addressIndexCache || now - addressIndexBuiltAt > ADDRESS_INDEX_TTL_MS) { + addressIndexCache = buildAddressIndex(cfg.batchPayloadDir); + addressIndexBuiltAt = now; + } + return addressIndexCache; +} + +function loadBatchPayload(batchId: string): { batchId?: string; leaves?: LeafRecord[] } | null { + const p = path.join(cfg.batchPayloadDir, `batch-${batchId}.json`); + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, 'utf8')) as { batchId?: string; leaves?: LeafRecord[] }; +} + +async function payloadWithEnrichedLeaves( + batchId: string, + raw: { batchId?: string; leaves?: LeafRecord[]; chainId?: number; paymentsRoot?: string } | null +): Promise { + if (!raw?.leaves?.length) return raw; + const chainId = raw.chainId ?? 138; + let leaves = raw.leaves; + if (cfg.enrichTokenTransfers || cfg.usdEnrichEnabled) { + leaves = await enrichLeaves(leaves, cfg.blockscoutApi); + } + const hashes = leaves.map((l) => + paymentLeafHash(chainId, { + txHash: String(l.txHash), + from: String(l.from), + to: String(l.to), + value: String(l.value ?? '0'), + nativeValueWei: l.nativeValueWei != null ? String(l.nativeValueWei) : undefined, + onChainValueWei: l.onChainValueWei != null ? String(l.onChainValueWei) : undefined, + tokenValue: l.tokenValue != null ? String(l.tokenValue) : undefined, + blockNumber: Number(l.blockNumber), + blockTimestamp: Number(l.blockTimestamp), + gasUsed: String(l.gasUsed ?? '0'), + success: Boolean(l.success), + }) + ); + const { root, proofs } = merkleProofs(hashes); + const leavesWithProofs = leaves.map((l, i) => ({ + ...l, + leafHash: hashes[i], + merkleProof: proofs[i], + })); + return { + ...raw, + batchId: raw.batchId ?? batchId, + leaves: leavesWithProofs, + merkleRootComputed: root, + merkleRootMatches: raw.paymentsRoot ? root.toLowerCase() === String(raw.paymentsRoot).toLowerCase() : null, + walletAttestationCopy: + 'Balances and payments as of Chain 138 checkpoint block in this batch, attested on Ethereum mainnet.', + }; +} + +app.get('/health', (_req, res) => { + res.json({ ok: true, proxy: cfg.checkpointProxy || null, batchDir: cfg.batchPayloadDir }); +}); + +/** Etherscan V2 supplement for Chain 138 (unsupported on official chainlist). */ +app.get('/v2/chainlist-supplement', chainlistSupplement); +app.get('/v2/api', (req, res) => { + void etherscanV2Api(req, res); +}); + +/** Public batch JSON for contentURI resolution (CHECKPOINT_PAYLOAD_PUBLIC_URL). */ +app.get('/v1/payload/batch-:batchId.json', (req, res) => { + const raw = loadBatchPayload(req.params.batchId); + if (!raw) return res.status(404).json({ error: 'batch not found' }); + res.type('json').send(JSON.stringify(raw, null, 2)); +}); + +app.get('/v1/checkpoint/latest', async (_req, res) => { + if (!hub) return res.status(503).json({ error: 'CHAIN138_MAINNET_CHECKPOINT_PROXY not set' }); + try { + const batchId = await hub.getLatestBatchId(); + const header = await hub.getLatestCheckpoint(); + let v1Count = 0n; + try { + v1Count = await mirror.getMirroredTransactionCount(); + } catch { + v1Count = 0n; + } + const raw = batchId > 0n ? loadBatchPayload(batchId.toString()) : null; + const payload = raw ? await payloadWithEnrichedLeaves(batchId.toString(), raw) : null; + res.json({ + source: 'v2', + batchId: batchId.toString(), + header: jsonSafe(header), + legacy: { mirrorCount: v1Count.toString(), tether: cfg.legacyTether }, + payload, + }); + } catch (e) { + res.status(500).json({ error: String(e) }); + } +}); + +app.get('/v1/checkpoint/:batchId', async (req, res) => { + if (!hub) return res.status(503).json({ error: 'proxy not set' }); + try { + const batchId = req.params.batchId; + const header = await hub.getCheckpoint(BigInt(batchId)); + const raw = loadBatchPayload(batchId); + const payload = raw ? await payloadWithEnrichedLeaves(batchId, raw) : null; + res.json({ batchId, header: jsonSafe(header), payload }); + } catch (e) { + res.status(500).json({ error: String(e) }); + } +}); + +app.get('/v1/tx/:txHash/attestation', async (req, res) => { + if (!hub) return res.status(503).json({ error: 'proxy not set' }); + try { + const txHash = req.params.txHash; + const [included, batchId] = await hub.isTxIncluded(txHash); + const raw = included && batchId > 0n ? loadBatchPayload(batchId.toString()) : null; + const payload = raw ? await payloadWithEnrichedLeaves(batchId.toString(), raw) : null; + res.json({ txHash, included, batchId: batchId.toString(), payload }); + } catch (e) { + res.status(500).json({ error: String(e) }); + } +}); + +/** Wallet helper: verify leaf against on-chain hub (client may also use @dbis/checkpoint-sdk). */ +app.get('/v1/checkpoint/:batchId/verify/:leafIndex', async (req, res) => { + if (!hub) return res.status(503).json({ error: 'proxy not set' }); + try { + const batchId = req.params.batchId; + const leafIndex = parseInt(req.params.leafIndex, 10); + const raw = loadBatchPayload(batchId); + if (!raw?.leaves?.length || leafIndex < 0 || leafIndex >= raw.leaves.length) { + return res.status(404).json({ error: 'leaf not found' }); + } + const payload = await payloadWithEnrichedLeaves(batchId, raw); + const leaves = (payload as { leaves?: LeafRecord[] }).leaves; + const leaf = leaves?.[leafIndex]; + if (!leaf || !(leaf as LeafRecord).merkleProof) { + return res.status(404).json({ error: 'proof missing' }); + } + const VERIFY_ABI = [ + 'function verifyPaymentInBatch(uint64 batchId, tuple(bytes32,address,address,uint256,uint256,uint64,uint256,bool) leaf, bytes32[] proof) view returns (bool)', + ]; + const verifyHub = new ethers.Contract(cfg.checkpointProxy, VERIFY_ABI, mainnet); + const l = leaf as LeafRecord; + const ok = await verifyHub.verifyPaymentInBatch( + BigInt(batchId), + [ + l.txHash, + l.from, + l.to, + merkleVerifyValueWei(l), + BigInt(String(l.blockNumber)), + BigInt(String(l.blockTimestamp)), + BigInt(String(l.gasUsed ?? 0)), + Boolean(l.success), + ], + (l as { merkleProof: string[] }).merkleProof + ); + res.json({ batchId, leafIndex, verified: ok, leaf }); + } catch (e) { + res.status(500).json({ error: String(e) }); + } +}); + +app.get('/v1/account/:address', async (req, res) => { + try { + const addr = req.params.address; + let blockTag: number | 'latest' = 'latest'; + if (req.query.atBatch) { + if (!hub) return res.status(503).json({ error: 'proxy not set' }); + const h = await hub.getCheckpoint(BigInt(String(req.query.atBatch))); + blockTag = Number(h.checkpointBlock); + } else if (hub) { + blockTag = Number(await hub.latestCheckpointBlock()); + } + const balance = await chain138.getBalance(addr, blockTag); + const index = getAddressIndex(); + const rows = index.byAddress[addr.toLowerCase()] ?? []; + const activity = summarizeAddressActivity(rows); + res.json({ + address: addr, + chainId: 138, + blockTag, + balanceWei: balance.toString(), + attestedActivity: activity, + mainnetSurfaces: { + transactionMirror: cfg.legacyMirror, + addressActivityRegistry: cfg.addressActivityRegistry || null, + checkpointHub: cfg.checkpointProxy || null, + }, + walletAttestationCopy: + 'Native balance as of Chain 138 checkpoint block. Attested payments (ETH + USD) via checkpoint batches on Ethereum mainnet.', + }); + } catch (e) { + res.status(500).json({ error: String(e) }); + } +}); + +app.get('/v1/account/:address/transactions', async (req, res) => { + try { + const addr = req.params.address; + const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 200); + const offset = parseInt(String(req.query.offset ?? '0'), 10) || 0; + const index = getAddressIndex(); + const { total, items } = getAddressTransactions(index, addr, limit, offset); + res.json({ + address: addr, + chainId: 138, + total, + limit, + offset, + items, + indexMeta: { + builtAt: index.builtAt, + batchCount: index.batchCount, + txCount: index.txCount, + }, + }); + } catch (e) { + res.status(500).json({ error: String(e) }); + } +}); + +app.get('/v1/account/:address/activity', async (req, res) => { + try { + const addr = req.params.address; + const index = getAddressIndex(); + const rows = index.byAddress[addr.toLowerCase()] ?? []; + const summary = summarizeAddressActivity(rows); + res.json({ + address: addr, + chainId: 138, + summary, + recent: rows.slice(0, 25).map((r) => ({ + ...r, + chain138ExplorerUrl: chain138ExplorerTxUrl(r.txHash), + })), + chain138Explorer: { + base: chain138ExplorerBase(), + txUrlTemplate: `${chain138ExplorerBase()}/tx/{txHash}`, + }, + etherscan: { + transactionMirrorEvents: `https://etherscan.io/address/${cfg.legacyMirror}#events`, + addressActivityEvents: cfg.addressActivityRegistry + ? `https://etherscan.io/address/${cfg.addressActivityRegistry}#events` + : null, + }, + }); + } catch (e) { + res.status(500).json({ error: String(e) }); + } +}); + +app.get('/v1/tx/:txHash/logs', async (req, res) => { + try { + const txHash = req.params.txHash; + const receipt = await chain138.getTransactionReceipt(txHash); + if (!receipt) return res.status(404).json({ error: 'receipt not found on chain 138' }); + res.json({ + txHash, + chainId: 138, + blockNumber: receipt.blockNumber, + status: receipt.status, + gasUsed: receipt.gasUsed.toString(), + logCount: receipt.logs.length, + logs: receipt.logs.map((log, i) => ({ + index: i, + address: log.address, + topics: log.topics, + data: log.data, + })), + }); + } catch (e) { + res.status(500).json({ error: String(e) }); + } +}); + +app.listen(cfg.port, () => { + console.log(`checkpoint-indexer listening on :${cfg.port}`); +}); diff --git a/services/checkpoint-indexer/src/tokenEnrich.ts b/services/checkpoint-indexer/src/tokenEnrich.ts new file mode 100644 index 0000000..031bd98 --- /dev/null +++ b/services/checkpoint-indexer/src/tokenEnrich.ts @@ -0,0 +1,50 @@ +import { + applyPrimaryTransferToLeaf, + fetchAllTokenTransfersForTx, + pickPrimaryFromSummaries, +} from '@dbis/checkpoint-core'; +import { checkpointIndexerConfig as cfg } from './config'; +import { enrichLeafUsdRecord } from './usdEnrich'; + +export type LeafRecord = Record; + +export async function enrichLeaves( + leaves: LeafRecord[], + blockscoutApi: string +): Promise { + const api = blockscoutApi.replace(/\/$/, ''); + const out: LeafRecord[] = []; + for (const leaf of leaves) { + const copy = { ...leaf }; + const txHash = String(leaf.txHash || ''); + const needsToken = + cfg.enrichTokenTransfers && + /^0x[a-fA-F0-9]{64}$/.test(txHash) && + !copy.token && + BigInt(String(copy.value ?? copy.nativeValueWei ?? '0')) === 0n; + + let allErc20: Awaited> = []; + if (needsToken || (cfg.usdEnrichEnabled && /^0x[a-fA-F0-9]{64}$/.test(txHash))) { + allErc20 = await fetchAllTokenTransfersForTx(api, txHash); + if (needsToken) { + applyPrimaryTransferToLeaf(copy, pickPrimaryFromSummaries(allErc20)); + } + } + + const withUsd = await enrichLeafUsdRecord(copy, { + apiBaseUrl: cfg.tokenAggregationApiUrl, + chainId: 138, + enabled: cfg.usdEnrichEnabled, + requestDelayMs: cfg.usdRequestDelayMs, + blockscoutApi: api, + preloadedErc20: allErc20, + }); + out.push(withUsd); + await sleep(60); + } + return out; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/services/checkpoint-indexer/src/usdEnrich.ts b/services/checkpoint-indexer/src/usdEnrich.ts new file mode 100644 index 0000000..6b910b0 --- /dev/null +++ b/services/checkpoint-indexer/src/usdEnrich.ts @@ -0,0 +1,25 @@ +import { + enrichLeafUsdFields, + fetchAllTokenTransfersForTx, + type TokenTransferSummary, + type UsdPricingConfig, +} from '@dbis/checkpoint-core'; +import type { LeafRecord } from './tokenEnrich'; + +export async function enrichLeafUsdRecord( + leaf: LeafRecord, + cfg: UsdPricingConfig & { + blockscoutApi: string; + preloadedErc20?: TokenTransferSummary[]; + } +): Promise { + if (cfg.enabled === false) return leaf; + if (leaf.usdEnrichedAt && leaf.transfers != null && leaf.valueUsd != null) { + return leaf; + } + + const allErc20 = + cfg.preloadedErc20 ?? + (await fetchAllTokenTransfersForTx(cfg.blockscoutApi, String(leaf.txHash))); + return (await enrichLeafUsdFields(cfg, leaf, allErc20)) as LeafRecord; +} diff --git a/services/checkpoint-indexer/tsconfig.json b/services/checkpoint-indexer/tsconfig.json new file mode 100644 index 0000000..fd9eab6 --- /dev/null +++ b/services/checkpoint-indexer/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/services/checkpoint-sdk/package.json b/services/checkpoint-sdk/package.json new file mode 100644 index 0000000..2e4d274 --- /dev/null +++ b/services/checkpoint-sdk/package.json @@ -0,0 +1,19 @@ +{ + "name": "@dbis/checkpoint-sdk", + "version": "0.1.0", + "private": true, + "description": "Wallet/dApp helpers for Chain 138 mainnet checkpoint attestation", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@dbis/checkpoint-core": "file:../../packages/checkpoint-core", + "ethers": "^6.13.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.4.0" + } +} diff --git a/services/checkpoint-sdk/src/index.ts b/services/checkpoint-sdk/src/index.ts new file mode 100644 index 0000000..2a2008c --- /dev/null +++ b/services/checkpoint-sdk/src/index.ts @@ -0,0 +1,64 @@ +import { ethers } from 'ethers'; +import { merkleProofs, merkleVerifyValueWei, paymentLeafV1Hash } from '@dbis/checkpoint-core'; + +const HUB_ABI = [ + 'function verifyPaymentInBatch(uint64 batchId, tuple(bytes32,address,address,uint256,uint256,uint64,uint256,bool) leaf, bytes32[] proof) view returns (bool)', + 'function isTxIncluded(bytes32 txHash) view returns (bool included, uint64 batchId)', + 'function getLatestBatchId() view returns (uint64)', +]; + +export type LeafRecord = Record; + +/** + * Client-side / indexer-assisted verification before high-value UI. + */ +export async function verifyPaymentInBatch( + hub: ethers.Contract | string, + provider: ethers.Provider, + batchId: bigint | number, + leaf: LeafRecord, + proof?: string[] +): Promise { + const contract = + typeof hub === 'string' ? new ethers.Contract(hub, HUB_ABI, provider) : hub; + const chainId = Number(leaf.chainId ?? 138); + const leafTuple = [ + leaf.txHash, + leaf.from, + leaf.to, + merkleVerifyValueWei(leaf), + BigInt(String(leaf.blockNumber)), + BigInt(String(leaf.blockTimestamp)), + BigInt(String(leaf.gasUsed ?? 0)), + Boolean(leaf.success), + ]; + const proofs = + proof ?? + (() => { + const h = paymentLeafV1Hash(chainId, { + txHash: String(leaf.txHash), + from: String(leaf.from), + to: String(leaf.to), + value: merkleVerifyValueWei(leaf), + blockNumber: leaf.blockNumber as number, + blockTimestamp: leaf.blockTimestamp as number, + gasUsed: leaf.gasUsed as bigint, + success: Boolean(leaf.success), + }); + return merkleProofs([h]).proofs[0]; + })(); + return contract.verifyPaymentInBatch(batchId, leafTuple, proofs); +} + +export async function fetchAttestation( + indexerBase: string, + txHash: string +): Promise { + const base = indexerBase.replace(/\/$/, ''); + const res = await fetch(`${base}/v1/tx/${txHash}/attestation`); + if (!res.ok) throw new Error(`indexer ${res.status}`); + return res.json(); +} + +export const WALLET_ATTESTATION_COPY = + 'Balances and payments as of Chain 138 checkpoint block in this batch, attested on Ethereum mainnet.'; diff --git a/services/checkpoint-sdk/tsconfig.json b/services/checkpoint-sdk/tsconfig.json new file mode 100644 index 0000000..3cacd25 --- /dev/null +++ b/services/checkpoint-sdk/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/services/relay/.env.bsc.example b/services/relay/.env.bsc.example index fb4ca20..77a9d6a 100644 --- a/services/relay/.env.bsc.example +++ b/services/relay/.env.bsc.example @@ -7,7 +7,8 @@ SOURCE_CHAIN_SELECTOR=138 DEST_CHAIN_NAME=BSC DEST_CHAIN_ID=56 -DEST_RPC_URL=https://bsc.publicnode.com +# Set BSC_RELAY_RPC_URL in smom-dbis-138/.env (ngrok). Scripts/casts use BSC_RPC_URL (Infura). +DEST_RPC_URL=${BSC_RELAY_RPC_URL} DEST_CHAIN_SELECTOR=11344663589394136015 DEST_RELAY_ROUTER=0x4d9Bc6c74ba65E37c4139F0aEC9fc5Ddff28Dcc4 DEST_RELAY_BRIDGE=0x886C6A4ABC064dbf74E7caEc460b7eeC31F1b78C diff --git a/services/relay/.env.local.example b/services/relay/.env.local.example new file mode 100644 index 0000000..67cd38a --- /dev/null +++ b/services/relay/.env.local.example @@ -0,0 +1,13 @@ +# Example operator overrides (copy to .env.local only for ad-hoc runs WITHOUT a named profile). +# Do NOT set DEST_CHAIN_ID, DEST_RPC_URL, DEST_RELAY_ROUTER, or DEST_RELAY_BRIDGE here when using +# start-relay.sh mainnet-cw — use the profile file instead. + +# Avalanche lane (prefer: bash start-relay.sh avax-cw) +# DEST_CHAIN_NAME=Avalanche +# DEST_CHAIN_ID=43114 +# DEST_RPC_URL=https://avalanche-c-chain.publicnode.com +# DEST_CHAIN_SELECTOR=6433500567565415381 +# DEST_RELAY_ROUTER=0xc9158759a7e3621f6bb191bf5d77605d6e25b410 +# DEST_RELAY_BRIDGE=0x635002c5fb227160cd2eac926d1baa61847f3c75 + +LOG_LEVEL=debug diff --git a/services/relay/.env.mainnet-cw b/services/relay/.env.mainnet-cw index eeecb47..ab68576 100644 --- a/services/relay/.env.mainnet-cw +++ b/services/relay/.env.mainnet-cw @@ -6,7 +6,8 @@ CCIP_ROUTER_CHAIN138=0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817 SOURCE_BRIDGE_ADDRESS=0x152ed3e9912161b76bdfd368d0c84b7c31c10de7 SOURCE_CHAIN_SELECTOR=138 -RPC_URL_MAINNET=https://mainnet.infura.io/v3/43b945b33d58463a9246cf5ca8aa6286 +# Publicnode avoids Infura 429 during mesh replay bursts (operator mesh scripts may share the same Infura key). +RPC_URL_MAINNET=https://ethereum-rpc.publicnode.com DEST_CHAIN_NAME=Ethereum Mainnet DEST_CHAIN_ID=1 DEST_CHAIN_SELECTOR=5009297550715157269 @@ -17,8 +18,8 @@ DEST_RELAY_BRIDGE_ALLOWLIST=0x2bF74583206A49Be07E0E8A94197C12987AbD7B5 RELAYER_PRIVATE_KEY=${PRIVATE_KEY} RELAYER_ADDRESS=0x4A666F96fC8764181194447A7dFdb7d471b301C8 -# Historical cW backfill is complete; cold starts should resume near head rather -# than requeueing legacy unsupported canonical-token messages. +# After mesh replay completes, set START_BLOCK=latest for cold starts. +# Mesh batch lockAndSend cluster ~block 0x514ff6 (5320694); use 5320000 for replay. START_BLOCK=latest POLL_INTERVAL=5000 CONFIRMATION_BLOCKS=1 diff --git a/services/relay/.env.mainnet-weth b/services/relay/.env.mainnet-weth index 7907851..c4805df 100644 --- a/services/relay/.env.mainnet-weth +++ b/services/relay/.env.mainnet-weth @@ -12,7 +12,9 @@ DEST_CHAIN_ID=1 DEST_CHAIN_SELECTOR=5009297550715157269 DEST_RELAY_ROUTER=0x416564Ab73Ad5710855E98dC7bC7Bff7387285BA DEST_RELAY_BRIDGE=0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939 -DEST_RELAY_BRIDGE_ALLOWLIST=0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939 +CCIP_RELAY_BRIDGE_LINK_MAINNET=0x2cd963d54a7Af576Fea71292f961Bf2604f3583A +DEST_RELAY_BRIDGE_LINK=0x2cd963d54a7Af576Fea71292f961Bf2604f3583A +DEST_RELAY_BRIDGE_ALLOWLIST=0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939,0x2cd963d54a7Af576Fea71292f961Bf2604f3583A RELAYER_PRIVATE_KEY=${PRIVATE_KEY} RELAYER_ADDRESS=0x4A666F96fC8764181194447A7dFdb7d471b301C8 diff --git a/services/relay/README.md b/services/relay/README.md index 8ed8f8b..eb3abac 100644 --- a/services/relay/README.md +++ b/services/relay/README.md @@ -25,10 +25,22 @@ Important: on 2026-04-04, a direct `138 -> Arbitrum` WETH send produced a real s ## Env Profiles Use the prebuilt env files in this folder: +- `.env.mainnet-cw` — Chain 138 cW → **Ethereum mainnet** (`CW_BRIDGE_MAINNET`) +- `.env.mainnet-weth` — WETH lane to mainnet - `.env.bsc` (template: `.env.bsc.example`) -- `.env.avax` +- `.env.avax-cw` — cW → Avalanche +- `.env.avax` — WETH → Avalanche - `.env` (default/fallback) -- `.env.local` only for local overrides that should beat the tracked profiles +- `.env.local` — **only** when running without a named profile, or set `RELAY_ALLOW_ENV_LOCAL=1` + +**Pre-flight (required before restart):** + +```bash +./scripts/verify/validate-relay-profiles.sh +./scripts/verify/diagnose-cw-mesh-ccip-relay.sh # mainnet cW lane + balances +``` + +Named profiles **do not** load `.env.local` (prevents mainnet router + Avalanche RPC mismatch). Each profile sets destination RPC, selector, relay router/bridge, and destination WETH token. @@ -60,6 +72,12 @@ From repo root: # ./scripts/bridge/fund-mainnet-relay-bridge.sh 1000000000000000 # 0.001 WETH wei ``` +## Destination tx confirmation timeout + +| Env | Default | Purpose | +|-----|---------|---------| +| `RELAY_TX_CONFIRM_TIMEOUT_MS` | `180000` (3 min) | Max wait for `tx.wait()` on mainnet relay txs. On timeout the message is retried instead of blocking the queue processor indefinitely. | + ## Relay shedding (save destination gas) When **no** 138→Mainnet (or configured destination) relay deliveries are needed, pause **destination-chain** transactions so the relayer does not spend native gas on `relayMessage` / direct `ccipReceive`: @@ -153,15 +171,24 @@ Example explorer backend wiring: ```bash CCIP_RELAY_HEALTH_URL=http://192.168.11.11:9860/healthz -CCIP_RELAY_HEALTH_URLS=mainnet=http://192.168.11.11:9860/healthz,bsc=http://192.168.11.11:9861/healthz,avax=http://192.168.11.11:9862/healthz +CCIP_RELAY_HEALTH_URLS=mainnet-weth=http://192.168.11.11:9860/healthz,mainnet-cw=http://192.168.11.11:9863/healthz,bsc=http://192.168.11.11:9861/healthz,avax=http://192.168.11.11:9862/healthz ``` Recommended systemd ports when running multiple relay workers on the same host: -- Mainnet: `9860` +- Mainnet WETH (default `.env`): `9860` +- Mainnet cW (`ccip-relay-mainnet-cw.service`): `9863` - BSC: `9861` - Avalanche: `9862` +### BSC profile (`start-relay.sh bsc`) + +- **Source:** Chain 138 public RPC (`RPC_URL_138` in `.env.bsc`). +- **Destination:** `BSC_RELAY_RPC_URL` in `smom-dbis-138/.env` (ngrok to operator BSC node on chain 56). +- **Upstream (not used for relay txs):** `BSC_RPC_URL` / Infura — for operator `cast` and health cross-checks. +- Sync + restart on r630-01: `../../../../scripts/deployment/sync-ccip-relay-bsc-r630-01.sh` +- Verify: `../../../../scripts/verify/check-bsc-relay-rpc.sh` + ## Critical Requirements - Relayer key must hold native gas on destination chain. diff --git a/services/relay/data/queue-state.json b/services/relay/data/queue-state.json new file mode 100644 index 0000000..0a9782e --- /dev/null +++ b/services/relay/data/queue-state.json @@ -0,0 +1,999 @@ +{ + "version": 1, + "queue": [ + { + "messageId": "0x6475a18f3d3ea0e2202be6195cf8a86c4e139e6eb444b2c7e5f84cc3c84ed8ca", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b27", + "transactionHash": "0x95983667b298d4763fb3dc1d4cc939b3f4d758afc19f2fb9287e1c004cdc63eb" + }, + { + "messageId": "0x2a4f28e673e93b08a52e1d08013e5f6630bd8a03ad983e397f8bb571b68b9f5e", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b2a", + "transactionHash": "0x19debf7c4076758de46a5d0723dde836930f850ac37e80d21c01d6a0de303198" + }, + { + "messageId": "0x8f05ecd8f476b9c6f5d3dbafb1fdcedeb5a8ac4aeeaf2721a992079e811c586d", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b2d", + "transactionHash": "0x9c4b9ddc5319d548e4d953b9b64338a1cc9354bd300e61aae125de9ad30986c0" + }, + { + "messageId": "0x82eb42eeb285974e9b25944ace6507545fe19b2b095e7a4f851e3b707292476f", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b2f", + "transactionHash": "0xee6f5def4f9eabdfce0b4b2b3b86a48c3f8aa056b14ca8cf6ac809ffc07271db" + }, + { + "messageId": "0xbb5da1f890acfdef2d82ff2a46da230d9777ce84f4b342a6a8c8f5a80c08d091", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b32", + "transactionHash": "0xa93f6b803e2a13ebe5ff32b708eab572688fbef20bc8a64abf831b29b9490246" + }, + { + "messageId": "0xfba2fa492768a0bc9393c5aecb70c7124a1a7e1793e365ed089a559d3032906f", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b35", + "transactionHash": "0xb1937494a79829e8fdf28996f0c03a7cff55e1488a717336a0ded35e68f1ecb2" + }, + { + "messageId": "0xe5ca824ee36ac99fcf50faf72442ef3d487c1310de68ab8430dad416b36c16ee", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b37", + "transactionHash": "0x1f4e861529be6f11f4242d7a0e19877b26c4b4e2e97aedc0b6eaba99e6855d49" + }, + { + "messageId": "0x57e51307699bc7720bee289f1b2d410d87965e24beee2eeb052bdd939386a0e6", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b3a", + "transactionHash": "0xe282f9e07459004f4c2842c3f764243c8a1ee15b24e40ea57d4c75ff92c73129" + }, + { + "messageId": "0xb66f0fcc9e7d1e6fbc2dbf6cc08660ff9dee8353bad32c587b0bae03bcf602bd", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b3d", + "transactionHash": "0x683e424ed7a36f48b9692ce09db25f2128b36d41d8ff13b470aeb796a816890c" + }, + { + "messageId": "0xd904b859e5501d0056c72fe48665ef7ebfada599501c7588c9a274dd35f3edc1", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b3f", + "transactionHash": "0xbe80d3f98b1cde79e8783a16bfde63907593bd2f0b3e2bfa9ff1e6b38f3439ac" + }, + { + "messageId": "0x414997c14c4654ee29cc496c11e3ca2e4a656ad4254cf21572c5356a90152d5e", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b42", + "transactionHash": "0x8bb0d34e6cab4800616f7f806895ae93cf928852597298b7989d88c00f25cc91" + }, + { + "messageId": "0x824d0f8fe9eef0f8f0a34b216465fefc6cdc5959ca5c7a939289315e20ee53ca", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b45", + "transactionHash": "0xed509d95744754290155e552249625baa576eee49aac67af746d9565e29fa462" + }, + { + "messageId": "0xe088429f9895bf7f54d2d684284ebef836ee1401eda9cb3cecaae3d1e784098d", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b47", + "transactionHash": "0x9215b090f79a96f8f5a51ed6fb4ecfafd73294d1180e9a99f2ab7b51def7da3e" + }, + { + "messageId": "0x8c4cb6e50d4ca9381f5d410bf54d341f057225e40726c350405e10c9241d5b0f", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b4a", + "transactionHash": "0xdacf1d933d677606286be22808fcafe08b117c8511e64c2f9324278211eccf05" + }, + { + "messageId": "0xaf5c40d6f003dd94760b2a7169c15113e4aba2efcf5cface1429c5805094c6cf", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b4d", + "transactionHash": "0x7f424945c325e6eb9aa4f3495caa1b7352fbf73c840b2c596e4c7be0d464443f" + }, + { + "messageId": "0x90c87be0261bbed83e94847aeed7743087c1a88c1c7eb961f0b5e2761dbd9393", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b4f", + "transactionHash": "0x3321b73c1339c2efd19f873c16330ba7d7303e2d984982c50f2e36fa00c7ab3e" + }, + { + "messageId": "0x4f7f8c5caf1a82691d814aa78deb14efb79caf665ae78f8a8cc05ef8ce508a07", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b52", + "transactionHash": "0xdf7eb5183a5bbe1ab5736d470cdc82b1ee8270c07788cd293ecf2c53f6401133" + }, + { + "messageId": "0xf75b1843fa2e6f37fc15a783b5fe7b4140ca5d601e2f5209926f26081b7e8e69", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b55", + "transactionHash": "0xfddc51a7eb046c8c4fc3addba2c2ec48202ae812a9cd7f212e75694957dfcc33" + }, + { + "messageId": "0xab590a1f31c0d7e328e2e82f2ddddde65b704bcac094e0f45725c081d371c802", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b57", + "transactionHash": "0xb5ba3f9ae68f2d84e17d5c6f1a8011955c16690701ba61f746b8bb22ef3d6775" + }, + { + "messageId": "0xc3b33a30a9b42c15d107088c8ef0de9a1815876903bb988a717ffa5045f126f2", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b5a", + "transactionHash": "0x1d2230408979dcd280421d4306fc329dde655c8e378d0c0f889626aec5f87ea0" + }, + { + "messageId": "0x8068247576aa5a592e3f06b7db0397c66d1bda011ce47722464f4fef1ddc8d62", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b5d", + "transactionHash": "0xd028910c90589b67854b0e62b827da5600d12777899185ad5f28e1e70df94300" + }, + { + "messageId": "0x9eb5df676c24f3376ab06ca1f9857b0e96a5cc6ce934c9ce20c40ab7745fa17b", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b5f", + "transactionHash": "0x7ee44f4b9c366a8f1c1b772299cc4ff714998298eb0edc24f38cddda6fcc23f8" + }, + { + "messageId": "0x6478c09f3252f19de66dbade528daedaa41d0dcd687507f542fa095421883590", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b62", + "transactionHash": "0xd84b9845e1ae68467952919088fff5fb647d5ecf6f9edefbd98fcc4d7d1ebd11" + }, + { + "messageId": "0xc5775aa57ba21ca45085fd37505781fe7ec3c99ea56b1cbacedcac02fdafa506", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b64", + "transactionHash": "0x0c78efbb9ada0283d2a04575856dad6483715320bb3a84144bea2344c53071d1" + }, + { + "messageId": "0x5123e5bcb1a304ef77472af3a012d3592d963997e19cf27cc38f969fd94e524f", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521ac5", + "transactionHash": "0x6cadf7cf17e3b259aa716f2e08529612602f1a8f574d793da61499f78323841d" + }, + { + "messageId": "0x6abbfedec3878c0c2cf507d4e6ba6981a822e229a1b491f0107152d442f4b951", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521ac8", + "transactionHash": "0xcdcca529e3ab818760e7b330796d20dbce8a35020e4c63b4c56301a60067ed82" + }, + { + "messageId": "0x090e19de12735664efb4d5855754b76931ccda088290371714fb7f423ff8cd3d", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000f22258f57794cc8e06237084b353ab30fffa640b0000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000000000000f4240", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521aca", + "transactionHash": "0x19fcea61bf2cbc4e3ad8a665e36a2e88f3d92398a78d68bdd4da961676011e13" + }, + { + "messageId": "0xbda1e691d24e65fb75d27c38de83f76e390533c50770fe7f3b4d04f0315a059a", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521ace", + "transactionHash": "0x4b2b39131b1f1483aa6fe336aefc35ce9811f1473fbf3a03949a8318a9799a83" + }, + { + "messageId": "0x6850215d788a42763f2fc61aa8a871cec3abbe8cda8a3ae99926d0dc572fbead", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521ad1", + "transactionHash": "0xaa329b0a3f6ac47476290be7a6bdd3337a3a4335f7405f4713d67681fc09c8e6" + }, + { + "messageId": "0x97a4613f0824535c6f4a0008ac55612ee696042a5f836ddcf0c1062ef085e815", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521ad4", + "transactionHash": "0x487ed2777908bdcabde261a5f3e7349379791dbfeb37dfbdd3051fac959f1349" + }, + { + "messageId": "0xa9e1a552c4855bf5e43044c2d4503a59cc418131be57fa3005ac29062ebc206d", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521ad7", + "transactionHash": "0x48cf699d7a2d5b0e0fd6c64b92ccffd7da7d343d0a1c0d6fee932f61a8c22c72" + }, + { + "messageId": "0xe426129b8e4783306ced41d837d97a8ce9799795a3666809963996987704f940", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521ad9", + "transactionHash": "0xa5fc521d0ede487ee81150dea0c32335be7b4871a1a896dee3d49441fff2cb2e" + }, + { + "messageId": "0x613395e8345e27a4de8be4e77a36327acba07b92edd76063c19446acd9b40a68", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521adc", + "transactionHash": "0xed4cd3bf7ac5419639b94cd74c203e76adaecfb003a439a5f893bb5b917e005c" + }, + { + "messageId": "0xd451a229c56de452ec8aab34ed7f739db6d484fc945e2a9d8e3376f9462bca6f", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521adf", + "transactionHash": "0xcfd3308a233464c74a8568b54a3bc8d5f0e9cc96e870530bca58db4d6ce4d8c1" + }, + { + "messageId": "0x2706a4168006eccb313e82216bfbebda3540ef57037ef928867c310218ffd688", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521ae2", + "transactionHash": "0x65b008996dab82adf808eca96da9ad4eead8065babb9732b7f20e45787cd0419" + }, + { + "messageId": "0x3c10e1776c12ce5afac20e952aff1be6fdd841dc8f26635a5a188048f17c60e9", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521ae4", + "transactionHash": "0x8497125e7ccbc5994fadb8ccec94dadbc949beea2fcbb30f4073b69b50beebcc" + }, + { + "messageId": "0xb1dec0ab3084f160f2cecc1965aaa57e8114f517da27073c930a0b9f9249a1ac", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521ae7", + "transactionHash": "0x58c1529fe05f82230c424559be00f6283ef36c0211c81fa313425b5aceb86f48" + }, + { + "messageId": "0x8a6c0881a2db805e4a994ff505c58967c7625f23185159be91b7215345ad0a1a", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521aea", + "transactionHash": "0x28c1c23da091873ebb07fc7d5da89d7e097033ca694ed24c37210266af9cf938" + }, + { + "messageId": "0xaa1866b03348d7f0b273c2a25ad202a65f8316564cb6cd23b7ff987f9cb14950", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521aec", + "transactionHash": "0x93041b602f7495904ef354b0b6fb41dd88e53eb174ec9de9552fbb2da3fcc7b9" + }, + { + "messageId": "0x3ccb8dd654ef6f13e4f7ac9b387cd76b342b2f2980b01f55d8642f40dc538757", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521aef", + "transactionHash": "0x311594a64b4ad92f401d512c7dd2b1f458a67c0ef70227774feff91e4935af4c" + }, + { + "messageId": "0xdb686cf93e92f95bfb795d70d600264eebfa35496f1b927b8721257366b4224f", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b67", + "transactionHash": "0x15126f05b4b1cf01c3d94b2124585574bb80c8a915ff782c3c501c07acd58ad6" + }, + { + "messageId": "0x45d606b544b356b8941502d61fe9311ea53fcd15b4db4acea62aa07f9913d786", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b6a", + "transactionHash": "0x4c92513a3df5af35ca95e195989e8034c122c8086dfddc8ad9fc421202bb1c3f" + }, + { + "messageId": "0x9f8f4481de0c9bd2fcdef094de0c0de53bcce15ecba6cdf0a1901adf7c50fbfa", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b6c", + "transactionHash": "0xd81dbaecc4ad22c44be93b2d8283e4c6cdd491a2cf3921a31c2917d373c944b1" + }, + { + "messageId": "0x60411d905c89707553466c1688a196a6860b08f55fd973e964f9dfb4640fe38f", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b6f", + "transactionHash": "0x4d46cb2faa169d279e10c788b49281db48df51771807cc770b5e5e654c52f1c4" + }, + { + "messageId": "0xb3707524718b29a865caf2b0d23b6940c26a21a05a9841f97170d7230777d1ed", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b72", + "transactionHash": "0xfd2b17cc4f3d8335c3e0993baf0abc8235d8e02561a437d0e23a05dbd56a0cba" + }, + { + "messageId": "0x42b0523e92569ebbcf353f8a1e92553a700a3aba7c6773948a8e156a666068b9", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b74", + "transactionHash": "0x3a29ba483a0c2d662ebe20c6e896eaf1753a2694cc72a3a70dc3e33886171b84" + }, + { + "messageId": "0xca50008aefbbf8f3998bbd5b06f57daf8dc54e9a084414840debafb9bb9a5eb5", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b77", + "transactionHash": "0x03f028e27e1452445d300650c6923c48d0502dd4ff073c376cbec2c0c8717af4" + }, + { + "messageId": "0xe0ae8009907519093bb9d611f7c11efbed1f735314c4115aa460ec4accf10006", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b7a", + "transactionHash": "0x8317b9d260523703ec70fdfd8a00468d34ef69f938d88c4556012c5c4459591d" + }, + { + "messageId": "0xabb92d90b5fde892e056ede91ea8c902df3808077e94ab99c23adfb57d314bd5", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b7c", + "transactionHash": "0x0b01a33e8786b82350388a360e889d19d126ea6c2cdf07082ee763d3b06239d9" + }, + { + "messageId": "0x61d542d1a89d6360cfd278ebbfd36d56ce23509460fb9ecc55c82e02dcd65721", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b7f", + "transactionHash": "0x5031b1e184519317cbb7ae5537092441f80f0a6392174b8a182592e77430f731" + }, + { + "messageId": "0x63de110941e22871739358a27b6c9ef2308ab349d492653366fd6ebd413a1f7c", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b82", + "transactionHash": "0x8bf6dfc793b5ce2590cfc720107dd95eda4222bff0b7a18c0f5a10689cfe5206" + }, + { + "messageId": "0xd36dd7a82f9a8ff788407981b30119d20b68523b211303500468c62efab88e84", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b84", + "transactionHash": "0x97a1deb0ca265cb0809ba8bf15678614652104df982cbec81bf141cfce01b880" + }, + { + "messageId": "0x1381c5c9f6404b7fd3658eaad9fa1fd7f24e93d39f32951f0afbf06164c302c9", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b87", + "transactionHash": "0x8f0f606332fbefc015bc250244e6581284fac03596691f4c0b4cdbbe7e059264" + }, + { + "messageId": "0x1a1bc18ad900c548f09124fd87e838b13a5f2912b83b3ec6e7c9caa9285dc20d", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b8a", + "transactionHash": "0x497001c2d0df140035676dda4c760dd50d74c1527a6ddbc2b0db687c46d1a5ea" + }, + { + "messageId": "0xd9e424ecdcf71655521edc8ee1b49491818af7b776761b0ac61b0911cb2f212f", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b8c", + "transactionHash": "0x3ea888ae2bb9346dd2d15bec6b6a8637a570331e4c607ad84e0bbcb73b9ce1cf" + }, + { + "messageId": "0x3bb9cf2d858ff012beab3a2e85198f0acbc088afa8c3d80f53074c82ea1a8694", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b8f", + "transactionHash": "0x296baa4ef9cd5d4b471b1d2e726fb564f3a29d70b7aef7c874b2aef57bf6dbde" + }, + { + "messageId": "0x0e27d284bb17bc331c54d0771cededf2dc9b0846cb24fcd3914f4c1418f2f9fd", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b92", + "transactionHash": "0x36554cc2bcce0bf8c4bb4a4a371ba74e24e545f2489224c8b408f61d94e00ae6" + }, + { + "messageId": "0x59879617e10d86e85c32df5745710bbc509a0eeb5f19874b88cc067058afd662", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b94", + "transactionHash": "0xcb2968b5abcc45d2bda3e4f6337f153cc2bd47fd184b9cb2ce41530dec548928" + }, + { + "messageId": "0x1ae4b5ef6cd370ce83221505541fa06baa7a786c14e354623b66ddc80efb4e53", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b97", + "transactionHash": "0xf85e56b59e75e3d427d0c6fd617f4530c914bd1c2e0e236be4e5259807e4a5fb" + }, + { + "messageId": "0x758a68dae35dee4d43032a2313d77f239dc38cfebf7f4adb320fc7e6d6529a07", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b99", + "transactionHash": "0x32060bb01e9a607c7749f3e0517552562f176d2cec98453cf067e3b8ed8bff2f" + }, + { + "messageId": "0x1a7dcf8eadec5b29b6c62df9bac55ad77e901992b240c2003f4cb199cc8c017c", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b9c", + "transactionHash": "0x3fe68e4cdbf7c3acc3a35ec18ff26720bc3bc39236fff32369237b8d638e2331" + }, + { + "messageId": "0xa0bb0160d0d22c9939091293c73ab0eef5505d8cf03b29de2faa4b3240d49f27", + "destinationChainSelector": { + "__type": "bigint", + "value": "5009297550715157269" + }, + "sender": "0x152eD3e9912161b76BDFd368D0C84B7C31C10dE7", + "receiver": "0x0000000000000000000000002bf74583206a49be07e0e8a94197c12987abd7b5", + "targetBridge": "0x2bF74583206A49Be07E0E8A94197C12987AbD7B5", + "data": "0x000000000000000000000000ee269e1226a334182aace90056ee4ee5cc8a67700000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c800000000000000000000000000000000000000000000000000038d7ea4c68000", + "tokenAmounts": [], + "feeToken": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "extraArgs": "0x", + "blockNumber": "0x521b9f", + "transactionHash": "0xf7eece35028ae5f7626c3367a68d16128419b0790aaca621a444bf05c7d7a214" + } + ], + "retryCounts": {}, + "savedAt": "2026-05-19T02:23:48.224Z" +} \ No newline at end of file diff --git a/services/relay/src/MessageQueue.js b/services/relay/src/MessageQueue.js index 3e9d4d4..64b8011 100644 --- a/services/relay/src/MessageQueue.js +++ b/services/relay/src/MessageQueue.js @@ -25,8 +25,10 @@ export class MessageQueue { serialize() { return { - version: 1, + version: 2, queue: this.queue, + processedIds: Array.from(this.processed), + failedIds: Array.from(this.failed), retryCounts: Object.fromEntries(this.retryCounts.entries()), savedAt: new Date().toISOString() }; @@ -98,11 +100,20 @@ export class MessageQueue { this.retryCounts = new Map( restoredRetryCounts.map(([messageId, count]) => [messageId, Number(count) || 0]) ); + const restoredProcessed = Array.isArray(parsed.processedIds) ? parsed.processedIds : []; + const restoredFailed = Array.isArray(parsed.failedIds) ? parsed.failedIds : []; + for (const messageId of restoredProcessed) { + if (messageId) this.processed.add(messageId); + } + for (const messageId of restoredFailed) { + if (messageId) this.failed.add(messageId); + } this.lastPersistedAt = parsed.savedAt || null; - if (restoredQueue.length > 0) { + if (restoredQueue.length > 0 || restoredProcessed.length > 0) { this.logger.info( - `Loaded ${restoredQueue.length} queued relay message(s) from ${this.persistencePath}` + `Loaded relay snapshot from ${this.persistencePath}: ` + + `queue=${restoredQueue.length} processed=${restoredProcessed.length} failed=${restoredFailed.length}` ); } } catch (error) { @@ -154,6 +165,7 @@ export class MessageQueue { this.retryCounts.delete(messageId); this.inFlight.delete(messageId); this.messageStore.delete(messageId); + this.queue = this.queue.filter((m) => m.messageId !== messageId); await this.persistSnapshot(); this.logger.info(`Message ${messageId} marked as processed`); } diff --git a/services/relay/src/RelayService.js b/services/relay/src/RelayService.js index 2f5acdc..e6c0173 100644 --- a/services/relay/src/RelayService.js +++ b/services/relay/src/RelayService.js @@ -43,6 +43,42 @@ export class RelayService { this.lastError = null; } + async getDestinationBridgeContract(targetBridge) { + const key = String(targetBridge).toLowerCase(); + let contract = this.destinationBridgeContracts.get(key); + if (!contract) { + contract = new ethers.Contract(targetBridge, RelayBridgeABI, this.destinationProvider); + this.destinationBridgeContracts.set(key, contract); + } + return contract; + } + + /** True when destination bridge already recorded this messageId (no relay tx needed). */ + async isDeliveredOnDestination(messageId, targetBridge) { + if (process.env.RELAY_SKIP_DESTINATION_PROCESSED_PROBE === '1') { + return false; + } + if (!targetBridge || !this.destinationProvider) { + return false; + } + try { + const bridge = await this.getDestinationBridgeContract(targetBridge); + if (bridge.processed) { + if (await bridge.processed(messageId)) { + return true; + } + } + if (bridge.processedTransfers) { + if (await bridge.processedTransfers(messageId)) { + return true; + } + } + } catch (probeErr) { + this.logger.debug(`Destination processed probe failed for ${messageId}`, probeErr); + } + return false; + } + normalizeAddress(value) { if (!value) return ''; try { @@ -77,10 +113,24 @@ export class RelayService { return targetBridge; } + resolveBridgeForMessage(messageData) { + const linkBridge = this.normalizeAddress(this.config.destinationChain.relayBridgeLinkAddress); + const wethBridge = this.normalizeAddress(this.config.destinationChain.relayBridgeAddress); + const tokenAmounts = messageData.tokenAmounts || []; + if (tokenAmounts.length > 0 && linkBridge) { + const sourceToken = String(tokenAmounts[0].token || '').toLowerCase(); + const link138 = '0xb7721dd53a8c629d9f1ba31a5819afe250002b03'; + if (sourceToken === link138) { + return linkBridge; + } + } + return this.resolveTargetBridge(messageData.receiver) || wethBridge; + } + evaluateMessageScope(messageData) { const sourceBridge = this.getConfiguredSourceBridge(); const sender = this.normalizeAddress(messageData.sender); - const targetBridge = this.resolveTargetBridge(messageData.receiver); + const targetBridge = this.resolveBridgeForMessage(messageData); const allowlist = this.getDestinationBridgeAllowlist(); if (sourceBridge && sender && sender.toLowerCase() !== sourceBridge.toLowerCase()) { @@ -128,6 +178,25 @@ export class RelayService { return String(error.shortMessage || error.message || error); } + /** + * Fail fast when RPC URL points at the wrong chain (e.g. Avalanche RPC + mainnet DEST_CHAIN_ID). + */ + async assertNetworkAlignment(label, provider, expectedChainId) { + const network = await provider.getNetwork(); + const actual = Number(network.chainId); + const expected = Number(expectedChainId); + if (!Number.isFinite(expected) || expected <= 0) { + throw new Error(`${label} expected chainId not configured (${expectedChainId})`); + } + if (actual !== expected) { + throw new Error( + `${label} RPC chain-id mismatch: provider reports ${actual} but config expects ${expected}. ` + + 'Check DEST_RPC_URL / RPC_URL_MAINNET and .env.local (named profiles skip .env.local by default).' + ); + } + this.logger.info('%s RPC chain-id OK (%s)', label, actual); + } + recordError(scope, error, extra = {}) { this.lastError = { at: new Date().toISOString(), @@ -321,6 +390,31 @@ export class RelayService { txOptions.gasPrice = gasPrice; return txOptions; } + + /** Prevent indefinite hang when mainnet confirmation stalls (blocks queue processor). */ + async waitForDestinationReceipt(tx, messageId) { + const timeoutMs = parseInt(process.env.RELAY_TX_CONFIRM_TIMEOUT_MS || '180000', 10); + const safeTimeout = Number.isFinite(timeoutMs) && timeoutMs >= 30_000 ? timeoutMs : 180_000; + let timer; + try { + return await Promise.race([ + tx.wait(), + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `tx.wait timed out after ${safeTimeout}ms for ${messageId}${tx?.hash ? ` (${tx.hash})` : ''}` + ) + ), + safeTimeout + ); + }) + ]); + } finally { + if (timer) clearTimeout(timer); + } + } async start() { this.logger.info('Initializing relay service...'); @@ -329,6 +423,9 @@ export class RelayService { // on the nonstandard Chain 138 RPC even though direct manual queries succeed. this.sourceProvider = new ethers.JsonRpcProvider(this.config.sourceChain.rpcUrl); this.destinationProvider = new ethers.JsonRpcProvider(this.config.destinationChain.rpcUrl); + + await this.assertNetworkAlignment('source', this.sourceProvider, this.config.sourceChain.chainId); + await this.assertNetworkAlignment('destination', this.destinationProvider, this.config.destinationChain.chainId); // Initialize signers if (!this.config.relayer.privateKey) { @@ -369,12 +466,10 @@ export class RelayService { ); } - // Start monitoring + // Start monitoring and queue drain concurrently (monitoring loop never returns). this.isRunning = true; + void this.startProcessingQueue(); await this.startMonitoring(); - - // Start processing queue - this.startProcessingQueue(); } async stop() { @@ -541,6 +636,12 @@ export class RelayService { amount: ta.amount, amountType: ta.amountType })); + + if (await this.isDeliveredOnDestination(messageId, scope.targetBridge)) { + this.logger.info(`Message ${messageId} already on destination; recording processed (live event)`); + await this.messageQueue.markProcessed(messageId); + return; + } await this.messageQueue.add({ messageId, @@ -637,6 +738,11 @@ export class RelayService { transaction_hash: log.transactionHash }; + if (await this.isDeliveredOnDestination(messageId, scope.targetBridge)) { + await this.messageQueue.markProcessed(messageId); + continue; + } + await this.messageQueue.add({ messageId, destinationChainSelector, @@ -752,7 +858,7 @@ export class RelayService { } // Route to bridge encoded in MessageSent.receiver (bytes). Fallback to static env bridge. - const targetBridge = scope.targetBridge || this.resolveTargetBridge(receiver); + const targetBridge = scope.targetBridge || this.resolveBridgeForMessage(messageData); if (!targetBridge) { throw new Error(`No destination bridge for message ${messageId}: receiver decode failed and DEST_RELAY_BRIDGE not set`); } @@ -774,30 +880,9 @@ export class RelayService { return null; } - let targetBridgeContract = this.destinationBridgeContracts.get(targetBridge.toLowerCase()); - if (!targetBridgeContract) { - targetBridgeContract = new ethers.Contract(targetBridge, RelayBridgeABI, this.destinationProvider); - this.destinationBridgeContracts.set(targetBridge.toLowerCase(), targetBridgeContract); - } + const targetBridgeContract = await this.getDestinationBridgeContract(targetBridge); - // Idempotency guard: do not relay a message that destination already marked processed. - let alreadyProcessed = false; - try { - if (targetBridgeContract.processed) { - alreadyProcessed = await targetBridgeContract.processed(messageId); - } - } catch (_) { - // ignore and try legacy method below - } - if (!alreadyProcessed) { - try { - if (targetBridgeContract.processedTransfers) { - alreadyProcessed = await targetBridgeContract.processedTransfers(messageId); - } - } catch (_) { - // destination bridge may not expose either helper - } - } + const alreadyProcessed = await this.isDeliveredOnDestination(messageId, targetBridge); if (alreadyProcessed) { this.logger.info(`Message ${messageId} already processed on destination; skipping relay tx`); await this.messageQueue.markProcessed(messageId); @@ -940,8 +1025,7 @@ export class RelayService { this.logger.info(`Relay transaction sent: ${tx.hash}`); - // Wait for confirmation - const receipt = await tx.wait(); + const receipt = await this.waitForDestinationReceipt(tx, messageId); this.logger.info(`Message ${messageId} relayed successfully. Transaction: ${receipt.hash}`); this.lastRelaySuccess = { diff --git a/services/relay/src/config.js b/services/relay/src/config.js index 12fa305..fcea6f4 100644 --- a/services/relay/src/config.js +++ b/services/relay/src/config.js @@ -110,6 +110,25 @@ function getEffectivePrivateKey() { } // Build mainnet RPC URL; use INFURA_PROJECT_SECRET (or METAMASK_SECRET) for Basic Auth when using Infura +function getBscRelayRpcUrl() { + const relay = process.env.BSC_RELAY_RPC_URL || ''; + if (relay && !relay.includes('${')) return relay; + const dest = process.env.DEST_RPC_URL || ''; + if (dest && !dest.includes('${')) return dest; + return ''; +} + +function getDestinationRpcUrl() { + const destChainId = process.env.DEST_CHAIN_ID ? parseInt(process.env.DEST_CHAIN_ID, 10) : 1; + if (destChainId === 56) { + const bscRelay = getBscRelayRpcUrl(); + if (bscRelay) return bscRelay; + } + const explicit = process.env.DEST_RPC_URL || ''; + if (explicit && !explicit.includes('${')) return explicit; + return getMainnetRpcUrl(); +} + function getMainnetRpcUrl() { let raw = process.env.RPC_URL_MAINNET || process.env.ETHEREUM_MAINNET_RPC || ''; if (!raw && process.env.INFURA_PROJECT_ID && !String(process.env.INFURA_PROJECT_ID).includes('${')) { @@ -147,6 +166,14 @@ function getDestinationRelayBridgeAddress() { ); } +function getDestinationRelayBridgeLinkAddress() { + return ( + process.env.DEST_RELAY_BRIDGE_LINK || + process.env.CCIP_RELAY_BRIDGE_LINK_MAINNET || + '' + ); +} + function getSkipMessageIds() { return new Set( String(process.env.RELAY_SKIP_MESSAGE_IDS || '') @@ -180,7 +207,13 @@ export const config = { destinationChain: { name: process.env.DEST_CHAIN_NAME || 'Ethereum Mainnet', chainId: process.env.DEST_CHAIN_ID ? parseInt(process.env.DEST_CHAIN_ID) : 1, - rpcUrl: process.env.DEST_RPC_URL || getMainnetRpcUrl(), + rpcUrl: getDestinationRpcUrl(), + // Upstream BSC mainnet RPC for operator tooling (casts, fee probes); relay lane may use ngrok. + upstreamRpcUrl: + process.env.DEST_RPC_UPSTREAM_URL || + process.env.BSC_RPC_URL || + process.env.BSC_MAINNET_RPC || + '', relayRouterAddress: process.env.DEST_RELAY_ROUTER || process.env.CCIP_RELAY_ROUTER_MAINNET || @@ -188,6 +221,8 @@ export const config = { '0x416564Ab73Ad5710855E98dC7bC7Bff7387285BA', relayBridgeAddress: getDestinationRelayBridgeAddress(), + relayBridgeLinkAddress: + getDestinationRelayBridgeLinkAddress(), deliveryMode: process.env.DEST_DELIVERY_MODE || 'router', // Optional CSV allowlist for per-message receiver routing. // When set, relay will only forward to bridges in this list. diff --git a/services/relay/start-relay.sh b/services/relay/start-relay.sh index f86b5d8..3f6474a 100755 --- a/services/relay/start-relay.sh +++ b/services/relay/start-relay.sh @@ -63,8 +63,14 @@ if [ -n "$PROFILE" ] && [ -f ".env.$PROFILE" ]; then load_env_file ".env.$PROFILE" fi +# Named profiles (mainnet-cw, bsc, …) must not inherit destination overrides from .env.local +# unless explicitly allowed — mixing caused mainnet router addresses on Avalanche RPC. if [ "$SKIP_ENV_LOCAL" != "1" ] && [ -f .env.local ]; then - load_env_file .env.local + if [ -n "$PROFILE" ] && [ "${RELAY_ALLOW_ENV_LOCAL:-0}" != "1" ]; then + echo "Note: skipping .env.local for profile=$PROFILE (set RELAY_ALLOW_ENV_LOCAL=1 to override)" + else + load_env_file .env.local + fi fi if [ -n "$PROFILE" ]; then diff --git a/services/relay/test.js b/services/relay/test.js index 9178843..32055bb 100644 --- a/services/relay/test.js +++ b/services/relay/test.js @@ -116,11 +116,21 @@ await queue.resetRetryCount(queuedMessage.messageId); await queue.retry(queuedMessage.messageId, { increment: false }); assert((await queue.getRetryCount(queuedMessage.messageId)) === 0, 'deferred requeue should not consume retry budget'); +await queue.markProcessed(queuedMessage.messageId); const persistentQueue = new MessageQueue(logger, { persistencePath: queueStatePath }); await persistentQueue.init(); assert( - persistentQueue.getStats().queueSize === 1, - 'persisted queue snapshot should reload queued messages after restart' + persistentQueue.getStats().queueSize === 0, + 'persisted queue snapshot should not reload processed messages' +); +assert( + persistentQueue.getStats().processed === 1, + 'persisted processedIds should reload after restart' +); +await persistentQueue.add(queuedMessage); +assert( + persistentQueue.getStats().queueSize === 0, + 'add should skip message ids in persisted processedIds' ); const probeRelay = new RelayService({ @@ -187,5 +197,48 @@ assert( await rm(tempDir, { recursive: true, force: true }); +// Chain-id guard (prevents mainnet router + Avalanche RPC mismatch) +const guardRelay = new RelayService({ + sourceChain: { + name: 'Chain 138', + chainId: 138, + rpcUrl: 'http://example.invalid', + routerAddress: '0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817', + bridgeAddress: '0x152ed3e9912161b76bdfd368d0c84b7c31c10de7', + }, + destinationChain: { + name: 'Ethereum Mainnet', + chainId: 1, + rpcUrl: 'http://example.invalid', + relayRouterAddress: '0x416564Ab73Ad5710855E98dC7bC7Bff7387285BA', + relayBridgeAddress: '0x2bF74583206A49Be07E0E8A94197C12987AbD7B5', + relayBridgeAllowlist: [], + chainSelector: 5009297550715157269n, + deliveryMode: 'router', + }, + tokenMapping: {}, + sourceChainSelector: 138n, + relayer: { privateKey: '', address: '' }, + monitoring: { startBlock: 'latest', pollInterval: 5000, confirmationBlocks: 1 }, + retry: { maxRetries: 3, retryDelay: 5000 }, + skipMessageIds: new Set(), +}, logger); + +const okProvider = { getNetwork: async () => ({ chainId: 1n }) }; +await guardRelay.assertNetworkAlignment('destination', okProvider, 1); + +let mismatchThrown = false; +try { + const badProvider = { getNetwork: async () => ({ chainId: 43114n }) }; + await guardRelay.assertNetworkAlignment('destination', badProvider, 1); +} catch (error) { + mismatchThrown = true; + assert( + String(error.message || error).includes('chain-id mismatch'), + 'wrong-chain RPC should throw chain-id mismatch' + ); +} +assert(mismatchThrown, 'assertNetworkAlignment should reject Avalanche RPC when DEST_CHAIN_ID=1'); + console.log('OK: relay service structure valid'); process.exit(0); diff --git a/services/state-anchoring-service/src/index.ts b/services/state-anchoring-service/src/index.ts index ab0f514..d003e1a 100644 --- a/services/state-anchoring-service/src/index.ts +++ b/services/state-anchoring-service/src/index.ts @@ -18,7 +18,10 @@ try { */ // Contract addresses (from .env or config/smart-contracts-master.json) -const TETHER_ADDRESS = process.env.TETHER_ADDRESS || '0x15DF1D5BFDD8Aa4b380445D4e3E9B38d34283619'; +const TETHER_ADDRESS = + process.env.TETHER_ADDRESS || + process.env.MAINNET_TETHER_ADDRESS || + '0x15DF1D5BFDD8Aa4b380445D4e3E9B38d34283619'; const CHAIN138_RPC = process.env.CHAIN138_RPC_URL || 'http://192.168.11.211:8545'; const MAINNET_RPC = process.env.MAINNET_RPC_URL || 'https://eth.llamarpc.com'; diff --git a/services/token-aggregation/.env.example b/services/token-aggregation/.env.example index 5c37376..4f3975a 100644 --- a/services/token-aggregation/.env.example +++ b/services/token-aggregation/.env.example @@ -54,6 +54,14 @@ CWUSDW_ADDRESS_43114=0xcfdCe5E660FC2C8052BDfa7aEa1865DD753411Ae # CWUSDW_BSC=0xC2FA05F12a75Ac84ea778AF9D6935cA807275E55 # CWUSDW_AVALANCHE=0xcfdCe5E660FC2C8052BDfa7aEa1865DD753411Ae +# Native asset USD (Chain 138 ETH uses mainnet WETH address as pricing proxy in the explorer UI). +# Precedence: live CoinGecko reference (ETH/BTC/…) → optional env override below → repo FX snapshot (avoid for ETH). +# Optional manual override when external APIs are down: +# CHAIN138_CANONICAL_PRICE_USD_ETH=2130 +# CANONICAL_PRICE_USD_ETH=2130 +# ETH_PRICE_USD=2130 +# Verify live price: bash scripts/verify/verify-explorer-native-eth-price.sh (from proxmox repo root) + # PostgreSQL (required for persistent index / reports) # DATABASE_URL=postgresql://user:pass@localhost:5432/token_aggregation diff --git a/services/token-aggregation/src/adapters/coingecko-adapter.ts b/services/token-aggregation/src/adapters/coingecko-adapter.ts index 7bb1d88..612915f 100644 --- a/services/token-aggregation/src/adapters/coingecko-adapter.ts +++ b/services/token-aggregation/src/adapters/coingecko-adapter.ts @@ -338,6 +338,54 @@ export class CoinGeckoAdapter implements ExternalApiAdapter { } } + /** + * Spot USD price for canonical reference symbols (ETH, BTC, …) when the chain + * is not on CoinGecko's contract API (e.g. Chain 138 native ETH / WETH proxy). + */ + async getReferenceMarketData(referenceSymbol: string): Promise { + const symbol = referenceSymbol.trim().toUpperCase(); + const coinId = REFERENCE_SYMBOL_TO_COIN_ID[symbol]; + if (!coinId) { + return null; + } + + const cacheKey = `reference_market_${coinId}`; + const cached = this.cache.get(cacheKey); + if (cached && cached.expiresAt > new Date()) { + return cached.data as MarketData; + } + + try { + const response = await this.api.get>('/simple/price', { + params: { + ids: coinId, + vs_currencies: 'usd', + include_last_updated_at: true, + }, + }); + + const usd = response.data?.[coinId]?.usd; + if (usd === undefined || !Number.isFinite(usd) || usd <= 0) { + return null; + } + + const marketData: MarketData = { + priceUsd: usd, + lastUpdated: new Date(), + }; + + this.cache.set(cacheKey, { + data: marketData, + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }); + + return marketData; + } catch (error) { + logger.error(`Error fetching CoinGecko reference market data for ${referenceSymbol}:`, error); + return null; + } + } + async getHistoricalReferencePrice( referenceSymbol: string, timestamp: Date diff --git a/services/token-aggregation/src/api/middleware/omnl-audit-middleware.ts b/services/token-aggregation/src/api/middleware/omnl-audit-middleware.ts new file mode 100644 index 0000000..39f706c --- /dev/null +++ b/services/token-aggregation/src/api/middleware/omnl-audit-middleware.ts @@ -0,0 +1,26 @@ +import type { Request, Response, NextFunction } from 'express'; +import { randomUUID } from 'crypto'; +import { appendOmnlAudit } from '../../services/omnl-audit-log'; + +/** Attach trace id and log every /omnl request (response status on finish). */ +export function omnlAuditMiddleware(req: Request, res: Response, next: NextFunction): void { + const traceId = (req.headers['x-omnl-trace-id'] as string) || randomUUID(); + res.setHeader('X-OMNL-Trace-Id', traceId); + const started = Date.now(); + res.on('finish', () => { + appendOmnlAudit({ + category: 'api', + action: `${req.method} ${req.path}`, + actor: req.headers['x-omnl-actor'] as string | undefined, + sourceSystem: (req.headers['x-omnl-source'] as string) || 'http', + traceId, + status: res.statusCode < 400 ? 'ok' : 'error', + metadata: { + statusCode: res.statusCode, + durationMs: Date.now() - started, + query: req.query, + }, + }); + }); + next(); +} diff --git a/services/token-aggregation/src/api/middleware/omnl-guards.ts b/services/token-aggregation/src/api/middleware/omnl-guards.ts index 51437be..0f77609 100644 --- a/services/token-aggregation/src/api/middleware/omnl-guards.ts +++ b/services/token-aggregation/src/api/middleware/omnl-guards.ts @@ -1,5 +1,12 @@ import type { Request, Response, NextFunction } from 'express'; +function extractBearerOrQuery(req: Request, key: string): boolean { + const auth = String(req.headers.authorization || ''); + const bearer = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''; + const q = String(req.query.access_token ?? '').trim(); + return bearer === key || q === key; +} + /** * When `OMNL_API_KEY` is set, require `Authorization: Bearer ` or `?access_token=`. * If unset, all requests pass (backwards compatible). @@ -10,12 +17,47 @@ export function omnlSensitiveRouteGuard(req: Request, res: Response, next: NextF next(); return; } - const auth = String(req.headers.authorization || ''); - const bearer = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''; - const q = String(req.query.access_token ?? '').trim(); - if (bearer === key || q === key) { + if (extractBearerOrQuery(req, key)) { next(); return; } res.status(401).json({ error: 'Unauthorized', hint: 'Set Authorization: Bearer or access_token for OMNL_API_KEY' }); } + +/** Public discovery endpoints (always unauthenticated). */ +const OMNL_PUBLIC_PATH_SUFFIXES = ['/omnl/openapi.json', '/omnl/catalog', '/omnl/integration-status']; + +function isPublicOmnlPath(path: string): boolean { + return OMNL_PUBLIC_PATH_SUFFIXES.some((s) => path.endsWith(s)); +} + +/** + * When `OMNL_REQUIRE_API_KEY=1` or `NODE_ENV=production`, require OMNL_API_KEY on all /omnl routes + * except openapi.json, catalog, and integration-status. + */ +export function omnlRequireApiKeyInProduction(req: Request, res: Response, next: NextFunction): void { + if (!req.path.startsWith('/omnl')) { + next(); + return; + } + const requireKey = + process.env.OMNL_REQUIRE_API_KEY === '1' || + (process.env.NODE_ENV || '').toLowerCase() === 'production'; + if (!requireKey || isPublicOmnlPath(req.path)) { + next(); + return; + } + const key = process.env.OMNL_API_KEY?.trim(); + if (!key) { + res.status(503).json({ + error: 'OMNL_API_KEY required in production', + hint: 'Set OMNL_API_KEY and pass Authorization: Bearer ', + }); + return; + } + if (extractBearerOrQuery(req, key)) { + next(); + return; + } + res.status(401).json({ error: 'Unauthorized' }); +} diff --git a/services/token-aggregation/src/api/middleware/rate-limit.ts b/services/token-aggregation/src/api/middleware/rate-limit.ts index f32d929..d0394d0 100644 --- a/services/token-aggregation/src/api/middleware/rate-limit.ts +++ b/services/token-aggregation/src/api/middleware/rate-limit.ts @@ -1,32 +1,40 @@ import rateLimit from 'express-rate-limit'; +import type { RequestHandler } from 'express'; + +/** express-rate-limit handlers are compatible at runtime; Express 5 typings need a narrow cast. */ +function asRateLimitMiddleware( + handler: ReturnType +): RequestHandler { + return handler as unknown as RequestHandler; +} const windowMs = parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10); const maxRequests = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10); -export const apiRateLimiter = rateLimit({ +export const apiRateLimiter = asRateLimitMiddleware(rateLimit({ windowMs, max: maxRequests, message: 'Too many requests from this IP, please try again later.', standardHeaders: true, legacyHeaders: false, -}); +})); -export const strictRateLimiter = rateLimit({ +export const strictRateLimiter = asRateLimitMiddleware(rateLimit({ windowMs: 60 * 1000, // 1 minute max: 10, message: 'Too many requests, please try again later.', standardHeaders: true, legacyHeaders: false, -}); +})); /** Stricter limit for RPC-heavy /api/v1/omnl/* (stacks with apiRateLimiter). */ const omnlWindowMs = parseInt(process.env.OMNL_RATE_LIMIT_WINDOW_MS || '60000', 10); const omnlMax = parseInt(process.env.OMNL_RATE_LIMIT_MAX || '30', 10); -export const omnlRateLimiter = rateLimit({ +export const omnlRateLimiter = asRateLimitMiddleware(rateLimit({ windowMs: omnlWindowMs, max: omnlMax, message: 'Too many OMNL API requests from this IP, please try again later.', standardHeaders: true, legacyHeaders: false, -}); +})); diff --git a/services/token-aggregation/src/api/routes/checkpoint.ts b/services/token-aggregation/src/api/routes/checkpoint.ts new file mode 100644 index 0000000..fa91e95 --- /dev/null +++ b/services/token-aggregation/src/api/routes/checkpoint.ts @@ -0,0 +1,152 @@ +import { Router, Request, Response } from 'express'; +import { existsSync, readFileSync, readdirSync } from 'fs'; +import { join, resolve } from 'path'; +import { logger } from '../../utils/logger'; + +const router: Router = Router(); + +function indexerBase(): string { + return (process.env.CHECKPOINT_INDEXER_URL || 'http://127.0.0.1:3099').replace(/\/$/, ''); +} + +function batchDir(): string { + const custom = process.env.CHECKPOINT_BATCH_DIR?.trim(); + if (custom) return custom; + const root = + process.env.PROXMOX_ROOT?.trim() || + process.env.PHOENIX_REPO_ROOT?.trim() || + resolve(__dirname, '../../../../../..'); + return join(root, 'reports/checkpoint-indexer/batches'); +} + +type CheckpointLeaf = { + txHash?: string; + valueUsd?: string; + nativeValueUsd?: string; + tokenValueUsd?: string; + totalTransfersUsd?: string; + priceSource?: string; + transfers?: Array<{ + token?: string; + tokenAddress?: string; + tokenSymbol?: string; + symbol?: string; + amountRaw?: string; + decimals?: number; + valueUsd?: string; + priceSource?: string; + }>; +}; + +function findLeaf(payload: { leaves?: CheckpointLeaf[] } | null, txHash: string): CheckpointLeaf | null { + if (!payload?.leaves?.length) return null; + const want = txHash.toLowerCase(); + return payload.leaves.find((l) => (l.txHash || '').toLowerCase() === want) ?? null; +} + +function loadLocalAttestation(txHash: string): { + included: boolean; + batchId: string; + batchTotalUsd?: string; + leaf: CheckpointLeaf | null; +} | null { + const dir = batchDir(); + if (!existsSync(dir)) return null; + const want = txHash.toLowerCase(); + const files = readdirSync(dir).filter((f) => /^batch-\d+\.json$/i.test(f)); + for (const file of files.sort((a, b) => { + const na = parseInt(a.replace(/\D/g, ''), 10); + const nb = parseInt(b.replace(/\D/g, ''), 10); + return nb - na; + })) { + try { + const raw = JSON.parse(readFileSync(join(dir, file), 'utf8')) as { + batchId?: string; + batchTotalUsd?: string; + leaves?: CheckpointLeaf[]; + }; + const leaf = findLeaf(raw, want); + if (!leaf) continue; + const batchId = String(raw.batchId ?? file.replace(/^batch-|\.json$/gi, '')); + return { included: true, batchId, batchTotalUsd: raw.batchTotalUsd, leaf }; + } catch { + continue; + } + } + return null; +} + +function leafResponse(leaf: CheckpointLeaf | null) { + if (!leaf) return null; + return { + valueUsd: leaf.valueUsd, + nativeValueUsd: leaf.nativeValueUsd, + tokenValueUsd: leaf.tokenValueUsd, + totalTransfersUsd: leaf.totalTransfersUsd, + priceSource: leaf.priceSource, + transfers: leaf.transfers?.map((t) => ({ + ...t, + tokenAddress: t.tokenAddress || t.token, + symbol: t.symbol || t.tokenSymbol, + })), + }; +} + +/** + * GET /checkpoint/tx/:txHash/attestation — USD-enriched attestation (indexer proxy + local batch fallback). + */ +router.get('/checkpoint/tx/:txHash/attestation', async (req: Request, res: Response) => { + const txHash = String(req.params.txHash || '').trim(); + if (!/^0x[a-fA-F0-9]{64}$/.test(txHash)) { + return res.status(400).json({ error: 'invalid tx hash' }); + } + + const url = `${indexerBase()}/v1/tx/${txHash}/attestation`; + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 12_000); + const upstream = await fetch(url, { signal: ctrl.signal }); + clearTimeout(timer); + if (upstream.ok) { + const raw = (await upstream.json()) as { + txHash?: string; + included?: boolean; + batchId?: string; + payload?: { leaves?: CheckpointLeaf[]; batchTotalUsd?: string }; + }; + const leaf = findLeaf(raw.payload ?? null, txHash); + return res.json({ + txHash: raw.txHash ?? txHash, + included: Boolean(raw.included), + batchId: raw.batchId ?? '0', + batchTotalUsd: raw.payload?.batchTotalUsd, + leaf: leafResponse(leaf), + source: 'checkpoint-indexer', + }); + } + } catch (e) { + logger.debug('checkpoint indexer proxy miss', { txHash, error: String(e) }); + } + + const local = loadLocalAttestation(txHash); + if (local) { + return res.json({ + txHash, + included: true, + batchId: local.batchId, + batchTotalUsd: local.batchTotalUsd, + leaf: leafResponse(local.leaf), + source: 'checkpoint-batch-dir', + }); + } + + res.json({ + txHash, + included: false, + batchId: '0', + leaf: null, + source: 'none', + }); +}); + +export default router; diff --git a/services/token-aggregation/src/api/routes/heatmap.ts b/services/token-aggregation/src/api/routes/heatmap.ts index caa7d8e..16863f5 100644 --- a/services/token-aggregation/src/api/routes/heatmap.ts +++ b/services/token-aggregation/src/api/routes/heatmap.ts @@ -1,4 +1,6 @@ import { Router, Request, Response } from 'express'; +import { readFileSync, existsSync } from 'fs'; +import path from 'path'; import { PoolRepository } from '../../database/repositories/pool-repo'; import { TokenRepository } from '../../database/repositories/token-repo'; import { resolvePoolTokenDisplays } from '../../services/token-display'; @@ -124,26 +126,86 @@ router.get('/routes/health', cacheMiddleware(60 * 1000), async (_req: Request, r } }); +/** + * Load bridge lane probe JSON from proxmox reports/status (repo root). + */ +function loadBridgeLaneProbes(): Record | null { + const candidates = [ + process.env.BRIDGE_LANE_PROBES_JSON, + path.resolve(__dirname, '../../../../../../reports/status/bridge-lane-link-probes-latest.json'), + path.resolve(process.cwd(), 'reports/status/bridge-lane-link-probes-latest.json'), + ].filter(Boolean) as string[]; + for (const p of candidates) { + if (existsSync(p)) { + try { + return JSON.parse(readFileSync(p, 'utf8')) as Record; + } catch { + // try next + } + } + } + return null; +} + +const CHAIN_ID_BY_LANE: Record = { + chain138: 138, + gnosis: 100, + cronos: 25, + celo: 42220, + wemix: 1111, +}; + /** * GET /api/v1/bridges/metrics - * Bridge telemetry (stub; fill from relay/CCIP when available). + * Bridge telemetry from reports/status/bridge-lane-link-probes-latest.json when present. */ router.get('/bridges/metrics', cacheMiddleware(60 * 1000), async (_req: Request, res: Response) => { try { - res.json({ - bridges: [ - { + const probe = loadBridgeLaneProbes(); + const minLinkWei = probe?.min_link_wei ? String(probe.min_link_wei) : '1000000000000000000'; + const lanes = (probe?.lanes ?? {}) as Record; + + const bridges: Array> = []; + for (const [laneKey, lane] of Object.entries(lanes)) { + const chainId = CHAIN_ID_BY_LANE[laneKey] ?? 0; + for (const variant of ['weth9', 'weth10'] as const) { + const entry = lane[variant]; + if (!entry?.bridge) continue; + const bal = entry.link_balance_wei ?? '0'; + const funded = BigInt(bal) >= BigInt(minLinkWei); + bridges.push({ bridge: 'CCIP', - fromChainId: 138, - toChainId: 1, - asset: 'WETH', - p50LatencySeconds: 180, - p95LatencySeconds: 420, - feeUsd: 4.25, - successRate: 0.998, - health: 'ok', - }, - ], + lane: laneKey, + variant: variant.toUpperCase(), + chainId, + chainName: lane.chain_name ?? laneKey, + bridgeAddress: entry.bridge, + linkBalanceWei: bal, + minLinkWei, + status: entry.status ?? (funded ? 'funded' : 'degraded'), + health: funded ? 'ok' : entry.status === 'unfunded' ? 'critical' : 'degraded', + source: probe ? 'reports/status/bridge-lane-link-probes-latest.json' : 'unknown', + asset: 'LINK', + }); + } + } + + if (bridges.length === 0) { + return res.json({ + bridges: [], + updated: null, + note: 'No probe file; run scripts/verify/probe-bridge-lane-link-balances.sh', + }); + } + + res.json({ + updated: probe?.updated ?? null, + minLinkWei, + bridges, }); } catch (error) { // eslint-disable-next-line no-console -- route error logging diff --git a/services/token-aggregation/src/api/routes/omnl-compliance-routes.ts b/services/token-aggregation/src/api/routes/omnl-compliance-routes.ts new file mode 100644 index 0000000..940ca6b --- /dev/null +++ b/services/token-aggregation/src/api/routes/omnl-compliance-routes.ts @@ -0,0 +1,166 @@ +import { Router, Request, Response } from 'express'; +import { omnlRateLimiter } from '../middleware/rate-limit'; +import { omnlSensitiveRouteGuard, omnlRequireApiKeyInProduction } from '../middleware/omnl-guards'; +import { omnlAuditMiddleware } from '../middleware/omnl-audit-middleware'; +import { runTripleStateReconcile } from '../../services/omnl-triple-reconcile'; +import { + buildIfrs7Disclosure, + buildIfrs9Disclosure, + buildIfrs13Disclosure, + buildIas21Ias37Disclosure, + buildFullDisclosure, + getComplianceSignoffsSummary, +} from '../../services/omnl-ifrs-disclosures'; +import { + saveIso20022Message, + listIso20022Messages, + getIso20022Message, + type Iso20022MessageType, +} from '../../services/omnl-iso20022-store'; +import { verifyOmnlWebhookSignature } from '../../services/omnl-webhooks'; +import { appendOmnlAudit } from '../../services/omnl-audit-log'; +import { getWeb3ComplianceSummary, buildNotarizationIntent } from '../../services/omnl-web3-compliance'; +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; + +const router = Router(); +router.use(omnlRateLimiter); +router.use(omnlAuditMiddleware); +router.use(omnlRequireApiKeyInProduction); + +router.get('/omnl/reconcile/triple-state', omnlSensitiveRouteGuard, async (req: Request, res: Response) => { + try { + const lineId = String(req.query.lineId || ''); + const report = await runTripleStateReconcile(lineId || undefined); + res.status(report.aligned ? 200 : 409).json(report); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + res.status(500).json({ error: msg }); + } +}); + +router.get('/omnl/disclosures/ifrs7', omnlSensitiveRouteGuard, async (req: Request, res: Response) => { + try { + res.json(await buildIfrs7Disclosure(String(req.query.lineId || '') || undefined)); + } catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } +}); + +router.get('/omnl/disclosures/ifrs9', omnlSensitiveRouteGuard, (_req, res) => { + res.json(buildIfrs9Disclosure()); +}); + +router.get('/omnl/disclosures/ifrs13', omnlSensitiveRouteGuard, (req, res) => { + res.json(buildIfrs13Disclosure(String(req.query.lineId || '') || undefined)); +}); + +router.get('/omnl/disclosures/ias21-ias37', omnlSensitiveRouteGuard, (_req, res) => { + res.json(buildIas21Ias37Disclosure()); +}); + +router.get('/omnl/compliance/signoffs', omnlSensitiveRouteGuard, (_req, res) => { + res.json(getComplianceSignoffsSummary()); +}); + +router.get('/omnl/compliance/web3', omnlSensitiveRouteGuard, (_req, res) => { + res.json(getWeb3ComplianceSummary()); +}); + +router.post('/omnl/compliance/notarization-intent', omnlSensitiveRouteGuard, (req, res) => { + const body = req.body as { + jurisdictionId?: string; + matrixControlId?: string; + contentHash?: string; + merkleRoot?: string; + metadataHash?: string; + }; + if (!body?.jurisdictionId || !body?.matrixControlId || !body?.contentHash) { + res.status(400).json({ error: 'jurisdictionId, matrixControlId, contentHash required' }); + return; + } + res.json(buildNotarizationIntent({ + jurisdictionId: body.jurisdictionId, + matrixControlId: body.matrixControlId, + contentHash: body.contentHash, + merkleRoot: body.merkleRoot, + metadataHash: body.metadataHash, + })); +}); + +router.get('/omnl/disclosures/full', omnlSensitiveRouteGuard, async (req: Request, res: Response) => { + const lineId = String(req.query.lineId || '') || undefined; + res.json(await buildFullDisclosure(lineId)); +}); + +router.post('/omnl/iso20022/messages', omnlSensitiveRouteGuard, (req: Request, res: Response) => { + try { + const body = req.body as { + messageType?: Iso20022MessageType; + payload?: string; + uetr?: string; + instructionId?: string; + settlementOrChainRef?: string; + accountingRef?: string; + }; + if (!body?.messageType || !body?.payload) { + res.status(400).json({ error: 'messageType and payload required' }); + return; + } + const record = saveIso20022Message({ + messageType: body.messageType, + payload: body.payload, + uetr: body.uetr, + instructionId: body.instructionId, + settlementOrChainRef: body.settlementOrChainRef, + accountingRef: body.accountingRef, + }); + res.status(201).json(record); + } catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } +}); + +router.get('/omnl/iso20022/messages', omnlSensitiveRouteGuard, (req, res) => { + const limit = parseInt(String(req.query.limit || '50'), 10); + res.json({ messages: listIso20022Messages(limit) }); +}); + +router.get('/omnl/iso20022/messages/:id', omnlSensitiveRouteGuard, (req, res) => { + const r = getIso20022Message(req.params.id as string); + if (!r) { + res.status(404).json({ error: 'not found' }); + return; + } + res.json(r); +}); + +router.post('/omnl/webhooks/inbound', (req: Request, res: Response) => { + const secret = process.env.OMNL_WEBHOOK_SECRET || ''; + const raw = typeof req.body === 'string' ? req.body : JSON.stringify(req.body); + const sig = req.headers['x-omnl-signature'] as string | undefined; + if (secret && !verifyOmnlWebhookSignature(raw, sig, secret)) { + res.status(401).json({ error: 'invalid signature' }); + return; + } + appendOmnlAudit({ + category: 'webhook_in', + action: 'inbound_webhook', + traceId: (req.body as { deliveryId?: string })?.deliveryId, + metadata: { bodyPreview: raw.slice(0, 500) }, + status: 'ok', + }); + res.json({ received: true }); +}); + +router.get('/omnl/roles/matrix', omnlSensitiveRouteGuard, (_req, res) => { + const root = process.env.PROXMOX_ROOT || process.env.PHOENIX_REPO_ROOT || process.cwd(); + const p = resolve(root, 'config/omnl-fineract-roles.v1.json'); + if (!existsSync(p)) { + res.status(404).json({ error: 'config/omnl-fineract-roles.v1.json not found' }); + return; + } + res.type('application/json').send(readFileSync(p, 'utf8')); +}); + +export default router; diff --git a/services/token-aggregation/src/api/routes/omnl-ipsas.ts b/services/token-aggregation/src/api/routes/omnl-ipsas.ts index a8f6bc8..4deb329 100644 --- a/services/token-aggregation/src/api/routes/omnl-ipsas.ts +++ b/services/token-aggregation/src/api/routes/omnl-ipsas.ts @@ -1,6 +1,7 @@ import { Router, Request, Response } from 'express'; import { omnlRateLimiter } from '../middleware/rate-limit'; -import { omnlSensitiveRouteGuard } from '../middleware/omnl-guards'; +import { omnlSensitiveRouteGuard, omnlRequireApiKeyInProduction } from '../middleware/omnl-guards'; +import { omnlAuditMiddleware } from '../middleware/omnl-audit-middleware'; import { loadIpsasRegistry, validateJournalPairWithMatrix, @@ -10,14 +11,17 @@ import { } from '../../services/omnl-ipsas-gl'; import { loadJournalMatrix } from '../../services/omnl-journal-matrix'; import { fetchOmnlCompliance, fetchOmnlComplianceAggregated } from '../../services/omnl-compliance'; +import { omnlComplianceCore138 } from '../../services/omnl-chain138-addresses'; const router = Router(); router.use(omnlRateLimiter); +router.use(omnlAuditMiddleware); +router.use(omnlRequireApiKeyInProduction); /** * GET /omnl/ipsas/registry — full IPSAS GL registry (codes, pairs, monetary layer hints). */ -router.get('/omnl/ipsas/registry', (_req: Request, res: Response) => { +router.get('/omnl/ipsas/registry', omnlSensitiveRouteGuard, (_req: Request, res: Response) => { try { res.json(loadIpsasRegistry()); } catch (e) { @@ -29,7 +33,7 @@ router.get('/omnl/ipsas/registry', (_req: Request, res: Response) => { /** * GET /omnl/ipsas/matrix — journal matrix (T-001…T-008) for Fineract posting alignment. */ -router.get('/omnl/ipsas/matrix', (_req: Request, res: Response) => { +router.get('/omnl/ipsas/matrix', omnlSensitiveRouteGuard, (_req: Request, res: Response) => { try { res.json(loadJournalMatrix()); } catch (e) { @@ -125,7 +129,7 @@ router.get('/omnl/ipsas/validate-pair', (req: Request, res: Response) => { /** * GET /omnl/ipsas/fineract-health — probe Fineract `/glaccounts?limit=1` (credentials from OMNL_FINERACT_*). */ -router.get('/omnl/ipsas/fineract-health', async (_req: Request, res: Response) => { +router.get('/omnl/ipsas/fineract-health', omnlSensitiveRouteGuard, async (_req: Request, res: Response) => { try { const r = await checkFineractConnectivity(); const configured = Boolean( @@ -197,9 +201,9 @@ router.get('/omnl/ipsas/compliance-context/:lineId', omnlSensitiveRouteGuard, as if (aggregated) { compliance = await fetchOmnlComplianceAggregated(lineId); } else { - const addr = process.env.OMNL_COMPLIANCE_CORE_138; + const addr = omnlComplianceCore138(); if (!addr) { - res.status(503).json({ error: 'OMNL_COMPLIANCE_CORE_138 not set' }); + res.status(503).json({ error: 'OMNL_COMPLIANCE_CORE_138 / V2 not set' }); return; } compliance = await fetchOmnlCompliance(138, lineId, addr); diff --git a/services/token-aggregation/src/api/routes/omnl.ts b/services/token-aggregation/src/api/routes/omnl.ts index a317708..096f11c 100644 --- a/services/token-aggregation/src/api/routes/omnl.ts +++ b/services/token-aggregation/src/api/routes/omnl.ts @@ -1,6 +1,8 @@ import { Router, Request, Response } from 'express'; import { Contract, JsonRpcProvider, type InterfaceAbi } from 'ethers'; import { omnlRateLimiter } from '../middleware/rate-limit'; +import { omnlRequireApiKeyInProduction } from '../middleware/omnl-guards'; +import { omnlAuditMiddleware } from '../middleware/omnl-audit-middleware'; import { fetchOmnlCompliance, fetchOmnlComplianceAggregated, @@ -9,6 +11,7 @@ import { loadCrossChainLines, loadCrossChainConfigPath, } from '../../services/omnl-compliance'; +import { omnlComplianceCore138, omnlReserveStore138 } from '../../services/omnl-chain138-addresses'; import { computeOmnlReconcileAnchor } from '../../services/omnl-reconcile-anchor'; import { getOmnlIntegrationStatus } from '../../services/omnl-integration-status'; import { getOmnlApiCatalog } from '../../services/omnl-api-catalog'; @@ -16,6 +19,8 @@ import omnlOpenApi from '../../resources/omnl-openapi.json'; const router = Router(); router.use(omnlRateLimiter); +router.use(omnlAuditMiddleware); +router.use(omnlRequireApiKeyInProduction); const REGISTRY_ABI: InterfaceAbi = [ 'function allLineIds() view returns (bytes32[])', @@ -133,7 +138,7 @@ router.get('/omnl/mirror-coordinator', async (req: Request, res: Response) => { function addrForChain(chainId: number): string | undefined { if (chainId === 138) { - return process.env.OMNL_COMPLIANCE_CORE_138; + return omnlComplianceCore138(); } if (chainId === 651940) { return process.env.OMNL_COMPLIANCE_CORE_651940; @@ -251,7 +256,7 @@ router.get('/omnl/breaker', async (req: Request, res: Response) => { router.get('/omnl/mirror-status/:lineId', async (req: Request, res: Response) => { try { const lineId = req.params.lineId as string; - const a138 = process.env.OMNL_RESERVE_STORE_138; + const a138 = omnlReserveStore138(); const a651940 = process.env.OMNL_RESERVE_STORE_651940; const out: Record = { lineId, @@ -293,7 +298,7 @@ router.get('/omnl/health', async (req: Request, res: Response) => { }); return; } - const a138 = process.env.OMNL_COMPLIANCE_CORE_138; + const a138 = omnlComplianceCore138(); const a651940 = process.env.OMNL_COMPLIANCE_CORE_651940; const out: Record = { lineId: line, diff --git a/services/token-aggregation/src/api/routes/quote.ts b/services/token-aggregation/src/api/routes/quote.ts index 63dba25..278bd9f 100644 --- a/services/token-aggregation/src/api/routes/quote.ts +++ b/services/token-aggregation/src/api/routes/quote.ts @@ -3,6 +3,7 @@ import { PoolRepository } from '../../database/repositories/pool-repo'; import { cacheMiddleware } from '../middleware/cache'; import { logger } from '../../utils/logger'; import { filterPoolsForRouting } from '../../config/gru-transport'; +import { CHAIN138_CANONICAL_LIVE_POOLS } from '../../config/chain138-live-dodo-pools'; import { resolveCanonicalQuoteAddress } from '../../config/canonical-tokens'; import { getLiveDodoPools } from '../../services/live-dodo-fallback'; import { @@ -14,6 +15,9 @@ import { const router: Router = Router(); const poolRepo = new PoolRepository(); +/** Live traded cUSDT/cUSDC DVM — see docs/11-references/EXPLORER_TOKEN_LIST_CROSSCHECK.md */ +const CANONICAL_CUSDT_CUSDC_POOL = CHAIN138_CANONICAL_LIVE_POOLS[0]; + /** * Uniswap V2-style constant-product quote: amountOut = (reserveOut * amountIn * 997) / (reserveIn * 1000 + amountIn * 997) * @@ -84,21 +88,67 @@ router.get( tokenInResolution.lookupAddress ); const indexedPools = filterPoolsForRouting(chainId, indexedPoolsRaw ?? []); - const livePools = - indexedPools.length > 0 - ? [] - : filterPoolsForRouting(chainId, (await getLiveDodoPools(chainId)) ?? []).filter( - (pool) => - pool.token0Address.toLowerCase() === tokenInResolution.lookupAddress || - pool.token1Address.toLowerCase() === tokenInResolution.lookupAddress - ); - const pairPools = [...indexedPools, ...livePools].filter( + const livePoolsForToken = filterPoolsForRouting( + chainId, + (await getLiveDodoPools(chainId)) ?? [] + ).filter( + (pool) => + pool.token0Address.toLowerCase() === tokenInResolution.lookupAddress || + pool.token1Address.toLowerCase() === tokenInResolution.lookupAddress + ); + const poolsByAddress = new Map(); + for (const pool of [...indexedPools, ...livePoolsForToken]) { + poolsByAddress.set(pool.poolAddress.toLowerCase(), pool); + } + const pairPools = [...poolsByAddress.values()].filter( (p) => p.token0Address.toLowerCase() === tokenOutResolution.lookupAddress || p.token1Address.toLowerCase() === tokenOutResolution.lookupAddress ); if (pairPools.length === 0) { + const pmmRpc = resolvePmmQuoteRpcUrl(); + const cusdt = '0x93e66202a11b1772e55407b32b44e5cd8eda7f22'; + const cusdc = '0xf22258f57794cc8e06237084b353ab30fffa640b'; + const isCusdtCusdc = + chainId === 138 && + pmmRpc && + ((tokenInResolution.lookupAddress === cusdt && + tokenOutResolution.lookupAddress === cusdc) || + (tokenInResolution.lookupAddress === cusdc && + tokenOutResolution.lookupAddress === cusdt)); + if (isCusdtCusdc) { + const onChainOut = await pmmQuoteAmountOutFromChain({ + rpcUrl: pmmRpc, + poolAddress: CANONICAL_CUSDT_CUSDC_POOL, + tokenInLookup: tokenInResolution.lookupAddress, + amountIn, + traderForView: resolvePmmQuoteTrader(), + }); + if (onChainOut !== null && onChainOut > BigInt(0)) { + return res.json({ + amountOut: onChainOut.toString(), + poolAddress: CANONICAL_CUSDT_CUSDC_POOL, + dexType: 'dodo', + executorAddress: + process.env.CHAIN_138_DODO_PMM_INTEGRATION || + process.env.DODO_PMM_INTEGRATION || + null, + quoteEngine: 'pmm-onchain', + canonicalLiquidity: { + requestedTokenInAddress: tokenInResolution.requestedAddress, + requestedTokenOutAddress: tokenOutResolution.requestedAddress, + requestedTokenInSymbol: tokenInResolution.requestedSymbol, + requestedTokenOutSymbol: tokenOutResolution.requestedSymbol, + lookupTokenInAddress: tokenInResolution.lookupAddress, + lookupTokenOutAddress: tokenOutResolution.lookupAddress, + lookupTokenInSymbol: tokenInResolution.lookupSymbol, + lookupTokenOutSymbol: tokenOutResolution.lookupSymbol, + usedFallback: true, + }, + }); + } + } return res.json({ amountOut: null, error: 'No pool found for this token pair', diff --git a/services/token-aggregation/src/api/routes/report.test.ts b/services/token-aggregation/src/api/routes/report.test.ts index e09dcb4..be11267 100644 --- a/services/token-aggregation/src/api/routes/report.test.ts +++ b/services/token-aggregation/src/api/routes/report.test.ts @@ -785,6 +785,10 @@ describe('Report API', () => { status: 'routing_enabled', statusReason: expect.stringContaining('public routing is enabled'), }); + expect(body.lifecycle).toMatchObject({ + routingEnabled: 1, + routeVisible: 1, + }); } finally { await import('fs/promises').then((fs) => fs.unlink(tempPath).catch(() => undefined)); if (previousPath === undefined) { @@ -796,6 +800,22 @@ describe('Report API', () => { }); }); + describe('GET /api/v1/report/external-indexer-readiness', () => { + it('returns tracker-facing readiness and source endpoints', async () => { + const res = await fetch(`${baseUrl}/api/v1/report/external-indexer-readiness?chainId=138`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.schema).toBe('dbis-external-indexer-readiness/v1'); + expect(body.chainId).toBe(138); + expect(body.summary.tokenCount).toBeGreaterThan(0); + expect(body.endpoints.coingecko).toContain('/api/v1/report/coingecko?chainId=138'); + expect(body.trackers.defiLlama.status).toBe('submitted_external_review_pending'); + expect(body.trackers.coinGecko).toHaveProperty('readyFromDbisSide'); + expect(body.trackers.coinMarketCap).toHaveProperty('readyFromDbisSide'); + expect(body.trackers.dexScreener.status).toBe('chain_indexing_external_pending'); + }); + }); + describe('GET /api/v1/report/gas-registry', () => { it('reads the live gas rollout registry from deployment-status json when available', async () => { const res = await fetch(`${baseUrl}/api/v1/report/gas-registry?chainId=10`); diff --git a/services/token-aggregation/src/api/routes/report.ts b/services/token-aggregation/src/api/routes/report.ts index 77b1f17..b90715f 100644 --- a/services/token-aggregation/src/api/routes/report.ts +++ b/services/token-aggregation/src/api/routes/report.ts @@ -1332,6 +1332,84 @@ router.get( } ); +/** GET /report/external-indexer-readiness — operator-facing readiness for third-party trackers. */ +router.get( + '/external-indexer-readiness', + cacheMiddleware(2 * 60 * 1000), + async (req: Request, res: Response) => { + try { + const chainId = parseInt(req.query.chainId as string, 10) || 138; + const tokens = await buildTokenReport(chainId); + const pools = filterPoolsForExposure(chainId, await poolRepo.getPoolsByChain(chainId)); + const tokenPoolRows = tokens.flatMap((token) => token.pools); + const positiveTokenPoolRows = tokenPoolRows.filter((pool) => pool.tvl > 0); + const positiveIndexedPools = pools.filter((pool) => (pool.totalLiquidityUsd ?? 0) > 0); + const totalLiquidityUsd = pools.reduce((sum, pool) => sum + (pool.totalLiquidityUsd || 0), 0); + const totalTokenPoolLiquidityUsd = tokenPoolRows.reduce((sum, pool) => sum + (pool.tvl || 0), 0); + const totalReportableLiquidityUsd = totalLiquidityUsd > 0 ? totalLiquidityUsd : totalTokenPoolLiquidityUsd; + const gruPools = getGruV2DeploymentPoolRows().filter((pool) => pool.chainId === chainId); + const gruRoutingEnabledPools = gruPools.filter((pool) => pool.status === 'routing_enabled' || pool.status === 'live'); + const publicBase = resolvePublicBaseUrl(req); + + res.set('Cache-Control', 'public, max-age=0, must-revalidate'); + res.json({ + generatedAt: new Date().toISOString(), + chainId, + schema: 'dbis-external-indexer-readiness/v1', + summary: { + tokenCount: tokens.length, + indexedPoolCount: pools.length, + positiveIndexedPoolCount: positiveIndexedPools.length, + tokenPoolRows: tokenPoolRows.length, + positiveTokenPoolRows: positiveTokenPoolRows.length, + totalLiquidityUsd: totalReportableLiquidityUsd, + gruV2PoolCount: gruPools.length, + gruV2RoutingEnabledPoolCount: gruRoutingEnabledPools.length, + }, + endpoints: { + all: `${publicBase}/api/v1/report/all?chainId=${chainId}`, + coingecko: `${publicBase}/api/v1/report/coingecko?chainId=${chainId}`, + cmc: `${publicBase}/api/v1/report/cmc?chainId=${chainId}`, + tokenList: `${publicBase}/api/v1/report/token-list?chainId=${chainId}`, + gruV2PmmPools: `${publicBase}/api/v1/report/gru-v2-pmm-pools?chainId=${chainId}`, + adoptionReadiness: `${publicBase}/api/v1/report/adoption-readiness`, + }, + trackers: { + defiLlama: { + status: chainId === 138 ? 'merged_chain_listed_tvl_pending_server_pricing' : 'not_applicable', + readyFromDbisSide: chainId === 138 && totalReportableLiquidityUsd > 0, + blocker: + chainId === 138 + ? 'Chain listed at defillama.com/chain/defi-oracle-meta; TVL $0 until defillama-server prices cUSDT/cUSDC on dfio_meta_main.' + : undefined, + submission: chainId === 138 ? 'https://github.com/DefiLlama/DefiLlama-Adapters/pull/19198' : undefined, + chainPage: chainId === 138 ? 'https://defillama.com/chain/defi-oracle-meta' : undefined, + mergedAt: chainId === 138 ? '2026-05-21T22:13:42Z' : undefined, + }, + coinGecko: { + status: totalReportableLiquidityUsd > 0 && tokens.length > 0 ? 'report_ready_external_listing_pending' : 'needs_internal_data', + readyFromDbisSide: totalReportableLiquidityUsd > 0 && tokens.length > 0, + blocker: 'CoinGecko asset-platform and token listing acceptance is external to this report API.', + }, + coinMarketCap: { + status: totalReportableLiquidityUsd > 0 && positiveTokenPoolRows.length > 0 ? 'report_ready_external_listing_pending' : 'needs_internal_data', + readyFromDbisSide: totalReportableLiquidityUsd > 0 && positiveTokenPoolRows.length > 0, + blocker: 'CoinMarketCap DEX/listing acceptance is external to this report API.', + }, + dexScreener: { + status: chainId === 138 ? 'chain_indexing_external_pending' : 'report_ready_external_listing_pending', + readyFromDbisSide: chainId === 138 && gruRoutingEnabledPools.length > 0, + blocker: chainId === 138 ? 'Dexscreener must index Chain 138 and its native pairs before native pair pages appear.' : undefined, + }, + }, + }); + } catch (error) { + logger.error('Error building report/external-indexer-readiness:', 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', @@ -1586,6 +1664,13 @@ router.get('/gru-v2-pmm-pools', async (req: Request, res: Response) => { lastModified: fileBacked?.lastModified, homeChainId: fileBacked?.data.homeChainId, count: pools.length, + lifecycle: { + live: pools.filter((pool) => pool.status === 'live').length, + routingEnabled: pools.filter((pool) => pool.status === 'routing_enabled').length, + configured: pools.filter((pool) => pool.status === 'configured').length, + proofRequired: pools.filter((pool) => pool.status === 'proof_required').length, + routeVisible: pools.filter((pool) => pool.status === 'live' || pool.status === 'routing_enabled').length, + }, pools, }); } catch (error) { diff --git a/services/token-aggregation/src/api/routes/tokens.ts b/services/token-aggregation/src/api/routes/tokens.ts index 941dce1..30ef38f 100644 --- a/services/token-aggregation/src/api/routes/tokens.ts +++ b/services/token-aggregation/src/api/routes/tokens.ts @@ -86,12 +86,41 @@ function isAcceptableHistoricalTimestamp( return deltaMs >= 0 && deltaMs <= maxAgeMs; } +type ExternalMarketFeeds = { + coingecko?: Awaited>; + cmc?: Awaited>; + dexscreener?: Awaited>; +}; + +async function enrichExternalFeedsWithReferencePrice( + chainId: number, + lookupAddress: string, + external: ExternalMarketFeeds | null +): Promise { + const feeds: ExternalMarketFeeds = { ...(external ?? {}) }; + if (feeds.coingecko?.priceUsd != null && feeds.coingecko.priceUsd > 0) { + return feeds; + } + + const canonical = resolveCanonicalPriceUsd(chainId, lookupAddress.toLowerCase()); + if (!canonical.referenceSymbol) { + return feeds; + } + + const referenceMarket = await coingeckoAdapter.getReferenceMarketData(canonical.referenceSymbol); + if (referenceMarket?.priceUsd != null && referenceMarket.priceUsd > 0) { + feeds.coingecko = referenceMarket; + } + + return feeds; +} + function buildMarketPricingExplorer( chainId: number, displayAddress: string, lookupAddress: string, marketData: Awaited>, - external: { coingecko?: Awaited>; cmc?: Awaited>; dexscreener?: Awaited> } | null + external: ExternalMarketFeeds | null ) { const pricing = resolveUsdValuation({ chainId, @@ -566,12 +595,17 @@ router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request, 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, explorer } = buildMarketPricingExplorer( chainId, normalizedAddress, resolution.lookupAddress, marketDataRaw, - { coingecko: coingeckoMarket, cmc: cmcMarket, dexscreener: dexscreenerMarket } + externalFeeds ); res.json({ diff --git a/services/token-aggregation/src/api/server.ts b/services/token-aggregation/src/api/server.ts index ee70ff3..c533843 100644 --- a/services/token-aggregation/src/api/server.ts +++ b/services/token-aggregation/src/api/server.ts @@ -20,6 +20,8 @@ import partnerPayloadRoutes from './routes/partner-payloads'; import plannerV2Routes from './routes/planner-v2'; import omnlRoutes from './routes/omnl'; import omnlIpsasRoutes from './routes/omnl-ipsas'; +import omnlComplianceRoutes from './routes/omnl-compliance-routes'; +import checkpointRoutes from './routes/checkpoint'; import { MultiChainIndexer } from '../indexer/chain-indexer'; import { OmnlEventPoller } from '../indexer/omnl-event-poller'; import { getDatabasePool } from '../database/client'; @@ -207,8 +209,10 @@ export class ApiServer { this.app.use('/api/v1', arbitrageRoutes); this.app.use('/api/v1', aggregatorRouteMatrixRoutes); this.app.use('/api/v1', partnerPayloadRoutes); + this.app.use('/api/v1', checkpointRoutes); this.app.use('/api/v1', omnlRoutes); this.app.use('/api/v1', omnlIpsasRoutes); + this.app.use('/api/v1', omnlComplianceRoutes); this.app.use('/api/v2', plannerV2Routes); // Admin routes (stricter rate limit) diff --git a/services/token-aggregation/src/config/canonical-tokens.ts b/services/token-aggregation/src/config/canonical-tokens.ts index ddde2cd..eacf5b0 100644 --- a/services/token-aggregation/src/config/canonical-tokens.ts +++ b/services/token-aggregation/src/config/canonical-tokens.ts @@ -7,6 +7,10 @@ import { isISO4217Supported } from './iso4217-symbol-registry'; import { isMonetaryUnitSupported } from './monetary-unit-symbol-registry'; import { loadTokenMappingLoader } from './repo-config-loader'; +import { + getCapitalMarketsClassification, + type CapitalMarketsFields, +} from './capital-markets-taxonomy'; export type TokenType = 'base' | 'w' | 'asset' | 'debt'; export type TokenRegistryFamily = 'iso4217' | 'commodity' | 'monetary_unit' | 'gas_native' | 'unclassified'; @@ -37,6 +41,8 @@ export interface CanonicalTokenSpec { preferredForX402?: boolean; /** Symbol whose current liquidity should be used for quote fallback until cutover. */ liquiditySourceSymbol?: string; + /** Institutional capital-markets taxonomy (from rwa-capital-markets-taxonomy.v1.json). */ + capitalMarkets?: CapitalMarketsFields; } interface GruTransportDeployment { @@ -778,7 +784,17 @@ export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [ description: 'Planned ALL Mainnet bridge-minted wrapped XAU representation for inbound Chain 138 cXAUT transport.', addresses: { [CHAIN_651940]: addr('cWAXAUT', CHAIN_651940) || '' }, }, - { symbol: 'LiXAU', name: 'XAU Liquidity-adjusted', type: 'base', decimals: 6, currencyCode: 'XAU', addresses: { [CHAIN_138]: addr('LiXAU', CHAIN_138), [CHAIN_651940]: addr('LiXAU', CHAIN_651940) } }, + { + symbol: 'LiXAU', + name: 'XAU Liquidity-adjusted', + type: 'base', + decimals: 6, + currencyCode: 'XAU', + registryFamily: 'commodity', + capitalMarkets: getCapitalMarketsClassification('LiXAU'), + description: 'M00 GRU XAU liquidity-adjusted commodity index — DefiLlama RWA candidate; not M1 cXAUC eMoney.', + addresses: { [CHAIN_138]: addr('LiXAU', CHAIN_138), [CHAIN_651940]: addr('LiXAU', CHAIN_651940) }, + }, // --- ISO-4217 W --- { symbol: 'USDW', name: 'USD W Token', type: 'w', decimals: 2, currencyCode: 'USD', addresses: { [CHAIN_138]: addr('USDW', CHAIN_138), [CHAIN_25]: addr('USDW', CHAIN_25), [CHAIN_651940]: addr('USDW', CHAIN_651940) } }, { symbol: 'EURW', name: 'EUR W Token', type: 'w', decimals: 2, currencyCode: 'EUR', addresses: { [CHAIN_138]: addr('EURW', CHAIN_138), [CHAIN_25]: addr('EURW', CHAIN_25), [CHAIN_651940]: addr('EURW', CHAIN_651940) } }, @@ -818,6 +834,13 @@ export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [ { symbol: 'sdcXAUC', name: 'Debt cXAUC (stable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('sdcXAUC', CHAIN_138), [CHAIN_651940]: addr('sdcXAUC', CHAIN_651940) } }, ]; +for (const spec of CANONICAL_TOKENS) { + if (!spec.capitalMarkets) { + const cm = getCapitalMarketsClassification(spec.symbol); + if (cm) spec.capitalMarkets = cm; + } +} + export function getCanonicalTokensByChain(chainId: number): CanonicalTokenSpec[] { return CANONICAL_TOKENS.filter( (t) => t.addresses[chainId] && String(t.addresses[chainId]).trim() !== '' @@ -844,8 +867,27 @@ export interface CanonicalQuoteAddressResolution { usedFallback: boolean; } +function isEvmAddress(value: string): boolean { + return /^0x[a-f0-9]{40}$/.test(value); +} + export function resolveCanonicalQuoteAddress(chainId: number, address: string): CanonicalQuoteAddressResolution { - const requestedAddress = normalizeAddress(address); + const raw = address.trim(); + const bySymbol = !isEvmAddress(normalizeAddress(raw)) + ? getCanonicalTokenBySymbol(chainId, raw) + : undefined; + if (bySymbol) { + const lookupAddress = normalizeAddress(bySymbol.addresses[chainId] || ''); + return { + requestedAddress: raw.toLowerCase(), + requestedSymbol: bySymbol.symbol, + lookupAddress: lookupAddress || raw.toLowerCase(), + lookupSymbol: bySymbol.symbol, + usedFallback: false, + }; + } + + const requestedAddress = normalizeAddress(raw); const requestedSpec = getCanonicalTokenByAddress(chainId, requestedAddress); if (!requestedSpec) { return { diff --git a/services/token-aggregation/src/config/capital-markets-taxonomy.ts b/services/token-aggregation/src/config/capital-markets-taxonomy.ts new file mode 100644 index 0000000..041c439 --- /dev/null +++ b/services/token-aggregation/src/config/capital-markets-taxonomy.ts @@ -0,0 +1,51 @@ +/** + * Institutional capital-markets classification (exposure vs instrument). + * Source of truth: config/rwa-capital-markets-taxonomy.v1.json + */ + +import taxonomy from '../../../../../config/rwa-capital-markets-taxonomy.v1.json'; + +export interface CapitalMarketsFields { + assetClass: string; + assetGroup: string; + instrumentType: string; + underlyingAsset: string; + tenor: string; + collateralType: string; + gruLayer?: string; + defillamaRwaEligible?: boolean; +} + +type TaxonomyInstrument = { + ticker: string; + asset_class: string; + asset_group: string; + instrument_type: string; + underlying_asset: string; + tenor: string; + collateral_type: string; + gru_layer?: string; + defillamaEligible?: boolean; +}; + +const byTicker = new Map( + (taxonomy.instruments as TaxonomyInstrument[]).map((row) => [ + row.ticker, + { + assetClass: row.asset_class, + assetGroup: row.asset_group, + instrumentType: row.instrument_type, + underlyingAsset: row.underlying_asset, + tenor: row.tenor, + collateralType: row.collateral_type, + gruLayer: row.gru_layer, + defillamaRwaEligible: row.defillamaEligible, + }, + ]), +); + +export function getCapitalMarketsClassification(symbol: string): CapitalMarketsFields | undefined { + return byTicker.get(symbol); +} + +export const RWA_CAPITAL_MARKETS_TAXONOMY_VERSION = taxonomy.version; diff --git a/services/token-aggregation/src/config/chain138-live-dodo-pools.ts b/services/token-aggregation/src/config/chain138-live-dodo-pools.ts new file mode 100644 index 0000000..94228de --- /dev/null +++ b/services/token-aggregation/src/config/chain138-live-dodo-pools.ts @@ -0,0 +1,17 @@ +/** Live traded DODO PMM pools on Chain 138 (Stack A). See EXPLORER_TOKEN_LIST_CROSSCHECK.md */ +export const CHAIN138_CANONICAL_LIVE_POOLS: readonly string[] = [ + '0x9e89bAe009adf128782E19e8341996c596ac40dC', + '0x866Cb44b59303d8dc5f4F9E3E7A8e8b0bf238d66', + '0xc39B7D0F40838cbFb54649d327f49a6DAC964062', + '0x67049e7333481e2cac91af61403ac7bddfab7bcd', + '0x72f1a0794153c3b8a1e8a731f1d8e1a52cb10dc5', + '0xb53a0508940b1ff90f1aad4f6cb50a7012fe5593', + '0xe227f6c0520c0c6e8786fe56fa76c4914f861533', + '0xf3e8a07d419b61f002114e64d79f7cf8f7989433', +] as const; + +const LIVE_POOL_SET = new Set(CHAIN138_CANONICAL_LIVE_POOLS.map((p) => p.toLowerCase())); + +export function isChain138CanonicalLivePool(poolAddress: string): boolean { + return LIVE_POOL_SET.has(poolAddress.trim().toLowerCase()); +} diff --git a/services/token-aggregation/src/config/cross-chain-bridges.ts b/services/token-aggregation/src/config/cross-chain-bridges.ts index 2074290..0d400f2 100644 --- a/services/token-aggregation/src/config/cross-chain-bridges.ts +++ b/services/token-aggregation/src/config/cross-chain-bridges.ts @@ -112,7 +112,7 @@ export interface RoutingRegistryEntry { } const ALLTRA_ADAPTER_138 = envAddr('ALLTRA_ADAPTER_ADDRESS') || envAddr('ALLTRA_CUSTOM_BRIDGE_ADDRESS') || '0x66FEBA2fC9a0B47F26DD4284DAd24F970436B8Dc'; -const CCIP_WETH9_138 = envAddr('CCIPWETH9_BRIDGE_CHAIN138') || '0x971cD9D156f193df8051E48043C476e53ECd4693'; +const CCIP_WETH9_138 = envAddr('CCIPWETH9_BRIDGE_CHAIN138') || '0xcacfd227A040002e49e2e01626363071324f820a'; const CCIP_STABLE_138 = envAnyAddr('CCIP_STABLE_BRIDGE_CHAIN138', 'CCIP_STABLECOIN_BRIDGE_CHAIN138'); const UNIVERSAL_CCIP_138 = envAnyAddr('UNIVERSAL_CCIP_BRIDGE_ADDRESS', 'UNIVERSAL_CCIP_BRIDGE'); diff --git a/services/token-aggregation/src/config/gru-transport.ts b/services/token-aggregation/src/config/gru-transport.ts index 932321d..b83e37d 100644 --- a/services/token-aggregation/src/config/gru-transport.ts +++ b/services/token-aggregation/src/config/gru-transport.ts @@ -1,3 +1,4 @@ +import { isChain138CanonicalLivePool } from './chain138-live-dodo-pools'; import { loadTokenMappingLoader } from './repo-config-loader'; export interface ConfigRef { @@ -311,6 +312,7 @@ export function shouldExposePublicPool( token0Address: string, token1Address: string ): boolean { + if (chainId === 138 && isChain138CanonicalLivePool(poolAddress)) return true; const loader = loadGruTransportLoader(); return loader?.shouldExposePublicPool?.(chainId, poolAddress, token0Address, token1Address) ?? true; } @@ -321,6 +323,7 @@ export function shouldUsePublicPoolForRouting( token0Address: string, token1Address: string ): boolean { + if (chainId === 138 && isChain138CanonicalLivePool(poolAddress)) return true; const loader = loadGruTransportLoader(); return loader?.shouldUsePublicPoolForRouting?.(chainId, poolAddress, token0Address, token1Address) ?? true; } diff --git a/services/token-aggregation/src/indexer/omnl-event-poller.ts b/services/token-aggregation/src/indexer/omnl-event-poller.ts index 4f389d9..616a881 100644 --- a/services/token-aggregation/src/indexer/omnl-event-poller.ts +++ b/services/token-aggregation/src/indexer/omnl-event-poller.ts @@ -2,6 +2,7 @@ import { Contract, JsonRpcProvider, type InterfaceAbi } from 'ethers'; import { logger } from '../utils/logger'; import { emitOmnlWebhook } from '../services/omnl-webhooks'; import { loadLastProcessedBlock, saveLastProcessedBlock } from './omnl-poller-state'; +import { omnlReserveStore138 } from '../services/omnl-chain138-addresses'; const RESERVE_ABI: InterfaceAbi = [ 'event ReserveCommitted(bytes32 indexed lineId,uint256 R,uint256 validUntil,bytes32 evidenceHash,bytes32 merkleRoot,uint256 version,address indexed by)', @@ -21,7 +22,7 @@ export class OmnlEventPoller { for (const chainId of chains) { const addr = chainId === 138 - ? process.env.OMNL_RESERVE_STORE_138 + ? omnlReserveStore138() : chainId === 651940 ? process.env.OMNL_RESERVE_STORE_651940 : process.env[`OMNL_RESERVE_STORE_${chainId}`]; diff --git a/services/token-aggregation/src/services/live-dodo-fallback.ts b/services/token-aggregation/src/services/live-dodo-fallback.ts index 681cc0e..c30cb27 100644 --- a/services/token-aggregation/src/services/live-dodo-fallback.ts +++ b/services/token-aggregation/src/services/live-dodo-fallback.ts @@ -2,6 +2,7 @@ import { ethers } from 'ethers'; import type { LiquidityPool } from '../database/repositories/pool-repo'; import { getChainConfig } from '../config/chains'; import { getDexFactories } from '../config/dex-factories'; +import { CHAIN138_CANONICAL_LIVE_POOLS, isChain138CanonicalLivePool } from '../config/chain138-live-dodo-pools'; import { shouldExposePublicPool } from '../config/gru-transport'; import { logger } from '../utils/logger'; import { estimateChain138DodoLiquidityUsd } from './chain138-dodo-liquidity'; @@ -23,6 +24,12 @@ const DODO_DVM_POOL_ABI = [ const CACHE_TTL_MS = 15_000; const livePoolCache = new Map(); +function bypassGruPoolFilter(chainId: number, poolAddress: string): boolean { + if (chainId !== 138) return false; + if (process.env.TOKEN_AGGREGATION_BYPASS_GRU_POOL_FILTER === '1') return true; + return isChain138CanonicalLivePool(poolAddress); +} + interface LivePoolSnapshot { token0Address: string; token1Address: string; @@ -130,7 +137,10 @@ export async function getLiveDodoPools(chainId: number): Promise 0) { + const integrationAddress = integrations[0]; + const integration = new ethers.Contract( + integrationAddress, + DODO_PMM_INTEGRATION_ABI, + provider + ); + for (const poolAddressRaw of CHAIN138_CANONICAL_LIVE_POOLS) { + const poolAddress = poolAddressRaw.toLowerCase(); + if (poolsByAddress.has(poolAddress)) continue; + try { + const snapshot = + await readPoolViaIntegration(integration, poolAddressRaw).catch(async () => + readPoolDirectly(provider, poolAddressRaw) + ); + const { token0Address, token1Address, reserve0, reserve1, price, createdAt } = snapshot; + const liquidityUsd = estimateChain138DodoLiquidityUsd({ + token0Address, + token1Address, + reserve0, + reserve1, + price, + }); + poolsByAddress.set(poolAddress, { + chainId, + poolAddress, + token0Address, + token1Address, + dexType: 'dodo', + factoryAddress: integrationAddress, + reserve0: reserve0.toString(), + reserve1: reserve1.toString(), + reserve0Usd: liquidityUsd.reserve0Usd, + reserve1Usd: liquidityUsd.reserve1Usd, + totalLiquidityUsd: liquidityUsd.totalLiquidityUsd, + volume24h: 0, + createdAtBlock: 0, + createdAtTimestamp: createdAt, + lastUpdated: new Date(), + }); + } catch (error) { + logger.warn( + `Skipping canonical live DODO pool ${poolAddressRaw} on chain ${chainId}: ${String(error)}` + ); + } + } + } + const pools = Array.from(poolsByAddress.values()).sort( (a, b) => b.totalLiquidityUsd - a.totalLiquidityUsd ); diff --git a/services/token-aggregation/src/services/omnl-api-catalog.ts b/services/token-aggregation/src/services/omnl-api-catalog.ts index da48d39..ed91adc 100644 --- a/services/token-aggregation/src/services/omnl-api-catalog.ts +++ b/services/token-aggregation/src/services/omnl-api-catalog.ts @@ -15,16 +15,21 @@ export function getOmnlApiCatalog(): { return { service: 'HYBX OMNL', basePath: '/api/v1', - version: '1.2.0', + version: '1.3.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' }, { method: 'GET', path: '/omnl/integration-status', description: 'Which env-backed integrations are configured', auth: 'none' }, - { method: 'GET', path: '/omnl/reconcile-anchor', description: 'SHA-256 of canonical IPSAS registry + journal matrix JSON', auth: 'none' }, + { 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: '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' }, { method: 'GET', path: '/omnl/cross-chain-lines', description: 'hybx-omnl-cross-chain-lines.json lines[]', auth: 'none' }, { method: 'GET', path: '/omnl/zk-verifier', description: 'ZK verifier address from OMNL_ZK_VERIFIER', auth: 'none' }, { method: 'GET', path: '/omnl/mirror-coordinator', description: 'Read on-chain mirror destination', query: ['chainId'], auth: 'none' }, - { method: 'GET', path: '/omnl/compliance/:lineId', description: 'ComplianceCore snapshot', query: ['chainId'], auth: 'none' }, + { method: 'GET', path: '/omnl/compliance/:lineId', description: 'ComplianceCore snapshot', query: ['chainId'], auth: 'OMNL_API_KEY when OMNL_REQUIRE_API_KEY=1' }, { method: 'GET', path: '/omnl/compliance-aggregated/:lineId', description: 'Aggregated supply + policy', auth: 'none' }, { method: 'GET', path: '/omnl/instruments', description: 'InstrumentRegistry lines', query: ['chainId'], auth: 'none' }, { method: 'GET', path: '/omnl/attestations/:lineId', description: 'ReserveCommitmentStore commitment', query: ['chainId'], auth: 'none' }, diff --git a/services/token-aggregation/src/services/omnl-audit-log.ts b/services/token-aggregation/src/services/omnl-audit-log.ts new file mode 100644 index 0000000..988e210 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-audit-log.ts @@ -0,0 +1,37 @@ +import { appendFileSync, mkdirSync } from 'fs'; +import { dirname, resolve } from 'path'; +import { randomUUID } from 'crypto'; + +export type OmnlAuditEntry = { + id: string; + ts: string; + category: 'api' | 'fineract' | 'webhook_out' | 'webhook_in' | 'reconcile' | 'iso20022'; + action: string; + actor?: string; + sourceSystem?: string; + traceId?: string; + referenceNumber?: string; + transactionHash?: string; + journalEntryId?: string; + status?: 'ok' | 'error' | 'skipped'; + metadata?: Record; +}; + +export function omnlAuditLogPath(): string { + const raw = process.env.OMNL_AUDIT_LOG_PATH?.trim(); + if (raw) return resolve(raw); + const root = process.env.PROXMOX_ROOT || process.env.PHOENIX_REPO_ROOT || process.cwd(); + return resolve(root, 'reports/audit/omnl-audit.jsonl'); +} + +/** Append-only JSONL audit trail (immutable by convention — no delete API). */ +export function appendOmnlAudit(entry: Omit & { id?: string; ts?: string }): void { + const path = omnlAuditLogPath(); + mkdirSync(dirname(path), { recursive: true }); + const row: OmnlAuditEntry = { + id: entry.id || randomUUID(), + ts: entry.ts || new Date().toISOString(), + ...entry, + }; + appendFileSync(path, `${JSON.stringify(row)}\n`, 'utf8'); +} diff --git a/services/token-aggregation/src/services/omnl-chain138-addresses.ts b/services/token-aggregation/src/services/omnl-chain138-addresses.ts new file mode 100644 index 0000000..c785fd6 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-chain138-addresses.ts @@ -0,0 +1,30 @@ +/** + * Chain 138 OMNL address resolution — prefers v2 stack after cutover. + */ +export function omnlComplianceCore138(): string | undefined { + const v2 = (process.env.OMNL_COMPLIANCE_CORE_V2_138 || '').trim(); + if (v2) return v2; + return (process.env.OMNL_COMPLIANCE_CORE_138 || '').trim() || undefined; +} + +export function omnlReserveStore138(): string | undefined { + const v2 = (process.env.OMNL_RESERVE_STORE_V2_138 || '').trim(); + if (v2) return v2; + const legacy = (process.env.OMNL_RESERVE_STORE_138 || '').trim(); + if (legacy) return legacy; + return (process.env.OMNL_RESERVE_COMMITMENT_STORE || '').trim() || undefined; +} + +export function omnlStackCutover(): { + usingV2: boolean; + complianceCore: string | undefined; + reserveStore: string | undefined; +} { + const complianceCore = omnlComplianceCore138(); + const reserveStore = omnlReserveStore138(); + const usingV2 = Boolean( + (process.env.OMNL_COMPLIANCE_CORE_V2_138 || '').trim() && + complianceCore === (process.env.OMNL_COMPLIANCE_CORE_V2_138 || '').trim() + ); + return { usingV2, complianceCore, reserveStore }; +} diff --git a/services/token-aggregation/src/services/omnl-compliance-pack.ts b/services/token-aggregation/src/services/omnl-compliance-pack.ts new file mode 100644 index 0000000..4031872 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-compliance-pack.ts @@ -0,0 +1,150 @@ +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import { createHash } from 'crypto'; + +function projectRoot(): string { + return ( + process.env.PROXMOX_ROOT?.trim() || + process.env.PHOENIX_REPO_ROOT?.trim() || + resolve(__dirname, '../../../../../..') + ); +} + +function readJson(relPath: string): T | null { + const p = resolve(projectRoot(), relPath); + if (!existsSync(p)) return null; + return JSON.parse(readFileSync(p, 'utf8')) as T; +} + +export interface SignoffRecord { + schemaVersion: string; + status: string; + policyId?: string; + methodologyId?: string; + policyDocPath?: string; + methodologyDocPath?: string; + signedAt?: string | null; + checklist?: Record; + classification?: string | Record; + model?: { + approach?: string; + stagingEnabled?: boolean; + forwardLookingScenarios?: boolean; + }; +} + +export function loadIas32Signoff(): SignoffRecord | null { + return readJson('config/compliance/ias32-gru-signoff.v1.json'); +} + +export function loadIfrs9Signoff(): SignoffRecord | null { + return readJson('config/compliance/ifrs9-ecl-signoff.v1.json'); +} + +export function isIas32Signed(): boolean { + const s = loadIas32Signoff(); + return s?.status === 'signed'; +} + +export function isIfrs9Signed(): boolean { + const s = loadIfrs9Signoff(); + return s?.status === 'signed'; +} + +export interface Ifrs9EclParameters { + schemaVersion: string; + reportingCurrency: string; + segments: Array<{ + id: string; + pd: number; + lgd: number; + stage: number; + glCodesExposure?: string[]; + officeIds?: number[]; + }>; + journalTemplate?: { + debitGlCode: string; + creditGlCode: string; + officeId: number; + narrativePrefix: string; + }; +} + +export function loadIfrs9EclParameters(): Ifrs9EclParameters | null { + const custom = process.env.OMNL_IFRS9_PARAMETERS_PATH?.trim(); + if (custom && existsSync(custom)) { + return JSON.parse(readFileSync(custom, 'utf8')) as Ifrs9EclParameters; + } + const live = readJson('config/compliance/ifrs9-ecl-parameters.v1.json'); + if (live) return live; + return readJson('config/compliance/ifrs9-ecl-parameters.v1.example.json'); +} + +export function loadXauReconcileBaseline(): Record | null { + return readJson('config/compliance/gru-xau-reconcile-baseline.v1.json'); +} + +export function computeEclAllowance(params: Ifrs9EclParameters, exposures: Array<{ segmentId: string; ead: number }>): { + totalEcl: number; + bySegment: Array<{ segmentId: string; ead: number; pd: number; lgd: number; ecl: number; stage: number }>; +} { + const bySegment: Array<{ segmentId: string; ead: number; pd: number; lgd: number; ecl: number; stage: number }> = []; + let totalEcl = 0; + for (const exp of exposures) { + const seg = params.segments.find((s) => s.id === exp.segmentId); + if (!seg) continue; + const ecl = exp.ead * seg.pd * seg.lgd; + bySegment.push({ + segmentId: exp.segmentId, + ead: exp.ead, + pd: seg.pd, + lgd: seg.lgd, + ecl, + stage: seg.stage, + }); + totalEcl += ecl; + } + return { totalEcl, bySegment }; +} + +export function hashPolicyDoc(relPath: string): string | null { + const p = resolve(projectRoot(), relPath); + if (!existsSync(p)) return null; + return createHash('sha256').update(readFileSync(p, 'utf8')).digest('hex'); +} + +export function loadIfrs13FairValueFeeds(): Record | null { + return readJson('config/compliance/ifrs13-fair-value-feeds.v1.json'); +} + +export function loadProvisionsRegister(): Record | null { + return readJson('config/omnl-provisions-register.json'); +} + +export function getComplianceSignoffsSummary(): Record { + const ias32 = loadIas32Signoff(); + const ifrs9 = loadIfrs9Signoff(); + const eclParams = loadIfrs9EclParameters(); + return { + generatedAt: new Date().toISOString(), + ias32: ias32 + ? { + ...ias32, + policySha256Computed: ias32.policyDocPath + ? hashPolicyDoc('gru-docs/docs/compliance/accounting/Accounting_Policy_IFRS_IAS32_GRU.md') + : null, + readyForExternalUse: ias32.status === 'signed', + } + : null, + ifrs9: ifrs9 + ? { + ...ifrs9, + readyForExternalUse: ifrs9.status === 'signed' && process.env.OMNL_IFRS9_ECL_ENABLED === '1', + } + : null, + eclParametersLoaded: Boolean(eclParams), + custodianSnapshotConfigured: existsSync( + resolve(projectRoot(), process.env.OMNL_CUSTODIAN_SNAPSHOT_PATH || 'config/omnl-custodian-snapshot.json') + ), + }; +} diff --git a/services/token-aggregation/src/services/omnl-compliance.ts b/services/token-aggregation/src/services/omnl-compliance.ts index 26583c9..e1a5b08 100644 --- a/services/token-aggregation/src/services/omnl-compliance.ts +++ b/services/token-aggregation/src/services/omnl-compliance.ts @@ -2,6 +2,7 @@ import { Contract, JsonRpcProvider, type InterfaceAbi } from 'ethers'; import { readFileSync, existsSync } from 'fs'; import { resolve } from 'path'; import { isCompliant, maxM1ForM0, minReservesForM0 } from './omnl-policy-math'; +import { omnlComplianceCore138, omnlReserveStore138 } from './omnl-chain138-addresses'; const COMPLIANCE_ABI: InterfaceAbi = [ 'function getCompliance(bytes32 lineId) view returns (uint256 s0, uint256 s1, uint256 r, uint256 validUntil, bytes32 evidenceHash, bytes32 merkleRoot, uint256 minR, uint256 maxS1, bool m0Ok, bool m1Ok, bool attestationStale, bool policyOk, bool operational, bool reportingCompliant)', @@ -137,9 +138,9 @@ async function readTotalSupply(rpcUrl: string, chainId: number, token: string): * Sum M0/M1 supply across chains for a logical line; use R/TTL from primary (138) reserve or compliance. */ export async function fetchOmnlComplianceAggregated(lineIdHex: string): Promise { - const addr138 = process.env.OMNL_COMPLIANCE_CORE_138; + const addr138 = omnlComplianceCore138(); if (!addr138) { - throw new Error('OMNL_COMPLIANCE_CORE_138 required for aggregated view'); + throw new Error('OMNL_COMPLIANCE_CORE_138 or OMNL_COMPLIANCE_CORE_V2_138 required for aggregated view'); } const lineId = lineIdHex.startsWith('0x') ? lineIdHex : `0x${lineIdHex}`; @@ -220,7 +221,7 @@ export async function fetchLatestAttestation( ): Promise<{ r: string; validUntil: string; evidenceHash: string; merkleRoot: string; version: string }> { const addr = chainId === 138 - ? process.env.OMNL_RESERVE_STORE_138 + ? omnlReserveStore138() : chainId === 651940 ? process.env.OMNL_RESERVE_STORE_651940 : process.env[`OMNL_RESERVE_STORE_${chainId}`]; diff --git a/services/token-aggregation/src/services/omnl-fineract-client.ts b/services/token-aggregation/src/services/omnl-fineract-client.ts new file mode 100644 index 0000000..00e38b7 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-fineract-client.ts @@ -0,0 +1,64 @@ +import axios, { type AxiosRequestConfig } from 'axios'; + +function fineractAuthHeaders(): { base: string; headers: Record } { + const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, ''); + const tenant = process.env.OMNL_FINERACT_TENANT || 'omnl'; + const user = + process.env.OMNL_FINERACT_USER?.trim() || + process.env.OMNL_FINERACT_USERNAME?.trim() || + 'app.omnl'; + const pass = process.env.OMNL_FINERACT_PASSWORD || ''; + if (!base || !pass) { + throw new Error('OMNL_FINERACT_BASE_URL and OMNL_FINERACT_PASSWORD required'); + } + const auth = Buffer.from(`${user}:${pass}`).toString('base64'); + return { + base, + headers: { + Authorization: `Basic ${auth}`, + 'Fineract-Platform-TenantId': tenant, + 'Content-Type': 'application/json', + }, + }; +} + +export async function fineractJournalExistsByReference(referenceNumber: string): Promise { + const { base, headers } = fineractAuthHeaders(); + const url = `${base}/journalentries`; + const cfg: AxiosRequestConfig = { + headers, + timeout: 60000, + params: { referenceNumber, limit: 1 }, + }; + try { + const { data } = await axios.get(url, cfg); + if (Array.isArray(data)) return data.length > 0; + const items = (data as { pageItems?: unknown[] })?.pageItems; + return Array.isArray(items) && items.length > 0; + } catch { + return false; + } +} + +export async function fineractOfficeHasClosureOnOrBefore(officeId: number, dateIso: string): Promise { + const { base, headers } = fineractAuthHeaders(); + const url = `${base}/glclosures`; + const { data } = await axios.get(url, { headers, params: { officeId }, timeout: 60000 }); + const rows = Array.isArray(data) ? data : []; + const target = new Date(dateIso).getTime(); + return rows.some((r: { closingDate?: string[] | string }) => { + const raw = Array.isArray(r.closingDate) ? r.closingDate.join('-') : r.closingDate; + if (!raw) return false; + return new Date(raw).getTime() >= target; + }); +} + +export async function fetchLatestJournalEntryId(officeId?: number): Promise { + const { base, headers } = fineractAuthHeaders(); + const params: Record = { limit: 1, orderBy: 'id', sortOrder: 'DESC' }; + if (officeId) params.officeId = officeId; + const { data } = await axios.get(`${base}/journalentries`, { headers, params, timeout: 60000 }); + const row = Array.isArray(data) ? data[0] : (data as { pageItems?: { id?: number }[] })?.pageItems?.[0]; + if (!row?.id) return null; + return String(row.id); +} diff --git a/services/token-aggregation/src/services/omnl-ifrs-disclosures.ts b/services/token-aggregation/src/services/omnl-ifrs-disclosures.ts new file mode 100644 index 0000000..7679163 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-ifrs-disclosures.ts @@ -0,0 +1,176 @@ +import { fetchFineractGlAccounts, loadIpsasRegistry } from './omnl-ipsas-gl'; +import { fetchOmnlComplianceAggregated } from './omnl-compliance'; +import { + loadIas32Signoff, + loadIfrs9Signoff, + loadIfrs9EclParameters, + computeEclAllowance, + isIas32Signed, + isIfrs9Signed, + getComplianceSignoffsSummary, + loadIfrs13FairValueFeeds, + loadProvisionsRegister, +} from './omnl-compliance-pack'; + +export { getComplianceSignoffsSummary }; + +/** IFRS 7-style liquidity / liability snapshot (machine-readable; requires accountant review). */ +export async function buildIfrs7Disclosure(lineId?: string): Promise> { + const line = lineId || process.env.OMNL_HEALTH_LINE_ID?.trim() || ''; + const registry = loadIpsasRegistry(); + const ias32 = loadIas32Signoff(); + let glCount = 0; + try { + glCount = (await fetchFineractGlAccounts()).length; + } catch { + glCount = 0; + } + let compliance: Record | null = null; + if (line) { + try { + compliance = (await fetchOmnlComplianceAggregated(line)) as unknown as Record; + } catch { + compliance = null; + } + } + return { + standard: 'IFRS 7', + disclaimer: isIas32Signed() + ? 'Automated extract — signed IAS 32 policy on file' + : 'Requires qualified accountant sign-off — IAS 32 policy pending', + generatedAt: new Date().toISOString(), + lineId: line || null, + ias32SignoffStatus: ias32?.status ?? 'missing', + financialLiabilities: { + policyReference: 'gru-docs/docs/compliance/accounting/Accounting_Policy_IFRS_IAS32_GRU.md', + classification: ias32?.classification ?? null, + glRoles: registry.monetaryLayerHints?.m1_liability, + fineractGlAccountCount: glCount, + }, + liquidityRisk: { + onChainReserveR: compliance?.r ?? null, + onChainM1SupplyS1: compliance?.s1 ?? null, + reportingCompliant: compliance?.reportingCompliant ?? null, + }, + creditRisk: { + eclEngineConfigured: Boolean(process.env.OMNL_IFRS9_ECL_ENABLED), + ifrs9SignoffStatus: loadIfrs9Signoff()?.status ?? 'missing', + }, + }; +} + +/** IFRS 9 classification / measurement + ECL allowance from parameters. */ +export function buildIfrs9Disclosure(exposureOverrides?: Array<{ segmentId: string; ead: number }>): Record { + const signoff = loadIfrs9Signoff(); + const params = loadIfrs9EclParameters(); + const exposures = + exposureOverrides ?? + (params?.segments ?? []).map((s) => ({ + segmentId: s.id, + ead: parseFloat(process.env[`ECL_EAD_${s.id.toUpperCase()}`] || '0') || 0, + })); + const ecl = + params && exposures.some((e) => e.ead > 0) + ? computeEclAllowance(params, exposures) + : { totalEcl: 0, bySegment: [] }; + return { + standard: 'IFRS 9', + disclaimer: isIfrs9Signed() + ? 'Signed methodology on file — verify amounts before posting' + : 'Requires qualified accountant sign-off — methodology pending', + generatedAt: new Date().toISOString(), + signoffStatus: signoff?.status ?? 'missing', + classification: { + gruInstrument: 'financial_liability', + defaultMeasurement: 'amortized_cost', + policyDoc: 'gru-docs/docs/compliance/accounting/IFRS9_ECL_METHODOLOGY_GRU.md', + approach: signoff?.model?.approach ?? (params ? 'from_parameters_file' : 'unset'), + }, + impairment: { + eclEnabled: process.env.OMNL_IFRS9_ECL_ENABLED === '1', + stagingModel: process.env.OMNL_IFRS9_STAGING_MODEL || signoff?.model?.approach || 'simplified', + provisionGlCodes: ['52100', '23010'], + totalEclAllowance: ecl.totalEcl, + bySegment: ecl.bySegment, + parametersSchemaVersion: params?.schemaVersion ?? null, + }, + derecognition: { + automated: false, + finteractJournalRequired: true, + monthlyScript: 'scripts/compliance/run-ifrs9-ecl-monthly.sh', + }, + }; +} + +/** IFRS 13 fair-value hierarchy tagging for reserve evidence. */ +export function buildIfrs13Disclosure(lineId?: string): Record { + const line = lineId || process.env.OMNL_HEALTH_LINE_ID?.trim() || ''; + const feeds = loadIfrs13FairValueFeeds(); + return { + standard: 'IFRS 13', + disclaimer: isIas32Signed() ? 'Supporting reserve evidence hierarchy' : 'Pending IAS 32 sign-off', + generatedAt: new Date().toISOString(), + lineId: line || null, + configSchemaVersion: feeds?.schemaVersion ?? null, + hierarchy: feeds?.hierarchy ?? { + level1: { + description: 'Quoted prices in active markets (e.g. LBMA gold fix feeds)', + envFeed: process.env.OMNL_LBMA_FEED_URL || null, + }, + level2: { + description: 'Observable inputs', + contracts: { + reserve: process.env.OMNL_RESERVE_COMMITMENT_STORE || null, + notary: process.env.OMNL_NOTARY_REGISTRY || null, + }, + }, + level3: { + description: 'Custodian / PoR attestation', + custodianPath: process.env.OMNL_CUSTODIAN_SNAPSHOT_PATH || 'config/omnl-custodian-snapshot.json', + }, + }, + deviationThresholdPercent: feeds?.deviationThresholdPercent ?? 2.5, + reportScript: 'scripts/omnl/omnl-fair-value-hierarchy-report.sh', + reserveCommitment: { + primaryChain: 138, + mirrorChain: 651940, + }, + }; +} + +/** IAS 21 FX / IAS 37 provisions pointers. */ +export function buildIas21Ias37Disclosure(): Record { + const provisions = loadProvisionsRegister(); + const provList = (provisions?.provisions as unknown[]) ?? []; + const contingent = (provisions?.contingentLiabilities as unknown[]) ?? []; + return { + generatedAt: new Date().toISOString(), + ias21: { + revaluationGlIncome: '42100', + revaluationGlExpense: '52100', + fxReserveGl: ['12010', '12020', '12090'], + automatedJob: 'scripts/omnl/omnl-fx-revaluation-post.sh', + }, + ias37: { + provisionGlHeader: '23000', + provisionGlDetail: '23010', + registerPath: 'config/omnl-provisions-register.json', + provisionCount: provList.length, + contingentLiabilityCount: contingent.length, + provisions: provList, + contingentLiabilities: contingent, + reportScript: 'scripts/omnl/omnl-provisions-report.sh', + }, + }; +} + +export async function buildFullDisclosure(lineId?: string): Promise> { + return { + generatedAt: new Date().toISOString(), + signoffs: getComplianceSignoffsSummary(), + ifrs7: await buildIfrs7Disclosure(lineId), + ifrs9: buildIfrs9Disclosure(), + ifrs13: buildIfrs13Disclosure(lineId), + ias21_ias37: buildIas21Ias37Disclosure(), + }; +} diff --git a/services/token-aggregation/src/services/omnl-integration-status.ts b/services/token-aggregation/src/services/omnl-integration-status.ts index 8f3a730..9dd5c5a 100644 --- a/services/token-aggregation/src/services/omnl-integration-status.ts +++ b/services/token-aggregation/src/services/omnl-integration-status.ts @@ -1,5 +1,6 @@ import { loadIpsasRegistryPath } from './omnl-ipsas-gl'; import { loadJournalMatrixPath } from './omnl-journal-matrix'; +import { omnlComplianceCore138, omnlReserveStore138 } from './omnl-chain138-addresses'; /** Non-secret snapshot of which OMNL integrations are configured (for probes and dashboards). */ export function getOmnlIntegrationStatus(): Record { @@ -7,12 +8,14 @@ export function getOmnlIntegrationStatus(): Record { fineract: Boolean( (process.env.OMNL_FINERACT_BASE_URL || '').trim() && (process.env.OMNL_FINERACT_PASSWORD || '').trim() ), - complianceCore138: Boolean((process.env.OMNL_COMPLIANCE_CORE_138 || '').trim()), + complianceCore138: Boolean(omnlComplianceCore138()), + complianceCoreV2_138: Boolean((process.env.OMNL_COMPLIANCE_CORE_V2_138 || '').trim()), complianceCore651940: Boolean((process.env.OMNL_COMPLIANCE_CORE_651940 || '').trim()), instrumentRegistry138: Boolean( (process.env.OMNL_INSTRUMENT_REGISTRY_138 || process.env.OMNL_INSTRUMENT_REGISTRY || '').trim() ), - reserveStore138: Boolean((process.env.OMNL_RESERVE_STORE_138 || '').trim()), + reserveStore138: Boolean(omnlReserveStore138()), + reserveStoreV2_138: Boolean((process.env.OMNL_RESERVE_STORE_V2_138 || '').trim()), reserveStore651940: Boolean((process.env.OMNL_RESERVE_STORE_651940 || '').trim()), circuitBreaker138: Boolean((process.env.OMNL_CIRCUIT_BREAKER_138 || '').trim()), circuitBreaker651940: Boolean((process.env.OMNL_CIRCUIT_BREAKER_651940 || '').trim()), diff --git a/services/token-aggregation/src/services/omnl-iso20022-store.ts b/services/token-aggregation/src/services/omnl-iso20022-store.ts new file mode 100644 index 0000000..aa1eb98 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-iso20022-store.ts @@ -0,0 +1,100 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs'; +import { join, resolve } from 'path'; +import { createHash, randomUUID } from 'crypto'; +import { appendOmnlAudit } from './omnl-audit-log'; + +export type Iso20022MessageType = 'pain.001' | 'pacs.008' | 'camt.053' | 'other'; + +export type Iso20022Record = { + id: string; + messageType: Iso20022MessageType; + receivedAt: string; + retentionUntil: string; + uetr?: string; + instructionId?: string; + settlementOrChainRef?: string; + accountingRef?: string; + payloadSha256: string; + payload: string; +}; + +function storeDir(): string { + const raw = process.env.OMNL_ISO20022_STORE_DIR?.trim(); + if (raw) return resolve(raw); + const root = process.env.PROXMOX_ROOT || process.env.PHOENIX_REPO_ROOT || process.cwd(); + return resolve(root, 'config/iso20022-omnl/messages'); +} + +function retentionYears(): number { + const y = parseInt(process.env.OMNL_ISO20022_RETENTION_YEARS || '10', 10); + return Number.isFinite(y) && y > 0 ? y : 10; +} + +export function iso20022RetentionUntil(fromIso?: string): string { + const d = fromIso ? new Date(fromIso) : new Date(); + d.setFullYear(d.getFullYear() + retentionYears()); + return d.toISOString(); +} + +export function saveIso20022Message(input: { + messageType: Iso20022MessageType; + payload: string; + uetr?: string; + instructionId?: string; + settlementOrChainRef?: string; + accountingRef?: string; +}): Iso20022Record { + const dir = storeDir(); + mkdirSync(dir, { recursive: true }); + const id = randomUUID(); + const receivedAt = new Date().toISOString(); + const payloadSha256 = createHash('sha256').update(input.payload, 'utf8').digest('hex'); + const record: Iso20022Record = { + id, + messageType: input.messageType, + receivedAt, + retentionUntil: iso20022RetentionUntil(receivedAt), + uetr: input.uetr, + instructionId: input.instructionId, + settlementOrChainRef: input.settlementOrChainRef, + accountingRef: input.accountingRef, + payloadSha256, + payload: input.payload, + }; + writeFileSync(join(dir, `${id}.json`), JSON.stringify(record, null, 2), 'utf8'); + appendOmnlAudit({ + category: 'iso20022', + action: 'store_message', + traceId: id, + referenceNumber: input.instructionId, + metadata: { messageType: input.messageType, payloadSha256 }, + status: 'ok', + }); + return record; +} + +export function listIso20022Messages(limit = 50): Array> { + const dir = storeDir(); + if (!existsSync(dir)) return []; + const files = readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .sort() + .reverse() + .slice(0, limit); + return files.map((f) => { + const r = JSON.parse(readFileSync(join(dir, f), 'utf8')) as Iso20022Record; + return { + id: r.id, + messageType: r.messageType, + receivedAt: r.receivedAt, + instructionId: r.instructionId, + accountingRef: r.accountingRef, + }; + }); +} + +export function getIso20022Message(id: string): Iso20022Record | null { + const p = join(storeDir(), `${id}.json`); + if (!existsSync(p)) return null; + return JSON.parse(readFileSync(p, 'utf8')) as Iso20022Record; +} diff --git a/services/token-aggregation/src/services/omnl-triple-reconcile.ts b/services/token-aggregation/src/services/omnl-triple-reconcile.ts new file mode 100644 index 0000000..9adb0fe --- /dev/null +++ b/services/token-aggregation/src/services/omnl-triple-reconcile.ts @@ -0,0 +1,226 @@ +import axios from 'axios'; +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import { fetchFineractGlAccounts } from './omnl-ipsas-gl'; +import { fetchOmnlComplianceAggregated } from './omnl-compliance'; +import { appendOmnlAudit } from './omnl-audit-log'; +import { loadXauReconcileBaseline } from './omnl-compliance-pack'; + +export interface TripleReconcileBreak { + code: string; + severity: 'critical' | 'high' | 'medium' | 'low'; + message: string; + fineract?: unknown; + onChain?: unknown; + custodian?: unknown; +} + +export interface TripleReconcileReport { + generatedAt: string; + lineId: string; + breaks: TripleReconcileBreak[]; + aligned: boolean; + fineract: { + glSnapshot: Array<{ glCode: string; name?: string; id: number }>; + liabilityGlCodes: string[]; + }; + onChain: { + r: string; + s0: string; + s1: string; + reportingCompliant: boolean; + attestationStale: boolean; + } | null; + custodian: { + source: string; + xauOunces?: string; + asOf?: string; + porAttestationRef?: string; + } | null; + comparison?: { + custodianOz?: number; + onChainROz?: number; + deltaOz?: number; + deltaPercent?: number; + withinTolerance?: boolean; + } | null; +} + +function loadCustodianSnapshot(): { + xauOunces?: string; + asOf?: string; + source: string; + porAttestationRef?: string; +} | null { + const envOz = process.env.OMNL_CUSTODIAN_RESERVE_XAU_OZ?.trim(); + if (envOz) { + return { source: 'env:OMNL_CUSTODIAN_RESERVE_XAU_OZ', xauOunces: envOz, asOf: new Date().toISOString() }; + } + const file = + process.env.OMNL_CUSTODIAN_SNAPSHOT_PATH?.trim() || + resolve(process.cwd(), 'config/omnl-custodian-snapshot.json'); + if (!existsSync(file)) return null; + try { + const j = JSON.parse(readFileSync(file, 'utf8')) as { + xauOunces?: string; + asOf?: string; + porAttestationRef?: string; + }; + return { + source: file, + xauOunces: j.xauOunces, + asOf: j.asOf, + porAttestationRef: j.porAttestationRef, + }; + } catch { + return null; + } +} + +/** Bank/custodian ↔ Fineract GL ↔ on-chain reserve R — operational reconciliation (not statutory sign-off). */ +export async function runTripleStateReconcile(lineId?: string): Promise { + const line = lineId || process.env.OMNL_HEALTH_LINE_ID?.trim() || ''; + const breaks: TripleReconcileBreak[] = []; + const generatedAt = new Date().toISOString(); + + let glRows: Awaited> = []; + try { + glRows = await fetchFineractGlAccounts(); + } catch (e) { + breaks.push({ + code: 'FINERACT_UNREACHABLE', + severity: 'critical', + message: e instanceof Error ? e.message : String(e), + }); + } + + const liabilityCodes = ['2000', '2100', '1050', '1000', '21010']; + const glSnapshot = glRows + .filter((r) => r.glCode && liabilityCodes.includes(String(r.glCode))) + .map((r) => ({ glCode: String(r.glCode), name: r.name, id: r.id })); + + let onChain: TripleReconcileReport['onChain'] = null; + if (!line) { + breaks.push({ + code: 'LINE_ID_MISSING', + severity: 'high', + message: 'Set OMNL_HEALTH_LINE_ID or pass lineId query param', + }); + } else { + try { + const snap = await fetchOmnlComplianceAggregated(line); + onChain = { + r: snap.r, + s0: snap.s0, + s1: snap.s1, + reportingCompliant: snap.reportingCompliant, + attestationStale: snap.attestationStale, + }; + if (!snap.reportingCompliant) { + breaks.push({ + code: 'ONCHAIN_NOT_REPORTING_COMPLIANT', + severity: 'high', + message: 'ComplianceCore reportingCompliant is false', + onChain, + }); + } + if (snap.attestationStale) { + breaks.push({ + code: 'ONCHAIN_ATTESTATION_STALE', + severity: 'medium', + message: 'Reserve attestation past validUntil', + onChain, + }); + } + } catch (e) { + breaks.push({ + code: 'ONCHAIN_READ_FAILED', + severity: 'high', + message: e instanceof Error ? e.message : String(e), + }); + } + } + + const custodian = loadCustodianSnapshot(); + if (!custodian?.xauOunces) { + breaks.push({ + code: 'CUSTODIAN_SNAPSHOT_MISSING', + severity: 'medium', + message: 'Set OMNL_CUSTODIAN_RESERVE_XAU_OZ or config/omnl-custodian-snapshot.json', + }); + } + + let comparison: TripleReconcileReport['comparison'] = null; + const baseline = loadXauReconcileBaseline(); + const ratioStr = + process.env.OMNL_XAU_R_TO_OZ_RATIO?.trim() || + (baseline?.rMinorUnitsPerTroyOunce + ? String(1 / Number(baseline.rMinorUnitsPerTroyOunce)) + : ''); + if (custodian?.xauOunces && onChain?.r && ratioStr) { + const custOz = parseFloat(custodian.xauOunces); + const rMinor = parseFloat(onChain.r); + const ratio = parseFloat(ratioStr); + if (Number.isFinite(custOz) && Number.isFinite(rMinor) && Number.isFinite(ratio) && ratio > 0) { + const onChainOz = rMinor * ratio; + const deltaOz = custOz - onChainOz; + const deltaPercent = onChainOz !== 0 ? (Math.abs(deltaOz) / onChainOz) * 100 : 0; + const tol = Number(baseline?.tolerancePercent ?? 2.5); + comparison = { + custodianOz: custOz, + onChainROz: onChainOz, + deltaOz, + deltaPercent, + withinTolerance: deltaPercent <= tol, + }; + if (!comparison.withinTolerance) { + breaks.push({ + code: 'CUSTODIAN_ONCHAIN_DELTA_EXCEEDED', + severity: 'high', + message: `Custodian vs on-chain XAU delta ${deltaPercent.toFixed(2)}% exceeds tolerance ${tol}%`, + custodian, + onChain, + }); + } + } + } else if (custodian?.xauOunces && onChain?.r && !ratioStr) { + breaks.push({ + code: 'CUSTODIAN_CHAIN_RATIO_UNCONFIGURED', + severity: 'medium', + message: 'Set OMNL_XAU_R_TO_OZ_RATIO or config/compliance/gru-xau-reconcile-baseline.v1.json', + custodian, + onChain, + }); + } + + if (glSnapshot.length === 0 && glRows.length > 0) { + breaks.push({ + code: 'FINERACT_CORE_GL_MISSING', + severity: 'high', + message: 'Expected GL codes 1000/1050/2000/2100 not all present in Fineract', + fineract: { count: glRows.length }, + }); + } + + const aligned = breaks.filter((b) => b.severity === 'critical' || b.severity === 'high').length === 0; + const report: TripleReconcileReport = { + generatedAt, + lineId: line, + breaks, + aligned, + fineract: { glSnapshot, liabilityGlCodes: liabilityCodes }, + onChain, + custodian, + comparison, + }; + + appendOmnlAudit({ + category: 'reconcile', + action: 'triple_state_reconcile', + traceId: line || undefined, + status: aligned ? 'ok' : 'error', + metadata: { breakCount: breaks.length, aligned }, + }); + + return report; +} diff --git a/services/token-aggregation/src/services/omnl-web3-compliance.ts b/services/token-aggregation/src/services/omnl-web3-compliance.ts new file mode 100644 index 0000000..8f1b7d2 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-web3-compliance.ts @@ -0,0 +1,95 @@ +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import { createHash } from 'crypto'; +import { id as keccakId } from 'ethers'; + +function projectRoot(): string { + return ( + process.env.PROXMOX_ROOT?.trim() || + process.env.PHOENIX_REPO_ROOT?.trim() || + resolve(__dirname, '../../../../../..') + ); +} + +function readJson(rel: string): T | null { + const p = resolve(projectRoot(), rel); + if (!existsSync(p)) return null; + return JSON.parse(readFileSync(p, 'utf8')) as T; +} + +/** Matches Solidity `keccak256(bytes(string))` for short jurisdiction / matrix ids. */ +export function jurisdictionIdBytes32(id: string): string { + return keccakId(id); +} + +export function matrixControlIdBytes32(rowId: string): string { + return keccakId(rowId); +} + +export interface Web3ComplianceSummary { + generatedAt: string; + deployed: Record; + universalControls: unknown[]; + jurisdictions: Record; + gnosisSafeRecommendation: unknown; + notarizationRequiredForRows: string[]; +} + +export function getWeb3ComplianceSummary(): Web3ComplianceSummary { + const web3 = readJson<{ + universalControls: unknown[]; + jurisdictions: Record; + gnosisSafeRecommendation: unknown; + }>('config/compliance/jurisdiction-web3-controls.v1.json'); + + const deployed = readJson<{ contracts?: Record }>( + 'config/compliance/web3-multisig-deployed.v1.json' + ); + + const envDeployed = { + OMNLJurisdictionPolicyRegistry: process.env.OMNL_JURISDICTION_REGISTRY || null, + OMNLNotaryRegistry: process.env.OMNL_NOTARY_REGISTRY || null, + OMNLComplianceMultisig: process.env.OMNL_COMPLIANCE_MULTISIG || null, + ReserveCommitmentStore: process.env.OMNL_RESERVE_COMMITMENT_STORE || null, + GovernanceController: process.env.OMNL_GOVERNANCE_CONTROLLER || null, + GnosisSafeAdmin: process.env.OMNL_GNOSIS_SAFE_ADMIN || null, + }; + + const idRows = web3?.jurisdictions?.ID?.matrixRowsRequiringNotarization ?? []; + + return { + generatedAt: new Date().toISOString(), + deployed: { + ...envDeployed, + ...(deployed?.contracts ?? {}), + }, + universalControls: web3?.universalControls ?? [], + jurisdictions: web3?.jurisdictions ?? {}, + gnosisSafeRecommendation: web3?.gnosisSafeRecommendation ?? null, + notarizationRequiredForRows: idRows, + }; +} + +export function buildNotarizationIntent(input: { + jurisdictionId: string; + matrixControlId: string; + contentHash: string; + merkleRoot?: string; + metadataHash?: string; +}): Record { + const content = input.contentHash.startsWith('0x') ? input.contentHash : `0x${input.contentHash}`; + return { + jurisdictionId: jurisdictionIdBytes32(input.jurisdictionId), + matrixControlId: matrixControlIdBytes32(input.matrixControlId), + contentHash: content, + merkleRoot: input.merkleRoot ?? `0x${'0'.repeat(64)}`, + metadataHash: input.metadataHash ?? `0x${'0'.repeat(64)}`, + kind: 'EvidencePackage', + contract: process.env.OMNL_NOTARY_REGISTRY || null, + policyConfigHash: existsSync(resolve(projectRoot(), 'config/compliance/jurisdiction-web3-controls.v1.json')) + ? `0x${createHash('sha256') + .update(readFileSync(resolve(projectRoot(), 'config/compliance/jurisdiction-web3-controls.v1.json'))) + .digest('hex')}` + : null, + }; +} diff --git a/services/token-aggregation/src/services/omnl-webhooks.ts b/services/token-aggregation/src/services/omnl-webhooks.ts index 68d7cd3..4e37cf1 100644 --- a/services/token-aggregation/src/services/omnl-webhooks.ts +++ b/services/token-aggregation/src/services/omnl-webhooks.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import { createHmac, timingSafeEqual } from 'crypto'; import { logger } from '../utils/logger'; +import { appendOmnlAudit } from './omnl-audit-log'; export type OmnlWebhookPayload = { event: string; @@ -61,8 +62,22 @@ export async function emitOmnlWebhook(body: OmnlWebhookPayload): Promise { urls.map(async (url) => { try { await axios.post(url, rawBody, { timeout: 15000, headers }); + appendOmnlAudit({ + category: 'webhook_out', + action: 'emit', + traceId: body.deliveryId, + metadata: { url, event: body.event, chainId: body.chainId }, + status: 'ok', + }); } catch (e) { logger.warn(`OMNL webhook failed ${url}`, { error: e instanceof Error ? e.message : String(e) }); + appendOmnlAudit({ + category: 'webhook_out', + action: 'emit', + traceId: body.deliveryId, + metadata: { url, event: body.event }, + status: 'error', + }); } }) ); diff --git a/services/token-aggregation/src/services/valuation-precedence.test.ts b/services/token-aggregation/src/services/valuation-precedence.test.ts index f528e53..677a364 100644 --- a/services/token-aggregation/src/services/valuation-precedence.test.ts +++ b/services/token-aggregation/src/services/valuation-precedence.test.ts @@ -182,6 +182,31 @@ describe('valuation-precedence', () => { expect(v.priceUsd).toBe(9.99); }); + 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 v = resolveUsdValuation({ + chainId: 138, + normalizedAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + indexer: { + chainId: 138, + tokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + priceUsd: 2490, + volume24h: 0, + volume7d: 0, + volume30d: 0, + liquidityUsd: 0, + holdersCount: 0, + transfers24h: 0, + lastUpdated: lu, + }, + coingecko: { priceUsd: 2130.5, lastUpdated: new Date() }, + }); + expect(v.sourceLayer).toBe('external_coingecko'); + expect(v.priceUsd).toBe(2130.5); + expect(v.stale).toBe(false); + }); + 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 99b7842..9c3d89f 100644 --- a/services/token-aggregation/src/services/valuation-precedence.ts +++ b/services/token-aggregation/src/services/valuation-precedence.ts @@ -2,7 +2,7 @@ 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 } from './canonical-price-oracle'; +import { resolveCanonicalPriceUsd, type CanonicalPriceResolution } from './canonical-price-oracle'; const CHAIN_138 = 138; @@ -88,6 +88,20 @@ function iso(d: Date): string { return d.toISOString(); } +/** Indexer rows seeded from the static FX snapshot should not beat live reference feeds. */ +function indexerMatchesRepoFallbackSnapshot( + indexer: TokenMarketData, + canonical: CanonicalPriceResolution +): boolean { + if (canonical.source !== 'repo-fallback' || canonical.priceUsd === undefined) { + return false; + } + if (indexer.priceUsd === undefined || indexer.priceUsd === null) { + return false; + } + return Math.abs(indexer.priceUsd - canonical.priceUsd) < 1e-6; +} + /** * Layered USD valuation: * - Default: indexer (staleness-aware) → CoinGecko → CMC → DexScreener → canonical env/repo. @@ -117,7 +131,8 @@ export function resolveUsdValuation(input: ValuationInput): TokenPricing { if (indexer?.priceUsd !== undefined && indexer.priceUsd !== null) { const lu = indexer.lastUpdated instanceof Date ? indexer.lastUpdated : new Date(indexer.lastUpdated); - const stale = ageMsOf(lu) > maxAgeSeconds * 1000; + const stale = + ageMsOf(lu) > maxAgeSeconds * 1000 || indexerMatchesRepoFallbackSnapshot(indexer, canonical); out.push({ layer: 'indexer_market', rank: 1, diff --git a/services/transaction-mirroring-service/dist/index.js b/services/transaction-mirroring-service/dist/index.js index f72f7fd..87a6270 100644 --- a/services/transaction-mirroring-service/dist/index.js +++ b/services/transaction-mirroring-service/dist/index.js @@ -32,22 +32,43 @@ var __importStar = (this && this.__importStar) || (function () { return result; }; })(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; Object.defineProperty(exports, "__esModule", { value: true }); exports.TransactionMirroringService = void 0; const ethers_1 = require("ethers"); const dotenv = __importStar(require("dotenv")); +const path_1 = __importDefault(require("path")); dotenv.config(); +// Fill contract addresses from config/smart-contracts-master.json when not set in .env (CJS-safe) +try { + const loaderPath = path_1.default.resolve(__dirname, '../../../../config/contracts-loader.cjs'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { loadContractsIntoProcessEnv } = require(loaderPath); + if (typeof loadContractsIntoProcessEnv === 'function') + loadContractsIntoProcessEnv([138, 1]); +} +catch (_) { /* optional: master JSON not found when run outside repo */ } /** * Transaction Mirroring Service * * Monitors ChainID 138 transactions and mirrors them to TransactionMirror contract on Mainnet */ -// Contract addresses -const MIRROR_ADDRESS = process.env.MIRROR_ADDRESS || '0x4CF42c4F1dBa748601b8938be3E7ABD732E87cE9'; +// Contract addresses (from .env or config/smart-contracts-master.json) +const MIRROR_ADDRESS = process.env.MIRROR_ADDRESS || + process.env.TRANSACTION_MIRROR_MAINNET || + '0x4CF42c4F1dBa748601b8938be3E7ABD732E87cE9'; const CHAIN138_RPC = process.env.CHAIN138_RPC_URL || 'http://192.168.11.211:8545'; const MAINNET_RPC = process.env.MAINNET_RPC_URL || 'https://eth.llamarpc.com'; const BATCH_INTERVAL_MS = parseInt(process.env.BATCH_INTERVAL_MS || '60000', 10); const MAX_BATCH_SIZE = 100; +/** Skip txs below this wei value (default 0 = mirror all). */ +const MIRROR_MIN_VALUE_WEI = BigInt(process.env.MIRROR_MIN_VALUE_WEI || '0'); +/** If set, only mirror txs in blocks >= this height (pilot: set to current block on start). */ +const MIRROR_MIN_BLOCK = process.env.MIRROR_MIN_BLOCK + ? parseInt(process.env.MIRROR_MIN_BLOCK, 10) + : undefined; // Contract ABI (simplified) const MIRROR_ABI = [ "function mirrorTransaction(bytes32 txHash, address from, address to, uint256 value, uint256 blockNumber, uint256 blockTimestamp, uint256 gasUsed, bool success, bytes calldata data) external", @@ -82,8 +103,17 @@ class TransactionMirroringService { console.log(`Admin: ${this.mainnetWallet.address}`); console.log(`Batch interval: ${BATCH_INTERVAL_MS}ms`); console.log(`Max batch size: ${MAX_BATCH_SIZE}`); + if (MIRROR_MIN_BLOCK !== undefined) { + console.log(`Mirror min block: ${MIRROR_MIN_BLOCK}`); + } + if (MIRROR_MIN_VALUE_WEI > 0n) { + console.log(`Mirror min value wei: ${MIRROR_MIN_VALUE_WEI}`); + } // Monitor new blocks this.chain138Provider.on('block', async (blockNumber) => { + if (MIRROR_MIN_BLOCK !== undefined && blockNumber < MIRROR_MIN_BLOCK) { + return; + } try { await this.processBlockTransactions(blockNumber); } @@ -128,6 +158,9 @@ class TransactionMirroringService { if (!tx || !receipt) { return; } + if (MIRROR_MIN_VALUE_WEI > 0n && tx.value < MIRROR_MIN_VALUE_WEI) { + return; + } // Check if already mirrored (optional - can track in database) try { const processed = await this.mirrorContract.processed(txHash); diff --git a/services/transaction-mirroring-service/src/index.ts b/services/transaction-mirroring-service/src/index.ts index cab1eb2..b2507cf 100644 --- a/services/transaction-mirroring-service/src/index.ts +++ b/services/transaction-mirroring-service/src/index.ts @@ -18,11 +18,20 @@ try { */ // Contract addresses (from .env or config/smart-contracts-master.json) -const MIRROR_ADDRESS = process.env.MIRROR_ADDRESS || '0x4CF42c4F1dBa748601b8938be3E7ABD732E87cE9'; +const MIRROR_ADDRESS = + process.env.MIRROR_ADDRESS || + process.env.TRANSACTION_MIRROR_MAINNET || + '0x4CF42c4F1dBa748601b8938be3E7ABD732E87cE9'; const CHAIN138_RPC = process.env.CHAIN138_RPC_URL || 'http://192.168.11.211:8545'; const MAINNET_RPC = process.env.MAINNET_RPC_URL || 'https://eth.llamarpc.com'; const BATCH_INTERVAL_MS = parseInt(process.env.BATCH_INTERVAL_MS || '60000', 10); const MAX_BATCH_SIZE = 100; +/** Skip txs below this wei value (default 0 = mirror all). */ +const MIRROR_MIN_VALUE_WEI = BigInt(process.env.MIRROR_MIN_VALUE_WEI || '0'); +/** If set, only mirror txs in blocks >= this height (pilot: set to current block on start). */ +const MIRROR_MIN_BLOCK = process.env.MIRROR_MIN_BLOCK + ? parseInt(process.env.MIRROR_MIN_BLOCK, 10) + : undefined; // Contract ABI (simplified) const MIRROR_ABI = [ @@ -80,9 +89,18 @@ class TransactionMirroringService { console.log(`Admin: ${this.mainnetWallet.address}`); console.log(`Batch interval: ${BATCH_INTERVAL_MS}ms`); console.log(`Max batch size: ${MAX_BATCH_SIZE}`); + if (MIRROR_MIN_BLOCK !== undefined) { + console.log(`Mirror min block: ${MIRROR_MIN_BLOCK}`); + } + if (MIRROR_MIN_VALUE_WEI > 0n) { + console.log(`Mirror min value wei: ${MIRROR_MIN_VALUE_WEI}`); + } // Monitor new blocks this.chain138Provider.on('block', async (blockNumber) => { + if (MIRROR_MIN_BLOCK !== undefined && blockNumber < MIRROR_MIN_BLOCK) { + return; + } try { await this.processBlockTransactions(blockNumber); } catch (error) { @@ -134,6 +152,10 @@ class TransactionMirroringService { return; } + if (MIRROR_MIN_VALUE_WEI > 0n && tx.value < MIRROR_MIN_VALUE_WEI) { + return; + } + // Check if already mirrored (optional - can track in database) try { const processed = await this.mirrorContract.processed(txHash); diff --git a/test/hybx-omnl/NotaryAndMultisig.t.sol b/test/hybx-omnl/NotaryAndMultisig.t.sol new file mode 100644 index 0000000..dbfdcbf --- /dev/null +++ b/test/hybx-omnl/NotaryAndMultisig.t.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {OMNLJurisdictionPolicyRegistry} from "../../contracts/hybx-omnl/OMNLJurisdictionPolicyRegistry.sol"; +import {OMNLNotaryRegistry} from "../../contracts/hybx-omnl/OMNLNotaryRegistry.sol"; +import {OMNLComplianceMultisig} from "../../contracts/hybx-omnl/OMNLComplianceMultisig.sol"; + +contract NotaryAndMultisigTest is Test { + uint256 internal constant PK1 = 0xA11CE; + uint256 internal constant PK2 = 0xB0B; + + OMNLJurisdictionPolicyRegistry public jurisdiction; + OMNLNotaryRegistry public notary; + OMNLComplianceMultisig public multisig; + + bytes32 internal constant JUR_ID = keccak256("ID"); + bytes32 internal constant ROW_ID = keccak256("ID-OMNL-001"); + bytes32 internal constant CONTENT = keccak256("package-hash"); + + function setUp() public { + jurisdiction = new OMNLJurisdictionPolicyRegistry(address(this)); + notary = new OMNLNotaryRegistry(address(this), address(jurisdiction)); + multisig = new OMNLComplianceMultisig(address(this), address(notary)); + + jurisdiction.publishPolicy(JUR_ID, keccak256("policy-v1"), 2, 2, 2, true); + + address s1 = vm.addr(PK1); + address s2 = vm.addr(PK2); + notary.setNotarySigner(s1, true); + notary.setNotarySigner(s2, true); + notary.setGlobalNotaryThreshold(2); + + multisig.grantRole(multisig.ADMIN_SIGNER_ROLE(), s1); + multisig.grantRole(multisig.ADMIN_SIGNER_ROLE(), s2); + multisig.grantRole(multisig.PROPOSER_ROLE(), address(this)); + multisig.setAdminThreshold(2); + } + + function testNotarizeAttested() public { + bytes[] memory sigs = _notarySigs(0); + vm.prank(address(0xBEEF)); + bytes32 key = notary.notarizeAttested( + JUR_ID, ROW_ID, CONTENT, bytes32(uint256(1)), bytes32(uint256(2)), OMNLNotaryRegistry.NotarizationKind.EvidencePackage, 0, sigs + ); + assertTrue(notary.isNotarized(JUR_ID, ROW_ID, CONTENT)); + OMNLNotaryRegistry.NotarizationRecord memory r = notary.getNotarization(key); + assertEq(r.version, 1); + } + + function testMultisigGatedExecution() public { + bytes[] memory sigs = _notarySigs(0); + notary.notarizeAttested( + JUR_ID, + ROW_ID, + CONTENT, + bytes32(uint256(1)), + bytes32(uint256(2)), + OMNLNotaryRegistry.NotarizationKind.EvidencePackage, + 0, + sigs + ); + + Target t = new Target(); + uint256 executionId = multisig.submitExecution(JUR_ID, address(t), 0, abi.encodeCall(Target.touch, ()), true, CONTENT, ROW_ID); + vm.prank(vm.addr(PK1)); + multisig.confirmExecution(executionId); + multisig.executePending(executionId); + assertTrue(t.touched()); + } + + function _notarySigs(uint256 nonce) internal view returns (bytes[] memory sigs) { + sigs = new bytes[](2); + sigs[0] = _signNotary(PK1, nonce); + sigs[1] = _signNotary(PK2, nonce); + } + + function _signNotary(uint256 pk, uint256 nonce) internal view returns (bytes memory) { + bytes32 digest = keccak256( + abi.encode( + notary.NOTARIZATION_TYPEHASH(), + block.chainid, + address(notary), + JUR_ID, + ROW_ID, + CONTENT, + bytes32(uint256(1)), + bytes32(uint256(2)), + nonce + ) + ); + bytes32 ethSignedMessageHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, ethSignedMessageHash); + return abi.encodePacked(r, s, v); + } +} + +contract Target { + bool public touched; + + function touch() external { + touched = true; + } +} diff --git a/test/hybx-omnl/ReserveNotaryGate.t.sol b/test/hybx-omnl/ReserveNotaryGate.t.sol new file mode 100644 index 0000000..9b6173a --- /dev/null +++ b/test/hybx-omnl/ReserveNotaryGate.t.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ReserveCommitmentStore} from "../../contracts/hybx-omnl/ReserveCommitmentStore.sol"; +import {OMNLJurisdictionPolicyRegistry} from "../../contracts/hybx-omnl/OMNLJurisdictionPolicyRegistry.sol"; +import {OMNLNotaryRegistry} from "../../contracts/hybx-omnl/OMNLNotaryRegistry.sol"; + +contract ReserveNotaryGateTest is Test { + uint256 internal constant PK = 0xA11CE; + + ReserveCommitmentStore public store; + OMNLNotaryRegistry public notary; + bytes32 internal constant LINE = keccak256("LINE"); + bytes32 internal constant JUR = keccak256("ID"); + bytes32 internal constant ROW = keccak256("ID-OMNL-001"); + bytes32 internal constant EV = keccak256("evidence"); + + function setUp() public { + OMNLJurisdictionPolicyRegistry jur = new OMNLJurisdictionPolicyRegistry(address(this)); + notary = new OMNLNotaryRegistry(address(this), address(jur)); + store = new ReserveCommitmentStore(address(this)); + store.configureNotaryGate(address(notary), true, JUR, ROW); + store.grantRole(store.RESERVE_COMMITTER_ROLE(), address(this)); + notary.setNotarySigner(vm.addr(PK), true); + notary.setGlobalNotaryThreshold(1); + } + + function testCommitFailsWithoutNotarization() public { + vm.expectRevert("ReserveCommitmentStore: evidence not notarized"); + store.commitReserve(LINE, 100, block.timestamp + 1 days, EV, bytes32(0)); + } + + function testCommitSucceedsAfterNotarization() public { + bytes[] memory sigs = new bytes[](1); + sigs[0] = _signNotary(0); + notary.notarizeAttested(JUR, ROW, EV, bytes32(0), bytes32(0), OMNLNotaryRegistry.NotarizationKind.EvidencePackage, 0, sigs); + store.commitReserve(LINE, 100, block.timestamp + 1 days, EV, bytes32(0)); + assertEq(store.getCommitment(LINE).R, 100); + } + + function _signNotary(uint256 nonce) internal view returns (bytes memory) { + bytes32 digest = keccak256( + abi.encode( + notary.NOTARIZATION_TYPEHASH(), + block.chainid, + address(notary), + JUR, + ROW, + EV, + bytes32(0), + bytes32(0), + nonce + ) + ); + bytes32 ethSignedMessageHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(PK, ethSignedMessageHash); + return abi.encodePacked(r, s, v); + } +} diff --git a/test/m00-diamond/M00MainnetBridgeFacet.t.sol b/test/m00-diamond/M00MainnetBridgeFacet.t.sol new file mode 100644 index 0000000..ed89ebf --- /dev/null +++ b/test/m00-diamond/M00MainnetBridgeFacet.t.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "../../contracts/m00-diamond/facets/M00MainnetBridgeFacet.sol"; +import "../../contracts/m00-diamond/interfaces/IM00MainnetBridgeFacet.sol"; + +contract M00MainnetBridgeFacetTest is Test { + M00MainnetBridgeFacet public facet; + address admin = address(0xA11CE); + + function setUp() public { + facet = new M00MainnetBridgeFacet(admin); + } + + function test_wireAndReadConfig() public { + IM00MainnetBridgeFacet.BridgeConfig memory cfg = IM00MainnetBridgeFacet.BridgeConfig({ + chain138BatchEmitter: address(0x1001), + chain138Mirror: address(0x1002), + mainnetCheckpoint: address(0x2001), + mainnetMirror: address(0x2002), + mainnetTether: address(0x2003), + rwaTokenFactory: address(0x3001), + rwaTokenRegistry: address(0x3002) + }); + vm.prank(admin); + facet.wireMainnetBridge(cfg); + IM00MainnetBridgeFacet.BridgeConfig memory out = facet.getMainnetBridgeConfig(); + assertEq(out.mainnetMirror, address(0x2002)); + assertEq(out.rwaTokenFactory, address(0x3001)); + } +} diff --git a/test/mainnet-checkpoint/AddressActivityRegistry.t.sol b/test/mainnet-checkpoint/AddressActivityRegistry.t.sol new file mode 100644 index 0000000..afd1262 --- /dev/null +++ b/test/mainnet-checkpoint/AddressActivityRegistry.t.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {AddressActivityRegistry} from "../../contracts/mainnet-checkpoint/AddressActivityRegistry.sol"; + +contract AddressActivityRegistryTest is Test { + AddressActivityRegistry registry; + address admin = address(0xA11CE); + address from = address(0x1111); + address to = address(0x2222); + + event ParticipantDebited( + bytes32 indexed chain138TxHash, + address indexed counterparty, + address indexed participant, + uint64 batchId, + uint256 valueWei, + uint64 valueUsdE8, + uint32 logCount, + bytes32 receiptHash, + uint256 blockNumber138 + ); + + event ParticipantCredited( + bytes32 indexed chain138TxHash, + address indexed participant, + address indexed counterparty, + uint64 batchId, + uint256 valueWei, + uint64 valueUsdE8, + uint32 logCount, + bytes32 receiptHash, + uint256 blockNumber138 + ); + + function setUp() public { + registry = new AddressActivityRegistry(admin); + } + + function testRecordBatchEmitsAndStores() public { + vm.startPrank(admin); + AddressActivityRegistry.ActivityRecord[] memory rows = + new AddressActivityRegistry.ActivityRecord[](1); + rows[0] = AddressActivityRegistry.ActivityRecord({ + txHash: keccak256("tx1"), + from: from, + to: to, + valueWei: 7500 ether, + blockNumber138: 1_943_065, + blockTimestamp138: 1_739_000_000, + valueUsdE8: 15_000_000_000_000, // 150k USD at 8 decimals + logCount: 0, + receiptHash: keccak256("receipt") + }); + + vm.expectEmit(true, true, true, true, address(registry)); + emit ParticipantDebited( + rows[0].txHash, + to, + from, + 42, + 7500 ether, + 15_000_000_000_000, + 0, + rows[0].receiptHash, + 1_943_065 + ); + vm.expectEmit(true, true, true, true, address(registry)); + emit ParticipantCredited( + rows[0].txHash, + to, + from, + 42, + 7500 ether, + 15_000_000_000_000, + 0, + rows[0].receiptHash, + 1_943_065 + ); + + registry.recordBatch(42, rows); + vm.stopPrank(); + + assertTrue(registry.recorded(rows[0].txHash)); + assertEq(registry.getParticipantTxCount(from), 1); + assertEq(registry.getParticipantTxCount(to), 1); + assertEq(registry.totalRecorded(), 1); + } + + function testReplayBlocked() public { + vm.startPrank(admin); + AddressActivityRegistry.ActivityRecord[] memory rows = + new AddressActivityRegistry.ActivityRecord[](1); + rows[0] = AddressActivityRegistry.ActivityRecord({ + txHash: keccak256("dup"), + from: from, + to: to, + valueWei: 1, + blockNumber138: 1, + blockTimestamp138: 1, + valueUsdE8: 0, + logCount: 0, + receiptHash: bytes32(0) + }); + registry.recordBatch(1, rows); + vm.expectRevert("already recorded"); + registry.recordBatch(2, rows); + vm.stopPrank(); + } +} diff --git a/test/mainnet-checkpoint/AddressActivityRegistryV2.t.sol b/test/mainnet-checkpoint/AddressActivityRegistryV2.t.sol new file mode 100644 index 0000000..84a4143 --- /dev/null +++ b/test/mainnet-checkpoint/AddressActivityRegistryV2.t.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {AddressActivityRegistryV2} from "../../contracts/mainnet-checkpoint/AddressActivityRegistryV2.sol"; + +contract AddressActivityRegistryV2Test is Test { + AddressActivityRegistryV2 registry; + address admin = address(0xA11CE); + + event PaymentAttested( + bytes32 indexed chain138TxHash, + bytes32 indexed instructionId, + address indexed creditor, + address debtor, + bytes32 uetr, + uint64 batchId, + uint8 msgTypeCode, + uint256 valueWei, + uint64 valueUsdE8, + bytes32 payloadHash, + bytes32 endToEndIdHash, + bytes32 debtorRefHash, + bytes32 creditorRefHash, + bytes32 purposeHash, + bytes32 receiptHash, + uint256 blockNumber138 + ); + + function setUp() public { + registry = new AddressActivityRegistryV2(admin); + } + + function testRecordBatchEmitsPaymentAttested() public { + AddressActivityRegistryV2.IsoAttestationRecord[] memory rows = + new AddressActivityRegistryV2.IsoAttestationRecord[](1); + rows[0] = AddressActivityRegistryV2.IsoAttestationRecord({ + txHash: keccak256("tx1"), + from: address(0x1), + to: address(0x2), + valueWei: 1 ether, + blockNumber138: 1943065, + blockTimestamp138: uint64(block.timestamp), + valueUsdE8: 1_500_000_000_000_000, + logCount: 1, + receiptHash: keccak256("rcpt"), + instructionId: keccak256("instr1"), + endToEndIdHash: keccak256("e2e"), + uetr: keccak256("uetr"), + payloadHash: keccak256("payload"), + msgTypeCode: registry.MSG_CHAIN138_SYNTH(), + debtorRefHash: keccak256("d"), + creditorRefHash: keccak256("c"), + purposeHash: keccak256("p") + }); + + vm.prank(admin); + vm.expectEmit(true, true, true, false); + emit PaymentAttested( + rows[0].txHash, + rows[0].instructionId, + rows[0].to, + rows[0].from, + rows[0].uetr, + 1, + rows[0].msgTypeCode, + rows[0].valueWei, + rows[0].valueUsdE8, + rows[0].payloadHash, + rows[0].endToEndIdHash, + rows[0].debtorRefHash, + rows[0].creditorRefHash, + rows[0].purposeHash, + rows[0].receiptHash, + rows[0].blockNumber138 + ); + registry.recordBatch(1, rows); + assertTrue(registry.recorded(rows[0].txHash)); + } +} diff --git a/test/mainnet-checkpoint/BlockHeaderOracle.t.sol b/test/mainnet-checkpoint/BlockHeaderOracle.t.sol new file mode 100644 index 0000000..cf9495f --- /dev/null +++ b/test/mainnet-checkpoint/BlockHeaderOracle.t.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; +import {BlockHeaderOracleExtension} from "../../contracts/mainnet-checkpoint/extensions/BlockHeaderOracleExtension.sol"; +import {ExtensionIds} from "../../contracts/mainnet-checkpoint/libraries/ExtensionIds.sol"; +import {CheckpointStorage} from "../../contracts/mainnet-checkpoint/storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "../../contracts/mainnet-checkpoint/libraries/CheckpointLeaf.sol"; +import {CheckpointFlags} from "../../contracts/mainnet-checkpoint/libraries/CheckpointFlags.sol"; +import {CheckpointHubConfig} from "../../contracts/mainnet-checkpoint/libraries/CheckpointHubConfig.sol"; + +contract BlockHeaderOracleTest is Test { + Chain138MainnetCheckpoint hub; + BlockHeaderOracleExtension oracle; + address admin = address(0xA11CE); + address submitter = address(0xB0B); + + function setUp() public { + Chain138MainnetCheckpoint impl = new Chain138MainnetCheckpoint(); + bytes memory initData = abi.encodeCall( + Chain138MainnetCheckpoint.initialize, + (admin, address(1), uint64(1), address(0)) + ); + hub = Chain138MainnetCheckpoint(address(new ERC1967Proxy(address(impl), initData))); + oracle = new BlockHeaderOracleExtension(admin); + vm.startPrank(admin); + hub.grantRole(hub.SUBMITTER_ROLE(), submitter); + CheckpointHubConfig.HubConfig memory cfg = CheckpointHubConfig.mainnetDefaults(); + cfg.enforcePreviousBatchId = false; + cfg.requireValidatorSigs = false; + cfg.allowCCIPIngress = false; + hub.applyConfig(cfg); + hub.registerExtension( + ExtensionIds.BLOCK_ORACLE, + address(oracle), + oracle.HOOK_BEFORE_SUBMIT() + ); + oracle.setRequireOracleRecord(true); + vm.stopPrank(); + } + + function testRevertsWithoutOracleRecord() public { + bytes32 bh = keccak256("bh"); + bytes32 sr = keccak256("sr"); + bytes32 root = _root(); + CheckpointStorage.CheckpointHeader memory header = _header(1, bh, sr, root); + vm.prank(submitter); + vm.expectRevert("oracle missing"); + hub.submitCheckpoint(header, hex"01", new bytes32[](0), ""); + } + + function testSubmitAfterOracleSet() public { + bytes32 bh = keccak256("bh"); + bytes32 sr = keccak256("sr"); + vm.prank(admin); + oracle.setBlockHeader(100, bh, sr); + bytes32 root = _root(); + CheckpointStorage.CheckpointHeader memory header = _header(1, bh, sr, root); + vm.prank(submitter); + hub.submitCheckpoint(header, hex"01", new bytes32[](0), ""); + assertEq(hub.getLatestBatchId(), 1); + } + + function _root() internal pure returns (bytes32) { + CheckpointLeaf.PaymentLeafV1 memory leaf = CheckpointLeaf.PaymentLeafV1({ + txHash: keccak256("tx"), + from: address(1), + to: address(2), + value: 1, + blockNumber: 100, + blockTimestamp: 1, + gasUsed: 1, + success: true + }); + return CheckpointLeaf.paymentLeafV1(138, leaf); + } + + function _header(uint64 batchId, bytes32 bh, bytes32 sr, bytes32 paymentsRoot) + internal + pure + returns (CheckpointStorage.CheckpointHeader memory) + { + return CheckpointStorage.CheckpointHeader({ + batchId: batchId, + previousBatchId: 0, + chainId: 138, + checkpointBlock: 100, + startBlock: 99, + endBlock: 100, + blockHash: bh, + stateRoot: sr, + paymentsRoot: paymentsRoot, + receiptsRoot: bytes32(0), + txCount: 1, + flags: CheckpointFlags.PARTIAL_BATCH, + submittedAt: 0, + submitter: address(0), + contentURI: bytes32(0) + }); + } +} diff --git a/test/mainnet-checkpoint/CcipReceive.t.sol b/test/mainnet-checkpoint/CcipReceive.t.sol new file mode 100644 index 0000000..70084d9 --- /dev/null +++ b/test/mainnet-checkpoint/CcipReceive.t.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; +import {CheckpointStorage} from "../../contracts/mainnet-checkpoint/storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "../../contracts/mainnet-checkpoint/libraries/CheckpointLeaf.sol"; +import {CheckpointFlags} from "../../contracts/mainnet-checkpoint/libraries/CheckpointFlags.sol"; +import {IRouterClient} from "../../contracts/ccip/IRouterClient.sol"; +import {CheckpointHubConfig} from "../../contracts/mainnet-checkpoint/libraries/CheckpointHubConfig.sol"; + +contract MockCcipRouter { + function deliver(address receiver, IRouterClient.Any2EVMMessage calldata message) external { + Chain138MainnetCheckpoint(receiver).ccipReceive(message); + } +} + +contract CcipReceiveTest is Test { + Chain138MainnetCheckpoint hub; + MockCcipRouter router; + address emitter = address(0xE01); + address admin = address(0xA11CE); + + function setUp() public { + router = new MockCcipRouter(); + Chain138MainnetCheckpoint impl = new Chain138MainnetCheckpoint(); + bytes memory initData = abi.encodeCall( + Chain138MainnetCheckpoint.initialize, + (admin, address(router), uint64(138_0001), emitter) + ); + hub = Chain138MainnetCheckpoint(address(new ERC1967Proxy(address(impl), initData))); + vm.startPrank(admin); + CheckpointHubConfig.HubConfig memory cfg = CheckpointHubConfig.mainnetDefaults(); + cfg.enforcePreviousBatchId = false; + cfg.requireValidatorSigs = false; + cfg.allowCCIPIngress = true; + cfg.ccipRouter = address(router); + cfg.batchEmitterOnSource = emitter; + hub.applyConfig(cfg); + vm.stopPrank(); + } + + function testCcipReceiveSubmitsBatch() public { + CheckpointLeaf.PaymentLeafV1 memory leaf = CheckpointLeaf.PaymentLeafV1({ + txHash: keccak256("ccip-tx"), + from: address(1), + to: address(2), + value: 1 ether, + blockNumber: 50, + blockTimestamp: 1, + gasUsed: 21000, + success: true + }); + bytes32 root = CheckpointLeaf.paymentLeafV1(138, leaf); + CheckpointStorage.CheckpointHeader memory header = CheckpointStorage.CheckpointHeader({ + batchId: 1, + previousBatchId: 0, + chainId: 138, + checkpointBlock: 50, + startBlock: 50, + endBlock: 50, + blockHash: keccak256("bh"), + stateRoot: keccak256("sr"), + paymentsRoot: root, + receiptsRoot: bytes32(0), + txCount: 1, + flags: CheckpointFlags.PARTIAL_BATCH, + submittedAt: 0, + submitter: address(0), + contentURI: bytes32(0) + }); + bytes32[] memory hashes = new bytes32[](1); + hashes[0] = leaf.txHash; + bytes memory payload = abi.encode(header, hex"01", hashes, bytes32(0), abi.encode(leaf)); + + IRouterClient.Any2EVMMessage memory message = IRouterClient.Any2EVMMessage({ + messageId: keccak256("mid"), + sourceChainSelector: 138_0001, + sender: abi.encode(emitter), + data: payload, + tokenAmounts: new IRouterClient.TokenAmount[](0) + }); + + router.deliver(address(hub), message); + assertEq(hub.getLatestBatchId(), 1); + } +} diff --git a/test/mainnet-checkpoint/Chain138MainnetCheckpoint.t.sol b/test/mainnet-checkpoint/Chain138MainnetCheckpoint.t.sol new file mode 100644 index 0000000..c823fca --- /dev/null +++ b/test/mainnet-checkpoint/Chain138MainnetCheckpoint.t.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; +import {CheckpointStorage} from "../../contracts/mainnet-checkpoint/storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "../../contracts/mainnet-checkpoint/libraries/CheckpointLeaf.sol"; +import {CheckpointFlags} from "../../contracts/mainnet-checkpoint/libraries/CheckpointFlags.sol"; +import {CheckpointErrors} from "../../contracts/mainnet-checkpoint/libraries/CheckpointErrors.sol"; + +contract Chain138MainnetCheckpointTest is Test { + Chain138MainnetCheckpoint checkpoint; + address admin = address(0xA11CE); + address submitter = address(0xB0B); + + function setUp() public { + Chain138MainnetCheckpoint impl = new Chain138MainnetCheckpoint(); + bytes memory initData = abi.encodeCall( + Chain138MainnetCheckpoint.initialize, + (admin, address(0xC0C0C0C0), uint64(1380001), address(0)) + ); + checkpoint = Chain138MainnetCheckpoint(address(new ERC1967Proxy(address(impl), initData))); + vm.startPrank(admin); + checkpoint.grantRole(checkpoint.SUBMITTER_ROLE(), submitter); + vm.stopPrank(); + } + + function _leaf(bytes32 txHash) internal pure returns (CheckpointLeaf.PaymentLeafV1 memory) { + return CheckpointLeaf.PaymentLeafV1({ + txHash: txHash, + from: address(1), + to: address(2), + value: 1 ether, + blockNumber: 100, + blockTimestamp: 1, + gasUsed: 21000, + success: true + }); + } + + function _buildRoot(CheckpointLeaf.PaymentLeafV1[] memory leaves) internal pure returns (bytes32) { + require(leaves.length == 1, "single leaf test"); + return CheckpointLeaf.paymentLeafV1(138, leaves[0]); + } + + function testSubmitCheckpointUpdatesLatest() public { + CheckpointLeaf.PaymentLeafV1[] memory leaves = new CheckpointLeaf.PaymentLeafV1[](1); + leaves[0] = _leaf(keccak256("tx1")); + bytes32 root = _buildRoot(leaves); + + CheckpointStorage.CheckpointHeader memory header = CheckpointStorage.CheckpointHeader({ + batchId: 1, + previousBatchId: 0, + chainId: 138, + checkpointBlock: 100, + startBlock: 99, + endBlock: 100, + blockHash: keccak256("bh"), + stateRoot: keccak256("sr"), + paymentsRoot: root, + receiptsRoot: bytes32(0), + txCount: 1, + flags: CheckpointFlags.PARTIAL_BATCH, + submittedAt: 0, + submitter: address(0), + contentURI: bytes32(0) + }); + + bytes32[] memory hashes = new bytes32[](1); + hashes[0] = leaves[0].txHash; + + vm.prank(submitter); + checkpoint.submitCheckpoint(header, hex"01", hashes, abi.encode(leaves)); + + assertEq(checkpoint.getLatestBatchId(), 1); + assertEq(checkpoint.getLatestCheckpoint().paymentsRoot, root); + (bool inc,) = checkpoint.isTxIncluded(leaves[0].txHash); + assertTrue(inc); + } + + function testPauseBlocksSubmit() public { + vm.prank(admin); + checkpoint.pause(); + CheckpointStorage.CheckpointHeader memory header; + vm.prank(submitter); + vm.expectRevert(CheckpointErrors.Paused.selector); + checkpoint.submitCheckpoint(header, hex"01", new bytes32[](0), ""); + } +} diff --git a/test/mainnet-checkpoint/Chain138ParticipantSurface.t.sol b/test/mainnet-checkpoint/Chain138ParticipantSurface.t.sol new file mode 100644 index 0000000..0eee4c9 --- /dev/null +++ b/test/mainnet-checkpoint/Chain138ParticipantSurface.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {Chain138ParticipantSurface} from "../../contracts/mainnet-checkpoint/Chain138ParticipantSurface.sol"; + +contract Chain138ParticipantSurfaceTest is Test { + Chain138ParticipantSurface surface; + address admin = address(0xA11CE); + + function setUp() public { + surface = new Chain138ParticipantSurface(admin); + } + + function testNotifyBatch() public { + Chain138ParticipantSurface.Notification[] memory items = + new Chain138ParticipantSurface.Notification[](1); + items[0] = Chain138ParticipantSurface.Notification({ + participant: address(0xBEEF), + chain138TxHash: keccak256("tx"), + instructionId: keccak256("instr"), + direction: 0, + batchId: 1, + valueWei: 1 ether, + valueUsdE8: 100 + }); + vm.prank(admin); + surface.notifyBatch(1, items); + assertTrue( + surface.notified(surface.notificationKey(address(0xBEEF), keccak256("tx"), 0)) + ); + } +} diff --git a/test/mainnet-checkpoint/CheckpointHubConfig.t.sol b/test/mainnet-checkpoint/CheckpointHubConfig.t.sol new file mode 100644 index 0000000..e8837f9 --- /dev/null +++ b/test/mainnet-checkpoint/CheckpointHubConfig.t.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; +import {CheckpointHubConfig} from "../../contracts/mainnet-checkpoint/libraries/CheckpointHubConfig.sol"; +import {ExtensionIds} from "../../contracts/mainnet-checkpoint/libraries/ExtensionIds.sol"; +import {MetricsExtension} from "../../contracts/mainnet-checkpoint/extensions/MetricsExtension.sol"; + +contract CheckpointHubConfigTest is Test { + Chain138MainnetCheckpoint hub; + address admin = address(0xA11CE); + + function setUp() public { + Chain138MainnetCheckpoint impl = new Chain138MainnetCheckpoint(); + bytes memory initData = abi.encodeCall( + Chain138MainnetCheckpoint.initialize, + (admin, address(0x1001), uint64(999), address(0)) + ); + hub = Chain138MainnetCheckpoint(address(new ERC1967Proxy(address(impl), initData))); + } + + function testApplyConfigUpdatesBatchSize() public { + CheckpointHubConfig.HubConfig memory cfg = CheckpointHubConfig.mainnetDefaults(); + cfg.batchSize = 25; + cfg.maxBatchWaitSeconds = 600; + cfg.ccipRouter = address(0x1001); + + vm.prank(admin); + hub.applyConfig(cfg); + + (uint16 batchSize, uint32 maxWait,,,,) = hub.getConfig(); + assertEq(batchSize, 25); + assertEq(maxWait, 600); + } + + function testSetExtensionActiveToggle() public { + MetricsExtension metrics = new MetricsExtension(); + vm.startPrank(admin); + hub.registerExtension( + ExtensionIds.METRICS, + address(metrics), + metrics.HOOK_AFTER_SUBMIT() + ); + hub.setExtensionActive(ExtensionIds.METRICS, false); + vm.stopPrank(); + (, , bool active) = hub.getExtension(ExtensionIds.METRICS); + assertFalse(active); + } + + function testEnforcePreviousBatchIdCanDisable() public { + CheckpointHubConfig.HubConfig memory cfg = CheckpointHubConfig.mainnetDefaults(); + cfg.enforcePreviousBatchId = false; + vm.prank(admin); + hub.applyConfig(cfg); + CheckpointHubConfig.HubConfig memory full = hub.getFullConfig(); + assertFalse(full.enforcePreviousBatchId); + } +} diff --git a/test/mainnet-checkpoint/CheckpointLeaf.t.sol b/test/mainnet-checkpoint/CheckpointLeaf.t.sol new file mode 100644 index 0000000..4d5b43a --- /dev/null +++ b/test/mainnet-checkpoint/CheckpointLeaf.t.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {CheckpointLeaf} from "../../contracts/mainnet-checkpoint/libraries/CheckpointLeaf.sol"; + +contract CheckpointLeafTest is Test { + function testBuildMerkleRoot_twoLeaves() public pure { + CheckpointLeaf.PaymentLeafV1 memory a = _leaf(keccak256("a")); + CheckpointLeaf.PaymentLeafV1 memory b = _leaf(keccak256("b")); + bytes32[] memory hashes = new bytes32[](2); + hashes[0] = CheckpointLeaf.paymentLeafV1(138, a); + hashes[1] = CheckpointLeaf.paymentLeafV1(138, b); + bytes32 root = CheckpointLeaf.buildMerkleRoot(hashes); + assertTrue(root != bytes32(0)); + } + + function testVerifyMerkle_singleLeaf() public pure { + CheckpointLeaf.PaymentLeafV1 memory a = _leaf(keccak256("solo")); + bytes32 leaf = CheckpointLeaf.paymentLeafV1(138, a); + bytes32[] memory proof = new bytes32[](0); + assertTrue(CheckpointLeaf.verifyMerkle(leaf, leaf, proof)); + } + + function _leaf(bytes32 txHash) private pure returns (CheckpointLeaf.PaymentLeafV1 memory) { + return CheckpointLeaf.PaymentLeafV1({ + txHash: txHash, + from: address(1), + to: address(2), + value: 1, + blockNumber: 1, + blockTimestamp: 1, + gasUsed: 21000, + success: true + }); + } +} diff --git a/test/mainnet-checkpoint/ExtensionHookWiring.t.sol b/test/mainnet-checkpoint/ExtensionHookWiring.t.sol new file mode 100644 index 0000000..7679dfb --- /dev/null +++ b/test/mainnet-checkpoint/ExtensionHookWiring.t.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; +import {ValidatorSigVerifierExtension} from "../../contracts/mainnet-checkpoint/extensions/ValidatorSigVerifierExtension.sol"; +import {TokenTransferFilterExtension} from "../../contracts/mainnet-checkpoint/extensions/TokenTransferFilterExtension.sol"; +import {ExtensionIds} from "../../contracts/mainnet-checkpoint/libraries/ExtensionIds.sol"; +import {CheckpointStorage} from "../../contracts/mainnet-checkpoint/storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "../../contracts/mainnet-checkpoint/libraries/CheckpointLeaf.sol"; +import {CheckpointFlags} from "../../contracts/mainnet-checkpoint/libraries/CheckpointFlags.sol"; +import {CheckpointHubConfig} from "../../contracts/mainnet-checkpoint/libraries/CheckpointHubConfig.sol"; +import {CheckpointEIP712} from "../../contracts/mainnet-checkpoint/libraries/CheckpointEIP712.sol"; +import {ICheckpointExtension} from "../../contracts/mainnet-checkpoint/interfaces/ICheckpointExtension.sol"; + +/// @notice Hub v3 passes validatorSignatures to VALIDATOR_SIG and leaf bytes to TOKEN_FILTER. +contract ExtensionHookWiringTest is Test { + Chain138MainnetCheckpoint hub; + ValidatorSigVerifierExtension validator; + uint256 validatorPk = 0xA11CE; + address validatorAddr; + address submitter = address(0xB0B); + + function setUp() public { + validatorAddr = vm.addr(validatorPk); + Chain138MainnetCheckpoint impl = new Chain138MainnetCheckpoint(); + bytes memory initData = abi.encodeCall( + Chain138MainnetCheckpoint.initialize, + (address(this), address(1), uint64(1), address(0)) + ); + hub = Chain138MainnetCheckpoint(address(new ERC1967Proxy(address(impl), initData))); + + validator = new ValidatorSigVerifierExtension(); + TokenTransferFilterExtension tokenFilter = new TokenTransferFilterExtension(); + tokenFilter.setAllowNative(true, 0); + + validator.setVerifyingContract(address(hub)); + address[] memory addrs = new address[](1); + addrs[0] = validatorAddr; + validator.setValidators(addrs, 1); + + _register(ExtensionIds.VALIDATOR_SIG, address(validator)); + _register(ExtensionIds.TOKEN_FILTER, address(tokenFilter)); + + CheckpointHubConfig.HubConfig memory cfg = CheckpointHubConfig.mainnetDefaults(); + cfg.enforcePreviousBatchId = false; + cfg.requireValidatorSigs = true; + hub.applyConfig(cfg); + hub.grantRole(hub.SUBMITTER_ROLE(), submitter); + } + + function testSubmitWithLeavesAndValidatorSig() public { + CheckpointLeaf.PaymentLeafV1[] memory leaves = new CheckpointLeaf.PaymentLeafV1[](1); + leaves[0] = CheckpointLeaf.PaymentLeafV1({ + txHash: keccak256("tx"), + from: address(3), + to: address(4), + value: 1, + blockNumber: 10, + blockTimestamp: 1, + gasUsed: 1, + success: true + }); + bytes32[] memory hashes = new bytes32[](1); + hashes[0] = CheckpointLeaf.paymentLeafV1(138, leaves[0]); + bytes32 root = CheckpointLeaf.buildMerkleRoot(hashes); + + CheckpointStorage.CheckpointHeader memory header = CheckpointStorage.CheckpointHeader({ + batchId: 1, + previousBatchId: 0, + chainId: 138, + checkpointBlock: 10, + startBlock: 10, + endBlock: 10, + blockHash: keccak256("bh"), + stateRoot: keccak256("sr"), + paymentsRoot: root, + receiptsRoot: bytes32(0), + txCount: 1, + flags: CheckpointFlags.PARTIAL_BATCH, + submittedAt: 0, + submitter: address(0), + contentURI: bytes32(0) + }); + + bytes32 digest = _validatorDigest(address(hub), block.chainid, header); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(validatorPk, digest); + bytes memory sig = abi.encodePacked(r, s, v); + + bytes32[] memory txHashes = new bytes32[](1); + txHashes[0] = leaves[0].txHash; + + vm.prank(submitter); + hub.submitCheckpointWithLeaves(header, sig, txHashes, leaves); + assertEq(hub.getLatestBatchId(), 1); + } + + function _validatorDigest( + address verifyingContract, + uint256 chainId, + CheckpointStorage.CheckpointHeader memory header + ) internal pure returns (bytes32) { + bytes32 structHash = keccak256( + abi.encode( + CheckpointEIP712.BATCH_ATTESTATION_TYPEHASH, + header.chainId, + header.batchId, + header.checkpointBlock, + header.blockHash, + header.stateRoot, + header.paymentsRoot, + header.previousBatchId + ) + ); + bytes32 domain = keccak256( + abi.encode( + CheckpointEIP712.DOMAIN_TYPEHASH, + keccak256("Chain138MainnetCheckpoint"), + keccak256("2"), + chainId, + verifyingContract + ) + ); + return keccak256(abi.encodePacked("\x19\x01", domain, structHash)); + } + + function _register(bytes32 id, address module) internal { + uint32 hooks = ICheckpointExtension(module).HOOK_BEFORE_SUBMIT() + | ICheckpointExtension(module).HOOK_AFTER_SUBMIT() + | ICheckpointExtension(module).HOOK_ON_CCIP() + | ICheckpointExtension(module).HOOK_VERIFY_LEAF(); + hub.registerExtension(id, module, hooks); + } +} diff --git a/test/mainnet-checkpoint/ExtensionRegistry.t.sol b/test/mainnet-checkpoint/ExtensionRegistry.t.sol new file mode 100644 index 0000000..aed0ddc --- /dev/null +++ b/test/mainnet-checkpoint/ExtensionRegistry.t.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; +import {MetricsExtension} from "../../contracts/mainnet-checkpoint/extensions/MetricsExtension.sol"; +import {ExtensionIds} from "../../contracts/mainnet-checkpoint/libraries/ExtensionIds.sol"; + +contract ExtensionRegistryTest is Test { + Chain138MainnetCheckpoint hub; + MetricsExtension metrics; + address admin = address(0xA11CE); + + function setUp() public { + Chain138MainnetCheckpoint impl = new Chain138MainnetCheckpoint(); + bytes memory initData = abi.encodeCall( + Chain138MainnetCheckpoint.initialize, + (admin, address(0), uint64(1), address(0)) + ); + hub = Chain138MainnetCheckpoint(address(new ERC1967Proxy(address(impl), initData))); + metrics = new MetricsExtension(); + vm.startPrank(admin); + hub.registerExtension( + ExtensionIds.METRICS, + address(metrics), + metrics.HOOK_BEFORE_SUBMIT() | metrics.HOOK_AFTER_SUBMIT() + ); + vm.stopPrank(); + } + + function testExtensionRegistered() public view { + (address module, , bool active) = hub.getExtension(ExtensionIds.METRICS); + assertEq(module, address(metrics)); + assertTrue(active); + assertEq(hub.extensionCount(), 1); + } +} diff --git a/test/mainnet-checkpoint/MinPaymentValue.t.sol b/test/mainnet-checkpoint/MinPaymentValue.t.sol new file mode 100644 index 0000000..b779a69 --- /dev/null +++ b/test/mainnet-checkpoint/MinPaymentValue.t.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; +import {CheckpointStorage} from "../../contracts/mainnet-checkpoint/storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "../../contracts/mainnet-checkpoint/libraries/CheckpointLeaf.sol"; +import {CheckpointFlags} from "../../contracts/mainnet-checkpoint/libraries/CheckpointFlags.sol"; +import {CheckpointHubConfig} from "../../contracts/mainnet-checkpoint/libraries/CheckpointHubConfig.sol"; +import {CheckpointErrors} from "../../contracts/mainnet-checkpoint/libraries/CheckpointErrors.sol"; + +contract MinPaymentValueTest is Test { + Chain138MainnetCheckpoint hub; + address admin = address(0xA11CE); + address submitter = address(0xB0B); + + function setUp() public { + Chain138MainnetCheckpoint impl = new Chain138MainnetCheckpoint(); + hub = Chain138MainnetCheckpoint( + address(new ERC1967Proxy(address(impl), abi.encodeCall(Chain138MainnetCheckpoint.initialize, (admin, address(1), uint64(1), address(0))))) + ); + vm.startPrank(admin); + hub.grantRole(hub.SUBMITTER_ROLE(), submitter); + CheckpointHubConfig.HubConfig memory cfg = CheckpointHubConfig.mainnetDefaults(); + cfg.enforcePreviousBatchId = false; + cfg.requireValidatorSigs = false; + cfg.minPaymentValueWei = 1 ether; + hub.applyConfig(cfg); + vm.stopPrank(); + } + + function testRevertsBelowMinInExtensionData() public { + CheckpointLeaf.PaymentLeafV1[] memory leaves = new CheckpointLeaf.PaymentLeafV1[](1); + leaves[0] = CheckpointLeaf.PaymentLeafV1({ + txHash: keccak256("low"), + from: address(1), + to: address(2), + value: 1, + blockNumber: 1, + blockTimestamp: 1, + gasUsed: 1, + success: true + }); + bytes32 root = CheckpointLeaf.paymentLeafV1(138, leaves[0]); + CheckpointStorage.CheckpointHeader memory header = CheckpointStorage.CheckpointHeader({ + batchId: 1, + previousBatchId: 0, + chainId: 138, + checkpointBlock: 1, + startBlock: 1, + endBlock: 1, + blockHash: keccak256("h"), + stateRoot: keccak256("s"), + paymentsRoot: root, + receiptsRoot: bytes32(0), + txCount: 1, + flags: CheckpointFlags.PARTIAL_BATCH, + submittedAt: 0, + submitter: address(0), + contentURI: bytes32(0) + }); + vm.prank(submitter); + vm.expectRevert(CheckpointErrors.BelowMinPayment.selector); + hub.submitCheckpointCommitment(header, hex"01", bytes32(0), abi.encode(leaves)); + } +} diff --git a/test/mainnet-checkpoint/MirrorDetailV2Decode.t.sol b/test/mainnet-checkpoint/MirrorDetailV2Decode.t.sol new file mode 100644 index 0000000..f2a9ae5 --- /dev/null +++ b/test/mainnet-checkpoint/MirrorDetailV2Decode.t.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {MirrorDetailExtension} from "../../contracts/mainnet-checkpoint/extensions/MirrorDetailExtension.sol"; +import {CheckpointStorage} from "../../contracts/mainnet-checkpoint/storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "../../contracts/mainnet-checkpoint/libraries/CheckpointLeaf.sol"; + +contract MirrorDetailV2DecodeTest is Test { + MirrorDetailExtension mirror; + + function setUp() public { + mirror = new MirrorDetailExtension(); + } + + function testAfterSubmitStoresV2TokenValue() public { + CheckpointLeaf.PaymentLeafV2[] memory leaves = new CheckpointLeaf.PaymentLeafV2[](1); + leaves[0] = CheckpointLeaf.PaymentLeafV2({ + txHash: keccak256("t"), + from: address(1), + to: address(2), + token: address(0xAA), + value: 5_000_000, + blockNumber: 1, + blockTimestamp: 2, + gasUsed: 3, + success: true, + logIndex: 0 + }); + bytes memory data = abi.encode(bytes1(0x02), leaves); + CheckpointStorage.CheckpointHeader memory header = CheckpointStorage.CheckpointHeader({ + batchId: 1, + previousBatchId: 0, + chainId: 138, + checkpointBlock: 1, + startBlock: 1, + endBlock: 1, + blockHash: bytes32(0), + stateRoot: bytes32(0), + paymentsRoot: bytes32(0), + receiptsRoot: bytes32(0), + txCount: 1, + flags: 0, + submittedAt: 0, + submitter: address(0), + contentURI: bytes32(0) + }); + mirror.afterSubmit(header, data); + (, , , uint256 value, , , , ) = mirror.transactions(leaves[0].txHash); + assertEq(value, 5_000_000); + assertEq(mirror.txToken(leaves[0].txHash), address(0xAA)); + } +} diff --git a/test/mainnet-checkpoint/PaymentLeafV2Submit.t.sol b/test/mainnet-checkpoint/PaymentLeafV2Submit.t.sol new file mode 100644 index 0000000..9a98c14 --- /dev/null +++ b/test/mainnet-checkpoint/PaymentLeafV2Submit.t.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {CheckpointLeaf} from "../../contracts/mainnet-checkpoint/libraries/CheckpointLeaf.sol"; +import {CheckpointPaymentsLib} from "../../contracts/mainnet-checkpoint/libraries/CheckpointPaymentsLib.sol"; + +contract PaymentLeafV2SubmitTest is Test { + function test_assertPaymentsRootV2() public { + CheckpointLeaf.PaymentLeafV2[] memory leaves = new CheckpointLeaf.PaymentLeafV2[](1); + leaves[0] = CheckpointLeaf.PaymentLeafV2({ + txHash: keccak256("tx"), + from: address(0x1), + to: address(0x2), + token: address(0x3), + value: 100, + blockNumber: 1, + blockTimestamp: 2, + gasUsed: 3, + success: true, + logIndex: 0 + }); + bytes32[] memory hashes = new bytes32[](1); + hashes[0] = CheckpointLeaf.paymentLeafV2(138, leaves[0]); + bytes32 root = CheckpointLeaf.buildMerkleRoot(hashes); + CheckpointPaymentsLib.assertPaymentsRootV2(138, root, leaves); + } +} diff --git a/test/mainnet-checkpoint/SubmitWithLeaves.t.sol b/test/mainnet-checkpoint/SubmitWithLeaves.t.sol new file mode 100644 index 0000000..e6cb3d9 --- /dev/null +++ b/test/mainnet-checkpoint/SubmitWithLeaves.t.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Chain138MainnetCheckpoint} from "../../contracts/mainnet-checkpoint/Chain138MainnetCheckpoint.sol"; +import {CheckpointStorage} from "../../contracts/mainnet-checkpoint/storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "../../contracts/mainnet-checkpoint/libraries/CheckpointLeaf.sol"; +import {CheckpointFlags} from "../../contracts/mainnet-checkpoint/libraries/CheckpointFlags.sol"; +import {CheckpointHubConfig} from "../../contracts/mainnet-checkpoint/libraries/CheckpointHubConfig.sol"; +import {CheckpointErrors} from "../../contracts/mainnet-checkpoint/libraries/CheckpointErrors.sol"; + +contract SubmitWithLeavesTest is Test { + Chain138MainnetCheckpoint hub; + address admin = address(0xA11CE); + address submitter = address(0xB0B); + + function setUp() public { + Chain138MainnetCheckpoint impl = new Chain138MainnetCheckpoint(); + bytes memory initData = abi.encodeCall( + Chain138MainnetCheckpoint.initialize, + (admin, address(1), uint64(1), address(0)) + ); + hub = Chain138MainnetCheckpoint(address(new ERC1967Proxy(address(impl), initData))); + vm.startPrank(admin); + hub.grantRole(hub.SUBMITTER_ROLE(), submitter); + CheckpointHubConfig.HubConfig memory cfg = CheckpointHubConfig.mainnetDefaults(); + cfg.enforcePreviousBatchId = false; + cfg.requireValidatorSigs = false; + hub.applyConfig(cfg); + vm.stopPrank(); + } + + function testSubmitWithLeavesVerifiesRoot() public { + CheckpointLeaf.PaymentLeafV1[] memory leaves = new CheckpointLeaf.PaymentLeafV1[](2); + leaves[0] = _leaf(keccak256("a")); + leaves[1] = _leaf(keccak256("b")); + bytes32[] memory hashes = new bytes32[](2); + hashes[0] = CheckpointLeaf.paymentLeafV1(138, leaves[0]); + hashes[1] = CheckpointLeaf.paymentLeafV1(138, leaves[1]); + bytes32 root = CheckpointLeaf.buildMerkleRoot(hashes); + + CheckpointStorage.CheckpointHeader memory header = CheckpointStorage.CheckpointHeader({ + batchId: 1, + previousBatchId: 0, + chainId: 138, + checkpointBlock: 10, + startBlock: 9, + endBlock: 10, + blockHash: keccak256("bh"), + stateRoot: keccak256("sr"), + paymentsRoot: root, + receiptsRoot: bytes32(0), + txCount: 2, + flags: CheckpointFlags.PARTIAL_BATCH, + submittedAt: 0, + submitter: address(0), + contentURI: bytes32(0) + }); + + bytes32[] memory txHashes = new bytes32[](2); + txHashes[0] = leaves[0].txHash; + txHashes[1] = leaves[1].txHash; + + vm.prank(submitter); + hub.submitCheckpointWithLeaves(header, hex"01", txHashes, leaves); + + assertEq(hub.getLatestBatchId(), 1); + } + + function testSubmitWithLeavesRevertsOnBadRoot() public { + CheckpointLeaf.PaymentLeafV1[] memory leaves = new CheckpointLeaf.PaymentLeafV1[](1); + leaves[0] = _leaf(keccak256("x")); + CheckpointStorage.CheckpointHeader memory header = CheckpointStorage.CheckpointHeader({ + batchId: 1, + previousBatchId: 0, + chainId: 138, + checkpointBlock: 1, + startBlock: 1, + endBlock: 1, + blockHash: keccak256("h"), + stateRoot: keccak256("s"), + paymentsRoot: keccak256("wrong"), + receiptsRoot: bytes32(0), + txCount: 1, + flags: CheckpointFlags.PARTIAL_BATCH, + submittedAt: 0, + submitter: address(0), + contentURI: bytes32(0) + }); + vm.prank(submitter); + vm.expectRevert(CheckpointErrors.RootMismatch.selector); + hub.submitCheckpointWithLeaves(header, hex"01", new bytes32[](0), leaves); + } + + function _leaf(bytes32 txHash) private pure returns (CheckpointLeaf.PaymentLeafV1 memory) { + return CheckpointLeaf.PaymentLeafV1({ + txHash: txHash, + from: address(3), + to: address(4), + value: 1, + blockNumber: 1, + blockTimestamp: 1, + gasUsed: 1, + success: true + }); + } +} diff --git a/test/mainnet-checkpoint/TokenTransferFilterV1.t.sol b/test/mainnet-checkpoint/TokenTransferFilterV1.t.sol new file mode 100644 index 0000000..e879f4a --- /dev/null +++ b/test/mainnet-checkpoint/TokenTransferFilterV1.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {TokenTransferFilterExtension} from "../../contracts/mainnet-checkpoint/extensions/TokenTransferFilterExtension.sol"; +import {CheckpointStorage} from "../../contracts/mainnet-checkpoint/storage/CheckpointStorage.sol"; +import {CheckpointLeaf} from "../../contracts/mainnet-checkpoint/libraries/CheckpointLeaf.sol"; + +contract TokenTransferFilterV1Test is Test { + TokenTransferFilterExtension filter; + + function setUp() public { + filter = new TokenTransferFilterExtension(); + filter.setAllowNative(true, 0); + } + + function testBeforeSubmitAcceptsAbiEncodedV1Leaves() public view { + CheckpointLeaf.PaymentLeafV1[] memory leaves = new CheckpointLeaf.PaymentLeafV1[](1); + leaves[0] = CheckpointLeaf.PaymentLeafV1({ + txHash: keccak256("tx"), + from: address(1), + to: address(2), + value: 0, + blockNumber: 1, + blockTimestamp: 1, + gasUsed: 21000, + success: true + }); + bytes memory data = abi.encode(leaves); + CheckpointStorage.CheckpointHeader memory header; + filter.beforeSubmit(header, data); + } +} diff --git a/test/rwa/RWADiamondFacets.t.sol b/test/rwa/RWADiamondFacets.t.sol new file mode 100644 index 0000000..9d4350f --- /dev/null +++ b/test/rwa/RWADiamondFacets.t.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "../../contracts/rwa/diamond/facets/RWAInstrumentFacet.sol"; +import "../../contracts/rwa/diamond/facets/RWADocumentFacet.sol"; +import "../../contracts/rwa/diamond/facets/RWAStandardsRegistryFacet.sol"; +import "../../contracts/rwa/diamond/RWAStorage.sol"; + +contract RWADiamondFacetsTest is Test { + RWAInstrumentFacet public instrument; + RWADocumentFacet public documents; + RWAStandardsRegistryFacet public standards; + + address public admin = address(0xA11CE); + bytes32 public assetId = keccak256("ISIN-US0001-PRIVATE-CLOOP"); + + function setUp() public { + instrument = new RWAInstrumentFacet(admin); + documents = new RWADocumentFacet(admin); + standards = new RWAStandardsRegistryFacet(admin); + } + + function test_privateClosedLoop_isinCusipHashes() public { + bytes32 isinHash = keccak256("US1234567890"); + bytes32 cusipHash = keccak256("123456789"); + + vm.prank(admin); + instrument.setIssuanceMode(assetId, RWAStorage.IssuanceMode.PrivateClosedLoop); + vm.prank(admin); + instrument.setInstrumentIdentity(assetId, isinHash, cusipHash, bytes32(0), bytes32(0)); + + assertEq(uint256(instrument.getIssuanceMode(assetId)), uint256(RWAStorage.IssuanceMode.PrivateClosedLoop)); + } + + function test_thirdPartyTokenization_pointer() public { + address extToken = address(0xBEEF); + + vm.prank(admin); + instrument.setIssuanceMode(assetId, RWAStorage.IssuanceMode.ThirdPartyTokenization); + vm.prank(admin); + instrument.setTokenPointer(assetId, extToken); + + assertEq(instrument.getTokenPointer(assetId), extToken); + } + + function test_anchor_ipfs_and_filecoin_documents() public { + bytes32 content = keccak256("prospectus-v1"); + + vm.prank(admin); + uint256 i0 = documents.anchorDocument(assetId, "ipfs://bafyProspectus", content); + vm.prank(admin); + uint256 i1 = documents.anchorDocument(assetId, "filecoin://f01234deal", content); + + assertEq(documents.documentCount(assetId), 2); + (bytes32 h0, bytes32 u0, RWAStorage.UriScheme s0,) = documents.getDocument(assetId, i0); + assertEq(h0, content); + assertEq(uint256(s0), uint256(RWAStorage.UriScheme.IPFS)); + (, , RWAStorage.UriScheme s1,) = documents.getDocument(assetId, i1); + assertEq(uint256(s1), uint256(RWAStorage.UriScheme.Filecoin)); + assertTrue(u0 != bytes32(0)); + } + + function test_enableFutureStandardFacet() public { + bytes32 erc3643Id = keccak256("ERC-3643"); + address facet = address(uint160(0xFAC7)); + + vm.prank(admin); + standards.enableStandard(erc3643Id, facet); + vm.prank(admin); + standards.bindAssetStandardFacet(assetId, erc3643Id, facet); + + assertTrue(standards.isStandardEnabled(erc3643Id)); + assertEq(standards.assetStandardFacet(assetId, erc3643Id), facet); + } +} diff --git a/test/rwa/RWATokenFactory.t.sol b/test/rwa/RWATokenFactory.t.sol new file mode 100644 index 0000000..93b3db7 --- /dev/null +++ b/test/rwa/RWATokenFactory.t.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "../../contracts/rwa/RWAToken.sol"; +import "../../contracts/rwa/RWATokenRegistry.sol"; +import "../../contracts/rwa/RWATokenFactory.sol"; +import "../../contracts/rwa/IRWATokenFactory.sol"; +import "../../contracts/rwa/IRWAToken.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import "../../contracts/rwa/RWATokenInterfaces.sol"; +/// @dev Minimal UAR stub so rwa-scoped `forge test` does not pull the full upgradeable registry tree. +contract MockUniversalAssetRegistry { + bytes32 public constant REGISTRAR_ROLE = keccak256("REGISTRAR_ROLE"); + mapping(address => bool) private _active; + + function grantRole(bytes32, address) external {} + + function registerRWAIndexAsset( + address tokenAddress, + string calldata, + string calldata, + uint8, + string calldata + ) external { + _active[tokenAddress] = true; + } + + function isAssetActive(address token) external view returns (bool) { + return _active[token]; + } +} + +contract RWATokenFactoryTest is Test { + bytes32 internal constant METHODOLOGY_HASH = + 0x6b6e599d0ba31d048b49302e263a12f0c59502f67a35100ad5c65b503b1d4b82; + + RWATokenRegistry public registry; + RWATokenFactory public factory; + MockUniversalAssetRegistry public uar; + + address public admin = address(0xA11CE); + address public owner = address(0xB0B); + address public publisher = address(0xC0DE); + + function setUp() public { + vm.startPrank(admin); + uar = new MockUniversalAssetRegistry(); + registry = new RWATokenRegistry(admin); + factory = new RWATokenFactory(admin, address(registry), address(uar)); + registry.grantRole(registry.REGISTRAR_ROLE(), address(factory)); + uar.grantRole(uar.REGISTRAR_ROLE(), address(factory)); + factory.grantRole(factory.DEPLOYER_ROLE(), admin); + vm.stopPrank(); + } + + function test_deployLiXAU_registersInRegistryAndUAR() public { + vm.prank(admin); + address token = factory.deployRWAIndex( + IRWATokenFactory.RWAProductConfig({ + indexTicker: "LiXAU", + name: "XAU Liquidity Index (M00)", + symbol: "LiXAU", + decimals: 6, + assetClass: "Commodities", + assetGroup: "Precious Metals", + instrumentType: "Commodity Index", + underlyingAsset: "Gold", + gruLayer: "M00", + jurisdiction: "International", + initialOwner: owner, + complianceAdmin: admin, + indexPublisher: publisher, + initialIndexValue: 1_050_000, + initialSupply: 0, + methodologyDocumentHash: METHODOLOGY_HASH, + registerInUniversalAssetRegistry: true + }) + ); + + assertEq(IRWAToken(token).methodologyDocumentHash(), METHODOLOGY_HASH); + assertTrue(registry.isRegistered("LiXAU")); + assertEq(registry.getToken("LiXAU"), token); + + IRWAToken rwa = IRWAToken(token); + assertTrue(rwa.isRwaIndex()); + assertFalse(rwa.isEmoney()); + assertEq(rwa.indexValue(), 1_050_000); + assertEq(keccak256(bytes(rwa.indexTicker())), keccak256("LiXAU")); + + assertTrue(uar.isAssetActive(token)); + } + + function test_revert_duplicateTicker() public { + IRWATokenFactory.RWAProductConfig memory cfg = _liXauConfig(); + vm.prank(admin); + factory.deployRWAIndex(cfg); + vm.prank(admin); + vm.expectRevert("RWATokenFactory: already registered"); + factory.deployRWAIndex(cfg); + } + + function test_revert_invalidTicker() public { + IRWATokenFactory.RWAProductConfig memory cfg = _liXauConfig(); + cfg.indexTicker = "cUSDT"; + vm.prank(admin); + vm.expectRevert(); + factory.deployRWAIndex(cfg); + } + + function test_indexPublisherUpdatesValue() public { + vm.prank(admin); + address token = factory.deployRWAIndex(_liXauConfig()); + vm.prank(publisher); + IRWAToken(token).updateIndexValue(2_000_000); + assertEq(IRWAToken(token).indexValue(), 2_000_000); + } + + function test_eip165_and_eip712_domain() public { + vm.prank(admin); + address token = factory.deployRWAIndex(_liXauConfig()); + IRWAToken165 rwa = IRWAToken165(token); + assertTrue(rwa.supportsInterface(type(IRWAToken).interfaceId)); + assertTrue(rwa.supportsInterface(type(IERC20).interfaceId)); + assertTrue(rwa.supportsInterface(type(IERC20Metadata).interfaceId)); + assertEq(rwa.eip20Version(), "1.0.0"); + assertTrue(rwa.eip712DomainSeparator() != bytes32(0)); + bytes32 h = rwa.hashIndexValueAttestation("LiXAU", 1_100_000, 1, block.timestamp + 3600); + assertTrue(h != bytes32(0)); + } + + function test_revert_zeroMethodologyHash() public { + IRWATokenFactory.RWAProductConfig memory cfg = _liXauConfig(); + cfg.methodologyDocumentHash = bytes32(0); + vm.prank(admin); + vm.expectRevert("RWATokenFactory: methodology"); + factory.deployRWAIndex(cfg); + } + + function _liXauConfig() internal view returns (IRWATokenFactory.RWAProductConfig memory) { + return IRWATokenFactory.RWAProductConfig({ + indexTicker: "LiXAU", + name: "XAU Liquidity Index (M00)", + symbol: "LiXAU", + decimals: 6, + assetClass: "Commodities", + assetGroup: "Precious Metals", + instrumentType: "Commodity Index", + underlyingAsset: "Gold", + gruLayer: "M00", + jurisdiction: "International", + initialOwner: owner, + complianceAdmin: admin, + indexPublisher: publisher, + initialIndexValue: 1_000_000, + initialSupply: 0, + methodologyDocumentHash: METHODOLOGY_HASH, + registerInUniversalAssetRegistry: false + }); + } +}