Skip to content

Commit dfd87e3

Browse files
committed
feat: Cloudflare Workers + R2 for metadata storage
- worker/: Cloudflare Worker API with R2 bucket binding - PUT /metadata/:hash — content-addressed upload with SHA-256 verification - GET /metadata/:hash — fetch with immutable cache headers - CORS support for browser clients - client/src/pinata.ts → Worker client (replaces Pinata) - Board.tsx uses hash-based metadata keying - README updated with Cloudflare deploy instructions (free tier) - .env.example with VITE_METADATA_API
1 parent ce1fae7 commit dfd87e3

10 files changed

Lines changed: 1846 additions & 72 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ client/dist/
2121
.idea/
2222
.vscode/
2323
*.iml
24+
worker/node_modules

README.md

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ This demonstrates the real use case — one wallet posts a job, another bids on
136136
- **Ephemeral Rollups**: MagicBlock SDK for delegation + gasless bidding
137137
- **Client**: React + TypeScript + Vite
138138
- **Wallet**: Phantom / Solflare via `@solana/wallet-adapter`
139+
- **Metadata**: Cloudflare Workers + R2 (content-addressed storage)
140+
- **Hosting**: Cloudflare Pages (free)
139141

140142
---
141143

@@ -145,12 +147,35 @@ This demonstrates the real use case — one wallet posts a job, another bids on
145147
- Node.js 18+
146148
- Rust + Anchor CLI
147149
- Solana CLI (devnet)
150+
- Wrangler CLI (`npm i -g wrangler`) — for Cloudflare deployment
148151

149-
### Install & Run Client
152+
### Local Development
150153
```bash
154+
# Client
155+
cd client && npm install && npm run dev
156+
157+
# Worker (optional — works without it in local-only mode)
158+
cd worker && npm install && npm run dev
159+
```
160+
161+
### Deploy to Cloudflare (Free)
162+
163+
```bash
164+
# 1. Login to Cloudflare
165+
wrangler login
166+
167+
# 2. Create R2 bucket
168+
wrangler r2 bucket create taskforest-metadata
169+
170+
# 3. Deploy metadata Worker
171+
cd worker && npm run deploy
172+
# Note the Worker URL
173+
174+
# 4. Deploy frontend to Pages
151175
cd client
152-
npm install
153-
npm run dev
176+
echo "VITE_METADATA_API=https://taskforest-api.<you>.workers.dev" > .env
177+
npm run build
178+
wrangler pages deploy dist --project-name taskforest
154179
```
155180

156181
### Build & Deploy Program

client/.env.example

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
# Pinata IPFS — get a free JWT at https://www.pinata.cloud/
2-
VITE_PINATA_JWT=your_pinata_jwt_here
1+
# Cloudflare Worker API URL (deployed Worker URL)
2+
# Leave empty for local-only metadata storage
3+
VITE_METADATA_API=https://taskforest-api.your-subdomain.workers.dev

client/src/Board.tsx

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from '@solana/web3.js'
88
import * as anchor from '@coral-xyz/anchor'
99
import idl from '../../target/idl/taskforest.json'
10-
import { uploadMetadata, fetchMetadata, hashMetadata } from './pinata'
10+
import { uploadMetadata, fetchMetadata, hashMetadata, hashMetadataHex } from './pinata'
1111
import type { TaskMetadata } from './pinata'
1212
import './Board.css'
1313

