LoginSignup
4
3

More than 3 years have passed since last update.

ZeppelinOSでスマートコントラクトをアップグレードさせてみた

Last updated at Posted at 2019-06-26

アップグレード可能なスマートコントラクトを簡単に作りたい!

Ethereumでデプロイしたコントラクトはアップグレードすることができないため、バグや機能拡張があった際にどうすることもできません、、、
ですが、Proxyコントラクト(Proxyパターン)を利用することで、アップグレード可能なコントラクトを作成することが可能です!

具体的には、Proxy用コントラクトと処理コントラクトを分けて作成することで、
Proxy用コントラクトからdelegatecallで処理用コントラクトを外部呼出しし、データ保持用と処理用を切り分け、更新したい場合は処理用コントラクトを更新、Proxyコントラクトが外部呼出しを行う際の参照先を変更することで、アップグレード可能なスマートコントラクトを作成できるようになるみたいです。

詳しくは、以下のサイトでまとめてくださっているのでご参照ください。
アップグレード可能なスマートコントラクトを実現する具体的なアプローチ

上記方法をとることで、自前でアップグレード可能なコントラクトを作成することができると思います。
ですが、コントラクトのバージョン管理やバグなくアップグレードできるように作成するのが手間かつ不安です、、、

そんな中見つけたのがZeppelinOSです!(知らなかったですが、かなり前からあったみたいです...)
ZeppelinOS :https://docs.zeppelinos.org/docs/start.html

簡単に導入できそうなのでZeppelinOSを使ってスマートコントラクトをアップグレードさせてみました!

実施環境

Win10
Docker

やってみる

環境作成

とりあえずDockerでtruffleとzosをインストール

truffle:スマートコントラクト開発用フレームワーク
zos:プロキシコントラクト用のフレームワーク
nan:zosインストール中エラーが出たので追加でインストール
zos-lib:zosで利用するライブラリ

$ docker run -it --name zos-test node:10.15.3 //bin/bash
$ cd ~/
$ mkdir test
$ cd test
$ npm install -y truffle nan zos zos-lib

ZeppelinOSを初期化。

$ npx zos init
? Welcome to ZeppelinOS! Choose a name for your project test
? Initial project version
Project initialized. Write a new contract in the contracts folder and run 'zos create' to deploy it.
$ tree -L 1
.
|-- contracts
|-- networks.js
|-- node_modules
|-- package-lock.json
`-- zos.json

networks.jsとzos.jsonファイルが作成されています。
接続先とzosの管理ファイルのようです。

networks.js
module.exports = {
  networks: {
    development: {
      protocol: 'http',
      host: 'localhost',
      port: 8545,
      gas: 5000000,
      gasPrice: 5e9,
      networkId: '*',
    },
  },
};
zos.json
{
  "zosversion": "2.2",
  "contracts": {},
  "dependencies": {},
  "name": "test",
  "version": "0.1.0"
}

truffleを使いたいのでtruffleを初期化。

$npx truffle init
$tree -L 1
.
|-- contracts
|-- migrations
|-- networks.js
|-- node_modules
|-- package-lock.json
|-- test
|-- truffle-config.js
`-- zos.json

truffleのファイルが作成されました。

ZeppelinOSで作成したnetworks.jsと、
truffleで作成truffle-config.jsで接続先設定が複数存在することになりますが、どちらが優先されるのでしょうか?
あとあとデプロイするので、その時確認していきます。

コントラクトを作る

コントラクトを作成します。
zos-libのInitializable.solを継承しinitializerを利用することで、デプロイ初回のみ稼働する処理を作成できるみたいです。

MyContract.sol
pragma solidity ^0.5.0;

import "zos-lib/contracts/Initializable.sol";

contract MyContract is Initializable {
  uint256 public x;

  function initialize(uint256 _x) initializer public {
    x = _x;
  }

  function get() public view returns (uint256) {
    return x;
  }

  function set(uint256 _x) public {
    x = _x;
  }
}

デプロイ

いよいよデプロイしていきます。
テストネットにはGanacheを利用します。

Ganacheを起動してワークスペース作成。
設定画面 > SERVER > HostNameでイーサネットを設定します。

truffle-config.jsに立ち上げたGanacheのRPC SERVER設定を追記します。

truffle-config.js
  networks: {
    development: {
      host: 'XXX.XXX.XXX.XXX',
      port: 7545,
      network_id: '*'
    }
 }

念のため、truffleのコンソールを立ち上げてつながるか確認。

$ npx truffle console
truffle(development)>
undefined
truffle(development)> .exit

準備が完了したので、デプロイしてみます。
zosプロジェクトにコントラクトをaddします。

$ npx zos add MyContract
✓ Compiling contracts with Truffle, using settings from truffle.js file
Truffle output:

Compiling your contracts...
===========================
> Compiling ./contracts/MyContract.sol
> Compiling zos-lib/contracts/Initializable.sol
> Artifacts written to /share/test/build/contracts
> Compiled successfully using:
   - solc: 0.5.8+commit.23d335f2.Emscripten.clang


✓ Added contract MyContract

addすることで、コントラクトのコンパイル & zos.jsonにaddしたコントラクト情報が追加されるみたいです。

zos.json
{
  "zosversion": "2.2",
  "contracts": {
    "MyContract": "MyContract"
  },
  "dependencies": {},
  "name": "test",
  "version": "0.1.0",
  "compiler": {
    "manager": "truffle",
    "compilerSettings": {
      "optimizer": {}
    }
  }
}

次にpushします。

$ npx zos push
✓ Compiling contracts with Truffle, using settings from truffle.js file
Truffle output:

Compiling your contracts...
===========================
> Compiling ./contracts/MyContract.sol
> Compiling zos-lib/contracts/Initializable.sol
> Artifacts written to /share/test/build/contracts
> Compiled successfully using:
   - solc: 0.5.8+commit.23d335f2.Emscripten.clang


? Pick a network (Use arrow keys)
❯ development
Could not connect to the development Ethereum network on http://localhost:8545. Please check your networks.js configuration file. Error: Invalid JSON RPC response: "".

ネットワーク設定でdevelopmentを選択すると、localhost:8545でエラーになります。
truffle-config.jsの設定がnetworks.jsで上書きされているようにみえます。

truffle-config.jsのネットワーク名をdevelopment_trfに変更して再チャレンジ!

$ npx zos push
✓ Compiling contracts with Truffle, using settings from truffle.js file
Truffle output:

Compiling your contracts...
===========================
> Compiling ./contracts/MyContract.sol
> Compiling zos-lib/contracts/Initializable.sol
> Artifacts written to /share/test/build/contracts
> Compiled successfully using:
   - solc: 0.5.8+commit.23d335f2.Emscripten.clang


? Pick a network
❯ development

上書きというか、参照されてないみたいです。
truffleのネットワーク設定を参照させたいので、networks.jsを削除して再チャレンジ。

$ npx zos push
✓ Compiling contracts with Truffle, using settings from truffle.js file
Truffle output:

Compiling your contracts...
===========================
> Compiling ./contracts/MyContract.sol
> Compiling zos-lib/contracts/Initializable.sol
> Artifacts written to /share/test/build/contracts
> Compiled successfully using:
   - solc: 0.5.8+commit.23d335f2.Emscripten.clang


? Pick a network development_trf
✓ Contract MyContract deployed
All contracts have been deployed

上手くいきました!
development_trfを選択してデプロイ完了です。
デプロイすることでzosのデプロイ情報ファイルが作成されるみたいです。

