// SPDX-License-Identifier: GPL-2.0-or-later pragma solidity 0.8.26; import {TickMath} from "./TickMath.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; interface IUniswapV3PoolOracle { function observe(uint32[] calldata secondsAgos) external view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s); } interface ISwapRouter02 { struct ExactInputParams { bytes path; address recipient; uint256 amountIn; uint256 amountOutMinimum; } function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut); } interface IERC20Min { function balanceOf(address) external view returns (uint256); function transfer(address to, uint256 value) external returns (bool); } /// @title GarnerCoin /// @notice A coin that is its own market, and whose trading fees are stored up as stock for the /// holders who lock it. /// /// Every buy and every sell pays a fee in ETH. The fee is swapped on Uniswap v3 — ETH → USDG → the /// coin's stock — and the stock goes into the coin's store, which pays itself out continuously over /// the following seven days: every second, a slice of the store is divided among the coins that are /// LOCKED, in proportion to their weight. Coins that are not locked earn nothing. /// /// A holder locks coins for one of four terms — 7, 30, 90 or 365 days — and a longer term weighs /// more (×1, ×1.5, ×2.5, ×5). Locked coins stay in the holder's wallet but cannot be moved or sold /// until the lock ends; at the end of its last day they are simply free again, and stop earning. /// Every lock ends at a UTC day boundary, never sooner than the term chosen. A lock can be topped up /// or extended, never shortened. The stock earned can be claimed at any time, during the lock or after. /// /// While nobody is locked the store stands still: nothing is paid out, and the seven days resume /// when somebody locks. /// /// Stock is never bought at a manipulated price: each swap must return at least what the two /// pools' own time-weighted average prices say it should, less the pools' fees and 2%. A swap that /// cannot meet that is not made — the ETH waits, and the next trade (or anyone) tries again. /// /// The market is a constant-product curve against a virtual ETH reserve, so there is liquidity from /// the first block. No owner, no pause, no upgrade, no fee switch. The launcher gets nothing. /// /// Deployed once as an implementation and cloned (EIP-1167) for every launch by GarnerFactory. contract GarnerCoin { // ------------------------------------------------------------------ ERC-20 string public name; string public symbol; uint8 public constant decimals = 18; uint256 public totalSupply; mapping(address => uint256) public balanceOf; mapping(address => mapping(address => uint256)) public allowance; event Transfer(address indexed from, address indexed to, uint256 value); event Approval(address indexed owner, address indexed spender, uint256 value); // ------------------------------------------------------------------ constants uint256 public constant SUPPLY = 1_000_000_000e18; /// @notice Fee ETH below this waits in `pendingEth` until a later trade tops it up (~5 cents). uint256 public constant MIN_CONVERT = 0.00002 ether; /// @notice Gas the stock swap is given (a two-hop swap into a stock uses ~300k here). uint256 public constant CONVERT_GAS = 600_000; /// @notice Gas kept back for reading the two price oracles before the swap. uint256 public constant ORACLE_GAS = 400_000; /// @notice Gas for one pool's price history read (~80k measured on the busiest pools here). uint256 public constant OBSERVE_GAS = 150_000; /// @notice How far below the average price, after pool fees, a stock buy may land. uint256 public constant MAX_SLIP_BPS = 200; /// @notice Scale of `stockPerWeight`. uint256 public constant ACC = 1e36; /// @notice How long the store takes to pay out what is in it, restarted by every purchase. uint256 public constant DRIP = 7 days; uint256 public constant DAY = 1 days; ISwapRouter02 public immutable router; address public immutable weth; address public immutable usdg; address public immutable factory; /// @notice The WETH/USDG pool every stock buy goes through first, and its fee. address public immutable ethPool; uint24 public immutable ethPoolFee; // ------------------------------------------------------------------ set once at launch address public creator; uint16 public feeBps; uint64 public launchedAt; uint256 public virtualEth; /// @notice The stock the fees buy, the USDG/stock pool they buy it through, and that pool's fee. address public stock; address public stockPool; uint24 public stockPoolFee; /// @notice Picture, description and links, stored as contract code (SSTORE2) by the factory. address public metaPointer; // ------------------------------------------------------------------ market state /// @notice Real ETH held by the curve (the fee ETH is not in it). uint256 public realEth; /// @notice Fee ETH not yet swapped into the stock. uint256 public pendingEth; /// @notice Lifetime fee ETH taken, and trades. uint256 public totalFeesEth; uint256 public tradeCount; /// @notice Lifetime fee ETH spent on the stock, the stock it bought, and the stock holders claimed. uint256 public ethSpent; uint256 public stockBought; uint256 public stockClaimed; // ------------------------------------------------------------------ the store // // These are as of `lastDrip`. Every view below brings them forward to now before answering. /// @notice Stock bought and not yet paid out. uint256 public dripping; /// @notice When `dripping` will have been paid out at the present pace. uint256 public dripEnd; /// @notice The moment the store was last brought forward to. uint256 public lastDrip; /// @notice Stock paid out to lockers since launch (set aside for them; some may be claimed). uint256 public stockReleased; /// @notice Stock paid out per unit of weight, cumulative since launch, scaled by ACC. uint256 public stockPerWeight; /// @notice Weight and coins of every lock that has not ended. uint256 public totalWeight; uint256 public totalLocked; struct Ending { uint128 weight; uint128 coins; } /// @notice What stops earning at the start of day `d` (unix time d × 1 day): the locks ending then. mapping(uint256 => Ending) public endingOn; /// @notice `stockPerWeight` at the start of day `d`, recorded only for days on which a lock ended. mapping(uint256 => uint256) public accOn; struct Lock { uint128 coins; uint128 weight; uint32 endDay; uint8 term; } mapping(address => Lock) internal _locks; mapping(address => uint256) internal _paidPerWeight; mapping(address => uint256) internal _owed; /// @notice Lifetime stock each holder has claimed. mapping(address => uint256) public claimedBy; uint256 private _locked; event Trade( address indexed trader, bool isBuy, uint256 ethAmount, uint256 coinAmount, uint256 feeEth, uint256 ethReserve, uint256 coinReserve ); /// @param fairOut what the average prices said `ethIn` was worth, in stock units /// @param inStore stock in the store after this purchase, paid out over the next seven days event StockBuy(uint256 ethIn, uint256 stockOut, uint256 fairOut, uint256 inStore); /// @param reason 1 = no average price could be read, 2 = the swap would have paid too much or failed event StockBuyDeferred(uint256 ethPending, uint8 reason); event Locked(address indexed holder, uint256 coins, uint256 weight, uint256 endsAt, uint8 term); event Claim(address indexed holder, address indexed to, uint256 amount); error AlreadyInitialized(); error OnlyFactory(); error Reentrancy(); error ZeroAmount(); error Slippage(); error EthTransferFailed(); error StockTransferFailed(); error InsufficientBalance(); error InsufficientAllowance(); error NeedsMoreGas(); error NothingToClaim(); error BadTerm(); error ShorterThanCurrent(); /// @notice The coins asked for are in the wallet but locked. error CoinsLocked(); modifier nonReentrant() { if (_locked == 1) revert Reentrancy(); _locked = 1; _; _locked = 0; } constructor(address router_, address weth_, address usdg_, address ethPool_, uint24 ethPoolFee_, address factory_) { router = ISwapRouter02(router_); weth = weth_; usdg = usdg_; ethPool = ethPool_; ethPoolFee = ethPoolFee_; factory = factory_; launchedAt = type(uint64).max; // the implementation itself can never be initialised } /// @dev The factory has already checked the stock: its pool exists and has a price history. function initialize( string calldata name_, string calldata symbol_, address metaPointer_, address creator_, address stock_, address stockPool_, uint24 stockPoolFee_, uint16 feeBps_, uint256 virtualEth_ ) external { if (msg.sender != factory) revert OnlyFactory(); if (launchedAt != 0) revert AlreadyInitialized(); name = name_; symbol = symbol_; metaPointer = metaPointer_; creator = creator_; stock = stock_; stockPool = stockPool_; stockPoolFee = stockPoolFee_; feeBps = feeBps_; virtualEth = virtualEth_; launchedAt = uint64(block.timestamp); lastDrip = block.timestamp; totalSupply = SUPPLY; balanceOf[address(this)] = SUPPLY; emit Transfer(address(0), address(this), SUPPLY); } // ------------------------------------------------------------------ ERC-20 logic function transfer(address to, uint256 value) external returns (bool) { _transfer(msg.sender, to, value); return true; } function approve(address spender, uint256 value) external returns (bool) { allowance[msg.sender][spender] = value; emit Approval(msg.sender, spender, value); return true; } function transferFrom(address from, address to, uint256 value) external returns (bool) { uint256 a = allowance[from][msg.sender]; if (a != type(uint256).max) { if (a < value) revert InsufficientAllowance(); allowance[from][msg.sender] = a - value; } _transfer(from, to, value); return true; } /// @dev Locked coins stay in the balance but cannot leave it until the lock ends. function _transfer(address from, address to, uint256 value) internal { uint256 b = balanceOf[from]; if (b < value) revert InsufficientBalance(); if (b - value < lockedOf(from)) revert CoinsLocked(); unchecked { balanceOf[from] = b - value; balanceOf[to] += value; } emit Transfer(from, to, value); } // ------------------------------------------------------------------ market function reserves() public view returns (uint256 ethReserve, uint256 coinReserve) { return (virtualEth + realEth, balanceOf[address(this)]); } /// @notice Coins out for `ethIn` sent to buy, and the part of it that buys stock for the store. function quoteBuy(uint256 ethIn) public view returns (uint256 coinsOut, uint256 fee) { fee = ethIn * feeBps / 10_000; uint256 net = ethIn - fee; (uint256 x, uint256 y) = reserves(); coinsOut = y * net / (x + net); } /// @notice ETH paid out for selling `coinsIn`, and the part of it that buys stock for the store. function quoteSell(uint256 coinsIn) public view returns (uint256 ethOut, uint256 fee) { (uint256 x, uint256 y) = reserves(); uint256 gross = x * coinsIn / (y + coinsIn); if (gross > realEth) gross = realEth; fee = gross * feeBps / 10_000; ethOut = gross - fee; } function buy(uint256 minCoinsOut, address to) external payable nonReentrant returns (uint256 coinsOut) { coinsOut = _buy(minCoinsOut, to); } /// @notice Buy, and lock what was bought (with any lock already held) for `term`, in one transaction. function buyAndLock(uint256 minCoinsOut, uint8 term) external payable nonReentrant returns (uint256 coinsOut) { coinsOut = _buy(minCoinsOut, msg.sender); _lock(msg.sender, coinsOut, term); } function _buy(uint256 minCoinsOut, address to) internal returns (uint256 coinsOut) { if (msg.value == 0) revert ZeroAmount(); uint256 fee; (coinsOut, fee) = quoteBuy(msg.value); if (coinsOut == 0 || coinsOut < minCoinsOut) revert Slippage(); realEth += msg.value - fee; tradeCount++; _transfer(address(this), to, coinsOut); (uint256 x, uint256 y) = reserves(); emit Trade(to, true, msg.value, coinsOut, fee, x, y); _accrue(fee); } function sell(uint256 coinsIn, uint256 minEthOut, address to) external nonReentrant returns (uint256 ethOut) { if (coinsIn == 0) revert ZeroAmount(); uint256 fee; (ethOut, fee) = quoteSell(coinsIn); if (ethOut == 0 || ethOut < minEthOut) revert Slippage(); _transfer(msg.sender, address(this), coinsIn); realEth -= ethOut + fee; tradeCount++; (uint256 x, uint256 y) = reserves(); emit Trade(msg.sender, false, ethOut, coinsIn, fee, x, y); _accrue(fee); _sendEth(to, ethOut); } // ------------------------------------------------------------------ buying the stock function _accrue(uint256 fee) internal { totalFeesEth += fee; pendingEth += fee; if (pendingEth >= MIN_CONVERT) _convert(pendingEth); } /// @dev Swaps `amt` of the pending fee ETH into the stock and puts it in the store, or leaves it /// pending. It never makes a trade fail — except for too little gas, which it refuses outright: a /// wallet estimates the smallest gas at which a transaction does not revert, and without this /// refusal that estimate would starve the swap inside the try and every fee would be deferred. function _convert(uint256 amt) internal { if (gasleft() < CONVERT_GAS + CONVERT_GAS / 63 + ORACLE_GAS) revert NeedsMoreGas(); uint256 fair = fairStockOut(amt); if (fair == 0) { emit StockBuyDeferred(pendingEth, 1); return; } uint256 minOut = Math.mulDiv(fair, (1e6 - uint256(ethPoolFee) - stockPoolFee) * (10_000 - MAX_SLIP_BPS), 1e10); if (minOut == 0) minOut = 1; pendingEth -= amt; try router.exactInput{value: amt, gas: CONVERT_GAS}( ISwapRouter02.ExactInputParams({ path: abi.encodePacked(weth, ethPoolFee, usdg, stockPoolFee, stock), recipient: address(this), amountIn: amt, amountOutMinimum: minOut }) ) returns (uint256 out) { ethSpent += amt; stockBought += out; // What was already in the store is paid out at its old pace up to now; then the whole // store, old and new, is spread over the next seven days. _update(); dripping += out; dripEnd = block.timestamp + DRIP; emit StockBuy(amt, out, fair, dripping); } catch { pendingEth += amt; emit StockBuyDeferred(pendingEth, 2); } } /// @notice Anyone can push pending fee ETH into the stock — all of it, or at most `maxEth` of it /// (a large backlog in a thin pool may only clear in pieces). function convert(uint256 maxEth) external nonReentrant { uint256 amt = pendingEth < maxEth ? pendingEth : maxEth; if (amt == 0) revert ZeroAmount(); _convert(amt); } /// @notice What `ethIn` is worth in the stock at the two pools' time-weighted average prices /// (ETH→USDG, then USDG→stock), before fees. Zero when either average cannot be read. /// Tries a 30-minute window, then 10 minutes, then 2: a very busy pool can have overwritten /// the older observations. Any window excludes a price pushed within the current block. function fairStockOut(uint256 ethIn) public view returns (uint256) { (bool ok1, int24 t1) = _meanTick(ethPool); if (!ok1) return 0; (bool ok2, int24 t2) = _meanTick(stockPool); if (!ok2) return 0; return _quoteAtTick(t2, _quoteAtTick(t1, ethIn, weth, usdg), usdg, stock); } function _meanTick(address pool) internal view returns (bool, int24) { uint32[] memory ago = new uint32[](2); for (uint256 i; i < 3; i++) { uint32 w = i == 0 ? 1800 : i == 1 ? 600 : 120; ago[0] = w; try IUniswapV3PoolOracle(pool).observe{gas: OBSERVE_GAS}(ago) returns (int56[] memory tc, uint160[] memory) { int56 d = tc[1] - tc[0]; int24 t = int24(d / int56(uint56(w))); if (d < 0 && d % int56(uint56(w)) != 0) t--; // round toward negative infinity, as Uniswap does return (true, t); } catch {} } return (false, 0); } /// @dev Uniswap's OracleLibrary.getQuoteAtTick, with a full-width base amount. function _quoteAtTick(int24 tick, uint256 baseAmount, address baseToken, address quoteToken) internal pure returns (uint256) { uint160 sqrtRatioX96 = TickMath.getSqrtRatioAtTick(tick); if (sqrtRatioX96 <= type(uint128).max) { uint256 ratioX192 = uint256(sqrtRatioX96) * sqrtRatioX96; return baseToken < quoteToken ? Math.mulDiv(ratioX192, baseAmount, 1 << 192) : Math.mulDiv(1 << 192, baseAmount, ratioX192); } uint256 ratioX128 = Math.mulDiv(sqrtRatioX96, sqrtRatioX96, 1 << 64); return baseToken < quoteToken ? Math.mulDiv(ratioX128, baseAmount, 1 << 128) : Math.mulDiv(1 << 128, baseAmount, ratioX128); } // ------------------------------------------------------------------ the store, paid out /// @dev The store's state, in memory, so the same arithmetic serves the views and the writes. struct Store { uint256 acc; uint256 weight; uint256 locked; uint256 pool; uint256 last; uint256 end; uint256 released; } function _load() internal view returns (Store memory s) { s = Store(stockPerWeight, totalWeight, totalLocked, dripping, lastDrip, dripEnd, stockReleased); } /// @dev Pays the store out from `s.last` to `t` at a constant weight. The pace is linear: what is /// left of the store over what is left of its seven days. What a division cannot place exactly /// stays in the store and is paid out with the rest. function _flow(Store memory s, uint256 t) internal pure { if (t <= s.last) return; uint256 dt = t - s.last; s.last = t; if (s.weight == 0) { // Nobody is locked: the store stands still and keeps its pace for when somebody is. if (s.end > t - dt) s.end += dt; return; } uint256 left = s.end > t - dt ? s.end - (t - dt) : 0; uint256 r = dt >= left ? s.pool : Math.mulDiv(s.pool, dt, left); if (r == 0) return; uint256 inc = Math.mulDiv(r, ACC, s.weight); // Rounded UP: the lockers' floors of `inc` add up to at most this, so the store never pays // out more than it held. uint256 placed = Math.mulDiv(inc, s.weight, ACC, Math.Rounding.Ceil); s.acc += inc; s.pool -= placed; s.released += placed; } /// @dev Brings the store forward to now, in memory, stopping at the start of every day on which /// locks ended to take their weight out. Returns `stockPerWeight` at the start of day `wantDay` /// if that day was passed on the way (zero otherwise). function _walk(Store memory s, uint256 wantDay) internal view returns (uint256 accWant) { for (uint256 day = s.last / DAY + 1; day * DAY <= block.timestamp; day++) { Ending memory e = endingOn[day]; if (e.weight == 0) continue; _flow(s, day * DAY); if (day == wantDay) accWant = s.acc; s.weight -= e.weight; s.locked -= e.coins; } _flow(s, block.timestamp); } /// @dev `_walk`, written to storage, recording `accOn` for every day on which locks ended. function _update() internal { Store memory s = _load(); if (s.last == block.timestamp) return; for (uint256 day = s.last / DAY + 1; day * DAY <= block.timestamp; day++) { Ending memory e = endingOn[day]; if (e.weight == 0) continue; _flow(s, day * DAY); accOn[day] = s.acc; s.weight -= e.weight; s.locked -= e.coins; } _flow(s, block.timestamp); stockPerWeight = s.acc; totalWeight = s.weight; totalLocked = s.locked; dripping = s.pool; lastDrip = s.last; dripEnd = s.end; stockReleased = s.released; } /// @notice Anyone may bring the store forward to now. Every lock, claim and stock purchase does /// this anyway; it exists so a coin left alone for a very long time can be caught up first. function poke() external nonReentrant { _update(); } // ------------------------------------------------------------------ locking /// @notice Days in each lock term, and its weight in tenths. function termDays(uint8 term) public pure returns (uint256) { if (term == 0) return 7; if (term == 1) return 30; if (term == 2) return 90; if (term == 3) return 365; revert BadTerm(); } function boostOf(uint8 term) public pure returns (uint256) { if (term == 0) return 10; if (term == 1) return 15; if (term == 2) return 25; if (term == 3) return 50; revert BadTerm(); } /// @notice Coins of `who` that cannot move right now. function lockedOf(address who) public view returns (uint256) { Lock memory l = _locks[who]; return uint256(l.endDay) * DAY > block.timestamp ? l.coins : 0; } /// @notice Lock `coins` more of your coins — 0 to only extend — together with any lock you already /// hold, for `term`. The whole lock then ends at the start of the first UTC day at least the term's /// length from now, and weighs that term's boost. It may not end sooner than the lock it replaces. function lock(uint256 coins, uint8 term) external nonReentrant { _lock(msg.sender, coins, term); } function _lock(address who, uint256 coins, uint8 term) internal { uint256 boost = boostOf(term); _update(); _settle(who); Lock memory l = _locks[who]; uint256 endDay = (block.timestamp + termDays(term) * DAY + DAY - 1) / DAY; uint256 held = lockedOf(who); if (coins > balanceOf[who] - held) revert InsufficientBalance(); if (held != 0) { if (endDay < l.endDay) revert ShorterThanCurrent(); totalWeight -= l.weight; totalLocked -= l.coins; Ending storage old = endingOn[l.endDay]; old.weight -= l.weight; old.coins -= l.coins; } uint256 total = held + coins; if (total == 0) revert ZeroAmount(); uint256 weight = total * boost / 10; totalWeight += weight; totalLocked += total; Ending storage e = endingOn[endDay]; e.weight += uint128(weight); e.coins += uint128(total); _locks[who] = Lock(uint128(total), uint128(weight), uint32(endDay), term); _paidPerWeight[who] = stockPerWeight; emit Locked(who, total, weight, endDay * DAY, term); } // ------------------------------------------------------------------ claiming /// @dev After `_update`: credits `a` with everything its lock earned up to now, or up to the start /// of the day it ended. function _settle(address a) internal { Lock memory l = _locks[a]; if (l.weight == 0) return; uint256 upto = uint256(l.endDay) * DAY <= block.timestamp ? accOn[l.endDay] : stockPerWeight; uint256 paid = _paidPerWeight[a]; if (upto <= paid) return; _owed[a] += Math.mulDiv(l.weight, upto - paid, ACC); _paidPerWeight[a] = upto; } /// @notice Stock `who` can claim right now. function claimable(address who) public view returns (uint256) { Lock memory l = _locks[who]; if (l.weight == 0) return _owed[who]; Store memory s = _load(); uint256 upto; if (uint256(l.endDay) * DAY <= block.timestamp) { // ended: at the start of its last day, read from storage if the store has passed it already upto = uint256(l.endDay) * DAY <= s.last ? accOn[l.endDay] : _walk(s, l.endDay); } else { _walk(s, 0); upto = s.acc; } uint256 paid = _paidPerWeight[who]; return _owed[who] + (upto > paid ? Math.mulDiv(l.weight, upto - paid, ACC) : 0); } /// @notice Send everything the caller has earned to `to`. Coins and lock stay as they are. function claim(address to) external nonReentrant returns (uint256 amount) { _update(); _settle(msg.sender); amount = _owed[msg.sender]; if (amount == 0) revert NothingToClaim(); _owed[msg.sender] = 0; claimedBy[msg.sender] += amount; stockClaimed += amount; if (!_callOk(stock, abi.encodeCall(IERC20Min.transfer, (to, amount)))) revert StockTransferFailed(); emit Claim(msg.sender, to, amount); } // ------------------------------------------------------------------ for the app /// @notice ABI-encoded (string image, string description, string website, string x, string telegram). function meta() public view returns (bytes memory data) { address p = metaPointer; if (p == address(0)) return data; uint256 size = p.code.length; if (size <= 1) return data; data = new bytes(size - 1); assembly ("memory-safe") { extcodecopy(p, add(data, 32), 1, sub(size, 1)) } } struct Info { string name; string symbol; address creator; uint16 feeBps; uint64 launchedAt; address stock; address stockPool; uint24 stockPoolFee; uint256 totalSupply; uint256 ethReserve; uint256 coinReserve; uint256 realEth; uint256 pendingEth; uint256 totalFeesEth; uint256 tradeCount; uint256 ethSpent; uint256 stockBought; uint256 stockClaimed; uint256 stockHeld; uint256 inStore; uint256 storeEnd; uint256 stockReleased; uint256 stockPerWeight; uint256 totalWeight; uint256 totalLocked; } /// @notice Everything about the coin, with the store brought forward to now. function info() external view returns (Info memory i) { (uint256 x, uint256 y) = reserves(); Store memory s = _load(); _walk(s, 0); i = Info( name, symbol, creator, feeBps, launchedAt, stock, stockPool, stockPoolFee, totalSupply, x, y, realEth, pendingEth, totalFeesEth, tradeCount, ethSpent, stockBought, stockClaimed, IERC20Min(stock).balanceOf(address(this)), s.pool, s.end, s.released, s.acc, s.weight, s.locked ); } /// @notice One holder, for the app: coins held, coins locked now, the lock (coins, weight, end, term — /// as last set, even if it has ended), stock claimable now, stock claimed so far. function holder(address a) external view returns ( uint256 coins, uint256 lockedNow, uint256 lockCoins, uint256 lockWeight, uint256 lockEndsAt, uint8 lockTerm, uint256 claimableNow, uint256 claimedSoFar ) { Lock memory l = _locks[a]; return (balanceOf[a], lockedOf(a), l.coins, l.weight, uint256(l.endDay) * DAY, l.term, claimable(a), claimedBy[a]); } function _callOk(address target, bytes memory data) internal returns (bool) { (bool ok, bytes memory ret) = target.call(data); return ok && (ret.length == 0 || (ret.length >= 32 && abi.decode(ret, (bool)))); } function _sendEth(address to, uint256 amt) internal { (bool ok,) = to.call{value: amt}(""); if (!ok) revert EthTransferFailed(); } /// @dev Only the router may send ETH here (a refund). A plain transfer is refused, so no ETH can /// end up outside the accounting. receive() external payable { if (msg.sender != address(router)) revert(); } }