🐡

Cadenceのかんどころ(1)トランザクションとリソース指向

2022/08/20に公開

はじめに

このシリーズはFlowブロックチェーンのスマートコントラクトを記述する言語であるCadenceを紹介する。Cadenceに入門するときは公式Hello Worldというような非常に役に立つサイトがあるのだが、そういったサイトは幅広い読者を対象にしているせいか、Ethereumなどでスマートコントラクトを開発してきた開発者にとっては、初心者向けの説明が冗長に感じてしまったりする。ついつい斜め読みしてしまい、気づいたら大切なところを読み飛ばしていて読み返すといったこともあるだろう。また、深く理解するための公式のドキュメントも役立つのだが、ボリュームがあるせいで読み始めるのにも気力がいる。

このシリーズはその両極の間になるように、簡単すぎないが難しすぎず、Cadenceの肝となる部分はしっかりまとめることを意識して書いた。要点だけをまとめたので、Cadenceのすべてが分かるというわけではないが、Cadenceの勘所は分かるように努力した。なのでCadenceが全く初めての方はこの記事を読む前に公式サイトに目を通すことをおすすめする。

  1. この記事
  2. ストレージ:スマートコントラクトの重要な機能である値の保存がCadenceでどう実装されるのかについて
  3. 参照型:Cadenceの参照型についてインタプリタの実装を見ながら調べてみる
  4. 制限型・ケイパビリティ:Cadenceの重要な概念であるケイパビリティとその理解に必要な制限型について

トランザクション・スクリプト

Flowのコントラクトの呼び出し方は、Ethereumと少し勝手が違う。Ethereumのトランザクションがスマートコントラクトを呼び出すときは、コールデータ(calldata)と呼ばれる言わば引数のようなものを渡し、呼び出されたコントラクトはその引数の値を解釈しそれに応じたロジックを走らせる。それに対してFlowのトランザクションがコントラクトを呼び出す時にはスクリプトが渡される。そのスクリプト内で、コントラクトの呼び出し方や呼び出し条件、事前事後の状態チェックをCadenceで記述するのだ。

やや雑な例えだが、bashなどのターミナルでUnixコマンドを一つ一つ叩くのがEthereumで、bashスクリプトにifやループなどの条件を含めてUnixコマンドをまとめて実行するのがFlowのイメージになる。

こういう方式にして得られる最初の利点は、複数のコントラクトを一つのトランザクションで好きな順番で同時実行できることだろう。1つ目の呼び出しの返し値を2つ目の呼び出しに使うといったことも1つのトランザクション内で完結できる。Ethereumで同じことをしようとすると、2回のトランザクションに分けて呼び出すか、プロキシとなるコントラクトを先に作ってそれを呼び出すといった間接的な方法しかない。

下に貼り付けたのは公式から引用してきた、Flowでカスタムトークンを送るトランザクションの例である。

// 1. 呼び出すトークンコントラクトをアドレスで指定
import ExampleToken from 0x01

// 2. トランザクションスクリプトのボディ
transaction {

  var temporaryVault: @ExampleToken.Vault

  // 3. 実行準備。アカウント認証。
  prepare(acct: AuthAccount) {
    let vaultRef = acct.borrow<&ExampleToken.Vault>(from: /storage/MainVault)
        ?? panic("Could not borrow a reference to the owner's vault")
      
    self.temporaryVault <- vaultRef.withdraw(amount: 10.0)
  }

  // 4. 実行部分
  execute {
    let recipient = getAccount(0x01)
    let receiverRef = recipient.getCapability(/public/MainReceiver)
                      .borrow<&ExampleToken.Vault{ExampleToken.Receiver}>()
                      ?? panic("Could not borrow a reference to the receiver")

    receiverRef.deposit(from: <-self.temporaryVault)

    log("Transfer succeeded!")
  }
}

スクリプトが何をやっているかはこの記事を最後まで読めば分かるようになる。今はとりあえずコメントで番号が振られた箇所の解説に留まる。

  1. スクリプトの最初にアクセスするコントラクトを指定する。Flowでは一つのアカウントに複数のコントラクトが結びつけられるので、コントラクト名も指定する。今回はExampleTokenというコントラクトをアカウントのアドレス0x01からimportする。(アカウントや鍵についてよく知らない場合はここを見ると良い)
  2. ここからトランザクションが始まる。スクリプト内にトランザクション・ブロックは一つしか書けない。
  3. 実行前の準備として、AuthAccount型のacctという引数が渡されたprepareブロックが実行される。後で詳しく説明するが、acctはEthereumで言うmsg.originのようなもので、言わばこのトランザクションに署名したアカウントである。このアカウントを使って、署名したアカウントにしかできない特権的なロジックをここに記述する。
  4. 実際の実行部分。prepareブロックで認証を済ませた値を使ってコントラクトを呼び出す。

ちなみにトランザクション内のブロックには、prepare. execute以外にも、preやpostといったものも定義できる。

transaction {
    prepare(signer: AuthAccount) {
        // ...
    }

    pre {
        // ...
    }

    execute {
        // ...
    }

    post {
        // ...
    }
}

preやpostはトランザクション実行前や実行後の条件などを指定するのに使われる。例えばトークンスワップするときに、約定価格に想定以上のスリッページが出た時にトランザクションをrevertさせる用途などがある。preとpostに記述できる命令は単体テストのassert文のように宣言的に書かれる。複雑なロジックを記述したいときは関数化する必要がある。

トランザクションがマルチシグアカウントによって発行された場合はprepareが取る引数は複数のAuthAccountになる。

// マルチシグ
prepare(signer1: AuthAccount, signer2: AuthAccount) 

この章では語れない詳細は公式のドキュメントを見てほしい。

リソース指向

公式のチュートリアルを読んでいくと、リソース指向という話が出てくる。これはCadenceで最も重要な概念だ。

Cadenceは資産(=リソース)を特別に扱う。例えば以下のようなSolidityコードの場合、プログラムが内部で使う数値 count と資産残高 amount のどちらも同じ変数という値の入れ物で平等に表現される。

uint count = 0;
uint amount = 1000;

if (count > 10) {
  balances[receiver] = amount;
}

値も資産価値も平等に変数という入れ物で表現することは、今までのあらゆるプログラミング言語でも行われてきた。しかし、Cadence ではスマートコントラクトを安全に記述するという目的のために、通常の値と、資産価値を表す値とを区別する。聞くよりも見る方が早いと思うので上のコードをCadenceで書いた以下のコードを見てほしい。

let count: Uint256 = 0
let amount <- create Vault(balance: 1000)

if (count > 10) {
  receiver.save(<- amount, to: /storage/Token)
}

見慣れない記号や命令がたくさんあるからと言って諦めてはいけない。まずcreateという命令が目に留まる。これはCadence上で資産(=リソース)として扱いたい値を初期化するときに使われる命令である。このように、Cadenceで扱われる値は、大きく分けて、普通の値(ストラクト型)もしくはリソースの値(リソース型)の二つに分類できる。

それではこれについて詳しく正確性を持って説明しよう。

まず、Cadenceではこの表のとおり、全ての変数の型の親は **Any 型**で表現され、オブジェクトと呼ばれる。その配下に AnyStruct型AnyResource型が位置する。プログラムで使われる普通の数値やオブジェクトは AnyStruct 型ストラクト型)の子クラスで表され、プログラム内で扱われえる資産と呼べる数値やオブジェクトは AnyResource型リソース型)の子クラスで表される。インタプリタ言語であるCadenceにはプリミティブ型のようなものはなく、整数 Int などの数値型も、文字列型である String といった型も AnyStruct 配下のストラクト型である。(Cadenceにはクラスという概念はないのだが、この記事では他言語だとクラス的だよねというものは便宜上クラスと呼んだりすることがあるのでご了承いただきたい。)

