@sigsafe/core
v0.1.0
Published
Decode any signable EVM payload into a structured, human-readable intent with risk flags.
Maintainers
Readme
@sigsafe/core
Decode any signable EVM payload into a structured, human-readable intent — with risk flags — before you sign.
In 2025, signature-phishing drained $83.85M across 106,106 victims. The attacks don't steal keys; they trick users into signing away funds — an unlimited Permit, a setApprovalForAll, an EIP-7702 delegation — shown as an opaque blob the user can't read. @sigsafe/core decodes the static structure of the payload itself (the actual bytes being signed), so there's no simulation to spoof and no RPC round-trip required.
It is harm-reduction, not a firewall. A decoder tells you what a payload is, not every downstream effect. Pair it with simulation for full coverage.
Install
npm install @sigsafe/core
# viem is a peer-level dependency and is pulled in automaticallyQuickstart
import { decode } from "@sigsafe/core";
// A malicious unlimited Permit signature (EIP-712 typed data)
const intent = await decode(typedData, { chainId: 1 });
intent.summary;
// "Off-chain permit (no gas): let 0xdead…beef spend UNLIMITED USD Coin from your wallet."
intent.risk; // "CRITICAL"
intent.action; // "PERMIT"
intent.flags; // [{ id: "unlimited-approval", severity: "CRITICAL", confidence: "high", ... }]decode() never throws on malformed input — it returns an UNKNOWN intent with a parse-error flag instead.
What it accepts
| Input | Example |
| --- | --- |
| EIP-712 typed data | eth_signTypedData_v4 payload (object or JSON string) |
| EIP-2612 / DAI / Permit2 permits | the typed-data permit standards |
| EIP-7702 authorizations | { chainId, address, nonce, r?, s?, yParity? } |
| Raw transactions | 0x02… (EIP-1559), legacy, etc. |
| Calldata | the data field — approve, transfer, setApprovalForAll, … |
| Raw transaction objects | { to, data, value, chainId } |
| personal_sign messages | plain text, SIWE logins, or raw hashes |
The output: DecodedIntent
interface DecodedIntent {
summary: string; // one-sentence plain English, safe to show a user
action: Action; // PERMIT | TOKEN_APPROVAL | DELEGATION | ...
risk: RiskLevel; // SAFE | INFO | WARNING | CRITICAL (max across flags)
flags: RiskFlag[]; // every risk raised, sorted by severity
inputType: InputType;
details: IntentDetails; // structured, discriminated by `kind`
raw: string;
chainId?: number;
}Each RiskFlag carries a confidence ("low" | "medium" | "high") so a consumer can tune its own threshold — show everything in a wallet UI, but block only CRITICAL + high in an automated bot — instead of treating every flag equally.
Risk rules
| Rule | Catches | Severity |
| --- | --- | --- |
| known-drainer | spender/delegate on a blocklist (or your customBlocklist) | CRITICAL |
| eip7702-delegation | EIP-7702 account delegation | CRITICAL |
| permit-to-eoa | approval/permit to a wallet, not a contract (needs rpcUrl) | CRITICAL |
| unlimited-approval | max-uint approval/permit | CRITICAL / WARNING |
| setapprovalforall | collection-wide NFT approval | CRITICAL / WARNING |
| ownership-transfer | transferOwnership / renounceOwnership | WARNING |
| chain-mismatch | typed-data chainId ≠ the chain you're on | CRITICAL |
| unknown-spender | bounded approval to an unlabelled address | WARNING / INFO |
| expired-deadline / far-future-deadline | permit deadline sanity | INFO |
| zero-address | transfer/approval to 0x0 | WARNING |
| blind-hash-sign | personal_sign of a raw 32-byte hash | WARNING |
Offline vs online
Works fully offline from static data. Provide an rpcUrl to unlock two best-effort enrichments (both fail-safe — a dead RPC never breaks a decode):
permit-to-eoa—eth_getCodeconfirms whether a spender is a contract or a personal wallet (the strongest drainer signal).- Token metadata —
symbol()/decimals()for accurate amounts and labels. Without it, bounded amounts are reported honestly in base units rather than guessing 18 decimals.
await decode(input, { chainId: 1, rpcUrl: "https://…", customBlocklist: ["0x…"] });
await decode(input, { offline: true }); // skip all network callsHex messages vs calldata. A hex-encoded
personal_signmessage is byte-identical to calldata. Auto-detection can't tell them apart, and guessing wrong is unsafe — so pass the method you already know:import { InputType } from "@sigsafe/core"; await decode(hexMessage, { inputType: InputType.PERSONAL_SIGN });
Limitations
- A decoder is not a simulator — it reports what a payload is, not its downstream effects.
- The blocklist is reactive; the structural rules are the real defence against new drainers.
- It cannot stop a user who reads the CRITICAL warning and signs anyway.
permit-to-eoaneeds anrpcUrlto confirm EOA-vs-contract.
License
MIT © Prazwal Ratti
