Assessment reports>Penumbra>Threat Model>Action: PositionOpen

Action: PositionOpen

A PositionOpen action opens a trading position that provides liquidity for swaps and contains just a position.

A position contains a state, which is one of Opened, Closed, Withdrawn, or Claimed; two reserve amounts; a TradingFunction, a nonce, and a flag indicating whether it is a limit order (that should be automatically closed once one of its reserves reach zero).

A TradingFunction specifies which pair of assets liquidity is being provided for, what fee should be paid for trades that go through this position, and two trading coefficients that specify what ratio this position permits swaps at.

The balance commitment associated with a PositionOpen is transparent, debits the position's reserves from the balance, and credits the LPNFT for the new position to the transaction's balance.

An LPNFT is an asset whose generator is derived from a hash of the position ID and current state. A position ID is a hash of the position's nonce, asset types, fee, and trading coefficients (importantly, not of its reserves or state, which are mutable).

The transaction planner will by default store the LPNFT for the position in an Output note as change, which can be used in a subsequent transaction with a Spend + PositionClose, but other arrangements are possible (e.g., using a PositionOpen and PositionClose within the same transaction to only provide liquidity at a certain exchange rate for one block and then storing the closed position's LPNFT in an Output note).

PositionOpen::check_stateless invokes Position::check_stateless, which checks that

  • both reserves are less than or equal to .

  • at least one of the reserves is nonzero.

  • both trading coefficients are nonzero (either being zero would imply an infinite price for the opposite asset).

  • both trading coefficients are less than or equal to .

  • the trading function's assets are distinct (to avoid creating self-edges in the position graph).

  • the fee is less than 50%.

PositionOpen::check_stateful checks that the position ID did not occur in any previous transaction by checking the state under "dex/position/{id}".

PositionOpen::execute calls PositionManager::put_position, which stores the position in "dex/position/{id}" and updates various indexes relating to the position.

There is a TOCTOU bug () that allows a position to be opened multiple times with the same ID within one transaction (see Finding ref), which should be prevented by adding a check to Transaction::check_stateless that ensures that all transaction IDs opened are unique.

Action: PositionClose

A PositionClose action closes a currently open position, specified by its ID.

The balance commitment associated with a PositionClose is transparent, debits the LPNFT for the open position with the specified ID, and credits the LPNFT for the closed position with the specified ID to the transaction's balance.

PositionClose::check_stateless and PositionClose::check_stateful both unconditionally succeed.

PositionClose::execute queues the position ID in the state under "dex/pending_position_closures" to be closed at the end of the block.

Action: PositionWithdraw

A PositionWithdraw action withdraws reserves from a closed position, specified by its ID, and specifies a transparent commitment to the reserves.

The balance commitment associated with a PositionWithdraw is transparent, debits the LPNFT for the closed position with the specified ID, and credits both the LPNFT for the withdrawn position with the specified ID as well as the reserves to the transaction's balance.

PositionWithdraw::check_stateless unconditionally succeeds.

PositionWithdraw::check_stateful checks that the reserves currently associated with the position in the state match the specified reserves to withdraw.

PositionWithdraw::execute retrieves the position from the state, checks that it is closed (returning an error if it is not), and stores that it is withdrawn in the state.

While there is a potential TOCTOU issue () if the reserves are modified (such as being traded against while open, then closed, then withdrawn, potentially resulting in the assets being swapped twice — once in the batch swap and again in the withdraw-with-stale-reserves), this seems to be unreachable in practice since PositionWithdraw checks that the position is closed immediately, while PositionClose defers the close to the end of the block.

The comments in PositionWithdraw::check_stateful imply that submitting PositionClose + PositionWithdraw for the same position in a single transaction is intended to be supported. This seems like it would require deferring withdrawals to the end of block to happen after the queue of position closures is processed and require checking that the reserves have not shifted there as well. But that would also require changing how the reserves are withdrawn, since while the state transition could be cancelled (and the position left closed instead of withdrawn) at the end of the block, there would be no straightforward way to cancel the withdrawn value at that point (since the value is not necessarily stored in a pair of Output notes, there is not necessarily even a pair of nullifiers to burn).

Per discussion with Penumbra Labs, it's not intended that a position should be able to be closed + withdrawn in the same transaction, and the comment in PositionWithdraw::check_stateful will be revised.

Action: PositionRewardClaim

The PositionRewardClaim actions were intended, in the future, to allow providing retroactive liquidity incentives. They are currently an unimplemented placeholder, and PositionRewardClaim::{check_stateless,check_stateful,execute} all unconditionally return an error.

PositionRewardClaim and State::Claimed were removed in in favour of a different mechanism involving adding sequence numbers to State::Withdrawn.

Zellic © 2025Back to top ↑