@zkproofport-app/sdk 0.2.15 → 0.3.0

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/dist/index.js CHANGED
@@ -84,6 +84,16 @@ const CIRCUIT_METADATA = {
84
84
  publicInputsCount: 148,
85
85
  publicInputNames: ['pubkey_modulus_limbs', 'domain', 'scope', 'nullifier', 'provider'],
86
86
  },
87
+ arc_eligibility: {
88
+ name: 'Arc Eligibility',
89
+ description: 'Prove eligibility and authorize one EIP-712 action, for an Arc gate',
90
+ // 6 x `pub [u8; 32]` in arc-eligibility/src/main.nr, flattened to bytes.
91
+ // Read off the compiled ABI rather than counted: signal_hash 0-31,
92
+ // domain_separator 32-63, action_hash 64-95, signer_list_merkle_root
93
+ // 96-127, scope 128-159, nullifier 160-191.
94
+ publicInputsCount: 192,
95
+ publicInputNames: ['signal_hash', 'domain_separator', 'action_hash', 'signer_list_merkle_root'],
96
+ },
87
97
  // Planned circuit — the identifier is reserved and the circuit exists, but it
88
98
  // is not officially supported yet. See CIRCUIT_SUPPORT_STATUS in ./circuits.
89
99
  // Forked from coinbase_attestation and shares its four public inputs, so the
@@ -323,6 +333,292 @@ const OIDC_DOMAIN_ATTESTATION_PUBLIC_INPUT_LAYOUT = {
323
333
  DOMAIN_END: 82,
324
334
  };
325
335
 
