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. An Albedo adapter intercepts thewindow.openpopup flow. 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, checkbox enables proceed |
| 76–100 | Critical | Recommend abort, type CRITICAL to proceed |
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.
Albedo is a web-based signer at albedo.link — it has no browser extension of its own that Gryd Lock can hook. Instead, the @albedo-link/intent npm library (used by dApps) opens a cross-origin popup via window.open('https://albedo.link/confirm', 'auth.albedo.link', ...) and communicates with it using postMessage.
Interception strategy (src/intercept/albedoMainWorldEntry.ts, MAIN world):
dApp calls albedo.tx({ xdr, network }) via @albedo-link/intent
│
▼
albedo-intent calls window.open('https://albedo.link/confirm', 'auth.albedo.link', ...)
│
▼
albedoMainWorldEntry.ts monkey-patches window.open at document_start
└─▶ detects target === 'auth.albedo.link'
└─▶ opens the real Albedo popup
└─▶ returns a Proxy wrapping the real popup window
│
▼
albedo-intent calls proxy.postMessage({ intent: 'tx', xdr, network, __reqid, ... })
│
▼
Proxy intercepts first postMessage for 'tx' / 'pay' intents with an xdr field
└─▶ routes xdr through GRYDLOCK_REQUEST_OUTCOME → bridge → background (same path as Freighter)
└─▶ background decodes XDR, scores destinations, opens warning popup
│
▼
'cancel' → real popup closed; synthetic albedoIntentResult error posted back
to the dApp (code -4, matching Albedo's standard rejection shape)
'allow' / 'proceed' → original postMessage forwarded to the real Albedo popup; user signs
in Albedo normally
What is not intercepted:
- Implicit-flow intents — these bypass the popup (they send directly via an iframe/session token established earlier). The XDR is not visible at the
window.openlayer. - Non-XDR intents (
public_key,sign_message,trust,exchange) — no transaction destination to score; passed through unmodified. - SEP-0007 link handler (Albedo browser extension variant) — uses a page redirect rather than a popup; different surface not covered by this adapter.
Manual verification: Load a dApp that calls albedo.tx({ xdr: '...', network: 'testnet' }), open the extension's dev popup first to confirm the bridge is alive, then trigger the Albedo signing flow. The Gryd Lock warning should appear before the Albedo confirmation dialog receives the intent payload.
The warning popup is fully accessible to screen reader users (e.g., VoiceOver, NVDA). It implements the alertdialog ARIA role and uses an assertive live region to ensure the risk tier is announced immediately upon opening. The popup wires the risk level, destination, and warning message together using aria-describedby so the complete context is conveyed coherently without relying on visual cues.
- 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 / albedoMainWorld.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)
│ ├── history/ # Decision history page (extension options page)
│ ├── intercept/ # Freighter + Albedo proxies + message-bridge protocol
│ ├── lib/ # Score → tier mapping, local decision history storage
│ └── popup/ # React warning UI — default (dev) and intercept modes
└── README.md
Every proceed/cancel decision made in the warning popup is recorded locally: destination,
asset (when present), score, tier, decision, and timestamp. Open it via the extension's
Options page (right-click the toolbar icon → Options, or chrome://extensions → Gryd Lock →
Details → Extension options).
Privacy:
- History lives only in
chrome.storage.localon your device — it is never transmitted anywhere. - Storage is capped to the most recent 200 decisions; older entries are dropped automatically.
- A Clear history button on the page deletes everything at once.
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, networkPassphrase }
│
▼
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;
│ generates the real requestId here, never in MAIN world,
│ so a page script can't observe it and forge a decision)
│ chrome.runtime.sendMessage
▼
src/background/background.ts (service worker)
│
├─▶ src/decode/decodeTransaction.ts → extractDestination(xdr)
│ returns all distinct destinations, not just one
│
├─▶ each destination scored independently via src/adapter/oracleAdapter.ts
│
└─▶ worst-tier destination opens the popup with all destinations
│
▼
src/popup/App.tsx (intercept mode) renders tier + worst-score + every destination
│ chrome.runtime.sendMessage({ type: 'DECISION_MADE', ... })
│ (popup closed any other way → chrome.windows.onRemoved fires instead,
│ background resolves that request to 'cancel' so it can't hang forever)
▼
background only resolves the pending request if DECISION_MADE's sender window matches
the popup it created for that requestId (otherwise ignored) → 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). - Keyboard-first approval: the warning is an approval dialog for a signing request, so it is
fully operable without a mouse.
src/popup/TierWarning.tsxrenders as a modal dialog (role="dialog",aria-modal, labelled by the tier heading) and:- focuses Cancel on open for every tier — the safe choice is always one keypress away, and a High/Critical warning never makes you hunt for focus;
- traps focus in the popup — Tab and Shift+Tab cycle through its interactive elements and wrap at the ends, so focus can't land on browser or extension UI while a decision is pending;
- treats Escape as Cancel, routed through the same
onCancelpath as the button, so an intercepted request declines identically however it was dismissed; - leaves Enter/Space activation to native
<button>behaviour rather than re-implementing it.
- 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 & timeouts: 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. - Destination-bearing operations:
payment,pathPaymentStrictSend/pathPaymentStrictReceive,createAccount,createClaimableBalance, andclaimClaimableBalance. AcreateClaimableBalancecontributes one candidate destination per claimant, since any of them may later claim it; a transaction with more than one claimant is a multiple-distinct-destination case and resolves to'allow'like any other batch, pending the dedicated multi-destination scoring in #20.claimClaimableBalancecarries no destination account in the operation itself — only an opaque balance ID — so the balance ID is scored in its place. - 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, including the theme-aware tier accent variables used by the popup.
Report suspected vulnerabilities privately using SECURITY.md. The threat model documents the extension's trust boundaries, current guarantees, explicit non-goals, known gaps, and security-hardening roadmap.
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. The popup follows the browser or OSprefers-color-schemesetting in both the default preview and interception flows.
npm run lint # ESLint
npm run typecheck # tsc --noEmit
npm run test:coverage # Vitest + v8 coverage (enforces thresholds)
npm run validate:manifest # MV3 / Chrome Web Store policy checks
npm run build # tsc -b && vite build && node scripts/build-extension.mjsAll five run in CI (.github/workflows/ci.yml) on every push to main and on every pull request.
Popup visual regression snapshots run in CI as well via npm run test:visual; if a UI change is
intentional, refresh baselines locally with npx playwright test --update-snapshots and commit the
updated files from tests/visual/popup.spec.ts-snapshots/.
Content Security Policy. manifest.json declares content_security_policy.extension_pages
as script-src 'self'; object-src 'self'. The Vite build emits no inline scripts or styles for
the popup or history pages — all scripts are bundled into separate .js files referenced by
<script src="..."> tags — so both popup modes (dev and intercept) load cleanly under this CSP
with no console violations.
Coverage policy. Thresholds are configured in vite.config.ts and enforced by
npm run test:coverage (CI runs this instead of bare vitest run). The following
files are excluded from coverage because they require Chrome APIs or a real DOM
that unit tests cannot provide:
src/intercept/mainWorldEntry.ts/src/intercept/bridgeEntry.ts— depend onchrome.*APIs andpostMessageacross extension worlds; covered by the e2e harness.src/intercept/albedoMainWorldEntry.ts— monkey-patcheswindow.openin the MAIN world; covered by the e2e harness. Pure behavioural logic is unit-tested inalbedoMainWorldEntry.test.ts.src/background/background.ts— service-workerchrome.*calls; covered by the e2e harness.src/popup/main.tsx— React entry-point boilerplate.src/intercept/protocol.ts— constant and type definitions only.
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.
See CONTRIBUTING.md for local setup, architecture guardrails, testing expectations, required quality gates, and the pull request checklist.
Changes that introduce or alter shared abstractions, browser-context responsibilities, message contracts, interception semantics, or security boundaries should follow the lightweight Architecture Decision Record process.
- 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. - Local decision history: persist proceed/cancel decisions to
chrome.storage.local(capped, on-device only) with a history page. - Albedo interception: monkey-patch
window.opento intercept theauth.albedo.linkpopup, proxy the firsttx/paypostMessage through the scoring pipeline before it reaches Albedo. - Swap the stub score for a live one from the adapter.
- Generalise interception beyond Freighter and Albedo (xBull, Lobstr).
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.
Dependabot checks npm dependencies and GitHub Actions every Monday. Updates are grouped so routine tooling changes do not obscure runtime changes that affect the wallet-warning path.
Dependabot alerts and security updates are controlled in repository settings and should remain enabled. This file configures the scheduled version-update pull requests.
The auto-merge policy is intentionally narrow:
- patch updates to direct development dependencies may auto-merge;
- patch updates to GitHub Actions may auto-merge;
- runtime dependencies, indirect dependencies, and every minor or major update require human review;
- a Dependabot PR with maintainer-authored changes is no longer auto-merge eligible.
Auto-merge does not replace CI. The repository must enable GitHub auto-merge, protect main, and
require the existing CI verify job. Repository or organization Actions settings must also allow
the workflow token to approve pull requests. If those settings are absent, maintainers should merge
Dependabot PRs manually after npm run lint, npm run typecheck, npm test, and npm run build
pass.