feat: Dynamically load poseidon-lite to reduce bundle size (~421KB)#850
feat: Dynamically load poseidon-lite to reduce bundle size (~421KB)#850gregnazario wants to merge 5 commits intomainfrom
Conversation
9dd697b to
5ecb37d
Compare
6ebc13f to
29a313b
Compare
There was a problem hiding this comment.
Pull request overview
This PR implements dynamic loading of poseidon-lite so bundlers can code-split Poseidon hashing out of the main SDK bundle, reducing initial bundle size for applications that don’t use Keyless features.
Changes:
- Converted Poseidon hashing utilities to async-by-default with dynamic
import("poseidon-lite"), and added sync variants for already-preloaded scenarios. - Updated Keyless/Federated Keyless crypto flows and account utilities to use the new async APIs (and sync variants in constructors).
- Updated tests, e2e flows, examples, and the changelog to reflect the new async behavior and preload requirements.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| vitest.config.ts | Adds a Poseidon preload setup file to stabilize tests under the new dynamic import. |
| tests/setupPoseidon.ts | Preloads Poseidon for unit/e2e tests. |
| tests/unit/poseidon.test.ts | Updates Poseidon tests for async hashing + sync variant after preload. |
| tests/unit/multiKey.test.ts | Switches to KeylessPublicKey.createSync usage in tests. |
| tests/unit/keyless.test.ts | Updates Keyless tests for async public key derivation and async verification helper. |
| tests/unit/accountSerialization.test.ts | Adds beforeAll preload + awaits ephemeral key generation for serialization coverage. |
| tests/e2e/transaction/transactionBuilder.test.ts | Updates keyless public key derivation to await async API. |
| src/core/crypto/poseidon.ts | Implements dynamic import + adds poseidonHashSync / hashStrToFieldSync. |
| src/core/crypto/keyless.ts | Makes keyless derivation/verification paths async and introduces sync constructors. |
| src/core/crypto/federatedKeyless.ts | Makes federated keyless derivation async and adds sync constructor path. |
| src/internal/keyless.ts | Awaits async key derivation methods during keyless account derivation. |
| src/account/EphemeralKeyPair.ts | Makes generate() async (preloads Poseidon) and uses sync hashing in ctor. |
| src/account/KeylessAccount.ts | Uses KeylessPublicKey.createSync in constructor. |
| src/account/FederatedKeylessAccount.ts | Uses FederatedKeylessPublicKey.createSync in constructor. |
| examples/typescript/keyless.ts | Awaits async EphemeralKeyPair.generate(). |
| examples/typescript/keyless_mainnet.ts | Awaits async EphemeralKeyPair.generate(). |
| examples/typescript/jwk_update.ts | Awaits async EphemeralKeyPair.generate(). |
| examples/typescript/federated_keyless.ts | Awaits async EphemeralKeyPair.generate(). |
| CHANGELOG.md | Documents the breaking changes and new preload/sync-variant patterns. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (strBytes.length > maxSizeBytes) { | ||
| throw new Error(`Inputted bytes of length ${strBytes} is longer than ${maxSizeBytes}`); | ||
| } |
There was a problem hiding this comment.
The error message uses ${strBytes} (a Uint8Array) instead of the byte length, so it will print something like 1,2,3 rather than a number. Use ${strBytes.length} (and consider adjusting the wording to avoid implying the array itself is a length).
| if (strBytes.length > maxSizeBytes) { | ||
| throw new Error(`Inputted bytes of length ${strBytes} is longer than ${maxSizeBytes}`); | ||
| } |
There was a problem hiding this comment.
Same issue as the async variant: the thrown error interpolates the Uint8Array (${strBytes}) instead of its length. Use ${strBytes.length} so callers get a useful size in the message.
| }) { | ||
| const publicKey = KeylessPublicKey.create(args); | ||
| const publicKey = KeylessPublicKey.createSync(args); | ||
| super({ publicKey, ...args }); | ||
| this.publicKey = publicKey; |
There was a problem hiding this comment.
Switching the constructor to KeylessPublicKey.createSync() means KeylessAccount.deserialize() (and any direct new KeylessAccount(...)) will now throw unless poseidon-lite has already been preloaded. Consider documenting this requirement prominently on the constructor/deserialize path, and/or providing an async deserializeAsync/fromBytesAsync that calls ensurePoseidonLoaded() before constructing.
| }) { | ||
| const publicKey = FederatedKeylessPublicKey.create(args); | ||
| const publicKey = FederatedKeylessPublicKey.createSync(args); | ||
| super({ publicKey, ...args }); | ||
| this.publicKey = publicKey; | ||
| this.audless = args.audless ?? false; |
There was a problem hiding this comment.
Using FederatedKeylessPublicKey.createSync() in the constructor means FederatedKeylessAccount.deserialize() (and any direct construction) will now fail unless poseidon-lite has already been loaded. Consider adding explicit docs on this, and/or an async deserialization/factory API that preloads poseidon via ensurePoseidonLoaded() before constructing.
Replace static import of poseidon-lite with dynamic import() to enable bundlers to code-split the ~421KB poseidon hash constant table into a separate chunk. This dramatically reduces the initial bundle size for applications that don't use Keyless accounts. Changes: - poseidon.ts: Replace static imports with cached dynamic import() - Add ensurePoseidonLoaded() async function for pre-loading - EphemeralKeyPair.generate() is now async (returns Promise) - deriveKeylessAccount() and verifyKeylessSignature() auto-load poseidon - Update tests to work with the new async loading pattern - Add vitest setupFile to pre-load poseidon before test execution BREAKING CHANGE: EphemeralKeyPair.generate() now returns Promise<EphemeralKeyPair> instead of EphemeralKeyPair. Direct use of new EphemeralKeyPair() or poseidonHash() requires calling await ensurePoseidonLoaded() first. Co-authored-by: Greg Nazario <greg@gnazar.io>
The examples under examples/typescript/ were using EphemeralKeyPair.generate() synchronously. Since generate() is now async, add await to all call sites. Co-authored-by: Greg Nazario <greg@gnazar.io>
Address review comment: poseidonHash now auto-loads poseidon-lite on first call instead of requiring explicit ensurePoseidonLoaded(). - poseidonHash() is now async and returns Promise<bigint> - hashStrToField() is now async and returns Promise<bigint> - Added sync variants (poseidonHashSync, hashStrToFieldSync) for constructors and deserialization paths - KeylessPublicKey.create/fromJwtAndPepper are now async with createSync variant for sync contexts - FederatedKeylessPublicKey.create/fromJwtAndPepper are now async with createSync variant - verifyKeylessSignatureWithJwkAndConfig is now async - MoveJWK.toScalar is now async - Removed explicit ensurePoseidonLoaded() from deriveKeylessAccount and verifyKeylessSignature (no longer needed) Co-authored-by: Greg Nazario <greg@gnazar.io>
- Make ensurePoseidonLoaded concurrency-safe with cached in-flight promise - Update ensurePoseidonLoaded JSDoc to clarify async vs sync usage - Fix error messages using strBytes instead of strBytes.length - Fix verifyKeylessSignatureWithJwkAndConfig JSDoc @returns - Mark verifySignature as @deprecated with correct class names in errors - Add try/catch in EphemeralKeyPair constructor for better error context - Add JSDoc to KeylessAccount/FederatedKeylessAccount constructors about poseidon preloading requirement Co-authored-by: Greg Nazario <greg@gnazar.io>
…ilure) The test used vi.mock with an async factory to mock generateSignedTransactionForSimulation, but ESM named imports create local bindings that aren't affected by vi.mock module replacement. Fix: use vi.doMock + vi.resetModules + dynamic imports so the mock is applied before the consuming module loads and captures the binding. Co-authored-by: Greg Nazario <greg@gnazar.io>
debf40f to
4022c25
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| loadPromise = import("poseidon-lite").then((mod) => { | ||
| numInputsToPoseidonFunc = [ | ||
| mod.poseidon1, | ||
| mod.poseidon2, | ||
| mod.poseidon3, | ||
| mod.poseidon4, | ||
| mod.poseidon5, | ||
| mod.poseidon6, | ||
| mod.poseidon7, | ||
| mod.poseidon8, | ||
| mod.poseidon9, | ||
| mod.poseidon10, | ||
| mod.poseidon11, | ||
| mod.poseidon12, | ||
| mod.poseidon13, | ||
| mod.poseidon14, | ||
| mod.poseidon15, | ||
| mod.poseidon16, | ||
| ]; | ||
| }); | ||
| } | ||
| return loadPromise; |
There was a problem hiding this comment.
ensurePoseidonLoaded() caches the first import("poseidon-lite") promise forever. If that dynamic import ever rejects (transient network/CDN issue, chunk load error, etc.), loadPromise remains rejected and subsequent calls will never retry, leaving poseidon permanently unusable for the process. Consider resetting loadPromise back to undefined in a .catch() handler (and/or clearing numInputsToPoseidonFunc) so a later call can retry the import, or rethrow with a stable error while still allowing a retry path.
| loadPromise = import("poseidon-lite").then((mod) => { | |
| numInputsToPoseidonFunc = [ | |
| mod.poseidon1, | |
| mod.poseidon2, | |
| mod.poseidon3, | |
| mod.poseidon4, | |
| mod.poseidon5, | |
| mod.poseidon6, | |
| mod.poseidon7, | |
| mod.poseidon8, | |
| mod.poseidon9, | |
| mod.poseidon10, | |
| mod.poseidon11, | |
| mod.poseidon12, | |
| mod.poseidon13, | |
| mod.poseidon14, | |
| mod.poseidon15, | |
| mod.poseidon16, | |
| ]; | |
| }); | |
| } | |
| return loadPromise; | |
| loadPromise = import("poseidon-lite") | |
| .then((mod) => { | |
| numInputsToPoseidonFunc = [ | |
| mod.poseidon1, | |
| mod.poseidon2, | |
| mod.poseidon3, | |
| mod.poseidon4, | |
| mod.poseidon5, | |
| mod.poseidon6, | |
| mod.poseidon7, | |
| mod.poseidon8, | |
| mod.poseidon9, | |
| mod.poseidon10, | |
| mod.poseidon11, | |
| mod.poseidon12, | |
| mod.poseidon13, | |
| mod.poseidon14, | |
| mod.poseidon15, | |
| mod.poseidon16, | |
| ]; | |
| }) | |
| .catch((error) => { | |
| // Allow future calls to retry the dynamic import after a transient failure. | |
| loadPromise = undefined; | |
| numInputsToPoseidonFunc = undefined; | |
| throw error; | |
| }); | |
| } | |
| return loadPromise!; |
| mockPost: postAptosFullNode as MockedFunction<typeof PostType>, | ||
| simulateTransaction: simulateTransaction as typeof SimType, |
There was a problem hiding this comment.
The casts in setupMocks() use typeof on type-only imports (PostType / SimType). In TypeScript this is a type error because those names don’t exist as values. Use MockedFunction<PostType> and cast simulateTransaction as SimType (or derive types via typeof import("...").postAptosFullNode) to keep the test file type-correct.
| mockPost: postAptosFullNode as MockedFunction<typeof PostType>, | |
| simulateTransaction: simulateTransaction as typeof SimType, | |
| mockPost: postAptosFullNode as MockedFunction<PostType>, | |
| simulateTransaction: simulateTransaction as SimType, |
Description
Implements Solution 1 from the feature request: make
poseidon-litedynamically loaded so bundlers can code-split it out of the main bundle.Currently,
poseidon-litecontributes ~421KB (minified+gzipped) to the SDK bundle, but it's only used for Keyless account derivation. This PR replaces the static import with a dynamicimport(), enabling bundlers (webpack, rollup, vite, esbuild) to automatically split poseidon-lite into a separate chunk that's only loaded when Keyless features are actually used.How it works:
poseidonHash()is now async and automatically loadsposeidon-liteon first call via dynamicimport()hashStrToField,KeylessPublicKey.create,verifyKeylessSignatureWithJwkAndConfig,MoveJWK.toScalar, etc.) are now async and auto-load transparentlyposeidonHashSync,hashStrToFieldSync,KeylessPublicKey.createSync,FederatedKeylessPublicKey.createSync) are available for constructors/deserialization paths where poseidon has already been loaded by a preceding async callensurePoseidonLoaded()is still exported for explicit pre-loading if neededKey changes:
src/core/crypto/poseidon.ts:poseidonHash→ async with auto-load; addedposeidonHashSync,hashStrToFieldSyncsrc/core/crypto/keyless.ts:KeylessPublicKey.create/fromJwtAndPepper→ async; addedcreateSync;computeIdCommitment,getPublicInputsHash,verifyKeylessSignatureWithJwkAndConfig,MoveJWK.toScalar→ asyncsrc/core/crypto/federatedKeyless.ts:FederatedKeylessPublicKey.create/fromJwtAndPepper→ async; addedcreateSyncsrc/account/EphemeralKeyPair.ts:generate()→ async; constructor usesposeidonHashSyncsrc/account/KeylessAccount.ts: Constructor usesKeylessPublicKey.createSyncsrc/account/FederatedKeylessAccount.ts: Constructor usesFederatedKeylessPublicKey.createSyncsrc/internal/keyless.ts: Removed explicitensurePoseidonLoaded()(auto-loaded by poseidon functions)How it works for end users:
deriveKeylessAccount()orEphemeralKeyPair.generate()— poseidon loads automaticallyposeidon-literemains independencies— it's still installed, just loaded on-demandBreaking changes:
poseidonHash()now returnsPromise<bigint>instead ofbiginthashStrToField()now returnsPromise<bigint>instead ofbigintKeylessPublicKey.create()andfromJwtAndPepper()now returnPromise<KeylessPublicKey>FederatedKeylessPublicKey.create()andfromJwtAndPepper()now returnPromise<FederatedKeylessPublicKey>verifyKeylessSignatureWithJwkAndConfig()now returnsPromise<void>instead ofvoidMoveJWK.toScalar()now returnsPromise<bigint>EphemeralKeyPair.generate()now returnsPromise<EphemeralKeyPair>KeylessPublicKey.verifySignature()andFederatedKeylessPublicKey.verifySignature()now throw directing users toverifySignatureAsync()Test Plan
pnpm buildpnpm checktests/setupPoseidon.ts) pre-loads poseidon for testsRelated Links
Checklist
pnpm fmt?CHANGELOG.md?