Need a crash course on Bitcoin layers?
→ READ OUR FREE GUIDE
Need a crash course on Bitcoin layers?
→ READ OUR FREE GUIDE
Need a crash course on Bitcoin layers?
→ READ OUR FREE GUIDE
Need a crash course on Bitcoin layers?
→ READ OUR FREE GUIDE
Need a crash course on Bitcoin layers?
→ READ OUR FREE GUIDE

How Every Stacks Address Has a Corresponding Bitcoin Address

A user’s first encounter with cryptographic magic is usually managing and sharing their public addresses. For users that bounce around on different networks and layers, dealing with multiple formats of public addresses and private keys is the norm. But on Stacks, your public address can actually be converted to a Bitcoin address, enabling single key management between the L1 and L2.

Type
Deep dive
Topic(s)
Stacks
Bitcoin
Published
May 14, 2025
Author(s)
Developer Advocate
How Every Stacks Address Has a Corresponding Bitcoin Address
Contents

Bitcoin and Stacks are intertwined in many ways: Stacks has Bitcoin finality, Clarity has read-access to Bitcoin, sBTC exists, etc. One important area of shared commonality that is less discussed lies in a user’s wallet, specifically the public key hash. This public key hash, which is controlled by the same single private key, allows a user to control addresses on both Bitcoin and Stacks, thanks to two encoding schemes: base58 and c32.

Recently, we put out a challenge for Stacks devs to prove this connection by writing a script that converts a Bitcoin address into a Stacks address using only the Clarity smart contract language. The challenge was inspired by another Stacks community dev, LNow, who wrote a base58 encoding script in Clarity - converting a Stacks address to Bitcoin. We wanted to prove that the reverse was possible as well, base58 decode script that turns a BTC address into a Stacks address. With a total of 6 submissions, only 1 stood out with a proper working solution done by Eamon Penland.

Now before we go into Eamon’s code submission, let’s talk about what base58 and c32 are, and why conversion between the two in Clarity is novel.

Breaking Down base58 and c32 Encoding Schemes

Base58 is a user-friendly set of characters used in Bitcoin to make addresses shorter & more human-friendly. It excludes easily confused characters (like 0, O, I, l). Base58 is used for encoding legacy (P2PKH) and script (P2SH) addresses as well as for encoding WIF private keys and other types of extended keys. 

The valid characters of base58 consist of: <code-rich-text>123456789<code-rich-text> <code-rich-text>ABCDEFGHJKLMNPQRSTUVWXYZ<code-rich-text> <code-rich-text>abcdefghijkmnopqrstuvwxyz<code-rich-text>

Bitcoin addresses use different prefixes, which are concatenated to the pubkey hash payload before base58 encoding. This results in a leading character that indicates the type of address represented by the base58 string. For example, legacy addresses are prefixed with a <code-rich-text>0x00<code-rich-text> hex before base58 encoding, which then gets manually converted into a 1, hence the reason why legacy addresses look like this: <code-rich-text>1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa<code-rich-text>

As a quick primer, "base" refers to the number of characters available for you to use when representing a value. The more characters you have in your base, the fewer characters you need to use to represent large numbers.

Stacks addresses, on the other hand, are c32 formatted, which is an encoding scheme implementation of Crockford base32. Similar to the base58 implementation, Stacks addresses are also prefixed with a version byte indicating the address version and network it is used for.

For a mainnet single signature address, the version byte of <code-rich-text>0x16<code-rich-text> is prefixed to the payload data before it all gets c32 encoded. And then the last step is to prepend the letter S to the beginning of the result which will make Stacks addresses look like this: <code-rich-text>SP1HEJ1XHBJZJFNA2AECYQXQGZD8EQE4F30D6H5CR<code-rich-text>

The valid characters of c32 consist of: <code-rich-text>0123456789<code-rich-text> <code-rich-text>ABCDEFGHJKMNPQRSTVWXYZ<code-rich-text>

What’s the Link Between base58 and c32?

Remember how we mentioned that a user is able to participate in Bitcoin and Stacks with the same single private key? A user can use the same private and public key pair to generate a Bitcoin supported address and a Stacks supported address enabling ownership of different Bitcoin layer assets.

The link between base58 and c32

Specifically a Bitcoin and a Stacks address can share the same public key hash (pubkey hash). This pubkey hash is public and the starting point for generating the public address. That means converting between a Stacks and Bitcoin address simply requires decoding an address to reveal its underlying pubkey hash, and then encoding it into a different formatted address.

The general steps to take when converting a Bitcoin address to its corresponding Stacks address:

  • Decode: Decode the Bitcoin address, via base58.
  • Extract: Isolate the pubkey hash away from the version byte and checksum.
  • Checksum: Generate a different checksum from the pubkey hash and the Stacks network version byte.
  • Encode: c32 encode the result of appending the checksum to the pubkey hash.
  • Concate: Concatenate the c32 encoded result with an “S” and the Stacks network version.

Elegant, right? But why does this matter and what can developers build with this?

Imagine passing a bitcoin address as a param to a Clarity function, then having an sBTC transfer done in that function's logic to the Bitcoin address' corresponding Stacks address. The user doesn’t need to know its Stacks address at all, but they do know that their native Bitcoin address has access to sBTC. Not only does this support use cases such as the proposal here by Larry Salibra, where he proposes the adoption of using native bitcoin addresses for Stacks, but it also pushes the limit of what Clarity can do as an open-source smart contract language. Apps that leverage this conversion on-chain let Bitcoin users interact with digital assets on Stacks network directly through their Bitcoin wallet, creating a seamless UX between the L1 and the L2. 

