Skip to content

Commit e733f0f

Browse files
committed
Refactor governance proposal metadata handling
- Moved metadata fetching and normalization logic to a dedicated module. - Introduced `fetchProposalMetadataWithFallback` to streamline metadata retrieval with fallback options. - Simplified proposal metadata normalization and fallback creation. - Added tests for proposal metadata helpers to ensure functionality and reliability. - Removed redundant code and improved overall code organization for better maintainability.
1 parent b2eda17 commit e733f0f

5 files changed

Lines changed: 585 additions & 615 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { afterEach, describe, expect, it, jest } from "@jest/globals";
2+
import {
3+
createProposalMetadataFallback,
4+
fetchProposalMetadataWithFallback,
5+
getAnchorUrls,
6+
normalizeProposalMetadata,
7+
} from "@/lib/governance/proposalMetadata";
8+
9+
const proposal = {
10+
tx_hash: "tx-proposal",
11+
cert_index: 0,
12+
governance_type: "info_action",
13+
};
14+
15+
afterEach(() => {
16+
jest.restoreAllMocks();
17+
});
18+
19+
describe("proposal metadata helpers", () => {
20+
it("normalizes usable Blockfrost metadata without extra fetching", async () => {
21+
const provider = {
22+
get: jest.fn(async () => ({
23+
tx_hash: "tx-proposal",
24+
cert_index: 0,
25+
hash: "hash",
26+
url: "https://example.com/metadata.json",
27+
bytes: "123",
28+
json_metadata: {
29+
body: {
30+
title: "Proposal title",
31+
abstract: "Proposal abstract",
32+
motivation: "Motivation",
33+
rationale: "Rationale",
34+
references: [{ "@type": "Other", label: "Spec", uri: "https://example.com" }],
35+
},
36+
authors: [{ name: "Ada" }],
37+
},
38+
})),
39+
};
40+
41+
const metadata = await fetchProposalMetadataWithFallback({ provider, proposal });
42+
43+
expect(provider.get).toHaveBeenCalledTimes(1);
44+
expect(metadata).toMatchObject({
45+
tx_hash: "tx-proposal",
46+
cert_index: 0,
47+
governance_type: "info_action",
48+
hash: "hash",
49+
json_metadata: {
50+
body: {
51+
title: "Proposal title",
52+
abstract: "Proposal abstract",
53+
motivation: "Motivation",
54+
rationale: "Rationale",
55+
},
56+
authors: [{ name: "Ada" }],
57+
},
58+
});
59+
});
60+
61+
it("hydrates metadata from a regular anchor URL", async () => {
62+
const provider = {
63+
get: jest.fn(async () => ({
64+
tx_hash: "tx-proposal",
65+
cert_index: 0,
66+
url: "https://example.com/anchor.json",
67+
})),
68+
};
69+
const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValue(
70+
new Response(
71+
JSON.stringify({
72+
body: {
73+
title: "Anchor title",
74+
abstract: "Anchor abstract",
75+
},
76+
authors: [{ name: "Anchor author" }],
77+
}),
78+
{ status: 200 },
79+
),
80+
);
81+
82+
const metadata = await fetchProposalMetadataWithFallback({ provider, proposal });
83+
84+
expect(fetchSpy).toHaveBeenCalledWith(
85+
"https://example.com/anchor.json",
86+
expect.objectContaining({ method: "GET" }),
87+
);
88+
expect(metadata?.json_metadata.body.title).toBe("Anchor title");
89+
expect(metadata?.json_metadata.authors).toEqual([{ name: "Anchor author" }]);
90+
});
91+
92+
it("tries IPFS gateway fallbacks until one returns usable JSON", async () => {
93+
expect(getAnchorUrls("ipfs://cid/path.json")).toEqual([
94+
"https://ipfs.io/ipfs/cid/path.json",
95+
"https://cloudflare-ipfs.com/ipfs/cid/path.json",
96+
"https://dweb.link/ipfs/cid/path.json",
97+
]);
98+
99+
const provider = {
100+
get: jest.fn(async () => ({
101+
tx_hash: "tx-proposal",
102+
cert_index: 0,
103+
url: "ipfs://cid/path.json",
104+
})),
105+
};
106+
const fetchSpy = jest
107+
.spyOn(globalThis, "fetch")
108+
.mockResolvedValueOnce(new Response("not found", { status: 504 }))
109+
.mockResolvedValueOnce(
110+
new Response(JSON.stringify({ title: "Wrapped anchor title" }), {
111+
status: 200,
112+
}),
113+
);
114+
115+
const metadata = await fetchProposalMetadataWithFallback({ provider, proposal });
116+
117+
expect(fetchSpy).toHaveBeenCalledTimes(2);
118+
expect(metadata?.json_metadata.body.title).toBe("Wrapped anchor title");
119+
});
120+
121+
it("returns fallback metadata without changing the ProposalMetadata shape", () => {
122+
const metadata = createProposalMetadataFallback(proposal);
123+
124+
expect(metadata).toEqual({
125+
tx_hash: "tx-proposal",
126+
cert_index: 0,
127+
governance_type: "info_action",
128+
hash: "",
129+
url: "",
130+
bytes: "",
131+
json_metadata: {
132+
body: {
133+
title: "Metadata could not be loaded.",
134+
abstract: "tx-proposal#0",
135+
motivation: "",
136+
rationale: "",
137+
references: [],
138+
},
139+
authors: [],
140+
},
141+
});
142+
});
143+
144+
it("normalizes missing or unusable metadata fields into safe defaults", () => {
145+
const metadata = normalizeProposalMetadata({ json_metadata: { body: null } }, proposal);
146+
147+
expect(metadata).toMatchObject({
148+
tx_hash: "tx-proposal",
149+
cert_index: 0,
150+
governance_type: "info_action",
151+
json_metadata: {
152+
body: {
153+
title: "Metadata could not be loaded.",
154+
abstract: "tx-proposal#0",
155+
motivation: "",
156+
rationale: "",
157+
references: [],
158+
},
159+
authors: [],
160+
},
161+
});
162+
});
163+
});

