From Solidity to Clarity: My Journey Building DeFi Direct on Stacks

Joseph AleonomohJoseph Aleonomoh
18 min read

Hey there! So I decided to take my DeFi Direct contract from Ethereum (Solidity) and port it over to Stacks (Clarity). Let me walk you through this wild ride of converting between two completely different smart contract languages. Buckle up, because this is gonna be a long one!

The Big Picture: What We're Building

Before we dive into the nitty-gritty, let me explain what DeFi Direct actually does. It's a bridge service that lets users convert their crypto tokens directly to fiat currency. Think of it like a fancy crypto ATM, but instead of spitting out cash, it deposits money straight into your bank account. Pretty cool, right?

The core flow is:

1. User initiates a transaction with their tokens

2. Contract locks the tokens and calculates fees

3. Transaction manager (off-chain service) processes the fiat conversion

4. Contract releases tokens to the appropriate parties

The Language Showdown: Solidity vs Clarity

Solidity: Ethereum

Solidity is like JavaScript's rebellious cousin who decided to handle money. It's object-oriented, has familiar syntax, and lets you do some pretty wild stuff (sometimes too wild, which is why we have so many hacks).

Clarity: Stacks

Clarity is like that friend who's super careful about everything. It's designed to be predictable, readable, and safe. No hidden surprises, no reentrancy attacks, no overflow issues. It's like having a smart contract language with training wheels, but in a good way.

Let's Start with the Basics: Contract Structure

Solidity Contract Declaration

// SPDX-License-Identifier: MIT // This is just a license comment. Clarity doesn't need this because... 
                                // well, it's just different.

pragma solidity ^0.8.28; // Tells the compiler which version to use.

// Solidity uses imports like most programming languages. 
// Clarity uses traits and contract references instead.
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "./DirectSettings.sol";

// This is inheritance. The contract extends two other contracts. 
// Clarity doesn't have inheritance, so we'll handle this differently.
contract FiatBridge is DirectSettings, ReentrancyGuard

Clarity Contract Declaration

What's happening here:

- ;; title: defi-direct - This is just a comment. Clarity uses ;; for comments instead of //.

- define-trait sip010-trait - This is defining an interface (trait in Clarity syntax). It's like defining what functions a token contract must have.

- The trait defines the SIP-010 standard, which is Stacks' equivalent of ERC-20. Notice how each function has explicit input and output types.

Data Structures: The Heart of Our Contract

;; title: defi-direct

;; SIP-010 Token Trait Definition

(define-trait sip010-trait
  (
    ;; Transfer function
    (transfer (uint principal principal (optional principal))
      (response bool uint))

    ;; Get balance of a principal
    (get-balance (principal)
      (response uint uint))

    ;; Get number of decimals
    (get-decimals ()
      (response uint uint))

    ;; Get token symbol
    (get-symbol ()
      (response (string-ascii 32) uint))

    ;; Get token name
    (get-name ()
      (response (string-ascii 32) uint))
  )
)

Solidity Transaction Struct

// This defines a custom data type. Think of it like a blueprint for transaction data.
struct Transaction {
    address user; // Stores the user's Ethereum address. In Clarity, this becomes principal.
    address token; // The token contract address.

    uint256 amount; // The amount of tokens. uint in Clarity.
    uint256 amountSpent; // How much was actually spent. uint in Clarity.
    uint256 transactionFee; // The fee amount. uint in Clarity.
    uint256 transactionTimestamp; // When the transaction happened. uint in Clarity.

    uint256 fiatBankAccountNumber; // Bank account number. uint in Clarity.
    string fiatBank; // Bank name. (string-ascii 32) in Clarity (limited to 32 characters).
    string recipientName; // Recipient's name. (string-ascii 32) in Clarity.
    uint256 fiatAmount; // Fiat amount. uint in Clarity.

