Skip to content

Babkenmes/widget cdn delivery#2045

Open
babkenmes wants to merge 29 commits into
dev-evm-wallet-class-refactorfrom
babkenmes/widget-cdn-delivery
Open

Babkenmes/widget cdn delivery#2045
babkenmes wants to merge 29 commits into
dev-evm-wallet-class-refactorfrom
babkenmes/widget-cdn-delivery

Conversation

@babkenmes

Copy link
Copy Markdown
Collaborator

No description provided.

babkenmes and others added 23 commits June 5, 2026 17:49
Lets integrators receive widget updates without redeploying. The host
installs only @layerswap/widget-react; the widget and its heavy deps
(framer-motion, radix, formik, swr, wallet adapters) load at runtime
from a content-hashed Rspack-built remote and stay out of the host's
bundle.

- packages/widget-react: @module-federation/runtime-based React loader.
  Exposes <LayerswapEmbed> with typed config, callbacks, walletDefaults,
  and host wagmi adoption via the EVM provider's externalWagmiConfig.
  Types re-exported from @layerswap/widget (optional peer-dep).
- apps/widget-cdn: Rspack + @module-federation/enhanced remote. Exposes
  ./Widget = LayerswapProvider+Swap with getDefaultProviders(). Shares
  react/react-dom/wagmi/viem/@tanstack/react-query/zustand as singletons
  pinned to catalog versions. Includes Node built-in polyfills (buffer,
  crypto, stream, etc.) the wallet adapters reach for that Next.js
  auto-polyfilled.
- examples/cdn-embed: Vite host. Shows the integration end-to-end with
  a WagmiProvider + connect button so host wallet state can be observed
  flowing into the widget via attachWagmiSync.
- packages/widget: export CallbacksContextType so widget-react can type
  the callbacks prop. No behavior change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
widget-cdn: static deploy with CORS + cache headers — short cache on
remoteEntry.js / mf-manifest.json so updates roll out, immutable
far-future cache on content-hashed chunks.

cdn-embed: static Vite deploy. VITE_LAYERSWAP_REMOTE_ENTRY env var
must be set on the Vercel project to point at the deployed widget-cdn
URL.

Both follow the existing monorepo pattern: build packages first via
the root `pnpm build:packages`, then the app/example.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Radix Popover.Portal defaults to document.body. The widget's Tailwind
utilities are scoped to `.layerswap-styles` (postcss-prefixwrap), so a
popover rendered as a child of <body> picks up none of them — no
z-index, no background, no border-radius — and ends up hidden behind
the wallet dialog overlay. The trigger looks dead.

Portal into `#widget` (read lazily on open, like vaulModal does for
the drawer itself) so the popover inherits the prefix scope and its
utilities apply.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Component name now matches the @layerswap/widget-react package name.
LayerswapEmbed was a leftover from the old @layerswap/embed package
name and read weirdly inside @layerswap/widget-react.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The cdn-embed folder name was off-theme after the package rename
(@layerswap/embed → @layerswap/widget-react). The new name describes
what the example actually is: a host app integrating the
@layerswap/widget-react package.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wagmi v2 auto-registers an EIP-6963-discovered connector for every
announced provider alongside any bare connector you declare. With both
`injected()` and an auto-discovered MetaMask connector active,
`reconnect()` restores both against the same wallet on refresh — the
widget then shows the same physical wallet twice in its connected list.

Disable auto-discovery for this single-connector example; document the
behavior inline so integrators understand the tradeoff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move the #widget portal target into the shadcn PopoverContent wrapper
so every popover in the widget is in-scope of `.layerswap-styles` by
default. Callers that need a different target still pass `container`
explicitly.

Revert the local fix in ProviderPicker now that the default handles
it. 13 other popovers across the widget (RouteSortingMenu, Slippage,
AddressPicker, WalletsDropdown, etc.) were latently broken the same
way and now work without per-site changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Several wallet adapter transitive deps requested older viem majors
(2.23, 2.31). pnpm hoisted all three alongside the catalog 2.44.1,
which caused MF's strict shared-singleton check to fire a warning per
hoisted copy at every build, and bundled dead viem code into chunks
the runtime never used.

Override resolves everything to 2.44.1. Build is now warning-free.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@imtbl/sdk (~3 MB raw / ~1 MB Brotli) was statically imported at the
root of @layerswap/wallets via the re-exports of
createImmutablePassportProvider + imtblPassportLoginCallback. Every
consumer of @layerswap/wallets — including the CDN remote — pulled the
SDK into its module graph even when no Passport config was supplied.

Move those value re-exports to a dedicated subpath:
  @layerswap/wallets/eager/imtbl-passport
The main entry now exports only the lazy descriptor; the SDK enters the
graph only via the descriptor's `await import(...)` thunk, which fires
on connect-modal open and only when immutablePassport config is set.

Update apps/bridge/pages/imtblRedirect.tsx (the OAuth callback that
genuinely needs the eager path) to import from the new subpath.

widget-cdn sync graph drops from ~3 MB initial + 3 MB lazy to ~1.76 MB
sync only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The .iphone-x rules in globals.css (~100 lines) had no consumer
anywhere in the codebase. They referenced /cnbs1.png as a host-served
asset, which forced any CDN integration to either copy the file into
their own public dir or accept a 404. Removing the dead rules makes
the widget CSS self-contained and drops the asset requirement.

Also drop the workaround copy from the example's public dir; the
asset is no longer referenced.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Production build now emits to dist/v1/ with content-hashed chunks
(`[name].[contenthash:8].js`). The entry point `remoteEntry.js` keeps
a stable filename so loaders can find it; chunks are immutable so
they can be cached far-future and served with the right cache rules.

vercel.json updated to match:
- /v1/remoteEntry.js + /v1/mf-manifest.json — short cache (60s),
  must-revalidate, so updates roll out quickly
- /v1/*.<hash>.js — far-future immutable
- /v1/* unhashed assets — 5min cache as a safe middle ground

LAYERSWAP_CHANNEL env var lets CI produce pinned immutable builds
under a version dir (e.g. v1.3.0/) alongside the rolling v1/.

Dev mode unchanged (flat dist/, no hashing) — the dev-server proxies
the artifact from the root and the example default URL still points
there.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
LayerswapWidget now accepts a `manifest` URL alongside the legacy
`remoteEntry`. When given:
  1. Fetches `<channel>/manifest.json` (short-cache).
  2. Honors `killSwitch: true` — refuses to load, fires onError with
     a typed ManifestError.
  3. With `verify: true`, verifies a detached ECDSA P-256 signature
     over the canonical manifest body using a public key baked into
     the package. Rejects if missing or invalid.
  4. Resolves `manifest.remoteEntry` relative to the manifest URL,
     then loads as before via @module-federation/runtime.

apps/widget-cdn/scripts/build-manifest.mjs emits manifest.json after
each production build. If LAYERSWAP_PRIVATE_KEY_PEM is set, the
manifest is signed with that key; otherwise an unsigned manifest is
emitted (the loader accepts it only when `verify: false`).

The bundled public key is a LOCAL PLACEHOLDER generated for testing.
Before pointing real integrators at the public CDN, generate the
production keypair in KMS/HSM, swap the public key constant in
`packages/widget-react/src/manifest.ts`, and bump the package
version — the public key is the trust anchor and rotating it
requires a widget-react release.

Tested end-to-end with a static CORS server serving dist/v1:
- Happy path: widget mounts.
- killSwitch: true → ManifestError('kill-switch'), onError fires,
  widget does not mount.
- Tampered signature → ManifestError('signature'), onError fires,
  widget does not mount.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Integrators can now drop chains from the default provider list via the
embed without installing wallet packages. Pass e.g.

  <LayerswapWidget
    walletProvidersConfig={{ exclude: ['tron', 'fuel'] }}
    …
  />

and those providers are filtered out before LayerswapProvider renders.
The connect modal's chain filter popover updates accordingly — verified
by the Bitcoin/EVM/Solana/Starknet-only set with tron + fuel excluded.

Per-chain config overrides (e.g. swap WC project ID just for SVM,
inject a customConnection for EVM) are the natural next addition under
the same prop without breaking the integrator API.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Real README: install, host wagmi adoption, multiInjectedProviderDiscovery
gotcha, full props table, manifest/kill-switch/signature flow, failure
modes, local-dev flow, security model.

CSP block recommends script-src + connect-src allowlists covering the
CDN origin, LayerSwap API, and WalletConnect relays. Documents why
style-src needs 'unsafe-inline' today (style-loader runtime injection)
and that removing it would be a build-time change in widget-cdn.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Production deploys of apps/widget-cdn now go through
.github/workflows/widget-cdn-deploy.yml on push to main (or
workflow_dispatch with a channel input). The workflow:

  1. Stages LAYERSWAP_PRIVATE_KEY_PEM from secrets to a tempfile.
  2. Runs `pnpm build`, which builds the remote and signs
     `manifest.json` via the existing build-manifest script.
  3. Runs `pnpm verify-manifest` (new) which round-trips the signature
     against the public key constant bundled into @layerswap/widget-react.
     Fails the deploy on key rotation mismatches before anything ships.
  4. `vercel build` packages the already-built artifact, `vercel deploy
     --prebuilt` uploads without re-running the build — Vercel never
     touches the signing key.
  5. Wipes the tempfile.
  6. Smoke-tests the deployed manifest URL: reachable, CORS set, signed,
     killSwitch off.

vercel.json disables git-integrated auto-deploys
(`git.deploymentEnabled.main: false`) so this workflow is the only path
to a signed production artifact.

`permissions: id-token: write` is set on the job for the eventual OIDC
upgrade to KMS-held keys — the README documents the migration shape.

Required GitHub secrets:
  LAYERSWAP_PRIVATE_KEY_PEM  - ECDSA P-256 private key whose public
                               half is baked into @layerswap/widget-react
  VERCEL_TOKEN               - deploy-rights API token
  VERCEL_ORG_ID
  VERCEL_PROJECT_ID

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the last security gap: signing the manifest only proved the
manifest was authentic. An attacker who controlled the CDN could keep
our signed manifest unchanged and replace any downstream chunk it
referenced — the loader would happily execute the tampered bytes.

This commit extends the (signed) manifest with a SHA-384 hash for
every JS file in the build:

  {
    "version": "0.1.0",
    "remoteEntry": "./remoteEntry.js",
    "chunks": {
      "remoteEntry.js":            "sha384-…",
      "__federation_expose_Widget.6ba26f6e.js": "sha384-…",
      "1499.09849eef.js":          "sha384-…",
      …253 entries…
    },
    "killSwitch": false,
    "signature": "…"
  }

At runtime, after the signature on the manifest is verified, the loader
installs an interceptor on `HTMLScriptElement.prototype.src` /
`setAttribute('src', …)`. Any time MF (or anything else) sets a script's
src to a URL under our CDN origin, the interceptor:

  - Finds the filename in the manifest's chunks map
  - Sets `integrity="sha384-…"` and `crossorigin="anonymous"` on the
    element before the browser starts fetching
  - For known-origin URLs not in the manifest, sets a deliberately
    invalid integrity so the browser refuses to execute

The browser does the verification natively — fast (~5-15 ms per chunk
on modern hardware, hidden behind network), reliable, and using the
same SRI machinery `<script integrity>` already provides.

build-manifest.mjs walks dist/<channel> and computes SHA-384 over every
.js file before signing. verify-manifest.mjs (CI sanity check) rehashes
each file on disk and confirms it matches what the manifest claims —
catches "the manifest was signed with stale hashes" bugs before deploy.

Tested end-to-end with a tampered chunk on disk:
  WIDGET_MOUNTS: 0
  console.error: Failed to find a valid digest in the 'integrity'
    attribute for resource '…__federation_expose_Widget.6ba26f6e.js'
    with computed SHA-384 integrity '…'. The resource has been blocked.
  ChunkLoadError surfaces to onError, fallback UI shows.

Restored the file: WIDGET_MOUNTS: 1, no SRI blocks, no errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ontext

Security & correctness:
- EVM: validate active account against connector's tracked accounts list
- Starknet: validate node_url is a well-formed https/wss URL before RpcProvider
- Starknet: move hydration re-entrancy guard to top of attemptHydration
- Paradex: validate persisted paradexAddress format; drop tampered entries
- TON: wrap Address.parse in try/catch, return undefined on malformed input
- Replace silent `// TODO` + console.log catches with console.error across
  EVM, Bitcoin, SVM, TRON, TON, Fuel, Starknet services

Architecture & React:
- Fuel: use scoped fuelStore for connectedWallets instead of global useWalletStore
- resolverContext: move setProviders side effect out of useMemo into useEffect;
  memoize provider arrays; back isInitialized with real post-commit state
- createConnectionStoreFromHook: reset store to EMPTY_PROVIDER on unmount/destroy
- LayerswapProvider: stabilize loadById/loadAll via ref to stop context cascade
- WidgetWrapper: hoist walletConnectConfigs to module scope
- Memoize createXXXProvider calls in playground + nextjs examples

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CRITICAL: canonicalize wasn't actually covering the chunks map.

`JSON.stringify(body, Object.keys(body).sort())` uses the array form
of the replacer, which is a PROPERTY ALLOWLIST applied recursively to
every nested object — not just the top level. The allowlist contained
the top-level field names (chunks, killSwitch, remoteEntry, signature,
version). When stringify recursed into `body.chunks`, it filtered
those keys against the same list — every chunk filename in
`chunks: { "1499.abc.js": "sha384-…", … }` got stripped because the
filenames aren't field names. Result: the signed bytes contained
`"chunks": {}`. Per-chunk hashes were not signed.

An attacker controlling the CDN could swap a chunk, update its entry
in the manifest's chunks map to the new hash, leave `signature`
untouched (it still verified because the canonical form excluded all
chunk entries), and the loader would set integrity to the attacker's
hash and run the malicious bytes. This defeated the entire CDN-tamper
protection that the previous SRI commit claimed to deliver.

Fix: hand-written recursive deterministic JSON serializer (sort keys
at every level, normalize undefined to null). Identical implementation
in three sites that all must agree byte-for-byte:
  - apps/widget-cdn/scripts/build-manifest.mjs (signer)
  - packages/widget-react/src/manifest.ts       (loader verifier)
  - apps/widget-cdn/scripts/verify-manifest.mjs (CI round-trip)

Old signatures don't verify under the new code — that's correct.
Re-sign after deploy.

Also addressed from the same review:

- vercel.json: added `/v1/manifest.json` CORS rule (was missing — the
  smoke step `grep access-control-allow-origin` would have failed on
  a real deploy).
- vercel.json: parameterized all source patterns over a channel
  segment matching /v[0-9][0-9.]*/, so workflow_dispatch with
  channel=v1.3.0 (or any future major) gets the same CORS + cache
  headers automatically. Previously every chunk under a non-v1
  channel would have shipped without CORS, and the SRI interceptor's
  crossorigin="anonymous" would then make the browser block every
  chunk → widget completely broken on any non-default channel.
