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-standardfunction 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-standardchecks 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:
- There are standard principals and contract principals
- Stacks blockchain supports two ways to authorize a transaction: a standard authorization, and a sponsored authorization
- There are principals that are standard on the network they are being used on or not → we will be discussing these
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.
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_indexStacks uses the following derivation path, specifically:
m / 44' / 5757' / 0' / 0 / 0Without 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 Level | Value | Observation |
|---|---|---|
| Master | m | Root of the HD wallet tree derived from the BIP39 seed. All keys originate from here; constant. |
| Purpose | 44' | Indicates use of BIP44 standard; constant. |
| Coin Type | 5757' | The registered coin type allocated for Stacks ecosystem, which was first added to the BIP registry in 2018. |
| Account | 0' | First account index. Hardened to isolate accounts from each other at the key level. |
| Change (Chain) | 0 | External chain (receiving addresses). 1 would represent internal/change addresses. |
| Address Index | 0 | First 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 mode | Spending Condition | Mainnet version | Testnet version | Hash algorithm |
|---|---|---|---|---|
0x00 | Single-signature | 22 (P) | 26 (T) | Bitcoin P2PKH |
0x01 | Multi-signature (sequential) | 20 (M) | 21 (N) | Bitcoin redeem script P2SH |
0x02 | Single-signature (segwit-wrapped) | 20 (M) | 21 (N) | Bitcoin P2WPKH-P2SH |
0x03 | Multi-signature (segwit, sequential) | 20 (M) | 21 (N) | Bitcoin P2WSH-P2SH |
0x05 | Multi-signature (non-sequential) | 20 (M) | 21 (N) | Bitcoin redeem script P2SH |
0x07 | Multi-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
0x04and0x06are 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:
| Mode | Description | What is hashed |
|---|---|---|
| P2PKH | Single-sig | hash160(pubkey) |
| P2SH | Multisig (sequential) | hash160(redeem_script) |
| P2WPKH | Segwit-wrapped single-sig | hash160(segwit(pubkey)) |
| P2WSH | Segwit 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_hash160The version byte depends on the hash mode and the address network, more specifically:
| Version | Network | Hash modes | Type |
|---|---|---|---|
22 (P) | Mainnet | 0x00 | P2PKH (single-sig) |
26 (T) | Testnet | 0x00 | P2PKH (single-sig) |
20 (M) | Mainnet | 0x01, 0x02, 0x03, 0x05, 0x07 | Script-hash (P2SH / segwit-wrapped) |
21 (N) | Testnet | 0x01, 0x02, 0x03, 0x05, 0x07 | Script-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 checksum8. 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.
[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)
Output → seed
eb448c0d8d84926ab0d5cc1ec80d4d7528e7fd08903ad5d953c091abce4e11a45f4f16d66c1dee58a2a11101eb87eac1f0d59991b1f7fece24a5051a5d71e3f02. Seed → Keypair (HD Derivation)
Input
seed
Operation
Derive keypair at
m/44'/5757'/0'/0/0
Output → keypair
Private key
753b7cc01a1a2e86221266a154af739463fce51219d97e4f856cd7200c3bd2a6Public key (compressed):
0390a5cac7c33fda49f70bc1b0866fa0ba7a9440d9de647fecb8132ceb76a94dfa3. 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.
Output → Hash 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))
Output → signer_hash
6d78de7b0625dfbfc16c3a8a5735f6dc3dc3f2ce5. Add Version Byte
Input
signer_hash
Operation
Prepend the version byte
version || signer_hash
0x1a for testnet P2PKH, 0x16 for mainnet P2PKH
Output → c32_input
Testnet (0x1a):
1a6d78de7b0625dfbfc16c3a8a5735f6dc3dc3f2ceMainnet (0x16):
166d78de7b0625dfbfc16c3a8a5735f6dc3dc3f2ce6. Compute Data Checksum
Input
c32_input
Operation
SHA256(SHA256(payload))[0:4]
Output → checksum
Testnet:
35687e14Mainnet:
18b834f87. 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
Output → c32_encoded_data
Testnet concatenated data:
1a6d78de7b0625dfbfc16c3a8a5735f6dc3dc3f2ce35687e14Testnet C32 encoded:
T1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGMMainnet concatenated data:
166d78de7b0625dfbfc16c3a8a5735f6dc3dc3f2ce18b834f8Mainnet C32 encoded:
P1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRCBGD7R8. Construct Final C32 Address
Input
c32_encoded_data
Operation
Prepend "S"
S || c32_encoded_data
Output → Principal
Testnet:
ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGMMainnet:
SP1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRCBGD7RCross-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: returnstrueif the principal's version byte matches the current networkcontract-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 principalsprincipal-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 networkprincipal-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:
(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 testnetObservations
- 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.copage 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 functionis-standardhas been added to identify whether or not aprincipalinstance 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:
(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:
(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-standardto prevent silent acceptance of testnet principals on mainnet - Remove
is-standardchecks 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-standardmay be preferred over system function errors, since calling contracts may need to handle failures gracefully
