Skip to content
Prividium

Building Frontends

You can use any frontend framework to develop a frontend application for Prividium. In this example, we will use Vue.js with vite, viem, and Typescript.

This guide assumes you have already deployed an ERC20 token contract to your Prividium network as shown in the Deploying Contracts quickstart guide.

Project setup

Create a new Vue app with TypeScript and vite and move into the project folder:

npm create vite@7 prividium-quickstart-app -- --template vue-ts
cd prividium-quickstart-app

Then install the prividium and viem npm packages:

npm install prividium viem

Prividium client

Create a new file in the src folder called prividium.ts.

touch src/prividium.ts

This file will be used to:

  1. Define the chain information.
  2. Instantiate the Prividium chain object.

Copy and paste the code below into prividium.ts.

prividium.ts
import { createPrividiumChain } from 'prividium';
import { defineChain } from 'viem';

export const prividiumChain = defineChain({
    id: Number.parseInt(import.meta.env.VITE_CHAIN_ID, 10),
    name: import.meta.env.VITE_CHAIN_NAME,
    nativeCurrency: {
        name: import.meta.env.VITE_NATIVE_CURRENCY_NAME || import.meta.env.VITE_NATIVE_CURRENCY_SYMBOL,
        symbol: import.meta.env.VITE_NATIVE_CURRENCY_SYMBOL,
        decimals: 18
    },
    rpcUrls: {
        default: {
            http: [import.meta.env.VITE_PRIVIDIUM_RPC_URL]
        },
        public: {
            http: [import.meta.env.VITE_PRIVIDIUM_RPC_URL]
        }
    },
    blockExplorers: {
        default: {
            name: 'Prividium Explorer',
            url: import.meta.env.VITE_PRIVIDIUM_BLOCK_EXPLORER_URL
        }
    },
    testnet: true
});

export const prividium = createPrividiumChain({
    clientId: import.meta.env.VITE_CLIENT_ID,
    chain: prividiumChain,
    authBaseUrl: import.meta.env.VITE_AUTH_BASE_URL,
    redirectUrl: `${window.location.origin}/auth-callback.html`,
    prividiumApiBaseUrl: import.meta.env.VITE_PRIVIDIUM_API_URL
});

Callback URL

Create a file in the project root folder called auth-callback.html

touch auth-callback.html

The Prividium SDK requires a callback page to complete the authentication flow securely. Later on, this callback page will be added to the "User apps" tab in the admin panel to authorize the app to perform authentication for the network.

Copy and paste the code below into auth-callback.html.

auth-callback.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Authentication Callback</title>
  </head>
  <body>
    <div>
      <h2>Completing sign in</h2>
      <p>You can close this window if it does not close automatically.</p>
      <div id="error-wrap"><span id="error-message"></span></div>
    </div>
    <script type="module" src="/src/auth-callback.ts"></script>
  </body>
</html>

Next, create a file in the src folder called auth-callback.ts.

touch src/auth-callback.ts

Copy and paste the code below into auth-callback.ts.

auth-callback.ts
import { handleAuthCallback } from 'prividium';

handleAuthCallback((error) => {
    const errorEl = document.getElementById('error-message');
    const errorWrap = document.getElementById('error-wrap');

    if (errorEl && errorWrap && error) {
        errorEl.textContent = error;
        errorWrap.style.display = 'block';
    }
});

Finally, edit the vite.config.ts file to support the callback url:

vite.config.ts
import { resolve } from 'node:path';
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';

export default defineConfig({
    plugins: [vue()],
    build: {
        rollupOptions: {
            input: {
                main: resolve(__dirname, 'index.html'),
                'auth-callback': resolve(__dirname, 'auth-callback.html')
            }
        }
    }
});

User authentication

To enable users to log in to your Prividium network, you can use the prividium.authorize method. You can pass optional requirements into this method as well. For this example, we are going to require that the user has a wallet associated with their account, and the wallet has the correct chain configuration.

To check if the user is currently logged in, you can use the prividium.isAuthorized method.

Once logged in, the app can fetch some data about the user's profile using prividium.fetchUser.

Edit App.vue to use the code below:

App.vue
<script setup lang="ts">
import { type Address, isAddress } from 'viem';
import { computed, onMounted, ref } from 'vue';
import { useTokenContract } from './composables/useTokenContract';
import { prividium } from './prividium';

type UserProfile = Awaited<ReturnType<typeof prividium.fetchUser>>;

const isAuthenticated = ref(prividium.isAuthorized());
const isAuthenticating = ref(false);
const authError = ref('');
const userProfile = ref<UserProfile | null>(null);

const walletAddress = ref<Address | ''>('');
const walletError = ref('');

const recipient = ref('');
const amount = ref('');

const {
    tokenAddress,
    tokenSymbol,
    formattedBalance,
    isBalanceLoading,
    txStatus,
    isTxPending,
    readBalance,
    sendTokens
} = useTokenContract(walletAddress);

const shortWallet = computed(() => {
    if (!walletAddress.value) return 'Not connected';
    return `${walletAddress.value.slice(0, 6)}...${walletAddress.value.slice(-4)}`;
});

async function loadProfile() {
    if (!isAuthenticated.value) {
        userProfile.value = null;
        return;
    }

    try {
        userProfile.value = await prividium.fetchUser();
    } catch (error) {
        authError.value = error instanceof Error ? error.message : 'Failed to load profile';
    }
}

async function signIn() {
    isAuthenticating.value = true;
    authError.value = '';

    try {
        await prividium.authorize({ scopes: ['wallet:required', 'network:required'] });
        isAuthenticated.value = true;
        await loadProfile();
    } catch (error) {
        isAuthenticated.value = false;
        authError.value = error instanceof Error ? error.message : 'Authentication failed';
    } finally {
        isAuthenticating.value = false;
    }
}

function signOut() {
    prividium.unauthorize();
    isAuthenticated.value = false;
    userProfile.value = null;
    walletAddress.value = '';
}

async function connectWallet() {
    walletError.value = '';

    try {
        if (!window.ethereum) {
            throw new Error('No injected wallet found. Install MetaMask or another EVM wallet.');
        }

        const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
        const firstAccount = Array.isArray(accounts) ? accounts[0] : undefined;
        if (typeof firstAccount !== 'string' || !isAddress(firstAccount)) {
            throw new Error('No valid wallet address was returned.');
        }

        walletAddress.value = firstAccount;
        await switchOrAddNetwork();
        await readBalance();
    } catch (error) {
        walletError.value = error instanceof Error ? error.message : 'Failed to connect wallet';
    }
}

async function switchOrAddNetwork() {
    if (!window.ethereum) return;

    const chainIdHex = `0x${prividium.chain.id.toString(16)}`;
    try {
        await window.ethereum.request({
            method: 'wallet_switchEthereumChain',
            params: [{ chainId: chainIdHex }]
        });
    } catch (error: unknown) {
        const walletErrorCode =
            typeof error === 'object' && error !== null && 'code' in error
                ? (error as { code?: number }).code
                : undefined;

        if (walletErrorCode === 4902) {
            await prividium.addNetworkToWallet();
            return;
        }

        throw error;
    }
}

async function submitTransfer() {
    await sendTokens(recipient.value, amount.value);
    if (!isTxPending.value && txStatus.value.startsWith('Transaction submitted:')) {
        amount.value = '';
    }
}

onMounted(async () => {
    if (isAuthenticated.value) {
        await loadProfile();
    }
});
</script>

<template>
  <main class="app-shell">
    <h1>Prividium Quickstart Token App</h1>

    <section class="card">
      <h2>Authentication</h2>
      <p v-if="isAuthenticated">Signed in</p>
      <p v-else>Not signed in</p>
      <div class="row">
        <button v-if="!isAuthenticated" :disabled="isAuthenticating" @click="signIn">
          {{ isAuthenticating ? 'Signing in...' : 'Sign in with Prividium' }}
        </button>
        <button v-else class="secondary" @click="signOut">Sign out</button>
      </div>
      <p v-if="authError" class="error">{{ authError }}</p>
    </section>

    <section v-if="isAuthenticated" class="card">
      <h2>User Profile</h2>
      <p><strong>Name:</strong> {{ userProfile?.displayName || '-' }}</p>
      <p><strong>User ID:</strong> {{ userProfile?.id || '-' }}</p>
      <p><strong>Wallet Count:</strong> {{ userProfile?.wallets?.length ?? 0 }}</p>
    </section>

    <section v-if="isAuthenticated" class="card">
      <h2>Wallet</h2>
      <p><strong>Connected Wallet:</strong> {{ shortWallet }}</p>
      <div class="row">
        <button @click="connectWallet">Connect wallet</button>
        <button class="secondary" :disabled="!walletAddress || isBalanceLoading" @click="readBalance">
          {{ isBalanceLoading ? 'Refreshing...' : 'Refresh balance' }}
        </button>
      </div>
      <p v-if="walletError" class="error">{{ walletError }}</p>
    </section>

    <section v-if="isAuthenticated" class="card">
      <h2>QuickstartToken</h2>
      <p><strong>Contract:</strong> {{ tokenAddress }}</p>
      <p><strong>Balance:</strong> <span id="balance-value">{{ formattedBalance }} {{ tokenSymbol }}</span></p>

      <div class="form-row">
        <label for="recipient">Recipient</label>
        <input id="recipient" v-model="recipient" placeholder="0x..." />
      </div>

      <div class="form-row">
        <label for="amount">Amount</label>
        <input id="amount" v-model="amount" placeholder="1.0" />
      </div>

      <button :disabled="isTxPending" @click="submitTransfer">
        {{ isTxPending ? 'Authorizing and sending...' : 'Send tokens' }}
      </button>
      <p v-if="txStatus" class="status">{{ txStatus }}</p>
    </section>
  </main>
</template>

Using contracts

Create a new composable file called useTokenContract.ts:

mkdir src/composables
touch src/composables/useTokenContract.ts

Copy and paste the code below:

useTokenContract.ts
import { createPrividiumClient } from 'prividium';
import {
    type Address,
    createWalletClient,
    custom,
    encodeFunctionData,
    erc20Abi,
    formatUnits,
    isAddress,
    parseUnits,
    zeroAddress
} from 'viem';
import { computed, type Ref, ref } from 'vue';
import { prividium, prividiumChain } from '../prividium';

