diff --git a/config/chainflip.json b/config/chainflip.json new file mode 100644 index 000000000..c589d50ea --- /dev/null +++ b/config/chainflip.json @@ -0,0 +1,8 @@ +{ + "mainnet": { + "chainflipVault": "0xF5e10380213880111522dd0efD3dbb45b9f62Bcc" + }, + "arbitrum": { + "chainflipVault": "0x79001a5e762f3bEFC8e5871b42F6734e00498920" + } +} diff --git a/deployments/_deployments_log_file.json b/deployments/_deployments_log_file.json index 44f97894e..1c12543d4 100644 --- a/deployments/_deployments_log_file.json +++ b/deployments/_deployments_log_file.json @@ -29722,5 +29722,37 @@ ] } } + }, + "ChainflipFacet": { + "arbitrum": { + "staging": { + "1.0.0": [ + { + "ADDRESS": "0xDd661337B48BEA5194F6d26F2C59fF0855E15289", + "OPTIMIZER_RUNS": "1000000", + "TIMESTAMP": "2025-02-17 17:00:59", + "CONSTRUCTOR_ARGS": "0x00000000000000000000000079001a5e762f3befc8e5871b42f6734e00498920", + "SALT": "", + "VERIFIED": "true" + } + ] + } + } + }, + "ReceiverChainflip": { + "mainnet": { + "staging": { + "1.0.0": [ + { + "ADDRESS": "0xf0CfD9A8Fb7954C9F9948b94AD6f1Cc27Faff54c", + "OPTIMIZER_RUNS": "1000000", + "TIMESTAMP": "2025-02-18 12:28:25", + "CONSTRUCTOR_ARGS": "0x000000000000000000000000156cebba59deb2cb23742f70dcb0a11cc775591f000000000000000000000000be27f03c8e6a61e2a4b1ee7940dbcb9204744d1c000000000000000000000000f5e10380213880111522dd0efd3dbb45b9f62bcc", + "SALT": "", + "VERIFIED": "true" + } + ] + } + } } } diff --git a/deployments/arbitrum.diamond.staging.json b/deployments/arbitrum.diamond.staging.json index ee64127be..2ca6a471e 100644 --- a/deployments/arbitrum.diamond.staging.json +++ b/deployments/arbitrum.diamond.staging.json @@ -138,12 +138,20 @@ "Version": "1.0.0" }, "0xE15C7585636e62b88bA47A40621287086E0c2E33": { - "Name": "", - "Version": "" + "Name": "DeBridgeDlnFacet", + "Version": "1.0.0" }, "0x08BfAc22A3B41637edB8A7920754fDb30B18f740": { "Name": "AcrossFacetV3", "Version": "1.1.0" + }, + "0x3aF0c2dB91f75f05493E51cFcF92eC5276bc85F8": { + "Name": "", + "Version": "" + }, + "0xDd661337B48BEA5194F6d26F2C59fF0855E15289": { + "Name": "ChainflipFacet", + "Version": "1.0.0" } }, "Periphery": { @@ -153,8 +161,9 @@ "GasZipPeriphery": "", "LiFiDEXAggregator": "", "LiFuelFeeCollector": "0x94EA56D8049e93E0308B9c7d1418Baf6A7C68280", - "Permit2Proxy": "0x6FC01BC9Ff6Cdab694Ec8Ca41B21a2F04C8c37E5", + "Permit2Proxy": "0xb33Fe241BEd9bf5F694101D7498F63a0d060F999", "ReceiverAcrossV3": "0xe4F3DEF14D61e47c696374453CD64d438FD277F8", + "ReceiverChainflip": "", "Receiver": "0x36E9B2E8A627474683eF3b1E9Df26D2bF04396f3", "ReceiverStargateV2": "", "RelayerCelerIM": "0xa1Ed8783AC96385482092b82eb952153998e9b70", diff --git a/deployments/arbitrum.staging.json b/deployments/arbitrum.staging.json index 96b7f9fe6..3c3dc84e4 100644 --- a/deployments/arbitrum.staging.json +++ b/deployments/arbitrum.staging.json @@ -35,7 +35,6 @@ "DeBridgeDlnFacet": "0xE15C7585636e62b88bA47A40621287086E0c2E33", "MayanFacet": "0xd596C903d78870786c5DB0E448ce7F87A65A0daD", "StandardizedCallFacet": "0xA7ffe57ee70Ac4998e9E9fC6f17341173E081A8f", - "MayanFacet": "0xd596C903d78870786c5DB0E448ce7F87A65A0daD", "GenericSwapFacetV3": "0xFf6Fa203573Baaaa4AE375EB7ac2819d539e16FF", "CalldataVerificationFacet": "0x90B5b319cA20D9E466cB5b843952363C34d1b54E", "AcrossFacetPacked": "0x7A3770a9504924d99D38BBba4F0116B756393Eb3", @@ -51,5 +50,6 @@ "AcrossFacetV3": "0x08BfAc22A3B41637edB8A7920754fDb30B18f740", "ReceiverAcrossV3": "0xe4F3DEF14D61e47c696374453CD64d438FD277F8", "AcrossFacetPackedV3": "0x21767081Ff52CE5563A29f27149D01C7127775A2", - "RelayFacet": "0x3cf7dE0e31e13C93c8Aada774ADF1C7eD58157f5" + "RelayFacet": "0x3cf7dE0e31e13C93c8Aada774ADF1C7eD58157f5", + "ChainflipFacet": "0xDd661337B48BEA5194F6d26F2C59fF0855E15289" } \ No newline at end of file diff --git a/deployments/mainnet.diamond.staging.json b/deployments/mainnet.diamond.staging.json index 66e35168e..43aa4c1ec 100644 --- a/deployments/mainnet.diamond.staging.json +++ b/deployments/mainnet.diamond.staging.json @@ -108,7 +108,10 @@ "ReceiverAcrossV3": "", "ReceiverStargateV2": "", "RelayerCelerIM": "", - "TokenWrapper": "0x5215E9fd223BC909083fbdB2860213873046e45d" + "TokenWrapper": "0x5215E9fd223BC909083fbdB2860213873046e45d", + "GasZipPeriphery": "", + "Permit2Proxy": "", + "ReceiverChainflip": "0x5DFcEbB675F3cA2049d4b6791Ce2284EA940204a" } } } \ No newline at end of file diff --git a/deployments/mainnet.staging.json b/deployments/mainnet.staging.json index d65e04d53..1cb750528 100644 --- a/deployments/mainnet.staging.json +++ b/deployments/mainnet.staging.json @@ -29,5 +29,6 @@ "SquidFacet": "0x933A3AfE2087FB8F5c9EE9A033477C42CC14c18E", "SynapseBridgeFacet": "0x57F98A94AC66e197AF6776D5c094FF0da2C0B198", "ThorSwapFacet": "0xa696287F37d21D566B9A80AC29b2640FF910C176", - "StandardizedCallFacet": "0x637Ac9AddC9C38b3F52878E11620a9060DC71d8B" + "StandardizedCallFacet": "0x637Ac9AddC9C38b3F52878E11620a9060DC71d8B", + "ReceiverChainflip": "0xf0CfD9A8Fb7954C9F9948b94AD6f1Cc27Faff54c" } \ No newline at end of file diff --git a/docs/ChainflipFacet.md b/docs/ChainflipFacet.md new file mode 100644 index 000000000..b76a60dbe --- /dev/null +++ b/docs/ChainflipFacet.md @@ -0,0 +1,104 @@ +# Chainflip Facet + +## How it works + +The Chainflip Facet enables cross-chain token transfers using Chainflip's protocol. It supports both EVM chains and non-EVM chains as destinations. + +```mermaid +graph LR; + D{LiFiDiamond}-- DELEGATECALL -->ChainflipFacet; + ChainflipFacet -- CALL --> ChainflipVault[Chainflip Vault] +``` + +## Public Methods + +- `function startBridgeTokensViaChainflip(BridgeData calldata _bridgeData, ChainflipData calldata _chainflipData)` + - Bridges tokens using Chainflip without performing any swaps +- `swapAndStartBridgeTokensViaChainflip(BridgeData memory _bridgeData, LibSwap.SwapData[] calldata _swapData, ChainflipData memory _chainflipData)` + - Performs swap(s) before bridging tokens using Chainflip + +## Chainflip Specific Parameters + +The methods listed above take a variable labeled `_chainflipData`. This data is specific to Chainflip and is represented as the following struct type: + +```solidity +struct ChainflipData { + bytes nonEVMReceiver; // Destination address for non-EVM chains (Solana, Bitcoin) + uint32 dstToken; // Chainflip specific token identifier on the destination chain + address dstCallReceiver; // Receiver contract address used for destination calls. Ignored if there is no destination call + LibSwap.SwapData[] dstCallSwapData; // Swap data to be used in destination calls. Ignored if no destination call + uint256 gasAmount; // Gas budget for the call on the destination chain + bytes cfParameters; // Additional parameters for future features +} +``` + +For non-EVM destinations (i.e. Solana, Bitcoin), set the `receiver` in `BridgeData` to `LibAsset.NON_EVM_ADDRESS` and provide the destination address in `nonEVMReceiver`. + +## Supported Chains + +The facet supports the following chains with their respective IDs: + +[Reference](https://docs.chainflip.io/swapping/integrations/advanced/vault-swaps#supported-chains) + +## Swap Data + +Some methods accept a `SwapData _swapData` parameter. + +Swapping is performed by a swap specific library that expects an array of calldata that can be run on various DEXs (i.e. Uniswap) to make one or multiple swaps before performing another action. + +The swap library can be found [here](../src/Libraries/LibSwap.sol). + +## LiFi Data + +Some methods accept a `BridgeData _bridgeData` parameter. + +This parameter is strictly for analytics purposes. It's used to emit events that we can later track and index in our subgraphs and provide data on how our contracts are being used. `BridgeData` and the events we can emit can be found [here](../src/Interfaces/ILiFi.sol). + +## Getting Sample Calls to interact with the Facet + +In the following some sample calls are shown that allow you to retrieve a populated transaction that can be sent to our contract via your wallet. + +All examples use our [/quote endpoint](https://apidocs.li.fi/reference/get_quote) to retrieve a quote which contains a `transactionRequest`. This request can directly be sent to your wallet to trigger the transaction. + +The quote result looks like the following: + +```javascript +const quoteResult = { + id: '0x...', // quote id + type: 'lifi', // the type of the quote (all lifi contract calls have the type "lifi") + tool: 'chainflip', // the bridge tool used for the transaction + action: {}, // information about what is going to happen + estimate: {}, // information about the estimated outcome of the call + includedSteps: [], // steps that are executed by the contract as part of this transaction, e.g. a swap step and a cross step + transactionRequest: { + // the transaction that can be sent using a wallet + data: '0x...', + to: '0x...', + value: '0x00', + from: '{YOUR_WALLET_ADDRESS}', + chainId: 100, + gasLimit: '0x...', + gasPrice: '0x...', + }, +} +``` + +A detailed explanation on how to use the /quote endpoint and how to trigger the transaction can be found [here](https://docs.li.fi/products/more-integration-options/li.fi-api/transferring-tokens-example). + +**Hint**: Don't forget to replace `{YOUR_WALLET_ADDRESS}` with your real wallet address in the examples. + +### Cross Only + +To get a transaction for a transfer from USDC on Ethereum to USDC on Arbitrum you can execute the following request: + +```shell +curl 'https://li.quest/v1/quote?fromChain=ETH&fromAmount=1000000&fromToken=USDC&toChain=ARB&toToken=USDC&slippage=0.03&allowBridges=chainflip&fromAddress={YOUR_WALLET_ADDRESS}' +``` + +### Swap & Cross + +To get a transaction for a transfer from USDT on Ethereum to USDC on Arbitrum you can execute the following request: + +```shell +curl 'https://li.quest/v1/quote?fromChain=ETH&fromAmount=1000000&fromToken=USDT&toChain=ARB&toToken=USDC&slippage=0.03&allowBridges=chainflip&fromAddress={YOUR_WALLET_ADDRESS}' +``` diff --git a/docs/README.md b/docs/README.md index 404deb64f..1ede9f3f7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ - [CalldataVerification Facet](./CalldataVerificationFacet.md) - [CBridge Facet](./CBridgeFacet.md) - [Celer Circle Bridge Facet](./CelerCircleBridgeFacet.md) +- [Chainflip Facet](./ChainflipFacet.md) - [Circle Bridge Facet](./CircleBridgeFacet.md) - [DeBridge DLN Facet](/docs/DeBridgeDlnFacet.md) - [DEX Manager Facet](./DexManagerFacet.md) @@ -58,5 +59,6 @@ - [LiFuelFeeCollector](./LiFuelFeeCollector.md) - [Receiver](./Receiver.md) - [ReceiverStargateV2](./ReceiverStargateV2.md) +- [ReceiverChainflip](./ReceiverChainflip.md) - [RelayerCelerIM](./RelayerCelerIM.md) - [Permit2Proxy](./Permit2Proxy.md) diff --git a/docs/ReceiverChainflip.md b/docs/ReceiverChainflip.md new file mode 100644 index 000000000..0ce4be35f --- /dev/null +++ b/docs/ReceiverChainflip.md @@ -0,0 +1,45 @@ +# ReceiverChainflip + +## Description + +Periphery contract used for receiving cross-chain transactions via Chainflip and executing arbitrary destination calls. Inherits from WithdrawablePeriphery to allow the owner to recover stuck assets. + +## Setup + +The contract is initialized with: +- owner: Address that can withdraw stuck funds +- executor: Contract used to perform swaps +- chainflipVault: Authorized Chainflip vault that can call this contract + +## How To Use + +The contract has one method which will be called by the Chainflip vault: + +```solidity + /// @notice Receiver function for Chainflip cross-chain messages + /// @dev This function can only be called by the Chainflip Vault on this network + /// @param srcChain The source chain according to Chainflip's nomenclature + /// @param srcAddress The source address on the source chain + /// @param message The message sent from the source chain + /// @param token The address of the token received + /// @param amount The amount of tokens received + function cfReceive( + uint32 srcChain, + bytes calldata srcAddress, + bytes calldata message, + address token, + uint256 amount + ) external payable +``` + +The message parameter contains: +- transactionId: bytes32 identifier for the cross-chain transaction +- swapData: Array of LibSwap.SwapData for executing destination swaps +- receiver: Address that will receive the tokens after any swaps + +## Token Handling + +- For ERC20 tokens, the contract approves the executor to spend the received tokens before executing swaps +- For native tokens (ETH), the contract forwards the received value directly to the executor +- All approvals are reset to 0 after the operation completes +- If destination swaps fail, the original bridged tokens (ERC20 or native) will be sent directly to the receiver address diff --git a/script/demoScripts/demoChainflip.ts b/script/demoScripts/demoChainflip.ts new file mode 100644 index 000000000..d6eba63d7 --- /dev/null +++ b/script/demoScripts/demoChainflip.ts @@ -0,0 +1,248 @@ +// Import required libraries and artifacts +import { + getContract, + parseUnits, + Narrow, + zeroAddress, + encodeAbiParameters, + formatEther, + formatUnits, +} from 'viem' +import { randomBytes } from 'crypto' +import dotenv from 'dotenv' +import erc20Artifact from '../../out/ERC20/ERC20.sol/ERC20.json' +import chainflipFacetArtifact from '../../out/ChainflipFacet.sol/ChainflipFacet.json' +import { ChainflipFacet, ILiFi } from '../../typechain' +import { SupportedChain } from './utils/demoScriptChainConfig' +import { + ensureBalance, + ensureAllowance, + executeTransaction, + ADDRESS_USDC_ARB, + ADDRESS_USDT_ARB, + ADDRESS_USDC_ETH, + ADDRESS_UNISWAP_ETH, + getUniswapDataERC20toExactERC20, + getUniswapDataExactETHToERC20, + ADDRESS_UNISWAP_ARB, + setupEnvironment, +} from './utils/demoScriptHelpers' +import deployments from '../../deployments/mainnet.staging.json' + +dotenv.config() + +// Contract addresses and ABIs +const RECEIVER_CHAINFLIP = deployments.ReceiverChainflip +const ERC20_ABI = erc20Artifact.abi as Narrow +const CHAINFLIP_FACET_ABI = chainflipFacetArtifact.abi as Narrow< + typeof chainflipFacetArtifact.abi +> + +/** + * Executes a direct bridge transaction without any swaps + * Transfers tokens directly from source to destination chain + */ +async function executeDirect( + lifiDiamondContract: any, + bridgeData: ILiFi.BridgeDataStruct, + chainflipData: ChainflipFacet.ChainflipDataStruct, + publicClient: any +) { + await executeTransaction( + () => + lifiDiamondContract.write.startBridgeTokensViaChainflip([ + bridgeData, + chainflipData, + ]), + 'Starting bridge tokens via Chainflip', + publicClient, + true + ) +} + +/** + * Executes a bridge transaction with a source chain swap + * Swaps tokens on the source chain before bridging + */ +async function executeWithSourceSwap( + lifiDiamondContract: any, + bridgeData: ILiFi.BridgeDataStruct, + chainflipData: ChainflipFacet.ChainflipDataStruct, + amount: bigint, + publicClient: any +) { + const swapData = await getUniswapDataERC20toExactERC20( + ADDRESS_UNISWAP_ARB, + 42161, + ADDRESS_USDT_ARB, + ADDRESS_USDC_ARB, + amount, + lifiDiamondAddress, + true + ) + + await executeTransaction( + () => + lifiDiamondContract.write.swapAndStartBridgeTokensViaChainflip([ + bridgeData, + [swapData], + chainflipData, + ]), + 'Swapping and starting bridge tokens via Chainflip', + publicClient, + true + ) +} + +/** + * Executes a bridge transaction with a destination chain call + * Bridges ETH and includes instructions for a swap on the destination chain + */ +async function executeWithDestinationCall( + lifiDiamondContract: any, + bridgeData: ILiFi.BridgeDataStruct, + chainflipData: ChainflipFacet.ChainflipDataStruct, + amount: bigint, + publicClient: any +) { + await executeTransaction( + () => + lifiDiamondContract.write.startBridgeTokensViaChainflip( + [bridgeData, chainflipData], + { value: amount } + ), + 'Starting bridge tokens via Chainflip with destination call', + publicClient, + true + ) +} + +async function main() { + const withSwap = process.argv.includes('--with-swap') + const withDestinationCall = process.argv.includes('--with-destination-call') + + if (withSwap && withDestinationCall) { + console.error( + 'Error: Cannot use both --with-swap and --with-destination-call flags' + ) + process.exit(1) + } + + // === Set up environment === + const srcChain: SupportedChain = 'arbitrum' + const destinationChainId = 1 // Mainnet + + const { + client, + publicClient, + walletAccount, + lifiDiamondAddress, + lifiDiamondContract, + } = await setupEnvironment(srcChain, CHAINFLIP_FACET_ABI) + const signerAddress = walletAccount.address + + // Amount setup + const totalAmount = parseUnits('0.005', 18) // 0.005 ETH total + const gasAmount = parseUnits('0.001', 18) // 0.001 ETH for gas + const amount = withDestinationCall ? totalAmount : parseUnits('10', 6) // 10 USDC/USDT + + // Token setup + const tokenToApprove = withDestinationCall + ? zeroAddress + : withSwap + ? ADDRESS_USDT_ARB + : ADDRESS_USDC_ARB + + console.info( + `\nBridge ${ + withDestinationCall ? formatEther(amount) : formatUnits(amount, 6) + } ${withDestinationCall ? 'ETH' : 'USDC/USDT'} from ${srcChain} --> Mainnet` + ) + console.info(`Connected wallet address: ${signerAddress}`) + + if (!withDestinationCall) { + const srcTokenContract = getContract({ + address: tokenToApprove, + abi: ERC20_ABI, + client, + }) + await ensureBalance(srcTokenContract, signerAddress, amount) + await ensureAllowance( + srcTokenContract, + signerAddress, + lifiDiamondAddress, + amount, + publicClient + ) + } + + // === Prepare bridge data === + const bridgeData: ILiFi.BridgeDataStruct = { + transactionId: `0x${randomBytes(32).toString('hex')}`, + bridge: 'chainflip', + integrator: 'ACME Devs', + referrer: zeroAddress, + sendingAssetId: tokenToApprove, + receiver: signerAddress, // Always use signer address + destinationChainId, + minAmount: amount, + hasSourceSwaps: withSwap, + hasDestinationCall: withDestinationCall, + } + + // Prepare destination swap data if needed + const dstSwapData = withDestinationCall + ? [ + await getUniswapDataExactETHToERC20( + ADDRESS_UNISWAP_ETH, + 1, // Mainnet chainId + totalAmount - gasAmount, + ADDRESS_USDC_ETH, + signerAddress, + false + ), + ] + : [] + + const chainflipData: ChainflipFacet.ChainflipDataStruct = { + nonEVMReceiver: '', + dstToken: withDestinationCall ? 1 : 3, // 1 for ETH, 3 for USDC on ETH + dstCallReceiver: RECEIVER_CHAINFLIP, + dstCallSwapData: dstSwapData, + gasAmount: withDestinationCall ? gasAmount : 0n, + cfParameters: '', + } + + // === Execute the appropriate transaction type === + if (withDestinationCall) { + await executeWithDestinationCall( + lifiDiamondContract, + bridgeData, + chainflipData, + amount, + publicClient + ) + } else if (withSwap) { + await executeWithSourceSwap( + lifiDiamondContract, + bridgeData, + chainflipData, + amount, + publicClient + ) + } else { + await executeDirect( + lifiDiamondContract, + bridgeData, + chainflipData, + publicClient + ) + } +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error) + process.exit(1) + }) diff --git a/script/demoScripts/utils/demoScriptHelpers.ts b/script/demoScripts/utils/demoScriptHelpers.ts index 3351387a0..be36589d9 100644 --- a/script/demoScripts/utils/demoScriptHelpers.ts +++ b/script/demoScripts/utils/demoScriptHelpers.ts @@ -1,4 +1,5 @@ import { privateKeyToAccount } from 'viem/accounts' +import { formatEther, formatUnits, zeroAddress } from 'viem' import path from 'path' import { fileURLToPath } from 'url' import { providers, Wallet, BigNumber, constants, Contract } from 'ethers' @@ -18,7 +19,7 @@ import { import networks from '../../../config/networks.json' import { SupportedChain, viemChainMap } from './demoScriptChainConfig' -export const DEV_WALLET_ADDRESS = '0x29DaCdF7cCaDf4eE67c923b4C22255A4B2494eD7' +export const DEV_WALLET_ADDRESS = '0xb9c0dE368BECE5e76B52545a8E377a4C118f597B' export const DEFAULT_DEST_PAYLOAD_ABI = [ 'bytes32', // Transaction Id @@ -180,19 +181,19 @@ export const getUniswapSwapDataERC20ToERC20 = async ( // get minAmountOut from Uniswap router console.log(`finalFromAmount : ${fromAmount}`) - const finalMinAmountOut = - minAmountOut.toString() !== '0' - ? minAmountOut - : BigNumber.from( - await getAmountsOutUniswap( - uniswapAddress, - chainId, - [sendingAssetId, receivingAssetId], - fromAmount - ) - ) - .mul(99) - .div(100) // Apply 1% slippage tolerance by default + let finalMinAmountOut: BigNumber + if (minAmountOut.toString() !== '0') { + finalMinAmountOut = minAmountOut + } else { + const amountsOut = await getAmountsOutUniswap( + uniswapAddress, + chainId, + [sendingAssetId, receivingAssetId], + fromAmount + ) + // Use the second element (index 1) as the estimated output + finalMinAmountOut = BigNumber.from(amountsOut[1]).mul(99).div(100) + } console.log(`finalMinAmountOut: ${finalMinAmountOut}`) const uniswapCalldata = ( @@ -620,6 +621,64 @@ export const getConfigElement = ( /** * Executes a blockchain transaction, validates its receipt (optional), and handles errors. */ +export const getUniswapDataExactETHToERC20 = async ( + uniswapAddress: string, + chainId: number, + exactETHAmount: bigint, + receivingAssetId: string, + receiverAddress: string, + requiresDeposit = false, + deadline = Math.floor(Date.now() / 1000) + 60 * 60 +) => { + const provider = getProviderForChainId(chainId) + + const uniswap = new Contract( + uniswapAddress, + [ + 'function getAmountsOut(uint amountIn, address[] calldata path) external view returns (uint[] memory amounts)', + 'function swapExactETHForTokens(uint amountOutMin, address[] calldata path, address to, uint deadline) external payable returns (uint[] memory amounts)', + ], + provider + ) + + const path = [ADDRESS_WETH_ETH, receivingAssetId] + + try { + // Get the expected output amount for the exact ETH input + const amounts = await uniswap.getAmountsOut(exactETHAmount, path) + const expectedOutput = amounts[1] + const minAmountOut = BigNumber.from(expectedOutput).mul(95).div(100) // 5% slippage tolerance + + console.log('Exact ETH input:', formatEther(exactETHAmount)) + console.log('Expected USDC output:', formatUnits(expectedOutput, 6)) + console.log('Min USDC output with slippage:', formatUnits(minAmountOut, 6)) + + const uniswapCalldata = ( + await uniswap.populateTransaction.swapExactETHForTokens( + minAmountOut, + path, + receiverAddress, + deadline + ) + ).data + + if (!uniswapCalldata) throw Error('Could not create Uniswap calldata') + + return { + callTo: uniswapAddress, + approveTo: uniswapAddress, + sendingAssetId: zeroAddress, // ETH + receivingAssetId, + fromAmount: exactETHAmount, + callData: uniswapCalldata, + requiresDeposit, + } + } catch (error) { + console.error('Error in Uniswap contract interaction:', error) + throw error + } +} + export const executeTransaction = async ( transaction: () => Promise, transactionDescription: string, diff --git a/script/deploy/facets/DeployChainflipFacet.s.sol b/script/deploy/facets/DeployChainflipFacet.s.sol new file mode 100644 index 000000000..ee45e1a3c --- /dev/null +++ b/script/deploy/facets/DeployChainflipFacet.s.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import { DeployScriptBase } from "./utils/DeployScriptBase.sol"; +import { stdJson } from "forge-std/Script.sol"; +import { ChainflipFacet } from "lifi/Facets/ChainflipFacet.sol"; + +contract DeployScript is DeployScriptBase { + using stdJson for string; + + constructor() DeployScriptBase("ChainflipFacet") {} + + function run() + public + returns (ChainflipFacet deployed, bytes memory constructorArgs) + { + constructorArgs = getConstructorArgs(); + + deployed = ChainflipFacet(deploy(type(ChainflipFacet).creationCode)); + } + + function getConstructorArgs() internal override returns (bytes memory) { + // get path of chainflip config file + string memory path = string.concat(root, "/config/chainflip.json"); + + // Read the Chainflip vault address from config + address chainflipVault = _getConfigContractAddress( + path, + string.concat(".", network, ".chainflipVault") + ); + + return abi.encode(chainflipVault); + } +} diff --git a/script/deploy/facets/DeployReceiverChainflip.s.sol b/script/deploy/facets/DeployReceiverChainflip.s.sol new file mode 100644 index 000000000..4557e2a93 --- /dev/null +++ b/script/deploy/facets/DeployReceiverChainflip.s.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import { DeployScriptBase } from "./utils/DeployScriptBase.sol"; +import { stdJson } from "forge-std/Script.sol"; +import { ReceiverChainflip } from "lifi/Periphery/ReceiverChainflip.sol"; + +contract DeployScript is DeployScriptBase { + using stdJson for string; + + constructor() DeployScriptBase("ReceiverChainflip") {} + + function run() + public + returns (ReceiverChainflip deployed, bytes memory constructorArgs) + { + constructorArgs = getConstructorArgs(); + + deployed = ReceiverChainflip( + deploy(type(ReceiverChainflip).creationCode) + ); + } + + function getConstructorArgs() internal override returns (bytes memory) { + // get path of global config file + string memory globalConfigPath = string.concat( + root, + "/config/global.json" + ); + + // read file into json variable + string memory globalConfigJson = vm.readFile(globalConfigPath); + + // extract refundWallet address + address refundWalletAddress = globalConfigJson.readAddress( + ".refundWallet" + ); + + // obtain address of Chainflip vault in current network from config file + string memory path = string.concat(root, "/config/chainflip.json"); + + address chainflipVault = _getConfigContractAddress( + path, + string.concat(".chainflipVault.", network) + ); + + // get Executor address from deploy log + path = string.concat( + root, + "/deployments/", + network, + ".", + fileSuffix, + "json" + ); + address executor = _getConfigContractAddress(path, ".Executor"); + + return abi.encode(refundWalletAddress, executor, chainflipVault); + } +} diff --git a/script/deploy/facets/UpdateChainflipFacet.s.sol b/script/deploy/facets/UpdateChainflipFacet.s.sol new file mode 100644 index 000000000..2fb27b000 --- /dev/null +++ b/script/deploy/facets/UpdateChainflipFacet.s.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import { UpdateScriptBase } from "./utils/UpdateScriptBase.sol"; +import { stdJson } from "forge-std/StdJson.sol"; +import { DiamondCutFacet, IDiamondCut } from "lifi/Facets/DiamondCutFacet.sol"; +import { ChainflipFacet } from "lifi/Facets/ChainflipFacet.sol"; + +contract DeployScript is UpdateScriptBase { + using stdJson for string; + + function run() + public + returns (address[] memory facets, bytes memory cutData) + { + return update("ChainflipFacet"); + } + + function getExcludes() internal pure override returns (bytes4[] memory) { + // No functions to exclude + return new bytes4[](0); + } + + function getCallData() internal pure override returns (bytes memory) { + // No initialization needed + return new bytes(0); + } +} diff --git a/script/deploy/resources/deployRequirements.json b/script/deploy/resources/deployRequirements.json index 3bfef1d48..4c707f8fb 100644 --- a/script/deploy/resources/deployRequirements.json +++ b/script/deploy/resources/deployRequirements.json @@ -569,5 +569,33 @@ "allowToDeployWithZeroAddress": "false" } } + }, + "ChainflipFacet": { + "configData": { + "_chainflipVault": { + "configFileName": "chainflip.json", + "keyInConfigFile": ".chainflipVault.", + "allowToDeployWithZeroAddress": "false" + } + } + }, + "ReceiverChainflip": { + "configData": { + "_owner": { + "configFileName": "global.json", + "keyInConfigFile": ".refundWallet", + "allowToDeployWithZeroAddress": "false" + }, + "_chainflipVault": { + "configFileName": "chainflip.json", + "keyInConfigFile": ".chainflipVault.", + "allowToDeployWithZeroAddress": "false" + } + }, + "contractAddresses": { + "Executor": { + "allowToDeployWithZeroAddress": "false" + } + } } } diff --git a/src/Facets/ChainflipFacet.sol b/src/Facets/ChainflipFacet.sol new file mode 100644 index 000000000..8675eaac8 --- /dev/null +++ b/src/Facets/ChainflipFacet.sol @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import { ILiFi } from "../Interfaces/ILiFi.sol"; +import { LibAsset } from "../Libraries/LibAsset.sol"; +import { LibSwap } from "../Libraries/LibSwap.sol"; +import { ReentrancyGuard } from "../Helpers/ReentrancyGuard.sol"; +import { SwapperV2 } from "../Helpers/SwapperV2.sol"; +import { Validatable } from "../Helpers/Validatable.sol"; +import { IChainflipVault } from "../Interfaces/IChainflip.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { InformationMismatch, InvalidConfig, InvalidReceiver } from "../Errors/GenericErrors.sol"; + +/// @title Chainflip Facet +/// @author LI.FI (https://li.fi) +/// @notice Allows bridging assets via Chainflip +/// @custom:version 1.0.0 +contract ChainflipFacet is ILiFi, ReentrancyGuard, SwapperV2, Validatable { + /// Events /// + event BridgeToNonEVMChain( + bytes32 indexed transactionId, + uint256 indexed destinationChainId, + bytes receiver + ); + + /// Errors /// + error EmptyNonEvmAddress(); + error UnsupportedChainflipChainId(); + + /// Storage /// + + IChainflipVault public immutable chainflipVault; + uint256 private constant CHAIN_ID_ETHEREUM = 1; + uint256 private constant CHAIN_ID_ARBITRUM = 42161; + uint256 private constant CHAIN_ID_SOLANA = 1151111081099710; + uint256 private constant CHAIN_ID_BITCOIN = 20000000000001; + uint32 private constant CHAINFLIP_ID_ETHEREUM = 1; + uint32 private constant CHAINFLIP_ID_ARBITRUM = 4; + uint32 private constant CHAINFLIP_ID_SOLANA = 5; + uint32 private constant CHAINFLIP_ID_BITCOIN = 3; + + /// Types /// + + /// @notice Parameters specific to Chainflip bridge operations + /// @param nonEVMReceiver Destination address for non-EVM chains (Solana, Bitcoin) + /// @param dstToken Chainflip specific token identifier on the destination chain + /// @param dstCallReceiver Receiver contract address used for destination calls. Ignored if no destination call + /// @param dstCallSwapData Swap data to be used in destination calls. Ignored if no destination call + /// @param gasAmount Gas budget for the call on the destination chain + /// @param cfParameters Additional parameters for future features + struct ChainflipData { + bytes nonEVMReceiver; + uint32 dstToken; + address dstCallReceiver; + LibSwap.SwapData[] dstCallSwapData; + uint256 gasAmount; + bytes cfParameters; + } + + /// Constructor /// + + /// @notice Constructor for the contract. + /// @param _chainflipVault Address of the Chainflip vault contract + constructor(IChainflipVault _chainflipVault) { + if (address(_chainflipVault) == address(0)) { + revert InvalidConfig(); + } + chainflipVault = _chainflipVault; + } + + /// External Methods /// + + /// @notice Bridges tokens via Chainflip + /// @param _bridgeData The core information needed for bridging + /// @param _chainflipData Data specific to Chainflip + function startBridgeTokensViaChainflip( + ILiFi.BridgeData memory _bridgeData, + ChainflipData calldata _chainflipData + ) + external + payable + nonReentrant + refundExcessNative(payable(msg.sender)) + validateBridgeData(_bridgeData) + doesNotContainSourceSwaps(_bridgeData) + { + LibAsset.depositAsset( + _bridgeData.sendingAssetId, + _bridgeData.minAmount + ); + _startBridge(_bridgeData, _chainflipData); + } + + /// @notice Performs a swap before bridging via Chainflip + /// @param _bridgeData The core information needed for bridging + /// @param _swapData An array of swap related data for performing swaps before bridging + /// @param _chainflipData Data specific to Chainflip + function swapAndStartBridgeTokensViaChainflip( + ILiFi.BridgeData memory _bridgeData, + LibSwap.SwapData[] calldata _swapData, + ChainflipData calldata _chainflipData + ) + external + payable + nonReentrant + refundExcessNative(payable(msg.sender)) + containsSourceSwaps(_bridgeData) + validateBridgeData(_bridgeData) + { + _bridgeData.minAmount = _depositAndSwap( + _bridgeData.transactionId, + _bridgeData.minAmount, + _swapData, + payable(msg.sender) + ); + _startBridge(_bridgeData, _chainflipData); + } + + /// Internal Methods /// + + /// @notice Contains the business logic for bridging via Chainflip + /// @param _bridgeData The core information needed for bridging, including sending/receiving details + /// @param _chainflipData Data specific to Chainflip, including Chainflip token identifiers and parameters + /// @dev Handles both EVM and non-EVM destinations, native and ERC20 tokens, and cross-chain messaging + function _startBridge( + ILiFi.BridgeData memory _bridgeData, + ChainflipData calldata _chainflipData + ) internal { + uint32 dstChain = _getChainflipChainId(_bridgeData.destinationChainId); + bool isNativeAsset = LibAsset.isNativeAsset( + _bridgeData.sendingAssetId + ); + + // Handle address encoding based on destination chain type + bytes memory encodedDstAddress; + if (_bridgeData.receiver == LibAsset.NON_EVM_ADDRESS) { + if (_chainflipData.nonEVMReceiver.length == 0) { + revert EmptyNonEvmAddress(); + } + encodedDstAddress = _chainflipData.nonEVMReceiver; + + emit BridgeToNonEVMChain( + _bridgeData.transactionId, + _bridgeData.destinationChainId, + _chainflipData.nonEVMReceiver + ); + } else { + // For EVM chains, use dstCallReceiver if there's a destination call, otherwise use bridge receiver + address receiverAddress = _bridgeData.hasDestinationCall + ? _chainflipData.dstCallReceiver + : _bridgeData.receiver; + + if (receiverAddress == address(0)) { + revert InvalidReceiver(); + } + + encodedDstAddress = abi.encodePacked(receiverAddress); + } + + // Handle ERC20 token approval outside the if/else to avoid code duplication + if (!isNativeAsset) { + LibAsset.maxApproveERC20( + IERC20(_bridgeData.sendingAssetId), + address(chainflipVault), + _bridgeData.minAmount + ); + } + + // Handle destination calls + if (_bridgeData.hasDestinationCall) { + if (_chainflipData.dstCallSwapData.length == 0) { + revert InformationMismatch(); + } + + bytes memory message = abi.encode( + _bridgeData.transactionId, + _chainflipData.dstCallSwapData, + _bridgeData.receiver + ); + + if (isNativeAsset) { + chainflipVault.xCallNative{ value: _bridgeData.minAmount }( + dstChain, + encodedDstAddress, + _chainflipData.dstToken, + message, + _chainflipData.gasAmount, + _chainflipData.cfParameters + ); + } else { + chainflipVault.xCallToken( + dstChain, + encodedDstAddress, + _chainflipData.dstToken, + message, + _chainflipData.gasAmount, + IERC20(_bridgeData.sendingAssetId), + _bridgeData.minAmount, + _chainflipData.cfParameters + ); + } + } else { + if (_chainflipData.dstCallSwapData.length > 0) { + revert InformationMismatch(); + } + + if (isNativeAsset) { + chainflipVault.xSwapNative{ value: _bridgeData.minAmount }( + dstChain, + encodedDstAddress, + _chainflipData.dstToken, + _chainflipData.cfParameters + ); + } else { + chainflipVault.xSwapToken( + dstChain, + encodedDstAddress, + _chainflipData.dstToken, + IERC20(_bridgeData.sendingAssetId), + _bridgeData.minAmount, + _chainflipData.cfParameters + ); + } + } + + emit LiFiTransferStarted(_bridgeData); + } + + /// @notice Converts LiFi internal chain IDs to Chainflip chain IDs + /// @param destinationChainId The LiFi chain ID to convert + /// @return The corresponding Chainflip chain ID + /// @dev Reverts if the destination chain is not supported + function _getChainflipChainId( + uint256 destinationChainId + ) internal pure returns (uint32) { + if (destinationChainId == CHAIN_ID_ETHEREUM) { + return CHAINFLIP_ID_ETHEREUM; + } else if (destinationChainId == CHAIN_ID_ARBITRUM) { + return CHAINFLIP_ID_ARBITRUM; + } else if (destinationChainId == CHAIN_ID_SOLANA) { + return CHAINFLIP_ID_SOLANA; + } else if (destinationChainId == CHAIN_ID_BITCOIN) { + return CHAINFLIP_ID_BITCOIN; + } else { + revert UnsupportedChainflipChainId(); + } + } +} diff --git a/src/Interfaces/IChainflip.sol b/src/Interfaces/IChainflip.sol new file mode 100644 index 000000000..751110b2d --- /dev/null +++ b/src/Interfaces/IChainflip.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @title Interface for Chainflip Vault contract +/// @custom:version 1.0.0 +interface IChainflipVault { + /// @notice Swaps native token to any supported asset on any supported chain + /// @param dstChain Destination chain for the swap + /// @param dstAddress Address where the swapped tokens will be sent to on the destination chain + /// @param dstToken Chainflip specific token identifier on the destination chain + /// @param cfParameters Additional metadata for future features + function xSwapNative( + uint32 dstChain, + bytes calldata dstAddress, + uint32 dstToken, + bytes calldata cfParameters + ) external payable; + + /// @notice Swaps ERC20 token to any supported asset on any supported chain + /// @param dstChain Destination chain for the swap + /// @param dstAddress Address where the swapped tokens will be sent to on the destination chain + /// @param dstToken Chainflip specific token identifier on the destination chain + /// @param srcToken Address of the token to be swapped from the source chain + /// @param amount Amount of the source token to be swapped + /// @param cfParameters Additional metadata for future features + function xSwapToken( + uint32 dstChain, + bytes calldata dstAddress, + uint32 dstToken, + IERC20 srcToken, + uint256 amount, + bytes calldata cfParameters + ) external; + + /// @notice Swaps native token and calls a contract on the destination chain with a message + /// @param dstChain Destination chain for the swap + /// @param dstAddress Address where the swapped tokens will be sent to on the destination chain + /// @param dstToken Chainflip specific token identifier on the destination chain + /// @param message Message that is passed to the destination address on the destination chain + /// @param gasAmount Gas budget for the call on the destination chain + /// @param cfParameters Additional metadata for future features + function xCallNative( + uint32 dstChain, + bytes calldata dstAddress, + uint32 dstToken, + bytes calldata message, + uint256 gasAmount, + bytes calldata cfParameters + ) external payable; + + /// @notice Swaps ERC20 token and calls a contract on the destination chain with a message + /// @param dstChain Destination chain for the swap + /// @param dstAddress Address where the swapped tokens will be sent to on the destination chain + /// @param dstToken Chainflip specific token identifier on the destination chain + /// @param message Message that is passed to the destination address on the destination chain + /// @param gasAmount Gas budget for the call on the destination chain + /// @param srcToken Address of the token to be swapped from the source chain + /// @param amount Amount of the source token to be swapped + /// @param cfParameters Additional metadata for future features + function xCallToken( + uint32 dstChain, + bytes calldata dstAddress, + uint32 dstToken, + bytes calldata message, + uint256 gasAmount, + IERC20 srcToken, + uint256 amount, + bytes calldata cfParameters + ) external; +} diff --git a/src/Periphery/ReceiverChainflip.sol b/src/Periphery/ReceiverChainflip.sol new file mode 100644 index 000000000..f9ac13194 --- /dev/null +++ b/src/Periphery/ReceiverChainflip.sol @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import { LibSwap } from "../Libraries/LibSwap.sol"; +import { LibAsset } from "../Libraries/LibAsset.sol"; +import { ILiFi } from "../Interfaces/ILiFi.sol"; +import { IExecutor } from "../Interfaces/IExecutor.sol"; +import { WithdrawablePeriphery } from "../Helpers/WithdrawablePeriphery.sol"; +import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; +import { InvalidConfig } from "../Errors/GenericErrors.sol"; + +/// @title ReceiverChainflip +/// @author LI.FI (https://li.fi) +/// @notice Receiver contract for Chainflip cross-chain swaps and message passing +/// @custom:version 1.0.0 +contract ReceiverChainflip is ILiFi, WithdrawablePeriphery { + using SafeTransferLib for address; + using SafeTransferLib for address payable; + + /// Storage /// + + /// @notice The executor contract used for performing swaps + IExecutor public immutable executor; + /// @notice The Chainflip vault contract that is authorized to call this contract + address public immutable chainflipVault; + /// @notice Chainflip's native token address representation + address internal constant CHAINFLIP_NATIVE_ADDRESS = + 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + /// Modifiers /// + + /// @notice Ensures only the Chainflip vault can call the function + /// @dev Reverts with UnAuthorized if called by any other address + modifier onlyChainflipVault() { + if (msg.sender != chainflipVault) { + revert UnAuthorized(); + } + _; + } + + /// Constructor /// + + /// @notice Initializes the contract with required addresses + /// @param _owner Address that can withdraw funds from this contract + /// @param _executor Address of the executor contract for performing swaps + /// @param _chainflipVault Address of the Chainflip vault that can call this contract + constructor( + address _owner, + address _executor, + address _chainflipVault + ) WithdrawablePeriphery(_owner) { + if ( + _owner == address(0) || + _executor == address(0) || + _chainflipVault == address(0) + ) { + revert InvalidConfig(); + } + executor = IExecutor(_executor); + chainflipVault = _chainflipVault; + } + + /// External Methods /// + + /// @notice Receiver function for Chainflip cross-chain messages + /// @dev This function can only be called by the Chainflip Vault on this network + /// @dev First param (unused): The source chain according to Chainflip's nomenclature + /// @dev Second param (unused): The source address on the source chain + /// @param message The message sent from the source chain + /// @param token The address of the token received + /// @param amount The amount of tokens received + function cfReceive( + uint32, // srcChain + bytes calldata, // srcAddress + bytes calldata message, + address token, + uint256 amount + ) external payable onlyChainflipVault { + // decode payload + ( + bytes32 transactionId, + LibSwap.SwapData[] memory swapData, + address receiver + ) = abi.decode(message, (bytes32, LibSwap.SwapData[], address)); + + // execute swap(s) + _swapAndCompleteBridgeTokens( + transactionId, + swapData, + token, + payable(receiver), + amount + ); + } + + /// Private Methods /// + + /// @notice Performs a swap before completing a cross-chain transaction + /// @param _transactionId The transaction id associated with the operation + /// @param _swapData Array of data needed for swaps + /// @param assetId Address of the token received from the source chain + /// @param receiver Address that will receive tokens in the end + /// @param amount Amount of token to swap + /// @dev If the swap fails, the original bridged tokens are sent directly to the receiver + /// @dev Approvals are reset to 0 after the operation completes + function _swapAndCompleteBridgeTokens( + bytes32 _transactionId, + LibSwap.SwapData[] memory _swapData, + address assetId, + address payable receiver, + uint256 amount + ) private { + // Group address conversion and store in memory to avoid multiple storage reads + address actualAssetId = assetId == CHAINFLIP_NATIVE_ADDRESS + ? LibAsset.NATIVE_ASSETID + : assetId; + bool isNative = LibAsset.isNativeAsset(actualAssetId); + + if (!isNative) { + // ERC20 token operations + actualAssetId.safeApproveWithRetry(address(executor), amount); + try + executor.swapAndCompleteBridgeTokens( + _transactionId, + _swapData, + actualAssetId, + receiver + ) + { + return; + } catch { + actualAssetId.safeTransfer(receiver, amount); + emit LiFiTransferRecovered( + _transactionId, + actualAssetId, + receiver, + amount, + block.timestamp + ); + } + actualAssetId.safeApprove(address(executor), 0); + } else { + // Native token operations + try + executor.swapAndCompleteBridgeTokens{ value: amount }( + _transactionId, + _swapData, + actualAssetId, + receiver + ) + { + return; + } catch { + receiver.safeTransferETH(amount); + emit LiFiTransferRecovered( + _transactionId, + actualAssetId, + receiver, + amount, + block.timestamp + ); + } + } + } + + /// @notice Receive native asset directly. + /// @notice Allows the contract to receive native assets directly + /// @dev Required for receiving native token transfers + receive() external payable {} +} diff --git a/test/solidity/Facets/ChainflipFacet.t.sol b/test/solidity/Facets/ChainflipFacet.t.sol new file mode 100644 index 000000000..38181dd04 --- /dev/null +++ b/test/solidity/Facets/ChainflipFacet.t.sol @@ -0,0 +1,538 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity 0.8.17; + +import { LibAllowList, TestBaseFacet, console, ERC20 } from "../utils/TestBaseFacet.sol"; +import { ChainflipFacet } from "lifi/Facets/ChainflipFacet.sol"; +import { IChainflipVault } from "lifi/Interfaces/IChainflip.sol"; +import { LibAsset } from "lifi/Libraries/LibAsset.sol"; +import { LibSwap } from "lifi/Libraries/LibSwap.sol"; +import { InformationMismatch, CannotBridgeToSameNetwork, InvalidConfig, InvalidReceiver } from "lifi/Errors/GenericErrors.sol"; + +// Stub ChainflipFacet Contract +contract TestChainflipFacet is ChainflipFacet { + constructor( + address _chainflipVault + ) ChainflipFacet(IChainflipVault(_chainflipVault)) {} + + function addDex(address _dex) external { + LibAllowList.addAllowedContract(_dex); + } + + function setFunctionApprovalBySignature(bytes4 _signature) external { + LibAllowList.addAllowedSelector(_signature); + } +} + +contract ChainflipFacetTest is TestBaseFacet { + ChainflipFacet.ChainflipData internal validChainflipData; + TestChainflipFacet internal chainflipFacet; + address internal CHAINFLIP_VAULT; + LibSwap.SwapData[] internal dstSwapData; + + uint256 internal constant CHAIN_ID_ETHEREUM = 1; + uint256 internal constant CHAIN_ID_ARBITRUM = 42161; + uint256 internal constant CHAIN_ID_SOLANA = 1151111081099710; + uint256 internal constant CHAIN_ID_BITCOIN = 20000000000001; + + function setUp() public { + customBlockNumberForForking = 18277082; + initTestBase(); + + // Read chainflip vault address from config using the new helper + CHAINFLIP_VAULT = getConfigAddressFromPath( + "chainflip.json", + ".mainnet.chainflipVault" + ); + vm.label(CHAINFLIP_VAULT, "Chainflip Vault"); + console.log("Chainflip Vault Address:", CHAINFLIP_VAULT); + + chainflipFacet = new TestChainflipFacet(CHAINFLIP_VAULT); + bytes4[] memory functionSelectors = new bytes4[](4); + functionSelectors[0] = chainflipFacet + .startBridgeTokensViaChainflip + .selector; + functionSelectors[1] = chainflipFacet + .swapAndStartBridgeTokensViaChainflip + .selector; + functionSelectors[2] = chainflipFacet.addDex.selector; + functionSelectors[3] = chainflipFacet + .setFunctionApprovalBySignature + .selector; + + addFacet(diamond, address(chainflipFacet), functionSelectors); + chainflipFacet = TestChainflipFacet(address(diamond)); + chainflipFacet.addDex(ADDRESS_UNISWAP); + chainflipFacet.setFunctionApprovalBySignature( + uniswap.swapExactTokensForTokens.selector + ); + chainflipFacet.setFunctionApprovalBySignature( + uniswap.swapTokensForExactETH.selector + ); + chainflipFacet.setFunctionApprovalBySignature( + uniswap.swapETHForExactTokens.selector + ); + + setFacetAddressInTestBase(address(chainflipFacet), "ChainflipFacet"); + + // adjust bridgeData + bridgeData.bridge = "chainflip"; + bridgeData.destinationChainId = 42161; // Arbitrum chain ID + + // Most properties are unused for normal bridging + validChainflipData.dstToken = 7; + } + + function testRevert_WhenConstructedWithZeroAddress() public { + vm.expectRevert(InvalidConfig.selector); + new TestChainflipFacet(address(0)); + } + + function initiateBridgeTxWithFacet(bool isNative) internal override { + if (isNative) { + chainflipFacet.startBridgeTokensViaChainflip{ + value: bridgeData.minAmount + }(bridgeData, validChainflipData); + } else { + chainflipFacet.startBridgeTokensViaChainflip( + bridgeData, + validChainflipData + ); + } + } + + function initiateSwapAndBridgeTxWithFacet( + bool isNative + ) internal override { + if (isNative) { + chainflipFacet.swapAndStartBridgeTokensViaChainflip{ + value: swapData[0].fromAmount + }(bridgeData, swapData, validChainflipData); + } else { + chainflipFacet.swapAndStartBridgeTokensViaChainflip( + bridgeData, + swapData, + validChainflipData + ); + } + } + + function test_CanBridgeTokensToSolana() + public + assertBalanceChange( + ADDRESS_USDC, + USER_SENDER, + -int256(defaultUSDCAmount) + ) + { + bridgeData.receiver = LibAsset.NON_EVM_ADDRESS; + bridgeData.destinationChainId = CHAIN_ID_SOLANA; + validChainflipData.dstToken = 6; + validChainflipData.nonEVMReceiver = bytes( + "EoW7FWTdPdZKpd3WAhH98c2HMGHsdh5yhzzEtk1u68Bb" + ); + + vm.startPrank(USER_SENDER); + + // approval + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + //prepare check for events + vm.expectEmit(true, true, true, true, _facetTestContractAddress); + emit LiFiTransferStarted(bridgeData); + + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + } + + function test_CanBridgeTokensToBitcoin() + public + assertBalanceChange( + ADDRESS_USDC, + USER_SENDER, + -int256(defaultUSDCAmount) + ) + assertBalanceChange(ADDRESS_DAI, USER_SENDER, 0) + { + bridgeData.receiver = LibAsset.NON_EVM_ADDRESS; + bridgeData.destinationChainId = CHAIN_ID_BITCOIN; + validChainflipData.dstToken = 6; + validChainflipData.nonEVMReceiver = bytes( + "bc1q6l08rtj6j907r2een0jqs6l7qnruwyxfshmf8a" + ); + + vm.startPrank(USER_SENDER); + + // approval + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + //prepare check for events + vm.expectEmit(true, true, true, true, _facetTestContractAddress); + emit LiFiTransferStarted(bridgeData); + + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + } + + function test_CanBridgeTokensToEthereum() + public + assertBalanceChange( + ADDRESS_USDC, + USER_SENDER, + -int256(defaultUSDCAmount) + ) + assertBalanceChange(ADDRESS_USDC, USER_RECEIVER, 0) + assertBalanceChange(ADDRESS_DAI, USER_SENDER, 0) + assertBalanceChange(ADDRESS_DAI, USER_RECEIVER, 0) + { + // Set source chain to Arbitrum for this test + vm.chainId(CHAIN_ID_ARBITRUM); + vm.roll(208460950); // Set specific block number for Arbitrum chain + + // Set destination to Ethereum + bridgeData.destinationChainId = CHAIN_ID_ETHEREUM; + validChainflipData.dstToken = 3; // USDC on Ethereum + + vm.startPrank(USER_SENDER); + + // approval + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + //prepare check for events + vm.expectEmit(true, true, true, true, _facetTestContractAddress); + emit LiFiTransferStarted(bridgeData); + + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + } + + function testRevert_WhenUsingUnsupportedDestinationChain() public { + // Set destination chain to Polygon (unsupported) + bridgeData.destinationChainId = 137; + + vm.startPrank(USER_SENDER); + + // approval + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(ChainflipFacet.UnsupportedChainflipChainId.selector); + + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + } + + function test_CanBridgeNativeTokensWithDestinationCall() + public + assertBalanceChange( + address(0), + USER_SENDER, + -int256(defaultNativeAmount) + ) + { + delete dstSwapData; + dstSwapData.push( + LibSwap.SwapData({ + callTo: address(0x123), + approveTo: address(0x123), + sendingAssetId: address(0), + receivingAssetId: address(0), + fromAmount: 0, + callData: "0x123456", + requiresDeposit: false + }) + ); + + bridgeData.destinationChainId = CHAIN_ID_ARBITRUM; + bridgeData.hasDestinationCall = true; + bridgeData.sendingAssetId = address(0); + bridgeData.minAmount = defaultNativeAmount; + + validChainflipData.dstToken = 7; + validChainflipData.dstCallReceiver = address(0x123); + validChainflipData.dstCallSwapData = dstSwapData; + validChainflipData.gasAmount = 100000; + + vm.startPrank(USER_SENDER); + + //prepare check for events + vm.expectEmit(true, true, true, true, _facetTestContractAddress); + emit LiFiTransferStarted(bridgeData); + + initiateBridgeTxWithFacet(true); + vm.stopPrank(); + } + + function test_CanBridgeNativeTokensWithoutDestinationCall() + public + assertBalanceChange( + address(0), + USER_SENDER, + -int256(defaultNativeAmount) + ) + { + bridgeData.destinationChainId = CHAIN_ID_ARBITRUM; + bridgeData.hasDestinationCall = false; + bridgeData.sendingAssetId = address(0); + bridgeData.minAmount = defaultNativeAmount; + + validChainflipData.dstToken = 7; + + vm.startPrank(USER_SENDER); + + //prepare check for events + vm.expectEmit(true, true, true, true, _facetTestContractAddress); + emit LiFiTransferStarted(bridgeData); + + initiateBridgeTxWithFacet(true); + vm.stopPrank(); + } + + function testBase_Revert_BridgeWithInvalidDestinationCallFlag() + public + override + { + vm.startPrank(USER_SENDER); + + // approval + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + // prepare bridgeData + bridgeData.hasDestinationCall = true; + + // In the ChainflipFacet, when hasDestinationCall is true, it uses dstCallReceiver + // which is zero by default, so it will revert with InvalidReceiver before checking + // for the destination call data + // + // NOTE: We override this test from TestBaseFacet because the base test expects + // InformationMismatch error, but our implementation checks for zero receiver address + // first (which throws InvalidReceiver) before checking for empty destination call data. + // The order of validation in _startBridge() means we hit the InvalidReceiver check + // before we would hit the InformationMismatch check. + vm.expectRevert(InvalidReceiver.selector); + + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + } + + function testRevert_WhenDestinationCallFlagMismatchesMessage() public { + // Case 1: hasDestinationCall is true but message is empty + bridgeData.hasDestinationCall = true; + validChainflipData.dstCallSwapData = dstSwapData; + validChainflipData.dstCallReceiver = address(0x123); // Set a non-zero address + + vm.startPrank(USER_SENDER); + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(InformationMismatch.selector); + initiateBridgeTxWithFacet(false); + + // Case 2: hasDestinationCall is false but message is not empty + bridgeData.hasDestinationCall = false; + delete dstSwapData; + dstSwapData.push( + LibSwap.SwapData({ + callTo: address(0x123), + approveTo: address(0x123), + sendingAssetId: address(0), + receivingAssetId: address(0), + fromAmount: 0, + callData: "0x123456", + requiresDeposit: false + }) + ); + validChainflipData.dstCallSwapData = dstSwapData; + validChainflipData.dstCallReceiver = address(0x123); // Ensure this is non-zero for the second case too + + vm.expectRevert(InformationMismatch.selector); + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + } + + function testRevert_WhenReceiverAddressIsZero() public { + // Test with regular EVM destination and zero receiver + bridgeData.receiver = address(0); + bridgeData.destinationChainId = CHAIN_ID_ARBITRUM; + bridgeData.hasDestinationCall = false; + + vm.startPrank(USER_SENDER); + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(InvalidReceiver.selector); + initiateBridgeTxWithFacet(false); + + // Test with destination call and zero dstCallReceiver + bridgeData.receiver = USER_RECEIVER; // Set a valid receiver + bridgeData.hasDestinationCall = true; + + // Set up valid swap data + delete dstSwapData; + dstSwapData.push( + LibSwap.SwapData({ + callTo: address(0x123), + approveTo: address(0x123), + sendingAssetId: address(0), + receivingAssetId: address(0), + fromAmount: 0, + callData: "0x123456", + requiresDeposit: false + }) + ); + validChainflipData.dstCallSwapData = dstSwapData; + validChainflipData.dstCallReceiver = address(0); // Zero address for destination call + + vm.expectRevert(InvalidReceiver.selector); + initiateBridgeTxWithFacet(false); + + vm.stopPrank(); + } + + function test_ChainIdMappings() public { + // Set source chain to Arbitrum for these tests + vm.chainId(CHAIN_ID_ARBITRUM); + vm.roll(208460950); // Set specific block number for Arbitrum chain + + vm.startPrank(USER_SENDER); + + // Test Ethereum mapping + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + bridgeData.destinationChainId = CHAIN_ID_ETHEREUM; + initiateBridgeTxWithFacet(false); + + // Test Arbitrum mapping (should fail as same network) + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + bridgeData.destinationChainId = CHAIN_ID_ARBITRUM; + vm.expectRevert(CannotBridgeToSameNetwork.selector); + initiateBridgeTxWithFacet(false); + + // Test Solana mapping + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + bridgeData.destinationChainId = CHAIN_ID_SOLANA; + bridgeData.receiver = LibAsset.NON_EVM_ADDRESS; + validChainflipData.nonEVMReceiver = bytes( + "EoW7FWTdPdZKpd3WAhH98c2HMGHsdh5yhzzEtk1u68Bb" + ); + initiateBridgeTxWithFacet(false); + + // Test Bitcoin mapping + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + bridgeData.destinationChainId = CHAIN_ID_BITCOIN; + validChainflipData.nonEVMReceiver = bytes( + "bc1q6l08rtj6j907r2een0jqs6l7qnruwyxfshmf8a" + ); + initiateBridgeTxWithFacet(false); + + // Test invalid chain ID + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + bridgeData.destinationChainId = 137; // Polygon + vm.expectRevert(ChainflipFacet.UnsupportedChainflipChainId.selector); + initiateBridgeTxWithFacet(false); + + vm.stopPrank(); + } + + function testRevert_WhenUsingEmptyNonEVMAddress() public { + bridgeData.receiver = LibAsset.NON_EVM_ADDRESS; + bridgeData.destinationChainId = CHAIN_ID_SOLANA; + validChainflipData.dstToken = 6; + validChainflipData.nonEVMReceiver = bytes(""); // Empty address should fail + + vm.startPrank(USER_SENDER); + + // approval + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(ChainflipFacet.EmptyNonEvmAddress.selector); + + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + } + + function test_CanBridgeTokensWithDestinationCall() + public + assertBalanceChange( + ADDRESS_USDC, + USER_SENDER, + -int256(defaultUSDCAmount) + ) + assertBalanceChange(ADDRESS_USDC, USER_RECEIVER, 0) + assertBalanceChange(ADDRESS_DAI, USER_SENDER, 0) + assertBalanceChange(ADDRESS_DAI, USER_RECEIVER, 0) + { + bridgeData.destinationChainId = CHAIN_ID_ARBITRUM; + bridgeData.hasDestinationCall = true; + + delete dstSwapData; + dstSwapData.push( + LibSwap.SwapData({ + callTo: address(0x123), + approveTo: address(0x123), + sendingAssetId: address(0), + receivingAssetId: address(0), + fromAmount: 0, + callData: "0x123456", + requiresDeposit: false + }) + ); + + validChainflipData.dstToken = 7; + validChainflipData.dstCallReceiver = address(0x123); + validChainflipData.dstCallSwapData = dstSwapData; + validChainflipData.gasAmount = 100000; + + vm.startPrank(USER_SENDER); + + // approval + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + //prepare check for events + vm.expectEmit(true, true, true, true, _facetTestContractAddress); + emit LiFiTransferStarted(bridgeData); + + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + } + + function testRevert_WhenNonEVMAddressWithEVMReceiver() public { + // Set source chain to Arbitrum for this test + vm.chainId(CHAIN_ID_ARBITRUM); + vm.roll(208460950); // Set specific block number for Arbitrum chain + + // Try to use nonEVMReceiver with an EVM address + bridgeData.receiver = USER_RECEIVER; // Use EVM address + bridgeData.destinationChainId = CHAIN_ID_ETHEREUM; + validChainflipData.dstToken = 6; + validChainflipData.nonEVMReceiver = bytes( + "bc1q6l08rtj6j907r2een0jqs6l7qnruwyxfshmf8a" + ); + + vm.startPrank(USER_SENDER); + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + // Should proceed normally since nonEVMReceiver is ignored for EVM addresses + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + } + + function testRevert_WhenDestinationCallTrueButSwapDataEmpty() public { + // Set up bridge data with destination call flag true + bridgeData.destinationChainId = CHAIN_ID_ARBITRUM; + bridgeData.hasDestinationCall = true; + bridgeData.sendingAssetId = ADDRESS_USDC; + + // Set up chainflip data but leave dstCallSwapData empty + validChainflipData.dstToken = 7; + validChainflipData.dstCallReceiver = address(0x123); + validChainflipData.gasAmount = 100000; + // Deliberately not setting dstCallSwapData + + vm.startPrank(USER_SENDER); + + // Approve spending + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + // Expect revert with InformationMismatch + vm.expectRevert(InformationMismatch.selector); + initiateBridgeTxWithFacet(false); + + vm.stopPrank(); + } +} diff --git a/test/solidity/Periphery/ReceiverChainflip.t.sol b/test/solidity/Periphery/ReceiverChainflip.t.sol new file mode 100644 index 000000000..d0791bbb3 --- /dev/null +++ b/test/solidity/Periphery/ReceiverChainflip.t.sol @@ -0,0 +1,410 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.17; + +import { TestBase, ILiFi, LibSwap, console, ERC20, UniswapV2Router02 } from "../utils/TestBase.sol"; +import { ExternalCallFailed, UnAuthorized, InvalidConfig } from "src/Errors/GenericErrors.sol"; +import { ReceiverChainflip } from "lifi/Periphery/ReceiverChainflip.sol"; +import { LibAsset } from "lifi/Libraries/LibAsset.sol"; +import { stdJson } from "forge-std/Script.sol"; +import { ERC20Proxy } from "lifi/Periphery/ERC20Proxy.sol"; +import { Executor } from "lifi/Periphery/Executor.sol"; +import { MockUniswapDEX, NonETHReceiver } from "../utils/TestHelpers.sol"; + +contract ReceiverChainflipTest is TestBase { + using stdJson for string; + + error ETHTransferFailed(); + + ReceiverChainflip internal receiver; + bytes32 guid = bytes32("12345"); + address receiverAddress = USER_RECEIVER; + address constant CHAINFLIP_NATIVE_ADDRESS = + 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + Executor executor; + ERC20Proxy erc20Proxy; + address chainflipVault; + + event ExecutorSet(address indexed executor); + + function setUp() public { + customBlockNumberForForking = 18277082; + initTestBase(); + + chainflipVault = getConfigAddressFromPath( + "chainflip.json", + ".mainnet.chainflipVault" + ); + vm.label(chainflipVault, "Chainflip Vault"); + + erc20Proxy = new ERC20Proxy(address(this)); + executor = new Executor(address(erc20Proxy), address(this)); + receiver = new ReceiverChainflip( + address(this), + address(executor), + chainflipVault + ); + vm.label(address(receiver), "ReceiverChainflip"); + vm.label(address(executor), "Executor"); + vm.label(address(erc20Proxy), "ERC20Proxy"); + } + + function testRevert_WhenConstructedWithZeroAddresses() public { + // Test zero owner address + vm.expectRevert(InvalidConfig.selector); + new ReceiverChainflip(address(0), address(executor), chainflipVault); + + // Test zero executor address + vm.expectRevert(InvalidConfig.selector); + new ReceiverChainflip(address(this), address(0), chainflipVault); + + // Test zero chainflip vault address + vm.expectRevert(InvalidConfig.selector); + new ReceiverChainflip(address(this), address(executor), address(0)); + } + + function test_ContractIsSetUpCorrectly() public { + receiver = new ReceiverChainflip( + address(this), + address(executor), + chainflipVault + ); + + assertEq(address(receiver.executor()) == address(executor), true); + assertEq(receiver.chainflipVault() == chainflipVault, true); + } + + function testRevert_OnlyChainflipVaultCanCallCfReceive() public { + // mock-send bridged funds to receiver contract + deal(ADDRESS_USDC, address(receiver), defaultUSDCAmount); + + // call from deployer of ReceiverChainflip + vm.startPrank(address(this)); + vm.expectRevert(UnAuthorized.selector); + receiver.cfReceive( + 1, // srcChain (Ethereum) + abi.encodePacked(address(0)), + abi.encode("payload"), + ADDRESS_USDC, + defaultUSDCAmount + ); + vm.stopPrank(); + + // call from random user + vm.startPrank(USER_SENDER); + vm.expectRevert(UnAuthorized.selector); + receiver.cfReceive( + 1, // srcChain (Ethereum) + abi.encodePacked(address(0)), + abi.encode("payload"), + ADDRESS_USDC, + defaultUSDCAmount + ); + vm.stopPrank(); + } + + function test_CanDecodeChainflipPayloadAndExecuteSwapERC20() public { + // mock-send bridged funds to receiver contract + deal(ADDRESS_USDC, address(receiver), defaultUSDCAmount); + + // Store initial balance + uint256 initialReceiverDAI = dai.balanceOf(receiverAddress); + + // encode payload with mock data + ( + bytes memory payload, + uint256 amountOutMin + ) = _getValidChainflipPayload(ADDRESS_USDC, ADDRESS_DAI); + + // fake a call from Chainflip vault + vm.startPrank(chainflipVault); + + vm.expectEmit(true, true, true, true, address(executor)); + emit LiFiTransferCompleted( + guid, + ADDRESS_USDC, + receiverAddress, + amountOutMin, + block.timestamp + ); + + receiver.cfReceive( + 1, // srcChain (Ethereum) + abi.encodePacked(address(0)), + payload, + ADDRESS_USDC, + defaultUSDCAmount + ); + + vm.stopPrank(); + + // Verify balances changed correctly + assertEq( + usdc.balanceOf(address(receiver)), + 0, + "Receiver should have 0 USDC after swap" + ); + assertEq( + dai.balanceOf(receiverAddress), + initialReceiverDAI + amountOutMin, + "Receiver should have received DAI" + ); + } + + function test_WillReturnFundsToUserIfDstCallFails() public { + // mock-send bridged funds to receiver contract + deal(ADDRESS_USDC, address(receiver), defaultUSDCAmount); + + // encode payload with mock data + string memory revertReason = "Just because"; + MockUniswapDEX mockDEX = new MockUniswapDEX(); + + LibSwap.SwapData[] memory swapData = new LibSwap.SwapData[](1); + swapData[0] = LibSwap.SwapData({ + callTo: address(mockDEX), + approveTo: address(mockDEX), + sendingAssetId: ADDRESS_USDC, + receivingAssetId: ADDRESS_USDC, + fromAmount: defaultUSDCAmount, + callData: abi.encodeWithSelector( + mockDEX.mockSwapWillRevertWithReason.selector, + revertReason + ), + requiresDeposit: false + }); + + bytes memory payload = abi.encode(guid, swapData, receiverAddress); + + vm.startPrank(chainflipVault); + + vm.expectEmit(true, true, true, true, address(receiver)); + emit LiFiTransferRecovered( + guid, + ADDRESS_USDC, + receiverAddress, + defaultUSDCAmount, + block.timestamp + ); + receiver.cfReceive( + 4, // srcChain (Arbitrum) + abi.encodePacked(address(0)), + payload, + ADDRESS_USDC, + defaultUSDCAmount + ); + + assertTrue(usdc.balanceOf(receiverAddress) == defaultUSDCAmount); + } + + function test_WillReturnNativeTokensToUserIfDstCallFails() public { + // Fund chainflipVault with native token as well + vm.deal(chainflipVault, 1 ether); + + // Create mock DEX that will revert + string memory revertReason = "Just because"; + MockUniswapDEX mockDEX = new MockUniswapDEX(); + + LibSwap.SwapData[] memory swapData = new LibSwap.SwapData[](1); + swapData[0] = LibSwap.SwapData({ + callTo: address(mockDEX), + approveTo: address(mockDEX), + sendingAssetId: LibAsset.NATIVE_ASSETID, + receivingAssetId: ADDRESS_DAI, + fromAmount: 1 ether, + callData: abi.encodeWithSelector( + mockDEX.mockSwapWillRevertWithReason.selector, + revertReason + ), + requiresDeposit: false + }); + + bytes memory payload = abi.encode(guid, swapData, receiverAddress); + + uint256 initialBalance = receiverAddress.balance; + + vm.startPrank(chainflipVault); + + vm.expectEmit(true, true, true, true, address(receiver)); + emit LiFiTransferRecovered( + guid, + LibAsset.NATIVE_ASSETID, + receiverAddress, + 1 ether, + block.timestamp + ); + + receiver.cfReceive{ value: 1 ether }( + 4, // srcChain (Arbitrum) + abi.encodePacked(address(0)), + payload, + CHAINFLIP_NATIVE_ADDRESS, + 1 ether + ); + + assertEq(receiverAddress.balance, initialBalance + 1 ether); + vm.stopPrank(); + } + + function testRevert_IfNativeRecoveryTransferFailsToSendETHToUser() public { + // Fund receiver with native token + vm.deal(address(receiver), 1 ether); + // Fund chainflipVault with native token + vm.deal(chainflipVault, 1 ether); + + // Create a contract that can't receive ETH + NonETHReceiver nonEthReceiver = new NonETHReceiver(); + + // Create mock DEX that will revert + MockUniswapDEX mockDEX = new MockUniswapDEX(); + + // Create swap data that will fail when trying to send ETH + LibSwap.SwapData[] memory swapData = new LibSwap.SwapData[](1); + swapData[0] = LibSwap.SwapData({ + callTo: address(mockDEX), + approveTo: address(mockDEX), + sendingAssetId: LibAsset.NATIVE_ASSETID, + receivingAssetId: LibAsset.NATIVE_ASSETID, // Try to get ETH back + fromAmount: 1 ether, + callData: abi.encodeWithSelector( + mockDEX.mockSwapWillRevertWithReason.selector, + "Mock swap failed" + ), + requiresDeposit: false + }); + + bytes memory payload = abi.encode( + guid, + swapData, + address(nonEthReceiver) + ); + + vm.startPrank(chainflipVault); + + vm.expectRevert(ETHTransferFailed.selector); + receiver.cfReceive{ value: 1 ether }( + 4, // srcChain (Arbitrum) + abi.encodePacked(address(0)), + payload, + CHAINFLIP_NATIVE_ADDRESS, + 1 ether + ); + + vm.stopPrank(); + } + + function test_CanDecodeChainflipPayloadAndExecuteSwapNative() public { + // fund chainflipVault with native token + vm.deal(chainflipVault, 0.01 ether); + + // Store initial balance + uint256 initialReceiverDAI = dai.balanceOf(receiverAddress); + + // encode payload with mock data + ( + bytes memory payload, + uint256 amountOutMin + ) = _getValidChainflipPayloadNative(0.01 ether); + + // fake a call from Chainflip vault + vm.startPrank(chainflipVault); + + vm.expectEmit(); + emit LiFiTransferCompleted( + guid, + LibAsset.NATIVE_ASSETID, + receiverAddress, + amountOutMin, + block.timestamp + ); + + receiver.cfReceive{ value: 0.01 ether }( + 4, // srcChain (Arbitrum) + abi.encodePacked(address(0)), + payload, + CHAINFLIP_NATIVE_ADDRESS, + 0.01 ether + ); + vm.stopPrank(); + + // Verify balances changed correctly + assertEq( + address(receiver).balance, + 0, + "Receiver should have 0 ETH after swap" + ); + assertEq( + dai.balanceOf(receiverAddress), + initialReceiverDAI + amountOutMin, + "Receiver should have received DAI" + ); + } + + // HELPER FUNCTIONS + function _getValidChainflipPayload( + address _sendingAssetId, + address _receivingAssetId + ) public view returns (bytes memory callData, uint256 amountOutMin) { + // create swapdata + address[] memory path = new address[](2); + path[0] = _sendingAssetId; + path[1] = _receivingAssetId; + + uint256 amountIn = defaultUSDCAmount; + amountOutMin = defaultUSDCAmount; // Use same amount for exact output + + LibSwap.SwapData[] memory swapData = new LibSwap.SwapData[](1); + swapData[0] = LibSwap.SwapData({ + callTo: address(uniswap), + approveTo: address(uniswap), + sendingAssetId: _sendingAssetId, + receivingAssetId: _receivingAssetId, + fromAmount: amountIn, + callData: abi.encodeWithSelector( + uniswap.swapTokensForExactTokens.selector, + amountOutMin, + amountIn, // Max input amount + path, + address(executor), + block.timestamp + 20 minutes + ), + requiresDeposit: true + }); + + // this is the "message" that we would receive from the other chain + callData = abi.encode(guid, swapData, receiverAddress); + } + + // Add helper function for native token swap data + function _getValidChainflipPayloadNative( + uint256 amountIn + ) public view returns (bytes memory callData, uint256 amountOutMin) { + // create swapdata for ETH -> DAI + address[] memory path = new address[](2); + path[0] = ADDRESS_WRAPPED_NATIVE; + path[1] = ADDRESS_DAI; + + // Calculate ETH input amount + uint256[] memory amounts = uniswap.getAmountsOut(amountIn, path); + amountOutMin = amounts[1]; + + LibSwap.SwapData[] memory swapData = new LibSwap.SwapData[](1); + swapData[0] = LibSwap.SwapData({ + callTo: address(uniswap), + approveTo: address(uniswap), + sendingAssetId: LibAsset.NATIVE_ASSETID, + receivingAssetId: ADDRESS_DAI, + fromAmount: amountIn, + callData: abi.encodeWithSelector( + uniswap.swapExactETHForTokens.selector, + amountOutMin, + path, + address(executor), + block.timestamp + 20 minutes + ), + requiresDeposit: true + }); + + // this is the "message" that we would receive from the other chain + callData = abi.encode(guid, swapData, receiverAddress); + } +} diff --git a/test/solidity/utils/TestBase.sol b/test/solidity/utils/TestBase.sol index 14813f95d..6dde098b0 100644 --- a/test/solidity/utils/TestBase.sol +++ b/test/solidity/utils/TestBase.sol @@ -17,6 +17,9 @@ import { LibAccess } from "lifi/Libraries/LibAccess.sol"; import { console } from "test/solidity/utils/Console.sol"; import { FeeCollector } from "lifi/Periphery/FeeCollector.sol"; import { NoSwapDataProvided, InformationMismatch, NativeAssetTransferFailed, ReentrancyError, InsufficientBalance, CannotBridgeToSameNetwork, InvalidReceiver, InvalidAmount, InvalidConfig, InvalidSendingToken, AlreadyInitialized, NotInitialized, UnAuthorized } from "src/Errors/GenericErrors.sol"; +import { stdJson } from "forge-std/StdJson.sol"; + +using stdJson for string; contract TestFacet { constructor() {} @@ -530,5 +533,18 @@ abstract contract TestBase is Test, DiamondTest, ILiFi { uint256 targetBlock = block.number + numBlocks; vm.roll(targetBlock); } + + function getConfigAddressFromPath( + string memory configFileName, + string memory jsonPath + ) internal returns (address) { + string memory path = string.concat( + vm.projectRoot(), + "/config/", + configFileName + ); + string memory json = vm.readFile(path); + return json.readAddress(jsonPath); + } //#endregion }