Skip to content

Commit 8b65ae6

Browse files
committed
docs: fix per-wallet APY walkthrough and align README with post-audit contract
- Fix walkthrough: use net unstake amount (495) not gross (550), correcting netProfit from 155 to 100 and APY from 19% to 12.3% - Clarify SQL schema: annotate rnbw_amount as gross, exit_fee for unstakes - Split event docs: per-wallet events vs PoolTotalsUpdated (global only) - Add DustSharesRemaining to custom errors table - Add MIN_SHARES_THRESHOLD (1e14) to security features - Add dust guard to unstaking flow step 2 - Note TWAC stale-rate approximation for integrators
1 parent 5334c94 commit 8b65ae6

File tree

1 file changed

+42
-22
lines changed

1 file changed

+42
-22
lines changed

README.md

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ Steps:
8484
Both functions return `netAmount` (the RNBW transferred to the user after exit fee).
8585

8686
1. Sync pool -- settle any pending fee drip into `totalPooledRnbw` before calculating.
87-
2. Validate -- shares > 0, sufficient balance, partial unstake check.
87+
2. Validate -- shares > 0, sufficient balance, partial unstake check, dust guard (partial unstake must not leave fewer than `MIN_SHARES_THRESHOLD` shares).
8888
3. Calculate value -- `rnbwValue = (sharesToBurn * totalPooledRnbw) / totalShares`.
8989
4. Exit fee -- `exitFee = rnbwValue * exitFeeBps / 10,000` (ceil-rounded, default 10%).
9090
5. Net amount -- if ceil-rounded fee consumes everything (dust), `netAmount = 0` and shares are burned with no transfer (lets users clear dust positions).
@@ -326,38 +326,50 @@ CREATE TABLE staking_events (
326326
event_type TEXT NOT NULL, -- 'stake', 'unstake', 'cashback'
327327
block_number BIGINT NOT NULL,
328328
block_timestamp TIMESTAMPTZ NOT NULL,
329-
rnbw_amount NUMERIC(78,0) NOT NULL,
329+
rnbw_amount NUMERIC(78,0) NOT NULL, -- gross: rnbwAmount (stake/cashback), rnbwValue (unstake)
330330
shares_delta NUMERIC(78,0) NOT NULL,
331331
exchange_rate NUMERIC(38,18) NOT NULL,
332-
exit_fee NUMERIC(78,0) DEFAULT 0,
332+
exit_fee NUMERIC(78,0) DEFAULT 0, -- only populated for unstake events
333333
tx_hash TEXT NOT NULL,
334334
UNIQUE(tx_hash, wallet, event_type)
335335
);
336336

337337
CREATE INDEX idx_staking_events_wallet ON staking_events(wallet, block_timestamp);
338338
```
339339

340-
#### Deriving Exchange Rate From Events (No Extra RPC Calls)
340+
For unstake events: `rnbw_amount` is the gross value before fee (`rnbwValue` from the event), `exit_fee` is the fee deducted. Net received = `rnbw_amount - exit_fee`. The APY formula uses the net amount (see walkthrough below).
341341

342-
The exchange rate is implicit in every event — no need to emit it separately:
342+
#### Which Events to Track
343343

344-
| Event | Formula |
345-
|-------|---------|
346-
| `Staked(user, rnbwAmount, sharesMinted, _)` | `rnbwAmount / sharesMinted` |
347-
| `Unstaked(user, sharesBurned, rnbwValue, _, _)` | `rnbwValue / sharesBurned` |
348-
| `CashbackAllocated(user, rnbwAmount, sharesMinted)` | `rnbwAmount / sharesMinted` |
344+
Two separate event sets serve different purposes:
345+
346+
**Per-wallet events** — needed for per-wallet APY (TWAC calculation). Each carries the `user` address, shares delta, and RNBW amounts:
347+
348+
| Event | Fields | Exchange Rate |
349+
|-------|--------|---------------|
350+
| `Staked(user, rnbwAmount, sharesMinted, _)` | who staked, how much, shares received | `rnbwAmount / sharesMinted` |
351+
| `Unstaked(user, sharesBurned, rnbwValue, _, _)` | who unstaked, shares burned, gross value, fee | `rnbwValue / sharesBurned` |
352+
| `CashbackAllocated(user, rnbwAmount, sharesMinted)` | who got cashback, amount, shares minted | `rnbwAmount / sharesMinted` |
353+
354+
**Global pool event** — for tracking the global exchange rate over time without reconstructing it from per-user events:
355+
356+
| Event | Fields | Exchange Rate |
357+
|-------|--------|---------------|
358+
| `PoolTotalsUpdated(totalPooledRnbw, totalShares)` | post-operation pool totals (no user address) | `totalPooledRnbw / totalShares` |
359+
360+
`PoolTotalsUpdated` fires after every stake, unstake, and cashback allocation. It is not useful for per-wallet APY because it has no `user` field. Use it when you only need the global rate history (e.g., charting exchange rate over time, computing global APY without the 2-block RPC approach).
349361

350362
> Edge case: the very first stake mints 1000 dead shares, so `sharesMinted = amount - 1000`. The derived rate is still correct but slightly above 1.0.
351363
352364
#### Walkthrough Example
353365

354366
Events for Alice:
355367

356-
| Event | Timestamp | RNBW Amount | Shares Delta | Exchange Rate |
357-
|-------|-----------|-------------|-------------|---------------|
358-
| stake | Jan 1 | 1000 | +1000 | 1.0 |
359-
| cashback | Mar 1 | 50 | +50 | 1.0 |
360-
| unstake | Jul 1 | 550 (gross) | -500 | 1.1 |
368+
| Event | Timestamp | RNBW Amount (gross) | Exit Fee | Net Received | Shares Delta | Exchange Rate |
369+
|-------|-----------|---------------------|----------|-------------|-------------|---------------|
370+
| stake | Jan 1 | 1000 | -- | -- | +1000 | 1.0 |
371+
| cashback | Mar 1 | 50 | -- | -- | +50 | 1.0 |
372+
| unstake | Jul 1 | 550 | 55 | 495 | -500 | 1.1 |
361373

362374
Step 1 — Build capital periods (what Alice had, for how long):
363375

@@ -367,32 +379,34 @@ Step 1 — Build capital periods (what Alice had, for how long):
367379
| Mar 1 → Jul 1 | 1050 | 1.0 | 1050 | 122 |
368380
| Jul 1 → Dec 31 | 550 | 1.1 | 605 | 183 |
369381

370-
Each period starts when an event changes the wallet's share balance. The capital is `running_shares × exchange_rate` at that point.
382+
Each period starts when an event changes the wallet's share balance. The capital is `running_shares × exchange_rate` at that point. The exchange rate changes continuously between events (drip), so this is an approximation — accuracy improves with more frequent user activity.
371383

372384
Step 2 — Compute TWAC:
373385

374386
```
375387
TWAC = (1000×59 + 1050×122 + 605×183) / (59 + 122 + 183)
376388
= (59000 + 128100 + 110715) / 364
377-
= 817.9 RNBW
389+
= 818.2 RNBW
378390
```
379391

380392
Step 3 — Compute net profit:
381393

394+
`totalUnstaked` is the **net** amount after exit fee (matches `totalRnbwUnstaked` in the contract / `net_received` in the indexer), not the gross value.
395+
382396
```
383397
netProfit = currentValue + totalUnstaked - totalStaked
384-
= 605 + 550 - 1000
385-
= 155 RNBW
398+
= 605 + 495 - 1000
399+
= 100 RNBW
386400
```
387401

388402
Step 4 — APY:
389403

390404
```
391405
duration = 364 days
392406
APY = (netProfit / TWAC) × (365.25 / duration)
393-
= (155 / 817.9) × (365.25 / 364)
394-
= 0.19
395-
= 19%
407+
= (100 / 818.2) × (365.25 / 364)
408+
= 0.123
409+
= 12.3%
396410
```
397411

398412
#### Formula Summary
@@ -402,11 +416,15 @@ Per-Wallet APY = (netProfit / TWAC) × (secondsPerYear / durationSeconds)
402416
403417
Where:
404418
netProfit = stakedAmount + totalUnstaked - totalStaked
419+
totalUnstaked = SUM(rnbw_amount - exit_fee) from unstake events (after exit fee)
420+
totalStaked = SUM(rnbw_amount) from stake events
405421
TWAC = Σ(capital_i × duration_i) / Σ(duration_i)
406422
secondsPerYear = 31,557,600 (365.25 days)
407423
durationSeconds = now - first_stake_timestamp
408424
```
409425

426+
`totalUnstaked` must use the **net** amount (after exit fee), not the gross. The contract stores this as `totalRnbwUnstaked`; in the indexer compute it as `SUM(rnbw_amount - exit_fee)` for unstake events.
427+
410428
> Most users have 2-10 events, so per-wallet queries are trivially fast even at 500 tx/day.
411429
412430
---
@@ -425,6 +443,7 @@ Where:
425443
- Dust unstake handling: when ceil-rounded exit fee consumes 100% of a dust unstake, shares are burned with no transfer (clears dust positions without reverting)
426444
- 2-step safe transfer: `proposeSafe()` + `acceptSafe()` prevents transfer to wrong address
427445
- Partial unstake toggle: `allowPartialUnstake` (default: disabled)
446+
- Partial unstake dust guard: when partial unstake is enabled, `_unstake` reverts with `DustSharesRemaining` if the user's remaining shares would be > 0 but < `MIN_SHARES_THRESHOLD` (1e14). Prevents attackers from leaving dust shares to bypass the dead-share sweep and inflate the exchange rate.
428447
- Preview dust guard: `previewStake(user, amount)` returns 0 instead of reverting for dust amounts and for first-time stakers below `minStakeAmount`
429448
- Recipient guards: `stakeFor` and `stakeForWithSignature` reject `address(0)`, `address(this)`, and `DEAD_ADDRESS` to prevent token locking and dead-share corruption
430449
- Batch size limit: `batchAllocateCashbackWithSignature` capped at 50 entries with upfront reserve check
@@ -442,6 +461,7 @@ All user-facing errors include contextual parameters for off-chain debugging. Ad
442461
| `ZeroSharesMinted` | `(user, amount)` | `_mintShares`, `_allocateCashback` |
443462
| `InvalidRecipient` | -- | `stakeFor`, `stakeForWithSignature` |
444463
| `PartialUnstakeDisabled` | `(user, sharesToBurn, totalUserShares)` | `_unstake` |
464+
| `DustSharesRemaining` | `(user, remainingShares)` | `_unstake` |
445465
| `DripDurationTooLow` | -- | `setDripDuration` |
446466
| `DripDurationTooHigh` | -- | `setDripDuration` |
447467

0 commit comments

Comments
 (0)