Skip to main content
在 Solana 上,LI.FI Intents 以一组 Anchor 程序的形式运行。其设计与 EVM 架构相呼应——输入结算、输出结算、预言机验证——但具体的运作部件是 Solana 原生的:资金存放在由 PDA 拥有的代币账户中,证明则是任何人都可以从公开输入重新计算出来的 PDA 账户。 本页是程序参考:订单结构体、每个程序的结算生命周期,以及证明和认证模型。如果 PDA 或 CPI 这类术语对你来说是新的,请先阅读 Solana vs EVM 入门介绍。

四个已部署的程序

当前的部署将系统拆分为四个程序,每个程序各司其职。devnet 和 mainnet-beta 上使用相同的程序 id。 Overview of the LI.FI intent programs on Solana 每个程序的接口参考就是它的 Anchor IDL——见下方的程序 IDL。 某个字段应该填状态 PDA还是程序 ID,因字段和路线而异——见下方的哪个身份对应哪个字段。

程序 ID 与派生账户

程序 ID 标识的是可执行代码。大多数指令还需要持有已部署程序状态的单例程序派生账户(PDA)。这些地址是确定性的,在 devnet 和 mainnet-beta 上相同,因为两个集群使用相同的程序 id 和种子;它们存储的配置内容则是各集群独立的。 Anchor 客户端可以从已发布 IDL 中的种子元数据解析出这些 PDA。其他客户端可以在本地用 PublicKey.findProgramAddressSync([Buffer.from(seed)], programId) 派生它们。普通的 RPC 查询无法推断出一个未知程序的 PDA 种子。 Polymer 的 PDA 存储着它所配置的证明器程序 id,目前在两个集群上都是 CdvSq48QUukYuMczgZAVNZrwcHNshBdtqrjW26sQiGPs。请从 OraclePolymer 账户读取该字段,而不要在订单数据中重复它。链映射、订单 context、填充记录、代币账户和认证都依赖于具体的链或订单,会按需派生;它们不是部署地址。订单 context 和填充记录必须在客户端派生——见程序 IDL。
Wormhole 也已部署在主网上,见部署概览。随附的 Wormhole IDL 是源码接口参考,内嵌地址与主网部署地址不同;不要将其内嵌地址当作主网部署地址。

哪个身份对应哪个字段

订单中每一个 32 字节的身份槽位都指向一个特定账户,程序 ID 和状态 PDA 不能互换。每条规则都对应特定的一端:下表中 MandateOutput 各行在该输出位于 Solana 上时成立(EVM 输出携带的则是 EVM 身份);input_oracle 一行在订单是在 Solana 上开启时成立。 对于 Solana → Solana 的同链订单,不涉及任何预言机程序,且只有一条规则:全部三个槽位——input_oracle、output.oracle 和 output.settler——都填输出结算器的状态 PDA(DHShHmVkTwCzUzAQbCu4GDqJmursuDscNR6o4hTBgeRy)。LI.FI 的构建工具和相关工具都按这种方式编码同链订单。这正是先释放输入的结算所要求的身份,因为该路径会从结算器本身重建全部三个槽位。同一笔订单也可以通过标准的 fill → finalise 路径结算。
在标准路径上,填充产生的 LocalAttestation 会把 output.oracle 记为其消费方。在规范编码下,这个消费方是输出结算器而不是托管程序,因此 finalise 会验证该认证,但不会消费它,其租金(约 0.0012 SOL,由填充者支付)也不会被收回。如果将 output.oracle 设为 input_settler_escrow 的程序 ID,finalise 就能消费该认证并退还租金,但该订单将不再适用于先释放输入的结算。LI.FI 使用的是规范编码。
open 不会验证这些身份字段,因此填错值的订单会被接受,并照样托管资金。求解器必须对照本页核实全部三个身份,并在填充前拒绝任何不匹配的情况。output.settler 填错,至少会让填充失败。跨链订单的 output.oracle 填错却不会:填充指令根本不会将该字段与某个账户进行比对,因此填充会按已签名的内容成功执行。随后证明提交会失败,求解器也就无法领取被托管的输入。
请重新派生这些值,而不要仅凭外形就信任任何 32 字节常量:从上表解析出程序 id,并根据文档记载的种子派生每一个 PDA,或者在你的集成测试中将已发布的地址钉住作为标准值。

订单结构

