Blockstream Enterprise
Recipes

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.

Policies apply to all resources, not just transactions. You can create policies for:

  • Spend requests (transactions)
  • User creation and modification
  • Wallet configuration changes
  • Role and group management
  • Asset restrictions
  • Any other resource action

How Policies Are Evaluated

Policies are evaluated in priority order (highest first). The first matching policy determines the outcome:

Request comes in

┌─────────────────────────────────────────┐
│ Policy A (priority: 100)                │
│ Expression: amount > 100000             │ ← Doesn't match, skip
│ Decision: REVIEW                        │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│ Policy B (priority: 90)                 │
│ Expression: recipient in whitelist      │ ← Matches!
│ Decision: ALLOW                         │
└─────────────────────────────────────────┘

Transaction executes immediately (ALLOW)

If no policy matches, the default behavior is to ALLOW the transaction. To deny by default, create a low-priority catch-all policy.

Policy Structure

Policy Definition ALLOW DENY REVIEW K OF GroupName Proposal Created resource action expression decision_on_true Execute Reject review_expression

Creating a Basic Policy

const policyResult = await broadcastRequest({
  action: 'add',
  resource: '/policies',
  details: {
    plid: uuidv4(),
    name: 'require-approval-for-large-transfers',
    description: 'Transfers over 10000 require manager approval',
    resource: `/wallets/${walletId}/spend-requests`,
    action: 'add',
    priority: 100, // Higher = evaluated first
    expression: `request.details.tx_type == "send" &&
                 request.details.request.recipients[0].amount > 10000`,
    decision_on_true: 'REVIEW',
    review_expression: `1 OF managers`,
  },
})

Policy Parameters

FieldTypeDescription
plidUUIDUnique policy identifier
nameStringHuman-readable name
resourceStringResource path to match (supports wildcards)
actionStringAction to match (add, edit, delete)
priorityNumberEvaluation order (higher = first)
expressionStringCondition that triggers the policy
decision_on_trueStringALLOW, DENY, or REVIEW
review_expressionStringApproval requirement (for REVIEW)

Review Expression Syntax

# Single group approval
1 OF groupName

# Multiple approvals from same group
2 OF security_team

# Multiple groups (AND)
(1 OF security_team) AND (1 OF compliance_team)

# Complex requirements
(2 OF security_team) AND (1 OF executives)

Policy Expression Functions

Policy expressions are evaluated using a sandboxed JavaScript runtime with access to predefined functions and variables.

Global Variables

VariableTypeDescription
requestObjectThe full request object with action, resource, details
actionStringThe action being performed (e.g., add, edit, delete)
resourceStringThe resource path (e.g., /wallets/uuid/spend-requests)
callerObjectInformation about the user making the request

Caller Object

Property/MethodDescriptionExample
caller.uidUser's unique identifiercaller.uid == 'user-uuid'
caller.emailUser's email addresscaller.email.endsWith('@company.com')
caller.statusUser's account statuscaller.status == 'active'
caller.firstNameUser's first name
caller.lastNameUser's last name
caller.can(action, resource)Check if user has RBAC permissioncaller.can('approve', '/proposals')
caller.hasRole(role)Check if user has a specific rolecaller.hasRole('super-admin')

Helper Functions

FunctionDescriptionExample
isEmail(str)Validate email formatisEmail(request.details.email)
matchesPattern(str, pattern)Regex pattern matchingmatchesPattern(address, '^bc1.*')
inRange(num, min, max)Check if number is within rangeinRange(amount, 0, 100000)

User & Group Functions

FunctionDescriptionExample
getUserGroups()Get current user's group IDsgetUserGroups().includes('group-uuid')
isInitiatedByUsers([userIds])Check if caller is in user listisInitiatedByUsers(['user-uuid-1', 'user-uuid-2'])
isInitiatedByGroups([groupIds])Check if caller belongs to any groupisInitiatedByGroups(['treasury-team-uuid'])

Account/Wallet Functions

FunctionDescriptionExample
isFromAccounts([walletIds])Check if request is from specified walletsisFromAccounts(['wallet-uuid'])
isToAccounts([walletIds])Check if recipients include specified walletsisToAccounts(['approved-wallet-uuid'])
getWalletRecipientGroups(walletId)Get recipient groups for a walletgetWalletRecipientGroups(request.details.wallet_id)

Recipient & Whitelist Functions

FunctionDescriptionExample
isToRecipientInWhitelists([whitelistIds])Check if all recipients are in whitelistsisToRecipientInWhitelists(['whitelist-uuid'])
isToRecipientInBlacklists([blacklistIds])Check if any recipient is in blacklistsisToRecipientInBlacklists(['blocked-uuid'])
getRecipientGroups([paymentDetailsIds])Get groups by payment details IDs
getRecipientWhitelists([paymentDetailsIds])Get whitelists by payment details IDs
getRecipientBlacklists([paymentDetailsIds])Get blacklists by payment details IDs
getRecipientGroupsByValues([values])Get groups by wallet IDs or addressesgetRecipientGroupsByValues(['wallet-id'])
getRecipientWhitelistsByValues([values])Get whitelists by wallet IDs or addressesgetRecipientWhitelistsByValues([recipientValue])
getRecipientBlacklistsByValues([values])Get blacklists by wallet IDs or addressesgetRecipientBlacklistsByValues([recipientValue])

Security Limits

Policy expressions run in a sandboxed environment with strict limits:

LimitValueDescription
Execution timeout100msMaximum execution time
Memory limit10MBMaximum heap size
Regex pattern length1000 charsMaximum regex pattern length

Example: Multi-Tier Approval Policy

// Policy requiring both security and compliance approval
const policyResult = await broadcastRequest({
  action: 'add',
  resource: '/policies',
  details: {
    plid: uuidv4(),
    name: 'multi-tier-approval',
    resource: `/wallets/${walletId}/spend-requests`,
    action: 'add',
    priority: 100,
    expression: 'request.details.tx_type == "issue"',
    decision_on_true: 'REVIEW',
    review_expression: `(1 OF ${securityGroupId}) AND (1 OF ${complianceGroupId})`,
  },
})

Example: Restriction-Based Policies

Policies can enforce transfer restrictions using wallet groups and recipient whitelists.

Restrict Transfers to Whitelisted Recipients Only

// Only allow transfers from wallets in "senders" group
// to recipients in "approved_recipients" whitelist
const restrictedTransferPolicy = await broadcastRequest({
  action: 'add',
  resource: '/policies',
  details: {
    plid: uuidv4(),
    name: 'whitelist-only-transfers',
    resource: '/wallets/*/spend-requests',
    action: 'add',
    priority: 100,
    expression: `
      request.details.tx_type == "send" &&
      getWalletRecipientGroups(request.details.wallet_id)
        .includes("${sendersGroupId}") &&
      request.details.request.recipients
        .map(r => r.recipient.value)
        .every(val => getRecipientWhitelistsByValues([val])
          .includes("${approvedRecipientsWhitelistId}"))
    `,
    decision_on_true: 'REVIEW',
    review_expression: `1 OF ${complianceGroupId}`,
  },
})

Policy Expression Breakdown

request.details.tx_type == "send"

Matches only send/transfer transactions.

getWalletRecipientGroups(request.details.wallet_id).includes("group-uuid")

Checks if the source wallet belongs to a specific recipient group.

request.details.request.recipients.map(r => r.recipient.value)

Extracts all recipient values (wallet IDs or addresses) from the request.

.every(val => getRecipientWhitelistsByValues([val]).includes("whitelist-uuid"))

Verifies ALL recipients are in the specified whitelist.

Deny Non-Whitelisted Transfers

// Deny transfers that don't meet whitelist requirements
const denyNonWhitelistedPolicy = await broadcastRequest({
  action: 'add',
  resource: '/policies',
  details: {
    plid: uuidv4(),
    name: 'deny-non-whitelisted',
    resource: `/wallets/${restrictedWalletId}/spend-requests`,
    action: 'add',
    priority: 90,
    expression: `
      request.details.tx_type == "send" &&
      !request.details.request.recipients
        .map(r => r.recipient.value)
        .every(val => getRecipientWhitelistsByValues([val])
          .includes("${whitelistId}"))
    `,
    decision_on_true: 'DENY',
  },
})

Multi-Group Transfer Restrictions

// CRA wallet can only send to CCA wallets, requires TOT approval
const craTransferPolicy = await broadcastRequest({
  action: 'add',
  resource: '/policies',
  details: {
    plid: uuidv4(),
    name: 'cra-to-cca-transfers',
    resource: `/wallets/${craWalletId}/spend-requests`,
    action: 'add',
    priority: 100,
    expression: `
      request.details.tx_type == "send" &&
      getWalletRecipientGroups(request.details.wallet_id)
        .includes("${craGroupId}") &&
      request.details.request.recipients
        .map(r => r.recipient.value)
        .every(val => getRecipientWhitelistsByValues([val])
          .includes("${ccaGroupId}"))
    `,
    decision_on_true: 'REVIEW',
    review_expression: `1 OF ${totGroupId}`,
  },
})

