AMP0 Assets

AMP0 (Asset Management Platform v0) is the first generation of Blockstream's asset management service. It predates AMP2 and is built on a legacy stack (the Green backend + GDK), but a large number of Liquid assets issued over the years are still AMP0 governed. If your venue wants to list, custody, or trade those assets, you need to integrate with AMP0.

This page covers AMP0 integration from the venue side: creating accounts, deriving addresses, monitoring balances, and spending funds that are cosigned by the AMP0 server. The integration is built on top of LWK, which ships partial AMP0 support in its amp0 module.

How AMP0 differs from AMP2

AMP0 and AMP2 solve a similar problem (issuer controlled transfer rules on Liquid assets), but the mechanics are different. If you already integrated AMP2, most of the intuition carries over, but the API surface and wallet model are distinct.

PropertyAMP0AMP2
Wallet model2-of-2 multisig (user + AMP0 server)2-of-2 multisig (user + AMP2 HSM)
Client libraryLWK (amp0 module) or GDK@blockstream/cryptic + @blockstream/amp-registry SDK, or LWK + Proxy
Account creationVia GDK apps (Blockstream App, green_cli, GDK directly)Via AMP2 SDK or Proxy
AuthWatch only username/password + mnemonic signerCOSE (ECDSA + RSA) + device authorization
Issue / reissue / burnOnly via AMP0 issuer API (not LWK)Via AMP2 SDK
Address spaceNo BIP44 style GAP_LIMIT; many unused addresses in a row are normalStandard gap limit scanning works
Address source of truthAMP0 server (must be requested from it)LWK derives locally from the descriptor
🚧

Read this before building anything

AMP0 has a few sharp edges that can lead to loss of funds if ignored. The two most important:

  1. Never call Wollet::address() for an AMP0 wallet. Always get addresses from the AMP0 server via Amp0::address(). The server only cosigns spends of UTXOs sent to addresses it has issued. Funds sent to a locally derived address cannot be moved.
  2. Never call BlockchainBackend::full_scan() for an AMP0 wollet. AMP0 accounts have no gap limit, so the default scan can stop early and miss transactions. Use full_scan_to_index() with the last_index reported by AMP0.

LWK AMP0 capability matrix

LWK supports the day to day operations a venue needs, but some issuer side flows are only available via the AMP0 API. Plan integrations accordingly (source):

OperationLWKGDKAMP0 API
Create AMP0 accountsYesYesNo
Receive on AMP0 accountsYesYesNo
Monitor AMP0 accountsYesYesNo
Send from AMP0 accountsYesYesNo
Accounts with 2FANoYesNo
Issue / reissue / burn AMP0 assetsNoNoYes
Set restrictions for AMP0 assetsNoNoYes

If you need full AMP0 support (2FA, issuer lifecycle), use GDK plus the AMP0 issuer API. For venue/custody integrations, LWK is enough.

End to end flow

flowchart LR
  setup["1. Create signer + AMP account"] --> watchOnly["2. Create Watch Only credentials"]
  watchOnly --> receive["3. Get addresses"]
  receive --> monitor["4. Sync & check balance"]
  monitor --> send["5. Build PSET"]
  send --> signUser["6. Sign with user key"]
  signUser --> signAmp["7. Ask AMP0 to cosign"]
  signAmp --> broadcast["8. Broadcast"]

Steps 1–2 run once per account. Steps 3–8 are the daily operations.

Prerequisites

Before you start, make sure you have:

  • LWK installed (LWK Installation). The AMP0 types live in lwk_wollet::amp0 (Rust) / lwk.amp0 (Python) / lwk_wasm.Amp0* (JS).
  • A Liquid signer: Jade, BIP 39 mnemonic, or any hardware/software signer supported by LWK.
  • Watch only credentials for the AMP0 account: a username and password chosen by you. These are what you'll use on every subsequent startup to reconnect to the account without exposing the seed.
  • The AMP ID of the account (returned when the account is created).
  • A network endpoint (Esplora or Waterfalls) on the matching Liquid network.
📘

Node.js WebSocket polyfill

AMP0 uses WebSockets internally (for the login challenge and cosign requests). The JavaScript package is called lwk_wasm — there is no separate lwk_node on npm. Older Node.js versions don't expose a global WebSocket, so install the ws package and polyfill before importing lwk_wasm:

npm install ws

globalThis.WebSocket = WebSocket;

// Only after the polyfill:

Browsers ship a native WebSocket and need no polyfill.

Setup: create an AMP0 account

You only need to do this once per account. After setup, you operate with the watch only credentials plus the signer.

1. Create a Liquid signer

Back up the mnemonic in a secure vault or HSM. Anyone with it controls both halves of the 2-of-2 on the user side.

use lwk_common::{Amp0Signer, Network};
use lwk_signer::SwSigner;
use lwk_wollet::amp0::blocking::{Amp0Connected};

let network = Network::TestnetLiquid;
let is_mainnet = false;
let (signer, mnemonic) = SwSigner::random(is_mainnet)?;

let signer_data = signer.amp0_signer_data()?;
from lwk import Network, Signer, Mnemonic, Amp0Connected

network = Network.testnet()
mnemonic = "<mnemonic>"
signer = Signer(Mnemonic(mnemonic), network)

signer_data = signer.amp0_signer_data()
const mnemonic = "<mnemonic>";
const m = new lwk.Mnemonic(mnemonic);
const network = lwk.Network.testnet();
const signer = new lwk.Signer(m, network);

const signerData = signer.amp0SignerData();

2. Authenticate against AMP0

AMP0 uses a challenge–response login. Connect, fetch a challenge, sign it with the signer, then log in.

Rust

let amp0 = Amp0Connected::new(network, signer_data)?;
let challenge = amp0.get_challenge()?;
let sig = signer.amp0_sign_challenge(&challenge)?;
let mut amp0 = amp0.login(&sig)?;

Python

amp0 = Amp0Connected(network, signer_data)
challenge = amp0.get_challenge()
sig = signer.amp0_sign_challenge(challenge)
amp0 = amp0.login(sig)

JavaScript

In lwk_wasm the Amp0Connected constructor itself is async (it establishes the WebSocket to the AMP0 server). There is no static connect() method — await the new expression directly:

const amp0Connected = await new lwk.Amp0Connected(network, signerData);
const challenge = await amp0Connected.getChallenge();
const sig = signer.amp0SignChallenge(challenge);
const amp0LoggedIn = await amp0Connected.login(sig);

If you forget the await, amp0Connected will be a Promise and the next call (getChallenge) throws "not a function".

3. Create an AMP0 account

Each AMP0 account has a numeric pointer (the derivation index). Ask the server for the next free pointer, derive the account xpub, then create the account. The returned AMP ID is the identifier the issuer will use to whitelist your wallet.

let pointer = amp0.next_account()?;
let account_xpub = signer.amp0_account_xpub(pointer)?;
let amp_id = amp0.create_amp0_account(pointer, &account_xpub)?;
pointer = amp0.next_account()
account_xpub = signer.amp0_account_xpub(pointer)
amp_id = amp0.create_amp0_account(pointer, account_xpub)
const pointer = amp0LoggedIn.nextAccount();
const accountXpub = signer.amp0AccountXpub(pointer);
const ampId = await amp0LoggedIn.createAmp0Account(pointer, accountXpub);

4. Create Watch only credentials

Watch only credentials let you run day to day operations (balance, addresses, PSET construction) without exposing the seed. Typically the signer only comes online to sign.

let username = "<username>";
let password = "<password>";
amp0.create_watch_only(&username, &password)?;
username = "<username>"
password = "<password>"
amp0.create_watch_only(username, password)
await amp0LoggedIn.createWatchOnly(username, password);
👍

Alternative setup paths

An AMP0 account can also be bootstrapped with any GDK based tool. This is often easier if you already have a Blockstream App install or a Jade:

Whichever path you pick, once you have a mnemonic/Jade, an AMP ID, and watch only credentials, the LWK flow below is the same.

Daily operations

All daily operations only need the watch only credentials and the AMP ID. Keep the signer offline except for the signing step.

Connect with Watch only

use lwk_wollet::amp0::blocking::Amp0;

let amp0 = Amp0::new(network, &username, &password, &amp_id)?;
from lwk import Amp0

amp0 = Amp0(network, username, password, amp_id)
const amp0 = await lwk.Amp0.newTestnet(username, password, ampId);
// Mainnet: await new lwk.Amp0(network, username, password, ampId);

Receive (get an address)

🚧

Never derive AMP0 addresses locally

Always call Amp0::address(). The server must issue the address for it to cosign spends later. Using Wollet::address() or WolletDescriptor::address() on an AMP0 wollet can permanently lock funds.

let addr = amp0.address(None)?; // None -> next unused address
println!("Deposit here: {}", addr.address());
addr = str(amp0.address(None).address())
print(f"Deposit here: {addr}")
const addrResult = await amp0.address(null);
console.log("Deposit here:", addrResult.address().toString());

Monitor (sync balances and transactions)

Derive the AMP0 wollet descriptor, then sync with Esplora/Waterfalls. Use full_scan_to_index() with the index reported by AMP0 so you don't miss any transactions.

use lwk_wollet::{Wollet, EsploraClient, ElementsNetwork, BlockchainBackend};

let wollet_descriptor = amp0.wollet_descriptor()?;
let mut wollet = Wollet::new(ElementsNetwork::LiquidTestnet, wollet_descriptor, None)?;

let url = "https://waterfalls.liquidwebwallet.org/liquidtestnet/api";
let mut client = EsploraClient::new_waterfalls(url, network)?;

let last_index = amp0.last_index()?;
let update = client.full_scan_to_index(&wollet, last_index)?;
wollet.apply_update(update)?;

let balance = wollet.balance()?;
let txs = wollet.transactions()?;
from lwk import Wollet, EsploraClient

wollet_descriptor = amp0.wollet_descriptor()
wollet = Wollet(network, wollet_descriptor, None)

url = "https://waterfalls.liquidwebwallet.org/liquidtestnet/api"
client = EsploraClient.new_waterfalls(url, network)

last_index = amp0.last_index()
update = client.full_scan_to_index(wollet, last_index)
wollet.apply_update(update)

balance = wollet.balance()
txs = wollet.transactions()
const wollet = amp0.wollet();

const url = "https://waterfalls.liquidwebwallet.org/liquidtestnet/api";
const client = new lwk.EsploraClient(network, url, true, 4, false);

const lastIndex = amp0.lastIndex();
const update = await client.fullScanToIndex(wollet, lastIndex);
if (update) {
  wollet.applyUpdate(update);
}

const balance = wollet.balance();
const txs = wollet.transactions();
🚧

Don't use the default full_scan()

AMP0 accounts do not have a BIP44 style gap limit. The default scan stops after enough unused addresses in a row and will report an incorrect (lower) balance. Always pass last_index from Amp0::last_index().

Send (PSET + AMP0 cosign)

👍

Fund the AMP0 wallet first

To exercise the cosign flow end to end you need some LBTC inside the AMP0 wallet. Send testnet LBTC to the address returned by amp0.address(null) using the public faucet at https://liquidtestnet.com/faucet, wait 1–2 blocks, then resync with full_scan_to_index(last_index).

Sending is a four step flow that mirrors the usual PSET lifecycle, with one extra step: after the user signs, AMP0 has to cosign.

flowchart LR
  build["1. TxBuilder.finish_for_amp0()"] --> amp0pset["Amp0Pset = PSET + blinding_nonces"]
  amp0pset --> userSign["2. signer.sign(pset)"]
  userSign --> reassemble["3. Rebuild Amp0Pset with signed PSET"]
  reassemble --> amp0Sign["4. amp0.sign(amp0pset)"]
  amp0Sign --> broadcast["5. client.broadcast(tx)"]
