洛伦茨:类型安全的可升级存储

71 阅读10分钟

Lorentz:类型安全的可升级存储空间

一旦部署,大规模的合同有时会变得过时,需要升级。 这方面的用例不仅包括增加新的功能,还包括修复错误,不幸的是,即使在完善的开发过程中也容易出现错误。

Michelson还没有提供改变已经部署的合同的内置功能,因此必须从合同代码中明确支持可升级性。

在这篇文章中,我们将向你展示如何在罗辑思维中制作一个可升级存储的智能合约。 在本系列的下一篇文章中,我们也将介绍代码升级。

但首先,让我们解决Tezos的可升级性应该是怎样的。

就地升级的能力

我们所说的可升级合约,是指具有以下属性的合约:

  1. 它的存储格式可以被改变:字段可以被添加或删除。
  2. 它的代码可以被修改。

读者可能会发现我们对可升级技术的分析很有趣,因为该文件广泛涵盖了Tezos中合同升级的主题。 此外,我们将只是简单地提到与我们的主题有关的要点。

如何在Tezos上创建一个可升级的合约

在Tezos的情况下,有两种主要的方法来实现可升级性,适合于Tezos:

  1. 在升级时,从头开始创建合约并迁移其存储。如果需要地址不变性(通常是这样),使用一个单独的代理合约,将所有调用委托给主合约的相关实例。
  2. bytes 将存储条目和入口的代码以打包的形式保存在big_map ,并在必要时更新它们。

虽然代理方法乍看之下似乎更简单,但它有一堆问题:

  • 迁移大型存储可能在费用和闲置时间方面非常昂贵。
  • 当涉及到代理合同时,基于SENDER 指令的授权技术不起作用。
  • 如果不重新启动代理,更新合同接口仍然是不可能的。

这就是为什么目前最好的方法是使用可就地升级的合同,尽管费用增加了。 然而,这也给合同开发者带来了一个严重的问题。

当整个存储和代码被保存在一个big_map ,处理它们就变得不那么方便了:Michelson的内置类型系统不能再保护我们避免某些类型的错误。 我们需要在语言引擎中直接对我们的新类型合约进行适当的支持。

在这篇文章中,我们将专注于通过使用洛伦兹的big_map 方法使合约存储可升级。

可升级的存储

Lorentz语言的一个优势特点是,一旦认识到对这些基元的需求,就可以快速实现。这个过程可以独立于语言核心的开发而进行。

为了演示可升级功能,我们将开始写一个简单的账本合同的草图。

存储表示

首先,让我们弄清楚我们的可升级存储中的条目必须由框架来表示。

显然,所有条目都必须出现在一个单一的big_map ,其类型在合约的所有版本中必须是相同的。由于这个映射可能需要包含各种类型的条目,我们必须假设这个映射的键和值是bytes ,并且可能包含一些打包的东西(其中 "打包 "具有对应于PACK 指令的语义)。

下一个观察:任何合约的存储只有两种条目:严格字段和懒惰地图:

  • 严格的字段是常规的PACK-able类型,如nat,string,list,set,map

    在 "严格 "下,我们在此仅指访问该条目需要反序列化整个条目:由于底层big_map 的性质,反序列化会在请求该字段时发生。在由product/sum类型代表的简单合约存储中,行为略有不同,在那里所有非big_map 的字段在合约代码运行前就被反序列化了。

  • Lazy map与big_map 非常相似,它按需反序列化map中的一个值。

    缺点是懒惰地图的条目不能被迭代(像itersize 这样的指令在这里不适用)。

为了表示一个懒惰地图的条目,我们可以把它的键和值打包并放入big_map 。然而,这样一来,不同的子地图可能会发生碰撞。所以我们把打包的(Pair submapName key) 作为键。

在字段的情况下,我们简单地使用打包的字段名作为键。

这是否意味着我应该选择更短的字段名?

由于字段名现在作为合同代码的一部分出现,一个合理的问题可能会出现:我应该声明更短的字段名作为一种优化措施吗? 这样做不会损害合同代码的可读性吗?

我们建议调整其编译选项,而不是以这种方式改变洛伦兹代码。

其中两个预定义的选项分别允许修改洛伦兹契约内的所有字符串字节。它们可以应用于洛伦兹Contract ,如下所示:

import Control.Lens ((&~), (.=))

optimizedContract :: Contract Parameter Storage
optimizedContract = myContract &~ do
  cCompilationOptionsL . coStringTransformerL .=
    ( True  -- visit lambdas
    , stringsTransform
    )

stringsTransform :: MText -> MText
stringsTransform = \case
  [mt|ledger|] -> [mt|l|]
  [mt|totalSupply|] -> [mt|ts|]
  other -> other

洛伦兹接口

很明显,对于一个应该是严格类型化的语言来说,手动处理所提到的存储表示法太麻烦了,而且不安全。这一点必须由框架来解决。

按照承诺,我们将进一步尝试编写一个概念验证合同--一个简单的可升级分类账。 这个合同必须存储每个地址的余额和合同内持有的代币总量。

为了定义具有上述结构的可升级存储,在洛伦兹中我们写道:

import Lorentz
import Lorentz.UStore  -- from `morley-upgradeable` package

data StoreTemplate = StoreTemplate
  { ledger :: Address |~> Natural       -- lazy submap
  , totalSupply :: UStoreField Natural  -- field
  } deriving stock (Generic)

type Storage = UStore StoreTemplate

这里的UStore 类型代表可升级存储。它有一个类型参数,我们称之为模板,它定义了存储的理想结构。

处理这种存储的方法与现有的非常相似。 如果我们需要处理子地图,我们可以使用ustoreMem,ustoreGetustoreUpdate 指令,这些指令模仿了处理普通地图的相应的 Michelson 指令。另外,我们还提供了额外的ustoreDelete,ustoreInsertustoreInsertNew 宏来覆盖最常见的使用情况:

creditTo :: Address : Natural : Storage : s :-> Storage : s
creditTo = do
  dupN @3; dupN @2
  ustoreGet #ledger; fromOption 0

  stackType @(Natural : Address : Natural : Storage : _)
  swap; dip add
  ustoreInsert #ledger

为了访问字段,我们可以使用ustoreGetFieldustoreSetField ,这与Lorentz为普通数据类型提供的getFieldsetField 类似:

creditTo :: forall s. Address : Natural : Storage : s :-> Storage : s
creditTo = do
  dip $ do
    dup @Natural; dip $ do
      dip $ ustoreGetField #totalSupply
      add
      ustoreSetField #totalSupply

  stackType @(Address : Natural : Storage : s)
  ... -- code that updates the 'ledger' map

所有使用UStore 的方法都可以在Hackage的文档中找到。

存储模板确保所有字段和懒人地图都被正确使用。

例如,现在的类型系统确保每个字段在整个合同代码中使用相同的类型:

f :: Storage : s :-> Storage : s
f = do push @Integer (-1); ustoreSetField #totalSupply

---

(src:5:28) error:
    • Couldn't match type ‘Natural’ with ‘Integer’

编译器甚至可以在某些时候给出所需类型的提示:

f :: Storage : s :-> Storage : s
f = do push _; ustoreSetField #totalSupply

---

(src:5:13) error:
    • Found hole: _ :: Natural
    • In the first argument of ‘push’, namely ‘_’

一个字段名中的错字现在将在编译时被处理。

而且读者可以注意到,这个接口不会让用户意外地从big_map 。字段被保证在那里,而且不需要每次访问一个字段时都手动写类似GET; ASSERT_SOME

这里有人会问:那存储初始化呢? 我可以在部署合同时忘记初始化一些字段吗?

在Haskell的世界里,我们可以优雅地处理这个问题:UStore StoreTemplate 可以从StoreTemplate 的值中构造出来,初始化StoreTemplate 是一个完全类型安全的动作:

initStorage :: Storage
initStorage = mkUStore StoreTemplate
  { ledger = UStoreSubMap mempty
  , totalSupply = UStoreField 0
  }

