From 957c21af6955367169cff17ceee00f73efd97fe3 Mon Sep 17 00:00:00 2001 From: Adam Hotait Date: Tue, 24 Feb 2026 17:29:20 +0100 Subject: [PATCH] feat(multi-atm): perfectly linear accrual --- contracts/token/MultiATM.sol | 265 ++-- contracts/token/MultiATMLinear.sol | 417 +++++ test/main.test.js | 2282 ++++++++++++++++++++++++++++ 3 files changed, 2799 insertions(+), 165 deletions(-) create mode 100644 contracts/token/MultiATMLinear.sol diff --git a/contracts/token/MultiATM.sol b/contracts/token/MultiATM.sol index a5afe37..0146460 100644 --- a/contracts/token/MultiATM.sol +++ b/contracts/token/MultiATM.sol @@ -2,22 +2,23 @@ pragma solidity ^0.8.27; -import { IAuthority } from "@openzeppelin/contracts/access/manager/IAuthority.sol"; -import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; -import { IERC20Metadata } from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; -import { ERC2771Context } from "@openzeppelin/contracts/metatx/ERC2771Context.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import { Hashes } from "@openzeppelin/contracts/utils/cryptography/Hashes.sol"; -import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; -import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; -import { SignedMath } from "@openzeppelin/contracts/utils/math/SignedMath.sol"; -import { Context } from "@openzeppelin/contracts/utils/Context.sol"; -import { Multicall } from "@openzeppelin/contracts/utils/Multicall.sol"; -import { Oracle } from "../oracle/Oracle.sol"; +import { IAuthority } from "@openzeppelin/contracts/access/manager/IAuthority.sol"; +import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; +import { ERC2771Context } from "@openzeppelin/contracts/metatx/ERC2771Context.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { Hashes } from "@openzeppelin/contracts/utils/cryptography/Hashes.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import { SignedMath } from "@openzeppelin/contracts/utils/math/SignedMath.sol"; +import { Context } from "@openzeppelin/contracts/utils/Context.sol"; +import { Multicall } from "@openzeppelin/contracts/utils/Multicall.sol"; +import { Oracle } from "../oracle/Oracle.sol"; import { PermissionManaged } from "../permissions/PermissionManaged.sol"; -contract MultiATM is ERC2771Context, PermissionManaged, Multicall { - using Math for *; +contract MultiATM is ERC2771Context, PermissionManaged, Multicall +{ + using Math for *; using SafeCast for *; uint256 private constant _BASIS_POINT_SCALE = 1e4; @@ -53,21 +54,8 @@ contract MultiATM is ERC2771Context, PermissionManaged, Multicall { mapping(bytes32 id => Pair) private _pairs; uint256 public feeBasisPoints; - event SwapExact( - IERC20 indexed input, - IERC20 indexed output, - uint256 inputAmount, - uint256 outputAmount, - address from, - address to - ); - event PairUpdated( - bytes32 indexed id, - IERC20 indexed token1, - IERC20 indexed token2, - Oracle oracle, - uint256 oracleTTL - ); + event SwapExact(IERC20 indexed input, IERC20 indexed output, uint256 inputAmount, uint256 outputAmount, address from, address to); + event PairUpdated(bytes32 indexed id, IERC20 indexed token1, IERC20 indexed token2, Oracle oracle, uint256 oracleTTL); event PairRemoved(bytes32 indexed id); event FeeUpdated(uint256 newFeeBasisPoints); error OutputAmountTooLow(uint256 outputAmount, uint256 minOutputAmount); @@ -77,207 +65,153 @@ contract MultiATM is ERC2771Context, PermissionManaged, Multicall { error InvalidFee(uint256 feeBasisPoints); /// @custom:oz-upgrades-unsafe-allow constructor - constructor( - IAuthority _authority, - address _trustedForwarder - ) PermissionManaged(_authority) ERC2771Context(_trustedForwarder) {} + constructor(IAuthority _authority, address _trustedForwarder) + PermissionManaged(_authority) + ERC2771Context(_trustedForwarder) + {} /**************************************************************************************************************** * Getters * ****************************************************************************************************************/ - function viewPairDetails( - IERC20 input, - IERC20 output - ) - public - view - virtual - returns ( - bytes32 id, - IERC20 token1, - IERC20 token2, - Oracle oracle, - uint256 oracleTTL, - uint256 numerator, - uint256 denominator - ) - { + function viewPairDetails(IERC20 input, IERC20 output) public view virtual returns ( + bytes32 id, + IERC20 token1, + IERC20 token2, + Oracle oracle, + uint256 oracleTTL, + uint256 numerator, + uint256 denominator + ) { id = hashPair(input, output); Pair storage pair = _pairs[id]; - return (id, pair.token1, pair.token2, pair.oracle, pair.oracleTTL, pair.numerator, pair.denominator); + return ( + id, + pair.token1, + pair.token2, + pair.oracle, + pair.oracleTTL, + pair.numerator, + pair.denominator + ); } function hashPair(IERC20 input, IERC20 output) public view virtual returns (bytes32) { - return - Hashes.commutativeKeccak256( - bytes32(uint256(uint160(address(input)))), - bytes32(uint256(uint160(address(output)))) - ); + return Hashes.commutativeKeccak256( + bytes32(uint256(uint160(address(input)))), + bytes32(uint256(uint160(address(output)))) + ); } /**************************************************************************************************************** * Core - preview swaps * ****************************************************************************************************************/ - function previewExactInput( - IERC20[] memory path, - uint256 inputAmount - ) public view virtual returns (uint256 /*outputAmount*/) { + function previewExactInput(IERC20[] memory path, uint256 inputAmount) public view virtual returns (uint256 /*outputAmount*/) { uint256 outputAmount = inputAmount; for (uint256 i = 0; i < path.length - 1; ++i) { - outputAmount = _exactInput(path[i], path[i + 1], outputAmount); + outputAmount = _exactInput(path[i], path[i+1], outputAmount); } return outputAmount.mulDiv(_BASIS_POINT_SCALE - feeBasisPoints, _BASIS_POINT_SCALE, Math.Rounding.Floor); } - function previewExactOutput( - IERC20[] memory path, - uint256 outputAmount - ) public view virtual returns (uint256 /*inputAmount*/) { + function previewExactOutput(IERC20[] memory path, uint256 outputAmount) public view virtual returns (uint256 /*inputAmount*/) { uint256 inputAmount = outputAmount; for (uint256 i = path.length - 1; i > 0; --i) { - inputAmount = _exactOutput(path[i - 1], path[i], inputAmount); + inputAmount = _exactOutput(path[i-1], path[i], inputAmount); } return inputAmount.mulDiv(_BASIS_POINT_SCALE, _BASIS_POINT_SCALE - feeBasisPoints, Math.Rounding.Ceil); } - function previewExactInputSingle( - IERC20 input, - IERC20 output, - uint256 inputAmount - ) public view virtual returns (uint256 /*outputAmount*/) { - return - _exactInput(input, output, inputAmount).mulDiv( - _BASIS_POINT_SCALE - feeBasisPoints, - _BASIS_POINT_SCALE, - Math.Rounding.Floor - ); + function previewExactInputSingle(IERC20 input, IERC20 output, uint256 inputAmount) public view virtual returns (uint256 /*outputAmount*/) { + return _exactInput(input, output, inputAmount).mulDiv(_BASIS_POINT_SCALE - feeBasisPoints, _BASIS_POINT_SCALE, Math.Rounding.Floor); } - function previewExactOutputSingle( - IERC20 input, - IERC20 output, - uint256 outputAmount - ) public view virtual returns (uint256 /*inputAmount*/) { - return - _exactOutput(input, output, outputAmount).mulDiv( - _BASIS_POINT_SCALE, - _BASIS_POINT_SCALE - feeBasisPoints, - Math.Rounding.Ceil - ); + function previewExactOutputSingle(IERC20 input, IERC20 output, uint256 outputAmount) public view virtual returns (uint256 /*inputAmount*/) { + return _exactOutput(input, output, outputAmount).mulDiv(_BASIS_POINT_SCALE, _BASIS_POINT_SCALE - feeBasisPoints, Math.Rounding.Ceil); } - function _exactInput( - IERC20 input, - IERC20 output, - uint256 inputAmount - ) internal view virtual returns (uint256 /*outputAmount*/) { - (, IERC20 token1, , Oracle oracle, uint256 oracleTTL, uint256 numerator, uint256 denominator) = viewPairDetails( - input, - output - ); + function _exactInput(IERC20 input, IERC20 output, uint256 inputAmount) internal view virtual returns (uint256 /*outputAmount*/) { + ( + , + IERC20 token1, + , + Oracle oracle, + uint256 oracleTTL, + uint256 numerator, + uint256 denominator + ) = viewPairDetails(input, output); require(address(oracle) != address(0), UnknownPair(input, output)); (int256 minPrice, int256 maxPrice) = _getPrices(oracle, oracleTTL); - return - inputAmount.mulDiv( - Math.ternary(input == token1, numerator * minPrice.toUint256(), denominator), - Math.ternary(input == token1, denominator, numerator * maxPrice.toUint256()), - Math.Rounding.Floor - ); + return inputAmount.mulDiv( + Math.ternary(input == token1, numerator * minPrice.toUint256(), denominator), + Math.ternary(input == token1, denominator, numerator * maxPrice.toUint256()), + Math.Rounding.Floor + ); } - function _exactOutput( - IERC20 input, - IERC20 output, - uint256 outputAmount - ) internal view virtual returns (uint256 /*inputAmount*/) { - (, IERC20 token1, , Oracle oracle, uint256 oracleTTL, uint256 numerator, uint256 denominator) = viewPairDetails( - input, - output - ); + function _exactOutput(IERC20 input, IERC20 output, uint256 outputAmount) internal view virtual returns (uint256 /*inputAmount*/) { + ( + , + IERC20 token1, + , + Oracle oracle, + uint256 oracleTTL, + uint256 numerator, + uint256 denominator + ) = viewPairDetails(input, output); require(address(oracle) != address(0), UnknownPair(input, output)); (int256 minPrice, int256 maxPrice) = _getPrices(oracle, oracleTTL); - return - outputAmount.mulDiv( - Math.ternary(input == token1, denominator, numerator * maxPrice.toUint256()), - Math.ternary(input == token1, numerator * minPrice.toUint256(), denominator), - Math.Rounding.Ceil - ); + return outputAmount.mulDiv( + Math.ternary(input == token1, denominator, numerator * maxPrice.toUint256()), + Math.ternary(input == token1, numerator * minPrice.toUint256(), denominator), + Math.Rounding.Ceil + ); } /**************************************************************************************************************** * Core - execute swaps * ****************************************************************************************************************/ - function swapExactInput( - IERC20[] memory path, - uint256 inputAmount, - address recipient, - uint256 minOutputAmount - ) public virtual restricted returns (uint256 /*outputAmount*/) { + function swapExactInput(IERC20[] memory path, uint256 inputAmount, address recipient, uint256 minOutputAmount) public virtual restricted() returns (uint256 /*outputAmount*/) { uint256 outputAmount = previewExactInput(path, inputAmount); require(outputAmount >= minOutputAmount, OutputAmountTooLow(outputAmount, minOutputAmount)); _swapExact(path[0], path[path.length - 1], inputAmount, outputAmount, _msgSender(), recipient); return outputAmount; } - function swapExactInputSingle( - IERC20 input, - IERC20 output, - uint256 inputAmount, - address recipient, - uint256 minOutputAmount - ) public virtual restricted returns (uint256 /*outputAmount*/) { + function swapExactInputSingle(IERC20 input, IERC20 output, uint256 inputAmount, address recipient, uint256 minOutputAmount) public virtual restricted() returns (uint256 /*outputAmount*/) { uint256 outputAmount = previewExactInputSingle(input, output, inputAmount); require(outputAmount >= minOutputAmount, OutputAmountTooLow(outputAmount, minOutputAmount)); _swapExact(input, output, inputAmount, outputAmount, _msgSender(), recipient); return outputAmount; } - function swapExactOutput( - IERC20[] memory path, - uint256 outputAmount, - address recipient, - uint256 maxInputAmount - ) public virtual restricted returns (uint256 /*inputAmount*/) { + function swapExactOutput(IERC20[] memory path, uint256 outputAmount, address recipient, uint256 maxInputAmount) public virtual restricted() returns (uint256 /*inputAmount*/) { uint256 inputAmount = previewExactOutput(path, outputAmount); require(inputAmount <= maxInputAmount, InputAmountTooHigh(inputAmount, maxInputAmount)); _swapExact(path[0], path[path.length - 1], inputAmount, outputAmount, _msgSender(), recipient); return inputAmount; } - function swapExactOutputSingle( - IERC20 input, - IERC20 output, - uint256 outputAmount, - address recipient, - uint256 maxInputAmount - ) public virtual restricted returns (uint256 /*inputAmount*/) { + function swapExactOutputSingle(IERC20 input, IERC20 output, uint256 outputAmount, address recipient, uint256 maxInputAmount) public virtual restricted() returns (uint256 /*inputAmount*/) { uint256 inputAmount = previewExactOutputSingle(input, output, outputAmount); require(inputAmount <= maxInputAmount, InputAmountTooHigh(inputAmount, maxInputAmount)); _swapExact(input, output, inputAmount, outputAmount, _msgSender(), recipient); return inputAmount; } - function _swapExact( - IERC20 input, - IERC20 output, - uint256 inputAmount, - uint256 outputAmount, - address from, - address to - ) private { + function _swapExact(IERC20 input, IERC20 output, uint256 inputAmount, uint256 outputAmount, address from, address to) private { SafeERC20.safeTransferFrom(input, from, address(this), inputAmount); SafeERC20.safeTransfer(output, to, outputAmount); emit SwapExact(input, output, inputAmount, outputAmount, from, to); } function _getPrices(Oracle oracle, uint256 oracleTTL) internal view virtual returns (int256 min, int256 max) { - (uint80 roundId, int256 latest, , , ) = oracle.latestRoundData(); - (, int256 previous, , uint256 updatedAt, ) = oracle.getRoundData(roundId - 1); + (uint80 roundId, int256 latest,,,) = oracle.latestRoundData(); + (, int256 previous,,uint256 updatedAt,) = oracle.getRoundData(roundId - 1); require(block.timestamp < updatedAt + oracleTTL, OracleValueTooOld(oracle)); min = SignedMath.min(latest, previous); max = SignedMath.max(latest, previous); @@ -286,12 +220,7 @@ contract MultiATM is ERC2771Context, PermissionManaged, Multicall { /**************************************************************************************************************** * Admin actions * ****************************************************************************************************************/ - function setPair( - IERC20Metadata token1, - IERC20Metadata token2, - Oracle oracle, - uint256 oracleTTL - ) public virtual restricted { + function setPair(IERC20Metadata token1, IERC20Metadata token2, Oracle oracle, uint256 oracleTTL) public virtual restricted() { bytes32 id = hashPair(token1, token2); _pairs[id] = Pair({ token1: token1, @@ -305,21 +234,27 @@ contract MultiATM is ERC2771Context, PermissionManaged, Multicall { emit PairUpdated(id, token1, token2, oracle, oracleTTL); } - function removePair(IERC20 token1, IERC20 token2) public virtual restricted { + function removePair(IERC20 token1, IERC20 token2) public virtual restricted() { bytes32 id = hashPair(token1, token2); delete _pairs[id]; emit PairRemoved(id); } - function setFee(uint256 newFeeBasisPoints) public virtual restricted { + function setFee(uint256 newFeeBasisPoints) public virtual restricted() { require(newFeeBasisPoints <= 50, InvalidFee(newFeeBasisPoints)); // Max 0.5% feeBasisPoints = newFeeBasisPoints; emit FeeUpdated(newFeeBasisPoints); } - function withdraw(IERC20 _token, address _to, uint256 _amount) public virtual restricted { - SafeERC20.safeTransfer(_token, _to, _amount == type(uint256).max ? _token.balanceOf(address(this)) : _amount); + function withdraw(IERC20 _token, address _to, uint256 _amount) public virtual restricted() { + SafeERC20.safeTransfer( + _token, + _to, + _amount == type(uint256).max + ? _token.balanceOf(address(this)) + : _amount + ); } /**************************************************************************************************************** @@ -336,4 +271,4 @@ contract MultiATM is ERC2771Context, PermissionManaged, Multicall { function _contextSuffixLength() internal view override(Context, ERC2771Context) returns (uint256) { return super._contextSuffixLength(); } -} +} \ No newline at end of file diff --git a/contracts/token/MultiATMLinear.sol b/contracts/token/MultiATMLinear.sol new file mode 100644 index 0000000..61c7d07 --- /dev/null +++ b/contracts/token/MultiATMLinear.sol @@ -0,0 +1,417 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import { IAuthority } from "@openzeppelin/contracts/access/manager/IAuthority.sol"; +import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; +import { ERC2771Context } from "@openzeppelin/contracts/metatx/ERC2771Context.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { Hashes } from "@openzeppelin/contracts/utils/cryptography/Hashes.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import { SignedMath } from "@openzeppelin/contracts/utils/math/SignedMath.sol"; +import { Context } from "@openzeppelin/contracts/utils/Context.sol"; +import { Multicall } from "@openzeppelin/contracts/utils/Multicall.sol"; +import { Oracle } from "../oracle/Oracle.sol"; +import { PermissionManaged } from "../permissions/PermissionManaged.sol"; + +contract MultiATMLinear is ERC2771Context, PermissionManaged, Multicall { + using Math for *; + using SafeCast for *; + + uint256 private constant _BASIS_POINT_SCALE = 1e4; + + struct Pair { + IERC20 token1; + IERC20 token2; + Oracle oracle; + uint256 oracleTTL; + uint256 numerator; + uint256 denominator; + bool linearYield; + } + // Numerator and denominator account for the difference in decimals between the two tokens AND for the decimals + // of the oracle. They are used to scale the conversion rate between the two tokens. + // + // For example, if token A has 18 decimals and token B has 6 decimals, and the oracle has 8 decimals, then + // - 1 token A correspond to 10**18 units (wei), + // - 1 token B correspond to 10**6 units (wei), + // - the rate provided by the oracle must be divided by 10**8. + // + // Therefore: + // ( / 10**18) * (rate / 10**8) = ( / 10**6) + // i.e. * rate * 10**6 = * 10**(18 + 8) + // + // Which gives us the following conversion rate: + // * * rate * / = + // * / rate / * = + // + // with: + // * numerator = 10** + // * denominator = 10**( + ). + + mapping(bytes32 id => Pair) private _pairs; + uint256 public feeBasisPoints; + + event SwapExact( + IERC20 indexed input, + IERC20 indexed output, + uint256 inputAmount, + uint256 outputAmount, + address from, + address to + ); + event PairUpdated( + bytes32 indexed id, + IERC20 indexed token1, + IERC20 indexed token2, + Oracle oracle, + uint256 oracleTTL, + bool linearYield + ); + event PairRemoved(bytes32 indexed id); + event FeeUpdated(uint256 newFeeBasisPoints); + error OutputAmountTooLow(uint256 outputAmount, uint256 minOutputAmount); + error InputAmountTooHigh(uint256 inputAmount, uint256 maxInputAmount); + error OracleValueTooOld(Oracle oracle); + error UnknownPair(IERC20 input, IERC20 output); + error InvalidFee(uint256 feeBasisPoints); + error InvalidOracleData(); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor( + IAuthority _authority, + address _trustedForwarder + ) PermissionManaged(_authority) ERC2771Context(_trustedForwarder) {} + + /**************************************************************************************************************** + * Getters * + ****************************************************************************************************************/ + function viewPairDetails( + IERC20 input, + IERC20 output + ) + public + view + virtual + returns ( + bytes32 id, + IERC20 token1, + IERC20 token2, + Oracle oracle, + uint256 oracleTTL, + uint256 numerator, + uint256 denominator, + bool linearYield + ) + { + id = hashPair(input, output); + Pair storage pair = _pairs[id]; + + return ( + id, + pair.token1, + pair.token2, + pair.oracle, + pair.oracleTTL, + pair.numerator, + pair.denominator, + pair.linearYield + ); + } + + function hashPair(IERC20 input, IERC20 output) public view virtual returns (bytes32) { + return + Hashes.commutativeKeccak256( + bytes32(uint256(uint160(address(input)))), + bytes32(uint256(uint160(address(output)))) + ); + } + + /**************************************************************************************************************** + * Core - preview swaps * + ****************************************************************************************************************/ + function previewExactInput( + IERC20[] memory path, + uint256 inputAmount + ) public view virtual returns (uint256 /*outputAmount*/) { + uint256 outputAmount = inputAmount; + for (uint256 i = 0; i < path.length - 1; ++i) { + outputAmount = _exactInput(path[i], path[i + 1], outputAmount); + } + return outputAmount.mulDiv(_BASIS_POINT_SCALE - feeBasisPoints, _BASIS_POINT_SCALE, Math.Rounding.Floor); + } + + function previewExactOutput( + IERC20[] memory path, + uint256 outputAmount + ) public view virtual returns (uint256 /*inputAmount*/) { + uint256 inputAmount = outputAmount; + for (uint256 i = path.length - 1; i > 0; --i) { + inputAmount = _exactOutput(path[i - 1], path[i], inputAmount); + } + return inputAmount.mulDiv(_BASIS_POINT_SCALE, _BASIS_POINT_SCALE - feeBasisPoints, Math.Rounding.Ceil); + } + + function previewExactInputSingle( + IERC20 input, + IERC20 output, + uint256 inputAmount + ) public view virtual returns (uint256 /*outputAmount*/) { + return + _exactInput(input, output, inputAmount).mulDiv( + _BASIS_POINT_SCALE - feeBasisPoints, + _BASIS_POINT_SCALE, + Math.Rounding.Floor + ); + } + + function previewExactOutputSingle( + IERC20 input, + IERC20 output, + uint256 outputAmount + ) public view virtual returns (uint256 /*inputAmount*/) { + return + _exactOutput(input, output, outputAmount).mulDiv( + _BASIS_POINT_SCALE, + _BASIS_POINT_SCALE - feeBasisPoints, + Math.Rounding.Ceil + ); + } + + function _exactInput( + IERC20 input, + IERC20 output, + uint256 inputAmount + ) internal view virtual returns (uint256 /*outputAmount*/) { + ( + , + IERC20 token1, + , + Oracle oracle, + uint256 oracleTTL, + uint256 numerator, + uint256 denominator, + bool linearYield + ) = viewPairDetails(input, output); + + require(address(oracle) != address(0), UnknownPair(input, output)); + + (int256 minPrice, int256 maxPrice) = _getPrices(oracle, oracleTTL, linearYield); + return + inputAmount.mulDiv( + Math.ternary(input == token1, numerator * minPrice.toUint256(), denominator), + Math.ternary(input == token1, denominator, numerator * maxPrice.toUint256()), + Math.Rounding.Floor + ); + } + + function _exactOutput( + IERC20 input, + IERC20 output, + uint256 outputAmount + ) internal view virtual returns (uint256 /*inputAmount*/) { + ( + , + IERC20 token1, + , + Oracle oracle, + uint256 oracleTTL, + uint256 numerator, + uint256 denominator, + bool linearYield + ) = viewPairDetails(input, output); + + require(address(oracle) != address(0), UnknownPair(input, output)); + + (int256 minPrice, int256 maxPrice) = _getPrices(oracle, oracleTTL, linearYield); + return + outputAmount.mulDiv( + Math.ternary(input == token1, denominator, numerator * maxPrice.toUint256()), + Math.ternary(input == token1, numerator * minPrice.toUint256(), denominator), + Math.Rounding.Ceil + ); + } + + /**************************************************************************************************************** + * Core - execute swaps * + ****************************************************************************************************************/ + function swapExactInput( + IERC20[] memory path, + uint256 inputAmount, + address recipient, + uint256 minOutputAmount + ) public virtual restricted returns (uint256 /*outputAmount*/) { + uint256 outputAmount = previewExactInput(path, inputAmount); + require(outputAmount >= minOutputAmount, OutputAmountTooLow(outputAmount, minOutputAmount)); + _swapExact(path[0], path[path.length - 1], inputAmount, outputAmount, _msgSender(), recipient); + return outputAmount; + } + + function swapExactInputSingle( + IERC20 input, + IERC20 output, + uint256 inputAmount, + address recipient, + uint256 minOutputAmount + ) public virtual restricted returns (uint256 /*outputAmount*/) { + uint256 outputAmount = previewExactInputSingle(input, output, inputAmount); + require(outputAmount >= minOutputAmount, OutputAmountTooLow(outputAmount, minOutputAmount)); + _swapExact(input, output, inputAmount, outputAmount, _msgSender(), recipient); + return outputAmount; + } + + function swapExactOutput( + IERC20[] memory path, + uint256 outputAmount, + address recipient, + uint256 maxInputAmount + ) public virtual restricted returns (uint256 /*inputAmount*/) { + uint256 inputAmount = previewExactOutput(path, outputAmount); + require(inputAmount <= maxInputAmount, InputAmountTooHigh(inputAmount, maxInputAmount)); + _swapExact(path[0], path[path.length - 1], inputAmount, outputAmount, _msgSender(), recipient); + return inputAmount; + } + + function swapExactOutputSingle( + IERC20 input, + IERC20 output, + uint256 outputAmount, + address recipient, + uint256 maxInputAmount + ) public virtual restricted returns (uint256 /*inputAmount*/) { + uint256 inputAmount = previewExactOutputSingle(input, output, outputAmount); + require(inputAmount <= maxInputAmount, InputAmountTooHigh(inputAmount, maxInputAmount)); + _swapExact(input, output, inputAmount, outputAmount, _msgSender(), recipient); + return inputAmount; + } + + function _swapExact( + IERC20 input, + IERC20 output, + uint256 inputAmount, + uint256 outputAmount, + address from, + address to + ) private { + SafeERC20.safeTransferFrom(input, from, address(this), inputAmount); + SafeERC20.safeTransfer(output, to, outputAmount); + emit SwapExact(input, output, inputAmount, outputAmount, from, to); + } + + // function _computeSlope(int256 numerator, int256 denominator) private pure returns (int256) { + // bool negative = (numerator < 0) != (denominator < 0); + // uint256 absSlope = Math.mulDiv(SignedMath.abs(numerator), _PRECISION, SignedMath.abs(denominator)); + // return negative ? -absSlope.toInt256() : absSlope.toInt256(); + // } + + // function _computeLinearRegression( + // uint256 latestTimestamp, + // int256 latestPrice, + // uint256 previousTimestamp, + // int256 previousPrice + // ) internal pure returns (int256 slope, int256 intercept) { + // slope = _computeSlope(latestPrice - previousPrice, (latestTimestamp - previousTimestamp).toInt256()); + // intercept = previousPrice; + // } + + // function _getPrices( + // Oracle oracle, + // uint256 oracleTTL, + // bool linearYield + // ) internal view virtual returns (int256 min, int256 max) { + // (uint80 roundId, int256 latest, , uint256 updatedAt, ) = oracle.latestRoundData(); + // require(block.timestamp < updatedAt + oracleTTL, OracleValueTooOld(oracle)); + // (, int256 previous, , uint256 previousUpdatedAt, ) = oracle.getRoundData(roundId - 1); + + // if (linearYield) { + // (int256 slope, int256 intercept) = _computeLinearRegression(updatedAt, latest, previousUpdatedAt, previous); + + // // price = slope * (currentTime - previousUpdatedAt) / _PRECISION + intercept + // uint256 absDeltaPrice = Math.mulDiv(SignedMath.abs(slope), block.timestamp - previousUpdatedAt, _PRECISION); + // min = intercept + (slope < 0 ? -absDeltaPrice.toInt256() : absDeltaPrice.toInt256()); + // require(min > 0, InvalidOracleData()); + // max = min; + // } else { + // min = SignedMath.min(latest, previous); + // max = SignedMath.max(latest, previous); + // } + // } + + function _getPrices( + Oracle oracle, + uint256 oracleTTL, + bool linearYield + ) internal view virtual returns (int256 min, int256 max) { + (uint80 roundId, int256 latest, , uint256 updatedAt, ) = oracle.latestRoundData(); + require(block.timestamp < updatedAt + oracleTTL, OracleValueTooOld(oracle)); + (, int256 previous, , uint256 previousUpdatedAt, ) = oracle.getRoundData(roundId - 1); + + if (linearYield) { + max = min = + previous + + ((latest - previous) * (block.timestamp - previousUpdatedAt).toInt256()) / + (updatedAt - previousUpdatedAt).toInt256(); + } else { + min = SignedMath.min(latest, previous); + max = SignedMath.max(latest, previous); + } + } + + /**************************************************************************************************************** + * Admin actions * + ****************************************************************************************************************/ + function setPair( + IERC20Metadata token1, + IERC20Metadata token2, + Oracle oracle, + uint256 oracleTTL, + bool linearYield + ) public virtual restricted { + bytes32 id = hashPair(token1, token2); + _pairs[id] = Pair({ + token1: token1, + token2: token2, + oracle: oracle, + oracleTTL: oracleTTL, + numerator: 10 ** token2.decimals(), + denominator: 10 ** (token1.decimals() + oracle.decimals()), + linearYield: linearYield + }); + + emit PairUpdated(id, token1, token2, oracle, oracleTTL, linearYield); + } + + function removePair(IERC20 token1, IERC20 token2) public virtual restricted { + bytes32 id = hashPair(token1, token2); + delete _pairs[id]; + + emit PairRemoved(id); + } + + function setFee(uint256 newFeeBasisPoints) public virtual restricted { + require(newFeeBasisPoints <= 50, InvalidFee(newFeeBasisPoints)); // Max 0.5% + feeBasisPoints = newFeeBasisPoints; + emit FeeUpdated(newFeeBasisPoints); + } + + function withdraw(IERC20 _token, address _to, uint256 _amount) public virtual restricted { + SafeERC20.safeTransfer(_token, _to, _amount == type(uint256).max ? _token.balanceOf(address(this)) : _amount); + } + + /**************************************************************************************************************** + * Context overrides * + ****************************************************************************************************************/ + function _msgSender() internal view override(Context, ERC2771Context) returns (address) { + return super._msgSender(); + } + + function _msgData() internal view override(Context, ERC2771Context) returns (bytes calldata) { + return super._msgData(); + } + + function _contextSuffixLength() internal view override(Context, ERC2771Context) returns (uint256) { + return super._contextSuffixLength(); + } +} diff --git a/test/main.test.js b/test/main.test.js index 858b31c..82a87b9 100644 --- a/test/main.test.js +++ b/test/main.test.js @@ -3153,6 +3153,2288 @@ describe('Main', function () { } }); + describe('MultiATMLinear', function () { + const oraclettl = time.duration.days(7); + + for (const stableDecimal of [6n, 18n, 36n]) { + const numFactor = 10n ** stableDecimal; + const denFactor = 10n ** 11n; // 11 = 5 (decimal of the token) + 6 (price scale) + + describe(`stable coin with ${stableDecimal} decimals`, function () { + const formatToken = (value) => ethers.parseUnits(value, 5); + const formatStable = (value) => ethers.parseUnits(value, stableDecimal); + + beforeEach(async function () { + /// deployment + this.contracts.stable = await deploy('ERC20DecimalsMock', [stableDecimal]); + this.contracts.atm = await deploy('MultiATMLinear', [ + this.contracts.manager.target, + this.contracts.forwarder.target, + ]); + + await this.contracts.manager.setRequirements( + this.contracts.atm, + [ + this.contracts.atm.interface.getFunction('swapExactInput').selector, + this.contracts.atm.interface.getFunction('swapExactInputSingle').selector, + this.contracts.atm.interface.getFunction('swapExactOutput').selector, + this.contracts.atm.interface.getFunction('swapExactOutputSingle').selector, + ], + [this.IDS['whitelisted']] + ); + + await this.contracts.manager.setRequirements( + this.contracts.atm, + [ + this.contracts.atm.interface.getFunction('setPair').selector, + this.contracts.atm.interface.getFunction('removePair').selector, + this.contracts.atm.interface.getFunction('withdraw').selector, + ], + [this.IDS['operator-exceptional']] + ); + await this.contracts.manager.addGroup(this.contracts.atm, this.IDS['whitelisted']); + + this.id = ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'address'], + [this.contracts.token, this.contracts.stable] + .map(getAddress) + .sort((a, b) => (ethers.toBigInt(a) > ethers.toBigInt(b) ? 1 : -1)) + ) + ); + + await expect(this.contracts.atm.hashPair(this.contracts.token, this.contracts.stable)).to.eventually.equal( + this.id + ); + await expect( + this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + false + ) + ) + .to.emit(this.contracts.atm, 'PairUpdated') + .withArgs(this.id, this.contracts.token, this.contracts.stable, this.contracts.oracle, oraclettl, false); + + /// mint and approve + await this.contracts.token.mint(this.contracts.atm, formatToken('100')); + await this.contracts.stable.mint(this.contracts.atm, formatStable('100')); + await this.contracts.token.mint(this.accounts.alice, formatToken('100')); + await this.contracts.stable.mint(this.accounts.bruce, formatStable('100')); + await this.contracts.token.connect(this.accounts.alice).approve(this.contracts.atm, ethers.MaxUint256); + await this.contracts.stable.connect(this.accounts.bruce).approve(this.contracts.atm, ethers.MaxUint256); + }); + + it('post deployment state', async function () { + expect(await this.contracts.atm.viewPairDetails(this.contracts.token, this.contracts.stable)).to.deep.equal([ + this.id, + this.contracts.token.target, + this.contracts.stable.target, + this.contracts.oracle.target, + oraclettl, + numFactor, + denFactor, + false, + ]); + expect(await this.contracts.atm.viewPairDetails(this.contracts.stable, this.contracts.token)).to.deep.equal([ + this.id, + this.contracts.token.target, + this.contracts.stable.target, + this.contracts.oracle.target, + oraclettl, + numFactor, + denFactor, + false, + ]); + }); + + for (const { description, oldPrice, newPrice } of [ + { + description: 'with constant price', + oldPrice: ethers.parseUnits('2.15467', 6), + newPrice: ethers.parseUnits('2.15467', 6), + }, + { + description: 'with price increase', + oldPrice: ethers.parseUnits('2.15467', 6), + newPrice: ethers.parseUnits('2.17832', 6), + }, + { + description: 'with price decrease', + oldPrice: ethers.parseUnits('2.17832', 6), + newPrice: ethers.parseUnits('2.15467', 6), + }, + ]) + describe(description, function () { + const buyPrice = oldPrice > newPrice ? oldPrice : newPrice; + const sellPrice = oldPrice < newPrice ? oldPrice : newPrice; + + const stableToToken = (amount, price, up = false) => + up ? divUp(amount * denFactor, price * numFactor) : (amount * denFactor) / price / numFactor; + + const tokenToStable = (amount, price, up = false) => + up ? divUp(amount * numFactor * price, denFactor) : (amount * numFactor * price) / denFactor; + + beforeEach(async function () { + const timestamp = await time.latest(); + await this.contracts.oracle.publishPrice(timestamp - 3600, oldPrice); + await this.contracts.oracle.publishPrice(timestamp + 3600, newPrice); + }); + + describe('exact input', function () { + describe('without fees', function () { + it('preview single', async function () { + await expect( + this.contracts.atm.previewExactInputSingle( + this.contracts.stable, + this.contracts.token, + formatStable('1.0') + ) + ).to.eventually.equal(stableToToken(formatStable('1.0'), buyPrice)); + await expect( + this.contracts.atm.previewExactInputSingle( + this.contracts.stable, + this.contracts.token, + formatStable('1.000001') + ) + ).to.eventually.equal(stableToToken(formatStable('1.000001'), buyPrice)); + await expect( + this.contracts.atm.previewExactInputSingle( + this.contracts.token, + this.contracts.stable, + formatToken('1.0') + ) + ).to.eventually.equal(tokenToStable(formatToken('1.0'), sellPrice)); + await expect( + this.contracts.atm.previewExactInputSingle( + this.contracts.token, + this.contracts.stable, + formatToken('1.00001') + ) + ).to.eventually.equal(tokenToStable(formatToken('1.00001'), sellPrice)); + await expect( + this.contracts.atm.previewExactInputSingle(this.contracts.manager, this.contracts.stable, 0) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'UnknownPair') + .withArgs(this.contracts.manager, this.contracts.stable); + }); + + it('preview path', async function () { + await expect( + this.contracts.atm.previewExactInput( + [this.contracts.stable, this.contracts.token], + formatStable('1.0') + ) + ).to.eventually.equal(stableToToken(formatStable('1.0'), buyPrice)); + await expect( + this.contracts.atm.previewExactInput( + [this.contracts.stable, this.contracts.token], + formatStable('1.000001') + ) + ).to.eventually.equal(stableToToken(formatStable('1.000001'), buyPrice)); + await expect( + this.contracts.atm.previewExactInput( + [this.contracts.token, this.contracts.stable], + formatToken('1.0') + ) + ).to.eventually.equal(tokenToStable(formatToken('1.0'), sellPrice)); + await expect( + this.contracts.atm.previewExactInput( + [this.contracts.token, this.contracts.stable], + formatToken('1.00001') + ) + ).to.eventually.equal(tokenToStable(formatToken('1.00001'), sellPrice)); + await expect(this.contracts.atm.previewExactInput([this.contracts.manager, this.contracts.stable], 0)) + .to.be.revertedWithCustomError(this.contracts.atm, 'UnknownPair') + .withArgs(this.contracts.manager, this.contracts.stable); + }); + + it('buy token given exact amount of stable - single', async function () { + const amountStable = formatStable('1.000001'); + const amountToken = stableToToken(amountStable, buyPrice); + + const tx = this.contracts.atm.connect(this.accounts.bruce).swapExactInputSingle( + this.contracts.stable, + this.contracts.token, + amountStable, + this.accounts.alice, + 0 // no minimum output amount + ); + await expect(tx).to.changeTokenBalances( + this.contracts.stable, + [this.accounts.bruce, this.contracts.atm], + [-amountStable, amountStable] + ); + await expect(tx).to.changeTokenBalances( + this.contracts.token, + [this.accounts.alice, this.contracts.atm], + [amountToken, -amountToken] + ); + }); + + it('buy token given exact amount of stable - path', async function () { + const amountStable = formatStable('1.000001'); + const amountToken = stableToToken(amountStable, buyPrice); + + const tx = this.contracts.atm.connect(this.accounts.bruce).swapExactInput( + [this.contracts.stable, this.contracts.token], + amountStable, + this.accounts.alice, + 0 // no minimum output amount + ); + await expect(tx).to.changeTokenBalances( + this.contracts.stable, + [this.accounts.bruce, this.contracts.atm], + [-amountStable, amountStable] + ); + await expect(tx).to.changeTokenBalances( + this.contracts.token, + [this.accounts.alice, this.contracts.atm], + [amountToken, -amountToken] + ); + }); + + it('buy stable given exact amount of token - single', async function () { + const amountToken = formatToken('1.00001'); + const amountStable = tokenToStable(amountToken, sellPrice); + + const tx = this.contracts.atm.connect(this.accounts.alice).swapExactInputSingle( + this.contracts.token, + this.contracts.stable, + amountToken, + this.accounts.bruce, + 0 // no minimum output amount + ); + await expect(tx).to.changeTokenBalances( + this.contracts.token, + [this.accounts.alice, this.contracts.atm], + [-amountToken, amountToken] + ); + await expect(tx).to.changeTokenBalances( + this.contracts.stable, + [this.accounts.bruce, this.contracts.atm], + [amountStable, -amountStable] + ); + }); + + it('buy stable given exact amount of token - path', async function () { + const amountToken = formatToken('1.00001'); + const amountStable = tokenToStable(amountToken, sellPrice); + + const tx = this.contracts.atm.connect(this.accounts.alice).swapExactInput( + [this.contracts.token, this.contracts.stable], + amountToken, + this.accounts.bruce, + 0 // no minimum output amount + ); + await expect(tx).to.changeTokenBalances( + this.contracts.token, + [this.accounts.alice, this.contracts.atm], + [-amountToken, amountToken] + ); + await expect(tx).to.changeTokenBalances( + this.contracts.stable, + [this.accounts.bruce, this.contracts.atm], + [amountStable, -amountStable] + ); + }); + + it('oracle not updated recently', async function () { + await time.increase(oraclettl + 3601); // oracle publish is block + 3600 + await expect( + this.contracts.atm.previewExactInputSingle(this.contracts.stable, this.contracts.token, 0) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.previewExactInputSingle(this.contracts.token, this.contracts.stable, 0) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.previewExactInput([this.contracts.stable, this.contracts.token], 0) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.previewExactInput([this.contracts.token, this.contracts.stable], 0) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.swapExactInputSingle( + this.contracts.stable, + this.contracts.token, + 0, + ethers.ZeroAddress, + 0 + ) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.swapExactInputSingle( + this.contracts.token, + this.contracts.stable, + 0, + ethers.ZeroAddress, + 0 + ) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.swapExactInput( + [this.contracts.stable, this.contracts.token], + 0, + ethers.ZeroAddress, + 0 + ) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.swapExactInput( + [this.contracts.token, this.contracts.stable], + 0, + ethers.ZeroAddress, + 0 + ) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + }); + + it('slippage protection', async function () { + const inputToken = formatToken('1.00001'); + const inputStable = formatStable('1.00001'); + const outputStable = tokenToStable(inputToken, sellPrice); + const outputToken = stableToToken(inputStable, buyPrice); + + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactInputSingle( + this.contracts.token, + this.contracts.stable, + inputToken, + this.accounts.bruce, + outputStable - 1n + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactInputSingle( + this.contracts.stable, + this.contracts.token, + inputStable, + this.accounts.alice, + outputToken - 1n + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactInput( + [this.contracts.token, this.contracts.stable], + inputToken, + this.accounts.bruce, + outputStable - 1n + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactInput( + [this.contracts.stable, this.contracts.token], + inputStable, + this.accounts.alice, + outputToken - 1n + ) + ).to.not.be.reverted; + + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactInputSingle( + this.contracts.token, + this.contracts.stable, + inputToken, + this.accounts.bruce, + outputStable + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactInputSingle( + this.contracts.stable, + this.contracts.token, + inputStable, + this.accounts.alice, + outputToken + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactInput( + [this.contracts.token, this.contracts.stable], + inputToken, + this.accounts.bruce, + outputStable + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactInput( + [this.contracts.stable, this.contracts.token], + inputStable, + this.accounts.alice, + outputToken + ) + ).to.not.be.reverted; + + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactInputSingle( + this.contracts.token, + this.contracts.stable, + inputToken, + this.accounts.bruce, + outputStable + 1n + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'OutputAmountTooLow') + .withArgs(outputStable, outputStable + 1n); + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactInputSingle( + this.contracts.stable, + this.contracts.token, + inputStable, + this.accounts.alice, + outputToken + 1n + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'OutputAmountTooLow') + .withArgs(outputToken, outputToken + 1n); + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactInput( + [this.contracts.token, this.contracts.stable], + inputToken, + this.accounts.bruce, + outputStable + 1n + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'OutputAmountTooLow') + .withArgs(outputStable, outputStable + 1n); + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactInput( + [this.contracts.stable, this.contracts.token], + inputStable, + this.accounts.alice, + outputToken + 1n + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'OutputAmountTooLow') + .withArgs(outputToken, outputToken + 1n); + }); + }); + + describe('with fees', function () { + beforeEach(async function () { + await expect(this.contracts.atm.connect(this.accounts.admin).setFee(20n)) // 0.2% + .to.emit(this.contracts.atm, 'FeeUpdated') + .withArgs(20n); + }); + + it('preview single', async function () { + await expect( + this.contracts.atm.previewExactInputSingle( + this.contracts.stable, + this.contracts.token, + formatStable('1.0') + ) + ).to.eventually.equal((stableToToken(formatStable('1.0'), buyPrice) * 9980n) / 10000n); + await expect( + this.contracts.atm.previewExactInputSingle( + this.contracts.stable, + this.contracts.token, + formatStable('1.000001') + ) + ).to.eventually.equal((stableToToken(formatStable('1.000001'), buyPrice) * 9980n) / 10000n); + await expect( + this.contracts.atm.previewExactInputSingle( + this.contracts.token, + this.contracts.stable, + formatToken('1.0') + ) + ).to.eventually.equal((tokenToStable(formatToken('1.0'), sellPrice) * 9980n) / 10000n); + await expect( + this.contracts.atm.previewExactInputSingle( + this.contracts.token, + this.contracts.stable, + formatToken('1.00001') + ) + ).to.eventually.equal((tokenToStable(formatToken('1.00001'), sellPrice) * 9980n) / 10000n); + await expect( + this.contracts.atm.previewExactInputSingle(this.contracts.manager, this.contracts.stable, 0) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'UnknownPair') + .withArgs(this.contracts.manager, this.contracts.stable); + }); + + it('preview path', async function () { + await expect( + this.contracts.atm.previewExactInput( + [this.contracts.stable, this.contracts.token], + formatStable('1.0') + ) + ).to.eventually.equal((stableToToken(formatStable('1.0'), buyPrice) * 9980n) / 10000n); + await expect( + this.contracts.atm.previewExactInput( + [this.contracts.stable, this.contracts.token], + formatStable('1.000001') + ) + ).to.eventually.equal((stableToToken(formatStable('1.000001'), buyPrice) * 9980n) / 10000n); + await expect( + this.contracts.atm.previewExactInput( + [this.contracts.token, this.contracts.stable], + formatToken('1.0') + ) + ).to.eventually.equal((tokenToStable(formatToken('1.0'), sellPrice) * 9980n) / 10000n); + await expect( + this.contracts.atm.previewExactInput( + [this.contracts.token, this.contracts.stable], + formatToken('1.00001') + ) + ).to.eventually.equal((tokenToStable(formatToken('1.00001'), sellPrice) * 9980n) / 10000n); + await expect(this.contracts.atm.previewExactInput([this.contracts.manager, this.contracts.stable], 0)) + .to.be.revertedWithCustomError(this.contracts.atm, 'UnknownPair') + .withArgs(this.contracts.manager, this.contracts.stable); + }); + + it('buy token given exact amount of stable - single', async function () { + const amountStable = formatStable('1.000001'); + const amountToken = (stableToToken(amountStable, buyPrice) * 9980n) / 10000n; + + const tx = this.contracts.atm.connect(this.accounts.bruce).swapExactInputSingle( + this.contracts.stable, + this.contracts.token, + amountStable, + this.accounts.alice, + 0 // no minimum output amount + ); + await expect(tx).to.changeTokenBalances( + this.contracts.stable, + [this.accounts.bruce, this.contracts.atm], + [-amountStable, amountStable] + ); + await expect(tx).to.changeTokenBalances( + this.contracts.token, + [this.accounts.alice, this.contracts.atm], + [amountToken, -amountToken] + ); + }); + + it('buy token given exact amount of stable - path', async function () { + const amountStable = formatStable('1.000001'); + const amountToken = (stableToToken(amountStable, buyPrice) * 9980n) / 10000n; + + const tx = this.contracts.atm.connect(this.accounts.bruce).swapExactInput( + [this.contracts.stable, this.contracts.token], + amountStable, + this.accounts.alice, + 0 // no minimum output amount + ); + await expect(tx).to.changeTokenBalances( + this.contracts.stable, + [this.accounts.bruce, this.contracts.atm], + [-amountStable, amountStable] + ); + await expect(tx).to.changeTokenBalances( + this.contracts.token, + [this.accounts.alice, this.contracts.atm], + [amountToken, -amountToken] + ); + }); + + it('buy stable given exact amount of token - single', async function () { + const amountToken = formatToken('1.00001'); + const amountStable = (tokenToStable(amountToken, sellPrice) * 9980n) / 10000n; + + const tx = this.contracts.atm.connect(this.accounts.alice).swapExactInputSingle( + this.contracts.token, + this.contracts.stable, + amountToken, + this.accounts.bruce, + 0 // no minimum output amount + ); + await expect(tx).to.changeTokenBalances( + this.contracts.token, + [this.accounts.alice, this.contracts.atm], + [-amountToken, amountToken] + ); + await expect(tx).to.changeTokenBalances( + this.contracts.stable, + [this.accounts.bruce, this.contracts.atm], + [amountStable, -amountStable] + ); + }); + + it('buy stable given exact amount of token - path', async function () { + const amountToken = formatToken('1.00001'); + const amountStable = (tokenToStable(amountToken, sellPrice) * 9980n) / 10000n; + + const tx = this.contracts.atm.connect(this.accounts.alice).swapExactInput( + [this.contracts.token, this.contracts.stable], + amountToken, + this.accounts.bruce, + 0 // no minimum output amount + ); + await expect(tx).to.changeTokenBalances( + this.contracts.token, + [this.accounts.alice, this.contracts.atm], + [-amountToken, amountToken] + ); + await expect(tx).to.changeTokenBalances( + this.contracts.stable, + [this.accounts.bruce, this.contracts.atm], + [amountStable, -amountStable] + ); + }); + + it('oracle not updated recently', async function () { + await time.increase(oraclettl + 3601); // oracle publish is block + 3600 + await expect( + this.contracts.atm.previewExactInputSingle(this.contracts.stable, this.contracts.token, 0) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.previewExactInputSingle(this.contracts.token, this.contracts.stable, 0) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.previewExactInput([this.contracts.stable, this.contracts.token], 0) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.previewExactInput([this.contracts.token, this.contracts.stable], 0) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.swapExactInputSingle( + this.contracts.stable, + this.contracts.token, + 0, + ethers.ZeroAddress, + 0 + ) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.swapExactInputSingle( + this.contracts.token, + this.contracts.stable, + 0, + ethers.ZeroAddress, + 0 + ) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.swapExactInput( + [this.contracts.stable, this.contracts.token], + 0, + ethers.ZeroAddress, + 0 + ) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.swapExactInput( + [this.contracts.token, this.contracts.stable], + 0, + ethers.ZeroAddress, + 0 + ) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + }); + + it('slippage protection', async function () { + const inputToken = formatToken('1.00001'); + const inputStable = formatStable('1.00001'); + const outputStable = (tokenToStable(inputToken, sellPrice) * 9980n) / 10000n; + const outputToken = (stableToToken(inputStable, buyPrice) * 9980n) / 10000n; + + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactInputSingle( + this.contracts.token, + this.contracts.stable, + inputToken, + this.accounts.bruce, + outputStable - 1n + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactInputSingle( + this.contracts.stable, + this.contracts.token, + inputStable, + this.accounts.alice, + outputToken - 1n + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactInput( + [this.contracts.token, this.contracts.stable], + inputToken, + this.accounts.bruce, + outputStable - 1n + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactInput( + [this.contracts.stable, this.contracts.token], + inputStable, + this.accounts.alice, + outputToken - 1n + ) + ).to.not.be.reverted; + + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactInputSingle( + this.contracts.token, + this.contracts.stable, + inputToken, + this.accounts.bruce, + outputStable + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactInputSingle( + this.contracts.stable, + this.contracts.token, + inputStable, + this.accounts.alice, + outputToken + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactInput( + [this.contracts.token, this.contracts.stable], + inputToken, + this.accounts.bruce, + outputStable + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactInput( + [this.contracts.stable, this.contracts.token], + inputStable, + this.accounts.alice, + outputToken + ) + ).to.not.be.reverted; + + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactInputSingle( + this.contracts.token, + this.contracts.stable, + inputToken, + this.accounts.bruce, + outputStable + 1n + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'OutputAmountTooLow') + .withArgs(outputStable, outputStable + 1n); + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactInputSingle( + this.contracts.stable, + this.contracts.token, + inputStable, + this.accounts.alice, + outputToken + 1n + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'OutputAmountTooLow') + .withArgs(outputToken, outputToken + 1n); + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactInput( + [this.contracts.token, this.contracts.stable], + inputToken, + this.accounts.bruce, + outputStable + 1n + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'OutputAmountTooLow') + .withArgs(outputStable, outputStable + 1n); + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactInput( + [this.contracts.stable, this.contracts.token], + inputStable, + this.accounts.alice, + outputToken + 1n + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'OutputAmountTooLow') + .withArgs(outputToken, outputToken + 1n); + }); + }); + }); + + describe('exact output', function () { + describe('without fees', function () { + it('preview single', async function () { + await expect( + this.contracts.atm.previewExactOutputSingle( + this.contracts.stable, + this.contracts.token, + formatToken('1.0') + ) + ).to.eventually.equal(tokenToStable(formatToken('1.0'), buyPrice, true)); + await expect( + this.contracts.atm.previewExactOutputSingle( + this.contracts.stable, + this.contracts.token, + formatToken('1.00001') + ) + ).to.eventually.equal(tokenToStable(formatToken('1.00001'), buyPrice, true)); + await expect( + this.contracts.atm.previewExactOutputSingle( + this.contracts.token, + this.contracts.stable, + formatStable('1.0') + ) + ).to.eventually.equal(stableToToken(formatStable('1.0'), sellPrice, true)); + await expect( + this.contracts.atm.previewExactOutputSingle( + this.contracts.token, + this.contracts.stable, + formatStable('1.000001') + ) + ).to.eventually.equal(stableToToken(formatStable('1.000001'), sellPrice, true)); + await expect( + this.contracts.atm.previewExactOutputSingle(this.contracts.manager, this.contracts.stable, 0) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'UnknownPair') + .withArgs(this.contracts.manager, this.contracts.stable); + }); + + it('preview path', async function () { + await expect( + this.contracts.atm.previewExactOutput( + [this.contracts.stable, this.contracts.token], + formatToken('1.0') + ) + ).to.eventually.equal(tokenToStable(formatToken('1.0'), buyPrice, true)); + await expect( + this.contracts.atm.previewExactOutput( + [this.contracts.stable, this.contracts.token], + formatToken('1.00001') + ) + ).to.eventually.equal(tokenToStable(formatToken('1.00001'), buyPrice, true)); + await expect( + this.contracts.atm.previewExactOutput( + [this.contracts.token, this.contracts.stable], + formatStable('1.0') + ) + ).to.eventually.equal(stableToToken(formatStable('1.0'), sellPrice, true)); + await expect( + this.contracts.atm.previewExactOutput( + [this.contracts.token, this.contracts.stable], + formatStable('1.000001') + ) + ).to.eventually.equal(stableToToken(formatStable('1.000001'), sellPrice, true)); + await expect( + this.contracts.atm.previewExactOutput([this.contracts.manager, this.contracts.stable], 0) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'UnknownPair') + .withArgs(this.contracts.manager, this.contracts.stable); + }); + + it('buy exact amount of token - single', async function () { + const amountToken = formatToken('1.00001'); + const amountStable = tokenToStable(amountToken, buyPrice, true); + + const tx = this.contracts.atm.connect(this.accounts.bruce).swapExactOutputSingle( + this.contracts.stable, + this.contracts.token, + amountToken, + this.accounts.alice, + ethers.MaxUint256 // no maximum input amount + ); + await expect(tx).to.changeTokenBalances( + this.contracts.stable, + [this.accounts.bruce, this.contracts.atm], + [-amountStable, amountStable] + ); + await expect(tx).to.changeTokenBalances( + this.contracts.token, + [this.accounts.alice, this.contracts.atm], + [amountToken, -amountToken] + ); + }); + + it('buy exact amount of token - path', async function () { + const amountToken = formatToken('1.00001'); + const amountStable = tokenToStable(amountToken, buyPrice, true); + + const tx = this.contracts.atm.connect(this.accounts.bruce).swapExactOutput( + [this.contracts.stable, this.contracts.token], + amountToken, + this.accounts.alice, + ethers.MaxUint256 // no maximum input amount + ); + await expect(tx).to.changeTokenBalances( + this.contracts.stable, + [this.accounts.bruce, this.contracts.atm], + [-amountStable, amountStable] + ); + await expect(tx).to.changeTokenBalances( + this.contracts.token, + [this.accounts.alice, this.contracts.atm], + [amountToken, -amountToken] + ); + }); + + it('buy exact amount of stable - single', async function () { + const amountStable = formatStable('1.000001'); + const amountToken = stableToToken(amountStable, sellPrice, true); + + const tx = this.contracts.atm.connect(this.accounts.alice).swapExactOutputSingle( + this.contracts.token, + this.contracts.stable, + amountStable, + this.accounts.bruce, + ethers.MaxUint256 // no maximum input amount + ); + await expect(tx).to.changeTokenBalances( + this.contracts.token, + [this.accounts.alice, this.contracts.atm], + [-amountToken, amountToken] + ); + await expect(tx).to.changeTokenBalances( + this.contracts.stable, + [this.accounts.bruce, this.contracts.atm], + [amountStable, -amountStable] + ); + }); + + it('buy exact amount of stable - path', async function () { + const amountStable = formatStable('1.000001'); + const amountToken = stableToToken(amountStable, sellPrice, true); + + const tx = this.contracts.atm.connect(this.accounts.alice).swapExactOutput( + [this.contracts.token, this.contracts.stable], + amountStable, + this.accounts.bruce, + ethers.MaxUint256 // no maximum input amount + ); + await expect(tx).to.changeTokenBalances( + this.contracts.token, + [this.accounts.alice, this.contracts.atm], + [-amountToken, amountToken] + ); + await expect(tx).to.changeTokenBalances( + this.contracts.stable, + [this.accounts.bruce, this.contracts.atm], + [amountStable, -amountStable] + ); + }); + + it('oracle not updated recently', async function () { + await time.increase(oraclettl + 3601); // oracle publish is block + 3600 + await expect( + this.contracts.atm.previewExactOutputSingle(this.contracts.stable, this.contracts.token, 0) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.previewExactOutputSingle(this.contracts.token, this.contracts.stable, 0) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.previewExactOutput([this.contracts.stable, this.contracts.token], 0) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.previewExactOutput([this.contracts.token, this.contracts.stable], 0) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.swapExactOutputSingle( + this.contracts.stable, + this.contracts.token, + 0, + ethers.ZeroAddress, + ethers.MaxUint256 + ) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.swapExactOutputSingle( + this.contracts.token, + this.contracts.stable, + 0, + ethers.ZeroAddress, + ethers.MaxUint256 + ) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.swapExactOutput( + [this.contracts.stable, this.contracts.token], + 0, + ethers.ZeroAddress, + ethers.MaxUint256 + ) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.swapExactOutput( + [this.contracts.token, this.contracts.stable], + 0, + ethers.ZeroAddress, + ethers.MaxUint256 + ) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + }); + + it('slippage protection', async function () { + const outputToken = formatToken('1.00001'); + const outputStable = formatStable('1.000001'); + const inputStable = tokenToStable(outputToken, buyPrice, true); + const inputToken = stableToToken(outputStable, sellPrice, true); + + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactOutputSingle( + this.contracts.token, + this.contracts.stable, + outputStable, + this.accounts.bruce, + inputToken - 1n + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'InputAmountTooHigh') + .withArgs(inputToken, inputToken - 1n); + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactOutputSingle( + this.contracts.stable, + this.contracts.token, + outputToken, + this.accounts.alice, + inputStable - 1n + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'InputAmountTooHigh') + .withArgs(inputStable, inputStable - 1n); + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactOutput( + [this.contracts.token, this.contracts.stable], + outputStable, + this.accounts.bruce, + inputToken - 1n + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'InputAmountTooHigh') + .withArgs(inputToken, inputToken - 1n); + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactOutput( + [this.contracts.stable, this.contracts.token], + outputToken, + this.accounts.alice, + inputStable - 1n + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'InputAmountTooHigh') + .withArgs(inputStable, inputStable - 1n); + + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactOutputSingle( + this.contracts.token, + this.contracts.stable, + outputStable, + this.accounts.bruce, + inputToken + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactOutputSingle( + this.contracts.stable, + this.contracts.token, + outputToken, + this.accounts.alice, + inputStable + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactOutput( + [this.contracts.token, this.contracts.stable], + outputStable, + this.accounts.bruce, + inputToken + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactOutput( + [this.contracts.stable, this.contracts.token], + outputToken, + this.accounts.alice, + inputStable + ) + ).to.not.be.reverted; + + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactOutputSingle( + this.contracts.token, + this.contracts.stable, + outputStable, + this.accounts.bruce, + inputToken + 1n + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactOutputSingle( + this.contracts.stable, + this.contracts.token, + outputToken, + this.accounts.alice, + inputStable + 1n + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactOutput( + [this.contracts.token, this.contracts.stable], + outputStable, + this.accounts.bruce, + inputToken + 1n + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactOutput( + [this.contracts.stable, this.contracts.token], + outputToken, + this.accounts.alice, + inputStable + 1n + ) + ).to.not.be.reverted; + }); + }); + + describe('with fees', function () { + beforeEach(async function () { + await expect(this.contracts.atm.connect(this.accounts.admin).setFee(20n)) // 0.2% + .to.emit(this.contracts.atm, 'FeeUpdated') + .withArgs(20n); + }); + + it('preview single', async function () { + await expect( + this.contracts.atm.previewExactOutputSingle( + this.contracts.stable, + this.contracts.token, + formatToken('1.0') + ) + ).to.eventually.equal(divUp(tokenToStable(formatToken('1.0'), buyPrice, true) * 10000n, 9980n)); + await expect( + this.contracts.atm.previewExactOutputSingle( + this.contracts.stable, + this.contracts.token, + formatToken('1.00001') + ) + ).to.eventually.equal(divUp(tokenToStable(formatToken('1.00001'), buyPrice, true) * 10000n, 9980n)); + await expect( + this.contracts.atm.previewExactOutputSingle( + this.contracts.token, + this.contracts.stable, + formatStable('1.0') + ) + ).to.eventually.equal(divUp(stableToToken(formatStable('1.0'), sellPrice, true) * 10000n, 9980n)); + await expect( + this.contracts.atm.previewExactOutputSingle( + this.contracts.token, + this.contracts.stable, + formatStable('1.000001') + ) + ).to.eventually.equal( + divUp(stableToToken(formatStable('1.000001'), sellPrice, true) * 10000n, 9980n) + ); + await expect( + this.contracts.atm.previewExactOutputSingle(this.contracts.manager, this.contracts.stable, 0) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'UnknownPair') + .withArgs(this.contracts.manager, this.contracts.stable); + }); + + it('preview path', async function () { + await expect( + this.contracts.atm.previewExactOutput( + [this.contracts.stable, this.contracts.token], + formatToken('1.0') + ) + ).to.eventually.equal(divUp(tokenToStable(formatToken('1.0'), buyPrice, true) * 10000n, 9980n)); + await expect( + this.contracts.atm.previewExactOutput( + [this.contracts.stable, this.contracts.token], + formatToken('1.00001') + ) + ).to.eventually.equal(divUp(tokenToStable(formatToken('1.00001'), buyPrice, true) * 10000n, 9980n)); + await expect( + this.contracts.atm.previewExactOutput( + [this.contracts.token, this.contracts.stable], + formatStable('1.0') + ) + ).to.eventually.equal(divUp(stableToToken(formatStable('1.0'), sellPrice, true) * 10000n, 9980n)); + await expect( + this.contracts.atm.previewExactOutput( + [this.contracts.token, this.contracts.stable], + formatStable('1.000001') + ) + ).to.eventually.equal( + divUp(stableToToken(formatStable('1.000001'), sellPrice, true) * 10000n, 9980n) + ); + await expect( + this.contracts.atm.previewExactOutput([this.contracts.manager, this.contracts.stable], 0) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'UnknownPair') + .withArgs(this.contracts.manager, this.contracts.stable); + }); + + it('buy exact amount of token - single', async function () { + const amountToken = formatToken('1.00001'); + const amountStable = divUp(tokenToStable(amountToken, buyPrice, true) * 10000n, 9980n); + + const tx = this.contracts.atm.connect(this.accounts.bruce).swapExactOutputSingle( + this.contracts.stable, + this.contracts.token, + amountToken, + this.accounts.alice, + ethers.MaxUint256 // no maximum input amount + ); + await expect(tx).to.changeTokenBalances( + this.contracts.stable, + [this.accounts.bruce, this.contracts.atm], + [-amountStable, amountStable] + ); + await expect(tx).to.changeTokenBalances( + this.contracts.token, + [this.accounts.alice, this.contracts.atm], + [amountToken, -amountToken] + ); + }); + + it('buy exact amount of token - path', async function () { + const amountToken = formatToken('1.00001'); + const amountStable = divUp(tokenToStable(amountToken, buyPrice, true) * 10000n, 9980n); + + const tx = this.contracts.atm.connect(this.accounts.bruce).swapExactOutput( + [this.contracts.stable, this.contracts.token], + amountToken, + this.accounts.alice, + ethers.MaxUint256 // no maximum input amount + ); + await expect(tx).to.changeTokenBalances( + this.contracts.stable, + [this.accounts.bruce, this.contracts.atm], + [-amountStable, amountStable] + ); + await expect(tx).to.changeTokenBalances( + this.contracts.token, + [this.accounts.alice, this.contracts.atm], + [amountToken, -amountToken] + ); + }); + + it('buy exact amount of stable - single', async function () { + const amountStable = formatStable('1.000001'); + const amountToken = divUp(stableToToken(amountStable, sellPrice, true) * 10000n, 9980n); + + const tx = this.contracts.atm.connect(this.accounts.alice).swapExactOutputSingle( + this.contracts.token, + this.contracts.stable, + amountStable, + this.accounts.bruce, + ethers.MaxUint256 // no maximum input amount + ); + await expect(tx).to.changeTokenBalances( + this.contracts.token, + [this.accounts.alice, this.contracts.atm], + [-amountToken, amountToken] + ); + await expect(tx).to.changeTokenBalances( + this.contracts.stable, + [this.accounts.bruce, this.contracts.atm], + [amountStable, -amountStable] + ); + }); + + it('buy exact amount of stable - path', async function () { + const amountStable = formatStable('1.000001'); + const amountToken = divUp(stableToToken(amountStable, sellPrice, true) * 10000n, 9980n); + + const tx = this.contracts.atm.connect(this.accounts.alice).swapExactOutput( + [this.contracts.token, this.contracts.stable], + amountStable, + this.accounts.bruce, + ethers.MaxUint256 // no maximum input amount + ); + await expect(tx).to.changeTokenBalances( + this.contracts.token, + [this.accounts.alice, this.contracts.atm], + [-amountToken, amountToken] + ); + await expect(tx).to.changeTokenBalances( + this.contracts.stable, + [this.accounts.bruce, this.contracts.atm], + [amountStable, -amountStable] + ); + }); + + it('oracle not updated recently', async function () { + await time.increase(oraclettl + 3601); // oracle publish is block + 3600 + await expect( + this.contracts.atm.previewExactOutputSingle(this.contracts.stable, this.contracts.token, 0) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.previewExactOutputSingle(this.contracts.token, this.contracts.stable, 0) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.previewExactOutput([this.contracts.stable, this.contracts.token], 0) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.previewExactOutput([this.contracts.token, this.contracts.stable], 0) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.swapExactOutputSingle( + this.contracts.stable, + this.contracts.token, + 0, + ethers.ZeroAddress, + ethers.MaxUint256 + ) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.swapExactOutputSingle( + this.contracts.token, + this.contracts.stable, + 0, + ethers.ZeroAddress, + ethers.MaxUint256 + ) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.swapExactOutput( + [this.contracts.stable, this.contracts.token], + 0, + ethers.ZeroAddress, + ethers.MaxUint256 + ) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + await expect( + this.contracts.atm.swapExactOutput( + [this.contracts.token, this.contracts.stable], + 0, + ethers.ZeroAddress, + ethers.MaxUint256 + ) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + }); + + it('slippage protection', async function () { + const outputToken = formatToken('1.00001'); + const outputStable = formatStable('1.000001'); + const inputStable = divUp(tokenToStable(outputToken, buyPrice, true) * 10000n, 9980n); + const inputToken = divUp(stableToToken(outputStable, sellPrice, true) * 10000n, 9980n); + + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactOutputSingle( + this.contracts.token, + this.contracts.stable, + outputStable, + this.accounts.bruce, + inputToken - 1n + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'InputAmountTooHigh') + .withArgs(inputToken, inputToken - 1n); + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactOutputSingle( + this.contracts.stable, + this.contracts.token, + outputToken, + this.accounts.alice, + inputStable - 1n + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'InputAmountTooHigh') + .withArgs(inputStable, inputStable - 1n); + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactOutput( + [this.contracts.token, this.contracts.stable], + outputStable, + this.accounts.bruce, + inputToken - 1n + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'InputAmountTooHigh') + .withArgs(inputToken, inputToken - 1n); + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactOutput( + [this.contracts.stable, this.contracts.token], + outputToken, + this.accounts.alice, + inputStable - 1n + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'InputAmountTooHigh') + .withArgs(inputStable, inputStable - 1n); + + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactOutputSingle( + this.contracts.token, + this.contracts.stable, + outputStable, + this.accounts.bruce, + inputToken + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactOutputSingle( + this.contracts.stable, + this.contracts.token, + outputToken, + this.accounts.alice, + inputStable + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactOutput( + [this.contracts.token, this.contracts.stable], + outputStable, + this.accounts.bruce, + inputToken + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactOutput( + [this.contracts.stable, this.contracts.token], + outputToken, + this.accounts.alice, + inputStable + ) + ).to.not.be.reverted; + + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactOutputSingle( + this.contracts.token, + this.contracts.stable, + outputStable, + this.accounts.bruce, + inputToken + 1n + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactOutputSingle( + this.contracts.stable, + this.contracts.token, + outputToken, + this.accounts.alice, + inputStable + 1n + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.alice) + .swapExactOutput( + [this.contracts.token, this.contracts.stable], + outputStable, + this.accounts.bruce, + inputToken + 1n + ) + ).to.not.be.reverted; + await expect( + this.contracts.atm + .connect(this.accounts.bruce) + .swapExactOutput( + [this.contracts.stable, this.contracts.token], + outputToken, + this.accounts.alice, + inputStable + 1n + ) + ).to.not.be.reverted; + }); + }); + }); + + it('preview path rounding', async function () { + const amount = ethers.WeiPerEther; + + await expect( + this.contracts.atm.previewExactInput( + [this.contracts.token, this.contracts.stable, this.contracts.token], + amount + ) + ).to.eventually.be.lte(amount); + await expect( + this.contracts.atm.previewExactInput( + [this.contracts.stable, this.contracts.token, this.contracts.stable], + amount + ) + ).to.eventually.be.lte(amount); + await expect( + this.contracts.atm.previewExactOutput( + [this.contracts.token, this.contracts.stable, this.contracts.token], + amount + ) + ).to.eventually.be.gte(amount); + await expect( + this.contracts.atm.previewExactOutput( + [this.contracts.stable, this.contracts.token, this.contracts.stable], + amount + ) + ).to.eventually.be.gte(amount); + }); + }); + + describe('withdraw', function () { + it('unauthorized', async function () { + await expect( + this.contracts.atm.connect(this.accounts.other).withdraw(this.contracts.token, this.accounts.alice, 1) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'RestrictedAccess') + .withArgs( + this.accounts.other, + this.contracts.atm, + this.contracts.atm.interface.getFunction('withdraw').selector + ); + }); + + it('partial', async function () { + await expect( + this.contracts.atm.connect(this.accounts.admin).withdraw(this.contracts.token, this.accounts.alice, 42) + ).to.changeTokenBalances(this.contracts.token, [this.contracts.atm, this.accounts.alice], [-42, 42]); + }); + + it('total', async function () { + const balance = await this.contracts.token.balanceOf(this.contracts.atm); + await expect( + this.contracts.atm + .connect(this.accounts.admin) + .withdraw(this.contracts.token, this.accounts.alice, ethers.MaxUint256) + ).to.changeTokenBalances( + this.contracts.token, + [this.contracts.atm, this.accounts.alice], + [-balance, balance] + ); + }); + }); + + it('setFee above max', async function () { + await expect(this.contracts.atm.connect(this.accounts.admin).setFee(51n)) + .to.be.revertedWithCustomError(this.contracts.atm, 'InvalidFee') + .withArgs(51n); + }); + }); + } + + describe('with linear yield pricing', function () { + const oraclettl = time.duration.days(7); + const stableDecimal = 6n; + const numFactor = 10n ** stableDecimal; + const denFactor = 10n ** 11n; + const formatToken = (value) => ethers.parseUnits(value, 5); + const formatStable = (value) => ethers.parseUnits(value, stableDecimal); + + beforeEach(async function () { + this.contracts.stable = await deploy('ERC20DecimalsMock', [stableDecimal]); + this.contracts.atm = await deploy('MultiATMLinear', [ + this.contracts.manager.target, + this.contracts.forwarder.target, + ]); + + await this.contracts.manager.setRequirements( + this.contracts.atm, + [ + this.contracts.atm.interface.getFunction('swapExactInput').selector, + this.contracts.atm.interface.getFunction('swapExactInputSingle').selector, + this.contracts.atm.interface.getFunction('swapExactOutput').selector, + this.contracts.atm.interface.getFunction('swapExactOutputSingle').selector, + ], + [this.IDS['whitelisted']] + ); + + await this.contracts.manager.setRequirements( + this.contracts.atm, + [ + this.contracts.atm.interface.getFunction('setPair').selector, + this.contracts.atm.interface.getFunction('removePair').selector, + this.contracts.atm.interface.getFunction('withdraw').selector, + ], + [this.IDS['operator-exceptional']] + ); + await this.contracts.manager.addGroup(this.contracts.atm, this.IDS['whitelisted']); + + this.id = ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'address'], + [this.contracts.token, this.contracts.stable] + .map(getAddress) + .sort((a, b) => (ethers.toBigInt(a) > ethers.toBigInt(b) ? 1 : -1)) + ) + ); + + await this.contracts.token.mint(this.contracts.atm, formatToken('1000')); + await this.contracts.stable.mint(this.contracts.atm, formatStable('1000')); + await this.contracts.token.mint(this.accounts.alice, formatToken('100')); + await this.contracts.stable.mint(this.accounts.bruce, formatStable('100')); + await this.contracts.token.connect(this.accounts.alice).approve(this.contracts.atm, ethers.MaxUint256); + await this.contracts.stable.connect(this.accounts.bruce).approve(this.contracts.atm, ethers.MaxUint256); + }); + + describe('setPair with linearYield flag', function () { + it('accepts linearYield = false (disabled)', async function () { + const timestamp = await time.latest(); + await this.contracts.oracle.publishPrice(timestamp - 3600, ethers.parseUnits('2.0', 6)); + await this.contracts.oracle.publishPrice(timestamp, ethers.parseUnits('2.1', 6)); + + await expect( + this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + false + ) + ).to.emit(this.contracts.atm, 'PairUpdated'); + + const details = await this.contracts.atm.viewPairDetails(this.contracts.token, this.contracts.stable); + expect(details.linearYield).to.equal(false); + }); + + it('accepts linearYield = true (enabled)', async function () { + const timestamp = await time.latest(); + await this.contracts.oracle.publishPrice(timestamp - 3600, ethers.parseUnits('2.0', 6)); + await this.contracts.oracle.publishPrice(timestamp, ethers.parseUnits('2.1', 6)); + + await expect( + this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + true + ) + ).to.emit(this.contracts.atm, 'PairUpdated'); + + const details = await this.contracts.atm.viewPairDetails(this.contracts.token, this.contracts.stable); + expect(details.linearYield).to.equal(true); + }); + }); + + describe('price calculation with linear extrapolation', function () { + const REGRESSION_EXPECTED = { + positive: { + sellOutput: 2075000n, + buyOutput: 481927n, + }, + negative: { + sellOutput: 1975000n, + buyOutput: 506329n, + }, + flat: { + sellOutput: 2100000n, + buyOutput: 476190n, + }, + }; + + describe('with positive slope (increasing prices)', function () { + beforeEach(async function () { + const timestamp = await time.latest(); + this.baseTimestamp = BigInt(timestamp - 3600); + // Two oracle data points + await this.contracts.oracle.publishPrice(this.baseTimestamp, ethers.parseUnits('2.00', 6)); + await this.contracts.oracle.publishPrice(this.baseTimestamp + 3600n, ethers.parseUnits('2.05', 6)); + + await this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + true + ); + + // swapTime: 1800s after the last price point (= baseTs + 5400) + this.swapTimestamp = this.baseTimestamp + 5400n; + }); + + it('computes extrapolated price for token sale', async function () { + await time.increaseTo(this.swapTimestamp); + + const inputAmount = formatToken('1.0'); + const actualOutput = await this.contracts.atm.previewExactInputSingle( + this.contracts.token, + this.contracts.stable, + inputAmount + ); + + expect(actualOutput).to.equal(REGRESSION_EXPECTED.positive.sellOutput); + }); + + it('computes extrapolated price for token purchase', async function () { + await time.increaseTo(this.swapTimestamp); + + const inputAmount = formatStable('10.0'); + const actualOutput = await this.contracts.atm.previewExactInputSingle( + this.contracts.stable, + this.contracts.token, + inputAmount + ); + + expect(actualOutput).to.equal(REGRESSION_EXPECTED.positive.buyOutput); + }); + }); + + describe('with negative slope (decreasing prices)', function () { + beforeEach(async function () { + const timestamp = await time.latest(); + this.baseTimestamp = BigInt(timestamp - 3600); + await this.contracts.oracle.publishPrice(this.baseTimestamp, ethers.parseUnits('2.05', 6)); + await this.contracts.oracle.publishPrice(this.baseTimestamp + 3600n, ethers.parseUnits('2.00', 6)); + + await this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + true + ); + + this.swapTimestamp = this.baseTimestamp + 5400n; + }); + + it('computes extrapolated price for token sale', async function () { + await time.increaseTo(this.swapTimestamp); + + const inputAmount = formatToken('1.0'); + const actualOutput = await this.contracts.atm.previewExactInputSingle( + this.contracts.token, + this.contracts.stable, + inputAmount + ); + + expect(actualOutput).to.equal(REGRESSION_EXPECTED.negative.sellOutput); + }); + + it('computes extrapolated price for token purchase', async function () { + await time.increaseTo(this.swapTimestamp); + + const inputAmount = formatStable('10.0'); + const actualOutput = await this.contracts.atm.previewExactInputSingle( + this.contracts.stable, + this.contracts.token, + inputAmount + ); + + expect(actualOutput).to.equal(REGRESSION_EXPECTED.negative.buyOutput); + }); + }); + + describe('with flat slope (constant prices)', function () { + beforeEach(async function () { + const timestamp = await time.latest(); + this.baseTimestamp = BigInt(timestamp - 3600); + await this.contracts.oracle.publishPrice(this.baseTimestamp, ethers.parseUnits('2.10', 6)); + await this.contracts.oracle.publishPrice(this.baseTimestamp + 3600n, ethers.parseUnits('2.10', 6)); + + await this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + true + ); + + this.swapTimestamp = this.baseTimestamp + 5400n; + }); + + it('returns the constant price', async function () { + await time.increaseTo(this.swapTimestamp); + + const inputAmount = formatToken('1.0'); + const actualOutput = await this.contracts.atm.previewExactInputSingle( + this.contracts.token, + this.contracts.stable, + inputAmount + ); + + expect(actualOutput).to.equal(REGRESSION_EXPECTED.flat.sellOutput); + }); + }); + }); + + describe('swap operations with linear yield pricing', function () { + beforeEach(async function () { + const timestamp = await time.latest(); + this.baseTimestamp = BigInt(timestamp - 3600); + // Two increasing price points + await this.contracts.oracle.publishPrice(this.baseTimestamp, ethers.parseUnits('2.00', 6)); + await this.contracts.oracle.publishPrice(this.baseTimestamp + 3600n, ethers.parseUnits('2.08', 6)); + + await this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + true + ); + }); + + it('swapExactInputSingle: sell token for stable', async function () { + const inputAmount = formatToken('1.0'); + + const aliceTokenBefore = await this.contracts.token.balanceOf(this.accounts.alice); + const bruceStableBefore = await this.contracts.stable.balanceOf(this.accounts.bruce); + const atmTokenBefore = await this.contracts.token.balanceOf(this.contracts.atm); + const atmStableBefore = await this.contracts.stable.balanceOf(this.contracts.atm); + + const tx = await this.contracts.atm + .connect(this.accounts.alice) + .swapExactInputSingle(this.contracts.token, this.contracts.stable, inputAmount, this.accounts.bruce, 0); + + const aliceTokenAfter = await this.contracts.token.balanceOf(this.accounts.alice); + const bruceStableAfter = await this.contracts.stable.balanceOf(this.accounts.bruce); + const atmTokenAfter = await this.contracts.token.balanceOf(this.contracts.atm); + const atmStableAfter = await this.contracts.stable.balanceOf(this.contracts.atm); + + expect(aliceTokenBefore - aliceTokenAfter).to.equal(inputAmount); + expect(atmTokenAfter - atmTokenBefore).to.equal(inputAmount); + + const stableReceived = bruceStableAfter - bruceStableBefore; + expect(stableReceived).to.be.gt(0); + expect(atmStableBefore - atmStableAfter).to.equal(stableReceived); + + await expect(tx).to.emit(this.contracts.atm, 'SwapExact'); + }); + + it('swapExactInputSingle: buy token with stable', async function () { + const inputAmount = formatStable('10.0'); + + const bruceStableBefore = await this.contracts.stable.balanceOf(this.accounts.bruce); + const aliceTokenBefore = await this.contracts.token.balanceOf(this.accounts.alice); + const atmStableBefore = await this.contracts.stable.balanceOf(this.contracts.atm); + const atmTokenBefore = await this.contracts.token.balanceOf(this.contracts.atm); + + const tx = await this.contracts.atm + .connect(this.accounts.bruce) + .swapExactInputSingle(this.contracts.stable, this.contracts.token, inputAmount, this.accounts.alice, 0); + + const bruceStableAfter = await this.contracts.stable.balanceOf(this.accounts.bruce); + const aliceTokenAfter = await this.contracts.token.balanceOf(this.accounts.alice); + const atmStableAfter = await this.contracts.stable.balanceOf(this.contracts.atm); + const atmTokenAfter = await this.contracts.token.balanceOf(this.contracts.atm); + + expect(bruceStableBefore - bruceStableAfter).to.equal(inputAmount); + expect(atmStableAfter - atmStableBefore).to.equal(inputAmount); + + const tokenReceived = aliceTokenAfter - aliceTokenBefore; + expect(tokenReceived).to.be.gt(0); + expect(atmTokenBefore - atmTokenAfter).to.equal(tokenReceived); + + await expect(tx).to.emit(this.contracts.atm, 'SwapExact'); + }); + + it('swapExactOutputSingle: sell token for exact stable', async function () { + const outputAmount = formatStable('5.0'); + + const aliceTokenBefore = await this.contracts.token.balanceOf(this.accounts.alice); + const bruceStableBefore = await this.contracts.stable.balanceOf(this.accounts.bruce); + const atmTokenBefore = await this.contracts.token.balanceOf(this.contracts.atm); + const atmStableBefore = await this.contracts.stable.balanceOf(this.contracts.atm); + + const tx = await this.contracts.atm + .connect(this.accounts.alice) + .swapExactOutputSingle( + this.contracts.token, + this.contracts.stable, + outputAmount, + this.accounts.bruce, + ethers.MaxUint256 + ); + + const aliceTokenAfter = await this.contracts.token.balanceOf(this.accounts.alice); + const bruceStableAfter = await this.contracts.stable.balanceOf(this.accounts.bruce); + const atmTokenAfter = await this.contracts.token.balanceOf(this.contracts.atm); + const atmStableAfter = await this.contracts.stable.balanceOf(this.contracts.atm); + + expect(bruceStableAfter - bruceStableBefore).to.equal(outputAmount); + expect(atmStableBefore - atmStableAfter).to.equal(outputAmount); + + const tokenSpent = aliceTokenBefore - aliceTokenAfter; + expect(tokenSpent).to.be.gt(0); + expect(atmTokenAfter - atmTokenBefore).to.equal(tokenSpent); + + await expect(tx).to.emit(this.contracts.atm, 'SwapExact'); + }); + + it('swapExactOutputSingle: buy exact token with stable', async function () { + const outputAmount = formatToken('1.0'); + + const bruceStableBefore = await this.contracts.stable.balanceOf(this.accounts.bruce); + const aliceTokenBefore = await this.contracts.token.balanceOf(this.accounts.alice); + const atmStableBefore = await this.contracts.stable.balanceOf(this.contracts.atm); + const atmTokenBefore = await this.contracts.token.balanceOf(this.contracts.atm); + + const tx = await this.contracts.atm + .connect(this.accounts.bruce) + .swapExactOutputSingle( + this.contracts.stable, + this.contracts.token, + outputAmount, + this.accounts.alice, + ethers.MaxUint256 + ); + + const bruceStableAfter = await this.contracts.stable.balanceOf(this.accounts.bruce); + const aliceTokenAfter = await this.contracts.token.balanceOf(this.accounts.alice); + const atmStableAfter = await this.contracts.stable.balanceOf(this.contracts.atm); + const atmTokenAfter = await this.contracts.token.balanceOf(this.contracts.atm); + + expect(aliceTokenAfter - aliceTokenBefore).to.equal(outputAmount); + expect(atmTokenBefore - atmTokenAfter).to.equal(outputAmount); + + const stableSpent = bruceStableBefore - bruceStableAfter; + expect(stableSpent).to.be.gt(0); + expect(atmStableAfter - atmStableBefore).to.equal(stableSpent); + + await expect(tx).to.emit(this.contracts.atm, 'SwapExact'); + }); + + it('oracle not updated recently reverts', async function () { + await time.increase(oraclettl); + + await expect( + this.contracts.atm.previewExactInputSingle(this.contracts.token, this.contracts.stable, formatToken('1.0')) + ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); + }); + }); + + describe('comparison: linear yield vs non-linear yield pricing', function () { + beforeEach(async function () { + const timestamp = await time.latest(); + + await this.contracts.oracle.publishPrice(timestamp - 3600, ethers.parseUnits('2.00', 6)); + await this.contracts.oracle.publishPrice(timestamp, ethers.parseUnits('2.10', 6)); + }); + + it('non-linear yield uses min/max of last 2 prices', async function () { + await this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + false // non-linear yield + ); + + // When selling token, use min price (2.00) + const sellOutput = await this.contracts.atm.previewExactInputSingle( + this.contracts.token, + this.contracts.stable, + formatToken('1.0') + ); + + const minPrice = ethers.parseUnits('2.00', 6); + const expectedSellOutput = (formatToken('1.0') * numFactor * minPrice) / denFactor; + expect(sellOutput).to.equal(expectedSellOutput); + + // When buying token, use max price (2.10) + const buyOutput = await this.contracts.atm.previewExactInputSingle( + this.contracts.stable, + this.contracts.token, + formatStable('10.0') + ); + + const maxPrice = ethers.parseUnits('2.10', 6); + const expectedBuyOutput = (formatStable('10.0') * denFactor) / (maxPrice * numFactor); + expect(buyOutput).to.equal(expectedBuyOutput); + }); + + it('linear yield uses extrapolated price (same for buy and sell)', async function () { + await this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + true // linear yield + ); + + const sellOutput = await this.contracts.atm.previewExactInputSingle( + this.contracts.token, + this.contracts.stable, + formatToken('1.0') + ); + + const buyOutput = await this.contracts.atm.previewExactInputSingle( + this.contracts.stable, + this.contracts.token, + formatStable('10.0') + ); + + const minPrice = ethers.parseUnits('2.00', 6); + const maxPrice = ethers.parseUnits('2.10', 6); + + const minSellOutput = (formatToken('1.0') * numFactor * minPrice) / denFactor; + expect(sellOutput).to.be.gte(minSellOutput); + + const minBuyOutput = (formatStable('10.0') * denFactor) / (maxPrice * numFactor); + expect(buyOutput).to.be.lte(minBuyOutput); + }); + }); + }); + + // Only the last 2 oracle rounds are used: + // previousPrice = 2.12 at previousTimestamp, latestPrice = 2.10 at latestTimestamp (delta = 3600s) + // At swapTime = previousTimestamp + 5400 (1800s after latest): + // price = previous + (latest - previous) * elapsed / timeDelta + // = 2120000 + (-20000) * 5400 / 3600 = 2120000 - 30000 = 2090000 + // + // Non-linear-yield would use min/max of last 2 prices: min=2100000, max=2120000 + // Linear yield price (2090000) differs from both, proving linear extrapolation works + const HARDCODED_EXPECTED = { + '6_6': { + // tokenDec=6, stableDec=6, oracleDec=6: numFactor=1e6, denFactor=1e12 + // sell 1 token (1e6): mulDiv(1e6 * 1e6, 2090000, 1e12) = 2090000 + sellOutput: 2090000n, + // buy with 10 stable (10e6): mulDiv(10e6 * 1e12, 1, 2090000 * 1e6) = floor(4784688.99...) = 4784688 + buyOutput: 4784688n, + // exact output 5 stable (5e6): mulDivUp(1, 5e6 * 1e12, 2090000 * 1e6) = ceil(2392344.49...) = 2392345 + exactInput: 2392345n, + nonAccrualSellOutput: 2100000n, + }, + '6_18': { + // tokenDec=6, stableDec=18, oracleDec=6: numFactor=1e18, denFactor=1e12 + // sell 1 token (1e6): mulDiv(1e6 * 1e18, 2090000, 1e12) = 2090000000000000000 + sellOutput: 2090000000000000000n, + // buy with 10 stable (10e18): mulDiv(10e18 * 1e12, 1, 2090000 * 1e18) = floor(4784688.99...) = 4784688 + buyOutput: 4784688n, + // exact output 5 stable (5e18): mulDivUp(1, 5e18 * 1e12, 2090000 * 1e18) = ceil(2392344.49...) = 2392345 + exactInput: 2392345n, + nonAccrualSellOutput: 2100000000000000000n, + }, + '18_6': { + // tokenDec=18, stableDec=6, oracleDec=6: numFactor=1e6, denFactor=1e24 + // sell 1 token (1e18): mulDiv(1e18 * 1e6, 2090000, 1e24) = 2090000 + sellOutput: 2090000n, + // buy with 10 stable (10e6): mulDiv(10e6 * 1e24, 1, 2090000 * 1e6) = 4784688995215311004 + buyOutput: 4784688995215311004n, + // exact output 5 stable (5e6): mulDivUp(1, 5e6 * 1e24, 2090000 * 1e6) = 2392344497607655503 + exactInput: 2392344497607655503n, + nonAccrualSellOutput: 2100000n, + }, + '18_18': { + // tokenDec=18, stableDec=18, oracleDec=6: numFactor=1e18, denFactor=1e24 + // sell 1 token (1e18): mulDiv(1e18 * 1e18, 2090000, 1e24) = 2090000000000000000 + sellOutput: 2090000000000000000n, + // buy with 10 stable (10e18): mulDiv(10e18 * 1e24, 1, 2090000 * 1e18) = 4784688995215311004 + buyOutput: 4784688995215311004n, + // exact output 5 stable (5e18): mulDivUp(1, 5e18 * 1e24, 2090000 * 1e18) = 2392344497607655503 + exactInput: 2392344497607655503n, + nonAccrualSellOutput: 2100000000000000000n, + }, + }; + + // Test linear yield with different token decimal combinations using exact timing and hardcoded values + for (const tokenDecimals of [6n, 18n]) { + for (const stableDecimals of [6n, 18n]) { + describe(`with linear yield - token ${tokenDecimals} decimals, stable ${stableDecimals} decimals (rigorous)`, function () { + const oraclettl = time.duration.days(7); + const numFactor = 10n ** stableDecimals; + const denFactor = 10n ** (tokenDecimals + 6n); + const formatToken = (value) => ethers.parseUnits(value, tokenDecimals); + const formatStable = (value) => ethers.parseUnits(value, stableDecimals); + const expectedKey = `${tokenDecimals}_${stableDecimals}`; + const expected = HARDCODED_EXPECTED[expectedKey]; + + beforeEach(async function () { + this.testToken = await deploy('ERC20DecimalsMock', [tokenDecimals]); + this.testStable = await deploy('ERC20DecimalsMock', [stableDecimals]); + this.testAtm = await deploy('MultiATMLinear', [ + this.contracts.manager.target, + this.contracts.forwarder.target, + ]); + + await this.contracts.manager.setRequirements( + this.testAtm, + [ + this.testAtm.interface.getFunction('swapExactInputSingle').selector, + this.testAtm.interface.getFunction('swapExactOutputSingle').selector, + ], + [this.IDS['whitelisted']] + ); + + await this.contracts.manager.setRequirements( + this.testAtm, + [this.testAtm.interface.getFunction('setPair').selector], + [this.IDS['operator-exceptional']] + ); + await this.contracts.manager.addGroup(this.testAtm, this.IDS['whitelisted']); + + await this.testToken.mint(this.testAtm, formatToken('1000')); + await this.testStable.mint(this.testAtm, formatStable('10000')); + await this.testToken.mint(this.accounts.alice, formatToken('100')); + await this.testStable.mint(this.accounts.bruce, formatStable('1000')); + await this.testToken.connect(this.accounts.alice).approve(this.testAtm, ethers.MaxUint256); + await this.testStable.connect(this.accounts.bruce).approve(this.testAtm, ethers.MaxUint256); + + const timestamp = await time.latest(); + this.baseTimestamp = BigInt(timestamp - 3600); + // Two oracle data points: previous (2.12) and latest (2.10) + await this.contracts.oracle.publishPrice(this.baseTimestamp, ethers.parseUnits('2.12', 6)); + await this.contracts.oracle.publishPrice(this.baseTimestamp + 3600n, ethers.parseUnits('2.10', 6)); + + // Store swap timestamp for tests: 1800s after the latest price point + this.swapTimestamp = this.baseTimestamp + 5400n; + }); + + it('setPair correctly sets numerator/denominator for decimal scaling', async function () { + await this.testAtm.setPair(this.testToken, this.testStable, this.contracts.oracle, oraclettl, true); + + const details = await this.testAtm.viewPairDetails(this.testToken, this.testStable); + expect(details.numerator).to.equal(numFactor); + expect(details.denominator).to.equal(denFactor); + expect(details.linearYield).to.equal(true); + }); + + it('swap token for stable with hardcoded expected output', async function () { + await this.testAtm.setPair(this.testToken, this.testStable, this.contracts.oracle, oraclettl, true); + + const tokenAmount = formatToken('1.0'); + + await time.setNextBlockTimestamp(this.swapTimestamp); + + const aliceTokenBefore = await this.testToken.balanceOf(this.accounts.alice); + const aliceStableBefore = await this.testStable.balanceOf(this.accounts.alice); + + await this.testAtm + .connect(this.accounts.alice) + .swapExactInputSingle(this.testToken, this.testStable, tokenAmount, this.accounts.alice, 0); + + const aliceTokenAfter = await this.testToken.balanceOf(this.accounts.alice); + const aliceStableAfter = await this.testStable.balanceOf(this.accounts.alice); + + expect(aliceTokenBefore - aliceTokenAfter).to.equal(tokenAmount); + + const actualStableReceived = aliceStableAfter - aliceStableBefore; + expect(actualStableReceived).to.equal(expected.sellOutput); + + // Verify linear yield price differs from non-linear-yield min/max pricing + expect(actualStableReceived).to.not.equal(expected.nonAccrualSellOutput); + }); + + it('swap stable for token with hardcoded expected output', async function () { + await this.testAtm.setPair(this.testToken, this.testStable, this.contracts.oracle, oraclettl, true); + + const stableAmount = formatStable('10.0'); + + await time.setNextBlockTimestamp(this.swapTimestamp); + + const bruceTokenBefore = await this.testToken.balanceOf(this.accounts.bruce); + const bruceStableBefore = await this.testStable.balanceOf(this.accounts.bruce); + + await this.testAtm + .connect(this.accounts.bruce) + .swapExactInputSingle(this.testStable, this.testToken, stableAmount, this.accounts.bruce, 0); + + const bruceTokenAfter = await this.testToken.balanceOf(this.accounts.bruce); + const bruceStableAfter = await this.testStable.balanceOf(this.accounts.bruce); + + expect(bruceStableBefore - bruceStableAfter).to.equal(stableAmount); + + const actualTokenReceived = bruceTokenAfter - bruceTokenBefore; + expect(actualTokenReceived).to.equal(expected.buyOutput); + }); + + it('exact output swap with hardcoded expected input', async function () { + await this.testAtm.setPair(this.testToken, this.testStable, this.contracts.oracle, oraclettl, true); + + const stableOutputAmount = formatStable('5.0'); + + await time.setNextBlockTimestamp(this.swapTimestamp); + + const aliceTokenBefore = await this.testToken.balanceOf(this.accounts.alice); + const aliceStableBefore = await this.testStable.balanceOf(this.accounts.alice); + + await this.testAtm + .connect(this.accounts.alice) + .swapExactOutputSingle( + this.testToken, + this.testStable, + stableOutputAmount, + this.accounts.alice, + ethers.MaxUint256 + ); + + const aliceTokenAfter = await this.testToken.balanceOf(this.accounts.alice); + const aliceStableAfter = await this.testStable.balanceOf(this.accounts.alice); + + // Verify exact input matches HARDCODED expectation + const actualTokenSpent = aliceTokenBefore - aliceTokenAfter; + expect(actualTokenSpent).to.equal(expected.exactInput); + expect(aliceStableAfter - aliceStableBefore).to.equal(stableOutputAmount); + }); + }); + } + } + }); + describe('Permission Manager', function () { const { address: caller } = ethers.Wallet.createRandom(); const { address: target } = ethers.Wallet.createRandom();