DenoでイケハヤのNFTを監視する対話型CLIを作る

ここ数年でブロックチェーンが一般的な技術として広まりつつあり、その活用例として最近ではNFTがよく話題になっています。
そして一部のインフルエンサーもこのNFTに目をつけ始め、中には最古のNFTと言われるCryptoPunksを数千万で購入したというインフルエンサーも出てきています。


今回は、Denoを使ってその最古のNFTである[CryptoPunks](https://www.larvalabs.com/cryptopunks)の売買を検知する対話型のCLIを作成してみようと思います。
サンプルとして、イケハヤ氏が3000万円で購入したというCryptoPunks#2280を監視してみます。

ちなみにイケハヤ氏が購入したCryptoPunksがこちら。
[<img width=300 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/e19e0efc-d81f-5169-539e-6c88102a844f.png" />](https://etherscan.io/tx/0xca83dce1cdb1a31241e3399608c163fe6c05a3366e7d1e7cd5ffc16bc61179ea)


## 実装方針

今回Denoで、実装するアプリケーションは、

1. 対話型CLIで監視したい対象のウォレットアドレスを受け付ける
1. CryptoPunksのTransferイベントを監視する
1. Transferイベントを検知したらLine Notifyで通知する

という3つの機能を持つことにします。
またこれらを実装するにあたり、Denoの下記の機能を利用します。

1. CDNを通じてnpmモジュールであるethers.jsを利用する
1. 標準機能のfetchでLine Notifyを呼び出す
1. DI(依存性注入)を導入して上記1と2の機能を疎結合にする。

## 1. Ethers.jsでイベントを監視する

ether.jsはDenoのモジュールとして提供されていないので、npmモジュールを活用します。
Denoでは、CDNからモジュールをimportして利用する仕組みがあるので、それを利用することでnpmモジュールをDenoで使うことができる様になります。

```typescript
import { ethers } from "https://esm.sh/ethers?dts";
```

URLに`?dts`をつけると、型の定義も一緒にダウンロードされます。
イベントの監視方法は、Node.jsで実装する場合と同じです。
あとでDIを導入するので、Classを用いて下記の様に書きます。

```typescript:main.ts
const abi = [
  "event Transfer(address indexed from, address indexed to, uint256 value)",
  "event PunkTransfer(address indexed from, address indexed to, uint256 punkIndex)",
  "event PunkOffered(uint indexed punkIndex, uint minValue, address indexed toAddress)",
];
const contractAddress = "0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB";

export class Main {
  private readonly provider: ethers.providers.WebSocketProvider;
  private readonly contract: ethers.Contract;

  constructor() {
    this.provider = new ethers.providers.WebSocketProvider(`wss://mainnet.infura.io/ws/v3/${/* <your token>*/ } `);
    this.contract = new ethers.Contract(contractAddress, abi, this.provider);
  }

  async run(address?: string): Promise<void> {
    console.log("blockNumber:", await this.provider.getBlockNumber());
    console.log("target:", address || "none");

    this.contract.on(
      "PunkTransfer",
      (from, to, punkIndex, { transactionHash }) => {
        const msg = `PunkTransfer: #${punkIndex} was transferd from ${from} to ${to}.`;
        if (
          !address ||
          address.toLocaleLowerCase() === from.toLocaleLowerCase()
        ) {
          // Send notify here.
        }
      }
    );
  }
}
```

ちゃんとネットワーク上から情報が取れていることを確認するため、`getBlockNumber`メソッドでblockNumberを取得してコンソールに表示しています。
ちなみにCryptoPunksは最古のNFTということで通常のNFTの規格であるERC721とは若干異なりトークン自体にマーケットの機能があったりします。
なので、`PunkTransfer`や`PunkOffered`などイベントのabiを用意し、それらを監視しています。

またここではINFURAを使ってEthereumのメインネットにアクセスしていますが、INFURAのアクセストークンはこちらよりアカウントを作成し、取得する必要があります。
https://infura.io/

## 2. Line Notifyで通知を送る

Line Notifyはhttpリクエストを送るだけで利用できるので、下記の様に実装して任意のテキストと画像を送信できる様にします。

```typescript:services/line-service.ts
export class LineService {
  async sendNotify(params: {
    message: string;
    imageThumbnail?: string;
    imageFullsize?: string;
  }): Promise<void> {
    await fetch("https://notify-api.line.me/api/notify", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${/* your token */}`,
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams(params),
    });

    console.log(JSON.stringify(params, null, 2));
  }
}
```

Line Norifyのアクセストークンは下記リンクから取得できます。
https://notify-bot.line.me/ja/

## 3. DIを導入する

Denoの`inject`というモジュールを利用してDIを導入します。
先程のLineServiceは以下の様になります。

```typescript:services/line-service.ts
import { Injectable } from "https://deno.land/x/inject@v0.1.2/mod.ts";

@Injectable()
export class LineService {
  // :
}
```

呼び元のMainクラスはDIの導入と併せて`LineService.sendNotify`を呼び出し、下記の様に修正します。

```typescript:main.ts
import { Bootstrapped } from "https://deno.land/x/inject@v0.1.2/mod.ts";　// ここを追加
import { LineService, WatchingService } from "./services/line-service.ts"; 　// ここを追加

// :

@Bootstrapped()　// ここを追加
export class Main {
  private readonly provider: ethers.providers.WebSocketProvider;
  private readonly contract: ethers.Contract;

  constructor(private readonly lineService: LineService) {　// ここを更新
    this.provider = new ethers.providers.WebSocketProvider(`wss://mainnet.infura.io/ws/v3/${/* <your token>*/ } `);
    this.contract = new ethers.Contract(contractAddress, abi, this.provider);
  }

  async run(address?: string): Promise<void> {
    console.log("blockNumber:", await this.provider.getBlockNumber());
    console.log("target:", address || "none");

    this.contract.on(
      "PunkTransfer",
      (from, to, punkIndex, { transactionHash }) => {
        const msg = `PunkTransfer: #${punkIndex} was transferd from ${from} to ${to}.`;
        if (
          !address ||
          address.toLocaleLowerCase() === from.toLocaleLowerCase()
        ) {
          this.sendNotify(msg, transactionHash, punkIndex);　// ここを追加
        }
      }
    );
  }

  // ここ以降を追加
  private async sendNotify(
    text: string,
    transactionHash: string,
    punkIndex: number
  ): Promise<void> {
    const punkIndexStr = punkIndex.toString().padStart(4, "0");
    const url = `https://www.larvalabs.com/public/images/cryptopunks/punk${punkIndexStr}.png`;
    const message = `${text} \nPlease refer to https://etherscan.io/tx/${transactionHash}`;

    await this.lineService.sendNotify({
      message,
      imageFullsize: url,
      imageThumbnail: url,
    });
  }
}
```

併せてTypeScriptでデコレーターを利用できるようにするために、`tsconfig.json`に下記の設定を追加します。

```json:tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
```

## 4. 対話型CLIで監視対象のウォレットアドレスを受け取る

Denoの`ask`というモジュールを利用して対話型CLIを実装します。
`ask`ではaddressという名称でウォレットアドレスを受け取り、併せてそのタイミングで入力されたアドレスが正しいこともチェックします。
またウォレットアドレスを入力しなかった場合は、全てのウォレットを対象にイベントを監視する様にします。

```typescript:app.ts
import Ask from "https://deno.land/x/ask@1.0.6/mod.ts";
import { bootstrap } from "https://deno.land/x/inject@v0.1.2/mod.ts";

import { Main } from "./main.ts";

const ask = new Ask();
const answer = await ask.prompt([
  {
    name: "address",
    type: "input",
    message: "target address",
    validate: (val) => val === "" || (!!val && ethers.utils.isAddress(val)),
  },
]);

const main = bootstrap(Main);
await main.run(String(answer.address));
```

## 5. 実行する

外部のURLを呼び出すので`allow-net`オプションを指定した上で、app.tsを呼び出します。
下記コマンドを実行するとウォレットアドレスの入力が求めれます。

```bash
$ deno run --allow-net=notify-api.line.me,mainnet.infura.io -c tsconfig.json ./app.ts
? target address 0xE545c0D41c622bCCCc8f103c0607D042A2242350
blockNumber: 13703306
target: 0xE545c0D41c622bCCCc8f103c0607D042A2242350
```
ウォレットアドレスを入力すると、対象のウォレットアドレスのイベント監視が始まります。
実際に受け取る通知はこんな感じになっています。
<img width=200 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/b6b070a0-9306-71e8-105f-a9551fed0fe0.png" />

## 6. おまけ

このままだとCLI的に監視している感がないため、簡単なアニメーションをコンソールに表示する`WatchingService`を作成します。
アニメーション実装にあたりDenoの`cursor`というモジュールを利用し、`now watching...`と表示させた上で`.`が時間とともに0~3つに増減するようにします。

```typescript:watching-service.ts
import { Injectable } from "https://deno.land/x/inject@v0.1.2/mod.ts";
import {
  clearRight,
  goLeft,
  nextLine,
} from "https://denopkg.com/iamnathanj/cursor@v2.2.0/mod.ts";

@Injectable()
export class WatchingService {
  private loading?: number;

  start(): void {
    let i = 0;
    Deno.stdout.writeSync(new TextEncoder().encode("\x1b[36m> \x1b[0m"));
    Deno.stdout.writeSync(new TextEncoder().encode("now watching"));

    this.loading = setInterval(async () => {
      if (i % 4 === 3) {
        await goLeft(3);
        await clearRight();
      } else {
        Deno.stdout.writeSync(new TextEncoder().encode("."));
      }
      i++;
    }, 500);
  }

  async stop(): Promise<void> {
    if (this.loading) {
      clearInterval(this.loading);
      await nextLine();
    }
  }
}
```

このサービスを、`Main`クラスで呼び出してみます。

```typescript:main.ts
import { Bootstrapped } from "https://deno.land/x/inject@v0.1.2/mod.ts";
import { LineService, WatchingService } from "./services/mod.ts"; // ここを更新

// :

@Bootstrapped()
export class Main {
  private readonly provider: ethers.providers.WebSocketProvider;
  private readonly contract: ethers.Contract;

  constructor(
    private readonly watchingService: WatchingService,　// ここを追加
    private readonly lineService: LineService
  ) {
    this.provider = new ethers.providers.WebSocketProvider(`wss://mainnet.infura.io/ws/v3/${/* <your token>*/ } `);
    this.contract = new ethers.Contract(contractAddress, abi, this.provider);
  }

