diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 50038986..6725028b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -58,13 +58,67 @@ jobs:
- name: Build optimized WASM
run: soroban contract build
- - name: Report WASM Size
+ - name: Compute WASM hashes & validate manifest
+ run: |
+ MANIFEST="contracts/wasm-manifest.json"
+ WASM_DIR="target/wasm32-unknown-unknown/release"
+ PASS=true
+
+ compute_hash() {
+ sha256sum "$1" | cut -d' ' -f1
+ }
+
+ # Build a lookup of expected hashes from manifest
+ echo "::group::WASM Hash Verification"
+ for wasm in "$WASM_DIR"/*.wasm; do
+ name=$(basename "$wasm" .wasm)
+ # Crate names in manifest: soroban_token -> token, soroban_vesting -> vesting
+ crate_name="${name//_token/}"
+ crate_name="${crate_name//_vesting/}"
+ # Map soroban-token -> token, soroban-vesting -> vesting
+ manifest_key=""
+ case "$name" in
+ *token*) manifest_key="token" ;;
+ *vesting*) manifest_key="vesting" ;;
+ *) manifest_key="$name" ;;
+ esac
+
+ actual_hash=$(compute_hash "$wasm")
+ echo "WASM: $name ($manifest_key)"
+ echo " Hash: $actual_hash"
+
+ # Get expected hash from manifest
+ expected_hash=$(python3 -c "import json; m=json.load(open('$MANIFEST')); v=m.get('$manifest_key',{}).get('versions',{}); latest=v.get(m.get('$manifest_key',{}).get('latest',''),{}); print(latest.get('wasm_hash',''))" 2>/dev/null || echo "")
+
+ if [ -n "$expected_hash" ]; then
+ echo " Expected: $expected_hash"
+ if [ "$actual_hash" = "$expected_hash" ]; then
+ echo " ✅ Match"
+ else
+ echo " ❌ Mismatch"
+ PASS=false
+ fi
+ else
+ echo " ⚠️ No expected hash in manifest — add one manually"
+ fi
+ done
+ echo "::endgroup::"
+
+ if [ "$PASS" = "false" ]; then
+ echo "WASM hashes do not match manifest. Did the contract code change?"
+ echo "Run: sha256sum target/wasm32-unknown-unknown/release/*.wasm"
+ echo "Then update contracts/wasm-manifest.json with the new hashes."
+ exit 1
+ fi
+
+ - name: Report WASM Size & Hashes
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
+ const crypto = require('crypto');
const wasmDir = 'target/wasm32-unknown-unknown/release';
if (!fs.existsSync(wasmDir)) {
@@ -75,11 +129,14 @@ jobs:
const files = fs.readdirSync(wasmDir);
const wasmFiles = files.filter(f => f.endsWith('.wasm'));
- let message = '### 📦 WASM Size Report\n\n| Contract | Size |\n| :--- | :--- |\n';
+ let message = '### 📦 WASM Report\n\n| Contract | Size | SHA256 Hash |\n| :--- | ---: | :--- |\n';
for (const file of wasmFiles) {
- const stats = fs.statSync(path.join(wasmDir, file));
+ const filePath = path.join(wasmDir, file);
+ const stats = fs.statSync(filePath);
const size = (stats.size / 1024).toFixed(2);
- message += `| \`${file}\` | ${size} KB |\n`;
+ const buf = fs.readFileSync(filePath);
+ const hash = crypto.createHash('sha256').update(buf).digest('hex');
+ message += `| \`${file}\` | ${size} KB | \`${hash}\` |\n`;
}
// Post result as PR comment
@@ -90,7 +147,7 @@ jobs:
});
const botComment = comments.find(comment =>
- comment.user.type === 'Bot' && comment.body.includes('WASM Size Report')
+ comment.user.type === 'Bot' && comment.body.includes('WASM Report')
);
if (botComment) {
diff --git a/.gitignore b/.gitignore
index 30380926..47ab5d74 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,4 @@ frontend/.next
frontend/node_modules
issue.md
pr.md
-*.stackdump
+*.stackdump*.tsbuildinfo
diff --git a/contracts/wasm-manifest.json b/contracts/wasm-manifest.json
new file mode 100644
index 00000000..c4a5504c
--- /dev/null
+++ b/contracts/wasm-manifest.json
@@ -0,0 +1,32 @@
+{
+ "token": {
+ "latest": "v1.0.0",
+ "versions": {
+ "v1.0.0": {
+ "wasm_hash": "",
+ "source_tag": "v1.0.0",
+ "build_ledger": "soroban-sdk-21.0.0"
+ }
+ },
+ "build_info": {
+ "sdk_version": "21.0.0",
+ "rust_version": "stable",
+ "profile": "release"
+ }
+ },
+ "vesting": {
+ "latest": "v1.0.0",
+ "versions": {
+ "v1.0.0": {
+ "wasm_hash": "",
+ "source_tag": "v1.0.0",
+ "build_ledger": "soroban-sdk-21.0.0"
+ }
+ },
+ "build_info": {
+ "sdk_version": "21.0.0",
+ "rust_version": "stable",
+ "profile": "release"
+ }
+ }
+}
diff --git a/frontend/.env.example b/frontend/.env.example
index c2532b1c..c383974b 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -5,4 +5,8 @@ NEXT_PUBLIC_NETWORK_PASSPHRASE=Public Global Stellar Network ; September 2015
NEXT_PUBLIC_TESTNET_HORIZON_URL=https://horizon-testnet.stellar.org
NEXT_PUBLIC_TESTNET_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
-NEXT_PUBLIC_TESTNET_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
\ No newline at end of file
+NEXT_PUBLIC_TESTNET_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
+
+# Reference WASM hashes are read from contracts/wasm-manifest.json served via
+# the /api/wasm-manifest endpoint. No env var needed — the manifest is checked
+# in and automatically served to the frontend.
\ No newline at end of file
diff --git a/frontend/app/api/wasm-manifest/route.ts b/frontend/app/api/wasm-manifest/route.ts
new file mode 100644
index 00000000..eb57155d
--- /dev/null
+++ b/frontend/app/api/wasm-manifest/route.ts
@@ -0,0 +1,27 @@
+import { NextResponse } from "next/server";
+import fs from "fs";
+import path from "path";
+
+const CACHE_TTL_MS = 300_000;
+let cachedData: string | null = null;
+let cacheExpiresAt = 0;
+
+export async function GET() {
+ if (cachedData && cacheExpiresAt > Date.now()) {
+ return new NextResponse(cachedData, {
+ headers: { "Content-Type": "application/json", "Cache-Control": "public, max-age=300" },
+ });
+ }
+
+ try {
+ const manifestPath = path.join(process.cwd(), "..", "contracts", "wasm-manifest.json");
+ const data = fs.readFileSync(manifestPath, "utf-8");
+ cachedData = data;
+ cacheExpiresAt = Date.now() + CACHE_TTL_MS;
+ return new NextResponse(data, {
+ headers: { "Content-Type": "application/json", "Cache-Control": "public, max-age=300" },
+ });
+ } catch {
+ return NextResponse.json({ error: "WASM manifest not found" }, { status: 404 });
+ }
+}
diff --git a/frontend/app/components/RecentLaunches.tsx b/frontend/app/components/RecentLaunches.tsx
index 0e0cde2a..6bd3edfc 100644
--- a/frontend/app/components/RecentLaunches.tsx
+++ b/frontend/app/components/RecentLaunches.tsx
@@ -7,6 +7,7 @@ import { Loader2, TrendingUp, ArrowRight } from "lucide-react";
import Link from "next/link";
import type { RecentToken } from "@/lib/recentTokens";
import { truncateAddress } from "@/lib/stellar";
+import { ContractVerificationBadge } from "@/components/ui/ContractVerificationBadge";
import { EmptyState } from "@/components/ui/EmptyState";
function timeAgo(iso: string): string {
@@ -103,10 +104,17 @@ export function RecentLaunches() {
{token.symbol.slice(0, 2)}
-
-
- {token.name}
-
+
+
+
+ {token.name}
+
+
+
{token.symbol}
diff --git a/frontend/app/token/[contractId]/PublicTokenPage.tsx b/frontend/app/token/[contractId]/PublicTokenPage.tsx
index 4b176122..f1984bdc 100644
--- a/frontend/app/token/[contractId]/PublicTokenPage.tsx
+++ b/frontend/app/token/[contractId]/PublicTokenPage.tsx
@@ -10,6 +10,7 @@ import {
Loader2,
Share2,
ExternalLink,
+ Lock,
} from "lucide-react";
import {
truncateAddress,
@@ -23,6 +24,7 @@ import { useNetwork } from "@/app/providers/NetworkProvider";
import { useWallet } from "@/app/hooks/useWallet";
import { useToast } from "@/app/providers/ToastProvider";
import { TokenStatusBanner } from "@/components/TokenStatusBanner";
+import { ContractVerificationBadge } from "@/components/ui/ContractVerificationBadge";
import InvalidTokenContract from "../../components/InvalidTokenContract";
// ---------------------------------------------------------------------------
@@ -455,6 +457,23 @@ export default function PublicTokenPage({
+ {/* Contract verification */}
+
+
+
+ {tokenInfo.isLocked && (
+
+
+ Immutable
+
+ )}
+
+
+
{/* Token info grid */}
{t("tokenDetails")}
diff --git a/frontend/components/ui/ContractVerificationBadge.tsx b/frontend/components/ui/ContractVerificationBadge.tsx
new file mode 100644
index 00000000..c4bab766
--- /dev/null
+++ b/frontend/components/ui/ContractVerificationBadge.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import { useEffect, useState, useRef } from "react";
+import { ShieldCheck, ShieldAlert, ShieldQuestion, Lock, Loader2 } from "lucide-react";
+import type { NetworkConfig } from "@/types/network";
+import { getContractWasmHash, fetchWasmManifest } from "@/lib/stellar";
+
+type VerificationStatus = "loading" | "verified" | "modified" | "unknown" | "unchecked";
+
+interface ContractVerificationBadgeProps {
+ contractId: string;
+ networkConfig: NetworkConfig;
+ isLocked?: boolean;
+ compact?: boolean;
+}
+
+function shortenHash(hash: string): string {
+ return `${hash.slice(0, 8)}...${hash.slice(-6)}`;
+}
+
+export function ContractVerificationBadge({
+ contractId,
+ networkConfig,
+ isLocked,
+ compact,
+}: ContractVerificationBadgeProps) {
+ const [status, setStatus] = useState("loading");
+ const [deployedHash, setDeployedHash] = useState(null);
+ const [referenceHash, setReferenceHash] = useState(null);
+ const [referenceVersion, setReferenceVersion] = useState(null);
+ const mountedRef = useRef(true);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ let cancelled = false;
+
+ async function verify() {
+ setStatus("loading");
+ try {
+ const [deployed, mfst] = await Promise.all([
+ getContractWasmHash(contractId, networkConfig),
+ fetchWasmManifest(),
+ ]);
+
+ if (cancelled) return;
+
+ if (!deployed) {
+ if (mountedRef.current) setStatus("unknown");
+ return;
+ }
+
+ if (mountedRef.current) setDeployedHash(deployed);
+
+ if (!mfst) {
+ if (mountedRef.current) setStatus("unknown");
+ return;
+ }
+
+ const tokenEntry = mfst.token;
+ const latestVersion = tokenEntry?.latest;
+ const versionData = latestVersion ? tokenEntry.versions[latestVersion] : null;
+
+ if (!versionData || !versionData.wasm_hash) {
+ if (mountedRef.current) setStatus("unknown");
+ return;
+ }
+
+ if (mountedRef.current) {
+ setReferenceHash(versionData.wasm_hash);
+ setReferenceVersion(latestVersion);
+ }
+
+ if (deployed === versionData.wasm_hash) {
+ if (mountedRef.current) setStatus("verified");
+ } else {
+ if (mountedRef.current) setStatus("modified");
+ }
+ } catch {
+ if (!cancelled && mountedRef.current) setStatus("unknown");
+ }
+ }
+
+ verify();
+
+ return () => {
+ cancelled = true;
+ mountedRef.current = false;
+ };
+ }, [contractId, networkConfig]);
+
+ if (status === "loading") {
+ if (compact) return null;
+ return (
+
+
+ Verifying...
+
+ );
+ }
+
+ if (status === "unknown") {
+ if (compact) return null;
+ return (
+
+
+ Unverified
+
+ );
+ }
+
+ if (status === "verified") {
+ return (
+
+
+ {compact ? "Verified" : `Verified (v${referenceVersion})`}
+ {isLocked && (
+
+ )}
+
+ );
+ }
+
+ if (status === "modified" && !compact) {
+ return (
+
+
+
+ Modified
+
+
+
Modified Contract
+
+ This contract's WASM hash does not match the reference build. It
+ may have been upgraded or deployed from different source code.
+
+
+ {deployedHash && (
+
Deployed: {shortenHash(deployedHash)}
+ )}
+ {referenceHash && (
+
Reference: {shortenHash(referenceHash)}
+ )}
+
+
+
+ );
+ }
+
+ if (status === "modified" && compact) {
+ return (
+
+
+ Modified
+
+ );
+ }
+
+ return null;
+}
diff --git a/frontend/hooks/useSoroban.ts b/frontend/hooks/useSoroban.ts
index 1ce71acc..b43f6bf6 100644
--- a/frontend/hooks/useSoroban.ts
+++ b/frontend/hooks/useSoroban.ts
@@ -95,6 +95,11 @@ export function useSoroban() {
(signedXdr: string) => stellar.submitTransaction(signedXdr, networkConfig),
[networkConfig],
);
+
+ const getContractWasmHash = useCallback(
+ (contractId: string) => stellar.getContractWasmHash(contractId, networkConfig),
+ [networkConfig],
+ );
return useMemo(
() => ({
fetchTokenInfo,
@@ -111,6 +116,7 @@ export function useSoroban() {
fetchAccountOperations,
buildBurnTransaction,
submitTransaction,
+ getContractWasmHash,
networkConfig,
// Pass through formatting helpers which don't need config
formatTokenAmount: stellar.formatTokenAmount,
@@ -131,6 +137,7 @@ export function useSoroban() {
fetchAccountOperations,
buildBurnTransaction,
submitTransaction,
+ getContractWasmHash,
networkConfig,
],
);
diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts
index 6fa82200..ab918d45 100644
--- a/frontend/lib/stellar.ts
+++ b/frontend/lib/stellar.ts
@@ -19,6 +19,43 @@ function getHorizonUrl(): string {
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
+export interface VerificationResult {
+ status: "verified" | "modified" | "unknown";
+ deployedHash: string | null;
+ referenceHash: string | null;
+ referenceVersion: string | null;
+ isLocked: boolean;
+}
+
+export interface WasmManifestEntry {
+ wasm_hash: string;
+ source_tag: string;
+ build_ledger: string;
+}
+
+export interface WasmManifest {
+ token: {
+ latest: string;
+ versions: Record;
+ build_info: { sdk_version: string; rust_version: string; profile: string };
+ };
+ vesting: {
+ latest: string;
+ versions: Record;
+ build_info: { sdk_version: string; rust_version: string; profile: string };
+ };
+}
+
+export async function fetchWasmManifest(): Promise {
+ try {
+ const res = await fetch("/api/wasm-manifest");
+ if (!res.ok) return null;
+ return (await res.json()) as WasmManifest;
+ } catch {
+ return null;
+ }
+}
+
export interface TokenInfo {
name: string;
symbol: string;
@@ -140,6 +177,38 @@ export async function simulateCall(
);
}
+/**
+ * Read the current WASM hash of a deployed Soroban contract.
+ *
+ * Uses the RPC getLedgerEntries endpoint to fetch the contract instance
+ * entry, which contains the executable WASM hash. Returns the hash as a
+ * lowercase hex string, or null when the entry cannot be read.
+ */
+export async function getContractWasmHash(
+ contractId: string,
+ config: NetworkConfig,
+): Promise {
+ try {
+ const rpc = new StellarSdk.rpc.Server(config.rpcUrl);
+ const instanceKey = StellarSdk.xdr.ScVal.scvLedgerKeyContractInstance();
+ const entry = await rpc.getContractData(contractId, instanceKey);
+ const contractData = (entry as { val: StellarSdk.xdr.LedgerEntryData }).val.contractData();
+ const scVal = contractData.val();
+ if (scVal.switch() !== StellarSdk.xdr.ScValType.scvContractInstance()) {
+ return null;
+ }
+ const instance = scVal.instance();
+ const executable = instance.executable();
+ if (executable.switch() !== StellarSdk.xdr.ContractExecutableType.contractExecutableWasm()) {
+ return null;
+ }
+ const wasmHashBuf = executable.wasmHash() as Buffer;
+ return Buffer.from(wasmHashBuf).toString("hex");
+ } catch {
+ return null;
+ }
+}
+
function encodeTopicSymbol(symbol: string): string {
return StellarSdk.nativeToScVal(symbol, { type: "symbol" }).toXDR("base64");
}