NFT Wallets Unleashed: A Data Structures and Application Design Journey (Struktury danych i projektowanie aplikacji)

Niezależnie od tego, czy są Państwo zainteresowani szumem wokół NFT, czy też nie, jako inżynier oprogramowania, bycie na bieżąco z najnowszymi innowacjami ma kluczowe znaczenie. Zagłębianie się w technologie leżące u podstaw tak modnych funkcji jest zawsze fascynujące. Zazwyczaj wolę pozwolić, aby kurz opadł, zanim wskoczę, ale teraz wydaje się, że jest to dobry czas, aby zbadać “o co chodzi w NFT”.

Terminologia

NFT oznacza Tokeny niewymienialne. Tokeny niewymienialne to tokeny oparte na łańcuchu bloków, które reprezentują własność zasobu cyfrowego. Zasoby cyfrowe może być wszystko, od ręcznie stworzonego obrazu, piosenki, muzyki, wpisu na blogu, całej cyfrowej książki, a nawet pojedynczego tweeta (który jest w zasadzie publicznie dostępnym rekordem z bazy danych znanej spółki publicznej). Aktywa te mają wartość publiczną i mogą być czyjąś własnością.

W przeciwieństwie do zamiennych tokenów, takich jak Bitcoiny lub Ethereum, które można zastąpić identycznymi jednostkami (mają tę samą wartość i można je wymienić na inne), NFT są unikalne (nie można ich wymienić w równym stopniu), zapewniając własność unikalnych zasobów cyfrowych i egzekwując cyfrowe prawa autorskie i prawa dotyczące znaków towarowych. NFT są oparte na technologii blockchain, gwarantując własność i ułatwiając jej przeniesienie.

Co budujemy

Tworzymy prototyp portfela NFT przy użyciu aplikacji konsolowej C# z (jeszcze nie tak znanym) .NET CLI SDK. The System.CommandLine choć wciąż w wersji beta, jest obiecująca i umożliwia tworzenie czystych i wydajnych interfejsów wiersza poleceń.

Minimalne wymagania dla portfeli NFT są następujące:

  1. Proszę przechowywać historię własności tokenów.
  2. Obsługa transakcji Mint (tworzenie tokenów).
  3. Obsługa transakcji Burn (niszczenie tokenów).
  4. Obsługa transakcji transferu (zmiana właściciela).

Zakładamy, że transakcje są w formacie JSON, ale dla celów edukacyjnych odczytamy je ze sformatowanego JSON (tekst lub plik na dysku), ponieważ nie mamy prawdziwego serwera sieci blockchain.

Proszę zachować prostotę

Aby zachować prostotę, zignorujemy szczegóły, takie jak konkretne sieci blockchain, algorytmy generowania hashów dla unikalnych NFT i wybór trwałego przechowywania (w naszym prototypie użyjemy pliku XML na dysku).

API

Biorąc pod uwagę wspomniane wymagania i ograniczenia, będziemy wspierać następujące rozwiązania polecenia.

Read Inline ( — read-inline <json>)

Odczytuje pojedynczy element JSON lub tablicę elementów JSON reprezentujących transakcje jako argument.

$> program --read-inline '{"Type": "Burn", "TokenId": "0x..."}' 
$> program --read-inline '[{"Type": "Mint", "TokenId": "0x...", "Address": "0x..."}, {"Type": "Burn", "TokenId": "0x..."}]'

Read File ( — read-file <file>)

Odczytuje pojedynczy element JSON lub tablicę elementów JSON reprezentujących transakcje z określonej lokalizacji pliku.

$> program --read-file transactions.json

NFT Ownership ( — nft <id>)

Zwraca informacje o własności dla NFT o podanym ID.

Wallet Ownership ( — wallet <address>)

Wyświetla listę wszystkich NFT aktualnie posiadanych przez portfel o podanym adresie.

$> program --wallet 0x...

Reset ( — reset)

Usuwa wszystkie dane przetworzone wcześniej przez program.

Transakcje NFT

Z perspektywy transakcji portfelowych musimy obsługiwać trzy rodzaje operacji, jak poniżej.

Mint

{ 
  "Type": "Mint", 
  "TokenId": string, 
  "Address": string 
}

Transakcja mint tworzy nowy token w portfelu z podanym adresem.

Burn

{ 
  "Type": "Burn", 
  "TokenId": string 
}

Transakcja burn niszczy token o podanym identyfikatorze.

Transfer

