Part 3: Building Transactions

This is Part 3 of our guide to building a Cardano transaction application with Next.js and Weld. In this section, you'll implement transaction building and submission functionality.

Introduction

Now for the best part: With our wallet integration in place, we're now ready to implement the transaction functionality. This involves creating API endpoints for transaction building and submission, as well as building the user interface components for transaction input and status feedback.

Transaction Flow Overview

For a comprehensive explanation of Cardano transaction concepts, please refer to our Transaction Guide. In this tutorial, we'll focus on implementing the following streamlined transaction flow:

  1. Get Wallet Data: Retrieve the user's wallet address and UTXOs

  2. Build Transaction: Call Anvil API to construct the transaction

  3. Sign Transaction: Use the connected wallet to sign the transaction

  4. Submit Transaction: Send the signed transaction to the Cardano network

  5. Display Result: Show the transaction ID and success/error feedback

Implementation Steps

1. Create Anvil API Utility Functions

Instead of calling the Anvil API directly, we'll create utility functions that offer several advantages including type safety, error handling, and secure API key management through Next.js server components.

Our implementation will include three key functions:

  • callAnvilApi: A generic request handler with proper error management

  • buildTransaction: Creates transactions with sender addresses and payment details

  • submitTransaction: Sends signed transactions to the Cardano network

Let's implement these utilities:

// src/utils/anvil-api.ts
"use server";

const BASE_URL = process.env.NEXT_PUBLIC_ANVIL_API_URL;

interface AnvilApiConfig<T = Record<string, unknown>> {
  endpoint: string;
  method?: "GET" | "POST";
  body?: T;
  apiKey?: string;
}

export async function callAnvilApi<T, B = Record<string, unknown>>({
  endpoint,
  method = "POST",
  body,
  apiKey,
}: AnvilApiConfig<B>): Promise<T> {
  if (!BASE_URL) {
    throw new Error("Anvil API base URL is not configured");
  }

  const key = apiKey || process.env.ANVIL_API_KEY;
  if (!key) {
    throw new Error("API key is required for Anvil API");
  }

  const headers = {
    "Content-Type": "application/json",
    "X-Api-Key": key,
  };

  try {
    const response = await fetch(`${BASE_URL}/${endpoint}`, {
      method,
      headers,
      body: body ? JSON.stringify(body) : undefined,
    });

    if (!response.ok) {
      const errorData = await response
        .json()
        .catch(() => ({ message: "Unknown error" }));
      throw new Error(
        `Anvil API Error (${response.status}): ${errorData.message || "Unknown error"}`,
      );
    }

    return (await response.json()) as T;
  } catch (error) {
    console.error("Anvil API request failed:", error);
    throw error;
  }
}

export interface BuildTransactionParams {
  changeAddress: string;
  utxos: string[];
  outputs: {
    address: string;
    lovelace: number;
    assets?: Record<string, number>;
  }[];
}

export interface TransactionBuildResult {
  hash: string;
  complete: string; // CBOR
  stripped: string; // CBOR
  witnessSet: string; // CBOR
}

export async function buildTransaction(
  params: BuildTransactionParams,
): Promise<TransactionBuildResult> {
  return callAnvilApi<TransactionBuildResult, BuildTransactionParams>({
    endpoint: "transactions/build",
    body: params,
  });
}

export interface SubmitTransactionParams {
  transaction: string; // CBOR
  signatures?: string[]; // CBOR
}

export interface TransactionSubmitResult {
  txHash: string;
}

export async function submitTransaction(
  params: SubmitTransactionParams,
): Promise<TransactionSubmitResult> {
  return callAnvilApi<TransactionSubmitResult, SubmitTransactionParams>({
    endpoint: "transactions/submit",
    body: params,
  });
}

2. Create API Endpoints for Transaction Building and Submission

Next.js offers a built-in API routes system through its App Router architecture that allows us to create serverless functions. For this application, we'll create two API endpoints to handle transaction building and submission.

See the Next.js Route Handlers documentation for more details on how these API routes work.

Transaction Building Endpoint

First, let's create the endpoint that builds a transaction with our Anvil API utility:

