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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions