100 个Solana日常技巧 - 提升 Solana 程序安全

本文是r0bre在3个多月的时间里编写的100个Solana技巧的合集,旨在提高Solana开发者和审计师的技能。内容涵盖项目结构、Anchor约束、代码学习、不变量、动态断言、全局程序状态、账户填充、PDA种子、剩余账户安全、日志记录、并行处理、数学运算安全、重入、ATA账户初始化、多重签名、事件响应计划、编程监控以及Solana账户模型等多个方面。

Solana 安全研究,作者 accretion.xyz

100 条 Solana 日常技巧

100 条 Solana 技巧,提升你的 Solana 开发和审计技能

今年早些时候,在超过 3 个月的时间里,我编写了 100 条 Solana 技巧,每天发布一条。期间,我收到了许多要求发布长篇博文的请求,将所有技巧集中在一起 —— 这就是它。祝你使用愉快!

(在 X 上的原始帖子) https://x.com/r0bre/status/1878796569597882757

Solana 技巧 1

构建你的项目结构。如果你的所有代码都放在一个单独的

lib.rs

文件中,那么工作起来会很困难。(最晚在第二个人查看你的代码时)。将其拆分。

我建议的结构:

  • lib.rs
  • instructions/

    • init.rs
    • transfer.rs
  • state/

    • global.rs
  • lib.rs

你的 lib.rs 应该只包含调用指令处理程序的代码。

每个指令文件,例如 init.rs,都应该包含 Accounts 结构体、验证函数和此指令的业务逻辑/实现。

每个状态文件,例如 global.rs,都应该包含账户定义,包括该特定状态账户的实现。

这将使每个使用你代码的人都更容易。

Anchor 甚至有一个内置命令来生成具有此结构的存储库:

anchor init <NAME> --template multiple

Solana 技巧 2

在编写 Anchor 约束时,尽量只使用 has_one。当 has_one 不可行,且约束比简单的 key 比较更复杂时,将其放入一个单独的验证函数中。并在你的约束函数中添加自定义错误代码。

Solana 技巧 3

不知道如何编写好的 Solana 程序?去看看那些构建良好的 dapps 是怎么做的。我建议学习 @SquadsProtocol v4 的基础知识。https://github.com/Squads-Protocol/v4

对于优秀的非 Anchor 代码,阅读 @sanctumso 的 S,以及 Ellipsis Labs 的 Plasma/gavel

Solana 技巧 4

区块链有一个很棒的机制:你可以处理整个交易,生成一个理论上的状态转换 S->S',然后如果 S' 看起来可疑,可以回滚它。要做到这一点,你可以使用 不变量(invariants)。

不变量(Invariant)函数是在状态上定义的。它们检查给定的状态是否有效,如果无效则返回错误。

你可以在改变状态的指令的末尾调用它们,以确保新状态有效。以下示例来自 Squads v4。

正如你所看到的,不变量(invariant)在函数的末尾被调用。所示的支出限额的 不变量(invariant) 确保金额永远不为 0,并且成员列表不为空或有重复项。

它被称为 不变量(invariant),因为现在我们可以声明一个始终为真/不变量(invariant) 的语句:支出限额的成员列表永远不为空。

Solana 技巧 5

昨天我们学习了 不变量(invariants)。它们是在状态上定义的,并且是静态的。但是我们可以在指令中使用与动态断言相同的原理。

例如,一个存款函数。

当用户将资金存入我们的合约时,资金量应该增加,而不是减少,减少将是一个安全问题。

我们可以通过在指令开始时和结束时保存总存款金额来构建这样的断言。然后我们断言,在结束时,金额大于开始时。

这是一个简单但有效的技术,可以为你的程序增加安全性!

Solana 技巧 6

使用带有全局状态枚举的全局程序状态帐户,用于不同的操作级别。

例如,你可能需要一个用于正常操作、完全停止操作、仅提款或限制操作的状态,该状态仅允许调用指令的子集。

该状态应由与升级权限不同的管理员密钥更改。理想情况下,此帐户可以只读方式传递给你的大多数指令。

这将允许你在发生事件时快速做出反应,例如黑客攻击、突然出现的错误,甚至像稳定币脱钩这样的生态系统问题。

试想一下,一起黑客攻击正在发生,但你无法阻止它,因为紧急程序升级没有通过。发布一个简单的状态更改指令会更容易,该指令将阻止易受攻击的指令被调用。

Solana 技巧 7

通过在你的帐户结构体中添加空填充物来面向未来。看到这个 _reserved 字段了吗?它将允许将来向此类型添加其他字段,而不会破坏向后兼容性。

你应该添加多少填充?理论上,如果你将来使用 Option<> 类型,那么只需一个字节就足够了。因为 Option::None 序列化为单个 0 字节,(而 Option::Some<> 将是更长的数据),现在向你的结构添加单个 0 字节将允许它被扩展。如果你不想依赖 Option 类型(因为你可能想要一个静态的结构大小),你可能应该至少保留 64 字节的填充,以覆盖可能添加的大多数类型。

Solana 技巧 8

在为你的程序的链上数据设计结构布局时,尝试将任何固定大小的值放在结构的开头,同时将可变大小的值(例如 options 或 vectors)放在结构的末尾。这将允许你始终在静态偏移量处读取固定大小的值,这对于某些需要固定偏移量的 RPC 方法可能很有用

Solana 技巧 9

在决定你的 PDA 的 种子(seeds) 时,遵循这个简单的模式:

  • 每个 种子(seed) 都以一些静态字符串开头,例如 "pool"
  • 在每个 种子(seed) 前缀的末尾添加一个特殊字符,例如 "pool" -> "pool:"
  • 或者确保你的任何 种子(seed) 前缀都不是另一个 种子(seed) 前缀的子字符串
  • 例如:应该避免不同的帐户 种子(seed) "pool" 和 "pool_admin",因为 "pool_admin" 以 "pool" 开头。但是 "pool:" 和 "pool_admin:" 是可以的。
  • 接下来,在你的 种子(seed) 中使用 pubkey。例如 "pool:" + mint.key()
  • 最后,如果需要,你可以添加数字 ID
  • 避免使用可变长度的字符串或字节。如果必须使用,请将它们放在你的 种子(seed) 的最末尾。
  • 总体模式:静态字符串 + pubkey(s) + ID
  • 保持简单。不要添加不必要的 种子(seed)

此模式应涵盖 >90% 的情况!

Solana 技巧 10

Anchor 的剩余帐户(remaining accounts) 是危险的。极其小心地使用它们。

这些帐户基本上是未经检查的帐户,通常用于 CPI。

当使用它们时,你必须手动验证这些帐户,这包括所有者检查、种子(seed) 检查、鉴别器和数据。通常,开发人员忘记对这些账户执行所有者检查,只是将它们反序列化。或者忘记了其他的检查。这可能会导致严重漏洞,而且我在过去一年中越来越多地看到这种情况。

因此,请确保在使用剩余帐户时正确检查它们:

  • 剩余帐户的预期数量是正确的
  • 对于每个帐户:始终检查所有者和鉴别器。如有必要,还要检查 PDA 种子(seed) 和内容。

Solana 技巧 11

在 solana 上使用 log 系统调用进行日志记录会消耗大量计算资源,并且如果日志太长,则会截断日志。但是使用相同数量的数据(你要记录的字符串)作为调用数据的 CPI 很便宜,而且不容易被截断。这就是我们有一个 noop 程序(什么都不做的程序)和用于 self-cpi 的 noop 指令的原因。使用它们意味着使用你的日志作为参数来调用程序或指令,并且不对它们做任何事情。然后你可以构建一个索引器来离线读取这些日志。请注意,如果其他程序调用此端点或程序,这可能会容易受到日志欺骗的攻击,因此必须进行身份验证,以便在外部人员调用时调用失败。

在此处阅读有关此内容的原始讨论之一:https://github.com/coral-xyz/anchor/issues/2408

Anchors 的 emit_cpi! 宏使用完全相同的模式来创建廉价的事件日志。

有趣的事实:这也是帐户压缩的工作原理 - 大量数据以廉价的调用数据而不是昂贵的链上数据来保存。

Solana 技巧 12

Solana 可以并行处理多个交易,因为每个交易都必须预先定义它将读取和写入的帐户。如果两个交易从同一个帐户读取,它们可以并行执行。如果一个写入,另一个读取,它们必须按顺序执行,否则当不清楚读取发生在写入之前还是之后时,我们将遇到共识问题。

明确地说,这意味着如果你的程序的两个指令都写入同一个帐户,solana 无法并行化它们。当你的程序被大量调用时,这变得很重要 —— 就像 AMM、模因联合曲线或在具有重大交易量的事件期间的铸币合约一样。

在这些情况下,你希望你的合约调用不会相互阻塞,这意味着你想要减少对同一帐户的写入。那么通常会写入哪个帐户呢?最好的例子是你的费用金库。当你在每笔交易中收取费用,并且所有费用都发送到同一个帐户时,这将在同一个帐户上产生大量的写入锁,并阻止你的交易被并行化。

一种解决方案是使用多个费用金库帐户而不是一个。这会将写入锁在它们之间进行平衡。

但是你如何让每个用户实际使用不同的收费金库呢?

@tensor_hq 提出了一个聪明的想法,即使用指令中另一个 Pubkey 的最后一个字节作为 分片(shard) 标识符。该字节可以是 0 到 255 之间的任何值,因此我们将有 256 个不同的费用金库。

如果我们从中派生 分片(shard) 的 pubkey 在事务与事务之间有足够的差异,那么我们将有效地在 256 个帐户之间分配写入锁。

唯一的缺点是管理员需要一个脚本来从这 256 个帐户中收取费用,但这不应该是一个问题。

费用金库不是唯一被大量写入锁定的帐户。下次你编写代码时,查看你的程序,看看哪些写入锁可能会成为问题,并看看是否可以分配它们。

Solana 技巧 13

即使 rust 也无法让你免于编程错误,那就是数学错误。这包括你只是不擅长数学的错误,以及计算环境产生意外(对于程序员)行为的错误,例如溢出或下溢。如果你从 0_u8 中减去 1 会发生什么?无符号字节没有负数,它们是无符号的。结果是 255。在金融应用程序的上下文中,这非常可怕。从我的帐户中扣除 1 个代币,现在我就变得富有了?对一个用户来说是好事,对协议来说不是好事。Rust 为你提供了保护自己的工具:

安全的数学。像 checked_add、saturating_add 等函数允许你安全地处理这些边缘情况。作为经验法则:永远不要直接使用 +、- 或 * 运算符,而是在进行金融数学运算时改用它们的安全版本。只有当你可以在数学上证明(理想情况下在你的代码旁边的注释中)永远不会发生溢出或下溢时才使用它们。

Solana 技巧 14

主流媒体可能已经告诉你 Solana 上没有重入。不要相信他们的谎言。存在一类特殊的重入,并且通常是安全漏洞的来源:自重入。

它是什么?正常的重入通常涉及两个合约。第一个易受攻击的合约将资金转移到另一个合约,该合约是一个攻击者合约。攻击者合约执行一个接收者Hook函数并回调到易受攻击的合约。现在,易受攻击的合约在转移过程中(一种过渡状态)被重新进入,这会导致漏洞。

Solana 禁止这种模式:A -> B -> A

B 不允许在它已经存在于调用堆栈中之后调用 A。

但是在 Solana 上,有一个重要的例外:自重入。允许 A -> A 调用。

现在,只有当攻击者可以控制这样的调用并将其变成意想不到的事情时,这才会成为问题。例如,合约 A 允许你调用任何其他合约 B。攻击者可以尝试指示 A 不仅调用任何其他合约,而是调用自身。

什么样的合约是这样工作的?实际上有很多!例如,多重签名或 DAO。

在 MS 或 DAO 中,用户创建提案以使用一些参数调用另一个合约。例如,调用代币程序进行代币转账,或调用 raydium 进行交换。

构建不当的 MS 或 DAO 程序可能容易受到自重入的攻击,在这种情况下,用户创建一个提案,该提案调用多重签名本身以放弃投票。

一旦提案被执行,该执行将调用多重签名程序,看到可执行的提案,放弃对其的投票,使其变为不可执行。在此之后,执行完成并且提案被执行。或者不可执行??

好吧,该提案处于一种奇怪的状态,不应该真正发生。

这些类型的自重入错误可能发生在任何调用用户选择的程序的程序中。通常最好通过显式禁止自重入调用来防止它们,方法是在调用之前检查程序地址。

有时,确保 CPI 不写入我们程序本身拥有的任何帐户就足够了。

最重要的是,你应该知道这种模式的存在。如果你是一名开发人员,它可能会让你免于错误。如果你是一名审计员,这可能是你的下一个发现 ;)

自我宣传时间:感谢阅读我两周的每日帖子。我喜欢写它们,并且会继续这样做。如果你正在 Solana 上构建程序并正在寻找安全审计,请给我发 DM。Accretion 的使命是提供最好的 Solana 审计,同时使审计变得容易。

Solana 技巧 15

我在每隔一次的审计中都看到了这个极其简单但通常很严重的错误。你能发现它吗?

如果你发现了,就拍拍自己的背吧。如果没有,嗯,这很简单:永远不要只 初始化(init) 关联代币账户。而是 有需要就初始化(init_if_needed) 它们。

为什么?当账户已存在时,Anchor 的 初始化(init) 约束将失败。并且任何人都可以为任何权限创建 ATA 账户,这就是他们的全部意义。

在这种情况下,攻击者可以在调用此指令之前为此 (pool) 和 铸币(mint) 创建 ATA。在创建 ATA 之后,此指令是不可调用的,因为 初始化(init) 将始终失败。有时,当他们可以使用不同的权限并在你的协议中实现相同的目标时,该错误并不那么严重。但是通常,对于一个特定的 铸币(mint),只能有一个 (pool),因为它通过 铸币(mint) 的 key 来 种子(seed)。因此,即使 (pool) 甚至还不存在,我们也可以为特定的 铸币(mint) 的 (pool) 创建 ATA 账户 - 并有效地 DoS 你的协议,使其无法在其 (pool) 中使用特定的 铸币(mint)。

总结一下:攻击者可以通过为你的协议创建代币账户来有效地 DoS 你的协议。这很有趣,不是吗。

所以请记住:ATA 权限被 种子(seed) 了吗?使用 init_if_needed

(而且,需要审计吗?给我发 DM 兄弟)

Solana 技巧 16

已经有关于此内容的精彩帖子和文档,因此我将保持简短。

当你的指令采用授权账户作为签名者输入,并且需要支付账户创建或重新分配的费用时,添加一个单独的签名者作为交易的付款人可能是明智的。用户仍然可以选择使用相同的权限来支付费用,方法是将它传递给付款人和权限,但他们可以选择从不同的账户支付。这提高了你的程序的生态系统互操作性,因为当其他程序通过 PDA 机制充当权限时,他们可能在 PDA 账户本身上没有足够的资金。

请注意,用户始终可以为交易费用指示单独的费用付款人,因此这主要适用于与租金或协议费用相关的费用。

以下是关于此内容的一些其他资源:

https://developers.metaplex.com/guides/general/payer-authority-pattern

https://x.com/blockiosaurus/status/1874868866716950831

Solana 技巧 17

在编写程序时,养成编写许多自定义错误代码的好习惯,并将它们附加到你的程序可能遇到的每个可能的失败中。例如,将它们放入你的约束、你的验证以及任何可能返回错误函数的函数中。这样,你不仅可以在错误发生时更轻松地调试错误,而且它还可以帮助你的测试设置。特别是,我还建议为每个错误情况编写至少一个测试,如果你编写了许多错误情况,这应该会让你获得相当不错的代码覆盖率。

我知道,这是一项非常麻烦的小工作,但这将显着提高你的代码质量,并且你会在错误进入生产环境之前发现它们。

Solana 技巧 18

为你的程序权限设置多重签名,例如升级权限或内部管理权限。这些多重签名应该保护你免受多种情况的影响。首先,密钥丢失,例如意外删除或设备损坏或丢失。可以通过稍微降低多重签名的阈值(理想情况下,通过进行适当的安全密钥备份)来防止这种情况,以便在成员无法投票的情况下,多重签名仍然可以执行某些操作。

你需要防范的下一种情况是密钥泄露。这意味着一些恶意人员获得了其一个或多个成员的密钥。为了防止这种情况,你需要将阈值设置得足够高,以使恶意方无法自行执行任何操作,但也要设置得不能太高,以免给他们超级少数,从而阻止多重签名。

