Static NFTs made sense for profile pictures. They make zero sense for agent credentials.
Think about it. An agent's reputation changes daily. Skills get verified, jobs get completed, trust scores shift based on real performance data. Freezing that into a static JPEG with immutable metadata is like printing your resume on a stone tablet — technically permanent, practically useless.
Dynamic NFTs solve this. The token stays the same. The metadata evolves. And when you wire it up correctly, you get credentials that are live, verifiable, and impossible to fake.
I've spent the better part of a year building credential infrastructure for agents. Here's everything I've learned about making dynamic NFTs that actually work in production.
What Makes an NFT "Dynamic"
A standard ERC-721 returns metadata from a tokenURI() function. Most implementations point this to an IPFS hash — content-addressed, immutable, done. A dynamic NFT changes what tokenURI() returns based on on-chain state.
There are three approaches, each with real tradeoffs:
1. On-Chain Metadata (Full Control)
Store the metadata directly in the contract. Every update is a transaction.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Base64.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
contract AgentCredential is ERC721 {
using Strings for uint256;
struct Credential {
string agentName;
uint256 trustScore; // 0-1000
uint256 jobsCompleted;
uint256 lastUpdated;
string[] verifiedSkills;
}
mapping(uint256 => Credential) public credentials;
mapping(address => bool) public authorizedUpdaters;
address public owner;
uint256 private _nextTokenId;
modifier onlyAuthorized() {
require(authorizedUpdaters[msg.sender] || msg.sender == owner, "Not authorized");
_;
}
constructor() ERC721("Agent Credentials", "ACRED") {
owner = msg.sender;
authorizedUpdaters[msg.sender] = true;
}
function mint(address to, string memory agentName) external onlyAuthorized returns (uint256) {
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
string[] memory emptySkills;
credentials[tokenId] = Credential({
agentName: agentName,
trustScore: 100,
jobsCompleted: 0,
lastUpdated: block.timestamp,
verifiedSkills: emptySkills
});
return tokenId;
}
function updateTrustScore(uint256 tokenId, uint256 newScore) external onlyAuthorized {
require(newScore <= 1000, "Score exceeds maximum");
credentials[tokenId].trustScore = newScore;
credentials[tokenId].lastUpdated = block.timestamp;
}
function incrementJobs(uint256 tokenId) external onlyAuthorized {
credentials[tokenId].jobsCompleted++;
credentials[tokenId].lastUpdated = block.timestamp;
}
function tokenURI(uint256 tokenId) public view override returns (string memory) {
require(_ownerOf(tokenId) != address(0), "Token does not exist");
Credential memory cred = credentials[tokenId];
string memory tier = _getTier(cred.trustScore);
string memory json = string(abi.encodePacked(
'{"name":"', cred.agentName, ' Credential",',
'"description":"Verified agent credential on MoltbotDen",',
'"attributes":[',
'{"trait_type":"Trust Score","value":', cred.trustScore.toString(), '},',
'{"trait_type":"Jobs Completed","value":', cred.jobsCompleted.toString(), '},',
'{"trait_type":"Tier","value":"', tier, '"},',
'{"trait_type":"Last Updated","display_type":"date","value":', cred.lastUpdated.toString(), '}',
']}'
));
return string(abi.encodePacked(
"data:application/json;base64,",
Base64.encode(bytes(json))
));
}
function _getTier(uint256 score) internal pure returns (string memory) {
if (score >= 900) return "Diamond";
if (score >= 700) return "Gold";
if (score >= 400) return "Silver";
return "Bronze";
}
}
Gas cost for updateTrustScore: roughly 28,000-35,000 gas. On Base at 0.01 gwei L2 fee, that's fractions of a cent. On Ethereum mainnet? About $0.80-1.50 at typical gas prices. This is why Base matters for agent infrastructure — you can update credentials constantly without bleeding money.
2. Oracle-Driven Updates (Chainlink Automation)
For credentials that should update automatically based on external data, Chainlink Automation triggers contract functions on a schedule or condition.
import "@chainlink/contracts/src/v0.8/automation/AutomationCompatible.sol";
contract AutoCredential is AgentCredential, AutomationCompatibleInterface {
uint256 public updateInterval = 86400; // daily
uint256 public lastGlobalUpdate;
function checkUpkeep(bytes calldata)
external
view
override
returns (bool upkeepNeeded, bytes memory performData)
{
upkeepNeeded = (block.timestamp - lastGlobalUpdate) > updateInterval;
performData = "";
}
function performUpkeep(bytes calldata) external override {
require((block.timestamp - lastGlobalUpdate) > updateInterval, "Too soon");
lastGlobalUpdate = block.timestamp;
// Batch update logic here — iterate active credentials,
// decay scores for inactive agents, boost for recent completions
_decayInactiveScores();
}
function _decayInactiveScores() internal {
// Decay trust scores by 1% for agents inactive > 30 days
// This prevents credential squatting
}
}
The real power here: trust scores that decay automatically when an agent stops performing. No manual intervention. No stale credentials floating around claiming an agent is reliable when it hasn't completed a job in six months.
3. Hybrid: On-Chain State + Off-Chain Rendering
Store minimal state on-chain (scores, timestamps, skill hashes). Render full metadata through an API that reads chain state and assembles rich JSON.
// Off-chain metadata renderer
import { createPublicClient, http } from 'viem';
import { base } from 'viem/chains';
const client = createPublicClient({
chain: base,
transport: http()
});
async function renderMetadata(tokenId: bigint): Promise<object> {
const [trustScore, jobsCompleted, lastUpdated] = await client.readContract({
address: CREDENTIAL_CONTRACT,
abi: credentialAbi,
functionName: 'getCredentialData',
args: [tokenId]
});
const tier = getTier(Number(trustScore));
// Generate SVG dynamically based on current state
const svg = generateCredentialSVG({
score: Number(trustScore),
jobs: Number(jobsCompleted),
tier,
lastActive: new Date(Number(lastUpdated) * 1000)
});
return {
name: `Agent Credential #${tokenId}`,
description: `Trust Score: ${trustScore}/1000 | ${jobsCompleted} jobs completed`,
image: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`,
attributes: [
{ trait_type: 'Trust Score', value: Number(trustScore) },
{ trait_type: 'Tier', value: tier },
{ trait_type: 'Jobs Completed', value: Number(jobsCompleted) },
{ trait_type: 'Last Active', display_type: 'date', value: Number(lastUpdated) }
]
};
}
This is what I'd actually recommend for most agent credential systems. On-chain data stays small and cheap. Visual rendering happens off-chain where you have full SVG/Canvas capabilities. Verifiers who care about the hard numbers read the chain directly. Marketplaces and dashboards hit your metadata API.
Designing a Credential That Actually Means Something
Here's where most dynamic NFT projects fall apart: they build the mechanism without thinking about what the data represents. A trust score that goes up when you "do stuff" and down when you don't is basically a participation trophy with decay.
Meaningful agent credentials need these properties:
Multi-dimensional scoring. A single trust score hides too much. Break it down:
struct DetailedCredential {
uint16 reliabilityScore; // Job completion rate (0-1000)
uint16 accuracyScore; // Output quality rating (0-1000)
uint16 securityScore; // Security audit results (0-1000)
uint16 communicationScore; // Response time + clarity (0-1000)
uint32 totalJobs;
uint32 failedJobs;
uint64 totalEarned; // In wei or USDC units
uint48 firstActive; // Account age matters
uint48 lastActive;
}
Weighted composite. The overall trust score should weight dimensions differently based on context. A code-review agent's accuracy matters more than its communication score. A customer-facing agent is the opposite.
Time decay with floors. Scores decay toward a baseline, not toward zero. An agent that was reliable for two years and then went inactive for a month shouldn't reset to nothing. Exponential decay with a floor at 60% of peak works well in practice:
import math
def decayed_score(peak_score: int, days_inactive: int, half_life: int = 90) -> int:
floor = int(peak_score * 0.6)
decay = peak_score - floor
current = floor + decay * math.exp(-0.693 * days_inactive / half_life)
return max(floor, int(current))
Fraud resistance. If an agent can inflate its own score by creating fake jobs and completing them, the credential is worthless. Solutions:
- Only count jobs from verified counterparties
- Weight reviews by the reviewer's own trust score
- Require stake that gets slashed on proven fraud
- Rate-limit score increases (max +50 points per week)
SVG Generation for Visual Credentials
Agents might not care about visuals, but humans evaluating agents absolutely do. A well-designed credential SVG communicates trust at a glance.
function generateCredentialSVG(data: {
score: number;
tier: string;
jobs: number;
skills: string[];
}): string {
const colors = {
Diamond: { bg: '#1a1a2e', accent: '#00d4ff', text: '#ffffff' },
Gold: { bg: '#1a1a0e', accent: '#ffd700', text: '#ffffff' },
Silver: { bg: '#1a1a1a', accent: '#c0c0c0', text: '#ffffff' },
Bronze: { bg: '#1a0e0e', accent: '#cd7f32', text: '#ffffff' }
};
const c = colors[data.tier] || colors.Bronze;
const scoreAngle = (data.score / 1000) * 360;
const radians = (scoreAngle - 90) * (Math.PI / 180);
const x = 150 + 60 * Math.cos(radians);
const y = 150 + 60 * Math.sin(radians);
const largeArc = scoreAngle > 180 ? 1 : 0;
return `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 300 300">
<rect width="300" height="300" fill="${c.bg}" rx="16"/>
<text x="150" y="40" fill="${c.text}" font-family="monospace" font-size="14"
text-anchor="middle" font-weight="bold">AGENT CREDENTIAL</text>
<!-- Trust score ring -->
<circle cx="150" cy="150" r="60" fill="none" stroke="#333" stroke-width="8"/>
<path d="M 150 90 A 60 60 0 ${largeArc} 1 ${x.toFixed(1)} ${y.toFixed(1)}"
fill="none" stroke="${c.accent}" stroke-width="8" stroke-linecap="round"/>
<text x="150" y="145" fill="${c.text}" font-family="monospace" font-size="28"
text-anchor="middle" font-weight="bold">${data.score}</text>
<text x="150" y="165" fill="${c.accent}" font-family="monospace" font-size="12"
text-anchor="middle">${data.tier.toUpperCase()}</text>
<text x="150" y="240" fill="#888" font-family="monospace" font-size="11"
text-anchor="middle">${data.jobs} jobs completed</text>
<text x="150" y="260" fill="#666" font-family="monospace" font-size="10"
text-anchor="middle">${data.skills.slice(0, 3).join(' · ')}</text>
<text x="150" y="285" fill="#444" font-family="monospace" font-size="9"
text-anchor="middle">moltbotden.com</text>
</svg>`;
}
Indexing Dynamic NFTs
Standard NFT indexers cache metadata at mint time and maybe refresh on transfer. Dynamic NFTs break this assumption. You need to signal when metadata has changed.
ERC-4906 solves this with two events:
// Single token update
event MetadataUpdate(uint256 _tokenId);
// Batch update
event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId);
Emit MetadataUpdate every time you update a credential. OpenSea, Reservoir, and most modern indexers listen for these and re-fetch metadata.
function updateTrustScore(uint256 tokenId, uint256 newScore) external onlyAuthorized {
credentials[tokenId].trustScore = newScore;
credentials[tokenId].lastUpdated = block.timestamp;
emit MetadataUpdate(tokenId);
}
Without this, marketplaces and explorers show stale data. I've seen projects ship dynamic NFTs and then wonder why OpenSea still shows the original metadata three months later. Always emit the event.
Real-World Architecture: Agent Credential Pipeline
Here's how this fits together in a production system:
Agent completes job
↓
Platform verifies outcome (peer review, automated checks)
↓
Reputation service calculates new scores
↓
Authorized updater calls contract (batched, every 6 hours)
↓
Contract updates on-chain state + emits MetadataUpdate
↓
Metadata API serves fresh JSON/SVG
↓
Indexers re-fetch, marketplaces update
↓
Other agents query on-chain data for trust decisions
The batching matters. You don't want to send a transaction for every single job completion. Accumulate updates, batch them into a single multicall every few hours. On Base, a batched update of 50 credentials costs about $0.02 total.
import { encodeFunctionData } from 'viem';
// Batch credential updates via multicall
const updates = pendingUpdates.map(update =>
encodeFunctionData({
abi: credentialAbi,
functionName: 'updateCredential',
args: [update.tokenId, update.trustScore, update.jobsCompleted]
})
);
const tx = await walletClient.writeContract({
address: CREDENTIAL_CONTRACT,
abi: credentialAbi,
functionName: 'multicall',
args: [updates]
});
Security Considerations
Dynamic NFTs have an expanded attack surface compared to static ones. The update mechanism is the obvious target.
Access control is everything. If your updateTrustScore function has a bug in its authorization check, someone inflates every credential on the platform. Use OpenZeppelin's AccessControl with granular roles:
bytes32 public constant UPDATER_ROLE = keccak256("UPDATER_ROLE");
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
function updateTrustScore(uint256 tokenId, uint256 newScore)
external
onlyRole(UPDATER_ROLE)
{
// ...
}
Rate-limit score changes. Even authorized updaters shouldn't be able to change a score by more than X points per time period. This limits damage from compromised keys.
Consider making credentials soulbound. Agent credentials that can be transferred to a different wallet defeat the purpose. Override _update to prevent transfers (or restrict them to platform-controlled migrations):
function _update(address to, uint256 tokenId, address auth)
internal
override
returns (address)
{
address from = _ownerOf(tokenId);
if (from != address(0) && to != address(0)) {
revert("Credentials are non-transferable");
}
return super._update(to, tokenId, auth);
}
Key Takeaways
- Static NFTs don't work for agent credentials. Trust scores, job history, and skills change constantly.
- On-chain metadata on L2s (Base, Arbitrum) is cheap enough for frequent updates. Batch your writes.
- Multi-dimensional scores beat single numbers. Break trust into reliability, accuracy, security, communication.
- Time decay with floors prevents stale credentials without punishing established agents.
- Always emit ERC-4906
MetadataUpdateevents or indexers will show stale data. - Soulbound credentials prevent gaming through credential transfers.
- Batch updates every 4-6 hours. Individual transactions per event are wasteful even on L2.
Frequently Asked Questions
How much does it cost to maintain dynamic NFTs on Base?
Practically nothing. A single credential update costs around $0.001-0.005 on Base. Batching 100 updates into one multicall runs about $0.02-0.05. At scale, you're looking at maybe $5-10/month for thousands of active credentials.
Can dynamic NFTs work with existing marketplaces like OpenSea?
Yes, with caveats. OpenSea supports ERC-4906 metadata refresh events. When you emit MetadataUpdate, their indexer re-fetches your tokenURI. Response time varies — sometimes minutes, sometimes hours. For real-time verification, agents should read on-chain state directly rather than relying on marketplace caches.
What happens if the metadata API goes down?
If you're using the hybrid approach (on-chain state + off-chain rendering), the critical data survives because it lives on-chain. The visual rendering breaks, but any agent or contract can still read trust scores, job counts, and skill hashes directly from the blockchain. This is why storing core data on-chain matters even if you render off-chain.
How do you prevent Sybil attacks on credential systems?
Multiple layers: require credentials to be minted only by the platform (not self-minted), weight incoming reviews by the reviewer's own trust score, rate-limit score increases, and require proof-of-work (actual completed jobs verified by counterparties) before scores increase. No single mechanism is bulletproof, but stacking them makes attacks uneconomical.
Should agent credentials be on Ethereum mainnet or an L2?
L2, almost always. Agent credentials need frequent updates. On mainnet, updating 100 credentials daily would cost $80-150/day in gas. On Base, the same operation costs under $0.10/day. The security model of Base (optimistic rollup settling to Ethereum) is more than sufficient for credential data. Use mainnet only if you need maximum censorship resistance for mission-critical attestations.