diff --git a/src/builder/BridgeRoutes.sol b/src/builder/BridgeRoutes.sol index 8ba3cb8..f2ee88d 100644 --- a/src/builder/BridgeRoutes.sol +++ b/src/builder/BridgeRoutes.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.27; import {AcrossActions} from "src/AcrossScripts.sol"; import {CCTPBridgeActions} from "src/BridgeScripts.sol"; import {Errors} from "src/builder/Errors.sol"; +import {HashMap} from "src/builder/HashMap.sol"; import {QuarkBuilder} from "src/builder/QuarkBuilder.sol"; import "src/builder/Strings.sol"; @@ -196,4 +197,18 @@ library Across { ) ); } + + // Returns whether or not an asset is bridged non-deterministically. This applies to WETH/ETH, where Across will send either ETH or WETH + // to the target address depending on if address is an EOA or contract. + function isNonDeterministicBridgeAction(HashMap.Map memory assetsBridged, string memory assetSymbol) + internal + pure + returns (bool) + { + uint256 bridgedAmount = HashMap.contains(assetsBridged, abi.encode(assetSymbol)) + ? HashMap.getUint256(assetsBridged, abi.encode(assetSymbol)) + : 0; + return bridgedAmount != 0 + && (Strings.stringEqIgnoreCase(assetSymbol, "ETH") || Strings.stringEqIgnoreCase(assetSymbol, "WETH")); + } } diff --git a/src/builder/QuarkBuilderBase.sol b/src/builder/QuarkBuilderBase.sol index e984f9c..0ddf243 100644 --- a/src/builder/QuarkBuilderBase.sol +++ b/src/builder/QuarkBuilderBase.sol @@ -5,7 +5,7 @@ import {IQuarkWallet} from "quark-core/src/interfaces/IQuarkWallet.sol"; import {Actions} from "src/builder/actions/Actions.sol"; import {Accounts} from "src/builder/Accounts.sol"; -import {BridgeRoutes} from "src/builder/BridgeRoutes.sol"; +import {Across, BridgeRoutes} from "src/builder/BridgeRoutes.sol"; import {EIP712Helper} from "src/builder/EIP712Helper.sol"; import {Math} from "src/lib/Math.sol"; import {MorphoInfo} from "src/builder/MorphoInfo.sol"; @@ -229,6 +229,15 @@ contract QuarkBuilderBase { uint256 supplementalBalance = HashMap.contains(assetsBridged, abi.encode(assetSymbolOut)) ? HashMap.getUint256(assetsBridged, abi.encode(assetSymbolOut)) : 0; + // Note: Right now, ETH/WETH is only bridged via Across. Across has a weird quirk where it will send ETH to EOAs and + // WETH to contracts. Since the QuarkBuilder cannot know if a QuarkWallet is deployed before the operation is actually + // executed on-chain, it needs to use the "wrap up to" script because it cannot know how much to wrap ahead of time. + bool useWrapUpTo; + if (Across.isNonDeterministicBridgeAction(assetsBridged, assetSymbolOut)) { + useWrapUpTo = true; + supplementalBalance = 0; + } + checkAndInsertWrapOrUnwrapAction({ actions: actions, quarkOperations: quarkOperations, @@ -240,7 +249,8 @@ contract QuarkBuilderBase { chainId: actionIntent.chainId, account: actionIntent.actor, blockTimestamp: actionIntent.blockTimestamp, - useQuotecall: actionIntent.useQuotecall + useQuotecall: actionIntent.useQuotecall, + useWrapUpTo: useWrapUpTo }); } } @@ -407,25 +417,36 @@ contract QuarkBuilderBase { uint256 chainId, address account, uint256 blockTimestamp, - bool useQuotecall + bool useQuotecall, + bool useWrapUpTo ) internal pure { // Check if inserting wrapOrUnwrap action is necessary uint256 assetBalanceOnChain = Accounts.getBalanceOnChain(assetSymbol, chainId, chainAccountsList) + supplementalBalance; if (assetBalanceOnChain < amount && TokenWrapper.hasWrapperContract(chainId, assetSymbol)) { - // If the asset has a wrapper counterpart, wrap/unwrap the token to cover the transferIntent amount + // If the asset has a wrapper counterpart, wrap/unwrap the token to cover the amount needed for the intent string memory counterpartSymbol = TokenWrapper.getWrapperCounterpartSymbol(chainId, assetSymbol); // Wrap/unwrap the token to cover the amount + uint256 amountToWrap; + if (useWrapUpTo) { + // If we are using the "wrap up to script", then the `amountToWrap` should be the entire amount needed + // for the intent + amountToWrap = amount; + } else { + // If we aren't using the "wrap up to" script, then we need to subtract the current balance from the + // amount to wrap + amountToWrap = amount - assetBalanceOnChain; + } (IQuarkWallet.QuarkOperation memory wrapOrUnwrapOperation, Actions.Action memory wrapOrUnwrapAction) = Actions.wrapOrUnwrapAsset( Actions.WrapOrUnwrapAsset({ chainAccountsList: chainAccountsList, assetSymbol: counterpartSymbol, - // NOTE: Wrap/unwrap the amount needed to cover the amount - amount: amount - assetBalanceOnChain, + amount: amountToWrap, chainId: chainId, sender: account, + useWrapUpTo: useWrapUpTo, blockTimestamp: blockTimestamp }), payment, diff --git a/src/builder/TokenWrapper.sol b/src/builder/TokenWrapper.sol index 31798b4..3472fe5 100644 --- a/src/builder/TokenWrapper.sol +++ b/src/builder/TokenWrapper.sol @@ -108,28 +108,36 @@ library TokenWrapper { return Strings.stringEqIgnoreCase(tokenSymbol, getKnownWrapperTokenPair(chainId, tokenSymbol).wrappedSymbol); } - function encodeActionToWrapOrUnwrap(uint256 chainId, string memory tokenSymbol, uint256 amount) + function encodeActionToWrapOrUnwrap(uint256 chainId, string memory tokenSymbol, uint256 amount, bool useWrapUpTo) internal pure returns (bytes memory) { KnownWrapperTokenPair memory pair = getKnownWrapperTokenPair(chainId, tokenSymbol); if (isWrappedToken(chainId, tokenSymbol)) { - return encodeActionToUnwrapToken(chainId, tokenSymbol, amount); + return encodeActionToUnwrapToken(chainId, tokenSymbol, amount, useWrapUpTo); } else { - return encodeActionToWrapToken(chainId, tokenSymbol, pair.underlyingToken, amount); + return encodeActionToWrapToken(chainId, tokenSymbol, pair.underlyingToken, amount, useWrapUpTo); } } - function encodeActionToWrapToken(uint256 chainId, string memory tokenSymbol, address tokenAddress, uint256 amount) - internal - pure - returns (bytes memory) - { + function encodeActionToWrapToken( + uint256 chainId, + string memory tokenSymbol, + address tokenAddress, + uint256 amount, + bool useWrapUpTo + ) internal pure returns (bytes memory) { if (Strings.stringEqIgnoreCase(tokenSymbol, "ETH")) { - return abi.encodeWithSelector( - WrapperActions.wrapETH.selector, getKnownWrapperTokenPair(chainId, tokenSymbol).wrapper, amount - ); + if (useWrapUpTo) { + return abi.encodeWithSelector( + WrapperActions.wrapETHUpTo.selector, getKnownWrapperTokenPair(chainId, tokenSymbol).wrapper, amount + ); + } else { + return abi.encodeWithSelector( + WrapperActions.wrapETH.selector, getKnownWrapperTokenPair(chainId, tokenSymbol).wrapper, amount + ); + } } else if (Strings.stringEqIgnoreCase(tokenSymbol, "stETH")) { return abi.encodeWithSelector( WrapperActions.wrapLidoStETH.selector, @@ -141,15 +149,23 @@ library TokenWrapper { revert NotWrappable(); } - function encodeActionToUnwrapToken(uint256 chainId, string memory tokenSymbol, uint256 amount) + function encodeActionToUnwrapToken(uint256 chainId, string memory tokenSymbol, uint256 amount, bool useWrapUpTo) internal pure returns (bytes memory) { if (Strings.stringEqIgnoreCase(tokenSymbol, "WETH")) { - return abi.encodeWithSelector( - WrapperActions.unwrapWETH.selector, getKnownWrapperTokenPair(chainId, tokenSymbol).wrapper, amount - ); + if (useWrapUpTo) { + return abi.encodeWithSelector( + WrapperActions.unwrapWETHUpTo.selector, + getKnownWrapperTokenPair(chainId, tokenSymbol).wrapper, + amount + ); + } else { + return abi.encodeWithSelector( + WrapperActions.unwrapWETH.selector, getKnownWrapperTokenPair(chainId, tokenSymbol).wrapper, amount + ); + } } else if (Strings.stringEqIgnoreCase(tokenSymbol, "wstETH")) { return abi.encodeWithSelector( WrapperActions.unwrapLidoWstETH.selector, getKnownWrapperTokenPair(chainId, tokenSymbol).wrapper, amount diff --git a/src/builder/actions/Actions.sol b/src/builder/actions/Actions.sol index 0a3402b..c966998 100644 --- a/src/builder/actions/Actions.sol +++ b/src/builder/actions/Actions.sol @@ -116,6 +116,7 @@ library Actions { uint256 amount; uint256 chainId; address sender; + bool useWrapUpTo; uint256 blockTimestamp; } @@ -1470,7 +1471,7 @@ library Actions { isReplayable: false, scriptAddress: CodeJarHelper.getCodeAddress(type(WrapperActions).creationCode), scriptCalldata: TokenWrapper.encodeActionToWrapOrUnwrap( - wrapOrUnwrap.chainId, wrapOrUnwrap.assetSymbol, wrapOrUnwrap.amount + wrapOrUnwrap.chainId, wrapOrUnwrap.assetSymbol, wrapOrUnwrap.amount, wrapOrUnwrap.useWrapUpTo ), scriptSources: scriptSources, expiry: wrapOrUnwrap.blockTimestamp + STANDARD_EXPIRY_BUFFER diff --git a/test/builder/BridgingLogic.t.sol b/test/builder/BridgingLogic.t.sol index c8f9bc1..7be66e5 100644 --- a/test/builder/BridgingLogic.t.sol +++ b/test/builder/BridgingLogic.t.sol @@ -23,14 +23,15 @@ import {PaymentInfo} from "src/builder/PaymentInfo.sol"; import {QuarkBuilder} from "src/builder/QuarkBuilder.sol"; import {QuarkBuilderBase} from "src/builder/QuarkBuilderBase.sol"; import {Quotecall} from "src/Quotecall.sol"; +import {TokenWrapper} from "src/builder/TokenWrapper.sol"; import {YulHelper} from "test/lib/YulHelper.sol"; -import {AcrossFFI} from "test/builder/mocks/AcrossFFI.sol"; +import {MockAcrossFFI, MockAcrossFFIConstants} from "test/builder/mocks/AcrossFFI.sol"; contract BridgingLogicTest is Test, QuarkBuilderTest { function setUp() public { // Deploy mock FFI for calling Across API - AcrossFFI mockFFI = new AcrossFFI(); + MockAcrossFFI mockFFI = new MockAcrossFFI(); vm.etch(FFI.ACROSS_FFI_ADDRESS, address(mockFFI).code); } @@ -48,7 +49,7 @@ contract BridgingLogicTest is Test, QuarkBuilderTest { chainAccountsList[1] = Accounts.ChainAccounts({ chainId: 8453, quarkSecrets: quarkSecrets_(address(0xb0b), bytes32(uint256(2))), - assetPositionsList: assetPositionsList_(8453, address(0xb0b), 0e18), + assetPositionsList: assetPositionsList_(8453, address(0xb0b), 0.5e18), cometPositions: emptyCometPositions_(), morphoPositions: emptyMorphoPositions_(), morphoVaultPositions: emptyMorphoVaultPositions_() @@ -69,6 +70,10 @@ contract BridgingLogicTest is Test, QuarkBuilderTest { assertEq(result.paymentCurrency, "usd", "usd currency"); + address multicallAddress = CodeJarHelper.getCodeAddress(type(Multicall).creationCode); + address wrapperActionsAddress = CodeJarHelper.getCodeAddress(type(WrapperActions).creationCode); + address transferActionsAddress = CodeJarHelper.getCodeAddress(type(TransferActions).creationCode); + // Check the quark operations assertEq(result.quarkOperations.length, 2, "two operations"); assertEq( @@ -101,8 +106,8 @@ contract BridgingLogicTest is Test, QuarkBuilderTest { address(0xb0b), // recipient weth_(1), // inputToken weth_(8453), // outputToken - 1e18 * (1e18 + 0.01e18) / 1e18 + 1e6, // inputAmount - 1e18, // outputAmount + 0.5e18 * (1e18 + MockAcrossFFIConstants.VARIABLE_FEE_PCT) / 1e18 + MockAcrossFFIConstants.GAS_FEE, // inputAmount + 0.5e18, // outputAmount 8453, // destinationChainId address(0), // exclusiveRelayer uint32(BLOCK_TIMESTAMP) - Across.QUOTE_TIMESTAMP_BUFFER, // quoteTimestamp @@ -122,28 +127,21 @@ contract BridgingLogicTest is Test, QuarkBuilderTest { assertEq( result.quarkOperations[1].scriptAddress, - address( - uint160( - uint256( - keccak256( - abi.encodePacked( - bytes1(0xff), - /* codeJar address */ - address(CodeJarHelper.CODE_JAR_ADDRESS), - uint256(0), - /* script bytecode */ - keccak256(type(TransferActions).creationCode) - ) - ) - ) - ) - ), - "script address for transfer is correct given the code jar address" + multicallAddress, + "script address for Multicall is correct given the code jar address" + ); + address[] memory callContracts = new address[](2); + callContracts[0] = wrapperActionsAddress; + callContracts[1] = transferActionsAddress; + bytes[] memory callDatas = new bytes[](2); + callDatas[0] = abi.encodeWithSelector( + WrapperActions.wrapETHUpTo.selector, TokenWrapper.getKnownWrapperTokenPair(8453, "WETH").wrapper, 1e18 ); + callDatas[1] = abi.encodeCall(TransferActions.transferERC20Token, (weth_(8453), address(0xceecee), 1e18)); assertEq( result.quarkOperations[1].scriptCalldata, - abi.encodeCall(TransferActions.transferERC20Token, (weth_(8453), address(0xceecee), 1e18)), - "calldata is TransferActions.transferERC20Token(USDC_8453, address(0xceecee), 5e6);" + abi.encodeWithSelector(Multicall.run.selector, callContracts, callDatas), + "calldata is Multicall.run([wrapperActionsAddress, transferActionsAddress], [WrapperActions.wrapETHUpTo(USDC_8453, 1e18), TransferActions.transferERC20Token(USDC_8453, address(0xceecee), 1e18))" ); assertEq( result.quarkOperations[1].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" @@ -168,8 +166,9 @@ contract BridgingLogicTest is Test, QuarkBuilderTest { price: WETH_PRICE, token: WETH_1, assetSymbol: "WETH", - inputAmount: 1e18 * (1e18 + 0.01e18) / 1e18 + 1e6, - outputAmount: 1e18, + inputAmount: 0.5e18 * (1e18 + MockAcrossFFIConstants.VARIABLE_FEE_PCT) / 1e18 + + MockAcrossFFIConstants.GAS_FEE, + outputAmount: 0.5e18, chainId: 1, recipient: address(0xb0b), destinationChainId: 8453, diff --git a/test/builder/mocks/AcrossFFI.sol b/test/builder/mocks/AcrossFFI.sol index 15e2324..b60bb31 100644 --- a/test/builder/mocks/AcrossFFI.sol +++ b/test/builder/mocks/AcrossFFI.sol @@ -3,7 +3,15 @@ pragma solidity 0.8.27; import {IAcrossFFI} from "src/interfaces/IAcrossFFI.sol"; -contract AcrossFFI is IAcrossFFI { +library MockAcrossFFIConstants { + uint256 public constant GAS_FEE = 1e6; + uint256 public constant VARIABLE_FEE_PCT = 0.01e18; +} + +contract MockAcrossFFI is IAcrossFFI { + uint256 public constant GAS_FEE = 1e6; + uint256 public constant VARIABLE_FEE_PCT = 0.01e18; + function requestAcrossQuote( address, /* inputToken */ address, /* outputToken */ @@ -11,6 +19,6 @@ contract AcrossFFI is IAcrossFFI { uint256, /* dstChain */ uint256 /* amount */ ) external pure override returns (uint256 gasFee, uint256 variableFeePct) { - return (1e6, 0.01e18); + return (MockAcrossFFIConstants.GAS_FEE, MockAcrossFFIConstants.VARIABLE_FEE_PCT); } }