Abstract
This EIP defines a standardized client-side privacy address format for privacy-preserving tokens on Ethereum. The format uses a versioned prefix (pv + version number) to support cryptographic evolution and future upgrades.
Privacy Version 1 (pv1) is the initial version defined by this standard, using the Baby Jubjub elliptic curve for zk-SNARK optimization. Future versions (pv2, pv3, etc.) MAY adopt different cryptographic schemes, such as post-quantum resistant algorithms.
The format is designed for privacy-preserving token protocols, including native privacy tokens, dual-mode tokens, and wrapper protocols that add privacy capabilities to existing ERC-20 tokens.
Key Characteristics:
- Client-side specification: Address generation and parsing are performed entirely off-chain without smart contract interaction
- Version support: The "pv" prefix followed by version number (pv1, pv2, pv3) allows future upgrades for new cryptographic schemes or post-quantum resistance
- Three-key architecture: Separate spend, scan, and encryption public keys for fine-grained permission control
- zk-SNARK optimization: Baby Jubjub elliptic curve for efficient zero-knowledge proof generation
- Multi-chain support: Single-character network codes supporting 58+ EVM-compatible chains
- Compact encoding: Base58 compression with FNV-1a checksum for error detection
- Wrapper protocol compatible: Applicable to wrapper protocol that adds privacy capabilities to existing ERC-20 tokens (e.g., DAI β zDAI)
By standardizing the privacy address format at the client level, this proposal enables interoperability between privacy-preserving dApps and seamless privacy asset transfers across the Ethereum ecosystem.
Motivation
Completing the Privacy Ecosystem
Privacy token protocols typically define interfaces for commitments, nullifiers, and note encryption, but leave the address format unspecified.
Without a standardized privacy address format:
- Each dApp implements custom address encoding, leading to ecosystem fragmentation
- Privacy assets cannot flow between different privacy dApps
- Users need different addresses for different protocols
- Wallets must implement custom logic for each privacy implementation
The Interoperability Problem
Consider these real-world scenarios that are currently impossible:
Scenario 1: Cross-dApp Privacy Transfers
User has privacy assets in dApp_A, wants to use them in dApp_B
Without pv1: Each dApp uses incompatible address formats
With pv1: Unified address format enables seamless transfersScenario 2: ENS Privacy Payments
Alice wants to receive private payments at alice.eth
With pv1: alice.eth can be associated with pv1MSxxxxxxxxWhy Not Use ERC-5564?
ERC-5564 (Stealth Addresses) is a valuable standard for general-purpose stealth addresses. While ERC-5564's schemeId mechanism could theoretically support different elliptic curves, the fundamental architectural differences make pv1 better suited for native privacy asset ecosystems:
| Aspect | ERC-5564 | pv1 |
|---|---|---|
| Key Structure | 2 keys (viewing + spending) | 3 keys (spend + scan + encryption) |
| Permission Granularity | Binary (view all or nothing) | Granular (scan, decrypt, spend separately) |
| Encoding | Hex (st:eth:0x...) | Base58 with checksum (pv1...) |
| Checksum | None | FNV-1a (error detection) |
| Version Support | schemeId (on-chain routing) | Prefix-based (pv1/pv2/pv3 in address) |
| Designed For | General stealth addresses | Native privacy assets (IZRC20) |
The three-key architecture is the core differentiator: it enables granular permission control by selectively sharing private keys:
- Audit-only access: Share scanPrivateKey only (can detect transactions, but not amounts)
- Accounting access: Share scanPrivateKey + encryptionPrivateKey (full read-only visibility)
- Full control: All three private keys (can spend funds)
This granularity is not possible with ERC-5564's two-key model.
For protocols using zero-knowledge proofs, pv1 uses the Baby Jubjub curve which provides better performance in zk-SNARK circuits due to its native compatibility with the BN254 scalar field.
Why Version Support Matters
The "pv" prefix stands for "Privacy Version", with "pv1" indicating version 1 of the format. This versioning enables:
- Elliptic curve upgrades: pv1 uses Baby Jubjub; future versions can adopt different curves
- Post-quantum migration: When quantum-resistant cryptography matures, pv2 could use lattice-based schemes
- Backward compatibility: Wallets can support multiple versions simultaneously
- Gradual ecosystem migration: No forced upgrades; users choose when to migrate
Design Philosophy
This standard embraces the principle: "Privacy addresses should be as usable as regular addresses."
Key design goals:
- Human-readable: Compact enough to share via messaging apps
- Error-resistant: Checksum prevents typos from losing funds
- Permission-granular: Three-key architecture enables selective disclosure
- Future-proof: Versioned format and extensible codes
- Off-chain first: No blockchain interaction required for address operations
Specification
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Definitions
- pv1 Address: A privacy address string in the format
pv[V][N][CompressedData][Checksum] - Privacy Version: The "pv" prefix followed by version number (1, 2, 3, etc.)
- Spend Public Key: Used by senders to derive stealth addresses for recipients
- Scan Public Key: Used by senders to compute shared secrets and view tags
- Encryption Public Key: Used by senders to encrypt note data for recipients
- Network Code: Single Base58 character identifying the blockchain network
- Compressed Data: Base58-encoded, point-compressed public keys
- Checksum: 4-character FNV-1a hash for error detection
Address Format
pv[V][N][CompressedData][Checksum]
ββββββββ¬ββββββββββ¬ββββββββββ¬βββββββββββββββββββ¬βββββββββββ
β pv β V β N β CompressedData β Checksum β
ββββββββΌββββββββββΌββββββββββΌβββββββββββββββββββΌβββββββββββ€
β 2 ch β 1 char β 1 char β Variable Base58 β 4 char β
βPrefixβ Version β Network β Public Keys β Base58 β
ββββββββ΄ββββββββββ΄ββββββββββ΄βββββββββββββββββββ΄βββββββββββ
- pv: Privacy Version prefix
- V: Version number (1, 2, 3, etc.) - pv1 uses Baby Jubjub curve
- N: Network code (M=mainnet, P=polygon, etc.)Network Codes
Implementations MUST support at least the following network codes:
| Network | Code | Chain ID | Description |
|---|---|---|---|
| Ethereum Mainnet | M | 1 | Primary deployment |
| Base Sepolia | T | 84532 | Testing networks |
| Polygon | P | 137 | Polygon PoS |
| Arbitrum | A | 42161 | Arbitrum One |
| Base | B | 8453 | Base L2 |
| Optimism | O | 10 | Optimism L2 |
| Avalanche | V | 43114 | Avalanche C-Chain |
| BNB Chain | S | 56 | BNB Smart Chain |
| Gnosis | G | 100 | Gnosis Chain |
Additional network codes MAY be defined. The Base58 character set allows up to 58 unique network codes.
Cryptographic Parameters
Elliptic Curve
For pv1, implementations MUST use the Baby Jubjub curve defined over the BN254 scalar field:
Curve: Twisted Edwards
Equation: axΒ² + yΒ² = 1 + dxΒ²yΒ²
Parameters:
a = 168700
d = 168696
p = 21888242871839275222246405745257275088548364400416034343698204186575808495617
Subgroup order (for scalars):
l = 2736030358979909402780800718157159386076813972158567259200215660948447373041This curve has cofactor 8. Implementations using circomlibjs SHOULD use babyJub.Base8 as the generator point, which generates the prime-order subgroup of l points and avoids small subgroup attacks.
This curve is compatible with circomlib and other zk-SNARK tooling.
Future versions (pv2, pv3, etc.) MAY adopt different elliptic curves as cryptographic needs evolve, such as post-quantum resistant curves or curves optimized for different proof systems.
Point Compression
Public keys MUST be compressed using the following algorithm:
- For point (x, y), store only x-coordinate (256 bits)
- Store y-coordinate parity as a single bit (odd = 1, even = 0)
- Pack three keys with parity flags into a single integer:
packedData = (flags << 768) | (spendX << 512) | (scanX << 256) | encryptionX
Where flags = (spendIsOdd ? 1 : 0) | (scanIsOdd ? 2 : 0) | (encryptionIsOdd ? 4 : 0)Base58 Encoding
Implementations MUST use the Bitcoin Base58 alphabet (excludes 0, O, I, l):
123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyzChecksum Calculation
Implementations MUST use FNV-1a hash for checksum:
function calculateChecksum(data) {
let hash = 0x811c9dc5; // FNV-1a initial value
for (let i = 0; i < data.length; i++) {
hash ^= data.charCodeAt(i);
hash = (hash * 0x01000193) >>> 0; // FNV-1a prime
}
// Clamp to 4-character Base58 range
const maxHash = 58**4 - 1;
return toBase58(hash % maxHash, 4);
}Client-Side Operations
All pv1 address operations are performed client-side without blockchain interaction:
Address Generation
function generatePv1Address(spendPubKey, scanPubKey, encryptPubKey, network) {
// 1. Validate inputs
validateNetwork(network);
validatePoint(spendPubKey);
validatePoint(scanPubKey);
validatePoint(encryptPubKey);
// 2. Compress public keys
const compressedData = compressPublicKeys(spendPubKey, scanPubKey, encryptPubKey);
// 3. Assemble address
const networkCode = NETWORK_CODES[network];
const baseData = `pv1${networkCode}${compressedData}`;
// 4. Calculate checksum
const checksum = calculateChecksum(baseData);
return baseData + checksum;
}Address Parsing
function parsePv1Address(pv1Address) {
// 1. Validate prefix and length
if (!pv1Address.startsWith('pv1') || pv1Address.length < 10) {
throw new Error('Invalid pv1 address format');
}
// 2. Extract components
const networkCode = pv1Address[3];
const checksum = pv1Address.slice(-4);
const compressedData = pv1Address.slice(4, -4);
// 3. Verify checksum
const baseData = pv1Address.slice(0, -4);
const expectedChecksum = calculateChecksum(baseData);
if (checksum !== expectedChecksum) {
throw new Error('Invalid checksum');
}
// 4. Validate network code
if (!REVERSE_NETWORK_CODES[networkCode]) {
throw new Error('Invalid network code');
}
// 5. Decompress public keys
const publicKeys = decompressPublicKeys(compressedData);
return {
version: 1,
network: REVERSE_NETWORK_CODES[networkCode],
...publicKeys
};
}Validation Rules
Implementations MUST validate pv1 addresses according to these rules:
| Check | Rule | Error |
|---|---|---|
| Prefix | Must start with "pv1" | Invalid prefix |
| Length | Between 135 and 150 characters | Invalid length |
| Network code | Must be in defined set | Unknown network |
| Characters | All must be valid Base58 | Invalid character |
| Checksum | Must match calculated value | Checksum mismatch |
| Points | Must be on Baby Jubjub curve | Invalid point |
Rationale
Why Three Keys Instead of Two?
ERC-5564 uses two keys (viewing + spending). We add a third key for encryption:
Benefits of three-key architecture:
-
Granular Permission Control (by selectively sharing private keys)
- Share
scanPrivateKeyonly: Detect transaction existence (audit) - Share
scanPrivateKey+encryptionPrivateKey: Full read-only visibility (accounting) - Keep
spendPrivateKeysecure: Only owner can spend
- Share
-
Enhanced Security
- Compromised scan key doesn't reveal amounts
- Compromised encryption key doesn't enable spending
- Defense in depth
-
Use Case Support
- Auditors: scanPrivateKey only (knows you received, not how much)
- Accountants: scanPrivateKey + encryptionPrivateKey (full visibility, no spending)
- Owner: all three private keys
-
Potential Extended Use Cases
The encryption public key, being stable and publicly accessible as part of the pv-address, could potentially support use cases beyond note encryption:
- Private messaging: Direct encrypted communication between users
- Encrypted metadata: Payment references, invoice numbers, or custom notes attached to transactions
- Cross-protocol notifications: Privacy-preserving alerts like "payment received" or "note available"
- Wallet-to-wallet communication: Encrypted payment requests, refund addresses, or coordination messages
This standard does not restrict how implementations utilize the encryption key. Developers are free to innovate and extend its usage beyond the core privacy token protocol, enabling richer privacy-preserving applications.
Why Baby Jubjub Instead of secp256k1?
| Consideration | secp256k1 | Baby Jubjub |
|---|---|---|
| Ethereum compatibility | Native | Requires field conversion |
| zk-SNARK efficiency | Low (non-native field) | High (native to BN254) |
| Proof generation | Slow | Fast |
| Circuit complexity | High | Low |
For privacy protocols using zk-SNARKs, the significant performance improvement justifies the additional complexity of using a non-native curve.
Why Base58 Instead of Hex?
- Shorter addresses: ~145 chars vs ~200+ chars in hex
- Human-readable: Avoids confusing characters (0/O, 1/l/I)
- Established: Used by Bitcoin addresses
- Checksum-friendly: Easy to append 4-char checksum
Why Single-Character Network Codes?
- Compactness: Saves ~10 characters vs full names
- Sufficient capacity: 58 codes cover all major chains
- Unambiguous: No parsing needed for network
- Extensible: New networks can be added
Why Client-Side Only?
- No gas costs: Address generation is free
- Instant: No blockchain confirmation needed
- Privacy: No on-chain footprint for address creation
- Offline capable: Works without network connection
- Simplicity: No smart contract deployment or interaction needed
Backwards Compatibility
This standard introduces a new client-side address format and does not modify existing standards or require smart contract changes.
Reference Implementation
A reference implementation in JavaScript:
class Pv1AddressManager {
constructor() {
this.version = 1;
this.prefix = "pv1";
this.base58Chars = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
this.networkCodes = {
'mainnet': 'M', 'testnet': 'T', 'polygon': 'P',
'arbitrum': 'A', 'base': 'B', 'optimism': 'O',
'avalanche': 'V', 'bsc': 'S', 'fantom': 'F', 'gnosis': 'G'
};
this.reverseNetworkCodes = Object.fromEntries(
Object.entries(this.networkCodes).map(([k, v]) => [v, k])
);
}
generatePv1Address(spendPubKey, scanPubKey, encryptPubKey, network) {
// Validate inputs
if (!this.networkCodes[network]) throw new Error(`Invalid network: ${network}`);
// Compress public keys
const compressedData = this._compressPublicKeys(spendPubKey, scanPubKey, encryptPubKey);
// Assemble address
const networkCode = this.networkCodes[network];
const baseData = `${this.prefix}${networkCode}${compressedData}`;
// Calculate checksum
const checksum = this._calculateChecksum(baseData);
return baseData + checksum;
}
parsePv1Address(pv1Address) {
// Validate format
if (!pv1Address.startsWith('pv1') || pv1Address.length < 135) {
throw new Error('Invalid pv1 address format');
}
// Extract components
const networkCode = pv1Address[3];
const checksum = pv1Address.slice(-4);
const compressedData = pv1Address.slice(4, -4);
// Verify checksum
const baseData = pv1Address.slice(0, -4);
const expectedChecksum = this._calculateChecksum(baseData);
if (checksum !== expectedChecksum) {
throw new Error(`Invalid checksum: expected ${expectedChecksum}, got ${checksum}`);
}
// Validate network code
const network = this.reverseNetworkCodes[networkCode];
if (!network) throw new Error(`Invalid network code: ${networkCode}`);
// Decompress public keys
const publicKeys = this._decompressPublicKeys(compressedData);
return {
version: this.version,
network,
...publicKeys
};
}
_compressPublicKeys(spendPubKey, scanPubKey, encryptPubKey) {
const compress = (point) => ({
x: BigInt(point[0]),
isOdd: BigInt(point[1]) % 2n === 1n
});
const spend = compress(spendPubKey);
const scan = compress(scanPubKey);
const encrypt = compress(encryptPubKey);
const flags = (spend.isOdd ? 1 : 0) | (scan.isOdd ? 2 : 0) | (encrypt.isOdd ? 4 : 0);
const packedData = (BigInt(flags) << 768n) |
(spend.x << 512n) |
(scan.x << 256n) |
encrypt.x;
return this._bigIntToBase58(packedData);
}
_decompressPublicKeys(compressedBase58) {
const packedData = this._base58ToBigInt(compressedBase58);
const flags = Number(packedData >> 768n);
const spendX = (packedData >> 512n) & ((1n << 256n) - 1n);
const scanX = (packedData >> 256n) & ((1n << 256n) - 1n);
const encryptX = packedData & ((1n << 256n) - 1n);
return {
spendPublicKey: [spendX.toString(), this._recoverY(spendX, (flags & 1) !== 0).toString()],
scanPublicKey: [scanX.toString(), this._recoverY(scanX, (flags & 2) !== 0).toString()],
encryptionPublicKey: [encryptX.toString(), this._recoverY(encryptX, (flags & 4) !== 0).toString()]
};
}
_recoverY(x, isOdd) {
// Baby Jubjub curve parameters
const p = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
const a = 168700n;
const d = 168696n;
// Edwards curve: axΒ² + yΒ² = 1 + dxΒ²yΒ²
// Solve for yΒ²: yΒ² = (1 - axΒ²) / (1 - dxΒ²)
const x2 = (x * x) % p;
const numerator = (1n + p - (a * x2) % p) % p;
const denominator = (1n + p - (d * x2) % p) % p;
const denominatorInv = this._modPow(denominator, p - 2n, p);
const y2 = (numerator * denominatorInv) % p;
let y = this._tonelliShanks(y2, p);
// Adjust parity
if (((y & 1n) === 1n) !== isOdd) {
y = p - y;
}
return y;
}
_calculateChecksum(data) {
let hash = 0x811c9dc5;
for (let i = 0; i < data.length; i++) {
hash ^= data.charCodeAt(i);
hash = (hash * 0x01000193) >>> 0;
}
return this._numberToBase58(hash % (58**4 - 1), 4);
}
_bigIntToBase58(bigint) {
if (bigint === 0n) return this.base58Chars[0];
let result = '';
while (bigint > 0n) {
result = this.base58Chars[Number(bigint % 58n)] + result;
bigint = bigint / 58n;
}
return result;
}
_base58ToBigInt(encoded) {
let result = 0n;
for (const char of encoded) {
const digit = this.base58Chars.indexOf(char);
if (digit === -1) throw new Error(`Invalid Base58 character: ${char}`);
result = result * 58n + BigInt(digit);
}
return result;
}
_numberToBase58(number, length) {
let result = '';
while (number > 0) {
result = this.base58Chars[number % 58] + result;
number = Math.floor(number / 58);
}
return result.padStart(length, this.base58Chars[0]);
}
_modPow(base, exp, mod) {
let result = 1n;
base = base % mod;
while (exp > 0n) {
if (exp % 2n === 1n) result = (result * base) % mod;
exp = exp / 2n;
base = (base * base) % mod;
}
return result;
}
_tonelliShanks(n, p) {
// Tonelli-Shanks algorithm for modular square root
// Full implementation in reference code repository
if (this._modPow(n, (p - 1n) / 2n, p) !== 1n) {
throw new Error('No square root exists');
}
let Q = p - 1n;
let S = 0n;
while (Q % 2n === 0n) {
Q /= 2n;
S++;
}
if (S === 1n) {
return this._modPow(n, (p + 1n) / 4n, p);
}
let z = 2n;
while (this._modPow(z, (p - 1n) / 2n, p) !== p - 1n) {
z++;
}
let M = S;
let c = this._modPow(z, Q, p);
let t = this._modPow(n, Q, p);
let R = this._modPow(n, (Q + 1n) / 2n, p);
while (t !== 1n) {
let i = 0n;
let temp = t;
while (temp !== 1n) {
temp = (temp * temp) % p;
i++;
}
let b = this._modPow(c, this._modPow(2n, M - i - 1n, p - 1n), p);
M = i;
c = (b * b) % p;
t = (t * c) % p;
R = (R * b) % p;
}
return R;
}
}Security Considerations
Private Key Security
Attack Vector: Compromise of private keys enables unauthorized spending or surveillance.
Mitigation:
spendPrivateKey: MUST be stored with highest security (hardware wallet recommended)scanPrivateKey: Can be shared with trusted parties for monitoring onlyencryptionPrivateKey: Compromise reveals amounts but not spending capability
Recommendation: Use hierarchical deterministic (HD) key derivation to generate all three keys from a single master seed.
Address Parsing Vulnerabilities
Attack Vector: Malformed addresses could cause parsing errors or incorrect key extraction.
Mitigation:
- MUST validate checksum before processing
- MUST verify extracted points are on the Baby Jubjub curve
- MUST reject addresses with invalid network codes
- SHOULD implement length bounds checking
Replay Attacks Across Networks
Attack Vector: A pv1 address generated for one network could be used on another.
Mitigation:
- Network code is embedded in the address
- Implementations MUST verify the network code matches the current chain
- Client applications SHOULD warn users of network mismatches
Encryption Key Extended Usage
While this standard defines the encryption public key primarily for encrypting note data in privacy token protocols, implementations MAY use it for additional purposes such as private messaging, encrypted metadata, or other privacy-preserving communications.
Privacy Considerations for Extended Usage:
- Linkability: Using the same encryption key for multiple purposes (e.g., both transactions and messaging) may create linkability between different types of activities associated with the same pv-address
- Key Compromise Impact: If the encryption private key is compromised, it affects all encrypted data across all use cases, not just transaction notes
- Use Case Evaluation: Implementations SHOULD evaluate privacy implications specific to their use case when extending encryption key usage beyond note encryption
The decision to use the encryption key for extended purposes is left to individual implementations. This flexibility enables innovation while maintaining the core address format standard.
Copyright
Copyright and related rights waived via CC0.
