CBTC Minting and Burning
⚠️ API Disclaimer: CBTC APIs are subject to change. There is no formal versioning policy today. Breaking changes are communicated via the site changelog.
Overview: The Full BTC to CBTC Lifecycle
Section titled “Overview: The Full BTC to CBTC Lifecycle”This guide covers the complete lifecycle of converting Bitcoin to CBTC and back: minting (BTC to CBTC on Canton) and burning (CBTC back to BTC). For a quick end-to-end walkthrough, see the CBTC Quick Start. This guide goes deeper into each step, covering edge cases, error handling, and recovery patterns for production integrations.
🛠️ Prerequisite: install the SDK. The Rust examples on this page use the
cbtc-libSDK. See SDK Setup and Installation for installingcbtc-libandcanton-lib, uploading the CBTC DAR files, and configuring your environment.
Key facts:
- Exchange rate: 1 BTC = 1 CBTC, always
- Confirmations required: 6 Bitcoin block confirmations (~60 minutes)
- Processing time: Additional 60-120 seconds after confirmations for Attestor verification
- Wallet requirement: Taproot-compatible Bitcoin wallet (P2TR addresses)
- Transaction limits: 0.0001 BTC minimum, 5 BTC maximum by default - set per account and adjustable on request (see below)
- Minter credential: Required before you can create a deposit or withdraw account (see below)
Transaction Limits
Section titled “Transaction Limits”Mint and burn amounts are bounded by per-account limits. The defaults are:
| Limit | Default |
|---|---|
| Minimum | 0.0001 BTC |
| Maximum | 5 BTC |
💡 Limits are adjustable. These are defaults, not fixed protocol constraints. If your integration needs a different range, contact sales@bitsafe.finance to have your account limits modified.
Read limits from the account, do not hardcode them
Section titled “Read limits from the account, do not hardcode them”Because limits are set per account and can be changed on request, read them at runtime rather than hardcoding the defaults. They are carried on the Deposit Account (for minting) and the Withdraw Account (for burning), and are returned by the deposit account status call:
use cbtc::mint_redeem::mint;
let status = mint::get_deposit_account_status(mint::GetDepositAccountStatusParams { ledger_host: ledger_host.clone(), party: party_id.clone(), access_token: access_token.clone(), api_url: api_url.clone(), account_contract_id: deposit_account.contract_id.clone(),}).await?;
if let Some(limits) = &status.limits { println!("Min: {:?} | Max: {:?}", limits.min_amount, limits.max_amount);}Both fields are optional. None means that bound is not enforced for the account:
pub struct Limits { pub min_amount: Option<DamlDecimal>, // serialised as "minAmount" pub max_amount: Option<DamlDecimal>, // serialised as "maxAmount"}Validate before submitting
Section titled “Validate before submitting”cbtc::mint_redeem::models::check_limits validates an amount against a set of limits locally, so an out-of-range request fails in your code instead of being rejected downstream:
use cbtc::mint_redeem::models::check_limits;
check_limits("Withdraw", amount.clone(), &withdraw_account.limits)?;It returns a descriptive error - "Withdraw amount 0.00001 is below minimum 0.0001" or "... exceeds maximum 5" - and succeeds when no limits are set.
Prerequisite: Obtain a Minter Credential
Section titled “Prerequisite: Obtain a Minter Credential”⚠️ Minting and burning require a Minter credential. This is a hard requirement, not an optional step. Creating a deposit account or a withdraw account will fail without one. Transferring, receiving, and holding CBTC do not require a credential — only minting and burning do.
A Minter credential is a Daml contract issued to your Canton party by the CBTC registrar. It carries a claim with the property hasCBTCRole and the value Minter, and you pass its contract ID into the account-creation calls.
How to request one
Section titled “How to request one”Minter credentials are issued as part of commercial onboarding. To request one, contact the BitSafe team at sales@bitsafe.finance with your Canton Party ID and target environment (testnet or mainnet).
Check whether you already hold one
Section titled “Check whether you already hold one”Once the registrar has issued your credential, use the cbtc::credentials module to find it. A credential is a Minter credential if any of its claims has property == "hasCBTCRole" and value == "Minter":
use cbtc::credentials::{list_credentials, ListCredentialsParams};
let credentials = list_credentials(ListCredentialsParams { ledger_host: ledger_host.clone(), party: party_id.clone(), access_token: access_token.clone(),}).await?;
let minter_credential_cids: Vec<String> = credentials .iter() .filter(|c| { c.claims .iter() .any(|claim| claim.property == "hasCBTCRole" && claim.value == "Minter") }) .map(|c| c.contract_id.clone()) .collect();
if minter_credential_cids.is_empty() { return Err("No Minter credential found for this party".into());}Accept a pending credential offer
Section titled “Accept a pending credential offer”The registrar issues the credential as an offer that your party must accept before it becomes active. If list_credentials returns nothing, check for a pending offer and accept it:
use cbtc::credentials::{ accept_credential_offer, find_user_service, list_credential_offers, AcceptCredentialOfferParams, FindUserServiceParams, ListCredentialOffersParams,};
let user_service = find_user_service(FindUserServiceParams { ledger_host: ledger_host.clone(), party: party_id.clone(), access_token: access_token.clone(),}).await?;
let offers = list_credential_offers(ListCredentialOffersParams { ledger_host: ledger_host.clone(), party: party_id.clone(), access_token: access_token.clone(),}).await?;
accept_credential_offer(AcceptCredentialOfferParams { ledger_host: ledger_host.clone(), party: party_id.clone(), access_token: access_token.clone(), user_service_contract_id: user_service.contract_id.clone(), user_service_template_id: user_service.template_id.clone(), credential_offer_cid: offers[0].contract_id.clone(),}).await?;ℹ️ Accepting a credential is permanent. It creates a persistent on-ledger contract with no archive choice, so a credential cannot be un-accepted. Accept only the offer you actually intend to use.
A complete runnable version of this flow is in the library’s credentials example.
How to Mint CBTC: Deposit Bitcoin and Receive Wrapped BTC on Canton
Section titled “How to Mint CBTC: Deposit Bitcoin and Receive Wrapped BTC on Canton”How It Works
Section titled “How It Works”sequenceDiagram participant Dev as Your App participant Canton as Canton Ledger API participant BTC as Bitcoin Network participant Att as Attestor Network Dev->>Canton: 1. Authenticate (get JWT) Dev->>Canton: 2. Create Deposit Account Canton-->>Dev: Deposit Account ID Dev->>Canton: 3. Request BTC Deposit Address Canton-->>Dev: Taproot address (P2TR) Dev->>BTC: 4. Send BTC to address BTC-->>Att: 5. Attestors monitor for 6 confirmations Att->>Canton: 6. Submit ConfirmDepositAction (threshold) Canton-->>Dev: 7. CBTC minted to your party
Step-by-Step
Section titled “Step-by-Step”Step 1: Authenticate
Obtain a JWT token from your OIDC provider (Keycloak is officially supported). See the Authentication Guide for setup details.
Step 2: Create a Deposit Account
A Deposit Account is required before you can generate deposit addresses. This call requires the Minter credential contract IDs obtained in the prerequisite step above.
Using cbtc-lib (Rust):
use cbtc::mint_redeem::{mint, attestor};
// First get account rules from the Attestorlet account_rules = attestor::get_account_contract_rules(&api_url).await?;
let deposit_account = mint::create_deposit_account(mint::CreateDepositAccountParams { ledger_host: ledger_host.clone(), party: party_id.clone(), user_name: username.clone(), access_token: access_token.clone(), account_rules, credential_cids: minter_credential_cids.clone(),}).await?;
println!("Deposit Account ID: {}", deposit_account.contract_id);Using Canton API (curl):
You can fetch the CBTCDepositAccountRules from the BitSafe API’s /cbtc/v1/account-contract-rules endpoint
⚠️ A Minter credential is required. The
CreateDepositAccountchoice takes your Minter credential contract IDs as an argument. See Prerequisite: Obtain a Minter Credential above. Requests without a valid credential are rejected.
curl -X POST "${LEDGER_HOST}/v2/commands/submit-and-wait-for-transaction" \ --header 'Authorization: Bearer ${ACCESS_TOKEN}' \ --header 'Content-Type: application/json' \ --data '{ "commands": { "commands": [ { "ExerciseCommand": { "templateId": "#cbtc:CBTC.DepositAccount:CBTCDepositAccountRules", "contractId": "${DA_RULES_CID}", "choice": "CBTCDepositAccountRules_CreateDepositAccount", "choiceArgument": { "owner" : "${OWNER_PARTY}", "credentialCids": ["${MINTER_CREDENTIAL_CID}"] } } } ], "actAs": [ "${OWNER_PARTY}" ], "commandId": "someCommandID", "disclosedContracts": [ { "templateId": "#cbtc:CBTC.DepositAccount:CBTCDepositAccountRules", "contractId": "${DA_RULES_CID}", "createdEventBlob": "${DA_RULES_BLOB}", "synchronizerId": "" } ] }}'Step 3: Generate a Bitcoin Deposit Address
Each deposit address is unique to your account and is a standard Taproot (P2TR) address.
Using cbtc-lib (Rust):
use cbtc::mint_redeem::mint;
let btc_address = mint::get_bitcoin_address(mint::GetBitcoinAddressParams { api_url: api_url.clone(), account_id: deposit_account.contract_id.clone(),}).await?;
println!("Send BTC to: {}", btc_address);Step 4: Send Bitcoin
Send the exact amount of BTC you want to mint as CBTC to the generated Taproot address from your Bitcoin wallet.
Step 5: Wait for Confirmations
The Attestor network automatically monitors the Bitcoin network. Once your transaction reaches 6 confirmations (~60 minutes), it transitions to the processing state.
You can poll for deposit status:
Using cbtc-lib (Rust):
use cbtc::mint_redeem::mint;
let status = mint::get_deposit_account_status(mint::GetDepositAccountStatusParams { ledger_host: ledger_host.clone(), party: party_id.clone(), access_token: access_token.clone(), api_url: api_url.clone(), account_contract_id: deposit_account.contract_id.clone(),}).await?;
println!("Bitcoin address: {} | Last processed block: {}", status.bitcoin_address, status.last_processed_bitcoin_block);Step 6: Attestor Verification
This step is fully automated. The Attestor network:
- Independently verifies the Bitcoin transaction has 6+ confirmations
- Each Attestor submits a
ConfirmDepositActionto the Canton governance module - Once the required threshold of confirmations is reached, the Coordinator executes the mint No action is required from your application during this step.
Step 7: CBTC Available
Your CBTC is minted and available in your Canton party. Check your balance:
Using cbtc-lib (Rust):
use cbtc::active_contracts;
let holdings = active_contracts::get(active_contracts::Params { ledger_host: ledger_host.clone(), party: party_id.clone(), access_token: access_token.clone(), instrument_id: cbtc::InstrumentId { admin: decentralized_party_id.clone(), id: cbtc::CBTC_TICKER.to_string(), }, account: None, // None reads every holding the party owns}).await?;
println!("CBTC holdings: {} contract(s)", holdings.len());How to Burn CBTC: Redeem Wrapped Bitcoin for Native BTC
Section titled “How to Burn CBTC: Redeem Wrapped Bitcoin for Native BTC”How It Works
Section titled “How It Works”sequenceDiagram participant Dev as Your App participant Canton as Canton Ledger API participant Att as Attestor Network participant BTC as Bitcoin Network Dev->>Canton: 1. Create WithdrawAccount (set BTC destination address) Dev->>Canton: 2. Submit withdrawal against WithdrawAccount Canton->>Att: 3. Attestors verify and approve withdrawal Att->>Att: 4. FROST threshold signing of BTC transaction Att->>BTC: 5. Broadcast signed transaction BTC-->>Dev: 6. BTC arrives after 6 confirmations
Step-by-Step
Section titled “Step-by-Step”Step 1: Initiate a Burn
First, create a WithdrawAccount with your destination BTC address. The destination address is stored on the WithdrawAccount and can be updated later. Then submit the withdrawal against that account. Like deposit account creation, this requires your Minter credential contract IDs.
Using cbtc-lib (Rust):
use cbtc::mint_redeem::redeem;
// Step 1: Create a withdraw accountlet withdraw_account = redeem::create_withdraw_account(redeem::CreateWithdrawAccountParams { ledger_host: ledger_host.clone(), party: party_id.clone(), user_name: username.clone(), access_token: access_token.clone(), account_rules_contract_id: rules.wa_rules.contract_id.clone(), account_rules_template_id: rules.wa_rules.template_id.clone(), account_rules_created_event_blob: rules.wa_rules.created_event_blob.clone(), destination_btc_address: btc_destination_address.clone(), credential_cids: minter_credential_cids.clone(),}).await?;
// Step 2: Submit the withdrawal (burns CBTC, Attestor network processes BTC payout)let updated_account = redeem::submit_withdraw(redeem::SubmitWithdrawParams { ledger_host: ledger_host.clone(), party: party_id.clone(), user_name: username.clone(), access_token: access_token.clone(), api_url: api_url.clone(), withdraw_account_contract_id: withdraw_account.contract_id.clone(), amount: cbtc::DamlDecimal::parse("0.001")?, holding_contract_ids: holding_ids, credential_cids: Some(minter_credential_cids.clone()),}).await?;
println!("Withdrawal submitted. Pending balance: {}", updated_account.pending_balance);ℹ️ Amounts are
DamlDecimal. Build them withcbtc::DamlDecimal::parse, which validates the value against Daml’s decimal precision rules up front, so an unrepresentable amount fails locally instead of being rejected by the ledger. This applies to transfer and allocation amounts too.
Step 2: Attestor Verification and Signing
The Attestor network:
- Verifies the burn request on Canton
- Constructs the Bitcoin withdrawal transaction
- Coordinates FROST threshold signing across Attestors
- Once the signing threshold is met, broadcasts the signed transaction to the Bitcoin network This step is fully automated. No action required.
Step 3: Bitcoin Delivery
After the signed transaction is broadcast, wait for 6 Bitcoin confirmations. Your BTC will arrive at the specified destination address.
Error Handling and Recovery Patterns for CBTC Integrations
Section titled “Error Handling and Recovery Patterns for CBTC Integrations”🔧 Error handling is critical for production integrations. The CBTC system includes built-in resilience, but your application should handle these scenarios gracefully.
Failed Broadcast
Section titled “Failed Broadcast”The system includes automatic retry logic. If a Bitcoin transaction fails to broadcast initially, the Coordinator detects the failure during subsequent periodic checks (every 60-120 seconds) and rebroadcasts using stored transaction data.
What your app should do: Monitor withdrawal status. If status remains in broadcasting for more than 10 minutes, log an alert for investigation.
Insufficient Confirmations
Section titled “Insufficient Confirmations”If a deposit stalls below 6 confirmations (e.g., due to Bitcoin network congestion), the system simply waits. There is no timeout.
What your app should do: Display the current confirmation count to the user. Consider showing an estimated time based on current Bitcoin block times.
Idempotency
Section titled “Idempotency”Each withdrawal generates a unique transaction ID that prevents accidental double-spending, even if network issues cause retry attempts. The system is designed to be idempotent.
What your app should do: Store the withdrawal request ID and use it for status checks rather than initiating duplicate requests.
Attestor Timeout
Section titled “Attestor Timeout”If governance approval is delayed (e.g., some Attestors are temporarily offline), the system continues to collect approvals. As long as the threshold can eventually be met, the operation will complete.
What your app should do: If a mint or burn is pending for more than 2 hours, escalate to BitSafe support.
Partial Failure
Section titled “Partial Failure”If some Attestors approve but the threshold is not reached (e.g., too many Attestors offline simultaneously), the operation will remain pending until the threshold is met or the situation is resolved.
What your app should do: Alert your operations team. Contact support@bitsafe.finance.
CBTC UTXO Management: Consolidation and Best Practices
Section titled “CBTC UTXO Management: Consolidation and Best Practices”⚠️ Important for high-volume integrations. Each CBTC transfer creates UTXOs. Canton recommends a maximum of 10 UTXOs per party. Exceeding this causes increased load and fees on your node.
The cbtc-lib Rust library provides functions for managing UTXOs:
use cbtc::consolidate;
// Check UTXO count and consolidate if threshold exceededlet result = consolidate::check_and_consolidate(consolidate::CheckConsolidateParams { party: party_id.clone(), instrument_id: cbtc::InstrumentId { admin: decentralized_party_id.clone(), id: cbtc::CBTC_TICKER.to_string(), }, threshold: 10, // Canton's soft limit ledger_host: ledger_host.clone(), access_token: access_token.clone(), registry_url: registry_url.clone(), decentralized_party_id: decentralized_party_id.clone(),}).await?;
if result.consolidated { println!("Consolidated {} UTXOs into {}", result.utxos_before, result.utxos_after);}Best practices:
- Monitor UTXO count per party and consolidate proactively
- Batch transfers where possible to minimise UTXO creation
- If creating many parties, use the Ledger API directly (not wallet UI/API) - see Canton docs
Escalation Path
Section titled “Escalation Path”| Situation | Action |
|---|---|
| Mint pending > 2 hours | Check Bitcoin confirmations first. If 6+ confirmations reached, contact BitSafe |
| Burn pending > 2 hours | Contact BitSafe engineering |
| Unexpected error from API | Retry with exponential backoff. If persistent, contact BitSafe |
| UTXO-related issues | Use cbtc-lib consolidation functions. If unresolved, contact BitSafe |
Support channels:
- Email: support@bitsafe.finance
Ask the docs
Answers come only from this documentation and can be wrong. Check the linked pages.
Ask a question about BitSafe, CBTC or the developer guides. For example: