Skip to content

feat: Dynamically load poseidon-lite to reduce bundle size (~421KB)#850

Open
gregnazario wants to merge 5 commits intomainfrom
cursor/poseidon-lite-optional-loading-3ce2
Open

feat: Dynamically load poseidon-lite to reduce bundle size (~421KB)#850
gregnazario wants to merge 5 commits intomainfrom
cursor/poseidon-lite-optional-loading-3ce2

Conversation

@gregnazario
Copy link
Collaborator

@gregnazario gregnazario commented Mar 20, 2026

Description

Implements Solution 1 from the feature request: make poseidon-lite dynamically loaded so bundlers can code-split it out of the main bundle.

Currently, poseidon-lite contributes ~421KB (minified+gzipped) to the SDK bundle, but it's only used for Keyless account derivation. This PR replaces the static import with a dynamic import(), 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 loads poseidon-lite on first call via dynamic import()
  • All keyless functions that use poseidon (hashStrToField, KeylessPublicKey.create, verifyKeylessSignatureWithJwkAndConfig, MoveJWK.toScalar, etc.) are now async and auto-load transparently
  • Sync variants (poseidonHashSync, hashStrToFieldSync, KeylessPublicKey.createSync, FederatedKeylessPublicKey.createSync) are available for constructors/deserialization paths where poseidon has already been loaded by a preceding async call
  • ensurePoseidonLoaded() is still exported for explicit pre-loading if needed

Key changes:

  • src/core/crypto/poseidon.ts: poseidonHash → async with auto-load; added poseidonHashSync, hashStrToFieldSync
  • src/core/crypto/keyless.ts: KeylessPublicKey.create/fromJwtAndPepper → async; added createSync; computeIdCommitment, getPublicInputsHash, verifyKeylessSignatureWithJwkAndConfig, MoveJWK.toScalar → async
  • src/core/crypto/federatedKeyless.ts: FederatedKeylessPublicKey.create/fromJwtAndPepper → async; added createSync
  • src/account/EphemeralKeyPair.ts: generate() → async; constructor uses poseidonHashSync
  • src/account/KeylessAccount.ts: Constructor uses KeylessPublicKey.createSync
  • src/account/FederatedKeylessAccount.ts: Constructor uses FederatedKeylessPublicKey.createSync
  • src/internal/keyless.ts: Removed explicit ensurePoseidonLoaded() (auto-loaded by poseidon functions)

How it works for end users:

  • Users who don't use Keyless accounts get a ~421KB smaller bundle with zero code changes
  • Users who use deriveKeylessAccount() or EphemeralKeyPair.generate() — poseidon loads automatically
  • poseidon-lite remains in dependencies — it's still installed, just loaded on-demand

Breaking changes:

  • poseidonHash() now returns Promise<bigint> instead of bigint
  • hashStrToField() now returns Promise<bigint> instead of bigint
  • KeylessPublicKey.create() and fromJwtAndPepper() now return Promise<KeylessPublicKey>
  • FederatedKeylessPublicKey.create() and fromJwtAndPepper() now return Promise<FederatedKeylessPublicKey>
  • verifyKeylessSignatureWithJwkAndConfig() now returns Promise<void> instead of void
  • MoveJWK.toScalar() now returns Promise<bigint>
  • EphemeralKeyPair.generate() now returns Promise<EphemeralKeyPair>
  • KeylessPublicKey.verifySignature() and FederatedKeylessPublicKey.verifySignature() now throw directing users to verifySignatureAsync()

Test Plan

  • All 27 unit tests pass (poseidon, keyless, accountSerialization, multiKey)
  • Build succeeds with pnpm build
  • Lint/format passes with pnpm check
  • Vitest setup file (tests/setupPoseidon.ts) pre-loads poseidon for tests

Related Links

Checklist

  • Have you ran pnpm fmt?
  • Have you updated the CHANGELOG.md?
Open in Web Open in Cursor 

@gregnazario gregnazario marked this pull request as ready for review March 20, 2026 01:44
@gregnazario gregnazario requested a review from a team as a code owner March 20, 2026 01:44
@cursor cursor bot force-pushed the cursor/poseidon-lite-optional-loading-3ce2 branch from 9dd697b to 5ecb37d Compare March 20, 2026 02:13
@gregnazario gregnazario requested a review from 0xmaayan March 20, 2026 02:58
@gregnazario gregnazario force-pushed the cursor/poseidon-lite-optional-loading-3ce2 branch from 6ebc13f to 29a313b Compare March 20, 2026 02:59
@gregnazario gregnazario requested a review from Copilot March 20, 2026 02:59
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +61 to +63
if (strBytes.length > maxSizeBytes) {
throw new Error(`Inputted bytes of length ${strBytes} is longer than ${maxSizeBytes}`);
}
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +81 to 83
if (strBytes.length > maxSizeBytes) {
throw new Error(`Inputted bytes of length ${strBytes} is longer than ${maxSizeBytes}`);
}
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 66 to 69
}) {
const publicKey = KeylessPublicKey.create(args);
const publicKey = KeylessPublicKey.createSync(args);
super({ publicKey, ...args });
this.publicKey = publicKey;
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 64 to 68
}) {
const publicKey = FederatedKeylessPublicKey.create(args);
const publicKey = FederatedKeylessPublicKey.createSync(args);
super({ publicKey, ...args });
this.publicKey = publicKey;
this.audless = args.audless ?? false;
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
cursoragent and others added 5 commits March 20, 2026 21:20
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>
@cursor cursor bot force-pushed the cursor/poseidon-lite-optional-loading-3ce2 branch from debf40f to 4022c25 Compare March 20, 2026 21:33
@gregnazario gregnazario requested a review from Copilot March 21, 2026 04:33
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +30 to +51
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;
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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!;

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +56
mockPost: postAptosFullNode as MockedFunction<typeof PostType>,
simulateTransaction: simulateTransaction as typeof SimType,
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
mockPost: postAptosFullNode as MockedFunction<typeof PostType>,
simulateTransaction: simulateTransaction as typeof SimType,
mockPost: postAptosFullNode as MockedFunction<PostType>,
simulateTransaction: simulateTransaction as SimType,

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants