Delegation Framework Tutorial for Intuition (ERC-7710)
Build a production-ready Next.js Claim Feed app with 1-click, gasless staking using ERC-7702 Hybrid Smart Accounts and ERC-7710 delegation.
Welcome! In this tutorial, we're going to build a fully functional Claim Feed on the Intuition Protocol.
By the end, you'll have a production-ready Next.js application where users can connect their MetaMask wallet, publish new statements to the Intuition Ledger, browse a live feed of claims, and support or oppose them - all without a single MetaMask popup per action.
Prerequisites
Before we dive in, make sure you have:
- Node.js 18+
- MetaMask browser extension installed
- A secondary "Admin" wallet - You need the private key of a throwaway wallet that will pay gas on behalf of your users. Never use your main wallet for this.
- Basic React/TypeScript knowledge - Comfortable with hooks, state, and components
What We're Building
Here is exactly what our Claim Feed will do by the end of this tutorial:
Frontend Features
- Intuition Network Connection - Connect MetaMask and automatically switch to the Intuition Mainnet
- 1-Click Upgrade Panel - Let users deploy a Hybrid Smart Account and sign a scoped delegation in one flow
- HSA Budget Progress Bar - A live display showing how much 1-Click budget the user has remaining
- Claim Publishing Form - A form where users can write statements that get published to the Intuition Ledger
- Infinite Scroll Feed - A live, paginated feed of all claims on the protocol, with Support and Oppose buttons
- Delegation Revocation - Let users revoke their 1-Click permissions at any time
Backend Features
- Gas Relayer API - A secure Next.js API route that holds our Admin wallet's private key and executes delegated stakes on behalf of users
How This Will Work
In standard Web3 applications, every single on-chain action requires the user to manually confirm a MetaMask popup and pay gas fees. For high-frequency social protocols like Intuition, where users constantly interact with knowledge graphs by creating claims, supporting statements, or opposing triples, this constant friction causes severe user drop-off. Delegated execution solves this UX bottleneck by allowing users to delegate specific, restricted permissions to an automated agent or backend relayer.
To understand how this architecture operates, it is helpful to explore the core protocol building blocks that make seamless delegated execution possible.
Core Concepts
-
Externally Owned Account (EOA): The foundational layer of user identity. This is the standard wallet address managed directly by browser extensions like MetaMask. In traditional web3 applications, an EOA must sign every individual transaction directly on-chain, limiting automation and forcing users to approve every gas fee manually.
-
ERC-7702 (Hybrid Smart Accounts / HSA): A protocol upgrade introducing code execution capabilities directly to the user's existing EOA. An HSA upgrades the user's EOA into a smart account deterministically, giving it programmable account capabilities without forcing the user to transfer funds to a new address or deploy an entirely separate smart contract wallet. Because the HSA address matches the user's EOA address, all assets and identities remain unified.
-
ERC-7710 (Delegation Framework): A standardized protocol for creating, signing, and redeeming execution authority off-chain. Instead of giving a third party full access to a wallet, ERC-7710 allows the user to sign an off-chain EIP-712 payload that grants another address, known as the delegatee, permission to execute specific actions on their behalf.
-
Caveat Enforcers: Smart contracts that enforce strict cryptographic constraints on the delegated payload. In the context of Intuition, caveats ensure that the delegatee can only call the MultiVault contract, can only execute the deposit function, can only spend up to a pre-defined TRUST budget, and can only execute a limited number of calls before the session key expires.
-
Backend Relayer: A secure application server holding an Admin Wallet private key. When a user clicks Support or Oppose on the claim feed, the frontend forwards the signed delegation payload to the relayer. The relayer then broadcasts the transaction to the blockchain, paying the gas fees so the user experiences zero transaction popups.
-
DelegationManager Contract: The central verification engine on Intuition. It receives the delegation payload from the relayer, verifies the user's signature, passes the transaction parameters through every attached Caveat Enforcer, and only forwards the call to the destination contract if every rule condition passes.
-
Intuition MultiVault Contract: The core smart contract protocol that manages Atoms, Triples, and bonding curve vaults on Intuition. When the DelegationManager validates a delegated execution, it calls deposit on the MultiVault, crediting the resulting vault shares directly to the user's address.
To see how these concepts connect during setup and execution, let's explore the delegation flow and the user flow.
Delegation Flow
Here is how delegation permissions are derived, funded, signed, and stored:
-
HSA Address Derivation: The application derives the user's deterministic Hybrid Smart Account address directly from their connected MetaMask wallet.
-
HSA Funding: The user transfers their chosen budget (for example, 5 TRUST) into their HSA address. This balance acts as their 1-Click gas tank.
-
User Signs Delegation: The user signs an off-chain EIP-712 delegation message where:
- from: The user's HSA address
- to: Our Admin Wallet address (
ADMIN_DELEGATEE) - caveats: Restricted strictly to calling
deposit()on the Intuition MultiVault up to the user-defined TRUST budget.
-
Off-Chain Storage: The signed delegation payload is saved in local storage without incurring any transaction gas fees for the user.

User Flow
Once delegation is configured, here is how user interactions, relayer dispatch, and on-chain settlement execute seamlessly:
-
User Interaction: The user clicks Support or Oppose on the claim feed with zero MetaMask popups.
-
Relayer Dispatch: The frontend forwards the saved delegation payload to our backend
/api/stakeroute. -
Admin Wallet Execution: Our backend uses our Admin Wallet private key to submit
DelegationManager.redeemDelegations()on-chain, covering the transaction gas fee on behalf of the user. -
Caveat Verification and Settlement: The DelegationManager contract verifies the user's cryptographic signature, enforces all attached caveats, and executes the deposit on the MultiVault contract, crediting vault shares directly to the user's account.

