-
Notifications
You must be signed in to change notification settings - Fork 52
feat: Burn alpha upload payment #427
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jmnmv12
wants to merge
14
commits into
staging
Choose a base branch
from
feat/burn_alpha_upload_payment
base: staging
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 11 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
6bc3ca8
feat: derive alpha price on-chain via get_subnet_price
jmnmv12 1fbafd3
fix: restore SN62_NETUID constant and get_alpha_price_tao signature
jmnmv12 cd56bfa
fix: use config.NETUID in get_alpha_price_tao, matching SubtensorClie…
jmnmv12 123609c
feat: add get_alpha_stake to SubtensorClient
jmnmv12 69135b4
feat: add amount_alpha_rao columns for alpha-burn payments
jmnmv12 16cc259
feat: return amount_alpha_rao from upload pricing responses
jmnmv12 bdad03f
feat: store amount_alpha_rao in payment quotes and reservations
jmnmv12 80e95f1
feat: add alpha-burn event and extrinsic verification helpers
jmnmv12 a45e101
feat: verify alpha burn via AlphaBurned event in upload endpoint
jmnmv12 635f9fe
feat: CLI submits SN62 alpha burn instead of TAO transfer
jmnmv12 2dab867
polish: minor fixes
jmnmv12 f331cd7
fix: :bug: Address PR comments
jmnmv12 07b5794
refactor: :recycle: Refactor test structure and alphaburned event exa…
jmnmv12 14f443f
fix: :card_file_box: Add constraint to validate that at least one of …
jmnmv12 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,15 +12,16 @@ | |
| 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_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 | ||
|
|
@@ -50,6 +51,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() | ||
|
|
@@ -94,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, | ||
|
|
@@ -108,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, | ||
| ) | ||
|
|
||
|
|
@@ -231,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: | ||
|
|
@@ -241,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)): | ||
|
|
@@ -307,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, | ||
| ) | ||
|
|
||
|
|
@@ -411,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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 40% is a lot, maybe 10%? |
||
|
|
||
| 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: | ||
|
|
@@ -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") | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.