最后,你可能想要考虑成员之间发生的合法纠纷。应该允许哪种多数来决定一项行动?

所有这些都可以归结为几个规则:

(n/m 多重签名表示 m 个成员中的 n 个,意味着有 m 个成员,阈值为 n)

1/m 不应该用于大多数情况 - 它们只是授予任何成员密钥的完全访问权限

m/m 多重签名也不应该使用 - 每个成员都可以劫持这个多重签名,或者单个密钥的丢失会导致整个多重签名的丢失

因此,最小的有意义的多重签名是 2/3 多重签名。它可以最大限度地防止一个成员丢失其密钥或变成流氓成员,因为其他两个成员始终可以通过一项提案。它还建立了一个简单的多数投票系统。

2/4 多重签名容易受到两派不同意见的影响,他们可以通过另一派不同意的提案。

这就是为什么我通常推荐使用公式

floor(m/2)+1 / m

作为多重签名配置。这会转换为

2/3、3/4、3/5、4/6、4/7 等都是不错的配置。

此外,有时给某些人在多重签名中赋予更多权利是有意义的,这可以转换为给他们两个成员密钥而不是一个。类似于多个董事会席位。

就这样,确保你的程序安全,并使用适当的多重签名。如果你想知道使用哪种多重签名,我推荐使用 @SquadsProtocol

Solana 技巧 19

为最坏的情况做好准备非常重要。如果你正在运行一个链上程序,那么有人在你的程序中发现漏洞并利用它,导致你和你的用户的资金损失,这是一种真正的可能性。而现实是,即使是经过良好审计的项目也可能发生这种情况。因此,我建议你制定一个在发生意外情况时的计划。因为当这种情况发生时,你将处于恐慌模式,并且无法做出最佳决策。以下是一些现在要准备的事情:制作一份事故文档,其中概述了整个过程。你应该有一个包含关键联系人信息的部分,例如律师、执法部门、审计员、可能能够冻结代币的 (bridge)/CEX/稳定币(stablecoin) 联系人、主要利益相关者以及安全联系他们的方式。接下来,你应该预先定义你团队中的职责:谁负责解决问题,谁负责沟通,谁负责调查黑客攻击?

你应该有一种预定义的方式来暂停合约,无论是通过程序升级和预先编写的冻结合约 分支(fork),还是通过管理指令。

理想情况下,你应该为你社区准备一些预先编写并经过律师批准的消息。

准备好所有这些而永远不需要它,远胜于什么都不准备然后在需要时才准备。显然,你可以为这种情况做更多的准备,但这对于今天来说已经足够了

Solana 技巧 20

在进行金融数学运算时,你通常使用 uint。让我们考虑一下它们的基本 舍入(rounding)。舍入(rounding) 发生在任何产生非整数结果的操作中,但 舍入(rounding) 为一个完整的整数。在 rust 中,这些通常是向下 舍入(rounding) 的。

例如:

2_u32 / 3_u32 = 0_u32 (0.66)

3_u32 / 2_u32 = 1_u32 (1.50)

5_u32 / 3_u32 = 1_u32 (1.66)

在这里玩玩。

https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=c7fab50826d2a7aa4ae69df9468ee3ba

这对我们意味着什么?这取决于我们正在计算什么。如果我们正在计算协议应该给用户的代币数量,那么向下 舍入(rounding) 是好的。如果我们正在计算用户应该给协议的代币数量,那么向下 舍入(rounding) 是不好的。通常,我们希望有利于协议,否则我们可能会容易受到 切香肠(salami slicing) 攻击。

在检查你的代码时,找到发生 舍入(rounding) 的地方,并思考你正在为谁的利益 舍入(rounding)。确保没有用户可以获得可利用的优势。

Solana 技巧 21

舍入(rounding) 类似的 Rust 中的另一个问题来源是类型转换。

在 Rust 中,我们可以使用 as 关键字在彼此之间转换类型。例如,1000 as u161000 as u8。第一个将导致 1000_u8,而第二个转换会将数字截断为 232_u8,因为 u8 只能跟踪高达 255 的数字。

因此,我们需要小心地将数字转换为可能不适合它的类型。

当将浮点数转换为整数时,rust 将执行饱和转换。例如,300.0_f32 as u8 将返回 255。如果我们使用 _to_int_unchecked::<u8> 'raw' 执行此转换,它将返回 44.

通常,使用 ::from 和 ::into 方法而不是 as 来转换 Rust 中的数字更安全。如果你必须使用 as 关键字,请确保不会发生意外的截断。

你可以在这里找到好的学习材料:https://doc.rust-lang.org/rust-by-example/types/cast.html

Solana 技巧 22

部署 Solana 程序后,确保它运行正确至关重要。因此,让我们来谈谈程序监控。我们可以监控程序的多个方面:

  • 指令调用及其值
  • 全局状态账户
  • TVL
  • 费用
  • 新账户创建
  • 事件

最简单的方法可能是监视预定义的帐户,因为你可以简单地定期轮询它们。当你有程序的 IDL 时,使用 anchor 创建监视脚本很简单。以下脚本将观察 MetaDAO 的主 DAO 帐户,每 60 秒读取一次 proposal_count 字段,并在发生更改时提醒我们。坦率地说,这是程序监视的最简单形式,并且不是很复杂,但这是一个很好的起点。

Solana 技巧 23

你想要将你的应用程序的访问权限 门禁(gate) 给列入白名单的用户吗?有一些模式允许这样做。第一个是 NFT 门禁(gated) 访问。在这种情况下,用户提供一个签名者钱包,以及这个签名者拥有的一个预定义 collection 的 NFT 代币帐户。重要的是要检查他们实际上不仅拥有这个代币帐户,而且 amount = 1,否则攻击者可以使用一个空的代币帐户绕过检查(以前见过这种情况......)。

第二种流行的模式是通过白名单 PDA 来 门禁(gate) 注册指令。这样的 PDA 具有 种子(seed) ["whitelist", whitelisted_user.key()] 或类似的东西。whitelisted_user 必须签署交易,并提供由管理员先前创建的 PDA 帐户。

另一种可能的解决方案是单个白名单帐户,其中包含一个 Vec<Pubkey>,其中包含列入白名单的帐户。但我建议使用 PDA 解决方案而不是此方法。

最后,另一个优雅的解决方案可以是 merkle 树。这允许你通过单个 根哈希(root hash) 定义完整的白名单,并且用户可以显示包含 证明(proof) 来访问 门禁(gated) 指令。此方法具有非常低的链上占用空间,但需要更多的链下 工程(engineering) 来向用户提供完整的 merkle 树,并且你需要实现 证明(proof) 生成。

Solana 技巧 24

表示 \$420.69 的最佳方式是什么?这是一个带小数点的数字,因此浮点听起来像是一个不错的选择。这是一个糟糕的主意。为什么?因为该数字表示资产价值。它必须非常精确。如果你的银行余额是 \$100,,并且在你存入 \$1 之后,余额变成了 \$100.99?你会高兴吗?这种情况可能会发生在浮点数上,因为由于它们的工作方式,它们本质上近似于数字。简而言之,浮点数使用二进制 分数(fraction),而我们在财务中想要的是精确的十进制数字。

给你一些例子:

0.1 + 0.2 = 0.30000000000000004

0.1 + 0.1 + 0.1 + 0.1 + 0.1 +0.1 + 0.1 + 0.1 + 0.1 + 0.1 = 0.9999999999999999

除此之外,让用户提供浮点输入可能会导致意外的错误,因为浮点数实现了特殊的数字,例如 +0-0NaN+inf-inf。相反,你总是应该使用整数,理想情况下是无符号整数。例如,你可以将 $420.69 表示为 42069,并将其定义为 美分(cents) 数。你还可以将其放大并将其表示为 42069000,甚至更大的数字,以便在计算中获得更高的精度。请记住:朋友不会让朋友在链上使用浮点数。

Solana 技巧 25

构建(building) 更大的程序时,有两种设计理念:

1) 将所有内容放在一个大程序中

2) 将程序拆分为子组件,创建一个多程序架构

将所有内容放入一个程序中是不言而喻的,我想谈谈另一个选择 - 将你的程序拆分为组件。

首先,举个例子。让我们考虑一下 MetaDAO。它实现了一个系统,其中包含多个 DAO、提案、将代币拆分为条件代币并进行交易。你可以将所有这些都塞进一个程序中,但他们选择了另一种架构。有一个 Autocrat 程序,它处理 DAO 和提案,并充当权限。接下来,我们有一个单独的条件金库程序,它负责 拆分(splitting) 和 合并(merging) 条件代币。最后,我们有 AMM,这是交易发生的部分。这种架构带来了一些优势:首先,它是 模块化(modular) 的。这意味着可以单独使用单独的组件,也可以更新或 替换(swap) 它们。但最好的部分之一是它创建了 特权分离(privilege separation)。在 Solana 上,允许程序写入自己的帐户。使用多程序架构,你可以确保某些帐户只能由某些代码部分写入。在 MetaDAO 示例中,任何条件金库代码都不会更改提案帐户。由于 CPI 很便宜,因此这是一种很好的模式。

但是多程序架构也有一些缺点。首先,它会使程序更加复杂。其次,运行时将你的调用深度限制为 4,并将你的 CPI 调用次数限制为 63。所以,基本上不要过度使用 CPI。最后,你会引入一些运营复杂性。例如,当在一个子程序中引入更新时,你可能还需要更新其他程序以支持这些更改。因此,理想情况下,你希望同时更新它们,或者至少在其中一个程序正在更新时暂停操作。这可能很棘手。但是,我建议为更大的程序使用多程序架构。你可以使用 updating 状态实现全局状态变量来解决最后一个问题。

Solana 技巧 26

Solana 程序中没有使用像 Anchor 这样的框架的一个常见 bug 在于帐户创建,程序经常这样做。新帐户由系统程序创建和分配,系统程序为此目的提供了方便的 create_account 函数。本能地,使用 create_account 函数应该是创建新帐户并完成它的完美方法,对吗?问题是它有一个 (catch): create_account 要求要创建的帐户具有 0 Lamports。如果你熟悉 Solana,你可能知道任何人都可以增加任何其他帐户的 Lamports,基本上是通过进行转账来实现的。因此,任何人都可以通过向该帐户转账单个 Lamport 来使你的 create_account 调用失败。他们所需要做的就是提前知道帐户地址,这通常是 PDA 帐户的情况。

那么,作为开发人员,你应该怎么做呢?嗯,你必须手动完成 create_account 会为你做的事情。这意味着,分配(allocate) 所需的空间,转移(transfer) 剩余的租金,并将该帐户 分配(assign) 给你的程序。

像 Anchor 这样的 框架(framework) 会在 幕后(curtain) 为你处理这个问题。

Solana 技巧 27

在 Solana 程序中查找资金损失漏洞的一个快速方法是查找程序执行的 invoke_signed 调用,并检查被调用的程序是否已正确验证。 在最坏的情况下,攻击者可以提供任意程序来调用,接收来自调用者的签名,从而使他们能够耗尽所有签名者的资金。 此外,请注意,签名者会扩展到 CPI。

因此,每当你的程序调用其他程序时,请务必检查被调用者的地址。

Solana 技巧 28

请注意,每当你的 Solana 程序与其他程序交互时,你都会引入交易对手风险。 如果他们的升级权限受到损害,他们的程序被利用,或者即使他们的程序被升级并且旧的结构或指令变得过时,你的程序可能会变得无法使用,或者在最坏的情况下被利用,或直接损失资金。 因此,在集成外部程序时,最好制定一些策略。

首先,不可升级的程序主要只包含现有代码的利用风险。 如果代码可靠且经过良好审核,则可以以最小的顾虑使用它们。

对于可升级的程序,你应确保他们的升级权限得到良好保护,最好是通过多重签名。 接下来,你应该确保你以尽可能少的权限调用他们的程序。 尝试传递尽可能多的只读账户。 仅在绝对必要时才传递可写账户或签名者。 理想情况下,以这样一种方式设计调用,即如果他们的程序变得恶意,他们无法完全耗尽你的程序,并且损失将受到限制。

Solana 技巧 29

找到许多潜在改进和维护高编码标准的一个快速方法是以尽可能烦人的 lints 方式来 lint 你的程序。 例如,运行如下内容

cargo clippy --all -- -W clippy::all -W clippy::pedantic -W clippy::restriction -W clippy::nursery -D warnings

可能会产生数百条建议。 你可以浏览此输出,驳回大多数过于学究的建议,并寻找偶尔合理的改进。

你不必每次编译项目时都这样做,但最好不时检查一下。

Solana 技巧 30

我时不时看到的一种错误模式是在使用 Vectors 并在 for 循环中逐个验证其内容时发生的。

这里的错误可能看起来很明显 - 当 vector 为空时会发生什么? for 循环完全跳过,检查通过,执行 for 循环后的代码。 有时这根本不是问题。

但有时只有在存在有效元素时才应执行 for 循环后的代码。

解决方案很简单:要求 vector 不为空!

Solana 技巧 31

当你拥有程序的 IDL 时,就可以很容易地为该程序构建客户端。 如果你正在用 Rust 构建,Anchor 提供了 declare_program!() 宏,或者你可以使用 Sanctum 的 solores。 在 TS 中,你也可以使用 Anchor 的 typescript 库,或 Metaplex 的 solita(但似乎未维护)。

还有许多在线工具,我最喜欢的是 https://bettercallsol.dev ,它可以导入 IDL 或链上交易,并允许你更改一些参数并重新发送交易。

此外,IDL 不仅允许你构建客户端,它们还使你能够破译链上数据。 例如,使用

anchor account --idl some-idl.json targetprogram.TargetAccount 11111SomePubkey1111111

你可以轻松读取和反序列化链上账户 11111SomePubkey1111111。 IDL 是你的朋友

Solana 技巧 32

按使用量计算,最受欢迎的 Solana 程序之一是关联 Token 程序。 它解决了以下问题:你想将 token 从你的钱包发送到其他人的钱包,但他们还没有该特定 mint 的 token 账户。 这就是 Solana 的账户模型的工作方式 - 没有为他们自动预分配的接收者 token 账户。 必须创建它。

你可以通过简单地创建一个随机 token 账户并将其分配给他们来解决这个问题。 将 token 发送到该账户,现在他们就拥有了这些 token。 足够简单,对吧? 好吧,这只是有点用。 问题是他们将无法轻易找到这些 token。 这就像在全球某个地方为他们开设一个信托,而没有通知他们。 也可能他们已经拥有该 mint 的 token 账户,但你只是不知道。 或者他们可能已经拥有该 mint 的多个 token 账户。 关联 token 程序解决了这个问题。

它定义了一种确定性的方式来推导他们的特定钱包、token mint 和正在使用的 token 程序的地址。 该地址是关联 token 程序的 PDA,通常称为关联 token 地址或 ATA。 关联 token 程序允许你在该地址分配一个 token 账户,并且它会将所有权分配给从中派生的钱包。 然后,该派生地址通常用作该特定 mint 的主 token 账户,并且索引器、钱包和发送者更容易找到它。

在另一个技巧中,我提到了使用 "init" 与 ATA 的可能的陷阱,因为任何人都可以创建 ATA,从而阻止 init。 实际上,ATA 程序提供了一个实用的内置解决方案,你可能已经在链上看到过:CreateIdempotent - 它会尝试初始化一个新账户,但如果它已经存在,则会优雅地返回。

另一个有趣的事实 - ATA 不能保证它所派生的钱包是 token 所有者,因为在分配后,token 所有者可以简单地将该 token 账户重新分配给另一个所有者。 但是,这不适用于 token22 ATA,因为这些 ATA 是使用不可变所有者扩展进行初始化的。

你可以在命令行中使用以下命令计算 ATA

solana find-program-derived-address ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL pubkey:$WALLET pubkey:$TOKENPROGRAM pubkey:$MINT

Solana 技巧 33

如果你是 Solana 开发人员或审计员,你可能熟悉 Token 程序。 该程序允许你执行所有预期的操作:创建新的 token mintmint、销毁和转移 token。 但有一些不太为人所知的功能:首先,token 的 mint(可以将其视为整个货币的配置)可以包含冻结权限。 这是一个定义的 pubkey,允许冻结 token 账户 - 这意味着它们将无法使用。 显然,这样的功能是危险的,可能会被用于 rugs,因此通常的做法是完全撤销此权限以及 mint 权限。