Project Setup
Let's initialize our Next.js project and install everything we need.
npx create-next-app@latest intuition-claim-feed
cd intuition-claim-feed
npm install viem @metamask/smart-accounts-kit @0xintuition/sdk @0xintuition/graphql @0xintuition/protocolEnvironment Variables
Create a .env.local file in the root of your project:
# The private key of your backend Admin Wallet
# This wallet pays gas for delegated stakes. Use a throwaway wallet.
ADMIN_PRIVATE_KEY=0xYourAdminWalletPrivateKeyHere
# The public address derived from the key above. The frontend needs this to
# know who it's delegating to — if it doesn't match ADMIN_PRIVATE_KEY, the
# relayer won't be able to redeem the delegations you create.
NEXT_PUBLIC_ADMIN_ADDRESS=0xYourAdminWalletAddressHereSecurity Note: The
ADMIN_PRIVATE_KEYmust never be exposed to the frontend. We will only access it inside Next.js API routes which run exclusively on the server.
Centralized Constants
Create src/lib/constants.ts. Keeping all contract addresses in one place means we only have to update them once if they ever change.
// src/lib/constants.ts
import { type Address } from 'viem';
// The Intuition Protocol MultiVault on Mainnet
export const MULTIVAULT: Address = '0x6E35cF57A41fA15eA0EaE9C33e751b01A784Fe7e';
// The MetaMask Delegation Manager on Intuition Mainnet
export const DELEGATION_MANAGER: Address = '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3';
// The function signature for staking in the MultiVault
export const DEPOSIT_SIG = 'deposit(address,bytes32,uint256,uint256)';
// Byte offsets for pinning specific arguments inside the deposit calldata
export const DEPOSIT_OFFSET = {
receiver: 4, // First argument after the 4-byte function selector
};Wallet Connection
Before we can do anything onchain, we need to connect to the user's MetaMask wallet and make sure they are on the correct network.
1. Define the Chain
Create src/lib/chains.ts. This tells viem everything it needs to know about the Intuition network.
// src/lib/chains.ts
import { defineChain } from 'viem';
export const intuitionMainnet = defineChain({
id: 1155,
name: 'Intuition',
nativeCurrency: { decimals: 18, name: 'Intuition', symbol: 'TRUST' },
rpcUrls: {
default: { http: ['https://rpc.intuition.systems/http'] },
},
blockExplorers: {
default: { name: 'Blockscout', url: 'https://explorer.intuition.systems' },
},
});2. The Wallet Context
Create src/lib/WalletContext.tsx. This context does three important things:
- It provides a
viemWalletClient(for sending transactions) andPublicClient(for reading from the chain) to every component in the app. - It handles the MetaMask connection flow via
window.ethereum. - It includes an
ensureChainhelper that switches the user to the Intuition network before any transaction, so we never get wrong-network reverts.
View src/lib/WalletContext.tsx
// src/lib/WalletContext.tsx
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { createWalletClient, custom, createPublicClient, http, WalletClient, PublicClient, Address } from 'viem';
import { intuitionMainnet } from './chains';
interface WalletContextType {
address: Address | null;
walletClient: WalletClient | null;
publicClient: PublicClient;
connect: () => Promise<void>;
disconnect: () => void;
ensureChain: () => Promise<void>;
}
// We create one shared PublicClient for the whole app.
// This is used for read-only operations like fetching balances or simulating transactions.
const publicClient = createPublicClient({
chain: intuitionMainnet,
transport: http(),
}) as PublicClient;
const WalletContext = createContext<WalletContextType | null>(null);
// This helper switches the user to Intuition Mainnet.
// If the chain hasn't been added to MetaMask yet (error 4902), it adds it first.
async function switchToIntuition(client: WalletClient) {
try {
await client.switchChain({ id: intuitionMainnet.id });
} catch (err: any) {
const code = err?.code ?? err?.cause?.code;
if (code === 4902 || /Unrecognized chain/i.test(err?.message ?? '')) {
await client.addChain({ chain: intuitionMainnet });
await client.switchChain({ id: intuitionMainnet.id });
} else {
throw err;
}
}
}
export function WalletProvider({ children }: { children: ReactNode }) {
const [address, setAddress] = useState<Address | null>(null);
const [walletClient, setWalletClient] = useState<WalletClient | null>(null);
const connect = async () => {
if (typeof window === 'undefined' || !(window as any).ethereum) {
alert('Please install MetaMask!');
return;
}
try {
const client = createWalletClient({
chain: intuitionMainnet,
transport: custom((window as any).ethereum),
});
const [addr] = await client.requestAddresses();
// We create a new client bound to the user's address.
// This prevents "Could not find an Account" errors in viem when signing.
const boundClient = createWalletClient({
account: addr,
chain: intuitionMainnet,
transport: custom((window as any).ethereum),
});
await switchToIntuition(client);
setAddress(addr);
setWalletClient(boundClient as WalletClient);
} catch (e) {
console.error(e);
}
};
// Checks the chain right before any transaction - in case the user switched networks manually
const ensureChain = async () => {
if (!walletClient) throw new Error('Wallet not connected');
await switchToIntuition(walletClient);
};
const disconnect = () => {
setAddress(null);
setWalletClient(null);
};
// Keep the app in sync if the user changes accounts in MetaMask
useEffect(() => {
if (typeof window === 'undefined' || !(window as any).ethereum) return;
const handleAccountsChanged = (accounts: string[]) => {
if (accounts.length > 0) setAddress(accounts[0] as Address);
else disconnect();
};
(window as any).ethereum.on('accountsChanged', handleAccountsChanged);
return () => (window as any).ethereum.removeListener('accountsChanged', handleAccountsChanged);
}, []);
return (
<WalletContext.Provider value={{ address, walletClient, publicClient, connect, disconnect, ensureChain }}>
{children}
</WalletContext.Provider>
);
}
export const useWallet = () => {
const context = useContext(WalletContext);
if (!context) throw new Error('useWallet must be used within WalletProvider');
return context;
};3. Wrap the App in the Provider
Open src/app/layout.tsx and wrap the app in <WalletProvider> so every component has access to the wallet context.
// src/app/layout.tsx
import './globals.css';
import { WalletProvider } from '@/lib/WalletContext';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-black text-white min-h-screen font-mono">
<WalletProvider>
{children}
</WalletProvider>
</body>
</html>
);
}4. The Connect Button
Create src/components/ConnectButton.tsx. This is a simple component that shows a truncated wallet address when connected, or a Connect button when not.
// src/components/ConnectButton.tsx
'use client';
import { useWallet } from '@/lib/WalletContext';
export function ConnectButton() {
const { address, connect, disconnect } = useWallet();
if (address) {
return (
<button
onClick={disconnect}
className="px-4 py-2 border border-white/20 text-white/70 font-mono text-sm hover:border-white hover:text-white transition-all"
>
{address.slice(0, 6)}...{address.slice(-4)}
</button>
);
}
return (
<button
onClick={connect}
className="px-4 py-2 bg-white text-black font-bold uppercase tracking-widest text-sm hover:bg-white/90 transition-all"
>
Connect Wallet
</button>
);
}The Upgrade Account Section
This is the core of the tutorial. We will build both the UI and the delegation logic together.
Before writing code, let's understand what happens under the hood when a user clicks "Enable 1-Click Staking":
- Initialize the HSA - We calculate the user's deterministic Hybrid Smart Account address from their wallet address. The HSA does not need to be deployed yet.
- Deploy the HSA - If it hasn't been deployed on-chain before, we deploy it. This is a one-time step.
- Fund the HSA - We transfer the user's chosen TRUST budget from their main wallet to the HSA. This becomes the "gas tank" for all future 1-Click actions.
- Approve the MultiVault - We grant the MultiVault permission to move funds from the HSA's behalf.
- Create and Sign the Delegation - We build the scoped delegation object with all its Caveat Enforcers and ask the user to sign it with MetaMask.
- Save the Delegation - We save the signed delegation to
localStorageso the feed can use it for future 1-Click actions without asking the user to sign again.
The Upgrade Account UI
Create src/components/UpgradeAccount.tsx. This component renders:
- An "Enable 1-Click Staking" flow with a budget input when no delegation exists
- A live budget progress bar and a "Disable" button when a delegation is active
// src/components/UpgradeAccount.tsx
'use client';
import { useState } from 'react';
import { useWallet } from '@/lib/WalletContext';
import { useAdminDelegation, ADMIN_DELEGATEE } from '@/hooks/useAdminDelegation';
import { formatEther } from 'viem';
export function UpgradeAccount() {
const { address } = useWallet();
const { smartAccount, delegation, isDeploying, error, setupDelegation, revokeDelegation, hsaBalance, initialBudget } = useAdminDelegation();
const [budget, setBudget] = useState('5');
if (!address) return null;
return (
<div className="mb-8 p-6 bg-white/5 border border-white/10 rounded-lg">
<div className="flex justify-between items-center flex-wrap gap-4">
<div>
<h3 className="text-lg font-bold text-white mb-2 uppercase tracking-wide">1-Click Staking</h3>
<p className="text-sm text-white/60 mb-1">
Deploy your Hybrid Smart Account and delegate to our secure Admin Wallet to enable seamless 1-click staking.
</p>
<p className="text-xs text-white/40">
Admin Delegatee: {ADMIN_DELEGATEE.slice(0, 6)}...{ADMIN_DELEGATEE.slice(-4)}
</p>
</div>
<div className="flex gap-4 items-center">
{delegation ? (
<button
onClick={revokeDelegation}
disabled={isDeploying}
className="px-4 py-2 border border-red-500/50 text-red-400 font-bold uppercase tracking-wider text-sm hover:bg-red-500/10 disabled:opacity-50 transition-colors rounded"
>
{isDeploying ? 'Revoking...' : 'Disable 1-Click (On-Chain)'}
</button>
) : (
<div className="flex gap-2 items-center">
<input
type="number"
value={budget}
onChange={(e) => setBudget(e.target.value)}
placeholder="Budget (TRUST)"
className="px-3 py-2 bg-black border border-white/20 text-white rounded text-sm w-32 outline-none focus:border-white/50"
min="0"
step="any"
/>
<span className="text-white/60 text-xs mr-2">TRUST</span>
<button
onClick={() => setupDelegation(budget, 100)}
disabled={isDeploying || !smartAccount || Number(budget) <= 0}
className="px-4 py-2 bg-white text-black font-bold uppercase tracking-wider text-sm hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded"
>
{isDeploying ? 'Setting up...' : 'Enable 1-Click Staking'}
</button>
</div>
)}
</div>
</div>
{delegation && (
<div className="mt-4 p-4 bg-green-500/10 border border-green-500/20 text-green-400 text-sm rounded">
<div className="mb-2 font-bold">Successfully configured! Your 1-Click Staking is active.</div>
{hsaBalance !== null && Number(initialBudget) > 0 && (
<div className="mt-4">
<div className="flex justify-between text-xs mb-1 text-green-300">
<span>HSA Budget Remaining</span>
<span>{Number(formatEther(hsaBalance)).toFixed(3)} TRUST</span>
</div>
<div className="w-full bg-black/50 h-2 rounded-full overflow-hidden">
<div
className="bg-green-500 h-full transition-all duration-500"
style={{ width: `${Math.min(100, Math.max(0, (Number(formatEther(hsaBalance)) / Number(initialBudget)) * 100))}%` }}
></div>
</div>
</div>
)}
</div>
)}
{error && (
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-sm rounded break-words">
Error: {error}
</div>
)}
</div>
);
}The Delegation Hook
Now create src/hooks/useAdminDelegation.ts. This is where all the logic behind the button lives. We extract it into a custom hook so the UI component above stays clean and focused on rendering.
Why do we use toFunctionSelector?
When we attach the AllowedMethods caveat, the blockchain requires the exact 4-byte EVM function selector - not a human-readable string. The selector for deposit(address,bytes32,uint256,uint256) is 0xcef6d209. If you pass the raw string instead, the Delegation Manager will silently reject the execution. We use viem's toFunctionSelector to generate this correctly.
View src/hooks/useAdminDelegation.ts
// src/hooks/useAdminDelegation.ts
import { useState, useEffect } from 'react';
import { useWallet } from '@/lib/WalletContext';
import {
Implementation,
toMetaMaskSmartAccount,
createDelegation,
ScopeType,
CaveatType,
MetaMaskSmartAccount
} from '@metamask/smart-accounts-kit';
import { encodeAbiParameters, encodeFunctionData, parseEther, type Address, createWalletClient, custom, toFunctionSelector } from 'viem';
import { MULTIVAULT, DEPOSIT_SIG, DEPOSIT_OFFSET, multiVaultAbi, ApprovalType } from '@/lib/constants';
import { intuitionMainnet } from '@/lib/chains';
export const ADMIN_DELEGATEE: Address = '0xYourAdminWalletPublicAddress';
const getStorageKey = (addr: string) => `intuition_admin_delegation_${addr.toLowerCase()}`;
const getBudgetStorageKey = (addr: string) => `intuition_admin_budget_${addr.toLowerCase()}`;
export function useAdminDelegation() {
const { walletClient, publicClient, address, ensureChain } = useWallet();
const [smartAccount, setSmartAccount] = useState<MetaMaskSmartAccount | null>(null);
const [isDeploying, setIsDeploying] = useState(false);
const [delegation, setDelegation] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const [hsaBalance, setHsaBalance] = useState<bigint | null>(null);
const [initialBudget, setInitialBudget] = useState<string>('0');
// On mount, load any existing delegation from localStorage
useEffect(() => {
if (!address) { setDelegation(null); return; }
const saved = localStorage.getItem(getStorageKey(address));
if (saved) {
try {
setDelegation(JSON.parse(saved, (key, value) =>
typeof value === 'string' && /^\d+n$/.test(value) ? BigInt(value.slice(0, -1)) : value
));
const savedBudget = localStorage.getItem(getBudgetStorageKey(address));
setInitialBudget(savedBudget ?? '1');
} catch (e) {
console.error('Failed to parse saved delegation', e);
}
} else {
setDelegation(null);
setInitialBudget('0');
}
}, [address]);
// Poll the HSA's live balance every 5 seconds to power the progress bar
useEffect(() => {
let interval: NodeJS.Timeout;
if (delegation && smartAccount && publicClient) {
const fetchBalance = async () => {
try {
const bal = await publicClient.getBalance({ address: smartAccount.address });
setHsaBalance(bal);
} catch (e) {}
};
fetchBalance();
interval = setInterval(fetchBalance, 5000);
} else {
setHsaBalance(null);
}
return () => clearInterval(interval);
}, [delegation, smartAccount, publicClient]);
// Initialize the HSA instance on wallet connect (does not deploy it yet)
useEffect(() => {
async function init() {
if (!address || !walletClient || !publicClient) return;
try {
const patchedClient = createWalletClient({
account: address,
chain: intuitionMainnet,
transport: custom((window as any).ethereum),
});
const sa = await toMetaMaskSmartAccount({
client: publicClient,
implementation: Implementation.Hybrid,
deployParams: [address, [], [], []],
deploySalt: '0x',
signer: { walletClient: patchedClient },
});
setSmartAccount(sa);
} catch (e) {
console.error('Failed to initialize Hybrid Smart Account:', e);
}
}
init();
}, [address, walletClient, publicClient]);
const setupDelegation = async (budgetTrust: string = '5', maxCalls: number = 100) => {
if (!smartAccount || !address || !walletClient || !publicClient) {
setError('Wallet not fully connected.');
return;
}
try {
setIsDeploying(true);
setError(null);
await ensureChain();
// Step 1: Deploy the HSA if it hasn't been deployed yet
const isDeployed = await smartAccount.isDeployed();
if (!isDeployed) {
console.log('Deploying HSA...');
const { factory, factoryData } = await smartAccount.getFactoryArgs();
if (factory && factoryData) {
const hash = await walletClient.sendTransaction({ account: address, to: factory, data: factoryData });
await publicClient.waitForTransactionReceipt({ hash });
}
}
// Step 2: Fund the HSA with the user's chosen TRUST budget
const saBal = await publicClient.getBalance({ address: smartAccount.address });
const budgetWei = parseEther(budgetTrust);
if (saBal < budgetWei) {
console.log(`Funding HSA with ${budgetTrust} TRUST...`);
const hash = await walletClient.sendTransaction({
account: address,
to: smartAccount.address,
value: budgetWei - saBal,
});
await publicClient.waitForTransactionReceipt({ hash });
}
// Step 3: Approve the MultiVault to operate with the HSA
console.log('Approving MultiVault...');
const approveHash = await walletClient.sendTransaction({
account: address,
to: MULTIVAULT,
data: encodeFunctionData({
abi: multiVaultAbi,
functionName: 'approve',
args: [smartAccount.address, ApprovalType.DEPOSIT],
}),
});
await publicClient.waitForTransactionReceipt({ hash: approveHash });
// Step 4: Build the delegation with all Caveat Enforcers
const expiry = Math.floor(Date.now() / 1000) + 30 * 86400; // 30 days
const newDelegation = createDelegation({
from: smartAccount.address,
to: ADMIN_DELEGATEE,
environment: smartAccount.environment,
scope: {
type: ScopeType.NativeTokenTransferAmount,
maxAmount: budgetWei,
allowedCalldata: [
// Pin the `receiver` argument to the user's address.
// This ensures the Admin can never stake to a different wallet.
{
startIndex: DEPOSIT_OFFSET.receiver,
value: encodeAbiParameters([{ type: 'address' }], [address]),
},
],
},
caveats: [
{ type: CaveatType.AllowedTargets, targets: [MULTIVAULT] },
{ type: CaveatType.AllowedMethods, selectors: [toFunctionSelector(DEPOSIT_SIG)] },
{ type: CaveatType.LimitedCalls, limit: maxCalls },
{ type: CaveatType.Timestamp, afterThreshold: 0, beforeThreshold: expiry },
],
});
// Step 5: Ask the user to sign the delegation
console.log('Signing Delegation...');
const signature = await smartAccount.signDelegation({ delegation: newDelegation });
const signedDelegation = { ...newDelegation, signature };
// Step 6: Save the signed delegation to localStorage
setDelegation(signedDelegation);
localStorage.setItem(getStorageKey(address), JSON.stringify(signedDelegation, (key, value) =>
typeof value === 'bigint' ? value.toString() + 'n' : value
));
localStorage.setItem(getBudgetStorageKey(address), budgetTrust);
setInitialBudget(budgetTrust);
console.log('Setup complete!');
} catch (e: any) {
console.error(e);
setError(e.shortMessage ?? e.message ?? 'An error occurred during setup');
} finally {
setIsDeploying(false);
}
};
const clearDelegation = () => {
setDelegation(null);
setHsaBalance(null);
setInitialBudget('0');
if (address) {
localStorage.removeItem(getStorageKey(address));
localStorage.removeItem(getBudgetStorageKey(address));
}
};
const revokeDelegation = async () => {
if (!smartAccount || !walletClient || !publicClient || !address) return;
try {
setIsDeploying(true);
setError(null);
await ensureChain();
// Revoking means removing the MultiVault's approval from the HSA
console.log('Revoking MultiVault approval...');
const hash = await walletClient.sendTransaction({
account: address,
to: MULTIVAULT,
data: encodeFunctionData({
abi: multiVaultAbi,
functionName: 'approve',
args: [smartAccount.address, ApprovalType.NONE],
}),
});
await publicClient.waitForTransactionReceipt({ hash });
clearDelegation();
console.log('Delegation successfully revoked on-chain.');
} catch (e: any) {
console.error(e);
setError(e.shortMessage ?? e.message ?? 'Failed to revoke delegation');
} finally {
setIsDeploying(false);
}
};
return {
smartAccount,
delegation,
isDeploying,
error,
setupDelegation,
clearDelegation,
revokeDelegation,
hsaBalance,
initialBudget,
};
}Publishing Claims
Now let's build the form that lets users publish new statements to the Intuition Ledger. Since this is a heavier, less frequent action, we keep a standard MetaMask signature for it.
What is a Triple?
On Intuition, every statement is structured as a Triple: a Subject, a Predicate, and an Object - three pieces of information linked together. Before you can create a Triple, each of those three pieces must exist as an Atom on the ledger. The @0xintuition/sdk abstracts this away - calling createAtomFromString will automatically check if the Atom already exists and only create it if it doesn't.
Create src/components/CreateClaimForm.tsx:
// src/components/CreateClaimForm.tsx
'use client';
import { useState } from 'react';
import { useWallet } from '@/lib/WalletContext';
import { createAtomFromString, createTripleStatement } from '@0xintuition/sdk';
import { parseAbi } from 'viem';
import { MULTIVAULT } from '@/lib/constants';
// We read the triple cost live from the contract so we never underpay
const costAbi = parseAbi(['function getTripleCost() view returns (uint256)']);
export function CreateClaimForm() {
const [claimText, setClaimText] = useState('');
const { address, walletClient, publicClient, ensureChain } = useWallet();
const [isPending, setIsPending] = useState(false);
const subjectUri = `caip10:eip155:1:${address}`; // The user's own identity Atom
const predicateUri = `claims`;
const objectUri = claimText; // The actual statement
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!address || !claimText || !walletClient || !publicClient) return;
setIsPending(true);
try {
// Guard against the user being on the wrong network before submitting
await ensureChain();
const patchedWalletClient = { ...walletClient, account: address };
const config = { address: MULTIVAULT, walletClient: patchedWalletClient as any, publicClient };
// Step 1: Create the three Atoms (Subject, Predicate, Object)
const subjectAtom = await createAtomFromString(config, subjectUri);
const predicateAtom = await createAtomFromString(config, predicateUri);
const objectAtom = await createAtomFromString(config, objectUri);
// Step 2: Read the current Triple creation cost from the contract
const tripleCost = await publicClient.readContract({
address: MULTIVAULT,
abi: costAbi,
functionName: 'getTripleCost',
});
// Step 3: Link the three Atoms together into a Triple
await createTripleStatement(config, {
args: [
[subjectAtom.state.termId],
[predicateAtom.state.termId],
[objectAtom.state.termId],
[tripleCost],
],
value: tripleCost,
});
setClaimText('');
alert('Success! Your claim has been created.');
} catch (e: any) {
console.error(e);
alert('Error creating claim: ' + e.message);
}
setIsPending(false);
};
if (!address) return null;
return (
<form onSubmit={handleCreate} className="bg-[#111] p-8 rounded-none border border-white/10 mb-8 relative group">
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-white/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
<h2 className="text-xl font-bold text-white mb-6 uppercase tracking-widest">Publish</h2>
<div className="mb-6">
<textarea
value={claimText}
onChange={(e) => setClaimText(e.target.value)}
className="w-full px-4 py-4 bg-black/50 text-white border border-white/20 focus:border-white focus:ring-1 focus:ring-white transition-all outline-none resize-none font-mono text-sm placeholder:text-white/30"
placeholder="ENTER STATEMENT..."
rows={3}
/>
</div>
<button
type="submit"
disabled={isPending || !claimText}
className="w-full bg-white text-black font-bold uppercase tracking-widest py-4 transition-all disabled:opacity-30 hover:bg-gray-200"
>
{isPending ? 'Processing...' : 'Submit to Ledger'}
</button>
</form>
);
}The Claim Feed UI
Now let's build the actual feed that displays all claims from the Intuition Protocol and lets users interact with them.
What is the Intuition GraphQL API?
Intuition provides a GraphQL API at https://mainnet.intuition.sh/v1/graphql that indexes all Atoms and Triples. We use the @0xintuition/graphql SDK which generates type-safe React Query hooks from this API. In our case, useInfiniteGetTriplesQuery gives us a paginated list of all Triples, sorted by newest first.
Create src/components/ClaimFeed.tsx. We will start with just the feed UI with placeholder handlers for Support and Oppose - then we will wire up the real delegation logic in the next section.
// src/components/ClaimFeed.tsx
'use client';
import { useWallet } from '@/lib/WalletContext';
import { useInfiniteGetTriplesQuery } from '@0xintuition/graphql';
import { formatUnits } from 'viem';
import { useState, useRef, useCallback } from 'react';
function ClaimItem({ claim, refetch }: { claim: any; refetch: () => void }) {
const { address } = useWallet();
const [isPending, setIsPending] = useState(false);
const [optimisticSupport, setOptimisticSupport] = useState<bigint | null>(null);
const [optimisticOppose, setOptimisticOppose] = useState<bigint | null>(null);
const supportShares = optimisticSupport !== null
? optimisticSupport
: BigInt(claim.term?.vaults?.[0]?.total_shares || '0');
const opposeShares = optimisticOppose !== null
? optimisticOppose
: BigInt(claim.counter_term?.vaults?.[0]?.total_shares || '0');
const handleSupport = async () => {
// We will wire this up in the next section
console.log('Support clicked for:', claim.term_id);
};
const handleOppose = async () => {
// We will wire this up in the next section
console.log('Oppose clicked for:', claim.counter_term_id);
};
const creatorAddress = claim.creator?.id || '0x0000000000000000000000000000000000000000';
return (
<div className="border border-white/10 p-5 bg-[#0a0a0a] mb-6 transition-all hover:bg-[#111] flex space-x-4">
<div className="shrink-0">
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center font-mono text-sm text-white/50">
{creatorAddress.slice(2, 4).toUpperCase()}
</div>
</div>
<div className="flex-1">
<div className="flex items-center justify-between text-sm mb-1">
<span className="font-bold text-white font-mono">
{creatorAddress.slice(0, 6)}...{creatorAddress.slice(-4)}
</span>
<span className="text-white/30 text-xs font-mono tracking-wider">
{new Date(claim.created_at).toLocaleDateString()}
</span>
</div>
<div className="text-white/90 text-base leading-relaxed mb-4">
<span className="font-semibold text-white">{claim.subject?.label || 'UNKNOWN'}</span>
<span className="text-white/50 mx-1">{claim.predicate?.label || 'claims'}</span>
<span className="font-medium text-white">{claim.object?.label || 'No description'}</span>
</div>
<div className="flex items-center space-x-6 text-sm text-white/50 font-mono">
<button
onClick={handleSupport}
disabled={isPending}
className="flex items-center space-x-2 hover:text-white transition-colors disabled:opacity-50 group"
>
<span className="group-hover:bg-white group-hover:text-black border border-white/20 px-2 py-0.5 rounded-full transition-all">
SUPPORT
</span>
<span className={optimisticSupport !== null ? 'text-green-400 font-bold' : ''}>
{Number(formatUnits(supportShares, 18)).toFixed(4)}
</span>
</button>
<button
onClick={handleOppose}
disabled={isPending}
className="flex items-center space-x-2 hover:text-white transition-colors disabled:opacity-50 group"
>
<span className="group-hover:bg-white group-hover:text-black border border-white/20 px-2 py-0.5 rounded-full transition-all">
OPPOSE
</span>
<span className={optimisticOppose !== null ? 'text-red-400 font-bold' : ''}>
{Number(formatUnits(opposeShares, 18)).toFixed(4)}
</span>
</button>
</div>
</div>
</div>
);
}
export function ClaimFeed() {
const { data, isLoading, error, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteGetTriplesQuery(
{ limit: 10, orderBy: [{ created_at: 'desc' }] },
{
initialPageParam: { offset: 0 },
getNextPageParam: (lastPage, allPages) => {
if (lastPage.triples.length < 10) return undefined;
return { offset: allPages.length * 10 };
},
}
);
// Infinite scroll using IntersectionObserver
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useCallback((node: HTMLDivElement | null) => {
if (isLoading || isFetchingNextPage) return;
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage) fetchNextPage();
});
if (node) observerRef.current.observe(node);
}, [isLoading, isFetchingNextPage, hasNextPage, fetchNextPage]);
if (isLoading && !data) return <div className="text-white/50 font-mono text-sm animate-pulse">Loading feed...</div>;
if (error) return <div className="text-red-500 font-mono">ERROR: {error.message}</div>;
if (!data?.pages[0]?.triples?.length) return <div className="text-white/50 text-center py-8 font-mono text-sm uppercase">No claims found. Be the first.</div>;
return (
<div className="space-y-4">
{data.pages.map((page, i) => (
<div key={i}>
{page.triples.map((claim: any) => (
<ClaimItem key={claim.term_id} claim={claim} refetch={refetch} />
))}
</div>
))}
<div ref={loadMoreRef} className="py-4 text-center">
{isFetchingNextPage && <div className="text-white/50 font-mono text-xs uppercase tracking-widest animate-pulse">Loading more...</div>}
{!hasNextPage && data.pages.length > 0 && <div className="text-white/30 font-mono text-xs uppercase tracking-widest">End of feed</div>}
</div>
</div>
);
}At this point, your feed will load and display all claims correctly. The Support and Oppose buttons will just log to the console. In the next section, we wire them up to the actual delegation.
Integrating Delegation Redemption
Now we complete the loop. We will update the handleSupport and handleOppose functions in ClaimFeed.tsx to check localStorage for a saved delegation. If one exists, we route the stake through our backend API. If not, we fall back to a standard MetaMask popup.
The Backend Relayer
First, we need to build the API route. Create src/app/api/stake/route.ts.
This runs on the server and is where our Admin Wallet lives. When the frontend calls it with a delegation and a claim's term ID, it:
- Revives the
BigIntvalues that were serialized to strings in JSON - Previews the deposit to calculate the minimum acceptable shares (slippage protection)
- Encodes the
depositcall for the Intuition MultiVault - Wraps everything in a
redeemDelegationscall to the Delegation Manager - Dry-runs the transaction to catch revert reasons before broadcasting
- Broadcasts the transaction and returns the hash
// src/app/api/stake/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createPublicClient, createWalletClient, http, encodeFunctionData } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { intuitionMainnet } from '@/lib/chains';
import { MULTIVAULT, DELEGATION_MANAGER, multiVaultAbi } from '@/lib/constants';
import { DelegationManager } from '@metamask/smart-accounts-kit/contracts';
import { createExecution, ExecutionMode } from '@metamask/smart-accounts-kit';
export async function POST(req: NextRequest) {
try {
const rawBody = await req.text();
// BigInt values in the delegation were serialized as strings ending in "n".
// We revive them back to BigInts before using them.
const { delegation, termId, curveId, assets, userAddress } = JSON.parse(rawBody, (key, value) =>
typeof value === 'string' && /^\d+n$/.test(value) ? BigInt(value.slice(0, -1)) : value
);
if (!delegation || !termId || curveId === undefined || !assets || !userAddress) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
}
const adminPrivateKey = process.env.ADMIN_PRIVATE_KEY;
if (!adminPrivateKey) {
return NextResponse.json({ error: 'Admin wallet not configured' }, { status: 500 });
}
const adminAccount = privateKeyToAccount(adminPrivateKey as `0x${string}`);
const publicClient = createPublicClient({ chain: intuitionMainnet, transport: http() });
const walletClient = createWalletClient({ account: adminAccount, chain: intuitionMainnet, transport: http() });
// Step 1: Preview the deposit to calculate minimum acceptable shares (1% slippage)
const [shares] = await publicClient.readContract({
address: MULTIVAULT,
abi: multiVaultAbi,
functionName: 'previewDeposit',
args: [termId, BigInt(curveId), BigInt(assets)],
});
const minShares = (shares * 99n) / 100n;
// Step 2: Encode the MultiVault deposit call
const callData = encodeFunctionData({
abi: multiVaultAbi,
functionName: 'deposit',
// IMPORTANT: The receiver must match the address pinned in the delegation's caveats
args: [userAddress, termId, BigInt(curveId), minShares],
});
// Step 3: Encode the DelegationManager redeemDelegations call
const target = DELEGATION_MANAGER;
const data = DelegationManager.encode.redeemDelegations({
delegations: [[delegation]],
modes: [ExecutionMode.SingleDefault],
executions: [[createExecution({ target: MULTIVAULT, value: BigInt(assets), callData })]],
});
// Step 4: Dry-run to catch revert reasons before spending gas
try {
await publicClient.call({ account: adminAccount.address, to: target, data });
} catch (simErr: any) {
console.error('Simulation failed:', simErr);
return NextResponse.json({
error: 'Transaction simulation failed',
details: simErr.shortMessage ?? simErr.message,
}, { status: 400 });
}
// Step 5: Broadcast the transaction
const hash = await walletClient.sendTransaction({ to: target, data });
// Return the hash immediately for a fast UI response
return NextResponse.json({ success: true, hash });
} catch (error: any) {
console.error('API Stake Error:', error);
return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 });
}
}Wiring Up the Feed Buttons
Now update the handleSupport and handleOppose functions inside ClaimFeed.tsx. Replace the placeholder console.log calls with the real delegation logic:
// src/components/ClaimFeed.tsx (updated handlers only)
const getStorageKey = (addr: string) => `intuition_admin_delegation_${addr.toLowerCase()}`;
const reviveBigInt = (key: string, value: any) =>
typeof value === 'string' && /^\d+n$/.test(value) ? BigInt(value.slice(0, -1)) : value;
// Inside ClaimItem, replace handleSupport with:
const handleSupport = async () => {
if (!address || !walletClient || !publicClient) return;
setIsPending(true);
try {
const stored = localStorage.getItem(getStorageKey(address));
const currentDelegation = stored ? JSON.parse(stored, reviveBigInt) : null;
if (currentDelegation) {
// 1-Click path: route through the backend relayer
const res = await fetch('/api/stake', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
delegation: currentDelegation,
termId: claim.term_id,
curveId: BigInt(1).toString(),
assets: parseEther('0.01').toString(), // Must meet the 0.01 TRUST protocol minimum
userAddress: address,
}, (key, value) => typeof value === 'bigint' ? value.toString() + 'n' : value),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.details || err.error || 'Failed to stake via relayer');
}
} else {
// Standard fallback: MetaMask popup
const patchedWalletClient = { ...walletClient, account: address };
await multiVaultDeposit(
{ address: MULTIVAULT, walletClient: patchedWalletClient as any, publicClient },
{
args: [address, claim.term_id, BigInt(1), BigInt(0)],
value: parseEther('0.01'),
}
);
}
// Optimistic UI update - show the stake immediately before the indexer catches up
setOptimisticSupport(BigInt(claim.term?.vaults?.[0]?.total_shares || '0') + parseEther('0.01'));
setTimeout(async () => {
await refetch();
setOptimisticSupport(null);
}, 4000);
} catch (e: any) {
console.error(e);
alert(e.message || 'Transaction failed');
}
setIsPending(false);
};Here is the full handleOppose implementation - the same pattern but targeting claim.counter_term_id:
const handleOppose = async () => {
if (!address || !walletClient || !publicClient) return;
setIsPending(true);
try {
const stored = localStorage.getItem(getStorageKey(address));
const currentDelegation = stored ? JSON.parse(stored, reviveBigInt) : null;
if (currentDelegation) {
// 1-Click path: route through the backend relayer
const res = await fetch('/api/stake', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
delegation: currentDelegation,
termId: claim.counter_term_id,
curveId: BigInt(1).toString(),
assets: parseEther('0.01').toString(),
userAddress: address,
}, (key, value) => typeof value === 'bigint' ? value.toString() + 'n' : value),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.details || err.error || 'Failed to stake via relayer');
}
} else {
// Standard fallback: MetaMask popup
const patchedWalletClient = { ...walletClient, account: address };
await multiVaultDeposit(
{ address: MULTIVAULT, walletClient: patchedWalletClient as any, publicClient },
{
args: [address, claim.counter_term_id, BigInt(1), BigInt(0)],
value: parseEther('0.01'),
}
);
}
// Optimistic UI update
setOptimisticOppose(BigInt(claim.counter_term?.vaults?.[0]?.total_shares || '0') + parseEther('0.01'));
setTimeout(async () => {
await refetch();
setOptimisticOppose(null);
}, 4000);
} catch (e: any) {
console.error(e);
alert(e.message || 'Transaction failed');
}
setIsPending(false);
};Troubleshooting
Execution reverted for an unknown reason
This almost always means a Caveat Enforcer rejected the execution. Common causes:
- Wrong function selector - The
AllowedMethodscaveat requires the 4-byte EVM selector. Always usetoFunctionSelector(), never pass the raw string. - Budget exhausted - The user's HSA balance has dropped below the
assetsvalue being sent. - Receiver mismatch - The
userAddresssent to the API does not match the address pinned in theallowedCalldatacaveat during setup.
MultiVault_DepositBelowMinimumDeposit
The Intuition Protocol enforces a global minimum deposit of 0.01 TRUST. If the assets value you send is lower than this, the MultiVault will revert. Always use at least parseEther('0.01').
BigInt Serialization Errors
If you see TypeError: Do not know how to serialize a BigInt, you are missing the custom replacer. When calling JSON.stringify with any object that contains BigInt values (like the delegation payload), always use:
JSON.stringify(value, (key, val) => typeof val === 'bigint' ? val.toString() + 'n' : val)And on the receiving end, always revive them:
JSON.parse(text, (key, val) => typeof val === 'string' && /^\d+n$/.test(val) ? BigInt(val.slice(0, -1)) : val)Conclusion
Congratulations! You have built a complete Claim Feed DApp on Intuition implementing the full ERC-7710 Delegation lifecycle - from signing a scoped delegation with Caveat Enforcers, to redeeming it via a backend relayer, to revoking it on-chain. This pattern of delegated execution is one of the most powerful tools in the ERC-7710 ecosystem and can be applied to any Intuition operation.