// src/app/api/transaction/build/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { buildTransaction, BuildTransactionParams } from '@/utils/anvil-api';

/**
 * Build a Cardano transaction using Anvil API
 * 
 * This endpoint builds a transaction with the provided inputs and outputs
 * It requires:
 * - changeAddress: The sender's address for change
 * - utxos: Array of UTXOs from the wallet
 * - outputs: Where to send the ADA/assets with amounts
 */
export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { changeAddress, utxos, outputs } = body as BuildTransactionParams;
    
    if (!changeAddress || !utxos || !outputs) {
      return NextResponse.json(
        { error: "Missing required parameters" },
        { status: 400 }
      );
    }

    // Call utility function to build the transaction
    const transactionData = await buildTransaction({ changeAddress, utxos, outputs });

    // Return the transaction data for signing
    return NextResponse.json({
      hash: transactionData.hash,
      complete: transactionData.complete,
      stripped: transactionData.stripped,
      witnessSet: transactionData.witnessSet
    });
  } catch (error) {
    console.error("Error building transaction:", error);
    
    return NextResponse.json(
      { error: error instanceof Error ? error.message : "Failed to build transaction" },
      { status: 500 }
    );
  }
}

Transaction Submission Endpoint

Next, let's create the endpoint that submits a signed transaction to the Cardano network:

// src/app/api/transaction/submit/route.ts
import { NextRequest, NextResponse } from "next/server";
import { submitTransaction, SubmitTransactionParams } from "@/utils/anvil-api";

/**
 * Submit a signed Cardano transaction to the blockchain using Anvil API
 *
 * This endpoint accepts:
 * - transaction: The transaction CBOR (can be unsigned)
 * - signatures: Optional array of signatures from the wallet
 */
export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { transaction, signatures } = body as SubmitTransactionParams;

    if (!transaction) {
      return NextResponse.json(
        { error: "Missing transaction" },
        { status: 400 },
      );
    }

    // Call utility function to submit the transaction
    const submissionData = await submitTransaction({ transaction, signatures });

    // Return the transaction hash and success message
    return NextResponse.json({
      hash: submissionData.txHash,
      message: "Transaction submitted successfully",
    });
  } catch (error) {
    console.error("Error submitting transaction:", error);

    return NextResponse.json(
      {
        error:
          error instanceof Error
            ? error.message
            : "Failed to submit transaction",
      },
      { status: 500 },
    );
  }
}

3. Create a Custom Hook for Transaction Management

To cleanly manage our transaction logic, we'll create a reusable custom hook that:

  • Tracks the entire transaction lifecycle with multiple status states

  • Manages API calls to our transaction endpoints

  • Handles wallet integration for signing

  • Provides consistent error handling and user feedback

  • Exposes a simple interface for components to use

This pattern keeps our UI components focused on presentation rather than complex transaction logic.

// src/hooks/useTransactionSubmission.ts
"use client";

import { useState, useCallback } from "react";
import { useWallet } from "@ada-anvil/weld/react";

// Transaction states for managing the flow
export type TransactionStatus =
  | "idle"
  | "building"
  | "signing"
  | "submitting"
  | "success"
  | "error";

export interface TransactionParams {
  recipient: string;
  ada: number;
}

