Virtuals Protocol

Code4rena · S-171 · Apr 2025

Virtuals Protocol

MEDIUMACCEPTED

// Frontrunnable Pool Creation Blocks Custom Token Agent Initialization Permanently

01 /

EXECUTIVE SUMMARY

EXECUTIVE_SUMMARY.log
ISSUE OVERVIEWThe initFromToken → executeTokenApplication workflow contains a race condition where a malicious actor can permanently DoS agent creation for any custom token by frontrunning Uniswap pool creation. Once the pool exists, executeTokenApplication always reverts with no recovery path.
AFFECTED COMPONENTAgentFactoryV4.sol — _createPair() (L543–551), executeTokenApplication() (L226)
RISK CLASSIFICATIONRace Condition / Permanent Denial of Service
SEVERITYMEDIUM
STATUSACCEPTED
02 /

ROOT CAUSE ANALYSIS

ROOT_CAUSE_ANALYSIS.log

_createPair() checks that the Uniswap pair does NOT exist and reverts if it does. Because pool creation is separated from agent registration (initFromToken registers first, pool creation happens in executeTokenApplication), any actor can create the pair in the gap window, permanently blocking the agent.

AgentFactoryV4.sol — _createPair (L543-551)
function _createPair(address tokenAddr) internal returns (address) {
    IUniswapV2Factory factory =
        IUniswapV2Factory(IUniswapV2Router02(_uniswapRouter).factory());

    // @audit Hard reverts if pool already exists — no recovery
    require(
        factory.getPair(tokenAddr, assetToken) == address(0),
        "pool already exists"
    );

    return factory.createPair(tokenAddr, assetToken);
}

// executeTokenApplication calls _createPair AFTER registration
// giving attackers a window to front-run
function executeTokenApplication(...) external {
    // ... token validation ...
    lp = _createPair(token); // reverts if Bob already created pair
}

INCORRECT ASSUMPTIONS

  • The gap between initFromToken (registration) and executeTokenApplication (pool creation) is safe because only the protocol creates pairs
  • Uniswap pair creation is permissioned or atomic with agent registration
  • A pre-existing pair can be reused — the require() check assumes exclusive creation rights

AFFECTED CONTRACTS

  • AgentFactoryV4.sol — _createPair() (L543–551)
  • AgentFactoryV4.sol — executeTokenApplication() (L226)
  • AgentFactoryV4.sol — initFromToken()
03 /

ATTACK SCENARIO

ATTACK_VECTOR — Permanent DoS via Uniswap Pair Pre-creation
01

Alice calls initFromToken(AliceToken) to register her custom token agent. Registration succeeds and the application is recorded.

02

Attacker monitors the mempool. Before Alice calls executeTokenApplication(), the attacker calls IUniswapV2Factory.createPair(AliceToken, VirtualToken) directly.

03

Alice calls executeTokenApplication(). Internally, _createPair() checks factory.getPair(AliceToken, assetToken) != address(0) — the pair exists — and reverts with "pool already exists".

04

Alice's agent is permanently uninitialized. She cannot recover: the application is registered but the required pair creation reverts every time. The custom token agent is permanently blocked.

04 /

IMPACT ASSESSMENT

HIGH

Permanent DoS

Any custom token agent registration can be permanently blocked by any actor willing to spend the gas to pre-create the Uniswap pair. The attack is cheap and unrecoverable.

HIGH

No Recovery Path

There is no mechanism to proceed with an existing pair or cancel and re-register. Affected applications are permanently stuck.

MEDIUM

Competitive Griefing

Competitors or protocol adversaries can selectively block specific token agents while allowing others, enabling targeted censorship of custom agent creation.

05 /

MITIGATION

Move pool creation into initFromToken so it happens atomically with registration, eliminating the window. Alternatively, modify _createPair() to return an existing pair instead of reverting.

— VULNERABLEimplementation
// Pool creation is delayed — creates attack window
function initFromToken(address tokenAddr, ...) public {
    _tokenApplication[tokenAddr] = id; // register first
    // pool created later in executeTokenApplication ← gap here
}
+ SECUREimplementation
// Option A: atomic creation in initFromToken
function initFromToken(address tokenAddr, ...) public {
    _createPairOrGet(tokenAddr); // create pair first
    _tokenApplication[tokenAddr] = id;
}

// Option B: tolerate existing pair in _createPair
function _createPair(address tokenAddr) internal returns (address) {
    address existing = factory.getPair(tokenAddr, assetToken);
    if (existing != address(0)) return existing; // reuse if exists
    return factory.createPair(tokenAddr, assetToken);
}

SECURITY CONSIDERATIONS

  • 01.Atomic pair creation eliminates the race window entirely — prefer Option A
  • 02.If reusing existing pairs (Option B), verify the pair was created with the correct assetToken to avoid malicious pair substitution
  • 03.Add a test that pre-creates the Uniswap pair and then calls initFromToken + executeTokenApplication — it should succeed
06 /

RESEARCHER NOTES

RESEARCHER_NOTES.md
@preetsinghmakkar— personal analysis

Race conditions in multi-step initialization flows are a recurring pattern in DeFi. Whenever I see a protocol split into 'register then execute' steps, I immediately ask: what can happen in the gap? Here, the gap allowed permanent DoS via a permissionless Uniswap factory call.

The severity driver is the absence of a recovery path. If executeTokenApplication could accept a pre-existing pair or if the application could be cancelled and re-submitted, this would be low severity. The combination of 'no error handling + no recovery' is what makes it matter.

The fix is straightforward once you identify the pattern: either make it atomic (create pair in initFromToken) or make it idempotent (accept existing pair in _createPair). Both solutions are one-line changes. The insight is recognizing that the require() check was written with an assumption of exclusive creation rights that Uniswap's permissionless factory doesn't enforce.

I document this class of bug as 'assumed exclusivity over permissionless infrastructure' — protocols that call permissionless external contracts and then rely on being the only caller create exploitable race windows. Always check: can an attacker call this external contract before the protocol does?

07 /

RESOURCES

08 /

RELATED FINDINGS