Plutus Pioneer Program – Week 3 筆記

Plutus Pioneer Program – Lecture #3

Plutus 版本更新(版本連結

影片中指定了新的 commit 需要更新 plutus。

新版 plutus 做了一些更新:

  • 原本的 mkValidator 函數參數改變
    • 原本:datum, redeemer, ValidatorCtx
    • 更新:datum, redeemer, ScriptContext
data ScriptContext = ScriptContext{ 
  scriptContextTxInfo :: TxInfo, 
  scriptContextPurpose :: ScriptPurpose 
}
  • 建構 script address 呼叫的建構子改變,更新後不需要計算 valHash
valHashvalidator :: Validator 
validator = Scripts.validatorScript inst 

scrAddress :: Ledger.Address 
scrAddress = scriptAddress validator
  • 新增了手續費功能。但目前的手續費固定為 10 Lovelace,並不符合未來在區塊鏈上的真實情況。在未來,手續費會依記憶體用量,驗證者所耗費的執行時間來估算手續費。

介紹 Script context

Week 02 介紹中沒有提到 validation 中的第三個參數:合約內容(Script Context),只有提到前兩個參數 datum 還有 redeemer。

plutus-ledger-api/src/Plutus/V1/Ledger/Contexts.hs 可以看到

data ScriptContext = 
  ScriptContext{
    scriptContextTxInfo :: TxInfo, 
    scriptContextPurpose :: ScriptPurpose 
  }

ScriptContext 有兩個參數。

  • ScriptPurpose 用來描述 script 的用途。目前我們所構造的 script 都是「支付用途(Spending purpose)」,另外還有其他種用途:
    • Minting:用來在區塊鏈上鑄造自己的地方貨幣
    • Rewarding:用於質押(staking)獎勵
    • Certifying:用於質押證明(delegation certificates)
data ScriptPurpose
    = Minting CurrencySymbol
    | Spending TxOutRef
    | Rewarding StakingCredential
    | Certifying DCert
  • TxInfo 用來記錄合約相關的基本參數。
data TxInfo = TxInfo
    { txInfoInputs      :: [TxInInfo] -- ^ Transaction inputs
    , txInfoInputsFees  :: [TxInInfo]     -- ^ Transaction inputs designated to pay fees
    , txInfoOutputs     :: [TxOut] -- ^ Transaction outputs
    , txInfoFee         :: Value -- ^ The fee paid by this transaction.
    , txInfoForge       :: Value -- ^ The 'Value' forged by this transaction.
    , txInfoDCert       :: [DCert] -- ^ Digests of certificates included in this transaction
    , txInfoWdrl        :: [(StakingCredential, Integer)] -- ^ Withdrawals
    , txInfoValidRange  :: SlotRange -- ^ The valid range for the transaction.
    , txInfoSignatories :: [PubKeyHash] -- ^ Signatures provided with the transaction, attested that they all signed the tx
    , txInfoData        :: [(DatumHash, Datum)]
    , txInfoId          :: TxId
    -- ^ Hash of the pending transaction (excluding witnesses)
    } deriving (Generic)

時間區間的管理 Time Handling

相較於以太坊智能合約,Cardano 智能合約最大的優勢就是合約不需要透過嘗試寫到區塊鏈上來判斷是否成功。Cardano 在錢包(wallet)就可以完成驗證(validation),驗證失敗的 transaction 不需要支付額外的 fee。

而時戳(Timestamp)是在判斷合約中一個很重要的參數。就像前幾個例子,都有對 time slot 的操作。所以在 TxInfo 之中就會給出一個 valid 的時間區間,在任何 script 被執行之前,當前時間是否符合 script 中所指定的時間區間就會先被檢查。

data TxInfo = TxInfo
  {
     -- ...
     , txInfoValidRange  :: SlotRange -- ^ The valid range for the transaction.
     -- ...
  }

再更詳細 trace 下去。可以在同一個資料夾下找到 Slot.hs

-- | The slot number. This is a good proxy for time, since on the Cardano blockchain
-- slots pass at a constant rate.
newtype Slot = Slot { getSlot :: Integer }
    deriving stock (Haskell.Eq, Haskell.Ord, Show, Generic)
    deriving anyclass (FromJSON, FromJSONKey, ToJSON, ToJSONKey, NFData)
    deriving newtype (Haskell.Num, AdditiveSemigroup, AdditiveMonoid, AdditiveGroup, Enum, Eq, Ord, Real, Integral, Serialise, Hashable, PlutusTx.IsData)

SlotRange 是一個 Interval

-- | An 'Interval' of 'Slot's.
type SlotRange = Interval Slot

Interval.hs 中可以看到定義

-- | An interval of @a@s.
--
--   The interval may be either closed or open at either end, meaning
--   that the endpoints may or may not be included in the interval.
--
--   The interval can also be unbounded on either side.
data Interval a = Interval { ivFrom :: LowerBound a, ivTo :: UpperBound a }
    deriving stock (Haskell.Eq, Haskell.Ord, Show, Generic)
    deriving anyclass (FromJSON, ToJSON, Serialise, Hashable, NFData)

但是使用者不會需要更動資料結構的內部邏輯。想要建構時間區間的話只要呼叫建構子即可。(interval is inclusive)

{-# INLINABLE interval #-}
-- | @interval a b@ includes all values that are greater than or equal
--   to @a@ and smaller than @b@. Therefore it includes @a@ but not it
--   does not include @b@.
interval :: a -> a -> Interval a
interval s s' = Interval (lowerBound s) (upperBound s')

另外在下面還可以看到一些建構開區間的建構子:

{-# INLINABLE from #-}
-- | @from a@ is an 'Interval' that includes all values that are
--  greater than or equal to @a@.
from :: a -> Interval a
from s = Interval (lowerBound s) (UpperBound PosInf True)

{-# INLINABLE to #-}
-- | @to a@ is an 'Interval' that includes all values that are
--  smaller than @a@.
to :: a -> Interval a
to s = Interval (LowerBound NegInf True) (upperBound s)

{-# INLINABLE always #-}
-- | An 'Interval' that covers every slot.
always :: Interval a
always = Interval (LowerBound NegInf True) (UpperBound PosInf True)

{-# INLINABLE never #-}
-- | An 'Interval' that is empty.
never :: Interval a
never = Interval (LowerBound PosInf True) (UpperBound NegInf True)

並有其他相關函數可以判斷 intervals 之間或是 slot 對 interval 的關係。

{-# INLINABLE member #-}
-- | Check whether a value is in an interval.
member :: Ord a => a -> Interval a -> Bool
member a i = i `contains` singleton a

{-# INLINABLE overlaps #-}
-- | Check whether two intervals overlap, that is, whether there is a value that
--   is a member of both intervals.
overlaps :: Ord a => Interval a -> Interval a -> Bool
overlaps l r = isEmpty (l `intersection` r)

{-# INLINABLE intersection #-}
-- | 'intersection a b' is the largest interval that is contained in 'a' and in
--   'b', if it exists.
intersection :: Ord a => Interval a -> Interval a -> Interval a
intersection (Interval l1 h1) (Interval l2 h2) = Interval (max l1 l2) (min h1 h2)

{-# INLINABLE hull #-}
-- | 'hull a b' is the smallest interval containing 'a' and 'b'.
hull :: Ord a => Interval a -> Interval a -> Interval a
hull (Interval l1 h1) (Interval l2 h2) = Interval (min l1 l2) (max h1 h2)

{-# INLINABLE contains #-}
-- | @a `contains` b@ is true if the 'Interval' @b@ is entirely contained in
--   @a@. That is, @a `contains` b@ if for every entry @s@, if @member s b@ then
--   @member s a@.
contains :: Ord a => Interval a -> Interval a -> Bool
contains (Interval l1 h1) (Interval l2 h2) = l1 <= l2 && h2 <= h1

{-# INLINABLE isEmpty #-}
-- | Check if an 'Interval' is empty.
isEmpty :: Ord a => Interval a -> Bool
isEmpty (Interval (LowerBound v1 in1) (UpperBound v2 in2)) = case v1 `compare` v2 of
    LT -> False
    GT -> True
    EQ -> not (in1 && in2)

{-# INLINABLE before #-}
-- | Check if a value is earlier than the beginning of an 'Interval'.
before :: Ord a => a -> Interval a -> Bool
before h (Interval f _) = lowerBound h < f

{-# INLINABLE after #-}
-- | Check if a value is later than the end of a 'Interval'.
after :: Ord a => a -> Interval a -> Bool
after h (Interval _ t) = upperBound h > t

基本練習

Vesting.hs

這個例子在描述腳本中的第一,三個參數 DatumScriptContext 的操作。

使用者情境:你想幫自己小孩從小開始存 ada,你想要存到他 18 歲時「只有他」可以去動用這個 ada account。也就是到了某個時間點,被存到合約的 ada 會被釋放並且可以被使用。


定義自己的資料結構: VestingDatum

  • 受益人(beneficiary):到時候可以取得腳本(script)中的金錢的錢包
  • 到期期限(deadline):到了某個時刻腳本才被解鎖
data VestingDatum = VestingDatum
    { beneficiary :: PubKeyHash
    , deadline    :: Slot
    } deriving Show

這裡逐漸看到 Haskell 的好處,函數式程式語言的邏輯真的是一目了然呢!

影片中的 coding style 挺良好的,使得邏輯具有相當的可讀性。Script 會檢查兩個條件:

  • signature 檢查參與者是否為 datum 中紀錄的 beneficiary 本人
  • deadline 是否已經到達

這裡取用了 ScriptContext 資料結構底下的 TxInfo

{-# INLINABLE mkValidator #-}
mkValidator :: VestingDatum -> () -> ScriptContext -> Bool
mkValidator dat () ctx =
    traceIfFalse "beneficiary's signature missing" checkSig      &&
    traceIfFalse "deadline not reached"            checkDeadline
  where
    info :: TxInfo
    info = scriptContextTxInfo ctx

    checkSig :: Bool
    checkSig = beneficiary dat `elem` txInfoSignatories info

    checkDeadline :: Bool
    checkDeadline = from (deadline dat) `contains` txInfoValidRange inf

檢查參與者是否為 datum 中紀錄的 beneficiary 本人

在 TxInfo 中有儲存一個 list of signature 在 txInfoSignatories 中。

elem 是用來檢查提供的元素是否在 list 之中。(http://zvon.org/other/haskell/Outputprelude/elem_f.html

Eq a => a -> [a] -> Bool

參與者( txInfoSignatorie )中如果有 beneficiary 的話,就 return True。

checkSig = beneficiary dat `elem` txInfoSignatories info

deadline 是否已經到達

這個就很簡單,呼叫 Interval 相關的函數即可。


再來看到實際動作(give, grab)那裡。

  • 動作 give 讓 tx 持有 datum 以及 Ada.lovelaceValueOf $ gpAmount gp 大小的 Ada tx = mustPayToTheScript dat $ Ada.lovelaceValueOf $ gpAmount gp
  • 動作 grab 可以看到 isSuitable 函數中吃進去三個參數:PubKeyHash(呼叫 grab 動作的 wallet 的 public key), Slot(時間),需要進行檢視的 transaction output。
    • 第一個 if 檢查是否存在 datum
    • 第二個 if 檢查存在的 datum 是否可以被 serialize 成想要的結構(VestingDatum)。(在最剛開始已經用 Haskell template programming 來生成 fromData 的建構子了)
    • 第三個 if 取用 Datum 中的參數 beneficiarydeadline 來做邏輯判斷
    isSuitable :: PubKeyHash -> Slot -> TxOutTx -> Bool isSuitable pkh now o = case txOutDatumHash $ txOutTxOut o of Nothing -> False Just h -> case Map.lookup h $ txData $ txOutTxTx o of Nothing -> False Just (Datum e) -> case PlutusTx.fromData e of Nothing -> False Just d -> beneficiary d == pkh && deadline d <= now

Parameterized.hs

這裡讓 mkValidator 比原本多拿一個參數(原本僅提供純資料:datum, redeemer, script),讓最開頭多一個 Parameter 結構來存屬於 mkValidator 這個動作的參數。

可以想像成是你會想要有同樣的資料被傳入,但你可以再額外指定對這些資料要做什麼動作。讓你的 mkValidator 函數可以在 runtime 做不一樣的互動。

首先修改 mkValidator 定義:

{-# INLINABLE mkValidator #-}
mkValidator :: VestingParam -> () -> () -> ScriptContext -> Bool
mkValidator p () () ctx = 
  -- ...

這時在宣告 script instance 時,變得也需要把參數傳來構造 script 。

inst :: VestingParam -> Scripts.ScriptInstance Vesting
inst p = Scripts.validator @Vesting
    (
      $$(PlutusTx.compile [|| mkValidator ||]) 
      `PlutusTx.applyCode` PlutusTx.liftCode p
    )
    $$(PlutusTx.compile [|| wrap ||])
  where
    wrap = Scripts.wrapValidator @() @()

關於 “Lift” 這個動作,是讓程式可以在 runtime 把 Haskell value 提升為 Plutus script value,因為並不是所有 Haskell 資料值都可以在 Plutus 底下被使用,所以需要額外這個動作來讓資料可以被 Plutus 使用。( plutus-tx/src/PlutusTx/Lift/Class.hs

-- | Class for types which can be lifted into Plutus IR. 
-- | Instances should be derived, do not write your
-- | own instance!
class Lift uni a where
    -- | Get a Plutus IR term corresponding to the given value.
    lift :: a -> RTCompile uni fun (Term TyName Name uni fun ())

PlutusTx.liftCode 這個動作以 Lift 為基礎( plutus-tx/src/PlutusTx/Lift.hs ),會是我們主要呼叫,把 Haskell value 提升為 Plutus script value 的轉換函數。

-- | Get a Plutus Core program corresponding to the given value 
-- | as a 'CompiledCodeIn', throwing any errors that occur as 
-- | exceptions and ignoring fresh names.
liftCode
    :: (Lift.Lift uni a, Throwable uni fun, PLC.ToBuiltinMeaning uni fun)
    => a -> CompiledCodeIn uni fun a
liftCode x = unsafely $ safeLiftCode x

現在回去看到剛剛宣告的 script instance 中的動作。( Parameterized.hs 第 65 行)用 liftCode 來提昇參數為 Plutus script value 後,使用 applyCode 來把資料餵給 template 產生的 mkValidator 函數。

(
  $$(PlutusTx.compile [|| mkValidator ||]) 
  `PlutusTx.applyCode` PlutusTx.liftCode p
)

另外對自定義的資料結構,有 PlutusTx.makeLift 來做像是之前 PlutusTx.unstableMakeIsData 的功能,使用 template 方式幫新增的結構做建構子。( Parameterized.hs 第 41 行)

PlutusTx.makeLift ''VestingParam

而原本 validator 單純是讓 Scripts.validatorScript 宣告一個資料 inst

validator :: Validator
validator = Scripts.validatorScript inst

現在因為 inst 需要參數,所以需要 . operator。 . operator 是 function composition 的 operator。 視 Scripts.validatorScript 還有 inst 為兩個函數,並把他們 compose 起來。 ( Parameterized.hs 70 ~ 74 行)

validator :: VestingParam -> Validator
validator = Scripts.validatorScript . inst

scrAddress :: VestingParam -> Ledger.Address
scrAddress = scriptAddress . validator

可以在下面 give, grab 動作中,

  • give 傳入 p 來創造 script instance (line 94)
  • grab
    • p 傳給 utxoAt 來找到屬於該 deadline 的 UTXO script (line 112)
    • p 傳到 validator 中來判斷是否符合 checkSig 還有 checkDeadline (line 118)

把合約腳本參數化的好處,一方面可以在 runtime 接收不同動作,另一方面 validation 本身就不用另外構造函數做判斷,而是在建立合約時就檢查是否可以成功執行。(correct by construction)

Homework 1

練習判斷邏輯的部分,對 datum 做相應的操作。

Homework 2

練習 parameterized script,並實作 Vesting.hs 中的邏輯。

(文章歡迎轉載,請註明出處為 eopxd.com)

Author: eopXD

Hi 我是 eop ,希望人生過得有趣有挑戰XD

Leave a Reply