336
+ /**
337
+ * Canonical ZKProofport circuit identifiers.
338
+ *
339
+ * This module is the single source of truth for circuit IDs. Every other layer
340
+ * — the mobile app, the demo, the relay, server-side provers — reads its list
341
+ * from here instead of keeping a private copy that silently drifts.
342
+ *
343
+ * It has **no imports and no dependencies**, so it can be pulled in on its own:
344
+ *
345
+ * ```typescript
346
+ * import { CIRCUIT_IDS, isCircuitId } from '@zkproofport-app/sdk/circuits';
347
+ * ```
348
+ *
349
+ * The same names are also re-exported from the package root
350
+ * (`@zkproofport-app/sdk`) for callers that already import the SDK.
351
+ *
352
+ * ## Identifiers are canonical and case-sensitive
353
+ *
354
+ * Each ID is the circuit's `name` in its `Nargo.toml`, verbatim. The format is
355
+ * lowercase with underscores. Hyphenated spellings (`coinbase-kyc`,
356
+ * `mdl-kr-age`) are directory or UI route names in other repositories — they
357
+ * are **not** circuit IDs, and passing one produces a nullifier mismatch or a
358
+ * failed on-chain lookup rather than a clear error.
359
+ */
360
+ /**
361
+ * Canonical circuit identifiers, keyed by a stable constant name.
362
+ *
363
+ * Prefer these constants over string literals so a rename is a compile error
364
+ * rather than a runtime surprise.
365
+ *
366
+ * @example
367
+ * ```typescript
368
+ * import { CIRCUIT_IDS } from '@zkproofport-app/sdk/circuits';
369
+ *
370
+ * const relay = await sdk.createRelayRequest(CIRCUIT_IDS.COINBASE_ATTESTATION, {
371
+ * scope: 'myapp.com',
372
+ * });
373
+ * ```
374
+ */
375
+ const CIRCUIT_IDS = Object.freeze({
376
+ /** Coinbase KYC attestation. Officially supported. */
377
+ COINBASE_ATTESTATION: 'coinbase_attestation',
378
+ /** Coinbase country attestation (inclusion / exclusion). Officially supported. */
379
+ COINBASE_COUNTRY_ATTESTATION: 'coinbase_country_attestation',
380
+ /** OIDC email-domain attestation. Officially supported. */
381
+ OIDC_DOMAIN_ATTESTATION: 'oidc_domain_attestation',
382
+ /** Arc eligibility: one EIP-712 action, authorized. Officially supported. */
383
+ ARC_ELIGIBILITY: 'arc_eligibility',
384
+ /** GIWA attestation. Planned — not officially supported yet. */
385
+ GIWA_ATTESTATION: 'giwa_attestation',
386
+ /** Korea Mobile ID ownership. Planned — not officially supported yet. */
387
+ MDL_KR_OWNERSHIP: 'mdl_kr_ownership',
388
+ /** Korea Mobile ID age threshold. Planned — not officially supported yet. */
389
+ MDL_KR_AGE: 'mdl_kr_age',
390
+ /** Korea Mobile ID si/do region. Planned — not officially supported yet. */
391
+ MDL_KR_REGION: 'mdl_kr_region',
392
+ });
393
+ /**
394
+ * Support status for every circuit.
395
+ *
396
+ * The record is exhaustive over {@link CircuitId}: adding an identifier without
397
+ * giving it a status is a compile error, so this list cannot drift from
398
+ * {@link CIRCUIT_IDS}.
399
+ *
400
+ * @example
401
+ * ```typescript
402
+ * import { CIRCUIT_SUPPORT_STATUS } from '@zkproofport-app/sdk/circuits';
403
+ *
404
+ * CIRCUIT_SUPPORT_STATUS.coinbase_attestation; // 'supported'
405
+ * CIRCUIT_SUPPORT_STATUS.mdl_kr_age; // 'planned'
406
+ * ```
407
+ */
408
+ const CIRCUIT_SUPPORT_STATUS = Object.freeze({
409
+ coinbase_attestation: 'supported',
410
+ coinbase_country_attestation: 'supported',
411
+ oidc_domain_attestation: 'supported',
412
+ arc_eligibility: 'experimental',
413
+ giwa_attestation: 'planned',
414
+ mdl_kr_ownership: 'planned',
415
+ mdl_kr_age: 'planned',
416
+ mdl_kr_region: 'planned',
417
+ });
418
+ /**
419
+ * Where each circuit's verification key FILE lives inside the
420
+ * `zkproofport/circuits` repository, relative to its root.
421
+ *
422
+ * Off-chain verification needs the verification key and nothing else — no
423
+ * circuit bytecode, no witness. The key is public by nature: it is what anyone
424
+ * checking a proof must already have, and the Solidity verifiers deployed
425
+ * on-chain embed the same key.
426
+ *
427
+ * NOTE THE TRAILING `/vk` TWICE. In that repository `target/vk` is a DIRECTORY
428
+ * holding `vk`, `vk_hash` and `SHA256SUMS`; the key itself is `target/vk/vk`.
429
+ * These paths were first copied here from the mobile app, which keeps the
430
+ * directory and the filename as two fields and joins them — so taking only the
431
+ * directory produced a URL that answers 404, and the first browser to try
432
+ * off-chain verification said "Could not fetch the verification key (HTTP 404)".
433
+ *
434
+ * Exhaustive over {@link CircuitId} on purpose. A new circuit whose key path is
435
+ * not listed here fails to compile rather than failing at verification time,
436
+ * where the only symptom would be a proof that cannot be checked.
437
+ */
438
+ const CIRCUIT_VK_PATHS = Object.freeze({
439
+ coinbase_attestation: 'coinbase-attestation/target/vk/vk',
440
+ coinbase_country_attestation: 'coinbase-country-attestation/target/vk/vk',
441
+ oidc_domain_attestation: 'oidc-domain-attestation/target/vk/vk',
442
+ arc_eligibility: 'arc-eligibility/target/vk/vk',
443
+ giwa_attestation: 'giwa-attestation/target/vk/vk',
444
+ mdl_kr_ownership: 'mdl/kr-ownership/target/vk/vk',
445
+ mdl_kr_age: 'mdl/kr-age/target/vk/vk',
446
+ mdl_kr_region: 'mdl/kr-region/target/vk/vk',
447
+ });
448
+ /**
449
+ * Every canonical circuit identifier, in declaration order.
450
+ *
451
+ * Includes circuits that are still `planned`. Use {@link SUPPORTED_CIRCUIT_IDS}
452
+ * when you only want the ones that are officially supported today.
453
+ */
454
+ const ALL_CIRCUIT_IDS = Object.freeze(Object.values(CIRCUIT_IDS));
455
+ /**
456
+ * Circuits that are officially supported today: the two Coinbase circuits and
457
+ * OIDC domain attestation.
458
+ *
459
+ * Derived from {@link CIRCUIT_SUPPORT_STATUS}, so it cannot fall out of sync.
460
+ */
461
+ const SUPPORTED_CIRCUIT_IDS = Object.freeze(ALL_CIRCUIT_IDS.filter((id) => CIRCUIT_SUPPORT_STATUS[id] === 'supported'));
462
+ /**
463
+ * Circuits whose identifiers are reserved but which are not officially
464
+ * supported yet: GIWA attestation and the three Korea Mobile ID circuits.
465
+ *
466
+ * Derived from {@link CIRCUIT_SUPPORT_STATUS}, so it cannot fall out of sync.
467
+ */
468
+ /**
469
+ * Circuits you can prove today, on a testnet, with everything subject to change.
470
+ *
471
+ * `arc_eligibility` is the reason this status exists. Calling it `planned`
472
+ * would be wrong — the circuit compiles, the verifier is deployed and a proof
473
+ * verifies against it — and calling it `supported` would be worse, because the
474
+ * only deployment is Arc Testnet, Circle has not published a mainnet chain id,
475
+ * and the public-input layout is still open. Neither existing word was true, so
476
+ * the honest move was a third word rather than picking the closer lie.
477
+ */
478
+ const EXPERIMENTAL_CIRCUIT_IDS = Object.freeze(ALL_CIRCUIT_IDS.filter(id => CIRCUIT_SUPPORT_STATUS[id] === 'experimental'));
479
+ const PLANNED_CIRCUIT_IDS = Object.freeze(ALL_CIRCUIT_IDS.filter((id) => CIRCUIT_SUPPORT_STATUS[id] === 'planned'));
480
+ /**
481
+ * Narrows an unknown value to a canonical circuit identifier.
482
+ *
483
+ * Returns `false` for hyphenated route names such as `'coinbase-kyc'`, which
484
+ * are not circuit IDs.
485
+ *
486
+ * @example
487
+ * ```typescript
488
+ * isCircuitId('coinbase_attestation'); // true
489
+ * isCircuitId('coinbase-kyc'); // false
490
+ * ```
491
+ */
492
+ function isCircuitId(value) {
493
+ return typeof value === 'string' && ALL_CIRCUIT_IDS.includes(value);
494
+ }
495
+ /**
496
+ * Narrows an unknown value to a circuit identifier that is officially supported
497
+ * today. Planned circuits return `false`.
498
+ *
499
+ * @example
500
+ * ```typescript
501
+ * isSupportedCircuitId('oidc_domain_attestation'); // true
502
+ * isSupportedCircuitId('mdl_kr_age'); // false — planned
503
+ * ```
504
+ */
505
+ function isSupportedCircuitId(value) {
506
+ return isCircuitId(value) && CIRCUIT_SUPPORT_STATUS[value] === 'supported';
507
+ }
508
+ /**
509
+ * Returns the support status of a circuit.
510
+ *
511
+ * @param circuit - A canonical circuit identifier.
512
+ * @throws Error when the value is not a canonical circuit identifier.
513
+ *
514
+ * @example
515
+ * ```typescript
516
+ * getCircuitSupportStatus('giwa_attestation'); // 'planned'
517
+ * ```
518
+ */
519
+ function getCircuitSupportStatus(circuit) {
520
+ if (!isCircuitId(circuit)) {
521
+ throw new Error(`Unknown circuit '${String(circuit)}'. Expected one of: ${ALL_CIRCUIT_IDS.join(', ')}`);
522
+ }
523
+ return CIRCUIT_SUPPORT_STATUS[circuit];
524
+ }
525
+ /**
526
+ * Whether a circuit's relay request must carry a wallet signature.
527
+ *
528
+ * The SDK checks this to fail fast — "Signer not set" locally beats a 401 from
529
+ * the relay two round trips later, with nothing saying which circuit needed
530
+ * what. The relay declares its OWN copy on purpose and does not import this
531
+ * one: it is a security boundary, and an auth policy that a published package
532
+ * can change is not a policy. A test in the relay asserts the two agree.
533
+ *
534
+ * `satisfies` and not an annotation, so a circuit added above is a compile
535
+ * error here until somebody decides which it is.
536
+ */
537
+ const CIRCUIT_NEEDS_WALLET_SIGNATURE = {
538
+ coinbase_attestation: true,
539
+ coinbase_country_attestation: true,
540
+ // Same Coinbase-attested wallet as above; what changes is that it signs an
541
+ // EIP-712 action rather than signal_hash.
542
+ arc_eligibility: true,
543
+ // The signature is inside the OIDC identity token, checked by the prover.
544
+ oidc_domain_attestation: false,
545
+ // Web2 (OmniOne CX) flows with no wallet binding at all.
546
+ mdl_kr_ownership: false,
547
+ mdl_kr_age: false,
548
+ mdl_kr_region: false,
549
+ giwa_attestation: false,
550
+ };
551
+
552
+ const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
553
+ /**
554
+ * The reason the value is not a usable action, or `null` when it is one.
555
+ *
556
+ * A string rather than a boolean because every caller shows it to somebody —
557
+ * a dapp developer reading a validation error, or a person looking at the
558
+ * mobile app's demo screen.
559
+ */
560
+ function validateTypedAction(value) {
561
+ if (value === undefined || value === null) {
562
+ return 'action is required for arc_eligibility. The circuit proves that a wallet authorised ONE EIP-712 action; there is nothing to prove without it.';
563
+ }
564
+ if (typeof value !== 'object' || Array.isArray(value)) {
565
+ return 'action must be an object.';
566
+ }
567
+ const a = value;
568
+ const d = a.domain;
569
+ if (!d || typeof d !== 'object') {
570
+ return 'action.domain is required.';
571
+ }
572
+ for (const k of ['name', 'version', 'verifyingContract']) {
573
+ if (typeof d[k] !== 'string' || !d[k]) {
574
+ return `action.domain.${k} must be a non-empty string.`;
575
+ }
576
+ }
577
+ if (typeof d.chainId !== 'number' || !Number.isInteger(d.chainId)) {
578
+ return 'action.domain.chainId must be an integer.';
579
+ }
580
+ if (!ADDRESS.test(d.verifyingContract)) {
581
+ return 'action.domain.verifyingContract must be a 20-byte hex address.';
582
+ }
583
+ if (!a.types || typeof a.types !== 'object' || Array.isArray(a.types)) {
584
+ return 'action.types is required.';
585
+ }
586
+ if (typeof a.primaryType !== 'string' || !a.primaryType) {
587
+ return 'action.primaryType is required.';
588
+ }
589
+ if (!(a.primaryType in a.types)) {
590
+ return `action.primaryType '${a.primaryType}' is not one of the structs in action.types.`;
591
+ }
592
+ if (!a.message || typeof a.message !== 'object' || Array.isArray(a.message)) {
593
+ return 'action.message is required.';
594
+ }
595
+ // Every field the struct declares must be present, or the wallet signs a
596
+ // different message than the one the caller described.
597
+ const fields = a.types[a.primaryType];
598
+ if (!Array.isArray(fields) || fields.length === 0) {
599
+ return `action.types.${a.primaryType} declares no fields.`;
600
+ }
601
+ const missing = fields
602
+ .map(f => f?.name)
603
+ .filter(n => typeof n === 'string' && !(n in a.message));
604
+ if (missing.length) {
605
+ return `action.message is missing: ${missing.join(', ')}`;
606
+ }
607
+ return null;
608
+ }
609
+ /** The action, or the reason it is not one. For text a person typed. */
610
+ function parseTypedAction(text) {
611
+ let raw;
612
+ try {
613
+ raw = JSON.parse(text);
614
+ }
615
+ catch (e) {
616
+ return { error: `Not valid JSON: ${e.message}` };
617
+ }
618
+ const error = validateTypedAction(raw);
619
+ return error ? { error } : { action: raw };
620
+ }
621
+
326
622
  /**
327
623
  * Deep Link utilities for ZKProofport SDK
328
624
  */
