Build Escrow dApp
In this section, you’ll build a complete escrow dApp that allows users to:- Claim mock tokens (Mock Coin, Mock TBTC, Mock zSUI)
- View token balances
- Create, accept, and cancel escrow transactions
Overview
The escrow dApp consists of:- Balance Display - Show all token balances
- Faucet Components - Claim mock tokens for testing
- Escrow Manager - Create and manage escrow transactions
1. Display Token Balances
Create a component to display all token balances including native SUI and mock tokens.src/components/Balance.tsx
Copy
import { useSuiClientQuery, useCurrentAccount } from "@mysten/dapp-kit";
import { Card, CardContent } from "./ui/card";
const PACKAGE_ID =
"0xfe02aaaf954b752272ea188d398e36d1d117d3641f4b90d21b2f0df3dfcf18a2";
export default function Balance() {
const account = useCurrentAccount();
// Native SUI balance
const { data: suiData } = useSuiClientQuery(
"getBalance",
{
owner: account?.address as string,
},
{
enabled: !!account,
}
);
// Mock Coin balance
const { data: mockCoinData } = useSuiClientQuery(
"getBalance",
{
owner: account?.address as string,
coinType: `${PACKAGE_ID}::mock_coin::MOCK_COIN`,
},
{
enabled: !!account,
}
);
// Mock TBTC balance
const { data: mockTbtcData } = useSuiClientQuery(
"getBalance",
{
owner: account?.address as string,
coinType: `${PACKAGE_ID}::mock_tbtc::MOCK_TBTC`,
},
{
enabled: !!account,
}
);
// Mock zSUI balance
const { data: mockZsuiData } = useSuiClientQuery(
"getBalance",
{
owner: account?.address as string,
coinType: `${PACKAGE_ID}::mock_zsui::MOCK_ZSUI`,
},
{
enabled: !!account,
}
);
const suiBalance = Number(suiData?.totalBalance ?? 0) / 1_000_000_000;
const mockCoinBalance =
Number(mockCoinData?.totalBalance ?? 0) / 1_000_000_000;
const mockTbtcBalance =
Number(mockTbtcData?.totalBalance ?? 0) / 1_000_000_000;
const mockZsuiBalance =
Number(mockZsuiData?.totalBalance ?? 0) / 1_000_000_000;
return (
<div className="flex gap-4 flex-wrap">
<Card className="w-fit">
<CardContent>
<span>SUI: {suiBalance.toFixed(2)}</span>
</CardContent>
</Card>
<Card className="w-fit">
<CardContent>
<span>Mock Coin: {mockCoinBalance.toFixed(2)}</span>
</CardContent>
</Card>
<Card className="w-fit">
<CardContent>
<span>Mock TBTC: {mockTbtcBalance.toFixed(2)}</span>
</CardContent>
</Card>
<Card className="w-fit">
<CardContent>
<span>Mock zSUI: {mockZsuiBalance.toFixed(2)}</span>
</CardContent>
</Card>
</div>
);
}
- Queries balances for all coin types
- Converts from smallest units (9 decimals) to human-readable format
- Displays all balances in cards
2. Faucet
Create components to claim test tokens. All three mint components follow the same pattern.Faucet Mock Coin
src/components/transaction/Faucet.tsx
Copy
"use client";
import { Transaction } from "@mysten/sui/transactions";
import {
useSignAndExecuteTransaction,
useSuiClient,
useCurrentAccount,
} from "@mysten/dapp-kit";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TransactionDialog } from "@/components/ui/dialog-transaction";
import { useState } from "react";
const PACKAGE_ID =
"0xfe02aaaf954b752272ea188d398e36d1d117d3641f4b90d21b2f0df3dfcf18a2";
const FAUCET_ID =
"0x4f5135f2706e1371adf34002e351c76d9c42d0b3a10c0a5dcc32e0f7605d48b0";
type CoinType = "MOCK_COIN" | "MOCK_TBTC" | "MOCK_ZSUI";
const coinConfigs = {
MOCK_COIN: {
label: "Mock Coin",
typeArg: `${PACKAGE_ID}::mock_coin::MOCK_COIN`,
},
MOCK_TBTC: {
label: "Mock TBTC",
typeArg: `${PACKAGE_ID}::mock_tbtc::MOCK_TBTC`,
},
MOCK_ZSUI: {
label: "Mock zSUI",
typeArg: `${PACKAGE_ID}::mock_zsui::MOCK_ZSUI`,
},
};
function FaucetTab({ coinType }: { coinType: CoinType }) {
const account = useCurrentAccount();
const client = useSuiClient();
const queryClient = useQueryClient();
const { mutate: signAndExecuteTransaction } = useSignAndExecuteTransaction();
const [dialogOpen, setDialogOpen] = useState(false);
const [txDigest, setTxDigest] = useState("");
const config = coinConfigs[coinType];
const handleClaim = () => {
if (!account) return;
const tx = new Transaction();
tx.moveCall({
target: `${PACKAGE_ID}::faucet::claim`,
typeArguments: [config.typeArg],
arguments: [tx.object(FAUCET_ID), tx.object("0x6")],
});
signAndExecuteTransaction(
{
transaction: tx,
},
{
onSuccess: (result) => {
console.log(`${config.label} claim successful!`, result);
setTxDigest(result.digest);
setDialogOpen(true);
client.waitForTransaction({ digest: result.digest });
// Invalidate balance queries to refresh balance display
queryClient.invalidateQueries({ queryKey: ["sui", "getBalance"] });
},
onError: (error) => {
console.error(`${config.label} claim failed:`, error);
},
}
);
};
return (
<>
<TransactionDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
digest={txDigest}
title={`${config.label} Claimed Successfully!`}
description={`You have successfully claimed 10,000 ${config.label}.`}
/>
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
You can claim 10,000 {config.label} every 1 minute.
</div>
<Button onClick={handleClaim} disabled={!account}>
Claim {config.label}
</Button>
</div>
</>
);
}
export default function Faucet() {
return (
<Card className="w-full max-w-2xl">
<CardHeader>
<CardTitle>Faucet</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="mock_coin" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="mock_coin" asChild>
<Button variant="noShadow">Mock Coin</Button>
</TabsTrigger>
<TabsTrigger value="mock_tbtc" asChild>
<Button variant="noShadow">Mock TBTC</Button>
</TabsTrigger>
<TabsTrigger value="mock_zsui" asChild>
<Button variant="noShadow">Mock zSUI</Button>
</TabsTrigger>
</TabsList>
<TabsContent value="mock_coin">
<FaucetTab coinType="MOCK_COIN" />
</TabsContent>
<TabsContent value="mock_tbtc">
<FaucetTab coinType="MOCK_TBTC" />
</TabsContent>
<TabsContent value="mock_zsui">
<FaucetTab coinType="MOCK_ZSUI" />
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}
- Automatically fetches TreasuryCap objects from user’s wallet
- Displays treasury caps in a dropdown
- Accepts human-readable amounts (e.g., “1” = 1 token)
- Converts to smallest units before sending transaction
3. Escrow Manager
Create a unified component to manage all escrow operations with tabs.src/components/transaction/Escrow.tsx
Copy
"use client";
import { Transaction } from "@mysten/sui/transactions";
import {
useSignAndExecuteTransaction,
useSuiClient,
useCurrentAccount,
useSuiClientQuery,
} from "@mysten/dapp-kit";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TransactionDialog } from "@/components/ui/dialog-transaction";
import { useState, useEffect, useMemo } from "react";
const PACKAGE_ID =
"0xfe02aaaf954b752272ea188d398e36d1d117d3641f4b90d21b2f0df3dfcf18a2";
const coinTypes = [
{ value: "0x2::sui::SUI", label: "Sui" },
{ value: `${PACKAGE_ID}::mock_coin::MOCK_COIN`, label: "Mock Coin" },
{ value: `${PACKAGE_ID}::mock_tbtc::MOCK_TBTC`, label: "Mock TBTC" },
{ value: `${PACKAGE_ID}::mock_zsui::MOCK_ZSUI`, label: "Mock zSUI" },
{ value: "custom", label: "Custom Coin Type" },
];
export default function Escrow() {
const account = useCurrentAccount();
const client = useSuiClient();
const queryClient = useQueryClient();
const { mutate: signAndExecuteTransaction } = useSignAndExecuteTransaction();
// Create Escrow State
const [depositCoinId, setDepositCoinId] = useState("");
const [depositCoinType, setDepositCoinType] = useState(
`${PACKAGE_ID}::mock_coin::MOCK_COIN`
);
const [isCustomDeposit, setIsCustomDeposit] = useState(false);
const [paymentCoinType, setPaymentCoinType] = useState(
`${PACKAGE_ID}::mock_zsui::MOCK_ZSUI`
);
const [isCustomPayment, setIsCustomPayment] = useState(false);
const [depositAmount, setDepositAmount] = useState("");
const [requestedAmount, setRequestedAmount] = useState("1");
// Accept Escrow State
const [acceptEscrowId, setAcceptEscrowId] = useState("");
const [acceptPaymentCoinId, setAcceptPaymentCoinId] = useState("");
const [acceptDepositType, setAcceptDepositType] = useState(
"mock_coin::MOCK_COIN"
);
const [acceptPaymentType, setAcceptPaymentType] = useState(
"mock_zsui::MOCK_ZSUI"
);
// Cancel Escrow State
const [cancelEscrowId, setCancelEscrowId] = useState("");
const [cancelDepositType, setCancelDepositType] = useState(
"mock_coin::MOCK_COIN"
);
const [cancelPaymentType, setCancelPaymentType] = useState(
"mock_zsui::MOCK_ZSUI"
);
// Transaction Dialog State
const [dialogOpen, setDialogOpen] = useState(false);
const [txDigest, setTxDigest] = useState("");
const [txTitle, setTxTitle] = useState("");
const [txDescription, setTxDescription] = useState("");
// Fetch deposit coins for create escrow
const { data: depositCoins } = useSuiClientQuery(
"getCoins",
{
owner: account?.address as string,
coinType: depositCoinType,
},
{
enabled: !!account,
}
);
// Fetch payment coins for accept escrow
const { data: paymentCoins } = useSuiClientQuery(
"getCoins",
{
owner: account?.address as string,
coinType: acceptPaymentType,
},
{
enabled: !!account && !!acceptPaymentType,
}
);
// Fetch escrow object details for auto-detection
const { data: escrowObject } = useSuiClientQuery(
"getObject",
{
id: acceptEscrowId,
options: {
showType: true,
showContent: true,
},
},
{
enabled: !!acceptEscrowId,
}
);
// Fetch escrow object for Cancel tab
const { data: cancelEscrowObject } = useSuiClientQuery(
"getObject",
{
id: cancelEscrowId,
options: {
showType: true,
showContent: true,
},
},
{
enabled: !!cancelEscrowId,
}
);
// Auto-detect coin types from escrow object
useEffect(() => {
if (escrowObject?.data?.type) {
const type = escrowObject.data.type;
// Extract type parameters from: 0xPACKAGE::simple_escrow::Escrow<DepositType, PaymentType>
const match = type.match(
/Escrow<(.+)::(\w+)::(\w+),\s*(.+)::(\w+)::(\w+)>/
);
if (match) {
const depositPackage = match[1];
const depositModule = match[2];
const depositStruct = match[3];
const paymentPackage = match[4];
const paymentModule = match[5];
const paymentStruct = match[6];
// Set the coin types
setAcceptDepositType(
`${depositPackage}::${depositModule}::${depositStruct}`
);
setAcceptPaymentType(
`${paymentPackage}::${paymentModule}::${paymentStruct}`
);
}
}
}, [escrowObject]);
// Auto-detect coin types for Cancel tab
useEffect(() => {
if (cancelEscrowObject?.data?.type) {
const type = cancelEscrowObject.data.type;
const match = type.match(
/Escrow<(.+)::(\w+)::(\w+),\s*(.+)::(\w+)::(\w+)>/
);
if (match) {
const depositPackage = match[1];
const depositModule = match[2];
const depositStruct = match[3];
const paymentPackage = match[4];
const paymentModule = match[5];
const paymentStruct = match[6];
setCancelDepositType(
`${depositPackage}::${depositModule}::${depositStruct}`
);
setCancelPaymentType(
`${paymentPackage}::${paymentModule}::${paymentStruct}`
);
}
}
}, [cancelEscrowObject]);
const availableDepositCoins = useMemo(
() => depositCoins?.data || [],
[depositCoins?.data]
);
const availablePaymentCoins = useMemo(
() => paymentCoins?.data || [],
[paymentCoins?.data]
);
// Auto-select payment coin when payment coins are loaded
useEffect(() => {
if (availablePaymentCoins.length > 0 && !acceptPaymentCoinId) {
// Auto-select the first available payment coin
setAcceptPaymentCoinId(availablePaymentCoins[0].coinObjectId);
}
}, [availablePaymentCoins, acceptPaymentCoinId]);
// Ensure deposit and payment coin types are different
useEffect(() => {
if (depositCoinType === paymentCoinType) {
// Find a different coin type
const differentCoin = coinTypes.find((c) => c.value !== depositCoinType);
if (differentCoin) {
setPaymentCoinType(differentCoin.value);
}
}
}, [depositCoinType, paymentCoinType]);
const handleCreate = () => {
if (!account || !depositCoinId) return;
const tx = new Transaction();
const depositType = depositCoinType;
const paymentType = paymentCoinType;
// Convert to smallest units (multiply by 1e9)
const requestAmountInSmallestUnit = BigInt(
Math.floor(Number(requestedAmount) * 1_000_000_000)
);
const depositAmountInSmallestUnit = BigInt(
Math.floor(Number(depositAmount) * 1_000_000_000)
);
const [depositCoin] = tx.splitCoins(tx.object(depositCoinId), [
tx.pure.u64(depositAmountInSmallestUnit),
]);
tx.moveCall({
target: `${PACKAGE_ID}::simple_escrow::create_escrow`,
typeArguments: [depositType, paymentType],
arguments: [depositCoin, tx.pure.u64(requestAmountInSmallestUnit)],
});
signAndExecuteTransaction(
{ transaction: tx },
{
onSuccess: (result) => {
console.log("Escrow created!", result);
setTxDigest(result.digest);
setTxTitle("Escrow Created Successfully!");
setTxDescription(
`Your escrow has been created with ${requestedAmount} ${
paymentCoinType.split("::")[1] || "Payment Coin"
} requested.`
);
setDialogOpen(true);
client.waitForTransaction({ digest: result.digest });
// Invalidate balance and coins queries
queryClient.invalidateQueries({ queryKey: ["sui", "getBalance"] });
queryClient.invalidateQueries({ queryKey: ["sui", "getCoins"] });
},
onError: (error) => console.error("Create failed:", error),
}
);
};
const handleAccept = () => {
if (!account || !acceptEscrowId || !acceptPaymentCoinId) return;
const tx = new Transaction();
const depositType = acceptDepositType;
const paymentType = acceptPaymentType;
if (!escrowObject?.data?.content) return;
const escrowContent = escrowObject.data.content;
if (escrowContent.dataType !== "moveObject") {
console.error("Escrow object is not a move object");
return;
}
const requestedAmount = (escrowContent.fields as Record<string, unknown>)
.requested_amount as string;
const [paymentCoin] = tx.splitCoins(tx.object(acceptPaymentCoinId), [
tx.pure.u64(requestedAmount),
]);
tx.moveCall({
target: `${PACKAGE_ID}::simple_escrow::accept_escrow`,
typeArguments: [depositType, paymentType],
arguments: [tx.object(acceptEscrowId), paymentCoin],
});
signAndExecuteTransaction(
{ transaction: tx },
{
onSuccess: (result) => {
console.log("Escrow accepted!", result);
setTxDigest(result.digest);
setTxTitle("Escrow Accepted Successfully!");
setTxDescription(
"You have accepted the escrow. The deposit has been sent to you and the payment to the seller."
);
setDialogOpen(true);
client.waitForTransaction({ digest: result.digest });
// Invalidate balance and coins queries
queryClient.invalidateQueries({ queryKey: ["sui", "getBalance"] });
queryClient.invalidateQueries({ queryKey: ["sui", "getCoins"] });
// Escrow object is deleted, so we don't need to invalidate it, but we might want to clear the form
setAcceptEscrowId("");
},
onError: (error) => console.error("Accept failed:", error),
}
);
};
const handleCancel = () => {
if (!account || !cancelEscrowId) return;
const tx = new Transaction();
const depositType = cancelDepositType;
const paymentType = cancelPaymentType;
tx.moveCall({
target: `${PACKAGE_ID}::simple_escrow::cancel_escrow`,
typeArguments: [depositType, paymentType],
arguments: [tx.object(cancelEscrowId)],
});
signAndExecuteTransaction(
{ transaction: tx },
{
onSuccess: (result) => {
console.log("Escrow cancelled!", result);
setTxDigest(result.digest);
setTxTitle("Escrow Cancelled Successfully!");
setTxDescription(
"You have cancelled the escrow and received your deposit back."
);
setDialogOpen(true);
client.waitForTransaction({ digest: result.digest });
// Invalidate balance and coins queries
queryClient.invalidateQueries({ queryKey: ["sui", "getBalance"] });
queryClient.invalidateQueries({ queryKey: ["sui", "getCoins"] });
},
onError: (error) => console.error("Cancel failed:", error),
}
);
};
return (
<>
<TransactionDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
digest={txDigest}
title={txTitle}
description={txDescription}
/>
<Card className="w-full max-w-2xl">
<CardHeader>
<CardTitle>Escrow Manager</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="create" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="create" asChild>
<Button variant="noShadow">Create</Button>
</TabsTrigger>
<TabsTrigger value="accept" asChild>
<Button variant="noShadow">Accept</Button>
</TabsTrigger>
<TabsTrigger value="cancel" asChild>
<Button variant="noShadow">Cancel</Button>
</TabsTrigger>
</TabsList>
<TabsContent value="create" className="space-y-4">
<div>
<label className="text-sm font-medium">Deposit Coin Type</label>
<select
className="w-full p-2 border rounded"
value={isCustomDeposit ? "custom" : depositCoinType}
onChange={(e) => {
const value = e.target.value;
if (value === "custom") {
setIsCustomDeposit(true);
setDepositCoinType("");
} else {
setIsCustomDeposit(false);
setDepositCoinType(value);
}
setDepositCoinId(""); // Reset selection when type changes
}}
>
{coinTypes.map((coin) => (
<option key={coin.value} value={coin.value}>
{coin.label}
</option>
))}
</select>
{isCustomDeposit && (
<Input
className="mt-2"
placeholder="Enter custom coin type (e.g. 0x...::module::COIN)"
value={depositCoinType}
onChange={(e) => setDepositCoinType(e.target.value)}
/>
)}
</div>
<div>
<label className="text-sm font-medium">
Select Coin to Deposit
</label>
{availableDepositCoins.length > 0 ? (
<select
className="w-full p-2 border rounded"
value={depositCoinId}
onChange={(e) => {
setDepositCoinId(e.target.value);
const selectedCoin = availableDepositCoins.find(
(c) => c.coinObjectId === e.target.value
);
if (selectedCoin) {
const balance = (
Number(selectedCoin.balance) / 1_000_000_000
).toString();
setDepositAmount(balance);
setRequestedAmount(balance);
}
}}
>
<option value="">Select a coin</option>
{availableDepositCoins.map((coin) => (
<option key={coin.coinObjectId} value={coin.coinObjectId}>
{coin.coinObjectId.slice(0, 6)}...
{coin.coinObjectId.slice(-4)} - {(Number(coin.balance) / 1_000_000_000).toFixed(2)}
</option>
))}
</select>
) : (
<p className="text-sm text-gray-500">
No coins of this type in your wallet
</p>
)}
</div>
<div>
<label className="text-sm font-medium">Deposit Amount</label>
<Input
type="number"
value={depositAmount}
onChange={(e) => {
const val = e.target.value;
setDepositAmount(val);
setRequestedAmount(val);
}}
step="0.000000001"
min="0"
/>
</div>
<div>
<label className="text-sm font-medium">
Payment Coin Type (Requested)
</label>
<select
className="w-full p-2 border rounded"
value={isCustomPayment ? "custom" : paymentCoinType}
onChange={(e) => {
const value = e.target.value;
if (value === "custom") {
setIsCustomPayment(true);
setPaymentCoinType("");
} else {
setIsCustomPayment(false);
setPaymentCoinType(value);
}
}}
>
{coinTypes.map((coin) => (
<option
key={coin.value}
value={coin.value}
disabled={
coin.value !== "custom" &&
coin.value === depositCoinType
}
>
{coin.label}
{coin.value !== "custom" && coin.value === depositCoinType
? " (same as deposit)"
: ""}
</option>
))}
</select>
{isCustomPayment && (
<Input
className="mt-2"
placeholder="Enter custom coin type (e.g. 0x...::module::COIN)"
value={paymentCoinType}
onChange={(e) => setPaymentCoinType(e.target.value)}
/>
)}
{depositCoinType === paymentCoinType && (
<p className="text-sm text-red-500 mt-1">
⚠️ Deposit and payment coin must be different
</p>
)}
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium">
Requested Amount (1:1 Match)
</label>
</div>
<Input
type="number"
placeholder="1"
value={requestedAmount}
readOnly
disabled
className="bg-gray-100 cursor-not-allowed"
step="0.000000001"
min="0"
/>
</div>
<Button
onClick={handleCreate}
disabled={
!account ||
!depositCoinId ||
depositCoinType === paymentCoinType
}
>
Create Escrow
</Button>
</TabsContent>
<TabsContent value="accept" className="space-y-4">
<div>
<label className="text-sm font-medium">Escrow Object ID</label>
<Input
type="text"
placeholder="0x..."
value={acceptEscrowId}
onChange={(e) => setAcceptEscrowId(e.target.value)}
/>
</div>
<div>
<label className="text-sm font-medium">
Deposit Coin Type (What you receive) - Auto-detected
</label>
<Input
type="text"
value={
coinTypes.find((c) => c.value === acceptDepositType)
?.label || acceptDepositType
}
disabled
className="bg-gray-100 cursor-not-allowed"
/>
</div>
<div>
<label className="text-sm font-medium">
Payment Coin Type (What you pay) - Auto-detected
</label>
<Input
type="text"
value={
coinTypes.find((c) => c.value === acceptPaymentType)
?.label || acceptPaymentType
}
disabled
className="bg-gray-100 cursor-not-allowed"
/>
</div>
{escrowObject?.data?.content?.dataType === "moveObject" && (
<div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
<div>
<label className="text-xs font-medium text-gray-500 uppercase">
You Will Receive
</label>
<p className="text-lg font-bold text-green-600">
{(
Number(
(
escrowObject.data.content.fields as Record<
string,
unknown
>
).deposit ||
(
(
escrowObject.data.content.fields as Record<
string,
unknown
>
).deposit as { fields: { value: string } }
)?.fields?.value ||
(
(
escrowObject.data.content.fields as Record<
string,
unknown
>
).deposit as { fields: { balance: string } }
)?.fields?.balance ||
0
) / 1_000_000_000
).toFixed(2)}{" "}
{coinTypes.find((c) => c.value === acceptDepositType)
?.label || "Coins"}
</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase">
You Will Pay
</label>
<p className="text-lg font-bold text-red-600">
{(
Number(
(
escrowObject.data.content.fields as Record<
string,
unknown
>
).requested_amount || 0
) / 1_000_000_000
).toFixed(2)}{" "}
{coinTypes.find((c) => c.value === acceptPaymentType)
?.label || "Coins"}
</p>
</div>
</div>
)}
<div>
<label className="text-sm font-medium">
Selected Payment Coin - Auto-selected
</label>
{availablePaymentCoins.length > 0 ? (
<Input
type="text"
value={
acceptPaymentCoinId
? `${acceptPaymentCoinId.slice(
0,
6
)}...${acceptPaymentCoinId.slice(-4)} - ${(
Number(
availablePaymentCoins.find(
(c) => c.coinObjectId === acceptPaymentCoinId
)?.balance || 0
) / 1_000_000_000
).toFixed(2)}`
: "No coin selected"
}
disabled
className="bg-gray-100 cursor-not-allowed"
/>
) : (
<p className="text-sm text-red-500">
⚠️ No payment coins of this type in your wallet
</p>
)}
</div>
{escrowObject?.data?.content?.dataType === "moveObject" &&
(escrowObject.data.content.fields as Record<string, unknown>)
.creator === account?.address && (
<p className="text-sm text-yellow-600 font-medium mb-2">
⚠️ Warning: You are the creator of this escrow. Accepting it
means swapping with yourself.
</p>
)}
<Button
onClick={handleAccept}
disabled={!account || !acceptEscrowId || !acceptPaymentCoinId}
>
Accept Escrow
</Button>
</TabsContent>
<TabsContent value="cancel" className="space-y-4">
<div>
<label className="text-sm font-medium">Escrow Object ID</label>
<Input
type="text"
placeholder="0x..."
value={cancelEscrowId}
onChange={(e) => setCancelEscrowId(e.target.value)}
/>
</div>
<div>
<label className="text-sm font-medium">
Deposit Coin Type - Auto-detected
</label>
<Input
type="text"
value={
coinTypes.find((c) => c.value === cancelDepositType)
?.label || cancelDepositType
}
disabled
className="bg-gray-100 cursor-not-allowed"
/>
</div>
<div>
<label className="text-sm font-medium">
Payment Coin Type - Auto-detected
</label>
<Input
type="text"
value={
coinTypes.find((c) => c.value === cancelPaymentType)
?.label || cancelPaymentType
}
disabled
className="bg-gray-100 cursor-not-allowed"
/>
</div>
<p className="text-sm text-red-600">
⚠️ Cancel escrow to get your deposit back. Only works if no
buyer has paid yet.
</p>
<Button
onClick={handleCancel}
disabled={!account || !cancelEscrowId}
>
Cancel Escrow
</Button>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</>
);
}
Escrow Flow
1. Setup (Seller)
- Claim tokens using faucet components
- Check balance in Balance component
2. Create Escrow (Seller)
- Go to Create tab
- Select deposit coin type
- Choose specific coin from dropdown
- Select payment coin type to request
- Enter amount to request
- Click “Create Escrow”
3. Accept Escrow (Buyer)
- Get escrow object ID from seller
- Go to Accept tab
- Paste escrow object ID
- Coin types auto-detect
- Payment coin auto-selects
- Verify “You Will Receive” and “You Will Pay” amounts
- Click “Accept Escrow”
Alternative: Cancel Escrow (Seller)
- Go to Cancel tab (only before buyer accepts)
- Paste escrow object ID
- Coin types auto-detect
- Click “Cancel Escrow” to get deposit back
Best Practices
Amount Handling
Always convert amounts to smallest units:Copy
const amountInSmallestUnit = Math.floor(Number(amount) * 1_000_000_000);
Coin Queries
Use specific coin types for accurate queries:Copy
coinType: `${PACKAGE_ID}::mock_coin::MOCK_COIN`;
Auto-Detection
Parse escrow type to extract coin types:Copy
const match = type.match(/Escrow<(.+)::(\w+)::(\w+),\s*(.+)::(\w+)::(\w+)>/);
Error Handling
Always handle success and error callbacks:Copy
{
onSuccess: (result) => console.log("Success!", result),
onError: (error) => console.error("Error:", error),
}
Next Steps
- Add loading states for better UX
- Implement transaction history
- Add escrow listing view
- Create notification system for escrow events
- Add escrow cancellation refund tracking
Congratulations! You’ve built a complete escrow dApp with minting, balance tracking, and full escrow management.