diff --git a/src/contracts/adapters/BlackholeV1Adapter.sol b/src/contracts/adapters/BlackholeV1Adapter.sol new file mode 100644 index 0000000..5394836 --- /dev/null +++ b/src/contracts/adapters/BlackholeV1Adapter.sol @@ -0,0 +1,143 @@ +// ╟╗ ╔╬ +// ╞╬╬ ╬╠╬ +// ╔╣╬╬╬ ╠╠╠╠╦ +// ╬╬╬╬╬╩ ╘╠╠╠╠╬ +// ║╬╬╬╬╬ ╘╠╠╠╠╬ +// ╣╬╬╬╬╬╬╬╬╬╬╬╬╬╬╬ ╒╬╬╬╬╬╬╬╜ ╠╠╬╬╬╬╬╬╬ ╠╬╬╬╬╬╬╬ ╬╬╬╬╬╬╬╬╠╠╠╠╠╠╠╠ +// ╙╬╬╬╬╬╬╬╬╬╬╬╬╬╬╬╬╕ ╬╬╬╬╬╬╬╜ ╣╠╠╬╬╬╬╬╬╬╬ ╠╬╬╬╬╬╬╬ ╬╬╬╬╬╬╬╬╬╠╠╠╠╠╠╠╩ +// ╙╣╬╬╬╬╬╬╬╬╬╬╬╬╬╬╬ ╔╬╬╬╬╬╬╬ ╔╠╠╠╬╬╬╬╬╬╬╬ ╠╬╬╬╬╬╬╬ ╣╬╬╬╬╬╬╬╬╬╬╬╠╠╠╠╝╙ +// ╘╣╬╬╬╬╬╬╬╬╬╬╬╬╬╬ ╒╠╠╠╬╠╬╩╬╬╬╬╬╬ ╠╬╬╬╬╬╬╬╣╬╬╬╬╬╬╬╙ +// ╣╬╬╬╬╬╬╬╬╬╬╠╣ ╣╬╠╠╠╬╩ ╚╬╬╬╬╬╬ ╠╬╬╬╬╬╬╬╬╬╬╬╬╬╬ +// ╣╬╬╬╬╬╬╬╬╬╣ ╣╬╠╠╠╬╬ ╣╬╬╬╬╬╬ ╠╬╬╬╬╬╬╬╬╬╬╬╬╬╬ +// ╟╬╬╬╬╬╬╬╩ ╬╬╠╠╠╠╬╬╬╬╬╬╬╬╬╬╬ ╠╬╬╬╬╬╬╬╠╬╬╬╬╬╬╬ +// ╬╬╬╬╬╬╬ ╒╬╬╠╠╬╠╠╬╬╬╬╬╬╬╬╬╬╬╬ ╠╬╬╬╬╬╬╬ ╣╬╬╬╬╬╬╬ +// ╬╬╬╬╬╬╬ ╬╬╬╠╠╠╠╝╝╝╝╝╝╝╠╬╬╬╬╬╬ ╠╬╬╬╬╬╬╬ ╚╬╬╬╬╬╬╬╬ +// ╬╬╬╬╬╬╬ ╣╬╬╬╬╠╠╩ ╘╬╬╬╬╬╬╬ ╠╬╬╬╬╬╬╬ ╙╬╬╬╬╬╬╬╬ +// + +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.0; + +import "../interface/IERC20.sol"; +import "../lib/SafeERC20.sol"; +import "../YakAdapter.sol"; + +interface IPairFactory { + function isPair(address) external view returns (bool); + function getFee(address _pairAddress, bool _stable) external view returns(uint256); + function pairCodeHash() external view returns (bytes32); + function isGenesis(address pair) external view returns (bool); + function getPair(address tokenA, address token, bool stable) external view returns (address); +} + +interface IPair { + function getAmountOut(uint256, address) external view returns (uint256); + function metadata() external view returns + (uint dec0, uint dec1, uint r0, uint r1, bool st, address t0, address t1); + + function swap( + uint256, + uint256, + address, + bytes calldata + ) external; +} + +contract BlackholeV1Adapter is YakAdapter { + using SafeERC20 for IERC20; + struct PairSwapMetadata { + uint decimals0; + uint decimals1; + uint reserve0; + uint reserve1; + bool stable; + address token0; + address token1; + uint balanceA; + uint balanceB; + uint reserveA; + uint reserveB; + uint decimalsA; + uint decimalsB; + } + bytes32 immutable PAIR_CODE_HASH; + address immutable FACTORY; + + constructor( + string memory _name, + address _factory, + uint256 _swapGasEstimate + ) YakAdapter(_name, _swapGasEstimate) { + FACTORY = _factory; + PAIR_CODE_HASH = getPairCodeHash(_factory); + } + + function getPairCodeHash(address _factory) internal view returns (bytes32) { + return IPairFactory(_factory).pairCodeHash(); + } + + function sortTokens(address tokenA, address tokenB) internal pure returns (address token0, address token1) { + (token0, token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA); + } + + // calculates the CREATE2 address for a pair without making any external calls + function pairFor(address tokenA, address tokenB, bool stable) public view returns (address pair) { + (address token0, address token1) = sortTokens(tokenA, tokenB); + return IPairFactory(FACTORY).getPair(token0, token1, stable); + } + + function _getAmoutOutSafe(address pair, uint amountIn, address tokenIn) internal view returns (uint) { + try IPair(pair).getAmountOut(amountIn, tokenIn) returns (uint amountOut) { + return amountOut; + } catch { + return 0; + } + } + + function getQuoteAndPair( + uint256 _amountIn, + address _tokenIn, + address _tokenOut + ) internal view returns (uint256 amountOut, address pair) { + address pairStable = pairFor(_tokenIn, _tokenOut, true); + address pairVolatile = pairFor(_tokenIn, _tokenOut, false); + uint amountStable; + uint amountVolatile; + if (IPairFactory(FACTORY).isPair(pairStable) && !IPairFactory(FACTORY).isGenesis(pairStable)) { + amountStable = _getAmoutOutSafe(pairStable, _amountIn, _tokenIn); + } + if (IPairFactory(FACTORY).isPair(pairVolatile) && !IPairFactory(FACTORY).isGenesis(pairVolatile)) { + amountVolatile = _getAmoutOutSafe(pairVolatile, _amountIn, _tokenIn); + } + (amountOut, pair) = amountStable > amountVolatile ? (amountStable, pairStable) : + (amountVolatile, pairVolatile); + if (pair == address(0)) { + return (0, address(0)); + } + return (amountOut, pair); + } + + function _query( + uint256 _amountIn, + address _tokenIn, + address _tokenOut + ) internal view override returns (uint256 amountOut) { + if (_tokenIn != _tokenOut && _amountIn != 0) (amountOut, ) = getQuoteAndPair(_amountIn, _tokenIn, _tokenOut); + } + + function _swap( + uint256 _amountIn, + uint256 _amountOut, + address _tokenIn, + address _tokenOut, + address to + ) internal override { + (uint256 amountOut, address pair) = getQuoteAndPair(_amountIn, _tokenIn, _tokenOut); + require(amountOut >= _amountOut, "Insufficent amount out"); + (uint256 amount0Out, uint256 amount1Out) = (_tokenIn < _tokenOut) + ? (uint256(0), amountOut) + : (amountOut, uint256(0)); + IERC20(_tokenIn).safeTransfer(pair, _amountIn); + IPair(pair).swap(amount0Out, amount1Out, to, new bytes(0)); + } +} diff --git a/src/deploy/avalanche/adapters/blackhole/blackhole.js b/src/deploy/avalanche/adapters/blackhole/blackhole.js new file mode 100644 index 0000000..f949875 --- /dev/null +++ b/src/deploy/avalanche/adapters/blackhole/blackhole.js @@ -0,0 +1,11 @@ +const { deployAdapter, addresses } = require('../../../utils') +const { factory } = addresses.avalanche.blackhole + +const networkName = 'avalanche' +const contractName = 'BlackholeV1Adapter' +const tags = [ 'blackhole' ] +const name = 'BlackholeV1Adapter' +const gasEstimate = 340_000 +const args = [ name, factory, gasEstimate ] + +module.exports = deployAdapter(networkName, tags, name, contractName, args) \ No newline at end of file diff --git a/src/misc/addresses.json b/src/misc/addresses.json index 44c3206..1e74960 100644 --- a/src/misc/addresses.json +++ b/src/misc/addresses.json @@ -274,7 +274,8 @@ "BONER": "0x32f3Fb90112bA4CAb66c24aA75ACbe0182A5d50B", "ARENA": "0xB8d7710f7d8349A506b75dD184F05777c82dAd0C", "BOIL": "0x0A9a9e0A695F52502CdDF7A59c880f4bDf2f0548", - "ID": "0x34a528Da3b2EA5c6Ad1796Eba756445D1299a577" + "ID": "0x34a528Da3b2EA5c6Ad1796Eba756445D1299a577", + "BLACK": "0xcd94a87696fac69edae3a70fe5725307ae1c43f6" }, "unilikeRouters": { "zero": "0x85995d5f8ee9645cA855e92de16FA62D26398060", @@ -451,6 +452,9 @@ "pharaoh": { "quoter": "0xc7d4412aa74c655B2e6e71bB6790d24AC90E393C", "factory": "0xAAA32926fcE6bE95ea2c51cB4Fcb60836D320C42" + }, + "blackhole": { + "factory": "0xfE926062Fb99CA5653080d6C14fE945Ad68c265C" } }, "dogechain": { diff --git a/src/test/spec/avalanche/adapters/blackhole.spec.js b/src/test/spec/avalanche/adapters/blackhole.spec.js new file mode 100644 index 0000000..2dbd109 --- /dev/null +++ b/src/test/spec/avalanche/adapters/blackhole.spec.js @@ -0,0 +1,74 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); +const { setTestEnv, addresses } = require("../../../utils/test-env"); +const { blackhole } = addresses.avalanche; + +describe("YakAdapter - Blackhole", function () { + let testEnv; + let tkns; + let ate; + + before(async () => { + const networkName = "avalanche"; + testEnv = await setTestEnv(networkName); + tkns = testEnv.supportedTkns; + + const contractName = "BlackholeV1Adapter"; + const gasEstimate = 340_000; + const adapterArgs = [contractName, blackhole.factory, gasEstimate]; + ate = await testEnv.setAdapterEnv(contractName, adapterArgs); + }); + + beforeEach(async () => { + testEnv.updateTrader(); + }); + + describe("Swapping matches query", () => { + it("50 EURC -> USDC", async () => { + const amountIn = "50"; + const tokenIn = tkns.EURC; + const tokenOut = tkns.USDC; + + const rawQuote = await ate.Adapter.query( + ethers.utils.parseUnits(amountIn, 6), + tokenIn.address, + tokenOut.address + ); + + console.log(`💱 Quote for swapping ${amountIn} EURC to USDC: ${ethers.utils.formatUnits(rawQuote, 6)} USDC`); + + expect(rawQuote).to.be.gt(0, "Expected a non-zero quote"); + + await ate.checkSwapMatchesQuery(amountIn, tokenIn, tokenOut); + }); + }); + + it("Query returns zero if tokens not found", async () => { + await ate.checkQueryReturnsZeroForUnsupportedTkns(tkns.EURC); + }); + + it("Swapping too much returns zero", async () => { + const dy = await ate.Adapter.query( + ethers.utils.parseUnits("10000", 18), + tkns.EURC.address, + tkns.USDC.address + ); + expect(dy).to.eq(0); + }); + + it("Adapter can only spend max-gas + buffer", async () => { + const gasBuffer = ethers.BigNumber.from("70000"); + const gasLimit = ethers.BigNumber.from(await ate.Adapter.swapGasEstimate()); + const gasUsed = await ate.Adapter.estimateGas.query( + ethers.utils.parseUnits("2000", 6), + tkns.EURC.address, + tkns.USDC.address + ); + expect(gasUsed).to.lt(gasLimit.add(gasBuffer)); + }); + + it("Gas-estimate is between max-gas-used and 110% max-gas-used", async () => { + const options = [["10", tkns.EURC, tkns.USDC]]; + await ate.checkGasEstimateIsSensible(options); + }); +});