A browser extension (Chrome, Manifest V3) that watches your Steam trade offers and verifies P2P trades against your DMarket deals. It bundles the DMarket P2P trade-tracker core (compiled from Kotlin Multiplatform) and drives it from a Manifest V3 service worker.
The trade-tracker business logic lives in a separate library — dmarket/p2p-tracker-core — consumed as the published npm package
@dmarket/p2p-tracker-corebehind a single import seam (src/core/tracker.ts). This repository owns the extension shell: the service worker, the popup, the on-page onboarding UI, and the page bridge.
- Node.js 22+ (WXT's own floor)
- npm 10+
- A Chromium-based browser for loading the unpacked build
npm install # installs deps and runs `wxt prepare`
npm run dev # dev build with hot reload (Chrome); includes the debug console
npm run build:debug # loadable debug build → .output/chrome-mv3-dev (debug console included)
npm run build # production build → .output/chrome-mv3 (debug tooling excluded)
npm run zip # packaged production zip
npm run zip:debug # packaged debug zip (same build as build:debug, zipped)
npm run check # the full gate: compile (tsc + guards) + lint + unit tests
npm run compile # type-check and guard scripts only
npm test # unit tests only (Vitest)
npm run lint # ESLint onlyLoad the unpacked extension via chrome://extensions (Developer mode → Load unpacked): use
.output/chrome-mv3-dev for a debug build or .output/chrome-mv3 for a production build. A packaged
production build of each release is attached to its
GitHub Release.
npm run check is the gate, and it is exactly what CI runs. Formatting is deliberately not
enforced — there is no Prettier configuration, so running Prettier here reformats whole files with
its own defaults.
Chrome is the shipping target. A Firefox build exists and is code-complete but has not been
live-validated, so treat it as unreleased: npm run build:firefox, npm run build:debug:firefox,
npm run zip:firefox → .output/firefox-mv3*. Same sources; the manifest gains webRequest (the
anti-CSRF path there is blocking webRequest rather than declarativeNetRequest) and a
browser_specific_settings.gecko block. Load it via about:debugging. Host permissions are opt-in
on Firefox MV3, so a development build needs them granted by hand in about:addons.
Copy .env.example to .env and fill in the values you need. Every integration (error reporting,
remote config) is optional and stays inactive until its variables are present, so the
extension builds and runs with an empty .env. Only WXT_-prefixed variables are exposed to the
bundle.
Two things are compiled in rather than configured, because their absence is silent and expensive: the
DMarket API and FE origins, and the production notary URL. So a production build arms the real TLSN
prover with no configuration at all; remote config can redirect the notary but can no longer switch it
off. The WXT_DEV_* variables override these in development builds only, and are dead code in a
production one.
The extension ships a debug console — a developer-only dashboard for inspecting the tracker at
runtime. It is compiled into development builds only and is stripped entirely from production builds
(npm run build / npm run zip).
Build a loadable debug build and load .output/chrome-mv3-dev unpacked:
npm run build:debugOpen it from the popup's “debug console” link (dev builds only), or navigate to the extension's
debug.html. It provides:
- a live network log of the core's HTTP traffic — each request as a copy-pasteable
curl, with the decoded response, and the core's lifecycle frames interleaved in causal order. Credentials are redacted at capture time; identifiers (steamids, deal ids,deviceId) are deliberately kept, since they are what the log exists to correlate; - session status: core version, next-heartbeat countdown, Steam/DMarket sign-in indicators, the
mirrored
block:reason, which prover the core resolved (prover:), and the outcome of the last proof (proof:) — a prover being configured and a proof being attempted are different facts, so they get separate pills; - an endpoint switcher for the FE, API and notary URLs, with Prod / Stage / Dev prefill buttons for the first two (debug builds default to Dev). FE and API restart the tracker in place; the notary URL is applied independently;
- force tick (an immediate heartbeat), retry proof (restart the tracker so a proof the core has latched off as refused is attempted again) and refresh config (fetch remote config now, bypassing the 1 h throttle). Each writes its outcome to the session log rather than to a transient pill, so it is timestamped against the traffic it caused;
- a blocking-state simulator: reproduce any state in the chain — no DMarket session, no Steam
session, wrong Steam account, onboarding not completed, DMarket error — by its real cause (the
core is pointed at a cookie name nothing holds, or a heartbeat reply is synthesised), not by
overwriting the mirrored reason. Rails refuse the Steam session-transfer and DMarket
refresh-tokenendpoints while armed, so simulating a signed-out state cannot rotate a live credential. This is also where the activation flag is toggled; - a freshness-mark injector for demand-driven proving, pinned to the storage row that holds the core's answer;
- a
chrome.storage.localinspector/editor to view, edit, add and remove persisted keys. JSON-in-string values are expanded for reading whileeditstill shows the raw stored value, and credentials are rendered through the redactor (so a row'ssteam_idand expiry are readable without exposing the token).
Steam's hosts are hard-coded in the tracker core, so the Steam-coupled flows (session refresh, trade
send, cancel) normally need a live Steam account. Set WXT_DEV_STEAM_URL to a local origin and a
non-production build sends every Steam request the core makes there instead — in an ordinary browser
window, with no launch flags. Only fetch is redirected: the Steam session cookie is still read from
the real steamcommunity.com origin, so set that cookie by hand for Steam to read as connected.
Unset (the default) means real Steam, and production builds ignore the variable entirely.
CircleCI (.circleci/config.yml). Every push runs:
| Job | Waits for | What it does |
|---|---|---|
check |
— | npm run compile (tsc + the guard scripts), npm run lint, npm test |
build-prod |
check |
production zip → the job's Artifacts tab |
build-debug |
check |
development zip (debug console, internal endpoints) → Artifacts |
The two builds run in parallel with each other, but neither starts until the checks pass — an installable artifact should never come from a pipeline whose own checks are red.
A push to release/vX.Y.Z adds the release chain to that same pipeline, so one release is one
pipeline: hold_release (approval) → release (tag + GitHub Release with the zip) → pack_crx →
store_preflight → hold_store_upload (approval) → upload_to_store. Those six exist only on a
release branch; a push anywhere else runs the three jobs above and nothing more.
Both zips are meant to be installed by hand: download, unzip, then chrome://extensions →
Developer mode → Load unpacked. Each build is verified by scripts/verify-build.mjs, which
checks the manifest version and version_name, the production permission/host surface, the absence
of debug tooling and of a wrapped fetch from a production bundle, that the production notary URL is
present (without it the core silently runs the no-op prover and every proofRequired deal stalls),
and that the TLSN prover was copied in. It also asserts the inverse for a development build, since
wxt build without --mode development produces a directory that looks fine and has no debug
console in it.
- Branch off as
release/vX.Y.Z(e.g.release/v1.0.1). The release jobs run only on branches matching that pattern, and the version in the name must equal step 2's —releasefails before tagging if they disagree. - Pin
@dmarket/p2p-tracker-coreto an exact stable version (npm install, commit the lockfile) — currently1.0.0-beta.1. A build made against a-SNAPSHOTcore is never released: a snapshot can be unpublished from npm, which would make the published build unreproducible. Note thatnpm run core:latestdeliberately moves the pin onto the newest snapshot for development work, so put it back before releasing. - Bump
versioninpackage.json. This is the single source of truth: WXT derives the manifest version from it, the zips are named from it, and the tag isv+ it. A SemVer prerelease suffix is fine — Chrome accepts only 1–4 dot-separated integers, so WXT puts the numeric prefix inmanifest.versionand the full string inversion_name(which is what Chrome shows on the extension card).1.0.0-beta.1ships as version1.0.0/ version_name1.0.0-beta.1. - Add the matching
## [x.y.z]section to CHANGELOG.md — it becomes the release notes, and the release fails without it. - Push the branch, then approve
hold_releasein CircleCI. Merge it back tomainafterwards — nothing in CI does that, and without itmaindrifts from what was published.
Only then does CI tag v<version> and publish a GitHub Release with the production zip, a sourcemaps
archive (for symbolicating crash reports), the production manifest and SHA256SUMS. A prerelease
version (0.x, or anything with a -suffix) is marked Pre-release on GitHub, so it is not
presented as the Latest release.
The debug build is deliberately not published — it is not what ships, and it inlines the internal
WXT_DEV_*/WXT_STAGE_* endpoints. Download it from the build-debug job's Artifacts tab in
CircleCI, on any push.
A push whose version is already tagged releases nothing, so re-pushing a release branch is safe;
[skip release] in the commit message skips it explicitly. main still runs check and both builds
on every push — it just cannot release.
Publishing to the Chrome Web Store is a second, separately approved pipeline triggered by that tag:
tag v* → pack_crx → store_preflight → hold_store_upload → upload_to_store
(approval)
pack_crx re-verifies the released zip and wraps it unchanged in a CRX3 signed with our own key (the
store item uses Verified CRX Uploads, so a plain zip is rejected). store_preflight then rehearses
the upload against the live store — read-only, it cannot send anything — so whoever approves already
knows what is published today and whether this version would be accepted. Only upload_to_store
writes, and only after the approval.
Every release consumes one numeric version, beta or not:
1.0.1-beta → 1.0.2-beta → 1.0.3 → 1.1.0-beta → 1.1.1
Betas are ordinary public releases that ship to every store user — the -beta suffix is a label so
users hold the right expectations, not a channel (the store has none: API v2 exposes only staged
rollout by percentage, which needs 10k+ weekly users). Chrome shows the full string, suffix included,
as version_name on the extension card.
So the usual SemVer habit of 1.1.0-beta.1 → 1.1.0-beta.2 → 1.1.0 does not work here: the
store compares manifest.version, from which WXT has already dropped the suffix, so all three are
manifest 1.1.0 and only the first could be uploaded. Bumping a suffix is not a version bump as far
as the store is concerned — which also makes a counter inside the suffix (-beta.2) misleading, since
the numbers already count the releases. upload_to_store's preflight refuses a non-increasing version
before uploading anything.
src/
entrypoints/ # service worker, popup, content scripts, offscreen document, debug page
background/ # service-worker glue: anti-CSRF, toolbar icon, cookie watch, bridge router
config/ # compiled-in defaults, the remote-config overlay, core parameter order
core/ # single import seam for the tracker core, plus the notary proof delegate
messaging/ # typed message contracts + the dmarket.com page bridge
state/ # activation flag, mirrored blocking reason, and the surface resolver
infra/ # error reporting, remote config (opt-in)
debug/ # developer-only debug console service-worker glue (dev builds only)
ui/ # popup, on-page UI, and debug-console components
util/ # redaction, storage-event registrar, match patterns
assets/ # icons used by the popup and on-page UI
testing/ # shared test stubs
scripts/ holds four guards that mechanise invariants a unit test cannot reach. Three run as part of
npm run compile: the core's positional configuration order against the installed package
(check-core-params), the blocking-state priority table against the core's own resolver
(check-surface-priority), and the create-trade cause mapping (check-create-trade-cause). The
fourth, verify-build, needs a built output and runs in CI's build jobs (npm run verify:build).
- WXT — Manifest V3 build framework
- Preact — UI
- TypeScript
- Vitest — unit tests, with WXT's fake-browser extension APIs
- ESLint — type-aware linting (
typescript-eslint); no formatter @dmarket/p2p-tracker-core— the trade-tracker business logic, compiled from Kotlin Multiplatform
See LICENSE.