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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,14 @@ jobs:
- run: pnpm build
working-directory: apps/web
env:
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co' }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'placeholder' }}
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_STELLAR_NETWORK: testnet
NEXT_PUBLIC_ENERGY_TOKEN_ID: placeholder
NEXT_PUBLIC_AUDIT_REGISTRY_ID: placeholder
NEXT_PUBLIC_COMMUNITY_GOVERNANCE_ID: placeholder
SUPABASE_SERVICE_ROLE_KEY: placeholder
MINTER_SECRET_KEY: SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
MINTER_SECRET_KEY: ${{ secrets.MINTER_SECRET_KEY }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

Expand Down
25 changes: 24 additions & 1 deletion apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# ─────────────────────────────────────────────────────────────────────────────
# SolarProof — environment variables
# Copy this file to .env.local and fill in your values.
# Copy this file to apps/web/.env.local and fill in your values for local development.
# Do not commit `.env.local` or any `.env.*.local` file.
# CI should read secrets from GitHub Actions secrets.
# Production should use Vercel environment variables.
# See docs/ONBOARDING.md for a step-by-step setup guide.
# ─────────────────────────────────────────────────────────────────────────────

Expand All @@ -26,6 +29,10 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
# Use "testnet" for development and staging; "mainnet" for production.
NEXT_PUBLIC_STELLAR_NETWORK=testnet

# [OPTIONAL] Override the default Soroban RPC endpoint.
# Example: https://soroban-testnet.stellar.org
NEXT_PUBLIC_STELLAR_RPC_URL=https://soroban-testnet.stellar.org

# [REQUIRED] Contract IDs — set these after running the deploy-contracts workflow
# or following the manual steps in docs/DEPLOYMENT.md.
# Each value is a 56-character Stellar contract address (C...).
Expand All @@ -37,10 +44,21 @@ NEXT_PUBLIC_COMMUNITY_GOVERNANCE_ID=
# [REQUIRED] Stellar secret key for the minter account (server-side only).
# This account mints energy_token certificates after a valid meter reading.
# Generate with: stellar keys generate minter --network testnet
# Local dev uses MINTER_SECRET_KEY in `.env.local`.
# Production should use MINTER_SECRET_ARN / MINTER_PREVIOUS_SECRET_ARN in Vercel.
# Never commit a real secret key. Use GitHub Actions secrets in CI/CD.
# Example: SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
MINTER_SECRET_KEY=

# [PRODUCTION] AWS Secrets Manager ARN for the active minter key.
MINTER_SECRET_ARN=

# [PRODUCTION] AWS Secrets Manager ARN for the previous minter key during rotation.
MINTER_PREVIOUS_SECRET_ARN=

# [OPTIONAL] AWS region for Secrets Manager.
AWS_REGION=us-east-1

# ── Redis (optional) ──────────────────────────────────────────────────────────
# Upstash Redis is used as a caching layer for certificate verification queries.
# If these are not set, caching is disabled and every /api/verify call hits Supabase.
Expand All @@ -63,3 +81,8 @@ LOGTAIL_SOURCE_TOKEN=
# In development, http://localhost:3000 is always permitted.
# Example: https://solarproof.vercel.app,https://staging.solarproof.vercel.app
CORS_ALLOWED_ORIGINS=https://solarproof.vercel.app

# ── Optional runtime configuration ───────────────────────────────────────────
# Rate limiting for reading submissions.
READINGS_RATE_LIMIT_PER_MINUTE=
READINGS_RATE_LIMIT_WINDOW_SECONDS=
73 changes: 22 additions & 51 deletions apps/web/src/app/api/readings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { z } from 'zod'
import { createServiceClient } from '@/lib/supabase'
import { computeReadingHash } from '@/lib/crypto'
import { kwhToStroops } from '@solarproof/stellar'
import { anchorReading, mintCertificates } from '@/lib/stellar'
import { invalidateCert, checkRateLimit } from '@/lib/cache'
import { fireWebhook } from '@/lib/webhooks'
import { logger } from '@/lib/logger'
import { requireAuth, isAuthError } from '@/lib/auth'
import { diagnoseMintFailure } from '@/lib/tracer-sim'
import { getIdempotentResponse, storeIdempotentResponse } from '@/lib/idempotency'
import { enqueue } from '@/lib/queue'

const MAX_PAGE_SIZE = 100

Expand Down Expand Up @@ -89,7 +90,7 @@ const ReadingSchema = z.object({
* Duplicate requests with the same key return the cached response without
* re-processing. Keys expire after IDEMPOTENCY_TTL_SECONDS (default 24 h).
*
* Returns 201 Created with { reading_id, anchor_tx_hash, mint_tx_hash }.
* Returns 202 Accepted with { reading_id, job_id }.
*/
export async function POST(req: NextRequest) {
const correlationId = req.headers.get('x-correlation-id') ?? undefined
Expand Down Expand Up @@ -198,7 +199,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Invalid meter signature' }, { status: 401 })
}

// Persist reading (anchored/minted will be updated by the background job)
// Persist reading; Stellar anchor + mint will be processed asynchronously.
const { data: reading, error: readingErr } = await db
.from('readings')
.insert({
Expand All @@ -218,57 +219,27 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Failed to save reading' }, { status: 500 })
}

// Anchor on-chain (hash only — full payload already in Supabase)
let anchorTxHash: string
try {
anchorTxHash = await anchorReading({ readingHash, nonce })
await db.from('readings').update({ anchored: true, anchor_tx_hash: anchorTxHash }).eq('id', reading.id)
log.info('readings.post.anchored', { reading_id: reading.id, anchor_tx_hash: anchorTxHash })
void fireWebhook(meter.cooperative_id, 'anchor', { reading_id: reading.id, anchor_tx_hash: anchorTxHash })
} catch (err) {
if (isAlreadyAnchoredError(err)) {
log.warn('readings.post.already_anchored', { reading_id: reading.id })
return NextResponse.json({ error: 'Reading already anchored', reading_id: reading.id }, { status: 409 })
}
const message = extractErrorMessage(err)
log.error('readings.post.anchor_failed', { reading_id: reading.id, error: message })
return NextResponse.json({ error: message, reading_id: reading.id }, { status: 500 })
const cooperative = meter.cooperatives as { admin_address: string } | null
const recipient = cooperative?.admin_address
if (!recipient) {
log.error('readings.post.missing_recipient', { reading_id: reading.id, cooperative_id: meter.cooperative_id })
return NextResponse.json({ error: 'No cooperative admin address' }, { status: 500 })
}

// Mint certificates
try {
const cooperative = meter.cooperatives as { admin_address: string } | null
const recipient = cooperative?.admin_address
if (!recipient) throw new Error('No cooperative admin address')

const mintTxHash = await mintCertificates(recipient, kwh)
await db.from('readings').update({ minted: true, mint_tx_hash: mintTxHash }).eq('id', reading.id)
await db.from('certificates').insert({
cooperative_id: meter.cooperative_id,
reading_id: reading.id,
reading_hash: readingHash.toString('hex'),
anchor_tx_hash: anchorTxHash,
mint_tx_hash: mintTxHash,
kwh,
issued_at: new Date().toISOString(),
retired: false,
})

// Invalidate any stale cache entries for this certificate
await invalidateCert(reading.id, readingHash.toString('hex'), mintTxHash)
const jobId = await enqueue('anchor_and_mint', {
readingId: reading.id,
readingHashHex: readingHash.toString('hex'),
recipientAddress: recipient,
kwh,
correlationId,
})

log.info('readings.post.minted', { reading_id: reading.id, mint_tx_hash: mintTxHash, kwh })
void fireWebhook(meter.cooperative_id, 'mint', { reading_id: reading.id, mint_tx_hash: mintTxHash, kwh })
log.info('readings.post.enqueued', { reading_id: reading.id, job_id: jobId })

const responseBody = { reading_id: reading.id, anchor_tx_hash: anchorTxHash, mint_tx_hash: mintTxHash }
if (idempotencyKey) {
await storeIdempotentResponse(idempotencyKey, { body: responseBody, status: 201 })
}
return NextResponse.json(responseBody, { status: 201 })
} catch (err) {
const message = err instanceof Error ? err.message : 'Mint failed'
log.error('readings.post.mint_failed', { reading_id: reading.id, error: message })
const diagnosis = await diagnoseMintFailure(reading.id, meter.cooperative_id, message)
return NextResponse.json({ error: message, reading_id: reading.id, anchor_tx_hash: anchorTxHash, diagnosis }, { status: 500 })
const responseBody = { reading_id: reading.id, job_id: jobId }
if (idempotencyKey) {
await storeIdempotentResponse(idempotencyKey, { body: responseBody, status: 202 })
}

return NextResponse.json(responseBody, { status: 202 })
}
7 changes: 5 additions & 2 deletions apps/web/src/lib/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export async function enqueue(type: JobType, payload: Record<string, unknown>):
* Process a single job by ID, retrying up to `MAX_ATTEMPTS` times on failure.
*
* Retries use exponential back-off (2 s, 4 s, 8 s). After all attempts are
* exhausted the job is marked `'failed'` and no further retries occur.
* exhausted the job is marked `'failed'` and moved to the dead-letter queue.
*
* @param jobId - UUID of the job record to process.
*/
Expand Down Expand Up @@ -100,6 +100,7 @@ async function runAnchorAndMint(
const { anchorReading, mintCertificates } = await import('@/lib/stellar')
const { createServiceClient: svc } = await import('@/lib/supabase')
const { invalidateCert } = await import('@/lib/cache')
const { fireWebhook } = await import('@/lib/webhooks')

const { readingId, readingHashHex, recipientAddress, kwh, correlationId } = payload as {
readingId: string
Expand All @@ -118,7 +119,7 @@ async function runAnchorAndMint(
const mintTxHash = await mintCertificates(recipientAddress, kwh, correlationId)
await db.from('readings').update({ minted: true, mint_tx_hash: mintTxHash }).eq('id', readingId)

// Fetch cooperative_id for certificate insert
// Fetch cooperative_id for certificate insert and webhooks
const { data: reading } = await db
.from('readings')
.select('meter_id, meters(cooperative_id)')
Expand All @@ -138,6 +139,8 @@ async function runAnchorAndMint(
retired: false,
})
await invalidateCert(readingId, readingHashHex, mintTxHash)
await fireWebhook(cooperativeId, 'anchor', { reading_id: readingId, anchor_tx_hash: anchorTxHash })
await fireWebhook(cooperativeId, 'mint', { reading_id: readingId, mint_tx_hash: mintTxHash, kwh })
}

return { anchor_tx_hash: anchorTxHash, mint_tx_hash: mintTxHash }
Expand Down
4 changes: 4 additions & 0 deletions docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ cp apps/web/.env.example apps/web/.env.local

Edit `apps/web/.env.local` and fill in:

> Local development secrets must live in `apps/web/.env.local` only. This file is gitignored and should never be committed.
> CI should use GitHub Actions secrets, and production should use Vercel environment variables.

```env
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
Expand All @@ -92,6 +95,7 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key

# Stellar
NEXT_PUBLIC_STELLAR_NETWORK=testnet
NEXT_PUBLIC_STELLAR_RPC_URL=https://soroban-testnet.stellar.org
NEXT_PUBLIC_ENERGY_TOKEN_ID= # from contract deployment
NEXT_PUBLIC_AUDIT_REGISTRY_ID= # from contract deployment
NEXT_PUBLIC_COMMUNITY_GOVERNANCE_ID= # from contract deployment
Expand Down