Back to docs

Changelog

All notable changes to the Zoff Wallet extension will be documented in this file.

Format follows Keep a Changelog.

Unreleased

0.5.12 - 2026-04-26

Added

  • window.cantonWallet.getNetwork() and connect({expectedNetwork}) — the extension surface now mirrors the two safety APIs added on the web side (bakery#72) so dApps loaded through the Chrome extension get the same cross-network signing protection as dApps loaded through the wallet web page. getNetwork() returns {network, synchronizerId, chainId} (network derived from the SW's BACKEND_URL, synchronizerId fetched lazily from the backend /health endpoint and cached for the SW lifetime, chainId mirrors network to match the canonical @zoffwallet/provider-interface SupportedNetwork enum). When a dApp passes expectedNetwork to connect(), the SW rejects with err.code === 'NETWORK_MISMATCH' + err.details = {walletNetwork, expectedNetwork} if the wallet is on a different network — same shape as the web-side WalletError, so a single dApp check works across both transports. The same check fires again at the start of signAndSubmit() (defense in depth — catches the wallet being re-deployed against a different network between connect and sign). The persisted approved-origin entry now carries expectedNetwork so the sign-time re-check survives SW kills. Confirmed against Helvetswap's integration contract with Toiki, 2026-04-26.

0.5.11 - 2026-04-25

Fixed

  • Swap tab now actually loads in the extension popup (was rendering the "Coming soon" empty state). The release-extension workflow built the bundle with only NEXT_PUBLIC_API_URL + NEXT_PUBLIC_CANTON_NETWORK set, but NEXT_PUBLIC_HELVETSWAP_ENABLED (and the three sibling Helvetswap config vars) were never exported in the GitHub Actions step. Without the flag, two things broke at compile time: (1) swap/page.tsx resolved HELVETSWAP_ENABLED to false and rendered <EmptyState title="Swap is coming soon" />, and (2) the webpack.NormalModuleReplacementPlugin in next.config.mjs substituted HelvetSwapPanel with the no-op stub, eliding the panel + its CSS + the Cormorant/DM Mono fonts from the bundle entirely. The webapp build had been picking up the values via docker-compose's build.args: block from /opt/zoff/.env.devnet, which is why the swap panel always worked there. Workflow now exports the four NEXT_PUBLIC_HELVETSWAP_* vars matching the devnet env file (ENABLED=true, API_URL=https://devnet-api.helvetswap.app, POOL_ID=cbtc-amulet-prod, DEX_PARTY=ClearportX-DEX-1::1220...43b37). Mainnet builds will need to override these whenever the extension build target flips off devnet.

0.5.10 - 2026-04-25

Fixed

  • Extension ID is now stable across reinstalls. Without a key field in the manifest, Chrome assigned a new random ID every time you removed + reloaded the unpacked extension — and the backend's CORS allowlist (gated on a single PUBLISHED_EXTENSION_ID env var) only accepts one specific ID. Result: every reinstall produced a 500 cascade on every API call (Allocation failed, CORS: origin not allowed) and required either re-onboarding from seed or an ops update to the env var. Generated a 2048-bit RSA keypair and committed the public-key key field to manifest.extension.json. The extension ID is now derived deterministically from this key via SHA-256 → translate 0-9a-f to a-p, producing the stable ID bibldddejemeimhkncfcjhldmceiafdj regardless of who loads the extension or where on disk. The old random-ID rollout is supported during transition by the comma-separated env var below; once everyone's on v0.5.10 the legacy IDs can be removed from the allowlist.
  • Backend PUBLISHED_EXTENSION_ID now accepts a comma-separated list of allowed extension IDs. Previously it was strictly single-valued, so during a key rollout (legacy random ID → new deterministic ID) the operator had to choose between breaking the old install and breaking the new one. The CORS plugin in backend/src/plugins/cors.plugin.ts now splits on ,, trims each, and matches the request origin against the resulting set. A single id (no commas) still works exactly as before — backwards-compatible. Also useful for parallel testing: keep the production ID alongside a developer's random ID without redeploying CORS config.

0.5.9 - 2026-04-25

Changed

  • Wallet tab page headings removed on Activity, Send, Receive, Settings. The sidebar nav already labels the active tab; the in-page H1/H2 was redundant visual noise (matches the pattern set by Swap in ext-v0.5.6). The Dashboard's "Good morning, X" greeting stays — it's the personalized landing line, not a generic page title. Net effect: every non-dashboard tab opens straight into content, with the active tab's identity carried by the sidebar.
  • Helvet swap card now sits at the same horizontal position as the Send card. The swap surface was hugging the wallet content area's left edge while the Send tab's card was 480px-centered inside its 672px wrapper, making the gap to the sidebar visibly different between tabs. Mirrored Send's inner-wrapper pattern (maxWidth: 480, margin: '0 auto') on the swap page so the Helvet panel sits in the same x position as Send. Extension popup (360px viewport) is unaffected — the inner max-width only kicks in above 480px.

Fixed

  • CC and CBTC token avatars now render at the same effective size everywhere they appear (Holdings list, Send token selector, Activity rows, Helvet panel coin slots). The cBTC bitmap shipped with ~5.5% transparent padding inside its 1024×1024 canvas — fill 89% × 88% — while the cc bitmap is edge-to-edge at 100% × 100%. At a fixed render size the cBTC disc came out ~11% smaller. Trimmed cbtc.png to its content bounding box (now 912×905, fill 100% × 100%); both render at identical effective sizes through TokenIcon's width={size} height={size} path. Bonus: file size 1.4 MB → 1.1 MB.
  • Settings footer doodle removed. The decorative gear above the "Zoff Wallet" footer label felt redundant with the wallet's identity already established throughout the surface. The other DoodleGear instances (used as row icons within Settings) are unaffected.

0.5.8 - 2026-04-25

Added

  • Helvetswap swap panel now supports CC → CBTC (AtoB direction). Added a Uniswap-style flip arrow between the "You pay" and "You receive" rows — one click swaps the input/output tokens, reserves, balance lookup, and decimals. The toggle is disabled while a swap is in-flight so users can't flip mid-polling. Default remains BtoA (CBTC → CC) so first-paint is unchanged. On the wire, direction is now plumbed through useHelvetswapSwap.submit(): AtoB routes the prepare call with symbol: 'CC' (backend's CcTransferService → Splice scan proxy) and writes memo.direction: 'AtoB'; BtoA keeps the existing symbol: 'CBTC' path (Cip56TransferService → DA Utilities registry). Both backend paths land the memo at the same meta.values["splice.lfdecentralizedtrust.org/reason"] key SwapPoller reads, so the DEX-side flow is symmetric. No backend changes.
  • Helvetswap live pool fetch via backend proxy. The wallet now sources the on-ledger HoldingPool contract ID (CID) from our canton-wallet backend at GET /helvetswap/pool, which authenticates with Helvetswap's private integrator endpoint using a server-side HELVETSWAP_INTEGRATOR_KEY. The key never reaches the browser bundle, so scraping the Zoff frontend can't exfiltrate it. Every swap refetches the CID immediately before building the memo (the pool rotates atomically on each settlement — a cached CID becomes single-use) and retries once on contract-not-found — the expected race under concurrent users. Key rotation is a backend env-file edit + docker restart zoff-backend-1; no frontend rebuild required. Retires NEXT_PUBLIC_HELVETSWAP_POOL_CID (the CID is now live-fetched) and the operator-only zoff.helvetswap.force localStorage override (the stale-CID footgun that made it necessary is gone).

Changed

  • Helvetswap swap panel got Toiki's brand skin: Alpine Ink + Champagne Gold. The swap surface on both webapp and extension popup now renders Helvet's institutional aesthetic — Cormorant Garamond italic display type for "Sovereign / Instant Exchange" eyebrow + amount inputs, DM Mono uppercase tracked labels and CTA, alpine ink (#0B0B0D) backgrounds with a champagne-gold (#C4A264) cross-field pattern + 2.2% film grain texture. The Toiki badge logo replaces the generic Swiss cross at the brand row; a 9s slow-pulsing gold neon halo traces the panel's rounded silhouette to lift it off the dark Zoff chrome. Zoff's bakery shell (sidebar, popup header, bottom nav) stays untouched per the partner-skin contract — the visual rupture between the warm Zoff frame and the cool Helvet panel is intentional. Vocabulary follows the press kit: "Settlement Fee" / "Atomic · Instant" / "Review Exchange" / "You Send / You Receive". Bundle cost ≈ 48 KB gzipped on first swap visit (Cormorant italic 300 + DM Mono 400/500 latin subsets + the panel chunk + Helvet CSS); flag-off mainnet bundles ship zero Helvet artifacts via a webpack.NormalModuleReplacementPlugin substitution. Wire layer (useHelvetswapSwap, useHelvetswapPool) untouched — the same state machine that green'd 2026-04-23 drives the new visuals.
  • Helvetswap swap panel is now visible to every devnet visitor. The panel previously required both NEXT_PUBLIC_HELVETSWAP_ENABLED=true AND a localStorage override (zoff.helvetswap.force=1). With live CID fetch retiring the stale-CID footgun, the override is gone and the panel is public on devnet when the build arg is enabled. CSP connect-src trimmed: the DA Utilities registry origin (api.utilities.digitalasset-dev.com) is no longer required — the DEX backend fetches that choice-context server-side, our browser never touches it.

Fixed

  • Helvetswap swap panel waits long enough for the DEX to confirm + surfaces live progress. The poll window was calibrated to 60s against a lightly-loaded SwapPoller (2026-04-24 morning's green completed in ~21s). Same-day afternoon smokes under concurrent-user load landed on-ledger fast but Helvetswap's public /api/swap/inspect cache took >60s to flip 404→200, so the panel timed out red even though each swap had already rotated the pool CID + moved reserves byte-for-byte per AMM math. Bumped POLL_TIMEOUT_MS to 180s to match Helvetswap's own integrator-polling.md 30s-horizon + backlog headroom. Separately, the shared Button component's loading prop replaces its children with a spinner, so the "Signing… / Submitting… / Waiting for DEX…" text existed in the code but was never rendered — users saw a featureless spinner for the full window. Added an inline status chip above the button with a live elapsed-seconds counter (Waiting for DEX confirmation · 47s) so the polling window has visible progress. A success/error/timeout toast fires on terminal state (e.g. Swap complete · 0.01 CBTC → 3923.52 CC) using the existing app-wide useToast.
  • Helvetswap swap panel now backs off when /api/swap/inspect 429s. The first UI-driven smoke on 2026-04-24 green'd at the backend + ledger layers (0.1 CBTC → 42,112.59 CC, reserves moved byte-for-byte, ledger txId verified) but the panel stayed stuck in a "polling" state because the fixed 1.5s poll cadence hammered the inspect endpoint during SwapPoller's ~5s pickup window, hit Helvetswap's rate limiter, and never saw the result.swap.status === 'completed' transition. Swapped the fixed-interval poll for exponential backoff (2s initial → 1.5× per failed attempt → 8s cap), with interval reset to 2s on any 200 response so the panel stays responsive near the known ~20s completion window. The 60s total timeout is unchanged.
  • Helvetswap swap panel now displays pool reserves. PR #44 modeled the /api/holding-pools/public response with fields reserveA/reserveB/feeRate, but the actual Helvetswap API returns reserveAmountA/reserveAmountB/feeBps (basis points). The panel read both as undefined, defaulted to 0, and the constant-product quote math returned 0 for any input — leaving the Swap button disabled. Added a wire-shape adapter in helvetswap-client.ts: HelvetswapPoolRaw captures the actual JSON shape, fetchHoldingPools() normalizes to the panel's internal HelvetswapPool interface, converting feeBps / 10000 into the decimal feeRate the quote math expects. Panel + hooks unchanged.
  • CSP connect-src now allows the Helvetswap AMM backend + DA Utilities registry. PR #44 shipped the Helvetswap swap panel with NEXT_PUBLIC_HELVETSWAP_API_URL as a build arg but the frontend's Content-Security-Policy header only permitted ${NEXT_PUBLIC_API_URL} https://analytics.zoff.app. Every Helvetswap fetch (/api/holding-pools/public, the DA Utilities TransferFactory choice-context endpoint) was blocked client-side with Refused to connect because it violates the document's Content-Security-Policy. The harness (backend/scripts/smoke-helvetswap.ts) never hit this because it runs from Node — no browser, no CSP. next.config.mjs now includes ${NEXT_PUBLIC_HELVETSWAP_API_URL} and https://api.utilities.digitalasset-dev.com in the connect-src directive; both are appended only when non-empty so mainnet builds without the Helvetswap env var produce the same CSP as before.

Added

  • Helvetswap swap UI on DevNet (feature-flagged). The wallet's Swap tab now hosts a Helvetswap AMM panel that drives a CBTC → CC swap end-to-end against the Canton DevNet cbtc-amulet-prod pool. The panel renders live pool reserves + a constant-product quote, collects the user's wallet password, routes the transfer through the backend's existing /transfer/prepare + /transfer/execute endpoints (symbol: 'CBTC', memo JSON written to splice.lfdecentralizedtrust.org/reason — the key Helvetswap's SwapPoller reads per project memory), and polls /api/swap/inspect for terminal success (result.swap.status === 'completed' AND result.consume.ok === true). Builds on the three wallet fixes shipped in #39 and the live-smoke green recorded 2026-04-23. Gated behind NEXT_PUBLIC_HELVETSWAP_ENABLED=true on devnet; Mainnet stays on the "Swap is coming soon" EmptyState. Requires NEXT_PUBLIC_HELVETSWAP_API_URL, NEXT_PUBLIC_HELVETSWAP_POOL_CID, NEXT_PUBLIC_HELVETSWAP_POOL_ID, NEXT_PUBLIC_HELVETSWAP_DEX_PARTY at build time. Direction is BtoA only (CBTC → CC — the direction validated 2026-04-23); AtoB (CC → CBTC) needs the Splice scan proxy for Amulet and is deferred.

Security

  • Backend /auth/verify + /auth/challenge now bind the presented public key to the claimed partyId via Canton's namespace-fingerprint derivation. Previously, the auth flow verified that the caller's Ed25519 signature was valid under the submitted public key, but never checked that the public key actually fingerprinted to the partyId's namespace. An attacker who knew any target partyId could mint a fresh keypair, sign their own challenge with it, and receive a JWT authenticating as the target — a full auth bypass on every non-public wallet endpoint (balances, transfer prepare/execute, activity). Reported by Toiki (Helvetswap) during an unrelated audit 2026-04-22; not exploited. Fix adds an explicit fingerprint check (hex("1220" || SHA-256(uint32_BE(12) || pubkey))) in backend/src/auth/canton-fingerprint.ts, enforced at both /auth/challenge (400 on mismatch, defense in depth) and /auth/verify (401 on mismatch, primary guard) with timing-safe comparison. Sandbox / non-external-shape partyIds continue to work via the existing exact-match publicKey check. Waitlist and extension download surfaces are unaffected.

0.5.7 - 2026-04-22

Fixed

  • Dashboard no longer surfaces strangers' party IDs as your accounts. On the shared devnet participant, api.parties.list returns every locally-hosted party, not just the authenticated user's. The unlock flow was persisting that full list as "your accounts," producing phantom Accounts 1..N in the switcher and spurious 403s on every balance/activity/transfer poll. Unlock now filters to partyIds present in the local keystore; all onboarding flows explicitly overwrite the accounts mirror with just the freshly-persisted accounts.

0.5.6 - 2026-04-22

Added

  • Onboarding now supports importing via a raw Ed25519 private key, for migrating a single account from another Canton wallet. Accepts 64-char hex, 0x-prefixed hex, or 32-byte base64. Party is allocated on Canton the same way BIP39-onboarded wallets do — the backend doesn't care where the key came from.

Changed

  • Settings shows Reveal Private Key instead of Reveal Recovery Phrase. Your recovery phrase is now shown only once during wallet creation — write it down and store it safely; Zoff will not display it again. Imported wallets via private key don't have a recovery phrase. The revealed key auto-hides after 30 seconds, and the clipboard is cleared 30 seconds after copy.

Removed

  • Post-onboarding recovery phrase reveal. Seed-phrase exfiltration is the leading wallet attack vector in 2024–2025; removing the reveal surface is worth the recovery-discipline tradeoff. Per-account private-key export covers every legitimate interop need.

Fixed

  • PK-imported wallets can now unlock and sign transactions. Post-onboarding, every unlockKeystore call site (WalletProvider, extension popup, send, merge, accept transfer, dApp approve, auto-accept toggle) routed through the BIP39 seed path, which throws for keystores created via private-key import. Unified behind a new unlockActiveKeypair helper that branches on keystore shape.
  • No more 401 noise during PK-import onboarding. The non-blocking prepare-transfer-preapproval setup was called before the freshly-issued JWT was wired into the api client.

0.5.5 - 2026-04-21

Fixed

  • Sending CIP-56 tokens (cBTC, USDCx) works again. The cbtc-network:: instrument admin party ID was truncated to 64 chars in both the backend constant and two frontend files, so every send 404'd at the external token registry with "Instrument configuration not found". Accept was unaffected because it derives the admin from the on-ledger TransferOffer payload.
  • Canton interactive-submission signatures now verify on-chain. Our backend was sending the deprecated SIGNATURE_FORMAT_RAW and drifting from the wallet SDK on hashingSchemeVersion, verboseHashing, deduplicationPeriod, and minLedgerTimeAbs. Any drift causes the participant to re-hash the prepared transaction to a different value than what the client signed, surfacing as "Received 0 valid signatures from distinct keys (1 invalid)". Every interactive-submission flow — accept offers, cancel preapproval, CC/cBTC send, merge — was failing before this fix.

0.5.4 - 2026-04-21

Fixed

  • Unlocking a wallet whose keystore predates the partyId field no longer skips the backend auth flow. Previously such wallets would silently unlock without a JWT, and every background poll (balances, transfer offers, activity) would 401. The unlock flow now falls back to the canton-wallet-accounts localStorage mirror when the keystore lacks a partyId.

0.5.3 - 2026-04-21

Fixed

  • Released extension zip now targets api.devnet.zoff.app instead of api.zoff.app. The release workflow was hardcoding the mainnet API URL, so every popup API call 500'd on CORS (mainnet is in maintenance and doesn't know the loaded extension's ID). Matches the local build:extension:prod npm script.

0.5.2 - 2026-04-21

Fixed

  • Extension popup no longer emits a CSP violation on load — the Umami analytics <script> was being baked into popup.html through the shared root layout, and Chrome MV3 blocks all remote scripts regardless of CSP. Umami is now skipped at build time when BUILD_TARGET=extension.
  • Wallet no longer enters a silently-broken "unlocked without a token" state when backend authentication fails mid-unlock. Previously every background poll (balances, transfer offers, activity) would then 401 forever with no visible cause; now the wallet stays on the lock screen and surfaces an error so the user can retry.

0.5.0 - 2026-04-17

Added

  • Privacy policy page at /privacy — required disclosure for the Chrome Web Store listing.
  • Screenshot capture mode for store assets (dev-only, gated behind two env flags; see SCREENSHOT_MODE.md).

Changed

  • Extension manifest description rewritten for clarity — the prior slogan is replaced with a functional product sentence Chrome Web Store reviewers can parse quickly.
  • Manifest now advertises homepage_url: https://zoff.app.
  • Extension icons re-rendered at their true filename dimensions (16/48/128 px) for sharper rendering in Chrome's toolbar and the Store listing.
  • Extension build strips legacy marketing assets from the shipped zip (privacy-policy.html, prior release artifacts), yielding a ~1.4 MB package.

Fixed

  • /privacy, /docs/changelog, and /docs/[slug] pages now render visibly on first paint — the framer-motion opacity: 0 SSR state was outrunning hydration on long-form text pages.
  • /docs/changelog no longer 404s in production — CHANGELOG.md is now included in the frontend Docker image.
  • Backend CI passes cleanly again (a dual-mode balance test mock was out of sync with the devnet update-stream migration).
  • Extension build script reliably exits 0 on success (cleanup trap was leaking a non-zero status from a guarded mv).

0.4.1 - 2026-04-16

Fixed

  • Copy address button now visible in extension popup account dropdown
  • Merge button hidden for CIP56 tokens (cBTC, USDCx) in settings page
  • Extension keystore type definition now includes encryptedMnemonic and iterations fields

Changed

  • Extension build script unified — accepts NEXT_PUBLIC_API_URL and NEXT_PUBLIC_CANTON_NETWORK env vars for any target environment
  • Extension CI builds automatically on every push to main (devnet artifact)

0.4.0 - 2026-04-14

Added

  • Two-tier recovery in Settings: "Reveal Recovery Phrase" for new wallets, "Export Private Key" for legacy wallets
  • Idle auto-lock after 5 minutes of inactivity (web app and extension)
  • accountChanged provider event now fires when user switches account in the wallet
  • Merge and self-transfer detection in activity feed (labeled "Merged" instead of misleading "Received")
  • Swap page shows "Coming Soon" state with sidebar badge
  • CORS production configuration with PUBLISHED_EXTENSION_ID env var support
  • Incremental balance polling with cursor pagination (no longer replays full ledger history)

Changed

  • dApp signAndSubmit for CC transfers now routes through the dedicated /transfer/prepare path instead of generic /tx/prepare, fixing dApp-initiated CC sends
  • UpdateStreamService refactored to module-level singleton with per-party offset and balance cache

Fixed

  • Lock Wallet button now works correctly (UnlockTransition phase reset on re-lock)
  • JWT expiry (401) now auto-relocks the wallet silently instead of showing "Unauthorized" error
  • Activity feed correctly displays transaction history after auth refresh
  • Sandbox activity view no longer hardcodes all transactions as "sent"

0.3.0 - 2026-04-11

Added

  • Initial public release of Zoff Wallet Chrome extension
  • Self-custodial BIP39 seed phrase generation and AES-256-GCM encrypted storage
  • Send, receive, and swap Canton Coin on DevNet
  • dApp connector via window.cantonWallet injection
  • Side panel and popup UI modes
  • Bridge sync between web app and extension
  • Multi-account support with BIP32-Ed25519 key derivation
  • Transaction history and activity feed
  • Dark/light theme toggle

Fixed

  • Extension bridge now syncs on bare zoff.app domain (not just subdomains)
  • Extension icons updated to bread emoji