Introduction

On September 15th, Anza engaged zkSecurity to review its implementation of confidential transfers, as part of the Token-2022 standard. Two consultants spent 4 weeks to review the full stack, from the extension logic added to the Token-2022 program, down to the zero-knowledge proofs implementations.

The code was found to be extremely well-documented, thoroughly specified, as well as written in a heavily defensive style. The audit did not find any serious vulnerabilities. The Anza team was also exceptionally collaborative and responsive during the audit.

Scope

The scope focused on parts of the following three repositories:

zk-sdk and ZK ElGamal Proof Program. The solana-program/zk-elgamal-proof repository contains implementation for all proof systems (an inner-product argument (IPA) implementation using bulletproofs, a range proof implementation from the bulletproofs paper, and a number of sigma proofs) as well as the zk elgamal proof Solana program. We audited the codebase at commit 703da254d7891aeafe085ce343b5048f80886a41.

ZK ElGamal Proof Built-In Program. We focused on the the ZK ElGamal Proof Program contained in the agave node codebase, which wraps and deploys the zk-elgamal-proof program implemented in the previous zk-elgamal-proof repository as a built-in program on the Solana blockchain. The audit was focused on commit 125487843ea46f3543683ac9a92a08cf5a7ce16c.

Confidential Transfers as an Extension to the Token-2022 Solana Program. The logic for confidential transfers in Solana was implemented as part of the solana-program/token-2022 repository, where low-level helpers, client-side logic, as well as a helper Solana program “elgamal registry” were segregated in a confidential-transfer/ folder, and the main confidential transfer logic was implemented as three main extensions in the program/src/extension/confidential_*/ folders. The audit used commit 20a9e87885dec4935278fe6dfe4bc4d435a09f6b.

Strategic Recommendations

We recommend addressing the following high-level issues:

Proofs APIs Are Insecure As Standalone Functions. All the proof APIs, including the bulletproofs, range proof, and sigma proofs APIs are insecure if used as standalone primitives. While this is not an issue in the current way they are used in the overall Token-2022 confidential transfer protocol, this could become an issue if used in other contexts. The insecurity of these APIs mostly stems from the fact that the verification logic is split across the SDK, the ZK ElGamal program and the confidential extensions to the Token-2022 program. Looking at the SDK as a standalone crate, the two main consequences of having split verification logic are that the public inputs are not properly constrained or computed (see Verifier Should Compute Expected Instance of the Fee Proof By Themselves and Unconstrained Fee in Standalone percentage_with_cap Proof) and the Fiat-Shamir implementations do not properly absorb instance parameters (see No Proofs Handle Fiat-Shamir Correctly In Standalone Versions, Fiat-Shamir Issue Leads To Colliding Transcripts In Range Proof and Fiat-Shamir of Public Parameters).

Unnecessary Determinism in Batch Verification. As pointed out in Unnecessary Deterministic Randomness in Batch Verification, the use of deterministic randomness (Fiat-Shamir style) has already been the source of two bugs, in exchange for debatable speed gains. Considering this, we recommend reconsidering whether batching verifier checks is necessary at all.

Code Duplication Is Error Prone. A lot of the Token-2022 confidential extensions logic involves code duplication across many (sub)instructions, which makes the code difficult to audit with respect to its global (implied) state machine. In Typed Abstraction Could Go a Long Way we ponder on the usefulness of typed abstractions, and on the trade-offs of refactoring at this point in time.

Solana Confidential Transfer Overview

The zk-sdk is the foundation of the confidential transfer protocol, as it provides the low-level zero-knowledge proof primitives. Previously part of the agave repository, it now exists as a standalone repository. The repository implements Authenticated Encryption (via AES-GCM-SIV), twisted ElGamal encryption, a number of zero-knowledge proof systems, as well as the building blocks for exposing all of these in a Solana program.

The implementation uses the Ristretto group (and the official dalek implementation), built on top of Curve25519. The abstraction provides a prime-order group while the implementation provides a safe-to-use API.

To implement Fiat-Shamir in the different proof systems, the codebase relies on the Merlin transcript framework, which builds on top of the Strobe protocol in order to safely absorb prover messages and the shape of the transcript in a duplex construction.

The Twisted ElGamal Encryption Scheme

Balances are encrypted using the twisted ElGamal encryption scheme. Since the scheme is additively homomorphic, balances and transfer amounts can be encrypted, and added/subtracted by the on-chain program.

To generate a keypair, a peer produces a random secret key s and a public key P=s1H where H is a known generator.

To asymmetrically encrypt a message x to a peer’s public key P, the sender computes a ciphertext as the pair (where G is a known generator as well):

C=xG+rH,D=rP

Finally, to decrypt, the recipient can compute and subtract rH from C (without learning r) by computing

sD=srP=srs1H=rH

From there on, the recipient still has to figure out the discrete logarithm of xG, which they can do with the usual discrete logarithm algorithm if x is small.

In Solana’s confidential transfer scheme, a few things are done to make this design realistic:

  • a token’s total supply is capped to a 64-bit value, and amounts (as well as some balances) are split between the lower 16-bit and the higher 32-bit (capped at 48 bits), allowing for easier solving of the discrete logarithm.
  • a pending balance is used to accumulate transfers to an account, allowing the account to produce transfer proofs without having their proof being invalidated (as someone being able to modify their actual balance would invalidate the proof), and separating the management of the main balance from the decryption of transfers received.
  • zero-knowledge proofs, including sigma protocols and range proofs, are used in a number of places to ensure that amounts are correct, or that balances are correctly added or subtracted to.

Furthermore, because a recipient never learns the random value r, many of the sigma proofs are used to prove correct re-encryption of encrypted values, where the new r value used in the re-encryption is known, allowing the range proofs to be created.

Sigma Protocols Overview

Sigma protocols (sometimes “Σ-protocols”) are a class of interactive zero-knowledge proofs. They are characterized by having 1 round (3 moves): the prover first sends a commitment, the verifier sends a challenge, and finally the prover responds with an opening. In comparison to zkSNARKs, Sigma protocols are not succinct; however, they are rarely applied to large problems and can be more performant than zkSNARKs for small, specialized tasks. As we will see below, Sigma protocols are particularly well-suited for proving statements about Pedersen commitments (and therefore Twisted ElGamal ciphertexts).

FS transform. These protocols can be made into non-interactive protocols using the Fiat-Shamir (FS) transform. In this section we give a reminder of the necessary security properties and outline how all the protocols used meet these.

Security properties

Necessary properties. For the overall system to be secure and confidential, it is necessary that the non-interactive proofs (resulting from the FS transform) have knowledge soundness and zero-knowledge. To guarantee these properties, we must analyze the security properties of the interactive protocols and ensure that the FS transform and non-interactive threat model do not incur a large security degradation.

Proving method. The standard way to show that the non-interactive proof is knowledge sound is to show that the interactive protocol has special soundness. To show that the non-interactive proof is zero-knowledge, it is enough to show that the interactive proof is honest-verifier zero-knowledge (HVZK). In some cases (e.g., the OR composition), it will be helpful to show a slight variant known as special HVZK.

