
It’s frustrating that TPS is the measure a lot of the wider crypto community look at as a measure of blockchain performance and yet it’s fraught with so many pitfalls and ways to manipulate it. People often confuse TPS with max TPS – the former being a measure of usage of the chain, and the latter being a measure of its processing capability. Demand does not necessarily have to be from human entities either and hence TPS can be boosted in ingenious ways (see Algorand’s TPS).
That being said, Cardano’s UTXO model brings with it a whole host of other issues that further complicate TPS as a measure. In this article I want to cover the much talked about “Transactions within Transactions” that Cardano can do, and try to answer the question “What would Cardano’s TPS look like if that was taken into account?”.
Note that this is based on research I did over summer and which I haven’t returned to. I held off doing a write up as I wanted to double check my work. From this article you’ll appreciate why – there is a ton of things to factor in when trying to analyze this properly. However I never did get my head back into the work and so rather than delaying releasing my analysis any further I’ll instead publish it along with my methodology for others to critique or re-use.
What is a transaction?
One single transaction in Cardano can send Ada to more than one address. And a well developed smart contract would take advantage of this to be more efficient.
Question: If one Cardano transaction sends Ada to two different addresses, how many transactions have taken place?
It’s a question I asked my followers and there were different answers. Some said it was clearly 1 Cardano transaction, the clue was in the name and the transactions within transactions should instead be called “transfers”.
I think there is a lot sense to this but I subscribe to a more practical view: What do people on other blockchains think when they say “transaction”? I would argue they actually mean transfers, and therefore when talking their language it’s right to say that this is in fact 2 transactions, as chains like Ethereum would process these two transfers as two separate transactions.
Putting semantics aside, you might then draw the conclusion: “Cardano’s TPS is far higher than what it appears as it contains transactions within transactions!”. Assuming that we are talking about transfers between two wallets here, then yes this is a true statement. But what if one of those two wallets was actually the same as the wallet it came from? Should that be counted as a legitimate transfer, or should it be ignored?
I would argue ignored but this brings us to how the UTXO model used by Cardano actually works.
How Cardano transactions are structured
For a good explanation of how Cardano’s UTXO model I highly recommend watching this video. However I’ll do my best to summarize it for you.
Cardano uses an accounting model that was devised by Satoshi Nakamoto for use in Bitcoin. This is known as the UTXO model. It is a continuous process of destruction and creation as I shall explain.
In the image below, Alice wants to pay Bob 10 Ada. In Alice’s wallet she current has a single UTXO containing 100 ADA. Ignore the nerdy sounding name “UTXO” – it just means “a record stored on the blockchain”. So it’s not like there are 100 separate records each containing 1 Ada, nope it’s just one single record. A bit like a bank note for 100 Ada.
Ok but she needs to pay Bob 10 Ada and she can’t just tear off 10 from this record and give it to him. It’s immutable. She can only send over that entire record.

