@pbnjam/bandersnatch 0.7.2-rc1 → 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.
- package/.github/workflows/docs.yml +1 -3
- package/README.md +30 -2
- package/package.json +4 -4
- package/src/__tests__/curve.test.ts +316 -1
- package/src/curve.ts +44 -34
- package/src/glv.ts +212 -0
- package/src/index.ts +3 -3
|
@@ -27,13 +27,11 @@ jobs:
|
|
|
27
27
|
- name: Setup Bun
|
|
28
28
|
uses: oven-sh/setup-bun@v2
|
|
29
29
|
with:
|
|
30
|
-
bun-version:
|
|
30
|
+
bun-version: 1.3.9
|
|
31
31
|
|
|
32
32
|
- name: Install dependencies
|
|
33
33
|
run: |
|
|
34
34
|
bun install
|
|
35
|
-
# Ensure TypeDoc can find all dependencies
|
|
36
|
-
bun install --frozen-lockfile || bun install
|
|
37
35
|
|
|
38
36
|
- name: Generate documentation
|
|
39
37
|
run: bun run docs
|
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,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pbnjam/bandersnatch",
|
|
3
|
-
"version": "0.7.2-
|
|
3
|
+
"version": "0.7.2-rc3",
|
|
4
4
|
"description": "Bandersnatch elliptic curve implementation",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/Esscrypt/bandersnatch.git"
|
|
9
|
+
"url": "git+https://github.com/Esscrypt/bandersnatch.git"
|
|
10
10
|
},
|
|
11
11
|
"homepage": "https://github.com/Esscrypt/bandersnatch",
|
|
12
12
|
"bugs": {
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"@noble/hashes": "2.0.1"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@types/bun": "1.3.
|
|
26
|
+
"@types/bun": "1.3.9",
|
|
27
27
|
"@types/node": "^22.0.0",
|
|
28
|
-
"bun-types": "1.3.
|
|
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
|
|
@@ -98,16 +99,22 @@ export class BandersnatchCurve {
|
|
|
98
99
|
* 1. Extracts the y-coordinate from the first 31 bytes (little-endian)
|
|
99
100
|
* 2. Extracts the x-coordinate sign from bit 7 of the last byte
|
|
100
101
|
* 3. Computes the x-coordinate from y using the curve equation
|
|
101
|
-
* 4.
|
|
102
|
+
* 4. Optionally validates the point is in the prime subgroup G
|
|
102
103
|
*
|
|
103
104
|
* @param bytes - Compressed point bytes in arkworks format (32 bytes)
|
|
105
|
+
* @param validateSubgroup - Whether to validate point is in prime subgroup (default: true)
|
|
106
|
+
* Set to false for faster validation when only checking if bytes
|
|
107
|
+
* represent a valid curve point (e.g., for padding point replacement)
|
|
104
108
|
* @returns Decompressed Noble EdwardsPoint
|
|
105
109
|
* @throws {Error} If the byte array length is not 32
|
|
106
110
|
* @throws {Error} If the y-coordinate exceeds the field modulus
|
|
107
111
|
* @throws {Error} If the point is not on the curve (no square root exists)
|
|
108
|
-
* @throws {Error} If
|
|
112
|
+
* @throws {Error} If validatePointOnCurve is true and point is not on the curve
|
|
109
113
|
*/
|
|
110
|
-
static bytesToPoint(
|
|
114
|
+
static bytesToPoint(
|
|
115
|
+
bytes: Uint8Array,
|
|
116
|
+
validatePointOnCurve = true,
|
|
117
|
+
): EdwardsPoint {
|
|
111
118
|
if (bytes.length !== 32) {
|
|
112
119
|
throw new Error(
|
|
113
120
|
`Invalid compressed point length: ${bytes.length}, expected 32`,
|
|
@@ -167,25 +174,27 @@ export class BandersnatchCurve {
|
|
|
167
174
|
// Create Noble point from affine coordinates
|
|
168
175
|
const point = Bandersnatch.fromAffine({ x: finalX, y })
|
|
169
176
|
|
|
170
|
-
//
|
|
177
|
+
// Optionally validate point is in prime subgroup as required by bandersnatch-vrf-spec section 2.1:
|
|
171
178
|
// "This function MUST outputs 'INVALID' if the octet-string does not decode
|
|
172
179
|
// to a point on the prime subgroup G"
|
|
173
180
|
// A point is in the prime subgroup if and only if multiplying by the curve order
|
|
174
181
|
// gives the identity point (infinity)
|
|
175
182
|
// Since @noble/curves requires 1 <= scalar < curve.n, we use CURVE_ORDER - 1
|
|
176
183
|
// and then add the point once more: point * CURVE_ORDER = point * (CURVE_ORDER - 1) + point
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const pointTimesOrder = this.add(pointTimesOrderMinusOne, point)
|
|
183
|
-
const isInPrimeSubgroup = pointTimesOrder.equals(Bandersnatch.ZERO)
|
|
184
|
-
|
|
185
|
-
if (!isInPrimeSubgroup) {
|
|
186
|
-
throw new Error(
|
|
187
|
-
'Point is not in prime subgroup: decoded point is not in G',
|
|
184
|
+
if (validatePointOnCurve) {
|
|
185
|
+
const curveOrderMinusOne = BANDERSNATCH_PARAMS.CURVE_ORDER - 1n
|
|
186
|
+
const pointTimesOrderMinusOne = this.scalarMultiply(
|
|
187
|
+
point,
|
|
188
|
+
curveOrderMinusOne,
|
|
188
189
|
)
|
|
190
|
+
const pointTimesOrder = this.add(pointTimesOrderMinusOne, point)
|
|
191
|
+
const isInPrimeSubgroup = pointTimesOrder.equals(Bandersnatch.ZERO)
|
|
192
|
+
|
|
193
|
+
if (!isInPrimeSubgroup) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
'Point is not in prime subgroup: decoded point is not in G',
|
|
196
|
+
)
|
|
197
|
+
}
|
|
189
198
|
}
|
|
190
199
|
|
|
191
200
|
return point
|
|
@@ -199,37 +208,38 @@ export class BandersnatchCurve {
|
|
|
199
208
|
* - Negative scalars: negates the point and uses positive scalar
|
|
200
209
|
* - Scalars >= curve order: reduces modulo curve order
|
|
201
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
|
+
*
|
|
202
215
|
* @param point - The curve point to multiply
|
|
203
216
|
* @param scalar - The scalar multiplier (can be negative or >= curve order)
|
|
217
|
+
* @param useGlv - Whether to use the GLV endomorphism optimization (default: true)
|
|
204
218
|
* @returns The result of scalar multiplication: `scalar * point`
|
|
205
219
|
* @throws {Error} If the point is invalid or not on the curve
|
|
206
220
|
*/
|
|
207
|
-
static scalarMultiply(
|
|
208
|
-
|
|
221
|
+
static scalarMultiply(
|
|
222
|
+
point: EdwardsPoint,
|
|
223
|
+
scalar: bigint,
|
|
224
|
+
useGlv = true,
|
|
225
|
+
): EdwardsPoint {
|
|
226
|
+
if (useGlv) {
|
|
227
|
+
return glvMultiply(point, scalar)
|
|
228
|
+
}
|
|
229
|
+
|
|
209
230
|
if (scalar === 0n) {
|
|
210
231
|
return Bandersnatch.ZERO
|
|
211
232
|
}
|
|
212
233
|
|
|
213
|
-
// Handle negative scalars: negate point and use positive scalar
|
|
214
234
|
if (scalar < 0n) {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const reducedScalar = positiveScalar % BANDERSNATCH_PARAMS.CURVE_ORDER
|
|
219
|
-
if (reducedScalar === 0n) {
|
|
220
|
-
return Bandersnatch.ZERO
|
|
221
|
-
}
|
|
222
|
-
return negPoint.multiply(reducedScalar)
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Reduce scalar modulo curve order if it's >= curve order
|
|
226
|
-
// @noble/curves requires 1 <= scalar < curve.n
|
|
227
|
-
const reducedScalar = scalar % BANDERSNATCH_PARAMS.CURVE_ORDER
|
|
228
|
-
if (reducedScalar === 0n) {
|
|
229
|
-
return Bandersnatch.ZERO
|
|
235
|
+
const positiveScalar = (-scalar) % BANDERSNATCH_PARAMS.CURVE_ORDER
|
|
236
|
+
if (positiveScalar === 0n) return Bandersnatch.ZERO
|
|
237
|
+
return point.negate().multiply(positiveScalar)
|
|
230
238
|
}
|
|
231
239
|
|
|
232
|
-
|
|
240
|
+
const reduced = scalar % BANDERSNATCH_PARAMS.CURVE_ORDER
|
|
241
|
+
if (reduced === 0n) return Bandersnatch.ZERO
|
|
242
|
+
return point.multiply(reduced)
|
|
233
243
|
}
|
|
234
244
|
|
|
235
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
|
-
//
|
|
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'
|