Skip to content

PaymentHub: Allow withdrawals/transfers with active channels via global per-coin locked_unit × active channel count #3722

@jolestar

Description

@jolestar

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    proposalNew external API or other notable changes

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions