Skip to content

perf(sdk): load on-chain program clients and eth contracts lazily - #14661

Merged
dylanjeffers merged 2 commits into
mainfrom
perf/sdk-lazy-onchain
Oct 13, 2026
Merged

dylanjeffers merged 2 commits into
mainfrom
perf/sdk-lazy-onchain

Conversation

@dylanjeffers

Copy link
Copy Markdown
Contributor

Part of moving crypto code off the app's startup path.

Problem

createSdkWithServices eagerly constructed ClaimableTokensClient, RewardManagerClient, PaymentRouterClient and EthereumService. That statically pulled @audius/spl → @coral-xyz/anchor, buffer-layout, borsh and all 9 @audius/eth ABIs into the web entry chunk for every visitor, including signed-out ones.

Change

  • Lazy stand-ins. LazyClaimableTokensClient, LazyRewardManagerClient, LazyPaymentRouterClient and LazyEthereumService implement each client's public interface. The compiler enforces this through implements PublicInterface<Client>.
    • The first method call dynamically imports and constructs the real client with the same config, then hands the call over.
    • Concurrent first calls share one load. A failed load isn't cached, so the next call retries.
    • Synchronous members are served without loading: RewardManagerClient.lookupTable and each eth contract's address come from config, and watchEvent returns its unwatch function synchronously.
  • sideEffects field in packages/sdk/package.json. It lists only sdkBrowserDist.ts and dist/sdk.min.js. This was required: without it, the export * from './services' re-exports kept anchor in the bundle even with the stand-ins.
  • Minor changeset. For the default services:
    • they're no longer instanceof their client classes (nothing in the monorepo checks this);
    • ethereum.<contract>.abi throws until that contract has been used (nothing in the monorepo reads it);
    • a bad config now fails on first use instead of when the SDK is created.

Apps that pass their own clients via config.services are unaffected. SolanaClient and the viem eth clients are unchanged.

Impact (web entry chunk)

5,762 → 5,489 kB minified (−273 kB), 1,567 → 1,510 kB gzip. anchor, @audius/spl, buffer-layout, @coral-xyz/borsh and the @audius/eth ABIs are no longer in the entry chunk.

Follow-ups

  • solanaClient.connection is read synchronously in about 30 places across common and web, which keeps @solana/web3.js tied to the SDK. Next step: add an async getConnection(), then make SolanaClient lazy.
  • SolanaAddress/PublicKeySchema validation uses new PublicKey(). A base58 32-byte check would do the same job without it.

Test plan

  • SDK typecheck, full vitest suite (224 passed), npm run build
  • New tests: nothing loads before the first call, concurrent first calls load once, errors propagate and the next call retries, sync members work before loading, the stand-in isn't mistaken for a promise
  • Mobile typecheck
  • Web smoke test: claim a reward, send a tip, buy a USDC track (exercises all three program clients)
  • protocol-dashboard / staking flows if they consume the default ethereum service

🤖 Generated with Claude Code

dylanjeffers and others added 2 commits October 12, 2026 14:40
createSdkWithServices now builds lazy facades for ClaimableTokensClient,
RewardManagerClient, PaymentRouterClient and EthereumService. Each loads
its implementation with a dynamic import on first use, so creating the
SDK no longer pulls in @audius/spl, @coral-xyz/anchor, buffer-layout or
the @audius/eth ABIs. Interfaces are unchanged; RewardManagerClient's
lookupTable and each contract's address stay synchronous.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lets bundlers drop SDK modules whose exports are unused, so the barrel's
re-exports of the program clients no longer keep anchor and @audius/spl
in the consumer's entry chunk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1e5e238

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@audius/sdk Minor
@audius/sdk-legacy Patch
@audius/protocol-dashboard Patch
@audius/sp-actions Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@socket-security

Copy link
Copy Markdown

Dependency limit exceeded — report not shown.

