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"); }