Sigma protocol for pre-image of a homomorphism

All the Sigma protocols in zk-sdk are instantiations of a more generic Sigma protocol to prove knowledge of the pre-image of a homomorphism. We refer readers to A Graduate Course in Applied Cryptography (Boneh and Shoup), chapter 19.5.4 for further details on this protocol. For convenience and as a means to introduce notation, we include the relevant excerpt below.

Test

Security of zk-sdk Sigma protocols

To prove security of the Sigma protocols in zk-sdk it suffices to show how these protocols map to the generic pre-image protocol above. In all cases it is easy to check that ψ is a group homomorphism and that the conditions are met to apply Theorem 19.12 of Boneh and Shoup.

Public key validity

Given a public key P, the public key validity protocol proves knowledge of a secret key s such that ψ(s1)=P where the groups 1, 2, the homomorphism ψ:12 and challenge set 𝒞 are defined as:

1:=p,2:=𝔾,ψ(s1):=s1·H,𝒞:=p.

Note that ψ is defined with respect to the system parameter H.

Zero check

Given a system parameter H and a Pedersen commitment C, the zero check protocol proves knowledge of a secret key s such that ψ(s)=(H,C) where the groups 1, 2, the homomorphism ψ:12 and challenge set 𝒞 are defined as:

1:=p,2:=𝔾2,ψ(s):=(s·P,s·D),𝒞:=p.

Note that ψ is defined with respect to the public key P and decryption handle D.

Grouped ciphertext validity

Given a Pedersen commitment C and decryption handles D1,,D, the grouped ciphertext validity protocol proves knowledge of a message x and opening r such that ψ(r,x)=(C,[D]i=1) where the groups 1, 2, the homomorphism ψ:12 and challenge set 𝒞 are defined as:

1:=(p)2,2:=𝔾+1,ψ(r,x):=(r·H+x·G,[r·Pi]i=1),𝒞:=p.

Note that ψ is defined with respect to the system parameters G,H and the public keys P1,,P.

Batched version of the Grouped ciphertext validity

Later on in the confidential payment protocol, the system handles commitments that have been split into two parts Clo and Chi. We can reuse the previous sigma proof by using by considering the lo and hi ciphertexts as two separate instances of the grouped ciphertext validity proof.

Instead of proving and verifying them individually, the zk-sdk batches the instances using a verifier challenge. Concretely, we set C=Clo+t·Chi=xloG+rloH+t·(xhiG+rhiH) where t is a verifier challenge (sampled after observing the independent commitments), and using the same aggregation trick on the decryption handles as well Di=Dlo,i+t·Dhi,i=rloPi+t·rhiPi.

The values C and Di are then proven using the grouped ciphertext validity proof as above. We discuss the security implications of this type of batching in A note on batching.

Ciphertext-commitment equality

Given a system parameter H and Pedersen commitments CEG,CPed, the ciphertext-commitment equality protocol proves knowledge of a secret key s, a message x and an opening r such that ψ(s,x,r)=(H,CEG,CPed) where the groups 1, 2, the homomorphism ψ:12 and challenge set 𝒞 are defined as:

1:=(p)3,2:=𝔾3,ψ(s,x,r):=(s·PEG,x·G+s·DEG,x·G+r·H),𝒞:=p.

Note that ψ is defined with respect to the system parameters G,H, the public key PEG and the decryption handle DEG.

Ciphertext-ciphertext equality

Given a system parameter H, Pedersen commitments C0,C1 and a decryption handle D0, the ciphertext-commitment equality protocol proves knowledge of a secret key s, a message x and an opening r such that ψ(s,x,r)=(H,C0,C1,D1) where the groups 1, 2, the homomorphism ψ:12 and challenge set 𝒞 are defined as:

1:=(p)3,2:=𝔾4,ψ(s,x,r):=(s·P0,x·G+s·D0,x·G+r·H,r·P1),𝒞:=p.

Note that ψ is defined with respect to the system parameters G,H, the public keys P0,P1 and the decryption handle D0.

Percentage with cap

The percentage with cap protocol is a composition of two Sigma protocols, where the verifier asserts that the prover knew a satisfying witness to either instance. We once again refer readers to Boneh and Shoup chapter 19.7.2 for further exposition. Special-soundness and special HVZK of the OR-composition follows from special-soundness and special HVZK of the individual components.

Fee is a percentage. Given a fee commitment Cfee, an amount commitment Camt, a basis point parameter bp and a claim Cclaimed, the first branch of the percentage with cap protocol defines

Cdelta:=Cfee·10000Camt·bp,

and proves knowledge of a message x and openings rdelta,rclaimed such that ψ(x,rdelta,rclaimed)=(Cdelta,Cclaimed) where the groups 1, 2, the homomorphism ψ:12 and challenge set 𝒞 are defined as:

1:=(p)3,2:=𝔾2,ψ(x,rdelta,rclaimed):=(x·G+rdelta·H,x·G+rclaimed·H),𝒞:=p.

Note that ψ is defined with respect to the system parameters G,H.

Fee is capped. Given a fee commitment Cfee, a maximum fee maxfee and a system parameter G, the second branch of the percentage with cap protocol defines D:=Cfeemaxfee·G and proves knowledge of an opening r such that ψ(r)=D where the groups 1, 2, the homomorphism ψ:12 and challenge set 𝒞 are defined as:

1:=p,2:=𝔾,ψ(r):=r·H,𝒞:=p.

Note that ψ is defined with respect to the system parameters H.

A note on batching

It is a common strategy to reduce proof size or verifier work to employ randomized batching, either of instances or of the verifier’s equations. The zk-sdk codebase employs both forms: it batches instances of the grouped ciphertext validity proof (see Batched version of the grouped ciphertext validity proof) and batches verifier checks for all sigma protocols that assert more than one equality.

In both cases, the resulting interactive protocol has one additional round and requires its own analysis. As noted by Attema, Fehr and Resch, these batching techniques do not always result in special-sound protocols (in the strict sense that the extractor must always succeed). Analyzing the resulting protocol and its behavior under the FS transform requires stronger tools than the ones used above. A formal security analysis is outside the scope of this report. Nonetheless, these techniques are widely deployed and we do not foresee security vulnerabilities associated with their application.

Range Proof Overview

A range proof is a protocol to show that a committed value v is in the range [0,max). The confidential payments system uses this to ensure that balances, fee and transfer amounts are correctly capped. We give an overview of the concrete range proof protocol implemented in the zk-sdk.

At a high level, the protocol works by showing that the binary decomposition of v has a fixed length. Proving such a statement immediately gives a range proof when the value max is a power of 2. Our overview will first focus on this simple case. We then show how the protocol can be used for maxima that are not powers of two.

Generators Disambiguation

Note that in this section we use different generators, which we attempt to disambiguate here.

Pedersen Commitments. Pedersen commitments are obtained from a value x and blinding factor/opening r by computing xG+rH, with generator G (B in the Dalek library) and the blinding point H (resp. B~). The base point G is the standard Curve25519 base point chosen as the point with coordinate x=9:

