diff --git a/backend/app/infrastructure/database/models.py b/backend/app/infrastructure/database/models.py index cada6115b5..32d74ed738 100644 --- a/backend/app/infrastructure/database/models.py +++ b/backend/app/infrastructure/database/models.py @@ -52,6 +52,14 @@ class GPStamps(BaseModel): stamps = Column(db.String, nullable=False) +class HolonymSBT(BaseModel): + __tablename__ = "holonym_sbts" + id = Column(db.Integer, primary_key=True) + user_id = Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + has_sbt = Column(db.Boolean, nullable=False, default=False) + sbt_details = Column(db.String, nullable=False, default="[]") + + class PatronModeEvent(BaseModel): __tablename__ = "patron_events" diff --git a/backend/app/infrastructure/database/user_antisybil.py b/backend/app/infrastructure/database/user_antisybil.py index f2b5dd3d23..6e9b48b160 100644 --- a/backend/app/infrastructure/database/user_antisybil.py +++ b/backend/app/infrastructure/database/user_antisybil.py @@ -2,7 +2,9 @@ from datetime import datetime import json -from app.infrastructure.database.models import GPStamps +from typing import List + +from app.infrastructure.database.models import GPStamps, HolonymSBT from app.infrastructure.database.user import get_by_address from app.exceptions import UserNotFound from app.extensions import db @@ -37,3 +39,29 @@ def get_score_by_address(user_address: str) -> Optional[GPStamps]: .filter_by(user_id=user.id) .first() ) + + +def get_sbt_by_address(user_address: str) -> Optional[HolonymSBT]: + user = get_by_address(user_address) + if user is None: + raise UserNotFound(user_address) + + return ( + HolonymSBT.query.order_by(HolonymSBT.created_at.desc()) + .filter_by(user_id=user.id) + .first() + ) + + +def add_sbt(user_address: str, has_sbt: bool, sbt_details: List[str]) -> HolonymSBT: + user = get_by_address(user_address) + + if user is None: + raise UserNotFound(user_address) + + verification = HolonymSBT( + user_id=user.id, has_sbt=has_sbt, sbt_details=json.dumps(sbt_details) + ) + db.session.add(verification) + + return verification diff --git a/backend/app/infrastructure/external_api/holonym/__init__.py b/backend/app/infrastructure/external_api/holonym/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/infrastructure/external_api/holonym/antisybil.py b/backend/app/infrastructure/external_api/holonym/antisybil.py new file mode 100644 index 0000000000..a1b87556d7 --- /dev/null +++ b/backend/app/infrastructure/external_api/holonym/antisybil.py @@ -0,0 +1,56 @@ +import requests +from typing import Tuple, List, Union +from enum import StrEnum +from eth_utils.address import to_checksum_address + +ACTION_ID = "123456789" # Default, holonym-wide ActionID. +# Use different ActionID to prevent tracking between protocols consuming Holonym. +# https://docs.holonym.id/for-developers/custom-sybil-resistance#how-to-set-an-actionid + + +class CredentialType(StrEnum): + """ + CredentialType specifies different methods used by Holonym. + + PHONE_NUMBER_VERIFICATION - phone number verification, code is sent also through e2e messengers + GOV_ID_KYC_WITH_ZK - KYC with zk barrier between provider and address + E_PASSPORT_ON_DEVICE_WITH_ZK - the most private, on-device only, currently android only + """ + + PHONE_NUMBER_VERIFICATION = "phone" + GOV_ID_KYC_WITH_ZK = "gov-id" + E_PASSPORT_ON_DEVICE_WITH_ZK = "epassport" + + +def check( + address: str, cred: Union[CredentialType, None] = None +) -> Tuple[bool, List[str]]: + """ + Check if address has a holonym's SBT. + + Specify credential type. If unspecified, function will query for + all credential types. + + Returns a tuple. First element is True if address has at least one SBT. + Second element contains a list credential types for which active SBT was found. + """ + address = to_checksum_address(address) + if cred is None: + creds = list(CredentialType) + else: + creds = [cred] + + results = {} + for cred in creds: + results[cred] = _call(address, cred) + + has_sbt = any(results.values()) + sbt_type = [k for k, v in results.items() if v] + return has_sbt, sbt_type + + +def _call(address: str, cred: str) -> bool: + dict = requests.get( + f"https://api.holonym.io/sybil-resistance/{cred}/optimism?user={address}&action-id={ACTION_ID}" + ).json() + return dict["result"] diff --git a/backend/app/infrastructure/routes/user.py b/backend/app/infrastructure/routes/user.py index e43cfd2297..db20911abe 100644 --- a/backend/app/infrastructure/routes/user.py +++ b/backend/app/infrastructure/routes/user.py @@ -27,6 +27,10 @@ "expires_at": fields.String( required=False, description="Expiry date, unix timestamp" ), + "holonym": fields.String( + required=False, + description="True, if user has Holonym SBT", + ), "score": fields.String( required=False, description="Score, parses as a float", @@ -186,21 +190,24 @@ def patch(self, user_address: str): ) class AntisybilStatus(OctantResource): @ns.doc( - description="Returns user's antisybil status.", + description="""Returns user's antisybil status.""", ) @ns.marshal_with(user_antisybil_status_model) @ns.response(200, "User's cached antisybil status retrieved") + @ns.response(404, {"status": "Unknown"}) def get(self, user_address: str): app.logger.debug(f"Getting user {user_address} cached antisybil status") + antisybil_status = get_user_antisybil_status(user_address) app.logger.debug(f"User {user_address} antisybil status: {antisybil_status}") - if antisybil_status is None: + if antisybil_status == (None, None): return {"status": "Unknown"}, 404 - score, expires_at = antisybil_status + gitcoin, passport = antisybil_status return { + "holonym": passport.has_sbt if passport else None, "status": "Known", - "score": score, - "expires_at": int(expires_at.timestamp()), + "score": gitcoin.score if gitcoin else None, + "expires_at": int(gitcoin.expires_at.timestamp()) if gitcoin else None, }, 200 @ns.doc( @@ -211,9 +218,11 @@ def get(self, user_address: str): @ns.response(504, "Could not refresh antisybil status. Upstream is unavailable.") def put(self, user_address: str): app.logger.info(f"Updating user {user_address} antisybil status") - score, expires_at = update_user_antisybil_status(user_address) + status = update_user_antisybil_status(user_address) + app.logger.info(f"Got status for user {user_address} = {status}") + passport, sbt = status app.logger.info( - f"User {user_address} antisybil status refreshed {[score, expires_at]}" + f"User {user_address} antisybil status refreshed {[passport.score, sbt.has_sbt, passport.expires_at]}" ) return {}, 204 diff --git a/backend/app/modules/modules_factory/current.py b/backend/app/modules/modules_factory/current.py index a95a8fa744..4b11fe0b90 100644 --- a/backend/app/modules/modules_factory/current.py +++ b/backend/app/modules/modules_factory/current.py @@ -40,7 +40,8 @@ ) from app.modules.user.patron_mode.service.events_based import EventsBasedUserPatronMode from app.modules.user.tos.service.initial import InitialUserTos, InitialUserTosVerifier -from app.modules.user.antisybil.service.initial import GitcoinPassportAntisybil +from app.modules.user.antisybil.service.passport import GitcoinPassportAntisybil +from app.modules.user.antisybil.service.holonym import HolonymAntisybil from app.modules.withdrawals.service.finalized import FinalizedWithdrawals from app.pydantic import Model from app.shared.blockchain_types import compare_blockchain_types, ChainTypes @@ -55,7 +56,8 @@ class CurrentServices(Model): user_allocations_nonce_service: UserAllocationNonceProtocol user_deposits_service: CurrentUserDeposits user_tos_service: UserTos - user_antisybil_service: GitcoinPassportAntisybil + user_antisybil_passport_service: GitcoinPassportAntisybil + user_antisybil_holonym_service: HolonymAntisybil octant_rewards_service: OctantRewards history_service: HistoryService simulated_pending_snapshot_service: SimulatePendingSnapshots @@ -97,7 +99,8 @@ def create(chain_id: int) -> "CurrentServices": user_allocations = SavedUserAllocations() user_allocations_nonce = SavedUserAllocationsNonce() user_withdrawals = FinalizedWithdrawals() - user_antisybil_service = GitcoinPassportAntisybil() + user_antisybil_passport_service = GitcoinPassportAntisybil() + user_antisybil_holonym_service = HolonymAntisybil() tos_verifier = InitialUserTosVerifier() user_tos = InitialUserTos(verifier=tos_verifier) patron_donations = EventsBasedUserPatronMode() @@ -111,7 +114,7 @@ def create(chain_id: int) -> "CurrentServices": score_delegation_verifier = SimpleObfuscationDelegationVerifier() score_delegation = SimpleObfuscationDelegation( verifier=score_delegation_verifier, - antisybil=user_antisybil_service, + antisybil=user_antisybil_passport_service, user_deposits_service=user_deposits, ) @@ -129,7 +132,8 @@ def create(chain_id: int) -> "CurrentServices": ) uq_threshold = UQ_THRESHOLD_MAINNET if is_mainnet else UQ_THRESHOLD_NOT_MAINNET uniqueness_quotients = PreliminaryUQ( - antisybil=GitcoinPassportAntisybil(), + passport=user_antisybil_passport_service, + holonym=user_antisybil_holonym_service, budgets=user_budgets, uq_threshold=uq_threshold, ) @@ -142,7 +146,8 @@ def create(chain_id: int) -> "CurrentServices": simulated_pending_snapshot_service=simulated_pending_snapshot_service, multisig_signatures_service=multisig_signatures, user_tos_service=user_tos, - user_antisybil_service=user_antisybil_service, + user_antisybil_passport_service=user_antisybil_passport_service, + user_antisybil_holonym_service=user_antisybil_holonym_service, projects_metadata_service=StaticProjectsMetadataService(), user_budgets_service=user_budgets, score_delegation_service=score_delegation, diff --git a/backend/app/modules/modules_factory/pending.py b/backend/app/modules/modules_factory/pending.py index 5989d42d98..ad942d913e 100644 --- a/backend/app/modules/modules_factory/pending.py +++ b/backend/app/modules/modules_factory/pending.py @@ -38,9 +38,10 @@ PendingUserAllocations, PendingUserAllocationsVerifier, ) -from app.modules.user.antisybil.service.initial import ( +from app.modules.user.antisybil.service.passport import ( GitcoinPassportAntisybil, ) +from app.modules.user.antisybil.service.holonym import HolonymAntisybil from app.modules.user.budgets.service.saved import SavedUserBudgets from app.modules.user.deposits.service.saved import SavedUserDeposits from app.modules.user.patron_mode.service.events_based import EventsBasedUserPatronMode @@ -99,7 +100,8 @@ def create(chain_id: int) -> "PendingServices": is_mainnet = compare_blockchain_types(chain_id, ChainTypes.MAINNET) uq_threshold = UQ_THRESHOLD_MAINNET if is_mainnet else UQ_THRESHOLD_NOT_MAINNET uniqueness_quotients = PreliminaryUQ( - antisybil=GitcoinPassportAntisybil(), + passport=GitcoinPassportAntisybil(), + holonym=HolonymAntisybil(), budgets=saved_user_budgets, uq_threshold=uq_threshold, ) diff --git a/backend/app/modules/modules_factory/protocols.py b/backend/app/modules/modules_factory/protocols.py index f761b02585..229eb47a67 100644 --- a/backend/app/modules/modules_factory/protocols.py +++ b/backend/app/modules/modules_factory/protocols.py @@ -1,5 +1,5 @@ from decimal import Decimal -from typing import Protocol, List, Dict, Tuple, Optional, runtime_checkable, Set +from typing import Protocol, List, Dict, Tuple, Optional, runtime_checkable, Container from app.context.manager import Context from app.engine.projects.rewards import ProjectRewardDTO, ProjectRewardsResult @@ -240,7 +240,9 @@ def delegate(self, context: Context, payload: ScoreDelegationPayload): def recalculate(self, context: Context, payload: ScoreDelegationPayload): ... - def check(self, context: Context, addresses: List[str]) -> Set[Tuple[str, str]]: + def check( + self, context: Context, addresses: List[str] + ) -> Container[Tuple[str, str]]: ... diff --git a/backend/app/modules/score_delegation/service/simple_obfuscation.py b/backend/app/modules/score_delegation/service/simple_obfuscation.py index 33c7ee8550..97ae602126 100644 --- a/backend/app/modules/score_delegation/service/simple_obfuscation.py +++ b/backend/app/modules/score_delegation/service/simple_obfuscation.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Protocol, runtime_checkable, Tuple, Container, List +from typing import Protocol, runtime_checkable, Tuple, Container, List, Optional from app.context.manager import Context from app.extensions import db @@ -9,6 +9,7 @@ from app.modules.dto import ScoreDelegationPayload from app.modules.score_delegation import core from app.modules.score_delegation.core import ActionType +from app.modules.user.antisybil.service.passport import GitcoinAntisybilDTO from app.pydantic import Model from flask import current_app as app @@ -20,7 +21,7 @@ class Antisybil(Protocol): def fetch_antisybil_status( self, _: Context, user_address: str - ) -> Tuple[float, datetime, any]: + ) -> Optional[GitcoinAntisybilDTO]: ... def update_antisybil_status( @@ -85,7 +86,7 @@ def _delegation( hashed_addresses = get_hashed_addresses( payload.primary_addr, payload.secondary_addr ) - score, expires_at, stamps = self.antisybil.fetch_antisybil_status( + score, stamps = self.antisybil.fetch_antisybil_status( context, payload.secondary_addr ) secondary_budget = self.user_deposits_service.get_user_effective_deposit( @@ -95,10 +96,10 @@ def _delegation( context, hashed_addresses=hashed_addresses, payload=payload, - score=score, + score=score.score, secondary_budget=secondary_budget, action_type=action, ) self.antisybil.update_antisybil_status( - context, payload.primary_addr, score, expires_at, stamps + context, payload.primary_addr, score.score, score.expires_at, stamps ) diff --git a/backend/app/modules/uq/service/preliminary.py b/backend/app/modules/uq/service/preliminary.py index 3779f34bc1..a01ad7a6dd 100644 --- a/backend/app/modules/uq/service/preliminary.py +++ b/backend/app/modules/uq/service/preliminary.py @@ -1,6 +1,5 @@ -from datetime import datetime from decimal import Decimal -from typing import Protocol, Optional, Tuple, runtime_checkable +from typing import Protocol, Optional, runtime_checkable from app.context.manager import Context from app.infrastructure.database.uniqueness_quotient import ( @@ -9,13 +8,23 @@ ) from app.modules.uq.core import calculate_uq from app.pydantic import Model +from app.modules.user.antisybil.service.holonym import HolonymAntisybilDTO +from app.modules.user.antisybil.service.passport import GitcoinAntisybilDTO @runtime_checkable -class Antisybil(Protocol): +class Passport(Protocol): def get_antisybil_status( self, _: Context, user_address: str - ) -> Optional[Tuple[float, datetime]]: + ) -> Optional[GitcoinAntisybilDTO]: + ... + + +@runtime_checkable +class Holonym(Protocol): + def get_sbt_status( + self, _: Context, user_address: str + ) -> Optional[HolonymAntisybilDTO]: ... @@ -26,7 +35,8 @@ def get_budget(self, context: Context, user_address: str) -> int: class PreliminaryUQ(Model): - antisybil: Antisybil + passport: Passport + holonym: Holonym budgets: UserBudgets uq_threshold: int @@ -56,7 +66,7 @@ def calculate(self, context: Context, user_address: str) -> Decimal: return calculate_uq(gp_score, self.uq_threshold) def _get_gp_score(self, context: Context, address: str) -> float: - antisybil_status = self.antisybil.get_antisybil_status(context, address) - if antisybil_status is None: + passport_status = self.passport.get_antisybil_status(context, address) + if passport_status is None: return 0.0 - return antisybil_status[0] + return passport_status[0] diff --git a/backend/app/modules/user/antisybil/controller.py b/backend/app/modules/user/antisybil/controller.py index 34951e20e7..3217acfd54 100644 --- a/backend/app/modules/user/antisybil/controller.py +++ b/backend/app/modules/user/antisybil/controller.py @@ -1,25 +1,37 @@ -from datetime import datetime -from typing import Tuple +from typing import Optional, Tuple from app.context.epoch_state import EpochState from app.context.manager import state_context from app.modules.registry import get_services +from app.modules.user.antisybil.service.holonym import HolonymAntisybilDTO +from app.modules.user.antisybil.service.passport import GitcoinAntisybilDTO -def get_user_antisybil_status(user_address: str) -> Tuple[int, datetime]: +def get_user_antisybil_status( + user_address: str, +) -> Tuple[Optional[GitcoinAntisybilDTO], Optional[HolonymAntisybilDTO]]: context = state_context(EpochState.CURRENT) - service = get_services(context.epoch_state).user_antisybil_service - return service.get_antisybil_status(context, user_address) + passport = get_services(context.epoch_state).user_antisybil_passport_service + gpscore = passport.get_antisybil_status(context, user_address) + holonym = get_services(context.epoch_state).user_antisybil_holonym_service + sbt = holonym.get_sbt_status(context, user_address) + return (gpscore, sbt) -def update_user_antisybil_status(user_address: str) -> Tuple[int, datetime]: +def update_user_antisybil_status( + user_address: str, +) -> Tuple[GitcoinAntisybilDTO, HolonymAntisybilDTO]: context = state_context(EpochState.CURRENT) - service = get_services(context.epoch_state).user_antisybil_service + passport = get_services(context.epoch_state).user_antisybil_passport_service - score, expires_at, all_stamps = service.fetch_antisybil_status( - context, user_address + gpscore, stamps = passport.fetch_antisybil_status(context, user_address) + passport.update_antisybil_status( + context, user_address, gpscore.score, gpscore.expires_at, stamps ) - service.update_antisybil_status( - context, user_address, score, expires_at, all_stamps - ) - return service.get_antisybil_status(context, user_address) + + holonym = get_services(context.epoch_state).user_antisybil_holonym_service + + sbt = holonym.fetch_sbt_status(context, user_address) + holonym.update_sbt_status(context, user_address, sbt.has_sbt, sbt.sbt_details) + + return (gpscore, sbt) diff --git a/backend/app/modules/user/antisybil/service/holonym.py b/backend/app/modules/user/antisybil/service/holonym.py new file mode 100644 index 0000000000..6e32911472 --- /dev/null +++ b/backend/app/modules/user/antisybil/service/holonym.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass +from flask import current_app as app + +from eth_utils.address import to_checksum_address + +import json +from typing import List, Optional + +from app.extensions import db +from app.infrastructure import database +from app.infrastructure.external_api.holonym.antisybil import check +from app.context.manager import Context +from app.pydantic import Model +from app.exceptions import UserNotFound + + +@dataclass +class HolonymAntisybilDTO: + has_sbt: bool + sbt_details: List[str] + + +class HolonymAntisybil(Model): + def get_sbt_status( + self, _: Context, user_address: str + ) -> Optional[HolonymAntisybilDTO]: + user_address = to_checksum_address(user_address) + try: + entry = database.user_antisybil.get_sbt_by_address(user_address) + except UserNotFound as ex: + app.logger.debug( + f"User {user_address} antisybil status: except UserNotFound" + ) + raise ex + + if entry is not None: + return HolonymAntisybilDTO( + has_sbt=entry.has_sbt, sbt_details=json.loads(entry.sbt_details) + ) + return None + + def fetch_sbt_status(self, _: Context, user_address: str) -> HolonymAntisybilDTO: + user_address = to_checksum_address(user_address) + has_sbt, sbt_type = check(user_address) + return HolonymAntisybilDTO(has_sbt=has_sbt, sbt_details=sbt_type) + + def update_sbt_status( + self, _: Context, user_address: str, has_sbt: bool, cred_type: List[str] + ): + database.user_antisybil.add_sbt(user_address, has_sbt, cred_type) + db.session.commit() diff --git a/backend/app/modules/user/antisybil/service/initial.py b/backend/app/modules/user/antisybil/service/passport.py similarity index 90% rename from backend/app/modules/user/antisybil/service/initial.py rename to backend/app/modules/user/antisybil/service/passport.py index a9ed1f5a5a..3549f5f295 100644 --- a/backend/app/modules/user/antisybil/service/initial.py +++ b/backend/app/modules/user/antisybil/service/passport.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from flask import current_app as app from eth_utils.address import to_checksum_address @@ -23,10 +24,16 @@ ) +@dataclass +class GitcoinAntisybilDTO: + score: float + expires_at: datetime + + class GitcoinPassportAntisybil(Model): def get_antisybil_status( self, _: Context, user_address: str - ) -> Optional[Tuple[float, datetime]]: + ) -> Optional[GitcoinAntisybilDTO]: user_address = to_checksum_address(user_address) try: score = database.user_antisybil.get_score_by_address(user_address) @@ -38,12 +45,12 @@ def get_antisybil_status( if score is not None: if user_address in GUEST_LIST and not _has_guest_stamp_applied_by_gp(score): score.score = score.score + 21.0 - return score.score, score.expires_at + return GitcoinAntisybilDTO(score=score.score, expires_at=score.expires_at) return None def fetch_antisybil_status( self, _: Context, user_address: str - ) -> Tuple[float, datetime, any]: + ) -> Tuple[GitcoinAntisybilDTO, any]: score = issue_address_for_scoring(user_address) def _retry_fetch(): @@ -62,7 +69,10 @@ def _retry_fetch(): expires_at = _parse_expiration_date( min([stamp["credential"]["expirationDate"] for stamp in valid_stamps]) ) - return float(score["score"]), expires_at, all_stamps + return ( + GitcoinAntisybilDTO(score=float(score["score"]), expires_at=expires_at), + all_stamps, + ) def update_antisybil_status( self, diff --git a/backend/app/settings.py b/backend/app/settings.py index 75c37cb388..8c07fa7939 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -92,7 +92,7 @@ class ProdConfig(Config): "pool_pre_ping": True, } X_REAL_IP_REQUIRED = parse_bool(os.getenv("X_REAL_IP_REQUIRED", "true")) - CACHE_TYPE = "RedisCache" + CACHE_TYPE = os.getenv("CACHE_TYPE", "RedisCache") CACHE_REDIS_HOST = os.getenv("CACHE_REDIS_HOST") CACHE_REDIS_PORT = os.getenv("CACHE_REDIS_PORT") CACHE_REDIS_PASSWORD = os.getenv("CACHE_REDIS_PASSWORD") diff --git a/backend/migrations/versions/003a0cad5494_add_cached_status_for_holonym_sbt.py b/backend/migrations/versions/003a0cad5494_add_cached_status_for_holonym_sbt.py new file mode 100644 index 0000000000..bbb0348dba --- /dev/null +++ b/backend/migrations/versions/003a0cad5494_add_cached_status_for_holonym_sbt.py @@ -0,0 +1,36 @@ +"""Add cached status for Holonym SBT + +Revision ID: 003a0cad5494 +Revises: 8b425b454a86 +Create Date: 2024-08-20 17:52:22.331919 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "003a0cad5494" +down_revision = "8b425b454a86" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "holonym_sbts", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("has_sbt", sa.Boolean(), nullable=False), + sa.Column("sbt_details", sa.String(), nullable=False), + sa.Column("created_at", sa.TIMESTAMP(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade(): + op.drop_table("holonym_sbts") diff --git a/backend/migrations/versions/8b425b454a86_fix_created_at_field.py b/backend/migrations/versions/8b425b454a86_fix_created_at_field.py index 0d8c01b0aa..d92473cd11 100644 --- a/backend/migrations/versions/8b425b454a86_fix_created_at_field.py +++ b/backend/migrations/versions/8b425b454a86_fix_created_at_field.py @@ -19,10 +19,14 @@ def upgrade(): - query = f"UPDATE score_delegation SET created_at = make_date(2024, 7, 17) WHERE hashed_addr IN ('{HASH1}', '{HASH2}', '{HASH3}');" + if op.get_bind().engine.name == "sqlite": + return + query = f"UPDATE score_delegation SET created_at = '2024-07-17T00:00:01.000000' WHERE hashed_addr IN ('{HASH1}', '{HASH2}', '{HASH3}');" op.execute(query) def downgrade(): + if op.get_bind().engine.name == "sqlite": + return query = f"UPDATE score_delegation SET created_at = NULL WHERE hashed_addr IN ('{HASH1}', '{HASH2}', '{HASH3}');" op.execute(query) diff --git a/backend/tests/api-e2e/test_api_antisybil.py b/backend/tests/api-e2e/test_api_antisybil.py index afbd8a5cec..617bfc3709 100644 --- a/backend/tests/api-e2e/test_api_antisybil.py +++ b/backend/tests/api-e2e/test_api_antisybil.py @@ -6,7 +6,42 @@ @pytest.mark.api -def test_antisybil(client: Client, ua_alice: UserAccount): +def test_holonym(client: Client): + address_with_sbt = "0x76273DCC41356e5f0c49bB68e525175DC7e83417" + + # check status for a known address with SBT before caching + database.user.add_user(address_with_sbt) + _, code = client.get_antisybil_score(address_with_sbt) + assert code == 404 # score for this user is not cached + + _, code = client.refresh_antisybil_score(address_with_sbt) + assert code == 204 + + # check after caching + score, code = client.get_antisybil_score(address_with_sbt) + assert code == 200 # score available + assert score["holonym"] == "True" + + # check re-fetch + _, code = client.refresh_antisybil_score(address_with_sbt) + assert code == 204 + + # check for address that can't have an SBT (known non-wallet smart-contract) + smart_contract_address = "0x7DD9c5Cba05E151C895FDe1CF355C9A1D5DA6429" + database.user.add_user(smart_contract_address) + _, code = client.get_antisybil_score(smart_contract_address) + assert code == 404 + + _, code = client.refresh_antisybil_score(smart_contract_address) + assert code == 204 + + score, code = client.get_antisybil_score(smart_contract_address) + assert code == 200 # score available + assert score["holonym"] == "False" + + +@pytest.mark.api +def test_passport(client: Client, ua_alice: UserAccount): database.user.add_user(ua_alice.address) # flow for an address known to GP _, code = client.get_antisybil_score(ua_alice.address) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 52d1da7180..1e2d2c2afe 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -96,6 +96,13 @@ MOCK_IS_CONTRACT = Mock() +def mock_holonym_check(*args, **kwargs): + if args[0] == "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266": + return (True, ["phone"]) + else: + return (False, []) + + def mock_gitcoin_passport_issue_address_for_scoring(*args, **kwargs): if args[0] == "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266": return { @@ -1187,10 +1194,18 @@ def patch_user_budget(monkeypatch): MOCK_GET_USER_BUDGET.return_value = USER_MOCKED_BUDGET +@pytest.fixture(scope="function") +def patch_holonym_check(monkeypatch): + monkeypatch.setattr( + "app.modules.user.antisybil.service.holonym.check", + mock_holonym_check, + ) + + @pytest.fixture(scope="function") def patch_gitcoin_passport_issue_address_for_scoring(monkeypatch): monkeypatch.setattr( - "app.modules.user.antisybil.service.initial.issue_address_for_scoring", + "app.modules.user.antisybil.service.passport.issue_address_for_scoring", mock_gitcoin_passport_issue_address_for_scoring, ) @@ -1198,7 +1213,7 @@ def patch_gitcoin_passport_issue_address_for_scoring(monkeypatch): @pytest.fixture(scope="function") def patch_gitcoin_passport_fetch_score(monkeypatch): monkeypatch.setattr( - "app.modules.user.antisybil.service.initial.fetch_score", + "app.modules.user.antisybil.service.passport.fetch_score", mock_gitcoin_passport_fetch_score, ) @@ -1206,7 +1221,7 @@ def patch_gitcoin_passport_fetch_score(monkeypatch): @pytest.fixture(scope="function") def patch_gitcoin_passport_fetch_stamps(monkeypatch): monkeypatch.setattr( - "app.modules.user.antisybil.service.initial.fetch_stamps", + "app.modules.user.antisybil.service.passport.fetch_stamps", mock_gitcoin_passport_fetch_stamps, ) diff --git a/backend/tests/modules/modules_factory/test_modules_factory.py b/backend/tests/modules/modules_factory/test_modules_factory.py index faf3452858..e2dc38e15f 100644 --- a/backend/tests/modules/modules_factory/test_modules_factory.py +++ b/backend/tests/modules/modules_factory/test_modules_factory.py @@ -33,7 +33,8 @@ PendingUserAllocationsVerifier, ) from app.modules.user.allocations.service.saved import SavedUserAllocations -from app.modules.user.antisybil.service.initial import GitcoinPassportAntisybil +from app.modules.user.antisybil.service.passport import GitcoinPassportAntisybil +from app.modules.user.antisybil.service.holonym import HolonymAntisybil from app.modules.user.budgets.service.saved import SavedUserBudgets from app.modules.user.deposits.service.calculated import CalculatedUserDeposits from app.modules.user.deposits.service.contract_balance import ( @@ -141,7 +142,8 @@ def test_pending_services_factory(): saved_user_budgets = SavedUserBudgets() user_nonce = SavedUserAllocationsNonce() uniqueness_quotients = PreliminaryUQ( - antisybil=GitcoinPassportAntisybil(), + passport=GitcoinPassportAntisybil(), + holonym=HolonymAntisybil(), budgets=saved_user_budgets, uq_threshold=UQ_THRESHOLD_MAINNET, ) diff --git a/backend/tests/modules/score_delegation/test_simple_obfuscation.py b/backend/tests/modules/score_delegation/test_simple_obfuscation.py index 44c7cf9824..c13274c83f 100644 --- a/backend/tests/modules/score_delegation/test_simple_obfuscation.py +++ b/backend/tests/modules/score_delegation/test_simple_obfuscation.py @@ -9,6 +9,7 @@ ) from app.infrastructure import database from app.modules.dto import ScoreDelegationPayload +from app.modules.user.antisybil.service.passport import GitcoinAntisybilDTO from app.modules.score_delegation.service.simple_obfuscation import ( SimpleObfuscationDelegation, SimpleObfuscationDelegationVerifier, @@ -39,8 +40,10 @@ def test_delegation( user_deposits = CalculatedUserDeposits(events_generator=mock_empty_events_generator) antisybil = Mock() antisybil.fetch_antisybil_status.return_value = ( - 20, - "4024-05-22T14:46:46.810800+00:00", + GitcoinAntisybilDTO( + score=20, + expires_at="4024-05-22T14:46:46.810800+00:00", + ), ["stamp"], ) service = SimpleObfuscationDelegation( @@ -59,8 +62,10 @@ def test_delegation_disabled_when_secondary_is_locking( user_deposits = CalculatedUserDeposits(events_generator=mock_events_generator) antisybil = Mock() antisybil.fetch_antisybil_status.return_value = ( - 20, - "4024-05-22T14:46:46.810800+00:00", + GitcoinAntisybilDTO( + score=20, + expires_at="4024-05-22T14:46:46.810800+00:00", + ), ["stamp"], ) payload = ScoreDelegationPayload( @@ -83,8 +88,10 @@ def test_disable_recalculation_when_secondary_address_is_used( user_deposits = CalculatedUserDeposits(events_generator=mock_empty_events_generator) antisybil = Mock() antisybil.fetch_antisybil_status.return_value = ( - 20, - "4024-05-22T14:46:46.810800+00:00", + GitcoinAntisybilDTO( + score=20, + expires_at="4024-05-22T14:46:46.810800+00:00", + ), ["stamp"], ) service = SimpleObfuscationDelegation( @@ -93,8 +100,7 @@ def test_disable_recalculation_when_secondary_address_is_used( service.delegate(context, payload) antisybil.fetch_antisybil_status.return_value = ( - 25, - "4024-05-22T14:46:46.810800+00:00", + GitcoinAntisybilDTO(score=25, expires_at="4024-05-22T14:46:46.810800+00:00"), ["stamp"], ) payload = ScoreDelegationPayload( @@ -115,8 +121,7 @@ def test_recalculation_when_delegation_is_not_done( user_deposits = CalculatedUserDeposits(events_generator=mock_empty_events_generator) antisybil = Mock() antisybil.fetch_antisybil_status.return_value = ( - 20, - "4024-05-22T14:46:46.810800+00:00", + GitcoinAntisybilDTO(score=20, expires_at="4024-05-22T14:46:46.810800+00:00"), ["stamp"], ) service = SimpleObfuscationDelegation( @@ -125,8 +130,7 @@ def test_recalculation_when_delegation_is_not_done( service.delegate(context, payload) antisybil.fetch_antisybil_status.return_value = ( - 25, - "4024-05-22T14:46:46.810800+00:00", + GitcoinAntisybilDTO(25, "4024-05-22T14:46:46.810800+00:00"), ["stamp"], ) payload = ScoreDelegationPayload( diff --git a/backend/tests/modules/uq/conftest.py b/backend/tests/modules/uq/conftest.py index 1a46984a88..b3d0491c0e 100644 --- a/backend/tests/modules/uq/conftest.py +++ b/backend/tests/modules/uq/conftest.py @@ -15,9 +15,17 @@ def mock_antisybil(): @pytest.fixture -def service(mock_antisybil, mock_user_budgets): +def mock_holonym(): + mock = Mock() + mock.get_sbt_status.return_value = (False, ["phone"]) + return mock + + +@pytest.fixture +def service(mock_antisybil, mock_holonym, mock_user_budgets): return PreliminaryUQ( - antisybil=mock_antisybil, + passport=mock_antisybil, + holonym=mock_holonym, budgets=mock_user_budgets, uq_threshold=UQ_THRESHOLD_MAINNET, ) diff --git a/backend/tests/modules/user/antisybil/test_antisybil.py b/backend/tests/modules/user/antisybil/test_antisybil.py index 40a2f30838..fe1888c7e2 100644 --- a/backend/tests/modules/user/antisybil/test_antisybil.py +++ b/backend/tests/modules/user/antisybil/test_antisybil.py @@ -4,7 +4,8 @@ from app.exceptions import UserNotFound from app.infrastructure import database from app.modules.common.delegation import get_hashed_addresses -from app.modules.user.antisybil.service.initial import GitcoinPassportAntisybil +from app.modules.user.antisybil.service.passport import GitcoinPassportAntisybil +from app.modules.user.antisybil.service.holonym import HolonymAntisybil from tests.helpers.context import get_context @@ -13,7 +14,40 @@ def before(app): pass -def test_antisybil_service( +def test_holonym_antisybil_service( + mock_users_db, + patch_holonym_check, +): + context = get_context(4) + service = HolonymAntisybil() + + # check unknown user + unknown_address = "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720" + try: + service.get_sbt_status(context, unknown_address) + except UserNotFound: + pass # expected + + alice, _, _ = mock_users_db + + # SBT not yet cached + assert service.get_sbt_status(context, alice.address) is None + + # SBT fetching for known owner returns expected data + entry = service.fetch_sbt_status(context, alice.address) + assert entry.has_sbt is True + assert entry.sbt_details == ["phone"] + + # Lets cache the result + service.update_sbt_status(context, alice.address, entry.has_sbt, entry.sbt_details) + + # Check cache state + entry = service.get_sbt_status(context, alice.address) + assert entry.has_sbt is True + assert entry.sbt_details == ["phone"] + + +def test_passport_antisybil_service( patch_gitcoin_passport_issue_address_for_scoring, patch_gitcoin_passport_fetch_score, patch_gitcoin_passport_fetch_stamps, @@ -32,15 +66,19 @@ def test_antisybil_service( alice, _, _ = mock_users_db assert service.get_antisybil_status(context, alice.address) is None - score, expires_at, stamps = service.fetch_antisybil_status(context, alice.address) - assert score == 2.572 + score, stamps = service.fetch_antisybil_status(context, alice.address) + assert score.score == 2.572 assert len(stamps) == 3 - assert expires_at == datetime.strptime("2090-01-01T00:00:00Z", "%Y-%m-%dT%H:%M:%SZ") + assert score.expires_at == datetime.strptime( + "2090-01-01T00:00:00Z", "%Y-%m-%dT%H:%M:%SZ" + ) - service.update_antisybil_status(context, alice.address, score, expires_at, stamps) + service.update_antisybil_status( + context, alice.address, score.score, score.expires_at, stamps + ) - score, _ = service.get_antisybil_status(context, alice.address) - assert score == 2.572 + score = service.get_antisybil_status(context, alice.address) + assert score.score == 2.572 def test_guest_stamp_score_bump_for_both_gp_and_octant_side_application( @@ -54,31 +92,37 @@ def test_guest_stamp_score_bump_for_both_gp_and_octant_side_application( service = GitcoinPassportAntisybil() alice, _, _ = mock_users_db - score, expires_at, stamps = service.fetch_antisybil_status(context, alice.address) - service.update_antisybil_status(context, alice.address, score, expires_at, stamps) - score, _ = service.get_antisybil_status(context, alice.address) - assert score == 2.572 # guest list score bonus not applied + score, stamps = service.fetch_antisybil_status(context, alice.address) + service.update_antisybil_status( + context, alice.address, score.score, score.expires_at, stamps + ) + score = service.get_antisybil_status(context, alice.address) + assert score.score == 2.572 # guest list score bonus not applied guest_address = "0x2f51E78ff8aeC6A941C4CEeeb26B4A1f03737c50" database.user.add_user(guest_address) - score, expires_at, stamps = service.fetch_antisybil_status(context, guest_address) - service.update_antisybil_status(context, guest_address, score, expires_at, stamps) - score, _ = service.get_antisybil_status(context, guest_address) + score, stamps = service.fetch_antisybil_status(context, guest_address) + service.update_antisybil_status( + context, guest_address, score.score, score.expires_at, stamps + ) + score = service.get_antisybil_status(context, guest_address) assert (not stamps) and ( - score == 21.0 + score.score == 21.0 ) # is on guest list, no stamps, applying 21 score bonus manually stamp_address = "0xBc6d82D8d6632938394905Bb0217Ad9c673015d1" database.user.add_user(stamp_address) - score, expires_at, stamps = service.fetch_antisybil_status(context, stamp_address) - service.update_antisybil_status(context, stamp_address, score, expires_at, stamps) - score, _ = service.get_antisybil_status(context, stamp_address) + score, stamps = service.fetch_antisybil_status(context, stamp_address) + service.update_antisybil_status( + context, stamp_address, score.score, score.expires_at, stamps + ) + score = service.get_antisybil_status(context, stamp_address) assert (stamps) and ( - score == 22.0 + score.score == 22.0 ) # is on guest list, HAS GUEST LIST STAMP, score is from fetch -def test_antisybil_cant_be_update_when_address_is_delegated(alice, bob): +def test_passport_antisybil_cant_be_update_when_address_is_delegated(alice, bob): context = get_context(4) score = 2.572 primary, secondary, both = get_hashed_addresses(alice.address, bob.address) diff --git a/localenv/docker-compose.yaml b/localenv/docker-compose.yaml index c5ccd7dc10..5ca0e1076b 100644 --- a/localenv/docker-compose.yaml +++ b/localenv/docker-compose.yaml @@ -28,6 +28,7 @@ services: ethereum: localhost:http://anvil:8545 GRAPH_LOG: debug ETHEREUM_REORG_THRESHOLD: 0 + init: true ipfs: image: ipfs/kubo:v0.27.0 @@ -74,6 +75,7 @@ services: IPFS_URL: http://ipfs:5001 SUBGRAPH_ADMIN_URL: http://graph-node:8020 SUBGRAPH_QUERY_URL: http://graph-node:8000 + init: true networks: octant: diff --git a/localenv/localenv.yaml b/localenv/localenv.yaml index 080f48b5f2..8b2a985883 100644 --- a/localenv/localenv.yaml +++ b/localenv/localenv.yaml @@ -48,6 +48,8 @@ services: DELEGATION_SALT: "salt" DELEGATION_SALT_PRIMARY: "salt_primary" + CACHE_TYPE: "SimpleCache" + depends_on: - backend-postgres - anvil diff --git a/localenv/multideployer/entrypoint.sh b/localenv/multideployer/entrypoint.sh index 59ab1d93e1..88182cff15 100755 --- a/localenv/multideployer/entrypoint.sh +++ b/localenv/multideployer/entrypoint.sh @@ -18,4 +18,4 @@ wait_for_rpc echo "" echo "anvil is ready!" -python3 /app/server.py +exec python3 /app/server.py diff --git a/package.json b/package.json index 03e74115a9..ea1b273c70 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "apitest:logs": "docker compose -p apitest -f ./localenv/docker-compose.yaml -f ./localenv/apitest.yaml logs", "apitest:run": "docker compose -p apitest -f ./localenv/docker-compose.yaml -f ./localenv/apitest.yaml run backend-apitest", "apitest:clean": "docker rm -v -f $(docker ps -qa --filter 'name=apitest') || true", - "preapitest:up": "yarn apitest:clean", + "preapitest:up": "yarn apitest:clean; rm -f backend/dev.db", "preapitest:run": "yarn localenv:build-backend" }, "repository": {