Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions .env-example
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ REFUEL_ACTION_GAS_CONSTANT=
REFUEL_ERC20_ACTION_GAS_CONSTANT=
SWEEP_ACTION_GAS_CONSTANT=
SWEEP_CCTP_ACTION_GAS_CONSTANT=
SWEEP_CCTP_V2_ACTION_GAS_CONSTANT=
SWEEP_DEPOSIT_ERC4626_ACTION_GAS_CONSTANT=
SWEEP_ERC20_ACTION_GAS_CONSTANT=
SWEEP_UNISWAP_V3_ACTION_GAS_CONSTANT=
SWEEP_WITHDRAW_ERC4626_ACTION_GAS_CONSTANT=
TRANSFER_ACTION_GAS_CONSTANT=
TRANSFER_CCTP_ACTION_GAS_CONSTANT=
TRANSFER_CCTP_V2_ACTION_GAS_CONSTANT=
TRANSFER_ERC20_ACTION_GAS_CONSTANT=
TRANSFER_ERC20_ONCE_ACTION_GAS_CONSTANT=
TRANSFER_ONCE_ACTION_GAS_CONSTANT=
Expand All @@ -50,6 +52,8 @@ EXPECTED_DEPOSIT_ERC4626_ACTION_ADDRESS=
EXPECTED_SWEEP_DEPOSIT_ERC4626_ACTION_ADDRESS=
EXPECTED_WITHDRAW_ERC4626_ACTION_ADDRESS=
EXPECTED_SWEEP_WITHDRAW_ERC4626_ACTION_ADDRESS=
EXPECTED_SWEEP_CCTP_V2_ACTION_ADDRESS=
EXPECTED_TRANSFER_CCTP_V2_ACTION_ADDRESS=


UNIVERSAL_ROUTER_ADDRESS=
Expand All @@ -59,6 +63,9 @@ WETH9_ADDRESS=
CCTP_TOKEN_MESSENGER_ADDRESS=
CCTP_TOKEN_MINTER_ADDRESS=

CCTP_V2_TOKEN_MESSENGER_ADDRESS=
CCTP_V2_TOKEN_MINTER_ADDRESS=

## Governance
# only needed when owner is an EOA
OWNER_PK=
Expand Down
93 changes: 57 additions & 36 deletions .gas-snapshot

Large diffs are not rendered by default.

104 changes: 104 additions & 0 deletions script/deployment/actions/DeploySweepCCTPV2Action.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity ^0.8.26;

import {Script, console2} from "forge-std/src/Script.sol";
import {VmSafe} from "forge-std/src/Vm.sol";

import {SweepCCTPV2Action} from "../../../src/actions/SweepCCTPV2Action.sol";

