Skip to main content

对象和包版本控制

你通过 ID 和版本引用链上存储的每个对象。当交易修改对象时,它将新内容写入具有相同 ID 但更高版本的链上引用。这意味着单个对象(具有 ID I)可能会出现在分布式存储中的多个条目中:

(I, v0) => ...
(I, v1) => ... # v0 < v1
(I, v2) => ... # v1 < v2

尽管在存储中出现多次,但交易只能访问对象的一个版本 - 最新版本(在前面的示例中是 v2)。而且,只有一个交易可以修改该版本的对象以创建新版本,从而保证了线性历史(v1 是在 I 处于 v0 状态创建的,而 v2 是在 I 处于 v1 状态创建的)。

版本是严格递增的,且 (ID, version) 对永不重复使用。此结构允许节点运营者清理其存储中现在无法访问的旧对象版本,如果他们选择这样做。然而,这并不是必需的,因为节点运营者可能会保留先前的对象版本,以响应对对象历史的请求,无论是来自其他正在追赶的节点还是来自 RPC 请求。

Move 对象

Sui 在其对象版本控制算法中使用 Lamport 时间戳。使用 Lamport 时间戳可以保证版本永远不会被重复使用,因为事务触及的对象的新版本是事务所有输入对象的最新版本加一。例如,使用版本 5 的 gas 对象 G 进行转移的事务将更新 OG 的版本至 1 + max(5, 3) = 6(版本 6)。

以下部分详细说明了 Lamport 版本的相关性,以维持“不重复使用 (ID, version)” 不变性或在访问对象的事务输入发生变化的情况下访问对象,具体取决于该对象的所有权。

地址所有的对象

你必须引用特定 ID 和版本的地址所有的事务输入。当验证者使用特定版本的所有权对象输入签署事务时,该对象的那个版本将锁定到该事务。验证者会拒绝签署需要相同输入(相同 ID 和版本)的其他事务的请求。

如果有 F + 1 个验证者签署一个以某个对象为输入的事务,另外有 F + 1 个验证者签署另一个以相同对象为输入的事务,该对象(以及两个事务的所有其他输入)将被认为是矛盾的,这意味着它们不能用于该纪元的任何进一步的事务。这是因为两个事务都无法形成共识,而无需依赖已经将对象提交给不同事务的验证者的签名,这是它无法获取的。所有锁在纪元结束时重置,这会再次释放对象。

info

只有对象的所有者才能使其出现矛盾,但这并不是一个理想的做法。你可以通过仔细管理地址所有的输入对象的版本来避免矛盾:永远不要尝试执行两个使用相同对象的不同事务。如果没有从网络为事务获取确切的成功或失败响应,请假设该事务可能已成功,不要将其对象用于不同的事务。

不可变对象

与地址所有的对象类似,你引用不可变对象的 ID 和版本,但它们不需要被锁定,因为它们的内容和版本不会更改。它们的版本是相关的,因为它们可能在冻结之前作为地址所有的对象开始生活。给定的版本标识了它们变为不可变的时刻。

共享对象

指定共享事务输入略微复杂。你通过其 ID、它在哪个版本共享的以及一个指示是否可变访问的标志引用它。你不指定事务访问对象的确切版本,因为共识在事务调度期间决定了这一点。在调度多个涉及相同共享对象的事务时,验证者同意这些事务的顺序,并相应地选择每个事务的输入版本(一个事务的输出版本变为下一个事务的输入版本,依此类推)。

你引用的不可变共享事务输入参与调度,但不会修改对象或增加其版本。

封装的对象

你无法通过对象存储中的 ID 访问封装的对象,你必须通过封装它们的对象进行访问。考虑以下示例,其中创建了一个 make_wrapped 函数,该函数包含一个包装在 Outer 对象中并返回给事务发送方的 Inner 对象。

module example::wrapped {
use sui::object::{Self, UID};
use sui::transfer;
use sui::tx_context::{Self, TxContext};

struct Inner has key, store {
id: UID,
x: u64,
}

struct Outer has key {
id: UID,
inner: Inner,
}

entry fun make_wrapped(ctx: &mut TxContext) {
let inner = Inner {
id: object::new(ctx),
x: 42,
};

let outer = Outer {
id: object::new(ctx),
inner,
};

transfer::transfer(outer, tx_context::sender(ctx));
}
}

在此示例中,Outer 的所有者必须将其指定为事务输入,然后访问其 inner 字段以读取 Inner 的实例。验证者会拒绝直接将封装对象(如 Outerinner)指定为输入的事务。因此,在读取该对象的事务中,你无需指定封装对象的版本。

封装对象最终可以变为“解封”,这意味着它们再次可以通过其 ID 访问:

module example::wrapped {
// ...

entry fun unwrap(outer: Outer, ctx: &TxContext) {
let Outer { id, inner } = outer;
object::delete(id);
transfer::transfer(inner, tx_context::sender(ctx));
}
}

在先前的代码中,unwrap 函数接受一个 Outer 实例,销毁它,并将 Inner 发送回发送方。调用此函数后,Outer 的先前所有者可以通过其 ID 直接访问 Inner,因为它现在已解封。包装和解封对象可以在其生命周期内多次发生,并且对象在所有这些事件中都保留其 ID。