    bool isCompleted; // Whether transaction is complete. Also bool in Clarity.
    bool isRefunded; // Whether transaction was refunded. Also bool in Clarity.
}

The Transaction struct is a schema for transaction data, including the user's Ethereum address, token contract address, token amount, amount spent, transaction fee, timestamp, bank account number, bank name, recipient's name, fiat amount, and statuses for completion and refund. In Clarity, addresses are principal, strings are limited to 32 characters, and numeric values are uint.

Clarity Transaction Structure

;; Instead of a struct, we define a map directly. 
;; Maps in Clarity are like dictionaries or hash tables.
(define-map transactions
  ;; Key: 32-byte buffer (e.g., transaction ID or hash), (like bytes32 in Solidity).
  ((buff 32))

  ;; Value: transaction data - a tuple (similar to a struct) with named fields.
  {
    user: principal,
    token: principal,

    amount: uint,
    amount-spent: uint,
    transaction-fee: uint,
    transaction-timestamp: uint,

    fiat-bank-account-number: uint,
    fiat-bank: (string-ascii 32),
    recipient-name: (string-ascii 32),
    fiat-amount: uint,

    is-completed: bool,
    is-refunded: bool
  }
)

The transactions map in Clarity serves the same purpose as a struct in Solidity….defining a schema to store and retrieve transaction data. Each entry is keyed by a 32-byte buffer (e.g., a hash or unique transaction ID), and the value is a tuple of typed fields. In Clarity, strings are defined with a maximum length (string-ascii 32) and numeric values use the uint type. Overall, while Solidity uses structs inside contracts, Clarity maps are defined at the top level and behave like typed dictionaries, enabling structured and predictable on-chain storage.

Storage: Where We Keep Our Data

Solidity Storage

mapping(bytes32 => Transaction) public transactions; // Maps transaction IDs to transaction data.

mapping(address => bytes32[]) public userTransactionIds; // Maps user addresses to arrays of their transaction IDs.

mapping(address => uint256) public collectedFees; // Maps token addresses to collected fees.

In Solidity, storage is often structured using mapping types, which act like hash tables for key-value storage on-chain. The transactions mapping associates each bytes32 transaction ID with a full Transaction struct, essentially storing detailed transaction records by their unique identifiers. The userTransactionIds mapping links each user's Ethereum address to an array of their transaction IDs, making it easy to query all transactions belonging to a specific user. Lastly, the collectedFees mapping keeps track of fees collected per token address.

Each mapping is marked as public, which automatically creates a getter function, allowing external contracts or frontend apps to access the stored values.

Clarity Storage

;; Maps are defined with define-map instead of mapping.

;; Define a map for storing detailed transaction data
(define-map transactions
  ;; Key: 32-byte buffer (e.g., transaction ID)
  ((buff 32))
  ;; Value: transaction details
  {
    user: principal,
    token: principal,

    amount: uint,
    amount-spent: uint,
    transaction-fee: uint,
    transaction-timestamp: uint,

    fiat-bank-account-number: uint,
    fiat-bank: (string-ascii 32),
    recipient-name: (string-ascii 32),
    fiat-amount: uint,

    is-completed: bool,
    is-refunded: bool
  }
)

;; Define a map for storing a list of transaction IDs per user
(define-map user-transaction-ids
  principal
  (list 1000 (buff 32))
)

;; Define a map for storing total fees collected per user
(define-map collected-fees
  principal
  uint
)

In Clarity, the equivalent storage structure is implemented using define-map. Each map is defined with strict typing for both the key and value. The transactions map stores detailed transaction data keyed by a (buff 32) buffer, similar to a bytes32 hash in Solidity. Instead of a struct, Clarity uses tuples with named fields to represent structured data.

The user-transaction-ids map stores a list of up to 1000 transaction IDs per user, using a principal as the key and a bounded list as the value.

The collected-fees map tracks the total amount of fees collected per user (or principal), using a uint value as the accumulator.