Note that this single record isn’t the entirety of the contents of Alice’s wallet. If she happened to have another record/bank note/UTXO (call it whatever you want to help with comprehension) that had exactly 10 Ada, then there would be no problem. She could just send over that record instead, and be done. Sadly she doesn’t. So when she tells her wallet to send over 10 Ada, her wallet scans through the all the different UTXOs in her possession and picks ones that sum up to or exceeds 10 Ada. In this case it chose the 1 record with 100 Ada in it.
Yay for Bob, he wanted 10 Ada but is being given 100 right? Not so fast. it has to go through the Cardano blockchain – that grey box in the middle representing the transaction. The grey box is told “I only want to give Bob 10 Ada, and get back 90, ok?”. And the Cardano protocol says “Sure”, at which point it destroys the 100 Ada record (well technically it marks it as having been “spent” so that it can never be reused), and issues two new UTXOs/records – one for 90 Ada that goes back to Alice, and one for 10 Ada that goes to Bob. Now instead of having a record containing 100 Ada, Alice has a record containing 90 Ada. Hence the destruction and creation aka the circle of UTXO life (cue Lion King song).
So this means at any point in time, your Cardano wallet consists a hotch-potch of different UTXO records of differing amounts that all sum up to the total ADA that your wallet tells you you own.
Transactions within transactions within Transactions
To further complicate matters, each UTXO within Cardano can contain more than just Ada. It can be ADA + multiple native assets (e.g. NFT tokens) for example. In this case you want to count each transfer of native assets as an additional count, but ignore any counts of native assets going back to the same user.
Addresses vs Stake Keys
One final spanner to throw in the works: Each Cardano wallet has several addresses. So you could see transfers happening between two different addresses, but in actual fact they belong to the same user.
But fear not! This is where stake_key comes in. This is a way to uniquely identify a user … assuming they have staked their Ada in a stake pool. If not – then there won’t be any stake key, and you have to fall back to using Address.
Calculating Cardano TPS?
Hopefully by now you’ve realized how complicated it is to determine something as simple as “how many transactions have happened between two distinct entities on Cardano“. You want a process that identifies valid transactions between to distinct entities, using stake_key where possible and takes into account native assets. And you want to exclude the transfers going back to the same user.
It took me a few attempts to come up with a way to query the Cardano blockchain in order to calculate this (methodology further below) but the end result was this:
- Top left: Cardano daily transaction count on a given day over summer was in the ~70k range
- Top right: But this comprises of ~80k actual transfers of Ada happening between distinct users
- Bottom right: And this rises to ~130k if you count native asset transfers
- Bottom left: This can be ignored – it just shows the daily volume (i.e. sum of Ada) being transferred
Assuming my methodology is sound what does this tell us?
- That the transactions within transactions argument doesn’t do much for transfers of ADA but does a lot when you factor in native assets
- That the number of transactions (or transfers) happening on Cardano is probably around a 90% higher than what is reported through a simple transaction count
- That you can assume that true TPS is roughly double whatever the reported amount is
I’ll let you draw your own conclusion from this. Below is my methodology. Would be great it someone else implemented it to see if they agree and get similar results.
Methodology
The following two Db-sync queries get to the heart of the approach. Essentially I’m using a Union query to come up with a table that I can then use to count up the # of transfers happening. There is a separate query for calculating transfers of ADA to calculating transfers of native assets. The startTxID and endTxID parameters allow you to run the query for any time period such as a particular day.
Transfer of ADA:
Transfer of Native Assets:
Here is the above translated into a Python function I used. (Yes, Yes..I know..I should set up a ReddSpark GitHub account where I can share this all properly. For now you will have to make do with this):
def getTx (db, startTxID, endTxID, debug=False):
## Note that we do not aggregate away the address here, even though we could, as we let the front-end decide if they want to allow user to drill down to the address level or not
adasql = f"select blockID, txhash,entity, sum(-sentAda) as sentAda,sum(receivedAda) as receivedAda, sum(receivedAda-sentAda) as netAda from \
(select block_id as blockID,encode(tx.hash,'hex') as txHash,\
COALESCE(sa.view,txo.address) as entity,\
sum(txo.value)/1000000 as sentAda, 0 as receivedAda\
from tx_out as txo\
inner join tx_in on txo.tx_id = tx_in.tx_out_id\
inner join tx on tx.id = tx_in.tx_in_id and tx_in.tx_out_index = txo.index\
left join stake_address as sa on txo.stake_address_id = sa.id\
where tx.id >={startTxID} and tx.id<={endTxID}\
group by tx.hash,tx.id,tx.block_id,sa.view,txo.address \
union \
select block_id as blockid,encode(tx.hash,'hex') as txHash, \
COALESCE(sa.view,txo.address) as entity, \
0 as sentAda, \
sum(txo.value)/1000000 as receivedAda\
from tx_out as txo \
inner join tx on tx.id =txo.tx_id \
left join stake_address as sa on txo.stake_address_id = sa.id\
where tx.id >={startTxID} and tx.id<={endTxID}\
group by tx.hash,tx.id,tx.block_id,sa.view,txo.address \
) as tmp \
group by blockID, txhash, entity order by txhash, entity"
tokensql = f"select blockID,txhash,tx_id,entity, encode(ma.policy,'hex') as policy, ma.fingerprint,encode(ma.name,'escape') as tokenname, sum(-sentTokens) as sentTokens, sum(receivedTokens) as receivedTokens, sum(receivedTokens-sentTokens) as nettokens from \
(select block_id as blockID,encode(tx.hash,'hex') as txHash,tx.id as tx_id, ma_txo.ident,\
COALESCE(sa.view,txo.address) as entity, \
sum(ma_txo.quantity) as sentTokens, 0 as receivedTokens\
from tx_out as txo\
inner join tx_in on txo.tx_id = tx_in.tx_out_id\
inner join tx on tx.id = tx_in.tx_in_id and tx_in.tx_out_index = txo.index\
left join stake_address as sa on txo.stake_address_id = sa.id\
left join ma_tx_out as ma_txo On txo.id = ma_txo.tx_out_id\
where tx.id >={startTxID} and tx.id<={endTxID}\
group by block_id,txo.id, tx.hash,tx.id,tx.block_id,sa.view,txo.address,ma_txo.ident \
union \
select block_id as blockID,encode(tx.hash,'hex') as txHash, tx.id as tx_id,ma_txo.ident,\
COALESCE(sa.view,txo.address) as entity, \
0 as sentTokens, sum(ma_txo.quantity) as receivedTokens\
from tx_out as txo \
inner join tx on tx.id =txo.tx_id \
left join stake_address as sa on txo.stake_address_id = sa.id\
left join ma_tx_out as ma_txo On txo.id = ma_txo.tx_out_id\
where tx.id >={startTxID} and tx.id<={endTxID}\
group by block_id,txo.id,tx.hash,tx.id,tx.block_id,sa.view,txo.address,ma_txo.ident \
) as tmp \
left join multi_asset as ma On tmp.ident = ma.id \
group by blockID,txhash, tx_id,entity, ma.policy, ma.fingerprint,ma.name \
having sum(receivedTokens-sentTokens)<>0"
if debug:
netAda = db.Execute(adasql)
netTokens = db.Execute(tokensql)
return netAda, netTokens, adasql, tokensql
else:
netAda = db.Execute(adasql)
netTokens = db.Execute(tokensql)
netAda = append_TxDates(db, netAda, 'blockID')
netAda.drop(['ID','blockID'], axis=1, inplace=True)
netTokens = netTokens[netTokens['nettokens'] != 0]
# Remove rows where no tokens were moved
if (len(netTokens)>0):
# This is where token transaction count based on sent transfers happens
netTokensSent = netTokens.loc[netTokens['nettokens'] <0]
netTokens_Agg = netTokensSent.groupby(['txhash', 'entity'], dropna=False, as_index=False).agg(tokens_txcount=('nettokens', 'count'))
# Using count here instead of a sum of net inscase a user sends 1 token and gets an entirely different one back which would make the sum here 0. Also note that this is counting up each transfer even if by the same user.
netAda=pd.merge(netAda, netTokens_Agg, how='left', on=['txhash', 'entity'])
netAda['tokens_txcount'] = netAda['tokens_txcount'].fillna(0)
# Now append dates
netTokens = append_TxDates(db, netTokens, 'blockID')
netTokens.drop(['ID','blockID'], axis=1, inplace=True)
# need to check if this needs to be done twice
netAda['date'] = pd.to_datetime(netAda['date'])
netTokens['date'] = pd.to_datetime(netTokens['date'])
return netAda,netTokens
else:
netAda['tokens_txcount'] = 0
return netAda, pd.DataFrame({'NA' : []})