每一个 Solana 意图都是一个 StandardOrder。用户在 Solana 上承诺一个输入,并描述求解器必须交付的输出——可以在 Solana 上,也可以在某条 EVM 链上。
该结构体携带的是一个输出的 Vec,但在 Solana 上开启的订单实际上只能有一个输出:如果预估的 finalise 交易放不下,open 就会拒绝该订单,而两个输出就已经超出上限(见开启)。源自其他链的订单仍然可以向 Solana 发送多个输出。
在 Solana 上,输入是单个代币和金额:
每个输出都使用 32 字节字段,以便能够描述任意目标链。Solana 地址就是公钥的原始 32 字节;EVM 地址则在左侧补零:
订单的身份来自其内容:order_id = keccak256(borsh_serialize(StandardOrder))。任何一方都可以重新计算出它,这也正是下文基于 PDA 的记账方式得以成立的原因。完整的类型定义,包括 Borsh 布局,见程序 IDL。
虽然 MandateOutput.amount 和荷兰式拍卖的 slope 是以 32 字节值传输的,但 Solana 在填充时会将它们收窄为 u64。超过 u64::MAX 的值会使该输出无法被填充(AmountTooLarge / ArithmeticOverflow)。

输入结算:input_settler_escrow

输入结算器是一个托管。开启订单会将用户的代币移入一个托管账户;它们只会离开这个账户一次——在 finalise 或 finalise_with_prefill 时流向求解器,或在 refund 或 refund_on_non_fill 时流回用户。这四条路径都会关闭订单的 context PDA,因此一旦其中一条成功执行,其他路径就永远不可能再执行。

程序状态

三种账户类型跟踪一笔订单的生命周期:

开启

open(order) 会托管输入:它将 order.input.amount 从用户的代币账户转入一个由订单 OrderContext PDA 所拥有的关联代币账户。该指令需要两个签名者——user,必须等于 order.user 并对代币扣款进行授权;以及 sponsor,负责支付订单各账户的租金。赞助者不必是交易的手续费支付方,也可以与用户是同一个密钥。当订单结算时,赞助者会收回托管代币账户和 order_context 的租金;consumed_order 的租金则永远不会退还——该 PDA 是一个永久性的重放防护——因此在为开启操作提供赞助时,请将其计入每笔订单的成本。 在转移资金之前,open 会验证订单:fill_deadline 不能已经过去(可以等于当前时间),expires 必须严格晚于当前时间且晚于 fill_deadline,订单必须至少有一个输出,链 id 和输入铸币也必须与订单一致。它还会预估最终 finalise 交易的大小,如果该预估值超过 1142 字节——即 1232 字节的数据包上限减去为 ComputeBudget 指令预留的 90 字节余量——就会拒绝该订单(FinaliseTxTooLarge):
一个 callback 为空、使用限价 context 的输出,预估为 1,062 字节(赞助者即用户时为 1,030 字节),为该输出的 callback_data 与 context 合计留出 80 字节(赞助者即用户时为 112 字节)。两个输出的预估至少为 1,332 字节,因此源自 Solana 的订单不能有多于一个输出。 一次成功的 open 会发出一个携带 order_id 和完整 StandardOrder 的 OpenEvent——订单服务器和求解器正是通过这个事件发现链上的开启操作的。

租金退还

每一条结算路径——finalise、finalise_with_prefill、refund、refund_on_non_fill——都会关闭托管代币账户和 order_context,并将两笔租金都退给赞助者。如果赞助者账户是可执行账户(一个程序),两笔租金会改为退给结算签名者:两条完结路径中是 solver,两条退款路径中是 signer。如果该账户同样是可执行账户,指令会以 ExecutableRentRefundAccount 失败。

结算

finalise(order, solve_params) 用于向求解器付款。它会验证每一个输出都已被填充,然后将托管的资金转给求解器选定的目的地,并关闭该订单的各个账户。solve_params 为每个输出携带一条 { solver, timestamp } 记录,按输出顺序排列,且输出 0 的求解器——该订单的规范求解器——必须签名。除托管相关账户外,该指令还需要托管程序的 local_consumer PDA 和 intents_protocol 程序,用于消费以托管程序为消费方的本地认证。 remaining_accounts 为每个输出携带两项:先是每个输出一个认证账户,再是每个输出一个租金退还账户,均按输出顺序排列:
  • 同链输出对应的认证是一个 LocalAttestation;跨链输出对应的是一个由该订单的 input_oracle 创建的 Attestation。
  • 只有当本地认证的消费方是托管程序(output.oracle = input_settler_escrow 程序 ID)时,租金退还槽位才有意义。此时两个账户都必须可写,且退还账户必须是该认证中存储的 rent_refund;finalise 会消费该认证并退还其租金。
  • 对于其他所有认证——远程认证,以及使用规范编码的同链认证——该槽位必须提供但会被忽略。任何已存在的账户都可以,例如 system program,且两项都可以是只读的。
数量不对会以 WrongAttestationCount 失败。交易结构和求解器指南见结算。 结算会发出一个 FinalisedEvent。其 settler 字段是该订单的 order_context PDA,而不是托管单例或程序 ID:
该事件不携带金额。请从托管程序的 transfer_checked 内部指令中读取结算金额。对于带转账手续费的 Token-2022 铸币,目的地收到的是该金额减去手续费。

先释放输入的结算(finalise_with_prefill)

finalise_with_prefill(order_id) 会在交付输出之前先向求解器付款。它支持单输出的 Solana 订单,其紧凑字段可以重建出规范的 StandardOrder。求解器工作流见先释放输入的 Solana 结算。 该指令会扫描 Instructions sysvar,寻找后续恰好一个 fill_samechain 或 native_fill_samechain。该填充必须是顶层指令,由同一个求解器签名,并携带相同的 order_id。 托管程序会根据紧凑负载和已认证的账户重建出该订单。它会验证订单哈希、确切的账户数量(fill_samechain 为 10 个,native_fill_samechain 为 5 个)、账户地址顺序、规范代币账户、fill_record PDA,以及固定的程序 id。任何不匹配都会使整笔交易失败,并回滚托管资金的释放。 这里不需要检查截止时间。fill_deadline 本身就是 order_id 的一部分,而填充操作自身也会拒绝已过期的截止时间,因此只要某次填充得以执行,就说明它是准时的。 两条结算指令都必须是顶层指令(NotTopLevel)。结算器不会检查它们之间的指令,因此这些指令可以自由地进行兑换、铸造或创建账户。但整笔交易仍必须符合 Solana 在大小、计算量和账户锁定方面的限制。 紧凑填充不会创建 LocalAttestation:结算已经在同一笔交易的前面完成,因此没有什么需要再证明的。它们只会创建 FillRecord。
紧凑填充必须跟在一个匹配的 finalise_with_prefill 之后,并位于同一笔交易中。紧凑填充的处理逻辑自身不会检查这一点。单独发送的紧凑填充仍会交付输出,但不会留下任何认证,因此求解器之后无法再通过 finalise 领取输入。
结算发出的是与 finalise 相同的 FinalisedEvent,其 settler 为 order_context PDA。托管程序会转出其当前的全部余额,因此 open 之后发送到托管账户的代币也会一并付出;请从 transfer_checked 内部指令中读取金额。

退款

托管资金总会通过以下两条无需许可的路径之一,返还给 order.user:
  • refund(order) —— 一旦到达 order.expires(now >= expires),任何人都可以调用。
  • refund_on_non_fill(order, output_index) —— 一旦某个输出被证明未被填充,任何人都可以调用,而这最早可以发生在 fill_deadline 刚过之后。证明一个输出未被填充就会为整个订单退款,因为 finalise 要求所有输出都被证明已填充。它需要托管程序的 local_consumer PDA 和 intents_protocol 程序,以及恰好两个 remaining_accounts:[attestation, rent_refund],其消费或忽略规则与 finalise 相同。关于未填充证明是如何产生的,见未填充认证。
迟到的填充无法撤销退款:finalise 会拒绝 timestamp > fill_deadline 的填充(FilledTooLate),并且这些结算路径通过 order_context PDA 互斥。

输出结算:output_settler_simple

输出结算器负责在 Solana 上交付输出,并将每一次交付记录在一个 FillRecord 中;普通填充还会创建一个 LocalAttestation。它支持经典 SPL 代币、兼容的 Token-2022 代币,以及原生 SOL。

订单类型

