All writeups
Access ControlNFTCode4rena

AI Arena: Hand-Picking Rare Fighter NFTs via redeemMintPass

June 26, 20266 min read

I audit a lot of DeFi, but every now and then a contest comes along that I actually want to play. AI Arena was one of those. It's a game where you train AI-powered fighters, level them up, and battle. Fighters are NFTs, each one with its own element, weight, and a set of physical attributes that are supposed to be handed out by the protocol, not chosen by you. The moment I read that, a little alarm went off. I like games, and I've spent enough time around them to know that the second a player can decide their own stats, the economy is in trouble.

So I leaned into that instinct for this one.

How I work through a contest

My routine is boring on purpose. I read the contest page, the company docs, and the README end to end before I touch a single line of code. Then I pull the repo, build it, and run the tests. Tests are gold. They show you what the devs were worried about and, more usefully, what they weren't. After that I read the codebase by hand and follow each function's path across contracts instead of judging it in isolation. Once I've got the shape of the thing, I hit the usual suspects: access control, and any place where randomness or an oracle could be nudged.

The minting flow was where my game brain and my auditor brain agreed. Fighters get created in FighterFarm.sol, and one of the entry points is redeemMintPass — you burn a mint pass NFT and get a fighter in return. Reading the docs, the intent was clear enough: the mint pass is the thing you redeem, and the fighter you get out of it should not be something you get to customize. You shouldn't pick its type. You definitely shouldn't pick rare traits.

Then I read the function.

The bug

Here's redeemMintPass, trimmed down to the part that matters:

function redeemMintPass(
    uint256[] calldata mintpassIdsToBurn,
    uint8[] calldata fighterTypes,
    uint8[] calldata iconsTypes,
    string[] calldata mintPassDnas,
    string[] calldata modelHashes,
    string[] calldata modelTypes
)
    external
{
    require(
        mintpassIdsToBurn.length == mintPassDnas.length &&
        mintPassDnas.length == fighterTypes.length &&
        fighterTypes.length == modelHashes.length &&
        modelHashes.length == modelTypes.length
    );
    for (uint16 i = 0; i < mintpassIdsToBurn.length; i++) {
        require(msg.sender == _mintpassInstance.ownerOf(mintpassIdsToBurn[i]));
        _mintpassInstance.burn(mintpassIdsToBurn[i]);
        _createNewFighter(
            msg.sender,
            uint256(keccak256(abi.encode(mintPassDnas[i]))),
            modelHashes[i],
            modelTypes[i],
            fighterTypes[i],
            iconsTypes[i],
            [uint256(100), uint256(100)]
        );
    }
}

Look at what's coming straight from the caller. fighterTypes is yours. mintPassDnas is yours. The only check in the loop is that you own the mint pass you're burning. Nothing validates that the type or the DNA you passed in matches anything the protocol intended for that pass. The mint pass was supposed to be a sealed envelope. In practice, you're the one filling it out.

Now follow the DNA into where attributes actually get decided. _createNewFighter does this:

if (customAttributes[0] == 100) {
    (element, weight, newDna) = _createFighterBase(dna, fighterType);
} else {
    element = customAttributes[0];
    weight = customAttributes[1];
    newDna = dna;
}
// ...
FighterOps.FighterPhysicalAttributes memory attrs =
    _aiArenaHelperInstance.createPhysicalAttributes(
        newDna,
        generation[fighterType],
        iconsType,
        dendroidBool
    );

redeemMintPass always passes [100, 100], so it takes the first branch and runs _createFighterBase(dna, fighterType), then createPhysicalAttributes(newDna, ...). At a glance that looks safe, because the contract is the one deriving the traits. The catch is that the whole thing is deterministic. Same DNA in, same fighter out. Every single time.

That's the part my game intuition wouldn't let go of. If attribute generation is a pure function of an input I control, then I don't need to roll the dice and hope. I can shop.

Why this is a big deal

Two concrete abuses fall right out of it.

First, the type. fighterType == 1 means Dendroid, the rarer class players aren't meant to mint freely. Since fighterTypes[i] comes straight from me, I just set it to 1 and redeem a Dendroid. No gate, no signature, nothing tying that pass to a "normal" fighter.

Second, the attributes. Because createPhysicalAttributes is deterministic on the DNA, I can watch fighters other people have already minted, find the ones with the rare traits I want, and read the exact DNA string that produced them straight off the chain. Then I feed that DNA into mintPassDnas and redeem the same desirable fighter for myself. If I wanted to be thorough about it, I could grind DNA values offline until I find ones that yield the rarest combinations, then mint those on demand. The randomness the protocol thinks it has is really just a lookup table I can browse.

In a game where scarcity is the whole point, that's the kind of thing that hollows out the economy over time. Rare fighters stop being rare. The people who pay for the strong NFTs are subsidizing anyone willing to read the contract. It quietly breaks the fairness the game is built on, which is exactly why this landed as a High.

What made it sting, in a good way, is that the codebase already had the right pattern sitting right there. claimFighters uses a signature from the protocol to authorize what a player can mint, so the player can't just invent their own properties. redeemMintPass skipped that step entirely. The fix wasn't some exotic redesign — it was applying the team's own existing guardrail to the door they left open.

The fix

Bind the mint pass to the fighter it's allowed to produce, and verify that binding on redemption. The clean way is the same signature approach claimFighters already uses: have the protocol sign off on the type and DNA for a given pass, and check that signature before minting.

function redeemMintPass(
    uint256[] calldata mintpassIdsToBurn,
    uint8[] calldata fighterTypes,
    uint8[] calldata iconsTypes,
    string[] calldata mintPassDnas,
    string[] calldata modelHashes,
    string[] calldata modelTypes,
    bytes calldata signature
)
    external
{
    // ...existing length and ownership checks...

    // The properties must be the ones the protocol authorized for these passes,
    // not whatever the caller felt like typing in.
    bytes32 msgHash = keccak256(abi.encode(
        msg.sender, mintpassIdsToBurn, fighterTypes, mintPassDnas
    ));
    require(_verifySignature(msgHash, signature), "invalid mint pass signature");

    // ...burn and create as before...
}

The exact shape can vary, but the principle doesn't: the player should not be the source of truth for their own fighter's type and traits. Whatever decides those values needs to be something they can't forge.

The lesson I took from it

This was one of around six issues I turned up across the AI Arena audit, and I'll be straight about it: some of them never got validated because I hit trouble getting my proof-of-concept tests to run cleanly, and an unproven finding is just a claim. That's the honest scorecard. But this one I'm glad I caught, because it's a perfect example of how a small omission compounds. One missing check on one function, and the rarity model the entire game depends on slowly comes apart. Nobody gets robbed in a single dramatic transaction. It just erodes, mint by mint, until the thing people paid for isn't worth what they paid.

That's the part I keep coming back to. The bugs that kill protocols in the long run usually aren't the clever ones. They're the boring oversights nobody thought to question, in code that looks fine until you follow where the inputs actually come from.

If you're shipping something where players, or users, get to hand inputs to a mint or a reward path, this is exactly the kind of gap I go looking for. If that sounds like your system, here's how I work.

Shipping something that needs this checked?

Get an independent smart contract audit before mainnet — manual review, Foundry testing, and fix verification.

Request an Audit