Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Poor man's CLI block explorer #6946

Merged
merged 1 commit into from
Jun 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions yarn-project/aztec.js/src/wallet/base_wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ export abstract class BaseWallet implements Wallet {
getContractClass(id: Fr): Promise<ContractClassWithId | undefined> {
return this.pxe.getContractClass(id);
}
getContractArtifact(id: Fr): Promise<ContractArtifact | undefined> {
return this.pxe.getContractArtifact(id);
}
addCapsule(capsule: Fr[]): Promise<void> {
return this.pxe.addCapsule(capsule);
}
Expand Down
6 changes: 6 additions & 0 deletions yarn-project/circuit-types/src/interfaces/pxe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,12 @@ export interface PXE {
*/
getContractClass(id: Fr): Promise<ContractClassWithId | undefined>;

/**
* Returns the contract artifact associated to a contract class.
* @param id - Identifier of the class.
*/
getContractArtifact(id: Fr): Promise<ContractArtifact | undefined>;

/**
* Queries the node to check whether the contract class with the given id has been publicly registered.
* TODO(@spalladino): This method is strictly needed to decide whether to publicly register a class or not
Expand Down
6 changes: 2 additions & 4 deletions yarn-project/circuit-types/src/logs/unencrypted_l2_log.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AztecAddress } from '@aztec/circuits.js';
import { EventSelector } from '@aztec/foundation/abi';
import { randomBytes, sha256Trunc } from '@aztec/foundation/crypto';
import { BufferReader, prefixBufferWithLength } from '@aztec/foundation/serialize';
import { BufferReader, prefixBufferWithLength, toHumanReadable } from '@aztec/foundation/serialize';

/**
* Represents an individual unencrypted log entry.
Expand Down Expand Up @@ -46,9 +46,7 @@ export class UnencryptedL2Log {
* @returns A human readable representation of the log.
*/
public toHumanReadable(): string {
const payload = this.data.every(byte => byte >= 32 && byte <= 126)
? this.data.toString('ascii')
: `0x` + this.data.toString('hex');
const payload = toHumanReadable(this.data);
return `UnencryptedL2Log(contractAddress: ${this.contractAddress.toString()}, selector: ${this.selector.toString()}, data: ${payload})`;
}

Expand Down
2 changes: 2 additions & 0 deletions yarn-project/circuit-types/src/notes/note_filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export type NoteFilter = {
owner?: AztecAddress;
/** The status of the note. Defaults to 'ACTIVE'. */
status?: NoteStatus;
/** The siloed nullifier for the note. */
siloedNullifier?: Fr;
};

/**
Expand Down
15 changes: 15 additions & 0 deletions yarn-project/circuits.js/src/structs/revert_code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ export class RevertCode {
return this.equals(RevertCode.OK);
}

public getDescription() {
switch (this.code) {
case RevertCodeEnum.OK:
return 'OK';
case RevertCodeEnum.APP_LOGIC_REVERTED:
return 'Application logic reverted';
case RevertCodeEnum.TEARDOWN_REVERTED:
return 'Teardown reverted';
case RevertCodeEnum.BOTH_REVERTED:
return 'Both reverted';
default:
return `Unknown RevertCode: ${this.code}`;
}
}

/**
* Having different serialization methods allows for
* decoupling the serialization for producing the content commitment hash
Expand Down
32 changes: 32 additions & 0 deletions yarn-project/cli/src/cmds/get_block.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { type DebugLogger, type LogFn } from '@aztec/foundation/log';

import { createCompatibleClient } from '../client.js';
import { inspectBlock } from '../inspect.js';

export async function getBlock(
rpcUrl: string,
maybeBlockNumber: number | undefined,
follow: boolean,
debugLogger: DebugLogger,
log: LogFn,
) {
const client = await createCompatibleClient(rpcUrl, debugLogger);
const blockNumber = maybeBlockNumber ?? (await client.getBlockNumber());
await inspectBlock(client, blockNumber, log, { showTxs: true });

if (follow) {
let lastBlock = blockNumber;
setInterval(async () => {
const newBlock = await client.getBlockNumber();
if (newBlock > lastBlock) {
const { blocks, notes } = await client.getSyncStatus();
const areNotesSynced = blocks >= newBlock && Object.values(notes).every(block => block >= newBlock);
if (areNotesSynced) {
log('');
await inspectBlock(client, newBlock, log, { showTxs: true });
lastBlock = newBlock;
}
}
}, 1000);
}
}
10 changes: 10 additions & 0 deletions yarn-project/cli/src/cmds/get_tx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { type TxHash } from '@aztec/aztec.js';
import { type DebugLogger, type LogFn } from '@aztec/foundation/log';

import { createCompatibleClient } from '../client.js';
import { inspectTx } from '../inspect.js';

export async function getTx(rpcUrl: string, txHash: TxHash, debugLogger: DebugLogger, log: LogFn) {
const client = await createCompatibleClient(rpcUrl, debugLogger);
await inspectTx(client, txHash, log, { includeBlockInfo: true });
}
15 changes: 0 additions & 15 deletions yarn-project/cli/src/cmds/get_tx_receipt.ts

This file was deleted.

17 changes: 14 additions & 3 deletions yarn-project/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,13 +359,24 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command {
});

program
.command('get-tx-receipt')
.command('get-tx')
.description('Gets the receipt for the specified transaction hash.')
.argument('<txHash>', 'A transaction hash to get the receipt for.', parseTxHash)
.addOption(pxeOption)
.action(async (txHash, options) => {
const { getTxReceipt } = await import('./cmds/get_tx_receipt.js');
await getTxReceipt(options.rpcUrl, txHash, debugLogger, log);
const { getTx } = await import('./cmds/get_tx.js');
await getTx(options.rpcUrl, txHash, debugLogger, log);
});

program
.command('get-block')
.description('Gets info for a given block or latest.')
.argument('[blockNumber]', 'Block height', parseOptionalInteger)
.option('-f, --follow', 'Keep polling for new blocks')
.addOption(pxeOption)
.action(async (blockNumber, options) => {
const { getBlock } = await import('./cmds/get_block.js');
await getBlock(options.rpcUrl, blockNumber, options.follow, debugLogger, log);
});

program
Expand Down
202 changes: 202 additions & 0 deletions yarn-project/cli/src/inspect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { type ContractArtifact } from '@aztec/aztec.js';
import { type ExtendedNote, NoteStatus, type PXE, type TxHash } from '@aztec/circuit-types';
import { type AztecAddress, type Fr } from '@aztec/circuits.js';
import { siloNullifier } from '@aztec/circuits.js/hash';
import { type LogFn } from '@aztec/foundation/log';
import { toHumanReadable } from '@aztec/foundation/serialize';
import { getCanonicalClassRegistererAddress } from '@aztec/protocol-contracts/class-registerer';
import { getCanonicalInstanceDeployer } from '@aztec/protocol-contracts/instance-deployer';

export async function inspectBlock(pxe: PXE, blockNumber: number, log: LogFn, opts: { showTxs?: boolean } = {}) {
const block = await pxe.getBlock(blockNumber);
if (!block) {
log(`No block found for block number ${blockNumber}`);
return;
}

log(`Block ${blockNumber} (${block.hash().toString()})`);
log(` Total fees: ${block.header.totalFees.toBigInt()}`);
log(
` Fee per gas unit: DA=${block.header.globalVariables.gasFees.feePerDaGas.toBigInt()} L2=${block.header.globalVariables.gasFees.feePerL2Gas.toBigInt()}`,
);
log(` Coinbase: ${block.header.globalVariables.coinbase}`);
log(` Fee recipient: ${block.header.globalVariables.feeRecipient}`);
log(` Timestamp: ${new Date(block.header.globalVariables.timestamp.toNumber() * 500)}`);
if (opts.showTxs) {
log(``);
const artifactMap = await getKnownArtifacts(pxe);
for (const txHash of block.body.txEffects.map(tx => tx.txHash)) {
await inspectTx(pxe, txHash, log, { includeBlockInfo: false, artifactMap });
}
} else {
log(` Transactions: ${block.body.txEffects.length}`);
}
}

export async function inspectTx(
pxe: PXE,
txHash: TxHash,
log: LogFn,
opts: { includeBlockInfo?: boolean; artifactMap?: ArtifactMap } = {},
) {
const [receipt, effects, notes] = await Promise.all([
pxe.getTxReceipt(txHash),
pxe.getTxEffect(txHash),
pxe.getNotes({ txHash, status: NoteStatus.ACTIVE_OR_NULLIFIED }),
]);

if (!receipt || !effects) {
log(`No receipt or effects found for transaction hash ${txHash.toString()}`);
return;
}

const artifactMap = opts?.artifactMap ?? (await getKnownArtifacts(pxe));

// Base tx data
log(`Tx ${txHash.toString()}`);
if (opts.includeBlockInfo) {
log(` Block: ${receipt.blockNumber} (${receipt.blockHash?.toString('hex')})`);
}
log(` Status: ${receipt.status} (${effects.revertCode.getDescription()})`);
if (receipt.error) {
log(` Error: ${receipt.error}`);
}
if (receipt.transactionFee) {
log(` Fee: ${receipt.transactionFee.toString()}`);
}

// Unencrypted logs
const unencryptedLogs = effects.unencryptedLogs.unrollLogs();
if (unencryptedLogs.length > 0) {
log(' Logs:');
for (const unencryptedLog of unencryptedLogs) {
const data = toHumanReadable(unencryptedLog.data, 1000);
log(` ${toFriendlyAddress(unencryptedLog.contractAddress, artifactMap)}: ${data}`);
}
}

// Public data writes
const writes = effects.publicDataWrites;
if (writes.length > 0) {
log(' Public data writes:');
for (const write of writes) {
log(` Leaf ${write.leafIndex.toString()} = ${write.newValue.toString()}`);
}
}

// Created notes
const noteEncryptedLogsCount = effects.noteEncryptedLogs.unrollLogs().length;
if (noteEncryptedLogsCount > 0) {
log(' Created notes:');
const notVisibleNotes = noteEncryptedLogsCount - notes.length;
if (notVisibleNotes > 0) {
log(` ${notVisibleNotes} notes not visible in the PXE`);
}
for (const note of notes) {
inspectNote(note, artifactMap, log);
}
}

// Nullifiers
const nullifierCount = effects.nullifiers.length;
const { deployNullifiers, initNullifiers, classNullifiers } = await getKnownNullifiers(pxe, artifactMap);
if (nullifierCount > 0) {
log(' Nullifiers:');
for (const nullifier of effects.nullifiers) {
const [note] = await pxe.getNotes({ siloedNullifier: nullifier });
const deployed = deployNullifiers[nullifier.toString()];
const initialized = initNullifiers[nullifier.toString()];
const registered = classNullifiers[nullifier.toString()];
if (nullifier.toBuffer().equals(txHash.toBuffer())) {
log(` Transaction hash nullifier ${nullifier.toShortString()}`);
} else if (note) {
inspectNote(note, artifactMap, log, `Nullifier ${nullifier.toShortString()} for note`);
} else if (deployed) {
log(
` Contract ${toFriendlyAddress(deployed, artifactMap)} deployed via nullifier ${nullifier.toShortString()}`,
);
} else if (initialized) {
log(
` Contract ${toFriendlyAddress(
initialized,
artifactMap,
)} initialized via nullifier ${nullifier.toShortString()}`,
);
} else if (registered) {
log(` Class ${registered} registered via nullifier ${nullifier.toShortString()}`);
} else {
log(` Unknown nullifier ${nullifier.toString()}`);
}
}
}

// L2 to L1 messages
if (effects.l2ToL1Msgs.length > 0) {
log(` L2 to L1 messages:`);
for (const msg of effects.l2ToL1Msgs) {
log(` ${msg.toString()}`);
}
}
}

function inspectNote(note: ExtendedNote, artifactMap: ArtifactMap, log: LogFn, text = 'Note') {
const artifact = artifactMap[note.contractAddress.toString()];
const contract = artifact?.name ?? note.contractAddress.toString();
const type = artifact?.notes[note.noteTypeId.toString()]?.typ ?? note.noteTypeId.toShortString();
log(` ${text} type ${type} at ${contract}`);
log(` Owner: ${toFriendlyAddress(note.owner, artifactMap)}`);
for (const field of note.note.items) {
log(` ${field.toString()}`);
}
}

function toFriendlyAddress(address: AztecAddress, artifactMap: ArtifactMap) {
const artifact = artifactMap[address.toString()];
if (!artifact) {
return address.toString();
}

return `${artifact.name}<${address.toString()}>`;
}

async function getKnownNullifiers(pxe: PXE, artifactMap: ArtifactMap) {
const knownContracts = await pxe.getContracts();
const deployerAddress = getCanonicalInstanceDeployer().address;
const registererAddress = getCanonicalClassRegistererAddress();
const initNullifiers: Record<string, AztecAddress> = {};
const deployNullifiers: Record<string, AztecAddress> = {};
const classNullifiers: Record<string, string> = {};
for (const contract of knownContracts) {
initNullifiers[siloNullifier(contract, contract).toString()] = contract;
deployNullifiers[siloNullifier(deployerAddress, contract).toString()] = contract;
}
for (const artifact of Object.values(artifactMap)) {
classNullifiers[
siloNullifier(registererAddress, artifact.classId).toString()
] = `${artifact.name}Class<${artifact.classId}>`;
}
return { initNullifiers, deployNullifiers, classNullifiers };
}

type ArtifactMap = Record<string, ContractArtifactWithClassId>;
type ContractArtifactWithClassId = ContractArtifact & { classId: Fr };
async function getKnownArtifacts(pxe: PXE): Promise<ArtifactMap> {
const knownContractAddresses = await pxe.getContracts();
const knownContracts = await Promise.all(knownContractAddresses.map(contract => pxe.getContractInstance(contract)));
const classIds = [...new Set(knownContracts.map(contract => contract?.contractClassId))];
const knownArtifacts = await Promise.all(
classIds.map(classId =>
classId ? pxe.getContractArtifact(classId).then(a => (a ? { ...a, classId } : undefined)) : undefined,
),
);
const map: Record<string, ContractArtifactWithClassId> = {};
for (const instance of knownContracts) {
if (instance) {
const artifact = knownArtifacts.find(a => a?.classId.equals(instance.contractClassId));
if (artifact) {
map[instance.address.toString()] = artifact;
}
}
}
return map;
}
8 changes: 8 additions & 0 deletions yarn-project/foundation/src/serialize/free_funcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,11 @@ export function fromTruncField(field: Fr): Buffer {
export function fromFieldsTuple(fields: Tuple<Fr, 2>): Buffer {
return from2Fields(fields[0], fields[1]);
}

export function toHumanReadable(buf: Buffer, maxLen?: number): string {
const result = buf.every(byte => byte >= 32 && byte <= 126) ? buf.toString('ascii') : `0x${buf.toString('hex')}`;
if (maxLen && result.length > maxLen) {
return result.slice(0, maxLen) + '...';
}
return result;
}
4 changes: 4 additions & 0 deletions yarn-project/pxe/src/database/kv_pxe_database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,10 @@ export class KVPxeDatabase implements PxeDatabase {
continue;
}

if (filter.siloedNullifier && !note.siloedNullifier.equals(filter.siloedNullifier)) {
continue;
}

result.push(note);
}
}
Expand Down
4 changes: 4 additions & 0 deletions yarn-project/pxe/src/pxe_service/pxe_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ export class PXEService implements PXE {
return artifact && getContractClassFromArtifact(artifact);
}

public getContractArtifact(id: Fr): Promise<ContractArtifact | undefined> {
return this.db.getContractArtifact(id);
}

public async registerAccount(secretKey: Fr, partialAddress: PartialAddress): Promise<CompleteAddress> {
const accounts = await this.keyStore.getAccounts();
const accountCompleteAddress = await this.keyStore.addAccount(secretKey, partialAddress);
Expand Down
Loading