本文深入探讨了 NEAR 区块链的存储系统,包括存储的工作原理、安全使用方法以及常见的陷阱。重点介绍了NEAR的存储机制(Storage Staking),以及如何使用 NEAR SDK 进行存储,需要注意 collection 的前缀需要保证唯一性,避免出现覆盖问题。
在本文中,我们将深入研究 NEAR 区块链的存储系统。我们将探讨存储在 NEAR 上是如何工作的,如何安全地使用它,并重点介绍一些常见的陷阱。
这是我们关于 NEAR 区块链的第二篇文章。如果你错过了第一篇文章,它深入探讨了分片和跨合约调用。
NEAR 上的每个帐户都有存储空间,可用于以持久的方式存储数据。因此,它通常用于存储诸如余额、NFT 元数据或其他变量。在底层,NEAR 上的存储是一个键值存储。但是,开发人员很少直接与 kv-store 交互,而是使用 NEAR SDK 来利用存储。
NEAR SDK 抽象了 kv-store,并提供了对存储更友好的交互。SDK 允许开发人员定义存储布局。这是一个用 #[near(contract_state)]
宏标记的结构体,它划定了此合约中哪些变量在存储中。一个合约只能定义一个存储布局结构体。例如,下面的合约在其存储中有 2 个变量:一个字符串和一个字节向量。
#[near(contract_state)]
pub struct Contract {
greeting: String,
vector: Vector<u8>,
}
可以使用所有常规的 Rust 数据类型,如 i32
和 String
,但 NEAR SDK 提供了集合类型的替代品(稍后会详细介绍)。default()
函数可用于为状态变量分配默认值。
当实例化 SDK 集合类型(如 Vector
)时,你必须为其指定唯一的 前缀。此前缀用于计算此集合的存储位置。
fn default() -> Self {
Self {
greeting: "hi".to_string(),
vector: Vector::new("vecPref"),
}
}
SDK 将完成序列化和反序列化这些高级变量与底层 kv-store 之间的繁重工作。执行此操作时,SDK 对 kv-store 使用以下结构:
STATE
的 kv-store 中。Vector
)使用用户定义的前缀单独存储。集合的每个元素都存储在自己的键值对中,键为 prefix|index of element
。在我们的示例中,假设 greeting = 'hi'
并且 vector
有一个等于 1
的元素,则合约将在其 kv-store 中包含以下内容:
[\
{\
"key": "STATE",\
// u32(greeting.length)|greeting|u64(vector.length)|u32(vector.prefix.length))|vector.prefix\
"value": "\x02\x00\x00\x00hi\x01\x00\x00\x00\x00\x00\x00\x00\x07\x00\x00\x00vecPref"\
},\
{\
// vector.prefix|u64(index to access)\
"key": "vecPref\x00\x00\x00\x00\x00\x00\x00\x00",\
// vector[0]\
"value": "\x01"\
}\
]
// Modified from https://docs.near.org/build/smart-contracts/anatomy/serialization#modifying-the-state
当调用将 self
作为参数的函数时,主合约状态(键为 STATE
)将被反序列化并完整地存储在内存中。这与 SDK 集合类型的内容形成对比,它们仅在需要时(例如,在 vec.get()
操作期间)反序列化并加载到内存中。
同样,主合约状态也会从内存中序列化,并在 合约执行结束时 存储回 kv-store 中。相反,集合类型的内容会在 集合被删除 (超出范围)时从内存中序列化并存储在存储中。这种细微的差异有一些含义,将在后面解释。
SDK 类型
现在很清楚存储在较低级别上是如何工作的,我们可以解释 SDK 集合类型(Vector
)和本机 Rust 集合类型(Vec
)之间的重要区别:SDK 集合如上所述存储在单独的键值对中,相反,本机 Rust 集合将存储在主合约状态(STATE
)中。由于主合约状态始终完整加载,这意味着 Rust 集合也将始终作为一个整体加载。对于大型集合来说,这可能非常消耗资源,甚至可能导致 DoS 问题。因此,对于大多数集合应使用 SDK 类型,而本机 Rust 集合仅应用于非常小的集合。
另一种应该使用 SDK 类型而不是本机类型的情况是 u64
和 u128
类型。由于 JSON 用于编码函数调用的输入和输出,而 JSON 无法直接表示 u64
和 u128
大小的整数,因此应使用 SDK 类型 U64
和 U128
而不是本机 Rust 类型 u64
和 u128
。SDK 类型在必要时会自动表示为字符串。
升级合约
NEAR 具有用于升级合约的内置功能。这允许在部署后修改合约的代码,但是,在此过程中不会修改存储。这意味着存储布局必须保持不变。否则,如果存储布局与存储不匹配,则可能会发生反序列化错误,并且合约将不再起作用。或者,如果必须更改存储布局,则可以实现一个迁移函数,该函数将存储迁移到新的存储布局。
存储质押
NEAR 实现了存储质押。这意味着根据合约中使用的存储量锁定一定数量的 NEAR。释放存储时,NEAR 从合约中解锁,可以转出。这意味着合约应始终拥有足够的 NEAR 代币来支付其可能执行的任何存储分配,否则在尝试写入时会发生 panic。存储成本应转嫁给用户,否则,合约会让自己面临恶意破坏和 DoS 攻击(将在下一节中介绍)。
NEP-145 定义了存储管理标准,这是一个将存储成本转嫁给用户的通用接口。使用此标准时,用户必须注册到合约并存入一些 NEAR 以支付其存储费用。他们可以根据自己对合约的使用情况来增加或提取其存储余额。
存储质押安全
存储质押机制意味着 NEAR 会根据合约使用的存储量被锁定。因此,合约必须准确跟踪每个用户消耗的存储量,并将此成本转嫁给用户。如果合约不这样做,它将打开几个攻击向量:
前缀冲突
使用 SDK 集合类型 时,开发人员必须指定前缀。此前缀用于计算集合在键值存储中存储位置的键。
enum
来定义存储键。这保证了它们的唯一性。例如:#[near]
#[derive(BorshStorageKey)]
pub enum Prefix {
Owners,
Users,
}
#[near(contract_state)]
pub struct StorageExample {
pub owners: Vector<String>,
pub users: LookupSet<String>,
}
impl Default for StorageExample {
fn default() -> Self {
Self {
owners: Vector::new(Prefix::Owners),
users: LookupSet::new(Prefix::Users),
}
}
}
// Modified from https://docs.near.org/build/smart-contracts/anatomy/collections#sdk-collections
不安全的集合
如上所述,在修改变量时,一些状态(例如 Vector
的内容)可以在保存其他状态(例如 Vector
的长度) 之前 保存到存储中。这可能会在某些情况下导致一些意外行为:
当实例化 2 个 具有相同前缀 的对象时,它们可以具有相同的内容(因为该内容已保存到存储中),但长度不同(保存在内存中)
let mut m = UnorderedMap::<u8, String>::new(b"m");
m.insert(1, "test".to_string()); // this writes "test" to storage
assert_eq!(m.len(), 1); // length is saved in memory
assert_eq!(m.get(&1), Some(&"test".to_string()));
m = UnorderedMap::new(b"m"); // instantiate second object with same prefix
assert!(m.is_empty()); // not same length
assert_eq!(m.get(&1), Some(&"test".to_string())); // but same contents
/// From https://docs.near.org/build/smart-contracts/anatomy/collections#error-prone-patterns
集合应附加到主状态。否则,内容将被写入存储,但元数据(如长度)会丢失。以下示例演示了这一点:
#[near(contract_state)]
pub struct Contract {
vector: Vector<i32>,
}
impl Default for Contract {
fn default() -> Self {
Self {
vector: Vector::new("pref".as_bytes()),
}
}
}
#[near]
impl Contract {
pub fn test(&mut self) -> String {
{
// v is 'detached from the state'
let mut v = Vector::<u32>::new("pref".as_bytes());
v.push(1);
assert_eq!(v.len(), 1);
assert_eq!(v.get(0).unwrap(), &1);
} // when v is dropped its contents are saved, but its metadata (length) is lost
assert_eq!(self.vector.len(), 0); // length was not saved
// when reading the storage manually, the content is intact
let storage_key = &[b"pref".as_slice(), &0u32.to_le_bytes()].concat();
assert_eq!(
near_sdk::env::storage_read(storage_key).unwrap(), 1u32.to_le_bytes()
);
}
}
NEAR SDK 的 collections
vs store
这篇文章假设使用 near_sdk::store
来进行集合操作。这是旧的 near_sdk::collections
模块的新版本。store
具有更好的 gas 效率以及其他优势。此外,collections
具有一些在嵌套时可能导致问题的行为。因此,建议使用 store
而不是 collections
。
NEAR 中的帐户存储具有一些有趣的机制,例如存储质押和集合管理。重要的是要了解这些机制及其细微之处是如何工作的,以便创建安全且强大的智能合约。
- 原文链接: blog.sigmaprime.io/near-...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!