A Hono-based API for managing onchain Seeds (artwork proposals) and NFT-based blessings for the Abraham ecosystem.
This API provides:
- Seed Creation - Onchain artwork proposals with authorized creator control
- Blessings System - NFT-based support/likes for Seeds
The system uses:
- TheSeeds Contract (Base L2) for onchain seed and blessing storage
- Privy for authentication
- Viem for Ethereum blockchain interactions
- Hono as the lightweight web framework
- ✅ Onchain Storage: All seeds stored on TheSeeds contract (Base L2)
- ✅ Authorized Creators: Only wallets with CREATOR_ROLE can create seeds
- ✅ Two Creation Modes:
- Backend-Signed (Gasless): API creates seed on behalf of creator (requires admin key)
- Client-Signed: Creator signs transaction directly with their wallet
- ✅ Access Control: Role-based permissions using OpenZeppelin AccessControl
- ✅ Onchain Blessings: All blessings stored on blockchain
- ✅ NFT-based eligibility: If you own N FirstWorks NFTs, you get N blessings per day
- ✅ 24-hour blessing period: Resets at midnight UTC
- ✅ Delegation Support: Users can approve backend to bless on their behalf (gasless)
- ✅ Daily NFT snapshots: Fast lookup for eligibility
-
Install dependencies
npm install
-
Set up environment variables
cp .env.example .env.local # Edit .env.local with your configurationGenerate your admin key:
# Generate a secure random admin key openssl rand -hex 32 # Add it to .env.local: # ADMIN_KEY=<generated_key_here>
-
Generate initial NFT snapshot
npm run snapshot:generate
-
Start the development server
npm run dev
The API will be running at http://localhost:3000
The Abraham API uses a snapshot of FirstWorks NFT ownership to determine blessing eligibility. This snapshot needs to be updated periodically to reflect current NFT ownership.
We provide a single command and single API endpoint that performs all three steps automatically:
- Generate NFT Snapshot - Fetches current FirstWorks ownership from Ethereum mainnet
- Generate Merkle Tree - Creates merkle tree with proofs for each holder
- Update Contract - Updates the merkle root on TheSeeds contract (L2)
Automatic snapshot and merkle tree storage - No manual git commits needed!
When BLOB_READ_WRITE_TOKEN is configured, the system automatically:
- ✅ Uploads snapshots to Vercel Blob storage after generation
- ✅ Uploads merkle trees to Vercel Blob storage
- ✅ Cleans up old versions (keeps last 5)
- ✅ Provides fast CDN-backed reads for API endpoints
- ✅ Eliminates manual git commit workflow
Setup:
- Go to Vercel Dashboard
- Select your project
- Go to "Storage" tab
- Create a Blob Store (if you haven't already)
- Copy the "Read-Write Token"
- Add to environment variables:
BLOB_READ_WRITE_TOKEN=your_vercel_blob_token_hereBenefits:
- 5GB free tier - More than enough for snapshots
- No manual workflow - Just call the API or run the script
- Automatic cleanup - Old versions are deleted automatically
- Fast reads - CDN-backed storage for quick API responses
Without Blob Storage:
- Snapshots saved to
/tmpon Vercel (ephemeral) - Must commit
latest.jsonandfirstWorks_merkle.jsonto git manually - Deployments use committed files from git
Update everything in one command:
npm run update-snapshotOptions:
# Skip contract update (only generate snapshot + merkle)
SKIP_CONTRACT_UPDATE=true npm run update-snapshot
# Specify network (default: baseSepolia)
NETWORK=base npm run update-snapshot
# Both options together
NETWORK=base SKIP_CONTRACT_UPDATE=true npm run update-snapshotEnvironment Variables Required:
# For snapshot generation (Ethereum mainnet)
FIRSTWORKS_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY
CONTRACT_ADDRESS=0x8F814c7C75C5E9e0EDe0336F535604B1915C1985
# For contract update (Base L2)
RELAYER_PRIVATE_KEY=0x... # Wallet with admin permissions
CONTRACT_ADDRESS=0x... # TheSeeds contract on Base
NETWORK=baseSepolia # or "base" for mainnet
BASE_SEPOLIA_RPC=https://sepolia.base.org
BASE_MAINNET_RPC=https://mainnet.base.orgOutput:
============================================================
STEP 1: Generating FirstWorks NFT Snapshot
============================================================
✓ Getting FirstWorks contract metadata...
✓ Fetching token ownership...
✓ Snapshot generated successfully
============================================================
STEP 2: Generating Merkle Tree
============================================================
✓ Generated 150 leaves
✓ Merkle Root: 0xabc123...
✓ Merkle tree generated successfully
============================================================
STEP 3: Updating Contract Merkle Root
============================================================
✓ Transaction hash: 0xdef456...
✓ Root updated in block 12345678
✓ Contract updated successfully
============================================================
SUMMARY
============================================================
✓ Snapshot Generated: ./lib/snapshots/snapshot-1234567890.json
✓ Merkle Tree Generated: ./lib/snapshots/firstWorks_merkle.json
✓ Merkle Root: 0xabc123...
✓ Contract Updated: 0xdef456...
✓ Block Number: 12345678
✓ ALL STEPS COMPLETED SUCCESSFULLY!
⚡ FAST: Snapshot generation completes in ~10-30 seconds using Alchemy's NFT API!
Requirements:
- Alchemy RPC URL (includes NFT API access - free tier available)
- Set
FIRSTWORKS_RPC_URLto your Alchemy endpointFallback: If not using Alchemy, falls back to slower RPC calls (may timeout on Vercel)
Endpoint: POST /api/admin/update-snapshot
Authentication:
- Admin key only (via
X-Admin-Keyheader) - No Privy authentication required for admin endpoints
Generating Your Admin Key:
The admin key is a secret you create yourself. Generate a strong random key:
# Using OpenSSL (recommended)
openssl rand -hex 32
# Using Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Example output:
# a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456Add it to your .env.local:
ADMIN_KEY=a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456Request:
curl -X POST http://localhost:3000/api/admin/update-snapshot \
-H "X-Admin-Key: your-secret-admin-key"Query Parameters:
skipContract(optional) - Set totrueto skip contract update
# Skip contract update
curl -X POST "http://localhost:3000/api/admin/update-snapshot?skipContract=true" \
-H "X-Admin-Key: your-secret-admin-key"Response:
{
"success": true,
"data": {
"snapshotPath": "./lib/snapshots/snapshot-1234567890.json",
"merklePath": "./lib/snapshots/firstWorks_merkle.json",
"merkleRoot": "0xabc123...",
"txHash": "0xdef456...",
"blockNumber": "12345678",
"steps": {
"snapshot": true,
"merkle": true,
"contract": true
},
"timestamp": "2025-10-24T15:30:00.000Z"
}
}TypeScript/JavaScript Example:
async function updateSnapshot() {
const response = await fetch('http://localhost:3000/api/admin/update-snapshot', {
method: 'POST',
headers: {
'X-Admin-Key': process.env.ADMIN_KEY // Only admin key needed
}
});
const result = await response.json();
if (result.success) {
console.log('Snapshot updated!');
console.log('Merkle Root:', result.data.merkleRoot);
console.log('TX Hash:', result.data.txHash);
} else {
console.error('Update failed:', result.error);
}
}
// Skip contract update (only generate snapshot + merkle)
async function updateSnapshotOnly() {
const response = await fetch('http://localhost:3000/api/admin/update-snapshot?skipContract=true', {
method: 'POST',
headers: {
'X-Admin-Key': process.env.ADMIN_KEY
}
});
const result = await response.json();
// ...
}If you're NOT using Alchemy (which provides fast NFT API), you may need this workflow:
# Run the unified update script
npm run update-snapshotThis will:
- Fetch FirstWorks NFT ownership from Ethereum mainnet
- Generate merkle tree with proofs
- Update merkle root on TheSeeds contract (L2)
- Save to
lib/snapshots/latest.jsonandlib/snapshots/firstWorks_merkle.json
# Add the updated snapshot files
git add lib/snapshots/latest.json lib/snapshots/firstWorks_merkle.json
# Commit
git commit -m "chore: update FirstWorks NFT snapshot"
# Push to GitHub
git pushVercel will automatically:
- Detect the git push
- Trigger a new deployment
- Include the updated snapshot files
- Deploy the new version
You can automate this with a GitHub Action that runs on a schedule:
# .github/workflows/update-snapshot.yml
name: Update NFT Snapshot
on:
schedule:
# Run daily at midnight UTC
- cron: '0 0 * * *'
workflow_dispatch: # Allow manual trigger
jobs:
update-snapshot:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm install
- name: Update snapshot
env:
FIRSTWORKS_RPC_URL: ${{ secrets.FIRSTWORKS_RPC_URL }}
L2_SEEDS_CONTRACT: ${{ secrets.L2_SEEDS_CONTRACT }}
DEPLOYER_PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }}
BASE_SEPOLIA_RPC_URL: ${{ secrets.BASE_SEPOLIA_RPC_URL }}
run: npm run update-snapshot
- name: Commit and push
run: |
git config user.name "GitHub Actions"
git config user.email "[email protected]"
git add lib/snapshots/latest.json lib/snapshots/firstWorks_merkle.json
git commit -m "chore: automated NFT snapshot update" || exit 0
git pushEndpoint: GET /api/admin/snapshot-status
Authentication: None required (public info)
curl http://localhost:3000/api/admin/snapshot-statusResponse:
{
"success": true,
"data": {
"snapshotExists": true,
"merkleExists": true,
"snapshot": {
"totalHolders": 150,
"totalSupply": 500,
"timestamp": "2025-10-24T12:00:00.000Z",
"blockNumber": 18500000,
"contractAddress": "0x8F814c7C75C5E9e0EDe0336F535604B1915C1985"
},
"merkle": {
"root": "0xabc123...",
"totalLeaves": 150,
"totalProofs": 150
}
}
}Endpoint: POST /api/admin/reload-snapshot
Authentication: Admin key only (no Privy token required)
Reloads the in-memory snapshot cache without regenerating or updating contract.
curl -X POST http://localhost:3000/api/admin/reload-snapshot \
-H "X-Admin-Key: your-secret-admin-key"Recommended Schedule:
- Daily - Automated cron job during low-traffic hours
- After major events - Large NFT transfers, minting events
- Before voting rounds - Ensure accurate eligibility data
Automation Example (Cron):
# Add to crontab (runs daily at 3 AM)
0 3 * * * cd /path/to/abraham-api && npm run update-snapshot >> logs/snapshot-update.log 2>&1Serverless Function (Vercel Cron):
// api/cron/update-snapshot.ts
import { updateSnapshotAndMerkle } from '../../scripts/updateSnapshot';
export default async function handler(req: Request) {
// Verify Vercel cron secret (automatically added by Vercel)
const authHeader = req.headers.get('Authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response('Unauthorized', { status: 401 });
}
try {
const result = await updateSnapshotAndMerkle();
return Response.json({ success: true, data: result });
} catch (error) {
return Response.json({ success: false, error: String(error) }, { status: 500 });
}
}
// vercel.json
{
"crons": [{
"path": "/api/cron/update-snapshot",
"schedule": "0 3 * * *" // Daily at 3 AM
}]
}Note: Vercel automatically adds a CRON_SECRET to your environment and includes it in the Authorization header for cron requests. This is different from your ADMIN_KEY and is managed by Vercel.
- Deploy TheSeeds contract to Base Sepolia or Base Mainnet
- Configure environment variables (see below)
- Grant CREATOR_ROLE to authorized wallets
Add these to your .env.local file:
# Required for backend-signed seed creation
RELAYER_PRIVATE_KEY=0x... # Private key for backend wallet (must have CREATOR_ROLE)
CONTRACT_ADDRESS=0x... # TheSeeds contract address
NETWORK=baseSepolia # or "base" for mainnet
ADMIN_KEY=your-secret-admin-key # Secret key for admin endpoints
# RPC URLs
BASE_SEPOLIA_RPC=https://sepolia.base.org
BASE_MAINNET_RPC=https://mainnet.base.org
# Privy (for authentication)
PRIVY_APP_ID=your-privy-app-id
PRIVY_APP_SECRET=your-privy-app-secretThere are two ways to authorize seed creators:
npx hardhat console --network baseSepoliaconst TheSeeds = await ethers.getContractAt("TheSeeds", "YOUR_CONTRACT_ADDRESS");
// Grant CREATOR_ROLE to a wallet
await TheSeeds.addCreator("0x_CREATOR_WALLET_ADDRESS");
// Verify the role was granted
const CREATOR_ROLE = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("CREATOR_ROLE"));
const hasRole = await TheSeeds.hasRole(CREATOR_ROLE, "0x_CREATOR_WALLET_ADDRESS");
console.log("Has CREATOR_ROLE:", hasRole);# Grant CREATOR_ROLE to a wallet
cast send YOUR_CONTRACT_ADDRESS \
"addCreator(address)" \
0x_CREATOR_WALLET_ADDRESS \
--rpc-url https://sepolia.base.org \
--private-key YOUR_ADMIN_PRIVATE_KEY
# Verify the role
cast call YOUR_CONTRACT_ADDRESS \
"hasRole(bytes32,address)(bool)" \
0x828634d95e775031b9ff576c159e20a8a57946bda7a10f5b0e5f3b5f0e0ad4e7 \
0x_CREATOR_WALLET_ADDRESS \
--rpc-url https://sepolia.base.orgNote: 0x828634d95e775031b9ff576c159e20a8a57946bda7a10f5b0e5f3b5f0e0ad4e7 is keccak256("CREATOR_ROLE")
When to use: When you want the backend to pay gas fees and control seed creation via admin authentication.
Requirements:
- Backend wallet must have CREATOR_ROLE on the contract
- Request must include
X-Admin-Keyheader ADMIN_KEYenvironment variable must be set
Example:
curl -X POST http://localhost:3000/api/seeds \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_PRIVY_TOKEN" \
-H "X-Admin-Key: your-secret-admin-key" \
-d '{
"ipfsHash": "QmX...",
"title": "My Seed Title",
"description": "Seed description"
}'When to use: When you want creators to sign transactions with their own wallets and pay their own gas.
Requirements:
- Creator wallet must have CREATOR_ROLE on the contract
- User must sign the transaction with their wallet (e.g., via wagmi/viem)
Example:
// 1. Request transaction data from API
const response = await fetch('/api/seeds/prepare', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${privyToken}`
},
body: JSON.stringify({
ipfsHash: 'QmX...',
title: 'My Seed',
description: 'Description'
})
});
const { transaction } = await response.json();
// 2. Sign and send transaction with user's wallet
import { useWalletClient } from 'wagmi';
const { data: walletClient } = useWalletClient();
const hash = await walletClient.sendTransaction({
to: transaction.to,
data: transaction.data,
});This guide covers all the ways you can create seeds onchain, from backend-signed transactions to direct contract interactions.
Seeds are artwork proposals stored onchain in TheSeeds contract. Only wallets with CREATOR_ROLE can create seeds. There are multiple ways to create seeds depending on your use case:
| Method | Who Pays Gas | Best For | Requires Admin Key |
|---|---|---|---|
| Backend-Signed (API) | Backend | Curated submissions, gasless UX | Yes |
| Client-Signed (API + Wallet) | User | Self-service creators | No |
| Hardhat Console | You | Development, testing, admin tasks | No |
| Cast (Foundry) | You | CLI workflows, scripts | No |
| Basescan UI | You | One-off manual creation | No |
Use Case: Backend creates seeds on behalf of creators, paying gas fees. Good for curated submissions or when you want to provide a gasless experience.
Prerequisites:
- Backend wallet has CREATOR_ROLE
RELAYER_PRIVATE_KEYset in.env.localADMIN_KEYset in.env.local- Privy authentication setup
Step-by-Step:
- Get authenticated with Privy:
import { usePrivy } from '@privy-io/react-auth';
const { getAccessToken } = usePrivy();
const token = await getAccessToken();- Call the API endpoint with admin key:
const response = await fetch('http://localhost:3000/api/seeds', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-Admin-Key': 'your-secret-admin-key' // From .env.local
},
body: JSON.stringify({
ipfsHash: 'QmX123...',
title: 'My Artwork Title',
description: 'Description of the artwork'
})
});
const result = await response.json();
console.log('Seed created:', result.data.seedId);
console.log('Transaction:', result.data.txHash);- Handle the response:
if (result.success) {
// Seed created successfully
const seedId = result.data.seedId;
const txHash = result.data.txHash;
const blockExplorer = result.data.blockExplorer;
// Show success message to user
alert(`Seed #${seedId} created! View on Basescan: ${blockExplorer}`);
} else {
// Handle errors
console.error('Error:', result.error);
}Common Errors:
401 Unauthorized - Invalid admin key→ CheckX-Admin-KeymatchesADMIN_KEYin.env.local403 Relayer does not have CREATOR_ROLE→ Grant CREATOR_ROLE to backend wallet503 Backend blessing service not configured→ AddRELAYER_PRIVATE_KEYto.env.local
Use Case: Creators sign transactions with their own wallets and pay their own gas. Good for self-service creator platforms.
Prerequisites:
- Creator wallet has CREATOR_ROLE (granted by admin)
- User has a connected wallet (via wagmi, viem, or Privy embedded wallet)
- Privy authentication setup
Step-by-Step:
- Prepare the transaction via API:
import { usePrivy } from '@privy-io/react-auth';
import { useWalletClient } from 'wagmi';
async function createSeed(ipfsHash: string, title: string, description: string) {
// 1. Get Privy token
const { getAccessToken } = usePrivy();
const token = await getAccessToken();
// 2. Request transaction data from API
const response = await fetch('http://localhost:3000/api/seeds/prepare', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ ipfsHash, title, description })
});
const { data } = await response.json();
// 3. Check if user has CREATOR_ROLE
if (!data.hasCreatorRole) {
alert('You need CREATOR_ROLE to create seeds. Contact an admin.');
return;
}
// 4. Sign and send transaction with user's wallet
const { data: walletClient } = useWalletClient();
const hash = await walletClient.sendTransaction({
to: data.transaction.to,
data: data.transaction.data,
});
console.log('Seed creation transaction sent:', hash);
// 5. Wait for confirmation (optional)
import { waitForTransactionReceipt } from 'viem';
const receipt = await waitForTransactionReceipt(walletClient, { hash });
console.log('Seed created in block:', receipt.blockNumber);
}- Complete React Component Example:
import { useState } from 'react';
import { usePrivy } from '@privy-io/react-auth';
import { useWalletClient } from 'wagmi';
function CreateSeedForm() {
const { getAccessToken, authenticated } = usePrivy();
const { data: walletClient } = useWalletClient();
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
try {
const formData = new FormData(e.currentTarget);
const ipfsHash = formData.get('ipfsHash') as string;
const title = formData.get('title') as string;
const description = formData.get('description') as string;
// Get transaction data
const token = await getAccessToken();
const response = await fetch('/api/seeds/prepare', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ ipfsHash, title, description })
});
const { data } = await response.json();
if (!data.hasCreatorRole) {
alert('You need CREATOR_ROLE to create seeds');
return;
}
// Sign with user's wallet
const hash = await walletClient.sendTransaction({
to: data.transaction.to,
data: data.transaction.data,
});
alert(`Seed created! Transaction: ${hash}`);
} catch (error) {
console.error('Error creating seed:', error);
alert('Failed to create seed');
} finally {
setLoading(false);
}
}
if (!authenticated) {
return <div>Please log in to create seeds</div>;
}
return (
<form onSubmit={handleSubmit}>
<input
name="ipfsHash"
placeholder="IPFS Hash (QmX...)"
required
/>
<input
name="title"
placeholder="Seed Title"
required
/>
<textarea
name="description"
placeholder="Description (optional)"
/>
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Seed'}
</button>
</form>
);
}Common Errors:
hasCreatorRole: false→ User needs CREATOR_ROLE granted by admin- Wallet rejects transaction → User needs gas (ETH) on Base network
AccessControl: account is missing role→ Wallet address doesn't have CREATOR_ROLE
Use Case: Development, testing, or admin tasks. Good for granting roles and creating test seeds.
Prerequisites:
- Hardhat installed (
npm install --save-dev hardhat) - Wallet with CREATOR_ROLE
PRIVATE_KEYin.env.local
Step-by-Step:
- Open Hardhat console:
npx hardhat console --network baseSepolia- Get contract instance:
const TheSeeds = await ethers.getContractAt(
"TheSeeds",
"0x878baad70577cf114a3c60fd01b5a036fd0c4bc8" // Your contract address
);- Check if you have CREATOR_ROLE:
const [signer] = await ethers.getSigners();
const myAddress = await signer.getAddress();
console.log("My address:", myAddress);
const CREATOR_ROLE = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("CREATOR_ROLE"));
const hasRole = await TheSeeds.hasRole(CREATOR_ROLE, myAddress);
console.log("Has CREATOR_ROLE:", hasRole);- Create a seed:
const tx = await TheSeeds.submitSeed(
"QmX123...", // IPFS hash
"My Artwork", // Title
"Description here" // Description
);
console.log("Transaction hash:", tx.hash);
await tx.wait();
console.log("Seed created!");
// Get the seed ID from events
const receipt = await tx.wait();
const event = receipt.events.find(e => e.event === 'SeedSubmitted');
const seedId = event.args.seedId.toNumber();
console.log("Seed ID:", seedId);- Verify the seed:
const seed = await TheSeeds.getSeed(seedId);
console.log("Title:", seed.title);
console.log("Creator:", seed.creator);
console.log("IPFS Hash:", seed.ipfsHash);Use Case: CLI workflows, automation scripts, CI/CD pipelines.
Prerequisites:
- Foundry installed (
curl -L https://foundry.paradigm.xyz | bash && foundryup) - Wallet with CREATOR_ROLE
- Private key or keystore
Step-by-Step:
- Set environment variables:
export CONTRACT=0x878baad70577cf114a3c60fd01b5a036fd0c4bc8
export RPC_URL=https://sepolia.base.org
export PRIVATE_KEY=0x... # Your private key- Check if you have CREATOR_ROLE:
# CREATOR_ROLE hash
CREATOR_ROLE=0x828634d95e775031b9ff576c159e20a8a57946bda7a10f5b0e5f3b5f0e0ad4e7
# Your wallet address
YOUR_ADDRESS=0x...
# Check role
cast call $CONTRACT \
"hasRole(bytes32,address)(bool)" \
$CREATOR_ROLE \
$YOUR_ADDRESS \
--rpc-url $RPC_URL
# Should return: true- Create a seed:
cast send $CONTRACT \
"submitSeed(string,string,string)" \
"QmX123..." \
"My Artwork Title" \
"Description of the artwork" \
--rpc-url $RPC_URL \
--private-key $PRIVATE_KEY- Get transaction receipt:
# The command will output a transaction hash
# View on Basescan:
# https://sepolia.basescan.org/tx/0x...- Query the seed (get latest seed ID first):
# Get total seed count
cast call $CONTRACT "getSeedCount()(uint256)" --rpc-url $RPC_URL
# Get seed details (e.g., seed ID 0)
cast call $CONTRACT \
"getSeed(uint256)" \
0 \
--rpc-url $RPC_URLAutomation Script Example:
#!/bin/bash
# create-seed.sh - Batch create seeds from a JSON file
CONTRACT=0x878baad70577cf114a3c60fd01b5a036fd0c4bc8
RPC_URL=https://sepolia.base.org
# Read seeds from JSON file
cat seeds.json | jq -c '.[]' | while read seed; do
IPFS=$(echo $seed | jq -r '.ipfsHash')
TITLE=$(echo $seed | jq -r '.title')
DESC=$(echo $seed | jq -r '.description')
echo "Creating seed: $TITLE"
cast send $CONTRACT \
"submitSeed(string,string,string)" \
"$IPFS" "$TITLE" "$DESC" \
--rpc-url $RPC_URL \
--private-key $PRIVATE_KEY
sleep 2 # Rate limiting
doneUse Case: One-off manual seed creation, no code required.
Prerequisites:
- Wallet with CREATOR_ROLE (MetaMask, WalletConnect, etc.)
- Contract verified on Basescan
Step-by-Step:
-
Navigate to contract on Basescan:
- Testnet:
https://sepolia.basescan.org/address/0x878baad... - Mainnet:
https://basescan.org/address/0x878baad...
- Testnet:
-
Go to "Contract" tab → "Write Contract"
-
Click "Connect to Web3" and connect your wallet
-
Find the
submitSeedfunction -
Fill in the parameters:
_ipfsHash:QmX123..._title:My Artwork Title_description:Description of the artwork
-
Click "Write" and confirm transaction in your wallet
-
View transaction and get seed ID from logs
Decision Tree:
Do you want gasless UX for users?
├─ YES → Use Method 1 (Backend-Signed API)
│ Requires: Admin key, backend with CREATOR_ROLE
│
└─ NO → Do users have their own wallets?
├─ YES → Use Method 2 (Client-Signed API)
│ Requires: User has CREATOR_ROLE, wallet connected
│
└─ NO → Are you doing development/testing?
├─ YES → Use Method 3 (Hardhat Console)
│ Good for: Interactive testing, debugging
│
└─ NO → Do you need automation/scripting?
├─ YES → Use Method 4 (Cast/Foundry)
│ Good for: CI/CD, batch operations
│
└─ NO → Use Method 5 (Basescan UI)
Good for: One-off manual creation
Cause: Backend wallet doesn't have CREATOR_ROLE on the contract
Solution: Grant CREATOR_ROLE to backend wallet
npx hardhat console --network baseSepoliaconst TheSeeds = await ethers.getContractAt("TheSeeds", CONTRACT_ADDRESS);
await TheSeeds.addCreator(BACKEND_WALLET_ADDRESS);Cause: User's wallet doesn't have CREATOR_ROLE
Solution: Admin must grant CREATOR_ROLE to user's wallet
cast send CONTRACT_ADDRESS \
"addCreator(address)" \
USER_WALLET_ADDRESS \
--rpc-url RPC_URL \
--private-key ADMIN_PRIVATE_KEYCause: X-Admin-Key header doesn't match ADMIN_KEY in .env.local
Solution: Check your environment variable:
# .env.local
ADMIN_KEY=your-secret-admin-key # Must match headerCause: RELAYER_PRIVATE_KEY not set in .env.local
Solution: Add backend wallet private key:
# .env.local
RELAYER_PRIVATE_KEY=0x... # Backend wallet private keyCause: Insufficient gas, network issues, or contract paused
Solution:
- Check wallet has enough ETH for gas
- Verify network is correct (Base Sepolia vs Base Mainnet)
- Check if contract is paused:
cast call CONTRACT_ADDRESS "paused()(bool)" --rpc-url RPC_URLHere's a complete example showing how to create a seed with proper error handling:
import { useState } from 'react';
import { usePrivy } from '@privy-io/react-auth';
import { useWalletClient } from 'wagmi';
interface SeedFormData {
ipfsHash: string;
title: string;
description?: string;
}
export function CreateSeedFlow() {
const { getAccessToken, authenticated } = usePrivy();
const { data: walletClient } = useWalletClient();
const [mode, setMode] = useState<'backend' | 'client'>('client');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
async function createSeed(data: SeedFormData) {
setLoading(true);
setError(null);
setSuccess(null);
try {
const token = await getAccessToken();
if (mode === 'backend') {
// Backend-signed (gasless)
const response = await fetch('/api/seeds', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-Admin-Key': process.env.NEXT_PUBLIC_ADMIN_KEY || ''
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
setSuccess(`Seed #${result.data.seedId} created! TX: ${result.data.txHash}`);
} else {
setError(result.error);
}
} else {
// Client-signed
const response = await fetch('/api/seeds/prepare', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(data)
});
const result = await response.json();
if (!result.data.hasCreatorRole) {
setError('You need CREATOR_ROLE to create seeds. Contact an admin.');
return;
}
// Sign with user's wallet
const hash = await walletClient.sendTransaction({
to: result.data.transaction.to,
data: result.data.transaction.data,
});
setSuccess(`Seed created! Transaction: ${hash}`);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create seed');
} finally {
setLoading(false);
}
}
if (!authenticated) {
return <div>Please log in to create seeds</div>;
}
return (
<div>
<h2>Create Seed</h2>
{/* Mode selector */}
<div>
<label>
<input
type="radio"
checked={mode === 'client'}
onChange={() => setMode('client')}
/>
Sign with my wallet (I pay gas)
</label>
<label>
<input
type="radio"
checked={mode === 'backend'}
onChange={() => setMode('backend')}
/>
Gasless (requires admin key)
</label>
</div>
{/* Form */}
<form onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createSeed({
ipfsHash: formData.get('ipfsHash') as string,
title: formData.get('title') as string,
description: formData.get('description') as string || undefined,
});
}}>
<input
name="ipfsHash"
placeholder="IPFS Hash (QmX...)"
required
/>
<input
name="title"
placeholder="Seed Title"
required
/>
<textarea
name="description"
placeholder="Description (optional)"
/>
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Seed'}
</button>
</form>
{/* Status messages */}
{error && <div style={{ color: 'red' }}>Error: {error}</div>}
{success && <div style={{ color: 'green' }}>{success}</div>}
</div>
);
}For detailed setup instructions, API documentation, and deployment guides, see:
- Quick Start Deployment - 5-step quick start
- Full Deployment Guide - Complete deployment and role setup
- Seed Creation System - Architecture and integration
- Blessing System - Blessing mechanics
All blessing endpoints require Privy authentication via Bearer token in the Authorization header.
Endpoint: GET /
Description: Check API status and view available endpoints
Authentication: None required
cURL Example:
curl http://localhost:3000Response:
{
"name": "Abraham API",
"version": "1.0.0",
"status": "healthy",
"endpoints": {
"blessings": "/api/blessings",
"eligibility": "/api/blessings/eligibility",
"stats": "/api/blessings/stats"
}
}Endpoint: POST /api/seeds
Description: Create a new seed onchain with backend-signed transaction (gasless for user)
Authentication:
- Privy JWT token (via
Authorizationheader) - Admin key (via
X-Admin-Keyheader)
Request Body:
{
"ipfsHash": "QmX...", // Required: IPFS hash of artwork
"title": "Seed Title", // Required: Title of the seed
"description": "Description" // Optional: Seed description
}cURL Example:
curl -X POST http://localhost:3000/api/seeds \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_PRIVY_TOKEN" \
-H "X-Admin-Key: your-secret-admin-key" \
-d '{
"ipfsHash": "QmX123...",
"title": "My First Seed",
"description": "A beautiful artwork"
}'Success Response (200):
{
"success": true,
"data": {
"seedId": 0,
"txHash": "0xabc123...",
"blockExplorer": "https://sepolia.basescan.org/tx/0xabc123...",
"seed": {
"id": 0,
"creator": "0x...",
"ipfsHash": "QmX123...",
"title": "My First Seed",
"description": "A beautiful artwork",
"votes": 0,
"blessings": 0,
"createdAt": 1729785600,
"minted": false
}
}
}Error Responses:
// 401 - Invalid admin key
{ "success": false, "error": "Unauthorized - Invalid admin key" }
// 403 - Backend doesn't have CREATOR_ROLE
{ "success": false, "error": "Relayer does not have CREATOR_ROLE" }
// 503 - Backend not configured
{ "success": false, "error": "Backend blessing service not configured" }Endpoint: POST /api/seeds/prepare
Description: Prepare seed creation transaction for client-side signing
Authentication: Privy JWT token (via Authorization header)
Request Body:
{
"ipfsHash": "QmX...", // Required
"title": "Seed Title", // Required
"description": "Description" // Optional
}cURL Example:
curl -X POST http://localhost:3000/api/seeds/prepare \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_PRIVY_TOKEN" \
-d '{
"ipfsHash": "QmX123...",
"title": "My Seed",
"description": "Description"
}'Success Response (200):
{
"success": true,
"data": {
"transaction": {
"to": "0x878baad70577cf114a3c60fd01b5a036fd0c4bc8",
"data": "0x...",
"from": "0x...",
"chainId": 84532
},
"hasCreatorRole": true,
"userAddress": "0x...",
"instructions": {
"step1": "Send this transaction using your wallet",
"step2": "Wait for transaction confirmation",
"step3": "Your seed will be created on-chain",
"note": "You have CREATOR_ROLE and can create seeds"
}
}
}Usage Example (with wagmi):
import { useWalletClient } from 'wagmi';
async function createSeed(ipfsHash: string, title: string, description: string) {
// 1. Get transaction data from API
const response = await fetch('/api/seeds/prepare', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${await getAccessToken()}`
},
body: JSON.stringify({ ipfsHash, title, description })
});
const { data } = await response.json();
if (!data.hasCreatorRole) {
alert('You need CREATOR_ROLE to create seeds');
return;
}
// 2. Sign and send with user's wallet
const { data: walletClient } = useWalletClient();
const hash = await walletClient.sendTransaction({
to: data.transaction.to,
data: data.transaction.data,
});
console.log('Seed created:', hash);
}Endpoint: GET /api/seeds/:seedId
Description: Get details of a specific seed from the blockchain
Authentication: None required
cURL Example:
curl http://localhost:3000/api/seeds/0Success Response (200):
{
"success": true,
"data": {
"id": 0,
"creator": "0x...",
"ipfsHash": "QmX123...",
"title": "My First Seed",
"description": "A beautiful artwork",
"votes": 5,
"blessings": 10,
"createdAt": 1729785600,
"minted": false,
"mintedInRound": 0
}
}Endpoint: GET /api/seeds/count
Description: Get total number of seeds created
Authentication: None required
cURL Example:
curl http://localhost:3000/api/seeds/countSuccess Response (200):
{
"success": true,
"data": {
"count": 42
}
}Endpoint: GET /api/seeds/creator/:address/check
Description: Check if a wallet address has CREATOR_ROLE
Authentication: None required
cURL Example:
curl http://localhost:3000/api/seeds/creator/0x1234.../checkSuccess Response (200):
{
"success": true,
"data": {
"address": "0x1234...",
"hasCreatorRole": true
}
}Endpoint: GET /api/blessings/eligibility
Description: Check if the authenticated user is eligible to perform blessings
Authentication: Required (Privy JWT token)
cURL Example:
curl http://localhost:3000/api/blessings/eligibility \
-H "Authorization: Bearer YOUR_PRIVY_TOKEN"JavaScript/TypeScript Example:
import { usePrivy } from '@privy-io/react-auth';
const { getAccessToken } = usePrivy();
async function checkEligibility() {
const token = await getAccessToken();
const response = await fetch('http://localhost:3000/api/blessings/eligibility', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await response.json();
console.log(data);
}Success Response (200):
{
"success": true,
"data": {
"eligible": true,
"nftCount": 5,
"maxBlessings": 5,
"usedBlessings": 2,
"remainingBlessings": 3,
"periodEnd": "2025-10-25T00:00:00.000Z",
"reason": null
}
}Not Eligible Response (200):
{
"success": true,
"data": {
"eligible": false,
"nftCount": 0,
"maxBlessings": 0,
"usedBlessings": 0,
"remainingBlessings": 0,
"periodEnd": "2025-10-25T00:00:00.000Z",
"reason": "No NFTs owned"
}
}Endpoint: GET /api/blessings/stats
Description: Get detailed blessing statistics for the authenticated user
Authentication: Required (Privy JWT token)
cURL Example:
curl http://localhost:3000/api/blessings/stats \
-H "Authorization: Bearer YOUR_PRIVY_TOKEN"JavaScript/TypeScript Example:
import { usePrivy } from '@privy-io/react-auth';
const { getAccessToken } = usePrivy();
async function getBlessingStats() {
const token = await getAccessToken();
const response = await fetch('http://localhost:3000/api/blessings/stats', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await response.json();
console.log(data);
}Success Response (200):
{
"success": true,
"data": {
"nftCount": 5,
"maxBlessings": 5,
"usedBlessings": 2,
"remainingBlessings": 3,
"periodStart": "2025-10-24T00:00:00.000Z",
"periodEnd": "2025-10-25T00:00:00.000Z"
}
}Endpoint: GET /api/blessings/delegation-status
Description: Check if the authenticated user has approved the backend as their delegate for gasless blessings
Authentication: Required (Privy JWT token)
cURL Example:
curl http://localhost:3000/api/blessings/delegation-status \
-H "Authorization: Bearer YOUR_PRIVY_TOKEN"JavaScript/TypeScript Example:
import { usePrivy } from '@privy-io/react-auth';
const { getAccessToken } = usePrivy();
async function checkDelegationStatus() {
const token = await getAccessToken();
const response = await fetch('http://localhost:3000/api/blessings/delegation-status', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await response.json();
console.log(data);
}Success Response (200) - Delegate Approved:
{
"success": true,
"data": {
"userAddress": "0xUser...",
"backendAddress": "0xBackend...",
"isDelegateApproved": true,
"canUseGaslessBlessings": true,
"message": "You have approved gasless blessings. The backend can submit blessings on your behalf."
}
}Success Response (200) - Not Yet Approved:
{
"success": true,
"data": {
"userAddress": "0xUser...",
"backendAddress": "0xBackend...",
"isDelegateApproved": false,
"canUseGaslessBlessings": false,
"message": "You have not yet approved gasless blessings. Call POST /blessings/prepare-delegate to get started."
}
}Success Response (200) - Backend Not Configured:
{
"success": true,
"data": {
"userAddress": "0xUser...",
"backendAddress": null,
"isDelegateApproved": false,
"canUseGaslessBlessings": false,
"message": "Backend relayer not configured. Gasless blessings are not available."
}
}Endpoint: POST /api/blessings
Description: Perform a blessing on a target item (e.g., post, content, etc.)
Authentication: Required (Privy JWT token)
Request Body:
{
"targetId": "string" // Required: ID of the item being blessed
}cURL Example:
curl -X POST http://localhost:3000/api/blessings \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_PRIVY_TOKEN" \
-d '{"targetId": "post_123"}'JavaScript/TypeScript Example:
import { usePrivy } from '@privy-io/react-auth';
const { getAccessToken } = usePrivy();
async function performBlessing(targetId: string) {
const token = await getAccessToken();
const response = await fetch('http://localhost:3000/api/blessings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ targetId })
});
const data = await response.json();
if (data.success) {
console.log(`Blessed! ${data.data.remainingBlessings} blessings left`);
} else {
console.error(`Error: ${data.error}`);
}
}
// Usage
performBlessing('post_123');Success Response (200):
{
"success": true,
"data": {
"targetId": "post_123",
"remainingBlessings": 2,
"message": "Blessing performed successfully",
"blessing": {
"id": "blessing_1729785600000_abc123",
"walletAddress": "0x1234567890abcdef1234567890abcdef12345678",
"targetId": "post_123",
"timestamp": "2025-10-24T15:30:00.000Z",
"nftCount": 5
}
}
}Error Response - No Blessings Remaining (403):
{
"success": false,
"error": "All blessings used for this period",
"remainingBlessings": 0
}Error Response - Missing targetId (400):
{
"error": "targetId is required"
}Endpoint: GET /api/blessings/all
Description: Get all blessing records with optional filters and pagination
Authentication: None required (public endpoint)
Query Parameters:
walletAddress(optional) - Filter by wallet addresstargetId(optional) - Filter by target IDlimit(optional) - Number of results per page (default: 50)offset(optional) - Pagination offset (default: 0)sortOrder(optional) - "asc" or "desc" (default: "desc" - most recent first)
cURL Examples:
Get all blessings (default 50 most recent):
curl http://localhost:3000/api/blessings/allGet blessings for a specific wallet:
curl "http://localhost:3000/api/blessings/all?walletAddress=0x1234..."Get blessings for a specific target:
curl "http://localhost:3000/api/blessings/all?targetId=post_123"Get with pagination:
curl "http://localhost:3000/api/blessings/all?limit=10&offset=0"JavaScript/TypeScript Example:
async function getAllBlessings(options?: {
walletAddress?: string;
targetId?: string;
limit?: number;
offset?: number;
sortOrder?: "asc" | "desc";
}) {
const params = new URLSearchParams();
if (options?.walletAddress) params.append('walletAddress', options.walletAddress);
if (options?.targetId) params.append('targetId', options.targetId);
if (options?.limit) params.append('limit', options.limit.toString());
if (options?.offset) params.append('offset', options.offset.toString());
if (options?.sortOrder) params.append('sortOrder', options.sortOrder);
const response = await fetch(
`http://localhost:3000/api/blessings/all?${params.toString()}`
);
return await response.json();
}
// Usage examples
const allBlessings = await getAllBlessings();
const userBlessings = await getAllBlessings({ walletAddress: '0x1234...' });
const postBlessings = await getAllBlessings({ targetId: 'post_123' });Success Response (200):
{
"success": true,
"data": {
"blessings": [
{
"id": "blessing_1729785600000_abc123",
"walletAddress": "0x1234567890abcdef1234567890abcdef12345678",
"targetId": "post_123",
"timestamp": "2025-10-24T15:30:00.000Z",
"nftCount": 5
},
{
"id": "blessing_1729785500000_def456",
"walletAddress": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
"targetId": "post_456",
"timestamp": "2025-10-24T15:28:20.000Z",
"nftCount": 3
}
],
"total": 2,
"limit": 50,
"offset": 0
}
}Endpoint: GET /api/blessings/target/:targetId
Description: Get all blessings for a specific target/creation (e.g., post, artwork, etc.)
Authentication: None required (public endpoint)
cURL Example:
curl http://localhost:3000/api/blessings/target/post_123JavaScript/TypeScript Example:
async function getBlessingsForTarget(targetId: string) {
const response = await fetch(
`http://localhost:3000/api/blessings/target/${targetId}`
);
return await response.json();
}
// Usage
const result = await getBlessingsForTarget('post_123');
console.log(`${result.data.count} total blessings`);Success Response (200):
{
"success": true,
"data": {
"targetId": "post_123",
"blessings": [
{
"id": "blessing_1729785600000_abc123",
"walletAddress": "0x1234567890abcdef1234567890abcdef12345678",
"targetId": "post_123",
"timestamp": "2025-10-24T15:30:00.000Z",
"nftCount": 5
},
{
"id": "blessing_1729785500000_xyz789",
"walletAddress": "0x9876543210fedcba9876543210fedcba98765432",
"targetId": "post_123",
"timestamp": "2025-10-24T15:25:00.000Z",
"nftCount": 2
}
],
"count": 2
}
}Endpoint: GET /api/blessings/wallet/:walletAddress
Description: Get all blessings performed by a specific wallet address
Authentication: None required (public endpoint)
cURL Example:
curl http://localhost:3000/api/blessings/wallet/0x1234567890abcdef1234567890abcdef12345678JavaScript/TypeScript Example:
async function getBlessingsByWallet(walletAddress: string) {
const response = await fetch(
`http://localhost:3000/api/blessings/wallet/${walletAddress}`
);
return await response.json();
}
// Usage
const result = await getBlessingsByWallet('0x1234...');
console.log(`User has blessed ${result.data.count} items`);Success Response (200):
{
"success": true,
"data": {
"walletAddress": "0x1234567890abcdef1234567890abcdef12345678",
"blessings": [
{
"id": "blessing_1729785600000_abc123",
"walletAddress": "0x1234567890abcdef1234567890abcdef12345678",
"targetId": "post_123",
"timestamp": "2025-10-24T15:30:00.000Z",
"nftCount": 5
},
{
"id": "blessing_1729785400000_ghi012",
"walletAddress": "0x1234567890abcdef1234567890abcdef12345678",
"targetId": "post_789",
"timestamp": "2025-10-24T15:26:40.000Z",
"nftCount": 5
}
],
"count": 2
}
}Endpoint: GET /api/blessings/firstworks/snapshot
Description: Get the current FirstWorks NFT ownership snapshot data showing all holders and their NFTs
Authentication: None required (public endpoint)
cURL Example:
curl http://localhost:3000/api/blessings/firstworks/snapshotJavaScript/TypeScript Example:
async function getFirstWorksSnapshot() {
const response = await fetch('http://localhost:3000/api/blessings/firstworks/snapshot');
const data = await response.json();
console.log(`Total Holders: ${data.data.totalHolders}`);
console.log(`Total Supply: ${data.data.totalSupply}`);
console.log(`Snapshot taken at: ${data.data.timestamp}`);
return data;
}Success Response (200):
{
"success": true,
"data": {
"contractAddress": "0x8F814c7C75C5E9e0EDe0336F535604B1915C1985",
"contractName": "FirstWorks",
"totalSupply": 100,
"timestamp": "2025-10-24T12:00:00.000Z",
"blockNumber": 18500000,
"holders": [
{
"address": "0x1234567890abcdef1234567890abcdef12345678",
"balance": 5,
"tokenIds": [1, 2, 3, 4, 5]
},
{
"address": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
"balance": 3,
"tokenIds": [6, 7, 8]
}
],
"totalHolders": 2,
"holderIndex": {
"0x1234567890abcdef1234567890abcdef12345678": [1, 2, 3, 4, 5],
"0xabcdefabcdefabcdefabcdefabcdefabcdefabcd": [6, 7, 8]
}
}
}Error Response - No Snapshot (404):
{
"error": "No snapshot available",
"message": "Run 'npm run snapshot:generate' to create a snapshot"
}Use Cases:
- Check if a user owns any NFTs:
snapshot.data.holderIndex[walletAddress] - Display NFT holder leaderboard
- Show total collection statistics
- Verify snapshot timestamp and freshness
Endpoint: POST /api/blessings/firstworks/reload-snapshot
Description: Force reload the FirstWorks NFT ownership snapshot without restarting the server
Authentication: None (should add admin auth in production)
cURL Example:
curl -X POST http://localhost:3000/api/blessings/firstworks/reload-snapshotSuccess Response (200):
{
"success": true,
"message": "FirstWorks snapshot reloaded successfully"
}Endpoint: GET /api/blessings/firstworks/nfts/:address
Description: Get all FirstWorks NFTs owned by an address with complete metadata (images, attributes, etc.) for frontend display
Authentication: None required (public endpoint)
Parameters:
address(path parameter) - Ethereum wallet address
How it works:
- Fetches token IDs from the snapshot
- For each token, gets the tokenURI from the FirstWorks contract
- Fetches and parses metadata JSON from IPFS/HTTP
- Returns complete NFT data with images, names, attributes, etc.
cURL Example:
# Get NFTs for a specific address
curl http://localhost:3000/api/blessings/firstworks/nfts/0x826be0f079a18c7f318efbead5f90df70a7b2e29JavaScript/TypeScript Example:
async function getUserNFTs(address: string) {
const response = await fetch(
`http://localhost:3000/api/blessings/firstworks/nfts/${address}`
);
const data = await response.json();
if (data.success) {
console.log(`Found ${data.data.totalOwned} NFTs`);
// Display NFT images
data.data.nfts.forEach(nft => {
if (nft.metadata) {
console.log(`Token #${nft.tokenId}:`, nft.metadata.name);
console.log(`Image:`, nft.metadata.image);
}
});
}
return data;
}Success Response (200):
{
"success": true,
"data": {
"address": "0x826be0f079a18c7f318efbead5f90df70a7b2e29",
"nfts": [
{
"tokenId": 1,
"tokenURI": "ipfs://QmXxx.../1",
"metadata": {
"name": "FirstWork #1",
"description": "An incredible piece of art",
"image": "ipfs://QmYyyy.../1.png",
"attributes": [
{
"trait_type": "Artist",
"value": "Abraham"
},
{
"trait_type": "Year",
"value": "2024"
}
]
},
"metadataError": null
},
{
"tokenId": 42,
"tokenURI": "ipfs://QmZzz.../42",
"metadata": {
"name": "FirstWork #42",
"description": "Another masterpiece",
"image": "ipfs://QmWww.../42.png",
"attributes": [...]
},
"metadataError": null
}
],
"totalOwned": 2,
"contractAddress": "0x8F814c7C75C5E9e0EDe0336F535604B1915C1985",
"contractName": "Abraham's First Works"
}
}Success Response - No NFTs (200):
{
"success": true,
"data": {
"address": "0x1234567890abcdef1234567890abcdef12345678",
"nfts": [],
"totalOwned": 0
}
}Error Response - Invalid Address (400):
{
"success": false,
"error": "Invalid Ethereum address format"
}Error Response - No Snapshot (404):
{
"success": false,
"error": "No snapshot available",
"message": "Snapshot data is not yet available. Please try again later."
}Use Cases:
- NFT Gallery: Display user's FirstWorks collection with images and metadata
- Profile Page: Show owned NFTs on user profile
- Blessing Eligibility UI: Display which NFTs make user eligible for blessings
- Collection Browser: Build a collection explorer showing all FirstWorks with metadata
Frontend Example (React):
import { useEffect, useState } from 'react';
interface NFT {
tokenId: number;
metadata: {
name: string;
image: string;
description: string;
attributes: Array<{ trait_type: string; value: string }>;
};
}
function UserNFTGallery({ address }: { address: string }) {
const [nfts, setNfts] = useState<NFT[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadNFTs() {
const response = await fetch(
`https://abraham-api.vercel.app/api/blessings/firstworks/nfts/${address}`
);
const data = await response.json();
if (data.success) {
setNfts(data.data.nfts.filter(nft => nft.metadata)); // Only show NFTs with metadata
}
setLoading(false);
}
loadNFTs();
}, [address]);
if (loading) return <div>Loading NFTs...</div>;
if (nfts.length === 0) return <div>No FirstWorks NFTs found</div>;
return (
<div className="nft-gallery">
{nfts.map(nft => (
<div key={nft.tokenId} className="nft-card">
<img
src={nft.metadata.image.replace('ipfs://', 'https://ipfs.io/ipfs/')}
alt={nft.metadata.name}
/>
<h3>{nft.metadata.name}</h3>
<p>{nft.metadata.description}</p>
</div>
))}
</div>
);
}Note: This endpoint uses the snapshot data for token ownership and fetches live metadata from the contract and IPFS. Metadata fetching may take a few seconds for users with many NFTs.
{
"error": "Missing or invalid authorization header"
}{
"error": "Wallet address not found"
}{
"error": "Failed to perform blessing",
"details": "Error message here"
}Here's a complete React hook for managing blessings:
import { useState, useEffect, useCallback } from 'react';
import { usePrivy } from '@privy-io/react-auth';
const API_BASE_URL = 'http://localhost:3000';
interface BlessingStats {
nftCount: number;
maxBlessings: number;
usedBlessings: number;
remainingBlessings: number;
periodStart: string;
periodEnd: string;
}
export function useBlessings() {
const { getAccessToken, authenticated } = usePrivy();
const [stats, setStats] = useState<BlessingStats | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch blessing stats
const fetchStats = useCallback(async () => {
if (!authenticated) return;
setLoading(true);
setError(null);
try {
const token = await getAccessToken();
const response = await fetch(`${API_BASE_URL}/api/blessings/stats`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (data.success) {
setStats(data.data);
} else {
setError(data.error);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch stats');
} finally {
setLoading(false);
}
}, [authenticated, getAccessToken]);
// Perform a blessing
const bless = useCallback(async (targetId: string) => {
if (!authenticated) {
setError('Not authenticated');
return { success: false, error: 'Not authenticated' };
}
setLoading(true);
setError(null);
try {
const token = await getAccessToken();
const response = await fetch(`${API_BASE_URL}/api/blessings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ targetId })
});
const data = await response.json();
if (data.success) {
// Refresh stats after successful blessing
await fetchStats();
return { success: true, data: data.data };
} else {
setError(data.error);
return { success: false, error: data.error };
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to bless';
setError(errorMsg);
return { success: false, error: errorMsg };
} finally {
setLoading(false);
}
}, [authenticated, getAccessToken, fetchStats]);
// Load stats on mount
useEffect(() => {
fetchStats();
}, [fetchStats]);
return {
stats,
loading,
error,
bless,
refresh: fetchStats
};
}
// Usage in a component:
function BlessingButton({ targetId }: { targetId: string }) {
const { stats, bless, loading } = useBlessings();
const handleBless = async () => {
const result = await bless(targetId);
if (result.success) {
alert(`Blessed! ${result.data.remainingBlessings} remaining`);
} else {
alert(`Error: ${result.error}`);
}
};
const canBless = stats && stats.remainingBlessings > 0;
return (
<button
onClick={handleBless}
disabled={!canBless || loading}
>
{loading ? 'Blessing...' : `Bless (${stats?.remainingBlessings || 0} left)`}
</button>
);
}abraham-api/
├── lib/
│ ├── abi/ # Contract ABIs
│ └── snapshots/ # NFT snapshot utilities
├── src/
│ ├── middleware/ # Auth middleware
│ ├── routes/ # API routes
│ ├── services/ # Business logic
│ ├── index.ts # Hono app
│ └── server.ts # Server entry point
└── package.json
# Development mode with hot reload
npm run dev
# Production mode
npm start
# Generate NFT snapshot
npm run snapshot:generate
# Type checking
npm run typecheckWhen you make changes to the smart contracts, you need to recompile and extract the ABI:
# Compile contracts (automatically extracts ABI)
npm run compile
# Or manually extract ABI after compilation
npm run extract-abiImportant: The ABI is stored in lib/abi/TheSeeds.json and is tracked by git. This ensures the ABI is available in deployments (Vercel, etc.) since the artifacts/ folder is gitignored.
When to update the ABI:
- After modifying
contracts/TheSeeds.sol - After pulling contract changes from git
- Before deploying to production
The postcompile script automatically runs extract-abi after each compilation, so normally you just need to run npm run compile.
npm install -g vercel
vercelAdd your environment variables in the Vercel dashboard.
MIT