gist January 2, 2025

Irys in SvelteKit

Upload files on-chain using Irys and Sveltekit. +layout.svelte
<script lang="ts">
    import {
      WalletModal,
      getShowConnectWallet,
      getWallets,
      hideConnectWallet,
      init,
      localStorageKey,
      network,
      walletStore
    } from "$lib/wallets";

    import {
      ConnectionProvider,
      WalletProvider,
    } from "@aztemi/svelte-on-solana-wallet-adapter-ui";

    import { onMount } from "svelte";
    
    let { children } = $props();
    let showConnectWallet = $derived(getShowConnectWallet());
    
    const wallets = $derived.by(getWallets);
    
    const onConnect = async (event: { detail: string }) => {
        await $walletStore.select(event.detail);
        await $walletStore.connect();
        hideConnectWallet();
    };
    
    onMount(() => {
        init();
    });
</script>

<ConnectionProvider {network} />
<WalletProvider {localStorageKey} {wallets} autoConnect={true} />

{#if showConnectWallet}
    <WalletModal
        maxNumberOfWallets={20}
        on:close={hideConnectWallet}
        on:connect={onConnect}
    />
{/if}

{@render children()}

irys.ts

import { WebUploader } from "@irys/web-upload";
import { WebSolana } from "@irys/web-upload-solana";
import type { Irys, UploadInput, UploadResponse } from "./types";

export const irysGateway = (dev: boolean) =>
  dev ? "https://devnet.irys.xyz" : "https://gateway.irys.xyz";

export const fileToDataUri = (file?: File): Promise<string | null> =>
  new Promise((resolve, reject) => {
    if (!file) return reject("No file selected");

    const reader = new FileReader();
    reader.onload = (e) => {
      resolve(e.target?.result as string);
    };
    reader.readAsDataURL(file);
  });

export const bufferFromFile = (file: File): Promise<Buffer> =>
  new Promise((resolve, reject) => {
    if (!file) return reject("No file selected");

    const reader = new FileReader();

    reader.readAsArrayBuffer(file);
    reader.onload = async (event) => {
      const arrayBuffer = event.target?.result as ArrayBuffer;
      const uint8Array = new Uint8Array(arrayBuffer);
      resolve(Buffer.from(uint8Array));
    };
  });

export const getIrys = async (
  adapter: any,
  rpcUrl: string,
  devnet: boolean
) => {
  try {
    let irysUploader;
    if (devnet) {
      irysUploader = await WebUploader(WebSolana)
        .withProvider(adapter)
        .withRpc(rpcUrl)
        .devnet();
    } else {
      irysUploader = await WebUploader(WebSolana).withProvider(adapter);
    }

    return irysUploader;
  } catch (error) {
    console.error("Error connecting to Irys:", error);
    throw new Error("Error connecting to Irys");
  }
};

export const fundIrysBalance = async (amount: number, irys: Irys) => {
  const fundTx = await irys.fund(irys.utils.toAtomic(amount));
  const response = await irys.funder.submitFundTransaction(fundTx.id);

  return response;
};

export const getIrysBalance = async (irys: Irys) => {
  // Get loaded balance in atomic units
  const atomicBalance = await irys.getBalance();
  // Convert balance to standard
  const convertedBalance = irys.utils.fromAtomic(atomicBalance);

  return Number(convertedBalance);
};

export const uploadToIrys = async (
  input: UploadInput,
  irys: Irys
): Promise<UploadResponse> => {
  const buffer = await bufferFromFile(input.file);
  const upload = await irys.upload(buffer, {
    tags: [
      {
        name: "sol-directory-media",
        value: input.type,
      },
    ],
  });

  return {
    id: upload.id,
    uri: `${irysGateway(input.dev)}/${upload.id}`,
    upload_transaction: upload.signature,
    formatting: input.type,
    type: input.type,
  };
};

media.svelte

<script lang="ts">
    import { dev } from "$app/environment";
    import Button from "$lib/components/ui/button/button.svelte";
    import * as Tabs from "$lib/components/ui/tabs/index.js";
    import { walletStore } from "$lib/wallets";
    import ConnectWallet from "$lib/wallets/connect.svelte";
    import type { IrysMedia, Media } from "@repo/sdk";
    import { CheckIcon, Images, Link, Loader2, Loader2Icon, PlusCircle, Upload, User, Wallet } from "lucide-svelte";
    import type { ReadableQuery } from "svelte-apollo";
    import { toast } from "svelte-sonner";
    import { tweened } from "svelte/motion";
    import Input from "../ui/input/input.svelte";
    import Item from "./item.svelte";
    import { media } from "./media.svelte";
    import SelectFile from "./select-file.svelte";

    const animatedBalance = tweened(0, { duration: 1000 });

    const initMedia = async () => {
      await media.init($walletStore);
      await media.getMedia();
      await media.getBalance();
    }

    // Initialize UserMedia when wallet is connected/changed
    $effect(() => {
      if ($walletStore?.publicKey) initMedia();
    });

    let needsFunding = $derived(typeof media.balance.data === "number" && media.balance.data < 0.0003);
    
  
    let selectedMedia = $state<IrysMedia | null>(null);
    let showAllMedia = $state(false);

    let mediaItems = $derived((showAllMedia ? media.media.data : media.media.data?.slice(0, 6) || []) as IrysMedia[]);

    const handleUpload = async () => {
      if (!media.selectedFile.data?.file) return;
      toast(`Preparing upload transaction, check wallet...`);
      await media.uploadMedia({
        file: media.selectedFile.data.file,
        type: "avatar",
        formatting: "",
      });
      toast(`Upload success!`);
      await media.getMedia();
    }

    const handleFund = async () => {
      try {
        const amount = 0.005;
        toast(`Preparing funding transaction, check wallet...`);
        await media.fundBalance(amount);
        toast(`Success! Added ${amount} SOL to your account.`);
      } catch (error) {
        console.error(error);
        toast(`Error adding funds, try again.`);
      }
    }

    const handleSetAvatar = async () => {
      try {
        if(!selectedMedia?.uri) return;
        await media.setAvatar(selectedMedia?.id);
        toast(`Success! Your avatar has been updated.`);
      } catch (error) {
        console.error(error);
        toast(`Error setting avatar, try again.`);
      }
    }
</script>

media.svelte.ts

import { dev } from "$app/environment";
import { PUBLIC_API_URL, PUBLIC_RPC_URL } from "$env/static/public";
import type {
  Irys,
  IrysMedia,
  MediaType,
  State,
  UploadResponse,
} from "./types";

import {
  irysGateway,
  fundIrysBalance,
  getIrys,
  getIrysBalance,
  uploadToIrys,
} from "./util";

class UserMedia {
  public irys: Irys | null = null;
  public ready: boolean = false;
  public showMediaManager: boolean = $state(false);
  public selectedFile: State<{
    file: File | null;
    dataUri: string | null;
  }> = $state({
    data: {
      file: null,
      dataUri: null,
    },
    error: null,
    loading: false,
    success: false,
  });
  public media: State<IrysMedia[]> = $state({
    data: [],
    error: null,
    loading: false,
    success: false,
  });
  public upload: State<UploadResponse> = $state({
    data: null,
    error: null,
    loading: false,
    success: false,
  });
  public balance: State<number | null> = $state({
    data: null,
    error: null,
    loading: false,
    success: false,
  });
  public fund: State<string> = $state({
    data: "",
    error: null,
    loading: false,
    success: false,
  });

  constructor() {}

  // Accepts $walletStore from Solana Svelte Wallet adapter
  public async init(adapter: any) {
    const irys = await getIrys(adapter, PUBLIC_RPC_URL, dev);
    this.irys = irys;
    this.ready = true;
  }

  public async getMedia() {
    try {
      this.media.loading = true;
      const getMediaQuery = `
        query getMedia {
          transactions(
            owners: ["${this.irys?.address}"]
            tags: [{ name: "sol-directory-media", values: ["avatar"] }]
          ) {
            edges {
              node {
                id
                address
              }
            }
          }
        }
      `;

      const response = await fetch(irysGateway(dev) + "/graphql", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ query: getMediaQuery }),
      });

      const data = await response.json();

      const formattedMedia = data.data.transactions.edges
        .map(({ node }: { node: { id: string } }) => ({
          id: node.id,
          uri: `${irysGateway(dev)}/${node.id}`,
        }))
        .reverse();

      this.media.data = formattedMedia ?? [];
      this.media.success = true;
    } catch (error) {
      this.media.error = error as Error;
      console.error("Error fetching user media:", error);
    } finally {
      this.media.loading = false;
    }
  }

  public async getBalance() {
    this.balance.loading = true;
    try {
      if (!this.irys) throw new Error("Irys not initialized");

      const balance = await getIrysBalance(this.irys);
      this.balance.data = balance;
      this.balance.success = true;
    } catch (error) {
      this.balance.error = error as Error;
      console.error("Error fetching balance:", error);
    } finally {
      this.balance.loading = false;
    }
  }

  // ~ $1.50 with SOL at $230
  public async fundBalance(amount: number = 0.005) {
    try {
      console.log("GOT HERE", this.irys);
      if (!this.irys) throw new Error("Irys not initialized");

      this.fund.loading = true;
      const response = await fundIrysBalance(amount, this.irys);
      console.log(response);
      this.getBalance();
      this.fund.success = true;
    } catch (error) {
      this.fund.error = error as Error;
      console.error("Error funding balance:", error);
    } finally {
      this.fund.loading = false;
    }
  }

  public async uploadMedia(input: {
    file: File;
    type: MediaType;
    formatting: string;
  }) {
    try {
      if (!this.irys) throw new Error("Irys not initialized");

      this.upload.loading = true;

      // Upload file to Irys
      const uploaded = await uploadToIrys(
        {
          file: input.file,
          type: input.type,
          formatting: input.formatting,
          dev,
        },
        this.irys
      );

      const response = await fetch(`${PUBLIC_API_URL}/user/media/create`, {
        credentials: "include",
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          uri: uploaded.uri,
          onchain_id: uploaded.id,
          upload_transaction: uploaded.upload_transaction,
          formatting: input.formatting,
          type: input.type,
        }),
      });

      this.upload.data = uploaded;
      this.upload.loading = false;
      this.upload.success = true;
    } catch (error) {
      this.upload.error = error as Error;
      console.error("Error uploading media:", error);
    } finally {
      this.upload.loading = false;
    }
  }
}

