> **Can't find what you're looking for?** Use `search_docs` on the docs MCP server at `https://docs.prividium.com/api/mcp` to find what you need.
>
> **Have feedback?** Use `submit_feedback` on the same MCP server.

Use this guide to add ERC-20 bridging actions to your frontend application.
The example uses `viem`, the Prividium SDK, and
`@matterlabs/zksync-js`.

## Prerequisites

* A browser wallet such as MetaMask.
* A local or testnet Prividium environment.
* (If using a testnet) An L1 RPC URL.

## 1. Configure system contract permissions

Before any user can bridge ERC-20 tokens between your Prividium zone and the L1,
you must enable some [system contract](/configuring/contracts/system-contracts) permissions.

1. Log in to Prividium as an admin user, and open the Admin Panel.
2. Select **Contracts** in the sidebar.
3. Configure the system contracts below.

### `L2NativeTokenVault`

Open `L2NativeTokenVault` at `0x0000000000000000000000000000000000010004`.
For local testing, configure all permissions to allow `All Users`.
For production,
use `Check Role` if only specific users should bridge.

### `InteropCenter`

Open `InteropCenter` at `0x000000000000000000000000000000000001000d`.
For local testing, configure all permissions to allow `All Users`.
For production,
use `Check Role` if only specific users should bridge.

Withdrawals route through the `InteropCenter` contract for protocol versions `32` and up. If you are working
with an older protocol v31 environment, also add `L2AssetRouter.withdraw(bytes32,bytes)` as a write permission on
`L2AssetRouter` at `0x0000000000000000000000000000000000010003`.

## 2. Choose where the ERC-20 starts

Choose a deployment path for your token.

#### Deploy on L1 first

### Deploy an ERC-20 token on L1

If you already have an ERC-20 token deployed on the L1, skip to the next step.

For this guide, you can use the same `QuickstartToken` contract from the
[developer quickstart](/developers/quickstart/deploying-contracts). Follow all the steps in the
[Deploying Contracts](/developers/quickstart/deploying-contracts) section except for the last steps to start a local
proxy and deploy the contract.

Then, use the command below to deploy the token contract to the L1.

:::code-group
```bash [npm]
npx hardhat ignition deploy ignition/modules/QuickstartToken.ts --network localL1
```

```bash [pnpm]
pnpm hardhat ignition deploy ignition/modules/QuickstartToken.ts --network localL1
```

```bash [yarn]
yarn hardhat ignition deploy ignition/modules/QuickstartToken.ts --network localL1
```

```bash [bun]
bun hardhat ignition deploy ignition/modules/QuickstartToken.ts --network localL1
```
:::

:::info
**Confirm the L1 deposit path can accept the sender**

If your environment uses the L1 Transaction Filterer, confirm one of these conditions:

* General deposits are enabled and the sender deposits to their own address.
* The sender is on the filterer's whitelist.

If the filterer rejects the sender, the deposit fails on L1 before Prividium can execute anything on L2.
:::

### Make one deposit to create or resolve the L2 token

The bridge resolves the L2 token address from `L2NativeTokenVault.l2TokenAddress(address)`. For a new L1 token, that L2
address may not exist until the first deposit executes.

Use the script below to test a deposit of the L1 token to your wallet and log the corresponding L2 token address for
your token. If you used the developer quickstart to deploy your token, you can just add this to your `scripts` folder
and install the `@matterlabs/zksync-js` and `prividium` SDKs.

:::code-group
```bash [npm]
npm install -D @matterlabs/zksync-js prividium
```

```bash [pnpm]
pnpm add -D @matterlabs/zksync-js prividium
```

```bash [yarn]
yarn add -D @matterlabs/zksync-js prividium
```

```bash [bun]
bun add -D @matterlabs/zksync-js prividium
```
:::

**bridge-erc20.ts**

```typescript
import type { DepositParams } from '@matterlabs/zksync-js/types/flows/deposits';
import type { TokensResource } from '@matterlabs/zksync-js/types/flows/token';
import type { WithdrawParams } from '@matterlabs/zksync-js/types/flows/withdrawals';
import { createContractsResource, createTokensResource, createWithdrawalsResource } from '@matterlabs/zksync-js/viem';
import { createViemClient, type ViemClient } from '@matterlabs/zksync-js/viem/client';
import { createViemSdk, type ViemSdk } from '@matterlabs/zksync-js/viem/sdk';
import { createPrividiumClient, createPrividiumSiweChain } from 'prividium/siwe';
import type { Account, Address, Chain, PublicClient, Transport } from 'viem';
import {
    createPublicClient,
    createWalletClient,
    defineChain,
    http,
    isAddress,
    parseEther,
    type WalletClient,
    zeroAddress
} from 'viem';
import { privateKeyToAccount } from 'viem/accounts';

const L1_TOKEN_ADDRESS = (process.env.L1_TOKEN_ADDRESS as Address) || '0x...'; // Your L1 token address
const L2_TOKEN_ADDRESS = (process.env.L2_TOKEN_ADDRESS as Address) || '0x...'; // Your L2 token address

const IS_WITHDRAWAL: boolean = process.env.IS_WITHDRAWAL === 'true';
const amountString = process.env.AMOUNT ?? '20';
const AMOUNT = parseEther(amountString);
const LOG_ADDRESSES_ONLY: boolean = process.env.LOG_ADDRESSES_ONLY === 'true';
const tokenAddress = IS_WITHDRAWAL ? L2_TOKEN_ADDRESS : L1_TOKEN_ADDRESS;

const L1_RPC = process.env.L1_RPC_URL ?? 'http://localhost:5010'; // e.g. https://sepolia.infura.io/v3/XXX
const PRIVIDIUM_API_BASE_URL = process.env.PRIVIDIUM_API_BASE_URL ?? 'http://localhost:8000';
const PRIVATE_KEY = process.env.PRIVATE_KEY || '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
const DOMAIN = process.env.DOMAIN ?? 'localhost:3000';
const CHAIN_ID = Number(process.env.CHAIN_ID ?? '506');
const CHAIN_NAME = process.env.CHAIN_NAME ?? 'Prividium Local';

const l1Chain = defineChain({
    id: Number(process.env.L1_CHAIN_ID ?? '31337'),
    name: 'Local L1 Chain',
    nativeCurrency: {
        name: 'Ether',
        symbol: 'ETH',
        decimals: 18
    },
    rpcUrls: {
        default: {
            http: [L1_RPC]
        }
    }
});

const l2Chain = defineChain({
    id: CHAIN_ID,
    name: CHAIN_NAME,
    nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
    rpcUrls: {
        default: { http: [new URL('/rpc', PRIVIDIUM_API_BASE_URL).toString()] }
    }
});

async function main() {
    if (!PRIVATE_KEY || PRIVATE_KEY.length !== 66) {
        throw new Error('Set your PRIVATE_KEY in the .env file');
    }
    if (!tokenAddress || !isAddress(tokenAddress)) {
        throw new Error('Set either the L1 or L2 token address');
    }

    // --- Viem clients ---
    const account = privateKeyToAccount(PRIVATE_KEY as `0x${string}`);

    const prividium = createPrividiumSiweChain({
        account,
        chain: l2Chain,
        prividiumApiBaseUrl: PRIVIDIUM_API_BASE_URL,
        domain: DOMAIN
    });

    const l1 = createPublicClient({
        chain: l1Chain,
        transport: http(L1_RPC)
    }) as PublicClient<Transport, Chain>;
    const l2 = createPrividiumClient({
        chain: prividium.chain,
        transport: prividium.transport,
        account
    }) as unknown as PublicClient;

    const l1Wallet: WalletClient<Transport, Chain, Account> = createWalletClient({
        chain: l1Chain,
        account,
        transport: http(L1_RPC)
    });
    const l2Wallet: WalletClient<Transport, Chain, Account> = createWalletClient({
        chain: prividium.chain,
        account,
        transport: prividium.transport
    });

    // client + sdk
    const client = createViemClient({ l1, l2, l1Wallet, l2Wallet });
    const sdk = createViemSdk(client);
    const withdrawals = createWithdrawalsResource(
        client,
        createL2OriginAwareTokens(client),
        createContractsResource(client)
    );

    if (!LOG_ADDRESSES_ONLY) {
        console.log(IS_WITHDRAWAL ? 'Withdrawing' : 'Depositing');
        console.log(AMOUNT);
        console.log(IS_WITHDRAWAL ? 'L2 token address:' : 'L1 token address:');
        console.log(tokenAddress);

        const me = account.address;
        const params = {
            amount: AMOUNT,
            to: me,
            token: tokenAddress
            // optional:
            // l2GasLimit: 300_000n,
            // gasPerPubdata: 800n,
            // operatorTip: 0n,
            // refundRecipient: me,
        } as const;

        if (IS_WITHDRAWAL) {
            const l1TokenAddress = await tryGetL1TokenAddress(sdk, tokenAddress);
            if (l1TokenAddress !== zeroAddress) {
                console.log('L1 token address: ', l1TokenAddress);
            } else {
                console.log(
                    'L1 token address is not registered yet. Creating an L2-origin withdrawal through the SDK.'
                );
            }
            await withdraw({ ...sdk, withdrawals }, params);
        } else {
            await ensureL1TokenRegistered(client, sdk, tokenAddress);
            await deposit(sdk, params);
        }
    }

    if (!IS_WITHDRAWAL) {
        if (!LOG_ADDRESSES_ONLY) {
            await ensureL1TokenRegistered(client, sdk, L1_TOKEN_ADDRESS);
        }
        const tokenInfo = await sdk.tokens.resolve(L1_TOKEN_ADDRESS);
        console.log('L2 token address: ', tokenInfo.l2);
    } else {
        const l1TokenAddress = await tryGetL1TokenAddress(sdk, L2_TOKEN_ADDRESS);
        if (l1TokenAddress === zeroAddress) {
            console.log(
                'L1 token address: not registered yet. If this is an L2-origin token, create and finalize a withdrawal first so the L1 NativeTokenVault can learn the counterpart.'
            );
        } else {
            console.log('L1 token address: ', l1TokenAddress);
        }
    }
}

main().catch((e) => {
    console.error(e);
    process.exit(1);
});

async function ensureL1TokenRegistered(client: ViemClient, sdk: ViemSdk, l1Token: Address) {
    const assetId = await sdk.tokens.assetIdOfL1(l1Token);
    if (!isZeroAssetId(assetId)) {
        return assetId;
    }

    const ntv = await sdk.contracts.l1NativeTokenVault();
    console.log('Registering L1 token in L1 NativeTokenVault:', l1Token);
    const hash = await client.l1Wallet.writeContract({
        address: ntv.address,
        abi: ntv.abi,
        functionName: 'registerToken',
        args: [l1Token],
        account: client.account,
        chain: l1Chain
    });
    const receipt = await client.l1.waitForTransactionReceipt({ hash });
    if (receipt.status !== 'success') {
        throw new Error(`L1 token registration failed: ${hash}`);
    }
    console.log('Registered L1 token:', hash);

    return sdk.tokens.assetIdOfL1(l1Token);
}

async function tryGetL1TokenAddress(sdk: ViemSdk, l2Token: Address) {
    const assetId = await sdk.tokens.assetIdOfL2(l2Token);
    if (isZeroAssetId(assetId)) {
        return zeroAddress;
    }

    const l1Token = await sdk.tokens.l1TokenFromAssetId(assetId);
    return l1Token === zeroAddress ? zeroAddress : l1Token;
}

function isZeroAssetId(assetId: `0x${string}`) {
    return BigInt(assetId) === 0n;
}

function createL2OriginAwareTokens(client: ViemClient): TokensResource {
    const tokens = createTokensResource(client);

    return {
        ...tokens,
        async resolve(ref, opts) {
            try {
                return await tokens.resolve(ref, opts);
            } catch (error) {
                const chain = typeof ref === 'string' ? (opts?.chain ?? 'l1') : ref.chain;
                const l2Token = typeof ref === 'string' ? ref : ref.address;
                if (chain !== 'l2' || !isL2OriginResolutionError(error)) {
                    throw error;
                }

                let assetId = await tokens.assetIdOfL2(l2Token);
                if (isZeroAssetId(assetId)) {
                    const { l2NativeTokenVault } = await client.contracts();
                    const registration = await client.l2.simulateContract({
                        address: l2NativeTokenVault.address,
                        abi: l2NativeTokenVault.abi,
                        functionName: 'ensureTokenIsRegistered',
                        args: [l2Token],
                        account: client.account
                    });
                    assetId = registration.result;
                }

                const [originChainId, baseTokenAssetId, wethL1, wethL2, isChainEthBased] = await Promise.all([
                    client.l2.getChainId(),
                    tokens.baseTokenAssetId(),
                    tokens.wethL1(),
                    tokens.wethL2(),
                    tokens.isChainEthBased()
                ]);

                return {
                    kind: 'erc20',
                    l1: zeroAddress,
                    l2: l2Token,
                    assetId,
                    originChainId: BigInt(originChainId),
                    isChainEthBased,
                    baseTokenAssetId,
                    wethL1,
                    wethL2
                };
            }
        }
    };
}

function isL2OriginResolutionError(error: unknown) {
    const message = String(error);
    return (
        message.includes('has no L1 counterpart registered') ||
        message.includes('is not registered in the L2 NativeTokenVault')
    );
}

async function deposit(sdk: ViemSdk, params: DepositParams) {
    // Create (prepare + send)
    const handle = await sdk.deposits.create(params);
    console.log('CREATE response:', handle);

    // Status (quick check)
    const status = await sdk.deposits.status(handle); /* input can be handle or l1TxHash */
    // status.phase: 'UNKNOWN' | 'L1_PENDING' | 'L1_INCLUDED' | 'L2_PENDING' | 'L2_EXECUTED' | 'L2_FAILED'
    console.log('STATUS response:', status);

    // Wait (L1 inclusion)
    const l1Receipt = await sdk.deposits.wait(handle, { for: 'l1' });
    console.log(
        'L1 Included at block:',
        l1Receipt?.blockNumber,
        'status:',
        l1Receipt?.status,
        'hash:',
        l1Receipt?.transactionHash
    );

    // Status again
    const status2 = await sdk.deposits.status(handle);
    console.log('STATUS2 response:', status2);

    // Wait for L2 execution
    const l2Receipt = await sdk.deposits.wait(handle, { for: 'l2' });
    console.log(
        'L2 Included at block:',
        l2Receipt?.blockNumber,
        'status:',
        l2Receipt?.status,
        'hash:',
        l2Receipt?.transactionHash
    );
}

async function withdraw(sdk: ViemSdk, params: WithdrawParams) {
    // Create (send L2 withdraw)
    const handle = await sdk.withdrawals.create(params);
    console.log('CREATE:', handle);

    // Quick status
    const status = await sdk.withdrawals.status(handle.l2TxHash); // input can be handle or l2TxHash
    // status.phase: 'UNKNOWN' | 'L2_PENDING' | 'PENDING' | 'READY_TO_FINALIZE' | 'FINALIZED' | 'UNFINALIZABLE'
    console.log('STATUS (initial):', status);

    // Wait for L2 inclusion
    const l2Receipt = await sdk.withdrawals.wait(handle, { for: 'l2' });
    console.log(
        'L2 included: block=',
        l2Receipt?.blockNumber,
        'status=',
        l2Receipt?.status,
        'hash=',
        l2Receipt?.transactionHash
    );

    // Wait until ready to finalize
    await sdk.withdrawals.wait(handle.l2TxHash, { for: 'ready' }); // becomes finalizable
    console.log('STATUS (ready):', await sdk.withdrawals.status(handle.l2TxHash));

    // Try to finalize on L1
    const fin = await sdk.withdrawals.tryFinalize(handle.l2TxHash);
    console.log('TRY FINALIZE:', fin);

    const l1Receipt = await sdk.withdrawals.wait(handle.l2TxHash, {
        for: 'finalized'
    });
    if (l1Receipt) {
        console.log('L1 finalize receipt:', l1Receipt.transactionHash);
    } else {
        console.log('Finalized (no local L1 receipt — possibly finalized by someone else).');
    }
}
```

