Flexible, framework-agnostic Solana transaction signing for Python applications
solana-keychain provides a unified interface for signing Solana transactions
with multiple backend implementations. Whether you need local keypairs for
development, enterprise vault integration, or managed wallet services, this
library offers a consistent API across all signing methods.
- Unified interface: a single
SolanaSignercontract for every backend - Async-first:
sign_transaction/sign_message/is_availableare coroutines - Verified wire format: golden-vector tests pin the exact serialized transaction bytes, so serialization can never silently drift
- Safe errors:
SignerErrorredacts sensitive detail from its message; match on its stablecodevalues - Minimal core: built on
soldersfor canonical transaction serialization and Ed25519 primitives
| Backend | Use Case | Module | Status |
|---|---|---|---|
| Memory | Local keypairs, development, testing | solana_keychain.memory |
✅ Available |
| Vault | Enterprise key management with HashiCorp Vault | solana_keychain.vault |
✅ Available |
| Privy | Embedded wallets with Privy infrastructure | solana_keychain.privy |
✅ Available |
| Turnkey | Non-custodial key management via Turnkey | solana_keychain.turnkey |
✅ Available |
| AWS KMS | AWS Key Management Service with Ed25519 signing | solana_keychain.aws_kms |
✅ Available |
| Fireblocks | Fireblocks institutional custody platform | solana_keychain.fireblocks |
✅ Available |
| Fordefi | Fordefi institutional MPC custody platform | solana_keychain.fordefi |
✅ Available |
| GCP KMS | Google Cloud Key Management Service with Ed25519 signing | solana_keychain.gcp_kms |
✅ Available |
| Dfns | Dfns wallet infrastructure with Ed25519 signing | solana_keychain.dfns |
✅ Available |
| Para | MPC wallets with Para infrastructure | solana_keychain.para |
✅ Available |
| CDP | Coinbase Developer Platform managed wallets | solana_keychain.cdp |
✅ Available |
| Crossmint | Crossmint managed wallets | solana_keychain.crossmint |
✅ Available |
| Openfort | Openfort backend wallets with TEE-stored keys | solana_keychain.openfort |
✅ Available |
| Utila | Utila MPC wallet integration | solana_keychain.utila |
✅ Available |
pip install solana-keychain # memory + vault
pip install 'solana-keychain[aws-kms]' # adds the AWS KMS backend
pip install 'solana-keychain[cdp]' # adds the CDP backend
pip install 'solana-keychain[crossmint]' # adds the Crossmint backend
pip install 'solana-keychain[dfns]' # adds the Dfns backend
pip install 'solana-keychain[fireblocks]' # adds the Fireblocks backend
pip install 'solana-keychain[fordefi]' # adds the Fordefi backend
pip install 'solana-keychain[gcp-kms]' # adds the GCP KMS backend
pip install 'solana-keychain[openfort]' # adds the Openfort backend
pip install 'solana-keychain[privy]' # adds the Privy backend
pip install 'solana-keychain[turnkey]' # adds the Turnkey backend
pip install 'solana-keychain[utila]' # adds the Utila backendRequires Python 3.10+. Backends built on heavy provider SDKs ship as optional
extras; importing such a backend without its extra raises an ImportError naming
the extra to install. Extras-gated backends are imported from their submodule
(e.g. from solana_keychain.aws_kms import create_aws_kms_signer), not from the
package root.
import asyncio
from solana_keychain import MemorySigner
async def main() -> None:
# Build a signer from a base58 key, a "[1,2,...]" byte array, raw bytes,
# or a Solana CLI keypair file.
signer = MemorySigner.from_private_key_file("/path/to/keypair.json")
print("address:", signer.pubkey)
# Sign an arbitrary message.
signature = await signer.sign_message(b"Hello Solana!")
print("signature:", signature)
# Sign a transaction (tx is a solders.transaction.VersionedTransaction):
# result = await signer.sign_transaction(tx)
# result.encoded_transaction # base64 wire transaction
# result.signature # this signer's signature
# result.is_complete # are all required signatures present?
# result.transaction # the authoritative signed transaction
asyncio.run(main())Every remote backend follows the same pattern: a config dataclass and an async
create_<backend>_signer factory that returns a ready-to-use signer:
from solana_keychain import VaultSignerConfig, create_vault_signer
signer = await create_vault_signer(
VaultSignerConfig(
api_base_url="https://vault.example.com",
token=os.environ["VAULT_TOKEN"],
key_name="my-solana-key",
public_key="4BuiY9QUUfPoAGNJBja3JapAuVWMc9c7in6UCgyC2zPR",
)
)Remote HTTP backends accept an optional http_client override in their config
(an httpx.AsyncClient, for custom TLS or proxies); when unset, requests go
through an HTTPS-enforcing one-shot client with a 60s timeout and redirects
rejected.
Every backend subclasses the SolanaSigner ABC from solana_keychain.core,
plus exactly the capability class matching its provider's shape:
class SolanaSigner(ABC):
@property
def pubkey(self) -> Pubkey: ...
async def sign_message(self, message: bytes) -> Signature: ...
async def is_available(self) -> bool: ...
class TransactionSigner(SolanaSigner):
"""Signs the caller's transaction as given; the caller broadcasts the result."""
async def sign_transaction(self, transaction: VersionedTransaction) -> SignedTransaction: ...
class ModifyingSigner(SolanaSigner):
"""The provider rewrites the transaction before signing it; continue from
`SignedTransaction.transaction`, never from the bytes submitted."""
async def modify_and_sign_transaction(
self, transaction: VersionedTransaction
) -> SignedTransaction: ...
class SendingSigner(SolanaSigner):
"""The provider signs and broadcasts server-side; the caller's transaction is
never mutated, and the returned signature identifies what landed."""
async def sign_and_send_transaction(self, transaction: VersionedTransaction) -> Signature: ...Both signing entry points return a
SignedTransaction(encoded_transaction, signature, is_complete, transaction);
is_complete reports whether every required signature is present. A
TransactionSigner signs the transaction in place and hands it back as
transaction; a ModifyingSigner leaves the caller's object untouched, because
solders messages are read-only, and hands back the provider's rewritten
transaction instead. Only transaction is guaranteed to match
encoded_transaction and the bytes signature covers. Legacy, v0 and v1
transactions are all accepted.
Errors are always SignerError with a stable code
(SIGNER_INVALID_PRIVATE_KEY, SIGNER_SIGNING_FAILED, …).
str()/repr() of a SignerError never include key material or raw remote responses.
The capability class a backend subclasses says whether the provider broadcasts; whether it can sign arbitrary bytes is fixed per backend:
| Backend | Capability class | sign_message |
|---|---|---|
| memory, vault, privy, turnkey, aws-kms, fireblocks, gcp-kms, dfns, para, openfort | TransactionSigner |
yes |
| cdp | TransactionSigner |
UTF-8 payloads only, otherwise SERIALIZATION_ERROR |
| utila | TransactionSigner |
SIGNING_FAILED |
| crossmint | SendingSigner |
SIGNING_FAILED |
fordefi black box (FordefiBlackBoxSigner) |
TransactionSigner |
yes |
fordefi native auto (FordefiNativeAutoSigner) |
SendingSigner |
yes |
fordefi native manual (FordefiNativeManualSigner) |
ModifyingSigner |
yes |
Crossmint executes every approved transaction server-side and exposes no
sign-only API, so it is a SendingSigner only. It may rewrite the
transaction to sponsor gas, in which case the returned signature identifies the
transaction it landed rather than covering the caller's bytes; the caller's
transaction is never modified.
create_fordefi_signer picks the Fordefi type from config.chain and
config.push_mode, and each type rejects a config meant for another:
| Config | Signer | Entry point |
|---|---|---|
no chain |
FordefiBlackBoxSigner |
sign_transaction |
chain, push_mode unset or "auto" |
FordefiNativeAutoSigner |
sign_and_send_transaction |
chain, push_mode="manual" |
FordefiNativeManualSigner |
modify_and_sign_transaction |
Black-box mode signs the caller's exact message bytes and leaves broadcasting to the caller. Native auto lets Fordefi update the blockhash and fees, then sign and broadcast; the caller's transaction is left untouched and the returned signature identifies what landed.
Native manual lets Fordefi rewrite the recent blockhash and the Compute Budget
fee instructions, then sign without broadcasting, so the caller broadcasts.
The returned signature covers Fordefi's bytes, not the ones submitted, and the
rewrite is not diffed against them: Fordefi is trusted for the rewrite. Inspect
result.transaction before broadcasting it. Fordefi must be the fee payer, so a
transaction that is not vault-paid is rejected before submitting, and it must
sign before every downstream signer: a transaction that already carries
signatures is accepted, but the rewrite voids them and the returned transaction
carries only Fordefi's.
The signature is ed25519-verified against the returned transaction's own message
at the vault's required-signer position; a signature that does not verify, or a
returned transaction the vault does not sign, fails with SIGNER_SIGNING_FAILED
and leaves the caller's transaction untouched.
import os
from solana_keychain.fordefi import FordefiSignerConfig, create_fordefi_signer
signer = await create_fordefi_signer(
FordefiSignerConfig(
access_token=os.environ["FORDEFI_ACCESS_TOKEN"],
vault_id=os.environ["FORDEFI_VAULT_ID"],
public_key=os.environ["FORDEFI_PUBLIC_KEY"],
private_key_pem=os.environ["FORDEFI_PRIVATE_KEY_PEM"],
chain="solana_mainnet",
push_mode="manual",
)
)
result = await signer.modify_and_sign_transaction(transaction)
if result.is_complete:
# Broadcast result.encoded_transaction through your RPC client.
pass
else:
# Sign result.transaction with the downstream signers, reserialize, broadcast.
passBound what Fordefi may spend through config.fee, for example
{"type": "custom", "priority_fee": "1000"}; that request is what Fordefi
honours, and the returned fee instructions are not checked against it locally.
Fordefi normally refreshes the blockhash but does not return its exact
lastValidBlockHeight, so broadcast manual results promptly rather than relying
on a locally known block-height expiry.
sign_and_send_transaction gets a transaction on chain with one call. A
SendingSigner (Crossmint, Fordefi native auto) broadcasts through its provider
and the send function is never called; a TransactionSigner or ModifyingSigner
signs and the send function broadcasts the base64-encoded result:
from solana_keychain import sign_and_send_transaction
signature = await sign_and_send_transaction(signer, transaction, rpc_send)From the repo root (recipes bootstrap python/.venv automatically):
just py-test # unit tests
just py-fmt # ruff format + lint + mypy
just py-build # sdist + wheelOr manually:
cd python
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
.venv/bin/pytestGolden wire-format vectors are pinned in tests/test_parity.py — the exact
serialized bytes for one canonical transaction. Never regenerate them to make
the suite pass; a mismatch means the library's output has drifted from the
Solana wire format.