@zkproofport-app/sdk 0.2.8 → 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.
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,23 +21,23 @@ 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)
40
- 6. Your app verifies the proof on-chain
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
+ 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
 
@@ -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
  |-------|------|----------|-------------|
@@ -165,12 +180,12 @@ const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
165
180
 
166
181
  ### `mdl_kr_ownership`
167
182
 
168
- 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 signature is required.
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.
169
184
 
170
185
  | Field | Type | Required | Description |
171
186
  |-------|------|----------|-------------|
172
- | `scope` | `string` | Yes | Application-specific identifier for proof uniqueness |
173
- | `discloseFlags` | `number` | No | Attribute disclosure bitmask `0x00`–`0x0F` (`0x01` name, `0x02` birth, `0x04` sex, `0x08` phone). Omit or `0` for a fully anonymous ownership proof. |
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. |
174
189
 
175
190
  ```typescript
176
191
  // Anonymous "holds a valid Korean mobile ID" proof
@@ -181,12 +196,12 @@ const relay = await sdk.createRelayRequest('mdl_kr_ownership', {
181
196
 
182
197
  ### `mdl_kr_age`
183
198
 
184
- Proves the user is at least `ageThreshold` years old according to their Korean mobile driver's license, without revealing the birth date.
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.
185
200
 
186
201
  | Field | Type | Required | Description |
187
202
  |-------|------|----------|-------------|
188
- | `scope` | `string` | Yes | Application-specific identifier |
189
- | `ageThreshold` | `number` | Yes | Minimum age to prove (integer 1–150, e.g. `19` for Korean adult verification) |
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) |
190
205
 
191
206
  ```typescript
