Skip to main content

Build daily check-in using Ronin Waypoint

Overview

This tutorial shows you how to build a daily check-in feature using Ronin Waypoint. Daily check-ins help game developers reward users for daily activity and support game studios in creating on-chain traction.

By completing this tutorial, you'll have a simple Next.js web app that sends transactions to Ronin and interacts with a daily check-in smart contract. With this app, users can do the following:

  • Sign in using either an email and password or social login.
  • Create a keyless wallet powered by MPC (multi-party computation) technology.
  • Send a check-in transaction to the smart contract on the Saigon testnet.

The source code for this tutorial is available in the GitHub repository.

Steps

Step 1. Create an app

If you don't have a web app yet, you can create a new one using the following steps:

  1. Install Node.js and npm from the official website. After installation, verify the versions by running the following commands in your terminal:

    node -v
    npm -v
  2. Initialize a new web app from a template:

    npx create-next-app@latest
  3. On installation, answer a series of questions as follows:

    What is your project named? example-daily-checkin
    Would you like to use TypeScript? Yes
    Would you like to use ESLint? Yes
    Would you like to use Tailwind CSS? Yes
    Would you like to use `src/` directory? No
    Would you like to use App Router? (recommended) Yes
    Would you like to customize the default import alias (@/*)? Yes
    What import alias would you like configured? @/*
  4. Start the development server by running the following command:

    npm run dev
  5. In your browser, open your app at http://localhost:3000:

Step 2. Install packages

The two packages you need for this tutorial:

  • @sky-mavis/waypoint: a package for integration with Ronin Waypoint. Offers RoninWaypointWallet, an EIP-1193-compatible Ethereum JavaScript provider that you can use with the standard Ethereum libraries, such as Ethers.js, web3.js, or viem to interact with the blockchain.
  • viem: our recommendation for a lightweight, composable, and type-safe Ethereum interface that supports TypeScript and offers clear documentation with examples.

To install the packages, run the following commands:

npm i @sky-mavis/waypoint
npm i viem

After that, your package.json file should look similar to this:

package.json
{
"name": "example-daily-checkin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@sky-mavis/waypoint": "workspace:*",
"next": "14.2.5",
"react": "^18",
"react-dom": "^18",
"viem": "2.9.2"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.5",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

Step 3. Get your client ID

  1. Go to the Developer Console.

  2. Create your Sky Mavis Account or log in if you already have one.

  3. Click Create application and fill in your app's name:

  4. Go to your app's details and fill in the required information:

    • Website URL: the URL of your web app.
    • User Terms Policy: the URL of your user terms policy.
    • Description: a brief description of your app.
    • App Logo: a 300x300-pixel logo for your app.
  5. Go to the App Permission tab and request access to the Sky Mavis Account (OAuth 2.0) service.

  6. Wait for approval from the Sky Mavis team.

  7. Configure the general settings:

    • Redirect URI: the URI to redirect the user to after logging in.
    • Origin URI: the URI of your web app.
  8. Copy your app's client ID.

Step 4. Set up a provider

  1. In the app directory, create a new file named web3-client.ts.

  2. Import RoninWaypointWallet from the SDK:

    web3-client.ts
    import { RoninWaypointWallet } from "@sky-mavis/waypoint"
  3. Create a new wallet provider with the RoninWaypointWallet.create method:

    web3-client.ts
    import { RoninWaypointWallet } from "@sky-mavis/waypoint"
    import { saigon } from "viem/chains";

    const idWalletProvider = RoninWaypointWallet.create({
    chainId: saigon.id,
    clientId: "0e188f93-b419-4b0f-8df4-0f976da91ee6", // Replace with your client ID
    })
  4. Wrap idWalletProvider with viem:

    web3-client.ts
    import { RoninWaypointWallet } from "@sky-mavis/waypoint"
    import { createPublicClient, createWalletClient, custom, http } from "viem"
    import { saigon } from "viem/chains"

    const idWalletProvider = RoninWaypointWallet.create({
    chainId: saigon.id,
    clientId: "0e188f93-b419-4b0f-8df4-0f976da91ee6", // Replace with your client ID
    })

    export const web3WalletClient = createWalletClient({
    transport: custom(idWalletProvider),
    chain: saigon,
    })

    export const web3PublicClient = createPublicClient({
    transport: http(),
    chain: saigon,
    })

In the preceding code snippet, you can see two client interfaces:

  • The PublicClient interface reads data from the blockchain. Use this client for actions that don't require signing or the wallet.
  • The WalletClient interface interacts with the user's wallet. Use this client for actions that require signing, such as sending transactions and sign requests.

Step 5. Add button to connect wallet

  1. Open the page.tsx file in the app directory.

  2. Remove the redundant code of the Next.js template:

    page.tsx
    export default function Home () {
    return <main className="flex min-h-screen flex-col items-start gap-4 p-24"></main>
    }
  3. Create a button for users to log in and connect a wallet:

    page.tsx
    <button
    className="px-4 py-3 font-bold text-white bg-blue-600 hover:bg-blue-700 rounded-xl"
    onClick={handleConnectWallet}
    >
    Login by Ronin Waypoint
    </button>
  4. Add states to manage the user's account and any login errors:

    page.tsx
    const [account, setAccount] = useState<Address>()
    const [error, setError] = useState<string>()
  5. Add a function to manage the wallet connection:

    page.tsx
    const handleConnectWallet = async () => {
    try {
    // This function retrieves the wallet address from a user's account, so we use WalletClient.
    // The requestAddresses method helps us get connected addresses from Ronin Waypoint.
    const newAccounts = await web3WalletClient.requestAddresses()

    // Ronin Waypoint always returns an array with one element: the user's address.
    if (newAccounts.length) {
    setAccount(newAccounts[0])
    setError(undefined)
    return
    }

    setError("Could not get account from wallet. Check your console for further details.")
    } catch (error) {
    console.log(error)
    setAccount(undefined)
    setError("User rejected wallet connection. Check your console for further details.")
    }
    }
  6. Add a UI component to display the user's address and login error:

    page.tsx
    return (
    <main className="flex min-h-screen flex-col items-start gap-4 p-24">
    <button
    className="px-4 py-3 font-bold text-white bg-blue-600 hover:bg-blue-700 rounded-xl"
    onClick={handleConnectWallet}
    >
    Login by Ronin Waypoint
    </button>

    {account && (
    <div>
    <div className="mt-2 text-lg font-semibold text-gray-800">Welcome back!</div>
    <div className="mt-1 text-sm font-semibold tracking-tight text-gray-600">
    Login as: {account}
    </div>
    </div>
    )}
    {error && <p className="text-red-600">{error}</p>}
    </main>
    )

By the end of this step, you built the flow for connecting a wallet to your app. The UI should look similar to this:

Step 6. Reconnect wallet on page refresh

While your app can connect to a wallet, the connection doesn't persist after a page refresh. To handle this, you need to silently reconnect the wallet when the page mounts or loads.

  1. Open the page.tsx file in the app directory.

  2. Add a useEffect hook to handle the wallet connection when the page mounts:

    page.tsx
    useEffect(() => {
    const reconnectWallet = async () => {
    }

    reconnectWallet()
    }, [])
  3. Use getAddresses to get connected addresses from Ronin Waypoint without opening a popup:

    page.tsx
    useEffect(() => {
    const reconnectWallet = async () => {
    try {
    const connectedAccounts = await web3WalletClient.getAddresses()

    if (connectedAccounts.length) {
    setAccount(connectedAccounts[0])
    setError(undefined)
    return
    }
    } catch (error) {
    /* empty */
    }
    }

    reconnectWallet()
    }, [])

