sRFC 00006: Writing SVG Images as PDAs of a Solana Program to implement On-chain images for NFTs

This proposal discusses storing image data in base64 SVG format directly to a Solana address, using a data URL header to make it natively readable by browsers. The image storage account is a PDA generated with the NFT token pubkey as a seed, making it easy to retrieve the image knowing just the programID and the NFT token pubkey.

Showcased below is the code used to implement Blockrons on-chain images, open sourced by me under CC1 (just give a thanks/attribution or something)

1. Main program - Lib.rs (written in solpg)

use anchor_lang::prelude::*;

declare_id!("BLKRNygNc7mWMpxQPXs4UoVPyApvyqy4v8uK9F3iJmqS");

#[program]
// Smart contract functions
pub mod imager {
    use super::*;

    // creates the image storage account
    pub fn create_imager(ctx: Context<CreateImager>,token: Pubkey) -> Result<()> {
        let imager = &mut ctx.accounts.imager;
        imager.authority = ctx.accounts.authority.key();
        imager.token = token;
        imager.img = ("").to_string();
        Ok(())
    }
    // puts data inside the image storage account by concatenating strings <- client-side to fit the solana txn limit
    pub fn update_imager(ctx: Context<UpdateImager>,token: Pubkey, image: String) -> Result<()> {   
        let imager = &mut ctx.accounts.imager;
        imager.authority = ctx.accounts.authority.key();
        imager.token = token;
        imager.img = format!("{}{}", imager.img, image); 
        Ok(())
    }

}

// Data validators
//token input (nft token addy) + programID are the main seeds for PDA
#[derive(Accounts)]
#[instruction(token: Pubkey)]
pub struct CreateImager<'info> {
#[account(mut)]
    authority: Signer<'info>,
    #[account(
        init_if_needed,
        seeds = [token.as_ref()],
        bump,
        payer = authority,
        space = 10000
    )]
    imager: Account<'info, Imager>,
    system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct UpdateImager<'info> {
    authority: Signer<'info>,
    #[account(mut, has_one = authority)]
    imager: Account<'info, Imager>,
}

// Data structures
#[account]
pub struct Imager {
    authority: Pubkey,
    token: Pubkey,
    img: String,
}

^^On-chain program is simple and only uses 2 functions - 1. create the PDA and 2. store data to the PDA thru string concatenation with multiple txns (to bypass solana’s byte txn limit)

2. Client side implementation - also deployed using solpg:

const systemProgram = anchor.web3.SystemProgram;
// EDIT THIS: token = public nft account addy you want to tie your on-chain image to
const token = new web3.PublicKey("NFT PUBKEY HERE");
// EDIT THIS: dataS = string of the base64 info
const dataS = new String("BASE64 SVG DATA HERE");


// do not touch, automatically divide dataS into strings to split transactions
var string1 = new String(dataS.slice(0,800));
var string2 = new String(dataS.slice(800,1600));
var string3 = new String(dataS.slice(1600,2400));
var string4 = new String(dataS.slice(2400,3200));
var string5 = new String(dataS.slice(3200,4000));
var string6 = new String(dataS.slice(4000,4800));
var string7 = new String(dataS.slice(4800,5600));

// program logic
    const [imagerPubkey, _] = await anchor.web3.PublicKey.findProgramAddress(
      [token.toBytes()],
      pg.program.programId
    );
    console.log("Your imager address", imagerPubkey.toString());

// create image storage account
    const [imager, _imagerBump] =
      await anchor.web3.PublicKey.findProgramAddress(
        [token.toBytes()],
        pg.program.programId
      );
    const tx = await pg.program.methods
      .createImager(token)
      .accounts({
        authority: pg.wallet.publicKey,
        imager: imager,
        systemProgram: systemProgram.programId,
      })
      .rpc();

// transact all 7 string parts    
    const tx1 = await pg.program.methods
      .updateImager(token,string1.toString())
      .accounts({
        imager: imagerPubkey,
      })
      .rpc();  
    const tx2 = await pg.program.methods
      .updateImager(token,string2.toString())
      .accounts({
        imager: imagerPubkey,
      })
      .rpc();
    const tx3 = await pg.program.methods
      .updateImager(token,string3.toString())
      .accounts({
        imager: imagerPubkey,
      })
      .rpc();
    const tx4 = await pg.program.methods
      .updateImager(token,string4.toString())
      .accounts({
        imager: imagerPubkey,
      })
      .rpc();
    const tx5 = await pg.program.methods
      .updateImager(token,string5.toString())
      .accounts({
        imager: imagerPubkey,
      })
      .rpc();
    const tx6 = await pg.program.methods
      .updateImager(token,string6.toString())
      .accounts({
        imager: imagerPubkey,
      })
      .rpc();
    const tx7 = await pg.program.methods
      .updateImager(token,string7.toString())
      .accounts({
        imager: imagerPubkey,
      })
      .rpc();
    
    console.log("Done!");    
