Rust Amp Client (amp-rust)
⚠️ DEVELOPMENT STATUS: This package is currently in development and undergoing early integration testing. The API may change and some endpoints are not yet implemented. Don't use in production environments.
A Rust client for the Blockstream AMP API.
Usage
Add the following to your Cargo.toml:
[dependencies]
amp-rust = "0.0.3"
Examples
For more detailed examples, please refer to the crate documentation.
Running Examples
You can run the included examples using cargo:
cargo run --example asset_summary
cargo run --example asset_display_tui
cargo run --example changelog
cargo run --example validate_gaid GAbYScu6jkWUND2jo3L4KJxyvo55d
cargo run --example get_distribution_info asset-uuid-123 distribution-uuid-456
AMP_TESTS=live cargo run --example create_issue_authorize_asset
AMP_TESTS=live cargo run --example create_issue_authorize_reissuable_asset
AMP_TESTS=live cargo run --example reissue_asset_example -- <ASSET_UUID>
AMP_TESTS=live cargo run --example burn_asset_example -- <ASSET_UUID>
cargo run --example end_to_end_distribution_example
Make sure to set up your .env file with the required credentials first.
Get a registered user
use amp_rs::ApiClient;
#[tokio::main]
async fn main() {
let client = ApiClient::new().unwrap();
let users = client.get_registered_users().await.unwrap();
let user_id = users.first().unwrap().id;
let user = client.get_registered_user(user_id).await.unwrap();
println!("{:?}", user);
}
Get an asset
use amp_rs::ApiClient;
#[tokio::main]
async fn main() {
let client = ApiClient::new().unwrap();
let assets = client.get_assets().await.unwrap();
let asset_uuid = assets.first().unwrap().asset_uuid.clone();
let asset = client.get_asset(&asset_uuid).await.unwrap();
println!("{:?}", asset);
}
Create a category
use amp_rs::{ApiClient, model::CategoryAdd};
#[tokio::main]
async fn main() {
let client = ApiClient::new().unwrap();
let new_category = CategoryAdd {
name: "Test Category".to_string(),
description: Some("A test category".to_string()),
};
let category = client.add_category(&new_category).await.unwrap();
println!("{:?}", category);
}
Register an asset with the Blockstream Asset Registry
Registering an asset with the Blockstream Asset Registry makes the asset name and ticker visible in user wallets when the asset is issued and distributed. This improves the user experience by displaying human-readable asset information instead of just the asset ID.
use amp_rs::ApiClient;
#[tokio::main]
async fn main() {
let client = ApiClient::new().unwrap();
let asset_uuid = "your_asset_uuid";
let response = client.register_asset(asset_uuid).await.unwrap();
if response.success {
println!("Asset registered successfully!");
if let Some(message) = response.message {
println!("Message: {}", message);
}
if let Some(asset) = response.asset_data {
println!("Asset name: {}", asset.name);
println!("Asset ticker: {:?}", asset.ticker);
println!("Is registered: {}", asset.is_registered);
}
}
}
You can also use the included example:
cargo run --example register_asset -- <ASSET_UUID>
Manage asset assignments
use amp_rs::ApiClient;
#[tokio::main]
async fn main() {
let client = ApiClient::new().unwrap();
let asset_uuid = "your_asset_uuid";
let assignment_id = "assignment_id";
let locked_assignment = client
.lock_asset_assignment(asset_uuid, assignment_id)
.await
.unwrap();
println!("Locked assignment: {:?}", locked_assignment);
let unlocked_assignment = client
.unlock_asset_assignment(asset_uuid, assignment_id)
.await
.unwrap();
println!("Unlocked assignment: {:?}", unlocked_assignment);
client
.delete_asset_assignment(asset_uuid, assignment_id)
.await
.unwrap();
println!("Assignment deleted");
}
Create and execute asset distributions
use amp_rs::{ApiClient, ElementsRpc, signer::LwkSoftwareSigner, model::AssetDistributionAssignment};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ApiClient::new().await?;
let elements_rpc = ElementsRpc::from_env()?;
let (mnemonic, signer) = LwkSoftwareSigner::generate_new_indexed(100)?;
println!("Using signer with mnemonic: {}...", &mnemonic[..50]);
let asset_uuid = "your_asset_uuid";
let wallet_name = "distribution_wallet";
let assignments = vec![
AssetDistributionAssignment {
user_id: "user123".to_string(),
address: "tlq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
amount: 0.00000100, },
AssetDistributionAssignment {
user_id: "user456".to_string(),
address: "tlq1qq9xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
amount: 0.00000050, },
];
let result = client.distribute_asset(
asset_uuid,
assignments,
&elements_rpc,
wallet_name,
&signer,
).await?;
println!("Distribution created successfully!");
println!("Distribution UUID: {}", result.distribution_uuid);
println!("Transaction ID: {}", result.txid);
Ok(())
}
Missing Endpoints
The following AMP API endpoints are not yet implemented in this client library. This list may not be exhaustive:
Asset Operations
GET /api/assets/{assetUuid}/reissuances - Get asset reissuances
GET /api/assets/{assetUuid}/txs - Get asset transactions
GET /api/assets/{assetUuid}/txs/{txid} - Get specific asset transaction
GET /api/assets/{assetUuid}/lost-outputs - Get asset lost outputs
POST /api/assets/{assetUuid}/update-blinders - Update asset blinders
Manager Operations
POST /api/managers/{managerId}/change-password - Change manager password
These and potentially other endpoints will be added in future releases. If you need any of these endpoints urgently, please open an issue on the project repository.
Token Management
The AMP client includes sophisticated token management with automatic persistence and refresh capabilities.
Features
- Automatic Token Persistence: Tokens are automatically saved to
token.json and loaded on subsequent runs
- Proactive Refresh: Tokens are automatically refreshed 5 minutes before expiry
- Thread-Safe Operations: All token operations are thread-safe and prevent race conditions
- Retry Logic: Built-in retry logic with exponential backoff for token operations
- Secure Storage: Tokens are stored securely using the
secrecy crate
Token Lifecycle
- First Run: Client obtains a new token from the API and persists it to disk
- Subsequent Runs: Client loads the existing token from disk if still valid
- Automatic Refresh: Token is automatically refreshed when it expires soon (within 5 minutes)
- Fallback: If refresh fails, client automatically obtains a new token
Usage Examples
use amp_rs::ApiClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ApiClient::new()?;
let token = client.get_token().await?;
println!("Token: {}...", &token[..20]);
if let Some(info) = client.get_token_info().await? {
println!("Token expires at: {}", info.expires_at);
println!("Token age: {:?}", info.age);
println!("Expires in: {:?}", info.expires_in);
}
let refreshed_token = client.force_refresh().await?;
println!("Refreshed token: {}...", &refreshed_token[..20]);
client.clear_token().await?;
Ok(())
}
Token Persistence Configuration
Token persistence is automatically enabled in the following scenarios:
- When
AMP_TESTS=live (for live API testing)
- When
AMP_TOKEN_PERSISTENCE=true is set
- In test environments (
cfg!(test))
The token file (token.json) contains:
{
"token": "your_jwt_token_here",
"expires_at": "2024-01-01T12:00:00Z",
"obtained_at": "2024-01-01T11:00:00Z"
}
Configuration
Environment Variables
The client can be configured using the following environment variables:
Authentication (Required for live tests)
AMP_USERNAME: Username for AMP API authentication
AMP_PASSWORD: Password for AMP API authentication
AMP_API_BASE_URL: Base URL for the AMP API (default: https://amp-test.blockstream.com/api)
Retry Configuration (Optional)
API_RETRY_MAX_ATTEMPTS: Maximum number of retry attempts (default: 3)
API_RETRY_BASE_DELAY_MS: Base delay between retries in milliseconds (default: 1000)
API_RETRY_MAX_DELAY_MS: Maximum delay between retries in milliseconds (default: 30000)
API_REQUEST_TIMEOUT_SECONDS: Request timeout in seconds (default: 10)
Test Configuration
AMP_TESTS: Set to live to run tests against the actual API
Token Persistence (Optional)
AMP_TOKEN_PERSISTENCE: Set to true to enable token persistence to disk (default: enabled for live tests)
Example Configuration
export AMP_USERNAME=your_username
export AMP_PASSWORD=your_password
export AMP_API_BASE_URL=https://amp-test.blockstream.com/api
export API_RETRY_MAX_ATTEMPTS=5
export API_RETRY_BASE_DELAY_MS=2000
export API_RETRY_MAX_DELAY_MS=60000
export API_REQUEST_TIMEOUT_SECONDS=30
export AMP_TESTS=live
export AMP_TOKEN_PERSISTENCE=true
Signer Setup and Usage
The AMP client includes a comprehensive signer implementation for handling asset operations like distribution, reissuance, and burning. The LwkSoftwareSigner provides testnet-focused transaction signing using Blockstream's Liquid Wallet Kit (LWK).
⚠️ Security Warning
TESTNET/REGTEST ONLY: The LwkSoftwareSigner is designed exclusively for testnet and regtest environments. It stores mnemonic phrases in plain text and should NEVER be used in production or with real funds.
Basic Signer Setup
Creating a Signer from Existing Mnemonic
use amp_rs::signer::{LwkSoftwareSigner, Signer};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let signer = LwkSoftwareSigner::new(mnemonic)?;
assert!(signer.is_testnet());
println!("Signer ready for testnet operations");
Ok(())
}
Generating a New Signer
use amp_rs::signer::LwkSoftwareSigner;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (mnemonic, signer) = LwkSoftwareSigner::generate_new()?;
println!("Generated mnemonic: {}...", &mnemonic[..50]);
println!("Mnemonic saved to mnemonic.local.json");
assert!(signer.is_testnet());
Ok(())
}
Indexed Mnemonic Access for Testing
For test isolation and consistent test environments, use indexed mnemonic access:
use amp_rs::signer::LwkSoftwareSigner;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (_, issuer_signer) = LwkSoftwareSigner::generate_new_indexed(100)?;
let (_, distributor_signer) = LwkSoftwareSigner::generate_new_indexed(101)?;
let (_, user_signer) = LwkSoftwareSigner::generate_new_indexed(102)?;
println!("Created role-based signers for testing");
Ok(())
}
Generating Addresses for Asset Issuance
Before issuing assets, you need to generate addresses that can receive the issued assets:
use amp_rs::signer::LwkSoftwareSigner;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (mnemonic, signer) = LwkSoftwareSigner::generate_new()?;
let treasury_address = signer.derive_address(0, 0)?; println!("Treasury address: {}", treasury_address);
Ok(())
}
Using Signer with Asset Distribution
The signer integrates seamlessly with the distribute_asset method and will be essential for future burn and reissuance operations:
use amp_rs::{ApiClient, ElementsRpc, signer::LwkSoftwareSigner, model::AssetDistributionAssignment};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let api_client = ApiClient::new().await?;
let elements_rpc = ElementsRpc::from_env()?;
let (mnemonic, signer) = LwkSoftwareSigner::generate_new_indexed(300)?;
println!("Using signer with mnemonic: {}...", &mnemonic[..50]);
let wallet_name = "amp_distribution_wallet".to_string();
let asset_uuid = "your-asset-uuid";
let assignments = vec![AssetDistributionAssignment {
user_id: "user123".to_string(),
address: "tlq1qq...".to_string(), amount: 0.00000001, }];
api_client.distribute_asset(
asset_uuid,
assignments,
&elements_rpc,
&wallet_name,
&signer, ).await?;
println!("Asset distribution completed successfully");
Ok(())
}
Wallet Integration
For Elements wallet integration, you can generate descriptors from the signer:
use amp_rs::signer::LwkSoftwareSigner;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (_, signer) = LwkSoftwareSigner::generate_new()?;
let descriptor = signer.get_wpkh_slip77_descriptor()?;
println!("Descriptor for wallet import: {}", descriptor);
Ok(())
}
Reissue an Asset
Reissuance allows you to expand the supply of a reissuable asset. The asset must have been created with is_reissuable: true and you must have reissuance tokens available in your wallet.
use amp_rs::{ApiClient, ElementsRpc, signer::LwkSoftwareSigner};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ApiClient::new().await?;
let elements_rpc = ElementsRpc::from_env()?;
let (_, signer) = LwkSoftwareSigner::generate_new_indexed(200)?;
let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
let amount_to_reissue = 1_000_000_000;
client.reissue_asset(asset_uuid, amount_to_reissue, &elements_rpc, &signer).await?;
println!("Reissuance completed successfully");
Ok(())
}
You can also use the included example:
AMP_TESTS=live cargo run --example reissue_asset_example -- <ASSET_UUID>
Burn an Asset
Burning allows you to permanently destroy (reduce the supply of) an asset. You must have sufficient asset balance in your wallet to burn.
use amp_rs::{ApiClient, ElementsRpc, signer::LwkSoftwareSigner};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ApiClient::new().await?;
let elements_rpc = ElementsRpc::from_env()?;
let (_, signer) = LwkSoftwareSigner::generate_new_indexed(300)?;
let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
let amount_to_burn = 100_000_000; let wallet_name = "amp_elements_wallet_static_for_funding";
client.burn_asset(asset_uuid, amount_to_burn, &elements_rpc, wallet_name, &signer).await?;
println!("Burn completed successfully");
Ok(())
}
You can also use the included example:
AMP_TESTS=live cargo run --example burn_asset_example -- <ASSET_UUID>
The signer abstraction ensures consistent transaction signing across all asset operations while maintaining security best practices for testnet development.
Known Issue: Null Owner Fields in Asset Ownerships
Overview
The AMP API has a known issue where it doesn't currently guarantee an owner field for Issuer Tracked assets when calling get_asset_ownerships. This issue only affects Issuer Tracked assets; Transfer Restricted assets currently transmit the owner field as expected.
When the API returns ownership records with null owner fields for Issuer Tracked assets, the client will throw a deserialization error. The error message includes the raw JSON response, allowing implementations to handle the error and rebuild missing owners using the GAIDs they have stored in their state management strategy.
Error Recovery Pattern
You can extract and repair the data by looking up owners via their GAID (Global Asset ID) in your local database:
use amp_rs::ApiClient;
async fn fetch_ownerships_with_recovery(
client: &ApiClient,
db: &DatabaseConnection, asset_uuid: &str,
) -> Result<Vec<Ownership>, Box<dyn std::error::Error>> {
match client.get_asset_ownerships(asset_uuid, None).await {
Ok(ownerships) => Ok(ownerships),
Err(e) => {
let error_string = format!("{}", e);
if error_string.contains("null") && error_string.contains("owner") {
let error_lines: Vec<&str> = error_string.lines().collect();
let mut in_raw_response = false;
let mut raw_json_lines = Vec::new();
for line in error_lines {
if line.contains("Raw Response:") {
in_raw_response = true;
continue;
}
if in_raw_response && !line.starts_with("===") {
raw_json_lines.push(line);
}
}
let raw_response = raw_json_lines.join("\n");
if let Ok(mut ownerships_json) = serde_json::from_str::<serde_json::Value>(&raw_response) {
if let Some(array) = ownerships_json.as_array_mut() {
for item in array.iter_mut() {
if let Some(obj) = item.as_object_mut() {
if obj.get("owner").and_then(|v| v.as_str()).is_none() {
if let Some(gaid) = obj.get("GAID").and_then(|v| v.as_str()) {
if let Ok(Some(user)) = find_user_by_gaid(db, gaid).await {
obj.insert(
"owner".to_string(),
serde_json::json!(user.registered_id.to_string()),
);
}
}
}
}
}
let fixed_ownerships = serde_json::from_value::<Vec<Ownership>>(
serde_json::Value::Array(array.clone()),
)?;
return Ok(fixed_ownerships);
}
}
}
Err(e)
}
}
}
Complete Error Recovery Example
For a complete, production-ready implementation with logging and detailed error handling, see this example from the sats-asset-manager-demo:
let ownerships = match client.get_asset_ownerships(asset_uuid, None).await {
Ok(ownerships) => ownerships,
Err(e) => {
use std::io::Write;
let error_string = format!("{}", e);
if error_string.contains("null") && error_string.contains("owner") {
let detailed_msg = format!(
"\n=== Attempting Error Recovery ===\n\
Detected null owner in ownerships response.\n\
Attempting to resolve owners via GAID lookup...\n\
Original error: {}\n",
e
);
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("app.log")
{
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let _ = writeln!(
file,
"[{}][asset:{}] {}",
timestamp, asset_uuid, detailed_msg
);
}
let error_lines: Vec<&str> = error_string.lines().collect();
let mut in_raw_response = false;
let mut raw_json_lines = Vec::new();
for line in error_lines {
if line.contains("Raw Response:") {
in_raw_response = true;
continue;
}
if in_raw_response && !line.starts_with("===") {
raw_json_lines.push(line);
}
}
let raw_response = raw_json_lines.join("\n");
if let Ok(mut ownerships_json) =
serde_json::from_str::<serde_json::Value>(&raw_response)
{
if let Some(array) = ownerships_json.as_array_mut() {
for item in array.iter_mut() {
if let Some(obj) = item.as_object_mut() {
if obj.get("owner").and_then(|v| v.as_str()).is_none() {
if let Some(gaid) = obj
.get("GAID")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
{
if let Ok(gaid_model) =
GaidService::find_by_gaid(db, &gaid).await
{
if let Some(gaid_entry) = gaid_model {
if let Ok(Some(user)) =
UserService::find_by_id(db, gaid_entry.user_id)
.await
{
if let Some(registered_id) = user.registered_id
{
obj.insert(
"owner".to_string(),
serde_json::json!(
registered_id.to_string()
),
);
let success_msg = format!("Resolved null owner via GAID {}: user {}", gaid, registered_id);
if let Ok(mut file) =
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("app.log")
{
let timestamp = chrono::Local::now()
.format("%Y-%m-%d %H:%M:%S");
let _ = writeln!(
file,
"[{}][asset:{}] {}",
timestamp, asset_uuid, success_msg
);
}
}
}
}
}
}
}
}
}
match serde_json::from_value::<Vec<amp_rs::model::Ownership>>(
serde_json::Value::Array(array.clone()),
) {
Ok(fixed_ownerships) => {
let recovery_msg =
"Successfully recovered ownerships after fixing null owners";
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("app.log")
{
let timestamp =
chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let _ = writeln!(
file,
"[{}][asset:{}] {}",
timestamp, asset_uuid, recovery_msg
);
}
fixed_ownerships
}
Err(parse_err) => {
let failure_msg =
format!("Failed to parse fixed ownerships: {}", parse_err);
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("app.log")
{
let timestamp =
chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let _ = writeln!(
file,
"[{}][asset:{}] {}",
timestamp, asset_uuid, failure_msg
);
}
return Err(Box::<dyn std::error::Error>::from(format!(
"Failed to recover ownerships: {}",
parse_err
)));
}
}
} else {
return Err(Box::<dyn std::error::Error>::from(
"Failed to parse ownerships as array",
));
}
} else {
return Err(Box::<dyn std::error::Error>::from(format!(
"Failed to extract raw response for recovery: {}",
e
)));
}
} else {
return Err(e);
}
}
};
Summary
This pattern allows applications to:
- Detect when the AMP API returns incomplete ownership data for Issuer Tracked assets
- Extract the raw JSON response from the error
- Resolve missing owner information using locally stored GAID mappings
- Successfully process ownership data despite the API limitation
Note: Transfer Restricted assets are not affected by this issue and should work without requiring error recovery.
Testing
To run the tests, you will need to set the AMP_USERNAME and AMP_PASSWORD environment variables.
AMP_USERNAME=... AMP_PASSWORD=... cargo test
To run the live tests, you will also need to set the AMP_TESTS environment variable to live.
AMP_USERNAME=... AMP_PASSWORD=... AMP_TESTS=live cargo test
Some tests that perform state-changing operations are ignored by default. To run them, use the --ignored flag.
AMP_USERNAME=... AMP_PASSWORD=... AMP_TESTS=live cargo test -- --ignored