Unlike Solidity, Clarity does not have a public keyword, so access to maps must be done explicitly via read functions.

Variables: The Simple Stuff

Solidity Variables

uint256 public spreadFeePercentage;
address public owner;
address public transactionManager;
address public feeReceiver;
address public vaultAddress;
bool public paused;

In Solidity, variables like spreadFeePercentage, owner, transactionManager, feeReceiver, vaultAddress, and paused are declared as state variables and often marked public, which automatically creates getter functions for external access. Solidity uses types like address, uint256, and bool without requiring explicit initialization in many cases.

Clarity Variables

(define-data-var owner principal tx-sender)
(define-data-var paused bool false)
(define-data-var spread-fee-percentage uint u0)
(define-data-var transaction-manager (optional principal) none)
(define-data-var fee-receiver (optional principal) none)
(define-data-var vault-address (optional principal) none)

In Clarity, similar functionality is achieved using define-data-var, where each variable is initialized with a specific type and value. For example, spread-fee-percentage is a uint initialized to u0, and owner is set to tx-sender, which represents the contract deployer. The variables at the top of the contract are set during deployment, meaning tx-sender is the deployer's address at that time, which is why the owner is set to it. Clarity introduces the concept of (optional principal) for variables like transaction-manager, fee-receiver, and vault-address. This means the variable may or may not hold a value (none by default), clearly indicating whether a principal is present or absent.

Error Handling: Making Things Safe

Solidity Error Handling

In Solidity, access control and validation are commonly handled using modifiers and require statements. For example, the onlyTransactionManager modifier ensures that only the designated manager can call certain functions:

modifier onlyTransactionManager() {
require(msg.sender == transactionManager, "Not transaction manager");
_;
}

Usage in functions:

require(amount > 0, "Amount must be greater than zero");

require(supportedTokens[token], "Token not supported");

Clarity Error Handling

In Clarity, similar functionality is achieved using error codes defined with define-constant. Instead of inline strings, errors are declared once and reused throughout the contract:

;; General Errors
(define-constant err-fee-too-high (err u100))
(define-constant err-not-owner (err u101))
(define-constant err-not-tx-manager (err u102))
(define-constant err-paused (err u105))
(define-constant err-not-paused (err u106))
(define-constant err-arithmetic-overflow (err u118))

;; Validation Errors
(define-constant err-token-not-supported (err u103))
(define-constant err-invalid-address (err u104))
(define-constant err-invalid-amount (err u110))
(define-constant err-invalid-bank-account (err u111))
(define-constant err-invalid-bank-name (err u112))
(define-constant err-invalid-recipient-name (err u113))
(define-constant err-invalid-fiat-amount (err u117))

;; Transaction Errors
(define-constant err-already-processed (err u107))
(define-constant err-amount-mismatch (err u108))
(define-constant err-insufficient-balance (err u109))
(define-constant err-transaction-not-found (err u114))
(define-constant err-transaction-data-error (err u115))
(define-constant err-list-too-long (err u116))

Why this approach?

- Clarity uses numeric error codes.

- (err u100) creates an error with code 100. The u means unsigned integer.

- We define all errors upfront, making the contract more organized.

Transaction Initiation

Solidity initiateFiatTransaction