// displays current data    
//    console.log("Your imager", imager);

^^Client side program is a little bit more complex as it is built to breakdown a long string into 7 diff txns of 800 bytes each - to fit solana’s txn limit. Currently handles for strings upto 5600 bytes but of course this can be extended until the PDA max of 10,000 bytes.

Typically, each Blockron image is 32x32 pixels and takes up 3-4kb of space.

4 Likes

Example of a data stored inside the string, inside the variable dataS:

data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgLTAuNSAzMiAzMiIgc2hhcGUtcmVuZGVyaW5nPSJjcmlzcEVkZ2VzIj4KPG1ldGFkYXRhPkJsb2Nrcm9ucyAwMTY8L21ldGFkYXRhPgo8cGF0aCBzdHJva2U9IiM1NTcwNjQiIGQ9Ik0wIDBoMzJNMCAxaDMyTTAgMmgzMk0wIDNoMzJNMCA0aDMyTTAgNWgzMk0wIDZoMTRNMjMgNmg5TTAgN2gxMk0yNCA3aDhNMCA4aDExTTI1IDhoN00wIDloMTBNMjUgOWg3TTAgMTBoMTBNMjUgMTBoN00wIDExaDEwTTI1IDExaDdNMCAxMmgxME0yNSAxMmg3TTAgMTNoOE0yNSAxM2g3TTAgMTRoOE0yNSAxNGg3TTAgMTVoOE0yNSAxNWg3TTAgMTZoOE0yNSAxNmg3TTAgMTdoOU0yNSAxN2g3TTAgMThoMTBNMjQgMThoOE0wIDE5aDEwTTI0IDE5aDhNMCAyMGgxME0yNCAyMGg4TTAgMjFoMTBNMjQgMjFoOE0wIDIyaDEwTTI0IDIyaDhNMCAyM2gxME0yMyAyM2g5TTAgMjRoMTBNMjAgMjRoMTJNMCAyNWgxME0yMCAyNWgxMk0wIDI2aDdNMjMgMjZoOU0wIDI3aDRNMjYgMjdoNk0wIDI4aDJNMjggMjhoNE0wIDI5aDFNMjkgMjloM00wIDMwaDFNMjkgMzBoM00wIDMxaDFNMjkgMzFoMyIgLz4KPHBhdGggc3Ryb2tlPSIjMTkxODE4IiBkPSJNMTQgNmg5TTEyIDdoMk0yMyA3aDFNMTEgOGgyTTI0IDhoMU0xMCA5aDJNMjQgOWgxTTEwIDEwaDFNMjQgMTBoMU0xMCAxMWgxTTI0IDExaDFNMTAgMTJoMU0yNCAxMmgxTTggMTNoM00xNSAxM2gzTTIyIDEzaDNNOCAxNGgxTTE1IDE0aDFNMTcgMTRoMU0yMiAxNGgxTTI0IDE0aDFNOCAxNWgxTTI0IDE1aDFNOCAxNmgxTTIwIDE2aDFNMjQgMTZoMU05IDE3aDJNMTkgMTdoMk0yNCAxN2gxTTEwIDE4aDFNMTIgMThoMU0yMyAxOGgxTTEwIDE5aDFNMTIgMTloMU0yMyAxOWgxTTEwIDIwaDFNMTMgMjBoMU0xNyAyMGg1TTIzIDIwaDFNMTAgMjFoMU0xMyAyMWgxTTIzIDIxaDFNMTAgMjJoMU0xMyAyMmgyTTIzIDIyaDFNMTAgMjNoMU0xNCAyM2g5TTEwIDI0aDFNMTkgMjRoMU0xMCAyNWgxTTE5IDI1aDFNNyAyNmgzTTIwIDI2aDNNNCAyN2gzTTIzIDI3aDNNMiAyOGgyTTI2IDI4aDJNMSAyOWgxTTI4IDI5aDFNMSAzMGgxTTI4IDMwaDFNMSAzMWgxTTI4IDMxaDEiIC8+CjxwYXRoIHN0cm9rZT0iIzg4ODg4OCIgZD0iTTE0IDdoOU0xMyA4aDJNMTIgOWgyTTExIDEwaDJNMTEgMTFoMU05IDE0aDJNMTAgMTVoMU0xMSAxNmgyTTExIDE3aDNNMTMgMThoMU0xNCAxOWgxTTE0IDIwaDFNMTUgMjFoMU0yMiAyMWgxTTE1IDIyaDFNMjIgMjJoMSIgLz4KPHBhdGggc3Ryb2tlPSIjZmNmY2ZjIiBkPSJNMTUgOGgzTTIzIDhoMU0xNCA5aDVNMjIgOWgyTTEzIDEwaDExTTEyIDExaDEyTTEyIDEyaDNNMTMgMTNoMk0xOCAxM2gxTTE0IDE0aDFNMTggMTRoMk0yMSAxNGgxTTE0IDE1aDZNMjEgMTVoM00xNSAxNmg1TTIxIDE2aDJNMTUgMTdoNE0yMSAxN2gyTTE2IDE4aDZNMTYgMTloMU0xOCAxOWgxTTIwIDE5aDEiIC8+CjxwYXRoIHN0cm9rZT0iI2U5ZTllOSIgZD0iTTE4IDhoNU0xOSA5aDNNMTUgMTJoOU0xMiAxM2gxTTE5IDEzaDNNMTIgMTRoMk0yMCAxNGgxTTEyIDE1aDJNMjAgMTVoMU0xMyAxNmgyTTIzIDE2aDFNMjMgMTdoMU0xNSAxOGgxTTIyIDE4aDFNMTUgMTloMU0yMiAxOWgxIiAvPgo8cGF0aCBzdHJva2U9IiNhNmE2YTYiIGQ9Ik0xMSAxMmgxTTExIDEzaDFNMTEgMTRoMU0xMSAxNWgxTTE0IDE3aDFNMTQgMThoMU0xNSAyMGgyTTIyIDIwaDFNMTYgMjFoNk0xNiAyMmg2IiAvPgo8cGF0aCBzdHJva2U9IiNhN2VkMDAiIGQ9Ik0xNiAxNGgxTTIzIDE0aDEiIC8+CjxwYXRoIHN0cm9rZT0iIzYxNjE2MSIgZD0iTTkgMTVoMU05IDE2aDJNMTEgMThoMU0xMyAxOWgxTTE0IDIxaDFNMTIgMjRoMU0xMiAyNWgzTTExIDI2aDhNMTAgMjdoMTJNOSAyOGgxMk0xMiAyOWg2IiAvPgo8cGF0aCBzdHJva2U9IiM0NDQ0NDQiIGQ9Ik0xMSAxOWgxTTExIDIwaDJNMTEgMjFoMk0xMSAyMmgyTTExIDIzaDNNMTEgMjRoMU0xMyAyNGg2TTExIDI1aDFNMTUgMjVoNE0xMCAyNmgxTTE5IDI2aDFNOCAyN2gyIiAvPgo8cGF0aCBzdHJva2U9IiM4NTg1ODUiIGQ9Ik0xNyAxOWgxTTE5IDE5aDFNMjEgMTloMSIgLz4KPHBhdGggc3Ryb2tlPSIjMzkyYzIwIiBkPSJNNyAyN2gxTTIyIDI3aDFNNSAyOGg0TTIxIDI4aDRNNiAyOWg2TTE4IDI5aDZNMTEgMzBoOCIgLz4KPHBhdGggc3Ryb2tlPSIjNGYxODE0IiBkPSJNNCAyOGgxTTI1IDI4aDFNMiAyOWgyTTI2IDI5aDJNMiAzMGgxTTI3IDMwaDFNMiAzMWgxTTI3IDMxaDEiIC8+CjxwYXRoIHN0cm9rZT0iIzMxMGEwOCIgZD0iTTQgMjloMk0yNCAyOWgyTTMgMzBoOE0xOSAzMGg4TTMgMzFoMjQiIC8+Cjwvc3ZnPg==

This is an SVG file in base 64 format, once you copy this entire string and paste it into a new browser window, it will automatically resolve into an image.

1 Like

I like this! And I think a base64 image data image string nested in a JSON file would be the way to go, or at least the easiest to implement. As far as I can tell this doesn’t need to be a contract change. If we just check the protocol format (https:// or solana://) and use a getAccount instead of a fetch in the Metaplex JS SDK then it should work perfectly. Both already return buffers so interpretation would work the same way.

2 Likes

Appreciate the comment, what would the JSON file look like? It will be directly stored in a solana account as a json variable?

Then inside the JSON, can we put another solana address for the image link? or should that be the image string in data URL format directly?

That way we can reduce the complexity to 1 solana account but limit the image size further.

2 Likes

It would just be the standard JSON metadata. All Metaplex NFTs have an on-chain URI which points to the JSON file, which points to the image URI. We’d just be replacing the image URI with a data:image string instead of a pointer to another account. You don’t even need a special program or anything, you just encode the JSON bytes directly in the Solana account.

2 Likes

How do you do this? Have not seen anything in solana documents :joy: all the tutorials I can find are how to put a counter variable on-chain lmao. It took a lot of testing to even get a string working :sweat_smile:

2 Likes

I guess you’d still have to write a program to do the actual write, but strings are just series of bytes so when you write the account (assuming non-anchor) you shove them directly into the account info data.

1 Like

Okay doing some testing to store the json object, on-chain. First, we create a suitable JSON that has no off-chain or exterior links.

{"name":"Blockrons 020","symbol":"BLKRNS","description":"Each Blockrons NFT has its art asset stored directly on-chain in an image container address. \n\nBlockrons 020 - \"Nomad\" \n\nImage container address: 4kHmoHh6FYp4ae8a3Nj5N64hbs2hB2AGfuhraCaNrGg7","seller_fee_basis_points":0,"image":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgLTAuNSAzMiAzMiIgc2hhcGUtcmVuZGVyaW5nPSJjcmlzcEVkZ2VzIj4KPG1ldGFkYXRhPkJsb2Nrcm9ucyAwMjA8L21ldGFkYXRhPgo8cGF0aCBzdHJva2U9IiM1NTcwNjQiIGQ9Ik0wIDBoMzJNMCAxaDMyTTAgMmgzMk0wIDNoMzJNMCA0aDMyTTAgNWgzMk0wIDZoMTNNMjMgNmg5TTAgN2gxMU0yNCA3aDhNMCA4aDEwTTI1IDhoN00wIDloMTBNMjUgOWg3TTAgMTBoMTBNMjUgMTBoN00wIDExaDlNMjUgMTFoN00wIDEyaDlNMjUgMTJoN00wIDEzaDlNMjUgMTNoN00wIDE0aDlNMjUgMTRoN00wIDE1aDlNMjUgMTVoN00wIDE2aDlNMjUgMTZoN00wIDE3aDlNMjUgMTdoN00wIDE4aDlNMjQgMThoOE0wIDE5aDlNMjQgMTloOE0wIDIwaDlNMjQgMjBoOE0wIDIxaDlNMjQgMjFoOE0wIDIyaDlNMjQgMjJoOE0wIDIzaDlNMjQgMjNoOE0wIDI0aDlNMjQgMjRoOE0wIDI1aDlNMjQgMjVoOE0wIDI2aDdNMjQgMjZoOE0wIDI3aDRNMjYgMjdoNk0wIDI4aDJNMjggMjhoNE0wIDI5aDFNMjkgMjloM00wIDMwaDFNMjkgMzBoM00wIDMxaDFNMjkgMzFoMyIgLz4KPHBhdGggc3Ryb2tlPSIjMzkyYzIwIiBkPSJNMTMgNmgzTTExIDdoN00xMCAxMGgyTTkgMTFoMk05IDEyaDFNOSAxM2gxTTkgMTRoMU05IDE1aDFNOSAxNmgxTTkgMTdoMU05IDE4aDFNOSAxOWgxTTkgMjBoMk05IDIxaDJNOSAyMmgyTTkgMjNoMk05IDI0aDJNOSAyNWgzTTkgMjZoM00xMCAyN2gzTTQgMjhoMU0xMSAyOGgzTTIgMjloMU05IDI5aDFNMTIgMjloMTBNMjQgMjloMk0yIDMwaDFNNCAzMGg3TTIzIDMwaDRNMiAzMWgyNSIgLz4KPHBhdGggc3Ryb2tlPSIjNTAzODJlIiBkPSJNMTYgNmg3TTE4IDdoNk0xMiAxMGg0TTIzIDEwaDJNMTEgMTFoMk0yNCAxMWgxTTEwIDEyaDJNMTAgMTNoMU0xMCAxNGgxTTEwIDE1aDJNMTAgMTZoMk0xMCAxN2gyTTEwIDE4aDJNMTAgMTloMk0xMSAyMGgxTTExIDIxaDFNMTEgMjJoMU0xMSAyM2gxTTIzIDIzaDFNMTEgMjRoMk0yMyAyNGgxTTEyIDI1aDFNMjMgMjVoMU0xMiAyNmgzTTIyIDI2aDJNNyAyN2gzTTEzIDI3aDRNMjEgMjdoM00xNCAyOGg5TTI1IDI4aDFNMyAyOWgzTTI2IDI5aDJNMyAzMGgxTTI3IDMwaDFNMjcgMzFoMSIgLz4KPHBhdGggc3Ryb2tlPSIjMzEwYTA4IiBkPSJNMTAgOGg1TTIzIDhoMk0xMCA5aDNNMjQgOWgxIiAvPgo8cGF0aCBzdHJva2U9IiM0ZjE4MTQiIGQ9Ik0xNSA4aDhNMTMgOWgxMSIgLz4KPHBhdGggc3Ryb2tlPSIjOGY3ODYzIiBkPSJNMTYgMTBoN00xMyAxMWgxMU0xMiAxMmgyTTExIDEzaDJNMTEgMTRoMk0xMiAxNWgxTTEyIDE2aDFNMTIgMTdoMU0xMiAxOGgxTTEyIDE5aDFNMTIgMjBoMU0xMiAyMWgxTTEyIDIyaDFNMTIgMjNoMU0xMyAyNGgxTTIyIDI0aDFNMTMgMjVoMTBNMTUgMjZoN00xNyAyN2g0IiAvPgo8cGF0aCBzdHJva2U9IiNhNmE2YTYiIGQ9Ik0xNCAxMmgxME0xMyAxM2gyTTE4IDEzaDJNMjEgMTNoMU0xMyAxNGgyTTE4IDE0aDJNMjEgMTRoMU0xMyAxNWg3TTIxIDE1aDNNMTMgMTZoN00yMSAxNmgyTTE0IDE3aDVNMjEgMTdoMk0xNCAxOGg5TTE1IDE5aDhNMTUgMjBoMk0yMiAyMGgxTTE2IDIxaDZNMTYgMjJoNiIgLz4KPHBhdGggc3Ryb2tlPSIjMTkxODE4IiBkPSJNMjQgMTJoMU0xNSAxM2gzTTIwIDEzaDFNMjIgMTNoM00xNSAxNGgxTTE3IDE0aDFNMjAgMTRoMU0yMiAxNGgxTTI0IDE0aDFNMjAgMTVoMU0yNCAxNWgxTTIwIDE2aDFNMjQgMTZoMU0xOSAxN2gyTTI0IDE3aDFNMjMgMThoMU0yMyAxOWgxTTEzIDIwaDFNMTcgMjBoNU0yMyAyMGgxTTEzIDIxaDFNMjMgMjFoMU0xMyAyMmgyTTIzIDIyaDFNMTQgMjNoOU0xOSAyNGgxTTcgMjZoMk00IDI3aDNNMjQgMjdoMk0yIDI4aDJNMjYgMjhoMk0xIDI5aDFNMjggMjloMU0xIDMwaDFNMjggMzBoMU0xIDMxaDFNMjggMzFoMSIgLz4KPHBhdGggc3Ryb2tlPSIjZmZmZmZmIiBkPSJNMTYgMTRoMU0yMyAxNGgxIiAvPgo8cGF0aCBzdHJva2U9IiM4ODg4ODgiIGQ9Ik0yMyAxNmgxTTEzIDE3aDFNMjMgMTdoMU0xMyAxOGgxTTE0IDE5aDFNMTQgMjBoMU0xNSAyMWgxTTIyIDIxaDFNMTUgMjJoMU0yMiAyMmgxIiAvPgo8cGF0aCBzdHJva2U9IiM2MTYxNjEiIGQ9Ik0xMyAxOWgxTTE0IDIxaDEiIC8+CjxwYXRoIHN0cm9rZT0iIzQ0NDQ0NCIgZD0iTTEzIDIzaDFNMTQgMjRoNSIgLz4KPHBhdGggc3Ryb2tlPSIjNjg2ODY4IiBkPSJNMjAgMjRoMiIgLz4KPHBhdGggc3Ryb2tlPSIjNDA0MDQwIiBkPSJNNSAyOGg2TTIzIDI4aDJNNiAyOWgzTTEwIDI5aDJNMjIgMjloMk0xMSAzMGgxMiIgLz4KPC9zdmc+","attributes":[],"properties":{"creators":[{"address":"F1QyW2RiabaUTHYYMZs6kVQmjw3QzhRWtAJNUp6ifWAe","share":100}]}}

Above is the json file used by the Blockrons 020 nft, solscan here: Solscan. We can successfully use url-data formatted base64 svg code as an image instead of a file link - it is read normally by browsers and wallets.

Next step is to put this json on-chain and make it readable. Lemme try by storing it as a basic string if that works, should still be readable on buffer

2 Likes

Okay stored the whole JSON as a string into this pda account:

4kHmoHh6FYp4ae8a3Nj5N64hbs2hB2AGfuhraCaNrGg7

On typescript clients, if you use connection.getAccountInfo(key), it should be able to show up in the read buffer data.

Would this work with:

?

2 Likes