【solidity初心者】ブログに仮想通貨(ETH)の投げ銭機能を追加

最近、ブロックチェーンを用いたスマートコントラクトに興味があり「Solidity」言語の学習を始めました。

簡単なチュートリアルを終えたので、スマートコントラクトとWeb3.jsを使ってブログに仮想通貨(ETH)の投げ銭機能を追加してみました。

Solidityの学習方法

Solidityはイーサリアム上で動作するスマートコントラクトを実装する為のプログラミング言語です。

Solidity学習サイト「cryptozombies」

まず基礎を学ぶために「cryptozombies」を利用しました。

cryptozombies.io

Dapp(分散型アプリケーション)開発をゲーム作成を通じて学ぶコースで日本語にも対応しています。

プログラミング初心者でも分かるくらいに基礎から丁寧に解説しているため分かりやすいです。唯一の欠点は古いバージョンを使った解説なので、最新のコンパイラでは動作しない事が多い点です。そのため流し読みして概要を把握する程度の使い方が良いかと思います。

Solidityの特徴

「Beginner to Intermediate Smart Contracts」のコースに一通り目を通しました。

Solidityは他のプログラミング言語と大差が無いですが、ブロックチェーンの書き込みをなるべく少なくする(ブロックチェーン書き込み時に手数料がかかるため)等注意が必要です。

また一度リリースするとプログラムの修正が難しいため、拡張性を持たせた設計にする必要もあります。

仮想通貨の投げ銭機能

チュートリアルを終えたので、スマートコントラクトを使ったブログ投げ銭機能を実装します。

概要は以下の通り

投げ銭(ブログ作者にETH送信)

送金額(ETH):

  • 送金額(ETH)を入力し送金ボタンを押すと、コントラクトへ送金する(MetaMaskを利用)
  • 送金した人には、お礼として秘密のURLを伝える
  • オーナーはコントラクト内に貯まったETHを自分のアドレスに引き出せる

コントラクトの実装

まずは「cryptozombies」で学んだ(コンパイラのバージョン「0.4.19」)記述方法でコードを作成します。

// SPDX-License-Identifier: MIT
pragma solidity ^0.4.19;

import "./Ownable.sol";

contract SocialTipping is Ownable {

    mapping (address => uint) public tipAmount;
    string private _secretURI;

    function tip() external payable {
        require(msg.value > 0);
        tipAmount[msg.sender] += msg.value;
    }
    function withdraw() external onlyOwner {
        require(this.balance > 0);
        owner.transfer(this.balance);
    }
    function getBalance() external view onlyOwner returns(uint) {
        return this.balance;
    }
    function setSecretURI(string uri) external onlyOwner {
        _secretURI = uri;
    }
    function getSecretURI() external view returns(string) {
        require(tipAmount[msg.sender] > 0);
        return _secretURI;
    }
}

最新のコンパイラで動作するよう下記の通り修正しました。またSolidityのコミュニティ使用されている、natspecフォーマットのコメントを追加しました。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;

import "@openzeppelin/contracts@4.4.1/access/Ownable.sol";

/// @title ソーシャル投げ銭
/// @author denim012
/// @notice 投げ銭(ETH)を受け取る。投げ銭者はお礼として秘密のURLにアクセスできる
contract SocialTipping is Ownable {

    // アドレス毎の投げ銭額(累計)
    mapping (address => uint) public tipAmount;
    // 投げ銭者に表示するURI
    string private _secretURI;

    /// @notice 投げ銭を受け取る
    function tip() external payable {
        require(msg.value > 0);
        tipAmount[msg.sender] += msg.value;
    }
    /// @notice 投げ銭を引き出す
    /// @dev スマートコントラクト内のETHをオーナーに送信
    function withdraw() external onlyOwner {
        require(address(this).balance > 0);
        payable(msg.sender).transfer(address(this).balance);
    }
    /// @notice 投げ銭の残高照会
    /// @return EHT残高
    /// @dev スマートコントラクト内のETH額(Wei)表示
    function getBalance() external view onlyOwner returns(uint) {
        return address(this).balance;
    }
    /// @notice 秘密のURIを設定する
    /// @param uri URI
    function setSecretURI(string memory uri) external onlyOwner {
        _secretURI = uri;
    }
    /// @notice 秘密のURIを取得する
    /// @return URI
    /// @dev 送金累計額がゼロより大きいアドレスのみ値を返却
    function getSecretURI() external view returns(string memory) {
        require(tipAmount[msg.sender] > 0);
        return _secretURI;
    }
}

remix上でコンパイルし、Ropstenテストネットワークにデプロイします。

コントラクトのアドレスとABIはクライアントから呼び出す際に必要なのでコピーします。

▼ABI

f:id:denim012:20220113162658p:plain

▼コントラクトのアドレス

f:id:denim012:20220113162709p:plain

クライアントの実装

クライアント側は下記の通り実装しました。

今使用している「はてなブログ」ではHTML編集は可能ですが、一部属性は自動で削除されてしまいます(onClick属性など)。はてなブログで動作する形で記述し、下記をブログ記事に貼り付けます。

