Liquid Assets
This page covers how to support plain Liquid issued assets in your venue: LBTC, USDt, and any other unrestricted asset issued directly on the Liquid Network. These assets are first class citizens of every Liquid transaction. Your wallet infrastructure handles them exactly the same way it handles LBTC, just with a different asset ID.
Plain Liquid assets are permissionless: anyone can issue one, anyone can hold one, and transfers don't require a cosignature from the issuer. If you also want to support issuer controlled assets (regulated securities, whitelisted stablecoins), see AMP0 Assets or Supporting AMP2 Assets.
What this page covers
There are two roles a venue can play with Liquid assets:
| Role | What you do | Where to look |
|---|---|---|
| Custody/trade | Accept deposits, list balances, process withdrawals | LWK Send and Receive + this page |
| Issue | Mint a new asset, optionally reissue or burn supply | Issuing an asset below |
Most venue integrations are pure custody/trade. The issuance section is relevant if you're creating your own token (e.g., a platform stablecoin, a loyalty point, a wrapped asset) without the compliance machinery of AMP.
The asset model at a glance
Liquid has two categories of assets:
- LBTC, the native currency. Pegged 1:1 to Bitcoin, always used for fees.
- Issued assets, created by anyone via a Liquid issuance transaction. Examples: USDt, tokenized securities, custom tokens.
Both are referenced everywhere by their 64 character hex asset ID, which is derived deterministically from the issuance transaction (see Liquid Assets concepts for the full model).
The LBTC asset ID differs between networks:
| Network | LBTC asset ID |
|---|---|
| Liquid mainnet | 6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d |
| Liquid testnet | 144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49 |
In code, prefer network.policyAsset() over hardcoding either value — the same code then works on both networks. Testnet LBTC can be obtained from the public faucet at https://liquidtestnet.com/faucet by pasting a confidential testnet address.
For venue purposes, remember three things:
- Store the hex asset ID, not the ticker. Tickers are not unique.
- Display amounts using the asset's precision (0–8). A balance of
50000sat on aprecision: 2asset means500.00, not50,000. - Every transaction needs some LBTC in the sending wallet to pay fees, even if you're only moving USDt.
Custody flow (listing an existing asset)
If the asset already exists and you just need to accept deposits, list balances, and process withdrawals, you don't need anything Liquid specific beyond the standard LWK flow:
flowchart LR register["Map asset_id in your system"] --> address["Derive deposit address (LWK)"] address --> scan["Sync wallet (Esplora / Waterfalls)"] scan --> balance["Read balance per asset"] balance --> withdraw["Build, sign, broadcast withdrawal PSET"]
Each step is covered in the LWK sections of this guide:
| Step | Guide |
|---|---|
| Create a wallet and hold the signer safely | Create a Wallet |
| Generate deposit addresses | Derive Addresses |
| Sync and read balances per asset | Sync and Check Balances |
| Build, sign, finalize, broadcast withdrawals | Send and Receive |
| Look up transactions, fees, asset metadata | Esplora API |
Nothing about this flow is asset specific: the same code moves LBTC, USDt, and any custom Liquid asset. You just pass a different asset_id into TxBuilder::add_recipient().
Always keep LBTC in every hot walletLiquid fees are paid in LBTC only. A wallet with a rich USDt balance but zero LBTC cannot send anything. Top LBTC up before it runs out on every wallet that processes withdrawals.
Resolving asset metadata
Users think in tickers and names; your backend should think in asset IDs. Bridge the two through the Liquid Asset Registry or a local allow-list.
| Source | Use for |
|---|---|
| Liquid Asset Registry API | Name, ticker, icon, precision, issuer domain |
| Your own asset table | Allow listing which assets your venue supports, display overrides |
| Contract hash | Verifying that the metadata matches what was committed on chain |
For a deeper treatment of the registry (including the v2.0 schema with mutable/admin fields), see Assets and Asset Types.
Issuing a new asset
Liquid lets you create a new asset in a single transaction. When you issue, you specify:
- Asset amount: how many satoshi units to mint (
satoshi_asset). - Asset receiver: an address that receives the new asset (defaults to your own wallet).
- Token amount: how many reissuance tokens to create. Set to
0if the supply is fixed forever. - Token receiver: an address for the reissuance tokens (defaults to your own wallet).
- Contract: optional metadata (name, ticker, domain, precision, issuer pubkey). Required if you want the asset listed in the Asset Registry.
The asset ID is derived from the issuance input and the contract hash: the same inputs + contract always produce the same asset ID. This is what lets third parties verify that an on chain asset matches a published contract.
Issuance flow
flowchart LR fund["Fund a wallet with LBTC"] --> contract["Define the contract"] contract --> build["TxBuilder.issue_asset()"] build --> sign["Sign locally"] sign --> finalize["Finalize"] finalize --> broadcast["Broadcast"] broadcast --> ids["Extract asset_id + token_id"]
1. Define the contract
A contract is a small JSON blob committed into the asset ID. Its fields are:
| Field | Type | Notes |
|---|---|---|
version | number | Currently 0 |
name | string | 1–255 ASCII characters |
ticker | string | 3–24 characters; letters, digits, ., - |
precision | number | 0–8; decimal mapping of satoshi → display units |
domain | string | Issuer's DNS domain (used for registry verification) |
issuer_pubkey | string (hex) | Compressed 33 byte public key of the issuer |
Precision controls how on chain satoshi amounts render:
| Satoshi issued | Precision | Display units |
|---|---|---|
| 1,000,000 | 0 | 1,000,000 |
| 1,000,000 | 2 | 10,000.00 |
| 1,000,000 | 6 | 1.000000 |
| 1,000,000 | 8 | 0.01000000 |
Contract fields are immutableOnce issued,
name,ticker,domain,precision, andissuer_pubkeyare locked by the contract hash. You cannot change them. Pick carefully, and test on testnet first.
use lwk_wollet::elements::AssetId;
use lwk_wollet::Contract;
use std::str::FromStr;
let contract_str = r#"{
"entity": { "domain": "example.com" },
"issuer_pubkey": "0337cceec0beea0232ebe14cba0197a9fbd45fcf2ec946749de920e71434c2b904",
"name": "Acme Token",
"precision": 8,
"ticker": "ACME",
"version": 0
}"#;
let contract = Contract::from_str(contract_str)?;from lwk import Contract
contract = Contract(
domain="example.com",
issuer_pubkey="0337cceec0beea0232ebe14cba0197a9fbd45fcf2ec946749de920e71434c2b904",
name="Acme Token",
precision=8,
ticker="ACME",
version=0,
)const contract = new lwk.Contract(
"example.com",
"0337cceec0beea0232ebe14cba0197a9fbd45fcf2ec946749de920e71434c2b904",
"Acme Token",
8,
"ACME",
0,
);2. Build the issuance transaction
The wallet that pays must hold some LBTC. issue_asset() adds the issuance to the PSET; the rest of the flow is the standard PSET lifecycle (LWK Send and Receive).
use lwk_wollet::{Wollet, WolletDescriptor};
let issued_asset: u64 = 10_000;
let reissuance_tokens: u64 = 1;
let builder = wollet.tx_builder();
let mut pset = builder
.issue_asset(
issued_asset,
None, // None -> auto-selects an address from the wallet
reissuance_tokens,
None, // None -> auto-selects an address from the wallet
Some(contract.clone()),
)?
.finish()?;issued_asset = 10_000
reissuance_tokens = 1
wallet_address = wollet.address(0).address()
builder = network.tx_builder()
builder.issue_asset(
issued_asset,
wallet_address,
reissuance_tokens,
wallet_address,
contract,
)
unsigned_pset = builder.finish(wollet)const issuedAsset = 10_000n;
const reissuanceTokens = 1n;
const walletAddress = wollet.address(0).address();
// TxBuilder in lwk_wasm is chainable — every method returns a NEW builder.
// Without reassignment the issueAsset call is silently a no-op.
let builder = network.txBuilder();
builder = builder.issueAsset(
issuedAsset,
walletAddress,
reissuanceTokens,
walletAddress,
contract,
);
const unsignedPset = builder.finish(wollet);
Receivers can be different walletsThe asset receiver and the reissuance token receiver can be different wallets with different security profiles. A common pattern: issue the tokens to a hot wallet for daily operations, but send the reissuance token to a cold storage address so future reissues require manual approval.
3. Sign, finalize, broadcast
The rest is identical to the standard send flow:
signer.sign(&mut pset)?;
let _ = wollet.finalize(&mut pset)?;
let tx = pset.extract_tx()?;
let txid = client.broadcast(&tx)?;signed_pset = signer.sign(unsigned_pset)
finalized_pset = wollet.finalize(signed_pset)
tx = finalized_pset.extract_tx()
txid = client.broadcast(tx)const signedPset = signer.sign(unsignedPset);
const finalized = wollet.finalize(signedPset);
const tx = finalized.extractTx();
const txid = await client.broadcastTx(tx);4. Extract the asset and token IDs
Both IDs are committed into the first input of the issuance transaction.
let (asset_id, token_id) = pset.inputs()[0].issuance_ids();
println!("asset_id = {}", asset_id);
println!("token_id = {}", token_id);asset_id = signed_pset.inputs()[0].issuance_asset()
token_id = signed_pset.inputs()[0].issuance_token()const input = signedPset.inputs()[0];
const assetId = input.issuanceAsset();
const tokenId = input.issuanceToken();Store both IDs in your system:
asset_idis what users, wallets, and explorers reference.token_idis the reissuance token: the key to minting more supply later. Treat it like a signing key.
Issuance rules and limits
- Maximum supply: 21,000,000 BTC equivalent (
2,100,000,000,000,000satoshi). This is a hard network level cap. - At least one non zero amount: either
satoshi_assetorsatoshi_tokenmust be greater than0. - Reissuance tokens are all or nothing: if you want to mint more later, you must create at least
1token at initial issuance. You cannot add reissuance capability afterwards. - Confidential vs explicit: issuance amounts can be blinded (default) or explicit. LWK handles this automatically; most integrations should stick with the default.
Reissuing and burning
After issuance, you can manage supply using the same PSET lifecycle:
- Reissue more of the asset by spending the reissuance token in a new transaction. The new supply is added to the total circulating amount.
- Burn supply by sending asset outputs to a provably unspendable output. This permanently reduces the circulating supply.
Both are standard TxBuilder operations — you don't need new infrastructure, just the reissuance token (for reissues) or the asset itself (for burns).
Reissue
Requires: the wallet still holds at least 1 reissuance token for the asset, plus some LBTC for fees.
let mut builder = network.tx_builder();
let mut pset = builder
.reissue_asset(
asset_id, // the asset to top up
1_000, // extra satoshi units to mint
None, // None -> deliver to this wallet
None, // None -> find the reissuance-token UTXO automatically
)?
.finish()?;
signer.sign(&mut pset)?;
wollet.finalize(&mut pset)?;
let tx = pset.extract_tx()?;
let txid = client.broadcast(&tx)?;builder = network.tx_builder()
builder.reissue_asset(asset_id, 1_000, None, None)
pset = builder.finish(wollet)
signed = signer.sign(pset)
finalized = wollet.finalize(signed)
tx = finalized.extract_tx()
txid = client.broadcast(tx)let builder = network.txBuilder();
builder = builder.reissueAsset(
assetId, // AssetId (or hex string)
1_000n, // extra satoshi units to mint
null, // null -> receive to this wallet
null // null -> locate the reissuance-token UTXO automatically
);
const unsignedPset = builder.finish(wollet);
const signedPset = signer.sign(unsignedPset);
const finalized = wollet.finalize(signedPset);
const tx = finalized.extractTx();
const txid = await client.broadcastTx(tx);Burn
Burn permanently removes supply by sending to a provably unspendable output committed to the chain. You do not need the reissuance token to burn.
let mut builder = network.tx_builder();
let mut pset = builder
.add_burn(500, &asset_id)? // burn 500 sats of this asset
.finish()?;
signer.sign(&mut pset)?;
wollet.finalize(&mut pset)?;
let tx = pset.extract_tx()?;
let txid = client.broadcast(&tx)?;builder = network.tx_builder()
builder.add_burn(500, asset_id)
pset = builder.finish(wollet)
signed = signer.sign(pset)
finalized = wollet.finalize(signed)
tx = finalized.extract_tx()
txid = client.broadcast(tx)let builder = network.txBuilder();
builder = builder.addBurn(500n, assetId); // 500 sats of this asset
const unsignedPset = builder.finish(wollet);
const signedPset = signer.sign(unsignedPset);
const finalized = wollet.finalize(signedPset);
const tx = finalized.extractTx();
const txid = await client.broadcastTx(tx);See also the LWK book for deeper reference:
For AMP2 managed assets, reissue and burn are exposed as dedicated endpoints in the issuer SDK. See Reissue and Burn.
Plain Liquid vs AMP managed assets
A quick decision guide:
| You want to... | Use |
|---|---|
| Accept deposits of LBTC, USDt, or any existing Liquid asset | Plain: standard LWK flow |
| Mint a simple unrestricted token (loyalty points, wrapped asset) | Plain: Issuing a new asset |
| Enforce a whitelist, per wallet transfer rules, or regulated supply | AMP: AMP0 or AMP2 |
If you start with a plain Liquid issuance and later need compliance controls, you'll need to migrate holders to a new asset ID. Issued assets cannot be "upgraded" into an AMP asset in place. When in doubt, issue via AMP from day one.
References
- Issuance (LWK book): authoritative reference for issuance fields and semantics.
- Reissuance (LWK book)
- Burn (LWK book)
- Liquid Asset Registry
- Liquid Assets (concepts): asset model and registry overview.
Next steps
- LWK Send and Receive: the same PSET flow applies to any asset.
- Esplora API: look up issuance transactions and asset supply.
- AMP0 Assets / AMP2 Assets: add issuer controlled assets.