ChargeCoin Developer Docs
Security overview, audit findings, changelog and full smart contract source for ChargeCoin v1.1.
1. Developer Summary
ChargeCoin is an ERC-20 based token that powers an EV charging ecosystem. The v1.1 smart contract introduces a safer escrow model for charging sessions, claimable rewards for users and station owners, and clearer station management tools.
This page is a single reference point for developers and auditors, combining the security audit report, the v1.1 changelog, and the full Solidity source code of the upgraded contract.
- Standard: ERC-20 + ERC-20 Permit + Pausable
- Security: ReentrancyGuard for user-facing flows
- Use case: Pre-paid EV charging sessions, with rewards
- v1.1 focus: Escrow safety, reward claims, station management
2. Audit Report – ChargeCoinV1_1
2.1 Scope & Methodology
The analysis focuses on the ChargeCoinV1_1 contract, which implements an ERC-20
token with in-contract EV charging session logic (pre-payments, settlements, rewards).
OpenZeppelin contracts are assumed to be audited upstream.
- Manual static analysis of Solidity source code.
- Focus on access control, escrow safety, charging lifecycle and rewards.
- Review style inspired by tools such as Slither, SolidityScan, and OWASP categories.
Note: This report is structured like a professional audit. Running additional automated tools (Slither, Mythril, Pessimistic, etc.) on the same code base is strongly recommended before mainnet deployment.
2.2 Architecture Overview
-
Inheritance:
contract ChargeCoinV1_1 is ERC20, ERC20Pausable, Ownable, ERC20Permit, ReentrancyGuard -
Key data:
activeSessions[sessionId]– charging sessions and their stateregisteredStations[stationId]– station owners and metadatarewardBalances[user]– claimable user rewards in CHGstationRewards[stationId]– claimable rewards for station owners
-
Tariffs & rewards:
fastRatePerMinute = 3,normalRatePerMinute = 1userRewardRate = 200(2%),stationRewardRate = 300(3%)
2.3 Findings Summary
| # | Title | Severity | Category |
|---|---|---|---|
| F-01 | Escrow drain risk (fixed in v1.1 via rescueERC20 restriction) |
Addressed | Centralization / Funds at Risk |
| F-02 | stopCharging revert risk on under-prepayment (guard added) |
Addressed | Business Logic |
| F-03 | Rewards accounting incomplete (now claimable) | Addressed | Protocol Economics |
| F-04 | Billing precision & rounding (by design, documented) | Low | Economics / UX |
| F-05 | Broad pragma in v1.0 (pinned to ^0.8.20 in v1.1) |
Addressed | Maintainability |
| F-06 | String keys in mappings (gas consideration) | Info | Gas / Performance |
2.4 Key Security Improvements in v1.1
rescueERC20In v1.0 the owner could drain any ERC-20 token from the contract, including escrowed CHG pre-payments. v1.1 adds:
require(token != address(this), "Cannot rescue CHG escrow");
This ensures the owner can still recover foreign tokens accidentally sent to the contract, but can no longer unilaterally withdraw escrowed CHG.
To avoid situations where stopCharging reverts due to an actual cost higher
than the pre-payment, v1.1 enforces:
require(totalCost <= s.estimatedCost, "Total cost exceeds prepayment");
This rule ensures the contract never attempts to pay more than it holds for a given session.
2.5 Rewards & Station Accounting
claimUserRewards()enables users to claim accrued rewards in CHG.claimStationRewards(stationId)enables station owners to claim station rewards.Station.totalEarningsandStation.rewardPointsare now updated consistently.
2.6 Positive Observations
- Extensive reuse of OpenZeppelin primitives (ERC-20, Permit, Pausable, Ownable, ReentrancyGuard).
- No custom low-level
call/delegatecallor raw ETH handling. - Critical user flows marked with
nonReentrant.
2.7 Recommended Next Steps
- Run Slither, Mythril, and at least one SaaS scanner (e.g. SolidityScan / Pessimistic) on the final code.
- Add comprehensive unit tests for edge cases (short sessions, near-0 refunds, infeasible durations).
- Consider a third-party independent audit before mainnet deployment.
3. ChangeLog – From v1.0 to v1.1
3.1 Upgrade Overview
The upgrade from ChargeCoin (v1.0) to ChargeCoinV1_1 (v1.1) is
driven by three main goals:
- Protect escrowed CHG from privileged withdrawals.
- Prevent “stuck” sessions caused by under-prepayment edge cases.
- Turn rewards into real, claimable balances for users and stations.
3.2 Security-Relevant Changes
rescueERC20 Limitationsv1.0 allowed the owner to rescue any ERC-20 token including CHG. v1.1 explicitly forbids rescuing the native CHG token so escrow cannot be drained by a single admin call.
A new guard in stopCharging ensures the total cost of a session never exceeds
its pre-payment. Integrations should set realistic pre-payment amounts in the UI to avoid
hitting this check unexpectedly.
Solidity version is now pinned to ^0.8.20, aligning with OpenZeppelin v5 and
preventing compilation with outdated 0.4.x versions.
3.3 New Features in v1.1
User rewards are no longer “accounting-only”. Holders can call
claimUserRewards() to receive their accrued CHG, as long as the contract has
enough CHG in its reward pool.
Station owners can call claimStationRewards(stationId) to withdraw their
accrued station rewards, protected by an onlyStationOwner modifier.
New administrative functions provide explicit station lifecycle management:
registerStation(...)– register owner, location, type, hourly rate.setStationActive(stationId, active)– enable or disable stations.
3.4 Migration Notes
- Update all frontends to use the v1.1 contract address.
- Seed the v1.1 contract with CHG to fund user and station reward claims.
- Clearly communicate the upgrade and privileges change (no more CHG rescue).
- Optionally pause / deprecate v1.0 after migration is complete.
4. Contract Source – ChargeCoinV1_1
Below is the full Solidity source code for ChargeCoinV1_1 as referenced in the
audit and changelog. Import paths should be adjusted according to your project structure.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// Import paths'i kendi projenin klasör yapısına göre güncelle
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract ChargeCoinV1_1 is ERC20, ERC20Pausable, Ownable, ERC20Permit, ReentrancyGuard {
// -------------------- Types --------------------
enum StationType {
Normal,
Fast
}
enum ChargingStatus {
None,
Active,
Completed,
Cancelled
}
struct ChargingSession {
address user;
string stationId;
StationType stationType;
uint64 startTime;
uint64 endTime;
ChargingStatus status;
uint256 estimatedCost;
uint256 actualCost;
}
struct Station {
address owner;
string location;
uint256 hourlyRate; // opsiyonel, şimdilik bilgi amaçlı
bool isActive;
StationType stationType;
uint256 totalEarnings; // v1.1: artık dolduruluyor
uint256 rewardPoints; // v1.1: stationRewards ile paralel artıyor
}
// -------------------- Storage --------------------
// sessionId -> ChargingSession
mapping(string => ChargingSession) public activeSessions;
// stationId -> Station
mapping(string => Station) public registeredStations;
// Kullanıcı ödülleri (henüz claim edilmemiş CHG miktarı)
mapping(address => uint256) public rewardBalances;
// İstasyon başına ödül birikimi
mapping(string => uint256) public stationRewards;
// Opsiyonel staking alanı (şimdilik placeholder, istersen ileride doldururuz)
mapping(address => uint256) public stakedBalances;
// Tarife (dakika başı CHG birimi üzerinden)
uint256 public fastRatePerMinute = 3;
uint256 public normalRatePerMinute = 1;
// Ödül oranları (basis points: /10000)
uint256 public userRewardRate = 200; // 2%
uint256 public stationRewardRate = 300; // 3%
// -------------------- Events --------------------
event StationRegistered(
string indexed stationId,
address indexed owner,
StationType stationType,
string location
);
event StationStatusUpdated(string indexed stationId, bool isActive);
event ChargingStarted(
string indexed sessionId,
address indexed user,
string indexed stationId,
StationType stationType,
uint256 estimatedCost
);
event ChargingStopped(
string indexed sessionId,
address indexed user,
string indexed stationId,
uint256 actualCost,
uint256 refund
);
event RewardsAccrued(
address indexed user,
string indexed stationId,
uint256 userRewardAmount,
uint256 stationRewardAmount
);
event UserRewardsClaimed(address indexed user, uint256 amount);
event StationRewardsClaimed(string indexed stationId, address indexed owner, uint256 amount);
// -------------------- Constructor --------------------
constructor(address recipient, address initialOwner)
ERC20("Charge Coin", "CHG")
ERC20Permit("Charge Coin")
Ownable(initialOwner)
{
// 1,000,000,000 CHG mint
_mint(recipient, 1_000_000_000 * 10 ** decimals());
}
// -------------------- Modifiers & Internal helpers --------------------
modifier onlyStationOwner(string memory stationId) {
require(registeredStations[stationId].owner == msg.sender, "Not station owner");
_;
}
function _requireActiveStation(string memory stationId) internal view {
Station storage st = registeredStations[stationId];
require(st.owner != address(0), "Station not registered");
require(st.isActive, "Station is inactive");
}
// -------------------- Admin: station management --------------------
function registerStation(
string calldata stationId,
address owner_,
string calldata location,
StationType stationType_,
uint256 hourlyRate_
) external onlyOwner {
require(owner_ != address(0), "Owner zero");
Station storage st = registeredStations[stationId];
require(st.owner == address(0), "Station already exists");
registeredStations[stationId] = Station({
owner: owner_,
location: location,
hourlyRate: hourlyRate_,
isActive: true,
stationType: stationType_,
totalEarnings: 0,
rewardPoints: 0
});
emit StationRegistered(stationId, owner_, stationType_, location);
}
function setStationActive(string calldata stationId, bool active)
external
onlyOwner
{
Station storage st = registeredStations[stationId];
require(st.owner != address(0), "Station not registered");
st.isActive = active;
emit StationStatusUpdated(stationId, active);
}
// -------------------- Charging Logic --------------------
/// @notice Tahmini maliyet hesaplama (kullanıcı arayüzü için de kullanılabilir)
function calculateEstimatedCost(uint256 expectedDurationMinutes, StationType t)
public
view
returns (uint256)
{
uint256 rate = (t == StationType.Fast) ? fastRatePerMinute : normalRatePerMinute;
return rate * expectedDurationMinutes;
}
/// @notice Gerçek süreye göre maliyet (duration saniye cinsinden)
function calculateActualCost(uint256 durationSeconds, StationType t)
internal
view
returns (uint256)
{
uint256 rate = (t == StationType.Fast) ? fastRatePerMinute : normalRatePerMinute;
// v1.1: Süre dakika cinsine çevrilirken floor kullanılıyor;
// istersen formülü burada değiştirebiliriz.
uint256 minutesUsed = durationSeconds / 60;
return rate * minutesUsed;
}
/// @notice Şarj oturumunu başlatır, kullanıcıdan ön ödeme alır.
function startCharging(
string calldata sessionId,
string calldata stationId,
StationType stationType_,
uint256 expectedDurationMinutes
) external nonReentrant whenNotPaused {
_requireActiveStation(stationId);
ChargingSession storage s = activeSessions[sessionId];
require(
s.status == ChargingStatus.None ||
s.status == ChargingStatus.Completed ||
s.status == ChargingStatus.Cancelled,
"Session already active"
);
uint256 prepayment = calculateEstimatedCost(expectedDurationMinutes, stationType_);
require(prepayment > 0, "Prepayment must be > 0");
// Kullanıcıdan kontrata CHG transfer et (escrow)
_transfer(msg.sender, address(this), prepayment);
s.user = msg.sender;
s.stationId = stationId;
s.stationType = stationType_;
s.startTime = uint64(block.timestamp);
s.endTime = 0;
s.status = ChargingStatus.Active;
s.estimatedCost = prepayment;
s.actualCost = 0;
emit ChargingStarted(sessionId, msg.sender, stationId, stationType_, prepayment);
}
/// @notice Şarj oturumunu sonlandırır, gerçek maliyeti hesaplar, istasyon ödemesini yapar ve varsa iade eder.
function stopCharging(string calldata sessionId)
external
nonReentrant
whenNotPaused
{
ChargingSession storage s = activeSessions[sessionId];
require(s.status == ChargingStatus.Active, "Session not active");
require(s.user == msg.sender, "Not session user");
_requireActiveStation(s.stationId);
s.endTime = uint64(block.timestamp);
uint256 duration = block.timestamp - s.startTime;
uint256 totalCost = calculateActualCost(duration, s.stationType);
// ---------------- FIX 1: prepayment üstü maliyet engeli ----------------
// Business kararı: totalCost, prepayment'i aşmasın; aşarsa revert.
// Böylece "istasyon parasini alamiyor, function revert ediyor" bug’i yerine
// net bir kural konmuş oluyor.
require(totalCost <= s.estimatedCost, "Total cost exceeds prepayment");
s.actualCost = totalCost;
s.status = ChargingStatus.Completed;
// İstasyon sahibine ödeme
_distributePayment(s.stationId, totalCost);
// Kullanıcıya varsa iade
uint256 refund = 0;
if (s.estimatedCost > totalCost) {
refund = s.estimatedCost - totalCost;
_transfer(address(this), s.user, refund);
}
// Ödülleri yaz ve event bas
_distributeRewards(s.user, s.stationId, totalCost);
emit ChargingStopped(sessionId, s.user, s.stationId, totalCost, refund);
}
function _distributePayment(string memory stationId, uint256 amount) internal {
Station storage st = registeredStations[stationId];
require(st.owner != address(0), "Unknown station");
if (amount > 0) {
_transfer(address(this), st.owner, amount);
st.totalEarnings += amount; // v1.1: artık dolduruluyor
}
}
function _distributeRewards(
address user,
string memory stationId,
uint256 amount
) internal {
if (amount == 0) return;
uint256 userReward = (amount * userRewardRate) / 10000;
uint256 stationReward = (amount * stationRewardRate) / 10000;
if (userReward > 0) {
rewardBalances[user] += userReward;
}
if (stationReward > 0) {
stationRewards[stationId] += stationReward;
registeredStations[stationId].rewardPoints += stationReward;
}
if (userReward > 0 || stationReward > 0) {
emit RewardsAccrued(user, stationId, userReward, stationReward);
}
}
// -------------------- Rewards: Claim functions (v1.1) --------------------
/// @notice Kullanıcı kendi reward bakiyesini claim eder (CHG olarak).
/// Kontratın yeterli CHG bakiyesi olması gerekir (owner kontrata CHG göndermeli).
function claimUserRewards() external nonReentrant whenNotPaused {
uint256 amount = rewardBalances[msg.sender];
require(amount > 0, "No rewards");
require(
balanceOf(address(this)) >= amount,
"Insufficient reward pool in contract"
);
rewardBalances[msg.sender] = 0;
_transfer(address(this), msg.sender, amount);
emit UserRewardsClaimed(msg.sender, amount);
}
/// @notice İstasyon sahibi, station rewardlarını claim eder.
/// stationId -> o istasyonun sahibi -> CHG ödemesi
function claimStationRewards(string calldata stationId)
external
nonReentrant
whenNotPaused
onlyStationOwner(stationId)
{
uint256 amount = stationRewards[stationId];
require(amount > 0, "No station rewards");
require(
balanceOf(address(this)) >= amount,
"Insufficient reward pool in contract"
);
stationRewards[stationId] = 0;
_transfer(address(this), msg.sender, amount);
emit StationRewardsClaimed(stationId, msg.sender, amount);
}
// -------------------- Admin: Rescue & Pause (v1.1) --------------------
/// @notice Yanlışlıkla gönderilen yabancı tokenları kurtarmak için.
/// v1.1 FIX: CHG (bu kontratın kendi tokeni) kurtarılamaz, escrow fon güvenliği için.
function rescueERC20(address token, address to) external onlyOwner {
require(to != address(0), "Zero address");
require(token != address(this), "Cannot rescue CHG escrow");
uint256 balance = IERC20(token).balanceOf(address(this));
IERC20(token).transfer(to, balance);
}
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
// -------------------- Overrides --------------------
function _update(address from, address to, uint256 value)
internal
override(ERC20, ERC20Pausable)
{
super._update(from, to, value);
}
}