zos.dev-5777.json
{
  "contracts": {
    "MyContract": {
      "address": "0x6684f937f2182801bcDAF4E87b58A75C35D399Be",
      "constructorCode": "608060405234801561001057600080fd5b50610275806100206000396000f3fe",
      "bodyBytecodeHash": "e569853a7786790e009487234e007962c5a833f976699426916e9e62977381f4",
      "localBytecodeHash": "3b95e7a22280d4a0e2a6ed4a92d4bdfd244bdc0acd21db038f65bd0909cc3c2f",
      "deployedBytecodeHash": "3b95e7a22280d4a0e2a6ed4a92d4bdfd244bdc0acd21db038f65bd0909cc3c2f",
      "types": {
        "t_bool": {
          "id": "t_bool",
          "kind": "elementary",
          "label": "bool"
        },
        "t_uint256": {
          "id": "t_uint256",
          "kind": "elementary",
          "label": "uint256"
        },

      ===省略=============================================

      },
      "storage": [
        {
          "contract": "Initializable",
          "path": "zos-lib/contracts/Initializable.sol",
          "label": "initialized",
          "astId": 41,
          "type": "t_bool",
          "src": "757:24:1"
        },
        {
          "contract": "Initializable",
          "path": "zos-lib/contracts/Initializable.sol",
          "label": "initializing",
          "astId": 43,
          "type": "t_bool",
          "src": "876:25:1"
        },

      ===省略=============================================

      ],
      "warnings": {
        "hasConstructor": false,
        "hasSelfDestruct": false,
        "hasDelegateCall": false,
        "hasInitialValuesInDeclarations": false,
        "uninitializedBaseContracts": []
      }
    }
  },
  "solidityLibs": {},
  "proxies": {},
  "zosversion": "2.2",
  "version": "0.1.0"
}

proxiesが空なので、普通にコントラクトがデプロイされただけみたいです。
Gnacheに登録されていると思うので動作を見てみます。

npx truffle console --network development_trf
truffle(development_trf)> myContract = await MyContract.at('0x6684f937f2182801bcDAF4E87b58A75C35D399Be')
truffle(development_trf)> myContract.get()
<BN: 0>
truffle(development_trf)> myContract.set(2)
{ tx:
   '0xa21783ecb6fcc6d69d8707b293a394ec7c630ccb51df288becd8a282eccaa512',
  receipt:
   { transactionHash:
      '0xa21783ecb6fcc6d69d8707b293a394ec7c630ccb51df288becd8a282eccaa512',
     transactionIndex: 0,
     blockHash:
      '0x16beb8af590b0e27c1e260432ee7ce7c16674cbcf5735cfe7f78ec68d24099c4',
     blockNumber: 273,
     from: '0xf500dfd70aaea58cc75ee657e7cbdf7282cc92fc',
     to: '0x6684f937f2182801bcdaf4e87b58a75c35d399be',
     gasUsed: 41706,
     cumulativeGasUsed: 41706,
     contractAddress: null,
     logs: [],
     status: true,
     logsBloom:
      '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
     v: '0x1b',
     r:
      '0x6c8e0f2ba433796334322f54faa462487ba2d91030b40b323c21ae96253d0890',
     s:
      '0x7b7c0269b7e61e1d077ec39237c2da249384fce03e199f7dc4b9ea66736725a0',
     rawLogs: [] },
  logs: [] }
truffle(development_trf)> myContract.get()
<BN: 2>

登録されているみたいです。

次にcreateを実行してみます。
これでプロキシコントラクトが作成され、先ほどのコントラクトが参照されるのだと思います。

$ npx zos create MyContract --init initialize --args 42
✓ Compiling contracts with Truffle, using settings from truffle.js file
Truffle output:

Compiling your contracts...
===========================
> Compiling ./contracts/MyContract.sol
> Compiling zos-lib/contracts/Initializable.sol
> Artifacts written to /share/test/build/contracts
> Compiled successfully using:
   - solc: 0.5.8+commit.23d335f2.Emscripten.clang


? Pick a network development_trf
All contracts are up to date
✓ Setting everything up to create contract instances
✓ Instance created at 0x9dcee86c9F0B308cE3d580391174f406a9369563
0x9dcee86c9F0B308cE3d580391174f406a9369563

デプロイ完了です。
zos.dev-5777.jsonの差分を見てみます。(※差分以外は省略しています。)

zos.dev-5777.json
  "proxies": {
    "test/MyContract": [
      {
        "address": "0x9dcee86c9F0B308cE3d580391174f406a9369563",
        "version": "0.1.0",
        "implementation": "0x6684f937f2182801bcDAF4E87b58A75C35D399Be",
        "kind": "Upgradeable"
      }
    ]
  },
  "zosversion": "2.2",
  "version": "0.1.0",
  "proxyAdmin": {
    "address": "0x1374a26FF0cFCA967a3A9a8AC5EcB19520055De7"
  }

