Proposal Details
Summary
Today withdraw_from_hub blocks any withdrawal when there is at least one active channel for the coin type. This is overly strict and prevents users from moving excess funds. This proposal introduces a global per-coin “locked unit” that scales with the number of active channels a user has, enabling withdrawals/transfers of the surplus while keeping a safety reserve for channel operations. Optionally, we extend this with cancellation-period dynamic locking for stronger safety.
Problem
- Current behavior in
frameworks/rooch-framework/sources/payment_channel.move:
- If there are any active channels for
CoinType, withdraw_from_hub<CoinType> rejects withdrawals.
- Impact:
- Users who deposited a large amount into their
PaymentHub cannot withdraw or transfer out surplus funds while channels remain active.
- UX friction and capital inefficiency.
- Safety requirement:
- We must ensure channel claims/finalization still succeed and that “must-pay” funds aren’t accidentally withdrawn.
Proposed Solution
Introduce a global, per-coin configuration “locked unit” (locked_unit_per_coin), and compute a minimum reserve per user as:
- required_locked(owner, T) = locked_unit_per_coin[T] × active_channel_count(owner, T)
- unlocked_balance(owner, T) = max(0, balance_in_hub(owner, T) − required_locked(owner, T))
Withdrawals/transfers are allowed up to unlocked_balance. The existence of active channels no longer blocks withdrawals outright.
Optional enhancement (recommended)
During channel cancellation/dispute, track and lock the “pending amount” until finalization:
- On
initiate_cancellation and dispute_cancellation, accumulate pending_amount into a per-owner, per-coin cancellation lock.
- On
finalize_cancellation, deduct the cancellation-locked amount accordingly.
- Then:
- required_locked = (locked_unit × active_channel_count) + cancel_locked
- Ensures during challenge periods that must-pay funds remain available.
Scope of Changes
- New global config (module-level, admin/governance-managed):
- Table<String, u256>
locked_unit_per_coin
- APIs:
set_locked_unit<CoinType>(locked_unit: u256) (access-controlled)
get_locked_unit(coin_type: String): u256
- Adjust withdrawals (and optionally add hub->hub transfers):
- Relax
withdraw_from_hub<CoinType> to permit withdrawal if amount <= unlocked_balance.
- Optional:
transfer_from_hub_entry<CoinType>(owner: &signer, to: address, amount: u256) to send from Hub directly to another account’s hub/account.
- Views/helpers:
get_required_locked_for_owner<CoinType>(owner: address): u256
get_unlocked_balance_in_hub<CoinType>(owner: address): u256
can_withdraw_from_hub_with_config<CoinType>(owner: address, amount: u256): bool
- Optional (cancellation dynamic locking):
- Per-owner, per-coin
cancel_locked inside PaymentHub, updated in initiate_cancellation/dispute_cancellation, released in finalize_cancellation.
Backward Compatibility
- Existing public APIs remain intact. The semantics of
withdraw_from_hub change from “blocked if any active channels” to “blocked only if exceeding unlocked balance.”
get_active_channel_count remains available for compatibility.
Governance / Access Control
set_locked_unit should be restricted (governance or designated admin). If there is an existing governance pattern in the framework, we should reuse it; otherwise, we can start with a restricted friend-only entry and wire governance later.
Error Handling
- Keep using
ErrorInsufficientBalance or introduce a more explicit ErrorInsufficientUnlockedBalance to improve observability when users exceed the unlocked portion.
- Return false from
can_withdraw_from_hub_with_config accordingly.
Alternatives Considered
- Per-user, per-coin manual reserve configuration: flexible but higher complexity and poor UX.
- Full per-channel or per-sub-channel state-derived reserves: precise but complex and more gas-heavy.
- Keep current behavior: simplest, but poor UX.
Risks
- If
locked_unit is set too low, users can still end up temporarily short on balance for large off-chain vouchers until they top up. This is mitigated by governance tuning and (optionally) by the cancellation dynamic lock which guarantees must-pay during challenge periods.
Migration Plan
- Default
locked_unit to a conservative value (e.g., 0 or a small baseline) and iterate based on mainnet feedback.
- Add views so SDK/UI can immediately display unlocked vs. locked portions.
Acceptance Criteria
- With active channels, users can withdraw/transfer any amount up to
unlocked_balance.
- Claims and finalization continue to succeed under normal conditions.
- Optional enhancement: during cancellation/dispute, the pending amount is reflected in locked computations and cannot be withdrawn until finalization.
Testing Plan
- Unit tests for:
- No active channels: full balance withdrawable.
- Active channels: withdrawable up to
balance − (locked_unit × active_channels).
- Optional: cancellation/dispute increases locked amount; finalize reduces it; attempts to withdraw during challenge cannot break must-pay.
- Edge cases:
- Zero
locked_unit.
- Multiple coins with different
locked_unit.
- Withdraw exactly equal to
unlocked_balance.
Affected Files
frameworks/rooch-framework/sources/payment_channel.move (global config; helper views; withdraw logic; optional transfer and cancellation locks).
Proposal Details
Summary
Today
withdraw_from_hubblocks any withdrawal when there is at least one active channel for the coin type. This is overly strict and prevents users from moving excess funds. This proposal introduces a global per-coin “locked unit” that scales with the number of active channels a user has, enabling withdrawals/transfers of the surplus while keeping a safety reserve for channel operations. Optionally, we extend this with cancellation-period dynamic locking for stronger safety.Problem
frameworks/rooch-framework/sources/payment_channel.move:CoinType,withdraw_from_hub<CoinType>rejects withdrawals.PaymentHubcannot withdraw or transfer out surplus funds while channels remain active.Proposed Solution
Introduce a global, per-coin configuration “locked unit” (
locked_unit_per_coin), and compute a minimum reserve per user as:Withdrawals/transfers are allowed up to
unlocked_balance. The existence of active channels no longer blocks withdrawals outright.Optional enhancement (recommended)
During channel cancellation/dispute, track and lock the “pending amount” until finalization:
initiate_cancellationanddispute_cancellation, accumulatepending_amountinto a per-owner, per-coin cancellation lock.finalize_cancellation, deduct the cancellation-locked amount accordingly.Scope of Changes
locked_unit_per_coinset_locked_unit<CoinType>(locked_unit: u256)(access-controlled)get_locked_unit(coin_type: String): u256withdraw_from_hub<CoinType>to permit withdrawal ifamount <= unlocked_balance.transfer_from_hub_entry<CoinType>(owner: &signer, to: address, amount: u256)to send from Hub directly to another account’s hub/account.get_required_locked_for_owner<CoinType>(owner: address): u256get_unlocked_balance_in_hub<CoinType>(owner: address): u256can_withdraw_from_hub_with_config<CoinType>(owner: address, amount: u256): boolcancel_lockedinsidePaymentHub, updated ininitiate_cancellation/dispute_cancellation, released infinalize_cancellation.Backward Compatibility
withdraw_from_hubchange from “blocked if any active channels” to “blocked only if exceeding unlocked balance.”get_active_channel_countremains available for compatibility.Governance / Access Control
set_locked_unitshould be restricted (governance or designated admin). If there is an existing governance pattern in the framework, we should reuse it; otherwise, we can start with a restricted friend-only entry and wire governance later.Error Handling
ErrorInsufficientBalanceor introduce a more explicitErrorInsufficientUnlockedBalanceto improve observability when users exceed the unlocked portion.can_withdraw_from_hub_with_configaccordingly.Alternatives Considered
Risks
locked_unitis set too low, users can still end up temporarily short on balance for large off-chain vouchers until they top up. This is mitigated by governance tuning and (optionally) by the cancellation dynamic lock which guarantees must-pay during challenge periods.Migration Plan
locked_unitto a conservative value (e.g., 0 or a small baseline) and iterate based on mainnet feedback.Acceptance Criteria
unlocked_balance.Testing Plan
balance − (locked_unit × active_channels).locked_unit.locked_unit.unlocked_balance.Affected Files
frameworks/rooch-framework/sources/payment_channel.move(global config; helper views; withdraw logic; optional transfer and cancellation locks).