test(sdk): account service unit tests (#76)

This commit is contained in:
nigiri
2025-04-23 20:30:30 -03:00
committed by GitHub
parent ec4deae3b5
commit da4a14469e
2 changed files with 639 additions and 165 deletions

View File

@@ -53,7 +53,7 @@ export class AccountService {
config: AccountServiceConfig
) {
this.logger = new Logger({ prefix: "Account" });
if("mnemonic" in config) {
if ("mnemonic" in config) {
this.account = this._initializeAccount(config.mnemonic);
} else {
this.account = config.account;
@@ -242,12 +242,12 @@ export class AccountService {
secret: Secret;
precommitment: Hash;
} {
if(index && index < 0n) {
if (index && index < 0n) {
throw AccountError.invalidIndex(index);
}
const accounts = this.account.poolAccounts.get(scope);
index = index || BigInt(accounts?.length || 0);
index = index ?? BigInt(accounts?.length || 0);
const nullifier = this._genDepositNullifier(scope, index);
const secret = this._genDepositSecret(scope, index);

View File

@@ -2,186 +2,660 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { AccountService } from "../../src/core/account.service.js";
import { DataService } from "../../src/core/data.service.js";
import { Hash, Secret } from "../../src/types/commitment.js";
import { DepositEvent, WithdrawalEvent } from "../../src/types/events.js";
import { PoolInfo, AccountCommitment } from "../../src/types/account.js";
import { RagequitEvent } from "../../src/types/events.js";
import {
AccountCommitment,
PoolAccount,
PoolInfo,
PrivacyPoolAccount,
} from "../../src/types/account.js";
import { poseidon } from "maci-crypto/build/ts/hashing.js";
import { Address } from "viem";
import { Address, Hex } from "viem";
import { english, generateMnemonic } from "viem/accounts";
function randomBigInt(): bigint {
return BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER));
}
// Helper function to create mock transaction hashes
function mockTxHash(index: bigint): `0x${string}` {
// Pad the index to create a valid 32-byte hash
const paddedIndex = index.toString(16).padStart(64, '0');
return `0x${paddedIndex}`;
}
import { AccountError } from "../../src/errors/account.error.js";
import { generateMasterKeys } from "../../src/crypto.js";
describe("AccountService", () => {
// Configuration for test data size
const NUM_DEPOSITS = 1; // Number of random deposits
const NUM_WITHDRAWALS = 2; // Number of withdrawals per pool account
// Test pool configuration
const POOL: PoolInfo = {
// Test constants
const TEST_MNEMONIC = generateMnemonic(english);
const TEST_POOL: PoolInfo = {
chainId: 1,
address: "0x8Fac8db5cae9C29e9c80c40e8CeDC47EEfe3874E" as Address,
scope: randomBigInt() as Hash,
scope: BigInt("123456789") as Hash,
deploymentBlock: 1000n,
};
let dataService: DataService;
let accountService: AccountService;
let masterKeys: [Secret, Secret];
let depositEvents: DepositEvent[] = [];
let withdrawalEvents: WithdrawalEvent[] = [];
const testMnemonic = generateMnemonic(english);
beforeEach(() => {
// Reset test data arrays
depositEvents = [];
withdrawalEvents = [];
// Mock the DataService first
dataService = {
getDeposits: vi.fn(async (chainId: number) => {
return chainId === POOL.chainId ? depositEvents : [];
}),
getWithdrawals: vi.fn(async (chainId: number) => {
return chainId === POOL.chainId ? withdrawalEvents : [];
}),
} as unknown as DataService;
// Initialize account service with mocked data service
accountService = new AccountService(dataService, {mnemonic: testMnemonic});
masterKeys = accountService.account.masterKeys;
// Generate test data
generateTestData();
});
function generateTestData() {
for (let i = 0; i < NUM_DEPOSITS; ++i) {
const value = 100n;
const label = randomBigInt() as Hash;
const [masterNullifier, masterSecret] = masterKeys;
const nullifier = poseidon([
masterNullifier,
POOL.scope,
BigInt(i),
]) as Secret;
const secret = poseidon([masterSecret, POOL.scope, BigInt(i)]) as Secret;
const precommitment = poseidon([nullifier, secret]) as Hash;
const commitment = poseidon([value, label, precommitment]) as Hash;
const deposit: DepositEvent = {
depositor: POOL.address,
commitment,
label,
value,
precommitment,
blockNumber: POOL.deploymentBlock + BigInt(i * 100),
transactionHash: mockTxHash(BigInt(i + 1)),
};
depositEvents.push(deposit);
// Track the current commitment for this withdrawal chain
let currentCommitment = {
hash: commitment,
value: value,
label: label,
nullifier,
secret,
blockNumber: deposit.blockNumber,
txHash: deposit.transactionHash,
};
let remainingValue = value;
for (let j = 0; j < NUM_WITHDRAWALS; ++j) {
const withdrawnAmount = 10n;
remainingValue -= withdrawnAmount;
// Generate withdrawal nullifier and secret using master keys
const withdrawalNullifier = poseidon([
masterNullifier,
currentCommitment.label,
BigInt(j),
]) as Secret;
const withdrawalSecret = poseidon([
masterSecret,
currentCommitment.label,
BigInt(j),
]) as Secret;
// Create precommitment and new commitment
const withdrawalPrecommitment = poseidon([
withdrawalNullifier,
withdrawalSecret,
]) as Hash;
const newCommitment = poseidon([
remainingValue,
currentCommitment.label,
withdrawalPrecommitment,
]) as Hash;
// Create withdrawal event
const withdrawal: WithdrawalEvent = {
withdrawn: withdrawnAmount,
spentNullifier: poseidon([withdrawalNullifier]) as Hash,
newCommitment,
blockNumber: currentCommitment.blockNumber + BigInt((j + 1) * 100),
transactionHash: mockTxHash(BigInt(i * 100 + j + 2)),
};
withdrawalEvents.push(withdrawal);
// Update current commitment for next iteration
currentCommitment = {
hash: newCommitment,
value: remainingValue,
label: currentCommitment.label,
nullifier: withdrawalNullifier,
secret: withdrawalSecret,
blockNumber: withdrawal.blockNumber,
txHash: withdrawal.transactionHash,
};
}
}
// Helper function to create mock transaction hashes
function mockTxHash(index: number): Hex {
// Pad the index to create a valid 32-byte hash
const paddedIndex = index.toString(16).padStart(64, "0");
return `0x${paddedIndex}` as Hex;
}
it("should reconstruct account history and find the valid deposit chain", async () => {
// Process the pool
await accountService.retrieveHistory([POOL]);
beforeEach(() => {
dataService = {
getDeposits: vi.fn(async () => []),
getWithdrawals: vi.fn(async () => []),
getRagequits: vi.fn(async () => []),
} as unknown as DataService;
// Log internal state
console.log(
"Account service internal state:",
accountService.account.poolAccounts,
);
accountService = new AccountService(dataService, { mnemonic: TEST_MNEMONIC });
});
accountService.account.poolAccounts.forEach((p) =>
console.log("PoolAccounts", p),
);
describe("constructor", () => {
it("initialize with master keys derived from mnemonic", () => {
const {
masterNullifier: expectedMasterNullifier,
masterSecret: expectedMasterSecret,
} = generateMasterKeys(TEST_MNEMONIC);
const [masterNullifier, masterSecret] = accountService.account.masterKeys;
const spendable = accountService
.getSpendableCommitments()
.get(POOL.scope) as AccountCommitment[];
expect(masterNullifier).toBeDefined();
expect(masterSecret).toBeDefined();
expect(masterNullifier).toBe(expectedMasterNullifier);
expect(masterSecret).toBe(expectedMasterSecret);
expect(accountService.account.poolAccounts.size).toBe(0);
});
if (spendable) {
console.log("Spendable:", spendable);
}
it("initialize with empty pool accounts map", () => {
expect(accountService.account.poolAccounts).toBeInstanceOf(Map);
expect(accountService.account.poolAccounts.size).toBe(0);
});
// Verify service calls
// expect(dataService.getDeposits).toHaveBeenCalledWith(POOL.chainId, {
// fromBlock: POOL.deploymentBlock,
// });
// expect(dataService.getWithdrawals).toHaveBeenCalledWith(POOL.chainId, {
// fromBlock: depositEvents[0].blockNumber,
// });
it("throw an error if account initialization fails", () => {
// Test that error is properly caught and re-thrown
expect(() => new AccountService(dataService, { mnemonic: "invalid mnemonic" })).toThrow(
AccountError
);
});
it("initialize with provided account", () => {
const ppAccount: PrivacyPoolAccount = {
masterKeys: [
BigInt("123456789") as Secret,
BigInt("987654321") as Secret,
],
poolAccounts: new Map(),
creationTimestamp: BigInt("123456789"),
lastUpdateTimestamp: BigInt("987654321"),
};
const account = new AccountService(dataService, { account: ppAccount });
expect(account).toBeDefined();
expect(account.account).toBe(ppAccount);
});
});
describe("createDepositSecrets", () => {
it("generate deterministic nullifier and secret for a scope", () => {
const { nullifier, secret, precommitment } =
accountService.createDepositSecrets(TEST_POOL.scope);
expect(nullifier).toBeDefined();
expect(secret).toBeDefined();
expect(precommitment).toBeDefined();
// Verify precommitment is the hash of nullifier and secret
const expectedPrecommitment = poseidon([nullifier, secret]);
expect(precommitment).toBe(expectedPrecommitment);
});
it("generate different secrets for different scopes", () => {
const scope1 = 123456789n as Hash;
const scope2 = 987654321n as Hash;
const result1 = accountService.createDepositSecrets(scope1);
const result2 = accountService.createDepositSecrets(scope2);
expect(result1.nullifier).not.toBe(result2.nullifier);
expect(result1.secret).not.toBe(result2.secret);
expect(result1.precommitment).not.toBe(result2.precommitment);
});
it("generates different secrets for different indices", () => {
const result1 = accountService.createDepositSecrets(TEST_POOL.scope, 0n);
const result2 = accountService.createDepositSecrets(TEST_POOL.scope, 1n);
expect(result1.nullifier).not.toBe(result2.nullifier);
expect(result1.secret).not.toBe(result2.secret);
expect(result1.precommitment).not.toBe(result2.precommitment);
});
it("uses the number of existing accounts as index if not provided", () => {
// Add a mock pool account for the scope
accountService.account.poolAccounts.set(TEST_POOL.scope, [
{} as PoolAccount,
{} as PoolAccount,
]);
const withIndexZero = accountService.createDepositSecrets(
TEST_POOL.scope,
0n
);
const withDefaultIndex = accountService.createDepositSecrets(
TEST_POOL.scope
);
// If the default index is used correctly, the results should be different
expect(withDefaultIndex.nullifier).not.toBe(withIndexZero.nullifier);
expect(withDefaultIndex.secret).not.toBe(withIndexZero.secret);
});
it("throws an error if the index is negative", () => {
expect(() => accountService.createDepositSecrets(TEST_POOL.scope, -1n)).toThrow(AccountError);
});
});
describe("createWithdrawalSecrets", () => {
let testCommitment: AccountCommitment;
beforeEach(() => {
// Set up a mock commitment and account
const label = BigInt("987654321") as Hash;
testCommitment = {
hash: BigInt("111222333") as Hash,
value: 100n,
label,
nullifier: BigInt("444555666") as Secret,
secret: BigInt("777888999") as Secret,
blockNumber: 1000n,
txHash: mockTxHash(1),
};
// Add an account with this commitment
accountService.account.poolAccounts.set(TEST_POOL.scope, [
{
label,
deposit: testCommitment,
children: [],
},
]);
});
it("generate deterministic nullifier and secret for a commitment", () => {
const { nullifier, secret } =
accountService.createWithdrawalSecrets(testCommitment);
expect(nullifier).toBeDefined();
expect(secret).toBeDefined();
expect(typeof nullifier).toBe("bigint");
expect(typeof secret).toBe("bigint");
});
it("throw an error if the commitment is not found", () => {
const unknownCommitment: AccountCommitment = {
...testCommitment,
label: BigInt("999999999") as Hash,
};
expect(() =>
accountService.createWithdrawalSecrets(unknownCommitment)
).toThrow(AccountError);
});
});
describe("addPoolAccount", () => {
it("adds a new pool account correctly", () => {
const scope = TEST_POOL.scope;
const value = 100n;
const nullifier = BigInt("123456789") as Secret;
const secret = BigInt("987654321") as Secret;
const label = BigInt("555666777") as Hash;
const blockNumber = 1000n;
const txHash = mockTxHash(1);
const newAccount = accountService.addPoolAccount(
scope,
value,
nullifier,
secret,
label,
blockNumber,
txHash
);
expect(newAccount).toBeDefined();
expect(newAccount.label).toBe(label);
expect(newAccount.deposit.value).toBe(value);
expect(newAccount.deposit.nullifier).toBe(nullifier);
expect(newAccount.deposit.secret).toBe(secret);
expect(newAccount.deposit.blockNumber).toBe(blockNumber);
expect(newAccount.deposit.txHash).toBe(txHash);
expect(newAccount.children).toEqual([]);
// Verify account was added to the map
expect(accountService.account.poolAccounts.has(scope)).toBe(true);
expect(accountService.account.poolAccounts.get(scope)!.length).toBe(1);
expect(accountService.account.poolAccounts.get(scope)![0]).toBe(
newAccount
);
});
it("generates the correct commitment hash", () => {
const scope = TEST_POOL.scope;
const value = 100n;
const nullifier = BigInt("123456789") as Secret;
const secret = BigInt("987654321") as Secret;
const label = BigInt("555666777") as Hash;
const blockNumber = 1000n;
const txHash = mockTxHash(1);
const newAccount = accountService.addPoolAccount(
scope,
value,
nullifier,
secret,
label,
blockNumber,
txHash
);
// Calculate expected commitment hash
const precommitment = poseidon([nullifier, secret]);
const expectedCommitment = poseidon([value, label, precommitment]);
expect(newAccount.deposit.hash).toBe(expectedCommitment);
});
it("adds multiple accounts to the same scope", () => {
const scope = TEST_POOL.scope;
// Add first account
accountService.addPoolAccount(
scope,
100n,
BigInt("111111111") as Secret,
BigInt("222222222") as Secret,
BigInt("333333333") as Hash,
1000n,
mockTxHash(1)
);
// Add second account
accountService.addPoolAccount(
scope,
200n,
BigInt("444444444") as Secret,
BigInt("555555555") as Secret,
BigInt("666666666") as Hash,
1100n,
mockTxHash(2)
);
expect(accountService.account.poolAccounts.get(scope)!.length).toBe(2);
expect(
accountService.account.poolAccounts.get(scope)!.at(0)!.deposit.value
).toBe(100n);
expect(
accountService.account.poolAccounts.get(scope)!.at(1)!.deposit.value
).toBe(200n);
});
});
describe("addWithdrawalCommitment", () => {
let parentCommitment: AccountCommitment;
beforeEach(() => {
// Set up parent commitment and account
const label = BigInt("987654321") as Hash;
parentCommitment = {
hash: BigInt("111222333") as Hash,
value: 100n,
label,
nullifier: BigInt("444555666") as Secret,
secret: BigInt("777888999") as Secret,
blockNumber: 1000n,
txHash: mockTxHash(1),
};
// Add an account with this commitment
accountService.account.poolAccounts.set(TEST_POOL.scope, [
{
label,
deposit: parentCommitment,
children: [],
},
]);
});
it("adds withdrawal commitment correctly", () => {
const value = 90n; // 100n - 10n withdrawal
const nullifier = BigInt("123123123") as Secret;
const secret = BigInt("456456456") as Secret;
const blockNumber = 1100n;
const txHash = mockTxHash(2);
const newCommitment = accountService.addWithdrawalCommitment(
parentCommitment,
value,
nullifier,
secret,
blockNumber,
txHash
);
// Verify commitment was created correctly
expect(newCommitment).toBeDefined();
expect(newCommitment.value).toBe(value);
expect(newCommitment.label).toBe(parentCommitment.label);
expect(newCommitment.nullifier).toBe(nullifier);
expect(newCommitment.secret).toBe(secret);
expect(newCommitment.blockNumber).toBe(blockNumber);
expect(newCommitment.txHash).toBe(txHash);
// Verify commitment was added to account
const account = accountService.account.poolAccounts.get(
TEST_POOL.scope
)!.at(0)!;
expect(account.children.length).toBe(1);
expect(account.children.at(0)!).toBe(newCommitment);
});
it("generates the correct commitment hash", () => {
const value = 90n;
const nullifier = BigInt("123123123") as Secret;
const secret = BigInt("456456456") as Secret;
const blockNumber = 1100n;
const txHash = mockTxHash(2);
const newCommitment = accountService.addWithdrawalCommitment(
parentCommitment,
value,
nullifier,
secret,
blockNumber,
txHash
);
// Calculate expected commitment hash
const precommitment = poseidon([nullifier, secret]);
const expectedCommitment = poseidon([
value,
parentCommitment.label,
precommitment,
]);
expect(newCommitment.hash).toBe(expectedCommitment);
});
it("finds parent commitment in account's children", () => {
// First create a child commitment
const intermediateCommitment = accountService.addWithdrawalCommitment(
parentCommitment,
90n,
BigInt("123123123") as Secret,
BigInt("456456456") as Secret,
1100n,
mockTxHash(2)
);
// Now create a second withdrawal from the first child
const secondChildCommitment = accountService.addWithdrawalCommitment(
intermediateCommitment,
80n,
BigInt("789789789") as Secret,
BigInt("321321321") as Secret,
1200n,
mockTxHash(3)
);
// Verify both children were added
const account = accountService.account.poolAccounts.get(
TEST_POOL.scope
)!.at(0)!;
expect(account.children.length).toBe(2);
expect(account.children.at(0)!).toBe(intermediateCommitment);
expect(account.children.at(1)!).toBe(secondChildCommitment);
});
it("throws an error if parent commitment is not found", () => {
const unknownCommitment: AccountCommitment = {
...parentCommitment,
hash: BigInt("999999999") as Hash,
};
expect(() =>
accountService.addWithdrawalCommitment(
unknownCommitment,
90n,
BigInt("123123123") as Secret,
BigInt("456456456") as Secret,
1100n,
mockTxHash(2)
)
).toThrow(AccountError);
});
});
describe("addRagequitToAccount", () => {
let testLabel: Hash;
beforeEach(() => {
// Set up an account
testLabel = BigInt("987654321") as Hash;
const commitment: AccountCommitment = {
hash: BigInt("111222333") as Hash,
value: 100n,
label: testLabel,
nullifier: BigInt("444555666") as Secret,
secret: BigInt("777888999") as Secret,
blockNumber: 1000n,
txHash: mockTxHash(1),
};
// Add an account with this commitment
accountService.account.poolAccounts.set(TEST_POOL.scope, [
{
label: testLabel,
deposit: commitment,
children: [],
},
]);
});
it("adds a ragequit event to account correctly", () => {
const ragequitEvent: RagequitEvent = {
ragequitter: "0x123456789abcdef",
commitment: BigInt("111222333") as Hash,
label: testLabel,
value: 100n,
blockNumber: 1100n,
transactionHash: mockTxHash(2),
};
const updatedAccount = accountService.addRagequitToAccount(
testLabel,
ragequitEvent
);
// Verify ragequit was added to account
expect(updatedAccount.ragequit).toBeDefined();
expect(updatedAccount.ragequit).toBe(ragequitEvent);
// Verify it's the same account in the map
const accountInMap = accountService.account.poolAccounts.get(
TEST_POOL.scope
)!.at(0)!;
expect(accountInMap.ragequit).toBe(ragequitEvent);
});
it("throws an error if no account with the label is found", () => {
const unknownLabel = BigInt("111111111") as Hash;
const ragequitEvent: RagequitEvent = {
ragequitter: "0x123456789abcdef",
commitment: BigInt("111222333") as Hash,
label: unknownLabel,
value: 100n,
blockNumber: 1100n,
transactionHash: mockTxHash(2),
};
expect(() =>
accountService.addRagequitToAccount(unknownLabel, ragequitEvent)
).toThrow(AccountError);
});
});
describe("getSpendableCommitments", () => {
beforeEach(() => {
// Scope 1: Account with non-zero value, not ragequit
const scope1 = BigInt("1111") as Hash;
const commitment1: AccountCommitment = {
hash: BigInt("10001") as Hash,
value: 100n,
label: BigInt("1001") as Hash,
nullifier: BigInt("10002") as Secret,
secret: BigInt("10003") as Secret,
blockNumber: 1000n,
txHash: mockTxHash(1),
};
accountService.account.poolAccounts.set(scope1, [
{
label: commitment1.label,
deposit: commitment1,
children: [],
},
]);
// Scope 2: Ragequit account
const scope2 = BigInt("2222") as Hash;
const commitment2: AccountCommitment = {
hash: BigInt("20001") as Hash,
value: 100n,
label: BigInt("2001") as Hash,
nullifier: BigInt("20002") as Secret,
secret: BigInt("20003") as Secret,
blockNumber: 1000n,
txHash: mockTxHash(3),
};
const ragequitEvent: RagequitEvent = {
ragequitter: "0x123456789abcdef",
commitment: commitment2.hash,
label: commitment2.label,
value: 100n,
blockNumber: 1100n,
transactionHash: mockTxHash(4),
};
accountService.account.poolAccounts.set(scope2, [
{
label: commitment2.label,
deposit: commitment2,
children: [],
ragequit: ragequitEvent,
},
]);
// Scope 3: Account with children
const scope3 = BigInt("3333") as Hash;
const depositCommitment: AccountCommitment = {
hash: BigInt("30001") as Hash,
value: 100n,
label: BigInt("3001") as Hash,
nullifier: BigInt("30002") as Secret,
secret: BigInt("30003") as Secret,
blockNumber: 1000n,
txHash: mockTxHash(5),
};
const childCommitment: AccountCommitment = {
hash: BigInt("30004") as Hash,
value: 50n, // Partial withdrawal
label: depositCommitment.label,
nullifier: BigInt("30005") as Secret,
secret: BigInt("30006") as Secret,
blockNumber: 1100n,
txHash: mockTxHash(6),
};
accountService.account.poolAccounts.set(scope3, [
{
label: depositCommitment.label,
deposit: depositCommitment,
children: [childCommitment],
},
]);
});
it("returns only non-zero, non-ragequit commitments", () => {
const spendableCommitments = accountService.getSpendableCommitments();
// Should include scope1 and scope3, but not scope2 (ragequit)
expect(spendableCommitments.size).toBe(2);
expect(spendableCommitments.has(BigInt("1111"))).toBe(true);
expect(spendableCommitments.has(BigInt("3333"))).toBe(true);
expect(spendableCommitments.has(BigInt("2222"))).toBe(false);
});
it("returns the latest commitment in the chain", () => {
const spendableCommitments = accountService.getSpendableCommitments();
// For scope3, should return the child commitment (latest) not the deposit
const scope3Commitments = spendableCommitments.get(BigInt("3333"))!;
expect(scope3Commitments.length).toBe(1);
expect(scope3Commitments.at(0)!.value).toBe(50n);
expect(scope3Commitments.at(0)!.hash).toBe(BigInt("30004"));
});
it("returns empty map when no spendable commitments exist", () => {
// Clear all accounts and add only zero-value and ragequit accounts
accountService.account.poolAccounts.clear();
// Add zero-value account
const zeroValueCommitment: AccountCommitment = {
hash: BigInt("50001") as Hash,
value: 0n,
label: BigInt("5001") as Hash,
nullifier: BigInt("50002") as Secret,
secret: BigInt("50003") as Secret,
blockNumber: 1000n,
txHash: mockTxHash(7),
};
accountService.account.poolAccounts.set(BigInt("5555") as Hash, [
{
label: zeroValueCommitment.label,
deposit: zeroValueCommitment,
children: [],
},
]);
// Add ragequit account
const ragequitCommitment: AccountCommitment = {
hash: BigInt("60001") as Hash,
value: 100n,
label: BigInt("6001") as Hash,
nullifier: BigInt("60002") as Secret,
secret: BigInt("60003") as Secret,
blockNumber: 1000n,
txHash: mockTxHash(8),
};
const ragequitEvent: RagequitEvent = {
ragequitter: "0x123456789abcdef",
commitment: ragequitCommitment.hash,
label: ragequitCommitment.label,
value: 100n,
blockNumber: 1100n,
transactionHash: mockTxHash(9),
};
accountService.account.poolAccounts.set(BigInt("6666") as Hash, [
{
label: ragequitCommitment.label,
deposit: ragequitCommitment,
children: [],
ragequit: ragequitEvent,
},
]);
const spendableCommitments = accountService.getSpendableCommitments();
expect(spendableCommitments.size).toBe(0);
});
});
});