Skip to content

Partial-fill order is over-filled and double-debited when re-submitted with partial_fill = None #2795

Description

@Maksandre

Describe the bug

A PartiallyFilled(n) order is re-executed for its full order.amount when a relayer re-submits the same signed order with SignedOrder.partial_fill = None, because the cumulative-fill cap lives entirely inside the Some(_) branch.

The cap that bounds a fill to the remaining amount only runs when partial_fill is Some:

if let Some(partial_fill) = signed_order.partial_fill {
    ensure!(order.relayer.is_some(), Error::<T>::RelayerRequiredForPartialFill);
    ensure!(order.partial_fills_enabled, Error::<T>::PartialFillsNotEnabled);
    let max_fill = if let Some(OrderStatus::PartiallyFilled(already_filled)) = order_status {
        order.amount.saturating_sub(already_filled)
    } else { order.amount };
    ensure!(partial_fill > 0 && partial_fill <= max_fill, Error::<T>::IncorrectPartialFillAmount);
}

(lib.rs:648.) With partial_fill = None the whole block is skipped. PartiallyFilled(n) is non-terminal, so it passes the Fulfilled / Cancelled checks. The execution path then sizes the swap from the full order amount:

let tao_in = TaoBalance::from(signed_order.partial_fill.unwrap_or(order.amount));

(lib.rs:729.) buy_alpha debits the signer the full order.amount again, and compute_order_status early-returns Fulfilled (lib.rs:682), discarding the recorded n. This breaks the conservation invariant sum(fills) <= order.amount. partial_fill lives on the SignedOrder envelope, not the signed payload, so a relayer flips it to None without invalidating the signature or order_id. The signer cannot cancel a PartiallyFilled order, so there is no recourse. The same applies to sell orders and the batched path.

To Reproduce

  1. Sign a buy order for 1,000 TAO with partial_fills_enabled = true and a relayer on the allow-list.
  2. As the relayer, submit it with partial_fill = Some(600): the signer is debited 600 TAO and the order becomes PartiallyFilled(600).
  3. Re-submit the same signed order with partial_fill = None.
  4. Observe the cumulative-fill cap is skipped, the swap re-executes for the full 1,000 TAO, the signer is debited another 1,000 TAO, and the order is marked Fulfilled.
  5. Total debit is 1,600 TAO for a 1,000-TAO order (with a first fill of amount - 1, the over-debit approaches 100% / nearly 2x).

Expected behavior

A partial_fill = None submission against a PartiallyFilled(n) order is rejected, or treated as "fill the remaining amount - n" (swap size clamped to amount - n and accumulated into the running total). compute_order_status must account for already_filled on the None path instead of unconditionally returning Fulfilled.

Screenshots

No response

Environment

testnet@0c774f00

Additional context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions