A production-quality, decentralised staking and liquidity-mining protocol written in Solidity 0.8.24 with zero OpenZeppelin dependencies. Built as a Smart Contract Engineer skill assessment.
Users deposit ERC-1363 LP tokens, earn dynamically-priced reward tokens, and withdraw after a mandatory 2-day cooldown — during which a security multisig can flag suspicious activity and reset the timer.
- What the protocol does
- Architecture overview
- Contract reference
- Storage layout
- Security model
- How to build, test, and report gas
- Deployment notes
- Further reading
NQ-Swap follows the canonical MasterChef liquidity-mining pattern, extended with a security-first withdrawal flow:
-
Stake — a user calls
stake(amount)(approve+call) or uses the single-transaction pathlpToken.transferAndCall(pool, amount)(ERC-1363). Their LP tokens are locked in the pool and they begin earning rewards immediately. -
Earn — a global accumulator
accRewardPerSharetracks cumulative reward tokens earned per unit of staked LP token since deployment. The pool queries a pluggableIRewardOracleevery time the accumulator is updated, so the emission rate can change without touching the pool contract. -
Request withdrawal —
requestWithdrawal(amount)moves the nominated LP tokens out of the earning set and queues aWithdrawalRequest. Any rewards earned up to that moment are credited into the user'spendingRewardsbalance. The 2-day cooldown clock starts. -
Claim rewards —
claimRewards()pays outpendingRewardsat any time, independent of any withdrawal. -
Execute withdrawal — once the cooldown expires,
executeWithdrawal(id)returns the LP tokens and any remaining pending rewards in one transaction. State is fully cleared before any token transfer (CEI order + re-entrancy mutex). -
Flag — the security multisig can call
flagWithdrawal(user, id)to reset the cooldown on a suspicious request. The multisig cannot cancel or seize tokens — it can only delay.
┌──────────────────────────────────────────────────────────┐
│ User │
│ stake() / transferAndCall() requestWithdrawal() │
│ claimRewards() executeWithdrawal() │
└─────────────────────┬────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ StakingPool │
│ │
│ • MasterChef accumulator (accRewardPerShare) │
│ • Two-step withdrawal with 2-day cooldown │
│ • ERC-1363 onTransferReceived callback │
│ • Re-entrancy mutex (nonReentrant) + CEI ordering │
│ • Yul muldiv with overflow protection │
│ • Packed storage slots (uint128 pairs) │
│ │
└────────────┬──────────────────────────┬──────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ IRewardOracle │ │ Security Multisig │
│ rewardPerBlock()│ │ flagWithdrawal() │
└──────────────────┘ └──────────────────────┘
│
▼
┌──────────────────┐ ┌──────────────────────┐
│ LP Token │ │ Reward Token │
│ (ERC-1363) │ │ (ERC-20) │
└──────────────────┘ └──────────────────────┘
src/
├── StakingPool.sol # Core protocol contract
├── interfaces/
│ ├── IERC20.sol # Minimal ERC-20 interface
│ ├── IERC1363.sol # ERC-1363 transferAndCall interface
│ ├── IERC1363Receiver.sol # onTransferReceived callback interface
│ └── IRewardOracle.sol # rewardPerBlock() oracle interface
├── tokens/
│ ├── ERC20Base.sol # Hand-rolled ERC-20 implementation
│ └── ERC1363Token.sol # ERC-1363 extension layer
└── oracle/
└── MockRewardOracle.sol # Static and variable-rate test oracle
test/
├── StakingPool.t.sol # 45 unit + fuzz tests
├── mocks/
│ ├── MockLP.sol # ERC-1363 LP token (owner-mintable)
│ └── MockRewardToken.sol # Plain ERC-20 reward token
└── invariants/
├── Handler.sol # Bounded actor for stateful fuzzer
└── StakingInvariant.t.sol # 7 invariant assertions
thoughts.md # Full engineering design document
GAS_REPORT.md # Before/after gas measurements
The only stateful contract in the system. Implements IERC1363Receiver.
| Function | Access | Description |
|---|---|---|
stake(uint256) |
public | Approve-then-stake LP tokens |
onTransferReceived(address,address,uint256,bytes) |
LP token only | ERC-1363 single-tx stake |
requestWithdrawal(uint256) |
public | Queue a withdrawal, starts cooldown |
executeWithdrawal(uint256) |
public | Finalise after cooldown expires |
claimRewards() |
public | Claim pending rewards without unstaking |
flagWithdrawal(address,uint256) |
multisig only | Reset cooldown on a suspicious request |
fundRewards(uint256) |
owner only | Deposit reward tokens into the pool |
setOracle(address) |
owner only | Replace the reward oracle |
setMultisig(address) |
owner only | Replace the security multisig |
pendingReward(address) |
view | Off-chain reward estimate for a user |
userInfo(address) |
view | User's staked amount, debt, pending rewards |
getWithdrawalRequest(address,uint256) |
view | Full details of a withdrawal request |
getUserWithdrawalIds(address) |
view | All active withdrawal IDs for a user |
withdrawalUnlockTime(address,uint256) |
view | Timestamp when a request becomes executable |
Constructor parameters:
constructor(
address _lpToken, // ERC-1363 LP token
address _rewardToken, // ERC-20 reward token
address _rewardOracle, // IRewardOracle implementation
address _securityMultisig // address with flagWithdrawal power
)Key constants:
| Constant | Value | Purpose |
|---|---|---|
PRECISION |
1e18 |
Accumulator scaling factor |
COOLDOWN |
172800 (2 days) |
Minimum withdrawal wait |
MAX_REWARD_PER_BLOCK |
1e24 |
Oracle rate hard cap |
Custom errors (gas-efficient, no string reasons):
SP__ZeroAmount, SP__ZeroRewardRate, SP__RewardRateTooHigh, SP__InsufficientStake, SP__WithdrawalNotFound, SP__CooldownNotElapsed, SP__OnlyMultisig, SP__WithdrawalAlreadyFlagged, SP__RewardInsolvency, SP__Locked, SP__WithdrawalIdOverflow, SP__InvalidLPToken, SP__NotLPToken, SP__MulOverflow, SP__ZeroAddress, SP__FundingFailed, SP__OnlyOwner
Hand-rolled ERC-20 + ERC-1363 implementation. No external dependencies. Used as the base for MockLP in tests. In a production deployment the LP token would come from an AMM (e.g. Uniswap v2 pair) and the reward token would be a standard ERC-20 — these implementations are provided to satisfy the "no OpenZeppelin" requirement and to make the test suite fully self-contained.
Implements IRewardOracle. Supports a fixed rate (set at construction) and a variable rate that the test suite can override mid-run via setRate(uint256). Replace with a Chainlink-backed or governance-controlled oracle before mainnet.
StakingPool storage after all gas optimisations (verified with forge inspect StakingPool storageLayout):
| Slot | Variable(s) | Type | Notes |
|---|---|---|---|
| 0 | rewardOracle |
address |
Mutable, owner-only |
| 1 | securityMultisig |
address |
Mutable, owner-only |
| 2 | accRewardPerShare |
uint256 |
Monotonically increasing |
| 3 | lastRewardBlock |
uint256 |
Updated every pool checkpoint |
| 4 | totalStaked | totalPendingWithdrawals |
uint128 | uint128 |
Packed — one SLOAD covers both |
| 5 | rewardReserve | nextWithdrawalId |
uint128 | uint128 |
Packed — one SLOAD covers both |
| 6 | _userInfo |
mapping(address => UserInfo) |
|
| 7 | _withdrawalRequests |
mapping(address => mapping(uint256 => WithdrawalRequest)) |
|
| 8 | _userWithdrawalIds |
mapping(address => uint256[]) |
Enumeration index only |
| 9 | _locked |
uint8 |
Re-entrancy mutex (1=open, 2=locked) |
UserInfo (two packed slots per user):
Slot 0: stakedAmount (uint128) | rewardDebt (uint128)
Slot 1: pendingRewards (uint128) | _reserved (uint128)
WithdrawalRequest (two packed slots per request):
Slot 0: amount (uint128) | requestTime (uint64) | flagged (bool) | _pad (uint56)
Slot 1: id (uint256)
Two layers of protection, neither relying on the other:
-
CEI (Checks-Effects-Interactions) — all state is zeroed and all arithmetic is finalised before any external
transfercall. Even if the mutex were absent a re-entrant call would read zeroed state and have nothing to steal. -
nonReentrantmutex — auint8flag (1=open, 2=locked) blocks every re-entrant entry.uint8rather thanboolbecause the compiler stores booleans as a full 32-byte word in some contexts;uint8packs more predictably.
_updatePool caps the oracle return value at MAX_REWARD_PER_BLOCK = 1e24. A compromised oracle returning type(uint256).max triggers SP__RewardRateTooHigh and the pool freezes rather than silently overflows. The oracle also cannot return zero (triggers SP__ZeroRewardRate) to prevent a griefing vector where the pool appears live but pays nothing.
onTransferReceived checks msg.sender == lpToken. Any direct call to this function from an address other than the registered LP token reverts with SP__NotLPToken. This prevents an attacker from crediting stakes backed by no real tokens.
_computeReward and _computeRewardAddition are implemented in Yul. The overflow check is explicit: if b > maxUint / a → revert. This is the standard muldiv overflow guard, written in assembly to avoid redundant checks on this hot path while keeping the semantics of 0.8.x checked arithmetic everywhere else.
executeWithdrawal deletes the WithdrawalRequest record from _withdrawalRequests and removes the ID from _userWithdrawalIds before any transfer calls. A second call on the same ID finds req.amount == 0 and reverts with SP__WithdrawalNotFound.
The security multisig can:
- Call
flagWithdrawalto reset a pending withdrawal's cooldown clock - Call it only once per request (the
flaggedbit prevents double-flagging)
The multisig cannot:
- Cancel or seize any user's funds
- Change any protocol parameter
- Prevent a withdrawal from ever executing — it can only delay by one additional
COOLDOWNperiod
claimRewards and executeWithdrawal both check pending <= rewardReserve before transferring. If the accounting ever drifts (e.g. due to a direct transfer to the contract not going through fundRewards), the transaction reverts with SP__RewardInsolvency rather than silently paying from tokens it doesn't own.
Prerequisites: Foundry installed and on $PATH.
forge buildCompiles all contracts under src/ and test/ with the Solidity 0.8.24 compiler, optimizer enabled at 200 runs.
forge test -vvExpected output: 52 tests passing (45 unit+fuzz, 7 invariants), 0 failures.
To see per-test gas in the terminal:
forge test -vv --gas-reportTo run only the unit/fuzz suite:
forge test --match-path test/StakingPool.t.sol -vvTo run only the invariant suite:
forge test --match-path test/invariants/StakingInvariant.t.sol -vvTo run a single test by name:
forge test --match-test testFuzz_PartialWithdrawal -vvvFuzz runs and invariant depth are configured in foundry.toml:
[profile.default.fuzz]
runs = 1000
seed = "0xdeadbeef"
[profile.default.invariant]
runs = 500
depth = 100Increase runs for a more thorough campaign before deploying.
# Take a fresh snapshot (writes to .gas-snapshot)
forge snapshot
# Compare against the checked-in optimised baseline
forge snapshot --diff .gas-snapshot-optimizedA positive diff means a regression; a negative diff means additional savings. The checked-in .gas-snapshot-optimized reflects the state after all optimisations described in GAS_REPORT.md.
forge inspect StakingPool storageLayoutUse this to verify the packed slot assignments match the table in this README after any future changes to the state variable declarations.
forge fmtThis project does not ship a deploy script — the assessment scope is the protocol contracts and their tests. The following notes apply to any real deployment:
-
Deploy order:
ERC1363Token(LP) → reward token → oracle →StakingPool. -
Fund the pool: After deployment the owner must call
rewardToken.approve(pool, amount)thenpool.fundRewards(amount)before any rewards can be paid. The pool reverts withSP__RewardInsolvencyon any claim until funded. -
Oracle: Replace
MockRewardOraclewith a governance-controlled or Chainlink-backed implementation before mainnet. The interface is a single function:function rewardPerBlock() external view returns (uint256). -
Multisig: Set
_securityMultisigto a real Gnosis Safe or equivalent. Rotatable at any time by the owner viasetMultisig(address). -
Immutables:
lpToken,rewardToken, andownerare set at construction and cannot be changed. Choose them carefully. -
No upgradability: The pool is intentionally non-upgradeable. If a critical bug is found, deploy a new pool and migrate liquidity. The simplicity trades off flexibility for a smaller attack surface.
-
Production gaps (known): See
thoughts.md§12 for an explicit list of features intentionally excluded from this assessment scope (fee-on-transfer token handling, multiple reward tokens, governance, emergency pause, etc.).
| Document | Contents |
|---|---|
thoughts.md |
Full engineering design document: pre-code threat modelling, data structure decisions, re-entrancy rationale, accumulator mechanics, Yul assembly explanation, testing methodology, known production gaps |
GAS_REPORT.md |
Per-test before/after gas table, explanation of each optimisation, storage layout diagram, net savings summary (−6.7%, 597,856 gas) |
| Suite | Tests | Status |
|---|---|---|
Unit + fuzz (StakingPool.t.sol) |
45 | ✅ All passing |
Invariant (StakingInvariant.t.sol) |
7 | ✅ All passing |
| Total | 52 | ✅ |
Invariants verified:
| ID | Assertion |
|---|---|
| I1 | lpToken.balanceOf(pool) == totalStaked + totalPendingWithdrawals |
| I2 | totalStaked <= lpToken.balanceOf(pool) |
| I3 | rewardReserve <= rewardToken.balanceOf(pool) |
| I4 | accRewardPerShare never decreases |
| I5 | No pending withdrawal is executable before its unlock time |
| I6 | Handler ghost variables match pool live state |
| I7 | nextWithdrawalId only increases |