はじめに
『DApps開発入門』という本や色々記事を書いているかるでねです。
今回は、Ethereumノードからのデータ取得をより柔軟かつ効率的にするために、従来のJSON-RPCに代わる標準的なGraphQLインターフェースを導入する仕組みを提案しているERC1767についてまとめていきます!
以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。
他にも様々なEIPについてまとめています。
概要
ERC1767は、Ethereumノードに保存されているデータへアクセスするための新しい方法として、GraphQLスキーマを標準化することを目的としています。
従来のJSON-RPCインターフェースは読み取り専用の情報提供手段として広く使われていますが、柔軟性や効率性の面で限界がありました。
ERC1767では、GraphQLを用いることで、その代替手段として、より使いやすく予測可能で効率的かつ将来的にも拡張しやすい仕組みを提供しようとしています。
動機
Ethereumの現在のJSON-RPCインターフェースには、いくつかの問題点があります。
仕様が非公式かつ不完全であるため、ノードごとに動作が微妙に異なったり、データの形式が統一されていなかったりします。
例えば、空のバイト列の表現ひとつをとっても、「""」「0x」「0x0」といった複数の表現が存在し、互換性の課題を生んでいます。
また、JSON-RPCは呼び出し側が必要とする情報を推測してデータを提供するため、無駄な処理が発生しやすいという問題もあります。
例えば、ブロック情報を取得する eth_getBlock
メソッドでは、実際には使われない可能性のある totalDifficulty
フィールドまで必ず読み込む仕様となっています。
このフィールドはブロックヘッダーとは別に保存されているため、追加のディスクアクセスが必要となり、パフォーマンスの低下を招きます。
同様に、トランザクションレシートの取得でも効率の悪さが顕著です。
go-ethereumでは1ブロック内のレシートがすべて1つのバイナリデータとして保存されていますが、個別のトランザクションのレシートを取得するにはこのデータ全体を読み込み、該当部分を抽出する必要があります。
さらに多くのユーザーがブロック内のすべてのレシートを取得しようとするため、同じバイナリを何度も読み込むことになり、処理量が二乗に増えてしまいます。
これらの非効率性は、JSON-RPCの設計上の制約からくるものであり、たとえ修正できたとしてもインターフェースが複雑になってしまうという新たな問題を抱えることになります。
そこで、ERC1767では既に多くの実績があるクエリ言語GraphQLの導入を提案しています。
GraphQLは、必要なデータのみを明確に指定して取得できるため、処理の無駄を減らし、開発者にとってもより柔軟で効率的なAPI設計を可能にします。
先行事例
GraphQLをEthereumノードへのアクセスに使うというアイデアは、Nick Johnson氏とEthQLというプロジェクトによってそれぞれ独立に開発されていました。
その後、両者が協力し、それぞれのスキーマを統合する努力がなされました。
ERC1767で提案されているスキーマは、主にEthQLで策定されたものをベースとしています。
このように、すでに実績のあるGraphQLベースのスキーマに標準化の枠組みを与えることで、Ethereumノードとのやり取りのあり方を抜本的に改善しようというのが、この提案の主な狙いです。
仕様
GraphQLエンドポイントの提供方法
ERC1767で定義されるGraphQL APIに対応するEthereumノードは、HTTP経由でGraphQLエンドポイントを提供する必要があります。
エンドポイントのパスは原則として /graphql
とし、デフォルトのポート番号は8547が推奨されています。
また、開発者がインタラクティブにクエリを試せるようにするためのGraphiQLツールも、任意で /
パスに設置可能です。
データ型の定義(スカラ型)
GraphQLではEthereumのデータ型を表現するために、以下のような専用のスカラ型が定義されています。
-
Bytes32
- 32バイト固定長のバイナリデータ。
0x
で始まる16進数形式。
- 32バイト固定長のバイナリデータ。
-
Address
- Ethereumのアドレス(20バイト)。
-
Bytes
- 任意長のバイナリデータ。空は
0x
と表現。
- 任意長のバイナリデータ。空は
-
BigInt
- 任意の大きさの整数。入力は数値または文字列(10進または16進)。
-
Long
- 64ビットの符号なし整数。
これにより、Ethereum特有のデータ形式を明確に取り扱うことができます。
スキーマ構造の概要
スキーマの中心は schema { query: Query, mutation: Mutation }
で、読み取り専用操作は Query
、トランザクション送信などの書き込み操作は Mutation
に分類されます。
スキーマ
# Bytes32 is a 32 byte binary string, represented as 0x-prefixed hexadecimal.
scalar Bytes32
# Address is a 20 byte Ethereum address, represented as 0x-prefixed hexadecimal.
scalar Address
# Bytes is an arbitrary length binary string, represented as 0x-prefixed hexadecimal.
# An empty byte string is represented as '0x'. Byte strings must have an even number of hexadecimal nybbles.
scalar Bytes
# BigInt is a large integer. Input is accepted as either a JSON number or as a string.
# Strings may be either decimal or 0x-prefixed hexadecimal. Output values are all
# 0x-prefixed hexadecimal.
scalar BigInt
# Long is a 64 bit unsigned integer.
scalar Long
schema {
query: Query
mutation: Mutation
}
# Account is an Ethereum account at a particular block.
type Account {
# Address is the address owning the account.
address: Address!
# Balance is the balance of the account, in wei.
balance: BigInt!
# TransactionCount is the number of transactions sent from this account,
# or in the case of a contract, the number of contracts created. Otherwise
# known as the nonce.
transactionCount: Long!
# Code contains the smart contract code for this account, if the account
# is a (non-self-destructed) contract.
code: Bytes!
# Storage provides access to the storage of a contract account, indexed
# by its 32 byte slot identifier.
storage(slot: Bytes32!): Bytes32!
}
# Log is an Ethereum event log.
type Log {
# Index is the index of this log in the block.
index: Int!
# Account is the account which generated this log - this will always
# be a contract account.
account(block: Long): Account!
# Topics is a list of 0-4 indexed topics for the log.
topics: [Bytes32!]!
# Data is unindexed data for this log.
data: Bytes!
# Transaction is the transaction that generated this log entry.
transaction: Transaction!
}
# Transaction is an Ethereum transaction.
type Transaction {
# Hash is the hash of this transaction.
hash: Bytes32!
# Nonce is the nonce of the account this transaction was generated with.
nonce: Long!
# Index is the index of this transaction in the parent block. This will
# be null if the transaction has not yet been mined.
index: Int
# From is the account that sent this transaction - this will always be
# an externally owned account.
from(block: Long): Account!
# To is the account the transaction was sent to. This is null for
# contract-creating transactions.
to(block: Long): Account
# Value is the value, in wei, sent along with this transaction.
value: BigInt!
# GasPrice is the price offered to miners for gas, in wei per unit.
gasPrice: BigInt!
# Gas is the maximum amount of gas this transaction can consume.
gas: Long!
# InputData is the data supplied to the target of the transaction.
inputData: Bytes!
# Block is the block this transaction was mined in. This will be null if
# the transaction has not yet been mined.
block: Block
# Status is the return status of the transaction. This will be 1 if the
# transaction succeeded, or 0 if it failed (due to a revert, or due to
# running out of gas). If the transaction has not yet been mined, this
# field will be null.
status: Long
# GasUsed is the amount of gas that was used processing this transaction.
# If the transaction has not yet been mined, this field will be null.
gasUsed: Long
# CumulativeGasUsed is the total gas used in the block up to and including
# this transaction. If the transaction has not yet been mined, this field
# will be null.
cumulativeGasUsed: Long
# CreatedContract is the account that was created by a contract creation
# transaction. If the transaction was not a contract creation transaction,
# or it has not yet been mined, this field will be null.
createdContract(block: Long): Account
# Logs is a list of log entries emitted by this transaction. If the
# transaction has not yet been mined, this field will be null.
logs: [Log!]
}
# BlockFilterCriteria encapsulates log filter criteria for a filter applied
# to a single block.
input BlockFilterCriteria {
# Addresses is list of addresses that are of interest. If this list is
# empty, results will not be filtered by address.
addresses: [Address!]
# Topics list restricts matches to particular event topics. Each event has a list
# of topics. Topics matches a prefix of that list. An empty element array matches any
# topic. Non-empty elements represent an alternative that matches any of the
# contained topics.
#
# Examples:
# - [] or nil matches any topic list
# - [[A]] matches topic A in first position
# - [[], [B]] matches any topic in first position, B in second position
# - [[A], [B]] matches topic A in first position, B in second position
# - [[A, B]], [C, D]] matches topic (A OR B) in first position, (C OR D) in second position
topics: [[Bytes32!]!]
}
# Block is an Ethereum block.
type Block {
# Number is the number of this block, starting at 0 for the genesis block.
number: Long!
# Hash is the block hash of this block.
hash: Bytes32!
# Parent is the parent block of this block.
parent: Block
# Nonce is the block nonce, an 8 byte sequence determined by the miner.
nonce: Bytes!
# TransactionsRoot is the keccak256 hash of the root of the trie of transactions in this block.
transactionsRoot: Bytes32!
# TransactionCount is the number of transactions in this block. if
# transactions are not available for this block, this field will be null.
transactionCount: Int
# StateRoot is the keccak256 hash of the state trie after this block was processed.
stateRoot: Bytes32!
# ReceiptsRoot is the keccak256 hash of the trie of transaction receipts in this block.
receiptsRoot: Bytes32!
# Miner is the account that mined this block.
miner(block: Long): Account!
# ExtraData is an arbitrary data field supplied by the miner.
extraData: Bytes!
# GasLimit is the maximum amount of gas that was available to transactions in this block.
gasLimit: Long!
# GasUsed is the amount of gas that was used executing transactions in this block.
gasUsed: Long!
# Timestamp is the unix timestamp at which this block was mined.
timestamp: BigInt!
# LogsBloom is a bloom filter that can be used to check if a block may
# contain log entries matching a filter.
logsBloom: Bytes!
# MixHash is the hash that was used as an input to the PoW process.
mixHash: Bytes32!
# Difficulty is a measure of the difficulty of mining this block.
difficulty: BigInt!
# TotalDifficulty is the sum of all difficulty values up to and including
# this block.
totalDifficulty: BigInt!
# OmmerCount is the number of ommers (AKA uncles) associated with this
# block. If ommers are unavailable, this field will be null.
ommerCount: Int
# Ommers is a list of ommer (AKA uncle) blocks associated with this block.
# If ommers are unavailable, this field will be null. Depending on your
# node, the transactions, transactionAt, transactionCount, ommers,
# ommerCount and ommerAt fields may not be available on any ommer blocks.
ommers: [Block]
# OmmerAt returns the ommer (AKA uncle) at the specified index. If ommers
# are unavailable, or the index is out of bounds, this field will be null.
ommerAt(index: Int!): Block
# OmmerHash is the keccak256 hash of all the ommers (AKA uncles)
# associated with this block.
ommerHash: Bytes32!
# Transactions is a list of transactions associated with this block. If
# transactions are unavailable for this block, this field will be null.
transactions: [Transaction!]
# TransactionAt returns the transaction at the specified index. If
# transactions are unavailable for this block, or if the index is out of
# bounds, this field will be null.
transactionAt(index: Int!): Transaction
# Logs returns a filtered set of logs from this block.
logs(filter: BlockFilterCriteria!): [Log!]!
# Account fetches an Ethereum account at the current block's state.
account(address: Address!): Account
# Call executes a local call operation at the current block's state.
call(data: CallData!): CallResult
# EstimateGas estimates the amount of gas that will be required for
# successful execution of a transaction at the current block's state.
estimateGas(data: CallData!): Long!
}
# CallData represents the data associated with a local contract call.
# All fields are optional.
input CallData {
# From is the address making the call.
from: Address
# To is the address the call is sent to.
to: Address
# Gas is the amount of gas sent with the call.
gas: Long
# GasPrice is the price, in wei, offered for each unit of gas.
gasPrice: BigInt
# Value is the value, in wei, sent along with the call.
value: BigInt
# Data is the data sent to the callee.
data: Bytes
}
# CallResult is the result of a local call operation.
type CallResult {
# Data is the return data of the called contract.
data: Bytes!
# GasUsed is the amount of gas used by the call, after any refunds.
gasUsed: Long!
# Status is the result of the call - 1 for success or 0 for failure.
status: Long!
}
# FilterCriteria encapsulates log filter criteria for searching log entries.
input FilterCriteria {
# FromBlock is the block at which to start searching, inclusive. Defaults
# to the latest block if not supplied.
fromBlock: Long
# ToBlock is the block at which to stop searching, inclusive. Defaults
# to the latest block if not supplied.
toBlock: Long
# Addresses is a list of addresses that are of interest. If this list is
# empty, results will not be filtered by address.
addresses: [Address!]
# Topics list restricts matches to particular event topics. Each event has a list
# of topics. Topics matches a prefix of that list. An empty element array matches any
# topic. Non-empty elements represent an alternative that matches any of the
# contained topics.
#
# Examples:
# - [] or nil matches any topic list
# - [[A]] matches topic A in first position
# - [[], [B]] matches any topic in first position, B in second position
# - [[A], [B]] matches topic A in first position, B in second position
# - [[A, B]], [C, D]] matches topic (A OR B) in first position, (C OR D) in second position
topics: [[Bytes32!]!]
}
# SyncState contains the current synchronisation state of the client.
type SyncState{
# StartingBlock is the block number at which synchronisation started.
startingBlock: Long!
# CurrentBlock is the point at which synchronisation has presently reached.
currentBlock: Long!
# HighestBlock is the latest known block number.
highestBlock: Long!
# PulledStates is the number of state entries fetched so far, or null
# if this is not known or not relevant.
pulledStates: Long
# KnownStates is the number of states the node knows of so far, or null
# if this is not known or not relevant.
knownStates: Long
}
# Pending represents the current pending state.
type Pending {
# TransactionCount is the number of transactions in the pending state.
transactionCount: Int!
# Transactions is a list of transactions in the current pending state.
transactions: [Transaction!]
# Account fetches an Ethereum account for the pending state.
account(address: Address!): Account
# Call executes a local call operation for the pending state.
call(data: CallData!): CallResult
# EstimateGas estimates the amount of gas that will be required for
# successful execution of a transaction for the pending state.
estimateGas(data: CallData!): Long!
}
type Query {
# Block fetches an Ethereum block by number or by hash. If neither is
# supplied, the most recent known block is returned.
block(number: Long, hash: Bytes32): Block
# Blocks returns all the blocks between two numbers, inclusive. If
# to is not supplied, it defaults to the most recent known block.
blocks(from: Long!, to: Long): [Block!]!
# Pending returns the current pending state.
pending: Pending!
# Transaction returns a transaction specified by its hash.
transaction(hash: Bytes32!): Transaction
# Logs returns log entries matching the provided filter.
logs(filter: FilterCriteria!): [Log!]!
# GasPrice returns the node's estimate of a gas price sufficient to
# ensure a transaction is mined in a timely fashion.
gasPrice: BigInt!
# ProtocolVersion returns the current wire protocol version number.
protocolVersion: Int!
# Syncing returns information on the current synchronisation state.
syncing: SyncState
}
type Mutation {
# SendRawTransaction sends an RLP-encoded transaction to the network.
sendRawTransaction(data: Bytes!): Bytes32!
}
主なGraphQL型
Account型
Ethereumのアカウント情報を表現します。
アドレス、残高、トランザクション数、コード(コントラクト)、およびストレージスロット単位での読み取り機能を提供します。
Transaction型
トランザクションの詳細を取得できます。
送信者・受信者、ガス、入力データ、ステータス、ブロック情報、生成されたコントラクトやログなどが含まれます。
Block型
ブロックの詳細を含む構造体です。
ハッシュ、親ブロック、トランザクションリスト、ガス使用量、タイムスタンプ、難易度、オムマー(uncleブロック)などの情報が取得可能です。
コントラクトの呼び出しやガスの見積もりをローカルで試行するための call
や estimateGas
も利用できます。
Log型
イベントログに関する情報を表します。
発生元のアカウント、トピック(indexedパラメータ)、データ(非indexed)、関連するトランザクション情報などを取得できます。
Pending型
未確定(pending)状態のトランザクションとアカウント情報を提供します。
ブロックには含まれていないが送信済みのトランザクションの確認や、pending状態におけるcall、estimateGasの実行が可能です。
CallData型とCallResult型
コントラクトのローカル呼び出しに使うパラメータが CallData
、その呼び出し結果が CallResult
に対応します。
結果には返却データ、ガス使用量、成功可否(status)が含まれます。
SyncState型
ノードの同期状態を確認するための型です。
開始ブロック、現在の同期位置、最大ブロック、取得済みステート数などを返します。
フィルター条件の入力型
-
FilterCriteria
:複数ブロックにまたがるログのフィルタ条件を指定。 -
BlockFilterCriteria
:単一ブロックにおけるログ取得のフィルタ指定。
いずれも、アドレスやトピック(イベントの識別子)を条件にできます。
Query型:読み取り操作
クライアントがEthereumノードから情報を取得するためのエントリーポイントです。
以下の操作が定義されています。
- ブロックの取得(番号またはハッシュ指定)
- トランザクションの取得(ハッシュ指定)
- ログのフィルタ取得
- ペンディング状態の確認
- ガス価格や同期状態の取得 など
Mutation型:書き込み操作
トランザクションの送信操作を定義します。sendRawTransaction
により、RLPエンコードされたトランザクションをネットワークへ送ることができます。
拡張についての方針
各Ethereumクライアント(例:GethやParity)は、このスキーマを拡張することができます。
ただし、拡張部分には clientGeth
や clientParity
のように明示的な接頭辞をつける必要があります。
共通仕様に追加したい場合は、新たなEIPを立てて正式に提案する必要があります。
補足
Ethereumノードの設計思想の変化
Ethereumノードの設計は、従来の「すべてを1つのノードでこなす」アプローチから、「必要な機能は専用プロセスで分離して扱う」という、いわばUnix的な思想へとシフトしています。
つまり、ノードはできる限り純粋なデータ提供者としての役割に集中し、トランザクションの署名やコードのコンパイルなど、書き込みや処理系の機能は別プロセスに委ねる方針です。
この考え方を反映して、GraphQL APIでは以下のような機能は最初から含まれていません。
- コントラクトのコンパイル(
eth_compile*
)機能はすでに非推奨となっており、GraphQLスキーマでも提供されていません。 - アカウント管理(
eth_accounts
)や署名(eth_sign
)、トランザクションの送信(eth_sendTransaction
)なども、将来的な非推奨が見込まれており、本スキーマでは除外されています。
こうした機能を利用したい場合は、ローカルウォレットや署名専用のデーモンプロセスといった、外部のツールやサービスを使う設計が推奨されています。
意図的に後回しにされた2つの領域
初期のGraphQL API仕様には、現時点であえて含めなかった機能領域が2つあります。
これは単に未実装というより、実装に際して慎重な設計判断が必要とされるためです。
まず、フィルター(filter)機能については、GraphQLの「Subscription(サブスクリプション)」と呼ばれる仕組みを使う必要がありますが、この仕組みを導入するとノードがクライアントごとの状態を保持する必要が出てくるため、Ethereumノードの「ステートレスであるべき」という設計方針と衝突する可能性があります。
このため、GraphQLにおけるfilterの標準化は、将来的に別のEIPで明確に設計される予定です。
もう1つはマイニング機能です。
これは現在あまり一般的に使われておらず、GraphQLで再実装しても得られる恩恵が少ないため、現時点では対象外とされ、今後必要であれば個別のEIPで定義されることになります。
互換性
ERC1767で提案されたGraphQLスキーマは、現在Ethereumノードが提供しているJSON-RPCインターフェースの「読み取り専用機能」の大部分を網羅しています。
つまり、これまでJSON-RPCを利用していた開発者は、GraphQLに切り替えても、基本的な読み取り操作についてはほとんど同等の操作が可能です。
GraphQLでは、各種RPCメソッドに相当する機能をクエリとして提供しており、それぞれのRPC呼び出しがどのようにGraphQLに対応するのかが明確に定義されています。
以下に、対応状況をまとめた一覧表を示します。
JSON-RPCとGraphQLの対応表
RPCメソッド名 | 対応状況 | GraphQLでの対応例 |
---|---|---|
eth_blockNumber | 実装済 | { block { number } } |
eth_call | 実装済 | { call(data: { to: "0x...", data: "0x..." }) { data status gasUsed } } |
eth_estimateGas | 実装済 | { estimateGas(data: { to: "0x...", data: "0x..." }) } |
eth_gasPrice | 実装済 | { gasPrice } |
eth_getBalance | 実装済 | { account(address: "0x...") { balance } } |
eth_getBlockByHash | 実装済 | { block(hash: "0x...") { ... } } |
eth_getBlockByNumber | 実装済 | { block(number: 123) { ... } } |
eth_getBlockTransactionCountByHash | 実装済 | { block(hash: "0x...") { transactionCount } } |
eth_getBlockTransactionCountByNumber | 実装済 | { block(number: x) { transactionCount } } |
eth_getCode | 実装済 | { account(address: "0x...") { code } } |
eth_getLogs | 実装済 |
{ logs(filter: { ... }) { ... } } または { block(...) { logs(filter: { ... }) { ... } } }
|
eth_getStorageAt | 実装済 | { account(address: "0x...") { storage(slot: "0x...") } } |
eth_getTransactionByBlockHashAndIndex | 実装済 | { block(hash: "0x...") { transactionAt(index: x) { ... } } } |
eth_getTransactionByBlockNumberAndIndex | 実装済 | { block(number: n) { transactionAt(index: x) { ... } } } |
eth_getTransactionByHash | 実装済 | { transaction(hash: "0x...") { ... } } |
eth_getTransactionCount | 実装済 | { account(address: "0x...") { transactionCount } } |
eth_getTransactionReceipt | 実装済 | { transaction(hash: "0x...") { ... } } |
eth_getUncleByBlockHashAndIndex | 実装済 | { block(hash: "0x...") { ommerAt(index: x) { ... } } } |
eth_getUncleByBlockNumberAndIndex | 実装済 | { block(number: n) { ommerAt(index: x) { ... } } } |
eth_getUncleCountByBlockHash | 実装済 | { block(hash: "0x...") { ommerCount } } |
eth_getUncleCountByBlockNumber | 実装済 | { block(number: x) { ommerCount } } |
eth_protocolVersion | 実装済 | { protocolVersion } |
eth_sendRawTransaction | 実装済(Mutation) | mutation { sendRawTransaction(data: data) } |
eth_syncing | 実装済 | { syncing { ... } } |
実装されていない機能とその理由
一部のRPCメソッドについては、GraphQLには実装されていません。
理由は主に以下の通りです。
- コンパイラ関連(
eth_compile*
系)はJSON-RPCでもすでに廃止されており、GraphQLにも含まれていません。 - フィルター関連(
eth_newFilter
など)は、将来的にGraphQLの「サブスクリプション」機能を用いた設計が必要とされるため、別のEIPで定義される予定です。 - アカウントや署名関連(
eth_accounts
,eth_sign
,eth_sendTransaction
)は、ノードではなく外部の署名ツールやウォレットで扱うべきとされており、本APIからは除外されています。 - マイニング関連(
eth_mining
,eth_submitWork
など)は使用頻度が低く、GraphQLによる再設計の恩恵が少ないため、別EIPで定義予定です。
引用
Nick Johnson (@arachnid), Raúl Kripalani (@raulk), Kris Shinn (@kshinn), "EIP-1767: GraphQL interface to Ethereum node data [DRAFT]," Ethereum Improvement Proposals, no. 1767, February 2019. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-1767.
最後に
今回は「Ethereumノードからのデータ取得をより柔軟かつ効率的にするために、従来のJSON-RPCに代わる標準的なGraphQLインターフェースを導入する仕組みを提案しているERC1767」についてまとめてきました!
いかがだったでしょうか?
質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!
他の媒体でも情報発信しているのでぜひ他も見ていってください!