Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<p align="center">
<img src="packages/extension/store-assets/icon.png" alt="Testudo" width="128" height="128">
<img src="packages/extension/assets/icon-testudo.svg" alt="Testudo" width="128" height="128">
</p>

<h1 align="center">Testudo</h1>
Expand Down Expand Up @@ -30,26 +30,29 @@ Testudo acts as an antivirus for every Ethereum transaction. It intercepts signa

| Capability | Method |
|-----------|--------|
| EIP-7702 delegation analysis | Bytecode capability extraction (14 detectors) |
| EIP-7702 delegation analysis | Bytecode capability extraction (23 detectors) |
| EIP-7702 cross-chain replay (`chainId=0`) | Typed "I ACCEPT THE RISK" confirmation gate |
| Token approvals (`approve`, `increaseAllowance`) | Calldata decoding + address check |
| NFT `setApprovalForAll` | Calldata decoding + marketplace allowlist |
| Permit / Permit2 signatures | Typed data primaryType detection |
| Blind signatures (`personal_sign`) | Phishing pattern scoring |
| `eth_sign` hard block | Always CRITICAL, typed confirmation required |
| Typed data address scanning | Recursive address extraction + batch check |
| Malicious transaction recipients | Address-only check pipeline |
| Phishing domain blocking | DNR rules + bloom filter + CDN sync |
| Deployer reputation | Nonce/age heuristic via Blockscout + RPC |
| Human-readable intent | Token metadata resolution + intent builder |
| Proxy resolution | EIP-1967 storage + EIP-1167 bytecode + cycle guard |

## Architecture

**3-layer defense** — every unknown address passes through:

1. **Safe Filter** (local, instant) — known-good addresses skip API
2. **Threat Intelligence API** (800ms timeout) — 15K+ malicious addresses + GoPlus real-time fallback
3. **Local Bytecode Analysis** (parallel) — 14 deterministic detectors, no ML
1. **Safe Filter** (local, instant) — known-good addresses skip API. Ed25519-signed CDN manifest with rollback protection
2. **Threat Intelligence API** (800ms timeout, per-chain routes) — aggregated malicious address registry + GoPlus real-time fallback
3. **Local Bytecode Analysis** (parallel) — 23 deterministic detectors, no ML

All results are **explainable**: "This contract HAS capability X because of opcode Y at offset Z."
Decision matrix is **fail-closed on strong local signals**: API "clean" cannot downgrade a CRITICAL local bytecode match (zero-day protection, ADR-013). All results are **explainable**: "This contract HAS capability X because of opcode Y at offset Z."

## Installation

Expand Down Expand Up @@ -82,9 +85,10 @@ yarn test