  async run(address?: string): Promise<void> {
    console.log("blockNumber:", await this.provider.getBlockNumber());
    console.log("target:", address || "none");

    // :

    this.watchingService.start();　// ここを追加
  }

  private async sendNotify(
    text: string,
    transactionHash: string,
    punkIndex: number
  ): Promise<void> {
    const punkIndexStr = punkIndex.toString().padStart(4, "0");
    const url = `https://www.larvalabs.com/public/images/cryptopunks/punk${punkIndexStr}.png`;
    const message = `${text} \nPlease refer to https://etherscan.io/tx/${transactionHash}`;

    await this.watchingService.stop();　// ここを追加
    await this.lineService.sendNotify({
      message,
      imageFullsize: url,
      imageThumbnail: url,
    });
    this.watchingService.start();　// ここを追加
  }
}
```

ちなみに上記のコード内で、サービスを読み込む際に

```typescript
import { LineService, WatchingService } from "./services/mod.ts";
```

としています。
それはつまり

```typescript:mod.ts
export * from "./line-service.ts";
export * from "./watching-service.ts";
```

というファイルを用意していることになります。
Denoでこういったファイルは、Node.jsのようにファイル名をindex.tsとするのではなく、Rustに倣ってmod.tsとするのが[こちら](https://deno.land/manual@v1.11.3/contributing/style_guide#do-not-use-the-filename-codeindextscodecodeindexjscode)の公式ドキュメントでは推奨されている様です。

これでアニメーションの実装は完了です。
コマンドを実行するとこんな感じになります。
![nft-tracker.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/205ba041-fc0b-6756-ec36-a6c76ca5cef0.gif)

## おわり
これであとはイケハヤ氏がCryptoPunks#2280を売買するのを待つばかりです。
今回作成したコードはこちら。
https://github.com/biga816/nft-tracker