-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathAppToken.sol
More file actions
367 lines (323 loc) · 13.6 KB
/
Copy pathAppToken.sol
File metadata and controls
367 lines (323 loc) · 13.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {FeeKind} from "../fees/FeeKind.sol";
interface IFeeCollector {
function depositAppToken(uint256 appId, FeeKind kind, address token, uint256 amount) external;
}
/**
* @title AppToken
* @author Elata Biosciences
* @custom:security-contact security@elata.bio
* @notice ERC20 token for individual apps in the Elata ecosystem with optional transfer fees.
* @dev Standard ERC20 extended with burning, ERC20Permit for gasless approvals, and a configurable
* transfer fee (default 1%, max 2%) routed through FeeCollector. Protocol contracts such as
* the bonding curve and staking vault are fee-exempt. Minting is role-controlled and can be
* permanently finalized. Stores app metadata including description, image URI, and website.
*/
contract AppToken is ERC20, ERC20Burnable, ERC20Permit, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant APP_OPERATOR_ROLE = keccak256("APP_OPERATOR_ROLE");
bytes32 public constant LP_MANAGER_ROLE = keccak256("LP_MANAGER_ROLE");
bytes32 public constant FEE_EXEMPT_MANAGER_ROLE = keccak256("FEE_EXEMPT_MANAGER_ROLE");
uint8 private immutable _decimals;
uint256 public immutable maxSupply;
/// @notice Whether minting has been finalized (irreversible)
bool public mintingFinalized;
// App metadata
string public appDescription;
string public appImageURI;
string public appWebsite;
address public appCreator;
// Transfer fee configuration
uint16 public transferFeeBps = 100; // 1%, governance-configurable
uint16 public constant MAX_TRANSFER_FEE_BPS = 200; // 2% max
address public governance;
mapping(address => bool) public transferFeeExempt;
// Burn configuration (portion of transfer fee that gets burned)
uint16 public burnFeeBps = 0; // Default: no burn
uint16 public constant MAX_BURN_FEE_BPS = 200; // 2% max burn
address public constant BURN_SINK = 0x000000000000000000000000000000000000dEaD;
// LP-keyed tax: only tax transfers to/from liquidity pools
mapping(address => bool) public isLiquidityPool;
// Fee collection
address public feeCollector;
uint256 public appId; // App ID for FeeCollector accounting
address public appVault; // Store vault for exemption
event AppMetadataUpdated(string description, string imageURI, string website);
event MintingFinalized();
event Minted(address indexed to, uint256 amount);
event TransferFeeUpdated(uint16 oldBps, uint16 newBps);
event BurnFeeBpsUpdated(uint16 oldBps, uint16 newBps);
event TransferFeeExemptSet(address indexed account, bool exempt);
event LPAddressUpdated(address indexed lp, bool isLP);
event FeeCollectorUpdated(address indexed oldCollector, address indexed newCollector, uint256 appId);
event TransferTaxCollected(uint256 indexed appId, address indexed token, uint256 amount, address from, address to);
error SupplyCapExceeded();
error OnlyCreator();
error MintingAlreadyFinalized();
error FeeTooHigh();
error OnlyGovernance();
error VaultAlreadySet();
error FeeCollectorNotSet();
/// @notice Constructor parameters bundled to avoid stack too deep
struct InitParams {
string name;
string symbol;
uint8 decimals;
uint256 maxSupply;
address creator;
address admin;
address governance;
// Legacy fields (no longer used for fee routing / yield distribution).
address appRewardsDistributor;
address rewardsDistributor;
address treasury;
}
/**
* @notice Initialize app token with metadata
* @param p InitParams struct containing all constructor parameters
*/
constructor(InitParams memory p) ERC20(p.name, p.symbol) ERC20Permit(p.name) {
require(p.creator != address(0) && p.admin != address(0), "Zero address");
require(p.governance != address(0), "Zero governance");
require(p.maxSupply > 0, "Invalid supply");
_decimals = p.decimals;
maxSupply = p.maxSupply;
appCreator = p.creator;
governance = p.governance;
// Set initial exemptions
transferFeeExempt[address(this)] = true;
transferFeeExempt[p.admin] = true; // factory
_grantRole(DEFAULT_ADMIN_ROLE, p.admin);
_grantRole(MINTER_ROLE, p.admin);
}
function decimals() public view override returns (uint8) {
return _decimals;
}
/**
* @notice Returns the app creator (compatible with IOwnable)
* @return Address of the app creator
*/
function owner() public view returns (address) {
return appCreator;
}
/**
* @notice Mint tokens to specified address (typically bonding curve)
* @param to Address to mint tokens to
* @param amount Amount of tokens to mint
*/
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
if (mintingFinalized) revert MintingAlreadyFinalized();
if (maxSupply > 0 && totalSupply() + amount > maxSupply) revert SupplyCapExceeded();
_mint(to, amount);
emit Minted(to, amount);
}
/**
* @notice Permanently disable further minting
* @dev Irreversible operation - locks supply forever
*/
function finalizeMinting() external onlyRole(DEFAULT_ADMIN_ROLE) {
mintingFinalized = true;
emit MintingFinalized();
}
/**
* @notice Update app metadata (only creator)
* @param description_ App description
* @param imageURI_ App image URI
* @param website_ App website URL
*/
function updateMetadata(string calldata description_, string calldata imageURI_, string calldata website_)
external
{
if (msg.sender != appCreator) revert OnlyCreator();
appDescription = description_;
appImageURI = imageURI_;
appWebsite = website_;
emit AppMetadataUpdated(description_, imageURI_, website_);
}
/**
* @notice Revoke minter role (makes supply fixed)
* @param account Address to revoke minter role from
*/
function revokeMinter(address account) external onlyRole(DEFAULT_ADMIN_ROLE) {
_revokeRole(MINTER_ROLE, account);
}
/**
* @notice Set the app vault address (called by factory after vault creation)
* @param _vault Vault address
*/
function setVault(address _vault) external onlyRole(DEFAULT_ADMIN_ROLE) {
if (appVault != address(0)) revert VaultAlreadySet();
require(_vault != address(0), "Zero vault");
appVault = _vault;
transferFeeExempt[_vault] = true;
}
/**
* @notice Set transfer fee in basis points
* @dev Callable by governance, admin, or APP_OPERATOR_ROLE
* @param newBps New fee rate (0-200 = 0-2%)
*/
function setTransferFeeBps(uint16 newBps) external {
if (
msg.sender != governance && !hasRole(DEFAULT_ADMIN_ROLE, msg.sender)
&& !hasRole(APP_OPERATOR_ROLE, msg.sender)
) {
revert OnlyGovernance();
}
if (newBps > MAX_TRANSFER_FEE_BPS) revert FeeTooHigh();
emit TransferFeeUpdated(transferFeeBps, newBps);
transferFeeBps = newBps;
}
/**
* @notice Set transfer fee exemption status
* @dev Callable by governance, admin, or FEE_EXEMPT_MANAGER_ROLE
* @param account Address to update
* @param exempt True to exempt from fees
*/
function setTransferFeeExempt(address account, bool exempt) external {
if (
msg.sender != governance && !hasRole(DEFAULT_ADMIN_ROLE, msg.sender)
&& !hasRole(FEE_EXEMPT_MANAGER_ROLE, msg.sender)
) {
revert OnlyGovernance();
}
transferFeeExempt[account] = exempt;
emit TransferFeeExemptSet(account, exempt);
}
/**
* @notice Set liquidity pool status for LP-keyed taxation
* @dev Callable by governance, admin, or LP_MANAGER_ROLE
* @dev Transfers to/from LP addresses are taxed; wallet-to-wallet is not
* @param lp Address to update
* @param isLP True if address is a liquidity pool
*/
function setLiquidityPool(address lp, bool isLP) external {
if (
msg.sender != governance && !hasRole(DEFAULT_ADMIN_ROLE, msg.sender)
&& !hasRole(LP_MANAGER_ROLE, msg.sender)
) {
revert OnlyGovernance();
}
isLiquidityPool[lp] = isLP;
emit LPAddressUpdated(lp, isLP);
}
/**
* @notice Set the FeeCollector address and app ID for tax routing
* @dev Called by factory after app creation
* @param _feeCollector FeeCollector contract address
* @param _appId App ID for per-app accounting
*/
function setFeeCollector(address _feeCollector, uint256 _appId) external onlyRole(DEFAULT_ADMIN_ROLE) {
address oldCollector = feeCollector;
feeCollector = _feeCollector;
appId = _appId;
// Exempt fee collector from taxes
if (_feeCollector != address(0)) {
transferFeeExempt[_feeCollector] = true;
}
emit FeeCollectorUpdated(oldCollector, _feeCollector, _appId);
}
/**
* @notice Get transfer fee info for caller
* @return feeBps Current fee rate in basis points
* @return maxFeeBps Maximum allowed fee rate
* @return isExempt Whether caller is exempt from fees
*/
function getTransferFeeInfo() external view returns (uint16 feeBps, uint16 maxFeeBps, bool isExempt) {
feeBps = transferFeeBps;
maxFeeBps = MAX_TRANSFER_FEE_BPS;
isExempt = transferFeeExempt[msg.sender];
}
/**
* @notice Calculate transfer fee for a given amount
* @param amount Transfer amount
* @return fee Fee amount
* @return netAmount Amount after fee
*/
function calculateTransferFee(uint256 amount) external view returns (uint256 fee, uint256 netAmount) {
fee = (amount * transferFeeBps) / 10_000;
netAmount = amount - fee;
}
/**
* @dev Override _update to implement LP-keyed fee-on-transfer
*
* LP-Keyed Tax Rules:
* - Tax only applies when: neither side is exempt AND (isLiquidityPool[from] || isLiquidityPool[to])
* - Wallet-to-wallet transfers are NOT taxed
* - Mints and burns are NOT taxed
* - Tax is routed to FeeCollector for per-app accounting and later distribution
*/
function _update(address from, address to, uint256 amount) internal override {
// Skip fee for mints and burns
if (from == address(0) || to == address(0)) {
super._update(from, to, amount);
return;
}
// Skip fee if either party is exempt
if (transferFeeExempt[from] || transferFeeExempt[to]) {
super._update(from, to, amount);
return;
}
// Skip fee if fee rate is zero
if (transferFeeBps == 0) {
super._update(from, to, amount);
return;
}
// LP-KEYED TAX: Only tax transfers involving liquidity pools
// Wallet-to-wallet transfers are NOT taxed
bool involvesLP = isLiquidityPool[from] || isLiquidityPool[to];
if (!involvesLP) {
super._update(from, to, amount);
return;
}
// Calculate fee for LP transfer
uint256 fee = (amount * transferFeeBps) / 10_000;
uint256 netAmount = amount - fee;
// Transfer net amount to recipient
super._update(from, to, netAmount);
// Calculate and apply burn (proportional portion of fee)
uint256 burnAmount = 0;
if (burnFeeBps > 0 && fee > 0) {
burnAmount = (fee * burnFeeBps) / transferFeeBps;
if (burnAmount > 0) {
super._update(from, BURN_SINK, burnAmount);
}
}
uint256 routedFee = fee - burnAmount;
// Route remaining fee to FeeCollector (if configured) or legacy distributors
if (routedFee > 0) {
if (feeCollector != address(0)) {
// FeeCollector accounting must be explicit (do not just transfer tokens).
// Move the fee to this contract, then deposit into FeeCollector so it can bucket
// by (appId, FeeKind, asset) and be swept/swapped into FeeSwapper.
super._update(from, address(this), routedFee);
_approve(address(this), feeCollector, routedFee);
IFeeCollector(feeCollector).depositAppToken(appId, FeeKind.TRANSFER_TAX, address(this), routedFee);
emit TransferTaxCollected(appId, address(this), routedFee, from, to);
} else {
// Enforce a single fee path; avoid silent legacy yield-like routing.
revert FeeCollectorNotSet();
}
}
}
/**
* @notice Set burn fee (portion of transfer fee that gets burned)
* @param newBps New burn fee in basis points (max 2%)
*/
function setBurnFeeBps(uint16 newBps) external {
if (
msg.sender != governance && !hasRole(DEFAULT_ADMIN_ROLE, msg.sender)
&& !hasRole(APP_OPERATOR_ROLE, msg.sender)
) {
revert OnlyGovernance();
}
require(newBps <= MAX_BURN_FEE_BPS, "Exceeds max burn fee");
uint16 oldBps = burnFeeBps;
burnFeeBps = newBps;
emit BurnFeeBpsUpdated(oldBps, newBps);
}
}