- sri.ts: replaced the malformed `sha384-INVALID-UNMANIFEST-CHUNK`
  sentinel with a syntactically valid SHA-384 SRI value
  (`sha384-${'A'.repeat(64)}`). Malformed values may be discarded as
  unparseable, leaving an empty metadata set and silently allowing
  the resource. Also added `crossorigin="anonymous"` in the same
  branch so CORS is consistent.
- sri.ts: dropped the dead HTMLElement.prototype `src` fallback.
- sri.ts: added a comment in lookupHash flagging the basename-only
  match assumption.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Make `manifest` the only public way to load the widget. Drops the
`remoteEntry` prop / `VITE_LAYERSWAP_REMOTE_ENTRY` escape hatch that
bypassed the manifest layer (and thus kill-switch + signing).

To keep local dev working, the widget-cdn rspack dev server now emits an
unsigned `manifest.json` next to `remoteEntry.js`, so dev uses the same
manifest path as prod instead of the legacy direct path.

- LayerswapWidget: `manifest` now required; drop `remoteEntry` from the
  Loadable union, resolveSource, destructure, and useMemo deps.
- widget-cdn rspack.config: dev-only plugin emits manifest.json.
- example host: default manifest to the local dev server; verify driven
  by VITE_LAYERSWAP_VERIFY (off by default since the dev manifest is
  unsigned).
- Docs updated to the manifest-only flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `walletProvidersConfig.include` alongside the existing `exclude`.
`include` keeps only the listed provider ids; `exclude` still drops
them. When both are set, `include` is applied first then `exclude`
subtracts. Filtering happens before any `loadProvider()` call, so chains
left out never dynamic-import their SDK — an `include` list gets the same
lazy-loading win as `exclude`.

EVM wagmi-config adoption now only replaces EVM when it survives the
filters, instead of force-injecting it, so the lists are respected
literally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
layerswap-embed-example Ready Ready Preview, Comment Jun 30, 2026 12:27pm
layerswap-widget-cdn Error Error Jun 30, 2026 12:27pm
layerswapapp Error Error Jun 30, 2026 12:27pm

Request Review

babkenmes and others added 2 commits June 25, 2026 20:37
- manifest: rename PLACEHOLDER_PUBLIC_KEY_SPKI_B64 → MANIFEST_VERIFY_PUBLIC_KEY_SPKI_B64
  (it is the live trust anchor, not a throwaway) and gate fetch caching on verify
- workflow: pass VERCEL_TOKEN via env instead of inline --token CLI arg
- evm: reject empty connector accounts instead of bypassing address validation
- runtime: SSR-guard initRemote/loadWidget; pin shared React to ^18 || ^19
- widget-react: narrow sideEffects to sri.ts so the SRI patch isn't tree-shaken;
  relax node engines to >=18
- vercel: add HSTS, X-Content-Type-Options, X-Frame-Options headers
- resolverContext: consolidate 6 provider memos into one stable object
- popover: useLayoutEffect for container portal to avoid mount flash
- LayerswapWidget: move onReady de-dup guard to a parent ref so it fires once

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces two packages and refactors widget-react to a thin wrapper, so the
CDN widget can be embedded from any environment with no host framework and the
loaders carry no dependency on the heavy @layerswap/widget package.

@layerswap/widget-js — framework-agnostic loader
- mountWidget(target, props, { manifest, verify }) → { update, destroy };
  works from plain <script>, Vue, Angular, Svelte, etc. The remote owns its
  own React root via a new './mount' CDN expose, so the host needs no React.
- Owns the security-critical loader primitives (manifest fetch/verify,
  canonicalization, runtime SRI interceptor), extracted out of widget-react so
  there is a single source of truth — no risk of the signing/verification
  paths drifting between copies.
- runtime.initRemote takes an optional shared map: vanilla hosts let the remote
  bundle its own React; React hosts share theirs.

@layerswap/widget-types — zero-runtime contract
- Single source of truth for the widget's public type surface (theme, config,
  callbacks, props). Theme types move here; @layerswap/widget re-exports them.
- The widget's LayerswapWidgetConfig now refines the contract
  (WidgetConfig & { settings: LayerSwapSettings; ... }) so the public and
  internal types can't structurally diverge — drift fails to compile.
- Deep fields (settings, initialValues, callback payloads) stay open so the
  package has no @layerswap/widget coupling.

widget-react
- Now depends only on widget-js (and re-exports its types); dropped the
  @layerswap/widget peer + devDep entirely. Shares the host's React (^18||^19)
  with the remote via the new shared map.

CDN comments reframe widget-js as the shared loader core (source of truth for
the key + canonicalization), not "the loader", since multiple loaders consume
the signed manifest. verify-manifest.mjs reads the public key from widget-js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace Vercel static hosting with an R2 object store fronted by a
Cloudflare Worker so widget builds are versioned, pinnable, and
rollback-able without a rebuild.

- Each build publishes to an immutable `dist/<version>/` prefix in R2,
  never overwritten. A single mutable `channels.json` maps rolling major
  channels (`/v1/`) to the current version; the Worker 302-redirects.
- Loader resolves `remoteEntry` against the post-redirect URL so the
  remote + chunks anchor at the immutable version path.
- Manifest now carries a real version (from @layerswap/widget) plus
  channel/gitSha/builtAt provenance.
- New Worker (worker/) serves R2 with immutable caching + CORS; new
  deploy-r2/rollback-r2 scripts; CI workflow rewritten Vercel -> R2.
- Rotate manifest signing keypair (public half baked into widget-js).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pinned version conflicted with package.json's packageManager field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ader

Integrators no longer need to pass the CDN manifest URL. `manifest` is now
optional, defaulting to the canonical Layerswap CDN (DEFAULT_MANIFEST_URL,
rolling v1 channel), and `verify` defaults to true since that endpoint is
signed. Override either to pin a build or target a local dev server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant