Shield, Transfer, and Withdraw with Bermuda
Approve a testnet token deposit, transfer shielded balances, and confirm a public withdrawal.
Community modules are developed and maintained independently by third-party contributors.
Tether and the WDK Team do not endorse or assume responsibility for their code, security, or maintenance. Use your own judgment and proceed at your own risk.
This guide uses the standalone Bermuda manager to shield USD₮0, transfer a shielded balance, and withdraw to a public address on Plasma testnet. Deposits use a public EVM account; transfers use a Bermuda address; withdrawals expose a public EVM recipient. These operations do not establish an anonymity guarantee.
These examples target Git source manifest version 0.3.2 at revision 59c711a, with both WDK wallet peers pinned to 1.0.0-beta.17. Follow the pinned Git installation instructions before using the examples. If you used 0.1.x, read the migration warning before depositing.
Prerequisites
- Complete the installation and runtime setup. The application code below also imports
ethers@6.17.0directly. - Run this standalone example in Node.js 22 with the pinned peers above.
- Supply an application-managed BIP-39 mnemonic or seed bytes. Keep these outside logs, analytics, operation records, and client configuration files.
- Use a trusted Plasma testnet RPC reporting chain ID
9746, and fund the master EVM account with testnet XPL for approvals and deposit gas. - Confirm Bermuda's testnet services and pool are available. The wallet RPC supplies SDK chain reads by default. Compliance, chain-state, proof-artifact, and relayer services have separate endpoints and trust requirements. See Configuration.
- Review each operation before submission and persist its intent. Serialize operations using the same account; a balance or allowance read is not a reservation.
The examples use these deployment identifiers from the inspected SDK and Bermuda's Plasma testnet deployment descriptor:
| Item | Plasma testnet value |
|---|---|
| Chain ID | 9746 |
| USD₮0 token | 0x502012b361AebCE43b26Ec812B74D9a51dB4D412 |
| Token decimals | 6 |
| Bermuda pool and approval spender | 0x5D4948111Ba7f37A8f67141E6B7d0EC27B7d4310 |
For this testnet token, 1_000_000n represents one USD₮0 unit. These addresses are not Plasma mainnet addresses. Verify the deployment before enabling submissions; matching token metadata and nonempty pool code do not prove protocol security or service readiness.
Shared Setup and Confirmation
Save the following blocks together as an application module. The exported functions accept seed, rpcUrl, and a positive bigint transactionMaxFee in wei. It rejects a public EVM approval or deposit when the checked fee estimate exceeds the threshold. The pinned peer later populates gas and fee fields separately without rechecking that liability, so this setting does not bind final fees, combined transaction costs, or Bermuda protocol and relay fees.
The application supplies two callbacks: confirm(details) reviews the submitted inputs and returns true to continue; recordHash({ kind, hash, chainId }) durably records each returned hash. Neither callback should include secrets in its records. Confirmation is not a fee quote or a cap on later shielded transfer or withdrawal costs. The deposit helper uses the SDK's fixed token spend mode and an allowance bounded to that budget; public gas remains separate.
import WalletManagerBermuda from '@bermuda/wdk-wallet-evm-bermuda'
import { TransactionError, TransactionErrorReason } from '@tetherto/wdk-wallet'
import { Contract, JsonRpcProvider, ZeroAddress, isAddress } from 'ethers'
const PLASMA_TESTNET_CHAIN_ID = 9746n
const USDT0_PLASMA_TESTNET = '0x502012b361AebCE43b26Ec812B74D9a51dB4D412'
const BERMUDA_POOL_PLASMA_TESTNET = '0x5D4948111Ba7f37A8f67141E6B7d0EC27B7d4310'
const TOKEN_ABI = [
'function symbol() view returns (string)',
'function decimals() view returns (uint8)'
]
function validateOperation(amount, confirm, recordHash) {
if (typeof amount !== 'bigint' || amount <= 0n) {
throw new Error('Amount must be a positive bigint in token base units')
}
if (typeof confirm !== 'function' || typeof recordHash !== 'function') {
throw new Error('Confirmation and durable hash recording are required')
}
}
async function withAccounts({ seed, rpcUrl, transactionMaxFee }, operation) {
if (typeof rpcUrl !== 'string' || !rpcUrl) {
throw new Error('A Plasma testnet RPC URL is required')
}
if (typeof transactionMaxFee !== 'bigint' || transactionMaxFee <= 0n) {
throw new Error('Set a positive estimated network fee threshold in wei')
}
const provider = new JsonRpcProvider(rpcUrl)
let wallet
try {
const { chainId } = await provider.getNetwork()
if (chainId !== PLASMA_TESTNET_CHAIN_ID) {
throw new Error('This example requires Plasma testnet, chain ID 9746')
}
const token = new Contract(USDT0_PLASMA_TESTNET, TOKEN_ABI, provider)
const [symbol, decimals, poolCode] = await Promise.all([
token.symbol(),
token.decimals(),
provider.getCode(BERMUDA_POOL_PLASMA_TESTNET)
])
if (symbol !== 'USDT0' || decimals !== 6n || poolCode === '0x') {
throw new Error('The expected testnet token or pool is unavailable')
}
wallet = new WalletManagerBermuda(seed, {
provider: rpcUrl,
transactionMaxFee
})
const master = await wallet.getAccount(0)
const shielded = await wallet.getBermudaAccount(0, 0)
return await operation({ master, shielded, provider })
} finally {
try {
wallet?.dispose()
} finally {
provider.destroy()
}
}
}
async function recordAndConfirm(master, kind, hash, recordHash) {
try {
await recordHash({ kind, hash, chainId: Number(PLASMA_TESTNET_CHAIN_ID) })
} catch (cause) {
throw new Error(`${kind} returned ${hash}, but recording failed; reconcile this hash before retrying`, { cause })
}
let result
try {
result = await master.waitForTransaction(hash, {
target: 'confirmed',
timeout: 120_000
})
} catch (cause) {
throw new Error(`${kind} has an unconfirmed outcome: ${hash}; reconcile before retrying`, { cause })
}
if (!['confirmed', 'final'].includes(result.finality) || result.success !== true) {
throw new Error(`${kind} did not confirm successfully: ${hash}`)
}
return result
}The pinned WDK peers return a normalized receipt from waitForTransaction(): inspect finality and success, rather than treating the returned object as a raw ethers receipt. A wait timeout does not cancel or reverse the transaction.
Deposit into Bermuda
USD₮0 on this testnet does not provide the EIP-2612 permit required by the SDK's fallback path. Approve the pool from the matching master EVM account before depositing. By default, a deposit requests an output amount and adds protocol and any one-off compliance lifetime fee on top. This helper instead supplies exactOutputAmounts: false: tokenSpendBudget is the maximum token funding for this single-recipient deposit, with fees deducted from it. The SDK may leave a base unit unspent or reject a budget that cannot cover the fees; the received amount is variable.
Approval authorizes the pool to spend tokens. The helper sets the allowance to the selected token budget and clears a different nonzero allowance before replacing it. Each approval requires native gas. The budget does not cap gas or other operations. Any allowance left active continues to authorize the pool after this call. The SDK can reject a deposit after approval; stop and reconcile any error after submission before trying again.
async function setDepositAllowance(master, requiredAllowance, recordHash) {
const current = await master.getAllowance(
USDT0_PLASMA_TESTNET, BERMUDA_POOL_PLASMA_TESTNET
)
if (current === requiredAllowance) return
// Clear existing authorization before setting the reviewed amount.
if (current !== 0n) {
const { hash } = await master.approve({
token: USDT0_PLASMA_TESTNET,
spender: BERMUDA_POOL_PLASMA_TESTNET,
amount: 0n
})
await recordAndConfirm(master, 'allowance-reset', hash, recordHash)
if (await master.getAllowance(USDT0_PLASMA_TESTNET, BERMUDA_POOL_PLASMA_TESTNET) !== 0n) {
throw new Error('Pool allowance did not reset to zero')
}
}
const { hash } = await master.approve({
token: USDT0_PLASMA_TESTNET,
spender: BERMUDA_POOL_PLASMA_TESTNET,
amount: requiredAllowance
})
await recordAndConfirm(master, 'deposit-approval', hash, recordHash)
const allowance = await master.getAllowance(
USDT0_PLASMA_TESTNET, BERMUDA_POOL_PLASMA_TESTNET
)
if (allowance !== requiredAllowance) {
throw new Error('Pool allowance differs from the reviewed amount')
}
}
export async function shieldUsdT0({
seed, rpcUrl, transactionMaxFee, tokenSpendBudget, confirm, recordHash
}) {
validateOperation(tokenSpendBudget, confirm, recordHash)
return withAccounts({ seed, rpcUrl, transactionMaxFee }, async ({ master, shielded }) => {
if (await master.getTokenBalance(USDT0_PLASMA_TESTNET) < tokenSpendBudget) {
throw new Error('The public USDt0 balance is below the selected token budget')
}
const to = shielded.getAddress()
if (await confirm({
kind: 'deposit', to, tokenSpendBudget, exactOutputAmounts: false,
token: USDT0_PLASMA_TESTNET, pool: BERMUDA_POOL_PLASMA_TESTNET
}) !== true) throw new Error('Deposit was not confirmed by the application')
await setDepositAllowance(master, tokenSpendBudget, recordHash)
let hash
try {
hash = await shielded.deposit(
{ token: USDT0_PLASMA_TESTNET, amount: tokenSpendBudget, to },
{ exactOutputAmounts: false }
)
} catch (error) {
if (error instanceof TransactionError &&
error.reason === TransactionErrorReason.INSUFFICIENT_BALANCE) {
throw new Error('Insufficient current deposit funding; reconcile recorded operations before retrying', { cause: error })
}
throw error
}
const receipt = await recordAndConfirm(master, 'deposit', hash, recordHash)
return { hash, receipt, to }
})
}For example, tokenSpendBudget: 1_000_000n permits funding of at most one testnet USD₮0 unit for this deposit, including its token fees. It does not request one unit of shielded output. The SDK builds the output using current deposit terms; a change in fees can reduce the output or make the operation fail within that budget.
The reset is an application allowance policy, not a claim that this testnet token always requires zero-first approval. It also reduces a pre-existing allowance larger than the budget. An interrupted or reverted deposit can leave an allowance active. Inspect it before deciding whether to revoke or submit another operation.
After successful on-chain execution, query a newly retrieved shielded account's getTokenBalance(USDT0_PLASMA_TESTNET) separately. Its scan can fail or lag, and vaulted balances are valued in the underlying token. Do not automatically submit another deposit because the balance has not appeared.
Transfer to a Bermuda Address
Supply the intended recipient's full Bermuda address, obtained from their getAddress(): 0x followed by 2,496 hex characters. A Bermuda address is not a 20-byte EVM address and does not encode the chain. The shape check below does not verify ownership, intended recipient, or payment readiness; the SDK still validates the operation.
The transfer amount requests the recipient output. A positive relayFee adds to the sender's shielded spend and requires a full Bermuda relayer fee-recipient address, because the SDK creates a shielded fee output. The pool can also charge a transfer fee on third-party outflow. Vaulted transfers convert underlying amounts to shares with rounding, so the requested amount is not a guarantee of an exact underlying balance change.
The relayer option is not a relay-service URL and does not replace the SDK's configured endpoint. transferMaxFee and transactionMaxFee do not cap token fees, and confirming these inputs does not approve a fixed total shielded spend.
function relayOptions(relayFee, relayer, recipientKind) {
if (typeof relayFee !== 'bigint' || relayFee < 0n) {
throw new Error('Relay fee must be a nonnegative bigint in token base units')
}
if (relayFee === 0n) return {}
if (recipientKind === 'bermuda') {
if (typeof relayer !== 'string' || !/^0x[0-9a-fA-F]{2496}$/.test(relayer)) {
throw new Error('A positive transfer fee requires a full Bermuda fee recipient')
}
} else if (!isAddress(relayer) || relayer.toLowerCase() === ZeroAddress) {
throw new Error('A positive withdrawal fee requires a nonzero EVM fee recipient')
}
return { relayFee, relayer }
}
export async function transferUsdT0({
seed, rpcUrl, transactionMaxFee,
to, amount, relayFee = 0n, relayer, confirm, recordHash
}) {
validateOperation(amount, confirm, recordHash)
if (typeof to !== 'string' || !/^0x[0-9a-fA-F]{2496}$/.test(to)) {
throw new Error('Supply the full recipient Bermuda address')
}
const options = relayOptions(relayFee, relayer, 'bermuda')
return withAccounts({ seed, rpcUrl, transactionMaxFee }, async ({ master, shielded }) => {
if (await confirm({
kind: 'transfer', to, amount, relayFee, relayer, token: USDT0_PLASMA_TESTNET
}) !== true) {
throw new Error('Transfer was not confirmed by the application')
}
const hash = await shielded.transfer({ token: USDT0_PLASMA_TESTNET, to, amount }, options)
const receipt = await recordAndConfirm(master, 'transfer', hash, recordHash)
return { hash, receipt }
})
}The SDK selects UTXOs and computes protocol fees while building the transfer. A balance read is not a lock, and can include value that cannot cover this operation because of class, policy, liquidity, or circuit limits. A returned relay hash does not establish successful confirmation or recipient indexing by itself. Check the receipt and then reconcile the recipient's balance separately.
Withdraw to a Public Address
Review the public EVM recipient explicitly. Omitting to in the wrapper defaults to the master EVM account; this helper requires to to avoid silently selecting that default.
By default, exactOutputAmounts: true treats withdrawal amount as the requested recipient output, with protocol and relay fees added to shielded spend. Set exactOutputAmounts: false explicitly to treat it as gross shielded spend and pay the recipient the remainder after fees. This guide uses one public recipient. For a positive withdrawal relay fee, supply a nonzero public EVM fee-recipient address. Neither WDK network-fee cap bounds these token fees.
export async function withdrawUsdT0({
seed, rpcUrl, transactionMaxFee,
to, amount, exactOutputAmounts = true,
relayFee = 0n, relayer, confirm, recordHash
}) {
validateOperation(amount, confirm, recordHash)
if (!isAddress(to) || to.toLowerCase() === ZeroAddress) {
throw new Error('Supply the intended nonzero public EVM recipient')
}
if (typeof exactOutputAmounts !== 'boolean') {
throw new Error('Select gross-spend or exact-recipient withdrawal accounting')
}
const options = { ...relayOptions(relayFee, relayer, 'evm'), exactOutputAmounts }
return withAccounts({ seed, rpcUrl, transactionMaxFee }, async ({ master, shielded }) => {
if (await confirm({
kind: 'withdraw', to, amount, exactOutputAmounts, relayFee, relayer,
token: USDT0_PLASMA_TESTNET
}) !== true) throw new Error('Withdrawal was not confirmed by the application')
const hash = await shielded.withdraw({ token: USDT0_PLASMA_TESTNET, to, amount }, options)
const receipt = await recordAndConfirm(master, 'withdraw', hash, recordHash)
return { hash, receipt }
})
}For example, amount: 1_000_000n with the default flag requests one testnet USD₮0 unit of recipient output, with fees added to shielded spend. With exactOutputAmounts: false, that amount instead includes the fees. Vaulted withdrawals also depend on share valuation and available liquidity; the SDK can reject insufficient or unavailable UTXOs. Confirmation reviews inputs, not a fixed fee quote or a guaranteed total spend.
USD₮0 withdrawals remain ERC-20 transfers. For the separate native path, deposit NATIVE_DEPOSIT_TOKEN to send native XPL as transaction value, including deposit token fees; the pool wraps it into WXPL. Depositing WXPL instead pulls the ERC-20 token and the wrapper handles its approval. Withdrawal unwrap controls wrapped versus native output. See the native-token options before adapting this ERC-20 example.
Lifetime and Interrupted Operations
The helpers initiate manager cleanup in finally and destroy their separately created receipt/metadata provider. The manager delegates EVM cleanup to the pinned peers, then clears cached Bermuda accounts' stored private scalar references and zeroes their X-Wing viewing-key byte arrays. Cleanup can stop on an exception; it does not erase caller-owned seed copies, delete persisted caches, close every SDK provider, or establish process-wide memory zeroization. Keep seed buffers alive until every consumer has finished, then release application references. Disposing one Bermuda account separately leaves its shared master EVM account available.
Persist returned hashes before waiting, and retain the operation intent even if no hash returns. A failure or timeout after submission can leave an approval, deposit, or relay transaction pending or included. Reconcile each recorded hash, sender nonce and allowance, and pool/shielded state before permitting another submission. Repeating a write is not a status check. If SDK scanning fails, inspect its service configuration and retry reads under an application-controlled schedule; do not infer an empty balance from a failed read.
Next Steps
- Review Provider and Fee Configuration
- Review Method and Option Contracts
- Review Account Setup and Runtime Limits