diff --git a/contracts/core/src/examples/Funder.sol b/contracts/core/src/examples/Funder.sol new file mode 100644 index 000000000..31b9afde8 --- /dev/null +++ b/contracts/core/src/examples/Funder.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity 0.8.24; + +import { XApp } from "src/pkg/XApp.sol"; +import { XGasPump } from "src/pkg/XGasPump.sol"; +import { ConfLevel } from "src/libraries/ConfLevel.sol"; + +/** + * @title Funder + * @notice Example contract that shows how to use XGasPump + */ +contract Funder is XApp, XGasPump { + address public thingDoer; + + constructor(address portal, address pump) XApp(portal, ConfLevel.Latest) XGasPump(pump) { } + + /** + * @notice Simple external method to let msg.sender swap msg.value ETH for OMNI, on Omni + */ + function getOMNI() external payable { + fillUp(msg.sender, msg.value); + } + + /** + * @notice Example of doing an xcall, and using excess msg.value to fund the caller on Omni, + * if they paid enough + */ + function doThingAndMaybeGetOMNI() external payable { + uint256 fee = xcall({ + destChainId: omniChainId(), + to: thingDoer, + data: abi.encodeWithSignature("doThing()"), + gasLimit: 100_000 + }); + + require(msg.value >= fee, "Funder: insufficient fee"); + + if (msg.value > fee) fillUpOrRefund(msg.sender, msg.value - fee); + } + + function doThingFee() external view returns (uint256) { + return feeFor({ destChainId: omniChainId(), data: abi.encodeWithSignature("doThing()"), gasLimit: 100_000 }); + } +} diff --git a/contracts/core/src/pkg/XGasPump.sol b/contracts/core/src/pkg/XGasPump.sol new file mode 100644 index 000000000..b43c1703b --- /dev/null +++ b/contracts/core/src/pkg/XGasPump.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.12; + +import { OmniGasPump } from "../token/OmniGasPump.sol"; + +/** + * @title XGasPump + * @notice Abstract contract that makes it easy to swap ETH for OMNI on Omni. + */ +abstract contract XGasPump { + event Refunded(address indexed recipient, uint256 amtETH, string reason); + event FundedOMNI(address indexed recipient, uint256 ethPaid, uint256 omniReceived); + + OmniGasPump public immutable omniGasPump; + + constructor(address exchange) { + omniGasPump = OmniGasPump(exchange); + } + + /** + * @notice Swap `amtETH` ETH for OMNI on Omni, funding `recipient`. + * Reverts if `amtETH` does not cover xcall fee, or is > max allowed swap. + */ + function fillUp(address recipient, uint256 amtETH) internal { + _fillUp(recipient, amtETH); + } + + /** + * @notice Fund `recipient` with `amtETH` worth of OMNI on Omni. + * If `amtETH` is not swappable, refund to `recipient`. + */ + function fillUpOrRefund(address recipient, uint256 amtETH) internal { + _fillUpOrRefund(recipient, recipient, amtETH); + } + + /** + * @notice Fund `recipient` with `amtETH` worth of OMNI on Omni. + * If `amtETH` is not swappable, refund to `refundTo`. + */ + function fillUpOrRefund(address refundTo, address recipient, uint256 amtETH) internal { + _fillUpOrRefund(refundTo, recipient, amtETH); + } + + function _fillUpOrRefund(address refundTo, address recipient, uint256 amtETH) internal { + (, bool succes, string memory reason) = omniGasPump.dryFillUp(amtETH); + + if (!succes) { + emit Refunded(refundTo, amtETH, reason); + payable(refundTo).transfer(amtETH); + return; + } + + _fillUp(recipient, amtETH); + } + + function _fillUp(address recipient, uint256 amtETH) private { + uint256 amtOMNI = omniGasPump.fillUp{ value: amtETH }(recipient); + emit FundedOMNI(recipient, amtETH, amtOMNI); + } +} diff --git a/contracts/core/src/token/OmniGasPump.sol b/contracts/core/src/token/OmniGasPump.sol new file mode 100644 index 000000000..446a26969 --- /dev/null +++ b/contracts/core/src/token/OmniGasPump.sol @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.24; + +import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import { XAppUpgradeable } from "src/pkg/XAppUpgradeable.sol"; +import { FeeOracleV1 } from "src/xchain/FeeOracleV1.sol"; +import { ConfLevel } from "src/libraries/ConfLevel.sol"; +import { OmniGasStation } from "./OmniGasStation.sol"; + +/** + * @title OmniGasPump + * @notice A unidirectional cross-chain gas exchange, allowing users to swap native ETH for native OMNI. + */ +contract OmniGasPump is XAppUpgradeable, OwnableUpgradeable, PausableUpgradeable { + /// @notice Emitted when the max swap is set + event MaxSwapSet(uint256 max); + + /// @notice Emitted when the gas station is set + event GasStationSet(address station); + + /// @notice Emitted when the toll is set + event TollSet(uint256 pct); + + /** + * @notice Emitted on a fillUp + * @param recipient Address on Omni to send OMNI to + * @param owed Total amount owed to `recipient` (sum of historical fillUps()), denominated in OMNI. + * @param amtETH Amount of ETH paid + * @param fee Xcall fee, denominated in ETH + * @param toll Toll taken by this contract, denominated in ETH + * @param amtOMNI Amount of OMNI swapped for + */ + event FilledUp(address indexed recipient, uint256 owed, uint256 amtETH, uint256 fee, uint256 toll, uint256 amtOMNI); + + /// @notice Gas limit passed to OmniGasStation.settleUp xcall + uint64 internal constant SETTLE_GAS = 100_000; + + /// @notice Denominator for toll percentage calculations + uint256 internal constant TOLL_DENOM = 1000; + + /// @notice Address of OmniGasStation on Omni + address public gasStation; + + /// @notice Max amt (in native token) that can be swapped in a single tx + uint256 public maxSwap; + + /// @notice Percentage toll taken by this contract for each swap, to disencentivize spamming + uint256 public toll; + + /// @notice Map recipient to total owed (sum of historical fillUps()), denominated in OMNI. + mapping(address => uint256) public owed; + + constructor() { + _disableInitializers(); + } + + struct InitParams { + address gasStation; + address portal; + address owner; + uint256 maxSwap; + uint256 toll; + } + + function initialize(InitParams calldata p) external initializer { + _setGasStation(p.gasStation); + _setMaxSwap(p.maxSwap); + _setToll(p.toll); + __XApp_init(p.portal, ConfLevel.Latest); + __Ownable_init(p.owner); + } + + /** + * @notice Swaps msg.value ETH for OMNI and sends it to `recipient` on Omni. + * + * Takes an xcall fee and a pct cut. Cut taken to disencentivize spamming. + * Returns the amount of OMNI swapped for. + * + * To retry (if OmniGasStation transfer fails), call swap() again with the + * same `recipient`, and msg.value == swapFee(). + * + * @param recipient Address on Omni to send OMNI to + */ + function fillUp(address recipient) public payable whenNotPaused returns (uint256) { + // take xcall fee + uint256 f = xfee(); + require(msg.value >= f, "OmniGasPump: insufficient fee"); + uint256 amtETH = msg.value - f; + + // check max + require(amtETH <= maxSwap, "OmniGasPump: over max"); + + // take toll + uint256 t = amtETH * toll / TOLL_DENOM; + amtETH -= t; + + uint256 amtOMNI = _toOmni(amtETH); + + // update owed + owed[recipient] += amtOMNI; + + // settle up with the gas station + xcall({ + destChainId: omniChainId(), + to: gasStation, + conf: ConfLevel.Latest, + data: abi.encodeCall(OmniGasStation.settleUp, (recipient, owed[recipient])), + gasLimit: SETTLE_GAS + }); + + emit FilledUp(recipient, owed[recipient], msg.value, f, t, amtOMNI); + + return amtOMNI; + } + + /** + * @notice Simulate a fillUp() + * Returns the amount of OMNI that `amtETH` msg.value would buy, whether + * or not it would succeed, and the revert reason, if any. + */ + function dryFillUp(uint256 amtETH) public view returns (uint256, bool, string memory) { + // take xcall fee + uint256 f = xfee(); + if (amtETH < f) return (0, false, "insufficient fee"); + amtETH = amtETH - f; + + // check max + if (amtETH > maxSwap) return (0, false, "over max"); + + // take toll + amtETH -= amtETH * toll / TOLL_DENOM; + + return (_toOmni(amtETH), true, ""); + } + + /// @notice Returns the xcall fee required for fillUp(). Does not include `pctCut`. + function xfee() public view returns (uint256) { + // Use max addrs & amount to use no zero byte calldata to ensure max fee + address recipient = address(type(uint160).max); + uint256 amt = type(uint256).max; + + return feeFor({ + destChainId: omniChainId(), + data: abi.encodeCall(OmniGasStation.settleUp, (recipient, amt)), + gasLimit: SETTLE_GAS + }); + } + + /// @notice Returns the amount of ETH needed to swap for `amtOMNI` + function quote(uint256 amtOMNI) public view returns (uint256) { + uint256 amtETH = _toEth(amtOMNI); + + // "undo" toll + amtETH += (amtETH * TOLL_DENOM / (TOLL_DENOM - toll)); + + // "undo" xcall fee + return amtETH + xfee(); + } + + /// @notice Converts `amtOMNI` to ETH, using the current conversion rate + function _toEth(uint256 amtOMNI) internal view returns (uint256) { + FeeOracleV1 o = FeeOracleV1(omni.feeOracle()); + return amtOMNI * o.CONVERSION_RATE_DENOM() / o.toNativeRate(omniChainId()); + } + + /// @notice Converts `amtETH` to OMNI, using the current conversion rate + function _toOmni(uint256 amtETH) internal view returns (uint256) { + FeeOracleV1 o = FeeOracleV1(omni.feeOracle()); + return amtETH * o.toNativeRate(omniChainId()) / o.CONVERSION_RATE_DENOM(); + } + + ////////////////////////////////////////////////////////////////////////////// + // Admin // + ////////////////////////////////////////////////////////////////////////////// + + /// @notice Pause fill ups + function pause() external onlyOwner { + _pause(); + } + + /// @notice Unpause fill ups + function unpause() external onlyOwner { + _unpause(); + } + + /// @notice Withdraw collected ETH to `to` + function withdraw(address to) external onlyOwner { + (bool success,) = to.call{ value: address(this).balance }(""); + require(success, "OmniGasPump: withdraw failed"); + } + + /// @notice Set the max swap, denominated in ETh + function setMaxSwap(uint256 max) external onlyOwner { + _setMaxSwap(max); + } + + /// @notice Set the address of the OmniGasStation, on Omni + function setOmniGasStation(address station) external onlyOwner { + _setGasStation(station); + } + + /// @notice Set the toll (as a percentage over PCT_DENOM) + function setToll(uint256 pct) external onlyOwner { + _setToll(pct); + } + + function _setToll(uint256 pct) internal { + require(pct < TOLL_DENOM, "OmniGasPump: pct too high"); + toll = pct; + emit TollSet(toll); + } + + function _setMaxSwap(uint256 max) internal { + require(max > 0, "OmniGasPump: zero max"); + maxSwap = max; + emit MaxSwapSet(max); + } + + function _setGasStation(address station) internal { + require(station != address(0), "OmniGasPump: zero address"); + gasStation = station; + emit GasStationSet(station); + } +} diff --git a/contracts/core/src/token/OmniGasStation.sol b/contracts/core/src/token/OmniGasStation.sol new file mode 100644 index 000000000..00b9ac0ac --- /dev/null +++ b/contracts/core/src/token/OmniGasStation.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.24; + +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { XAppUpgradeable } from "src/pkg/XAppUpgradeable.sol"; +import { ConfLevel } from "src/libraries/ConfLevel.sol"; + +/** + * @title OmniGasStation + * @notice Pays out all gas owed by way of OmniGasPumps on other chains + */ +contract OmniGasStation is XAppUpgradeable, OwnableUpgradeable, PausableUpgradeable { + /** + * @notice Emitted on settleUp + * @param recipient Address + * @param chainID ChainID of the pump + * @param owed Total amount owed to `recipient` by way of `chainID`'s OmniGasPump + * @param fueled Total amount sent to `recipient` by this contract. + * @param success True if the transfer was successful (if true, owed == fueled) + */ + event SettledUp(address indexed recipient, uint64 indexed chainID, uint256 owed, uint256 fueled, bool success); + + /// @notice Emitted when a OmniGasPump is set for a chain + event GasPumpAdded(uint64 indexed chainID, address addr); + + //// @notice Map chainID to addr to true, if authorized to withdraw + mapping(uint64 => address) public pumps; + + /// @notice Map recipient to chainID to total fueled + mapping(address => mapping(uint64 => uint256)) public fueled; + + constructor() { + _disableInitializers(); + } + + function initialize(address portal, address owner) external initializer { + __XApp_init(portal, ConfLevel.Finalized); + __Ownable_init(owner); + } + + /** + * @notice Settle up with a recipient. If `owed` is more than they've been fueled, send the difference. + * @param recipient Address to receive the funds + * @param owed Total amount owed to `recipient`, by way of xmsg.sourceChainId's OmniGasPump + */ + function settleUp(address recipient, uint256 owed) external xrecv whenNotPaused { + require(isXCall() && isPump(xmsg.sourceChainId, xmsg.sender), "GasStation: unauthorized"); + + uint256 settled = fueled[recipient][xmsg.sourceChainId]; + + // If already settled, revert + require(owed >= settled, "GasStation: already funded"); + + // Transfer the difference + (bool success,) = recipient.call{ value: owed - settled }(""); + + // Update books. We do not bother doing so pre-transfer, because isXCall() prevents reentrancy + if (success) fueled[recipient][xmsg.sourceChainId] = owed; + + emit SettledUp(recipient, xmsg.sourceChainId, owed, fueled[recipient][xmsg.sourceChainId], success); + } + + /// @notice Set the pump addr for a chain + function setPump(uint64 chainID, address addr) external onlyOwner { + pumps[chainID] = addr; + emit GasPumpAdded(chainID, addr); + } + + /// @notice Return true if `chainID` has a registered pump at `addr` + function isPump(uint64 chainID, address addr) public view returns (bool) { + return addr != address(0) && addr == pumps[chainID]; + } + + /// @notice Pause withdrawals + function pause() external onlyOwner { + _pause(); + } + + /// @notice Unpause withdrawals + function unpause() external onlyOwner { + _unpause(); + } + + receive() external payable { } +}