EIP.tools

EIP.tools

⚠️ DraftStandards Track: ERC

ERC-8091: Privacy Address Format

A client-side address format specification for privacy-preserving tokens using zk-SNARK optimized cryptography

Authors
Created2025-11-24
Discussion Linkhttps://ethereum-magicians.org/t/erc-8091-privacy-address-format/26689

Markdown

https://raw.githubusercontent.com/ethereum/ERCs/re...
Pull Request#1373PR open

EIP-GPT summary

Contents
AbstractMotivationCompleting the Privacy EcosystemThe Interoperability ProblemWhy Not Use ERC-5564?Why Version Support MattersDesign PhilosophySpecificationDefinitionsAddress FormatNetwork CodesCryptographic ParametersElliptic CurvePoint CompressionBase58 EncodingChecksum CalculationClient-Side OperationsAddress GenerationAddress ParsingValidation RulesRationaleWhy Three Keys Instead of Two?Why Baby Jubjub Instead of secp256k1?Why Base58 Instead of Hex?Why Single-Character Network Codes?Why Client-Side Only?Backwards CompatibilityReference ImplementationSecurity ConsiderationsPrivate Key SecurityAddress Parsing VulnerabilitiesReplay Attacks Across NetworksEncryption Key Extended UsageCopyright

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 transfers

Scenario 2: ENS Privacy Payments

Alice wants to receive private payments at alice.eth
With pv1: alice.eth can be associated with pv1MSxxxxxxxx

Why 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:

AspectERC-5564pv1
Key Structure2 keys (viewing + spending)3 keys (spend + scan + encryption)
Permission GranularityBinary (view all or nothing)Granular (scan, decrypt, spend separately)
EncodingHex (st:eth:0x...)Base58 with checksum (pv1...)
ChecksumNoneFNV-1a (error detection)
Version SupportschemeId (on-chain routing)Prefix-based (pv1/pv2/pv3 in address)
Designed ForGeneral stealth addressesNative 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:

  1. Elliptic curve upgrades: pv1 uses Baby Jubjub; future versions can adopt different curves
  2. Post-quantum migration: When quantum-resistant cryptography matures, pv2 could use lattice-based schemes
  3. Backward compatibility: Wallets can support multiple versions simultaneously
  4. 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:

  1. Human-readable: Compact enough to share via messaging apps
  2. Error-resistant: Checksum prevents typos from losing funds
  3. Permission-granular: Three-key architecture enables selective disclosure
  4. Future-proof: Versioned format and extensible codes
  5. 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:

NetworkCodeChain IDDescription
Ethereum MainnetM1Primary deployment
Base SepoliaT84532Testing networks
PolygonP137Polygon PoS
ArbitrumA42161Arbitrum One
BaseB8453Base L2
OptimismO10Optimism L2
AvalancheV43114Avalanche C-Chain
BNB ChainS56BNB Smart Chain
GnosisG100Gnosis 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 = 2736030358979909402780800718157159386076813972158567259200215660948447373041

This 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:

  1. For point (x, y), store only x-coordinate (256 bits)
  2. Store y-coordinate parity as a single bit (odd = 1, even = 0)
  3. 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):

123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz

Checksum 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:

CheckRuleError
PrefixMust start with "pv1"Invalid prefix
LengthBetween 135 and 150 charactersInvalid length
Network codeMust be in defined setUnknown network
CharactersAll must be valid Base58Invalid character
ChecksumMust match calculated valueChecksum mismatch
PointsMust be on Baby Jubjub curveInvalid 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:

  1. Granular Permission Control (by selectively sharing private keys)

    • Share scanPrivateKey only: Detect transaction existence (audit)
    • Share scanPrivateKey + encryptionPrivateKey: Full read-only visibility (accounting)
    • Keep spendPrivateKey secure: Only owner can spend
  2. Enhanced Security

    • Compromised scan key doesn't reveal amounts
    • Compromised encryption key doesn't enable spending
    • Defense in depth
  3. Use Case Support

    • Auditors: scanPrivateKey only (knows you received, not how much)
    • Accountants: scanPrivateKey + encryptionPrivateKey (full visibility, no spending)
    • Owner: all three private keys
  4. 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?

Considerationsecp256k1Baby Jubjub
Ethereum compatibilityNativeRequires field conversion
zk-SNARK efficiencyLow (non-native field)High (native to BN254)
Proof generationSlowFast
Circuit complexityHighLow

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?

  1. Shorter addresses: ~145 chars vs ~200+ chars in hex
  2. Human-readable: Avoids confusing characters (0/O, 1/l/I)
  3. Established: Used by Bitcoin addresses
  4. Checksum-friendly: Easy to append 4-char checksum

Why Single-Character Network Codes?

  1. Compactness: Saves ~10 characters vs full names
  2. Sufficient capacity: 58 codes cover all major chains
  3. Unambiguous: No parsing needed for network
  4. Extensible: New networks can be added

Why Client-Side Only?

  1. No gas costs: Address generation is free
  2. Instant: No blockchain confirmation needed
  3. Privacy: No on-chain footprint for address creation
  4. Offline capable: Works without network connection
  5. 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 only
  • encryptionPrivateKey: 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 and related rights waived via CC0.

EIP.tools

EIP.tools

Search, read, and map Ethereum improvement proposals, ERCs, RIPs, and CAIPs from one focused interface.

Farcaster
by @apoorveth