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:

RoleWhat you doWhere to look
Custody/tradeAccept deposits, list balances, process withdrawalsLWK Send and Receive + this page
IssueMint a new asset, optionally reissue or burn supplyIssuing 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:

NetworkLBTC asset ID
Liquid mainnet6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d
Liquid testnet144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49

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:

  1. Store the hex asset ID, not the ticker. Tickers are not unique.
  2. Display amounts using the asset's precision (0–8). A balance of 50000 sat on a precision: 2 asset means 500.00, not 50,000.
  3. 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:

StepGuide
Create a wallet and hold the signer safelyCreate a Wallet
Generate deposit addressesDerive Addresses
Sync and read balances per assetSync and Check Balances
Build, sign, finalize, broadcast withdrawalsSend and Receive
Look up transactions, fees, asset metadataEsplora 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 wallet

Liquid 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.

SourceUse for
Liquid Asset Registry APIName, ticker, icon, precision, issuer domain
Your own asset tableAllow listing which assets your venue supports, display overrides
Contract hashVerifying 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 0 if 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:

FieldTypeNotes
versionnumberCurrently 0
namestring1–255 ASCII characters
tickerstring3–24 characters; letters, digits, ., -
precisionnumber0–8; decimal mapping of satoshi → display units
domainstringIssuer's DNS domain (used for registry verification)
issuer_pubkeystring (hex)Compressed 33 byte public key of the issuer

Precision controls how on chain satoshi amounts render:

Satoshi issuedPrecisionDisplay units
1,000,00001,000,000
1,000,000210,000.00
1,000,00061.000000
1,000,00080.01000000
🚧

Contract fields are immutable

Once issued, name, ticker, domain, precision, and issuer_pubkey are 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 wallets

The 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_id is what users, wallets, and explorers reference.
  • token_id is 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,000 satoshi). This is a hard network level cap.
  • At least one non zero amount: either satoshi_asset or satoshi_token must be greater than 0.
  • Reissuance tokens are all or nothing: if you want to mint more later, you must create at least 1 token 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 assetPlain: 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 supplyAMP: 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

Next steps

The Liquid Network is a Bitcoin layer-2 enabling the issuance of security tokens and other digital assets.

© 2023 Liquid Network
All rights reserved.

Feedback and Content Requests

We'd be happy to hear your suggestions on how we can improve this site.

BuildOnL2 Community

The official BuildOnL2 community lives
at community.liquid.net. Join us and build the future of Bitcoin on Liquid.

Telegram

Community-driven telegram group where
most of the Liquid developers hang out.
Go to t.me/liquid_devel to join.