Programming the Bitcoin address - P2PKH


You need to have a valid bitcoin address to receive bitcoins in your wallet. There are multiple approaches to create a bitcoin address by writing a program. In this article, we will see how to create a P2PKH (pay to public key hash) address. Although this is not widely used, and is a legacy address, it is simple to build and understand. Another approach, P2SH (pay to script hash), will be discussed in a later article.

Legacy Pay to Public Key Hash (P2PKH)

This address is generated by hashing public key in a particular way. For that purpose, we will need to generate private key and public key. We can only generate a public key from a private key, otherwise public key won’t be associated with anything and won’t have a meaning. This is important because if we sign a signature with private key, only public key can verify that signatures are from the same owner. If private key and public key are not associated, we can’t verify that.

We can go ahead and try to code from scratch and learn to create public key and private key, but it will involve a lot of math concepts and will make this article much more complex. Other than simplicity, it is not recommended to code cryptographic primitives by yourself and use them in production. Always use well-tested and well-reputed crypto libraries for that purpose.

I shall use Rust to demonstrate these examples. If you want to run examples, make sure you have Rust installed with code editor of your choice.

Project Setup

After installing rust and your code editor, make sure that you are in a correct directory to create a new project. You can run following command in your terminal

cargo new p2pkh

It will generate a new rust project. Open p2pkh directory in your code editor and create two files in the src directory, named keys.rs and address.rs.

After that, open src/main.rs and remove existing boilerplate code. keys.rs and address.rs are our modules and we have to import them in our main module to use in our project. We can do it like this

pub mod keys;
pub mod address;

Generating keys

Now, we will need two third party crates(libraries) k256 and rand. You can add it to your project by running this command in terminal. Make sure you are in the p2pkh directory.

cargo add k256
cargo add rand@0.8.0

Just copy and paste this code in src/keys.rs.

use k256::{
    EncodedPoint,
    ecdsa::{SigningKey, VerifyingKey},
};
use rand::rngs::OsRng;

pub struct PrivateKey(SigningKey);

impl PrivateKey {
    pub fn new() -> Self {
        Self(SigningKey::random(&mut OsRng))
    }
}

pub struct PublicKey(pub VerifyingKey);

impl PublicKey {
    pub fn new(private_key: &PrivateKey) -> Self {
        PublicKey(*private_key.0.verifying_key())
    }

    // Compressed version is used in later versions of Bitcoin protocol
    pub fn to_compressed_point(&self) -> EncodedPoint {
        self.0.to_encoded_point(true)
    }
    pub fn to_uncompressed_point(&self) -> EncodedPoint {
        self.0.to_encoded_point(false)
    }
}

Let’s explore the above code a bit.

pub fn new() -> Self {
    Self(SigningKey::random(&mut OsRng))
}

SigningKey is part of the k256 crate and will create a private key via random method. Keep in mind that private key should be created by using a completely random number. We pass &mut OsRng to this function because OsRng provides cryptographically secure random numbers. It would not be secure to use any other type of random function because they are not sufficiently random. We added rand module to access OsRng.

pub fn new(private_key: &PrivateKey) -> Self {
    PublicKey(*private_key.0.verifying_key())
}

You can see that we can get our public key by using existing private key and it is not that complex. The public key is also known as the ‘verifying key’. You can get both uncompressed and compressed versions of the same public key, as shown in example.

pub fn to_compressed_point(&self) -> EncodedPoint {
    self.0.to_encoded_point(true)
}
pub fn to_uncompressed_point(&self) -> EncodedPoint {
    self.0.to_encoded_point(false)
}

In earlier bitcoin implementations, public key were 65 bytes long and considered too large. Recognizing that it consumes excessive space and made transfer and storage inconvenient, a compressed version was introduced, which only requires 33 bytes. Compression details are a in-depth and involve some math concepts, so we will leave it here.

Generating Address

Next, create a file src/address.rs, and paste the following code there.

use k256::sha2::{Digest, Sha256};
use ripemd::Ripemd160;

use crate::{
    keys::{PrivateKey, PublicKey},
};

pub struct P2PKH {
    address: [u8; 20],
}

impl P2PKH {
    pub fn new(public_key: &PublicKey, compress_public_key: bool) -> Self {
        let bytes = if compress_public_key {
            public_key.to_compressed_point()
        } else {
            public_key.to_uncompressed_point()
        };
        let bytes = bytes.as_bytes();

        if compress_public_key {
            assert!(bytes.len() == 33);
            assert!(&bytes[0] == &2 || &bytes[0] == &3);
        } else {
            assert!(&bytes[0] == &4);
            assert!(bytes.len() == 65);
        }

        let sha256 = Sha256::digest(bytes);
        let ripemd160 = Ripemd160::digest(sha256);
        // this should always be 20 bytes
        assert!(ripemd160.len() == 20);

        let mut address = [0u8; 20];
        address.copy_from_slice(&ripemd160);

        Self { address }
    }

    pub fn to_hex(&self) -> String {
        hex::encode(self.address)
    }
}

We have created a struct named P2PKH which will have the address attribute, which will contain 20 bytes. RIPEMD160 is a hashing algorithm and is always going to generate a 20-byte hash, which is why we are going to store address in a 20 bytes array.

Now coming to the new method, which will receive a public key and a parameter to decide if we are gonna compress the public key or not. If compress_public_key is true, compress the public key otherwise just return the uncompressed one. Note that we are redeclaring bytes variable using let bytes=bytes.as_bytes(). This is known as shadowing, as the new variable will ‘shadow’ old variable. This new variable will contain the raw bytes of the public key.

