The Gryd Lock browser extension — catches a Stellar transaction before signing and warns the user if the destination looks fraudulent.
This is the product. It runs entirely in the user's browser. It hooks the wallet signing flow, decodes the pending transaction, requests a risk score for the destination, and renders a four-tier warning. It never blocks — it warns, and the user decides.
For detailed information on data handling, data protection, and our no-telemetry architecture, see PRIVACY.md.
Status: Early build. A Freighter
signTransactionproxy decodes the destination, routes it through the oracle adapter, and shows the warning before signing. A live oracle connection is not yet built — see the roadmap.
User initiates a transaction in a Stellar wallet
│
▼
Extension intercepts the unsigned transaction (Freighter signing flow)
│
▼
Decode the transaction XDR → extract destination address / asset
│
▼
Request a 0–100 risk score (via grydlock-oracle-adapter)
│
▼
Map the score to a warning tier → show the warning
│
▼
User proceeds or cancels — the extension never blocks
| Score | Tier | Behaviour |
|---|---|---|
| 0–20 | Low | Green indicator, proceed |
| 21–50 | Elevated | Soft warning |
| 51–75 | High | Strong warning, require explicit confirm |
| 76–100 | Critical | Recommend abort, explain why |
Stellar has no universal injected wallet provider — each wallet exposes its own signing API, so interception is per-wallet, not global. Gryd Lock targets Freighter first (the most widely used browser wallet), proves the interception pattern, then generalises to xBull, Albedo, and Lobstr.
- TypeScript — extension logic
- React — warning UI in the popup
- Stellar SDK (JS) — decoding the unsigned transaction
- Manifest V3 — Chrome / Brave / Edge extension format
The risk score itself is fetched through grydlock-oracle-adapter; this repo holds no scoring logic.
grydlock-extension/
├── manifest.json # MV3 — popup, background service worker, content scripts
├── scripts/
│ └── build-extension.mjs # esbuild bundle for background.js / mainWorld.js / bridge.js
├── src/
│ ├── adapter/ # Oracle adapter stub — getScore(destination)
│ ├── background/ # Service worker: decodes XDR, scores, opens the warning popup
│ ├── decode/ # XDR → destination extraction (Stellar SDK)
│ ├── intercept/ # Freighter signTransaction proxy + message-bridge protocol
│ ├── lib/ # Score → tier mapping
│ └── popup/ # React warning UI — default (dev) and intercept modes
└── README.md
Toolbar click (dev/testing) — unchanged from the stub-only build:
manifest.json (action.default_popup)
│
▼
src/popup/index.html → main.tsx → App.tsx (default mode)
│
├─▶ src/adapter/oracleAdapter.ts → getScore(destination)
├─▶ src/lib/tiers.ts → tierForScore(score)
└─▶ src/popup/DevScoreSlider.tsx (dev-only override)
Real Freighter signing — @stellar/freighter-api's signTransaction doesn't call a global
function; it posts { source: 'FREIGHTER_EXTERNAL_MSG_REQUEST', type: 'SUBMIT_TRANSACTION', ... }
to window, and Freighter's own content script replies the same way. That postMessage traffic is
the actual interception point:
dApp posts { source: FREIGHTER_EXTERNAL_MSG_REQUEST, type: SUBMIT_TRANSACTION, transactionXdr }
│
▼
src/intercept/mainWorldEntry.ts (MAIN world; grabs the request via stopImmediatePropagation()
│ before Freighter's own listener sees it)
│ window.postMessage (Gryd Lock's own internal request/response, separate from Freighter's)
▼
src/intercept/bridgeEntry.ts (isolated world; only place with chrome.* API access)
│ chrome.runtime.sendMessage
▼
src/background/background.ts (service worker)
│
├─▶ src/decode/decodeTransaction.ts → extractDestination(xdr)
│ no single destination? → outcome 'allow', nothing shown, request passes through
│
├─▶ src/adapter/oracleAdapter.ts → getScore(destination)
│
└─▶ chrome.windows.create(popup?mode=intercept&requestId&destination&score)
│
▼
src/popup/App.tsx (intercept mode) renders the tier + destination + Proceed/Cancel
│ chrome.runtime.sendMessage({ type: 'DECISION_MADE', ... })
▼
background resolves the pending request → bridge → mainWorld
│
▼
'cancel' → mainWorld synthesizes a decline FREIGHTER_EXTERNAL_MSG_RESPONSE;
Freighter's own listener never sees the request at all
'proceed' / 'allow' → mainWorld re-posts the original request (tagged so it isn't
re-intercepted) for Freighter to handle exactly as it would have
- Registration-order dependent: this only works if
mainWorldEntry.ts's listener registers before Freighter's own content script does. Both run atdocument_start, but Chrome does not guarantee injection order across different extensions — the same tradeoff every postMessage-based wallet-firewall extension accepts. - Why the split:
mainWorldEntry.tsruns in the page's own JS context (needed to see the page'spostMessagetraffic) but has nochrome.*API access there;bridgeEntry.tsruns alongside it in the isolated content-script world and is the only piece that can talk to the extension viachrome.runtime. Decoding and scoring happen in the background worker rather than inmainWorldEntry.tsso the Stellar SDK ships once per browser session instead of being injected into every page (mainWorld.jsis ~2 KB; the SDK lives inbackground.jsinstead). - Pure logic:
src/intercept/resolveOutcome.tsis the testable core — given a decode function, a score function, and a decision function, it returns'allow' | 'proceed' | 'cancel'with no Chrome APIs involved, so it's covered by ordinary Vitest unit tests. - Graceful degradation: transactions with no single determinable destination (malformed XDR, no
destination-bearing operation, or multiple distinct destinations) resolve to
'allow'— Gryd Lock never blocks what it can't assess. - Tests:
src/decode/decodeTransaction.test.tsandsrc/intercept/resolveOutcome.test.tscover the decode/scoring/decision logic directly;src/adapter/oracleAdapter.test.tsandsrc/lib/tiers.test.tscover the adapter stub and tier mapping;src/popup/App.test.tsxcovers both the popup's default (loading/error/retry/dev-slider) and intercept-mode rendering, against a mocked adapter and a stubbedchrome.runtime.
npm installnpm run build(ornpm run devfor a local dev server against the default/dev popup only — the content scripts and background worker require a realchrome://extensionsload).- Go to
chrome://extensions, enable Developer mode, click Load unpacked, select thedist/output. - Open the popup from the toolbar to exercise the dev/testing flow — the score comes from the
adapter stub, and in dev builds the dev control lets you drag through all four tiers. To exercise
real interception, visit a page with Freighter installed and call
signTransaction.
npm run lint # ESLint (including automated privacy/network-call restrictions)
npm run typecheck # tsc --noEmit
npm test # Vitest
npm run build # tsc -b && vite build && node scripts/build-extension.mjsAll four run in CI (.github/workflows/ci.yml) on every push to main and on every pull request. The linting step automatically enforces that no network-call APIs (like fetch, XMLHttpRequest, WebSocket, EventSource, navigator.sendBeacon) or Horizon server connections are initiated outside the src/adapter/ directory.
getScore now includes a built‑in timeout to prevent the signing flow from hanging indefinitely. The default timeout is 5 seconds and can be overridden per call via the optional options parameter. If the operation exceeds the timeout, the function resolves with a fallback score of -1, which is treated as an unknown score and results in a safe warning tier.
You can configure the default timeout by modifying src/adapter/config.ts (DEFAULT_GET_SCORE_TIMEOUT_MS). Tests use a shorter timeout to verify the fallback behaviour.
- Popup renders one score across the four tiers. (stub)
- Fetch the score through the oracle adapter (stub score) — prove the query path end to end.
- Freighter interception: proxy
signTransaction, decode the XDR, extract the destination, route it through the adapter. - Swap the stub score for a live one from the adapter.
- Generalise interception beyond Freighter.
Do not build real interception until the adapter returns a real score. Interception without a working score source is a warning with nothing to warn about.