Skip to content

Commit

Permalink
feat(contracts-rfq): Token Zap [SLT-389] (#3352)
Browse files Browse the repository at this point in the history
* feat: scaffold `ZapData` library

* test: define expected behavior for ZapDataV1

* feat: encoding, validation

* feat: decoding

* feat: scaffold `TokenZap`

* test: add coverage for TokenZap

* feat: expose encoding/decoding

* feat: implement `zap`

* fix: noAmount test, slight refactor

* test: scenarios where target contract reverts

* test: extra/missing funds scenarios

* refactor: TokenZap -> TokenZapV1

* test: FastBridgeV2 + TokenZapV1 integration

* fix: should revert when zero target in encoding

* chore: docs

* added target != addr 0 assumptions

* added one more target != addr 0 assumption

* refactor: relax ZapData pragma

* docs: improve grammar in ZapDataV1 comments

* test: adapt to #3382

* docs: NatSpec, fixing errors

---------

Co-authored-by: parodime <[email protected]>
  • Loading branch information
ChiTimesChi and parodime authored Nov 22, 2024
1 parent 79a40b5 commit 743e859
Show file tree
Hide file tree
Showing 10 changed files with 1,089 additions and 0 deletions.
119 changes: 119 additions & 0 deletions packages/contracts-rfq/contracts/libs/ZapDataV1.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

// 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__TargetZeroAddress();
error ZapDataV1__UnsupportedVersion(uint16 version);

/// @notice Validates that encodedZapData is 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_);
}

/// @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 us 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)
{
if (target_ == address(0)) revert ZapDataV1__TargetZeroAddress();
// 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_);
}

/// @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))
}
}

/// @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)))
}
}

/// @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:]
);
}

/// @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)))
}
}
}
113 changes: 113 additions & 0 deletions packages/contracts-rfq/contracts/zaps/TokenZapV1.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// 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";

/// @title TokenZapV1
/// @notice Facilitates atomic token operations known as "Zaps," allowing to execute predefined actions
/// on behalf of users like deposits or swaps. Supports ERC20 tokens and native gas tokens (e.g., ETH).
/// @dev Tokens must be pre-transferred to the contract for execution, with native tokens sent as msg.value.
/// This contract is stateless and does not hold assets between Zaps; leftover tokens can be claimed by anyone.
/// Ensure Zaps fully utilize tokens or revert to prevent fund loss.
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 a 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) {
// Validate the ZapData format and extract the target address.
zapData.validateV1();
address target = zapData.target();
if (token == NATIVE_GAS_TOKEN) {
// For native gas token (e.g., ETH), verify msg.value matches the expected amount.
// No approval needed since native token doesn't use allowances.
if (msg.value != amount) revert TokenZapV1__AmountIncorrect();
} else {
// For ERC20 tokens, grant unlimited approval to the target if the current allowance is insufficient.
// This is safe since the contract doesn't custody tokens between zaps.
if (IERC20(token).allowance(address(this), target) < amount) {
IERC20(token).forceApprove(target, type(uint256).max);
}
// Note: balance check is omitted as the target contract will revert if there are insufficient funds.
}
// Construct the payload for the target contract call with the Zap action.
// The payload is modified to replace the placeholder amount with the actual amount.
bytes memory payload = zapData.payload(amount);
// Perform the Zap action, forwarding full msg.value to the target contract.
// Note: this will bubble up any revert from the target contract.
Address.functionCallWithValue({target: target, data: payload, value: msg.value});
// Return function selector to indicate successful execution
return this.zap.selector;
}

/// @notice Encodes the ZapData for a Zap action.
/// @dev 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 the 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 than 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();
}
// External integrations do not need to understand the specific `AMOUNT_NOT_PRESENT` semantics.
// Therefore, they can specify any value greater than or equal to `payload.length` to indicate
// that the amount is not present in the payload.
if (amountPosition >= payload.length) {
amountPosition = ZapDataV1.AMOUNT_NOT_PRESENT;
}
// At this point, we have 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

0 comments on commit 743e859

Please sign in to comment.