Back to Blog
Stacks Principal Derivation and Cross-Network Pitfalls

Stacks Principal Derivation and Cross-Network Pitfalls

ABA|March 30, 2026|20 min read|

Executive Overview

  • On Stacks, the same account has different principals (addresses) on mainnet and testnet. This is by design and arises from the network-specific version byte used during C32 encoding
  • Despite this difference, Stacks smart contracts accept cross-network principals in most operations. Tokens can be transferred to, and storage can record, a testnet principal on mainnet, for example
  • A small set of system functions (e.g. is-standard, principal-construct?, principal-destruct?, contract-hash?) explicitly differentiate between networks and will behave differently for non-matching principals
  • The is-standard function is the recommended guard for privileged-role setters to prevent accidental use of testnet principals on mainnet, which would silently lock key functionality
  • Redundant is-standard checks waste runtime execution budget and should be removed. However, an explicit error code may be preferred over system function error codes, if contracts need to handle failures gracefully

Stacks Principals and What Is Standard

With regard to the Stacks blockchain terminology and naming conventions, the word "standard" is used in several contexts which has added slight confusion over the years.

To give some examples:

Addresses on Stacks are referred to as principals. Both terms will be used interchangeably throughout the article.

An account is what is stored on the blockchain and its representation in Clarity is an address or principal.

Standard accounts are the equivalent of EOAs (Externally-owned accounts) in Ethereum, meaning that they are owned by one (or more) private keys and can initiate transactions on their own.

Contract accounts are equivalent to contract addresses on EVM chains and are not externally owned; they are controlled by the code stored to this account.

Contract accounts are dependent on the underlying standard account that deployed it and several other constraints. For the purpose of this article, we will focus on standard accounts and principals, not contract accounts. More information on both can be found in SIP-005.


Mainnet versus Testnet Principal Format

For people new to Stacks, one notable difference from other chains is that addresses (both standard and contract) are encoded differently on mainnet versus testnet even though they belong to the same underlying account.

The same account will have different addresses on either testnet or mainnet; this is by design. SIP-005 describes this. To further elaborate on this, we'll go from a mnemonic to the Stacks mainnet/testnet addresses in steps.

From Secret Key to Stacks Addresses

A note on terminology: wallets such as Ledger may show that your account is tied to a secret key, which is represented by 24 words.

Ledger secret key display

In technical terms, these 24 words are your mnemonic. The mnemonic deterministically derives a seed value, from which private keys are derived; the corresponding public key is then used to derive your Stacks address.

At a high level, the derivation flow is: mnemonic → keypair → signer hash → ST / SP addresses:

1. Mnemonic → Seed (BIP39)

Starting from a mnemonic, the seed value is derived as dictated by BIP39, which uses the PBKDF2 key derivation algorithm, with HMAC-SHA512 as the pseudo-random function internally.

seed = PBKDF2(mnemonic, "mnemonic" + passphrase)

Note: unless explicitly provided, Stacks tooling (e.g. Clarinet, stacks.js) derives the BIP39 seed using an empty passphrase, meaning the salt used in PBKDF2 is the literal string "mnemonic" as required by BIP39.

The seed value then allows an expansion into a tree of keys, where each node can derive child keys via the derivation algorithm. This is a fancy way of saying multiple, different, private/public key pairs can be safely derived from the same seed further on.

2. Seed → Keypair (HD Derivation)

Stacks follows BIP32/BIP44-style for private key hierarchical deterministic (HD) wallet derivation path. Following the BIP, we note that generally the derivation path takes on the form of:

m / purpose' / coin_type' / account' / change / address_index

Stacks uses the following derivation path, specifically:

m / 44' / 5757' / 0' / 0 / 0

Without going into much detail, we can briefly describe the above string (path) by exemplifying and mapping it to BIP-0044 elements.

