> ## Documentation Index
> Fetch the complete documentation index at: https://l2docs.whitechain.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Quick end-to-end test on testnet

> Deploy a test token, then run a full canonical bridge deposit and withdrawal on Whitechain Sepolia to verify the flow end to end.

<Note>
  This tutorial is testnet-only. It uses public faucets and the shortened testnet dispute delays. The timings here do not apply to mainnet, where a withdrawal takes minimum 7 days. See [OP Stack canonical bridge](/build/bridge/bridge-assets) for the mainnet figures.
</Note>

This walkthrough runs a full canonical bridge deposit and withdrawal on Whitechain Sepolia and Ethereum Sepolia. It exists to verify the bridge hands-on: every step maps to a claim on the [OP Stack canonical bridge](/build/bridge/bridge-assets) page. Budget about 1 hour, up to about 3 hours in the worst case, most of it waiting for the withdrawal.

## Before you start

You need MetaMask with two networks added and the same account funded on both.

| Network            | Chain ID | RPC                                                                               | Explorer                                                                 |
| ------------------ | -------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Ethereum Sepolia   | 11155111 | any public Sepolia RPC, for example `https://ethereum-sepolia-rpc.publicnode.com` | [sepolia.etherscan.io](https://sepolia.etherscan.io)                     |
| Whitechain Sepolia | 1874     | `https://rpc.testnet.whitechain.io`                                               | [explorer.testnet.whitechain.io](https://explorer.testnet.whitechain.io) |

Fund the account on each side:

* Sepolia ETH from a public faucet, for example the [Google Cloud Sepolia faucet](https://cloud.google.com/application/web3/faucet/ethereum/sepolia).
* Testnet WBT from the [Whitechain faucet](/learn/get-started/get-testnet-wbt), to pay gas on Whitechain Sepolia.

The same address is used on both networks. The contract addresses this tutorial calls are listed on the [canonical bridge page](/build/bridge/bridge-assets#contract-addresses); the ones you need are the `L1StandardBridge`, the `OptimismMintableERC20Factory` (`0x4200000000000000000000000000000000000012`), the `L2StandardBridge` (`0x4200000000000000000000000000000000000010`), the `OptimismPortal`, and the `DisputeGameFactoryProxy`.

<Warning>
  Use a dedicated testnet account, never a wallet that holds real funds. The withdrawal step loads a private key from a file. Never use a mainnet key, and never commit the key file.
</Warning>

## 1. Deploy a test ERC20 on Sepolia

You need a token pair to move. Deploy a minimal ERC20 on Sepolia. Use an OpenZeppelin ERC20 with 6 decimals, a constructor that mints a starting balance, and an open `mint` method so you can refill later.

<Accordion title="TestToken.sol">
  ```solidity TestToken.sol theme={null}
  // SPDX-License-Identifier: MIT
  pragma solidity ^0.8.30;

  import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

  contract TestToken is ERC20 {
      constructor() ERC20("Test Token", "TST") {
          _mint(msg.sender, 1_000_000 * 10 ** decimals());
      }

      function decimals() public pure override returns (uint8) {
          return 6;
      }

      function mint(address to, uint256 amount) external {
          _mint(to, amount);
      }
  }
  ```
</Accordion>

Deploy it to Sepolia with whichever tool you prefer, then record the deployed L1 token address.

<Tabs>
  <Tab title="Remix">
    In [Remix](https://remix.ethereum.org), deploy to the "Injected Provider" (MetaMask on Sepolia). Enable "Verify contract on Etherscan" in the deploy panel so the L1 token verifies automatically. Record the deployed L1 token address.
  </Tab>

  <Tab title="Hardhat">
    In a [Hardhat](/build/deploy/deploy-with-hardhat) project, install the OpenZeppelin contracts, add the token at `contracts/TestToken.sol`, and add a `sepolia` network to `hardhat.config.ts`:

    ```bash Terminal theme={null}
    npm install @openzeppelin/contracts
    ```

    ```ts hardhat.config.ts theme={null}
    networks: {
      sepolia: {
        type: "http",
        url: "https://ethereum-sepolia-rpc.publicnode.com",
        accounts: [process.env.PRIVATE_KEY!],
      },
    },
    ```

    Deploy and verify:

    ```ts scripts/deploy-token.ts theme={null}
    import { network } from "hardhat";

    const { viem } = await network.create("sepolia");
    const token = await viem.deployContract("TestToken");
    console.log("TestToken deployed to:", token.address);
    ```

    ```bash Terminal theme={null}
    npx hardhat run scripts/deploy-token.ts --network sepolia
    npx hardhat verify --network sepolia <token_address>
    ```
  </Tab>

  <Tab title="Foundry">
    In a [Foundry](/build/deploy/deploy-with-foundry) project, install the OpenZeppelin contracts, add the token at `src/TestToken.sol`, then deploy with `forge create`:

    ```bash Terminal theme={null}
    forge install OpenZeppelin/openzeppelin-contracts
    echo '@openzeppelin/=lib/openzeppelin-contracts/' >> remappings.txt
    ```

    Verification on Sepolia Etherscan needs a free API key in `ETHERSCAN_API_KEY`.

    ```bash Terminal theme={null}
    forge create src/TestToken.sol:TestToken \
      --rpc-url https://ethereum-sepolia-rpc.publicnode.com \
      --private-key $PRIVATE_KEY \
      --broadcast \
      --verify --verifier etherscan --etherscan-api-key $ETHERSCAN_API_KEY
    ```

    The deployed L1 token address prints under `Deployed to`.
  </Tab>
</Tabs>

## 2. Create the L2 token pair

A deposit only works once the L2 side of the pair exists. Create it with the factory on Whitechain Sepolia.

1. Open the `OptimismMintableERC20Factory` at `0x4200000000000000000000000000000000000012` in the [testnet explorer](https://explorer.testnet.whitechain.io/address/0x4200000000000000000000000000000000000012) and go to the write-as-proxy tab.
2. Call `createOptimismMintableERC20WithDecimals` with your L1 token address as `_remoteToken`, a name and symbol for L2, and `6` for `_decimals`.
3. Open the transaction and read the emitted `OptimismMintableERC20Created` (or `StandardL2TokenCreated`) event. The new L2 token address is in the log.
4. Verify the pairing on the new L2 token: `remoteToken()` returns your L1 token, and `bridge()` returns the `L2StandardBridge` (`0x4200000000000000000000000000000000000010`).

<Note>
  Factory-created tokens show only raw bytecode in the explorer, so their read and write tabs are not available. To call the token from a UI, use the Remix "At Address" feature: paste the L2 token address against a minimal `OptimismMintableERC20` interface (with `balanceOf`, `remoteToken`, and `bridge`) and interact from there.
</Note>

## 3. Deposit (L1 to L2)

1. On the L1 token in [Sepolia Etherscan](https://sepolia.etherscan.io), call `approve` with the `L1StandardBridge` as the spender and an amount at or above what you plan to deposit.
2. On the `L1StandardBridge`, call `depositERC20` through the write-as-proxy tab.

| Field          | Example value         | Note                    |
| -------------- | --------------------- | ----------------------- |
| `_l1Token`     | your L1 token address | the token you deployed  |
| `_l2Token`     | your L2 token address | from step 2             |
| `_amount`      | `10000000`            | 10 tokens at 6 decimals |
| `_minGasLimit` | `200000`              | gas for L2 execution    |
| `_extraData`   | `0x`                  | empty                   |

3. After about 2 to 5 minutes, call `balanceOf` on the L2 token with your address to confirm the balance arrived.
4. Optional: check the L1 token balance of the `L1StandardBridge` to see the deposit held in escrow, which shows the lock-and-mint behavior.

<Note>
  A bridged token does not appear in MetaMask automatically. Import the L2 token address manually (Import tokens in MetaMask on Whitechain Sepolia) to see the balance in the wallet.
</Note>

## 4. Withdraw (L2 to L1)

The withdrawal has an on-chain start you do by hand, then a prove and finalize sequence that cannot be done through the explorer. You run those two steps with a script.

### Initiate the withdrawal on L2

On the `L2StandardBridge` (`0x4200000000000000000000000000000000000010`) in the testnet explorer, call `bridgeERC20`:

| Field          | Value                             |
| -------------- | --------------------------------- |
| `_localToken`  | your L2 token address             |
| `_remoteToken` | your L1 token address             |
| `_amount`      | amount to withdraw, in base units |
| `_minGasLimit` | `200000`                          |
| `_extraData`   | `0x`                              |

Record the transaction hash. This is the L2 withdrawal transaction the script needs.

### Prove and finalize with viem

Set up a small project and install viem.

```bash Terminal theme={null}
npm install viem
npm install --save-dev typescript @types/node tsx
```

Create `.env` with a dedicated testnet key and the L2 withdrawal hash from the previous step. The Whitechain Sepolia RPC `https://rpc.testnet.whitechain.io` is archive-capable, which the proof generation (`eth_getProof`) requires.

```bash .env theme={null}
PRIVATE_KEY=0x_your_dedicated_testnet_key
L2_WITHDRAWAL_TX=0x_your_l2_bridge_erc20_tx_hash
```

The script below uses viem OP Stack actions. Three details matter for Whitechain. First, the built-in `whitechainSepolia` chain does not carry the OP Stack contract addresses or a `sourceId`, so the script defines the L2 chain explicitly. Second, Whitechain runs fault proofs (dispute games), so the script uses `getWithdrawalStatus` to wait out the delays. The `waitToFinalize` action assumes the legacy `l2OutputOracle` model and does not apply here. Third, do not confuse the similarly named `whitechainTestnet` export in `viem/chains`, which is a different network (see the warning below).

<Warning>
  `viem/chains` also exports a chain literally named `whitechainTestnet`, but that is a *different* network (chain id `2625`, RPC `rpc-testnet.whitechain.io`). The network this guide uses, chain id `1874` with RPC `rpc.testnet.whitechain.io`, ships in viem as `whitechainSepolia`. Do not import `whitechainTestnet` here; use the explicit `defineChain` below instead.
</Warning>

<Accordion title="withdraw.ts">
  ```ts withdraw.ts theme={null}
  import { createPublicClient, createWalletClient, http, defineChain } from 'viem'
  import { privateKeyToAccount } from 'viem/accounts'
  import { sepolia } from 'viem/chains'
  import {
    publicActionsL1,
    publicActionsL2,
    walletActionsL1,
    getWithdrawals,
  } from 'viem/op-stack'

  const whitechainSepolia = defineChain({
    id: 1874,
    name: 'Whitechain Sepolia',
    nativeCurrency: { name: 'WhiteBIT Token', symbol: 'WBT', decimals: 18 },
    rpcUrls: { default: { http: ['https://rpc.testnet.whitechain.io'] } },
    sourceId: 11155111,
    contracts: {
      portal: { 11155111: { address: '0xff9b597b0781457ae6aa7256ca5ed5839bf7d0c3' } },
      disputeGameFactory: { 11155111: { address: '0xfaa2faa8912c069c01abc169c33713c79027c833' } },
      l1StandardBridge: { 11155111: { address: '0x0c50be539ab5d72d226038928f2eb25100899ded' } },
    },
  })

  const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)

  const L1_RPC = 'https://ethereum-sepolia-rpc.publicnode.com'
  const publicL1 = createPublicClient({ chain: sepolia, transport: http(L1_RPC) }).extend(publicActionsL1())
  const walletL1 = createWalletClient({ account, chain: sepolia, transport: http(L1_RPC) }).extend(walletActionsL1())
  const publicL2 = createPublicClient({ chain: whitechainSepolia, transport: http() }).extend(publicActionsL2())

  const L2_WITHDRAWAL_TX = process.env.L2_WITHDRAWAL_TX as `0x${string}`
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))

  async function main() {
    const receipt = await publicL2.getTransactionReceipt({ hash: L2_WITHDRAWAL_TX })
    const [withdrawal] = getWithdrawals(receipt)

    // Wait for a dispute game that covers the withdrawal's L2 block, then prove.
    const { output, withdrawal: w } = await publicL1.waitToProve({ receipt, targetChain: whitechainSepolia })
    const proveArgs = await publicL2.buildProveWithdrawal({ output, withdrawal: w })
    const proveHash = await walletL1.proveWithdrawal(proveArgs)
    const proveReceipt = await publicL1.waitForTransactionReceipt({ hash: proveHash })
    if (proveReceipt.status !== 'success') throw new Error(`Prove tx reverted: ${proveHash}`)

    // Poll status until both delays elapse (fault-proof chain: not waitToFinalize).
    let status = await publicL1.getWithdrawalStatus({ receipt, targetChain: whitechainSepolia })
    while (status !== 'ready-to-finalize' && status !== 'finalized') {
      await sleep(60_000)
      status = await publicL1.getWithdrawalStatus({ receipt, targetChain: whitechainSepolia })
    }

    // Finalize on L1.
    if (status !== 'finalized') {
      const finalizeHash = await walletL1.finalizeWithdrawal({ targetChain: whitechainSepolia, withdrawal: w })
      const finalizeReceipt = await publicL1.waitForTransactionReceipt({ hash: finalizeHash })
      if (finalizeReceipt.status !== 'success') throw new Error(`Finalize tx reverted: ${finalizeHash}`)
    }

    console.log('withdrawal finalized', withdrawal.withdrawalHash)
  }

  main()
  ```
</Accordion>

Run it:

```bash Terminal theme={null}
npx tsx --env-file=.env withdraw.ts
```

`--env-file` loads the `.env` you created above. Without it `process.env.PRIVATE_KEY` is undefined and the script throws on the first line.

The script leaves itself running through the wait. When it prints `withdrawal finalized`, confirm the L1 token balance on your address increased by the withdrawn amount, and that the `L1StandardBridge` escrow balance decreased by the same amount.

## Timing expectations

Testnet timings observed during validation. Mainnet is far longer; see the [canonical bridge timing parameters](/build/bridge/bridge-assets#timing-parameters).

| Phase                                                     | Expected time                     |
| --------------------------------------------------------- | --------------------------------- |
| Deposit L1 to L2                                          | 2 to 5 minutes                    |
| Wait for a covering dispute game                          | up to about 45 minutes            |
| `proofMaturityDelaySeconds` after prove                   | 15 minutes                        |
| `disputeGameFinalityDelaySeconds` after the game resolves | 5 minutes                         |
| Full withdrawal cycle                                     | about 1 hour, up to about 3 hours |

The wait for a covering game can exceed one proposer interval, because the game must cover the exact L2 block of your withdrawal. If the latest game predates your transaction, you wait for the next one.

## What you have verified

Completing this tutorial confirms the following claims on the canonical bridge page.

| Step                                               | Claim verified                                                                 |
| -------------------------------------------------- | ------------------------------------------------------------------------------ |
| Deposit balance arrives in minutes                 | Deposits finalize on L2 in about 2 minutes                                     |
| Escrow balance rises on deposit, token mints on L2 | Lock-and-mint for L1-origin tokens                                             |
| Factory creates a working L2 pair                  | Token issuer flow via `OptimismMintableERC20Factory`                           |
| Prove then wait then finalize                      | The withdrawal is a multi-step flow, not automatic                             |
| Delays must elapse before finalize                 | `proofMaturityDelaySeconds` and `disputeGameFinalityDelaySeconds` are enforced |
| L1 balance rises, escrow falls on finalize         | Burn-and-release back to L1                                                    |

## Related

* [OP Stack canonical bridge](/build/bridge/bridge-assets)
* [Use viem with Whitechain](/build/dapps/use-viem)
* [Faucet](/learn/get-started/get-testnet-wbt)
