A Rust crate for generating output scripts, parsing/encoding addresses, and building/signing transactions across multiple cryptocurrency networks.
This is a Rust port of the Go library
github.com/KarpelesLab/outscript.
All cryptography is provided by the pure-Rust
purecrypto crate.
| Network | Address Formats | Transactions |
|---|---|---|
| Bitcoin | p2pkh, p2pk, p2wpkh, p2sh:p2wpkh, p2wsh, p2tr | BtcTx |
| Bitcoin Cash | p2pkh, p2pk (CashAddr) | BtcTx |
| Litecoin | p2pkh, p2pk, p2wpkh, p2sh:p2wpkh | BtcTx |
| Dogecoin | p2pkh, p2pk | BtcTx |
| Namecoin | p2pkh, p2sh | BtcTx |
| Monacoin | p2pkh, p2sh, p2wpkh | BtcTx |
| Dash | p2pkh, p2sh | BtcTx |
| Electraproto | p2pkh, p2sh, p2wpkh | BtcTx |
| EVM (Ethereum, etc.) | EIP-55 checksummed | EvmTx |
| Massa | AU (user) / AS (smart contract) | - |
| Solana | Base58 (32 bytes) | SolanaTx |
| Cardano | Shelley bech32 (addr / addr_test / stake) | CardanoTx |
| Zcash (transparent) | t1 / t3 base58check (tm / t2 on testnet) | zcashtx::ZcashTx (v5, ZIP-244) |
use outscript::Script;
use outscript::crypto::secp256k1::SecpPrivateKey;
use outscript::crypto::ed25519;
use outscript::PubKey;
// Bitcoin / EVM (secp256k1)
let key = SecpPrivateKey::from_bytes(&seed).unwrap();
let s = Script::new(key.public_key());
let addr = s.address("p2wpkh", &["bitcoin"]).unwrap(); // bc1q...
let eth = s.address("eth", &[]).unwrap(); // 0x...
// Solana / Massa (ed25519)
let pk = ed25519::public_from_seed(&seed);
let s = Script::new(PubKey::Ed25519(pk));
let sol = s.address("solana", &["solana"]).unwrap(); // base58
// Cardano (ed25519). "cardano" yields a Shelley enterprise address (payment
// credential only); pass "cardano-testnet" for the testnet form.
let addr = s.address("cardano", &[]).unwrap(); // addr1...
let test = s.address("cardano", &["cardano-testnet"]).unwrap(); // addr_test1...
// Base (payment+stake) and reward addresses need two key hashes:
use outscript::{cardano_base_address, cardano_reward_address, cardano_key_hash};
let ph = cardano_key_hash(&payment_pub);
let sh = cardano_key_hash(&stake_pub);
let base = cardano_base_address(&ph, &sh, "cardano").unwrap(); // addr1...
let reward = cardano_reward_address(&sh, "cardano").unwrap(); // stake1...use outscript::{parse_bitcoin_based_address, parse_evm_address, parse_solana_address, parse_massa_address};
let out = parse_bitcoin_based_address("auto", "1A1zP1...").unwrap(); // auto-detect
let out = parse_evm_address("0x2AeB8ADD...").unwrap();
let out = parse_solana_address("83astBRgu...").unwrap();
let out = parse_massa_address("AU16f3K8u...").unwrap();
// Cardano (addr / addr_test / stake / stake_test)
use outscript::parse_cardano_address;
let out = parse_cardano_address("addr1vx2fxv2umyhttkxyxp8...").unwrap();
let raw = out.bytes(); // raw address bytes (header + credentials), for a tx outputuse outscript::{BtcTx, BtcTxSign};
let mut tx = BtcTx::from_bytes(&raw).unwrap();
tx.sign(&[
BtcTxSign::new(&key0, "p2pk"),
BtcTxSign::new(&key1, "p2wpkh").amount(600_000_000),
]).unwrap();
let bytes = tx.bytes();
// P2TR (BIP-341 key-path, SIGHASH_DEFAULT) — PrevScript is required.
tx.sign(&[BtcTxSign::new(&key, "p2tr").amount(100_000).prev_script(prev_spk)]).unwrap();Taproot supports both raw SecpPrivateKey signing (the library applies the
BIP-341 tweak) and external signers implementing the [Signer::sign_taproot]
method (TSS / MuSig2 / FROST / HSM). Use [crypto::secp256k1::taproot_tweak]
and [BtcTx::taproot_sighash] to compute the tweaked key and sighash offline.
Every BIP-341 sighash type is supported (.sighash(0x83) for
SINGLE|ANYONECANPAY, and so on).
Setting bit 0x20 (btcraw::SIGHASH_UNIFIED) in an input's sighash signs it
under the unified signature hash that Bitcoin Knots specifies
(doc/unified-sighash.md).
It is one BIP-341-style message for every script type (bare/P2SH, segwit v0,
taproot key path and tapscript) and every hash type. It commits to every spent
output, so each entry needs amount and prev_script. It applies only on a
chain that activates it; elsewhere such signatures are invalid.
use outscript::btcraw::SIGHASH_UNIFIED;
let all = u32::from(SIGHASH_UNIFIED) | 0x01; // ALL|UNIFIED
tx.sign(&[
BtcTxSign::new(&key0, "p2wpkh").amount(50_000).prev_script(spk0).sighash(all),
BtcTxSign::new(&key1, "p2tr").amount(70_000).prev_script(spk1).sighash(all),
]).unwrap();A PSBT input whose PSBT_IN_SIGHASH_TYPE sets the bit is signed the same
way. RawTx::unified_sighash computes the digest directly, including the annex
and codeseparator position.
The taproot module builds script trees without allocating. A tree is a
slice of leaves in depth-first order with their depths, the same shape as the
BIP-371 tap tree:
use outscript::taproot::{
TapLeaf, MAX_CONTROL_BLOCK_LEN, control_block_to_slice, p2tr_script_pubkey, tap_tree_root,
};
// root
// / \
// leaf_a * two scripts at depth 2 under one branch,
// / \ one at depth 1
// leaf_b leaf_c
let leaves = [TapLeaf::new(1, &leaf_a), TapLeaf::new(2, &leaf_b), TapLeaf::new(2, &leaf_c)];
let root = tap_tree_root(&leaves).unwrap();
let script_pubkey = p2tr_script_pubkey(&internal_key, Some(&root)).unwrap();
let mut control_block = [0u8; MAX_CONTROL_BLOCK_LEN];
let n = control_block_to_slice(&internal_key, &leaves, 1, &mut control_block).unwrap();Spend such an output through the key path by giving the signer the root, or
through a <key> OP_CHECKSIG leaf with its control block:
// key path: the internal key, tweaked with the tree's merkle root
tx.sign(&[BtcTxSign::new(&internal, "p2tr").amount(amt).prev_script(spk.clone())
.tap_merkle_root(root)]).unwrap();
// script path: the leaf key signs untweaked; witness = [sig, script, control block]
tx.sign(&[BtcTxSign::new(&leaf_key, "p2tr").amount(amt).prev_script(spk)
.tap_leaf(leaf_b.to_vec(), control_block[..n].to_vec())]).unwrap();For other leaf scripts, compute the digest with RawTx::taproot_sighash (any
hash type, annex, code separator position), sign it with
SecpPrivateKey::sign_schnorr, and assemble the witness yourself.
Parse, update, sign (fully or partially), combine, finalize and extract
Partially Signed Bitcoin Transactions. Every operation works on borrowed bytes
and writes into a caller buffer, so it also runs without alloc; the
*_to_vec variants below need alloc.
use outscript::psbt::Psbt;
// creator: from an unsigned BtcTx (or a btcraw::RawTx with create_to_slice)
let psbt = tx.to_psbt().unwrap();
// updater: attach what signers need
let psbt = Psbt::parse(&psbt)?.set_witness_utxo_to_vec(0, 100_000, &prev_spk)?;
// signer: signs every input the key is involved in
let (psbt, signed) = Psbt::parse(&psbt)?.sign_to_vec(&key)?;
// combiner / finalizer / extractor
let psbt = Psbt::parse(&psbt)?.combine_to_vec(&Psbt::parse(&other_signers_psbt)?)?;
let (psbt, finalized) = Psbt::parse(&psbt)?.finalize_to_vec()?;
let raw_tx = Psbt::parse(&psbt)?.extract_tx_to_vec()?;
// base64
let text = Psbt::parse(&psbt)?.to_base64();
let bytes = Psbt::decode_base64(&text)?;Signing covers P2PKH, P2PK, multisig, P2WPKH, P2WSH, their P2SH-nested forms
and P2TR, through the PsbtSigner trait for external signers. The
implementation reproduces the BIP-174 test vectors byte for byte.
Taproot inputs are signed on the key path, including outputs that commit to a
script tree, and on the script path for every tapscript leaf the key appears
in, with any BIP-341 sighash type. The finalizer uses the key-path signature
when there is one, and otherwise spends through a <key> OP_CHECKSIG or
multi_a leaf that has enough signatures:
// updater: what a signer needs for an output that commits to a script tree
psbt.set_tap_internal_key(0, &internal_key, &mut out)?;
psbt.set_tap_merkle_root(0, &root, &mut out)?;
psbt.add_tap_leaf_script(0, &control_block[..n], &leaf_b, &mut out)?;use outscript::{EvmTx, EvmTxType, AbiValue};
use num_bigint::BigInt;
let mut tx = EvmTx {
tx_type: EvmTxType::Eip1559,
chain_id: 1,
nonce: 0,
gas_tip_cap: BigInt::from(1_000_000_000u64),
gas_fee_cap: BigInt::from(20_000_000_000u64),
gas: 21000,
to: "0x...".into(),
value: BigInt::from(10u64).pow(18),
..Default::default()
};
tx.call("transfer(address,uint256)", &[/* AbiValue... */]).unwrap();
tx.sign(&key).unwrap();
let data = tx.to_bytes().unwrap();
let sender = tx.sender_address().unwrap();use outscript::solana::{new_solana_tx, transfer_instruction, SolanaKey};
let ix = transfer_instruction(from, to, 1_000_000); // lamports
let mut tx = new_solana_tx(from, blockhash, &[ix]).unwrap();
tx.sign(&[seed]).unwrap();
let data = tx.to_bytes().unwrap();
let txid = tx.hash().unwrap(); // first signaturenew_solana_tx_v0 builds a v0 transaction with address lookup tables, and
new_solana_tx_v1 a v1 transaction (SIMD-0385, up to 4096 bytes): fee and
resource requests move from ComputeBudget instructions into a
SolanaTxConfig, and the signatures trail the message.
use outscript::solana::{new_solana_tx_v1, SolanaTxConfig};
let config = SolanaTxConfig::new() // unset = minimum (0 CU, 32 KiB heap, ...)
.with_priority_fee(5_000) // total lamports, not per compute unit
.with_compute_unit_limit(200_000);
let mut tx = new_solana_tx_v1(from, blockhash, config, &[ix]).unwrap();
tx.sign(&[seed]).unwrap();
let data = tx.to_bytes().unwrap(); // starts with 0x81SolanaTx::from_bytes recognizes all three formats.
Builds Shelley/Conway-era transactions: a CBOR-encoded body (inputs, outputs,
fee, optional TTL), ADA and native-asset outputs, and Ed25519 vkey witnesses.
The transaction id and signing digest are blake2b-256 of the transaction body.
use outscript::{CardanoTx, CardanoInput, CardanoOutput, parse_cardano_address};
let to = parse_cardano_address("addr1vx2fxv2umyhttkxyxp8...").unwrap();
let mut tx = CardanoTx {
inputs: vec![CardanoInput { txid: prev_txid /* 32 bytes */, index: 0 }],
outputs: vec![CardanoOutput {
address: to.bytes().to_vec(),
amount: 1_000_000, // lovelace
assets: vec![],
}],
fee: 170_000,
ttl: 41_000_000, // optional (slot); 0 omits it
witnesses: vec![],
};
// Sign with one or more 32-byte standard Ed25519 seeds (a vkey witness per seed)
tx.sign(&[seed]).unwrap();
let data = tx.to_bytes().unwrap(); // CBOR transaction
let txid = tx.hash().unwrap(); // blake2b-256 of the bodyCardano HD wallets (CIP-1852) use BIP32-Ed25519 extended keys, which store an
already-expanded 64-byte secret and cannot be used as a standard Ed25519 seed.
Sign with those (or any external/HSM signer) through the CardanoSigner trait:
use outscript::CardanoExtendedKey;
// secret is the 64-byte extended secret (e.g. the first 64 bytes of an xprv)
let ext = CardanoExtendedKey::new(&secret).unwrap();
tx.sign_with(&[&ext]).unwrap(); // standard Ed25519 signature, verifiable as usualDerive keys from BIP-39 entropy using the Icarus master-key scheme and the
CIP-1852 path m/1852'/1815'/account'/role/index:
use outscript::{cardano_icarus_master_key, cardano_harden as h, cardano_key_hash,
cardano_base_address};
let master = cardano_icarus_master_key(&entropy, &[]).unwrap(); // &[] = no passphrase
// payment key m/1852'/1815'/0'/0/0 and stake key m/1852'/1815'/0'/2/0
let spend = master.derive_path(&[h(1852), h(1815), h(0), 0, 0]).unwrap();
let stake = master.derive_path(&[h(1852), h(1815), h(0), 2, 0]).unwrap();
let ph = cardano_key_hash(&spend.public_key());
let sh = cardano_key_hash(&stake.public_key());
let addr = cardano_base_address(&ph, &sh, "cardano").unwrap(); // addr1...
// `spend` signs transactions directly via sign_with.
// Watch-only soft derivation (no private key) is available from an xpub:
let xpub = master.derive_path(&[h(1852), h(1815), h(0), 0]).unwrap()
.extended_public_key().unwrap();
let child = xpub.derive_child(0).unwrap();Native tokens are added via CardanoOutput.assets (CardanoAsset { policy_id, asset_name, amount }). Plutus scripts, certificates, staking actions and
metadata are out of scope.
Zcash's transparent side is Bitcoin-like: P2PKH outputs and ECDSA scriptSigs.
What differs is the v5 header and the ZIP-244 digests, personalized
BLAKE2b-256 trees rather than double SHA-256. zcashtx::ZcashTx borrows its
transparent inputs and outputs, and any Sapling or Orchard bundle as raw
bytes, so a transparent input can be signed inside a transaction built by a
shielded wallet too (the digests only hash the bundles' fields). Everything
runs without alloc.
use outscript::zcashtx::{ZcashTx, ZcashTxIn, ZcashTxOut, branch};
let inputs = [ZcashTxIn { txid: prev_txid, vout: 1, ..Default::default() }];
let outputs = [ZcashTxOut { amount: 90_000, script: &dest_script }];
let tx = ZcashTx {
consensus_branch_id: branch::NU6_2, // the upgrade in force when mined
expiry_height: current_height + 40,
inputs: &inputs,
outputs: &outputs,
..Default::default()
};
// what each input spends: its amount and scriptPubKey
let prevouts = [ZcashTxOut { amount: 100_000, script: &my_script }];
let signed = tx.sign(&[&key], &prevouts)?; // one key per input, SIGHASH_ALL
let txid = tx.txid()?;sighash gives any ZIP-244 signature digest (all hash types, with or without
ANYONECANPAY, and the shielded one), sign_input_to_slice one scriptSig,
and parse_into reads a v5 transaction back. Shielded spends and outputs are
not produced: that needs the Sapling/Orchard provers. Addresses go through
decode_zcash_address / parse_zcash_address, and encode_address_to_slice
with the zcash or zcash-testnet network.
Air-gapped signers take PSBTs in and out as QR codes, in one of two text encodings. Both are implemented down to the strings — what goes into the QR codes, or comes out of a scanner — and need no chain feature.
Uniform Resources (bcur, BCR-2020-005) carry CBOR as bytewords. Long
payloads become a fountain-coded stream of parts: the receiver needs no
particular one of them, only enough of them.
use outscript::bcur::{self, Decoder, Encoder};
// a PSBT travels as a CBOR byte string, in fragments of at most 200 bytes
let cbor = bcur::bytes_to_cbor(&psbt);
let mut encoder = Encoder::new(bcur::TYPE_CRYPTO_PSBT, &cbor, 200)?;
// "ur:crypto-psbt/1-3/lpadaxcs...": show these in a loop until the other
// side is done; uppercase them for smaller QR codes
let part = encoder.next_part().to_ascii_uppercase();
// the other way: feed whatever the scanner sees, in any order, any case
let mut decoder = Decoder::new();
while !decoder.receive(&scan()?)? {}
assert_eq!(decoder.ur_type(), Some("crypto-psbt"));
let psbt = bcur::cbor_to_bytes(decoder.message().unwrap())?;Short payloads are single-part (bcur::encode / bcur::decode), and
bcur::bytewords has the three bytewords styles on their own.
BBQr (bbqr, Coinkite's specification) cuts a file into numbered
parts, in hex, base32, or compressed then base32. Every part has to be
received, in any order.
use outscript::bbqr::{self, FileType, Joiner};
// parts of at most 2132 characters: a version 27 QR code. Compressed if
// that makes the file smaller, as base32 otherwise.
let parts = bbqr::split(&psbt, FileType::PSBT, 2132)?; // "B$ZP0300..."
let mut joiner = Joiner::new();
while !joiner.receive(&scan()?)? {
println!("{} of {}", joiner.received_part_count(), joiner.part_count());
}
let (file_type, psbt) = joiner.finish()?;Compressed data stays within the 1 KiB window the specification asks for, so that hardware wallets can decompress it. The compressor (minizlib) is a small one: close to zlib on the likes of PSBTs, where little repeats, and a long way behind on very repetitive files. Anything zlib produces is decompressed.
Both decoders bound what untrusted parts can make them allocate
(Decoder::with_limits, Joiner::with_max_len).
let reward = outscript::block_reward("bitcoin", 840_000).unwrap(); // 3.125 BTC in sats
let total = outscript::cumulative_reward("bitcoin", 840_000).unwrap(); // total mintedChains are opt-in. Each chain feature enables its modules, output-script
formats and address codecs, and pulls in only the curve arithmetic it needs
from purecrypto. All chains are enabled by default.
| Feature | Enables | Curve |
|---|---|---|
bitcoin |
Bitcoin-family scripts and addresses, btcraw, BtcTx, PSBT, script guessing, BtcAmount, block rewards |
secp256k1 |
evm |
EIP-55 addresses, evmraw, EvmTx, ABI helpers |
secp256k1 |
solana |
addresses, program-derived addresses, SolanaTx |
ed25519 |
cardano |
Shelley addresses, BIP32-Ed25519 derivation, CardanoTx |
ed25519 |
massa |
addresses | ed25519 |
zcash |
transparent addresses, zcashtx (v5 transactions, ZIP-244 ids and sighashes, signing) |
secp256k1 |
The transports are features too, independent of any chain and enabled by default:
| Feature | Enables | Dependency |
|---|---|---|
bcur |
Uniform Resources: bytewords, ur: strings, fountain encoder and decoder |
|
bbqr |
BBQr: parts, splitting and joining, compression | minizlib |
The secp256k1 and ed25519 features can also be enabled on their own for
the raw crypto helpers and the matching PubKey variant. Formats and
networks of chains that are not enabled are simply unknown to
generate_script, formats_per_network and encode_address_to_slice.
# Solana only: no secp256k1 code is compiled in
outscript = { version = "0.1", default-features = false, features = ["std", "solana"] }SecpPrivateKey and CardanoExtendedKey wipe their key material when dropped
and implement Zeroize to scrub it on demand. Signing and key derivation wipe
the secret-derived buffers they create, and CardanoExtendedKey::bytes returns
a self-wiping buffer. The wiping API is re-exported from purecrypto, so no
extra dependency is needed:
use outscript::crypto::{Zeroize, Zeroizing, secp256k1::SecpPrivateKey};
let secret = Zeroizing::new(load_secret()); // your copy: wiped on drop
let mut key = SecpPrivateKey::from_bytes(&secret).unwrap();
let sig = key.sign_der(&digest);
key.zeroize(); // or just let it dropSeeds passed by reference (Solana and Cardano sign(&[seed])) stay yours to
wipe. Wiping is hygiene: it cannot reach copies the compiler left in registers
or on the stack.
The crate is #![no_std]. Independently of the chain features, the runtime
tiers are:
| Features | Available |
|---|---|
std (default) |
everything, plus std::io adapters (BtcTx::read_from, BtcVarInt::read_from/write_to) |
alloc |
everything else: Out/Script, address parsing, all transaction types, RLP/CBOR, JSON, multi-part UR and BBQr |
| none | a heap-free core (below) |
Disabling the default features also disables every chain, so name the ones you need:
# heap-free core only
outscript = { version = "0.1", default-features = false, features = ["bitcoin", "evm"] }
# full API on no_std targets with an allocator
outscript = { version = "0.1", default-features = false, features = ["alloc", "bitcoin", "evm", "solana", "cardano", "massa", "zcash"] }Without alloc you still get:
- Keys and signing — secp256k1 ECDSA/Schnorr/taproot, Ed25519, Cardano BIP32-Ed25519 derivation.
- Scripts and addresses —
generate_scriptfor every built-in format,encode_address_to_sliceto render them, anddecode_*_addressto parse Bitcoin-family, EVM, Massa, Solana and Cardano addresses. - Transaction signing —
psbt::Psbt(the full BIP-174 workflow),btcraw::RawTx(legacy, BIP-143, taproot and unified sighashes, serialization, txid),zcashtx::ZcashTx(v5 serialization, ZIP-244 ids and sighashes, signing) andevmraw::RawEvmTx(legacy/EIP-2930/EIP-1559 signing, encoding, hash, sender recovery). - Utilities — Solana keys/PDAs/compact-u16, EVM ABI selectors and ERC-20
calldata,
BtcAmountparsing/formatting, script guessing, and base58, bech32/CashAddr, EIP-55, pushdata and varint codecs. - Transports — one part at a time:
bcurbytewords,Ur::parseand single-part URs;bbqrheaders,encode_part_to_slice/decode_part_to_slice, anddeflate_to_slice/inflate_to_slice. The fountain code and the splitting and joining of whole files needalloc.
Results come back in caller buffers or small inline values:
use outscript::{PubKey, address, generate_script, crypto::secp256k1::SecpPrivateKey};
use outscript::evmraw::{EvmTxType, RawEvmTx};
let key = SecpPrivateKey::from_bytes(&secret).unwrap();
let pubkey = PubKey::Secp256k1(key.public_key());
// bc1q... for the key's P2WPKH script
let script = generate_script(&pubkey, "p2wpkh").unwrap();
let mut buf = [0u8; address::MAX_ADDRESS_LEN];
let n = address::encode_address_to_slice("p2wpkh", &script, "bitcoin", &mut buf).unwrap();
// sign an EIP-1559 transfer and encode it for broadcast
let tx = RawEvmTx {
tx_type: EvmTxType::Eip1559,
chain_id: 1,
nonce: 0,
max_priority_fee_per_gas: 1_000_000_000,
max_fee_per_gas: 30_000_000_000,
gas: 21_000,
to: Some(recipient),
value: amount_be,
data: &[],
};
let sig = tx.sign(&key).unwrap();
let mut raw = [0u8; 128];
let len = tx.encode_signed_to_slice(&sig, &mut raw).unwrap();- Format / Insertable — a sequence of operations (literal bytes, lookups, hashes, push-data, taproot tweak) that derive an output script from a key.
- Script — holds a [
PubKey] and evaluates named formats, caching results. - Out — a generated output script with its format name, hex and network flags; converts to/from human-readable addresses.
- Transactions —
BtcTx,EvmTx,SolanaTx,CardanoTxwith binary serialization, signing and hashing.
See LICENSE.