@zkproofport-app/sdk 0.2.7 → 0.2.9

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
@@ -3,7 +3,7 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@zkproofport-app/sdk)](https://www.npmjs.com/package/@zkproofport-app/sdk)
4
4
  [![license](https://img.shields.io/npm/l/@zkproofport-app/sdk)](./LICENSE)
5
5
 
6
- TypeScript SDK for requesting zero-knowledge proofs from the [ZKProofport](https://zkproofport.com) mobile app and verifying them on-chain. Supports Coinbase KYC/Country attestations and OIDC domain attestations (Google, Microsoft 365).
6
+ TypeScript SDK for requesting zero-knowledge proofs from the [ZKProofport](https://zkproofport.com) mobile app and verifying them on-chain. Six circuits are supported: Coinbase KYC and country attestations, OIDC email-domain attestations (Google, Microsoft 365), and the three Korean mobile ID (mDL) circuits.
7
7
 
8
8
  ## How It Works
9
9
 
@@ -21,22 +21,22 @@ TypeScript SDK for requesting zero-knowledge proofs from the [ZKProofport](https
21
21
  │ │ relay callback
22
22
  │ v
23
23
  │ ┌──────────────────────────────────────────────────┐
24
- │ │ SDK receives result (WebSocket / polling) │
24
+ │ │ SDK receives result (WebSocket / polling) │
25
25
  │ │ (proof, publicInputs, status) │
26
26
  │ └─────────────────────┬────────────────────────────┘
27
27
  │ │
28
28
  v v
29
29
  ┌──────────────┐ ┌──────────────────┐ ┌───────────────────┐
30
30
  │ Verify │────>│ On-chain verify │────>│ Access granted │
31
- │ on-chain │ │ (Base Mainnet) │ │ or denied │
31
+ │ on-chain │ │ (Base) │ │ or denied │
32
32
  └──────────────┘ └──────────────────┘ └───────────────────┘
33
33
  ```
34
34
 
35
- 1. Your app sets a wallet signer and creates a proof request via the SDK
36
- 2. The SDK authenticates with the relay using challenge-signature (EIP-191) and gets a tracked request ID
37
- 3. The SDK displays a QR code (desktop) or opens the deep link (mobile)
38
- 4. The user opens the ZKProofport app, which generates the ZK proof
39
- 5. The proof result flows back through the relay to your app via WebSocket (or polling)
35
+ 1. Your app creates a proof request through the SDK
36
+ 2. The SDK asks the relay for a one-time challenge and a tracked request ID. For the Coinbase circuits it also signs that challenge with your wallet signer (EIP-191); the OIDC and mDL circuits need no signature
37
+ 3. Your app shows the returned deep link as a QR code (desktop) or navigates to it (mobile)
38
+ 4. The user opens the ZKProofport app, which generates the ZK proof on-device
39
+ 5. The proof result flows back through the relay to your app over WebSocket (HTTP polling as fallback)
40
40
  6. Your app verifies the proof on-chain
41
41
 
42
42
  ## Installation
@@ -51,16 +51,19 @@ npm install @zkproofport-app/sdk
51
51
  npm install ethers
52
52
  ```
53
53
 
54
+ `ethers` v6 is recommended; v5 also works. Real-time delivery uses `socket.io-client`, which ships as a dependency of this package — nothing extra to install.
55
+
54
56
  ## Quick Start
55
57
 
56
58
  ```typescript
57
59
  import { ProofportSDK } from '@zkproofport-app/sdk';
60
+ import type { CircuitType, ProofResponse } from '@zkproofport-app/sdk';
58
61
  import { BrowserProvider } from 'ethers';
59
62
 
60
63
  // 1. Initialize
61
64
  const sdk = ProofportSDK.create();
62
65
 
63
- // 2. Set wallet signer (ethers v6 Signer)
66
+ // 2. Set wallet signer (ethers v6 Signer) — required for the Coinbase circuits
64
67
  const provider = new BrowserProvider(window.ethereum);
65
68
  const signer = await provider.getSigner();
66
69
  sdk.setSigner(signer);
@@ -72,31 +75,41 @@ const relay = await sdk.createRelayRequest('coinbase_attestation', {
72
75
 
73
76
  // 4. Show QR code to user
74
77
  const qrDataUrl = await sdk.generateQRCode(relay.deepLink);
75
- document.getElementById('qr').src = qrDataUrl;
78
+ (document.getElementById('qr') as HTMLImageElement).src = qrDataUrl;
76
79
 
77
80
  // 5. Wait for proof (WebSocket primary, HTTP polling fallback)
78
81
  const result = await sdk.waitForProof(relay.requestId);
79
82
 
80
83
  if (result.status === 'completed') {
81
- // 6. Verify on-chain
82
- const verification = await sdk.verifyOnChain(
83
- result.circuit,
84
- result.proof,
85
- result.publicInputs
86
- );
84
+ // 6. Verify on-chain — the relay result carries the verifier to call
85
+ const response: ProofResponse = {
86
+ requestId: result.requestId,
87
+ circuit: result.circuit as CircuitType,
88
+ status: 'completed',
89
+ proof: result.proof,
90
+ publicInputs: result.publicInputs,
91
+ verifierAddress: result.verifierAddress,
92
+ chainId: result.chainId,
93
+ };
94
+
95
+ const verification = await sdk.verifyResponseOnChain(response);
87
96
  console.log('Valid:', verification.valid);
88
97
  }
89
98
  ```
90
99
 
91
100
  ## Supported Circuits
92
101
 
102
+ Circuit IDs are canonical and case-sensitive — pass them exactly as written here. `sdk.getSupportedCircuits()` returns the same list at runtime.
103
+
93
104
  ### `coinbase_attestation`
94
105
 
95
- Proves that a user has completed Coinbase KYC identity verification without revealing any personal information.
106
+ Proves that a user has completed Coinbase KYC identity verification without revealing any personal information. Requires a wallet signer.
96
107
 
97
108
  | Field | Type | Required | Description |
98
109
  |-------|------|----------|-------------|
99
110
  | `scope` | `string` | Yes | Application-specific identifier (e.g., your domain). Ensures proof uniqueness per app. |
111
+ | `userAddress` | `string` | No | Address to prove the attestation for. Omit it and the mobile app asks the user to connect a wallet. |
112
+ | `rawTransaction` | `string` | No | Pre-fetched attestation transaction data. Omit it and the app fetches the attestation itself. |
100
113
 
101
114
  ```typescript
102
115
  const relay = await sdk.createRelayRequest('coinbase_attestation', {
@@ -106,13 +119,15 @@ const relay = await sdk.createRelayRequest('coinbase_attestation', {
106
119
 
107
120
  ### `coinbase_country_attestation`
108
121
 
109
- Proves a user's country based on Coinbase verification, supporting inclusion and exclusion checks, without revealing the actual country.
122
+ Proves a user's country based on Coinbase verification, supporting inclusion and exclusion checks, without revealing the actual country. Requires a wallet signer.
110
123
 
111
124
  | Field | Type | Required | Description |
112
125
  |-------|------|----------|-------------|
113
126
  | `scope` | `string` | Yes | Application-specific identifier |
114
127
  | `countryList` | `string[]` | Yes | ISO 3166-1 alpha-2 country codes (e.g., `['US', 'KR']`) |
115
128
  | `isIncluded` | `boolean` | Yes | `true` = prove user IS from listed countries; `false` = prove user is NOT |
129
+ | `userAddress` | `string` | No | Address to prove the attestation for (optional, as above) |
130
+ | `rawTransaction` | `string` | No | Pre-fetched attestation transaction data (optional, as above) |
116
131
 
117
132
  ```typescript
118
133
  const relay = await sdk.createRelayRequest('coinbase_country_attestation', {
@@ -124,7 +139,7 @@ const relay = await sdk.createRelayRequest('coinbase_country_attestation', {
124
139
 
125
140
  ### `oidc_domain_attestation`
126
141
 
127
- Prove email domain affiliation via OIDC Sign-In. The mobile app handles authentication and proof generation entirely on-device — the user's email is never revealed.
142
+ Prove email domain affiliation via OIDC Sign-In. The mobile app handles authentication and proof generation entirely on-device — the user's email is never revealed. No wallet signer is required.
128
143
 
129
144
  | Field | Type | Required | Description |
130
145
  |-------|------|----------|-------------|
@@ -163,6 +178,58 @@ const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
163
178
 
164
179
  > When `provider` is set, the mobile app verifies the user's account is managed by the specified workspace provider (e.g., Google Workspace `hd` claim, Microsoft 365 `tid` claim). Without `provider`, only the email domain is verified.
165
180
 
181
+ ### `mdl_kr_ownership`
182
+
183
+ Proves the user holds a valid Korean mobile driver's license (모바일 신분증). The license data stays on-device; only the attributes selected by `discloseFlags` are revealed. No wallet signer is required.
184
+
185
+ | Field | Type | Required | Description |
186
+ |-------|------|----------|-------------|
187
+ | `scope` | `string` | Yes | Application-specific identifier for proof uniqueness. Non-empty, 256 characters or fewer, no control characters. |
188
+ | `discloseFlags` | `number` | No | Attribute disclosure bitmask, integer 0–15 (`0x01` name, `0x02` birth, `0x04` sex, `0x08` phone). Omit or `0` for a fully anonymous ownership proof. |
189
+
190
+ ```typescript
191
+ // Anonymous "holds a valid Korean mobile ID" proof
192
+ const relay = await sdk.createRelayRequest('mdl_kr_ownership', {
193
+ scope: 'myapp.com',
194
+ });
195
+ ```
196
+
197
+ ### `mdl_kr_age`
198
+
199
+ Proves the user is at least `ageThreshold` years old according to their Korean mobile driver's license, without revealing the birth date. No wallet signer is required.
200
+
201
+ | Field | Type | Required | Description |
202
+ |-------|------|----------|-------------|
203
+ | `scope` | `string` | Yes | Application-specific identifier (same rules as above) |
204
+ | `ageThreshold` | `number` | Yes | Minimum age to prove — integer between 1 and 150 (e.g. `19` for Korean adult verification) |
205
+
206
+ ```typescript
207
+ const relay = await sdk.createRelayRequest('mdl_kr_age', {
208
+ scope: 'myapp.com',
209
+ ageThreshold: 19,
210
+ });
211
+ ```
212
+
213
+ ### `mdl_kr_region`
214
+
215
+ Proves the user's registered address is in the specified si/do region without revealing the full address. No wallet signer is required.
216
+
217
+ | Field | Type | Required | Description |
218
+ |-------|------|----------|-------------|
219
+ | `scope` | `string` | Yes | Application-specific identifier (same rules as above) |
220
+ | `targetRegion` | `string` | Yes | Region (si/do) to prove residency in, in Korean (e.g. `'경기도'`, `'서울특별시'`). Non-empty, 256 characters or fewer, no control characters. |
221
+
222
+ ```typescript
223
+ const relay = await sdk.createRelayRequest('mdl_kr_region', {
224
+ scope: 'myapp.com',
225
+ targetRegion: '경기도',
226
+ });
227
+ ```
228
+
229
+ > mDL inputs are checked before the request leaves your process: a missing `scope`, an out-of-range `discloseFlags`, a non-integer `ageThreshold` or an empty `targetRegion` throws locally instead of coming back as a relay error.
230
+
231
+ > The mDL circuits do not require `setSigner()` — the proof is bound to the license via an on-device nullifier (`nullifier_value` public input), not a wallet address. Use `sdk.extractNullifier(publicInputs, circuit)` for sybil-resistant user identification.
232
+
166
233
  ## Integration Guide
167
234
 
168
235
  ### Step 1: Initialize
@@ -173,11 +240,11 @@ import { ProofportSDK } from '@zkproofport-app/sdk';
173
240
  const sdk = ProofportSDK.create();
174
241
  ```
175
242
 
176
- `ProofportSDK.create()` returns an SDK instance pre-configured with the relay server and verifier contracts. No configuration needed.
243
+ `ProofportSDK.create()` returns an SDK instance pre-configured with the relay server. No configuration needed.
177
244
 
178
245
  ### Step 2: Set Wallet Signer
179
246
 
180
- The SDK uses challenge-signature authentication (EIP-191). Set a wallet signer that can sign messages:
247
+ The Coinbase circuits (`coinbase_attestation`, `coinbase_country_attestation`) authenticate with a signed challenge (EIP-191). Set a wallet signer that can sign messages:
181
248
 
182
249
  ```typescript
183
250
  import { BrowserProvider } from 'ethers';
@@ -191,14 +258,14 @@ The `WalletSigner` interface requires two methods:
191
258
 
192
259
  ```typescript
193
260
  interface WalletSigner {
194
- signMessage(message: string): Promise<string>;
261
+ signMessage(message: string | Uint8Array): Promise<string>;
195
262
  getAddress(): Promise<string>;
196
263
  }
197
264
  ```
198
265
 
199
266
  Any ethers v5/v6 `Signer` is compatible.
200
267
 
201
- > **OIDC Domain note:** Wallet signer is not required for OIDC Domain proofs. See Step 3 for OIDC-specific usage.
268
+ > **When you can skip this step:** the OIDC Domain and the three mDL circuits never sign a challenge. Calling `createRelayRequest` for those circuits without a signer works. Calling it for a Coinbase circuit without one throws `Signer not set. Call setSigner() first. Wallet signature is required for this circuit.`
202
269
 
203
270
  #### About challenge-signature
204
271
 
@@ -215,7 +282,7 @@ sdk.setSigner(Wallet.createRandom());
215
282
 
216
283
  ### Step 3: Create Request (via Relay)
217
284
 
218
- `createRelayRequest` authenticates with the relay (challenge-signature), creates a tracked proof request, and returns a deep link.
285
+ `createRelayRequest` fetches a challenge and request ID from the relay, signs the challenge when the circuit needs it, creates a tracked proof request, and returns a deep link.
219
286
 
220
287
  ```typescript
221
288
  const relay = await sdk.createRelayRequest('coinbase_attestation', {
@@ -225,6 +292,7 @@ const relay = await sdk.createRelayRequest('coinbase_attestation', {
225
292
  dappIcon: 'https://myapp.com/icon.png',
226
293
  message: 'Verify your identity to continue',
227
294
  nonce: 'unique-nonce-123', // Optional: replay prevention
295
+ returnScheme: 'mydapp://', // Optional: mobile-only, see below
228
296
  });
229
297
 
230
298
  // relay.requestId — Relay-issued UUID
@@ -233,6 +301,51 @@ const relay = await sdk.createRelayRequest('coinbase_attestation', {
233
301
  // relay.pollUrl — Relative URL for HTTP polling
234
302
  ```
235
303
 
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
+
306
+ #### Switching back to your app: `returnScheme`
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.
309
+
310
+ ```typescript
311
+ // Native app: your own registered scheme
312
+ await sdk.createRelayRequest('coinbase_attestation', { scope: 'myapp.com' }, {
313
+ returnScheme: 'mydapp://',
314
+ });
315
+
316
+ // Web app: your own origin
317
+ await sdk.createRelayRequest('coinbase_attestation', { scope: 'myapp.com' }, {
318
+ returnScheme: 'https://myapp.com',
319
+ });
320
+ ```
321
+
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:
323
+
324
+ ```typescript
325
+ const relay = await sdk.createRelayRequest('coinbase_attestation', { scope: 'myapp.com' },
326
+ ProofportSDK.isMobile() ? { returnScheme: 'https://myapp.com' } : {});
327
+ ```
328
+
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.
330
+
331
+ Two shapes are accepted, and nothing else:
332
+
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 |
337
+
338
+ Anything else throws before the SDK makes a single network call — no challenge is consumed, so nothing is wasted on a typo:
339
+
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
341
+ - 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`
344
+
345
+ The value is matched case-insensitively and stored lowercased by the relay.
346
+
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.
348
+
236
349
  **OIDC Domain Attestation:**
237
350
 
238
351
  ```typescript
@@ -253,7 +366,6 @@ const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
253
366
  provider: 'google',
254
367
  }, {
255
368
  dappName: 'My DApp',
256
- dappIcon: 'https://myapp.com/icon.png',
257
369
  message: 'Verify your organization membership',
258
370
  });
259
371
 
@@ -264,7 +376,6 @@ const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
264
376
  provider: 'microsoft',
265
377
  }, {
266
378
  dappName: 'My DApp',
267
- dappIcon: 'https://myapp.com/icon.png',
268
379
  message: 'Verify your organization membership',
269
380
  });
270
381
  ```
@@ -277,13 +388,15 @@ Generate a QR code from the relay deep link for the user to scan with the ZKProo
277
388
 
278
389
  ```typescript
279
390
  const qrDataUrl = await sdk.generateQRCode(relay.deepLink, {
280
- width: 400,
391
+ width: 400, // pixels (default: 300)
281
392
  darkColor: '#1a1a1a',
282
393
  margin: 4,
283
394
  });
284
- document.getElementById('qr').src = qrDataUrl;
395
+ (document.getElementById('qr') as HTMLImageElement).src = qrDataUrl;
285
396
  ```
286
397
 
398
+ `QRCodeOptions` accepts `width`, `margin`, `darkColor`, `lightColor` and `errorCorrectionLevel` (`'L' | 'M' | 'Q' | 'H'`, default `'M'`).
399
+
287
400
  **Other QR formats:**
288
401
 
289
402
  ```typescript
@@ -293,7 +406,7 @@ const svg = await sdk.generateQRCodeSVG(relay.deepLink);
293
406
  // Render to canvas
294
407
  await sdk.renderQRCodeToCanvas(canvasElement, relay.deepLink, { width: 400 });
295
408
 
296
- // Check if data fits QR limits
409
+ // Check if data fits QR limits (2953 bytes)
297
410
  const { size, withinLimit } = sdk.checkQRCodeSize(relay.deepLink);
298
411
  ```
299
412
 
@@ -307,7 +420,7 @@ if (ProofportSDK.isMobile()) {
307
420
 
308
421
  ### Step 5: Wait for Proof
309
422
 
310
- **`waitForProof` (recommended)** — Uses WebSocket (Socket.IO) for instant delivery, with automatic HTTP polling fallback if `socket.io-client` is not installed or connection fails.
423
+ **`waitForProof` (recommended)** — Uses WebSocket (Socket.IO) for instant delivery and falls back to HTTP polling automatically if the socket connection fails.
311
424
 
312
425
  ```typescript
313
426
  const result = await sdk.waitForProof(relay.requestId, {
@@ -318,6 +431,8 @@ const result = await sdk.waitForProof(relay.requestId, {
318
431
  });
319
432
  ```
320
433
 
434
+ `result.status` is `'pending'`, `'completed'` or `'failed'`. On `'failed'`, `result.error` explains why.
435
+
321
436
  **Alternative: Subscribe to real-time updates directly:**
322
437
 
323
438
  ```typescript
@@ -337,7 +452,7 @@ const unsubscribe = await sdk.subscribe(relay.requestId, {
337
452
 
338
453
  ```typescript
339
454
  // Single poll
340
- const result = await sdk.pollResult(relay.requestId);
455
+ const once = await sdk.pollResult(relay.requestId);
341
456
 
342
457
  // Poll until terminal state
343
458
  const result = await sdk.waitForResult(relay.requestId, {
@@ -347,17 +462,29 @@ const result = await sdk.waitForResult(relay.requestId, {
347
462
  });
348
463
  ```
349
464
 
465
+ When you are done, `sdk.disconnect()` closes any open socket.
466
+
350
467
  ### Step 6: Verify On-Chain
351
468
 
352
469
  Verify the proof cryptographically by calling the deployed Solidity verifier contract.
353
470
 
471
+ The relay result tells you which verifier contract to call (`verifierAddress`, `chainId`), so verification goes through `verifyResponseOnChain`:
472
+
354
473
  ```typescript
474
+ import type { CircuitType, ProofResponse } from '@zkproofport-app/sdk';
475
+
355
476
  if (result.status === 'completed') {
356
- const verification = await sdk.verifyOnChain(
357
- result.circuit,
358
- result.proof,
359
- result.publicInputs
360
- );
477
+ const response: ProofResponse = {
478
+ requestId: result.requestId,
479
+ circuit: result.circuit as CircuitType,
480
+ status: 'completed',
481
+ proof: result.proof,
482
+ publicInputs: result.publicInputs,
483
+ verifierAddress: result.verifierAddress,
484
+ chainId: result.chainId,
485
+ };
486
+
487
+ const verification = await sdk.verifyResponseOnChain(response);
361
488
 
362
489
  if (verification.valid) {
363
490
  console.log('Proof verified on-chain!');
@@ -367,47 +494,57 @@ if (result.status === 'completed') {
367
494
  }
368
495
  ```
369
496
 
370
- **Or verify from a `ProofResponse` object:**
497
+ The SDK connects to the right network on its own. Pass your own ethers `Provider` or `Signer` as the second argument if you would rather use your existing connection:
371
498
 
372
499
  ```typescript
373
- const verification = await sdk.verifyResponseOnChain(response);
500
+ const verification = await sdk.verifyResponseOnChain(response, myProvider);
374
501
  ```
375
502
 
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.
504
+
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`.
506
+
376
507
  ### Step 7: Extract Scope, Nullifier, and Domain
377
508
 
378
509
  After verification, extract data from the public inputs:
379
510
 
380
511
  ```typescript
381
- if (result.status === 'completed') {
382
- // Extract scope — the keccak256 hash of the scope string you provided
383
- const scope = sdk.extractScope(result.publicInputs, result.circuit);
512
+ import type { CircuitType } from '@zkproofport-app/sdk';
513
+
514
+ if (result.status === 'completed' && result.publicInputs && result.circuit) {
515
+ const circuit = result.circuit as CircuitType;
516
+
517
+ // Extract scope — the bytes32 scope value carried in the public inputs
518
+ const scope = sdk.extractScope(result.publicInputs, circuit);
384
519
 
385
520
  // Extract nullifier — a unique, deterministic hash per user + scope
386
521
  // Same user with the same scope always produces the same nullifier
387
- const nullifier = sdk.extractNullifier(result.publicInputs, result.circuit);
522
+ const nullifier = sdk.extractNullifier(result.publicInputs, circuit);
388
523
 
389
- console.log('Scope:', scope); // '0x7a6b70726f...'
524
+ console.log('Scope:', scope); // '0x7a6b70726f...'
390
525
  console.log('Nullifier:', nullifier); // '0xabc123...'
391
526
 
392
527
  // Extract domain — only for OIDC Domain Attestation
393
- if (result.circuit === 'oidc_domain_attestation') {
394
- const domain = sdk.extractDomain(result.publicInputs, result.circuit);
528
+ if (circuit === 'oidc_domain_attestation') {
529
+ const domain = sdk.extractDomain(result.publicInputs, circuit);
395
530
  console.log('Domain:', domain); // 'example.com'
396
531
  }
397
532
  }
398
533
  ```
399
534
 
535
+ All three return `null` rather than throwing when the public inputs are too short for the circuit's layout.
536
+
400
537
  The **nullifier** serves as a privacy-preserving user identifier:
401
538
  - Deterministic: same user + same scope = same nullifier (enables duplicate detection)
402
- - Privacy-preserving: the wallet address (Coinbase) or email (OIDC) is never revealed
539
+ - Privacy-preserving: the wallet address (Coinbase), the email (OIDC) or the license (mDL) is never revealed
403
540
  - Scope-bound: different scopes produce different nullifiers for the same user
404
541
 
405
542
  > **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.
406
543
 
407
544
  The **domain** (OIDC Domain Attestation only) is the email domain the user proved:
408
- - Extracted from the circuit's public inputs
545
+ - Decoded from the circuit's public inputs, up to 64 ASCII characters
409
546
  - Matches the domain parameter provided during proof request
410
- - Available only for `oidc_domain_attestation` circuits
547
+ - `extractDomain` returns `null` for every other circuit
411
548
 
412
549
  **Standalone utility functions** are also available for use outside the SDK class:
413
550
 
@@ -418,7 +555,8 @@ import {
418
555
  extractDomainFromPublicInputs,
419
556
  } from '@zkproofport-app/sdk';
420
557
 
421
- // Works with all circuits: coinbase_attestation, coinbase_country_attestation, oidc_domain_attestation
558
+ // Scope and nullifier work for all six circuits — the layout is picked from the
559
+ // circuit id, so always pass it:
422
560
  const scope = extractScopeFromPublicInputs(publicInputs, 'coinbase_attestation');
423
561
  const nullifier = extractNullifierFromPublicInputs(publicInputs, 'coinbase_attestation');
424
562
 
@@ -426,17 +564,23 @@ const nullifier = extractNullifierFromPublicInputs(publicInputs, 'coinbase_attes
426
564
  const oidcScope = extractScopeFromPublicInputs(publicInputs, 'oidc_domain_attestation');
427
565
  const oidcNullifier = extractNullifierFromPublicInputs(publicInputs, 'oidc_domain_attestation');
428
566
 
567
+ // The three mDL circuits share one layout
568
+ const mdlNullifier = extractNullifierFromPublicInputs(publicInputs, 'mdl_kr_age');
569
+
429
570
  // Extract domain from OIDC Domain Attestation
430
571
  const domain = extractDomainFromPublicInputs(publicInputs, 'oidc_domain_attestation');
431
- // domain: 'example.com' or null if circuit doesn't match or inputs insufficient
572
+ // domain: 'example.com', or null if the circuit doesn't match or inputs are insufficient
432
573
  ```
433
574
 
575
+ Omitting the circuit argument falls back to the `coinbase_attestation` layout, which is why the examples above always pass it explicitly.
576
+
434
577
  ## Complete Example
435
578
 
436
579
  End-to-end integration using the relay flow:
437
580
 
438
581
  ```typescript
439
582
  import { ProofportSDK } from '@zkproofport-app/sdk';
583
+ import type { CircuitType, ProofResponse } from '@zkproofport-app/sdk';
440
584
  import { BrowserProvider } from 'ethers';
441
585
 
442
586
  async function verifyUser() {
@@ -458,30 +602,38 @@ async function verifyUser() {
458
602
 
459
603
  // Display QR code
460
604
  const qrDataUrl = await sdk.generateQRCode(relay.deepLink, { width: 400 });
461
- document.getElementById('qr-image').src = qrDataUrl;
462
- document.getElementById('status').textContent = 'Scan the QR code with ZKProofport';
605
+ (document.getElementById('qr-image') as HTMLImageElement).src = qrDataUrl;
606
+ document.getElementById('status')!.textContent = 'Scan the QR code with ZKProofport';
463
607
 
464
608
  // Wait for proof result
465
609
  const result = await sdk.waitForProof(relay.requestId, {
466
610
  onStatusChange: (update) => {
467
- document.getElementById('status').textContent = `Status: ${update.status}`;
611
+ document.getElementById('status')!.textContent = `Status: ${update.status}`;
468
612
  },
469
613
  });
470
614
 
471
615
  if (result.status === 'completed') {
472
616
  // Verify on-chain
473
- const verification = await sdk.verifyOnChain(
474
- result.circuit,
475
- result.proof,
476
- result.publicInputs
477
- );
617
+ const response: ProofResponse = {
618
+ requestId: result.requestId,
619
+ circuit: result.circuit as CircuitType,
620
+ status: 'completed',
621
+ proof: result.proof,
622
+ publicInputs: result.publicInputs,
623
+ verifierAddress: result.verifierAddress,
624
+ chainId: result.chainId,
625
+ };
626
+
627
+ const verification = await sdk.verifyResponseOnChain(response);
478
628
 
479
629
  if (verification.valid) {
480
- document.getElementById('status').textContent = 'Identity verified!';
630
+ document.getElementById('status')!.textContent = 'Identity verified!';
481
631
  // Grant access to your application
632
+ } else {
633
+ document.getElementById('status')!.textContent = `Invalid proof: ${verification.error}`;
482
634
  }
483
635
  } else {
484
- document.getElementById('status').textContent = `Failed: ${result.error}`;
636
+ document.getElementById('status')!.textContent = `Failed: ${result.error}`;
485
637
  }
486
638
 
487
639
  // Cleanup
@@ -491,11 +643,11 @@ async function verifyUser() {
491
643
 
492
644
  ## Configuration
493
645
 
494
- `ProofportSDK.create()` returns a fully configured SDK instance. No manual configuration is needed — relay URLs, verifier contracts, and chain settings are all built-in.
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.
495
647
 
496
648
  ## Types Reference
497
649
 
498
- All 15 exported types:
650
+ Types you can import:
499
651
 
500
652
  ```typescript
501
653
  import type {
@@ -504,6 +656,9 @@ import type {
504
656
  CoinbaseKycInputs,
505
657
  CoinbaseCountryInputs,
506
658
  OidcDomainInputs,
659
+ MdlKrOwnershipInputs,
660
+ MdlKrAgeInputs,
661
+ MdlKrRegionInputs,
507
662
  CircuitInputs,
508
663
  ProofRequest,
509
664
  ProofResponse,
@@ -519,21 +674,24 @@ import type {
519
674
 
520
675
  | Type | Description |
521
676
  |------|-------------|
522
- | `CircuitType` | `'coinbase_attestation' \| 'coinbase_country_attestation' \| 'oidc_domain_attestation'` |
523
- | `ProofRequestStatus` | `'pending' \| 'completed' \| 'error' \| 'cancelled'` |
524
- | `CoinbaseKycInputs` | Inputs for `coinbase_attestation` (`{ scope, userAddress?, rawTransaction? }`) |
525
- | `CoinbaseCountryInputs` | Inputs for `coinbase_country_attestation` (`{ scope, countryList, isIncluded, ... }`) |
526
- | `OidcDomainInputs` | Inputs for `oidc_domain_attestation` (`{ domain, scope, provider? }`) |
527
- | `CircuitInputs` | Union: `CoinbaseKycInputs \| CoinbaseCountryInputs \| OidcDomainInputs` |
528
- | `ProofRequest` | Proof request object with `requestId`, `circuit`, `inputs`, metadata, and expiry |
529
- | `ProofResponse` | Proof response with `status`, `proof`, `publicInputs`, `verifierAddress`, `chainId` |
677
+ | `CircuitType` | `'coinbase_attestation' \| 'coinbase_country_attestation' \| 'oidc_domain_attestation' \| 'mdl_kr_ownership' \| 'mdl_kr_age' \| 'mdl_kr_region'` |
678
+ | `ProofRequestStatus` | `'pending' \| 'completed' \| 'error' \| 'cancelled'` — the status on a `ProofResponse` |
679
+ | `CoinbaseKycInputs` | Inputs for `coinbase_attestation`: `{ scope, userAddress?, rawTransaction? }` |
680
+ | `CoinbaseCountryInputs` | Inputs for `coinbase_country_attestation`: `{ scope, countryList, isIncluded, userAddress?, rawTransaction? }` |
681
+ | `OidcDomainInputs` | Inputs for `oidc_domain_attestation`: `{ domain, scope, provider? }` |
682
+ | `MdlKrOwnershipInputs` | Inputs for `mdl_kr_ownership`: `{ scope, discloseFlags? }` |
683
+ | `MdlKrAgeInputs` | Inputs for `mdl_kr_age`: `{ scope, ageThreshold }` |
684
+ | `MdlKrRegionInputs` | Inputs for `mdl_kr_region`: `{ scope, targetRegion }` |
685
+ | `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 |
530
688
  | `QRCodeOptions` | QR customization: `width`, `margin`, `darkColor`, `lightColor`, `errorCorrectionLevel` |
531
689
  | `VerifierContract` | Verifier contract info: `{ address, chainId, abi }` |
532
- | `ProofportConfig` | SDK configuration (internal use — `ProofportSDK.create()` handles defaults) |
533
- | `ChallengeResponse` | Challenge from relay: `{ challenge, expiresAt }` |
534
- | `WalletSigner` | Signer interface: `{ signMessage(msg), getAddress() }` |
535
- | `RelayProofRequest` | Relay response: `{ requestId, deepLink, status, pollUrl }` |
536
- | `RelayProofResult` | Relay result: `{ requestId, status, proof?, publicInputs?, circuit?, error? }` |
690
+ | `ProofportConfig` | Constructor configuration — `ProofportSDK.create()` fills it in for you |
691
+ | `ChallengeResponse` | Challenge from the relay: `{ requestId, challenge, expiresAt }` |
692
+ | `WalletSigner` | Signer interface: `{ signMessage(message), getAddress() }` |
693
+ | `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? }` |
537
695
 
538
696
  The `OidcDomainInputs` interface:
539
697
 
@@ -545,6 +703,8 @@ interface OidcDomainInputs {
545
703
  }
546
704
  ```
547
705
 
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.
707
+
548
708
  ## Public Input Layout Constants
549
709
 
550
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.
@@ -554,24 +714,25 @@ import {
554
714
  COINBASE_ATTESTATION_PUBLIC_INPUT_LAYOUT,
555
715
  COINBASE_COUNTRY_PUBLIC_INPUT_LAYOUT,
556
716
  OIDC_DOMAIN_ATTESTATION_PUBLIC_INPUT_LAYOUT,
717
+ MDL_KR_PUBLIC_INPUT_LAYOUT,
557
718
  } from '@zkproofport-app/sdk';
558
719
  ```
559
720
 
560
- **Coinbase KYC Attestation** (128 fields total):
721
+ **Coinbase KYC Attestation** (128 fields):
561
722
  ```typescript
562
723
  COINBASE_ATTESTATION_PUBLIC_INPUT_LAYOUT = {
563
- SIGNAL_HASH_START: 0, // RSA modulus limbs (Coinbase signer)
724
+ SIGNAL_HASH_START: 0, // Signal hash
564
725
  SIGNAL_HASH_END: 31,
565
- MERKLE_ROOT_START: 32, // Merkle root of signers
726
+ MERKLE_ROOT_START: 32, // Merkle root of the attestation signer list
566
727
  MERKLE_ROOT_END: 63,
567
- SCOPE_START: 64, // keccak256 hash of scope string
728
+ SCOPE_START: 64, // Scope value
568
729
  SCOPE_END: 95,
569
730
  NULLIFIER_START: 96, // Unique identifier per user+scope
570
731
  NULLIFIER_END: 127,
571
732
  }
572
733
  ```
573
734
 
574
- **Coinbase Country Attestation** (150 fields total):
735
+ **Coinbase Country Attestation** (150 fields):
575
736
  ```typescript
576
737
  COINBASE_COUNTRY_PUBLIC_INPUT_LAYOUT = {
577
738
  SIGNAL_HASH_START: 0,
@@ -589,7 +750,7 @@ COINBASE_COUNTRY_PUBLIC_INPUT_LAYOUT = {
589
750
  }
590
751
  ```
591
752
 
592
- **OIDC Domain Attestation** (148 fields total):
753
+ **OIDC Domain Attestation** (148 fields):
593
754
  ```typescript
594
755
  OIDC_DOMAIN_ATTESTATION_PUBLIC_INPUT_LAYOUT = {
595
756
  PUBKEY_MODULUS_START: 0, // RSA modulus limbs (JWT issuer key)
@@ -597,29 +758,69 @@ OIDC_DOMAIN_ATTESTATION_PUBLIC_INPUT_LAYOUT = {
597
758
  DOMAIN_STORAGE_START: 18, // Domain bytes (up to 64 ASCII characters)
598
759
  DOMAIN_STORAGE_END: 81,
599
760
  DOMAIN_LEN: 82, // Domain string length
600
- SCOPE_START: 83, // keccak256 hash of scope string
761
+ SCOPE_START: 83, // Scope value
601
762
  SCOPE_END: 114,
602
763
  NULLIFIER_START: 115, // Unique identifier per user+scope
603
764
  NULLIFIER_END: 146,
604
765
  PROVIDER: 147, // OIDC provider code (0=none, 1=Google, 2=Microsoft)
605
766
 
606
- // Deprecated aliases (use new names above)
767
+ // Deprecated aliases (use the names above)
607
768
  DOMAIN_START: 18, // @deprecated Use DOMAIN_STORAGE_START
608
769
  DOMAIN_END: 82, // @deprecated Use DOMAIN_LEN
609
770
  }
610
771
  ```
611
772
 
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,
780
+
781
+ // mdl_kr_ownership only
782
+ OWNERSHIP_DISCLOSE_FLAGS: 64,
783
+ OWNERSHIP_OWNER_COMMIT_START: 65,
784
+ OWNERSHIP_OWNER_COMMIT_END: 96,
785
+
786
+ // mdl_kr_age only
787
+ AGE_THRESHOLD: 64,
788
+ AGE_CURRENT_YEAR: 65,
789
+
790
+ // mdl_kr_region only
791
+ REGION_CODE_START: 64,
792
+ REGION_CODE_END: 95,
793
+ }
794
+ ```
795
+
612
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.
613
797
 
614
798
  ## Error Handling
615
799
 
616
- All async SDK methods throw standard `Error` objects:
800
+ Async SDK methods throw standard `Error` objects. The messages are stable enough to log, not to switch on:
617
801
 
618
802
  ```typescript
619
803
  try {
804
+ // Coinbase circuit without a signer
620
805
  await sdk.createRelayRequest('coinbase_attestation', { scope: 'app.com' });
621
806
  } catch (err) {
622
- // "Signer not set. Call setSigner() first."
807
+ // "Signer not set. Call setSigner() first. Wallet signature is required for this circuit."
808
+ }
809
+
810
+ try {
811
+ await sdk.createRelayRequest('mdl_kr_age', { scope: 'app.com', ageThreshold: 0 });
812
+ } catch (err) {
813
+ // "ageThreshold is required and must be an integer between 1 and 150 (circuit: mdl_kr_age)"
814
+ }
815
+
816
+ try {
817
+ await sdk.createRelayRequest('coinbase_attestation', { scope: 'app.com' }, {
818
+ returnScheme: 'https://myapp.com/done',
819
+ });
820
+ } 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)
623
824
  }
624
825
 
625
826
  try {
@@ -629,6 +830,10 @@ try {
629
830
  }
630
831
  ```
631
832
 
833
+ A relay rejection surfaces as its message, e.g. `Duplicate nonce (replay detected)` for a reused `nonce`, or `Request not found or expired` from `pollResult()`.
834
+
835
+ Verification is the exception: `verifyResponseOnChain` and `verifyOnChain` report failures as `{ valid: false, error }` instead of throwing.
836
+
632
837
  ## Networks
633
838
 
634
839
  Proofs are verified on **Base** (Ethereum L2). The SDK handles network configuration automatically — no manual setup required.