fix: added proper nullifier matching and ragequit fetching

This commit is contained in:
moebius
2025-03-14 14:35:36 +01:00
parent 1ba7e58fbc
commit a93c0f7419
3 changed files with 313 additions and 89 deletions

View File

@@ -9,8 +9,10 @@ import {
PoolInfo,
PrivacyPoolAccount,
} from "../types/account.js";
import { DepositEvent, RagequitEvent, WithdrawalEvent } from "../types/events.js";
import { Logger } from "../utils/logger.js";
import { AccountError } from "../errors/account.error.js";
import { ErrorCode } from "../errors/base.error.js";
/**
* Service responsible for managing privacy pool accounts and their associated commitments.
@@ -42,6 +44,21 @@ export class AccountService {
this.account = account || this._initializeAccount(mnemonic);
}
/**
* Initializes a new account from a mnemonic phrase.
*
* @param mnemonic - The mnemonic phrase to derive keys from
* @returns A new PrivacyPoolAccount with derived master keys
*
* @remarks
* This method derives two master keys from the mnemonic:
* 1. A master nullifier key from account index 0
* 2. A master secret key from account index 1
* These keys are used to deterministically generate nullifiers and secrets for deposits and withdrawals.
*
* @throws {AccountError} If account initialization fails
* @private
*/
private _initializeAccount(mnemonic: string): PrivacyPoolAccount {
try {
this.logger.debug("Initializing account with mnemonic");
@@ -70,26 +87,67 @@ export class AccountService {
}
}
/**
* Generates a deterministic nullifier for a deposit.
*
* @param scope - The scope of the pool
* @param index - The index of the deposit
* @returns A deterministic nullifier for the deposit
* @private
*/
private _genDepositNullifier(scope: Hash, index: bigint): Secret {
const [masterNullifier] = this.account.masterKeys;
return poseidon([masterNullifier, scope, index]) as Secret;
}
/**
* Generates a deterministic secret for a deposit.
*
* @param scope - The scope of the pool
* @param index - The index of the deposit
* @returns A deterministic secret for the deposit
* @private
*/
private _genDepositSecret(scope: Hash, index: bigint): Secret {
const [, masterSecret] = this.account.masterKeys;
return poseidon([masterSecret, scope, index]) as Secret;
}
/**
* Generates a deterministic nullifier for a withdrawal.
*
* @param label - The label of the commitment
* @param index - The index of the withdrawal
* @returns A deterministic nullifier for the withdrawal
* @private
*/
private _genWithdrawalNullifier(label: Hash, index: bigint): Secret {
const [masterNullifier] = this.account.masterKeys;
return poseidon([masterNullifier, label, index]) as Secret;
}
/**
* Generates a deterministic secret for a withdrawal.
*
* @param label - The label of the commitment
* @param index - The index of the withdrawal
* @returns A deterministic secret for the withdrawal
* @private
*/
private _genWithdrawalSecret(label: Hash, index: bigint): Secret {
const [, masterSecret] = this.account.masterKeys;
return poseidon([masterSecret, label, index]) as Secret;
}
/**
* Hashes a commitment using the Poseidon hash function.
*
* @param value - The value of the commitment
* @param label - The label of the commitment
* @param precommitment - The precommitment hash
* @returns The commitment hash
* @private
*/
private _hashCommitment(
value: bigint,
label: Hash,
@@ -98,6 +156,14 @@ export class AccountService {
return poseidon([value, label, precommitment]) as Hash;
}
/**
* Hashes a precommitment using the Poseidon hash function.
*
* @param nullifier - The nullifier for the commitment
* @param secret - The secret for the commitment
* @returns The precommitment hash
* @private
*/
private _hashPrecommitment(nullifier: Secret, secret: Secret): Hash {
return poseidon([nullifier, secret]) as Hash;
}
@@ -106,6 +172,11 @@ export class AccountService {
* Gets all spendable commitments across all pools.
*
* @returns A map of scope to array of spendable commitments
*
* @remarks
* A commitment is considered spendable if:
* 1. It has a non-zero value
* 2. The account it belongs to has not been ragequit
*/
public getSpendableCommitments(): Map<bigint, AccountCommitment[]> {
const result = new Map<bigint, AccountCommitment[]>();
@@ -114,6 +185,11 @@ export class AccountService {
const nonZeroCommitments: AccountCommitment[] = [];
for (const account of accounts) {
// Skip accounts that have been ragequit
if (account.ragequit) {
continue;
}
const lastCommitment =
account.children.length > 0
? account.children[account.children.length - 1]
@@ -137,6 +213,10 @@ export class AccountService {
* @param scope - The scope of the pool to deposit into
* @param index - Optional index for deterministic generation
* @returns The nullifier, secret, and precommitment for the deposit
*
* @remarks
* If no index is provided, it uses the current number of accounts for the scope.
* The precommitment is a hash of the nullifier and secret, used in the deposit process.
*/
public createDepositSecrets(
scope: Hash,
@@ -161,6 +241,12 @@ export class AccountService {
*
* @param commitment - The commitment to spend
* @returns The nullifier and secret for the new commitment
*
* @remarks
* The index used for generating the withdrawal nullifier and secret is based on
* the number of children the account already has, ensuring each withdrawal has
* a unique nullifier.
*
* @throws {AccountError} If no account is found for the commitment
*/
public createWithdrawalSecrets(commitment: AccountCommitment): {
@@ -198,6 +284,11 @@ export class AccountService {
* @param blockNumber - The block number of the deposit
* @param txHash - The transaction hash of the deposit
* @returns The new pool account
*
* @remarks
* This method creates a new account with the deposit commitment and adds it to the
* pool accounts map under the specified scope. The commitment hash is calculated
* from the value, label, and precommitment.
*/
public addPoolAccount(
scope: Hash,
@@ -248,6 +339,12 @@ export class AccountService {
* @param blockNumber - The block number of the withdrawal
* @param txHash - The transaction hash of the withdrawal
* @returns The new commitment
*
* @remarks
* This method finds the account containing the parent commitment, creates a new
* commitment with the provided parameters, and adds it to the account's children.
* The new commitment inherits the label from the parent commitment.
*
* @throws {AccountError} If no account is found for the commitment
*/
public addWithdrawalCommitment(
@@ -299,6 +396,53 @@ export class AccountService {
return newCommitment;
}
/**
* Adds a ragequit event to an existing pool account
*
* @param label - The label of the account to add the ragequit to
* @param ragequit - The ragequit event to add
* @returns The updated pool account
*
* @remarks
* When an account has a ragequit event, it can no longer be spent.
* This method finds the account with the matching label and attaches
* the ragequit event to it.
*
* @throws {AccountError} If no account is found with the given label
*/
public addRagequitToAccount(
label: Hash,
ragequit: RagequitEvent
): PoolAccount {
let foundAccount: PoolAccount | undefined;
let foundScope: Hash | undefined;
// Find the account with the matching label
for (const [scope, accounts] of this.account.poolAccounts.entries()) {
foundAccount = accounts.find((account) => account.label === label);
if (foundAccount) {
foundScope = scope;
break;
}
}
if (!foundAccount || !foundScope) {
throw new AccountError(
`No account found with label ${label}`,
ErrorCode.INVALID_INPUT
);
}
// Add the ragequit event to the account
foundAccount.ragequit = ragequit;
this.logger.info(
`Added ragequit event to account with label ${label}, value ${ragequit.value}`
);
return foundAccount;
}
/**
* Retrieves the history of deposits and withdrawals for the given pools.
*
@@ -306,68 +450,97 @@ export class AccountService {
*
* @remarks
* This method performs the following steps:
* 1. Fetches all deposit events for each pool
* 2. Reconstructs account state from deposits
* 3. Processes withdrawals to update account state
* 1. Initializes pool accounts for each pool if they don't exist
* 2. For each pool, fetches deposit events and reconstructs accounts
* 3. Processes withdrawals and ragequits to update account state
*
* The account reconstruction is deterministic based on the master keys,
* allowing the full state to be recovered from on-chain events.
*
* @throws {DataError} If event fetching fails
* @throws {AccountError} If account state reconstruction fails
*/
public async retrieveHistory(pools: PoolInfo[]): Promise<void> {
// Log the start of the history retrieval process
this.logger.info(`Fetching events for ${pools.length} pools`);
// Initialize pool accounts map for each pool if it doesn't exist
for (const pool of pools) {
if (!this.account.poolAccounts.has(pool.scope)) {
this.account.poolAccounts.set(pool.scope, []);
}
}
// Process all pools in parallel for better performance
await Promise.all(
pools.map(async (pool) => {
// Log which pool is being processed
this.logger.info(
`Processing pool ${pool.address} on chain ${pool.chainId} from block ${pool.deploymentBlock}`,
);
const deposits = await this.dataService.getDeposits(pool.chainId, {
fromBlock: pool.deploymentBlock,
});
// Fetch all deposit events for this pool
const deposits = await this.dataService.getDeposits(pool);
this.logger.info(
`Found ${deposits.length} deposits for pool ${pool.address}`,
);
const depositMap = new Map<Hash, (typeof deposits)[0]>();
// Create a map of deposits by precommitment for efficient lookup
const depositMap = new Map<Hash, DepositEvent>();
for (const deposit of deposits) {
depositMap.set(deposit.precommitment, deposit);
}
// Track found deposits for logging and debugging
const foundDeposits: Array<{
index: bigint;
nullifier: Secret;
secret: Secret;
pool: PoolInfo;
deposit: (typeof deposits)[0];
}> = [];
// Start with index 0 and try to find deposits deterministically
let index = BigInt(0);
let firstDepositBlock: bigint | undefined;
// Deterministically generate deposit secrets and check if they match on-chain deposits
while (true) {
// Generate nullifier, secret, and precommitment for this index
const nullifier = this._genDepositNullifier(pool.scope, index);
const secret = this._genDepositSecret(pool.scope, index);
const precommitment = this._hashPrecommitment(nullifier, secret);
// Look for a deposit with this precommitment
const deposit = depositMap.get(precommitment);
if (!deposit) break;
if (!deposit) break; // No more deposits found, exit the loop
// Track the earliest deposit block for later withdrawal processing
if (!firstDepositBlock || deposit.blockNumber < firstDepositBlock) {
firstDepositBlock = deposit.blockNumber;
}
foundDeposits.push({ index, nullifier, secret, deposit });
// Create a new pool account for this deposit
this.addPoolAccount(
pool.scope,
deposit.value,
nullifier,
secret,
deposit.label,
deposit.blockNumber,
deposit.transactionHash,
);
// Track the found deposit
foundDeposits.push({ index, nullifier, secret, pool, deposit });
// Move to the next index
index++;
}
if (foundDeposits.length === 0) {
// If no accounts were found for this scope, log and skip further processing
if (this.account.poolAccounts.get(pool.scope)!.length === 0) {
this.logger.info(
`No Pool Accounts were found for scope ${pool.scope}`,
);
@@ -378,71 +551,123 @@ export class AccountService {
`Found ${foundDeposits.length} deposits for pool ${pool.address}`,
);
// Process deposits first
const accounts = foundDeposits.map(({ nullifier, secret, deposit }) => {
return this.addPoolAccount(
pool.scope,
deposit.value,
nullifier,
secret,
deposit.label,
deposit.blockNumber,
deposit.transactionHash,
);
});
// Create a map for faster account lookups
const accountMap = new Map<Hash, PoolAccount>();
for (const account of accounts) {
accountMap.set(account.label, account);
}
// Process withdrawals
await this._processWithdrawals(
pool.chainId,
firstDepositBlock!,
accountMap,
);
// Process withdrawals and ragequits for all pools
// This is done after all deposits are processed to ensure we have the complete account state
await this._processWithdrawals(pools);
}),
);
}
/**
* Processes withdrawal events for all pools and updates account state.
*
* @param pools - Array of pool configurations to process withdrawals for
*
* @remarks
* This method performs the following steps for each pool:
* 1. Identifies the earliest deposit block for each scope
* 2. Fetches withdrawal and ragequit events from that block
* 3. Maps withdrawals by nullifier hash and ragequits by label for efficient lookup
* 4. For each account, reconstructs the withdrawal history by:
* - Generating nullifiers sequentially
* - Matching them against on-chain events
* - Adding matched withdrawals to the account state
* 5. Adds ragequit events to accounts if found
*
* @throws {DataError} If event fetching fails
* @private
*/
private async _processWithdrawals(
chainId: number,
fromBlock: bigint,
foundAccounts: Map<Hash, PoolAccount>,
pools: PoolInfo[]
): Promise<void> {
const withdrawals = await this.dataService.getWithdrawals(chainId, {
fromBlock,
});
await Promise.all(
pools.map(async (pool) => {
const accounts = this.account.poolAccounts.get(pool.scope);
for (const withdrawal of withdrawals) {
for (const account of foundAccounts.values()) {
const isParentCommitment =
BigInt(account.deposit.nullifier) === BigInt(withdrawal.spentNullifier) ||
account.children.some(child => BigInt(child.nullifier) === BigInt(withdrawal.spentNullifier));
// Skip if no accounts for this scope
if (!accounts || accounts.length === 0) {
this.logger.info(
`No accounts found for pool ${pool.address} with scope ${pool.scope}`
);
return;
}
if (isParentCommitment) {
const parentCommitment = account.children.length > 0
? account.children[account.children.length - 1]
: account.deposit;
// Find the earliest deposit block for this scope
let firstDepositBlock = BigInt(Number.MAX_SAFE_INTEGER);
for (const account of accounts) {
if (account.deposit.blockNumber < firstDepositBlock) {
firstDepositBlock = account.deposit.blockNumber;
}
}
if (!parentCommitment) {
this.logger.warn(`No parent commitment found for withdrawal ${withdrawal.spentNullifier.toString()}`);
continue;
// Fetch withdrawal events from the first deposit block
const withdrawals = await this.dataService.getWithdrawals(pool, firstDepositBlock);
const ragequits = await this.dataService.getRagequits(pool, firstDepositBlock);
this.logger.info(
`Found ${withdrawals.length} withdrawals for pool ${pool.address}`
);
if (withdrawals.length === 0) {
return;
}
// Map withdrawals by spent nullifier for quick lookup
const withdrawalMap = new Map<Hash, WithdrawalEvent>();
for (const withdrawal of withdrawals) {
withdrawalMap.set(withdrawal.spentNullifier, withdrawal);
}
// Map ragequits by label for quick lookup
const ragequitMap = new Map<Hash, RagequitEvent>();
for (const ragequit of ragequits) {
ragequitMap.set(ragequit.label, ragequit);
}
// Process each account
for (const account of accounts) {
let currentCommitment = account.deposit;
let index = BigInt(0);
// Continue processing withdrawals until no more are found
while (true) {
// Generate nullifier for this withdrawal
const nullifier = this._genWithdrawalNullifier(account.label, index);
const nullifierHash = poseidon([nullifier]) as Hash;
// Look for a withdrawal event with this nullifier
const withdrawal = withdrawalMap.get(nullifierHash);
if (!withdrawal) {
break;
}
// Generate secret for this withdrawal
const secret = this._genWithdrawalSecret(account.label, index);
// Add the withdrawal commitment to the account
const newCommitment = this.addWithdrawalCommitment(
currentCommitment,
withdrawal.withdrawn,
nullifier,
secret,
withdrawal.blockNumber,
withdrawal.transactionHash
);
// Update current commitment to the newly created one
currentCommitment = newCommitment;
// Increment index for next potential withdrawal
index++;
}
this.addWithdrawalCommitment(
parentCommitment,
withdrawal.withdrawn,
withdrawal.spentNullifier as unknown as Secret,
parentCommitment.secret,
withdrawal.blockNumber,
withdrawal.transactionHash,
);
break;
const ragequit = ragequitMap.get(account.label);
if (ragequit) {
this.addRagequitToAccount(account.label, ragequit);
}
}
}
}
})
);
}
}

View File

@@ -11,6 +11,7 @@ import {
WithdrawalEvent,
RagequitEvent,
} from "../types/events.js";
import { PoolInfo } from "../types/account.js";
import { Hash } from "../types/commitment.js";
import { Logger } from "../utils/logger.js";
import { DataError } from "../errors/data.error.js";
@@ -73,18 +74,16 @@ export class DataService {
* @throws {DataError} If client is not configured, network error occurs, or event data is invalid
*/
async getDeposits(
chainId: number,
options: EventFilterOptions = {},
pool: PoolInfo
): Promise<DepositEvent[]> {
try {
const client = this.getClientForChain(chainId);
const config = this.getConfigForChain(chainId);
const client = this.getClientForChain(pool.chainId);
const config = this.getConfigForChain(pool.chainId);
const logs = await client.getLogs({
address: config.privacyPoolAddress,
address: pool.address,
event: DEPOSIT_EVENT,
fromBlock: options.fromBlock ?? config.startBlock,
toBlock: options.toBlock,
fromBlock: pool.deploymentBlock ?? config.startBlock
}).catch(error => {
throw new DataError(
"Failed to fetch deposit logs",
@@ -127,7 +126,7 @@ export class DataService {
});
} catch (error) {
if (error instanceof DataError) throw error;
throw DataError.networkError(chainId, error instanceof Error ? error : new Error(String(error)));
throw DataError.networkError(pool.chainId, error instanceof Error ? error : new Error(String(error)));
}
}
@@ -140,18 +139,17 @@ export class DataService {
* @throws {DataError} If client is not configured, network error occurs, or event data is invalid
*/
async getWithdrawals(
chainId: number,
options: EventFilterOptions = {},
pool: PoolInfo,
fromBlock: bigint = pool.deploymentBlock
): Promise<WithdrawalEvent[]> {
try {
const client = this.getClientForChain(chainId);
const config = this.getConfigForChain(chainId);
const client = this.getClientForChain(pool.chainId);
const config = this.getConfigForChain(pool.chainId);
const logs = await client.getLogs({
address: config.privacyPoolAddress,
address: pool.address,
event: WITHDRAWAL_EVENT,
fromBlock: options.fromBlock ?? config.startBlock,
toBlock: options.toBlock,
fromBlock: fromBlock ?? config.startBlock,
}).catch(error => {
throw new DataError(
"Failed to fetch withdrawal logs",
@@ -190,7 +188,7 @@ export class DataService {
});
} catch (error) {
if (error instanceof DataError) throw error;
throw DataError.networkError(chainId, error instanceof Error ? error : new Error(String(error)));
throw DataError.networkError(pool.chainId, error instanceof Error ? error : new Error(String(error)));
}
}
@@ -203,18 +201,17 @@ export class DataService {
* @throws {DataError} If client is not configured, network error occurs, or event data is invalid
*/
async getRagequits(
chainId: number,
options: EventFilterOptions = {},
pool: PoolInfo,
fromBlock: bigint = pool.deploymentBlock
): Promise<RagequitEvent[]> {
try {
const client = this.getClientForChain(chainId);
const config = this.getConfigForChain(chainId);
const client = this.getClientForChain(pool.chainId);
const config = this.getConfigForChain(pool.chainId);
const logs = await client.getLogs({
address: config.privacyPoolAddress,
address: pool.address,
event: RAGEQUIT_EVENT,
fromBlock: options.fromBlock ?? config.startBlock,
toBlock: options.toBlock,
fromBlock: fromBlock ?? config.startBlock,
}).catch(error => {
throw new DataError(
"Failed to fetch ragequit logs",
@@ -255,7 +252,7 @@ export class DataService {
});
} catch (error) {
if (error instanceof DataError) throw error;
throw DataError.networkError(chainId, error instanceof Error ? error : new Error(String(error)));
throw DataError.networkError(pool.chainId, error instanceof Error ? error : new Error(String(error)));
}
}

View File

@@ -1,10 +1,12 @@
import { Hash, Secret } from "./commitment.js";
import { Hex } from "viem";
import { RagequitEvent } from "./events.js";
export interface PoolAccount {
label: Hash;
deposit: AccountCommitment;
children: AccountCommitment[];
ragequit?: RagequitEvent
}
export interface AccountCommitment {
@@ -27,7 +29,7 @@ export interface PrivacyPoolAccount {
export interface PoolInfo {
chainId: number;
address: string;
address: Hex;
scope: Hash;
deploymentBlock: bigint;
}