Journal
7 min read

Anatomy of the PatolabsOG contract

229 lines of Solidity, an ERC-721 with on-chain SVG art, a 1-per-tier invariant enforced on transfers, and a few design decisions worth explaining.

The contract behind Patolabs OG is verified and readable on Basescan. 229 lines of Solidity, no proxy, no external libraries beyond OpenZeppelin. This article breaks down the design choices, the pitfalls avoided, and what I'd do differently.

The skeleton

contract PatolabsOG is ERC721, Ownable2Step, ReentrancyGuard {

Three inheritances, each for a specific reason.

ERC721 — the NFT standard, not ERC721Enumerable. On-chain enumeration costs gas on every transfer to maintain index arrays. PatolabsOG doesn't need it: the frontend uses events to list a wallet's tokens, and views like ownsTier(address, uint8) are enough for the TestFlight gate. Gas saved on every mint and transfer.

Ownable2Step instead of Ownable — this is the detail most NFT contracts get wrong. With Ownable, a single transferOwnership(newAddress) call and it's done. Type the wrong address, and the contract is gone forever. Ownable2Step splits the transfer into two steps: the current owner proposes, the new owner accepts. Until acceptance happens, nothing changes. For a contract that holds funds (mint revenue), this is the bare minimum.

ReentrancyGuard — on the mint() function. Minting sends an ERC-721 token via _safeMint, which calls onERC721Received on the recipient. If the recipient is a malicious contract, it can attempt to re-enter mint() during the callback. The guard shuts that down. Cost: one extra SSTORE per mint. Worth it.

The invariant: one token per tier per wallet

This is the contract's central point. Each address can only hold one copy of each tier. The constraint is enforced on every transfer, not just on mint.

function _update(address to, uint256 tokenId, address auth)
    internal override returns (address from)
{
    from = super._update(to, tokenId, auth);
    uint8 tier = _tokenTier[tokenId];
    if (from != address(0)) _tierCount[from][tier]--;
    if (to   != address(0)) {
        require(_tierCount[to][tier] == 0, "Already owns this tier");
        _tierCount[to][tier]++;
    }
    return from;
}

_update() is OpenZeppelin 5.x's internal hook that intercepts all token movements — mint, transfer, burn. By overriding it, we guarantee the invariant is checked everywhere, without touching transferFrom, safeTransferFrom, and their variants.

The _tierCount mapping is a mapping(address => mapping(uint8 => uint256)). It replaces an earlier hasMintedTier that only checked at mint time — the receiver of a transfer wasn't validated, so a wallet could receive a token and then mint another one of the same tier, ending up with two. The fix moved the check from mint to the transfer hook, which covers both sides of every movement.

Concrete consequence: if someone lists a Supporter on OpenSea and the buyer already owns one, the transaction reverts. No workaround as long as the buyer hasn't sold or transferred theirs.

The mint

function mint(uint8 tier) external payable nonReentrant {
    require(tier <= TIER_FOUNDER, "Invalid tier");
    TierConfig storage t = _tiers[tier];
    require(_tierCount[msg.sender][tier] == 0, "Already owns this tier");
    require(msg.value >= t.price,              "Insufficient payment");
    require(t.minted < t.maxSupply,            "Tier sold out");

    uint256 tokenId = _nextTokenId++;
    t.minted++;
    _tokenTier[tokenId]      = tier;
    _tokenTierIndex[tokenId] = t.minted;
    _safeMint(msg.sender, tokenId);

    emit Minted(msg.sender, tokenId, tier);
}

A few points:

The require on _tierCount is redundant with the check in _update()_safeMint at the end will trigger _update() which checks the same thing. But the explicit check at the top of mint() has two advantages: a clear error message for the caller, and gas savings on failure (we revert before writing anything to storage).

_tokenTierIndex stores the token's position within its tier (1-based). Token #7 might be "Backer 3 of 300". This info is used in metadata but not in contract logic — it's purely cosmetic.

msg.value >= t.price rather than == is a classic choice: if someone sends too much ETH, they don't lose their transaction. The surplus stays in the contract and gets withdrawn via withdraw(). We could auto-refund the surplus, but that adds an external call inside mint — extra attack surface for marginal benefit.

The walls: receive and fallback

receive() external payable { revert("Use mint()"); }
fallback() external payable { revert("Use mint()"); }

Two lines, one principle: the only way to send ETH to the contract is through mint(). No accidental transfers, no send or transfer succeeding silently. If someone sends ETH without calling mint(), the transaction reverts with an explicit message.

It's a safety net against user error. It doesn't prevent a selfdestruct from another contract forcing ETH in (known EVM edge case), but it covers 99% of real-world scenarios.

On-chain SVG art

This is the most unusual part of the contract. When someone calls tokenURI(), the contract builds a complete SVG and returns it as base64 JSON.

function tokenURI(uint256 tokenId) public view override returns (string memory) {
    // ...
    string memory svg   = _buildSVG(tierIdxStr, maxStr, ti, tName, tColor);
    string memory image = string.concat(
        "data:image/svg+xml;base64,",
        Base64.encode(bytes(svg))
    );

    string memory json = Base64.encode(bytes(string.concat(
        '{"name":"Patolabs OG #', idStr, '",',
        '"description":"Original supporter of patolabs. ...",',
        '"image":"', image, '",',
        '"attributes":[',
            '{"trait_type":"Tier","value":"', tName, '"},',
            '{"trait_type":"Edition","value":"', tierIdxStr, ' of ', maxStr, '"}',
        ']}'
    )));

    return string.concat("data:application/json;base64,", json);
}

Double base64 encoding: the SVG is encoded once for the image field, then the entire JSON is encoded for the final data URI. This is the standard pattern for on-chain metadata, but keep in mind it's expensive in read gas — tokenURI() is a view function, so free as an external call, but heavy if called from another on-chain contract.

The SVG itself is assembled via string.concat (Solidity 0.8.12+), not abi.encodePacked. More readable and avoids the cast to string at the end.

function _buildSVG(...) internal pure returns (string memory) {
    return string.concat(
        '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">',
        _svgDefs(accent),
        '<rect width="400" height="400" rx="32" fill="url(#bg)"/>',
        // ... logo, text, decorations
        '</svg>'
    );
}

The structure: a background with a linear gradient (#1F1F24 to #0B0B0D), a subtle tier-colored border, the Patolabs logo top-left (a chevron + stylized P as SVG paths), the big "OG" centered with a gradient fill, the tier name, edition number, and "patolabs" signature bottom-left.

Decorations vary by tier via _tierDecorations():

function _tierDecorations(uint8 tier, string memory accent)
    internal pure returns (string memory)
{
    if (tier == TIER_SUPPORTER) return "";
    // Backer: one ring
    // Founder: two rings + 4 corner brackets
}

Supporter: nothing. Backer: an SVG circle around the text. Founder: two concentric circles and four polylines forming corner brackets. Opacities are staggered (0.25, 0.15, 0.5) to keep the rendering subtle.

Owner functions

function setTierPrice(uint8 tier, uint256 price) external onlyOwner {
    require(price > 0, "Price must be > 0");
    // ...
}

function withdraw() external onlyOwner {
    uint256 amount = address(this).balance;
    (bool ok,) = owner().call{value: amount}("");
    require(ok, "Withdraw failed");
}

Two functions, two powers.

setTierPrice allows price adjustments without redeploying. The require(price > 0) prevents setting a tier to free — a deliberate choice to avoid mint spam. The maxSupply values, on the other hand, are set in the constructor and can never be modified. No setter, no mutable variable — this is the contract's strongest guarantee.

withdraw uses call{value} instead of transfer — the post-Istanbul (EIP-1884) best practice to avoid gas stipend issues. ETH always goes to owner(), not to an arbitrary parameter.

What I'd do differently

The contract does what it needs to do. But in hindsight:

The ETH surplus on mint could be refunded. msg.value >= t.price is safe, but msg.value == t.price with a clear error message ("Send exactly X") would be cleaner for the user. The surplus is recoverable via withdraw(), but I'm the one recovering it, not the minter.

The absence of a burn() function is a choice. If someone wants to get rid of their token, they can transfer it to the zero address (the _update() hook handles it). But an explicit burn() would be more ergonomic.

Events could include more data — tierIndex in Minted for instance, to save an extra call to tierPositionOf() on the indexer side.

These are details. The contract fits in 229 lines, it's readable, it does what it promises, and every line has a reason. For a support program contract, that's exactly what's needed.

The full code is verifiable on Basescan.

— Pato