새로운 서명자 추가하기

이 가이드는 solana-keychain 라이브러리에 새로운 키 관리 솔루션을 통합하고자 하는 지갑 서비스 제공자와 개발자를 위한 것입니다. 귀하의 서명자 구현을 추가함으로써, 개발자들이 통합된 인터페이스를 통해 안전한 솔라나 트랜잭션 서명을 위해 귀하의 서비스를 사용할 수 있게 됩니다.

Keychain은 Rust, TypeScript, Python, Go 구현체의 기능을 동등하게 유지합니다. 네 가지를 모두 포함한 PR을 강력히 권장합니다. 하나만 기여하셔도 괜찮지만, 서명자가 릴리스에 포함되기 전에 나머지도 필요합니다.

Claude Code를 사용 중이신가요? 저장소의 add-signer-ci 스킬이 포크 PR로 기여된 새 서명자의 CI 워크플로를 설정해 줍니다.

아키텍처 개요

이 라이브러리는 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에 기능 플래그를 추가합니다. 서명자가 reqwest를 사용한다면 내부 _remote 마커도 포함합니다
  • src/lib.rs의 Signer 열거형을 업데이트합니다(배리언트, dispatch_signer! arm, 그리고 서명자의 형태에 맞는 기능 접근자의 arm)
  • HTTP 클라이언트에 HTTPS 강제 적용 및 타임아웃 설정
  • 메시지 바이트에 서명하고, 첨부하기 전에 반환된 서명을 검증합니다
  • 포괄적인 단위 테스트를 추가합니다(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 integration
use 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 API
async 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 text
if !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단계: 서명자 트레이트 구현

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 마커를 포함하며, 이 마커는 src/error.rs의 From<reqwest::Error> 구현을 제어합니다. 마커가 없으면 해당 기능만 활성화했을 때 reqwest 호출의 ?가 컴파일되지 않습니다.

[features]
default = ["memory", "sdk-v2"]
memory = []
vault = ["dep:reqwest", "_remote"]
privy = ["dep:reqwest", "dep:p256", "_remote"]
your_service = ["dep:reqwest", "_remote"] # Add your feature
all = ["memory", "vault", "privy", "...", "your_service"] # Update all

7단계: 서명자 열거형 업데이트

src/lib.rs에 서명자를 추가하세요. 기본 SolanaSigner 구현은 dispatch_signer! 매크로를 통해 디스패치하므로 그곳에 arm을 하나 추가합니다. 기능 접근은 as_transaction_signer(), as_modifying_signer(), as_sending_signer() 접근자에 있습니다. 사용하는 기능 트레이트에 맞는 접근자에 Some(s) arm을 추가하세요(전송 또는 수정 백엔드의 경우 as_transaction_signer()에도 None arm을 추가).

// 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 method
impl 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_available
macro_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 shape
impl 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를 생성하세요. 통합 테스트는 (wiremock이 아닌) 실제 서비스 API에 대해 실행되며 integration-tests 기능 뒤에 게이트됩니다. 각 파일에는 다음이 필요합니다:

  • 환경 변수 이름에 대한 pub const 선언
  • dotenvy를 통해 환경 변수를 읽는 get_signer() 헬퍼
  • 세 개의 테스트 함수: 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-key
YOUR_SERVICE_WALLET_ID=your-wallet-id
# YOUR_SERVICE_API_BASE_URL=https://api.yourservice.com/v1 # Optional, defaults to this

필수 변수는 플레이스홀더 값과 함께 먼저 나열하고, 선택 변수는 기본값과 함께 주석 처리하세요. 통합 테스트에 사용되는 모든 환경 변수는 .env.example에 있어야 하며, TypeScript, Python, Go 테스트 모음도 동일한 이름을 사용합니다.

11단계: 문서 업데이트

README.md의 지원되는 백엔드 표에 서명자를 추가하세요:

백엔드사용 사례기능 플래그
Memory로컬 keypair, 개발, 테스팅memory
VaultHashiCorp Vault를 사용한 엔터프라이즈 키 관리vault
PrivyPrivy 인프라를 사용한 임베디드 지갑privy
TurnkeyTurnkey를 통한 비수탁형 키 관리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(())
}

통합 테스트

원시 cargo 대신 just 레시피를 사용하세요. SDK 기능(sdk-v2, sdk-v3, sdk-v4)은 상호 배타적이므로 cargo test --all-features는 실패합니다.

# Unit tests across the sdk-v2, sdk-v3 and sdk-v4 matrices
just rust-test
# Live integration tests (require credentials in .env)
just rust-test-integration

CI 워크플로 업데이트(포크 PR)

