diff --git a/.gitignore b/.gitignore index 5a40c04..ca2ff34 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,4 @@ dmypy.json # env files *.env *.envrc +.idea/ diff --git a/Makefile b/Makefile index 570aee4..f959125 100644 --- a/Makefile +++ b/Makefile @@ -19,4 +19,4 @@ test: ENV=test poetry run python3 -m pytest -v --cov=huma_signals --color=yes --cov-report term-missing run-local: - ENV=development poetry run python3 -m uvicorn huma_signals.api.main:app --reload + ENV=development poetry run python3 -m uvicorn huma_signals.api.main:app --reload --port 8001 diff --git a/huma_signals/adapters/allowlist/README.md b/huma_signals/adapters/allowlist/README.md new file mode 100644 index 0000000..0a55839 --- /dev/null +++ b/huma_signals/adapters/allowlist/README.md @@ -0,0 +1,30 @@ +# Huma Lending Pool Signal Adapter + +This is the repository for the Signal Adapter that fetch signal from Huma lending pool. + +## Type of signals + +- on-chain pool setting +- pool's EA (underwriter) settings +- pool liquidity +- pool utilization + +## Local Development + +See [here](../../../docs/getting_started.md) for the development guide. + +## Required environment variable + +The following environment variable is required to run the adapter + +```bash +WEB3_PROVIDER_URL +``` + +You can get Alchemy keys [here](https://docs.alchemy.com/docs/alchemy-quickstart-guide). + +## Tests + +``` +make test +``` diff --git a/huma_signals/adapters/allowlist_contract/README.md b/huma_signals/adapters/allowlist_contract/README.md new file mode 100644 index 0000000..0bbe8d3 --- /dev/null +++ b/huma_signals/adapters/allowlist_contract/README.md @@ -0,0 +1,34 @@ +# Huma Lending Pool Signal Adapter + +This determines whether a borrower is authorized to +borrow from a lending pool given a contract address that implements the following function: +```typescript +function getWhitelistedBorrowers() public view returns (address[] memory); +``` + +## Type of signals + +- if borrower is in the set of authorized borrower addresses + +## Local Development + +See [here](../../../docs/getting_started.md) for the development guide. + +## Required environment variable + +The following environment variable is required to run the adapter + +```bash +WEB3_PROVIDER_URL +ETHERSCAN_API_KEY +ETHERSCAN_BASE_URL +``` +Keep in mind that the contract abi is pulled from Etherscan. + +You can get Alchemy keys [here](https://docs.alchemy.com/docs/alchemy-quickstart-guide). + +## Tests + +``` +make test +``` diff --git a/huma_signals/adapters/allowlist_contract/__init__.py b/huma_signals/adapters/allowlist_contract/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/huma_signals/adapters/allowlist_contract/abi/YourContract.json b/huma_signals/adapters/allowlist_contract/abi/YourContract.json new file mode 100644 index 0000000..d9b60c5 --- /dev/null +++ b/huma_signals/adapters/allowlist_contract/abi/YourContract.json @@ -0,0 +1,158 @@ +[ + { + "inputs": [ + { + "internalType": "address[]", + "name": "_owners", + "type": "address[]" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "greetingSetter", + "type": "address" + }, + { + "indexed": false, + "internalType": "string", + "name": "newGreeting", + "type": "string" + }, + { + "indexed": false, + "internalType": "bool", + "name": "premium", + "type": "bool" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "GreetingChange", + "type": "event" + }, + { + "inputs": [], + "name": "getWhitelistedBorrowers", + "outputs": [ + { + "internalType": "address[]", + "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "greeting", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "owners", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "premium", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "_newGreeting", + "type": "string" + } + ], + "name": "setGreeting", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "totalCounter", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "userGreetingCounter", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "withdraw", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "stateMutability": "payable", + "type": "receive" + } + ] \ No newline at end of file diff --git a/huma_signals/adapters/allowlist_contract/adapter.py b/huma_signals/adapters/allowlist_contract/adapter.py new file mode 100644 index 0000000..75f801e --- /dev/null +++ b/huma_signals/adapters/allowlist_contract/adapter.py @@ -0,0 +1,56 @@ +import pathlib +import aiofiles + +import orjson +import pydantic +import web3 +from typing import Any, ClassVar, List + +from huma_signals import models +from huma_signals.adapters import models as adapter_models +from huma_signals.adapters.utils.etherscan_client import EtherscanClient +from huma_signals.commons import web3_utils +from huma_signals.commons.chains import Chain +from huma_signals.settings import settings + + +class AllowListSignal(models.HumaBaseModel): + """ + Signals emitted by the allowlist adapter. + + Note this is a temporary adapter and will be removed once the allowlist is integrated into the EAs. + """ + + on_allowlist: bool = False + +class AllowListContractAdapter(adapter_models.SignalAdapterBase): + name: ClassVar[str] = "allowlist_contract" + required_inputs: ClassVar[List[str]] = ["contract_address", "chain", "borrower_wallet_address"] + signals: ClassVar[List[str]] = list(AllowListSignal.__fields__.keys()) + etherscan_client: EtherscanClient = pydantic.Field(default=EtherscanClient()) + + + async def fetch( # pylint: disable=arguments-differ + self, contract_address: str, chain: str, borrower_wallet_address: str, *args: Any, **kwargs: Any + ) -> AllowListSignal: + chain_obj = Chain.from_chain_name(chain) + w3 = await web3_utils.get_w3(chain_obj, settings.web3_provider_url) + + abi = await self._get_abi(chain_obj) + allow_list_contract = w3.eth.contract( + address=web3.Web3.to_checksum_address(contract_address), + abi=orjson.loads(abi), + ) + + whitelisted_borrowers = await allow_list_contract.functions.getWhitelistedBorrowers().call() + return AllowListSignal( + on_allowlist=(borrower_wallet_address in whitelisted_borrowers) + ) + + async def _get_abi(self, chain_obj): + if chain_obj == Chain.LOCAL: + async with aiofiles.open(pathlib.Path(__file__).parent.resolve() / "abi" / "YourContract.json", + encoding="utf-8") as f: + return await f.read() + else: + return await self.etherscan_client.get_contract_abi("0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413") diff --git a/huma_signals/adapters/registry.py b/huma_signals/adapters/registry.py index 6211718..709b839 100644 --- a/huma_signals/adapters/registry.py +++ b/huma_signals/adapters/registry.py @@ -2,6 +2,7 @@ from huma_signals.adapters import models from huma_signals.adapters.allowlist import adapter as allowlist_adapter +from huma_signals.adapters.allowlist_contract import adapter as allowlist_contract_adapter from huma_signals.adapters.ethereum_wallet import adapter as ethereum_wallet_adapter from huma_signals.adapters.lending_pools import adapter as lending_pools_adapter from huma_signals.adapters.request_network import adapter as request_network_adapter @@ -10,6 +11,7 @@ lending_pools_adapter.LendingPoolAdapter.name: lending_pools_adapter.LendingPoolAdapter, request_network_adapter.RequestNetworkInvoiceAdapter.name: request_network_adapter.RequestNetworkInvoiceAdapter, allowlist_adapter.AllowListAdapter.name: allowlist_adapter.AllowListAdapter, + allowlist_contract_adapter.AllowListContractAdapter.name: allowlist_contract_adapter.AllowListContractAdapter, ethereum_wallet_adapter.EthereumWalletAdapter.name: ethereum_wallet_adapter.EthereumWalletAdapter, } diff --git a/huma_signals/adapters/utils/__init__.py b/huma_signals/adapters/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/huma_signals/adapters/utils/etherscan_client.py b/huma_signals/adapters/utils/etherscan_client.py new file mode 100644 index 0000000..2791fe0 --- /dev/null +++ b/huma_signals/adapters/utils/etherscan_client.py @@ -0,0 +1,27 @@ +import pydantic +import httpx + +from huma_signals import models +from huma_signals.settings import settings + + +class EtherscanClient(models.HumaBaseModel): + base_url: str = pydantic.Field(default=settings.etherscan_base_url) + api_key: str = pydantic.Field(default=settings.etherscan_api_key) + + async def get_contract_abi(self, address: str) -> str: + try: + async with httpx.AsyncClient(base_url=self.base_url) as client: + resp = await client.get( + f"{self.base_url}/api?" + f"module=contract&" + f"action=getabi&" + f"address={address}&" + f"apikey={self.api_key}", + ) + resp.raise_for_status() + payload = resp.json() + if payload["status"] == "1": + return payload["result"] + except httpx.HTTPStatusError as e: + raise e diff --git a/huma_signals/commons/chains.py b/huma_signals/commons/chains.py index aec83c7..3b3995e 100644 --- a/huma_signals/commons/chains.py +++ b/huma_signals/commons/chains.py @@ -7,6 +7,7 @@ class Chain(enum.Enum): ETHEREUM = "ETHEREUM" GOERLI = "GOERLI" POLYGON = "POLYGON" + LOCAL = "LOCAL" @staticmethod def from_chain_name(chain_name: str) -> Chain: @@ -16,6 +17,8 @@ def from_chain_name(chain_name: str) -> Chain: return Chain.GOERLI if chain_name.lower() in ("polygon", "matic"): return Chain.POLYGON + if chain_name.lower() in ("local"): + return Chain.LOCAL raise ValueError(f"Unsupported chain: {chain_name}") def chain_name(self) -> str: diff --git a/huma_signals/commons/web3_utils.py b/huma_signals/commons/web3_utils.py index 28a0934..e6a2078 100644 --- a/huma_signals/commons/web3_utils.py +++ b/huma_signals/commons/web3_utils.py @@ -11,6 +11,7 @@ chains.Chain.ETHEREUM: "1", chains.Chain.GOERLI: "5", chains.Chain.POLYGON: "998", + chains.Chain.LOCAL: "31337", } _MODULES: Dict[str, Union[Type[module.Module], Sequence[Any]]] = {