The eth_requestAccounts method is different from eth_accounts. The former is an EIP-1102 method that returns a promise, while the latter is an EIP-1193 method that returns an array of addresses. For more information, see the MetaMask documentation.

Step 7. Gather smart contract information

In this tutorial, we interact with a smart contract for a daily check-in on the Saigon testnet. You can find the contract deployed at the address 0x9cbc47af3d33aaafbb442cb951dd737a488d693a.

To interact with a smart contract, you need the following information:

  • The address of the contract.
  • The ABI (Application Binary Interface) of the contract, which is a JSON file that describes the functions, events, and errors of the contract.

This tutorial uses two functions:

  • isMissedCheckIn: checks if the user has missed a check-in. The function takes the user's address as input and returns a boolean. The ABI for this function is as follows:

    ABI for isMissedCheckIn
    {
    inputs: [
    {
    internalType: "address",
    name: "user",
    type: "address",
    },
    ],
    name: "isMissedCheckIn",
    outputs: [
    {
    internalType: "bool",
    name: "",
    type: "bool",
    },
    ],
    stateMutability: "view",
    type: "function",
    }
  • checkIn: checks in the user. The function takes the user's address as input and doesn't return anything. The ABI for this function is as follows:

    ABI for checkIn
    {
    inputs: [
    {
    internalType: "address",
    name: "to",
    type: "address",
    },
    ],
    name: "checkIn",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
    }
  1. In the app directory, create a new directory named check-in.

  2. Inside check-in, create a file named common.ts with the following code:

    common.ts
    import { getAddress } from "viem"

    export const CHECK_IN_ADDRESS = getAddress("0x9CBC47AF3d33aaAFBb442cB951DD737a488D693A")

    export const CHECK_IN_ABI = [
    {
    inputs: [
    {
    internalType: "address",
    name: "user",
    type: "address",
    },
    ],
    name: "isMissedCheckIn",
    outputs: [
    {
    internalType: "bool",
    name: "",
    type: "bool",
    },
    ],
    stateMutability: "view",
    type: "function",
    },
    {
    inputs: [
    {
    internalType: "address",
    name: "to",
    type: "address",
    },
    ],
    name: "checkIn",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
    },
    ] as const
  3. In the same directory, create a check-in.tsx file to contain the check-in UI:

    check-in.tsx
    import { FC, useState } from "react"
    import { Address } from "viem"

    type Props = {
    account: Address
    }
    export const CheckIn: FC<Props> = ({ account }) => {
    return (
    <div>
    Placeholder
    </div>
    )
    }
  4. Also there, create a file use-is-checked-in.ts to contain a hook for getting the check-in status of an address:

    use-is-checked-in.ts
    import { useState } from "react"
    import { Address } from "viem"

    export const useIsCheckedIn = (currentAddress: Address) => {
    const [missed, setMissed] = useState<boolean | undefined>()
    const [loading, setLoading] = useState<boolean>(false)

    return {
    data: !missed,
    loading,
    }
    }

Step 8. Get check-in status

  1. In the check-in directory, open the use-is-checked-in.ts file:

    use-is-checked-in.ts
    const fetchIsCheckedIn = async () => {
    setLoading(true)
    setMissed(undefined)

    try {
    // The isMissed function reads data from the blockchain, so we use PublicClient.
    // We just need to pass the function name and the user's address to get the check-in status.
    const isMissed = await web3PublicClient.readContract({
    address: CHECK_IN_ADDRESS,
    abi: CHECK_IN_ABI,
    functionName: "isMissedCheckIn",
    args: [currentAddress],
    })

    // Remember to use `setMissed` for data update and `setLoading` for loading state.
    setMissed(isMissed)
    setLoading(false)
    return
    } catch (error) {
    /* empty */
    }

    setMissed(undefined)
    setLoading(false)
    }
  2. Call fetchIsCheckedIn in the useEffect hook, returning fetchIsCheckedIn for refetching the status after a check-in:

    use-is-checked-in.ts
    useEffect(() => {
    fetchIsCheckedIn()
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [currentAddress])

    return {
    data: !missed,
    loading,
    refetch: fetchIsCheckedIn,
    }

Step 9. Create UI for check-in status

  1. In the check-in directory, open the check-in.tsx file.

  2. To get the check-in status, consume the useIsCheckedIn hook:

    check-in.tsx
    const { data: isCheckedIn, loading, refetch: refetchCheckedIn } = useIsCheckedIn(account)
  3. Display the status in the UI:

    check-in.tsx
    return (
    <div>
    <div className="mt-8 font-semibold tracking-wider">
    {loading ? (
    <div className="text-yellow-600">Loading...</div>
    ) : (
    <>
    {isCheckedIn ? (
    <div className="text-green-600">You are already checked in for today.</div>
    ) : (
    <div className="text-yellow-600">Please check in now!</div>
    )}
    </>
    )}
    </div>
    </div>
    )
  4. In the app directory, open the page.tsx file.

  5. Render the CheckIn component if the account is available:

    page.tsx
    return (
    <main className="flex flex-col items-start min-h-screen gap-4 p-24">
    {/* EXISTING CODE */}

    {account && <CheckIn account={account} />}
    </main>
    )

By the end of this step, you built the flow for getting the check-in status of a user. Your app's UI should look similar to this:

Step 10. Add check-in button

  1. In the check-in directory, open the check-in.tsx file.

  2. Add states to manage the check-in transaction:

    check-in.tsx
    const [txHash, setTxHash] = useState<string>()
    const [isWaitTx, setIsWaitTx] = useState<boolean>(false)
    const [error, setError] = useState<string>()

    // txHash: to store the transaction hash received after sending the check-in transaction.
    // isWaitTx: to show a loading indicator when the user sends their check-in transaction.
    // error: to display any errors that occur during the transaction.
  3. Add a button for checking in, and keep it turned off while loading and waiting for a transaction to process. Use handleCheckIn for the onClick listener.

    check-in.tsx
    return (
    <div>
    {/* EXISTING CODE */}

    <button
    className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-xl mt-2 disabled:opacity-50"
    disabled={isCheckedIn || loading || isWaitTx}
    onClick={handleCheckIn}
    >
    {isWaitTx ? "Wait for transaction" : "Check In"}
    </button>
    </div>
    )

Step 11. Send check-in transaction

  1. In the check-in directory, open the check-in.tsx file.

  2. Change the loading status to true when the user clicks the Check In button:

    check-in.tsx
    const handleCheckIn = async () => {
    setTxHash(undefined)
    setIsWaitTx(true)
    }
  3. Before sending the transaction, we need to simulate it:

    • Does the user have enough RON to execute the transaction? The wallet handles gas estimation using the eth_estimategas method.
    • eth_estimateGas just reads data from the blockchain, so we use PublicClient.
    • For more information on eth_estimategas, see MetaMask documentation.
    check-in.tsx
    const handleCheckIn = async () => {
    setTxHash(undefined)
    setIsWaitTx(true)

    // Wrap the simulation in a try-catch block to handle errors.
    try {
    const { txRequest } = await web3PublicClient.simulateContract({
    account,
    address: CHECK_IN_ADDRESS,
    abi: CHECK_IN_ABI,
    // Call the checkIn function of the smart contract to simulate a check-in transaction.
    functionName: "checkIn",
    // Pass the user's address as an argument.
    args: [account],
    })
    } catch (error) {
    console.log(error)

    setError("Could not send transaction. Check your console for further details.")
    setTxHash(undefined)
    setIsWaitTx(false)
    }
    }

    The simulation returns a txRequest object that contains all the information needed to send the transaction to the blockchain.

  4. After simulating, send the transaction to the blockchain and receive the transaction hash:

    check-in.tsx
    // Pass the txRequest object received from the simulation
    // to the writeContract function to send the transaction.

    // We need the user to sign the transaction, so we use WalletClient.
    const txHash = await web3WalletClient.writeContract(txRequest)
    // Store the transaction hash
    setTxHash(txHash)
  5. Display the transaction hash or error to the user:

    check-in.tsx
    return (
    <div>
    {/* PREVIOUS CODE */}

    <div className="flex flex-col gap-2 mt-8">
    <button
    className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-xl mt-2 disabled:opacity-50"
    disabled={isCheckedIn || loading || isWaitTx}
    onClick={handleCheckIn}
    >
    {isWaitTx ? "Wait for transaction" : "Check In"}
    </button>

    {txHash && (
    <>
    <p className="font-semibold text-green-600">Send successfully!</p>
    <a
    className="text-blue-700 hover:text-blue-800"
    href={`https://saigon-app.roninchain.com/tx/${txHash}`}
    >
    Check your transaction here
    </a>
    </>
    )}

    {error && <p className="text-red-600">{error}</p>}
    </div>
    </div>
    )

Step 12. Refresh check-in status

At the previous step, you sent the check-in transaction to the blockchain, but the user still sees the "Please check in now!" message. Why?

  • The user just sent the transaction, but the blockchain hasn't processed it yet.
  • Transactions on Ronin often take 3-4 seconds to process.

To handle this, you need to wait for the blockchain to process the transaction and then refresh the check-in status.

  1. In the check-in directory, open the check-in.tsx file.

  2. After sending the transaction in the handleCheckIn function, use the waitForTransactionReceipt function on PublicClient to get the transaction receipt:

    check-in.tsx
    const receipt = await web3PublicClient.waitForTransactionReceipt({
    hash: txHash,
    })
  3. If the transaction is successful, refetch the check-in status and finish the check-in flow:

    check-in.tsx
    if (receipt.status === "success") {
    refetchCheckedIn()
    setError(undefined)
    setIsWaitTx(false)

    return
    }

    throw "Transaction reverted!"

By the end of this step, you built the flow for sending a check-in transaction to the blockchain. Your app's UI should look similar to this:

Wrap-up

In this tutorial, you learned the following:

  • How to register an app in the Developer Console.
  • How to consume @sky-mavis/waypoint. This package is an EIP-1193 provider that you can use with any Ethereum JavaScript client.
  • The difference between the Public Client and Wallet Client interfaces.
  • How to get data from the blockchain.
  • How to sign a transaction using Wallet Client.
  • How to wait for a transaction's processing and then refresh the UI.

Challenge

Try to build the following features:

  • Get the check-in streak of a user.
  • Estimate the next time a user can check in.
  • Use another wallet, such as Ronin Wallet or MetaMask.

See also

Ronin Waypoint Web SDK