基于 Lamport 时间戳的版本控制方案确保对象解封时的版本始终大于包装时的版本,以防止版本重用。

  • 在一个包装对象 I 的对象 O 的事务 W 之后,O 的版本大于或等于 I 的版本。这意味着以下条件之一为真:
    • I 是输入,因此版本严格较低。
    • I 是新的,并且版本相等。
  • 在稍后解封 I 以将其从 O 中取出的事务之后,以下必须为真:
    • O 的输入版本大于或等于 W 之后的版本,因为这是一笔较晚的交易,因此版本只能增加。
    • 输出中的 I 版本必须严格大于 O 的输入版本。

这导致以下不等式链对于包装前的 I 版本:

  • 小于或等于包装后的 O 版本
  • 小于或等于包装前的 O 版本
  • 小于解封后的 I 版本

因此,包装前的 I 版本小于解封后的 I 版本。

动态字段

从版本控制的角度来看,存储在动态字段中的值的行为类似于封装的对象:

  • 它们只能通过字段的父对象访问,而不能作为直接事务输入访问。
  • 基于前一点,你无需在事务输入中提供它们的 ID 或版本。
  • 基于 Lamport 时间戳的版本控制确保,当字段包含一个对象并且事务删除该字段时,其值变得可通过其 ID 访问,并且该值的版本已递增到以前未使用的版本。
info

与封装的对象相比,动态字段的一个区别是,如果事务修改动态对象字段,则该字段的版本将在该事务中递增,而封装对象的版本不会递增。

向父对象添加新的动态字段还会创建一个 Field 对象,负责将字段名称和值与该父对象关联起来。与其他新创建的对象不同,通过 sui::object::new 创建 Field 实例的 ID 不是使用。相反,它被计算为父对象 ID 和字段名称的类型和值的哈希,以便你可以使用它通过其父对象和名称查找 Field

当你删除一个字段时,Sui 将删除其关联的 Field,如果你使用相同的名称添加一个新字段,Sui 将创建一个具有相同 ID 的新实例。基于 Lamport 时间戳的版本控制,再加上动态字段仅通过其父对象访问,确保了在此过程中不会重用(ID,版本)对:

  • 删除原始字段的事务将父对象的版本递增到大于已删除字段的版本。
  • 创建相同字段的新版本的事务将以大于父对象版本的版本创建该字段。

因此,新 Field 实例的版本大于已删除 Field 的版本。

Move 包也具有版本,并存储在链上,但其遵循与对象不同的版本控制方案,因为它们从一开始就是不可变的。这意味着你只需通过它们的 ID 引用包事务输入(例如,对于 Move 调用事务的函数所在的包),并且始终以其最新版本加载。

用户包

每次发布或升级包时,Sui 会生成一个新的 ID。新发布的包的版本设置为 1,而升级包的版本比其升级的包的版本大 1。与对象不同,包的旧版本在升级后仍然可以访问。例如,想象一个包 P,它被发布并升级了两次。它可能在存储中表示为:

(0x17fb7f87e48622257725f584949beac81539a3f4ff864317ad90357c37d82605, 1) => P v1
(0x260f6eeb866c61ab5659f4a89bc0704dd4c51a573c4f4627e40c5bb93d4d500e, 2) => P v2
(0xd24cc3ec3e2877f085bc756337bf73ae6976c38c3d93a0dbaf8004505de980ef, 3) => P v3

在此示例中,相同包的所有三个版本都具有不同的 ID。这些包具有递增的版本,但仍然可以调用 v1,即使 v2v3 已存在于链上。

框架包

框架包(例如 Move 标准库位于 0x1,Sui 框架位于 0x2,Sui 系统位于 0x3,Deepbook 位于 0xdee9)是一种特殊情况,因为它们的 ID 在升级过程中必须保持稳定。网络可以通过系统事务在纪元边界上升级框架包,同时保留它们的 ID,但只能对它们进行一次此操作,因为它们被视为像其他包一样不可变。框架包的新版本保留与其前身相同的 ID,但将其版本递增一:

(0x1, 1) => MoveStdlib v1
(0x1, 2) => MoveStdlib v2
(0x1, 3) => MoveStdlib v3

在先前的示例中,展示了 Move 标准库的前三个版本的链上表示。

包版本

Sui 智能合约被组织成可升级的包,因此,任何给定包的多个版本可以存在于链上。在可以使用链上包之前,必须发布其第一个原始版本。当升级包时,将创建该包的新版本。每次升级包都基于该包在版本历史中的上一个版本。换句话说,你只能从第 n - 1 版本升级第 n 版本的包。例如,你可以将包从版本 1 升级到 2,但之后你只能将该包从版本 2 升级到 3;不允许从版本 1 直接升级到版本 3。

包清单文件中存在版本控制的概念,分别存在于包部分和依赖项部分。例如,考虑以下清单代码:

[package]
name = "some_pkg"
version = "1.0.0"

[dependencies]
another_pkg = { git = "https://github.com/another_pkg/another_pkg.git" , version = "2.0.0"}

在此时,清单中的版本引用仅用于用户级文档,因为 publishupgrade 命令不利用此信息。如果你使用清单文件中的某个包版本发布包,然后修改并使用不同版本(使用 publish 命令而不是 upgrade 命令)重新发布相同的包,那么这两个包被视为不同的包,而不是同一包的链上版本。你不能将其中任何一个用作依赖项覆盖以替代另一个。虽然在构建包时可以指定此类型的覆盖,但在链上发布或升级时会导致错误。