// コントラクトアドレス
const address = "0x65e532cEc0cDcf7D9Aa3c90Ef8cCEF2d9bC326a2";
// コントラクトABI
const abi = [
	{
		"anonymous": false,
		"inputs": [
			{
				"indexed": true,
				"internalType": "address",
				"name": "previousOwner",
				"type": "address"
			},
			{
				"indexed": true,
				"internalType": "address",
				"name": "newOwner",
				"type": "address"
			}
		],
		"name": "OwnershipTransferred",
		"type": "event"
	},
	{
		"inputs": [],
		"name": "renounceOwnership",
		"outputs": [],
		"stateMutability": "nonpayable",
		"type": "function"
	},
	{
		"inputs": [
			{
				"internalType": "string",
				"name": "uri",
				"type": "string"
			}
		],
		"name": "setSecretURI",
		"outputs": [],
		"stateMutability": "nonpayable",
		"type": "function"
	},
	{
		"inputs": [],
		"name": "tip",
		"outputs": [],
		"stateMutability": "payable",
		"type": "function"
	},
	{
		"inputs": [
			{
				"internalType": "address",
				"name": "newOwner",
				"type": "address"
			}
		],
		"name": "transferOwnership",
		"outputs": [],
		"stateMutability": "nonpayable",
		"type": "function"
	},
	{
		"inputs": [],
		"name": "withdraw",
		"outputs": [],
		"stateMutability": "nonpayable",
		"type": "function"
	},
	{
		"inputs": [],
		"name": "getBalance",
		"outputs": [
			{
				"internalType": "uint256",
				"name": "",
				"type": "uint256"
			}
		],
		"stateMutability": "view",
		"type": "function"
	},
	{
		"inputs": [],
		"name": "getSecretURI",
		"outputs": [
			{
				"internalType": "string",
				"name": "",
				"type": "string"
			}
		],
		"stateMutability": "view",
		"type": "function"
	},
	{
		"inputs": [],
		"name": "owner",
		"outputs": [
			{
				"internalType": "address",
				"name": "",
				"type": "address"
			}
		],
		"stateMutability": "view",
		"type": "function"
	},
	{
		"inputs": [
			{
				"internalType": "address",
				"name": "",
				"type": "address"
			}
		],
		"name": "tipAmount",
		"outputs": [
			{
				"internalType": "uint256",
				"name": "",
				"type": "uint256"
			}
		],
		"stateMutability": "view",
		"type": "function"
	}
];

var btn = document.querySelector("#tip");
btn.addEventListener("click", tip);
function tip() {
	// Metamaskのインストールチェック
	if (!window.ethereum) {
		alert("MetaMaskをインストールしてください");
		return;
	}
	const web3 = new Web3(window.ethereum);
	// コントラクトのインスタンス化
	const contract = new web3.eth.Contract(
		abi,
		address
	);
	const amt = document.querySelector("#amount").value;
	//ユーザのアドレスを取得
	ethereum.request({ method: "eth_requestAccounts" })
	.then((accounts) => {
		const userAccount = accounts[0];
		document.querySelector("#secretURI").innerText = "送金中・・・";
		contract.methods.tip().send({ from: userAccount, value: web3.utils.toWei(amt, "ether") })
		// 秘密のURL表示処理
		.then(() => {
			contract.methods.getSecretURI().call({ from: userAccount })
			.then((result) => {
				document.querySelector("#secretURI").innerText = result;
			})
		})
		.catch(() => {
			document.querySelector("#secretURI").innerText = "送金失敗";
		})
	});
}

動作確認

送金額を入力し、送金ボタンを押します。

すると、MetaMaskが起動し接続確認ダイアログが表示されます。

f:id:denim012:20220113162729p:plain

接続後、コントラクトへの送金ダイアログが表示されました。投げ銭金額の他に手数料がかかります。

f:id:denim012:20220113162742p:plain

送金後、10秒ほど経った後にURLが表示されました。

f:id:denim012:20220113162829p:plain

Etherscanで確認すると、コントラクト内にETHが保存されていることが確認できます。

f:id:denim012:20220113162847p:plain

各処理の手数料(ガス代)

今回のコントラクト実行時の手数料(ガス代)は下記の通りです。

  • デプロイ:0.00265ETH (=1,012円)
  • setSecretURI():0.000286ETH (=109円)
  • tip():0.000211ETH (=81円)
  • withdraw():0.000106ETH (=40円)

現在のイーサリアムメインネットワークでは1ETH=381,940円です。円換算すると手数料が馬鹿にならないことが分かります。

まとめ

SolidityとWeb3.jsを使ってブログに仮想通貨の投げ銭機能を追加しました。

Solidityは一般的なプログラミング言語と大差が無く、基礎を学べばある程度の事は作成できそうです。ただ、バージョンアップが頻繁に行われていて、1年前の記述が全然動作しないと言ったことも発生しています。

後、投げ銭で受け取った仮想通貨収入に対しては税金がかかるので、真似して作成する場合はどのタイミングで(コントラクトに送金があったタイミング or コントラクトから自分のウォレットに引き出したタイミング)、どのような税金がかかるか理解する必要があります。