// Deny all other transfers from CRA wallet
const craDenyOthersPolicy = await broadcastRequest({
  action: 'add',
  resource: '/policies',
  details: {
    plid: uuidv4(),
    name: 'cra-deny-others',
    resource: `/wallets/${craWalletId}/spend-requests`,
    action: 'add',
    priority: 50, // Lower priority - evaluated after whitelist policy
    expression: 'request.details.tx_type == "send"',
    decision_on_true: 'DENY',
  },
})

Simplified Policy Examples

Using the built-in helper functions for cleaner expressions:

// Allow only transfers from treasury wallets to approved recipients
const treasuryPolicy = await broadcastRequest({
  action: 'add',
  resource: '/policies',
  details: {
    plid: uuidv4(),
    name: 'treasury-transfers',
    resource: '/wallets/*/spend-requests',
    action: 'add',
    priority: 100,
    expression: `
      request.details.tx_type == "send" &&
      isFromAccounts(["${treasuryWalletId}"]) &&
      isToRecipientInWhitelists(["${approvedRecipientsWhitelistId}"])
    `,
    decision_on_true: 'REVIEW',
    review_expression: `1 OF ${treasuryManagersGroupId}`,
  },
})

// Block transfers to blacklisted recipients
const blacklistPolicy = await broadcastRequest({
  action: 'add',
  resource: '/policies',
  details: {
    plid: uuidv4(),
    name: 'block-blacklisted',
    resource: '/wallets/*/spend-requests',
    action: 'add',
    priority: 200, // Higher priority - checked first
    expression: `
      request.details.tx_type == "send" &&
      isToRecipientInBlacklists(["${sanctionsBlacklistId}"])
    `,
    decision_on_true: 'DENY',
  },
})

// Require approval for transfers initiated by junior staff
const juniorStaffPolicy = await broadcastRequest({
  action: 'add',
  resource: '/policies',
  details: {
    plid: uuidv4(),
    name: 'junior-staff-approval',
    resource: '/wallets/*/spend-requests',
    action: 'add',
    priority: 80,
    expression: `
      request.details.tx_type == "send" &&
      isInitiatedByGroups(["${juniorStaffGroupId}"])
    `,
    decision_on_true: 'REVIEW',
    review_expression: `1 OF ${seniorStaffGroupId}`,
  },
})

Proposal Approval Workflow

Approval Flow Diagram

Proposal Created Review Process Execution REVIEW Yes No No Yes UTXOs Unlocked Spend Request Policy Match? Create Proposal Status: PENDING UTXOs Locked Reviewer 1 Approve? Record Approval Status: REJECTED All Requirements Met? Wait for more approvals Status: APPROVED Sign Transaction Broadcast to Network Status: EXECUTED UTXOs Unlocked

Getting Proposal Details

const proposalDetails = await broadcastRequest({
  action: 'get',
  resource: `/proposals/${proposalId}`,
  details: {},
})

console.log('Status:', proposalDetails.details.status)
// 'pending' | 'approved' | 'executed' | 'rejected' | 'canceled'

Approving a Proposal

// Create SDK instance for reviewer
const reviewerInstance = new Blockstream(
  reviewer.rsaPrivateKeyPem,
  reviewer.ecdsaPrivateKey,
  reviewer.deviceUUID,
  SERVER_RSA_PUBLIC_KEY_PEM,
  SERVER_ECDSA_PUBLIC_KEY,
)

// Submit approval
const reviewResult = await broadcastRequest(
  {
    action: 'edit',
    resource: `/proposals/${proposalId}/review`,
    details: {
      verdict: 'approved',
      comment: 'Approved - meets all requirements',
    },
  },
  {},
  reviewerInstance,
)

Rejecting a Proposal

const rejectResult = await broadcastRequest(
  {
    action: 'edit',
    resource: `/proposals/${proposalId}/review`,
    details: {
      verdict: 'denied',
      comment: 'Rejected - insufficient documentation',
    },
  },
  {},
  reviewerInstance,
)

// Proposal status becomes 'rejected'
// UTXOs are unlocked
// Spend request is removed

Canceling a Proposal (Creator)

const cancelResult = await broadcastRequest({
  action: 'edit',
  resource: `/proposals/${proposalId}/cancel`,
  details: {
    reason: 'No longer needed',
  },
})

Complete Example: Asset Issuance with Approval

This example demonstrates the full workflow from setup to execution.

import { Blockstream } from '@blockstream/ecs-js-sdk'
import { v4 as uuidv4 } from 'uuid'

async function issueAssetWithApproval() {
  const timestamp = Date.now()

  // ========================================
  // SETUP: Create wallet, group, and policy
  // ========================================

  // 1. Create and fund wallet
  const { walletId, walletAddress } = await createLiquidWallet(timestamp)
  await faucetLiquidAddress(walletAddress, walletId)

  // 2. Create reviewer group
  const reviewerGroupId = uuidv4()
  await broadcastRequest({
    action: 'add',
    resource: '/groups',
    details: {
      gid: reviewerGroupId,
      name: `reviewers_${timestamp}`,
      description: 'Asset issuance reviewers',
    },
  })

  // 3. Create reviewer user and add to group
  const reviewerKeys = await generateUserKeyPairs()
  const reviewerResult = await broadcastRequest({
    action: 'add',
    resource: '/users',
    details: {
      uid: uuidv4(),
      email: `reviewer_${timestamp}@example.com`,
      first_name: 'Reviewer',
      last_name: 'User',
      ecdsa_pubkey: reviewerKeys.ecdsa.publicKeyHex,
      rsa_pubkey: reviewerKeys.rsa.publicKeyPem,
      type: 'api',
    },
  })
  const reviewerUserId = reviewerResult.details.uid
  const reviewerDeviceUuid = reviewerResult.details.device_uuid

  await broadcastRequest({
    action: 'edit',
    resource: `/groups/${reviewerGroupId}/users/add`,
    details: { users: [reviewerUserId] },
  })

  // 4. Create policy requiring reviewer approval
  await broadcastRequest({
    action: 'add',
    resource: '/policies',
    details: {
      plid: uuidv4(),
      name: `issuance_policy_${timestamp}`,
      resource: `/wallets/${walletId}/spend-requests`,
      action: 'add',
      priority: 100,
      expression: 'request.details.tx_type == "issue"',
      decision_on_true: 'REVIEW',
      review_expression: `1 OF reviewers_${timestamp}`,
    },
  })

  // ========================================
  // STEP 1: Create Spend Request
  // ========================================

  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,
        reissue_amount: 50000,
        contract: {
          name: `Token_${timestamp}`,
          ticker: 'TKN',
          domain: 'example.com',
          version: 0,
          precision: 8,
        },
      },
    },
  })

  console.log('Spend request status:', issueResult.status) // 'pending'
  const proposalId = issueResult.details.proposal_id
  console.log('Proposal created:', proposalId)

  // ========================================
  // STEP 2: Reviewer Approves Proposal
  // ========================================

  const reviewerInstance = new Blockstream(
    reviewerKeys.rsa.privateKeyPem,
    reviewerKeys.ecdsa.privateKey,
    reviewerDeviceUuid,
    SERVER_RSA_PUBLIC_KEY_PEM,
    SERVER_ECDSA_PUBLIC_KEY,
  )

  const reviewResult = await broadcastRequest(
    {
      action: 'edit',
      resource: `/proposals/${proposalId}/review`,
      details: {
        verdict: 'approved',
        comment: 'Approved for issuance',
      },
    },
    {},
    reviewerInstance,
  )

  console.log('Review submitted:', reviewResult.status)

  // ========================================
  // STEP 3: Wait for Execution
  // ========================================

  // Wait for proposal to execute
  await waitForProposalStatus(proposalId, 'executed')

  // Wait for spend request to process
  await waitForSpendRequestStatus(spendRequestId, 'processed')

  // ========================================
  // STEP 4: Verify Results
  // ========================================

  const finalSpendRequest = await broadcastRequest({
    action: 'get',
    resource: `/spend-requests/${spendRequestId}`,
  })

  const assetId = finalSpendRequest.details.asset_id
  console.log('Asset created:', assetId)
  console.log('Transaction ID:', finalSpendRequest.details.txid)

  // Check wallet balance
  const balances = await broadcastRequest({
    action: 'get',
    resource: `/wallets/${walletId}/balances`,
  })

  console.log('Wallet balances:', balances.details)

  return { walletId, assetId, proposalId }
}

Balance States During Workflow

Understanding how balances change throughout the transaction lifecycle:

StageAvailableLockedUnconfirmed
Before request100,00000
After request (pending)0100,0000
After approval0100,0000
After broadcast98,50001,500
After confirmation100,00000

Note: UTXOs remain locked during the entire approval process to prevent double-spending.


Error Handling

ErrorCauseResolution
status: deniedPolicy rejected the requestCheck policy expressions
status: failedTransaction broadcast failedCheck wallet balance and network
Proposal stuck in pendingMissing approvalsEnsure reviewers are in correct groups
UTXOs still lockedProposal not resolvedCancel or complete the proposal

On this page