export const media = new UserMedia();

types.ts

const media = {
  avatar: {},
};

import { getIrys } from "./media";

export type MediaType = keyof typeof media;

export type IrysMedia = {
  id: string;
  uri: string;
};

export type Media = {
  id: string;
  uri: string;
  user_id: string;
  type: MediaType;
  created_at: Date;
  formatting: string;
  upload_transaction: string;
};

export type UploadResponse = Omit<Media, "created_at" | "user_id">;
export type Irys = Awaited<ReturnType<typeof getIrys>>;

export type State<T> = {
  data: T | null;
  error: Error | null;
  success: boolean;
  loading: boolean;
};

export type UploadInput = {
  file: File;
  type: MediaType;
  formatting: string;
  dev: boolean;
};

export type CreateMediaResponse = {
  uri: string;
  id: string;
  formatting: string;
} | null;

wallet.ts

// Svelte 5-afied Svelte on Solana wallet adapter
// This needs to be refactored/improved since I learned a bunch of new Svelte 5 things.
import { dasApi } from "@metaplex-foundation/digital-asset-standard-api";
import { mplTokenMetadata } from "@metaplex-foundation/mpl-token-metadata";
import {
  walletAdapterIdentity,
  type WalletAdapter,
} from "@metaplex-foundation/umi-signer-wallet-adapters";
import { irysUploader } from "@metaplex-foundation/umi-uploader-irys";
import { BaseMessageSignerWalletAdapter } from "@solana/wallet-adapter-base";

