A Rust client library for BitSafe's bridged assets on Canton Network: CBTC (Bitcoin) and BETH (Ether). One API shape serves both assets. The library also re-exports the Canton Token Standard operations of canton-lib, so you can hold, send and receive both assets.
Status: 0.1.0. The API can change in any 0.x release. The crate is not on crates.io; you add it as a git dependency. It needs Rust 1.94 or newer (edition 2024).
Add the crate to your Cargo.toml:
[dependencies]
bitsafe-token = { git = "https://github.com/DLC-link/bitsafe-token-lib", tag = "v0.1.0" }
# Login. Or use your own OpenID Connect client and pass its access token.
keycloak = { git = "https://github.com/DLC-link/canton-lib", tag = "v0.10.0" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }If you add other canton-lib crates, pin the same tag, v0.10.0. Two
different pins make Cargo build two copies of the shared types, and then the
types do not match.
You need these, for the network you use:
- A Canton participant node with the JSON ledger API, such as the participant of your Canton Network validator.
- A party on that participant. A party is your identity on the ledger,
such as
your-party::1220.... - A Keycloak (or other OpenID Connect) user that can act as your party.
- The Digital Asset Registry Utility on your validator, for holdings and transfers.
- The DAR packages on your participant. See
dars/README.md;check_darstells you what is missing. - A Minter credential, to mint or burn only. The asset's registrar offers it to your party; ask BitSafe for it. You accept the offer once. Holdings and transfers need no credential.
| Term | Meaning |
|---|---|
| Registrar | The party that issues an asset on one network. Each asset module knows its registrar for each network |
| Minter credential | A credential from the registrar with the claim hasCBTCRole or hasBETHRole = Minter. The mint and burn calls need it |
| Deposit account | A contract that ties your party to a deposit: a BTC address for CBTC, a deposit id for BETH |
| Withdraw account | A contract that holds your payout address and your pending (burned) balance |
| Withdraw request | The payout record. The attestors create it after a burn and archive it after the payout |
| Holding | One unit of an asset that your party owns, like a UTXO |
Mint: create a deposit account and get the BTC address to fund. Burn: burn CBTC into a withdraw account. The attestors then pay out BTC.
use bitsafe_token::{
DamlDecimal, Network,
credentials::{ListCredentialsParams, list_credentials},
tokens::cbtc::{self, mint, redeem},
};
use keycloak::login::{PasswordParams, password, token_url};
#[tokio::main]
async fn main() -> Result<(), String> {
let network = Network::Devnet;
let ledger_host = "https://participant.example.com/api/json-api".to_string();
let party = "your-party::1220...".to_string();
// Log in as a user that can act as `party`.
let access_token = password(PasswordParams {
client_id: "your-client-id".into(),
username: "your-username".into(),
password: "your-password".into(),
url: token_url("https://keycloak.example.com", "your-realm"),
})
.await?
.access_token;
// The registrar's Minter credential, which you accepted once.
let credentials = list_credentials(ListCredentialsParams {
ledger_host: ledger_host.clone(),
party: party.clone(),
access_token: access_token.clone(),
})
.await?;
let credential_cids = cbtc::minter_credential_cids(network, &credentials);
// Mint: create a deposit account and get the BTC address to fund.
let rules = mint::get_account_contract_rules(network).await?;
let account = mint::create_deposit_account(mint::CreateDepositAccountParams {
ledger_host: ledger_host.clone(),
party: party.clone(),
access_token: access_token.clone(),
account_rules: rules.clone(),
credential_cids: credential_cids.clone(),
})
.await?;
let btc_address = mint::get_bitcoin_address(network, &account).await?;
println!("Send BTC to {btc_address}");
// Burn: create a withdraw account, then burn 0.001 CBTC into it.
let withdraw_account = redeem::create_withdraw_account(redeem::CreateWithdrawAccountParams {
ledger_host: ledger_host.clone(),
party: party.clone(),
access_token: access_token.clone(),
account_rules: rules,
destination_address: "your-btc-address".into(),
credential_cids: credential_cids.clone(),
})
.await?;
let holdings = redeem::list_holdings(redeem::ListHoldingsParams {
ledger_host: ledger_host.clone(),
party: party.clone(),
access_token: access_token.clone(),
instrument_id: cbtc::instrument(network),
})
.await?;
let withdraw_account = redeem::submit_withdraw(
network,
redeem::SubmitWithdrawParams {
ledger_host: ledger_host.clone(),
party: party.clone(),
access_token: access_token.clone(),
account: &withdraw_account,
amount: DamlDecimal::parse("0.001").map_err(|e| e.to_string())?,
holdings: &holdings,
credential_cids,
},
)
.await?;
println!("Pending: {}", withdraw_account.pending_balance);
// Later: the attestors create a withdraw request and pay out.
let requests = redeem::list_withdraw_requests(redeem::ListWithdrawRequestsParams {
ledger_host,
party,
access_token,
})
.await?;
println!("{} withdraw request(s)", requests.len());
Ok(())
}BETH uses the same calls under tokens::beth. Only the deposit step
differs: deposit_call builds the Ethereum transaction that mints BETH. The
library holds no keys and sends nothing. You sign and send the transaction
with your own wallet, on the chain in call.chain_id.
use bitsafe_token::{
Network,
tokens::beth::mint::{self, U256},
};
// Log in and find the Minter credential as in the CBTC example, with
// `beth::minter_credential_cids`.
async fn mint_beth(
network: Network,
ledger_host: String,
party: String,
access_token: String,
credential_cids: Vec<String>,
) -> Result<(), String> {
let rules = mint::get_account_contract_rules(network).await?;
let account = mint::create_deposit_account(mint::CreateDepositAccountParams {
ledger_host,
party,
access_token,
account_rules: rules,
credential_cids,
})
.await?;
// 0.01 ETH, in wei.
let call = mint::deposit_call(network, &account, U256::from(10_000_000_000_000_000_u64))?;
println!("chain {} to {} value {} data {}", call.chain_id, call.to, call.value, call.data);
Ok(())
}Units. The two directions use different units:
deposit_calltakes wei as aU256. 1 ETH is 10^18 wei, and the amount must be a whole multiple of 100000000 wei.redeem::submit_withdrawtakes ETH as aDamlDecimal, such as0.01, the same as CBTC takes BTC.beth::mint::wei_to_daml_decimalanddaml_decimal_to_weiconvert.
Chains. Devnet and testnet BETH run on Sepolia (chain 11155111), so you
need Sepolia ETH. Mainnet BETH runs on Ethereum mainnet (chain 1). The
library does not read the bridge's live depositLimits() or paused();
check both with your Ethereum provider before you send.
The re-exported TokenClient reads balances and sends either asset. The
asset module gives it the right configuration for each network:
use bitsafe_token::{
DamlDecimal, KeycloakConfig, Network, SendParams, TokenClient, TokenStandardVersion,
tokens::cbtc,
};
use keycloak::login::token_url;
#[tokio::main]
async fn main() -> Result<(), String> {
let mut client = TokenClient::connect(cbtc::client_config(
Network::Mainnet,
"https://participant.example.com/api/json-api".into(),
"your-party::1220...".into(),
KeycloakConfig {
client_id: "your-client-id".into(),
username: "your-username".into(),
password: "your-password".into(),
url: token_url("https://keycloak.example.com", "your-realm"),
},
TokenStandardVersion::V2,
))
.await?;
println!("balance {} CBTC", client.balance().await?);
client
.send(SendParams {
receiver: "receiver-party::1220...".into(),
amount: DamlDecimal::parse("0.01").map_err(|e| e.to_string())?,
reference: None,
execute_before: None,
input_holding_cids: None,
})
.await?;
Ok(())
}On mainnet this moves real funds. Use token_url for the Keycloak URL; the
older password_url builds a path that current Keycloak servers do not
serve. The Token Standard has two entry points: V1 names a party as a string,
and V2 names an Account. Both move the same holdings, and the examples use
V2 where both exist.
Network is a plain enum: Devnet, Testnet, Mainnet. Each asset module
maps a Network to its own registrar, so you pass only the network.
| Network | Utility registry | BitSafe API | BETH chain |
|---|---|---|---|
| devnet | https://api.utilities.digitalasset-dev.com | https://api.devnet.bitsafe.finance | Sepolia, 11155111 |
| testnet | https://api.utilities.digitalasset-staging.com | https://api.testnet.bitsafe.finance | Sepolia, 11155111 |
| mainnet | https://api.utilities.digitalasset.com | https://api.mainnet.bitsafe.finance | Ethereum, 1 |
- It returns errors as text. Every function returns
Result<_, String>. - It does not wait for a mint. Poll your holdings to see the minted amount.
- It lists only active withdraw requests. The attestors archive a request after the payout, so a paid request no longer appears.
- It does not check the network of a BTC address. Use an address of the Bitcoin network that your Canton network pays out on.
- It reads no Ethereum state. It reads no bridge limits, no pause state and no deposit events.
examples/README.md lists 30 runnable examples, for
both assets: credentials, mint, burn, transfers, allocations and batch
distribution. Copy .env.example to .env, fill it in, and
run cargo run --example <name>.
Run the gates before you open a pull request:
cargo fmt -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo testtokens::<asset> is the whole public, asset-facing API. kits and flows
are crate-private building blocks. tokens may use flows and kits,
flows may use kits, and nothing imports upward; review checks this.
The localnet suite runs the CBTC and BETH flows against a local Canton sandbox. It needs Docker:
docker compose -f localnet/docker-compose.yml up -d --wait
cargo test --lib localnet -- --ignored --nocapture --test-threads=1
docker compose -f localnet/docker-compose.yml down -vThe first start pulls the image and bootstraps the sandbox, which takes
several minutes. Set LOCALNET_LEDGER_HOST to use a ledger other than
http://localhost:7575.
MIT. See LICENSE.