Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(contracts-rfq): Token Zap [SLT-389] #3352

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions packages/contracts-rfq/contracts/libs/ZapDataV1.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// solhint-disable no-inline-assembly
library ZapDataV1 {
/// @notice Version of the Zap Data struct.
uint16 internal constant VERSION = 1;

/// @notice Value that indicates the amount is not present in the target function's payload.
uint16 internal constant AMOUNT_NOT_PRESENT = 0xFFFF;

// Offsets of the fields in the packed ZapData struct
// uint16 version [000 .. 002)
// uint16 amountPosition [002 .. 004)
// address target [004 .. 024)
// bytes payload [024 .. ***)

// forgefmt: disable-start
uint256 private constant OFFSET_AMOUNT_POSITION = 2;
uint256 private constant OFFSET_TARGET = 4;
uint256 private constant OFFSET_PAYLOAD = 24;
// forgefmt: disable-end

error ZapDataV1__InvalidEncoding();
error ZapDataV1__UnsupportedVersion(uint16 version);

/// @notice Validates the encodedZapData to be a tightly packed encoded payload for ZapData struct.
/// @dev Checks that all the required fields are present and the version is correct.
function validateV1(bytes calldata encodedZapData) internal pure {
// Check the minimum length: must at least include all static fields.
if (encodedZapData.length < OFFSET_PAYLOAD) revert ZapDataV1__InvalidEncoding();
// Once we validated the length, we can be sure that the version field is present.
uint16 version_ = version(encodedZapData);
if (version_ != VERSION) revert ZapDataV1__UnsupportedVersion(version_);
}
ChiTimesChi marked this conversation as resolved.
Show resolved Hide resolved

/// @notice Encodes the ZapData struct by tightly packing the fields.
/// Note: we don't know the exact amount of tokens that will be used for the Zap at the time of encoding,
/// so we provide the reference index where the token amount is encoded within `payload_`. This allows up to
/// hot-swap the token amount in the payload, when the Zap is performed.
/// @dev `abi.decode` will not work as a result of the tightly packed fields. Use `decodeZapData` instead.
/// @param amountPosition_ Position (start index) where the token amount is encoded within `payload_`.
/// This will usually be `4 + 32 * n`, where `n` is the position of the token amount in
/// the list of parameters of the target function (starting from 0).
/// Or `AMOUNT_NOT_PRESENT` if the token amount is not encoded within `payload_`.
/// @param target_ Address of the target contract.
/// @param payload_ ABI-encoded calldata to be used for the `target_` contract call.
/// If the target function has the token amount as an argument, any placeholder amount value
/// can be used for the original ABI encoding of `payload_`. The placeholder amount will
/// be replaced with the actual amount, when the Zap Data is decoded.
function encodeV1(
uint16 amountPosition_,
address target_,
bytes memory payload_
)
internal
pure
returns (bytes memory encodedZapData)
{
// Amount is encoded in [amountPosition_ .. amountPosition_ + 32), which should be within the payload.
if (amountPosition_ != AMOUNT_NOT_PRESENT && (uint256(amountPosition_) + 32 > payload_.length)) {
revert ZapDataV1__InvalidEncoding();
}
return abi.encodePacked(VERSION, amountPosition_, target_, payload_);
}
ChiTimesChi marked this conversation as resolved.
Show resolved Hide resolved

/// @notice Extracts the version from the encoded Zap Data.
function version(bytes calldata encodedZapData) internal pure returns (uint16 version_) {
// Load 32 bytes from the start and shift it 240 bits to the right to get the highest 16 bits.
assembly {
version_ := shr(240, calldataload(encodedZapData.offset))
}
}
Dismissed Show dismissed Hide dismissed

/// @notice Extracts the target address from the encoded Zap Data.
function target(bytes calldata encodedZapData) internal pure returns (address target_) {
// Load 32 bytes from the offset and shift it 96 bits to the right to get the highest 160 bits.
assembly {
target_ := shr(96, calldataload(add(encodedZapData.offset, OFFSET_TARGET)))
}
}
Dismissed Show dismissed Hide dismissed

/// @notice Extracts the payload from the encoded Zap Data. Replaces the token amount with the provided value,
/// if it was present in the original data (if amountPosition is not AMOUNT_NOT_PRESENT).
/// @dev This payload will be used as a calldata for the target contract.
function payload(bytes calldata encodedZapData, uint256 amount) internal pure returns (bytes memory) {
// The original payload is located at encodedZapData[OFFSET_PAYLOAD:].
uint16 amountPosition = _amountPosition(encodedZapData);
// If the amount was not present in the original payload, return the payload as is.
if (amountPosition == AMOUNT_NOT_PRESENT) {
return encodedZapData[OFFSET_PAYLOAD:];
}
// Calculate the start and end indexes of the amount in ZapData from its position within the payload.
// Note: we use inclusive start and exclusive end indexes for easier slicing of the ZapData.
uint256 amountStartIndexIncl = OFFSET_PAYLOAD + amountPosition;
uint256 amountEndIndexExcl = amountStartIndexIncl + 32;
// Check that the amount is within the ZapData.
if (amountEndIndexExcl > encodedZapData.length) revert ZapDataV1__InvalidEncoding();
// Otherwise we need to replace the amount in the payload with the provided value.
return abi.encodePacked(
// Copy the original payload up to the amount
encodedZapData[OFFSET_PAYLOAD:amountStartIndexIncl],
// Replace the originally encoded amount with the provided value
amount,
// Copy the rest of the payload after the amount
encodedZapData[amountEndIndexExcl:]
);
}
ChiTimesChi marked this conversation as resolved.
Show resolved Hide resolved

/// @notice Extracts the amount position from the encoded Zap Data.
function _amountPosition(bytes calldata encodedZapData) private pure returns (uint16 amountPosition) {
// Load 32 bytes from the offset and shift it 240 bits to the right to get the highest 16 bits.
assembly {
amountPosition := shr(240, calldataload(add(encodedZapData.offset, OFFSET_AMOUNT_POSITION)))
}
}
Dismissed Show dismissed Hide dismissed
}
100 changes: 100 additions & 0 deletions packages/contracts-rfq/contracts/zaps/TokenZapV1.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {IZapRecipient} from "../interfaces/IZapRecipient.sol";
import {ZapDataV1} from "../libs/ZapDataV1.sol";

import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract TokenZapV1 is IZapRecipient {
using SafeERC20 for IERC20;
using ZapDataV1 for bytes;

address public constant NATIVE_GAS_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;

error TokenZapV1__AmountIncorrect();
error TokenZapV1__PayloadLengthAboveMax();

/// @notice Performs a Zap action using the specified token and amount. This amount must be previously
/// transferred to this contract (or supplied as msg.value if the token is native gas token).
/// @dev The provided ZapData contains the target address and calldata for the Zap action, and must be
/// encoded using the encodeZapData function.
/// @param token Address of the token to be used for the Zap action.
/// @param amount Amount of the token to be used for the Zap action.
/// Must match msg.value if the token is native gas token.
/// @param zapData Encoded Zap Data containing the target address and calldata for the Zap action.
/// @return selector Selector of this function to signal the caller about the success of the Zap action.
function zap(address token, uint256 amount, bytes calldata zapData) external payable returns (bytes4) {
// Check that the ZapData is valid before decoding it
zapData.validateV1();
address target = zapData.target();
// Approve the target contract to spend the token. TokenZapV1 does not custody any tokens outside of the
// zap action, so we can approve the arbitrary target contract.
if (token == NATIVE_GAS_TOKEN) {
// No approvals are needed for the native gas token, just check that the amount is correct
if (msg.value != amount) revert TokenZapV1__AmountIncorrect();
} else {
// Issue the approval only if the current allowance is less than the required amount
if (IERC20(token).allowance(address(this), target) < amount) {
IERC20(token).forceApprove(target, type(uint256).max);
}
ChiTimesChi marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Avoid unlimited approvals to reduce security risk

Setting an unlimited allowance (type(uint256).max) to the target contract can be risky if the target is malicious or becomes compromised. It's safer to approve only the necessary amount.

Apply this diff to approve only the required amount:

if (IERC20(token).allowance(address(this), target) < amount) {
-    IERC20(token).forceApprove(target, type(uint256).max);
+    IERC20(token).forceApprove(target, amount);
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Issue the approval only if the current allowance is less than the required amount
if (IERC20(token).allowance(address(this), target) < amount) {
IERC20(token).forceApprove(target, type(uint256).max);
}
// Issue the approval only if the current allowance is less than the required amount
if (IERC20(token).allowance(address(this), target) < amount) {
IERC20(token).forceApprove(target, amount);
}

}
// Perform the Zap action, forwarding full msg.value to the target contract
// Note: this will bubble up any revert from the target contract
bytes memory payload = zapData.payload(amount);
Address.functionCallWithValue({target: target, data: payload, value: msg.value});
return this.zap.selector;
}
Fixed Show fixed Hide fixed
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Consider adding reentrancy protection to the zap function

The zap function allows external calls to arbitrary contracts via functionCallWithValue. To prevent potential reentrancy attacks, it's advisable to add reentrancy protection using OpenZeppelin's ReentrancyGuard.

Apply this diff to add reentrancy protection:

+import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

-contract TokenZapV1 is IZapRecipient {
+contract TokenZapV1 is IZapRecipient, ReentrancyGuard {

-function zap(address token, uint256 amount, bytes calldata zapData) external payable returns (bytes4) {
+function zap(address token, uint256 amount, bytes calldata zapData) external payable nonReentrant returns (bytes4) {

Committable suggestion was skipped due to low confidence.

🧰 Tools
🪛 GitHub Check: Slither

[warning] 28-48: Unused return
TokenZapV1.zap(address,uint256,bytes) (contracts/zaps/TokenZapV1.sol#28-48) ignores return value by Address.functionCallWithValue({target:target,data:payload,value:msg.value}) (contracts/zaps/TokenZapV1.sol#46)


🛠️ Refactor suggestion

Emit an event after successful zap execution

Emitting an event upon successful execution of the zap function can improve transparency and facilitate off-chain tracking and debugging.

Apply this diff to add an event and emit it:

+event ZapExecuted(address indexed sender, address token, uint256 amount, address target);

 function zap(address token, uint256 amount, bytes calldata zapData) external payable nonReentrant returns (bytes4) {
     // existing code
     Address.functionCallWithValue({target: target, data: payload, value: msg.value});
+    emit ZapExecuted(msg.sender, token, amount, target);
     return this.zap.selector;
 }

Committable suggestion was skipped due to low confidence.

🧰 Tools
🪛 GitHub Check: Slither

[warning] 28-48: Unused return
TokenZapV1.zap(address,uint256,bytes) (contracts/zaps/TokenZapV1.sol#28-48) ignores return value by Address.functionCallWithValue({target:target,data:payload,value:msg.value}) (contracts/zaps/TokenZapV1.sol#46)


/// @notice Encodes the ZapData for a Zap action.
/// Note: at the time of encoding we don't know the exact amount of tokens that will be used for the Zap,
/// as we don't have a quote for performing a Zap. Therefore a placeholder value for amount must be used
/// when abi-encoding the payload. A reference index where the actual amount is encoded within the payload
/// must be provided in order to replace the placeholder with the actual amount when the Zap is performed.
/// @param target Address of the target contract.
/// @param payload ABI-encoded calldata to be used for the `target` contract call.
/// If the target function has the token amount as an argument, any placeholder amount value
/// can be used for the original ABI encoding of `payload`. The placeholder amount will
/// be replaced with the actual amount, when the Zap Data is decoded.
/// @param amountPosition Position (start index) where the token amount is encoded within `payload`.
/// This will usually be `4 + 32 * n`, where `n` is the position of the token amount in
/// the list of parameters of the target function (starting from 0).
/// Any value greater or equal to `payload.length` can be used if the token amount is
/// not an argument of the target function.
function encodeZapData(
address target,
bytes memory payload,
uint256 amountPosition
)
external
pure
returns (bytes memory)
{
if (payload.length > ZapDataV1.AMOUNT_NOT_PRESENT) {
revert TokenZapV1__PayloadLengthAboveMax();
}
if (amountPosition >= payload.length) {
amountPosition = ZapDataV1.AMOUNT_NOT_PRESENT;
}
// At this point we checked that both amountPosition and payload.length fit in uint16
return ZapDataV1.encodeV1(uint16(amountPosition), target, payload);
}

/// @notice Decodes the ZapData for a Zap action. Replaces the placeholder amount with the actual amount,
/// if it was present in the original `payload`. Otherwise returns the original `payload` as is.
/// @param zapData Encoded Zap Data containing the target address and calldata for the Zap action.
/// @param amount Actual amount of the token to be used for the Zap action.
function decodeZapData(
bytes calldata zapData,
uint256 amount
)
public
pure
returns (address target, bytes memory payload)
{
zapData.validateV1();
target = zapData.target();
payload = zapData.payload(amount);
}
}
34 changes: 34 additions & 0 deletions packages/contracts-rfq/test/harnesses/ZapDataV1Harness.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {ZapDataV1} from "../../contracts/libs/ZapDataV1.sol";

contract ZapDataV1Harness {
function validateV1(bytes calldata encodedZapData) public pure {
ZapDataV1.validateV1(encodedZapData);
}

function encodeV1(
uint16 amountPosition_,
address target_,
bytes memory payload_
)
public
pure
returns (bytes memory encodedZapData)
{
return ZapDataV1.encodeV1(amountPosition_, target_, payload_);
}

function version(bytes calldata encodedZapData) public pure returns (uint16) {
return ZapDataV1.version(encodedZapData);
}

function target(bytes calldata encodedZapData) public pure returns (address) {
return ZapDataV1.target(encodedZapData);
}

function payload(bytes calldata encodedZapData, uint256 amount) public pure returns (bytes memory) {
return ZapDataV1.payload(encodedZapData, amount);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {TokenZapV1IntegrationTest, VaultManyArguments, IFastBridge, IFastBridgeV2} from "./TokenZapV1.t.sol";

// solhint-disable func-name-mixedcase, ordering
contract FastBridgeV2TokenZapV1DstTest is TokenZapV1IntegrationTest {
event BridgeRelayed(
bytes32 indexed transactionId,
address indexed relayer,
address indexed to,
uint32 originChainId,
address originToken,
address destToken,
uint256 originAmount,
uint256 destAmount,
uint256 chainGasAmount
);

function setUp() public virtual override {
vm.chainId(DST_CHAIN_ID);
super.setUp();
}

function mintTokens() public virtual override {
deal(relayer, DST_AMOUNT);
dstToken.mint(relayer, DST_AMOUNT);
vm.prank(relayer);
dstToken.approve(address(fastBridge), type(uint256).max);
}

function relay(
IFastBridge.BridgeParams memory params,
IFastBridgeV2.BridgeParamsV2 memory paramsV2,
bool isToken
)
public
{
bytes memory encodedBridgeTx = encodeBridgeTx(params, paramsV2);
vm.prank({msgSender: relayer, txOrigin: relayer});
fastBridge.relay{value: isToken ? paramsV2.zapNative : DST_AMOUNT}(encodedBridgeTx);
}

function expectEventBridgeRelayed(
IFastBridge.BridgeParams memory params,
IFastBridgeV2.BridgeParamsV2 memory paramsV2,
bool isToken
)
public
{
bytes32 txId = keccak256(encodeBridgeTx(params, paramsV2));
vm.expectEmit(address(fastBridge));
emit BridgeRelayed({
transactionId: txId,
relayer: relayer,
to: address(dstZap),
originChainId: SRC_CHAIN_ID,
originToken: isToken ? address(srcToken) : NATIVE_GAS_TOKEN,
destToken: isToken ? address(dstToken) : NATIVE_GAS_TOKEN,
originAmount: SRC_AMOUNT,
destAmount: DST_AMOUNT,
chainGasAmount: paramsV2.zapNative
});
}

function checkBalances(bool isToken) public view {
if (isToken) {
assertEq(dstToken.balanceOf(user), 0);
assertEq(dstToken.balanceOf(relayer), 0);
assertEq(dstToken.balanceOf(address(fastBridge)), 0);
assertEq(dstToken.balanceOf(address(dstZap)), 0);
assertEq(dstToken.balanceOf(address(dstVault)), DST_AMOUNT);
assertEq(dstVault.balanceOf(user, address(dstToken)), DST_AMOUNT);
} else {
assertEq(address(user).balance, 0);
assertEq(address(relayer).balance, 0);
assertEq(address(fastBridge).balance, 0);
assertEq(address(dstZap).balance, 0);
assertEq(address(dstVault).balance, DST_AMOUNT);
assertEq(dstVault.balanceOf(user, NATIVE_GAS_TOKEN), DST_AMOUNT);
}
}

function test_relay_depositTokenParams() public {
expectEventBridgeRelayed({params: tokenParams, paramsV2: depositTokenParams, isToken: true});
relay({params: tokenParams, paramsV2: depositTokenParams, isToken: true});
checkBalances({isToken: true});
}

function test_relay_depositTokenWithZapNativeParams() public {
expectEventBridgeRelayed({params: tokenParams, paramsV2: depositTokenWithZapNativeParams, isToken: true});
relay({params: tokenParams, paramsV2: depositTokenWithZapNativeParams, isToken: true});
checkBalances({isToken: true});
// Extra ETH will be also custodied by the Vault
assertEq(address(dstVault).balance, ZAP_NATIVE);
}

function test_relay_depositTokenRevertParams_revert() public {
vm.expectRevert(VaultManyArguments.VaultManyArguments__SomeError.selector);
relay({params: tokenParams, paramsV2: depositTokenRevertParams, isToken: true});
}

function test_relay_depositNativeParams() public {
expectEventBridgeRelayed({params: nativeParams, paramsV2: depositNativeParams, isToken: false});
relay({params: nativeParams, paramsV2: depositNativeParams, isToken: false});
checkBalances({isToken: false});
}

function test_relay_depositNativeNoAmountParams() public {
expectEventBridgeRelayed({params: nativeParams, paramsV2: depositNativeNoAmountParams, isToken: false});
relay({params: nativeParams, paramsV2: depositNativeNoAmountParams, isToken: false});
checkBalances({isToken: false});
}

function test_relay_depositNativeRevertParams_revert() public {
vm.expectRevert(VaultManyArguments.VaultManyArguments__SomeError.selector);
relay({params: nativeParams, paramsV2: depositNativeRevertParams, isToken: false});
}
}
Loading
Loading