@bolyra/sdk 0.2.0 → 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.
Files changed (51) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +63 -0
  3. package/README.md +2 -2
  4. package/dist/delegation.d.ts +64 -16
  5. package/dist/delegation.d.ts.map +1 -1
  6. package/dist/delegation.js +200 -17
  7. package/dist/delegation.js.map +1 -1
  8. package/dist/errors.d.ts +12 -0
  9. package/dist/errors.d.ts.map +1 -1
  10. package/dist/errors.js +32 -1
  11. package/dist/errors.js.map +1 -1
  12. package/dist/handshake.d.ts +2 -0
  13. package/dist/handshake.d.ts.map +1 -1
  14. package/dist/handshake.js +55 -13
  15. package/dist/handshake.js.map +1 -1
  16. package/dist/identity.d.ts +24 -0
  17. package/dist/identity.d.ts.map +1 -1
  18. package/dist/identity.js +46 -0
  19. package/dist/identity.js.map +1 -1
  20. package/dist/index.d.ts +8 -3
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +26 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/model-binding.d.ts +113 -0
  25. package/dist/model-binding.d.ts.map +1 -0
  26. package/dist/model-binding.js +195 -0
  27. package/dist/model-binding.js.map +1 -0
  28. package/dist/offchain.d.ts +89 -0
  29. package/dist/offchain.d.ts.map +1 -0
  30. package/dist/offchain.js +300 -0
  31. package/dist/offchain.js.map +1 -0
  32. package/dist/prover.d.ts +21 -0
  33. package/dist/prover.d.ts.map +1 -0
  34. package/dist/prover.js +171 -0
  35. package/dist/prover.js.map +1 -0
  36. package/dist/types.d.ts +29 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/utils.d.ts +4 -0
  39. package/dist/utils.d.ts.map +1 -1
  40. package/dist/utils.js +14 -0
  41. package/dist/utils.js.map +1 -1
  42. package/package.json +5 -3
  43. package/src/delegation.ts +268 -30
  44. package/src/errors.ts +46 -0
  45. package/src/handshake.ts +69 -20
  46. package/src/identity.ts +55 -1
  47. package/src/index.ts +29 -2
  48. package/src/offchain.ts +344 -0
  49. package/src/prover.ts +178 -0
  50. package/src/types.ts +32 -0
  51. package/src/utils.ts +23 -0
package/src/errors.ts CHANGED
@@ -67,3 +67,49 @@ export class StaleProofError extends BolyraError {
67
67
  );
68
68
  }
69
69
  }
70
+
71
+ export class InvalidSecretError extends BolyraError {
72
+ constructor(reason: string) {
73
+ super(
74
+ `Invalid secret: ${reason}. Provide a non-zero bigint less than the BN254 scalar field order (approx 2^254).`,
75
+ 'INVALID_SECRET',
76
+ { reason }
77
+ );
78
+ }
79
+ }
80
+
81
+ export class CircuitArtifactNotFoundError extends ProofGenerationError {
82
+ constructor(artifactPath: string, artifactType: 'wasm' | 'zkey' | 'vkey') {
83
+ super(
84
+ artifactType === 'vkey' ? 'verification' : 'proof generation',
85
+ `Circuit artifact not found: ${artifactPath}. ` +
86
+ `Ensure the ${artifactType} file exists at this path. ` +
87
+ `If using a custom circuitDir, verify it contains the compiled circuit outputs. ` +
88
+ `Run the circuit build script or download trusted artifacts from the Bolyra release.`
89
+ );
90
+ this.code = 'CIRCUIT_ARTIFACT_NOT_FOUND';
91
+ this.details = { ...this.details, artifactPath, artifactType };
92
+ }
93
+ }
94
+
95
+ export class MerkleTreeError extends BolyraError {
96
+ constructor(reason: string, details?: Record<string, unknown>) {
97
+ super(
98
+ `Merkle tree operation failed: ${reason}. ` +
99
+ `Check that the tree is properly initialized and the leaf index is within bounds.`,
100
+ 'MERKLE_TREE_ERROR',
101
+ { reason, ...details }
102
+ );
103
+ }
104
+ }
105
+
106
+ export class ConfigurationError extends BolyraError {
107
+ constructor(field: string, reason: string) {
108
+ super(
109
+ `Invalid SDK configuration for "${field}": ${reason}. ` +
110
+ `Review the BolyraConfig interface and ensure all required fields are set correctly.`,
111
+ 'CONFIGURATION_ERROR',
112
+ { field, reason }
113
+ );
114
+ }
115
+ }
package/src/handshake.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as snarkjs from 'snarkjs';
2
2
  import * as path from 'path';
