Escrow - Time and Condition Locks
Learning Objectives
Create time-locked escrows using FinishAfter and CancelAfter
Implement crypto-condition escrows for conditional releases
Finish and cancel escrows appropriately
Design escrow-based applications for scheduled payments and atomic swaps
Understand reserve implications and escrow lifecycle
Sometimes you need XRP to be released only when certain conditions are met:
- Scheduled payments: Release funds on a specific date
- Vesting schedules: Employee compensation over time
- Atomic swaps: Cross-chain exchanges without trust
- Milestone payments: Release when work is verified
On Ethereum, you'd write a smart contract. On XRPL, you use native Escrow—battle-tested, no audit required, minimal fees.
- Creator locks XRP in an escrow
- Escrow specifies conditions for release
- Anyone can trigger release when conditions are met
- Creator can cancel if cancellation window passes
Two time conditions control escrows:
FinishAfter: // Earliest the escrow CAN be released
CancelAfter: // Earliest the escrow CAN be canceled
Timeline examples:
Case 1: Only FinishAfter
|--created--[FinishAfter]--can finish forever-->
Can't be canceled
Case 2: Only CancelAfter
|--created--can finish immediately--[CancelAfter]--can cancel-->
Must be finished before CancelAfter or creator can take it back
Case 3: Both (FinishAfter < CancelAfter)
|--created--[FinishAfter]--can finish--[CancelAfter]--can cancel-->
Window to finish between the two times
```
// src/escrow/create-escrow.js
const xrpl = require('xrpl');
async function createTimeLockedEscrow(
senderWallet,
destination,
amountXRP,
finishAfter, // Date object or null
cancelAfter // Date object or null
) {
if (!finishAfter && !cancelAfter) {
throw new Error('Must specify at least FinishAfter or CancelAfter');
}
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const escrow = {
TransactionType: 'EscrowCreate',
Account: senderWallet.address,
Destination: destination,
Amount: xrpl.xrpToDrops(amountXRP)
};
if (finishAfter) {
escrow.FinishAfter = dateToRippleTime(finishAfter);
}
if (cancelAfter) {
escrow.CancelAfter = dateToRippleTime(cancelAfter);
// CancelAfter must be after FinishAfter if both specified
if (finishAfter && cancelAfter <= finishAfter) {
throw new Error('CancelAfter must be after FinishAfter');
}
}
console.log('Creating escrow:');
console.log( Amount: ${amountXRP} XRP);
console.log( Destination: ${destination});
if (finishAfter) console.log( FinishAfter: ${finishAfter.toISOString()});
if (cancelAfter) console.log( CancelAfter: ${cancelAfter.toISOString()});
const prepared = await client.autofill(escrow);
const signed = senderWallet.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);
if (result.result.meta.TransactionResult === 'tesSUCCESS') {
// Get the escrow sequence for later reference
const escrowSequence = prepared.Sequence;
console.log('✓ Escrow created');
console.log( Escrow sequence: ${escrowSequence});
console.log( Owner: ${senderWallet.address});
return {
success: true,
sequence: escrowSequence,
owner: senderWallet.address,
hash: signed.hash
};
}
return {
success: false,
resultCode: result.result.meta.TransactionResult
};
} finally {
await client.disconnect();
}
}
function dateToRippleTime(date) {
// Ripple epoch: January 1, 2000 (946684800 Unix timestamp)
const rippleEpoch = 946684800;
return Math.floor(date.getTime() / 1000) - rippleEpoch;
}
function rippleTimeToDate(rippleTime) {
const rippleEpoch = 946684800;
return new Date((rippleTime + rippleEpoch) * 1000);
}
module.exports = { createTimeLockedEscrow, dateToRippleTime, rippleTimeToDate };
```
// Create escrow that releases after 30 days
async function create30DayEscrow(sender, recipient, amount) {
const now = new Date();
const thirtyDays = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
const fortyDays = new Date(now.getTime() + 40 * 24 * 60 * 60 * 1000);
return createTimeLockedEscrow(
sender,
recipient,
amount,
thirtyDays, // Can finish after 30 days
fortyDays // Can cancel after 40 days (10 day window)
);
}
```
Crypto-conditions are cryptographic puzzles. To release the escrow, you must provide the solution (fulfillment).
- Condition: SHA-256 hash of some secret
- Fulfillment: The original secret (preimage)
If hash(fulfillment) === condition, escrow releases
```
This enables atomic swaps: Party A creates escrow with condition, Party B can only claim if they know the secret.
// src/escrow/crypto-conditions.js
const crypto = require('crypto');
function generateCondition() {
// Generate random 32-byte preimage
const preimage = crypto.randomBytes(32);
// Create SHA-256 hash
const hash = crypto.createHash('sha256').update(preimage).digest();
// Encode as crypto-condition format
// PREIMAGE-SHA-256 condition format:
// Prefix: A0 25 80 20 [32 bytes hash] 81 01 20
const condition = Buffer.concat([
Buffer.from('A025802020', 'hex'), // Prefix + length markers
hash,
Buffer.from('810120', 'hex') // Suffix (max fulfillment length = 32)
]).toString('hex').toUpperCase();
// Fulfillment format:
// Prefix: A0 22 80 20 [32 bytes preimage]
const fulfillment = Buffer.concat([
Buffer.from('A0228020', 'hex'), // Prefix
preimage
]).toString('hex').toUpperCase();
return {
preimage: preimage.toString('hex'),
condition: condition,
fulfillment: fulfillment
};
}
// Verify a fulfillment matches a condition
function verifyFulfillment(condition, fulfillment) {
// Extract hash from condition (bytes 5-37)
const conditionBuffer = Buffer.from(condition, 'hex');
const expectedHash = conditionBuffer.slice(5, 37);
// Extract preimage from fulfillment (bytes 4-36)
const fulfillmentBuffer = Buffer.from(fulfillment, 'hex');
const preimage = fulfillmentBuffer.slice(4, 36);
// Compute hash of preimage
const actualHash = crypto.createHash('sha256').update(preimage).digest();
return expectedHash.equals(actualHash);
}
module.exports = { generateCondition, verifyFulfillment };
```
// src/escrow/conditional-escrow.js
const xrpl = require('xrpl');
const { generateCondition, dateToRippleTime } = require('./crypto-conditions');
async function createConditionalEscrow(
senderWallet,
destination,
amountXRP,
condition, // Hex-encoded crypto-condition
cancelAfter // When it can be canceled if not claimed
) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const escrow = {
TransactionType: 'EscrowCreate',
Account: senderWallet.address,
Destination: destination,
Amount: xrpl.xrpToDrops(amountXRP),
Condition: condition
};
if (cancelAfter) {
escrow.CancelAfter = dateToRippleTime(cancelAfter);
}
console.log('Creating conditional escrow:');
console.log( Amount: ${amountXRP} XRP);
console.log( Condition: ${condition.substring(0, 20)}...);
const prepared = await client.autofill(escrow);
const signed = senderWallet.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);
if (result.result.meta.TransactionResult === 'tesSUCCESS') {
return {
success: true,
sequence: prepared.Sequence,
owner: senderWallet.address,
hash: signed.hash
};
}
return {
success: false,
resultCode: result.result.meta.TransactionResult
};
} finally {
await client.disconnect();
}
}
// Complete example: Create escrow that requires secret to claim
async function createSecretEscrow(sender, recipient, amount) {
// Generate condition and fulfillment
const { condition, fulfillment, preimage } = generateCondition();
// Create escrow with 24-hour cancel window
const cancelTime = new Date(Date.now() + 24 * 60 * 60 * 1000);
const result = await createConditionalEscrow(
sender,
recipient,
amount,
condition,
cancelTime
);
if (result.success) {
console.log('\n*** SAVE THIS INFORMATION ***');
console.log(Fulfillment (needed to claim): ${fulfillment});
console.log(Preimage: ${preimage});
console.log('Give the fulfillment to the recipient to claim the escrow');
}
return {
...result,
fulfillment,
preimage
};
}
module.exports = { createConditionalEscrow, createSecretEscrow };
```
// src/escrow/finish-escrow.js
const xrpl = require('xrpl');
async function finishEscrow(
finisherWallet, // Anyone can finish if conditions met
escrowOwner, // Original creator's address
escrowSequence, // Sequence number of EscrowCreate
fulfillment = null // Required if escrow has Condition
) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const finish = {
TransactionType: 'EscrowFinish',
Account: finisherWallet.address,
Owner: escrowOwner,
OfferSequence: escrowSequence
};
if (fulfillment) {
finish.Condition = await getEscrowCondition(client, escrowOwner, escrowSequence);
finish.Fulfillment = fulfillment;
}
console.log('Finishing escrow:');
console.log( Owner: ${escrowOwner});
console.log( Sequence: ${escrowSequence});
const prepared = await client.autofill(finish);
const signed = finisherWallet.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);
if (result.result.meta.TransactionResult === 'tesSUCCESS') {
// Find the delivered amount
const delivered = findDeliveredAmount(result.result.meta);
console.log('✓ Escrow finished successfully');
console.log( Delivered: ${xrpl.dropsToXrp(delivered)} XRP);
return {
success: true,
delivered,
hash: signed.hash
};
}
console.log('✗ Escrow finish failed:', result.result.meta.TransactionResult);
return {
success: false,
resultCode: result.result.meta.TransactionResult
};
} finally {
await client.disconnect();
}
}
async function getEscrowCondition(client, owner, sequence) {
const escrows = await client.request({
command: 'account_objects',
account: owner,
type: 'escrow'
});
const escrow = escrows.result.account_objects.find(
e => e.Sequence === sequence
);
return escrow?.Condition;
}
function findDeliveredAmount(meta) {
for (const node of meta.AffectedNodes) {
if (node.DeletedNode?.LedgerEntryType === 'Escrow') {
return node.DeletedNode.FinalFields.Amount;
}
}
return null;
}
module.exports = { finishEscrow };
```
Finishing conditional escrows costs more than regular transactions:
// Conditional escrow finish fee calculation
function estimateFinishFee(fulfillmentLength) {
// Base fee: 10 drops
// Additional: 320 drops per 16 bytes of fulfillment/condition
const baseFee = 10;
const additionalFee = Math.ceil(fulfillmentLength / 16) * 320;
return baseFee + additionalFee;
}
// For a 32-byte PREIMAGE-SHA-256:
// Fulfillment is ~36 bytes encoded
// Fee: 10 + (3 * 320) = 970 drops = ~0.00097 XRP
// src/escrow/cancel-escrow.js
const xrpl = require('xrpl');
async function cancelEscrow(
cancellerWallet, // Anyone can cancel if CancelAfter passed
escrowOwner,
escrowSequence
) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
// First verify escrow exists and is cancellable
const escrowInfo = await getEscrowInfo(client, escrowOwner, escrowSequence);
if (!escrowInfo) {
return { success: false, error: 'Escrow not found' };
}
if (escrowInfo.CancelAfter) {
const cancelTime = rippleTimeToDate(escrowInfo.CancelAfter);
if (new Date() < cancelTime) {
return {
success: false,
error: Cannot cancel until ${cancelTime.toISOString()}
};
}
} else {
return {
success: false,
error: 'Escrow has no CancelAfter - cannot be canceled'
};
}
const cancel = {
TransactionType: 'EscrowCancel',
Account: cancellerWallet.address,
Owner: escrowOwner,
OfferSequence: escrowSequence
};
console.log('Canceling escrow...');
const prepared = await client.autofill(cancel);
const signed = cancellerWallet.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);
if (result.result.meta.TransactionResult === 'tesSUCCESS') {
console.log('✓ Escrow canceled - funds returned to owner');
return { success: true, hash: signed.hash };
}
return {
success: false,
resultCode: result.result.meta.TransactionResult
};
} finally {
await client.disconnect();
}
}
async function getEscrowInfo(client, owner, sequence) {
const escrows = await client.request({
command: 'account_objects',
account: owner,
type: 'escrow'
});
return escrows.result.account_objects.find(
e => e.Sequence === sequence
);
}
function rippleTimeToDate(rippleTime) {
return new Date((rippleTime + 946684800) * 1000);
}
module.exports = { cancelEscrow };
```
// src/escrow/query-escrows.js
const xrpl = require('xrpl');
async function getAccountEscrows(address) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const response = await client.request({
command: 'account_objects',
account: address,
type: 'escrow'
});
return response.result.account_objects.map(escrow => ({
sequence: escrow.Sequence,
owner: escrow.Account,
destination: escrow.Destination,
amount: Number(escrow.Amount) / 1_000_000,
finishAfter: escrow.FinishAfter
? rippleTimeToDate(escrow.FinishAfter)
: null,
cancelAfter: escrow.CancelAfter
? rippleTimeToDate(escrow.CancelAfter)
: null,
condition: escrow.Condition || null,
status: getEscrowStatus(escrow)
}));
} finally {
await client.disconnect();
}
}
function getEscrowStatus(escrow) {
const now = Date.now() / 1000 + 946684800; // Current Ripple time
if (escrow.FinishAfter && now < escrow.FinishAfter) {
return 'pending'; // Can't finish yet
}
if (escrow.CancelAfter && now >= escrow.CancelAfter) {
return 'cancellable'; // Can be canceled
}
if (!escrow.FinishAfter || now >= escrow.FinishAfter) {
return 'ready'; // Can be finished (if fulfillment provided for conditional)
}
return 'unknown';
}
function rippleTimeToDate(rippleTime) {
return new Date((rippleTime + 946684800) * 1000);
}
module.exports = { getAccountEscrows };
```
async function displayEscrowDashboard(address) {
const escrows = await getAccountEscrows(address);
console.log(\n=== Escrows for ${address} ===\n);
if (escrows.length === 0) {
console.log('No active escrows');
return;
}
for (const escrow of escrows) {
console.log(Escrow #${escrow.sequence}:);
console.log( Amount: ${escrow.amount} XRP);
console.log( Destination: ${escrow.destination});
console.log( Status: ${escrow.status});
if (escrow.finishAfter) {
console.log( Finish after: ${escrow.finishAfter.toISOString()});
}
if (escrow.cancelAfter) {
console.log( Cancel after: ${escrow.cancelAfter.toISOString()});
}
if (escrow.condition) {
console.log( Has condition: Yes (need fulfillment));
}
console.log('');
}
}
```
// src/escrow/atomic-swap.js
/*
Atomic Swap between Alice (XRPL) and Bob (other chain):
- Alice and Bob agree on trade: 100 XRP for 0.01 BTC
- Alice generates secret and condition
- Alice creates escrow on XRPL with condition
- Bob sees Alice's escrow, creates equivalent on Bitcoin with same condition hash
- Alice claims Bob's Bitcoin using her secret (reveals fulfillment)
- Bob uses revealed fulfillment to claim Alice's XRP escrow
- Trade complete - no trust required!
If either fails to act in time, both escrows cancel.
*/
async function initiateAtomicSwap(
aliceWallet,
bobXRPLAddress,
xrpAmount,
timeoutHours = 24
) {
const { generateCondition } = require('./crypto-conditions');
const { createConditionalEscrow } = require('./conditional-escrow');
// Step 1: Generate the condition
const { condition, fulfillment, preimage } = generateCondition();
// Step 2: Create escrow with condition
const cancelTime = new Date(Date.now() + timeoutHours * 60 * 60 * 1000);
const result = await createConditionalEscrow(
aliceWallet,
bobXRPLAddress,
xrpAmount,
condition,
cancelTime
);
if (result.success) {
console.log('\n=== ATOMIC SWAP INITIATED ===');
console.log('Give Bob this information:');
console.log( XRPL Escrow Owner: ${aliceWallet.address});
console.log( XRPL Escrow Sequence: ${result.sequence});
console.log( Condition hash: ${condition});
console.log( Cancel time: ${cancelTime.toISOString()});
console.log('\nKeep this secret (use to claim Bob's side):');
console.log( Fulfillment: ${fulfillment});
console.log( Preimage: ${preimage});
return {
...result,
condition,
fulfillment,
preimage,
cancelTime
};
}
return result;
}
async function completeAtomicSwap(
bobWallet,
aliceAddress,
escrowSequence,
fulfillment
) {
const { finishEscrow } = require('./finish-escrow');
// Bob finishes the escrow using the fulfillment Alice revealed
return finishEscrow(bobWallet, aliceAddress, escrowSequence, fulfillment);
}
---
Each escrow increases the owner's reserve:
// Escrow reserve impact
const ownerReservePerEscrow = 2; // XRP
// Example: Account creates 5 escrows of 100 XRP each
// Locked in escrows: 500 XRP
// Additional reserve: 10 XRP
// Total impact: 510 XRP unavailable
function calculateEscrowImpact(escrows) {
const totalLocked = escrows.reduce((sum, e) => sum + e.amount, 0);
const additionalReserve = escrows.length * 2;
return {
locked: totalLocked,
reserve: additionalReserve,
total: totalLocked + additionalReserve
};
}
EscrowCreate
|
v
+------------------[ACTIVE]------------------+
| | |
| time < FinishAfter time >= FinishAfter
| (wait) (can finish) |
| | |
| v |
| EscrowFinish --------> [COMPLETED]
| (with fulfillment |
| if conditional) |
| |
| time >= CancelAfter |
| (can cancel) |
| | |
v v |
[EXPIRED] <-- EscrowCancel |
| | |
v v |
Funds returned to owner Funds sent to destinationXRPL escrows are a powerful primitive for programmable payments. Time-based escrows are straightforward and reliable. Crypto-condition escrows enable advanced patterns like atomic swaps but require careful handling of secrets. Always set appropriate CancelAfter values as a safety net.
Assignment: Build a complete system for scheduled XRP payments using escrows.
Requirements:
Create time-locked escrows with configurable dates
Create conditional escrows with generated secrets
Validate inputs (dates, amounts, addresses)
Display escrow details after creation
List all escrows for an account
Show status (pending/ready/cancellable)
Calculate time until finish/cancel possible
Track escrow reserves
Finish escrows when conditions met
Cancel escrows after deadline
Handle conditional escrows with fulfillment
Verify completion
Create multiple scheduled payments (e.g., monthly)
Dashboard showing upcoming releases
Automated finish when time arrives
Error handling and recovery
Escrow creation works correctly (25%)
Management shows accurate status (25%)
Resolution handles all cases (25%)
Application is practical and complete (25%)
Time investment: 3 hours
Value: Foundation for any time-locked or conditional payment application
Knowledge Check
Question 1 of 2Bob creates an escrow sending XRP to Carol with FinishAfter in the past (already passed). Who can finish the escrow?
- Escrow: https://xrpl.org/escrow.html
- EscrowCreate: https://xrpl.org/escrowcreate.html
- EscrowFinish: https://xrpl.org/escrowfinish.html
- EscrowCancel: https://xrpl.org/escrowcancel.html
- Crypto-Conditions: https://xrpl.org/crypto-conditions.html
- Ripple's XRP escrow: https://xrpl.org/blog/2017/ripple-escrows-55-billion-xrp-for-supply-predictability.html
- Atomic swaps: Various technical articles
For Next Lesson:
You now understand escrow for time-locked payments. Lesson 11 covers Payment Channels—for high-frequency, low-cost streaming payments.
End of Lesson 10
Total words: ~4,900
Estimated completion time: 50 minutes reading + 3 hours for deliverable
Key Takeaways
FinishAfter is when, CancelAfter is deadline
: FinishAfter prevents early release; CancelAfter provides a safety net for returning funds.
Always set CancelAfter for conditional escrows
: Without it, a lost fulfillment means funds locked forever.
Anyone can finish/cancel
: Transactions are permissionless once conditions are met. This is by design.
Crypto-conditions enable trustless trades
: The secret/hash pattern allows atomic swaps without intermediaries.
Reserve costs add up
: Each escrow costs 2 XRP reserve plus the locked amount. Plan for this. ---