这个值以后可以在我们的测试框架中传递,或者以各种格式打印出来,以便用不同的工具进一步发起合同:

initStorageAsMichelson = printLorentzValue True initStorage

-- >>> putStrLn initStorageAsMichelson
-- { Elt 0x05010000000b746f74616c537570706c79 0x050000 }
-- ↓ from 'aeson-pretty' package
import qualified Data.Aeson.Encode.Pretty as Json
import Morley.Micheline (toExpression)

initStorageAsMicheline = Json.encode $ toExpression $ toVal initStorage

{- >>> putTextLn (decodeUtf8 initStorageAsMicheline)
[
    {
        "args": [
            {
                "bytes": "05010000000b746f74616c537570706c79"
            },
            {
                "bytes": "050000"
            }
        ],
        "prim": "Elt"
    }
]
-}

正如人们可以公平地注意到的那样,地图中的这种键和值的二进制表示法,手动操作起来不是很方便。 然而,大多数时候,它对最终用户是隐藏的,甚至现在的区块链探索者也可以检测和解释打包数据。

为了构建依赖于用户输入的存储,我们通常会编写一个小型的专用命令行工具。 其他框架(例如中间件使用的框架)也可以尝试构建这样的存储,但要方便地做到这一点,需要编写一个标准和实现它的库。

多态性

现在,如果我正在编写一个为智能合约定义有用的通用基元的库,这是否意味着处理存储的方法必须被实现两次--为普通类型和UStore

不一定。

我们提供了以多态的方式处理存储空间的方法。stGetField stSetField stInsert这些方法的完整列表可以在Hackage上的Lorentz文档中找到。

因此,例如,上面定义的creditTo 的代码可以改写为:

-- | Constraint on storage used in our ledger.
type StorageC store =
  ( StoreHasSubmap store "ledger" Address Natural
  , StoreHasField store "totalSupply" Natural
  )
-- (A)

creditTo
  :: StorageC store  -- (A)
  => Address : Natural : store : s :-> store : s
creditTo = do
  -- Update total supply
  dip $ do
    dup @Natural; dip $ do
      dip $ stGetField #totalSupply  -- (B)
      add
      stSetField #totalSupply  -- (B)

  -- Update ledger
  dupN @3; dupN @2
  stGet #ledger; fromOption 0  -- (B)
  swap; dip add
  stInsert #ledger  -- (B)

被替换的ustore* 方法的调用被标记为(B)

当作为store ,我们传递我们的Storage = UStore template ,这个实现就会起作用。只要有相同的ledger 子图和totalSupply 字段,template 类型所使用的确切布局就不再重要了。这可以通过添加一个StorageC 约束来实现(见(A) )。

一个普通的产品类型也可以被用作传递给我们方法的存储。 我们只需要提供一个StoreHasField 实例,指定如何访问所需的字段即可:

data Storage = Storage
  { ledger :: BigMap Address Natural
  , totalSupply :: Natural
  } deriving stock (Generic)
    deriving anyclass (IsoValue)

instance HasFieldOfType Storage name field =>
         StoreHasField Storage name field where
  storeFieldOps = storeFieldOpsADT

-- ↑ Note that in older versions of `lorentz` package some different
-- instances may be necessary; just follow the error messages.

为什么是一些新的实例?

读者可能会问,为什么这个新的StoreHasField ,必须由用户来管理,而不是隐式派生。

这种StoreHasFieldStoreHasSubmap 实例的机制是一个非常强大的工具,因为它允许在复杂的情况下定位字段和子图,即使必要的条目没有以必要的形式实际存在于存储中。

一个真实的故事:我们的一个FA1.2合同,在Michelson只允许一个big_map 的时候实现的,必须把余额和审批放在一张地图上,作为BigMap Address (Natural, Map Address, Natural) 。当big_map 的限制被取消后,我们简化了FA1.2基础库中的方法,所以现在他们需要两个独立的BigMap Address NaturalBigMap (Address, Address) Natural

