diff --git a/docker-compose-ci.yml b/docker-compose-ci.yml index 06ff567..4a9448b 100644 --- a/docker-compose-ci.yml +++ b/docker-compose-ci.yml @@ -30,3 +30,4 @@ services: - POLYGONSCAN_BASE_URL=https://api.polygonscan.com - REQUEST_NETWORK_SUBGRAPH_ENDPOINT_URL=https://api.thegraph.com/subgraphs/name/requestnetwork/request-payments-goerli - REQUEST_NETWORK_INVOICE_API_URL=https://goerli.api.huma.finance/invoice + - BULLA_NETWORK_SUBGRAPH_ENDPOINT_URL=https://api.thegraph.com/subgraphs/name/bulla-network/bulla-contracts-goerli diff --git a/huma_signals/adapters/bulla_network/__init__.py b/huma_signals/adapters/bulla_network/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/huma_signals/adapters/bulla_network/adapter.py b/huma_signals/adapters/bulla_network/adapter.py new file mode 100644 index 0000000..b38dad2 --- /dev/null +++ b/huma_signals/adapters/bulla_network/adapter.py @@ -0,0 +1,254 @@ +import datetime +import decimal +from typing import Any, ClassVar, Dict, List, Optional + +import httpx +import pandas as pd +import pydantic +import structlog +import web3 + +from huma_signals.adapters import models as adapter_models +from huma_signals.adapters.bulla_network import models +from huma_signals.adapters.ethereum_wallet import adapter as ethereum_wallet_adapter +from huma_signals.adapters.polygon_wallet import adapter as polygon_wallet_adapter +from huma_signals.commons import chains, tokens +from huma_signals.settings import settings + +_DEFAULT_GRAPHQL_CHUNK_SIZE = 1000 + +logger = structlog.get_logger(__name__) + + +class BullaNetworkInvoiceAdapter(adapter_models.SignalAdapterBase): + name: ClassVar[str] = "bulla_network" + required_inputs: ClassVar[List[str]] = ["borrower_wallet_address", "claim_id"] + signals: ClassVar[List[str]] = list( + models.BullaNetworkInvoiceSignals.__fields__.keys() + ) + bulla_network_subgraph_endpoint_url: str = pydantic.Field( + default=settings.bulla_network_subgraph_endpoint_url + ) + + @pydantic.validator("bulla_network_subgraph_endpoint_url") + def validate_bulla_network_subgraph_endpoint_url(cls, value: str) -> str: + if not value: + raise ValueError("bulla_network_subgraph_endpoint_url is required") + return value + + @classmethod + async def _get_claim_payments( + cls, + creditor_address: Optional[str], + debtor_address: Optional[str], + bn_subgraph_endpoint_url: str, + ) -> List[Any]: + where_clause = "" + if creditor_address: + where_clause += f'claim_: {{creditor: "{creditor_address}"}},\n' + if debtor_address: + where_clause += f'debtor: "{debtor_address}",\n' + + claim_payments = [] + last_chunk_size = _DEFAULT_GRAPHQL_CHUNK_SIZE + last_id = "" + async with httpx.AsyncClient() as client: + while last_chunk_size == _DEFAULT_GRAPHQL_CHUNK_SIZE: + query = f""" + query HumaBullaNetworkClaims {{ + claimPaymentEvents( + first: {_DEFAULT_GRAPHQL_CHUNK_SIZE}, + where: {{ + {where_clause} + id_gt: "{last_id}" + }} + orderBy: id, + orderDirection: asc + ) {{ + id + debtor + claim {{ + id + creditor {{id}} + token {{symbol}}}} + paymentAmount + timestamp + transactionHash + paidBy + }} + }} + """ + resp = await client.post( + bn_subgraph_endpoint_url, + json={"query": query}, + ) + response_json = resp.json() + new_chunk = response_json["data"]["claimPaymentEvents"] + claim_payments.extend(new_chunk) + last_chunk_size = len(new_chunk) + if len(claim_payments) > 0: + last_id = claim_payments[-1]["id"] + + return claim_payments + + @classmethod + def enrich_claim_payments_data(cls, claims_raw_df: pd.DataFrame) -> pd.DataFrame: + """ + Enriches the raw claims data with additional information + """ + if len(claims_raw_df) == 0: + return pd.DataFrame( + columns=[ + "id", + "token_symbol", + "creditor_address", + "debtor_address", + "timestamp", + "transactionHash", + "amount", + "paid_by_debtor", + "claim_id", + ] + ) + df = claims_raw_df.copy().drop_duplicates("id") + df["claim_id"] = df.claim.apply(lambda x: x["id"]) + df["creditor"] = df.claim.apply(lambda x: x["creditor"]["id"]) + df["paid_by_debtor"] = df.debtor.eq(df.paidBy) + df["token_symbol"] = df.claim.apply(lambda x: x["token"]["symbol"]) + df["txn_time"] = pd.to_datetime(df.timestamp, unit="s") + df["amount"] = df.paymentAmount.astype(float) + df["token_usd_price"] = ( + df["token_symbol"].map(tokens.TOKEN_USD_PRICE_MAPPING).fillna(0) + ) + df["amount_usd"] = (df.amount * df.token_usd_price).astype(int) + return df + + @classmethod + def get_claim_payment_stats( + cls, enriched_df: pd.DataFrame + ) -> Dict[str, int | decimal.Decimal]: + """ + Calculate some basic stats from the enriched claim data + """ + if len(enriched_df) == 0: + return { + "total_amount": 0, + "total_txns": 0, + "earliest_txn_age_in_days": 0, + "last_txn_age_in_days": 999, + "unique_payees": 0, + "unique_payers": 0, + } + return { + "total_amount": enriched_df.amount_usd.sum(), + "total_txns": len(enriched_df), + "earliest_txn_age_in_days": ( + datetime.datetime.now() - enriched_df.txn_time.min() + ).days, + "last_txn_age_in_days": ( + datetime.datetime.now() - enriched_df.txn_time.max() + ).days, + "unique_payees": enriched_df["creditor"].nunique(), + "unique_payers": enriched_df["debtor"].nunique(), + } + + async def fetch( # pylint: disable=too-many-arguments, arguments-differ + self, + borrower_wallet_address: str, + claim_id: int, + *args: Any, + **kwargs: Any, + ) -> models.BullaNetworkInvoiceSignals: + if not web3.Web3.is_address(borrower_wallet_address): + raise ValueError( + f"Invalid borrower wallet address: {borrower_wallet_address}" + ) + + invoice = await models.Invoice.from_claim_id( + claim_id, self.bulla_network_subgraph_endpoint_url + ) + + records = [] + records.extend( + await self._get_claim_payments( + None, invoice.payer, self.bulla_network_subgraph_endpoint_url + ) + ) + records.extend( + await self._get_claim_payments( + invoice.payee, None, self.bulla_network_subgraph_endpoint_url + ) + ) + claim_payments_df = pd.DataFrame.from_records(records) + enriched_claim_payments_df = self.enrich_claim_payments_data(claim_payments_df) + + payer_stats = self.get_claim_payment_stats( + enriched_claim_payments_df[ + enriched_claim_payments_df["debtor"] == invoice.payer + ] + ) + payee_stats = self.get_claim_payment_stats( + enriched_claim_payments_df[ + enriched_claim_payments_df["creditor"] == invoice.payee + ] + ) + pair_stats = self.get_claim_payment_stats( + enriched_claim_payments_df[ + (enriched_claim_payments_df["debtor"] == invoice.payer) + & (enriched_claim_payments_df["creditor"] == invoice.payee) + ] + ) + + this_claim_payment_stats = enriched_claim_payments_df[ + (enriched_claim_payments_df["debtor"] == invoice.payer) + & (enriched_claim_payments_df["claim_id"] == str(claim_id)) + & (enriched_claim_payments_df["paid_by_debtor"].eq(True)) + ] + + # Fetch wallet tenure + if settings.chain in {chains.Chain.ETHEREUM, chains.Chain.GOERLI}: + logger.info("Fetching wallet tenure for ethereum") + payee_wallet = await ethereum_wallet_adapter.EthereumWalletAdapter().fetch( + invoice.payee + ) # type: Any + payer_wallet = await ethereum_wallet_adapter.EthereumWalletAdapter().fetch( + invoice.payer + ) # type: Any + elif settings.chain == chains.Chain.POLYGON: + logger.info("Fetching wallet tenure for polygon") + payee_wallet = await polygon_wallet_adapter.PolygonWalletAdapter().fetch( + invoice.payee + ) + payer_wallet = await polygon_wallet_adapter.PolygonWalletAdapter().fetch( + invoice.payer + ) + else: + raise ValueError(f"Unsupported chain for wallet tenure: {settings.chain}") + + return models.BullaNetworkInvoiceSignals( + payer_tenure=payer_wallet.wallet_tenure_in_days, + payer_recent=payer_stats.get("last_txn_age_in_days", 0), + payer_count=payer_stats.get("total_txns", 0), + payer_total_amount=payer_stats.get("total_amount", 0), + payer_unique_payees=payer_stats.get("unique_payees", 0), + payee_tenure=payee_wallet.wallet_tenure_in_days, + payee_recent=payee_stats.get("last_txn_age_in_days", 0), + payee_count=payee_stats.get("total_txns", 0), + payee_total_amount=payee_stats.get("total_amount", 0), + payee_unique_payers=payee_stats.get("unique_payers", 0), + mutual_count=pair_stats.get("total_txns", 0), + mutual_total_amount=pair_stats.get("total_amount", 0), + payee_match_borrower=( + invoice.payee.lower() == borrower_wallet_address.lower() + ), + borrower_own_invoice=( + invoice.token_owner.lower() == borrower_wallet_address.lower() + ), + payer_match_payee=(invoice.payer.lower() == invoice.payee.lower()), + days_until_due_date=((invoice.due_date - datetime.datetime.utcnow()).days), + invoice_amount=invoice.amount, + invoice_status=invoice.status, + payer_has_accepted_invoice=len(this_claim_payment_stats) > 0, + # payer_on_allowlist=(invoice.payer.lower() in _ALLOWED_PAYER_ADDRESSES), + payer_on_allowlist=True, + ) diff --git a/huma_signals/adapters/bulla_network/models.py b/huma_signals/adapters/bulla_network/models.py new file mode 100644 index 0000000..ba01773 --- /dev/null +++ b/huma_signals/adapters/bulla_network/models.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import datetime +import decimal + +import httpx +import pydantic +import structlog +import web3 + +from huma_signals import models + +logger = structlog.get_logger() + + +class BullaNetworkInvoiceSignals(models.HumaBaseModel): + # transactions based features: Payer Quality + payer_tenure: int = pydantic.Field( + ..., description="The number of days since the payer's first transaction" + ) + payer_recent: int = pydantic.Field( + ..., description="The number of days since the payer's most recent transaction" + ) + payer_count: int = pydantic.Field( + ..., description="The number of transactions the payer has made" + ) + payer_total_amount: int = pydantic.Field( + ..., description="The total amount the payer has paid" + ) + payer_unique_payees: int = pydantic.Field( + ..., description="The number of unique payees the payer has paid" + ) + + # transactions based features: Payee Quality + payee_tenure: int = pydantic.Field( + ..., description="The number of days since the payee's first transaction" + ) + payee_recent: int = pydantic.Field( + ..., description="The number of days since the payee's most recent transaction" + ) + payee_count: int = pydantic.Field( + ..., description="The number of transactions the payee has made" + ) + payee_total_amount: int = pydantic.Field( + ..., description="The total amount the payee has received" + ) + payee_unique_payers: int = pydantic.Field( + ..., description="The number of unique payers the payee has received" + ) + + # transactions based features: Pair Quality + mutual_count: int = pydantic.Field( + ..., description="The number of transactions between the payer and payee" + ) + mutual_total_amount: int = pydantic.Field( + ..., description="The total amount the payer and payee have transacted" + ) + + # invoice based features + payee_match_borrower: bool = pydantic.Field( + ..., description="Whether the borrower is the invoice's payee" + ) + payer_match_payee: bool = pydantic.Field( + ..., description="Whether the payee is the invoice's payer" + ) + borrower_own_invoice: bool = pydantic.Field( + ..., description="Whether the borrower own the invoice NFT token" + ) + days_until_due_date: int = pydantic.Field( + ..., description="The number of days until the invoice's due date" + ) + invoice_amount: decimal.Decimal = pydantic.Field( + ..., description="The amount of the invoice" + ) + invoice_status: str = pydantic.Field( + ..., + description="The status of the invoice (Paid, Pending, Repaying, Rejected, Rescinded)", + ) + payer_has_accepted_invoice: bool = pydantic.Field( + ..., description="Whether the payer has paid at least 1 wei of the invoice" + ) + + # allowlist feature + payer_on_allowlist: bool = pydantic.Field( + ..., description="Whether the payer is on the allowlist" + ) + + +class Invoice(models.HumaBaseModel): + token_owner: str = pydantic.Field(..., description="The address of the token owner") + currency: str = pydantic.Field( + ..., description="The currency of the invoice (e.g. USDC, ETH, etc.)" + ) + amount: decimal.Decimal = pydantic.Field( + ..., description="The amount of the invoice (e.g. 100)" + ) + status: str = pydantic.Field( + ..., description="The status of the invoice (e.g. PAID, UNPAID, etc." + ) + payer: str = pydantic.Field(..., description="The payer's address") + payee: str = pydantic.Field(..., description="The payee's address") + creation_date: datetime.datetime = pydantic.Field( + ..., description="The date the invoice was created" + ) + due_date: datetime.datetime = pydantic.Field( + ..., description="The date the invoice is due" + ) + + # TODO: Support the balance field, 0 means it's not paid + @classmethod + async def from_claim_id(cls, claim_id: int, subgraph_url: str) -> Invoice: + try: + async with httpx.AsyncClient(base_url=subgraph_url) as client: + query = f""" + query BullaNetworkClaimRequest {{ + claims( + where: {{id: "{claim_id}"}} + ) {{ + id + token {{ symbol }} + creditor {{ id }} + debtor {{ id }} + transactionHash + amount + created + dueBy + status + }} + }} + """ + resp = await client.post( + subgraph_url, + json={"query": query}, + ) + resp.raise_for_status() + + response_json = resp.json() + claims = response_json["data"]["claims"] + if len(claims) != 1: + raise ValueError(f"Claim not found with Id: {claim_id}") + + claim = claims[0] + + creditor = claim["creditor"]["id"].lower() + debtor = claim["debtor"]["id"].lower() + + if not web3.Web3.is_address(creditor): + raise ValueError( + f"Invoice's creditor is not a valid address: {creditor}" + ) + if not web3.Web3.is_address(debtor): + raise ValueError( + f"Invoice's debtor is not a valid address: {debtor}" + ) + + return cls( + token_owner=creditor, + currency=claim["token"]["symbol"], + amount=decimal.Decimal(claim["amount"]), + status=claim["status"], + payer=debtor, + payee=creditor, + creation_date=datetime.datetime.fromtimestamp( + int(claim["created"]) + ), + due_date=datetime.datetime.fromtimestamp(int(claim["dueBy"])), + ) + except httpx.HTTPStatusError as e: + logger.error( + f"Bulla Network Subgragh API returned status code {e.response.status_code}", + exc_info=True, + base_url=subgraph_url, + claim_id=claim_id, + ) + + raise Exception( + f"Bulla Network Subgraph API returned status code {e.response.status_code}", + ) from e diff --git a/huma_signals/adapters/registry.py b/huma_signals/adapters/registry.py index e3d6754..6ff3fd1 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.bulla_network import adapter as bulla_network_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.polygon_wallet import adapter as polygon_wallet_adapter @@ -10,6 +11,7 @@ ADAPTER_REGISTRY: Dict[str, Type[models.SignalAdapterBase]] = { lending_pools_adapter.LendingPoolAdapter.name: lending_pools_adapter.LendingPoolAdapter, request_network_adapter.RequestNetworkInvoiceAdapter.name: request_network_adapter.RequestNetworkInvoiceAdapter, + bulla_network_adapter.BullaNetworkInvoiceAdapter.name: bulla_network_adapter.BullaNetworkInvoiceAdapter, allowlist_adapter.AllowListAdapter.name: allowlist_adapter.AllowListAdapter, ethereum_wallet_adapter.EthereumWalletAdapter.name: ethereum_wallet_adapter.EthereumWalletAdapter, polygon_wallet_adapter.PolygonWalletAdapter.name: polygon_wallet_adapter.PolygonWalletAdapter, diff --git a/huma_signals/settings.py b/huma_signals/settings.py index 7239a61..5ba5c75 100644 --- a/huma_signals/settings.py +++ b/huma_signals/settings.py @@ -61,5 +61,8 @@ class Config: request_network_subgraph_endpoint_url: str request_network_invoice_api_url: str + # adapter: bulla_network + bulla_network_subgraph_endpoint_url: str + settings = Settings() diff --git a/tests/adapters/bulla_network/__init__.py b/tests/adapters/bulla_network/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/adapters/bulla_network/test_adapter.py b/tests/adapters/bulla_network/test_adapter.py new file mode 100644 index 0000000..f6a5a27 --- /dev/null +++ b/tests/adapters/bulla_network/test_adapter.py @@ -0,0 +1,142 @@ +import decimal + +import pytest + +from huma_signals.adapters.bulla_network import adapter + + +def describe_adapter() -> None: + def it_validate_bn_subgraph_endpoint_url() -> None: + with pytest.raises(ValueError): + adapter.BullaNetworkInvoiceAdapter(bulla_network_subgraph_endpoint_url="") + + def describe_get_claim_payments() -> None: + @pytest.fixture + def bn_subgraph_endpoint_url() -> str: + return "https://api.thegraph.com/subgraphs/name/bulla-network/bulla-contracts-goerli" + + @pytest.fixture + def from_address() -> str: + return "0xcd003c72BF78F9C56C8eDB9DC4d450be8292d339".lower() + + @pytest.fixture + def to_address() -> str: + return ( + "0xf734908501a0B8d8d57C291ea1849490ccEdc16D".lower() + ) # gitleaks:allow + + async def it_returns_claim_payment_history( + bn_subgraph_endpoint_url: str, from_address: str, to_address: str + ) -> None: + claim_payments = ( + await adapter.BullaNetworkInvoiceAdapter._get_claim_payments( + to_address, None, bn_subgraph_endpoint_url + ) + ) + assert len(claim_payments) > 0 + assert claim_payments[-1]["claim"]["creditor"]["id"] == to_address + assert claim_payments[-1]["debtor"].startswith("0x") + + claim_payments = ( + await adapter.BullaNetworkInvoiceAdapter._get_claim_payments( + None, from_address, bn_subgraph_endpoint_url + ) + ) + assert len(claim_payments) > 0 + assert claim_payments[-1]["debtor"] == from_address + assert claim_payments[-1]["claim"]["creditor"]["id"].startswith("0x") + + claim_payments = ( + await adapter.BullaNetworkInvoiceAdapter._get_claim_payments( + to_address, from_address, bn_subgraph_endpoint_url + ) + ) + assert len(claim_payments) > 0 + assert claim_payments[-1]["claim"]["creditor"]["id"] == to_address + assert claim_payments[-1]["debtor"] == from_address + + def describe_fetch() -> None: + @pytest.fixture + def bn_subgraph_endpoint_url() -> str: + return "https://api.thegraph.com/subgraphs/name/bulla-network/bulla-contracts-goerli" + + @pytest.fixture + def borrower_address() -> str: + return "0xf734908501a0B8d8d57C291ea1849490ccEdc16D".lower() + + @pytest.fixture + def claim_id() -> int: + return 234 + + @pytest.fixture + def paid_claim_id() -> int: + return 237 + + @pytest.fixture + def fraud_claim_id() -> int: + return 238 + + @pytest.fixture + def payer_wallet_address() -> str: + return "0xcd003c72BF78F9C56C8eDB9DC4d450be8292d339".lower() + + @pytest.fixture + def payee_wallet_address() -> str: + return "0xf734908501a0B8d8d57C291ea1849490ccEdc16D".lower() + + async def it_can_fetch_signals( + bn_subgraph_endpoint_url: str, + borrower_address: str, + claim_id: int, + ) -> None: + signals = await adapter.BullaNetworkInvoiceAdapter( + bulla_network_subgraph_url=bn_subgraph_endpoint_url, + ).fetch( + borrower_wallet_address=borrower_address, + claim_id=claim_id, + ) + + assert signals.payee_match_borrower is True + assert signals.invoice_amount == decimal.Decimal("1_000_000") + assert signals.borrower_own_invoice is True + assert signals.payer_on_allowlist is True + assert signals.invoice_status == "Pending" + assert signals.payer_has_accepted_invoice is False + + async def it_can_fetch_signals_paid_invoice( + bn_subgraph_endpoint_url: str, + borrower_address: str, + paid_claim_id: int, + ) -> None: + signals = await adapter.BullaNetworkInvoiceAdapter( + bulla_network_subgraph_url=bn_subgraph_endpoint_url, + ).fetch( + borrower_wallet_address=borrower_address, + claim_id=paid_claim_id, + ) + + assert signals.payee_match_borrower is True + assert signals.invoice_amount == decimal.Decimal("1_000_000") + assert signals.borrower_own_invoice is True + assert signals.payer_on_allowlist is True + assert signals.invoice_status == "Paid" + assert signals.payer_has_accepted_invoice is True + + async def it_can_fetch_signals_fraud_invoice( + bn_subgraph_endpoint_url: str, + borrower_address: str, + fraud_claim_id: int, + ) -> None: + signals = await adapter.BullaNetworkInvoiceAdapter( + bulla_network_subgraph_url=bn_subgraph_endpoint_url, + ).fetch( + borrower_wallet_address=borrower_address, + claim_id=fraud_claim_id, + ) + + assert signals.payee_match_borrower is True + assert signals.invoice_amount == decimal.Decimal("1_000_000") + assert signals.borrower_own_invoice is True + assert signals.payer_on_allowlist is True + assert signals.invoice_status == "Repaying" + assert signals.payer_has_accepted_invoice is False diff --git a/tests/adapters/bulla_network/test_models.py b/tests/adapters/bulla_network/test_models.py new file mode 100644 index 0000000..ea83922 --- /dev/null +++ b/tests/adapters/bulla_network/test_models.py @@ -0,0 +1,48 @@ +import datetime +import decimal + +import pytest +import web3 + +from huma_signals.adapters.bulla_network import models + + +@pytest.fixture +def claim_id() -> int: + return 234 + + +@pytest.fixture +def payer_wallet_address() -> str: + return "0xcd003c72BF78F9C56C8eDB9DC4d450be8292d339" + + +@pytest.fixture +def payee_wallet_address() -> str: + return "0xf734908501a0B8d8d57C291ea1849490ccEdc16D" + + +@pytest.fixture +def subgraph_url() -> str: + return ( + "https://api.thegraph.com/subgraphs/name/bulla-network/bulla-contracts-goerli" + ) + + +def describe_invoice() -> None: + async def it_can_be_initialized_with_a_claim_id( + claim_id: int, + subgraph_url: str, + payee_wallet_address: str, + payer_wallet_address: str, + ) -> None: + invoice = await models.Invoice.from_claim_id(claim_id, subgraph_url) + assert ( + web3.Web3.to_checksum_address(invoice.token_owner) == payee_wallet_address + ) + assert web3.Web3.to_checksum_address(invoice.payer) == payer_wallet_address + assert invoice.currency == "USDC" + assert invoice.amount == decimal.Decimal("1_000_000") + assert web3.Web3.to_checksum_address(invoice.payee) == payee_wallet_address + assert invoice.creation_date > datetime.datetime(2023, 2, 28) + assert invoice.due_date > datetime.datetime(2023, 3, 6)