Lock funds with time-based and cryptographic conditions. Create, finish, and cancel escrows. Understand smart-contract-like functionality native to XRPL.
What you'll learn
You already know how to send XRP from one account to another instantly. An escrow is what you reach for when "instantly" is the wrong answer — when value should move only after a deadline passes, or only if someone produces a secret. Think of it as a programmable lockbox baked directly into the ledger: you deposit XRP, attach the conditions under which it may be released, and the network itself — not a third party, not a server you have to keep running — enforces the rules.
That's the closest thing the XRP Ledger has to a "smart contract" for payments, and it covers a surprising amount of ground: a freelancer milestone that unlocks on a date, a hashed-timelock leg of a cross-chain swap, a deposit that returns to the sender if the counterparty never shows. No Solidity, no gas, no deployment — just three transaction types.
In this module you'll write a single TypeScript file that creates a time-based escrow and releases it once its delay elapses, then creates a crypto-conditional escrow locked behind a secret and finishes it by revealing that secret. Everything runs on the testnet, so you can lock and unlock play-XRP as many times as you like. If you haven't done the Payments module yet, do it first — we build directly on the connect / fund / submit-and-wait pattern from there.
An escrow's whole lifecycle is just three transaction types:
EscrowCreate — locks XRP out of the sender's spendable balance and writes an escrow object into the ledger. You set the Amount, the Destination who'll receive it, and the conditions.EscrowFinish — releases the locked XRP to the destination, if the conditions are satisfied.EscrowCancel — returns the locked XRP to the sender, if the escrow has expired unfinished.The subtle part is that EscrowFinish and EscrowCancel don't reference the escrow by some ID. They reference it by the pair Owner (the account that created it) plus OfferSequence (the Sequence number the EscrowCreate transaction had). Hold on to that sequence number when you create an escrow — without it you can't finish or cancel later.
One more thing worth saying up front, because it surprises people: anyone can submit the EscrowFinish or EscrowCancel. The submitter just pays the fee; the funds always go where the escrow says — to the Destination on finish, back to the Owner on cancel. So a destination can claim their own escrow, but so can a watcher bot you run. The ledger only cares that the conditions are met, not who asks.
When you create an escrow you attach at least one of two gates:
FinishAfter — a release time. The escrow cannot be finished until this moment passes. This is your "time lock."Condition — a cryptographic puzzle (a PREIMAGE-SHA-256 condition). The escrow can only be finished by whoever submits the matching Fulfillment — the secret preimage. This is your "hash lock."You can use either, or both together (finish requires both the time to pass and the secret to be revealed). There's also an optional CancelAfter: an expiry time after which the escrow can no longer be finished at all and is only good for cancelling back to the sender. A purely time-based escrow needs a FinishAfter; a conditional one needs a Condition. If you set both FinishAfter and CancelAfter, the cancel time must be later than the finish time — otherwise the escrow would expire before it could ever be claimed.
Same dependencies as before, plus one library for crypto-conditions:
npm i xrpl five-bells-condition
npm i -D typescript tsx @types/node
Open a connection and fund two wallets exactly as in the Payments module:
import * as xrpl from 'xrpl'
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233')
async function main() {
await client.connect()
const { wallet: sender } = await client.fundWallet()
const { wallet: receiver } = await client.fundWallet()
// ... escrow work goes here ...
await client.disconnect()
}
main()
Now the single most common mistake with escrows: time. XRPL timestamps are not Unix time. They count seconds from the Ripple epoch — midnight UTC on January 1st, 2000 — which is 946,684,800 seconds after the Unix epoch. Pass a raw Date.now() value and your "10 second" escrow lands roughly 30 years in the future. xrpl.js gives you helpers so you never do that arithmetic by hand:
// "release 10 seconds from now", expressed in Ripple time
const finishAfter = xrpl.isoTimeToRippleTime(
new Date(Date.now() + 10_000).toISOString(),
)
Let's lock 50 XRP that the receiver can claim after a short delay. Notice we capture the Sequence from the created transaction — that becomes the OfferSequence we need to finish it.
const createTx: xrpl.EscrowCreate = {
TransactionType: 'EscrowCreate',
Account: sender.classicAddress,
Destination: receiver.classicAddress,
Amount: xrpl.xrpToDrops('50'),
FinishAfter: xrpl.isoTimeToRippleTime(
new Date(Date.now() + 10_000).toISOString(),
),
}
const created = await client.submitAndWait(createTx, {
autofill: true,
wallet: sender,
})
if (created.result.meta?.TransactionResult !== 'tesSUCCESS') {
throw new Error(`Create failed: ${created.result.meta?.TransactionResult}`)
}
// The sequence of THIS transaction is the OfferSequence we'll finish with.
const offerSequence = created.result.tx_json.Sequence
console.log('Escrow created, OfferSequence =', offerSequence)
The moment this validates, 50 XRP plus the standard reserve for one ledger object leaves the sender's spendable balance. The XRP isn't gone — it's parked in an escrow object owned by the sender — but it can't be spent until the escrow is finished or cancelled.
Wait for the delay to elapse, then submit an EscrowFinish pointing at the Owner + OfferSequence pair. For a purely time-based escrow there's no secret to reveal — you just have to be past FinishAfter:
// Wait out the FinishAfter delay (plus a little slack for ledger close time).
await new Promise((r) => setTimeout(r, 12_000))
const finishTx: xrpl.EscrowFinish = {
TransactionType: 'EscrowFinish',
Account: receiver.classicAddress, // anyone can submit; here the receiver does
Owner: sender.classicAddress,
OfferSequence: offerSequence,
}
const finished = await client.submitAndWait(finishTx, {
autofill: true,
wallet: receiver,
})
console.log('Finish result:', finished.result.meta?.TransactionResult)
console.log('Receiver balance:', await client.getXrpBalance(receiver.address))
If you submit before FinishAfter you'll get tecNO_PERMISSION rather than success — the ledger is refusing because the gate hasn't opened yet. Once it succeeds, the 50 XRP lands in the receiver's balance and the escrow object disappears from the ledger.
Time locks are useful, but the real power shows up when release depends on a secret instead of (or alongside) a clock. A PREIMAGE-SHA-256 condition works like this: you generate 32 random bytes (the preimage), hash them into a condition, and lock the escrow with that condition. The escrow can only be finished by someone who presents the original preimage — the fulfillment. Reveal the secret, unlock the funds. This is the building block behind hashed-timelock contracts and atomic cross-chain swaps.
import * as cc from 'five-bells-condition'
import * as crypto from 'crypto'
const preimage = crypto.randomBytes(32)
const fulfillmentObj = new cc.PreimageSha256()
fulfillmentObj.setPreimage(preimage)
// The Condition goes ON the ledger (public). Keep the Fulfillment SECRET.
const condition = fulfillmentObj
.getConditionBinary()
.toString('hex')
.toUpperCase()
const fulfillment = fulfillmentObj
.serializeBinary()
.toString('hex')
.toUpperCase()
Create the escrow with the Condition. A condition-only escrow can be finished as soon as it exists, but you almost always pair it with a CancelAfter so the sender can reclaim funds if the secret is never revealed:
const condCreate: xrpl.EscrowCreate = {
TransactionType: 'EscrowCreate',
Account: sender.classicAddress,
Destination: receiver.classicAddress,
Amount: xrpl.xrpToDrops('20'),
Condition: condition,
CancelAfter: xrpl.isoTimeToRippleTime(
new Date(Date.now() + 3600_000).toISOString(), // 1 hour to reclaim
),
}
const condCreated = await client.submitAndWait(condCreate, {
autofill: true,
wallet: sender,
})
const condSequence = condCreated.result.tx_json.Sequence
To finish it, supply both the Condition and the Fulfillment:
const condFinish: xrpl.EscrowFinish = {
TransactionType: 'EscrowFinish',
Account: receiver.classicAddress,
Owner: sender.classicAddress,
OfferSequence: condSequence,
Condition: condition,
Fulfillment: fulfillment,
}
const condFinished = await client.submitAndWait(condFinish, {
autofill: true,
wallet: receiver,
})
console.log('Conditional finish:', condFinished.result.meta?.TransactionResult)
One practical detail: finishing a conditional escrow costs more than the base fee. The network charges a surcharge that scales with the size of the fulfillment, to compensate validators for verifying it. With autofill: true, xrpl.js computes the higher fee for you — but don't be surprised when an EscrowFinish that carries a fulfillment costs noticeably more than a plain payment.
If an escrow is never finished and its CancelAfter has passed, the locked XRP isn't stuck forever — anyone can submit an EscrowCancel to return it to the sender:
const cancelTx: xrpl.EscrowCancel = {
TransactionType: 'EscrowCancel',
Account: sender.classicAddress,
Owner: sender.classicAddress,
OfferSequence: condSequence,
}
// Only succeeds once CancelAfter has passed; otherwise tecNO_PERMISSION.
This is the safety valve that makes escrows usable for real agreements: the sender's money is never permanently trapped by a counterparty who simply walks away. Time-based releases, secret-gated releases, and guaranteed refunds — three transaction types, all enforced by the ledger itself.
You're done when a single script, in one run, creates a time-based escrow (50 XRP, short FinishAfter), waits, finishes it with EscrowFinish, and prints the receiver's balance going up — then creates a crypto-conditional escrow, finishes it by revealing the fulfillment, and confirms both finishes return tesSUCCESS. Remember the Owner + OfferSequence pair is how every finish/cancel finds its escrow. Capture the create and finish transaction hashes, confirm them on the Testnet Explorer, and submit a repo or gist containing your escrow.ts (plus the hashes) for a peer to verify.
Assignments
0 of 3 complete