tl;dr: Confidential fungible assets (CFAs) are in town! But first, a moment of silence for veiled coins.
Notation
- For time complexities, we use:
- $\Fadd{n}$ for $n$ field additions in $\F$
-
$\Fmul{n}$ for $n$ field multiplications in $\F$
- $\Gadd{\Gr}{n}$ for $n$ additions in $\Gr$
- $\Gmul{\Gr}{n}$ for $n$ individual scalar multiplications in $\Gr$
- $\fmsm{\Gr}{n}$ for a size-$n$ MSM in $\Gr$ where the group element bases are known ahead of time (i.e., fixed-base)
- when the scalars are always from a set $S$, then we use $\fmsmSmall{\Gr}{n}{S}$
- $\vmsm{\Gr}{n}$ for a size-$n$ MSM in $\Gr$ where the group element bases are not known ahead of time (i.e., variable-base)
- when the scalars are always from a set $S$, then we use $\vmsmSmall{\Gr}{n}{S}$
- We assume a prime-order group $\term{\Gr}$ of prime order $\term{p}$
- We use additive group notation: $a \cdot G$ denotes scalar multiplication in $\Gr$, where $a\in \Zp$ and $G\in\Gr$
Confidential asset notation
If we decide to use $b$ for the base, use $w$ for the chunk size!
- $\term{b}$
- chunk size in bits
- $\term{B}=2^b$
- chunk size as an integer
- $\term{\ell}$
- # of available balance chunks
- In Aptos, balances are 128 bits $\Rightarrow \ell\cdot b = 128$
- $\term{n}$
- # of pending balance chunks and # of transferred amount chunks
- In Aptos, transferred amounts are 64 bits $\Rightarrow n \cdot b = 64$
- $\term{t}$
- each account can receive up to $2^t$ incoming transfers, after which it needs to be rolled over
- i.e., the owner must send a TXN that rolls over their pending into their available balance
- $\Rightarrow$ pending balance chunks are always $< \emph{2^{b+t}}$
- $\Rightarrow$ available balance chunks are always $\le (2^b - 1) + (2^b-1)2^t = (2^b - 1)(1 + 2^t) = 2^{b+t} - 1$
- assuming we only roll over into normalized available balances (i.e., with chunks $< 2^b$)
Preliminaries
We assume familiarity with:
- Public-key encryption
- In particular, Twisted ElGamal
- ZK range proofs (e.g., Bulletproofs1, BFGW, DeKART)
- $\Sigma$-protocols
- Baby-step giant-step (BSGS) discrete log algorithms
Related work
There is a long line of work on confidential asset-like protocols, both in Bitcoin’s UTXO model, and in Ethereum’s account model. Our work builds-and-improves upon these works:
- 2015, Confidential assets2
- 2018, Zether3
- 2020, PGC4
- 2025, Taurus Releases Open-Source Private Security Token for Banks, Powered by Aztec, see repo here
- 2025, Solana’s confidential transfers
Upgradeability
There will be many reasons to upgrade our confidential asset protocol (CFA):
- Performance improvements
- Bugs in $\Sigma$-protocols or ZK range proof
- Post-quantum security
Depending on which aspect of the protocol must change, upgrades can range from trivial to very tricky.
Increasing the # of max pending transfers
The parameter $\emph{t}$ defined above can be increased as long as we can compute discrete logs for values $\le 2^{b+t}$.
The justification for its $t = 16$ value is discussed later on.
Upgrading ZKPs
This can be done trivially: just add a new enum variant while maintaining the old one for a while.
If there is a soundness bug, the old variant must be disallowed.
This will break old dapps, unfortunately, but that is inherent in order to protect against theft.
Define the CFA algorithms so as to reason about upgradeability more easily.
Upgrading encryption scheme
We may want to upgrade our encryption scheme for efficiency reasons or for security (e.g., post-quantum).
Option 1: Force users to upgrade their balance ciphertexts
One way5 to support such an upgrade is to force the owning user to re-encrypt their obsolete balance ciphertexts under the new encryption scheme. Then, we’d only allow transfers between users who are upgraded to the new scheme. (Option two would be to implement support for sending confidential assets from an obsolete balance ciphertext into an upgraded balance ciphertext.)
Such upgrades may happen repeatedly, so we must ensure complexity does not get out of hand: e.g., if over the years we’ve upgraded our encryption scheme $n-1$ times, then there may be a total of $n$ ciphertext types flying around ($n\ge 1$).
Naively, we’d want to support converting from any obsoleted encryption scheme to any new scheme, but that would require too many re-encryption implementations: $(n-1) + (n-2) + \ldots + 2 + 1 = O(n^2)$.
A solution would be to only implement re-encryption from the $i$th scheme to $(i+1)$th scheme, for any $i\in[n-1]$. This could be slow, since it requires $n-1$ re-encryptions. If so, we can do it in $O(\log{n})$ re-encryptions in a skip list-like fashion. (By allowing upgrades to skip intermediate schemes, we would reduce the number of required re-encryptions.)
Another challenge: post-upgrade, existing dapps, now with an out-of-date SDK, will not know how to handle the new encryption scheme. So, such upgrades are backwards incompatible.
For example, old dapps will be “surprised” to see that the user’s balance is no longer encrypted under the old scheme (i.e., the SDK sees that the balance enum is a new unrecognized variant). If so, the SDK should display a user-friendly error like “This dapp must be upgraded to support new confidential asset features.”
It’s unclear whether there’s something better we could do to maintain backwards compatibility. I think the main problematic scenario is:
- Alice used a new dapp that converted her entire balance to the new scheme
- Alice uses an old dapp that panics when it cannot handle the new scheme
We may want to strongly recommend that dapps/wallets only allow the user to manually upgrade their ciphertexts? This way, at least users understand that upgrading may make their assets inaccessible on older dapps.
Option 2: Universal transfers
Again, the assumption is that, over the years, we’ve upgraded our encryption scheme $n-1$ times $\Rightarrow$ there may be a total of $n$ ciphertext types flying around ($n\ge 1$).
To deal with this, we could simply build functionality that allows to transfer CFAs between any scheme $i,j\in[n]$.
During a send, the SDK should prefer encrypting the amounts for the recipient under the highest supported scheme $j$ their account supports. (Or, encrypt for the max $j$ supported by the contract; it’s just that the user, depending on what dapp/wallet they are using, may not be able to access that balance.)
We could enforce “progress” by only allowing to send from a scheme $i$ to a scheme $j$ when $j > i$, but only when we are certain dapps have been upgraded to the latest SDK so they can handle the new ciphertexts. This way, we would ensure that older ciphertexts don’t proliferate.
FAQ
How does auditing currently work?
First, Aptos governance can install a global auditor EK: every transferred amount and every user’s balance6 will be encrypted under that EK.
Second, governance can also install an asset-specific auditor EK (e.g., an auditor EK for the APT token). Such asset-specific auditor EKs take precedence over the global auditor EK. In this sense, an asset type’s effective auditor is the asset-specific auditor, if set. If not, it’s the global auditor, if set. (Otherwise, there is no effective auditor at all.)
Third, we allow users to re-encrypt their transferred amounts under any extra auditor EK they please, not just the effective auditor that may (or may not) be “installed” on-chain. (Note, this re-encryption is only for transferred amounts, not balances.)
We do not require a ZKPoK when setting a token-specific EK, to simplify deployment and implementation. It can be easily added in the future.
Why 16-bit chunk sizes?
We chose $b=16$-bit chunks for two reasons.
First, it allows us to use the naive DL algorithm to instantly decrypt TXN amounts. This should make confidential dapps very responsive and fast.
Second, it ensures that, after $2^t$ incoming transfers, the pending balance chunks remain $\le 2^{b+t} - 1$. For example, for $t = 16$ (i.e., for $\le$ 65,536 incoming transfers), the pending balance chunks will remain $\le 2^{b+t} - 1 = 2^{32} - 1$. This, in turn, ensures fast decryption times for pending (and available) balances.
Why do we think there could be so many incoming transfers? They may arise in some use cases, such as payment processors, where it would be important to seamlessly receive many transfers. In fact, $2^{16}$ may not even be enough there. (Fortunately, the $t$ parameter is easy to increase as we deploy faster DL algorithms.)
What are the main tensions in the current ElGamal-based design
The main tension is between:
- The ElGamal ciphertext (and associated proof) sizes: i.e., the # of pending chunks $n$ and available chunks $\ell$
- The decryption time for a TXN’s amount: i.e., the chunk size $b$
- We must be able to compute DLs on $b$-bit encrypted amount chunks, very fast, in the browser
- We must be able to compute DLs on $(b+t)$-bit encrypted balance chunks, reasonably fast, in the browser
- DL times are fast for 32 bits (see WASM benchmarks here) $\Rightarrow b = t = 16$
- Fortunately, as we improve our DL algorithms, we can simply increase $t$, in a backwards compatible fashion.
We can make decryption arbitrarily fast by reducing the chunk size $b$, but we would increase confidential transaction sizes and also the cost to verify them due to more $\Sigma$-protocol verification work. This would drive up gas costs.
Important open question: Minimizing the impact of higher $n$ and $\ell$ on our $\Sigma$-protocols using some careful design would be very interesting!
Follow up question: How fast is univariate DeKART?
Note that if we use DeKART, as we make the chunk size $b$ smaller, even though the # of chunks $\ell$ (and $n$) increase, the range proof size and the verification time will actually decrease!
And we can speed up the verification time further: instead of proving that the chunks are in $[2^b)$, we can prove that they are in $[(2^k)^{b/k})$ and get a $k$ times smaller proof with faster verification (TBD).
So we have to find a sweet spot. Currently, we believe this to be either $b=8$-bit or $b=16$-bit chunks. (TBD.)
A secondary tension is between:
- The # of incoming transfers $2^t$ we allow without requiring a rollover from the pending balance into the available balance
- The max discrete log instance we are willing to ever solve for via specialized algorithms like [BL12]7
- This instance would arise when decrypting the pending or the available balance
One of the difficulties is that [BL12]7 is a probabilistic algorithm. This seems harmless, in theory, but we’ve actually encountered failures that are hard to debug when confidential apps are deployed. Furthermore, our current [BL12]7 implementation is in WASM (compiled from Rust) which increases the size of confidential dapps, complicates our code and makes debugging harder.
So, for ease of debugging, ease of implementing and for a consistent UX, we’d prefer deterministic algorithms that are guaranteed to terminate in a known amount of steps, like BSGS. This way, we can guarantee none of our users will ever run into issues.
Unfortunately, deterministic algorithms are slower: BSGS on values in $[m)$ takes $O(\sqrt{m})$ time and space while [BL12]7 only takes $O(m^{1/3})$ time and space. This means that the highest $m$ we can hope to use with BSGS is in $[2^{32}, 2^{36})$, or so. So, our $b+t=32$.
How many types of discrete log instances do you have to solve?
Recall that:
- Transferred amounts are 64-bits and chunked into $b$-bit chunks.
- Balances are 128-bit and also $b$-bit chunked $\Rightarrow$ they have double the # of chunks
- Also, balances “accumulate” transfers in
So, we have to solve two types of DL instances.
- We need to repeatedly decrypt TXN amounts $\Rightarrow$ need very fast DL algorithm for $b$-bit values
- We need to one time decrypt the pending and available balance $\Rightarrow$ we need a reasonably-fast DL algorithm for $\approx (b + t)$-bit values, if we want to support up to $\emph{2^t}$ incoming transfers
To what extent can users provide hints in their TXNs and/or accounts to speed up decryption?
First, for TXN amounts, the chunk sizes are picked so that decryption is very fast. We will likely implement $O(1)$-time decryption with a table of size $2^{16} \cdot 32 = 2^{21}$ bytes $= 2$ MiB. As a result, there is no hint that the sender can include to make this decryption faster.
Open question: How much can we hope to reduce this table size?
Ideally, we are looking to have $\mathcal{H} : \{ j\cdot G : j\in[m)\} \rightarrow [m)$ with $\mathcal{H}(j\cdot G) = j,\forall j\in[m)$.
Such an ideal hash function may need at least $2^m \cdot \log_2{m} = 2^{m+\log_2\log_2{m}}$ bits.
Follow up question: Would this help reduce storage for BSGS?
I think so, yes!
Second, for pending balances, it’s tricky because they change constantly as the user is getting paid. Viewed differently, the hints are the decrypted amounts in all TXNs received since the last rollover which, as explained above, are very fast to fetch.
A key question:
Should we decrypt pending balances by doing $n$ DLs of size $<2^{b+t}$ each?
Or should we give ourselves a way to fetch the last $2^t$ TXNs, instantly decrypt them and add them up?
Decision:
To minimize impact on our own full nodes and/or indexers, and since we’ll need a DL algorithm for available balances anyway (see below), we should decrypt pending balances manually.
We can of course change this in the future.
Third, for available balances, this is where the sender can indeed store a hint for themselves. The sender can do so:
- After sending a TXN out, which decreases their available balance
- After normalizing their available balance
If:
- Dealing with incorrect hints is not too expensive/cumbersome to implement
- Storing the hint is not too expensive, gas-wise.
- Decrypting the hint is significantly faster than doing $\ell$ DLs of size $< 2^{b+t}$ each
…then the complexity may be warranted.
On the other hand, if using $b+t = 32$ bits, then I estimate that a 32-bit discrete log via BSGS will take around 1 second in the browser (i.e., 13 ms $\times$ 10x slowdown${}\times \ell$ chunks $= 13$ ms${} \times 10 \times 8$ chunks $= 1.04$ seconds).
Decision: Either way, we need a DL algorithm for $(b+t)$-bit values in case the hint is wrong/corrupted by bad SDKs. So, for now, we adopt the simpler approach, but we should leave open the possibility of adding hints in the future.
We can allow for “extensions” fields in a user’s confidential asset balance.
Maybe make it an enum.
How smart can the SDK be to avoid decryption work?
Should the SDK just poll for a change in the encrypted balances on-chain and decrypt them? (Simple, but slow if user is receiving lots of transfers.)
Or should the SDK be more “smart” and be aware of the last decrypted balance and the transactions received since, including rollovers and normalizations? (Complex, but much more efficient.)
One challenge with the “smart” approach is that the SDK may need to fetch up to $2^t$ payment TXNs plus extra rollover and normalization TXNs.
This is an open question for our SDK people.
Why not go for a general-purpose zkSNARK-based design?
Question: Why did Aptos go for a special-purpose design based on the Bulletproofs ZK range proof and $\Sigma$-protocols, rather than a design based on a general-purpose zkSNARK (e.g., Groth16, PLONK, or even Bulletproofs itself)?
Short answer: Our special-purpose design best addresses the tension between efficiency and security.
Long answer: General-purpose zkSNARKs are not a panacea:
- They remain slow when computing proofs
- This makes it slow to transact confidentially on your browser or phone.
- They may require complicated multi-party computation (MPC) setup ceremonies to bootstrap securely
- This makes it difficult and risky to upgrade confidential assets if there are bugs discovered, or new features are desired
- Implementing any functionality, including confidential assets, as a general-purpose “ZK circuit” is a dangerous minefield (e.g., circom)
- It is very difficult to do both correctly & efficiently8
- To make matters worse, getting it wrong means user funds would be stolen.
Still, general-purpose zkSNARK approaches, if done right, do have advantages:
- Smaller TXN sizes
- Cheaper verification costs.
So why opt for a special-purpose design like ours?
Because we can nonetheless achieve competitively-small TXN sizes and cheap verification, while also ensuring:
- Computing proofs is fast
- This makes it easy to transact on the browser, phone or even on a hardware wallet
- There is no MPC setup ceremony required
- This makes upgrades easily possible
- The implementation is much easier to get right
- We can sleep well at night knowing our users’ funds are safe
Integration with Petra
Keyless accounts
Aptos Keyless accounts are both a blessing and a curse when it comes to building confidential dapps.
On one hand, they are great: we can build confidential dapps like confidential.aptoslabs.com without having to wait for wallets like Petra to implement support for confidential assets. How come? Keyless dapps are walletless: they internally implement support for sending transactions. Therefore, it is very easy to extend them to send confidential transfers (case in point: confidential.aptoslabs.com). Even better, a keyless dapp can derive each account’s confidential asset decryption key (DK) from that account’s keyless pepper9. As a result, things can remain keyless even though the dapp is performing confidential transfers.
On the other hand, it is difficult to change keyless wallets like Petra to support confidentiality for keyless accounts. The main reason: Petra keyless accounts have the option of installing a backup Ed25519 key, allowing them to operate in a 1-out-of-2 fashion. While this allows keyless users to recover their account if they lose their OIDC account, it complicates management of the confidential asset decryption key (DK): we need to figure out a way to recover the DK when the user only has their backup Ed25519. This is further complicated by the various states that a keyless account can be in Petra:
graph TB;
classDef noConf fill:#d4edda,stroke:#28a745;
A["<u>1-factor</u>: OIDC<br/>(_no_ confidentiality)"]:::noConf
B["<u>2-factor</u>: OIDC or Ed25519<br/>(_no_ confidentiality)"]:::noConf
A-- install Ed25519 backup key -->B;
B-- rotate Ed25519 backup key -->B;
linkStyle 0 stroke:#2196F3,stroke-width:2px
linkStyle 1 stroke:#9C27B0,stroke-width:2px
Our task: Ensure that, regardless of the account’s state (1-factor or 2-factor) or which factors the owner has (OIDC, backup key), the owner can always recover the account’s DK: i.e., the account stays either OIDC-recoverable via their OIDC factor or Ed25519-recoverable via the Ed25519 SK
Our approach:
First, we always deterministically derive the DK from the pepper_base, from which the pepper is derived.
Second, if the account reaches the 2-factor state, we maintain an encryption of the DK under the backup key on-chain.
One complication:
If the account is in the 2-factor state, without confidentiality enabled, and the user recovered this account via their Ed25519 factor, we will never allow them to enable confidentiality because we cannot access the pepper_base.
Instead, we will ask the user to sign in via their OIDC factor if they want to enable confidentiality10.
Update the keyless blog with the pepper derivation scheme.)
The state machine below depicts all the states we need to handle 👇
graph TB;
classDef noConf fill:#d4edda,stroke:#28a745;
classDef withConfNoDK fill:#fff3cd,stroke:#ffc107;
classDef withConfDK fill:#e6ccb3,stroke:#8b4513;
A["<u>1-factor</u>: OIDC<br/>(_no_ confidentiality)"]:::noConf
B["<u>2-factor</u>: OIDC or Ed25519<br/>(_no_ confidentiality)"]:::noConf
C["<u>1-factor</u>: OIDC<br/>(confidential **with** _pepper_ DK)"]:::withConfNoDK
D["<u>2-factor</u>: OIDC or Ed25519<br/>(confidential **with** _pepper_ DK encrypted on-chain)"]:::withConfDK
A-- install Ed25519 backup key -->B;
A-- enable confidentiality (<code>register_ek</code>)-->C
B-- rotate Ed25519 backup key -->B;
B-- enable confidentiality<br/>(via OIDC factor **and** Ed25519 PK from indexer: <code>register_ek_and_encrypt_dk</code>) -->D
B-- enable confidentiality<br/>(via Ed25519 factor) is **forbidden** -->B;
C-- install Ed25519 backup key (<code>upsert_ed25519_backup_key_and_encrypt_dk</code>) -->D
D-- rotate Ed25519 backup key (<code>upsert_ed25519_backup_key_and_encrypt_dk</code>) -->D;
linkStyle 0,5 stroke:#2196F3,stroke-width:2px
linkStyle 2,6 stroke:#9C27B0,stroke-width:2px
linkStyle 1,3,4 stroke:#FF9800,stroke-width:2px
Note that if the keyless account has an Ed25519 backup key, the user signed in via OIDC and the user wants to enable confidentiality, then the wallet will fetch the Ed25519 pubkey from the Aptos indexer in order to encrypt the pepper-derived DK on-chain. This will make that DK Ed25519-recoverable in case the user ever loses their OIDC factor.
IND-CCA symmetric encryption scheme
To encrypt the DK under the user’s Ed25519 backup key, we need an IND-CCA-secure (i.e., AEAD) symmetric encryption scheme. We need to:
- derive its symmetric key deterministically from the Ed25519 secret key (SK)
- work across Petra browser extension, mobile (React Native / Expo) and web.
- avoid additional dependencies beyond what comes transitively from
@aptos-labs/ts-sdk.
Our proposal: HKDF-SHA-512 for key derivation, composed with XChaCha20-Poly1305 as the AEAD.
Both are available via @noble/hashes and @noble/ciphers and are existing dependencies of the Aptos TypeScript SDK.
The scheme:
K = HKDF-SHA512(
IKM = ed25519_sk, // 32 raw bytes (the seed \vec{k})
salt = utf8("petra-wallet-aead-v1"), // constant, public
info = utf8("confidential assets DK encryption under Ed25519"), // domain separator
32
)
// Encrypt
aad = "" // empty AAD
nonce = randomBytes(24) // XChaCha = 192-bit nonce
ct = XChaCha20Poly1305(K, nonce).encrypt(msg, aad) // includes 16-byte tag
ciphertext = nonce[0..24) || ct
// Note: when appending the nonce, all 24 bytes should be appended, even if 0.
Sample TypeScript code that should run unmodified on Petra extension, web and mobile:
import { hkdf } from '@noble/hashes/hkdf';
import { sha512 } from '@noble/hashes/sha512';
import { xchacha20poly1305 } from '@noble/ciphers/chacha';
import { randomBytes } from '@noble/ciphers/utils';
// Note: low entropy salt is okay because IKM (i.e., Ed25519 SK) is high-entropy
// (see https://crypto.stackexchange.com/a/97987)
const SALT = new TextEncoder().encode('petra-wallet-aead-v1');
const INFO = new TextEncoder().encode('confidential assets DK encryption under Ed25519');
function deriveKey(ed25519_sk: Uint8Array) {
// defense in depth
if (ed25519_sk.length !== 32) throw new Error('expected 32-byte seed, not expanded form');
return hkdf(sha512, ed25519_sk, SALT, INFO, 32);
}
// Note: `ed25519_sk` is the 32-byte Ed25519 seed \vec{k} from EdDSA.KeyGen: i.e., the random b=2λ-bit
// value sampled at keygen, *not* the 64-byte expanded \vec{h} = H_1(\vec{k}).
// Recall that the signing scalar `a` is *derived from* the lower half
// of \vec{h} via bit clamping, and the upper half of \vec{h} is the
// Schnorr nonce derivation prefix.
//
// `ed25519_sk` is what Petra actually derives via
// `Ed25519PrivateKey.fromDerivationPath(path, mnemonic)` and stores as an
// `Ed25519PrivateKey` in @aptos-labs/ts-sdk, whose `.toUint8Array()`
// returns the 32-byte seed (see `packages/core/src/utils/privateKey.ts`:
// `PRIVATE_KEY_HEX_LENGTH = 64` hex chars = 32 bytes).
export function encrypt(ed25519_sk: Uint8Array, msg: Uint8Array) {
const key = deriveKey(ed25519_sk);
const nonce = randomBytes(24);
if (nonce.length !== 24) throw new Error('nonce must be 24 bytes'); // defense in depth
const aad = new Uint8Array();
const ctxt_inner = xchacha20poly1305(key, nonce, aad).encrypt(msg);
const ctxt = new Uint8Array(24 + ctxt_inner.length);
ctxt.set(nonce, 0);
ctxt.set(ctxt_inner, 24);
return ctxt;
}
export function decrypt(ed25519_sk: Uint8Array, ctxt: Uint8Array) {
const nonce = ctxt.subarray(0, 24);
const ctxt_inner = ctxt.subarray(24);
const key = deriveKey(ed25519_sk);
const aad = new Uint8Array();
return xchacha20poly1305(key, nonce, aad).decrypt(ctxt_inner);
}
192-bit random nonces give us virtually no birthday-bound concerns: the probability of a nonce collision after encrypting $q$ messages with XChaCha is $\approx \frac{q^2}{2\cdot 2^{192}}$. So even if $q \approx 2^{32}$ this remains $2^{-129}$, which is negligible.
Appendix: Links
Audit:
Slides:
- Confidential assets: 10,000 feet view
- WIP: Confidential assets: deep dive
- Confidential asset: A cryptographic deep dive
Documentation & articles:
- aptos.dev docs (stale!)
- Build with Confidential Transactions on Aptos (stale!)
- Aptos Improvement Proposal (AIP) 143: Confidential APT (will be updated with more nuance)
Repositories:
- Ristretto255 discrete logarithm algorithms
- WASM bindings for DL algorithms and Bulletproofs
- Move contract (stale! see PRs below)
- TypeScript SDK (stale! see PRs below)
- Confidential payments demo webapp (stale! see PRs below)
Apps:
v1.1 pull requests (PRs)
- Merged: v1.1 Move contract in aptos-core: new $\Sigma$ protocol homomorphism framework, domain-separated Fiat-Shamir transform, global and asset-specific auditors, upgradability via
enum’s - Merged: v1.1.1 Move contract in aptos-core: enforce allow-listing on testnet too
- Merged: v1.1.2 Move contract in aptos-core: auditor EK fix and other minor fixes
- Merged: v1.1.3 Move contract in aptos-core: emergency pause
- Merged: Fiat-Shamir bugfix in aptos-core: add $\sigma$’s to the transcript before deriving $\beta$ challenges 🤦
- v1.1.4 Move contract in aptos-core: keyless integration
- Merged: DL algorithms in ristretto255-dlog
- Merged: WASM bindings for DL algorithms and Bulletproofs in confidential-asset-wasm-bindings
- Merged: v1.1 SDK in aptos-ts-sdk
- Merged: v1.1.2 and v1.1.3 SDK in aptos-ts-sdk
- aptos.dev docs in aptos-docs
Appendix: Benchmarks
All benchmarks are run single-threaded on an Apple Macbook Pro M4 Max.
Bit-widths of Aptos balances (in octas) on mainnet
tl;dr: One third have at most 1, 2, 3 or 4 bits. 18% have 15 bits. 15% have 14 or 16 bits. Graph below 👇