You can run the script using the command below inside the Hardhat project. Make sure to replace
`<YOUR_L1_TOKEN_ADDRESS>` with your deployed L1 token address.

:::code-group
```bash [npm]
L1_TOKEN_ADDRESS=<YOUR_L1_TOKEN_ADDRESS> npx hardhat run --no-compile scripts/bridge-erc20.ts --network localPrividium
```

```bash [pnpm]
L1_TOKEN_ADDRESS=<YOUR_L1_TOKEN_ADDRESS> pnpm hardhat run --no-compile scripts/bridge-erc20.ts --network localPrividium
```

```bash [yarn]
L1_TOKEN_ADDRESS=<YOUR_L1_TOKEN_ADDRESS> yarn hardhat run --no-compile scripts/bridge-erc20.ts --network localPrividium
```

```bash [bun]
L1_TOKEN_ADDRESS=<YOUR_L1_TOKEN_ADDRESS> bun hardhat run --no-compile scripts/bridge-erc20.ts --network localPrividium
```
:::

After the script completes, it will log the L2 address of the token. Save this address to use later.

### Register the token permissions

The next step is to configure the contract permissions for the token contract on your Prividium network.

If you deployed the token contract using the quickstart guide, continue to the next step of the quickstart:
[Configuring Contracts](/developers/quickstart/configuring-contracts).

