Skip to content

Fix critical security vulnerabilities: claim verification, secret exposure, input validation#3

Open
devin-ai-integration[bot] wants to merge 3 commits into
mainfrom
devin/1782167168-security-fixes
Open

Fix critical security vulnerabilities: claim verification, secret exposure, input validation#3
devin-ai-integration[bot] wants to merge 3 commits into
mainfrom
devin/1782167168-security-fixes

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jun 22, 2026

Copy link
Copy Markdown

Summary

Addresses multiple security vulnerabilities found during a codebase audit, plus adds missing build configuration that was preventing Vercel deployment.

Critical: Unprotected claimReward (reward drain vulnerability)

Previously anyone could call claimReward(campaignId) and receive USDC without proving task completion. Now requires ECDSA signature verification from a trusted backend verifier:

// New constructor: constructor(address _usdc, address _verifier)
function claimReward(uint256 _campaignId, bytes calldata _signature) external {
    bytes32 messageHash = keccak256(abi.encodePacked(_campaignId, msg.sender));
    bytes32 ethSignedHash = messageHash.toEthSignedMessageHash();
    require(ethSignedHash.recover(_signature) == verifier, "Invalid claim signature");
    // ... transfer reward
}

The frontend now fetches a signature from /api/verify-claim before submitting the claim tx.

Critical: Hardcoded WalletConnect Project ID

providers.tsx had projectId: 'YOUR_WALLETCONNECT_PROJECT_ID' — moved to process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID with a graceful fallback + console warning.

High: No .gitignore — secrets at risk of being committed

Added comprehensive .gitignore covering .env*, node_modules/, build artifacts, and IDE files.

Medium: No input validation on campaign creation form

Added URL pattern validation (x.com|twitter.com status links only), numeric bounds checking (reward > 0, participants 1–10,000), and user-facing error messages.

Build fix: Missing config files