let bytes = if compress_public_key {
    public_key.to_compressed_point()
} else {
    public_key.to_uncompressed_point()
};
let bytes = bytes.as_bytes();

Next, there are some assertions to make sure that we are doing everything okay. These assertions should not be in present production code. These are just to make sure that we are getting what we want and to make it clear in our code that that is what bitcoin is doing. If compress_public_key is true, then length of public key must be 33 bytes, and first byte either should be ‘02’ or ‘03’. If compress_public_key is false, then length of public key should be 65 bytes, and first byte should be ‘04’.

if compress_public_key {
    assert!(bytes.len() == 33);
    assert!(&bytes[0] == &2 || &bytes[0] == &3);
} else {
    assert!(&bytes[0] == &4);
    assert!(bytes.len() == 65);
}

After getting and validating raw bytes, we create a sha26 hash first, like this

let sha256 = Sha256::digest(bytes);

We need to hash it again, but this time, it would be a RIPEMD160 hash of the previous hash.

let ripemd160 = Ripemd160::digest(sha256);

Remember, the address should only be 20 bytes long, because RIPEMD160 is 20 bytes long. Here we create an array of 20 bytes and copy the hash into that array. Then we return the instance of P2PKH at the end by returning Self.

let mut address = [0u8; 20];
address.copy_from_slice(&ripemd160);
Self { address }

We also have to_hex function, which returns string of hexadecimal characters of the address. We use hex crate for that. You can run cargo add hex in your terminal to add that package.

pub fn to_hex(&self) -> String {
    hex::encode(self.address)
}

Writing Test

Let’s write a test to check if everything is working fine and to see our output. In src/address.rs, paste it at the end of your file.

#[test]
fn test_p2pkh() {
    let private_key = PrivateKey::new();
    let public_key = PublicKey::new(&private_key);
    let legacy_address = P2PKH::new(&public_key, false);
    let address_hex = legacy_address.to_hex();

    println!("P2PKH Address: {}", address_hex);
}

We are creating new instances of PrivateKey and PublicKey. P2PKH needs &public_key and a parameter to decide if it needs to compress the public key or not. We are converting that address into hexadecimal string and then printing that on terminal. To execute this, you need to run following command in your terminal

cargo test addresses::address::test_p2pkh -- --nocapture

It will print a different address than mine because your keys will be different. There are some problems with this address though. It is not easy to read and is a somewhat lengthy. Users can confuse characters such as lowercase ‘l’, uppercase ‘I’, uppercase ‘O’ and the numeral ‘0’. Bitcoin uses base58check encoding to improve clarity by removing these confusing characters and by making address a bit shorter. Currently, our address has 40 characters, but after converting to base58check format, it will have 34 characters. We can do this by ourselves.

Base68Check Encoding

Let’s create another struct Base58 in the src/address.rs.

pub struct Base58;
impl Base58{
    // Code goes here
}

We will need the encode function which will be called directly without creating an instance. Function would go like this

impl Base58{
    pub fn encode(version: u8, payload: &[u8]) -> String {
        // Implementation goes here
    }
}

It will receive version(the bitcoin network version) and payload (the bytes we need to encode). It will return a String.

To make encode function work, we need to do something like this:

  • Create a vector of bytes
  • First, add the version to it.
    • version has different purposes. On mainnet for p2pkh, version should be 0x00.
    • On testnet for p2pkh, version should be 0x6f.
  • Append payload to the same bytes array. It will be a single array, but version will come first. Let’s call this bytes.
  • Take a sha256 hash of bytes. Let’s call it sha256_1.
  • Again take a sha256 hash of sha256_1. Let’s call it sha256_2.
  • We need to take first 4 bytes of sha256_2. It’s known as checksum.
  • Create another empty vector, let’s call it to_encode.
  • Push version to to_encode.
  • Copy all bytes from payload to to_encode.
  • Copy all bytes from checksum to to_encode.
  • Then run base58 encode algorithm on to_encode.

That’s it. These are all the steps we need to encode to base58check form. We will not be writing algorithm to encode to base58 form, because it will be detailed and long. We will use bs58 crate for that purpose. Let’s add the crate first.

cargo add bs58

Then in encode function of impl Base58 block, paste the following code.

let mut bytes = [version].to_vec();
bytes.extend_from_slice(payload);

let sha256_1 = Sha256::digest(bytes);
let sha256_2 = Sha256::digest(sha256_1);
let checksum = &sha256_2[0..4];

let mut to_encode = vec![];
to_encode.push(version);

for byte in payload {
    to_encode.push(byte.clone());
}

for byte in checksum {
    to_encode.push(*byte);
}

bs58::encode(to_encode).into_string()

Everything about this code has been explained above, and it is not that complex. Now we would like to verify it. Just paste the following line in test_p2pkh at the end.

let encoded_address = Base58::encode(0, &legacy_address.address);
println!("Base58Check encoded: {encoded_address}");

Run the following command again, and you will see the output both in hexadecimal string form and in base58check form. If the version is 0x00, then encoded_address will have a prefix of ‘1’. If version is 0x6f, the encoded_address will have a prefix of either ‘m’ or ‘n’.

If you want, you can test this base58check encoded address in bitcoin address validator. I found that if you have version 0x00, means you are generating the address for mainnet, the validator will say your address is okay. If you have version 0x65, means you are generating address for testnet, validator may say that your address is invalid.

Conclusion

We learned how to create a P2PKH address ourselves by writing a program. You can generate testnet as well as mainnet addresses. We can generate compressed as well as uncompressed keys. We can also create a base58check form of bitcoin address bytes, which is actually the valid address. Next we will take a look at a different type of address, which is Pay to Script Hash (P2SH).