diff --git a/.gas-snapshot b/.gas-snapshot index 9d84617e..8d99b218 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,6 +1,7 @@ ApproveAndSwapTest:testSwap() (gas: 285500) ApproveAndSwapTest:testSwapFailsIfWeExpectedTooMuch() (gas: 364666) ApproveAndSwapTest:testSwapFailsWithNoApproval() (gas: 122231) +CCTPBridge:testBridgeToBase() (gas: 146874) CometClaimRewardsTest:testClaimComp() (gas: 131265) CometRepayAndWithdrawMultipleAssetsTest:testInvalidInput() (gas: 68171) CometRepayAndWithdrawMultipleAssetsTest:testRepayAndWithdrawMultipleAssets() (gas: 153860) diff --git a/foundry.toml b/foundry.toml index df930182..752bd8ad 100644 --- a/foundry.toml +++ b/foundry.toml @@ -7,9 +7,10 @@ evm_version = "paris" libs = [ "./lib" ] +[profile.ir] via_ir = true optimizer = true optimizer_runs = 100000000 bytecode_hash = "none" -cbor_metadata = false \ No newline at end of file +cbor_metadata = false diff --git a/src/BridgeScripts.sol b/src/BridgeScripts.sol new file mode 100644 index 00000000..cd223c08 --- /dev/null +++ b/src/BridgeScripts.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.23; + +import {IERC20} from "openzeppelin/token/ERC20/IERC20.sol"; +import {ITokenMessenger} from "./interfaces/ITokenMessenger.sol"; + +contract CCTPBridgeActions { + function bridgeUSDC( + address tokenMessenger, + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken + ) external { + IERC20(burnToken).approve(tokenMessenger, amount); + ITokenMessenger(tokenMessenger).depositForBurn(amount, destinationDomain, mintRecipient, burnToken); + } +} diff --git a/src/builder/BridgeRoutes.sol b/src/builder/BridgeRoutes.sol new file mode 100644 index 00000000..dd3686b3 --- /dev/null +++ b/src/builder/BridgeRoutes.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.23; + +import "./Strings.sol"; + +library BridgeRoutes { + enum BridgeType { + NONE, + CCTP + } + + struct Bridge { + // Note: Cannot name these `address` nor `type` because those are both reserved keywords + address bridgeAddress; + BridgeType bridgeType; + } + + function hasBridge(uint256 srcChainId, uint256 dstChainId, string memory assetSymbol) internal pure returns (bool) { + if (getBridge(srcChainId, dstChainId, assetSymbol).bridgeType == BridgeType.NONE) { + return false; + } else { + return true; + } + } + + function getBridge(uint256 srcChainId, uint256 dstChainId, string memory assetSymbol) internal pure returns (Bridge memory) { + if (srcChainId == 1) { + return getBridgeForMainnet(dstChainId, assetSymbol); + } else if (srcChainId == 8453) { + return getBridgeForBase(dstChainId, assetSymbol); + } else { + return Bridge({ + bridgeAddress: address(0), + bridgeType: BridgeType.NONE + }); + // revert BridgeNotFound(1, dstChainid, assetSymbol); + } + } + + function getBridgeForMainnet(uint256 /*dstChainId*/, string memory assetSymbol) internal pure returns (Bridge memory) { + if (Strings.stringEqIgnoreCase(assetSymbol, "USDC")) { + return Bridge({ + bridgeAddress: 0xBd3fa81B58Ba92a82136038B25aDec7066af3155, + bridgeType: BridgeType.CCTP + }); + } else { + return Bridge({ + bridgeAddress: address(0), + bridgeType: BridgeType.NONE + }); + // revert BridgeNotFound(1, dstChainid, assetSymbol); + } + } + + function getBridgeForBase(uint256 /*dstChainId*/, string memory assetSymbol) internal pure returns (Bridge memory) { + if (Strings.stringEqIgnoreCase(assetSymbol, "USDC")) { + return Bridge({ + bridgeAddress: 0x1682Ae6375C4E4A97e4B583BC394c861A46D8962, + bridgeType: BridgeType.CCTP + }); + } else { + return Bridge({ + bridgeAddress: address(0), + bridgeType: BridgeType.NONE + }); + // revert BridgeNotFound(1, dstChainid, assetSymbol); + } + } +} diff --git a/src/builder/QuarkBuilder.sol b/src/builder/QuarkBuilder.sol new file mode 100644 index 00000000..bcc6830a --- /dev/null +++ b/src/builder/QuarkBuilder.sol @@ -0,0 +1,394 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.23; + +import {IQuarkWallet} from "quark-core/src/interfaces/IQuarkWallet.sol"; +import {TransferActions} from "../DeFiScripts.sol"; +import {CCTPBridgeActions} from "../BridgeScripts.sol"; + +import "./BridgeRoutes.sol"; +import "./Strings.sol"; + +contract QuarkBuilder { + /* ===== Constants ===== */ + string constant VERSION = "1.0.0"; + + string constant PAYMENT_METHOD_OFFCHAIN = "OFFCHAIN"; + string constant PAYMENT_METHOD_PAYCALL = "PAY_CALL"; + string constant PAYMENT_METHOD_QUOTECALL = "QUOTE_CALL"; + + string constant ACTION_TYPE_BRIDGE = "BRIDGE"; + string constant ACTION_TYPE_TRANSFER = "TRANSFER"; + + string constant PAYMENT_CURRENCY_USD = "usd"; + string constant PAYMENT_CURRENCY_USDC = "usdc"; + + /* ===== Custom Errors ===== */ + + error AssetPositionNotFound(); + error BridgeNotFound(uint256 srcChainId, uint256 dstChainId, string assetSymbol); + error FundsUnavailable(); + error InsufficientFunds(); + error InvalidInput(); + error MaxCostTooHigh(); + + /* ===== Input Types ===== */ + + struct ChainAccounts { + uint256 chainId; + QuarkState[] quarkStates; + AssetPositions[] assetPositionsList; + } + + // We map this to the Portfolio data structure that the client will already have. + // This includes fields that builder may not necessarily need, however it makes + // the client encoding that much simpler. + struct QuarkState { + address account; + bool hasCode; + bool isQuark; + string quarkVersion; + uint256 quarkNextNonce; + } + + // Similarly, this is designed to intentionally reduce the encoding burden for the client + // by making it equivalent in structure to data already in portfolios. + struct AssetPositions { + address asset; + string symbol; + uint256 decimals; + uint256 usdPrice; + AccountBalance[] accountBalances; + } + + struct AccountBalance { + address account; + uint256 balance; + } + + struct Payment { + bool isToken; + // Note: Payment `currency` should be the same across chains + string currency; + PaymentMaxCost[] maxCosts; + } + + struct PaymentMaxCost { + uint256 chainId; + uint256 amount; + } + + /* ===== Output Types ===== */ + + struct BuilderResult { + // version of the builder interface. (Same as VERSION, but attached to the output.) + string version; + // array of quark operations to execute to fulfill the client intent + IQuarkWallet.QuarkOperation[] quarkOperations; + // array of action context and other metadata corresponding 1:1 with quarkOperations + QuarkAction[] quarkActions; + // EIP-712 digest to sign for a MultiQuarkOperation to fulfill the client intent. + // Empty when quarkOperations.length == 0. + bytes multiQuarkOperationDigest; + // EIP-712 digest to sign for a single QuarkOperation to fulfill the client intent. + // Empty when quarkOperations.length != 1. + bytes quarkOperationDigest; + // client-provided paymentCurrency string that was used to derive token addresses. + // client may re-use this string to construct a request that simulates the transaction. + string paymentCurrency; + } + + // With QuarkAction, we try to define fields that are as 1:1 as possible with the + // simulate endpoint request schema. + struct QuarkAction { + uint256 chainId; + string actionType; + bytes actionContext; + // One of the PAYMENT_METHOD_* constants. + string paymentMethod; + // Address of payment token on chainId. + // Null address if the payment method was OFFCHAIN. + address paymentToken; + uint256 paymentMaxCost; + } + + struct TransferActionContext { + uint256 amount; + uint256 price; + address token; + uint256 chainId; + address recipient; + } + + struct BridgeActionContext { + uint256 amount; + uint256 price; + address token; + uint256 chainId; + address recipient; + uint256 destinationChainId; + } + + /* ===== Internal/Intermediate Types ===== */ + + // Note: This is just the AssetPositions type with an extra `chainId` field + struct AssetPositionsWithChainId { + uint256 chainId; + address asset; + string symbol; + uint256 decimals; + uint256 usdPrice; + AccountBalance[] accountBalances; + } + + function filterChainAccounts(string memory assetSymbol, ChainAccounts[] memory chainAccountsList) + internal + pure + returns (ChainAccounts[] memory filtered) + { + filtered = new ChainAccounts[](chainAccountsList.length); + for (uint256 i = 0; i < chainAccountsList.length; ++i) { + // NOTE: there can only be one asset positions struct for a given asset on a given chain. + AssetPositions memory selectedPositions; + for (uint256 j = 0; j < chainAccountsList[i].assetPositionsList.length; ++j) { + if (Strings.stringEqIgnoreCase(assetSymbol, chainAccountsList[i].assetPositionsList[j].symbol)) { + selectedPositions = chainAccountsList[i].assetPositionsList[j]; + break; + } + } + + AssetPositions[] memory positionsList; + if (selectedPositions.asset != address(0)) { + positionsList = new AssetPositions[](1); + positionsList[0] = selectedPositions; + } + + filtered[i] = ChainAccounts({ + chainId: chainAccountsList[i].chainId, + quarkStates: chainAccountsList[i].quarkStates, + assetPositionsList: positionsList + }); + } + } + + function findChainAccounts(uint256 chainId, ChainAccounts[] memory chainAccountsList) + internal + pure + returns (ChainAccounts memory found) + { + for (uint256 i = 0; i < chainAccountsList.length; ++i) { + if (chainAccountsList[i].chainId == chainId) { + return found = chainAccountsList[i]; + } + } + } + + function findAssetPositions(string memory assetSymbol, AssetPositions[] memory assetPositionsList) + internal + pure + returns (AssetPositions memory found) + { + for (uint256 i = 0; i < assetPositionsList.length; ++i) { + if (Strings.stringEqIgnoreCase(assetSymbol, assetPositionsList[i].symbol)) { + return found = assetPositionsList[i]; + } + } + } + + function sumBalances(AssetPositions memory assetPositions) internal pure returns (uint256) { + uint256 totalBalance = 0; + for (uint j = 0; j < assetPositions.accountBalances.length; ++j) { + totalBalance += assetPositions.accountBalances[j].balance; + } + return totalBalance; + } + + // TODO: handle transfer max + // TODO: support expiry + function transfer( + uint256 chainId, + string calldata assetSymbol, + uint256 amount, + address recipient, + Payment calldata payment, + ChainAccounts[] calldata chainAccountsList + ) external pure returns (BuilderResult memory) { + ChainAccounts[] memory transferChainAccounts = filterChainAccounts(assetSymbol, chainAccountsList); + ChainAccounts[] memory paymentChainAccounts; + if (payment.isToken) { + paymentChainAccounts = filterChainAccounts(payment.currency, chainAccountsList); + } + + // INSUFFICIENT_FUNDS + // There are not enough aggregate funds on all chains to fulfill the transfer. + { + uint256 aggregateTransferAssetBalance; + for (uint256 i = 0; i < transferChainAccounts.length; ++i) { + aggregateTransferAssetBalance += sumBalances(findAssetPositions(assetSymbol, transferChainAccounts[i].assetPositionsList)); + } + if (aggregateTransferAssetBalance < amount) { + revert InsufficientFunds(); + } + } + + // TODO: Pay with bridged payment.currency? + // MAX_COST_TOO_HIGH + // There is at least one chain that does not have sufficient payment assets to cover the maxCost for that chain. + // Note: This check assumes we will not be bridging payment tokens for the user + if (payment.isToken) { + for (uint i = 0; i < payment.maxCosts.length; ++i) { + uint256 paymentAssetBalanceOnChain = sumBalances( + findAssetPositions( + assetSymbol, + findChainAccounts(payment.maxCosts[i].chainId, paymentChainAccounts) + .assetPositionsList + ) + ); + uint256 paymentAssetNeeded = payment.maxCosts[i].amount; + // If the payment token is the transfer token and this is the target chain, we need to account for the transfer amount when checking token balances + if (Strings.stringEqIgnoreCase(payment.currency, assetSymbol) && chainId == payment.maxCosts[i].chainId) { + paymentAssetNeeded += amount; + } + if (paymentAssetBalanceOnChain < paymentAssetNeeded) { + revert MaxCostTooHigh(); + } + } + } + + // FUNDS_UNAVAILABLE + // For some reason, funds that may otherwise be bridgeable or held by the user cannot be made available to fulfill the transaction. + // Funds cannot be bridged, e.g. no bridge exists + // Funds cannot be withdrawn from comet, e.g. no reserves + // In order to consider the availability here, we’d need comet data to be passed in as an input. (So, if we were including withdraw.) + { + uint256 aggregateTransferAssetAvailableBalance; + for (uint i = 0; i < transferChainAccounts.length; ++i) { + for (uint j = 0; j < transferChainAccounts[i].assetPositionsList[0].accountBalances.length; ++j) { + if (BridgeRoutes.hasBridge(transferChainAccounts[i].chainId, chainId, assetSymbol)) { + aggregateTransferAssetAvailableBalance += transferChainAccounts[i].assetPositionsList[0].accountBalances[j].balance; + } + } + } + if (aggregateTransferAssetAvailableBalance < amount) { + revert FundsUnavailable(); + } + } + + // Construct Quark Operations: + + // If Payment.isToken: + // Wrap Quark operation around a Paycall/Quotecall + // Process for generating Paycall transaction: + + // We need to find the (payment token address, payment token price feed address) to derive the CREATE2 address of the Paycall script + // TODO: define helper function to get (payment token address, payment token price feed address) given a chain ID + + // TODO: + // If not enough assets on the chain ID: + // Then bridging is required AND/OR withdraw from Comet is required + // Prepend a bridge action to the list of actions + // Bridge `amount` of `chainAsset` to `recipient` + IQuarkWallet.QuarkOperation memory bridgeQuarkOperation; + // TODO: implement get assetBalanceOnChain + uint256 localBalance = sumBalances(findChainAccounts(chainId, transferChainAccounts).assetPositionsList[0]); + // Note: User will always have enough payment token on destination chain, since we already check that in the MaxCostTooHigh() check + if (localBalance < amount) { + // Construct bridge operation if not enough funds on target chain + // TODO: bridge routing logic (which bridge to prioritize, how many bridges?) + + // TODO: construct action contexts + if (payment.isToken) { + // wrap around paycall + } else { + bytes[] memory scriptSources = new bytes[](1); + scriptSources[0] = type(CCTPBridgeActions).creationCode; + address scriptAddress = address(0); + /* + getCodeAddress( + address(0), // IQuarkWallet(accountBalances[i].account).factory().codeJar() + type(CCTPBridgeActions).creationCode + ); + */ + bridgeQuarkOperation = IQuarkWallet.QuarkOperation({ + nonce: 0, // TODO: get next nonce + scriptAddress: scriptAddress, + scriptCalldata: abi.encodeWithSelector( + CCTPBridgeActions.bridgeUSDC.selector, + recipient, + amount + ), + scriptSources: scriptSources, + expiry: 99999999999 // TODO: never expire? + }); + } + } + + // Then, transfer `amount` of `chainAsset` to `recipient` + IQuarkWallet.QuarkOperation memory transferQuarkOperation; + // TODO: construct action contexts + if (Strings.stringEqIgnoreCase(assetSymbol, "ETH")) { + if (payment.isToken) { + // wrap around paycall + } else { + // Native ETH transfer + transferQuarkOperation = ERC20Transfer(recipient, amount, paymentChainAccounts); + } + } else { + if (payment.isToken) { + // wrap around paycall + } else { + // ERC20 transfer + transferQuarkOperation = ERC20Transfer(recipient, amount, paymentChainAccounts); + } + } + + // TODO: construct QuarkOperation of size 1 or 2 depending on bridge or not + // return QuarkAction({ + // version: version, + // actionType: actionType, + // actionContext: actionContext, + // operations: operations + // }); + + return BuilderResult({ + version: VERSION, + quarkOperations: new IQuarkWallet.QuarkOperation[](0), + quarkActions: new QuarkAction[](0), + multiQuarkOperationDigest: new bytes(0), + quarkOperationDigest: new bytes(0), + paymentCurrency: payment.currency + }); + } + + function ERC20Transfer(address recipient, uint256 amount, ChainAccounts[] memory paymentChainAccounts) internal pure returns (IQuarkWallet.QuarkOperation memory) { + bytes[] memory scriptSources = new bytes[](1); + scriptSources[0] = type(TransferActions).creationCode; + /* + getCodeAddress( + address(0), // IQuarkWallet(accountBalances[i].account).factory().codeJar() + type(CCTPBridgeActions).creationCode + ); + */ + // ERC20 transfer + return IQuarkWallet.QuarkOperation({ + nonce: 0, // TODO: get next nonce + scriptAddress: address(0), + scriptCalldata: abi.encodeWithSelector( + TransferActions.transferERC20Token.selector, + // TODO: this needs to be per-chain correct + paymentChainAccounts[0].assetPositionsList[0].asset, + recipient, + amount + ), + scriptSources: scriptSources, + expiry: 99999999999 // TODO: never expire? + }); + } +} + + +// 1. Input validation (custom errors) +// 2. Constructing the operation +// a) Bridge operation (conditional) +// b) Wrap around Paycall/Quotecall (conditional) +// c) Transfer operation (non-conditional) +// 3. Constructing the BuilderResult (action contexts, eip-712 digest) diff --git a/src/builder/Strings.sol b/src/builder/Strings.sol new file mode 100644 index 00000000..3b722188 --- /dev/null +++ b/src/builder/Strings.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.23; + +library Strings { + function stringEq(string memory a, string memory b) internal pure returns (bool) { + return keccak256(abi.encodePacked(a)) == keccak256(abi.encodePacked(b)); + } + + function stringEqIgnoreCase(string memory a, string memory b) internal pure returns (bool) { + return keccak256(abi.encodePacked(toLowerCase(a))) == keccak256(abi.encodePacked(toLowerCase(b))); + } + + function toLowerCase(string memory str) internal pure returns (string memory) { + bytes memory strBytes = bytes(str); + for (uint i = 0; i < strBytes.length; i++) { + if (strBytes[i] >= 0x41 && strBytes[i] <= 0x5A) { + strBytes[i] = bytes1(uint8(strBytes[i]) + 32); + } + } + return string(strBytes); + } +} diff --git a/src/interfaces/ITokenMessenger.sol b/src/interfaces/ITokenMessenger.sol new file mode 100644 index 00000000..99c94ed1 --- /dev/null +++ b/src/interfaces/ITokenMessenger.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.23; + +interface ITokenMessenger { + function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken) + external + returns (uint64 _nonce); +} diff --git a/test/CCTPBridgeActions.t.sol b/test/CCTPBridgeActions.t.sol new file mode 100644 index 00000000..933d528c --- /dev/null +++ b/test/CCTPBridgeActions.t.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.23; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import "forge-std/StdUtils.sol"; +import "forge-std/StdMath.sol"; + +import {CodeJar} from "codejar/src/CodeJar.sol"; + +import {QuarkWallet} from "quark-core/src/QuarkWallet.sol"; +import {QuarkStateManager} from "quark-core/src/QuarkStateManager.sol"; + +import {QuarkWalletProxyFactory} from "quark-proxy/src/QuarkWalletProxyFactory.sol"; + +import {YulHelper} from "./lib/YulHelper.sol"; +import {SignatureHelper} from "./lib/SignatureHelper.sol"; +import {QuarkOperationHelper, ScriptType} from "./lib/QuarkOperationHelper.sol"; + +import "src/BridgeScripts.sol"; + +/** + * Tests for CCTP Bridge + */ +contract CCTPBridge is Test { + QuarkWalletProxyFactory public factory; + uint256 alicePrivateKey = 0xa11ce; + address alice = vm.addr(alicePrivateKey); + + // Contracts address on mainnet + address constant tokenMessenger = 0xBd3fa81B58Ba92a82136038B25aDec7066af3155; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + // Domain value for the CCTP bridge, DIFFERENT from chain id + uint32 constant mainnetDomain = 0; + uint32 constant baseDomain = 6; + + function setUp() public { + // Fork setup + vm.createSelectFork( + string.concat( + "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") + ), + 18429607 // 2023-10-25 13:24:00 PST + ); + factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); + } + + function testBridgeToBase() public { + vm.pauseGasMetering(); + QuarkWallet wallet = QuarkWallet(factory.create(alice, address(0))); + bytes memory cctpBridgeScript = new YulHelper().getCode("BridgeScripts.sol/CCTPBridgeActions.json"); + + deal(USDC, address(wallet), 1_000_000e6); + + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + wallet, + cctpBridgeScript, + abi.encodeCall( + CCTPBridgeActions.bridgeUSDC, + (tokenMessenger, 500_000e6, 6, bytes32(uint256(uint160(address(wallet)))), USDC) + ), + ScriptType.ScriptSource + ); + (uint8 v, bytes32 r, bytes32 s) = new SignatureHelper().signOp(alicePrivateKey, wallet, op); + + assertEq(IERC20(USDC).balanceOf(address(wallet)), 1_000_000e6); + vm.resumeGasMetering(); + wallet.executeQuarkOperation(op, v, r, s); + assertEq(IERC20(USDC).balanceOf(address(wallet)), 500_000e6); + } +}