192
207
  const relay = await sdk.createRelayRequest('mdl_kr_age', {
@@ -197,12 +212,12 @@ const relay = await sdk.createRelayRequest('mdl_kr_age', {
197
212
 
198
213
  ### `mdl_kr_region`
199
214
 
200
- Proves the user's registered address is in the specified si/do region without revealing the full address.
215
+ Proves the user's registered address is in the specified si/do region without revealing the full address. No wallet signer is required.
201
216
 
202
217
  | Field | Type | Required | Description |
203
218
  |-------|------|----------|-------------|
204
- | `scope` | `string` | Yes | Application-specific identifier |
205
- | `targetRegion` | `string` | Yes | Region (si/do) to prove residency in, in Korean (e.g. `'경기도'`, `'서울특별시'`) |
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. |
206
221
 
207
222
  ```typescript
208
223
  const relay = await sdk.createRelayRequest('mdl_kr_region', {
@@ -211,7 +226,9 @@ const relay = await sdk.createRelayRequest('mdl_kr_region', {
211
226
  });
212
227
  ```
213
228
 
214
- > 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(result.publicInputs, result.circuit)` for sybil-resistant user identification.
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.
215
232
 
216
233
  ## Integration Guide
217
234
 
@@ -223,11 +240,11 @@ import { ProofportSDK } from '@zkproofport-app/sdk';
223
240
  const sdk = ProofportSDK.create();
224
241
  ```
225
242
 
226
- `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.
227
244
 
228
245
  ### Step 2: Set Wallet Signer
229
246
 
230
- 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:
231
248
 
232
249
  ```typescript
233
250
  import { BrowserProvider } from 'ethers';
@@ -241,20 +258,20 @@ The `WalletSigner` interface requires two methods:
241
258
 
242
259
  ```typescript
243
260
  interface WalletSigner {
244
- signMessage(message: string): Promise<string>;
261
+ signMessage(message: string | Uint8Array): Promise<string>;
245
262
  getAddress(): Promise<string>;
246
263
  }
247
264
  ```
248
265
 
249
266
  Any ethers v5/v6 `Signer` is compatible.
250
267
 
251
- > **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.`
252
269
 
253
270
  #### About challenge-signature
254
271
 
255
- 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.
256
273
 
257
- 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.
258
275
 
259
276
  ```typescript
260
277
  import { Wallet } from 'ethers';
@@ -265,7 +282,7 @@ sdk.setSigner(Wallet.createRandom());
265
282
 
266
283
  ### Step 3: Create Request (via Relay)
267
284
 
268
- `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.
269
286
 
270
287
  ```typescript
271
288
  const relay = await sdk.createRelayRequest('coinbase_attestation', {
@@ -275,6 +292,7 @@ const relay = await sdk.createRelayRequest('coinbase_attestation', {
275
292
  dappIcon: 'https://myapp.com/icon.png',
276
293
  message: 'Verify your identity to continue',
277
294
  nonce: 'unique-nonce-123', // Optional: replay prevention
295
+ returnScheme: 'mydapp://', // Optional: mobile-only, see below
278
296
  });
279
297
 
280
298
  // relay.requestId — Relay-issued UUID
@@ -283,11 +301,56 @@ const relay = await sdk.createRelayRequest('coinbase_attestation', {
283
301
  // relay.pollUrl — Relative URL for HTTP polling
284
302
  ```
285
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
+
286
349
  **OIDC Domain Attestation:**
287
350
 
288
351
  ```typescript
289
352
  // Email domain verification
290
- const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
353
+ const relayDomain = await sdk.createRelayRequest('oidc_domain_attestation', {
291
354
  domain: 'company.com',
292
355
  scope: 'myapp.com',
293
356
  }, {
@@ -297,24 +360,22 @@ const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
297
360
  });
298
361
 
299
362
  // Organization membership verification (Google Workspace)
300
- const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
363
+ const relayGoogle = await sdk.createRelayRequest('oidc_domain_attestation', {
301
364
  domain: 'company.com',
302
365
  scope: 'myapp.com',
303
366
  provider: 'google',
304
367
  }, {
305
368
  dappName: 'My DApp',
306
- dappIcon: 'https://myapp.com/icon.png',
307
369
  message: 'Verify your organization membership',
308
370
  });
309
371
 
310
372
  // Organization membership verification (Microsoft 365)
311
- const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
373
+ const relayMicrosoft = await sdk.createRelayRequest('oidc_domain_attestation', {
312
374
  domain: 'company.com',
313
375
  scope: 'myapp.com',
314
376
  provider: 'microsoft',
315
377
  }, {
316
378
  dappName: 'My DApp',
317
- dappIcon: 'https://myapp.com/icon.png',
318
379
  message: 'Verify your organization membership',
319
380
  });
320
381
  ```
@@ -327,13 +388,15 @@ Generate a QR code from the relay deep link for the user to scan with the ZKProo
327
388
 
328
389
  ```typescript
329
390
  const qrDataUrl = await sdk.generateQRCode(relay.deepLink, {
330
- width: 400,
391
+ width: 400, // pixels (default: 300)
331
392
  darkColor: '#1a1a1a',
332
- margin: 4,
393
+ margin: 4, // quiet zone in modules (default: 2)
333
394
  });
334
- document.getElementById('qr').src = qrDataUrl;
395
+ (document.getElementById('qr') as HTMLImageElement).src = qrDataUrl;
335
396
  ```
336
397
 
398
+ `QRCodeOptions` accepts `width` (default `300`), `margin` (default `2`), `darkColor` (default `'#000000'`), `lightColor` (default `'#ffffff'`) and `errorCorrectionLevel` (`'L' | 'M' | 'Q' | 'H'`, default `'M'`).
399
+
337
400
  **Other QR formats:**
338
401
 
339
402
  ```typescript
@@ -343,10 +406,12 @@ const svg = await sdk.generateQRCodeSVG(relay.deepLink);
343
406
  // Render to canvas
344
407
  await sdk.renderQRCodeToCanvas(canvasElement, relay.deepLink, { width: 400 });
345
408
 
346
- // Check if data fits QR limits
409
+ // Check if data fits QR limits (2953 bytes)
347
410
  const { size, withinLimit } = sdk.checkQRCodeSize(relay.deepLink);
348
411
  ```
349
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
+
350
415
  **Mobile:** On mobile browsers, redirect directly to the deep link instead of showing a QR code:
351
416
 
352
417
  ```typescript
@@ -357,7 +422,7 @@ if (ProofportSDK.isMobile()) {
357
422
 
358
423
  ### Step 5: Wait for Proof
359
424
 
360
- **`waitForProof` (recommended)** — Uses WebSocket (Socket.IO) for instant delivery, with automatic HTTP polling fallback if `socket.io-client` is not installed or connection fails.
425
+ **`waitForProof` (recommended)** — Uses WebSocket (Socket.IO) for instant delivery and falls back to HTTP polling automatically if the socket connection fails.
361
426
 
362
427
  ```typescript
363
428
  const result = await sdk.waitForProof(relay.requestId, {
@@ -368,6 +433,8 @@ const result = await sdk.waitForProof(relay.requestId, {
368
433
  });
369
434
  ```
370
435
 
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.
437
+
371
438
  **Alternative: Subscribe to real-time updates directly:**
372
439
 
373
440
  ```typescript
@@ -387,7 +454,7 @@ const unsubscribe = await sdk.subscribe(relay.requestId, {
387
454
 
388
455
  ```typescript
389
456
  // Single poll
390
- const result = await sdk.pollResult(relay.requestId);
457
+ const once = await sdk.pollResult(relay.requestId);
391
458
 
392
459
  // Poll until terminal state
393
460
  const result = await sdk.waitForResult(relay.requestId, {
@@ -397,17 +464,137 @@ const result = await sdk.waitForResult(relay.requestId, {
397
464
  });
398
465
  ```
399
466
 
400
- ### Step 6: Verify On-Chain
467
+ When you are done, `sdk.disconnect()` closes any open socket.
468
+
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
401
578
 
402
579
  Verify the proof cryptographically by calling the deployed Solidity verifier contract.
403
580
 
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:
582
+
404
583
  ```typescript
584
+ import type { CircuitType, ProofResponse } from '@zkproofport-app/sdk';
585
+
405
586
  if (result.status === 'completed') {
406
- const verification = await sdk.verifyOnChain(
407
- result.circuit,
408
- result.proof,
409
- result.publicInputs
410
- );
587
+ const response: ProofResponse = {
588
+ requestId: result.requestId,
589
+ circuit: result.circuit as CircuitType,
590
+ status: 'completed',
591
+ proof: result.proof,
592
+ publicInputs: result.publicInputs,
593
+ verifierAddress: result.verifierAddress,
594
+ chainId: result.chainId,
595
+ };
596
+
597
+ const verification = await sdk.verifyResponseOnChain(response);
411
598
 
412
599
  if (verification.valid) {
413
600
  console.log('Proof verified on-chain!');
@@ -417,47 +604,107 @@ if (result.status === 'completed') {
417
604
  }
418
605
  ```
419
606
 
420
- **Or verify from a `ProofResponse` object:**
607
+ 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:
421
608
 
422
609
  ```typescript
423
- const verification = await sdk.verifyResponseOnChain(response);
610
+ const verification = await sdk.verifyResponseOnChain(response, myProvider);
424
611
  ```
425
612
 
426
- ### Step 7: Extract Scope, Nullifier, and Domain
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.
614
+
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.
427
616
 
428
- After verification, extract data from the public inputs:
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`.
618
+
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.
429
624
 
430
625
  ```typescript
431
- if (result.status === 'completed') {
432
- // Extract scope — the keccak256 hash of the scope string you provided
433
- const scope = sdk.extractScope(result.publicInputs, result.circuit);
626
+ import type { CircuitType } from '@zkproofport-app/sdk';
627
+
628
+ if (result.status === 'completed' && result.publicInputs && result.circuit) {
629
+ const circuit = result.circuit as CircuitType;
630
+
631
+ // Extract scope — the bytes32 scope value carried in the public inputs
632
+ const scope = sdk.extractScope(result.publicInputs, circuit);
434
633
 
435
634
  // Extract nullifier — a unique, deterministic hash per user + scope
436
635
  // Same user with the same scope always produces the same nullifier
437
- const nullifier = sdk.extractNullifier(result.publicInputs, result.circuit);
636
+ const nullifier = sdk.extractNullifier(result.publicInputs, circuit);
438
637
 
439
- console.log('Scope:', scope); // '0x7a6b70726f...'
638
+ console.log('Scope:', scope); // '0x7a6b70726f...'
440
639
  console.log('Nullifier:', nullifier); // '0xabc123...'
441
640
 
442
641
  // Extract domain — only for OIDC Domain Attestation
443
- if (result.circuit === 'oidc_domain_attestation') {
444
- const domain = sdk.extractDomain(result.publicInputs, result.circuit);
642
+ if (circuit === 'oidc_domain_attestation') {
643
+ const domain = sdk.extractDomain(result.publicInputs, circuit);
445
644
  console.log('Domain:', domain); // 'example.com'
446
645
  }
447
646
  }
448
647
  ```
449
648
 
450
- The **nullifier** serves as a privacy-preserving user identifier:
451
- - Deterministic: same user + same scope = same nullifier (enables duplicate detection)
452
- - Privacy-preserving: the wallet address (Coinbase) or email (OIDC) is never revealed
453
- - Scope-bound: different scopes produce different nullifiers for the same user
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
666
+
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.
454
693
 
455
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.
456
695
 
457
- The **domain** (OIDC Domain Attestation only) is the email domain the user proved:
458
- - Extracted from the circuit's public inputs
459
- - Matches the domain parameter provided during proof request
460
- - Available only for `oidc_domain_attestation` circuits
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
+ ```
461
708
 
462
709
  **Standalone utility functions** are also available for use outside the SDK class:
463
710
 
@@ -468,7 +715,8 @@ import {
468
715
  extractDomainFromPublicInputs,
469
716
  } from '@zkproofport-app/sdk';
470
717
 
471
- // Works with all circuits: coinbase_attestation, coinbase_country_attestation, oidc_domain_attestation, mdl_kr_ownership, mdl_kr_age, mdl_kr_region
718
+ // Scope and nullifier work for all six circuits — the layout is picked from the
719
+ // circuit id, so always pass it:
472
720
  const scope = extractScopeFromPublicInputs(publicInputs, 'coinbase_attestation');
473
721
  const nullifier = extractNullifierFromPublicInputs(publicInputs, 'coinbase_attestation');
474
722
 
@@ -476,18 +724,26 @@ const nullifier = extractNullifierFromPublicInputs(publicInputs, 'coinbase_attes
476
724
  const oidcScope = extractScopeFromPublicInputs(publicInputs, 'oidc_domain_attestation');
477
725
  const oidcNullifier = extractNullifierFromPublicInputs(publicInputs, 'oidc_domain_attestation');
478
726
 
727
+ // The three mDL circuits share one layout
728
+ const mdlNullifier = extractNullifierFromPublicInputs(publicInputs, 'mdl_kr_age');
729
+
479
730
  // Extract domain from OIDC Domain Attestation
480
731
  const domain = extractDomainFromPublicInputs(publicInputs, 'oidc_domain_attestation');
481
- // domain: 'example.com' or null if circuit doesn't match or inputs insufficient
732
+ // domain: 'example.com', or null if the circuit doesn't match or inputs are insufficient
482
733
  ```
483
734
 
735
+ Omitting the circuit argument falls back to the `coinbase_attestation` layout, which is why the examples above always pass it explicitly.
736
+
484
737
  ## Complete Example
485
738
 
486
739
  End-to-end integration using the relay flow:
487
740
 
488
741
  ```typescript
489
742
  import { ProofportSDK } from '@zkproofport-app/sdk';
490
- import { BrowserProvider } from 'ethers';
743
+ import type { CircuitType, ProofResponse } from '@zkproofport-app/sdk';
744
+ import { BrowserProvider, keccak256, toUtf8Bytes } from 'ethers';
745
+
746
+ const SCOPE = 'myapp.com';
491
747
 
492
748
  async function verifyUser() {
493
749
  // Initialize
@@ -500,7 +756,7 @@ async function verifyUser() {
500
756
 
501
757
  // Create proof request via relay
502
758
  const relay = await sdk.createRelayRequest('coinbase_attestation', {
503
- scope: 'myapp.com',
759
+ scope: SCOPE,
504
760
  }, {
505
761
  dappName: 'My DApp',
506
762
  message: 'Verify your identity',
@@ -508,30 +764,53 @@ async function verifyUser() {
508
764
 
509
765
  // Display QR code
510
766
  const qrDataUrl = await sdk.generateQRCode(relay.deepLink, { width: 400 });
511
- document.getElementById('qr-image').src = qrDataUrl;
512
- document.getElementById('status').textContent = 'Scan the QR code with ZKProofport';
767
+ (document.getElementById('qr-image') as HTMLImageElement).src = qrDataUrl;
768
+ document.getElementById('status')!.textContent = 'Scan the QR code with ZKProofport';
513
769
 
514
770
  // Wait for proof result
515
771
  const result = await sdk.waitForProof(relay.requestId, {
516
772
  onStatusChange: (update) => {
517
- document.getElementById('status').textContent = `Status: ${update.status}`;
773
+ document.getElementById('status')!.textContent = `Status: ${update.status}`;
518
774
  },
519
775
  });
520
776
 
521
777
  if (result.status === 'completed') {
778
+ const circuit = result.circuit as CircuitType;
779
+ const publicInputs = result.publicInputs ?? [];
780
+
522
781
  // Verify on-chain
523
- const verification = await sdk.verifyOnChain(
524
- result.circuit,
525
- result.proof,
526
- result.publicInputs
527
- );
528
-
529
- if (verification.valid) {
530
- document.getElementById('status').textContent = 'Identity verified!';
531
- // Grant access to your application
782
+ const response: ProofResponse = {
783
+ requestId: result.requestId,
784
+ circuit,
785
+ status: 'completed',
786
+ proof: result.proof,
787
+ publicInputs,
788
+ verifierAddress: result.verifierAddress,
789
+ chainId: result.chainId,
790
+ };
791
+
792
+ const verification = await sdk.verifyResponseOnChain(response);
793
+
794
+ if (!verification.valid) {
795
+ document.getElementById('status')!.textContent = `Invalid proof: ${verification.error}`;
796
+ sdk.disconnect();
797
+ return;
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;
532
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
533
812
  } else {
534
- document.getElementById('status').textContent = `Failed: ${result.error}`;
813
+ document.getElementById('status')!.textContent = `Failed: ${result.error}`;
535
814
  }
536
815
 
537
816
  // Cleanup
@@ -541,11 +820,11 @@ async function verifyUser() {
541
820
 
542
821
  ## Configuration
543
822
 
544
- `ProofportSDK.create()` returns a fully configured SDK instance. No manual configuration is needed — relay URLs, verifier contracts, and chain settings are all 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`).
545
824
 
546
825
  ## Types Reference
547
826
 
548
- All 15 exported types:
827
+ Types you can import:
549
828
 
550
829
  ```typescript
551
830
  import type {
@@ -554,6 +833,9 @@ import type {
554
833
  CoinbaseKycInputs,
555
834
  CoinbaseCountryInputs,
556
835
  OidcDomainInputs,
836
+ MdlKrOwnershipInputs,
837
+ MdlKrAgeInputs,
838
+ MdlKrRegionInputs,
557
839
  CircuitInputs,
558
840
  ProofRequest,
559
841
  ProofResponse,
@@ -569,21 +851,24 @@ import type {
569
851
 
570
852
  | Type | Description |
571
853
  |------|-------------|
572
- | `CircuitType` | `'coinbase_attestation' \| 'coinbase_country_attestation' \| 'oidc_domain_attestation'` |
573
- | `ProofRequestStatus` | `'pending' \| 'completed' \| 'error' \| 'cancelled'` |
574
- | `CoinbaseKycInputs` | Inputs for `coinbase_attestation` (`{ scope, userAddress?, rawTransaction? }`) |
575
- | `CoinbaseCountryInputs` | Inputs for `coinbase_country_attestation` (`{ scope, countryList, isIncluded, ... }`) |
576
- | `OidcDomainInputs` | Inputs for `oidc_domain_attestation` (`{ domain, scope, provider? }`) |
577
- | `CircuitInputs` | Union: `CoinbaseKycInputs \| CoinbaseCountryInputs \| OidcDomainInputs` |
578
- | `ProofRequest` | Proof request object with `requestId`, `circuit`, `inputs`, metadata, and expiry |
579
- | `ProofResponse` | Proof response with `status`, `proof`, `publicInputs`, `verifierAddress`, `chainId` |
854
+ | `CircuitType` | `'coinbase_attestation' \| 'coinbase_country_attestation' \| 'oidc_domain_attestation' \| 'mdl_kr_ownership' \| 'mdl_kr_age' \| 'mdl_kr_region'` |
855
+ | `ProofRequestStatus` | `'pending' \| 'completed' \| 'error' \| 'cancelled'` — the status on a `ProofResponse` |
856
+ | `CoinbaseKycInputs` | Inputs for `coinbase_attestation`: `{ scope, userAddress?, rawTransaction? }` |
857
+ | `CoinbaseCountryInputs` | Inputs for `coinbase_country_attestation`: `{ scope, countryList, isIncluded, userAddress?, rawTransaction? }` |
858
+ | `OidcDomainInputs` | Inputs for `oidc_domain_attestation`: `{ domain, scope, provider? }` |
859
+ | `MdlKrOwnershipInputs` | Inputs for `mdl_kr_ownership`: `{ scope, discloseFlags? }` |
860
+ | `MdlKrAgeInputs` | Inputs for `mdl_kr_age`: `{ scope, ageThreshold }` |
861
+ | `MdlKrRegionInputs` | Inputs for `mdl_kr_region`: `{ scope, targetRegion }` |
862
+ | `CircuitInputs` | Union of every input type above (plus an empty-input form for circuits that need nothing from the dApp) |
863
+ | `ProofRequest` | Request object: `requestId`, `circuit`, `inputs`, `createdAt`, plus optional `message`, `dappName`, `dappIcon`, `returnScheme`, `expiresAt` |
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) |
580
865
  | `QRCodeOptions` | QR customization: `width`, `margin`, `darkColor`, `lightColor`, `errorCorrectionLevel` |
581
866
  | `VerifierContract` | Verifier contract info: `{ address, chainId, abi }` |
582
- | `ProofportConfig` | SDK configuration (internal use — `ProofportSDK.create()` handles defaults) |
583
- | `ChallengeResponse` | Challenge from relay: `{ challenge, expiresAt }` |
584
- | `WalletSigner` | Signer interface: `{ signMessage(msg), getAddress() }` |
585
- | `RelayProofRequest` | Relay response: `{ requestId, deepLink, status, pollUrl }` |
586
- | `RelayProofResult` | Relay result: `{ requestId, status, proof?, publicInputs?, circuit?, error? }` |
867
+ | `ProofportConfig` | Constructor configuration — `ProofportSDK.create()` fills it in for you |
868
+ | `ChallengeResponse` | Challenge from the relay: `{ requestId, challenge, expiresAt }` |
869
+ | `WalletSigner` | Signer interface: `{ signMessage(message), getAddress() }` |
870
+ | `RelayProofRequest` | Result of `createRelayRequest()`: `{ requestId, deepLink, status, pollUrl }` |
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) |
587
872
 
588
873
  The `OidcDomainInputs` interface:
589
874
 
@@ -595,81 +880,128 @@ interface OidcDomainInputs {
595
880
  }
596
881
  ```
597
882
 
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.
884
+
598
885
  ## Public Input Layout Constants
599
886
 
600
- 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.
601
888
 
602
889
  ```typescript
603
890
  import {
604
891
  COINBASE_ATTESTATION_PUBLIC_INPUT_LAYOUT,
605
892
  COINBASE_COUNTRY_PUBLIC_INPUT_LAYOUT,
606
893
  OIDC_DOMAIN_ATTESTATION_PUBLIC_INPUT_LAYOUT,
894
+ MDL_KR_PUBLIC_INPUT_LAYOUT,
607
895
  } from '@zkproofport-app/sdk';
608
896
  ```
609
897
 
610
- **Coinbase KYC Attestation** (128 fields total):
898
+ Two rules hold for every table below:
899
+
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.
902
+
611
903
  ```typescript
612
- COINBASE_ATTESTATION_PUBLIC_INPUT_LAYOUT = {
613
- SIGNAL_HASH_START: 0, // RSA modulus limbs (Coinbase signer)
614
- SIGNAL_HASH_END: 31,
615
- MERKLE_ROOT_START: 32, // Merkle root of signers
616
- MERKLE_ROOT_END: 63,
617
- SCOPE_START: 64, // keccak256 hash of scope string
618
- SCOPE_END: 95,
619
- NULLIFIER_START: 96, // Unique identifier per user+scope
620
- NULLIFIER_END: 127,
621
- }
622
- ```
904
+ const L = COINBASE_ATTESTATION_PUBLIC_INPUT_LAYOUT;
623
905
 
624
- **Coinbase Country Attestation** (150 fields total):
625
- ```typescript
626
- COINBASE_COUNTRY_PUBLIC_INPUT_LAYOUT = {
627
- SIGNAL_HASH_START: 0,
628
- SIGNAL_HASH_END: 31,
629
- MERKLE_ROOT_START: 32,
630
- MERKLE_ROOT_END: 63,
631
- COUNTRY_LIST_START: 64, // Packed country codes
632
- COUNTRY_LIST_END: 83,
633
- COUNTRY_LIST_LENGTH: 84, // Number of countries
634
- IS_INCLUDED: 85, // Boolean: user in list or not
635
- SCOPE_START: 86,
636
- SCOPE_END: 117,
637
- NULLIFIER_START: 118,
638
- NULLIFIER_END: 149,
639
- }
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));
640
911
  ```
641
912
 
642
- **OIDC Domain Attestation** (148 fields total):
913
+ ### `coinbase_attestation` — 128 entries
914
+
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 |
921
+
922
+ ### `coinbase_country_attestation` — 150 entries
923
+
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 |
946
+
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
+
643
967
  ```typescript
644
- OIDC_DOMAIN_ATTESTATION_PUBLIC_INPUT_LAYOUT = {
645
- PUBKEY_MODULUS_START: 0, // RSA modulus limbs (JWT issuer key)
646
- PUBKEY_MODULUS_END: 17,
647
- DOMAIN_STORAGE_START: 18, // Domain bytes (up to 64 ASCII characters)
648
- DOMAIN_STORAGE_END: 81,
649
- DOMAIN_LEN: 82, // Domain string length
650
- SCOPE_START: 83, // keccak256 hash of scope string
651
- SCOPE_END: 114,
652
- NULLIFIER_START: 115, // Unique identifier per user+scope
653
- NULLIFIER_END: 146,
654
- PROVIDER: 147, // OIDC provider code (0=none, 1=Google, 2=Microsoft)
968
+ import { keccak256 } from 'ethers';
655
969
 
656
- // Deprecated aliases (use new names above)
657
- DOMAIN_START: 18, // @deprecated Use DOMAIN_STORAGE_START
658
- DOMAIN_END: 82, // @deprecated Use DOMAIN_LEN
659
- }
660
- ```
970
+ const regionCodeFor = (region: string): string => {
971
+ const padded = new Uint8Array(64);
972
+ padded.set(new TextEncoder().encode(region));
973
+ return keccak256(padded);
974
+ };
661
975
 
662
- > **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.
976
+ // regionCodeFor('경기도') — compare against indices 64–95, rebuilt with toBytes32()
977
+ ```
663
978
 
664
979
  ## Error Handling
665
980
 
666
- All async SDK methods throw standard `Error` objects:
981
+ Async SDK methods throw standard `Error` objects. The messages are stable enough to log, not to switch on:
667
982
 
668
983
  ```typescript
669
984
  try {
985
+ // Coinbase circuit without a signer
670
986
  await sdk.createRelayRequest('coinbase_attestation', { scope: 'app.com' });
671
987
  } catch (err) {
672
- // "Signer not set. Call setSigner() first."
988
+ // "Signer not set. Call setSigner() first. Wallet signature is required for this circuit."
989
+ }
990
+
991
+ try {
992
+ await sdk.createRelayRequest('mdl_kr_age', { scope: 'app.com', ageThreshold: 0 });
993
+ } catch (err) {
994
+ // "ageThreshold is required and must be an integer between 1 and 150 (circuit: mdl_kr_age)"
995
+ }
996
+
997
+ try {
998
+ await sdk.createRelayRequest('coinbase_attestation', { scope: 'app.com' }, {
999
+ returnScheme: 'https://myapp.com/done',
1000
+ });
1001
+ } catch (err) {
1002
+ // "returnScheme must be a bare custom scheme such as "mydapp://" or an https
1003
+ // origin such as "https://myapp.com" — paths, query strings and fragments are
1004
+ // not accepted" (thrown before any network call)
673
1005
  }
674
1006
 
675
1007
  try {
@@ -679,6 +1011,12 @@ try {
679
1011
  }
680
1012
  ```
681
1013
 
1014
+ 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()`.
1015
+
1016
+ Verification is the exception: `verifyResponseOnChain` and `verifyOnChain` report failures as `{ valid: false, error }` instead of throwing.
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
+
682
1020
  ## Networks
683
1021
 
684
1022
  Proofs are verified on **Base** (Ethereum L2). The SDK handles network configuration automatically — no manual setup required.