DEV Community

Cover image for Tokenizing Investments with NFTs on Equillar
Nacho Colomina Torregrosa
Nacho Colomina Torregrosa

Posted on

Tokenizing Investments with NFTs on Equillar

Introduction

A few days ago I faced an interesting problem: how to manage multiple investments per user in the Equillar contract? The traditional solution would be something like Map<Address, Vec<Investment>>, but then I wondered: what if each project was an NFT and each investment a minted token id?

Let me tell you how I implemented it using the OpenZeppelin's stellar-tokens.

The Original Problem

Imagine an investment contract where:

  • Users can invest multiple times
  • Each investment has independent monthly payments
  • Users should be able to sell/transfer their investments

With the traditional approach, transferring a specific investment would require complicated custom logic. With NFTs, it comes almost for free.

The Solution: NFTs as Investment Certificates

Every time a contract is deployed, the constructor:

  • Loads the project investment conditions (rate, goal, return months etc ...).
  • Stores the NFT metadata (token_uri, name and symbol).
  • Foreach investment a new token id is minted. This ID token relates the address that have invested with its corresponding data (earnings, payment start date, etc.)

Let's explore it step by step:

Basic Setup

First, we add the dependency:

[dependencies]
stellar-tokens = "0.6.0"
Enter fullscreen mode Exit fullscreen mode

And make our contract implements the OpenZeppelin's NonFungibleToken trait:

use stellar_tokens::non_fungible::{Base, NonFungibleToken};

#[contractimpl(contracttrait)]
impl NonFungibleToken for InvestmentContract {
    type ContractType = Base;   
}
Enter fullscreen mode Exit fullscreen mode

That's it. You now have working NFT.

Constructor: Initializing the Collection

In the constructor, you need to configure the NFT collection metadata:

pub fn __constructor(
        env: Env,
        owner_addr: Address,
        project_address: Address,
        token_addr: Address,
        uri: String,
        name: String,
        symbol: String,
        investment_params: InvestmentContractParams
    ) -> Result<(), Error> {
        owner_addr.require_auth();
        // validations ...
        InvestmentReturnType::from_number(investment_params.return_type).ok_or(Error::UnsupportedReturnType)?;

        ownable::set_owner(&env, &owner_addr);
        let contract_data = ContractData::from_investment_contract_params(&investment_params, token_addr, project_address);

        // Add NFT metadata
        Base::set_metadata(&env, uri, name, symbol);
        Storage::update_contract_data(&env, &contract_data);
        Ok(())
    }
Enter fullscreen mode Exit fullscreen mode

What do these parameters do?

  • uri: Base URL for metadata (e.g., "https://project.com")
  • name: Collection name (e.g., "InvestmentNFT")
  • symbol: Short symbol (e.g., "INVST")

Minting: A new Token ID for each investment

Here's the magic. When someone invests, we mint a new token and link it to the investment data:

pub fn invest(env: Env, addr: Address, amount: i128) -> Result<Investment, Error> {
    addr.require_auth();

    // 1. Validations
    let contract_data = Storage::get_contract_data(&env);
    validation::validate_investment(amount, &contract_data, ...)?;

    // 2. Transfer tokens to contract
    let tk = get_token(&env, &contract_data);
    tk.transfer(&addr, &env.current_contract_address(), &amount)?;

    // 3. Mint the NFT (sequential: 0, 1, 2, ...)
    let token_id = Base::sequential_mint(&env, &addr);

    // 4. Create and store investment data using token_id as key
    let investment = Investment::new(&env, &contract_data, &amount, decimals, token_id);
    Storage::update_investment(&env, token_id, &investment);

    Ok(investment)
}
Enter fullscreen mode Exit fullscreen mode

The important part:

  • Base::sequential_mint(&env, &addr) mints a new token to the investor's address and returns the token_id
  • We use that token_id as the key to store the investment data
  • The NFT and investment are linked forever

Storage: Two Connected Worlds

We have two independent but related storages:

NFT Storage (stellar-tokens):

NFTStorageKey::Owner(token_id)  Address  // Who owns the NFT
Enter fullscreen mode Exit fullscreen mode

Investment Storage (ours):

DataKey::Investment(token_id)  Investment  // That investment's data
Enter fullscreen mode Exit fullscreen mode

When transferring the NFT, only the first storage changes. The investment data stays there, but now "belongs" to the new NFT owner.

Claiming Payments

When a user wants to claim their payments, we use the NFT to verify ownership:

pub fn claim(env: Env, token_id: u32) -> Result<Investment, Error> {
    // 1. Verify caller owns the NFT
    let addr: Address = Self::owner_of(&env, token_id);
    addr.require_auth();

    // 2. Get investment data
    let mut investment = Storage::get_investment(&env, token_id)
        .ok_or(Error::AddressHasNotInvested)?;

    // 3. Process pending payments
    let amount = investment.process_payments(&env, &contract_data);

    // 4. Transfer tokens
    let tk = get_token(&env, &contract_data);
    tk.try_transfer(&env.current_contract_address(), &addr, &amount)?;

    // 5. Update state
    Storage::update_investment_with_claim(&env, token_id, &investment);

    Ok(investment)
}
Enter fullscreen mode Exit fullscreen mode

Self::owner_of(&env, token_id) tells us who currently owns the NFT. If Alice sold her NFT #42 to Bob, owner_of(42) now returns Bob's address.

Free Secondary Market

This is the best part: any Stellar NFT-compatible marketplace can list these tokens. The flow would be:

  1. Alice invests 1000 USDC → receives NFT #42
  2. Alice lists NFT #42 on a marketplace for 1100 USDC
  3. Bob buys the NFT
  4. Bob can now claim the remaining monthly payments

You don't need extra code. When transferring the NFT, the payment rights go with it.

Methods You Get Automatically

By implementing NonFungibleToken, you get for free:

  • balance(account) — how many investment NFTs a user has
  • owner_of(token_id) — who is the current owner
  • transfer(from, to, token_id) — transfer the NFT
  • approve(approver, approved, token_id) — give permission to a third party (approved)
  • token_uri(token_id) — metadata URL

All work out-of-the-box. You can call them from other contracts or from a frontend.

Advantages of This Approach

Advantages:

  • ✅ Native transferability of investments
  • ✅ Compatibility with explorers and wallets
  • ✅ Less custom code
  • ✅ Multiple investments per user without complications
  • ✅ Each investment is independent

Tradeoffs:

  • ⚠️ You need to manage an additional token_id
  • ⚠️ Minting has a cost (small, but exists)

Conclusion

Using NFTs to manage investments turned out to be more elegant than I expected. The code is simpler, transferability is native, and ecosystem integration comes for free.

Is it the right approach for everything? No, but it's useful because it allows easily assigning more than one investment per user in the same project and transferring them easily if the user wants to.

If you're building something similar, check out OpenZeppelin's stellar-tokens. The library does the heavy lifting for you.


Resources

Top comments (0)