Skip to main content

Overview

StoryProtocol handles all ESTORY token transfers between users:
  • Tipping: Send ESTORY to creators to show appreciation
  • Paywalls: Pay to unlock premium content
  • Safe Transfers: Uses OpenZeppelin’s SafeERC20 to prevent silent failures
  • Reentrancy Protection: nonReentrant guard on all payment functions
  • Pausable: Admin can pause payments in emergencies

Contract Address

const STORY_PROTOCOL_ADDRESS = "0xA51a4cA00cC4C81A5F7cB916D0BFa1a4aD6f4a71";

Contract Code

contracts/StoryProtocol.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

contract StoryProtocol is ReentrancyGuard, Pausable, AccessControl {
    using SafeERC20 for IERC20;

    IERC20 public immutable storyToken;
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

    event TipSent(address indexed from, address indexed to, uint256 amount, uint256 indexed storyId);
    event ContentUnlocked(address indexed payer, address indexed author, uint256 amount, uint256 indexed contentId);

    constructor(address _tokenAddress, address initialAdmin) {
        storyToken = IERC20(_tokenAddress);
        _grantRole(DEFAULT_ADMIN_ROLE, initialAdmin);
        _grantRole(PAUSER_ROLE, initialAdmin);
    }

    function tipCreator(address creator, uint256 amount, uint256 storyId) 
        external 
        nonReentrant 
        whenNotPaused 
    {
        require(amount > 0, "Amount must be > 0");
        require(creator != address(0), "Invalid creator address");
        
        storyToken.safeTransferFrom(msg.sender, creator, amount);
        emit TipSent(msg.sender, creator, amount, storyId);
    }

    function payPaywall(address author, uint256 amount, uint256 contentId) 
        external 
        nonReentrant 
        whenNotPaused 
    {
        require(amount > 0, "Amount must be > 0");
        require(author != address(0), "Invalid author address");

        storyToken.safeTransferFrom(msg.sender, author, amount);
        emit ContentUnlocked(msg.sender, author, amount, contentId);
    }

    function pause() external onlyRole(PAUSER_ROLE) {
        _pause();
    }

    function unpause() external onlyRole(PAUSER_ROLE) {
        _unpause();
    }
}

Functions

tipCreator

Send ESTORY tokens to a creator as a tip.
function tipCreator(address creator, uint256 amount, uint256 storyId) external
creator
address
required
Creator’s wallet address (recipient of tip)
amount
uint256
required
Tip amount in wei (e.g., 10 ESTORY = 10 * 10^18 wei)
storyId
uint256
required
Database ID of the story being tipped (for backend indexing)
event
TipSent
Emitted on success with from, to, amount, and storyId
Pre-Approval Required: Users must approve StoryProtocol to spend ESTORY before tipping:
await eStoryToken.approve(STORY_PROTOCOL_ADDRESS, amount);
Reverts if:
  • amount is 0
  • creator is address(0)
  • Insufficient allowance
  • Insufficient balance
  • Contract is paused

payPaywall

Pay to unlock premium content.
function payPaywall(address author, uint256 amount, uint256 contentId) external
author
address
required
Author’s wallet address (recipient of payment)
amount
uint256
required
Payment amount in wei
contentId
uint256
required
Database ID of the content being unlocked
event
ContentUnlocked
Emitted on success. Backend listens for this to grant access in Supabase.
Backend Integration: The iStory backend listens for ContentUnlocked events and updates the unlocked_content table in Supabase to grant the payer access to the content.

pause / unpause (Admin Only)

function pause() external onlyRole(PAUSER_ROLE)
function unpause() external onlyRole(PAUSER_ROLE)
Pauses/resumes all payment functions. Only callable by addresses with PAUSER_ROLE.

Events

TipSent

event TipSent(address indexed from, address indexed to, uint256 amount, uint256 indexed storyId);
Emitted when a tip is sent.
from
address
Tipper’s address
to
address
Creator’s address
amount
uint256
Tip amount in wei
storyId
uint256
Database ID of the story

ContentUnlocked

event ContentUnlocked(address indexed payer, address indexed author, uint256 amount, uint256 indexed contentId);
Emitted when content is unlocked.
payer
address
User who paid to unlock content
author
address
Content author (recipient)
amount
uint256
Payment amount in wei
contentId
uint256
Database ID of the unlocked content

Usage Examples

Tip a Creator

import { useStoryProtocol } from '@/app/hooks/useStoryProtocol';
import { useEStoryToken } from '@/app/hooks/useEStoryToken';
import { parseEther } from 'viem';
import { STORY_PROTOCOL_ADDRESS } from '@/lib/contracts';

async function tipStory(creatorAddress: string, storyId: number) {
  const { approve } = useEStoryToken();
  const { tipCreator } = useStoryProtocol();
  
  const amount = parseEther("5"); // 5 ESTORY
  
  // Step 1: Approve StoryProtocol to spend tokens
  await approve(STORY_PROTOCOL_ADDRESS, amount);
  
  // Step 2: Send tip
  await tipCreator(creatorAddress, amount, BigInt(storyId));
  
  toast.success("Tip sent successfully!");
}