另一个功能是委托 - token 账户所有者可以将他们的一些 token 余额委托给另一个账户,这意味着该另一个账户将被允许花费这么多的 token 账户。 这种委托可以随时撤销。

创建此功能是为了让用户可以将一些金额委托给程序,并确保程序不会提取超过委托的 token,但实际上很少使用它(我不认为我见过任何实际使用此功能的生产程序)。

Token 程序的最后一个不常见功能是多重签名。 token 程序允许将 mint 权限、账户所有者或委托人定义为 M (总数 N) 多重签名,这是另一个实际上从未使用过的功能,而是使用了更彻底的多重签名实现,例如 squads 或 realms。

你应该了解的另一件事是,用户 token 账户可以随时被用户关闭。 如果你尝试构建类似空投程序的东西,该程序在列表中收集 token 账户,然后在一个指令中尝试将 token 发送到该列表中所有定义的 token 账户,这可能会变成一个错误模式。 如果其中一个用户关闭了他们的 token 账户,则转移可能会失败。

通常,几乎永远不建议在你的程序的任何地方硬编码或保存用户可关闭的 token 账户。 相反,你只需保存他们的权限,并允许使用他们的任何 token 账户。

我想提到的最后一个小功能是 token 账户所有权是可以转让的。 这意味着当我有一个包含 1 USDC 的 token 账户时,我也可以将该账户的所有权重新分配给你的权限,而不是使用转移指令将该 USDC 发送给你。 听起来很傻,但从理论上讲,这可以用来规避转移费用(无论如何,使用旧的 token 程序是无法真正强制执行的,除非使用冻结-解冻模式),或者让转移对窥探者隐藏起来 - 稍后会详细介绍这一点;)

Solana 技巧 34

solana cli 非常强大,基本上完全取代了 solana explorer。

获取有关 solana 账户的信息:

solana account $PUBKEY

获取账户的交易历史记录:

solana transaction-history $PUBKEY

获取有关交易的完整信息:

solana confirm -v $TRANSACTIONSIGNATURE

获取区块信息:

solana block $BLOCK

流式传输实时交易日志:

solana logs

一切都在那里 - 你还需要什么?

Solana 技巧 35

旧的 solana token 程序去年通过添加一个名为 Token22 的新 token 程序进行了扩展。 为什么它被称为 token22? 没人知道。 他们可能想在 2022 年推出它,以击败 etherim 的 token2049 时间表。 无论如何,更新后的 token 程序包含许多有趣的扩展,同时还 1:1 支持经典的 token 功能。 新的扩展包括机密转移、转移费用、可关闭的 mint、不可转让的 token、永久委托人、转移Hook、token 账户转移所需的备忘录、token 账户的不可变所有权、默认账户状态(例如,创建时冻结)、cpi 保护以及一些非功能性扩展,例如计息 token、元数据和元数据指针。 这有很多功能,尤其是在尝试在你的协议中实现 token22 token 时需要考虑很多事情。 许多功能相对无害,但在你的程序中实现 token22 支持时,应格外小心地考虑某些功能。 例如,转移费用意味着存款和取款需要支付费用。 可关闭的 mint 可能非常危险,你可能不想支持它们。 永久委托人始终可以从你的协议金库中提取资金。

我希望你考虑每个扩展,它可能如何影响你的协议,并决定你是否要支持它。 然后,每当有人向你的协议引入新的 mint 时,如果所讨论的 mint 实现了你不喜欢的扩展之一(例如永久委托人),则禁止它。 在另一个提示中,我将向你展示如何轻松实现这一点。

Solana 技巧 36

你知道吗,如果你的协议要同时接受原生 sol 和 spl token 作为存款,则无需实现这两个版本。 相反,你可以只实现 spl token 版本,并且用户将能够将其原生 sol 作为包装的 Sol 存入。

现在,关于包装的 Sol 一个有趣的事实:

wSol 不是由第三方发行的某种衍生品。 不,它实际上是 Solana 原生的,并且在 token 程序中具有自己的特殊实现。

要创建 wSol,你需要为原生 mint 创建一个 token 账户,即 `So11111111111111111111111111111111111111112`。 然后,你将 Sol 发送到该账户,并调用 token 程序指令 `SyncNative`,该指令会将 token 账户的金额更新为你已存入的 Sol 减去租金。 现在,你可以像使用任何其他 token 账户一样使用该 token 账户。

要解包 Sol,你必须关闭 token 账户,并且其中包含的所有 Sol 将被转移出去,作为原生 Sol 返回。

Solana 技巧 37

让我与你分享一个很大程度上未记录的 Anchor 秘密:你可以在你的 Anchor 程序中定义回退函数,当没有定义的指令与给定的指令鉴别符匹配时,将调用这些函数。 要定义回退函数,只需添加一个具有以下签名的函数

pub fn fallback(
  program_id: &Pubkey,
  accounts: &[AccountInfo],
  data: &[u8],
) -> ProgramResult {}

到你的程序模块中。 将使用如上所示的原始 solana 入口点参数调用此函数。 请注意,你只能定义一个这样的函数!

Solana 技巧 38

程序如何知道正在调用哪个指令? 程序如何安全地区分不同的账户类型? 答案是:鉴别符。 它们是什么? 不,它们不会让你卷入 DEI 诉讼。 鉴别符是指令数据或账户中的前几个字节。 例如,你可以简单地使用单字节鉴别符。 然后,如果指令数据的第一个字节是 0x01,我们调用程序中的第一个指令。 0x02,调用第二个。 等等。 实际上,Anchor 使用 8 字节鉴别符,并且它们不像 1,2,3 那样枚举。 相反,anchor 将 instruction name 与命名空间 global 一起哈希。 例如,initialize 指令的 sha256("global:initialize")。 然后,当调用你的程序时,Anchor 的调度程序检查指令的前 8 个字节,并迭代这些指令哈希的列表以找到匹配的一个。 当没有命中时,将调用来自先前提示的回退函数! Anchor 账户的工作方式相同,并且当你编写本机 Solana 程序时,你也应该为你的账户使用鉴别符:你的程序拥有的每种账户类型都应该有一个唯一的标识符序列,你将其放在账户的开头,以防止账户混淆攻击。 每当你的程序加载给定的账户时,它应该首先检查账户所有者和鉴别符。

你可能知道某些程序没有鉴别符 - 例如 token 程序。 token 程序的两个主要账户类型是 MintTokenAccount,并且它们通常通过其账户大小而不是使用鉴别符来区分。 但是,当你的程序也支持可变长度的账户(如 Token 程序的 Multisigs)时,这种方法可能会出现问题。 但是,在 Token 程序的情况下,这不是问题。 TokenAccounts 是 165 字节。 Mints 是 82 字节,Multisigs 是 3+n*32 字节,永远不是 165 或 82。因此,当 Token 程序拥有的账户长度为 165 字节时,我们可以确定它是 TokenAccount

Solana 技巧 39

Rust 有两种重要的注释类型,你应该知道:// 和 ///。 双斜杠只是普通的旧注释。 使用它们来为你的代码添加上下文、注释掉开发期间的内容,或证明你的不良编码风格是合理的。 如果你是一名千兆级开发人员,你还会在每次在代码中进行一些计算时添加一个简短的注释来描述你已实现的数学函数。 现在,三斜杠是文档字符串。 你将它们放在定义结构、字段、方法、函数或任何内容之前的行中。 此文档字符串支持 markdown 语法,可用于生成 HTML 文档。 许多编辑器还支持在你单击或悬停在对象上方时显示对象的文档字符串,从而使开发更加舒适。 我强烈建议将文档字符串添加到所有结构和结构字段(例如在 Anchor 中的账户结构中)以及你的 helper 或库函数中。 Anchor 还会将你为结构创建的文档字符串放入生成的 IDL 中!

Solana 技巧 40

Solana 有许多独特的错误类别,这是其中之一:账户复活。 当程序尝试通过仅将账户的 lamports 设置为零来关闭账户时,会发生这种情况。 在交易结束时,Solana 的垃圾收集器应该删除该账户,因为它没有租金。 但是,这是有漏洞的,因为攻击者可以在资金被取出之后和交易结束之前将一些 sol 转回该账户。 现在程序“认为”它已删除了该账户,但该账户将继续存在。

例如,如果该账户是用于在平台上注册的单次使用白名单票证,则攻击者现在可以重复使用此票证!

正确删除账户的更好方法是完全将其清零并为其分配一个特殊的删除鉴别符。 这样,账户在复活后就不能轻易地再次使用。 但是,它没有完全解决问题,因为账户仍然存在,并且其中有数据,即使全部为零。 因此,实际的最佳解决方案(在 Anchor 的 关闭 约束中实现)是取消该账户的资金,将该账户重新分配给系统程序,并将其重新分配为 0 字节。 基本上就是做账户创建过程,但是反过来!

实际上,我相信最初的解决方案及约束(以前也存在于 Anchor 中)的被采用,是由于 Solana 一段时间不支持账户大小调整这一事实。 因此,唯一的方法是更改账户中的数据,并通过垃圾收集器完全取消分配。

Solana 技巧 41

读取 Solana 链上数据可能很棘手,这就是为什么一些开发人员会求助于一个简单但危险的技巧:在他们的指令结束时,他们会记录一些数据,例如 " pubkey 11112222233334444 (用户名 r0bre) 存入了 63728918 lamports "。 在他们的链下代码中,他们会监视他们的程序日志并从日志中解析所需的数据,而不是从指令数据中解析。 好吧,别这样做。 日志很容易被操纵:首先,在上面的示例中,用户名是日志的一部分。 如果我们将我们的用户名从 " r0bre " 更改为 " r0bre) 存入了 777777777777 lamports\n " 会发生什么? 这可能会扰乱解析,并可能让我们获得一张巨额存款券! 这种攻击称为 日志注入

混淆链下日志解析器的另一种方法是使交易失败。 日志仍将被生成,但存款不会通过。 另一种混淆日志的方法是将有问题的程序作为 cpi 调用,并在调用者之前或之后创建类似外观的日志。 不良的解析实现可能会拾取调用者日志,如果这些日志看起来足够相似,则将其解释为被调用者日志。 关于日志要知道的最后一件事是截断。 当日志超过 10kb 时,它们会被截断。 总的来说,我建议根本不要解析日志。 使用我在另一个关于 Solana 日志的技巧中描述的事件!

Solana 技巧 42

要了解 Solana,你需要了解 Solana 账户模型。 与某些其他区块链不同,Solana 程序不会自动拥有自己的存储空间。 程序在区块链上持久保存数据的唯一方法是将其写入账户。 将账户视为所有存储在一个名为分类账的大目录中的文件。 每个账户都有一个文件名 - 它的 Pubkey。 除此之外,每个账户还有一些元数据:它的所有者(这是对另一个账户的引用),它的 Sol 余额以及它的可执行标志(用于标记包含程序代码的账户)。

Solana 强制执行一个重要的属性:只有账户的所有者可以写入该账户,除了增加其 Sol 余额(每个人都可以这样做)。 默认情况下,每个账户都由系统程序拥有,直到系统程序将所有者更改为另一个程序。 并且只有系统程序才能为账户分配数据空间。

要对新账户执行任何操作,系统程序需要该账户的签名。 请注意,不是运行时或 SVM,而是系统程序在这里请求签名。 运行时将仅验证签名的正确性。

一旦账户重新分配给你的程序,现在你的程序就可以管理对该账户的访问权限。 你的程序可以完全忽略该账户的 Pubkey 的签名,并且私钥的所有者将无法再对该账户执行任何写入操作。 既然你的程序拥有该账户,它就可以将数据写入该账户并减少其 lamports。 但是,作为账户的所有者,你的程序无法为该账户签名,除非它是 PDA。

在将账户作为输入时,在读取其数据之前始终检查账户的所有者非常重要。 因为那是你信任将数据放入该账户的程序。

Solana 技巧 43

我有时会看到这种模式,其中一个约束通过种子和 has_one 同时检查与另一个账户的关系。 通常,这种双重检查是多余的,只会浪费 CU。 当 has_one 检查的字段实际上是不可变的时,就是这种情况。 因此,在给定的示例中,没有代码会在初始化后更改 UserData 结构中的用户字段。 你可以只检查 has_one 关系,而不是进行这种双重检查! 但不要太在意这一点,多余的检查也可能是有益的,尤其是在维护偶尔更新的代码库时 - 多余的检查可以帮助降低未来更新意外绕过单个检查的风险。

Solana 技巧 44

你是否对编写一个小而简单的 Solana 程序时的臃肿感到恼火? 比如为什么二进制文件这么大? 为什么编译需要这么长时间,为什么它会使用这么多 CU? 所有这些依赖关系从何而来? 好吧,我有一个解决方案! 你可以使用 anza 的 pinocchio,而不是 solana-program,它可以为你的程序提供所有重要的基本 solana 功能,并且减少了臃肿。 没有依赖关系! 但要注意:当使用像这样的最小框架时,你需要自己负责实现安全功能。

https://github.com/anza-xyz/pinocchio

​​

Solana 技巧 45

谁支付交易费用? 在 Solana 中,交易的第一个签名者是被指定的费用支付者。 在运行时开始执行指令之前,此签名者会被验证为费用支付者,并且当交易抛出错误并被还原时,也会向其收费。 这意味着多件事:费用支付者必须是一个系统账户。 如果它是一个程序账户并且交易被还原,我们就无法向其收费,因为我们没有程序的批准。 此外,这还意味着费用支付者不仅是可写的,而且在交易失败的情况下也会被写入,这是一个有趣的副作用。 这意味着像 fusewallet 这样的个人多重签名必须始终在非多重签名账户中保留一些资金来支付费用。 但这也意味着我们可以拥有不同的费用支付者和签名权限。

请注意,交易费用支付者不会自动成为为租金分配付费的账户,即使这通常被标记为“付款人”。 程序可以定义任何签名者作为这些的专用付款人!

Solana 技巧 46

想要对程序的 CU 使用情况进行基准测试吗? 实际上,这非常容易! 你可以简单地使用 solana_program::log::sol_log_compute_units() 函数来打印当前的 CU 使用情况。 如果你想让它更好,你可以定义一个宏来为你的测量创建一个漂亮的 CU 使用情况打印输出。 例如,你可以使用此处定义的 compute_fn! 宏:

https://github.com/solana-developers/cu_optimizations?tab=readme-ov-file#how-to-measure-cu

使用这个,我们可以发现,在使用 to_account_info 之后,使用其字符串转换来记录 pubkey 非常昂贵! 使用内置的 Pubkey.log() 便宜得多

Solana 技巧 47

需要帮助来处理交易吗? 让我向你介绍两个变量:计算单元和优先级费用。 当你的程序进行计算时,它会使用计算单元:更多的工作意味着更多的 CU,而更少的工作意味着更少的 CU。 现在,默认情况下,调度程序不知道你的程序将使用多少 CU。 这就是为什么将 setComputeUnitLimit 指令添加到你的交易中可能很有用,该指令将告诉验证者你将仅使用最多 X CU! 这样,验证者可以更轻松地有效地安排你的交易。 要知道要请求多少,你要模拟你的交易并请求大约模拟量,并留出一些缓冲区。 请注意,CU 可能会因不同的签名者、Pubkey 或链上状态而波动,因此你应该模拟几种不同的场景,并确保提供 bumps! 此外,你可能还想添加优先级费用。 你可以使用 setComputeUnitPrice 指令定义你想要为每个 CU 花费多少微 lamports,这将增加支付给验证者的额外费用,从而激励他们包含你的交易。 现在你可能会认为使用高费用可以确保你的交易会被立即并始终包含,但不幸的是,目前情况并非如此。 由于调度程序内部的原因,支付较低费用的人可能仍然在你之前被包含。 但是,与没有费用相比,你有更好的机会获得费用。 这些天,始终包含一小笔优先级费用几乎是最佳实践。

Solana 技巧 48

始终小心使用 slots 边界或纪元边界作为程序状态的变更。 使用 jito 捆绑包或作为验证者,某人可能是与你的程序在状态变更之前进行交互的最后一个人,并且是在状态变更之后进行交互的第一个人。 有时,这会打开漏洞或其他攻击者的机会。 确保以一种方式设计你的程序,即他们不应通过跨越这些状态转换的捆绑包获得不公平的优势!

Solana 技巧 49

你可能听说过这个术语在流传:零拷贝。 让我帮助你理解它。 当你的程序处理大型账户时,你要避免在反序列化期间通常发生的任何不必要的拷贝 - 它们只是太昂贵了。 在反序列化你的账户数据时,通常 anchor 将拷贝所有给定的数据并将它们放入一个新的反序列化结构中。

零拷贝是一种编程模式,其中程序不拷贝数据,而是对同一组数据进行操作,只传递引用并在同一组数据上工作。 在反序列化期间,这意味着我们不创建拷贝,而是将反序列化的结构精确地覆盖在现有字节数组的顶部。

要将零拷贝与 anchor 一起使用,你需要在你的账户上使用

#[account(zero_copy)]

属性,该属性将实现必要的特征,并且在你的约束中,你可以使用 AccountLoader<> 而不是 Account<>。 然后,当你在你的指令中使用该账户时,你将需要在使用 load_init() 函数进行初始化时加载它,并在你想读写零拷贝账户时使用 load()load_mut(_)

使用零拷贝模式来处理非常大的链上账户,或者当你想要减少 CU 使用量时。 请注意,通过零拷贝,直到加载账户才检查鉴别符。 因此,如果你从不加载账户,则你的指令可能容易受到账户混淆攻击。

Solana 技巧 50

Solana 的系统程序强制执行 10MB 的最大账户大小。 要存储此数量的数据,你需要支付 73 Sol 的费用才能获得租金豁免! 现在这可能只是一个简单的事实,但在实践中还有更多需要考虑。 因为存储数据的账户将是程序拥有的账户,所以我们可能更喜欢使用 PDA。 然而,现在对于 PDA,我们遇到了以下问题:PDA 必须通过 CPI 创建,而不能通过顶级指令创建。 并且 CPI 有一个有趣的限制:账户在 CPI 期间不能增长超过 10240 字节。 我认为这是因为当你调用某个其他程序,并且该程序返回的账户比以前大了超过 10240 字节时,你的内存管理可能会变得棘手。

这意味着当你的程序需要使用大于 10240 字节的账户时,你有两个选择。 简单的方法是使用一个 Keypair-账户,在程序外分配它并将其分配给你的程序,让你很容易获得高达 10MB 的收益,但你不会有 PDA。 另一种选择(如果你想使用 PDA)是逐步重新分配,以 10240 字节为增量,直到你达到所需的账户大小。 要执行这两个版本中的第一个,你可以在初始化账户时使用 anchor 的零拷贝和 #[account(zero)] 约束,表明它是一个已分配的、充满零的账户。

请注意,当你查找此信息时,你会得到很多令人困惑的陈述。 让我们清理一下:Solana 账户可以是 10MB 大小。 你的 anchor 程序不能轻易地将这么大的账户分配为 PDA,因为它必须使用 CPI,而 CPI 限制账户一次增长超过 10240 字节。 虽然 PDA 可以大于该值,但你只需要在多个 CPI 中重新分配它们。

希望这能澄清问题!

Solana 技巧 51

在向你的指令添加大量账户时,是否遇到堆栈帧错误? 或者在使用大账户时? 或者当你的函数变长时? 你应该知道 Solana 只给你 4KB 的堆栈空间和 32KB 的堆空间来使用。 此外,你应该知道 anchor 默认会将你的反序列化账户放在堆栈上。 那么你能做什么呢? 首先,你可以通过将它们包装在 Accounts 结构中的 Box<> 中来使 Anchor 在堆上分配账户,但要知道这需要将账户反序列化为定义的结构。 避免堆栈错误的另一种方法是通过将函数拆分为非内联子函数来缩短函数。 这是因为在调用函数时,新的堆栈帧是全新的! 你还可以通过不在 Accounts 结构中定义未使用的账户来将它们放入 remaining_accounts 中,但要注意,如果你最终手动验证它们,你必须非常小心。 节省堆栈空间的另一种方法是使用我在之前的提示中描述的零拷贝。 这不会为你的账户分配新空间,而是将给定的账户字节 blob 重新解释为给定的结构,从而节省你的的堆栈空间。

Solana 技巧 52

Solana 在不断变化 - 你一年前学到的关于它的任何东西可能不再正确。 这是因为正在进行积极的开发,不断改进链并添加新功能。 新想法通过 Solana 改进文档 (SIMD) 提出。 你可以在 github 上找到它们。 目前,有很多关于 SIMD 228 以更改 Solana 的质押奖励的讨论。 SIMD 让你提前了解即将推出的 Solana 功能。

新功能通常通过功能门激活。 它们代表着准备就绪并等待激活的已实现功能。 你可以通过在终端中运行 solana feature status 来找到它们。 每个功能都有一个 pubkey,并且可以由核心开发人员在某个 slot 激活,以便所有验证者同时使用。

通常,功能首先在测试网上进行测试,因此你可以查看当前正在测试哪些功能!

作为审计员或开发人员,密切关注新的 SIMD 和功能,以了解区块链内部的变化始终是一个好主意。 有时会取消旧的限制,或者可以直接添加新的限制,从而直接影响你的工作。

Solana 技巧 53

Solana 使用一个非常简单的实现,该实现将你限制为 32KB 的堆空间,甚至不允许你释放内存:一个碰撞分配器。 这是可以想象到的最简单的堆实现。 例如:第一个 8 字节的分配会让你在 0x0 处获得一些内存,下一个 16 字节的分配会在 0x08 处让你获得一些内存,下一个分配在 0x18 处让你获得一些内存。 你只需不断增加已分配的内存,直到用完为止。 此分配器跟踪的所有内容是它从哪里开始以及到目前为止已分配了多少内存。 现在,这对于较小的简单程序来说很好。 但是,如果你的程序需要超过 32KB 的堆空间或想要释放内存,那么你就不走运了。

除非 rust 允许你使用你自己的自定义堆实现! 使用 #[global_allocator] 装饰器,我们可以定义一个堆实现来代替标准的 bump allocator。你可以使用像 smalloc 这样的库,或者编写你自己的简单堆实现,或者只是改进现有的 bump allocator,例如允许释放最后分配的对象。

现在理论上你可以做另一个技巧 - 使用 ComputeBudget 程序的 RequestHeapFrame 指令来请求额外的堆空间。它的工作方式类似于增加 CU 限制或费用,但它有其自身的问题,这些问题在以下博客文章中有所阐述,我强烈建议阅读:https://research.composable.finance/t/creating-the-solana-ibc-bridge-part-1/320

Solana技巧 54

这是一个我从审计 @MetaDAOProject 的新 Launchpad 中获得的有趣想法:为你的事件创建一个序列号。每当你的程序发出一个事件,它会在内部递增一个账户上的数字。这允许外部索引器轻松地创建一个事件序列,因为它们是在链上发生的。缺点是你的程序会更频繁地写入一个账户,有时会增加一次写入,而以前可能没有。一种在不写入账户的情况下做类似事情的方法是,在发出事件时记录一个时间戳,允许你的索引器按时间对事件进行排序。然而,一个离散的计数器将允许索引器不仅对事件进行排序,还可以确保它没有跳过任何事件。

Solana技巧 55

Solana 程序有时依赖于外部状态、时间或其他不断变化的条件,并且它们的状态或操作应该反映这一点。例如,一个程序可能希望每天根据外部价格发出奖励。或者一个 LST 可能每个 epoch 更新其价格。现在的问题是,程序不能仅仅在某些时间过去时,或者在其他此类条件下调用自身。在 Solana 上,必须始终有一些调用者主动调用该程序。这就是为什么许多程序构建所谓的 crank 指令 - 这些通常是无需许可的指令,可以推进程序的状态。例如,这样的指令可能会检查 epoch 并更新价格信息。一些程序使用许可的 crank,这意味着只有某些用户可以调用它们,但无需许可的 crank 已经成为一个常见的和广泛接受的标准。现在谁来调用 crank?通常,程序本身只是运行一个 cranking 脚本,定期调用 crank。其他时候,cranking 会被激励,这意味着 cranker 可能会收到一些 cranking 的资金,无论是租金回报、实际资金,还是通过能够控制 crank 前后第一个和最后一个指令来获得间接优势。作为一名审计员,关于 crank 有几个需要考虑的事情:首先,无需许可的 crank 始终是程序中的一个重要攻击面。一个恶意的 cranker 可能会做什么?他们对状态有什么影响吗?cranker A 和 cranker B 是否可以以不同的方式进行 cranking,以至于相同起始状态下的相同程序最终会处于不同的状态?另一个需要考虑的是 cranker 获得的优势。他们可以进行某种三明治攻击或套利,利用 crank 前后的价格吗?如果可能,这可以接受吗?当没有人 cranking 时与有人 cranking 的激励相比,影响有多糟糕?如果你的程序中有多个 crank,如果它们失去同步(一个被 cranking,另一个没有)会怎么样?

一种迫使用户进行 cranking 的方法是,向其他频繁调用的指令的 cranking 函数添加 CPI 或函数调用。这样,你可以让程序通过常规的、非显式的 crank 使用来 crank。这通常是一个优雅的解决方案,但需要你将 crank 的账户传递给其他指令。

Solana技巧 56

Solana 交易自然受到总交易长度的限制 - 1232 字节,这是从最大 ipv6 传输单元 1280 减去网络标头得出的。因为交易必须包括它们使用的账户地址,并且地址长度为 32 字节,所以理论上你可以传递给一个指令的账户数量限制为 38 个。但在实践中,由于额外的标头、指令数据和其他传递的字段,这个限制更接近 30 个。现在如果你想传递超过 30 个地址怎么办?有一种方法:地址查找表(通常称为 ALT 或 LUT)。查找表允许在链上准备一个地址列表,然后可以从指令中引用该列表,就好像这些账户是直接传递的一样。为此,你必须使用新的 v0 版本化交易,而不是传统的交易格式,允许你在一个单独的字段中指定查找表。目前这限制你每个交易使用 64 个地址,尽管查找表最多可以存储 256 个地址。

要创建一个查找表,你需要使用 createLookupTable 指令调用查找表程序,然后使用 extendLookupTable 指令分批添加新地址,每次扩展表 30 个地址。

它们的工作方式是创建提及查找表的交易,并说“我引用此查找表的第 46 个账户”。这意味着我们需要避免在通过引用进行查找时,地址可能发生任何变化的情况。这就是为什么查找表是仅追加的,并且只能在以最近的插槽(最后 256 个区块)为种子的地址处创建,然后只能在创建后经过 256 个插槽才能关闭,这样就不会在同一个地址打开一个新的查找表,并通过这种方法改变某个索引处的账户。

Solana技巧 57

Anchor v0.31 发布了!它为你的约束引入了一个新的账户类型:LazyAccount。要使用它,你首先需要启用 anchor-lang 的 lazy-account 功能。然后,你可以在任何你想使用的地方用 LazyAccount<> 替换 Account<>。那么它有什么作用呢?它是一个很好的新类型,主要用于从大型账户中读取单个字段。例如,你可能有一个保存大量状态的大账户,但你只需要从中读取一个 u64。现在你可以使用 LazyAccount 来不完全反序列化这个大账户,而只加载你需要的字段。为此,Anchor 为你的账户结构中定义的每个字段创建了辅助函数。例如,如果该结构有一个 authority 字段,现在会有一个名为

ctx.accounts.my

_account.load_authority()? 的函数,仅用于读取该字段。现在你可能想知道,安全性如何?嗯,Anchor 在任何情况下也会检查账户鉴别器和程序所有权,以确保安全。你仍然可以使用 load()load_mut() 反序列化整个账户,就像零拷贝账户一样。目前,只有在不进行 mut 的情况下加载时,你才能真正通过这种方法节省 CU,因为进行 mutating 将导致整个账户被反序列化,尽管这可能会在未来的版本中发生变化。所以你应该知道,通过仅将此类型用于从较大的账户读取单个字段,你才能获得最佳使用效果。

Solana技巧 58

当你需要在程序中测量时间的流逝时,你可以使用 clock sysvar,方法是 Clock::get()?。(你不必传递 sysvar 账户)。这个 sysvar 为你提供当前的插槽、epoch、epoch 开始的时间戳、我们知道领导者调度的未来 epoch,以及当前的 unix 时间戳。

当前的 unix 时间戳具有秒级分辨率。它是当前插槽的时间戳,因此此插槽内的所有交易将具有相同的时间戳。因为插槽只有 400 毫秒,这意味着连续的插槽可能具有相同的 unix 时间戳。验证者强制执行此时间戳只能增加,而不能在块之间减少。

因此,每当你使用时间戳进行状态更改时,你应该知道,即使你将一些过期值设置为当前时间,以下插槽可能仍然具有相同的时间戳,并且在过期窗口内。

Solana技巧 59

每当你的程序具有某种订单、报价、提案或其他由一方创建,并且可以由第二方执行或接受的账户时,你需要考虑这一点。是否可以更新此报价的账户,或者关闭并在同一地址重新打开?在这种情况下,它可能容易受到 TOCTOU 攻击(检查时间,使用时间)。你可能在现实世界中听说过它,称为“bait and switch”(诱骗和转换)。

示例:账户 420 代表 Bob 创建的,以 100 sol 购买你的稀有 NFT 的有效报价。这是一个不错的报价,所以你发送一个交易“接受账户 420 的报价”。当你发送交易时,Bob 将同一报价更新为以 0.01 sol 购买你的 NFT。他通过使用现有的“UpdateOffer”指令,或者取消报价并在同一地址重新打开一个新报价来实现这一点。

你的交易紧随其后,你刚刚以 0.01 sol 出售了你的有价值的 NFT。可怕的场景,对吧?

现在你可能想知道 - Bob 是怎么做到的?他怎么能让他的交易在你之前进入?有多种方法可以实现这一点。例如,你可能正在使用一个恶意的 RPC 或前端来打包你的交易。或者,当前的领导者-验证者正在执行攻击。或者他们正在运行一个私有 mempool,并且有人付费让他们在你的交易之前包含他们的攻击交易。或者,Bob 只是不断创建这样的报价,然后在没有看到你的交易的情况下随机更改它们,希望有人看到报价并接受它,然后以更便宜的报价成交。正如你所看到的,有人可能会尝试利用这一点有多种方法。那么如何解决它呢?最好的解决方案是在你的接受指令中包含精确定义要接受的报价的参数。因此,不仅仅是“接受账户 420 的报价",而是 "接受账户 420 的报价,以至少 100 sol 的价格出售 1 个 NFT”。

Solana技巧 60

对于 Launchpad 类型程序(其中代币被孵化,然后在不同的协议(如 Raydium,Meteora 等)上发布)的一个常见攻击模式是池蹲或毕业抢跑。这个想法是,攻击者在毕业之前从 launchpad 购买代币,然后使用与 launchpad 将使用的相同平台创建一个池。现在有时攻击者可以在 launchpad 想要启动的确切地址/PDA 创建这个池。例如,当池种子是 "pool" + mint1.key() + mint2.key(),或类似的预定内容时。如果使用了这样的种子,并且 launchpad 想要使用 initializePool 这样的 CPI 指令,launchpad 毕业将会失败。这意味着资金将卡在 launchpad 中,因为当一个池已经存在时,毕业 CPI 无法创建一个新池!

要正确地毕业到这样一个池,launchpad 需要添加流动性,而不是创建一个新池。使用传统的 cp-amm,这并不容易。Launchpad 需要先买入或卖入现有的池,直到价格与 launchpad 的价格相匹配,然后才能添加流动性。

由于这样的问题,像 raydium 最新的 amm 这样的新池允许在非确定性地址初始化池。这意味着 launchpad 不需要担心被抢跑或池蹲 - 它可以在不同的地址启动池,然后让套利者处理价格差异。

Solana技巧 61

Solana 为所有事物使用账户。为你的程序存储数据。实现功能门。存储程序本身。以及向你的程序提供关于集群的环境信息!这是通过特殊的账户完成的,即所谓的 sysvar。这些账户有特殊的地址,以 Sysvar 开头,例如 SysvarC1ock11111111111111111111111111111111。将它们加载到你的指令中,允许你的程序访问它们的信息,例如来自 clock sysvar 的当前插槽或时间戳。其他的 sysvar 包括 EpochSchedule, Fees, Instructions, RecentBlockhashes, Rent, Slothashes, SlotHistory, StakeHistory, EpochRewards 和 LastRestartSlot。所有这些 sysvar 对不同的事物都有用,从计算租金成本和时间戳到获取关于验证者排放和交易内省的信息。最常用的可能是 Rent, Clock, 和 Instructions sysvar, 每个开发者都应该知道如何使用它们。自从几周前激活的 simd 0127,sysvar 不必作为账户包含在指令中,因为它们可以通过 syscall 直接访问。虽然程序仍然可以通过账户访问它们,如果他们想的话。在实践中,你可以简单地使用它们的 get 方法访问你的 sysvar,就像 let clock = Clock::get()?; 过去,我们已经看到多个黑客攻击,其中程序没有检查 sysvar 账户地址,黑客能够提供一个伪造的 sysvar 账户。所以确保检查你正在使用的 sysvar 地址 - 或者更好的是,现在使用 syscall 接口而不是账户!

