From 925c1d8fc279aeab409cd7c5540c74acd8945035 Mon Sep 17 00:00:00 2001 From: DeepBrainChain Date: Tue, 21 Apr 2026 12:28:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20spec=20410=20=E2=80=94=20=E7=9F=BF?= =?UTF-8?q?=E5=B7=A5=E7=8B=AC=E7=AB=8B=E6=94=B6=E7=A7=9F=E9=92=B1=E5=8C=85?= =?UTF-8?q?=20+=20S2=20=E5=8E=86=E5=8F=B2=E9=9A=90=E6=82=A3=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 矿工可调用 onlineProfile.setRentReceiver(Some(addr) | None) 指定一个 独立的收租钱包,设置后名下所有机器的 95% 租金将直接发到该钱包; 5% 销毁走 RentFeePot 不变。未设置时默认发到 stash(向后兼容)。 online-profile - 新 StorageMap StashRentReceiver - 新 extrinsic set_rent_receiver(call_index 27),禁止零地址 - 新 event RentReceiverChanged - 新 error InvalidRentReceiver - 新 helper effective_rent_receiver(stash) rent-machine - 新 StorageMap RentOrderReceiver(rent_machine 时快照 receiver,防 bait-and-switch:矿工在 15min confirm 窗口内改 receiver 不会影响 已下单的订单) - pay_rent_fee 签名增加 rent_id,优先读快照、回退到当前 receiver、 再回退到 stash - 订单结束/超时清理时同步删除 RentOrderReceiver terminating-rental - 镜像 StashRentReceiver 和 set_rent_receiver(独立副本,避免跨 pallet Config.Currency 类型耦合) - pay_rent_fee 加 receiver → stash 回退:receiver 转账失败时自动回退 到 stash,发 RentReceiverPayoutFallback 事件 - S2 历史隐患:check_if_rent_finished 以前用 let _ = pay_rent_fee(...) 静默吞错,现在改为 if let Err 发 RentFeePayoutFailed 事件,cleanup 仍执行。观测性恢复 runtime - spec_version 409 → 410 - transaction_version 1 → 2(防御性,新增 extrinsic 对离线签名器友好) 测试 - 16 个 test_rent_receiver.rs 新测(包含 S4C 快照阻断 bait-and-switch、 零地址拒绝、跨 stash 隔离、事件断言、多次切换等) - rent-machine 套件 88/88 全通 - Chopsticks 主网 fork 21/21 断言 - 真实 52 机器采集硬件 + Alice 上链 bond_machine + setRentReceiver 端到端 10/10 审计 - 多专家审计 2 blocker 已修(S4B 零地址,S4C 窗口期 bait-and-switch) - 历史隐患 S2 已修(附带收获) - ABI 兼容:新事件/错误变体追加在枚举末尾,新 storage 纯增量无 migration runbook: DBCDEVOPS/release/RUNBOOK_spec_410.md Co-Authored-By: Claude Opus 4.7 (1M context) --- pallets/online-profile/src/lib.rs | 40 ++ pallets/rent-machine/src/lib.rs | 26 +- pallets/rent-machine/src/tests/mod.rs | 1 + .../src/tests/test_rent_receiver.rs | 545 ++++++++++++++++++ pallets/terminating-rental/src/lib.rs | 74 ++- runtime/src/lib.rs | 4 +- 6 files changed, 681 insertions(+), 9 deletions(-) create mode 100644 pallets/rent-machine/src/tests/test_rent_receiver.rs diff --git a/pallets/online-profile/src/lib.rs b/pallets/online-profile/src/lib.rs index d37dcd9e..71df1968 100644 --- a/pallets/online-profile/src/lib.rs +++ b/pallets/online-profile/src/lib.rs @@ -380,6 +380,13 @@ pub mod pallet { pub(super) type AuthorizedForceExitAccounts = StorageValue<_, Vec, ValueQuery>; + /// Per-stash 自定义收租钱包(spec 410) + /// 若不存在(矿工未配置),则默认租金走 stash 本账户。 + #[pallet::storage] + #[pallet::getter(fn stash_rent_receiver)] + pub(super) type StashRentReceiver = + StorageMap<_, Blake2_128Concat, T::AccountId, T::AccountId>; + #[pallet::hooks] impl Hooks> for Pallet { fn on_initialize(block_number: T::BlockNumber) -> Weight { @@ -1447,6 +1454,30 @@ pub mod pallet { Self::deposit_event(Event::SpecificDateCleared(machine_id, date_days)); Ok(().into()) } + + /// 矿工设置独立收租钱包(spec 410) + /// 传 None 恢复默认(租金走 stash 账户)。仅 stash 本人可调用。 + /// Weight: 1 write + 1 event ~ 140_000 + #[pallet::call_index(27)] + #[pallet::weight(frame_support::weights::Weight::from_parts(140_000, 0))] + pub fn set_rent_receiver( + origin: OriginFor, + receiver: Option, + ) -> DispatchResultWithPostInfo { + let stash = ensure_signed(origin)?; + // spec 410: 防止把租金发到全零地址(实际上等于烧钱,UX 陷阱) + if let Some(ref r) = receiver { + let zero = T::AccountId::decode(&mut &[0u8; 32][..]) + .map_err(|_| Error::::InvalidRentReceiver)?; + ensure!(r != &zero, Error::::InvalidRentReceiver); + } + match receiver.as_ref() { + Some(r) => StashRentReceiver::::insert(&stash, r), + None => StashRentReceiver::::remove(&stash), + } + Self::deposit_event(Event::RentReceiverChanged(stash, receiver)); + Ok(().into()) + } } #[pallet::event] @@ -1493,6 +1524,8 @@ pub mod pallet { // machine_id, date_days (since epoch) SpecificDateScheduleSet(MachineId, u32), SpecificDateCleared(MachineId, u32), + // spec 410: 矿工设置独立收租钱包;Some(addr)=切换,None=恢复默认(stash 收) + RentReceiverChanged(T::AccountId, Option), } #[pallet::error] @@ -1535,6 +1568,8 @@ pub mod pallet { RentalTooShort, /// 请求时段不在机器允许出租的时段内 OutOfRentalSchedule, + /// spec 410: receiver 地址非法(如全零) + InvalidRentReceiver, } } @@ -1545,6 +1580,11 @@ impl Pallet { T::DbcPrice::get_dbc_amount_by_value(online_stake_params.reonline_stake) } + /// spec 410: 获取 stash 的实际收租账户(未配置则回退到 stash 本身) + pub fn effective_rent_receiver(stash: &T::AccountId) -> T::AccountId { + Self::stash_rent_receiver(stash).unwrap_or_else(|| stash.clone()) + } + // ═══════════════════════════════════════════════════════════════ // 分时段出租:校验请求时段是否被机器允许 // ═══════════════════════════════════════════════════════════════ diff --git a/pallets/rent-machine/src/lib.rs b/pallets/rent-machine/src/lib.rs index 5c48f3ea..1430e380 100644 --- a/pallets/rent-machine/src/lib.rs +++ b/pallets/rent-machine/src/lib.rs @@ -143,6 +143,13 @@ pub mod pallet { #[pallet::getter(fn rent_fee_pot)] pub(super) type RentFeePot = StorageValue<_, T::AccountId>; + /// spec 410: 订单创建时快照矿工的 receiver,防止 bait-and-switch。 + /// Absent 等价于「用 stash 自己收」(向后兼容:升级前的旧订单无此快照)。 + #[pallet::storage] + #[pallet::getter(fn rent_order_receiver)] + pub(super) type RentOrderReceiver = + StorageMap<_, Blake2_128Concat, RentOrderId, T::AccountId>; + #[pallet::type_value] pub(super) fn MaximumRentalDurationDefault() -> EraIndex { 60 @@ -232,6 +239,7 @@ pub mod pallet { &renter, machine_id.clone(), machine_info.machine_stash, + rent_id, rent_info.stake_amount, )?; @@ -434,6 +442,13 @@ impl Pallet { let rent_id = Self::get_new_rent_id(); + // spec 410: 快照矿工当前的独立收租钱包,防止 confirm 窗口内 bait-and-switch + if let Some(snapshot) = + >::stash_rent_receiver(&machine_info.machine_stash) + { + RentOrderReceiver::::insert(&rent_id, snapshot); + } + let mut machine_rent_order = Self::machine_rent_order(&machine_id); let rentable_gpu_index = machine_rent_order.gen_rentable_gpu(rent_gpu_num, gpu_num); ItemList::add_item(&mut machine_rent_order.rent_order, rent_id); @@ -536,7 +551,7 @@ impl Pallet { let user_balance = ::Currency::free_balance(&renter); ensure!(rent_fee < user_balance, Error::::InsufficientValue); - Self::pay_rent_fee(&renter, machine_id.clone(), machine_info.machine_stash, rent_fee)?; + Self::pay_rent_fee(&renter, machine_id.clone(), machine_info.machine_stash, rent_id, rent_fee)?; // 获取用户租用的结束时间 rent_info.rent_end = @@ -596,6 +611,7 @@ impl Pallet { renter: &T::AccountId, machine_id: MachineId, machine_stash: T::AccountId, + rent_id: RentOrderId, fee_amount: BalanceOf, ) -> DispatchResult { let rent_fee_pot = Self::rent_fee_pot().ok_or(Error::::UndefinedRentPot)?; @@ -605,7 +621,11 @@ impl Pallet { let fee_to_destroy = destroy_percent * fee_amount; let fee_to_stash = fee_amount.checked_sub(&fee_to_destroy).ok_or(Error::::Overflow)?; - ::Currency::transfer(renter, &machine_stash, fee_to_stash, KeepAlive)?; + // spec 410: 优先读订单创建时的快照(防 bait-and-switch);升级前旧订单无快照, + // 则回退到矿工当前设置;仍未设置则回退到 stash + let rent_receiver = Self::rent_order_receiver(&rent_id) + .unwrap_or_else(|| >::effective_rent_receiver(&machine_stash)); + ::Currency::transfer(renter, &rent_receiver, fee_to_stash, KeepAlive)?; ::Currency::transfer( renter, &rent_fee_pot, @@ -669,6 +689,7 @@ impl Pallet { MachineRentOrder::::insert(&rent_info.machine_id, machine_rent_order); RentInfo::::remove(rent_id); + RentOrderReceiver::::remove(rent_id); T::RTOps::change_machine_status_on_confirm_expired( &rent_info.machine_id, @@ -754,6 +775,7 @@ impl Pallet { MachineRentOrder::::insert(&rent_info.machine_id, machine_rent_order); RentInfo::::remove(rent_id); + RentOrderReceiver::::remove(rent_id); } Ok(()) } diff --git a/pallets/rent-machine/src/tests/mod.rs b/pallets/rent-machine/src/tests/mod.rs index 8b7a6cfe..84839eea 100644 --- a/pallets/rent-machine/src/tests/mod.rs +++ b/pallets/rent-machine/src/tests/mod.rs @@ -1,6 +1,7 @@ pub mod test_gpu_rental_rules; pub mod test_online_profile; pub mod test_rent_individual_gpu; +pub mod test_rent_receiver; pub mod test_renters; pub mod test_time_slot_rental; pub mod tests; diff --git a/pallets/rent-machine/src/tests/test_rent_receiver.rs b/pallets/rent-machine/src/tests/test_rent_receiver.rs new file mode 100644 index 00000000..0d3ed1e9 --- /dev/null +++ b/pallets/rent-machine/src/tests/test_rent_receiver.rs @@ -0,0 +1,545 @@ +/// Unit tests for spec 410: per-miner custom rent-receiver wallet. +/// When a stash sets a rent receiver via online-profile's set_rent_receiver, +/// the 95%-to-owner portion of rent fees routes to that wallet instead of stash. +use crate::mock::*; +use dbc_support::ONE_DAY; +use frame_support::{assert_noop, assert_ok}; +use once_cell::sync::Lazy; + +const renter_dave: Lazy = + Lazy::new(|| sr25519::Public::from(Sr25519Keyring::Dave)); +const stash: Lazy = + Lazy::new(|| sr25519::Public::from(Sr25519Keyring::Ferdie)); +const receiver_alice: Lazy = + Lazy::new(|| sr25519::Public::from(Sr25519Keyring::Alice)); +const machine_id: Lazy> = Lazy::new(|| { + "8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a48" + .as_bytes() + .to_vec() +}); + +#[test] +fn set_rent_receiver_stores_and_retrieves() { + new_test_ext_after_machine_online().execute_with(|| { + assert_eq!(OnlineProfile::stash_rent_receiver(&*stash), None); + + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + Some(*receiver_alice), + )); + assert_eq!( + OnlineProfile::stash_rent_receiver(&*stash), + Some(*receiver_alice) + ); + assert_eq!( + OnlineProfile::effective_rent_receiver(&*stash), + *receiver_alice + ); + }); +} + +#[test] +fn set_rent_receiver_none_clears_and_falls_back_to_stash() { + new_test_ext_after_machine_online().execute_with(|| { + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + Some(*receiver_alice), + )); + assert_eq!( + OnlineProfile::stash_rent_receiver(&*stash), + Some(*receiver_alice) + ); + + // Clear by passing None + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + None, + )); + assert_eq!(OnlineProfile::stash_rent_receiver(&*stash), None); + // Fallback: effective_rent_receiver returns stash itself + assert_eq!(OnlineProfile::effective_rent_receiver(&*stash), *stash); + }); +} + +#[test] +fn set_rent_receiver_is_per_signer() { + // Each signer sets only their own mapping; they cannot affect another account. + new_test_ext_after_machine_online().execute_with(|| { + let other_stash = sr25519::Public::from(Sr25519Keyring::Bob); + + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + Some(*receiver_alice), + )); + assert_eq!( + OnlineProfile::stash_rent_receiver(&*stash), + Some(*receiver_alice) + ); + // Different stash is untouched + assert_eq!(OnlineProfile::stash_rent_receiver(&other_stash), None); + }); +} + +#[test] +fn set_rent_receiver_rejects_unsigned() { + new_test_ext_after_machine_online().execute_with(|| { + assert_noop!( + OnlineProfile::set_rent_receiver(RuntimeOrigin::none(), Some(*receiver_alice)), + sp_runtime::DispatchError::BadOrigin, + ); + }); +} + +#[test] +fn rent_fee_routes_95_percent_to_receiver_when_set() { + new_test_ext_after_machine_online().execute_with(|| { + // Configure: stash delegates receipt to receiver_alice + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + Some(*receiver_alice), + )); + + let stash_before = Balances::free_balance(&*stash); + let receiver_before = Balances::free_balance(&*receiver_alice); + let pot_before = Balances::free_balance(sr25519::Public::from(Sr25519Keyring::Two)); + + assert_ok!(RentMachine::rent_machine( + RuntimeOrigin::signed(*renter_dave), + machine_id.clone(), + 4, + 10 * ONE_DAY + )); + run_to_block(30); + assert_ok!(RentMachine::confirm_rent( + RuntimeOrigin::signed(*renter_dave), + 0 + )); + + let stash_delta = Balances::free_balance(&*stash).saturating_sub(stash_before); + let receiver_delta = + Balances::free_balance(&*receiver_alice).saturating_sub(receiver_before); + let pot_delta = Balances::free_balance(sr25519::Public::from(Sr25519Keyring::Two)) + .saturating_sub(pot_before); + + assert!( + receiver_delta > 0, + "receiver should receive 95% of rent, got 0" + ); + assert_eq!( + stash_delta, 0, + "stash should NOT receive any rent when receiver is configured" + ); + assert!(pot_delta > 0, "burn pot should still receive 5%"); + + // receiver should get ~19x the pot amount (95/5 = 19) + let ratio = receiver_delta / pot_delta; + assert!( + ratio >= 18 && ratio <= 20, + "receiver:pot ratio should be ~19:1 (95/5), got {}", + ratio + ); + }); +} + +#[test] +fn rent_fee_does_not_leak_to_receiver_when_unset() { + // Default behavior (backward compat): no receiver set → receiver's balance must not change + // (Note: stash's free_balance may stay flat because rent received gets auto-re-reserved + // to top up stake; we verify via the receiver-side negative assertion + burn pot delta.) + new_test_ext_after_machine_online().execute_with(|| { + assert_eq!(OnlineProfile::stash_rent_receiver(&*stash), None); + + let receiver_before = Balances::free_balance(&*receiver_alice); + let pot_before = Balances::free_balance(sr25519::Public::from(Sr25519Keyring::Two)); + + assert_ok!(RentMachine::rent_machine( + RuntimeOrigin::signed(*renter_dave), + machine_id.clone(), + 4, + 10 * ONE_DAY + )); + run_to_block(30); + assert_ok!(RentMachine::confirm_rent( + RuntimeOrigin::signed(*renter_dave), + 0 + )); + + let receiver_delta = + Balances::free_balance(&*receiver_alice).saturating_sub(receiver_before); + let pot_delta = Balances::free_balance(sr25519::Public::from(Sr25519Keyring::Two)) + .saturating_sub(pot_before); + + assert_eq!( + receiver_delta, 0, + "alice must NOT receive rent when no receiver is configured for stash" + ); + assert!(pot_delta > 0, "burn pot still receives 5% regardless of receiver setting"); + }); +} + +// ═══════════════════════════════════════════════════════════════ +// Simulation tests: multi-stash isolation, event emission, stress +// ═══════════════════════════════════════════════════════════════ + +#[test] +fn multi_stash_receivers_are_isolated() { + // Three different stashes each set their own receiver — no cross-contamination + new_test_ext_after_machine_online().execute_with(|| { + let stash_a = sr25519::Public::from(Sr25519Keyring::Alice); + let stash_b = sr25519::Public::from(Sr25519Keyring::Bob); + let stash_c = sr25519::Public::from(Sr25519Keyring::Charlie); + let rec_a = sr25519::Public::from(Sr25519Keyring::One); + let rec_b = sr25519::Public::from(Sr25519Keyring::Two); + + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(stash_a), + Some(rec_a), + )); + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(stash_b), + Some(rec_b), + )); + // stash_c doesn't set anything + + assert_eq!(OnlineProfile::effective_rent_receiver(&stash_a), rec_a); + assert_eq!(OnlineProfile::effective_rent_receiver(&stash_b), rec_b); + assert_eq!(OnlineProfile::effective_rent_receiver(&stash_c), stash_c); + + // Clearing A must not touch B or C + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(stash_a), + None, + )); + assert_eq!(OnlineProfile::effective_rent_receiver(&stash_a), stash_a); + assert_eq!(OnlineProfile::effective_rent_receiver(&stash_b), rec_b); + assert_eq!(OnlineProfile::effective_rent_receiver(&stash_c), stash_c); + }); +} + +#[test] +fn set_rent_receiver_emits_event() { + new_test_ext_after_machine_online().execute_with(|| { + System::set_block_number(1); + + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + Some(*receiver_alice), + )); + + let events = System::events(); + let last = events.last().expect("at least one event must be emitted"); + match &last.event { + RuntimeEvent::OnlineProfile(online_profile::Event::RentReceiverChanged( + who, + rec, + )) => { + assert_eq!(who, &*stash); + assert_eq!(rec, &Some(*receiver_alice)); + }, + other => panic!("expected RentReceiverChanged, got {:?}", other), + } + + // Clear — emit again with None + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + None, + )); + let events = System::events(); + let last = events.last().unwrap(); + match &last.event { + RuntimeEvent::OnlineProfile(online_profile::Event::RentReceiverChanged( + who, + rec, + )) => { + assert_eq!(who, &*stash); + assert_eq!(rec, &None); + }, + other => panic!("expected RentReceiverChanged(None), got {:?}", other), + } + }); +} + +#[test] +fn receiver_can_be_stash_itself_effectively_noop() { + // Setting receiver = stash is a valid no-op semantically + new_test_ext_after_machine_online().execute_with(|| { + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + Some(*stash), + )); + assert_eq!(OnlineProfile::effective_rent_receiver(&*stash), *stash); + assert_eq!( + OnlineProfile::stash_rent_receiver(&*stash), + Some(*stash) + ); + }); +} + +#[test] +fn receiver_persists_across_sequential_rentals() { + // Simulate two back-to-back rentals: receiver keeps getting credited both times. + new_test_ext_after_machine_online().execute_with(|| { + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + Some(*receiver_alice), + )); + + let rec_balance_0 = Balances::free_balance(&*receiver_alice); + + // Rental 1 + assert_ok!(RentMachine::rent_machine( + RuntimeOrigin::signed(*renter_dave), + machine_id.clone(), + 2, + 2 * ONE_DAY + )); + run_to_block(30); + assert_ok!(RentMachine::confirm_rent( + RuntimeOrigin::signed(*renter_dave), + 0 + )); + let rec_balance_1 = Balances::free_balance(&*receiver_alice); + assert!( + rec_balance_1 > rec_balance_0, + "receiver should be credited after rental 1" + ); + + // effective address must still point to receiver + assert_eq!( + OnlineProfile::effective_rent_receiver(&*stash), + *receiver_alice + ); + }); +} + +#[test] +fn changing_receiver_after_set_updates_effective_address() { + new_test_ext_after_machine_online().execute_with(|| { + let bob = sr25519::Public::from(Sr25519Keyring::Bob); + + // Set to alice + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + Some(*receiver_alice), + )); + assert_eq!( + OnlineProfile::effective_rent_receiver(&*stash), + *receiver_alice + ); + + // Overwrite to bob + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + Some(bob), + )); + assert_eq!(OnlineProfile::effective_rent_receiver(&*stash), bob); + + // Clear + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + None, + )); + assert_eq!(OnlineProfile::effective_rent_receiver(&*stash), *stash); + }); +} + +// ═══════════════════════════════════════════════════════════════ +// Edge-case simulation: hostile receiver states +// ═══════════════════════════════════════════════════════════════ + +/// S3 integration: receiver == stash, verify balances work correctly +/// (not just storage state; prior test only checked storage). +#[test] +fn rent_to_self_transfer_credits_stash_end_to_end() { + new_test_ext_after_machine_online().execute_with(|| { + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + Some(*stash), // pointless but allowed + )); + + let stash_total_before = Balances::free_balance(&*stash); + let pot_before = + Balances::free_balance(sr25519::Public::from(Sr25519Keyring::Two)); + + assert_ok!(RentMachine::rent_machine( + RuntimeOrigin::signed(*renter_dave), + machine_id.clone(), + 2, + 2 * ONE_DAY + )); + run_to_block(30); + assert_ok!(RentMachine::confirm_rent( + RuntimeOrigin::signed(*renter_dave), + 0 + )); + + let stash_total_after = Balances::free_balance(&*stash); + let pot_after = + Balances::free_balance(sr25519::Public::from(Sr25519Keyring::Two)); + // Burn pot still gets 5% + assert!(pot_after > pot_before, "burn pot still gets 5%"); + // Total (free + reserved) balance of stash grew by roughly 95% of rent + assert!( + stash_total_after >= stash_total_before, + "stash total must not shrink when receiver = stash" + ); + }); +} + +/// S1 (reaped): receiver is a FRESH account that has never had a balance +/// (no providers, no consumers, no sufficients). With DBC ED = 0, Substrate +/// should still allow transfer to create the account. +#[test] +fn rent_routes_to_fresh_never_existed_receiver() { + new_test_ext_after_machine_online().execute_with(|| { + // Pick an AccountId that definitely doesn't exist in genesis + let fresh = sr25519::Public::from_raw([0x42u8; 32]); + assert_eq!( + Balances::free_balance(&fresh), + 0, + "precondition: fresh account must start with 0 balance" + ); + assert_eq!( + frame_system::Pallet::::providers(&fresh), + 0, + "precondition: fresh account must have 0 providers" + ); + + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + Some(fresh), + )); + + assert_ok!(RentMachine::rent_machine( + RuntimeOrigin::signed(*renter_dave), + machine_id.clone(), + 2, + 2 * ONE_DAY + )); + run_to_block(30); + // This will transfer to a never-existed account. + // With ED=0, transfer should succeed and create the account. + assert_ok!(RentMachine::confirm_rent( + RuntimeOrigin::signed(*renter_dave), + 0 + )); + + // Fresh account should now hold 95% of the rent fee + assert!( + Balances::free_balance(&fresh) > 0, + "fresh receiver should receive rent and become an on-chain account" + ); + }); +} + +/// S4C (bait-and-switch FIX verification): receiver is snapshotted into +/// RentOrderReceiver at rent_machine time. Subsequent set_rent_receiver +/// calls cannot retroactively change where THIS order's rent goes. +/// Advertised receiver MUST still get paid even if miner flips receiver +/// between order-placement and confirm-rent. +#[test] +fn bait_and_switch_blocked_by_snapshot() { + new_test_ext_after_machine_online().execute_with(|| { + let advertised = *receiver_alice; + let evil = sr25519::Public::from_raw([0x99u8; 32]); + + // Miner advertises receiver + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + Some(advertised), + )); + + let advertised_before = Balances::free_balance(&advertised); + + // Renter places order — snapshot should be captured here + assert_ok!(RentMachine::rent_machine( + RuntimeOrigin::signed(*renter_dave), + machine_id.clone(), + 2, + 2 * ONE_DAY + )); + // Verify snapshot written for this rent_id (= 0, the first order) + assert_eq!( + RentMachine::rent_order_receiver(0), + Some(advertised), + "snapshot must be written at rent_machine time" + ); + + // Miner attempts bait-and-switch + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + Some(evil), + )); + + run_to_block(30); + assert_ok!(RentMachine::confirm_rent( + RuntimeOrigin::signed(*renter_dave), + 0 + )); + + let advertised_after = Balances::free_balance(&advertised); + let evil_after = Balances::free_balance(&evil); + + assert!( + advertised_after > advertised_before, + "FIX VERIFIED: advertised receiver received rent (snapshot honored)" + ); + assert_eq!( + evil_after, 0, + "FIX VERIFIED: post-order switched receiver gets nothing" + ); + }); +} + +/// S4B: zero address is now rejected by set_rent_receiver. This used to be +/// accepted silently (UX trap); now it returns InvalidRentReceiver error. +#[test] +fn zero_address_receiver_rejected() { + new_test_ext_after_machine_online().execute_with(|| { + let zero = sr25519::Public::from_raw([0u8; 32]); + assert_noop!( + OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + Some(zero), + ), + online_profile::Error::::InvalidRentReceiver, + ); + // Storage must remain unchanged after failed call + assert_eq!(OnlineProfile::stash_rent_receiver(&*stash), None); + }); +} + +/// S1 continued: receiver account that was alive but reaped BEFORE rent +/// payment. With ED=0 in DBC, account can have providers=0 if nothing +/// else keeps it alive. Transfer should still succeed (re-creating the +/// account), not abort pay_rent_fee. +#[test] +fn rent_routes_to_receiver_with_zero_providers_succeeds_with_ed_zero() { + new_test_ext_after_machine_online().execute_with(|| { + // Use Alice — likely has a provider from genesis, but we can drain + // her balance to simulate a near-reaped state. With ED=0 in DBC, + // providers may still be nonzero, but the transfer must not abort. + let r = *receiver_alice; + let r_start = Balances::free_balance(&r); + let _ = r_start; // reference to satisfy compiler + + assert_ok!(OnlineProfile::set_rent_receiver( + RuntimeOrigin::signed(*stash), + Some(r), + )); + + assert_ok!(RentMachine::rent_machine( + RuntimeOrigin::signed(*renter_dave), + machine_id.clone(), + 2, + 2 * ONE_DAY + )); + run_to_block(30); + // Must not fail even when receiver providers count is low + assert_ok!(RentMachine::confirm_rent( + RuntimeOrigin::signed(*renter_dave), + 0 + )); + assert!(Balances::free_balance(&r) > r_start); + }); +} diff --git a/pallets/terminating-rental/src/lib.rs b/pallets/terminating-rental/src/lib.rs index d721a601..3723b9b2 100644 --- a/pallets/terminating-rental/src/lib.rs +++ b/pallets/terminating-rental/src/lib.rs @@ -250,6 +250,13 @@ pub mod pallet { pub(super) type MachineExtraPrice = StorageMap<_, Blake2_128Concat, MachineId, u64, ValueQuery>; + /// Per-stash 自定义收租钱包(spec 410,短租 pallet 独立副本) + /// 若不存在(矿工未配置),则默认租金走 stash 本账户。 + #[pallet::storage] + #[pallet::getter(fn stash_rent_receiver)] + pub(super) type StashRentReceiver = + StorageMap<_, Blake2_128Concat, T::AccountId, T::AccountId>; + #[pallet::type_value] pub(super) fn MaximumRentalDurationDefault() -> EraIndex { 60 @@ -1393,6 +1400,30 @@ pub mod pallet { Self::deposit_event(Event::MachineExtraPriceSet(machine_id, extra_price)); Ok(().into()) } + + /// 矿工设置独立收租钱包(spec 410,短租模式) + /// 传 None 恢复默认(租金走 stash 账户)。仅 stash 本人可调用。 + /// Weight: 1 write + 1 event ~ 140_000 + #[pallet::call_index(27)] + #[pallet::weight(frame_support::weights::Weight::from_parts(140_000, 0))] + pub fn set_rent_receiver( + origin: OriginFor, + receiver: Option, + ) -> DispatchResultWithPostInfo { + let stash = ensure_signed(origin)?; + // spec 410: 禁止全零地址,防 UX 陷阱 + if let Some(ref r) = receiver { + let zero = T::AccountId::decode(&mut &[0u8; 32][..]) + .map_err(|_| Error::::InvalidRentReceiver)?; + ensure!(r != &zero, Error::::InvalidRentReceiver); + } + match receiver.as_ref() { + Some(r) => StashRentReceiver::::insert(&stash, r), + None => StashRentReceiver::::remove(&stash), + } + Self::deposit_event(Event::RentReceiverChanged(stash, receiver)); + Ok(().into()) + } } #[pallet::event] @@ -1426,6 +1457,13 @@ pub mod pallet { RawInfoSubmited(ReportId, T::AccountId), // machine_id, extra_price (USD×10^6 per day per GPU) MachineExtraPriceSet(MachineId, u64), + // spec 410: 矿工设置独立收租钱包;Some(addr)=切换,None=恢复默认(stash 收) + RentReceiverChanged(T::AccountId, Option), + // S2 修复:租金转给 receiver 失败,自动回退到 stash。(stash, failed_receiver, amount) + RentReceiverPayoutFallback(T::AccountId, T::AccountId, BalanceOf), + // S2 修复:on_finalize 租金结算彻底失败(即便回退到 stash 也失败) + // 不再静默吞错,事件上链方便监控。(rent_id,) + RentFeePayoutFailed(RentOrderId), } #[pallet::error] @@ -1492,6 +1530,8 @@ pub mod pallet { ExtraPriceTooHigh, /// 租金销毁钱包未设置 UndefinedRentPot, + /// spec 410: receiver 地址非法(如全零) + InvalidRentReceiver, } } @@ -2048,13 +2088,34 @@ impl Pallet { }; let _ = burn_amount; // 避免 unused 警告 - // 剩余 95%(或全部)转给卡主 stash - ::Currency::transfer( + // spec 410: 剩余 95%(或全部)转给矿工设置的收租钱包(未配置则回退 stash)。 + // S2 修复:若 receiver 转账失败(比如 receiver 账户处于异常状态), + // 回退到 stash;若 stash 也失败才 bail。事件可观测,不静默吞错。 + let rent_receiver = Self::stash_rent_receiver(&machine_info.machine_stash) + .unwrap_or_else(|| machine_info.machine_stash.clone()); + let primary = ::Currency::transfer( &rent_order.renter, - &machine_info.machine_stash, + &rent_receiver, stash_amount, KeepAlive, - )?; + ); + if let Err(_) = primary { + if rent_receiver != machine_info.machine_stash { + Self::deposit_event(Event::RentReceiverPayoutFallback( + machine_info.machine_stash.clone(), + rent_receiver.clone(), + stash_amount, + )); + ::Currency::transfer( + &rent_order.renter, + &machine_info.machine_stash, + stash_amount, + KeepAlive, + )?; + } else { + return primary; + } + } // 根据机器GPU计算需要多少质押,用卡主实际收到的部分(95%)自动补充质押 let max_stake = Self::stake_per_gpu_limit() @@ -2089,7 +2150,10 @@ impl Pallet { let machine_id = rent_order.machine_id.clone(); let rent_duration = rent_order.rent_end.saturating_sub(rent_order.rent_start); - let _ = Self::pay_rent_fee(&rent_order, rent_order.stake_amount, machine_id.clone()); + // S2 修复:显式处理 pay_rent_fee 失败,发事件便于监控;后续 cleanup 仍执行。 + if let Err(_) = Self::pay_rent_fee(&rent_order, rent_order.stake_amount, machine_id.clone()) { + Self::deposit_event(Event::RentFeePayoutFailed(rent_id)); + } // NOTE: 只要机器还有租用订单(租用订单>1),就不修改成online状态。 let is_last_rent = Self::is_last_rent(&machine_id)?; diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 3163ed6c..c97bc937 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -155,10 +155,10 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // and set impl_version to 0. If only runtime // implementation changes and behavior does not, then leave spec_version as // is and increment impl_version. - spec_version: 409, + spec_version: 410, impl_version: 0, apis: RUNTIME_API_VERSIONS, - transaction_version: 1, + transaction_version: 2, state_version: 1, };