每个输出的 context 字段编码了它的订单类型:第一个字节选择类型,其余部分是特定类型的负载,所有整数均为大端序。 其他任何类型字节都会导致 NotImplemented 失败;负载长度错误会导致 InvalidContextDataLength 失败。这些类型背后的定价公式与 EVM 共用——见拍卖。 紧凑的同链填充只能覆盖其中两种类型:空 context 和独占限价。荷兰式拍卖、独占荷兰式拍卖,或者显式写成 0x00 字节的限价订单,都必须通过 fill 或 native_fill 来处理。

填充

四条指令用于交付输出。标准的一对接受完整的 MandateOutput;紧凑的一对接受先释放输入的结算所使用的更小负载。
  • fill(order_id, mandate_output, fill_deadline, filler_data) —— 用于经典 SPL 和兼容的 Token-2022 输出。它会将代币转给接收方,创建 FillRecord 和 LocalAttestation 账户,并发出一个 OutputFilledEvent。order_id 是输入链的标识符;填充时不会对其进行验证。
  • native_fill(...) —— 用于原生 SOL 输出。输出代币必须是零地址 [0; 32];转账通过 system program 进行。
  • fill_samechain(args) 和 native_fill_samechain(args) —— 同样的两种交付方式,但由 CompactSamechainOrder 而非 MandateOutput 驱动。两者都必须作为顶层指令调用(NotTopLevel),都会将签名者记录为求解器而不是读取 filler_data 字段,并且都只创建 FillRecord——不创建 LocalAttestation。只能在同一笔交易中一个匹配的 finalise_with_prefill 之后使用它们。
其余一切都从该指令已经认证过的账户中重建:链 id 来自结算器状态,接收方和输出铸币来自填充账户(原生填充时为 32 字节的零),求解器来自签名者。重建出的输出始终将输出结算器的状态 PDA 同时作为 oracle 和 settler,且不携带任何 callback_data。ExclusiveFor 使用它自身携带的 exclusive_for 公钥;只有 ExclusiveForSelf 会展开为签名者自己的公钥。None 会展开为空 context,因此一个 context 显式写成 0x00 字节的订单无法通过这种方式被填充。 filler_data 是为源链领取而记录的 32 字节求解器 id;它既不是从 Solana 的 filler 签名者派生的,也不会与之比对。请选择一个能够在输入链上为完结授权的身份。特别是当输入位于 Solana 上时,它必须是将调用 finalise 的 Solana 签名者的原始公钥字节。 指令账户和失败条件由程序 IDL 以及上述验证规则定义。
output_settler_simple 要求 callback_data 为空。Solana 上不存在填充后的回调执行。

填充记录

每一条填充指令都会创建一个 FillRecord PDA——防止重复填充的守卫:它的存在会阻止对同一输出的二次填充。各填充指令将该账户命名为 fill_record;attest_not_filled 则把同一个地址命名为 fill_id。它的种子是 [order_id, mandate_output_hash]——与系统中大多数 PDA 不同,它没有字符串前缀。
mandate_output_hash 是该输出紧凑打包编码(而不是其 Borsh 编码)的 keccak-256 值:
chain_id 和 amount 取其 32 字节大端序值。种子中的 order_id 必须逐字节等于源链的订单标识符:对于源自 Solana 的订单,是订单结构中的 Borsh 哈希;否则是源链自己的订单 ID。以其他任何值记录的填充都无法为该订单结算。 一旦严格超过 close_after,任何人都可以调用 close_fill_record(order_id, output_hash) 关闭该记录,并将其租金退还给记录中的填充者;调用者无法改变租金去向。这个时间点的设计保证:只要某个输出仍可能被创建未填充证明,其记录就无法被关闭。关闭时会发出 FillRecordClosedEvent。过早关闭会以 FillRecordNotClosable 失败。
各填充指令将 fill_deadline 作为参数接收,只会将其与时钟比较(fill_deadline >= now);它们无法将其与已签名的订单比对。请始终传入订单确切的 fill_deadline:close_after——以及”在你的记录可能被关闭时不会有人能创建未填充证明”这一保证——都是根据你传入的值推导出来的。在订单已签名的 fill_deadline 之后才执行的填充——例如在记录被关闭后,通过传入更晚的截止时间进行的第二次付款——会交付代币,但永远无法为该订单结算,因为 finalise 会拒绝时间戳晚于已签名 fill_deadline 的填充(FilledTooLate)。
不要创建带有重复输出的订单。结算器先验证、再填充:两个完全相同的输出会合并成一次付款——第二次填充会被第一次填充产生的 FillRecord 阻止,但该订单仍然可以完结。请让各个输出保持不同,例如使用不同的金额或接收方。

未填充认证的创建

某个输出的 fill_deadline 过后,任何人都可以调用 attest_not_filled 来证明该输出从未被填充。它要求该输出的 FillRecord PDA 尚未初始化——只要填充记录存在,该输出就无法被证明为未填充——并会创建一个”未填充”的 LocalAttestation,从而在输入链上解锁提前退款路径(refund_on_non_fill)。 该认证只能在一个 48 小时的窗口内创建:
调用过早会以 FillDeadlineNotPassed 失败;超出窗口后调用会以 NotFilledWindowElapsed 失败。这个窗口限制的是证明的创建时间,而不是它的有效期:在窗口内创建的证明,之后仍可用于 refund_on_non_fill。如果没有人及时创建它,该订单在到期后仍可通过 refund 退款。 一次未填充退款无法被撤销:finalise 会拒绝任何时间戳晚于订单已签名 fill_deadline 的填充(FilledTooLate),而退款会关闭该订单。

认证

在 EVM 链上,输出已被填充的证明存放在验证接口背后的合约存储中。Solana 走的是另一条路:证明就是账户。当一次填充被验证时,协议会创建一个地址由该填充内容派生而来的 PDA——因此预期地址上的账户就是证明。对于 LocalAttestation,该账户还必须未被标记为已消费(见消费本地认证)。
一个认证账户并不是一个签名。它是一个确定性的 PDA,只有在某次具体的填充——或某个填充的证明——已按协议规则完成验证时才会被创建。
两个基本原语覆盖了整个系统:
  • LocalAttestation 在某个输出于 Solana 上通过 fill 或 native_fill 被填充时,在 Solana 上创建(紧凑填充不会创建它)。它证明了这次填充发生在它所在的那条链上。
  • Attestation 在某个预言机验证了一个远程证明或消息之后,在 Solana 上创建。它代表的是发生在另一条链上的一次填充。
这两种账户类型,以及用于验证其 PDA 的辅助函数,都位于 intents_protocol 程序中(IDL:intents_protocol.json)。 调用 PublicKey.findProgramAddressSync(seeds, programId) 时,仅传入上表列出的种子。该函数会自动寻找并返回 bump,不要在输入种子列表中额外添加 bump。重建或验证 PDA 时,再单独使用返回的 bump。

被认证的内容

一份认证锁定的是一个描述某个输出发生了什么的负载。存在两种负载类型,通过一个 4 字节的魔数前缀区分:
  • FillDescription 表示某个输出已被填充。它的魔数是 keccak("OIF.Fill") 的前 4 个字节(0xd1252dff),其布局为 MAGIC || solver (32B) || order_id (32B) || timestamp (u32 BE) || common_payload。
  • NotFilledDescription 表示某个输出在其截止时间之前未被填充。它的魔数是 keccak("OIF.NotFilled") 的前 4 个字节(0x830c1e1c),其布局为 MAGIC || order_id (32B) || fill_deadline (u32 BE) || common_payload。
common_payload 编码的是输出本身:TOKEN (32B) || AMOUNT (32B) || RECIPIENT (32B) || CALLBACK_LENGTH (u16 BE) + CALLBACK_DATA || CONTEXT_LENGTH (u16 BE) + CONTEXT。这种紧凑打包的大端序编码,与指令参数和账户所使用的 Borsh 编码是分开的。 一份填充描述会以两种方式被哈希,二者的区别在你调试一次失败的完结时很重要:
  • data_hash,被 LocalAttestation 使用,是不包含时间戳的描述的 keccak 值。时间戳单独存储在账户中并单独验证。
  • payload_hash,被 Attestation 使用,是完整描述(包含时间戳)的 keccak 值。
对于未填充描述,两种哈希都覆盖完整负载,且存储的时间戳固定为该输出的 fill_deadline。
长度前缀是 u16,因此 callback_data 和 context 各自的上限是 65,535 字节。该编码与 EVM 实现的 MandateOutputEncodingLib 字节兼容,并由跨实现的测试向量锁定。

LocalAttestation:Solana 上的填充

