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

Merged
merged 23 commits into from
Nov 22, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7ad4444
feat: scaffold `ZapData` library
ChiTimesChi Oct 28, 2024
b7858bf
test: define expected behavior for ZapDataV1
ChiTimesChi Oct 28, 2024
9265257
feat: encoding, validation
ChiTimesChi Oct 28, 2024
38d8673
feat: decoding
ChiTimesChi Oct 28, 2024
61eb61d
feat: scaffold `TokenZap`
ChiTimesChi Oct 29, 2024
217859c
test: add coverage for TokenZap
ChiTimesChi Oct 29, 2024
14cef36
feat: expose encoding/decoding
ChiTimesChi Oct 29, 2024
5ca648c
feat: implement `zap`
ChiTimesChi Oct 29, 2024
7f4c1c4
fix: noAmount test, slight refactor
ChiTimesChi Oct 29, 2024
596c60d
test: scenarios where target contract reverts
ChiTimesChi Oct 29, 2024
3202b70
test: extra/missing funds scenarios
ChiTimesChi Oct 29, 2024
e76174f
refactor: TokenZap -> TokenZapV1
ChiTimesChi Oct 30, 2024
ae4fe60
test: FastBridgeV2 + TokenZapV1 integration
ChiTimesChi Oct 30, 2024
1ec3df8
fix: should revert when zero target in encoding
ChiTimesChi Oct 30, 2024
cbc4fbd
chore: docs
ChiTimesChi Oct 30, 2024
ad8d479
added target != addr 0 assumptions
parodime Nov 4, 2024
2e5f7d1
added one more target != addr 0 assumption
parodime Nov 4, 2024
6b9b2b9
Merge branch 'master' into feat/token-zap
ChiTimesChi Nov 11, 2024
4c04066
refactor: relax ZapData pragma
ChiTimesChi Nov 11, 2024
9c3dd79
docs: improve grammar in ZapDataV1 comments
ChiTimesChi Nov 21, 2024
c5473bb
Merge branch 'master' into feat/token-zap
ChiTimesChi Nov 21, 2024
194e3c0
test: adapt to #3382
ChiTimesChi Nov 21, 2024
678dcb5
docs: NatSpec, fixing errors
ChiTimesChi Nov 22, 2024
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
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.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__TargetZeroAddress();
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)
{
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))
}
}
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
}
115 changes: 115 additions & 0 deletions packages/contracts-rfq/contracts/zaps/TokenZapV1.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// 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";

/// @notice TokenZapV1 enables atomic token operations called "Zaps". A Zap executes a predefined action with tokens
/// on behalf of a user, such as depositing into a vault or performing a token swap.
/// The contract supports both ERC20 tokens and the chain's native gas token (e.g. ETH).
/// @dev For security and efficiency, TokenZapV1 requires tokens to be pre-transferred to the contract before executing
/// a Zap, rather than using transferFrom. For native tokens, the amount must be sent as msg.value.
/// IMPORTANT: This contract is stateless and does not custody assets between Zaps. Any tokens left in the contract
/// can be claimed by anyone. To prevent loss of funds, ensure that Zaps either consume the entire token amount
/// or revert the transaction.
ChiTimesChi marked this conversation as resolved.
Show resolved Hide resolved
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) {
// Validate the ZapData format and extract target address
zapData.validateV1();
address target = zapData.target();
if (token == NATIVE_GAS_TOKEN) {
// For native gas token (e.g. ETH), verify msg.value matches 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 target if current allowance insufficient.
// This is safe since contract doesn't custody tokens between zaps.
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
// Note: Balance check omitted as target contract will revert if 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;
}
Dismissed Show dismissed Hide dismissed

/// @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();
}
// 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 checked that both `amountPosition` and `payload.length` fit in uint16.
return ZapDataV1.encodeV1(uint16(amountPosition), target, payload);
}
ChiTimesChi marked this conversation as resolved.
Show resolved Hide resolved

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