@zkproofport-app/sdk 0.2.9 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +286 -103
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ TypeScript SDK for requesting zero-knowledge proofs from the [ZKProofport](https
|
|
|
37
37
|
3. Your app shows the returned deep link as a QR code (desktop) or navigates to it (mobile)
|
|
38
38
|
4. The user opens the ZKProofport app, which generates the ZK proof on-device
|
|
39
39
|
5. The proof result flows back through the relay to your app over WebSocket (HTTP polling as fallback)
|
|
40
|
-
6. Your app verifies the proof on-chain
|
|
40
|
+
6. Your app verifies the proof on-chain, then reads its public inputs to confirm it answers the question that was asked
|
|
41
41
|
|
|
42
42
|
## Installation
|
|
43
43
|
|
|
@@ -269,9 +269,9 @@ Any ethers v5/v6 `Signer` is compatible.
|
|
|
269
269
|
|
|
270
270
|
#### About challenge-signature
|
|
271
271
|
|
|
272
|
-
The challenge-signature mechanism
|
|
272
|
+
The challenge-signature mechanism exists **for replay prevention**. Each challenge is one-time use and is consumed immediately. The relay recovers the signer's address from the signature and logs it; the address is not fed into the circuit, and it does not have to be the address that holds the attestation — the mobile app decides that, from `userAddress` or from the wallet the user connects.
|
|
273
273
|
|
|
274
|
-
|
|
274
|
+
So for server-side or headless environments an ephemeral random wallet is fine. A persistent wallet (fixed private key) is **not recommended**: it adds key management for no functional benefit.
|
|
275
275
|
|
|
276
276
|
```typescript
|
|
277
277
|
import { Wallet } from 'ethers';
|
|
@@ -350,7 +350,7 @@ Omit `returnScheme` and nothing changes: the request is created without it and t
|
|
|
350
350
|
|
|
351
351
|
```typescript
|
|
352
352
|
// Email domain verification
|
|
353
|
-
const
|
|
353
|
+
const relayDomain = await sdk.createRelayRequest('oidc_domain_attestation', {
|
|
354
354
|
domain: 'company.com',
|
|
355
355
|
scope: 'myapp.com',
|
|
356
356
|
}, {
|
|
@@ -360,7 +360,7 @@ const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
|
|
|
360
360
|
});
|
|
361
361
|
|
|
362
362
|
// Organization membership verification (Google Workspace)
|
|
363
|
-
const
|
|
363
|
+
const relayGoogle = await sdk.createRelayRequest('oidc_domain_attestation', {
|
|
364
364
|
domain: 'company.com',
|
|
365
365
|
scope: 'myapp.com',
|
|
366
366
|
provider: 'google',
|
|
@@ -370,7 +370,7 @@ const relay = await sdk.createRelayRequest('oidc_domain_attestation', {
|
|
|
370
370
|
});
|
|
371
371
|
|
|
372
372
|
// Organization membership verification (Microsoft 365)
|
|
373
|
-
const
|
|
373
|
+
const relayMicrosoft = await sdk.createRelayRequest('oidc_domain_attestation', {
|
|
374
374
|
domain: 'company.com',
|
|
375
375
|
scope: 'myapp.com',
|
|
376
376
|
provider: 'microsoft',
|
|
@@ -390,12 +390,12 @@ Generate a QR code from the relay deep link for the user to scan with the ZKProo
|
|
|
390
390
|
const qrDataUrl = await sdk.generateQRCode(relay.deepLink, {
|
|
391
391
|
width: 400, // pixels (default: 300)
|
|
392
392
|
darkColor: '#1a1a1a',
|
|
393
|
-
margin: 4,
|
|
393
|
+
margin: 4, // quiet zone in modules (default: 2)
|
|
394
394
|
});
|
|
395
395
|
(document.getElementById('qr') as HTMLImageElement).src = qrDataUrl;
|
|
396
396
|
```
|
|
397
397
|
|
|
398
|
-
`QRCodeOptions` accepts `width
|
|
398
|
+
`QRCodeOptions` accepts `width` (default `300`), `margin` (default `2`), `darkColor` (default `'#000000'`), `lightColor` (default `'#ffffff'`) and `errorCorrectionLevel` (`'L' | 'M' | 'Q' | 'H'`, default `'M'`).
|
|
399
399
|
|
|
400
400
|
**Other QR formats:**
|
|
401
401
|
|
|
@@ -410,6 +410,8 @@ await sdk.renderQRCodeToCanvas(canvasElement, relay.deepLink, { width: 400 });
|
|
|
410
410
|
const { size, withinLimit } = sdk.checkQRCodeSize(relay.deepLink);
|
|
411
411
|
```
|
|
412
412
|
|
|
413
|
+
All three generators throw `QR code data too large (… bytes). Maximum is 2953 bytes.` rather than emitting an unscannable image. Relay deep links are far below that; the check matters if you build your own link.
|
|
414
|
+
|
|
413
415
|
**Mobile:** On mobile browsers, redirect directly to the deep link instead of showing a QR code:
|
|
414
416
|
|
|
415
417
|
```typescript
|
|
@@ -431,7 +433,7 @@ const result = await sdk.waitForProof(relay.requestId, {
|
|
|
431
433
|
});
|
|
432
434
|
```
|
|
433
435
|
|
|
434
|
-
`
|
|
436
|
+
`waitForProof` settles once the request reaches a terminal state. Step 6 walks through the object it hands back, field by field, and through the states that are not terminal.
|
|
435
437
|
|
|
436
438
|
**Alternative: Subscribe to real-time updates directly:**
|
|
437
439
|
|
|
@@ -464,11 +466,119 @@ const result = await sdk.waitForResult(relay.requestId, {
|
|
|
464
466
|
|
|
465
467
|
When you are done, `sdk.disconnect()` closes any open socket.
|
|
466
468
|
|
|
467
|
-
### Step 6:
|
|
469
|
+
### Step 6: Read the Result
|
|
470
|
+
|
|
471
|
+
`waitForProof()`, `waitForResult()` and `pollResult()` all hand back the same object, a `RelayProofResult`. A completed one looks like this (long values elided):
|
|
472
|
+
|
|
473
|
+
```typescript
|
|
474
|
+
{
|
|
475
|
+
requestId: '9f1c8a02-4e2b-4f0a-9c3e-1d5a7b0e2f44',
|
|
476
|
+
status: 'completed',
|
|
477
|
+
circuit: 'coinbase_attestation',
|
|
478
|
+
proof: '0x1f8b0a…', // proof bytes, opaque
|
|
479
|
+
publicInputs: ['0x00…2f', '0x00…a1', …], // ordered — 128 entries for this circuit
|
|
480
|
+
verifierAddress: '0x…', // contract that can check this proof
|
|
481
|
+
chainId: …, // network that contract lives on
|
|
482
|
+
deepLink: 'zkproofport://proof-request?data=…',
|
|
483
|
+
createdAt: '2026-02-07T09:14:02.101Z',
|
|
484
|
+
updatedAt: '2026-02-07T09:14:48.902Z',
|
|
485
|
+
}
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
| Field | Type | Present when | What it is |
|
|
489
|
+
|-------|------|--------------|------------|
|
|
490
|
+
| `requestId` | `string` | always | The relay-issued id you waited on. Echoed back so one handler can serve many requests. |
|
|
491
|
+
| `status` | `string` | always | Lifecycle state — see below. |
|
|
492
|
+
| `circuit` | `string` | terminal results | Which circuit produced the proof, as reported by the mobile app. You need it to read `publicInputs`, because the layout is per circuit. Undefined while the request is still pending. |
|
|
493
|
+
| `proof` | `string` | `'completed'` | The proof bytes as a `0x` hex string. Opaque: the only thing to do with it is hand it to a verifier. |
|
|
494
|
+
| `publicInputs` | `string[]` | `'completed'` | The circuit's public inputs **in circuit order**. This is the part that carries meaning — what each index holds is in [Public Input Layout Constants](#public-input-layout-constants). |
|
|
495
|
+
| `verifierAddress` | `string` | `'completed'` | Address of the Solidity verifier contract that can check this exact proof. It comes from the mobile app — forward it, do not hardcode one. |
|
|
496
|
+
| `chainId` | `number` | `'completed'` | The chain that `verifierAddress` lives on. Same rule: forward it. |
|
|
497
|
+
| `error` | `string` | failures | Human-readable reason from the mobile app or the relay. |
|
|
498
|
+
| `deepLink` | `string` | polling path only | The deep link the request was created with. |
|
|
499
|
+
| `createdAt`, `updatedAt` | `string` | polling path only | ISO timestamps of creation and last update. |
|
|
500
|
+
|
|
501
|
+
The last three come from the relay's polling endpoint. `waitForProof()` normally resolves over the WebSocket, whose payload does not carry them, so do not build logic on their presence.
|
|
502
|
+
|
|
503
|
+
#### What `status` can be
|
|
504
|
+
|
|
505
|
+
| `status` | Terminal | What happened | What you do |
|
|
506
|
+
|----------|----------|---------------|-------------|
|
|
507
|
+
| `'pending'` | no | The request exists; no proof yet | Keep waiting — `waitForProof()` does that for you |
|
|
508
|
+
| `'completed'` | yes | A proof came back | `proof`, `publicInputs`, `verifierAddress` and `chainId` are set. Verify it (Step 7) before trusting anything in it |
|
|
509
|
+
| `'error'` | yes | The app could not produce a proof — no attestation on that address, sign-in refused, generation failed | Read `error`, show it, let the user start over |
|
|
510
|
+
| `'failed'` | yes | Same meaning and same handling as `'error'` | Handle the pair; the type declares `'failed'`, the app sends `'error'` |
|
|
511
|
+
| `'cancelled'` | see below | The user declined the request in the ZKProofport app | Reset your UI |
|
|
512
|
+
|
|
513
|
+
`RelayProofResult` declares `status` as `'pending' | 'completed' | 'failed'`, which is narrower than what the relay can deliver, so TypeScript rejects a direct comparison against `'error'`. Widen it once and branch on the string:
|
|
514
|
+
|
|
515
|
+
```typescript
|
|
516
|
+
const status: string = result.status;
|
|
517
|
+
|
|
518
|
+
if (status === 'completed') {
|
|
519
|
+
// verify it
|
|
520
|
+
} else if (status === 'error' || status === 'failed') {
|
|
521
|
+
showError(result.error ?? 'Proof generation failed');
|
|
522
|
+
}
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
`waitForProof()` settles on `'completed'`, `'error'` and `'failed'`. It does **not** settle on `'cancelled'` — a cancellation arrives as a status update, and if you ignore it the call sits there until `timeoutMs` expires. Watch for it if you want to drop the QR dialog the moment the user says no:
|
|
526
|
+
|
|
527
|
+
```typescript
|
|
528
|
+
const result = await sdk.waitForProof(relay.requestId, {
|
|
529
|
+
onStatusChange: (update) => {
|
|
530
|
+
if (update.status === 'cancelled') {
|
|
531
|
+
// The user declined in the app. This promise will not settle on its own.
|
|
532
|
+
hideQrDialog();
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
`waitForResult()`, the polling path, is narrower still: it returns only on `'completed'` and `'failed'`, and keeps polling through `'error'` and `'cancelled'` until `timeoutMs`. Prefer `waitForProof()`, or drive `pollResult()` in a loop of your own if you want to decide what counts as terminal.
|
|
539
|
+
|
|
540
|
+
#### What is not on the result
|
|
541
|
+
|
|
542
|
+
- **No `nullifier` field.** The nullifier is inside `publicInputs`. Read it with `sdk.extractNullifier()` — Step 8.
|
|
543
|
+
- **No `numPublicInputs` field.** It is `publicInputs.length`.
|
|
544
|
+
- **A `'completed'` result can arrive with `proof` and `publicInputs` missing.** That means the relay's buffered copy of the result has expired — results are kept for a short window only. There is nothing left to fetch for that `requestId`; the user has to make a new request.
|
|
545
|
+
|
|
546
|
+
#### Retry, or show the user?
|
|
547
|
+
|
|
548
|
+
| Situation | Retry automatically | Show the user |
|
|
549
|
+
|-----------|--------------------|----------------|
|
|
550
|
+
| `createRelayRequest()` throws `Too many requests. Please try again later.` | Yes, with backoff | No |
|
|
551
|
+
| Network failure reaching the relay | Yes | No |
|
|
552
|
+
| `pollResult()` throws `Request not found or expired` | No — that id is gone | Yes, ask them to start again |
|
|
553
|
+
| `status: 'error'` / `'failed'` | No — a retry re-runs the same failing thing | Yes: `error` says what went wrong, and it is usually the user's move (connect the right wallet, sign in, use the device holding the licence) |
|
|
554
|
+
| `status: 'cancelled'` | No | Nothing to say — they just declined |
|
|
555
|
+
| `waitForProof()` timeout | Only if the user asks for it | Yes |
|
|
556
|
+
| `verifyResponseOnChain()` returns `{ valid: false }` | No | Treat it as a rejected proof, not an outage — Step 7 |
|
|
557
|
+
|
|
558
|
+
#### `ProofResponse` — the shape on-chain verification takes
|
|
559
|
+
|
|
560
|
+
`verifyResponseOnChain()` does not take the relay result directly. It takes a `ProofResponse`, which you build from it:
|
|
561
|
+
|
|
562
|
+
| `ProofResponse` field | Type | Required | Where it comes from |
|
|
563
|
+
|-----------------------|------|----------|---------------------|
|
|
564
|
+
| `requestId` | `string` | Yes | `result.requestId` |
|
|
565
|
+
| `circuit` | `CircuitType` | Yes | `result.circuit`, narrowed — the relay types it as a plain `string` |
|
|
566
|
+
| `status` | `'pending' \| 'completed' \| 'error' \| 'cancelled'` | Yes | `'completed'`. Anything else and verification returns `{ valid: false, error: 'Invalid or incomplete response' }` without touching the chain. Note the relay says `'failed'` where this type says `'error'` |
|
|
567
|
+
| `proof` | `string` | When completed | `result.proof` |
|
|
568
|
+
| `publicInputs` | `string[]` | When completed | `result.publicInputs` |
|
|
569
|
+
| `verifierAddress` | `string` | When completed | `result.verifierAddress`. Without it there is no contract to call and verification fails with `No verifier address provided…` |
|
|
570
|
+
| `chainId` | `number` | When completed | `result.chainId`. Used to pick a network when you pass no provider of your own |
|
|
571
|
+
| `numPublicInputs` | `number` | No | Never delivered by the relay, and never read during verification — `publicInputs.length` is used instead. Omit it |
|
|
572
|
+
| `timestamp` | `number` | No | Not delivered by the relay. Omit it |
|
|
573
|
+
| `error` | `string` | On failure | `result.error`. Carried for your own logging; verification ignores it |
|
|
574
|
+
|
|
575
|
+
There is no `nullifier` field here either.
|
|
576
|
+
|
|
577
|
+
### Step 7: Verify On-Chain
|
|
468
578
|
|
|
469
579
|
Verify the proof cryptographically by calling the deployed Solidity verifier contract.
|
|
470
580
|
|
|
471
|
-
The relay result tells you which
|
|
581
|
+
The relay result tells you which contract to call (`verifierAddress`) and on which chain (`chainId`), so verification goes through `verifyResponseOnChain`, which reads both off the response:
|
|
472
582
|
|
|
473
583
|
```typescript
|
|
474
584
|
import type { CircuitType, ProofResponse } from '@zkproofport-app/sdk';
|
|
@@ -500,13 +610,17 @@ The SDK connects to the right network on its own. Pass your own ethers `Provider
|
|
|
500
610
|
const verification = await sdk.verifyResponseOnChain(response, myProvider);
|
|
501
611
|
```
|
|
502
612
|
|
|
503
|
-
`verifyResponseOnChain`
|
|
613
|
+
`verifyResponseOnChain` reports failures instead of throwing them. An incomplete response (`status` other than `'completed'`, or a missing `proof` / `publicInputs`) returns `{ valid: false, error: 'Invalid or incomplete response' }` without making a network call; a missing `verifierAddress` returns `{ valid: false, error: 'No verifier address provided…' }`; and a reverting contract call returns `{ valid: false, error }` carrying the revert message. It does throw in one case: a response carrying a `chainId` the SDK has no built-in endpoint for, when you passed no provider of your own. Pass your own provider if you ever hit that.
|
|
504
614
|
|
|
505
|
-
|
|
615
|
+
`valid: true` means the verifier contract accepted the proof: it is a real proof for that circuit, and its public inputs are the ones it was proved against. It does **not** yet mean the proof answers *your* question — that it was bound to your scope, or that the domain or country in it is the one you asked for. Those live in the public inputs, and reading them is Step 8. `valid: false` is a rejected proof, not an outage: do not retry it, refuse the user.
|
|
506
616
|
|
|
507
|
-
|
|
617
|
+
> **Why not `verifyOnChain`?** `sdk.verifyOnChain(circuit, proof, publicInputs, providerOrSigner?)` takes the same proof in raw pieces. Those pieces carry no verifier address, and it never looks at a response for one — it consults only the `verifiers` map the SDK was constructed with, which `ProofportSDK.create()` leaves empty. Out of the box it therefore returns `{ valid: false, error: 'No verifier address provided. Configure via SDK or ensure proof response includes verifierAddress.' }`. Use `verifyResponseOnChain`.
|
|
508
618
|
|
|
509
|
-
|
|
619
|
+
### Step 8: Extract Scope, Nullifier, and Domain
|
|
620
|
+
|
|
621
|
+
A verified proof tells you the public inputs are genuine. It does not tell you what they say. Reading them is a separate step, and it is the step that decides whether the proof answers the question you asked.
|
|
622
|
+
|
|
623
|
+
Do it **after** `verifyResponseOnChain()` returns `valid: true`. These helpers are plain parsers — they will happily decode an array that no verifier has ever seen.
|
|
510
624
|
|
|
511
625
|
```typescript
|
|
512
626
|
import type { CircuitType } from '@zkproofport-app/sdk';
|
|
@@ -532,19 +646,65 @@ if (result.status === 'completed' && result.publicInputs && result.circuit) {
|
|
|
532
646
|
}
|
|
533
647
|
```
|
|
534
648
|
|
|
535
|
-
All three return `null` rather than throwing when the public inputs are too short for the circuit's layout.
|
|
649
|
+
All three return `null` rather than throwing when the public inputs are too short for the circuit's layout. Pass the wrong circuit id and you get no error at all, just the wrong 32 entries decoded into a plausible-looking value — always pass the `circuit` the result came with.
|
|
650
|
+
|
|
651
|
+
#### Check the scope
|
|
652
|
+
|
|
653
|
+
Every circuit publishes `scope` as `keccak256` of the scope string you sent. Comparing it is how you find out that the proof was made for *your* request and not replayed from someone else's app:
|
|
654
|
+
|
|
655
|
+
```typescript
|
|
656
|
+
import { keccak256, toUtf8Bytes } from 'ethers';
|
|
657
|
+
|
|
658
|
+
const expectedScope = keccak256(toUtf8Bytes('myapp.com'));
|
|
659
|
+
|
|
660
|
+
if (sdk.extractScope(publicInputs, circuit) !== expectedScope) {
|
|
661
|
+
throw new Error('Proof was generated for a different scope');
|
|
662
|
+
}
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
#### Use the nullifier
|
|
536
666
|
|
|
537
|
-
The
|
|
538
|
-
|
|
539
|
-
-
|
|
540
|
-
-
|
|
667
|
+
The nullifier is a deterministic hash over the user's credential and the scope:
|
|
668
|
+
|
|
669
|
+
- **Deterministic** — same user, same scope, same nullifier, every time. That is what makes duplicate detection possible.
|
|
670
|
+
- **Privacy-preserving** — the wallet address (Coinbase), the email (OIDC) and the licence (mDL) stay hidden. There is no way back from the nullifier to the person.
|
|
671
|
+
- **Scope-bound** — the same user proving to a different scope produces an unrelated nullifier, so your users cannot be correlated across apps.
|
|
672
|
+
|
|
673
|
+
On your side, it is the primary key of a verified user. Store it once verification succeeds, with a unique constraint, and check it before granting anything:
|
|
674
|
+
|
|
675
|
+
```typescript
|
|
676
|
+
const nullifier = sdk.extractNullifier(publicInputs, circuit);
|
|
677
|
+
if (!nullifier) throw new Error('Public inputs too short for this circuit');
|
|
678
|
+
|
|
679
|
+
// One claim per user, forever: the insert is the double-proof check.
|
|
680
|
+
const inserted = await db.verifiedUsers.insertIfAbsent({
|
|
681
|
+
nullifier, // '0xabc123…'
|
|
682
|
+
circuit,
|
|
683
|
+
claimedAt: new Date(),
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
if (!inserted) {
|
|
687
|
+
// This credential has already been used for this scope.
|
|
688
|
+
throw new Error('Already claimed');
|
|
689
|
+
}
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
Keep the scope you used fixed for the lifetime of that record. Change the scope string and every returning user looks brand new, because every nullifier changes with it.
|
|
541
693
|
|
|
542
694
|
> **OIDC Domain:** The nullifier is a hash of the user's email and scope. The same email + scope always produces the same nullifier, enabling Sybil resistance without revealing the email address.
|
|
543
695
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
696
|
+
#### Check the domain
|
|
697
|
+
|
|
698
|
+
`extractDomain()` (OIDC Domain Attestation only) returns the email domain the proof was made for, decoded from the public inputs — up to 64 ASCII characters. It returns `null` for every other circuit.
|
|
699
|
+
|
|
700
|
+
The circuit binds it to the signed token: the part of the email after `@` has to equal this domain, exactly, to the end of the address. What the circuit does not decide is *which* domain — the prover picks it. So compare it with what you asked for:
|
|
701
|
+
|
|
702
|
+
```typescript
|
|
703
|
+
const domain = sdk.extractDomain(publicInputs, 'oidc_domain_attestation');
|
|
704
|
+
if (domain !== 'company.com') {
|
|
705
|
+
throw new Error(`Proof is for ${domain}, not company.com`);
|
|
706
|
+
}
|
|
707
|
+
```
|
|
548
708
|
|
|
549
709
|
**Standalone utility functions** are also available for use outside the SDK class:
|
|
550
710
|
|
|
@@ -581,7 +741,9 @@ End-to-end integration using the relay flow:
|
|
|
581
741
|
```typescript
|
|
582
742
|
import { ProofportSDK } from '@zkproofport-app/sdk';
|
|
583
743
|
import type { CircuitType, ProofResponse } from '@zkproofport-app/sdk';
|
|
584
|
-
import { BrowserProvider } from 'ethers';
|
|
744
|
+
import { BrowserProvider, keccak256, toUtf8Bytes } from 'ethers';
|
|
745
|
+
|
|
746
|
+
const SCOPE = 'myapp.com';
|
|
585
747
|
|
|
586
748
|
async function verifyUser() {
|
|
587
749
|
// Initialize
|
|
@@ -594,7 +756,7 @@ async function verifyUser() {
|
|
|
594
756
|
|
|
595
757
|
// Create proof request via relay
|
|
596
758
|
const relay = await sdk.createRelayRequest('coinbase_attestation', {
|
|
597
|
-
scope:
|
|
759
|
+
scope: SCOPE,
|
|
598
760
|
}, {
|
|
599
761
|
dappName: 'My DApp',
|
|
600
762
|
message: 'Verify your identity',
|
|
@@ -613,25 +775,40 @@ async function verifyUser() {
|
|
|
613
775
|
});
|
|
614
776
|
|
|
615
777
|
if (result.status === 'completed') {
|
|
778
|
+
const circuit = result.circuit as CircuitType;
|
|
779
|
+
const publicInputs = result.publicInputs ?? [];
|
|
780
|
+
|
|
616
781
|
// Verify on-chain
|
|
617
782
|
const response: ProofResponse = {
|
|
618
783
|
requestId: result.requestId,
|
|
619
|
-
circuit
|
|
784
|
+
circuit,
|
|
620
785
|
status: 'completed',
|
|
621
786
|
proof: result.proof,
|
|
622
|
-
publicInputs
|
|
787
|
+
publicInputs,
|
|
623
788
|
verifierAddress: result.verifierAddress,
|
|
624
789
|
chainId: result.chainId,
|
|
625
790
|
};
|
|
626
791
|
|
|
627
792
|
const verification = await sdk.verifyResponseOnChain(response);
|
|
628
793
|
|
|
629
|
-
if (verification.valid) {
|
|
630
|
-
document.getElementById('status')!.textContent = 'Identity verified!';
|
|
631
|
-
// Grant access to your application
|
|
632
|
-
} else {
|
|
794
|
+
if (!verification.valid) {
|
|
633
795
|
document.getElementById('status')!.textContent = `Invalid proof: ${verification.error}`;
|
|
796
|
+
sdk.disconnect();
|
|
797
|
+
return;
|
|
634
798
|
}
|
|
799
|
+
|
|
800
|
+
// A real proof is not yet a proof of what you asked: check the scope.
|
|
801
|
+
if (sdk.extractScope(publicInputs, circuit) !== keccak256(toUtf8Bytes(SCOPE))) {
|
|
802
|
+
document.getElementById('status')!.textContent = 'Proof was made for another scope';
|
|
803
|
+
sdk.disconnect();
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Key the user on the nullifier — same person + same scope, same value
|
|
808
|
+
const nullifier = sdk.extractNullifier(publicInputs, circuit);
|
|
809
|
+
document.getElementById('status')!.textContent = 'Identity verified!';
|
|
810
|
+
console.log('Verified user:', nullifier);
|
|
811
|
+
// Grant access to your application
|
|
635
812
|
} else {
|
|
636
813
|
document.getElementById('status')!.textContent = `Failed: ${result.error}`;
|
|
637
814
|
}
|
|
@@ -643,7 +820,7 @@ async function verifyUser() {
|
|
|
643
820
|
|
|
644
821
|
## Configuration
|
|
645
822
|
|
|
646
|
-
`ProofportSDK.create()` returns a fully configured SDK instance. No manual configuration is needed
|
|
823
|
+
`ProofportSDK.create()` returns a fully configured SDK instance. No manual configuration is needed: the relay endpoint is built in, and the verifier contract and network to check a proof against arrive with the proof itself (`verifierAddress`, `chainId`).
|
|
647
824
|
|
|
648
825
|
## Types Reference
|
|
649
826
|
|
|
@@ -684,14 +861,14 @@ import type {
|
|
|
684
861
|
| `MdlKrRegionInputs` | Inputs for `mdl_kr_region`: `{ scope, targetRegion }` |
|
|
685
862
|
| `CircuitInputs` | Union of every input type above (plus an empty-input form for circuits that need nothing from the dApp) |
|
|
686
863
|
| `ProofRequest` | Request object: `requestId`, `circuit`, `inputs`, `createdAt`, plus optional `message`, `dappName`, `dappIcon`, `returnScheme`, `expiresAt` |
|
|
687
|
-
| `ProofResponse` |
|
|
864
|
+
| `ProofResponse` | What `verifyResponseOnChain()` takes: `requestId`, `circuit`, `status`, and — when completed — `proof`, `publicInputs`, `verifierAddress`, `chainId`; `error` when it failed. `numPublicInputs` and `timestamp` also exist but the relay never fills them, so omit them. Field by field in [Step 6](#step-6-read-the-result) |
|
|
688
865
|
| `QRCodeOptions` | QR customization: `width`, `margin`, `darkColor`, `lightColor`, `errorCorrectionLevel` |
|
|
689
866
|
| `VerifierContract` | Verifier contract info: `{ address, chainId, abi }` |
|
|
690
867
|
| `ProofportConfig` | Constructor configuration — `ProofportSDK.create()` fills it in for you |
|
|
691
868
|
| `ChallengeResponse` | Challenge from the relay: `{ requestId, challenge, expiresAt }` |
|
|
692
869
|
| `WalletSigner` | Signer interface: `{ signMessage(message), getAddress() }` |
|
|
693
870
|
| `RelayProofRequest` | Result of `createRelayRequest()`: `{ requestId, deepLink, status, pollUrl }` |
|
|
694
|
-
| `RelayProofResult` | Result of `waitForProof()` / `waitForResult()` / `pollResult()`: `{ requestId, status
|
|
871
|
+
| `RelayProofResult` | Result of `waitForProof()` / `waitForResult()` / `pollResult()`: `{ requestId, status, deepLink?, createdAt?, updatedAt?, proof?, publicInputs?, verifierAddress?, chainId?, circuit?, error? }`. `status` is declared as `'pending' \| 'completed' \| 'failed'` but the relay also delivers `'error'` and `'cancelled'` — [Step 6](#step-6-read-the-result) |
|
|
695
872
|
|
|
696
873
|
The `OidcDomainInputs` interface:
|
|
697
874
|
|
|
@@ -703,11 +880,11 @@ interface OidcDomainInputs {
|
|
|
703
880
|
}
|
|
704
881
|
```
|
|
705
882
|
|
|
706
|
-
Note that `RelayProofResult` (what you get back from the relay) and `ProofResponse` (what `verifyResponseOnChain` takes) are different types: the relay result's `status` uses `'failed'`, and its `circuit` is a plain `string`. Step 6 shows the conversion.
|
|
883
|
+
Note that `RelayProofResult` (what you get back from the relay) and `ProofResponse` (what `verifyResponseOnChain` takes) are different types: the relay result's `status` uses `'failed'`, and its `circuit` is a plain `string`. Step 6 shows both shapes side by side and Step 7 does the conversion.
|
|
707
884
|
|
|
708
885
|
## Public Input Layout Constants
|
|
709
886
|
|
|
710
|
-
|
|
887
|
+
`publicInputs` is an **ordered array whose layout is fixed by the circuit**. Index 64 in one circuit has nothing to do with index 64 in another. Reading it with the wrong layout does not fail loudly — it returns a well-formed value that means something else, which is how a proof ends up verifying a claim nobody asked for. Prefer `sdk.extractScope()` / `extractNullifier()` / `extractDomain()`, and reach for these constants only for the fields those do not cover.
|
|
711
888
|
|
|
712
889
|
```typescript
|
|
713
890
|
import {
|
|
@@ -718,82 +895,86 @@ import {
|
|
|
718
895
|
} from '@zkproofport-app/sdk';
|
|
719
896
|
```
|
|
720
897
|
|
|
721
|
-
|
|
722
|
-
```typescript
|
|
723
|
-
COINBASE_ATTESTATION_PUBLIC_INPUT_LAYOUT = {
|
|
724
|
-
SIGNAL_HASH_START: 0, // Signal hash
|
|
725
|
-
SIGNAL_HASH_END: 31,
|
|
726
|
-
MERKLE_ROOT_START: 32, // Merkle root of the attestation signer list
|
|
727
|
-
MERKLE_ROOT_END: 63,
|
|
728
|
-
SCOPE_START: 64, // Scope value
|
|
729
|
-
SCOPE_END: 95,
|
|
730
|
-
NULLIFIER_START: 96, // Unique identifier per user+scope
|
|
731
|
-
NULLIFIER_END: 127,
|
|
732
|
-
}
|
|
733
|
-
```
|
|
898
|
+
Two rules hold for every table below:
|
|
734
899
|
|
|
735
|
-
**
|
|
736
|
-
|
|
737
|
-
COINBASE_COUNTRY_PUBLIC_INPUT_LAYOUT = {
|
|
738
|
-
SIGNAL_HASH_START: 0,
|
|
739
|
-
SIGNAL_HASH_END: 31,
|
|
740
|
-
MERKLE_ROOT_START: 32,
|
|
741
|
-
MERKLE_ROOT_END: 63,
|
|
742
|
-
COUNTRY_LIST_START: 64, // Packed country codes
|
|
743
|
-
COUNTRY_LIST_END: 83,
|
|
744
|
-
COUNTRY_LIST_LENGTH: 84, // Number of countries
|
|
745
|
-
IS_INCLUDED: 85, // Boolean: user in list or not
|
|
746
|
-
SCOPE_START: 86,
|
|
747
|
-
SCOPE_END: 117,
|
|
748
|
-
NULLIFIER_START: 118,
|
|
749
|
-
NULLIFIER_END: 149,
|
|
750
|
-
}
|
|
751
|
-
```
|
|
900
|
+
- **Every number is an index into `publicInputs`, and `_END` is inclusive.** Slice with `slice(START, END + 1)`.
|
|
901
|
+
- **One entry is one field element, not one 32-byte value.** A `bytes32` such as a scope or a nullifier is spread over 32 consecutive entries, each holding a single byte in its low byte: `publicInputs[64] === '0x00…2f'` is the byte `0x2f`. Rebuild it by concatenating those 32 low bytes. The one exception is the OIDC RSA modulus, whose 18 entries each hold a 128-bit limb.
|
|
752
902
|
|
|
753
|
-
**OIDC Domain Attestation** (148 fields):
|
|
754
903
|
```typescript
|
|
755
|
-
|
|
756
|
-
PUBKEY_MODULUS_START: 0, // RSA modulus limbs (JWT issuer key)
|
|
757
|
-
PUBKEY_MODULUS_END: 17,
|
|
758
|
-
DOMAIN_STORAGE_START: 18, // Domain bytes (up to 64 ASCII characters)
|
|
759
|
-
DOMAIN_STORAGE_END: 81,
|
|
760
|
-
DOMAIN_LEN: 82, // Domain string length
|
|
761
|
-
SCOPE_START: 83, // Scope value
|
|
762
|
-
SCOPE_END: 114,
|
|
763
|
-
NULLIFIER_START: 115, // Unique identifier per user+scope
|
|
764
|
-
NULLIFIER_END: 146,
|
|
765
|
-
PROVIDER: 147, // OIDC provider code (0=none, 1=Google, 2=Microsoft)
|
|
904
|
+
const L = COINBASE_ATTESTATION_PUBLIC_INPUT_LAYOUT;
|
|
766
905
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
906
|
+
// Rebuild a bytes32 value from 32 single-byte entries.
|
|
907
|
+
const toBytes32 = (fields: string[]): string =>
|
|
908
|
+
'0x' + fields.map((f) => (BigInt(f) & 0xffn).toString(16).padStart(2, '0')).join('');
|
|
909
|
+
|
|
910
|
+
const signalHash = toBytes32(publicInputs.slice(L.SIGNAL_HASH_START, L.SIGNAL_HASH_END + 1));
|
|
771
911
|
```
|
|
772
912
|
|
|
773
|
-
|
|
774
|
-
```typescript
|
|
775
|
-
MDL_KR_PUBLIC_INPUT_LAYOUT = {
|
|
776
|
-
SCOPE_START: 0, // Scope value
|
|
777
|
-
SCOPE_END: 31,
|
|
778
|
-
NULLIFIER_START: 32, // nullifier_value
|
|
779
|
-
NULLIFIER_END: 63,
|
|
913
|
+
### `coinbase_attestation` — 128 entries
|
|
780
914
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
915
|
+
| Index | Constants | Circuit field | What it is |
|
|
916
|
+
|-------|-----------|---------------|------------|
|
|
917
|
+
| 0–31 | `SIGNAL_HASH_START` / `_END` | `signal_hash` | `bytes32` anti-replay challenge for the request |
|
|
918
|
+
| 32–63 | `MERKLE_ROOT_START` / `_END` | `signer_list_merkle_root` | `bytes32` root of the Coinbase attestation-signer list the proof was checked against |
|
|
919
|
+
| 64–95 | `SCOPE_START` / `_END` | `scope` | `bytes32` — `keccak256` of your scope string. Compare it (Step 8) |
|
|
920
|
+
| 96–127 | `NULLIFIER_START` / `_END` | `nullifier` | `bytes32`, unique per user and scope |
|
|
785
921
|
|
|
786
|
-
|
|
787
|
-
AGE_THRESHOLD: 64,
|
|
788
|
-
AGE_CURRENT_YEAR: 65,
|
|
922
|
+
### `coinbase_country_attestation` — 150 entries
|
|
789
923
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
924
|
+
| Index | Constants | Circuit field | What it is |
|
|
925
|
+
|-------|-----------|---------------|------------|
|
|
926
|
+
| 0–31 | `SIGNAL_HASH_START` / `_END` | `signal_hash` | `bytes32` anti-replay challenge |
|
|
927
|
+
| 32–63 | `MERKLE_ROOT_START` / `_END` | `signer_list_merkle_root` | `bytes32` signer-list root |
|
|
928
|
+
| 64–83 | `COUNTRY_LIST_START` / `_END` | `country_list` | 10 slots of 2 ASCII bytes, one byte per entry — entries 64–65 are the first code, 66–67 the second, and so on, in the order you sent them. Only the first `country_list_length` slots are meaningful; the rest are zero |
|
|
929
|
+
| 84 | `COUNTRY_LIST_LENGTH` | `country_list_length` | How many of those 10 slots count |
|
|
930
|
+
| 85 | `IS_INCLUDED` | `is_included` | `1` = the user **is** in the list, `0` = the user is **not** |
|
|
931
|
+
| 86–117 | `SCOPE_START` / `_END` | `scope` | `bytes32` scope hash |
|
|
932
|
+
| 118–149 | `NULLIFIER_START` / `_END` | `nullifier` | `bytes32`, unique per user and scope |
|
|
933
|
+
|
|
934
|
+
The country list and the `is_included` flag are public inputs, which means the proof is only as strong as the list it was proved against. Read indices 64–85 back and confirm they are the list and the direction you asked for; a valid proof against a different list is still a valid proof.
|
|
935
|
+
|
|
936
|
+
### `oidc_domain_attestation` — 148 entries
|
|
937
|
+
|
|
938
|
+
| Index | Constants | Circuit field | What it is |
|
|
939
|
+
|-------|-----------|---------------|------------|
|
|
940
|
+
| 0–17 | `PUBKEY_MODULUS_START` / `_END` | `pubkey_modulus_limbs` | The RSA modulus of the OIDC issuer's signing key, as 18 × 128-bit limbs. Not single bytes |
|
|
941
|
+
| 18–81 | `DOMAIN_STORAGE_START` / `_END` | `domain` storage | The domain as ASCII, one byte per entry. Entries past the length at index 82 are zero |
|
|
942
|
+
| 82 | `DOMAIN_LEN` | `domain` length | How many of those 64 bytes are the domain. `extractDomain()` does this decoding for you |
|
|
943
|
+
| 83–114 | `SCOPE_START` / `_END` | `scope` | `bytes32` scope hash |
|
|
944
|
+
| 115–146 | `NULLIFIER_START` / `_END` | `nullifier` | `bytes32` over the user's email and the scope |
|
|
945
|
+
| 147 | `PROVIDER` | `provider` | `0` = Google, `1` = Microsoft. The circuit accepts no other value |
|
|
795
946
|
|
|
796
|
-
|
|
947
|
+
`DOMAIN_START` and `DOMAIN_END` are kept as deprecated aliases of `DOMAIN_STORAGE_START` and `DOMAIN_LEN`. Use the current names — the old pair reads as a range and is not one.
|
|
948
|
+
|
|
949
|
+
Index 147 tells you which issuer signed the token, not whether the account belongs to an organisation. Workspace and Microsoft 365 membership is checked on-device only when you set `provider` in the request; if you did not set it, do not read a membership guarantee out of this field.
|
|
950
|
+
|
|
951
|
+
### Korea Mobile ID (mDL) — one layout, three circuits
|
|
952
|
+
|
|
953
|
+
All three mDL circuits share the same first 64 entries and differ after that. Entries are single bytes, except `age_threshold` and `current_year`, which each hold their whole value in one entry.
|
|
954
|
+
|
|
955
|
+
| Index | Constants | Circuit field | Applies to |
|
|
956
|
+
|-------|-----------|---------------|------------|
|
|
957
|
+
| 0–31 | `SCOPE_START` / `_END` | `scope` | all three |
|
|
958
|
+
| 32–63 | `NULLIFIER_START` / `_END` | `nullifier_value` | all three |
|
|
959
|
+
| 64 | `OWNERSHIP_DISCLOSE_FLAGS` | `disclose_flags` | `mdl_kr_ownership` (97 entries) — the bitmask that was actually proved (`0x01` name, `0x02` birth, `0x04` sex, `0x08` phone) |
|
|
960
|
+
| 65–96 | `OWNERSHIP_OWNER_COMMIT_START` / `_END` | `owner_commit` | `mdl_kr_ownership` — `bytes32` commitment to the disclosed attributes. All zero when `disclose_flags` is `0`, which is the anonymous case |
|
|
961
|
+
| 64 | `AGE_THRESHOLD` | `age_threshold` | `mdl_kr_age` (66 entries) — the age the proof actually clears. Read it back; it is a public input, so confirm it is the threshold you asked for |
|
|
962
|
+
| 65 | `AGE_CURRENT_YEAR` | `current_year` | `mdl_kr_age` — the year the comparison was made in |
|
|
963
|
+
| 64–95 | `REGION_CODE_START` / `_END` | `region_code` | `mdl_kr_region` (96 entries) — `bytes32`, not a readable region name. See below |
|
|
964
|
+
|
|
965
|
+
`region_code` is `keccak256` over the region name, UTF-8 and zero-padded to 64 bytes. The circuit proves that the region on the licence hashes to this value, but the value itself is a public input — so recompute it from the region you asked for and compare, exactly as you do with the scope:
|
|
966
|
+
|
|
967
|
+
```typescript
|
|
968
|
+
import { keccak256 } from 'ethers';
|
|
969
|
+
|
|
970
|
+
const regionCodeFor = (region: string): string => {
|
|
971
|
+
const padded = new Uint8Array(64);
|
|
972
|
+
padded.set(new TextEncoder().encode(region));
|
|
973
|
+
return keccak256(padded);
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
// regionCodeFor('경기도') — compare against indices 64–95, rebuilt with toBytes32()
|
|
977
|
+
```
|
|
797
978
|
|
|
798
979
|
## Error Handling
|
|
799
980
|
|
|
@@ -834,6 +1015,8 @@ A relay rejection surfaces as its message, e.g. `Duplicate nonce (replay detecte
|
|
|
834
1015
|
|
|
835
1016
|
Verification is the exception: `verifyResponseOnChain` and `verifyOnChain` report failures as `{ valid: false, error }` instead of throwing.
|
|
836
1017
|
|
|
1018
|
+
A proof that fails on the phone is not an exception either — it comes back as a result with `status: 'error'` and an `error` string. Step 6 covers those, and which of them are worth retrying.
|
|
1019
|
+
|
|
837
1020
|
## Networks
|
|
838
1021
|
|
|
839
1022
|
Proofs are verified on **Base** (Ethereum L2). The SDK handles network configuration automatically — no manual setup required.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zkproofport-app/sdk",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "ZKProofport SDK for requesting zero-knowledge proofs from the ZKProofport mobile app and verifying them on-chain",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.esm.js",
|