当输出结算器完成一次填充时,它会计算出 data_hash,并通过 CPI 调用 intents_protocol 来创建 LocalAttestation。该账户的地址由 b"local_attestation"、attestator(对该 CPI 签名的输出结算器 PDA)、consumer、data_hash 和 PDA 派生过程返回的 bump 派生而来。consumer 是一个 32 字节标识符,标识预期在输出链上消费该证明的对象——通常是输出侧的预言机程序。该种子格式由 intents_protocol 中的 validate_local_attestation(...) 在链上强制执行。 时间戳来自填充发生时的 Solana 时钟,并经过带检查的转换变为 u32(时钟值超出范围会以 InvalidTimestamp 失败)。 该账户存储:

消费本地认证

LocalAttestation 对其约定的消费方而言只能使用一次。当消费方以不可逆的方式使用该证明时,它会通过 intents_protocol::consume_local_attestation 消费该认证:该指令会验证 PDA、存储的时间戳和存储的 rent_refund,然后关闭该账户,并将其租金退还给 rent_refund。为消费签名的 authority 必须是消费方本身,或其程序的 ["local_consumer"] PDA。 其他任何情况——包括规范编码下的同链认证,其消费方是输出结算器——都只会被验证而永远不会被消费,其租金会一直锁定。 如果存储的 rent_refund 是一个可执行账户,消费仍会成功:该账户会被保留并标记为 consumed = true,同时发出 LocalAttestationRentDeferredEvent。之后由 reclaim_local_attestation_rent 将其关闭,其目的地不能是可执行账户。一旦记录中的 rent_refund 不再是可执行账户,任何人都可以调用它,把租金退还给该账户。若要把租金发往其他地址,则需要记录中的 rent_refund 以 rent_refund_authority 的身份签名。只有已消费的认证才能被回收(LocalAttestationNotConsumed)。 已消费的认证——无论是已关闭,还是以 consumed = true 保留——都不再是有效证明,因此不能再被导出,也不能再用于结算。

Attestation:从预言机接收到的证明

当某个预言机程序验证了一个远程证明——一个 Wormhole VAA、一个 Polymer 证明——时,它会通过 CPI 调用 intents_protocol,在 Solana 上为每个已验证的负载注册记录。该地址由 b"attestation"、attestator(由 order.input_oracle 指定的输入侧预言机)、chain_id.to_le_bytes()(小端序的 u128)、source、application、payload_hash 和 PDA 派生过程返回的 bump 派生而来。由 validate_attestation(...) 在链上强制执行。 source 和 application 这两个种子,将一份证明与其来源以及是谁发出它绑定在一起:
  • source —— 输出链上预言机或发出方的 32 字节标识符。
  • application —— 输出结算器的 32 字节标识符,通常就是 MandateOutput.settler。
在完结时,输入结算器会直接从订单自身的字段派生出预期的 PDA:chain_id 来自 output.chain_id,source 来自 output.oracle,application 来自 output.settler。
这正是为什么一笔订单的 MandateOutput.oracle 和 MandateOutput.settler 必须是你所使用的预言机系统在输出链上的正确标识符。如果它们填错了,证明就会落在一个完结逻辑永远不会去查看的 PDA 上。

端到端的证明流程

这两种账户类型是同一次中继的两半,分别位于不同的链上。下面的步骤 1 和步骤 2 运行在输出链上(当某个输出在 Solana 上被填充时);步骤 3 和步骤 4 运行在输入链上(当一笔 Solana 订单在等待某个远程填充的证明时)。对于一笔跨链订单,Solana 只扮演这两个角色中的一个。一笔同链的 Solana 订单则会完全跳过这个中继:完结会直接验证该次填充的 LocalAttestation(步骤 4)。
1

填充创建 LocalAttestation(输出链)

某个求解器通过 output_settler_simple 在 Solana 上填充一个输出。结算器会转移资产,创建一个用于防止重复填充的 FillRecord PDA,并创建一个锁定该次填充内容和时间戳的 LocalAttestation。
2

输出侧预言机对其进行验证(输出链)

在输出链上,预言机会检查 LocalAttestation 是否存在于正确的 PDA 上、是否未被消费,以及其存储的时间戳是否与负载相符:解码 FillDescription,重新计算不含时间戳的 data_hash,验证该 PDA。Polymer 的 submit 只做验证;submit_consume 和 Wormhole 的 submit 还会消费该认证。
3

输入侧预言机创建 Attestation(输入链)

在输入链上,预言机会验证它自己的证明格式——一个 Wormhole VAA 或一个 Polymer 证明——并为每一个已验证的 payload_hash 创建一个 Attestation。各预言机的具体细节见预言机系统。
4