export function useTokenContract(walletAddress: Ref<Address | ''>) {
    const tokenAddress = import.meta.env.VITE_TOKEN_ADDRESS as Address;

    const tokenSymbol = ref('TOKEN');
    const tokenDecimals = ref(18);
    const tokenBalance = ref<bigint | null>(null);
    const isBalanceLoading = ref(false);
    const txStatus = ref('');
    const isTxPending = ref(false);

    const formattedBalance = computed(() => {
        if (tokenBalance.value === null) return '-';
        return formatUnits(tokenBalance.value, tokenDecimals.value);
    });

    function getRpcClient() {
        if (!window.ethereum || !walletAddress.value) {
            console.log('Wallet is not connected.');
            return;
        }
        return createPrividiumClient({
            chain: prividiumChain,
            transport: prividium.transport,
            account: walletAddress.value
        });
    }

    function getWalletClient() {
        if (!window.ethereum || !walletAddress.value) {
            console.log('Wallet is not connected.');
            return;
        }
        return createWalletClient({
            chain: prividium.chain,
            transport: custom(window.ethereum)
        });
    }

    async function readBalance() {
        if (!isAddress(tokenAddress)) {
            throw new Error('VITE_TOKEN_ADDRESS is not a valid token address.');
        }

        isBalanceLoading.value = true;
        try {
            const rpc = getRpcClient();
            if (!rpc) {
                console.log('MISSING RPC CLIENT');
                return;
            }
            const [symbol, decimals, balance] = await Promise.all([
                rpc.readContract({ address: tokenAddress, abi: erc20Abi, functionName: 'symbol' }),
                rpc.readContract({ address: tokenAddress, abi: erc20Abi, functionName: 'decimals' }),
                rpc.readContract({
                    address: tokenAddress,
                    abi: erc20Abi,
                    functionName: 'balanceOf',
                    args: [walletAddress.value || zeroAddress]
                })
            ]);

            tokenSymbol.value = symbol;
            tokenDecimals.value = Number(decimals);
            tokenBalance.value = balance;
        } finally {
            isBalanceLoading.value = false;
        }
    }

    async function sendTokens(recipient: string, amount: string) {
        txStatus.value = '';

        if (!isAddress(tokenAddress)) {
            txStatus.value = 'Set VITE_TOKEN_ADDRESS to your deployed QuickstartToken contract address.';
            return;
        }

        if (!walletAddress.value) {
            txStatus.value = 'Connect your wallet first.';
            return;
        }

        if (!isAddress(recipient)) {
            txStatus.value = 'Recipient must be a valid address.';
            return;
        }

        let parsedAmount: bigint;
        try {
            parsedAmount = parseUnits(amount, tokenDecimals.value);
        } catch {
            txStatus.value = 'Amount is invalid.';
            return;
        }

        if (parsedAmount <= 0n) {
            txStatus.value = 'Amount must be greater than zero.';
            return;
        }

        isTxPending.value = true;
        try {
            const rpc = getRpcClient();
            if (!rpc) {
                console.log('MISSING RPC CLIENT');
                return;
            }

            const calldata = encodeFunctionData({
                abi: erc20Abi,
                functionName: 'transfer',
                args: [recipient as Address, parsedAmount]
            });

            const nonce = await rpc.getTransactionCount({ address: walletAddress.value });

            await prividium.authorizeTransaction({
                walletAddress: walletAddress.value,
                toAddress: tokenAddress,
                nonce,
                calldata
            });

            const gas = await rpc.estimateGas({
                account: walletAddress.value,
                to: tokenAddress,
                data: calldata
            });
            const gasPrice = await rpc.getGasPrice();
            const walletClient = getWalletClient();
            if (!walletClient) {
                console.log('MISSING WALLET CLIENT');
                return;
            }
            const hash = await walletClient.sendTransaction({
                account: walletAddress.value,
                to: tokenAddress,
                data: calldata,
                nonce,
                gas,
                gasPrice,
                chain: prividiumChain
            });

            txStatus.value = `Transaction submitted: ${hash}`;
            await readBalance();
        } catch (error) {
            txStatus.value = error instanceof Error ? error.message : 'Failed to send token transaction';
        } finally {
            isTxPending.value = false;
        }
    }

    return {
        tokenAddress,
        tokenSymbol,
        tokenDecimals,
        tokenBalance,
        formattedBalance,
        isBalanceLoading,
        txStatus,
        isTxPending,
        readBalance,
        sendTokens
    };
}

Reading contracts

In standard web3 applications, it's possible to create a public client capable of reading the chain without the user signing in with their wallet. However with Prividium, it's required that a user must be authenticated and their wallet address known before reading the chain. This is because even read operations for contracts can be configured to allow some users but not others. Additionally, the request must go through the user's special RPC url, which contains their Per-User RPC token.

Because of this, instead of using viem's default createPublicClient method for read operations, we use the createPrividiumClient method from the prividium SDK. This prividium client can then be used to read the state of the chain or contracts as long as the contract permissions are configured to allow this.

Sending transactions

All write transactions (transactions that change the state of a contract or the balance of a wallet) require authorization first before sending. These transactions are authorized via the authorizeTransaction method available on the Prividium instance created with createPrividiumChain.

This can be seen in the sendTokens function.

Permissions need to be defined for a contract to call the authorizeTransaction method, even if the user has full_sequencer_rpc_access.

