RestのNetworkFeesTransactionを算出してみる

<https://testnet1.symbol-mikun.net:3001/network/fees/transaction>
```json
{
  "averageFeeMultiplier": 137,
  "medianFeeMultiplier": 100,
  "highestFeeMultiplier": 10000,
  "lowestFeeMultiplier": 0,
  "minFeeMultiplier": 10
}
```
これを計算してみます。

## どういう計算なのか

デフォルト設定のRESTでは、直近300ブロックから求めています。ただ、トランザクションがない場合、手数料乗数がゼロになるので平均と中央値は、ゼロの場合はネットワークプロパティにある`defaultDynamicFeeMultiplier`の値(100)に置き換えて計算しています。`minFeeMultiplier`はノードプロパティで設定しているノード固有値です。

## 計算する

RestじゃなくてPeerに直接聞いてみます。Peerからブロックを取得するとトランザクションまで付いてくるのでサイズが大きくなりますが、今のスカスカのブロックなら大丈夫でしょう。きっと。

### PacketBuffer

受信したパケットを読むためのクラスです。

```typescript:catapult/PacketBuffer.ts
export class PacketBuffer {
  private _length: number
  private _index: number

  constructor(private readonly _buffer: Buffer) {
    this._length = _buffer.length
    this._index = 0
  }

  addOffset(addOffset: number): number {
    this._index += addOffset
    return this._index
  }

  readUInt8(addOffset: number = 0): number {
    this.addOffset(addOffset)
    const readData = this._buffer.readUInt8(this._index)
    this._index += 1
    return readData
  }

  readUInt16LE(addOffset: number = 0): number {
    this.addOffset(addOffset)
    const readData = this._buffer.readUInt16LE(this._index)
    this._index += 2
    return readData
  }

  readUInt32LE(addOffset: number = 0): number {
    this.addOffset(addOffset)
    const readData = this._buffer.readUInt32LE(this._index)
    this._index += 4
    return readData
  }

  readBigUInt64LE(addOffset: number = 0): bigint {
    this.addOffset(addOffset)
    const readData = this._buffer.readBigUInt64LE(this._index)
    this._index += 8
    return readData
  }

  readString(length: number, addOffset: number = 0): string {
    this.addOffset(addOffset)
    const readData = this._buffer.toString('utf8', this._index, this._index + length)
    this._index += length
    return readData
  }

  readHexString(length: number, addOffset: number = 0): string {
    this.addOffset(addOffset)
    const readData = this._buffer.toString('hex', this._index, this._index + length)
    this._index += length
    return readData
  }

  get index(): number {
    return this._index
  }

  get length(): number {
    return this._length
  }
}
```

### SslSocket

SSLソケット通信する抽象クラスです。後述するCatapultクラスで継承して使用します。

