I recently built a simple—but surprisingly deep—escrow application on the Sui blockchain. My goal was to learn Move, master Sui’s object model, and tie it all together with a React + TypeScript frontend. Along the way I hit a bunch of hurdles—some in Move, others in Typescript—and each taught me something new. Here’s the story of how I built my first dApp on sui and what all problems I faced along the way.
Why I Chose Sui & Move
- Everything is an object On Sui, NFTs and coins are actual objects you can move around easily. That means I don’t need to deal with complex smart contract state—just send or modify an object directly.
- No silly bugs with assets Move is strict. You can’t lose or duplicate tokens by mistake. If I try something unsafe with an NFT (like forgetting to return it), it won’t even compile. That’s a huge plus when you’re handling swaps or locks.
- Frontend is super smooth Thanks to @mysten/dapp-kit, I could plug Sui into my React project with minimal effort. I didn’t have to write custom wallet logic or complex RPCs—it all felt like writing regular web code.
- Helpful structure for learning Move forces you to think clearly about ownership, transfers, and safety. At first it was hard, but now I feel like I understand smart contracts better than I ever did with Solidity.
How the Escrow Works (Alice & Bob)
Let’s understand this with Alice and Bob — two NFT collectors who want to trade safely.
🤝Bob locks his NFT
- Bob wants to exchange his NFT, but he doesn’t want to risk it being taken without a fair trade. So, he uses the on-chain
lock()
function to lock his NFT, which generates a unique Key. - This Key is like a secret code that can unlock the NFT later.
- He then shares this Key with Alice (off-chain).
🔒 Why lock?
- Once locked, Bob can’t transfer, sell, or tamper with the NFT.
- If Bob tries to unlock it manually, the Key gets destroyed.
- So if he unlocks it after Alice creates an escrow using the Key, the swap will fail — ensuring trust.
🤝Alice creates the escrow
Alice takes:
- The Key Bob gave her
- One of her own unlocked NFTs
- Bob’s wallet address
She then calls create_escrow()
on-chain, locking in:
- The Key (to Bob’s NFT)
- Her own NFT
- Bob’s address (recipient)
This creates an on-chain escrow object visible to Bob.
🤝Bob accepts or rejects the trade
Bob now sees this escrow in his “Received Escrows”.
He can:
- Call
swap()
to complete the trade — both NFTs are swapped using the smart contract. - Or call
unlock()
to reject it — and retrieve his locked NFT (but the Key changes, so the swap can’t happen).
🧠 Why This Matters
🔐 Tamper-proof:
Because Bob’s NFT is locked, Alice knows it won’t disappear before the trade. And if it does, the swap fails safely.
🛠️ Trustless exchange:
This setup removes the need for a trusted middleman or marketplace. Alice and Bob only rely on the smart contract logic to perform a secure and fair exchange.
No third party. No risks. Just code.
Key Challenges & How I Solved Them
1. Move Option<T>
& option::extract
Symptom: My escrow struct wrapped the NFT in Option. Calling option::extract moves the value out, leaving None. When I later tried to read or re-extract it, the transaction aborted.
Fix: Always guard with assert!(option::is_some(&escrow.escrowed_obj), …) before extracting, and never touch that field again unless you reinitialize or return it.
2. Fetching data fields from the getOwnedObjects()
Symptom: getOwnedObjects() by default only returns { objectId, version, digest }. My .fields calls on obj.data.content failed silently.
Fix: Request full data:
await client.getOwnedObjects({
owner: address,
options: { showType: true, showContent: true }
});
then check:
if(nft.data.content?.dataType==="moveObject"){
const fields = nft.data.content.fields as {
name:string
}
}
3. Listing Shared Escrows via Events
Symptom: Shared escrow objects live under a system “shared” address, not each user’s account, so getOwnedObjects(
) never showed them.
Fix: In create_escrow
, emit a EscrowCreated
event:
event::emit(EscrowCreated { id, sender: ctx.sender(), recipient });
Then on frontend:
const response = await client.queryEvents({
query: {
MoveEventType:"0x31b818703f625a7521c1a09d95f5cecddbaa0fe163bb83fe84d3105d86d14062::escrow::EscrowCreated",
},
limit: 50,
});
const events = response.data;
const objectPromises = events.map((event) => {
const parsed = event.parsedJson as EscrowCreatedEvent;
if (parsed?.id) {
return fetchEscrowObject(parsed.id);
}
return null;
}).filter(Boolean);
Lessons & Takeaways
- Move’s resource model is powerful but has a learning curve—especially around Option and object abilities (copy/drop).
- Always request full RPC data (showType and showContent) before reading Move object fields.
- Events + queryEvents are essential to track shared objects or past actions.
- Clear separation of concerns—hooks for chain logic, context for state, components for UI—makes the app maintainable.
✨ Thanks for reading! Feel free to share or comment if you have questions or want to chat about Sui blockchain or Web3.
👉 Check out the code on GitHub: https://github.com/bhanavigoyal/sui-escrow
Feel free to star, fork, or DM me for collab! 🚀
➡️ I’m exploring Sui development with Move and on-chain assets. If you’re curious or building in Web3, let’s connect!
Here’s something exciting: Dev.to is distributing free tokens to reward our amazing writing community. Visit the claim page here. wallet connection required. – Dev.to Community Support