From 6bc3ca83b0d8f2a0ced0e889c2ddb67c3c4cb715 Mon Sep 17 00:00:00 2001 From: jmnmv12 Date: Wed, 8 Jul 2026 11:31:36 +0100 Subject: [PATCH 01/14] feat: derive alpha price on-chain via get_subnet_price --- api/src/utils/upload_agent_helpers.py | 7 +++++++ tests/api/test_upload_agent_helpers.py | 18 ++++++++++++++++++ utils/bittensor.py | 17 +++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/api/src/utils/upload_agent_helpers.py b/api/src/utils/upload_agent_helpers.py index 6ba8b91d..ec9633b5 100644 --- a/api/src/utils/upload_agent_helpers.py +++ b/api/src/utils/upload_agent_helpers.py @@ -149,3 +149,10 @@ async def get_tao_price() -> float: data = r.json() return data["bittensor"]["usd"] + + +async def get_alpha_price() -> float: + """Return the SN62 alpha price in USD: (alpha price in TAO from chain) * (TAO price in USD).""" + alpha_tao = await subtensor_client.get_alpha_price_tao() + tao_usd = await get_tao_price() + return alpha_tao * tao_usd diff --git a/tests/api/test_upload_agent_helpers.py b/tests/api/test_upload_agent_helpers.py index 5e3142d2..e8d31942 100644 --- a/tests/api/test_upload_agent_helpers.py +++ b/tests/api/test_upload_agent_helpers.py @@ -1,7 +1,10 @@ +from unittest.mock import AsyncMock + import pytest from bittensor_wallet.keypair import Keypair from fastapi import HTTPException +from api.src.utils import upload_agent_helpers from api.src.utils.upload_agent_helpers import check_signature @@ -24,3 +27,18 @@ def test_check_signature_rejects_invalid_signature() -> None: assert exc_info.value.status_code == 400 assert exc_info.value.detail == "Invalid signature" + + +@pytest.mark.anyio +async def test_get_alpha_price_composes_chain_and_tao(monkeypatch) -> None: + # alpha price in TAO from chain, TAO price in USD from CoinGecko -> alpha price in USD + monkeypatch.setattr( + upload_agent_helpers.subtensor_client, + "get_alpha_price_tao", + AsyncMock(return_value=0.002), + ) + monkeypatch.setattr(upload_agent_helpers, "get_tao_price", AsyncMock(return_value=400.0)) + + price = await upload_agent_helpers.get_alpha_price() + + assert price == pytest.approx(0.8) # 0.002 TAO/alpha * 400 USD/TAO diff --git a/utils/bittensor.py b/utils/bittensor.py index 376706a9..92b15310 100644 --- a/utils/bittensor.py +++ b/utils/bittensor.py @@ -106,6 +106,23 @@ async def get_balance(self, address: str) -> "Balance": assert self._subtensor is not None, "Subtensor client is not initialized" return await self._subtensor.get_balance(address=address) + async def get_alpha_price_tao(self, block: int | None = None) -> float: + """Return the current alpha price (in TAO) for the configured subnet. + + Parameters + ---------- + block : int | None, optional + Block at which to read, by default latest. + + Returns + ------- + float + Alpha price denominated in TAO. + """ + assert self._subtensor is not None, "Subtensor client is not initialized" + price = await self._subtensor.get_subnet_price(netuid=config.NETUID, block=block) + return float(price.tao) + async def get_block(self, block_hash: str) -> dict | None: """Retrieve a block by its hash. From 1fbafd3929ebb29a56ef1e2ff7dd990c506645fd Mon Sep 17 00:00:00 2001 From: jmnmv12 Date: Wed, 8 Jul 2026 11:34:58 +0100 Subject: [PATCH 02/14] fix: restore SN62_NETUID constant and get_alpha_price_tao signature --- api/src/utils/upload_agent_helpers.py | 5 ++++- utils/bittensor.py | 8 +++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/api/src/utils/upload_agent_helpers.py b/api/src/utils/upload_agent_helpers.py index ec9633b5..8e1407bd 100644 --- a/api/src/utils/upload_agent_helpers.py +++ b/api/src/utils/upload_agent_helpers.py @@ -151,8 +151,11 @@ async def get_tao_price() -> float: return data["bittensor"]["usd"] +SN62_NETUID = 62 + + async def get_alpha_price() -> float: """Return the SN62 alpha price in USD: (alpha price in TAO from chain) * (TAO price in USD).""" - alpha_tao = await subtensor_client.get_alpha_price_tao() + alpha_tao = await subtensor_client.get_alpha_price_tao(netuid=SN62_NETUID) tao_usd = await get_tao_price() return alpha_tao * tao_usd diff --git a/utils/bittensor.py b/utils/bittensor.py index 92b15310..9331cd94 100644 --- a/utils/bittensor.py +++ b/utils/bittensor.py @@ -106,11 +106,13 @@ async def get_balance(self, address: str) -> "Balance": assert self._subtensor is not None, "Subtensor client is not initialized" return await self._subtensor.get_balance(address=address) - async def get_alpha_price_tao(self, block: int | None = None) -> float: - """Return the current alpha price (in TAO) for the configured subnet. + async def get_alpha_price_tao(self, netuid: int = 62, block: int | None = None) -> float: + """Return the current alpha price (in TAO) for the given subnet. Parameters ---------- + netuid : int, optional + Subnet whose alpha price to read, by default 62. block : int | None, optional Block at which to read, by default latest. @@ -120,7 +122,7 @@ async def get_alpha_price_tao(self, block: int | None = None) -> float: Alpha price denominated in TAO. """ assert self._subtensor is not None, "Subtensor client is not initialized" - price = await self._subtensor.get_subnet_price(netuid=config.NETUID, block=block) + price = await self._subtensor.get_subnet_price(netuid=netuid, block=block) return float(price.tao) async def get_block(self, block_hash: str) -> dict | None: From cd56bfa3b910d46d572ec913280c090b9e1571b4 Mon Sep 17 00:00:00 2001 From: jmnmv12 Date: Wed, 8 Jul 2026 11:50:53 +0100 Subject: [PATCH 03/14] fix: use config.NETUID in get_alpha_price_tao, matching SubtensorClient convention --- api/src/utils/upload_agent_helpers.py | 2 +- utils/bittensor.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/api/src/utils/upload_agent_helpers.py b/api/src/utils/upload_agent_helpers.py index 8e1407bd..95a79c3f 100644 --- a/api/src/utils/upload_agent_helpers.py +++ b/api/src/utils/upload_agent_helpers.py @@ -156,6 +156,6 @@ async def get_tao_price() -> float: async def get_alpha_price() -> float: """Return the SN62 alpha price in USD: (alpha price in TAO from chain) * (TAO price in USD).""" - alpha_tao = await subtensor_client.get_alpha_price_tao(netuid=SN62_NETUID) + alpha_tao = await subtensor_client.get_alpha_price_tao() tao_usd = await get_tao_price() return alpha_tao * tao_usd diff --git a/utils/bittensor.py b/utils/bittensor.py index 9331cd94..92b15310 100644 --- a/utils/bittensor.py +++ b/utils/bittensor.py @@ -106,13 +106,11 @@ async def get_balance(self, address: str) -> "Balance": assert self._subtensor is not None, "Subtensor client is not initialized" return await self._subtensor.get_balance(address=address) - async def get_alpha_price_tao(self, netuid: int = 62, block: int | None = None) -> float: - """Return the current alpha price (in TAO) for the given subnet. + async def get_alpha_price_tao(self, block: int | None = None) -> float: + """Return the current alpha price (in TAO) for the configured subnet. Parameters ---------- - netuid : int, optional - Subnet whose alpha price to read, by default 62. block : int | None, optional Block at which to read, by default latest. @@ -122,7 +120,7 @@ async def get_alpha_price_tao(self, netuid: int = 62, block: int | None = None) Alpha price denominated in TAO. """ assert self._subtensor is not None, "Subtensor client is not initialized" - price = await self._subtensor.get_subnet_price(netuid=netuid, block=block) + price = await self._subtensor.get_subnet_price(netuid=config.NETUID, block=block) return float(price.tao) async def get_block(self, block_hash: str) -> dict | None: From 123609c9b17668b7b88b910d83233a86fc2a5d05 Mon Sep 17 00:00:00 2001 From: jmnmv12 Date: Wed, 8 Jul 2026 11:57:57 +0100 Subject: [PATCH 04/14] feat: add get_alpha_stake to SubtensorClient --- utils/bittensor.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/utils/bittensor.py b/utils/bittensor.py index 92b15310..99c983de 100644 --- a/utils/bittensor.py +++ b/utils/bittensor.py @@ -2,13 +2,13 @@ from typing import TYPE_CHECKING from bittensor.core.async_subtensor import AsyncSubtensor +from bittensor.utils.balance import Balance from bittensor_wallet.keypair import Keypair import api.config as config if TYPE_CHECKING: from bittensor.core.types import BlockInfo - from bittensor.utils.balance import Balance logger = logging.getLogger(__name__) @@ -106,6 +106,29 @@ async def get_balance(self, address: str) -> "Balance": assert self._subtensor is not None, "Subtensor client is not initialized" return await self._subtensor.get_balance(address=address) + async def get_alpha_stake(self, coldkey: str, block: int | None = None) -> Balance: + """Return the total alpha a coldkey holds staked on the configured subnet. + + Parameters + ---------- + coldkey : str + Coldkey ss58 address whose alpha stake to sum. + block : int | None, optional + Block at which to read, by default latest. + + Returns + ------- + Balance + Total alpha staked by the coldkey on the subnet. + """ + assert self._subtensor is not None, "Subtensor client is not initialized" + stake_info = await self._subtensor.get_stake_info_for_coldkey(coldkey_ss58=coldkey, block=block) + total = sum( + (info.stake for info in stake_info if info.netuid == config.NETUID), + start=Balance.from_rao(0).set_unit(config.NETUID), + ) + return total + async def get_alpha_price_tao(self, block: int | None = None) -> float: """Return the current alpha price (in TAO) for the configured subnet. From 69135b44e9c024555a9027244b18e6bff4aa6198 Mon Sep 17 00:00:00 2001 From: jmnmv12 Date: Wed, 8 Jul 2026 15:07:25 +0100 Subject: [PATCH 05/14] feat: add amount_alpha_rao columns for alpha-burn payments --- .../2026_07_07_alpha_burn_payments.py | 34 +++++++++++++++++++ db/models/payment.py | 8 +++-- models/payments.py | 8 +++-- 3 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 alembic/versions/2026_07_07_alpha_burn_payments.py diff --git a/alembic/versions/2026_07_07_alpha_burn_payments.py b/alembic/versions/2026_07_07_alpha_burn_payments.py new file mode 100644 index 00000000..a3601633 --- /dev/null +++ b/alembic/versions/2026_07_07_alpha_burn_payments.py @@ -0,0 +1,34 @@ +"""Add alpha burn payment columns + +Revision ID: a1b2c3d4e5f6 +Revises: e7f3a1b2c905 +Create Date: 2026-07-07 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, Sequence[str], None] = "e7f3a1b2c905" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("evaluation_payments", sa.Column("amount_alpha_rao", sa.BigInteger(), nullable=True)) + op.add_column("upload_payment_quotes", sa.Column("amount_alpha_rao", sa.BigInteger(), nullable=True)) + op.alter_column("evaluation_payments", "amount_rao", existing_type=sa.Integer(), nullable=True) + op.alter_column("upload_payment_quotes", "amount_rao", existing_type=sa.BigInteger(), nullable=True) + op.alter_column("upload_payment_quotes", "send_address", existing_type=sa.Text(), nullable=True) + + +def downgrade() -> None: + op.alter_column("upload_payment_quotes", "send_address", existing_type=sa.Text(), nullable=False) + op.alter_column("upload_payment_quotes", "amount_rao", existing_type=sa.BigInteger(), nullable=False) + op.alter_column("evaluation_payments", "amount_rao", existing_type=sa.Integer(), nullable=False) + op.drop_column("upload_payment_quotes", "amount_alpha_rao") + op.drop_column("evaluation_payments", "amount_alpha_rao") diff --git a/db/models/payment.py b/db/models/payment.py index 949d331c..1e3ab70b 100644 --- a/db/models/payment.py +++ b/db/models/payment.py @@ -28,7 +28,8 @@ class EvaluationPayment(Base, CreatedAtMixin): ) miner_hotkey: Mapped[str] = mapped_column(sa.Text, nullable=False) miner_coldkey: Mapped[str] = mapped_column(sa.Text, nullable=False) - amount_rao: Mapped[int] = mapped_column(sa.Integer, nullable=False) + amount_rao: Mapped[Optional[int]] = mapped_column(sa.Integer, nullable=True) + amount_alpha_rao: Mapped[Optional[int]] = mapped_column(sa.BigInteger, nullable=True) __table_args__ = (sa.PrimaryKeyConstraint("payment_block_hash", "payment_extrinsic_index"),) @@ -42,6 +43,7 @@ class UploadPaymentQuote(Base, CreatedAtMixin): server_default=sa.text("gen_random_uuid()"), ) miner_hotkey: Mapped[str] = mapped_column(sa.Text, nullable=False) - amount_rao: Mapped[int] = mapped_column(sa.BigInteger, nullable=False) - send_address: Mapped[str] = mapped_column(sa.Text, nullable=False) + amount_rao: Mapped[Optional[int]] = mapped_column(sa.BigInteger, nullable=True) + amount_alpha_rao: Mapped[Optional[int]] = mapped_column(sa.BigInteger, nullable=True) + send_address: Mapped[Optional[str]] = mapped_column(sa.Text, nullable=True) expires_at: Mapped[datetime] = mapped_column(sa.TIMESTAMP(timezone=True), nullable=False) diff --git a/models/payments.py b/models/payments.py index 7c92b904..2acc3d9a 100644 --- a/models/payments.py +++ b/models/payments.py @@ -14,7 +14,8 @@ class Payment(BaseModel): miner_hotkey: str miner_coldkey: str - amount_rao: int + amount_rao: Optional[int] = None + amount_alpha_rao: Optional[int] = None created_at: datetime @@ -22,7 +23,8 @@ class Payment(BaseModel): class PaymentQuote(BaseModel): quote_id: UUID miner_hotkey: str - amount_rao: int - send_address: str + amount_rao: Optional[int] = None + amount_alpha_rao: Optional[int] = None + send_address: Optional[str] = None created_at: datetime expires_at: datetime From 16cc259e40018ca3ce3ee94f6a4e8352af587b74 Mon Sep 17 00:00:00 2001 From: jmnmv12 Date: Wed, 8 Jul 2026 15:15:03 +0100 Subject: [PATCH 06/14] feat: return amount_alpha_rao from upload pricing responses --- models/upload.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/models/upload.py b/models/upload.py index 20bc7dc7..19ab4f6d 100644 --- a/models/upload.py +++ b/models/upload.py @@ -14,17 +14,15 @@ class AgentUploadResponse(BaseModel): class UploadPriceResponse(BaseModel): """Response model for upload pricing""" - amount_rao: int = Field(..., description="Amount to send for evaluation (in RAO)") - send_address: str = Field(..., description="TAO address to send evaluation payment to") + amount_alpha_rao: int = Field(..., description="Amount of SN62 alpha to burn (in 1e9 units)") class AgentCheckResponse(AgentUploadResponse): """Response model for successful agent upload preflight checks""" quote_id: UUID = Field(..., description="Quote ID to include when uploading or resuming") - amount_rao: int = Field(..., description="Amount to send for evaluation (in RAO)") - send_address: str = Field(..., description="TAO address to send evaluation payment to") - expires_at: datetime = Field(..., description="Latest on-chain payment timestamp accepted for this quote") + amount_alpha_rao: int = Field(..., description="Amount of SN62 alpha to burn (in 1e9 units)") + expires_at: datetime = Field(..., description="Latest on-chain burn timestamp accepted for this quote") class ErrorResponse(BaseModel): From bdad03f920d6965e8ce44deb2ed3e0e098c72dd1 Mon Sep 17 00:00:00 2001 From: jmnmv12 Date: Wed, 8 Jul 2026 15:21:52 +0100 Subject: [PATCH 07/14] feat: store amount_alpha_rao in payment quotes and reservations --- queries/payments.py | 21 +++++++++----------- tests/queries/test_payments.py | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 12 deletions(-) create mode 100644 tests/queries/test_payments.py diff --git a/queries/payments.py b/queries/payments.py index d7b4906e..a9ca2923 100644 --- a/queries/payments.py +++ b/queries/payments.py @@ -13,7 +13,7 @@ async def reserve_payment( payment_extrinsic_index: str, miner_hotkey: str, miner_coldkey: str, - amount_rao: int, + amount_alpha_rao: int, quote_id: Optional[UUID] = None, ) -> Optional[Payment]: """Reserve a payment for an upload agent operation. It creates a new payment record with the given details, but with a NULL agent_id or it retrieves an existing payment row. The payment is considered reserved until the agent_id is set, which happens when the upload is completed. @@ -30,8 +30,8 @@ async def reserve_payment( Hotkey of the miner. miner_coldkey : str Coldkey of the miner. - amount_rao : int - Amount of RAO associated with the payment. + amount_alpha_rao : int + Amount of SN62 alpha (1e9 units) burned. quote_id : Optional[UUID], optional Server-issued upload payment quote used to validate the payment. @@ -48,7 +48,7 @@ async def reserve_payment( agent_id, miner_hotkey, miner_coldkey, - amount_rao, + amount_alpha_rao, quote_id ) VALUES ($1, $2, NULL, $3, $4, $5, $6) ON CONFLICT DO NOTHING @@ -57,7 +57,7 @@ async def reserve_payment( payment_extrinsic_index, miner_hotkey, miner_coldkey, - amount_rao, + amount_alpha_rao, quote_id, ) return await retrieve_payment_by_hash( @@ -130,23 +130,20 @@ async def retrieve_payment_by_hash( async def create_payment_quote( conn: DatabaseConnection, miner_hotkey: str, - amount_rao: int, - send_address: str, + amount_alpha_rao: int, expires_at: datetime, ) -> PaymentQuote: result = await conn.fetchrow( """ INSERT INTO upload_payment_quotes ( miner_hotkey, - amount_rao, - send_address, + amount_alpha_rao, expires_at - ) VALUES ($1, $2, $3, $4) + ) VALUES ($1, $2, $3) RETURNING * """, miner_hotkey, - amount_rao, - send_address, + amount_alpha_rao, expires_at, ) return PaymentQuote(**result) diff --git a/tests/queries/test_payments.py b/tests/queries/test_payments.py new file mode 100644 index 00000000..e46bf08e --- /dev/null +++ b/tests/queries/test_payments.py @@ -0,0 +1,36 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +import utils.database as _db +from queries.payments import create_payment_quote, reserve_payment + + +@pytest.fixture(autouse=True) +async def clean_tables(postgres_db): + yield + async with _db.pool.acquire() as conn: + await conn.execute("TRUNCATE evaluation_payments, upload_payment_quotes RESTART IDENTITY CASCADE") + + +@pytest.mark.anyio +async def test_create_quote_persists_alpha_amount(): + quote = await create_payment_quote( + miner_hotkey="5FHkey", + amount_alpha_rao=120_344_620_287_164, + expires_at=datetime.now(timezone.utc) + timedelta(hours=1), + ) + assert quote.amount_alpha_rao == 120_344_620_287_164 + + +@pytest.mark.anyio +async def test_reserve_payment_persists_alpha_amount(): + payment = await reserve_payment( + payment_block_hash="0xabc", + payment_extrinsic_index="3", + miner_hotkey="5FHkey", + miner_coldkey="5FCold", + amount_alpha_rao=500_000_000, + ) + assert payment is not None + assert payment.amount_alpha_rao == 500_000_000 From 80e95f12c2ac68c9e451d74e05defb83c775cf14 Mon Sep 17 00:00:00 2001 From: jmnmv12 Date: Wed, 8 Jul 2026 15:28:49 +0100 Subject: [PATCH 08/14] feat: add alpha-burn event and extrinsic verification helpers --- api/src/endpoints/upload.py | 48 +++++++++++++++++ tests/api/test_alpha_burn_verification.py | 66 +++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 tests/api/test_alpha_burn_verification.py diff --git a/api/src/endpoints/upload.py b/api/src/endpoints/upload.py index 90cdc742..fbaedbae 100644 --- a/api/src/endpoints/upload.py +++ b/api/src/endpoints/upload.py @@ -50,6 +50,14 @@ UPLOAD_PAYMENT_QUOTE_TTL_SECONDS = 60 * 60 OUTDATED_UPLOAD_CLIENT_MESSAGE = "This upload client is outdated. Please upgrade Ridges CLI and retry." +BURN_CALL_FUNCTIONS = frozenset({"burn_alpha", "add_stake_burn"}) +# Candidate attribute keys on the AlphaBurned event, in priority order (exact label, then snake_case). +_ALPHA_BURN_KEYS = { + "coldkey": ("Coldkey", "coldkey"), + "netuid": ("Netuid", "netuid"), + "amount": ("Actual Alpha Decrease", "actual_alpha_decrease", "alpha", "amount"), +} + # We use a lock per hotkey to prevent multiple agents being uploaded at the same time for the same hotkey hotkey_locks: dict[str, asyncio.Lock] = {} hotkey_locks_lock = asyncio.Lock() @@ -438,3 +446,43 @@ async def check_if_extrinsic_failed(block_hash: str, extrinsic_index: int) -> bo return True return False + + +def _event_attr(attributes, *names): + """Read a named attribute from an event's attributes, tolerating dict or [{name,value}] shapes.""" + if isinstance(attributes, dict): + for name in names: + if name in attributes: + return attributes[name] + return None + for entry in attributes or []: + if isinstance(entry, dict) and entry.get("name") in names: + return entry.get("value") + return None + + +def find_alpha_burned_event(events: list, extrinsic_index: int) -> dict: + """Return the attributes of the SubtensorModule.AlphaBurned event for the given extrinsic index.""" + for event in events: + if event.get("extrinsic_idx") != extrinsic_index: + continue + inner = event.get("event", {}) + if inner.get("module_id") == "SubtensorModule" and inner.get("event_id") == "AlphaBurned": + return inner.get("attributes", {}) + raise HTTPException(status_code=402, detail="Burn event not found") + + +def verify_burn_extrinsic(extrinsic, expected_coldkey: str) -> None: + """Cross-check the extrinsic at the burn index: recognized burn call, signed by the expected coldkey.""" + try: + value = extrinsic.value_serialized + call = value["call"] + signer = value["address"] + except (KeyError, TypeError, AttributeError): + raise HTTPException(status_code=402, detail="Burn extrinsic could not be decoded") from None + + if call.get("call_module") != "SubtensorModule" or call.get("call_function") not in BURN_CALL_FUNCTIONS: + raise HTTPException(status_code=402, detail="Extrinsic is not a recognized alpha burn") + + if signer != expected_coldkey: + raise HTTPException(status_code=402, detail="Burn was not signed by the miner coldkey") diff --git a/tests/api/test_alpha_burn_verification.py b/tests/api/test_alpha_burn_verification.py new file mode 100644 index 00000000..46bbf0f2 --- /dev/null +++ b/tests/api/test_alpha_burn_verification.py @@ -0,0 +1,66 @@ +import pytest +from fastapi import HTTPException + +from api.src.endpoints import upload as upload_module +from api.src.endpoints.upload import find_alpha_burned_event, verify_burn_extrinsic + +COLDKEY = "5C8769orColdkey" + + +def _burn_event(idx, coldkey=COLDKEY, netuid=62, amount=120_344_620_287_164): + return { + "extrinsic_idx": idx, + "event": { + "module_id": "SubtensorModule", + "event_id": "AlphaBurned", + "attributes": { + "Coldkey": coldkey, + "Hotkey": "5FHhot", + "Actual Alpha Decrease": amount, + "Netuid": netuid, + }, + }, + } + + +def _extrinsic(coldkey=COLDKEY, call_function="burn_alpha", call_module="SubtensorModule"): + class Ext: + value_serialized = { + "address": coldkey, + "call": {"call_module": call_module, "call_function": call_function, "call_args": []}, + } + + return Ext() + + +def test_find_alpha_burned_event_returns_attributes(): + events = [_burn_event(2)] + attrs = find_alpha_burned_event(events, 2) + assert upload_module._event_attr(attrs, "Netuid", "netuid") == 62 + + +def test_find_alpha_burned_event_missing_raises_402(): + events = [_burn_event(2)] + with pytest.raises(HTTPException) as exc: + find_alpha_burned_event(events, 5) + assert exc.value.status_code == 402 + + +def test_verify_burn_extrinsic_accepts_burn_alpha(): + verify_burn_extrinsic(_extrinsic(call_function="burn_alpha"), COLDKEY) + + +def test_verify_burn_extrinsic_accepts_add_stake_burn(): + verify_burn_extrinsic(_extrinsic(call_function="add_stake_burn"), COLDKEY) + + +def test_verify_burn_extrinsic_rejects_non_burn_call(): + with pytest.raises(HTTPException) as exc: + verify_burn_extrinsic(_extrinsic(call_function="transfer_keep_alive", call_module="Balances"), COLDKEY) + assert exc.value.status_code == 402 + + +def test_verify_burn_extrinsic_rejects_wrong_signer(): + with pytest.raises(HTTPException) as exc: + verify_burn_extrinsic(_extrinsic(coldkey="5Fother"), COLDKEY) + assert exc.value.status_code == 402 From a45e1016502a9d13041f83c94060b3d64baad00c Mon Sep 17 00:00:00 2001 From: jmnmv12 Date: Wed, 8 Jul 2026 15:37:36 +0100 Subject: [PATCH 09/14] feat: verify alpha burn via AlphaBurned event in upload endpoint --- api/src/endpoints/upload.py | 92 +++++++++++------------ tests/api/test_openrouter_secrets.py | 16 ++-- tests/api/test_upload.py | 106 +++++++++++++++++---------- 3 files changed, 124 insertions(+), 90 deletions(-) diff --git a/api/src/endpoints/upload.py b/api/src/endpoints/upload.py index fbaedbae..15088a61 100644 --- a/api/src/endpoints/upload.py +++ b/api/src/endpoints/upload.py @@ -12,6 +12,7 @@ from api.src.utils.openrouter_validation import validate_openrouter_keys from api.src.utils.request_cache import hourly_cache from api.src.utils.upload_agent_helpers import ( + SN62_NETUID, as_utc, check_agent_banned, check_file_size, @@ -19,8 +20,8 @@ check_if_python_file, check_rate_limit, check_signature, + get_alpha_price, get_miner_hotkey, - get_tao_price, timestamp_ms_to_utc_datetime, ) from models.agent import Agent, AgentCreate, AgentStatus @@ -102,12 +103,15 @@ async def check_agent_post( check_if_python_file(agent_file.filename) await check_file_size(agent_file) coldkey = await subtensor_client.get_hotkey_owner(miner_hotkey) - miner_balance = (await subtensor_client.get_balance(coldkey)).rao + miner_alpha_stake = (await subtensor_client.get_alpha_stake(coldkey)).rao payment_cost = await get_upload_price() - if payment_cost.amount_rao > miner_balance: + if payment_cost.amount_alpha_rao > miner_alpha_stake: raise HTTPException( status_code=402, - detail=f"Insufficient balance. You need {payment_cost.amount_rao} RAO to upload this agent. You have {miner_balance} RAO.", + detail=( + f"Insufficient alpha. You need {payment_cost.amount_alpha_rao} alpha (1e9 units) " + f"staked on SN{SN62_NETUID} to upload. You have {miner_alpha_stake}." + ), ) await validate_openrouter_keys( openrouter_api_key=openrouter_api_key, @@ -116,16 +120,14 @@ async def check_agent_post( expires_at = datetime.now(timezone.utc) + timedelta(seconds=UPLOAD_PAYMENT_QUOTE_TTL_SECONDS) quote = await create_payment_quote( miner_hotkey=miner_hotkey, - amount_rao=payment_cost.amount_rao, - send_address=payment_cost.send_address, + amount_alpha_rao=payment_cost.amount_alpha_rao, expires_at=expires_at, ) return AgentCheckResponse( status="success", message="Agent check successful", quote_id=quote.quote_id, - amount_rao=quote.amount_rao, - send_address=quote.send_address, + amount_alpha_rao=quote.amount_alpha_rao, expires_at=quote.expires_at, ) @@ -239,7 +241,7 @@ async def post_agent( logger.warning(f"Payment with block hash {payment_block_hash} has been refunded. Rejecting upload.") raise PaymentRefunded() - # Retrieve payment details from the chain + # Retrieve the burn block + events from the chain try: payment_block_info = await subtensor_client.get_block_info(block_hash=payment_block_hash) except Exception as e: @@ -249,45 +251,42 @@ async def post_agent( if payment_block_info is None: raise HTTPException(status_code=402, detail="Payment block not found") - coldkey = await subtensor_client.get_hotkey_owner(miner_hotkey, block=int(payment_block_info.number)) try: payment_extrinsic_index_int = int(payment_extrinsic_index) if payment_extrinsic_index_int < 0: raise ValueError payment_extrinsic = payment_block_info.extrinsics[payment_extrinsic_index_int] - payment_extrinsic_value = payment_extrinsic.value_serialized - payment_call = payment_extrinsic_value["call"] - call_args = {arg["name"]: arg["value"] for arg in payment_call["call_args"]} - payment_value = call_args.get("value") - destination = call_args.get("dest") - payment_address = payment_extrinsic_value["address"] - except (ValueError, TypeError, IndexError, KeyError, AttributeError): - raise HTTPException(status_code=402, detail="Payment extrinsic could not be decoded") from None - - if ( - payment_call.get("call_module") != "Balances" - or payment_call.get("call_function") != "transfer_keep_alive" - ): - raise HTTPException(status_code=402, detail="Payment extrinsic is not a TAO transfer") + except (ValueError, TypeError, IndexError, AttributeError): + raise HTTPException(status_code=402, detail="Burn extrinsic could not be decoded") from None - if payment_value is None or await check_if_extrinsic_failed( - payment_block_hash, payment_extrinsic_index_int - ): - raise HTTPException(status_code=402, detail="Payment value not found") + if await check_if_extrinsic_failed(payment_block_hash, payment_extrinsic_index_int): + raise HTTPException(status_code=402, detail="Burn extrinsic failed on-chain") + + coldkey = await subtensor_client.get_hotkey_owner(miner_hotkey, block=int(payment_block_info.number)) - # Make sure coldkey is the same as hotkeys owner coldkey - if coldkey != payment_address: + # Cross-check the extrinsic: recognized burn call signed by the miner coldkey. + verify_burn_extrinsic(payment_extrinsic, expected_coldkey=coldkey) + + # Event is the source of truth for amount, netuid, and burner. + events = await subtensor_client.get_events(block_hash=payment_block_hash) + burn_attrs = find_alpha_burned_event(events, payment_extrinsic_index_int) + + event_netuid = _event_attr(burn_attrs, *_ALPHA_BURN_KEYS["netuid"]) + if event_netuid != SN62_NETUID: + raise HTTPException(status_code=402, detail=f"Burn is not on SN{SN62_NETUID}") + + event_coldkey = _event_attr(burn_attrs, *_ALPHA_BURN_KEYS["coldkey"]) + if event_coldkey != coldkey: raise HTTPException(status_code=402, detail="Coldkey does not match") - # Make sure destination is our upload send address - if destination != quote.send_address: - raise HTTPException( - status_code=402, - detail=f"Destination does not match. The payment should be sent to {quote.send_address}", - ) + burned_alpha_rao = _event_attr(burn_attrs, *_ALPHA_BURN_KEYS["amount"]) + if burned_alpha_rao is None: + raise HTTPException(status_code=402, detail="Burn amount not found") + burned_alpha_rao = int(burned_alpha_rao) + if burned_alpha_rao < quote.amount_alpha_rao: + raise HTTPException(status_code=402, detail="Burn amount too low") - if payment_value != quote.amount_rao: - raise HTTPException(status_code=402, detail="Payment amount does not match") + payment_value = burned_alpha_rao payment_block_time = timestamp_ms_to_utc_datetime(payment_block_info.timestamp) if not (as_utc(quote.created_at) <= payment_block_time <= as_utc(quote.expires_at)): @@ -315,7 +314,7 @@ async def post_agent( payment_extrinsic_index=payment_extrinsic_index, miner_hotkey=miner_hotkey, miner_coldkey=coldkey, - amount_rao=payment_value, + amount_alpha_rao=payment_value, quote_id=quote.quote_id, ) @@ -419,17 +418,18 @@ async def post_agent( @router.get("/eval-pricing", tags=["eval-pricing"], response_model=UploadPriceResponse) @hourly_cache() async def get_upload_price() -> UploadPriceResponse: - TAO_PRICE = await get_tao_price() + ALPHA_PRICE = await get_alpha_price() eval_cost_usd = 5 - # Get the amount of tao required per eval - eval_cost_tao = eval_cost_usd / TAO_PRICE + # Alpha required to cover the eval cost at the current alpha price. + eval_cost_alpha = eval_cost_usd / ALPHA_PRICE - # Add a buffer against price fluctuations and eval cost variance. If this is over, we burn the difference. Determined EoD by net eval charges - net amount received - # This also makes production evals more expensive than local by a good margin to discourage testing in production and variance farming - amount_rao = int(eval_cost_tao * 1e9 * 1.4) + # Keep the 1.4x buffer. Burned alpha is destroyed (not reclaimable), so the buffer now + # absorbs alpha-price movement between quote and burn and keeps prod uploads meaningfully + # more expensive than local testing to discourage variance farming. + amount_alpha_rao = int(eval_cost_alpha * 1e9 * 1.4) - return UploadPriceResponse(amount_rao=amount_rao, send_address=config.UPLOAD_SEND_ADDRESS) + return UploadPriceResponse(amount_alpha_rao=amount_alpha_rao) async def check_if_extrinsic_failed(block_hash: str, extrinsic_index: int) -> bool: diff --git a/tests/api/test_openrouter_secrets.py b/tests/api/test_openrouter_secrets.py index 53ea219e..53a10238 100644 --- a/tests/api/test_openrouter_secrets.py +++ b/tests/api/test_openrouter_secrets.py @@ -122,10 +122,17 @@ async def _fake_get_hotkey_owner(*args, **kwargs): async def _fake_get_balance(*args, **kwargs): return SimpleNamespace(rao=10**12) + async def _fake_get_alpha_stake(*args, **kwargs): + return SimpleNamespace(rao=10**12) + monkeypatch.setattr( upload_endpoint, "subtensor_client", - SimpleNamespace(get_hotkey_owner=_fake_get_hotkey_owner, get_balance=_fake_get_balance), + SimpleNamespace( + get_hotkey_owner=_fake_get_hotkey_owner, + get_balance=_fake_get_balance, + get_alpha_stake=_fake_get_alpha_stake, + ), ) async def fake_validate_openrouter_keys(*, openrouter_api_key: str | None, openrouter_management_key: str | None): @@ -152,14 +159,13 @@ async def fake_check_agent_banned(*, miner_hotkey: str) -> None: return None async def fake_get_upload_price(*args, **kwargs): - return SimpleNamespace(amount_rao=1, send_address="send-address") + return SimpleNamespace(amount_alpha_rao=1) - async def fake_create_payment_quote(*, miner_hotkey: str, amount_rao: int, send_address: str, expires_at): + async def fake_create_payment_quote(*, miner_hotkey: str, amount_alpha_rao: int, expires_at): return SimpleNamespace( quote_id=uuid4(), miner_hotkey=miner_hotkey, - amount_rao=amount_rao, - send_address=send_address, + amount_alpha_rao=amount_alpha_rao, expires_at=expires_at, ) diff --git a/tests/api/test_upload.py b/tests/api/test_upload.py index 0a30df8d..02cc0e77 100644 --- a/tests/api/test_upload.py +++ b/tests/api/test_upload.py @@ -24,8 +24,7 @@ FAKE_EXTRINSIC_INDEX = "1" FAKE_HOTKEY = "5FHneTesthKey123" FAKE_COLDKEY = "5FColdKey456" -FAKE_AMOUNT_RAO = 100_000_000 -FAKE_SEND_ADDRESS = "5FUploadWalletAddress" +FAKE_AMOUNT_ALPHA_RAO = 120_344_620_287_164 FAKE_OWNER_HOTKEY = upload_module.config.OWNER_HOTKEY FAKE_BLOCK_TIME = datetime(2026, 6, 9, 18, 0, tzinfo=timezone.utc) @@ -36,12 +35,9 @@ def upload_prod_mode(): """Run all tests in this module against the prod code path.""" original_env = upload_module.config.ENV - original_send_address = upload_module.config.UPLOAD_SEND_ADDRESS upload_module.config.ENV = "prod" - upload_module.config.UPLOAD_SEND_ADDRESS = FAKE_SEND_ADDRESS yield upload_module.config.ENV = original_env - upload_module.config.UPLOAD_SEND_ADDRESS = original_send_address @pytest.fixture(autouse=True) @@ -100,24 +96,33 @@ def _make_fake_timestamp_extrinsic() -> MagicMock: return ext -def _make_fake_extrinsic(coldkey: str, amount_rao: int, dest: str) -> MagicMock: +def _make_fake_burn_extrinsic(coldkey: str) -> MagicMock: ext = MagicMock() ext.value_serialized = { "address": coldkey, - "call": { - "call_module": "Balances", - "call_function": "transfer_keep_alive", - "call_args": [ - {"name": "dest", "value": dest}, - {"name": "value", "value": amount_rao}, - ], - }, + "call": {"call_module": "SubtensorModule", "call_function": "burn_alpha", "call_args": []}, } - ext.value = ext.value_serialized - ext.__getitem__ = MagicMock(side_effect=lambda key: coldkey if key == "address" else None) return ext +def _fake_events(extrinsic_idx: int, coldkey: str, netuid: int, amount: int) -> list: + return [ + { + "extrinsic_idx": extrinsic_idx, + "event": { + "module_id": "SubtensorModule", + "event_id": "AlphaBurned", + "attributes": { + "Coldkey": coldkey, + "Hotkey": FAKE_HOTKEY, + "Actual Alpha Decrease": amount, + "Netuid": netuid, + }, + }, + } + ] + + def _install_mocks(monkeypatch) -> None: """Patch blockchain + S3. prod flag and UPLOAD_SEND_ADDRESS are set by upload_prod_mode.""" monkeypatch.setattr(upload_module, "check_signature", MagicMock()) @@ -137,11 +142,16 @@ def _install_mocks(monkeypatch) -> None: timestamp=int(FAKE_BLOCK_TIME.timestamp() * 1000), extrinsics=[ _make_fake_timestamp_extrinsic(), - _make_fake_extrinsic(FAKE_COLDKEY, FAKE_AMOUNT_RAO, FAKE_SEND_ADDRESS), + _make_fake_burn_extrinsic(FAKE_COLDKEY), ], ) ), ) + monkeypatch.setattr( + upload_module.subtensor_client, + "get_events", + AsyncMock(return_value=_fake_events(1, FAKE_COLDKEY, upload_module.SN62_NETUID, FAKE_AMOUNT_ALPHA_RAO)), + ) monkeypatch.setattr( upload_module.subtensor_client, "get_hotkey_owner", @@ -149,13 +159,13 @@ def _install_mocks(monkeypatch) -> None: ) monkeypatch.setattr( upload_module.subtensor_client, - "get_balance", - AsyncMock(return_value=MagicMock(rao=FAKE_AMOUNT_RAO * 10)), + "get_alpha_stake", + AsyncMock(return_value=MagicMock(rao=FAKE_AMOUNT_ALPHA_RAO * 10)), ) monkeypatch.setattr( upload_module, "get_upload_price", - AsyncMock(return_value=MagicMock(amount_rao=FAKE_AMOUNT_RAO, send_address=FAKE_SEND_ADDRESS)), + AsyncMock(return_value=MagicMock(amount_alpha_rao=FAKE_AMOUNT_ALPHA_RAO)), ) monkeypatch.setattr("queries.agent.upload_text_file_to_s3", AsyncMock()) response_validate_open_router_keys = MagicMock() @@ -175,8 +185,7 @@ def _install_mocks(monkeypatch) -> None: async def _insert_quote( *, hotkey: str = FAKE_HOTKEY, - amount_rao: int = FAKE_AMOUNT_RAO, - send_address: str = FAKE_SEND_ADDRESS, + amount_alpha_rao: int = FAKE_AMOUNT_ALPHA_RAO, created_at: datetime = FAKE_BLOCK_TIME - timedelta(minutes=1), expires_at: datetime = FAKE_BLOCK_TIME + timedelta(minutes=15), ) -> uuid.UUID: @@ -184,14 +193,12 @@ async def _insert_quote( async with _db.pool.acquire() as conn: await conn.execute( """ - INSERT INTO upload_payment_quotes - (quote_id, miner_hotkey, amount_rao, send_address, created_at, expires_at) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO upload_payment_quotes (quote_id, miner_hotkey, amount_alpha_rao, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5) """, quote_id, hotkey, - amount_rao, - send_address, + amount_alpha_rao, created_at, expires_at, ) @@ -268,21 +275,19 @@ async def test_check_agent_persists_payment_quote(): ) assert response.status == "success" - assert response.amount_rao == FAKE_AMOUNT_RAO - assert response.send_address == FAKE_SEND_ADDRESS + assert response.amount_alpha_rao == FAKE_AMOUNT_ALPHA_RAO async with _db.pool.acquire() as conn: row = await conn.fetchrow( """ - SELECT miner_hotkey, amount_rao, send_address, expires_at, created_at + SELECT miner_hotkey, amount_alpha_rao, expires_at, created_at FROM upload_payment_quotes WHERE quote_id = $1 """, response.quote_id, ) assert row["miner_hotkey"] == FAKE_HOTKEY - assert row["amount_rao"] == FAKE_AMOUNT_RAO - assert row["send_address"] == FAKE_SEND_ADDRESS + assert row["amount_alpha_rao"] == FAKE_AMOUNT_ALPHA_RAO assert row["expires_at"] > row["created_at"] @@ -327,14 +332,14 @@ async def test_partial_failure_retry_succeeds(): await conn.execute( """ INSERT INTO evaluation_payments - (payment_block_hash, payment_extrinsic_index, agent_id, miner_hotkey, miner_coldkey, amount_rao, quote_id) + (payment_block_hash, payment_extrinsic_index, agent_id, miner_hotkey, miner_coldkey, amount_alpha_rao, quote_id) VALUES ($1, $2, NULL, $3, $4, $5, $6) """, FAKE_BLOCK_HASH, FAKE_EXTRINSIC_INDEX, FAKE_HOTKEY, FAKE_COLDKEY, - FAKE_AMOUNT_RAO, + FAKE_AMOUNT_ALPHA_RAO, quote_id, ) @@ -363,13 +368,13 @@ async def test_refunded_payment_raises_402(): uuid.uuid4(), "0xdeadbeef1235", "1", - FAKE_AMOUNT_RAO, + FAKE_AMOUNT_ALPHA_RAO, "0xrefundtxhash", "0xuploadtxhash", FAKE_BLOCK_HASH, FAKE_EXTRINSIC_INDEX, FAKE_COLDKEY, - FAKE_AMOUNT_RAO, + FAKE_AMOUNT_ALPHA_RAO, ) with pytest.raises(HTTPException) as exc_info: @@ -384,11 +389,16 @@ async def test_refunded_payment_raises_402(): @pytest.mark.anyio -async def test_amount_mismatch_raises_402(monkeypatch): - """A payment with the wrong on-chain amount is rejected before reservation.""" +async def test_burn_below_quote_raises_402(monkeypatch): + """A burn event with an amount below the quoted amount is rejected before reservation.""" from fastapi import HTTPException - quote_id = await _insert_quote(amount_rao=FAKE_AMOUNT_RAO + 1) + monkeypatch.setattr( + upload_module.subtensor_client, + "get_events", + AsyncMock(return_value=_fake_events(1, FAKE_COLDKEY, upload_module.SN62_NETUID, FAKE_AMOUNT_ALPHA_RAO - 1)), + ) + quote_id = await _insert_quote(amount_alpha_rao=FAKE_AMOUNT_ALPHA_RAO) with pytest.raises(HTTPException) as exc_info: await _call_post_agent(quote_id=quote_id) @@ -401,6 +411,24 @@ async def test_amount_mismatch_raises_402(monkeypatch): assert payment is None +@pytest.mark.anyio +async def test_burn_wrong_coldkey_raises_402(monkeypatch): + """A burn event signed/attributed to a different coldkey than the miner's is rejected.""" + from fastapi import HTTPException + + monkeypatch.setattr( + upload_module.subtensor_client, + "get_events", + AsyncMock(return_value=_fake_events(1, "5Fimposter", upload_module.SN62_NETUID, FAKE_AMOUNT_ALPHA_RAO)), + ) + quote_id = await _insert_quote() + + with pytest.raises(HTTPException) as exc_info: + await _call_post_agent(quote_id=quote_id) + + assert exc_info.value.status_code == 402 + + @pytest.mark.anyio async def test_missing_quote_id_raises_clean_400(): """Old clients are rejected with a clear error instead of stale pricing behavior.""" From 635f9febea12fc6493c592749dcd7f51b9539707 Mon Sep 17 00:00:00 2001 From: jmnmv12 Date: Wed, 8 Jul 2026 15:43:54 +0100 Subject: [PATCH 10/14] feat: CLI submits SN62 alpha burn instead of TAO transfer --- miners/cli/commands/upload.py | 19 ++++++++------- tests/miners/test_upload_command.py | 37 +++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/miners/cli/commands/upload.py b/miners/cli/commands/upload.py index 9a8194d5..a6f6057c 100644 --- a/miners/cli/commands/upload.py +++ b/miners/cli/commands/upload.py @@ -194,11 +194,11 @@ def _unlock_coldkey(wallet) -> None: def _confirm_payment(payment_method_details: dict) -> bool: + amount_alpha = payment_method_details["amount_alpha_rao"] / 1e9 confirm_payment = Prompt.ask( ( - f"\n[bold yellow]Proceed with payment of {payment_method_details['amount_rao']} RAO " - f"({payment_method_details['amount_rao'] / 1e9} TAO) to " - f"{payment_method_details['send_address']}?[/bold yellow]" + f"\n[bold yellow]Proceed with an IRREVERSIBLE burn of {amount_alpha} alpha " + f"({payment_method_details['amount_alpha_rao']} in 1e9 units) on SN62?[/bold yellow]" ), choices=["y", "n"], default="n", @@ -211,11 +211,12 @@ def _submit_eval_payment(*, wallet, payment_method_details: dict) -> PaymentRece subtensor = Subtensor(network=os.environ.get("SUBTENSOR_NETWORK", "finney")) payment_payload = subtensor.substrate.compose_call( - call_module="Balances", - call_function="transfer_keep_alive", + call_module="SubtensorModule", + call_function="burn_alpha", call_params={ - "dest": payment_method_details["send_address"], - "value": payment_method_details["amount_rao"], + "hotkey": wallet.hotkey.ss58_address, + "amount": payment_method_details["amount_alpha_rao"], + "netuid": 62, }, ) @@ -233,8 +234,8 @@ def _submit_eval_payment(*, wallet, payment_method_details: dict) -> PaymentRece def _print_payment_receipt(receipt: PaymentReceipt) -> None: console.print( - "\n[yellow]Payment extrinsic submitted. If something goes wrong with the upload, " - "you can use this information to get a refund[/yellow]" + "\n[yellow]Burn extrinsic submitted. Burns are irreversible; if the upload fails, " + "use this info with `ridges resume-upload` to retry (not for a refund)[/yellow]" ) if receipt.quote_id: console.print(f"[cyan]Payment Quote ID:[/cyan] {receipt.quote_id}") diff --git a/tests/miners/test_upload_command.py b/tests/miners/test_upload_command.py index 56f46bd9..7cdd9467 100644 --- a/tests/miners/test_upload_command.py +++ b/tests/miners/test_upload_command.py @@ -64,8 +64,7 @@ def post(self, url: str, *, files=None, data=None, json=None, timeout=None): def test_check_upload_allowed_sends_both_openrouter_keys(tmp_path: Path) -> None: quote_response = { "quote_id": "quote-123", - "amount_rao": 123, - "send_address": "5Send", + "amount_alpha_rao": 120_344_620_287_164, "expires_at": "2026-06-10T00:00:00Z", } client = _FakeClient(_FakeResponse(200, json_data=quote_response)) @@ -124,3 +123,37 @@ def test_upload_payload_includes_both_openrouter_keys() -> None: assert payload["openrouter_management_key"] == "sk-or-v1-management" assert payload["quote_id"] == "quote-123" assert "payment_time" not in payload + + +def test_submit_eval_payment_composes_burn_alpha(monkeypatch): + from unittest.mock import MagicMock + + calls = {} + + fake_substrate = MagicMock() + fake_substrate.compose_call = MagicMock(side_effect=lambda **kw: calls.update(kw) or "payload") + fake_substrate.create_signed_extrinsic = MagicMock(return_value="signed") + fake_substrate.submit_extrinsic = MagicMock(return_value=MagicMock(block_hash="0xblock", extrinsic_idx=4)) + fake_subtensor = MagicMock(substrate=fake_substrate) + monkeypatch.setattr(upload_module, "Subtensor", MagicMock(return_value=fake_subtensor), raising=False) + + import sys + + fake_bt = MagicMock() + fake_bt.Subtensor = MagicMock(return_value=fake_subtensor) + monkeypatch.setitem(sys.modules, "bittensor", fake_bt) + + wallet = MagicMock() + wallet.coldkey = "ck" + wallet.hotkey.ss58_address = "5FHhot" + + details = {"amount_alpha_rao": 120_344_620_287_164, "quote_id": "q1"} + receipt = upload_module._submit_eval_payment(wallet=wallet, payment_method_details=details) + + assert calls["call_module"] == "SubtensorModule" + assert calls["call_function"] == "burn_alpha" + assert calls["call_params"]["netuid"] == 62 + assert calls["call_params"]["amount"] == 120_344_620_287_164 + assert receipt.block_hash == "0xblock" + assert receipt.extrinsic_index == 4 + assert receipt.quote_id == "q1" From 2dab8679778dbd55c5fc68e220ff028edfef3dd4 Mon Sep 17 00:00:00 2001 From: jmnmv12 Date: Wed, 8 Jul 2026 15:55:49 +0100 Subject: [PATCH 11/14] polish: minor fixes --- miners/cli/commands/upload.py | 2 +- tests/api/test_upload.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/miners/cli/commands/upload.py b/miners/cli/commands/upload.py index a6f6057c..10ce0ffd 100644 --- a/miners/cli/commands/upload.py +++ b/miners/cli/commands/upload.py @@ -197,7 +197,7 @@ def _confirm_payment(payment_method_details: dict) -> bool: amount_alpha = payment_method_details["amount_alpha_rao"] / 1e9 confirm_payment = Prompt.ask( ( - f"\n[bold yellow]Proceed with an IRREVERSIBLE burn of {amount_alpha} alpha " + f"\n[bold yellow]Proceed with an IRREVERSIBLE burn of {amount_alpha:,.4f} alpha " f"({payment_method_details['amount_alpha_rao']} in 1e9 units) on SN62?[/bold yellow]" ), choices=["y", "n"], diff --git a/tests/api/test_upload.py b/tests/api/test_upload.py index 02cc0e77..9c1a818e 100644 --- a/tests/api/test_upload.py +++ b/tests/api/test_upload.py @@ -124,7 +124,7 @@ def _fake_events(extrinsic_idx: int, coldkey: str, netuid: int, amount: int) -> def _install_mocks(monkeypatch) -> None: - """Patch blockchain + S3. prod flag and UPLOAD_SEND_ADDRESS are set by upload_prod_mode.""" + """Patch blockchain + S3. prod flag is set by upload_prod_mode.""" monkeypatch.setattr(upload_module, "check_signature", MagicMock()) monkeypatch.setattr(upload_module, "check_hotkey_registered", AsyncMock()) monkeypatch.setattr(upload_module, "check_agent_banned", AsyncMock()) From f331cd7a084be19a467df1efd692d4e1fb749e1b Mon Sep 17 00:00:00 2001 From: jmnmv12 Date: Thu, 9 Jul 2026 14:48:28 +0100 Subject: [PATCH 12/14] fix: :bug: Address PR comments --- api/src/endpoints/upload.py | 92 +++------------- api/src/utils/upload_agent_helpers.py | 146 ++++++++++++++++++++++++-- miners/cli/commands/upload.py | 2 +- tests/api/test_upload.py | 6 +- 4 files changed, 156 insertions(+), 90 deletions(-) diff --git a/api/src/endpoints/upload.py b/api/src/endpoints/upload.py index 15088a61..eb769c83 100644 --- a/api/src/endpoints/upload.py +++ b/api/src/endpoints/upload.py @@ -12,17 +12,19 @@ from api.src.utils.openrouter_validation import validate_openrouter_keys from api.src.utils.request_cache import hourly_cache from api.src.utils.upload_agent_helpers import ( - SN62_NETUID, as_utc, check_agent_banned, check_file_size, check_hotkey_registered, + check_if_extrinsic_failed, check_if_python_file, check_rate_limit, check_signature, + find_alpha_burned_event, get_alpha_price, get_miner_hotkey, timestamp_ms_to_utc_datetime, + verify_burn_extrinsic, ) from models.agent import Agent, AgentCreate, AgentStatus from models.upload import AgentCheckResponse, AgentUploadResponse, ErrorResponse, UploadPriceResponse @@ -51,14 +53,6 @@ UPLOAD_PAYMENT_QUOTE_TTL_SECONDS = 60 * 60 OUTDATED_UPLOAD_CLIENT_MESSAGE = "This upload client is outdated. Please upgrade Ridges CLI and retry." -BURN_CALL_FUNCTIONS = frozenset({"burn_alpha", "add_stake_burn"}) -# Candidate attribute keys on the AlphaBurned event, in priority order (exact label, then snake_case). -_ALPHA_BURN_KEYS = { - "coldkey": ("Coldkey", "coldkey"), - "netuid": ("Netuid", "netuid"), - "amount": ("Actual Alpha Decrease", "actual_alpha_decrease", "alpha", "amount"), -} - # We use a lock per hotkey to prevent multiple agents being uploaded at the same time for the same hotkey hotkey_locks: dict[str, asyncio.Lock] = {} hotkey_locks_lock = asyncio.Lock() @@ -110,7 +104,7 @@ async def check_agent_post( status_code=402, detail=( f"Insufficient alpha. You need {payment_cost.amount_alpha_rao} alpha (1e9 units) " - f"staked on SN{SN62_NETUID} to upload. You have {miner_alpha_stake}." + f"staked on SN{config.NETUID} to upload. You have {miner_alpha_stake}." ), ) await validate_openrouter_keys( @@ -259,7 +253,8 @@ async def post_agent( except (ValueError, TypeError, IndexError, AttributeError): raise HTTPException(status_code=402, detail="Burn extrinsic could not be decoded") from None - if await check_if_extrinsic_failed(payment_block_hash, payment_extrinsic_index_int): + events = await subtensor_client.get_events(block_hash=payment_block_hash) + if await check_if_extrinsic_failed(payment_extrinsic_index_int, events): raise HTTPException(status_code=402, detail="Burn extrinsic failed on-chain") coldkey = await subtensor_client.get_hotkey_owner(miner_hotkey, block=int(payment_block_info.number)) @@ -268,25 +263,18 @@ async def post_agent( verify_burn_extrinsic(payment_extrinsic, expected_coldkey=coldkey) # Event is the source of truth for amount, netuid, and burner. - events = await subtensor_client.get_events(block_hash=payment_block_hash) - burn_attrs = find_alpha_burned_event(events, payment_extrinsic_index_int) + burn_event = find_alpha_burned_event(events, payment_extrinsic_index_int) - event_netuid = _event_attr(burn_attrs, *_ALPHA_BURN_KEYS["netuid"]) - if event_netuid != SN62_NETUID: - raise HTTPException(status_code=402, detail=f"Burn is not on SN{SN62_NETUID}") + if burn_event.netuid != config.NETUID: + raise HTTPException(status_code=402, detail=f"Burn is not on SN{config.NETUID}") - event_coldkey = _event_attr(burn_attrs, *_ALPHA_BURN_KEYS["coldkey"]) - if event_coldkey != coldkey: + if burn_event.coldkey != coldkey: raise HTTPException(status_code=402, detail="Coldkey does not match") - burned_alpha_rao = _event_attr(burn_attrs, *_ALPHA_BURN_KEYS["amount"]) - if burned_alpha_rao is None: - raise HTTPException(status_code=402, detail="Burn amount not found") - burned_alpha_rao = int(burned_alpha_rao) - if burned_alpha_rao < quote.amount_alpha_rao: + if burn_event.alpha_decrease < quote.amount_alpha_rao: raise HTTPException(status_code=402, detail="Burn amount too low") - payment_value = burned_alpha_rao + payment_value = burn_event.alpha_decrease payment_block_time = timestamp_ms_to_utc_datetime(payment_block_info.timestamp) if not (as_utc(quote.created_at) <= payment_block_time <= as_utc(quote.expires_at)): @@ -430,59 +418,3 @@ async def get_upload_price() -> UploadPriceResponse: amount_alpha_rao = int(eval_cost_alpha * 1e9 * 1.4) return UploadPriceResponse(amount_alpha_rao=amount_alpha_rao) - - -async def check_if_extrinsic_failed(block_hash: str, extrinsic_index: int) -> bool: - events = await subtensor_client.get_events(block_hash=block_hash) - - for event in events: - if event.get("extrinsic_idx") != extrinsic_index: - continue - - module = event["event"]["module_id"] - event_id = event["event"]["event_id"] - - if module == "System" and event_id == "ExtrinsicFailed": - return True - - return False - - -def _event_attr(attributes, *names): - """Read a named attribute from an event's attributes, tolerating dict or [{name,value}] shapes.""" - if isinstance(attributes, dict): - for name in names: - if name in attributes: - return attributes[name] - return None - for entry in attributes or []: - if isinstance(entry, dict) and entry.get("name") in names: - return entry.get("value") - return None - - -def find_alpha_burned_event(events: list, extrinsic_index: int) -> dict: - """Return the attributes of the SubtensorModule.AlphaBurned event for the given extrinsic index.""" - for event in events: - if event.get("extrinsic_idx") != extrinsic_index: - continue - inner = event.get("event", {}) - if inner.get("module_id") == "SubtensorModule" and inner.get("event_id") == "AlphaBurned": - return inner.get("attributes", {}) - raise HTTPException(status_code=402, detail="Burn event not found") - - -def verify_burn_extrinsic(extrinsic, expected_coldkey: str) -> None: - """Cross-check the extrinsic at the burn index: recognized burn call, signed by the expected coldkey.""" - try: - value = extrinsic.value_serialized - call = value["call"] - signer = value["address"] - except (KeyError, TypeError, AttributeError): - raise HTTPException(status_code=402, detail="Burn extrinsic could not be decoded") from None - - if call.get("call_module") != "SubtensorModule" or call.get("call_function") not in BURN_CALL_FUNCTIONS: - raise HTTPException(status_code=402, detail="Extrinsic is not a recognized alpha burn") - - if signer != expected_coldkey: - raise HTTPException(status_code=402, detail="Burn was not signed by the miner coldkey") diff --git a/api/src/utils/upload_agent_helpers.py b/api/src/utils/upload_agent_helpers.py index 95a79c3f..b127f900 100644 --- a/api/src/utils/upload_agent_helpers.py +++ b/api/src/utils/upload_agent_helpers.py @@ -1,5 +1,7 @@ import logging +from dataclasses import dataclass from datetime import datetime, timedelta, timezone +from typing import Any import httpx from bittensor_wallet.keypair import Keypair @@ -12,6 +14,17 @@ logger = logging.getLogger(__name__) +@dataclass(slots=True, frozen=True) +class BurnEvent: + coldkey: str + hotkey: str + alpha_decrease: int + netuid: int + + +BURN_CALL_FUNCTIONS = frozenset({"burn_alpha", "add_stake_burn"}) + + def get_miner_hotkey(file_info: str) -> str: logger.debug(f"Getting miner hotkey from file info: {file_info}.") miner_hotkey = file_info.split(":")[0] @@ -47,7 +60,9 @@ async def check_agent_banned(miner_hotkey: str) -> None: logger.debug(f"Miner hotkey {miner_hotkey} is not banned.") -def check_rate_limit(latest_agent_created_at_in_latest_set_id: datetime) -> None: +def check_rate_limit( + latest_agent_created_at_in_latest_set_id: datetime, +) -> None: logger.debug("Checking if miner is rate limited...") earliest_allowed_time = latest_agent_created_at_in_latest_set_id + timedelta( @@ -75,7 +90,10 @@ def timestamp_ms_to_utc_datetime(timestamp_ms: int | None) -> datetime: try: return datetime.fromtimestamp(int(timestamp_ms) / 1000, timezone.utc) except (TypeError, ValueError): - raise HTTPException(status_code=402, detail="Payment block timestamp could not be decoded") from None + raise HTTPException( + status_code=402, + detail="Payment block timestamp could not be decoded", + ) from None def as_utc(dt: datetime) -> datetime: @@ -93,7 +111,10 @@ def check_signature(public_key: str, file_info: str, signature: str, miner_hotke logger.error( f"Attempt to upload an agent with a public key that does not correspond to the miner hotkey. Public key ss58 address: {keypair.ss58_address}, Miner hotkey: {miner_hotkey}." ) - raise HTTPException(status_code=400, detail="Public key does not correspond to miner hotkey") + raise HTTPException( + status_code=400, + detail="Public key does not correspond to miner hotkey", + ) if not keypair.verify(file_info, bytes.fromhex(signature)): logger.error( @@ -151,11 +172,124 @@ async def get_tao_price() -> float: return data["bittensor"]["usd"] -SN62_NETUID = 62 - - async def get_alpha_price() -> float: """Return the SN62 alpha price in USD: (alpha price in TAO from chain) * (TAO price in USD).""" alpha_tao = await subtensor_client.get_alpha_price_tao() tao_usd = await get_tao_price() return alpha_tao * tao_usd + + +async def check_if_extrinsic_failed(extrinsic_index: int, events: list) -> bool: + """Validate if the extrinsic failed based on the events. + + Parameters + ---------- + extrinsic_index : int + Index of the extrinsic in the block. + events : list + List of events to check. + + Returns + ------- + bool + True if the extrinsic failed, False otherwise. + """ + logger.debug(f"Checking if extrinsic at index {extrinsic_index} failed based on events...") + for event in events: + if event.get("extrinsic_idx") != extrinsic_index: + continue + + module = event["event"]["module_id"] + event_id = event["event"]["event_id"] + + if module == "System" and event_id == "ExtrinsicFailed": + return True + + return False + + +def _parse_alpha_burned_attributes(attributes: list | tuple | dict) -> BurnEvent: + """Parse SubtensorModule.AlphaBurned event attributes into a BurnEvent. + + AsyncSubtensor/substrate-interface decodes this event as a positional tuple/list (coldkey, hotkey, actual_alpha_decrease, netuid) rather than named attributes. + + Named attributes are also supported as a fallback. + + Parameters + ---------- + attributes : list|tuple|dict + Event attributes, either as a tuple/list or a dict. + + Returns + ------- + BurnEvent + Parsed burn event with coldkey, hotkey, alpha_decrease, and netuid. + + """ + if isinstance(attributes, (tuple, list)) and len(attributes) >= 4: + coldkey, hotkey, alpha_decrease, netuid = attributes[:4] + return BurnEvent(coldkey=coldkey, hotkey=hotkey, alpha_decrease=int(alpha_decrease), netuid=int(netuid)) + if isinstance(attributes, dict): + try: + return BurnEvent( + coldkey=attributes["Coldkey"], + hotkey=attributes["Hotkey"], + alpha_decrease=int(attributes["Actual Alpha Decrease"]), + netuid=int(attributes["Netuid"]), + ) + except (KeyError, TypeError, ValueError): + raise HTTPException(status_code=402, detail="Burn event attributes could not be decoded") from None + raise HTTPException(status_code=402, detail="Burn event attributes could not be decoded") + + +def find_alpha_burned_event(events: list, extrinsic_index: int) -> BurnEvent: + """Find the AlphaBurned event in the events list and returned a parsed + BurnEvent. + + Parameters + ---------- + events : list + List of events to search for the AlphaBurned event. + extrinsic_index : int + Index of the extrinsic to search for. + + Returns + ------- + BurnEvent + Parsed burn event with coldkey, hotkey, alpha_decrease, and netuid. + """ + for event in events: + if event.get("extrinsic_idx") != extrinsic_index: + continue + inner = event.get("event", {}) + if inner.get("module_id") == "SubtensorModule" and inner.get("event_id") == "AlphaBurned": + alpha_burned_event = _parse_alpha_burned_attributes(inner.get("attributes", {})) + logger.debug(f"Found AlphaBurned event: {alpha_burned_event}") + return alpha_burned_event + raise HTTPException(status_code=402, detail="Burn event not found") + + +def verify_burn_extrinsic(extrinsic: Any, expected_coldkey: str) -> None: + """Validate that the extrinsic is a recognized alpha burn and that it was signed by the expected miner coldkey. + + Parameters + ---------- + extrinsic : Any + Extrinsic data to validate. + expected_coldkey : str + The expected coldkey of the miner. + """ + try: + value = extrinsic.value_serialized + call = value["call"] + signer = value["address"] + except (KeyError, TypeError, AttributeError): + raise HTTPException(status_code=402, detail="Burn extrinsic could not be decoded") from None + logger.debug("Verifying call module and function for burn extrinsic...") + if call.get("call_module") != "SubtensorModule" or call.get("call_function") not in BURN_CALL_FUNCTIONS: + raise HTTPException(status_code=402, detail="Extrinsic is not a recognized alpha burn") + + logger.debug("Verifying that the burn extrinsic was signed by the expected miner coldkey...") + if signer != expected_coldkey: + raise HTTPException(status_code=402, detail="Burn was not signed by the miner coldkey") + logger.debug("Burn extrinsic is valid and signed by the expected miner coldkey.") diff --git a/miners/cli/commands/upload.py b/miners/cli/commands/upload.py index 10ce0ffd..62ba53ee 100644 --- a/miners/cli/commands/upload.py +++ b/miners/cli/commands/upload.py @@ -235,7 +235,7 @@ def _submit_eval_payment(*, wallet, payment_method_details: dict) -> PaymentRece def _print_payment_receipt(receipt: PaymentReceipt) -> None: console.print( "\n[yellow]Burn extrinsic submitted. Burns are irreversible; if the upload fails, " - "use this info with `ridges resume-upload` to retry (not for a refund)[/yellow]" + "use this info with `ridges resume-upload` to retry[/yellow]" ) if receipt.quote_id: console.print(f"[cyan]Payment Quote ID:[/cyan] {receipt.quote_id}") diff --git a/tests/api/test_upload.py b/tests/api/test_upload.py index 9c1a818e..37ae73b3 100644 --- a/tests/api/test_upload.py +++ b/tests/api/test_upload.py @@ -150,7 +150,7 @@ def _install_mocks(monkeypatch) -> None: monkeypatch.setattr( upload_module.subtensor_client, "get_events", - AsyncMock(return_value=_fake_events(1, FAKE_COLDKEY, upload_module.SN62_NETUID, FAKE_AMOUNT_ALPHA_RAO)), + AsyncMock(return_value=_fake_events(1, FAKE_COLDKEY, upload_module.config.NETUID, FAKE_AMOUNT_ALPHA_RAO)), ) monkeypatch.setattr( upload_module.subtensor_client, @@ -396,7 +396,7 @@ async def test_burn_below_quote_raises_402(monkeypatch): monkeypatch.setattr( upload_module.subtensor_client, "get_events", - AsyncMock(return_value=_fake_events(1, FAKE_COLDKEY, upload_module.SN62_NETUID, FAKE_AMOUNT_ALPHA_RAO - 1)), + AsyncMock(return_value=_fake_events(1, FAKE_COLDKEY, upload_module.config.NETUID, FAKE_AMOUNT_ALPHA_RAO - 1)), ) quote_id = await _insert_quote(amount_alpha_rao=FAKE_AMOUNT_ALPHA_RAO) @@ -419,7 +419,7 @@ async def test_burn_wrong_coldkey_raises_402(monkeypatch): monkeypatch.setattr( upload_module.subtensor_client, "get_events", - AsyncMock(return_value=_fake_events(1, "5Fimposter", upload_module.SN62_NETUID, FAKE_AMOUNT_ALPHA_RAO)), + AsyncMock(return_value=_fake_events(1, "5Fimposter", upload_module.config.NETUID, FAKE_AMOUNT_ALPHA_RAO)), ) quote_id = await _insert_quote() From 07b57948952df6d2949401b50eff9ce3cac80b72 Mon Sep 17 00:00:00 2001 From: jmnmv12 Date: Thu, 9 Jul 2026 15:06:47 +0100 Subject: [PATCH 13/14] refactor: :recycle: Refactor test structure and alphaburned event examples --- tests/api/test_alpha_burn_verification.py | 66 ---------------- tests/api/test_upload.py | 7 +- tests/api/test_upload_agent_helpers.py | 94 ++++++++++++++++++++++- tests/queries/test_payments.py | 36 --------- 4 files changed, 94 insertions(+), 109 deletions(-) delete mode 100644 tests/api/test_alpha_burn_verification.py delete mode 100644 tests/queries/test_payments.py diff --git a/tests/api/test_alpha_burn_verification.py b/tests/api/test_alpha_burn_verification.py deleted file mode 100644 index 46bbf0f2..00000000 --- a/tests/api/test_alpha_burn_verification.py +++ /dev/null @@ -1,66 +0,0 @@ -import pytest -from fastapi import HTTPException - -from api.src.endpoints import upload as upload_module -from api.src.endpoints.upload import find_alpha_burned_event, verify_burn_extrinsic - -COLDKEY = "5C8769orColdkey" - - -def _burn_event(idx, coldkey=COLDKEY, netuid=62, amount=120_344_620_287_164): - return { - "extrinsic_idx": idx, - "event": { - "module_id": "SubtensorModule", - "event_id": "AlphaBurned", - "attributes": { - "Coldkey": coldkey, - "Hotkey": "5FHhot", - "Actual Alpha Decrease": amount, - "Netuid": netuid, - }, - }, - } - - -def _extrinsic(coldkey=COLDKEY, call_function="burn_alpha", call_module="SubtensorModule"): - class Ext: - value_serialized = { - "address": coldkey, - "call": {"call_module": call_module, "call_function": call_function, "call_args": []}, - } - - return Ext() - - -def test_find_alpha_burned_event_returns_attributes(): - events = [_burn_event(2)] - attrs = find_alpha_burned_event(events, 2) - assert upload_module._event_attr(attrs, "Netuid", "netuid") == 62 - - -def test_find_alpha_burned_event_missing_raises_402(): - events = [_burn_event(2)] - with pytest.raises(HTTPException) as exc: - find_alpha_burned_event(events, 5) - assert exc.value.status_code == 402 - - -def test_verify_burn_extrinsic_accepts_burn_alpha(): - verify_burn_extrinsic(_extrinsic(call_function="burn_alpha"), COLDKEY) - - -def test_verify_burn_extrinsic_accepts_add_stake_burn(): - verify_burn_extrinsic(_extrinsic(call_function="add_stake_burn"), COLDKEY) - - -def test_verify_burn_extrinsic_rejects_non_burn_call(): - with pytest.raises(HTTPException) as exc: - verify_burn_extrinsic(_extrinsic(call_function="transfer_keep_alive", call_module="Balances"), COLDKEY) - assert exc.value.status_code == 402 - - -def test_verify_burn_extrinsic_rejects_wrong_signer(): - with pytest.raises(HTTPException) as exc: - verify_burn_extrinsic(_extrinsic(coldkey="5Fother"), COLDKEY) - assert exc.value.status_code == 402 diff --git a/tests/api/test_upload.py b/tests/api/test_upload.py index 37ae73b3..3dc2927a 100644 --- a/tests/api/test_upload.py +++ b/tests/api/test_upload.py @@ -112,12 +112,7 @@ def _fake_events(extrinsic_idx: int, coldkey: str, netuid: int, amount: int) -> "event": { "module_id": "SubtensorModule", "event_id": "AlphaBurned", - "attributes": { - "Coldkey": coldkey, - "Hotkey": FAKE_HOTKEY, - "Actual Alpha Decrease": amount, - "Netuid": netuid, - }, + "attributes": (coldkey, FAKE_HOTKEY, amount, netuid), }, } ] diff --git a/tests/api/test_upload_agent_helpers.py b/tests/api/test_upload_agent_helpers.py index e8d31942..03d1113e 100644 --- a/tests/api/test_upload_agent_helpers.py +++ b/tests/api/test_upload_agent_helpers.py @@ -5,7 +5,10 @@ from fastapi import HTTPException from api.src.utils import upload_agent_helpers -from api.src.utils.upload_agent_helpers import check_signature +from api.src.utils.upload_agent_helpers import check_signature, find_alpha_burned_event, verify_burn_extrinsic + +COLDKEY = "5C8769orColdkey" +HOTKEY = "5FHhot" def test_check_signature_accepts_matching_hotkey() -> None: @@ -42,3 +45,92 @@ async def test_get_alpha_price_composes_chain_and_tao(monkeypatch) -> None: price = await upload_agent_helpers.get_alpha_price() assert price == pytest.approx(0.8) # 0.002 TAO/alpha * 400 USD/TAO + + +# ── AlphaBurned event parsing + burn extrinsic verification ──────────────────── + + +def _burn_event_tuple(idx, coldkey=COLDKEY, netuid=62, amount=120_344_620_287_164): + """A burn event with positional-tuple attributes — the confirmed live shape from AsyncSubtensor.""" + return { + "extrinsic_idx": idx, + "event": { + "module_id": "SubtensorModule", + "event_id": "AlphaBurned", + "attributes": (coldkey, HOTKEY, amount, netuid), + }, + } + + +def _burn_event_dict(idx, coldkey=COLDKEY, netuid=62, amount=120_344_620_287_164): + """A burn event with dict-shaped attributes (fallback shape, not the confirmed live one).""" + return { + "extrinsic_idx": idx, + "event": { + "module_id": "SubtensorModule", + "event_id": "AlphaBurned", + "attributes": { + "Coldkey": coldkey, + "Hotkey": HOTKEY, + "Actual Alpha Decrease": amount, + "Netuid": netuid, + }, + }, + } + + +def _extrinsic(coldkey=COLDKEY, call_function="burn_alpha", call_module="SubtensorModule"): + class Ext: + value_serialized = { + "address": coldkey, + "call": {"call_module": call_module, "call_function": call_function, "call_args": []}, + } + + return Ext() + + +def test_find_alpha_burned_event_parses_tuple_shape(): + """Confirmed live shape: a positional tuple (coldkey, hotkey, actual_alpha_decrease, netuid).""" + events = [_burn_event_tuple(2, amount=115_259_028_589, netuid=62)] + burn_event = find_alpha_burned_event(events, 2) + assert burn_event.coldkey == COLDKEY + assert burn_event.hotkey == HOTKEY + assert burn_event.alpha_decrease == 115_259_028_589 + assert burn_event.netuid == 62 + + +def test_find_alpha_burned_event_parses_dict_shape(): + """Dict-shaped attributes are supported as a fallback.""" + events = [_burn_event_dict(2, amount=120_344_620_287_164, netuid=62)] + burn_event = find_alpha_burned_event(events, 2) + assert burn_event.coldkey == COLDKEY + assert burn_event.hotkey == HOTKEY + assert burn_event.alpha_decrease == 120_344_620_287_164 + assert burn_event.netuid == 62 + + +def test_find_alpha_burned_event_missing_raises_402(): + events = [_burn_event_tuple(2)] + with pytest.raises(HTTPException) as exc: + find_alpha_burned_event(events, 5) + assert exc.value.status_code == 402 + + +def test_verify_burn_extrinsic_accepts_burn_alpha(): + verify_burn_extrinsic(_extrinsic(call_function="burn_alpha"), COLDKEY) + + +def test_verify_burn_extrinsic_accepts_add_stake_burn(): + verify_burn_extrinsic(_extrinsic(call_function="add_stake_burn"), COLDKEY) + + +def test_verify_burn_extrinsic_rejects_non_burn_call(): + with pytest.raises(HTTPException) as exc: + verify_burn_extrinsic(_extrinsic(call_function="transfer_keep_alive", call_module="Balances"), COLDKEY) + assert exc.value.status_code == 402 + + +def test_verify_burn_extrinsic_rejects_wrong_signer(): + with pytest.raises(HTTPException) as exc: + verify_burn_extrinsic(_extrinsic(coldkey="5Fother"), COLDKEY) + assert exc.value.status_code == 402 diff --git a/tests/queries/test_payments.py b/tests/queries/test_payments.py deleted file mode 100644 index e46bf08e..00000000 --- a/tests/queries/test_payments.py +++ /dev/null @@ -1,36 +0,0 @@ -from datetime import datetime, timedelta, timezone - -import pytest - -import utils.database as _db -from queries.payments import create_payment_quote, reserve_payment - - -@pytest.fixture(autouse=True) -async def clean_tables(postgres_db): - yield - async with _db.pool.acquire() as conn: - await conn.execute("TRUNCATE evaluation_payments, upload_payment_quotes RESTART IDENTITY CASCADE") - - -@pytest.mark.anyio -async def test_create_quote_persists_alpha_amount(): - quote = await create_payment_quote( - miner_hotkey="5FHkey", - amount_alpha_rao=120_344_620_287_164, - expires_at=datetime.now(timezone.utc) + timedelta(hours=1), - ) - assert quote.amount_alpha_rao == 120_344_620_287_164 - - -@pytest.mark.anyio -async def test_reserve_payment_persists_alpha_amount(): - payment = await reserve_payment( - payment_block_hash="0xabc", - payment_extrinsic_index="3", - miner_hotkey="5FHkey", - miner_coldkey="5FCold", - amount_alpha_rao=500_000_000, - ) - assert payment is not None - assert payment.amount_alpha_rao == 500_000_000 From 14f443fc13516146aa016701b7fe5c6052ba5ced Mon Sep 17 00:00:00 2001 From: jmnmv12 Date: Thu, 9 Jul 2026 15:37:09 +0100 Subject: [PATCH 14/14] fix: :card_file_box: Add constraint to validate that at least one of amount_rao or amount_alpha_rao is filled --- .../versions/2026_07_07_alpha_burn_payments.py | 8 ++++++++ db/models/payment.py | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/alembic/versions/2026_07_07_alpha_burn_payments.py b/alembic/versions/2026_07_07_alpha_burn_payments.py index a3601633..faffc65d 100644 --- a/alembic/versions/2026_07_07_alpha_burn_payments.py +++ b/alembic/versions/2026_07_07_alpha_burn_payments.py @@ -18,15 +18,23 @@ depends_on: Union[str, Sequence[str], None] = None +_CHECK_NAME = "ck_amount_rao_xor_amount_alpha_rao" +_CHECK_SQL = "num_nonnulls(amount_rao, amount_alpha_rao) = 1" + + def upgrade() -> None: op.add_column("evaluation_payments", sa.Column("amount_alpha_rao", sa.BigInteger(), nullable=True)) op.add_column("upload_payment_quotes", sa.Column("amount_alpha_rao", sa.BigInteger(), nullable=True)) op.alter_column("evaluation_payments", "amount_rao", existing_type=sa.Integer(), nullable=True) op.alter_column("upload_payment_quotes", "amount_rao", existing_type=sa.BigInteger(), nullable=True) op.alter_column("upload_payment_quotes", "send_address", existing_type=sa.Text(), nullable=True) + op.create_check_constraint(_CHECK_NAME, "evaluation_payments", _CHECK_SQL) + op.create_check_constraint(_CHECK_NAME, "upload_payment_quotes", _CHECK_SQL) def downgrade() -> None: + op.drop_constraint(_CHECK_NAME, "upload_payment_quotes", type_="check") + op.drop_constraint(_CHECK_NAME, "evaluation_payments", type_="check") op.alter_column("upload_payment_quotes", "send_address", existing_type=sa.Text(), nullable=False) op.alter_column("upload_payment_quotes", "amount_rao", existing_type=sa.BigInteger(), nullable=False) op.alter_column("evaluation_payments", "amount_rao", existing_type=sa.Integer(), nullable=False) diff --git a/db/models/payment.py b/db/models/payment.py index 1e3ab70b..eaf48fa4 100644 --- a/db/models/payment.py +++ b/db/models/payment.py @@ -31,7 +31,13 @@ class EvaluationPayment(Base, CreatedAtMixin): amount_rao: Mapped[Optional[int]] = mapped_column(sa.Integer, nullable=True) amount_alpha_rao: Mapped[Optional[int]] = mapped_column(sa.BigInteger, nullable=True) - __table_args__ = (sa.PrimaryKeyConstraint("payment_block_hash", "payment_extrinsic_index"),) + __table_args__ = ( + sa.PrimaryKeyConstraint("payment_block_hash", "payment_extrinsic_index"), + sa.CheckConstraint( + "num_nonnulls(amount_rao, amount_alpha_rao) = 1", + name="ck_amount_rao_xor_amount_alpha_rao", + ), + ) class UploadPaymentQuote(Base, CreatedAtMixin): @@ -47,3 +53,10 @@ class UploadPaymentQuote(Base, CreatedAtMixin): amount_alpha_rao: Mapped[Optional[int]] = mapped_column(sa.BigInteger, nullable=True) send_address: Mapped[Optional[str]] = mapped_column(sa.Text, nullable=True) expires_at: Mapped[datetime] = mapped_column(sa.TIMESTAMP(timezone=True), nullable=False) + + __table_args__ = ( + sa.CheckConstraint( + "num_nonnulls(amount_rao, amount_alpha_rao) = 1", + name="ck_amount_rao_xor_amount_alpha_rao", + ), + )