Build Your First Blockchain App in 15 min with Sui & Typescript

Build Your First Blockchain App in 15 min with Sui & Typescript

(experienced web dev edition)

For more beginner-friendly and wordier version, click here.

Ahh, blockchain. The word that strikes fear into the hearts of many a developer.

Let's be honest - almost none of us really know how the heck they work. Crypto this, node that, hash my address (daddy). What even ARE validators? Validate deez nuts!

Well, lucky for you and I, this ain't 2017 no more. The chains themselves may be as complicated as ever, but the tooling around them has matured dramatically. Let me show you how easy it is to build your first blockchain app on one of the newer and faster chains, Sui ('sweet', the t is silent).

Our final app will show coin balances for any given wallet, like so:

The Stack

We'll be using HTMX + Typescript (running on bunjs). There's also a dapp-kit Sui provides for use in React apps.

  1. Install bun (you can use Node on Windows, just replace Elysia with Express):
curl -fsSL https://bun.sh/install | bash
  1. Go to the parent folder of the would-be project in terminal.

  2. Create project scaffolding using bun create and install dependencies:

bun create elysia sui-first-bun
cd sui-first-bun
bun add @elysiajs/html @mysten/sui.js

The scaffolding is minimal but saves us a bit of typing. The two dependencies are Elysia's html extension and Sui's JS library. We're ready to code!

The Base

  1. open project

You should see this structure:

In a terminal, run bun dev. You should see:

  1. configure

    - rename index.ts file to index.tsx. Update the name also on line 6 of package.json file.

    - in tsconfig.json, add these 3 lines under compilerOptions:

  • in index.tsx, at the top add these 2 lines:

      import { html } from "@elysiajs/html";
      import * as Sui from "@mysten/sui.js/client";
    
  • finally, add .use(html()) to Elysia server declaration:

      const app = new Elysia()
        .use(html())
        .get("/", () => "Hello Elysia")
        .listen(3000);
    

Check that the page still works at localhost:3000.

  1. add base html

Add new file in the src folder named BaseHTML.tsx with this code:

export default function BaseHTML() {
  return (
    <html lang="en">
      <head>
      <script
      src="https://unpkg.com/htmx.org@1.9.10"
      integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
      crossorigin="anonymous"></script>
      <script src="https://cdn.tailwindcss.com"></script>
      <title>My First Sui-t Bun</title>
      </head>
      <body>
        <div class="flex justify-center p-4">
          <form hx-post="/coins" hx-target="#results">
            <input name="address" type="text" class="border pl-1 pr-1"></input>
            <button type="submit" class="ml-4 border pl-2 pr-2">Check if Chose Rich</button>
          </form>
        </div>
        <div id="results" class="flex justify-center"></div>
      </body>
    </html>
  )
}

(The most important line, of course, is the text between <title> tags - a 10/10 pun ๐Ÿ˜Š)

If you haven't used HTMX before, the cape-wearing heroes here are the two keywords hx-post and hx-target. The first one sends the form to a /coins address on the web server, and the latter tells htmx to paste w/e comes back from the server into a div with id 'results'.

To send our new html code back to the client, in index.tsx replace the line .get("/", () => "Hello Elysia") with:

.get("/", () => BaseHTML())

Add a corresponding import at the top of the file:

import BaseHTML from "./BaseHTML";

Save, refresh browser page and you should see:

Calling Sui Blockchain

Let's tell the fox (Elysia) what she must do when someone tries to go to /coins. Insert the following line in-between .get and .listen lines:

.post("/coins", ({ body }) => balances(body))

The full block should look like so:

const app = new Elysia()
  .use(html())
  .get("/", () => BaseHTML())
  .post("/coins", ({ body }) => balances(body))
  .listen(3000);

"body" is a special keyword used with Elysia to represent the data received from the client. We're asking the web server to "plz pass the body of the POST to our function, balances. Thank you." Never forget to say 'thank you' even when talking to a program. Especially when talking to a program. Ahem hi AI ahem.

Let's define balances() function. At the bottom of index.tsx, add:

function balances(body: any) {
  const addy = body.address;

  if (!addy || addy === "") {
    return <div>Plz enter wallet addy above</div>;
  }

  return (
    <div class="grid grid-cols-[50px_60px_150px] text-middle">
      <div class="col-span-3">{addy}</div>
    </div>
  );
}

Pretty straightforward.

It's time to actually use the Sui JS wrapper. Right above the function balances(...) line, insert this line:

const client = new Sui.SuiClient({ url: Sui.getFullnodeUrl("mainnet") });

Now back inside balances() function, after the if that checks if address was provided, let's read the blockchain!

const coins = await client.getAllBalances({ owner: addy });

You'll see that "await" is now underlined. This is because the balances() function wasn't defined as asynchronous, and all Sui functions are defined as Promises.

Fix this by adding "async" in front of function balances:

