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
67 changes: 62 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ frontend/.next
frontend/node_modules
issue.md
pr.md
*.stackdump
*.stackdump*.tsbuildinfo
32 changes: 32 additions & 0 deletions contracts/wasm-manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
6 changes: 5 additions & 1 deletion frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.
27 changes: 27 additions & 0 deletions frontend/app/api/wasm-manifest/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
16 changes: 12 additions & 4 deletions frontend/app/components/RecentLaunches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -103,10 +104,17 @@ export function RecentLaunches() {
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-stellar-500/10 text-sm font-bold text-stellar-400">
{token.symbol.slice(0, 2)}
</div>
<div className="min-w-0">
<h3 className="truncate font-semibold text-white">
{token.name}
</h3>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-semibold text-white">
{token.name}
</h3>
<ContractVerificationBadge
contractId={token.contractId}
networkConfig={networkConfig}
compact
/>
</div>
<p className="text-xs text-gray-400">{token.symbol}</p>
</div>
</div>
Expand Down
19 changes: 19 additions & 0 deletions frontend/app/token/[contractId]/PublicTokenPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Loader2,
Share2,
ExternalLink,
Lock,
} from "lucide-react";
import {
truncateAddress,
Expand All @@ -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";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -455,6 +457,23 @@ export default function PublicTokenPage({

<TokenStatusBanner tokenInfo={tokenInfo} walletState={walletState} />

{/* Contract verification */}
<section aria-label="Contract verification" className="mb-6">
<div className="flex flex-wrap items-center gap-3">
<ContractVerificationBadge
contractId={contractId}
networkConfig={networkConfig}
isLocked={tokenInfo.isLocked}
/>
{tokenInfo.isLocked && (
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/10 px-2.5 py-0.5 text-xs font-medium text-green-400">
<Lock className="h-3 w-3" />
Immutable
</span>
)}
</div>
</section>

{/* Token info grid */}
<section aria-label="Token details" className="mb-10"> <h2 className="mb-4 text-sm font-medium uppercase tracking-wider text-gray-500">
{t("tokenDetails")}
Expand Down
Loading