agora/agora/Agora/Governor/Scripts.hs

589 lines
22 KiB
Haskell

{- |
Module : Agora.Governor.Scripts
Maintainer : connor@mlabs.city
Description: Plutus scripts for Governors.
Plutus scripts for Governors.
-}
module Agora.Governor.Scripts (
-- * GST
-- $gst
-- * Scripts
governorPolicy,
governorValidator,
) where
--------------------------------------------------------------------------------
import Agora.AuthorityToken (
authorityTokensValidIn,
singleAuthorityTokenBurned,
)
import Agora.Governor (
PGovernorDatum (PGovernorDatum),
PGovernorRedeemer (..),
pgetNextProposalId,
pisGovernorDatumValid,
)
import Agora.Proposal (
PProposalDatum (..),
ProposalStatus (Draft, Locked),
phasNeutralEffect,
pisEffectsVotesCompatible,
pisVotesEmpty,
pneutralOption,
pwinner,
)
import Agora.Proposal.Time (validateProposalStartingTime)
import Agora.Scripts (
AgoraScripts,
authorityTokenSymbol,
governorSTSymbol,
proposalSTSymbol,
proposalValidatoHash,
stakeSTSymbol,
)
import Agora.Stake (
PStakeDatum (..),
pnumCreatedProposals,
)
import Agora.Utils (
plistEqualsBy,
pscriptHashToTokenName,
validatorHashToAddress,
)
import Plutarch.Api.V1.AssocMap (plookup)
import Plutarch.Api.V1.AssocMap qualified as AssocMap
import Plutarch.Api.V2 (
PMintingPolicy,
PScriptPurpose (PMinting, PSpending),
PTxOut,
PValidator,
)
import Plutarch.Extra.AssetClass (passetClass, passetClassValueOf)
import Plutarch.Extra.Field (pletAll, pletAllC)
import "liqwid-plutarch-extra" Plutarch.Extra.List (pfindJust, pmapMaybe)
import Plutarch.Extra.Map (pkeys, ptryLookup)
import Plutarch.Extra.Maybe (passertPJust, pjust, pmaybe, pmaybeData, pnothing)
import Plutarch.Extra.Ord (psort)
import Plutarch.Extra.Record (mkRecordConstr, (.&), (.=))
import Plutarch.Extra.ScriptContext (
pfindTxInByTxOutRef,
pfromDatumHash,
pfromOutputDatum,
pisUTXOSpent,
pscriptHashFromAddress,
pvalueSpent,
)
import "liqwid-plutarch-extra" Plutarch.Extra.TermCont (
pguardC,
pletC,
pletFieldsC,
pmatchC,
ptryFromC,
)
import Plutarch.Extra.Value (psymbolValueOf)
import PlutusLedgerApi.V1 (TxOutRef)
--------------------------------------------------------------------------------
{- $gst
Governance state token, aka. GST, is an NFT that identifies a UTXO that
carries the state datum of the Governance script.
This token is minted by a one-shot monetary policy 'governorPolicy',
meaning that the token has guaranteed uniqueness.
The 'governorValidator' ensures that exactly one GST stays
at the address of itself forever.
-}
--------------------------------------------------------------------------------
{- | Policy for minting GSTs.
This policy perform the following checks:
- The UTXO referenced in the parameter is spent in the transaction.
- Exactly one GST is minted.
- Ensure the token name is empty.
- Said UTXO should carry a valid 'Agora.Governor.GovernorDatum'.
NOTE: It's user's responsibility to make sure the token is sent to the corresponding governor validator.
We /can't/ really check this in the policy, otherwise we create a cyclic reference issue.
@since 0.1.0
-}
governorPolicy :: TxOutRef -> ClosedTerm PMintingPolicy
governorPolicy initialSpend =
plam $ \_ ctx -> unTermCont $ do
PMinting ((pfield @"_0" #) -> gstSymbol) <-
pmatchC (pfromData $ pfield @"purpose" # ctx)
let txInfo = pfromData $ pfield @"txInfo" # ctx
txInfoF <-
pletFieldsC
@'[ "mint"
, "inputs"
, "outputs"
, "datums"
, "validRange"
]
txInfo
pguardC "Referenced utxo should be spent" $
pisUTXOSpent # pconstant initialSpend # txInfoF.inputs
pguardC "Exactly one token should be minted" $
let vMap = pfromData $ pto txInfoF.mint
tnMap =
passertPJust # "GST symbol entry"
#$ plookup # gstSymbol # vMap
in tnMap #== AssocMap.psingleton # pconstant "" # 1
let governorOutputDatum =
passertPJust # "Governor output should present"
#$ pfindJust
# plam
( flip (pletFields @'["value", "datum"]) $ \txOutF ->
let isGovernorUTxO =
psymbolValueOf # gstSymbol
# txOutF.value #== 1
governorDatum =
ptrace "Resolve governor datum" $
pfromOutputDatum @PGovernorDatum # txOutF.datum
# txInfoF.datums
in pif isGovernorUTxO (pjust # governorDatum) pnothing
)
# pfromData txInfoF.outputs
pguardC "Governor output datum valid" $
pisGovernorDatumValid # governorOutputDatum
pure $ popaque $ pconstant ()
{- | Validator for Governors.
== Common checks
The validator always ensures:
- The UTXO which holds the GST must be spent.
- The GST always stays at the validator's address.
- The new state UTXO has a valid datum of type 'Agora.Governor.GovernorDatum'.
== Creating a Proposal
When the redeemer is 'Agora.Governor.CreateProposal', the script will check:
- For governor's state datum:
* 'Agora.Governor.nextProposalId' is advanced.
* Nothing is changed other that that.
- Exactly one stake (the "input stake") must be provided in the input:
* At least 'Agora.Stake.stackedAmount' of GT must be spent in the transaction.
* The transaction must be signed by the stake owner.
- Exactly one new proposal state token is minted.
- An UTXO which holds the newly minted proposal state token is sent to the proposal validator.
This UTXO must have a valid datum of type 'Agora.Proposal.ProposalDatum', the datum must:
* Copy its id and thresholds from the governor's state.
* Have status set to 'Proposal.Draft'.
* Have zero votes.
* Have exactly one cosigner - the stake owner
- An UTXO which holds the stake state token is sent back to the stake validator.
This UTXO must have a valid datum of type 'Agora.Stake.StakeDatum':
* The 'Agora.Stake.stakedAmount' and 'Agora.Stake.owner' should not be changed,
comparing to the input stake.
* The new proposal locks must be appended to the 'Agora.Stake.lockedBy'.
== Minting GATs
When the redeemer is 'Agora.Governor.MintGATs', the script will check:
- Governor's state is not changed.
- Exactly only one proposal is in the inputs. Let's call this the /input proposal/.
- The proposal is in the 'Proposal.Executable' state.
NOTE: The input proposal is found by looking for the UTXO with a proposal state token in the inputs.
=== Effect Group Selection
Currently a proposal can have two or more than two options to vote on,
meaning that it can contains two or more effect groups,
according to [#39](https://github.com/Liqwid-Labs/agora/issues/39).
Either way, the shapes of 'Proposal.votes' and 'Proposal.effects' should be the same.
This is checked by 'Proposal.proposalDatumValid'.
The script will look at the the 'Proposal.votes' to determine which group has the highest votes,
said group shoud be executed.
During the process, minimum votes requirement will also be enforced.
Next, the script will:
- Ensure that for every effect in the said effect group,
exactly one valid GAT is minted and sent to the effect.
- The amount of GAT minted in the transaction should be equal to the number of effects.
- A new UTXO is sent to the proposal validator, this UTXO should:
* Include the one proposal state token.
* Have a valid datum of type 'Proposal.ProposalDatum'.
This datum should be as same as the one of the input proposal,
except its status should be 'Proposal.Finished'.
== Changing the State
Redeemer 'Agora.Governor.MutateGovernor' allows the state datum to be changed by an external effect.
In this case, the script will check
- Exactly one GAT is burnt in the transaction.
- Said GAT is tagged by the effect.
@since 0.1.0
-}
governorValidator ::
-- | Lazy precompiled scripts.
AgoraScripts ->
ClosedTerm PValidator
governorValidator as =
plam $ \datum redeemer ctx -> unTermCont $ do
pstSymbol <- pletC $ pconstant $ proposalSTSymbol as
atSymbol <- pletC $ pconstant $ authorityTokenSymbol as
----------------------------------------------------------------------------
ctxF <- pletAllC ctx
txInfo <- pletC $ pfromData ctxF.txInfo
txInfoF <-
pletFieldsC
@'[ "mint"
, "inputs"
, "outputs"
, "datums"
, "signatories"
, "validRange"
]
txInfo
----------------------------------------------------------------------------
governorInputDatum <- fst <$> ptryFromC @PGovernorDatum datum
governorInputDatumF <- pletAllC governorInputDatum
PSpending ((pfield @"_0" #) -> governorInputRef) <-
pmatchC $ pfromData ctxF.purpose
let governorInput =
pfield @"resolved"
#$ passertPJust # "Malformed script context: own input not found"
#$ pfindTxInByTxOutRef
# governorInputRef
# txInfoF.inputs
governorInputF <- pletFieldsC @'["address", "value"] governorInput
----------------------------------------------------------------------------
governorOutputDatum <-
pletC $
passertPJust
# "Own output should present"
#$ pfindJust
# plam
( flip pletAll $ \outputF ->
let gstSymbol = pconstant $ governorSTSymbol as
isGovernorUTxO =
foldl1
(#&&)
[ ptraceIfFalse "Own by governor validator" $
outputF.address #== governorInputF.address
, ptraceIfFalse "Has governor ST" $
psymbolValueOf # gstSymbol # outputF.value #== 1
]
datum =
ptrace "Resolve governor datum" $
pfromOutputDatum @PGovernorDatum
# outputF.datum
# txInfoF.datums
in pif
isGovernorUTxO
(pjust # datum)
pnothing
)
# pfromData txInfoF.outputs
----------------------------------------------------------------------------
getStakeDatum :: Term _ (PTxOut :--> PMaybe PStakeDatum) <-
pletC $
plam $
flip (pletFields @'["value", "datum"]) $ \txOutF ->
let sstSymbol = pconstant $ stakeSTSymbol as
isStakeUTxO =
psymbolValueOf
# sstSymbol
# txOutF.value #== 1
datum =
ptrace "Resolve stake input datum" $
pfromData $
pfromOutputDatum
# txOutF.datum
# txInfoF.datums
in pif isStakeUTxO (pjust # datum) pnothing
getProposalDatum :: Term _ (PTxOut :--> PMaybe PProposalDatum) <-
pletC $
plam $
flip (pletFields @'["value", "datum", "address"]) $ \txOutF ->
let proposalValidatorAddress =
pconstant $
validatorHashToAddress $
proposalValidatoHash as
isProposalUTxO =
txOutF.address #== pdata proposalValidatorAddress
#&& psymbolValueOf # pstSymbol # txOutF.value #== 1
proposalDatum =
ptrace "Resolve proposal output datum" $
pfromData $
pfromOutputDatum
# txOutF.datum
# txInfoF.datums
in pif isProposalUTxO (pjust # proposalDatum) pnothing
----------------------------------------------------------------------------
governorRedeemer <- pfromData . fst <$> ptryFromC redeemer
pure $
pmatch governorRedeemer $ \case
PCreateProposal -> unTermCont $ do
-- Check that the transaction advances proposal id.
let expectedNextProposalId =
pgetNextProposalId
# governorInputDatumF.nextProposalId
expectedNewDatum =
mkRecordConstr
PGovernorDatum
( #proposalThresholds .= governorInputDatumF.proposalThresholds
.& #nextProposalId .= pdata expectedNextProposalId
.& #proposalTimings .= governorInputDatumF.proposalTimings
.& #createProposalTimeRangeMaxWidth
.= governorInputDatumF.createProposalTimeRangeMaxWidth
.& #maximumProposalsPerStake
.= governorInputDatumF.maximumProposalsPerStake
)
pguardC "Only next proposal id gets advanced" $
governorOutputDatum #== expectedNewDatum
-- Check that exactly one proposal token is being minted.
pguardC "Exactly one proposal token must be minted" $
let vMap = pfromData $ pto txInfoF.mint
tnMap = plookup # pstSymbol # vMap
-- Ada and PST
onlyPST = plength # pto vMap #== 2
onePST =
pmaybe
# pconstant False
# plam (#== AssocMap.psingleton # pconstant "" # 1)
# tnMap
in onlyPST #&& onePST
-- Check that a stake is spent to create the propsal,
-- and the value it contains meets the requirement.
let stakeInputDatum =
passertPJust # "Stake input should present"
#$ pfindJust
# plam ((getStakeDatum #) . (pfield @"resolved" #))
# pfromData txInfoF.inputs
stakeInputDatumF <- pletAllC stakeInputDatum
pguardC "Proposals created by the stake must not exceed the limit" $
pnumCreatedProposals # stakeInputDatumF.lockedBy
#< governorInputDatumF.maximumProposalsPerStake
let gtThreshold =
pfromData $
pfield @"create"
# governorInputDatumF.proposalThresholds
pguardC "Require minimum amount of GTs" $
gtThreshold #< stakeInputDatumF.stakedAmount
-- Check that the newly minted PST is sent to the proposal validator,
-- and the datum it carries is legal.
let proposalOutputDatum =
passertPJust # "Proposal output should present"
#$ pfindJust
# getProposalDatum
# pfromData txInfoF.outputs
proposalOutputDatumF <- pletAllC proposalOutputDatum
let expectedCosigners = psingleton @PBuiltinList # stakeInputDatumF.owner
pguardC "Proposal datum correct" $
foldl1
(#&&)
[ ptraceIfFalse "has neutral effect" $
phasNeutralEffect # proposalOutputDatumF.effects
, ptraceIfFalse "votes have valid shape" $
pisEffectsVotesCompatible # proposalOutputDatumF.effects # proposalOutputDatumF.votes
, ptraceIfFalse "votes are empty" $
pisVotesEmpty # proposalOutputDatumF.votes
, ptraceIfFalse "id correct" $
proposalOutputDatumF.proposalId #== governorInputDatumF.nextProposalId
, ptraceIfFalse "status is Draft" $
proposalOutputDatumF.status #== pconstantData Draft
, ptraceIfFalse "cosigners correct" $
plistEquals # pfromData proposalOutputDatumF.cosigners # expectedCosigners
, ptraceIfFalse "starting time valid" $
validateProposalStartingTime
# governorInputDatumF.createProposalTimeRangeMaxWidth
# txInfoF.validRange
# proposalOutputDatumF.startingTime
, ptraceIfFalse "copy over configurations" $
proposalOutputDatumF.thresholds #== governorInputDatumF.proposalThresholds
#&& proposalOutputDatumF.timingConfig #== governorInputDatumF.proposalTimings
]
pure $ popaque $ pconstant ()
------------------------------------------------------------------------
PMintGATs -> unTermCont $ do
pguardC "Governor state should not be changed" $ governorOutputDatum #== governorInputDatum
-- Filter out proposal inputs and ouputs using PST and the address of proposal validator.
pguardC "The governor can only process one proposal at a time" $
(psymbolValueOf # pstSymbol #$ pvalueSpent # txInfoF.inputs) #== 1
let proposalInputDatum =
passertPJust # "Proposal input not found"
#$ pfindJust
# plam ((getProposalDatum #) . (pfield @"resolved" #))
# pfromData txInfoF.inputs
proposalInputDatumF <-
pletFieldsC @'["effects", "status", "thresholds", "votes"]
proposalInputDatum
-- Check that the proposal state is advanced so that a proposal cannot be executed twice.
pguardC "Proposal must be in locked(executable) state in order to execute effects" $
proposalInputDatumF.status #== pconstantData Locked
-- Find the highest votes and the corresponding tag.
let quorum = pto $ pto $ pfromData $ pfield @"execute" # proposalInputDatumF.thresholds
neutralOption = pneutralOption # proposalInputDatumF.effects
finalResultTag = pwinner # proposalInputDatumF.votes # quorum # neutralOption
-- The effects of the winner outcome.
effectGroup <- pletC $ ptryLookup # finalResultTag #$ proposalInputDatumF.effects
let -- For a given output, check if it contains a single valid GAT.
getReceiverScriptHash =
plam
( \output -> unTermCont $ do
outputF <- pletFieldsC @'["address", "datum", "value"] output
let isAuthorityUTxO =
psymbolValueOf
# atSymbol
# outputF.value #== 1
handleAuthorityUTxO =
unTermCont $ do
receiverScriptHash <-
pletC $
passertPJust # "GAT receiver should be a script"
#$ pscriptHashFromAddress # outputF.address
effect <-
pletAllC $
passertPJust # "Receiver should be in the effect group"
#$ AssocMap.plookup # receiverScriptHash # effectGroup
let tagToken =
pmaybeData
# pconstant ""
# plam (pscriptHashToTokenName . pfromData)
# effect.scriptHash
gatAssetClass = passetClass # atSymbol # tagToken
valueGATCorrect =
passetClassValueOf
# outputF.value
# gatAssetClass #== 1
let hasCorrectDatum =
effect.datumHash #== pfromDatumHash # outputF.datum
pguardC "Authority output valid" $
foldr1
(#&&)
[ ptraceIfFalse "GAT valid" $ authorityTokensValidIn # atSymbol # output
, ptraceIfFalse "Correct datum" hasCorrectDatum
, ptraceIfFalse "Value correctly encodes Auth Check script" valueGATCorrect
]
pure receiverScriptHash
pure $
pif
isAuthorityUTxO
(pjust # handleAuthorityUTxO)
pnothing
)
-- The sorted hashes of all the GAT receivers.
actualReceivers =
psort
#$ pmapMaybe
# getReceiverScriptHash
# pfromData txInfoF.outputs
expectedReceivers = pkeys @PList # effectGroup
-- This check ensures that it's impossible to send more than one GATs
-- to a validator in the winning effect group.
pguardC "Each script in the effect group gets a GAT" $
plistEqualsBy
# plam (\(pfromData -> x) y -> x #== y)
# expectedReceivers
# actualReceivers
pure $ popaque $ pconstant ()
------------------------------------------------------------------------
PMutateGovernor -> unTermCont $ do
pguardC "Governor output datum is valid" $
pisGovernorDatumValid # governorOutputDatum
-- Check that a GAT is burnt.
pguardC "One valid GAT burnt" $
singleAuthorityTokenBurned atSymbol txInfoF.inputs txInfoF.mint
pure $ popaque $ pconstant ()