From 42ad6e8de71d2ffe7876ecebace21203dce7138e Mon Sep 17 00:00:00 2001 From: Adam Hotait Date: Wed, 11 Feb 2026 10:46:02 +0100 Subject: [PATCH 1/2] chore(multi-atm): format --- contracts/token/MultiATM.sol | 265 ++++++++++++++++++++++------------- 1 file changed, 165 insertions(+), 100 deletions(-) diff --git a/contracts/token/MultiATM.sol b/contracts/token/MultiATM.sol index 0146460..a5afe37 100644 --- a/contracts/token/MultiATM.sol +++ b/contracts/token/MultiATM.sol @@ -2,23 +2,22 @@ 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; @@ -54,8 +53,21 @@ 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); @@ -65,153 +77,207 @@ 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); @@ -220,7 +286,12 @@ 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, @@ -234,27 +305,21 @@ 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); } /**************************************************************************************************************** @@ -271,4 +336,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 +} From e30f7f89ab144b14d2a769c0c51c2ae1e850dd2f Mon Sep 17 00:00:00 2001 From: Adam Hotait Date: Wed, 11 Feb 2026 14:37:06 +0100 Subject: [PATCH 2/2] feat(multi-atm): linear accrual --- contracts/token/MultiATM.sol | 136 +++++- test/main.test.js | 774 ++++++++++++++++++++++++++++++++++- 2 files changed, 883 insertions(+), 27 deletions(-) diff --git a/contracts/token/MultiATM.sol b/contracts/token/MultiATM.sol index a5afe37..6e7bb42 100644 --- a/contracts/token/MultiATM.sol +++ b/contracts/token/MultiATM.sol @@ -21,6 +21,8 @@ contract MultiATM is ERC2771Context, PermissionManaged, Multicall { using SafeCast for *; uint256 private constant _BASIS_POINT_SCALE = 1e4; + uint256 private constant _PRECISION = 1e18; + uint8 private constant _MAX_REGRESSION_POINTS = 30; struct Pair { IERC20 token1; @@ -29,6 +31,7 @@ contract MultiATM is ERC2771Context, PermissionManaged, Multicall { uint256 oracleTTL; uint256 numerator; uint256 denominator; + uint8 accrualRounds; } // 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. @@ -66,7 +69,8 @@ contract MultiATM is ERC2771Context, PermissionManaged, Multicall { IERC20 indexed token1, IERC20 indexed token2, Oracle oracle, - uint256 oracleTTL + uint256 oracleTTL, + uint8 accrualRounds ); event PairRemoved(bytes32 indexed id); event FeeUpdated(uint256 newFeeBasisPoints); @@ -75,6 +79,8 @@ contract MultiATM is ERC2771Context, PermissionManaged, Multicall { error OracleValueTooOld(Oracle oracle); error UnknownPair(IERC20 input, IERC20 output); error InvalidFee(uint256 feeBasisPoints); + error InvalidAccrualRounds(uint8 accrualRounds); + error InvalidOracleData(); /// @custom:oz-upgrades-unsafe-allow constructor constructor( @@ -99,13 +105,23 @@ contract MultiATM is ERC2771Context, PermissionManaged, Multicall { Oracle oracle, uint256 oracleTTL, uint256 numerator, - uint256 denominator + uint256 denominator, + uint8 accrualRounds ) { 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, + pair.accrualRounds + ); } function hashPair(IERC20 input, IERC20 output) public view virtual returns (bytes32) { @@ -172,14 +188,20 @@ contract MultiATM is ERC2771Context, PermissionManaged, Multicall { IERC20 output, uint256 inputAmount ) internal view virtual returns (uint256 /*outputAmount*/) { - (, IERC20 token1, , Oracle oracle, uint256 oracleTTL, uint256 numerator, uint256 denominator) = viewPairDetails( - input, - output - ); + ( + , + IERC20 token1, + , + Oracle oracle, + uint256 oracleTTL, + uint256 numerator, + uint256 denominator, + uint8 accrualRounds + ) = viewPairDetails(input, output); require(address(oracle) != address(0), UnknownPair(input, output)); - (int256 minPrice, int256 maxPrice) = _getPrices(oracle, oracleTTL); + (int256 minPrice, int256 maxPrice) = _getPrices(oracle, oracleTTL, accrualRounds); return inputAmount.mulDiv( Math.ternary(input == token1, numerator * minPrice.toUint256(), denominator), @@ -193,14 +215,20 @@ contract MultiATM is ERC2771Context, PermissionManaged, Multicall { IERC20 output, uint256 outputAmount ) internal view virtual returns (uint256 /*inputAmount*/) { - (, IERC20 token1, , Oracle oracle, uint256 oracleTTL, uint256 numerator, uint256 denominator) = viewPairDetails( - input, - output - ); + ( + , + IERC20 token1, + , + Oracle oracle, + uint256 oracleTTL, + uint256 numerator, + uint256 denominator, + uint8 accrualRounds + ) = viewPairDetails(input, output); require(address(oracle) != address(0), UnknownPair(input, output)); - (int256 minPrice, int256 maxPrice) = _getPrices(oracle, oracleTTL); + (int256 minPrice, int256 maxPrice) = _getPrices(oracle, oracleTTL, accrualRounds); return outputAmount.mulDiv( Math.ternary(input == token1, denominator, numerator * maxPrice.toUint256()), @@ -275,12 +303,72 @@ contract MultiATM is ERC2771Context, PermissionManaged, Multicall { 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); + 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( + Oracle oracle, + uint8 n + ) internal view returns (int256 slope, int256 intercept, uint48 baseTimestamp) { + require(n >= 2 && n <= _MAX_REGRESSION_POINTS, InvalidAccrualRounds(n)); + + uint80 latestRoundId; + (latestRoundId, , , , ) = oracle.latestRoundData(); + require(latestRoundId > 0 && latestRoundId + 1 >= n, InvalidOracleData()); + + uint80 startRoundId = latestRoundId + 1 - n; + uint256 baseTs; + (, , baseTs, , ) = oracle.getRoundData(startRoundId); + baseTimestamp = uint48(baseTs); + + int256 sumT; + int256 sumP; + int256 sumTP; + int256 sumT2; + + for (uint8 i = 0; i < n; i++) { + (, int256 price, uint256 timestamp, , ) = oracle.getRoundData(startRoundId + i); + int256 t = (timestamp - baseTs).toInt256(); + sumT += t; + sumP += price; + sumTP += t * price; + sumT2 += t * t; + } + + int256 nInt = int256(uint256(n)); + int256 denominator = nInt * sumT2 - sumT * sumT; + + require(denominator > 0, InvalidOracleData()); + + slope = _computeSlope(nInt * sumTP - sumT * sumP, denominator); + intercept = (sumP - (slope * sumT) / int256(_PRECISION)) / nInt; + } + + function _getPrices( + Oracle oracle, + uint256 oracleTTL, + uint8 accrualRounds + ) internal view virtual returns (int256 min, int256 max) { + (uint80 roundId, int256 latest, , uint256 updatedAt, ) = oracle.latestRoundData(); require(block.timestamp < updatedAt + oracleTTL, OracleValueTooOld(oracle)); - min = SignedMath.min(latest, previous); - max = SignedMath.max(latest, previous); + + if (accrualRounds == 0) { + require(roundId >= 1, InvalidOracleData()); + (, int256 previous, , , ) = oracle.getRoundData(roundId - 1); + min = SignedMath.min(latest, previous); + max = SignedMath.max(latest, previous); + } else { + (int256 slope, int256 intercept, uint48 baseTimestamp) = _computeLinearRegression(oracle, accrualRounds); + + // price = slope * (currentTime - baseTimestamp) / _PRECISION + intercept + uint256 absDeltaPrice = Math.mulDiv(SignedMath.abs(slope), block.timestamp - baseTimestamp, _PRECISION); + min = intercept + (slope < 0 ? -absDeltaPrice.toInt256() : absDeltaPrice.toInt256()); + require(min > 0, InvalidOracleData()); + max = min; + } } /**************************************************************************************************************** @@ -290,19 +378,25 @@ contract MultiATM is ERC2771Context, PermissionManaged, Multicall { IERC20Metadata token1, IERC20Metadata token2, Oracle oracle, - uint256 oracleTTL + uint256 oracleTTL, + uint8 accrualRounds ) public virtual restricted { bytes32 id = hashPair(token1, token2); + require( + accrualRounds == 0 || (accrualRounds >= 2 && accrualRounds <= _MAX_REGRESSION_POINTS), + InvalidAccrualRounds(accrualRounds) + ); _pairs[id] = Pair({ token1: token1, token2: token2, oracle: oracle, oracleTTL: oracleTTL, numerator: 10 ** token2.decimals(), - denominator: 10 ** (token1.decimals() + oracle.decimals()) + denominator: 10 ** (token1.decimals() + oracle.decimals()), + accrualRounds: accrualRounds }); - emit PairUpdated(id, token1, token2, oracle, oracleTTL); + emit PairUpdated(id, token1, token2, oracle, oracleTTL, accrualRounds); } function removePair(IERC20 token1, IERC20 token2) public virtual restricted { diff --git a/test/main.test.js b/test/main.test.js index 858b31c..dd7cab3 100644 --- a/test/main.test.js +++ b/test/main.test.js @@ -1581,10 +1581,10 @@ describe('Main', function () { this.id ); await expect( - this.contracts.atm.setPair(this.contracts.token, this.contracts.stable, this.contracts.oracle, oraclettl) + this.contracts.atm.setPair(this.contracts.token, this.contracts.stable, this.contracts.oracle, oraclettl, 0) ) .to.emit(this.contracts.atm, 'PairUpdated') - .withArgs(this.id, this.contracts.token, this.contracts.stable, this.contracts.oracle, oraclettl); + .withArgs(this.id, this.contracts.token, this.contracts.stable, this.contracts.oracle, oraclettl, 0); /// mint and approve await this.contracts.token.mint(this.contracts.atm, formatToken('100')); @@ -1604,6 +1604,7 @@ describe('Main', function () { oraclettl, numFactor, denFactor, + 0n, ]); expect(await this.contracts.atm.viewPairDetails(this.contracts.stable, this.contracts.token)).to.deep.equal([ this.id, @@ -1613,6 +1614,7 @@ describe('Main', function () { oraclettl, numFactor, denFactor, + 0n, ]); }); @@ -1808,7 +1810,7 @@ describe('Main', function () { }); it('oracle not updated recently', async function () { - await time.increase(oraclettl); + await time.increase(oraclettl + 3601); await expect( this.contracts.atm.previewExactInputSingle(this.contracts.stable, this.contracts.token, 0) ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); @@ -2166,7 +2168,7 @@ describe('Main', function () { }); it('oracle not updated recently', async function () { - await time.increase(oraclettl); + await time.increase(oraclettl + 3601); await expect( this.contracts.atm.previewExactInputSingle(this.contracts.stable, this.contracts.token, 0) ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); @@ -2522,7 +2524,7 @@ describe('Main', function () { }); it('oracle not updated recently', async function () { - await time.increase(oraclettl); + await time.increase(oraclettl + 3601); await expect( this.contracts.atm.previewExactOutputSingle(this.contracts.stable, this.contracts.token, 0) ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); @@ -2886,7 +2888,7 @@ describe('Main', function () { }); it('oracle not updated recently', async function () { - await time.increase(oraclettl); + await time.increase(oraclettl + 3601); await expect( this.contracts.atm.previewExactOutputSingle(this.contracts.stable, this.contracts.token, 0) ).to.be.revertedWithCustomError(this.contracts.atm, 'OracleValueTooOld'); @@ -3151,6 +3153,766 @@ describe('Main', function () { }); }); } + + describe('with accrual (linear regression 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('MultiATM', [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 validation', function () { + it('rejects accrualRounds = 1 (invalid)', async function () { + await expect( + this.contracts.atm.setPair(this.contracts.token, this.contracts.stable, this.contracts.oracle, oraclettl, 1) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'InvalidAccrualRounds') + .withArgs(1); + }); + + it('rejects accrualRounds > 30 (max exceeded)', async function () { + await expect( + this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + 31 + ) + ) + .to.be.revertedWithCustomError(this.contracts.atm, 'InvalidAccrualRounds') + .withArgs(31); + }); + + it('accepts accrualRounds = 0 (disabled)', async function () { + await expect( + this.contracts.atm.setPair(this.contracts.token, this.contracts.stable, this.contracts.oracle, oraclettl, 0) + ).to.emit(this.contracts.atm, 'PairUpdated'); + + const details = await this.contracts.atm.viewPairDetails(this.contracts.token, this.contracts.stable); + expect(details.accrualRounds).to.equal(0n); + }); + + it('accepts accrualRounds = 2 (minimum)', 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, 2) + ).to.emit(this.contracts.atm, 'PairUpdated'); + + const details = await this.contracts.atm.viewPairDetails(this.contracts.token, this.contracts.stable); + expect(details.accrualRounds).to.equal(2n); + }); + + it('accepts accrualRounds = 30 (maximum)', async function () { + const timestamp = await time.latest(); + for (let i = 0; i < 30; i++) { + await this.contracts.oracle.publishPrice(timestamp - (30 - i) * 3600, ethers.parseUnits('2.0', 6)); + } + + await expect( + this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + 30 + ) + ).to.emit(this.contracts.atm, 'PairUpdated'); + + const details = await this.contracts.atm.viewPairDetails(this.contracts.token, this.contracts.stable); + expect(details.accrualRounds).to.equal(30n); + }); + }); + + describe('linear regression with insufficient oracle data', function () { + beforeEach(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)); + }); + + it('reverts when oracle has fewer rounds than required', async function () { + await this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + 5 + ); + + await expect( + this.contracts.atm.previewExactInputSingle(this.contracts.stable, this.contracts.token, formatStable('1.0')) + ).to.be.revertedWithCustomError(this.contracts.atm, 'InvalidOracleData'); + }); + }); + + describe('price calculation with linear regression', function () { + // Hardcoded expected values computed independently: + // - Prices at T=16200 (baseTs + 16200, i.e. 30 min after last price point) + // - Token: 5 decimals, Stable: 6 decimals, Oracle: 6 decimals + // - numFactor = 10^6, denFactor = 10^11 + // - tokenInput = 100000 (1.0 token), stableInput = 10000000 (10.0 stable) + const REGRESSION_EXPECTED = { + positive: { + // Prices: [2.00, 2.05, 2.10, 2.15, 2.20] -> slope=13888888888888888888, intercept=2000000 + priceAt16200: 2224999n, + sellOutput: 2224999n, // sell 1 token + buyOutput: 449438n, // buy with 10 stable + }, + negative: { + // Prices: [2.20, 2.15, 2.10, 2.05, 2.00] -> slope=-13888888888888888888, intercept=2199999 + priceAt16200: 1975000n, + sellOutput: 1975000n, + buyOutput: 506329n, + }, + flat: { + // Prices: [2.10, 2.10, 2.10, 2.10, 2.10] -> slope=0, intercept=2100000 + priceAt16200: 2100000n, + sellOutput: 2100000n, + buyOutput: 476190n, + }, + }; + + describe('with positive slope (increasing prices)', function () { + beforeEach(async function () { + const timestamp = await time.latest(); + this.baseTimestamp = BigInt(timestamp - 4 * 3600); + this.prices = [ + ethers.parseUnits('2.00', 6), + ethers.parseUnits('2.05', 6), + ethers.parseUnits('2.10', 6), + ethers.parseUnits('2.15', 6), + ethers.parseUnits('2.20', 6), + ]; + this.timestamps = [ + this.baseTimestamp, + this.baseTimestamp + 3600n, + this.baseTimestamp + 7200n, + this.baseTimestamp + 10800n, + this.baseTimestamp + 14400n, + ]; + + for (let i = 0; i < this.prices.length; i++) { + await this.contracts.oracle.publishPrice(this.timestamps[i], this.prices[i]); + } + + await this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + 5 + ); + + // Store swap timestamp for hardcoded value verification + this.swapTimestamp = this.baseTimestamp + 16200n; + }); + + it('computes extrapolated price for token sale', async function () { + // Use increaseTo to advance block.timestamp for view 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 - 4 * 3600); + this.prices = [ + ethers.parseUnits('2.20', 6), + ethers.parseUnits('2.15', 6), + ethers.parseUnits('2.10', 6), + ethers.parseUnits('2.05', 6), + ethers.parseUnits('2.00', 6), + ]; + this.timestamps = [ + this.baseTimestamp, + this.baseTimestamp + 3600n, + this.baseTimestamp + 7200n, + this.baseTimestamp + 10800n, + this.baseTimestamp + 14400n, + ]; + + for (let i = 0; i < this.prices.length; i++) { + await this.contracts.oracle.publishPrice(this.timestamps[i], this.prices[i]); + } + + await this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + 5 + ); + + this.swapTimestamp = this.baseTimestamp + 16200n; + }); + + 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 - 4 * 3600); + this.price = ethers.parseUnits('2.10', 6); + this.prices = [this.price, this.price, this.price, this.price, this.price]; + this.timestamps = [ + this.baseTimestamp, + this.baseTimestamp + 3600n, + this.baseTimestamp + 7200n, + this.baseTimestamp + 10800n, + this.baseTimestamp + 14400n, + ]; + + for (let i = 0; i < this.prices.length; i++) { + await this.contracts.oracle.publishPrice(this.timestamps[i], this.prices[i]); + } + + await this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + 5 + ); + + this.swapTimestamp = this.baseTimestamp + 16200n; + }); + + 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 accrual pricing', function () { + beforeEach(async function () { + const timestamp = await time.latest(); + this.baseTimestamp = BigInt(timestamp - 4 * 3600); + // Increasing prices + this.prices = [ + ethers.parseUnits('2.00', 6), + ethers.parseUnits('2.02', 6), + ethers.parseUnits('2.04', 6), + ethers.parseUnits('2.06', 6), + ethers.parseUnits('2.08', 6), + ]; + this.timestamps = [ + this.baseTimestamp, + this.baseTimestamp + 3600n, + this.baseTimestamp + 7200n, + this.baseTimestamp + 10800n, + this.baseTimestamp + 14400n, + ]; + + for (let i = 0; i < this.prices.length; i++) { + await this.contracts.oracle.publishPrice(this.timestamps[i], this.prices[i]); + } + + await this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + 5 + ); + }); + + 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: accrual vs non-accrual 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-accrual uses min/max of last 2 prices', async function () { + await this.contracts.atm.setPair( + this.contracts.token, + this.contracts.stable, + this.contracts.oracle, + oraclettl, + 0 // non-accrual + ); + + // 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('accrual 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, + 2 // accrual with 2 rounds (uses last 2 prices for regression) + ); + + 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); + }); + }); + }); + + // Hardcoded expected values for rigorous testing + // These values were computed independently using the linear regression formula: + // Prices: [2.00, 2.08, 2.05, 2.12, 2.10] at t=[0, 3600, 7200, 10800, 14400] + // Linear regression gives: slope=6666666666666666666 (per 1e18), intercept=2022000 + // At swapTime (baseTs + 16200): price = 2129999 + // + // Non-accrual would use min/max of last 2 prices: min=2100000, max=2120000 + // Accrual price (2129999) differs from both, proving linear extrapolation works + const HARDCODED_EXPECTED = { + '6_6': { + sellOutput: 2129999n, + buyOutput: 4694837n, + exactInput: 2347419n, + nonAccrualSellOutput: 2100000n, + }, + '6_18': { + sellOutput: 2129999000000000000n, + buyOutput: 4694837n, + exactInput: 2347419n, + nonAccrualSellOutput: 2100000000000000000n, + }, + '18_6': { + sellOutput: 2129999n, + buyOutput: 4694837884900415446n, + exactInput: 2347418942450207724n, + nonAccrualSellOutput: 2100000n, + }, + '18_18': { + sellOutput: 2129999000000000000n, + buyOutput: 4694837884900415446n, + exactInput: 2347418942450207724n, + nonAccrualSellOutput: 2100000000000000000n, + }, + }; + + // Test accrual 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 accrual - 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('MultiATM', [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 - 4 * 3600); + this.timestamps = [ + this.baseTimestamp, + this.baseTimestamp + 3600n, + this.baseTimestamp + 7200n, + this.baseTimestamp + 10800n, + this.baseTimestamp + 14400n, + ]; + // Prices that produce: slope=6666666666666666666, intercept=2022000 + this.prices = [ + ethers.parseUnits('2.00', 6), // t=0 + ethers.parseUnits('2.08', 6), // t=3600 + ethers.parseUnits('2.05', 6), // t=7200 + ethers.parseUnits('2.12', 6), // t=10800 + ethers.parseUnits('2.10', 6), // t=14400 + ]; + + for (let i = 0; i < this.prices.length; i++) { + await this.contracts.oracle.publishPrice(this.timestamps[i], this.prices[i]); + } + + // Store swap timestamp for tests: baseTimestamp + 16200 (30 min after last price) + this.swapTimestamp = this.baseTimestamp + 16200n; + }); + + it('setPair correctly sets numerator/denominator for decimal scaling', async function () { + await this.testAtm.setPair(this.testToken, this.testStable, this.contracts.oracle, oraclettl, 5); + + const details = await this.testAtm.viewPairDetails(this.testToken, this.testStable); + expect(details.numerator).to.equal(numFactor); + expect(details.denominator).to.equal(denFactor); + expect(details.accrualRounds).to.equal(5n); + }); + + it('swap token for stable with hardcoded expected output', async function () { + await this.testAtm.setPair(this.testToken, this.testStable, this.contracts.oracle, oraclettl, 5); + + 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 accrual price differs from non-accrual 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, 5); + + 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, 5); + + 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 () {