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
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
| Field | Type | Description |
|---|---|---|
plid | UUID | Unique policy identifier |
name | String | Human-readable name |
resource | String | Resource path to match (supports wildcards) |
action | String | Action to match (add, edit, delete) |
priority | Number | Evaluation order (higher = first) |
expression | String | Condition that triggers the policy |
decision_on_true | String | ALLOW, DENY, or REVIEW |
review_expression | String | Approval 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
| Variable | Type | Description |
|---|---|---|
request | Object | The full request object with action, resource, details |
action | String | The action being performed (e.g., add, edit, delete) |
resource | String | The resource path (e.g., /wallets/uuid/spend-requests) |
caller | Object | Information about the user making the request |
Caller Object
| Property/Method | Description | Example |
|---|---|---|
caller.uid | User's unique identifier | caller.uid == 'user-uuid' |
caller.email | User's email address | caller.email.endsWith('@company.com') |
caller.status | User's account status | caller.status == 'active' |
caller.firstName | User's first name | |
caller.lastName | User's last name | |
caller.can(action, resource) | Check if user has RBAC permission | caller.can('approve', '/proposals') |
caller.hasRole(role) | Check if user has a specific role | caller.hasRole('super-admin') |
Helper Functions
| Function | Description | Example |
|---|---|---|
isEmail(str) | Validate email format | isEmail(request.details.email) |
matchesPattern(str, pattern) | Regex pattern matching | matchesPattern(address, '^bc1.*') |
inRange(num, min, max) | Check if number is within range | inRange(amount, 0, 100000) |
User & Group Functions
| Function | Description | Example |
|---|---|---|
getUserGroups() | Get current user's group IDs | getUserGroups().includes('group-uuid') |
isInitiatedByUsers([userIds]) | Check if caller is in user list | isInitiatedByUsers(['user-uuid-1', 'user-uuid-2']) |
isInitiatedByGroups([groupIds]) | Check if caller belongs to any group | isInitiatedByGroups(['treasury-team-uuid']) |
Account/Wallet Functions
| Function | Description | Example |
|---|---|---|
isFromAccounts([walletIds]) | Check if request is from specified wallets | isFromAccounts(['wallet-uuid']) |
isToAccounts([walletIds]) | Check if recipients include specified wallets | isToAccounts(['approved-wallet-uuid']) |
getWalletRecipientGroups(walletId) | Get recipient groups for a wallet | getWalletRecipientGroups(request.details.wallet_id) |
Recipient & Whitelist Functions
| Function | Description | Example |
|---|---|---|
isToRecipientInWhitelists([whitelistIds]) | Check if all recipients are in whitelists | isToRecipientInWhitelists(['whitelist-uuid']) |
isToRecipientInBlacklists([blacklistIds]) | Check if any recipient is in blacklists | isToRecipientInBlacklists(['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 addresses | getRecipientGroupsByValues(['wallet-id']) |
getRecipientWhitelistsByValues([values]) | Get whitelists by wallet IDs or addresses | getRecipientWhitelistsByValues([recipientValue]) |
getRecipientBlacklistsByValues([values]) | Get blacklists by wallet IDs or addresses | getRecipientBlacklistsByValues([recipientValue]) |
Security Limits
Policy expressions run in a sandboxed environment with strict limits:
| Limit | Value | Description |
|---|---|---|
| Execution timeout | 100ms | Maximum execution time |
| Memory limit | 10MB | Maximum heap size |
| Regex pattern length | 1000 chars | Maximum 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
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 removedCanceling 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:
| Stage | Available | Locked | Unconfirmed |
|---|---|---|---|
| Before request | 100,000 | 0 | 0 |
| After request (pending) | 0 | 100,000 | 0 |
| After approval | 0 | 100,000 | 0 |
| After broadcast | 98,500 | 0 | 1,500 |
| After confirmation | 100,000 | 0 | 0 |
Note: UTXOs remain locked during the entire approval process to prevent double-spending.
Error Handling
| Error | Cause | Resolution |
|---|---|---|
status: denied | Policy rejected the request | Check policy expressions |
status: failed | Transaction broadcast failed | Check wallet balance and network |
Proposal stuck in pending | Missing approvals | Ensure reviewers are in correct groups |
| UTXOs still locked | Proposal not resolved | Cancel or complete the proposal |
Related Documentation
- API User Onboarding - User authentication setup
- Wallet Onboarding - Wallets setup
- Batch Requests - Batch API operations
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.
Microsoft Entra ID Setup Guide
This guide covers the complete setup of Microsoft Entra ID as an identity provider for the Custody Engine.