Mōdsefa.Donate

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 ServiceConfigState instance. The fields serviceConfigName and serviceConfigProvider are set using values from the action's parameters ('ParamValue').
    • It creates the first PricingTierState instance, similarly setting its fields from user-provided parameters.
  • 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 the bootstrapUtxo that 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 a ServiceConfigState if 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 new PricingTierState instance, with its data coming from the action's parameters.
  • Constraints: This action has a single but very important constraint: 'MustBeSignedByState @ServiceConfigState 'TypedTheOnlyInstance "serviceConfigProvider"'.
    • @ServiceConfigState 'TypedTheOnlyInstance tells the transaction builder to find the unique ServiceConfigState instance 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 the serviceConfigProvider field of that state's datum.
    • This is how we enforce ownership: only the key designated in the on-chain ServiceConfigState can 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 the ServiceConfigState.
    • '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 the serviceConfigProvider field 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 current serviceConfigProvider, 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!