// 1. Build PSET (special `finish_for_amp0` variant)
let mut builder = network.tx_builder();
builder.drain_lbtc_wallet(); // or add_recipient(...)
let amp0_pset = builder.finish_for_amp0(&wollet)?;

// 2. Sign with the user key
let mut pset = amp0_pset.pset();
signer.sign(&mut pset)?;

// 3. Reassemble with the original blinding nonces
let amp0_pset_signed = Amp0Pset::new(pset, amp0_pset.blinding_nonces())?;

// 4. Ask AMP0 to cosign
let tx = amp0.sign(amp0_pset_signed)?;

// 5. Broadcast
let txid = client.broadcast(&tx)?;
from lwk import Amp0Pset

# 1. Build PSET with the AMP0 variant
b = network.tx_builder()
b.drain_lbtc_wallet()  # or b.add_recipient(...)
amp0pset = b.finish_for_amp0(wollet)

# 2. User signs the PSET
pset = amp0pset.pset()
pset = signer.sign(pset)

# 3. Reassemble
amp0pset = Amp0Pset(pset, amp0pset.blinding_nonces())

# 4. AMP0 cosigns
tx = amp0.sign(amp0pset)

# 5. Broadcast
txid = client.broadcast(tx)
// 1. Build PSET with finishForAmp0()
let b = network.txBuilder();
b = b.drainLbtcWallet(); // or b.addRecipient(addr, amount, assetId)
const amp0pset = b.finishForAmp0(wollet);

// 2. User signs
const pset = amp0pset.pset();
const signedPset = signer.sign(pset);

// 3. Reassemble with blinding nonces
const amp0psetSigned = new lwk.Amp0Pset(signedPset, amp0pset.blindingNonces());

// 4. AMP0 cosigns
const tx = await amp0.sign(amp0psetSigned);

// 5. Broadcast
const txid = await client.broadcastTx(tx);

finish() vs finish_for_amp0()

Always use finish_for_amp0() for AMP0 wallets. The regular finish() does not emit the blinding_nonces the AMP0 cosigner needs, and the server will reject the cosign request.

If all the asset's AMP0 rules are satisfied (whitelist, limits, etc.), AMP0 returns the fully signed transaction. Otherwise, the cosign request fails and the transaction is never produced. This is how transfer controls are enforced on AMP0 assets.

Issuance, reissuance, burn, and restrictions

These operations are not available through LWK. They live in the AMP0 issuer API and require an issuer account:

OperationWhere to do it
Issue a new AMP0 assetAMP0 issuer API
Reissue / burn supplyAMP0 issuer API
Set per asset restrictionsAMP0 issuer API

If your venue is only listing existing AMP0 assets, you don't need to touch these. If you're also the issuer, refer to the AMP Overview documentation for the issuer flows, or consider starting directly on AMP2 (Issuer Guide) for new issuances.

Common pitfalls

SymptomLikely causeFix
Funds visible on chain but balance is zeroUsed full_scan() instead of full_scan_to_index()Resync with full_scan_to_index(last_index)
AMP0 refuses to cosignSpending a UTXO at an address not issued by AMP0Never use Wollet::address(); always Amp0::address()
AMP0 refuses to cosignBuilt the PSET with finish() instead of finish_for_amp0()Rebuild with finish_for_amp0() and pass blinding_nonces
Login failsWrong signer or network for the accountThe signer must match the account's mnemonic and network
WebSocket is not defined in Node.jsMissing WebSocket polyfill in lwk_wasmglobalThis.WebSocket = require('ws').WebSocket before importing lwk_wasm
amp0Connected.getChallenge is not a functionForgot to await the Amp0Connected constructor in JSconst c = await new lwk.Amp0Connected(network, signerData)
AMP0 address never receives fundsThe AMP0 wallet needs to be funded once before you can exercise the send/cosign flowSend testnet LBTC to amp0.address(null).address() via liquidtestnet.com/faucet and resync

Examples and 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.