// @ts-expect-error
import { walletStore } from "@aztemi/svelte-on-solana-wallet-adapter-core";

// Set supported wallets
let _wallets: BaseMessageSignerWalletAdapter[] = $state([]);
export const init = async () => {
  const { PhantomWalletAdapter } = await import(
    "@solana/wallet-adapter-wallets"
  );

  _wallets = [new PhantomWalletAdapter()];
};
export const getWallets = () => _wallets;

// Adapt walletStore to svelte 5
let _adapter = $state<{ adapter: WalletAdapter }>();
walletStore.subscribe(
  ($value: BaseMessageSignerWalletAdapter & { adapter: WalletAdapter }) => {
    _adapter = $value;
  }
);
export const getAdapter = () => _adapter;

let _showModal = $state<boolean>(false);
export const getShowConnectWallet = () => _showModal;
export const showConnectWallet = () => (_showModal = true);
export const hideConnectWallet = () => (_showModal = false);

// Create new umi instance with wallet adapter
export const createUmi = async (rpc: string) => {
  if (!_adapter) return;

  const { createUmi } = await import(
    "@metaplex-foundation/umi-bundle-defaults"
  );

  const umi = createUmi(rpc)
    .use(walletAdapterIdentity(_adapter.adapter))
    .use(mplTokenMetadata())
    .use(irysUploader())
    .use(dasApi());

  return umi;
};

Get in touch