contract DeploySweepCCTPV2Action is Script {
/// @dev make sure to run `cp .env_example .env` and fill in each variable
/// then run `source .env` in your terminal before copying and pasting one of the commands below
/// @dev this script will deploy to the same address on every chain.
/// this deterministic address depend on a few things:
/// - the owner address
/// - the salt
/// - the creation code of the contract
/// - **** the number of optimizer_runs will change the creation code (see foundry.toml) ****
/// - **** the version of the Solidity compiler will change the creation code ****
/// - **** the EVM version (cancun, prague, etc) will change the creation code ****
/// - **** dependency versions can change the creation code ****
/// - **** the forge version can change the creation code ****
/// - **** compiler flags (--via-ir, --overwrite, etc) can change the creation code ****
/// - the address of the deployer (this won't change because we are using the cannoical Create2 factory 0x4e59b44847b379578588920ca78fbf26c0b4956c, but good to know)
///
///
/// if any of these values change, the addresses will change, so we must be careful to keep these values constant.
/// in order to help with this, a check is added here to ensure that the calculated address matches the expected address
/// before deploying. if the addresses do not match, the script will revert.
// command to generate the expected deployment address (without actually deploying):
//
// forge script DeploySweepCCTPV2Action

// commands to deterministically deploy (and check the expected address before deploying):
//
// - with private key (on Anvil): forge script DeploySweepCCTPV2Action --broadcast --fork-url http://localhost:8545 --private-key $ANVIL_DEPLOYER_PK
// - with private key: forge script DeploySweepCCTPV2Action --broadcast --rpc-url $RPC_URL --private-key $DEPLOYER_PK
// - with Ledger: forge script DeploySweepCCTPV2Action --broadcast --rpc-url $RPC_URL --ledger
// - with AWS: forge script DeploySweepCCTPV2Action --broadcast --rpc-url $RPC_URL --aws

bytes32 constant salt = keccak256("ON_TIME_INSTRUCTED_MONEY");

error ExpectedAddressMismatch();

function run() public {
address tokenMessengerV2Address = vm.envAddress("CCTP_V2_TOKEN_MESSENGER_ADDRESS");
address tokenMinterV2Address = vm.envAddress("CCTP_V2_TOKEN_MINTER_ADDRESS");
address feeTokenRegistryAddress = vm.envAddress("EXPECTED_FEE_TOKEN_REGISTRY_ADDRESS");
address treasuryAddress = vm.envAddress("EXPECTED_TREASURY_ADDRESS");
uint256 gasConstant = vm.envUint("SWEEP_CCTP_V2_ACTION_GAS_CONSTANT");

// if this isn't a dry-run (aka we're using `--broadcast`), make sure to check the expected address
if (vm.isContext(VmSafe.ForgeContext.ScriptBroadcast)) {
checkExpectedAddress(
tokenMessengerV2Address, tokenMinterV2Address, feeTokenRegistryAddress, treasuryAddress, gasConstant
);
}

vm.startBroadcast();

// deterministically deploy SweepCCTPV2Action via canonical Create2 deployer
SweepCCTPV2Action sweepCCTPV2Action = new SweepCCTPV2Action{salt: salt}(
tokenMessengerV2Address, tokenMinterV2Address, feeTokenRegistryAddress, treasuryAddress, gasConstant
);

vm.stopBroadcast();

console2.log("SweepCCTPV2Action deployed at:", address(sweepCCTPV2Action));
}

function checkExpectedAddress(
address tokenMessengerV2Address,
address tokenMinterV2Address,
address feeTokenRegistryAddress,
address treasuryAddress,
uint256 gasConstant
) public view {
/// @dev before deploying for the first time, generate this expected address by running this script in dry-run mode (see above).
/// once it has been deployed for the first time, that deployed address should be used as the expected address from then on.
address expectedAddress = vm.envAddress("EXPECTED_SWEEP_CCTP_V2_ACTION_ADDRESS");

// calculate the expected address using the current init code
address calculatedAddress = vm.computeCreate2Address(
salt,
keccak256(
abi.encodePacked(
type(SweepCCTPV2Action).creationCode,
abi.encode(
tokenMessengerV2Address,
tokenMinterV2Address,
feeTokenRegistryAddress,
treasuryAddress,
gasConstant
)
)
)
);

// revert if the expected address does not match the calculated address
if (expectedAddress != calculatedAddress) {
revert ExpectedAddressMismatch();
}
}
}

104 changes: 104 additions & 0 deletions script/deployment/actions/DeployTransferCCTPV2Action.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity ^0.8.26;

import {Script, console2} from "forge-std/src/Script.sol";
import {VmSafe} from "forge-std/src/Vm.sol";

import {TransferCCTPV2Action} from "../../../src/actions/TransferCCTPV2Action.sol";