尽管如此,在有我们合同的存储库中,我们设法在完全不改变存储格式的情况下切换到新版本的库(从而避免了迁移已经部署的合同的需要),只需添加适当的StoreHasSubmap 实例。

这证明了将合约逻辑分成几个抽象层是多么方便,在我们的案例中--一个专门的业务逻辑层和一个完全独立的棘手的地图元素访问层。 这样的机会在缺乏多态性的智能合约语言中是很难实现的,在这种语言中,开发者不得不在每次类型改变时诉诸代码重复。


我们使用多态存储访问的一个库合同是FA1.2 ManagedLedger。 我们用它来实现各种必须包括FA1.2功能的终端产品合同¹。


¹ 请记住,在Michelson中,将代码分割成多个合约是相当昂贵的。这一点在本篇文章的 "小心合约间的通信 "部分有解释。

可组合性

有一种合理的趋势是将合约分割成小的可重用组件。 例如,我们的生产分类帐合约可能由FA2核心+管理+暂停组件组成。 在这方面,让每个组件描述自己的存储部分是可取的。

我们的可升级存储并不限制开发者应用上述做法,因为存储模板允许嵌套条目:

---- All.hs module
---------------------------------

-- All the components consolidated

data StoreTemplate = StoreTemplate
  { ledgerStore :: LedgerStoreTemplate
  , adminStore  :: AdminStoreTemplate
  , pausedStore :: PauseStoreTemplate
  } deriving stock (Generic)

initStore :: Address -> StoreTemplate
initStore adminAddr = StoreTemplate
  { ledgerStore = initLedgerStore
  , adminStore  = initAdminStore adminAddr
  , pausedStore = initPauseStore
  }

type Storage = UStore StoreTemplate

---- Ledger.hs module
---------------------------------

data LedgerStoreTemplate = LedgerStoreTemplate
  { ledger :: Address |~> Natural
  , totalSupply :: UStoreField Natural
  } deriving stock (Generic)

initLedgerStore :: LedgerStoreTemplate
initLedgerStore = LedgerStoreTemplate
  { ledger = UStoreSubMap mempty
  , totalSupply = UStoreField 0
  }

---- Admininstation.hs module
---------------------------------

data AdminStoreTemplate = AdminStoreTemplate
  { admin :: UStoreField Address
  , pendingNextAdmin :: UStoreField Address
    -- ↑ for two-phase ownership transfer
  } deriving stock (Generic)

initAdminStore :: Address -> AdminStoreTemplate
initAdminStore adminAddr = AdminStoreTemplate
  { admin = adminAddr
  , pendingNextAdmin = adminAddr
  }

---- Pausable.hs module
---------------------------------

data PauseStoreTemplate = PauseStoreTemplate
  { paused :: Bool
  } deriving stock (Generic)

initPauseStore :: PauseStoreTemplate
initPauseStore = PauseStoreTemplate False

All.hs 接下来, , 和 模块定义了每个子组件的存储(在现实生活中,这些模块会被放在不同的文件中)。Ledger.hs Administration.hs Pausable.hs

All.hs 部分,我们将所有子组件的存储空间粘合在一起,并提供初始存储值。注意,这个模块不需要知道任何关于子组件存储空间的内部表示。

从合同代码的角度来看,这个存储仍然是一个平面结构。这意味着我们的多态creditTo 方法将在新的复杂存储上工作,而不需要做任何改变。

结论

在这篇文章中,我们考虑了Lorentz对可就地升级合约的存储方式。 虽然在Michelson中以类型安全的方式实现这样的功能是不可能的,如果不在语言核心中加入专门的功能,在Lorentz层面上解决这个问题并不构成问题。

在接下来的文章中,我们将接触到可升级的入口和故事中最有趣的部分:类型安全的合约迁移。

虽然这篇文章在系列文章中出现得比较晚,但在洛伦兹语言诞生后,我们几乎立即遇到了编写生产规模的可升级合约的需求。因此,UStore ,这个功能几乎和对积和类型的支持一样古老,现在应该已经相当稳定。

目前,我们正在开发一个可升级合约的通用接口,它将作为tzip-18包含在Tezos开发建议库中。