Building a Medical Report Registry on Conflux eSpace
Tamper-proof medical reports on-chain — issue, verify, and revoke with a Next.js frontend and one smart contract.
Why put medical reports on a blockchain?
Labs and hospitals need to issue reports that employers, schools, or other parties can trust. Paper and PDFs can be forged. A registry on-chain lets anyone verify that a report was issued by a known address, at a given time, and whether it has been revoked — without storing the report content itself.
We built cfxmedical: a medical report registry on Conflux eSpace Testnet (Chain ID 71). Only hashes of reports and patient IDs are stored; no raw patient data goes on-chain. Labs issue and revoke; anyone can verify.
This post walks through the important parts: the smart contract, wallet connection, hash generation, and issuing a report from the frontend.
1. Smart contract: what we store and who can do what
The contract is called MedicalStringReportRegistry (we use the same logic in a file named medcical.sol). It keeps a mapping from report hash → report record. Each record stores only hashes and metadata, not the report body or patient name.
Data model and storage
contract MedicalStringReportRegistry {
struct Report {
bytes32 reportHash;
bytes32 patientIdHash;
address issuedBy;
uint256 issuedAt;
bool revoked;
}
mapping(bytes32 => Report) private reports;
- reportHash — identifies the report (e.g. keccak256 of the report content or a unique ID).
- patientIdHash — hash of the patient identifier so we never store raw PII.
- issuedBy — the lab’s address (msg.sender when issuing).
- issuedAt — block timestamp.
- revoked — whether the report has been invalidated.
Issue a report (labs only)
Anyone can call issueReport; in practice only trusted labs should do so. The contract only allows one record per report hash.
function issueReport(
bytes32 _reportHash,
bytes32 _patientIdHash
) external {
require(reports[_reportHash].issuedAt == 0, "Report already exists");
reports[_reportHash] = Report({
reportHash: _reportHash,
patientIdHash: _patientIdHash,
issuedBy: msg.sender,
issuedAt: block.timestamp,
revoked: false
});
emit ReportIssued(
_reportHash,
_patientIdHash,
msg.sender,
block.timestamp
);
}
Revoke (only the issuer)
Only the address that issued a report can revoke it.
function revokeReport(bytes32 _reportHash)
external
reportExists(_reportHash)
{
require(
reports[_reportHash].issuedBy == msg.sender,
"Only issuing lab can revoke"
);
reports[_reportHash].revoked = true;
emit ReportRevoked(_reportHash);
}
Verify (anyone, read-only)
Anyone can check whether a report exists and get issuer, time, and revoked status.
function verifyReport(bytes32 _reportHash)
external
view
reportExists(_reportHash)
returns (
bytes32 patientIdHash,
address issuedBy,
uint256 issuedAt,
bool revoked
)
{
Report memory report = reports[_reportHash];
return (
report.patientIdHash,
report.issuedBy,
report.issuedAt,
report.revoked
);
}
}
So: only hashes on-chain; labs issue and revoke; anyone can verify.
2. Frontend: one contract, one ABI
We use a single source of truth for the contract: the Hardhat build artifact. The app imports the ABI and address from there so the UI and the deployed contract never get out of sync.
// lib/registry-contract.ts
import MedicalStringReportRegistryArtifact from "@/artifacts/contracts/MedicalStringReportRegistry.sol/MedicalStringReportRegistry.json";
export const REGISTRY_ABI =
MedicalStringReportRegistryArtifact.abi as readonly unknown[];
export const REGISTRY_CONTRACT_ADDRESS =
process.env.NEXT_PUBLIC_REGISTRY_CONTRACT_ADDRESS ||
"0x8149747667Fa572740336c81121fFEB2bf4EBF6e";
All panels (Issue, Verify, Revoke) use this ABI and address.
3. Wallet connection and Conflux eSpace
The app uses ethers v6 and MetaMask (or any wallet that supports wallet_addEthereumChain). We force Conflux eSpace Testnet so users don’t sign on the wrong chain.
Network config
// lib/wallet-context.tsx
const CONFLUX_TESTNET = {
chainId: "0x47",
chainName: "Conflux eSpace Testnet",
nativeCurrency: { name: "CFX", symbol: "CFX", decimals: 18 },
rpcUrls: ["https://evmtestnet.confluxrpc.com"],
blockExplorerUrls: ["https://evmtestnet.confluxscan.net"],
};
Connect and attach the contract
On “Connect Wallet” we request accounts, ensure the correct network, then create a signed contract instance so the user can send transactions (issue, revoke).
const connect = useCallback(async () => {
// ...
await ensureNetwork()
const accounts = (await window.ethereum.request({
method: "eth_requestAccounts",
})) as string[]
const provider = new BrowserProvider(window.ethereum)
const signer = await provider.getSigner()
const registry = new Contract(
REGISTRY_CONTRACT_ADDRESS,
REGISTRY_ABI as never[],
signer
)
setAddress(accounts[0])
setContract(registry)
}, [ensureNetwork])
The same context exposes contract to the Issue and Revoke panels; Verify can use a read-only instance or the same contract (view calls don’t need a signer).
4. Hash generator: from text to bytes32
We never send report text or patient IDs to the chain — only their keccak256 hashes. The UI has a “Hash generator” that turns human-readable strings into the two bytes32 values the contract expects.
// components/hash-generator-panel.tsx
import { keccak256, toUtf8Bytes } from "ethers"
const generateHashes = () => {
if (!reportText.trim() || !patientText.trim()) return
try {
setReportHash(keccak256(toUtf8Bytes(reportText.trim())))
setPatientHash(keccak256(toUtf8Bytes(patientText.trim())))
} catch (e) {
console.error(e)
}
}
-
Report content (e.g.
"LAB-2024-001"or the full report body) → report hash (bytes32). -
Patient ID (e.g.
"PAT-12345") → patient ID hash (bytes32).
Verifiers who know the same strings can recompute the same hashes and check them on-chain.
5. Issuing a report from the UI
The Issue Report panel uses the wallet context: it needs a connected wallet and the contract instance with signer. It validates both inputs as 66-character hex (0x + 64 hex digits), then calls the contract and shows the transaction link.
// components/issue-report-panel.tsx
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!isConnected || !contract) {
setStatus("error")
setMessage("Connect your wallet first to issue a report.")
return
}
// ... bytes32 validation (0x + 64 hex chars) ...
try {
const tx = await contract.issueReport(reportHash.trim(), patientIdHash.trim())
await tx.wait()
setTxHash(tx.hash)
setStatus("success")
setMessage("Report successfully issued and recorded on-chain.")
// ... clear form, show ConfluxScan link ...
} catch (err) {
setStatus("error")
setMessage(err instanceof Error ? err.message : "Transaction failed.")
}
}
After success we show “View transaction on ConfluxScan” using https://evmtestnet.confluxscan.net/tx/${tx.hash} so the lab (and anyone else) can see the on-chain proof.
6. Verify and revoke
-
Verify — The Verify panel calls
contract.verifyReport(reportHash)and displayspatientIdHash,issuedBy,issuedAt, andrevoked. No wallet needed for this read. -
Revoke — The Revoke panel calls
contract.revokeReport(reportHash)with the connected wallet; the transaction succeeds only ifmsg.senderis the same asissuedBy. We again show the ConfluxScan link after success.
Summary
| Part | Role |
|---|---|
| Smart contract | Stores only report and patient ID hashes; labs issue/revoke, anyone verifies. |
| Registry contract module | Single ABI + address from Hardhat artifact. |
| Wallet context | Connects to Conflux eSpace, attaches contract with signer. |
| Hash generator | keccak256(text) → bytes32 for report and patient ID. |
| Issue panel | contract.issueReport(…) and show tx link. |
We deploy the contract to Conflux eSpace Testnet (Chain ID 71), use Next.js for the UI, and ethers for all contract calls. Raw medical data stays off-chain; only hashes and metadata live on-chain, so the registry is both verifiable and privacy-conscious.
Try it
- Clone the repo, set
NEXT_PUBLIC_REGISTRY_CONTRACT_ADDRESS(or use the default deployed address). - Run
npm run dev, open the app, and connect your wallet to Conflux eSpace Testnet. - Use Hash generator to get report and patient hashes, then Issue Report to send a transaction.
- Use Verify Report to query on-chain; use Revoke Report (as the issuing address) to revoke.
Contract and deploy scripts are in the repo; the medical registry logic lives in medcical.sol / contracts/MedicalStringReportRegistry.sol.
GitHub; https://github.com/Vikash-8090-Yadav/cfxmedical
Demo Video; https://youtu.be/IpVqsFFfhuA
Built for Conflux eSpace Testnet · Chain ID 71