@@ -501,9 +797,22 @@ function parseProofResponseUrl(url) {
501
797
  if (!requestId || !status) {
502
798
  return null;
503
799
  }
800
+ // A missing or unrecognised circuit is a REJECTION, not a default.
801
+ //
802
+ // This used to fall back to `coinbase_attestation`. A callback carrying a
803
+ // country or OIDC proof, or a circuit this build has never heard of, was
804
+ // then handed to the caller labelled Coinbase KYC -- the parse succeeded,
805
+ // the label was wrong, and nothing downstream could tell. Whoever reads
806
+ // `response.circuit` decides which verifier and which public-input layout
807
+ // to use, so the wrong label is a proof checked against another circuit's
808
+ // bytes.
809
+ const circuit = urlObj.searchParams.get('circuit');
810
+ if (!circuit || !isCircuitId(circuit)) {
811
+ return null;
812
+ }
504
813
  const response = {
505
814
  requestId,
506
- circuit: urlObj.searchParams.get('circuit') || 'coinbase_attestation',
815
+ circuit,
507
816
  status,
508
817
  };
509
818
  if (status === 'completed') {
@@ -805,15 +1114,15 @@ function validateProofRequest(request) {
805
1114
  if (!request.circuit) {
806
1115
  return { valid: false, error: 'Missing circuit type' };
807
1116
  }
808
- if (![
809
- 'coinbase_attestation',
810
- 'coinbase_country_attestation',
811
- 'oidc_domain_attestation',
812
- 'mdl_kr_ownership',
813
- 'mdl_kr_age',
814
- 'mdl_kr_region',
815
- ].includes(request.circuit)) {
816
- return { valid: false, error: `Invalid circuit type: ${request.circuit}` };
1117
+ // The list is the SDK's own, not a copy. The copy that used to sit here
1118
+ // named six circuits and had missed two — `giwa_attestation` and
1119
+ // `arc_eligibility` — so a link for either was refused as an invalid type
1120
+ // while every other layer accepted it.
1121
+ if (!isCircuitId(request.circuit)) {
1122
+ return {
1123
+ valid: false,
1124
+ error: `Invalid circuit type: ${request.circuit}. Expected one of: ${ALL_CIRCUIT_IDS.join(', ')}`,
1125
+ };
817
1126
  }
818
1127
  if (!request.callbackUrl) {
819
1128
  return { valid: false, error: 'Missing callbackUrl' };
@@ -862,6 +1171,16 @@ function validateProofRequest(request) {
862
1171
  return { valid: false, error: mdlError };
863
1172
  }
864
1173
  }
1174
+ else if (request.circuit === 'arc_eligibility') {
1175
+ const inputs = request.inputs;
1176
+ if (inputs.userAddress && !/^0x[a-fA-F0-9]{40}$/.test(inputs.userAddress)) {
1177
+ return { valid: false, error: 'Invalid userAddress format' };
1178
+ }
1179
+ const actionError = validateTypedAction(inputs.action);
1180
+ if (actionError) {
1181
+ return { valid: false, error: actionError };
1182
+ }
1183
+ }
865
1184
  // Check expiry
866
1185
  if (request.expiresAt && Date.now() > request.expiresAt) {
867
1186
  return { valid: false, error: 'Request has expired' };
@@ -1145,7 +1464,7 @@ function getDefaultProvider(chainId) {
1145
1464
  * This function resolves the verifier contract from SDK config or proof response,
1146
1465
  * connects to the blockchain, and calls the verify() method with the proof and public inputs.
1147
1466
  *
1148
- * @param circuit - The canonical circuit identifier (e.g., "coinbase_attestation")
1467
+ * @param circuit - The canonical circuit identifier. REQUIRED.
1149
1468
  * @param parsedProof - Parsed proof object containing proofHex and publicInputsHex
1150
1469
  * @param providerOrSigner - Optional ethers.js Provider or Signer instance. If not provided, uses default RPC for the chain
1151
1470
  * @param customVerifier - Optional custom verifier contract config (takes priority over responseVerifier)
@@ -1243,7 +1562,7 @@ function requireVerifier(circuit, verifier) {
1243
1562
  /**
1244
1563
  * Get verifier contract address for a circuit.
1245
1564
  *
1246
- * @param circuit - The canonical circuit identifier (e.g., "coinbase_attestation")
1565
+ * @param circuit - The canonical circuit identifier. REQUIRED.
1247
1566
  * @param customVerifier - Optional custom verifier contract config
1248
1567
  * @returns Verifier contract address as hex string
1249
1568
  * @throws Error if no verifier is configured for the circuit
@@ -1260,7 +1579,7 @@ function getVerifierAddress(circuit, customVerifier) {
1260
1579
  /**
1261
1580
  * Get chain ID for a circuit's verifier contract.
1262
1581
  *
1263
- * @param circuit - The canonical circuit identifier (e.g., "coinbase_attestation")
1582
+ * @param circuit - The canonical circuit identifier. REQUIRED.
1264
1583
  * @param customVerifier - Optional custom verifier contract config
1265
1584
  * @returns Chain ID number (e.g., 11155111 for Sepolia, 84532 for Base Sepolia)
1266
1585
  * @throws Error if no verifier is configured for the circuit
@@ -1274,6 +1593,55 @@ function getVerifierAddress(circuit, customVerifier) {
1274
1593
  function getVerifierChainId(circuit, customVerifier) {
1275
1594
  return requireVerifier(circuit, customVerifier).chainId;
1276
1595
  }
1596
+ /**
1597
+ * Where `scope` and `nullifier` sit in each circuit's public inputs.
1598
+ *
1599
+ * Keyed by `CircuitId`, which is the point: adding a circuit to the SDK makes
1600
+ * this object a compile error until somebody opens that circuit's `fn main`
1601
+ * and writes its offsets down. Nothing here can be inferred from the proof —
1602
+ * the public inputs are a flat array of bytes with no framing.
1603
+ *
1604
+ * It replaced an `if / else if / else` chain whose final `else` handed every
1605
+ * unrecognised circuit — and every call that named no circuit at all —
1606
+ * Coinbase's offsets. For `arc_eligibility` that is not a near miss: its
1607
+ * public inputs are signal_hash, domain_separator, action_hash, merkle root,
1608
+ * scope, nullifier, so the nullifier slot under Coinbase's offsets holds the
1609
+ * MERKLE ROOT, a value identical for every user. Duplicate detection built on
1610
+ * it would report one person.
1611
+ */
1612
+ const PUBLIC_INPUT_OFFSETS = Object.freeze({
1613
+ // signal_hash, signer_list_merkle_root, scope, nullifier
1614
+ coinbase_attestation: { scope: [64, 95], nullifier: [96, 127] },
1615
+ // Same four fields in the same order as Coinbase.
1616
+ giwa_attestation: { scope: [64, 95], nullifier: [96, 127] },
1617
+ // country_list, country_list_length and is_included sit before scope.
1618
+ coinbase_country_attestation: { scope: [86, 117], nullifier: [118, 149] },
1619
+ // pubkey_modulus_limbs and the bounded domain sit before scope.
1620
+ oidc_domain_attestation: { scope: [83, 114], nullifier: [115, 146] },
1621
+ // signal_hash, domain_separator, action_hash, merkle root, then the pair.
1622
+ arc_eligibility: { scope: [128, 159], nullifier: [160, 191] },
1623
+ // The Korea mDL circuits open with the pair; signal_hash is commented out.
1624
+ mdl_kr_ownership: { scope: [0, 31], nullifier: [32, 63] },
1625
+ mdl_kr_age: { scope: [0, 31], nullifier: [32, 63] },
1626
+ mdl_kr_region: { scope: [0, 31], nullifier: [32, 63] },
1627
+ });
1628
+ /**
1629
+ * The offsets for a circuit, or an error naming what was asked for.
1630
+ *
1631
+ * Throwing is the whole point. A caller that omits the circuit, or names one
1632
+ * this SDK has never heard of, is asking a question with no answer; returning
1633
+ * a guess produced a scope and a nullifier that belong to different bytes and
1634
+ * looked exactly like real ones.
1635
+ */
1636
+ function offsetsFor(circuit) {
1637
+ if (circuit === undefined) {
1638
+ throw new Error(`A circuit id is required to read public inputs: the layout differs per circuit and cannot be inferred from the proof. Pass one of: ${ALL_CIRCUIT_IDS.join(', ')}`);
1639
+ }
1640
+ if (!isCircuitId(circuit)) {
1641
+ throw new Error(`Unknown circuit '${circuit}'. Expected one of: ${ALL_CIRCUIT_IDS.join(', ')}`);
1642
+ }
1643
+ return PUBLIC_INPUT_OFFSETS[circuit];
1644
+ }
1277
1645
  /**
1278
1646
  * Extract scope value from public inputs array.
1279
1647
  *
@@ -1291,24 +1659,7 @@ function getVerifierChainId(circuit, customVerifier) {
1291
1659
  * ```
1292
1660
  */
1293
1661
  function extractScopeFromPublicInputs(publicInputsHex, circuit) {
1294
- let start, end;
1295
- if (circuit === 'coinbase_country_attestation') {
1296
- start = 86;
1297
- end = 117;
1298
- }
1299
- else if (circuit === 'oidc_domain_attestation') {
1300
- start = 83;
1301
- end = 114;
1302
- }
1303
- else if (circuit !== undefined && circuit.startsWith('mdl_kr_')) {
1304
- // All Korea mDL circuits: scope [0..31], nullifier_value [32..63]
1305
- start = 0;
1306
- end = 31;
1307
- }
1308
- else {
1309
- start = 64;
1310
- end = 95;
1311
- }
1662
+ const [start, end] = offsetsFor(circuit).scope;
1312
1663
  if (publicInputsHex.length <= end)
1313
1664
  return null;
1314
1665
  const scopeFields = publicInputsHex.slice(start, end + 1);
@@ -1323,7 +1674,10 @@ function extractScopeFromPublicInputs(publicInputsHex, circuit) {
1323
1674
  * detection without revealing the wallet address.
1324
1675
  *
1325
1676
  * @param publicInputsHex - Array of hex-encoded field elements
1326
- * @param circuit - Circuit type (defaults to coinbase_attestation)
1677
+ * @param circuit - Circuit id. REQUIRED: the layout differs per circuit and
1678
+ * cannot be inferred from the proof. It used to default to
1679
+ * `coinbase_attestation`, which returned the right-shaped bytes read from
1680
+ * the wrong place for every other circuit.
1327
1681
  * @returns Nullifier as hex string (bytes32), or null if publicInputs too short
1328
1682
  *
1329
1683
  * @example
@@ -1333,24 +1687,7 @@ function extractScopeFromPublicInputs(publicInputsHex, circuit) {
1333
1687
  * ```
1334
1688
  */
1335
1689
  function extractNullifierFromPublicInputs(publicInputsHex, circuit) {
1336
- let start, end;
1337
- if (circuit === 'coinbase_country_attestation') {
1338
- start = 118;
1339
- end = 149;
1340
- }
1341
- else if (circuit === 'oidc_domain_attestation') {
1342
- start = 115;
1343
- end = 146;
1344
- }
1345
- else if (circuit !== undefined && circuit.startsWith('mdl_kr_')) {
1346
- // All Korea mDL circuits: scope [0..31], nullifier_value [32..63]
1347
- start = 32;
1348
- end = 63;
1349
- }
1350
- else {
1351
- start = 96;
1352
- end = 127;
1353
- }
1690
+ const [start, end] = offsetsFor(circuit).nullifier;
1354
1691
  if (publicInputsHex.length <= end)
1355
1692
  return null;
1356
1693
  const nullifierFields = publicInputsHex.slice(start, end + 1);
@@ -1400,174 +1737,6 @@ function reconstructBytes32FromFields(fields) {
1400
1737
  return '0x' + bytes;
1401
1738
  }
1402
1739
 
1403
- /**
1404
- * Canonical ZKProofport circuit identifiers.
1405
- *
1406
- * This module is the single source of truth for circuit IDs. Every other layer
1407
- * — the mobile app, the demo, the relay, server-side provers — reads its list
1408
- * from here instead of keeping a private copy that silently drifts.
1409
- *
1410
- * It has **no imports and no dependencies**, so it can be pulled in on its own:
1411
- *
1412
- * ```typescript
1413
- * import { CIRCUIT_IDS, isCircuitId } from '@zkproofport-app/sdk/circuits';
1414
- * ```
1415
- *
1416
- * The same names are also re-exported from the package root
1417
- * (`@zkproofport-app/sdk`) for callers that already import the SDK.
1418
- *
1419
- * ## Identifiers are canonical and case-sensitive
1420
- *
1421
- * Each ID is the circuit's `name` in its `Nargo.toml`, verbatim. The format is
1422
- * lowercase with underscores. Hyphenated spellings (`coinbase-kyc`,
1423
- * `mdl-kr-age`) are directory or UI route names in other repositories — they
1424
- * are **not** circuit IDs, and passing one produces a nullifier mismatch or a
1425
- * failed on-chain lookup rather than a clear error.
1426
- */
1427
- /**
1428
- * Canonical circuit identifiers, keyed by a stable constant name.
1429
- *
1430
- * Prefer these constants over string literals so a rename is a compile error
1431
- * rather than a runtime surprise.
1432
- *
1433
- * @example
1434
- * ```typescript
1435
- * import { CIRCUIT_IDS } from '@zkproofport-app/sdk/circuits';
1436
- *
1437
- * const relay = await sdk.createRelayRequest(CIRCUIT_IDS.COINBASE_ATTESTATION, {
1438
- * scope: 'myapp.com',
1439
- * });
1440
- * ```
1441
- */
1442
- const CIRCUIT_IDS = Object.freeze({
1443
- /** Coinbase KYC attestation. Officially supported. */
1444
- COINBASE_ATTESTATION: 'coinbase_attestation',
1445
- /** Coinbase country attestation (inclusion / exclusion). Officially supported. */
1446
- COINBASE_COUNTRY_ATTESTATION: 'coinbase_country_attestation',
1447
- /** OIDC email-domain attestation. Officially supported. */
1448
- OIDC_DOMAIN_ATTESTATION: 'oidc_domain_attestation',
1449
- /** GIWA attestation. Planned — not officially supported yet. */
1450
- GIWA_ATTESTATION: 'giwa_attestation',
1451
- /** Korea Mobile ID ownership. Planned — not officially supported yet. */
1452
- MDL_KR_OWNERSHIP: 'mdl_kr_ownership',
1453
- /** Korea Mobile ID age threshold. Planned — not officially supported yet. */
1454
- MDL_KR_AGE: 'mdl_kr_age',
1455
- /** Korea Mobile ID si/do region. Planned — not officially supported yet. */
1456
- MDL_KR_REGION: 'mdl_kr_region',
1457
- });
1458
- /**
1459
- * Support status for every circuit.
1460
- *
1461
- * The record is exhaustive over {@link CircuitId}: adding an identifier without
1462
- * giving it a status is a compile error, so this list cannot drift from
1463
- * {@link CIRCUIT_IDS}.
1464
- *
1465
- * @example
1466
- * ```typescript
1467
- * import { CIRCUIT_SUPPORT_STATUS } from '@zkproofport-app/sdk/circuits';
1468
- *
1469
- * CIRCUIT_SUPPORT_STATUS.coinbase_attestation; // 'supported'
1470
- * CIRCUIT_SUPPORT_STATUS.mdl_kr_age; // 'planned'
1471
- * ```
1472
- */
1473
- const CIRCUIT_SUPPORT_STATUS = Object.freeze({
1474
- coinbase_attestation: 'supported',
1475
- coinbase_country_attestation: 'supported',
1476
- oidc_domain_attestation: 'supported',
1477
- giwa_attestation: 'planned',
1478
- mdl_kr_ownership: 'planned',
1479
- mdl_kr_age: 'planned',
1480
- mdl_kr_region: 'planned',
1481
- });
1482
- /**
1483
- * Where each circuit's verification key lives inside the `zkproofport/circuits`
1484
- * repository, relative to its root.
1485
- *
1486
- * Off-chain verification needs the verification key and nothing else — no
1487
- * circuit bytecode, no witness. The key is public by nature: it is what anyone
1488
- * checking a proof must already have, and the Solidity verifiers deployed
1489
- * on-chain embed the same key.
1490
- *
1491
- * Exhaustive over {@link CircuitId} on purpose. A new circuit whose key path is
1492
- * not listed here fails to compile rather than failing at verification time,
1493
- * where the only symptom would be a proof that cannot be checked.
1494
- */
1495
- const CIRCUIT_VK_PATHS = Object.freeze({
1496
- coinbase_attestation: 'coinbase-attestation/target/vk',
1497
- coinbase_country_attestation: 'coinbase-country-attestation/target/vk',
1498
- oidc_domain_attestation: 'oidc-domain-attestation/target/vk',
1499
- giwa_attestation: 'giwa-attestation/target/vk',
1500
- mdl_kr_ownership: 'mdl/kr-ownership/target/vk',
1501
- mdl_kr_age: 'mdl/kr-age/target/vk',
1502
- mdl_kr_region: 'mdl/kr-region/target/vk',
1503
- });
1504
- /**
1505
- * Every canonical circuit identifier, in declaration order.
1506
- *
1507
- * Includes circuits that are still `planned`. Use {@link SUPPORTED_CIRCUIT_IDS}
1508
- * when you only want the ones that are officially supported today.
1509
- */
1510
- const ALL_CIRCUIT_IDS = Object.freeze(Object.values(CIRCUIT_IDS));
1511
- /**
1512
- * Circuits that are officially supported today: the two Coinbase circuits and
1513
- * OIDC domain attestation.
1514
- *
1515
- * Derived from {@link CIRCUIT_SUPPORT_STATUS}, so it cannot fall out of sync.
1516
- */
1517
- const SUPPORTED_CIRCUIT_IDS = Object.freeze(ALL_CIRCUIT_IDS.filter((id) => CIRCUIT_SUPPORT_STATUS[id] === 'supported'));
1518
- /**
1519
- * Circuits whose identifiers are reserved but which are not officially
1520
- * supported yet: GIWA attestation and the three Korea Mobile ID circuits.
1521
- *
1522
- * Derived from {@link CIRCUIT_SUPPORT_STATUS}, so it cannot fall out of sync.
1523
- */
1524
- const PLANNED_CIRCUIT_IDS = Object.freeze(ALL_CIRCUIT_IDS.filter((id) => CIRCUIT_SUPPORT_STATUS[id] === 'planned'));
1525
- /**
1526
- * Narrows an unknown value to a canonical circuit identifier.
1527
- *
1528
- * Returns `false` for hyphenated route names such as `'coinbase-kyc'`, which
1529
- * are not circuit IDs.
1530
- *
1531
- * @example
1532
- * ```typescript
1533
- * isCircuitId('coinbase_attestation'); // true
1534
- * isCircuitId('coinbase-kyc'); // false
1535
- * ```
1536
- */
1537
- function isCircuitId(value) {
1538
- return typeof value === 'string' && ALL_CIRCUIT_IDS.includes(value);
1539
- }
1540
- /**
1541
- * Narrows an unknown value to a circuit identifier that is officially supported
1542
- * today. Planned circuits return `false`.
1543
- *
1544
- * @example
1545
- * ```typescript
1546
- * isSupportedCircuitId('oidc_domain_attestation'); // true
1547
- * isSupportedCircuitId('mdl_kr_age'); // false — planned
1548
- * ```
1549
- */
1550
- function isSupportedCircuitId(value) {
1551
- return isCircuitId(value) && CIRCUIT_SUPPORT_STATUS[value] === 'supported';
1552
- }
1553
- /**
1554
- * Returns the support status of a circuit.
1555
- *
1556
- * @param circuit - A canonical circuit identifier.
1557
- * @throws Error when the value is not a canonical circuit identifier.
1558
- *
1559
- * @example
1560
- * ```typescript
1561
- * getCircuitSupportStatus('giwa_attestation'); // 'planned'
1562
- * ```
1563
- */
1564
- function getCircuitSupportStatus(circuit) {
1565
- if (!isCircuitId(circuit)) {
1566
- throw new Error(`Unknown circuit '${String(circuit)}'. Expected one of: ${ALL_CIRCUIT_IDS.join(', ')}`);
1567
- }
1568
- return CIRCUIT_SUPPORT_STATUS[circuit];
1569
- }
1570
-
1571
1740
  /**
1572
1741
  * Off-chain proof verification — checking a proof against its verification key,
1573
1742
  * with no chain, no RPC, and no gas.
@@ -2765,11 +2934,83 @@ class ProofportSDK {
2765
2934
  }
2766
2935
  return await response.json();
2767
2936
  }
2937
+ /**
2938
+ * Creates a proof request through the relay server.
2939
+ *
2940
+ * This is the recommended way to create proof requests. The relay server:
2941
+ * - Issues a server-side requestId (validated by the mobile app)
2942
+ * - Tracks request status in Redis
2943
+ * - Builds the deep link with relay callback URL
2944
+ * - Stores inputs hash for deep link integrity verification
2945
+ *
2946
+ * @param circuit - Circuit type identifier
2947
+ * @param inputs - Circuit-specific inputs
2948
+ * @param options - Request options (message, dappName, dappIcon, nonce, returnScheme)
2949
+ * @param options.returnScheme - Which app the ZKProofport app should switch back
2950
+ * to once it has finished. Put **your own** deep link scheme here — the bare
2951
+ * scheme your app registers, e.g. `'mydapp://'`. Only a native app has an
2952
+ * answer to this question; a web app omits the option entirely, because
2953
+ * opening its https origin would launch a new browser tab on a fresh page
2954
+ * rather than return the user to the tab they started from.
2955
+ *
2956
+ * This is intentionally **not** a URL and not a return address: no path, query
2957
+ * string or fragment is accepted, and the ZKProofport app never sends the proof
2958
+ * there. The proof still comes back to you the same way it always did — over the
2959
+ * relay, via `waitForProof()` / `waitForResult()`. `returnScheme` only answers
2960
+ * "which app should come back to the foreground", exactly like the
2961
+ * `redirect.native` value a wallet uses to hand control back after signing.
2962
+ *
2963
+ * **Omit it and the SDK decides for you.** When the request is created from a
2964
+ * page running in Chrome for iOS (`CriOS`) or Firefox for iOS (`FxiOS`), the
2965
+ * SDK fills in `'googlechrome://'` or `'firefox://'` by itself: opening either
2966
+ * scheme bare brings that browser forward without navigating, so the user lands
2967
+ * back on the tab they were already reading. Every other browser sends nothing,
2968
+ * because nothing safe exists to send — Safari has no such scheme, and Brave
2969
+ * and Arc are indistinguishable from Safari on iOS, so guessing would eject the
2970
+ * user into the wrong browser.
2971
+ * On Android nothing is needed at all: the ZKProofport app brings itself to the
2972
+ * back and the browser resumes exactly as it was. When no target is sent and
2973
+ * none can be inferred, the ZKProofport app tells the user the proof was
2974
+ * delivered and lets them switch back themselves.
2975
+ *
2976
+ * There is no auto-switch on proof failure; only on success and on the user
2977
+ * declining the request.
2978
+ *
2979
+ * Rejected with an error before the request is sent: empty or whitespace values,
2980
+ * anything longer than 128 characters, values carrying a host/path/query/fragment
2981
+ * (including https origins such as `'https://myapp.com'`), and the `http:`,
2982
+ * `file:`, `data:`, `javascript:`, `intent:`, `tel:`, `sms:`, `mailto:` families.
2983
+ * @returns Promise resolving to RelayProofRequest with requestId, deepLink, pollUrl
2984
+ * @throws Error if signer not set, returnScheme is malformed, or the relay request fails
2985
+ *
2986
+ * @example
2987
+ * ```typescript
2988
+ * const sdk = ProofportSDK.create();
2989
+ * sdk.setSigner(signer);
2990
+ *
2991
+ * const relay = await sdk.createRelayRequest('coinbase_attestation', {
2992
+ * scope: 'myapp.com'
2993
+ * }, { dappName: 'My DApp', returnScheme: 'mydapp://' });
2994
+ *
2995
+ * // Generate QR code from relay deep link
2996
+ * const qr = await sdk.generateQRCode(relay.deepLink);
2997
+ *
2998
+ * // Wait for proof (WebSocket primary, polling fallback)
2999
+ * const result = await sdk.waitForProof(relay.requestId);
3000
+ * ```
3001
+ */
3002
+ // Which circuits need a wallet signature, from the one record in
3003
+ // src/circuits.ts. The typed-out pair that used to sit here had missed
3004
+ // arc_eligibility, so a dapp calling createRelayRequest() for it with no
3005
+ // signer got no local error and a relay 401 two round trips later.
3006
+ static needsWalletSignature(circuit) {
3007
+ return CIRCUIT_NEEDS_WALLET_SIGNATURE[circuit] === true;
3008
+ }
2768
3009
  async createRelayRequest(circuit, inputs, options = {}) {
2769
3010
  if (!this.relayUrl) {
2770
3011
  throw new Error('relayUrl is required. Set it in ProofportSDK config.');
2771
3012
  }
2772
- const needsSignature = ProofportSDK.WALLET_SIGNATURE_CIRCUITS.includes(circuit);
3013
+ const needsSignature = ProofportSDK.needsWalletSignature(circuit);
2773
3014
  if (needsSignature && !this.signer) {
2774
3015
  throw new Error('Signer not set. Call setSigner() first. Wallet signature is required for this circuit.');
2775
3016
  }
@@ -2781,6 +3022,17 @@ class ProofportSDK {
2781
3022
  throw new Error(`${mdlError} (circuit: ${circuit})`);
2782
3023
  }
2783
3024
  }
3025
+ // arc_eligibility: the action is what the proof binds to, so a request
3026
+ // without a usable one cannot be honoured. Checked here, before a
3027
+ // challenge is burned, because the alternative is a refusal from inside
3028
+ // the mobile app -- a screen the dapp developer never sees, reached after
3029
+ // the person has already scanned a QR code.
3030
+ if (circuit === 'arc_eligibility') {
3031
+ const actionError = validateTypedAction(inputs.action);
3032
+ if (actionError) {
3033
+ throw new Error(`${actionError} (circuit: ${circuit})`);
3034
+ }
3035
+ }
2784
3036
  // Return target: validated before we burn a challenge, so a typo surfaces
2785
3037
  // as a local error instead of a relay 400 on the second round trip.
2786
3038
  if (options.returnScheme !== undefined) {
@@ -3105,76 +3357,6 @@ class ProofportSDK {
3105
3357
  return extractDomainFromPublicInputs(publicInputs, circuit);
3106
3358
  }
3107
3359
  }
3108
- /**
3109
- * Creates a proof request through the relay server.
3110
- *
3111
- * This is the recommended way to create proof requests. The relay server:
3112
- * - Issues a server-side requestId (validated by the mobile app)
3113
- * - Tracks request status in Redis
3114
- * - Builds the deep link with relay callback URL
3115
- * - Stores inputs hash for deep link integrity verification
3116
- *
3117
- * @param circuit - Circuit type identifier
3118
- * @param inputs - Circuit-specific inputs
3119
- * @param options - Request options (message, dappName, dappIcon, nonce, returnScheme)
3120
- * @param options.returnScheme - Which app the ZKProofport app should switch back
3121
- * to once it has finished. Put **your own** deep link scheme here — the bare
3122
- * scheme your app registers, e.g. `'mydapp://'`. Only a native app has an
3123
- * answer to this question; a web app omits the option entirely, because
3124
- * opening its https origin would launch a new browser tab on a fresh page
3125
- * rather than return the user to the tab they started from.
3126
- *
3127
- * This is intentionally **not** a URL and not a return address: no path, query
3128
- * string or fragment is accepted, and the ZKProofport app never sends the proof
3129
- * there. The proof still comes back to you the same way it always did — over the
3130
- * relay, via `waitForProof()` / `waitForResult()`. `returnScheme` only answers
3131
- * "which app should come back to the foreground", exactly like the
3132
- * `redirect.native` value a wallet uses to hand control back after signing.
3133
- *
3134
- * **Omit it and the SDK decides for you.** When the request is created from a
3135
- * page running in Chrome for iOS (`CriOS`) or Firefox for iOS (`FxiOS`), the
3136
- * SDK fills in `'googlechrome://'` or `'firefox://'` by itself: opening either
3137
- * scheme bare brings that browser forward without navigating, so the user lands
3138
- * back on the tab they were already reading. Every other browser sends nothing,
3139
- * because nothing safe exists to send — Safari has no such scheme, and Brave
3140
- * and Arc are indistinguishable from Safari on iOS, so guessing would eject the
3141
- * user into the wrong browser.
3142
- * On Android nothing is needed at all: the ZKProofport app brings itself to the
3143
- * back and the browser resumes exactly as it was. When no target is sent and
3144
- * none can be inferred, the ZKProofport app tells the user the proof was
3145
- * delivered and lets them switch back themselves.
3146
- *
3147
- * There is no auto-switch on proof failure; only on success and on the user
3148
- * declining the request.
3149
- *
3150
- * Rejected with an error before the request is sent: empty or whitespace values,
3151
- * anything longer than 128 characters, values carrying a host/path/query/fragment
3152
- * (including https origins such as `'https://myapp.com'`), and the `http:`,
3153
- * `file:`, `data:`, `javascript:`, `intent:`, `tel:`, `sms:`, `mailto:` families.
3154
- * @returns Promise resolving to RelayProofRequest with requestId, deepLink, pollUrl
3155
- * @throws Error if signer not set, returnScheme is malformed, or the relay request fails
3156
- *
3157
- * @example
3158
- * ```typescript
3159
- * const sdk = ProofportSDK.create();
3160
- * sdk.setSigner(signer);
3161
- *
3162
- * const relay = await sdk.createRelayRequest('coinbase_attestation', {
3163
- * scope: 'myapp.com'
3164
- * }, { dappName: 'My DApp', returnScheme: 'mydapp://' });
3165
- *
3166
- * // Generate QR code from relay deep link
3167
- * const qr = await sdk.generateQRCode(relay.deepLink);
3168
- *
3169
- * // Wait for proof (WebSocket primary, polling fallback)
3170
- * const result = await sdk.waitForProof(relay.requestId);
3171
- * ```
3172
- */
3173
- // Circuits that require wallet signature (used as circuit input)
3174
- ProofportSDK.WALLET_SIGNATURE_CIRCUITS = [
3175
- 'coinbase_attestation',
3176
- 'coinbase_country_attestation',
3177
- ];
3178
3360
 
3179
3361
  exports.ALL_CIRCUIT_IDS = ALL_CIRCUIT_IDS;
3180
3362
  exports.CIRCUIT_IDS = CIRCUIT_IDS;
@@ -3182,6 +3364,7 @@ exports.CIRCUIT_SUPPORT_STATUS = CIRCUIT_SUPPORT_STATUS;
3182
3364
  exports.CIRCUIT_VK_PATHS = CIRCUIT_VK_PATHS;
3183
3365
  exports.COINBASE_ATTESTATION_PUBLIC_INPUT_LAYOUT = COINBASE_ATTESTATION_PUBLIC_INPUT_LAYOUT;
3184
3366
  exports.COINBASE_COUNTRY_PUBLIC_INPUT_LAYOUT = COINBASE_COUNTRY_PUBLIC_INPUT_LAYOUT;
3367
+ exports.EXPERIMENTAL_CIRCUIT_IDS = EXPERIMENTAL_CIRCUIT_IDS;
3185
3368
  exports.MDL_KR_PUBLIC_INPUT_LAYOUT = MDL_KR_PUBLIC_INPUT_LAYOUT;
3186
3369
  exports.OIDC_DOMAIN_ATTESTATION_PUBLIC_INPUT_LAYOUT = OIDC_DOMAIN_ATTESTATION_PUBLIC_INPUT_LAYOUT;
3187
3370
  exports.PLANNED_CIRCUIT_IDS = PLANNED_CIRCUIT_IDS;
@@ -3194,5 +3377,7 @@ exports.extractScopeFromPublicInputs = extractScopeFromPublicInputs;
3194
3377
  exports.getCircuitSupportStatus = getCircuitSupportStatus;
3195
3378
  exports.isCircuitId = isCircuitId;
3196
3379
  exports.isSupportedCircuitId = isSupportedCircuitId;
3380
+ exports.parseTypedAction = parseTypedAction;
3381
+ exports.validateTypedAction = validateTypedAction;
3197
3382
  exports.verifyProofOffChain = verifyProofOffChain;
3198
3383
  //# sourceMappingURL=index.js.map