コンテンツへスキップ

ソロステーキングの税金計算(確定申告用のコード)

背景

イーサリアムのソロステーキングでは課税対象となり得る報酬が毎分のように発生しています。

受け取った報酬を一生売却しないのであれば、シンプルに足し算をすれば税金の計算ができます。
なんならこういったサイトで円換算の報酬履歴をワンクリックで出力できます。

ただ、少しでもETHを売却したり他の暗号資産と交換したら計算地獄が待ち受けています。
その理由が「ETHを売却する度に、保有している全ETHの平均取得単価を再計算しなければいけない」という税法上のルールがあるからです。

例えば、100ETHを保有していて平均取得単価が1万円だとします。
そして1ETHあたりの価格が10万円に上昇した時に1ETHをステーキング報酬として入手したとします。
この時点で1ETH(10万円)の雑所得が課税対象となります。ここまではシンプルですよね。

仮に報酬を受け取った直後に1ETHを売却した場合、「10万円で入手したから売買益は0円か」とはなりません。今まで持っていたETHと入手したETHは区別することができないので、入手時点で平均取得単価を再計算する必要があります。

( 100 ETH x 1万円 ) + ( 1 ETH x 10万円 ) = 110 万円
110 万円 / 101 ETH ≒ 1.09 万円 / 1 ETH

つまり、報酬の1ETHを売却した時の売買益は 10万円 – 1.09 万円 = 8.91 万円です。
トータルでは、10万円 + 8.91 万円 = 18.91 万円の雑所得が課税対象になります。

面倒くさいソロステーキングの税金計算が少しでも楽になるようにコードを書いてみました。

課税タイミングを考える

前提知識として、ソロステーキングの報酬は大きく分けて2種類に分類されます。

  • 常に貰える「証明報酬」:先ほど言った毎分のように発生する報酬とはこれにあたります。バリーデータ1つあたり6分に一度取引を証明する機会があるため、複数のバリデーターを運用していれば文字通り毎分のように証明報酬が発生します。ただし、報酬は即自由に動かせるわけではなく、自分が所有権を持つアドレスに出金されるのを待つ必要があります。バリデーター1つあたり約1週間に1度自分のアドレスに出金されるため、複数のバリデーターを運用していれば毎日のように自分のアドレスに振り込まれます。
  • ブロック生成時に貰える「ブロック生成報酬」:バリデーター1つあたり、平均して約4か月に1回ブロックを生成することができます。ブロック生成時には、ブロック生成報酬が証明報酬に加算されます。また、ユーザーが支払った取引手数料とMEV報酬に関しては、自分が所有権を持つアドレスに直接入金されます。

税務署から明確なガイドラインはありませんが課税タイミングについては2通りの考え方があり得ます。

  • 報酬が発生した時点を課税タイミングとする」:証明報酬もブロック生成報酬も、出金を待たずに報酬が発生した時点を課税タイミングとします。毎分のように報酬は発生してますが、分単位で計算するのは現実的ではないため日次でまとめます。こういったサイトを使えば税金計算が楽です。
  • 自分が所有権を持っているアドレスに出金された時点を課税タイミングとする」:報酬を自由に動かせるようになった時を課税タイミングとします。証明報酬とブロック生成報酬は約1週間に1度、ブロック生成したときの取引手数料とMEV報酬はブロック生成時に自分が所有権を持っているアドレスに出金されます。この課税タイミングで報酬を自動的に計算してくれるツールがないため今回作成しました。

自分的に一番しっくりくるのが2番目です。現に、僕は出金が可能になるイーサリアムのShapella(Shanghai + Capella)アップデートまでソロステーキングの報酬は確定申告していません。なぜなら、イーサリアムネットワークまたは自分に起因するミスによって出金が保証されていなかったからです。実現していない損益を申告するのは合理性に欠けると感じていました。出金が解禁されて報酬が自分のアドレスに送金されたら、それまで溜まっていた2年分の報酬を一気に確定申告するつもりです。(このやり方で税務署に刺されないと思いますが、刺されたら加算税を払えばいいだけです。)

あと、確定申告する際は、自分のアドレスの取引履歴を遡って漏れがないか確認する人が多いと思います。1番目のやり方だと、報酬が発生する日付が自分のアドレスに出金される日付とズレるので確認がしにくいです。

また、1番目のやり方だと、計算頻度が多すぎることに加え、マイナス損益も考慮する必要が出てきます。停電・インターネット遮断・ハードウェアの故障などの理由からバリデーターが半日以上オフラインになることは十分考えられます。僕は実際に過去2年間で数回サーバーに支障が出たことがあります。こういった複雑さを回避する意味でも、僕は2番目の出金された時点を課税タイミングにする方を選んでいます。

使うAPI

3つのAPIを活用して、それぞれから必要な情報を取得します。
いずれも無料のAPIですが、EtherscanBeaconcha.inは無料ユーザー登録が必要です。
Alpha Vantageはなぜかユーザー登録不要で一応入力するメールアドレスは架空でもOKです。

  • Etherscan API: 約1週間に1度出金される金額を取得します
  • Beaconchain API: ユーザーが支払った取引手数料とMEV報酬の金額を取得します
  • Alpha Vantage API: ETH/JPYの価格情報を取得します。無料で1年以上遡って取得できるAPIはここくらいしか見つかりませんでした。たった1回のAPIリクエストで1000日分取得できるのもよきです。

APIでデータを取得したら後は表計算をするだけです。

コードの説明

GitHubのリポジトリを公開しようと思いましたが、自分のアドレス等を誤って保存してしまったためコードのみを公開します。

  1. 必要なライブラリをインストールします。
pip install pyyaml requests pandas
  1. 以下のようなconfig.yamlを作成します。
    自分のAPIキー、アドレス、バリデーター番号を入力してください。
    addressvalidator_indexは好きなだけ羅列してOKです。
api_key:
  etherscan: "XXXXXX"
  beaconchain: "XXXXXX"
  alphavantage: "XXXXXX"
address:
  1: "0x000...000"
  2: "0x000...000"
validator_index:
  1: "123"
  2: "234"
  3: "345"
get_year: "2023"
  1. Python(.py)ファイルを作成し、config.yamlと同じディレクトリに保存してください。

以後はPythonファイルの中身の説明になります。
クラスを定義します。

import yaml
import requests
import pandas as pd

with open('config.yaml', 'r') as file:
    config = yaml.safe_load(file)

class BeaconchainAPI:
    
    def __init__(self, api_key):
        self.api_key = api_key

    def _get_data_from_url(self, url, filt=''):
        response = requests.get(url)

        if response.status_code == 200:
            data = response.json()
            data = data['data']

            return data
        else:
            raise Exception(f"Error in HTTP request: {response.status_code}")
        
    def get_execution_produced(self, validator_index):
        # Get a list of proposed or mined blocks from a given fee recipient address, proposer index or proposer pubkey.
        # Mixed use of recipient addresses and proposer indexes or proposer pubkeys with an offset is discouraged as it can lead to skipped entries.
        url = f'https://beaconcha.in/api/v1/execution/{validator_index}/produced?apikey={self.api_key}&limit=1000'
        return self._get_data_from_url(url)

class EtherscanAPI:
    
    def __init__(self, api_key):
        self.api_key = api_key

    def _get_data_from_url(self, url, filt=''):
        response = requests.get(url)

        if response.status_code == 200:
            data = response.json()
            transactions = data['result']

            return transactions
        else:
            raise Exception(f"Error in HTTP request: {response.status_code}") 

    def get_withdrawals(self, eth_address):
        url = 'https://api.etherscan.io/api' + \
              '?module=account' + \
              '&action=txsBeaconWithdrawal' + \
              '&address=' + eth_address + \
              '&startblock=0' + \
              '&endblock=99999999' + \
              '&sort=asc' + \
              '&apikey=' + self.api_key
        return self._get_data_from_url(url)

class AlphaVantageAPI:
    
    def __init__(self, api_key):
        self.api_key = api_key
        
    def _get_data_from_url(self, url, filt=''):
        response = requests.get(url)

        if response.status_code == 200:
            data = response.json()
            price_data = data['Time Series (Digital Currency Daily)']
            return price_data
        else:
            raise Exception(f"Error in HTTP request: {response.status_code}")        

    def get_historical_daily_eth_yen(self):
        url = 'https://www.alphavantage.co/query?function=DIGITAL_CURRENCY_DAILY&symbol=ETH&market=JPY&apikey=' + self.api_key
        return self._get_data_from_url(url)

クラスを読みこみます。

