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

# USDB Withdrawal Guide

This guide covers the steps necessary to bridge USDB from Blast (L2) to Ethereum (L1), where it is paid out as DAI. The guide is geared towards exchanges and other custodians that need to bridge USDB from contracts on L2, such as multisigs or custody contracts.

The steps on Blast are calls made by the contract that holds your USDB; each lists the contract, function, arguments, and ETH value, so you can execute them with whatever your contract or custody platform supports. The steps on Ethereum can be sent from any account, except the final claim, which must be sent by your recipient. See [Building the Prove, Finalize and Claim Transactions](#building-the-prove-finalize-and-claim-transactions) for TypeScript code examples.

## How It Works

USDB is backed by DAI held on Ethereum by Blast's **USDYieldManager**, which earns Blast's native yield on it. Withdrawing USDB uses the standard OP Stack bridge together with Blast's **USD withdrawal queue**, and pays out **DAI**:

* **On Blast**, your USDB is sent to the **L2BlastBridge**, which starts the withdrawal.
* **When you finalize** on Ethereum, a DAI withdrawal request is created for your recipient in the USD withdrawal queue.
* **Once Blast has processed the USD withdrawal queue**, up to 1 day after finalizing, your recipient **claims** the DAI.

| Stage | Network | Function called |
| - | - | - |
| Initiate | Blast (L2) | `bridgeERC20To` on the L2BlastBridge |
| *Wait up to \~1 hour until ready to prove* | | |
| Prove | Ethereum (L1) | `proveWithdrawalTransaction` on the OptimismPortal |
| *Wait **1 day** for the challenge period* | | |
| Finalize | Ethereum (L1) | `finalizeWithdrawalTransaction` on the OptimismPortal |
| *Wait up to **1 day** for the USD withdrawal queue* | | |
| Claim | Ethereum (L1) | `claimWithdrawal` on the USDYieldManager, sent by your recipient |

## Before You Start

### Your L2 Contract Must Be Able to Call the Bridge

The withdrawal is initiated by **the contract that holds the USDB** on the L2. It must be able to make the following contract call: `bridgeERC20To` on the L2BlastBridge. A contract that can only transfer tokens to an address can't start a withdrawal itself; move the USDB to a contract or account that can.

### Verify Your L1 Recipient

Before you start, verify that the intended recipient address meets the following conditions:

* **It exists on Ethereum.** There is contract code at the address, or it's an EOA you control. If nothing is deployed there yet, the DAI withdrawal request is still created for it, but whoever later deploys a contract at that address controls it. A not-yet-deployed multisig must later be deployed with exactly the same configuration to reach the same address.
* **It's the contract you expect.** Its source is verified and it's the contract type you intended. For a multisig, it has the owners and threshold you expect.
* **It can claim the DAI.** The DAI is **not** sent automatically: the recipient itself must call `claimWithdrawal` on the USDYieldManager. Multisigs can do this. Many deposit-forwarder and sweeper contracts **can't**, and the DAI would be stuck. If your L1 contract can't make arbitrary contract calls, withdraw to a multisig or EOA you control first.
* **It can transfer the DAI out.** It must be able to send ERC20 tokens it holds to another address. A contract that can receive tokens but not send them leaves the DAI stuck permanently.

### Anyone Can Prove and Finalize

`proveWithdrawalTransaction` and `finalizeWithdrawalTransaction` on Ethereum don't check who sends them. Any EOA with ETH for gas can submit them; your L2 contract and its signers don't need to do anything on Ethereum. Only the **claim** must be sent by your recipient.

### Test With a Small Amount First

Be sure to run the whole flow end-to-end with a small amount before moving large balances.

## Withdraw Your USDB

<Steps>
  <Step title="Initiate the withdrawal (Blast)">
    | | |
    | - | - |
    | Sent by | Your L2 contract |
    | Contract | **L2BlastBridge** `0x4300000000000000000000000000000000000005` |
    | Function | `bridgeERC20To(address _localToken, address _remoteToken, address _to, uint256 _amount, uint32 _minGasLimit, bytes _extraData)` (selector `0x540abf73`) |
    | Arguments | `_localToken`: USDB `0x4300000000000000000000000000000000000003` <br /> `_remoteToken`: DAI `0x6B175474E89094C44Da98b954EedeAC495271d0F` <br /> `_to`: your Ethereum recipient <br /> `_amount`: amount of USDB to withdraw in wei <br /> `_minGasLimit`: `800000` <br /> `_extraData`: `0x` (empty), or an internal reference |
    | ETH value | 0 |

    No token approval is needed for USDB.

    Record these values. The later steps and status checks use them:

    * the **L2 transaction hash**
    * its **L2 block number**
    * the **`withdrawalHash`**: the last field of the `MessagePassed` event emitted by the L2ToL1MessagePasser (`0x4200000000000000000000000000000000000016`) in this transaction
  </Step>

  <Step title="Prove the withdrawal (Ethereum)">
    Wait until the L2 output containing your transaction has been posted to Ethereum, up to \~1 hour (see [Ready to Prove?](#ready-to-prove)). Then call `proveWithdrawalTransaction` on the **OptimismPortal** (`0x0Ec68c5B10F21EFFb74f2A5C61DFe6b08C0Db6Cb`):

    ```solidity theme={null}
    function proveWithdrawalTransaction(
        Types.WithdrawalTransaction memory _tx,
        uint256 _l2OutputIndex,
        Types.OutputRootProof calldata _outputRootProof,
        bytes[] calldata _withdrawalProof
    ) external;
    ```

    The proof arguments are built from your L2 transaction hash. See [Building the Prove, Finalize and Claim Transactions](#building-the-prove-finalize-and-claim-transactions) for code that does this.
  </Step>

  <Step title="Wait for the challenge period">
    Wait **1 day** after proving.
  </Step>

  <Step title="Finalize the withdrawal (Ethereum)">
    Call `finalizeWithdrawalTransaction` on the **OptimismPortal**, with `hintId` set to `0`:

    ```solidity theme={null}
    function finalizeWithdrawalTransaction(
        uint256 hintId,                        // 0 for USDB
        Types.WithdrawalTransaction memory _tx // same _tx as in the prove step
    ) external;
    ```

    <Warning>
      This signature **differs from other OP Stack chains**: Blast's portal takes an extra `hintId` argument before the withdrawal. For USDB, `hintId` is always `0`.
    </Warning>

    <Warning>
      Set the finalize transaction's gas limit explicitly. The portal reverts with `SafeCall: Not enough gas` if the limit is too low. A withdrawal initiated with `_minGasLimit` `800000` needs about 1,300,000 gas, so use **1,600,000**.
    </Warning>

    See [Building the Prove, Finalize and Claim Transactions](#building-the-prove-finalize-and-claim-transactions) for code that builds and sends this call.

    Finalizing creates a DAI withdrawal request for your recipient in the USD withdrawal queue. **Record the finalize transaction hash**: the claim step uses it to find the request's `requestId`.
  </Step>

  <Step title="Wait for the USD withdrawal queue">
    Wait up to **1 day** after finalizing, until Blast has processed your request in the USD withdrawal queue (see [USD Queue Processed?](#usd-queue-processed)).
  </Step>

  <Step title="Claim the DAI (Ethereum, sent by your recipient)">
    | | |
    | - | - |
    | Sent by | **Your L1 recipient** (the `_to` from the initiate step) |
    | Contract | **USDYieldManager** `0xa230285d5683C74935aD14c446e137c8c8828438` |
    | Function | `claimWithdrawal(uint256 _requestId, uint256 _hintId)` (selector `0xf21340e4`) |
    | Arguments | `_requestId`: from the `WithdrawalRequested` event in your finalize transaction <br /> `_hintId`: `USDYieldManager.findCheckpointHint(_requestId, 1, USDYieldManager.getLastCheckpointId())` |
    | ETH value | 0 |

    The call reverts with `CallerIsNotRecipient()` if it's sent from any other address. On success, the DAI is transferred to your recipient.

    See [Building the Prove, Finalize and Claim Transactions](#building-the-prove-finalize-and-claim-transactions) for code that finds the `requestId` and `hintId` and builds this call for your recipient to submit.

    <Note>
      The amount paid out is calculated from the queue checkpoint's share price. It can be slightly less than the amount withdrawn only if the underlying yield had an uncovered loss.
    </Note>
  </Step>
</Steps>

## Building the Prove, Finalize and Claim Transactions

The examples below use TypeScript and [viem](https://viem.sh) `2.57.3`. The claim must be sent by your recipient, so the claim example builds the call for your recipient to submit instead of sending it.

### Setup

```typescript theme={null}
import {
  createPublicClient,
  createWalletClient,
  encodeFunctionData,
  http,
  isAddressEqual,
  parseAbi,
  parseEventLogs,
  type Hash,
  type Hex,
} from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { blast, mainnet } from 'viem/chains';
import { getWithdrawals, publicActionsL1, publicActionsL2, walletActionsL1 } from 'viem/op-stack';

// Any Ethereum account with ETH for gas can prove and finalize.
const account = privateKeyToAccount(process.env.PRIVATE_KEY as Hex);

const publicClientL1 = createPublicClient({ chain: mainnet, transport: http(process.env.L1_RPC_URL) })
  .extend(publicActionsL1());
const walletClientL1 = createWalletClient({ account, chain: mainnet, transport: http(process.env.L1_RPC_URL) })
  .extend(walletActionsL1());
const publicClientL2 = createPublicClient({ chain: blast, transport: http() })
  .extend(publicActionsL2());
```

### Prove

Run this once the withdrawal is [ready to prove](#ready-to-prove).

```typescript theme={null}
async function prove(l2TxHash: Hash) {
  const receipt = await publicClientL2.getTransactionReceipt({ hash: l2TxHash });
  const [withdrawal] = getWithdrawals(receipt);

  // Prove against the latest L2 output posted to Ethereum. Any output at or after the withdrawal's
  // block works, and public Blast RPCs only serve storage proofs for recent blocks.
  const latestL2Block = await publicClientL1.readContract({
    address: blast.contracts.l2OutputOracle[mainnet.id].address,
    abi: parseAbi(['function latestBlockNumber() view returns (uint256)']),
    functionName: 'latestBlockNumber',
  });
  const output = await publicClientL1.getL2Output({ l2BlockNumber: latestL2Block, targetChain: blast });

  const args = await publicClientL2.buildProveWithdrawal({ output, withdrawal });
  return walletClientL1.proveWithdrawal(args);
}
```

### Finalize

Run this once the [challenge period is over](#challenge-period-over). Then [confirm delivery](#delivered).

```typescript theme={null}
async function finalize(l2TxHash: Hash) {
  const receipt = await publicClientL2.getTransactionReceipt({ hash: l2TxHash });
  const [withdrawal] = getWithdrawals(receipt);

  // Blast's portal takes a hintId before the withdrawal, so call it directly instead of using viem's
  // finalizeWithdrawal. The hintId is only used for ETH withdrawals; for USDB it is always 0.
  const hintId = 0n;
  const finalizeCall = {
    address: blast.contracts.portal[mainnet.id].address,
    abi: parseAbi([
      'struct WithdrawalTransaction { uint256 nonce; address sender; address target; uint256 value; uint256 gasLimit; bytes data; }',
      'function finalizeWithdrawalTransaction(uint256 hintId, WithdrawalTransaction _tx)',
    ]),
    functionName: 'finalizeWithdrawalTransaction',
    args: [hintId, withdrawal],
  } as const;

  // The portal reverts with "SafeCall: Not enough gas" if the gas limit is too low. 1,600,000 is enough for a
  // withdrawal initiated with _minGasLimit 800000; increase it if you used a higher _minGasLimit.
  return walletClientL1.writeContract({ ...finalizeCall, gas: 1_600_000n });
}
```

### Claim

Run this with the hash of your finalize transaction once the [USD queue has processed your request](#usd-queue-processed), up to 1 day after finalizing. It returns the call to submit **from your L1 recipient**.

```typescript theme={null}
const usdYieldManager = {
  address: '0xa230285d5683C74935aD14c446e137c8c8828438',
  abi: parseAbi([
    'event WithdrawalRequested(uint256 indexed requestId, address indexed requestor, address indexed recipient, uint256 amount)',
    'function getLastFinalizedRequestId() view returns (uint256)',
    'function getLastCheckpointId() view returns (uint256)',
    'function findCheckpointHint(uint256 requestId, uint256 start, uint256 end) view returns (uint256)',
    'function claimWithdrawal(uint256 requestId, uint256 hintId) returns (bool)',
  ]),
} as const;

// Builds the claim call from the hash of your finalize transaction on Ethereum. The claim must be sent
// by the recipient, so submit the returned call from your L1 recipient contract.
async function buildClaim(l1FinalizeTxHash: Hash) {
  // Finalizing created a DAI withdrawal request for your recipient: find its requestId.
  const receipt = await publicClientL1.getTransactionReceipt({ hash: l1FinalizeTxHash });
  const [request] = parseEventLogs({
    abi: usdYieldManager.abi,
    eventName: 'WithdrawalRequested',
    logs: receipt.logs.filter((log) => isAddressEqual(log.address, usdYieldManager.address)),
  });
  const { requestId, recipient } = request.args;

  const lastFinalizedRequestId = await publicClientL1.readContract({
    ...usdYieldManager,
    functionName: 'getLastFinalizedRequestId',
  });
  if (lastFinalizedRequestId < requestId) throw new Error('The USD withdrawal queue has not processed this request yet');

  const lastCheckpointId = await publicClientL1.readContract({ ...usdYieldManager, functionName: 'getLastCheckpointId' });
  const hintId = await publicClientL1.readContract({
    ...usdYieldManager,
    functionName: 'findCheckpointHint',
    args: [requestId, 1n, lastCheckpointId],
  });

  return {
    from: recipient,
    to: usdYieldManager.address,
    data: encodeFunctionData({ abi: usdYieldManager.abi, functionName: 'claimWithdrawal', args: [requestId, hintId] }),
  };
}
```

## Checking Withdrawal Status

Every stage can be checked with read-only calls on Ethereum, from your own tooling or the **Read as Proxy** tab on Etherscan, using the **L2 block number** and **`withdrawalHash`** you recorded when you initiated, and the `requestId` from your finalize transaction.

| Stage | Check | Done when |
| - | - | - |
| Ready to prove | `L2OutputOracle.latestBlockNumber()` | ≥ your L2 block number |
| Proven | `OptimismPortal.provenWithdrawals(withdrawalHash)` | `timestamp` ≠ 0 |
| Challenge period over | proven `timestamp` + `L2OutputOracle.FINALIZATION_PERIOD_SECONDS()` | ≤ the current time |
| Finalized | `OptimismPortal.finalizedWithdrawals(withdrawalHash)` | `true` |
| Delivered | `WithdrawalRequested` event in the finalize transaction | present |
| USD queue processed | `USDYieldManager.getLastFinalizedRequestId()` | ≥ your `requestId` |
| Claimed | `USDYieldManager.getWithdrawalStatus([requestId])` | `isClaimed` is `true` |

### Ready to Prove?

```solidity theme={null}
L2OutputOracle.latestBlockNumber() >= l2BlockNumber
```

### Proven?

```solidity theme={null}
(bytes32 outputRoot, uint128 timestamp, uint128 l2OutputIndex, uint256 requestId)
    = OptimismPortal.provenWithdrawals(withdrawalHash);
```

A `timestamp` of `0` means the withdrawal hasn't been proven. `requestId` is always `0` for USDB; the USD queue request is created when you finalize.

### Challenge Period Over?

```solidity theme={null}
block.timestamp >= timestamp + L2OutputOracle.FINALIZATION_PERIOD_SECONDS()
```

`timestamp` is the proven timestamp from the previous check. The challenge period is currently 86400 seconds (1 day).

### Finalized?

```solidity theme={null}
OptimismPortal.finalizedWithdrawals(withdrawalHash) == true
```

This means the finalize transaction has run. It doesn't by itself mean the DAI withdrawal request was created: check [Delivered?](#delivered).

### Delivered?

Finalizing hands the withdrawal to the **L1CrossDomainMessenger** (`0x5D4472f31Bd9385709ec61305AFc749F0fA8e9d0`), which calls the L1BlastBridge to create the DAI withdrawal request. If that call fails, the withdrawal is still marked as finalized, but the messenger records the message as failed and no request is created.

Confirm delivery in any of these ways:

* The finalize transaction emitted `WithdrawalRequested` from the USDYieldManager, with `recipient` set to your recipient. Its `requestId` is the one to claim.
* `L1CrossDomainMessenger.successfulMessages(keccak256(data))` is `true`, where `data` is the `data` field of your withdrawal's `MessagePassed` event.

### USD Queue Processed?

```solidity theme={null}
USDYieldManager.getLastFinalizedRequestId() >= requestId
```

Once this is true, `USDYieldManager.findCheckpointHint(requestId, 1, USDYieldManager.getLastCheckpointId())` returns the `hintId` to claim with.

### Claimed?

```solidity theme={null}
USDYieldManager.getWithdrawalStatus([requestId])
// returns (amount, recipient, timestamp, isFinalized, isClaimed) for each request
```

Once `isClaimed` is `true`, the DAI has been transferred to your recipient.

## Contract Addresses

### Blast (Chain ID 81457)

| Contract | Address |
| - | - |
| USDB | `0x4300000000000000000000000000000000000003` |
| L2BlastBridge | `0x4300000000000000000000000000000000000005` |
| L2ToL1MessagePasser | `0x4200000000000000000000000000000000000016` |

### Ethereum (Chain ID 1)

| Contract | Address |
| - | - |
| DAI | `0x6B175474E89094C44Da98b954EedeAC495271d0F` |
| OptimismPortal | `0x0Ec68c5B10F21EFFb74f2A5C61DFe6b08C0Db6Cb` |
| L2OutputOracle | `0x826D1B0D4111Ad9146Eb8941D7Ca2B6a44215c76` |
| L1BlastBridge | `0x3a05E5d33d7Ab3864D53aaEc93c8301C1Fa49115` |
| USDYieldManager | `0xa230285d5683C74935aD14c446e137c8c8828438` |
| L1CrossDomainMessenger | `0x5D4472f31Bd9385709ec61305AFc749F0fA8e9d0` |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.