PDA и CPI: адрес без ключа и вызов чужой программы
До сих пор аккаунтами владели люди: за каждым стоял приватный ключ, которым подписывают. Но программе часто нужно владеть аккаунтом самой: хранилище фонда, счёт пула ликвидности, эскроу сделки. Не может же программа хранить приватный ключ у себя в коде, он же открыт всем. Solana решает это двумя механизмами, которые работают в паре: PDA (адрес, у которого ключа нет ни у кого) и CPI (как программа зовёт другую программу и подписывает за свой PDA). Разберём оба на хранилище lamports.
PDA: адрес, специально выбитый без ключа
Вспомни эллиптические кривые: обычный публичный ключ это точка на кривой ed25519, и у неё есть парный приватный ключ. Подписать за такой адрес может только владелец ключа.
PDA устроен наоборот: это адрес, специально выбитый так, чтобы точки на кривой для него не было. Раз точки нет, приватного ключа не существует вовсе, ни у кого. Подписать за PDA умеет ровно одна программа, из чьего program_id он выведен, и только предъявив сиды.
Выводится PDA детерминированно из сидов (произвольных байтовых меток) и program_id:
use solana_program::pubkey::Pubkey;
pub const VAULT_SEED: &[u8] = b"vault";
/// Адрес хранилища для владельца. Возвращает и адрес, и bump.
pub fn vault_address(owner: &Pubkey, program_id: &Pubkey) -> (Pubkey, u8) {
Pubkey::find_program_address(&[VAULT_SEED, owner.as_ref()], program_id)
}
find_program_address перебирает дополнительный байт bump от 255 вниз, пока не найдёт значение, дающее адрес вне кривой. Свойства, которые из этого следуют:
- Детерминизм. Одни сиды и
program_idвсегда дают тот же адрес. Клиент выводит его сам, заранее, не спрашивая цепь. - Разделение. Разные сиды дают разные PDA: у каждого владельца своё хранилище, потому что в сиды входит его ключ.
- Никакого ключа. Адрес гарантированно off-curve, подписать за него обычной подписью нельзя.
// PDA обязан лежать вне кривой: приватного ключа для него не существует.
let (vault, _bump) = vault_address(&owner, &program_id);
assert!(!vault.is_on_curve());
CPI: одна программа зовёт другую
Теперь механизм вызова. CPI это когда твоя программа во время работы зовёт другую программу. Классика: чтобы двигать lamports, зовём встроенную System Program, а не переписываем её. Есть две формы вызова, и разница между ними это сердце урока.
invoke для случая, когда за нужный аккаунт подписывает обычный ключ. Кладём lamports на хранилище: подписывает плательщик, у него есть ключ, никакой магии.
use solana_program::{program::invoke, system_instruction};
fn deposit(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let account_iter = &mut accounts.iter();
let payer = next_account_info(account_iter)?;
let vault = next_account_info(account_iter)?;
let system_program = next_account_info(account_iter)?;
if !payer.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// Хранилище выводится из ключа плательщика: убедимся, что прислали то самое.
let (expected_vault, _bump) = vault_address(payer.key, program_id);
if vault.key != &expected_vault {
return Err(ProgramError::InvalidArgument);
}
// Обычный CPI: плательщик подписал транзакцию сам, сиды не нужны.
invoke(
&system_instruction::transfer(payer.key, vault.key, amount),
&[payer.clone(), vault.clone(), system_program.clone()],
)
}
Кстати, а существует ли хранилище до первого взноса? Нет, и это нормально: перевод lamports на адрес, по которому аккаунта ещё нет, сам создаёт пустой аккаунт (ноль байт данных, owner это System Program) и кладёт туда сумму. Тут вступает правило rent-exempt из урока 32: аккаунт существует, только пока его баланс покрывает минимум за занимаемое место, даже когда данных ноль байт. Минимум считается одной строкой, let min = rent.minimum_balance(0);, и первый взнос должен его покрывать, иначе аккаунту не бывать.
invoke_signed для случая, когда подписать надо за PDA. Снимаем lamports с хранилища: у PDA ключа нет, поэтому за него подписывает программа, предъявляя те же сиды, из которых он выведен.
use solana_program::program::invoke_signed;
fn withdraw(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let account_iter = &mut accounts.iter();
let owner = next_account_info(account_iter)?;
let vault = next_account_info(account_iter)?;
let recipient = next_account_info(account_iter)?;
let system_program = next_account_info(account_iter)?;
// Снять вправе только владелец, из чьего ключа выведен PDA.
if !owner.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
let (expected_vault, bump) = vault_address(owner.key, program_id);
if vault.key != &expected_vault {
return Err(ProgramError::InvalidArgument);
}
// CPI с подписью PDA: приватного ключа нет, предъявляем сиды с bump.
// Рантайм выведет из них адрес и убедится, что это ровно наш vault.
invoke_signed(
&system_instruction::transfer(vault.key, recipient.key, amount),
&[vault.clone(), recipient.clone(), system_program.clone()],
&[&[VAULT_SEED, owner.key.as_ref(), &[bump]]],
)
}
Ключевая строка последняя: &[&[VAULT_SEED, owner.key.as_ref(), &[bump]]]. Это сиды подписи. Рантайм берёт их, прогоняет тот же вывод, что и find_program_address, и проверяет: получается ли из этих сидов и program_id именно тот PDA, который мы двигаем? Если да, подпись PDA считается предъявленной. Программа не может подписать за чужой PDA: она не знает чужих сидов, а подделать вывод нельзя, это криптографический хеш.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.