Skip to content

Feature: Implement Daily AI Usage Limit with Cryptographic Challenge Verification #13

@IgorShadurin

Description

@IgorShadurin

Overview

Implement a daily limit of 10 AI usages per user for the "Fill with AI" feature. The system must use a cryptographic challenge-response mechanism to verify the user's identity and track usage accurately.

Requirements

  • Limit "Fill with AI" usage to 10 requests per day per user
  • Generate a UUIDv4 challenge when user opens the AI modal
  • Require cryptographic signature of the challenge before processing AI request
  • Verify signature authenticity and user existence on backend
  • Reset usage counters daily at midnight UTC
  • Cover the feature with tests

Backend Implementation

1. Database Changes

File: /backend/migrations/YYYYMMDDHHMMSS_add_ai_usage_tracking.js

exports.up = function(knex) {
  return knex.schema
    .createTable('ai_challenges', table => {
      table.uuid('id').primary();
      table.string('user_address', 42).notNullable();
      table.timestamp('created_at').defaultTo(knex.fn.now());
      table.boolean('used').defaultTo(false);
      table.index(['user_address', 'used']);
    })
    .createTable('ai_usage', table => {
      table.increments('id');
      table.string('user_address', 42).notNullable();
      table.date('usage_date').notNullable();
      table.integer('count').defaultTo(1);
      table.timestamp('created_at').defaultTo(knex.fn.now());
      table.timestamp('updated_at').defaultTo(knex.fn.now());
      table.unique(['user_address', 'usage_date']);
    });
};

exports.down = function(knex) {
  return knex.schema
    .dropTable('ai_challenges')
    .dropTable('ai_usage');
};

2. Backend Models

File: /backend/models/aiUsage.js

const knex = require('../db/knex');
const { v4: uuidv4 } = require('uuid');
const { verifySignature } = require('../utils/crypto');

class AIUsage {
  /**
   * Generate a new challenge for user authentication
   * @param {string} userAddress - The blockchain address of the user
   * @returns {Promise<string>} - The generated challenge UUID
   */
  static async generateChallenge(userAddress) {
    const challengeId = uuidv4();
    
    await knex('ai_challenges').insert({
      id: challengeId,
      user_address: userAddress,
      used: false
    });
    
    return challengeId;
  }
  
  /**
   * Verify challenge signature and check daily usage limits
   * @param {string} challengeId - The UUID challenge
   * @param {string} signature - The cryptographic signature
   * @param {string} userAddress - The user's blockchain address
   * @returns {Promise<boolean>} - Whether the request is authorized
   */
  static async verifyAndTrackUsage(challengeId, signature, userAddress) {
    // Get challenge from database
    const challenge = await knex('ai_challenges')
      .where({ id: challengeId, used: false })
      .first();
      
    if (!challenge) {
      throw new Error('Invalid or expired challenge');
    }
    
    // Verify the challenge was created for this user
    if (challenge.user_address !== userAddress) {
      throw new Error('Challenge does not match user');
    }
    
    // Verify signature
    const isValidSignature = verifySignature(challengeId, signature, userAddress);
    if (!isValidSignature) {
      throw new Error('Invalid signature');
    }
    
    // Mark challenge as used
    await knex('ai_challenges')
      .where({ id: challengeId })
      .update({ used: true });
    
    // Check and update usage count
    const today = new Date().toISOString().split('T')[0];
    
    const currentUsage = await knex('ai_usage')
      .where({ 
        user_address: userAddress,
        usage_date: today
      })
      .first();
    
    if (currentUsage) {
      // Check if limit reached
      if (currentUsage.count >= 10) {
        throw new Error('Daily AI usage limit reached');
      }
      
      // Increment usage count
      await knex('ai_usage')
        .where({ 
          user_address: userAddress,
          usage_date: today
        })
        .update({ 
          count: currentUsage.count + 1,
          updated_at: knex.fn.now()
        });
    } else {
      // Create new usage record
      await knex('ai_usage').insert({
        user_address: userAddress,
        usage_date: today,
        count: 1
      });
    }
    
    return true;
  }
  
