Skip to content

Commit cc3ae5a

Browse files
author
CloudLobster
committed
fix: World ID v4 proper flow - RP signature + v4 verify API
- Worker: POST /api/world-id/rp-signature (signRequest from @worldcoin/idkit-server) - Worker: verify now forwards to POST /v4/verify/{rp_id} (not v2) - Frontend: fetches RP signature before opening IDKitRequestWidget - Frontend: passes rp_context + allow_legacy_proofs to widget - Env: WORLD_ID_RP_ID in wrangler.toml, SIGNING_KEY as wrangler secret - Follows official docs.world.org/world-id/idkit/integrate
1 parent 367dbd2 commit cc3ae5a

File tree

6 files changed

+122
-89
lines changed

6 files changed

+122
-89
lines changed

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/src/components/WorldIdVerify.tsx

Lines changed: 63 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { useState, useEffect, useCallback } from 'react';
2-
import { IDKitRequestWidget, useIDKitRequest, orbLegacy } from '@worldcoin/idkit';
3-
import type { IDKitResult } from '@worldcoin/idkit';
2+
import { IDKitRequestWidget, orbLegacy } from '@worldcoin/idkit';
3+
import type { IDKitResult, RpContext } from '@worldcoin/idkit';
44

55
const API_BASE = (typeof window !== 'undefined' && window.location.hostname === 'localhost') ? '' : 'https://api.basemail.ai';
66
const WORLD_ID_APP_ID = 'app_7099aeba034f8327d91420254b4b660e';
77
const WORLD_ID_ACTION = 'verify-human';
8+
const WORLD_ID_RP_ID = 'rp_2b23fabfd8dffcaf';
89