Solana技巧 62

每个 solana 账户都有一个所有者,这是一个允许写入这个 solana 账户并减少其 lamports 的程序。但是程序呢,谁拥有它们?

嗯,有一些特殊的程序拥有程序,并负责管理它们的更新和执行:程序加载器。大多数程序当前使用的加载器是可升级 BPF 加载器。在此之前,存在现已弃用的 BPF 加载器,它无法再使用,以及 BPF 加载器的第二个版本,它仅支持不可变的程序。顾名思义,可升级加载器支持升级程序代码。此升级只能由升级权限执行。加载器负责创建新程序、升级程序、更改程序升级权限以及关闭程序及其缓冲区。它还负责正确执行你的程序,例如加载其参数和启动其入口点。目前,可升级加载器使用一种架构,其中你的程序代码保存在一个单独的可执行程序数据账户中,而主程序账户仅包含关于此数据账户在哪里的信息。(如果你曾经想知道为什么你的程序官方地址上几乎没有数据)。然而,地平线上出现了一个新的加载器,它可能会改变它的工作方式。作为一名开发者或审计员,你应该知道存在不同的加载器,但你想要使用具有可以撤销的升级权限的可升级加载器。

Solana技巧 63

正常的 Solana 程序存在于账户中 - 它们的字节码保存在链上,由加载器程序(如 BPF 加载器)加载,并在 Solana 的运行时中执行。它们通常用 Rust 编写,编译成 BPF 字节码,并由开发者部署。

但并非所有程序都以这种方式工作。特别是,Solana 有所谓的预编译程序。这些程序直接构建到验证器软件本身中。它们具有像普通程序一样的程序地址,但它们的实现被硬编码到 Solana 的核心代码库中,而不是存储在链上。

为什么?性能。预编译程序直接在本地代码中运行,绕过了常规程序的所有 VM 臃肿。这使得它们对于常见的计算密集型任务来说明显更快。

Solana 预编译程序的一些示例包括 ed25519 签名验证和 secp256k1 验证。这些将在 SVM 中占用大量 CU。如何使用预编译程序?你像任何其他 Solana 程序一样通过 CPI 调用它们。例如,要验证 ed25519 签名,你可以像任何其他 CPI 一样调用 ed25519_program(地址:Ed25519SigVerify111111111111111111111111111)

Solana技巧 64

直到 Metaplex 介入并实施其 mpl 元数据标准,Solana 代币在历史上并没有一种规范的方式来在链上存储元数据。从那时起,代币元数据(包括常规代币和 NFT)都存储在属于 Metaplex 程序的 PDA 中,并且从你的代币 Mint 地址派生而来。

现在,在我看来,元数据的格式有点奇怪。它在链上保存名称和符号,但也会保存一个 URI,该 URI 链接到存储在任何 Web 服务器上的链下 JSON 文件。此 JSON 文件包含 - 再次包含名称、符号、描述和一个指向代币图像的链接。现在,这种设计使得检索关于代币的信息更加容易,因为你可以直接从 Web 服务器 JSON 获取它,而不必从链上获取它。但这带来了它自身的风险。

元数据可以通过元数据更新权限进行更改,也可以在链上进行更改。这意味着有人可以部署一个代币,并在任何时候更改它的名称!显然,这带来了一些 rug-potential,大多数 rug-checking 工具会标记启用了元数据升级权限的代币。然而,他们没有标记的是一个提供可变内容的元数据 JSON URI。我指的是什么?我可以使用我自己的 Web 服务器来托管元数据 JSON 文件。我可以指示我自己的 Web 服务器在任何时候更改此 JSON 文件的内容,而 URI 保持不变。这样,我仍然有效地拥有可变的元数据,即使没有元数据升级权限。我可以随时更改代币的名称、符号、描述和图像,但只有保存在链下 JSON 中的版本,而链上数据可能保持不变。这取决于你的钱包、浏览器或前端将显示哪个元数据 - 来自 JSON,还是来自链上。

所有这些可以通过强制使用非可变数据 URI 来避免,例如 ipfs,它充当一个 Web 服务器,将其数据存储在一个 URI 上,该 URI 在其路径中包含数据的哈希值。这可以防止在不更改 URI 的情况下更改数据,并保护你的代币免受更改的 JSON 元数据的影响。

Solana技巧 65

我已经见过几次,并且我相信可以提高你的代码质量的一种模式是,为 Anchor 的结构定义和实现你自己的 trait。

例如,你可能有十几个不同的指令,每个指令都有自己的账户,但它们共享更高层次的模式。例如,每个指令可能都有一个 authority、一个 PDA 和一种复杂的方式来检查授权。此授权检查在各个指令中可能是相同的,但账户有时可能不同。你可以为你的指令的一个子集定义一个 trait,以获取每个指令的正确的相关账户,然后在泛型 trait 上定义一次授权函数。例如,Light 协议正是这样做的,GMX 使用相同的模式实现他们的授权系统。

你也可以在帐户类型上定义 trait,例如定义你想要为你的账户提供的常见函数,例如获取账户大小或不变性检查。使用这些抽象,你可以驾驭和使用 Rust 的真正力量。停止成为语言的受害者,开始让语言成为你的工具

Solana技巧 66

你知道你可以在你的 Anchor 账户结构中定义子结构吗?

使用这种模式,你可以将常见的账户组合定义为它们自己的账户结构,并在嵌套结构上实现常见的函数。

例如,你的 admin 指令可能总是需要一个全局账户和一个 admin 签名者。使用这种模式,你可以将这些账户(全局账户和 admin)放入它们自己的结构中,定义它们的关系一次,然后在你想实现另一个 admin 函数时使用这个结构,而无需重新实现约束或验证。

我看到这种模式的使用频率非常低,即使我认为应该更频繁地使用它!许多错误来自复制粘贴错误,或者表面上重复的代码实际上是不同的。

使用像这样的模式,你可以减少代码重复和出错的可能性。

Solana技巧 67

你有没有见过一个 Anchor 账户结构,其中一个账户被标记为 Option<>

这意味着可以传递一个账户,也可以传递 None,这是运行时本身不提供的功能。那么你的程序如何知道什么时候一个账户是 NoneSome(使用 Option)?

当一个指令被调用时,Solana 会将一个账户索引列表传递给该指令,每个索引都引用交易中存在的一个账户。没有官方的方法来提供一个丢失的账户的索引。那么 Anchor 是怎么做的?

答案:这是一个骗局!Anchor 使用一种技巧来创建 None 选项。当一个账户具有 Option 类型时,它只是将 program_id 本身定义为 None 选项,任何其他值都将是 Some!在实践中,这看起来就像提供的屏幕截图中的那样。在第一种情况下,Account #4Program 相同,这意味着 Anchor 会将其解析为 None。在第二种情况下,为 Account #4 提供了其他 Pubkey,因此 Anchor 会将其解析为 Some

那么,这是否意味着 None 类型不会节省任何交易空间?因为在 None 的情况下,我们将必须提供程序 id?不!因为提供 program_id 只是为引用已存在的账户的 id 添加了一个额外的字节。

现在,这种有些 hack 的实现方式会带来一些后果。想象一下,我们有一个指令,它会做类似的事情:

RegisterProgram { program_to_register: Option<Account<Program>> }

我们将永远无法调用此指令并注册当前正在执行的程序,因为这始终会被解释为 None

不确定是否有任何合法的场景会破坏这一点,但是知道这一点很好!

Solana技巧 68

在 Solana 程序中隐藏着一个非常隐蔽的错误,该程序尝试通过直接添加/减少账户的 lamports 来“手动”更改 lamports。特别是,存在一个隐蔽的约束:在直接修改账户 lamports 后,你不能执行正常的 CPI。CPI 将失败,并显示错误消息:指令执行前后账户余额总和不匹配

这是一个极其隐蔽的错误,因为你所做的只是直接修改 lamports (例如 **info.lamports.borrow_mut() += 1000;),然后执行一些 CPI。你本身并没有做错任何事情!

那么为什么会发生这种情况?

因为运行时。

每当输入一个指令时,运行时会记录该指令中涉及的所有账户的账户余额。然后,当通过 CPI 输入另一个指令时,运行时将更新 CPI 中所有账户的余额,并使用未包含在 CPI 中的所有账户的最后一个已知余额,以进行余额检查。

这是一个示例:

有两个账户的指令:A 有 100,B 有 100。

运行时记录这一点,总计 200。

此 ix 然后修改 A = 110 和 B = 90,然后仅使用账户 A 执行 CPI。

然后,运行时将更新 A = 110,并使用 B 的最后一个已知值 100,因为它不包含在 CPI 中。

所以现在运行时认为总计是 210,这比原来的 200 更多!它抛出一个错误。

有多种方法可以解决这个问题:首先,我们可以将所有直接账户 lamport 修改移到所有 CPI 之后,以避免这个问题。另一种解决此问题的方法是,将所有 lamport 金额已更改的账户包含在所有 CPI 中,只需将它们添加为剩余账户即可。这将让运行时获取这些账户的更新金额,从而纠正计算。

Solana技巧 69

在开发 Solana 程序时,当你处理帐户数据时,经常会遇到借用检查器问题。一种优雅的解决方案是使用块作用域来限制变量的生命周期。

此模式可帮助你:

- 控制何时释放借用

- 避免“无法可变借用,因为它也被不可变借用”的错误

- 通过将变量范围限制在需要的地方来保持代码更简洁

- 避免不必要的数据克隆

块作用域是一种轻量级的方式,可以准确地告诉借用检查器你的意图,而无需复杂的生命周期注释或不安全的代码。

Solana技巧 70

你的程序可能会为用户和协议保留一些资金,无论是 Token 账户,还是 Sol。通常,你会有特定的指令供用户向协议添加资金 - 无论是通过存款、LPing、购买、出售、交换、锁定、staking 还是你能想到的任何其他方式。但是你不应该依赖于你自己的指令作为用户可以存入资金的唯一方式。事实上,一个整个链上的 bug 类别都来自这个错误的假设。捐赠攻击。这意味着攻击者可以通过向你的协议的代币账户或其 sol 的正常账户发送资金来破解你的协议。记住 - 任何人都可以向你发送代币。并且任何人都可以增加任何 solana 账户的 lamports,而无需其许可。因此,如果你在你的程序中使用这些金额进行计算,你可能会得到意想不到的结果。通常,这与舍入或其他计算错误结合使用,这将以某种方式夸大攻击者对存款的主张。总的来说,你应该总是进行防御性计算,要么考虑到资金的潜在突然增加,要么保留你的存款内部计数,忽略账户中的实际资金。如果你这样做,你应该确保在通过提款使你的内部计数达到 0 时引入一种特殊情况 - 在这种情况下,从账户中完全提取所有资金,而不仅仅是你的最后一个内部计数,否则你将无法关闭代币账户。

Solana技巧 71

在 Solana 上构建区块链应用程序时,开发人员经常希望将随机性纳入游戏、NFT 分发或公平选择过程中。但是,在链上实现真正的随机性几乎是不可能的,并且通常会导致易受攻击的系统。

首先,区块链在设计上是确定性的。每个验证者在执行你的程序时必须得出相同的结论。这个基本属性与真正的随机性相冲突。

其次,验证者可以选择何时将你的交易包含在一个区块中,在执行你的交易之前影响账户值,并且通常可以抢跑交易。

第三,我们可以构建在某些结果上恢复的交易 - 例如,在调用一个随机的抛Coin程序后,我可以调用我自己的程序来检查我是否赢得了抛Coin,如果没有,则恢复整个交易。

开发人员经常尝试以下方法:

- 使用区块哈希值、插槽或时间戳作为种子(可预测)

- 链上使用来自账户的种子值的 PRNG(可操纵)

- 用户提供的熵(可博弈)

如果你的程序中需要随机性,你应该依赖于外部可验证的随机预言机。另一种可行的方法可以是提交揭示方案,但任何这种方法都应该由经验丰富的密码学家仔细审查。

总的来说,最好避免在你的链上程序中出现任何随机性。

Solana技巧 72

在使用 Solana 账户管理用户存款时,有两种主要方法:统一的程序金库或多个金库(每个用户、每个池等)。

统一的程序池的工作方式如下:你有一个全局 PDA,例如“vault”,并且来自不同用户的所有存款都被发送到此金库。(或金库的 ATA)。这意味着当两个用户将资金存入两个不同的池时,程序将更新每个池账户中的池余额,但存入的代币将最终进入同一个代币账户,该账户充当主要的协议范围金库。

要提取资金,无论我们从哪个池提取资金,都会传递相同的“vault”PDA 并进行签名,作为金库权限。

此设置使跟踪协议的 TVL 变得容易,但可能更容易受到漏洞利用的攻击。找到一种从主金库窃取资金的方法的攻击者可能会立即耗尽所有资金,因为它都在一个账户中。

现在,一种不同的方法可以解决这个问题:使用每个用户或每个池的多个金库。在我们的例子中,这意味着协议为每个池创建新的 ATA。

用户 1 存入池 1 的资金将在池 1 的 ATA 中。用户 2 存入池 2 的资金将在池 2 的 ATA 中。

并且因为池本身就是一个 PDA,所以我们可以只使用该池作为提款的签名者,而不是使用全局权限 PDA。

这种方法可以保护一个池中的资金免受针对另一个池的攻击。但是,这种方法使得获取协议的 TVL 变得更加困难,这也是获取 Solana 协议的 TVL 困难的主要原因之一。你需要找到所有池,然后获取它们的代币账户,而不是只使用主金库。

总的来说,我建议你的协议使用多池方法。它还给你带来了不必为所有池写锁定同一个主金库的优势。这也使得池的“储备证明”更容易 - 你可以查看池的内部余额,然后检查它是否实际持有此数量的代币。

Solana技巧 73

在学习 Solana 时,理解账户模型至关重要。但是在编写实际代码时,你会遇到多个相关的类型:AccountInfoAccountMeta 和你解析的 Account 类型。有什么区别?AccountInfo 是你的面包和黄油。这是传递给你的程序的账户的默认高级反序列化。它包括 pubkey、lamports、data、所有者、租金信息以及签名者/写入者/可执行标志。接下来,你的反序列化的 Account 是从 AccountInfo.data 字段创建的。此数据从一个字节数组转换为有意义的数据,例如对其他 Pubkeyu64 或其他类型的引用。最后,AccountMeta 只是一个包含 pubkey 以及可写和签名者标志的小结构。每当你调用另一个程序时,例如通过 CPI,你需要为你想传递给该程序的每个账户提供一个 AccountMeta 列表,定义你想设置的标志。所以总结一下:AccountInfo 是你的程序从链上读取的底层信息。Accounts 是解析后的 AccountInfo.dataAccountMeta 用于定义你在调用程序时想要如何传递账户。很简单,不是吗?

Solana技巧 74

Rust 的 unsafe 关键字绕过了编译器的安全检查。它允许正常 Rust 代码不允许的五种操作:使用原始内存指针、调用不安全函数、访问静态可变变量、实现某些特殊 trait 以及访问 union 字段。如果使用不正确,这些操作中的每一个都可能导致内存问题。

在 Solana 开发中,你主要会在直接在数据类型之间进行转换时遇到 unsafe,例如将原始账户数据转换为结构化格式:

unsafe { &* (
    data.as_ptr() as *const TokenAccount) }

那么什么时候使用它呢?仅在性能需要或处理原始数据结构时才使用。始终保持不安全块尽可能小,记录为什么需要它们,并进行审计。

永远不要使用 unsafe 作为绕过编译器错误的捷径 - 这些错误通常是为了保护你。

作为审计员,你应始终格外小心地检查不安全块。检查在执行不安全操作之前是否保证了所有前提条件。验证是否满足了强制转换操作的内存对齐要求。确保不安全块是最小的且封装良好。注意可能导致越界内存访问的潜在整数溢出!

Solana技巧 75

