diff --git a/package-lock.json b/package-lock.json index f8da860..f477f0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,8 +45,29 @@ }, "engines": { "node": "22.x" + }, + "workspaces": [ + "server" + ] + }, + "server": { + "name": "helphone-prover", + "version": "1.0.0", + "dependencies": { + "@aztec/bb.js": "^0.87.9", + "@noir-lang/noir_js": "^1.0.0-beta.9", + "@stellar/stellar-sdk": "^16.0.0", + "cors": "^2.8.5", + "express": "^4.21.0" + }, + "devDependencies": { + "supertest": "^7.0.0" } }, + "node_modules/helphone-prover": { + "resolved": "server", + "link": true + }, "node_modules/@adobe/css-tools": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", diff --git a/package.json b/package.json index 174bad2..2400049 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,18 @@ "version": "1.0.0", "description": "", "main": "server/index.js", + "workspaces": [ + "server" + ], "scripts": { "dev": "node scripts/dev.mjs", "dev:vite": "vite", + "dev:all": "node scripts/dev.mjs", "server": "node server/index.js", "start": "node server/index.js", - "dev:all": "node scripts/dev.mjs", + "start:all": "node scripts/dev.mjs", "build": "vite build", + "build:all": "npm run build && npm run build --workspace server --if-present", "preview": "vite preview", "prepare": "husky", "test": "vitest run", diff --git a/server/package.json b/server/package.json index 1db594f..2908046 100644 --- a/server/package.json +++ b/server/package.json @@ -1,9 +1,11 @@ { "name": "helphone-prover", + "version": "1.0.0", "private": true, "type": "module", "scripts": { "start": "node index.js", + "build": "node -e \"process.exit(0)\"", "test": "node --test *.test.js" }, "dependencies": { diff --git a/src/components/MapboxWrapper.jsx b/src/components/MapboxWrapper.jsx new file mode 100644 index 0000000..9070ec2 --- /dev/null +++ b/src/components/MapboxWrapper.jsx @@ -0,0 +1,76 @@ +import { forwardRef } from "react"; +import Map, { NavigationControl } from "react-map-gl/mapbox"; +import "mapbox-gl/dist/mapbox-gl.css"; + +/** + * MapboxWrapper (#87) + * + * Encapsulates Mapbox / react-map-gl initialisation so pages don't have to + * repeat the access-token wiring, the default view state and the standard + * on-map controls. Everything specific to a screen — markers, sources, + * layers, popups, controllers — is passed as `children` and rendered inside + * the underlying `` exactly as before. + * + * The component forwards its ref to the react-map-gl `` instance, so + * callers that need the imperative map handle (`ref.current.getMap()`) keep + * working. + * + * Props: + * - `mapStyle` Mapbox style URL (required). + * - `onMapClick` Click handler, receives the react-map-gl event + * (`e.lngLat` etc). Optional. + * - `initialViewState` Overrides the default world view. Optional. + * - `accessToken` Overrides `VITE_MAPBOX_TOKEN`. Optional. + * - `showNavigationControl` Render the built-in zoom/compass control + * (default `true`). + * - `navigationControlPosition` default `"bottom-right"`. + * - `style` Container style, defaults to fill the parent. + * - `children` Map overlays. + * - any other prop is forwarded to ``. + */ + +const DEFAULT_TOKEN = import.meta.env.VITE_MAPBOX_TOKEN; + +const DEFAULT_VIEW_STATE = { + // Centre of the world, fully zoomed out — the same starting point Help.jsx + // used before this component existed. + longitude: 0, + latitude: 20, + zoom: 2, +}; + +const FILL_PARENT = { width: "100%", height: "100%" }; + +const MapboxWrapper = forwardRef(function MapboxWrapper( + { + mapStyle, + onMapClick, + initialViewState, + accessToken, + showNavigationControl = true, + navigationControlPosition = "bottom-right", + style = FILL_PARENT, + children, + ...rest + }, + ref, +) { + return ( + + {showNavigationControl && ( + + )} + {children} + + ); +}); + +export default MapboxWrapper; diff --git a/src/lib/provers.js b/src/lib/provers.js new file mode 100644 index 0000000..c8ecadd --- /dev/null +++ b/src/lib/provers.js @@ -0,0 +1,151 @@ +/** + * ZK proof-generation strategy (#86). + * + * `generateLocationProof` in `zk.js` used to branch between server-side and + * browser-side proving with an inline `if (proverUrl) { … } else { … }`. + * This file pulls that decision into a small strategy: a common + * {@link ProofGenerator} shape, two concrete implementations + * ({@link ServerProver}, {@link BrowserProver}), and a selector. + * + * The concrete proving work still lives in `zk.js` — the provers are thin + * adapters that hold the runtime context (prover URL, fallback flag) and + * call back into the implementation functions passed to their constructors. + * Behaviour, error messages and the single-flight lock are unchanged; only + * the dispatch is restructured. + */ + +/** + * @typedef {object} ProofResult + * @property {Uint8Array} proof + * @property {Uint8Array} publicInputsBytes + * @property {Uint8Array} publicInputsPrefix + * @property {string} nullifier + * @property {object} zone + */ + +/** + * Common interface every prover implements. + * + * `isAvailable()` is a cheap, synchronous check of whether this strategy is + * even eligible for the current runtime (a configured URL, an opt-in flag). + * It is **not** a network health check — a `ServerProver` can be "available" + * and still fail in `generate()` if the server is down; the caller decides + * whether to fall through to the next prover on that failure. + * + * `generate(opts)` runs the proof and resolves a {@link ProofResult}, or + * rejects with a caller-facing `Error`. + */ +export class ProofGenerator { + /** @returns {string} stable identifier, e.g. `'server'` / `'browser'` */ + get name() { + throw new Error('ProofGenerator.name is abstract') + } + + /** @returns {boolean} */ + isAvailable() { + throw new Error('ProofGenerator.isAvailable() is abstract') + } + + /** + * @param {object} _opts + * @returns {Promise} + */ + async generate(_opts) { + throw new Error('ProofGenerator.generate() is abstract') + } +} + +/** + * Proves via the remote ZK prover server (health check + `/prove`). + * + * Always eligible when a `proverUrl` is configured; a dead server surfaces + * as a rejection from {@link generate}, at which point the dispatcher in + * `zk.js` decides whether {@link BrowserProver} may take over. + */ +export class ServerProver extends ProofGenerator { + /** + * @param {{ proverUrl: string, request: (opts: object) => Promise }} deps + * `request` is `zk.js`'s `_requestServerProof` (health check included). + */ + constructor({ proverUrl, request }) { + super() + this.proverUrl = proverUrl + this._request = request + } + + get name() { + return 'server' + } + + isAvailable() { + return Boolean(this.proverUrl) + } + + generate(opts) { + return this._request({ ...opts, proverUrl: this.proverUrl }) + } +} + +/** + * Proves in-browser with Noir + Barretenberg. + * + * Only eligible when `VITE_ZK_BROWSER_FALLBACK === 'true'` *as a fallback + * after a server failure*; when there is no server configured at all it runs + * unconditionally (matching the previous behaviour, where a missing + * `VITE_ZK_PROVER_URL` fell straight through to browser proving). The + * `run` implementation keeps the existing single-flight lock so a second + * concurrent call awaits the first proof rather than starting another. + */ +export class BrowserProver extends ProofGenerator { + /** + * @param {{ allowed: boolean, run: (opts: object) => Promise }} deps + * `run` is `zk.js`'s lock-wrapped browser proof runner. + */ + constructor({ allowed, run }) { + super() + this._allowed = Boolean(allowed) + this._run = run + } + + get name() { + return 'browser' + } + + isAvailable() { + return this._allowed + } + + generate(opts) { + return this._run(opts) + } +} + +/** + * Builds the ordered prover list for the current runtime: `ServerProver` + * first when a URL is configured, then `BrowserProver`. `zk.js` walks this + * list, trying the server and (subject to `BrowserProver.isAvailable()`) + * falling through to the browser. + * + * @param {{ + * proverUrl: string, + * allowBrowserFallback: boolean, + * requestServerProof: (opts: object) => Promise, + * runBrowserProof: (opts: object) => Promise, + * }} ctx + * @returns {ProofGenerator[]} + */ +export function selectProvers({ + proverUrl, + allowBrowserFallback, + requestServerProof, + runBrowserProof, +}) { + const provers = [] + if (proverUrl) { + provers.push(new ServerProver({ proverUrl, request: requestServerProof })) + } + provers.push( + new BrowserProver({ allowed: allowBrowserFallback, run: runBrowserProof }), + ) + return provers +} diff --git a/src/lib/zk.js b/src/lib/zk.js index 2288b4c..34c0fce 100644 --- a/src/lib/zk.js +++ b/src/lib/zk.js @@ -1,4 +1,5 @@ import { StrKey } from '@stellar/stellar-sdk' +import { selectProvers } from './provers' let _noir = null let _backend = null @@ -386,16 +387,50 @@ function buildCampaignPrefix(publicInputsBytes) { * @param {{ lat: number, lng: number, campaignId?: string, recipientAddress: string, zone?: object }} opts * @returns {{ proof: Uint8Array, publicInputsBytes: Uint8Array, nullifier: string }} */ +/** + * Runs the in-browser proof under the module-level single-flight lock: a + * second concurrent call awaits the first proof instead of starting another. + * Extracted from {@link generateLocationProof} so it can be handed to + * {@link BrowserProver} as its `run` implementation — behaviour is identical + * to the previous inline block. + */ +function _browserProofSingleFlight(args) { + if (_proofLock) { + args.onLog('Proof already in progress — waiting for it to complete') + return _proofLock + } + + _proofLock = _browserProof(args) + + return _proofLock.finally(() => { + _proofLock = null + }) +} + export async function generateLocationProof({ lat, lng, campaignId = '1', recipientAddress, zone, onLog = () => {} }) { const proverUrl = resolveProverUrl() const allowBrowserFallback = import.meta.env.VITE_ZK_BROWSER_FALLBACK === 'true' const proofZone = normalizeZone(zone) + const args = { lat, lng, campaignId, recipientAddress, zone: proofZone, onLog } + + // #86 — dispatch through the prover strategy instead of an inline branch. + // selectProvers() returns [ServerProver, BrowserProver] when a prover URL + // is configured, or [BrowserProver] when it is not. + const provers = selectProvers({ + proverUrl, + allowBrowserFallback, + requestServerProof: _requestServerProof, + runBrowserProof: _browserProofSingleFlight, + }) + + const server = provers.find((p) => p.name === 'server') + const browser = provers.find((p) => p.name === 'browser') - if (proverUrl) { + if (server) { try { - return await _requestServerProof({ lat, lng, campaignId, recipientAddress, zone: proofZone, onLog, proverUrl }) + return await server.generate(args) } catch (err) { - if (!allowBrowserFallback) { + if (!browser.isAvailable()) { onLog('ZK prover server is not available') const hint = import.meta.env.PROD ? 'Set VITE_ZK_PROVER_URL to your hosted ZK prover (see README → Deploy).' @@ -406,18 +441,7 @@ export async function generateLocationProof({ lat, lng, campaignId = '1', recipi } } - if (_proofLock) { - onLog('Proof already in progress — waiting for it to complete') - return _proofLock - } - - _proofLock = _browserProof({ lat, lng, campaignId, recipientAddress, zone: proofZone, onLog }) - - try { - return await _proofLock - } finally { - _proofLock = null - } + return browser.generate(args) } function resolveProverUrl() { diff --git a/src/pages/Help.jsx b/src/pages/Help.jsx index 879d410..4c9da98 100644 --- a/src/pages/Help.jsx +++ b/src/pages/Help.jsx @@ -10,15 +10,9 @@ import { import { Link } from "react-router-dom"; import { StellarWalletsKit } from "@creit-tech/stellar-wallets-kit/sdk"; import { KitEventType } from "@creit-tech/stellar-wallets-kit/types"; -import Map, { - Marker, - Popup, - Source, - Layer, - NavigationControl, - useMap, -} from "react-map-gl/mapbox"; +import { Marker, Popup, Source, Layer, useMap } from "react-map-gl/mapbox"; import "mapbox-gl/dist/mapbox-gl.css"; +import MapboxWrapper from "../components/MapboxWrapper"; import { getRequest, getActiveRequests, @@ -2054,8 +2048,8 @@ export function cancellationToken() { const RETRY_CLS = "hp-mobile-open"; const RETRY_ID = "helphone-help-sidebar"; -// Dead-letter queue for safeToggleClass — plain object avoids the global Map -// constructor which is shadowed by the react-map-gl import on line 13. +// Dead-letter queue for safeToggleClass — a plain null-prototype object, +// used as a simple string-keyed store (no inherited keys to collide with). const dlq = Object.create(null); /** @@ -4584,6 +4578,8 @@ export default function Help() {