function initiateFiatTransaction(
    address token,
    uint256 amount,
    uint256 _fiatBankAccountNumber,
    uint256 _fiatAmount,
    string memory _fiatBank,
    string memory _recipientName
)
    external
    nonReentrant
    whenNotPaused
    returns (bytes32 txId)
{
    require(amount > 0, "Amount must be greater than zero");
    require(supportedTokens[token], "Token not supported");
    require(_fiatBankAccountNumber > 0, "Invalid bank account number");
    require(bytes(_fiatBank).length > 0, "Invalid bank name");
    require(bytes(_recipientName).length > 0, "Invalid recipient name");

    uint256 feeAmount = (amount * spreadFeePercentage) / 10000;
    uint256 totalAmount = amount + feeAmount;

    IERC20 tokenContract = IERC20(token);

    require(tokenContract.balanceOf(msg.sender) >= totalAmount, "Insufficient Balance");
    require(
        tokenContract.transferFrom(msg.sender, address(this), totalAmount),
        "Transfer failed"
    );

    collectedFees[token] += feeAmount;

    txId = keccak256(abi.encodePacked(
        msg.sender,
        token,
        amount,
        block.timestamp
    ));

    transactions[txId] = Transaction({
        user: msg.sender,
        token: token,
        amount: amount,
        amountSpent: 0,
        transactionFee: feeAmount,
        transactionTimestamp: block.timestamp,
        fiatBankAccountNumber: _fiatBankAccountNumber,
        fiatBank: _fiatBank,
        recipientName: _recipientName,
        fiatAmount: _fiatAmount,
        isCompleted: false,
        isRefunded: false
    });

    userTransactionIds[msg.sender].push(txId);

    emit TransactionInitiated(txId, msg.sender, amount);

    return txId;
}

Let's break this down line by line:

1. Function signature:

- Solidity: function initiateFiatTransaction(...) external nonReentrant whenNotPaused returns (bytes32 txId)

- Clarity: (define-public (initiate-fiat-transaction ...)) - No need for external or modifiers, Clarity handles this differently.

2. Validation checks:

- Solidity: require(amount > 0, "Amount must be greater than zero");

- Clarity: (asserts! (> amount u0) err-invalid-amount) - asserts! is like require, but it takes an error code instead of a string.

3. Fee calculation:

- Solidity: uint256 feeAmount = (amount * spreadFeePercentage) / 10000;

- Clarity: (fee-amt (/ (* amount fee) u10000)) - Notice the prefix notation (* amount fee) instead of amount * fee.

4. Token transfer:

- Solidity: require(tokenContract.transferFrom(msg.sender, address(this), totalAmount), "Transfer failed");

- Clarity: (try! (contract-call? token-contract transfer total-amt tx-sender (as-contract tx-sender) none)) - try! is like require, and contract-call? is how you call other contracts.

5. Transaction ID generation:

- Solidity: txId = keccak256(abi.encodePacked(msg.sender, token, amount, block.timestamp));

- Clarity: (tx-id (sha256 (concat (unwrap! (to-consensus-buff? amount) err-transaction-data-error) (unwrap! (to-consensus-buff? now) err-transaction-data-error)))) - Much more verbose, but very explicit about what's happening.

Clarity initiate-fiat-transaction

(define-public (initiate-fiat-transaction
  (token-contract <sip010-trait>)
  (amount uint)
  (fiat-bank-account-number uint)
  (fiat-amount uint)
  (fiat-bank (string-ascii 32))
  (recipient-name (string-ascii 32))
)
  (let (
    (token (contract-of token-contract))
    (is-supported (default-to false (map-get? supported-tokens token)))
    (fee (var-get spread-fee-percentage))
    (fee-amt (/ (* amount fee) u10000))
    (total-amt (+ amount fee-amt))
    (now stacks-block-height)
    (tx-id (sha256
             (concat
               (unwrap! (to-consensus-buff? amount) err-transaction-data-error)
               (unwrap! (to-consensus-buff? now) err-transaction-data-error)
             )
           ))
  )
    (begin
      ;; Validation
      (asserts! (not (var-get paused)) err-paused)
      (asserts! (> amount u0) err-invalid-amount)
      (asserts! (> fiat-bank-account-number u0) err-invalid-bank-account)
      (asserts! (> fiat-amount u0) err-invalid-fiat-amount)
      (asserts! (> (len fiat-bank) u0) err-invalid-bank-name)
      (asserts! (> (len recipient-name) u0) err-invalid-recipient-name)
      (asserts! is-supported err-token-not-supported)

      ;; Checking for overflow
      (asserts! (> total-amt amount) err-arithmetic-overflow)

      ;; Token transfer
      (try! (contract-call? token-contract transfer total-amt tx-sender (as-contract tx-sender) none))

      ;; Recording the transaction
      (map-set transactions tx-id {
        user: tx-sender,
        token: token,
        amount: amount,
        amount-spent: u0,
        transaction-fee: fee-amt,
        transaction-timestamp: now,
        fiat-bank-account-number: fiat-bank-account-number,
        fiat-bank: fiat-bank,
        recipient-name: recipient-name,
        fiat-amount: fiat-amount,
        is-completed: false,
        is-refunded: false
      })

      ;; Update user transaction list
      (let ((existing-ids (default-to (list) (map-get? user-transaction-ids tx-sender))))
        (map-set user-transaction-ids tx-sender
          (unwrap! (as-max-len? (append existing-ids tx-id) u100) (err u116))
        )
      )

      (ok tx-id)
    )
  )
)

Key Clarity differences:

- (let (...)) - This is how you declare local variables in Clarity. Everything is immutable!

- (begin ...) - Groups multiple expressions together.

- (map-set ...) - Sets a value in a map.

- (unwrap! ...) - Unwraps an optional value, throwing an error if it's none.

- (as-max-len? ... u1000) - Ensures the list doesn't exceed 100 items. Like I said earlier Clarity is very strict about list sizes!

Transaction Completion

Solidity completeTransaction

function completeTransaction(bytes32 txId, uint256 amountSpent)
    external
    onlyTransactionManager
    nonReentrant
{
    Transaction storage txn = transactions[txId];

    require(!txn.isCompleted && !txn.isRefunded, "Transaction already processed");
    require(amountSpent == txn.amount, "Amount spent not equal locked amount");

    txn.amountSpent = amountSpent;
    txn.isCompleted = true;

    require(IERC20(txn.token).transfer(feeReceiver, txn.transactionFee), "Fee transfer failed");
    require(IERC20(txn.token).transfer(vaultAddress, amountSpent), "Transfer failed");

    emit TransactionCompleted(txId, amountSpent);
}

Breaking it down:

- Transaction storage txn = transactions[txId]; - Gets a reference to the transaction in storage.

- require(!txn.isCompleted && !txn.isRefunded, "Transaction already processed"); - Checks transaction state.

- txn.amountSpent = amountSpent; - Updates the transaction (mutable).

- require(IERC20(txn.token).transfer(...), "Transfer failed"); - Transfers tokens.

Clarity complete-transaction

(define-public (complete-transaction
  (token <sip010-trait>) ;; Require trait reference
  (tx-id (buff 32))
  (amount-spent uint)
)
  (let (
    (tx (map-get? transactions tx-id))
    (tx-manager (var-get transaction-manager))
  )
    (begin
      (asserts! (is-some tx-manager) err-not-tx-manager)
      (asserts! (is-eq tx-sender (unwrap! tx-manager err-not-tx-manager)) err-not-tx-manager)
      (asserts! (is-some tx) err-transaction-not-found)

      (let ((txn (unwrap! tx err-transaction-data-error)))
        (asserts! (not (get is-completed txn)) err-already-processed)
        (asserts! (not (get is-refunded txn)) err-already-processed)
        (asserts! (is-eq amount-spent (get amount txn)) err-amount-mismatch)

        ;; Verify the token matches stored principal
        (asserts! (is-eq (contract-of token) (get token txn)) err-token-not-supported)

        ;; Transfer fee to fee-receiver
        (try!
          (contract-call? token transfer
            (get transaction-fee txn)
            (as-contract tx-sender)
            (unwrap! (var-get fee-receiver) err-invalid-address)
            none
          )
        )

        ;; Mark transaction as completed
        (map-set transactions tx-id
          (merge txn {
            amount-spent: amount-spent,
            is-completed: true
          })
        )

        (ok true)
      )
    )
  )
)

