Implementing Core User Actions
Tutorial Part 4: Implementing Core User Actions
With our application architecture defined, we can now specify what users can actually do. Actions are the heart of a Modsefa application. Each 'ActionSpec' is a self-contained, type-level declaration that describes a single user interaction, including the operations to perform, the constraints that must be met, and the parameters the user must provide.
From these specifications, Modsefa will automatically generate both the on-chain validator logic to enforce the rules and the off-chain code to build the transactions.
All the code for this section is in examples/Modsefa/Examples/Subscription/Spec.hs.
Action 1: InitializeServiceSpec
Every application needs an entry point. The InitializeServiceSpec action creates the first state instances for our service: the singleton ServiceConfigState instance and the initial PricingTierState instance.
type InitializeServiceSpec =
'ActionSpec @SubscriptionApp "InitializeService"
'[ 'Op ('Create @ServiceConfigState
'[ 'SetTo "serviceConfigName" ('ParamValue "serviceName")
, 'SetTo "serviceConfigProvider" ('ParamValue "serviceProvider")
]
'[])
, 'Op ('Create @PricingTierState
'[ 'SetTo "pricingTierName" ('ParamValue "tierName")
, 'SetTo "pricingTierPrice" ('ParamValue "price")
, 'SetTo "pricingTierAssetClass" ('ParamValue "assetClass")
, 'SetTo "pricingTierBillingPeriod" ('ParamValue "billingPeriod")
, 'SetTo "pricingTierContractLength" ('ParamValue "contractLength")
]
'[])
]
'[ 'MustSpendValidatorParam "ServiceAndPricingValidator" "bootstrapUtxo"
, 'MustNotExist @ServiceConfigState 'TypedTheOnlyInstance
]
'[ '("serviceName", BuiltinByteString)
, '("serviceProvider", PubKeyHash)
, '("tierName", BuiltinByteString)
, '("price", Integer)
, '("assetClass", AssetClass)
, '("billingPeriod", POSIXTime)
, '("contractLength", POSIXTime)
]Let's break this down:
Operations(the first list): This action performs two'Create'operations.- It creates one
ServiceConfigStateinstance. The fieldsserviceConfigNameandserviceConfigProviderare set using values from the action's parameters ('ParamValue'). - It creates the first
PricingTierStateinstance, similarly setting its fields from user-provided parameters.
- It creates one
Constraints(the second list): The transaction must satisfy two conditions.'MustSpendValidatorParam "ServiceAndPricingValidator" "bootstrapUtxo": This is the critical constraint that makes this an initialization action. It forces the transaction to spend thebootstrapUtxothat parameterizes the main validator, thus "booting up" this unique instance of the service.'MustNotExist @ServiceConfigState 'TypedTheOnlyInstance': This ensures that the initialization action can only be run once by preventing the creation of aServiceConfigStateif one already exists.
Parameters(the third list): This defines the data the user must provide to build the transaction—a service name, the provider's public key hash, and details for the first pricing tier.
Action 2: CreatePricingTierSpec
Once the service is initialized, the provider might want to add more pricing tiers. This action allows them to do so.
type CreatePricingTierSpec =
'ActionSpec @SubscriptionApp "CreatePricingTier"
'[ 'Op ('Create @PricingTierState
'[ 'SetTo "pricingTierName" ('ParamValue "tierName")
, 'SetTo "pricingTierPrice" ('ParamValue "price")
, 'SetTo "pricingTierAssetClass" ('ParamValue "assetClass")
, 'SetTo "pricingTierBillingPeriod" ('ParamValue "billingPeriod")
, 'SetTo "pricingTierContractLength" ('ParamValue "contractLength")
]
'[])
]
'[ 'MustBeSignedByState @ServiceConfigState 'TypedTheOnlyInstance "serviceConfigProvider"
]
'[ '("tierName", BuiltinByteString)
, '("price", Integer)
, '("assetClass", AssetClass)
, '("billingPeriod", POSIXTime)
, '("contractLength", POSIXTime)
]Operations: This action performs a single'Create'operation to produce a newPricingTierStateinstance, with its data coming from the action's parameters.Constraints: This action has a single but very important constraint:'MustBeSignedByState @ServiceConfigState 'TypedTheOnlyInstance "serviceConfigProvider"'.@ServiceConfigState 'TypedTheOnlyInstancetells the transaction builder to find the uniqueServiceConfigStateinstance and include it as a reference input."serviceConfigProvider"tells the validator script generator that the transaction must be signed by the public key hash found in theserviceConfigProviderfield of that state's datum.- This is how we enforce ownership: only the key designated in the on-chain
ServiceConfigStatecan authorize the creation of new pricing tiers.
Actions 3 & 4: Updating the Service Configuration
Once a service is running, the provider will inevitably need to change things. These next two actions demonstrate how to perform an 'Update' operation on a singleton state instance, and how to use 'Preserve' to ensure that only specific fields are modified.
Updating the Service Name
The UpdateServiceConfigSpec allows the provider to change the name of the service while keeping the owner the same.
type UpdateServiceConfigSpec =
'ActionSpec @SubscriptionApp "UpdateServiceConfig"
'[ 'Op ('Update @ServiceConfigState 'TypedTheOnlyInstance
'[ 'SetTo "serviceConfigName" ('ParamValue "newServiceName")
, 'Preserve "serviceConfigProvider"
]
'[])
]
'[ 'MustBeSignedByState @ServiceConfigState 'TypedTheOnlyInstance "serviceConfigProvider"
]
'[ '("newServiceName", BuiltinByteString)
]Operations: This action performs a single'Update'on the'TypedTheOnlyInstance'of theServiceConfigState.'SetTo "serviceConfigName" ...updates the name to the new value provided as a parameter.'Preserve "serviceConfigProvider"is a crucial part of the validation. It tells the validator script to enforce that theserviceConfigProviderfield in the datum must be identical in both the input state instance and the new output state instance. This prevents the action from being maliciously used to change the owner.
Constraints: Just like creating a pricing tier, this action must be signed by the currentserviceConfigProvider, ensuring only the authorized owner can make changes.
Updating the Service Provider (Transferring Ownership)
Similarly, the UpdateServiceProviderSpec allows for transferring ownership of the service.
type UpdateServiceProviderSpec =
'ActionSpec @SubscriptionApp "UpdateServiceProvider"
'[ 'Op ('Update @ServiceConfigState 'TypedTheOnlyInstance
'[ 'Preserve "serviceConfigName"
, 'SetTo "serviceConfigProvider" ('ParamValue "newServiceProvider")
]
'[])
]
'[ 'MustBeSignedByState @ServiceConfigState 'TypedTheOnlyInstance "serviceConfigProvider"
]
'[ '("newServiceProvider", PubKeyHash)
]This action follows a similar pattern to the previous one, but with the roles of the fields reversed. It uses 'Preserve' to lock in the serviceConfigName while allowing the serviceConfigProvider to be updated. The same signature constraint applies, meaning only the current owner can initiate a transfer to a new owner.
We have now defined four of our core actions, covering the initial lifecycle of our service: initialization, creation of new states, and secure updates. You can see a clear pattern emerging: we declare the intent of the action (create a state, preserve a field, require a signature) and Modsefa handles the implementation details of how to build that transaction and how to validate it on-chain.
In the next part, we'll look at more advanced actions that use batching, state aggregation, and arithmetic. Let me know if you have any feedback on this section!