This pull request scan exceeded the 10,000-dependency limit applied to this scan, so the results are incomplete and may be inaccurate. To avoid reporting false positives, Socket has not posted a report.

Upgrade your plan to raise the dependency limit and get complete reports, or view the partial scan in the dashboard.

Socket is always free for open source. If this is a non-commercial open source project, contact us to request a free Team account.

@dylanjeffers
dylanjeffers merged commit aeba05f into main Oct 13, 2026
24 checks passed
@dylanjeffers
dylanjeffers deleted the perf/sdk-lazy-onchain branch October 13, 2026 22:10
dylanjeffers added a commit that referenced this pull request Oct 13, 2026
Part of moving crypto code off the app's startup path. This is the
common/web/mobile half; #14661 (SDK), #14662 (hedgehog), #14663 (crypto
shim) and #14660 (boot work) are the others.

## Problem
Crypto libraries were statically reachable from the app root through
shared barrels, not through sagas:
- `local-storage.ts` → `@audius/common/services` →
`AudiusBackend.ts`/`solana.ts` pulled in web3.js, spl-token, viem and
Jupiter.
- `reducers.ts` → the `buy-usdc`/`purchase-content`/`ui` index files,
which re-export their sagas.
- `AppProviders` → `WagmiProvider`.
- `registerNiceModals.ts` statically registered the purchase, withdraw,
wallet and rewards modals.

## Changes
1. **Solana, Jupiter and viem load on demand (common).**
- AudiusBackend's Solana methods moved to `solanaBackend.ts`, which
loads on first call. `findAssociatedTokenAddress` is now async; its 3
callers are updated.
- `audius-backend/solana` is no longer re-exported from the services
barrel.
- WalletClient, the hedgehog Solana wallet service and the
wallet/Jupiter/coin query hooks import web3.js, spl-token, tweetnacl,
viem and Jupiter inside the async code that uses them.
- `isValidSolAddress` now checks for a 32-byte base58 key instead of
constructing a `PublicKey`.
2. **Crypto sagas start on demand (web + mobile).**
- A new `lazySagas` helper waits for a feature's first trigger action,
dynamic-imports its sagas, spawns them, and replays the buffered
actions.
- Replays go to the saga middleware's channel, so reducers don't run
twice.
   - Converted:
- common: purchase-content, withdraw-USDC, Stripe onramp, buy-USDC
(triggered by `onrampOpened`/`startRecoveryIfNecessary`)
     - mobile: token dashboard and wallet-connect
3. **No app-wide `WagmiProvider` (web).**
- `useConnectExternalWallets` was the only wagmi-context consumer; it
now passes AppKit's config explicitly.
   - `ReownAppKitModal` calls `reconnect()` when it loads.
   - `auth.ts` and sign-out import `@wagmi/core` dynamically.
4. **14 purchase/wallet/rewards modals registered lazily (web)**, using
the existing `registerLazyModal`.
- `CoinflowPurchaseProtection` now mounts when the browser is idle, in
its own Suspense boundary. It used to share a boundary with `<Outlet
/>`, so page content waited on the Coinflow chunk.

## Impact (web entry chunk)
- **This PR alone:** 5,627 → 5,285 KB minified, 1,526 → 1,430 KB
gzipped. wagmi, `@wagmi/core` and `@jup-ag/api` are out, and so is most
crypto feature code.
- **With #14660–#14663 merged together:** 5,627 → 4,213 KB minified
(−25%), 1,526 → 1,126 KB gzipped (−26%). Crypto libraries in the entry
drop from 2,447 to 1,368 KB pre-minify. What's left is viem/noble
(needed for signing) and `@solana/web3.js` via the SDK's
`SolanaRelay`/`SolanaClient`; that's the next step.

