AMP2 Assets
AMP2 (Asset Management Platform v2) is Blockstream's modern platform for issuer controlled Liquid assets: tokenized securities, regulated stablecoins, and any asset where the issuer needs to enforce whitelists, transfer caps, or lock/unlock policies. This page covers everything a venue needs to support AMP2 assets: the model, wallet registration, receiving, sending, and the cosigning flow.
If you only need to support permissionless Liquid assets (LBTC, USDt), you do not need AMP2. See Support Liquid Assets. For the legacy AMP platform, see AMP0 Assets.
How AMP2 differs from plain Liquid assets
| Property | Plain Liquid asset | AMP2 managed asset |
|---|---|---|
| Who can hold it | Anyone | Only wallets registered with the issuer |
| Who can transfer | Anyone with the asset | Requires issuer cosignature |
| Wallet type | Single sig (your key only) | 2-of-2 multisig (your key + AMP2 server key) |
| Restrictions | None (network level only) | Issuer defined (per wallet, per asset) |
| Custody | Self custodial | Self custodial (you hold one of two keys) |
You still hold your own key. AMP2 holds the other. Neither party can move assets unilaterally: both signatures are required.
Why a venue would support AMP2 assets
- Regulated securities: tokenized stocks, bonds, and funds that require KYC'd holders.
- Compliance controlled stablecoins: stablecoins with transfer restrictions.
- Institutional assets: any token where the issuer needs a whitelist or transfer controls.
If issuers on Liquid are creating assets via AMP2 and you want your users to hold or trade them, you need to register as a venue.
Venue architecture
As a venue, you sit between your users and the AMP2 platform. Your users never talk to AMP2 directly: you do it on their behalf.
flowchart LR users["Your Users"] --> exchange["Your Exchange"] exchange --> lwk["LWK"] exchange --> proxy["AMP2 Proxy"] proxy --> amp["AMP2 Server"] lwk --> liquid["Liquid Network"] amp --> liquid
The AMP2 Proxy translates your plain JSON requests into the COSE encrypted format AMP2 expects. You never implement COSE yourself.
End to end flow
flowchart LR register["1. Register 2-of-2 wallet"] --> receive["2. Receive assets"] receive --> build["3. Build PSET (LWK)"] build --> cosign["4. AMP2 cosigns"] cosign --> sign["5. You sign"] sign --> finalize["6. Finalize"] finalize --> broadcast["7. Broadcast"]
Step 1 runs once per wallet. Step 2 is passive. Steps 3 to 7 happen on every withdrawal.
Wallet strategies
You can structure your venue wallets in different ways:
| Strategy | Description | Pros | Cons |
|---|---|---|---|
| Omnibus wallet | Single wallet for all your users | Simple to manage | No on chain isolation between users |
| Per user wallets | Separate wallet per user | Full isolation, easier auditing | More wallets to register and manage |
| Hot/cold split | Active trading wallet + cold storage | Better security | Requires moving assets between wallets |
Most venues start with an omnibus wallet and track individual user balances in their own database.
Prerequisites
Before registering a wallet, make sure you have:
- An LWK signer set up. See Create a Wallet.
- The AMP2 server's xpub, provided during onboarding with the asset issuer.
- Access to the AMP2 Proxy endpoint.
Step 1: Register the 2-of-2 wallet
An AMP2 wallet uses a 2-of-2 multisig Confidential Transaction descriptor that combines your xpub with the AMP2 server's xpub.
Build the descriptor
let descriptor_str = format!(
"ct(elip151,elwsh(multi(2,{},{})))",
your_keyorigin_xpub, // e.g., [3d970d04/87h/1h/0h]tpub.../<0;1>/*
amp2_server_keyorigin_xpub // e.g., [c67f5991/87h/1h/0h]tpub.../<0;1>/*
);This produces a descriptor like:
ct(elip151,elwsh(multi(2,[3d970d04/87'/1'/0']tpubDC34.../<0;1>/*,[c67f5991/87'/1'/0']tpubDC4S.../<0;1>/*)))
Breakdown:
ct(elip151): Confidential Transactions using ELIP-151 for blinding.elwsh(multi(2,...)): Elements witness script hash, 2-of-2 multisig.- First key: your xpub (you hold the private key).
- Second key: AMP2's server xpub (AMP2 holds the private key in an HSM).
<0;1>/*: receive and change derivation paths.
If you're using the AMP2 Rust client, it can construct this for you:
use amp2::Amp2;
let amp2 = Amp2::new(your_keyorigin_xpub.to_string(), amp_url.to_string()).unwrap();
let descriptor = amp2.descriptor_from_str(your_keyorigin_xpub).unwrap();Register and get a WID
Submit the descriptor to get a wallet ID (WID):
let registration = amp2.register(descriptor).await.unwrap();
let wid = registration.wid;
println!("Wallet registered: wid={}", wid);The WID is deterministically derived from the descriptor. Store it: you'll use it for all future operations with this wallet.
Registration behavior
| Scenario | Result |
|---|---|
| New descriptor | Wallet created, new WID returned |
| Same descriptor, same blinding keys | Same WID returned (idempotent) |
| Same descriptor, different blinding keys | 403 Forbidden |
| Invalid descriptor format | Validation error |
Registration is idempotent: calling it again with the same descriptor returns the same WID. It's safe to call on application startup.
Choosing the right network
| Network | AMP2 URL | Derivation path |
|---|---|---|
| Testnet | https://amp2beta.testnet.blockstream.com/ | 87h/1h/0h |
| Mainnet | Provided during onboarding | 87h/0h/0h |
Your xpub derivation path must match the network. Testnet xpubs start with tpub, mainnet with xpub.
What happens after registration
Once your wallet is registered:
- The issuer can see it in their wallet list.
- The issuer can send AMP2 managed assets to it.
- The issuer can assign restriction policies to it.
- You can start tracking balances by syncing the wollet.
The wallet needs to be whitelisted by the issuer (via restriction groups) before it can receive restricted assets.
Step 2: Receive assets
Receiving is passive and works exactly like any other Liquid asset:
- The issuer sends assets to your registered wallet (they know your WID).
- You sync your wollet with the blockchain.
- The new balance appears.
Create the wollet from the 2-of-2 descriptor:
use lwk_wollet::{Wollet, ElementsNetwork};
let network = ElementsNetwork::LiquidTestnet;
let wollet = Wollet::new(network, &descriptor).unwrap();This wollet works exactly like a single sig wollet for reading purposes. You can sync it, check balances, list transactions, and derive addresses. The difference only shows up when you spend.
wollet.full_scan(&client).unwrap();
let balances = wollet.balance().unwrap();
for (asset_id, amount) in &balances {
println!("Asset: {} | Balance: {} sats", asset_id, amount);
}No action required on your side for the incoming transfer. The issuer builds the transaction, AMP2 cosigns it, and it lands on chain. Your wollet picks it up during the next scan.
Step 3: Send assets
Sending from a 2-of-2 wallet requires both signatures. The flow:
sequenceDiagram participant You as Your Exchange participant LWK as LWK participant Proxy as AMP2 Proxy participant AMP as AMP2 Server participant Net as Liquid Network You->>LWK: Build PSET LWK-->>You: Unsigned PSET You->>Proxy: Submit PSET for cosigning Proxy->>AMP: Forward (COSE-wrapped) AMP->>AMP: Check restrictions AMP-->>Proxy: Cosigned PSET (or rejection) Proxy-->>You: Cosigned PSET You->>LWK: Sign with your key LWK-->>You: Fully signed PSET You->>LWK: Finalize LWK-->>You: Raw transaction You->>Net: Broadcast
1. Build the PSET
Build the transaction locally with LWK, just like a normal send:
use lwk_wollet::TxBuilder;
let mut builder = TxBuilder::new(network);
builder.add_recipient(&recipient_address, amount, &asset_id).unwrap();
let pset = builder.finish(&wollet).unwrap();2. Submit for cosigning
Send the unsigned PSET to AMP2 through the Proxy:
let cosigned_pset = amp2.cosign(pset).await.unwrap();At this point, AMP2 checks:
- Is the sender wallet registered?
- Is the recipient wallet registered (if sending to another AMP2 wallet)?
- Do the issuer's restriction policies allow this transfer?
If everything passes, AMP2 adds its signature and returns the cosigned PSET. Otherwise, you get an error.
3. Sign with your key
Add your signature to the cosigned PSET:
let signed_pset = signer.sign(&cosigned_pset).unwrap();4. Finalize and broadcast
let finalized = wollet.finalize(&signed_pset).unwrap();
let txid = client.broadcast(&finalized).unwrap();
println!("Broadcast: {}", txid);The PSET lifecycle for AMP2
Here is the full state machine a PSET goes through:
flowchart TD created["Created (unsigned)"] --> submitted["Submitted to Proxy"] submitted --> checked["AMP2 checks restrictions"] checked -->|"allowed"| cosigned["AMP2 cosigns (1 of 2)"] checked -->|"rejected"| rejected["Rejection returned"] cosigned --> userSigned["You sign (2 of 2)"] userSigned --> finalized["Finalized"] finalized --> broadcast["Broadcast to Liquid"]
Signing order
The signing order is flexible:
- AMP2 first, then you (recommended): submit the unsigned PSET to the Proxy, get AMP2's signature, then add yours. Avoids wasting signatures on PSETs AMP2 would reject.
- You first, then AMP2: sign first, then submit for cosigning. AMP2 accepts PSETs regardless of whether your signature is already present.
Proxy API
The AMP2 Proxy exposes a JSON API. You do not implement the COSE encryption layer: the Proxy handles that translation.
Submit a PSET for cosigning
POST /proxy/cosign
Content-Type: application/json
{
"pset": "<base64-encoded PSET>"
}
Success response:
{
"pset": "<base64-encoded cosigned PSET>"
}Error response:
{
"error": "restriction_violation",
"message": "Recipient wallet wid=abc123 is not in restriction group 'allowed_holders'"
}Register a wallet through the Proxy
POST /proxy/register
Content-Type: application/json
{
"descriptor": "<wallet descriptor string>"
}
Response:
{
"wid": "wallet_id_string"
}What AMP2 validates at cosigning
When you submit a PSET for cosigning, AMP2 checks:
- Wallet registration: are the sending wallet(s) registered with AMP2?
- Recipient registration: for AMP2 managed assets, is the recipient wallet also registered?
- Restriction policies: does the transfer comply with the issuer's rules?
- Is the recipient in the correct restriction group?
- Does the amount stay within configured limits?
- Is the asset currently unlocked?
- UTXO validity: are the input UTXOs valid and unspent?
- PSET structure: is the PSET well formed and consistent?
Only if all checks pass does AMP2 add its HSM backed signature.
Handling rejections
AMP2 may reject a cosign request if the transfer violates restrictions. Common reasons:
| Rejection | Meaning | What to do |
|---|---|---|
| Recipient not registered | The target wallet isn't known to AMP2 | Register the recipient wallet first |
| Recipient not whitelisted | The target wallet isn't in the right restriction group | Contact the issuer to whitelist |
| Transfer amount exceeds limit | Issuer set a transfer cap | Send a smaller amount or contact issuer |
| Asset locked | The issuer has locked the asset or specific UTXOs | Wait for unlock or contact issuer |
Your application should handle these errors gracefully and surface clear messages to your users.
Error handling sketch
match amp2.cosign(pset).await {
Ok(cosigned) => {
let signed = signer.sign(&cosigned).unwrap();
let finalized = wollet.finalize(&signed).unwrap();
let txid = client.broadcast(&finalized).unwrap();
println!("Success: {}", txid);
}
Err(e) => {
match e.kind() {
ErrorKind::RestrictionViolation => {
log::warn!("Transfer blocked by issuer restrictions: {}", e);
}
ErrorKind::WalletNotFound => {
log::warn!("Recipient wallet not registered: {}", e);
}
ErrorKind::AssetLocked => {
log::warn!("Asset is currently locked: {}", e);
}
_ => {
log::error!("Unexpected cosigning error: {}", e);
}
}
}
}Timeouts and retries
Cosigning is a synchronous operation that typically completes in under a second. Network issues or server load can cause delays.
Recommendations:
- Timeout: set a 30 second timeout on the cosign request.
- Retries: retry on network errors (5xx, timeouts) with exponential backoff.
- Idempotency: the same PSET submitted twice produces the same result, so retries are safe.
Sending to non AMP2 wallets
AMP2 managed assets can only be sent to other AMP2 registered wallets. You cannot send them to arbitrary Liquid addresses. This is by design: the issuer controls the set of allowed holders.
If a user wants to withdraw an AMP2 asset to an external wallet, that wallet needs to be registered with the issuer first.
Mixed wallets and LBTC for fees
Your 2-of-2 AMP2 wallet can also hold plain Liquid assets (LBTC, USDt). However, spending from this wallet always requires AMP2's cosignature, even for plain assets, because the wallet is a 2-of-2 multisig at the protocol level.
For this reason, many venues keep separate wallets:
- A single sig wallet for LBTC and plain assets (no AMP2 dependency).
- A 2-of-2 wallet specifically for AMP2 managed assets.
Even in a 2-of-2 wallet, transaction fees are paid in LBTC. Make sure your AMP2 wallet always has a small amount of LBTC to cover fees. If you're only receiving AMP2 managed assets (not LBTC) into this wallet, you'll need to send some LBTC to it from your single sig wallet. This LBTC send is a normal Liquid transaction to one of the 2-of-2 wallet's addresses.
Next steps
- Support Liquid Assets: permissionless Liquid assets.
- AMP0 Assets: legacy AMP platform.
- Go Live Checklist: prepare for production.