“sBTC is layer 2 Bitcoin. It should look and function like Bitcoin, only with more features and better UX and faster because it’s on layer 2. By look and function like Bitcoin I mean sBTC should work with bitcoin addresses and bitcoin QR codes.” — Larry Salibra

Now that you understand the purpose behind this challenge, let’s go through how Eamon implemented base58 in Clarity to realize the conversion between Bitcoin and Stacks addresses in a decentralized manner.

Implementing base58 in Clarity

There is both an encoding and decoding process for base58. We’ll take a look at the decoding process as this was the basis for the challenge. 

Below is the main conversion function of Eamon’s winning script submission:


(define-read-only (base58-to-address (addr (string-ascii 35)))
    (let (
        (base58-array (string-to-base58-array addr))
        (leading-ones (unwrap-panic (slice? base58-array u0 (default-to u0 (index-of? (map is-one base58-array) false)))))
        (base256 (add-zero-bytes (base58-to-base256 base58-array) (len leading-ones)))
        (version (unwrap-panic (element-at? base256 u0)))
        (payload-length (- (len base256) u4))
        (payload (unwrap-panic (slice? base256 u1 payload-length)))
    )
    (principal-construct? (if is-in-mainnet 0x16 0x1a) (unwrap-panic (as-max-len? (bytes-to-hex payload) u20)))
))

As you can see from the function, a base58 formatted Bitcoin address is passed in as a string parameter. This string is then going to be converted into an array of indexes, which specify where each character of the Bitcoin address is located in the <code-rich-text>BASE58_CHARS<code-rich-text> constant.


;; Constants
(define-constant BASE58_CHARS "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")

;; Base58 conversion helpers
(define-read-only (get-base58-value (char (string-ascii 1)))
    (unwrap-panic (index-of? BASE58_CHARS char))
)

(define-read-only (string-to-base58-array (str (string-ascii 40)))
    (map get-base58-value str)
)

Any leading 1’s are then identified because 1’s are treated a bit special in base58 for Bitcoin addresses. Remember from earlier, they are prepended to any base58 mainnet address to indicate address version type. On testnet, the letters “m” or “n”  are used. 

The array from above is then going to be converted to its raw byte format through a series of multiplication and addition logic used in base conversions. In particular, the code will convert the base58 characters to its corresponding byte value in hex.


;; Base256 conversion helpers
(define-read-only (process-byte-with-carry (existing-byte uint) (state {carry: uint, new-bytes: (list 35 uint)}))
    (let (
        (total (+ (* existing-byte u58) (get carry state)))
        (new-carry (/ total u256))
        (new-byte (mod total u256))
    )
        {
            carry: new-carry,
            new-bytes: (unwrap-panic (as-max-len? (append (get new-bytes state) new-byte) u35))
        }
    )
)

(define-read-only (process-base58-value (b58 uint) (bytes (list 35 uint)))
    (let (
        (result (fold process-byte-with-carry bytes {carry: b58, new-bytes: (list)}))
        (final-carry (get carry result))
        (final-bytes (get new-bytes result))
    )
        (if (> final-carry u0)
            (unwrap-panic (as-max-len? (concat final-bytes (list final-carry)) u35))
            final-bytes
        )
    )
)

(define-read-only (base58-to-base256 (base58-array (list 40 uint)))
    (let (
        (array-len (len base58-array))
        (reversed-array (fold process-base58-value 
            (unwrap-panic (slice? base58-array u0 array-len))
            (list u0)
        ))
    )
        (fold process-reverse reversed-array (list))
    )
)

The pubkey hash payload is then extracted and isolated. This will then be used to construct the Stacks principal.


(payload-length (- (len base256) u4))
(payload (unwrap-panic (slice? base256 u1 payload-length)))

Finally, the pubkey hash payload is then converted to hexadecimal buffer and passed into <code-rich-text>`principal-construct?`<code-rich-text> to form its corresponding Stacks principal.


(principal-construct? (if is-in-mainnet 0x16 0x1a) (unwrap-panic (as-max-len? (bytes-to-hex payload) u20)))

So if you run Eamon’s gist in Clarinet or the Clarity Playground, and then call the main function, it should work properly as intended.


>> (contract-call? .base58-decode base58-to-address "mqVnk6NPRdhntvfm4hh9vvjiRkFDUuSYsH")
(ok 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM)

As mentioned in a previous blog on types of Bitcoin addresses, not all Bitcoin addresses utilize base58 encoding. Base58 encoding is mainly used by legacy addresses (starts with “1”). This makes the encoding/decoding back and forth between Stacks and Bitcoin legacy addresses seamless.

Get Cooking With base58

Check out LNow’s Clarity recipe for both the decoding and encoding of base58 in the Hiro Cookbook. As LNow was instrumental in ideating and mentoring for this challenge, LNow’s scripts cover specific edge cases (ie, handling mainnet/testnet address versions) that Eamon’s submission does not, but Eamon’s script still deserves proper recognition for the effort put in. 

Use this and other recipes to build out more use-cases that can pull native Bitcoiners into Stacks. 

Product updates & dev resources straight to your inbox
Your Email is in an invalid format
Checkbox is required.
Thanks for
subscribing.
Oops! Something went wrong while submitting the form.
Start cooking
Copy link
Mailbox
Hiro news & product updates straight to your inbox
Only relevant communications. We promise we won’t spam.

Related stories