@zkproofport-app/sdk 0.2.9 → 0.2.10

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.
Files changed (2) hide show
  1. package/README.md +286 -103
  2. 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 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';
@@ -350,7 +350,7 @@ Omit `returnScheme` and nothing changes: the request is created without it and t
350
350
 
351
351
  ```typescript
352
352
  // Email domain verification
353
- const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
353
+ const relayDomain = await sdk.createRelayRequest('oidc_domain_attestation', {
354
354
  domain: 'company.com',
355
355
  scope: 'myapp.com',
356
356
  }, {
@@ -360,7 +360,7 @@ const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
360
360
  });
361
361
 
362
362
  // Organization membership verification (Google Workspace)
363
- const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
363
+ const relayGoogle = await sdk.createRelayRequest('oidc_domain_attestation', {
364
364
  domain: 'company.com',
365
365
  scope: 'myapp.com',
366
366
  provider: 'google',
@@ -370,7 +370,7 @@ const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
370
370
  });
371
371
 
372
372
  // Organization membership verification (Microsoft 365)
373
- const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
373
+ const relayMicrosoft = await sdk.createRelayRequest('oidc_domain_attestation', {
374
374
  domain: 'company.com',
375
375
  scope: 'myapp.com',
376
376
  provider: 'microsoft',
@@ -390,12 +390,12 @@ Generate a QR code from the relay deep link for the user to scan with the ZKProo
390
390
  const qrDataUrl = await sdk.generateQRCode(relay.deepLink, {
391
391
  width: 400, // pixels (default: 300)
392
392
  darkColor: '#1a1a1a',
393
- margin: 4,
393
+ margin: 4, // quiet zone in modules (default: 2)
394
394
  });
395
395
  (document.getElementById('qr') as HTMLImageElement).src = qrDataUrl;
396
396
  ```
397
397
 
398
- `QRCodeOptions` accepts `width`, `margin`, `darkColor`, `lightColor` and `errorCorrectionLevel` (`'L' | 'M' | 'Q' | 'H'`, default `'M'`).
398
+ `QRCodeOptions` accepts `width` (default `300`), `margin` (default `2`), `darkColor` (default `'#000000'`), `lightColor` (default `'#ffffff'`) and `errorCorrectionLevel` (`'L' | 'M' | 'Q' | 'H'`, default `'M'`).
399
399
 
400
400
  **Other QR formats:**
401
401
 
@@ -410,6 +410,8 @@ await sdk.renderQRCodeToCanvas(canvasElement, relay.deepLink, { width: 400 });
410
410
  const { size, withinLimit } = sdk.checkQRCodeSize(relay.deepLink);
411
411
  ```
412
412
 
413
+ 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.
414
+
413
415
  **Mobile:** On mobile browsers, redirect directly to the deep link instead of showing a QR code:
414
416
 
415
417
  ```typescript
@@ -431,7 +433,7 @@ const result = await sdk.waitForProof(relay.requestId, {
431
433
  });
432
434
  ```
433
435
 
434
- `result.status` is `'pending'`, `'completed'` or `'failed'`. On `'failed'`, `result.error` explains why.
436
+ `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
437
 
436
438
  **Alternative: Subscribe to real-time updates directly:**
437
439
 
@@ -464,11 +466,119 @@ const result = await sdk.waitForResult(relay.requestId, {
464
466
 
465
467
  When you are done, `sdk.disconnect()` closes any open socket.
466
468
 
467
- ### Step 6: Verify On-Chain
469
+ ### Step 6: Read the Result
470
+
471
+ `waitForProof()`, `waitForResult()` and `pollResult()` all hand back the same object, a `RelayProofResult`. A completed one looks like this (long values elided):
472
+
473
+ ```typescript
474
+ {
475
+ requestId: '9f1c8a02-4e2b-4f0a-9c3e-1d5a7b0e2f44',
476
+ status: 'completed',
477
+ circuit: 'coinbase_attestation',
478
+ proof: '0x1f8b0a…', // proof bytes, opaque
479
+ publicInputs: ['0x00…2f', '0x00…a1', …], // ordered — 128 entries for this circuit
480
+ verifierAddress: '0x…', // contract that can check this proof
481
+ chainId: …, // network that contract lives on
482
+ deepLink: 'zkproofport://proof-request?data=…',
483
+ createdAt: '2026-02-07T09:14:02.101Z',
484
+ updatedAt: '2026-02-07T09:14:48.902Z',
485
+ }
486
+ ```
487
+
488
+ | Field | Type | Present when | What it is |
489
+ |-------|------|--------------|------------|
490
+ | `requestId` | `string` | always | The relay-issued id you waited on. Echoed back so one handler can serve many requests. |
491
+ | `status` | `string` | always | Lifecycle state — see below. |
492
+ | `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. |
493
+ | `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. |
494
+ | `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). |
495
+ | `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. |
496
+ | `chainId` | `number` | `'completed'` | The chain that `verifierAddress` lives on. Same rule: forward it. |
497
+ | `error` | `string` | failures | Human-readable reason from the mobile app or the relay. |
498
+ | `deepLink` | `string` | polling path only | The deep link the request was created with. |
499
+ | `createdAt`, `updatedAt` | `string` | polling path only | ISO timestamps of creation and last update. |
500
+
501
+ 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.
502
+
503
+ #### What `status` can be
504
+
505
+ | `status` | Terminal | What happened | What you do |
506
+ |----------|----------|---------------|-------------|
507
+ | `'pending'` | no | The request exists; no proof yet | Keep waiting — `waitForProof()` does that for you |
508
+ | `'completed'` | yes | A proof came back | `proof`, `publicInputs`, `verifierAddress` and `chainId` are set. Verify it (Step 7) before trusting anything in it |
509
+ | `'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 |
510
+ | `'failed'` | yes | Same meaning and same handling as `'error'` | Handle the pair; the type declares `'failed'`, the app sends `'error'` |
511
+ | `'cancelled'` | see below | The user declined the request in the ZKProofport app | Reset your UI |
512
+
513
+ `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:
514
+
515
+ ```typescript
516
+ const status: string = result.status;
517
+
518
+ if (status === 'completed') {
519
+ // verify it
520
+ } else if (status === 'error' || status === 'failed') {
521
+ showError(result.error ?? 'Proof generation failed');
522
+ }
523
+ ```
524
+
525
+ `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:
526
+
527
+ ```typescript
528
+ const result = await sdk.waitForProof(relay.requestId, {
529
+ onStatusChange: (update) => {
530
+ if (update.status === 'cancelled') {
531
+ // The user declined in the app. This promise will not settle on its own.
532
+ hideQrDialog();
533
+ }
534
+ },
535
+ });
536
+ ```
537
+
538
+ `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.
539
+
540
+ #### What is not on the result
541
+
542
+ - **No `nullifier` field.** The nullifier is inside `publicInputs`. Read it with `sdk.extractNullifier()` — Step 8.
543
+ - **No `numPublicInputs` field.** It is `publicInputs.length`.
544
+ - **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.
545
+
546
+ #### Retry, or show the user?
547
+
548
+ | Situation | Retry automatically | Show the user |
549
+ |-----------|--------------------|----------------|
550
+ | `createRelayRequest()` throws `Too many requests. Please try again later.` | Yes, with backoff | No |
551
+ | Network failure reaching the relay | Yes | No |
552
+ | `pollResult()` throws `Request not found or expired` | No — that id is gone | Yes, ask them to start again |
553
+ | `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) |
554
+ | `status: 'cancelled'` | No | Nothing to say — they just declined |
555
+ | `waitForProof()` timeout | Only if the user asks for it | Yes |
556
+ | `verifyResponseOnChain()` returns `{ valid: false }` | No | Treat it as a rejected proof, not an outage — Step 7 |
557
+
558
+ #### `ProofResponse` — the shape on-chain verification takes
559
+
560
+ `verifyResponseOnChain()` does not take the relay result directly. It takes a `ProofResponse`, which you build from it:
561
+
562
+ | `ProofResponse` field | Type | Required | Where it comes from |
563
+ |-----------------------|------|----------|---------------------|
564
+ | `requestId` | `string` | Yes | `result.requestId` |
565
+ | `circuit` | `CircuitType` | Yes | `result.circuit`, narrowed — the relay types it as a plain `string` |
566
+ | `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'` |
567
+ | `proof` | `string` | When completed | `result.proof` |
568
+ | `publicInputs` | `string[]` | When completed | `result.publicInputs` |
569
+ | `verifierAddress` | `string` | When completed | `result.verifierAddress`. Without it there is no contract to call and verification fails with `No verifier address provided…` |
570
+ | `chainId` | `number` | When completed | `result.chainId`. Used to pick a network when you pass no provider of your own |
571
+ | `numPublicInputs` | `number` | No | Never delivered by the relay, and never read during verification — `publicInputs.length` is used instead. Omit it |
572
+ | `timestamp` | `number` | No | Not delivered by the relay. Omit it |
573
+ | `error` | `string` | On failure | `result.error`. Carried for your own logging; verification ignores it |
574
+
575
+ There is no `nullifier` field here either.
576
+
577
+ ### Step 7: Verify On-Chain
468
578
 
469
579
  Verify the proof cryptographically by calling the deployed Solidity verifier contract.
470
580
 
471
- The relay result tells you which verifier contract to call (`verifierAddress`, `chainId`), so verification goes through `verifyResponseOnChain`:
581
+ 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
582
 
473
583
  ```typescript
474
584
  import type { CircuitType, ProofResponse } from '@zkproofport-app/sdk';
@@ -500,13 +610,17 @@ The SDK connects to the right network on its own. Pass your own ethers `Provider
500
610
  const verification = await sdk.verifyResponseOnChain(response, myProvider);
501
611
  ```
502
612
 
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.
613
+ `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.
504
614
 
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`.
615
+ `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.
506
616
 
507
- ### Step 7: Extract Scope, Nullifier, and Domain
617
+ > **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`.
508
618
 
509
- After verification, extract data from the public inputs:
619
+ ### Step 8: Extract Scope, Nullifier, and Domain
620
+
621
+ 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.
622
+
623
+ 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
624
 
511
625
  ```typescript
512
626
  import type { CircuitType } from '@zkproofport-app/sdk';
@@ -532,19 +646,65 @@ if (result.status === 'completed' && result.publicInputs && result.circuit) {
532
646
  }
533
647
  ```
534
648
 
535
- All three return `null` rather than throwing when the public inputs are too short for the circuit's layout.
649
+ 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.
650
+
651
+ #### Check the scope
652
+
653
+ 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:
654
+
655
+ ```typescript
656
+ import { keccak256, toUtf8Bytes } from 'ethers';
657
+
658
+ const expectedScope = keccak256(toUtf8Bytes('myapp.com'));
659
+
660
+ if (sdk.extractScope(publicInputs, circuit) !== expectedScope) {
661
+ throw new Error('Proof was generated for a different scope');
662
+ }
663
+ ```
664
+
665
+ #### Use the nullifier
536
666
 
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
667
+ The nullifier is a deterministic hash over the user's credential and the scope:
668
+
669
+ - **Deterministic** — same user, same scope, same nullifier, every time. That is what makes duplicate detection possible.
670
+ - **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.
671
+ - **Scope-bound** — the same user proving to a different scope produces an unrelated nullifier, so your users cannot be correlated across apps.
672
+
673
+ 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:
674
+
675
+ ```typescript
676
+ const nullifier = sdk.extractNullifier(publicInputs, circuit);
677
+ if (!nullifier) throw new Error('Public inputs too short for this circuit');
678
+
679
+ // One claim per user, forever: the insert is the double-proof check.
680
+ const inserted = await db.verifiedUsers.insertIfAbsent({
681
+ nullifier, // '0xabc123…'
682
+ circuit,
683
+ claimedAt: new Date(),
684
+ });
685
+
686
+ if (!inserted) {
687
+ // This credential has already been used for this scope.
688
+ throw new Error('Already claimed');
689
+ }
690
+ ```
691
+
692
+ 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
693
 
542
694
  > **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
695
 
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
696
+ #### Check the domain
697
+
698
+ `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.
699
+
700
+ 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:
701
+
702
+ ```typescript
703
+ const domain = sdk.extractDomain(publicInputs, 'oidc_domain_attestation');
704
+ if (domain !== 'company.com') {
705
+ throw new Error(`Proof is for ${domain}, not company.com`);
706
+ }
707
+ ```
548
708
 
549
709
  **Standalone utility functions** are also available for use outside the SDK class:
550
710
 
@@ -581,7 +741,9 @@ End-to-end integration using the relay flow:
581
741
  ```typescript
582
742
  import { ProofportSDK } from '@zkproofport-app/sdk';
583
743
  import type { CircuitType, ProofResponse } from '@zkproofport-app/sdk';
584
- import { BrowserProvider } from 'ethers';
744
+ import { BrowserProvider, keccak256, toUtf8Bytes } from 'ethers';
745
+
746
+ const SCOPE = 'myapp.com';
585
747
 
586
748
  async function verifyUser() {
587
749
  // Initialize
@@ -594,7 +756,7 @@ async function verifyUser() {
594
756
 
595
757
  // Create proof request via relay
596
758
  const relay = await sdk.createRelayRequest('coinbase_attestation', {
597
- scope: 'myapp.com',
759
+ scope: SCOPE,
598
760
  }, {
599
761
  dappName: 'My DApp',
600
762
  message: 'Verify your identity',
@@ -613,25 +775,40 @@ async function verifyUser() {
613
775
  });
614
776
 
615
777
  if (result.status === 'completed') {
778
+ const circuit = result.circuit as CircuitType;
779
+ const publicInputs = result.publicInputs ?? [];
780
+
616
781
  // Verify on-chain
617
782
  const response: ProofResponse = {
618
783
  requestId: result.requestId,
619
- circuit: result.circuit as CircuitType,
784
+ circuit,
620
785
  status: 'completed',
621
786
  proof: result.proof,
622
- publicInputs: result.publicInputs,
787
+ publicInputs,
623
788
  verifierAddress: result.verifierAddress,
624
789
  chainId: result.chainId,
625
790
  };
626
791
 
627
792
  const verification = await sdk.verifyResponseOnChain(response);
628
793
 
629
- if (verification.valid) {
630
- document.getElementById('status')!.textContent = 'Identity verified!';
631
- // Grant access to your application
632
- } else {
794
+ if (!verification.valid) {
633
795
  document.getElementById('status')!.textContent = `Invalid proof: ${verification.error}`;
796
+ sdk.disconnect();
797
+ return;
634
798
  }
799
+
800
+ // A real proof is not yet a proof of what you asked: check the scope.
801
+ if (sdk.extractScope(publicInputs, circuit) !== keccak256(toUtf8Bytes(SCOPE))) {
802
+ document.getElementById('status')!.textContent = 'Proof was made for another scope';
803
+ sdk.disconnect();
804
+ return;
805
+ }
806
+
807
+ // Key the user on the nullifier — same person + same scope, same value
808
+ const nullifier = sdk.extractNullifier(publicInputs, circuit);
809
+ document.getElementById('status')!.textContent = 'Identity verified!';
810
+ console.log('Verified user:', nullifier);
811
+ // Grant access to your application
635
812
  } else {
636
813
  document.getElementById('status')!.textContent = `Failed: ${result.error}`;
637
814
  }
@@ -643,7 +820,7 @@ async function verifyUser() {
643
820
 
644
821
  ## Configuration
645
822
 
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.
823
+ `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
824
 
648
825
  ## Types Reference
649
826
 
@@ -684,14 +861,14 @@ import type {
684
861
  | `MdlKrRegionInputs` | Inputs for `mdl_kr_region`: `{ scope, targetRegion }` |
685
862
  | `CircuitInputs` | Union of every input type above (plus an empty-input form for circuits that need nothing from the dApp) |
686
863
  | `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 |
864
+ | `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
865
  | `QRCodeOptions` | QR customization: `width`, `margin`, `darkColor`, `lightColor`, `errorCorrectionLevel` |
689
866
  | `VerifierContract` | Verifier contract info: `{ address, chainId, abi }` |
690
867
  | `ProofportConfig` | Constructor configuration — `ProofportSDK.create()` fills it in for you |
691
868
  | `ChallengeResponse` | Challenge from the relay: `{ requestId, challenge, expiresAt }` |
692
869
  | `WalletSigner` | Signer interface: `{ signMessage(message), getAddress() }` |
693
870
  | `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? }` |
871
+ | `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
872
 
696
873
  The `OidcDomainInputs` interface:
697
874
 
@@ -703,11 +880,11 @@ interface OidcDomainInputs {
703
880
  }
704
881
  ```
705
882
 
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.
883
+ 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
884
 
708
885
  ## Public Input Layout Constants
709
886
 
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.
887
+ `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
888
 
712
889
  ```typescript
713
890
  import {
@@ -718,82 +895,86 @@ import {
718
895
  } from '@zkproofport-app/sdk';
719
896
  ```
720
897
 
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
- ```
898
+ Two rules hold for every table below:
734
899
 
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
- ```
900
+ - **Every number is an index into `publicInputs`, and `_END` is inclusive.** Slice with `slice(START, END + 1)`.
901
+ - **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
902
 
753
- **OIDC Domain Attestation** (148 fields):
754
903
  ```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)
904
+ const L = COINBASE_ATTESTATION_PUBLIC_INPUT_LAYOUT;
766
905
 
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
- }
906
+ // Rebuild a bytes32 value from 32 single-byte entries.
907
+ const toBytes32 = (fields: string[]): string =>
908
+ '0x' + fields.map((f) => (BigInt(f) & 0xffn).toString(16).padStart(2, '0')).join('');
909
+
910
+ const signalHash = toBytes32(publicInputs.slice(L.SIGNAL_HASH_START, L.SIGNAL_HASH_END + 1));
771
911
  ```
772
912
 
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,
913
+ ### `coinbase_attestation` — 128 entries
780
914
 
781
- // mdl_kr_ownership only
782
- OWNERSHIP_DISCLOSE_FLAGS: 64,
783
- OWNERSHIP_OWNER_COMMIT_START: 65,
784
- OWNERSHIP_OWNER_COMMIT_END: 96,
915
+ | Index | Constants | Circuit field | What it is |
916
+ |-------|-----------|---------------|------------|
917
+ | 0–31 | `SIGNAL_HASH_START` / `_END` | `signal_hash` | `bytes32` anti-replay challenge for the request |
918
+ | 32–63 | `MERKLE_ROOT_START` / `_END` | `signer_list_merkle_root` | `bytes32` root of the Coinbase attestation-signer list the proof was checked against |
919
+ | 64–95 | `SCOPE_START` / `_END` | `scope` | `bytes32` — `keccak256` of your scope string. Compare it (Step 8) |
920
+ | 96–127 | `NULLIFIER_START` / `_END` | `nullifier` | `bytes32`, unique per user and scope |
785
921
 
786
- // mdl_kr_age only
787
- AGE_THRESHOLD: 64,
788
- AGE_CURRENT_YEAR: 65,
922
+ ### `coinbase_country_attestation` — 150 entries
789
923
 
790
- // mdl_kr_region only
791
- REGION_CODE_START: 64,
792
- REGION_CODE_END: 95,
793
- }
794
- ```
924
+ | Index | Constants | Circuit field | What it is |
925
+ |-------|-----------|---------------|------------|
926
+ | 0–31 | `SIGNAL_HASH_START` / `_END` | `signal_hash` | `bytes32` anti-replay challenge |
927
+ | 32–63 | `MERKLE_ROOT_START` / `_END` | `signer_list_merkle_root` | `bytes32` signer-list root |
928
+ | 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 |
929
+ | 84 | `COUNTRY_LIST_LENGTH` | `country_list_length` | How many of those 10 slots count |
930
+ | 85 | `IS_INCLUDED` | `is_included` | `1` = the user **is** in the list, `0` = the user is **not** |
931
+ | 86–117 | `SCOPE_START` / `_END` | `scope` | `bytes32` scope hash |
932
+ | 118–149 | `NULLIFIER_START` / `_END` | `nullifier` | `bytes32`, unique per user and scope |
933
+
934
+ 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.
935
+
936
+ ### `oidc_domain_attestation` — 148 entries
937
+
938
+ | Index | Constants | Circuit field | What it is |
939
+ |-------|-----------|---------------|------------|
940
+ | 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 |
941
+ | 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 |
942
+ | 82 | `DOMAIN_LEN` | `domain` length | How many of those 64 bytes are the domain. `extractDomain()` does this decoding for you |
943
+ | 83–114 | `SCOPE_START` / `_END` | `scope` | `bytes32` scope hash |
944
+ | 115–146 | `NULLIFIER_START` / `_END` | `nullifier` | `bytes32` over the user's email and the scope |
945
+ | 147 | `PROVIDER` | `provider` | `0` = Google, `1` = Microsoft. The circuit accepts no other value |
795
946
 
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.
947
+ `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.
948
+
949
+ 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.
950
+
951
+ ### Korea Mobile ID (mDL) — one layout, three circuits
952
+
953
+ 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.
954
+
955
+ | Index | Constants | Circuit field | Applies to |
956
+ |-------|-----------|---------------|------------|
957
+ | 0–31 | `SCOPE_START` / `_END` | `scope` | all three |
958
+ | 32–63 | `NULLIFIER_START` / `_END` | `nullifier_value` | all three |
959
+ | 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) |
960
+ | 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 |
961
+ | 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 |
962
+ | 65 | `AGE_CURRENT_YEAR` | `current_year` | `mdl_kr_age` — the year the comparison was made in |
963
+ | 64–95 | `REGION_CODE_START` / `_END` | `region_code` | `mdl_kr_region` (96 entries) — `bytes32`, not a readable region name. See below |
964
+
965
+ `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:
966
+
967
+ ```typescript
968
+ import { keccak256 } from 'ethers';
969
+
970
+ const regionCodeFor = (region: string): string => {
971
+ const padded = new Uint8Array(64);
972
+ padded.set(new TextEncoder().encode(region));
973
+ return keccak256(padded);
974
+ };
975
+
976
+ // regionCodeFor('경기도') — compare against indices 64–95, rebuilt with toBytes32()
977
+ ```
797
978
 
798
979
  ## Error Handling
799
980
 
@@ -834,6 +1015,8 @@ A relay rejection surfaces as its message, e.g. `Duplicate nonce (replay detecte
834
1015
 
835
1016
  Verification is the exception: `verifyResponseOnChain` and `verifyOnChain` report failures as `{ valid: false, error }` instead of throwing.
836
1017
 
1018
+ 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.
1019
+
837
1020
  ## Networks
838
1021
 
839
1022
  Proofs are verified on **Base** (Ethereum L2). The SDK handles network configuration automatically — no manual setup required.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zkproofport-app/sdk",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "ZKProofport SDK for requesting zero-knowledge proofs from the ZKProofport mobile app and verifying them on-chain",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.esm.js",