@@ -112,12 +112,11 @@ export default function Board() {
112112
parsed.sort((a, b) => b.jobId - a.jobId)
113113
setJobs(parsed)
114114

115-
// Fetch metadata from IPFS for all jobs
115+
// Fetch metadata from API for all jobs
116116
for (const job of parsed) {
117-
const cidKey = `tf_cid_${job.pubkey.toBase58()}`
118-
const cid = localStorage.getItem(cidKey)
119-
if (cid) {
120-
fetchMetadata(cid).then(meta => {
117+
const hashKey = localStorage.getItem(`tf_hash_${job.pubkey.toBase58()}`)
118+
if (hashKey) {
119+
fetchMetadata(hashKey).then(meta => {
121120
if (meta) setMetadataMap(prev => ({ ...prev, [job.pubkey.toBase58()]: meta }))
122121
})
123122
}
@@ -184,17 +183,16 @@ export default function Board() {
184183
deadline: Math.floor(Date.now() / 1000) + 7200,
185184
}
186185

187-
log('Uploading task metadata to IPFS...')
188-
let ipfsCid = ''
186+
log('Uploading task metadata...')
187+
let metaHash = ''
189188
try {
190-
ipfsCid = await uploadMetadata(metadata)
191-
if (ipfsCid) {
192-
log(`📄 Metadata on IPFS: ${ipfsCid.slice(0, 16)}...`)
193-
} else {
194-
log('⚠️ No Pinata JWT — using local-only metadata')
189+
metaHash = await uploadMetadata(metadata)
190+
if (metaHash) {
191+
log(`📄 Metadata stored: ${metaHash.slice(0, 16)}...`)
195192
}
196193
} catch (e) {
197-
log(`⚠️ IPFS upload failed, continuing with local: ${(e as Error).message.slice(0, 60)}`)
194+
log(`⚠️ Upload failed, continuing with local: ${(e as Error).message.slice(0, 60)}`)
195+
metaHash = await hashMetadataHex(metadata)
198196
}
199197

200198
// Hash metadata for on-chain verification
@@ -214,11 +212,9 @@ export default function Board() {
214212
const sig = await sendTx(connection, tx)
215213
log(`✅ Job #${jobId} created (${rewardSol} SOL escrowed) tx:${sig.slice(0, 12)}...`)
216214

217-
// Save CID + metadata locally for display
218-
if (ipfsCid) {
219-
localStorage.setItem(`tf_cid_${jobPDA.toBase58()}`, ipfsCid)
220-
}
221-
localStorage.setItem(`tf_desc_${jobPDA.toBase58()}`, jobDesc || 'Task')
215+
// Save hash + metadata locally for display
216+
localStorage.setItem(`tf_hash_${jobPDA.toBase58()}`, metaHash)
217+
localStorage.setItem(`tf_meta_${metaHash}`, JSON.stringify(metadata))
222218
setMetadataMap(prev => ({ ...prev, [jobPDA.toBase58()]: metadata }))
223219

224220
// Step 2: Auto-delegate to open for bidding
@@ -462,20 +458,15 @@ export default function Board() {
462458
const meta = metadataMap[job.pubkey.toBase58()]
463459
const desc = meta?.description || localStorage.getItem(`tf_desc_${job.pubkey.toBase58()}`)
464460
const title = meta?.title
465-
const cid = localStorage.getItem(`tf_cid_${job.pubkey.toBase58()}`)
461+
const hash = localStorage.getItem(`tf_hash_${job.pubkey.toBase58()}`)
466462
return (
467463
<div className="job-meta-block">
468464
{title && <div className="job-meta-title">{title}</div>}
469465
{desc && <div className="job-desc">{desc}</div>}
470-
{cid && (
471-
<a
472-
className="job-ipfs-link"
473-
href={`https://gateway.pinata.cloud/ipfs/${cid}`}
474-
target="_blank"
475-
rel="noreferrer"
476-
>
477-
📄 IPFS: {cid.slice(0, 12)}...
478-
</a>
466+
{hash && (
467+
<span className="job-ipfs-link">
468+
🔗 {hash.slice(0, 16)}...
469+
</span>
479470
)}
480471
</div>
481472
)

client/src/pinata.ts

Lines changed: 51 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
const PINATA_GATEWAY = 'https://gateway.pinata.cloud/ipfs'
1+
/**
2+
* Metadata storage client.
3+
* Uploads/fetches task metadata via the Cloudflare Worker + R2 API.
4+
* Falls back to localStorage-only if no API URL is configured.
5+
*/
6+
7+
// Set this to your deployed Worker URL, or leave empty for local-only mode
8+
const API_URL = import.meta.env.VITE_METADATA_API || ''
29

310
export interface TaskMetadata {
411
title: string
@@ -12,55 +19,72 @@ export interface TaskMetadata {
1219
}
1320

1421
/**
15-
* Upload task metadata JSON to IPFS via Pinata.
16-
* Returns the IPFS CID (content identifier).
22+
* SHA-256 hash of the metadata JSON as a hex string.
23+
*/
24+
export async function hashMetadataHex(metadata: TaskMetadata): Promise<string> {
25+
const json = JSON.stringify(metadata)
26+
const encoded = new TextEncoder().encode(json)
27+
const hashBuffer = await crypto.subtle.digest('SHA-256', encoded)
28+
return Array.from(new Uint8Array(hashBuffer))
29+
.map(b => b.toString(16).padStart(2, '0'))
30+
.join('')
31+
}
32+
33+
/**
34+
* SHA-256 hash as a 32-byte array (for on-chain storage).
35+
*/
36+
export async function hashMetadata(metadata: TaskMetadata): Promise<number[]> {
37+
const json = JSON.stringify(metadata)
38+
const encoded = new TextEncoder().encode(json)
39+
const hashBuffer = await crypto.subtle.digest('SHA-256', encoded)
40+
return Array.from(new Uint8Array(hashBuffer))
41+
}
42+
43+
/**
44+
* Upload task metadata to R2 via the Worker API.
45+
* Returns the content hash (used as the key).
1746
*/
1847
export async function uploadMetadata(metadata: TaskMetadata): Promise<string> {
19-
const jwt = import.meta.env.VITE_PINATA_JWT
20-
if (!jwt) {
21-
console.warn('VITE_PINATA_JWT not set — falling back to local-only storage')
22-
return ''
48+
const json = JSON.stringify(metadata)
49+
const hash = await hashMetadataHex(metadata)
50+
51+
if (!API_URL) {
52+
console.warn('VITE_METADATA_API not set — using local-only storage')
53+
return hash // return hash anyway for localStorage keying
2354
}
2455

25-
const res = await fetch('https://api.pinata.cloud/pinning/pinJSONToIPFS', {
26-
method: 'POST',
27-
headers: {
28-
'Content-Type': 'application/json',
29-
Authorization: `Bearer ${jwt}`,
30-
},
31-
body: JSON.stringify({
32-
pinataContent: metadata,
33-
pinataMetadata: {
34-
name: `taskforest-${metadata.title.slice(0, 30)}`,
35-
},
36-
}),
56+
const res = await fetch(`${API_URL}/metadata/${hash}`, {
57+
method: 'PUT',
58+
headers: { 'Content-Type': 'application/json' },
59+
body: json,
3760
})
3861

3962
if (!res.ok) {
4063
const err = await res.text()
41-
throw new Error(`Pinata upload failed: ${err}`)
64+
throw new Error(`Upload failed: ${err}`)
4265
}
4366

44-
const data = await res.json()
45-
return data.IpfsHash as string // e.g. "QmXyz..."
67+
return hash
4668
}
4769

4870
/**
49-
* Fetch task metadata from IPFS via public gateway.
71+
* Fetch task metadata from the Worker API.
5072
* Uses localStorage as cache to avoid repeated fetches.
5173
*/
52-
export async function fetchMetadata(cid: string): Promise<TaskMetadata | null> {
53-
if (!cid) return null
74+
export async function fetchMetadata(hash: string): Promise<TaskMetadata | null> {
75+
if (!hash) return null
5476

5577
// Check cache first
56-
const cacheKey = `tf_ipfs_${cid}`
78+
const cacheKey = `tf_meta_${hash}`
5779
const cached = localStorage.getItem(cacheKey)
5880
if (cached) {
5981
try { return JSON.parse(cached) } catch { /* re-fetch */ }
6082
}
6183

84+
if (!API_URL) return null
85+
6286
try {
63-
const res = await fetch(`${PINATA_GATEWAY}/${cid}`, {
87+
const res = await fetch(`${API_URL}/metadata/${hash}`, {
6488
signal: AbortSignal.timeout(8000),
6589
})
6690
if (!res.ok) return null
@@ -71,14 +95,3 @@ export async function fetchMetadata(cid: string): Promise<TaskMetadata | null> {
7195
return null
7296
}
7397
}
74-
75-
/**
76-
* SHA-256 hash of the metadata JSON, returned as a 32-byte array.
77-
* This gets stored on-chain for content verification.
78-
*/
79-
export async function hashMetadata(metadata: TaskMetadata): Promise<number[]> {
80-
const json = JSON.stringify(metadata)
81-
const encoded = new TextEncoder().encode(json)
82-
const hashBuffer = await crypto.subtle.digest('SHA-256', encoded)
83-
return Array.from(new Uint8Array(hashBuffer))
84-
}

0 commit comments

Comments
 (0)