Prepared by:
HALBORN
Last Updated 07/25/2025
Date of Engagement: July 1st, 2025 - July 7th, 2025
100% of all REPORTED Findings have been addressed
All findings
14
Critical
1
High
1
Medium
4
Low
4
Informational
4
IPWorld
engaged Halborn to conduct a security assessment of their smart contracts starting at July 1st, 2025 and ending on July 7th, 2025. The security assessment was scoped to the smart contracts provided in the ipdotworld/core Github repository provided to Halborn
. Further details can be found in the Scope section of this report.
Halborn
was provided 5 (five) days for the engagement, and assigned one full-time security engineer to review the security of the smart contracts in scope. The engineer is a blockchain and smart contract security expert with advanced penetration testing and smart contract hacking skills, and deep knowledge of multiple blockchain protocols.
The purpose of the assessment is to:
Identify potential security issues within the smart contracts.
Ensure that smart contract functionality operates as intended.
In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which were mostly addressed by the IPWorld team
. The main ones were the following:
Initialize nextTick prior to entering the harvest loop to prevent startTick corruption and ensure proper fee collection.
Create vesting schedule immediately when prerequisites are met, independent of WETH collection to prevent permanent blocking.
Clamp tick bounds within repositionBidWall() to prevent TickMath reverts and harvest denial-of-service attacks.
Accept user-defined deadline parameters in liquidity operations to restore slippage protection against MEV attacks.
Rename V2 initializer to initializeV2 and protect with reinitializer(2) modifier to enable proper proxy upgrades.
Remove base initializers from reinitializer functions to prevent "already initialized" reverts during upgrades.
Use call() without gas limits for ETH transfers to ensure compatibility with smart contract wallets.
Add missing safety guards to claimToken() including array length validation and SafeERC20 usage.
Add fee-share sum validation in constructor to prevent treasury calculation underflows and harvest reverts.
The Operator
role has extensive control over both data and funds within the IPWorld system. Since it is assumed to be a trusted role, risks and findings specifically tied to it were not included in this report. However, Halborn
strongly recommends that the IPWorld
team handle all private keys, including the Operator
key, with the highest level of security and operational caution.
Halborn employed a combination of manual, semi-automated, and automated security testing to balance efficiency, timeliness, practicality, and accuracy within the scope of this assessment. Manual testing is essential for uncovering flaws in logic, process, and implementation, while automated techniques enhance code coverage and quickly identify deviations from security best practices. The following phases and tools were utilized throughout the assessment:
Research into the architecture and purpose of the smart contracts.
Manual code review and walkthrough of the smart contracts.
Manual assessment of critical Solidity variables and functions to identify potential vulnerability classes.
Manual testing using custom scripts.
Static security analysis of the scoped contracts and imported functions using Slither
.
Local deployment and testing with Foundry
.
EXPLOITABILITY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
1
High
1
Medium
4
Low
4
Informational
4
Security analysis | Risk level | Remediation Date |
---|---|---|
Incorrect Tick-Segment Search Yields Zero-Fee Harvests | Critical | Solved - 07/10/2025 |
Zero-WETH Harvest & Single-Range Deployment Can Permanently Block Vesting | High | Solved - 07/10/2025 |
Extreme-Tick Bid-Wall Repositioning Can Cause harvest DoS | Medium | Solved - 07/12/2025 |
Uncallable Initializer in IPOwnerVault | Medium | Solved - 07/10/2025 |
Repeating Base Initializers in Re-Initializer Will Revert | Medium | Solved - 07/10/2025 |
Harvest fails for old tokens without repositionBidWall() function | Medium | Solved - 07/22/2025 |
Hard-Coded deadline Nullifies Slippage Protection | Low | Solved - 07/10/2025 |
Operator-Only claimToken() Is Unused | Low | Solved - 07/10/2025 |
Fixed-Gas ETH Transfer May Revert for Smart-Contract Wallets | Low | Solved - 07/10/2025 |
Fee-Share Sum Not Bounded | Low | Solved - 07/10/2025 |
claimIp() Lacks Basic Validation | Informational | Solved - 07/23/2025 |
setExpectedSigner Accepts Zero Address | Informational | Solved - 07/10/2025 |
Unchecked Increments Could Save Gas | Informational | Acknowledged - 07/10/2025 |
TODO Markers Indicating Incomplete Features | Informational | Solved - 07/23/2025 |
//
IPWorld::harvest()
attempts to locate which tick interval currently contains the pool price so it can burn/collect the proper liquidity segment. The loop that computes startTick
and nextTick
overwrites startTick
with an uninitialised nextTick
(0
) on its first iteration, corrupting the bounds:
int24 startTick = startTickList[0];
int24 nextTick;
uint256 i;
for (i = 1; i <= length; ++i) {
startTick = nextTick; // nextTick is 0 on first pass
nextTick = (i == length) ? MAX_TICK : startTickList[i];
if (startTick < currentTick && currentTick < nextTick) break;
}
With startTick
reset to 0
, the function later calls burn()
/ collect()
on a range (e.g., -887 220 → 120 000
) that does not match the stored position (120 000 → 144 000
). This logic harvests from an empty range, returning (0,0)
and starving the protocol of fees.
Run forge test --mt test_Vesting_CreatedVerified -vvv
.
0
.Initialize nextTick
prior to entering the loop and assign variable values in the proper sequence:
int24 nextTick = startTickList[0];
int24 startTick;
for (uint256 i = 1; i <= length; ++i) {
startTick = nextTick;
nextTick = (i == length) ? MAX_TICK : startTickList[i];
if (startTick < currentTick && currentTick < nextTick) break;
}
SOLVED: The IPWorld team has successfully implemented the recommended mitigation measures.
//
IPWorld.harvest()
initializes the IP owner vesting schedule only after it has collected at least one wei of WETH.
if (wethAmount != 0) {
…
if (recipient != address(0) && !IIPOwnerVault(ownerVault).vesting(token).isSet) {
IIPOwnerVault(ownerVault).createVestingOnTokenDeploy(token);
}
}
Exploit Scenario:
No fees have accrued yet — immediately after createIpToken()
mints liquidity, fee counters are at zero.
Permissionless harvest — anyone can call harvest(token)
; the IP owner does not need to be the first caller.
The caller invokes harvest before any swap occurs, resulting in wethAmount == 0
and bypassing the vesting process.
Single-range deployment (optional) — if the token was deployed with a single tick range, this zero-fee harvest also removes all liquidity from the pool and burns a portion of the IP tokens.
Impact:
The vesting schedule is never established; the IP owner cannot withdraw their allocation (vesting(token).isSet == false
).
Protocol revenue sharing (distributeOwdAmount
) is delayed.
In the case of a single tick range, the situation is permanently blocked because no further WETH fees can accrue without liquidity.
Run forge test --mt test_Vesting_CreatedVerified -vvv
, as in here we are receiving no harvest. We can observe that the vesting schedule is not created:
Create the vesting schedule promptly once both prerequisites are satisfied:
Tokens are stored in the vault.
An IP recipient has been assigned.
address recipient = _ipaRecipient[ipaId];
if (recipient != address(0) && !IIPOwnerVault(ownerVault).vesting(token).isSet) {
IIPOwnerVault(ownerVault).createVestingOnTokenDeploy(token);
}
if (wethAmount != 0) {
… // fee distribution logic
}
SOLVED: The suggested mitigation has been implemented by the IPWorld team.
//
When IPWorld.harvest()
completes fee collection, it forwards a portion of the WETH to the IP-token and calls repositionBidWall()
:
uint256 bidWallAmount = wethAmount * bidWallShare / PRECISION;
IERC20(_weth).transfer(address(token), bidWallAmount);
IIPToken(token).repositionBidWall();
The IPToken.repositionBidWall()
function removes the existing "bid-wall" liquidity position and mints a new one one tick away from the current price. The following describes the two symmetric failure scenarios:
Left boundary (−MAX_TICK): occurs when the IP-token is token0
(nativeIsZero == false
).
_collectLiquidity(pool, bidWallTickLower, nativeIsZero);
...
int24 baseTick = currentTick - 1; // = −887 221
newTickUpper = _validTick(baseTick, true); // clamps to −MAX_TICK
newTickLower = newTickUpper - TICK_SPACING; // −887 280 < MIN_TICK
_addLiquidity(... newTickLower ...) // ↯ TickMath.revert("T")
Right boundary (+MAX_TICK): occurs when WETH is token0
(nativeIsZero == true
).
_collectLiquidity(pool, bidWallTickLower, nativeIsZero);
...
int24 baseTick = currentTick + 1; // = 887 221
newTickLower = _validTick(baseTick, false); // clamps to +MAX_TICK
newTickUpper = newTickLower + TICK_SPACING; // 887 280 > MAX_TICK
_addLiquidity(... newTickUpper ...) // ↯ TickMath.revert("T")
In cases where newTickLower
underflows below TickMath.MIN_TICK
(−887,272), or newTickUpper
overflows above TickMath.MAX_TICK
(887,220), both scenarios cause TickMath
to revert with the generic "T()"
error, effectively rendering harvest()
inoperable.
Impact:
This is an edge case but can result in a repeatedly-triggerable denial-of-service for IPWorld.harvest()
on affected tokens.
Protocol fee distribution, bid-wall refresh, treasury income, and IP-owner vesting are all interrupted.
The attack cost is minimal once the attacker possesses enough tokens to manipulate the thin one-sided pool.
Add the following test case to test/IPWorld.t.sol
:
function test_BidWall_DoS_Revert() public {
vm.prank(address(operator));
(, address tokenAddr) =
ipWorld.createIpToken(alice, "CHILL", "CHILL", address(0), startTickList, allocationList);
IPToken ipToken = IPToken(tokenAddr);
// 2. Fund the token with a small amount of WETH to bypass the 0-balance guard.
// This mimics the WETH that IPWorld would send during harvest.
weth.deposit{value: 1 ether}();
weth.transfer(tokenAddr, 1 ether);
// 3. Calling `repositionBidWall()` now must revert with Uniswap's 'T' error
// because the computed `newTickLower` underflows below MIN_TICK.
vm.expectRevert(bytes("T()"));
ipToken.repositionBidWall();
}
Clamp tick bounds within repositionBidWall()
(and _collectLiquidity
) to ensure:
If newTickLower
would be below MIN_TICK
, it is set to MIN_TICK
.
If newTickUpper
would be above MAX_TICK
, it is set to MAX_TICK
.
SOLVED: The IPWorld
team implemented boundary checks after tick calculation. The system now skips repositioning when near tick boundaries while maintaining all essential protocol functions, including fee collection, distribution, token burning, and treasury payments, which continue to operate normally. This approach creates a system where the bid wall temporarily halts repositioning at extreme price levels that are economically costly to reach and sustain, rather than disrupting the entire protocol. Repositioning automatically resumes when market conditions normalize.
//
The IPOwnerVault::initialize
function in V2 retains the initializer
modifier from version 1.
function initialize(address initialOwner) public initializer {
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
}
Since the proxy has already been initialized using version 1, this call will revert.
Rename it to initializeV2
and protect it using reinitializer(2)
.
SOLVED: The IPWorld team has implemented the recommended mitigation measures.
//
IPWorld.sol
defines an upgrade hook:
function initialize(address initialOwner) public reinitializer(2) {
__UUPSUpgradeable_init();
__Ownable_init(initialOwner);
}
Both __UUPSUpgradeable_init()
and __Ownable_init()
are tagged with initializer
(version 1). After the proxy is deployed, version 1 is already consumed. Calling them again from inside a reinitializer(2)
will therefore revert with "contract is already initialized". Similar issue exists in IPOwnerVault.sol
.
Remove the base initializers and only set new variables:
function initializeV2(..) public reinitializer(2) {
// no __UUPSUpgradeable_init or __Ownable_init here
newStateVar = …;
}
SOLVED: The suggested mitigation was implemented.
//
After upgrading the IPWorld contract, the harvest()
function fails when called on tokens deployed with older versions of IPToken that lack the repositionBidWall()
function. This introduces a critical backward compatibility issue, preventing successful fee collection and distribution for existing tokens.
function harvest(address token) external {
// ... fee collection logic ...
if (wethAmount != 0) {
uint256 bidWallAmount = wethAmount * bidWallShare / PRECISION;
uint256 ipOwnerAmount = wethAmount * ipOwnerShare / PRECISION;
IERC20(_weth).transfer(address(token), bidWallAmount);
IIPToken(token).repositionBidWall();
// ... rest of distribution logic
}
}
Impact:
Failure to harvest fees from legacy tokens
Blocked distribution of LP fees to IP owners and stakers
Potential accumulation of unharvested fees within pools
Implement a fallback mechanism that:
Attempts to retrieve the pool address via the liquidityPool()
function.
If this attempt fails, calculates the pool address using the Uniswap V3 Factory.
For legacy tokens, skips the buyback process and directs the buyback funds to the treasury instead.
SOLVED: The above fix was implemented by IPWorld
team.
//
The IPWorldLPManager
forwards liquidity operations to the NonfungiblePositionManager
. For both mint()
and burn()
functions, it sets the deadline
parameter to block.timestamp
instead of accepting it from the user:
deadline: block.timestamp
Since the call is executed within the same transaction, the check require(block.timestamp <= deadline)
within the periphery contract always passes, even if the user's transaction remains in the mempool for minutes or hours. This hard-coded value effectively nullifies the purpose of the deadline
parameter, leaving liquidity providers vulnerable to price fluctuations and MEV exploitation while their transaction is pending.
Accept a uint256 deadline
parameter from the caller and pass it unchanged to the position-manager
struct:
function mint(..., uint256 deadline) external payable returns (...) {
require(block.timestamp <= deadline, "deadline passed");
INonfungiblePositionManager.MintParams memory params = … { deadline: deadline };
...
}
Similarly, this applies to the burn()
helper function.
SOLVED: The IPWorld team has implemented the recommended mitigation measures.
//
IPWorld::claimToken()
permits the Operator
to transfer any ERC-20 tokens held by the IPWorld
contract to an arbitrary set of recipients:
function claimToken(address token, address[] calldata addressList, uint256[] calldata amountList)
external
onlyOperator
{
…
if (amount > 0) IERC20(token).transfer(recipient, amount);
}
However, this function is never invoked by the designated Operator
contract.
Additional vulnerabilities within claimToken()
increase the security risk:
The function does not enforce that addressList.length == amountList.length
.
It ignores the return value of IERC20.transfer
, potentially losing funds when interacting with non-standard ERC-20 tokens.
No events are emitted, which hampers off-chain monitoring and auditing.
Clarify responsibility:
If token recovery is meant to be manual, restrict the function to onlyOwner
(or remove it entirely) and execute via multisig.
If it must remain operator-controlled, expose a wrapper in Operator
that is protected by off-chain signatures.
Add the missing safety guards:
require(addressList.length == amountList.length);
Emit an event (e.g., TokenClaimed(token, recipient, amount)
).
Use SafeERC20.safeTransfer
to handle non-standard tokens.
SOLVED:
This finding was fixed with following comment from IPWorld team:
Regarding "unused function": The claimToken()
function will be called by an EOA (Externally Owned Account) Operator, not by the Operator contract. This is an intentional design decision for manual token distribution.
Implemented fix: Added array length validation to prevent out-of-bounds access
Added new error IPWorld_InvalidArgument
for invalid arguments
Added validation: if (length \!= amountList.length) revert Errors.IPWorld_InvalidArgument();
This ensures addressList
and amountList
have matching lengths
//
Operator::_transferETH()
sends ETH to a recipient using a hard-coded gas stipend of 30,000:
(bool success,) = to.call{value: value, gas: 30_000}(new bytes(0));
if (!success) revert();
Many smart contract wallets (e.g., Gnosis Safe) and on-chain interactions require more than 30,000 gas to execute their fallback functions. When the recipient is such a contract, the call reverts, preventing users from receiving refunds or proceeds after createIpTokenWithSig
and other processes that rely on _transferETH
.
Consequently, certain wallet types (e.g., safes, paymasters, proxy wallets) are unable to interact with the protocol because refund transactions fail.
Use Address.sendValue
(forwards all available gas and reverts on failure) or an unconstrained call{value: value}
.
Alternatively, allow the caller to withdraw unclaimed ETH manually.
SOLVED: Removed the hardcoded gas limit of 30,000
from the _transferETH
function and replaced it with a call to to.call{value: value}("")
//
In IPWorld::constructor
, the variables burnShare
, ipOwnerShare
, and bidWallShare
are only validated individually through range checks. However, if the sum of ipOwnerShare
and bidWallShare
exceeds PRECISION (1,000,000)
, it causes an underflow in the treasury payout calculation, resulting in every harvest()
transaction reverting.
(success,) = treasury.call{value: wethAmount - ipOwnerAmount - bidWallAmount}("");
Include the following check in the constructor:
require(ipOwnerShare + bidWallShare <= PRECISION, "Share overflow");
SOLVED: The IPWorld team has implemented the recommended mitigation measures.
//
The IPWorld::claimIp()
function allows an operator to overwrite the existing recipient for any IP without checking whether the IP was previously claimed or that the caller is the current owner.
Add basic safeguards such as:
require(_ipaRecipient[ipaId] == address(0), "IP already claimed");
or implement a two-step transfer process, requiring the current recipient to approve any changes.
SOLVED: Implemented a two-step transfer process requiring approval from the current owner.
//
In Operator::setExpectedSigner
, setting signer to address(0)
blocks all future EIP-712 operations.
function setExpectedSigner(address expectedSigner_) external onlyOwner {
expectedSigner = expectedSigner_;
}
Add a check for address(0)
:
require(expectedSigner_ != address(0), "zero signer");
SOLVED: The setExpectedSigner
function now performs proper validation to prevent setting the zero address.
//
In IPWorld
, loop counters utilize checked arithmetic, although overflow cannot occur in this context.
for (uint256 i = 1; i <= length; ++i) { ... }
Use the unchecked
block for for
loop increments in IPWorld.sol
:
for (uint256 i = 1; i <= length; ) {
...
unchecked { ++i; }
}
ACKNOWLEDGED: The IPWorld team acknowledged the finding with following comment - "While the suggestion is technically valid, the trade-off between code clarity and minimal gas savings doesn't justify the change at this time."
//
Several contracts still contain // TODO
comments indicating incomplete logic. These placeholders range from missing event emissions to generic revert()
statements lacking specific error codes. Leaving such TODOs in deployed code reduces transparency, disrupts off-chain accounting, and can result in vague or misleading failure messages.
Complete the implementation or removal of all TODO items prior to deployment:
IPToken.sol::storyHuntV3MintCallback
IPWorldLPManager.sol::whitelistToken
IPWorldLPManager.sol::burn
IPOwnerVault.sol::constructor
IPWorld.sol::harvest
SOLVED: The issues marked with TODO placeholders have been addressed and resolved.
Halborn
employed automated testing techniques to improve coverage in specific areas of the scope's smart contracts. One of the tools utilized was Slither
, a Solidity static analysis framework. After verifying that the smart contracts in the repository could be compiled correctly into their ABIs and binary formats, Slither
was executed against the contracts. This tool performs static analysis to verify mathematical relationships between Solidity variables, enabling the detection of invalid or inconsistent API usage throughout the entire codebase.
All issues detected by Slither
were determined to be false positives and were therefore not included in the issue list presented in this report.
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
// Download the full report
Core V2
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed