Xây dựng dapp trên cardano từ con số không
About Lesson

Writing Test Cases and Offchain Code for Smart Contracts

Bài viết này hướng dẫn bạn cách viết test cases cho hợp đồng thông minh NFT Marketplace sử dụng ngôn ngữ Aiken, cũng như migrate logic sang offchain code bằng thư viện MeshJS (MJS) để tương tác thực tế trên blockchain Cardano. Nội dung dựa trên video hướng dẫn, tập trung vào các trường hợp test và xây dựng giao dịch (sale, buy, withdraw, update). Bài viết được thiết kế để bạn có thể thực hiện mà không cần xem video.

Điều Kiện Tiên Quyết

  • Aiken phiên bản 1.1.19 trở lên đã cài đặt.
  • Node.js và npm để quản lý dự án TypeScript.
  • Thư viện MeshJS phiên bản 1.8.14, Vitest cho testing, và dotenv cho biến môi trường.
  • Ví Cardano (như Eternl) với địa chỉ testnet và một số ADA để test giao dịch.
  • Project ID từ Blockfrost để kết nối blockchain testnet.

Bước 1: Viết Test Cases Trong Aiken

Trong Aiken, chúng ta tập trung vào validator spend với hai redeemer: Buy và WithdrawOrUpdate. Test cases kiểm tra các trường hợp thành công và thất bại.

  1. Tạo File Test:

    • Tạo file marketplace_test.aiken trong dự án Aiken.
  2. Định Nghĩa Datum Mẫu:

    fn mock_datum(id: Int) -> MarketplaceDatum {
      MarketplaceDatum {
        seller: mock_address(0),
        price: 200_000_000, // 200 ADA
        policy_id: mock_policy_id(id),
        asset_name: "test"
      }
    }
     
  3. Hàm Tạo Giao Dịch Test:

    • Tạo các hàm như get_buy_txget_withdraw_txget_update_tx để mô phỏng giao dịch với các tham số (e.g., only_one_input_from_script, is_payment_valid, is_seller_signed).
  4. Các Test Cases:

    • Test thành công cho Buy (chỉ một input, thanh toán đúng).
    • Test thất bại cho Buy (nhiều input, thanh toán không đủ).
    • Test thành công cho Withdraw và Update (người ký là seller).
    • Test thất bại cho Withdraw và Update (người ký không phải seller).

    Ví dụ test:

    test success_buy() {
      let tx = get_buy_tx(true, true)
      marketplace.validator(mock_datum(0), Buy, tx.reference, tx)
    }
    
    test fail_buy_with_multiple_inputs() {
      let tx = get_buy_tx(false, true)
      !marketplace.validator(mock_datum(0), Buy, tx.reference, tx)
    }
     
  5. Chạy Test:

    • Chạy lệnh aiken check để kiểm tra tất cả test cases pass.

Bước 2: Migrate Sang Offchain Code Với MeshJS

Chuyển logic sang TypeScript sử dụng MeshJS để xây dựng giao dịch thực tế.

  1. Khởi Tạo Dự Án:

    • Chạy npm init -y để tạo package.json.
    • Cài đặt thư viện: npm install @meshsdk/core@1.8.14 vitest dotenv.
    • Tạo file .gitignore (thêm node_modules và .env).
    • Tạo file .env với BLOCKFROST_PROJECT_ID và mnemonic cho ví test.
  2. Cấu Trúc Dự Án:

    • Tạo folder test với marketplace.test.ts.
    • Tạo folder src với mesh_adapter.ts và index.ts.
  3. File mesh_adapter.ts (Adapter cho MeshJS):

    import { MeshTxBuilder, BrowserWallet } from '@meshsdk/core';
    import * as dotenv from 'dotenv';
    
    dotenv.config();
    
    export class MeshAdapter {
      wallet: BrowserWallet;
      provider: any; // BlockfrostProvider
      meshBuilder: MeshTxBuilder;
    
      constructor(wallet: BrowserWallet, provider: any) {
        this.wallet = wallet;
        this.provider = provider;
        this.meshBuilder = new MeshTxBuilder({ network: process.env.BLOCKFROST_PROJECT_ID.startsWith('preprod') ? 0 : 1 });
        // Load marketplace compiled code from Aiken build
        this.marketplaceCompiledCode = '/* Compiled code from Aiken */';
      }
    
      // Các hàm helper: getUtxosForTx, getUtxoByTxHash, readDatum, etc.
    }
     
  4. File index.ts (Marketplace Contract):

    import { MeshAdapter } from './mesh_adapter';
    
    export class MarketplaceContract extends MeshAdapter {
      async sale(unit: string, priceInLovelace: number): Promise<string> {
        // Build tx: add output to script address, attach datum
        // Return unsigned tx as hex
      }
    
      async buy(unit: string): Promise<string> {
        // Query UTXO from script, build tx with input from script, redeemer Buy
      }
    
      async withdraw(unit: string): Promise<string> {
        // Build tx to withdraw UTXO back to wallet
      }
    
      async update(unit: string, newPriceInLovelace: number): Promise<string> {
        // Build tx to update datum with new price
      }
    }
     
  5. File marketplace.test.ts (Test Offchain):

    • Sử dụng Vitest để test các hàm sale, buy, withdraw, update.
    • Khởi tạo wallet và provider từ .env.
    • Test bằng cách build unsigned tx, sign, submit, và kiểm tra tx hash.

    Ví dụ:

    import { describe, it, expect, beforeEach } from 'vitest';
    import { MarketplaceContract } from '../src/index';
    
    describe('Marketplace Tests', () => {
      let contract: MarketplaceContract;
    
      beforeEach(async () => {
        // Init wallet and provider
      });
    
      it('should sale NFT', async () => {
        const unsignedTx = await contract.sale('unit_here', 100_000_000);
        // Sign and submit, expect tx hash length 64
      });
    
      // Tương tự cho buy, withdraw, update
    });
     
  6. Chạy Test:

    • Cập nhật package.json scripts: "test": "vitest run".
    • Chạy npm run test để kiểm tra.

Giải Thích Tổng Quan

  • Test Trong Aiken: Kiểm tra logic validator với các trường hợp thành công/thất bại.
  • Offchain Với MeshJS: Xây dựng giao dịch thực tế, sử dụng Blockfrost để query blockchain, và xử lý datum/redeemer.
  • Các hàm chính: sale (liệt kê NFT), buy (mua), withdraw (rút/delisting), update (cập nhật giá).

Bước 6: Kiểm Tra Và Debug

  • Sử dụng console.log để xem unsigned tx.
  • Kiểm tra giao dịch trên explorer như Preprod Cardano Scan.
  • Xử lý lỗi: Đảm bảo UTXO tồn tại, datum đúng, và signer hợp lệ.

Kết Luận

Bài viết cung cấp hướng dẫn hoàn chỉnh để test và triển khai offchain cho NFT Marketplace. Trong các bước tiếp theo, bạn có thể tích hợp vào ứng dụng Next.js với API để người dùng tương tác qua ví browser.

Nếu cần thêm chi tiết, tham khảo tài liệu MeshJS tại https://meshjs.dev hoặc Aiken tại https://aiken-lang.org.

Bài Tập

📝 Bài tập 1: Tạo file test cho smart contract Marketplace

Đề bài

Khởi tạo file test để kiểm thử logic của smart contract Marketplace viết bằng Aiken.

Yêu cầu

  • Tạo file marketplace.test trong thư mục tests/.
  • Viết test đầu tiên kiểm tra khi người mua gửi đúng số ADA, giao dịch hợp lệ.
  • Dùng lệnh aiken test để chạy.
Cách giải

Dùng lệnh tạo file test trong dự án Aiken và viết test cơ bản với expect true.

Đáp án

use cardano/assets.{add, from_lovelace}
use cardano/transaction.{InlineDatum, Input, Transaction}
use marketplace.{Buy, MarketplaceDatum, WithdrawOrUpdate}
use mocktail.{
  add_input, complete, mock_script_address, mock_script_output, mock_tx_hash,
  mock_utxo_ref, mocktail_tx, required_signer_hash, tx_in, tx_in_inline_datum,
  tx_out, tx_out_inline_datum,
}
use mocktail/virgin_address.{mock_pub_key_address}
use mocktail/virgin_key_hash.{mock_policy_id, mock_pub_key_hash}

fn mock_datum() -> MarketplaceDatum {
  MarketplaceDatum {
    seller: mock_pub_key_address(0, None),
    price: 200_000_000,
    asset_name: "Test NFT",
    policy_id: mock_policy_id(0),
  }
}

fn get_buy_test_tx(
  is_only_one_input_from_script: Bool,
  is_process_paid: Bool,
) -> Transaction {
  let input_value =
    from_lovelace(2_000_000) |> add(mock_policy_id(0), "Test NFT", 1)

  mocktail_tx()
    |> tx_out(
        True,
        mock_pub_key_address(0, None),
        if is_process_paid {
          from_lovelace(202_000_000)
        } else {
          from_lovelace(100_000_000)
        },
      )
    |> complete()
    |> add_input(
        True,
        Input {
          output_reference: mock_utxo_ref(0, 1),
          output: mock_script_output(
            mock_script_address(0, None),
            input_value,
            InlineDatum(Some(mock_datum())),
          ),
        },
      )
    |> add_input(
        !is_only_one_input_from_script,
        Input {
          output_reference: mock_utxo_ref(0, 2),
          output: mock_script_output(
            mock_script_address(0, None),
            input_value,
            InlineDatum(Some(mock_datum())),
          ),
        },
      )
}

fn get_withdraw_test_tx(is_seller_signed: Bool) {
  mocktail_tx()
    |> tx_in(
        True,
        mock_tx_hash(0),
        1,
        from_lovelace(1_000_000),
        mock_script_address(0, None),
      )
    |> tx_in_inline_datum(True, mock_datum())
    |> required_signer_hash(
        True,
        if is_seller_signed {
          mock_pub_key_hash(0)
        } else {
          mock_pub_key_hash(5)
        },
      )
    |> complete()
}

fn get_update_test_tx(is_seller_signed: Bool) {
  let new_datum =
    MarketplaceDatum {
      seller: mock_pub_key_address(0, None),
      price: 500_000_000,
      asset_name: "Test NFT",
      policy_id: mock_policy_id(0),
    }

  mocktail_tx()
    |> tx_in(
        True,
        mock_tx_hash(0),
        1,
        from_lovelace(1_000_000),
        mock_script_address(0, None),
      )
    |> tx_in_inline_datum(True, mock_datum())
    |> tx_out_inline_datum(True, new_datum)
    |> required_signer_hash(
        True,
        if is_seller_signed {
          mock_pub_key_hash(0)
        } else {
          mock_pub_key_hash(5)
        },
      )
    |> complete()
}

test success_buy() {
  let output_reference = mock_utxo_ref(0, 1)
  let redeemer = Buy
  let is_only_one_input_from_script = True
  let is_process_paid = True

  let tx = get_buy_test_tx(is_only_one_input_from_script, is_process_paid)

  marketplace.marketplace.spend(
    Some(mock_datum()),
    redeemer,
    output_reference,
    tx,
  )
}

test fail_buy_with_mutiple_script_input() {
  let output_reference = mock_utxo_ref(0, 1)
  let redeemer = Buy
  let is_only_one_input_from_script = False
  let is_process_paid = True

  let tx = get_buy_test_tx(is_only_one_input_from_script, is_process_paid)

  !marketplace.marketplace.spend(
    Some(mock_datum()),
    redeemer,
    output_reference,
    tx,
  )
}

test fail_buy_without_proceed_paid() {
  let output_reference = mock_utxo_ref(0, 1)
  let redeemer = Buy
  let is_only_one_input_from_script = True
  let is_process_paid = False

  let tx = get_buy_test_tx(is_only_one_input_from_script, is_process_paid)

  !marketplace.marketplace.spend(
    Some(mock_datum()),
    redeemer,
    output_reference,
    tx,
  )
}

test success_withdraw() {
  let output_reference = mock_utxo_ref(0, 0)
  let redeemer = WithdrawOrUpdate
  let is_seller_signed = True

  let tx = get_withdraw_test_tx(is_seller_signed)

  marketplace.marketplace.spend(
    Some(mock_datum()),
    redeemer,
    output_reference,
    tx,
  )
}

test fail_withdraw_without_signature() {
  let output_reference = mock_utxo_ref(0, 0)
  let redeemer = WithdrawOrUpdate
  let is_seller_signed = False

  let tx = get_withdraw_test_tx(is_seller_signed)

  !marketplace.marketplace.spend(
    Some(mock_datum()),
    redeemer,
    output_reference,
    tx,
  )
}

test success_update() {
  let output_reference = mock_utxo_ref(0, 0)
  let redeemer = WithdrawOrUpdate
  let is_seller_signed = True

  let tx = get_update_test_tx(is_seller_signed)

  marketplace.marketplace.spend(
    Some(mock_datum()),
    redeemer,
    output_reference,
    tx,
  )
}

test fail_update_without_signature() {
  let output_reference = mock_utxo_ref(0, 0)
  let redeemer = WithdrawOrUpdate
  let is_seller_signed = False

  let tx = get_update_test_tx(is_seller_signed)

  !marketplace.marketplace.spend(
    Some(mock_datum()),
    redeemer,
    output_reference,
    tx,
  )
}
 

📝 Bài tập 2: Viết test case cho hàm buy khi người mua gửi sai số tiền

Đề bài

Kiểm tra trường hợp giao dịch không hợp lệ khi người mua gửi sai số tiền.

Yêu cầu

  • Thêm test mới trong marketplace.test.
  • Kiểm tra giá trị ADA nhỏ hơn giá NFT.
  • Kết quả mong đợi: test phải fail.
Cách giải

So sánh giá trị ada_sent khác nft_price trong test.

Đáp án

use cardano/assets.{add, from_lovelace}
use cardano/transaction.{InlineDatum, Input, Transaction}
use marketplace.{Buy, MarketplaceDatum, WithdrawOrUpdate}
use mocktail.{
  add_input, complete, mock_script_address, mock_script_output, mock_tx_hash,
  mock_utxo_ref, mocktail_tx, required_signer_hash, tx_in, tx_in_inline_datum,
  tx_out, tx_out_inline_datum,
}
use mocktail/virgin_address.{mock_pub_key_address}
use mocktail/virgin_key_hash.{mock_policy_id, mock_pub_key_hash}

fn mock_datum() -> MarketplaceDatum {
  MarketplaceDatum {
    seller: mock_pub_key_address(0, None),
    price: 200_000_000,
    asset_name: "Test NFT",
    policy_id: mock_policy_id(0),
  }
}

fn get_buy_test_tx(
  is_only_one_input_from_script: Bool,
  is_process_paid: Bool,
) -> Transaction {
  let input_value =
    from_lovelace(2_000_000) |> add(mock_policy_id(0), "Test NFT", 1)

  mocktail_tx()
    |> tx_out(
        True,
        mock_pub_key_address(0, None),
        if is_process_paid {
          from_lovelace(202_000_000)
        } else {
          from_lovelace(100_000_000)
        },
      )
    |> complete()
    |> add_input(
        True,
        Input {
          output_reference: mock_utxo_ref(0, 1),
          output: mock_script_output(
            mock_script_address(0, None),
            input_value,
            InlineDatum(Some(mock_datum())),
          ),
        },
      )
    |> add_input(
        !is_only_one_input_from_script,
        Input {
          output_reference: mock_utxo_ref(0, 2),
          output: mock_script_output(
            mock_script_address(0, None),
            input_value,
            InlineDatum(Some(mock_datum())),
          ),
        },
      )
}

fn get_withdraw_test_tx(is_seller_signed: Bool) {
  mocktail_tx()
    |> tx_in(
        True,
        mock_tx_hash(0),
        1,
        from_lovelace(1_000_000),
        mock_script_address(0, None),
      )
    |> tx_in_inline_datum(True, mock_datum())
    |> required_signer_hash(
        True,
        if is_seller_signed {
          mock_pub_key_hash(0)
        } else {
          mock_pub_key_hash(5)
        },
      )
    |> complete()
}

fn get_update_test_tx(is_seller_signed: Bool) {
  let new_datum =
    MarketplaceDatum {
      seller: mock_pub_key_address(0, None),
      price: 500_000_000,
      asset_name: "Test NFT",
      policy_id: mock_policy_id(0),
    }

  mocktail_tx()
    |> tx_in(
        True,
        mock_tx_hash(0),
        1,
        from_lovelace(1_000_000),
        mock_script_address(0, None),
      )
    |> tx_in_inline_datum(True, mock_datum())
    |> tx_out_inline_datum(True, new_datum)
    |> required_signer_hash(
        True,
        if is_seller_signed {
          mock_pub_key_hash(0)
        } else {
          mock_pub_key_hash(5)
        },
      )
    |> complete()
}

test success_buy() {
  let output_reference = mock_utxo_ref(0, 1)
  let redeemer = Buy
  let is_only_one_input_from_script = True
  let is_process_paid = True

  let tx = get_buy_test_tx(is_only_one_input_from_script, is_process_paid)

  marketplace.marketplace.spend(
    Some(mock_datum()),
    redeemer,
    output_reference,
    tx,
  )
}

test fail_buy_with_mutiple_script_input() {
  let output_reference = mock_utxo_ref(0, 1)
  let redeemer = Buy
  let is_only_one_input_from_script = False
  let is_process_paid = True

  let tx = get_buy_test_tx(is_only_one_input_from_script, is_process_paid)

  !marketplace.marketplace.spend(
    Some(mock_datum()),
    redeemer,
    output_reference,
    tx,
  )
}

test fail_buy_without_proceed_paid() {
  let output_reference = mock_utxo_ref(0, 1)
  let redeemer = Buy
  let is_only_one_input_from_script = True
  let is_process_paid = False

  let tx = get_buy_test_tx(is_only_one_input_from_script, is_process_paid)

  !marketplace.marketplace.spend(
    Some(mock_datum()),
    redeemer,
    output_reference,
    tx,
  )
}

test success_withdraw() {
  let output_reference = mock_utxo_ref(0, 0)
  let redeemer = WithdrawOrUpdate
  let is_seller_signed = True

  let tx = get_withdraw_test_tx(is_seller_signed)

  marketplace.marketplace.spend(
    Some(mock_datum()),
    redeemer,
    output_reference,
    tx,
  )
}

test fail_withdraw_without_signature() {
  let output_reference = mock_utxo_ref(0, 0)
  let redeemer = WithdrawOrUpdate
  let is_seller_signed = False

  let tx = get_withdraw_test_tx(is_seller_signed)

  !marketplace.marketplace.spend(
    Some(mock_datum()),
    redeemer,
    output_reference,
    tx,
  )
}

test success_update() {
  let output_reference = mock_utxo_ref(0, 0)
  let redeemer = WithdrawOrUpdate
  let is_seller_signed = True

  let tx = get_update_test_tx(is_seller_signed)

  marketplace.marketplace.spend(
    Some(mock_datum()),
    redeemer,
    output_reference,
    tx,
  )
}

test fail_update_without_signature() {
  let output_reference = mock_utxo_ref(0, 0)
  let redeemer = WithdrawOrUpdate
  let is_seller_signed = False

  let tx = get_update_test_tx(is_seller_signed)

  !marketplace.marketplace.spend(
    Some(mock_datum()),
    redeemer,
    output_reference,
    tx,
  )
}
 

📝 Bài tập 3: Cấu hình Mesh SDK và Blockfrost Provider

Đề bài

Cài đặt và cấu hình môi trường offchain với Mesh SDK.

Yêu cầu

  • Cài Mesh SDK bằng npm.
  • Tạo file .env lưu Project ID của Blockfrost.
  • Cấu hình provider trong code.

Cách giải

Cài Mesh và tạo provider với Project ID từ Blockfrost.

Cách giải
npm install @meshsdk/core @meshsdk/common
 
import { BlockfrostProvider } from "@meshsdk/core";

const provider = new BlockfrostProvider("mainnet", process.env.BLOCKFROST_ID);
 

📝 Bài tập 4: Viết hàm sell() trong offchain code

Đề bài

Tạo hàm TypeScript sale() để đăng bán NFT trên marketplace.

Yêu cầu

  • Truyền vào tham số: assetpricesellerAddress.
  • Dùng Mesh SDK để tạo giao dịch có metadata và output kèm datum.
  • Trả về hash giao dịch sau khi submit.
Cách giải

Dùng Mesh SDK với Transaction().sendAssets().attachMetadata() và submit().

Đáp án

import { Transaction } from "@meshsdk/core";

async function sale(asset, price, sellerAddress) {
  const tx = new Transaction({ initiator: sellerAddress })
    .sendAssets({ address: MARKETPLACE_ADDR, assets: { [asset]: 1 } })
    .attachMetadata(721, { price })
    .build();

  const txHash = await tx.submit();
  return txHash;
}
 

📝 Bài tập 5: Kiểm thử giao dịch offchain bằng Vitest

Đề bài

Viết test kiểm thử giao dịch sale() bằng Vitest.

Yêu cầu

  • Cài Vitest và viết test đơn giản gọi sale().
  • In ra txHash nếu giao dịch thành công.
  • Sử dụng mô phỏng (mock) provider khi test.
Cách giải

Sử dụng vi.fn() để tạo mock provider và xác minh hàm được gọi.

Đáp án

import { describe, it, expect, vi } from "vitest";
import { sale } from "./marketplace";

describe("Marketplace sale", () => {
  it("should return tx hash", async () => {
    const mockProvider = vi.fn().mockResolvedValue("mockTxHash123");
    const result = await sale("asset1", 100, "addr_test1...");
    expect(result).toBeDefined();
  });
});