AMP Asset Transaction Workflow
This guide describes the complete transaction workflow for AMP assets in the Custody Engine, including policy evaluation, proposal creation, and multi-party approval processes.
Overview
Transactions in the Custody Engine follow a governance-driven workflow that separates transaction initiation from execution. This architecture provides several critical benefits for enterprise custody:
Why Governance-Driven Transactions?
Traditional cryptocurrency wallets execute transactions immediately upon signing. The Custody Engine introduces a policy layer between request and execution:
Traditional: User Signs → Transaction Executes
Custody Engine: User Requests → Policy Evaluates → [Approval Process] → Transaction ExecutesBenefits of this approach:
| Benefit | Description |
|---|---|
| Fraud Prevention | Large or unusual transactions can require additional approval |
| Compliance | Enforce regulatory requirements (KYC, AML, travel rule) before execution |
| Operational Control | Separate "requesters" from "approvers" in your organization |
| Audit Trail | Every transaction has a documented approval chain |
| Risk Management | Implement spending limits, whitelists, and time-based restrictions |
The Transaction Lifecycle
- Create Spend Request - Initiate a transaction (issue, send, burn, reissue)
- Policy Evaluation - System evaluates policies against the request
- Proposal Creation - If policy triggers review, a proposal is created
- Approval Process - Designated reviewers approve or reject
- Transaction Execution - Upon approval, transaction is processed
Transaction Flow Diagram
Spend Request State Machine
Understanding UTXO Locking
When a spend request is created, the system locks the UTXOs (Unspent Transaction Outputs) that will be used. This prevents double-spending during the approval process:
Before Request: [UTXO-A: 50,000] [UTXO-B: 30,000] [UTXO-C: 20,000]
Available Available Available
After Request: [UTXO-A: 50,000] [UTXO-B: 30,000] [UTXO-C: 20,000]
(spending 70k) LOCKED LOCKED Available
After Approval: [UTXO-D: 28,500] (change output, new UTXO)
AvailableWhy locking matters:
- Prevents the same funds from being included in multiple pending transactions
- Ensures the transaction can be executed once approved
- UTXOs are automatically unlocked if the proposal is rejected or canceled
Transaction Types
| Type | Value | Network | Description |
|---|---|---|---|
| Issue | issue | Liquid only | Create a new asset with initial supply |
| Send | send | Liquid, Bitcoin | Transfer assets to recipients |
| Burn | burn | Liquid only | Permanently remove assets from supply |
| Reissue | reissue | Liquid only | Increase supply of existing asset |
Note: Asset issuance, burning, and reissuance are Liquid-specific features. Bitcoin wallets only support send/transfer operations.
Creating Spend Requests
Issue Asset (Liquid Only)
Creates a new asset on the Liquid network with initial supply and optional reissuance tokens.
const spendRequestId = uuidv4()
const issueResult = await broadcastRequest({
action: 'add',
resource: `/wallets/${walletId}/spend-requests`,
details: {
id: spendRequestId,
wallet_id: walletId,
tx_type: 'issue',
request: {
issue_amount: 100000, // Initial supply (in base units)
reissue_amount: 50000, // Reissuance tokens (0 = non-reissuable)
coingecko_id: 'bitcoin', // Price tracking (optional)
contract: {
name: 'My Token',
ticker: 'MTK',
domain: 'mytoken.example.com',
version: 0,
precision: 8, // Decimal places (8 = satoshi-like)
},
},
},
})
// Response depends on policy evaluation
if (issueResult.status === 'pending') {
// Policy triggered review - proposal created
const proposalId = issueResult.details.proposal_id
console.log('Proposal created:', proposalId)
} else if (issueResult.status === 'success') {
// No policy or ALLOW - executed immediately
const assetId = issueResult.details.asset_id
console.log('Asset created:', assetId)
}Issue Request Parameters
| Field | Type | Required | Description |
|---|---|---|---|
issue_amount | Number | Yes | Initial token supply |
reissue_amount | Number | Yes | Reissuance tokens (0 for non-reissuable) |
coingecko_id | String | No | CoinGecko ID for price tracking |
contract.name | String | Yes | Asset display name |
contract.ticker | String | Yes | Asset ticker symbol |
contract.domain | String | Yes | Domain for asset verification |
contract.version | Number | Yes | Contract version (usually 0) |
contract.precision | Number | Yes | Decimal places (0-8) |
Transfer - Liquid Network
Transfer Liquid assets (L-BTC or issued assets) to recipients.
// Transfer to another wallet by wallet ID
const liquidTransferResult = await broadcastRequest({
action: 'add',
resource: `/wallets/${walletId}/spend-requests`,
details: {
id: uuidv4(),
wallet_id: walletId,
tx_type: 'send',
request: {
asset_id: assetId, // Asset to transfer
recipients: [
{
amount: 1000,
recipient: {
type: 'wallet',
value: recipientWalletId,
},
},
],
},
},
})
// Transfer to an external Liquid address
const liquidAddressTransfer = await broadcastRequest({
action: 'add',
resource: `/wallets/${walletId}/spend-requests`,
details: {
id: uuidv4(),
wallet_id: walletId,
tx_type: 'send',
request: {
asset_id: assetId,
recipients: [
{
amount: 5000,
recipient: {
type: 'address',
value: 'lq1qqxyz...', // Liquid address
},
},
],
},
},
})
// Transfer L-BTC (policy asset)
const LBTC_ASSET_ID = '6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d'
const lbtcTransfer = await broadcastRequest({
action: 'add',
resource: `/wallets/${walletId}/spend-requests`,
details: {
id: uuidv4(),
wallet_id: walletId,
tx_type: 'send',
request: {
asset_id: LBTC_ASSET_ID,
recipients: [
{
amount: 100000, // 0.001 L-BTC (in satoshis)
recipient: {
type: 'wallet',
value: recipientWalletId,
},
},
],
},
},
})
// Multi-recipient transfer
const multiRecipientTransfer = await broadcastRequest({
action: 'add',
resource: `/wallets/${walletId}/spend-requests`,
details: {
id: uuidv4(),
wallet_id: walletId,
tx_type: 'send',
request: {
asset_id: assetId,
recipients: [
{
amount: 1000,
recipient: { type: 'wallet', value: wallet1Id },
},
{
amount: 2000,
recipient: { type: 'wallet', value: wallet2Id },
},
{
amount: 3000,
recipient: { type: 'address', value: 'lq1qqxyz...' },
},
],
},
},
})Transfer - Bitcoin Network
Transfer BTC to recipients. Bitcoin wallets only support native BTC transfers.
// Transfer to another wallet by wallet ID
const btcTransferResult = await broadcastRequest({
action: 'add',
resource: `/wallets/${btcWalletId}/spend-requests`,
details: {
id: uuidv4(),
wallet_id: btcWalletId,
tx_type: 'send',
request: {
recipients: [
{
amount: 50000, // 0.0005 BTC (in satoshis)
recipient: {
type: 'wallet',
value: recipientBtcWalletId,
},
},
],
},
},
})
// Transfer to an external Bitcoin address
const btcAddressTransfer = await broadcastRequest({
action: 'add',
resource: `/wallets/${btcWalletId}/spend-requests`,
details: {
id: uuidv4(),
wallet_id: btcWalletId,
tx_type: 'send',
request: {
recipients: [
{
amount: 100000,
recipient: {
type: 'address',
value: 'bc1qxyz...', // Bitcoin address
},
},
],
},
},
})Transfer Request Parameters
| Field | Type | Required | Description |
|---|---|---|---|
asset_id | String | Liquid only | Asset ID to transfer (not needed for Bitcoin) |
recipients | Array | Yes | List of recipients |
recipients[].amount | Number | Yes | Amount in base units (satoshis) |
recipients[].recipient.type | String | Yes | wallet or address |
recipients[].recipient.value | String | Yes | Wallet ID or blockchain address |
Burn Assets (Liquid Only)
Permanently remove assets from circulation.
// Step 1: Enable burning for the wallet (if not already enabled)
await broadcastRequest({
action: 'edit',
resource: `/assets/${assetId}/wallets/${walletId}/restrictions`,
details: {
can_burn: true,
},
})
// Step 2: Create burn request
const burnResult = await broadcastRequest({
action: 'add',
resource: `/wallets/${walletId}/spend-requests`,
details: {
id: uuidv4(),
wallet_id: walletId,
tx_type: 'burn',
request: {
asset_id: assetId,
amount: 1000,
},
},
})
if (burnResult.status === 'success') {
console.log('Burn executed, TXID:', burnResult.details.txid)
} else if (burnResult.status === 'pending') {
console.log('Burn requires approval, proposal:', burnResult.details.proposal_id)
}Burn Request Parameters
| Field | Type | Required | Description |
|---|---|---|---|
asset_id | String | Yes | Asset ID to burn |
amount | Number | Yes | Amount to burn (in base units) |
Note: Burning is irreversible. The wallet must have
can_burn: truein asset restrictions.
Reissue Assets (Liquid Only)
Increase the supply of an existing asset. Requires reissuance tokens from the original issuance.
How Reissuance Tokens Work
When you issue an asset on Liquid, two separate tokens are created:
- The Asset - The actual token with your specified
issue_amount - Reissuance Token - A special token that grants permission to mint more of the asset
Key Concepts:
| Concept | Description |
|---|---|
| Reissuance Token | A separate token created during issuance that authorizes minting new supply |
| reissue_amount | Number of reissuance tokens created (typically 1) |
| Non-reissuable Asset | If reissue_amount: 0, no reissuance token is created and supply is permanently fixed |
| Token Holder | Only the wallet holding the reissuance token can mint new supply |
Reissuance Token Transfer
The reissuance token can be transferred to another wallet, effectively transferring the ability to mint new tokens. This is useful for:
- Delegating minting authority to another party
- Moving control to a more secure wallet
- Multi-party asset management
// Step 1: After issuance, check wallet balances to find the reissuance token
const balances = await broadcastRequest({
action: 'get',
resource: `/wallets/${issuerWalletId}/balances`,
})
// You'll see 3 balances after issuance:
// 1. L-BTC (fee asset)
// 2. Your issued asset (issue_amount)
// 3. Reissuance token (reissue_amount)
const reissuanceTokenId = balances.details.find(
b => b.asset_id !== LBTC_ASSET_ID && b.asset_id !== issuedAssetId,
)?.asset_id
console.log('Reissuance Token ID:', reissuanceTokenId)
// Step 2: Transfer reissuance token to another wallet
const transferReissuanceToken = await broadcastRequest({
action: 'add',
resource: `/wallets/${issuerWalletId}/spend-requests`,
details: {
id: uuidv4(),
wallet_id: issuerWalletId,
tx_type: 'send',
request: {
asset_id: reissuanceTokenId,
recipients: [
{
amount: 1, // Transfer the reissuance token
recipient: {
type: 'wallet',
value: treasuryWalletId, // New holder can now reissue
},
},
],
},
},
})
// Step 3: Now treasuryWallet can reissue the asset
const reissueFromTreasury = await broadcastRequest({
action: 'add',
resource: `/wallets/${treasuryWalletId}/spend-requests`,
details: {
id: uuidv4(),
wallet_id: treasuryWalletId,
tx_type: 'reissue',
request: {
asset_id: issuedAssetId, // Original asset ID
amount: 50000, // New tokens to mint
},
},
})Reissue Request
const reissueResult = await broadcastRequest({
action: 'add',
resource: `/wallets/${walletId}/spend-requests`,
details: {
id: uuidv4(),
wallet_id: walletId,
tx_type: 'reissue',
request: {
asset_id: assetId,
amount: 25000, // Additional supply to mint
},
},
})
if (reissueResult.status === 'success') {
console.log('Reissue executed, new tokens minted')
} else if (reissueResult.status === 'pending') {
console.log('Reissue requires approval, proposal:', reissueResult.details.proposal_id)
}Reissue Request Parameters
| Field | Type | Required | Description |
|---|---|---|---|
asset_id | String | Yes | Asset ID to reissue (not the reissuance token ID) |
amount | Number | Yes | Additional supply to mint |
Reissuance Scenarios
| Scenario | reissue_amount | Result |
|---|---|---|
| Fixed supply token | 0 | Asset can never be reissued |
| Standard reissuable | 1 | One wallet can reissue at a time |
| Multiple reissuance tokens | >1 | Can split minting authority across wallets |
Important:
- The wallet performing
reissuemust hold the reissuance token- You reissue using the asset ID, not the reissuance token ID
- Reissuance tokens are consumed and recreated during each reissue operation
Transaction Statuses
Spend Request Statuses
| Status | Description |
|---|---|
pending | Waiting for policy approval (proposal created) |
processed | Transaction successfully broadcast and confirmed |
failed | Transaction failed (broadcast error, insufficient funds, etc.) |
Proposal Statuses
| Status | Description |
|---|---|
pending | Waiting for reviewer approvals |
approved | All required approvals received, executing |
executed | Transaction broadcast successfully |
rejected | Rejected by a reviewer |
denied | Failed policy validation |
canceled | Canceled by the creator |
Checking Transaction Status
// Check spend request status
const spendRequestStatus = await broadcastRequest({
action: 'get',
resource: `/spend-requests/${spendRequestId}`,
details: {},
})
console.log('Status:', spendRequestStatus.details.status)
console.log('Asset ID:', spendRequestStatus.details.asset_id)
console.log('TXID:', spendRequestStatus.details.txid)
console.log('Proposal IDs:', spendRequestStatus.details.proposal_ids)
// Check proposal status (if pending approval)
const proposalStatus = await broadcastRequest({
action: 'get',
resource: `/proposals/${proposalId}`,
details: {},
})
console.log('Proposal Status:', proposalStatus.details.status)Waiting for Status Changes
// Wait for spend request to be processed
async function waitForSpendRequestStatus(
spendRequestId: string,
targetStatus: 'processed' | 'failed',
maxRetries = 60,
delayMs = 2000,
) {
for (let i = 0; i < maxRetries; i++) {
const result = await broadcastRequest({
action: 'get',
resource: `/spend-requests/${spendRequestId}`,
details: {},
})
if (result.details.status === targetStatus) {
return result.details
}
if (result.details.status === 'failed') {
throw new Error('Spend request failed')
}
await new Promise(resolve => setTimeout(resolve, delayMs))
}
throw new Error('Timeout waiting for spend request status')
}
// Wait for proposal execution
async function waitForProposalStatus(
proposalId: string,
targetStatus: 'executed' | 'rejected' | 'canceled',
maxRetries = 60,
delayMs = 2000,
) {
for (let i = 0; i < maxRetries; i++) {
const result = await broadcastRequest({
action: 'get',
resource: `/proposals/${proposalId}`,
details: {},
})
if (result.details.status === targetStatus) {
return result.details
}
if (['rejected', 'denied', 'canceled'].includes(result.details.status)) {
throw new Error(`Proposal ${result.details.status}`)
}
await new Promise(resolve => setTimeout(resolve, delayMs))
}
throw new Error('Timeout waiting for proposal status')
}
// Usage
await waitForProposalStatus(proposalId, 'executed')
await waitForSpendRequestStatus(spendRequestId, 'processed')Batch Requests
Send multiple operations in one encrypted round-trip. Batching reduces network overhead, keeps sequential workflows together, and — with atomic mode — guarantees all mutations either succeed or roll back together.
Policy Configuration
Policies are the heart of the Custody Engine's governance system. They define rules that control when any action requires approval, who can approve it, and under what conditions actions should be automatically allowed or denied.