Qubic Wallet Browser Extension (Chrome MV3), built with React, TypeScript, Vite, Tailwind, and Bun.
- Bun
>= 1.3 - Node
>= 22(required for semantic-release) - Chrome (or Chromium-based browser with extension developer mode)
- Install dependencies:
bun install
- Run web dev mode:
bun dev
- Build extension bundle:
bun run build
bun dev: Vite dev serverbun run dev:extension: extension watch build todist-dev/bun run build: typecheck + production build todist/bun run lint: Biome checksbun run format: Biome formattingbun run preview: local preview server
- Run
bun run build. - Open
chrome://extensions. - Enable
Developer mode. - Click
Load unpacked. - Select the
dist/folder.
Use this when you want live updates without reloading from scratch.
- Run:
bun run dev:extension
- In
chrome://extensions, loaddist-dev/once. - Keep the popup/sidepanel open and iterate while watch mode rebuilds.
public/
_locales/ # Chrome Web Store listing strings (__MSG_*__ lookups)
branding/ # logos and brand assets
icons/ # extension icons
manifest.config.ts # source of truth for manifest.json (generated at build)
src/
app/ # app providers/setup
components/ # reusable UI and feature components
extension/ # MV3 entry points (background, content-script, inpage-provider)
hooks/ # shared React hooks
lib/ # wallet/business/storage helpers
lib/dapp/ # dApp provider: controller, signing, storage, protocol
locales/ # i18n translations
pages/ # routed screens
router/ # route map + guards
styles/ # global styles/tokens
main.tsx # app entry
popup.html # popup entry point
sidepanel.html # sidepanel entry pointmanifest.json is generated at build time from manifest.config.ts, in two variants:
- Production (
bun run build) — name/description resolved via__MSG_*__frompublic/_locales/<lang>/messages.jsonwithdefault_locale: "en". - Development (
bun run dev:extension) — literal"Qubic Wallet (Dev)"name so the unpacked build coexists with the store install.
The manifest version is synced from package.json. Semver prerelease counters are mapped to Chrome's 4th version segment (e.g. 1.0.0-beta.7 → version: "1.0.0.7"), and the original string is preserved as version_name.
- Routing uses
HashRouterfor extension-safe navigation. - Wallet state is event-driven through storage + custom events (for account/lock updates).
- Watch-only and vault-backed accounts are both supported.
- Pending transactions are persisted locally and reconciled against network tick data.
- Sensitive vault data is managed through the SDK vault store.
- Passphrases/seeds are handled in-memory only during active flows and cleared on completion paths.
- App reset now clears wallet-specific keys only (scoped cleanup), not all origin storage.
- RPC calls are scoped to
https://rpc.qubic.org/*. The extension also requests broad host permissions (http://*/*,https://*/*) to inject the dApp provider into web pages.
The extension injects window.qubic into regular web pages (http/https).
connect(): Promise<{ connected: true; origin: string }>disconnect(): Promise<{ disconnected: true }>getAccount(): Promise<{ identity: string; name?: string } | null>signMessage(params): Promise<{ signatureHex: string; digestHex: string }>signTransaction(params): Promise<{ txId: string; targetTick: number; txBytesBase64: string; txBytesHex: string }>sendTransaction(params): Promise<{ txId: string; targetTick: number; txBytesBase64: string; txBytesHex: string; networkTxId: string; broadcast: unknown }>
window.qubic.on('accountChanged', cb)— fires with{ identity: string; name?: string }when the user switches to an approved account, ornullwhen the new account is not approved for this origin (the dApp should treatnullas "no account available" and promptconnect()again)window.qubic.on('disconnect', cb)— fires when the origin is disconnected
on() returns an unsubscribe function. You can also call off(event, cb) to remove a listener.
Basic usage:
const provider = (window as Window & { qubic?: any }).qubic
if (!provider?.isQubic) {
throw new Error('Qubic Wallet extension not found')
}
await provider.connect()
const account = await provider.getAccount()
// signMessage accepts a plain string or an object with message/hex/base64
const signedMessage = await provider.signMessage('hello qubic')
// await provider.signMessage({ message: 'hello qubic' })
// await provider.signMessage({ hex: '0x48656c6c6f' })
// await provider.signMessage({ base64: 'SGVsbG8=' })
// signTransaction: sign only (you broadcast)
const signedTx = await provider.signTransaction({
toIdentity: 'DESTINATION_IDENTITY',
amount: '1',
// optional
targetTick: 123456,
inputType: 0,
// optional bytes: Uint8Array | number[] | hex string | base64 string
// inputBytes: new Uint8Array([...]),
})
// sendTransaction: sign + broadcast via the wallet
const sentTx = await provider.sendTransaction({
toIdentity: 'DESTINATION_IDENTITY',
amount: '1000',
// optional: explicit tick or offset (default offset: 10)
// targetTick: 123456,
// targetTickOffset: 10,
})
// listen for account changes
const unsub = provider.on('accountChanged', (account) => {
if (account) {
console.log('switched to:', account) // { identity: string; name?: string }
} else {
// new account is not approved for this origin — prompt connect() again
console.log('account not available, call connect() to approve')
}
})
// unsub() to stop listeningError handling:
try {
await window.qubic.signMessage({ message: 'hello' })
} catch (error: any) {
console.error(error.code, error.message)
}Common provider error codes:
NOT_CONNECTED— origin not connected or account not approvedUSER_REJECTED— user declined the approval requestINVALID_PARAMS— invalid or missing method parametersINVALID_PASSPHRASE— incorrect wallet passphraseWATCH_ONLY_ACCOUNT— active account cannot signNO_ACCOUNT— no active account selectedMETHOD_NOT_SUPPORTED— unknown provider methodUNSUPPORTED_ORIGIN— sender origin not allowedINVALID_REQUEST— malformed request or too many pending requestsINTERNAL_ERROR— unexpected wallet error
signMessage params:
string— plain text to sign{ message: string }— plain text{ hex: string }— hex-encoded bytes (must start with0x, even length){ base64: string }— base64-encoded bytes
signTransaction / sendTransaction params:
| Param | Type | Required | Notes |
|---|---|---|---|
toIdentity |
string |
yes | 60-char uppercase Qubic identity |
amount |
string | number | bigint |
yes | Must be > 0 for simple transfers (inputType 0) |
targetTick |
number |
no | Explicit target tick. If omitted, resolved automatically (sendTransaction only) |
targetTickOffset |
number |
no | Offset from current tick (1-60, default 10). Ignored if targetTick is set. sendTransaction only |
inputType |
number |
no | Smart contract input type (0 = simple transfer) |
inputBytes |
Uint8Array | number[] | string |
no | SC input data. String is parsed as hex (0x...) or base64 |
Notes for dApp developers:
connectrequires user approval (approve/reject) in the extension.connectalso requires an active account. If no account is available, the request fails withNO_ACCOUNT(no approval/onboarding popup is opened).signMessage,signTransaction, andsendTransactionrequire user approval and wallet passphrase confirmation.signTransactionreturns signed bytes only. Broadcasting is handled by your app/backend.sendTransactionsigns and broadcasts in one step. It resolves the target tick automatically if not provided (usingtargetTickOffset, default 10).- Permissions are per-account:
connectapproves the currently active account for the requesting origin. Switching to a different account requires the dApp to callconnectagain.
- Requests requiring approval are persisted so they survive MV3 service worker restarts.
- Final results are stored short-term and polled by the content script.
- Pending signing payloads are encrypted at rest in
chrome.storage.localusing a key stored inchrome.storage.session. - The page/content-script bridge is scoped with a per-page session token to reduce
window.postMessagespoofing risk.
bun run build- Reload extension in
chrome://extensions - Ensure the wallet has an active account selected.
- Open a dApp page on
http://localhost:*orhttps://... - Run:
window.qubicawait window.qubic.connect()await window.qubic.getAccount()await window.qubic.signMessage({ message: 'hello' })await window.qubic.sendTransaction({ toIdentity: '...', amount: '1' })
Connected websites can be managed in Settings -> Connected sites, including the per-site authorized account list.
For a full interactive test app, see wallet-extension-dapp.
Husky hooks run automatically:
- Pre-commit: Format and lint staged files (
lint-staged) - Pre-push: TypeScript compilation check (
tsc -b)
Bypass with --no-verify if needed.
Follow Conventional Commits: <type>(<scope>): <description>
Examples: feat: add feature, fix(accounts): resolve bug, chore: update deps
Valid types: feat, fix, chore, refactor, docs, test, perf, ci, build, revert
bun run lint
bunx tsc -b
bun run build- If popup looks stale, reload the extension in
chrome://extensions. - If account data appears out of sync, switch account once or reload the active view.
- If a local build fails after dependency changes, run:
rm -rf node_modules bun install
Releases are automated via Semantic Release on merge to main:
- Version bumps based on commit types (
feat-> minor,fix-> patch,BREAKING CHANGE-> major) - Automatic changelog generation
- GitHub release with
wallet-extension-dist.zipartifact
Manual trigger: Actions -> release workflow -> Run workflow on main
Local dry-run: bun run release:dry (requires Node.js >= 22)