Added tsconfig.json (with @/* path alias), next.config.js, tailwind.config.ts, postcss.config.js — these were missing from the repo and blocking both local and Vercel builds.

Other

  • Added .env.example files for both contracts/ and frontend/ documenting required secrets
  • Added dotenv to hardhat config for secure env loading
  • Deploy script now requires VERIFIER_ADDRESS env var
  • Constructor validates non-zero addresses
  • Added package-lock.json for reproducible builds

Link to Devin session: https://app.devin.ai/sessions/2739eed836e64215a58bb1be90c003ff
Requested by: @fcfsprojects


Open in Devin Review

- Add signature-based claim verification to prevent unauthorized reward draining
  (claimReward now requires a backend-signed message proving task completion)
- Move WalletConnect project ID from hardcoded value to env variable
- Add .gitignore to prevent committing secrets, node_modules, build artifacts
- Add .env.example files documenting required secrets for contracts and frontend
- Add input validation on frontend (tweet URL format, numeric bounds)
- Add dotenv to hardhat config for secure secret loading
- Add verifier address to constructor and deploy script
- Update contract ABI to match new claimReward signature

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@fcfsprojects fcfsprojects self-assigned this Jun 22, 2026
@devin-ai-integration

Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@vercel

vercel Bot commented Jun 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
fcfsproject Ready Ready Preview, Comment Jun 22, 2026 10:42pm

- Add tsconfig.json with @/ path alias for module resolution
- Add next.config.js, tailwind.config.ts, postcss.config.js
- Handle missing WalletConnect projectId gracefully with fallback + warning
  (prevents build crash during static generation)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 withdrawFees permanently locks protocolFee if campaign creator never calls withdrawRemaining

The withdrawFees function counts c.protocolFee as locked for all ACTIVE campaigns (line 248). Once a campaign expires, it remains ACTIVE until the creator explicitly calls withdrawRemaining (which sets status to COMPLETED). If the creator loses their key or simply never withdraws, the protocolFee remains permanently locked in the locked calculation. This is pre-existing logic not changed by the PR, but it interacts with the new signature scheme since the verification adds friction that may cause more campaigns to expire with partial participation and uncollected fees.

(Refers to lines 244-249)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread frontend/src/app/page.tsx
Comment on lines +88 to +91
const handleClaim = async (campaignId: number) => {
// In production, fetch the signature from a backend verification service
// that confirms the user completed the task on Twitter/X
const response = await fetch(`/api/verify-claim?campaignId=${campaignId}&wallet=${address}`)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 handleClaim passes undefined wallet address to API when wallet is disconnected

The handleClaim function (line 91) uses address from useAccount() directly in the fetch URL without checking if the wallet is connected. When isConnected is false, address is undefined, resulting in a fetch to /api/verify-claim?campaignId=1&wallet=undefined. The claim buttons on the browse tab (line 151) have no disabled={!isConnected} guard, so a disconnected user can trigger this. The backend would receive the literal string "undefined" as the wallet parameter, likely producing an invalid signature or error that provides a confusing user experience.

Suggested change
const handleClaim = async (campaignId: number) => {
// In production, fetch the signature from a backend verification service
// that confirms the user completed the task on Twitter/X
const response = await fetch(`/api/verify-claim?campaignId=${campaignId}&wallet=${address}`)
const handleClaim = async (campaignId: number) => {
if (!isConnected || !address) {
alert('Please connect your wallet first.')
return
}
// In production, fetch the signature from a backend verification service
// that confirms the user completed the task on Twitter/X
const response = await fetch(`/api/verify-claim?campaignId=${campaignId}&wallet=${address}`)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +173 to +176
bytes32 messageHash = keccak256(abi.encodePacked(_campaignId, msg.sender));
bytes32 ethSignedHash = messageHash.toEthSignedMessageHash();
address signer = ethSignedHash.recover(_signature);
require(signer == verifier, "Invalid claim signature");

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Signature message does not include contract address or chain ID

The signature hash at contracts/FCFSCampaign.sol:173 is keccak256(abi.encodePacked(_campaignId, msg.sender)). This does not bind the signature to a specific contract deployment or chain. If the same verifier key is used across multiple deployments (e.g., Base Sepolia and Base Mainnet), a signature issued for one deployment's campaign ID N could be replayed on the other deployment's campaign ID N for the same user. Including block.chainid and address(this) in the hash (or using EIP-712 typed data) would prevent cross-chain and cross-contract replay. The hasClaimed mapping prevents same-contract replay, but not cross-deployment replay.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +97 to 100
function setVerifier(address _verifier) external onlyOwner {
require(_verifier != address(0), "Invalid verifier address");
verifier = _verifier;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 setVerifier invalidates all outstanding signatures without warning

When the owner calls setVerifier to rotate the verifier key, any previously-issued signatures that haven't been submitted on-chain yet become permanently invalid (since recover will return the old address, failing the signer == verifier check at line 176). There's no grace period, no event emitted for off-chain systems to detect the change, and no mechanism to honor both old and new verifiers during a transition period. In production, a verifier rotation during active campaigns could cause users who legitimately completed tasks to lose their claim eligibility.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment thread frontend/src/app/page.tsx
Comment on lines +88 to 104
const handleClaim = async (campaignId: number) => {
// In production, fetch the signature from a backend verification service
// that confirms the user completed the task on Twitter/X
const response = await fetch(`/api/verify-claim?campaignId=${campaignId}&wallet=${address}`)
if (!response.ok) {
alert('Task verification failed. Complete the task first.')
return
}
const { signature } = await response.json()

writeContract({
address: contractAddress,
abi: contractABI,
functionName: 'claimReward',
args: [BigInt(campaignId)],
args: [BigInt(campaignId), signature as `0x${string}`],
})
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 handleClaim async function lacks try-catch, causing unhandled promise rejections

The handleClaim function is async and used as a click handler (onClick={() => handleClaim(id)} at line 151). If fetch() throws a network error (e.g., offline, DNS failure) or response.json() throws on malformed JSON, the resulting promise rejection is completely unhandled. Since React onClick handlers don't catch async rejections, this leads to an unhandled promise rejection with no user feedback.

Prompt for agents
The handleClaim function in frontend/src/app/page.tsx (lines 88-104) is an async function used as an onClick handler but has no try-catch block. If fetch() throws a network error or response.json() throws on malformed JSON, the error is completely unhandled. Wrap the function body in a try-catch block that catches errors and either shows an alert or sets an error state to inform the user that something went wrong.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@@ -5,15 +5,20 @@ import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 OpenZeppelin v5.x import path for ReentrancyGuard may be incorrect

The contract imports @openzeppelin/contracts/security/ReentrancyGuard.sol at line 6, but the package.json specifies "@openzeppelin/contracts": "^5.0.0". In OpenZeppelin v5.x, ReentrancyGuard was moved to @openzeppelin/contracts/utils/ReentrancyGuard.sol. This would cause a compilation failure. Not reported as a bug per guidelines (compiler would catch it), but flagging for awareness since there are no node_modules or lock files in the repo to verify the actual resolved version.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@fcfsprojects fcfsprojects left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. Thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant