Blockstream Enterprise
Recipes

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 Executes

Benefits of this approach:

BenefitDescription
Fraud PreventionLarge or unusual transactions can require additional approval
ComplianceEnforce regulatory requirements (KYC, AML, travel rule) before execution
Operational ControlSeparate "requesters" from "approvers" in your organization
Audit TrailEvery transaction has a documented approval chain
Risk ManagementImplement spending limits, whitelists, and time-based restrictions

The Transaction Lifecycle

  1. Create Spend Request - Initiate a transaction (issue, send, burn, reissue)
  2. Policy Evaluation - System evaluates policies against the request
  3. Proposal Creation - If policy triggers review, a proposal is created
  4. Approval Process - Designated reviewers approve or reject
  5. Transaction Execution - Upon approval, transaction is processed

Transaction Flow Diagram

rect [rgb(240, 248, 255)] alt [Policy Decision: ALLOW] [Policy Decision: DENY] [Policy Decision: REVIEW] rect [rgb(255, 248, 240)] loop [Until K-of-N requirement met] rect [rgb(240, 255, 240)] rect [rgb(255, 240, 255)] POST /wallets/:id/spend-requests Lock UTXOs Broadcast transaction status: success Unlock UTXOs status: denied status: pending, proposal_id POST /proposals/:id/review Check review_expression All approvals received Sign and broadcast transaction Unlock remaining UTXOs Proposal executed Step 2: Policy Evaluation Step 3: Approval Process Step 4: Execution User Server Reviewers Blockchain Policy Engine

Spend Request State Machine

Create spend request Submit to policy engine ALLOW decision DENY decision REVIEW decision All approvals received Reviewer rejects Creator cancels Transaction broadcast Broadcast error UTXOs unlocked UTXOs unlocked UTXOs unlocked Complete Created PolicyEval Processed Failed Pending Approved Rejected Canceled

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)
                    Available

Why 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

TypeValueNetworkDescription
IssueissueLiquid onlyCreate a new asset with initial supply
SendsendLiquid, BitcoinTransfer assets to recipients
BurnburnLiquid onlyPermanently remove assets from supply
ReissuereissueLiquid onlyIncrease 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

FieldTypeRequiredDescription
issue_amountNumberYesInitial token supply
reissue_amountNumberYesReissuance tokens (0 for non-reissuable)
coingecko_idStringNoCoinGecko ID for price tracking
contract.nameStringYesAsset display name
contract.tickerStringYesAsset ticker symbol
contract.domainStringYesDomain for asset verification
contract.versionNumberYesContract version (usually 0)
contract.precisionNumberYesDecimal 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

FieldTypeRequiredDescription
asset_idStringLiquid onlyAsset ID to transfer (not needed for Bitcoin)
recipientsArrayYesList of recipients
recipients[].amountNumberYesAmount in base units (satoshis)
recipients[].recipient.typeStringYeswallet or address
recipients[].recipient.valueStringYesWallet 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

FieldTypeRequiredDescription
asset_idStringYesAsset ID to burn
amountNumberYesAmount to burn (in base units)

Note: Burning is irreversible. The wallet must have can_burn: true in 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:

  1. The Asset - The actual token with your specified issue_amount
  2. Reissuance Token - A special token that grants permission to mint more of the asset
Initial Issuance Later: Reissue Required POST /spend-requeststx_type: issue issue_amount: 100000reissue_amount: 1 Asset TokenID: abc123...Amount: 100,000 Reissuance TokenID: def456...Amount: 1 Wallet holdsReissuance Token POST /spend-requeststx_type: reissue Mint 50,000 moreAsset Tokens

Key Concepts:

ConceptDescription
Reissuance TokenA separate token created during issuance that authorizes minting new supply
reissue_amountNumber of reissuance tokens created (typically 1)
Non-reissuable AssetIf reissue_amount: 0, no reissuance token is created and supply is permanently fixed
Token HolderOnly 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

FieldTypeRequiredDescription
asset_idStringYesAsset ID to reissue (not the reissuance token ID)
amountNumberYesAdditional supply to mint

Reissuance Scenarios

Scenarioreissue_amountResult
Fixed supply token0Asset can never be reissued
Standard reissuable1One wallet can reissue at a time
Multiple reissuance tokens>1Can split minting authority across wallets

Important:

  • The wallet performing reissue must 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

StatusDescription
pendingWaiting for policy approval (proposal created)
processedTransaction successfully broadcast and confirmed
failedTransaction failed (broadcast error, insufficient funds, etc.)

Proposal Statuses

StatusDescription
pendingWaiting for reviewer approvals
approvedAll required approvals received, executing
executedTransaction broadcast successfully
rejectedRejected by a reviewer
deniedFailed policy validation
canceledCanceled 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')

On this page