## Test plan
- [x] Typecheck: common, web, mobile, sdk (also checked combined with
the other 4 branches)
- [x] common vitest (the 7 `upload/selectors.test.ts` failures also
happen on main), web vitest, mobile jest 34/34
- [x] New `lazySagas` tests, including no double reducer run on replay
- [x] iOS production Metro bundle builds (dynamic imports resolve)
- [x] Prod build against prod API, signed out: `/trending` and `/signin`
render with no app errors; a lazy modal opens; a dispatched trigger
loads its sagas and handles the replayed action
- [ ] Buy a premium track with USDC balance, with card (Stripe and
Coinflow), and as a guest
- [ ] Withdraw USDC to a Solana address and through Coinflow
- [ ] External wallet sign-in; reload while connected (reconnects?);
add/remove a linked wallet; sign out (disconnects?)
- [ ] Fan club connect-wallet and the launchpad; send tokens; buy/sell
swap; payout wallet modal
- [ ] Claim rewards and vested coins
- [ ] Mobile: token dashboard wallets, Phantom connect and sign

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
dylanjeffers added a commit that referenced this pull request Oct 13, 2026
## Impact
| | min | gzip |
|---|---|---|
| Web entry, base | 2,479,377 | 716,551 |
| Web entry, this PR | **2,203,062** | **637,628** |

- Crypto in the web entry drops from 1,367 to 682 KB pre-minify.
`@solana/web3.js`, bn.js, buffer-layout, borsh, superstruct,
rpc-websockets and jayson are gone; `@noble/curves` drops from 193 to 81
KB because web3.js's ed25519 copy went with it.
- What's left is viem/noble/scure, which signing and login need.
- Mobile bundle size is unchanged (Metro has no splitting), but web3.js
is no longer evaluated when the SDK is created.

## Changes
- **`LazySolanaClient`** follows the `Lazy*` facade pattern from #14661.
Program clients build their PublicKey-based default config only when
they load.
- **New `solanaClient.getConnection()`.** Every
`solanaClient.connection` read in common, web, the SDK program clients,
TracksApi/AlbumsApi and the lookup-table script now uses it. Sagas use
`yield* call([client, client.getConnection])`.
- **`SolanaRelay` and `PublicKeySchema`** import web3.js inside their
async paths.
- **`SolanaAddressSchema`** validates 32 base58 bytes with
`@scure/base`. A test checks it accepts and rejects exactly the same as
`new PublicKey()` across about 2,060 inputs: valid keys, off-curve PDAs,
31/33-byte keys, leading zeros, invalid characters, empty strings and
random strings.
- **`createSdk` and `DeveloperAppsApi`** no longer import values through
the services barrel.
- **`PaymentRouterClient/index.ts`** uses named re-exports so Metro
keeps them lazy.
- **`SolanaWallet`** uses `@scure/base` instead of bs58.

## ⚠️ Breaking SDK changes (changeset: **major**)
- On the default client, `sdk.services.solanaClient.connection` throws a
clear error until the client has loaded, meaning after any awaited call
such as `getConnection()`. Use `await solanaClient.getConnection()`.
- `PublicKeySchema` and `MintSchema` only parse asynchronously
(`parseAsync`/`safeParseAsync`). The SDK's own methods already did.
- The default `rewardManagerClient.lookupTable` is available only after
that client loads, unless it's passed in config.
- Default clients aren't `instanceof` their classes.
- A `solanaClient` passed in through `services` is used as-is.

## Test plan
- [x] tsc: sdk, common, web, mobile. sdk vitest (238), web vitest (188),
mobile jest (49). New tests check that creating the SDK doesn't load
web3.js, plus the `LazySolanaClient` and lookup-table behavior.
- [x] Prod build smoke test: `/trending` and `/signin` work
- [ ] Buy USDC (Coinflow, Stripe); buy a track and an album with USDC;
withdraw USDC; payout wallet modal
- [ ] Jupiter/artist-coin swaps from the internal and an external
wallet; claim rewards; wallet and coin balances; mint recovery
- [ ] Mobile: Coinflow onramp and withdraw

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant