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/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..36cb4d14 --- /dev/null +++ b/src/builder/BridgeRoutes.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.23; + +library BridgeRoutes { + enum BridgeType { + NONE, + CCTP + } + + uint256 constant BASE_CHAIN_ID = 8453; + uint256 constant MAINNET_CHAIN_ID = 1; + + mapping(uint256 => uint32) public chainIdToCCTPDomainId; + + constructor() { + // Right now we only have Mainnet and Base + chainIdToCCTPDomainId[MAINNET_CHAIN_ID] = 0; + chainIdToCCTPDomainId[BASE_CHAIN_ID] = 6; + } + + struct Bridge { + // Note: Cannot name these `address` nor `type` because those are both reserved keywords + address bridgeAddress; + BridgeType bridgeType; + uint32 domainId; + } + + 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 getBridge(uint256 srcChainId, uint256 dstChainId, string memory assetSymbol) internal pure returns (Bridge memory) { + if (srcChainId == MAINNET_CHAIN_ID) { + return getBridgeForMainnet(dstChainId, assetSymbol); + } else if (srcChainId == BASE_CHAIN_ID) { + 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 (dstChainId == MAINNET_CHAIN_ID) { + revert("Cannot bridge to the same chain"); + } + + if (compareStrings(assetSymbol, "USDC")) { + return Bridge({ + bridgeAddress: 0xBd3fa81B58Ba92a82136038B25aDec7066af3155, + bridgeType: BridgeType.CCTP, + domainId: chainIdToCCTPDomainId[dstChainId] + }); + } 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 (dstChainId == BASE_CHAIN_ID) { + revert("Cannot bridge to the same chain"); + } + + if (compareStrings(assetSymbol, "USDC")) { + return Bridge({ + bridgeAddress: 0x1682Ae6375C4E4A97e4B583BC394c861A46D8962, + bridgeType: BridgeType.CCTP, + domainId: chainIdToCCTPDomainId[dstChainId] + }); + } 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..1671b16d --- /dev/null +++ b/src/builder/QuarkBuilder.sol @@ -0,0 +1,530 @@ +// 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 {Paycall} from "../Paycall.sol"; + +import "./BridgeRoutes.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"; + + uint256 constant BRIDGE_COST_OFFSET_USDC = 1_000_000; + uint256 constant BRIDGE_MINIMUM_AMOUNT_USDC = 1_000_000; + uint256 constant MAX_BRIDGE_ACTION = 2; + /* ===== 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 { + // TODO: rename? + bool isToken; + string currency; + // TODO: make into struct? + uint256[] chainIds; + uint256[] maxCosts; + } + + /* ===== 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 + QuarkWallet.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; + } + + // TODO: convert strings to lower case before comparing. maybe rename to `compareSymbols` + function compareStrings(string memory a, string memory b) internal pure returns (bool) { + return keccak256(abi.encodePacked((a))) == keccak256(abi.encodePacked((b))); + } + + function filterAssetPositionsForSymbol(string memory assetSymbol, ChainAccount[] memory chainAccountsList) internal pure returns (AssetPositionsWithChainId[] memory) { + uint numMatches = 0; + // First loop to count the number of matching AssetPositions + for (uint i = 0; i < chainAccountsList.length; ++i) { + for (uint j = 0; j < chainAccountsList[i].assetPositionsList.length; ++j) { + if (compareStrings(chainAccountsList[i].assetPositionsList[j].symbol, assetSymbol)) { + numMatches++; + } + } + } + + AssetPositionsWithChainId[] memory matchingAssetPositions = new AssetPositionsWithChainId[](numMatches); + uint index = 0; + // Second loop to populate the matchingAssetPositions array + for (uint i = 0; i < chainAccountsList.length; ++i) { + for (uint j = 0; j < chainAccountsList[i].assetPositionsList.length; ++j) { + if (compareStrings(chainAccountsList[i].assetPositionsList[j].symbol, assetSymbol)) { + AssetPositions memory assetPositions = chainAccountsList[i].assetPositionsList[j]; + matchingAssetPositions[index] = AssetPositionsWithChainId({ + chainId: chainAccountsList[i].chainId, + asset: assetPositions.asset, + symbol: assetPositions.symbol, + decimals: assetPositions.decimals, + usdPrice: assetPositions.usdPrice, + accountBalances: assetPositions.accountBalances + }); + index++; + } + } + } + + return matchingAssetPositions; + } + + function getAssetPositionForSymbolAndChain(string memory assetSymbol, uint256 chainId, ChainAccount[] memory chainAccountsList) internal pure returns (AssetPositionsWithChainId memory) { + uint index = 0; + // Second loop to populate the matchingAssetPositions array + for (uint i = 0; i < chainAccountsList.length; ++i) { + if (chainAccountsList[i].chainId != chainId) { + continue; + } + + for (uint j = 0; j < chainAccountsList[i].assetPositionsList.length; ++j) { + if (compareStrings(chainAccountsList[i].assetPositionsList[j].symbol, assetSymbol)) { + AssetPositions memory assetPositions = chainAccountsList[i].assetPositionsList[j]; + return AssetPositionsWithChainId({ + chainId: chainAccountsList[i].chainId, + asset: assetPositions.asset, + symbol: assetPositions.symbol, + decimals: assetPositions.decimals, + usdPrice: assetPositions.usdPrice, + accountBalances: assetPositions.accountBalances + }); + } + } + } + + revert AssetPositionNotFound(); + } + + function sumUpBalances(AssetPositionsWithChainId 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 assetSymbol, + uint256 amount, + address recipient, + Payment calldata payment, + ChainAccounts[] calldata chainAccountsList + ) external pure returns (BuilderResult memory) { + if (payment.chainIds.length != payment.maxCosts.length) { + revert InvalidInput(); + } + + AssetPositionsWithChainId[] transferAssetPositions = filterAssetPositionsForSymbol(assetSymbol, chainAccountsList); + AssetPositionsWithChainId[] paymentAssetPositions; + if (payment.isToken) { + paymentAssetPositions = filterAssetPositionsForSymbol(payment.currency, chainAccountsList); + } + + // INSUFFICIENT_FUNDS + // There are not enough aggregate funds on all chains to fulfill the transfer. + uint256 aggregateTransferAssetBalance; + for (uint i = 0; i < transferAssetPositions.length; ++i) { + for (uint j = 0; j < transferAssetPositions[i].accountBalances.length; ++j) { + aggregateTransferAssetBalance += transferAssetPositions[i].accountBalances[j].balance; + } + } + if (aggregateTransferAssetBalance < amount) { + revert InsufficientFunds(); + } + + // TODO: Pay with bridged USDC? + // MAX_COST_TOO_HIGH + // There are not enough funds on each chain to satisfy the total max payment cost, after transferring. + // (amount of payment token on chain id - transfer amount (IF IS SAME TOKEN AND SAME CHAIN ID)) < maxPaymentAmount on chain id + // Note: This check assumes we will not be bridging payment tokens for the user + if (payment.isToken) { + for (uint i = 0; i < payment.chainIds.length; ++i) { + AssetPositionsWithChainId memory paymentAssetPosition = getAssetPositionForSymbolAndChain(payment.currency, payment.chainIds[i], chainAccountsList); + uint256 paymentAssetBalanceOnChain = sumUpBalances(paymentAssetPosition); + uint256 paymentAssetNeeded = payment.maxCosts[i]; + if (payment.currency == assetSymbol && chainId == payment.chainIds[i]) { + paymentAssetNeeded += transferAmount; + } + 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 < transferAssetPositions.length; ++i) { + uint256 srcChainId = transferAssetPositions[i].chainId; + for (uint j = 0; j < transferAssetPositions[i].accountBalances.length; ++j) { + if (BridgeRoutes.hasBridge(srcChainId, chainId, assetSymbol)) { + aggregateTransferAssetAvailableBalance += transferAssetPositions[i].accountBalances[j].balance; + } + } + } + if (aggregateTransferAssetBalance < 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` + QuarkOperation[] memory operations = new QuarkOperation[](3); + uint256 operationsIndex = 0; + bytes[] memory actionContexts = new bytes[](3); + uint256 actionContextsIndex = 0; + + // TODO: implement get assetBalanceOnChain + uint256 transferAssetBalanceOnTargetChain = getAssetBalanceOnChain(assetSymbol, chainId, chainAccountsList); + // Note: User will always have enough payment token on destination chain, since we already check that in the MaxCostTooHigh() check + if (transferAssetBalanceOnTargetChain < amount) { + uint256 amountLeft = amount - transferAssetBalanceOnTargetChain; + uint256 bridgeActionCount = 0; + // Construct bridge operation if not enough funds on target chain + // TODO: bridge routing logic (which bridge to prioritize, how many bridges?) + // Iterate chainAccountList and find upto 2 chains that can provide enough fund + // Backend can provide optimal routes by adjust the order in chainAccountList. + for (uint i = 0; i < chainAccountsList.length; ++i) { + if (amountLeft == 0) { + break; + } + + if (chainAccountsList[i].chainId == chainId) { + continue; + } + + if (!BridgeRoutes.hasBridge(chainAccountsList[i].chainId, , assetSymbol)) { + continue; + } + + uint256 transferAssetBalanceOnBridgeChain = getAssetBalanceOnChain(assetSymbol, chainAccountsList[i].chainId, chainAccountsList); + if (transferAssetBalanceOnBridgeChain >= BRIDGE_MINIMUM_AMOUNT_USDC + BRIDGE_COST_OFFSET) { + // Construct bridge operation + uint256 amountToBridge = transferAssetBalanceOnBridgeChain - BRIDGE_COST_OFFSET >= amountLeft ? amountLeft : transferAssetBalanceOnBridgeChain; + amountLeft -= transferAssetBalanceOnBridgeChain - BRIDGE_COST_OFFSET; + + if (bridgeActionCount >= MAX_BRIDGE_ACTION) { + revert("Too many bridge actions"); + } + + // TODO: construct action contexts + if (payment.isToken) { + // wrap around paycall + Bridge memory bridge = BridgeRoutes.getBridge(srcChainId, chainId, assetSymbol); + address paycallAddress = getCodeAddress(codeJar, type(Paycall).creationCode); + address bridgeActionsAddress = getCodeAddress(codeJar, type(CCTPBridgeActions).creationCode); + address assetAddress = getAssetPositionForSymbolAndChain(assetSymbol, chainId, chainAccountsList).asset; + operations[operationsIndex++] = QuarkOperation({ + nonce: , // TODO: get next nonce + chainId: chainId, + scriptAddress: paycallAddress, + scriptCalldata: abi.encodeWithSelector( + Paycall.run.selector, + bridgeActionsAddress, + abi.encodeWithSelector( + CCTPBridgeActions.bridgeUSDC.selector, + bridge.bridgeAddress, + amountToBridge, + bridge.domainId, + bytes32(uint256(uint160(recipient)), + assetAddress + ) + ), + scriptSources: scriptSources, + expiry: 99999999999 // TODO: never expire? + }); + } else { + Bridge memory bridge = BridgeRoutes.getBridge(srcChainId, chainId, assetSymbol); + address scriptAddress = getCodeAddress(codeJar, type(CCTPBridgeActions).creationCode); + address assetAddress = getAssetPositionForSymbolAndChain(assetSymbol, chainId, chainAccountsList).asset; + operations[operationsIndex++] = QuarkOperation({ + nonce: , // TODO: get next nonce + chainId: chainId, + scriptAddress: scriptAddress, + scriptCalldata: abi.encodeCall( + CCTPBridgeActions.bridgeUSDC, + (bridge.bridgeAddress, amountToBridge, bridge.domainId, bytes32(uint256(uint160(recipient)), assetAddress) + ), + scriptSources: scriptSources, + expiry: 99999999999 // TODO: never expire? + }); + } + + actionContexts[actionContextsIndex++] = abi.encode(BridgeActionContext({ + amount: amountToBridge, + price: tokenPrice, // TODO: get token price + token: assetAddress, + chainId: srcChainId, + recipient: recipient, + destinationChainId: chainId + })); + + bridgeActionCount++; + } + } + + if (amountLeft > 0) { + revert("Not enought fund even after bridge"); + } + } + + // Then, transfer `amount` of `chainAsset` to `recipient` + address scriptAddress = getCodeAddress(codeJar, type(TransferActions).creationCode); + // TODO: don't necessarily need scriptSources + bytes[] memory scriptSources = new bytes[](1); + scriptSources[0] = type(TransferActions).creationCode; + // TODO: construct action contexts + if (assetSymbol == "ETH") { + if (payment.isToken) { + // wrap around paycall + address paycallAddress = getCodeAddress(codeJar, type(Paycall).creationCode); + operations[operationsIndex++] = QuarkOperation({ + nonce: , // TODO: get next nonce + chainId: chainId, + scriptAddress: paycallAddress, + scriptCalldata: abi.encodeWithSelector( + Paycall.run.selector, + scriptAddress, + abi.encodeWithSelector(TransferActions.transferNativeToken.selector, recipient, amount) + ), + scriptSources: scriptSources, + expiry: 99999999999 // TODO: never expire? + }); + } else { + // Native ETH transfer + operations[operationsIndex++] = QuarkOperation({ + nonce: , // TODO: get next nonce + chainId: chainId, + scriptAddress: scriptAddress, + scriptCalldata: abi.encodeWithSelector(TransferActions.transferNativeToken.selector, recipient, amount), + scriptSources: scriptSources, + expiry: 99999999999 // TODO: never expire? + }); + } + } else { + if (payment.isToken) { + // wrap around paycall + address paycallAddress = getCodeAddress(codeJar, type(Paycall).creationCode); + opertaions[operationsIndex++] = QuarkOperation({ + nonce: , // TODO: get next nonce + chainId: chainId, + scriptAddress: paycallAddress, + scriptCalldata: abi.encodeWithSelector( + Paycall.run.selector, + scriptAddress, + abi.encodeWithSelector(TransferActions.transferERC20Token.selector, token, recipient, amount) + ), + scriptSources: scriptSources, + expiry: 99999999999 // TODO: never expire? + }); + } else { + // ERC20 transfer + operations[operationsIndex++] = QuarkOperation({ + nonce: , // TODO: get next nonce + chainId: chainId, + scriptAddress: scriptAddress, + scriptCalldata: abi.encodeWithSelector(TransferActions.transferERC20Token.selector, token, recipient, amount), + scriptSources: scriptSources, + expiry: 99999999999 // TODO: never expire? + }); + } + } + + actionContexts[actionContextsIndex++] = abi.encode(TransferActionContext({ + amount: amount, + price: tokenPrice, // TODO: get token price + token: token, + chainId: chainId, + recipient: recipient + })); + + // Construct QuarkOperation with the "right" size + QuarkOperation[] memory operationsRst = new QuarkOperation[](operationsIndex + 1); + for (uint i = 0; i < ooperationsIndex + 1; ++i) { + operationsRst[i] = operations[i]; + } + + // Construct the contexts with the right size + bytes[] memory actionContextsRst = new bytes[](actionContextsIndex + 1); + for (uint i = 0; i < actionContextsIndex + 1; ++i) { + actionContextsRst[i] = actionContexts[i]; + } + bytes memory encodedActionContexts = abi.encode(actionContextsRst); + + return QuarkAction({ + version: version, + actionType: actionType, + actionContext: encodedActionContexts, + operations: operationsRst + }); + + // TODO: return these + 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 + QuarkWallet.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; + } + } +} + + +// 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/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); + } +}