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

PropertyPlain Liquid assetAMP2 managed asset
Who can hold itAnyoneOnly wallets registered with the issuer
Who can transferAnyone with the assetRequires issuer cosignature
Wallet typeSingle sig (your key only)2-of-2 multisig (your key + AMP2 server key)
RestrictionsNone (network level only)Issuer defined (per wallet, per asset)
CustodySelf custodialSelf 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:

StrategyDescriptionProsCons
Omnibus walletSingle wallet for all your usersSimple to manageNo on chain isolation between users
Per user walletsSeparate wallet per userFull isolation, easier auditingMore wallets to register and manage
Hot/cold splitActive trading wallet + cold storageBetter securityRequires 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:

  1. An LWK signer set up. See Create a Wallet.
  2. The AMP2 server's xpub, provided during onboarding with the asset issuer.
  3. 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.../&lt;0;1&gt;/*,[c67f5991/87'/1'/0']tpubDC4S.../&lt;0;1&gt;/*)))

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

ScenarioResult
New descriptorWallet created, new WID returned
Same descriptor, same blinding keysSame WID returned (idempotent)
Same descriptor, different blinding keys403 Forbidden
Invalid descriptor formatValidation 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

NetworkAMP2 URLDerivation path
Testnethttps://amp2beta.testnet.blockstream.com/87h/1h/0h
MainnetProvided during onboarding87h/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:

  1. The issuer sends assets to your registered wallet (they know your WID).
  2. You sync your wollet with the blockchain.
  3. 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:

  1. Wallet registration: are the sending wallet(s) registered with AMP2?
  2. Recipient registration: for AMP2 managed assets, is the recipient wallet also registered?
  3. 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?
  4. UTXO validity: are the input UTXOs valid and unspent?
  5. 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:

RejectionMeaningWhat to do
Recipient not registeredThe target wallet isn't known to AMP2Register the recipient wallet first
Recipient not whitelistedThe target wallet isn't in the right restriction groupContact the issuer to whitelist
Transfer amount exceeds limitIssuer set a transfer capSend a smaller amount or contact issuer
Asset lockedThe issuer has locked the asset or specific UTXOsWait 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

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.