@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.
- package/LICENSE +201 -0
- package/NOTICE +63 -0
- package/README.md +2 -2
- package/dist/delegation.d.ts +64 -16
- package/dist/delegation.d.ts.map +1 -1
- package/dist/delegation.js +200 -17
- package/dist/delegation.js.map +1 -1
- package/dist/errors.d.ts +12 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +32 -1
- package/dist/errors.js.map +1 -1
- package/dist/handshake.d.ts +2 -0
- package/dist/handshake.d.ts.map +1 -1
- package/dist/handshake.js +55 -13
- package/dist/handshake.js.map +1 -1
- package/dist/identity.d.ts +24 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +46 -0
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -3
- package/dist/index.js.map +1 -1
- package/dist/model-binding.d.ts +113 -0
- package/dist/model-binding.d.ts.map +1 -0
- package/dist/model-binding.js +195 -0
- package/dist/model-binding.js.map +1 -0
- package/dist/offchain.d.ts +89 -0
- package/dist/offchain.d.ts.map +1 -0
- package/dist/offchain.js +300 -0
- package/dist/offchain.js.map +1 -0
- package/dist/prover.d.ts +21 -0
- package/dist/prover.d.ts.map +1 -0
- package/dist/prover.js +171 -0
- package/dist/prover.js.map +1 -0
- package/dist/types.d.ts +29 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +14 -0
- package/dist/utils.js.map +1 -1
- package/package.json +5 -3
- package/src/delegation.ts +268 -30
- package/src/errors.ts +46 -0
- package/src/handshake.ts +69 -20
- package/src/identity.ts +55 -1
- package/src/index.ts +29 -2
- package/src/offchain.ts +344 -0
- package/src/prover.ts +178 -0
- package/src/types.ts +32 -0
- 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
|
-
|
|
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, '
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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.
|
|
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
|
-
//
|
|
31
|
+
// Prover backend (v0.4 — rapidsnark 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';
|
package/src/offchain.ts
ADDED
|
@@ -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
|
+
}
|