src/components/pages/wallet/governance/proposal/index.tsx

Lines changed: 15 additions & 157 deletions
Original file line numberDiff line numberDiff line change
@@ -17,131 +17,10 @@ import ReactMarkdown from 'react-markdown';
1717
import remarkGfm from "remark-gfm";
1818
import { Badge } from "@/components/ui/badge";
1919
import { CheckCircle2, XCircle, Clock, Calendar, Coins, Hash, FileText, Wallet } from "lucide-react";
20-
21-
const hasUsableJsonMetadata = (value: any): boolean =>
22-
Boolean(value && typeof value === "object" && value.body && typeof value.body === "object");
23-
24-
const getAnchorUrls = (anchorUrl: string): string[] => {
25-
if (!anchorUrl) return [];
26-
if (!anchorUrl.startsWith("ipfs://")) {
27-
return [anchorUrl];
28-
}
29-
const cidPath = anchorUrl.replace("ipfs://", "");
30-
return [
31-
`https://ipfs.io/ipfs/${cidPath}`,
32-
`https://cloudflare-ipfs.com/ipfs/${cidPath}`,
33-
`https://dweb.link/ipfs/${cidPath}`,
34-
];
35-
};
36-
37-
async function fetchJsonFromUrl(url: string): Promise<any> {
38-
const controller = new AbortController();
39-
const timeout = setTimeout(() => controller.abort(), 10000);
40-
try {
41-
const res = await fetch(url, {
42-
method: "GET",
43-
headers: { Accept: "application/json" },
44-
signal: controller.signal,
45-
});
46-
if (!res.ok) {
47-
throw new Error(`HTTP ${res.status} ${res.statusText}`);
48-
}
49-
const text = await res.text();
50-
return JSON.parse(text);
51-
} finally {
52-
clearTimeout(timeout);
53-
}
54-
}
55-
56-
async function hydrateMetadataFromAnchor(rawMetadata: any, txHash: string, certIndex: number): Promise<any> {
57-
if (hasUsableJsonMetadata(rawMetadata?.json_metadata)) {
58-
return rawMetadata;
59-
}
60-
const anchorUrl =
61-
typeof rawMetadata?.url === "string" && rawMetadata.url.length > 0
62-
? rawMetadata.url
63-
: null;
64-
if (!anchorUrl) return rawMetadata;
65-
66-
const candidateUrls = getAnchorUrls(anchorUrl);
67-
for (const url of candidateUrls) {
68-
try {
69-
const anchorJson = await fetchJsonFromUrl(url);
70-
const jsonMetadata = anchorJson?.body ? anchorJson : { body: anchorJson };
71-
if (hasUsableJsonMetadata(jsonMetadata)) {
72-
return { ...rawMetadata, json_metadata: jsonMetadata };
73-
}
74-
} catch {
75-
// Try next URL candidate.
76-
}
77-
}
78-
return rawMetadata;
79-
}
80-
81-
const normalizeProposalMetadata = (
82-
rawMetadata: any,
83-
txHash: string,
84-
certIndex: number,
85-
): ProposalMetadata => {
86-
const rawBody = rawMetadata?.json_metadata?.body;
87-
const body = rawBody && typeof rawBody === "object" ? rawBody : {};
88-
const rawAuthors = rawMetadata?.json_metadata?.authors;
89-
const authors = Array.isArray(rawAuthors)
90-
? rawAuthors
91-
.map((author) =>
92-
typeof author?.name === "string" ? { name: author.name } : null,
93-
)
94-
.filter((author): author is { name: string } => Boolean(author))
95-
: [];
96-
const references = Array.isArray((body as any).references)
97-
? (body as any).references.filter(
98-
(ref: any) =>
99-
ref &&
100-
typeof ref === "object" &&
101-
typeof ref.label === "string" &&
102-
typeof ref.uri === "string" &&
103-
typeof ref["@type"] === "string",
104-
)
105-
: [];
106-
107-
return {
108-
tx_hash:
109-
typeof rawMetadata?.tx_hash === "string" ? rawMetadata.tx_hash : txHash,
110-
cert_index:
111-
Number.isFinite(Number(rawMetadata?.cert_index))
112-
? Number(rawMetadata.cert_index)
113-
: certIndex,
114-
governance_type:
115-
typeof rawMetadata?.governance_type === "string"
116-
? rawMetadata.governance_type
117-
: "",
118-
hash: typeof rawMetadata?.hash === "string" ? rawMetadata.hash : "",
119-
url: typeof rawMetadata?.url === "string" ? rawMetadata.url : "",
120-
bytes: typeof rawMetadata?.bytes === "string" ? rawMetadata.bytes : "",
121-
json_metadata: {
122-
body: {
123-
title:
124-
typeof (body as any).title === "string"
125-
? (body as any).title
126-
: "Metadata could not be loaded.",
127-
abstract:
128-
typeof (body as any).abstract === "string"
129-
? (body as any).abstract
130-
: `${txHash}#${certIndex}`,
131-
motivation:
132-
typeof (body as any).motivation === "string"
133-
? (body as any).motivation
134-
: "",
135-
rationale:
136-
typeof (body as any).rationale === "string"
137-
? (body as any).rationale
138-
: "",
139-
references,
140-
},
141-
authors,
142-
},
143-
};
144-
};
20+
import {
21+
createProposalMetadataFallback,
22+
fetchProposalMetadataWithFallback,
23+
} from "@/lib/governance/proposalMetadata";
14524

14625
function WalletGovernanceProposalContent({ id }: { id: string }) {
14726
const network = useSiteStore((state) => state.network);
@@ -186,40 +65,19 @@ function WalletGovernanceProposalContent({ id }: { id: string }) {
18665
.get(detailsPath)
18766
.catch(() => null) as Promise<ProposalDetails | null>;
18867

189-
// Try primary metadata path first.
190-
let metadata: ProposalMetadata | null = null;
191-
const metadataPath = `/governance/proposals/${txHash}/${certIndex}/metadata`;
192-
try {
193-
metadata = (await hydrateMetadataFromAnchor(
194-
await blockchainProvider.get(metadataPath),
195-
txHash,
196-
Number(certIndex),
197-
)) as ProposalMetadata;
198-
} catch {
199-
200-
// Fallback: use details.id (gov_action_id) endpoint when available.
201-
const detailsForFallback = await detailsPromise;
202-
const govActionId =
203-
detailsForFallback && typeof detailsForFallback.id === "string"
204-
? detailsForFallback.id
205-
: null;
206-
207-
if (govActionId) {
208-
const fallbackPath = `/governance/proposals/${govActionId}/metadata`;
209-
try {
210-
metadata = (await hydrateMetadataFromAnchor(
211-
await blockchainProvider.get(fallbackPath),
212-
txHash,
213-
Number(certIndex),
214-
)) as ProposalMetadata;
215-
} catch {
216-
// Use default metadata below.
217-
}
218-
}
219-
}
68+
const proposal = {
69+
tx_hash: txHash,
70+
cert_index: Number(certIndex),
71+
governance_type: "",
72+
};
73+
const metadata = await fetchProposalMetadataWithFallback({
74+
provider: blockchainProvider,
75+
proposal,
76+
fetchDetails: () => detailsPromise,
77+
});
22078

22179
setProposalMetadata(
222-
normalizeProposalMetadata(metadata, txHash, Number(certIndex)),
80+
metadata ?? createProposalMetadataFallback(proposal),
22381
);
22482

22583
// Fetch proposal details

0 commit comments

Comments
 (0)