Building a Medical Report Registry on Conflux eSpace

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 hashreport 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 displays patientIdHash, issuedBy, issuedAt, and revoked. No wallet needed for this read.
  • Revoke — The Revoke panel calls contract.revokeReport(reportHash) with the connected wallet; the transaction succeeds only if msg.sender is the same as issuedBy. 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

  1. Clone the repo, set NEXT_PUBLIC_REGISTRY_CONTRACT_ADDRESS (or use the default deployed address).
  2. Run npm run dev, open the app, and connect your wallet to Conflux eSpace Testnet.
  3. Use Hash generator to get report and patient hashes, then Issue Report to send a transaction.
  4. 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