Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4f5b0c2
feat(popup)+chore(release): expandable Recent Activity + v0.3.0 bundle
Lykhoyda Apr 21, 2026
d1dc53d
polish(popup): forensic-log aesthetic for Recent Activity details
Lykhoyda Apr 21, 2026
53ac8eb
docs(readme): use SVG source icon instead of PNG
Lykhoyda Apr 21, 2026
7ac790c
chore(build): auto-load .env.local for TESTUDO_API_KEY / TESTUDO_API_URL
Lykhoyda Apr 21, 2026
fcb135b
fix(messaging): eliminate TDZ in sendTestudoRequest when response is …
Lykhoyda Apr 21, 2026
cf3762f
chore(release): bundle v0.3.1 — version bump + store zip + release log
Lykhoyda Jun 8, 2026
4e22936
fix(core): require corroboration in detectEcrecover to close drainer …
Lykhoyda Jun 8, 2026
73b9b3d
fix(extension): MessagePort bridge + MAIN-world content script (ADR-0…
Lykhoyda Jun 8, 2026
c254eda
docs: log 48 audit findings + trust-boundary v2 decision/roadmap
Lykhoyda Jun 8, 2026
8042890
fix(extension): parse decimal-string chainId correctly (AUDIT-4/11/30)
Lykhoyda Jun 8, 2026
7df2c20
fix(extension): fail-secure off-mainnet bytecode/deployer analysis (A…
Lykhoyda Jun 8, 2026
2ea695a
test(extension): guard bloom round-trip at non-byte-aligned size; ref…
Lykhoyda Jun 8, 2026
d2bec58
docs: record HIGH-findings resolution (AUDIT-4/5/6/11/26/29/30)
Lykhoyda Jun 8, 2026
2e73928
merge: origin/main into release/v0.3.0 (resolve to v0.3.1 + ADR-016 s…
Lykhoyda Jun 9, 2026
298cabb
chore(release): drop committed store zip; build it in CI instead (Cod…
Lykhoyda Jun 14, 2026
6186225
merge: origin/main into release/v0.3.0 (post-gate; resolve conflicts)
Lykhoyda Jun 14, 2026
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
Binary file modified docs/BUGS.md
Binary file not shown.
Binary file modified docs/DECISIONS.md
Binary file not shown.
Binary file modified docs/ROADMAP.md
Binary file not shown.
Binary file not shown.
Binary file modified docs/adr/README.md
Binary file not shown.
37 changes: 28 additions & 9 deletions packages/core/src/detectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,27 +230,46 @@ export function detectTokenSelectors(instructions: Instruction[]): TokenSelector
}

export function detectEcrecover(instructions: Instruction[]): boolean {
// ecrecover is the precompile at address 0x01. Genuine detection requires the
// precompile address to be pushed before a CALL/STATICCALL. Two evidence levels:
// 1. PUSH20 0x00..01 — an unambiguous 20-byte address literal; nobody pushes
// this except to call the precompile, so it stands alone.
// 2. PUSH1 0x01 — indistinguishable from pushing the integer 1 (loop counters,
// booleans, lengths), so it counts ONLY when corroborated by a KECCAK256 in
// the same look-back window: real signature verification hashes the message
// immediately before the precompile call. (AUDIT-3: prevents an attacker from
// suppressing CRITICAL drainer warnings with a single stray PUSH1 0x01.)
for (let i = 0; i < instructions.length; i++) {
const instruction = instructions[i];
// Check for both STATICCALL (0xFA) and CALL (0xF1) - older contracts use CALL for precompiles
// STATICCALL (0xFA) is the modern read-only precompile idiom; CALL (0xF1) is
// used by older contracts. Both are accepted with sufficient evidence.
if (instruction.opcode === 'STATICCALL' || instruction.opcode === 'CALL') {
const lookBackLimit = Math.max(0, i - LOOK_AHEAD.address);
let hasPush1Addr = false;
let hasKeccak = false;
for (let j = i - 1; j >= lookBackLimit; j--) {
const prevInstruction = instructions[j];
if (prevInstruction.opcode === 'PUSH1' && prevInstruction.data) {
const value = prevInstruction.data[0];
if (value === 0x01) {
return true;
}
}
if (prevInstruction.opcode === 'PUSH20' && prevInstruction.data) {
const allZerosExceptLast =
const isPrecompileAddress =
prevInstruction.data.slice(0, 19).every((b) => b === 0) &&
prevInstruction.data[19] === 0x01;
if (allZerosExceptLast) {
if (isPrecompileAddress) {
return true;
}
}
if (
prevInstruction.opcode === 'PUSH1' &&
prevInstruction.data &&
prevInstruction.data[0] === 0x01
) {
hasPush1Addr = true;
}
if (prevInstruction.opcode === 'KECCAK256') {
hasKeccak = true;
}
}
if (hasPush1Addr && hasKeccak) {
return true;
}
}
}
Expand Down
23 changes: 21 additions & 2 deletions packages/core/tests/detectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,7 @@ describe('detectTokenSelectors', () => {
});

describe('detectEcrecover', () => {
it('detects ecrecover with STATICCALL + PUSH1 0x01', () => {
it('detects ecrecover with KECCAK256 + PUSH1 0x01 + STATICCALL', () => {
const instructions = parseBytecode(AUTHORIZATION_CONTRACTS.withEcrecover);
expect(detectEcrecover(instructions)).toBe(true);
});
Expand All @@ -863,7 +863,7 @@ describe('detectEcrecover', () => {
expect(detectEcrecover(instructions)).toBe(true);
});

it('detects ecrecover with CALL + PUSH1 0x01 (older contracts)', () => {
it('detects ecrecover with KECCAK256 + PUSH1 0x01 + CALL (older contracts)', () => {
const instructions = parseBytecode(AUTHORIZATION_CONTRACTS.withEcrecoverCall);
expect(detectEcrecover(instructions)).toBe(true);
});
Expand All @@ -877,6 +877,25 @@ describe('detectEcrecover', () => {
const instructions = parseBytecode(AUTHORIZATION_CONTRACTS.noAuth);
expect(detectEcrecover(instructions)).toBe(false);
});

// AUDIT-3: a bare `PUSH1 0x01` is ubiquitous (loop counters, booleans, lengths).
// It must NOT alone signal an ecrecover auth pattern, or an attacker can suppress
// CRITICAL drainer warnings by inserting one stray opcode before any CALL.
it('does NOT treat bare PUSH1 0x01 + CALL as ecrecover (drainer bypass)', () => {
const instructions = parseBytecode('0x6001f1'); // PUSH1 0x01; CALL
expect(detectEcrecover(instructions)).toBe(false);
});

it('does NOT treat bare PUSH1 0x01 + STATICCALL as ecrecover (no hash)', () => {
const instructions = parseBytecode('0x6001fa'); // PUSH1 0x01; STATICCALL
expect(detectEcrecover(instructions)).toBe(false);
});

it('detects real ecrecover: KECCAK256 + PUSH1 0x01 + STATICCALL', () => {
// PUSH1 0x00; PUSH1 0x00; KECCAK256; PUSH1 0x01; STATICCALL
const instructions = parseBytecode('0x60006000206001fa');
expect(detectEcrecover(instructions)).toBe(true);
});
});

describe('detectMsgSenderCheck', () => {
Expand Down
13 changes: 8 additions & 5 deletions packages/core/tests/fixtures/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,16 +155,19 @@ export const TOKEN_TRANSFER_CONTRACTS = {
noTokenSelectors: '0x6001600201',
};

// Real ecrecover hashes the message (KECCAK256) immediately before the precompile
// call, so a bare `PUSH1 0x01` is not sufficient evidence (AUDIT-3). These fixtures
// model the realistic shape: PUSH1 0x00; PUSH1 0x00; KECCAK256; PUSH1 0x01; (STATIC)CALL.
export const AUTHORIZATION_CONTRACTS = {
withEcrecover: '0x6001fa',
withEcrecover: '0x60006000206001fa',
withEcrecoverPush20: `0x73${'00'.repeat(19)}01fa`,
withEcrecoverCall: '0x6001f1',
withEcrecoverCall: '0x60006000206001f1',
withEcrecoverCallPush20: `0x73${'00'.repeat(19)}01f1`,
withMsgSenderCheck: '0x3360001014',
withNonceTracking: '0x60005460016001015500',
withFullAuth: '0x6001fa3360001014600054600100015500',
withFullAuth: '0x60006000206001fa3360001014600054600100015500',
noAuth: '0x63a9059cbb',
ecrecoverWithoutNonce: '0x63a9059cbb6001fa',
ecrecoverWithoutNonce: '0x63a9059cbb60006000206001fa',
msgSenderWithoutEcrecover: '0x63a9059cbb3360001014',
};

Expand Down Expand Up @@ -360,6 +363,6 @@ export const EXTCODEHASH_CONTRACTS = {
export const DRAINER_PATTERNS = {
infernoStyle: '0x63a22cb46573deadbeefdeadbeefdeadbeefdeadbeefdeadbeeef1',
crimeEnjoyerWithToken: '0x3663a9059cbb60006000f1',
safeWalletPattern: '0x63a9059cbb6001fa600054600100015500',
safeWalletPattern: '0x63a9059cbb60006000206001fa600054600100015500',
legitimateWithAuth: '0x63a9059cbb3360001014600054600100015500',
};
6 changes: 3 additions & 3 deletions packages/extension/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ window.ethereum.request intercepted

### Message Bridge (content.ts → background.ts)

**Transport**: CustomEvent + nonce channel (ADR-011). Content script generates `crypto.randomUUID()` nonce, passes via `data-testudo-nonce` attribute. Injected script reads nonce and creates channel with nonce-prefixed event names. Prevents response forgery by hostile dApps.
**Transport**: Private MessagePort bridge (ADR-016, supersedes ADR-011). `injected.js` is a declared MAIN-world content script; the ISOLATED content script creates a `MessageChannel` and transfers one port to it once at `document_start` (before any page script runs). All traffic then flows over a capability the page cannot observe, enumerate, or forge — no nonce. Order-resilient handshake (`ISO_READY`/`MAIN_READY`); fail-secure async init (no top-level throw).

**Constants**: `src/utils/message-types.ts` — shared `MessageTypes` object (use instead of raw strings).

Expand Down Expand Up @@ -94,7 +94,7 @@ src/
│ └── typed-data.ts # EIP-7702, permit, address extraction
├── services/ # I/O bridges
│ ├── channel.ts # CustomEvent + nonce channel (ADR-011)
│ ├── channel.ts # MessagePort bridge (ADR-016)
│ ├── messaging.ts # IPC: sendTestudoRequest, requestAddressCheck, etc.
│ └── deployer-lookup.ts # Blockscout API + viem RPC
Expand Down Expand Up @@ -184,7 +184,7 @@ Located in `packages/extension/tests/`:
| decoder/intent-builder.test.ts | 34 | All 8 context-specific intent builders, replay risk |
| decoder/token-resolver.test.ts | 16 | Well-known lookup, RPC fallback, cache |
| services/deployer-lookup.test.ts | 8 | Blockscout API, viem RPC, error handling |
| services/channel.test.ts | 11 | Channel isolation, nonce lifecycle, empty guard |
| services/channel.test.ts | 7 | MessagePort request/response correlation, timeout, handshake wiring |

```bash
yarn workspace @testudo/extension run test
Expand Down
11 changes: 9 additions & 2 deletions packages/extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Testudo",
"short_name": "Testudo",
"version": "0.3.0",
"version": "0.3.1",
"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 Expand Up @@ -45,12 +45,19 @@
"js": ["content.js"],
"run_at": "document_start",
"all_frames": true
},
{
"matches": ["<all_urls>"],
"js": ["injected.js"],
"run_at": "document_start",
"world": "MAIN",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rebuild the Web Store zip with the MAIN-world manifest

The newly committed packages/extension/store-assets/testudo-v0.3.1.zip is not built from this manifest: inspecting the archive shows its manifest.json still has only the isolated content.js content script and still exposes injected.js as a web-accessible resource, and its bundled content.js still contains the old testudo-handshake/DOM injection path. If that zip is uploaded to the Chrome Web Store, none of the ADR-016 MessagePort/MAIN-world bridge changes in this commit will ship, so the release continues using the old trust-boundary code despite the source fix.

Useful? React with 👍 / 👎.

"all_frames": true
}
],

"web_accessible_resources": [
{
"resources": ["injected.js", "fonts/*"],
"resources": ["fonts/*"],
"matches": ["<all_urls>"]
}
],
Expand Down
2 changes: 1 addition & 1 deletion packages/extension/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@testudo/extension",
"version": "0.2.0",
"version": "0.3.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
4 changes: 4 additions & 0 deletions packages/extension/rolldown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,12 @@ const shared = {

export default defineConfig([
{
// injected.js is a MAIN-world content script (ADR-016): it must be a single
// classic self-contained bundle (no ESM import/export, no chunks) because it
// runs under the page's CSP. IIFE guarantees that.
input: 'src/injected.tsx',
...shared,
output: { ...shared.output, format: 'iife' as const },
},
{
input: 'src/content.ts',
Expand Down
79 changes: 59 additions & 20 deletions packages/extension/src/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ function cacheKey(address: string, chainId: number | undefined): string {
return `${chain}:${address}`;
}

/**
* Bytecode + deployer analysis currently runs only against Ethereum mainnet
* (DEFAULT_RPC + mainnet Blockscout). On any other chain those layers would
* query the WRONG chain — a false "clean" for an L2-only contract, or a bogus
* verdict from an unrelated mainnet contract at the same address (AUDIT-5).
* Until per-chain RPC + explorers exist we fail secure: skip the local layers
* off-mainnet and let the chain-aware threat API decide (the decision matrix
* preserves UNKNOWN, never a false clean).
*/
function supportsBytecodeAnalysis(chainId: number | undefined): boolean {
return chainId === undefined || chainId === DEFAULT_CHAIN_ID;
}

export function createAnalysisPipeline(deps: AnalysisDeps): AnalysisPipeline {
const analysisCache = new Map<string, { result: ExtendedAnalysisResult; timestamp: number }>();
const deployerCache = new Map<string, DeployerStaticInfo>();
Expand Down Expand Up @@ -186,17 +199,21 @@ export function createAnalysisPipeline(deps: AnalysisDeps): AnalysisPipeline {
return result;
}

// LAYER 1 + LAYER 2: Run API and Local Analysis in parallel
// LAYER 1 + LAYER 2: Run the chain-aware API and (mainnet-only) local
// analysis in parallel. Off-mainnet, bytecode + deployer analysis is
// skipped (fail-secure) so we never produce a wrong-chain verdict (AUDIT-5).
const apiUrl = await getApiUrl();
const runLocal = supportsBytecodeAnalysis(chainId);
const settings = await deps.getSettings();
const rpcUrl = settings.rpcUrl || DEFAULT_RPC;
const client = deps.getOrCreateClient(rpcUrl);
const deployerKey = cacheKey(normalizedAddress, chainId);

async function fetchDeployerStaticCached(addr: string): Promise<DeployerStaticInfo | null> {
const cached = deployerCache.get(addr);
const cached = deployerCache.get(deployerKey);
if (cached) return cached;
const client = deps.getOrCreateClient(rpcUrl);
const info = await deps.fetchDeployerStaticInfo(addr as Address, client);
if (info) deployerCache.set(addr, info);
if (info) deployerCache.set(deployerKey, info);
return info;
}

Expand All @@ -205,11 +222,17 @@ export function createAnalysisPipeline(deps: AnalysisDeps): AnalysisPipeline {
timeoutId = setTimeout(() => resolve('timeout'), ANALYSIS_TIMEOUT);
});

const localTasks = runLocal
? [
deps.analyzeContract(normalizedAddress as `0x${string}`, { rpcUrl }),
fetchDeployerStaticCached(normalizedAddress),
]
: [];

const settled = await Promise.race([
Promise.allSettled([
deps.checkAddressThreat(normalizedAddress, { baseUrl: apiUrl, chainId }),
deps.analyzeContract(normalizedAddress as `0x${string}`, { rpcUrl }),
fetchDeployerStaticCached(normalizedAddress),
...localTasks,
]).then((results) => {
clearTimeout(timeoutId);
return results;
Expand Down Expand Up @@ -238,24 +261,40 @@ export function createAnalysisPipeline(deps: AnalysisDeps): AnalysisPipeline {
return result;
}

const [apiResult, localResult, deployerResult] = settled;

const apiResult = settled[0];
const api =
apiResult.status === 'fulfilled'
? apiResult.value
? (apiResult.value as ApiClientResult)
: ({ success: false, error: 'Promise rejected' } as ApiClientResult);

let local =
localResult.status === 'fulfilled'
? localResult.value
: ({
risk: 'UNKNOWN',
threats: ['Local analysis failed'],
address: normalizedAddress,
blocked: false,
} as AnalysisResult);

const deployerStatic = deployerResult.status === 'fulfilled' ? deployerResult.value : null;
let local: AnalysisResult;
let deployerStatic: DeployerStaticInfo | null = null;

if (runLocal) {
const localResult = settled[1];
const deployerResult = settled[2];
local =
localResult?.status === 'fulfilled'
? (localResult.value as AnalysisResult)
: ({
risk: 'UNKNOWN',
threats: ['Local analysis failed'],
address: normalizedAddress,
blocked: false,
} as AnalysisResult);
deployerStatic =
deployerResult?.status === 'fulfilled'
? (deployerResult.value as DeployerStaticInfo | null)
: null;
} else {
// Fail-secure: bytecode analysis was not run on this chain → UNKNOWN, never clean.
local = {
risk: 'UNKNOWN',
threats: [`Bytecode analysis unavailable on chain ${chainId}`],
address: normalizedAddress as `0x${string}`,
blocked: false,
} as AnalysisResult;
}

// Merge deployer warnings into local result
if (deployerStatic) {
Expand Down
Loading
Loading