diff --git a/.gitignore b/.gitignore index 847bb491de..127e456554 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,7 @@ contracts/dist/ contracts/.localKeyValueStorage contracts/.localKeyValueStorage.mainnet contracts/.localKeyValueStorage.holesky +contracts/.localKeyValueStorage.base contracts/scripts/defender-actions/dist/ contracts/lib/defender-actions/dist/ diff --git a/contracts/README.md b/contracts/README.md index 8403927e9f..85b23ca7ee 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -369,6 +369,11 @@ pnpm hardhat updateAction --id f92ea662-fc34-433b-8beb-b34e9ab74685 --file sonic pnpm hardhat updateAction --id b1d831f1-29d4-4943-bb2e-8e625b76e82c --file claimBribes pnpm hardhat updateAction --id 6567d7c6-7ec7-44bd-b95b-470dd1ff780b --file manageBribeOnSonic pnpm hardhat updateAction --id 6a633bb0-aff8-4b37-aaae-b4c6f244ed87 --file managePassThrough +# These are Base -> Mainnet & Mainnet -> Base actions +# they share the codebase. The direction of relaying attestations is defined by the +# network of the relayer that is attached to the action +pnpm hardhat updateAction --id bb43e5da-f936-4185-84da-253394583665 --file crossChainRelay +pnpm hardhat updateAction --id e571409b-5399-48e4-bfb2-50b7af9903aa --file crossChainRelay ``` `rollup` can be installed globally to avoid the `npx` prefix. diff --git a/contracts/scripts/defender-actions/crossChainRelay.js b/contracts/scripts/defender-actions/crossChainRelay.js new file mode 100644 index 0000000000..121e48f48f --- /dev/null +++ b/contracts/scripts/defender-actions/crossChainRelay.js @@ -0,0 +1,72 @@ +const { ethers } = require("ethers"); +const { Defender } = require("@openzeppelin/defender-sdk"); +const { processCctpBridgeTransactions } = require("../../tasks/crossChain"); +const { getNetworkName } = require("../../utils/hardhat-helpers"); +const { configuration } = require("../../utils/cctp"); + +// Entrypoint for the Defender Action +const handler = async (event) => { + console.log( + `DEBUG env var in handler before being set: "${process.env.DEBUG}"` + ); + + // Initialize defender relayer provider and signer + const client = new Defender(event); + // Chain ID of the target contract relayer signer + const provider = client.relaySigner.getProvider({ ethersVersion: "v5" }); + const { chainId } = await provider.getNetwork(); + let sourceProvider; + const signer = await client.relaySigner.getSigner(provider, { + speed: "fastest", + ethersVersion: "v5", + }); + + // destinatino chain is mainnet, source chain is base + if (chainId === 1) { + if (!event.secrets.BASE_PROVIDER_URL) { + throw new Error("BASE_PROVIDER_URL env var required"); + } + sourceProvider = new ethers.providers.JsonRpcProvider( + event.secrets.BASE_PROVIDER_URL + ); + } + // destination chain is base, source chain is mainnet + else if (chainId === 8453) { + if (!event.secrets.PROVIDER_URL) { + throw new Error("PROVIDER_URL env var required"); + } + sourceProvider = new ethers.providers.JsonRpcProvider( + event.secrets.PROVIDER_URL + ); + } else { + throw new Error(`Unsupported chain id: ${chainId}`); + } + + const networkName = await getNetworkName(sourceProvider); + const isMainnet = networkName === "mainnet"; + const isBase = networkName === "base"; + + let config; + if (isMainnet) { + config = configuration.mainnetBaseMorpho.mainnet; + } else if (isBase) { + config = configuration.mainnetBaseMorpho.base; + } else { + throw new Error(`Unsupported network name: ${networkName}`); + } + + await processCctpBridgeTransactions({ + destinationChainSigner: signer, + sourceChainProvider: sourceProvider, + store: client.keyValueStore, + networkName, + blockLookback: config.blockLookback, + cctpDestinationDomainId: config.cctpDestinationDomainId, + cctpSourceDomainId: config.cctpSourceDomainId, + cctpIntegrationContractAddress: config.cctpIntegrationContractAddress, + cctpIntegrationContractAddressDestination: + config.cctpIntegrationContractAddressDestination, + }); +}; + +module.exports = { handler }; diff --git a/contracts/scripts/defender-actions/rollup.config.cjs b/contracts/scripts/defender-actions/rollup.config.cjs index d216607209..ec1fedc7b3 100644 --- a/contracts/scripts/defender-actions/rollup.config.cjs +++ b/contracts/scripts/defender-actions/rollup.config.cjs @@ -40,6 +40,7 @@ const actions = [ "sonicRequestWithdrawal", "sonicClaimWithdrawals", "claimBribes", + "crossChainRelay", ]; module.exports = actions.map((action) => ({ diff --git a/contracts/tasks/crossChain.js b/contracts/tasks/crossChain.js new file mode 100644 index 0000000000..8b76650141 --- /dev/null +++ b/contracts/tasks/crossChain.js @@ -0,0 +1,213 @@ +//const { KeyValueStoreClient } = require("@openzeppelin/defender-sdk"); +const ethers = require("ethers"); +const { logTxDetails } = require("../utils/txLogger"); +const { api: cctpApi } = require("../utils/cctp"); + +const cctpOperationsConfig = async ({ + destinationChainSigner, + sourceChainProvider, + networkName, + cctpIntegrationContractAddress, + cctpIntegrationContractAddressDestination, +}) => { + const cctpIntegratorAbi = [ + "event TokensBridged(uint32 peerDomainID,address peerStrategy,address usdcToken,uint256 tokenAmount,uint256 maxFee,uint32 minFinalityThreshold,bytes hookData)", + "event MessageTransmitted(uint32 peerDomainID,address peerStrategy,uint32 minFinalityThreshold,bytes message)", + "function relay(bytes message, bytes attestation) external", + ]; + + const cctpIntegrationContractSource = new ethers.Contract( + cctpIntegrationContractAddress, + cctpIntegratorAbi, + sourceChainProvider + ); + const cctpIntegrationContractDestination = new ethers.Contract( + cctpIntegrationContractAddressDestination, + cctpIntegratorAbi, + destinationChainSigner + ); + + return { + networkName, + cctpIntegrationContractSource, + cctpIntegrationContractDestination, + }; +}; + +const fetchAttestation = async ({ transactionHash, cctpChainId }) => { + console.log( + `Fetching attestation for transaction hash: ${transactionHash} on cctp chain id: ${cctpChainId}` + ); + const response = await fetch( + `${cctpApi}/v2/messages/${cctpChainId}?transactionHash=${transactionHash}` + ); + if (!response.ok) { + throw new Error( + `Error fetching attestation code: ${ + response.status + } error: ${await response.text()}` + ); + } + const resultJson = await response.json(); + + if (resultJson.messages.length !== 1) { + throw new Error( + `Expected 1 attestation, got ${resultJson.messages.length}` + ); + } + + const message = resultJson.messages[0]; + const status = message.status; + if (status !== "complete") { + throw new Error(`Attestation is not complete, status: ${status}`); + } + + return { + attestation: message.attestation, + message: message.message, + status: "ok", + }; +}; + +// TokensBridged & MessageTransmitted are the 2 events that are emitted when a transaction is published to the CCTP contract +// One transaction containing such message can at most only contain one of these events +const fetchTxHashesFromCctpTransactions = async ({ + config, + blockLookback, + overrideBlock, + sourceChainProvider, +} = {}) => { + let resolvedFromBlock, resolvedToBlock; + if (overrideBlock) { + resolvedFromBlock = overrideBlock; + resolvedToBlock = overrideBlock; + } else { + const latestBlock = await sourceChainProvider.getBlockNumber(); + resolvedFromBlock = Math.max(latestBlock - blockLookback, 0); + resolvedToBlock = latestBlock; + } + + const cctpIntegrationContractSource = config.cctpIntegrationContractSource; + + const tokensBridgedTopic = + cctpIntegrationContractSource.interface.getEventTopic("TokensBridged"); + const messageTransmittedTopic = + cctpIntegrationContractSource.interface.getEventTopic("MessageTransmitted"); + + console.log( + `Fetching event logs from block ${resolvedFromBlock} to block ${resolvedToBlock}` + ); + const [eventLogsTokenBridged, eventLogsMessageTransmitted] = + await Promise.all([ + sourceChainProvider.getLogs({ + address: cctpIntegrationContractSource.address, + fromBlock: resolvedFromBlock, + toBlock: resolvedToBlock, + topics: [tokensBridgedTopic], + }), + sourceChainProvider.getLogs({ + address: cctpIntegrationContractSource.address, + fromBlock: resolvedFromBlock, + toBlock: resolvedToBlock, + topics: [messageTransmittedTopic], + }), + ]); + + // There should be no duplicates in the event logs, but still deduplicate to be safe + const possiblyDuplicatedTxHashes = [ + ...eventLogsTokenBridged, + ...eventLogsMessageTransmitted, + ].map((log) => log.transactionHash); + const allTxHashes = Array.from(new Set([...possiblyDuplicatedTxHashes])); + + console.log(`Found ${allTxHashes.length} transactions that emitted messages`); + return { allTxHashes }; +}; + +const processCctpBridgeTransactions = async ({ + block = undefined, + dryrun = false, + destinationChainSigner, + sourceChainProvider, + store, + networkName, + blockLookback, + cctpDestinationDomainId, + cctpSourceDomainId, + cctpIntegrationContractAddress, + cctpIntegrationContractAddressDestination, +}) => { + const config = await cctpOperationsConfig({ + destinationChainSigner, + sourceChainProvider, + networkName, + cctpIntegrationContractAddress, + cctpIntegrationContractAddressDestination, + }); + console.log( + `Fetching cctp messages posted on ${config.networkName} network.${ + block ? ` Only for block: ${block}` : " Looking at most recent blocks" + }` + ); + + const { allTxHashes } = await fetchTxHashesFromCctpTransactions({ + config, + overrideBlock: block, + sourceChainProvider, + blockLookback, + }); + for (const txHash of allTxHashes) { + const storeKey = `cctp_message_${txHash}`; + const storedValue = await store.get(storeKey); + + if (storedValue === "processed") { + console.log( + `Transaction with hash: ${txHash} has already been processed. Skipping...` + ); + continue; + } + + const { attestation, message, status } = await fetchAttestation({ + transactionHash: txHash, + cctpChainId: cctpSourceDomainId, + }); + if (status !== "ok") { + console.log( + `Attestation from tx hash: ${txHash} on cctp chain id: ${config.cctpSourceDomainId} is not attested yet, status: ${status}. Skipping...` + ); + } + + console.log( + `Attempting to relay attestation with tx hash: ${txHash} and message: ${message} to cctp chain id: ${cctpDestinationDomainId}` + ); + + if (dryrun) { + console.log( + `Dryrun: Would have relayed attestation with tx hash: ${txHash} to cctp chain id: ${cctpDestinationDomainId}` + ); + continue; + } + + const relayTx = await config.cctpIntegrationContractDestination.relay( + message, + attestation + ); + console.log( + `Relay transaction with hash ${relayTx.hash} sent to cctp chain id: ${cctpDestinationDomainId}` + ); + const receipt = await logTxDetails(relayTx, "CCTP relay"); + + // Final verification + if (receipt.status === 1) { + console.log("SUCCESS: Transaction executed successfully!"); + await store.put(storeKey, "processed"); + } else { + console.log("FAILURE: Transaction reverted!"); + throw new Error(`Transaction reverted - status: ${receipt.status}`); + } + } +}; + +module.exports = { + processCctpBridgeTransactions, +}; diff --git a/contracts/tasks/defender.js b/contracts/tasks/defender.js index 80d06f5b4d..5b929b4cb2 100644 --- a/contracts/tasks/defender.js +++ b/contracts/tasks/defender.js @@ -70,4 +70,5 @@ const updateAction = async ({ id, file }) => { module.exports = { setActionVars, updateAction, + getClient, }; diff --git a/contracts/tasks/tasks.js b/contracts/tasks/tasks.js index 32c5606409..14d0bf4c1c 100644 --- a/contracts/tasks/tasks.js +++ b/contracts/tasks/tasks.js @@ -19,7 +19,7 @@ const { decryptMasterPrivateKey, } = require("./amazon"); const { collect, setDripDuration } = require("./dripper"); -const { getSigner } = require("../utils/signers"); +const { getSigner, getDefenderSigner } = require("../utils/signers"); const { snapAero } = require("./aero"); const { storeStorageLayoutForAllContracts, @@ -143,6 +143,10 @@ const { } = require("./beaconTesting"); const { claimMerklRewards } = require("./merkl"); +const { processCctpBridgeTransactions } = require("./crossChain"); +const { keyValueStoreLocalClient } = require("../utils/defender"); +const { configuration } = require("../utils/cctp"); + const log = require("../utils/logger")("tasks"); // Environment tasks. @@ -1219,6 +1223,75 @@ task("stakeValidators").setAction(async (_, __, runSuper) => { return runSuper(); }); +/** + * This function relays the messages between mainnet and base networks. + * + * IMPORTANT!!! + * If possible please use the defender action and not local execution. The defender action stores into the cloud + * key-value store the transaction hashes that have already been relayed. Relaying the transaction via this task + * will make the defender relayer continuously fail relaying the transaction that has already been processed. + * If the action is ran every ~12 hours and looks back for ~1 day worth of blocks it might fail to run 2-3 times and + * then skip some pending transactions that would need relaying. + */ +task( + "relayCCTPMessage", + "Fetches CCTP attested Messages via Circle Gateway API and relays it to the integrator contract" +) + .addOptionalParam( + "block", + "Override the block number at which the message emission transaction happened", + undefined, + types.int + ) + .addOptionalParam( + "dryrun", + "Do not call verifyBalances on the strategy contract. Just log the params including the proofs", + false, + types.boolean + ) + .setAction(async (taskArgs) => { + const networkName = await getNetworkName(); + const storeFilePath = require("path").join( + __dirname, + "..", + `.localKeyValueStorage.${networkName}` + ); + + // This action only works with the Defender Relayer signer + const signer = await getDefenderSigner(); + const store = keyValueStoreLocalClient({ _storePath: storeFilePath }); + + const isMainnet = networkName === "mainnet"; + const isBase = networkName === "base"; + + let config; + if (isMainnet) { + config = configuration.mainnetBaseMorpho.mainnet; + } else if (isBase) { + config = configuration.mainnetBaseMorpho.base; + } else { + throw new Error(`Unsupported network name: ${networkName}`); + } + + await processCctpBridgeTransactions({ + ...taskArgs, + destinationChainSigner: signer, + sourceChainProvider: ethers.provider, + store, + networkName, + blockLookback: config.blockLookback, + cctpDestinationDomainId: config.cctpDestinationDomainId, + cctpSourceDomainId: config.cctpSourceDomainId, + cctpIntegrationContractAddress: config.cctpIntegrationContractAddress, + cctpIntegrationContractAddressDestination: + config.cctpIntegrationContractAddressDestination, + }); + }); + +task("relayCCTPMessage").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + subtask("exitValidator", "Starts the exit process from a validator") .addParam( "pubkey", diff --git a/contracts/utils/addresses.js b/contracts/utils/addresses.js index 98eef41e98..1d8f6da17a 100644 --- a/contracts/utils/addresses.js +++ b/contracts/utils/addresses.js @@ -693,11 +693,10 @@ addresses.hoodi.defenderRelayer = "0x419B6BdAE482f41b8B194515749F3A2Da26d583b"; addresses.hoodi.mockBeaconRoots = "0xdCfcAE4A084AA843eE446f400B23aA7B6340484b"; // Crosschain Strategy -// addresses.CrossChainStrategyProxy = -// "TBD"; -// addresses.mainnet.CrossChainStrategyProxy = -// "TBD"; -// addresses.base.CrossChainStrategyProxy = -// "TBD"; - +addresses.base.CrossChainRemoteStrategy = "TODO"; +addresses.mainnet.CrossChainMasterStrategy = "TODO"; +// CCTP Circle Contract addresses: https://developers.circle.com/cctp/references/contract-addresses +addresses.CCTPTokenMessengerV2 = "0x28b5a0e9c621a5badaa536219b3a228c8168cf5d"; +addresses.CCTPMessageTransmitterV2 = + "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64"; module.exports = addresses; diff --git a/contracts/utils/cctp.js b/contracts/utils/cctp.js index 3422aba26c..890c206233 100644 --- a/contracts/utils/cctp.js +++ b/contracts/utils/cctp.js @@ -1,8 +1,34 @@ +const addresses = require("./addresses"); + const cctpDomainIds = { Ethereum: 0, Base: 6, }; +const api = "https://iris-api.circle.com"; +const configuration = { + mainnetBaseMorpho: { + mainnet: { + cctpDestinationDomainId: cctpDomainIds.Base, + cctpSourceDomainId: cctpDomainIds.Ethereum, + cctpIntegrationContractAddress: + addresses.mainnet.CrossChainMasterStrategy, + cctpIntegrationContractAddressDestination: + addresses.base.CrossChainRemoteStrategy, + blockLookback: 14600, // a bit over 2 days in block time on mainnet + }, + base: { + cctpDestinationDomainId: cctpDomainIds.Ethereum, + cctpSourceDomainId: cctpDomainIds.Base, + cctpIntegrationContractAddress: addresses.base.CrossChainRemoteStrategy, + cctpIntegrationContractAddressDestination: + addresses.mainnet.CrossChainMasterStrategy, + blockLookback: 87600, // a bit over 2 days in block time on base + }, + }, +}; module.exports = { cctpDomainIds, + api, + configuration, }; diff --git a/contracts/utils/defender.js b/contracts/utils/defender.js new file mode 100644 index 0000000000..52f131b8b2 --- /dev/null +++ b/contracts/utils/defender.js @@ -0,0 +1,45 @@ +const fs = require("fs"); +const path = require("path"); + +const keyValueStoreLocalClient = ({ _storePath }) => ({ + storePath: _storePath, + + async get(key) { + return this.getStore()[key]; + }, + + async put(key, value) { + this.updateStore((store) => { + store[key] = value; + }); + }, + + async del(key) { + this.updateStore((store) => { + delete store[key]; + }); + }, + + getStore() { + try { + if (!fs.existsSync(this.storePath)) { + return {}; + } + const contents = fs.readFileSync(this.storePath, "utf8"); + return contents ? JSON.parse(contents) : {}; + } catch (error) { + return {}; + } + }, + + updateStore(updater) { + const store = this.getStore(); + updater(store); + fs.mkdirSync(path.dirname(this.storePath), { recursive: true }); + fs.writeFileSync(this.storePath, JSON.stringify(store, null, 2)); + }, +}); + +module.exports = { + keyValueStoreLocalClient, +};