Key differences:

- (map-get? transactions tx-id) - Returns an optional value. If the transaction doesn't exist, it returns none.

- (is-some tx-manager) - Checks if the optional value is not none.

- (get is-completed txn) - Gets a field from the tuple. Like txn.isCompleted in Solidity.

- (merge txn {...}) - Creates a new tuple by merging the old one with new values. Since Clarity is immutable, we can't modify the existing tuple.

Refund Function: When Things Go Wrong

Solidity refund

function refund(bytes32 txId)
    external
    onlyOwner
    nonReentrant
{
    Transaction storage txn = transactions[txId];

    require(!txn.isCompleted && !txn.isRefunded, "Transaction already processed");

    txn.isRefunded = true;

    require(
        IERC20(txn.token).balanceOf(address(this)) >= txn.amount + txn.transactionFee,
        "Insufficient balance"
    );

    require(
        IERC20(txn.token).transfer(txn.user, txn.amount + txn.transactionFee),
        "Transfer failed"
    );

    emit TransactionRefunded(txId, txn.amount);
}

Clarity refund

(define-public (refund
  (tx-id (buff 32))
  (token-contract <sip010-trait>)
)
  (let ((tx (map-get? transactions tx-id)))
    (begin
      (asserts! (is-eq tx-sender (var-get owner)) err-not-owner)
      (asserts! (is-some tx) err-transaction-not-found)

      (let ((txn (unwrap! tx err-transaction-data-error)))
        (asserts! (not (get is-completed txn)) err-already-processed)
        (asserts! (not (get is-refunded txn)) err-already-processed)

        ;; Refund amount to user
        (try!
          (contract-call? token-contract transfer
            (get amount txn)
            (as-contract (get user txn))
            (unwrap! (var-get vault-address) err-invalid-address)
            none
          )
        )

        ;; Mark as refunded
        (map-set transactions tx-id
          (merge txn { is-refunded: true })
        )

        (ok true)
      )
    )
  )
)

VERY INTERESTING NOTE: The Clarity version requires the token contract as a parameter, while Solidity can look it up from storage. This is because Clarity is more explicit about dependencies. Had a hard time figuring it out.

Read-Only Functions: Get Functions

Solidity getTransactionsByAddress

function getTransactionsByAddress(address user)
    external
    view
    returns (Transaction[] memory)
{
    bytes32[] memory txIds = userTransactionIds[user];
    Transaction[] memory userTransactions = new Transaction[](txIds.length);

    for (uint256 i = 0; i < txIds.length; i++) {
        userTransactions[i] = transactions[txIds[i]];
    }

    return userTransactions;
}

Clarity get-transaction-ids

(define-read-only (get-transaction-ids (user principal))

(default-to (list) (map-get? user-transaction-ids user))

)

Big difference: Clarity doesn't have loops in the same way Solidity does. Instead of iterating through arrays, you'd typically use list operations or handle this differently. The Clarity version just returns the list of transaction IDs, and you'd need to fetch each transaction individually.

Testing: Making Sure Everything Works

Now let's talk about testing! This is where things get really interesting because testing in Clarity is quite different from Solidity. I spent almost seven days writing Clarity tests to get used to it. I must say, the learning curve with Clarity is not too steep because once you get the hang of it, it sticks.

Solidity Testing (Hardhat)

