Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
min-release-age=7
26 changes: 23 additions & 3 deletions circom/components.circom
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,13 @@ template PoseidonDecrypt(l) {
// Check the last ciphertext element
ciphertext[decryptedLength] === strategies[n].out[1];

// If length > 3, check if the last (3 - (l mod 3)) elements of the message
// are 0
// If length not devisible by 3 then add padding
if (l % 3 > 0) {
if (l % 3 == 1) {
decrypted[decryptedLength - 1] === 0;
decrypted[decryptedLength - 2] === 0;
} else if (l % 3 == 2) {
decrypted[decryptedLength - 1] === 0;
decrypted[decryptedLength - 2] === 0;
}
}
}
Expand Down Expand Up @@ -113,6 +112,13 @@ template ElGamalEncrypt() {
checkPoint2.x <== msg[0];
checkPoint2.y <== msg[1];

// Verify the randomness is not zero
// With random = 0 the ciphertext degenerates to (identity, msg), which any observer can
// read, so the encrypted value would no longer be private.
component checkRandomIsZero = IsZero();
checkRandomIsZero.in <== random;
checkRandomIsZero.out === 0;

component randomBits = Num2Bits(253);
randomBits.in <== random;

Expand Down Expand Up @@ -335,6 +341,13 @@ template CheckPCT() {
lt.in[1] <== baseOrder;
lt.out === 1;

// Verify the randomness is not zero
// With random = 0 both the auth key and the encryption key become the identity point,
// which is public knowledge, so anyone could decrypt the PCT.
component checkRandomIsZero = IsZero();
checkRandomIsZero.in <== random;
checkRandomIsZero.out === 0;

component checkAuthKey = BabyPbk();
checkAuthKey.in <== random;

Expand Down Expand Up @@ -377,6 +390,13 @@ template CheckRegistrationHash() {
signal input senderPrivateKey;
signal input senderAddress;

// Verify the sender address fits in 160 bits
// Registrar.register() reads the account as address(uint160(input[2])). Without this
// bound, senderAddress + k * 2^160 hashes differently while resolving to the same
// account, which defeats the duplicate-registration guard.
component addressBits = Num2Bits(160);
addressBits.in <== senderAddress;

component hash = Poseidon(3);
hash.inputs[0] <== chainID;
hash.inputs[1] <== senderPrivateKey;
Expand Down
81 changes: 42 additions & 39 deletions contracts/EncryptedERC.sol
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ import {BabyJubJub} from "./libraries/BabyJubJub.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

// types
import {CreateEncryptedERCParams, Point, EGCT, EncryptedBalance, AmountPCT, MintProof, TransferProof, WithdrawProof, BurnProof, TransferInputs} from "./types/Types.sol";
import {CreateEncryptedERCParams, Point, EGCT, AmountPCT, MintProof, TransferProof, WithdrawProof, BurnProof, TransferInputs} from "./types/Types.sol";

// errors
import {UserNotRegistered, InvalidProof, TransferFailed, UnknownToken, InvalidChainId, InvalidNullifier, ZeroAddress} from "./errors/Errors.sol";
import {UserNotRegistered, InvalidProof, TransferFailed, UnknownToken, InvalidChainId, InvalidNullifier, ZeroAddress, AmountTooSmall} from "./errors/Errors.sol";

// interfaces
import {IRegistrar} from "./interfaces/IRegistrar.sol";
Expand All @@ -27,7 +27,6 @@ import {IWithdrawVerifier} from "./interfaces/verifiers/IWithdrawVerifier.sol";
import {ITransferVerifier} from "./interfaces/verifiers/ITransferVerifier.sol";
import {IBurnVerifier} from "./interfaces/verifiers/IBurnVerifier.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";

// /$$$$$$$$ /$$$$$$$ /$$$$$$
// | $$_____/| $$__ $$ /$$__ $$
Expand Down Expand Up @@ -621,31 +620,32 @@ contract EncryptedERC is
address tokenAddress,
uint256[7] memory amountPCT
) internal returns (uint256 dust, uint256 tokenId) {
// Get token decimals and handle scaling
uint8 tokenDecimals = IERC20Metadata(tokenAddress).decimals();
// Register the token if it's new. This captures decimals() once, at registration, so a
// token cannot report one value on deposit and another on withdrawal.
if (tokenIds[tokenAddress] == 0) {
_addToken(tokenAddress);
}
tokenId = tokenIds[tokenAddress];

// Get the registered token decimals and handle scaling
uint8 registeredDecimals = tokenDecimals[tokenId];

uint256 value = amount;
dust = 0;

// Scale down if token has more decimals
if (tokenDecimals > decimals) {
uint256 scalingFactor = 10 ** (tokenDecimals - decimals);
if (registeredDecimals > decimals) {
uint256 scalingFactor = 10 ** (registeredDecimals - decimals);
value = amount / scalingFactor;
dust = amount % scalingFactor;
}
// Scale up if token has fewer decimals
else if (tokenDecimals < decimals) {
uint256 scalingFactor = 10 ** (decimals - tokenDecimals);
else if (registeredDecimals < decimals) {
uint256 scalingFactor = 10 ** (decimals - registeredDecimals);
value = amount * scalingFactor;
dust = 0;
}

// Register the token if it's new
if (tokenIds[tokenAddress] == 0) {
_addToken(tokenAddress);
}
tokenId = tokenIds[tokenAddress];

// Return early if the scaled value is zero
if (value == 0) {
return (dust, tokenId);
Expand All @@ -657,29 +657,25 @@ contract EncryptedERC is
uint256[2] memory publicKey = registrar.getUserPublicKey(to);

// Encrypt the value with the receiver's public key
//
// NOTE: BabyJubJub.encrypt uses a fixed randomness of 1, because a contract has no
// source of randomness. Deposit ciphertexts are therefore deterministic: equal
// amounts to the same key produce identical ciphertexts, and the accumulated
// randomness of a deposit-only account is just its publicly observable operation
// count. Such a balance is derivable by anyone from the deposit and withdrawal
// amounts, which are already public in the ERC20 transfers -- but it does mean a
// converter balance carries no confidentiality until the account receives a
// private transfer with real randomness.
EGCT memory eGCT = BabyJubJub.encrypt(
Point({x: publicKey[0], y: publicKey[1]}),
value
);

// Add to the receiver's balance
EncryptedBalance storage balance = balances[to][tokenId];

if (balance.eGCT.c1.x == 0 && balance.eGCT.c1.y == 0) {
balance.eGCT = eGCT;
} else {
balance.eGCT.c1 = BabyJubJub._add(balance.eGCT.c1, eGCT.c1);
balance.eGCT.c2 = BabyJubJub._add(balance.eGCT.c2, eGCT.c2);
}

// Update transaction history
balance.amountPCTs.push(
AmountPCT({pct: amountPCT, index: balance.transactionIndex})
);
balance.transactionIndex++;

// Commit the new balance
_commitUserBalance(to, tokenId);
// Add to the receiver's balance and record the amount PCT.
// Shared with the transfer and mint paths on purpose: this used to be an inline
// copy of _addToUserBalance, which meant the MAX_PENDING_AMOUNT_PCTS ceiling in
// _addToUserHistory did not apply to deposits.
_addToUserBalance(to, tokenId, eGCT, amountPCT);
}

return (dust, tokenId);
Expand All @@ -699,23 +695,30 @@ contract EncryptedERC is
uint256 amount,
address tokenAddress
) internal {
// Get token decimals and handle scaling
uint256 tokenDecimals = IERC20Metadata(tokenAddress).decimals();
// Get the registered token decimals and handle scaling
uint256 registeredDecimals = tokenDecimals[tokenIds[tokenAddress]];

uint256 value = amount;
uint256 scalingFactor = 0;

// Scale up if token has more decimals
if (tokenDecimals > decimals) {
scalingFactor = 10 ** (tokenDecimals - decimals);
if (registeredDecimals > decimals) {
scalingFactor = 10 ** (registeredDecimals - decimals);
value = amount * scalingFactor;
}
// Scale down if token has fewer decimals
else if (tokenDecimals < decimals) {
scalingFactor = 10 ** (decimals - tokenDecimals);
else if (registeredDecimals < decimals) {
scalingFactor = 10 ** (decimals - registeredDecimals);
value = amount / scalingFactor;
}

// Reject amounts that scale down to nothing. The caller's encrypted balance has
// already been debited by the full amount at this point, so paying out zero would
// destroy the value silently and still emit a Withdraw event for it.
if (value == 0) {
revert AmountTooSmall();
}

// Transfer the tokens to the receiver
IERC20 token = IERC20(tokenAddress);
SafeERC20.safeTransfer(token, to, value);
Expand Down
14 changes: 13 additions & 1 deletion contracts/EncryptedUserBalances.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
pragma solidity 0.8.27;

import {EncryptedBalance, EGCT, BalanceHistory, AmountPCT} from "./types/Types.sol";
import {InvalidProof} from "./errors/Errors.sol";
import {InvalidProof, PendingHistoryLimitReached} from "./errors/Errors.sol";
import {BabyJubJub} from "./libraries/BabyJubJub.sol";

/**
Expand All @@ -21,6 +21,13 @@ import {BabyJubJub} from "./libraries/BabyJubJub.sol";
* allowing users to prove they have sufficient funds without revealing the actual amount.
*/
contract EncryptedUserBalances {
///////////////////////////////////////////////////
/// Constants ///
///////////////////////////////////////////////////

// Add limit to pending history to keep the gas cost of pruning history affordable for each account.
uint256 public constant MAX_PENDING_AMOUNT_PCTS = 300;

///////////////////////////////////////////////////
/// State Variables ///
///////////////////////////////////////////////////
Expand Down Expand Up @@ -183,6 +190,11 @@ contract EncryptedUserBalances {
) internal {
EncryptedBalance storage balance = balances[user][tokenId];

// bound the pending history so the prune loop stays affordable for the account
if (balance.amountPCTs.length >= MAX_PENDING_AMOUNT_PCTS) {
revert PendingHistoryLimitReached();
}

uint256 nonce = balance.nonce;
uint256 balanceHash = _hashEGCT(balance.eGCT);
balanceHash = uint256(keccak256(abi.encode(balanceHash, nonce)));
Expand Down
2 changes: 1 addition & 1 deletion contracts/Registrar.sol
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ contract Registrar {
}

// check if the user is already registered
if (isRegistered[registrationHash] && isUserRegistered(account)) {
if (isRegistered[registrationHash] || isUserRegistered(account)) {
revert UserAlreadyRegistered();
}

Expand Down
2 changes: 2 additions & 0 deletions contracts/errors/Errors.sol
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@ error InvalidSender();
error InvalidRegistrationHash();
error ZeroAddress();
error TokenBlacklisted(address token);
error PendingHistoryLimitReached();
error AmountTooSmall();
31 changes: 31 additions & 0 deletions contracts/mocks/EncryptedERCHarness.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// (c) 2026, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.

// SPDX-License-Identifier: Ecosystem

pragma solidity 0.8.27;

import {EncryptedERC} from "../EncryptedERC.sol";
import {AmountPCT, CreateEncryptedERCParams, EncryptedBalance} from "../types/Types.sol";

/**
* @dev Test-only EncryptedERC variant that can construct a worst-case pending history.
*/
contract EncryptedERCHarness is EncryptedERC {
constructor(CreateEncryptedERCParams memory params) EncryptedERC(params) {}

function seedPendingHistory(
address user,
uint256 tokenId,
uint256[7] calldata pct,
uint256 count,
uint256 checkpointIndex
) external {
EncryptedBalance storage balance = balances[user][tokenId];
for (uint256 i = 0; i < count; i++) {
balance.amountPCTs.push(
AmountPCT({pct: pct, index: checkpointIndex})
);
}
}
}
57 changes: 57 additions & 0 deletions contracts/mocks/EncryptedUserBalancesHarness.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// (c) 2026, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.

// SPDX-License-Identifier: Ecosystem

pragma solidity 0.8.27;

import {EncryptedUserBalances} from "../EncryptedUserBalances.sol";
import {AmountPCT, EncryptedBalance} from "../types/Types.sol";

/**
* @dev Test-only harness for measuring the cost of pruning pending amount PCT history.
* It seeds fully non-zero entries so the benchmark reflects the expensive storage-clear path.
*/
contract EncryptedUserBalancesHarness is EncryptedUserBalances {
function seedHistory(
address user,
uint256 tokenId,
uint256 count
) external {
EncryptedBalance storage balance = balances[user][tokenId];
uint256 startIndex = balance.amountPCTs.length;

for (uint256 i = 0; i < count; i++) {
uint256[7] memory pct;
for (uint256 j = 0; j < pct.length; j++) {
pct[j] = (startIndex + i) * pct.length + j + 1;
}
balance.amountPCTs.push(
AmountPCT({pct: pct, index: startIndex + i})
);
}

balance.transactionIndex = startIndex + count;
}

function appendHistory(address user, uint256 tokenId) external {
uint256[7] memory pct;
pct[0] = 1;
_addToUserHistory(user, tokenId, pct);
}

function pruneHistory(
address user,
uint256 tokenId,
uint256 transactionIndex
) external {
_deleteUserHistory(user, tokenId, transactionIndex);
}

function pendingHistoryLength(
address user,
uint256 tokenId
) external view returns (uint256) {
return balances[user][tokenId].amountPCTs.length;
}
}
5 changes: 5 additions & 0 deletions contracts/tokens/TokenTracker.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pragma solidity 0.8.27;

import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {TokenBlacklisted, InvalidOperation} from "../errors/Errors.sol";
import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";

/**
* @title TokenTracker
Expand Down Expand Up @@ -45,6 +46,9 @@ contract TokenTracker is Ownable2Step {
mapping(address tokenAddress => bool isBlacklisted)
public blacklistedTokens;

/// @notice Decimals reported by each registered token, captured at registration
mapping(uint256 tokenId => uint8 decimals) public tokenDecimals;

///////////////////////////////////////////////////
/// Modifiers ///
///////////////////////////////////////////////////
Expand Down Expand Up @@ -139,6 +143,7 @@ contract TokenTracker is Ownable2Step {
uint256 newTokenId = nextTokenId;
tokenIds[tokenAddress] = newTokenId;
tokenAddresses[newTokenId] = tokenAddress;
tokenDecimals[newTokenId] = IERC20Metadata(tokenAddress).decimals();
tokens.push(tokenAddress);
nextTokenId++;
}
Expand Down
Loading
Loading