beaconchain_api_key = config['api_key']['beaconchain']
beaconchain = BeaconchainAPI(beaconchain_api_key)
etherscan_api_key = config['api_key']['etherscan']
etherscan = EtherscanAPI(etherscan_api_key)
alphavantage_api_key = config['api_key']['alphavantage']
alphavantage = AlphaVantageAPI(alphavantage_api_key)

自分が所有権をもつアドレスに直ぐ出金される報酬(Executionレイヤー報酬)を取得します。

validator_indexes = ""
for index in config['validator_index']:
    validator_indexes += config['validator_index'][index] + ","
validator_indexes = validator_indexes[:-1]


df_mined_blocks = pd.DataFrame(columns=['validatorIndex', 'blockNumber', 'date', 'amount(eth)', 'relay'])
mined_blocks = beaconchain.get_execution_produced(validator_indexes)



data = []
for mined_block in mined_blocks:
    proposer_index = int(mined_block['posConsensus']['proposerIndex'])
    block_number = int(mined_block['blockNumber'])
    timestamp = pd.to_datetime(int(mined_block['timestamp']), unit='s')
    producer_reward = int(mined_block['producerReward']) / (10 ** 18)
    if mined_block['relay'] is None:
        relay_tag = None
    else:
        relay_tag = mined_block['relay']['tag']    
    data.append([proposer_index, block_number, timestamp, producer_reward, relay_tag])
df_data = pd.DataFrame(data, columns=df_mined_blocks.columns)
df_mined_blocks = pd.concat([df_mined_blocks, df_data])
df_mined_blocks = df_mined_blocks.sort_values(by='date').reset_index(drop=True)

バリデーター1つあたり約1週間に1度自分のアドレスに出金される報酬(Consensusレイヤーの報酬)を取得します

df_withdrawals = pd.DataFrame(columns=['validatorIndex', 'blockNumber', 'date', 'amount(eth)', 'address'])
for index in config['address']:
    eth_address = config['address'][index]
    withdrawals = etherscan.get_withdrawals(eth_address)
    
    data = []
    for withdrawal in withdrawals:
        validator_index = int(withdrawal['validatorIndex'])
        address = withdrawal['address']
        block_number = int(withdrawal['blockNumber'])
        timestamp = pd.to_datetime(int(withdrawal['timestamp']), unit='s')
        amount = int(withdrawal['amount']) / (10 ** 9)
        data.append([validator_index, block_number, timestamp, amount, address])
    df_data = pd.DataFrame(data, columns=df_withdrawals.columns)    
    df_withdrawals = pd.concat([df_withdrawals, df_data])

df_withdrawals = df_withdrawals.sort_values(by='date').reset_index(drop=True)

ETH/JPY価格データを取得します

eth_yen = alphavantage.get_historical_daily_eth_yen()
dates = []
prices = []
for date, value in eth_yen.items():
    dates.append(date)
    prices.append(float(value['4a. close (JPY)']))
df_eth_yen = pd.DataFrame({'date': dates, 'ETH/JPY': prices})

取得した3つのデータを組み合わせます

df_mined_blocks['source'] = 'execution'
df_withdrawals['source'] = 'consensus'
df_concat_raw = pd.concat([df_mined_blocks, df_withdrawals])
df_concat = df_concat_raw.drop(columns=['relay', 'address']).sort_values(by='date').reset_index(drop=True)

get_year = int(config['get_year'])
df_year_earnings = df_concat[df_concat['date'].dt.year == get_year]

df_year_earnings['date'] = pd.to_datetime(df_year_earnings['date']).dt.date
df_eth_yen['date'] = pd.to_datetime(df_eth_yen['date']).dt.date

df_year_earnings_yen = pd.merge(df_year_earnings, df_eth_yen, on='date', how='left')
df_year_earnings_yen['amount(jpy)'] = (df_year_earnings_yen['amount(eth)'] * df_year_earnings_yen['ETH/JPY']).round(2)

完成系の表は以下のような感じです。
一番右の列に日本円での収入が計算されています。
ちなみに収入を受け取る際に手数料は発生していないのでご安心を。

indexvalidatorIndexblockNumberdateamount(eth)sourceETH/JPYamount(jpy)
0123168660142023-03-200.019025execution257842.862344905.46
1456169963492023-04-070.011555execution277327.127163204.51
2789170534302023-04-154.742582consensus311087.551801475358.22

後はDeFiとかでやった取引きと帳尻を合わせれば確定申告の準備は整います。