agora/agora/Agora/Stake.hs
2022-07-19 22:48:02 +08:00

539 lines
15 KiB
Haskell

{-# LANGUAGE TemplateHaskell #-}
{- |
Module : Agora.Stake
Maintainer : emi@haskell.fyi
Description: Vote-lockable stake UTXOs holding GT.
Vote-lockable stake UTXOs holding GT.
-}
module Agora.Stake (
-- * Haskell-land
StakeDatum (..),
StakeRedeemer (..),
Stake (..),
ProposalLock (..),
-- * Plutarch-land
PStakeDatum (..),
PStakeRedeemer (..),
PProposalLock (..),
PStakeRole (..),
-- * Utility functions
pstakeLocked,
pnumCreatedProposals,
pextractVoteOption,
pgetStakeRole,
pisVoter,
pisCreator,
pisPureCreator,
pisIrrelevant,
) where
import Agora.Plutarch.Orphans ()
import Agora.Proposal (PProposalId, PResultTag, ProposalId (..), ResultTag (..))
import Agora.SafeMoney (GTTag)
import Data.Tagged (Tagged (..))
import GHC.Generics qualified as GHC
import Generics.SOP (Generic, HasDatatypeInfo, I (I))
import Plutarch.Api.V1 (
PPubKeyHash,
)
import Plutarch.DataRepr (
DerivePConstantViaData (..),
PDataFields,
PIsDataReprInstances (PIsDataReprInstances),
)
import Plutarch.Extra.IsData (
DerivePConstantViaDataList (..),
ProductIsData (ProductIsData),
)
import Plutarch.Extra.List (pnotNull)
import Plutarch.Extra.Other (DerivePNewtype' (..))
import Plutarch.Extra.Sum (PSum (..))
import Plutarch.Extra.Traversable (pfoldMap)
import Plutarch.Lift (PConstantDecl, PUnsafeLiftDecl (..))
import Plutarch.SafeMoney (PDiscrete)
import Plutarch.Show (PShow (..))
import PlutusLedgerApi.V1 (PubKeyHash)
import PlutusLedgerApi.V1.Value (AssetClass)
import PlutusTx qualified
import Prelude hiding (Num (..))
--------------------------------------------------------------------------------
{- | Parameters for creating Stake scripts.
@since 0.1.0
-}
data Stake = Stake
{ gtClassRef :: Tagged GTTag AssetClass
-- ^ Used when inlining the AssetClass of a 'PDiscrete' in the script code.
, proposalSTClass :: AssetClass
}
deriving stock
( -- | @since 0.1.0
GHC.Generic
)
{- | Locks that are stored in the stake datums for various purposes.
NOTE: Due to retracting votes always being possible,
this lock will only lock with contention on the proposal.
FIXME: Contention on Proposals could create contention
on voting which in turn creates contention on stakers.
Vaguely this is the dependency graph for this locking
interaction. Both the stake validator and the proposal
validator are only able to check for one another through
the datum belonging to the ST:
@
┌─────────────────┐ ┌────────────────────┐
│ Stake Validator ├─┐ │ Proposal Validator │
└────────┬────────┘ │ └──────┬─────┬───────┘
│ │ │ │
│ ┌─┼────────┘ │
▼ │ │ ▼
┌──────────────┐ │ │ ┌─────────────────┐
│ Stake Policy │◄─┘ └►│ Proposal Policy │
└──────────────┘ └─────────────────┘
@
@since 0.1.0
-}
data ProposalLock
= -- | The stake was used to create a proposal.
--
-- This kind of lock is placed upon the creation of a proposal, in order
-- to limit creation of proposals per stake.
--
-- See also: https://github.com/Liqwid-Labs/agora/issues/68
--
-- @since 0.2.0
Created
ProposalId
-- ^ The identifier of the proposal.
| -- | The stake was used to vote on a proposal.
--
-- This kind of lock is placed while voting on a propsoal, in order to
-- prevent depositing and withdrawing when votes are in place.
--
-- @since 0.2.0
Voted
ProposalId
-- ^ The identifier of the proposal.
ResultTag
-- ^ The option which was voted on. This allows votes to be retracted.
deriving stock
( -- | @since 0.1.0
Show
, -- | @since 0.1.0
GHC.Generic
)
deriving anyclass
( -- | @since 0.1.0
Generic
)
PlutusTx.makeIsDataIndexed
''ProposalLock
[ ('Created, 0)
, ('Voted, 1)
]
{- | Haskell-level redeemer for Stake scripts.
@since 0.1.0
-}
data StakeRedeemer
= -- | Deposit or withdraw a discrete amount of the staked governance token.
-- Stake must be unlocked.
DepositWithdraw (Tagged GTTag Integer)
| -- | Destroy a stake, retrieving its LQ, the minimum ADA and any other assets.
-- Stake must be unlocked.
Destroy
| -- | Permit a Vote to be added onto a 'Agora.Proposal.Proposal'.
-- This also adds a lock to the 'lockedBy' field. See 'ProposalLock'.
-- This needs to be done in sync with casting a vote, otherwise
-- it's possible for a lock to be permanently placed on the stake,
-- and then the funds are lost.
PermitVote
| -- | Retract a vote, removing it from the 'lockedBy' field. See 'ProposalLock'.
-- This action checks for permission of the 'Agora.Proposal.Proposal'. Finished proposals are
-- always allowed to have votes retracted and won't affect the Proposal datum,
-- allowing 'Stake's to be unlocked.
RetractVotes
| -- | The owner can consume stake if nothing is changed about it.
-- If the proposal token moves, this is equivalent to the owner consuming it.
WitnessStake
deriving stock (Show, GHC.Generic)
PlutusTx.makeIsDataIndexed
''StakeRedeemer
[ ('DepositWithdraw, 0)
, ('Destroy, 1)
, ('PermitVote, 2)
, ('RetractVotes, 3)
, ('WitnessStake, 4)
]
{- | Haskell-level datum for Stake scripts.
@since 0.1.0
-}
data StakeDatum = StakeDatum
{ stakedAmount :: Tagged GTTag Integer
-- ^ Tracks the amount of governance token staked in the datum.
-- This also acts as the voting weight for 'Agora.Proposal.Proposal's.
, owner :: PubKeyHash
-- ^ The hash of the public key this stake belongs to.
--
-- TODO Support for MultiSig/Scripts is tracked here:
-- https://github.com/Liqwid-Labs/agora/issues/45
, lockedBy :: [ProposalLock]
-- ^ The current proposals locking this stake. This field must be empty
-- for the stake to be usable for deposits and withdrawals.
}
deriving stock (Show, GHC.Generic)
deriving anyclass (Generic)
deriving
( -- | @since 0.1.0
PlutusTx.ToData
, -- | @since 0.1.0
PlutusTx.FromData
)
via (ProductIsData StakeDatum)
--------------------------------------------------------------------------------
{- | Plutarch-level datum for Stake scripts.
@since 0.1.0
-}
newtype PStakeDatum (s :: S) = PStakeDatum
{ getStakeDatum ::
Term
s
( PDataRecord
'[ "stakedAmount" ':= PDiscrete GTTag
, "owner" ':= PPubKeyHash
, "lockedBy" ':= PBuiltinList (PAsData PProposalLock)
]
)
}
deriving stock
( -- | @since 0.1.0
GHC.Generic
)
deriving anyclass
( -- | @since 0.1.0
Generic
)
deriving anyclass
( -- | @since 0.1.0
PIsDataRepr
)
deriving
( -- | @since 0.1.0
PlutusType
, -- | @since 0.1.0
PIsData
, -- | @since 0.1.0
PDataFields
, -- | @since 0.1.0
PEq
)
via (DerivePNewtype' PStakeDatum)
-- | @since 0.1.0
instance Plutarch.Lift.PUnsafeLiftDecl PStakeDatum where
type PLifted PStakeDatum = StakeDatum
-- | @since 0.1.0
deriving via
(DerivePConstantViaDataList StakeDatum PStakeDatum)
instance
(Plutarch.Lift.PConstantDecl StakeDatum)
-- | @since 0.1.0
deriving via
PAsData (DerivePNewtype' PStakeDatum)
instance
PTryFrom PData (PAsData PStakeDatum)
{- | Plutarch-level redeemer for Stake scripts.
@since 0.1.0
-}
data PStakeRedeemer (s :: S)
= -- | Deposit or withdraw a discrete amount of the staked governance token.
PDepositWithdraw (Term s (PDataRecord '["delta" ':= PDiscrete GTTag]))
| -- | Destroy a stake, retrieving its LQ, the minimum ADA and any other assets.
PDestroy (Term s (PDataRecord '[]))
| PPermitVote (Term s (PDataRecord '[]))
| PRetractVotes (Term s (PDataRecord '[]))
| PWitnessStake (Term s (PDataRecord '[]))
deriving stock
( -- | @since 0.1.0
GHC.Generic
)
deriving anyclass
( -- | @since 0.1.0
Generic
)
deriving anyclass
( -- | @since 0.1.0
PIsDataRepr
)
deriving
( -- | @since 0.1.0
PlutusType
, -- | @since 0.1.0
PIsData
)
via PIsDataReprInstances PStakeRedeemer
-- | @since 0.1.0
deriving via
PAsData (PIsDataReprInstances PStakeRedeemer)
instance
PTryFrom PData (PAsData PStakeRedeemer)
-- | @since 0.1.0
instance Plutarch.Lift.PUnsafeLiftDecl PStakeRedeemer where
type PLifted PStakeRedeemer = StakeRedeemer
-- | @since 0.1.0
deriving via
(DerivePConstantViaData StakeRedeemer PStakeRedeemer)
instance
(Plutarch.Lift.PConstantDecl StakeRedeemer)
{- | Plutarch-level version of 'ProposalLock'.
@since 0.2.0
-}
data PProposalLock (s :: S)
= PCreated (Term s (PDataRecord '["created" ':= PProposalId]))
| PVoted
( Term
s
( PDataRecord
'[ "votedOn" ':= PProposalId
, "votedFor" ':= PResultTag
]
)
)
deriving stock
( -- | @since 0.1.0
GHC.Generic
)
deriving anyclass
( -- | @since 0.1.0
Generic
, -- | @since 0.1.0
HasDatatypeInfo
)
deriving anyclass
( -- | @since 0.1.0
PIsDataRepr
)
deriving
( -- | @since 0.1.0
PlutusType
, -- | @since 0.1.0
PIsData
, -- | @since 0.1.0
PEq
)
via (PIsDataReprInstances PProposalLock)
-- | @since 0.1.0
deriving via
PAsData (PIsDataReprInstances PProposalLock)
instance
PTryFrom PData (PAsData PProposalLock)
-- | @since 0.1.0
instance Plutarch.Lift.PUnsafeLiftDecl PProposalLock where
type PLifted PProposalLock = ProposalLock
-- | @since 0.1.0
deriving via
(DerivePConstantViaData ProposalLock PProposalLock)
instance
(Plutarch.Lift.PConstantDecl ProposalLock)
-- | @since 0.2.0
instance PShow PProposalLock where
pshow' :: Bool -> Term s PProposalLock -> Term s PString
pshow' True x = "(" <> pshow' False x <> ")"
pshow' False lock = pmatch lock $ \case
PCreated ((pfield @"created" #) -> pid) -> "PCreated " <> pshow' True pid
PVoted x -> pletFields @'["votedOn", "votedFor"] x $ \xF ->
"PVoted " <> pshow' True xF.votedOn <> " " <> pshow' True xF.votedFor
--------------------------------------------------------------------------------
{- | Check whether a Stake is locked. If it is locked, various actions are unavailable.
@since 0.2.0
-}
pstakeLocked :: forall (s :: S). Term s (PStakeDatum :--> PBool)
pstakeLocked = phoistAcyclic $
plam $ \stakeDatum ->
let locks :: Term _ (PBuiltinList (PAsData PProposalLock))
locks = pfield @"lockedBy" # stakeDatum
in pnotNull # locks
{- | Get the number of *alive* proposals that were created by the given stake.
@since 0.2.0
-}
pnumCreatedProposals :: Term s (PBuiltinList (PAsData PProposalLock) :--> PInteger)
pnumCreatedProposals =
phoistAcyclic $
plam $ \l ->
pto $
pfoldMap
# plam
( \(pfromData -> lock) -> pmatch lock $ \case
PCreated _ -> pcon $ PSum 1
_ -> mempty
)
# l
{- | The role of a stake for a particular proposal. Scott-encoded.
@since 0.2.0
-}
data PStakeRole (s :: S)
= -- | The stake was used to vote on the proposal.
PVoter
(Term s PResultTag)
-- ^ The option which was voted for.
| -- | The stake was used to create the propsoal.
PCreator
| -- | The stake was used to both create and vote on the proposal.
PBoth
(Term s PResultTag)
-- ^ The option which was voted for.
| -- | The stake has nothing to do with the given propsoal.
PIrrelevant
deriving stock
( -- | @since 0.2.0
GHC.Generic
)
deriving anyclass
( -- | @since 0.2.0
Generic
, -- | @since 0.2.0
PlutusType
, -- | @since 0.2.0
HasDatatypeInfo
, -- | @since 0.2.0
PEq
)
{- | Retutn true if the stake was used to voted on the proposal.
@since 0.2.0
-}
pisVoter :: Term s (PStakeRole :--> PBool)
pisVoter = phoistAcyclic $
plam $ \sr -> pmatch sr $ \case
PVoter _ -> pconstant True
PBoth _ -> pconstant True
_ -> pconstant False
{- | Retutn true if the stake was used to create the proposal.
@since 0.2.0
-}
pisCreator :: Term s (PStakeRole :--> PBool)
pisCreator = phoistAcyclic $
plam $ \sr -> pmatch sr $ \case
PCreator -> pconstant True
PBoth _ -> pconstant True
_ -> pconstant False
{- | Retutn true if the stake was used to create the proposal, but not vote on
the proposal.
@since 0.2.0
-}
pisPureCreator :: Term s (PStakeRole :--> PBool)
pisPureCreator = phoistAcyclic $
plam $ \sr -> pmatch sr $ \case
PCreator -> pconstant True
_ -> pconstant False
{- | Return true if the stake isn't related to the proposal.
@since 0.2.0
-}
pisIrrelevant :: Term s (PStakeRole :--> PBool)
pisIrrelevant = phoistAcyclic $
plam $ \sr -> pmatch sr $ \case
PIrrelevant -> pconstant True
_ -> pconstant False
{- | Get the role of a stake for the proposal specified by the poroposal id,
given the 'StakeDatum.lockedBy' field of the stake.
Note that the list of locks is cosidered valid only if it contains at most
two locks from the given proposal: one voter lock and one creator lock.
@since 0.2.0
-}
pgetStakeRole :: Term s (PProposalId :--> PBuiltinList (PAsData PProposalLock) :--> PStakeRole)
pgetStakeRole = phoistAcyclic $
plam $ \pid locks ->
pfoldl
# plam
( \role (pfromData -> lock) ->
let thisRole = pmatch lock $ \case
PCreated ((pfield @"created" #) -> pid') ->
pif
(pid' #== pid)
(pcon PCreator)
(pcon PIrrelevant)
PVoted lock' -> pletFields @'["votedOn", "votedFor"] lock' $ \lockF ->
pif
(lockF.votedOn #== pid)
(pcon $ PVoter lockF.votedFor)
(pcon PIrrelevant)
in pcombineStakeRole # thisRole # role
)
# pcon PIrrelevant
# locks
where
pcombineStakeRole :: Term s (PStakeRole :--> PStakeRole :--> PStakeRole)
pcombineStakeRole = phoistAcyclic $
plam $ \x y ->
let cannotCombine = ptraceError "duplicate roles"
in pmatch x $ \case
PVoter r -> pmatch y $ \case
PCreator -> pcon $ PBoth r
PIrrelevant -> x
_ -> cannotCombine
PCreator -> pmatch y $ \case
PVoter r -> pcon $ PBoth r
PIrrelevant -> x
_ -> cannotCombine
PBoth _ -> cannotCombine
PIrrelevant -> y
{- | Get the outcome that was voted for.
@since 0.2.0
-}
pextractVoteOption :: Term s (PStakeRole :--> PResultTag)
pextractVoteOption = phoistAcyclic $
plam $ \sr -> pmatch sr $ \case
PVoter r -> r
PBoth r -> r
_ -> ptraceError "not voter"