Note: The apostrophe (') in the path denotes that hardened derivation (BIP32) is used. In hardened derivation, child keys cannot be derived from a parent public key, and knowledge of a child key does not allow recovery of the parent private key. This avoids a known vulnerability in non-hardened derivation.

The following table elaborates on the Stacks derivation path:

Path LevelValueObservation
MastermRoot of the HD wallet tree derived from the BIP39 seed. All keys originate from here; constant.
Purpose44'Indicates use of BIP44 standard; constant.
Coin Type5757'The registered coin type allocated for Stacks ecosystem, which was first added to the BIP registry in 2018.
Account0'First account index. Hardened to isolate accounts from each other at the key level.
Change (Chain)0External chain (receiving addresses). 1 would represent internal/change addresses.
Address Index0First address in the derivation path. Incrementing this generates new addresses under the same account.

Changing any level value in the derivation path results in a completely different key pair and address, even when using the same mnemonic. This property is critical for deterministic wallet design and also a common source of integration bugs when paths are mismatched across tools.

Side Note: Stacks Accounts and Multisig

The derivation path shown above focuses on deriving a single private/public key pair from the mnemonic (Address Index is 0). However, the BIP derivation algorithm itself allows up to 2³² − 1 values to be created. Stacks also supports multiple key-pairs on the same account via this mechanism. This means that Stacks accounts can be multisigs by design.

In practice, this is achieved using the derivation path with multiple address indexes. It is important to mention that, while a mnemonic can generate billions of keys, a Stacks multisig account is fundamentally bounded by transaction serialization limits and a 2-byte signature threshold, making extremely large signer sets (e.g., 2³² − 1) impossible.

The 2-byte threshold is a blockchain constraint, mentioned in SIP-005, which already limits the theoretical maximum number of signers to 65,535.

A 2-byte signature count indicating the number of signatures that are required for the authorization to be valid.

In practice, with signer signatures being required to be incorporated in the Stacks transaction itself, realistically no more than a few thousand signers can be set on a multisig. This ignores, of course, the complete lack of practicality in having such a large signer count.

3. Keypairs → Hash Mode

A Stacks standard account is defined as:

Standard accounts. These are accounts owned by one or more private keys. Only standard accounts can originate and pay for transactions. A transaction originating from a standard account is only valid if a threshold of its private keys sign it. The address for a standard account is the hash of this threshold value and all allowed public keys. Due to the need for backwards compatibility with Stacks v1, there are four ways to hash an account's public keys and threshold, and they are identical to Bitcoin's pay-to-public-key-hash, multisig pay-to-script-hash, pay-to-witness-public-key-hash, and multisig pay-to-witness-script-hash hashing algorithms (see appendix).

In other words, a standard account is defined by:

  • A set of public keys
  • A signature threshold
  • A hashing mode used to derive the account identifier

The original 4 hashing algorithms defined in SIP-005 correspond to Bitcoin-style constructions and are explicitly defined as such.

SIP-027 later extended this with two additional non-sequential multisig modes, bringing the total to six. Before this, with sequential multisigs, signatures had to be ordered to match the declared public key sequence in the spending condition. Non-sequential multisig removes this constraint. Each signature carries a key index, so signatures can be provided in any order.

A hash mode byte indicates which algorithm will be used. The matching is defined as:

Hash modeSpending ConditionMainnet versionTestnet versionHash algorithm
0x00Single-signature22 (P)26 (T)Bitcoin P2PKH
0x01Multi-signature (sequential)20 (M)21 (N)Bitcoin redeem script P2SH
0x02Single-signature (segwit-wrapped)20 (M)21 (N)Bitcoin P2WPKH-P2SH
0x03Multi-signature (segwit, sequential)20 (M)21 (N)Bitcoin P2WSH-P2SH
0x05Multi-signature (non-sequential)20 (M)21 (N)Bitcoin redeem script P2SH
0x07Multi-signature (segwit, non-sequential)20 (M)21 (N)Bitcoin P2WSH-P2SH

Observations:

  • The letters shown next to each version number are the C32 encoding of that byte
  • The mode values follow a bitwise encoding scheme: bit 0 signals multisig, bit 1 signals segwit-wrapped, and bit 2 signals non-sequential. Modes 0x04 and 0x06 are therefore not defined, as non-sequential signing only applies to multisig. 0x05 (P2SHNonSequential) is the recommended mode for new multisig implementations

At this point, the account parameters are fixed and the hash mode determines how they are encoded into the address.

4. Hash Mode → Signer Hash

Stacks computes a 20-byte signer hash from an input that depends on the hash mode.

The hashing logic used by Stacks is the same HASH160 construction as legacy Bitcoin address formats (equivalent to OP_HASH160) where Hash160 takes the SHA256 hash of its input, then takes the RIPEMD160 hash of the 32-byte result.

hash160 = RIPEMD160(SHA256(public_key))

Thus, depending on the hash mode, the signer hash is calculated as follows:

ModeDescriptionWhat is hashed
P2PKHSingle-sighash160(pubkey)
P2SHMultisig (sequential)hash160(redeem_script)
P2WPKHSegwit-wrapped single-sighash160(segwit(pubkey))
P2WSHSegwit multisig (sequential)hash160(segwit(script))
P2SH (non-sequential)Multisig (non-sequential)hash160(redeem_script)
P2WSH (non-sequential)Segwit multisig (non-sequential)hash160(segwit(script))

The non-sequential modes (0x05, 0x07) introduced by SIP-027 hash identically to their sequential counterparts (0x01, 0x03). The difference is in how signatures are ordered and validated during transaction authorization, not in how the address is derived.

An interesting observation is that Stacks' smart contract language, Clarity, also has hash160 implemented as a system function.

5. Add Version Byte

The next step is creating the payload for the Crockford base-32 (C32) encoding as follows:

payload = version_byte || signer_hash160

The version byte depends on the hash mode and the address network, more specifically:

VersionNetworkHash modesType
22 (P)Mainnet0x00P2PKH (single-sig)
26 (T)Testnet0x00P2PKH (single-sig)
20 (M)Mainnet0x01, 0x02, 0x03, 0x05, 0x07Script-hash (P2SH / segwit-wrapped)
21 (N)Testnet0x01, 0x02, 0x03, 0x05, 0x07Script-hash (P2SH / segwit-wrapped)

Note: the letters shown next to each version number are the C32 encoding of that byte.

The version byte determines whether a principal is valid for a given network.

6. Compute Data Checksum

The C32 checksum is the first four bytes of the double-SHA256 of the payload (the version byte prepended to the signer hash).

checksum = sha256(sha256(payload))[0:4]

7. Apply C32 Encoding

C32 encoding is applied to the full byte sequence: version_byte || hash160 || checksum

0      1                             21              25
|------|-----------------------------|---------------|
version     20-byte (signer_hash160)      4-byte checksum

8. Construct Final C32 Address

The last step is to take the previously generated C32 encoding and prepend it with a capital S, as documented:

The only difference between a c32check string and c32 address is that the letter 'S' is pre-pended.
principal = `S` | `{C32_encoded_signer_hash160_plus_checksum_and_version}`

Concrete Example: Deriving Stacks Principals from a Mnemonic

Before starting our derivation example, here are several other examples of principals encoded with different hash modes. The second character of each address encodes the version byte (P = mainnet single-sig, T = testnet single-sig, M = mainnet multi-sig, N = testnet multi-sig):

SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0  # mainnet, single-signature (P2PKH)
ST000000000000000000002AMW42H              # testnet, single-signature (P2PKH)
SM2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQVX8X0G  # mainnet, multi-signature (P2SH)
SN2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKP6D2ZK9  # testnet, multi-signature (P2SH)

Let's walk through how you derive the Stacks mainnet and testnet principals from a mnemonic.

We'll do this by following the exact steps mentioned above, applying them to the default Clarinet deployer account for devnet and an empty BIP39 passphrase.

toml
[accounts.deployer]
mnemonic = "twice kind fence tip hidden tilt action fragile skin nothing glory cousin green tomorrow spring wrist shed math olympic multiply hip blue scout claw"

1. Mnemonic → Seed (BIP39)

Input

mnemonic + passphrase = ""

Operation

PBKDF2-HMAC-SHA512(mnemonic, "mnemonic", 2048)

Outputseed

eb448c0d8d84926ab0d5cc1ec80d4d7528e7fd08903ad5d953c091abce4e11a45f4f16d66c1dee58a2a11101eb87eac1f0d59991b1f7fece24a5051a5d71e3f0

2. Seed → Keypair (HD Derivation)

Input

seed

Operation

Derive keypair at

m/44'/5757'/0'/0/0

Outputkeypair

Private key

753b7cc01a1a2e86221266a154af739463fce51219d97e4f856cd7200c3bd2a6

Public key (compressed):

0390a5cac7c33fda49f70bc1b0866fa0ba7a9440d9de647fecb8132ceb76a94dfa

3. Keypair → Hash Mode

Input

keypair

Operation

select hash mode

For a standard single-key wallet, 0x00 (P2PKH) is the default. It applies hash160 directly to the public key.

OutputHash Mode

Hash mode: 0x00 (P2PKH, single-signature)

Algorithm: hash160(pubkey)


4. Hash Mode → Signer Hash

Input

pubkey + hash_mode

Operation

Hash the public key using the hash160 algorithm

RIPEMD160(SHA256(pubkey))

Outputsigner_hash

6d78de7b0625dfbfc16c3a8a5735f6dc3dc3f2ce

5. Add Version Byte

Input

signer_hash

Operation

Prepend the version byte

version || signer_hash

0x1a for testnet P2PKH, 0x16 for mainnet P2PKH

Outputc32_input

Testnet (0x1a):

1a6d78de7b0625dfbfc16c3a8a5735f6dc3dc3f2ce

Mainnet (0x16):

166d78de7b0625dfbfc16c3a8a5735f6dc3dc3f2ce

6. Compute Data Checksum

Input

c32_input

Operation

SHA256(SHA256(payload))[0:4]

Outputchecksum

Testnet:

35687e14

Mainnet:

18b834f8

7. Apply C32 Encoding

Input

C32 input + checksum

Operation

Concatenate C32 input (which contains version and signer hash) with the checksum

c32_input (version || signer_hash) || checksum

Outputc32_encoded_data

Testnet concatenated data:

1a6d78de7b0625dfbfc16c3a8a5735f6dc3dc3f2ce35687e14

Testnet C32 encoded:

T1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM

Mainnet concatenated data:

166d78de7b0625dfbfc16c3a8a5735f6dc3dc3f2ce18b834f8

Mainnet C32 encoded:

P1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRCBGD7R

8. Construct Final C32 Address

Input

c32_encoded_data

Operation

Prepend "S"

S || c32_encoded_data

Output → Principal

Testnet:

ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM

Mainnet:

SP1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRCBGD7R
Principal Construction Algorithm
Principal Construction Algorithm

Cross-Network Principal Behavior

We've detailed how an account on Stacks has different principals (addresses) on mainnet versus testnet, and have reached the important point that:

On Stacks, any principal can receive assets on any network, but only principals whose version byte matches the current network can initiate transactions on that network.

From the perspective of Clarity smart contracts, principals are treated uniformly as a principal data type, regardless of their originating network. As a result, most operations do not enforce network matching.

In practice, this means:

  • Cross-network principals cannot initiate transactions on a different network
  • Cross-network principals can be used in all other contexts, examples include:
    • Receiving STX transfers
    • Receiving, minting, or burning fungible tokens
    • Receiving, minting, or burning non-fungible tokens
    • Being stored and retrieved from contract state (e.g. var-set, var-get)

In other words, a testnet principal used on mainnet behaves identically to a mainnet principal in all contexts except initiating transactions.

It is important to note that this does not imply any bridging between networks. Transferring assets to a principal derived for another network does not make those assets available on that network. Mainnet and testnet are completely independent. It simply means that on one blockchain, tokens will be associated with a non-standard network principal.

Functions Sensitive to Principal Network Type

An exception to the "behave in the same manner" statement comes with system functions that directly work with principals and that specifically account for principal network type.

These functions are:

  • is-standard: returns true if the principal's version byte matches the current network
  • contract-hash?: computes the hash of a contract identifier; returns an error if the contract does not exist on the current network, including in cases of mismatched contract principals
  • principal-construct?: constructs a principal from a version byte and a hash; returns an error response if the version byte does not correspond to the current network
  • principal-destruct?: deconstructs a principal into its version byte and hash components; if the version byte of principal-address does not match the network, then an err value is returned

The most commonly encountered of these, and relevant to our article, is is-standard. It tests whether a given principal's version byte matches the current network, and therefore whether that principal is authorized to spend or initiate calls on that network.

Its purpose is to prevent principals from a different network from being silently accepted as valid spenders or callers. Without an explicit check, a smart contract has no mechanism to reject a cross-network principal before storing it in state or assigning it a privileged role.

is-standard

input: principal output: bool signature: (is-standard standard-or-contract-principal)

description:

Tests whether standard-or-contract-principal matches the current network type, and therefore represents a principal that can spend tokens on the current network type. That is, the network is either of type mainnet, or testnet. Only SPxxxx and SMxxxx c32check form addresses can spend tokens on a mainnet, whereas only STxxxx and SNxxxx c32check forms addresses can spend tokens on a testnet. All addresses can receive tokens, but only principal c32check form addresses that match the network type can spend tokens on the network. This method will return true if and only if the principal matches the network type, and false otherwise.

Note: This function was introduced in Clarity 2 and only available starting with Stacks 2.1.

example:

clarity
(is-standard 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6) ;; returns true on testnet and false on mainnet
(is-standard 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6.foo) ;; returns true on testnet and false on mainnet
(is-standard 'SP3X6QWWETNBZWGBK6DRGTR1KX50S74D3433WDGJY) ;; returns true on mainnet and false on testnet
(is-standard 'SP3X6QWWETNBZWGBK6DRGTR1KX50S74D3433WDGJY.foo) ;; returns true on mainnet and false on testnet
(is-standard 'SZ2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQ9H6DPR) ;; returns false on both mainnet and testnet

Observations

  • The above boxed description is mostly taken from the repo VM docs, which is a bit more elaborate that the one on the docs.stacks.co page for some reason
  • When the function was added in Stacks 2.1, the changelog incorrectly noted that the role of the function was to determine if a principal belonged to a standard or contract account
The Clarity function is-standard has been added to identify whether or not a principal instance is a standard or contract principal.

This goes to show that the over-use of the "standard" word (mentioned in Stacks Principals and What Is Standard) confused even the developers at times.

How to Safeguard Your Project and Avoid Some Audit Findings

As auditors, we need to take everything into consideration when reviewing a project. From economic attacks and MEV to network discrepancies like these.

Since Stacks allows principals meant for a different network to be used on the current network, some issues can appear. We'll elaborate on these issues further on, and how to avoid them.

Cross-network Principal Mismatch

Accepting or storing a principal from a different network without validation can result in permanently unusable permissions, broken administrative flows, or locked contract functionality with no on-chain recovery path.

For example, if an admin setter stores a testnet principal on mainnet, that principal will never be able to call privileged functions on mainnet, effectively freezing administrative control.

On codebases where direct principals with privileged roles are used, we specifically recommend using the is-standard function to verify that any passed principal belongs to the current network. Such a check can be easily implemented as follows:

clarity
(define-constant ERR_NOT_STANDARD u70000)
;; ...
(asserts! (is-standard address) ERR_NOT_STANDARD)

Example of how such a finding would be presented during an audit:

🔵 Add Standard Checks For All Saved Principals

Description

Throughout the codebase, there are sensitive principals that are saved in the storage contracts. None of these principals are checked to be standard (meaning to belong to the current network).

By mistake, a testnet principal may be used instead of a mainnet principal, permanently locking privileged functionality with no recovery path.

Recommendation

In all storage contracts that save principals, check that the principals are valid for the current network using the is-standard function.

Note: This finding would be informational in an audit according to our classification standard.

Redundant Standard Checks On Pre-Filtered Principals

Some developers are already aware of the underlying principal issue and have already added the is-standard checks.

However, in some cases these are redundant if the principal was (or will be) already filtered by other functions that differentiate between principal networks.

Principals from Traits are Already Filtered

Another important safeguard is implicitly provided when using traits. Passing a testnet principal in a contract call meant for a trait on mainnet will result in a blockchain contract lookup check on the passed trait to ensure interface compatibility. This check will revert with RuntimeCheckErrorKind::NoSuchContract since a testnet contract cannot exist on mainnet.

In other words, there is no need to do any network checks on principals taken from a trait, since the transaction can only reach execution if the trait is valid.

Example of a case where doing an is-standard check is redundant is when is-standard is applied to the result of a contract-of call.

The contract-of function takes as input a trait and retrieves the underlying principal. If the trait is implemented by a testnet principal, then the underlying contract does not exist on the current network as a deployed contract, resulting in a RuntimeCheckErrorKind::NoSuchContract runtime error being emitted at runtime, as described above.

Example of how such a finding would be presented during an audit:

🔵 Remove Redundant Standard Checks On Pre-Filtered Principals

Description

Throughout the codebase there are instances of determining the principal contract of a trait by using contract-of and then proceeding to also check if that principal belongs to the current network via is-standard check.

Adding an is-standard check on principals derived from a contract-of result is redundant, as contract-of works only with traits, which are already verified at the blockchain level internally to belong to the current network, otherwise runtime errors are emitted.

Recommendation

Remove the is-standard checks on all principals derived from contract-of calls.

Note: This finding would be informational, an execution fee optimization specifically, in an audit according to our classification standard.

Other cases where an is-standard check is redundant are when the same principal will later be passed to one of the functions listed in Functions Sensitive to Principal Network Type, since those functions already revert on a non-standard principal.

Nuances On When to Skip the Check

There are some nuances on skipping an is-standard check if there are other functions that would revert with a non-standard principal.

There may be cases where it makes sense to allow the is-standard call first, since it costs relatively few runtime resources, so as to revert with a specific and documented error instead of an error specific to the underlying function.

To elaborate, take the example implementation check mentioned earlier:

clarity
(define-constant ERR_NOT_STANDARD u70000)
;; ...
(asserts! (is-standard address) ERR_NOT_STANDARD)

For example, a calling contract can catch the error in a match error branch, and behave differently if the error code is u70000, vs u2 which is a standard error for SIP-010::transfers. Note that u2 can also be returned by a contract-hash? call on a mismatched network principal. Differentiating the root cause on-chain therefore becomes difficult without a dedicated error code.

This is a trade-off between execution cost and explicit failure handling. Codebases prioritizing execution cost reduction may wish to omit the check, while those needing caller-friendly error handling can choose to keep it.

Conclusion

Stacks principals implement Bitcoin-style address construction, extended with network-specific version bytes. This difference is largely invisible at the smart contract level, meaning cross-network principals work in most operations and only a few system functions enforce network consistency.

This behavior has security implications. A single misplaced testnet principal in a privileged-role setter can silently lock a protocol, with no immediate runtime error to surface the mistake.

Key takeaways:

  • Stacks mainnet and testnet addresses are derived from the same keypair; only the version byte in the C32 encoding differs
  • Smart contracts accept cross-network principals for token transfers, storage operations, and most other use cases
  • Only a few system functions (e.g. is-standard, principal-construct?, principal-destruct?, contract-hash?) enforce network consistency
  • Always guard privileged-role setters with is-standard to prevent silent acceptance of testnet principals on mainnet
  • Remove is-standard checks where the same principal will be passed to a function that already rejects non-standard principals via reverts. The check is redundant and wastes execution budget
  • Where the check is not redundant, an explicit is-standard may be preferred over system function errors, since calling contracts may need to handle failures gracefully