Environment variables

Create a .env file in the project root folder, and copy and paste the example configuration below.

.env.example
VITE_CLIENT_ID=<your-client-id>
VITE_TOKEN_ADDRESS=0x...

# Keep these values for a local prividium instance
VITE_CHAIN_ID=6565
VITE_CHAIN_NAME=Prividium Local
VITE_NATIVE_CURRENCY_NAME=Ether
VITE_NATIVE_CURRENCY_SYMBOL=ETH
VITE_AUTH_BASE_URL=http://localhost:3001
VITE_PRIVIDIUM_API_URL=http://localhost:8000
VITE_PRIVIDIUM_RPC_URL=http://localhost:8000/rpc
VITE_PRIVIDIUM_BLOCK_EXPLORER_URL=http://localhost:3010

For the VITE_TOKEN_ADDRESS, use the ERC20 token contract address you already have deployed.

For a local instance of Prividium, you can keep all of the default network values. For a live testnet or mainnet, make sure to update all the values to match your network.

Setting up the client ID

To get the value for the VITE_CLIENT_ID, your application must be registered in the admin panel.

If you don't have admin access in the Prividium instance you are using, you'll need to ask an admin to set this up for you. Otherwise, you can follow the steps below.

In the "User Apps" tab of the admin panel, click on the "New application" button to create a new user application.

Set the "Whitelisted Origin" as http://localhost:5173.

Under Redirect URIs add http://localhost:5173/auth-callback.html. This should match the path for the callback URL set up previously.

Give the app a name, and then click "Save".

Once created, copy the OAuth Client ID value and use it for the VITE_CLIENT_ID in your .env file. This is used in the Prividium client in the prividium.ts file you created earlier.

(Optional) Add styling

To add some better styling for the app, replace the src/style.css file with the one below.

style.css
:root {
    font-family:
        ui-sans-serif,
        system-ui,
        -apple-system,
        Segoe UI,
        Roboto,
        Helvetica,
        Arial,
        sans-serif;
    color: #1a1f25;
    background: #f2f5f7;
}

* {
    box-sizing: border-box;
}

body {
    margin: 0;
}

#app {
    min-height: 100vh;
}

.app-shell {
    width: min(760px, 94vw);
    margin: 28px auto;
    display: grid;
    gap: 14px;
}

h1,
h2 {
    margin: 0 0 10px;
}

.card {
    background: #fff;
    border: 1px solid #d6dde5;
    border-radius: 12px;
    padding: 16px;
}

.row {
    display: flex;
    gap: 8px;
    flex-wrap: wrap;
}

button,
input {
    border-radius: 8px;
    border: 1px solid #c5ced8;
    font: inherit;
}

button {
    cursor: pointer;
    padding: 8px 12px;
    background: #0f6dff;
    color: #fff;
    border-color: #0f6dff;
}

button.secondary {
    background: #fff;
    color: #1a1f25;
    border-color: #c5ced8;
}

button:disabled {
    opacity: 0.6;
    cursor: default;
}

.form-row {
    display: grid;
    gap: 6px;
    margin-bottom: 10px;
}

input {
    padding: 8px;
}

.error {
    color: #a22222;
}

.status {
    margin-top: 10px;
    word-break: break-word;
}

Testing the application

Use Chrome browser for testing this application.

Setting up your wallet

Log in with a Prividium account in the user panel and make sure the account has a wallet associated with the account. If using local-prividium, you can log in with any of the default wallets listed in the README.md file. Add one to your Metamask using the private key.

Once you've added the wallet, go to the "Wallets" tab of the user panel and click on the "Add Network to Wallet" button to add the network with your Per-User RPC token.

Running the application

Run the command in the project folder to start the local development server:

npm run dev

The app will be running at http://localhost:5173/.

You should now be able to:

  • Log in with Prividium
  • See your user profile information
  • Connect your wallet
  • See your wallet's token balance
  • Transfer tokens to another user