Это руководство предназначено для поставщиков сервисов кошельков и
разработчиков, которые хотят интегрировать новые решения для управления ключами
в библиотеку solana-keychain. Добавив реализацию вашего подписанта, вы
позволите разработчикам использовать ваш сервис для безопасной подписи
транзакций Solana через единый интерфейс.
Keychain поддерживает паритет своих реализаций на Rust, TypeScript, Python и Go. PR, включающие все четыре, предпочтительны. Если вы можете внести вклад только в одну, это нормально, но учтите, что остальные потребуются до того, как подписант попадёт в релиз.
Используете Claude Code? Навык add-signer-ci
в репозитории настраивает CI-воркфлоу для нового подписанта, добавленного через PR из форка.
Обзор архитектуры
Библиотека использует архитектуру на основе трейтов, определённых в src/traits.rs. Каждый
подписант реализует базовый трейт SolanaSigner (pubkey, sign_message,
is_available) плюс ровно один трейт возможностей, соответствующий устройству провайдера:
TransactionSigner(sign_transaction): провайдер подписывает байты вызывающей стороны, а вызывающая сторона сама рассылает транзакцию. Подходит для большинства бэкендов.ModifyingSigner(modify_and_sign_transaction): провайдер переписывает транзакцию, а затем подписывает её без рассылки.SendingSigner(sign_and_send_transaction): провайдер подписывает и рассылает транзакцию на стороне сервера.
Библиотека также предоставляет единое перечисление Signer, которое оборачивает все
реализации и позволяет выбирать бэкенды подписи во время выполнения. Оно реализует
только базовый трейт; доступ к возможностям осуществляется через as_transaction_signer(),
as_modifying_signer() и as_sending_signer().
Краткий контрольный список интеграции
- Создайте модуль вашего подписанта с реализацией
- Реализуйте базовый трейт
SolanaSigner(pubkey,sign_message,is_available) плюс один трейт возможностей, соответствующий устройству вашего провайдера (обычноTransactionSigner) - Добавьте флаг функции в
Cargo.toml, включая внутренний маркер_remote, если ваш подписант использует reqwest - Обновите перечисление
Signerвsrc/lib.rs(вариант, веткуdispatch_signer!и ветку в методе доступа к возможностям, соответствующем вашему типу) - Обеспечьте HTTPS и настройте таймауты для HTTP-клиентов
- Подпишите байты сообщения и проверьте возвращённую подпись, прежде чем прикреплять её
- Добавьте исчерпывающие модульные тесты (на основе wiremock, в вашем модуле)
- Добавьте файл интеграционных тестов и обновите
.env.example - Добавьте реализации на TypeScript, Python и Go
- Обновите документацию
- Обновления CI-воркфлоу (согласуйте с мейнтейнерами, см. Обновления CI-воркфлоу)
- Отправьте PR
Шаг 1: Создайте модуль вашего подписанта
Создайте новую директорию в src/ для вашей реализации:
src/├── your_service/│ ├── mod.rs # Main implementation with SolanaSigner trait│ └── types.rs # API request/response types (if needed)
Шаг 2: Определите структуру вашего подписанта
В src/your_service/mod.rs определите структуру вашего подписанта:
//! YourService API signer integrationuse crate::sdk_adapter::{Pubkey, Signature, VersionedTransaction};use crate::{error::SignerError, traits::SolanaSigner};use std::str::FromStr;/// YourService-based signer using YourService's API#[derive(Clone)]pub struct YourServiceSigner {api_key: String,api_secret: String,wallet_id: String,api_base_url: String,client: reqwest::Client,public_key: Pubkey,}impl std::fmt::Debug for YourServiceSigner {fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {f.debug_struct("YourServiceSigner").field("public_key", &self.public_key).finish_non_exhaustive()}}
Шаг 3: Реализуйте конструктор и вспомогательные методы
Удаленные подписанты должны обеспечивать HTTPS и настраивать таймауты HTTP.
Используйте общую структуру HttpClientConfig для настройки таймаутов.
use crate::http_client_config::HttpClientConfig;impl YourServiceSigner {pub fn new(api_key: String,api_secret: String,wallet_id: String,public_key: String,http_config: Option<HttpClientConfig>,) -> Result<Self, SignerError> {let pubkey = Pubkey::from_str(&public_key).map_err(|e| SignerError::InvalidPublicKey(format!("Invalid public key: {e}")))?;let http = http_config.unwrap_or_default();let builder = reqwest::Client::builder().timeout(http.resolved_request_timeout()).connect_timeout(http.resolved_connect_timeout());// Enforce HTTPS in production; wiremock uses HTTP in tests#[cfg(not(test))]let builder = builder.https_only(true);let client = builder.build().map_err(|e| {SignerError::ConfigError(format!("Failed to build HTTP client: {e}"))})?;Ok(Self {api_key,api_secret,wallet_id,api_base_url: "https://api.yourservice.com/v1".to_string(),client,public_key: pubkey,})}/// Sign raw bytes using your service's APIasync fn sign(&self, message: &[u8]) -> Result<Signature, SignerError> {let encoded_message = base64::engine::general_purpose::STANDARD.encode(message);let url = format!("{}/sign", self.api_base_url);let response = self.client.post(&url).header("Authorization", format!("Bearer {}", self.api_key)).json(&serde_json::json!({"wallet_id": self.wallet_id,"message": encoded_message,})).send().await?;// Use generic error messages: never expose raw API response textif !response.status().is_success() {let status = response.status().as_u16();return Err(SignerError::remote_api(format!("YourService API returned status {status}")));}// Parse response: always use map_err, never .expect() or .unwrap()let response_data: SignResponse = response.json().await.map_err(|e| SignerError::SerializationError(format!("Failed to parse response: {e}")))?;let sig_bytes = base64::engine::general_purpose::STANDARD.decode(&response_data.signature).map_err(|e| SignerError::SerializationError(format!("Failed to decode signature: {e}")))?;let sig_array: [u8; 64] = sig_bytes.try_into().map_err(|_| SignerError::SigningFailed("Invalid signature length".to_string()))?;Ok(Signature::from(sig_array))}}
Шаг 4: Реализуйте трейты Signer
Базовый трейт SolanaSigner содержит 2 асинхронных метода (sign_message,
is_available) плюс pubkey(). Подпись транзакций вынесена в трейт возможностей;
большинство провайдеров реализуют TransactionSigner, чей метод sign_transaction возвращает
SignTransactionResult (размеченное перечисление, показывающее, что транзакция подписана полностью или
частично).
Подписывайте байты сообщения, а не всю сетевую транзакцию целиком, и проверяйте
подпись провайдера по этим байтам, прежде чем прикреплять её. Используйте общие
вспомогательные функции TransactionUtil для подписи и сериализации.
use crate::signature_util::verify_or_reject;use crate::traits::{SignTransactionResult, TransactionSigner};use crate::transaction_util::TransactionUtil;#[async_trait::async_trait]impl SolanaSigner for YourServiceSigner {fn pubkey(&self) -> Pubkey {self.public_key}async fn sign_message(&self, message: &[u8]) -> Result<Signature, SignerError> {let signature = self.sign(message).await?;verify_or_reject(&signature, &self.public_key, message)?;Ok(signature)}async fn is_available(&self) -> bool {let url = format!("{}/health", self.api_base_url);self.client.get(&url).send().await.map(|r| r.status().is_success()).unwrap_or(false)}}#[async_trait::async_trait]impl TransactionSigner for YourServiceSigner {async fn sign_transaction(&self,tx: &mut VersionedTransaction,) -> Result<SignTransactionResult, SignerError> {let message = tx.message.serialize();let signature = self.sign(&message).await?;verify_or_reject(&signature, &self.public_key, &message)?;TransactionUtil::add_signature_to_transaction(tx, &self.public_key, signature)?;let serialized = TransactionUtil::serialize_transaction(tx)?;Ok(TransactionUtil::classify_signed_transaction(tx,(serialized, signature),))}}
Если ваш провайдер переписывает транзакцию перед подписью, реализуйте
вместо этого ModifyingSigner и полностью замените транзакцию вызывающей стороны
байтами провайдера. Если он рассылает транзакцию на стороне сервера, реализуйте SendingSigner
и оставьте транзакцию вызывающей стороны нетронутой. Никогда не реализуйте возможность, которую
ваш провайдер не может обеспечить.
Шаг 5: Добавьте типы API (опционально)
Если вашему API требуются пользовательские типы, создайте
src/your_service/types.rs:
use serde::{Deserialize, Serialize};#[derive(Serialize)]pub struct SignRequest {pub wallet_id: String,pub message: String,}#[derive(Deserialize)]pub struct SignResponse {pub signature: String,}
Шаг 6: Добавьте флаг функции
Обновите Cargo.toml, добавив своего подписанта как опциональную функцию. Удалённые бэкенды
включают внутренний маркер _remote, который управляет реализацией From<reqwest::Error>
в src/error.rs; без него оператор ? в вызовах reqwest не скомпилируется, если включена только
ваша функция.
[features]default = ["memory", "sdk-v2"]memory = []vault = ["dep:reqwest", "_remote"]privy = ["dep:reqwest", "dep:p256", "_remote"]your_service = ["dep:reqwest", "_remote"] # Add your featureall = ["memory", "vault", "privy", "...", "your_service"] # Update all
Шаг 7: Обновите перечисление Signer
Добавьте своего подписанта в src/lib.rs. Базовая реализация SolanaSigner диспетчеризуется через
макрос dispatch_signer!, поэтому добавьте туда одну ветку. Доступ к возможностям реализован в
методах as_transaction_signer(), as_modifying_signer() и as_sending_signer():
добавьте ветку Some(s) в тот метод, который соответствует вашему трейту возможностей (а для
бэкенда с рассылкой или модификацией — ещё и ветку None в as_transaction_signer()).
// Add feature-gated module#[cfg(feature = "your_service")]pub mod your_service;// Re-export your signer type#[cfg(feature = "your_service")]pub use your_service::YourServiceSigner;// Add to Signer enum#[derive(Debug)]pub enum Signer {#[cfg(feature = "memory")]Memory(MemorySigner),// ... existing variants#[cfg(feature = "your_service")]YourService(YourServiceSigner), // Add your variant}// Add constructor methodimpl Signer {#[cfg(feature = "your_service")]pub fn from_your_service(api_key: String,api_secret: String,wallet_id: String,public_key: String,) -> Result<Self, SignerError> {Ok(Self::YourService(YourServiceSigner::new(api_key,api_secret,wallet_id,public_key,None, // uses default HttpClientConfig)?))}}// Update the dispatch macro: the base SolanaSigner impl uses it for// pubkey, sign_message and is_availablemacro_rules! dispatch_signer {($self:ident, $signer:pat => $body:expr) => {match $self {// ... existing variants#[cfg(feature = "your_service")]Signer::YourService($signer) => $body,}};}// Add your arm to the capability accessor matching your shapeimpl Signer {pub fn as_transaction_signer(&self) -> Option<&dyn TransactionSigner> {match self {// ... existing variants#[cfg(feature = "your_service")]Signer::YourService(s) => Some(s),}}}
Шаг 8: Добавьте комплексные тесты
Добавьте тесты в ваш модуль (в конце src/your_service/mod.rs):
#[cfg(test)]mod tests {use super::*;use solana_sdk::{signature::Keypair, signer::Signer};use wiremock::{matchers::{header, method, path},Mock, MockServer, ResponseTemplate,};#[tokio::test]async fn test_new() {let keypair = Keypair::new();let signer = YourServiceSigner::new("test-key".to_string(),"test-secret".to_string(),"test-wallet".to_string(),keypair.pubkey().to_string(),None,);assert!(signer.is_ok());}#[tokio::test]async fn test_sign_message() {let mock_server = MockServer::start().await;let keypair = Keypair::new();let message = b"test message";let signature = keypair.sign_message(message);Mock::given(method("POST")).and(path("/sign")).respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"signature": base64::engine::general_purpose::STANDARD.encode(signature.as_ref())}))).expect(1).mount(&mock_server).await;let mut signer = YourServiceSigner::new("test-key".to_string(),"test-secret".to_string(),"test-wallet".to_string(),keypair.pubkey().to_string(),None,).unwrap();signer.api_base_url = mock_server.uri();let result = signer.sign_message(message).await;assert!(result.is_ok());}#[tokio::test]async fn test_sign_unauthorized() {let mock_server = MockServer::start().await;let keypair = Keypair::new();Mock::given(method("POST")).and(path("/sign")).respond_with(ResponseTemplate::new(401)).expect(1).mount(&mock_server).await;let mut signer = YourServiceSigner::new("bad-key".to_string(),"bad-secret".to_string(),"test-wallet".to_string(),keypair.pubkey().to_string(),None,).unwrap();signer.api_base_url = mock_server.uri();let result = signer.sign_message(b"test").await;assert!(result.is_err());}}
Также покройте случай сбоя проверки подписи: замоканный ответ, подпись которого не проходит проверку, должен возвращать ошибку.
Шаг 9: Добавьте интеграционные тесты
Создайте rust/src/tests/test_<name>_integration.rs. Интеграционные тесты выполняются
с реальным API сервиса (не wiremock) и защищены функцией
integration-tests. Каждому файлу нужны:
- объявления
pub constдля имён переменных окружения - вспомогательная функция
get_signer(), которая читает переменные окружения черезdotenvy - три тестовые функции:
test_<name>_sign_message,test_<name>_sign_transactionиtest_<name>_is_available - ограничения по функциям:
#[cfg(feature = "your_service")]на модуле и#[cfg(feature = "integration-tests")]на каждом тесте
Полный пример смотрите в rust/src/tests/test_para_integration.rs, а свой модуль
объявите в rust/src/tests/mod.rs:
pub mod test_your_service_integration;
Шаг 10: Обновите переменные окружения
Обновите корневой файл .env.example, добавив переменные окружения вашего подписанта по
существующему образцу:
# YourService Configuration (for SIGNER_TYPE=your_service)YOUR_SERVICE_API_KEY=your-api-keyYOUR_SERVICE_WALLET_ID=your-wallet-id# YOUR_SERVICE_API_BASE_URL=https://api.yourservice.com/v1 # Optional, defaults to this
Сначала перечислите обязательные переменные с placeholder-значениями, а необязательные закомментируйте
с указанием значений по умолчанию. Каждая переменная окружения, используемая в интеграционных тестах, должна быть указана в
.env.example, а наборы тестов на TypeScript, Python и Go используют те же имена.
Шаг 11: Обновите документацию
Добавьте ваш подписывающий модуль в таблицу поддерживаемых бэкендов в README.md:
| Бэкенд | Сценарий использования | Флаг функции |
|---|---|---|
| Memory | Локальные пары ключей, разработка, тестирование | memory |
| Vault | Корпоративное управление ключами с HashiCorp Vault | vault |
| Privy | Встроенные кошельки с инфраструктурой Privy | privy |
| Turnkey | Управление ключами без хранения через Turnkey | turnkey |
| YourService | Краткое описание вашего сервиса | your_service |
Также добавьте свой бэкенд в таблицу возможностей подписантов в README каждого языка. Добавьте пример использования:
use solana_keychain::{Signer, SolanaSigner};#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> {let signer = Signer::from_your_service("your-api-key".to_string(),"your-api-secret".to_string(),"your-wallet-id".to_string(),"your-public-key".to_string(),)?;let pubkey = signer.pubkey();println!("Public key: {}", pubkey);Ok(())}
Тестирование вашей интеграции
Используйте рецепты just вместо «сырого» cargo. Функции SDK (sdk-v2,
sdk-v3, sdk-v4) взаимоисключающие, поэтому cargo test --all-features
завершится с ошибкой.
# Unit tests across the sdk-v2, sdk-v3 and sdk-v4 matricesjust rust-test# Live integration tests (require credentials in .env)just rust-test-integration
Обновления CI-воркфлоу (PR из форков)
CI — двухфазный процесс. Согласуйте с мейнтейнерами подготовку main, прежде чем
ваш PR можно будет протестировать: они обновляют fork-external-live-manual.yml, добавляют переменные окружения
в ci.yml и настраивают GitHub Secrets.
Ваш PR должен включать следующие изменения второй фазы вместе с кодом вашего подписанта:
ci.yml: добавьте свою функцию Rust в матрицуbackend(передall) и свою функцию интеграционного теста в матрицуtesttypescript-ci.yml: добавьте свой пакет в матрицы модульных и интеграционных тестов, а также добавьте переменные окружения в шаг интеграционных тестовtypescript-publish.yml: добавьте свой пакет вPUBLISH_PACKAGES, в массивpackagesGitHub Release и в сводную таблицуpython-ci.yml: добавьте бэкенд в условияCI_SIGNER_*вresolve-signersи в матрицу интеграционных тестовgo-ci.yml: добавьте бэкенд в условияCI_SIGNER_*и вmoduleBySigner
Подписывающий модуль TypeScript
Если вы также добавляете пакет подписывающего модуля TypeScript, создайте его в
typescript/packages/your-signer/. Ключевые паттерны:
- Фабричная функция
createYourSigner()возвращаетSolanaTransactionSigner<TAddress> & SolanaMessageSigner<TAddress>; держите класс подписанта внутренним и экспортируйте только фабрику и тип её конфигурации - Экспортировать интерфейс конфигурации (
YourSignerConfig) - Если ваш бэкенд не умеет подписывать сообщения, опустите
SolanaMessageSignerи вообще не добавляйте методsignMessages: Kit классифицирует подписантов по наличию методов, поэтому заглушка, выбрасывающая ошибку, приведёт к неверной классификации подписанта - Требуйте HTTPS в полях конфигурации
apiBaseUrlс помощьюassertHttpsUrl()из@solana/keychain-core - Выполняйте удалённые вызовы через
fetchSignerJson()из@solana/keychain-core, который отвечает за сопоставление ошибок, очистку ответов, отклонение редиректов и таймаут по умолчанию - Очищайте любой другой текст ошибок удалённого API с помощью
sanitizeRemoteErrorResponse()из@solana/keychain-core - Использовать
throwSignerError(SignerErrorCode.*, { cause, message })из@solana/keychain-core - Учитывайте
config?.abortSignalв каждом методе подписи, передавая его вfetchSignerJson()иsignBatchStaggered() - Проверяйте
requestDelayMsс помощьюvalidateRequestDelayMs()и выполняйте пакетную обработку черезsignBatchStaggered() - Добавить JSDoc
@throwsк фабричным функциям со списком кодов ошибок - Объявляйте пакеты
@solana/*как peer-зависимости версии>=8.1.0 - Модульные тесты на vitest с замоканным
fetch; интеграционные тесты сrunSignerIntegrationTestиз@solana/keychain-test-utils
Отправляющие подписанты
Если ваш провайдер переписывает транзакцию или рассылает её на стороне сервера, его
подпись нельзя применить к транзакции вызывающей стороны, поэтому это должен быть
SolanaSendingSigner, а не SolanaTransactionSigner:
- Реализуйте
signAndSendTransactions()и не добавляйтеsignTransactions(а такжеsignMessages, если только бэкенд действительно не подписывает сообщения) - Учитывайте
config?.abortSignal. После того как провайдер принял запрос на создание, сообщайте о любом сбое (включая прерывание) как оBROADCAST_UNCONFIRMED, указывая идентификатор транзакции провайдера, а причину прерывания сохраняйте вcause - Добавьте типизированную перегрузку
createKeychainSigner, возвращающую тип вашего отправляющего подписанта - Исключите свой бэкенд из
KeychainKitPluginConfigвtypescript/packages/kit-plugin/src/keychain-plugin.ts, поскольку отправляющий подписант не может выступать в ролиpayerилиidentityклиента Kit - Проверьте в модульных тестах, что для ваших экземпляров
isTransactionPartialSigner()возвращает false, аisTransactionSendingSigner()— true
Обновление основного пакета
Обновите typescript/packages/keychain/ (нужно изменить 7 файлов):
src/types.ts: добавьтеYourSignerConfigв размеченное объединениеKeychainSignerConfigsrc/create-keychain-signer.ts: добавьте ветку switch (каждый пакет бэкенда загружается через динамическийimport())src/resolve-address.ts: добавьте в ветку switch для быстрого пути или пути с запросомsrc/index.ts: добавьте экспорты типа конфигурации, пространства имён и фабричной функцииpackage.json: добавьте зависимость@solana/keychain-your-signer: "workspace:*"tsconfig.json: добавьте ссылку{ "path": "../your-signer" }typescript/scripts/test-treeshake-umbrella.mjs: добавьте свой пакет вSIGNER_MARKERS, а свою фабрику — вFACTORIES
Операторы switch содержат исчерпывающие проверки never, поэтому TypeScript выдаст ошибку,
если вы добавите вариант в объединение, но пропустите ветку. После этого запустите just ts-treeshake:
каждый пакет и зонтичный пакет должны оставаться пригодными для tree-shaking.
Подписант на Python
Создайте модуль в python/src/solana_keychain/your_signer/ (__init__.py
плюс signer.py). Ключевые паттерны:
- Наследуйтесь от
SolanaSignerизsolana_keychain.core(свойствоpubkey,async sign_message(),async is_available()) плюс ровно один класс возможностей:TransactionSigner(async sign_transaction()),ModifyingSigner(async modify_and_sign_transaction()) илиSendingSigner(async sign_and_send_transaction()) - Экспортируйте фабрику
async create_your_signer(), которая ожидаетinit(), если бэкенд определяет свой адрес удалённо; до инициализации подпись вызываетSignerErrorCode.NOT_INITIALIZED - Экспортируйте датакласс конфигурации (
YourSignerConfig), в котором секреты помеченыfield(repr=False) - Требуйте HTTPS в полях базового URL с помощью
assert_https_url()и направляйте каждый запрос черезfetch_signer_json() - Проверяйте подпись провайдера, прежде чем возвращать её; используйте
signed_message_bytes()для получения байтов, покрываемых подписью, и никогда не формируйте префикс версии вручную - Зарегистрируйте бэкенд в
_BACKENDSвpython/src/solana_keychain/keychain.py - Если бэкенду нужен SDK провайдера, защитите импорт, выбрасывайте
ImportErrorс указаниемpip install 'solana-keychain[your-signer]', объявите extra вpython/pyproject.tomlи никогда не импортируйте бэкенд сразу из корня пакета - Модульные тесты на pytest:
respxдля HTTP-бэкендов, заглушки клиентов для SDK-бэкендов и проверка того, что ни один секрет не попадает вstr()илиrepr()возникающих ошибок - Добавьте фабрику
make_your_signer()вpython/tests/integration/test_live_signers.pyи зарегистрируйте её в_MESSAGE_CAPABLE(или в_TRANSACTION_ONLYдля бэкендов безsign_message)
Go-подписант
Каждый Go-бэкенд — это отдельный модуль в go/signers/yoursigner/ с путём
модуля github.com/solana-foundation/solana-keychain/go/signers/yoursigner/v2.
Эталонный пример смотрите в go/signers/para/. Основные паттерны:
- Реализуйте
core.SolanaSigner(Pubkey(),SignMessage(ctx, message),IsAvailable(ctx)) и ровно один интерфейс возможностей:core.TransactionSigner,core.ModifyingSignerилиcore.SendingSigner. Возможность определяется приведением типа, поэтому бэкенд, который не может подписывать транзакцию, не должен иметь методаSignTransactionвообще - Экспортируйте структуру
Configи конструкторNew(New(ctx, cfg), если бэкенд получает свой адрес удалённо; до этого возвращайтеcore.NewNotInitializedError) - Создавайте HTTP-клиенты через
core.NewHTTPClientилиcore.ResolveHTTPClientи проверяйте базовые URL с помощьюcore.NormalizeHTTPSBaseURL - Подписывайте транзакции через
core.SignTransactionWith, который подписывает байты сообщения и прикрепляет подпись; проверяйте подписи провайдера с помощьюcore.VerifySignature - Возвращайте ошибки через
core.NewSignerError/core.WrapSignerErrorсо стабильными кодами, такими какcore.CodeSigningFailed - Модульные тесты на
httptest; интеграционные тесты вintegration_test.goза тегом//go:build integration
Контрольный список перед отправкой
Перед отправкой pull request:
- Код компилируется без предупреждений (
just build) - Все тесты проходят (
just test) - Код отформатирован и проходит проверку линтером (
just fmt) - В коде нет жёстко заданных значений или секретов
- Сообщения об ошибках являются общими (без необработанного текста ответов API)
- Никаких
.expect()и.unwrap()для непроверенных ответов API - Для удалённых HTTP-клиентов обеспечено использование HTTPS
- Тайм-ауты HTTP настроены через
HttpClientConfig - Возвращённые подписи проверяются по локально вычисленным байтам сообщения
- Соблюдены соглашения об именовании (snake_case для Rust и Python, camelCase для TypeScript)
- Добавлены интеграционные тесты и обновлён
.env.example - Включены реализации на TypeScript, Python и Go (либо их добавление согласовано с мейнтейнерами)
- Обновлены зонтичные пакеты (зонтичный пакет TypeScript, Python
_BACKENDS) - Включены изменения CI
- Добавлено в таблицы поддерживаемых бэкендов и возможностей подписантов в README.md
Рекомендации по реализации
Обработка ошибок
Всегда используйте существующие варианты SignerError. И Display, и Debug
у SignerError скрывают данные, поэтому делайте сообщения общими и никогда не
включайте в них необработанные данные ответа API. Никогда не используйте
.expect() или .unwrap() для непроверенных ответов API:
// Good: uses the constructor with a generic messagereturn Err(SignerError::remote_api(format!("YourService API returned status {status}")));// Good: converts from standard errorslet bytes = base64::decode(data).map_err(|e| SignerError::SerializationError(format!("Failed to decode: {e}")))?;
Требования безопасности
Это не рекомендации: потребители выбирают бэкенд, опираясь на них. Если ваш
провайдер делает какое-либо из требований невыполнимым, укажите это в PR и в
docs/SECURITY_MODEL.md репозитория.
- Проверяйте то, что подписал провайдер. Вычислите байты сообщения локально и проверьте по ним возвращённую подпись в позиции, которую требует ваш подписант, прежде чем прикреплять её. Подпись, не прошедшая проверку, должна приводить к ошибке и ни в коем случае не прикрепляться.
- Указывайте, рассылает ли провайдер транзакции. Если он выполняет отправку на стороне сервера, реализуйте форму отправки и возвращайте подпись, по которой определяется попавшая в сеть транзакция. Если у провайдера нет конечной точки только для подписи, сделайте бэкенд только отправляющим, а не отправляйте транзакцию за спиной вызывающей стороны.
- Никогда не оставляйте вызывающей стороне транзакцию, не покрытую подписью. Провайдер, который переписывает транзакцию, подписывает собственные байты: либо оставьте транзакцию вызывающей стороны нетронутой (случай, когда рассылкой управляет провайдер), либо используйте модифицирующую форму и полностью замените её байтами провайдера.
- Сообщайте о неопределённости рассылки как таковой. Сбой после отправки
всё равно мог привести к попаданию транзакции в сеть. Возвращайте
BROADCAST_UNCONFIRMED, а не общую ошибку, передавайте идентификатор транзакции провайдера, если он у вас есть, и отправляйте ключ идемпотентности, производный от сообщения, при каждом создании, чтобы побайтово идентичная повторная отправка была безопасной. - Никогда не подписывайте до инициализации. Используйте
Option<Pubkey>(а неPubkey::default()) для публичного ключа доinit()и возвращайте ошибку конфигурации, если подпись запрошена раньше. - Никогда не логируйте конфиденциальные данные (приватные ключи, секреты API,
необработанные ответы API) и используйте реализацию
Debug, скрывающую конфиденциальные поля - Требуйте HTTPS (
https_only(true), с условием#[cfg(not(test))]для wiremock) и настраивайте таймауты запроса и соединения черезHttpClientConfig(по умолчанию: 30 с / 5 с) - Обнуляйте промежуточный ключевой материал с помощью
zeroize::Zeroizing<Vec<u8>>
Тестирование с использованием моков
Используйте wiremock для имитации HTTP API. Проверяйте только тип ошибки, а не
текст сообщения об ошибке:
#[cfg(test)]mod tests {use wiremock::{MockServer, Mock, ResponseTemplate};#[tokio::test]async fn test_api_call() {let mock_server = MockServer::start().await;Mock::given(method("POST")).respond_with(ResponseTemplate::new(200)).mount(&mock_server).await;// Use mock_server.uri() as your api_base_url}}
Получение помощи
- Изучите существующие реализации подписантов для понимания паттернов:
src/memory/mod.rs: простой, синхронныйsrc/privy/mod.rs: требует инициализацииsrc/turnkey/mod.rs: сложная обработка подписейsrc/vault/mod.rs: внешняя клиентская библиотекаtypescript/packages/para/,python/src/solana_keychain/para/иgo/signers/para/: полные эталонные реализации на других языках
- Ключевые файлы:
src/traits.rs(определения трейтов),src/transaction_util.rsиsrc/signature_util.rs(общие вспомогательные функции),src/http_client_config.rs(конфигурация таймаутов) - Полное руководство по репозиторию: docs/ADDING_SIGNERS.md
- Откройте issue для обсуждения дизайна перед началом работы
Пример структуры PR
feat(signer): add YourService signer integrationAdds support for YourService as a signing backend.- [X] Code compiles without warnings (`just build`)- [X] Code is formatted/linting passes (`just fmt`)- [X] Add comprehensive tests with wiremock - All tests pass (`just test`)- [X] Implemented SolanaSigner and TransactionSigner for YourServiceSigner- [X] Added feature flag 'your_service'- [X] HTTPS enforced, HTTP timeouts configured- [X] Returned signatures verified before attaching- [X] Added integration tests and updated .env.example- [X] TypeScript, Python and Go implementations included- [X] CI Phase 2 changes included- [X] Added to README.md supported backends tableCloses #1337
Is this page helpful?