Upside
Findings & Analysis Report
2025-06-08
Table of contents
Overview
About C4
Code4rena (C4) is a competitive audit platform where security researchers, referred to as Wardens, review, audit, and analyze codebases for security vulnerabilities in exchange for bounties provided by sponsoring projects.
A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.
During the audit outlined in this document, C4 conducted an analysis of the Upside smart contract system. The audit took place from May 19 to May 26, 2025.
Final report assembled by Code4rena.
Summary
The C4 analysis yielded an aggregated total of 0 High and Medium vulnerabilities. Additionally, C4 analysis included 27 reports detailing issues with a risk rating of LOW severity or non-critical.
All of the issues presented here are linked back to their original finding, which may include relevant context from the judge and Upside team.
Scope
The code under review can be found within the C4 Upside repository, and is composed of 2 smart contracts written in the Solidity programming language and includes 379 lines of Solidity code.
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
Low Risk and Non-Critical Issues
For this audit, 27 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by Brene received the top score from the judge.
The following wardens also submitted reports: 0xcb90f054, 0xhp9, 0xozovehe, Anyasaa, befree3x, c0pp3rscr3w3r, CasinoCompiler, dobrevaleri, dystopia, eLSeR17, farismaulana, harry, hgrano, Jamil, K42, MrPotatoMagic, peppef, PolarizedLight, rayss, ret2basic, Roger, SafetyBytes, sakibcy, Sakursf, Tigerfrake, and vaminator.
[01] Front-running attack in tokenize function
Finding description
The tokenize()
function in the UpsideProtocol
contract allows users to tokenize the provided url
. A critical parameter governing this process is tokenizeFeeEnabled
, which determines whether users must pay a fee in order to call the function. When this flag is set to false
, users can freely call tokenize()
without paying any fee.
This design introduces a significant front-running risk. Since the function can be called without restriction or cost when fees are disabled, malicious actors can monitor pending transactions in the mempool and submit the same tokenize()
call with a higher gas price to get their transaction mined first.
Once the second user successfully tokenizes the provided URL, the contract sets a mapping entry for that URL:
urlToMetaCoinMap[_url] = metaCoinAddress;
This causes urlToMetaCoinMap[_ur]
to be non-zero. Therefore, when user1’s transaction is eventually mined, the following check fails:
if (urlToMetaCoinMap[_url] != address(0)) {
revert MetaCoinExists();
}
Impact
Attackers can steal high-value URLs (e.g., popular brand names).
Recommended mitigation steps
Require users to first commit to a hash of the URL, and then reveal the full URL in a second transaction after a short delay. This prevents front-runners from knowing what is being tokenized in the first transaction.
Proof of concept
- In PoC.test.ts, make the following modification:
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
import { ethers as hhethers } from "hardhat";
import {IERC20Metadata, UpsideMetaCoin, UpsideProtocol, UpsideStakingStub} from "../types";
// Add this import
+ import { expect } from "chai";
- Add the following test in the same file:
it("Should permit user2 to execute transactions ahead of user1", async function () {
const url1 = "https://example1.com";
const liquidityTokenAddress = await liquidityToken.getAddress();
const tokenizeFeeDestinationAddress = await owner.getAddress();
// Fee configuration
const newFeeInfo = {
tokenizeFeeEnabled: true,
tokenizeFeeDestinationAddress: tokenizeFeeDestinationAddress,
swapFeeStartingBp: 9900, // 99%
swapFeeDecayBp: 100, // 1%
swapFeeDecayInterval: 6, // 6 seconds
swapFeeFinalBp: 100, // 1%
swapFeeDeployerBp: 1000, // 10%
swapFeeSellBp: 100, // 1%
};
// Set fee info
await upsideProtocol.connect(owner).setFeeInfo(newFeeInfo);
// Turn off auto-mining to simulate pending transactions in the mempool
await network.provider.send("evm_setAutomine", [false]);
// User1 submits a transaction with normal gas fees
const user1Tx = await upsideProtocol.connect(user1).tokenize(url1, liquidityTokenAddress, {
gasPrice: ethers.parseUnits("10", "gwei"), // Low priority
});
// User2 submits the same transcation with higher gas price to front-run
const user2Tx = await upsideProtocol.connect(user2).tokenize(url1, liquidityTokenAddress, {
gasPrice: ethers.parseUnits("100", "gwei"), // High priority
});
// Mine the transactions, user2's should be prioritized
await network.provider.send("evm_mine");
// Reactivate auto-mining for normal operation
await network.provider.send("evm_setAutomine", [true]);
// Verify that user1's transaction failed because the URL was already tokenized
await expect(user1Tx.wait()).to.be.revertedWithCustomError(upsideProtocol, "MetaCoinExists");
}).timeout(100000000);
[02] Transferring ownership may result in fees being unintentionally paid to the previous owner
Finding description and impact
In the protocol, the tokenizeFeeDestinationAddress
is a configurable parameter that determines where tokenization fees are sent when the fee system is enabled. This address is set by the owner
of the contract using a function setFeeInfo
.
function setFeeInfo(FeeInfo calldata _newFeeInfo) external onlyOwner {
if (
_newFeeInfo.swapFeeDeployerBp > 10000 ||
_newFeeInfo.swapFeeDecayBp > 10000 ||
_newFeeInfo.swapFeeFinalBp > 10000 ||
_newFeeInfo.swapFeeStartingBp > 10000 ||
_newFeeInfo.swapFeeSellBp > 10000 ||
>> _newFeeInfo.tokenizeFeeDestinationAddress == address(0) ||
_newFeeInfo.swapFeeStartingBp < _newFeeInfo.swapFeeFinalBp ||
_newFeeInfo.swapFeeDecayInterval == 0
) {
revert InvalidSetting();
}
feeInfo = _newFeeInfo;
emit FeeInfoSet(_newFeeInfo);
}
However, when ownership of the contract is transferred using the standard Ownable
pattern (e.g., via transferOwnership()
), the tokenizeFeeDestinationAddress
does not automatically update to reflect the new owner. As a result, all tokenization fees continue being sent to the previous owner, even though they no longer control the contract. This creates a temporal vulnerability between the point of ownership transfer and the point at which the new owner explicitly updates the fee configuration. If the new owner delays or forgets to call setFeeInfo
, then fees are still sent to the previous owner.
Recommended mitigation steps
Override the transferOwnership()
from Ownable
to check that if the current fee recipient is the owner trying to exit, then it should revert.
Proof of concept
- In
PoC.test.ts
, make the following modification:
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
import { ethers as hhethers } from "hardhat";
import {IERC20Metadata, UpsideMetaCoin, UpsideProtocol, UpsideStakingStub} from "../types";
+ import { expect } from "chai";
describe("C4 PoC Test Suite", function () {
---snip---
let user1: HardhatEthersSigner;
+ let user2: HardhatEthersSigner;
---snip---
before(async function () {
signers = await hhethers.getSigners();
owner = signers[2];
user1 = signers[0];
+ user2 = signers[1];
---snip---
});
+ // the test below goes here
});
- Add the following test in the same file:
it("should send fees to original owner", async function () {
const url1 = "https://openzeppelin.com";
const url2 = "https://cantina.com";
const liquidityTokenAddress = await liquidityToken.getAddress();
const tokenizeFeeDestinationAddress = await owner.getAddress();
const _mintAmount = hhethers.parseUnits("100", 6);
const _feeAmount = hhethers.parseUnits("5", 6);
// Configure fee parameters with current owner as the fee destination
const newFeeInfo = {
tokenizeFeeEnabled: true,
tokenizeFeeDestinationAddress: tokenizeFeeDestinationAddress,
swapFeeStartingBp: 9900, // 99%
swapFeeDecayBp: 100, // 1%
swapFeeDecayInterval: 6, // 6 seconds
swapFeeFinalBp: 100, // 1%
swapFeeDeployerBp: 1000, // 10%
swapFeeSellBp: 100, // 1%
};
// Owner sets the fee configuration
await upsideProtocol.connect(owner).setFeeInfo(newFeeInfo);
// set tokenize fee to 5e6
await upsideProtocol.connect(owner).setTokenizeFee(liquidityTokenAddress, _feeAmount);
// mint liquidityToken to user1
await liquidityToken.mint(await user1.getAddress(), _mintAmount);
// assert that user1 received the minted tokens
let user1LiquidityTokenBalance = await liquidityToken.balanceOf(await user1.getAddress());
expect(user1LiquidityTokenBalance).to.be.eq(_mintAmount);
// user1 performs tokenization (url1), fee should go to current fee destination (owner)
await liquidityToken.connect(user1).approve(await upsideProtocol.getAddress(), _feeAmount);
await upsideProtocol.connect(user1).tokenize(url1, liquidityTokenAddress);
// Validate that the fee was received by the original owner
let tokenizeFeeDestinationAddressBalance = await liquidityToken.balanceOf(tokenizeFeeDestinationAddress);
expect(tokenizeFeeDestinationAddressBalance).to.be.eq(hhethers.parseUnits("5", 6));
// Transfer ownership to user2
await upsideProtocol.connect(owner).transferOwnership(await user2.getAddress());
// asset that new ownership is assigned to user2
expect(await upsideProtocol.owner()).to.be.eq(await user2.getAddress());
// user1 performs a second tokenization (url2) after ownership transfer
await liquidityToken.connect(user1).approve(await upsideProtocol.getAddress(), _feeAmount);
await upsideProtocol.connect(user1).tokenize(url2, liquidityTokenAddress);
// assert that the original owner still receives the fee post-ownership transfer
let currentTokenizeFeeDestinationAddressBalance = await liquidityToken.balanceOf(await owner.getAddress());
expect(currentTokenizeFeeDestinationAddressBalance).to.be.eq(hhethers.parseUnits("10", 6));
});
[03] Swap fee bypass via small trade amounts
Finding description
The swap()
function in the UpsideProtocol
smart contract is vulnerable to fee circumvention when users initiate swaps with extremely small input amounts. The issue stems from the use of integer division in Solidity, which truncates values. As a result, when the calculated fee amount is less than 1 (due to the small trade size), it truncates to zero, effectively bypassing the protocol’s fee logic.
if (_isBuy) {
// @dev On buy, the dynamic time fee is used
fee = (_tokenAmount * swapFeeBp) / 10000;
tokenAmountAfterFee = _tokenAmount - fee;
This behavior directly undermines the intended fee model, allowing users to perform token swaps without contributing any fee to the protocol. The bypass is particularly problematic given the economic incentives it creates—users could automate micro-swaps to avoid fees entirely while still draining value from the system.
Impact
No fees are collected on small trade size.
Recommended mitigation steps
Set a protocol-wide minimum input amount to reject trades too small to generate fees:
+ require(_amount >= MIN_SWAP_AMOUNT, "Swap amount too small");
Proof of concept
- In
PoC.test.ts
, make the following modification:
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
import { ethers as hhethers } from "hardhat";
import {IERC20Metadata, UpsideMetaCoin, UpsideProtocol, UpsideStakingStub} from "../types";
// Add this import
+ import { expect } from "chai";
- Add the following test in
PoC.test.ts
:
it("should implement fee exemption for user1 during swap", async function () {
const tokenizeFeeDestinationAddress = await owner.getAddress();
const _mintAmount = hhethers.parseUnits("100", 6);
// Set up a initial swap fee configuration
const newFeeInfo = {
tokenizeFeeEnabled: true,
tokenizeFeeDestinationAddress: tokenizeFeeDestinationAddress,
swapFeeStartingBp: 100, // 10%
swapFeeDecayBp: 100, // 1%
swapFeeDecayInterval: 6, // 6 seconds
swapFeeFinalBp: 90, // 0.9%
swapFeeDeployerBp: 1000, // 10%
swapFeeSellBp: 100, // 1%
};
// Set fee info:
await upsideProtocol.connect(owner).setFeeInfo(newFeeInfo);
// mint user1 with liquidityToken
await liquidityToken.mint(await user1.getAddress(), _mintAmount);
// Verify user1 received the correct liquidity token balance
let user1LiquidityTokenBalance = await liquidityToken.balanceOf(await user1.getAddress());
expect(user1LiquidityTokenBalance).to.be.eq(_mintAmount);
// Perform a swap with an extremely small input amount
const _tokenAmount = hhethers.parseUnits("0.000099", 6);
await liquidityToken.connect(user1).approve(await upsideProtocol.getAddress(), _tokenAmount);
await upsideProtocol.connect(user1).swap(
await sampleLinkToken.getAddress(),
true,
_tokenAmount,
0,
await user1.getAddress()
);
// assert that user1 receives a non-zero amount of LINK tokens
let user1LinkTokenBalance = await sampleLinkToken.balanceOf(await user1.getAddress());
expect(user1LinkTokenBalance).to.be.gt(0);
// Check that no protocol fees were collected from the swap
let claimableProtocolFees = await upsideProtocol.claimableProtocolFees();
expect(claimableProtocolFees).to.be.eq(0); // Fee bypass confirmed
});
Note: QA report issues deemed invalid by the judge have been removed for reporting purposes.
Disclosures
C4 is an open organization governed by participants in the community.
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.