From a0d519c0d79715030be83ef9ac185741392acfda Mon Sep 17 00:00:00 2001 From: akash Date: Mon, 6 Jul 2026 22:02:16 +0530 Subject: [PATCH] feat: add EtherealExecutor and HypercoreDepositExecutor Adds destination-side executors that deposit bridged funds into Ethereal's ExchangeGateway (native USDe) and Hypercore's CoreDepositWallet (USDC on HyperEVM), plus CREATE3 deploy scripts and chain config. Also hardens deployCctpClaimExecutor with non-fatal verification and adds an arc CCTP claim executor deployment (Sourcify-based verification, since arc isn't Etherscan-verified) and its HyperEVM HypercoreDepositExecutor deployment record. --- deployments/prod/addresses/arc.json | 3 +- deployments/prod/addresses/hyperEvm.json | 3 +- package.json | 2 + scripts/deploy/cctpClaimExecutorConfig.ts | 5 + scripts/deploy/create3.ts | 16 + scripts/deploy/deployCctpClaimExecutor.ts | 32 +- scripts/deploy/deployEtherealExecutor.ts | 275 +++++++++++++++++ scripts/deploy/deployHypercoreExecutor.ts | 292 ++++++++++++++++++ scripts/deploy/etherealExecutorAddresses.ts | 76 +++++ scripts/deploy/etherealExecutorConfig.ts | 23 ++ scripts/deploy/hypercoreExecutorAddresses.ts | 73 +++++ scripts/deploy/hypercoreExecutorConfig.ts | 21 ++ .../deploy/verifyCctpClaimExecutorBatch.ts | 1 + scripts/e2e/config.ts | 2 + src/executors/EtherealExecutor.sol | 230 ++++++++++++++ src/executors/HypercoreDepositExecutor.sol | 210 +++++++++++++ 16 files changed, 1252 insertions(+), 12 deletions(-) create mode 100644 scripts/deploy/deployEtherealExecutor.ts create mode 100644 scripts/deploy/deployHypercoreExecutor.ts create mode 100644 scripts/deploy/etherealExecutorAddresses.ts create mode 100644 scripts/deploy/etherealExecutorConfig.ts create mode 100644 scripts/deploy/hypercoreExecutorAddresses.ts create mode 100644 scripts/deploy/hypercoreExecutorConfig.ts create mode 100644 src/executors/EtherealExecutor.sol create mode 100644 src/executors/HypercoreDepositExecutor.sol diff --git a/deployments/prod/addresses/arc.json b/deployments/prod/addresses/arc.json index 21878b9..cb669b8 100644 --- a/deployments/prod/addresses/arc.json +++ b/deployments/prod/addresses/arc.json @@ -1,3 +1,4 @@ { - "RFQVaultExecutor": "0x97caCa78AC2a94c67643d07843F85AFAa44a3ea5" + "RFQVaultExecutor": "0x97caCa78AC2a94c67643d07843F85AFAa44a3ea5", + "CctpClaimExecutor": "0x424a31a57f7c63918ecaa2fac38016a8af5a6ec2" } diff --git a/deployments/prod/addresses/hyperEvm.json b/deployments/prod/addresses/hyperEvm.json index cb669b8..002f460 100644 --- a/deployments/prod/addresses/hyperEvm.json +++ b/deployments/prod/addresses/hyperEvm.json @@ -1,4 +1,5 @@ { "RFQVaultExecutor": "0x97caCa78AC2a94c67643d07843F85AFAa44a3ea5", - "CctpClaimExecutor": "0x424a31a57f7c63918ecaa2fac38016a8af5a6ec2" + "CctpClaimExecutor": "0x424a31a57f7c63918ecaa2fac38016a8af5a6ec2", + "HypercoreDepositExecutor": "0x20dfbf72767129c9305d7b34d67770815a793fb8" } diff --git a/package.json b/package.json index b5b69ff..65ab264 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "deploy:cctp-claim-executor:all": "ts-node scripts/deploy/deployCctpClaimExecutorAllChains.ts", "deploy:rfq-vault-executor:all": "ts-node scripts/deploy/deployRFQVaultExecutorAllChains.ts", "deploy:rfq-vault-executor": "hardhat run scripts/deploy/deployRFQVaultExecutor.ts --network", + "deploy:ethereal-executor": "hardhat run scripts/deploy/deployEtherealExecutor.ts --network", + "deploy:hypercore-executor": "hardhat run scripts/deploy/deployHypercoreExecutor.ts --network", "check:openrouter": "hardhat run scripts/deploy/checkOpenRouterDeployment.ts", "verify:openrouter": "ts-node scripts/deploy/verifyOpenRouterBatch.ts", "check:allowance-holder": "hardhat run scripts/deploy/checkAllowanceHolderDeployment.ts", diff --git a/scripts/deploy/cctpClaimExecutorConfig.ts b/scripts/deploy/cctpClaimExecutorConfig.ts index b649fab..64d989d 100644 --- a/scripts/deploy/cctpClaimExecutorConfig.ts +++ b/scripts/deploy/cctpClaimExecutorConfig.ts @@ -88,6 +88,11 @@ export const CCTP_CLAIM_EXECUTOR_CHAIN_CONFIG: Record setTimeout(resolve, 5000)); - await hre.run('verify:verify', { - address: executorAddress, - constructorArguments: [ - owner, - chainConfig.messageTransmitter, - solverSigner, - chainConfig.usdcAddress, - ], - }); + try { + await hre.run('verify:verify', { + address: executorAddress, + constructorArguments: [ + owner, + chainConfig.messageTransmitter, + solverSigner, + chainConfig.usdcAddress, + ], + }); + console.log('Contract verified on block explorer'); + } catch (err) { + console.warn( + 'Contract verification failed (deployment succeeded):', + err instanceof Error ? err.message : err, + ); + } } } diff --git a/scripts/deploy/deployEtherealExecutor.ts b/scripts/deploy/deployEtherealExecutor.ts new file mode 100644 index 0000000..6a4fd0c --- /dev/null +++ b/scripts/deploy/deployEtherealExecutor.ts @@ -0,0 +1,275 @@ +/** + * Deployment script for EtherealExecutor via CreateX CREATE3. + * + * Deploy on Ethereal only — the sole destination chain USDe deposits into ExchangeGateway. + * + * Usage: + * npx hardhat run scripts/deploy/deployEtherealExecutor.ts --network ethereal + * + * Required env vars: + * DEPLOYER_PRIVATE_KEY — deployer wallet private key + * + * Optional env vars: + * OWNER_ADDRESS — EtherealExecutor owner/EXECUTOR_ROLE holder; defaults to DEFAULT_OWNER_ADDRESS or deployer + * RESCUE_ADDRESS — RESCUE_ROLE holder; defaults to the resolved owner address + */ + +import hre, { ethers } from 'hardhat'; +import { + CREATE_X_FACTORY, + Create3ABI, + ETHEREAL_EXECUTOR_CREATE3_SALT, + ETHEREAL_EXECUTOR_CREATE3_SALT_TEXT, + CALLDATA_EXECUTOR_EXPECTED_ADDRESS, + decodeCreate3DeploymentFromTxReceipt, + hasContractBytecode, +} from './create3'; +import { findDeploymentRegistryRow } from './deploymentRegistry'; +import { writeEtherealExecutorAddress } from './etherealExecutorAddresses'; +import { ETHEREAL_EXECUTOR_CHAIN_CONFIG } from './etherealExecutorConfig'; + +const DEFAULT_OWNER_ADDRESS = '0x0E1B5AB67aF1c99F8c7Ebc71f41f75D4D6211e53'; + +const ADDR_HEX_RE = /^0x[a-fA-F0-9]{40}$/; + +function resolveOwnerAddress(deployerAddress: string): string { + const envOwner = process.env.OWNER_ADDRESS?.trim(); + if (envOwner && ADDR_HEX_RE.test(envOwner)) { + return envOwner; + } + + if (DEFAULT_OWNER_ADDRESS && ADDR_HEX_RE.test(DEFAULT_OWNER_ADDRESS)) { + return DEFAULT_OWNER_ADDRESS; + } + + return deployerAddress; +} + +function resolveRescueAddress(ownerAddress: string): string { + const envRescue = process.env.RESCUE_ADDRESS?.trim(); + if (envRescue && ADDR_HEX_RE.test(envRescue)) { + return envRescue; + } + + return ownerAddress; +} + +function resolveCalldataExecutorAddress(chainId: number): string { + const row = findDeploymentRegistryRow(chainId); + const registryAddress = row?.calldataExecutorAddress?.trim(); + if (registryAddress && ADDR_HEX_RE.test(registryAddress)) { + return registryAddress; + } + + return CALLDATA_EXECUTOR_EXPECTED_ADDRESS; +} + +async function getEtherealExecutorInitcode(params: { + owner: string; + rescueAddress: string; + calldataExecutor: string; + exchangeGateway: string; + depositToken: string; +}): Promise { + const factory = await ethers.getContractFactory('EtherealExecutor'); + const deployTransaction = await factory.getDeployTransaction( + params.owner, + params.rescueAddress, + params.calldataExecutor, + params.exchangeGateway, + params.depositToken, + ); + + if (!deployTransaction.data) { + throw new Error('EtherealExecutor deploy transaction data is empty'); + } + + return deployTransaction.data; +} + +async function assertEtherealExecutorDeployment(params: { + address: string; + owner: string; + calldataExecutor: string; + exchangeGateway: string; + depositToken: string; +}): Promise { + const contract = new ethers.Contract( + params.address, + [ + 'function owner() view returns (address)', + 'function CALLDATA_EXECUTOR() view returns (address)', + 'function exchangeGateway() view returns (address)', + 'function DEPOSIT_TOKEN() view returns (address)', + ], + ethers.provider, + ); + + const [owner, calldataExecutor, exchangeGateway, depositToken] = await Promise.all([ + contract.owner() as Promise, + contract.CALLDATA_EXECUTOR() as Promise, + contract.exchangeGateway() as Promise, + contract.DEPOSIT_TOKEN() as Promise, + ]); + + if (owner.toLowerCase() !== params.owner.toLowerCase()) { + throw new Error(`EtherealExecutor owner mismatch: expected ${params.owner}, got ${owner}`); + } + + if (calldataExecutor.toLowerCase() !== params.calldataExecutor.toLowerCase()) { + throw new Error( + `EtherealExecutor CALLDATA_EXECUTOR mismatch: expected ${params.calldataExecutor}, got ${calldataExecutor}`, + ); + } + + if (exchangeGateway.toLowerCase() !== params.exchangeGateway.toLowerCase()) { + throw new Error( + `EtherealExecutor exchangeGateway mismatch: expected ${params.exchangeGateway}, got ${exchangeGateway}`, + ); + } + + if (depositToken.toLowerCase() !== params.depositToken.toLowerCase()) { + throw new Error( + `EtherealExecutor DEPOSIT_TOKEN mismatch: expected ${params.depositToken}, got ${depositToken}`, + ); + } +} + +function printBackendConfigSnippet(chainId: number, address: string): void { + console.log('\n=== bungee-backend config snippet ==='); + console.log( + 'Update executorAddress in bungee-backend/src/modules/bungee-auto/constants.ts (ETHEREAL_DEPOSIT_CONFIG):', + ); + console.log(` [${chainId}]: '${address}',`); +} + +async function persistEtherealExecutorAddress(params: { + network: string; + chainId: number; + address: string; +}): Promise { + const filePath = await writeEtherealExecutorAddress(params.network, params.address); + console.log('Deployment JSON:', filePath); + printBackendConfigSnippet(params.chainId, params.address); +} + +async function main() { + const [deployer] = await ethers.getSigners(); + const networkName = hre.network.name; + const chainId = Number((await ethers.provider.getNetwork()).chainId); + + const chainConfig = ETHEREAL_EXECUTOR_CHAIN_CONFIG[chainId]; + if (!chainConfig) { + throw new Error(`Chain ${chainId} is not configured for EtherealExecutor deployment`); + } + + console.log('Deployer: ', deployer.address); + console.log('Network: ', networkName); + console.log('Chain ID: ', chainId); + console.log('CREATE3 salt: ', ETHEREAL_EXECUTOR_CREATE3_SALT_TEXT); + console.log(''); + + const owner = resolveOwnerAddress(deployer.address); + const rescueAddress = resolveRescueAddress(owner); + const calldataExecutor = resolveCalldataExecutorAddress(chainId); + const { exchangeGateway, depositToken } = chainConfig; + + console.log('Owner: ', owner); + console.log('RescueAddress: ', rescueAddress); + console.log('CalldataExecutor: ', calldataExecutor); + console.log('ExchangeGateway: ', exchangeGateway); + console.log('DepositToken: ', depositToken); + console.log(''); + + const initcode = await getEtherealExecutorInitcode({ + owner, + rescueAddress, + calldataExecutor, + exchangeGateway, + depositToken, + }); + + const create3Factory = new ethers.Contract(CREATE_X_FACTORY, Create3ABI, deployer); + + const expectedAddress = (await create3Factory.deployCreate3.staticCall( + ETHEREAL_EXECUTOR_CREATE3_SALT, + initcode, + )) as string; + + console.log('Expected address:', expectedAddress); + + const existingBytecode = await ethers.provider.getCode(expectedAddress); + if (hasContractBytecode(existingBytecode)) { + console.log(`EtherealExecutor already deployed at ${expectedAddress}`); + await assertEtherealExecutorDeployment({ + address: expectedAddress, + owner, + calldataExecutor, + exchangeGateway, + depositToken, + }); + await persistEtherealExecutorAddress({ + network: networkName, + chainId, + address: expectedAddress, + }); + return; + } + + console.log('Deploying EtherealExecutor via CREATE3...'); + const create3Deployment = await create3Factory.deployCreate3( + ETHEREAL_EXECUTOR_CREATE3_SALT, + initcode, + ); + console.log('CREATE3 deployment tx:', create3Deployment.hash); + + const receipt = await create3Deployment.wait(); + const executorAddress = decodeCreate3DeploymentFromTxReceipt({ receipt }); + if (!executorAddress) { + throw new Error('EtherealExecutor address not found in CREATE3 deployment receipt'); + } + + if (executorAddress.toLowerCase() !== expectedAddress.toLowerCase()) { + throw new Error( + `Deployed address ${executorAddress} does not match staticCall ${expectedAddress}`, + ); + } + + await assertEtherealExecutorDeployment({ + address: executorAddress, + owner, + calldataExecutor, + exchangeGateway, + depositToken, + }); + + console.log('\n=== Deployment Summary ==='); + console.log(`EtherealExecutor (${networkName}): ${executorAddress}`); + await persistEtherealExecutorAddress({ + network: networkName, + chainId, + address: executorAddress, + }); + + if (chainId !== 31337) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + + try { + await hre.run('verify:verify', { + address: executorAddress, + constructorArguments: [owner, rescueAddress, calldataExecutor, exchangeGateway, depositToken], + }); + console.log('Contract verified on block explorer'); + } catch (err) { + console.warn( + 'Contract verification failed (deployment succeeded):', + err instanceof Error ? err.message : err, + ); + } + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/deploy/deployHypercoreExecutor.ts b/scripts/deploy/deployHypercoreExecutor.ts new file mode 100644 index 0000000..358d713 --- /dev/null +++ b/scripts/deploy/deployHypercoreExecutor.ts @@ -0,0 +1,292 @@ +/** + * Deployment script for HypercoreDepositExecutor via CreateX CREATE3. + * + * Deploy on HyperEVM only — the sole destination chain for the Hypercore deposit executor. + * + * Usage: + * npx hardhat run scripts/deploy/deployHypercoreExecutor.ts --network hyperEvm + * + * Required env vars: + * DEPLOYER_PRIVATE_KEY — deployer wallet private key + * + * Optional env vars: + * OWNER_ADDRESS — HypercoreDepositExecutor owner/EXECUTOR_ROLE holder; defaults to + * DEFAULT_OWNER_ADDRESS or deployer + * RESCUE_ADDRESS — RESCUE_ROLE holder; defaults to the resolved owner address + */ + +import hre, { ethers } from 'hardhat'; +import { + CREATE_X_FACTORY, + Create3ABI, + HYPERCORE_EXECUTOR_CREATE3_SALT, + HYPERCORE_EXECUTOR_CREATE3_SALT_TEXT, + CALLDATA_EXECUTOR_EXPECTED_ADDRESS, + decodeCreate3DeploymentFromTxReceipt, + hasContractBytecode, +} from './create3'; +import { findDeploymentRegistryRow } from './deploymentRegistry'; +import { writeHypercoreExecutorAddress } from './hypercoreExecutorAddresses'; +import { HYPERCORE_EXECUTOR_CHAIN_CONFIG } from './hypercoreExecutorConfig'; + +const DEFAULT_OWNER_ADDRESS = '0x0E1B5AB67aF1c99F8c7Ebc71f41f75D4D6211e53'; + +const ADDR_HEX_RE = /^0x[a-fA-F0-9]{40}$/; + +function resolveOwnerAddress(deployerAddress: string): string { + const envOwner = process.env.OWNER_ADDRESS?.trim(); + if (envOwner && ADDR_HEX_RE.test(envOwner)) { + return envOwner; + } + + if (DEFAULT_OWNER_ADDRESS && ADDR_HEX_RE.test(DEFAULT_OWNER_ADDRESS)) { + return DEFAULT_OWNER_ADDRESS; + } + + return deployerAddress; +} + +function resolveRescueAddress(ownerAddress: string): string { + const envRescue = process.env.RESCUE_ADDRESS?.trim(); + if (envRescue && ADDR_HEX_RE.test(envRescue)) { + return envRescue; + } + + return ownerAddress; +} + +function resolveCalldataExecutorAddress(chainId: number): string { + const row = findDeploymentRegistryRow(chainId); + const registryAddress = row?.calldataExecutorAddress?.trim(); + if (registryAddress && ADDR_HEX_RE.test(registryAddress)) { + return registryAddress; + } + + return CALLDATA_EXECUTOR_EXPECTED_ADDRESS; +} + +async function getHypercoreExecutorInitcode(params: { + owner: string; + rescueAddress: string; + calldataExecutor: string; + coreDepositWallet: string; + depositToken: string; +}): Promise { + const factory = await ethers.getContractFactory('HypercoreDepositExecutor'); + const deployTransaction = await factory.getDeployTransaction( + params.owner, + params.rescueAddress, + params.calldataExecutor, + params.coreDepositWallet, + params.depositToken, + ); + + if (!deployTransaction.data) { + throw new Error('HypercoreDepositExecutor deploy transaction data is empty'); + } + + return deployTransaction.data; +} + +async function assertHypercoreExecutorDeployment(params: { + address: string; + owner: string; + calldataExecutor: string; + coreDepositWallet: string; + depositToken: string; +}): Promise { + const contract = new ethers.Contract( + params.address, + [ + 'function owner() view returns (address)', + 'function CALLDATA_EXECUTOR() view returns (address)', + 'function coreDepositWallet() view returns (address)', + 'function DEPOSIT_TOKEN() view returns (address)', + ], + ethers.provider, + ); + + const [owner, calldataExecutor, coreDepositWallet, depositToken] = + await Promise.all([ + contract.owner() as Promise, + contract.CALLDATA_EXECUTOR() as Promise, + contract.coreDepositWallet() as Promise, + contract.DEPOSIT_TOKEN() as Promise, + ]); + + if (owner.toLowerCase() !== params.owner.toLowerCase()) { + throw new Error( + `HypercoreDepositExecutor owner mismatch: expected ${params.owner}, got ${owner}`, + ); + } + + if (calldataExecutor.toLowerCase() !== params.calldataExecutor.toLowerCase()) { + throw new Error( + `HypercoreDepositExecutor CALLDATA_EXECUTOR mismatch: expected ${params.calldataExecutor}, got ${calldataExecutor}`, + ); + } + + if (coreDepositWallet.toLowerCase() !== params.coreDepositWallet.toLowerCase()) { + throw new Error( + `HypercoreDepositExecutor coreDepositWallet mismatch: expected ${params.coreDepositWallet}, got ${coreDepositWallet}`, + ); + } + + if (depositToken.toLowerCase() !== params.depositToken.toLowerCase()) { + throw new Error( + `HypercoreDepositExecutor DEPOSIT_TOKEN mismatch: expected ${params.depositToken}, got ${depositToken}`, + ); + } +} + +function printBackendConfigSnippet(chainId: number, address: string): void { + console.log('\n=== bungee-backend config snippet ==='); + console.log( + 'Update executorAddress in bungee-backend/src/modules/bungee-auto/constants.ts (HYPERCORE_DEPOSIT_CONFIG):', + ); + console.log(` [${chainId}]: '${address}',`); +} + +async function persistHypercoreExecutorAddress(params: { + network: string; + chainId: number; + address: string; +}): Promise { + const filePath = await writeHypercoreExecutorAddress( + params.network, + params.address, + ); + console.log('Deployment JSON:', filePath); + printBackendConfigSnippet(params.chainId, params.address); +} + +async function main() { + const [deployer] = await ethers.getSigners(); + const networkName = hre.network.name; + const chainId = Number((await ethers.provider.getNetwork()).chainId); + + const chainConfig = HYPERCORE_EXECUTOR_CHAIN_CONFIG[chainId]; + if (!chainConfig) { + throw new Error( + `Chain ${chainId} is not configured for HypercoreDepositExecutor deployment`, + ); + } + + console.log('Deployer: ', deployer.address); + console.log('Network: ', networkName); + console.log('Chain ID: ', chainId); + console.log('CREATE3 salt: ', HYPERCORE_EXECUTOR_CREATE3_SALT_TEXT); + console.log(''); + + const owner = resolveOwnerAddress(deployer.address); + const rescueAddress = resolveRescueAddress(owner); + const calldataExecutor = resolveCalldataExecutorAddress(chainId); + const { coreDepositWallet, depositToken } = chainConfig; + + console.log('Owner: ', owner); + console.log('RescueAddress: ', rescueAddress); + console.log('CalldataExecutor: ', calldataExecutor); + console.log('CoreDepositWallet:', coreDepositWallet); + console.log('DepositToken: ', depositToken); + console.log(''); + + const initcode = await getHypercoreExecutorInitcode({ + owner, + rescueAddress, + calldataExecutor, + coreDepositWallet, + depositToken, + }); + + const create3Factory = new ethers.Contract(CREATE_X_FACTORY, Create3ABI, deployer); + + const expectedAddress = (await create3Factory.deployCreate3.staticCall( + HYPERCORE_EXECUTOR_CREATE3_SALT, + initcode, + )) as string; + + console.log('Expected address:', expectedAddress); + + const existingBytecode = await ethers.provider.getCode(expectedAddress); + if (hasContractBytecode(existingBytecode)) { + console.log(`HypercoreDepositExecutor already deployed at ${expectedAddress}`); + await assertHypercoreExecutorDeployment({ + address: expectedAddress, + owner, + calldataExecutor, + coreDepositWallet, + depositToken, + }); + await persistHypercoreExecutorAddress({ + network: networkName, + chainId, + address: expectedAddress, + }); + return; + } + + console.log('Deploying HypercoreDepositExecutor via CREATE3...'); + const create3Deployment = await create3Factory.deployCreate3( + HYPERCORE_EXECUTOR_CREATE3_SALT, + initcode, + ); + console.log('CREATE3 deployment tx:', create3Deployment.hash); + + const receipt = await create3Deployment.wait(); + const executorAddress = decodeCreate3DeploymentFromTxReceipt({ receipt }); + if (!executorAddress) { + throw new Error( + 'HypercoreDepositExecutor address not found in CREATE3 deployment receipt', + ); + } + + if (executorAddress.toLowerCase() !== expectedAddress.toLowerCase()) { + throw new Error( + `Deployed address ${executorAddress} does not match staticCall ${expectedAddress}`, + ); + } + + await assertHypercoreExecutorDeployment({ + address: executorAddress, + owner, + calldataExecutor, + coreDepositWallet, + depositToken, + }); + + console.log('\n=== Deployment Summary ==='); + console.log(`HypercoreDepositExecutor (${networkName}): ${executorAddress}`); + await persistHypercoreExecutorAddress({ + network: networkName, + chainId, + address: executorAddress, + }); + + if (chainId !== 31337) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + + try { + await hre.run('verify:verify', { + address: executorAddress, + constructorArguments: [ + owner, + rescueAddress, + calldataExecutor, + coreDepositWallet, + depositToken, + ], + }); + console.log('Contract verified on block explorer'); + } catch (err) { + console.warn( + 'Contract verification failed (deployment succeeded):', + err instanceof Error ? err.message : err, + ); + } + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/deploy/etherealExecutorAddresses.ts b/scripts/deploy/etherealExecutorAddresses.ts new file mode 100644 index 0000000..c0d37ec --- /dev/null +++ b/scripts/deploy/etherealExecutorAddresses.ts @@ -0,0 +1,76 @@ +import { mkdir, readFile, writeFile } from 'fs/promises'; +import { dirname, resolve } from 'path'; + +export type EtherealExecutorDeployments = { + EtherealExecutor: string; +}; + +export function etherealExecutorAddressesPath( + network: string, + stage = 'prod', +): string { + return resolve( + process.cwd(), + 'deployments', + stage, + 'addresses', + `${network}.json`, + ); +} + +export async function readEtherealExecutorAddress( + network: string, + stage = 'prod', +): Promise { + const filePath = etherealExecutorAddressesPath(network, stage); + + try { + const raw = await readFile(filePath, 'utf8'); + const deployments = JSON.parse(raw) as Partial; + const address = deployments.EtherealExecutor?.trim(); + + if (!address) { + return undefined; + } + + return address; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + + throw err; + } +} + +/** + * Persists EtherealExecutor address for a network. + */ +export async function writeEtherealExecutorAddress( + network: string, + address: string, + stage = 'prod', +): Promise { + const filePath = etherealExecutorAddressesPath(network, stage); + let deployments: Partial = {}; + + try { + const raw = await readFile(filePath, 'utf8'); + deployments = JSON.parse(raw) as Partial; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + + deployments.EtherealExecutor = address; + + await mkdir(dirname(filePath), { recursive: true }); + await writeFile( + filePath, + `${JSON.stringify(deployments, null, 2)}\n`, + 'utf8', + ); + + return filePath; +} diff --git a/scripts/deploy/etherealExecutorConfig.ts b/scripts/deploy/etherealExecutorConfig.ts new file mode 100644 index 0000000..7f6f631 --- /dev/null +++ b/scripts/deploy/etherealExecutorConfig.ts @@ -0,0 +1,23 @@ +/** + * Ethereal-chain config for EtherealExecutor deployment. + * Ethereal is a single-chain (appchain) deployment target — USDe is its native gas token, + * so `depositToken` uses the native sentinel address. + */ +import { CHAIN_IDS } from '../e2e/config'; + +/// @dev native sentinel — matches CurrencyLib.NATIVE_TOKEN_ADDRESS in src/common/lib/CurrencyLib.sol +export const NATIVE_TOKEN_ADDRESS = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; + +export type EtherealExecutorChainConfig = { + chainId: number; + exchangeGateway: string; + depositToken: string; +}; + +export const ETHEREAL_EXECUTOR_CHAIN_CONFIG: Record = { + [CHAIN_IDS.ETHEREAL]: { + chainId: CHAIN_IDS.ETHEREAL, + exchangeGateway: '0xB3cDC82035C495c484C9fF11eD5f3Ff6d342e3cc', + depositToken: NATIVE_TOKEN_ADDRESS, + }, +}; diff --git a/scripts/deploy/hypercoreExecutorAddresses.ts b/scripts/deploy/hypercoreExecutorAddresses.ts new file mode 100644 index 0000000..ca9addd --- /dev/null +++ b/scripts/deploy/hypercoreExecutorAddresses.ts @@ -0,0 +1,73 @@ +import { mkdir, readFile, writeFile } from 'fs/promises'; +import { dirname, resolve } from 'path'; + +export type HypercoreExecutorDeployments = { + HypercoreDepositExecutor: string; +}; + +export function hypercoreExecutorAddressesPath( + network: string, + stage = 'prod', +): string { + return resolve( + process.cwd(), + 'deployments', + stage, + 'addresses', + `${network}.json`, + ); +} + +export async function readHypercoreExecutorAddress( + network: string, + stage = 'prod', +): Promise { + const filePath = hypercoreExecutorAddressesPath(network, stage); + + try { + const raw = await readFile(filePath, 'utf8'); + const deployments = JSON.parse(raw) as Partial; + const address = deployments.HypercoreDepositExecutor?.trim(); + + if (!address) { + return undefined; + } + + return address; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + + throw err; + } +} + +export async function writeHypercoreExecutorAddress( + network: string, + address: string, + stage = 'prod', +): Promise { + const filePath = hypercoreExecutorAddressesPath(network, stage); + let deployments: Record = {}; + + try { + const raw = await readFile(filePath, 'utf8'); + deployments = JSON.parse(raw) as Record; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + + deployments.HypercoreDepositExecutor = address; + + await mkdir(dirname(filePath), { recursive: true }); + await writeFile( + filePath, + `${JSON.stringify(deployments, null, 2)}\n`, + 'utf8', + ); + + return filePath; +} diff --git a/scripts/deploy/hypercoreExecutorConfig.ts b/scripts/deploy/hypercoreExecutorConfig.ts new file mode 100644 index 0000000..f021b55 --- /dev/null +++ b/scripts/deploy/hypercoreExecutorConfig.ts @@ -0,0 +1,21 @@ +/** + * HyperEVM-chain config for HypercoreDepositExecutor deployment. + */ +import { CHAIN_IDS } from '../e2e/config'; + +export type HypercoreExecutorChainConfig = { + chainId: number; + coreDepositWallet: string; + depositToken: string; +}; + +export const HYPERCORE_EXECUTOR_CHAIN_CONFIG: Record< + number, + HypercoreExecutorChainConfig +> = { + [CHAIN_IDS.HYPEREVM]: { + chainId: CHAIN_IDS.HYPEREVM, + coreDepositWallet: '0x6B9E773128f453f5c2C60935Ee2DE2CBc5390A24', + depositToken: '0xb88339CB7199b77E23DB6E890353E22632Ba630f', + }, +}; diff --git a/scripts/deploy/verifyCctpClaimExecutorBatch.ts b/scripts/deploy/verifyCctpClaimExecutorBatch.ts index def8ef7..d6a09ae 100644 --- a/scripts/deploy/verifyCctpClaimExecutorBatch.ts +++ b/scripts/deploy/verifyCctpClaimExecutorBatch.ts @@ -60,6 +60,7 @@ const EXPLORERS_BY_CHAIN_ID = new Map([ [43114, { verifier: 'etherscan', chainArg: '43114' }], [57073, { verifier: 'custom', chainArg: '57073', verifierUrl: 'https://explorer.inkonchain.com/api' }], [59144, { verifier: 'etherscan', chainArg: '59144' }], + [5042, { verifier: 'sourcify', chainArg: '5042' }], ]); function isAlreadyVerified(output: string): boolean { diff --git a/scripts/e2e/config.ts b/scripts/e2e/config.ts index e4f926a..3602f39 100644 --- a/scripts/e2e/config.ts +++ b/scripts/e2e/config.ts @@ -45,6 +45,8 @@ export const CHAIN_IDS = { /** Plume mainnet — Relay WPLUME → Base USDC e2e. */ PLUME: 98866, KATANA: 747474, + /** Ethereal mainnet — deposit-only destination chain for EtherealExecutor. */ + ETHEREAL: 5064014, } as const; /** Base URL for explorer transaction pages: `${prefix}${txHash}`. */ diff --git a/src/executors/EtherealExecutor.sol b/src/executors/EtherealExecutor.sol new file mode 100644 index 0000000..92699ca --- /dev/null +++ b/src/executors/EtherealExecutor.sol @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.34; + +import {ReentrancyGuard} from "solady/src/utils/ReentrancyGuard.sol"; + +import {IBungeeExecutor} from "../interfaces/IBungeeExecutor.sol"; +import {AccessControl} from "../common/utils/AccessControl.sol"; +import {RescueFundsLib} from "../common/lib/RescueFundsLib.sol"; +import {CurrencyLib} from "../common/lib/CurrencyLib.sol"; +import {RESCUE_ROLE} from "../common/AccessRoles.sol"; + +// ============ Interfaces ============ // + +/// @notice Minimal interface for ICollateralManager, used by IExchangeGateway. +interface ICollateralManager { + struct DepositOnBehalfRequest { + address account; + bytes32 subaccount; + bytes32 tokenName; + uint256 amount; + } +} + +/// @notice Minimal interface for Ethereal's ExchangeGateway deposit entrypoint. +interface IExchangeGateway { + function depositOnBehalf( + ICollateralManager.DepositOnBehalfRequest[] calldata params, + bytes32 referralCode + ) external payable; +} + +/// @title EtherealExecutor +/// @notice Destination-side IBungeeExecutor that deposits bridged USDe into Ethereal's +/// ExchangeGateway on behalf of the user. +/// @dev Single-output port of marketplace's EtherealDepositHandler.sol for the OpenRouter +/// BungeeReceiver/CalldataExecutor system. USDe is Ethereal's native gas token, so bridged +/// funds arrive here as native value and are forwarded via `depositOnBehalf{value: amount}`. +/// In case the destination execution reverts, there is an access-controlled retry function +/// as a fallback in the unlikely case funds are left stranded in this contract. +contract EtherealExecutor is IBungeeExecutor, AccessControl, ReentrancyGuard { + // ============ Errors ============ + + /// @notice Thrown when a quote has already been executed + error QuoteAlreadyExecuted(); + + /// @notice Thrown when the payload cannot be decoded properly + error InvalidPayload(); + + /// @notice Thrown when the token provided doesn't match the expected deposit token + error InvalidToken(); + + /// @notice Thrown when the caller is not the OpenRouter CalldataExecutor + error OnlyCalldataExecutor(); + + // ============ Events ============ + + /// @notice Emitted when a deposit to Ethereal is successfully executed + /// @param quoteId The unique correlation ID of the OpenRouter quote + /// @param recipient The address receiving tokens on Ethereal + /// @param token The token deposited + /// @param amount The amount deposited + event DepositExecuted( + bytes32 indexed quoteId, + address indexed recipient, + address indexed token, + uint256 amount + ); + + // ============ Constants ============ + + /// @notice Role identifier for addresses authorized to manually execute deposits + bytes32 public constant EXECUTOR_ROLE = keccak256("EXECUTOR_ROLE"); + + /// @notice referralCode for Bungee + bytes32 public constant BUNGEE_REFERRAL_CODE = bytes32("BUNGEE"); + + /// @notice bytes32 identifier for primary account + bytes32 public constant PRIMARY_SUB_ACCOUNT = bytes32("primary"); + + /// @notice bytes32 identifier USDe token name + bytes32 public constant USD_TOKEN_NAME = bytes32("USD"); + + // ============ State Variables ============ // + + /// @notice The address of the OpenRouter CalldataExecutor contract + address public immutable CALLDATA_EXECUTOR; + + /// @notice The token accepted for deposits (native sentinel — USDe is Ethereal's gas token) + address public immutable DEPOSIT_TOKEN; + + /// @notice The ExchangeGateway contract for depositing to Ethereal + IExchangeGateway public exchangeGateway; + + /// @notice Mapping to track executed quotes (prevents replay) + mapping(bytes32 quoteId => bool executed) public quoteExecuted; + + // ============ Constructor ============ // + + /** + * @param _owner The owner address with admin capabilities + * @param _rescueAddress The address authorized to rescue stuck funds + * @param _calldataExecutor The address of the OpenRouter CalldataExecutor contract + * @param _exchangeGateway The address of the ExchangeGateway contract on Ethereal + * @param _depositToken The token accepted for deposits (native sentinel for USDe on Ethereal) + */ + constructor( + address _owner, + address _rescueAddress, + address _calldataExecutor, + address _exchangeGateway, + address _depositToken + ) AccessControl(_owner) { + CALLDATA_EXECUTOR = _calldataExecutor; + exchangeGateway = IExchangeGateway(_exchangeGateway); + DEPOSIT_TOKEN = _depositToken; + + _grantRole(EXECUTOR_ROLE, _owner); + _grantRole(RESCUE_ROLE, _rescueAddress); + } + + // ============ External Functions ============ // + + /// @inheritdoc IBungeeExecutor + /// @dev Called by CalldataExecutor after BungeeReceiver forwards bridged funds to this + /// contract. `callData` is expected to be `abi.encode(recipient)`. + function executeData( + bytes32 quoteId, + uint256 amount, + address token, + bytes calldata callData + ) external payable override nonReentrant { + if (msg.sender != CALLDATA_EXECUTOR) revert OnlyCalldataExecutor(); + if (quoteExecuted[quoteId]) revert QuoteAlreadyExecuted(); + if (token != DEPOSIT_TOKEN) revert InvalidToken(); + + address recipient = _decodeAndValidatePayload(callData); + quoteExecuted[quoteId] = true; + + _depositOnBehalf(recipient, amount); + + emit DepositExecuted(quoteId, recipient, token, amount); + } + + /** + * @notice Manually executes a deposit to Ethereal for recovery purposes + * @dev Can only be called by addresses with EXECUTOR_ROLE. Used when executeData fails + * during fulfillment and funds are stuck in the contract. + * @param quoteId Unique correlation ID of the OpenRouter quote (used for replay protection) + * @param recipient The address to receive tokens on Ethereal + * @param amount The amount to deposit + */ + function executeRequest( + bytes32 quoteId, + address recipient, + uint256 amount + ) external onlyRole(EXECUTOR_ROLE) nonReentrant { + if (quoteExecuted[quoteId]) revert QuoteAlreadyExecuted(); + quoteExecuted[quoteId] = true; + + _depositOnBehalf(recipient, amount); + + emit DepositExecuted(quoteId, recipient, DEPOSIT_TOKEN, amount); + } + + // ============ Owner Only Functions ============ // + + /** + * @notice Updates the ExchangeGateway address + * @dev Only callable by owner + * @param _newExchangeGateway The new ExchangeGateway address + */ + function updateExchangeGateway(address _newExchangeGateway) external onlyOwner { + exchangeGateway = IExchangeGateway(_newExchangeGateway); + } + + // ============ View Functions ============ // + + /** + * @notice Checks if a quote has been executed + * @param quoteId The quote ID to check + * @return True if the quote has been executed + */ + function isQuoteExecuted(bytes32 quoteId) external view returns (bool) { + return quoteExecuted[quoteId]; + } + + // ============ Rescue Functions ============ // + + /** + * @notice Rescues funds from the contract if they are stuck + * @dev Can only be called by addresses with RESCUE_ROLE + * @param token The address of the token to rescue + * @param to The address to send rescued funds to + * @param amount The amount to rescue + */ + function rescueFunds(address token, address to, uint256 amount) external onlyRole(RESCUE_ROLE) { + RescueFundsLib.rescueFunds(token, to, amount); + } + + /// @notice receive native token (i.e USDe) + receive() external payable {} + + // ============ Internal Functions ============ // + + function _depositOnBehalf(address recipient, uint256 amount) internal { + ICollateralManager.DepositOnBehalfRequest[] memory requests = new ICollateralManager.DepositOnBehalfRequest[]( + 1 + ); + + requests[0] = ICollateralManager.DepositOnBehalfRequest({ + account: recipient, + subaccount: PRIMARY_SUB_ACCOUNT, + tokenName: USD_TOKEN_NAME, + amount: amount + }); + + exchangeGateway.depositOnBehalf{value: amount}(requests, BUNGEE_REFERRAL_CODE); + } + + /** + * @notice Decodes and validates the callData payload for a deposit request + * @dev Reverts with InvalidPayload if callData is too short + * @param callData The raw payload bytes expected to be abi.encode(address) + * @return recipient The decoded recipient address + */ + function _decodeAndValidatePayload(bytes calldata callData) internal pure returns (address recipient) { + if (callData.length < 32) revert InvalidPayload(); + recipient = abi.decode(callData, (address)); + } +} diff --git a/src/executors/HypercoreDepositExecutor.sol b/src/executors/HypercoreDepositExecutor.sol new file mode 100644 index 0000000..d0f6e93 --- /dev/null +++ b/src/executors/HypercoreDepositExecutor.sol @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.34; + +import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; +import {ReentrancyGuard} from "solady/src/utils/ReentrancyGuard.sol"; + +import {IBungeeExecutor} from "../interfaces/IBungeeExecutor.sol"; +import {AccessControl} from "../common/utils/AccessControl.sol"; +import {RescueFundsLib} from "../common/lib/RescueFundsLib.sol"; +import {RESCUE_ROLE} from "../common/AccessRoles.sol"; + +// ============ Interfaces ============ // + +/// @notice Minimal interface for Hypercore's CoreDepositWallet. +interface ICoreDepositWallet { + /** + * @notice Deposits tokens to credit a specific recipient on Hypercore. + * @param recipient The address receiving the tokens on Hypercore. + * @param amount The amount of tokens being deposited. + * @param destinationDex The destination dex on Hypercore (0 for Core Perps, uint32.max for Core Spot). + */ + function depositFor(address recipient, uint256 amount, uint32 destinationDex) external; +} + +/// @title HypercoreDepositExecutor +/// @notice Destination-side IBungeeExecutor that forwards bridged USDC on HyperEVM into +/// Hypercore's CoreDepositWallet on behalf of the user. +/// @dev Single-output port of marketplace's HypercoreDepositBungeeExecutor.sol for the OpenRouter +/// BungeeReceiver/CalldataExecutor system. Only supports USDC on HyperEVM — the token accepted +/// by CoreDepositWallet. In case the destination execution reverts, there is an access-controlled +/// retry function as a fallback in the unlikely case funds are left stranded in this contract — +/// CalldataExecutor's `excessivelySafeCall` swallows executor reverts silently on-chain, so this +/// is load-bearing, not a nice-to-have. +contract HypercoreDepositExecutor is IBungeeExecutor, AccessControl, ReentrancyGuard { + // ============ Errors ============ + + /// @notice Thrown when a quote has already been executed + error QuoteAlreadyExecuted(); + + /// @notice Thrown when the payload cannot be decoded properly + error InvalidPayload(); + + /// @notice Thrown when the token provided doesn't match the expected deposit token + error InvalidToken(); + + /// @notice Thrown when the caller is not the OpenRouter CalldataExecutor + error OnlyCalldataExecutor(); + + // ============ Events ============ + + /// @notice Emitted when a deposit to Hypercore is successfully executed + /// @param quoteId The unique correlation ID of the OpenRouter quote + /// @param recipient The address receiving tokens on Hypercore + /// @param token The token deposited + /// @param amount The amount deposited + /// @param destinationDex The destination dex on Hypercore (0 for Core Perps, uint32.max for Core Spot) + event DepositExecuted( + bytes32 indexed quoteId, + address indexed recipient, + address indexed token, + uint256 amount, + uint32 destinationDex + ); + + // ============ Constants ============ + + /// @notice Role identifier for addresses authorized to manually execute deposits for recovery + bytes32 public constant EXECUTOR_ROLE = keccak256("EXECUTOR_ROLE"); + + // ============ State Variables ============ // + + /// @notice The address of the OpenRouter CalldataExecutor contract + address public immutable CALLDATA_EXECUTOR; + + /// @notice The token accepted for deposits (USDC on HyperEVM) + address public immutable DEPOSIT_TOKEN; + + /// @notice The CoreDepositWallet contract for depositing to Hypercore + ICoreDepositWallet public coreDepositWallet; + + /// @notice Mapping to track executed quotes (prevents replay) + mapping(bytes32 quoteId => bool executed) public quoteExecuted; + + // ============ Constructor ============ // + + /** + * @param _owner The owner address with admin capabilities + * @param _rescueAddress The address authorized to rescue stuck funds + * @param _calldataExecutor The address of the OpenRouter CalldataExecutor contract + * @param _coreDepositWallet The address of the CoreDepositWallet contract on HyperEVM + * @param _depositToken The token accepted for deposits (USDC on HyperEVM) + */ + constructor( + address _owner, + address _rescueAddress, + address _calldataExecutor, + address _coreDepositWallet, + address _depositToken + ) AccessControl(_owner) { + CALLDATA_EXECUTOR = _calldataExecutor; + coreDepositWallet = ICoreDepositWallet(_coreDepositWallet); + DEPOSIT_TOKEN = _depositToken; + + _grantRole(EXECUTOR_ROLE, _owner); + _grantRole(RESCUE_ROLE, _rescueAddress); + } + + // ============ External Functions ============ // + + /// @inheritdoc IBungeeExecutor + /// @dev Called by CalldataExecutor after BungeeReceiver forwards bridged USDC to this + /// contract. `callData` is expected to be `abi.encode(recipient, destinationDex)` — same + /// encoding the legacy marketplace-side HypercoreDepositBungeeExecutor already uses. + function executeData( + bytes32 quoteId, + uint256 amount, + address token, + bytes calldata callData + ) external payable override nonReentrant { + if (msg.sender != CALLDATA_EXECUTOR) revert OnlyCalldataExecutor(); + if (quoteExecuted[quoteId]) revert QuoteAlreadyExecuted(); + if (token != DEPOSIT_TOKEN) revert InvalidToken(); + + (address recipient, uint32 destinationDex) = _decodeAndValidatePayload(callData); + quoteExecuted[quoteId] = true; + + _depositFor(recipient, amount, destinationDex); + + emit DepositExecuted(quoteId, recipient, token, amount, destinationDex); + } + + /** + * @notice Manually executes a deposit to Hypercore for recovery purposes + * @dev Can only be called by addresses with EXECUTOR_ROLE. Used when executeData fails + * during fulfillment and funds are stuck in the contract. + * @param quoteId Unique correlation ID of the OpenRouter quote (used for replay protection) + * @param recipient The address to receive tokens on Hypercore + * @param amount The amount to deposit + * @param destinationDex The destination dex on Hypercore (0 for Core Perps, uint32.max for Core Spot) + */ + function executeRequest( + bytes32 quoteId, + address recipient, + uint256 amount, + uint32 destinationDex + ) external onlyRole(EXECUTOR_ROLE) nonReentrant { + if (quoteExecuted[quoteId]) revert QuoteAlreadyExecuted(); + quoteExecuted[quoteId] = true; + + _depositFor(recipient, amount, destinationDex); + + emit DepositExecuted(quoteId, recipient, DEPOSIT_TOKEN, amount, destinationDex); + } + + // ============ Owner Only Functions ============ // + + /** + * @notice Updates the CoreDepositWallet address + * @dev Only callable by owner + * @param _newCoreDepositWallet The new CoreDepositWallet address + */ + function updateCoreDepositWallet(address _newCoreDepositWallet) external onlyOwner { + coreDepositWallet = ICoreDepositWallet(_newCoreDepositWallet); + } + + // ============ View Functions ============ // + + /** + * @notice Checks if a quote has been executed + * @param quoteId The quote ID to check + * @return True if the quote has been executed + */ + function isQuoteExecuted(bytes32 quoteId) external view returns (bool) { + return quoteExecuted[quoteId]; + } + + // ============ Rescue Functions ============ // + + /** + * @notice Rescues funds from the contract if they are stuck + * @dev Can only be called by addresses with RESCUE_ROLE + * @param token The address of the token to rescue + * @param to The address to send rescued funds to + * @param amount The amount to rescue + */ + function rescueFunds(address token, address to, uint256 amount) external onlyRole(RESCUE_ROLE) { + RescueFundsLib.rescueFunds(token, to, amount); + } + + // ============ Internal Functions ============ // + + function _depositFor(address recipient, uint256 amount, uint32 destinationDex) internal { + SafeTransferLib.safeApproveWithRetry(DEPOSIT_TOKEN, address(coreDepositWallet), amount); + coreDepositWallet.depositFor(recipient, amount, destinationDex); + } + + /** + * @notice Decodes and validates the callData payload for a deposit request + * @dev Reverts with InvalidPayload if callData is too short + * @param callData The raw payload bytes expected to be abi.encode(address, uint32) + * @return recipient The decoded recipient address + * @return destinationDex The decoded destination dex on Hypercore + */ + function _decodeAndValidatePayload( + bytes calldata callData + ) internal pure returns (address recipient, uint32 destinationDex) { + if (callData.length < 64) revert InvalidPayload(); + (recipient, destinationDex) = abi.decode(callData, (address, uint32)); + } +}