describe("FiatBridge", function () {
  let fiatBridge;
  let mockToken;
  let owner;
  let user;
  let txManager;

  beforeEach(async function () {
    [owner, user, txManager] = await ethers.getSigners();

    const MockToken = await ethers.getContractFactory("MockERC20");
    mockToken = await MockToken.deploy();

    const FiatBridge = await ethers.getContractFactory("FiatBridge");
    fiatBridge = await FiatBridge.deploy(
      100,                 // spreadFeePercentage
      txManager.address,   // transactionManager
      owner.address,       // feeReceiver
      owner.address        // vaultAddress
    );

    await mockToken.mint(user.address, ethers.utils.parseEther("1000"));
    await fiatBridge.addSupportedToken(mockToken.address);
  });

  it("should initiate transaction", async function () {
    await mockToken
      .connect(user)
      .approve(fiatBridge.address, ethers.utils.parseEther("100"));

    const tx = await fiatBridge.connect(user).initiateFiatTransaction(
      mockToken.address,
      ethers.utils.parseEther("100"),
      12345678,
      50000,
      "Test Bank",
      "John Doe"
    );

    expect(tx).to.emit(fiatBridge, "TransactionInitiated");
  });
});

Clarity Testing (Vitest)

import { describe, expect, it, beforeEach } from "vitest";
import { Cl } from "@stacks/transactions";

const accounts = simnet.getAccounts();

const owner = accounts.get("deployer")!;
const tx_manager = accounts.get("wallet_1")!;
const fee_recv = accounts.get("wallet_2")!;
const vault = accounts.get("wallet_3")!;
const user = accounts.get("wallet_4")!;

describe("defi-direct: unit tests", () => {
  beforeEach(() => {
    const fee = Cl.uint(100);

    simnet.callPublicFn(
      "defi-direct",
      "initializer",
      [
        fee,
        Cl.principal(tx_manager),
        Cl.principal(fee_recv),
        Cl.principal(vault),
      ],
      owner
    );
  });

  it("should initiate fiat transaction successfully", () => {
    const amount = Cl.uint(1000);
    const fiatBankAccount = Cl.uint(12345678);
    const fiatAmount = Cl.uint(500);
    const fiatBank = Cl.stringAscii("TestBank");
    const recipientName = Cl.stringAscii("Alice");

    const { result } = simnet.callPublicFn(
      "defi-direct",
      "initiate-fiat-transaction",
      [
        Cl.contractPrincipal(owner, mockTokenContract),
        amount,
        fiatBankAccount,
        fiatAmount,
        fiatBank,
        recipientName,
      ],
      user
    );

    if (result.type === "ok") {
      expect(result.value).toBeDefined();

      const txId = result.value;

      const { result: txResult } = simnet.callReadOnlyFn(
        "defi-direct",
        "get-transaction",
        [txId],
        owner
      );

      expect(txResult.value).toBeDefined();
    }
  });
});

Key testing differences:

- Simnet vs Hardhat: Clarity uses a simulated network called "simnet" instead of Hardhat's local blockchain.

- Account management: Instead of ethers.getSigners(), you get accounts from simnet.getAccounts().

- Function calls: simnet.callPublicFn() instead of contract.function().

- Type handling: Clarity uses Cl.uint(), Cl.principal(), etc. to create typed values.

- Result handling: Clarity functions return (ok value) or (err code), so you check result.type.

Integration Testing

Clarity Integration Test

describe("defi-direct: integration tests", () => {
  beforeEach(() => {
    // Mint tokens to user
    simnet.callPublicFn(
      "mock-sip010",
      "mint",
      [Cl.uint(100000), Cl.principal(user)],
      owner
    );

    // Initialize contract
    const fee = Cl.uint(250);
    simnet.callPublicFn(
      "defi-direct",
      "initializer",
      [
        fee,
        Cl.principal(tx_manager),
        Cl.principal(fee_recv),
        Cl.principal(vault),
      ],
      owner
    );

    // Add supported token
    const mockTokenPrincipal = `${owner}.${mockTokenContract}`;
    simnet.callPublicFn(
      "defi-direct",
      "add-supported-token",
      [Cl.principal(mockTokenPrincipal)],
      owner
    );
  });

  it("should reject transaction when paused", () => {
    // Pause the contract
    simnet.callPublicFn("defi-direct", "pause", [], owner);

    const { result } = simnet.callPublicFn(
      "defi-direct",
      "initiate-fiat-transaction",
      [
        Cl.contractPrincipal(owner, mockTokenContract),
        Cl.uint(1000),
        Cl.uint(12345678),
        Cl.uint(500),
        Cl.stringAscii("Test Bank"),
        Cl.stringAscii("John Doe"),
      ],
      user
    );

    expect(result.type).toBe("err");
    expect(result.value.value).toBe(105n); // err-paused
  });
});

