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.
| Property | AMP0 | AMP2 |
|---|---|---|
| Wallet model | 2-of-2 multisig (user + AMP0 server) | 2-of-2 multisig (user + AMP2 HSM) |
| Client library | LWK (amp0 module) or GDK | @blockstream/cryptic + @blockstream/amp-registry SDK, or LWK + Proxy |
| Account creation | Via GDK apps (Blockstream App, green_cli, GDK directly) | Via AMP2 SDK or Proxy |
| Auth | Watch only username/password + mnemonic signer | COSE (ECDSA + RSA) + device authorization |
| Issue / reissue / burn | Only via AMP0 issuer API (not LWK) | Via AMP2 SDK |
| Address space | No BIP44 style GAP_LIMIT; many unused addresses in a row are normal | Standard gap limit scanning works |
| Address source of truth | AMP0 server (must be requested from it) | LWK derives locally from the descriptor |
Read this before building anythingAMP0 has a few sharp edges that can lead to loss of funds if ignored. The two most important:
- Never call
Wollet::address()for an AMP0 wallet. Always get addresses from the AMP0 server viaAmp0::address(). The server only cosigns spends of UTXOs sent to addresses it has issued. Funds sent to a locally derived address cannot be moved.- 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. Usefull_scan_to_index()with thelast_indexreported 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):
| Operation | LWK | GDK | AMP0 API |
|---|---|---|---|
| Create AMP0 accounts | Yes | Yes | No |
| Receive on AMP0 accounts | Yes | Yes | No |
| Monitor AMP0 accounts | Yes | Yes | No |
| Send from AMP0 accounts | Yes | Yes | No |
| Accounts with 2FA | No | Yes | No |
| Issue / reissue / burn AMP0 assets | No | No | Yes |
| Set restrictions for AMP0 assets | No | No | Yes |
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 polyfillAMP0 uses WebSockets internally (for the login challenge and cosign requests). The JavaScript package is called
lwk_wasm— there is no separatelwk_nodeon npm. Older Node.js versions don't expose a globalWebSocket, so install thewspackage and polyfill before importinglwk_wasm:npm install wsglobalThis.WebSocket = WebSocket; // Only after the polyfill:Browsers ship a native
WebSocketand 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 pathsAn 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:
- Blockstream App: mobile/desktop GUI with Jade support (easiest).
- green_cli: CLI, Jade support.
- GDK: direct library integration.
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, &_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 locallyAlways call
Amp0::address(). The server must issue the address for it to cosign spends later. UsingWollet::address()orWolletDescriptor::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 defaultfull_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_indexfromAmp0::last_index().
Send (PSET + AMP0 cosign)
Fund the AMP0 wallet firstTo 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 withfull_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()vsfinish_for_amp0()Always use
finish_for_amp0()for AMP0 wallets. The regularfinish()does not emit theblinding_noncesthe 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:
| Operation | Where to do it |
|---|---|
| Issue a new AMP0 asset | AMP0 issuer API |
| Reissue / burn supply | AMP0 issuer API |
| Set per asset restrictions | AMP0 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
| Symptom | Likely cause | Fix |
|---|---|---|
| Funds visible on chain but balance is zero | Used full_scan() instead of full_scan_to_index() | Resync with full_scan_to_index(last_index) |
| AMP0 refuses to cosign | Spending a UTXO at an address not issued by AMP0 | Never use Wollet::address(); always Amp0::address() |
| AMP0 refuses to cosign | Built the PSET with finish() instead of finish_for_amp0() | Rebuild with finish_for_amp0() and pass blinding_nonces |
| Login fails | Wrong signer or network for the account | The signer must match the account's mnemonic and network |
WebSocket is not defined in Node.js | Missing WebSocket polyfill in lwk_wasm | globalThis.WebSocket = require('ws').WebSocket before importing lwk_wasm |
amp0Connected.getChallenge is not a function | Forgot to await the Amp0Connected constructor in JS | const c = await new lwk.Amp0Connected(network, signerData) |
| AMP0 address never receives funds | The AMP0 wallet needs to be funded once before you can exercise the send/cosign flow | Send testnet LBTC to amp0.address(null).address() via liquidtestnet.com/faucet and resync |
Examples and references
- AMP0 in LWK (book): authoritative reference.
- liquidwebwallet.org: production integration of AMP0 via LWK/WASM.
- AMP0 Rust tests: real, runnable examples.
- AMP Overview (docs.liquid.net): AMP0 issuer side concepts and APIs.
Next steps
- Support Liquid Assets: plain (unrestricted) Liquid issued assets.
- Supporting AMP2 Assets: modern AMP managed assets (2-of-2 with HSM cosigning).
- LWK Send and Receive: the non AMP0 transaction flow for reference.