Mōdsefa.Donate

Defining the States and Validators

Tutorial Part 2: Defining the States and Validators

Before we can define what our application does, we first need to define the data it operates on. This involves two main steps: defining our on-chain data structures as Haskell types and then specifying the validators that will govern them. All the code for this part can be found in examples/Modsefa/Examples/Subscription/Types.hs and examples/Modsefa/Examples/Subscription/Validators.hs.


Step 1: Define Your Data Types

First, we define standard Haskell records for each piece of on-chain data. Modsefa uses Template Haskell (makeLift and makeIsDataIndexed) to automatically handle the conversion of these types to and from their on-chain Plutus Data representation.

-- The core configuration for the service 
data ServiceConfig = ServiceConfig 
  { serviceConfigName :: BuiltinByteString
  , serviceConfigProvider :: PubKeyHash
  } deriving (Eq, Show, Generic) 
PlutusTx.makeLift ''ServiceConfig 
PlutusTx.makeIsDataIndexed ''ServiceConfig [('ServiceConfig, 0)]
  
-- A purchasable subscription tier 
data PricingTier = PricingTier 
  { pricingTierName :: BuiltinByteString
  , pricingTierPrice :: Integer
  , pricingTierAssetClass :: AssetClass
  , pricingTierBillingPeriod:: POSIXTime
  , pricingTierContractLength :: POSIXTime 
  } deriving (Eq, Show, Generic) 
PlutusTx.makeLift ''PricingTier
PlutusTx.makeIsDataIndexed ''PricingTier [('PricingTier, 0)]
 
-- A redeemable coupon for discounts
data Coupon = Coupon 
  { couponId :: Integer
  , couponBatchId :: TxOutRef
  , couponDiscountPercent :: Integer
  } deriving (Eq, Show, Generic) 
PlutusTx.makeLift ''Coupon
PlutusTx.makeIsDataIndexed ''Coupon [('Coupon, 0)]
instance Mappable Coupon 
 
-- An individual customer's subscription details 
data CustomerSubscription = CustomerSubscription
  { customerSubscriptionPkh :: PubKeyHash
  , customerSubscriptionPrice :: Integer
  , customerSubscriptionAssetClass :: AssetClass
  , customerSubscriptionBillingPeriod :: POSIXTime
  , customerSubscriptionContractEndDate :: POSIXTime
  , customerSubscriptionPaidThrough :: POSIXTime
  , customerSubscriptionServiceProviderValidator :: ScriptHash
  } deriving (Eq, Show, Generic)
PlutusTx.makeLift ''CustomerSubscription
PlutusTx.makeIsDataIndexed ''CustomerSubscription [('CustomerSubscription, 0)]
  
  -- A placeholder type for our aggregate treasury state 
newtype TreasuryAda = TreasuryAda ()
  deriving (Show, Eq, Generic)
PlutusTx.makeIsDataIndexed ''TreasuryAda [('TreasuryAda, 0)]
PlutusTx.makeLift ''TreasuryAda

Step 2: Define Your State Types

Next, we promote these data types into StateTypes. This is a simple type alias that links a type-level name (like "ServiceConfig") to the Haskell record (ServiceConfig).

type ServiceConfigState = 'ST "ServiceConfig" ServiceConfig 
type PricingTierState = 'ST "PricingTier" PricingTier 
type CouponState = 'ST "Coupon" Coupon 
type CustomerSubscriptionState = 'ST "CustomerSubscription" CustomerSubscription 
type TreasuryAdaState = 'ST "TreasuryAda" TreasuryAda

Now that Modsefa knows about our states, we must implement the StateRepresentable typeclass for each one. This tells the framework how to identify and manage instances of these states on the blockchain.

Instance-Based States:

Most of our states are instance-based, a state instance is identified by a UTxO containing a unique token that is minted by the validator that manages the state (OwnPolicy).

instance StateRepresentable ServiceConfigState where
  stateIdentifier _ = TokenIdentified OwnPolicy "ServiceConfig" 1
  type AllowedRefStrategy ServiceConfigState = 'OnlyAsUnique
 
instance StateRepresentable PricingTierState where
  stateIdentifier _ = TokenIdentified OwnPolicy "PricingTier" 1
  type AllowedRefStrategy PricingTierState = 'OnlyByProperty
 
instance StateRepresentable CouponState where
  stateIdentifier _ = TokenIdentified OwnPolicy "Coupon" 1
  type AllowedRefStrategy CouponState = 'OnlyByProperty
 
instance StateRepresentable CustomerSubscriptionState where
  stateIdentifier _ = TokenIdentified OwnPolicy "CustomerSubscription" 1
  type AllowedRefStrategy CustomerSubscriptionState = 'OnlyByProperty
  • ServiceConfigState has a reference strategy of 'OnlyAsUnique, which enforces that only one instance of this state can exist for the entire application.

  • PricingTierState and CouponState use 'OnlyByProperty, allowing for multiple instances that can be queried and referenced by the data in their datums (e.g., "find the pricing tier where the name is 'Premium'").

Aggregate State:

The TreasuryAdaState is different. It's an aggregate state, so its stateIdentifier isn't a unique token but rather the asset it accumulates (GYLovelace). Its reference strategy is 'NoRef' because we don't interact with it as a single instance, but rather as a pool of value.

instance StateRepresentable TreasuryAdaState where 
  stateIdentifier _ = AggregateAsset GYLovelace 
  type AllowedRefStrategy TreasuryAdaState = 'NoRef

Step 3: Specify the Validators

With our states defined, we now declare the validators that will manage them. We create an empty data type to act as a tag for each validator and then implement the ValidatorSpec typeclass.

data ServiceAndPricingValidator
data CouponValidator
data CustomerValidator
data TreasuryValidator
 
instance ValidatorSpec ServiceAndPricingValidator where
  type Params ServiceAndPricingValidator = '[ '("bootstrapUtxo", TxOutRef) ]
  type ManagedStates ServiceAndPricingValidator = '[ServiceConfigState, PricingTierState]
  type ValidatorAppName ServiceAndPricingValidator = "ServiceAndPricingValidator"
  type ValidatorPlutusVersion ServiceAndPricingValidator = 'PlutusV3
  type ValidatorInstanceType ServiceAndPricingValidator = SingleInstance
 
instance ValidatorSpec CouponValidator where
  type Params CouponValidator = '[ '("serviceValidatorAddress", Address) ]
  type ManagedStates CouponValidator = '[CouponState]
  type ValidatorAppName CouponValidator = "CouponValidator"
  type ValidatorPlutusVersion CouponValidator = 'PlutusV3
  type ValidatorInstanceType CouponValidator = SingleInstance
 
instance ValidatorSpec CustomerValidator where
  type Params CustomerValidator = '[ '("customerPkh", PubKeyHash) ]
  type ManagedStates CustomerValidator = '[CustomerSubscriptionState]
  type ValidatorAppName CustomerValidator = "CustomerValidator"
  type ValidatorPlutusVersion CustomerValidator = 'PlutusV3
  type ValidatorInstanceType CustomerValidator = MultiInstance
 
instance ValidatorSpec TreasuryValidator where
  type Params TreasuryValidator = '[ '("serviceValidatorAddress", Address) ]
  type ManagedStates TreasuryValidator = '[TreasuryAdaState]
  type ValidatorAppName TreasuryValidator = "TreasuryValidator"
  type ValidatorPlutusVersion TreasuryValidator = 'PlutusV3
  type ValidatorInstanceType TreasuryValidator = SingleInstance

Here's what this defines:

  • ManagedStates: Connects each validator to the StateTypes it's responsible for.
  • Params: Defines the data required to parameterize the validator script.
    • ServiceAndPricingValidator is parameterized by a bootstrapUtxo to create a unique instance of the entire service.
    • CustomerValidator is parameterized by a customerPkh, meaning a unique validator instance will be created for each customer.
  • ValidatorInstanceType: Specifies whether the application can have a single (SingleInstance) or multiple (MultiInstance) instances of this validator. This is how we enforce our singleton and multi-instance patterns.

We have now defined all the data structures and the validators that will protect them. The logic of how these validators behave will be automatically generated from the actions we define in a future step.

Defining the States and Validators