Crypto Taxes the Hard Way: Transfer Accounts
Disclaimer: nothing on this blog is advice about the substance of your taxes. I have no background in accounting and no idea whether this code will produce valid results in your (or any!) tax situation.
Transfer accounts are relatively standard accounting trick that I took a while to figure out. You should learn them straight away though! It’ll save a lot of time when using Hledger or plain text accounting generally, and they’re especially useful for crypto taxes.
The easy way
Suppose you have accounts with BigBank and MockEx, along with a Bitcoin wallet, and you’re in the USA. Here’s a diagram of the accounts, transfers, and currencies you probably need to track:
The easiest way to represent it in hledger is probably to parse all the transfers from the MockEx data and ignore them in the BigBank + Wallet data, like this:
# mockex.rules
# One account is always the main one for the current file
account1 assets:mockex
# The other account and currency can be decided by regex matches
if (Incoming|Outgoing).*USD
account2 assets:bigbank
currency USD
if (Withdrawal|Deposit).*BTC
account2 assets:wallet
currency BTC# bigbank.rules
# Regex to match the bank's descriptions
if TRANSFER (TO|FROM) MOCKEX
skip# wallet.rules
# Regex to match your own annotations
# (Alternatively, can use individual TXIDs)
if mockex
skipIf this is your only way of trading crypto, you might even be able to finish your taxes without parsing the bank or wallet data at all!
The elegant, sustainable way
Sadly, the easy way doesn’t scale. As you add accounts it’ll become harder to remember which transfers should be parsed and which should be ignored. At some point, you should really add transfer accounts:
Each transfer will now be recorded as two halves: into the transfer account, and back out (not necessarily in that order). For the cost of adding a few extra hledger accounts, we get a graph that can be neatly decomposed to match the data files.
Now we can parse all the transfers from each source and dump them into the main journal, and they should all balance.
# bigbank.rules
currency USD
account1 assets:bigbank
if TRANSFER (TO|FROM) MOCKEX
account2 transfers:USD# mockex.rules
account1 assets:mockex
if (Incoming|Outgoing).*USD
account2 transfers:USD
currency USD
if (Withdrawal|Deposit).*BTC
account2 transfers:BTC
currency BTC# wallet.rules
currency BTC
account1 assets:wallet
if mockex
account2 transfers:BTCThis isn’t only an ergonomic improvement. It also makes it much easier to double check that the amounts reported by each institution match up. (You might be surprised—or not—to learn that several large US exchanges do weird rounding errors in their own favor.) By checking that the transfer accounts zero out periodically, we can catch various errors.
Speaking of which, it would also be reasonable to call these “error” accounts rather than “transfers”,
because their accumulated balances track imbalances in your accounting.
That might be more appealing if you already have an error account for other reasons.
Handling many accounts
What if you have more than one bank account, or more than one wallet? This will handle it automatically with no additional work!
It also handles multiple accounts within the same bank.
For example if you have checking, credit, and savings/money market accounts you can just
regex match on whatever description the accounts use for incoming and outgoing transfers,
and call them all transfers:USD.
# bigbank-checking.rules
currency USD
# Other accounts will be the same except this line
account1 assets:bigbank:checking
# ... unless they also need different regexes
if ^TRANSFER.*
account2 transfers:USD
# (The credit accounts might also have their + and -
# signs flipped, but that's out of scope for today.)Handling many currencies
Only a little bit of additional work this time. Suppose you have an Ethereum
wallet, and it transacts in multiple currencies: moving ERC20 tokens, paying
fees in ETH, getting airdrops or spam, etc. Technically you could send them all
via the same transfers:ETH account, but I find it easier to make one per
token. That way I don’t have to remember that transfers:ETH can contain
other currencies, which used to trip me up a lot. Either way is fine though.
Here’s what a transfer might look like in this style after parsing a csv from each of your wallets and dumping them both into the main journal:
2025-11-01 wallet1 send LINK
assets:wallet1 -10.000 LINK
assets:wallet1 -0.001 ETH
expenses:fees 0.001 ETH
transfers:LINK 10.000 LINK
2025-11-01 wallet2 receive LINK
assets:wallet2 10.000 LINK
transfers:LINK -10.000 LINKThe last pro tip for today is that if you name your transfer accounts based on
tickers (LINK rather than chainlink), you can generate them as needed in
your rules files.