Unlock Paywalled Content

Paywall Flow
import { useStoryProtocol } from '@/app/hooks/useStoryProtocol';
import { parseEther } from 'viem';

async function unlockContent(authorAddress: string, contentId: number, price: string) {
  const { approve } = useEStoryToken();
  const { payPaywall } = useStoryProtocol();
  
  const amount = parseEther(price); // e.g., "2.5" ESTORY
  
  // Step 1: Approve payment
  await approve(STORY_PROTOCOL_ADDRESS, amount);
  
  // Step 2: Pay paywall
  await payPaywall(authorAddress, amount, BigInt(contentId));
  
  // Step 3: Backend listens for ContentUnlocked event and grants access
  // Frontend polls /api/paywall/check?contentId=123 until access is granted
}

Listen for Events (Backend)

Backend Event Listener
import { publicClient } from '@/lib/wagmi';
import { STORY_PROTOCOL_ABI, STORY_PROTOCOL_ADDRESS } from '@/lib/contracts';
import { supabaseAdmin } from '@/app/utils/supabase/supabaseAdmin';

// Listen for ContentUnlocked events
const unwatch = publicClient.watchContractEvent({
  address: STORY_PROTOCOL_ADDRESS,
  abi: STORY_PROTOCOL_ABI,
  eventName: 'ContentUnlocked',
  onLogs: async (logs) => {
    for (const log of logs) {
      const { payer, author, amount, contentId } = log.args;
      
      // Grant access in Supabase
      await supabaseAdmin.from('unlocked_content').insert({
        user_id: payer,
        content_id: contentId.toString(),
        author_id: author,
        amount_paid: amount.toString(),
        unlocked_at: new Date().toISOString()
      });
      
      console.log(`Content ${contentId} unlocked for ${payer}`);
    }
  }
});

Security Features

SafeERC20

StoryProtocol uses OpenZeppelin’s SafeERC20.safeTransferFrom() instead of raw transferFrom():
// ✅ Good: Reverts if transfer fails
storyToken.safeTransferFrom(msg.sender, creator, amount);

// ❌ Bad: Returns false on failure (easy to miss)
storyToken.transferFrom(msg.sender, creator, amount);
safeTransferFrom() reverts if:
  • Token transfer returns false
  • Token doesn’t return a boolean (some ERC20s)
  • Token reverts
This prevents silent failures and loss of funds.

Reentrancy Protection

All payment functions use nonReentrant modifier:
function tipCreator(...) external nonReentrant whenNotPaused {
    // Safe from reentrancy attacks
    storyToken.safeTransferFrom(msg.sender, creator, amount);
}
Why Reentrancy Protection?Even though ESTORY token is a standard ERC20 (no hooks), nonReentrant protects against:
  • Future upgrades to the token contract
  • Malicious ERC777-style tokens if contract is used with other tokens
  • Cross-function reentrancy
Best practice: Always use nonReentrant on functions that transfer value.

Validation

All inputs are validated:
require(amount > 0, "Amount must be > 0");
require(creator != address(0), "Invalid creator address");
Prevents:
  • Zero-value transactions (gas waste)
  • Sending tokens to address(0) (burns tokens unintentionally)

Gas Optimization

IERC20 public immutable storyToken;
immutable keyword bakes the token address into the contract bytecode at deployment.Gas Savings: ~2100 gas per read (vs. storage slot read)
event TipSent(
    address indexed from,
    address indexed to,
    uint256 amount,
    uint256 indexed storyId
);
indexed parameters are stored in the event log’s topics (not data), enabling efficient filtering:
// Filter tips to a specific creator
publicClient.getLogs({
  event: parseAbiItem('event TipSent(address indexed from, address indexed to, uint256 amount, uint256 indexed storyId)'),
  args: { to: creatorAddress }
});

ABI Reference

lib/contracts.ts
export const STORY_PROTOCOL_ABI = [
  "function tipCreator(address creator, uint256 amount, uint256 storyId)",
  "function payPaywall(address author, uint256 amount, uint256 contentId)",
  "event TipSent(address indexed from, address indexed to, uint256 amount, uint256 indexed storyId)",
  "event ContentUnlocked(address indexed payer, address indexed author, uint256 amount, uint256 indexed contentId)"
] as const;

Common Errors

User hasn’t approved StoryProtocol to spend ESTORY tokens.Fix: Call eStoryToken.approve(STORY_PROTOCOL_ADDRESS, amount) before tipping/paying.
User doesn’t have enough ESTORY tokens.Fix: Check balance with eStoryToken.balanceOf(userAddress) before attempting payment.
Tried to send 0 tokens.Fix: Ensure amount > 0 before calling the function.
Tried to send tokens to address(0).Fix: Validate recipient address is not zero address.
ESTORY token contract is paused (admin emergency action).Fix: Wait for admin to call unpause() on eStoryToken.

Next Steps

eStoryToken

Learn about token approvals and balances

StoryNFT

Mint NFT collections from your stories

Build docs developers (and LLMs) love