The Big Differences: What Makes Clarity Special

1. Immutability

In Solidity, you can modify variables directly:

txn.isCompleted = true;

In Clarity, you create new values:

(map-set transactions tx-id (merge txn { is-completed: true }))

2. Explicit Error Handling

Solidity uses strings:

require(amount > 0, "Amount must be greater than zero");

Clarity uses codes:

(asserts! (> amount u0) err-invalid-amount

3. No Loops

Solidity can iterate:

for (uint256 i = 0; i < txIds.length; i++) {

userTransactions[i] = transactions[txIds[i]];

}

Clarity uses list operations:

(append existing-ids tx-id)

4. Trait-Based Interfaces

Solidity uses interfaces:

interface IERC20 {

function transfer(address to, uint256 amount) external returns (bool);

}

Clarity uses traits:

(define-trait sip010-trait (

(transfer (uint principal principal (optional principal)) (response bool uint))

))

5. Optional Values

Solidity uses zero addresses:

address public feeReceiver; // might be address(0)

Clarity uses optionals:

(define-data-var fee-receiver (optional principal) none)

Performance and Gas Considerations

Security Differences

Solidity Security Concerns

- Reentrancy attacks (need ReentrancyGuard)

- Integer overflow/underflow (need SafeMath or ^0.8.0)

- Access control (need onlyOwner modifiers)

Clarity Security Features

- No reentrancy possible (by design)

- No integer overflow/underflow

- Explicit access control with asserts!

Lessons Learned: The Good, The Bad, and The Ugly

The Good

1. Clarity is safer by design - No reentrancy, no overflow, no hidden surprises

2. Better error handling - Numeric error codes are more efficient

3. Explicit dependencies - You can't accidentally call the wrong contract

The Bad

1. More verbose - Everything needs to be explicit

2. No loops - Some operations are more complex

3. Learning curve - Lisp-like syntax is unfamiliar to most developers, making the learning curve quite steep.

The Ugly

1. Immutable everything - Can't modify existing data, only create new

2. List size limits - Everything has explicit size constraints

3. Verbose error handling - Need to define every possible error

4. No inheritance - Can't extend contracts like in Solidity

Conclusion: Was It Worth It?

Honestly? It depends on what you're building. For DeFi Direct, the security benefits of Clarity are huge. No reentrancy attacks, no overflow issues, no hidden surprises. But the development experience is definitely more challenging.

The biggest takeaway is that Clarity forces you to think differently about smart contracts. It's not just a different syntax - it's a different philosophy. Solidity says "here's a powerful language, be careful with it." Clarity says "here's a safe language, we'll prevent you from making mistakes."

For a DeFi protocol handling real money, that safety is worth the extra verbosity

The testing experience is also quite different. Clarity's simnet is more integrated with the development environment, but the testing patterns are less familiar to most developers.

In the end, both languages can create the same features. It just depends on whether you prefer the flexibility of Solidity or the safety and predictability of Clarity.

For DeFi Direct, I'm happy with the Clarity version. It's more secure, more predictable, and gives me confidence that the contract will behave exactly as expected.

And that's my journey from Solidity to Clarity! I hope you enjoyed this deep dive into the differences between these two smart contract languages. If you're considering making the same switch, just remember: it's not just a syntax change, it's a whole new way of thinking about smart contracts. It really opens your mind.

0
Subscribe to my newsletter

Read articles from Joseph Aleonomoh directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Joseph Aleonomoh
Joseph Aleonomoh