  /**
   * Get current usage count for a user
   * @param {string} userAddress - The blockchain address of the user
   * @returns {Promise<number>} - Current usage count
   */
  static async getCurrentUsage(userAddress) {
    const today = new Date().toISOString().split('T')[0];
    
    const usage = await knex('ai_usage')
      .where({ 
        user_address: userAddress,
        usage_date: today
      })
      .first();
      
    return usage ? usage.count : 0;
  }
}

module.exports = AIUsage;

3. Utility Functions

File: /backend/utils/crypto.js

const ethers = require('ethers');

/**
 * Verifies a signature against a message and address
 * @param {string} message - The original message (challenge)
 * @param {string} signature - The cryptographic signature
 * @param {string} address - The blockchain address
 * @returns {boolean} - Whether the signature is valid
 */
function verifySignature(message, signature, address) {
  try {
    // Create the Ethereum-specific signing message
    const ethMessage = `\x19Ethereum Signed Message:\n${message.length}${message}`;
    
    // Hash the message
    const msgHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(ethMessage));
    
    // Recover the signing address
    const recoveredAddress = ethers.utils.recoverAddress(msgHash, signature);
    
    // Check if the recovered address matches the expected address
    return recoveredAddress.toLowerCase() === address.toLowerCase();
  } catch (error) {
    console.error('Signature verification error:', error);
    return false;
  }
}

module.exports = {
  verifySignature
};

4. API Endpoints

File: /backend/routes/ai.js

const express = require('express');
const router = express.Router();
const AIUsage = require('../models/aiUsage');
const auth = require('../middleware/auth');

/**
 * Generate a challenge for AI usage authentication
 */
router.post('/challenge', auth, async (req, res) => {
  try {
    const userAddress = req.user.address;
    
    // Generate a new challenge
    const challengeId = await AIUsage.generateChallenge(userAddress);
    
    // Get current usage
    const currentUsage = await AIUsage.getCurrentUsage(userAddress);
    
    res.json({
      success: true,
      challengeId,
      currentUsage,
      dailyLimit: 10,
      remainingUsage: Math.max(0, 10 - currentUsage)
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
});

/**
 * Verify challenge and process AI request
 */
router.post('/process', auth, async (req, res) => {
  try {
    const { challengeId, signature, aiPrompt } = req.body;
    const userAddress = req.user.address;
    
    if (!challengeId || !signature || !aiPrompt) {
      return res.status(400).json({
        success: false,
        error: 'Missing required parameters'
      });
    }
    
    // Verify signature and track usage
    await AIUsage.verifyAndTrackUsage(challengeId, signature, userAddress);
    
    // Process AI request
    // ... existing AI processing logic ...
    
    res.json({
      success: true,
      // AI response data
    });
  } catch (error) {
    res.status(error.message === 'Daily AI usage limit reached' ? 429 : 400).json({
      success: false,
      error: error.message
    });
  }
});

/**
 * Get current AI usage stats
 */
router.get('/usage', auth, async (req, res) => {
  try {
    const userAddress = req.user.address;
    const currentUsage = await AIUsage.getCurrentUsage(userAddress);
    
    res.json({
      success: true,
      currentUsage,
      dailyLimit: 10,
      remainingUsage: Math.max(0, 10 - currentUsage)
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
});

module.exports = router;

Frontend Implementation

1. AI Service

File: /app/services/AIService.ts

import { ethers } from 'ethers';
import api from './api';

/**
 * Service for handling AI operations with usage limits
 */
class AIService {
  /**
   * Request a new challenge for AI usage
   * @returns Challenge data including UUID and usage statistics
   */
  async requestChallenge(): Promise<{
    challengeId: string;
    currentUsage: number;
    dailyLimit: number;
    remainingUsage: number;
  }> {
    const response = await api.post('/api/ai/challenge');
    return response.data;
  }

  /**
   * Sign a challenge using the user's wallet
   * @param challengeId - The UUID challenge to sign
   * @returns The signature
   */
  async signChallenge(challengeId: string): Promise<string> {
    try {
      if (!window.ethereum) {
        throw new Error('No Ethereum wallet found');
      }

      const provider = new ethers.providers.Web3Provider(window.ethereum);
      const signer = provider.getSigner();
      const signature = await signer.signMessage(challengeId);
      
      return signature;
    } catch (error) {
      console.error('Error signing challenge:', error);
      throw error;
    }
  }

  /**
   * Process an AI request with required authentication
   * @param challengeId - The UUID challenge
   * @param signature - The cryptographic signature
   * @param aiPrompt - The AI prompt to process
   * @returns The AI response
   */
  async processAIRequest(
    challengeId: string,
    signature: string,
    aiPrompt: string
  ): Promise<any> {
    const response = await api.post('/api/ai/process', {
      challengeId,
      signature,
      aiPrompt
    });
    
    return response.data;
  }
  
  /**
   * Get current AI usage statistics
   * @returns Usage statistics
   */
  async getUsageStats(): Promise<{
    currentUsage: number;
    dailyLimit: number;
    remainingUsage: number;
  }> {
    const response = await api.get('/api/ai/usage');
    return response.data;
  }
}

export default new AIService();

2. AI Modal Component

File: /app/components/FillWithAIModal.tsx

import React, { useState, useEffect } from 'react';
import { Modal, Button, Alert, ProgressBar, Spinner } from 'react-bootstrap';
import AIService from '../services/AIService';

interface FillWithAIModalProps {
  show: boolean;
  onHide: () => void;
  onSubmit: (generatedContent: string) => void;
}

export const FillWithAIModal: React.FC<FillWithAIModalProps> = ({
  show,
  onHide,
  onSubmit
}) => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [prompt, setPrompt] = useState('');
  const [usage, setUsage] = useState({
    currentUsage: 0,
    dailyLimit: 10,
    remainingUsage: 10
  });
  const [challengeId, setChallengeId] = useState<string | null>(null);

  // Load challenge and usage stats when modal opens
  useEffect(() => {
    if (show) {
      loadChallenge();
    }
  }, [show]);

  const loadChallenge = async () => {
    try {
      setLoading(true);
      setError(null);
      
      const challengeData = await AIService.requestChallenge();
      setChallengeId(challengeData.challengeId);
      setUsage({
        currentUsage: challengeData.currentUsage,
        dailyLimit: challengeData.dailyLimit,
        remainingUsage: challengeData.remainingUsage
      });
    } catch (error) {
      setError('Failed to load AI challenge. Please try again.');
      console.error('Challenge error:', error);
    } finally {
      setLoading(false);
    }
  };

  const handleSubmit = async () => {
    if (!prompt.trim()) {
      setError('Please enter a prompt');
      return;
    }
    
    if (!challengeId) {
      setError('Authentication challenge not loaded');
      return;
    }
    
    if (usage.remainingUsage <= 0) {
      setError('Daily AI usage limit reached. Try again tomorrow.');
      return;
    }
    
    try {
      setLoading(true);
      setError(null);
      
      // Sign the challenge
      const signature = await AIService.signChallenge(challengeId);
      
      // Process the AI request
      const result = await AIService.processAIRequest(
        challengeId,
        signature,
        prompt
      );
      
      // Update usage stats
      setUsage(prev => ({
        ...prev,
        currentUsage: prev.currentUsage + 1,
        remainingUsage: Math.max(0, prev.remainingUsage - 1)
      }));
      
      // Pass generated content to parent
      onSubmit(result.generatedContent);
      
      // Close modal
      onHide();
    } catch (error) {
      if (error.response?.status === 429) {
        setError('Daily AI usage limit reached. Try again tomorrow.');
      } else if (error.message.includes('wallet')) {
        setError('Wallet connection failed. Please make sure your wallet is connected.');
      } else {
        setError(`AI generation failed: ${error.response?.data?.error || error.message}`);
      }
      console.error('AI process error:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <Modal show={show} onHide={onHide} centered>
      <Modal.Header closeButton>
        <Modal.Title>Fill with AI</Modal.Title>
      </Modal.Header>
      <Modal.Body>
        {error && <Alert variant="danger">{error}</Alert>}
        
        <div className="mb-3">
          <label className="form-label">Daily Usage</label>
          <ProgressBar 
            now={(usage.currentUsage / usage.dailyLimit) * 100} 
            variant={usage.remainingUsage > 2 ? 'success' : 'warning'} 
          />
          <small className="text-muted mt-1 d-block">
            {usage.remainingUsage} of {usage.dailyLimit} AI generations remaining today
          </small>
        </div>
        
        <div className="mb-3">
          <label htmlFor="ai-prompt" className="form-label">Prompt</label>
          <textarea
            id="ai-prompt"
            className="form-control"
            rows={4}
            value={prompt}
            onChange={(e) => setPrompt(e.target.value)}
            disabled={loading || usage.remainingUsage <= 0}
            placeholder="Enter your prompt for AI content generation..."
          />
        </div>
      </Modal.Body>
      <Modal.Footer>
        <Button variant="secondary" onClick={onHide} disabled={loading}>
          Cancel
        </Button>
        <Button 
          variant="primary" 
          onClick={handleSubmit} 
          disabled={loading || !prompt.trim() || usage.remainingUsage <= 0}
        >
          {loading ? (
            <>
              <Spinner animation="border" size="sm" className="me-2" />
              Processing...
            </>
          ) : (
            'Generate'
          )}
        </Button>
      </Modal.Footer>
    </Modal>
  );
};

export default FillWithAIModal;

3. App.js Integration

File: /app/App.tsx or wherever the AI modal is used

// Import the updated modal
import FillWithAIModal from './components/FillWithAIModal';

// Example usage
const [showAIModal, setShowAIModal] = useState(false);

const handleAIGenerated = (content) => {
  // Handle the generated content
  console.log('AI generated content:', content);
  // Update your form or content
};

// In your JSX:
<Button onClick={() => setShowAIModal(true)}>
  Fill with AI
</Button>

<FillWithAIModal
  show={showAIModal}
  onHide={() => setShowAIModal(false)}
  onSubmit={handleAIGenerated}
/>

Testing Requirements

  1. Unit Tests

    • Test challenge generation
    • Test signature verification
    • Test usage limit enforcement
    • Test daily reset functionality
  2. Integration Tests

    • Test full flow from challenge generation to AI response
    • Test with different user addresses
    • Test limit reached scenarios
  3. Manual Testing

    • Verify UI feedback when limit reached
    • Test with actual wallet signatures
    • Verify daily reset works properly

Deployment Considerations

  1. Database Migration

    • Run the migration on production/staging
    • Verify table creation and indexes
  2. Feature Flagging

    • Consider implementing behind a feature flag for gradual rollout
    • Monitor usage patterns after deployment
  3. Admin Dashboard

    • Consider adding admin ability to view/reset user limits

Success Criteria

  • User can only use AI feature 10 times per day
  • Challenge-based cryptographic verification works correctly
  • Frontend provides clear feedback on remaining usage
  • Daily usage resets properly at midnight UTC
  • System correctly rejects requests beyond the limit

Timeline

  • Database changes: 0.5 day
  • Backend implementation: 1 day
  • Frontend implementation: 1 day
  • Testing: 0.5 day

Notes

  • Consider caching challenge verification to improve performance
  • Plan for scaling if AI feature usage grows significantly
  • Consider implementing a premium tier with higher limits in the future
  • Add monitoring on usage patterns to detect potential abuse

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions