diff --git a/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx b/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx index d9efea5..3aff6e9 100644 --- a/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx +++ b/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx @@ -45,7 +45,7 @@ function ScopedProposalHistory({ community }: { community: CommunityView }) { try { const client = createReadOnlyGovernorClient(governorContract); const transaction = await client.proposal_state({ - proposal_id: Uint8Array.from(Buffer.from(proposalId, "hex")), + proposal_id: Buffer.from(proposalId, "hex") as any, }); setStates((current) => ({ ...current, diff --git a/apps/web/src/components/community/CommunityMetadataPreview.tsx b/apps/web/src/components/community/CommunityMetadataPreview.tsx new file mode 100644 index 0000000..116bfe9 --- /dev/null +++ b/apps/web/src/components/community/CommunityMetadataPreview.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { type CommunityMetadataDraft, validateCommunityMetadataDraft } from "@/lib/community/schema"; +import { serializeCommunityMetadata, type SerializedMetadata } from "@/lib/community/metadata"; + +export function CommunityMetadataPreview({ draft }: { draft: CommunityMetadataDraft }) { + const [serialized, setSerialized] = useState(null); + + useEffect(() => { + let active = true; + serializeCommunityMetadata(draft).then((res) => { + if (active) setSerialized(res); + }); + return () => { + active = false; + }; + }, [draft]); + + if (!serialized) { + return
Building metadata...
; + } + + const errors = validateCommunityMetadataDraft(draft); + const isValid = Object.keys(errors).length === 0; + + function handleDownload() { + if (!isValid || !serialized) return; + const blob = new Blob([serialized.json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "community-metadata.json"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + return ( +
+
+
{serialized.json}
+
+
+
+ SHA-256 Hash: + {serialized.hash} +
+ +
+ {!isValid && ( +

+ Fix metadata errors before downloading. +

+ )} +
+ ); +} diff --git a/apps/web/src/components/community/CreateCommunityWizard.tsx b/apps/web/src/components/community/CreateCommunityWizard.tsx index 3a5e565..e2b8158 100644 --- a/apps/web/src/components/community/CreateCommunityWizard.tsx +++ b/apps/web/src/components/community/CreateCommunityWizard.tsx @@ -13,6 +13,7 @@ import { import { useWallet } from "@/context/WalletProvider"; import { useNetworkGuard } from "@/hooks/useNetworkGuard"; import { NetworkMismatchNotice } from "@/components/NetworkMismatchNotice"; +import { CommunityMetadataPreview } from "./CommunityMetadataPreview"; import { CREATION_STEPS, CREATION_DRAFT_STORAGE_KEY, @@ -73,6 +74,11 @@ const STEP_LABELS: Record = { const METADATA_FIELDS = [ { key: "name", label: "Community name", placeholder: "Stolla Builders" }, { key: "symbol", label: "Token symbol", placeholder: "STBL" }, + { key: "description", label: "Description", placeholder: "A community for builders" }, + { key: "collectionUri", label: "Collection URI", placeholder: "ipfs://Qm..." }, + { key: "logo", label: "Logo URI", placeholder: "ipfs://Qm..." }, + { key: "externalLinkLabel", label: "Link Label", placeholder: "Website" }, + { key: "externalLinkUrl", label: "Link URL", placeholder: "https://stolla.org" }, { key: "metadataUri", label: "IPFS metadata URI", placeholder: "ipfs://Qm..." }, ] as const; @@ -358,7 +364,17 @@ export function CreateCommunityWizard({ ))} - + +
+

+ Community Metadata +

+ +
+ +
+ +
)} diff --git a/apps/web/src/hooks/useNetworkGuard.ts b/apps/web/src/hooks/useNetworkGuard.ts index cc54cb9..6cf1ee4 100644 --- a/apps/web/src/hooks/useNetworkGuard.ts +++ b/apps/web/src/hooks/useNetworkGuard.ts @@ -2,18 +2,15 @@ import { useMemo } from "react"; import { useWallet } from "@/context/WalletProvider"; -import { compareNetworks, type NetworkComparison } from "@/lib/network"; +import { compareNetworks, describeNetwork, type NetworkComparison } from "@/lib/network"; import { activeNetwork } from "@/lib/stellar"; -/** - * Reconciles the network the wallet reports with the one the application is - * configured for. Every gate in the app reads this rather than the wallet - * network directly, so mismatch handling stays in one place. - */ export function useNetworkGuard(): NetworkComparison { - const { walletNetwork } = useWallet(); - return useMemo( - () => compareNetworks(activeNetwork, walletNetwork), - [walletNetwork], - ); + const { walletNetworkPassphrase, walletNetwork } = useWallet(); + return useMemo(() => { + const detected = walletNetworkPassphrase + ? describeNetwork(walletNetworkPassphrase, walletNetwork ?? undefined) + : null; + return compareNetworks(activeNetwork, detected); + }, [walletNetworkPassphrase, walletNetwork]); } diff --git a/apps/web/src/lib/community-creation.test.ts b/apps/web/src/lib/community-creation.test.ts index e92e25b..df3544c 100644 --- a/apps/web/src/lib/community-creation.test.ts +++ b/apps/web/src/lib/community-creation.test.ts @@ -17,7 +17,12 @@ import { NETWORKS, compareNetworks, describeNetwork } from "./network"; const DRAFT: CommunityDraft = { name: "Stolla Builders", symbol: "STBL", + description: "A community for builders", + collectionUri: "ipfs://QmCollection", metadataUri: "ipfs://QmCollection", + logo: "", + externalLinkLabel: "", + externalLinkUrl: "", votingDelay: "1", votingPeriod: "10000", proposalThreshold: "1", diff --git a/apps/web/src/lib/community-creation.ts b/apps/web/src/lib/community-creation.ts index b68ac08..b110758 100644 --- a/apps/web/src/lib/community-creation.ts +++ b/apps/web/src/lib/community-creation.ts @@ -12,7 +12,12 @@ export type CreationStep = (typeof CREATION_STEPS)[number]; export type CommunityDraft = { name: string; symbol: string; + description: string; + collectionUri: string; metadataUri: string; + logo: string; + externalLinkLabel: string; + externalLinkUrl: string; votingDelay: string; votingPeriod: string; proposalThreshold: string; @@ -67,7 +72,12 @@ export type CreationState = { export const DEFAULT_DRAFT: CommunityDraft = { name: "", symbol: "", + description: "", + collectionUri: "", metadataUri: "", + logo: "", + externalLinkLabel: "", + externalLinkUrl: "", votingDelay: "1", votingPeriod: "10000", proposalThreshold: "1", diff --git a/apps/web/src/lib/community-factory.test.ts b/apps/web/src/lib/community-factory.test.ts index 517aee4..8b7fe33 100644 --- a/apps/web/src/lib/community-factory.test.ts +++ b/apps/web/src/lib/community-factory.test.ts @@ -11,7 +11,12 @@ import type { CommunitySimulation, CommunityDraft } from "./community-creation"; const DRAFT: CommunityDraft = { name: "Stolla Builders", symbol: "STBL", + description: "A community for builders", + collectionUri: "ipfs://QmCollection", metadataUri: "ipfs://QmCollection", + logo: "", + externalLinkLabel: "", + externalLinkUrl: "", votingDelay: "1", votingPeriod: "10000", proposalThreshold: "1", diff --git a/apps/web/src/lib/community/metadata.test.ts b/apps/web/src/lib/community/metadata.test.ts new file mode 100644 index 0000000..4b9a00d --- /dev/null +++ b/apps/web/src/lib/community/metadata.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { serializeCommunityMetadata } from "./metadata"; +import type { CommunityMetadataDraft } from "./schema"; + +const baseDraft: CommunityMetadataDraft = { + name: " My Community ", + symbol: "MC", + description: " This is a \n test. ", + collectionUri: "ipfs://collection", + metadataUri: "ipfs://metadata", + logo: " https://logo.png ", + externalLinkLabel: " Website ", + externalLinkUrl: " https://example.com ", +}; + +describe("serializeCommunityMetadata", () => { + it("normalizes inputs and maintains fixed ordering", async () => { + const res1 = await serializeCommunityMetadata(baseDraft); + const res2 = await serializeCommunityMetadata({ + ...baseDraft, + name: "My Community", // Already trimmed + description: "This is a \n test.", + logo: "https://logo.png", + externalLinkLabel: "Website", + externalLinkUrl: "https://example.com", + }); + + expect(res1.json).toBe(res2.json); + expect(res1.hash).toBe(res2.hash); + expect(res1.json).toContain('"name": "My Community"'); + expect(res1.json).toContain('"logo": "https://logo.png"'); + expect(res1.json).toContain('"label": "Website"'); + }); + + it("handles Unicode correctly", async () => { + const draft = { + ...baseDraft, + name: "🚀 Emoji 🚀", + description: "Tésting Ünicode", + }; + const res = await serializeCommunityMetadata(draft); + expect(res.json).toContain("🚀 Emoji 🚀"); + expect(res.json).toContain("Tésting Ünicode"); + // Verify hash stability for specific known input + expect(res.hash).toHaveLength(64); + }); + + it("handles empty optional fields", async () => { + const draft = { + ...baseDraft, + logo: " ", + externalLinkLabel: " ", + externalLinkUrl: "", + }; + const res = await serializeCommunityMetadata(draft); + expect(res.json).not.toContain("logo"); + expect(res.json).toContain('"externalLinks": []'); + }); + + it("escapes characters as standard JSON", async () => { + const draft = { + ...baseDraft, + description: 'Quote " test \\ slash', + }; + const res = await serializeCommunityMetadata(draft); + expect(res.json).toContain('Quote \\" test \\\\ slash'); + }); +}); diff --git a/apps/web/src/lib/community/metadata.ts b/apps/web/src/lib/community/metadata.ts new file mode 100644 index 0000000..0eef655 --- /dev/null +++ b/apps/web/src/lib/community/metadata.ts @@ -0,0 +1,48 @@ +import { COMMUNITY_SCHEMA_VERSION, type CommunityMetadataDraft } from "./schema"; + +export type SerializedMetadata = { + bytes: Uint8Array; + json: string; + hash: string; +}; + +export async function serializeCommunityMetadata(draft: CommunityMetadataDraft): Promise { + const metadata = { + schemaVersion: COMMUNITY_SCHEMA_VERSION, + name: draft.name.trim(), + description: draft.description.trim(), + } as Record; + + if (draft.logo.trim()) { + metadata.logo = draft.logo.trim(); + } + + const linkLabel = draft.externalLinkLabel.trim(); + const linkUrl = draft.externalLinkUrl.trim(); + + if (linkLabel && linkUrl) { + metadata.externalLinks = [{ label: linkLabel, url: linkUrl }]; + } else { + metadata.externalLinks = []; + } + + // To ensure the same normalized inputs always produce identical JSON bytes, + // we must insert the keys in a consistent order. + const orderedMetadata = { + schemaVersion: metadata.schemaVersion, + name: metadata.name, + description: metadata.description, + ...(metadata.logo ? { logo: metadata.logo } : {}), + externalLinks: metadata.externalLinks, + }; + + const json = JSON.stringify(orderedMetadata, null, 2); + const encoder = new TextEncoder(); + const bytes = encoder.encode(json); + + const hashBuffer = await crypto.subtle.digest("SHA-256", bytes); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hash = hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); + + return { bytes, json, hash }; +} diff --git a/apps/web/src/lib/communityFactory/useCommunityDeployment.ts b/apps/web/src/lib/communityFactory/useCommunityDeployment.ts index 5e9772d..344beb4 100644 --- a/apps/web/src/lib/communityFactory/useCommunityDeployment.ts +++ b/apps/web/src/lib/communityFactory/useCommunityDeployment.ts @@ -27,7 +27,7 @@ const stageLabels: Record = { }; export function useCommunityDeployment() { - const { address, networkPassphrase, signTransaction } = useWallet(); + const { address, walletNetworkPassphrase, signTransaction } = useWallet(); const activeSubmissionRef = useRef(false); const [stage, setStage] = useState("idle"); const [error, setError] = useState(null); @@ -55,7 +55,7 @@ export function useCommunityDeployment() { const nextOutcome = await deployCommunityFromWizard(state, { address, expectedNetworkPassphrase: config.networkPassphrase, - walletNetworkPassphrase: networkPassphrase, + walletNetworkPassphrase: walletNetworkPassphrase, createClient: () => createCommunityFactoryClient({ publicKey: address ?? "", @@ -78,7 +78,7 @@ export function useCommunityDeployment() { activeSubmissionRef.current = false; } }, - [address, isSubmitting, networkPassphrase, signTransaction], + [address, isSubmitting, walletNetworkPassphrase, signTransaction], ); return { diff --git a/apps/web/src/lib/contracts.ts b/apps/web/src/lib/contracts.ts index 0814ee1..0b3ba71 100644 --- a/apps/web/src/lib/contracts.ts +++ b/apps/web/src/lib/contracts.ts @@ -42,7 +42,7 @@ export function createNftClient({ const nft = contractId ?? requireContractIds().nft; return new NftClient({ contractId: nft, - networkPassphrase: config.passphrase, + networkPassphrase: config.networkPassphrase, rpcUrl: config.rpcUrl, publicKey, signTransaction, @@ -59,7 +59,7 @@ export function createGovernorClient({ if (mocked) return mocked as unknown as GovernorClient; return new GovernorClient({ contractId: governor, - networkPassphrase: config.passphrase, + networkPassphrase: config.networkPassphrase, rpcUrl: config.rpcUrl, publicKey, signTransaction, @@ -70,7 +70,7 @@ export function createReadOnlyNftClient(contractId?: string) { const nft = contractId ?? requireContractIds().nft; return new NftClient({ contractId: nft, - networkPassphrase: config.passphrase, + networkPassphrase: config.networkPassphrase, rpcUrl: config.rpcUrl, }); } @@ -81,7 +81,7 @@ export function createReadOnlyGovernorClient(contractId?: string) { if (mocked) return mocked as unknown as GovernorClient; return new GovernorClient({ contractId: governor, - networkPassphrase: config.passphrase, + networkPassphrase: config.networkPassphrase, rpcUrl: config.rpcUrl, }); }