diff --git a/src/collateral_sdk/abi/Collateral.abi.json b/src/collateral_sdk/abi/Collateral.abi.json index 2278791..5537f1f 100644 --- a/src/collateral_sdk/abi/Collateral.abi.json +++ b/src/collateral_sdk/abi/Collateral.abi.json @@ -156,6 +156,81 @@ "name": "CollateralWithdrawn", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "miner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "contributor", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "ContributorDeposited", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "miner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "contributor", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "ContributorSlashed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "miner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "contributor", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "ContributorWithdrawn", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -278,6 +353,30 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "contributorBalance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -296,6 +395,29 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "miner", + "type": "address" + }, + { + "internalType": "address", + "name": "contributor", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "depositFor", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [], "name": "getSlashedCollateral", @@ -335,6 +457,44 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "minerSlashedCollateral", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "minerTotalCollateral", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "owner", @@ -399,6 +559,29 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "miner", + "type": "address" + }, + { + "internalType": "address", + "name": "contributor", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "slashFromContributor", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [], "name": "slashedCollateral", @@ -412,6 +595,32 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "totalAllCollateral", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalAllSlashed", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "totalCollateral", @@ -425,6 +634,32 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "totalDelegatedCollateral", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalDelegatedSlashedCollateral", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -473,5 +708,28 @@ "outputs": [], "stateMutability": "nonpayable", "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "miner", + "type": "address" + }, + { + "internalType": "address", + "name": "contributor", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "withdrawFor", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" } -] +] \ No newline at end of file diff --git a/src/collateral_sdk/collateral.py b/src/collateral_sdk/collateral.py index 28e8d1c..d74f949 100644 --- a/src/collateral_sdk/collateral.py +++ b/src/collateral_sdk/collateral.py @@ -89,6 +89,7 @@ def subtensor_network(self) -> str: class CollateralManager: """ A class to manage collateral operations on the PTN network. + Supports both regular collateral operations and contributor-based collateral operations. Args: network (Network): The network to use for collateral operations. Defaults to Network.TESTNET. @@ -123,6 +124,20 @@ def subtensor_api(self): self._subtensor_api = SubtensorApi(network=self.network.subtensor_network) return self._subtensor_api + @property + def web3(self) -> Web3: + """Get Web3 instance (cached for performance).""" + if not hasattr(self, "_web3") or self._web3 is None: + self._web3 = Web3(Web3.HTTPProvider(self.network.evm_endpoint)) + return self._web3 + + @property + def collateral_contract(self): + """Get main collateral contract instance (cached for performance).""" + if not hasattr(self, "_collateral_contract") or self._collateral_contract is None: + self._collateral_contract = self.web3.eth.contract(self.program_address, abi=self.abi) + return self._collateral_contract + def _get_stake_added_amount(self, events: list[dict]) -> Balance: """ Extract the stake added amount from the triggered events. @@ -159,10 +174,7 @@ def balance_of(self, address: str) -> int: if not is_valid_ss58_address(address): raise ValueError(f"Invalid SS58 address: {address}") - web3 = Web3(Web3.HTTPProvider(self.network.evm_endpoint)) - contract = web3.eth.contract(self.program_address, abi=self.abi) # pyright: ignore[reportArgumentType, reportCallIssue] - - balance = contract.functions.balanceOf(ss58_to_h160(address)).call() + balance = self.collateral_contract.functions.balanceOf(ss58_to_h160(address)).call() return balance def create_stake_transfer_extrinsic( @@ -192,7 +204,7 @@ def create_stake_transfer_extrinsic( """ if amount <= 0: - raise ValueError("Amount must be greater than zero: {amount}") + raise ValueError(f"Amount must be greater than zero: {amount}") if not is_valid_ss58_address(source_stake): raise ValueError(f"Invalid stake address: {source_stake}") @@ -321,8 +333,7 @@ def deposit( if not (module_name == "SubtensorModule" and function_name == "transfer_stake"): raise ValueError( - f"Invalid extrinsic: expected 'SubtensorModule.transfer_stake', " - f"got '{module_name}.{function_name}'" + f"Invalid extrinsic: expected 'SubtensorModule.transfer_stake', got '{module_name}.{function_name}'" ) if isinstance(call_args := extrinsic["call"]["call_args"], dict): @@ -446,20 +457,19 @@ def deposit( # 4. Deposit the collateral into the EVM contract. for i in range(max_retries): try: - web3 = Web3(Web3.HTTPProvider(self.network.evm_endpoint)) - contract = web3.eth.contract(self.program_address, abi=self.abi) # pyright: ignore[reportArgumentType, reportCallIssue] - - tx = contract.functions.deposit(ss58_to_h160(source_hotkey), stake_added.rao).build_transaction( + tx = self.collateral_contract.functions.deposit( + ss58_to_h160(source_hotkey), stake_added.rao + ).build_transaction( { "chainId": self.network.evm_chain_id, "from": owner_address, - "nonce": web3.eth.get_transaction_count(owner_address, block_identifier="pending"), # pyright: ignore[reportArgumentType] + "nonce": self.web3.eth.get_transaction_count(owner_address, block_identifier="pending"), # pyright: ignore[reportArgumentType] } ) - signed_tx = web3.eth.account.sign_transaction(tx, private_key=owner_private_key) - tx_hash = web3.eth.send_raw_transaction(signed_tx.raw_transaction) - receipt = web3.eth.wait_for_transaction_receipt(tx_hash) + signed_tx = self.web3.eth.account.sign_transaction(tx, private_key=owner_private_key) + tx_hash = self.web3.eth.send_raw_transaction(signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) if receipt["status"] == 1: break @@ -535,20 +545,17 @@ def force_deposit( for i in range(max_retries): try: - web3 = Web3(Web3.HTTPProvider(self.network.evm_endpoint)) - contract = web3.eth.contract(self.program_address, abi=self.abi) # pyright: ignore[reportArgumentType, reportCallIssue] - - tx = contract.functions.deposit(ss58_to_h160(address), amount).build_transaction( + tx = self.collateral_contract.functions.deposit(ss58_to_h160(address), amount).build_transaction( { "chainId": self.network.evm_chain_id, "from": owner_address, - "nonce": web3.eth.get_transaction_count(owner_address), # pyright: ignore[reportArgumentType] + "nonce": self.web3.eth.get_transaction_count(owner_address, block_identifier="pending"), # pyright: ignore[reportArgumentType] } ) - signed_tx = web3.eth.account.sign_transaction(tx, private_key=owner_private_key) - tx_hash = web3.eth.send_raw_transaction(signed_tx.raw_transaction) - receipt = web3.eth.wait_for_transaction_receipt(tx_hash) + signed_tx = self.web3.eth.account.sign_transaction(tx, private_key=owner_private_key) + tx_hash = self.web3.eth.send_raw_transaction(signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) if receipt["status"] == 1: break @@ -599,20 +606,17 @@ def force_withdraw( for i in range(max_retries): try: - web3 = Web3(Web3.HTTPProvider(self.network.evm_endpoint)) - contract = web3.eth.contract(self.program_address, abi=self.abi) # pyright: ignore[reportArgumentType, reportCallIssue] - - tx = contract.functions.withdraw(ss58_to_h160(address), amount).build_transaction( + tx = self.collateral_contract.functions.withdraw(ss58_to_h160(address), amount).build_transaction( { "chainId": self.network.evm_chain_id, "from": owner_address, - "nonce": web3.eth.get_transaction_count(owner_address), # pyright: ignore[reportArgumentType] + "nonce": self.web3.eth.get_transaction_count(owner_address, block_identifier="pending"), # pyright: ignore[reportArgumentType] } ) - signed_tx = web3.eth.account.sign_transaction(tx, private_key=owner_private_key) - tx_hash = web3.eth.send_raw_transaction(signed_tx.raw_transaction) - receipt = web3.eth.wait_for_transaction_receipt(tx_hash) + signed_tx = self.web3.eth.account.sign_transaction(tx, private_key=owner_private_key) + tx_hash = self.web3.eth.send_raw_transaction(signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) if receipt["status"] == 1: break @@ -636,10 +640,7 @@ def get_slashed_collateral(self) -> int: int: The total amount of slashed collateral in Rao unit. """ - web3 = Web3(Web3.HTTPProvider(self.network.evm_endpoint)) - contract = web3.eth.contract(self.program_address, abi=self.abi) # pyright: ignore[reportArgumentType, reportCallIssue] - - balance = contract.functions.getSlashedCollateral().call() + balance = self.collateral_contract.functions.slashedCollateral().call() return balance def get_total_collateral(self) -> int: @@ -650,10 +651,7 @@ def get_total_collateral(self) -> int: int: The total amount of collateral in Rao unit. """ - web3 = Web3(Web3.HTTPProvider(self.network.evm_endpoint)) - contract = web3.eth.contract(self.program_address, abi=self.abi) # pyright: ignore[reportArgumentType, reportCallIssue] - - balance = contract.functions.getTotalCollateral().call() + balance = self.collateral_contract.functions.totalCollateral().call() return balance def slash( @@ -671,7 +669,7 @@ def slash( Args: address (str): The SS58 address to slash from. - amount (float): The amount of alpha tokens to slash in Rao unit. + amount (int): The amount of alpha tokens to slash in Rao unit. owner_address (str): The owner address the EVM contract. owner_private_key (str): The private key of the owner. max_backoff (float): The maximum backoff time in seconds for retries. Defaults to 30.0. @@ -690,7 +688,7 @@ def slash( raise ValueError(f"Invalid SS58 address: {address}") if amount <= 0: - raise ValueError("Amount must be greater than zero: {amount}") + raise ValueError(f"Amount must be greater than zero: {amount}") amount: Balance = Balance.from_rao(amount, netuid=self.network.netuid) @@ -699,20 +697,17 @@ def slash( for i in range(max_retries): try: - web3 = Web3(Web3.HTTPProvider(self.network.evm_endpoint)) - contract = web3.eth.contract(self.program_address, abi=self.abi) # pyright: ignore[reportArgumentType, reportCallIssue] - - tx = contract.functions.slash(ss58_to_h160(address), amount.rao).build_transaction( + tx = self.collateral_contract.functions.slash(ss58_to_h160(address), amount.rao).build_transaction( { "chainId": self.network.evm_chain_id, "from": owner_address, - "nonce": web3.eth.get_transaction_count(owner_address), # pyright: ignore[reportArgumentType] + "nonce": self.web3.eth.get_transaction_count(owner_address, block_identifier="pending"), # pyright: ignore[reportArgumentType] } ) - signed_tx = web3.eth.account.sign_transaction(tx, private_key=owner_private_key) - tx_hash = web3.eth.send_raw_transaction(signed_tx.raw_transaction) - receipt = web3.eth.wait_for_transaction_receipt(tx_hash) + signed_tx = self.web3.eth.account.sign_transaction(tx, private_key=owner_private_key) + tx_hash = self.web3.eth.send_raw_transaction(signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) if receipt["status"] == 1: break @@ -772,7 +767,7 @@ def withdraw( """ if amount <= 0: - raise ValueError("Amount must be greater than zero: {amount}") + raise ValueError(f"Amount must be greater than zero: {amount}") if not is_valid_ss58_address(source_coldkey): raise ValueError(f"Invalid destination SS58 address: {source_coldkey}") @@ -796,20 +791,19 @@ def withdraw( # 1. Withdraw the collateral from the EVM contract. for i in range(max_retries): try: - web3 = Web3(Web3.HTTPProvider(self.network.evm_endpoint)) - contract = web3.eth.contract(self.program_address, abi=self.abi) # pyright: ignore[reportArgumentType, reportCallIssue] - - tx = contract.functions.withdraw(ss58_to_h160(source_hotkey), amount.rao).build_transaction( + tx = self.collateral_contract.functions.withdraw( + ss58_to_h160(source_hotkey), amount.rao + ).build_transaction( { "chainId": self.network.evm_chain_id, "from": owner_address, - "nonce": web3.eth.get_transaction_count(owner_address), # pyright: ignore[reportArgumentType] + "nonce": self.web3.eth.get_transaction_count(owner_address, block_identifier="pending"), # pyright: ignore[reportArgumentType] } ) - signed_tx = web3.eth.account.sign_transaction(tx, private_key=owner_private_key) - tx_hash = web3.eth.send_raw_transaction(signed_tx.raw_transaction) - receipt = web3.eth.wait_for_transaction_receipt(tx_hash) + signed_tx = self.web3.eth.account.sign_transaction(tx, private_key=owner_private_key) + tx_hash = self.web3.eth.send_raw_transaction(signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) if receipt["status"] == 1: break @@ -861,3 +855,688 @@ def withdraw( raise SubtensorError(f"Failed to transfer the stake to the destination wallet: {e}") from e return amount + + # ============================================================================ + # Contributor-related methods + # ============================================================================ + + def contributor_balance(self, miner_address: str, contributor_address: str) -> int: + """ + Get the balance of the deposited alpha tokens for the given miner-contributor pair. + + Args: + miner_address (str): The SS58 address of the miner. + contributor_address (str): The SS58 address of the contributor. + + Returns: + int: The balance of the miner-contributor pair in Rao unit. + """ + + if not is_valid_ss58_address(miner_address): + raise ValueError(f"Invalid miner SS58 address: {miner_address}") + + if not is_valid_ss58_address(contributor_address): + raise ValueError(f"Invalid contributor SS58 address: {contributor_address}") + + balance = self.collateral_contract.functions.contributorBalance( + ss58_to_h160(miner_address), ss58_to_h160(contributor_address) + ).call() + return balance + + def contributor_miner_total(self, miner_address: str) -> int: + """ + Get the total collateral amount for a specific miner across all contributors. + + Args: + miner_address (str): The SS58 address of the miner. + + Returns: + int: The total collateral amount for the miner in Rao unit. + """ + + if not is_valid_ss58_address(miner_address): + raise ValueError(f"Invalid miner SS58 address: {miner_address}") + + total = self.collateral_contract.functions.minerTotalCollateral(ss58_to_h160(miner_address)).call() + return total + + def contributor_miner_slashed(self, miner_address: str) -> int: + """ + Get the total amount of slashed collateral for a specific miner. + + Args: + miner_address (str): The SS58 address of the miner. + + Returns: + int: The total amount of slashed collateral for the miner in Rao unit. + """ + + if not is_valid_ss58_address(miner_address): + raise ValueError(f"Invalid miner SS58 address: {miner_address}") + + slashed = self.collateral_contract.functions.minerSlashedCollateral(ss58_to_h160(miner_address)).call() + return slashed + + def contributor_total(self) -> int: + """ + Get the total amount of collateral in the EVM contract across all miners and contributors. + + Returns: + int: The total amount of collateral in Rao unit. + """ + + balance = self.collateral_contract.functions.totalCollateral().call() + return balance + + def contributor_slashed(self) -> int: + """ + Get the total amount of slashed collateral in the EVM contract. + + Returns: + int: The total amount of slashed collateral in Rao unit. + """ + + balance = self.collateral_contract.functions.slashedCollateral().call() + return balance + + def contributor_deposit( + self, + extrinsic: GenericExtrinsic, + source_hotkey: str, + vault_stake: str, + vault_wallet: Wallet, + miner_address: str, + contributor_address: str, + owner_address: str, + owner_private_key: str, + wallet_password: Optional[str] = None, + max_backoff: float = 30.0, + max_retries: int = 3, + ) -> Balance: + """ + Submit the extrinsic to the Subtensor network and deposit the alpha tokens into the EVM contract. + This function should be called on the owner validator side. + + Args: + extrinsic (GenericExtrinsic): The signed extrinsic for the stake transfer. + source_hotkey (str): The source miner hotkey to deposit from. + vault_stake (str): The stake's SS58 address of the vault to deposit the alpha tokens to. + vault_wallet (Wallet): The wallet of the vault. + miner_address (str): The SS58 address of the miner to deposit for. + contributor_address (str): The SS58 address of the contributor. + owner_address (str): The owner address of the EVM contract. + owner_private_key (str): The private key of the owner. + wallet_password (Optional[str]): The password for the source wallet. + max_backoff (float): The maximum backoff time in seconds for retries. Defaults to 30.0. + max_retries (int): The maximum number of attempts to retry. Defaults to 3. + + Returns: + Balance: The amount of alpha tokens deposited. + + CAUTION: + This method is assumed to be called from a trusted node such as a owner/super validator. + The owner/super validator should store the private key in a secure location, load and pass it to this method. + NEVER, NEVER expose the private key to the public! + + IMPORTANT: + If a critical error occurs, log the error and transfer the stake back to the source address manually! + """ + + # Input validation + if not is_valid_ss58_address(miner_address): + raise ValueError(f"Invalid miner SS58 address: {miner_address}") + + if not is_valid_ss58_address(contributor_address): + raise ValueError(f"Invalid contributor SS58 address: {contributor_address}") + + origin_coldkey = ( + ss58_encode((extrinsic["address"].value)) + if extrinsic["address"].value.startswith("0x") or extrinsic["address"].value.startswith("0X") + else extrinsic["address"].value + ) + + call = extrinsic["call"] + module_name = call["call_module"]["name"].value + function_name = call["call_function"]["name"].value + + if not (module_name == "SubtensorModule" and function_name == "transfer_stake"): + raise ValueError( + f"Invalid extrinsic: expected 'SubtensorModule.transfer_stake', got '{module_name}.{function_name}'" + ) + + if isinstance(call_args := extrinsic["call"]["call_args"], dict): + destination_coldkey = call_args["destination_coldkey"].value + destination_netuid = call_args["destination_netuid"].value + origin_hotkey = call_args["hotkey"].value + origin_netuid = call_args["origin_netuid"].value + else: + try: + call_args = extrinsic.value["call"]["call_args"] # pyright: ignore[reportOptionalSubscript] + destination_coldkey = next(arg for arg in call_args if arg["name"] == "destination_coldkey")["value"] + destination_netuid = next(arg for arg in call_args if arg["name"] == "destination_netuid")["value"] + origin_hotkey = next(arg for arg in call_args if arg["name"] == "hotkey")["value"] + origin_netuid = next(arg for arg in call_args if arg["name"] == "origin_netuid")["value"] + except StopIteration: + raise ValueError("Invalid extrinsic: missing required call arguments") + + if destination_coldkey != vault_wallet.coldkeypub.ss58_address: + raise ValueError( + f"The extrinsic's destination {destination_coldkey} does not match the vault wallet {vault_wallet.coldkeypub.ss58_address}" + ) + + if destination_netuid != self.network.netuid: + raise ValueError( + f"The extrinsic's destination netuid {destination_netuid} does not match the network's netuid {self.network.netuid}" + ) + + if origin_hotkey != source_hotkey: + raise ValueError( + f"The extrinsic's origin hotkey {origin_hotkey} does not match the source hotkey {source_hotkey}" + ) + + if origin_netuid != self.network.netuid: + raise ValueError( + f"The extrinsic's origin netuid {origin_netuid} does not match the network's netuid {self.network.netuid}" + ) + + # 1. Transfer the stake to the vault wallet. + try: + result: ExtrinsicReceipt = self.subtensor_api._subtensor.substrate.submit_extrinsic( + extrinsic, + wait_for_inclusion=True, + ) + + if result.is_success: + try: + stake_added = self._get_stake_added_amount(result.triggered_events) + except ValueError: + raise CriticalError("A stake has been transferred, but the amount is unknown") + else: + raise ChainError.from_error(result.error_message) + + except CriticalError: + raise + except Exception as e: + raise SubtensorError(f"Failed to transfer the stake to the vault wallet: {e}") from e + + # 2. Move the stake to the vault's stake address. + if origin_hotkey != vault_stake: + try: + move_call: GenericCall = self.subtensor_api._subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="move_stake", + call_params={ + "origin_hotkey": origin_hotkey, + "origin_netuid": self.network.netuid, + "destination_hotkey": vault_stake, + "destination_netuid": self.network.netuid, + "alpha_amount": stake_added.rao, + }, + ) + + move_extrinsic: GenericExtrinsic = self.subtensor_api._subtensor.substrate.create_signed_extrinsic( + call=move_call, + keypair=vault_wallet.get_coldkey(wallet_password) if wallet_password else vault_wallet.coldkey, + ) + + result: ExtrinsicReceipt = self.subtensor_api._subtensor.substrate.submit_extrinsic( + move_extrinsic, + wait_for_inclusion=True, + ) + + if result.is_success: + try: + stake_added = self._get_stake_added_amount(result.triggered_events) + except ValueError: + raise CriticalError("A stake has been transferred and moved, but the amount is unknown") + else: + raise ChainError.from_error(result.error_message) + + except CriticalError: + raise + except Exception as e: + # 3. Revert the stake transfer if the stake move fails. + try: + revert_extrinsic: GenericExtrinsic = self.create_stake_transfer_extrinsic( + amount=stake_added.rao, + source_stake=origin_hotkey, + source_wallet=vault_wallet, + dest=origin_coldkey, + wallet_password=wallet_password, + ) + + result: ExtrinsicReceipt = self.subtensor_api._subtensor.substrate.submit_extrinsic( + revert_extrinsic, + wait_for_inclusion=True, + ) + + if not result.is_success: + raise ChainError.from_error(result.error_message) + + except Exception as e: + # When the revert fails, raise a critical error. + raise CriticalError(f"Failed to revert the stake transfer: {e}") from e + + # After reverting the stake transfer, raise an error for the stake move failure. + raise SubtensorError(f"Failed to move the stake to the vault's stake address: {e}") from e + + # 4. Deposit the collateral into the EVM contract. + for i in range(max_retries): + try: + tx = self.collateral_contract.functions.depositFor( + ss58_to_h160(miner_address), ss58_to_h160(contributor_address), stake_added.rao + ).build_transaction( + { + "chainId": self.network.evm_chain_id, + "from": owner_address, + "nonce": self.web3.eth.get_transaction_count(owner_address, block_identifier="pending"), # pyright: ignore[reportArgumentType] + } + ) + + signed_tx = self.web3.eth.account.sign_transaction(tx, private_key=owner_private_key) + tx_hash = self.web3.eth.send_raw_transaction(signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) + + if receipt["status"] == 1: + break + else: + raise RuntimeError( + f"Transaction failed: {tx_hash.hex()}" if tx_hash in dir() else "Transaction failed" + ) + + except Exception as e: + if i < max_retries - 1: + time.sleep(min(2**i, max_backoff)) + continue + else: + # 5. Revert the stake transfer if deposit in the contributor EVM fails. + try: + revert_extrinsic: GenericExtrinsic = self.create_stake_transfer_extrinsic( + amount=stake_added.rao, + source_stake=vault_stake, + source_wallet=vault_wallet, + dest=origin_coldkey, + wallet_password=wallet_password, + ) + + result: ExtrinsicReceipt = self.subtensor_api._subtensor.substrate.submit_extrinsic( + revert_extrinsic, + wait_for_inclusion=True, + ) + + if not result.is_success: + raise ChainError.from_error(result.error_message) + + except Exception as e: + # When the revert fails, raise a critical error. + raise CriticalError(f"Failed to revert the stake transfer: {e}") from e + + # After reverting the stake transfer, raise an error for the deposit failure. + raise EVMError(f"Failed to deposit into the EVM contract: {e}") from e + + return stake_added + + def contributor_withdraw( + self, + amount: int, + source_coldkey: str, + source_hotkey: str, + vault_stake: str, + vault_wallet: Wallet, + miner_address: str, + contributor_address: str, + owner_address: str, + owner_private_key: str, + wallet_password: Optional[str] = None, + max_backoff: float = 30.0, + max_retries: int = 3, + ) -> Balance: + """ + Submit the extrinsic to the Subtensor network and withdraw the alpha tokens from the EVM contract. + This function should be called on the owner validator side. + + Args: + amount (int): The alpha token amount to withdraw in Rao unit. + source_coldkey (str): The source miner coldkey to withdraw the alpha tokens. + source_hotkey (str): The source miner hotkey to withdraw the alpha tokens. + vault_stake (str): The stake's SS58 address of the vault to withdraw the alpha tokens from. + vault_wallet (Wallet): The wallet of the vault. + miner_address (str): The SS58 address of the miner to withdraw for. + contributor_address (str): The SS58 address of the contributor. + owner_address (str): The owner address of the EVM contract. + owner_private_key (str): The private key of the owner. + wallet_password (Optional[str]): The password for the source wallet. + max_backoff (float): The maximum backoff time in seconds for retries. Defaults to 30.0. + max_retries (int): The maximum number of attempts to retry. Defaults to 3. + + Returns: + Balance: The amount of alpha tokens withdrawn. + + CAUTION: + This method is assumed to be called from a trusted node such as a owner/super validator. + The owner/super validator should store the private key in a secure location, load and pass it to this method. + NEVER, NEVER expose the private key to the public! + + IMPORTANT: + If a critical error occurs, log the error and force deposit the withdrawn amount back to the destination address manually! + """ + + if amount <= 0: + raise ValueError(f"Amount must be greater than zero: {amount}") + + if not is_valid_ss58_address(source_coldkey): + raise ValueError(f"Invalid source coldkey SS58 address: {source_coldkey}") + + if not is_valid_ss58_address(source_hotkey): + raise ValueError(f"Invalid source hotkey SS58 address: {source_hotkey}") + + if not is_valid_ss58_address(vault_stake): + raise ValueError(f"Invalid vault stake SS58 address: {vault_stake}") + + if not is_valid_ss58_address(miner_address): + raise ValueError(f"Invalid miner SS58 address: {miner_address}") + + if not is_valid_ss58_address(contributor_address): + raise ValueError(f"Invalid contributor SS58 address: {contributor_address}") + + amount: Balance = Balance.from_rao(amount, netuid=self.network.netuid) + + if amount > ( + balance := Balance.from_rao( + self.contributor_balance(miner_address, contributor_address), netuid=self.network.netuid + ) + ): + raise ValueError(f"Insufficient balance: {balance}, requested: {amount}") + + # 1. Withdraw the collateral from the EVM contract. + for i in range(max_retries): + try: + tx = self.collateral_contract.functions.withdrawFor( + ss58_to_h160(miner_address), ss58_to_h160(contributor_address), amount.rao + ).build_transaction( + { + "chainId": self.network.evm_chain_id, + "from": owner_address, + "nonce": self.web3.eth.get_transaction_count(owner_address, block_identifier="pending"), # pyright: ignore[reportArgumentType] + } + ) + + signed_tx = self.web3.eth.account.sign_transaction(tx, private_key=owner_private_key) + tx_hash = self.web3.eth.send_raw_transaction(signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) + + if receipt["status"] == 1: + break + else: + raise RuntimeError( + f"Transaction failed: {tx_hash.hex()}" if tx_hash in dir() else "Transaction failed" + ) + + except Exception as e: + if i < max_retries - 1: + time.sleep(min(2**i, max_backoff)) + continue + else: + raise EVMError(f"Failed to withdraw from the EVM contract: {e}") from e + + # 2. Transfer the stake to the source coldkey. + try: + transfer_extrinsic: GenericExtrinsic = self.create_stake_transfer_extrinsic( + amount=amount.rao, + source_stake=vault_stake, + source_wallet=vault_wallet, + dest=source_coldkey, + wallet_password=wallet_password, + ) + + result: ExtrinsicReceipt = self.subtensor_api._subtensor.substrate.submit_extrinsic( + transfer_extrinsic, + wait_for_inclusion=True, + ) + + if not result.is_success: + raise ChainError.from_error(result.error_message) + + except Exception as e: + # 3. Revert the withdrawal if the stake transfer fails. + try: + self.contributor_force_deposit( + amount=amount.rao, + miner_address=miner_address, + contributor_address=contributor_address, + owner_address=owner_address, + owner_private_key=owner_private_key, + ) + + except Exception as e: + # When the revert fails, raise a critical error. + raise CriticalError(f"Failed to revert the withdrawal from the EVM contract: {e}") from e + + # After reverting the withdrawal, raise an error for the stake transfer failure. + raise SubtensorError(f"Failed to transfer the stake to the destination wallet: {e}") from e + + return amount + + def contributor_force_deposit( + self, + miner_address: str, + contributor_address: str, + amount: int, + owner_address: str, + owner_private_key: str, + max_backoff: float = 30.0, + max_retries: int = 3, + ) -> None: + """ + Force deposit the specified amount of alpha tokens into the EVM contract without a stake transfer. + This function should be called on the owner validator side. + + Args: + miner_address (str): The SS58 address of the miner to deposit for. + contributor_address (str): The SS58 address of the contributor. + amount (int): The amount of alpha tokens to deposit in Rao unit. + owner_address (str): The owner address of the EVM contract. + owner_private_key (str): The private key of the owner. + max_backoff (float): The maximum backoff time in seconds for retries. Defaults to 30.0. + max_retries (int): The maximum number of attempts to retry. Defaults to 3. + + Returns: + None + + CAUTION: + This method is assumed to be called from a trusted node such as a owner/super validator. + The owner/super validator should store the private key in a secure location, load and pass it to this method. + NEVER, NEVER expose the private key to the public! + """ + + if not is_valid_ss58_address(miner_address): + raise ValueError(f"Invalid miner SS58 address: {miner_address}") + + if not is_valid_ss58_address(contributor_address): + raise ValueError(f"Invalid contributor SS58 address: {contributor_address}") + + if amount <= 0: + raise ValueError(f"Amount must be greater than zero: {amount}") + + for i in range(max_retries): + try: + tx = self.collateral_contract.functions.depositFor( + ss58_to_h160(miner_address), ss58_to_h160(contributor_address), amount + ).build_transaction( + { + "chainId": self.network.evm_chain_id, + "from": owner_address, + "nonce": self.web3.eth.get_transaction_count(owner_address, block_identifier="pending"), # pyright: ignore[reportArgumentType] + } + ) + + signed_tx = self.web3.eth.account.sign_transaction(tx, private_key=owner_private_key) + tx_hash = self.web3.eth.send_raw_transaction(signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) + + if receipt["status"] == 1: + break + else: + raise RuntimeError( + f"Transaction failed: {tx_hash.hex()}" if tx_hash in dir() else "Transaction failed" + ) + + except Exception as e: + if i < max_retries - 1: + time.sleep(min(2**i, max_backoff)) + continue + else: + raise EVMError(f"Failed to force deposit for miner-contributor pair: {e}") from e + + def contributor_force_withdraw( + self, + miner_address: str, + contributor_address: str, + amount: int, + owner_address: str, + owner_private_key: str, + max_backoff: float = 30.0, + max_retries: int = 3, + ) -> None: + """ + Force withdraw the specified amount of alpha tokens from the EVM contract without a stake transfer. + This function should be called on the owner validator side. + + Args: + miner_address (str): The SS58 address of the miner to withdraw for. + contributor_address (str): The SS58 address of the contributor. + amount (int): The amount of alpha tokens to withdraw in Rao unit. + owner_address (str): The owner address of the EVM contract. + owner_private_key (str): The private key of the owner. + max_backoff (float): The maximum backoff time in seconds for retries. Defaults to 30.0. + max_retries (int): The maximum number of attempts to retry. Defaults to 3. + + Returns: + None + + CAUTION: + This method is assumed to be called from a trusted node such as a owner/super validator. + The owner/super validator should store the private key in a secure location, load and pass it to this method. + NEVER, NEVER expose the private key to the public! + """ + + if not is_valid_ss58_address(miner_address): + raise ValueError(f"Invalid miner SS58 address: {miner_address}") + + if not is_valid_ss58_address(contributor_address): + raise ValueError(f"Invalid contributor SS58 address: {contributor_address}") + + if amount <= 0: + raise ValueError(f"Amount must be greater than zero: {amount}") + + for i in range(max_retries): + try: + tx = self.collateral_contract.functions.withdrawFor( + ss58_to_h160(miner_address), ss58_to_h160(contributor_address), amount + ).build_transaction( + { + "chainId": self.network.evm_chain_id, + "from": owner_address, + "nonce": self.web3.eth.get_transaction_count(owner_address, block_identifier="pending"), # pyright: ignore[reportArgumentType] + } + ) + + signed_tx = self.web3.eth.account.sign_transaction(tx, private_key=owner_private_key) + tx_hash = self.web3.eth.send_raw_transaction(signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) + + if receipt["status"] == 1: + break + else: + raise RuntimeError( + f"Transaction failed: {tx_hash.hex()}" if tx_hash in dir() else "Transaction failed" + ) + + except Exception as e: + if i < max_retries - 1: + time.sleep(min(2**i, max_backoff)) + continue + else: + raise EVMError(f"Failed to force withdraw for miner-contributor pair: {e}") from e + + def contributor_slash( + self, + miner_address: str, + contributor_address: str, + amount: int, # pyright: ignore[reportRedeclaration] + owner_address: str, + owner_private_key: str, + max_backoff: float = 30.0, + max_retries: int = 3, + ) -> Balance: + """ + Slash the specified amount of alpha tokens from the EVM contract. + This function should be called on the owner validator side. + + Args: + miner_address (str): The SS58 address of the miner. + contributor_address (str): The SS58 address of the contributor to slash from. + amount (int): The amount of alpha tokens to slash in Rao unit. + owner_address (str): The owner address of the EVM contract. + owner_private_key (str): The private key of the owner. + max_backoff (float): The maximum backoff time in seconds for retries. Defaults to 30.0. + max_retries (int): The maximum number of attempts to retry. Defaults to 3. + + Returns: + Balance: The amount of alpha tokens slashed. + + CAUTION: + This method is assumed to be called from a trusted node such as a owner/super validator. + The owner/super validator should store the private key in a secure location, load and pass it to this method. + NEVER, NEVER expose the private key to the public! + """ + + if not is_valid_ss58_address(miner_address): + raise ValueError(f"Invalid miner SS58 address: {miner_address}") + + if not is_valid_ss58_address(contributor_address): + raise ValueError(f"Invalid contributor SS58 address: {contributor_address}") + + if amount <= 0: + raise ValueError(f"Amount must be greater than zero: {amount}") + + amount: Balance = Balance.from_rao(amount, netuid=self.network.netuid) + + if amount > ( + balance := Balance.from_rao( + self.contributor_balance(miner_address, contributor_address), netuid=self.network.netuid + ) + ): + raise ValueError(f"Insufficient balance: {balance}, requested: {amount}") + + for i in range(max_retries): + try: + tx = self.collateral_contract.functions.slashFromContributor( + ss58_to_h160(miner_address), ss58_to_h160(contributor_address), amount.rao + ).build_transaction( + { + "chainId": self.network.evm_chain_id, + "from": owner_address, + "nonce": self.web3.eth.get_transaction_count(owner_address, block_identifier="pending"), # pyright: ignore[reportArgumentType] + } + ) + + signed_tx = self.web3.eth.account.sign_transaction(tx, private_key=owner_private_key) + tx_hash = self.web3.eth.send_raw_transaction(signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) + + if receipt["status"] == 1: + break + else: + raise RuntimeError( + f"Transaction failed: {tx_hash.hex()}" if tx_hash in dir() else "Transaction failed" + ) + + except Exception as e: + if i < max_retries - 1: + time.sleep(min(2**i, max_backoff)) + continue + else: + raise EVMError(f"Failed to slash from contributor: {e}") from e + + return amount