```typescript:catapult/SslSocket.ts
import { X509Certificate } from 'node:crypto'
import { readFileSync } from 'node:fs'
import tls, { ConnectionOptions } from 'node:tls'

export abstract class SslSocket {
  private _connectionOptions: ConnectionOptions
  protected _x509Certificate: X509Certificate | undefined
  private tlsSocket: tls.TLSSocket | undefined
  private readonly SYMBOL_HEADER_SIZE = 8

  constructor(
    caCertPath: string,
    nodeCertPath: string,
    nodeKeyPath: string,
    host: string = '127.0.0.1',
    port: number = 7900,
    timeout: number = 3000
  ) {
    this._connectionOptions = {
      host,
      port,
      timeout,
      cert: Buffer.concat([
        readFileSync(nodeCertPath),
        readFileSync(caCertPath),
      ]),
      key: readFileSync(nodeKeyPath),
      rejectUnauthorized: false,
    }
  }

  protected async connect(): Promise<void> {
    // console.debug('socket connected.')
    this.tlsSocket = await new Promise<tls.TLSSocket>((resolve) => {
      const sock = tls.connect(this._connectionOptions, () => {
        resolve(sock)
      })
    })
  }

  protected async request(
    packetType: number,
    payload?: Uint8Array,
    isResponse = true
  ): Promise<Uint8Array | undefined> {
    // Symbolパケット生成
    const payloadSize = payload ? payload.length : 0
    const packetSize = this.SYMBOL_HEADER_SIZE + payloadSize
    const symbolPacketBuffer = new ArrayBuffer(packetSize)
    // Symbolヘッダー編集
    const symbolHeader = new DataView(symbolPacketBuffer)
    symbolHeader.setUint32(0, packetSize, true)
    symbolHeader.setUint32(4, packetType, true)
    // Symbolペイロード編集
    if (payload) {
      const symbolPayload = new Uint8Array(
        symbolPacketBuffer,
        this.SYMBOL_HEADER_SIZE,
        payloadSize
      )
      symbolPayload.set(payload)
    }
    // 接続確認
    if (!this.tlsSocket || this.tlsSocket.closed) await this.connect()
    if (!this.tlsSocket) throw Error('Failed to connect socket.')
    // Symbolパケット送信
    this.tlsSocket.write(new Uint8Array(symbolPacketBuffer))
    if (!isResponse) return // レスポンスなしの場合
    return await this.receiver(this.tlsSocket, packetType) // レスポンスありの場合
  }

  private async receiver(
    socket: tls.TLSSocket,
    packetType: number
  ): Promise<Uint8Array | undefined> {
    return new Promise<Uint8Array | undefined>((resolve, reject) => {
      if (this.tlsSocket === undefined) reject('tlsSocket undefined.')

      let responseSize = this.SYMBOL_HEADER_SIZE // ヘッダ分のサイズを前もって付与
      let responseData: Uint8Array | undefined = undefined

      // SSL接続
      socket.on(
        'secureConnect',
        () => (this._x509Certificate = socket.getPeerX509Certificate())
      )

      // データ受信
      socket.once('data', (data) => {
        // レスポンスデータ（ヘッダ）取得
        const nodeBufferView = Buffer.from(new Uint8Array(data).buffer)
        // レスポンスサイズチェック
        const responseDataSize = nodeBufferView.readUInt32LE(0)
        if (responseDataSize === 0) {
          socket.destroy()
          reject('Received data is empty.')
        }
        // レスポンスパケットタイプチェック
        const responsePacketType = nodeBufferView.readUInt32LE(4)
        if (responsePacketType !== packetType) {
          socket.destroy()
          reject(
            `Mismatch packet type: expect: ${packetType} actual: ${responsePacketType}`
          )
        }
        // ヘッダが問題なければデータ部取得
        socket.on('data', (data) => {
          const tempResponseData = new Uint8Array(data)
          responseSize += tempResponseData.length
          if (!responseData) {
            // 初回
            responseData = tempResponseData
          } else {
            // 連結
            const merged = new Uint8Array(
              responseData.length + tempResponseData.length
            )
            merged.set(responseData)
            merged.set(tempResponseData, responseData.length)
            responseData = merged
          }
          if (responseDataSize <= responseSize) {
            resolve(responseData)
          }
        })
      })
      // タイムアウト
      socket.on('timeout', () => {
        socket.destroy()
        console.debug(`socket timeout: ${packetType}`)
        reject('timeout')
      })
      // エラー
      socket.on('error', (error) => {
        socket.destroy()
        console.debug(`socket error: ${packetType}`)
        reject(error)
      })
      // 切断
      socket.on('close', () => {
        // console.debug(`socket close: ${packetType}`)
      })
    })
  }

  protected close() {
    if (this.tlsSocket && !this.tlsSocket.closed) {
      this.tlsSocket.end(new Uint8Array())
    }
  }
}
```

### Catapult

SSLソケット通信で実際にデータを取得するクラスです。パケットタイプは色々ありますが、今回使用するブロック高とブロック(レンジ読み)を取得する所だけ書いてます。

```typescript:catapult/Catapult.ts
import { models } from 'symbol-sdk/symbol'
import { PacketBuffer } from './PacketBuffer.js'
import { SslSocket } from './SslSocket.js'

class ChainStatistics {
  constructor(
    public height: bigint,
    public finalizedHeight: bigint,
    public scoreHigh: bigint,
    public scoreLow: bigint
  ) {}
  static deserialize(payload: Uint8Array) {
    const bufferView = new PacketBuffer(Buffer.from(payload))
    const height = bufferView.readBigUInt64LE()
    const finalizedHeight = bufferView.readBigUInt64LE()
    const scoreHigh = bufferView.readBigUInt64LE()
    const scoreLow = bufferView.readBigUInt64LE()
    return new ChainStatistics(height, finalizedHeight, scoreHigh, scoreLow)
  }
}

export class Catapult extends SslSocket {
  private PacketType = {
    CHAIN_STATISTICS: 0x0_05,
    PULL_BLOCKS: 0x0_08,
  }

  async getChainStatistics(isSocketClose: boolean = true) {
    let chainStatistics: ChainStatistics | undefined
    try {
      const socketData = await this.request(this.PacketType.CHAIN_STATISTICS)
      if (socketData) chainStatistics = ChainStatistics.deserialize(socketData)
      if (isSocketClose) this.close()
    } catch (e) {
      console.error(e)
    }
    return chainStatistics
  }

  async getBlocks(
    startHeight: bigint,
    count: number,
    maxSize: number,
    isSocketClose: boolean = true
  ) {
    const blocks: models.Block[] = []
    try {
      const payloadBuffer = new ArrayBuffer(16)
      const payloadDataView = new DataView(payloadBuffer)
      payloadDataView.setBigUint64(0, startHeight, true)
      payloadDataView.setInt32(8, count, true)
      payloadDataView.setInt32(12, maxSize, true)
      const socketData = await this.request(
        this.PacketType.PULL_BLOCKS,
        Buffer.from(payloadBuffer)
      )
      if (socketData) {
        let offset = 0
        do {
          const block = models.BlockFactory.deserialize(
            socketData.slice(offset)
          ) as models.Block
          blocks.push(block)
          offset += block.size
        } while (offset < socketData.length)
      }
      if (isSocketClose) this.close()
    } catch (e) {
      console.error(e)
    }
    return blocks
  }
}
```

### 証明書準備

SSLソケット通信のための証明書を作成します。

```bash
npm install -g simple-symbol-node-cert-cli
simple-symbol-node-cert-cli generate
```

パスワードは秘密鍵を暗号化して保存するためのものです。

### main

Catapultのコンストラクタに渡すノードホスト名は公開されているノードであれば、Peerでも何でも良いです。ちなみに、sakiaはPeerのみです。
現在ブロック高を取得して、そこから過去300ブロック分取得し、平均値、中央値、最大値、最長値を算出します。

```typescript:main.ts
import { Catapult } from './catapult/Catapult.js'

const catapult = new Catapult(
  'cert/ca.crt.pem',
  'cert/node.crt.pem',
  'cert/node.key.pem',
  'sakia.harvestasya.com'
)

// 平均
const ave = (arr: number[]) => arr.reduce((pre, cur) => pre + cur) / arr.length
// 中央値
const mdn = (arr: number[]) => {
  arr.sort()
  const mid = arr.length / 2
  return mid % 1 ? arr[mid - 0.5] : (arr[mid - 1] + arr[mid]) / 2
}
// 最大値
const max = (arr: number[]) => arr.reduce((pre, cur) => Math.max(pre, cur))
// 最小値
const min = (arr: number[]) => arr.reduce((pre, cur) => Math.min(pre, cur))

// Peerから現在のブロック高取得
const chainStatistics = await catapult.getChainStatistics(false) // ソケット開けたままにする
if (!chainStatistics) throw Error('no data.')
// Peerから300ブロック取得
const startHeight = chainStatistics.height - BigInt(299)
const blocks = await catapult.getBlocks(startHeight, 300, 1_048_576) // 1MiBで打ち切り

// 手数料乗数取得
const defaultDynamicFeeMultiplier = 100 // ネットワークプロパティにあるデフォルト値
const feeMultipliers = []
for (const block of blocks) {
  feeMultipliers.push(block.feeMultiplier.value as number)
}
// 0はdefaultDynamicFeeMultiplierの値に置き換え
const defaultedFeeMultipliers = feeMultipliers.map((val) =>
  val === 0 ? defaultDynamicFeeMultiplier : val
)
// 各種値算出
const averageFeeMultiplier = Math.floor(ave(defaultedFeeMultipliers))
const medianFeeMultiplier = Math.floor(mdn(defaultedFeeMultipliers))
const highestFeeMultiplier = max(feeMultipliers)
const lowestFeeMultiplier = min(feeMultipliers)

console.log(`averageFeeMultiplier: ${averageFeeMultiplier}`)
console.log(`medianFeeMultiplier : ${medianFeeMultiplier}`)
console.log(`highestFeeMultiplier: ${highestFeeMultiplier}`)
console.log(`lowestFeeMultiplier : ${lowestFeeMultiplier}`)
```