proxies以降の部分に値が追加されました。

addressがプロキシのアドレス。
implementationが先ほどデプロイしたコントラクトのアドレスみたいです。
proxyAdminがプロキシアドレス管理用のアカウントみたいです。ZeppelinOS ProxyAdmin

それでは実際に動かしてみます。
まずは、pushの時にデプロイしたコントラクトから。

truffle(development_trf)> myContract = await MyContract.at('0x6684f937f2182801bcDAF4E87b58A75C35D399Be')
undefined
truffle(development_trf)> myContract.get()
<BN: 2>

当たり前ですが、先ほどコンソールでsetした値が保持され続けています。
次にプロキシコントラクトを動かしてみます。

truffle(development_trf)> myContract2 = await MyContract.at('0x9dcee86c9F0B308cE3d580391174f406a9369563')
undefined
truffle(development_trf)> myContract2.get()
<BN: 2a>

initialize時に設定した値が設定されています。
プロキシアドレスの値を変えてみます。※set時のトランザクション部分は省略しています。

truffle(development_trf)> myContract2.set(111)
truffle(development_trf)> myContract2.get()
<BN: 6f>

上手く更新できました。
あたりまえですが、pushでデプロイしたコントラクトには影響はないです。

truffle(development_trf)> myContract.get()
<BN: 2>

アップグレードしてみる

アップグレードしてみます。
アップグレード内容としては、Functionを増やしただけです。

MyContract.sol
pragma solidity ^0.5.0;

import "zos-lib/contracts/Initializable.sol";

contract MyContract is Initializable {
  uint256 public x;

  function initialize(uint256 _x) initializer public {
    x = _x;
  }

  function get() public view returns (uint256) {
    return x;
  }

  function set(uint256 _x) public {
    x = _x;
  }

  function get2() public view returns (uint256) {
    return x;
  }

  function set2(uint256 _x) public {
    x = _x;
  }
}

まずはpush。

npx zos push
✓ Compiling contracts with Truffle, using settings from truffle.js file
Truffle output:

Compiling your contracts...
===========================
> Compiling ./contracts/MyContract.sol
> Compiling zos-lib/contracts/Initializable.sol
> Artifacts written to /share/test/build/contracts
> Compiled successfully using:
   - solc: 0.5.8+commit.23d335f2.Emscripten.clang


? Pick a network development_trf
✓ Contract MyContract deployed
All contracts have been deployed

pushしたので、zos.dev-5777.jsonを見てみます。
※★マークが差分箇所

zos.dev-5777.json
{
  "contracts": {
    "MyContract": {
      "address": "0xEEBE33b05E2a254Cc9dD5c3DD53Cd3B2723F5FbF",
      "constructorCode": "608060405234801561001057600080fd5b506102eb806100206000396000f3fe",
      "bodyBytecodeHash": "bfd43f1f360ee66693f641cd11005eafc65d5f06246f2de800069565d523728b",
      "localBytecodeHash": "bce1409ee0ff584a19c39e94b736cbc6a0e8bae6f4f846bfe326e3a4b72a071b",
      "deployedBytecodeHash": "bce1409ee0ff584a19c39e94b736cbc6a0e8bae6f4f846bfe326e3a4b72a071b",
      "types": {
        "t_bool": {
          "id": "t_bool",
          "kind": "elementary",
          "label": "bool"
        },
        "t_uint256": {
          "id": "t_uint256",
          "kind": "elementary",
          "label": "uint256"
        },
        "t_array:50<t_uint256>": {
          "id": "t_array:50<t_uint256>",
          "valueType": "t_uint256",
          "length": "50",
          "kind": "array",
          "label": "uint256[50]"
        }
      },
      "storage": [
        {
          "contract": "Initializable",
          "path": "zos-lib/contracts/Initializable.sol",
          "label": "initialized",
          "astId": 59,
          "type": "t_bool",
          "src": "757:24:1"
        },
        {
          "contract": "Initializable",
          "path": "zos-lib/contracts/Initializable.sol",
          "label": "initializing",
          "astId": 61,
          "type": "t_bool",
          "src": "876:25:1"
        },
        {
          "contract": "Initializable",
          "path": "zos-lib/contracts/Initializable.sol",
          "label": "______gap",
          "astId": 117,
          "type": "t_array:50<t_uint256>",
          "src": "1951:29:1"
        },
        {
          "contract": "MyContract",
          "path": "contracts/MyContract.sol",
          "label": "x",
          "astId": 6,
          "type": "t_uint256",
          "src": "118:16:0"
        }
      ],
      "warnings": {
        "hasConstructor": false,
        "hasSelfDestruct": false,
        "hasDelegateCall": false,
        "hasInitialValuesInDeclarations": false,
        "uninitializedBaseContracts": [],
        "storageUncheckedVars": [],
        "storageDiff": []
      }
    }
  },
  "solidityLibs": {},
  "proxies": {
    "test/MyContract": [
      {
        "address": "0x9dcee86c9F0B308cE3d580391174f406a9369563",
        "version": "0.1.0",
        "implementation": "0x6684f937f2182801bcDAF4E87b58A75C35D399Be",
        "kind": "Upgradeable"
      }
    ]
  },
  "zosversion": "2.2",
  "version": "0.1.0",
  "proxyAdmin": {
    "address": "0x1374a26FF0cFCA967a3A9a8AC5EcB19520055De7"
  }
}

