General Concepts
Transaction Lifecycle
How spend requests flow through Blockstream Enterprise Custody - from creation through policy evaluation to execution.
All transactions in Blockstream Enterprise Custody follow a governance-driven workflow. Instead of executing immediately on signing, every spend request passes through the Policy Engine first.
Why governance-driven transactions?
Traditional wallets execute transactions the moment a key signs. Blockstream Enterprise Custody introduces a policy layer between request and execution:
Traditional: User Signs -> Transaction Executes
Enterprise Custody: User Requests -> Policy Evaluates -> [Approval Process] -> Transaction ExecutesThis separation enables:
| Capability | Description |
|---|---|
| Fraud prevention | Large or unusual transactions can require additional approval |
| Compliance | Enforce KYC/AML/travel rule requirements before execution |
| Operational control | Separate requesters from approvers |
| Audit trail | Every transaction has a documented approval chain |
| Risk management | Spending limits, whitelists, and time-based restrictions via policies |
The lifecycle
- Create spend request - Initiate a transaction; UTXOs are locked immediately
- Policy evaluation - The Policy Engine evaluates policies in priority order
- Decision - ALLOW (execute immediately), DENY (reject + unlock), or REVIEW (create proposal)
- Approval process - If REVIEW: designated reviewers approve or reject via proposals
- Execution - Transaction is signed and broadcast once all conditions are met
Spend request states
UTXO locking
When a spend request is created, the system immediately locks the UTXOs (Unspent Transaction Outputs) selected for that transaction. This prevents double-spending during the approval window:
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)
AvailableUTXOs are automatically unlocked if the proposal is rejected or canceled.
Checking status
// Check spend request status
const spendRequest = await broadcastRequest({
action: 'get',
resource: `/spend-requests/${spendRequestId}`,
details: {},
})
console.log(spendRequest.details.status) // pending | processed | failed
// Check proposal status
const proposal = await broadcastRequest({
action: 'get',
resource: `/proposals/${proposalId}`,
details: {},
})
console.log(proposal.details.status) // pending | approved | executed | rejected | denied | canceledSpend 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 |
Polling for completion
async function waitForSpendRequest(
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 || result.details.status === 'failed') {
return result.details
}
await new Promise(resolve => setTimeout(resolve, delayMs))
}
throw new Error('Timeout waiting for spend request')
}