Date: 2026-03-27 (OGBadge addendum: 2026-04-22) Auditor: Automated (Slither v0.11.4 / v0.11.5) + Manual Code Review Contracts: ReviewRegistry.sol, RewardsVault.sol, WelpToken.sol, PriceFeed.sol, OGBadge.sol Solidity: 0.8.20 (core), 0.8.24 (OGBadge) | Framework: Foundry | Network: Sepolia Testnet
- Executive Summary
- Slither Static Analysis
- Manual Code Review
- Frontend Security Audit
- Full App Flow Audit
- OGBadge Addendum (2026-04-22)
- Recommendations
| Severity | Count |
|---|---|
| Critical | 0 |
| High | 0 |
| Medium | 3 |
| Low | 5 |
| Informational | 6 |
The contracts are well-structured with appropriate use of OpenZeppelin's Ownable2Step, Pausable, and ReentrancyGuard. No critical or high-severity issues were found. The medium findings relate to design patterns that should be addressed before mainnet deployment.
Slither analyzed 18 contracts with 100 detectors, producing 27 results. Below are the findings relevant to the Welp source contracts (OpenZeppelin library findings excluded).
Detector: missing-inheritance
RewardsVaultshould inherit fromIRewardsVault(defined in ReviewRegistry.sol)WelpTokenshould inherit fromIWelpToken(defined in RewardsVault.sol)
Impact: Without explicit inheritance, the compiler won't catch interface mismatches. If the interface changes but the implementation doesn't, calls will silently revert.
Recommendation: Have RewardsVault is IRewardsVault, ... and WelpToken is IWelpToken, ....
Detector: timestamp
ReviewRegistry.submitReview()usesblock.timestampfor check-in window validation (checkInTime == 0 || block.timestamp > checkInTime + CHECKIN_WINDOW) and review cooldown (lastReview != 0 && block.timestamp < lastReview + REVIEW_COOLDOWN).
Impact: Miners can manipulate block.timestamp by ~15 seconds on PoW chains. On Sepolia/mainnet PoS, validators have less manipulation ability. Given the 24h/48h windows, this is negligible risk.
Recommendation: Acceptable for current use case. No action needed.
Detector: constable-states
RewardsVault.tier1Threshold(line 22) is never modified after deployment and should be declaredconstant.
Impact: Wastes a small amount of gas on every read.
Recommendation: Change to int256 public constant tier1Threshold = 0; or include it in setTierConfig if it should be configurable.
Detector: naming-convention
- Parameters in
RewardsVault.setTierConfig()use underscore prefix (_tier2Threshold, etc.) which Slither flags.
Impact: Style-only. No functional impact.
Recommendation: This is an intentional naming convention to distinguish parameters from state variables. No change needed.
Detector: pragma, solc-version
- OpenZeppelin uses
^0.8.20, Welp contracts use locked0.8.20. Minor Solidity bugs flagged for 0.8.20 (VerbatimInvalidDeduplication,FullInlinerNonExpressionSplitArgumentEvaluationOrder,MissingSideEffectsOnSelectorAccess).
Impact: None of these bugs affect the patterns used in Welp contracts (no verbatim assembly, no complex inline functions, no .selector access on errors).
Recommendation: Consider upgrading to 0.8.24+ for mainnet to get all bug fixes. Locked pragma is good practice.
Detector: assembly
- Only in OpenZeppelin's
StorageSlot.sol. Not in Welp source code.
| # | Finding | Severity | Status |
|---|---|---|---|
| 1 | Self-voting allowed — A reviewer can upvote/downvote their own reviews. _vote() does not check msg.sender != r.reviewer. |
Low | Open |
| 2 | No check-in rate limiting — A user can call checkIn() repeatedly with no cooldown, spamming CheckedIn events. |
Low | Open |
| 3 | Reputation can go negative — reputation is int256 and can be driven arbitrarily negative by coordinated downvoting. Consider a floor (e.g., 0). |
Informational | Open |
| 4 | Silent skip if rewardsVault not set — submitReview() silently skips reward distribution if rewardsVault == address(0) (line 163). This means reviews submitted before vault is wired get no reward retroactively. |
Informational | By Design |
| 5 | No ipfsHash validation — The ipfsHash parameter is not validated for length or format. A malicious user could pass an empty string or very long string. |
Informational | Open |
| 6 | Review data immutability — Once submitted, reviews cannot be edited or deleted. This is intentional for on-chain integrity but means wrong/malicious content persists. | Informational | By Design |
| # | Finding | Severity | Status |
|---|---|---|---|
| 1 | Redundant RegistryNotSet check — Line 102 checks address(reviewRegistry) == address(0), but line 100 already requires msg.sender == address(reviewRegistry). If registry is zero, msg.sender can't be zero, so this check is unreachable. |
Informational | Open |
| 2 | No reward cap / rate limiting — Any number of rewards can be minted per block. If ReviewRegistry is compromised or a bug allows rapid review submissions, token supply inflates unchecked. | Low | Open |
| # | Finding | Severity | Status |
|---|---|---|---|
| 1 | No supply cap — mint() has no max supply check. Unlimited tokens can be minted. |
Low | Open |
| 2 | Clean and minimal — Good use of onlyRewardsVault modifier and Pausable. No issues found. |
— | — |
- Wiring is correct: Deploy script properly connects all three contracts.
- Ownable2Step used everywhere — two-step ownership transfer prevents accidental loss.
- ReentrancyGuard on
submitReviewanddistributeReward— prevents reentrancy between the two. - Pausable on ReviewRegistry and WelpToken — owner can emergency-stop operations.
| # | Finding | Severity |
|---|---|---|
| 1 | Profile stored in localStorage — useProfile stores user profile (displayName, avatar, categories) in localStorage. This is client-side only and easily manipulated. Not a security risk for a review platform since all meaningful data is on-chain. |
Informational |
| 2 | No input sanitization on display name — Onboarding accepts any string up to 24 chars. Since it's only rendered in React (which auto-escapes), XSS is not a risk. | Informational |
| 3 | Pinata JWT in NEXT_PUBLIC_ env var — The Pinata API key is exposed to the client. Anyone can use it to upload to your Pinata account. For production, route IPFS uploads through a backend API. | Medium |
| 4 | Placeholder IPFS hash on failure — If Pinata upload fails, a fake hash QmPlaceholder{timestamp} is used. This means on-chain reviews may reference non-existent IPFS content. |
Low |
- Status: Working
- Correctly redirects: no wallet →
/welcome, wallet + no profile →/onboarding, ready →/dashboard
- Status: Working
- Connect Wallet button functions correctly
- "Learn More" scroll works
- "Get Started" button at bottom connects wallet
- Auto-redirects to
/onboardingor/dashboardwhen wallet connects - Issue: None
- Status: Working
- 3-step flow (name → avatar → categories) works correctly
- Progress indicator updates properly
- Back/Next navigation works
- Validation: name ≥ 2 chars, avatar required, 2-3 categories required
- "Complete Setup" saves to localStorage and redirects to
/dashboard - Issue: None
- Status: Working with notes
- Personalized greeting with avatar + display name: Working (from localStorage)
- Getting Started card: Working — disappears after first review (uses
hasReviewedflag) - Tier progress bar: Working — reads
reputationfrom ReviewRegistry + tier thresholds from RewardsVault - 4 stat cards: Working — Total Visits and Reviews Written both show review count (these are the same number since there's no separate visit tracking)
- Points Balance: Working — reads WELP token
balanceOffrom chain - Recent Activity: Working — shows user's reviews with business names, ratings, dates
- Quick Actions: Working — all 4 buttons link to
/businessesor/feed - Nearby Businesses: Working (after off-by-one fix applied in this session)
- Issues found:
- "Scan QR Code" quick action just links to
/businesses— no QR functionality exists - "Settings" quick action links to
/feed— there is no settings page - Nearby Businesses shows hardcoded "4.8" rating and "0.5 mi" distance — not real data
- "Total Visits" card shows review count, not actual check-in count (no on-chain check-in counter exists)
- "Badges: No badges yet" is hardcoded — no badge system exists
- "Scan QR Code" quick action just links to
- Status: Working (after off-by-one fix)
- Search by name/category: Working
- Category filter pills: Working
- Business cards with avatars: Working
- "Showing X of Y businesses" counter: Working
- Stars are hardcoded to 5 stars for all businesses (no average rating calculation)
- Issue (FIXED this session): Was querying business IDs 1-10 instead of 0-9. Cosetta's (ID 0) was missing.
- Status: Working (after fixes applied this session)
- Business header card with avatar, name, category, location: Working
- Check In button: Working (calls
checkInon-chain) - "Checked In ✓" state: Working (after
queryClient.invalidateQueriesfix) - "Write a Review" button appears after check-in: Working
- Reviews section: Working — shows reviews with avatars, addresses, dates, ratings, vote counts
- Stars in header are hardcoded to 5 — no average calculation
- Issue (FIXED this session): Check-in confirmation didn't update UI. Fixed with query invalidation +
checkInConfirmedfallback.
- Status: Working (after error handling added this session)
- Star rating picker: Working — hover + click + display
- Review text textarea: Working
- Submit flow: IPFS upload → on-chain
submitReview→ confirmation screen with confetti - Error toasts for
NotCheckedIn,ReviewCooldown,InvalidRating, user rejection: Working (added this session) - Confirmation screen shows "You earned $1.00 WLP" — this is hardcoded, actual reward is 100 WELP tokens (100e18 wei)
- "Back to Business" button on confirmation: Working
- Issues:
- Can navigate directly to
/review?businessId=0without checking in — the on-chain tx will revert with proper error toast now - "$1.00 WLP" text is misleading — should say "100 WELP" or similar
- No gate checking if user is checked in before showing the form
- Can navigate directly to
- Status: Working (after off-by-one fix)
- Reviews load newest-first: Working
- Business names resolve from on-chain data: Working
- Upvote/Downvote buttons: Working — calls on-chain
upvote/downvote, disables after voting - "Already voted" state persists across page loads: Working (reads
hasVotedfrom chain) - Reputation badges (Rep: X, Elite/Premium): Working
- Issue (FIXED this session): Business name resolution was querying IDs 1-N instead of 0-N.
- Disconnecting wallet → navbar shows "Connect Wallet" only, nav links hidden
- Navigating to
/dashboardwhile disconnected → redirects to/welcome - Reconnecting → onboarding skipped if profile exists in localStorage
- Issue: If a user clears localStorage but keeps the same wallet, they must re-onboard (by design)
| Data Point | Source |
|---|---|
| Display name, avatar, categories | localStorage |
| WELP balance | On-chain (WelpToken.balanceOf) |
| Reputation / XP | On-chain (ReviewRegistry.reputation) |
| Tier thresholds | On-chain (RewardsVault.tier2Threshold, tier3Threshold) |
| Reviews, ratings, votes | On-chain (ReviewRegistry.reviews) |
| Business list | On-chain (ReviewRegistry.businesses) |
| Check-in status | On-chain (ReviewRegistry.lastCheckIn) |
| Star ratings on cards | Hardcoded (always 5 stars) |
| Distance on dashboard | Hardcoded ("0.5 mi") |
| Badges | Hardcoded ("No badges yet") |
| "$1.00 WLP" reward text | Hardcoded (actual: 100 WELP tokens) |
| QR code scanning | Not implemented (links to /businesses) |
| Settings page | Not implemented (links to /feed) |
Standalone soulbound ERC-721 deployed alongside the core stack. Deployed address: 0x44e5B877BB1f42Ea1EBE3733682A48F0caf433Da. Contract is independent of the main review/rewards path -- no existing contract was modified, upgraded, or redeployed for this release.
| Severity | Findings in OGBadge source |
|---|---|
| Critical | 0 |
| High | 0 |
| Medium | 0 |
| Low | 0 |
| Informational | 0 |
Slither v0.11.5 ran against src/OGBadge.sol with --filter-paths lib/ (excluding OpenZeppelin library code). Result: 0 findings on project code. The only Slither output not filtered out is pragma-version chatter inherited from OpenZeppelin; none of it affects the patterns used in OGBadge.
12 unit tests in contracts/test/OGBadge.t.sol, 100% green. Coverage includes:
- Constructor state (token reference, owner, metadata, constants)
- Happy-path mint (approve + mintBadge, balances on both ERC-20 and ERC-721 sides)
- Revert: mint without WELP approval
- Revert: second mint from the same address (
"already minted") - Revert: 101st mint after supply exhausted (
"sold out") - Soulbound:
transferFrombetween non-zero addresses reverts - Soulbound:
safeTransferFromreverts - Soulbound: approved spender cannot bypass the check
tokenURIreturns the configured URI for existing tokenstokenURIreverts on non-existent tokenssetTokenURIonlyOwner (unauthorized caller reverts withOwnableUnauthorizedAccount)setTokenURIupdates retroactively (all existing tokens reflect the new URI)
All 105 pre-existing tests across the rest of the suite continue to pass. Total: 105 passing, 1 skipped (Sepolia fork test requiring RPC env var).
OGBadge burns WELP by calling welpToken.transferFrom(msg.sender, 0x...dEaD, 100e18) rather than invoking a burn() function. The deployed WelpToken MVP does not implement ERC20Burnable, and redeploying the token would invalidate every existing balance, so a dead-address transfer is the only available path without a token migration.
Precedent: This is an industry-standard fallback when burn() isn't exposed. Tether (USDT) and the original DAI migration both relied on dead-address transfers for similar reasons. Etherscan and most block explorers annotate transfers to 0x...dEaD and 0x...0000 as burns, so end-user accounting tools still treat them correctly.
Effect on total supply: WelpToken.totalSupply() does not decrement -- the tokens remain on the ledger, parked at an address nobody controls. In practice the tokens are permanently removed from circulation because the dead address has no known private key (it's a vanity address with the hex string dEaD in the tail; generating a collision is not computationally feasible). For tokenomics accounting, circulating supply should be calculated as totalSupply() - balanceOf(DEAD).
Roadmap: WelpToken v2 with ERC20Burnable and ERC20Permit is planned post-graduation. OGBadge is written so it stays compatible with either version -- if a token migration ever happens, the badge contract continues to work without modification because all it needs is ERC-20 transferFrom.
Transfer restriction is implemented in the _update override:
function _update(address to, uint256 tokenId, address auth)
internal
override
returns (address)
{
address from = _ownerOf(tokenId);
require(from == address(0) || to == address(0), "Soulbound: non-transferable");
return super._update(to, tokenId, auth);
}_update is the single choke point OpenZeppelin's ERC-721 v5 uses for every ownership change -- mint, burn, transferFrom, safeTransferFrom, and any custom transfer logic routed through _transfer or _safeTransfer. By gating it on "at least one side must be zero", mint (from == 0) and burn (to == 0) remain possible but peer-to-peer transfer (both non-zero) reverts unconditionally.
Approvals are still allowed for UX (some wallets check approve as part of token-inspection flows), but the soulbound check fires regardless of who the caller is. An approved spender calling transferFrom reverts with the same message as an unapproved caller, which the test suite verifies.
- No rescue function. There is no owner-callable path to retrieve WELP sent to
0x...dEaD. This is intentional -- the burn is the point. Adding a rescue would undermine the scarcity claim and reintroduce trusted-owner risk that the contract is specifically designed to avoid. - No burn entrypoint exposed externally. Although
_updatepermitsto == address(0)for library compatibility, OGBadge deliberately does not expose a burn function. A user who wants to shed the badge can't. This mirrors other reputation-style soulbound NFTs (e.g. POAP moments). - Shared metadata. All badges share the same
tokenURI. The badge is a credential, not a per-token collectible. If the owner callssetTokenURIlater, every existing token reflects the new URI. Downstream marketplaces that cache metadata per-token may not refresh until a re-index.
- Add supply cap to WelpToken — prevent unlimited minting
- Route Pinata uploads through backend — don't expose JWT to client
- Implement IRewardsVault/IWelpToken inheritance — compiler-enforced interface compliance
- Add self-vote prevention —
require(msg.sender != r.reviewer)in_vote()
- Add check-in rate limiting (e.g., once per hour per business)
- Validate
ipfsHashparameter length (min 46, max 64 chars) - Calculate and display actual average ratings instead of hardcoded 5 stars
- Fix reward display text to match actual token amounts
- Remove or implement placeholder features (QR scan, Settings, badges, distance)
- Consider upgrading to Solidity 0.8.24+
- Add Echidna/Foundry fuzz testing for invariants
- Implement on-chain check-in counter for accurate "Total Visits" stat
- Add governance or multisig for owner operations
- Add
RewardsVaultNotSetrevert instead of silent skip insubmitReview