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()andconnect({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'sBACKEND_URL, synchronizerId fetched lazily from the backend/healthendpoint and cached for the SW lifetime, chainId mirrorsnetworkto match the canonical@zoffwallet/provider-interfaceSupportedNetworkenum). When a dApp passesexpectedNetworktoconnect(), the SW rejects witherr.code === 'NETWORK_MISMATCH'+err.details = {walletNetwork, expectedNetwork}if the wallet is on a different network — same shape as the web-sideWalletError, so a single dApp check works across both transports. The same check fires again at the start ofsignAndSubmit()(defense in depth — catches the wallet being re-deployed against a different network between connect and sign). The persisted approved-origin entry now carriesexpectedNetworkso 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_NETWORKset, butNEXT_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.tsxresolvedHELVETSWAP_ENABLEDtofalseand rendered<EmptyState title="Swap is coming soon" />, and (2) thewebpack.NormalModuleReplacementPlugininnext.config.mjssubstitutedHelvetSwapPanelwith 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'sbuild.args:block from/opt/zoff/.env.devnet, which is why the swap panel always worked there. Workflow now exports the fourNEXT_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
keyfield 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 singlePUBLISHED_EXTENSION_IDenv 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-keykeyfield tomanifest.extension.json. The extension ID is now derived deterministically from this key via SHA-256 → translate0-9a-ftoa-p, producing the stable IDbibldddejemeimhkncfcjhldmceiafdjregardless 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_IDnow 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 inbackend/src/plugins/cors.plugin.tsnow 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'swidth={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,
directionis now plumbed throughuseHelvetswapSwap.submit(): AtoB routes the prepare call withsymbol: 'CC'(backend'sCcTransferService→ Splice scan proxy) and writesmemo.direction: 'AtoB'; BtoA keeps the existingsymbol: 'CBTC'path (Cip56TransferService→ DA Utilities registry). Both backend paths land the memo at the samemeta.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-sideHELVETSWAP_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. RetiresNEXT_PUBLIC_HELVETSWAP_POOL_CID(the CID is now live-fetched) and the operator-onlyzoff.helvetswap.forcelocalStorage 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.NormalModuleReplacementPluginsubstitution. 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=trueAND 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. CSPconnect-srctrimmed: 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/inspectcache 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. BumpedPOLL_TIMEOUT_MSto 180s to match Helvetswap's ownintegrator-polling.md30s-horizon + backlog headroom. Separately, the sharedButtoncomponent'sloadingprop 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-wideuseToast. - Helvetswap swap panel now backs off when
/api/swap/inspect429s. 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 theresult.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/publicresponse with fieldsreserveA/reserveB/feeRate, but the actual Helvetswap API returnsreserveAmountA/reserveAmountB/feeBps(basis points). The panel read both as undefined, defaulted to0, and the constant-product quote math returned0for any input — leaving the Swap button disabled. Added a wire-shape adapter inhelvetswap-client.ts:HelvetswapPoolRawcaptures the actual JSON shape,fetchHoldingPools()normalizes to the panel's internalHelvetswapPoolinterface, convertingfeeBps / 10000into the decimalfeeRatethe quote math expects. Panel + hooks unchanged. - CSP
connect-srcnow allows the Helvetswap AMM backend + DA Utilities registry. PR #44 shipped the Helvetswap swap panel withNEXT_PUBLIC_HELVETSWAP_API_URLas 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 withRefused 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.mjsnow includes${NEXT_PUBLIC_HELVETSWAP_API_URL}andhttps://api.utilities.digitalasset-dev.comin theconnect-srcdirective; 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-prodpool. 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/executeendpoints (symbol: 'CBTC', memo JSON written tosplice.lfdecentralizedtrust.org/reason— the key Helvetswap'sSwapPollerreads per project memory), and polls/api/swap/inspectfor terminal success (result.swap.status === 'completed'ANDresult.consume.ok === true). Builds on the three wallet fixes shipped in #39 and the live-smoke green recorded 2026-04-23. Gated behindNEXT_PUBLIC_HELVETSWAP_ENABLED=trueon devnet; Mainnet stays on the "Swap is coming soon" EmptyState. RequiresNEXT_PUBLIC_HELVETSWAP_API_URL,NEXT_PUBLIC_HELVETSWAP_POOL_CID,NEXT_PUBLIC_HELVETSWAP_POOL_ID,NEXT_PUBLIC_HELVETSWAP_DEX_PARTYat build time. Direction isBtoAonly (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/challengenow bind the presented public key to the claimedpartyIdvia 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 thepartyId's namespace. An attacker who knew any targetpartyIdcould 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))) inbackend/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-shapepartyIds continue to work via the existing exact-matchpublicKeycheck. 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.listreturns 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
unlockKeystorecall 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 newunlockActiveKeypairhelper that branches on keystore shape. - No more 401 noise during PK-import onboarding. The non-blocking
prepare-transfer-preapprovalsetup 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_RAWand drifting from the wallet SDK onhashingSchemeVersion,verboseHashing,deduplicationPeriod, andminLedgerTimeAbs. 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
partyIdfield 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 thecanton-wallet-accountslocalStorage mirror when the keystore lacks apartyId.
0.5.3 - 2026-04-21
Fixed
- Released extension zip now targets
api.devnet.zoff.appinstead ofapi.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 localbuild:extension:prodnpm 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 intopopup.htmlthrough the shared root layout, and Chrome MV3 blocks all remote scripts regardless of CSP. Umami is now skipped at build time whenBUILD_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-motionopacity: 0SSR state was outrunning hydration on long-form text pages./docs/changelogno longer 404s in production —CHANGELOG.mdis 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
encryptedMnemonicanditerationsfields
Changed
- Extension build script unified — accepts
NEXT_PUBLIC_API_URLandNEXT_PUBLIC_CANTON_NETWORKenv 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)
accountChangedprovider 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_IDenv var support - Incremental balance polling with cursor pagination (no longer replays full ledger history)
Changed
- dApp
signAndSubmitfor CC transfers now routes through the dedicated/transfer/preparepath 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.cantonWalletinjection - 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.appdomain (not just subdomains) - Extension icons updated to bread emoji