/// The Ed25519 basepoint, as an `EdwardsPoint`.
///
/// This is called `_POINT` to distinguish it from
/// `ED25519_BASEPOINT_TABLE`, which should be used for scalar
/// multiplication (it's much faster).
pub const ED25519_BASEPOINT_POINT: EdwardsPoint = EdwardsPoint {
    X: FieldElement51::from_limbs([
        1738742601995546,
        1146398526822698,
        2070867633025821,
        562264141797630,
        587772402128613,
    ]),
    Y: FieldElement51::from_limbs([
        1801439850948184,
        1351079888211148,
        450359962737049,
        900719925474099,
        1801439850948198,
    ]),
    Z: FieldElement51::from_limbs([1, 0, 0, 0, 0]),
    T: FieldElement51::from_limbs([
        1841354044333475,
        16398895984059,
        755974180946558,
        900171276175154,
        1821297809914039,
    ]),
};

// ...

/// The Ristretto basepoint, as a `RistrettoPoint`.
///
/// This is called `_POINT` to distinguish it from `_TABLE`, which
/// provides fast scalar multiplication.
pub const RISTRETTO_BASEPOINT_POINT: RistrettoPoint = RistrettoPoint(ED25519_BASEPOINT_POINT);

// ...

/// Pedersen base point for encoding messages to be committed.
pub const G: RistrettoPoint = RISTRETTO_BASEPOINT_POINT;

while the point H is derived via another hash-to-curve based on a hash of the standard Ristretto basepoint:

/// The Ristretto basepoint, in `CompressedRistretto` format.
pub const RISTRETTO_BASEPOINT_COMPRESSED: CompressedRistretto = CompressedRistretto([
    0xe2, 0xf2, 0xae, 0x0a, 0x6a, 0xbc, 0x4e, 0x71, 0xa8, 0x84, 0xa9, 0x61, 0xc5, 0x00, 0x51, 0x5f,
    0x58, 0xe3, 0x0b, 0x6a, 0xa5, 0x82, 0xdd, 0x8d, 0xb6, 0xa6, 0x59, 0x45, 0xe0, 0x8d, 0x2d, 0x76,
]);

/// Pedersen base point for encoding the commitment openings.
pub static H: std::sync::LazyLock<RistrettoPoint> = std::sync::LazyLock::new(|| {
    RistrettoPoint::hash_from_bytes::<Sha3_512>(RISTRETTO_BASEPOINT_COMPRESSED.as_bytes())
});

IPA’s output point Q. The IPA protocol produces a randomized basepoint to aggregate the result of the inner product into the commitment to the inputs of the inner product. This point Q is computed as Q=wG (or wB in the Dalek documentation) with a random challenge w.

IPA generators 𝐇IPA. The IPA protocol has a second set of generators in order to commit (in a non-hiding way) to the inputs of the inner product. The left input vector uses the vector of points 𝐆IPA, while the right input vector uses the generators 𝐇IPA. All of these generators are independent and computed using a hash-to-curve algorithm.

IPA generators in the range proof protocol. As part of the range proof protocol, the base 𝐇IPA is not used as is to commit to the second input of the inner product; instead the protocol uses the bases H which are formed from shifts with the vector yn of powers of y: H=yn𝐇IPA.

Aggregating Range Proofs Into a Single Check

The implementation aggregates multiple range proofs into a single proof. This aggregation technique is explained in section 4.3 “Aggregating Logarithmic Proofs” of the Bulletproofs paper. Since the implementation is built on top of the Dalek implementation, we reexplain the aggregation following Dalek’s notation.

As a reminder, the vanilla equation to prove that one value v is in the range [0,2n) is the following:

z2v=z2𝐚L,2n+z𝐚L1𝐚R,𝐲n+𝐚L,𝐚R𝐲n

In the aggregated case, we prove m values v0,,vm1 with bit-lengths n0,,nm1. Each has a bit vector 𝐚L(i){0,1}ni and 𝐚R(i)=𝐚L(i)1.

The heart of the change is rather straightforward: instead of looking at different series of bits and independently performing the two bit-related constraints on them (check that 𝐚R is computed correctly, check that each vector entry is 0 or 1), we can simply concatenate the vectors and check them as a single longer vector:

  • 𝐚L=𝐚L(0)𝐚L(m1)
  • 𝐚R=𝐚R(0)𝐚R(m1)

with total length N=ini.

We can similarly extend the vector 𝐲N to cover the whole concatenated vector. The batched check becomes

i=0m1zi+2vi=i=0m1zi+2𝐚L(i),2ni+z𝐚L1𝐚R,𝐲N+𝐚L,𝐚R𝐲N.

Define the blockwise offset vector

𝐙=(z22n0)(z32n1)(zm+12nm1),

so that

i=0m1zi+2𝐚L(i),2ni=𝐚L,𝐙.

The check then becomes

i=0m1zi+2vi+z1,𝐲N=𝐚L,𝐙+z𝐲Nz𝐚R,𝐲N+𝐚L,𝐚R𝐲N.

Add z1,𝐙+z𝐲N to both sides to complete the square:

i=0m1zi+2vi+(zz2)1,𝐲Ni=0m1zi+31,2ni=𝐚Lz1,𝐲N(𝐚R+z1)+𝐙.

Thus the unblinded vectors are

𝐥=𝐚Lz1,𝐫=𝐲N(𝐚R+z1)+(z22n0zm+12nm1),

with

𝐥,𝐫=i=0m1zi+2vi+(zz2)1,𝐲Ni=0m1zi+31,2ni.

Finally, to make it zero-knowledge, sample blinding vectors 𝐬L,𝐬R and define

𝐥(x)=(𝐚Lz1)+𝐬Lx,𝐫(x)=𝐲N(𝐚R+z1+𝐬Rx)+(z22n0zm+12nm1).

You can see that in the implementation:

// 4. Construct the blinded vector polynomials l(x) and r(x).
//    l(x) = (a_L - z*1) + s_L*x
//    r(x) = y^nm o (a_R + z*1 + s_R*x) + (z^2*2^n_1 || ... || z^{m+1}*2^n_m)
//    where `o` is the Hadamard product and `||` is vector concatenation.
let mut l_poly = util::VecPoly1::zero(nm);
let mut r_poly = util::VecPoly1::zero(nm);

let mut i = 0;
let mut exp_z = z * z;
let mut exp_y = Scalar::ONE;

for (amount_i, n_i) in amounts.iter().zip(bit_lengths.iter()) {
    let mut exp_2 = Scalar::ONE;

    for j in 0..(*n_i) {
        // `j` is guaranteed to be at most `u64::BITS` (a 6-bit number) and therefore,
        // casting is lossless and right shift can be safely unwrapped
        let a_L_j = Scalar::from(amount_i.checked_shr(j as u32).unwrap() & 1);
        let a_R_j = a_L_j - Scalar::ONE;

        l_poly.0[i] = a_L_j - z;
        l_poly.1[i] = s_L[i];
        r_poly.0[i] = exp_y * (a_R_j + z) + exp_z * exp_2;
        r_poly.1[i] = exp_y * s_R[i];

        exp_y *= y;
        exp_2 = exp_2 + exp_2;

        // `i` is capped by the sum of vectors in `bit_lengths`
        i = i.checked_add(1).unwrap();
    }
    exp_z *= z;
}

Merging The Range Proof With IPA

In the Dalek range-proof write-up (https://doc-internal.dalek.rs/bulletproofs/notes/range_proof/index.html#proving-that-mathbflx-mathbfrx-are-correct), the verifier constructs the IPA input as:

P=e~B~+A+xS+z𝐲N+𝐙,Hz1,𝐆,

which is algebraically the same as 𝐥(x),𝐆+𝐫(x),H for the aggregated case (with the blockwise offset 𝐙).

The prover also provides the scalar evaluation t(x), which presumably represents the result of the inner product.

Everything is evaluated at a challenge x: the vectors are polynomials in x but the IPA itself runs on the evaluated vectors 𝐥(x),𝐫(x) (i.e., after instantiating x via Fiat–Shamir). With these in hand, the verifier runs the standard IPA verification as we describe here:

1. We have the IPA input

P=(x),𝐆+r(x),H.

2. Add the claimed inner-product output:

P+t(x)Q=(x),𝐆+r(x),H+(x),r(x)Q.

3. Add the IPA folding terms (one round per i, with transcript-derived ui) to produce a reduced commitment of the statement:

P+t(x)Q+i(ui2Li+ui2Ri)=,G+r,H+,rQ,

where after all folds we are down to scalars =a, r=b and the reduced bases G,H are known transcript-dependent combinations of the original bases (think G=𝐬,𝐆, H=𝐬1,H for challenge-derived weights 𝐬).

4. Conclude with the reduced opening (the prover reveals a,b):

Pcommitment input+t(x)Qcommitment output+i(ui2Li+ui2Ri)term to produce the reduction=aG+bH+abQreduced form

Now we substitute the concrete form of P from the aggregated range proof:

P=eblindH+A+xS+z𝐲N+𝐙,Hz1,𝐆.

After substitution, the verifier actually checks:

(eblindH+A+xS+z𝐲N+𝐙,Hz1,𝐆)+t(x)Q+i(ui2Li+ui2Ri)=aG+bH+abQ.

On the left, we’ve replaced P with its concrete commitment built from (A,S,eblind,z,y,𝐙), and added t(x)Q plus the IPA folding terms. On the right, the verifier expects the reduced opening: the prover’s revealed scalars a,b multiplied with the reduced bases G,H, plus abQ.

Final Check of the Implemented Protocol

The verifier checks that the following multiscalar multiplication equals the identity:

let mega_check = RistrettoPoint::optional_multiscalar_mul(
    // A
    iter::once(Scalar::ONE)
        // + x * S
        .chain(iter::once(x))
        // + d ( x * T_1 )
        .chain(iter::once(d * x))
        // + d ( x^2 * T_2 )
        .chain(iter::once(d * x * x))
        // - e_blinding * H
        // - d ( t_x_blinding * H )
        .chain(iter::once(-self.e_blinding - d * self.t_x_blinding))
        // + w * (t_x - a * b) * G
        // + d (delta(bitlengths, y, z) - t_x) * G
        .chain(iter::once(basepoint_scalar))
        // + <x_sq, L>
        .chain(x_sq.iter().cloned())
        // + <x_inv_sq, R>
        .chain(x_inv_sq.iter().cloned())
        // - z * <1, G_IPA> - a <s, G_IPA>
        .chain(gs)
        // + <z y^n + Z', H_IPA> - b H_IPA
        .chain(hs)
        // + d (\sum_i z^{2+i} * V_i)
        .chain(value_commitment_scalars),
    iter::once(self.A.decompress())
        .chain(iter::once(self.S.decompress()))
        .chain(iter::once(self.T_1.decompress()))
        .chain(iter::once(self.T_2.decompress()))
        .chain(iter::once(Some(*H)))
        .chain(iter::once(Some(G)))
        .chain(self.ipp_proof.L_vec.iter().map(|L| L.decompress()))
        .chain(self.ipp_proof.R_vec.iter().map(|R| R.decompress()))
        .chain(bp_gens.G(nm).map(|&x| Some(x)))
        .chain(bp_gens.H(nm).map(|&x| Some(x)))
        .chain(comms.iter().map(|V| Some(*V.get_point()))),
)
.ok_or(RangeProofVerificationError::MultiscalarMul)?;

The verifier checks that the following multiscalar multiplication equals the identity:

MSM=1·A+x·S+dx·T1+dx2·T2+(eblinddtx(blind))·H+(w(txab)+d(δtx))·G+iui2·Li+iui2·Ri+j(zasj)·GIPA,j+j(z+yj1(Z[j]bsj1))·HIPA,j+kdzk+2·Vk=𝒪.

We can see more clearly what this “mega check” verifies by splitting the checks that are not shifted by the challenge d as:

MSMfree=1·A+x·Seblind·H+w(txab)·G+iui2·Li+iui2·Ri+j(zasj)·GIPA,j+j(z+yj1(Z[j]bsj1))·HIPA,j.

And the checks that are shifted by the challenge d as:

MSMd=x·T1+x2·T2tx(blind)·H+(δtx)·G+kzk+2·Vk.

So the check is aggregated as

MSMfree+d·MSMd=𝒪.

When max is not a Power of Two

To apply the range proof for v[0,max) in the case where max is not a power of two, we first compute a complement value v and then show that v and v are in the range [0,2k), where k is chosen such that max<2k.

Specifically, the complement value v is computed as:

v:=(max1)v.

We can see why constraining v and v prove the desired statement by considering the following series of implications:

0v<2k 0(max1)v2k1 2k1v(max1)0 2k1+max1vmax1

Combining this and the other range for v, we conclude that v[0,max).

Overview of the ZK Elgamal Proof Program

The verifiers for the different zero-knowledge proofs implemented in the zk-sdk are exposed in Solana as the built-in ZK ElGamal Proof program with program id ZkE1Gama1Proof11111111111111111111111111111.

The built-in is declared in the agave node code in builtins/src/lib.rs:

pub static BUILTINS: &[BuiltinPrototype] = &[
    // TRUNCATED...
    testable_prototype!(BuiltinPrototype {
        core_bpf_migration_config: None,
        name: zk_elgamal_proof_program,
        enable_feature_id: Some(feature_set::zk_elgamal_proof_program_enabled::id()),
        program_id: solana_sdk_ids::zk_elgamal_proof_program::id(),
        entrypoint: solana_zk_elgamal_proof_program::Entrypoint::vm,
    }),
];

where the entrypoint referenced is generated via a macro and is implemented to dispatch different instructions:

declare_process_instruction!(Entrypoint, 0, |invoke_context| {
    if invoke_context
        .get_feature_set()
        .disable_zk_elgamal_proof_program
        && !invoke_context
            .get_feature_set()
            .reenable_zk_elgamal_proof_program
    {
        ic_msg!(
            invoke_context,
            "zk-elgamal-proof program is temporarily disabled"
        );
        return Err(InstructionError::InvalidInstructionData);
    }

    let transaction_context = &invoke_context.transaction_context;
    let instruction_context = transaction_context.get_current_instruction_context()?;
    let instruction_data = instruction_context.get_instruction_data();
    let instruction = ProofInstruction::instruction_type(instruction_data)
        .ok_or(InstructionError::InvalidInstructionData)?;

    match instruction {
        ProofInstruction::CloseContextState => {
            invoke_context
                .consume_checked(CLOSE_CONTEXT_STATE_COMPUTE_UNITS)
                .map_err(|_| InstructionError::ComputationalBudgetExceeded)?;
            ic_msg!(invoke_context, "CloseContextState");
            process_close_proof_context(invoke_context)
        }
        ProofInstruction::VerifyZeroCiphertext => {
            invoke_context
                .consume_checked(VERIFY_ZERO_CIPHERTEXT_COMPUTE_UNITS)
                .map_err(|_| InstructionError::ComputationalBudgetExceeded)?;
            ic_msg!(invoke_context, "VerifyZeroCiphertext");
            process_verify_proof::<ZeroCiphertextProofData, ZeroCiphertextProofContext>(
                invoke_context,
            )
        }
        // TRUNCATED...

As the instructions are implemented natively, the compute units (on-chain cost of running these instructions) are hardcoded as follows:

pub const CLOSE_CONTEXT_STATE_COMPUTE_UNITS: u64 = 3_300;
pub const VERIFY_ZERO_CIPHERTEXT_COMPUTE_UNITS: u64 = 6_000;
pub const VERIFY_CIPHERTEXT_CIPHERTEXT_EQUALITY_COMPUTE_UNITS: u64 = 8_000;
pub const VERIFY_CIPHERTEXT_COMMITMENT_EQUALITY_COMPUTE_UNITS: u64 = 6_400;
pub const VERIFY_PUBKEY_VALIDITY_COMPUTE_UNITS: u64 = 2_600;
pub const VERIFY_PERCENTAGE_WITH_CAP_COMPUTE_UNITS: u64 = 6_500;
pub const VERIFY_BATCHED_RANGE_PROOF_U64_COMPUTE_UNITS: u64 = 111_000;
pub const VERIFY_BATCHED_RANGE_PROOF_U128_COMPUTE_UNITS: u64 = 200_000;
pub const VERIFY_BATCHED_RANGE_PROOF_U256_COMPUTE_UNITS: u64 = 368_000;
pub const VERIFY_GROUPED_CIPHERTEXT_2_HANDLES_VALIDITY_COMPUTE_UNITS: u64 = 6_400;
pub const VERIFY_BATCHED_GROUPED_CIPHERTEXT_2_HANDLES_VALIDITY_COMPUTE_UNITS: u64 = 13_000;
pub const VERIFY_GROUPED_CIPHERTEXT_3_HANDLES_VALIDITY_COMPUTE_UNITS: u64 = 8_100;
pub const VERIFY_BATCHED_GROUPED_CIPHERTEXT_3_HANDLES_VALIDITY_COMPUTE_UNITS: u64 = 16_400;

There are essentially two types of instructions:

  1. The verify functions, which allow you to verify different types of zero-knowledge proofs
  2. A close context state function, which allows you to close a storage account meant to be a temporary place to hold claims, where a claim is an on-chain evidence that a specific proof was verified (think the type of the proof and the instance)

All proofs follow the same format, which is a structure that includes both the statement/instance and the proof. All proof types implement the following trait, where Pod is the atomic type being handled by the SVM, and context_data and the type T refer to the statement/instance associated with a ZKP:

pub trait ZkProofData<T: Pod> {
    const PROOF_TYPE: ProofType;

    fn context_data(&self) -> &T;

    #[cfg(not(target_os = "solana"))]
    fn verify_proof(&self) -> Result<(), ProofVerificationError>;
}

Verification of a proof comes with two knobs that one can tweak:

  1. A proof can be provided directly as part of the instruction data, or it can be provided as part of some arbitrary account’s data (a “proof_data_account”), given an additional offset in that data (allowing multiple proofs to be stored in temporary storage accounts).
  2. An externally-allocated “context state” account can be provided in order to store a claim (the result of the verification) to be used by another instruction. If this account is not provided, the result is short-lived as it will have to be accessed by another instruction from the same transaction (using instruction introspection).

Overview of the Token 2022 Confidential Transfer Extension

Confidential transfers in Solana are added on top of the token-2022 standard as extensions, which we go into more detail in this section. Essentially, base mint accounts are augmented with confidential transfer extensions to advertise that confidential transfers are enabled; from there on, token accounts created for the mint are also augmented with their own confidential transfer extensions which will store the account’s encrypted balances (in the style of zether).

Verifying Proofs And Extracting Instances

As seen in the previous section, proofs relevant to the Token-2022 confidential transfer extensions are verified via the ZK ElGamal Proof program. When the confidential extensions want to enforce some zero-knowledge proofs and extract their instances/statements, they have to either:

  1. find if a ZK ElGamal Proof instruction for a specific proof type is present, and if so extract the statement associated with that proof within its associated instruction data
  2. find a “proof context account” owned by the ZK ElGamal Proof program that contain the correct proof type, and extract the statement (called context data) stored next to the proof type under that account data

Both ways ensure that the proof (described uniquely by its type and its context data) has been correctly verified, because if this wasn’t the case the whole transaction would have been reverted (in the first case) or no proof context account owned by the ZK ElGamal Proof program would exist.

Accounts and Extensions in Token-2022

Accounts owned by the Token-2022 program have an implicit type (which is known due to the length of the data field itself, for example, a multisig account is always 355 bytes), which is made explicit via the encoding of an AccountType if their data field also contains extensions.

There are two main AccountTypes: Mint and Account. A single Mint is used to represent a specific mint (i.e. a token), whereas many Accounts are used to represent individual user accounts associated to a mint. The function unpack is used to load these from the data field of Solana accounts while ensuring that the AccountType is correctly set in case of the presence of extensions:

// checks that the token_account_data account stores an Account account type (if it has any extension data)
let mut token_account = PodStateWithExtensionsMut::<PodAccount>::unpack(token_account_data)?;

// checks that the mint_data account stores a Mint account type (if it has any extension data)
let mut mint = PodStateWithExtensionsMut::<PodMint>::unpack(mint_data)?;

Note also that an unpack_uninitialized can be used to ensure that the base type is filled with zeros (uninitialized), as extensions are meant to be initialized before a base type is (effectively locking any extension initialization thereafter).

Visually, extensions are encoded (using a Tag-Length-Value encoding) in the data field of an account containing a base type Mint or Account in the following way:

+---------------------------+
|        (Pod)Mint          |
+---------------------------+
|       Extension 1         |
+---------------------------+
|           ...             |
+---------------------------+
|       Extension N         |
+---------------------------+

Instructions

The Token-2022 program processes an instruction by deserializing the first byte of the input and routing the instruction to the correct implementation. As implemented, it recognizes three main confidential instructions:

    pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> ProgramResult {
        if let Ok(instruction_type) = decode_instruction_type(input) {
            match instruction_type {
                // TRUNCATED...
                PodTokenInstruction::ConfidentialTransferExtension => {
                    confidential_transfer::processor::process_instruction(
                        program_id,
                        accounts,
                        &input[1..],
                    )
                }
                // TRUNCATED...
                PodTokenInstruction::ConfidentialTransferFeeExtension => {
                    confidential_transfer_fee::processor::process_instruction(
                        program_id,
                        accounts,
                        &input[1..],
                    )
                }
                // TRUNCATED...
                PodTokenInstruction::ConfidentialMintBurnExtension => {
                    msg!("Instruction: ConfidentialMintBurnExtension");
                    confidential_mint_burn::processor::process_instruction(
                        program_id,
                        accounts,
                        &input[1..],
                    )
                }

The second byte then tells us about subinstructions. Let’s first take a look at the main three confidential instructions:

ConfidentialTransfer. This instruction manages the two main confidential transfer extensions: one to manage a confidential transfers configuration on the mint, and one to manage the confidential transfers on individual token accounts. It can be used without the two other extensions, and contain all the required subinstructions from managing the mint to handling transfers between token accounts of the mint.

ConfidentialTransferFee. This instruction contains the logic to initialize the ConfidentialTransferFeeConfig extension in a mint, which is required when fees are enabled on the base mint (via another base fee extension). The subinstructions defined there allow “harvesting” fees, and sending these to a destination account (if authorized by the base fee extension authority). In addition, a confidential fee authority is set to allow rotation of its Elgamal public key (which is needed as fees are encrypted).

ConfidentialMintBurn. Finally, a mint can be configured via a confidential mint-burn extension to only allow for direct minting of confidential balances, disallowing the existence of a base token. Without this extension, minting new tokens has to happen on the base mint, and then transfered to the “shielded world” via the confidential transfer deposit subinstruction. (Note that even without this extension, the confidential transfer can choose to disallow non-confidential transfers.)

In the next sections we cover each of the main confidential instructions in more detail.

Confidential Transfer Instruction

The confidential transfer instruction is the first instruction for confidential transfer, and it can be used by itself without the other two. It consists of 13 subinstructions that are either about initializing and managing a mint, or about initializing and managing token accounts. The latter also includes transferring between accounts.

Activating this extension creates a “two-worlds system” with a base balance (in the base type) and an encrypted balance (in the extension). Allowing base → encrypted movements via the Deposit subinstruction and encrypted → base movements via the Withdraw subinstruction.

Here are its subinstructions:

  1. InitializeMint - Set up confidential transfer settings (authority, auditor, auto-approve) on a new mint
  2. UpdateMint - Change auto-approve setting and auditor ElGamal key on existing mint
  3. ApproveAccount - Mint authority approves a configured account to use confidential transfers
  4. ConfigureAccount - Set up an account’s ElGamal public key and enable confidential transfers
  5. EmptyAccount - Withdraw all available balance to prepare account for closing
  6. Deposit - Convert non-confidential tokens into confidential pending balance
  7. Withdraw - Convert confidential available balance into non-confidential tokens
  8. Transfer - Send confidential tokens from one account to another
  9. ApplyPendingBalance - Move pending balance into available balance (activate received transfers)
  10. EnableConfidentialCredits - Allow account to receive incoming confidential transfers
  11. DisableConfidentialCredits - Reject incoming confidential transfers
  12. EnableNonConfidentialCredits - Allow account to receive incoming non-confidential transfers
  13. DisableNonConfidentialCredits - Reject incoming non-confidential transfers

A base mint, still uninitialized, can be configured with the following ConfidentialTransferMint extension in order to activate confidential transfers:

// --- token-2022/interface/src/extension/confidential_transfer/mod.rs ---

pub struct ConfidentialTransferMint {
    /// Authority to modify the `ConfidentialTransferMint` configuration and to
    /// approve new accounts (if `auto_approve_new_accounts` is true)
    ///
    /// The legacy Token Multisig account is not supported as the authority
    pub authority: OptionalNonZeroPubkey,

    /// Indicate if newly configured accounts must be approved by the
    /// `authority` before they may be used by the user.
    ///
    /// * If `true`, no approval is required and new accounts may be used
    ///   immediately
    /// * If `false`, the authority must approve newly configured accounts (see
    ///   `ConfidentialTransferInstruction::ConfigureAccount`)
    pub auto_approve_new_accounts: PodBool,

    /// Authority to decode any transfer amount in a confidential transfer.
    pub auditor_elgamal_pubkey: OptionalNonZeroElGamalPubkey,
}

Once the base mint is initialized, extensions cannot be changed. From there on, any token account initialized for this mint can also ask to receive a ConfidentialTransferAccount extension (through the ConfigureAccount extension) in order to enable the “encrypted world”:

pub struct ConfidentialTransferAccount {
    /// `true` if this account has been approved for use. All confidential
    /// transfer operations for the account will fail until approval is
    /// granted.
    pub approved: PodBool,

    /// The public key associated with ElGamal encryption
    pub elgamal_pubkey: PodElGamalPubkey,

    /// The low 16 bits of the pending balance (encrypted by `elgamal_pubkey`)
    pub pending_balance_lo: EncryptedBalance,

    /// The high 32 bits of the pending balance (encrypted by `elgamal_pubkey`)
    pub pending_balance_hi: EncryptedBalance,

    /// The available balance (encrypted by `encryption_pubkey`)
    pub available_balance: EncryptedBalance,

    /// The decryptable available balance
    pub decryptable_available_balance: DecryptableBalance,

    /// If `false`, the extended account rejects any incoming confidential
    /// transfers
    pub allow_confidential_credits: PodBool,

    /// If `false`, the base account rejects any incoming transfers
    pub allow_non_confidential_credits: PodBool,

    /// The total number of `Deposit` and `Transfer` instructions that have
    /// credited `pending_balance`
    pub pending_balance_credit_counter: PodU64,

    /// The maximum number of `Deposit` and `Transfer` instructions that can
    /// credit `pending_balance` before the `ApplyPendingBalance`
    /// instruction is executed
    pub maximum_pending_balance_credit_counter: PodU64,

    /// The `expected_pending_balance_credit_counter` value that was included in
    /// the last `ApplyPendingBalance` instruction
    pub expected_pending_balance_credit_counter: PodU64,

    /// The actual `pending_balance_credit_counter` when the last
    /// `ApplyPendingBalance` instruction was executed
    pub actual_pending_balance_credit_counter: PodU64,
}

Note that at this point, the account might still not be approved, depending on the mint configuration (which might require manual approving from the mint authority). A token account can still trade in the “base world” (via its non-encrypted balance available in the base type).

We summarize how the approval logic impacts the different functionalities of the program in the table below (ignoring process_initialize_mint and process_update_mint which are pure mint subinstructions):

Subinstruction Approval
ConfigureAccount Sets approved
ConfigureAccountWithRegistry Sets approved
ApproveAccount Sets approved=true
EmptyAccount Ignores approval status
Deposit Enforces approved account
Withdraw Enforces approved account
Transfer Enforces approved account
TransferWithFee Enforces approved account
ApplyPendingBalance Ignores approval status
EnableConfidentialCredits Ignores approval status
DisableConfidentialCredits Ignores approval status
EnableNonConfidentialCredits Ignores approval status
DisableNonConfidentialCredits Ignores approval status

Note that confidential transfers still have to play nice with other (confidential and non-confidential) extensions, we summarize some of that in the following table:

Sub-Instruction PausableConfig TransferFeeConfig NonTransferableAccount
InitializeMint N/A N/A N/A
UpdateMint N/A N/A N/A
ConfigureAccount N/A Check if present for fee N/A
ApproveAccount N/A N/A N/A
EmptyAccount N/A N/A N/A
Deposit Check if paused N/A Block if present
Withdraw Check if paused N/A Block if present
Transfer Check if paused Check presence for fee logic Block source if present
TransferWithFee Check if paused Get fee parameters Block source if present
ApplyPendingBalance N/A N/A N/A
DisableConfidentialCredits N/A N/A N/A
EnableConfidentialCredits N/A N/A N/A
DisableNonConfidentialCredits N/A N/A N/A
EnableNonConfidentialCredits N/A N/A N/A

Finally, note that another “ElGamal Registry” program is also implemented in order to facilitate creating a registry of pubkey validity proofs (which are proofs that someone knows the private key associated with the public key). In this registry, anyone can post a link between any (valid) public key to an actual Solana system account. This permits someone that does not know your ElGamal private key to still create a confidential token account (presumably for a new confidential mint) for you, as the token account creation subinstruction requires the sigma proof.

Confidential Transfer Fee

The second confidential instruction is used only when the base mint has transfer fees enabled (via a base transfer fee extension). This extension allows fees to be calculated (via the OR sigma proof explained previously) and withheld from the transaction amount. To do that, the previous instruction is configured to detect the presence of the transfer fee extension in the mint, and add a ConfidentialTransferFeeAmount extension to any token account being configured for confidential transfer. The role of that new extension is to hold fees collected in all transfers sent to that account, until they get harvested by a mint authority, which is what this instruction is about: it helps create and manage that authority, as well as harvest the fees from many accounts and deposit them into arbitrary accounts. Here are its subinstructions:

  1. InitializeConfidentialTransferFeeConfig - Set up confidential fee configuration with withdrawal authority ElGamal key
  2. EnableHarvestToMint and DisableHarvestToMint - Pause or unpause the harvesting of fees
  3. HarvestWithheldTokensToMint - Move withheld fees from accounts to mint (permissionless batching operation)
  4. WithdrawWithheldTokensFromMint - Send accumulated fees from mint to destination account (after harvesting them with the previous instruction)
  5. WithdrawWithheldTokensFromAccounts - The two previous instructions can be compressed into a single one, avoiding the need to move the fees to the mint, by collecting and sending fees directly from multiple accounts to a destination account

A ConfidentialTransferFeeConfig extension must be added to the mint, if the mint is configured with the base transfer fee (and in fact, confidential transfer functions won’t work if the mint wasn’t initialized correctly with that extension):

// -- token-2022/interface/src/extension/confidential_transfer_fee/mod.rs --

pub struct ConfidentialTransferFeeConfig {
    /// Optional authority to set the withdraw withheld authority ElGamal key
    pub authority: OptionalNonZeroPubkey,

    /// Withheld fees from accounts must be encrypted with this ElGamal key.
    ///
    /// Note that whoever holds the ElGamal private key for this ElGamal public
    /// key has the ability to decode any withheld fee amount that are
    /// associated with accounts. When combined with the fee parameters, the
    /// withheld fee amounts can reveal information about transfer amounts.
    pub withdraw_withheld_authority_elgamal_pubkey: PodElGamalPubkey,

    /// If `false`, the harvest of withheld tokens to mint is rejected.
    pub harvest_to_mint_enabled: PodBool,

    /// Withheld confidential transfer fee tokens that have been moved to the
    /// mint for withdrawal.
    pub withheld_amount: EncryptedWithheldAmount,
}

pub struct ConfidentialTransferFeeAmount {
    /// Amount withheld during confidential transfers, to be harvested to the mint
    pub withheld_amount: EncryptedWithheldAmount,
}

Note that the design has fees deposited in recipient accounts, as opposed to always depositing them to the same “mint collecting fee” account, in order to avoid a situation where every transaction of that confidential mint locks the same account (which is unfavorable in the highly-parallelizable transaction execution model of Solana). This mirrors the design of the base transfer fee extension.

As with the previous confidential transfer instruction, this one also has to play nice with non-confidential extensions:

Sub-Instruction TransferFeeConfig
InitializeConfidentialTransferFeeConfig N/A
WithdrawWithheldTokensFromMint Required for authority
WithdrawWithheldTokensFromAccounts Required for authority
HarvestWithheldTokensToMint Required for existence
EnableHarvestToMint N/A
DisableHarvestToMint N/A

Confidential Mint Burn

Finally, the last confidential instruction allows the minting of new tokens confidentially. Previously, tokens had to be minted through the base token, and deposited onto the confidential-side of the fence (and withdrawn back to the base-side of the fence). A mint configured with the confidential mint burn extension does not allow the existence of base tokens point blank. Here are its subinstructions:

  1. InitializeMint - Set up confidential mint/burn extension with supply encryption key
  2. RotateSupplyElGamalPubkey - Change the ElGamal key used to encrypt supply
  3. UpdateDecryptableSupply - Update the AES-encrypted supply for authority’s tracking
  4. Mint - Create new tokens directly into an account’s confidential pending balance
  5. Burn - Destroy tokens from an account’s confidential available balance
  6. ApplyPendingBurn - Subtract accumulated burns from the confidential supply

The configuration extension struct looks like the following, keeping track of the total supply in an encrypted balance:

// --- token-2022/interface/src/extension/confidential_mint_burn/mod.rs ---

pub struct ConfidentialMintBurn {
    /// The confidential supply of the mint (encrypted by `encryption_pubkey`)
    pub confidential_supply: PodElGamalCiphertext,
    /// The decryptable confidential supply of the mint
    pub decryptable_supply: PodAeCiphertext,
    /// The ElGamal pubkey used to encrypt the confidential supply
    pub supply_elgamal_pubkey: PodElGamalPubkey,
    /// The amount of burn amounts not yet aggregated into the confidential supply
    pub pending_burn: PodElGamalCiphertext,
}

Minting acts similarly to normal transfers, where two recipients are created: the account the mint goes to, and the confidential_supply balance above.

Since everyone can “burn”, the burning transfers have to be processed periodically by the mint authority similarly to pending balances in the main confidential transfer account extension.

As usual, the added instruction/extension has to play nice with non-confidential ones:

Sub-Instruction PausableConfig TransferFeeConfig NonTransferableAccount
InitializeMint N/A N/A N/A
RotateSupplyElGamalPubkey Check if paused N/A N/A
UpdateDecryptableSupply N/A N/A N/A
Mint Check if paused N/A N/A
Burn Check if paused N/A N/A
ApplyPendingBurn N/A N/A N/A

Initialization and Interaction Between Confidential Extensions

In total, there are five confidential extensions (implementing the Extension trait in the code) that can be encoded in mint or token accounts. Mint extensions have to be initialized before the base mint is initialized, whereas the confidential token extensions can be added on top of an initialized base token. This allows mints to honestly expose and maintain their configurations to users. We recapitulate where these extensions are initialized for the first time in the table below:

Instruction Subinstruction Target Extension Initialized
ConfidentialTransfer InitializeConfidentialTransferMint Mint ConfidentialTransferMint
ConfidentialTransfer ConfigureAccount / ConfigureAccountWithRegistry Token Account ConfidentialTransferAccount
ConfidentialTransfer ConfigureAccount / ConfigureAccountWithRegistry Token Account (conditional) ConfidentialTransferFeeAmount (if mint has TransferFeeConfig)
ConfidentialTransferFee InitializeConfidentialTransferFeeConfig Mint ConfidentialTransferFeeConfig
ConfidentialMintBurn InitializeMint Mint ConfidentialMintBurn

While many interactions exist, we note a final one implemented in the following function:

    pub fn check_for_invalid_mint_extension_combinations(
        mint_extension_types: &[Self],
    ) -> Result<(), TokenError> {
        let mut transfer_fee_config = false;
        let mut confidential_transfer_mint = false;
        let mut confidential_transfer_fee_config = false;
        let mut confidential_mint_burn = false;
        // TRUNCATED...

        for extension_type in mint_extension_types {
            match extension_type {
                ExtensionType::TransferFeeConfig => transfer_fee_config = true,
                ExtensionType::ConfidentialTransferMint => confidential_transfer_mint = true,
                ExtensionType::ConfidentialTransferFeeConfig => {
                    confidential_transfer_fee_config = true
                }
                ExtensionType::ConfidentialMintBurn => confidential_mint_burn = true,
                // TRUNCATED...
            }
        }

        if confidential_transfer_fee_config && !(transfer_fee_config && confidential_transfer_mint)
        {
            return Err(TokenError::InvalidExtensionCombination);
        }

        if transfer_fee_config && confidential_transfer_mint && !confidential_transfer_fee_config {
            return Err(TokenError::InvalidExtensionCombination);
        }

        if confidential_mint_burn && !confidential_transfer_mint {
            return Err(TokenError::InvalidExtensionCombination);
        }

        // TRUNCATED...

called by Processor::_process_initialize_mint() (InitializeMint and InitializeMint2), which enforces the following invariants:

  • if confidential transfer fees are activated, then confidential transfers must be activated first, as well as base transfer fees
  • if transfer fees are enabled (via the base extension) and confidential transfers are enabled as well, then confidential transfer fees must be activated
  • if confidential minting is activated, then confidential transfers must be activated first

Interaction With Base Operations

Furthermore, there are a number of other already-existing operations in the base account types that must continue to work as intended with the newly added confidential extensions. We recapitulate how the different extensions affect the already existing operations:

Instruction Base Behavior Confidential Extension Effect Blocks?
InitializeMint Sets mint authority, decimals, freeze authority None - CT mint extension initialized separately via ConfidentialTransferInstruction::InitializeMint No
InitializeMint2 Same as InitializeMint but doesn’t require rent sysvar account None - CT mint extension initialized separately No
InitializeAccount Creates token account, sets owner, validates extensions from mint None - CT account extension must be explicitly initialized via ConfigureAccount No
InitializeAccount2 Same as InitializeAccount but owner is instruction data instead of account None - CT account extension must be explicitly initialized via ConfigureAccount No
InitializeAccount3 Same as InitializeAccount2 but doesn’t require rent sysvar account None - CT account extension must be explicitly initialized via ConfigureAccount No
InitializeMultisig Creates multisig account with M-of-N signers None - multisig is orthogonal to CT No
InitializeMultisig2 Same as InitializeMultisig but doesn’t require rent sysvar account None - multisig is orthogonal to CT No
Transfer Moves tokens from source to destination base balance Checks destination’s allow_non_confidential_credits flag Yes - if destination has CT extension and flag is false
TransferChecked Same as Transfer but validates decimals Checks destination’s allow_non_confidential_credits flag Yes - if destination has CT extension and flag is false
Approve Sets delegate and delegated_amount on base account None - delegation only affects base balance, not CT balance No
ApproveChecked Same as Approve but validates decimals None - delegation only affects base balance, not CT balance No
Revoke Clears delegate and delegated_amount None - only affects base delegation No
SetAuthority Changes authority for various account/mint operations Supports ConfidentialTransferMint and ConfidentialTransferFeeConfig authority types No
MintTo Increases destination base balance and mint supply If ConfidentialMintBurn extension present, blocks regular minting Yes - if mint has ConfidentialMintBurn extension
MintToChecked Same as MintTo but validates decimals If ConfidentialMintBurn extension present, blocks regular minting Yes - if mint has ConfidentialMintBurn extension
Burn Decreases source base balance and mint supply None No
BurnChecked Same as Burn but validates decimals None No
CloseAccount Transfers lamports to destination, deletes account Requires ConfidentialTransferAccount and ConfidentialTransferFeeAmount balances to be zero Yes - if any CT balances non-zero, must call EmptyAccount first
FreezeAccount Sets account state to Frozen Blocks ALL operations - both base and CT (deposit, withdraw, transfer) Yes (indirectly)
ThawAccount Sets account state back to Initialized (unfreezes) Allows operations again - both base and CT No (enables operations)
SyncNative Updates wrapped SOL account amount to match lamports None - works independently of CT No
GetAccountDataSize Returns the size needed for an account with specified extensions None - just calculates size, doesn’t check CT No
InitializeMintCloseAuthority Sets close authority for mint account None - CT doesn’t affect mint closing No
InitializeImmutableOwner Marks account owner as immutable None - CT works with immutable owners No
AmountToUiAmount Converts raw amount to UI amount string None - operates on base amounts only No
UiAmountToAmount Converts UI amount string to raw amount None - operates on base amounts only No
Reallocate Reallocates account to fit new extensions None - can reallocate to add CT extension No
CreateNativeMint Creates the native SOL mint None - native mint doesn’t support CT No
InitializeNonTransferableMint Initializes non-transferable mint extension None - separate extension, but CT transfers would also be blocked No
InitializePermanentDelegate Sets permanent delegate for the mint None - permanent delegate can burn from base balance only No
WithdrawExcessLamports Withdraws lamports above rent-exempt minimum None - only affects lamports, not token balances No