Blockchain Audit Trail for Wealth Advisory
Deploy a Solidity smart contract that records tamper-proof hashes of AI advisory interactions.
Introduction
30 Second Summary
When a financial advisor recommends a strategy, their clients and regulators need proof that the recommendation was never altered after the fact. This is the core challenge of financial record-keeping.
In this project, you will build a blockchain-based audit trail that records tamper-proof cryptographic fingerprints of AI advisory interactions on Ethereum's Sepolia testnet.
What You'll Build
You'll create a Solidity smart contract and a Node.js verification script that together prove advisory records are authentic and unaltered.
By the end of this project, you'll have:
- A deployed AdvisoryAuditTrail smart contract on Sepolia that stores tamper-proof keccak256 hashes of advisory interactions.
- A Node.js verification script that proves any advisory record is authentic by recomputing its hash and comparing it against the on-chain record.
- A working end-to-end audit trail that connects your AI advisory system to an immutable blockchain ledger.
- Secret Mission: Add batch recording to submit multiple advisory hashes in a single transaction, reducing gas costs.
Are there any prerequisites?
This project assumes you have completed Project 1 (AI Wealth Advisory Avatar) and have intermediate blockchain and Solidity knowledge. You'll also need Node.js v22.13.0+ installed and a MetaMask wallet configured.
Set Up Hardhat 3 and Sepolia Access
Before you can write a smart contract, you need a development environment that compiles Solidity, manages deployments, and connects to Ethereum testnets.
In this step, you will set up Hardhat 3 (the smart contract development framework), connect it to the Sepolia testnet through Alchemy, and store your secrets securely in Hardhat's encrypted keystore.
In this step, get ready to:
- Initialize a Hardhat 3 project with the viem template.
- Configure Alchemy and MetaMask for Sepolia testnet access.
- Verify Sepolia connectivity by compiling the project.
Initialize the Hardhat project
Hardhat 3 uses pnpm as its recommended package manager. Your Node.js installation includes Corepack, a built-in tool that manages package managers like pnpm without a separate install.
- Press the Windows key to open your search bar.
- Type Terminal and press Enter to open it.
- Enable pnpm through Corepack by running this command:
corepack enable
What does this command do?
Corepack is bundled with Node.js v22.13.0+. Running corepack enable creates shims so that pnpm commands work without any separate installation.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir advisory-audit-trail
cd advisory-audit-trail
- Initialize a Hardhat 3 project by running:
pnpm dlx hardhat@3.9.0 --init
What happens during initialization?
Hardhat will prompt you with configuration options. Select A TypeScript Hardhat project using Node Test Runner and Viem when asked for the project type. Accept the defaults for everything else.
The init command installs all required dependencies automatically, including the hardhat-toolbox-viem plugin which bundles Hardhat Ignition, contract verification, and viem integration.
- Wait for the installation to finish. You should see a success message once all packages are installed.
- Verify the project compiles by running:
npx hardhat build
- You should see output confirming the Counter.sol contract compiled successfully.
Build command not working?
- If you see a "Hardhat is not installed" error, make sure you ran pnpm dlx hardhat@3.9.0 --init inside the advisory-audit-trail folder and that it completed successfully.
- If you see a corepack error, try running corepack enable again in a terminal with administrator privileges.
Help me troubleshoot my Hardhat 3 project setup
Configure Alchemy and MetaMask for Sepolia
To deploy contracts to Sepolia testnet, you need an RPC endpoint (a gateway to the Ethereum network) and test ETH to pay for gas fees.
- Open your browser and go to alchemy.com.
- Click Sign Up and create a free account.
- Once signed in, click Create New App.
- Select Ethereum as the chain and Sepolia as the network.
- Give your app a name (e.g. advisory-audit-trail) and click Create App.
- On your app dashboard, find and copy your HTTPS RPC URL. Save it somewhere temporarily.
What is an RPC URL?
An RPC URL is your application's connection point to the Ethereum network. Alchemy runs Ethereum nodes and exposes them through these URLs so you don't need to run your own node.
Now you need Sepolia test ETH to pay for transaction fees when deploying your contract.
- Go to the Google Cloud Sepolia faucet at cloud.google.com/application/web3/faucet/ethereum/sepolia.
- Paste your MetaMask wallet address into the field.
- Click Receive and wait for the transaction to complete.
- Open MetaMask and switch to the Sepolia network. Confirm you see a non-zero ETH balance.
Faucet alternatives
If the Google Cloud faucet is unavailable, try the Alchemy faucet at alchemy.com/faucets/ethereum-sepolia. It provides 0.1 ETH every 24 hours but requires 0.001 ETH on Ethereum mainnet.
Next, store your RPC URL and private key in Hardhat's encrypted keystore so they never appear in your code.
- Back in your terminal (inside the advisory-audit-trail folder), store your Alchemy RPC URL by running:
npx hardhat keystore set SEPOLIA_RPC_URL
- When prompted, enter a keystore password. Then paste your Alchemy HTTPS RPC URL.
- Store your MetaMask private key by running:
npx hardhat keystore set SEPOLIA_PRIVATE_KEY
- Enter your keystore password again, then paste the private key from MetaMask.
Never use your main wallet
Only use a dedicated test wallet for this project. Never store real funds in the wallet whose private key you export. If the key is ever compromised, you could lose all assets in that wallet.
How do I export my private key from MetaMask?
In MetaMask, click the three-dot menu next to your account name. Select Account Details, then Show Private Key. Enter your MetaMask password to reveal it. Copy the key and paste it into the terminal prompt.
Keystore command not recognized?
- Make sure you are inside the advisory-audit-trail directory where the Hardhat project was initialized.
- The hardhat-keystore plugin is bundled with hardhat-toolbox-viem. If it's missing, re-run pnpm dlx hardhat@3.9.0 --init to reinstall dependencies.
Help me store secrets in Hardhat keystore
Verify Sepolia connectivity
Your hardhat.config.ts file needs a Sepolia network entry that references the secrets you just stored. The scaffold may already include a template for this, but you need to confirm it matches the required format.
- Open the advisory-audit-trail folder in VS Code.
- Open hardhat.config.ts from the file explorer.
- Replace the entire file contents with the following:
import { defineConfig, configVariable } from "hardhat/config";
import hardhatToolboxViemPlugin from "@nomicfoundation/hardhat-toolbox-viem";
export default defineConfig({
plugins: [hardhatToolboxViemPlugin],
solidity: {
version: "0.8.28",
},
networks: {
sepolia: {
type: "http",
chainType: "l1",
url: configVariable("SEPOLIA_RPC_URL"),
accounts: [configVariable("SEPOLIA_PRIVATE_KEY")],
},
},
});
What does this config do?
- The plugins array loads the viem toolbox, which bundles Ignition, contract verification, and testing utilities.
- The solidity.version sets the compiler to 0.8.28, matching the pragma in your contract.
- The sepolia network uses configVariable() to pull secrets from the encrypted keystore at runtime. Your keys never appear in source code.
- Save hardhat.config.ts.
- Confirm the project still compiles by running:
npx hardhat build
- You should see the contracts compile without errors. This confirms your config is valid and Hardhat can read the Solidity compiler version.
Seeing a config error?
- Check that the import at the top uses defineConfig, configVariable from "hardhat/config". Both must be imported together.
- Ensure hardhatToolboxViemPlugin is imported from "@nomicfoundation/hardhat-toolbox-viem" and placed inside the plugins array.
Help me fix my hardhat.config.ts
✔️ Awesome, I've got everything!
Great. Double check you have saved hardhat.config.ts and that npx hardhat build completes without errors.
ⓧ I'd like to double check the full code
Here is the complete hardhat.config.ts you should have at this point:
import { defineConfig, configVariable } from "hardhat/config";
import hardhatToolboxViemPlugin from "@nomicfoundation/hardhat-toolbox-viem";
export default defineConfig({
plugins: [hardhatToolboxViemPlugin],
solidity: {
version: "0.8.28",
},
networks: {
sepolia: {
type: "http",
chainType: "l1",
url: configVariable("SEPOLIA_RPC_URL"),
accounts: [configVariable("SEPOLIA_PRIVATE_KEY")],
},
},
});
Your Hardhat 3 environment is ready and connected to Sepolia. Next up, you will write the AdvisoryAuditTrail smart contract that stores tamper-proof hashes on-chain.
Write the AdvisoryAuditTrail Smart Contract
Your Hardhat project is compiled and connected to Sepolia. Now it is time to write the core piece of your audit trail system: the smart contract itself.
The contract needs to do three things: store a cryptographic fingerprint of each advisory interaction, prove who recorded it and when, and let anyone verify a record has not been tampered with. You will encode all of this in Solidity using a struct, a mapping, events, and three focused functions.
In this step, get ready to:
- Design the contract's data structures, events, and hash-generation function.
- Implement the recording and verification functions with access control.
- Compile the contract and confirm the ABI is generated.
Design the contract storage and events
Every audit record needs three pieces of metadata: when it was created, who created it, and whether it exists at all. You will store these in a struct and map each keccak256 hash to its record.
- Remove the scaffold contract that came with the template by running:
rm contracts/Counter.sol contracts/Counter.t.sol
- Confirm the files are gone by running:
ls contracts/
- You should see an empty directory with no .sol files listed.
- Create the contract file contracts/AdvisoryAuditTrail.sol with the following foundation code:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
contract AdvisoryAuditTrail {
// Owner restricts who can record advisories
address public owner;
// Stores the audit metadata for each recorded hash
struct AuditRecord {
uint256 timestamp;
address advisor;
bool exists;
}
// Maps a keccak256 hash to its audit record
mapping(bytes32 => AuditRecord) public records;
// Emitted when a new advisory is recorded on-chain
event AdvisoryRecorded(
bytes32 indexed recordHash,
address indexed advisor,
uint256 timestamp
);
// Custom errors (gas-efficient alternative to require strings)
error RecordAlreadyExists(bytes32 recordHash);
error NotOwner();
// Restricts function access to the contract deployer
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
// Sets the deployer as the contract owner
constructor() {
owner = msg.sender;
}
}
What does this code do?
This sets up the contract's entire data layer and access control:
- AuditRecord stores the block timestamp, the advisor's address, and an exists flag for quick lookups.
- The records mapping uses a bytes32 key so you can look up any hash in constant time.
- Custom errors like RecordAlreadyExists cost less gas than require strings and are the modern Solidity best practice.
- The onlyOwner modifier ensures only the deployer can write records, preventing unauthorized entries.
- Save contracts/AdvisoryAuditTrail.sol.
- You won't see a change yet. The next chunk adds the core hashing function.
Seeing red squiggles in your editor?
Make sure the Solidity extension in VS Code is set to use compiler version 0.8.28. If you see a version mismatch warning, open your command palette and select the workspace compiler version.
Why is my Solidity extension showing errors in AdvisoryAuditTrail.sol?
- In contracts/AdvisoryAuditTrail.sol, find the closing } of the contract and add the generateRecordHash function just above it:
// Computes a deterministic hash of advisory interaction data
function generateRecordHash(
bytes32 clientId,
string calldata recommendation,
uint256 amount,
uint256 advisoryTimestamp
) public pure returns (bytes32) {
// Pre-hash the recommendation string for collision safety
return keccak256(
abi.encode(
clientId,
keccak256(bytes(recommendation)),
amount,
advisoryTimestamp
)
);
}
What does generateRecordHash do?
This function takes the four fields of an advisory interaction and produces a single bytes32 fingerprint. Anyone can call it (it is pure) to compute the expected hash off-chain and compare it against what is stored.
The recommendation string is pre-hashed with keccak256(bytes(recommendation)) before encoding. This converts a dynamic type into a fixed-size bytes32 so the outer abi.encode operates only on fixed-size types.
Why abi.encode instead of abi.encodePacked?
For multi-field records with mixed types, abi.encode provides unambiguous encoding that prevents hash collisions. With abi.encodePacked, dynamic types like strings can collide ("AB" + "C" produces the same bytes as "A" + "BC"). Security-critical audit trails must use abi.encode.
- Save contracts/AdvisoryAuditTrail.sol.
- Verify the contract compiles at this point by running:
npx hardhat build
- You should see a message confirming the Solidity file compiled successfully with no errors.
Build failing with a compilation error?
Check that the generateRecordHash function is inside the contract braces (before the final }). Also confirm there are no stray characters or missing semicolons.
Help me fix this compilation error
Implement recordAdvisory and verifyRecord
The data layer is ready. Now you need two functions: one that writes a hash to the blockchain (creating a permanent record), and one that reads it back for verification.
- In contracts/AdvisoryAuditTrail.sol, find the closing } of the contract and add the recordAdvisory function just above it:
// Stores a record hash on-chain with timestamp and advisor info
function recordAdvisory(bytes32 recordHash) external onlyOwner {
// Prevent duplicate entries
if (records[recordHash].exists) {
revert RecordAlreadyExists(recordHash);
}
// Store the audit record
records[recordHash] = AuditRecord({
timestamp: block.timestamp,
advisor: msg.sender,
exists: true
});
// Emit event for off-chain indexing
emit AdvisoryRecorded(recordHash, msg.sender, block.timestamp);
}
What does recordAdvisory do?
This is the write function. It takes a pre-computed hash and stores it permanently on-chain along with the current block timestamp and the caller's address.
The duplicate check (if (records[recordHash].exists)) ensures no hash can be overwritten. Once recorded, the data is immutable. The AdvisoryRecorded event allows off-chain services to index new records without polling.
- Save contracts/AdvisoryAuditTrail.sol.
- You won't see a change yet. The next chunk adds the read function to complete the contract.
- Still in contracts/AdvisoryAuditTrail.sol, add the verifyRecord function just above the closing }:
// Returns the stored audit data for a given hash
function verifyRecord(bytes32 recordHash)
external
view
returns (uint256 timestamp, address advisor, bool exists)
{
AuditRecord storage record = records[recordHash];
return (record.timestamp, record.advisor, record.exists);
}
What does verifyRecord do?
This is the read function. It takes a hash and returns its stored metadata: the timestamp, the advisor address, and whether the record exists. Because it is a view function, calling it costs no gas.
Anyone can call verifyRecord to check whether a specific advisory interaction has been recorded on-chain. This is how auditors and regulators prove records have not been tampered with.
- Save contracts/AdvisoryAuditTrail.sol.
✔️ Awesome, I've got everything!
Great. Make sure you have saved contracts/AdvisoryAuditTrail.sol before moving on to the compile step.
ⓧ I'd like to double check the full code
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
contract AdvisoryAuditTrail {
address public owner;
struct AuditRecord {
uint256 timestamp;
address advisor;
bool exists;
}
mapping(bytes32 => AuditRecord) public records;
event AdvisoryRecorded(
bytes32 indexed recordHash,
address indexed advisor,
uint256 timestamp
);
error RecordAlreadyExists(bytes32 recordHash);
error NotOwner();
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
constructor() {
owner = msg.sender;
}
function generateRecordHash(
bytes32 clientId,
string calldata recommendation,
uint256 amount,
uint256 advisoryTimestamp
) public pure returns (bytes32) {
return keccak256(
abi.encode(
clientId,
keccak256(bytes(recommendation)),
amount,
advisoryTimestamp
)
);
}
function recordAdvisory(bytes32 recordHash) external onlyOwner {
if (records[recordHash].exists) {
revert RecordAlreadyExists(recordHash);
}
records[recordHash] = AuditRecord({
timestamp: block.timestamp,
advisor: msg.sender,
exists: true
});
emit AdvisoryRecorded(recordHash, msg.sender, block.timestamp);
}
function verifyRecord(bytes32 recordHash)
external
view
returns (uint256 timestamp, address advisor, bool exists)
{
AuditRecord storage record = records[recordHash];
return (record.timestamp, record.advisor, record.exists);
}
}
Compile and verify there are no errors
With all three functions in place, it is time to compile the complete contract and confirm Hardhat generates the ABI artifact.
- Compile the contract by running:
npx hardhat build
- You should see a message indicating the Solidity file compiled successfully.
- Confirm the ABI was generated by checking the artifacts directory:
ls artifacts/build-info/
- You should see .json and .output.json files listed. These contain the compiled ABI and build information for your contract.
Not seeing any artifacts?
Run npx hardhat clean to clear the cache, then run npx hardhat build again. If compilation still fails, check that your hardhat.config.ts specifies version: "0.8.28" under the solidity key.
Help me fix my build output
Your smart contract is compiled and its ABI is ready. Next up, you will write automated tests to prove the contract behaves correctly before deploying it to a live network.
Write Tests for the Audit Trail
Your smart contract compiles cleanly, but how do you know it actually works as intended? Automated tests let you verify every function before deploying to a live network.
In this step, you will write a TypeScript test suite that exercises every path through your contract. The tests use viem for contract interaction and ethers.js for off-chain hash computation, proving that your JavaScript code produces the exact same hashes as the Solidity contract.
In this step, get ready to:
- Write a test file covering recording, verification, and duplicate rejection.
- Prove off-chain and on-chain hash computation produce identical results.
- Run all four tests and confirm they pass.
Create a TypeScript test file
Hardhat 3 uses Node.js's built-in node:test runner, which is fast and has no external dependencies. Your test file will deploy the contract to a local simulated blockchain, call functions, and assert the results.
- Create a new file called test/AdvisoryAuditTrail.ts in your advisory-audit-trail project folder.
- Add the imports, the deployFixture helper, and the computeRecordHash helper by pasting this code:
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import hre from "hardhat";
import { keccak256, AbiCoder, toUtf8Bytes } from "ethers";
describe("AdvisoryAuditTrail", () => {
// Deploy a fresh contract instance for each test
async function deployFixture() {
const network = await hre.network.create();
const { viem } = network;
const [owner, other] = await viem.getWalletClients();
const publicClient = await viem.getPublicClient();
const auditTrail = await viem.deployContract("AdvisoryAuditTrail");
return { auditTrail, owner, other, publicClient, viem };
}
// Replicate Solidity's keccak256(abi.encode(...)) in JavaScript
function computeRecordHash(
clientId: `0x${string}`,
recommendation: string,
amount: bigint,
advisoryTimestamp: bigint
): `0x${string}` {
const abiCoder = AbiCoder.defaultAbiCoder();
const recommendationHash = keccak256(toUtf8Bytes(recommendation));
const encoded = abiCoder.encode(
["bytes32", "bytes32", "uint256", "uint256"],
[clientId, recommendationHash, amount, advisoryTimestamp]
);
return keccak256(encoded) as `0x${string}`;
}
});
What does this code do?
The file imports describe and it from Node's built-in test runner. It also imports hashing utilities from ethers to replicate the contract's encoding logic in JavaScript.
The deployFixture function creates a fresh local blockchain network, deploys your contract, and returns everything you need to interact with it.
The computeRecordHash function mirrors the Solidity contract's generateRecordHash logic. It hashes the recommendation string first with keccak256, then encodes all fields with AbiCoder.encode (matching Solidity's abi.encode), and hashes the result.
- Save test/AdvisoryAuditTrail.ts. You won't see output yet. The next chunks add test cases that use these helpers.
Now add three test cases inside the describe block, after the computeRecordHash function. These test recording, verification, and duplicate rejection.
- Paste the following three tests below the computeRecordHash function, still inside the describe block:
it("should record an advisory and emit event", async () => {
const { auditTrail, owner, publicClient } = await deployFixture();
const clientId = "0x" + "ab".repeat(32) as `0x${string}`;
const recordHash = computeRecordHash(
clientId,
"Diversify into index funds",
1000000n,
1719500000n
);
// Record the advisory hash on-chain
const txHash = await auditTrail.write.recordAdvisory([recordHash]);
await publicClient.waitForTransactionReceipt({ hash: txHash });
// Verify the record exists
const result = await auditTrail.read.verifyRecord([recordHash]);
assert.equal(result[2], true);
});
it("should verify a recorded hash returns correct data", async () => {
const { auditTrail, publicClient } = await deployFixture();
const clientId = "0x" + "cd".repeat(32) as `0x${string}`;
const recordHash = computeRecordHash(
clientId,
"Increase bond allocation to 40%",
500000n,
1719600000n
);
// Record then verify the stored data
const txHash = await auditTrail.write.recordAdvisory([recordHash]);
await publicClient.waitForTransactionReceipt({ hash: txHash });
const result = await auditTrail.read.verifyRecord([recordHash]);
assert.equal(result[2], true);
// Timestamp should be non-zero (block.timestamp was set)
assert.notEqual(result[0], 0n);
});
it("should revert on duplicate recording", async () => {
const { auditTrail, publicClient } = await deployFixture();
const clientId = "0x" + "ef".repeat(32) as `0x${string}`;
const recordHash = computeRecordHash(
clientId,
"Sell tech stocks",
200000n,
1719700000n
);
// First recording succeeds
const txHash = await auditTrail.write.recordAdvisory([recordHash]);
await publicClient.waitForTransactionReceipt({ hash: txHash });
// Second recording with same hash should revert
await assert.rejects(
async () => {
await auditTrail.write.recordAdvisory([recordHash]);
}
);
});
What do these tests cover?
The first test records an advisory hash and confirms it exists on-chain by calling verifyRecord. The result[2] value is the exists boolean from the struct.
The second test checks that verifyRecord returns a non-zero timestamp (result[0]) alongside the existence flag. This confirms the contract stores metadata correctly.
The third test ensures the RecordAlreadyExists custom error fires when you try to record the same hash twice. This is critical for audit integrity.
- Save test/AdvisoryAuditTrail.ts.
- Run the tests so far by running:
npx hardhat test
You should see 3 passing tests in the terminal output.
Tests failing or not found?
- Make sure your test file is saved at test/AdvisoryAuditTrail.ts (not inside a subfolder or named differently).
- Check that the three it() blocks are inside the describe block, after the two helper functions.
- If you see a compilation error, ensure contracts/AdvisoryAuditTrail.sol compiles cleanly with npx hardhat build.
Why are my Hardhat tests failing?
Test the hash consistency
The most important property of your audit trail is that a hash computed off-chain (in your Node.js app) matches the hash computed on-chain (in Solidity). If they diverge, you cannot verify records. This test proves equivalence.
- Add this fourth test inside the describe block, after the duplicate recording test:
it("should match off-chain and on-chain hash computation", async () => {
const { auditTrail } = await deployFixture();
const clientId = "0x" + "11".repeat(32) as `0x${string}`;
const recommendation = "Rebalance portfolio quarterly";
const amount = 750000n;
const advisoryTimestamp = 1719800000n;
// Compute hash using our JavaScript helper
const offChainHash = computeRecordHash(
clientId,
recommendation,
amount,
advisoryTimestamp
);
// Compute hash using the Solidity contract's function
const onChainHash = await auditTrail.read.generateRecordHash([
clientId,
recommendation,
amount,
advisoryTimestamp,
]);
// Both must be identical
assert.equal(offChainHash, onChainHash);
});
Why is this test so important?
This test calls the contract's generateRecordHash function on-chain and compares the result against the JavaScript computeRecordHash helper. If both produce the same bytes32 value, you have proof that your verification script can independently recompute any advisory's fingerprint without touching the blockchain.
This is the foundation of tamper-proof auditing. Anyone with the original data can recompute the hash and compare it against the on-chain record.
- Save test/AdvisoryAuditTrail.ts.
Run all tests
- Run the complete test suite by running:
npx hardhat test
You should see all 4 tests passing. The output will list each test name with a pass indicator.
Seeing a hash mismatch in the fourth test?
- Make sure the computeRecordHash function uses toUtf8Bytes to convert the recommendation string before hashing it. This matches keccak256(bytes(recommendation)) in Solidity.
- Verify the encode types array is ["bytes32", "bytes32", "uint256", "uint256"]. The second bytes32 is for the pre-hashed recommendation, not the raw string.
Why is my off-chain hash not matching the on-chain hash?
✔️ Awesome, I've got everything!
Great. Double check you have saved test/AdvisoryAuditTrail.ts and all 4 tests pass.
ⓧ I'd like to double check the full code
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import hre from "hardhat";
import { keccak256, AbiCoder, toUtf8Bytes } from "ethers";
describe("AdvisoryAuditTrail", () => {
async function deployFixture() {
const network = await hre.network.create();
const { viem } = network;
const [owner, other] = await viem.getWalletClients();
const publicClient = await viem.getPublicClient();
const auditTrail = await viem.deployContract("AdvisoryAuditTrail");
return { auditTrail, owner, other, publicClient, viem };
}
function computeRecordHash(
clientId: `0x${string}`,
recommendation: string,
amount: bigint,
advisoryTimestamp: bigint
): `0x${string}` {
const abiCoder = AbiCoder.defaultAbiCoder();
const recommendationHash = keccak256(toUtf8Bytes(recommendation));
const encoded = abiCoder.encode(
["bytes32", "bytes32", "uint256", "uint256"],
[clientId, recommendationHash, amount, advisoryTimestamp]
);
return keccak256(encoded) as `0x${string}`;
}
it("should record an advisory and emit event", async () => {
const { auditTrail, owner, publicClient } = await deployFixture();
const clientId = "0x" + "ab".repeat(32) as `0x${string}`;
const recordHash = computeRecordHash(
clientId,
"Diversify into index funds",
1000000n,
1719500000n
);
const txHash = await auditTrail.write.recordAdvisory([recordHash]);
await publicClient.waitForTransactionReceipt({ hash: txHash });
const result = await auditTrail.read.verifyRecord([recordHash]);
assert.equal(result[2], true);
});
it("should verify a recorded hash returns correct data", async () => {
const { auditTrail, publicClient } = await deployFixture();
const clientId = "0x" + "cd".repeat(32) as `0x${string}`;
const recordHash = computeRecordHash(
clientId,
"Increase bond allocation to 40%",
500000n,
1719600000n
);
const txHash = await auditTrail.write.recordAdvisory([recordHash]);
await publicClient.waitForTransactionReceipt({ hash: txHash });
const result = await auditTrail.read.verifyRecord([recordHash]);
assert.equal(result[2], true);
assert.notEqual(result[0], 0n);
});
it("should revert on duplicate recording", async () => {
const { auditTrail, publicClient } = await deployFixture();
const clientId = "0x" + "ef".repeat(32) as `0x${string}`;
const recordHash = computeRecordHash(
clientId,
"Sell tech stocks",
200000n,
1719700000n
);
const txHash = await auditTrail.write.recordAdvisory([recordHash]);
await publicClient.waitForTransactionReceipt({ hash: txHash });
await assert.rejects(
async () => {
await auditTrail.write.recordAdvisory([recordHash]);
}
);
});
it("should match off-chain and on-chain hash computation", async () => {
const { auditTrail } = await deployFixture();
const clientId = "0x" + "11".repeat(32) as `0x${string}`;
const recommendation = "Rebalance portfolio quarterly";
const amount = 750000n;
const advisoryTimestamp = 1719800000n;
const offChainHash = computeRecordHash(
clientId,
recommendation,
amount,
advisoryTimestamp
);
const onChainHash = await auditTrail.read.generateRecordHash([
clientId,
recommendation,
amount,
advisoryTimestamp,
]);
assert.equal(offChainHash, onChainHash);
});
});
Your contract is tested and verified locally. Next up, you will deploy it to the live Sepolia testnet and verify the source code on Etherscan.
Deploy to Sepolia and Verify
Your contract is tested and ready. Now it needs to live on a real blockchain where regulators and auditors can independently verify the records.
In this step, you will deploy the AdvisoryAuditTrail contract to Sepolia testnet using Hardhat Ignition, then verify the source code on Etherscan so anyone can inspect the contract logic.
In this step, get ready to:
- Create a Hardhat Ignition deployment module.
- Deploy the contract to Sepolia testnet.
- Verify the contract source code on Sepolia Etherscan.
Create a Hardhat Ignition deployment module
Hardhat Ignition is a declarative deployment system. Instead of writing imperative scripts, you define a module that describes what to deploy. Ignition handles ordering, error recovery, and state tracking.
- Create a new file at ignition/modules/AdvisoryAuditTrail.ts by running:
touch ignition/modules/AdvisoryAuditTrail.ts
What about the Counter module?
Your project already has an ignition/modules/ folder from the default Hardhat scaffold. The Counter module that came with the template is fine to leave in place. You are creating a new module alongside it.
- Open ignition/modules/AdvisoryAuditTrail.ts in VS Code and paste the following code:
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
export default buildModule("AdvisoryAuditTrailModule", (m) => {
// Deploy the AdvisoryAuditTrail contract with no constructor args
const auditTrail = m.contract("AdvisoryAuditTrail");
return { auditTrail };
});
What does this code do?
buildModule defines a named deployment module. The callback receives a ModuleBuilder (m) that you use to declare what should be deployed.
m.contract("AdvisoryAuditTrail") tells Ignition to deploy the contract. Since the constructor takes no arguments, you pass nothing extra. Ignition automatically sets msg.sender as the deployer, which becomes the contract owner.
- Save ignition/modules/AdvisoryAuditTrail.ts.
Seeing an import error?
If your editor underlines the @nomicfoundation/hardhat-ignition/modules import, confirm that @nomicfoundation/hardhat-toolbox-viem is installed in your package.json. The toolbox includes Hardhat Ignition.
help me fix an import error for hardhat-ignition/modules
Deploy to Sepolia
With the module ready, you can now send the contract to the live Sepolia testnet. Ignition will compile with its production profile, prompt for your keystore password, and broadcast the deployment transaction.
- Deploy the contract to Sepolia by running:
npx hardhat ignition deploy ignition/modules/AdvisoryAuditTrail.ts --network sepolia
What happens when you run this?
- Hardhat prompts you for your keystore password to unlock your SEPOLIA_RPC_URL and SEPOLIA_PRIVATE_KEY.
- It compiles the contract with the production build profile.
- You are asked to confirm the deployment. Type "yes" when prompted.
- Ignition broadcasts the transaction, waits for it to be mined, and prints the deployed contract address.
- When prompted, enter your keystore password.
- Type yes to confirm the deployment.
- Copy the deployed contract address from the output. You will need it in the next step.
You should see output similar to this, with your own contract address:
Hardhat Ignition 🚀
Deploying [ AdvisoryAuditTrailModule ]
Batch #1
Executed AdvisoryAuditTrailModule#AdvisoryAuditTrail
[ AdvisoryAuditTrailModule ] successfully deployed 🚀
Deployed Addresses
AdvisoryAuditTrailModule#AdvisoryAuditTrail - 0x...
Where does Ignition store deployment data?
Ignition saves deployment results in ignition/deployments/chain-11155111 (11155111 is Sepolia's chain ID). This folder tracks what was deployed so Ignition can recover from errors or skip already-deployed contracts on re-runs.
Deployment failing?
If you see an insufficient funds error, your wallet does not have enough Sepolia ETH. Return to the Google Cloud faucet and request more test ETH.
If you see a connection error, confirm your SEPOLIA_RPC_URL keystore value is correct. Re-run npx hardhat keystore set SEPOLIA_RPC_URL to update it.
help me debug my Hardhat Ignition deployment to Sepolia
Verify on Etherscan
Verifying your contract publishes its source code on Etherscan. This lets anyone read the contract logic and confirm it matches the deployed bytecode. For an audit trail, public verifiability is the entire point.
- Verify the contract on Sepolia Etherscan by running:
npx hardhat verify [[CONTRACT_ADDRESS="your deployed contract address"]] --network sepolia
What does verification do?
Etherscan compiles your source code with the same Solidity version and settings, then checks that the resulting bytecode matches what is deployed on-chain. If it matches, Etherscan marks the contract as verified and displays a green checkmark.
After verification, anyone can read the recordAdvisory and verifyRecord functions directly on Etherscan without needing access to your source code.
- Open your browser and navigate to https://sepolia.etherscan.io/address/YOUR_CONTRACT_ADDRESS (replacing the address with your actual deployed address).
- Click the Contract tab. You should see a green checkmark next to the source code, confirming verification succeeded.
Verification failing?
If you get an error indicating the address does not have bytecode, Etherscan has not indexed your contract yet. Wait one minute and retry the verify command.
If you see a "already verified" message, your contract is already public on Etherscan. Navigate to the contract page to confirm.
help me fix Hardhat contract verification on Sepolia Etherscan
✔️ Awesome, I've got everything!
Great. Double check you have your deployed contract address saved somewhere. You will need it in the next step when building the verification script.
ⓧ I'd like to double check the full code
Here is the complete Ignition module file:
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
export default buildModule("AdvisoryAuditTrailModule", (m) => {
const auditTrail = m.contract("AdvisoryAuditTrail");
return { auditTrail };
});
Your contract is live and publicly verified on Sepolia. Next up, you will build a script that records advisory data on-chain and proves its integrity.
Build the Verification Script
Your contract is live on Sepolia, but a deployed contract alone does not prove anything to a regulator or auditor.
What closes the loop is a script that simulates an advisory interaction, computes the same keccak256 hash your contract expects, records it on-chain, and then reads it back to prove the record is intact. That is exactly what you will build in this step.
In this step, get ready to:
- Create a TypeScript script that connects to your deployed contract on Sepolia.
- Compute an advisory record hash off-chain and record it on the blockchain.
- Verify the record on-chain and print an integrity report.
Create the script and connect to your contract
The verification script needs to connect to Sepolia using the same RPC credentials you stored in the Hardhat keystore, then get a reference to your deployed contract.
- Create the file scripts/record-and-verify.ts and add the following code:
import hre from "hardhat";
import { keccak256, AbiCoder, toUtf8Bytes } from "ethers";
async function main() {
// Connect to the Sepolia network using stored keystore credentials
const connection = await hre.network.connect("sepolia");
const { viem } = connection;
// Replace with your actual deployed contract address
const CONTRACT_ADDRESS = "your-deployed-contract-address-here";
// Get a reference to the deployed contract
const auditTrail = await viem.getContractAt(
"AdvisoryAuditTrail",
CONTRACT_ADDRESS as `0x${string}`
);
}
- Replace your-deployed-contract-address-here with the contract address you recorded in the previous step (the address printed when Ignition deployed your contract).
What does this code do?
hre.network.connect("sepolia") opens a live connection to the Sepolia network using the RPC URL and private key stored in your Hardhat keystore.
viem.getContractAt() gives you a typed contract instance you can call read and write methods on, using the ABI generated when you compiled the contract.
Simulate advisory data and compute the off-chain hash
In a production system, this data comes from your AI advisory application. For now, you will hardcode a realistic advisory interaction and compute its cryptographic fingerprint locally.
- Add the following code inside the main() function, below the auditTrail declaration:
// Simulate advisory interaction data
const clientId = "0x" + "aa".repeat(32) as `0x${string}`;
const recommendation = "Allocate 60% to diversified ETFs, 30% bonds, 10% alternatives";
const amount = 2500000n; // $2.5M portfolio
const advisoryTimestamp = BigInt(Math.floor(Date.now() / 1000));
// Compute hash off-chain (matching Solidity abi.encode)
const abiCoder = AbiCoder.defaultAbiCoder();
const recommendationHash = keccak256(toUtf8Bytes(recommendation));
const encoded = abiCoder.encode(
["bytes32", "bytes32", "uint256", "uint256"],
[clientId, recommendationHash, amount, advisoryTimestamp]
);
const recordHash = keccak256(encoded) as `0x${string}`;
console.log("=== Advisory Audit Trail ===");
console.log(`Client ID: ${clientId}`);
console.log(`Recommendation: ${recommendation}`);
console.log(`Amount: ${amount}`);
console.log(`Timestamp: ${advisoryTimestamp}`);
console.log(`Record Hash: ${recordHash}`);
console.log("");
Why hash the recommendation separately?
Strings are dynamic types in Solidity. By pre-hashing the recommendation text with keccak256(toUtf8Bytes(recommendation)), you convert it to a fixed-size bytes32 before encoding. This matches exactly what the contract does with keccak256(bytes(recommendation)).
The outer AbiCoder.encode() then operates only on fixed-size types, producing the same byte sequence as Solidity's abi.encode(). This is what guarantees off-chain and on-chain hashes will always match.
Record the hash on-chain and verify
Now the script will submit the hash as a transaction, wait for confirmation, then read the record back to prove it was stored correctly.
- Add the following code below the console.log("") line you just added:
// Record the advisory hash on-chain
console.log("Recording advisory on-chain...");
const publicClient = await viem.getPublicClient();
const txHash = await auditTrail.write.recordAdvisory([recordHash]);
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
console.log(`Transaction confirmed: ${receipt.transactionHash}`);
console.log(`Block number: ${receipt.blockNumber}`);
console.log("");
// Verify the record exists on-chain
console.log("Verifying record on-chain...");
const result = await auditTrail.read.verifyRecord([recordHash]);
const [storedTimestamp, advisor, exists] = result;
console.log("=== Verification Report ===");
console.log(`Record exists: ${exists}`);
console.log(`On-chain timestamp: ${storedTimestamp}`);
console.log(`Recorded by advisor: ${advisor}`);
console.log(`Integrity verified: ${exists ? "PASS" : "FAIL"}`);
}
main().catch(console.error);
What does this code do?
auditTrail.write.recordAdvisory([recordHash]) sends a transaction to the blockchain that stores your hash permanently. The waitForTransactionReceipt call pauses until the transaction is mined into a block.
auditTrail.read.verifyRecord([recordHash]) is a free read-only call (no gas cost) that fetches the stored timestamp, advisor address, and existence flag. If exists is true, the record is intact and unaltered.
- Save scripts/record-and-verify.ts.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before running it.
ⓧ I'd like to double check the full code
import hre from "hardhat";
import { keccak256, AbiCoder, toUtf8Bytes } from "ethers";
async function main() {
const connection = await hre.network.connect("sepolia");
const { viem } = connection;
const CONTRACT_ADDRESS = "your-deployed-contract-address-here";
const auditTrail = await viem.getContractAt(
"AdvisoryAuditTrail",
CONTRACT_ADDRESS as `0x${string}`
);
const clientId = "0x" + "aa".repeat(32) as `0x${string}`;
const recommendation = "Allocate 60% to diversified ETFs, 30% bonds, 10% alternatives";
const amount = 2500000n;
const advisoryTimestamp = BigInt(Math.floor(Date.now() / 1000));
const abiCoder = AbiCoder.defaultAbiCoder();
const recommendationHash = keccak256(toUtf8Bytes(recommendation));
const encoded = abiCoder.encode(
["bytes32", "bytes32", "uint256", "uint256"],
[clientId, recommendationHash, amount, advisoryTimestamp]
);
const recordHash = keccak256(encoded) as `0x${string}`;
console.log("=== Advisory Audit Trail ===");
console.log(`Client ID: ${clientId}`);
console.log(`Recommendation: ${recommendation}`);
console.log(`Amount: ${amount}`);
console.log(`Timestamp: ${advisoryTimestamp}`);
console.log(`Record Hash: ${recordHash}`);
console.log("");
console.log("Recording advisory on-chain...");
const publicClient = await viem.getPublicClient();
const txHash = await auditTrail.write.recordAdvisory([recordHash]);
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
console.log(`Transaction confirmed: ${receipt.transactionHash}`);
console.log(`Block number: ${receipt.blockNumber}`);
console.log("");
console.log("Verifying record on-chain...");
const result = await auditTrail.read.verifyRecord([recordHash]);
const [storedTimestamp, advisor, exists] = result;
console.log("=== Verification Report ===");
console.log(`Record exists: ${exists}`);
console.log(`On-chain timestamp: ${storedTimestamp}`);
console.log(`Recorded by advisor: ${advisor}`);
console.log(`Integrity verified: ${exists ? "PASS" : "FAIL"}`);
}
main().catch(console.error);
Run the script against Sepolia
Time to see your audit trail in action. The script will send a real transaction to Sepolia, spending a tiny amount of your test ETH.
- Execute the script by running:
npx hardhat run scripts/record-and-verify.ts
- Wait for the transaction to confirm. You should see output similar to this:
=== Advisory Audit Trail ===
Client ID: 0xaaaa...aa
Recommendation: Allocate 60% to diversified ETFs, 30% bonds, 10% alternatives
Amount: 2500000
Timestamp: 1751018400
Record Hash: 0x3f2a...
Recording advisory on-chain...
Transaction confirmed: 0x7c1e...
Block number: 12345678
Verifying record on-chain...
=== Verification Report ===
Record exists: true
On-chain timestamp: 1751018410
Recorded by advisor: 0xYourWalletAddress
Integrity verified: PASS
What should I see?
The key line is Integrity verified: PASS. This proves the hash you computed locally matches what the blockchain stored. The on-chain timestamp will differ slightly from your advisory timestamp because it reflects the block's mining time.
- Copy the transaction hash from the output.
- Open https://sepolia.etherscan.io in your browser.
- Paste the transaction hash into the search bar and press Enter.
- Confirm you can see your AdvisoryRecorded event in the transaction's Logs tab.
Transaction failing?
If you see a NotOwner revert, the private key in your Hardhat keystore does not match the account that deployed the contract. Make sure SEPOLIA_PRIVATE_KEY is the same key you used during deployment.
If you see RecordAlreadyExists, you have already recorded this exact hash. Change the recommendation text slightly and re-run the script to produce a different hash.
If the transaction hangs, your wallet may be out of Sepolia ETH. Visit the Google Cloud faucet to get more test ETH.
Your audit trail is fully operational. You have proven that an advisory record can be hashed off-chain, stored immutably on the blockchain, and independently verified at any time. This is the exact pattern that financial firms use to satisfy regulatory record-keeping requirements.
Secret mission
Add Batch Recording
In production, advisory sessions generate multiple interactions at once. Recording them one by one wastes gas and slows down your system. Can you add a batch function that submits multiple hashes in a single transaction?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on Sepolia testnet and Alchemy's free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local advisory-audit-trail project folder.
- Alchemy Sepolia app (free tier, no charges within free limits).
- Hardhat encrypted keystore (stores your RPC URL and private key locally).
- AdvisoryAuditTrail smart contract deployed on Sepolia testnet (immutable, no ongoing cost).
Keep everything running
No action needed. Choose this if you plan to continue building on your audit trail contract.
- Your AdvisoryAuditTrail contract remains deployed on Sepolia indefinitely at no cost.
- Your Alchemy free tier provides 300M compute units per month. Your Sepolia app will not incur charges.
- Your Hardhat keystore stays encrypted on your machine and is only accessible through the npx hardhat keystore command.
- Your MetaMask wallet retains the Sepolia test account. Sepolia ETH has no real-world value.
Pause - I'll come back to this later
There are no running processes or paid services to stop. Everything persists at zero cost.
- Sepolia contracts persist permanently on the blockchain without any maintenance.
- Alchemy does not charge for inactive free-tier apps.
- Your local project files remain in the advisory-audit-trail folder on your Desktop.
Delete - I don't want to use this again
Remove all local project resources. The deployed contract on Sepolia cannot be removed from the blockchain, but it has no ongoing cost.
- Delete the local project folder by running this command in your terminal:
macOS
cd ~/Desktop
rm -rf advisory-audit-trail
Windows
cd ~\Desktop
Remove-Item -Recurse -Force advisory-audit-trail
- Revoke your Sepolia private key in MetaMask if you created a dedicated test wallet. Open MetaMask, click the three-dot menu on the test account, and select Remove account.
- Delete your Alchemy Sepolia app. Log in to your Alchemy dashboard, find your Sepolia app, click the Settings tab, and select Delete App.
- The AdvisoryAuditTrail contract will remain on Sepolia permanently. This is by design. Blockchain records are immutable and Sepolia has no real-world cost.
Nice Work!
Nice Work!
Nice work. You just built a production-grade blockchain audit trail that cryptographically proves AI-generated financial advice has not been tampered with. This is the exact pattern used by regulated firms to meet compliance requirements like MiFID II and SEC record-keeping.
You've learned how to:
- Built and deployed an AdvisoryAuditTrail smart contract to Ethereum Sepolia, using Hardhat Ignition for declarative deployment and verifying the source on Etherscan for public transparency.
- Implemented tamper-proof keccak256 hashing with abi.encode for structured advisory data, ensuring collision-safe encoding across mixed types.
- Built a Node.js verification script that computes hashes off-chain and proves they match on-chain records, demonstrating full off-chain/on-chain hash equivalence.
- Secret Mission: Added batch recording to efficiently submit multiple advisory hashes in a single transaction, reducing per-record gas costs.
Ready to quiz yourself?