完结时对每个输出核对一个认证(输入链)

当求解器调用 finalise 时,输入结算器会为每个输出验证一个通过 remaining_accounts 传入的认证账户,同时每个输出还需要一个租金退还槽位。同链输出预期是一个时间戳与该输出 SolveParams.timestamp 相符的 LocalAttestation;跨链输出预期是一个位于由 (input_oracle, chain_id, source, application, payload_hash) 派生出的 PDA 上的 Attestation。
对于远程认证而言,存在于那个确定性的 PDA 上这件事本身就是全部的证明:预言机只会在底层消息通过验证之后才创建该账户。

未填充认证(提前退款)

同样的两种原语也可以携带”某个输出未被填充”的证明,从而在订单到期之前解锁一次退款:
  • 在输出链上,output_settler_simple::attest_not_filled 会创建一个 LocalAttestation,其 data_hash 是完整 NotFilledDescription 的 keccak 值,其存储的时间戳是该输出的 fill_deadline。
  • 预言机中继它的方式与中继一份填充负载完全相同——NOT_FILLED_MAGIC 前缀会在两端选中”未填充”的哈希规则。
  • 在输入链上,refund_on_non_fill 会验证由此产生的 LocalAttestation(同链)或 Attestation(跨链),并在不等待 expires 的情况下为该托管退款——见退款。
  • 未填充的 LocalAttestation 只能在 fill_deadline 之后 48 小时内创建——见未填充认证的创建。

链配置

intents_protocol 还存储着认证系统所依赖的配置:
  • ChainId —— 一个单例 PDA(种子 "chain_id"),保存该部署的规范链 id。set_chain_id(chain_id) 只能被调用一次,任何签名者都可以调用——各部署会将其作为最初的第一步执行,在任何其他人抢先之前完成。各结算器程序会在自己的 initialize 时复制这个值。
  • ChainMapping —— 一个 PDA(种子为 ["chain_mapping", oracle, protocol_chain_id_le]),将某个消息传递协议自己的链 id 映射到规范链 id。每个协议都有自己的方案:Polymer 对 EVM 链使用 EVM 链 id,但对 Solana 使用自己的协议 id(2);Wormhole 则使用它自己的 u16 id。每个预言机的所有者会通过该预言机的 set_chain_mapping(protocol_chain_id, chain_id) 指令创建映射——仅限所有者调用,且每个映射只能设置一次。跨链认证需要这些映射:如果某个证明来自一条没有映射的链,它就无法被注册。

事件

intents_protocol 会为每一次状态变化发出一个 Anchor 事件,索引器可以订阅它们:
  • LocalAttestationCreatedEvent
  • LocalAttestationConsumedEvent { attestator, consumer, data_hash, rent_refund }
  • LocalAttestationRentDeferredEvent { local_attestation, rent_refund } —— 已消费,但由于 rent_refund 是可执行账户,租金被暂时保留
  • LocalAttestationRentReclaimedEvent { local_attestation, rent_refund }
  • AttestationCreatedEvent
  • ChainIdConfiguredEvent
  • ChainMapConfiguredEvent
其他程序发出的事件:
  • input_settler_escrow —— OpenEvent、FinalisedEvent(两条完结路径都是)、RefundedEvent
  • output_settler_simple —— OutputFilledEvent、OutputNotFilledEvent、FillRecordClosedEvent { order_id, output_hash, rent_refund }
  • polymer 和 wormhole —— OutputProvenEvent

常见陷阱

  • 32 字节标识符填错。 每个体系都有自己的 32 字节表示方式:Solana 的 Pubkey::to_bytes()、左侧补零到 32 字节的 EVM 地址、Wormhole 的 emitter 地址。把它们弄混,会得到一个验证方永远找不到的 PDA。
  • 每个身份字段在不同阶段失败。 在填充之前,请对照身份矩阵核对全部三者:
    • 如果 MandateOutput.settler 填错了,填充会被拒绝。该订单会一直无法填充,直到过期退款。
    • 如果 StandardOrder.input_oracle 填错了,完结逻辑会用错误的认证方派生出预期的 Attestation 地址,因此永远找不到预言机实际创建的那个认证。
    • 如果 MandateOutput.oracle 填错了,填充仍会成功,因为这个字段在填充时不会被检查。求解器的资产会立即离开;但之后的证明提交会因认证被拒绝而失败,求解器也就得不到偿付。
  • 时间戳不一致。 同链验证要求 LocalAttestation.timestamp == SolveParams.timestamp。如果求解器传入的时间戳与输出结算器存储的不一致,完结就会失败。
  • 缺少租金退还槽位。 finalise 和 refund_on_non_fill 要求为每个认证提供一个租金退还槽位,即使该槽位会被忽略。只传入认证会以 WrongAttestationCount 失败。