在审计程序或作为开发者设计指令时,你应该意识到抢先交易攻击(frontrunning attacks)。其思路是,用户提交他们的交易,而其他人可能会在交易落地之前观察到该交易,并添加他们自己的交易,以便在用户的交易之前执行。你可能以前听说过这个术语,并且可能本能地认为它与交易有关——用户下一个订单,然后抢先者在用户之前下一个订单,以便从用户产生的价格变动中获利。但这并不是抢先交易的唯一方式。通常,在各种帐户初始化指令中也可以发现抢先交易攻击。用户创建一个交易以使用某些设置初始化帐户——如果存在漏洞,则抢先者可以在用户之前使用不同的设置初始化同一帐户。如果用户随后继续使用该帐户,认为它已使用他们的设置初始化,他们可能会被利用。通常,当其他人可以执行指令以在同一地址创建帐户,或操纵指令结果所依赖的外部状态时,可能会出现抢先交易向量。虽然后者通常是这种情况,但它取决于用户的结果——如果我可以让用户获得比预期更差的执行结果,那么这就是一个有效的攻击向量。

Solana 技巧 76

Flashloan(闪电贷)是一种非常强大的原语,DeFi 独有 —— 无需任何抵押品即可借入无限量的资金,但你必须在同一笔交易中偿还。(好吧,资金仅限于协议拥有并愿意给你的金额,但能够借入数百万的闪电贷并不罕见。)但是它在 Solana 上是如何运作的呢?

Flashloan 通常分 3 个步骤工作:借款、使用、偿还。所有这些步骤都必须在一次交易中完成,并且通常分为彼此独立调用的指令 —— 不使用 CPI。你可以改为使用 CPI 来实现闪电贷,尽管这可能会限制在闪电贷期间可以执行的操作。有些程序明确禁止通过 CPI 调用,有时额外的 CPI 会因为当前最大堆栈深度为 4 而失败。

因此,典型的闪电贷程序实现 2 个闪电贷指令:borrow_flashloan 和 repay_flashloan。(或者你想要调用的任何名称)。

现在有趣的部分是:在借款指令中,我们要求用户传递指令 sysvar。使用这个 sysvar,我们可以执行交易内省。这意味着我们可以查看当前交易是如何构建的。借款指令使用它首先确认它自身没有通过 CPI 调用,然后它迭代交易中所有后续的指令调用,直到找到对闪电贷程序的下一个调用。它断言下一个调用是带有正确贷款账户的 repay 指令。它通过查看指令鉴别器来检查这一点。

repay 指令也应该禁止作为 CPI 调用。

这样我们就可以确保闪电贷按预期完成:借款 - 使用 - 偿还,中间没有任何可疑的事情。特别是,我们想要防止有人可能 借款 - 借款 - 偿还,或 借款 借款 偿还 偿还,或任何其他不是 借款-使用-偿还 的借款和偿还组合的攻击。我们还想要防止诸如 借款 - 更改闪电贷程序中的设置 - 偿还 之类的事情。同一贷款的偿还应该始终是与同一程序的下一次交互 —— 中间只有闪电贷的使用。

Solana 技巧 77

当创建 solana 帐户时,这意味着网络中的每个验证器都必须存储它们。这项服务是有成本的 —— 租金(rent)。租金根据帐户大小收取。(你可以使用命令 solana rent 100 找出 100 字节的租金是多少。它会给你免租金额)。免租金额相当于 2 年存储的租金。由于租金将从存储数据的帐户中扣除,这意味着在某些时候帐户将为空并且应该被删除,这可能会导致各种问题。这就是为什么这个租金支付系统已经逐步淘汰,我们改为使用这个 2 年的免租金额。如果帐户中有这个金额,则不会收取实际租金,并且帐户关闭后你可以获得全部租金存款返还。如今,我们只是称免租存款为“租金”。

最近,大约在 3 周前,Solana 激活了功能 CJzY83:禁用租金费用收取。自从这个功能以来,验证器实际上不再收取低于免租限额的帐户的租金。这不再是必要的,因为不再有支付租金的帐户,并且任何会创建非免租帐户的交易都会失败。验证器正在每个 epoch 结束时浪费处理时间来尝试收取租金。因此,“租金”一词现在纯粹是历史性的 —— 它现在实际上只是一个完全可赎回的存款。

Solana 技巧 78

有没有想过你在 Solana 程序中的所有这些 'a, 'b, 'info 标签是怎么回事?尤其是在传递到你的指令中的 Context<> 周围?

这些标签称为生命周期(lifetimes)。简而言之,它们描述了每个值将存在多长时间,因此可以安全地引用它们,而不会导致诸如 use-after-free 之类的内存问题。

现在,Context 结构具有以下字段:

  • program_id

  • accounts

  • remaining_accounts

  • bumps

前三个实际上是引用:本质上是指向 program_id, accountsremaining_accounts 的三个指针。

生命周期声明指针的目标数据存在于这个相对的时间长度内。这是一个例子:

pub program_id: &'a Pubkey,

意思是 program_id 是指向 Pubkey 的指针,并且它指向的 Pubkey 将存在于生命周期 'a 内。

正如你所看到的,我们并没有明确说明 'a 的生命周期意味着什么。生命周期始终是相对的。我们没有在任何地方声明 'a 是在程序开始时创建并在程序结束时删除的。不,我们只是说它存在。

那么它们有什么用呢?一旦我们引入关系,它们就会变得有用。

如果你仔细观察以下代码:

pub fn process_ix&lt;'a, 'b, 'c: 'info, 'info>(
  ctx: &'a Context&lt;'a, 'b, 'c, 'info,
  ProcessIx&lt;'info>>,
  instruction_data: InstructionData,
) -> Result&lt;()> {

你会注意到第一行中的声明 'c: 'info

这意味着:'c 必须至少存在与 'info 一样长的时间

由于 'c 指的是对所有 remaining_accounts 的引用,而 'infoAccountInfos 的生命周期,因此这在直观上是有意义的。如果 AccountInfo 的生命周期在 'c 之前过期,我们的 remaining_accounts 可能会尝试访问无效数据。

顺便说一句,当我们定义我们的 derive(Accounts) 结构时,'info 是我们用于其他 AccountInfos 的相同生命周期。

这就是全部。希望你现在了解什么是生命周期以及这堆 <'a, 'b, 'c, 'info, ....

对你来说更有意义!

Solana 技巧 79

让我们今天深入研究 Solana 密钥背后的密码学。Solana 使用 Ed25519,它基于 Edwards-curve 数字签名算法 (EdDSA) 并使用特定的素数:2^255-19。曲线意味着:椭圆曲线密码学。

简要入门:

椭圆曲线具有有趣的属性,即如果你在曲线上选择两个点并通过它们绘制一条线,则该线将在第三个点与曲线相交。现在你可以在有限域内将此点反映在 x 轴上以获得两个选定点的总和。(将有限域想象成数学的一个围栏版本,其中你使用循环的值集而不是所有数字。例如,字母表。你可以使用 A-Z 而不是数字。添加 A+B = C,添加 Y+B = A。但是我们“在曲线上”进行数学运算,这样做是因为在这种数学子集中我们具有某些属性)。现在,我们不是仅仅在曲线上添加随机数,而是可以通过使用曲线上该点的切线将相同的数字添加到自身。切线与曲线相交的任何位置,我们都有两个点的总和。现在这里的美妙之处在于计算 kP 非常容易(P 是点,k 是标量),因此只需对某个点重复执行此操作,但几乎不可能进行反向运算 - 当我们有 kP 的结果并且也有 P 时,我们无法真正找出过去使用的 k。因此 k 有点私密 - 我们的私钥,而 kP 是我们的公钥,它直接由私钥构建。

我知道这很多是胡说八道,但这意味着:

  • 你的私钥是一个秘密标量,可用于计算你的公钥

  • 你的公钥在曲线上,而你的私钥只是一个随机数

  • PDA 被定义为不在曲线上的“公钥”——因此它们不能有私钥。不能使用标量来找到不在曲线上的点

如果你对它的工作原理更感兴趣,我可以推荐以下资源:

https://curves.xargs.org

Solana 技巧 80

有一个极其简单但经常被忽视的概念区分了优秀的代码和糟糕的代码。

它是命名

我并不是指在命名你的项目 dump dot run 还是 limp dot fun 之间的挣扎。

我的意思是命名你的代码中的变量、结构、指令、枚举、类型和模块。

其中一些对你来说可能很明显 - 将所有变量命名为 ab 显然会令人困惑。但让我让你惊讶:像 withdrawdeposit 这样清晰的名称可能同样不透明。是用户存款吗?他们是存入他们的帐户,还是 LPing?是管理员提取费用吗?还是用户提取他们的 stake?...

而且不要让我开始说 owner

作为开发者,你通常不会注意到自己不良的命名习惯 - 你的名字对你自己来说似乎很清晰。但是一旦你引入另一位开发者或审计员,他们就会为你的代码而苦苦挣扎。

苦苦挣扎意味着 - 每次都必须查找实现,因为他们不确定该名称的含义。忘记要使用哪个变量,因为名称不合逻辑。只是减慢了他们的工作流程,随着时间的推移,这会加剧。

简单的修复:好好命名你的东西。 知易行难。让我尝试指导你完成它。

让我们从状态帐户开始,例如将存储各个用户数据的帐户。通常你想要在这里使用大写的名词,描述该对象。例如:User。保持简单,但要清楚此对象存储的内容。其他好的帐户名称是 Global 用于你的全局程序状态,或 Pool 用于池。我认为一个不好的名字是 Config,因为它不清楚它配置的内容 - ProgramConfigFeeConfigGlobalConfig 可能会更好。

接下来,你的指令。正确命名这一点很重要。以我的经验,最清晰的指令名称遵循 主语-谓语-宾语 的模式。主语是有权执行此指令的授权方。谓语是指令执行的操作。宾语是对什么执行此操作。

一些例子:user_withdraw_lpadmin_collect_feespublic_init_userpublic_crank_market。如果一个指令是公开的,你可以省略前缀 public__。这些指令名称可以立即清楚地表明它们做什么以及谁可以调用它们。

这些指令名称的坏版本是:withdrawwithdraw_feesnew_usercrank。想象一下具有相同 4 个指令的相同程序,其中一个使用前 4 个名称,另一个使用第二个版本。与另一个相比,其中一个立即感到草率。

接下来,你的输入帐户变量。我这里指的是你为每个输入帐户分配的帐户变量命名。这些变量存在于你的 anchor 指令定义结构中(derive(Accounts))。这里只需使用描述性名称。调用签名者 authoritypayer(仅当他们支付租金等费用时才使用 payer)。只是不要使用 owner。调用你的 mints mint,而不是 token。这部分应该很容易。

接下来,结构/帐户中的变量或字段。你希望它们也很清楚,并且类型应该是显而易见的。duh. 让我们以 Order.locked 为例。这将是一个非常糟糕的名字。locked - 它是 boolenum 还是 mount/u64?什么是 locked?它是关于资金还是整个 Order?一个更好的名字可能是 Order.locked_fee_amount。很明显,它可能是一个数字,并且可能与此订单相关的费用有关。

在为保存数字的变量创建名称时 - 有时你可以想出比“amount”更好的名称。例如:fee_bps:很明显,金额应以基点表示。或者 fee_lamports:很明显,我们期望一定数量的 lamports (sol)。(你也可以为这些创建自定义类型,而不是使用 u64)。

最后一个重要的事情是命名你的枚举。

当你为你的一个帐户定义一个 State 枚举时,每个状态名称都应该清楚地传达其自身的含义。例如,让我们以一个治理提案为例。一个提案可能已被提议,现在正在进行投票。在这种情况下,State::VotingState::Proposed 更合适,因为 Voting 意味着它已被提议,并且还为我们提供了投票正在进行的信息。一个更糟糕的替代方案可能是 State::Live 或类似的。开发者可能意味着“该提案现在正在进行并正在进行投票”。但是状态本身并不直接暗示这一点。它也可能意味着该提案已经获得批准并已实施。

就这样。好好命名你的东西。求你了

Solana 技巧 81

当你遇到一个开源的 Solana 程序,并且你正在考虑存入你辛苦赚来的钱时,信任开发者很重要。

是的,我们身处加密货币领域,是的,不要信任,而是验证等等……但即使他们的代码有 20 次审计,并且他们的代码对你来说看起来很干净,不幸的是,一个经验丰富的 Solana 开发者也很容易隐藏后门。(这会让他们随时 rug 项目的资金。)

实际上,默认情况下,大多数 Solana 程序都有一个“后门”,即升级权限。虽然这可以在公共场合锁定在一个多重签名或 DAO 中,以便用户确信该程序不会在没有事先通知的情况下进行升级。但还有其他类型的后门。

其中一种是通过费用 - 管理员只是突然将费用更新为 100%(或者如果代码以某种方式允许,甚至更多)。

另一种后门机制是在公共代码中添加非常隐蔽的错误,或者只是将后门代码隐藏在测试模块之间,或任何地方。查找“underhanded c contest”以了解这一点。

另一种特别隐蔽的技术是创建一个创建特殊的后门帐户的程序版本,初始化它们,然后将程序升级到一个完全没有提及这些帐户的版本。审查者必须检查链状态并找到这些帐户,然后才能批准该代码。我们也可以将代码隐藏在依赖项和天知道的什么地方。

我所说的一切是:如果你使用的程序的开发者真的想 rug 你,他们会找到一种方法。你可以拥有的最佳无后门保证是:

  • 不可升级,或严格的多重签名

  • 程序部署到全新的密钥对。已检查所有先前版本是否符合以下条件

  • 没有不受信任的依赖项

  • 已考虑到后门进行了审计

  • 代码已标准化并产生相同的构建(已去除注释、测试、任何不必要的内容)

  • 管理员干预在能力方面受到限制(例如,管理员也无法超过定义的最大协议费用常量)

  • 开发者已公开身份

  • 形式化验证

作为开发者,你可以通过做更多上述事情来在自己的程序中建立更多的信任。

Solana 技巧 82

在编写 solana 程序时,你肯定会遇到宏,特别是如果你使用过 Anchor。例如,在 rust 中进行日志记录是使用 println! 宏完成的,或者在 solana_program 中使用 msg!。但还有其他宏,例如 #[derive(Accounts)]#[account]declare_id!() 或你的约束,如 #[account(mut)]。所以首先:什么是宏?简单地说 - 宏是在程序编译时为你生成代码的快捷方式。它们为你生成可重复的代码,这样你就不必每次都写出来。与函数的区别在于宏是在编译时创建的,并且可以合并你的语法,例如变量的名称。你可能已经注意到,有不同类型的宏:- 声明式宏:简单的模式匹配宏,你在其中匹配某些语法模式并从中生成代码 - 过程宏:类似于在 AST TokenStream 对象上运行并将其转换为代码的函数。需要额外的依赖项 - derive 宏:自动为结构和枚举实现 traits,例如 #[derive(Accounts)] - 属性宏:将某些属性应用于项目。例如 #[account(mut)] 现在有趣的部分是:你不必仅仅依靠 Anchor 提供的宏。你可以实现你自己的!从声明式宏开始,你可能想要构建你自己的调试函数,其中包含增强的信息,例如当前正在执行的指令和行。或者你可以构建一个 CU 日志记录包装器。你也可以构建你自己的断言宏。使用过程宏(或声明式宏),你可以为常见的事情(例如传输、费用计算、安全数学等)构建快捷方式。你可以实现你自己的 derive 宏以添加到你的 Accounts 结构中 - 例如添加验证函数,或为你的 #[account] 结构生成帐户大小计算。使用属性宏,你可以创建你自己的约束。例如,你可以为你的指令构建一个 admin_only 宏。总的来说,一旦你习惯了创建宏所需的奇怪语法,它们就会变得非常有用。通常,错误源于复制粘贴错误,因为开发者在他们的程序中复制了 10 次费用计算逻辑,或者他们复制了 20 次相同的 token 传输逻辑。宏的最佳用途之一是创建一个可靠的实现,然后你可以在你的程序的任何地方使用它。下次你发现自己重复大量代码时,请考虑改用宏!作为审计员,你应该了解 cargo-expand 工具。(cargo install cargo-expand),然后你可以在你的工作目录中使用 cargo expand。此工具将展开所有宏并生成代码,以便你可以根据需要查看实际生成的代码。

Solana 技巧 83

Solana 程序中一个常见的错误是忘记在 CPI 之后重新加载帐户,或者在 CPI 之前写入帐户。

这意味着什么?通常你的程序,特别是 Anchor 的工作方式如下:

在你的指令开始时,反序列化输入帐户并将它们的反序列化版本复制到堆栈上(或者使用 Box 复制到堆中)。

然后,你使用堆栈上的工作副本执行所有业务逻辑,并且仅在交易结束时,才在指令结束之前将你的更改写入链。

现在,当你执行 CPI 时,被调用的程序将不会收到你的帐户的工作副本。它们收到链上的帐户状态。

当被调用的程序完成时,它可能会将数据写入它自己的帐户。现在,当你的调用者程序再次启动时,默认情况下它不会读取链状态并覆盖其帐户的本地工作副本。如果你希望发生这种情况,你需要主动重新加载帐户。

记住:只有所有者程序才能写入帐户。你的程序可以写入它自己的帐户,而其他程序可以写入它们的帐户。

因此,对你来说一个简单的行动规则:

  • 当你的程序写入帐户数据然后调用另一个带有修改后的帐户的程序,以便被调用者将从帐户中读取数据时,请确保首先将你的写入序列化到链,以便这些更改到达被调用者

  • 当你的程序在调用另一个程序后从属于另一个程序的帐户中读取帐户数据时,请确保重新加载帐户,以便在 CPI 期间完成的更改反映在你的工作副本中

Solana 技巧 84

通常,你的程序设计将涉及明确定义的状态,即使你实际上没有定义它们。例如,一个启动板程序有 3 个阶段:初始化,在此配置启动,然后是从任何想要参与的人那里收集资金的阶段,以及最后一个阶段,其中 token 被启动,或者启动未能收集到足够的资金。你可以在没有状态变量的情况下构建它,只需检查诸如收集的总资金和当前时间以及定义的启动时间窗口之类的变量。但是这样的代码很快就会变得非常混乱,它依赖于许多手动检查,这很容易出错。因此,你应该定义一个状态变量。你可以通过创建一个枚举并为我们的启动定义每个状态,例如 {Initialized, Collecting, Launched, Failed} 来做到这一点。

现在,要真正利用你新定义的状态,你应该直接在枚举上将显式状态转换方法定义为 Impl!每个状态转换函数都应该仅在满足所有条件时才执行状态转换,然后相应地定义下一个状态。

你知道 Rust 枚举可以有参数吗?例如,你可以将你的 Launched 状态定义为 Launched { committed: u64 },直接编码一个仅在此状态下存在的变量。

除了状态转换函数之外,你现在还可以在你的枚举上定义条件检查,并使用 Rust 最好的功能之一:match 语句!

在你的指令逻辑中使用具有清晰转换函数和 match 语句的状态变量是编写安全 solana 代码的作弊码。实际上,许多 bug 源于不清晰的状态和状态转换,并且一丝不苟地使用这样的系统可以完全填补这个漏洞。

Solana 技巧 85

当你定义你的 Solana 程序时,你需要定义一个入口点。这是 SVM 将开始程序执行的代码中的位置。程序通常类似于那些书,你阅读一页,然后它告诉你接下来应该跳到哪一页,这取决于你选择的故事线。入口点是你开始阅读的第一页。

在一个 native 程序中,你像这样定义它:

#[cfg(not(feature = "no-entrypoint"))]
use solana_program::entrypoint;
#[cfg(not(feature = "no-entrypoint"))]
entrypoint!(process_instruction);

其中 process_instruction 是你的入口点函数,它接受参数 program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8]