export function useTransactionSubmission() {
  const [status, setStatus] = useState<TransactionStatus>("idle");
  const [txHash, setTxHash] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  const wallet = useWallet(); // Use Weld's wallet hook

  const submitTransaction = useCallback(
    async ({ recipient, ada }: TransactionParams) => {
      // Convert ADA to lovelace (1 ADA = 1,000,000 lovelace)
      const lovelace = Math.floor(ada * 1_000_000);

      try {
        // Reset states
        setError(null);
        setTxHash(null);
        setStatus("building");

        // Validate wallet connection
        if (
          !wallet.isConnected ||
          !wallet.changeAddressBech32 ||
          !wallet.handler
        ) {
          throw new Error("Please connect your wallet first");
        }

        // Step 1: Build transaction
        const buildResponse = await fetch("/api/transaction/build", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            changeAddress: wallet.changeAddressBech32, // Sender's address from Weld
            utxos: await wallet.handler.getUtxos(), // Get available UTXOs from Weld
            outputs: [{ address: recipient, lovelace }],
          }),
        });

        if (!buildResponse.ok) {
          const errorData = await buildResponse.json();
          throw new Error(errorData.error || "Failed to build transaction");
        }

        const buildData = await buildResponse.json();

        // Step 2: Sign transaction
        setStatus("signing");
        const signature = await wallet.handler.signTx(buildData.complete);

        if (!signature) {
          throw new Error("Failed to sign transaction");
        }

        // Step 3: Submit transaction
        setStatus("submitting");
        const submitResponse = await fetch("/api/transaction/submit", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            transaction: buildData.complete,
            signatures: [signature],
          }),
        });

        if (!submitResponse.ok) {
          const errorData = await submitResponse.json();
          throw new Error(errorData.error || "Failed to submit transaction");
        }

        const submitData = await submitResponse.json();

        setTxHash(submitData.hash);
        setStatus("success");
        return submitData.hash;
      } catch (err) {
        setError(err instanceof Error ? err.message : "Unknown error");
        setStatus("error");
        return null;
      }
    },
    [wallet],
  );

  const reset = useCallback(() => {
    setStatus("idle");
    setTxHash(null);
    setError(null);
  }, []);

  return {
    status,
    txHash,
    error,
    submitTransaction,
    reset,
    isProcessing:
      status !== "idle" && status !== "error" && status !== "success",
  };
}

4. Create the Transaction Form Component

Now, let's create the UI component for transactions. This component will:

  • Provide inputs for recipient address and ADA amount

  • Display transaction status and results

  • Handle form validation and submission

  • Show a link to a blockchain explorer after successful transactions

The form integrates with our custom hook from the previous step to manage transaction state and processing. It also demonstrates proper error handling and user feedback throughout the transaction flow.

// src/components/TransactionForm.tsx
"use client";

import { useState, useCallback } from "react";
import { useWallet } from "@ada-anvil/weld/react";
import { useTransactionSubmission } from "@/hooks/useTransactionSubmission";
const NETWORK = process.env.NEXT_PUBLIC_NETWORK || "preprod";

const getExplorerUrl = (txHash: string) => {
  return NETWORK === "mainnet"
    ? `https://cexplorer.io/tx/${txHash}`
    : `https://${NETWORK}.cexplorer.io/tx/${txHash}`;
};

function getButtonText(status: string, adaAmount: number): string {
  if (status === "building") return "Building...";
  if (status === "signing") return "Signing...";
  if (status === "submitting") return "Submitting...";
  if (adaAmount === 0) return "Send ADA";
  return `Send ${adaAmount} ADA`;
}

export default function TransactionForm() {
  // Form state
  const [recipient, setRecipient] = useState("");
  const [adaAmount, setAdaAmount] = useState(0);

  // Use our custom hook for transaction management
  const {
    status,
    txHash,
    error,
    submitTransaction,
    reset: resetTransaction,
  } = useTransactionSubmission();

  // Get wallet information from the Weld hook
  const wallet = useWallet();

  // Handle the transaction submission process
  const handleSendTransaction = useCallback(
    async (e: React.FormEvent) => {
      e.preventDefault();
      await submitTransaction({ recipient, ada: adaAmount });
    },
    [submitTransaction, recipient, adaAmount],
  );

  // Render success state if transaction completed
  if (status === "success" && txHash) {
    return (
      <section className="paper">
        <h2>Transaction Successful</h2>
        <span>Transaction ID:</span>
        <br />
        <a
          href={getExplorerUrl(txHash)}
          target="_blank"
          rel="noopener noreferrer"
          className="break mt-1 font-mono text-sm text-blue-600 underline"
        >
          <b>{txHash}</b>
        </a>
        <div className="mt-6">
          <button onClick={resetTransaction} className="btn">
            Send Another
          </button>
        </div>
      </section>
    );
  }

  const isFormDisabled =
    !wallet.isConnected || (status !== "idle" && status !== "error");

  // Render transaction form
  return (
    <section className="paper">
      <h2>Send Transaction</h2>
      <form onSubmit={handleSendTransaction}>
        {/* Wallet connection status */}
        {!wallet.isConnected && (
          <div className="break mb-2 text-red-600">
            Please connect your wallet first.
          </div>
        )}

        {/* Recipient address input */}
        <div className="mb-2">
          <label htmlFor="recipient" className="block mb-1 font-medium">
            Recipient Address
          </label>
          <input
            className="custom-rounded"
            name="recipient"
            id="recipient"
            value={recipient}
            onChange={(e) => setRecipient(e.target.value)}
            type="text"
            placeholder="addr..."
            required
            disabled={isFormDisabled}
          />
        </div>

        {/* ADA amount input */}
        <div className="mb-4">
          <label htmlFor="ada-amount" className="block mb-1 font-medium">
            Amount (ADA)
          </label>
          <input
            className="custom-rounded"
            name="ada-amount"
            id="ada-amount"
            value={adaAmount || ""}
            onChange={(e) => {
              const value = parseFloat(e.target.value);
              setAdaAmount(isNaN(value) ? 0 : value);
            }}
            type="number"
            min="1"
            step="1"
            placeholder="Amount in ADA"
            required
            disabled={isFormDisabled}
          />
        </div>

        {/* Submit button */}
        <button type="submit" className="btn" disabled={isFormDisabled}>
          {getButtonText(status, adaAmount)}
        </button>

        {error && (
          <div className="break mt-2 text-red-600">
            <strong>Error:</strong> {error}
          </div>
        )}
      </form>
    </section>
  );
}

5. Update the Home Page to Include the Transaction Form

Finally, let's update the home page to include both the wallet connector and transaction form:

// src/app/page.tsx
import WalletConnector from "@/components/WalletConnector";
import TransactionForm from "@/components/TransactionForm";

export default function Home() {
  return (
    <main className="container mx-auto p-4">
      <h1 className="text-2xl font-bold mb-6">Cardano Transaction App</h1>
      
      <div className="grid md:grid-cols-2 gap-6">
        <WalletConnector />
        {/* Add the TransactionForm component below */}
        <TransactionForm /> 
      </div>
    </main>
  );
}

Testing Your Transaction Flow

  1. Start your development server:

npm run dev
  1. Navigate to your application (usually at http://localhost:3000)

  2. Connect your wallet following the instructions from Part 2

  3. Test the transaction flow:

    • Enter a valid recipient address (use a testnet address for testing)

    • Enter a small amount of ADA (e.g., 1-2 ADA)

    • Click the "Send" button

    • Observe the transaction states: Building → Signing → Submitting → Success

    • Verify the transaction ID appears and links to a block explorer

Troubleshooting

Common Transaction Errors

  1. Insufficient Funds

    • Ensure your wallet has enough testnet ADA for the transaction and fees

    • Try sending a smaller amount

  2. Invalid Address

    • Verify you're using a valid Cardano address format

    • Check that you're using a testnet address when testing on testnet

  3. Signature Failure

    • Make sure your wallet is unlocked

    • Try reconnecting your wallet if signing fails

    • Check that your wallet is enabled for dApp interactions

  4. Network Issues

    • Check your internet connection

    • Verify the Anvil API is available and responding (Use /health endpoint)

Deployment Considerations

When deploying your application to production:

  1. Environment Variables: Ensure your production environment has the correct API URLs and keys

  2. Network Selection: Update the NEXT_PUBLIC_NETWORK to mainnet for production use

  3. Error Handling: Implement more robust error handling and user feedback

  4. Security: Ensure your API keys are properly secured and not exposed to clients

Conclusion

Congratulations! You've successfully built a complete Cardano transaction application using Next.js and Weld. Your application can now:

  • Connect to Cardano wallets

  • Build and sign transactions

  • Submit transactions to the Cardano network

  • Display transaction results

Completed Cardano Transaction App with Next.js and Weld
Your completed Cardano transaction application

Next Steps

To further enhance your application, consider exploring:

Last updated

Was this helpful?