Skip to content

Latest commit

 

History

56 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

outscript

CI crates.io docs.rs License: MIT

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.

Supported Networks

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)

Usage

Address generation

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...

Address parsing

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 output

Bitcoin transactions

use 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).

Unified opt-in sighash

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.

Taproot script trees

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.

PSBT (BIP-174)

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)?;

EVM transactions

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();

Solana transactions

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 signature

new_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 0x81

SolanaTx::from_bytes recognizes all three formats.

Cardano transactions

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 body

Cardano 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 usual

HD key derivation (CIP-1852 / BIP32-Ed25519)

Derive 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 transactions (transparent)

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.

Moving PSBTs around: UR and BBQr

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).

Block rewards

let reward = outscript::block_reward("bitcoin", 840_000).unwrap();      // 3.125 BTC in sats
let total  = outscript::cumulative_reward("bitcoin", 840_000).unwrap(); // total minted

Cargo features

Chains 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"] }

Key hygiene

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 drop

Seeds 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.

no_std and no-alloc

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 addressesgenerate_script for every built-in format, encode_address_to_slice to render them, and decode_*_address to parse Bitcoin-family, EVM, Massa, Solana and Cardano addresses.
  • Transaction signingpsbt::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) and evmraw::RawEvmTx (legacy/EIP-2930/EIP-1559 signing, encoding, hash, sender recovery).
  • Utilities — Solana keys/PDAs/compact-u16, EVM ABI selectors and ERC-20 calldata, BtcAmount parsing/formatting, script guessing, and base58, bech32/CashAddr, EIP-55, pushdata and varint codecs.
  • Transports — one part at a time: bcur bytewords, Ur::parse and single-part URs; bbqr headers, encode_part_to_slice / decode_part_to_slice, and deflate_to_slice / inflate_to_slice. The fountain code and the splitting and joining of whole files need alloc.

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();

Architecture

  • 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.
  • TransactionsBtcTx, EvmTx, SolanaTx, CardanoTx with binary serialization, signing and hashing.

License

See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages