65.9K
CodeProject 正在变化。 阅读更多。
Home

NFT 钱包的解放:数据结构和应用程序设计之旅

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.16/5 (4投票s)

2024年1月29日

Apache

6分钟阅读

viewsIcon

3265

在利用 C# 和 .NET Core 构建钱包命令行(CLI)应用原型的同时,探索 NFT 和区块链的世界,并使用高效的数据结构。

引言

无论你是否追赶 NFT 的热潮,作为一名软件工程师,紧跟最新的创新技术至关重要。深入研究这些热门功能背后的技术总是很有趣。通常,我喜欢等尘埃落定后再入场,但现在似乎是探索“NFT 到底是什么”的好时机。

术语

NFT非同质化代币Non-fungible tokens)的缩写。非同质化代币是基于区块链的代币,代表对某一数字资产的所有权。数字资产可以是任何东西,从一张手绘图片、一首歌曲、一段音乐、一篇博客文章或整本电子书,甚至是一条推文(它本质上是知名上市公司数据库中的一条公开记录)。这些资产具有公共价值,并且可以被某人拥有。

与比特币(Bitcoin)或以太坊(Etherium)等同质化代币不同,同质化代币可以与相同的单位互换(它们价值相同,一个可以换另一个),而 NFT 是独一无二的(不能等价交换),从而确保了独特数字资产的所有权,并强制执行数字版权和商标法。NFT 基于区块链技术,保障所有权并促进所有权转移。

我们构建什么

我们正在使用 C# 控制台应用和(尚不那么著名的).NET CLI SDK 创建一个 NFT 钱包原型。System.CommandLine 库虽然仍处于测试阶段,但前景广阔,能够创建简洁高效的命令行界面。

NFT 钱包的最低要求如下:

  1. 保存代币所有权历史的记录。
  2. 支持铸造(Mint)交易(创建代币)。
  3. 支持销毁(Burn)交易(销毁代币)。
  4. 支持转移(Transfer)交易(变更所有权)。

我们假设交易采用 JSON 格式,但出于教学目的,我们将从格式化的 JSON(文本或磁盘文件)中读取它们,因为我们没有真实的区块链网络服务器。

保持简单

为了简单起见,我们将忽略特定区块链网络、用于生成独特 NFT 的哈希算法以及持久化存储的选择(在我们的原型中,我们将使用磁盘上的一个 XML 文件)等细节。

API

考虑到上述要求和限制,我们将支持以下命令:

内联读取 (--read-inline <json>)

读取单个 JSON 元素或代表交易的 JSON 元素数组作为参数。

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

读取文件 (--read-file <file>)

从指定的文件位置读取单个 JSON 元素或代表交易的 JSON 元素数组。

$> program --read-file transactions.json

NFT 所有权 (--nft <id>)

返回具有给定 ID 的 NFT 的所有权信息。

$> program --nft 0x...

钱包所有权 (--wallet <address>)

列出具有给定地址的钱包当前拥有的所有 NFT。

$> program --wallet 0x...

重置 (--reset)

删除程序先前处理的所有数据。

$> program --reset

NFT 交易

如上所述,我们必须支持以下交易。

铸造 (Mint)

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

铸造交易在具有所提供地址的钱包中创建一个新代币。

销毁 (Burn)

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

销毁交易会销毁具有给定 ID 的代币。

传输

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

转移交易通过移除“from”钱包地址来变更代币的所有权,并将其添加到“to”钱包地址。

交易操作

在下面这批交易的示例中,我们创建了三个新代币,销毁了一个,并转移了另一个的所有权。

[
	{
		"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"
	}
]

如上所示,代币由虚拟的十六进制格式值来标识。钱包地址应由我们底层的虚拟区块链网络支持。我们跳过了对这些值的验证,重点关注我们 NFT 钱包中操作和存储的效率。

数据结构设计

为了支持所有必要的操作,我们必须考虑高效执行以下三种类型的任务:

  • 持久化存储所提供的虚拟 NFT 代币 ID 和 NFT 钱包地址之间的所有权关系信息。
  • 通过代币 ID 快速查询哪个钱包包含该代币。
  • 快速查询某个钱包拥有哪些代币。
  • 在钱包地址之间高效地变更代币的所有权。

我们首先创建一个类来表示单笔交易。

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; }
}