contract DeployTransferCCTPV2Action is Script {
/// @dev make sure to run `cp .env_example .env` and fill in each variable
/// then run `source .env` in your terminal before copying and pasting one of the commands below
/// @dev this script will deploy to the same address on every chain.
/// this deterministic address depend on a few things:
/// - the owner address
/// - the salt
/// - the creation code of the contract
/// - **** the number of optimizer_runs will change the creation code (see foundry.toml) ****
/// - **** the version of the Solidity compiler will change the creation code ****
/// - **** the EVM version (cancun, prague, etc) will change the creation code ****
/// - **** dependency versions can change the creation code ****
/// - **** the forge version can change the creation code ****
/// - **** compiler flags (--via-ir, --overwrite, etc) can change the creation code ****
/// - the address of the deployer (this won't change because we are using the cannoical Create2 factory 0x4e59b44847b379578588920ca78fbf26c0b4956c, but good to know)
///
///
/// if any of these values change, the addresses will change, so we must be careful to keep these values constant.
/// in order to help with this, a check is added here to ensure that the calculated address matches the expected address
/// before deploying. if the addresses do not match, the script will revert.
// command to generate the expected deployment address (without actually deploying):
//
// forge script DeployTransferCCTPV2Action

// commands to deterministically deploy (and check the expected address before deploying):
//
// - with private key (on Anvil): forge script DeployTransferCCTPV2Action --broadcast --fork-url http://localhost:8545 --private-key $ANVIL_DEPLOYER_PK
// - with private key: forge script DeployTransferCCTPV2Action --broadcast --rpc-url $RPC_URL --private-key $DEPLOYER_PK
// - with Ledger: forge script DeployTransferCCTPV2Action --broadcast --rpc-url $RPC_URL --ledger
// - with AWS: forge script DeployTransferCCTPV2Action --broadcast --rpc-url $RPC_URL --aws

bytes32 constant salt = keccak256("ON_TIME_INSTRUCTED_MONEY");

error ExpectedAddressMismatch();

function run() public {
address tokenMessengerV2Address = vm.envAddress("CCTP_V2_TOKEN_MESSENGER_ADDRESS");
address tokenMinterV2Address = vm.envAddress("CCTP_V2_TOKEN_MINTER_ADDRESS");
address feeTokenRegistryAddress = vm.envAddress("EXPECTED_FEE_TOKEN_REGISTRY_ADDRESS");
address treasuryAddress = vm.envAddress("EXPECTED_TREASURY_ADDRESS");
uint256 gasConstant = vm.envUint("TRANSFER_CCTP_V2_ACTION_GAS_CONSTANT");

// if this isn't a dry-run (aka we're using `--broadcast`), make sure to check the expected address
if (vm.isContext(VmSafe.ForgeContext.ScriptBroadcast)) {
checkExpectedAddress(
tokenMessengerV2Address, tokenMinterV2Address, feeTokenRegistryAddress, treasuryAddress, gasConstant
);
}

vm.startBroadcast();

// deterministically deploy TransferCCTPV2Action via canonical Create2 deployer
TransferCCTPV2Action transferCCTPV2Action = new TransferCCTPV2Action{salt: salt}(
tokenMessengerV2Address, tokenMinterV2Address, feeTokenRegistryAddress, treasuryAddress, gasConstant
);

vm.stopBroadcast();

console2.log("TransferCCTPV2Action deployed at:", address(transferCCTPV2Action));
}

function checkExpectedAddress(
address tokenMessengerV2Address,
address tokenMinterV2Address,
address feeTokenRegistryAddress,
address treasuryAddress,
uint256 gasConstant
) public view {
/// @dev before deploying for the first time, generate this expected address by running this script in dry-run mode (see above).
/// once it has been deployed for the first time, that deployed address should be used as the expected address from then on.
address expectedAddress = vm.envAddress("EXPECTED_TRANSFER_CCTP_V2_ACTION_ADDRESS");

// calculate the expected address using the current init code
address calculatedAddress = vm.computeCreate2Address(
salt,
keccak256(
abi.encodePacked(
type(TransferCCTPV2Action).creationCode,
abi.encode(
tokenMessengerV2Address,
tokenMinterV2Address,
feeTokenRegistryAddress,
treasuryAddress,
gasConstant
)
)
)
);

// revert if the expected address does not match the calculated address
if (expectedAddress != calculatedAddress) {
revert ExpectedAddressMismatch();
}
}
}

151 changes: 151 additions & 0 deletions src/actions/SweepCCTPV2Action.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity ^0.8.26;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

import {ITokenMessengerV2} from "./external/ITokenMessengerV2.sol";
import {ITokenController} from "./external/ITokenController.sol";

import {InstructionLib} from "../libraries/Instruction.sol";

import {OtimFee} from "./fee-models/OtimFee.sol";

import {IAction} from "./interfaces/IAction.sol";
import {ISweepCCTPV2Action, INSTRUCTION_TYPEHASH, ARGUMENTS_TYPEHASH} from "./interfaces/ISweepCCTPV2Action.sol";

import {InvalidArguments, BalanceUnderThreshold, CCTPTokenNotSupported, CCTPMaxFeeTooLow} from "./errors/Errors.sol";

