@zkproofport-app/sdk 0.2.9 → 0.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +338 -131
- package/dist/ProofportSDK.d.ts +22 -8
- package/dist/deeplink.d.ts +70 -0
- package/dist/index.esm.js +130 -16
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +130 -16
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +130 -16
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.ts +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ TypeScript SDK for requesting zero-knowledge proofs from the [ZKProofport](https
|
|
|
37
37
|
3. Your app shows the returned deep link as a QR code (desktop) or navigates to it (mobile)
|
|
38
38
|
4. The user opens the ZKProofport app, which generates the ZK proof on-device
|
|
39
39
|
5. The proof result flows back through the relay to your app over WebSocket (HTTP polling as fallback)
|
|
40
|
-
6. Your app verifies the proof on-chain
|
|
40
|
+
6. Your app verifies the proof on-chain, then reads its public inputs to confirm it answers the question that was asked
|
|
41
41
|
|
|
42
42
|
## Installation
|
|
43
43
|
|
|
@@ -269,9 +269,9 @@ Any ethers v5/v6 `Signer` is compatible.
|
|
|
269
269
|
|
|
270
270
|
#### About challenge-signature
|
|
271
271
|
|
|
272
|
-
The challenge-signature mechanism
|
|
272
|
+
The challenge-signature mechanism exists **for replay prevention**. Each challenge is one-time use and is consumed immediately. The relay recovers the signer's address from the signature and logs it; the address is not fed into the circuit, and it does not have to be the address that holds the attestation — the mobile app decides that, from `userAddress` or from the wallet the user connects.
|
|
273
273
|
|
|
274
|
-
|
|
274
|
+
So for server-side or headless environments an ephemeral random wallet is fine. A persistent wallet (fixed private key) is **not recommended**: it adds key management for no functional benefit.
|
|
275
275
|
|
|
276
276
|
```typescript
|
|
277
277
|
import { Wallet } from 'ethers';
|
|
@@ -292,7 +292,7 @@ const relay = await sdk.createRelayRequest('coinbase_attestation', {
|
|
|
292
292
|
dappIcon: 'https://myapp.com/icon.png',
|
|
293
293
|
message: 'Verify your identity to continue',
|
|
294
294
|
nonce: 'unique-nonce-123', // Optional: replay prevention
|
|
295
|
-
returnScheme: 'mydapp://', // Optional:
|
|
295
|
+
returnScheme: 'mydapp://', // Optional: native apps only, see below
|
|
296
296
|
});
|
|
297
297
|
|
|
298
298
|
// relay.requestId — Relay-issued UUID
|
|
@@ -303,54 +303,78 @@ const relay = await sdk.createRelayRequest('coinbase_attestation', {
|
|
|
303
303
|
|
|
304
304
|
Every field of the third argument is optional: `message`, `dappName` and `dappIcon` are shown to the user in the ZKProofport app, `nonce` is a value of your own choosing that the relay refuses to accept twice, and `returnScheme` is described next.
|
|
305
305
|
|
|
306
|
-
#### Switching back
|
|
306
|
+
#### Switching back: `returnScheme`
|
|
307
307
|
|
|
308
|
-
|
|
308
|
+
When ZKProofport finishes a proof, the user is sitting in the ZKProofport app. `returnScheme` answers one question: **which app should come back to the foreground?**
|
|
309
|
+
|
|
310
|
+
It names an app. It is not a URL, not a return address, and the proof is never sent to it — the proof still reaches you exactly as before, through `waitForProof()` / `waitForResult()`. This is the same handoff a wallet performs after a signature (`redirect.native`).
|
|
311
|
+
|
|
312
|
+
**One form is accepted:**
|
|
313
|
+
|
|
314
|
+
| Form | Example |
|
|
315
|
+
|------|---------|
|
|
316
|
+
| Bare custom scheme | `'mydapp://'` — an RFC 3986 scheme followed by exactly `://` |
|
|
317
|
+
|
|
318
|
+
**If you are a native app**, pass the scheme your app registers:
|
|
309
319
|
|
|
310
320
|
```typescript
|
|
311
|
-
// Native app: your own registered scheme
|
|
312
321
|
await sdk.createRelayRequest('coinbase_attestation', { scope: 'myapp.com' }, {
|
|
313
322
|
returnScheme: 'mydapp://',
|
|
314
323
|
});
|
|
315
|
-
|
|
316
|
-
// Web app: your own origin
|
|
317
|
-
await sdk.createRelayRequest('coinbase_attestation', { scope: 'myapp.com' }, {
|
|
318
|
-
returnScheme: 'https://myapp.com',
|
|
319
|
-
});
|
|
320
324
|
```
|
|
321
325
|
|
|
322
|
-
**
|
|
326
|
+
**If you are a web page, pass nothing.** You have no app to come back to, so there is no value you can honestly put here — and the SDK handles the browser case for you:
|
|
323
327
|
|
|
324
328
|
```typescript
|
|
325
|
-
|
|
326
|
-
|
|
329
|
+
// Correct. Do not pass returnScheme from a web page.
|
|
330
|
+
await sdk.createRelayRequest('coinbase_attestation', { scope: 'myapp.com' });
|
|
327
331
|
```
|
|
328
332
|
|
|
329
|
-
|
|
333
|
+
##### What the SDK does automatically
|
|
334
|
+
|
|
335
|
+
With the option omitted, the SDK looks at the page's own user agent and decides:
|
|
336
|
+
|
|
337
|
+
| Where your page is running | What the SDK sends | What the user sees when the proof is done |
|
|
338
|
+
|---|---|---|
|
|
339
|
+
| **Chrome for iOS** (`CriOS`) | `googlechrome://` | Chrome comes forward on **the tab they were already reading** — no new tab, no reload, page state intact |
|
|
340
|
+
| **Firefox for iOS** (`FxiOS`) | `firefox://` | Same: Firefox comes forward on the tab they were already reading |
|
|
341
|
+
| **Android**, any browser | nothing | The ZKProofport app puts itself in the background and the browser resumes exactly as it was |
|
|
342
|
+
| **Safari, Brave, Arc, Edge, Opera on iOS** | nothing | ZKProofport tells them the proof was delivered and to switch back; the system "‹ Back" breadcrumb is still at the top left |
|
|
343
|
+
| **In-app webviews** (KakaoTalk, Naver, Line, Instagram) | nothing | Same notice. Any scheme would destroy the page they are on |
|
|
344
|
+
| **Desktop** | nothing | This is the QR flow: the proof happens on a different device, and your page updates over the relay socket |
|
|
345
|
+
|
|
346
|
+
Only Chrome and Firefox are auto-detected, each from a positive UA token, because they are the two iOS browsers whose bare scheme is **verified in their own source** to bring the app forward without navigating anywhere. There is no Safari equivalent. Brave and Arc are indistinguishable from Safari on iOS, so guessing would eject those users into a browser they were not using — worse than doing nothing. Edge and Opera are closed source and stay out until someone can show their bare schemes behave the same way.
|
|
347
|
+
|
|
348
|
+
An explicit `returnScheme` always wins; the SDK never overrides a value you set.
|
|
349
|
+
|
|
350
|
+
##### `https://` origins are not accepted
|
|
351
|
+
|
|
352
|
+
An https origin such as `'https://myapp.com'` used to be allowed. **It is now rejected** by the SDK, the relay and the ZKProofport app alike.
|
|
330
353
|
|
|
331
|
-
|
|
354
|
+
Opening one does not return anybody anywhere. The OS hands the URL to the browser, and the browser opens a **new tab** on a freshly loaded page — the tab your user actually started in is left behind, along with everything in it, including the socket waiting for the proof you just generated. The round trip the field exists to complete was the thing it broke.
|
|
332
355
|
|
|
333
|
-
|
|
334
|
-
|------|---------|-------|
|
|
335
|
-
| Bare custom scheme | `'mydapp://'` | An RFC 3986 scheme followed by exactly `://` |
|
|
336
|
-
| https origin | `'https://myapp.com'`, `'https://myapp.com:8443'` | Host (and optional port) only |
|
|
356
|
+
##### What is rejected
|
|
337
357
|
|
|
338
|
-
|
|
358
|
+
These throw before the SDK makes a single network call, so no challenge is consumed and nothing is wasted on a typo:
|
|
339
359
|
|
|
340
|
-
-
|
|
360
|
+
- **https and http origins** — `'https://myapp.com'`, `'https://myapp.com:8443'` (see above)
|
|
361
|
+
- **bare `https://` and `http://`** — a browser with no page to open is not a return target
|
|
362
|
+
- a host, path, query string or fragment — `'mydapp://transfer?to=0x...'` — which is what stops a request driving your app to a specific action rather than just opening it
|
|
341
363
|
- an empty or whitespace-containing value, or one longer than 128 characters
|
|
342
|
-
-
|
|
343
|
-
-
|
|
364
|
+
- non-ASCII schemes (use punycode if you somehow need one)
|
|
365
|
+
- schemes the OS treats specially: `about`, `blob`, `content`, `data`, `facetime`, `facetime-audio`, `file`, `ftp`, `intent`, `javascript`, `jar`, `mailto`, `sms`, `tel`, `vbscript`
|
|
344
366
|
|
|
345
367
|
The value is matched case-insensitively and stored lowercased by the relay.
|
|
346
368
|
|
|
347
|
-
|
|
369
|
+
##### When nothing happens
|
|
370
|
+
|
|
371
|
+
There is no auto-switch when proof generation **fails** — the error has to be read in the ZKProofport app first. And when no target is sent and none can be inferred, ZKProofport shows the user a notice saying the proof was delivered and to switch back themselves. That is the intended outcome on iOS outside Chrome, not a bug.
|
|
348
372
|
|
|
349
373
|
**OIDC Domain Attestation:**
|
|
350
374
|
|
|
351
375
|
```typescript
|
|
352
376
|
// Email domain verification
|
|
353
|
-
const
|
|
377
|
+
const relayDomain = await sdk.createRelayRequest('oidc_domain_attestation', {
|
|
354
378
|
domain: 'company.com',
|
|
355
379
|
scope: 'myapp.com',
|
|
356
380
|
}, {
|
|
@@ -360,7 +384,7 @@ const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
|
|
|
360
384
|
});
|
|
361
385
|
|
|
362
386
|
// Organization membership verification (Google Workspace)
|
|
363
|
-
const
|
|
387
|
+
const relayGoogle = await sdk.createRelayRequest('oidc_domain_attestation', {
|
|
364
388
|
domain: 'company.com',
|
|
365
389
|
scope: 'myapp.com',
|
|
366
390
|
provider: 'google',
|
|
@@ -370,7 +394,7 @@ const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
|
|
|
370
394
|
});
|
|
371
395
|
|
|
372
396
|
// Organization membership verification (Microsoft 365)
|
|
373
|
-
const
|
|
397
|
+
const relayMicrosoft = await sdk.createRelayRequest('oidc_domain_attestation', {
|
|
374
398
|
domain: 'company.com',
|
|
375
399
|
scope: 'myapp.com',
|
|
376
400
|
provider: 'microsoft',
|
|
@@ -390,12 +414,12 @@ Generate a QR code from the relay deep link for the user to scan with the ZKProo
|
|
|
390
414
|
const qrDataUrl = await sdk.generateQRCode(relay.deepLink, {
|
|
391
415
|
width: 400, // pixels (default: 300)
|
|
392
416
|
darkColor: '#1a1a1a',
|
|
393
|
-
margin: 4,
|
|
417
|
+
margin: 4, // quiet zone in modules (default: 2)
|
|
394
418
|
});
|
|
395
419
|
(document.getElementById('qr') as HTMLImageElement).src = qrDataUrl;
|
|
396
420
|
```
|
|
397
421
|
|
|
398
|
-
`QRCodeOptions` accepts `width
|
|
422
|
+
`QRCodeOptions` accepts `width` (default `300`), `margin` (default `2`), `darkColor` (default `'#000000'`), `lightColor` (default `'#ffffff'`) and `errorCorrectionLevel` (`'L' | 'M' | 'Q' | 'H'`, default `'M'`).
|
|
399
423
|
|
|
400
424
|
**Other QR formats:**
|
|
401
425
|
|
|
@@ -410,6 +434,8 @@ await sdk.renderQRCodeToCanvas(canvasElement, relay.deepLink, { width: 400 });
|
|
|
410
434
|
const { size, withinLimit } = sdk.checkQRCodeSize(relay.deepLink);
|
|
411
435
|
```
|
|
412
436
|
|
|
437
|
+
All three generators throw `QR code data too large (… bytes). Maximum is 2953 bytes.` rather than emitting an unscannable image. Relay deep links are far below that; the check matters if you build your own link.
|
|
438
|
+
|
|
413
439
|
**Mobile:** On mobile browsers, redirect directly to the deep link instead of showing a QR code:
|
|
414
440
|
|
|
415
441
|
```typescript
|
|
@@ -431,7 +457,7 @@ const result = await sdk.waitForProof(relay.requestId, {
|
|
|
431
457
|
});
|
|
432
458
|
```
|
|
433
459
|
|
|
434
|
-
`
|
|
460
|
+
`waitForProof` settles once the request reaches a terminal state. Step 6 walks through the object it hands back, field by field, and through the states that are not terminal.
|
|
435
461
|
|
|
436
462
|
**Alternative: Subscribe to real-time updates directly:**
|
|
437
463
|
|
|
@@ -464,11 +490,119 @@ const result = await sdk.waitForResult(relay.requestId, {
|
|
|
464
490
|
|
|
465
491
|
When you are done, `sdk.disconnect()` closes any open socket.
|
|
466
492
|
|
|
467
|
-
### Step 6:
|
|
493
|
+
### Step 6: Read the Result
|
|
494
|
+
|
|
495
|
+
`waitForProof()`, `waitForResult()` and `pollResult()` all hand back the same object, a `RelayProofResult`. A completed one looks like this (long values elided):
|
|
496
|
+
|
|
497
|
+
```typescript
|
|
498
|
+
{
|
|
499
|
+
requestId: '9f1c8a02-4e2b-4f0a-9c3e-1d5a7b0e2f44',
|
|
500
|
+
status: 'completed',
|
|
501
|
+
circuit: 'coinbase_attestation',
|
|
502
|
+
proof: '0x1f8b0a…', // proof bytes, opaque
|
|
503
|
+
publicInputs: ['0x00…2f', '0x00…a1', …], // ordered — 128 entries for this circuit
|
|
504
|
+
verifierAddress: '0x…', // contract that can check this proof
|
|
505
|
+
chainId: …, // network that contract lives on
|
|
506
|
+
deepLink: 'zkproofport://proof-request?data=…',
|
|
507
|
+
createdAt: '2026-02-07T09:14:02.101Z',
|
|
508
|
+
updatedAt: '2026-02-07T09:14:48.902Z',
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
| Field | Type | Present when | What it is |
|
|
513
|
+
|-------|------|--------------|------------|
|
|
514
|
+
| `requestId` | `string` | always | The relay-issued id you waited on. Echoed back so one handler can serve many requests. |
|
|
515
|
+
| `status` | `string` | always | Lifecycle state — see below. |
|
|
516
|
+
| `circuit` | `string` | terminal results | Which circuit produced the proof, as reported by the mobile app. You need it to read `publicInputs`, because the layout is per circuit. Undefined while the request is still pending. |
|
|
517
|
+
| `proof` | `string` | `'completed'` | The proof bytes as a `0x` hex string. Opaque: the only thing to do with it is hand it to a verifier. |
|
|
518
|
+
| `publicInputs` | `string[]` | `'completed'` | The circuit's public inputs **in circuit order**. This is the part that carries meaning — what each index holds is in [Public Input Layout Constants](#public-input-layout-constants). |
|
|
519
|
+
| `verifierAddress` | `string` | `'completed'` | Address of the Solidity verifier contract that can check this exact proof. It comes from the mobile app — forward it, do not hardcode one. |
|
|
520
|
+
| `chainId` | `number` | `'completed'` | The chain that `verifierAddress` lives on. Same rule: forward it. |
|
|
521
|
+
| `error` | `string` | failures | Human-readable reason from the mobile app or the relay. |
|
|
522
|
+
| `deepLink` | `string` | polling path only | The deep link the request was created with. |
|
|
523
|
+
| `createdAt`, `updatedAt` | `string` | polling path only | ISO timestamps of creation and last update. |
|
|
524
|
+
|
|
525
|
+
The last three come from the relay's polling endpoint. `waitForProof()` normally resolves over the WebSocket, whose payload does not carry them, so do not build logic on their presence.
|
|
526
|
+
|
|
527
|
+
#### What `status` can be
|
|
528
|
+
|
|
529
|
+
| `status` | Terminal | What happened | What you do |
|
|
530
|
+
|----------|----------|---------------|-------------|
|
|
531
|
+
| `'pending'` | no | The request exists; no proof yet | Keep waiting — `waitForProof()` does that for you |
|
|
532
|
+
| `'completed'` | yes | A proof came back | `proof`, `publicInputs`, `verifierAddress` and `chainId` are set. Verify it (Step 7) before trusting anything in it |
|
|
533
|
+
| `'error'` | yes | The app could not produce a proof — no attestation on that address, sign-in refused, generation failed | Read `error`, show it, let the user start over |
|
|
534
|
+
| `'failed'` | yes | Same meaning and same handling as `'error'` | Handle the pair; the type declares `'failed'`, the app sends `'error'` |
|
|
535
|
+
| `'cancelled'` | see below | The user declined the request in the ZKProofport app | Reset your UI |
|
|
536
|
+
|
|
537
|
+
`RelayProofResult` declares `status` as `'pending' | 'completed' | 'failed'`, which is narrower than what the relay can deliver, so TypeScript rejects a direct comparison against `'error'`. Widen it once and branch on the string:
|
|
538
|
+
|
|
539
|
+
```typescript
|
|
540
|
+
const status: string = result.status;
|
|
541
|
+
|
|
542
|
+
if (status === 'completed') {
|
|
543
|
+
// verify it
|
|
544
|
+
} else if (status === 'error' || status === 'failed') {
|
|
545
|
+
showError(result.error ?? 'Proof generation failed');
|
|
546
|
+
}
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
`waitForProof()` settles on `'completed'`, `'error'` and `'failed'`. It does **not** settle on `'cancelled'` — a cancellation arrives as a status update, and if you ignore it the call sits there until `timeoutMs` expires. Watch for it if you want to drop the QR dialog the moment the user says no:
|
|
550
|
+
|
|
551
|
+
```typescript
|
|
552
|
+
const result = await sdk.waitForProof(relay.requestId, {
|
|
553
|
+
onStatusChange: (update) => {
|
|
554
|
+
if (update.status === 'cancelled') {
|
|
555
|
+
// The user declined in the app. This promise will not settle on its own.
|
|
556
|
+
hideQrDialog();
|
|
557
|
+
}
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
`waitForResult()`, the polling path, is narrower still: it returns only on `'completed'` and `'failed'`, and keeps polling through `'error'` and `'cancelled'` until `timeoutMs`. Prefer `waitForProof()`, or drive `pollResult()` in a loop of your own if you want to decide what counts as terminal.
|
|
563
|
+
|
|
564
|
+
#### What is not on the result
|
|
565
|
+
|
|
566
|
+
- **No `nullifier` field.** The nullifier is inside `publicInputs`. Read it with `sdk.extractNullifier()` — Step 8.
|
|
567
|
+
- **No `numPublicInputs` field.** It is `publicInputs.length`.
|
|
568
|
+
- **A `'completed'` result can arrive with `proof` and `publicInputs` missing.** That means the relay's buffered copy of the result has expired — results are kept for a short window only. There is nothing left to fetch for that `requestId`; the user has to make a new request.
|
|
569
|
+
|
|
570
|
+
#### Retry, or show the user?
|
|
571
|
+
|
|
572
|
+
| Situation | Retry automatically | Show the user |
|
|
573
|
+
|-----------|--------------------|----------------|
|
|
574
|
+
| `createRelayRequest()` throws `Too many requests. Please try again later.` | Yes, with backoff | No |
|
|
575
|
+
| Network failure reaching the relay | Yes | No |
|
|
576
|
+
| `pollResult()` throws `Request not found or expired` | No — that id is gone | Yes, ask them to start again |
|
|
577
|
+
| `status: 'error'` / `'failed'` | No — a retry re-runs the same failing thing | Yes: `error` says what went wrong, and it is usually the user's move (connect the right wallet, sign in, use the device holding the licence) |
|
|
578
|
+
| `status: 'cancelled'` | No | Nothing to say — they just declined |
|
|
579
|
+
| `waitForProof()` timeout | Only if the user asks for it | Yes |
|
|
580
|
+
| `verifyResponseOnChain()` returns `{ valid: false }` | No | Treat it as a rejected proof, not an outage — Step 7 |
|
|
581
|
+
|
|
582
|
+
#### `ProofResponse` — the shape on-chain verification takes
|
|
583
|
+
|
|
584
|
+
`verifyResponseOnChain()` does not take the relay result directly. It takes a `ProofResponse`, which you build from it:
|
|
585
|
+
|
|
586
|
+
| `ProofResponse` field | Type | Required | Where it comes from |
|
|
587
|
+
|-----------------------|------|----------|---------------------|
|
|
588
|
+
| `requestId` | `string` | Yes | `result.requestId` |
|
|
589
|
+
| `circuit` | `CircuitType` | Yes | `result.circuit`, narrowed — the relay types it as a plain `string` |
|
|
590
|
+
| `status` | `'pending' \| 'completed' \| 'error' \| 'cancelled'` | Yes | `'completed'`. Anything else and verification returns `{ valid: false, error: 'Invalid or incomplete response' }` without touching the chain. Note the relay says `'failed'` where this type says `'error'` |
|
|
591
|
+
| `proof` | `string` | When completed | `result.proof` |
|
|
592
|
+
| `publicInputs` | `string[]` | When completed | `result.publicInputs` |
|
|
593
|
+
| `verifierAddress` | `string` | When completed | `result.verifierAddress`. Without it there is no contract to call and verification fails with `No verifier address provided…` |
|
|
594
|
+
| `chainId` | `number` | When completed | `result.chainId`. Used to pick a network when you pass no provider of your own |
|
|
595
|
+
| `numPublicInputs` | `number` | No | Never delivered by the relay, and never read during verification — `publicInputs.length` is used instead. Omit it |
|
|
596
|
+
| `timestamp` | `number` | No | Not delivered by the relay. Omit it |
|
|
597
|
+
| `error` | `string` | On failure | `result.error`. Carried for your own logging; verification ignores it |
|
|
598
|
+
|
|
599
|
+
There is no `nullifier` field here either.
|
|
600
|
+
|
|
601
|
+
### Step 7: Verify On-Chain
|
|
468
602
|
|
|
469
603
|
Verify the proof cryptographically by calling the deployed Solidity verifier contract.
|
|
470
604
|
|
|
471
|
-
The relay result tells you which
|
|
605
|
+
The relay result tells you which contract to call (`verifierAddress`) and on which chain (`chainId`), so verification goes through `verifyResponseOnChain`, which reads both off the response:
|
|
472
606
|
|
|
473
607
|
```typescript
|
|
474
608
|
import type { CircuitType, ProofResponse } from '@zkproofport-app/sdk';
|
|
@@ -500,13 +634,17 @@ The SDK connects to the right network on its own. Pass your own ethers `Provider
|
|
|
500
634
|
const verification = await sdk.verifyResponseOnChain(response, myProvider);
|
|
501
635
|
```
|
|
502
636
|
|
|
503
|
-
`verifyResponseOnChain`
|
|
637
|
+
`verifyResponseOnChain` reports failures instead of throwing them. An incomplete response (`status` other than `'completed'`, or a missing `proof` / `publicInputs`) returns `{ valid: false, error: 'Invalid or incomplete response' }` without making a network call; a missing `verifierAddress` returns `{ valid: false, error: 'No verifier address provided…' }`; and a reverting contract call returns `{ valid: false, error }` carrying the revert message. It does throw in one case: a response carrying a `chainId` the SDK has no built-in endpoint for, when you passed no provider of your own. Pass your own provider if you ever hit that.
|
|
638
|
+
|
|
639
|
+
`valid: true` means the verifier contract accepted the proof: it is a real proof for that circuit, and its public inputs are the ones it was proved against. It does **not** yet mean the proof answers *your* question — that it was bound to your scope, or that the domain or country in it is the one you asked for. Those live in the public inputs, and reading them is Step 8. `valid: false` is a rejected proof, not an outage: do not retry it, refuse the user.
|
|
504
640
|
|
|
505
|
-
> `sdk.verifyOnChain(circuit, proof, publicInputs, providerOrSigner?)` takes the same proof in raw pieces
|
|
641
|
+
> **Why not `verifyOnChain`?** `sdk.verifyOnChain(circuit, proof, publicInputs, providerOrSigner?)` takes the same proof in raw pieces. Those pieces carry no verifier address, and it never looks at a response for one — it consults only the `verifiers` map the SDK was constructed with, which `ProofportSDK.create()` leaves empty. Out of the box it therefore returns `{ valid: false, error: 'No verifier address provided. Configure via SDK or ensure proof response includes verifierAddress.' }`. Use `verifyResponseOnChain`.
|
|
506
642
|
|
|
507
|
-
### Step
|
|
643
|
+
### Step 8: Extract Scope, Nullifier, and Domain
|
|
508
644
|
|
|
509
|
-
|
|
645
|
+
A verified proof tells you the public inputs are genuine. It does not tell you what they say. Reading them is a separate step, and it is the step that decides whether the proof answers the question you asked.
|
|
646
|
+
|
|
647
|
+
Do it **after** `verifyResponseOnChain()` returns `valid: true`. These helpers are plain parsers — they will happily decode an array that no verifier has ever seen.
|
|
510
648
|
|
|
511
649
|
```typescript
|
|
512
650
|
import type { CircuitType } from '@zkproofport-app/sdk';
|
|
@@ -532,19 +670,65 @@ if (result.status === 'completed' && result.publicInputs && result.circuit) {
|
|
|
532
670
|
}
|
|
533
671
|
```
|
|
534
672
|
|
|
535
|
-
All three return `null` rather than throwing when the public inputs are too short for the circuit's layout.
|
|
673
|
+
All three return `null` rather than throwing when the public inputs are too short for the circuit's layout. Pass the wrong circuit id and you get no error at all, just the wrong 32 entries decoded into a plausible-looking value — always pass the `circuit` the result came with.
|
|
674
|
+
|
|
675
|
+
#### Check the scope
|
|
676
|
+
|
|
677
|
+
Every circuit publishes `scope` as `keccak256` of the scope string you sent. Comparing it is how you find out that the proof was made for *your* request and not replayed from someone else's app:
|
|
678
|
+
|
|
679
|
+
```typescript
|
|
680
|
+
import { keccak256, toUtf8Bytes } from 'ethers';
|
|
681
|
+
|
|
682
|
+
const expectedScope = keccak256(toUtf8Bytes('myapp.com'));
|
|
683
|
+
|
|
684
|
+
if (sdk.extractScope(publicInputs, circuit) !== expectedScope) {
|
|
685
|
+
throw new Error('Proof was generated for a different scope');
|
|
686
|
+
}
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
#### Use the nullifier
|
|
690
|
+
|
|
691
|
+
The nullifier is a deterministic hash over the user's credential and the scope:
|
|
692
|
+
|
|
693
|
+
- **Deterministic** — same user, same scope, same nullifier, every time. That is what makes duplicate detection possible.
|
|
694
|
+
- **Privacy-preserving** — the wallet address (Coinbase), the email (OIDC) and the licence (mDL) stay hidden. There is no way back from the nullifier to the person.
|
|
695
|
+
- **Scope-bound** — the same user proving to a different scope produces an unrelated nullifier, so your users cannot be correlated across apps.
|
|
696
|
+
|
|
697
|
+
On your side, it is the primary key of a verified user. Store it once verification succeeds, with a unique constraint, and check it before granting anything:
|
|
698
|
+
|
|
699
|
+
```typescript
|
|
700
|
+
const nullifier = sdk.extractNullifier(publicInputs, circuit);
|
|
701
|
+
if (!nullifier) throw new Error('Public inputs too short for this circuit');
|
|
702
|
+
|
|
703
|
+
// One claim per user, forever: the insert is the double-proof check.
|
|
704
|
+
const inserted = await db.verifiedUsers.insertIfAbsent({
|
|
705
|
+
nullifier, // '0xabc123…'
|
|
706
|
+
circuit,
|
|
707
|
+
claimedAt: new Date(),
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
if (!inserted) {
|
|
711
|
+
// This credential has already been used for this scope.
|
|
712
|
+
throw new Error('Already claimed');
|
|
713
|
+
}
|
|
714
|
+
```
|
|
536
715
|
|
|
537
|
-
|
|
538
|
-
- Deterministic: same user + same scope = same nullifier (enables duplicate detection)
|
|
539
|
-
- Privacy-preserving: the wallet address (Coinbase), the email (OIDC) or the license (mDL) is never revealed
|
|
540
|
-
- Scope-bound: different scopes produce different nullifiers for the same user
|
|
716
|
+
Keep the scope you used fixed for the lifetime of that record. Change the scope string and every returning user looks brand new, because every nullifier changes with it.
|
|
541
717
|
|
|
542
718
|
> **OIDC Domain:** The nullifier is a hash of the user's email and scope. The same email + scope always produces the same nullifier, enabling Sybil resistance without revealing the email address.
|
|
543
719
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
720
|
+
#### Check the domain
|
|
721
|
+
|
|
722
|
+
`extractDomain()` (OIDC Domain Attestation only) returns the email domain the proof was made for, decoded from the public inputs — up to 64 ASCII characters. It returns `null` for every other circuit.
|
|
723
|
+
|
|
724
|
+
The circuit binds it to the signed token: the part of the email after `@` has to equal this domain, exactly, to the end of the address. What the circuit does not decide is *which* domain — the prover picks it. So compare it with what you asked for:
|
|
725
|
+
|
|
726
|
+
```typescript
|
|
727
|
+
const domain = sdk.extractDomain(publicInputs, 'oidc_domain_attestation');
|
|
728
|
+
if (domain !== 'company.com') {
|
|
729
|
+
throw new Error(`Proof is for ${domain}, not company.com`);
|
|
730
|
+
}
|
|
731
|
+
```
|
|
548
732
|
|
|
549
733
|
**Standalone utility functions** are also available for use outside the SDK class:
|
|
550
734
|
|
|
@@ -581,7 +765,9 @@ End-to-end integration using the relay flow:
|
|
|
581
765
|
```typescript
|
|
582
766
|
import { ProofportSDK } from '@zkproofport-app/sdk';
|
|
583
767
|
import type { CircuitType, ProofResponse } from '@zkproofport-app/sdk';
|
|
584
|
-
import { BrowserProvider } from 'ethers';
|
|
768
|
+
import { BrowserProvider, keccak256, toUtf8Bytes } from 'ethers';
|
|
769
|
+
|
|
770
|
+
const SCOPE = 'myapp.com';
|
|
585
771
|
|
|
586
772
|
async function verifyUser() {
|
|
587
773
|
// Initialize
|
|
@@ -594,7 +780,7 @@ async function verifyUser() {
|
|
|
594
780
|
|
|
595
781
|
// Create proof request via relay
|
|
596
782
|
const relay = await sdk.createRelayRequest('coinbase_attestation', {
|
|
597
|
-
scope:
|
|
783
|
+
scope: SCOPE,
|
|
598
784
|
}, {
|
|
599
785
|
dappName: 'My DApp',
|
|
600
786
|
message: 'Verify your identity',
|
|
@@ -613,25 +799,40 @@ async function verifyUser() {
|
|
|
613
799
|
});
|
|
614
800
|
|
|
615
801
|
if (result.status === 'completed') {
|
|
802
|
+
const circuit = result.circuit as CircuitType;
|
|
803
|
+
const publicInputs = result.publicInputs ?? [];
|
|
804
|
+
|
|
616
805
|
// Verify on-chain
|
|
617
806
|
const response: ProofResponse = {
|
|
618
807
|
requestId: result.requestId,
|
|
619
|
-
circuit
|
|
808
|
+
circuit,
|
|
620
809
|
status: 'completed',
|
|
621
810
|
proof: result.proof,
|
|
622
|
-
publicInputs
|
|
811
|
+
publicInputs,
|
|
623
812
|
verifierAddress: result.verifierAddress,
|
|
624
813
|
chainId: result.chainId,
|
|
625
814
|
};
|
|
626
815
|
|
|
627
816
|
const verification = await sdk.verifyResponseOnChain(response);
|
|
628
817
|
|
|
629
|
-
if (verification.valid) {
|
|
630
|
-
document.getElementById('status')!.textContent = 'Identity verified!';
|
|
631
|
-
// Grant access to your application
|
|
632
|
-
} else {
|
|
818
|
+
if (!verification.valid) {
|
|
633
819
|
document.getElementById('status')!.textContent = `Invalid proof: ${verification.error}`;
|
|
820
|
+
sdk.disconnect();
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// A real proof is not yet a proof of what you asked: check the scope.
|
|
825
|
+
if (sdk.extractScope(publicInputs, circuit) !== keccak256(toUtf8Bytes(SCOPE))) {
|
|
826
|
+
document.getElementById('status')!.textContent = 'Proof was made for another scope';
|
|
827
|
+
sdk.disconnect();
|
|
828
|
+
return;
|
|
634
829
|
}
|
|
830
|
+
|
|
831
|
+
// Key the user on the nullifier — same person + same scope, same value
|
|
832
|
+
const nullifier = sdk.extractNullifier(publicInputs, circuit);
|
|
833
|
+
document.getElementById('status')!.textContent = 'Identity verified!';
|
|
834
|
+
console.log('Verified user:', nullifier);
|
|
835
|
+
// Grant access to your application
|
|
635
836
|
} else {
|
|
636
837
|
document.getElementById('status')!.textContent = `Failed: ${result.error}`;
|
|
637
838
|
}
|
|
@@ -643,7 +844,7 @@ async function verifyUser() {
|
|
|
643
844
|
|
|
644
845
|
## Configuration
|
|
645
846
|
|
|
646
|
-
`ProofportSDK.create()` returns a fully configured SDK instance. No manual configuration is needed
|
|
847
|
+
`ProofportSDK.create()` returns a fully configured SDK instance. No manual configuration is needed: the relay endpoint is built in, and the verifier contract and network to check a proof against arrive with the proof itself (`verifierAddress`, `chainId`).
|
|
647
848
|
|
|
648
849
|
## Types Reference
|
|
649
850
|
|
|
@@ -683,15 +884,15 @@ import type {
|
|
|
683
884
|
| `MdlKrAgeInputs` | Inputs for `mdl_kr_age`: `{ scope, ageThreshold }` |
|
|
684
885
|
| `MdlKrRegionInputs` | Inputs for `mdl_kr_region`: `{ scope, targetRegion }` |
|
|
685
886
|
| `CircuitInputs` | Union of every input type above (plus an empty-input form for circuits that need nothing from the dApp) |
|
|
686
|
-
| `ProofRequest` | Request object: `requestId`, `circuit`, `inputs`, `createdAt`, plus optional `message`, `dappName`, `dappIcon`, `returnScheme
|
|
687
|
-
| `ProofResponse` |
|
|
887
|
+
| `ProofRequest` | Request object: `requestId`, `circuit`, `inputs`, `createdAt`, plus optional `message`, `dappName`, `dappIcon`, `returnScheme` (a bare scheme such as `mydapp://`), `expiresAt` |
|
|
888
|
+
| `ProofResponse` | What `verifyResponseOnChain()` takes: `requestId`, `circuit`, `status`, and — when completed — `proof`, `publicInputs`, `verifierAddress`, `chainId`; `error` when it failed. `numPublicInputs` and `timestamp` also exist but the relay never fills them, so omit them. Field by field in [Step 6](#step-6-read-the-result) |
|
|
688
889
|
| `QRCodeOptions` | QR customization: `width`, `margin`, `darkColor`, `lightColor`, `errorCorrectionLevel` |
|
|
689
890
|
| `VerifierContract` | Verifier contract info: `{ address, chainId, abi }` |
|
|
690
891
|
| `ProofportConfig` | Constructor configuration — `ProofportSDK.create()` fills it in for you |
|
|
691
892
|
| `ChallengeResponse` | Challenge from the relay: `{ requestId, challenge, expiresAt }` |
|
|
692
893
|
| `WalletSigner` | Signer interface: `{ signMessage(message), getAddress() }` |
|
|
693
894
|
| `RelayProofRequest` | Result of `createRelayRequest()`: `{ requestId, deepLink, status, pollUrl }` |
|
|
694
|
-
| `RelayProofResult` | Result of `waitForProof()` / `waitForResult()` / `pollResult()`: `{ requestId, status
|
|
895
|
+
| `RelayProofResult` | Result of `waitForProof()` / `waitForResult()` / `pollResult()`: `{ requestId, status, deepLink?, createdAt?, updatedAt?, proof?, publicInputs?, verifierAddress?, chainId?, circuit?, error? }`. `status` is declared as `'pending' \| 'completed' \| 'failed'` but the relay also delivers `'error'` and `'cancelled'` — [Step 6](#step-6-read-the-result) |
|
|
695
896
|
|
|
696
897
|
The `OidcDomainInputs` interface:
|
|
697
898
|
|
|
@@ -703,11 +904,11 @@ interface OidcDomainInputs {
|
|
|
703
904
|
}
|
|
704
905
|
```
|
|
705
906
|
|
|
706
|
-
Note that `RelayProofResult` (what you get back from the relay) and `ProofResponse` (what `verifyResponseOnChain` takes) are different types: the relay result's `status` uses `'failed'`, and its `circuit` is a plain `string`. Step 6 shows the conversion.
|
|
907
|
+
Note that `RelayProofResult` (what you get back from the relay) and `ProofResponse` (what `verifyResponseOnChain` takes) are different types: the relay result's `status` uses `'failed'`, and its `circuit` is a plain `string`. Step 6 shows both shapes side by side and Step 7 does the conversion.
|
|
707
908
|
|
|
708
909
|
## Public Input Layout Constants
|
|
709
910
|
|
|
710
|
-
|
|
911
|
+
`publicInputs` is an **ordered array whose layout is fixed by the circuit**. Index 64 in one circuit has nothing to do with index 64 in another. Reading it with the wrong layout does not fail loudly — it returns a well-formed value that means something else, which is how a proof ends up verifying a claim nobody asked for. Prefer `sdk.extractScope()` / `extractNullifier()` / `extractDomain()`, and reach for these constants only for the fields those do not cover.
|
|
711
912
|
|
|
712
913
|
```typescript
|
|
713
914
|
import {
|
|
@@ -718,82 +919,86 @@ import {
|
|
|
718
919
|
} from '@zkproofport-app/sdk';
|
|
719
920
|
```
|
|
720
921
|
|
|
721
|
-
|
|
722
|
-
```typescript
|
|
723
|
-
COINBASE_ATTESTATION_PUBLIC_INPUT_LAYOUT = {
|
|
724
|
-
SIGNAL_HASH_START: 0, // Signal hash
|
|
725
|
-
SIGNAL_HASH_END: 31,
|
|
726
|
-
MERKLE_ROOT_START: 32, // Merkle root of the attestation signer list
|
|
727
|
-
MERKLE_ROOT_END: 63,
|
|
728
|
-
SCOPE_START: 64, // Scope value
|
|
729
|
-
SCOPE_END: 95,
|
|
730
|
-
NULLIFIER_START: 96, // Unique identifier per user+scope
|
|
731
|
-
NULLIFIER_END: 127,
|
|
732
|
-
}
|
|
733
|
-
```
|
|
922
|
+
Two rules hold for every table below:
|
|
734
923
|
|
|
735
|
-
**
|
|
736
|
-
|
|
737
|
-
COINBASE_COUNTRY_PUBLIC_INPUT_LAYOUT = {
|
|
738
|
-
SIGNAL_HASH_START: 0,
|
|
739
|
-
SIGNAL_HASH_END: 31,
|
|
740
|
-
MERKLE_ROOT_START: 32,
|
|
741
|
-
MERKLE_ROOT_END: 63,
|
|
742
|
-
COUNTRY_LIST_START: 64, // Packed country codes
|
|
743
|
-
COUNTRY_LIST_END: 83,
|
|
744
|
-
COUNTRY_LIST_LENGTH: 84, // Number of countries
|
|
745
|
-
IS_INCLUDED: 85, // Boolean: user in list or not
|
|
746
|
-
SCOPE_START: 86,
|
|
747
|
-
SCOPE_END: 117,
|
|
748
|
-
NULLIFIER_START: 118,
|
|
749
|
-
NULLIFIER_END: 149,
|
|
750
|
-
}
|
|
751
|
-
```
|
|
924
|
+
- **Every number is an index into `publicInputs`, and `_END` is inclusive.** Slice with `slice(START, END + 1)`.
|
|
925
|
+
- **One entry is one field element, not one 32-byte value.** A `bytes32` such as a scope or a nullifier is spread over 32 consecutive entries, each holding a single byte in its low byte: `publicInputs[64] === '0x00…2f'` is the byte `0x2f`. Rebuild it by concatenating those 32 low bytes. The one exception is the OIDC RSA modulus, whose 18 entries each hold a 128-bit limb.
|
|
752
926
|
|
|
753
|
-
**OIDC Domain Attestation** (148 fields):
|
|
754
927
|
```typescript
|
|
755
|
-
|
|
756
|
-
PUBKEY_MODULUS_START: 0, // RSA modulus limbs (JWT issuer key)
|
|
757
|
-
PUBKEY_MODULUS_END: 17,
|
|
758
|
-
DOMAIN_STORAGE_START: 18, // Domain bytes (up to 64 ASCII characters)
|
|
759
|
-
DOMAIN_STORAGE_END: 81,
|
|
760
|
-
DOMAIN_LEN: 82, // Domain string length
|
|
761
|
-
SCOPE_START: 83, // Scope value
|
|
762
|
-
SCOPE_END: 114,
|
|
763
|
-
NULLIFIER_START: 115, // Unique identifier per user+scope
|
|
764
|
-
NULLIFIER_END: 146,
|
|
765
|
-
PROVIDER: 147, // OIDC provider code (0=none, 1=Google, 2=Microsoft)
|
|
928
|
+
const L = COINBASE_ATTESTATION_PUBLIC_INPUT_LAYOUT;
|
|
766
929
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
930
|
+
// Rebuild a bytes32 value from 32 single-byte entries.
|
|
931
|
+
const toBytes32 = (fields: string[]): string =>
|
|
932
|
+
'0x' + fields.map((f) => (BigInt(f) & 0xffn).toString(16).padStart(2, '0')).join('');
|
|
933
|
+
|
|
934
|
+
const signalHash = toBytes32(publicInputs.slice(L.SIGNAL_HASH_START, L.SIGNAL_HASH_END + 1));
|
|
771
935
|
```
|
|
772
936
|
|
|
773
|
-
|
|
774
|
-
```typescript
|
|
775
|
-
MDL_KR_PUBLIC_INPUT_LAYOUT = {
|
|
776
|
-
SCOPE_START: 0, // Scope value
|
|
777
|
-
SCOPE_END: 31,
|
|
778
|
-
NULLIFIER_START: 32, // nullifier_value
|
|
779
|
-
NULLIFIER_END: 63,
|
|
937
|
+
### `coinbase_attestation` — 128 entries
|
|
780
938
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
939
|
+
| Index | Constants | Circuit field | What it is |
|
|
940
|
+
|-------|-----------|---------------|------------|
|
|
941
|
+
| 0–31 | `SIGNAL_HASH_START` / `_END` | `signal_hash` | `bytes32` anti-replay challenge for the request |
|
|
942
|
+
| 32–63 | `MERKLE_ROOT_START` / `_END` | `signer_list_merkle_root` | `bytes32` root of the Coinbase attestation-signer list the proof was checked against |
|
|
943
|
+
| 64–95 | `SCOPE_START` / `_END` | `scope` | `bytes32` — `keccak256` of your scope string. Compare it (Step 8) |
|
|
944
|
+
| 96–127 | `NULLIFIER_START` / `_END` | `nullifier` | `bytes32`, unique per user and scope |
|
|
785
945
|
|
|
786
|
-
|
|
787
|
-
AGE_THRESHOLD: 64,
|
|
788
|
-
AGE_CURRENT_YEAR: 65,
|
|
946
|
+
### `coinbase_country_attestation` — 150 entries
|
|
789
947
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
948
|
+
| Index | Constants | Circuit field | What it is |
|
|
949
|
+
|-------|-----------|---------------|------------|
|
|
950
|
+
| 0–31 | `SIGNAL_HASH_START` / `_END` | `signal_hash` | `bytes32` anti-replay challenge |
|
|
951
|
+
| 32–63 | `MERKLE_ROOT_START` / `_END` | `signer_list_merkle_root` | `bytes32` signer-list root |
|
|
952
|
+
| 64–83 | `COUNTRY_LIST_START` / `_END` | `country_list` | 10 slots of 2 ASCII bytes, one byte per entry — entries 64–65 are the first code, 66–67 the second, and so on, in the order you sent them. Only the first `country_list_length` slots are meaningful; the rest are zero |
|
|
953
|
+
| 84 | `COUNTRY_LIST_LENGTH` | `country_list_length` | How many of those 10 slots count |
|
|
954
|
+
| 85 | `IS_INCLUDED` | `is_included` | `1` = the user **is** in the list, `0` = the user is **not** |
|
|
955
|
+
| 86–117 | `SCOPE_START` / `_END` | `scope` | `bytes32` scope hash |
|
|
956
|
+
| 118–149 | `NULLIFIER_START` / `_END` | `nullifier` | `bytes32`, unique per user and scope |
|
|
957
|
+
|
|
958
|
+
The country list and the `is_included` flag are public inputs, which means the proof is only as strong as the list it was proved against. Read indices 64–85 back and confirm they are the list and the direction you asked for; a valid proof against a different list is still a valid proof.
|
|
795
959
|
|
|
796
|
-
|
|
960
|
+
### `oidc_domain_attestation` — 148 entries
|
|
961
|
+
|
|
962
|
+
| Index | Constants | Circuit field | What it is |
|
|
963
|
+
|-------|-----------|---------------|------------|
|
|
964
|
+
| 0–17 | `PUBKEY_MODULUS_START` / `_END` | `pubkey_modulus_limbs` | The RSA modulus of the OIDC issuer's signing key, as 18 × 128-bit limbs. Not single bytes |
|
|
965
|
+
| 18–81 | `DOMAIN_STORAGE_START` / `_END` | `domain` storage | The domain as ASCII, one byte per entry. Entries past the length at index 82 are zero |
|
|
966
|
+
| 82 | `DOMAIN_LEN` | `domain` length | How many of those 64 bytes are the domain. `extractDomain()` does this decoding for you |
|
|
967
|
+
| 83–114 | `SCOPE_START` / `_END` | `scope` | `bytes32` scope hash |
|
|
968
|
+
| 115–146 | `NULLIFIER_START` / `_END` | `nullifier` | `bytes32` over the user's email and the scope |
|
|
969
|
+
| 147 | `PROVIDER` | `provider` | `0` = Google, `1` = Microsoft. The circuit accepts no other value |
|
|
970
|
+
|
|
971
|
+
`DOMAIN_START` and `DOMAIN_END` are kept as deprecated aliases of `DOMAIN_STORAGE_START` and `DOMAIN_LEN`. Use the current names — the old pair reads as a range and is not one.
|
|
972
|
+
|
|
973
|
+
Index 147 tells you which issuer signed the token, not whether the account belongs to an organisation. Workspace and Microsoft 365 membership is checked on-device only when you set `provider` in the request; if you did not set it, do not read a membership guarantee out of this field.
|
|
974
|
+
|
|
975
|
+
### Korea Mobile ID (mDL) — one layout, three circuits
|
|
976
|
+
|
|
977
|
+
All three mDL circuits share the same first 64 entries and differ after that. Entries are single bytes, except `age_threshold` and `current_year`, which each hold their whole value in one entry.
|
|
978
|
+
|
|
979
|
+
| Index | Constants | Circuit field | Applies to |
|
|
980
|
+
|-------|-----------|---------------|------------|
|
|
981
|
+
| 0–31 | `SCOPE_START` / `_END` | `scope` | all three |
|
|
982
|
+
| 32–63 | `NULLIFIER_START` / `_END` | `nullifier_value` | all three |
|
|
983
|
+
| 64 | `OWNERSHIP_DISCLOSE_FLAGS` | `disclose_flags` | `mdl_kr_ownership` (97 entries) — the bitmask that was actually proved (`0x01` name, `0x02` birth, `0x04` sex, `0x08` phone) |
|
|
984
|
+
| 65–96 | `OWNERSHIP_OWNER_COMMIT_START` / `_END` | `owner_commit` | `mdl_kr_ownership` — `bytes32` commitment to the disclosed attributes. All zero when `disclose_flags` is `0`, which is the anonymous case |
|
|
985
|
+
| 64 | `AGE_THRESHOLD` | `age_threshold` | `mdl_kr_age` (66 entries) — the age the proof actually clears. Read it back; it is a public input, so confirm it is the threshold you asked for |
|
|
986
|
+
| 65 | `AGE_CURRENT_YEAR` | `current_year` | `mdl_kr_age` — the year the comparison was made in |
|
|
987
|
+
| 64–95 | `REGION_CODE_START` / `_END` | `region_code` | `mdl_kr_region` (96 entries) — `bytes32`, not a readable region name. See below |
|
|
988
|
+
|
|
989
|
+
`region_code` is `keccak256` over the region name, UTF-8 and zero-padded to 64 bytes. The circuit proves that the region on the licence hashes to this value, but the value itself is a public input — so recompute it from the region you asked for and compare, exactly as you do with the scope:
|
|
990
|
+
|
|
991
|
+
```typescript
|
|
992
|
+
import { keccak256 } from 'ethers';
|
|
993
|
+
|
|
994
|
+
const regionCodeFor = (region: string): string => {
|
|
995
|
+
const padded = new Uint8Array(64);
|
|
996
|
+
padded.set(new TextEncoder().encode(region));
|
|
997
|
+
return keccak256(padded);
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
// regionCodeFor('경기도') — compare against indices 64–95, rebuilt with toBytes32()
|
|
1001
|
+
```
|
|
797
1002
|
|
|
798
1003
|
## Error Handling
|
|
799
1004
|
|
|
@@ -815,12 +1020,12 @@ try {
|
|
|
815
1020
|
|
|
816
1021
|
try {
|
|
817
1022
|
await sdk.createRelayRequest('coinbase_attestation', { scope: 'app.com' }, {
|
|
818
|
-
returnScheme: 'https://myapp.com
|
|
1023
|
+
returnScheme: 'https://myapp.com',
|
|
819
1024
|
});
|
|
820
1025
|
} catch (err) {
|
|
821
|
-
// "returnScheme must be a bare custom scheme such as "mydapp://"
|
|
822
|
-
//
|
|
823
|
-
// not
|
|
1026
|
+
// "returnScheme must be a bare custom scheme such as "mydapp://" — hosts,
|
|
1027
|
+
// paths, query strings and fragments are not accepted, and an https URL is
|
|
1028
|
+
// not a return target" (thrown before any network call)
|
|
824
1029
|
}
|
|
825
1030
|
|
|
826
1031
|
try {
|
|
@@ -834,6 +1039,8 @@ A relay rejection surfaces as its message, e.g. `Duplicate nonce (replay detecte
|
|
|
834
1039
|
|
|
835
1040
|
Verification is the exception: `verifyResponseOnChain` and `verifyOnChain` report failures as `{ valid: false, error }` instead of throwing.
|
|
836
1041
|
|
|
1042
|
+
A proof that fails on the phone is not an exception either — it comes back as a result with `status: 'error'` and an `error` string. Step 6 covers those, and which of them are worth retrying.
|
|
1043
|
+
|
|
837
1044
|
## Networks
|
|
838
1045
|
|
|
839
1046
|
Proofs are verified on **Base** (Ethereum L2). The SDK handles network configuration automatically — no manual setup required.
|