CI는 2단계 프로세스입니다. PR을 테스트하기 전에 메인테이너와 조율하여 main을 준비하세요. 메인테이너가 fork-external-live-manual.yml을 업데이트하고, ci.yml에 환경 변수를 추가하며, GitHub Secrets를 구성합니다.

PR에는 서명자 코드와 함께 다음 2단계 변경 사항이 포함되어야 합니다:

  • ci.yml: backend 매트릭스(all 앞)에 Rust 기능을 추가하고, test 매트릭스에 통합 테스트 함수를 추가합니다
  • typescript-ci.yml: 단위 및 통합 테스트 매트릭스에 패키지를 추가하고, 통합 테스트 단계에 환경 변수를 추가합니다
  • typescript-publish.yml: PUBLISH_PACKAGES, GitHub Release packages 배열, 요약 표에 패키지를 추가합니다
  • python-ci.yml: resolve-signers의 CI_SIGNER_* 게이팅과 통합 매트릭스에 백엔드를 추가합니다
  • go-ci.yml: CI_SIGNER_* 게이팅과 moduleBySigner에 백엔드를 추가합니다

TypeScript 서명자

TypeScript 서명자 패키지도 추가하는 경우, typescript/packages/your-signer/에 생성하세요. 주요 패턴:

  • 팩토리 함수 createYourSigner()는 SolanaTransactionSigner<TAddress> & SolanaMessageSigner<TAddress>를 반환합니다. 서명자 클래스는 내부용으로 유지하고 팩토리와 해당 설정 타입만 export하세요
  • 설정 인터페이스 내보내기 (YourSignerConfig)
  • 백엔드가 메시지에 서명할 수 없다면 SolanaMessageSigner를 생략하고 signMessages 메서드를 아예 노출하지 마세요. Kit은 메서드 존재 여부로 서명자를 분류하므로, 예외를 던지는 스텁은 서명자를 잘못 분류하게 됩니다
  • @solana/keychain-core의 assertHttpsUrl()로 apiBaseUrl 설정 필드에 HTTPS를 강제하세요
  • @solana/keychain-core의 fetchSignerJson()을 통해 원격 호출을 수행하세요. 이 함수가 오류 매핑, 응답 정제, 리디렉션 거부, 기본 타임아웃을 담당합니다
  • 그 외 원격 API 오류 텍스트는 @solana/keychain-core의 sanitizeRemoteErrorResponse()로 정제하세요
  • @solana/keychain-core의 throwSignerError(SignerErrorCode.*, { cause, message }) 사용
  • 모든 서명 메서드에서 config?.abortSignal을 준수하고, 이를 fetchSignerJson()과 signBatchStaggered()에 전달하세요
  • validateRequestDelayMs()로 requestDelayMs를 검증하고 signBatchStaggered()로 일괄 처리하세요
  • 오류 코드 목록을 포함한 @throws JSDoc을 팩토리 함수에 추가
  • @solana/* 패키지를 >=8.1.0 피어 의존성으로 선언하세요
  • vitest와 모킹된 fetch를 사용한 단위 테스트, @solana/keychain-test-utils의 runSignerIntegrationTest를 사용한 통합 테스트

전송 서명자

프로바이더가 트랜잭션을 재작성하거나 서버 측에서 브로드캐스트한다면 그 서명을 호출자의 트랜잭션에 적용할 수 없으므로, SolanaTransactionSigner 대신 SolanaSendingSigner여야 합니다:

  • signAndSendTransactions()를 구현하고 signTransactions는 노출하지 마세요 (백엔드가 실제로 메시지에 서명하는 경우가 아니라면 signMessages도 마찬가지)
  • config?.abortSignal을 준수하세요. 프로바이더가 생성 요청을 수락한 이후에는 (중단을 포함한) 모든 실패를 프로바이더 트랜잭션 ID를 담은 BROADCAST_UNCONFIRMED로 보고하고, 중단 사유는 cause로 유지하세요
  • 전송 서명자 타입을 반환하는 타입이 지정된 createKeychainSigner 오버로드를 추가하세요
  • typescript/packages/kit-plugin/src/keychain-plugin.ts의 KeychainKitPluginConfig에서 백엔드를 제외하세요. 전송 서명자는 Kit 클라이언트의 payer나 identity로 사용할 수 없기 때문입니다
  • 단위 테스트에서 인스턴스에 대해 isTransactionPartialSigner()는 false이고 isTransactionSendingSigner()는 true임을 검증하세요

통합 패키지 업데이트

typescript/packages/keychain/을 업데이트하세요(수정할 파일 7개):

  1. src/types.ts: KeychainSignerConfig 판별 유니온에 YourSignerConfig를 추가합니다
  2. src/create-keychain-signer.ts: switch case를 추가합니다(각 백엔드 패키지는 동적 import()로 로드됩니다)
  3. src/resolve-address.ts: fast-path 또는 fetch-path switch case에 추가합니다
  4. src/index.ts: 설정 타입, 네임스페이스, 팩토리 함수 export를 추가합니다
  5. package.json: @solana/keychain-your-signer: "workspace:*" 의존성을 추가합니다
  6. tsconfig.json: { "path": "../your-signer" } 참조를 추가합니다
  7. typescript/scripts/test-treeshake-umbrella.mjs: SIGNER_MARKERS에 패키지를, FACTORIES에 팩토리를 추가합니다

switch 문에는 완전성을 확인하는 never 검사가 있어, 유니온에 추가하고 case를 빠뜨리면 TypeScript가 오류를 냅니다. 이후 just ts-treeshake를 실행하세요. 모든 패키지와 umbrella 패키지는 트리 셰이킹이 가능한 상태를 유지해야 합니다.

Python 서명자

python/src/solana_keychain/your_signer/에 모듈을 생성하세요(__init__.py와 signer.py). 주요 패턴:

  • solana_keychain.core의 SolanaSigner를 상속하고(pubkey 프로퍼티, async sign_message(), async is_available()) 기능 클래스를 정확히 하나 함께 상속합니다: TransactionSigner(async sign_transaction()), ModifyingSigner (async modify_and_sign_transaction()) 또는 SendingSigner (async sign_and_send_transaction())
  • 백엔드가 주소를 원격으로 확인하는 경우 init()을 await하는 async create_your_signer() 팩토리를 export하세요. init 전에 서명하면 SignerErrorCode.NOT_INITIALIZED가 발생합니다
  • 비밀 값을 field(repr=False)로 표시한 설정 데이터클래스 (YourSignerConfig)를 export하세요
  • assert_https_url()로 기본 URL 필드에 HTTPS를 강제하고, 모든 요청을 fetch_signer_json()을 통해 보내세요
  • 프로바이더의 서명을 반환하기 전에 검증하세요. 서명이 포함하는 바이트에는 직접 만든 버전 접두사 대신 signed_message_bytes()를 사용하세요
  • python/src/solana_keychain/keychain.py의 _BACKENDS에 백엔드를 등록하세요
  • 백엔드에 공급자 SDK가 필요하다면 import를 보호하고, pip install 'solana-keychain[your-signer]'를 명시한 ImportError를 발생시키며, python/pyproject.toml에 extra를 선언하세요. 또한 패키지 루트에서 백엔드를 즉시 import해서는 안 됩니다
  • pytest를 사용한 단위 테스트: HTTP 백엔드에는 respx, SDK 백엔드에는 스텁 클라이언트를 사용하고, 발생한 오류의 str() 또는 repr()에 시크릿이 나타나지 않는지 확인하는 검사를 포함합니다
  • python/tests/integration/test_live_signers.py에 make_your_signer() 팩토리를 추가하고 _MESSAGE_CAPABLE에 등록하세요(sign_message가 없는 백엔드는 _TRANSACTION_ONLY에 등록)

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. 기능은 타입 단언(type assertion)으로 감지되므로, 트랜잭션에 서명할 수 없는 백엔드에는 SignTransaction 메서드가 전혀 없어야 합니다
  • Config 구조체와 New 생성자를 export하세요(백엔드가 주소를 원격으로 확인하는 경우 New(ctx, cfg)를 사용하며, 그 전에는 core.NewNotInitializedError를 반환)
  • HTTP 클라이언트는 core.NewHTTPClient 또는 core.ResolveHTTPClient로 빌드하고, 기본 URL은 core.NormalizeHTTPSBaseURL로 검증하세요
  • 트랜잭션 서명은 core.SignTransactionWith를 사용하세요. 이 함수는 메시지 바이트에 서명한 뒤 서명을 첨부합니다. 공급자의 서명은 core.VerifySignature로 검증하세요
  • 오류는 core.NewSignerError / core.WrapSignerError와 core.CodeSigningFailed 같은 안정적인 코드로 반환하세요
  • 단위 테스트는 httptest로 작성하고, 통합 테스트는 //go:build integration 태그 뒤에 두어 integration_test.go에 작성하세요

제출 체크리스트

PR을 제출하기 전:

  • 코드가 경고 없이 컴파일됨 (just build)
  • 모든 테스트 통과 (just test)
  • 코드 포맷팅/린팅 통과 (just fmt)
  • 코드에 하드코딩된 값이나 시크릿 없음
  • 오류 메시지는 일반적으로 작성 (원시 API 응답 텍스트 없음)
  • 신뢰할 수 없는 API 응답에 .expect() 또는 .unwrap()을 사용하지 않음
  • 원격 HTTP 클라이언트에서 HTTPS 강제 적용
  • HttpClientConfig를 통한 HTTP 타임아웃 설정
  • 반환된 서명을 로컬에서 계산한 메시지 바이트로 검증함
  • 명명 규칙 준수(Rust와 Python은 snake_case, TypeScript는 camelCase)
  • 통합 테스트 추가 및 .env.example 업데이트
  • TypeScript, Python, Go 구현 포함(또는 메인테이너와 계획 중)
  • 엄브렐라 패키지 업데이트(TypeScript 엄브렐라, Python _BACKENDS)
  • CI 변경 사항 포함
  • README.md의 지원 백엔드 표와 서명자 기능 표에 추가

구현 팁

오류 처리

항상 기존 SignerError 변형을 사용하세요. SignerError의 Display와 Debug는 모두 민감 정보가 가려지므로, 메시지는 일반적인 내용으로 유지하고 원시 API 응답 데이터는 절대 포함하지 마세요. 신뢰할 수 없는 API 응답에는 .expect() 또는 .unwrap()을 절대 사용하지 마세요:

// Good: uses the constructor with a generic message
return Err(SignerError::remote_api(format!(
"YourService API returned status {status}"
)));
// Good: converts from standard errors
let bytes = base64::decode(data)
.map_err(|e| SignerError::SerializationError(format!("Failed to decode: {e}")))?;

보안 요구 사항

이는 권장 사항이 아닙니다. 사용자는 이 요건을 근거로 백엔드를 선택합니다. 공급자 때문에 요건 중 하나를 충족할 수 없다면, PR과 저장소의 docs/SECURITY_MODEL.md에 그 사실을 명시하세요.

  • 공급자가 서명한 내용을 검증하세요. 메시지 바이트를 로컬에서 계산하고, 반환된 서명을 첨부하기 전에 서명자가 요구하는 위치에서 해당 바이트에 대해 검증하세요. 검증에 실패한 서명은 반드시 실패로 처리해야 하며, 절대 첨부해서는 안 됩니다.
  • 공급자가 브로드캐스트하는지 여부를 명시하세요. 공급자가 서버 측에서 실행한다면 전송(sending) 형태로 구현하고, 랜딩된 트랜잭션을 식별하는 서명을 반환하세요. 공급자에 서명 전용 엔드포인트가 없다면, 호출자 모르게 제출하지 말고 백엔드를 전송 전용으로 만드세요.
  • 서명이 포괄하지 않는 트랜잭션을 호출자가 보유하게 두지 마세요. 트랜잭션을 재작성하는 공급자는 자신의 바이트에 서명합니다. 호출자의 트랜잭션을 그대로 두거나 (브로드캐스트 관리형 경우), 수정(modifying) 형태를 택해 공급자의 바이트로 전체를 교체하세요.
  • 브로드캐스트의 불확실성은 있는 그대로 보고하세요. 제출 후 발생한 실패도 실제로는 랜딩되었을 수 있습니다. 일반적인 오류 대신 BROADCAST_UNCONFIRMED를 노출하고, 공급자의 트랜잭션 ID가 있다면 항상 함께 전달하며, 바이트가 동일한 재전송이 안전하도록 모든 생성 요청에 메시지에서 파생한 멱등성 키를 보내세요.
  • 초기화 전에는 절대 서명하지 마세요. init() 전의 공개 키에는 Pubkey::default()가 아닌 Option<Pubkey>를 사용하고, 먼저 서명을 시도하면 구성(config) 오류로 실패 처리하세요.
  • 민감한 데이터(개인 키, API 시크릿, 원시 API 응답)는 절대 로깅하지 말고, 민감한 필드를 숨기는 Debug 구현을 사용하세요
  • HTTPS를 강제하고(https_only(true), wiremock을 위해 #[cfg(not(test))]로 제어), HttpClientConfig를 통해 요청 및 연결 타임아웃을 설정하세요(기본값: 30초/5초)
  • 중간 키 자료는 zeroize::Zeroizing<Vec<u8>>로 제로화(zeroize)하세요

모의 객체를 사용한 테스트

HTTP API 모킹에는 wiremock를 사용하세요. 오류 메시지 텍스트가 아닌 오류 유형에 대해서만 검증하세요:

#[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
  • 작업을 시작하기 전에 설계 논의를 위한 이슈를 생성하세요

PR 구조 예시

feat(signer): add YourService signer integration
Adds 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 table
Closes #1337

Is this page helpful?