Skip to content

Commit

Permalink
Merge pull request #10 from baumstern/hc
Browse files Browse the repository at this point in the history
feat: retrieve impact reports
  • Loading branch information
thebeyondr committed Jan 27, 2024
2 parents 2d288b5 + 53366a4 commit 0f21312
Show file tree
Hide file tree
Showing 9 changed files with 2,925 additions and 392 deletions.
35 changes: 35 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Tests

on:
push:
branches:
- main
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-22.04

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v2
with:
version: 8

- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'

- run: pnpm install

- run: pnpm build

- name: Test server functionality
run: pnpm exec vitest
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,27 @@ VoiceDeck is a platform that allows users to contribute retroactive funding for
We recommend [direnv](https://direnv.net/) for managing your environment variables


## Server Design

### Endpoint Details

`/impact-reports`
- **Returns**: An array of `Report` objects.
- **Purpose**: To provide impact reports to the UI.
- **Implementation Details**: Uses `fetchReports()` from `server/impactReportHelpers.ts`.

### Server Functions

Located in `app/server/impactReportHelpers.ts`:

- `fetchReports`: Function to retrieve reports, including interaction with Hypercerts.

### Data Models

- **Impact Report**: The report or stories that have been published previously and verified to actually produce an impact.
- **Hypercert**: A token representing a claim of impactful work, which is fractionable and transferable, conforming to the ERC-1155 standard for semi-fungible tokens.
- **Hypercert Metadata**: A set of data associated with a Hypercert, detailing the scope of work, contributors, impact, and rights, stored on IPFS.

### Separation of Concerns

The `/impact-reports` endpoint is responsible for serving impact reports. The implementation details of how the server retrieves data from Hypercert are abstracted away and managed within the `app/server/impactReportHelpers.ts` file.
37 changes: 37 additions & 0 deletions app/routes/impact-reports.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { fetchReports } from "~/server/impactReportHelpers";

export const loader = async () => {
const ownerAddress = process.env.HC_OWNER_ADDRESS;
try {
if (!ownerAddress) {
throw new Error("Owner address environment variable is not set");
}
const reports = await fetchReports(ownerAddress);
return new Response(JSON.stringify(reports), {
status: 200,
statusText: "OK",
});
} catch (error) {
console.error(`Failed to load impact reports: ${error}`);
return new Response(
JSON.stringify({ error: "Failed to load impact reports" }),
{
status: 500,
statusText: "Internal Server Error",
},
);
}
};

// or you can use loader like this and import it in the route file:
// export const loader = async (): Promise<IReportLoader> => {
// const ownerAddress = process.env.HC_OWNER_ADDRESS;
// return { reports: await fetchReports(ownerAddress) };
// };
//
//
// and then retrive the data like this in `Index()` function:
// export default function Index() {
// const { reports } = useLoaderData<typeof loader>();
// ...
// }
32 changes: 32 additions & 0 deletions app/server/impactReportHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { it } from "node:test";
import { afterAll, expect, expectTypeOf, test, vi } from "vitest";
import { fetchReports } from "~/server/impactReportHelpers";
import { Report } from "~/types";

test("fetch reports", async () => {
// address used to mint test hypercerts in Sepolia testnet
const ownerAddress = "0x42fbf4d890b4efa0fb0b56a9cc2c5eb0e07c1536";
const consoleMock = vi.spyOn(console, "log");

afterAll(() => {
consoleMock.mockReset();
});

it("should fetch reports", async () => {
const result = await fetchReports(ownerAddress);

expectTypeOf(result).toEqualTypeOf<Report[]>();
expect(result.length).toBeGreaterThan(0);
expect(consoleMock).toHaveBeenCalledWith("Fetching reports from remote");
});

it("should not fetch reports if already cached", async () => {
const result = await fetchReports(ownerAddress);

expectTypeOf(result).toEqualTypeOf<Report[]>();
expect(result.length).toBeGreaterThan(0);
expect(consoleMock).toHaveBeenCalledWith(
"Reports already exist, no need to fetch from remote",
);
});
});
140 changes: 140 additions & 0 deletions app/server/impactReportHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import {
ClaimsByOwnerQuery,
HypercertClient,
HypercertIndexerInterface,
HypercertMetadata,
HypercertsStorage,
} from "@hypercerts-org/sdk";
import { Claim, Report } from "~/types";

// ==============================
// Report Fetching Functionality
// ==============================

// cached reports to avoid fetching them again
let reports: Report[] | null = null;

/**
* Fetches reports either from the cache or by generating them if not already cached.
* @returns A promise that resolves to an array of reports.
* @throws Throws an error if fetching reports fails.
*/
export const fetchReports = async (ownerAddress: string): Promise<Report[]> => {
try {
// Fetch reports from cache if already fetched
if (reports) {
console.log("Reports already exist, no need to fetch from remote");
console.log(`Existing reports: ${reports.length}`);
} else {
// Fetch reports from remote if not already cached
console.log("Fetching reports from remote");
const claims = await getHypercertClaims(
ownerAddress,
getHypercertClient().indexer,
);
reports = await Promise.all(
claims.map(async (claim, index) => {
const metadata = await getHypercertMetadata(
claim.uri as string,
getHypercertClient().storage,
);
return {
id: claim.id,
title: metadata.name,
summary: metadata.description,
image: metadata.image,
// use hardcoded values for now
// TODO: fetch from CMS or define type(or enum or whatever)
state: index === 0 ? "Madhya Pradesh" : "Kerala",
category: metadata.hypercert?.work_scope.value?.[0],
// tentatively, it represent $1000
totalCost: 1000,
// TODO: fetch from blockchain when Hypercert Marketplace is ready
fundedSoFar: Math.floor(Math.random() * 1000),
} as Report;
}),
);
}

return reports;
} catch (error) {
console.error(`Failed to fetch reports: ${error}`);
throw new Error("Failed to fetch reports");
}
};

// ==============================
// Hypercert Client and Metadata Fetching Functionality
// ==============================

// singleton instance of HypercertClient
let hypercertClient: HypercertClient | null = null;

/**
* Retrieves the singleton instance of the HypercertClient.
* @returns The HypercertClient instance.
*/
export const getHypercertClient = (): HypercertClient => {
if (hypercertClient) {
return hypercertClient;
}
hypercertClient = new HypercertClient({ chain: { id: 11155111 } }); // Sepolia testnet

return hypercertClient;
};

/**
* Fetches the claims owned by the specified address from the Hypercert indexer.
* @param ownerAddress - The address of the owner of the claims.
* @param indexer - An instance of HypercertIndexer to retrieve claims from the [Graph](https://thegraph.com/docs/en/)
* @returns A promise that resolves to an array of claims.
* @throws Will throw an error if the owner address is not set or the claims cannot be fetched.
*/
export const getHypercertClaims = async (
ownerAddress: string,
indexer: HypercertIndexerInterface,
): Promise<Claim[]> => {
let claims: Claim[] | null;

console.log(`Fetching claims owned by ${ownerAddress}`);
try {
// see graphql query: https://github.com/hypercerts-org/hypercerts/blob/d7f5fee/sdk/src/indexer/queries/claims.graphql#L1-L11
const response = await indexer.claimsByOwner(ownerAddress as string, {
orderDirections: "asc",
first: 100,
// skip the first 2 claims (they are dummy of 0x42FbF4d890B4EFA0FB0b56a9Cc2c5EB0e07C1536 in Sepolia testnet)
skip: 2,
});
claims = (response as ClaimsByOwnerQuery).claims as Claim[];
console.log(`Fetched claims: ${claims ? claims.length : 0}`);

return claims;
} catch (error) {
console.error(`Failed to fetch claims owned by ${ownerAddress}: ${error}`);
throw new Error(`Failed to fetch claims claims owned by ${ownerAddress}`);
}
};

/**
* Retrieves the metadata for a given claim URI from IPFS.
* @param claimUri - The IPFS URI of the claim for which metadata is to be fetched.
* @param storage - An instance of HypercertsStorage to retrieve metadata from IPFS.
* @returns A promise that resolves to the metadata of the claim.
* @throws Will throw an error if the metadata cannot be fetched.
*/
export const getHypercertMetadata = async (
claimUri: string,
storage: HypercertsStorage,
): Promise<HypercertMetadata> => {
let metadata: HypercertMetadata | null;

try {
const response = await storage.getMetadata(claimUri);
metadata = response;

return metadata;
} catch (error) {
console.error(`Failed to fetch metadata of ${claimUri}: ${error}`);
throw new Error(`Failed to fetch metadata of ${claimUri}`);
}
};
51 changes: 51 additions & 0 deletions app/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Represents 1 Impact report
* @property {string} id - The ID of associated hypercert
* @property {string} title - The title of the report
* @property {string} summary - The summary of the report
* @property {string} image - The image of the report
* @property {string} state - The state where the impact is being made
* @property {string} category - The category of the report
* @property {number} totalCost - The total cost of the report in USD
* @property {number} fundedSoFar - The amount funded so far in USD
* @property {string} created_at - The date the report was created
* @property {string} updated_at - The date the report was updated
*/
export interface Report {
id: string;
title: string;
summary: string;
image: string;
state: string;
category: string;
totalCost: number;
fundedSoFar: number;
created_at?: string;
updated_at?: string;
}

/**
* Represents 1 Hypercert
* @property {string} __typename - The address of the contract
* @property {string} contract - The address of the contract where the claim is stored.
* @property {any} tokenID - The token ID.
* @property {any} creator - The address of the creator.
* @property {string} id - The ID of the claim.
* @property {any} owner - The address of the owner.
* @property {any} totalUnits - The total number of units.
* @property {string} uri - The URI of the claim metadata.
*/
export interface Claim {
__typename?: "Claim";
contract: string;
// biome-ignore lint: type definition imported from @hypercerts-org/sdk
tokenID: any;
// biome-ignore lint: type definition imported from @hypercerts-org/sdk
creator?: any | null;
id: string;
// biome-ignore lint: type definition imported from @hypercerts-org/sdk
owner?: any | null;
// biome-ignore lint: type definition imported from @hypercerts-org/sdk
totalUnits?: any | null;
uri?: string | null;
}
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"dependencies": {
"@fontsource-variable/plus-jakarta-sans": "^5.0.19",
"@hypercerts-org/sdk": "^1.4.2-alpha.0",
"@remix-run/node": "^2.5.0",
"@remix-run/react": "^2.5.0",
"@remix-run/serve": "^2.5.0",
Expand All @@ -30,6 +31,7 @@
"devDependencies": {
"@biomejs/biome": "1.5.1",
"@remix-run/dev": "^2.5.0",
"@types/node": "^20.11.5",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"autoprefixer": "^10.4.16",
Expand All @@ -39,7 +41,8 @@
"tailwindcss": "^3.4.1",
"typescript": "^5.1.6",
"vite": "^5.0.0",
"vite-tsconfig-paths": "^4.2.1"
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^1.2.1"
},
"engines": {
"node": ">=18.0.0"
Expand Down
Loading

0 comments on commit 0f21312

Please sign in to comment.