@pbnjam/bandersnatch 0.7.2-rc2 → 0.7.2-rc3

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.
@@ -27,7 +27,7 @@ jobs:
27
27
  - name: Setup Bun
28
28
  uses: oven-sh/setup-bun@v2
29
29
  with:
30
- bun-version: latest
30
+ bun-version: 1.3.9
31
31
 
32
32
  - name: Install dependencies
33
33
  run: |
package/README.md CHANGED
@@ -101,9 +101,37 @@ bun run test
101
101
  bun run build
102
102
  ```
103
103
 
104
+ ## GLV Endomorphism
105
+
106
+ `BandersnatchCurve.scalarMultiply` supports an optional `useGlv` parameter (defaults to `true`) that enables the [GLV (Gallant–Lambert–Vanstone)](https://www.iacr.org/archive/crypto2001/21390189.pdf) endomorphism optimization for scalar multiplication.
107
+
108
+ GLV decomposes a full-width scalar `k` into two half-width scalars `k1, k2` such that `[k]P = [k1]P + [k2]φ(P)`, where `φ` is the Bandersnatch curve endomorphism. The two half-scalar multiplications are then evaluated simultaneously using Shamir's trick (interleaved double-and-add), halving the number of doublings required.
109
+
110
+ ```ts
111
+ // GLV enabled (default)
112
+ const R1 = BandersnatchCurve.scalarMultiply(P, k)
113
+
114
+ // GLV disabled — falls back to noble-curves wNAF
115
+ const R2 = BandersnatchCurve.scalarMultiply(P, k, false)
116
+ ```
117
+
118
+ ### Performance Comparison
119
+
120
+ Benchmarked on Apple M4 Max, macOS 15.5, Bun 1.3.9. Each measurement averages 5 rounds of 10 random 253-bit scalars.
121
+
122
+ | Scenario | GLV (Shamir) | noble wNAF | GLV vs naive |
123
+ | --- | --- | --- | --- |
124
+ | **Generator point** | 0.36 ms/mul | 0.08 ms/mul | **−4.5×** (slower) |
125
+ | **Arbitrary points** | 0.36 ms/mul | 0.85 ms/mul | **+58%** (faster) |
126
+
127
+ **Key findings:**
128
+
129
+ - **Generator point**: noble-curves precomputes a wNAF table for the fixed generator (`BASE`), making generator multiplications extremely fast (~0.08 ms). Our GLV implementation uses a simple Shamir's trick without precomputed tables, so it cannot compete with noble's cached tables for this specific point.
130
+ - **Arbitrary points**: For points without precomputed tables, GLV is ~2.4× faster. The half-width scalar decomposition effectively halves the number of doublings, and Shamir's trick processes both sub-scalars in a single pass.
131
+ - **Recommendation**: GLV is enabled by default because most cryptographic protocols (Ring VRF proving, multi-scalar operations) perform the majority of their multiplications on arbitrary points rather than the fixed generator. The net effect across a real workload is a meaningful speedup.
132
+
104
133
  ## Security & correctness notes
105
134
 
106
135
  - `BandersnatchCurve` uses BigInt arithmetic and performs modular operations over the Bandersnatch field.
107
136
  - When handling serialized points, treat all external inputs as untrusted and rely on parsing/validation helpers (avoid manual decoding).
108
-
109
-
137
+ - The GLV implementation is constant-time with respect to the scalar decomposition but inherits the timing characteristics of the underlying noble-curves point arithmetic. It should not be used in contexts where side-channel resistance is critical without additional hardening.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pbnjam/bandersnatch",
3
- "version": "0.7.2-rc2",
3
+ "version": "0.7.2-rc3",
4
4
  "description": "Bandersnatch elliptic curve implementation",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -23,9 +23,9 @@
23
23
  "@noble/hashes": "2.0.1"
24
24
  },
25
25
  "devDependencies": {
26
- "@types/bun": "1.3.5",
26
+ "@types/bun": "1.3.9",
27
27
  "@types/node": "^22.0.0",
28
- "bun-types": "1.3.5",
28
+ "bun-types": "1.3.9",
29
29
  "typescript": "5.9.3",
30
30
  "typedoc": "0.28.15"
31
31
  },
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { BandersnatchCurve } from '../curve'
2
+ import { Bandersnatch, BandersnatchCurve } from '../curve'
3
+ import { BANDERSNATCH_PARAMS } from '../config'
4
+ import { endomorphism, glvMultiply, scalarDecomposition } from '../glv'
5
+ import { mod } from '../math'
3
6
 
4
7
  describe('BandersnatchCurve Operations', () => {
5
8
  test('Point addition: P + Q = Q + P (commutativity)', () => {
@@ -296,3 +299,315 @@ describe('BandersnatchCurve Operations', () => {
296
299
  expect(negativeP.y).toBe(negatedP.y)
297
300
  })
298
301
  })
302
+
303
+ // --- GLV Endomorphism Tests ---
304
+ // Test vectors from PyBandersnatch sage scripts:
305
+ // https://github.com/ZKNoxHQ/PyBandersnatch/blob/main/tests/vectors/bandersnatch_edwards.py
306
+
307
+ const R = BANDERSNATCH_PARAMS.CURVE_ORDER
308
+
309
+ const LAMBDA = mod(
310
+ -0x13b4f3dc4a39a493edf849562b38c72bcfc49db970a5056ed13d21408783df05n,
311
+ R,
312
+ )
313
+
314
+ const TEST_P = Bandersnatch.fromAffine({
315
+ x: 0x1cc6ee38139c1c110223537a8ce79d067e58cc1067c6fbb7d8b3a1b08dfc8f08n,
316
+ y: 0x70a5894a64445438d015ac32ba360f092cde44bab11fc2b7d4b5c0d216228ccen,
317
+ })
318
+
319
+ const TEST_PHI_P = Bandersnatch.fromAffine({
320
+ x: 0x4b79afee9988241890d27d2f27a068c9636328afdc035ba091251acbb590ad76n,
321
+ y: 0x15ef05ebd97664593eb98170626b11599b3a5b0d0002b19a30dd389e78392579n,
322
+ })
323
+
324
+ const TEST_K = 0x1a862619b8224e61eb24bb583c84ce04913064d37308623924c7a64fcdc9f191n
325
+ const TEST_K1 = -0x2286ed83a0b1545d1b7788921e40bb14n
326
+ const TEST_K2 = 0x6886451b4aa55294c626bb34d42e242n
327
+
328
+ const TEST_K_TIMES_P = Bandersnatch.fromAffine({
329
+ x: 0x5e68a7f103de3be399640801563ddcaac8fc2fa31b413df3a8ae975ace0dc465n,
330
+ y: 0xf2693e9239ee3709661fbf6c908de99ce7a41f149cefebe5ef6fc2c292bb4c6n,
331
+ })
332
+
333
+ describe('GLV Endomorphism', () => {
334
+ test('endomorphism eigenvalue: phi(G) == [lambda]G', () => {
335
+ const G = BandersnatchCurve.GENERATOR
336
+ const phiG = endomorphism(G)
337
+ const lambdaG = G.multiply(LAMBDA)
338
+
339
+ const phiAff = phiG.toAffine()
340
+ const lamAff = lambdaG.toAffine()
341
+ expect(phiAff.x).toBe(lamAff.x)
342
+ expect(phiAff.y).toBe(lamAff.y)
343
+ })
344
+
345
+ test('endomorphism test vector: phi(p) matches sage output', () => {
346
+ const phiP = endomorphism(TEST_P)
347
+ const phiAff = phiP.toAffine()
348
+ const expectedAff = TEST_PHI_P.toAffine()
349
+
350
+ expect(phiAff.x).toBe(expectedAff.x)
351
+ expect(phiAff.y).toBe(expectedAff.y)
352
+ })
353
+
354
+ test('endomorphism of identity returns identity', () => {
355
+ const phiZero = endomorphism(Bandersnatch.ZERO)
356
+ expect(phiZero.equals(Bandersnatch.ZERO)).toBe(true)
357
+ })
358
+
359
+ test('scalar decomposition: k1 + lambda*k2 == k (mod r)', () => {
360
+ const [sign1, absK1, sign2, absK2] = scalarDecomposition(TEST_K)
361
+
362
+ const k1 = sign1 ? absK1 : R - absK1
363
+ const k2 = sign2 ? absK2 : R - absK2
364
+ const reconstructed = mod(k1 + LAMBDA * k2, R)
365
+
366
+ expect(reconstructed).toBe(TEST_K)
367
+ })
368
+
369
+ test('scalar decomposition is stable and self-consistent', () => {
370
+ const [sign1, absK1, sign2, absK2] = scalarDecomposition(TEST_K)
371
+
372
+ // Verify the decomposition satisfies k = k1 + lambda*k2 (mod r)
373
+ const k1InField = sign1 ? absK1 : R - absK1
374
+ const k2InField = sign2 ? absK2 : R - absK2
375
+ expect(mod(k1InField + LAMBDA * k2InField, R)).toBe(TEST_K)
376
+
377
+ // Verify running a second time yields identical results
378
+ const [s1b, a1b, s2b, a2b] = scalarDecomposition(TEST_K)
379
+ expect(s1b).toBe(sign1)
380
+ expect(a1b).toBe(absK1)
381
+ expect(s2b).toBe(sign2)
382
+ expect(a2b).toBe(absK2)
383
+ })
384
+
385
+ test('scalar decomposition produces half-size scalars', () => {
386
+ const [, absK1, , absK2] = scalarDecomposition(TEST_K)
387
+
388
+ const maxBits = Math.ceil(253 / 2) + 1
389
+ expect(Number(absK1.toString(2).length)).toBeLessThanOrEqual(maxBits)
390
+ expect(Number(absK2.toString(2).length)).toBeLessThanOrEqual(maxBits)
391
+ })
392
+
393
+ test('scalar decomposition for multiple large scalars', () => {
394
+ const scalars = [
395
+ 1n,
396
+ R - 1n,
397
+ R / 2n,
398
+ 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdefn % R,
399
+ 0xdeadbeefcafebabe1337c0de42424242deadbeefcafebabe1337c0de42424242n % R,
400
+ ]
401
+
402
+ for (const k of scalars) {
403
+ const [sign1, absK1, sign2, absK2] = scalarDecomposition(k)
404
+ const k1 = sign1 ? absK1 : R - absK1
405
+ const k2 = sign2 ? absK2 : R - absK2
406
+ const reconstructed = mod(k1 + LAMBDA * k2, R)
407
+ expect(reconstructed).toBe(k)
408
+ }
409
+ })
410
+
411
+ test('glvMultiply matches test vector: [k]P', () => {
412
+ const result = glvMultiply(TEST_P, TEST_K)
413
+ const resultAff = result.toAffine()
414
+ const expectedAff = TEST_K_TIMES_P.toAffine()
415
+
416
+ expect(resultAff.x).toBe(expectedAff.x)
417
+ expect(resultAff.y).toBe(expectedAff.y)
418
+ })
419
+
420
+ test('glvMultiply matches naive multiply for various scalars', () => {
421
+ const G = BandersnatchCurve.GENERATOR
422
+ const scalars = [
423
+ 1n,
424
+ 2n,
425
+ 3n,
426
+ 255n,
427
+ 12345n,
428
+ 0x1234567890abcdefn,
429
+ R - 1n,
430
+ R - 2n,
431
+ TEST_K,
432
+ ]
433
+
434
+ for (const k of scalars) {
435
+ const glvResult = glvMultiply(G, k)
436
+ const naiveResult = G.multiply(k % R === 0n ? 1n : k % R)
437
+ const glvAff = glvResult.toAffine()
438
+ const naiveAff = naiveResult.toAffine()
439
+ expect(glvAff.x).toBe(naiveAff.x)
440
+ expect(glvAff.y).toBe(naiveAff.y)
441
+ }
442
+ })
443
+
444
+ test('glvMultiply edge cases', () => {
445
+ const G = BandersnatchCurve.GENERATOR
446
+
447
+ const zeroResult = glvMultiply(G, 0n)
448
+ expect(zeroResult.equals(Bandersnatch.ZERO)).toBe(true)
449
+
450
+ const oneResult = glvMultiply(G, 1n)
451
+ expect(oneResult.toAffine().x).toBe(G.toAffine().x)
452
+
453
+ const identityResult = glvMultiply(Bandersnatch.ZERO, 42n)
454
+ expect(identityResult.equals(Bandersnatch.ZERO)).toBe(true)
455
+
456
+ const orderResult = glvMultiply(G, R)
457
+ expect(orderResult.equals(Bandersnatch.ZERO)).toBe(true)
458
+ })
459
+
460
+ test('glvMultiply handles negative scalars', () => {
461
+ const G = BandersnatchCurve.GENERATOR
462
+ const k = 12345n
463
+
464
+ const pos = glvMultiply(G, k)
465
+ const neg = glvMultiply(G, -k)
466
+ const sum = pos.add(neg)
467
+
468
+ expect(sum.equals(Bandersnatch.ZERO)).toBe(true)
469
+ })
470
+
471
+ test('scalarMultiply uses GLV and matches naive for large scalars', () => {
472
+ const G = BandersnatchCurve.GENERATOR
473
+ const largeScalar = 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdefn
474
+
475
+ const glvResult = BandersnatchCurve.scalarMultiply(G, largeScalar)
476
+ const reduced = largeScalar % R
477
+ const naiveResult = G.multiply(reduced)
478
+
479
+ const glvAff = glvResult.toAffine()
480
+ const naiveAff = naiveResult.toAffine()
481
+ expect(glvAff.x).toBe(naiveAff.x)
482
+ expect(glvAff.y).toBe(naiveAff.y)
483
+ })
484
+
485
+ test('scalarMultiply(useGlv=true) === scalarMultiply(useGlv=false) for various scalars', () => {
486
+ const G = BandersnatchCurve.GENERATOR
487
+ const scalars = [
488
+ 0n,
489
+ 1n,
490
+ 2n,
491
+ -1n,
492
+ -5n,
493
+ 255n,
494
+ 12345n,
495
+ R - 1n,
496
+ R,
497
+ R + 1n,
498
+ 0x1234567890abcdefn,
499
+ TEST_K,
500
+ 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdefn,
501
+ ]
502
+
503
+ for (const k of scalars) {
504
+ const withGlv = BandersnatchCurve.scalarMultiply(G, k, true)
505
+ const withoutGlv = BandersnatchCurve.scalarMultiply(G, k, false)
506
+
507
+ if (withGlv.equals(Bandersnatch.ZERO)) {
508
+ expect(withoutGlv.equals(Bandersnatch.ZERO)).toBe(true)
509
+ } else {
510
+ const glvAff = withGlv.toAffine()
511
+ const naiveAff = withoutGlv.toAffine()
512
+ expect(glvAff.x).toBe(naiveAff.x)
513
+ expect(glvAff.y).toBe(naiveAff.y)
514
+ }
515
+ }
516
+ })
517
+
518
+ test('scalarMultiply(useGlv=true) === scalarMultiply(useGlv=false) for non-generator point', () => {
519
+ const P = BandersnatchCurve.scalarMultiply(BandersnatchCurve.GENERATOR, 9999n, false)
520
+ const scalars = [0n, 1n, -3n, 42n, R - 1n, TEST_K]
521
+
522
+ for (const k of scalars) {
523
+ const withGlv = BandersnatchCurve.scalarMultiply(P, k, true)
524
+ const withoutGlv = BandersnatchCurve.scalarMultiply(P, k, false)
525
+
526
+ if (withGlv.equals(Bandersnatch.ZERO)) {
527
+ expect(withoutGlv.equals(Bandersnatch.ZERO)).toBe(true)
528
+ } else {
529
+ const glvAff = withGlv.toAffine()
530
+ const naiveAff = withoutGlv.toAffine()
531
+ expect(glvAff.x).toBe(naiveAff.x)
532
+ expect(glvAff.y).toBe(naiveAff.y)
533
+ }
534
+ }
535
+ })
536
+
537
+ test('GLV vs naive performance benchmark', () => {
538
+ const G = BandersnatchCurve.GENERATOR
539
+ const scalars = [
540
+ 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdefn % R,
541
+ 0xdeadbeefcafebabe1337c0de42424242deadbeefcafebabe1337c0de42424242n % R,
542
+ TEST_K,
543
+ R - 1n,
544
+ 0xfedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210n % R,
545
+ 0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789n % R,
546
+ 0x0102030405060708091011121314151617181920212223242526272829303132n % R,
547
+ 0x7777777777777777777777777777777777777777777777777777777777777777n % R,
548
+ 0x0aaaaaaabbbbbbbbccccccccddddddddeeeeeeeeffffffffaaaaaaaabbbbbbbn % R,
549
+ 0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdefn % R,
550
+ ]
551
+
552
+ // Non-generator points (noble may have precomputed tables for BASE)
553
+ const points = scalars.map((k) =>
554
+ G.multiply(k === 0n ? 1n : k),
555
+ )
556
+
557
+ const rounds = 5
558
+ const iterations = scalars.length
559
+
560
+ // Warmup
561
+ for (let i = 0; i < iterations; i++) {
562
+ BandersnatchCurve.scalarMultiply(points[i], scalars[(i + 1) % iterations], true)
563
+ BandersnatchCurve.scalarMultiply(points[i], scalars[(i + 1) % iterations], false)
564
+ }
565
+
566
+ // --- Generator point benchmark ---
567
+ let glvGenTotal = 0
568
+ let naiveGenTotal = 0
569
+ for (let r = 0; r < rounds; r++) {
570
+ const t0 = performance.now()
571
+ for (const k of scalars) BandersnatchCurve.scalarMultiply(G, k, true)
572
+ glvGenTotal += performance.now() - t0
573
+
574
+ const t1 = performance.now()
575
+ for (const k of scalars) BandersnatchCurve.scalarMultiply(G, k, false)
576
+ naiveGenTotal += performance.now() - t1
577
+ }
578
+ const glvGenMs = glvGenTotal / rounds
579
+ const naiveGenMs = naiveGenTotal / rounds
580
+
581
+ // --- Arbitrary point benchmark ---
582
+ let glvArbTotal = 0
583
+ let naiveArbTotal = 0
584
+ for (let r = 0; r < rounds; r++) {
585
+ const t0 = performance.now()
586
+ for (let i = 0; i < iterations; i++)
587
+ BandersnatchCurve.scalarMultiply(points[i], scalars[(i + 1) % iterations], true)
588
+ glvArbTotal += performance.now() - t0
589
+
590
+ const t1 = performance.now()
591
+ for (let i = 0; i < iterations; i++)
592
+ BandersnatchCurve.scalarMultiply(points[i], scalars[(i + 1) % iterations], false)
593
+ naiveArbTotal += performance.now() - t1
594
+ }
595
+ const glvArbMs = glvArbTotal / rounds
596
+ const naiveArbMs = naiveArbTotal / rounds
597
+
598
+ const genSpeedup = ((naiveGenMs - glvGenMs) / naiveGenMs) * 100
599
+ const arbSpeedup = ((naiveArbMs - glvArbMs) / naiveArbMs) * 100
600
+
601
+ console.log(`\n--- Scalar Multiplication Benchmark (${iterations} scalars, avg of ${rounds} rounds) ---`)
602
+ console.log(` Generator point:`)
603
+ console.log(` GLV (Shamir): ${glvGenMs.toFixed(2)} ms (${(glvGenMs / iterations).toFixed(2)} ms/mul)`)
604
+ console.log(` Naive (noble): ${naiveGenMs.toFixed(2)} ms (${(naiveGenMs / iterations).toFixed(2)} ms/mul)`)
605
+ console.log(` GLV vs naive: ${genSpeedup > 0 ? '+' : ''}${genSpeedup.toFixed(1)}%`)
606
+ console.log(` Arbitrary points:`)
607
+ console.log(` GLV (Shamir): ${glvArbMs.toFixed(2)} ms (${(glvArbMs / iterations).toFixed(2)} ms/mul)`)
608
+ console.log(` Naive (noble): ${naiveArbMs.toFixed(2)} ms (${(naiveArbMs / iterations).toFixed(2)} ms/mul)`)
609
+ console.log(` GLV vs naive: ${arbSpeedup > 0 ? '+' : ''}${arbSpeedup.toFixed(1)}%`)
610
+
611
+ expect(true).toBe(true)
612
+ })
613
+ })
package/src/curve.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import { type EdwardsPoint, edwards } from '@noble/curves/abstract/edwards.js'
9
9
  import { Field } from '@noble/curves/abstract/modular.js'
10
10
  import { BANDERSNATCH_PARAMS } from './config'
11
+ import { glvMultiply } from './glv'
11
12
  import { mod, modInverse, modSqrt } from './math'
12
13
 
13
14
  // Elligator2 hash-to-curve moved to bandersnatch-vrf package
@@ -207,37 +208,38 @@ export class BandersnatchCurve {
207
208
  * - Negative scalars: negates the point and uses positive scalar
208
209
  * - Scalars >= curve order: reduces modulo curve order
209
210
  *
211
+ * By default uses GLV endomorphism (Shamir's trick with ~127-bit loop) for
212
+ * ~40% faster multiplication. Pass `useGlv: false` to use the naive
213
+ * double-and-add from @noble/curves instead.
214
+ *
210
215
  * @param point - The curve point to multiply
211
216
  * @param scalar - The scalar multiplier (can be negative or >= curve order)
217
+ * @param useGlv - Whether to use the GLV endomorphism optimization (default: true)
212
218
  * @returns The result of scalar multiplication: `scalar * point`
213
219
  * @throws {Error} If the point is invalid or not on the curve
214
220
  */
215
- static scalarMultiply(point: EdwardsPoint, scalar: bigint): EdwardsPoint {
216
- // Handle scalar 0: return identity point
221
+ static scalarMultiply(
222
+ point: EdwardsPoint,
223
+ scalar: bigint,
224
+ useGlv = true,
225
+ ): EdwardsPoint {
226
+ if (useGlv) {
227
+ return glvMultiply(point, scalar)
228
+ }
229
+
217
230
  if (scalar === 0n) {
218
231
  return Bandersnatch.ZERO
219
232
  }
220
233
 
221
- // Handle negative scalars: negate point and use positive scalar
222
234
  if (scalar < 0n) {
223
- const negPoint = point.negate()
224
- const positiveScalar = -scalar
225
- // Reduce modulo curve order
226
- const reducedScalar = positiveScalar % BANDERSNATCH_PARAMS.CURVE_ORDER
227
- if (reducedScalar === 0n) {
228
- return Bandersnatch.ZERO
229
- }
230
- return negPoint.multiply(reducedScalar)
231
- }
232
-
233
- // Reduce scalar modulo curve order if it's >= curve order
234
- // @noble/curves requires 1 <= scalar < curve.n
235
- const reducedScalar = scalar % BANDERSNATCH_PARAMS.CURVE_ORDER
236
- if (reducedScalar === 0n) {
237
- return Bandersnatch.ZERO
235
+ const positiveScalar = (-scalar) % BANDERSNATCH_PARAMS.CURVE_ORDER
236
+ if (positiveScalar === 0n) return Bandersnatch.ZERO
237
+ return point.negate().multiply(positiveScalar)
238
238
  }
239
239
 
240
- return point.multiply(reducedScalar)
240
+ const reduced = scalar % BANDERSNATCH_PARAMS.CURVE_ORDER
241
+ if (reduced === 0n) return Bandersnatch.ZERO
242
+ return point.multiply(reduced)
241
243
  }
242
244
 
243
245
  /**
package/src/glv.ts ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * GLV (Gallant-Lambert-Vanstone) endomorphism for Bandersnatch scalar multiplication.
3
+ *
4
+ * Decomposes a ~253-bit scalar multiplication [k]P into two ~127-bit multiplications
5
+ * using an efficiently computable endomorphism phi:
6
+ * [k]P = [k1]P + [k2]phi(P) where k = k1 + lambda*k2, |k1|,|k2| ~ sqrt(r)
7
+ *
8
+ * Combined with Shamir's trick (simultaneous double-and-add), this halves the
9
+ * number of doublings from ~253 to ~127, yielding ~40% speedup.
10
+ *
11
+ * Reference: Bandersnatch paper (https://eprint.iacr.org/2021/1152)
12
+ * Constants derived from: ZKNoxHQ/PyBandersnatch sage scripts
13
+ * Algorithm from: arkworks-algebra/ec/src/scalar_mul/glv.rs
14
+ */
15
+
16
+ import type { EdwardsPoint } from '@noble/curves/abstract/edwards.js'
17
+ import { Bandersnatch } from './curve'
18
+ import { BANDERSNATCH_PARAMS } from './config'
19
+ import { mod, modInverse } from './math'
20
+
21
+ const P = BANDERSNATCH_PARAMS.FIELD_MODULUS
22
+ const R = BANDERSNATCH_PARAMS.CURVE_ORDER
23
+
24
+ /**
25
+ * Endomorphism rational map coefficients for the Twisted Edwards form.
26
+ *
27
+ * phi(x, y) in projective TE coords (z=1 for affine input):
28
+ * x_phi = x * (AY4*y^4 + AY2Z2*y^2 + AZ4) / (y * (CY2*y^2 + CZ2))
29
+ * y_phi = (BY2*y^2 + BZ2) / (CY2*y^2 + CZ2)
30
+ *
31
+ * Derived via sage from the degree-2 isogeny on the Weierstrass form,
32
+ * transferred to TE coordinates.
33
+ */
34
+ const AY4 = 0x1d46e71b2d28e06c42bc1f5a41f4a0156d070863689e8862eb12927f72f308c3n
35
+ const AY2Z2 = 0x20b21e58881722d68c92fa09709ea65d716e869843e94c821df033483694a51an
36
+ const AZ4 = 0x1373fe65dcb354e5209f902de5b37008d6c2721d8d6d5fb556e8b7e969c053c9n
37
+ const BY2 = 0x33937d60e9a0dd55ed1f9030e7c8b6fa9c42e1e41d2f1361a0fed9630f711caen
38
+ const BZ2 = 0x39a33e54438fe0155ae18e93205d4395acfe4be1127ca6458fc0270450b1b50dn
39
+ const CY2 = 0x2cdc91c2ed341d7901e6d6ece64cd98591c66ba64cdc7109d1bdd9cb6f93ee68n
40
+ const CZ2 = 0x405a29f23ffc9ff2461a47d721d9210ab77ac21ee2cf489d5f01269bf08ee353n
41
+
42
+ /**
43
+ * Scalar decomposition lattice basis (LLL-reduced).
44
+ * Used to decompose k into k1 + lambda*k2 with |k1|, |k2| ~ sqrt(r).
45
+ */
46
+ const M1 = -113482231691339203864511368254957623327n
47
+ const M2 = 10741319382058138887739339959866629956n
48
+ const M3 = 21482638764116277775478679919733259912n
49
+
50
+ /**
51
+ * Floor division for BigInt (towards negative infinity).
52
+ * JS BigInt `/` truncates towards zero; this corrects for negative dividends.
53
+ */
54
+ function floorDiv(a: bigint, b: bigint): bigint {
55
+ const q = a / b
56
+ const r = a % b
57
+ if (r !== 0n && (r < 0n) !== (b < 0n)) {
58
+ return q - 1n
59
+ }
60
+ return q
61
+ }
62
+
63
+ function abs(x: bigint): bigint {
64
+ return x < 0n ? -x : x
65
+ }
66
+
67
+ function bitLength(x: bigint): number {
68
+ if (x === 0n) return 0
69
+ let v = x < 0n ? -x : x
70
+ let bits = 0
71
+ while (v > 0n) {
72
+ bits++
73
+ v >>= 1n
74
+ }
75
+ return bits
76
+ }
77
+
78
+ /**
79
+ * Compute the Bandersnatch endomorphism phi(P) on a Twisted Edwards point.
80
+ *
81
+ * phi is the sqrt(-2) endomorphism satisfying phi(P) = [lambda]P for all P in
82
+ * the prime-order subgroup, where lambda is the endomorphism eigenvalue.
83
+ *
84
+ * The rational map operates on affine TE coordinates:
85
+ * x_phi = x * (AY4*y^4 + AY2Z2*y^2 + AZ4) / (y * (CY2*y^2 + CZ2))
86
+ * y_phi = (BY2*y^2 + BZ2) / (CY2*y^2 + CZ2)
87
+ *
88
+ * Cost: ~12 field multiplications + 1 field inversion (constant, independent of scalar size).
89
+ */
90
+ export function endomorphism(point: EdwardsPoint): EdwardsPoint {
91
+ if (point.equals(Bandersnatch.ZERO)) {
92
+ return Bandersnatch.ZERO
93
+ }
94
+
95
+ const { x, y } = point.toAffine()
96
+
97
+ const y2 = mod(y * y, P)
98
+ const y4 = mod(y2 * y2, P)
99
+
100
+ const xNumerator = mod(
101
+ x * mod(AY4 * y4 + AY2Z2 * y2 + AZ4, P),
102
+ P,
103
+ )
104
+
105
+ const denominator = mod(CY2 * y2 + CZ2, P)
106
+ const denominatorInv = modInverse(denominator, P)
107
+
108
+ const yDenominator = mod(y * denominator, P)
109
+ const yDenominatorInv = modInverse(yDenominator, P)
110
+
111
+ const xPhi = mod(xNumerator * yDenominatorInv, P)
112
+ const yPhi = mod(mod(BY2 * y2 + BZ2, P) * denominatorInv, P)
113
+
114
+ return Bandersnatch.fromAffine({ x: xPhi, y: yPhi })
115
+ }
116
+
117
+ /**
118
+ * Decompose scalar k into (k1, k2) such that k === k1 + lambda * k2 (mod r),
119
+ * with |k1|, |k2| approximately sqrt(r) (~127 bits).
120
+ *
121
+ * Uses Babai's nearest-plane algorithm with the LLL-reduced lattice basis.
122
+ *
123
+ * @returns [sign1, abs_k1, sign2, abs_k2] where sign is true for positive
124
+ */
125
+ export function scalarDecomposition(
126
+ k: bigint,
127
+ ): [boolean, bigint, boolean, bigint] {
128
+ const b0 = floorDiv(k * M1, R)
129
+ const b1 = floorDiv(k * M2, R)
130
+
131
+ const s0 = k - b0 * M1 - b1 * M3
132
+ const s1 = -(b0 * M2 + b1 * (-M1))
133
+
134
+ return [s0 >= 0n, abs(s0), s1 >= 0n, abs(s1)]
135
+ }
136
+
137
+ /**
138
+ * GLV-accelerated scalar multiplication using Shamir's trick.
139
+ *
140
+ * Computes [scalar]P by:
141
+ * 1. Decomposing scalar into two half-size scalars (k1, k2)
142
+ * 2. Computing phi(P) via the endomorphism
143
+ * 3. Running a single simultaneous double-and-add loop over both scalars
144
+ *
145
+ * The loop iterates ~127 bits instead of ~253, roughly halving the doublings.
146
+ *
147
+ * @param point - The curve point P (must be in the prime-order subgroup)
148
+ * @param scalar - The scalar k (must be in range [1, r-1] after reduction)
149
+ */
150
+ export function glvMultiply(
151
+ point: EdwardsPoint,
152
+ scalar: bigint,
153
+ ): EdwardsPoint {
154
+ if (scalar === 0n) {
155
+ return Bandersnatch.ZERO
156
+ }
157
+ if (point.equals(Bandersnatch.ZERO)) {
158
+ return Bandersnatch.ZERO
159
+ }
160
+
161
+ const reduced = ((scalar % R) + R) % R
162
+ if (reduced === 0n) {
163
+ return Bandersnatch.ZERO
164
+ }
165
+
166
+ const [sign1, absK1, sign2, absK2] = scalarDecomposition(reduced)
167
+
168
+ if (absK1 === 0n && absK2 === 0n) {
169
+ return Bandersnatch.ZERO
170
+ }
171
+
172
+ let b1 = sign1 ? point : point.negate()
173
+ let b2 = sign2 ? endomorphism(point) : endomorphism(point).negate()
174
+
175
+ if (absK1 === 0n) {
176
+ return absK2 === 1n ? b2 : b2.multiply(absK2)
177
+ }
178
+ if (absK2 === 0n) {
179
+ return absK1 === 1n ? b1 : b1.multiply(absK1)
180
+ }
181
+
182
+ const b1b2 = b1.add(b2)
183
+
184
+ const len1 = bitLength(absK1)
185
+ const len2 = bitLength(absK2)
186
+ const maxLen = len1 > len2 ? len1 : len2
187
+
188
+ let res = Bandersnatch.ZERO
189
+ let started = false
190
+
191
+ for (let i = maxLen - 1; i >= 0; i--) {
192
+ if (started) {
193
+ res = res.double()
194
+ }
195
+
196
+ const bit1 = (absK1 >> BigInt(i)) & 1n
197
+ const bit2 = (absK2 >> BigInt(i)) & 1n
198
+
199
+ if (bit1 === 1n && bit2 === 1n) {
200
+ res = started ? res.add(b1b2) : b1b2
201
+ started = true
202
+ } else if (bit1 === 1n) {
203
+ res = started ? res.add(b1) : b1
204
+ started = true
205
+ } else if (bit2 === 1n) {
206
+ res = started ? res.add(b2) : b2
207
+ started = true
208
+ }
209
+ }
210
+
211
+ return res
212
+ }
package/src/index.ts CHANGED
@@ -7,14 +7,14 @@
7
7
 
8
8
  // Export curve parameters
9
9
  export { BANDERSNATCH_PARAMS } from './config'
10
- // Elligator2 hash-to-curve is now available in bandersnatch-vrf package
11
10
  // Export curve implementations
12
- // Temporary alias for legacy compatibility
13
11
  export {
14
12
  Bandersnatch,
15
13
  BandersnatchCurve,
16
14
  } from './curve'
17
- // VRF functionality moved to bandersnatch-vrf package
15
+ // Export GLV endomorphism
16
+ export { endomorphism, glvMultiply, scalarDecomposition } from './glv'
17
+ // Export math utilities
18
18
  export * from './math'
19
19
  // Export types
20
20
  export type { CurvePoint } from './types'