ストラクト型の値をリソースとして扱いたい場合は、下のコードのように、ユーザーが定義する(User-Defined)リソースオブジェクトのメンバとして内包させる。

// ユーザー定義ストラクト型
pub struct Player {
  // ...メンバ定義
}

// ユーザー定義リソース型
pub resource Vault {
  pub value: UInt64  // ストラクト型のメンバ
  pub player: Player // ストラクト型のメンバ
  // ... 
}

リソース型の制約

リソース型にすると何がうれしいのだろうか。リソースがコードの中で目立って見える?いや違う。

リソースの値をリソース型として静的型付けすることにより、世間一般的に資産と呼べる概念が持つべき一連の制約に従っているべきかを静的解析できるのだ。くだけた言い方をすると、資産は勝手に生成されたり、削除されたり、他の関係ない数値と混ざったりするとまずいので、リソース型として宣言された値がそういうことになっているとコンパイルが失敗するようにできるようにした(正確に言えばインタプリタ言語のCadenceにコンパイルやビルドという概念はない。ここではあくまで静的解析をフレンドリーな表現に言い改めているに過ぎないことをご了承いただきたい)。Cadenceのリソース型は以下に示す制約に従わなければならない。

リソース型の制約

  • リソースオブジェクトは、コピー出来ず、move演算子による移動のみできる
  • リソースオブジェクトは、saveされるかdestroyされないといけない。

リソース型の変数はコピーできず、移動(move)だけが許されている。つまり、 = という記号で別の変数に代入はできない。代わりに、 <- という記号(move演算子)でリソースの居場所を移すことだけができる。関数がリソース型の引数を取る場合も、上の save 関数のように <- 記号が使われる。これはリソースオブジェクトが意図しない形で複製されて、リソースオブジェクトの完全性が損なわれるのを防ぐためである。move演算子で移動したリソースは移動前の変数からはアクセスできなくなる。

2つ目の制約が言っているのは、リソース型の値は、トランザクションの実行コンテクスト内で、上の save 関数を使ってアカウントのストレージに保管されるか、destory 命令で消去されるかのどちらかが行われなければいけないということである。これが破られているとトランザクションが失敗する。

destroy amount // destory "amount" resource object

リソース型の制約はHaskellのLinear型拡張の考え方から影響を受けているらしい。詳しく知りたい方はこのような記事で深堀りしてみると良い。

リソース型で表す資産と呼ばれるものには、トークンやNFTといった資産性が自明のものから、権限といった抽象度の高いものまで、プログラマーがリソースと見なせばどんな値もリソース型で表現することができる。

ユーザー定義型の定義構文をもう少し詳しく見てみる。

// ストラクト型の定義
pub struct Player {
  pub var name: String
  pub var age: Int	

    // コンストラクタ
  pub init(name: String, age: Int) {
    self.name = name
    self.age = age
  }

	// メンバ変数
  pub fun happyBirthday() {
    self.age = self.age + 1
  }
}

// リソース型の定義
pub resource Vault {
  pub var count: Int

  pub init(count: Int) {
    self.count = count
  }

  pub fun increment() {
    self.count = self.count + 1
  }
}

ストラクト型は struct 、リソース型は resource キーワードで修飾される。どちらもコンストラクタはinitで定義され、メンバ変数の定義方法にも差はない。リソース型はデストラクタを定義できたりもする。公式のこの記事が詳しい。

ちなみにコンストラクタを呼び出す際には以下のような違いが出てくる。

let player = Player(name: "Bob", age: 1)
let vault <- create Vault(count: 1)

destroy vault

リソース型は create 命令で明示的にインスタンス化され、move演算子で変数vaultに移動する。

また、関数がリソース型の引数を取るときなど、リソース型の型名がコード内に出てくる際は @Vaultのように、@マークが前につく。 これはリソース型をそれと見分けが付きやすくするためだ。

