Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ The project is focused on real certificate workflows, not generic task farming.

| Contract | Contract ID | Verify Link | Status |
|---|---|---|---|
| NFT Certificate Contract | CCC732QGOBVC2MJEBHIS4RU57IGHJSWHBL6BHD2AXNUCNYBWA3PNL4WO | [Stellar Expert](https://stellar.expert/explorer/testnet/contract/CCC732QGOBVC2MJEBHIS4RU57IGHJSWHBL6BHD2AXNUCNYBWA3PNL4WO) | Verified on testnet |
| NFT Certificate Contract | CCBM2ZDOU3LUDVWNX6TV55YDLRH2EO6LXYL7SRXYI3HXT422MNSWJSUY | [Stellar Expert](https://stellar.expert/explorer/testnet/contract/CCBM2ZDOU3LUDVWNX6TV55YDLRH2EO6LXYL7SRXYI3HXT422MNSWJSUY) | Verified on testnet |
| Verifier Contract | CBUVPMCNQ33YITCLGQGPRXAMS3C3BYCBLREEXRIFVRJ5LUYJXJTM4NGA | [Stellar Expert](https://stellar.expert/explorer/testnet/contract/CBUVPMCNQ33YITCLGQGPRXAMS3C3BYCBLREEXRIFVRJ5LUYJXJTM4NGA) | Verified on testnet |

### Deployment Notes
Expand All @@ -97,7 +97,28 @@ The project is focused on real certificate workflows, not generic task farming.
| Horizon | https://horizon-testnet.stellar.org |
| Env Key | NEXT_PUBLIC_NFT_CONTRACT_ID |
| Env Key | NEXT_PUBLIC_VERIFIER_CONTRACT_ID |
| Env Key | NEXT_PUBLIC_SOROBAN_RPC_URL |

### Manual Contract Deployment

The repository does not currently automate on-chain deployment in CI. Use the Soroban CLI and a funded deploy account to publish contracts after building artifacts.

```powershell
npm run build:contracts
pwsh ./scripts/deploy-contracts.ps1 -Network testnet -Deploy
```

If you do not want to deploy immediately, build artifacts only with:

```powershell
npm run build:contracts
```

## 🆕 New Smart Contract Feature

- ✅ On-chain issuer reputation tracking for every issued certificate.
- ✅ Endorsement support: certificates can receive on-chain endorsements and endorsement history can be queried.
- ✅ Verifier contract performs cross-contract validation by querying the NFT contract directly.

## 🧪 Test Evidence

Expand Down Expand Up @@ -136,6 +157,7 @@ CertMint is designed around public proof and controlled access.
| Auth | Sign in / Sign up flow | Implemented |
| Minting | Certificate mint wizard | Implemented |
| Approvals | Multi-Level Workflows (Standard: Faculty/Issuer → Admin → Mint; Academic: Faculty → HOD → Registrar → Mint) | Implemented |
| Endorsements | On-chain endorsements for issued certificates | Implemented |
| Reputation | Issuer Reputation System (Total Issued, Revoked Count, Reputation Score tracked on-chain) | Implemented |
| Hooks | Shared wallet and mint integration logic | Added |
| Verification | Search by certificate ID or TX hash | Implemented |
Expand All @@ -150,6 +172,8 @@ CertMint is designed around public proof and controlled access.
| nft_certificate | transfer | Move ownership |
| nft_certificate | burn / revoke path | Invalidate issued credentials & decrements issuer reputation score |
| nft_certificate | get_issuer | Query issuer reputation data (total issued, revoked, reputation score) |
| nft_certificate | endorse_certificate | Add on-chain endorsements to a certificate |
| nft_certificate | get_endorsements | Query certificate endorsement history |
| verifier | verify by token | Validate a certificate record |
| verifier | verify by wallet | Check ownership-related proof |

Expand Down Expand Up @@ -256,7 +280,8 @@ npm run lint
npm run build
npm run test:e2e
cd contracts
cargo test
cargo test --all
cargo build --release --target wasm32v1-none
```

### 5) Run with Docker (Recommended)
Expand Down
161 changes: 161 additions & 0 deletions app/actions/endorsements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"use server";

import { getEndorserKeypair, ENDORSER_METADATA } from "@/lib/endorsements";
import { createAdminClient } from "@/lib/supabase/admin";

export async function endorseCertificateAction(
tokenId: number,
endorserType: 'techverse' | 'mentor' | 'organization'
) {
const supabase = createAdminClient();

// 1. Find the certificate ID
const { data: cert, error: certErr } = await supabase
.from("certificates")
.select("id")
.eq("token_id", tokenId)
.single();

if (certErr || !cert) {
throw new Error("Certificate not found in database.");
}

const keypair = getEndorserKeypair(endorserType);
const publicKey = keypair.publicKey();
const contractId = process.env.NEXT_PUBLIC_NFT_CONTRACT_ID;
if (!contractId || contractId === "PLACEHOLDER") {
throw new Error("NFT contract ID is not configured.");
}

const { rpc: SorobanRpc, TransactionBuilder, Contract, nativeToScVal, Networks } = await import("@stellar/stellar-sdk");
const sorobanServer = new SorobanRpc.Server(
process.env.NEXT_PUBLIC_SOROBAN_RPC_URL || "https://soroban-testnet.stellar.org"
);

// 2. Fund the endorser account if it doesn't exist on testnet
try {
const accountRes = await fetch(`https://horizon-testnet.stellar.org/accounts/${publicKey}`);
if (accountRes.status === 404) {
console.log(`Funding ${endorserType} account ${publicKey} on testnet...`);
const fundRes = await fetch(`https://friendbot.stellar.org/?addr=${publicKey}`);
if (!fundRes.ok) {
throw new Error(`Failed to fund endorser account via Friendbot: ${fundRes.statusText}`);
}
// Wait for ledger close (3 seconds)
await new Promise((resolve) => setTimeout(resolve, 3000));
}
} catch (err) {
console.warn(`Friendbot funding check/call failed for ${endorserType}:`, err);
}

// 3. Build, sign, and submit the endorse transaction on-chain
let txHash = "";
try {
const sourceAccount = await sorobanServer.getAccount(publicKey);
const contract = new Contract(contractId);
const operation = contract.call(
"endorse_certificate",
nativeToScVal(tokenId, { type: "u64" }),
nativeToScVal(publicKey, { type: "address" })
);

const tx = new TransactionBuilder(sourceAccount, {
fee: "500",
networkPassphrase: Networks.TESTNET,
})
.addOperation(operation)
.setTimeout(60)
.build();

tx.sign(keypair);

const submitResponse = await sorobanServer.sendTransaction(tx);
if (submitResponse.status === "ERROR") {
throw new Error(`On-chain endorsement failed: ${String(submitResponse.errorResult)}`);
}

txHash = submitResponse.hash;

// Poll for status
let attempts = 0;
let succeeded = false;
while (attempts < 10) {
await new Promise((r) => setTimeout(r, 2000));
const poll = await sorobanServer.getTransaction(txHash);
if (poll.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) {
succeeded = true;
break;
}
if (poll.status === SorobanRpc.Api.GetTransactionStatus.FAILED) {
throw new Error("Transaction failed on-chain.");
}
attempts++;
}

if (!succeeded) {
throw new Error("Transaction submission timed out.");
}
} catch (err: unknown) {
console.error("Blockchain endorsement failed:", err);
throw new Error(`Blockchain error: ${err instanceof Error ? err.message : String(err)}`);
}

// 4. Save the endorsement to the database
try {
const metadata = ENDORSER_METADATA[endorserType];
const { error: insertErr } = await supabase
.from("endorsements")
.insert({
cert_id: cert.id,
token_id: tokenId,
endorser_wallet: publicKey,
endorser_name: metadata.name,
tx_hash: txHash,
});

if (insertErr) {
console.error("Database insert error:", insertErr);
throw new Error(`Database error: ${insertErr.message}`);
}
} catch (dbErr: unknown) {
console.error("Supabase insert failed:", dbErr);
throw dbErr;
}

return { success: true, txHash };
}

export async function saveFreighterEndorsementAction(
tokenId: number,
endorserWallet: string,
txHash: string
) {
const supabase = createAdminClient();

const { data: cert, error: certErr } = await supabase
.from("certificates")
.select("id")
.eq("token_id", tokenId)
.single();

if (certErr || !cert) {
throw new Error("Certificate not found in database.");
}

const { error: insertErr } = await supabase
.from("endorsements")
.insert({
cert_id: cert.id,
token_id: tokenId,
endorser_wallet: endorserWallet,
endorser_name: "External Endorser",
tx_hash: txHash,
});

if (insertErr) {
console.error("Database insert error for Freighter endorsement:", insertErr);
throw new Error(`Database error: ${insertErr.message}`);
}

return { success: true };
}
19 changes: 19 additions & 0 deletions app/certificate/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { getIssuerReputation } from "@/lib/reputation";
import { getCertificateEndorsements } from "@/lib/endorsements";
import { EndorsementsPanel } from "@/components/endorsements-panel";

export const dynamic = 'force-dynamic';

Expand Down Expand Up @@ -29,6 +31,11 @@ export default async function CertificateDetailPage({ params }: { params: Promis
certificate.issuer_wallet,
certificate.tx_hash
);

const endorsements = await getCertificateEndorsements(
tokenId,
certificate.tx_hash
);

let emojiBadge = "📄";
if (certificate.cert_type === "HACKATHON") emojiBadge = "🏆";
Expand Down Expand Up @@ -144,6 +151,18 @@ export default async function CertificateDetailPage({ params }: { params: Promis
</div>
</div>

<div className="mt-10">
<div className="flex items-center gap-4 mb-6">
<span className="text-sm font-semibold text-[#866E65] uppercase tracking-[0.1em]">Endorsements</span>
<div className="h-px flex-1 bg-[#EFDED5]"></div>
</div>
<EndorsementsPanel
tokenId={tokenId}
initialEndorsements={endorsements}
issuerWallet={certificate.issuer_wallet}
/>
</div>

<div className="mt-10">
<div className="flex items-center gap-4 mb-6">
<span className="text-sm font-semibold text-[#866E65] uppercase tracking-[0.1em]">On-chain Details</span>
Expand Down
82 changes: 49 additions & 33 deletions app/verify/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createClient } from "@/lib/supabase/server";
import { getIssuerReputation } from "@/lib/reputation";
import { getCertificateEndorsements } from "@/lib/endorsements";
import { EndorsementsPanel } from "@/components/endorsements-panel";

export const dynamic = 'force-dynamic';

Expand Down Expand Up @@ -59,6 +61,12 @@ export default async function VerifyPage({
searchResult.tx_hash
);
searchResult.reputation_info = reputationInfo;

const endorsements = await getCertificateEndorsements(
searchResult.token_id,
searchResult.tx_hash
);
searchResult.endorsements = endorsements;
}
}

Expand Down Expand Up @@ -214,42 +222,50 @@ export default async function VerifyPage({

{/* Certificate Preview Column (Right Side) */}
{searchResult && (
<section className="rounded-[2rem] border border-[#EBD8CF] bg-white/90 p-7 shadow-[0_24px_52px_-34px_rgba(143,88,59,0.5)] backdrop-blur sm:p-10 sticky top-12">
<h2 className="text-sm font-semibold text-[#866E65] uppercase tracking-[0.1em] mb-6">Certificate Preview</h2>

<article className={`rounded-2xl border bg-gradient-to-br p-6 sm:p-8 shadow-sm ${certTypeThemes[searchResult.cert_type] || certTypeThemes["HACKATHON"]}`}>
<p className="text-3xl sm:text-4xl">
{certTypeIcons[searchResult.cert_type] || "📜"} {searchResult.cert_type}
</p>
<section className="rounded-[2rem] border border-[#EBD8CF] bg-white/90 p-7 shadow-[0_24px_52px_-34px_rgba(143,88,59,0.5)] backdrop-blur sm:p-10 sticky top-12 space-y-6">
<div>
<h2 className="text-sm font-semibold text-[#866E65] uppercase tracking-[0.1em] mb-6">Certificate Preview</h2>

<div className="mt-6">
<h3 className="font-[family-name:var(--font-display)] text-3xl sm:text-4xl text-[#201714] leading-tight">
{searchResult.title}
</h3>
{searchResult.description && (
<p className="mt-3 text-base sm:text-lg text-[#4F423E] leading-relaxed">
{searchResult.description}
</p>
)}
</div>

<div className="mt-10 space-y-2 text-sm text-[#4A3E3A] border-t border-black/5 pt-6">
<p className="font-medium text-[#201714]">
Issued: {new Date(searchResult.created_at).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
</p>
<p className="text-xs uppercase tracking-[0.12em] text-[#7E6A62]">
Type: {certTypeLabels[searchResult.cert_type] || searchResult.cert_type}
</p>
<p className="text-xs font-mono font-bold text-[#1A1211]">
Verification ID: #{searchResult.token_id}
<article className={`rounded-2xl border bg-gradient-to-br p-6 sm:p-8 shadow-sm ${certTypeThemes[searchResult.cert_type] || certTypeThemes["HACKATHON"]}`}>
<p className="text-3xl sm:text-4xl">
{certTypeIcons[searchResult.cert_type] || "📜"} {searchResult.cert_type}
</p>
{searchResult.is_revoked && (
<p className="mt-3 inline-block rounded-md bg-red-100 px-2 py-1 text-xs font-bold text-red-800">
REVOKED

<div className="mt-6">
<h3 className="font-[family-name:var(--font-display)] text-3xl sm:text-4xl text-[#201714] leading-tight">
{searchResult.title}
</h3>
{searchResult.description && (
<p className="mt-3 text-base sm:text-lg text-[#4F423E] leading-relaxed">
{searchResult.description}
</p>
)}
</div>

<div className="mt-10 space-y-2 text-sm text-[#4A3E3A] border-t border-black/5 pt-6">
<p className="font-medium text-[#201714]">
Issued: {new Date(searchResult.created_at).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
</p>
)}
</div>
</article>
<p className="text-xs uppercase tracking-[0.12em] text-[#7E6A62]">
Type: {certTypeLabels[searchResult.cert_type] || searchResult.cert_type}
</p>
<p className="text-xs font-mono font-bold text-[#1A1211]">
Verification ID: #{searchResult.token_id}
</p>
{searchResult.is_revoked && (
<p className="mt-3 inline-block rounded-md bg-red-100 px-2 py-1 text-xs font-bold text-red-800">
REVOKED
</p>
)}
</div>
</article>
</div>

<EndorsementsPanel
tokenId={searchResult.token_id}
initialEndorsements={searchResult.endorsements || []}
issuerWallet={searchResult.issuer_wallet}
/>
</section>
)}

Expand Down
Loading
Loading