-
Notifications
You must be signed in to change notification settings - Fork 33
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(contracts-rfq): Token Zap [SLT-389] (#3352)
* 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
1 parent
79a40b5
commit 743e859
Showing
10 changed files
with
1,089 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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))) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
34
packages/contracts-rfq/test/harnesses/ZapDataV1Harness.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
119 changes: 119 additions & 0 deletions
119
packages/contracts-rfq/test/integration/FastBridgeV2.TokenZapV1.Dst.t.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}); | ||
} | ||
} |
Oops, something went wrong.