Use the L2 token address you got from the previous step. Then, start the local proxy from the
[Deploying Contracts](/developers/quickstart/deploying-contracts#setting-up-the-proxy) section, and run the
`setup-permissions` script against your Prividium network.

### Configuring a custom ERC-20 token

If you used a custom token contract that differs from the quickstart token, follow the steps below:

1. Open the Admin Panel.
2. Select **Contracts**.
3. Click **Add contract** and enter:
   * **Name**: the token name, for example `Local Bridge Token`
   * **Contract address**: the L2 ERC-20 token address you got from the previous step
   * **ABI**: the ERC-20 ABI, including `approve`, `allowance`, and `balanceOf`
4. Click **Save Changes**. The panel opens the token contract's details page on the **Function Permissions** tab.
5. Update these permissions:

| Function                     | Access type | Why it is needed                                              |
| ---------------------------- | ----------- | ------------------------------------------------------------- |
| `approve(address,uint256)`   | Write       | Allows the bridge to spend the token before withdrawal.       |
| `allowance(address,address)` | Read        | Lets the SDK check whether approval is already high enough.   |
| `balanceOf(address)`         | Read        | Lets the UI and SDK check the user's withdrawable L2 balance. |

Use the same rule type for all three token permissions. For a local guide run, choose `All Users`. For a restricted app,
choose `Check Role` and make sure your test user has that role.

#### Deploy native L2 token first

### Deploy the ERC-20 token on L2

If your environment has `VITE_TOKEN_DEPLOYMENT_ENABLED` set to true, the easiest way to deploy a new token contract is
through the [deployable templates](/configuring/contracts/contract-deployment#deploy-flow) in the admin panel.

:::note
Follow the steps to [setup your wallet](#setting-up-your-wallet) before trying to deploy with the deployable template.
:::

If you don't have token deployments enabled through deployable templates, you can follow the steps in the
[Deploying Contracts](/developers/quickstart/deploying-contracts) guide to deploy an ERC-20 token on your L2 Prividium
network.

### Register the token permissions

If you deployed the token contract using the deployable template, the token permissions should already be configured for
you.

If you deployed the token contract using the quickstart guide, the next step is to continue to the next step of the
quickstart: [Configuring Contracts](/developers/quickstart/configuring-contracts).

### Configuring a custom ERC-20 token

If you used a custom token contract that differs from the quickstart token, follow the steps below:

1. Open the Admin Panel.
2. Select **Contracts**.
3. Click **Add contract** and enter:
   * **Name**: the token name, for example `Local Bridge Token`
   * **Contract address**: the L2 ERC-20 token address you got from the previous step
   * **ABI**: the ERC-20 ABI, including `approve`, `allowance`, and `balanceOf`
4. Click **Save Changes**. The panel opens the token contract's details page on the **Function Permissions** tab.
5. Update these permissions:

| Function                     | Access type | Why it is needed                                              |
| ---------------------------- | ----------- | ------------------------------------------------------------- |
| `approve(address,uint256)`   | Write       | Allows the bridge to spend the token before withdrawal.       |
| `allowance(address,address)` | Read        | Lets the SDK check whether approval is already high enough.   |
| `balanceOf(address)`         | Read        | Lets the UI and SDK check the user's withdrawable L2 balance. |

Use the same rule type for all three token permissions. For a local guide run, choose `All Users`. For a restricted app,
choose `Check Role` and make sure your test user has that role.

### Make one withdrawal to create or resolve the L1 token

:::info
**Confirm the L1 deposit path can accept the sender**

If your environment uses the L1 Transaction Filterer, confirm one of these conditions:

* General deposits are enabled and the sender deposits to their own address.
* The sender is on the filterer's whitelist.

If the filterer rejects the sender, the deposit fails on L1 before Prividium can execute anything on L2.
:::

The bridge resolves the L1 token address from the asset ID returned from `L2NativeTokenVault.assetId(address)`. For a
new L2 token, that L1 address may not exist until the first withdrawal executes.

Use the script below to test a withdrawal of the L2 token to your wallet and log the corresponding L1 token address for
your token. If you used the developer quickstart to deploy your token, you can just add this to your `scripts` folder
and install the `@matterlabs/zksync-js` and `prividium` SDKs.

:::code-group
```bash [npm]
npm install -D @matterlabs/zksync-js prividium
```

```bash [pnpm]
pnpm add -D @matterlabs/zksync-js prividium
```

```bash [yarn]
yarn add -D @matterlabs/zksync-js prividium
```

```bash [bun]
bun add -D @matterlabs/zksync-js prividium
```
:::

**bridge-erc20.ts**

```typescript
import type { DepositParams } from '@matterlabs/zksync-js/types/flows/deposits';
import type { TokensResource } from '@matterlabs/zksync-js/types/flows/token';
import type { WithdrawParams } from '@matterlabs/zksync-js/types/flows/withdrawals';
import { createContractsResource, createTokensResource, createWithdrawalsResource } from '@matterlabs/zksync-js/viem';
import { createViemClient, type ViemClient } from '@matterlabs/zksync-js/viem/client';
import { createViemSdk, type ViemSdk } from '@matterlabs/zksync-js/viem/sdk';
import { createPrividiumClient, createPrividiumSiweChain } from 'prividium/siwe';
import type { Account, Address, Chain, PublicClient, Transport } from 'viem';
import {
    createPublicClient,
    createWalletClient,
    defineChain,
    http,
    isAddress,
    parseEther,
    type WalletClient,
    zeroAddress
} from 'viem';
import { privateKeyToAccount } from 'viem/accounts';

const L1_TOKEN_ADDRESS = (process.env.L1_TOKEN_ADDRESS as Address) || '0x...'; // Your L1 token address
const L2_TOKEN_ADDRESS = (process.env.L2_TOKEN_ADDRESS as Address) || '0x...'; // Your L2 token address

const IS_WITHDRAWAL: boolean = process.env.IS_WITHDRAWAL === 'true';
const amountString = process.env.AMOUNT ?? '20';
const AMOUNT = parseEther(amountString);
const LOG_ADDRESSES_ONLY: boolean = process.env.LOG_ADDRESSES_ONLY === 'true';
const tokenAddress = IS_WITHDRAWAL ? L2_TOKEN_ADDRESS : L1_TOKEN_ADDRESS;

const L1_RPC = process.env.L1_RPC_URL ?? 'http://localhost:5010'; // e.g. https://sepolia.infura.io/v3/XXX
const PRIVIDIUM_API_BASE_URL = process.env.PRIVIDIUM_API_BASE_URL ?? 'http://localhost:8000';
const PRIVATE_KEY = process.env.PRIVATE_KEY || '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
const DOMAIN = process.env.DOMAIN ?? 'localhost:3000';
const CHAIN_ID = Number(process.env.CHAIN_ID ?? '506');
const CHAIN_NAME = process.env.CHAIN_NAME ?? 'Prividium Local';

const l1Chain = defineChain({
    id: Number(process.env.L1_CHAIN_ID ?? '31337'),
    name: 'Local L1 Chain',
    nativeCurrency: {
        name: 'Ether',
        symbol: 'ETH',
        decimals: 18
    },
    rpcUrls: {
        default: {
            http: [L1_RPC]
        }
    }
});

const l2Chain = defineChain({
    id: CHAIN_ID,
    name: CHAIN_NAME,
    nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
    rpcUrls: {
        default: { http: [new URL('/rpc', PRIVIDIUM_API_BASE_URL).toString()] }
    }
});

async function main() {
    if (!PRIVATE_KEY || PRIVATE_KEY.length !== 66) {
        throw new Error('Set your PRIVATE_KEY in the .env file');
    }
    if (!tokenAddress || !isAddress(tokenAddress)) {
        throw new Error('Set either the L1 or L2 token address');
    }

    // --- Viem clients ---
    const account = privateKeyToAccount(PRIVATE_KEY as `0x${string}`);

    const prividium = createPrividiumSiweChain({
        account,
        chain: l2Chain,
        prividiumApiBaseUrl: PRIVIDIUM_API_BASE_URL,
        domain: DOMAIN
    });

    const l1 = createPublicClient({
        chain: l1Chain,
        transport: http(L1_RPC)
    }) as PublicClient<Transport, Chain>;
    const l2 = createPrividiumClient({
        chain: prividium.chain,
        transport: prividium.transport,
        account
    }) as unknown as PublicClient;

    const l1Wallet: WalletClient<Transport, Chain, Account> = createWalletClient({
        chain: l1Chain,
        account,
        transport: http(L1_RPC)
    });
    const l2Wallet: WalletClient<Transport, Chain, Account> = createWalletClient({
        chain: prividium.chain,
        account,
        transport: prividium.transport
    });

    // client + sdk
    const client = createViemClient({ l1, l2, l1Wallet, l2Wallet });
    const sdk = createViemSdk(client);
    const withdrawals = createWithdrawalsResource(
        client,
        createL2OriginAwareTokens(client),
        createContractsResource(client)
    );

    if (!LOG_ADDRESSES_ONLY) {
        console.log(IS_WITHDRAWAL ? 'Withdrawing' : 'Depositing');
        console.log(AMOUNT);
        console.log(IS_WITHDRAWAL ? 'L2 token address:' : 'L1 token address:');
        console.log(tokenAddress);

        const me = account.address;
        const params = {
            amount: AMOUNT,
            to: me,
            token: tokenAddress
            // optional:
            // l2GasLimit: 300_000n,
            // gasPerPubdata: 800n,
            // operatorTip: 0n,
            // refundRecipient: me,
        } as const;

        if (IS_WITHDRAWAL) {
            const l1TokenAddress = await tryGetL1TokenAddress(sdk, tokenAddress);
            if (l1TokenAddress !== zeroAddress) {
                console.log('L1 token address: ', l1TokenAddress);
            } else {
                console.log(
                    'L1 token address is not registered yet. Creating an L2-origin withdrawal through the SDK.'
                );
            }
            await withdraw({ ...sdk, withdrawals }, params);
        } else {
            await ensureL1TokenRegistered(client, sdk, tokenAddress);
            await deposit(sdk, params);
        }
    }

    if (!IS_WITHDRAWAL) {
        if (!LOG_ADDRESSES_ONLY) {
            await ensureL1TokenRegistered(client, sdk, L1_TOKEN_ADDRESS);
        }
        const tokenInfo = await sdk.tokens.resolve(L1_TOKEN_ADDRESS);
        console.log('L2 token address: ', tokenInfo.l2);
    } else {
        const l1TokenAddress = await tryGetL1TokenAddress(sdk, L2_TOKEN_ADDRESS);
        if (l1TokenAddress === zeroAddress) {
            console.log(
                'L1 token address: not registered yet. If this is an L2-origin token, create and finalize a withdrawal first so the L1 NativeTokenVault can learn the counterpart.'
            );
        } else {
            console.log('L1 token address: ', l1TokenAddress);
        }
    }
}

main().catch((e) => {
    console.error(e);
    process.exit(1);
});

async function ensureL1TokenRegistered(client: ViemClient, sdk: ViemSdk, l1Token: Address) {
    const assetId = await sdk.tokens.assetIdOfL1(l1Token);
    if (!isZeroAssetId(assetId)) {
        return assetId;
    }

    const ntv = await sdk.contracts.l1NativeTokenVault();
    console.log('Registering L1 token in L1 NativeTokenVault:', l1Token);
    const hash = await client.l1Wallet.writeContract({
        address: ntv.address,
        abi: ntv.abi,
        functionName: 'registerToken',
        args: [l1Token],
        account: client.account,
        chain: l1Chain
    });
    const receipt = await client.l1.waitForTransactionReceipt({ hash });
    if (receipt.status !== 'success') {
        throw new Error(`L1 token registration failed: ${hash}`);
    }
    console.log('Registered L1 token:', hash);

    return sdk.tokens.assetIdOfL1(l1Token);
}

async function tryGetL1TokenAddress(sdk: ViemSdk, l2Token: Address) {
    const assetId = await sdk.tokens.assetIdOfL2(l2Token);
    if (isZeroAssetId(assetId)) {
        return zeroAddress;
    }

    const l1Token = await sdk.tokens.l1TokenFromAssetId(assetId);
    return l1Token === zeroAddress ? zeroAddress : l1Token;
}

function isZeroAssetId(assetId: `0x${string}`) {
    return BigInt(assetId) === 0n;
}

function createL2OriginAwareTokens(client: ViemClient): TokensResource {
    const tokens = createTokensResource(client);

    return {
        ...tokens,
        async resolve(ref, opts) {
            try {
                return await tokens.resolve(ref, opts);
            } catch (error) {
                const chain = typeof ref === 'string' ? (opts?.chain ?? 'l1') : ref.chain;
                const l2Token = typeof ref === 'string' ? ref : ref.address;
                if (chain !== 'l2' || !isL2OriginResolutionError(error)) {
                    throw error;
                }

                let assetId = await tokens.assetIdOfL2(l2Token);
                if (isZeroAssetId(assetId)) {
                    const { l2NativeTokenVault } = await client.contracts();
                    const registration = await client.l2.simulateContract({
                        address: l2NativeTokenVault.address,
                        abi: l2NativeTokenVault.abi,
                        functionName: 'ensureTokenIsRegistered',
                        args: [l2Token],
                        account: client.account
                    });
                    assetId = registration.result;
                }

                const [originChainId, baseTokenAssetId, wethL1, wethL2, isChainEthBased] = await Promise.all([
                    client.l2.getChainId(),
                    tokens.baseTokenAssetId(),
                    tokens.wethL1(),
                    tokens.wethL2(),
                    tokens.isChainEthBased()
                ]);

                return {
                    kind: 'erc20',
                    l1: zeroAddress,
                    l2: l2Token,
                    assetId,
                    originChainId: BigInt(originChainId),
                    isChainEthBased,
                    baseTokenAssetId,
                    wethL1,
                    wethL2
                };
            }
        }
    };
}

function isL2OriginResolutionError(error: unknown) {
    const message = String(error);
    return (
        message.includes('has no L1 counterpart registered') ||
        message.includes('is not registered in the L2 NativeTokenVault')
    );
}

async function deposit(sdk: ViemSdk, params: DepositParams) {
    // Create (prepare + send)
    const handle = await sdk.deposits.create(params);
    console.log('CREATE response:', handle);

    // Status (quick check)
    const status = await sdk.deposits.status(handle); /* input can be handle or l1TxHash */
    // status.phase: 'UNKNOWN' | 'L1_PENDING' | 'L1_INCLUDED' | 'L2_PENDING' | 'L2_EXECUTED' | 'L2_FAILED'
    console.log('STATUS response:', status);

    // Wait (L1 inclusion)
    const l1Receipt = await sdk.deposits.wait(handle, { for: 'l1' });
    console.log(
        'L1 Included at block:',
        l1Receipt?.blockNumber,
        'status:',
        l1Receipt?.status,
        'hash:',
        l1Receipt?.transactionHash
    );

    // Status again
    const status2 = await sdk.deposits.status(handle);
    console.log('STATUS2 response:', status2);

    // Wait for L2 execution
    const l2Receipt = await sdk.deposits.wait(handle, { for: 'l2' });
    console.log(
        'L2 Included at block:',
        l2Receipt?.blockNumber,
        'status:',
        l2Receipt?.status,
        'hash:',
        l2Receipt?.transactionHash
    );
}

async function withdraw(sdk: ViemSdk, params: WithdrawParams) {
    // Create (send L2 withdraw)
    const handle = await sdk.withdrawals.create(params);
    console.log('CREATE:', handle);

    // Quick status
    const status = await sdk.withdrawals.status(handle.l2TxHash); // input can be handle or l2TxHash
    // status.phase: 'UNKNOWN' | 'L2_PENDING' | 'PENDING' | 'READY_TO_FINALIZE' | 'FINALIZED' | 'UNFINALIZABLE'
    console.log('STATUS (initial):', status);

    // Wait for L2 inclusion
    const l2Receipt = await sdk.withdrawals.wait(handle, { for: 'l2' });
    console.log(
        'L2 included: block=',
        l2Receipt?.blockNumber,
        'status=',
        l2Receipt?.status,
        'hash=',
        l2Receipt?.transactionHash
    );

    // Wait until ready to finalize
    await sdk.withdrawals.wait(handle.l2TxHash, { for: 'ready' }); // becomes finalizable
    console.log('STATUS (ready):', await sdk.withdrawals.status(handle.l2TxHash));

    // Try to finalize on L1
    const fin = await sdk.withdrawals.tryFinalize(handle.l2TxHash);
    console.log('TRY FINALIZE:', fin);

    const l1Receipt = await sdk.withdrawals.wait(handle.l2TxHash, {
        for: 'finalized'
    });
    if (l1Receipt) {
        console.log('L1 finalize receipt:', l1Receipt.transactionHash);
    } else {
        console.log('Finalized (no local L1 receipt — possibly finalized by someone else).');
    }
}
```

You can run the script using the command below inside the Hardhat project. Make sure to replace
`<YOUR_L2_TOKEN_ADDRESS>` with your deployed L2 token address.

:::code-group
```bash [npm]
L2_TOKEN_ADDRESS=<YOUR_L2_TOKEN_ADDRESS> IS_WITHDRAWAL=true npx hardhat run --no-compile scripts/bridge-erc20.ts --network localPrividium
```

```bash [pnpm]
L2_TOKEN_ADDRESS=<YOUR_L2_TOKEN_ADDRESS> IS_WITHDRAWAL=true pnpm hardhat run --no-compile scripts/bridge-erc20.ts --network localPrividium
```

```bash [yarn]
L2_TOKEN_ADDRESS=<YOUR_L2_TOKEN_ADDRESS> IS_WITHDRAWAL=true yarn hardhat run --no-compile scripts/bridge-erc20.ts --network localPrividium
```

```bash [bun]
L2_TOKEN_ADDRESS=<YOUR_L2_TOKEN_ADDRESS> IS_WITHDRAWAL=true bun hardhat run --no-compile scripts/bridge-erc20.ts --network localPrividium
```
:::

After the script completes, it will log the L1 address of the token. Save this address to use later.

## 3. Create a frontend project

Create a new Vite project with Vue and TypeScript:

:::code-group
```bash [npm]
npm create vite@latest prividium-bridge-frontend -- --template vue-ts
```

```bash [pnpm]
pnpm create vite@latest prividium-bridge-frontend --template vue-ts
```

```bash [yarn]
yarn create vite prividium-bridge-frontend --template vue-ts
```

```bash [bun]
bun create vite@latest prividium-bridge-frontend --template vue-ts
```
:::

```bash
cd prividium-bridge-frontend
```

Then, install the following dependencies:

:::code-group
```bash [npm]
npm install prividium @matterlabs/zksync-js viem
```

```bash [pnpm]
pnpm add prividium @matterlabs/zksync-js viem
```

```bash [yarn]
yarn add prividium @matterlabs/zksync-js viem
```

```bash [bun]
bun add prividium @matterlabs/zksync-js viem
```
:::

### Create the frontend OAuth application

Register the frontend in the Prividium Admin Panel:

1. Open the Admin Panel.
2. Select **User Apps** in the sidebar.
3. Click **New application**.
4. Set **Name** to `Local Bridge Frontend`.
5. Set **Whitelisted Origin** to `http://localhost:5173`.
6. Add `http://localhost:5173/authorize-callback.html` under **Redirect URIs**.
7. Click **Save**.
8. Copy the generated **OAuth Client ID**.

Save this for the next step.

### Configure environment variables

Create a `.env.local` file in your `prividium-bridge-frontend` folder and configure the OAuth Client ID and the L1 and L2 token addresses.
If you aren't using the default local environment, you will also need to configure the Prividium environment values.

**.env.example**

```bash
VITE_PRIVIDIUM_CLIENT_ID=
VITE_L1_ERC20_TOKEN_ADDRESS=0x...
VITE_L2_ERC20_TOKEN_ADDRESS=0x...
VITE_AUTH_CALLBACK_URL='http://localhost:5173/authorize-callback.html'

# Not required for the default local Prividium environment
# VITE_L1_RPC_URL=
# VITE_L1_CHAIN_ID=
# VITE_L1_CHAIN_NAME=
# VITE_PRIVIDIUM_API_BASE_URL=
# VITE_AUTH_BASE_URL=
# VITE_CHAIN_ID=
# VITE_CHAIN_NAME=
# VITE_NATIVE_CURRENCY_NAME=
# VITE_NATIVE_CURRENCY_SYMBOL=
# VITE_NATIVE_CURRENCY_DECIMALS=
# VITE_BLOCK_EXPLORER_URL=
# VITE_ERC20_TOKEN_DECIMALS=
```

### Add the Prividium callback page

Create `authorize-callback.html` in your `prividium-bridge-frontend` folder:

**authorize-callback.html**

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Prividium Auth Callback</title>
    <script type="module">
      import { handleAuthCallback } from 'prividium';

      handleAuthCallback((errorMessage) => {
        const status = document.getElementById('callback-status');
        if (!status) return;

        if (errorMessage) {
          status.textContent = `Authentication error: ${errorMessage}`;
          status.setAttribute('data-state', 'error');
          return;
        }

        status.textContent = 'Authentication complete. You can return to the app tab.';
        status.setAttribute('data-state', 'success');
      });
    </script>
  </head>
  <body>
    <p id="callback-status" data-state="pending">Completing authentication...</p>
  </body>
</html>
```

This page completes the Prividium OAuth popup flow.

### Create the Prividium helpers

Create `src/lib/prividium.ts`:

**prividium.ts**

```typescript
// src/lib/prividium.js
import { createPrividiumChain } from 'prividium';
import { createPublicClient, defineChain } from 'viem';

const PRIVIDIUM_API_BASE_URL = import.meta.env.VITE_PRIVIDIUM_API_BASE_URL ?? 'http://localhost:8000';
const PRIVIDIUM_CLIENT_ID = import.meta.env.VITE_PRIVIDIUM_CLIENT_ID;
const AUTH_BASE_URL = import.meta.env.VITE_AUTH_BASE_URL ?? 'http://localhost:3001';
const AUTH_CALLBACK_URL = import.meta.env.VITE_AUTH_CALLBACK_URL ?? `${window.location.origin}/callback`;
const CHAIN_ID = Number(import.meta.env.VITE_CHAIN_ID ?? '506');
const CHAIN_NAME = import.meta.env.VITE_CHAIN_NAME ?? 'Prividium Local';
const NATIVE_CURRENCY_NAME = import.meta.env.VITE_NATIVE_CURRENCY_NAME ?? 'Ether';
const NATIVE_CURRENCY_SYMBOL = import.meta.env.VITE_NATIVE_CURRENCY_SYMBOL ?? 'ETH';
const NATIVE_CURRENCY_DECIMALS = Number(import.meta.env.VITE_NATIVE_CURRENCY_DECIMALS ?? '18');
const BLOCK_EXPLORER_URL = import.meta.env.VITE_BLOCK_EXPLORER_URL ?? 'http://localhost:3010';

if (!PRIVIDIUM_CLIENT_ID) {
    throw new Error('Set VITE_PRIVIDIUM_CLIENT_ID to the client ID of your Prividium app');
}

export const prividiumChain = defineChain({
    id: CHAIN_ID,
    name: CHAIN_NAME,
    nativeCurrency: {
        name: NATIVE_CURRENCY_NAME,
        symbol: NATIVE_CURRENCY_SYMBOL,
        decimals: NATIVE_CURRENCY_DECIMALS
    },
    rpcUrls: {
        default: {
            http: [new URL('/rpc', PRIVIDIUM_API_BASE_URL).toString()]
        }
    },
    blockExplorers: BLOCK_EXPLORER_URL
        ? {
              default: {
                  name: 'Explorer',
                  url: BLOCK_EXPLORER_URL
              }
          }
        : undefined
});

// Initialize Prividium SDK
export const prividium = createPrividiumChain({
    clientId: PRIVIDIUM_CLIENT_ID,
    chain: prividiumChain,
    authBaseUrl: AUTH_BASE_URL,
    prividiumApiBaseUrl: PRIVIDIUM_API_BASE_URL,
    redirectUrl: AUTH_CALLBACK_URL,
    onAuthExpiry: () => {
        console.log('Authentication expired - please reconnect');
    }
});

export async function authorizeWalletNetworkScopes() {
    if (!prividium.isAuthorized()) {
        await prividium.authorize({
            scopes: ['wallet:required', 'network:required']
        });
    }
}

export const publicClient = createPublicClient({
    chain: prividium.chain,
    transport: prividium.transport
});
```

### Create the bridge helpers

Create `src/erc20-bridge.ts`:

**erc20-bridge.ts**

```typescript
import { createViemClient, createViemSdk } from '@matterlabs/zksync-js/viem';
import { createPrividiumClient } from 'prividium';
import {
    type Account,
    type Address,
    type Chain,
    createPublicClient,
    createWalletClient,
    custom,
    defineChain,
    encodeFunctionData,
    type Hex,
    http,
    numberToHex,
    type PublicClient,
    type Transport,
    type WalletClient
} from 'viem';
import { authorizeWalletNetworkScopes, prividium } from './lib/prividium';

const L1_RPC_URL = import.meta.env.VITE_L1_RPC_URL ?? 'http://localhost:5010';
const L1_CHAIN_ID = Number(import.meta.env.VITE_L1_CHAIN_ID ?? '31337');
const L1_CHAIN_NAME = import.meta.env.VITE_L1_CHAIN_NAME ?? 'Anvil L1';
const L1_NATIVE_CURRENCY_NAME = import.meta.env.VITE_L1_NATIVE_CURRENCY_NAME ?? 'Ether';
const L1_NATIVE_CURRENCY_SYMBOL = import.meta.env.VITE_L1_NATIVE_CURRENCY_SYMBOL ?? 'ETH';
const L1_NATIVE_CURRENCY_DECIMALS = Number(import.meta.env.VITE_L1_NATIVE_CURRENCY_DECIMALS ?? '18');

type SdkWalletClient = WalletClient<Transport, Chain, Account>;
type SdkPublicClient = PublicClient<Transport, Chain>;
type WithdrawErc20WithAuthorizationParams = {
    l2Token: Address;
    amount: bigint;
    account: Address;
    to?: Address;
    onStatus?: (message: string) => void;
};

export const l1Chain = defineChain({
    id: L1_CHAIN_ID,
    name: L1_CHAIN_NAME,
    nativeCurrency: {
        name: L1_NATIVE_CURRENCY_NAME,
        symbol: L1_NATIVE_CURRENCY_SYMBOL,
        decimals: L1_NATIVE_CURRENCY_DECIMALS
    },
    rpcUrls: {
        default: {
            http: [L1_RPC_URL]
        }
    }
});

export const l1PublicClient = createPublicClient({
    chain: l1Chain,
    transport: http(L1_RPC_URL)
}) as SdkPublicClient;

async function getConnectedAccount(): Promise<Address> {
    if (!window.ethereum) {
        throw new Error('Wallet extension not detected');
    }

    const accounts = (await window.ethereum.request({
        method: 'eth_requestAccounts'
    })) as Address[];

    const account = accounts[0];
    if (!account) {
        throw new Error('No wallet account selected');
    }

    return account;
}

async function switchWalletToL1(): Promise<void> {
    if (!window.ethereum) {
        throw new Error('Wallet extension not detected');
    }

    const chainIdHex = numberToHex(l1Chain.id);

    try {
        await window.ethereum.request({
            method: 'wallet_switchEthereumChain',
            params: [{ chainId: chainIdHex }]
        });
    } catch (error) {
        if ((error as { code?: number }).code !== 4902) {
            throw error;
        }

        await addL1NetworkToWallet(chainIdHex);
    }

    const currentChainId = await window.ethereum.request({ method: 'eth_chainId' });
    if (currentChainId !== chainIdHex) {
        await addL1NetworkToWallet(chainIdHex);
        await window.ethereum.request({
            method: 'wallet_switchEthereumChain',
            params: [{ chainId: chainIdHex }]
        });
    }
}

async function addL1NetworkToWallet(chainIdHex: Hex): Promise<void> {
    if (!window.ethereum) {
        throw new Error('Wallet extension not detected');
    }

    await window.ethereum.request({
        method: 'wallet_addEthereumChain',
        params: [
            {
                chainId: chainIdHex,
                chainName: l1Chain.name,
                nativeCurrency: l1Chain.nativeCurrency,
                rpcUrls: [L1_RPC_URL]
            }
        ]
    });
}

async function switchWalletToPrividium(): Promise<void> {
    await authorizeWalletNetworkScopes();
    await prividium.addNetworkToWallet();
}

async function switchOrAddPrividiumNetwork(): Promise<void> {
    if (!window.ethereum) {
        throw new Error('Wallet extension not detected');
    }

    const chainIdHex = numberToHex(prividium.chain.id);

    try {
        await window.ethereum.request({
            method: 'wallet_switchEthereumChain',
            params: [{ chainId: chainIdHex }]
        });
    } catch (error) {
        if ((error as { code?: number }).code !== 4902) {
            throw error;
        }

        await prividium.addNetworkToWallet();
    }
}

function appendDataSuffix(data: Hex, dataSuffix?: Hex) {
    if (!dataSuffix) {
        return data;
    }

    return `${data}${dataSuffix.slice(2)}` as Hex;
}

export async function createErc20BridgeSdk(options?: { includeL2Wallet?: boolean }) {
    if (!window.ethereum) {
        throw new Error('Wallet extension not detected');
    }

    await authorizeWalletNetworkScopes();
    const account = await getConnectedAccount();

    const l2 = createPrividiumClient({
        chain: prividium.chain,
        transport: prividium.transport,
        account
    }) as unknown as SdkPublicClient;

    const l1Wallet = createWalletClient({
        account,
        chain: l1Chain,
        transport: custom(window.ethereum)
    }) as unknown as SdkWalletClient;

    const l2Wallet = options?.includeL2Wallet
        ? (createWalletClient({
              account,
              chain: prividium.chain,
              transport: custom(window.ethereum)
          }) as unknown as SdkWalletClient)
        : undefined;

    return {
        account,
        sdk: createViemSdk(createViemClient({ l1: l1PublicClient, l2, l1Wallet, l2Wallet }))
    };
}

export async function depositErc20(params: { l1Token: Address; amount: bigint; to?: Address }) {
    await switchWalletToL1();

    const { account, sdk } = await createErc20BridgeSdk();
    const to = params.to ?? account;

    const quote = await sdk.deposits.quote({
        token: params.l1Token,
        amount: params.amount,
        to
    });

    const handle = await sdk.deposits.create({
        token: params.l1Token,
        amount: params.amount,
        to
    });

    return {
        quote,
        l1TxHash: handle.l1TxHash,
        waitForL2Execution: () => sdk.deposits.wait(handle, { for: 'l2' })
    };
}

export async function withdrawErc20(params: { l2Token: Address; amount: bigint; to?: Address }) {
    await switchWalletToPrividium();

    const { account, sdk } = await createErc20BridgeSdk({ includeL2Wallet: true });
    const to = params.to ?? account;

    const quote = await sdk.withdrawals.quote({
        token: params.l2Token,
        amount: params.amount,
        to
    });

    const handle = await sdk.withdrawals.create({
        token: params.l2Token,
        amount: params.amount,
        to
    });

    return {
        quote,
        l2TxHash: handle.l2TxHash,
        waitUntilReadyToFinalize: () => sdk.withdrawals.wait(handle, { for: 'ready' }),
        finalize: () => sdk.withdrawals.finalize(handle.l2TxHash)
    };
}

export async function withdrawErc20WithAuthorization(params: WithdrawErc20WithAuthorizationParams) {
    if (!window.ethereum) {
        throw new Error('Wallet extension not detected');
    }

    await authorizeWalletNetworkScopes();
    await switchOrAddPrividiumNetwork();

    const { sdk } = await createErc20BridgeSdk();
    const to = params.to ?? params.account;
    const plan = await sdk.withdrawals.prepare({
        token: params.l2Token,
        amount: params.amount,
        to
    });

    const l2Client = createPrividiumClient({
        chain: prividium.chain,
        transport: prividium.transport,
        account: params.account
    });
    const l2Wallet = createWalletClient({
        account: params.account,
        chain: prividium.chain,
        transport: custom(window.ethereum)
    });

    let nextNonce = await l2Client.getTransactionCount({ address: params.account, blockTag: 'pending' });
    let l2TxHash: Hex | undefined;

    for (const step of plan.steps) {
        const tx = step.tx;
        const args = tx.args ?? [];
        const calldata = appendDataSuffix(
            encodeFunctionData({
                abi: tx.abi,
                functionName: tx.functionName,
                args
            }),
            tx.dataSuffix
        );
        const nonce = nextNonce++;
        const fee1559 =
            tx.maxFeePerGas !== undefined && tx.maxPriorityFeePerGas !== undefined
                ? {
                      maxFeePerGas: tx.maxFeePerGas,
                      maxPriorityFeePerGas: tx.maxPriorityFeePerGas
                  }
                : {};
        const gas =
            tx.gas ??
            ((await l2Client.estimateContractGas({
                address: tx.address,
                abi: tx.abi,
                functionName: tx.functionName,
                args,
                account: params.account,
                ...(tx.value !== undefined ? { value: tx.value } : {}),
                ...fee1559
            } as Parameters<typeof l2Client.estimateContractGas>[0])) *
                115n) /
                100n;

        params.onStatus?.(`Authorizing ${step.key} transaction...`);
        await prividium.authorizeTransaction({
            walletAddress: params.account,
            toAddress: tx.address,
            nonce,
            calldata,
            ...(tx.value !== undefined ? { value: tx.value } : {})
        });

        params.onStatus?.(`Sending ${step.key} transaction...`);
        const hash = await l2Wallet.writeContract({
            address: tx.address,
            abi: tx.abi,
            functionName: tx.functionName,
            args,
            account: params.account,
            gas,
            nonce,
            ...(tx.value !== undefined ? { value: tx.value } : {}),
            ...(tx.dataSuffix !== undefined ? { dataSuffix: tx.dataSuffix } : {}),
            ...(tx.chain !== undefined ? { chain: tx.chain } : {}),
            ...fee1559
        } as Parameters<typeof l2Wallet.writeContract>[0]);

        const receipt = await l2Client.waitForTransactionReceipt({ hash });
        if (receipt.status !== 'success') {
            throw new Error(`${step.key} transaction reverted on L2.`);
        }

        l2TxHash = hash;
    }

    if (!l2TxHash) {
        throw new Error('Withdrawal did not submit an L2 transaction.');
    }

    return { l2TxHash };
}

export async function finalizeErc20Withdrawal(l2TxHash: `0x${string}`) {
    await switchWalletToL1();

    const { sdk } = await createErc20BridgeSdk();
    const status = await sdk.withdrawals.status(l2TxHash);

    if (status.phase === 'FINALIZED') {
        return { done: true, l1TxHash: status.l1FinalizeTxHash };
    }

    if (status.phase !== 'READY_TO_FINALIZE' && status.phase !== 'FINALIZE_FAILED') {
        return { done: false, phase: status.phase };
    }

    const result = await sdk.withdrawals.finalize(l2TxHash);
    return {
        done: result.status.phase === 'FINALIZED',
        phase: result.status.phase,
        l1TxHash: result.receipt?.transactionHash
    };
}
```

The helper switches the wallet to L1 for deposits and finalization. It switches back to the Prividium network for
withdrawals.

### Edit `App.vue`

Finally, edit the `App.vue` file to call the prividium and bridging helper functions.

**App.vue**

```vue
<script setup lang="ts">
import { createPrividiumClient } from 'prividium';
import type { Address } from 'viem';
import { erc20Abi, formatUnits, isAddress, parseUnits } from 'viem';
import { computed, onMounted, ref, watch } from 'vue';
import {
    depositErc20,
    finalizeErc20Withdrawal,
    l1Chain,
    l1PublicClient,
    withdrawErc20WithAuthorization
} from './erc20-bridge';
import { authorizeWalletNetworkScopes, prividium } from './lib/prividium';

type BridgeAction = 'connect' | 'deposit' | 'withdraw' | 'finalize';
type BridgeDirection = 'deposit' | 'withdraw';
type TokenState = {
    l1Balance: bigint;
    l2Balance: bigint | null;
};

const l1Token = (import.meta.env.VITE_L1_ERC20_TOKEN_ADDRESS ?? '') as Address;
const l2Token = (import.meta.env.VITE_L2_ERC20_TOKEN_ADDRESS ?? '') as Address;
const tokenDecimals = Number(import.meta.env.VITE_ERC20_TOKEN_DECIMALS ?? '18');

const amount = ref('10');
const recipient = ref('');
const withdrawalTxHash = ref('');
const pendingAction = ref<BridgeAction | null>(null);
const direction = ref<BridgeDirection>('deposit');
const walletAddress = ref<Address | null>(null);
const tokenSymbol = ref('');
const l1TokenBalance = ref<bigint | null>(null);
const l2TokenBalance = ref<bigint | null>(null);
const statusMessage = ref('Connect your wallet, choose a direction, and submit a bridge transaction.');
const errorMessage = ref('');
const balanceError = ref('');
const l1TxHash = ref('');
const l2TxHash = ref('');
const finalizedTxHash = ref('');
const finalizationPhase = ref('');
const statusTxHash = ref('');
const statusTxLabel = ref('');
const copiedStatusTxHash = ref(false);
const isConnected = ref(prividium.isAuthorized());

const fallbackBridgeError = 'Something went wrong. Check the browser console for details.';
const fallbackBalanceError = 'Unable to load one or more token balances. Check the browser console for details.';
const l2PermissionBalanceError =
    'Unable to load the L2 balance with the current Prividium permissions. Check the browser console for details.';
const copyResetDelayMs = 2200;
const depositL2WaitTimeoutMs = 30000;

let copyResetTimeout: ReturnType<typeof setTimeout> | undefined;

const configuredTokens = computed(() => isAddress(l1Token) && isAddress(l2Token));
const isPending = computed(() => pendingAction.value !== null);
const activeTokenBalance = computed(() =>
    direction.value === 'deposit' ? l1TokenBalance.value : l2TokenBalance.value
);
const sourceNetwork = computed(() => (direction.value === 'deposit' ? l1Chain.name : prividium.chain.name));
const submitLabel = computed(() => {
    if (pendingAction.value === 'deposit') return 'Depositing...';
    if (pendingAction.value === 'withdraw') return 'Withdrawing...';
    return direction.value === 'deposit' ? 'Submit deposit' : 'Submit withdrawal';
});
const formattedL1Balance = computed(() => formatTokenBalance(l1TokenBalance.value));
const formattedL2Balance = computed(() => formatTokenBalance(l2TokenBalance.value));
const displaySymbol = computed(() => tokenSymbol.value || 'tokens');
const maxAmount = computed(() =>
    activeTokenBalance.value === null ? '' : formatUnits(activeTokenBalance.value, tokenDecimals)
);
const hasZeroSourceBalance = computed(() => activeTokenBalance.value === 0n);
const bridgeFormDisabled = computed(() => isPending.value || hasZeroSourceBalance.value);
const hasWithdrawalToFinalize = computed(() => isTransactionHash(withdrawalTxHash.value));
const finalizeLabel = computed(() => {
    if (pendingAction.value === 'finalize') return 'Checking...';
    return hasWithdrawalToFinalize.value ? 'Check readiness / finalize' : 'Enter withdrawal hash';
});
const finalizationHelpText = computed(() => {
    if (finalizedTxHash.value) {
        return 'This withdrawal has been finalized on L1.';
    }

    if (finalizationPhase.value) {
        return `Current withdrawal phase: ${finalizationPhase.value}. Check again later if it is not ready yet.`;
    }

    if (hasWithdrawalToFinalize.value) {
        return 'Withdrawals are not finalizable immediately. Click below anytime to check readiness; if it is ready, the app will submit the L1 finalization transaction.';
    }

    return 'After withdrawing, the L2 transaction hash appears here. Paste one here later to check whether it is ready to finalize on L1.';
});
const zeroBalanceMessage = computed(() => {
    if (!hasZeroSourceBalance.value) {
        return '';
    }

    return `You have a 0 ${displaySymbol.value} balance on ${sourceNetwork.value}, so ${
        direction.value === 'deposit' ? 'deposits' : 'withdrawals'
    } are disabled.`;
});

function formatTokenBalance(balance: bigint | null) {
    if (!isConnected.value) return 'Connect wallet';
    if (balance === null) return 'Loading...';
    return `${formatUnits(balance, tokenDecimals)} ${displaySymbol.value}`;
}

function sleep(ms: number) {
    return new Promise((resolve) => {
        window.setTimeout(resolve, ms);
    });
}

function setStatusTransaction(label: string, hash: string) {
    statusTxLabel.value = label;
    statusTxHash.value = hash;
    copiedStatusTxHash.value = false;
}

async function copyStatusTransactionHash() {
    if (!statusTxHash.value) {
        return;
    }

    try {
        await navigator.clipboard.writeText(statusTxHash.value);
        copiedStatusTxHash.value = true;
    } catch {
        errorMessage.value = 'Could not copy the transaction hash.';
        return;
    }

    if (copyResetTimeout) {
        window.clearTimeout(copyResetTimeout);
    }

    copyResetTimeout = window.setTimeout(() => {
        copiedStatusTxHash.value = false;
        copyResetTimeout = undefined;
    }, copyResetDelayMs);
}

function getAmountPattern() {
    return tokenDecimals === 0 ? /^\d*$/ : new RegExp(`^\\d*(?:\\.\\d{0,${tokenDecimals}})?$`);
}

function clampBridgeAmountInput(value: string) {
    if (value === '') return '';
    if (!getAmountPattern().test(value)) return null;
    if (activeTokenBalance.value === null) return value;

    const normalizedValue = value.endsWith('.') ? value.slice(0, -1) : value;
    if (normalizedValue === '') return value;

    try {
        const parsedValue = parseUnits(normalizedValue, tokenDecimals);
        return parsedValue > activeTokenBalance.value ? maxAmount.value : value;
    } catch {
        return null;
    }
}

function parseBridgeAmount() {
    if (!amount.value.trim()) {
        throw new Error('Enter an amount to bridge.');
    }

    if (activeTokenBalance.value === null) {
        throw new Error('Token balance is still loading. Try again in a moment.');
    }

    const parsedAmount = parseUnits(amount.value, tokenDecimals);

    if (parsedAmount <= 0n) {
        throw new Error('Amount must be greater than zero.');
    }

    if (parsedAmount > activeTokenBalance.value) {
        amount.value = maxAmount.value;
        throw new Error('Amount cannot exceed your available balance.');
    }

    return parsedAmount;
}

function setAmountFromInput(event: Event) {
    const input = event.target as HTMLInputElement;
    const nextAmount = clampBridgeAmountInput(input.value);

    if (nextAmount === null) {
        input.value = amount.value;
        return;
    }

    if (nextAmount !== input.value) {
        input.value = nextAmount;
    }

    amount.value = nextAmount;
}

function setMaxAmount() {
    if (maxAmount.value) {
        amount.value = maxAmount.value;
    }
}

function optionalRecipient() {
    const value = recipient.value.trim();
    if (!value) {
        return undefined;
    }

    if (!isAddress(value)) {
        throw new Error('Recipient must be a valid EVM address.');
    }

    return value;
}

function requireConfiguredToken(token: Address, label: string) {
    if (!isAddress(token)) {
        throw new Error(`Set ${label} in your Vite environment before bridging.`);
    }

    return token;
}

function clearTokenState() {
    l1TokenBalance.value = null;
    l2TokenBalance.value = null;
    balanceError.value = '';
}

function collectAddresses(value: unknown, seen = new Set<unknown>()): Address[] {
    if (typeof value === 'string') {
        return isAddress(value) ? [value] : [];
    }

    if (typeof value !== 'object' || value === null || seen.has(value)) {
        return [];
    }

    seen.add(value);

    if (Array.isArray(value)) {
        return value.flatMap((item) => collectAddresses(item, seen));
    }

    return Object.values(value).flatMap((item) => collectAddresses(item, seen));
}

function hasAddress(addresses: Address[], address: Address) {
    return addresses.some((item) => item.toLowerCase() === address.toLowerCase());
}

async function getPrividiumUserWalletAddresses() {
    const user = await prividium.fetchUser();
    return [...new Set(collectAddresses(user.wallets).map((address) => address.toLowerCase()))] as Address[];
}

async function getWalletAccount(requestAccount = false): Promise<Address | null> {
    if (!window.ethereum) {
        throw new Error('Wallet extension not detected');
    }

    const accounts = (await window.ethereum.request({
        method: requestAccount ? 'eth_requestAccounts' : 'eth_accounts'
    })) as Address[];

    const account = accounts[0];
    return account && isAddress(account) ? account : null;
}

async function requestWalletAccountSelection() {
    if (!window.ethereum) {
        throw new Error('Wallet extension not detected');
    }

    try {
        await window.ethereum.request({
            method: 'wallet_requestPermissions',
            params: [{ eth_accounts: {} }]
        });
    } catch (error) {
        const code =
            typeof error === 'object' && error !== null && 'code' in error
                ? (error as { code?: number }).code
                : undefined;

        if (code === 4001) {
            throw error;
        }

        return;
    }
}

async function refreshWalletState(requestAccount = false) {
    walletAddress.value = await getWalletAccount(requestAccount);
    isConnected.value = prividium.isAuthorized() && walletAddress.value !== null;

    if (walletAddress.value && !recipient.value.trim()) {
        recipient.value = walletAddress.value;
    }
}

async function switchOrAddPrividiumNetwork() {
    if (!window.ethereum) {
        throw new Error('Wallet extension not detected');
    }

    const chainIdHex = `0x${prividium.chain.id.toString(16)}`;

    try {
        await window.ethereum.request({
            method: 'wallet_switchEthereumChain',
            params: [{ chainId: chainIdHex }]
        });
    } catch (error) {
        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 readL2BalanceWithDiagnostics(account: Address) {
    const sdkTransportClient = createPrividiumClient({
        chain: prividium.chain,
        transport: prividium.transport,
        account
    });

    try {
        return await sdkTransportClient.readContract({
            address: l2Token,
            abi: erc20Abi,
            functionName: 'balanceOf',
            args: [account]
        });
    } catch {
        return null;
    }
}

async function refreshTokenState() {
    balanceError.value = '';

    if (!isConnected.value || !walletAddress.value) {
        clearTokenState();
        return null;
    }

    if (!configuredTokens.value) {
        tokenSymbol.value = '';
        clearTokenState();
        return null;
    }

    try {
        const symbol = await l1PublicClient.readContract({
            address: l1Token,
            abi: erc20Abi,
            functionName: 'symbol'
        });
        tokenSymbol.value = symbol;

        const nextL1Balance = await l1PublicClient.readContract({
            address: l1Token,
            abi: erc20Abi,
            functionName: 'balanceOf',
            args: [walletAddress.value]
        });

        const prividiumWalletAddresses = await getPrividiumUserWalletAddresses();
        if (!hasAddress(prividiumWalletAddresses, walletAddress.value)) {
            l1TokenBalance.value = nextL1Balance;
            l2TokenBalance.value = null;
            balanceError.value =
                'Connected wallet is not attached to the signed-in Prividium user. Switch wallets or sign in with the matching Prividium user.';
            return { l1Balance: nextL1Balance, l2Balance: null };
        }

        const nextL2Balance = await readL2BalanceWithDiagnostics(walletAddress.value);

        l1TokenBalance.value = nextL1Balance;
        l2TokenBalance.value = nextL2Balance;
        balanceError.value = nextL2Balance === null ? l2PermissionBalanceError : '';

        const clampedAmount = clampBridgeAmountInput(amount.value);
        if (clampedAmount !== null) {
            amount.value = clampedAmount;
        }

        return { l1Balance: nextL1Balance, l2Balance: nextL2Balance };
    } catch {
        l2TokenBalance.value = null;
        balanceError.value = fallbackBalanceError;
        return null;
    }
}

function depositLooksExecuted(before: TokenState | null, after: TokenState | null, depositedAmount: bigint) {
    if (
        before?.l2Balance === null ||
        before?.l2Balance === undefined ||
        after?.l2Balance === null ||
        after?.l2Balance === undefined
    ) {
        return false;
    }

    return after.l2Balance >= before.l2Balance + depositedAmount;
}

async function runBridgeAction<T>(action: BridgeAction, task: () => Promise<T>) {
    pendingAction.value = action;
    errorMessage.value = '';

    try {
        return await task();
    } catch (error) {
        console.error(`Bridge ${action} failed`, error);
        const message = getUserFacingErrorMessage(error);
        errorMessage.value = message;
        statusMessage.value = message;
        return undefined;
    } finally {
        pendingAction.value = null;
    }
}

function getUserFacingErrorMessage(error: unknown) {
    if (!(error instanceof Error)) {
        return fallbackBridgeError;
    }

    if (error.message.startsWith('Set VITE_')) return error.message;
    if (error.message.startsWith('Enter ')) return error.message;
    if (error.message.startsWith('Recipient ')) return error.message;
    if (error.message.startsWith('Amount ')) return error.message;
    if (error.message.startsWith('Connect with Prividium')) return error.message;
    if (error.message.startsWith('Token balance is still loading')) return error.message;
    if (error.message.startsWith('Wallet extension not detected')) return error.message;

    return fallbackBridgeError;
}

async function onConnectClick() {
    return runBridgeAction('connect', async () => {
        statusMessage.value = 'Opening Prividium authorization...';

        await authorizeWalletNetworkScopes();
        statusMessage.value = 'Choose the wallet attached to your Prividium user...';
        await requestWalletAccountSelection();
        await refreshWalletState(true);
        await switchOrAddPrividiumNetwork();
        await refreshTokenState();

        statusMessage.value = 'Connected with Prividium. You can bridge tokens now.';

        return isConnected.value;
    });
}

function onDisconnectClick() {
    prividium.unauthorize();
    isConnected.value = false;
    walletAddress.value = null;
    clearTokenState();
    recipient.value = '';
    statusTxHash.value = '';
    statusTxLabel.value = '';
    copiedStatusTxHash.value = false;
    statusMessage.value = 'Wallet disconnected. Connect again before bridging tokens.';
    errorMessage.value = '';
}

async function requirePrividiumConnection() {
    await refreshWalletState();

    if (!isConnected.value) {
        throw new Error('Connect with Prividium before bridging.');
    }
}

async function onSubmitBridgeClick() {
    return runBridgeAction(direction.value, async () => {
        await requirePrividiumConnection();
        const initialTokenState = await refreshTokenState();

        const parsedAmount = parseBridgeAmount();
        const to = optionalRecipient();

        if (direction.value === 'deposit') {
            statusMessage.value = 'Sending deposit...';
            const result = await depositErc20({
                l1Token: requireConfiguredToken(l1Token, 'VITE_L1_ERC20_TOKEN_ADDRESS'),
                amount: parsedAmount,
                to
            });

            l1TxHash.value = result.l1TxHash;
            setStatusTransaction('L1 deposit tx', result.l1TxHash);
            l2TxHash.value = '';
            withdrawalTxHash.value = '';
            finalizedTxHash.value = '';
            finalizationPhase.value = '';
            statusMessage.value = 'Deposit submitted. Waiting for L2 execution...';
            let latestTokenState = await refreshTokenState();

            try {
                const l2WaitResult = await Promise.race([
                    result.waitForL2Execution().then(() => 'executed' as const),
                    sleep(depositL2WaitTimeoutMs).then(() => 'timeout' as const)
                ]);

                latestTokenState = await refreshTokenState();
                const balanceShowsExecuted = depositLooksExecuted(initialTokenState, latestTokenState, parsedAmount);

                statusMessage.value =
                    l2WaitResult === 'executed' || balanceShowsExecuted
                        ? 'Deposit executed on L2. Balances refreshed.'
                        : 'Deposit submitted. L2 confirmation is taking longer than expected; balances were refreshed and will update when execution is available.';
            } catch {
                latestTokenState = await refreshTokenState();
                statusMessage.value = depositLooksExecuted(initialTokenState, latestTokenState, parsedAmount)
                    ? 'Deposit executed on L2. Balances refreshed.'
                    : 'Deposit submitted. The automatic L2 confirmation check did not finish, but balances were refreshed.';
            }

            return result.l1TxHash;
        }

        statusMessage.value = 'Sending withdrawal...';
        const account = walletAddress.value;
        if (!account) {
            throw new Error('Connect with Prividium before bridging.');
        }

        const result = await withdrawErc20WithAuthorization({
            l2Token: requireConfiguredToken(l2Token, 'VITE_L2_ERC20_TOKEN_ADDRESS'),
            amount: parsedAmount,
            account,
            to,
            onStatus: () => {
                statusMessage.value = 'Sending withdrawal...';
            }
        });

        l2TxHash.value = result.l2TxHash;
        setStatusTransaction('L2 withdrawal tx', result.l2TxHash);
        withdrawalTxHash.value = result.l2TxHash;
        finalizedTxHash.value = '';
        finalizationPhase.value = 'Waiting for L1 readiness';
        statusMessage.value =
            'Withdrawal submitted on L2. It is not ready to finalize immediately; use “Check readiness / finalize” below to see when L1 finalization is available.';
        await refreshTokenState();

        return result.l2TxHash;
    });
}

async function onFinalizeClick(l2Hash = withdrawalTxHash.value as `0x${string}`) {
    return runBridgeAction('finalize', async () => {
        await requirePrividiumConnection();

        if (!l2Hash || !isTransactionHash(l2Hash)) {
            throw new Error('Enter the L2 withdrawal transaction hash to finalize.');
        }

        statusMessage.value = 'Checking withdrawal finalization status...';
        const result = await finalizeErc20Withdrawal(l2Hash);

        finalizedTxHash.value = result.l1TxHash ?? '';
        if (result.l1TxHash) {
            setStatusTransaction('L1 finalize tx', result.l1TxHash);
        }
        const phase = result.done ? 'FINALIZED' : (result.phase ?? 'UNKNOWN');
        finalizationPhase.value = phase;
        statusMessage.value = result.done
            ? 'Withdrawal finalized on L1.'
            : `Withdrawal is not ready to finalize yet. Current phase: ${phase}. Check again in a bit.`;

        if (result.done) {
            await refreshTokenState();
        }

        return result;
    });
}

function isTransactionHash(value: string): value is `0x${string}` {
    return /^0x[a-fA-F0-9]{64}$/.test(value);
}

watch(direction, () => {
    if (isConnected.value) {
        void refreshTokenState();
    }
});

watch(withdrawalTxHash, (nextHash, previousHash) => {
    if (nextHash !== previousHash && nextHash !== l2TxHash.value) {
        finalizationPhase.value = '';
        finalizedTxHash.value = '';
    }
});

onMounted(async () => {
    try {
        await refreshWalletState();
        if (isConnected.value) {
            await refreshTokenState();
        } else {
            clearTokenState();
        }
    } catch {
        isConnected.value = false;
        clearTokenState();
    }
});
</script>

<template>
  <main class="bridge-shell">
    <section class="hero-panel">
      <div class="panel-heading">
        <p class="eyebrow">Prividium ERC-20 bridge</p>
        <h1>Move tokens between L1 and Prividium</h1>
        <p>
          Bridge {{ displaySymbol }} in either direction, then finalize withdrawals when they
          are ready on L1.
        </p>
      </div>

      <section class="connect-card">
        <div>
          <span>Wallet access</span>
          <strong id="bridge-connection-status">{{ isConnected ? 'Connected with Prividium' : 'Not connected' }}</strong>
          <p v-if="walletAddress" id="bridge-wallet-address">{{ walletAddress }}</p>
          <p id="bridge-status-message">{{ statusMessage }}</p>
          <p v-if="errorMessage" id="bridge-error" class="error">{{ errorMessage }}</p>
        </div>
        <button
          class="connect-button"
          :disabled="isPending"
          type="button"
          @click="isConnected ? onDisconnectClick() : onConnectClick()"
        >
          {{ pendingAction === 'connect' ? 'Connecting...' : isConnected ? 'Disconnect wallet' : 'Connect with Prividium' }}
        </button>
      </section>

      <div v-if="!configuredTokens" class="notice warning">
        Add both token addresses with
        <code>VITE_L1_ERC20_TOKEN_ADDRESS</code>
        and
        <code>VITE_L2_ERC20_TOKEN_ADDRESS</code>.
      </div>

      <div v-if="isConnected" class="metric-grid">
        <div>
          <span>Token</span>
          <strong id="bridge-token-symbol">{{ displaySymbol }}</strong>
        </div>
        <div>
          <span>L1 balance</span>
          <strong id="bridge-l1-balance">{{ formattedL1Balance }}</strong>
        </div>
        <div>
          <span>L2 balance</span>
          <strong id="bridge-l2-balance">{{ formattedL2Balance }}</strong>
        </div>
      </div>

      <p v-if="balanceError" id="bridge-balance-error" class="inline-error">{{ balanceError }}</p>
    </section>

    <template v-if="isConnected">
      <section class="workspace-panel">
        <div class="section-heading">
          <p class="eyebrow">Bridge transaction</p>
          <h2>{{ direction === 'deposit' ? 'Deposit to Prividium' : 'Withdraw to L1' }}</h2>
        </div>

        <div class="direction-toggle" role="group" aria-label="Bridge direction">
          <button
            :class="{ active: direction === 'deposit' }"
            type="button"
            @click="direction = 'deposit'"
          >
            Deposit
          </button>
          <button
            :class="{ active: direction === 'withdraw' }"
            type="button"
            @click="direction = 'withdraw'"
          >
            Withdraw
          </button>
        </div>

        <p v-if="zeroBalanceMessage" class="inline-warning">{{ zeroBalanceMessage }}</p>

        <form class="bridge-form" @submit.prevent="onSubmitBridgeClick">
          <label>
            <span>Amount</span>
            <div class="amount-input">
              <input
                :value="amount"
                :disabled="bridgeFormDisabled"
                inputmode="decimal"
                placeholder="10"
                @input="setAmountFromInput"
              />
              <button :disabled="!maxAmount || bridgeFormDisabled" type="button" @click="setMaxAmount">
                Max
              </button>
            </div>
          </label>

          <label>
            <span>Recipient address</span>
            <input v-model="recipient" :disabled="bridgeFormDisabled" placeholder="0x..." />
          </label>

          <button class="submit-button" :disabled="bridgeFormDisabled || !configuredTokens" type="submit">
            {{ submitLabel }}
          </button>
        </form>

        <section class="status-card" aria-live="polite">
          <div>
            <span>Status</span>
            <strong>{{ statusMessage }}</strong>
          </div>
          <div v-if="statusTxHash" class="status-transaction">
            <span>{{ statusTxLabel }}</span>
            <code id="bridge-status-tx-hash">{{ statusTxHash }}</code>
            <button
              class="copy-button"
              :class="{ copied: copiedStatusTxHash }"
              :aria-label="copiedStatusTxHash ? 'Transaction hash copied' : 'Copy transaction hash'"
              type="button"
              @click="copyStatusTransactionHash"
            >
              <svg v-if="copiedStatusTxHash" viewBox="0 0 24 24" aria-hidden="true">
                <path d="M20 6 9 17l-5-5" />
              </svg>
              <svg v-else viewBox="0 0 24 24" aria-hidden="true">
                <rect x="9" y="9" width="11" height="11" rx="2" />
                <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
              </svg>
            </button>
          </div>
        </section>
      </section>

      <section class="workspace-panel">
        <div class="section-heading">
          <p class="eyebrow">Finalize withdrawal</p>
          <h2>Complete an existing withdrawal</h2>
        </div>

        <form class="finalize-form" @submit.prevent="onFinalizeClick()">
          <label>
            <span>L2 withdrawal transaction hash</span>
            <input v-model="withdrawalTxHash" placeholder="0x..." />
          </label>
          <p class="helper-text">{{ finalizationHelpText }}</p>

          <button
            class="submit-button secondary"
            :disabled="isPending || !hasWithdrawalToFinalize"
            type="submit"
          >
            {{ finalizeLabel }}
          </button>
        </form>
      </section>
    </template>
  </main>
</template>

<style scoped>
:global(body) {
  background: #f2f5f7;
}

:global(#app) {
  width: auto;
  border-inline: 0;
}

.bridge-shell {
  min-height: 100svh;
  width: min(760px, 94vw);
  margin: 28px auto;
  display: grid;
  gap: 14px;
  align-content: start;
  background: #f2f5f7;
  color: #1a1f25;
  box-sizing: border-box;
}

.hero-panel,
.workspace-panel {
  width: 100%;
  padding: 16px;
  border: 1px solid #d6dde5;
  border-radius: 12px;
  background: #fff;
  box-sizing: border-box;
  text-align: left;
}

.panel-heading,
.section-heading {
  display: grid;
  gap: 8px;
}

.panel-heading {
  margin-bottom: 16px;
}

.section-heading {
  margin-bottom: 14px;
}

.eyebrow {
  margin: 0;
  color: #64748b;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0;
  text-transform: uppercase;
}

h1,
h2,
p {
  margin: 0;
}

h1 {
  color: #1a1f25;
  font-size: 32px;
  line-height: 1.15;
  letter-spacing: 0;
}

h2 {
  color: #1a1f25;
  font-size: 22px;
  line-height: 1.15;
  letter-spacing: 0;
}

.panel-heading p:last-child {
  max-width: 620px;
  color: #56616f;
}

.notice {
  margin: 18px 0;
  padding: 14px 16px;
  border-radius: 8px;
  font-size: 14px;
}

.warning {
  border: 1px solid #f59e0b;
  background: #fffbeb;
  color: #92400e;
}

.connect-card {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  align-items: center;
  margin-bottom: 14px;
  padding: 12px;
  border: 1px solid #d6dde5;
  border-radius: 12px;
  background: #f8fafc;
}

.connect-card span,
.metric-grid span,
.status-card span,
label span {
  display: block;
  margin-bottom: 4px;
  color: #56616f;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0;
  text-transform: uppercase;
}

.connect-card strong,
.metric-grid strong,
.status-card strong {
  display: block;
  color: #1a1f25;
}

.connect-card p {
  margin-top: 4px;
  color: #56616f;
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  font-size: 12px;
  overflow-wrap: anywhere;
}

.metric-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 10px;
}

.metric-grid div {
  min-width: 0;
  padding: 12px;
  border: 1px solid #d6dde5;
  border-radius: 12px;
  background: #f8fafc;
}

.metric-grid strong {
  overflow-wrap: anywhere;
}

.direction-toggle {
  position: relative;
  display: inline-grid;
  grid-template-columns: repeat(2, minmax(120px, 1fr));
  gap: 0;
  margin-bottom: 14px;
  border: 1px solid #c5ced8;
  border-radius: 8px;
  background: #fff;
}

.direction-toggle button {
  position: relative;
  z-index: 1;
  min-height: 38px;
  border: 0;
  border-radius: 7px;
  background: #fff;
  color: #1a1f25;
  transition:
    background 160ms ease,
    color 160ms ease;
}

.direction-toggle button.active {
  background: #0f6dff;
  color: #fff;
}

.bridge-form,
.finalize-form {
  display: grid;
  gap: 16px;
}

.bridge-form {
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
}

label {
  display: grid;
  gap: 6px;
  color: #56616f;
  font-size: 14px;
  font-weight: 700;
}

input {
  width: 100%;
  min-height: 42px;
  padding: 8px;
  border: 1px solid #c5ced8;
  border-radius: 8px;
  background: #fff;
  color: #1a1f25;
  font: inherit;
  box-sizing: border-box;
}

input:focus {
  border-color: #0f6dff;
  outline: 3px solid rgba(15, 109, 255, 0.16);
}

.amount-input {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 8px;
}

button {
  min-height: 40px;
  padding: 8px 12px;
  border: 1px solid #0f6dff;
  border-radius: 8px;
  background: #0f6dff;
  color: #fff;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}

button.connect-button {
  flex: 0 0 auto;
  background: #0f6dff;
}

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

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

.submit-button {
  grid-column: 1 / -1;
}

.status-card {
  margin-top: 14px;
  padding: 12px;
  border: 1px solid #d6dde5;
  border-radius: 12px;
  background: #f8fafc;
}

.status-transaction {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 8px;
  align-items: center;
  margin-top: 12px;
  padding-top: 12px;
  border-top: 1px solid #d6dde5;
}

.status-transaction span {
  grid-column: 1 / -1;
}

.status-transaction code {
  display: block;
  min-width: 0;
  padding: 0;
  border-radius: 0;
  background: transparent;
  color: #1a1f25;
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  font-size: 13px;
  line-height: 1.45;
  overflow-wrap: anywhere;
}

.copy-button {
  display: inline-grid;
  width: 36px;
  min-width: 36px;
  height: 36px;
  min-height: 36px;
  place-items: center;
  padding: 0;
  border: 1px solid #cbd5e1;
  background: #fff;
  color: #56616f;
}

.copy-button.copied {
  border-color: #16a34a;
  background: #ecfdf5;
  color: #15803d;
}

.copy-button svg {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-width: 2;
}

.error,
.inline-error,
.inline-warning,
.helper-text {
  margin-top: 10px;
}

.error,
.inline-error {
  color: #a22222;
}

.inline-warning {
  margin-bottom: 14px;
  color: #92400e;
}

.helper-text {
  color: #56616f;
  font-size: 14px;
  line-height: 1.45;
}

input:disabled {
  cursor: not-allowed;
  background: #f1f5f9;
  color: #94a3b8;
}

code {
  font-size: 12px;
}

@media (max-width: 760px) {
  .bridge-shell {
    width: 100%;
    margin: 0;
    gap: 0;
  }

  .hero-panel,
  .workspace-panel {
    width: 100%;
    border-inline: 0;
    border-radius: 0;
    box-shadow: none;
    padding: 18px;
  }

  .hero-panel + .workspace-panel,
  .workspace-panel + .workspace-panel {
    border-top: 0;
  }

  h1 {
    font-size: 32px;
  }

  .connect-card {
    align-items: stretch;
    flex-direction: column;
  }

  .metric-grid,
  .bridge-form {
    grid-template-columns: 1fr;
  }

  .direction-toggle {
    grid-template-columns: 1fr;
  }
}
</style>
```

## 4. Test the 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.

:::note
**Adding the network again**

If you have already completed this step previously with a different account or with a local instance of Prividium™ that
you restarted, or you have regenerated your Per-User RPC token, delete the old Prividium™ network and add it again.
:::

### Start the frontend

Start the Vite dev server with:

:::code-group
```bash [npm]
npm run dev
```

```bash [pnpm]
pnpm dev
```

```bash [yarn]
yarn dev
```

```bash [bun]
bun dev
```
:::

Then open [`http://localhost:5173`](http://localhost:5173), connect with Prividium, and choose to either deposit or withdraw some of your tokens.
For withdrawals, note that there are two steps: initiating the withdrawal, and finalizing it on the L1.

If you need to send tokens from your deployer wallet to another wallet, try using the [`send-tokens`](/developers/quickstart/contract-interaction) script in the quickstart.

## Troubleshooting

| Symptom                                 | What to check                                                        |
| --------------------------------------- | -------------------------------------------------------------------- |
| Deposit transaction reverts on L1       | Check the L1 Transaction Filterer and the sender's whitelist status. |
| Withdrawal returns "not authorized"     | Review system contract and token contract permissions.               |
| The withdrawal is not ready to finalize | Wait a few minutes for the withdrawal to be ready.                   |
