Web3
現状Web3は若干胡散臭く聞こえるので避けているエンジニアも多いんじゃないかと思っています。ご多分に漏れず自分もつい最近までそう思っていました。ただ、75億円というありえない金額でやりとりされていたり、エンジニアとして普通に生きていれば非常に良く耳にするキーワードであることもまた事実なので、Web3とはどういうものか?を学んでみたいと思い一冊本を読んでみることにしました。もちろんこれ(←これのせいで胡散臭さにより拍車をかけた側面もありますよねw)ではなくテクノロジーが予測する未来を読みました。
Web3は最初にどの本から入るかで印象が変わる
本はわりとたまたま手に取って読んだだけなんですが、かなり良い本でした。Web3において最初に読む本は結構需要かもしれないなと思いました。Web3は儲かる!という前提で書かれた本で学ぶと、WEB3の本質を見誤るかもなと思いました。今日現在たまたまバブルのようなブームでお金の可能性が見えていますが、Web3の本質はそこではなくもっと面白い可能性がある技術だと思いました。特にNFTとイーサリアムがめちゃくちゃ面白そうで興味を持ちました。
IDから画像を出すプログラムを書きたい
本ではWeb3という色んな話の一つとしてイーサリアムとNFTを取り上げているだけで、技術的な所はあまり深く出てきません。イーサリアムはプログラマブルであることが一つの特徴として挙げられていたので、まずはプログラムで何かイーサリアムに関わる事をやったみたいと思いました。そこでNFTはIDに対応する画像があるようなので、まずはIDを指定したら画像が表示されるプログラムを書いてみようと思いました。
C#で書いてみたい
また自分はC#が好きなので、C#でNFTのIDから画像を表示することが出来ないか、出来ないならその部分を含めて自分で作ってやろうという気持ちでした。調べてみるとNFTをUnityで表示するという記事を見つけました。読んでみるとC#でというよりはJavascriptを中心に扱っている内容でした。web3.jsを使ってWeb3のインスタンスを作っている感じだったので、これと同じことが出来ないか調べてみるとNethereumという.NETのethereumライブラリで同じことが出来そうでした。
contractaddressとtokenID
記事の中でcontractaddressとtokenIDを指定してやれば画像が出させそうなのは分かりました。そこで実際にC#でコードを書いてWeb3のインスタンスを作ってみようとすると色々と疑問が出てきました。Web3のインスタンスを作るにはまず第一引数にurlを渡してやる必要があります。ただデフォルト引数が付いていてそれがlocalhostを指定しています。urlに何を渡したら良いんだろう?これが最初の疑問でした。
public Web3(string url = @"http://localhost:8545/", ILog log = null, AuthenticationHeaderValue authenticationHeader = null)
infura
URLに関しては記事の中でinfuraで作成したURLと書いてありました。で、infuraって何?ってことですが、ググるっとわりと最初に否定的な記事が出ました。Infuraへの過度な依存で『イーサリアムのビジョンは失敗に終わる』。たださらに調べているとNethereumに限らずJavaScriptでもWeb3のURLにinfuraを渡している例はちょこちょこ見つかりました。これがベストな方法か分からないですが、とりあえず一旦infuraを使ってみることにしました。とはいえやることはアカウント作るぐらいですぐ使えました。
Contract
infuraを渡してWeb3のインタンスを作ったら次にweb3.Eth.GetContractにcontractAddressを指定してContractのインスタンスを取得します。そこでabiという文字列を渡せとまた分からないキーワードが出てきました。調べてみると「ABI(Application Binary Interface)は、バイナリファイルへのアクセスに対して互換性を与えるために定義されるものです」とあり、静的に決まっているものを渡せばよさそうです。ググってみるとNethereumのissuesでもそのまま渡しているサンプルがあったので、これをそのままパクっってきました。
//abiに渡す文字列(issuesまま←もう少しシンプルにできるかもしれないが未検証)
private static readonly string abi = @"[{""inputs"":[],""stateMutability"":""nonpayable"",""type"":""constructor""},{""anonymous"":false,""inputs"":[{""indexed"":true,""internalType"":""address"",""name"":""owner"",""type"":""address""},{""indexed"":true,""internalType"":""address"",""name"":""approved"",""type"":""address""},{""indexed"":true,""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""}],""name"":""Approval"",""type"":""event""},{""anonymous"":false,""inputs"":[{""indexed"":true,""internalType"":""address"",""name"":""owner"",""type"":""address""},{""indexed"":true,""internalType"":""address"",""name"":""operator"",""type"":""address""},{""indexed"":false,""internalType"":""bool"",""name"":""approved"",""type"":""bool""}],""name"":""ApprovalForAll"",""type"":""event""},{""anonymous"":false,""inputs"":[{""indexed"":true,""internalType"":""address"",""name"":""from"",""type"":""address""},{""indexed"":true,""internalType"":""address"",""name"":""to"",""type"":""address""},{""indexed"":true,""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""}],""name"":""Transfer"",""type"":""event""},{""inputs"":[{""internalType"":""address"",""name"":""to"",""type"":""address""},{""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""}],""name"":""approve"",""outputs"":[],""stateMutability"":""nonpayable"",""type"":""function""},{""inputs"":[{""internalType"":""address"",""name"":""owner"",""type"":""address""}],""name"":""balanceOf"",""outputs"":[{""internalType"":""uint256"",""name"":"""",""type"":""uint256""}],""stateMutability"":""view"",""type"":""function""},{""inputs"":[{""internalType"":""address"",""name"":""_newOwner"",""type"":""address""}],""name"":""changeOwner"",""outputs"":[{""internalType"":""bool"",""name"":"""",""type"":""bool""}],""stateMutability"":""nonpayable"",""type"":""function""},{""inputs"":[{""internalType"":""address"",""name"":""_itemOwner"",""type"":""address""},{""internalType"":""uint256"",""name"":""_id"",""type"":""uint256""},{""internalType"":""string"",""name"":""_tokenURI"",""type"":""string""}],""name"":""forgeNewNFT"",""outputs"":[{""internalType"":""bool"",""name"":"""",""type"":""bool""}],""stateMutability"":""nonpayable"",""type"":""function""},{""inputs"":[{""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""}],""name"":""getApproved"",""outputs"":[{""internalType"":""address"",""name"":"""",""type"":""address""}],""stateMutability"":""view"",""type"":""function""},{""inputs"":[{""internalType"":""address"",""name"":""owner"",""type"":""address""},{""internalType"":""address"",""name"":""operator"",""type"":""address""}],""name"":""isApprovedForAll"",""outputs"":[{""internalType"":""bool"",""name"":"""",""type"":""bool""}],""stateMutability"":""view"",""type"":""function""},{""inputs"":[],""name"":""name"",""outputs"":[{""internalType"":""string"",""name"":"""",""type"":""string""}],""stateMutability"":""view"",""type"":""function""},{""inputs"":[{""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""}],""name"":""ownerOf"",""outputs"":[{""internalType"":""address"",""name"":"""",""type"":""address""}],""stateMutability"":""view"",""type"":""function""},{""inputs"":[{""internalType"":""address"",""name"":""from"",""type"":""address""},{""internalType"":""address"",""name"":""to"",""type"":""address""},{""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""}],""name"":""safeTransferFrom"",""outputs"":[],""stateMutability"":""nonpayable"",""type"":""function""},{""inputs"":[{""internalType"":""address"",""name"":""from"",""type"":""address""},{""internalType"":""address"",""name"":""to"",""type"":""address""},{""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""},{""internalType"":""bytes"",""name"":""_data"",""type"":""bytes""}],""name"":""safeTransferFrom"",""outputs"":[],""stateMutability"":""nonpayable"",""type"":""function""},{""inputs"":[{""internalType"":""address"",""name"":""operator"",""type"":""address""},{""internalType"":""bool"",""name"":""approved"",""type"":""bool""}],""name"":""setApprovalForAll"",""outputs"":[],""stateMutability"":""nonpayable"",""type"":""function""},{""inputs"":[{""internalType"":""bytes4"",""name"":""interfaceId"",""type"":""bytes4""}],""name"":""supportsInterface"",""outputs"":[{""internalType"":""bool"",""name"":"""",""type"":""bool""}],""stateMutability"":""view"",""type"":""function""},{""inputs"":[],""name"":""symbol"",""outputs"":[{""internalType"":""string"",""name"":"""",""type"":""string""}],""stateMutability"":""view"",""type"":""function""},{""inputs"":[{""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""}],""name"":""tokenURI"",""outputs"":[{""internalType"":""string"",""name"":"""",""type"":""string""}],""stateMutability"":""view"",""type"":""function""},{""inputs"":[{""internalType"":""address"",""name"":""from"",""type"":""address""},{""internalType"":""address"",""name"":""to"",""type"":""address""},{""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""}],""name"":""transferFrom"",""outputs"":[],""stateMutability"":""nonpayable"",""type"":""function""}]";
var web3 = new Web3($"https://mainnet.infura.io/v3/{infuraApiKey}");
var contract = web3.Eth.GetContract(abi, contractaddress);
TokenURI
さらにサンプルは以下のように続いていました。今回自分が取りたいのはトークンのURIであり、そこでtokenIDを渡してやればURIを取得することが出来そうでした。
//issuesにあったコード
Function functionTransfer = contract.GetFunction("transfer");
var sss = functionTransfer.CallAsync<bool>(addressTo, token);
↓
//自分がとりたいtokenURIに変更
var tokenFunction = contract.GetFunction("tokenURI");
var tokenUri = await tokenFunction.CallAsync<string>(tokenId);
これで実際tokenURIが取れました。
ipfs://ipfs/QmPAg1mjxcEQPPtqsLoEcauVedaeMH81WXDPvPx3VC5zUz
ipfs
ただこのtokenURIはhttpではなくipfsというプロコトルが返ってきました。ipfs?また分からない単語です。調べてみるとIPFSは分散ファイルシステムにデータを保存、共有するためのプロトコル、かつP2Pネットワークであると書いてありました。あ、仮想通貨はそもそもP2Pでやりとりしているなと言うことを思い出してなるほどと思いました。ipfsは以下のルールでhttpsに置換してやれば実際に有効なURLとして扱えるようでした。
tokenUri.Replace("ipfs://ipfs", "https://ipfs.io/ipfs");
ipfs://ipfs/QmPAg1mjxcEQPPtqsLoEcauVedaeMH81WXDPvPx3VC5zUz
↓
https://ipfs.io/ipfs/QmPAg1mjxcEQPPtqsLoEcauVedaeMH81WXDPvPx3VC5zUz
JSON取得
つまりcontractaddressとして0x2a46f2ffd99e19a89476e2f62270e0a35bbf0756を渡すと、tokenUriとしてipfs://ipfs/QmPAg1mjxcEQPPtqsLoEcauVedaeMH81WXDPvPx3VC5zUzが返ってきて、それをhttpsに変換してURLにするとhttps://ipfs.io/ipfs/QmPAg1mjxcEQPPtqsLoEcauVedaeMH81WXDPvPx3VC5zUz になり、このURLならブラウザでも開けました。実際にブラウザで開くと以下のJSONが返ってきました。
{"title": "EVERYDAYS: THE FIRST 5000 DAYS", "name": "EVERYDAYS: THE FIRST 5000 DAYS", "type": "object", "imageUrl": "https://ipfsgateway.makersplace.com/ipfs/QmZ15eQX8FPjfrtdX3QYbrhZxJpbLpvDpsgb2p3VEH8Bqq", "description": "I made a picture from start to finish every single day from May 1st, 2007 - January 7th, 2021. This is every motherfucking one of those pictures.", "attributes": [{"trait_type": "Creator", "value": "beeple"}], "properties": {"name": {"type": "string", "description": "EVERYDAYS: THE FIRST 5000 DAYS"}, "description": {"type": "string", "description": "I made a picture from start to finish every single day from May 1st, 2007 - January 7th, 2021. This is every motherfucking one of those pictures."}, "preview_media_file": {"type": "string", "description": "https://ipfsgateway.makersplace.com/ipfs/QmZ15eQX8FPjfrtdX3QYbrhZxJpbLpvDpsgb2p3VEH8Bqq"}, "preview_media_file_type": {"type": "string", "description": "jpg"}, "created_at": {"type": "datetime", "description": "2021-02-16T00:07:31.674688+00:00"}, "total_supply": {"type": "int", "description": 1}, "digital_media_signature_type": {"type": "string", "description": "SHA-256"}, "digital_media_signature": {"type": "string", "description": "6314b55cc6ff34f67a18e1ccc977234b803f7a5497b94f1f994ac9d1b896a017"}, "raw_media_file": {"type": "string", "description": "https://ipfsgateway.makersplace.com/ipfs/QmXkxpwAHCtDXbbZHUwqtFucG1RMS6T87vi1CdvadfL7qA"}}}
このJSONのimageUrlこそがIDで指定した画像でした。
CSでコード
ここまで分かればcontractaddressとtokenIdから画像のURLを取得するコードが書けました。
private static readonly string Abi = @"[{""inputs"":[],""stateMutability"":""nonpayable"",""type"":""constructor""},{""anonymous"":false,""inputs"":[{""indexed"":true,""internalType"":""address"",""name"":""owner"",""type"":""address""},{""indexed"":true,""internalType"":""address"",""name"":""approved"",""type"":""address""},{""indexed"":true,""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""}],""name"":""Approval"",""type"":""event""},{""anonymous"":false,""inputs"":[{""indexed"":true,""internalType"":""address"",""name"":""owner"",""type"":""address""},{""indexed"":true,""internalType"":""address"",""name"":""operator"",""type"":""address""},{""indexed"":false,""internalType"":""bool"",""name"":""approved"",""type"":""bool""}],""name"":""ApprovalForAll"",""type"":""event""},{""anonymous"":false,""inputs"":[{""indexed"":true,""internalType"":""address"",""name"":""from"",""type"":""address""},{""indexed"":true,""internalType"":""address"",""name"":""to"",""type"":""address""},{""indexed"":true,""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""}],""name"":""Transfer"",""type"":""event""},{""inputs"":[{""internalType"":""address"",""name"":""to"",""type"":""address""},{""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""}],""name"":""approve"",""outputs"":[],""stateMutability"":""nonpayable"",""type"":""function""},{""inputs"":[{""internalType"":""address"",""name"":""owner"",""type"":""address""}],""name"":""balanceOf"",""outputs"":[{""internalType"":""uint256"",""name"":"""",""type"":""uint256""}],""stateMutability"":""view"",""type"":""function""},{""inputs"":[{""internalType"":""address"",""name"":""_newOwner"",""type"":""address""}],""name"":""changeOwner"",""outputs"":[{""internalType"":""bool"",""name"":"""",""type"":""bool""}],""stateMutability"":""nonpayable"",""type"":""function""},{""inputs"":[{""internalType"":""address"",""name"":""_itemOwner"",""type"":""address""},{""internalType"":""uint256"",""name"":""_id"",""type"":""uint256""},{""internalType"":""string"",""name"":""_tokenURI"",""type"":""string""}],""name"":""forgeNewNFT"",""outputs"":[{""internalType"":""bool"",""name"":"""",""type"":""bool""}],""stateMutability"":""nonpayable"",""type"":""function""},{""inputs"":[{""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""}],""name"":""getApproved"",""outputs"":[{""internalType"":""address"",""name"":"""",""type"":""address""}],""stateMutability"":""view"",""type"":""function""},{""inputs"":[{""internalType"":""address"",""name"":""owner"",""type"":""address""},{""internalType"":""address"",""name"":""operator"",""type"":""address""}],""name"":""isApprovedForAll"",""outputs"":[{""internalType"":""bool"",""name"":"""",""type"":""bool""}],""stateMutability"":""view"",""type"":""function""},{""inputs"":[],""name"":""name"",""outputs"":[{""internalType"":""string"",""name"":"""",""type"":""string""}],""stateMutability"":""view"",""type"":""function""},{""inputs"":[{""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""}],""name"":""ownerOf"",""outputs"":[{""internalType"":""address"",""name"":"""",""type"":""address""}],""stateMutability"":""view"",""type"":""function""},{""inputs"":[{""internalType"":""address"",""name"":""from"",""type"":""address""},{""internalType"":""address"",""name"":""to"",""type"":""address""},{""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""}],""name"":""safeTransferFrom"",""outputs"":[],""stateMutability"":""nonpayable"",""type"":""function""},{""inputs"":[{""internalType"":""address"",""name"":""from"",""type"":""address""},{""internalType"":""address"",""name"":""to"",""type"":""address""},{""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""},{""internalType"":""bytes"",""name"":""_data"",""type"":""bytes""}],""name"":""safeTransferFrom"",""outputs"":[],""stateMutability"":""nonpayable"",""type"":""function""},{""inputs"":[{""internalType"":""address"",""name"":""operator"",""type"":""address""},{""internalType"":""bool"",""name"":""approved"",""type"":""bool""}],""name"":""setApprovalForAll"",""outputs"":[],""stateMutability"":""nonpayable"",""type"":""function""},{""inputs"":[{""internalType"":""bytes4"",""name"":""interfaceId"",""type"":""bytes4""}],""name"":""supportsInterface"",""outputs"":[{""internalType"":""bool"",""name"":"""",""type"":""bool""}],""stateMutability"":""view"",""type"":""function""},{""inputs"":[],""name"":""symbol"",""outputs"":[{""internalType"":""string"",""name"":"""",""type"":""string""}],""stateMutability"":""view"",""type"":""function""},{""inputs"":[{""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""}],""name"":""tokenURI"",""outputs"":[{""internalType"":""string"",""name"":"""",""type"":""string""}],""stateMutability"":""view"",""type"":""function""},{""inputs"":[{""internalType"":""address"",""name"":""from"",""type"":""address""},{""internalType"":""address"",""name"":""to"",""type"":""address""},{""internalType"":""uint256"",""name"":""tokenId"",""type"":""uint256""}],""name"":""transferFrom"",""outputs"":[],""stateMutability"":""nonpayable"",""type"":""function""}]";
public static async Task<string> ToImageUrlAsync(this string contractaddress, BigInteger tokenId, string infuraApiKey, HttpClient httpClient)
{
var web3 = new Web3($"https://mainnet.infura.io/v3/{infuraApiKey}");
var contract = web3.Eth.GetContract(Abi, contractaddress);
var tokenFunction = contract.GetFunction("tokenURI");
var tokenUri = await tokenFunction.CallAsync<string>(tokenId);
var ipfsUrl = tokenUri.Replace("ipfs://ipfs", "https://ipfs.io/ipfs");
var response = await httpClient.GetAsync(ipfsUrl);
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<IpfsJson>(json) ?? new IpfsJson();
return result.imageUrl;
}
これで画像のURLが返ってきました。
https://ipfsgateway.makersplace.com/ipfs/QmZ15eQX8FPjfrtdX3QYbrhZxJpbLpvDpsgb2p3VEH8Bqq
MAUIからBlazor
画像のURLが取れれば後は表示するだけです。最初はMAUIでcontractaddressとtokenIdを入力したら画像を表示するアプリを書こうと思ってちょっと作りましたが、ダウンロードして実行しないと見えないのは手間かと思って、Blazorでサイトを作りました。左メニューのNFTをクリックして「検索」ボタンを押せば画像が表示します。contractaddressとtokenIdを変更するとまだ幾つかエラーが返ってくるケースもありますが、正しく出る画像もあるし、もう少し勉強しないとエラーは回避できなそうだったので一旦勉強用としてはこれで完成として公開しました。以下がサイトです。
今後
最初の目標としてcontractaddressとtokenIdから画像を出すプログラムは書けたので次、何をするかまだ決めかねています。これ勉強したら?とかアドバイスがあればぜひコメントに書いてもらえると嬉しいです!