NFT & Digital Assets
On-Chain SVG NFTs: Building Dynamic Metadata That Lives on the Blockchain
TL;DR
I build NFT platforms where the art and metadata live entirely on-chain — no IPFS, no Arweave, no external servers. The NFT renders itself from Solidity using SVG generation and base64 encoding directly inside tokenURI(). The result is an NFT that cannot be taken down, cannot have its image broken by a gateway going offline, and can evolve dynamically based on on-chain state. This article walks through every pattern I use in production: SVG generation in Solidity, base64 encoding, dynamic properties that change based on holder behavior, gas optimization, and a complete contract you can deploy today.
Why On-Chain Metadata Matters
Most NFTs are a lie. Not the ownership part — that is real, stored on Ethereum, immutable. The lie is the art. The vast majority of NFT collections point to an IPFS hash or, worse, a centralized server for their image and metadata. When that server goes down, when that IPFS gateway stops pinning your file, your "immutable" NFT becomes a pointer to nothing.
I learned this the hard way working with a client whose NFT collection had its metadata hosted on a provider that shut down six months after launch. Thousands of NFTs suddenly showed blank images on OpenSea. The tokens still existed on-chain, but everything that made them visually meaningful was gone.
That experience changed how I approach NFT engineering. Now, every NFT platform I build stores metadata on-chain by default. The art is generated by the smart contract itself using SVG, encoded in base64, and returned directly from the tokenURI() function. No external dependencies. No infrastructure to maintain. The blockchain is the server.
Here is what on-chain metadata gives you:
- Permanence — your NFT exists as long as the blockchain exists. No gateway, no server, no pin needed.
- Trustlessness — anyone can verify exactly what the NFT looks like by reading the contract. No trust in a third party to serve the correct image.
- Dynamism — because the SVG is generated at read time, you can make it respond to on-chain state. The NFT can change appearance based on how long the holder has owned it, how many transfers it has undergone, or any other on-chain data.
- Composability — other contracts can read your NFT's properties directly. No off-chain indexer needed.
The trade-off is gas cost. Storing and generating SVG on-chain costs more than pointing to an IPFS hash. But for projects where permanence and dynamism matter, the trade-off is worth every wei.
SVG Generation in Solidity
SVG is the ideal format for on-chain NFTs because it is text-based. Unlike PNG or JPEG, SVG files are XML strings, which means they can be constructed by concatenating strings in Solidity. No binary encoding, no compression algorithms — just string building.
Here is the core pattern I use for generating SVG inside a Solidity contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/utils/Base64.sol";
contract OnChainSVGNFT is ERC721 {
using Strings for uint256;
uint256 private _nextTokenId;
struct TokenData {
uint256 mintTimestamp;
uint256 transferCount;
address originalMinter;
}
mapping(uint256 => TokenData) public tokenData;
constructor() ERC721("OnChainSVG", "OCSVG") {}
function mint() external {
uint256 tokenId = _nextTokenId++;
tokenData[tokenId] = TokenData({
mintTimestamp: block.timestamp,
transferCount: 0,
originalMinter: msg.sender
});
_safeMint(msg.sender, tokenId);
}
function _generateSVG(uint256 tokenId) internal view returns (string memory) {
TokenData memory data = tokenData[tokenId];
uint256 age = (block.timestamp - data.mintTimestamp) / 1 days;
string memory bgColor = _getBackgroundColor(age);
string memory ringColor = _getRingColor(data.transferCount);
return string(
abi.encodePacked(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">',
'<rect width="400" height="400" fill="', bgColor, '"/>',
'<circle cx="200" cy="200" r="120" fill="none" stroke="',
ringColor, '" stroke-width="8"/>',
'<circle cx="200" cy="200" r="80" fill="#F7931A"/>',
'<text x="200" y="190" text-anchor="middle" ',
'font-family="monospace" font-size="14" fill="#fff">',
'#', tokenId.toString(), '</text>',
'<text x="200" y="220" text-anchor="middle" ',
'font-family="monospace" font-size="12" fill="#C9D1E0">',
age.toString(), ' days</text>',
'</svg>'
)
);
}
function _getBackgroundColor(uint256 age) internal pure returns (string memory) {
if (age >= 365) return "#0A0E1A";
if (age >= 180) return "#111827";
if (age >= 30) return "#1A2236";
return "#1E293B";
}
function _getRingColor(uint256 transfers) internal pure returns (string memory) {
if (transfers == 0) return "#F7931A";
if (transfers <= 3) return "#FFA940";
if (transfers <= 10) return "#E07B0A";
return "#FFB84D";
}
}A few things to notice about this pattern:
String concatenation uses `abi.encodePacked` — this is significantly cheaper than using string.concat() or Solidity's native + operator for strings. For SVG generation where you are concatenating many fragments, the gas savings add up.
The SVG is generated at read time — _generateSVG is a view function. It costs no gas to call. The SVG is only constructed when someone reads the tokenURI, which means the image is always current. If the token has been held for 100 days, the SVG reflects that at the moment of reading.
Data is stored in a struct — I store the minimum data needed to drive the visual output. Mint timestamp, transfer count, and original minter. The SVG generation function reads this data and produces a unique visual for each token.
Colors are deterministic — given the same on-chain state, the same SVG is always produced. This makes the output verifiable. Anyone can call the function and confirm the visual matches what they see on a marketplace.
Base64 Encoding for TokenURI
The ERC-721 standard expects tokenURI() to return a URL. For off-chain metadata, this is typically an IPFS or HTTP URL pointing to a JSON file. For on-chain metadata, we use a data URI that embeds the entire JSON payload inline.
The format is:
data:application/json;base64,<base64-encoded-json>Inside that JSON, the image field also uses a data URI:
data:image/svg+xml;base64,<base64-encoded-svg>Here is how I implement this in the contract:
function tokenURI(uint256 tokenId) public view override returns (string memory) {
_requireOwned(tokenId);
TokenData memory data = tokenData[tokenId];
uint256 age = (block.timestamp - data.mintTimestamp) / 1 days;
string memory svg = _generateSVG(tokenId);
string memory attributes = string(
abi.encodePacked(
'[',
'{"trait_type":"Age (Days)","value":', age.toString(), '},',
'{"trait_type":"Transfers","value":', data.transferCount.toString(), '},',
'{"trait_type":"Evolution","value":"', _getEvolutionStage(age), '"}',
']'
)
);
string memory json = string(
abi.encodePacked(
'{"name":"OnChainSVG #', tokenId.toString(),
'","description":"A fully on-chain SVG NFT that evolves over time.',
'","image":"data:image/svg+xml;base64,',
Base64.encode(bytes(svg)),
'","attributes":', attributes, '}'
)
);
return string(
abi.encodePacked(
"data:application/json;base64,",
Base64.encode(bytes(json))
)
);
}
function _getEvolutionStage(uint256 age) internal pure returns (string memory) {
if (age >= 365) return "Ancient";
if (age >= 180) return "Elder";
if (age >= 30) return "Mature";
return "Seedling";
}OpenZeppelin's Base64 library handles the encoding. It is gas-efficient and battle-tested. I never roll my own base64 implementation — the risk of encoding bugs in a contract that cannot be upgraded is too high.
The critical thing to understand is that the double encoding is intentional. The SVG is base64-encoded and embedded inside the JSON. Then the entire JSON is base64-encoded for the data URI. When a marketplace like OpenSea reads the tokenURI, it decodes the outer base64 to get JSON, parses the image field, and decodes the inner base64 to render the SVG.
One gotcha I have hit in production: `abi.encodePacked` has a 12-element limit per call. If your SVG or JSON string has more than 12 concatenation segments, you need to break it into multiple abi.encodePacked calls and nest them. I typically build the SVG in sections — header, body, footer — and combine them at the end.
Dynamic Properties Based on State
This is where on-chain SVGs become genuinely powerful. Because the SVG is generated at read time from on-chain data, the NFT's visual appearance can change based on any state the contract can access.
Here are the dynamic properties I have implemented in production projects:
Time-Based Evolution
The most common pattern. The NFT changes appearance based on how long the current holder has owned it. I track this using block.timestamp at mint or transfer:
function _update(
address to,
uint256 tokenId,
address auth
) internal override returns (address) {
address from = super._update(to, tokenId, auth);
if (from != address(0) && to != address(0)) {
tokenData[tokenId].transferCount++;
}
return from;
}By overriding _update (the internal function that ERC-721 calls on every transfer), I increment the transfer counter without adding any external function calls. This is the cleanest way to track transfers in OpenZeppelin v5.
Interaction-Based Traits
For a staking protocol I built, the NFT changed color based on how much yield the holder had earned. The contract queried the staking state:
function _getYieldTier(uint256 tokenId) internal view returns (string memory) {
uint256 earned = stakingContract.earned(tokenId);
if (earned >= 100 ether) return "Diamond";
if (earned >= 10 ether) return "Gold";
if (earned >= 1 ether) return "Silver";
return "Bronze";
}The SVG rendered different visual elements based on the yield tier. Diamond holders got an animated gradient background. Bronze holders got a solid color. The NFT became a visual representation of the holder's participation in the protocol.
Cross-Contract Data
On-chain SVGs can read data from other contracts. I have built NFTs that display a holder's ENS name, their governance voting power, or their position in a DeFi protocol. The SVG becomes a dashboard rendered as an image:
function _getVotingPower(address holder) internal view returns (uint256) {
return governanceToken.getVotes(holder);
}This is composability in action. The NFT does not just represent ownership — it represents the holder's entire on-chain identity, updated in real time.
Gas Considerations
On-chain SVG generation is not free. Here are the gas costs I have measured across production deployments, and the optimization techniques I use to keep them manageable.
Minting Gas
Minting an on-chain SVG NFT costs roughly the same as minting any ERC-721 — the SVG is not generated at mint time. The only additional cost is storing the TokenData struct, which adds approximately 40,000-60,000 gas for a struct with 2-3 fields. For context, a standard ERC-721 mint costs around 90,000 gas, so the struct storage brings total mint cost to approximately 130,000-150,000 gas.
TokenURI Gas (Read Only)
The SVG generation happens in tokenURI(), which is a view function. When called off-chain (by a marketplace, a frontend, or an RPC call), it costs zero gas. The computation is performed by the node processing the eth_call. This is the key insight: complex SVG generation is free for end users. The computational cost exists, but it is borne by the node, not the user's wallet.
However, if another contract calls tokenURI() on-chain, it does consume gas. I have seen this in composability scenarios where one contract reads another's metadata. For those cases, keep the SVG simple or provide a separate function that returns raw data without the SVG rendering.
Storage Optimization
Every byte stored on-chain costs gas. Here is how I minimize storage:
// Expensive — stores string
mapping(uint256 => string) public tokenNames; // DON'T
// Cheap — stores uint8 index, generates string in view function
mapping(uint256 => uint8) public tokenNameIndex; // DO
function _getNameFromIndex(uint8 index) internal pure returns (string memory) {
if (index == 0) return "Seedling";
if (index == 1) return "Sprout";
if (index == 2) return "Sapling";
return "Oak";
}Store integers, generate strings. Store indices, map to values in pure functions. Every string you avoid storing on-chain saves thousands of gas.
String Concatenation Costs
abi.encodePacked is the cheapest concatenation method in Solidity, but it still costs gas proportional to the length of the output. For complex SVGs with many elements, I use these patterns:
- Precompute repeated strings — if the same SVG fragment appears in every token, define it as a constant
- Use integer math for positioning — calculate coordinates with
uint256arithmetic rather than concatenating position strings - Limit SVG complexity — the most gas-efficient on-chain SVGs use geometric primitives (circles, rectangles, lines) rather than paths or text-heavy layouts
In my experience, a well-optimized on-chain SVG NFT adds less than 10% to total deployment cost compared to an IPFS-based approach. The ongoing cost difference is zero because tokenURI is a view function.
Testing SVG Output
Testing on-chain SVGs is different from testing standard smart contract logic. You need to verify both the contract behavior (minting, transferring, access control) and the visual output (the SVG renders correctly).
Here is my testing workflow:
Step 1: Unit Test the TokenURI Output
// test/OnChainSVGNFT.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/OnChainSVGNFT.sol";
contract OnChainSVGNFTTest is Test {
OnChainSVGNFT nft;
address alice = makeAddr("alice");
function setUp() public {
nft = new OnChainSVGNFT();
}
function test_tokenURI_returns_valid_data_uri() public {
vm.prank(alice);
nft.mint();
string memory uri = nft.tokenURI(0);
// Verify it starts with the data URI prefix
assertEq(_startsWith(uri, "data:application/json;base64,"), true);
}
function test_tokenURI_changes_with_age() public {
vm.prank(alice);
nft.mint();
string memory uriBefore = nft.tokenURI(0);
// Fast-forward 31 days
vm.warp(block.timestamp + 31 days);
string memory uriAfter = nft.tokenURI(0);
// URIs should differ because age changed
assertFalse(
keccak256(bytes(uriBefore)) == keccak256(bytes(uriAfter))
);
}
function test_transfer_increments_count() public {
vm.prank(alice);
nft.mint();
address bob = makeAddr("bob");
vm.prank(alice);
nft.transferFrom(alice, bob, 0);
(, uint256 transfers,) = nft.tokenData(0);
assertEq(transfers, 1);
}
function _startsWith(
string memory str,
string memory prefix
) internal pure returns (bool) {
bytes memory strBytes = bytes(str);
bytes memory prefixBytes = bytes(prefix);
if (prefixBytes.length > strBytes.length) return false;
for (uint256 i = 0; i < prefixBytes.length; i++) {
if (strBytes[i] != prefixBytes[i]) return false;
}
return true;
}
}Step 2: Visual Verification Script
Unit tests verify the structure, but you need to actually see the SVG to confirm it looks right. I use a Foundry script that writes the SVG to a file:
// script/RenderSVG.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Script.sol";
import "../src/OnChainSVGNFT.sol";
contract RenderSVG is Script {
function run() external {
OnChainSVGNFT nft = new OnChainSVGNFT();
nft.mint();
string memory uri = nft.tokenURI(0);
vm.writeFile("output/token0.txt", uri);
// Warp to test different evolution stages
vm.warp(block.timestamp + 31 days);
string memory uri31 = nft.tokenURI(0);
vm.writeFile("output/token0_31days.txt", uri31);
vm.warp(block.timestamp + 180 days);
string memory uri180 = nft.tokenURI(0);
vm.writeFile("output/token0_180days.txt", uri180);
}
}I then decode the base64 output in the browser or with a simple script to verify the SVG renders correctly at each evolution stage. This visual QA step has caught bugs that unit tests missed — incorrect color values, overlapping text, malformed SVG attributes.
Step 3: Fuzz Testing Dynamic Properties
function testFuzz_background_color_deterministic(uint256 age) public pure {
// Bound age to reasonable range
age = bound(age, 0, 3650);
string memory color = _getBackgroundColor(age);
bytes memory colorBytes = bytes(color);
// Every return value should be a valid hex color
assertEq(colorBytes[0], bytes1("#"));
assertEq(colorBytes.length, 7);
}Fuzz testing ensures that every possible input produces valid SVG output. I fuzz token IDs, timestamps, and transfer counts to verify that no edge case produces broken SVG.
Comparison with IPFS-Based Metadata
I have shipped both approaches in production. Here is an honest comparison based on real projects:
| Factor | On-Chain SVG | IPFS / Arweave |
|---|---|---|
| Permanence | Guaranteed. Lives as long as the chain. | Depends on pinning. IPFS is not permanent by default. |
| Cost (deploy) | Higher. More contract bytecode. | Lower. Contract just stores a hash. |
| Cost (ongoing) | Zero. View functions are free. | Pinning costs. Gateway fees. |
| Image complexity | Limited to SVG primitives. No raster images. | Unlimited. Any format, any resolution. |
| Dynamic content | Native. SVG regenerates on every read. | Requires updating metadata and re-pinning. |
| Marketplace support | Excellent. All major marketplaces decode data URIs. | Excellent. IPFS gateway URLs are universal. |
| Verification | Anyone can read the contract. | Must verify content hash matches the CID. |
| File size | Practical limit ~24KB SVG (contract size limit). | No practical limit. |
My recommendation: Use on-chain SVGs for projects where dynamism, permanence, or composability are core features. Use IPFS for high-resolution art, photography, video, or any content that cannot be expressed as vector graphics.
For many projects, I use a hybrid approach. The core identity of the NFT — shape, color, text — is generated on-chain. A supplementary high-resolution render is stored on Arweave and linked in the metadata as an animation_url or external_url. The on-chain version ensures permanence; the Arweave version provides visual richness.
Building Evolving NFTs
The most compelling use case for on-chain SVGs is NFTs that evolve. Not through centralized metadata updates, but through genuine on-chain state changes that alter the visual output.
Here is a pattern I have used for a project where NFTs evolved through four stages based on holder behavior:
enum Stage { Seed, Growth, Bloom, Eternal }
function _getStage(uint256 tokenId) internal view returns (Stage) {
TokenData memory data = tokenData[tokenId];
uint256 age = (block.timestamp - data.mintTimestamp) / 1 days;
uint256 interactions = data.transferCount;
// Stage requires both time AND engagement
if (age >= 365 && interactions >= 5) return Stage.Eternal;
if (age >= 90 && interactions >= 3) return Stage.Bloom;
if (age >= 30 && interactions >= 1) return Stage.Growth;
return Stage.Seed;
}The key design decision: evolution requires both time and interaction. A token that sits in a wallet for a year without any on-chain interaction stays at a lower stage than one that has been actively used in the ecosystem. This creates a meaningful relationship between holder behavior and NFT appearance.
Each stage renders a completely different SVG. Seed stage shows a simple circle. Growth adds rays. Bloom adds a flower pattern. Eternal renders a complex mandala. The visual progression rewards long-term holders who actively participate.
function _generateStageSVG(
uint256 tokenId,
Stage stage
) internal view returns (string memory) {
if (stage == Stage.Eternal) return _renderEternal(tokenId);
if (stage == Stage.Bloom) return _renderBloom(tokenId);
if (stage == Stage.Growth) return _renderGrowth(tokenId);
return _renderSeed(tokenId);
}
function _renderSeed(uint256 tokenId) internal view returns (string memory) {
return string(
abi.encodePacked(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">',
'<rect width="400" height="400" fill="#1E293B"/>',
'<circle cx="200" cy="200" r="40" fill="#F7931A" opacity="0.6"/>',
'<text x="200" y="360" text-anchor="middle" ',
'font-family="monospace" font-size="11" fill="#6B7FA3">',
'Seed #', tokenId.toString(), '</text>',
'</svg>'
)
);
}
function _renderEternal(uint256 tokenId) internal view returns (string memory) {
return string(
abi.encodePacked(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">',
'<defs><radialGradient id="g">',
'<stop offset="0%" stop-color="#F7931A"/>',
'<stop offset="100%" stop-color="#0A0E1A"/>',
'</radialGradient></defs>',
'<rect width="400" height="400" fill="url(#g)"/>',
_renderMandala(),
'<text x="200" y="360" text-anchor="middle" ',
'font-family="monospace" font-size="11" fill="#FFB84D">',
'Eternal #', tokenId.toString(), '</text>',
'</svg>'
)
);
}
function _renderMandala() internal pure returns (string memory) {
return string(
abi.encodePacked(
'<circle cx="200" cy="200" r="120" fill="none" ',
'stroke="#FFA940" stroke-width="1" opacity="0.4"/>',
'<circle cx="200" cy="200" r="90" fill="none" ',
'stroke="#F7931A" stroke-width="2" opacity="0.6"/>',
'<circle cx="200" cy="200" r="60" fill="none" ',
'stroke="#FFB84D" stroke-width="3" opacity="0.8"/>',
'<circle cx="200" cy="200" r="30" fill="#F7931A"/>'
)
);
}One important production lesson: emit events when evolution happens. Marketplaces like OpenSea listen for the MetadataUpdate event (ERC-4906) to know when to refresh an NFT's metadata. Without this, the marketplace cache will show stale images:
import "@openzeppelin/contracts/interfaces/IERC4906.sol";
// When you want to signal a metadata refresh
emit MetadataUpdate(tokenId);
// Or for a batch refresh
emit BatchMetadataUpdate(fromTokenId, toTokenId);I typically emit MetadataUpdate inside the _update override, so every transfer triggers a metadata refresh on marketplaces. For time-based evolution, I provide a public function that anyone can call to trigger the event for a specific token.
Real Project Examples
Here are patterns from actual contracts I have deployed:
Membership NFTs with On-Chain Status
For a DAO client, I built membership NFTs that displayed the holder's governance participation directly in the SVG. The NFT showed the member's vote count, proposal count, and delegation status — all read from on-chain contracts at render time. Members who actively participated in governance had visually distinct NFTs from passive holders. This created social proof: you could see from the NFT image alone whether someone was an active DAO participant.
Achievement NFTs for DeFi Protocols
For a DeFi protocol, I built achievement badges as on-chain SVGs. When a user hit a milestone (first deposit, $10K TVL, 100 days of continuous staking), the protocol minted an SVG NFT that encoded the achievement details. The SVG included the exact timestamp, the amount, and a unique pattern generated from the user's address. These could not be faked because the contract verified the achievement conditions before minting.
Generative Art with Deterministic Randomness
For a generative art project, I used the combination of tokenId, block.prevrandao, and the minter's address as a seed for deterministic visual generation:
function _getSeed(uint256 tokenId) internal view returns (uint256) {
return uint256(keccak256(abi.encodePacked(
tokenId,
tokenData[tokenId].originalMinter,
tokenData[tokenId].mintTimestamp
)));
}
function _seedToColor(uint256 seed, uint256 index) internal pure returns (string memory) {
uint256 hue = (seed >> (index * 8)) % 360;
// Convert HSL hue to a hex color (simplified)
if (hue < 30) return "#F7931A";
if (hue < 60) return "#FFA940";
if (hue < 120) return "#00C97B";
if (hue < 240) return "#4A9EFF";
return "#FFB84D";
}The seed is deterministic — the same token always generates the same visual. But each token has a unique seed, producing a unique artwork. The randomness is verifiable on-chain because anyone can recompute the seed from public data.
Key Takeaways
- On-chain SVGs make NFTs truly permanent. No IPFS pinning, no server maintenance, no broken images. The blockchain is the only dependency.
- `tokenURI()` as a view function means SVG generation is free for users. The computational cost is borne by the RPC node, not the user's wallet. Build complex visuals without worrying about gas.
- Dynamic NFTs require on-chain state storage. Store the minimum data needed (timestamps, counters, indices) and generate everything else in view functions. Store integers, generate strings.
- `abi.encodePacked` is your string builder. It is the most gas-efficient concatenation method, but remember the 12-element limit per call. Break complex SVGs into helper functions.
- Base64 double-encoding is the standard. SVG encodes into the
imagefield. JSON encodes into the data URI. Every major marketplace supports this format.
- ERC-4906 events are essential. Without
MetadataUpdateevents, marketplaces will cache stale metadata. Emit them on transfers and state changes.
- Test visually, not just logically. Unit tests verify structure. Render scripts verify appearance. Both are required for production confidence.
- Hybrid approaches work. On-chain SVG for permanence and dynamism. Arweave for high-resolution supplementary content. Use both where appropriate.
- Evolution mechanics should reward behavior, not just time. The most engaging dynamic NFTs require holder interaction, not just passive holding.
- The contract size limit (24KB) is your SVG complexity budget. Design within this constraint. Geometric primitives and mathematical patterns produce stunning visuals within tight bytecode limits.
On-chain SVG NFTs are not the right choice for every project. But for NFTs that need to be permanent, dynamic, and composable, they are the most powerful pattern available. I have shipped them across Ethereum, Arbitrum, and Base, and the contracts will outlive every IPFS gateway and centralized server they compete against.
*I am Uvin Vindula — I build Web3 and AI systems from Sri Lanka for global clients. If you are building an NFT platform that needs on-chain metadata, dynamic visuals, or any smart contract engineering, check out my services or reach out at contact@uvin.lk.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.