本页说明一笔交易在 rand_sendTransaction 接受之后、区块打包之前会发生什么:若被拒绝,原因是什么;为何它可能未经任何失败便从池中消失;区块按什么顺序填充;以及大量无效证明能对验证者造成什么、不能造成什么。内存池位于 crates/randprotocol-node/src/mempool.rs;其前置的防护位于 admission.rs。
没有发送方,因此没有 nonce
在账户型链上,内存池按 nonce 对每个发送方的交易排序。屏蔽链没有发送方:一个 bundle 不携带地址,也不携带签名,两个 bundle 只有在触及同一张票据时才相互关联。因此不再有按发送方排序的工作可做。取代 nonce 记账的是冲突跟踪:池中为每笔待处理交易的废止符和承诺建立索引,并拒绝任何声明了其中之一的新交易。账本对若干计数器同样如此,每个取值只接受一笔交易:验证者的注册 nonce(Unbond 或 Withdraw)、代币的 mint_nonce(TokenMint 与 SetAuthority 共用)、RegisterToken 所要求的注册表索引,以及桥的 pause_nonce 和 list_nonce。每一个都是一个槽位,池中至多有一笔交易可以占用。两笔这样的交易至多只有一笔可能被打包,携带另一笔既要耗费提议者的区块空间,也要让每个节点在每轮 gossip 中多做一次证明验证。
池中至多容纳 10,000 笔交易(node.rs 中的 Mempool::new(10_000)),并且自 v0.6.8 起,至多容纳八个区块的字节数:MAX_POOL_BLOCKS = 8 乘以链的 max_block_bytes,在 chain 21 上为 152 MiB(八个 19 MiB 的区块;chain 20 上为 160 MiB)(audit v6,CH-7)。此前只限制了数量,而一笔携带证明的交易大小从约 1.3 MB 到超过 12 MB 不等。每笔交易都针对共识树的顶端(而非最后提交的区块)处的账本进行验证:即提议者实际会在其上构建的状态。
准入:先廉价,后昂贵
经由 RPC 或 gossip 到达的交易依次经过以下步骤,你看到的错误即第一个拒绝:
- 重复。 该哈希已在池中:
already in mempool。 - 冲突。 某个废止符、某个承诺,或上述 nonce 与索引槽位之一已被一笔待处理交易占用:
conflicts with a pending transaction over <hex>。对于代币槽位,该十六进制值是一个显示键:一个标记字(mint_nonce为tokn,注册索引为tidx),随后是索引和 nonce。两个中继者抢先提交的同一桥证明,则由其摘要加以区分:conflicts with a pending transaction over bridge attestation <hash>。 - 已满。
mempool full。容量检查是这三步中的最后一步。达到字节上限时,新交易可以取代池中排名严格低于它的条目——先按手续费高出每个条目当前每 KiB 下限的幅度,再按手续费——从最便宜的开始逐个驱逐,直到容纳得下;若这些仍不足,或只是持平,则被拒绝的是新交易。治理类操作不受这两项上限约束,也永远不会被驱逐:四个桥治理操作PauseMints、UnpauseMints、RegisterBridgedToken、ListBacking,以及自 v0.6.8 起的RotatePqGuardians、RotatePauseKey、它们的…V2形式、CancelRotation、AdmitValidator和SlashEquivocation。它们的字节和条目仍计入上限。PauseMints是应对守护者密钥泄露的制动:没有 bundle、没有手续费,并且是攻击者正在铸造期间必须进入区块的那一笔交易,因此已满的池不得将其拒之门外。该豁免受冲突步骤约束:pause_nonce和list_nonce各自每次只容许池中有一笔交易,因此四个桥操作至多只能让条目超出上限两个;其余操作各自消耗自己的 nonce 或一个法定人数。 Ledger::validate的廉价部分。 所有可能在准入与打包之间过期、且无需证明即可检查的内容:锚点是最近 1,024 个根之一(chain 20 和 chain 21 上,取自创世的proof_window_blocks;没有该字段时为 256),time在窗口之内,废止符未被花费,承诺尚不存在,证明的摘要未被占用,质押操作的注册 nonce 为当前值,代币操作的mint_nonce或注册索引与注册表所在位置一致(链已越过它之后为wrong mint nonce、wrong token index),暂停或上架的 nonce 为桥的当前值。交易所创建的承诺也在此计算,包括账本自身为Withdraw、BridgeAttest或聚合者支付派生的票据,它不在线路上传输,但同样不得冲突。体积上限在解码任何内容之前检查:证明超过链的max_proof_bytes(默认 2 MiB,chain 20 上为 4 MiB)、证明(attestation)超过 16 KiB,或交易大于一个区块(max_block_bytes:默认 4 MiB,chain 20 上为 20 MiB),均被直接拒绝,因此超大的数据块不会换来任何计算。以封存(已修剪)标记形式出现的 bundle 证明,在封存形式同步之外的同一步骤被拒绝,错误为PrunedFormOutsideSync。随后,池在仍不验证任何内容的情况下读取证明头。三项策略优先:调用的输入表、keccak 表或 sha256 表行数不足 2^7 的不入池(CallRevealsPrivateInputs),没有任何调用能够容纳的部署也不入池(ProgramUncallable),bundle 或调用证明的头字段为诚实证明者不会写出的值的交易同样不入池(NonCanonicalProof)。后两项在 chain 20 上同时也是hardening_v6之下的账本规则。自 v0.6.8 起,声明的度数超过最高表上限的证明头,在检查遍历它之前就以NonCanonicalProof拒绝(audit v6,ZKV-4);此预筛选中任何位置发生 panic,都只是拒绝该笔交易(VerifierPanicked),而不会让节点宕机。然后是手续费下限:在 chain 20 上,池解码调用或 invoke 证明所声明的 gas 上限,并以顶端的价格拒绝低于BUNDLE_BASE + gas_price × limit + byte_price × ⌈bytes/1024⌉(invoke 另加其 cell 费用)的手续费,错误为FeeTooLow(手续费)。这正是区块自身的规则,因此池所要求的与区块完全一致。- 昂贵的部分,在共识循环之外。 交易被排入队列,至多 64 深,交给四个验证工作线程之一。工作线程在阻塞任务上运行
Ledger::validate的其余部分:bundle 摘要、bundle 证明所声明的 gas 上限(自 chain 18 起恰为 20,479,否则为BundleGasLimit)、bundle 证明,以及自 chain 17 起其针对交易绑定的授权证明(chain 21 上针对hc_auth,多重签名形式的证明则针对hc_auth_multisig;自 v0.8.0 起,每个Ok结论都保存在容量为 32,768 条目的验证缓存中,因此区块应用时不会再次验证该证明),对于调用,还有它自己的证明及其手续费下限。验证针对顶端的快照进行,该快照惰性克隆,每次顶端变化至多一次,且仅在有任务等待时才克隆。经 gossip 到来的交易若发现队列已满,则被丢弃,既不验证也不转发,以免阻塞负责投票的循环;对等节点的下一轮 gossip 会再次携带它。 - 复查并入池。
insert_verified针对交易实际入池时的顶端再次运行第 4 步,因为在证明验证期间顶端可能已经移动;随后接纳该交易,若来自 gossip,则转发出去。
第 1 至 4 步即 Mempool::precheck;第 5 步是唯一验证 STARK 的步骤。由于该开销由工作线程承担,大量无效证明只会占满四个线程,而不会影响共识循环。交易与操作列出了账本自身的准入顺序,第 4 步和第 5 步合起来即运行这一顺序。
针对 gossip 的两道防护与一个缓存
在准入之前,有两项措施仅适用于来自对等节点的交易;RPC 提交会跳过它们,因为该端口属于运营者自己,且已受请求体大小限制约束。唯一受到计量的 RPC 路径是下文的水龙头。
- 按对等节点的字节计量器。 自 v0.6.8 起,每个 gossip 帧在解码之前,就先计入其转发者的字节预算:突发容量为八个帧,每秒补充两个最大传输尺寸的帧;超出预算的帧不经解码直接丢弃(audit v6,GOSSIP-1)。对整个证明做哈希得到的交易 id,只在转发者的额度之内计算。
- 按对等节点的交易计量器。 每个转发节点一个令牌桶,突发容量 16,每秒补充 4 个(
PEER_TX_BURST、PEER_TX_PER_SEC)。它计量的是转发该交易的对等节点,而不是交易的作者,因为屏蔽链无法指认作者。 - 裁决缓存。 如果本节点已因一个仅取决于交易字节本身的原因拒绝了某笔交易,例如证明无效、签名无效或某个部分超出尺寸,那么再次收到时会从一个容量为 8,192 项的 FIFO 缓存(
REFUSED_CACHE_ENTRIES)中直接拒绝,而不会再验证第二次。凡是涉及本节点状态的裁决,例如未知的锚点或已花费的 nullifier,一律不缓存:否则落后一个区块的节点会把自己对即将生效的交易“毒化”。该清单是一份允许列表,因此新增的错误在有人另行决定之前一律视为状态。以下几项显示了界线所在:- 自 v0.6.8 起,
BadPauseSignature(the pause signature does not verify)不是永久性的(audit v6,BRG-18)。它在链当前的暂停密钥下检查签名,而桥规则 v2 可以轮换该密钥,所以它属于状态:落后于一次轮换一个区块的节点,否则会在重启之前一直拒绝新持有者的PauseMints。PQ 联合签名索引越界出于同样的原因也移出了缓存。在 v0.6.7 及之前,即链 18 和链 19 上,这两者都被当作永久性裁决缓存。现在,重复的无效暂停每次送达都要付出一次 Dilithium2 验证的代价,并受按对等节点计量器的约束。 PrunedFormOutsideSync有意不设为永久性。已封存捆绑包的标记形式,其哈希等于原始交易自身的 id,而缓存正是以该 id 为键,所以缓存这一拒绝将使任何 gossip 对等节点都能在全网范围内审查这笔诚实交易。- 一个证明声明的 gas 上限,是其自身字节与创世常量之间的比较,因此
BundleGasLimit和AuthGasLimit是永久性的。FeeTooLow不是:在按 gas 计量的链上,下限随价格而变动。第 4 步的三项区块头策略同样永不缓存:每一项都是当前这个构建的认知,后续构建可能改变它。 - 在设有水龙头允许列表的链上(链 20 和链 21),
FaucetRecipientNotAllowed是永久性的:它把铸造的接收方与一个创世常量作比较。FaucetBudgetExhausted不是:它属于状态,同一笔铸造在下一个纪元会再次被接纳。 - 代币操作的
BadSignature同样不是永久性的,这与质押操作不同:代币的铸造权限属于状态,SetAuthority可以把它交给另一把密钥,同样的字节在该轮换提交之前可能被拒绝,提交之后则被接受。字节层面的代币拒绝(名称或符号无效、小数位过多、数额为零、代币索引为 0)会被缓存;操作不得设置的销毁字段、承诺无法打开为其数额的铸造,以及盲化因子不是派生值的桥接存款,同样会被缓存。
- 自 v0.6.8 起,
水龙头限额
rand_mint 不收费,每次调用占用池中一笔交易;在链 21 上,与链 20 一样,水龙头处于开启状态,其背后有一座存有价值的桥。创世配置限定了它能做的事:一次铸造只向 16 个在允许列表中的钱包支付,每个含 1,000 个区块的纪元内至多 10,000 RAND(Staking),并且创世 staking.faucet_minters 列表中的 18 把密钥里,只有一把可以签署它(MinterNotAllowed,永不缓存:自链 16 起为账本规则,在链 15 上为节点策略,当时该列表即创世验证者)。在此之上,每个节点还各自对自己的水龙头计量:连续八次铸造,每秒补充一次(FAUCET_MINT_BURST、FAUCET_MINT_PER_SEC)。超出后,调用返回 faucet is rate limited on this node (8 mints back to back, refilling at 1/s); try again shortly。该限额按节点进程而不是按调用者计算,因为 RPC 端口没有可供计量的对等节点身份,而受保护的资源是本节点的交易池。结合上文的容量豁免,水龙头的洪泛无法在 PauseMints 之前填满交易池。
Gossipsub 以应用层验证方式运行,因此一笔交易只有在本节点验证通过之后才会转发给其他对等节点。洪泛无效交易的对等节点会被计量器限速,由缓存直接作答,并且不会占用共识循环的任何开销。
构建区块
当验证者领导一个视图时,它向交易池索取候选交易。candidates_within 只保留那些对其所基于的账本仍然适用的交易,办法是重新执行准入中开销较小的那一半:锚点仍在窗口内,time 仍在窗口内,nullifier 尚未被花费,承诺尚未出现。它还会剔除 mint_nonce 或注册索引已被链越过的代币操作,以及 nonce 已过期的暂停或上架操作。自 v0.6.8 起,它会依据准入时记下的 gas 上限,按其所基于的链顶的现行价格为每个调用和 invoke 重新定价,并略过费用已低于下限的交易;这类交易仍留在池中,待价格回落后会再次被提供(audit v6,CH-9)。自链 20 起,它再对其余交易排序:治理类优先,其次按超出当前下限的费用除以交易的 KiB 数,再次按费用从高到低,并以哈希打破平局,这样基于同一个池构建区块的每个诚实提议者都会选出同一个区块,并填充至2,000 笔交易与该链区块上限的预算之内(MAX_BLOCK_TXS;max_block_bytes,默认为 4 MiB,链 20 上为 20 MiB,链 21 上为 19 MiB)。区块所缺的是字节,因此超额部分按每 KiB 计算。没有 gas 配置段的链和没有 gas 策略的节点会跳过该排序键,按治理、费用和哈希排序,一如链 15。治理优先是暂停保证的另一半:PauseMints 按设计不付费,因为它必须能从一个不持有任何 RAND 的钱包发出,所以若只按费用排序,紧急制动就会被排在每一笔付费交易之后。
当有调用或 invoke 就绪时,还适用另一条规则(audit v6,POOL-2;每条链上的节点策略,而非有效性规则)。只有这类交易才支付字节价格,而一笔只比其固定基础费用多付一点的转账,按每 KiB 计算会排在它们之前,因此一连串的转账可能使一个就绪的调用始终进不了任何区块。所以,固定费用交易在第一轮中至多占用区块字节数的四分之三;调用放置完毕后,剩余的空间再按原有顺序用被推迟的转账填满。区块空间没有损失,而在没有调用就绪时一切照旧(Fees)。
对于剩余空间而言过大的交易会被跳过,而不是终止该区块:排在它后面的较小交易仍可入块。链 18 上的一笔转账约为 2.85 MB,其中捆绑包证明 1.49 MB,授权证明 1.36 MB,因此链 20 的 20 MiB 区块可容纳约七笔,链 21 的 19 MiB 区块按此尺寸可容纳六笔(约束集 9 的证明尺寸在此尚未测得),而默认的 4 MiB 区块可容纳一笔。在链 15 上,每笔转账约 1.43 MB,20 MiB 可容纳十四笔。负载之下,由费用决定谁入块(Fees)。自 v0.8.0 起,交易池也是副本重建区块的来源:领导者广播区块头和交易哈希(紧凑区块,Consensus),副本只按哈希获取其交易池和近期交易缓存中缺少的交易体;被按转发者计量器拒绝的广播交易,会未经验证地保存在一个容量为一个区块的溢出缓存中以供该次重建使用,绝不会进入交易池。
修剪
每次提交之后,prune 会从池中恰好剔除 candidates_within 本会略过的那些交易:其票据已被他人花费的交易,其承诺现已存在的交易,其 nonce 或注册索引已被链越过的交易,以及其锚点或 time 已滑出窗口的交易(链 20 和链 21 上为 1,024 个区块,链 19 及之前为 256 个)。因此,在双花竞争中落败的捆绑包会自行消失,而不会向任何人产生任何错误。rand_sendTransaction 的接受不等于提交:请轮询 rand_getTransaction,直到它返回一个区块;如果锚点窗口过去仍无结果,则请基于新的锚点重建捆绑包并再次提交。
观察
rand_status 报告 mempool_size、verify_queue(等待工作线程的交易)和 refused_cache(裁决缓存中的条目数)。verify_queue 持续停在 64,意味着证明到达的速度超过了四个工作线程的验证速度,在密钥已预热时每个约 16 ms;mempool_size 在区块持续满载时仍不断增长,则意味着决定交易能否入块的是费用,而不是到达顺序。
尚未实现的部分
- 无替换机制。 自链 18 起,gas 与字节价格每个区块都会推动一次调用的最低费用门槛上下浮动,直至其上限;转账的最低费用门槛则是固定的。对于作用于相同 note 的交易,不存在以更高费用的交易替换已入池交易的机制,冲突规则会拒绝这种做法。被价格挤出的调用会留在交易池中等待、不再被提供,直到价格回落或其锚点窗口过期。在 v0.6.7(链 18 和 19)上,已入池的调用保留其被接纳时的最低费用门槛,在价格上涨后仍被提供,并在提议者的试应用中以
FeeTooLow失败。fullnode 的区块空间文档将按费用排序的内存池列为扩大区块之前的一步,并将区块级聚合列为提升每区块上限的手段。 - 无按存留时间的过期。 已入池的交易只会因被打包、因达到字节上限而被逐出,或因锚点窗口与时间窗口到期而离开:链 20 和链 21 上为 1,024 个区块,按浏览器在链 20 上报告的每区块 1.34 秒计约 23 分钟(链 21 的前 2,300 个区块为 1.17 秒)。直至链 19,该窗口为 256 个区块,约五分钟。
- 无内存池 RPC。 没有列出待处理交易的方法;
rand_status中的mempool_size以及rand_getMempoolInfo中的数量、字节数和最长存留时间是仅有的视图,而浏览器只索引已提交的区块。