程序派生地址是一个没有私钥的地址,由程序而不是人拥有。你打交道的每一个池子账户、每一个金库、每一个按用户的状态账户,都是它。
推导它简单到大多数教程写完那一行就结束了。★但对机器人来说真正重要的,是之后的部分:它的代价、什么时候做、以及你实际在用哪个 bump。★
推导
import { PublicKey } from "@solana/web3.js";
const [pda, bump] = PublicKey.findProgramAddressSync(
[
Buffer.from("vault"), // 字面量种子
owner.toBuffer(), // ★32 字节★
mint.toBuffer(), // ★32 字节★
],
PROGRAM_ID,
);
这个函数把你的种子和程序 ID 一起哈希,然后检查结果是否落在 ed25519 曲线之外。如果落在曲线上,就可能存在一个带私钥的地址,那个结果就不能用。 于是它把一个 bump 字节从 255 开始递减,反复尝试,直到找到一个在曲线外的。
★那个循环就是代价。★ 常见情况下很快,原理上没有上界,而且你每调用一次它就跑一次。
为什么要缓存它
给定账户的种子永远不变,这意味着推导出的地址也永远不变:
// ★同样的输入,同样的输出,每一次都是。推导一次就够。★
const pdaCache = new Map<string, [PublicKey, number]>();
function getVault(owner: PublicKey, mint: PublicKey) {
const key = `${owner.toBase58()}:${mint.toBase58()}`;
let v = pdaCache.get(key);
if (!v) {
v = PublicKey.findProgramAddressSync(
[Buffer.from("vault"), owner.toBuffer(), mint.toBuffer()],
PROGRAM_ID,
);
pdaCache.set(key, v);
}
return v;
}
对一个每笔交易要推导好几个 PDA、而且高频运行的机器人来说,这是实打实的 CPU,反复花在一个不可能改变的答案上。★对打新狙击而言,它属于预计算阶段 —— 和 blockhash 缓存、连接预热放在一起 —— 而不属于"检测到上新"和"调用 sendRawTransaction"之间。★
如果你的推导已经预计算好、交易还是落得晚,免费领个 BoltTx key 改一行就能测测提交路径。
bump 不是装饰
findProgramAddressSync 返回的是规范 bump —— 能产出有效曲线外地址的最大那个值。这个区分重要,因为其它 bump 也可能产出有效地址。
// ★规范:从 255 往下数,第一个可用的。★
const [pda, bump] = PublicKey.findProgramAddressSync(seeds, PROGRAM_ID);
// ★任意:bump 由你提供,不做搜索。★
const alt = PublicKey.createProgramAddressSync(
[...seeds, Buffer.from([bump - 1])],
PROGRAM_ID,
);
★如果一个程序接受用户提供的 bump 却不检查它是否规范,那么同一个逻辑账户就可能存在两个不同的有效地址。★ 这是一类真实的漏洞 —— 程序以为自己在操作某个账户,而调用方把它指向了另一个。
作为调用方,实操规则很简单:永远传规范 bump。 把 bump 存在链上的程序期待的是它,而会重新计算的程序,你传别的它就会和你不一致。
把 bump 存下来,不要在链上重新推导
这是代价在程序内部显现的地方,也是 Anchor 代码审查里常见的一条:
// ★贵:每次调用都在链上跑那个搜索循环。★
let (pda, bump) = Pubkey::find_program_address(seeds, program_id);
// ★便宜:拿存好的 bump 校验一个候选。★
let pda = Pubkey::create_program_address(
&[seed_a, seed_b, &[stored_bump]],
program_id,
)?;
find_program_address 会在链上跑同样的递减搜索,为一个"账户创建时本来就可以存下来"的结果消耗计算单元。★一个每条指令都这样推导好几个 PDA 的程序,会把可观的一部分计算预算花在已知的结果上。★
在 Anchor 里,这就是"让框架去找 bump"和"用 bump = account.bump 指向存好的值"之间的差别。
会咬人的种子规则
每个种子 32 字节,最多 16 个种子。 一个公钥正好 32 字节,装得下;一个更长的字符串装不下。
// ★太长 —— 这里会抛异常。★
Buffer.from("a_very_long_seed_string_that_exceeds_the_limit_here")
// ★哈希成 32 字节。★
createHash("sha256").update(longValue).digest()
种子是字节,不是字符串。 Buffer.from("vault") 和 Buffer.from("Vault") 推导出不同的地址,小端写的 u64 和大端写的也是。★这里对不上会产出一个链上根本不存在的地址,而你拿到的错误是 AccountNotFound —— 它指向账户,而不是指向你的编码。★
// 字节序要和程序完全一致。
const indexSeed = Buffer.alloc(8);
indexSeed.writeBigUInt64LE(BigInt(index)); // ★小端,对应 Rust 的 to_le_bytes★
顺序是身份的一部分。 调换两个种子会得到不同的地址,而不是一个错误。
调试一个对不上的 PDA
当推导出的地址和程序期待的不一致时,按这个顺序查:
const [pda, bump] = PublicKey.findProgramAddressSync(seeds, PROGRAM_ID);
console.log("推导出:", pda.toBase58(), "bump:", bump);
const info = await connection.getAccountInfo(pda);
console.log("存在:", !!info, "owner:", info?.owner.toBase58());
★owner 是这里最有用的一个字段。★ 如果账户存在但属于另一个程序,说明你的种子对了、程序 ID 错了。 如果它根本不存在,要么种子错了,要么账户确实还没被创建 —— 而对一个新代币来说,后者才是常态。
这也正是打新狙击没法预计算代币自身 PDA 的原因。 上新之前 mint 不存在,所以任何以它为种子的 PDA 都无法提前推导。把所有以已知值为种子的都预算好,并接受剩下那部分在关键路径上。
上链应该是什么水平
我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。
★推导是微秒级的,落块是 slot 级的。★ 缓存 PDA 值得做是因为它免费,不是因为它改变你落在哪个区块 —— 那部分是在你的字节离开进程之后才决定的。
BoltTx 在这里的位置
我们负责提交。地址推导完全发生在你的进程里,在任何东西被签名之前。
提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前、传输途中观察不到。
你在本地签名。我们不托管资金、不代签、不修改交易内容 —— 包括你的指令引用了哪些账户。tip 带在交易里、从你自己的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。
免费领 API key,没有月费:
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
常见问题
Solana 的 PDA 是什么? 程序派生地址 —— 一个刻意落在 ed25519 曲线之外的地址,因此不存在对应私钥。由程序代替它签名,这就是程序拥有账户的方式。
JavaScript 里怎么推导 PDA?
PublicKey.findProgramAddressSync(seeds, programId) 返回地址和它的规范 bump。种子和它们的顺序必须和程序用的完全一致。
PDA 里的 bump 是什么?
一个追加到种子后面、好让结果落在曲线外的字节。findProgramAddressSync 从 255 往下数,返回第一个可用的值,那就是规范 bump。
PDA 推导该缓存吗? 该。同样的种子永远产出同样的地址,而推导每次都要跑一个搜索循环。对延迟敏感的代码,它属于预计算阶段而不是热路径。
为什么 find_program_address 在链上很贵?
它在程序内部跑同样的递减搜索,为一个你本可以存下来的结果消耗计算单元。改用 create_program_address 配存好的 bump。
规范 bump 和非规范 bump 有什么区别? 规范 bump 是能产出有效地址的最大那个值。其它 bump 也可能产出有效地址,所以一个接受未经检查的用户 bump 的程序,可能会操作到非预期的账户。
种子最长能多少? 每个种子 32 字节,最多 16 个。公钥正好装下。 更长的值要先哈希成 32 字节。
我推导的 PDA 为什么和程序对不上?
通常是种子编码不一致 —— 字符串大小写不同,或者数字的字节序写反了。Rust 的 to_le_bytes 对应 writeBigUInt64LE,不是大端那个。
PDA 为什么报 AccountNotFound?
要么种子错了、你推出了一个没人创建过的地址,要么账户确实还不存在。查 getAccountInfo 并看 owner 字段,就能区分这两种。
能给一个还不存在的代币推导 PDA 吗? 如果 mint 是种子之一,不能。这正是打新狙击者没法预计算那些地址、必须等 mint 出现之后才推导的原因。
种子顺序重要吗? 重要。顺序不同就是不同的地址,而且没有任何错误提示你。 就程序而言,顺序是这个账户身份的一部分。
怎么把一个数字当种子传?
按程序使用的字节序写进 buffer,Rust 通常是小端。Buffer.alloc(8) 配 writeBigUInt64LE,对应用 to_le_bytes 写的 u64。
关联代币账户是 PDA 吗?
是。ATA 由钱包、代币程序和 mint 推导而来,这也是 getAssociatedTokenAddress 是一次推导而不是一次查询的原因。
两组不同的种子会产出同一个 PDA 吗? 实际上不会,因为种子是和程序 ID 一起哈希的。现实中的碰撞风险是非规范 bump 为同一个逻辑账户产出第二个有效地址。
PDA 需要租金豁免吗? 需要,和任何存数据的账户一样。由出资创建那笔交易的人付,而且账户之后关闭时可以取回。
一笔交易能引用多少个 PDA? 和任何账户一样的限制:每个占 1232 字节里的 32 字节。PDA 推导出来之后就是普通账户 key,lookup table 对它们的压缩方式也一样。