在 NFT 的世界里,所有者由钱包地址表示,我们添加了一个时间戳来跟踪新代币的创建时间或在钱包之间转移的时间。

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

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

最高效的算法执行时间复杂度应该是 O(1),对吗?基于哈希的集合允许我们以 O(1) 的效率支持 GET 操作,这意味着我们必须使用 Dictionary<K, V> 作为整个存储。但为了使所有操作都高效,我们必须牺牲内存,因为只有一个高效的集合是不够的。相反,我们将在内存中使用多个集合。让我们先逐个分析,然后再讨论这个解决方案。

> 请记住,在以下代码中,我们不验证代币 ID 或钱包地址。

哪个钱包拥有该代币?

由于一个代币只能由一个钱包拥有,因此我们使用代币 ID(key)和钱包地址(value)之间的直接映射关系。这使我们能够轻松支持“--nft”操作,回答所有者是谁的问题。

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;
}

钱包拥有哪些代币?

为了高效地列出钱包拥有的代币,我们维护了一个从钱包地址(key)到其代币 ID 列表(value)的映射,这样我们就可以轻松支持“--wallet”操作。

public class TokenStorage
{
	// To easily find list of owned Tokens in the wallet.
	public Dictionary<string, list="">> 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);
}

所有权转移和历史记录

为了高效地支持每个代币所有权变更的历史记录,我们需要将代币 ID(key)映射到一个所有者钱包地址列表(values)。这个列表必须以一种方式排序,使我们能够高效地获取最后一个所有者(但仍然能够在需要时列出所有历史记录)。我们还希望能够高效地插入新的历史记录(到末尾)。链表(Linked List)非常适合这种历史记录数据结构:它允许我们以 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; }
}

借助这些结构,我们可以在 TransactionManager 中高效地支持 NFT 的铸造、销毁和转移操作。请跟随代码中的注释。

铸造新代币

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;
}

销毁代币

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);
	}
}

转移代币

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;
}

最终,我们的 代币存储 数据结构将如下所示,并通过额外的内存冗余以 O(1) 的效率支持所有必要操作。

public class TokenStorage
{
	public TokenStorage()
	{
		NftTokenWalletMap = new Dictionary<string, string="">();
		WalletNftTokensMap = new Dictionary<string, list="">>();
		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="">> 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; }
}

应用程序设计

遵循面向对象编程(OOP)设计,我们创建了多个实体:

  1. TransactionManager 支持的所有交易。
  2. 每个 CLI 命令都继承自一个基础的 Command,其业务逻辑在相应的 CommandHandler 中实现。
  3. ConsoleOutputHandler 扮演视图接口的角色(类似于 MVC 概念),用于向控制台打印信息,这使我们有可能将应用程序的输出发送到显示器、网络、Web 等。
  4. 我们使用 NewtonsoftJson 库来解析传入的请求,并使用 System.Xml 来处理我们的持久化 XML 存储文件。

Picture 2. Diagram.

所有这些使我们能够实现一套单元测试,你也可以在仓库中找到它们。

Picture 3. Tests green.

现在,得益于 System.CommandLine 库,可以轻松地将所有命令组合成一个小应用程序,如下所示

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="">();
    }
}

运行你的钱包

现在,我们可以运行我们的小型 CLI。它包含一个很好的帮助信息,列出了所有命令(感谢 System.CommandLine 库)。

>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.

如果我们从 JSON 文件中读取所有交易,那么在执行完成后,我们可以找到一个名为“WalletDb.xml”的 XML 钱包存储文件。

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

Picture 4. Xml Storage container file.

现在,让我们逐个执行以下交易:

>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 

成果

如我们所见,我们能够以 O(1) 的效率实现所有操作。不幸的是,这需要在内存使用上做出权衡。在生产场景中,考虑到可能无法容纳在单台机器内存中的大型数据集,可能需要做出妥协。根据需求,可能需要牺牲效率来优化内存使用,或者反之。

虽然这个例子展示了在独立系统中的一种折衷方案,但在生产环境中,可能会优先选择支持带冗余的可扩展映射的第三方软件。这会增加额外的复杂性,但对于分布式系统的运营效率至关重要。

这次探索让我们深入了解了 NFT 的世界以及支持其操作的数据结构。希望这对你来说既有趣又有用。

敬请期待更多内容!

历史

  • 2024年1月29日:初始版本
© . All rights reserved.