A two-phase, blockchain-enabled certification prototype:
Both phases mint the same underlying token type — a soulbound (non-transferable) ERC-721 certificate — distinguished on-chain by a kind field (PARTICIPATION vs EVALUATION), so certificates can never be resold or transferred once issued, only verified.
┌────────────────────────┐
Registration ─────────►│ data/participants.json│◄── Phase 1 "Google Sheet"
(manual verify/approve)│ data/submissions.json │◄── Phase 2 "Google Sheet"/LMS
└───────────┬────────────┘
│ approvalStatus == "Approved"
│ evaluation.status == "Completed"
▼
┌────────────────────────┐
│ scripts/phase1_mint.js │ auto-mint on approval
│ scripts/phase2_mint.js │ mint graded certificate
└───────────┬────────────┘
│ issueCertificate(to, kind, name, event, uri)
▼
┌────────────────────────┐
│ contracts/CertificateSBT.sol │ Solidity 0.8.24
│ ERC-721 + AccessControl │ Polygon-ready
│ soulbound (non-transferable) │
└───────────┬────────────┘
│ CertificateIssued(tokenId, kind, to, ...)
▼
┌────────────────────────┐
│ metadata/*.json │ ERC-721 metadata +
│ (name, attrs, marks, │ verification link
│ grade, comments, etc.) │
└───────────┬────────────┘
│
▼
┌────────────────────────┐
│ scripts/phase1_email.js │ manual trigger only
│ (mock SMTP → certificates/*.txt)
└────────────────────────┘
Certificates should never be tradable or transferable — they represent a
fact about a person, not an asset. CertificateSBT overrides ERC-721’s
internal _update hook so every transfer (except the initial mint) reverts
with "CertificateSBT: non-transferable (soulbound)". This was verified live
in scripts/verify_demo.js (see Sample run log below).
A single contract keeps verification simple (one address, one explorer page)
while still letting each certificate carry phase-specific metadata. The
CertKind enum (0 = PARTICIPATION, 1 = EVALUATION) is stored on-chain per
token; all the rich Phase 2 fields (marks, grade, evaluator, parameters,
comments, audio feedback URL) live in the linked JSON metadata, exactly as
ERC-721 metadata is intended to work.
contracts/
CertificateSBT.sol Soulbound ERC-721 + AccessControl certificate contract
scripts/
compile.js Compiles the contract via the solc npm package
deploy.js Deploys CertificateSBT (local Hardhat / Polygon Amoy)
phase1_mint.js Auto-mints participation certificates on approval
phase1_email.js Manually-triggered email delivery step
phase2_mint.js Mints graded evaluation certificates
verify_demo.js Reads back on-chain state + proves soulbound transfer block
generate_certificate_images.py Renders sample certificate PNGs from metadata
lib/common.js Shared helpers (contract loader, verification link builder)
data/
participants.json Mock "Google Sheet" — Phase 1 registrations
submissions.json Mock "Google Sheet"/LMS export — Phase 2 submissions + evaluations
build/
CertificateSBT.json Compiled ABI + bytecode
deployment.json Deployed contract address + network
metadata/
participation-*.json Generated Phase 1 certificate metadata
evaluation-*.json Generated Phase 2 certificate metadata
certificates/
email-*.txt Mock delivered emails (Phase 1)
assets/
sample-participation-certificate.png
sample-evaluation-certificate.png
| Layer | Choice |
|---|---|
| Smart contract | Solidity 0.8.24, ERC-721 + AccessControl (OpenZeppelin v5), soulbound |
| Target chain | Polygon (Amoy testnet config included; deploys locally by default for this submission) |
| Backend | Node.js + Hardhat + ethers.js v6 |
| Data store | Mock JSON files (data/*.json) standing in for Google Sheets / a DB |
| Metadata | ERC-721 standard JSON (name, description, image, attributes) + program-specific fields |
Note on compilation: this sandbox’s network egress does not reach
binaries.soliditylang.org(Hardhat’s default compiler downloader), soscripts/compile.jscompiles via thesolcnpm package directly instead ofnpx hardhat compile. Functionally identical output. In an environment with normal internet access,npx hardhat compileworks too.
```bash npm install
node scripts/compile.js
npx hardhat node
npx hardhat run scripts/deploy.js –no-compile –network localhost
npx hardhat run scripts/phase1_mint.js –no-compile –network localhost
npx hardhat run scripts/phase1_email.js –no-compile –network localhost
npx hardhat run scripts/phase2_mint.js –no-compile –network localhost
cp .env.example .env and fill in POLYGON_AMOY_RPC_URL and
DEPLOYER_PRIVATE_KEY (a funded Amoy testnet wallet — get test MATIC from
the Polygon faucet).npx hardhat run scripts/deploy.js --no-compile --network amoy--network amoy instead of --network localhost.buildVerificationLink() in scripts/lib/common.js will automatically
point at amoy.polygonscan.com — a real, working block explorer link.“When a participant is marked Approved, the certificate should be minted
automatically.”
phase1_mint.js scans data/participants.json and mints for every record
where approvalStatus === "Approved" and no certificate has been issued yet.
Nothing else triggers a mint — there’s no separate “click to mint” step.
“Email delivery should remain Pending and be triggered manually after the
workshop.”
phase1_mint.js never touches emailStatus. It’s a completely separate
script (phase1_email.js) that an admin runs on demand; it only emails
participants whose certificateStatus === "Issued" and
emailStatus === "Pending".
Contract deployed at: 0x5340aCB453B7951a896f238EAAb0EFF74c2DA12A Network: Polygon Amoy Testnet (Chain ID 80002) Explorer: https://amoy.polygonscan.com/address/0x5340aCB453B7951a896f238EAAb0EFF74c2DA12A
=== PHASE 1 MINT === MINT P-001 (Ananya Rao) - approvalStatus=Approved -> issuing certificate… -> tokenId=1 -> tx: https://amoy.polygonscan.com/tx/0x1aec5abb738e0966d5ff72be9a3819b430ab2d698efa336b334914f7982317ca SKIP P-002 (Rohan Mehta) - approvalStatus=Pending SKIP P-003 (Fatima Sheikh) - approvalStatus=Rejected Done. 1 certificate(s) minted.
=== PHASE 1 EMAIL (manual trigger) === SENT P-001 (Ananya Rao) -> ananya.rao@example.com Done. 1 email(s) sent.
=== PHASE 2 MINT === MINT S-001 (Ananya Rao) -> tokenId=2, grade=A (87/100) -> tx: https://amoy.polygonscan.com/tx/0x76bc15b2a0bc21ca5581fa2754813bda88e5084f78e090369f6554adcd748527 MINT S-002 (Rohan Mehta) -> tokenId=3, grade=B+ (80/100) -> tx: https://amoy.polygonscan.com/tx/0x6a20a50df26b0318d611419a8089939db11a0346cd56cdf2e08c30bb8ea87d6f Done. 2 certificate(s) minted.
All transactions publicly verifiable at the explorer link above.