910
interface Props {
1011
token: string;
@@ -18,6 +19,7 @@ export default function WorldIdVerify({ token, handle, wallet }: Props) {
1819
const [verifiedAt, setVerifiedAt] = useState<number | null>(null);
1920
const [error, setError] = useState('');
2021
const [widgetOpen, setWidgetOpen] = useState(false);
22+
const [rpContext, setRpContext] = useState<RpContext | null>(null);
2123

2224
// Check current status on mount
2325
useEffect(() => {
@@ -35,51 +37,59 @@ export default function WorldIdVerify({ token, handle, wallet }: Props) {
3537
.catch(() => setStatus('unverified'));
3638
}, [handle]);
3739

38-
const handleSuccess = useCallback(async (result: IDKitResult) => {
39-
setStatus('verifying');
40+
// Fetch RP signature before opening widget
41+
const handleOpenWidget = useCallback(async () => {
4042
setError('');
41-
4243
try {
43-
// Extract v3 legacy proof fields from result
44-
const v3 = result.responses?.find((r: any) => r.version === 'v3' || r.merkle_root);
45-
const merkle_root = v3?.merkle_root || (result as any).merkle_root;
46-
const nullifier_hash = v3?.nullifier_hash || (result as any).nullifier_hash;
47-
const proof = v3?.proof || (result as any).proof;
48-
49-
if (!merkle_root || !nullifier_hash || !proof) {
50-
setError('No valid proof in response');
51-
setStatus('unverified');
52-
return;
53-
}
54-
55-
const res = await fetch(`${API_BASE}/api/world-id/verify`, {
44+
const res = await fetch(`${API_BASE}/api/world-id/rp-signature`, {
5645
method: 'POST',
5746
headers: {
5847
'Content-Type': 'application/json',
5948
'Authorization': `Bearer ${token}`,
6049
},
61-
body: JSON.stringify({
62-
merkle_root,
63-
nullifier_hash,
64-
proof,
65-
verification_level: v3?.verification_level || 'orb',
66-
signal: wallet,
67-
}),
6850
});
69-
const data = await res.json() as any;
70-
if (data.ok || data.is_human) {
71-
setStatus('verified');
72-
setVerificationLevel(data.verification_level || 'orb');
73-
setVerifiedAt(Math.floor(Date.now() / 1000));
74-
} else {
75-
setError(data.error || 'Verification failed');
76-
setStatus('unverified');
51+
if (!res.ok) {
52+
const data = await res.json() as any;
53+
setError(data.error || 'Failed to get RP signature');
54+
return;
7755
}
56+
const rpSig = await res.json() as { sig: string; nonce: string; created_at: number; expires_at: number };
57+
setRpContext({
58+
rp_id: WORLD_ID_RP_ID,
59+
nonce: rpSig.nonce,
60+
created_at: rpSig.created_at,
61+
expires_at: rpSig.expires_at,
62+
signature: rpSig.sig,
63+
});
64+
setWidgetOpen(true);
7865
} catch (e: any) {
7966
setError(e.message || 'Network error');
80-
setStatus('unverified');
8167
}
82-
}, [token, wallet]);
68+
}, [token]);
69+
70+
// handleVerify: send proof to backend for v4 verification
71+
const handleVerify = useCallback(async (result: IDKitResult) => {
72+
const res = await fetch(`${API_BASE}/api/world-id/verify`, {
73+
method: 'POST',
74+
headers: {
75+
'Content-Type': 'application/json',
76+
'Authorization': `Bearer ${token}`,
77+
},
78+
body: JSON.stringify(result),
79+
});
80+
if (!res.ok) {
81+
const data = await res.json() as any;
82+
throw new Error(data.error || 'Backend verification failed');
83+
}
84+
}, [token]);
85+
86+
// onSuccess: update UI after successful verification
87+
const handleSuccess = useCallback((_result: IDKitResult) => {
88+
setStatus('verified');
89+
setVerificationLevel('orb');
90+
setVerifiedAt(Math.floor(Date.now() / 1000));
91+
setWidgetOpen(false);
92+
}, []);
8393

8494
if (status === 'loading') {
8595
return (
@@ -118,7 +128,7 @@ export default function WorldIdVerify({ token, handle, wallet }: Props) {
118128
</p>
119129

120130
<button
121-
onClick={() => setWidgetOpen(true)}
131+
onClick={handleOpenWidget}
122132
disabled={status === 'verifying'}
123133
className="bg-gradient-to-r from-purple-600 to-blue-600 text-white px-6 py-3 rounded-xl text-sm font-bold hover:from-purple-500 hover:to-blue-500 transition disabled:opacity-50 flex items-center gap-2"
124134
>
@@ -133,18 +143,23 @@ export default function WorldIdVerify({ token, handle, wallet }: Props) {
133143
)}
134144
</button>
135145

136-
<IDKitRequestWidget
137-
app_id={WORLD_ID_APP_ID as `app_${string}`}
138-
action={WORLD_ID_ACTION}
139-
preset={orbLegacy({ signal: wallet })}
140-
open={widgetOpen}
141-
onOpenChange={setWidgetOpen}
142-
onSuccess={handleSuccess}
143-
onError={(err) => {
144-
setError(`Verification error: ${err}`);
145-
setStatus('unverified');
146-
}}
147-
/>
146+
{rpContext && (
147+
<IDKitRequestWidget
148+
app_id={WORLD_ID_APP_ID as `app_${string}`}
149+
action={WORLD_ID_ACTION}
150+
rp_context={rpContext}
151+
allow_legacy_proofs={true}
152+
preset={orbLegacy({ signal: wallet })}
153+
open={widgetOpen}
154+
onOpenChange={setWidgetOpen}
155+
handleVerify={handleVerify}
156+
onSuccess={handleSuccess}
157+
onError={(err) => {
158+
setError(`Verification error: ${err}`);
159+
setWidgetOpen(false);
160+
}}
161+
/>
162+
)}
148163

149164
{error && (
150165
<p className="text-red-400 text-sm mt-3">{error}</p>

worker/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"db:migrate:local": "wrangler d1 execute basemail-db --file=src/db/schema.sql --local"
1010
},
1111
"dependencies": {
12+
"@worldcoin/idkit-server": "^1.0.0",
1213
"hono": "^4.7.0",
1314
"mimetext": "^3.0.24",
1415
"postal-mime": "^2.4.1",

worker/src/routes/world-id.ts

Lines changed: 53 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Hono } from 'hono';
22
import { AppBindings } from '../types';
33
import { authMiddleware } from '../auth';
4+
import { signRequest } from '@worldcoin/idkit-server';
45

56
const worldId = new Hono<AppBindings>();
67

@@ -23,68 +24,75 @@ async function ensureTable(db: D1Database) {
2324
`);
2425
}
2526

27+
/**
28+
* POST /api/world-id/rp-signature
29+
* Generate RP signature for IDKit v4. Requires auth.
30+
* Returns: { sig, nonce, created_at, expires_at }
31+
*/
32+
worldId.post('/rp-signature', authMiddleware(), async (c) => {
33+
const SIGNING_KEY = c.env.WORLD_ID_SIGNING_KEY;
34+
const ACTION = c.env.WORLD_ID_ACTION || 'verify-human';
35+
36+
if (!SIGNING_KEY) {
37+
return c.json({ error: 'World ID signing key not configured' }, 500);
38+
}
39+
40+
const { sig, nonce, createdAt, expiresAt } = signRequest(ACTION, SIGNING_KEY);
41+
42+
return c.json({
43+
sig,
44+
nonce,
45+
created_at: createdAt,
46+
expires_at: expiresAt,
47+
});
48+
});
49+
2650
/**
2751
* POST /api/world-id/verify
28-
* Accepts IDKit proof, verifies with World ID cloud API, stores result.
29-
* Body: { merkle_root, nullifier_hash, proof, verification_level, signal? }
52+
* Accepts full IDKit result, forwards to World ID v4 verify API.
53+
* Body: IDKit result payload (forwarded as-is)
3054
*/
3155
worldId.post('/verify', authMiddleware(), async (c) => {
3256
const auth = c.get('auth');
33-
const body = await c.req.json<{
34-
merkle_root: string;
35-
nullifier_hash: string;
36-
proof: string;
37-
verification_level?: string;
38-
signal?: string;
39-
}>();
40-
41-
const { merkle_root, nullifier_hash, proof, verification_level, signal } = body;
42-
43-
if (!merkle_root || !nullifier_hash || !proof) {
44-
return c.json({ error: 'Missing required proof fields' }, 400);
45-
}
57+
const body = await c.req.json();
4658

47-
const APP_ID = c.env.WORLD_ID_APP_ID;
48-
const ACTION = c.env.WORLD_ID_ACTION || 'verify-human';
59+
const RP_ID = c.env.WORLD_ID_RP_ID;
4960

50-
if (!APP_ID) {
61+
if (!RP_ID) {
5162
return c.json({ error: 'World ID not configured' }, 500);
5263
}
5364

54-
// Call World ID cloud verification API
55-
// v2 endpoint works for both v3 and v4 proofs (with allow_legacy_proofs)
65+
// Forward IDKit result to World ID v4 verify API
5666
const verifyRes = await fetch(
57-
`https://developer.worldcoin.org/api/v2/verify/${APP_ID}`,
67+
`https://developer.world.org/api/v4/verify/${RP_ID}`,
5868
{
5969
method: 'POST',
6070
headers: { 'Content-Type': 'application/json' },
61-
body: JSON.stringify({
62-
merkle_root,
63-
nullifier_hash,
64-
proof,
65-
action: ACTION,
66-
signal: signal || '',
67-
}),
71+
body: JSON.stringify(body),
6872
},
6973
);
7074

7175
if (!verifyRes.ok) {
7276
const err = await verifyRes.text();
73-
console.error('World ID verify failed:', verifyRes.status, err);
77+
console.error('World ID v4 verify failed:', verifyRes.status, err);
7478
return c.json({
7579
error: 'World ID verification failed',
7680
detail: verifyRes.status === 400 ? 'Invalid proof or already used' : 'Verification service error',
81+
status: verifyRes.status,
7782
}, 400);
7883
}
7984

80-
const verifyData = await verifyRes.json<{
81-
success: boolean;
82-
nullifier_hash: string;
83-
action: string;
84-
}>();
85+
const verifyData = await verifyRes.json<any>();
86+
87+
// Extract nullifier from response
88+
// v3 legacy: responses[0].nullifier
89+
// v4: responses[0].nullifier
90+
const firstResponse = verifyData.responses?.[0];
91+
const nullifier = firstResponse?.nullifier;
92+
const identifier = firstResponse?.identifier || 'orb';
8593

86-
if (!verifyData.success) {
87-
return c.json({ error: 'Proof verification returned false' }, 400);
94+
if (!nullifier) {
95+
return c.json({ error: 'No nullifier in verification response' }, 400);
8896
}
8997

9098
// Ensure DB table exists
@@ -93,7 +101,7 @@ worldId.post('/verify', authMiddleware(), async (c) => {
93101
// Check if this nullifier was already used (same human, different account)
94102
const existing = await c.env.DB.prepare(
95103
'SELECT handle FROM world_id_verifications WHERE nullifier_hash = ?'
96-
).bind(nullifier_hash).first<{ handle: string }>();
104+
).bind(nullifier).first<{ handle: string }>();
97105

98106
if (existing) {
99107
if (existing.handle === auth.handle) {
@@ -104,12 +112,15 @@ worldId.post('/verify', authMiddleware(), async (c) => {
104112
}, 409);
105113
}
106114

115+
// Determine version from response
116+
const version = verifyData.protocol_version || 'v4';
117+
107118
// Store verification
108119
const id = crypto.randomUUID();
109120
await c.env.DB.prepare(`
110121
INSERT INTO world_id_verifications (id, handle, wallet, nullifier_hash, verification_level, world_id_version)
111-
VALUES (?, ?, ?, ?, ?, 'v4')
112-
`).bind(id, auth.handle, auth.wallet, nullifier_hash, verification_level || 'orb').run();
122+
VALUES (?, ?, ?, ?, ?, ?)
123+
`).bind(id, auth.handle, auth.wallet, nullifier, identifier, version).run();
113124

114125
// Mark account as human (add column if not exists — D1 safe)
115126
try {
@@ -122,7 +133,8 @@ worldId.post('/verify', authMiddleware(), async (c) => {
122133
return c.json({
123134
ok: true,
124135
is_human: true,
125-
verification_level: verification_level || 'orb',
136+
verification_level: identifier,
137+
protocol_version: version,
126138
message: '✅ Human verified!',
127139
});
128140
});
@@ -154,7 +166,7 @@ worldId.get('/status/:handle', async (c) => {
154166

155167
/**
156168
* DELETE /api/world-id/verify
157-
* Authenticated: remove own World ID verification (rare, but useful)
169+
* Authenticated: remove own World ID verification
158170
*/
159171
worldId.delete('/verify', authMiddleware(), async (c) => {
160172
const auth = c.get('auth');

worker/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export interface Env {
1616
PAYMENT_ESCROW_ADDRESS?: string; // PaymentEscrow 合約地址 (Base Mainnet)
1717
WORLD_ID_APP_ID?: string; // World ID app_id
1818
WORLD_ID_ACTION?: string; // World ID action name (default: verify-human)
19+
WORLD_ID_RP_ID?: string; // World ID rp_id
20+
WORLD_ID_SIGNING_KEY?: string; // World ID RP signing key (SECRET - never expose)
1921
}
2022

2123
export interface Account {

worker/wrangler.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ REGISTRY_CONTRACT = "0x54569D87348ba71e33832b78D61FBC0B94Fed17D"
1616
PAYMENT_ESCROW_ADDRESS = "0xaf41b976978ac981d79c1008dd71681355c71bf6"
1717
WORLD_ID_APP_ID = "app_7099aeba034f8327d91420254b4b660e"
1818
WORLD_ID_ACTION = "verify-human"
19+
WORLD_ID_RP_ID = "rp_2b23fabfd8dffcaf"
20+
# WORLD_ID_SIGNING_KEY is stored as a wrangler secret (never in code!)
1921

2022
# D1 Database — 帳號與郵件索引
2123
[[d1_databases]]

0 commit comments

Comments
 (0)