From d6071431acfc7c50c377c0eb928ec12af37f136e Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 6 Jun 2024 14:15:59 +0100 Subject: [PATCH] feat: Poor man's CLI block explorer Adds a command line block explorer in the CLI via a new `get-block` command, with an optional `--follow` for streaming new blocks. Required adding a new call to the PXE to return a contract artifact (as opposed to a class) and a new note filter to retrieve notes by nullifier. --- .../aztec.js/src/wallet/base_wallet.ts | 3 + .../circuit-types/src/interfaces/pxe.ts | 6 + .../src/logs/unencrypted_l2_log.ts | 6 +- .../circuit-types/src/notes/note_filter.ts | 2 + .../circuits.js/src/structs/revert_code.ts | 15 ++ yarn-project/cli/src/cmds/get_block.ts | 32 +++ yarn-project/cli/src/cmds/get_tx.ts | 10 + yarn-project/cli/src/cmds/get_tx_receipt.ts | 15 -- yarn-project/cli/src/index.ts | 17 +- yarn-project/cli/src/inspect.ts | 202 ++++++++++++++++++ .../foundation/src/serialize/free_funcs.ts | 8 + .../pxe/src/database/kv_pxe_database.ts | 4 + .../pxe/src/pxe_service/pxe_service.ts | 4 + 13 files changed, 302 insertions(+), 22 deletions(-) create mode 100644 yarn-project/cli/src/cmds/get_block.ts create mode 100644 yarn-project/cli/src/cmds/get_tx.ts delete mode 100644 yarn-project/cli/src/cmds/get_tx_receipt.ts create mode 100644 yarn-project/cli/src/inspect.ts diff --git a/yarn-project/aztec.js/src/wallet/base_wallet.ts b/yarn-project/aztec.js/src/wallet/base_wallet.ts index 9ef5b80b253..a2bd9a6f5b3 100644 --- a/yarn-project/aztec.js/src/wallet/base_wallet.ts +++ b/yarn-project/aztec.js/src/wallet/base_wallet.ts @@ -67,6 +67,9 @@ export abstract class BaseWallet implements Wallet { getContractClass(id: Fr): Promise { return this.pxe.getContractClass(id); } + getContractArtifact(id: Fr): Promise { + return this.pxe.getContractArtifact(id); + } addCapsule(capsule: Fr[]): Promise { return this.pxe.addCapsule(capsule); } diff --git a/yarn-project/circuit-types/src/interfaces/pxe.ts b/yarn-project/circuit-types/src/interfaces/pxe.ts index 944601d1fb0..4df12dc8395 100644 --- a/yarn-project/circuit-types/src/interfaces/pxe.ts +++ b/yarn-project/circuit-types/src/interfaces/pxe.ts @@ -334,6 +334,12 @@ export interface PXE { */ getContractClass(id: Fr): Promise; + /** + * Returns the contract artifact associated to a contract class. + * @param id - Identifier of the class. + */ + getContractArtifact(id: Fr): Promise; + /** * 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 diff --git a/yarn-project/circuit-types/src/logs/unencrypted_l2_log.ts b/yarn-project/circuit-types/src/logs/unencrypted_l2_log.ts index d293853bf37..152d0998bc6 100644 --- a/yarn-project/circuit-types/src/logs/unencrypted_l2_log.ts +++ b/yarn-project/circuit-types/src/logs/unencrypted_l2_log.ts @@ -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. @@ -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})`; } diff --git a/yarn-project/circuit-types/src/notes/note_filter.ts b/yarn-project/circuit-types/src/notes/note_filter.ts index 7eb6c62c865..b014a9b2367 100644 --- a/yarn-project/circuit-types/src/notes/note_filter.ts +++ b/yarn-project/circuit-types/src/notes/note_filter.ts @@ -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; }; /** diff --git a/yarn-project/circuits.js/src/structs/revert_code.ts b/yarn-project/circuits.js/src/structs/revert_code.ts index 678fb6209dd..55ac00d331c 100644 --- a/yarn-project/circuits.js/src/structs/revert_code.ts +++ b/yarn-project/circuits.js/src/structs/revert_code.ts @@ -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 diff --git a/yarn-project/cli/src/cmds/get_block.ts b/yarn-project/cli/src/cmds/get_block.ts new file mode 100644 index 00000000000..c27ee92c303 --- /dev/null +++ b/yarn-project/cli/src/cmds/get_block.ts @@ -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); + } +} diff --git a/yarn-project/cli/src/cmds/get_tx.ts b/yarn-project/cli/src/cmds/get_tx.ts new file mode 100644 index 00000000000..9c38b6c9cfc --- /dev/null +++ b/yarn-project/cli/src/cmds/get_tx.ts @@ -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 }); +} diff --git a/yarn-project/cli/src/cmds/get_tx_receipt.ts b/yarn-project/cli/src/cmds/get_tx_receipt.ts deleted file mode 100644 index 6119f035aac..00000000000 --- a/yarn-project/cli/src/cmds/get_tx_receipt.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { type TxHash } from '@aztec/aztec.js'; -import { JsonStringify } from '@aztec/foundation/json-rpc'; -import { type DebugLogger, type LogFn } from '@aztec/foundation/log'; - -import { createCompatibleClient } from '../client.js'; - -export async function getTxReceipt(rpcUrl: string, txHash: TxHash, debugLogger: DebugLogger, log: LogFn) { - const client = await createCompatibleClient(rpcUrl, debugLogger); - const receipt = await client.getTxReceipt(txHash); - if (!receipt) { - log(`No receipt found for transaction hash ${txHash.toString()}`); - } else { - log(`\nTransaction receipt: \n${JsonStringify(receipt, true)}\n`); - } -} diff --git a/yarn-project/cli/src/index.ts b/yarn-project/cli/src/index.ts index 05f34d22db9..1981edc4e83 100644 --- a/yarn-project/cli/src/index.ts +++ b/yarn-project/cli/src/index.ts @@ -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('', '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 diff --git a/yarn-project/cli/src/inspect.ts b/yarn-project/cli/src/inspect.ts new file mode 100644 index 00000000000..f395bea46b1 --- /dev/null +++ b/yarn-project/cli/src/inspect.ts @@ -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 = {}; + const deployNullifiers: Record = {}; + const classNullifiers: Record = {}; + 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; +type ContractArtifactWithClassId = ContractArtifact & { classId: Fr }; +async function getKnownArtifacts(pxe: PXE): Promise { + 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 = {}; + 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; +} diff --git a/yarn-project/foundation/src/serialize/free_funcs.ts b/yarn-project/foundation/src/serialize/free_funcs.ts index 144827a907f..35bf70c35b2 100644 --- a/yarn-project/foundation/src/serialize/free_funcs.ts +++ b/yarn-project/foundation/src/serialize/free_funcs.ts @@ -194,3 +194,11 @@ export function fromTruncField(field: Fr): Buffer { export function fromFieldsTuple(fields: Tuple): 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; +} diff --git a/yarn-project/pxe/src/database/kv_pxe_database.ts b/yarn-project/pxe/src/database/kv_pxe_database.ts index 5d6bfb528b0..3288433f52a 100644 --- a/yarn-project/pxe/src/database/kv_pxe_database.ts +++ b/yarn-project/pxe/src/database/kv_pxe_database.ts @@ -271,6 +271,10 @@ export class KVPxeDatabase implements PxeDatabase { continue; } + if (filter.siloedNullifier && !note.siloedNullifier.equals(filter.siloedNullifier)) { + continue; + } + result.push(note); } } diff --git a/yarn-project/pxe/src/pxe_service/pxe_service.ts b/yarn-project/pxe/src/pxe_service/pxe_service.ts index f7d6b8b02c5..91dc5d26d3c 100644 --- a/yarn-project/pxe/src/pxe_service/pxe_service.ts +++ b/yarn-project/pxe/src/pxe_service/pxe_service.ts @@ -173,6 +173,10 @@ export class PXEService implements PXE { return artifact && getContractClassFromArtifact(artifact); } + public getContractArtifact(id: Fr): Promise { + return this.db.getContractArtifact(id); + } + public async registerAccount(secretKey: Fr, partialAddress: PartialAddress): Promise { const accounts = await this.keyStore.getAccounts(); const accountCompleteAddress = await this.keyStore.addAccount(secretKey, partialAddress);