3
+ import * as fs from 'fs';
3
4
  import {
4
5
  HumanIdentity,
5
6
  AgentCredential,
@@ -7,7 +8,8 @@ import {
7
8
  Proof,
8
9
  BolyraConfig,
9
10
  } from './types';
10
- import { ProofGenerationError } from './errors';
11
+ import { ProofGenerationError, CircuitArtifactNotFoundError, VerificationError } from './errors';
12
+ import { proveGroth16, ProverBackend } from './prover';
11
13
 
12
14
  // Default paths to circuit artifacts (relative to package root)
13
15
  const DEFAULT_CIRCUIT_DIR = path.join(__dirname, '../../circuits/build');
@@ -38,16 +40,37 @@ export async function proveHandshake(
38
40
  scope?: bigint;
39
41
  nonce?: bigint;
40
42
  config?: BolyraConfig;
43
+ backend?: ProverBackend;
41
44
  },
42
45
  ): Promise<{ humanProof: Proof; agentProof: Proof; nonce: bigint }> {
43
46
  const scope = options?.scope ?? 1n;
44
47
  const nonce = options?.nonce ?? BigInt(Date.now());
45
48
  const circuitDir = options?.config?.circuitDir ?? DEFAULT_CIRCUIT_DIR;
49
+ const backend = options?.backend ?? 'auto';
50
+
51
+ // Validate circuit artifacts exist before attempting proof generation
52
+ const humanWasm = path.join(circuitDir, 'HumanUniqueness_js/HumanUniqueness.wasm');
53
+ const humanZkey = path.join(circuitDir, 'HumanUniqueness_final.zkey');
54
+ const agentWasm = path.join(circuitDir, 'AgentPolicy_js/AgentPolicy.wasm');
55
+ const agentZkey = path.join(circuitDir, 'AgentPolicy_final.zkey');
56
+
57
+ if (!fs.existsSync(humanWasm)) {
58
+ throw new CircuitArtifactNotFoundError(humanWasm, 'wasm');
59
+ }
60
+ if (!fs.existsSync(humanZkey)) {
61
+ throw new CircuitArtifactNotFoundError(humanZkey, 'zkey');
62
+ }
63
+ if (!fs.existsSync(agentWasm)) {
64
+ throw new CircuitArtifactNotFoundError(agentWasm, 'wasm');
65
+ }
66
+ if (!fs.existsSync(agentZkey)) {
67
+ throw new CircuitArtifactNotFoundError(agentZkey, 'zkey');
68
+ }
46
69
 
47
70
  // Generate both proofs in parallel
48
71
  const [humanProof, agentProof] = await Promise.all([
49
- generateHumanProof(human, scope, nonce, circuitDir),
50
- generateAgentProof(agent, nonce, circuitDir),
72
+ generateHumanProof(human, scope, nonce, circuitDir, backend),
73
+ generateAgentProof(agent, nonce, circuitDir, backend),
51
74
  ]);
52
75
 
53
76
  return { humanProof, agentProof, nonce };
@@ -58,6 +81,7 @@ async function generateHumanProof(
58
81
  scope: bigint,
59
82
  nonce: bigint,
60
83
  circuitDir: string,
84
+ backend: ProverBackend,
61
85
  ): Promise<Proof> {
62
86
  const wasmPath = path.join(
63
87
  circuitDir,
@@ -78,12 +102,7 @@ async function generateHumanProof(
78
102
  };
79
103
 
80
104
  try {
81
- const { proof, publicSignals } = await snarkjs.groth16.fullProve(
82
- input,
83
- wasmPath,
84
- zkeyPath,
85
- );
86
- return { proof, publicSignals };
105
+ return await proveGroth16(input, wasmPath, zkeyPath, backend);
87
106
  } catch (err: any) {
88
107
  throw new ProofGenerationError(
89
108
  'HumanUniqueness',
@@ -96,12 +115,13 @@ async function generateAgentProof(
96
115
  agent: AgentCredential,
97
116
  nonce: bigint,
98
117
  circuitDir: string,
118
+ backend: ProverBackend,
99
119
  ): Promise<Proof> {
100
120
  const wasmPath = path.join(
101
121
  circuitDir,
102
122
  'AgentPolicy_js/AgentPolicy.wasm',
103
123
  );
104
- const zkeyPath = path.join(circuitDir, 'AgentPolicy_plonk.zkey');
124
+ const zkeyPath = path.join(circuitDir, 'AgentPolicy_final.zkey');
105
125
 
106
126
  const currentTimestamp = BigInt(Math.floor(Date.now() / 1000));
107
127
  const requiredScopeMask = 0n; // no required scope for basic handshake
@@ -126,12 +146,7 @@ async function generateAgentProof(
126
146
  };
127
147
 
128
148
  try {
129
- const { proof, publicSignals } = await snarkjs.plonk.fullProve(
130
- input,
131
- wasmPath,
132
- zkeyPath,
133
- );
134
- return { proof, publicSignals };
149
+ return await proveGroth16(input, wasmPath, zkeyPath, backend);
135
150
  } catch (err: any) {
136
151
  throw new ProofGenerationError(
137
152
  'AgentPolicy',
@@ -158,8 +173,43 @@ export async function verifyHandshake(
158
173
  ): Promise<HandshakeResult> {
159
174
  const circuitDir = config?.circuitDir ?? DEFAULT_CIRCUIT_DIR;
160
175
 
161
- // Verify human proof (Groth16)
176
+ // Validate proof structure before verification
177
+ if (!humanProof || !humanProof.proof || !Array.isArray(humanProof.publicSignals)) {
178
+ throw new VerificationError(
179
+ 'Invalid humanProof structure: expected { proof: object, publicSignals: string[] }. ' +
180
+ 'Ensure you are passing the proof object returned by proveHandshake().'
181
+ );
182
+ }
183
+ if (!agentProof || !agentProof.proof || !Array.isArray(agentProof.publicSignals)) {
184
+ throw new VerificationError(
185
+ 'Invalid agentProof structure: expected { proof: object, publicSignals: string[] }. ' +
186
+ 'Ensure you are passing the proof object returned by proveHandshake().'
187
+ );
188
+ }
189
+ if (humanProof.publicSignals.length < 2) {
190
+ throw new VerificationError(
191
+ `humanProof has ${humanProof.publicSignals.length} public signals, expected at least 2. ` +
192
+ 'The proof may have been generated with an incompatible circuit version.'
193
+ );
194
+ }
195
+ if (agentProof.publicSignals.length < 3) {
196
+ throw new VerificationError(
197
+ `agentProof has ${agentProof.publicSignals.length} public signals, expected at least 3. ` +
198
+ 'The proof may have been generated with an incompatible circuit version.'
199
+ );
200
+ }
201
+
202
+ // Verify vkey files exist
162
203
  const humanVkeyPath = path.join(circuitDir, 'HumanUniqueness_vkey.json');
204
+ if (!fs.existsSync(humanVkeyPath)) {
205
+ throw new CircuitArtifactNotFoundError(humanVkeyPath, 'vkey');
206
+ }
207
+ const agentVkeyPath = path.join(circuitDir, 'AgentPolicy_groth16_vkey.json');
208
+ if (!fs.existsSync(agentVkeyPath)) {
209
+ throw new CircuitArtifactNotFoundError(agentVkeyPath, 'vkey');
210
+ }
211
+
212
+ // Verify human proof (Groth16)
163
213
  const humanVkey = require(humanVkeyPath);
164
214
  const humanValid = await snarkjs.groth16.verify(
165
215
  humanVkey,
@@ -167,10 +217,9 @@ export async function verifyHandshake(
167
217
  humanProof.proof,
168
218
  );
169
219
 
170
- // Verify agent proof (PLONK)
171
- const agentVkeyPath = path.join(circuitDir, 'AgentPolicy_vkey.json');
220
+ // Verify agent proof (Groth16)
172
221
  const agentVkey = require(agentVkeyPath);
173
- const agentValid = await snarkjs.plonk.verify(
222
+ const agentValid = await snarkjs.groth16.verify(
174
223
  agentVkey,
175
224
  agentProof.publicSignals,
176
225
  agentProof.proof,
package/src/identity.ts CHANGED
@@ -1,6 +1,58 @@
1
1
  import { HumanIdentity, AgentCredential, Permission } from './types';
2
2
  import { poseidon2, poseidon5, eddsaSign, derivePublicKey, derivePublicKeyScalar } from './utils';
3
- import { InvalidPermissionError } from './errors';
3
+ import { InvalidPermissionError, InvalidSecretError } from './errors';
4
+
5
+ // BN254 scalar field order (Baby Jubjub subgroup order)
6
+ export const BN254_FIELD_ORDER = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
7
+
8
+ /**
9
+ * Validate a secret value for use with createHumanIdentity.
10
+ * Throws InvalidSecretError if the secret is zero, negative, or exceeds BN254 field.
11
+ *
12
+ * Call this before createHumanIdentity() for strict input validation.
13
+ * createHumanIdentity itself is permissive (the crypto layer handles reduction),
14
+ * but using an invalid secret will produce an identity that fails proof generation.
15
+ *
16
+ * @param secret - The secret to validate
17
+ * @throws InvalidSecretError if validation fails
18
+ */
19
+ export function validateHumanSecret(secret: bigint): void {
20
+ if (secret === 0n) {
21
+ throw new InvalidSecretError(
22
+ 'secret must be non-zero — a zero secret produces a trivial identity that cannot generate valid proofs'
23
+ );
24
+ }
25
+ if (secret < 0n) {
26
+ throw new InvalidSecretError(
27
+ 'secret must be positive — negative values are not valid field elements'
28
+ );
29
+ }
30
+ if (secret >= BN254_FIELD_ORDER) {
31
+ throw new InvalidSecretError(
32
+ `secret exceeds BN254 scalar field order (got ${secret.toString().slice(0, 20)}..., max is ~2^254). Use a value less than ${BN254_FIELD_ORDER}`
33
+ );
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Validate an expiry timestamp for use with createAgentCredential.
39
+ * Throws InvalidPermissionError if the timestamp is in the past.
40
+ *
41
+ * Call this before createAgentCredential() to catch expired timestamps early.
42
+ * The circuit enforces expiry at verification time, but this provides an early check.
43
+ *
44
+ * @param expiryTimestamp - Unix timestamp to validate
45
+ * @throws InvalidPermissionError if timestamp is not in the future
46
+ */
47
+ export function validateAgentExpiry(expiryTimestamp: bigint): void {
48
+ const nowSeconds = BigInt(Math.floor(Date.now() / 1000));
49
+ if (expiryTimestamp <= nowSeconds) {
50
+ throw new InvalidPermissionError(
51
+ `expiryTimestamp (${expiryTimestamp}) is not in the future (current time: ${nowSeconds}). ` +
52
+ `Set expiryTimestamp to a Unix timestamp after the current time, e.g. BigInt(Math.floor(Date.now() / 1000) + 86400) for +1 day.`
53
+ );
54
+ }
55
+ }
4
56
 
5
57
  /**
6
58
  * Create a human identity (EdDSA keypair + commitment).
@@ -19,6 +71,7 @@ import { InvalidPermissionError } from './errors';
19
71
  export async function createHumanIdentity(
20
72
  secret: bigint,
21
73
  ): Promise<HumanIdentity> {
74
+ validateHumanSecret(secret);
22
75
  // HumanUniqueness circuit uses BabyPbk (direct scalar multiply),
23
76
  // NOT EdDSA prv2pub. Use derivePublicKeyScalar here.
24
77
  const publicKey = await derivePublicKeyScalar(secret);
@@ -52,6 +105,7 @@ export async function createAgentCredential(
52
105
  permissions: Permission[],
53
106
  expiryTimestamp: bigint,
54
107
  ): Promise<AgentCredential> {
108
+ validateAgentExpiry(expiryTimestamp);
55
109
  const bitmask = permissionsToBitmask(permissions);
56
110
  validateCumulativeBitEncoding(bitmask);
57
111
 
package/src/index.ts CHANGED
@@ -4,8 +4,11 @@ export type {
4
4
  AgentCredential,
5
5
  HandshakeResult,
6
6
  DelegationResult,
7
+ DelegateeMerkleProof,
7
8
  Proof,
8
9
  BolyraConfig,
10
+ OffchainVerificationResult,
11
+ BatchCheckpoint,
9
12
  } from './types';
10
13
 
11
14
  // Permission enum
@@ -17,13 +20,33 @@ export {
17
20
  createAgentCredential,
18
21
  permissionsToBitmask,
19
22
  validateCumulativeBitEncoding,
23
+ validateHumanSecret,
24
+ validateAgentExpiry,
25
+ BN254_FIELD_ORDER,
20
26
  } from './identity';
21
27
 
22
- // Handshake (v0.2 — real proof generation via snarkjs)
28
+ // Handshake (v0.2 — real proof generation via snarkjs / rapidsnark)
23
29
  export { proveHandshake, verifyHandshake } from './handshake';
24
30
 
25
- // Delegation (stubscoming in v0.3)
31
+ // Prover backend (v0.4rapidsnark for sub-200ms proofs)
32
+ export { proveGroth16, activeProverBackend } from './prover';
33
+ export type { ProverBackend } from './prover';
34
+
35
+ // Off-chain verification (v0.3 — batch mode, ~100x gas reduction)
36
+ export {
37
+ verifyHandshakeOffchain,
38
+ OffchainVerificationBatch,
39
+ postBatchRoot,
40
+ computeSessionCommitment,
41
+ verifyMerkleInclusion,
42
+ } from './offchain';
43
+
44
+ // Delegation (v0.3 — scope-narrowing one-way delegation, chain-linked on-chain)
26
45
  export { delegate, verifyDelegation } from './delegation';
46
+ export type { DelegateInput } from './delegation';
47
+
48
+ // Poseidon hashes (exposed for chain-link verification in integrations)
49
+ export { poseidon2, poseidon3, poseidon4 } from './utils';
27
50
 
28
51
  // Errors
29
52
  export {
@@ -34,4 +57,8 @@ export {
34
57
  ExpiredCredentialError,
35
58
  ScopeEscalationError,
36
59
  StaleProofError,
60
+ InvalidSecretError,
61
+ CircuitArtifactNotFoundError,
62
+ MerkleTreeError,
63
+ ConfigurationError,
37
64
  } from './errors';
@@ -0,0 +1,344 @@
1
+ import * as snarkjs from 'snarkjs';
2
+ import * as path from 'path';
3
+ import * as fs from 'fs';
4
+ import { ethers } from 'ethers';
5
+ import {
6
+ Proof,
7
+ BolyraConfig,
8
+ HandshakeResult,
9
+ OffchainVerificationResult,
10
+ BatchCheckpoint,
11
+ } from './types';
12
+ import { VerificationError, CircuitArtifactNotFoundError } from './errors';
13
+ import { poseidon2 } from './utils';
14
+
15
+ // Default paths to circuit artifacts (relative to package root)
16
+ const DEFAULT_CIRCUIT_DIR = path.join(__dirname, '../../circuits/build');
17
+
18
+ /**
19
+ * Verify a handshake off-chain using local snarkjs verification.
20
+ * Same interface as verifyHandshake but never touches the chain.
21
+ * Produces a HandshakeResult suitable for batching.
22
+ *
23
+ * Gas savings: 0 gas per verification (vs ~300k+ on-chain).
24
+ * The batch root is posted once for N verifications.
25
+ */
26
+ export async function verifyHandshakeOffchain(
27
+ humanProof: Proof,
28
+ agentProof: Proof,
29
+ nonce: bigint,
30
+ config?: BolyraConfig,
31
+ ): Promise<HandshakeResult> {
32
+ const circuitDir = config?.circuitDir ?? DEFAULT_CIRCUIT_DIR;
33
+
34
+ // Validate proof structure
35
+ if (!humanProof || !humanProof.proof || !Array.isArray(humanProof.publicSignals)) {
36
+ throw new VerificationError(
37
+ 'Invalid humanProof structure: expected { proof: object, publicSignals: string[] }.'
38
+ );
39
+ }
40
+ if (!agentProof || !agentProof.proof || !Array.isArray(agentProof.publicSignals)) {
41
+ throw new VerificationError(
42
+ 'Invalid agentProof structure: expected { proof: object, publicSignals: string[] }.'
43
+ );
44
+ }
45
+ if (humanProof.publicSignals.length < 2) {
46
+ throw new VerificationError(
47
+ `humanProof has ${humanProof.publicSignals.length} public signals, expected at least 2.`
48
+ );
49
+ }
50
+ if (agentProof.publicSignals.length < 3) {
51
+ throw new VerificationError(
52
+ `agentProof has ${agentProof.publicSignals.length} public signals, expected at least 3.`
53
+ );
54
+ }
55
+
56
+ // Load verification keys
57
+ const humanVkeyPath = path.join(circuitDir, 'HumanUniqueness_vkey.json');
58
+ if (!fs.existsSync(humanVkeyPath)) {
59
+ throw new CircuitArtifactNotFoundError(humanVkeyPath, 'vkey');
60
+ }
61
+ const agentVkeyPath = path.join(circuitDir, 'AgentPolicy_groth16_vkey.json');
62
+ if (!fs.existsSync(agentVkeyPath)) {
63
+ throw new CircuitArtifactNotFoundError(agentVkeyPath, 'vkey');
64
+ }
65
+
66
+ // Verify both proofs locally (no on-chain interaction)
67
+ const humanVkey = require(humanVkeyPath);
68
+ const humanValid = await snarkjs.groth16.verify(
69
+ humanVkey,
70
+ humanProof.publicSignals,
71
+ humanProof.proof,
72
+ );
73
+
74
+ const agentVkey = require(agentVkeyPath);
75
+ const agentValid = await snarkjs.groth16.verify(
76
+ agentVkey,
77
+ agentProof.publicSignals,
78
+ agentProof.proof,
79
+ );
80
+
81
+ return {
82
+ humanNullifier: BigInt(humanProof.publicSignals[1]),
83
+ agentNullifier: BigInt(agentProof.publicSignals[1]),
84
+ sessionNonce: nonce,
85
+ scopeCommitment: BigInt(agentProof.publicSignals[2]),
86
+ verified: humanValid && agentValid,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Compute the session commitment for a HandshakeResult.
92
+ * sessionCommitment = Poseidon2(humanNullifier, Poseidon2(agentNullifier, sessionNonce))
93
+ * This binds all three fields into a single leaf for the batch Merkle tree.
94
+ */
95
+ export async function computeSessionCommitment(result: HandshakeResult): Promise<bigint> {
96
+ const inner = await poseidon2(result.agentNullifier, result.sessionNonce);
97
+ return poseidon2(result.humanNullifier, inner);
98
+ }
99
+
100
+ /**
101
+ * Accumulates verified handshake sessions and produces a Poseidon Merkle root.
102
+ * The root can be posted on-chain in a single transaction, amortizing gas
103
+ * across all sessions in the batch (target: ~100x reduction).
104
+ *
105
+ * Tree construction: binary Poseidon Merkle tree. If the number of leaves
106
+ * is not a power of 2, zero-padding is applied to the right.
107
+ */
108
+ export class OffchainVerificationBatch {
109
+ private sessions: HandshakeResult[] = [];
110
+ private commitments: bigint[] = [];
111
+ private cachedRoot: bigint | null = null;
112
+
113
+ /** Number of sessions in the batch. */
114
+ get size(): number {
115
+ return this.sessions.length;
116
+ }
117
+
118
+ /**
119
+ * Add a verified handshake result to the batch.
120
+ * Resets the cached Merkle root (will be recomputed on next getMerkleRoot call).
121
+ *
122
+ * @returns The OffchainVerificationResult with batchIndex set.
123
+ */
124
+ async add(result: HandshakeResult): Promise<OffchainVerificationResult> {
125
+ if (!result.verified) {
126
+ throw new VerificationError(
127
+ 'Cannot add unverified handshake to batch. Verify the handshake first.'
128
+ );
129
+ }
130
+
131
+ const batchIndex = this.sessions.length;
132
+ const commitment = await computeSessionCommitment(result);
133
+
134
+ this.sessions.push(result);
135
+ this.commitments.push(commitment);
136
+ this.cachedRoot = null; // invalidate cache
137
+
138
+ return {
139
+ ...result,
140
+ batchIndex,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Compute the Poseidon Merkle root of all session commitments.
146
+ * Uses a binary tree with zero-padding to the next power of 2.
147
+ * Result is cached until a new session is added.
148
+ */
149
+ async getMerkleRoot(): Promise<bigint> {
150
+ if (this.sessions.length === 0) {
151
+ return 0n;
152
+ }
153
+
154
+ if (this.cachedRoot !== null) {
155
+ return this.cachedRoot;
156
+ }
157
+
158
+ this.cachedRoot = await buildPoseidonMerkleRoot(this.commitments);
159
+ return this.cachedRoot;
160
+ }
161
+
162
+ /**
163
+ * Get a Merkle inclusion proof for a specific session in the batch.
164
+ * Returns sibling hashes and path indices (0 = left, 1 = right) from leaf to root.
165
+ *
166
+ * @param sessionIndex - Index of the session (from OffchainVerificationResult.batchIndex)
167
+ * @returns Merkle proof (siblings + pathIndices) or throws if index is out of bounds.
168
+ */
169
+ async getProofOfInclusion(
170
+ sessionIndex: number,
171
+ ): Promise<{ siblings: bigint[]; pathIndices: number[] }> {
172
+ if (sessionIndex < 0 || sessionIndex >= this.sessions.length) {
173
+ throw new VerificationError(
174
+ `Session index ${sessionIndex} out of bounds (batch has ${this.sessions.length} sessions).`
175
+ );
176
+ }
177
+
178
+ return buildPoseidonMerkleProof(this.commitments, sessionIndex);
179
+ }
180
+
181
+ /**
182
+ * Get the session commitment at a given index.
183
+ */
184
+ getCommitment(index: number): bigint {
185
+ if (index < 0 || index >= this.commitments.length) {
186
+ throw new VerificationError(
187
+ `Index ${index} out of bounds (batch has ${this.commitments.length} sessions).`
188
+ );
189
+ }
190
+ return this.commitments[index];
191
+ }
192
+
193
+ /**
194
+ * Get all session commitments (for external verification).
195
+ */
196
+ getCommitments(): bigint[] {
197
+ return [...this.commitments];
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Post a batch Merkle root on-chain. A single transaction checkpoints N sessions.
203
+ *
204
+ * Gas cost: ~50k-80k gas for a single storage write + event emission,
205
+ * regardless of how many sessions are in the batch.
206
+ * For 100 sessions: ~500 gas/session vs ~300k gas/session on-chain = ~600x reduction.
207
+ *
208
+ * @param batch - The batch to checkpoint
209
+ * @param signer - An ethers Signer (connected to the target chain)
210
+ * @param registryAddress - Address of the IdentityRegistry (or a BatchCheckpoint contract)
211
+ * @returns The BatchCheckpoint with on-chain timestamp
212
+ */
213
+ export async function postBatchRoot(
214
+ batch: OffchainVerificationBatch,
215
+ signer: ethers.Signer,
216
+ registryAddress: string,
217
+ ): Promise<BatchCheckpoint> {
218
+ if (batch.size === 0) {
219
+ throw new VerificationError('Cannot post empty batch root on-chain.');
220
+ }
221
+
222
+ const root = await batch.getMerkleRoot();
223
+
224
+ // ABI for the postBatchRoot function on the BatchCheckpoint extension contract.
225
+ // function postBatchRoot(uint256 root, uint256 sessionCount) external
226
+ const abi = [
227
+ 'function postBatchRoot(uint256 root, uint256 sessionCount) external',
228
+ 'event BatchRootPosted(uint256 indexed root, uint256 sessionCount, uint256 timestamp)',
229
+ ];
230
+
231
+ const contract = new ethers.Contract(registryAddress, abi, signer);
232
+ const tx = await contract.postBatchRoot(root, batch.size);
233
+ const receipt = await tx.wait();
234
+
235
+ const timestamp = receipt?.blockNumber
236
+ ? (await signer.provider!.getBlock(receipt.blockNumber))?.timestamp ?? Math.floor(Date.now() / 1000)
237
+ : Math.floor(Date.now() / 1000);
238
+
239
+ return {
240
+ root,
241
+ timestamp,
242
+ sessionCount: batch.size,
243
+ };
244
+ }
245
+
246
+ // ============ Internal Merkle Tree Helpers ============
247
+
248
+ /**
249
+ * Pad leaves array to the next power of 2 with zeros.
250
+ */
251
+ function padToPowerOfTwo(leaves: bigint[]): bigint[] {
252
+ if (leaves.length === 0) return [0n];
253
+ let size = 1;
254
+ while (size < leaves.length) {
255
+ size *= 2;
256
+ }
257
+ const padded = [...leaves];
258
+ while (padded.length < size) {
259
+ padded.push(0n);
260
+ }
261
+ return padded;
262
+ }
263
+
264
+ /**
265
+ * Build a Poseidon Merkle root from a list of leaf commitments.
266
+ * Binary tree, zero-padded to next power of 2.
267
+ */
268
+ async function buildPoseidonMerkleRoot(leaves: bigint[]): Promise<bigint> {
269
+ let layer = padToPowerOfTwo(leaves);
270
+
271
+ while (layer.length > 1) {
272
+ const nextLayer: bigint[] = [];
273
+ for (let i = 0; i < layer.length; i += 2) {
274
+ nextLayer.push(await poseidon2(layer[i], layer[i + 1]));
275
+ }
276
+ layer = nextLayer;
277
+ }
278
+
279
+ return layer[0];
280
+ }
281
+
282
+ /**
283
+ * Build a Merkle inclusion proof for a specific leaf index.
284
+ * Returns siblings and path indices (0 = leaf is on the left, 1 = leaf is on the right).
285
+ */
286
+ async function buildPoseidonMerkleProof(
287
+ leaves: bigint[],
288
+ index: number,
289
+ ): Promise<{ siblings: bigint[]; pathIndices: number[] }> {
290
+ const padded = padToPowerOfTwo(leaves);
291
+ const siblings: bigint[] = [];
292
+ const pathIndices: number[] = [];
293
+
294
+ let layer = padded;
295
+ let currentIndex = index;
296
+
297
+ while (layer.length > 1) {
298
+ const siblingIndex = currentIndex % 2 === 0 ? currentIndex + 1 : currentIndex - 1;
299
+ siblings.push(layer[siblingIndex]);
300
+ pathIndices.push(currentIndex % 2); // 0 = left, 1 = right
301
+
302
+ // Build next layer
303
+ const nextLayer: bigint[] = [];
304
+ for (let i = 0; i < layer.length; i += 2) {
305
+ nextLayer.push(await poseidon2(layer[i], layer[i + 1]));
306
+ }
307
+ layer = nextLayer;
308
+ currentIndex = Math.floor(currentIndex / 2);
309
+ }
310
+
311
+ return { siblings, pathIndices };
312
+ }
313
+
314
+ /**
315
+ * Verify a Merkle inclusion proof against a known root.
316
+ * Useful for verifiers who receive a proof-of-inclusion from a session participant.
317
+ *
318
+ * @param leaf - The session commitment (leaf value)
319
+ * @param siblings - Sibling hashes from the proof
320
+ * @param pathIndices - Path indices (0 = left, 1 = right)
321
+ * @param expectedRoot - The expected Merkle root (from on-chain checkpoint)
322
+ * @returns true if the proof is valid
323
+ */
324
+ export async function verifyMerkleInclusion(
325
+ leaf: bigint,
326
+ siblings: bigint[],
327
+ pathIndices: number[],
328
+ expectedRoot: bigint,
329
+ ): Promise<boolean> {
330
+ if (siblings.length !== pathIndices.length) {
331
+ return false;
332
+ }
333
+
334
+ let current = leaf;
335
+ for (let i = 0; i < siblings.length; i++) {
336
+ if (pathIndices[i] === 0) {
337
+ current = await poseidon2(current, siblings[i]);
338
+ } else {
339
+ current = await poseidon2(siblings[i], current);
340
+ }
341
+ }
342
+
343
+ return current === expectedRoot;
344
+ }