@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 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 was developed **for relay nonce replay prevention**. Each challenge is one-time use and consumed immediately. The signer's recovered address is recorded as `clientId` in relay server logs, which helps the relay operator track requests.
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
- For server-side or headless environments, using an ephemeral random wallet is fine. A persistent wallet (fixed private key) is **not recommended** as it adds unnecessary key management overhead with no functional benefit.
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: mobile-only, see below
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 to your app: `returnScheme`
306
+ #### Switching back: `returnScheme`
307
307
 
308
- By default the user stays in the ZKProofport app after generating a proof and switches back to your app themselves. Pass `returnScheme` and the ZKProofport app brings your app to the foreground once the proof has been delivered — the same handoff a wallet performs after a signature.
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
- **Only set it in the mobile deep-link flow.** The switch happens on the phone that generated the proof, so it makes sense only when that phone is also where the user started — i.e. when you navigated the browser to `relay.deepLink` on the same device. In the desktop QR flow the user is watching the desktop screen while the proof happens on their phone; opening a page or an app on the phone at that moment surfaces something nobody asked for. Gate it:
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
- const relay = await sdk.createRelayRequest('coinbase_attestation', { scope: 'myapp.com' },
326
- ProofportSDK.isMobile() ? { returnScheme: 'https://myapp.com' } : {});
329
+ // Correct. Do not pass returnScheme from a web page.
330
+ await sdk.createRelayRequest('coinbase_attestation', { scope: 'myapp.com' });
327
331
  ```
328
332
 
329
- `returnScheme` is **not** a return address. It says which app to open, nothing more — the proof still reaches you the usual way, through `waitForProof()` / `waitForResult()`, and the ZKProofport app never posts anything to it.
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
- Two shapes are accepted, and nothing else:
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
- | Form | Example | Notes |
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
- Anything else throws before the SDK makes a single network call — no challenge is consumed, so nothing is wasted on a typo:
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
- - a path, query string or fragment (`'mydapp://transfer?to=0x...'`, `'https://myapp.com/done'`), which is what keeps a request from driving your app to a specific action
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
- - userinfo (`'https://user:pass@myapp.com'`) and non-ASCII hosts (use punycode)
343
- - `http://` and other schemes the OS treats specially: `about`, `blob`, `content`, `data`, `facetime`, `facetime-audio`, `file`, `ftp`, `intent`, `javascript`, `jar`, `mailto`, `sms`, `tel`, `vbscript`
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
- Omit `returnScheme` and nothing changes: the request is created without it and the user stays in the ZKProofport app when the proof is done. There is no auto-switch when proof generation fails either — the error has to be read in the ZKProofport app first.
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 relay = await sdk.createRelayRequest('oidc_domain_attestation', {
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 relay = await sdk.createRelayRequest('oidc_domain_attestation', {
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 relay = await sdk.createRelayRequest('oidc_domain_attestation', {
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`, `margin`, `darkColor`, `lightColor` and `errorCorrectionLevel` (`'L' | 'M' | 'Q' | 'H'`, default `'M'`).
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
- `result.status` is `'pending'`, `'completed'` or `'failed'`. On `'failed'`, `result.error` explains why.
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: Verify On-Chain
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 verifier contract to call (`verifierAddress`, `chainId`), so verification goes through `verifyResponseOnChain`:
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` never throws for a bad response: an incomplete one (`status` other than `'completed'`, missing `proof` or `publicInputs`) returns `{ valid: false, error: 'Invalid or incomplete response' }`, and a reverting contract call returns `{ valid: false, error }` with the revert message.
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, but those pieces carry no verifier address — it returns `{ valid: false, error: 'No verifier address provided...' }` unless the SDK was constructed with a verifier for that circuit. Prefer `verifyResponseOnChain`.
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 7: Extract Scope, Nullifier, and Domain
643
+ ### Step 8: Extract Scope, Nullifier, and Domain
508
644
 
509
- After verification, extract data from the public inputs:
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
- The **nullifier** serves as a privacy-preserving user identifier:
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
- The **domain** (OIDC Domain Attestation only) is the email domain the user proved:
545
- - Decoded from the circuit's public inputs, up to 64 ASCII characters
546
- - Matches the domain parameter provided during proof request
547
- - `extractDomain` returns `null` for every other circuit
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: 'myapp.com',
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: result.circuit as CircuitType,
808
+ circuit,
620
809
  status: 'completed',
621
810
  proof: result.proof,
622
- publicInputs: result.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 — the relay endpoint and the network used for verification are built in.
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`, `expiresAt` |
687
- | `ProofResponse` | Proof response: `requestId`, `circuit`, `status`, and — when completed — `proof`, `publicInputs`, `numPublicInputs`, `verifierAddress`, `chainId`, `timestamp`; `error` when it failed |
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: 'pending' \| 'completed' \| 'failed', deepLink?, createdAt?, updatedAt?, proof?, publicInputs?, verifierAddress?, chainId?, circuit?, error? }` |
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
- The SDK exports constants defining the field positions in each circuit's public inputs array. These are useful when working with standalone extraction functions or building custom verification logic.
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
- **Coinbase KYC Attestation** (128 fields):
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
- **Coinbase Country Attestation** (150 fields):
736
- ```typescript
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
- OIDC_DOMAIN_ATTESTATION_PUBLIC_INPUT_LAYOUT = {
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
- // Deprecated aliases (use the names above)
768
- DOMAIN_START: 18, // @deprecated Use DOMAIN_STORAGE_START
769
- DOMAIN_END: 82, // @deprecated Use DOMAIN_LEN
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
- **Korea Mobile ID (mDL)** — one layout shared by the three mDL circuits. The prefix is identical everywhere; the suffix depends on which predicate the circuit proves (`mdl_kr_ownership` 97 fields, `mdl_kr_age` 66, `mdl_kr_region` 96):
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
- // mdl_kr_ownership only
782
- OWNERSHIP_DISCLOSE_FLAGS: 64,
783
- OWNERSHIP_OWNER_COMMIT_START: 65,
784
- OWNERSHIP_OWNER_COMMIT_END: 96,
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
- // mdl_kr_age only
787
- AGE_THRESHOLD: 64,
788
- AGE_CURRENT_YEAR: 65,
946
+ ### `coinbase_country_attestation` — 150 entries
789
947
 
790
- // mdl_kr_region only
791
- REGION_CODE_START: 64,
792
- REGION_CODE_END: 95,
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
- > **Note on field positions:** Each position in the public inputs array corresponds to a field element in the circuit. For bytes32 values (scope, nullifier, signal hash), 32 consecutive fields are concatenated to form the final value.
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/done',
1023
+ returnScheme: 'https://myapp.com',
819
1024
  });
820
1025
  } catch (err) {
821
- // "returnScheme must be a bare custom scheme such as "mydapp://" or an https
822
- // origin such as "https://myapp.com" — paths, query strings and fragments are
823
- // not accepted" (thrown before any network call)
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.