同链意图

Solana 到 Solana 的意图完全跳过远程证明。每一笔同链订单都会将输出结算器的状态 PDA 填入全部三个身份槽位(settler、oracle、inputOracle)——见哪个身份对应哪个字段。这样的订单有两条结算路径:
  • 标准路径: fill 创建一个 LocalAttestation,之后的 finalise 直接验证它——不涉及任何预言机程序。该认证不会被消费,因此其租金不会被退还。
  • 先释放输入: 先释放输入的结算会在交付输出的同一笔交易中向求解器付款,因此求解器可以直接用用户的输入来置办这笔输出,而不必动用自己的余额。紧凑填充不会创建任何认证。该订单还必须满足:callback_data 为空,且 context 为空或为独占限价。EVM 通过 finalise 的回调达到同样的效果;Solana 没有回调机制,而是需要一条特定的后置填充指令。
Same-chain Solana intent flow

预言机

对于跨链订单,预言机程序负责将填充证明从输出链带到输入链,并将其注册为 Attestation 账户。Polymer 和 Wormhole 的部署地址见部署概览。请使用订单和路由所配置的预言机。这些实现如何验证证明,见预言机系统;它们所写入的账户模型,见上文的认证。

扩展该系统

这些程序是可组合的。每个具体的程序都封装了一个共享的基础 crate——input_settler_base、output_settler_base 和 oracle_base——由它承载验证逻辑。你可以在同样的基础之上构建新的结算器或预言机,从而使其与系统的其余部分保持兼容。 对于自定义预言机的作者,oracle_base 提供了共享的辅助函数。在输出侧,consume_and_encode_payloads 接收 (LocalAttestation, rent_refund) 对,使用预言机传入的消费权限(Polymer 的 local_consumer PDA,或 Wormhole 自己的状态 PDA)验证并消费每一个认证,再将这些负载编码进一条跨链消息中;consume_payload 处理单个证明。在输入侧,decode_and_attest_payloads 会解码一条预言机已经验证过的消息,并为每个负载创建一个 Attestation 账户。Wormhole 使用它;Polymer 的 receive_attest 则自行解析已证明的 EVM 事件。

程序 IDL

每个程序的 Anchor IDL 都是机器可读的接口参考:包含每一条指令、每一个账户、每一种类型(含 Borsh 布局)、事件,以及该程序自身的错误码。跨程序共享的错误位于单独的目录文件 errors.json 中。请在此下载,并用 Anchor 的 TypeScript 客户端加载它们: 上表中四个已部署程序的 IDL 内嵌了前文列出的程序 id。Wormhole 的 IDL 是源码接口参考,其内嵌地址与已公布的主网部署不同。本页未确认该 IDL 与主网部署的兼容性;不要用其内嵌地址替换已部署的程序 ID。 这些是 LI.FI 经过验证的生产 IDL。Solana IDL 包页面提供了该包的其余部分——共享错误目录、来源记录、生成的 TypeScript 类型——以及 IDL 无法表达的规则:哪些 PDA 需要自行派生,以及每条指令确切的 remaining_accounts 布局。
有些 PDA 的种子是程序根据指令参数计算出来的值,IDL 无法表达这一点,因此 IDL 中没有这些账户的种子元数据,Anchor 也不会自动解析它们。请在客户端派生它们并显式传入:
  • order_context = PDA(["order_context", order_id]),consumed_order = PDA(["consumed_order", order_id]),其中 order_id 按订单结构中的方式计算——用于 open、finalise、refund 和 refund_on_non_fill。传入 order_context 后,Anchor 会据此派生出托管代币账户。
  • fill_record = output_settler_simple 下的 PDA([order_id, mandate_output_hash])——用于每一条填充指令,attest_not_filled 中则以 fill_id 之名使用(见填充记录)。

后续步骤

预言机系统

Polymer 如何验证和中继证明

Solana vs EVM

与 EVM 实现的差异及其原因