新たにデプロイしたので、MyConstract部分が変更されました。
astId部分はStrageのIDっぽいですが、使われ方がよくわかりません。

次にupdateをしていきます。

$ npx zos update MyContract
? Pick a network development_trf
✓ Compiling contracts with Truffle, using settings from truffle.js file
Truffle output:

Compiling your contracts...
===========================
> Compiling ./contracts/MyContract.sol
> Compiling zos-lib/contracts/Initializable.sol
> Artifacts written to /share/test/build/contracts
> Compiled successfully using:
   - solc: 0.5.8+commit.23d335f2.Emscripten.clang


All contracts are up to date
? Do you want to call a function on the instance after upgrading it? No
✓ Instance upgraded at 0x9dcee86c9F0B308cE3d580391174f406a9369563. Transaction receipt: 0x0076479db3f80f6ce8ea8fc683d475bfbfd3f4e6aa733ccd13635a582a8b0982

上手くアップデートできました。
アップデートの最後にアップデートしたインスタンスの動作確認もできるみたいです。(私はnoでしませんでした。)

zos.dev-5777.jsonの差分を見ていきます。

zos.dev-5777.json
"implementation": "0xEEBE33b05E2a254Cc9dD5c3DD53Cd3B2723F5FbF",

一か所だけです。プロキシが呼び出すコントラクトがpushでデプロイしたコントラクトに変更されたみたいです。
では、実際に動作させていきます。

myContract1が初めにデプロイしたコントラクト
myContract2が今回update時にデプロイしたコントラクト
myContractproがプロキシコントラクト
です。

npx truffle console --network development_trf
truffle(development_trf)> myContract1 = await MyContract.at('0x6684f937f2182801bcDAF4E87b58A75C35D399Be')
undefined
truffle(development_trf)> myContract2 = await MyContract.at('0xEEBE33b05E2a254Cc9dD5c3DD53Cd3B2723F5FbF')
undefined
truffle(development_trf)> myContractpro = await MyContract.at('0x9dcee86c9F0B308cE3d580391174f406a9369563')
undefined
truffle(development_trf)> myContract1.get()
<BN: 2>
truffle(development_trf)> myContract2.get()
<BN: 0>
truffle(development_trf)> myContractpro.get()
<BN: 6f>

プロキシコントラクト(myContractpro)を見ると、アップデート前に更新された値が正しく返却されていることがわかります。
myContract2については、setしていないため初期値です。

次にFunctionを見ていきます。

truffle(development_trf)> myContract1.get2()
Error: Returned error: VM Exception while processing transaction: revert
    at XMLHttpRequest._onHttpResponseEnd (/share/test/node_modules/truffle/build/webpack:/~/xhr2-cookies/dist/xml-http-request.js:318:1)
    at XMLHttpRequest._setReadyState (/share/test/node_modules/truffle/build/webpack:/~/xhr2-cookies/dist/xml-http-request.js:208:1)
    at XMLHttpRequestEventTarget.dispatchEvent (/share/test/node_modules/truffle/build/webpack:/~/xhr2-cookies/dist/xml-http-request-event-target.js:34:1)
    at XMLHttpRequest.request.onreadystatechange (/share/test/node_modules/truffle/build/webpack:/~/web3-providers-http/src/index.js:96:1)
    at /share/test/node_modules/truffle/build/webpack:/packages/truffle-provider/wrapper.js:112:1
    at /share/test/node_modules/truffle/build/webpack:/~/web3-core-requestmanager/src/index.js:140:1
    at Object.ErrorResponse (/share/test/node_modules/truffle/build/webpack:/~/web3-core-requestmanager/~/web3-core-helpers/src/errors.js:29:1)
truffle(development_trf)> myContract2.get2()
<BN: 0>
truffle(development_trf)> myContractpro.get2()
<BN: 6f>
truffle(development_trf)>

strageの値を保持したままFunctionが追加されていること(アップデートされていること)が確認できました!

トランザクション発行者を見てみる

外部コントラクトをCallするFunctionとCallされるコントラクトを作成して、発行者が誰か見てみます。
Proxyコントラクトがdelegatecallしてロジック呼び出してCallするので、発行者はProxyコントラクトアドレスになるはずです。

MyContract.sol
pragma solidity ^0.5.0;

import "zos-lib/contracts/Initializable.sol";

contract MyContract is Initializable {

==省略==================

  function call2(address _address) public{
    _address.call(abi.encodeWithSignature("setSender()"));
  }
}
CallContract.sol
pragma solidity ^0.5.0;

import "zos-lib/contracts/Initializable.sol";

contract CallContract is Initializable {

  address public msgSender;

  function setSender() public {
    msgSender = msg.sender;
  }

  function get() public view returns (address) {
    return msgSender;
  }
}

やってみます。確認に使うコントラクトはどちらもProxyコントラクトです。

$ npx truffle console --network development_trf
truffle(development_trf)> Caller = await MyContract.at('0x9dcee86c9F0B308cE3d580391174f406a9369563')
undefined
truffle(development_trf)> Call = await CallContract.at('0xdD4Bf0c508fD2EfF2336056dd7d596726b865b81')
undefined
truffle(development_trf)> Call.get()
'0xF500dFd70aaEa58CC75eE657e7cBDF7282Cc92fc'
truffle(development_trf)> Caller.call2('0xdD4Bf0c508fD2EfF2336056dd7d596726b865b81')
{ tx:
   '0x431c2c0a612423be942362412ee6c4eb0bc5b958fef9945af744ac84174b4fb5',
  receipt:
   { transactionHash:
      '0x431c2c0a612423be942362412ee6c4eb0bc5b958fef9945af744ac84174b4fb5',
     transactionIndex: 0,
     blockHash:
      '0x978cb062f1e3536683c3c90607a22fb2c88dfc097681fa96e18998f41e96b798',
     blockNumber: 300,
     from: '0xf500dfd70aaea58cc75ee657e7cbdf7282cc92fc',
     to: '0x9dcee86c9f0b308ce3d580391174f406a9369563',
     gasUsed: 32593,
     cumulativeGasUsed: 32593,
     contractAddress: null,
     logs: [],
     status: true,
     logsBloom:
      '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
     v: '0x1b',
     r:
      '0x3a3adeb65f9faf1eb6971b6d3ee66f76695cbf836bae2e60396cee5ddb30d521',
     s:
      '0x68f0d8ec3f794a9221154caf66b72c748f44f610b8cc7b07a1c4040ed0353ed2',
     rawLogs: [] },
  logs: [] }
truffle(development_trf)> Call.get()
'0x9dcee86c9F0B308cE3d580391174f406a9369563'

想定していた通り、発行者はプロキシコントラクトになっていました。

まとめ

アップグレード可能なZeppelinOSを試してみました。
導入自体は簡単にできましたが、
アップグレード前のコントラクトもブロックチェーン上では生き続けるので注意が必要だと感じました。

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3