Ristretto255: curve-dalek25519-ng microbenchmarks
ristretto255 point compression
time: [3.7242 µs 3.7265 µs 3.7290 µs]
ristretto255 point addition
time: [126.28 ns 127.90 ns 130.90 ns]
MSM benchmarks:
ristretto255/vartime_multiscalar_mul/64
time: [301.26 µs 301.73 µs 302.21 µs]
ristretto255/vartime_multiscalar_mul/128
time: [597.56 µs 598.18 µs 598.79 µs]
ristretto255/vartime_multiscalar_mul/256
time: [1.1076 ms 1.1091 ms 1.1106 ms]
ristretto255/vartime_multiscalar_mul/512
time: [1.9242 ms 1.9265 ms 1.9295 ms]
ristretto255/vartime_multiscalar_mul/1024
time: [3.3877 ms 3.3911 ms 3.3944 ms]
ristretto255/vartime_multiscalar_mul/2048
time: [6.0133 ms 6.0203 ms 6.0275 ms]
Unfortunately, Ristretto255 is cursed: it needs canonical square roots computed during point compression, which are not batchable. (This is inherent for any Decaf-like group, it seems.) This means that the DL algorithms are dominated by point-compression necessary to index into the precomputed tables.
WASM size
Currently, WASM size is 774 KiB. Most of this is just the 512 KiB precomputed table for TBSGS-$k$.
git clone https://github.com/aptos-labs/confidential-asset-wasm-bindings
cd confidential-asset-wasm-bindings/
./scripts/wasm-sizes.sh
This unified WASM combines both discrete log and range proof functionality into a single module, sharing the curve25519-dalek elliptic curve library.
Gas benchmarks for confidential_asset v1.1 Move module
Events cost essentially nothing: I benchmarked with V2 empty events and the difference is 0-8 gas units across all operations. The fat V1 events are fine.
Confidential vs. normal transfer
Q: “How much more expensive are confidential transfers?”
A: ~30x gas, ~59x TXN size 👇
| Operation | Gas | Gas overhead | TXN payload (KiB) | TXN payload overhead |
|---|---|---|---|---|
| Normal fungible asset transfer | 100 | — | 0.07 | — |
| Confidential transfer (no auditors) | 3,026 | 30.3x | 4.13 | 58.7x |
Detailed gas costs
The full gas benchmark logs are here, but a nicer summary follows below.
Steady-state costs
These are the costs that matter in practice: all on-chain storage already exists from prior calls.
| Operation | Gas | Cost (cents) | # of calls / $1 | Sigma (KiB) | Bulletproofs (KiB) | Other data (KiB) | Total crypto overhead (KiB) |
|---|---|---|---|---|---|---|---|
| register | 12,841 | 1.2841 | 77 | 0.06 (66%) | — | 0.03 (33%) | 0.09 |
| deposit | 182 | 0.0182 | 5,494 | — | — | — | — |
| rollover | 132 | 0.0132 | 7,575 | — | — | — | — |
| rotate key | 370 | 0.0370 | 2,702 | 0.44 (60%) | — | 0.28 (39%) | 0.72 |
| withdraw (no auditor) | 2,017 | 0.2017 | 495 | 1.09 (47%) | 0.72 (31%) | 0.50 (21%) | 2.31 |
| withdraw (eff. auditor) | 2,219 | 0.2219 | 450 | 1.34 (47%) | 0.72 (25%) | 0.75 (26%) | 2.81 |
| transfer (no auditors) | 3,026 | 0.3026 | 330 | 1.72 (43%) | 1.38 (34%) | 0.88 (22%) | 3.97 |
| transfer (1 extra only) | 3,130 | 0.3130 | 319 | 1.84 (43%) | 1.38 (32%) | 1.03 (24%) | 4.25 |
| transfer (2 extra only) | 3,216 | 0.3216 | 311 | 1.97 (43%) | 1.38 (30%) | 1.19 (26%) | 4.53 |
| transfer (3 extra only) | 3,320 | 0.3320 | 301 | 2.09 (43%) | 1.38 (28%) | 1.34 (27%) | 4.81 |
| transfer (eff. auditor only) | 3,309 | 0.3309 | 302 | 2.09 (44%) | 1.38 (29%) | 1.25 (26%) | 4.72 |
| transfer (eff. + 1 extra) | 3,413 | 0.3413 | 293 | 2.22 (44%) | 1.38 (27%) | 1.41 (28%) | 5.00 |
| transfer (eff. + 2 extra) | 3,499 | 0.3499 | 285 | 2.34 (44%) | 1.38 (26%) | 1.56 (29%) | 5.28 |
| transfer (eff. + 3 extra) | 3,603 | 0.3603 | 277 | 2.47 (44%) | 1.38 (24%) | 1.72 (30%) | 5.56 |
First-time costs
These operations incur a one-time storage fee when they first create on-chain data (e.g., the FA pool store for deposits, or the auditor’s R_aud component for withdraw/transfer). The TXN sizes are identical to steady-state since the same data is submitted.
| Operation | Gas | vs. steady-state |
|---|---|---|
| deposit (first time) | 5,515 | 30.3x |
| withdraw (eff. auditor, first time) | 3,315 | 1.5x |
| transfer (eff. auditor only, first time) | 4,405 | 1.3x |
| transfer (eff. + 1 extra, first time) | 4,509 | 1.3x |
| transfer (eff. + 2 extra, first time) | 4,595 | 1.3x |
| transfer (eff. + 3 extra, first time) | 4,699 | 1.3x |
v1.0 → v1.1 speedup
The gas schedule changed between v1.0 and v1.1 benchmarks (~10x increase in gas units). The speedup ratios below compare v1.0 and v1.1 numbers measured on the same gas schedule (the old one). The absolute numbers above reflect the current gas schedule.
| Operation | v1.0 gas | v1.1 gas | Speedup |
|---|---|---|---|
| register | 1,276 | 1,282 | ~same (storage-dominated) |
| deposit | 19 | 18 | ~same |
| rollover | 13 | 13 | same |
| rotate key | 215 | 37 | 5.81x |
| withdraw | 215 | 204 | 1.05x |
| transfer | 339 | 305 | 1.11x |
For the old code, see v1.0 gas benchmark logs here.
Appendix: Implementation challenges
Move serialization of handle-based “native” structs like RistrettoPoint
We cannot easily deserialize structs like:
/// A sigma protocol *proof* always consists of:
/// 1. a *commitment* $A \in \mathbb{G}^m$
/// 2. a *response* $\sigma \in \mathbb{F}^k$
struct Proof has drop {
A: vector<RistrettoPoint>,
sigma: vector<Scalar>,
}
…because RistrettoPoint just contains a Move VM handle pointing to an underlying Move VM Rust struct.
We instead have to define a special de-serializable type:
struct SerializableProof has drop {
A: vector<CompressedRistretto>,
sigma: vector<Scalar>,
}
…because CompressedRistretto is serializable: it just wraps a vector<u8>.
Then, we have to write some custom logic in Move that deserializes bytes into a Proof by going through the intermediate SerializableProof struct.
But we cannot even write from_bcs::from_bytes<SerializableProof>(bytes) in Move, because a publicly-exposed from_bytes would allow anyone to create any structs they want, which breaks Move’s “structs as capabilities” security model.
So, in the end, we just have to write a function like this for every struct we need deserialized:
fun deserialize_proof(A: vector<vector<u8>>, sigma: vector<vector<u8>>): Proof
Annoying.
Alternatively, but probably more expensive, since we are writing Aptos framework code, we could make aptos_framework::confidential_asset a friend of aptos_framework::util and call unsafe BCS deserialization code to obtain an intermediate SerializableProof struct:
use aptos_framework::util;
fun deserialize_proof_bcs(bytes: vector<u8>): Proof {
let proof: SerializableProof = util::from_bytes(bytes);
sigma_protocols::proof::from_serializable_proof(proof)
}
…but we still have to, more, or less, manually write code for each struct that converts between its “serializable” counterpart and its actual counterpart.
So, by that point, we may as well just implement deserialize_proof() and deserialize_<struct>() in general for all of our structs.
Annoying.
References
For cited works, see below 👇👇
(In terms of availability, this does not impose extra assumptions: the pepper service is already trusted for availability.) As a result, decentralizing the pepper service becomes very important if users are to rely on it in this way.
-
Bulletproofs: Short Proofs for Confidential Transactions and More, by B. Bünz and J. Bootle and D. Boneh and A. Poelstra and P. Wuille and G. Maxwell, in 2018 IEEE Symposium on Security and Privacy (SP), 2018 ↩
-
Confidential transactions, by Gregory Maxwell, 2015, [URL] ↩
-
Zether: Towards Privacy in a Smart Contract World, by Bünz, Benedikt and Agrawal, Shashank and Zamani, Mahdi and Boneh, Dan, in Financial Cryptography and Data Security, 2020 ↩
-
PGC: Pretty Good Decentralized Confidential Payment System with Auditability, by Yu Chen and Xuecheng Ma and Cong Tang and Man Ho Au, in Cryptology ePrint Archive, Report 2019/319, 2019, [URL] ↩
-
I wonder if this is generally true… ↩
-
Auditor balance ciphertexts are maintained on a “best-effort” basis: they can only be updated during a transfer out of an account, during a public withdrawal, or during a normalization. But they cannot be updated after an incoming transfer. They can be slightly stale in this sense. Put differently, only the “available” balance ciphertext is encrypted under the effective auditor’s EK, but not the “pending” balance. ↩
-
Computing small discrete logarithms faster, by Daniel Bernstein and Tanja Lange, 2012, [URL] ↩ ↩2 ↩3 ↩4
-
Writing efficient and secure ZK circuits is extremely difficult. I quote from a recent survey paper11 on implementing general-purpose zkSNARK-based systems: “We find that developers seem to struggle in correctly implementing arithmetic circuits that are free of vulnerabilities, especially due to most tools exposing a low-level programming interface that can easily lead to misuse without extensive domain knowledge in cryptography.” ↩
-
This convenience does come at the price of reducing confidentiality to the privacy of the keyless pepper service: if the service is compromised, then the pepper is revealed and thus confidentiality is lost. ↩
-
There are two alternatives we could adopt, but neither are great. The first alternative would ask the user to rotate their 2-factor keyless account to a traditional Ed25519 account. This is not great as we are asking the user to give up their OIDC factor so we can more easily manage their DK in the wallet (by deriving it directly from the Ed25519 SK). Yet the user may later recover access to their OIDC factor and may even be surprised when he cannot access the account anymore because he may not have understood the “gravity” of the key rotation. The second alternative, thanks to Philip Vu for pointing it out, is to once again derive the DK from the Ed25519 SK, but leave the OIDC factor in. If the user ever signs in with the OIDC factor, we can ask them for their Ed25519 mnemonic to obtain the old DK and do a DK rotation to a new pepper-based DK (and also encrypt this new DK on-chain). The problem with this approach is we could inadvertently lock users out of their confidential assets: i.e., users who forgot their mnemonic and are now logging in with their OIDC. ↩
-
SoK: What don’t we know? Understanding Security Vulnerabilities in SNARKs, by Stefanos Chaliasos and Jens Ernstberger and David Theodore and David Wong and Mohammad Jahanara and Benjamin Livshits, 2024 ↩