async function balances(body: any) {

Let's take care of the scenario where the wallet has no coins. After the call to getAllBalances, add:


  if (!coins || coins.length === 0) {
    return <div>No coins in this wallet. They didn't choose rich.</div>;
  }

If we do get coin balances back, let's include them in the HTML we send back to the client. Replace <div class="col-span-3">{addy}</div> with:

{coins.map(c => <><div class="col-span-2">{c.coinType}</div><div>{c.totalBalance}</div></>)}

Refresh the page and provide a wallet address. You should get smth like:

getAllBalances() does not return back metadata for each coin, such as the symbol ($SUI, etc.) or how many decimal places the coin is using. So, for SUI, we're actually seeing the MIST balance, not SUI balance. I.e., my wallet (seen here) has 4.35 SUI, not 4 billion SUI. Unfortunately ๐Ÿ˜…. And the long number in front of ::fud::FUD is basically the address of the contract that deployed the coin.

Wrapping Up

We need to make another call for each coin we get back to get that coin's metadata - the symbol, url to icon/image (if any), etc.

There are many ways to skin this fox; here I picked a fast and dirty one.

Let's first define a type for our view model. Insert this somewhere outside the body of function balances():

type coin_balance = {
  symbol: string,
  url?: string | null,
  balance: string
}

Now back inside the balances() function, right above the final "return" statement, add this code:

let coin_balances: coin_balance[] = []
for (let coin of coins) { 
  const meta = await client.getCoinMetadata({ coinType: coin.coinType });

  if (!meta) {     
    coin_balances.push({ symbol: coin.coinType, url: undefined, balance: coin.totalBalance });
  } else {
    const balance = Number(coin.totalBalance) / Math.pow(10, meta.decimals);
    coin_balances.push({ symbol: meta.symbol, url: meta.iconUrl, balance: String(balance)});
  }
}

Mostly self-explanatory.

By dividing the totalBalance by 10 to the power of decimals value we get from the metadata, we get the real coin amount (as far as I understand). I.e., my 4 billion SUI balance becomes the more realistic 4.35 SUI.

Now we need to display this data. Replace the {coins.map...} line inside the return statement with this:

{coin_balances.map(coin_row)}

And finally, add the coin_row() function:

function coin_row(cb: coin_balance) {
  const img = cb.url ? <img src={cb.url} class="ml-2 w-6 h-6"></img> : <></>;
  return <><div>{img}</div><div>{cb.symbol}:</div> <div>{cb.balance}</div></>;
}

Save, go back to your browser and refresh, enter a wallet address, and vuala:

I'm up 5% on that dog coin today! Which is... checks notes... $0.08. NICE! ๐Ÿ˜ญ

And that's a wrap! You can try some Sui wallets from top 100 list to see what coins they hold.

Thank you for reading my first blog about Sui! I love you!

Bun and Elysia wave you goodbye ๐ŸฅŸ๐ŸฆŠ๐Ÿ‘‹๐Ÿผ

Follow me on X if you're mad.


If you'd like to double-check the code, your final index.tsx should look like this. You can also download the source code from github.

import { Elysia } from "elysia";
import { html } from "@elysiajs/html";
import * as Sui from "@mysten/sui.js/client";
import BaseHTML from "./BaseHTML";

const app = new Elysia()
  .use(html())
  .get("/", () => BaseHTML())
  .post("/coins", ({ body }) => balances(body))
  .listen(3000);

console.log(
  `๐ŸฆŠ Elysia is running at ${app.server?.hostname}:${app.server?.port}`
);

type coin_balance = {
  symbol: string,
  url?: string | null,
  balance: string
}

const client = new Sui.SuiClient({ url: Sui.getFullnodeUrl("mainnet") });

async function balances(body: any) {
  const addy = body.address;

  if (!addy || addy === "") {
    return <div>Plz enter wallet addy above</div>;
  }

  const coins = await client.getAllBalances({ owner: addy });

  if (!coins || coins.length === 0) {
    return <div>No coins in this wallet. They didn't choose rich.</div>;
  }

  let coin_balances: coin_balance[] = []
  for (let coin of coins) {
    const meta = await client.getCoinMetadata({ coinType: coin.coinType });

    if (!meta) {
      coin_balances.push({ symbol: coin.coinType, url: undefined, balance: coin.totalBalance });
    } else {
      const balance = Number(coin.totalBalance) / Math.pow(10, meta.decimals);
      coin_balances.push({ symbol: meta.symbol, url: meta.iconUrl, balance: String(balance)});
    }
  }

  return (
    <div class="grid grid-cols-[50px_60px_150px] text-middle">
      {coin_balances.map(coin_row)}
    </div>
  );
}

function coin_row(cb: coin_balance) {
  const img = cb.url ? <img src={cb.url} class="ml-2 w-6 h-6"></img> : <></>;
  return <><div>{img}</div><div>{cb.symbol}:</div> <div>{cb.balance}</div></>;
}
ย