Uniswap V3 API(Subgraph)とGASを使ってGoogle SheetsにUnclaimed Feesを自動定期収集する

どうも、初めましての投稿になります。imai@株式会社SaaSisです。
普段は新設の工場で、システム立ち上げのためのPMやトラブルシューティングなどを仕事にしています。

今回は仕事から離れて、Ethereum DeFiアプリケーション Uniswap V3をプライベートで使用していてちょっと不便に感じた部分を、簡単なプログラミングで効率化する方法をゆるい感じで紹介します。

なお、当記事ではUniswap自体の仕組み等については詳しく説明しません。
投資はくれぐれも自己責任でお願い致します🥺

## 動機

Uniswap V3でLP（流動性提供）を行うと、`Unclaimed fees`（未請求の報酬）という手数料をGETできる。
[参考URL(私の保有しているPoolではなくランダムのものです)](https://app.uniswap.org/#/pool/128209)
この値、流動性を提供している通貨ペアで`Swap`（両替）がある度に増えるのだが、私の知る限り今現在の保有額しか確認できない。
この保有額の推移を自動で取得・記録し、グラフで表示できないかと考えた。

## 前提

* Uniswap V3でLPを行なっていること
* Googleアカウントを持っていること（無料のものでもOKと思われるが、未検証なので注意）
* GraphQL、Google Apps Script、Google Sheetsに関する知識があること

## 手順


### SubgraphのPlaygroundページでクエリを試す
Uniswap V3ではクエリにGraphQLを使用する[Subgraph API](https://thegraph.com/)が無料公開されている。
まずはこれを使って値の取得を試みる。

[Uniswap V3のAPI Playgroundページ](https://thegraph.com/legacy-explorer/subgraph/uniswap/uniswap-v3)

上記ページの下部に、GraphQLをクエリのみで簡単に試すことのできるPlayground画面がある。
ページに初めてアクセスしたときはデフォルトの`Example query`になっているはずなので、中央ペイン右上の▶︎ボタンを押す。
クエリが実行され、その結果が中央ペインに出力されることを確認。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2066108/b11a797d-ae9a-25f6-eeef-b2fb2c8466f5.png)

もちろん、この情報はなんの役にも立たないので、クエリを変更する必要がある。
LPに関する情報は`Position`パラメータで取ることができる。
（クエリのスキーマパラメータ詳細についてはあまりドキュメントが整備されていない為、ここで少しハマった）

```GraphQL
{
  position(id: {id}) { # {id}に自分が所持しているPoolのIDを入れる（ex. 128209） 
    token0{ # ペアとなる通貨（1つ目）
      id
      symbol # 通貨のシンボル（ex. USDC）
      name
      decimals # Unclaimed feesの元となる数値。後述の計算に使用する
    }
    token1{ # ペアとなる通貨（2つ目）
      id
      symbol
      name
      decimals
    }
    pool {
      feeGrowthGlobal0X128 # 後述の計算に使用する
      feeGrowthGlobal1X128 
    }
    liquidity
    feeGrowthInside0LastX128 # 後述の計算に使用する
    feeGrowthInside1LastX128
    tickLower {
      feeGrowthOutside0X128 # 後述の計算に使用する
      feeGrowthOutside1X128
    }
    tickUpper {
      feeGrowthOutside0X128 # 後述の計算に使用する
      feeGrowthOutside1X128
    }
  }
}
```
2行目の`{id}`以外は、上記クエリを編集せずコピペで実行して構わない。
大きな数値がずらっと中央ペインに並べば、クエリは成功。

### Unclaimed feesを計算するための数式を用意する

ここら辺は私も自信がないのだが、語義的に小数であるべきdecimalsの値が非常に大きな整数値になっている辺りを見ると、本来の小数値に変換する必要があるらしい。
詳しい説明は
分からんので
省き、[こちらの回答](https://ethereum.stackexchange.com/a/109484)を参考にする。

```javascript
feetoken0 = ((feeGrowthGlobal0X128 - feeGrowthOutside0X128_lower - feeGrowthOutside0X128_upper - feeGrowthInside0LastX128)/(2**128))*liquidity/(1*10**decimals)
```

ただし、javascript上では`2^53 - 1`を超える整数を扱うとエラーを吐いてしまうので、上記コードに加えてそれぞれの計算に`BigInt`を使う。

### GASにスクリプトを書く

幸い、`BigInt`はGASでも使用可能。
コーディングの流れは:

1. `UrlFetchApp.fetch(url, options)`でデータを取得
1. それぞれの値をBigIntに変換し計算（BigIntはBigInt同士でないと四則演算ができないので注意）
1. 日付とともに対象のスプレッドシートセルへ値を出力

下記、2つの`Pool`を取得してセルに出力するサンプルコード。
（かなりやっつけで書いてます。汚くて申し訳ないです🥺）

```javascript
const ss = SpreadsheetApp.openById("YOUR_SHEET_ID");
// url, idsのベタ書きを避けたい場合: https://developers.google.com/apps-script/guides/properties
const url = "https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3";
const ids = [
  YOUR_POOL_ID1,
  YOUR_POOL_ID2
];

function generateOptions(id) {
  const queryString = `{
    position(id: ${id}) {
      token0{
        id
        symbol
        name
        decimals
      }
      token1{
        id
        symbol
        name
        decimals
      }
      pool {
        feeGrowthGlobal0X128
        feeGrowthGlobal1X128
      }
      liquidity
      feeGrowthInside0LastX128
      feeGrowthInside1LastX128
      tickLower {
        feeGrowthOutside0X128
        feeGrowthOutside1X128
      }
      tickUpper {
        feeGrowthOutside0X128
        feeGrowthOutside1X128
      }
    }
  }`;
  const options = {
    method: "POST",
    contentType: "application/json",
    payload: JSON.stringify({ query: queryString })
  }
  return options;
}

function generateDailyDataOptions(ids) {
  const queryString = `{
  tokenHourDatas(first:2, orderBy: periodStartUnix, orderDirection: desc, where: {token_in: ["${ids[0]}", "${ids[1]}"]}) {
    periodStartUnix
    id
    priceUSD
    token{
      id
      symbol
    }
  }
}`
  const options = {
    method: "POST",
    contentType: "application/json",
    payload: JSON.stringify({ query: queryString })
  }
  return options;
}

function main() {


  for (const id of ids) {
    const json = fetchDeFiData(url, generateOptions(id));
    WriteConvertedValues(json);
  }
}

function fetchDeFiData(url, options) {
  try {
    const res = UrlFetchApp.fetch(url, options);
    const json = JSON.parse(res.getContentText());
    return json;
  } catch (e) {

  }
}

function WriteConvertedValues(json) {
  const position = json.data.position;

  const feeGrowthGlobal0X128 = BigInt(position.pool.feeGrowthGlobal0X128);
  const feeGrowthInside0LastX128 = BigInt(position.feeGrowthInside0LastX128);
  const feeGrowthOutside0X128_lower = BigInt(position.tickLower.feeGrowthOutside0X128);
  const feeGrowthOutside0X128_upper = BigInt(position.tickUpper.feeGrowthOutside0X128);
  const liquidity = BigInt(position.liquidity);

  const internalCalc0 = feeGrowthGlobal0X128 - feeGrowthOutside0X128_lower - feeGrowthOutside0X128_upper - feeGrowthInside0LastX128;
  const decimals0 = position.token0.decimals;
  const feetoken0 = parseFloat(internalCalc0 * liquidity / BigInt(2 ** 128)) / (1 * 10 ** decimals0);
  const tokenName0 = position.token0.name;
  const symbol0 = position.token0.symbol;
  const id0 = position.token0.id;

  const feeGrowthGlobal1X128 = BigInt(position.pool.feeGrowthGlobal1X128);
  const feeGrowthInside1LastX128 = BigInt(position.feeGrowthInside1LastX128);
  const feeGrowthOutside1X128_lower = BigInt(position.tickLower.feeGrowthOutside1X128);
  const feeGrowthOutside1X128_upper = BigInt(position.tickUpper.feeGrowthOutside1X128);

  const internalCalc1 = feeGrowthGlobal1X128 - feeGrowthOutside1X128_lower - feeGrowthOutside1X128_upper - feeGrowthInside1LastX128;
  const decimals1 = position.token1.decimals;
  const feetoken1 = parseFloat(internalCalc1 * liquidity / BigInt(2 ** 128)) / (1 * 10 ** decimals1);
  const tokenName1 = position.token1.name;
  const symbol1 = position.token1.symbol;
  const id1 = position.token1.id;

  const dailyJson = fetchDeFiData(url, generateDailyDataOptions([id0, id1]));
  const tokenHourDatas = dailyJson.data.tokenHourDatas;
  const priceUSD0 = tokenHourDatas[0].id === id0 ? tokenHourDatas[0].priceUSD : tokenHourDatas[1].priceUSD;
  const priceUSD1 = tokenHourDatas[1].id === id1 ? tokenHOurDatas[1].priceUSD : tokenHourDatas[0].priceUSD;

  const sheet = ss.getSheetByName('DeFi');
  const targetRow = sheet.getRange(1, 1).getNextDataCell(SpreadsheetApp.Direction.DOWN).getRow() + 1;

  const values = [
    [new Date().toLocaleDateString('ja-JP'), tokenName0, feetoken0, symbol0, priceUSD0, feetoken0 * priceUSD0],
    [new Date().toLocaleDateString('ja-JP'), tokenName1, feetoken1, symbol1, priceUSD1, feetoken1 * priceUSD1]
  ]

  const range = sheet.getRange(targetRow, 1, 2, 6);
  range.setValues(values);
}
```

### Google SheetsのQUERY関数やグラフを使ってデータを自動整形する & 定期実行のトリガーを設定する

割愛。
QUERY関数は濫用注意ではあるものの、すごく使えるのでものにしたい。

### 眺める

あぁ^〜癒されるんじゃぁ^〜😇

## まとめ

途中で時間が足りなくなったので、中途半端な形ではありますが一旦公開します。

飽きたからではない

後々機会があれば更新します！

## ほか、参考文献

https://ethereum.stackexchange.com/questions/99906/uniswap-v3-api-how-to-track-unclaimed-fees-programmatically