From 7622d1b967b930f1b5fa70bef3ec8212a22faf03 Mon Sep 17 00:00:00 2001 From: Adam Hotait Date: Thu, 26 Feb 2026 19:18:22 +0100 Subject: [PATCH] feat(multi-atm): linear accrual interpolated --- .../token/MultiATMLinearInterpolated.sol | 398 +++ test/main.test.js | 2394 +++++++++++++++++ 2 files changed, 2792 insertions(+) create mode 100644 contracts/token/MultiATMLinearInterpolated.sol diff --git a/contracts/token/MultiATMLinearInterpolated.sol b/contracts/token/MultiATMLinearInterpolated.sol new file mode 100644 index 0000000..9a9b0b0 --- /dev/null +++ b/contracts/token/MultiATMLinearInterpolated.sol @@ -0,0 +1,398 @@ +// 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 MultiATMLinearInterpolated is ERC2771Context, PermissionManaged, Multicall { + using Math for *; + 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; + IERC20 token2; + Oracle oracle; + 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. + // + // 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, + uint8 accrualRounds + ); + 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 InvalidAccrualRounds(uint8 accrualRounds); + 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, + 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, + pair.accrualRounds + ); + } + + 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, + uint8 accrualRounds + ) = viewPairDetails(input, output); + + require(address(oracle) != address(0), UnknownPair(input, output)); + + (int256 minPrice, int256 maxPrice) = _getPrices(oracle, oracleTTL, accrualRounds); + 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, + uint8 accrualRounds + ) = viewPairDetails(input, output); + + require(address(oracle) != address(0), UnknownPair(input, output)); + + (int256 minPrice, int256 maxPrice) = _getPrices(oracle, oracleTTL, accrualRounds); + 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 _getPrices( + Oracle oracle, + uint256 oracleTTL, + uint256 accrualRounds + ) internal view virtual returns (int256 min, int256 max) { + (uint80 roundId, int256 latest, , uint256 updatedAt, ) = oracle.latestRoundData(); + require(roundId + 1 >= accrualRounds, InvalidOracleData()); + require(block.timestamp < updatedAt + oracleTTL, OracleValueTooOld(oracle)); + if (accrualRounds == 0) { + (, int256 previous, , , ) = oracle.getRoundData(roundId - 1); + min = SignedMath.min(latest, previous); + max = SignedMath.max(latest, previous); + } else { + int256 sumT = 0; + int256 sumP = 0; + int256 sumTT = 0; + int256 sumTP = 0; + for (uint256 currentRound = roundId + 1 - accrualRounds; currentRound <= roundId; ++currentRound) { + (, latest, , updatedAt, ) = oracle.getRoundData(currentRound.toUint80()); + sumT += updatedAt.toInt256(); + sumP += latest; + sumTT += updatedAt.toInt256() * updatedAt.toInt256(); + sumTP += updatedAt.toInt256() * latest; + } + min = accrualRounds.toInt256(); + max = min * sumTP - sumT * sumP; + latest = min * sumTT - sumT * sumT; + require(latest > 0, InvalidOracleData()); + min = max = (sumP - (sumT * max) / latest) / min + (block.timestamp.toInt256() * max) / latest; + require(min > 0, InvalidOracleData()); + } + } + + /**************************************************************************************************************** + * Admin actions * + ****************************************************************************************************************/ + function setPair( + IERC20Metadata token1, + IERC20Metadata token2, + Oracle oracle, + 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()), + accrualRounds: accrualRounds + }); + + emit PairUpdated(id, token1, token2, oracle, oracleTTL, accrualRounds); + } + + 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 82a87b9..bce184b 100644 --- a/test/main.test.js +++ b/test/main.test.js @@ -5435,6 +5435,2400 @@ describe('Main', function () { } }); + describe('MultiATMLinearInterpolated', 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('MultiATMLinearInterpolated', [ + 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, 0) + ) + .to.emit(this.contracts.atm, 'PairUpdated') + .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')); + 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, + 0n, + ]); + 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, + 0n, + ]); + }); + + 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); + 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); + 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); + 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); + 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 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('MultiATMLinearInterpolated', [ + 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: 2225000n, + sellOutput: 2225000n, // 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 = 2130000 + // + // Non-accrual would use min/max of last 2 prices: min=2100000, max=2120000 + // Accrual price (2130000) differs from both, proving linear extrapolation works + const HARDCODED_EXPECTED = { + '6_6': { + sellOutput: 2130000n, + buyOutput: 4694835n, + exactInput: 2347418n, + nonAccrualSellOutput: 2100000n, + }, + '6_18': { + sellOutput: 2130000000000000000n, + buyOutput: 4694835n, + exactInput: 2347418n, + nonAccrualSellOutput: 2100000000000000000n, + }, + '18_6': { + sellOutput: 2130000n, + buyOutput: 4694835680751173708n, + exactInput: 2347417840375586855n, + nonAccrualSellOutput: 2100000n, + }, + '18_18': { + sellOutput: 2130000000000000000n, + buyOutput: 4694835680751173708n, + exactInput: 2347417840375586855n, + 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('MultiATMLinearInterpolated', [ + 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 () { const { address: caller } = ethers.Wallet.createRandom(); const { address: target } = ethers.Wallet.createRandom();