{ 
  "Type": "Transfer", 
  "TokenId": string, 
  "From": string, 
  "To": string 
}

Transakcja transferu zmienia własność tokena poprzez usunięcie adresu portfela “z” i dodanie go do adresu portfela “do”.

Operacje transakcyjne

W poniższym przykładzie partii transakcji tworzymy trzy nowe tokeny, niszczymy jeden i przenosimy własność na inny:

[
    {
        "Type": "Mint",
        "TokenId": "0xA000000000000000000000000000000000000000",
        "Address": "0x1000000000000000000000000000000000000000"
    },
    {
        "Type": "Mint",
        "TokenId": "0xB000000000000000000000000000000000000000",
        "Address": "0x2000000000000000000000000000000000000000"
    },
    {
        "Type": "Mint",
        "TokenId": "0xC000000000000000000000000000000000000000",
        "Address": "0x3000000000000000000000000000000000000000"
    },
    {
        "Type": "Burn",
        "TokenId": "0xA000000000000000000000000000000000000000"
    },
    {
        "Type": "Transfer",
        "TokenId": "0xB000000000000000000000000000000000000000",
        "From": "0x2000000000000000000000000000000000000000",
        "To": "0x3000000000000000000000000000000000000000"
    }
]

Jak widać, tokeny są identyfikowane przez wyimaginowane wartości w formacie szesnastkowym. Adresy portfeli powinny być obsługiwane przez naszą bazową wyimaginowaną sieć blockchain. Weryfikacja tych wartości jest pomijana, koncentrując się na wydajności operacji i przechowywania w naszym portfelu NFT.

Projekt struktury danych

Aby obsłużyć wszystkie niezbędne operacje, musimy pomyśleć o wydajnym wykonaniu następujących trzech rodzajów zadań:

  • Dostarczane są trwałe informacje o relacji własności między wyimaginowanymi identyfikatorami tokenów NFT a adresami portfeli NFT.
  • Proszę szybko odpowiedzieć, który portfel zawiera token według identyfikatora tokena.
  • Szybka odpowiedź na pytanie, jakie tokeny są własnością określonych portfeli.
  • Skuteczna zmiana własności tokena między adresami portfeli.

Zaczynamy od stworzenia klasy reprezentującej pojedynczą transakcję.

public class Transaction
{
 // Transaction type: Mint, Burn, Transfer, etc. 
 // As a type, we may use enum here as well.
 [JsonProperty("Type", Required = Required.Always)]
 public string Type { get; set; }

 [JsonProperty("TokenId", Required = Required.Always)]
 public string TokenId { get; set; }

 // Address of the Wallet to own Token Id created (Minted)
 [JsonProperty("Address", Required = Required.Default)]
 public string Address { get; set; }

 // From Address of the Transfer operation.
 [JsonProperty("From", Required = Required.Default)]
 public string From { get; set; }

 // To Address of the Transfer operation.
 [JsonProperty("To", Required = Required.Default)]
 public string To { get; set; }
}

W świecie NFT właściciel jest reprezentowany przez adres portfela, a my dodajemy znacznik czasu, aby śledzić, kiedy nowy token jest tworzony lub przenoszony między portfelami.

public class OwnershipInfo
{
 [XmlElement("WalletAddress")]
 public string WalletAddress { get; set; }

 [XmlElement("Timestamp")]
 public DateTime Timestamp {  get; set; }
}

Najbardziej wydajne algorytmy powinny być wykonywane w czasie O(1), prawda? Kolekcje oparte na haszowaniu pozwalają nam obsługiwać operacje GET z wydajnością O(1), co oznacza, że musimy używać Dictionary< K, V> dla całego magazynu. Ale aby wszystkie operacje były wydajne, musimy poświęcić pamięć, ponieważ nie wystarczy mieć tylko jedną wydajną kolekcję. Zamiast tego będziemy używać wielu kolekcji w pamięci. Przyjrzyjmy się temu najpierw kawałek po kawałku, a następnie omówmy to rozwiązanie.

Proszę pamiętać, że w poniższym kodzie nie weryfikujemy identyfikatorów tokenów ani adresów portfeli.

Który portfel jest właścicielem tokena?

Ponieważ token może być własnością tylko jednego portfela, bezpośrednia mapa adres-adres pomiędzy Token ID (klucz) i Adres portfela (wartość). Dzięki temu możemy łatwo obsługiwać ” — nft“, odpowiadając na pytanie, kto jest właścicielem.

public class TokenStorage
{
 // To easily find owning wallet by NFT token.
 public Dictionary<string, string> NftTokenWalletMap { get; set; }
}

public async Task<string> FindWalletOwnerAsync(string tokenId)
{
 if (_tokenStorage.NftTokenWalletMap.ContainsKey(tokenId))
 {
  return await Task<string>.FromResult(_tokenStorage.NftTokenWalletMap[tokenId]);
 }

 return null;
}

Które tokeny są własnością portfela?

Aby skutecznie wyświetlić listę tokenów posiadanych przez portfel, należy utworzyć mapę Adresy portfeli (klucz) do list ich Identyfikatory tokenów (wartość) jest utrzymywana, dzięki czemu możemy łatwo obsługiwać operację “- - wallet“.

public class TokenStorage
{
 // To easily find list of owned Tokens in the wallet.
 public Dictionary<string, List<string>> WalletNftTokensMap { get; set; }
}

public async Task<List<string>> GetTokensAsync(string walletId)
{
 var result = new List<string>();

 if (_tokenStorage.WalletNftTokensMap.ContainsKey(walletId) &&
  _tokenStorage.WalletNftTokensMap[walletId] != null)
 {
  result = _tokenStorage.WalletNftTokensMap[walletId];

  result.Sort();
 }

 return await Task.FromResult(result);
}

Przeniesienie własności i historia

Aby skutecznie obsługiwać historię zmian własności dla każdego tokena, musimy mapować Token Id (klucz) do listy Adresy portfeli właścicieli (wartości). Lista ta musi być posortowana w taki sposób, abyśmy mogli sprawnie pobrać ostatnią z nich (ale nadal móc wyświetlić całą historię, gdy zajdzie taka potrzeba). Chcemy również efektywnie wstawiać nowe rekordy historii (do końca). Lista połączona jest tym, co dobrze pasuje do tej struktury danych historia-rejestr: pozwala nam wstawiać nowe rekordy i pobierać ostatni z wydajnością O(1).

public class TokenStorage
{
 // To easily change the ownership.
 public Dictionary<string, NFTToken> NftTokenOwnershipMap { get; set; }
}

public class NFTToken
{
 public string TokenId { get; set; }

 /// <summary>
 /// Allows to efficiently insert new owners.
 /// </summary>
 public LinkedList<OwnershipInfo> OwnershipInfo { get; set; }
}

Dzięki tym strukturom możemy efektywnie wspierać operacje bicia, wypalania i przenoszenia na NFT w TransactionManager. Proszę postępować zgodnie z komentarzami w kodzie.

Mint New Token

private bool MintNFTToken(string tokenId, string walletAddress)
{
 // Is token really new/unique?
 if (!_tokenStorage.NftTokenWalletMap.ContainsKey(tokenId))
 {
  // Do we know such wallet address?
  if (!_tokenStorage.WalletNftTokensMap.ContainsKey(walletAddress))
  {
   // Remember a new wallet address.
   _tokenStorage.WalletNftTokensMap.Add(walletAddress, new List<string>());
  }
  
  // Add token to the wallet to Wallet-Token records.
  _tokenStorage.WalletNftTokensMap[walletAddress].Add(tokenId);
  
  // Add Token-Wallet record.
  _tokenStorage.NftTokenWalletMap.Add(tokenId, walletAddress);

  // Create an Ownership entry in history
  var nftToken = new NFTToken
  {
   TokenId = tokenId,
   OwnershipInfo = new LinkedList<OwnershipInfo>()
  };

  // Insert the record
  nftToken.OwnershipInfo.AddFirst(
   new OwnershipInfo
   {
    WalletAddress = walletAddress,
    Timestamp = DateTime.Now
   });
  _tokenStorage.NftTokenOwnershipMap.Add(tokenId, nftToken);

  return true;
 }

 return false;
}

Burn Token

private void BurnNFTToken(string tokenId)
{
 if (_tokenStorage.NftTokenWalletMap.ContainsKey(tokenId))
 {
  string walletId = _tokenStorage.NftTokenWalletMap[tokenId];

  _tokenStorage.NftTokenWalletMap.Remove(tokenId);

  if (_tokenStorage.WalletNftTokensMap.ContainsKey(walletId))
  {
   _tokenStorage.WalletNftTokensMap.Remove(walletId);
  }
 }

 if (_tokenStorage.NftTokenOwnershipMap.ContainsKey(tokenId))
 {
  _tokenStorage.NftTokenOwnershipMap.Remove(tokenId);
 }
}

Transfer Token

private bool ChangeOwnership(string tokenId, string oldWalletAddress, string newWalletAddress)
{
 // Validate that token is actually owned by From
 if (_tokenStorage.NftTokenWalletMap.ContainsKey(tokenId) &&
  _tokenStorage.NftTokenWalletMap[tokenId].Equals(oldWalletAddress))
 {
  // Remove existing Wallet-Token record, it's not valid anymore.
  _tokenStorage.WalletNftTokensMap[oldWalletAddress].Remove(tokenId);
  // Add a new one.
  if (!_tokenStorage.WalletNftTokensMap.ContainsKey(newWalletAddress))
  {
   _tokenStorage.WalletNftTokensMap.Add(newWalletAddress, new List<string>());
  }
  _tokenStorage.WalletNftTokensMap[newWalletAddress].Add(tokenId);

  // Update a second map that maps back Token to Wallet.
  _tokenStorage.NftTokenWalletMap[tokenId] = newWalletAddress;

  // Now, create a new ownership history record.
  NFTToken nftToken = _tokenStorage.NftTokenOwnershipMap[tokenId];
  nftToken.OwnershipInfo.AddFirst(
   new OwnershipInfo
   {
    WalletAddress = newWalletAddress,
    Timestamp = DateTime.Now
   });

  return true;
 }

 return false;
}

Wreszcie, nasz przechowywanie tokenów struktury danych będą wyglądać tak i będą obsługiwać wszystkie niezbędne operacje z wydajnością O(1) z dodatkową redundancją pamięci.

public class TokenStorage
{
 public TokenStorage()
 {
  NftTokenWalletMap = new Dictionary<string, string>();
  WalletNftTokensMap = new Dictionary<string, List<string>>();
  NftTokenOwnershipMap = new Dictionary<string, NFTToken>();
 }

 // To easily find owning wallet by NFT token.
 public Dictionary<string, string> NftTokenWalletMap { get; set; }

 // To easily find list of owned Tokens in the wallet.
 public Dictionary<string, List<string>> WalletNftTokensMap { get; set; }

 // To easily change the ownership.
 public Dictionary<string, NFTToken> NftTokenOwnershipMap { get; set; }
}

public class NFTToken
{
 public string TokenId { get; set; }

 /// <summary>
 /// Allows to efficiently insert new owners.
 /// </summary>
 public LinkedList<OwnershipInfo> OwnershipInfo { get; set; }
}

Projektowanie aplikacji

Zgodnie z projektem programowania obiektowego (OOP) tworzymy szereg podmiotów:

  1. Wszystkie transakcje są obsługiwane przez TransactionManager.
  2. Każde polecenie CLI jest dziedziczone z bazy Polecenie z logiką biznesową zaimplementowaną w odpowiednich CommandHandlers.
  3. ConsoleOutputHandlers odgrywają rolę interfejsu widoku (podobnego do koncepcji MVC) do drukowania na konsoli, co pozwala nam potencjalnie wysyłać dane wyjściowe aplikacji do wyświetlacza, sieci, sieci itp.
  4. Używamy biblioteki NewtonsoftJson do analizowania przychodzących żądań, a także System.Xml do pracy z naszym trwałym plikiem XML.

Prosty projekt OOP aplikacji CLI

Prosty projekt OOP aplikacji CLI

Wszystko to pozwala nam zaimplementować zestaw testów jednostkowych, które również znajdą Państwo w repozytorium.

Testy jednostkowe

Testy jednostkowe

Teraz, dzięki bibliotece System.CommandLine, łatwo jest połączyć wszystkie polecenia w małą aplikację w następujący sposób:

class Program
{
    static async Task<int> Main(string[] args)
    {
        var root = new RootCommand();
        root.Description = "Wallet CLI app to work with NFT tokens.";

        root.AddCommand(new ReadFileCommand());
        root.AddCommand(new ReadInlineCommand());
        root.AddCommand(new WalletCommand());
        root.AddCommand(new ResetCommand());
        root.AddCommand(new NftCommand());

        root.Handler = CommandHandler.Create(() => root.Invoke(args));

        return await new CommandLineBuilder(root)
           .UseHost(_ => Host.CreateDefaultBuilder(args), builder => builder
                .ConfigureServices(RegisterServices)
                .UseCommandHandler<ReadFileCommand, ReadFileCommandHandler>()
                .UseCommandHandler<ReadInlineCommand, ReadInlineCommandHandler>()
                .UseCommandHandler<WalletCommand, WalletCommandHandler>()
                .UseCommandHandler<ResetCommand, ResetCommandHandler>()
                .UseCommandHandler<NftCommand, NftCommandHandler>())
           .UseDefaults()
           .Build()
           .InvokeAsync(args);
    }

    private static void RegisterServices(IServiceCollection services)
    {
        services.AddHttpClient();
        services.AddSingleton<IFileSystem, XmlFileSystem>();
        services.AddSingleton<ITransactionsManager, TransactionsManager>();
        services.AddSingleton<IConsoleOutputHandlers, ConsoleOutputHandlers>();
    }
}

Uruchom swój portfel

Teraz możemy uruchomić nasz mały CLI. Zawiera on małą pomoc z listą poleceń (dzięki System.CommandLine library):

>nft.app.exe -h
Description:
  Wallet CLI app to work with NFT tokens.

Usage:
  Nft.App [command] [options]

Options:
  --version       Show version information
  -?, -h, --help  Show help and usage information

Commands:
  --read-file <filePath>  Reads transactions from the ?le in the speci?ed location.
  --read-inline <json>    Reads either a single json element, or an array of json elements representing transactions as
                          an argument.
  --wallet <Address>      Lists all NFTs currently owned by the wallet of the given address.
  --reset                 Deletes all data previously processed by the program.
  --nft <tokenId>         Returns ownership information for the nft with the given id.

Jeśli odczytamy wszystkie transakcje z pliku JSON, po zakończeniu wykonywania możemy znaleźć magazyn portfela XML “WalletDb.xml”.

>Nft.App --read-file transactions.json

Plik kontenera Xml Storage

Plik kontenera Xml Storage

Teraz proszę wykonać następujące transakcje jedna po drugiej i obserwować wyniki:

>Nft.App --read-file transactions.json 
Read 5 transaction(s) 

>Nft.App --nft 0xA000000000000000000000000000000000000000
Token 0xA000000000000000000000000000000000000000 is not owned by any wallet 

>Nft.App --nft 0xB000000000000000000000000000000000000000
Token 0xA000000000000000000000000000000000000000 is owned by 0x3000000000000000000000000000000000000000 

>Nft.App --nft 0xC000000000000000000000000000000000000000
Token 0xC000000000000000000000000000000000000000 is owned by 0x3000000000000000000000000000000000000000 

>Nft.App --nft 0xD000000000000000000000000000000000000000
Token 0xA000000000000000000000000000000000000000 is not owned by any wallet 

>Nft.App --read-inline  "{ \"Type\": \"Mint\", \"TokenId\": \"0xD000000000000000000000000000000000000000\", \"Address\": \"0x1000000000000000000000000000000000000000\" }"
Read 1 transaction(s) 

>Nft.App --nft 0xD000000000000000000000000000000000000000
Token 0xA000000000000000000000000000000000000000 is owned by 0x1000000000000000000000000000000000000000 

>Nft.App --wallet 0x3000000000000000000000000000000000000000
Wallet 0x3000000000000000000000000000000000000000 holds 2 Tokens: 
0xB000000000000000000000000000000000000000 
0xC000000000000000000000000000000000000000 

>Nft.App -—reset 
Program was reset 

>Nft.App --wallet 0x3000000000000000000000000000000000000000
Wallet 0x3000000000000000000000000000000000000000 holds no Tokens 

Wyniki

Jak widać, byliśmy w stanie zaimplementować wszystkie operacje Wallet z wydajnością O(1). Niestety, wiąże się to z kompromisami w zakresie wykorzystania pamięci. W scenariuszach produkcyjnych rozważania dotyczące dużych zbiorów danych, które mogą nie mieścić się w pamięci RAM pojedynczej maszyny, mogą prowadzić do kompromisów. W zależności od wymagań, konieczne może być poświęcenie wydajności na rzecz zoptymalizowanego wykorzystania pamięci lub odwrotnie.

Chociaż ten przykład pokazuje kompromis dla samodzielnego systemu, w środowisku produkcyjnym preferowane może być oprogramowanie innych firm obsługujące skalowalne mapowania z redundancją. Wprowadza to dodatkową złożoność, ale ma kluczowe znaczenie dla wydajności operacyjnej w systemach rozproszonych.

Ta eksploracja zapewnia wgląd w świat NFT i struktur danych wspierających ich operacje. Mam nadzieję, że było to dla Państwa interesujące i przydatne.

Proszę czekać na więcej!