Advanced Actions and Concepts
Tutorial Part 5: Advanced Actions and Concepts
Now that we've covered the basic lifecycle of our service, let's explore the more advanced actions in examples/Modsefa/Examples/Subscription/Spec.hs. These specifications demonstrate how Modsefa handles batch operations, inter-state dependencies, payments, and state consumption.
Action 5: BatchCreateCouponsSpec
Creating coupons one by one would be inefficient. This action uses the 'Map' operation to create an entire batch of CouponState instances in a single transaction.
type BatchCreateCouponsSpec =
'ActionSpec @SubscriptionApp "BatchCreateCoupons"
'[ 'Map
('Create @CouponState
'[ 'SetTo "couponId" ('ParamValue "couponId")
, 'SetTo "couponBatchId" ('ParamValue "batchIdUtxo")
, 'SetTo "couponDiscountPercent" ('ParamValue "couponDiscountPercent")
]
'[])
"newCoupons"
'[ 'MustHaveUniqueField "couponId" ]
]
'[ 'MustBeSignedByState @ServiceConfigState 'TypedTheOnlyInstance "serviceConfigProvider"
, 'MustSpendActionParam "batchIdUtxo"
]
'[ '("newCoupons", [Coupon])
, '("batchIdUtxo", TxOutRef)
]Operations: The'Map'operation is the key feature here.- It takes a
'Create @CouponState'operation as its template. - It iterates over the
"newCoupons"parameter (which is a list ofCouponrecords). For eachCouponin the list, it runs theCreateoperation. - The
'ParamValue's inside the'Map'(like"couponId") automatically refer to the fields of theCouponrecord from the list, not the top-level action parameters. - It also applies a
'MustHaveUniqueField "couponId"'constraint, ensuring that no two coupons within the same batch have the same ID.
- It takes a
Constraints:'MustSpendActionParam "batchIdUtxo"ensures that a specific UTxO provided by the user is spent. This UTxO's reference is then stored in each created coupon, effectively grouping them into a batch that can be managed together later.
Action 6: BatchDeleteCouponsSpec
Complementing the batch creation is an action to delete a group of coupons. This also uses the 'Map' operation, but this time with a 'Delete' operation inside.
type BatchDeleteCouponsSpec =
'ActionSpec @SubscriptionApp "BatchDeleteCoupons"
'[ 'Map
('Delete @CouponState
('TypedUniqueWhere
('And ('FieldEquals "couponId" ('ParamValue "couponId"))
('FieldEquals "couponBatchId" ('ParamValue "couponBatchId"))
)
)
'[])
"couponsToDelete"
'[]
]
'[ 'MustBeSignedByState @ServiceConfigState 'TypedTheOnlyInstance "serviceConfigProvider"
]
'[ '("couponsToDelete", [Coupon])
]Operations: The 'Map' operation iterates over the "couponsToDelete" list. For each Coupon record provided, it performs a 'Delete' operation. The TypedUniqueWhere predicate ensures that it finds and deletes the specific on-chain coupon instance that matches both the couponId and couponBatchId from the list item.
Action 7: SubscribeSpec
This action is a significant step forward because it's our first example where the creation of one state depends on the data within another. While previous actions referenced the ServiceConfigState for signature validation, this is the first time we are using a referenced state's datum to dynamically construct a new state.
type SubscribeSpec =
'ActionSpec @SubscriptionApp "Subscribe"
'[ 'Let "selectedTier"
('Reference @PricingTierState
('TypedUniqueWhere ('FieldEquals "pricingTierName" ('ParamValue "tierName")))
'[]
)
, 'Op ('Create @CustomerSubscriptionState
'[ 'SetTo "customerSubscriptionPkh" ('ParamValue "customerPkh")
, 'SetTo "customerSubscriptionPrice"
('StateFieldValue "selectedTier" "pricingTierPrice")
, 'SetTo "customerSubscriptionAssetClass"
('StateFieldValue "selectedTier" "pricingTierAssetClass")
, 'SetTo "customerSubscriptionBillingPeriod"
('StateFieldValue "selectedTier" "pricingTierBillingPeriod")
, 'SetTo "customerSubscriptionPaidThrough"
('AddValue
'CurrentTime
('StateFieldValue "selectedTier" "pricingTierBillingPeriod")
)
, 'SetTo "customerSubscriptionContractEndDate"
('AddValue
'CurrentTime
('StateFieldValue "selectedTier" "pricingTierContractLength")
)
, 'SetTo "customerSubscriptionServiceProviderValidator"
('ParamValue "derivedServiceProviderValidatorHash")
]
'[]
)
]
'[ 'MustBeSignedByParam "customerPkh"
, 'MustAddToAggregateState TreasuryAdaState
('StateFieldValue "selectedTier" "pricingTierPrice")
]
'[ '("customerPkh", PubKeyHash)
, '("tierName", BuiltinByteString)
]Operations: This action introduces the'Let'operation.'Let "selectedTier" ('Reference ...): This finds the uniquePricingTierStateinstance whose name matches the user's input and gives it the label"selectedTier". The'Reference'operation makes the state's datum available without spending it.'Op ('Create @CustomerSubscriptionState ...): When creating the new subscription, it uses'StateFieldValue "selectedTier" "pricingTierPrice"to read the price directly from the datum of the referenced pricing tier. This ensures the customer always pays the correct, on-chain price.
Constraints: This introduces our first payment constraint.'MustAddToAggregateState TreasuryAdaState ...: This constraint directs the transaction builder to add an output to theTreasuryValidator. The amount of Lovelace in this output is determined by'StateFieldValue "selectedTier" "pricingTierPrice", ensuring the payment collected matches the price from the on-chain pricing tier.
Action 8: SubscribeWithCouponSpec
This final subscription action combines everything we've learned: referencing multiple states, performing on-the-fly arithmetic, and consuming a state instance.
type SubscribeWithCouponSpec =
'ActionSpec @SubscriptionApp "SubscribeWithCoupon"
'[ 'Let "selectedTier"
('Reference @PricingTierState
('TypedUniqueWhere ('FieldEquals "pricingTierName" ('ParamValue "tierName")))
'[])
, 'Let "selectedCoupon"
('Reference @CouponState
('TypedUniqueWhere
('And ('FieldEquals "couponId" ('ParamValue "couponCode"))
('FieldEquals "couponBatchId" ('ParamValue "couponBatch"))
)
)
'[])
, 'Op ('Create @CustomerSubscriptionState
'[ 'SetTo "customerSubscriptionPkh" ('ParamValue "customerPkh")
, 'SetTo "customerSubscriptionPrice"
('SubtractValue
('StateFieldValue "selectedTier" "pricingTierPrice")
('DivideValue
('MultiplyValue
('StateFieldValue "selectedTier" "pricingTierPrice")
('StateFieldValue "selectedCoupon" "couponDiscountPercent")
)
('IntValue 100)
)
)
, 'SetTo "customerSubscriptionAssetClass"
('StateFieldValue "selectedTier" "pricingTierAssetClass")
, 'SetTo "customerSubscriptionBillingPeriod"
('StateFieldValue "selectedTier" "pricingTierBillingPeriod")
, 'SetTo "customerSubscriptionPaidThrough"
('AddValue
'CurrentTime
('StateFieldValue "selectedTier" "pricingTierBillingPeriod")
)
, 'SetTo "customerSubscriptionContractEndDate"
('AddValue
'CurrentTime
('StateFieldValue "selectedTier" "pricingTierContractLength")
)
, 'SetTo "customerSubscriptionServiceProviderValidator"
('ParamValue "derivedServiceProviderValidatorHash")
]
'[])
, 'Op ('Delete @CouponState ('TypedByLabel "selectedCoupon") '[])
]
'[ 'MustBeSignedByParam "customerPkh"
, 'MustAddToAggregateState TreasuryAdaState
('SubtractValue
('StateFieldValue "selectedTier" "pricingTierPrice")
('DivideValue
('MultiplyValue
('StateFieldValue "selectedTier" "pricingTierPrice")
('StateFieldValue "selectedCoupon" "couponDiscountPercent")
)
('IntValue 100)
)
)
]
'[ '("customerPkh", PubKeyHash)
, '("tierName", BuiltinByteString)
, '("couponCode", Integer)
, '("couponBatch", TxOutRef)
]Operations: This is our most complex sequence yet.- It uses two
'Let'operations to reference both thePricingTierStateand theCouponStatethe user wants to use. - It uses the arithmetic operations (
'SubtractValue','MultiplyValue','DivideValue') to calculate the discounted price in real-time based on the on-chain data from the referenced tier and coupon. - Crucially, it includes
'Op ('Delete @CouponState ('TypedByLabel "selectedCoupon") '[]). This consumes the coupon's state instance, ensuring it can only be used once. The'TypedByLabel'guarantees that the exact coupon we referenced is the one that gets deleted.
- It uses two
Constraints: The'MustAddToAggregateState'constraint for this action also uses the same arithmetic to ensure the correct discounted amount is sent to the treasury.
Action 9: WithdrawTreasurySpec
This action allows the service provider to securely withdraw funds from the treasury.
type WithdrawTreasurySpec =
'ActionSpec @SubscriptionApp "WithdrawTreasury"
'[]
'[ 'MustBeSignedByState @ServiceConfigState 'TypedTheOnlyInstance "serviceConfigProvider"
, 'MustWithdrawFromAggregateState TreasuryAdaState
('ParamValue "amount")
('ParamValue "destination")
]
'[ '("amount", Integer)
, '("destination", Address)
]Constraints: The logic is handled entirely by constraints.
'MustBeSignedByState ...ensures only the authorized service provider can perform a withdrawal.'MustWithdrawFromAggregateState TreasuryAdaState ...is the counterpart to the deposit constraint. It instructs the transaction builder to:- Find UTxOs at the
TreasuryValidatoraddress that sum to at least the withdrawalamount. - Spend those UTxOs.
- Create a new output to the
destinationaddress containing theamount. - Send any remaining change back to the
TreasuryValidatoraddress.
- Find UTxOs at the
We have now defined the complete set of user actions for our application. We've seen how to handle everything from simple state creation to complex, multi-state interactions with on-chain calculations.
In the final part of this tutorial, we will look at how to generate the validator code and use the client library to build transactions and query our application's state.