```
packages/
core/ # @testudo/core — Detection engine (190 tests)
extension/ # @testudo/extension — Chrome extension (Preact + Signals)
e2e/ # End-to-end tests (Playwright, 43 tests)
core/ # @testudo/core — Detection engine (395 tests)
extension/ # @testudo/extension — Chrome extension (Preact + Signals, 375 tests)
e2e/ # Playwright E2E tests
e2e-docker/ # Synpress + Anvil attack-pattern E2E

apps/
mock-dapp/ # Demo playground for testing
Expand Down
9 changes: 9 additions & 0 deletions packages/extension/.env.local.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Copy to `.env.local` (gitignored) and fill in values.
# The build reads this automatically; explicit env vars still win.

# API key for testudo-api. Get from Railway env on the testudo-api service.
TESTUDO_API_KEY=

# Override the API base URL for local/staging testing.
# Default: https://testudo-api-production.up.railway.app
# TESTUDO_API_URL=http://localhost:3000
4 changes: 2 additions & 2 deletions packages/extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"manifest_version": 3,
"name": "Testudo",
"short_name": "Testudo",
"version": "0.2.0",
"description": "Protect your wallet from malicious EIP-7702 delegations. Real-time bytecode analysis warns you before signing dangerous contracts.",
"version": "0.3.0",
"description": "Antivirus for your Ethereum wallet. Analyzes contracts, approvals, and signatures before you sign — EIP-7702, permits, phishing, and more.",
"homepage_url": "https://github.com/Lykhoyda/Testudo",
"minimum_chrome_version": "137",

Expand Down
164 changes: 160 additions & 4 deletions packages/extension/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -284,21 +284,177 @@

.activity-item {
display: flex;
align-items: center;
gap: 12px;
flex-direction: column;
background: var(--surface);
padding: 12px;
border-radius: var(--r-md);
border: 1px solid var(--border);
cursor: pointer;
transition: all 0.15s;
transition: background 0.15s, border-color 0.15s;
}

.activity-item:hover {
background: var(--surface-raised);
border-color: var(--border-hover);
}

.activity-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}

.activity-item.expanded {
background: var(--surface-raised);
border-color: var(--border-hover);
}

.activity-main {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: transparent;
border: 0;
width: 100%;
text-align: left;
color: inherit;
font: inherit;
cursor: pointer;
border-radius: var(--r-md);
}

.activity-main:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}

.activity-item.expanded .activity-main {
border-radius: var(--r-md) var(--r-md) 0 0;
}

.activity-chevron {
font-size: 18px !important;
color: var(--text-dim);
flex-shrink: 0;
transition: transform 0.15s;
}

.activity-chevron.open {
transform: rotate(180deg);
color: var(--text-muted);
}

.activity-details {
position: relative;
border-top: 1px solid var(--border);
padding: 14px 14px 16px;
font-family: var(--font-ui);
font-size: 13px;
line-height: 1.45;
}

.activity-details::before {
content: '';
position: absolute;
top: -1px;
left: 12px;
right: 12px;
height: 2px;
border-radius: 0 0 2px 2px;
background: var(--border-strong);
opacity: 0.8;
}

.activity-details[data-risk="critical"]::before { background: var(--danger); }
.activity-details[data-risk="high"]::before,
.activity-details[data-risk="medium"]::before { background: var(--warn); }
.activity-details[data-risk="low"]::before { background: var(--safe); }

.activity-details-grid {
display: grid;
grid-template-columns: 68px 1fr;
row-gap: 8px;
column-gap: 12px;
margin: 0;
}

.activity-details-grid dt {
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 10px;
font-weight: 600;
align-self: start;
padding-top: 2px;
}

.activity-details-grid dd {
margin: 0;
color: var(--text);
word-break: break-all;
font-size: 13px;
}

.activity-details-mono {
font-family: var(--font-mono);
font-size: 12px !important;
color: var(--text-muted);
line-height: 1.4;
}

.activity-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px 3px 8px;
border-radius: 999px;
font-weight: 600;
font-size: 12px;
letter-spacing: 0.01em;
border: 1px solid transparent;
}

.activity-status.blocked {
color: var(--danger);
background: var(--danger-bg);
border-color: var(--danger-border);
}

.activity-status.allowed {
color: var(--safe);
background: var(--safe-bg);
border-color: var(--safe-border);
}

.activity-status-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: currentColor;
box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 20%, transparent);
flex-shrink: 0;
}

.activity-threats {
display: flex;
flex-wrap: wrap;
gap: 4px 5px;
}

.activity-threat-chip {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border-radius: 4px;
background: var(--warn-bg);
border: 1px solid var(--warn-border);
color: var(--warn);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.03em;
line-height: 1.5;
}

.activity-icon-wrapper {
display: flex;
align-items: center;
Expand Down
29 changes: 28 additions & 1 deletion packages/extension/rolldown.config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,39 @@
import { defineConfig } from 'rolldown';
import { copyFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const distDir = join(__dirname, 'dist');
const fontsDir = join(distDir, 'fonts');

// Load `.env.local` (gitignored) into process.env so developers don't have to
// re-export TESTUDO_API_KEY on every build. Explicit env vars win over the file.
function loadEnvFile(path: string): void {
if (!existsSync(path)) return;
const content = readFileSync(path, 'utf-8');
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (process.env[key] === undefined) {
process.env[key] = value;
}
}
console.log(`[Testudo Build] Loaded env from ${path.replace(__dirname, '.')}`);
}

loadEnvFile(join(__dirname, '.env.local'));

if (!existsSync(distDir)) {
mkdirSync(distDir);
}
Expand Down
Loading
Loading