/// @title SweepCCTPV2Action
/// @author Otim Labs, Inc.
/// @notice an Action that sweeps ERC20 tokens from the user's account to a target on a different chain via CCTP V2 when the balance is greater than or equal to a threshold
contract SweepCCTPV2Action is IAction, ISweepCCTPV2Action, OtimFee {
using InstructionLib for InstructionLib.Instruction;

/// @notice the CCTP V2 TokenMessenger contract
ITokenMessengerV2 public immutable tokenMessengerV2;
/// @notice the CCTP V2 TokenMinter contract
/// @dev the TokenMinter contract implements the ITokenController interface
ITokenController public immutable tokenMinterV2;

constructor(
address tokenMessengerV2Address,
address tokenMinterAddress,
address feeTokenRegistryAddress,
address treasuryAddress,
uint256 gasConstant_
) OtimFee(feeTokenRegistryAddress, treasuryAddress, gasConstant_) {
tokenMessengerV2 = ITokenMessengerV2(tokenMessengerV2Address);
tokenMinterV2 = ITokenController(tokenMinterAddress);
}

/// @inheritdoc IAction
function argumentsHash(bytes calldata arguments) public pure returns (bytes32, bytes32) {
return (INSTRUCTION_TYPEHASH, hash(abi.decode(arguments, (SweepCCTPV2))));
}

/// @inheritdoc ISweepCCTPV2Action
function hash(SweepCCTPV2 memory arguments) public pure returns (bytes32) {
return keccak256(
abi.encode(
ARGUMENTS_TYPEHASH,
arguments.token,
arguments.destinationDomain,
arguments.destinationMintRecipient,
arguments.threshold,
arguments.endBalance,
arguments.destinationCaller,
arguments.maxFeeThouBPS,
arguments.minFinalityThreshold,
hash(arguments.fee)
)
);
}

/// @inheritdoc IAction
function execute(
InstructionLib.Instruction calldata instruction,
InstructionLib.Signature calldata,
InstructionLib.ExecutionState calldata executionState
) external override returns (bool) {
// initial gas measurement for fee calculation
uint256 startGas = gasleft();

// decode the arguments from the instruction
SweepCCTPV2 memory arguments = abi.decode(instruction.arguments, (SweepCCTPV2));

// if first execution, validate the arguments
if (executionState.executionCount == 0) {
if (
arguments.token == address(0) || arguments.destinationMintRecipient == bytes32(0)
|| arguments.endBalance > arguments.threshold
) {
revert InvalidArguments();
}
}

// get the user's token balance
uint256 balance = IERC20(arguments.token).balanceOf(address(this));

// if the balance is under the threshold or equal to the endBalance, revert.
// the endBalance check is to prevent the instruction from executing
// when threshold == endBalance == balance because in this case we would have transferAmount == 0
// slither-disable-next-line incorrect-equality
if (balance < arguments.threshold || balance == arguments.endBalance) {
revert BalanceUnderThreshold();
}

// get the CCTP burnLimitPerMessage for the token
uint256 burnLimitPerMessage = tokenMinterV2.burnLimitsPerMessage(arguments.token);

// if the burnLimitPerMessage is zero, the token is not supported
if (burnLimitPerMessage == 0) {
revert CCTPTokenNotSupported();
}

// calculate the transferAmount
uint256 transferAmount = balance - arguments.endBalance;

// if the transferAmount is over the burnLimitPerMessage, emit an event and just transfer the burnLimitPerMessage
if (transferAmount > burnLimitPerMessage) {
transferAmount = burnLimitPerMessage;

emit CCTPBurnLimitReached(arguments.token, burnLimitPerMessage);
}

// validate maxFeeThouBPS against CCTP's minFee
uint256 calculatedMaxFee;
if (arguments.minFinalityThreshold < 2000) {
uint256 cctpMinFee = tokenMessengerV2.minFee();
if (arguments.maxFeeThouBPS < cctpMinFee) {
revert CCTPMaxFeeTooLow(arguments.maxFeeThouBPS, cctpMinFee);
}
// calculate actual fee based on transfer amount
calculatedMaxFee = tokenMessengerV2.getMinFeeAmount(transferAmount);
} else {
// standard transfers (minFinalityThreshold >= 2000) have no cost
calculatedMaxFee = 0;
}

// approve the transferAmount to the CCTP TokenMessenger contract
// slither-disable-next-line unused-return
IERC20(arguments.token).approve(address(tokenMessengerV2), transferAmount);

// initiate CCTP V2 transfer
tokenMessengerV2.depositForBurn(
transferAmount,
arguments.destinationDomain,
arguments.destinationMintRecipient,
arguments.token,
arguments.destinationCaller,
calculatedMaxFee,
arguments.minFinalityThreshold
);

// charge the fee
chargeFee(startGas - gasleft(), arguments.fee);

// this action has no auto-deactivation cases
return false;
}
}

Loading