当你将另一个 solana 程序作为依赖项包含时,你应该使用 no-entrypoint 功能来包含它,这样你就不会定义两个入口点。这就是上面这个功能定义的目的。

当你创建一个 anchor 程序时,入口点由程序宏为你定义。

但是运行时如何知道入口点是什么呢?由于程序是 ELF 文件,你可能会认为它们将简单地使用 Elf 入口点。但事实并非如此。相反,注册了一个具有密钥 0x71E3CF81 的函数,它是字符串“entrypoint”的 murmur3 哈希,并且此函数用作入口点。这就是全部!

Solana 技巧 86

密码学通常需要计算量很大的操作。这就是为什么 Solana 提供了多个加密原语作为链上的预编译或系统调用。让我们具体看看这些加密函数是什么,以及我们可以用它们做什么!

首先,Solana 有 3 个相关的 native 程序:

Ed25519SigVerify111111111111111111111111111,它在 ed25519 曲线(native 的 solana 曲线)上提供签名验证

KeccakSecp256k11111111111111111111111111111,它在以太坊和比特币的 native ECDSA 曲线提供签名验证,以及

Secp256r1SigVerify1111111111111111111111111,它为许多硬件安全密钥生成的签名提供签名验证。

接下来,Solana 提供了许多相关的系统调用。仅对于哈希,我们就有系统调用来计算 sha256、keccak256、blake3 和 poseidon 哈希。

除此之外,我们还有一些特殊的操作,例如 secp256k1_recover 以从 ECDSA 上的签名恢复密钥,以及多个用于 ed25519 的专门操作。

此外,还有用于 alt_bn128 计算的特殊系统调用 - 另一种椭圆曲线,对于零知识实现尤其有用。这些包括标准操作,例如乘法、加法,但也包括 alt bn128 压缩,这是另一种减少 ZK 计算中的计算和空间成本的技巧。

最后,我们有一个用于大模幂的特殊系统调用,大模幂是许多密码系统中使用的另一种常见操作。

现在我们有了确凿的事实 - 我们实际上可以利用所有这些做什么?首先,我们可以在链上验证数字签名。这可以用于验证身份或其他交易层。通过对 ECDSA 的专门支持,我们可以构建直接与以太坊和比特币一起使用的原语,例如跨链桥!通过 altbn128 支持和高效的哈希函数,我们可以构建各种零知识协议,在链上验证证明。Secp256r1 验证允许我们直接支持硬件安全密钥。想到了不同的密码系统,例如 zk rollup、同态加密、可验证随机函数或自定义隐私协议。

Solana 技巧 87

当你从你的钱包在 Solana 上发送交易时,实际上会发生什么?

首先,一些应用程序为你构建交易,并要求你签名。此时,交易如下所示:“使用数据 asdf 和帐户 X(writeable, signer) Y(writeable), Z(read only) 调用程序 A,然后使用数据 qwerty 和帐户 (X writeable, Signer), W(read only), U(writeable) 调用程序 B”。即使它已指示签名者,在你使用你的私钥签名之前,也没有签名。

签名后,交易将与消息和签名一起完全序列化,并发送出去。但是发送到哪里?发送到 RPC 节点。RPC 节点充当区块链的网关。它通常会为你检查交易的正确性,然后该节点会将交易直接转发到当前或即将到来的领导者,领导者是现在轮到创建区块的验证器。领导者通过其交易处理单元接收交易,然后处理它。

现在,在高度拥塞时期,solana 会遇到大量垃圾邮件,需要确定优先级。这就是为什么还有一些其他方法可以发送你的交易的方式。首先,直接发送给领导者 - 使用一些自定义代码,你可以直接跳过 RPC 并将你的交易直接发送给领导者,尽管你将以这种方式促进网络垃圾邮件。作为一种抵抗,Solana 引入了 SWQoS(stake weighted quality of service,权益加权服务质量),它允许领导者优先处理通过来自 stake 的验证器的已建立通道提交的交易。

现在你为什么要了解这些东西?

首先,你可以优化你的交易落地能力:选择具有强大 SWQoS 节点的 RPC,从而为你提供更好的交易落地机会。其次,你应该意识到,默认情况下,你的交易不会直接进入区块链 - 而是首先由 RPC 接收。这意味着,如果你将交易发送到恶意 RPC,它们可以在你的交易在链上提交之前访问你的交易,类似于 mempool。例如,他们可以将你的交易与买入和卖出打包为三明治,从而给你带来更差的执行结果。(对于打包,他们不需要更改你的签名,他们只需添加两个自己的事务即可与你的事务一起执行!)对于开发者和审计员来说,这意味着你应该意识到抢先交易的风险非常真实。期望提交给你的程序的指令可以被抢先交易,并以最大程度地减少对用户的负面影响的方式设计它们。

Solana 技巧 88

某些程序需要支持任意调用 - 这意味着用户可以提供你的程序随后将调用的任意其他程序。例如,这种模式用于多重签名和 DAO 提案。它也可以用于某些类型的闪电贷,或基础设施程序,如桥梁、层或虚拟机。但是,你如何让你的程序安全地调用,甚至 invoke_signed 任意其他程序?

首先,我们需要区分 invoke 和 invoke_signed。基本上它们是相同的,但 invoke_signed 可以为调用添加新的 PDA“签名”。重要的是要知道,两者都将传递它们从父调用收到的签名。因此,如果我调用程序 A,为我的钱包 W 签名,并且 A 调用 B,B 也将收到我的钱包 W 的签名,无论是使用 invoke 还是 invoke_signed。

那么程序如何确保被调用者不会做任何坏事呢?首先,“做坏事”基本上转化为 Solana 术语中意外地更改帐户数据。毕竟,这就是所有程序所做的。因此,我们拥有的最好的工具是不将我们不希望更改的帐户作为帐户信息传递到 CPI 中。但是,有时你必须将帐户传递到 CPI 中。也许被调用者可能需要它们,或者你需要传递帐户以保持运行时的余额检查正常。因此,你的工具箱中第二个最好的工具:将帐户标记为只读。通过将帐户标记为只读,调用者可以确保被调用者不会更改关键帐户。

接下来,还有另一个重要的检查要做:当用户可以为你提供任何帐户来调用时,他们也可以提供你自己的程序,从而创建自我重入场景。通常建议禁止自我重入调用,或者通过在执行之前检查提议调用的指令数据,将它们限制在某些指令中。

Solana 技巧 89

让我们详细调查一下 Solana 交易是如何序列化的!

Solana 交易由两部分组成:签名数组和交易消息。

交易序列化中的所有数组都使用短向量(short vecs),这意味着数组长度仅被序列化为一到三个字节,并且通常仅一个字节,只要数组长度小于 0x7f。

签名数组只是存储 64 字节的签名。简单。消息包含我们有趣的交易数据,让我们深入研究一下!

首先,有两种类型的消息:Version 0 和 Legacy。(我不知道谁想出这些名字)。Version 0 支持很酷的东西,例如地址查找表。但是 legacy 是我们的命脉。它由 3 个部分组成:消息头、另一个帐户地址(Pubkeys)短向量、最近的区块哈希和另一个指令短向量。 消息头仅由 3 个字节组成,这些字节是以下账户地址向量中的偏移量:第一个偏移量将整个向量分成两部分:偏移量之前的所有地址必须是签名者,之后的所有地址必须不是签名者。标头中的其余两个偏移量将这些半部分中的每一个进一步分成半部分,有效地将数组分成四份。它们的目的是将签名者和非签名者分成可写签名者、只读签名者、可写非签名者和只读非签名者。这是一个非常聪明的序列化技巧:仅使用三个索引字节并通过对键进行排序,我们就知道每个键是签名者、可写还是只读。接下来,最近的区块哈希用作我们交易的时间证明,后跟一个指令数组。每个指令分为三个部分:指令调用的程序 id 的索引、指令想要使用的账户数组(编码为索引),最后是与我们的调用一起使用的指令调用数据。

所有账户索引都简单地引用账户列表中的索引。这意味着在不同交易中调用相同指令的两个用户可能在其指令中具有不同的索引,这完全取决于 Pubkey 的排序。

我们可以从中了解到什么?首先,如果账户地址已经存在于同一交易的任何交易中,则向指令添加其他账户只会向交易添加一个字节。

此外,账户属性(如可写、可读或已签名)在整个交易中共享!

Solana技巧 90

你知道可以用 Rust 编写 Solana 程序,但你知道也可以用 C 编写吗?Solana Labs 甚至提供了一些关于如何用 C 编写 Solana 程序的官方示例!你只需要使用 C solana_sdk.h,定义一个入口点,使用 sdk 的 sol_deserialize 函数反序列化输入参数,然后执行你想要的操作!(甚至不是那么晦涩 — 如果我没记错的话,mayanswap 用 C 语言实现了他们的 Solana 程序)。

所以,正如你所看到的,用 C 语言编写 Solana 合约一点也不难。但是,让我更进一步:你可以用汇编语言编写你的合约!我从 @deanmlittle 那里窃取了所附的示例,它在 Solana 上实现了斐波那契数列。你会注意到它也很容易 — 你只需要知道反序列化格式和系统调用。如果你想构建自己的汇编程序,请使用 deans https://github.com/deanmlittle/sbpf 来尝试一下。

那么这一切的意义是什么呢?如果不仅仅是为了低级别的学习体验,你可能还想尝试用 C 和汇编编写程序以进行优化。众所周知,Anchor 程序的代码非常臃肿,无论是在程序大小还是在 CU 使用方面。在最低级别构建你的程序可以使其非常小巧且高效。使用内联 asm,你也可以用 rust 编写你的程序,但可以使用汇编优化关键函数。

Solana技巧 91

如果你将同一个账户两次传递给指令会发生什么?

首先,当账户两次作为只读传递时,这不是问题 — 它的状态不会改变,所以没有什么可担心的。但是,当你改变账户时(即将其作为可写传递时),这可能会成为问题。那么,如果你将一个账户传递两次:一次作为只读,一次作为可写会发生什么?好吧,实际上你将把该账户作为可写账户传递到你的交易一次,并引用它两次。要了解接下来会发生什么,你需要了解何时从账户读取数据以及何时写入数据。

读取发生在 account_info 反序列化为账户时,写入发生在账户序列化为 account_info 时。通常,账户在指令开始时读取,在指令结束时写入。这意味着在内部,指令将开始处理相同的数据,以不同的方式(或不改变它们)改变它们,然后逐个写入生成的账户状态。当我们逐个写入时,这意味着最后一次写入将是在链上的那一次。当账户作为可读账户传递给 anchor 时,它不会在指令结束时序列化到链上,这意味着当我们作为可写和可读账户传递给 anchor 程序时,写入的值将占上风。

当我们两次将账户作为可写账户传递给 anchor 时,最后一次写入将占上风,并且写入按照它们在 Account 结构中出现的顺序进行。

接下来是另一个有趣的版本:如果同一个账户在一条指令中两次传递给 anchor 的 init 会发生什么?很简单:该指令将因账户已被使用的错误而失败,因为第一次初始化将通过,第二次将尝试分配/分配同一个账户,这将失败。那么...如果我们使用 init_if_needed 会怎么样?第二次 init_if_needed 调用将因鉴别器 (discriminator) 检查失败而失败。但是为什么呢?第一次 init_if_needed 没有设置正确的鉴别器吗?答案是:仅在交易结束时,当账户数据写入链上时!当达到第二次 init_if_needed 时,该账户已分配,因此跳过 init 部分,然后执行常规账户检查。但是,此时账户鉴别器将只有 0 个字节,这意味着鉴别器检查将会失败。所以,你真的不必担心 init 中的重复账户!

Solana技巧 92

我已经发布了一些关于减少 CU 使用量的方法,但从未真正谈论过它的意义。CU 表示计算单元 — 它是衡量计算机执行你的程序所需时间的一个指标。基本上,它的概念与以太坊中的 gas 相同。当你的程序执行时,这意味着当前的领导者将运行它,产生一些结果,然后每个验证者也将运行相同的程序来验证领导者的结果。Solana 的有趣之处在于我们不会直接收取 CU 费用。相反,Solana 对每个签名收取 5000 lamports 的基本费用,并且你的指令有 20 万个 CU 的预算。如果你超出该限制,你的指令将失败。但是,你可以使用 ComputeBudget 程序来请求不同的(更高或更低的)计算预算。在同一步骤中,你还可以提供优先级费用,即你愿意为每个计算单元支付的金额,作为签名成本的补充。

这就引出了一些问题:当支付优先级费用是自愿的时候,为什么有人会支付优先级费用?以及为什么有人会请求少于 200000 个计算单元?

这两个问题的答案是:为了提高交易的执行效率。你可以支付优先级费用 — 正如其名称所暗示的那样 — 以便将你的交易优先于其他交易。这与小额贿赂没有什么不同。请求较低的计算单元数量本应有助于领导者的调度程序。安排 2 天的假期比安排 4 周的假期更容易,因此你希望调度程序会更早地包含你的交易,因为它更容易安排。另一个方面是:无论何时你请求计算单元并为其付费,你都在为整个请求的配额付费。因此,如果你请求 200000 个 CU,为它支付高优先级费用,但只使用 10000 个 CU,那么你只会为 19 万个 CU 多付了钱。因此,你想要做的是测量你的指令的 CU 使用量,并只请求该数量。

