Escrow - Time and Condition Locks | XRPL Development 101 | XRP Academy - XRP Academy
3 free lessons remaining this month

Free preview access resets monthly

Upgrade for Unlimited
Skip to main content
intermediate50 min

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.

  1. Creator locks XRP in an escrow
  2. Escrow specifies conditions for release
  3. Anyone can trigger release when conditions are met
  4. 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):

  1. Alice and Bob agree on trade: 100 XRP for 0.01 BTC
  2. Alice generates secret and condition
  3. Alice creates escrow on XRPL with condition
  4. Bob sees Alice's escrow, creates equivalent on Bitcoin with same condition hash
  5. Alice claims Bob's Bitcoin using her secret (reveals fulfillment)
  6. Bob uses revealed fulfillment to claim Alice's XRP escrow
  7. 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 destination

XRPL 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 2

Bob creates an escrow sending XRP to Carol with FinishAfter in the past (already passed). Who can finish the escrow?

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

1

FinishAfter is when, CancelAfter is deadline

: FinishAfter prevents early release; CancelAfter provides a safety net for returning funds.

2

Always set CancelAfter for conditional escrows

: Without it, a lost fulfillment means funds locked forever.

3

Anyone can finish/cancel

: Transactions are permissionless once conditions are met. This is by design.

4

Crypto-conditions enable trustless trades

: The secret/hash pattern allows atomic swaps without intermediaries.

5

Reserve costs add up

: Each escrow costs 2 XRP reserve plus the locked amount. Plan for this. ---