diff --git a/allways/cli/swap_commands/admin.py b/allways/cli/swap_commands/admin.py index ebc08df..2d933ec 100644 --- a/allways/cli/swap_commands/admin.py +++ b/allways/cli/swap_commands/admin.py @@ -20,10 +20,9 @@ def admin_group(): set-min-swap Set minimum swap amount (0 = no minimum) set-max-swap Set maximum swap amount (0 = no maximum) set-votes Set required validator votes - set-recycle-address Set address for fee recycling transfers add-vali Add a validator remove-vali Remove a validator - recycle-fees Recycle accumulated fees (stake + burn alpha) + recycle-fees Stake accumulated fees on-chain via chain extension transfer-ownership Transfer contract ownership danger halt Halt the system (block new reservations) danger resume Resume the system (allow new reservations) @@ -411,42 +410,28 @@ def remove_vali(hotkey: str): console.print(f'[red]Failed to remove validator: {e}[/red]\n') -@admin_group.command('set-recycle-address') -@click.argument('account_id', type=str) -def set_recycle_address(account_id: str): - """Set the address where recycled fees are transferred. - - Example: - alw admin set-recycle-address 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY - """ - _, wallet, _, client = get_cli_context() - - console.print('\n[bold]Set Recycle Address[/bold]\n') - console.print(f' Address: {account_id}\n') - - if not click.confirm('Confirm?'): - console.print('[yellow]Cancelled[/yellow]') - return - - try: - with loading('Submitting transaction...'): - client.set_recycle_address(wallet=wallet, address=account_id) - console.print(f'[green]Recycle address set to {account_id}[/green]\n') - except ContractError as e: - console.print(f'[red]Failed to set recycle address: {e}[/red]\n') - - @admin_group.command('recycle-fees') def recycle_fees(): - """Transfer accumulated fees to the designated recycle address. + """Stake accumulated fees on-chain via chain extension. Example: alw admin recycle-fees """ _, wallet, _, client = get_cli_context() + try: + accumulated = client.get_accumulated_fees() + except ContractError: + accumulated = None + + if accumulated is not None and accumulated == 0: + console.print('\n[yellow]No accumulated fees to recycle[/yellow]\n') + return + + fee_display = f'{from_rao(accumulated):.4f} TAO' if accumulated else 'unknown' console.print('\n[bold]Recycle Fees[/bold]\n') - console.print(' Action: transfer all accumulated fees to recycle address\n') + console.print(f' Accumulated fees: {fee_display}') + console.print(' Action: stake fees on-chain via chain extension\n') if not click.confirm('Confirm recycling fees?'): console.print('[yellow]Cancelled[/yellow]') @@ -457,7 +442,10 @@ def recycle_fees(): client.recycle_fees(wallet=wallet) console.print('[green]Fees recycled successfully[/green]\n') except ContractError as e: - console.print(f'[red]Failed to recycle fees: {e}[/red]\n') + console.print(f'[red]Failed to recycle fees: {e}[/red]') + if 'ContractReverted' in str(e): + console.print('[dim]Hint: treasury hotkey may not be registered on the subnet[/dim]') + console.print() @admin_group.command('transfer-ownership') diff --git a/allways/cli/swap_commands/view.py b/allways/cli/swap_commands/view.py index d1342b2..7a60514 100644 --- a/allways/cli/swap_commands/view.py +++ b/allways/cli/swap_commands/view.py @@ -430,7 +430,6 @@ def _read(fn, default=None): accumulated_fees_rao = _read(client.get_accumulated_fees) total_recycled_rao = _read(client.get_total_recycled_fees) owner = _read(client.get_owner) - recycle_address = _read(client.get_recycle_address, default=None) except ContractError as e: console.print(f'[red]Failed to read contract parameters: {e}[/red]') return @@ -457,8 +456,6 @@ def _read(fn, default=None): table.add_row('Accumulated Fees', f'{from_rao(accumulated_fees_rao):.4f} TAO') table.add_row('Total Recycled Fees', f'{from_rao(total_recycled_rao):.4f} TAO') table.add_row('Owner', owner) - if recycle_address: - table.add_row('Recycle Address', recycle_address) console.print(table) console.print() diff --git a/allways/contract_client.py b/allways/contract_client.py index 1c65ed9..9cfb62d 100644 --- a/allways/contract_client.py +++ b/allways/contract_client.py @@ -47,7 +47,6 @@ 'set_consensus_threshold': bytes.fromhex('c0d8ec47'), 'set_min_swap_amount': bytes.fromhex('800e1573'), 'set_max_swap_amount': bytes.fromhex('3e868f32'), - 'set_recycle_address': bytes.fromhex('50dfe685'), 'set_reservation_ttl': bytes.fromhex('3143d9e3'), 'set_fee_divisor': bytes.fromhex('8832de41'), 'recycle_fees': bytes.fromhex('97756ea1'), @@ -65,7 +64,6 @@ 'get_accumulated_fees': bytes.fromhex('bf3b5d4e'), 'get_total_recycled_fees': bytes.fromhex('9910e939'), 'get_owner': bytes.fromhex('07fcd0b1'), - 'get_recycle_address': bytes.fromhex('3847e06c'), 'get_pending_slash': bytes.fromhex('48c78c4a'), 'get_min_swap_amount': bytes.fromhex('fca7daa4'), 'get_max_swap_amount': bytes.fromhex('97826e04'), @@ -130,7 +128,6 @@ 'set_consensus_threshold': [('percent', 'u8')], 'set_min_swap_amount': [('amount', 'u128')], 'set_max_swap_amount': [('amount', 'u128')], - 'set_recycle_address': [('address', 'AccountId')], 'set_reservation_ttl': [('blocks', 'u32')], 'set_fee_divisor': [('divisor', 'u128')], 'recycle_fees': [], @@ -243,6 +240,7 @@ class ContractErrorKind(Enum): 24: ('HashMismatch', 'Request hash does not match computed hash'), 25: ('PendingConflict', 'A pending vote exists for a different request'), 26: ('SameChain', 'Source and destination chains must be different'), + 27: ('SystemHalted', 'System is halted — no new activity allowed'), } @@ -843,9 +841,6 @@ def get_owner(self) -> str: def get_halted(self) -> bool: return self._read_bool('get_halted') - def get_recycle_address(self) -> str: - return self._read_account_id('get_recycle_address') - def is_validator(self, account: str) -> bool: return self._read_bool('is_validator', {'account': account}) @@ -1112,12 +1107,6 @@ def set_min_swap_amount(self, wallet: bt.Wallet, amount_rao: int) -> str: bt.logging.info(f'Min swap amount set to {amount_rao}: {tx_hash}') return tx_hash - def set_recycle_address(self, wallet: bt.Wallet, address: str) -> str: - self._ensure_initialized() - tx_hash = self._exec_contract_raw('set_recycle_address', args={'address': address}, keypair=wallet.hotkey) - bt.logging.info(f'Recycle address set to {address}: {tx_hash}') - return tx_hash - def set_reservation_ttl(self, wallet: bt.Wallet, blocks: int) -> str: self._ensure_initialized() tx_hash = self._exec_contract_raw('set_reservation_ttl', args={'blocks': blocks}, keypair=wallet.hotkey) diff --git a/allways/metadata/allways_swap_manager.json b/allways/metadata/allways_swap_manager.json index 7b94053..306916e 100644 --- a/allways/metadata/allways_swap_manager.json +++ b/allways/metadata/allways_swap_manager.json @@ -1,6 +1,6 @@ { "source": { - "hash": "0x30b3f2dc49bdb3ba3843f7c0851a8036e6b4f89e76132515d88653301a41ff15", + "hash": "0x15206c9262e680d8c0d5ce92e3b880e2d4c0c30d6e2e489c706be5d3904405cc", "language": "ink! 5.1.1", "compiler": "rustc 1.91.1", "build_info": { @@ -6419,9 +6419,8 @@ "variant": {} }, "path": [ - "ink_env", - "types", - "NoChainExtension" + "allways_swap_manager", + "SubtensorExtension" ] } } diff --git a/smart-contracts/ink/errors.rs b/smart-contracts/ink/errors.rs index 33ea324..4812c78 100644 --- a/smart-contracts/ink/errors.rs +++ b/smart-contracts/ink/errors.rs @@ -58,6 +58,6 @@ pub enum Error { PendingConflict, /// Source and destination chains must be different SameChain, - /// System is halted — no new reservations allowed + /// System is halted — no new activity allowed SystemHalted, } diff --git a/smart-contracts/ink/events.rs b/smart-contracts/ink/events.rs index de9299d..6d290a1 100644 --- a/smart-contracts/ink/events.rs +++ b/smart-contracts/ink/events.rs @@ -118,10 +118,21 @@ pub struct ConfigUpdated { pub value: u128, } -/// Event emitted when accumulated fees are recycled (transferred to recycle address) +/// Event emitted when accumulated fees are recycled — either via the subtensor +/// chain extension (`via_chain_ext = true`) or via the immutable custodial +/// fallback address (`via_chain_ext = false`). #[ink::event] pub struct FeesRecycled { pub tao_amount: u128, + pub via_chain_ext: bool, +} + +/// Emitted exactly once, when `recycle_fees` successfully calls the chain +/// extension for the first time. After this fires, the fallback custodial +/// path is permanently disabled. +#[ink::event] +pub struct ChainExtensionLatched { + pub at_block: u32, } /// Event emitted when ownership is transferred diff --git a/smart-contracts/ink/lib.rs b/smart-contracts/ink/lib.rs index 703cafb..a69491f 100644 --- a/smart-contracts/ink/lib.rs +++ b/smart-contracts/ink/lib.rs @@ -7,7 +7,48 @@ mod events; use types::{SwapData, SwapStatus, VoteType}; use errors::Error; -#[ink::contract] +#[ink::chain_extension(extension = 0x1000)] +pub trait SubtensorExtension { + type ErrorCode = SubtensorError; + + #[ink(function = 18)] + fn add_stake_recycle( + hotkey: ::AccountId, + netuid: u16, + amount: u64, + ) -> u64; +} + +#[ink::scale_derive(Encode, Decode, TypeInfo)] +pub enum SubtensorError { + ChainExtensionFailed, +} + +impl ink::env::chain_extension::FromStatusCode for SubtensorError { + fn from_status_code(status_code: u32) -> Result<(), Self> { + match status_code { + 0 => Ok(()), + _ => Err(Self::ChainExtensionFailed), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[ink::scale_derive(TypeInfo)] +pub enum CustomEnvironment {} + +impl ink::env::Environment for CustomEnvironment { + const MAX_EVENT_TOPICS: usize = + ::MAX_EVENT_TOPICS; + type AccountId = ::AccountId; + type Balance = ::Balance; + type Hash = ::Hash; + type Timestamp = ::Timestamp; + type BlockNumber = ::BlockNumber; + type ChainExtension = SubtensorExtension; +} + +#[ink::contract(env = crate::CustomEnvironment)] mod allways_swap_manager { use super::*; use events::*; @@ -21,7 +62,14 @@ mod allways_swap_manager { // Configuration owner: AccountId, treasury_hotkey: AccountId, + // Immutable custodial fallback: where fees land until the subtensor + // chain extension is live. No setter — constructor-only. Once + // `chain_ext_enabled` latches to true, this address is never used again. recycle_address: AccountId, + // One-way latch: flips to true the first time `add_stake_recycle` + // succeeds. Never flips back. After latching, recycle_fees has no + // fallback and must use the chain extension. + chain_ext_enabled: bool, netuid: u16, fulfillment_timeout_blocks: u32, reservation_ttl: u32, @@ -238,6 +286,7 @@ mod allways_swap_manager { owner: Self::env().caller(), treasury_hotkey, recycle_address, + chain_ext_enabled: false, netuid, fulfillment_timeout_blocks, reservation_ttl, @@ -1082,13 +1131,6 @@ mod allways_swap_manager { Ok(()) } - #[ink(message)] - pub fn set_recycle_address(&mut self, address: AccountId) -> Result<(), Error> { - self.ensure_owner()?; - self.recycle_address = address; - Ok(()) - } - #[ink(message)] pub fn set_reservation_ttl(&mut self, blocks: u32) -> Result<(), Error> { self.ensure_owner()?; @@ -1125,22 +1167,61 @@ mod allways_swap_manager { Ok(()) } + /// Recycle accumulated fees. Permissionless — anyone can call. + /// + /// Once the chain extension has latched (success observed at least once), + /// this function can only succeed via the chain extension. Before that, + /// it probes the chain extension on every call; on failure, it falls + /// back to transferring fees to the immutable `recycle_address`. #[ink(message)] pub fn recycle_fees(&mut self) -> Result<(), Error> { - self.ensure_owner()?; - let fees = self.accumulated_fees; if fees == 0 { return Err(Error::ZeroAmount); } - self.env().transfer(self.recycle_address, fees) + if self.chain_ext_enabled { + let amount: u64 = fees.try_into().map_err(|_| Error::TransferFailed)?; + self.env() + .extension() + .add_stake_recycle(self.treasury_hotkey, self.netuid, amount) + .map_err(|_| Error::TransferFailed)?; + self.finalize_recycle(fees, true); + return Ok(()); + } + + let chain_ext_ok = match u64::try_from(fees) { + Ok(amount) => self + .env() + .extension() + .add_stake_recycle(self.treasury_hotkey, self.netuid, amount) + .is_ok(), + Err(_) => false, + }; + + if chain_ext_ok { + self.chain_ext_enabled = true; + self.env().emit_event(ChainExtensionLatched { + at_block: self.env().block_number(), + }); + self.finalize_recycle(fees, true); + return Ok(()); + } + + self.env() + .transfer(self.recycle_address, fees) .map_err(|_| Error::TransferFailed)?; + self.finalize_recycle(fees, false); + Ok(()) + } + fn finalize_recycle(&mut self, fees: Balance, via_chain_ext: bool) { self.accumulated_fees = 0; self.total_recycled_fees = self.total_recycled_fees.saturating_add(fees); - self.env().emit_event(FeesRecycled { tao_amount: fees }); - Ok(()) + self.env().emit_event(FeesRecycled { + tao_amount: fees, + via_chain_ext, + }); } // ===================================================================== @@ -1212,6 +1293,16 @@ mod allways_swap_manager { self.total_recycled_fees } + #[ink(message)] + pub fn get_recycle_address(&self) -> AccountId { + self.recycle_address + } + + #[ink(message)] + pub fn get_chain_ext_enabled(&self) -> bool { + self.chain_ext_enabled + } + #[ink(message)] pub fn get_owner(&self) -> AccountId { self.owner @@ -1222,11 +1313,6 @@ mod allways_swap_manager { self.halted } - #[ink(message)] - pub fn get_recycle_address(&self) -> AccountId { - self.recycle_address - } - #[ink(message)] pub fn get_pending_slash(&self, swap_id: u64) -> Balance { self.pending_slashes.get(swap_id).map(|(_, amount)| amount).unwrap_or(0)