Implement the fund locking functionality to allow users to securely lock their ADA in an escrow contract.
Introduction
In this part, we'll implement the fund locking functionality, which allows users to securely lock their ADA in an escrow contract. This involves creating a transaction that transfers funds to a script address with specific conditions for unlocking.
Understanding the Fund Locking Process
The fund locking process involves these steps:
User Input: The user selects an amount of ADA to lock.
Transaction Building: The application calls the Anvil API to build a transaction that sends funds to the escrow script address with the user's key hash as a datum.
Transaction Signing: The connected wallet signs the transaction, authorizing the fund transfer.
Transaction Submission: The signed transaction is submitted to the Cardano blockchain.
Status Tracking: The transaction hash is displayed to the user. In Part 4, we'll implement proper status tracking.
Smart Contract Datum Structure
When locking funds in the escrow address, we attach a datum containing the owner's verification key hash. This datum is essential for two reasons:
The Hello World smart contract uses it to validate unlock requests
It associates locked funds with their rightful owner
When building transaction outputs, we include this datum with the escrow script address, ensuring only the original owner can later unlock these funds with the correct signature.
datum: {
type: "inline",
value: {
owner: paymentKeyHash // The owner's key hash who can later unlock
},
shape: {
validatorHash: validatorHash,
purpose: "spend"
}
}
This datum associates the locked funds with the original owner, ensuring only they can unlock these funds later when providing the correct redeemer message ("Hello, World!") and their signature.
Setup React Query
Before we integrate with the Anvil API, let's first set up React Query which we'll use for managing transaction state src/components/ReactQueryProvider.tsx:
1. Create React Query Provider
// src/components/ReactQueryProvider.tsx
"use client";
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
const queryClient = new QueryClient();
export default function ReactQueryProvider({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
2. Update Root Layout
Update your src/app/layout.tsx component to include the React Query provider:
You need to add the import and wrap the application with the ReactQueryProvider provider.
RootLayout Component `src/app/layout.tsx`
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ClientWeldProvider } from "@/components/WeldProvider";
import ReactQueryProvider from '@/components/ReactQueryProvider';
import { cookies } from "next/headers";
import { STORAGE_KEYS } from "@ada-anvil/weld/server";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Cardano Smart Escrow",
description: "Smart escrow solution for Cardano blockchain",
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
// Retrieve wallet connection state from cookies for SSR
const cookieStore = await cookies();
const wallet = cookieStore.get(STORAGE_KEYS.connectedWallet)?.value;
const changeAddress = cookieStore.get(STORAGE_KEYS.connectedChange)?.value;
const stakeAddress = cookieStore.get(STORAGE_KEYS.connectedStake)?.value;
const lastConnectedWallet = wallet ? { wallet, changeAddress, stakeAddress } : undefined;
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased bg-white`}>
<ReactQueryProvider>
{/* Wrap the application with the ClientWeldProvider for wallet connectivity */}
<ClientWeldProvider lastConnectedWallet={lastConnectedWallet}>
{children}
</ClientWeldProvider>
</ReactQueryProvider>
</body>
</html>
);
}
Adding Anvil API Integration
We'll first set up the Anvil API integration, which we'll use for transaction building and submission.
2. Create the Anvil API Module
Let's create a src/lib/anvil-api.ts module to handle interactions with the Anvil API for building and submitting transactions:
Notice how the lockFunds function that calls the Anvil API. This is building a transaction that sends ADA to the escrow script address with the specified datum.
Once submitted, the transaction will be included in a block and the ADA will be locked in the escrow address.
// src/lib/anvil-api.ts
const API = process.env.ANVIL_API_ENDPOINT;
const X_API_KEY = process.env.ANVIL_API_KEY;
if (!API || !X_API_KEY) {
throw new Error('ANVIL_API_ENDPOINT or ANVIL_API_KEY environment variables are not set');
}
const getHeaders = () => ({
'Content-Type': 'application/json',
'x-api-key': X_API_KEY,
});
// Error handling utilities
const handleApiError = (context: string, error: unknown): string => {
console.error(`Error ${context}:`, error);
const message = error instanceof Error ? error.message : String(error);
return `Failed to ${context}: ${message}`;
};
// Generic API fetch with error handling
async function fetchApi<T>(endpoint: string, options: RequestInit, context: string): Promise<T> {
try {
const response = await fetch(`${API}${endpoint}`, options);
if (!response.ok) {
const errText = await response.text();
console.error(`${context} error:`, response.status, response.statusText, errText);
throw new Error(`${response.status} ${response.statusText} - ${errText}`);
}
return await response.json() as T;
} catch (error) {
throw new Error(handleApiError(context, error));
}
}
// Interface for lock funds parameters
interface LockFundsParams {
changeAddress: string; // User's wallet address for change
lovelaceAmount: number; // Amount in lovelace to lock in escrow
ownerKeyHash: string; // Public key hash of the owner who can unlock funds
message?: string; // Optional transaction message
}
// Interface for the lock funds response
interface LockFundsResponse {
txHash?: string; // Transaction hash if successful
complete?: string; // Complete tx for client-side signing
error?: string; // Error message if the request fails
}
/**
* Get the script address for a validator hash
*/
export async function getScriptAddress(validatorHash: string): Promise<string> {
const data = await fetchApi<{ hex: string }>(
`/validators/${validatorHash}/address`,
{
method: 'GET',
headers: getHeaders(),
},
'get script address'
);
return data.hex;
}
/**
* Get the payment verification key hash from an address
*/
export async function getAddressKeyHash(address: string): Promise<string> {
const data = await fetchApi<{ payment: string }>(
`/utils/addresses/parse`,
{
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ address }),
},
'get address key hash'
);
return data.stake;
}
/**
* Lock funds in the escrow smart contract
* This creates a transaction that sends ADA to the script address with the specified datum
*/
export async function lockFunds(params: LockFundsParams): Promise<LockFundsResponse> {
try {
// Derive owner stake key hash for datum.
// This is in case wallets use multi-accounts (i.e. Eternl) and the you use a different payment address to unlock the funds
// Using the stake key hash will allow you to use any payment address to sign and pay for Anvil API fees and still unlock the funds.
const stakeKeyHash = await getAddressKeyHash(params.changeAddress);
// Get the validator hash from environment
const validatorHash = process.env.ESCROW_VALIDATOR_HASH;
if (!validatorHash) {
throw new Error('Escrow validator hash not found');
}
// Get script address
const scriptAddress = await getScriptAddress(validatorHash);
// Prepare the transaction input
const input = {
changeAddress: params.changeAddress,
message: params.message || "Locking funds in escrow using Anvil API",
outputs: [
{
address: scriptAddress,
lovelace: params.lovelaceAmount,
datum: {
type: "inline",
value: {
owner: stakeKeyHash
},
shape: {
validatorHash: validatorHash,
purpose: "spend"
}
}
}
],
};
// Build the transaction using our generic fetch utility
const result = await fetchApi<{ hash: string, complete: string }>(
`/transactions/build`,
{
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(input),
},
'build lock transaction'
);
// Return hash and complete transaction for client-side signing and DB recording
return {
txHash: result.hash,
complete: result.complete,
};
} catch (error: unknown) {
console.error('Error locking funds:', error);
const message = error instanceof Error ? error.message : String(error);
return { error: message };
}
}
/**
* Submit a signed transaction to the blockchain
*/
export async function submitTransaction(signedTx: string, complete: string): Promise<{ txHash: string }> {
const result = await fetchApi<{ txHash: string }>(
`/transactions/submit`,
{
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({
signatures: [signedTx],
transaction: complete,
}),
},
'submit transaction'
);
return { txHash: result.txHash };
}
4. Update Transaction Status Types
Let's add a new types.ts file to include the states for the locking process:
// src/lib/types.ts
// Transaction status constants
export const TX_STATUS = {
PENDING: 'pending' as const,
SIGN_LOCK: 'signLock' as const,
SIGN_UNLOCK: 'signUnlock' as const,
CONFIRMED: 'confirmed' as const,
UNLOCKED: 'unlocked' as const,
} as const;
// Create a type from the values
export type TransactionStatus = typeof TX_STATUS[keyof typeof TX_STATUS];
export type Transaction = {
txHash: string;
wallet: string;
amount: number;
status: TransactionStatus;
timestamp: number;
};
5. Create API Endpoint for Locking Funds
Now, let's create an API endpoint src/app/api/escrow/lock/route.ts in our Next.js application for locking funds:
After we have called the API to build the transaction, we will prompt the user to sign the transaction via Weld.
Upon signing, we will submit the signed transaction to the blockchain via the following API route:
This src/app/api/escrow/submit/route.ts endpoint will be used for submitting both lock and unlock transactions. We will update it later to handle unlock transactions.
// src/app/api/escrow/submit/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { submitTransaction } from '@/lib/anvil-api';
import { TX_STATUS } from '@/lib/types';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { complete, signature, type } = body;
// Validate inputs
if (!complete || !signature || type !== TX_STATUS.SIGN_LOCK) {
return NextResponse.json(
{ error: 'Missing complete transaction or signature' },
{ status: 400 }
);
}
// Submit the signed transaction to the blockchain
const result = await submitTransaction(signature, complete);
return NextResponse.json({ txHash: result.txHash });
} catch (error: unknown) {
console.error('Error submitting transaction:', error);
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json(
{ error: message || 'Failed to submit transaction' },
{ status: 500 }
);
}
}
7. Create the Transaction Operations Hook
Let's create a custom hook src/hooks/useTransactionOperations.ts to handle transaction operations:
See the lockFunds function to see how we call our API endpoints and use Weld to sign the transaction between calls. We will also update this later to handle unlock transactions.
This hook encapsulates the logic for building, signing, and submitting transactions. It handles all the API calls and error states, making our component code cleaner.
8a. Create the Lock Funds Form Component
Now, let's create the form component src/components/LockFundsForm.tsx for locking funds:
Finally, update the home page src/app/page.tsx to include the LockFundsForm component:
// src/app/page.tsx
/**
* Main dashboard page for the Cardano Escrow application
* Displays wallet connector and escrow functionality
*/
import WalletConnector from "@/components/WalletConnector";
import LockFundsForm from "@/components/LockFundsForm";
export default function Page() {
return (
<main className="container mx-auto p-6 max-w-3xl">
<h1 className="text-3xl font-bold mb-6 text-black">Cardano Escrow</h1>
<WalletConnector />
<LockFundsForm />
</main>
);
}
Testing Your Implementation
To test the fund locking functionality:
Start your development server:
npm run dev
Navigate to http://localhost:3000 in your browser
Connect your wallet using the wallet connector
Use the slider to select an amount of ADA to lock
Click the "Lock Funds" button
Approve the transaction in your wallet when prompted
Verify that you see a success message with the transaction hash
Troubleshooting
Transaction Building Errors
If you encounter errors when building transactions:
Check that your Anvil API key is correct in your .env.local file
Verify that the validator hash is set correctly in your environment variables
Ensure your wallet has sufficient funds (including for transaction fees)
Check the browser console and server logs for more detailed error messages
Wallet Signing Errors
If transaction signing fails:
Make sure your wallet is unlocked
Check that you've approved the dApp connection
Try refreshing the page and reconnecting your wallet
In a real application, you'd want to store transaction details for later reference. This lessens your need to query the blockchain for users transactions. We'll implement this in Part 4 using a database.
Congratulations! You've completed Part 3 of the guide. Your application can now lock funds in a Cardano smart contract.