pub fun helloAndCreate(): @Vault {
  log("hello")
  return <-create Vault(count: 1)
}

// @Vaultというふうに@が付くので、すぐにリソースと分かる
pub fun byeAndDestroy(asset: @Vault) {
  log("bye")
  destroy asset
}

pub fun main() {
  let vault <- helloAndCreate()
  byeAndDestroy(asset: <- vault)
}

また、リソース型のメンバ変数を持つオブジェクト型を定義するときは、それもリソース型でないといけない。別の言い方をすれば、ストラクト型のメンバにリソース型のメンバ変数は置けない。ストラクト型の中にリソース型が入ってしまうと、せっかくのリソース型の制約が無意味になってしまう。それと同じ理由で、リソース型の配列や辞書もまたリソース型として扱われる。

let v2 <- create Vault(count: 2)

// リソース型の配列もまたリソース型である
let arrayOfVaults: @[Vault] <- [<- create Vault(count: 0), <- create Vault(count: 1), <- v2]
destroy arrayOfVaults

制約違反の静的解析

Cadenceのソースコードはデプロイされる前に静的解析され、リソース型の値が上記の制約を守っているか精査される。リソースの扱い方が間違っている場合、そのプログラムはデプロイすらされないのだ。

コントラクトの実行中、つまり動的にもリソース型の制約チェックは働いている。トランザクションが呼び出したコントラクトを実行中に、リソース型の制約が破られるようなことがあればトランザクション自体が失敗する。

このような制約はリソースが予期せぬところで無くなったりコピーされることを防いでいるのだが、例えば、スマートコントラクトの世界で悪名高いReentrancy Attack(再入性攻撃)対策になっている。再入性攻撃は、Solidityの共通脆弱性タイプをまとめたSWCSWC107に登録されているスマートコントラクトに対する有名な攻撃手法だ。この攻撃を防ぐために、Solidityでコードを書く際にはCEIパターン(Check-Effect-Interactionパターン)に従うことが推奨されている。しかし、リソース型という概念を取り入れたCadenceではこのパターンに従わなくても良い。例えば以下のような関数を見てみよう。

fun pay(acct: AuthAccount) {
  let c <- acct.load<@Balance>(from: /storage/Balance) ?? panic("Balance not found")
	
  // Check
  if (c.balance < THRESHOLD) {
    panic("not enough balance")
  }

  // Effect
  c.decrease()

  // Interaction
  OtherContract.methodThatMayIncludeCallToPay(acct: acct)
  
  // Effect !?
  acct.save(<-c, to: /storage/Balance)
}

OtherContract.methodThatMayIncludeCallToPay(acct: acct) の中で、このpay関数が再帰呼び出しされると考える。Solidityでこの書き方をすると、再帰的に無限にpay関数が呼ばれてしまうおそれがある。しかしCadenceなら1回目の再侵入(つまりこの関数の2回目の呼び出し)でpanicを起こす。なぜなら2回目のloadの呼び出し時にはストレージ/storage/Balanceの中は空なのでloadが失敗しNilが返り、panic(”Balance not found”)が呼ばれて実行停止するからだ。

リソース型のオブジェクトはmove演算子(<-)によって常に一つの場所から別の場所に移動し複製されない。別の言い方をすれば、リソースオブジェクトは複数のコールスタックから同時に参照できないため、再入性攻撃によってリソースオブジェクトを再度扱うことはできないのだ。勝手に複製されると困るオブジェクト、つまり資産的に振舞ってほしい概念をリソース型で表現すれば、プログラマーが深く考えなくとも、ランタイムが自然に再入性攻撃を防いでくれるのである。

公式のチュートリアルはリソースの扱い方を網羅的に解説してくれているので詳しく知りたい方はそちらに目を通していただきたい。

次回

次回はアカウント・ストレージについての説明を行う。

Discussion