你应该意识到,同一指令的 CU 使用量可能会波动 — 通常是由于 PDA。因为 PDA 找到的第一个 bump 会导致不在曲线上的哈希,所以有时可能需要多次尝试才能找到该 bump — 从而导致交易的 CU 成本更高。

有关各种系统调用的 CU 成本的参考,请查看屏幕截图!

Solana技巧 93

编写 Solana 程序时,你可能会经常使用 u64。但是,你会将其用于不同的事物:Lamports 的数量、token 的数量、slot 编号等等。

这导致出现以下情况:你实现了应该用于 Lamports 的辅助函数,但由于它们采用 u64,因此可以将其用于 token 数量或插槽。通常,建议将类型安全引入你的程序。例如,你可以引入 Lamports 类型和 TokenBalance 类型,并确保它们永远不会混合在一起。然后,你还可以在这些类型上实现函数,例如 apply_fee。

在 rust 中实现类型时,你可能想要使用

type Lamports = u64;

但这将毫无用处,因为它不会强制类型安全。如果你真的想安全,则需要使用结构类型,例如

struct Lamports(u64);

但是随后你可以在该结构上实现你习惯的函数!你可以使用 .0 访问该值,也可以通过实现自定义访问器函数来访问该值。

Solana技巧 94

最近,我在 Solana 程序中遇到了以下检查。该指令将另一个账户作为输入,并在通过以下检查后保存该账户地址以供以后使用:

if *ctx.accounts.other_program_account.owner != other_program::ID {
  return Err(Error::InvalidAddress.into());
}

other_program 是一个相关的程序,只有一种 Account 类型。

假设是:如果该账户属于 other_program,它将具有那一种 Account 类型,并且我们可以稍后按原样使用它。

但是,我找到了一种完全绕过此假设的方法:瞬态所有者攻击。在 solana 上,你可以将你可以签名的系统账户分配给任何其他程序。这意味着我可以创建一个账户并简单地将其分配给 other_program 拥有。这将绕过这个简单的检查,但是我在它被该程序拥有后真的不能对该账户做任何事情,对吗?

这就是瞬态发挥作用的地方:当我将一个账户分配给另一个程序并且不在该账户中存入任何 lamports 时,该分配将持续到我的交易结束,但之后该账户将被删除并自动重新分配给系统程序。

这意味着我可以将一个账户分配给 other_program,调用易受攻击的指令,这将通过检查并保存账户地址,然后在另一个交易中,我可以将该账户分配给另一个程序,并在其中放入虚假数据。

(老实说,这种攻击很少有用且适用,但它很巧妙)

Solana技巧 95

你对抗 bug 最有效的工具之一是测试。但是测试很难,尤其是在 Solana 上,我们有艰难的 DevEx 和啃玻璃的传统。但是今天我想帮助你编写一些测试!

你应该了解 Solana 生态系统中的五种主要测试方法(我可能还遗漏了一些):

Rust 单元测试:

最先也是最容易开始的测试方法是只使用 Rust 单元测试。这些测试应该测试单个函数或小序列,甚至不是整个指令。你可以通过创建如下模块来添加它们,添加在 panic 时失败的测试:

#[cfg(test)]
mod tests {
  use super::*;
  #[test]
  fn my_test() { assert_eq!(some_function(4,5), 20))}}

使用此方法测试程序中的单个辅助函数和实用程序。

Solana 程序测试 (Cargo Test SBF):

这是 OG solana 测试工具包。你可以通过包含 solana-program-test crate 在 rust 中编写测试,然后创建一个测试模块,在其中定义多个测试用例。在每个测试中,你创建一个 ProgramTest 实例,该实例充当你的区块链和 RPC 替代。与单元测试的不同之处在于,ProgramTest 实例在 SBPF VM 中部署你的程序,并且你可以实际调用完整的指令并检查生成的账户状态。人体工程学类似于单元测试,但是你需要编写一些辅助函数才能在测试之前使你的程序进入所需的状态。(例如创建 mint,空投 token,初始化程序状态等)

Anchor 测试:

Anchor 提供了一个内置脚本,用于使用 `anchor test` 运行测试,该脚本使用 typescript 和 mocha 编写测试。你像在 typescript 中编写程序客户端一样,在 typescript 中定义你的测试。这是一个运行简单测试的舒适环境。默认情况下,这将使用本地 solana-test-validator 实例。这意味着对于你的测试,会启动一个本地验证器,这有其自身的优点和缺点。我最喜欢的是你可以运行 anchor test --detach,它会在测试套件之后保持验证器运行,然后你可以使用任何 solana 浏览器查看测试交易。在 typescript 中编写测试有很多优点:你可以轻松地定义测试前和测试后例程,你可以使用 typescript 库,并且你正在像大多数集成可能会使用你的程序一样进行测试。

LiteSVM:

上述测试框架在某些方面有时会受到限制 - 例如,当你想要运行大型测试套件时,启动整个验证器可能太慢了。有时你想要测试非常特定的状态,或者从特定状态开始运行多个测试。你可能希望在测试区块链实例中具有特殊能力。这些能力包括更改账户数据、向前或向后移动系统时钟,甚至禁用签名验证,以便你可以为你在测试环境中没有密钥的特定账户签名。

这些功能在上面的测试框架中实际上不可用。但是,LiteSVM 旨在做到这一点。你可以使用 Rust、TS 甚至是 Python (solders) 中的 LiteSVM 来编写具有这些特殊能力的测试。LiteSVM 创建了一个针对测试优化的 Solana VM 而不是验证器,这使得它与替代方案相比相当快。它的 API 也非常简单!

Mollusk:

Mollusk 是 Solana 测试领域的新成员,由 Anza 直接提供。在内部,它是另一个超轻量级测试工具,不依赖于任何验证器的大型组件。它在处理指令时会创建一个小型程序缓存、交易上下文和调用上下文,然后使用 BPF 加载器直接执行程序。由于它非常轻量,因此它仅提供四种主要的 API 方法:process_instructionprocess_and_validate_instruction,以及相同的两种方法,但适用于 instruction_chain,而不是单个指令。

除了编写许多断言或 require 语句之外,mollusk 还允许你提供一个 Checks 数组,这些检查是预定义的、可配置的常见 require 语句,例如检查程序结果、账户余额或账户数据。它还提供了一个内置的计算单元基准测试模块,并支持测试 fixtures。

作为一名开发人员,我鼓励你尝试所有这些选项,并了解你喜欢使用什么。编写测试比在最有效的框架中编写测试更重要,并且我认为你应该使用任何可以帮助你编写更多更好的测试的框架。

审计员也应该熟悉所有这些。通常你可能想在审计中测试某些内容,并且在客户端已经使用的任何测试框架中添加另一个测试是最方便的。这就是为什么你应该习惯在所有这些框架中编写一个简单的测试!

Solana技巧 96

昨天我谈到了测试 Solana 程序。今天我想谈谈一种特殊的测试类型:Fuzzing(或模糊测试)。

什么是 fuzzing?简而言之,你不是手动编写无数的测试,而是创建一个自动化的测试设置,该设置会为你的程序生成随机输入,并以编程方式验证程序是否正确执行。例如,在传统软件领域,你可以自动生成随机图像文件并尝试使用 photoshop 打开它们,观察程序是否崩溃。

在 solana 程序的上下文中,这意味着你会自动为单元测试生成函数调用,或者为程序测试生成指令调用,或者生成指令调用序列,并将它们抛给你的程序。这部分并不难。你可以轻松地为你的程序生成数百万条指令,用于随机组合的存款金额和签名者密钥对。由于存在如此多的输入和序列的可能组合,因此艺术在于创建实际测试相关内容的测试用例。

Solana 程序接受两个输入:指令数据和账户。

对于指令数据,你可以生成不同的参数值,同时确保它们满足你程序的约束。真正的挑战在于账户 - 你需要创建具有不同所有权模式、余额和数据布局的有效账户结构。

Trident 等工具为 Solana 提供专门的 fuzzing,自动生成针对潜在漏洞的测试用例。对于基于 Rust 的程序,LibFuzzer 集成允许进行复杂的基于突变的测试,而无需运行完整的 Solana 运行时。

最有效的方法是将覆盖引导的 fuzzing(优先考虑触发未经测试的代码路径的输入)与交易序列 fuzzing(测试模拟真实用户交互的指令链)相结合。这有助于发现的不仅是崩溃,还有细微的逻辑错误和经济攻击向量。

准备好开始了吗?首先创建一个简单的测试工具,为你的最关键函数生成随机指令数据。从一个指令类型开始,然后扩大范围。尝试使用 Trident 以获得更全面的框架,或者如果你熟悉 Rust FFI,则可以尝试使用 LibFuzzer。即使通宵运行基本的 fuzzing 也可以发现你的手动测试遗漏的极端情况 - 可能会使你免于在生产中遭受代价高昂的漏洞利用。

Solana技巧 97

你是否知道 solana 正在开发一种名为 mucho 的新开发者工具包?

该工具包将包含开发和测试 Solana 程序所需的一切:它带有 solana cli 和 agave 工具套件、mucho cli、rust、anchor、一个 fuzz 测试框架和一个 Solana 程序代码覆盖率工具。

在其核心中,它提供了自己的 cli,即具有以下功能的 mucho cli:

  • 管理你的 solana 开发工具
  • 运行 solana 测试验证器
  • 检查交易、账户等。
  • 支持新的 Solana.toml 文件
  • 构建和部署 solana 程序
  • token 工具,如创建 mint 和 transfer
  • 查看余额
  • 以及更多即将推出的功能...

mucho 的特别之处在于统一的开发者体验。无需使用具有不同界面的多种工具,mucho 为整个开发生命周期提供一致的工作流程。集成的 fuzz 测试和代码覆盖率工具对于编写安全程序尤其有价值!

要开始使用,只需使用 npx mucho@latest install 安装 mucho,然后使用 mucho --help 了解特定命令。例如,使用 mucho inspect,你只需提供一个公钥、交易签名或区块号,它就会自动为你提供正确的信息,就像网络浏览器一样。

公平警告:Mucho 仍处于积极开发阶段 - 预计会出现 bug 和粗糙的边缘。暂时将其视为 alpha 版本。我自己也遇到了一些 bug,但开发团队正在积极修复问题。尝试将其用于辅助项目,但在稳定之前,可能暂时不要将其用于生产工作。如果出现问题,请查看 GitHub 问题 - 很可能有人已经报告了它。

Solana技巧 98

在构建和审计 Solana 程序时,深入了解 CPI 非常重要。因此,今天我想谈谈执行 CPI 调用时存在的一些限制。我之前已经在单独的线程中讨论过其中大多数内容,但我认为将它们收集在一起会很好。首先,CPI 表示跨程序调用。这意味着一个程序调用另一个程序。每个 CPI 获取要调用的程序地址、要调用的账户、指令和签名者种子。你是否知道每个 PDA 的签名者种子都有限制?每个 PDA 最多可以有 16 个种子,每个种子最多可以有 32 字节长。目前,我们想要通过 CPI 调用的程序必须在交易中作为顶级传递给调用指令(但这可能会改变)。每个将在 CPI 中被访问的账户也必须存在于顶级交易中,这会自动限制我们可以执行 CPI 的账户数量。但是,通过版本化交易和地址查找表,该数量非常高。尽管交易的账户锁定限制仍然为 64。通过执行 CPI,我们可以向交易添加虚拟签名者 - 为从调用程序派生的 PDA 签名。我们通过向调用提供签名者种子来添加它们。目前,CPI 调用的最大调用深度为 4。这意味着我们的程序 A 可以调用 B,B 可以调用 C,C 可以调用 D,但不能再进一步。但是,有消息称很快就会增加此限制。你将遇到的另一个 CPI 限制是,在 CPI 调用期间,账户最多可以增加 10kb 大小。下一个限制是你的计算预算 - 这不是对特定 CPI 的直接限制,但你的交易的整个交易共享的 CU 限制为 1.4M CU(或默认情况下为 20 万 - 你需要 CU 预算程序来解锁 1.4M)。但从理论上讲,如果你的指令在 CPI 之前用完 1.4M CU,则 CPI 将失败。此外,在执行直接 Lamport 访问(直接添加和减去 lamports)然后在之后执行 CPI 时要小心。你需要包括所有具有已更改 lamports 的账户信息,或者不包括任何账户信息,否则运行时会报错。因此,即使被调用程序实际上没有使用它们,也要包括它们!运行时会密切关注你。它还会确保你不执行重入,这意味着你不能执行 A->B->A CPI 调用循环。但是 A->A 没问题。此外,你可以执行的 CPI 数量存在总体上限。这称为跟踪长度,上限为 64,这意味着你在交易中最多可以执行 63 次 CPI!但是,此绑定包括你交易中的所有指令!我认为这就是你需要了解的有关 CPI 限制的所有信息!

Solana技巧 99

许多协议在不披露其源代码的情况下启动其 Solana 程序。他们可能认为这可以保护他们免受黑客和竞争的侵害,但是有一些方法可以从闭源程序中获取信息。

首先,我们可以使用以下命令转储程序二进制文件

solana program dump <PROGRAM_ID> program[dot]so

并使用以下命令对其进行反汇编

llvm-objdump --print-imm-hex --source --disassemble program[dot]so

运行 strings program[dot]so 也会给我们一些提示:二进制文件中的所有字符串,通常包括所有指令名称,以及调试信息,例如错误消息或枚举名称,都可以找到。这对于了解程序的作用是一个很好的开始!

接下来,我们想要获取 IDL。有时它会在链上可用(使用 anchor idl fetch)。有时开发人员会在他们的文档中分享它。有时会有一个公共 SDK 包含 IDL。有时你可以查看他们的网站应用程序的前端源代码,以找到嵌入其中的 IDL。(我有一个关于此技巧的帖子)如果我们不走运地没有这些选项中的任何一个,我们将不得不手动重建它。

我们可以从链上程序日志、跟踪和账户中了解很多信息。你可以加载尽可能多的调用该程序的指令,然后以编程方式列出该程序在哪个指令中执行的所有 CPI。接下来,你可以确定哪些指令采用哪些账户,以及哪些账户在不同的指令之间共享。一个很棒的方法是亲自使用该应用程序,并在每次交互中记录哪些链上指令被触发以及哪些账户和数据被触发。仅阅读程序日志通常也会为你提供很多信息!

你可能想使用 Dune 而不是链和浏览器。它允许你构建更复杂的查询,例如仅向你显示具有某些指令鉴别器的指令。

有时,你可以找到带有其他调试信息的程序的 devnet 部署,例如更多日志记录!这可能是一个真正的作弊代码,但是无法保证主网程序的行为完全相同。

一旦你用尽了你的简单选项,你就可以拉起火炮 - 使用反向工程工具,例如 ghidra、IDA 或 binary ninja。实际上,与 MCP 和现代 LLM 一起,你可以以非常低的努力获得非常好的反编译。

你也可以只测试交易看看会发生什么,使用 RPC 调用 simulateTransaction,它还允许你在没有正确签名的情况下模拟交易。

我们目前正在资助一项反向工程赏金,以改善该领域的工具和知识。还剩一周多的时间来获得 5000 美元价格的份额!在这里找到它:

https://earn.superteam.fun/listing/reverse-engineering-solana/

Solana技巧 100

这将是最后一个Solana技巧.. 让我与你分享一个秘密:从我的 Solana 技巧中学到最多的人是我自己。当我开始编写它们时,我以为我已经将这些技巧牢记在心。但是,在发布新的技巧时,我经常会仔细检查我的内容,并了解到我不太确定它的实际工作方式。然后我查看了一些代码,并且经常意识到即使是官方文档也存在错误。所以我的最后一个Solana技巧是:检查代码。不要从 twitter 帖子、过时的文档或你表弟的母亲的侄子那里获得你的绝对真理。阅读代码。运行代码。编写一些代码来尝试一下。自己检查一下。这是你可以确定某件事物就是它所是的方式的唯一途径。希望你喜欢我的 100 天 Solana 技巧。我可能会休息几天不发帖子。我计划在恢复精力后发布更多长篇内容。所以请关注 @accretion_xyz 博客。

  • 原文链接: accretionxyz.substack.co...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 2
收藏 3
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
accretionxyz
accretionxyz
江湖只有他的大名,没有他的介绍。