@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 +492 -154
- package/dist/ProofportSDK.d.ts +24 -3
- package/dist/deeplink.d.ts +16 -0
- package/dist/index.esm.js +97 -3
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +97 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +97 -3
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.ts +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@zkproofport-app/sdk)
|
|
4
4
|
[](./LICENSE)
|
|
5
5
|
|
|
6
|
-
TypeScript SDK for requesting zero-knowledge proofs from the [ZKProofport](https://zkproofport.com) mobile app and verifying them on-chain.
|
|
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
|
|
31
|
+
│ on-chain │ │ (Base) │ │ or denied │
|
|
32
32
|
└──────────────┘ └──────────────────┘ └───────────────────┘
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
1. Your app
|
|
36
|
-
2. The SDK
|
|
37
|
-
3.
|
|
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
|
|
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
|
|
83
|
-
result.
|
|
84
|
-
result.
|
|
85
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
407
|
-
result.
|
|
408
|
-
result.
|
|
409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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,
|
|
636
|
+
const nullifier = sdk.extractNullifier(result.publicInputs, circuit);
|
|
438
637
|
|
|
439
|
-
console.log('Scope:', scope);
|
|
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 (
|
|
444
|
-
const domain = sdk.extractDomain(result.publicInputs,
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
//
|
|
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 {
|
|
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:
|
|
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')
|
|
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')
|
|
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
|
|
524
|
-
result.
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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')
|
|
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
|
|
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
|
-
|
|
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
|
|
575
|
-
| `CoinbaseCountryInputs` | Inputs for `coinbase_country_attestation
|
|
576
|
-
| `OidcDomainInputs` | Inputs for `oidc_domain_attestation
|
|
577
|
-
| `
|
|
578
|
-
| `
|
|
579
|
-
| `
|
|
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` |
|
|
583
|
-
| `ChallengeResponse` | Challenge from relay: `{ challenge, expiresAt }` |
|
|
584
|
-
| `WalletSigner` | Signer interface: `{ signMessage(
|
|
585
|
-
| `RelayProofRequest` |
|
|
586
|
-
| `RelayProofResult` |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
976
|
+
// regionCodeFor('경기도') — compare against indices 64–95, rebuilt with toBytes32()
|
|
977
|
+
```
|
|
663
978
|
|
|
664
979
|
## Error Handling
|
|
665
980
|
|
|
666
|
-
|
|
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.
|