Skip to content
Conduit

skill

sign-safe-skill

Offline, deterministic signing-time safety gate for Solana — decodes an opaque transaction, classifies danger primitives, and emits SIGN/HOLD/REJECT + verdict.json for agent gating. Built for the Sola

lrafasouza/sign-safe-skillcommunitylistedunreviewed

Set up

Skills are installed by placing their folder where your agent loads skills.

The download comes from github.com/lrafasouza/sign-safe-skill itself, not from our server, so what you get is exactly what its author published.

Install instructions come from the README

Skills do not use an MCP config. Follow the steps in the README below.

README

synced Oct 22, 2026

sign-safe -- Solana Signing-Time Safety Gate

CI npm version node license: MIT

A Claude Code skill (and a runnable TypeScript library) that decodes an opaque Solana transaction before you sign it, classifies it against a danger-primitive catalog, computes the signer-perspective outflow, and emits a SIGN / HOLD / REJECT verdict plus a machine-readable verdict.json for autonomous-agent gating.

Extends: solana-dev-skill -- core Solana development (programs, frontend, testing, security). sign-safe layers a signing-time gate on top; it does not duplicate the core skill.

Install

No clone required — run it straight from npm:

# one-off, nothing installed (offline decode + verdict):
npx sign-safe <tx.b64>

# or install the CLI:
npm install -g sign-safe
sign-safe <tx.b64>          # exit code IS the verdict: 0 SIGN / 10 HOLD / 20 REJECT
cat tx.b64 | sign-safe      # or pipe base64 on stdin

# MCP server for autonomous agents (zero-dependency, stdio):
npx sign-safe-mcp

# or as a library (programmatic API + signTransaction/MWA gate):
npm install sign-safe

Requires Node >= 18.20. The core is offline by default — no key, no RPC, no broadcast. To run the test suite or regenerate fixtures, clone the repo instead (see Quickstart).

Judge in 3 minutes

sign-safe answers one question: what does this Solana transaction do to me before I sign it?

git clone https://github.com/lrafasouza/sign-safe-skill sign-safe
cd sign-safe
npm ci
npm run verify:all
npm run demo:attack-pack

Expected result:

  • npm test reports 803 passed / 42 files.
  • The deterministic fixture runner reports 80 PASS / 0 FAIL.
  • The attack replay reports 37/37 held or rejected before signing.
  • False SIGN: 0.
  • Package dry-run and production dependency audit pass.

How to read the verdict:

  • REJECT: known dangerous shape or untrusted bytes; do not sign.
  • HOLD: unclear, policy-sensitive, or human-review case; do not auto-sign.
  • SIGN: recognized benign shape under the current static policy. SIGN is not a universal safety guarantee; recipient, amount, business intent, and external policy still matter.

Proofs at a glance

DocumentWhat it proves
docs/precision-report.md18.4% SIGN / 81.6% HOLD / 0 false-REJECT on frozen 500-tx benign corpus + 37 malicious patterns
docs/evaluator-transcript.mdAnnotated terminal session: build → test → replay → pack
SECURITY.mdThreat model, scope, responsible disclosure
RUBRIC_CHECKLIST.mdBounty rubric mapped to concrete evidence
DEMO.mdFour step-by-step CLI scenarios (SIGN / REJECT / attack replay / Squads hidden-authority)
docs/sample-verdicts/Verbatim JSON outputs for SIGN, HOLD, REJECT, and Squads-HOLD — reproducible from fixtures

Per-transaction CLI (one command)

# REJECT example — authority transfer blocked before signing:
npm run cli -- skill/fixtures/02_setauthority_reject.b64

What offline-static detection catches that simulation misses

Static decoding flags danger shapes that are invisible to simulation because they are structural properties of the message bytes, not execution outcomes:

  • SetAuthority / owner change — authority transfer is in the instruction discriminator, not an account balance delta.
  • System Assign / AssignWithSeed — reassigns program-owner without moving lamports; a simulated balance check shows nothing.
  • SPL Token Approve (delegate) — grants a third party unlimited spend rights; the signer's balance does not change at sign time.
  • Durable nonce (AdvanceNonceAccount at ix0) — a non-expiring message that can be replayed at any future block; simulation runs at one block, misses the replay vector entirely.
  • ALT-obscured instructions — a v0 message with unresolved Address Lookup Table entries hides the real account set; static detection flags rolesUnverified before the ALT is fetched.
  • Squads hidden inner instructions — a vaultTransactionExecute top-level instruction embeds inner CPI calls in an off-chain PDA; simulation sees only the shell unless the PDA is fetched first.

Honest limits: the offline-deterministic core requires no network. Recall figures are corpus-scoped (see docs/precision-report.md for methodology). Transaction simulation (--simulate) and RPC enrichment (--rpc) are optional online extensions — they never downgrade a static REJECT.

For a shorter walkthrough, see DEMO.md. For a compact proof transcript, see docs/evaluator-transcript.md.

What's new in v0.6

  • Now on npm: npx sign-safe <tx.b64> (nothing installed) and npx sign-safe-mcp for agents — see Install. The published CLI is regression-tested by its bin name, so the exit-code gate (0 SIGN / 10 HOLD / 20 REJECT) can never silently break.
  • Real on-chain attack corpus: the actual Drift exploit transactions (April 2026, ~$285M) captured by signature, decoded offline, and frozen as fixtures — both decode to HOLD before signing (durable-nonce advance + Squads v4). Complements the 37 synthetic attack patterns; deliberately small (the publicly-documented, retrievable set), never overclaimed.
  • IDL-cross-verified registry: 69/69 Anchor instruction discriminators checked programmatically against canonical on-chain IDLs (snake_case preimage vs camelCase IDL), so a wrong discriminator fails a test instead of mis-clear-signing a transaction.
  • Mutation + fuzz testing: Stryker on the classifier (70.2% behavioral kill rate, reported as-is, not inflated to a target); a fast-check fuzz harness proves the fail-closed invariant — across 44,288 single-byte mutations of an authority transfer, 0 ever flip to SIGN (and it's falsifiable: disabling the fix yields 243 SIGN).
  • Benign corpus grown 100 → 500 real frozen mainnet transactions, precision recomputed: 18.4% SIGN / 81.6% HOLD / 0% false-REJECT (curated set — not "catches everything").
  • 803 tests across 42 files (up from 777/40 in v0.5).

What's new in v0.5

  • Transaction simulation (--simulate, escalate-only): parses simulateTransaction innerInstructions + pre/post balances to surface real signer outflow and catch swap-output drains (proceeds routed to a non-signer). Advisory only — never downgrades a static REJECT.
  • Native Stake program: Authorize / AuthorizeChecked / AuthorizeWithSeed → REJECT (authority transfer); Withdraw → HOLD (REJECT under --strict).
  • Programmatic API + adapters: import reviewBase64 / reviewWithEnrichment directly, or pass a web3.js / @solana/kit transaction via sign-safe/adapters. Zero-dependency core; web3.js + kit are optional peers.
  • Signing gate + MCP: guardedSignTransaction / MWA wrapper throws on REJECT before the key is touched; a zero-dep sign-safe-mcp stdio server exposes review_transaction to agents.
  • Verdict schema: a requiresHumanReview boolean + documented current Finding.category values for machine consumers, backed by skill/schema/verdict.schema.json and exposed as the MCP outputSchema.
  • Bounded auxiliary-program recognition: Associated Token Account Create/CreateIdempotent are INFO only when the same verified signer funds and owns it; valid UTF-8 Memo is INFO instead of unknown-program HOLD; external/unverified ATA creation, ATA RecoverNested, unknown ATA tags, and invalid Memo payloads still HOLD.
  • More coverage: durable-nonce fee-payer asymmetry (the Drift council shape), the Lighthouse guard as an INFO-only positive signal, and Marginfi v2 + Squads v4 in the registry.
  • 777 tests across 40 files (up from 607/29 in v0.4), including a 13-case adversarial threat sweep and a 37-fixture attack replay pack.

What's new in v0.4

  • Online enrichment (--rpc <url>): resolves Address Lookup Tables, auto-fetches and clear-signs Squads VaultTransaction PDA inner instructions, and screens Token-2022 mint extensions (PermanentDelegate / TransferHook). Without --rpc, behavior is byte-identical and fully offline.
  • Two-tier posture (default): an unknown program writing a value-bearing account is now HOLD instead of REJECT, calibrated by a precision study on 500 real benign mainnet transactions (0 false-REJECTs). Use --strict to restore the aggressive single-tier posture for institutional signing.
  • Clear-signing registry expanded to 12 programs: Metaplex Token Metadata, Metaplex Bubblegum, Jupiter v6, Orca Whirlpools, Raydium AMM v4, Pump.fun, Pump AMM/PumpSwap, Raydium CLMM, Raydium CPMM, Drift, Kamino klend, Meteora DLMM, Programs may have safe and dangerous instruction tiers or recognize-only coverage; unlisted instructions remain HOLD.
  • Precision study (docs/precision-report.md): 500 real frozen mainnet benign transactions + 37 synthetic malicious patterns. At v0.4: 33% SIGN / 67% HOLD / 0% false-REJECT. Malicious recall: 100%.
  • 607 tests across 29 files (up from 292/16 in v0.3).
  • --vault-pda <pubkey> overrides the auto-extracted Squads PDA address.
  • --digest prints the SHA-256 short code for cross-device byte-identity confirmation.
  • --json emits verdict.json only (machine-readable, no human prose).
  • --threshold <lamports> overrides the large-transfer HOLD threshold.

The problem (Drift, 2026)

In late March 2026, signers on Drift's security multisig blind-signed durable-nonce transactions that looked routine but transferred administrative control. A durable-nonce message has no blockhash expiry, so the signed payloads stayed valid until they were executed on April 1, 2026 to drain roughly $285M -- a governance/signing compromise, not a smart-contract bug (Chainalysis, TRM Labs). The signers saw an inscrutable base64 blob and an "Approve" button: no decode, no danger classification, no outflow accounting between the blob and their signature.

sign-safe is the missing layer: a deterministic, offline gate that sits between the bytes and the signature. It is complementary to transaction simulation (Blowfish / Phantom-style) -- static decoding flags authority/ownership changes and danger-primitive shapes that simulation has been shown to miss (Blockaid), while simulation catches economic/oracle outcomes that static decoding cannot. Use both.

sign-safe is also positioned as the wallet-side, usable-today complement to sRFC 39 "Solana Clear Sign": it does not require per-program IDL adoption or wallet firmware changes — it runs as a CLI gate or agent skill over any base64 message, today.

How sign-safe would have caught Drift

The Drift attack had two layers: the transaction was (1) a durable-nonce carrier (non-expiring, can be replayed at any future block), and (2) executed via a Squads vaultTransactionExecute whose CPI inner instruction was an update_admin -- an admin authority transfer to the attacker's address. Neither piece was visible in the signed top-level message bytes; signers saw only the Squads program id and an opaque 8-byte discriminator.

With sign-safe:

  1. Durable nonce alone is NEVER SIGN. An AdvanceNonceAccount at ix0 forces at minimum HOLD -- a non-expiring transaction is always flagged regardless of the rest of the payload.

  2. Squads execute without inner bytes is NEVER SIGN. The offline core detects vaultTransactionExecute by program id + Anchor discriminator (c208a15799a4…) and injects a mandatory HOLD finding (squads-execute-unverified). A signer cannot approve without first fetching the VaultTransaction PDA bytes.

  3. Durable nonce + a REJECT-class inner danger = REJECT (Drift composite). In default mode, nonce + the mandatory unverified-Squads HOLD remains HOLD until the VaultTransaction is fetched or another REJECT-class danger is present. --strict broadens nonce + any non-INFO finding to REJECT.

  4. With --rpc (or the VaultTransaction PDA bytes), the inner update_admin is decoded and named. The offline core borsh-decodes the VaultTransaction, resolves inner program IDs from the embedded account_keys array, and classifies the leading 8 bytes against the Anchor authority-mutation registry. It finds a1b028d53cb8b3e4 = update_admin -> REJECT, emitting a finding labelled "Anchor: update_admin (admin transfer) [inner, via Squads vault]". The signer now sees exactly what they are about to approve: not a Squads shell, but an admin handoff.

Verdict path summary: durable-nonce at ix0 + squads-execute-unverified -> HOLD by default (REJECT under --strict). With the PDA bytes, nonce + anchor-inner-update_admin -> REJECT, exit code 20, before any key is touched.

What it does

Given a base64 message (legacy or v0), the deterministic core:

  1. decodes the wire format with our own parser (no web3.js dependency in the core), enforcing the runtime sanitization invariants (D19),
  2. derives roles with a two-layer writability model — the positional partition (is_writable_index) AND the runtime demotion layer (reserved-account-keys + program-id demotion, SIMD-0105) — exposing both modes; Address-Lookup-Table accounts keep their real writable/readonly role but are marked addressVerified: false (their concrete address is unknown offline),
  3. classifies each instruction against a 37-entry danger catalog covering both SPL Token and Token-2022 (authority/ownership handoffs, program upgrade/close, durable nonces incl. nonce withdrawals, delegate/approve grants, account closes & freezes, mint/supply changes, token burns, large transfers, Native Stake authority/withdrawal flows) plus bounded ATA/Memo auxiliary-program recognition and a pure Token-2022 TLV extension walker, and against a 14-program DeFi/NFT registry (Metaplex Token Metadata, Bubblegum cNFT, Jupiter v6, Orca Whirlpools, Raydium AMM v4, Pump.fun, Pump AMM/PumpSwap, Raydium CLMM, Raydium CPMM, Drift, Kamino klend, Meteora DLMM, Marginfi v2, Squads v4) that names NFT transfers/burns and has a safe-instruction tier so routine swaps SIGN,
  4. surfaces transfer recipients in every SOL and SPL transfer -- the verdict and SIGN reason explicitly name the destination address ("sends X lamports to ADDR"), and an outboundToNonSigner flag identifies when value leaves to an external address,
  5. screens recipients, delegates, and new-authorities against an optional injectable drainer blocklist (ctx.recipientBlocklist): any match becomes a REJECT finding. An optional holdOutboundTransfers policy flag escalates all external-recipient transfers to at least HOLD,
  6. computes the statically-declared signer outflow,
  7. emits a SIGN / HOLD / REJECT verdict + verdict.json, escalating the Drift composite (durable-nonce marker at ix0 + authority change) to REJECT,
  8. clear-signs Squads v4 proposals (WHEN given the VaultTransaction PDA bytes, or automatically with --rpc): offline borsh-decodes the PDA, resolves inner program IDs from the embedded account_keys array, classifies them against the 11-entry Anchor authority-mutation registry (catalog/anchor-danger.json), and folds the inner findings into the verdict,
  9. computes a deterministic transaction digest (digest.ts, SHA-256 with a 20-hex-char human-verifiable short code XXXX-XXXX-XXXX-XXXX-XXXX) so signers can confirm byte identity across devices out-of-band.

Fail-closed by construction: malformed input -> REJECT; unresolved ALT references can never produce SIGN; truly unknown programs can never produce SIGN, and in --strict mode if they write to a value-bearing account they force REJECT; a recognized DeFi/NFT program with an unrecognized instruction is HOLD, never SIGN.

The SIGN / HOLD / REJECT contract

The verdict is a three-way, severity-ordered decision. The decision is always the worst outcome triggered by any instruction (REJECT dominates HOLD dominates SIGN), and the exit code mirrors it so agents and scripts can gate on it directly.

VerdictExitMeaningTriggered by
SIGN0Every instruction is recognized and within thresholds. Still not a guarantee of intent — verify recipients and amounts yourself.No danger primitives, no unknown programs, no unverified ALT references, outflow under threshold.
HOLD10Needs a human. Plausibly legitimate but capable of loss; do not auto-sign.Delegate grants, account closes, durable-nonce setup, permanent-delegate, large transfers, unknown programs (default mode), or any unverified ALT reference.
REJECT20Do not sign. Either a catastrophic primitive or the bytes could not be trusted.Authority handoffs, program upgrades, the Drift-style durable-nonce composite, blocklist hits, decode failure, or (in --strict mode) an unknown program writing to a value-bearing account.

A SIGN verdict is deliberately the weakest claim in the system: it says "nothing in this blob is recognized as dangerous," never "this is safe."

Quickstart

For using the tool, prefer the npm install above (npx sign-safe <tx.b64>). The clone below is for development — running the test suite, regenerating fixtures, or hacking on the core.

git clone https://github.com/lrafasouza/sign-safe-skill sign-safe
cd sign-safe
npm install
npm run gen-fixtures   # (re)generate the 10 synthetic .b64 fixtures from @solana/web3.js
npm test               # 803 tests across 42 files
npm run verify:all     # build + tests + fixtures + attack replay + pack + production audit
npm run demo:attack-pack

Offline (no RPC required)

# Review a base64 message file:
node --import tsx skill/src/cli.ts skill/fixtures/02_setauthority_reject.b64

# Pipe base64 on stdin:
cat msg.b64 | node --import tsx skill/src/cli.ts

# JSON only (for agents):
node --import tsx skill/src/cli.ts msg.b64 --json

# Override the large-transfer threshold (lamports):
node --import tsx skill/src/cli.ts msg.b64 --threshold 5000000000

# Compute the SHA-256 short code for cross-device confirmation:
node --import tsx skill/src/cli.ts msg.b64 --digest

# Institutional / strict mode (unknown writable -> REJECT):
node --import tsx skill/src/cli.ts msg.b64 --strict

With RPC (ALT resolution + Squads auto-clear-sign)

--rpc is a trusted enrichment input. A malicious or faulty endpoint can withhold or falsify enrichment, but it cannot remove or downgrade findings derived from the signed transaction bytes. Helius MCP/RPC is a natural production backend; record the endpoint and treat its responses as provenance-bearing data.

# v0 transaction with ALTs: resolves lookup tables, upgrades HOLD -> SIGN if clean
node --import tsx skill/src/cli.ts msg_v0.b64 --rpc https://api.mainnet-beta.solana.com

# Squads proposal: auto-fetches the VaultTransaction PDA and decodes inner instructions
node --import tsx skill/src/cli.ts squads_propose.b64 --rpc https://api.mainnet-beta.solana.com

# Override the auto-extracted Squads vault PDA address:
node --import tsx skill/src/cli.ts squads_propose.b64 \
  --rpc https://api.mainnet-beta.solana.com \
  --vault-pda 5udTJFsR7jnBTcTV6sRYbmLcDrBC2RK8eVp8rvBYMg5F

verdict.json shape

The --json flag emits a machine-readable verdict that agents gate on:

{
  "schema": "sign-safe/verdict@1",
  "decision": "SIGN" | "HOLD" | "REJECT",   // gate on this
  "reason": "string",                         // qualified, factual; never reassuring
  "messageVersion": "legacy" | 0,
  "worstSeverity": "INFO" | "HOLD" | "REJECT",
  "findings": [
    {
      "id": "spl-set-authority",
      "label": "SPL Token SetAuthority",
      "severity": "REJECT",
      "instructionIndex": 0,
      "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
      "detail": "SetAuthority on spl-token: authority_type=0 (MintTokens), new_authority=<base58>.",
      "mapsToLoss": "Hands mint/freeze/owner authority to an attacker, who can then mint, freeze, or seize at will."
    }
  ],
  "outflow": {
    "lamports": "0",          // base-10 decimal string (exact past 2^53)
    "splTransfers": [],
    "exceedsLamportThreshold": false
  },
  "flags": {
    "unknownProgramPresent": false,
    "altLookupsPresent": false,
    "rolesUnverified": false,
    "decodeFailed": false
  },
  "unknownPrograms": []
}

See skill/references/verdict-contract.md for the full decision-rule specification and the banned-reassurance-phrase contract.

CLI flags

FlagDefaultDescription
--rpc <url>(offline)Enable on-chain enrichment: ALT resolution, Squads PDA auto-fetch, Token-2022 mint-extension screening.
--vault-pda <pubkey>(auto-extracted)Override the Squads VaultTransaction PDA address (requires --rpc).
--simulateoffAfter the offline pass, call simulateTransaction (requires --rpc) for inner-instruction + balance-diff context. Escalate-only: can raise SIGN → HOLD/REJECT, never downgrade a static verdict.
--strictoffRestore aggressive posture: unknown writable → REJECT, broad durable-nonce composite.
--threshold <lamports>1 SOLOverride the large-transfer HOLD threshold.
--jsonoffEmit verdict.json only (no human prose). Exit code still mirrors verdict.
--digestoffPrint the SHA-256 short code (XXXX-XXXX-XXXX-XXXX-XXXX) for cross-device byte-identity confirmation.

Exit codes: 0 = SIGN, 10 = HOLD, 20 = REJECT.

Real CLI output: a REJECT (fixture 02) vs a SIGN (fixture 01)

The contrast below is the whole point of the skill. Same command, two opaque base64 blobs, two opposite verdicts — decided offline, before any signature.

A dangerous transaction is caught (fixture 02 — an SPL SetAuthority):

$ node --import tsx skill/src/cli.ts skill/fixtures/02_setauthority_reject.b64

================================================================
[ REJECT ]  Contains a REJECT-class danger primitive: SPL Token SetAuthority.
================================================================
message version : legacy
worst severity  : REJECT
static outflow  : 0 lamports
flags           : unknownProgram=false alt=false unverifiedRoles=false decodeFailed=false

findings (1):
  - [REJECT] ix#0 SPL Token SetAuthority
      SetAuthority on spl-token: authority_type=0 (MintTokens), new_authority=<base58>.
      maps to loss: Hands mint/freeze/owner authority to an attacker, who can then mint, freeze, or seize at will.

verdict.json:
{
  "schema": "sign-safe/verdict@1",
  "decision": "REJECT",
  "reason": "Contains a REJECT-class danger primitive: SPL Token SetAuthority.",
  "messageVersion": "legacy",
  "worstSeverity": "REJECT",
  "findings": [
    {
      "id": "spl-set-authority",
      "label": "SPL Token SetAuthority",
      "severity": "REJECT",
      "instructionIndex": 0,
      "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
      "detail": "SetAuthority on spl-token: authority_type=0 (MintTokens), new_authority=<base58>.",
      "mapsToLoss": "Hands mint/freeze/owner authority to an attacker, who can then mint, freeze, or seize at will."
    }
  ],
  "outflow": { "lamports": "0", "splTransfers": [], "exceedsLamportThreshold": false },
  "flags": { "unknownProgramPresent": false, "altLookupsPresent": false, "rolesUnverified": false, "decodeFailed": false },
  "unknownPrograms": []
}
$ echo $?
20

A benign transaction passes (fixture 01 — a 0.01 SOL System transfer):

$ node --import tsx skill/src/cli.ts skill/fixtures/01_safe_sol_transfer.b64

================================================================
[ SIGN ]  Recognized instructions within thresholds; no danger primitives, unknown programs, or unverified ALT references. Not a guarantee of intent -- verify the recipients and amounts yourself.
================================================================
message version : legacy
worst severity  : INFO
static outflow  : 10000000 lamports
flags           : unknownProgram=false alt=false unverifiedRoles=false decodeFailed=false

findings (0):
  (none)

verdict.json:
{
  "schema": "sign-safe/verdict@1",
  "decision": "SIGN",
  "reason": "Recognized instructions within thresholds; no danger primitives, unknown programs, or unverified ALT references. Not a guarantee of intent -- verify the recipients and amounts yourself.",
  "messageVersion": "legacy",
  "worstSeverity": "INFO",
  "findings": [],
  "outflow": { "lamports": "10000000", "splTransfers": [], "exceedsLamportThreshold": false },
  "flags": { "unknownProgramPresent": false, "altLookupsPresent": false, "rolesUnverified": false, "decodeFailed": false },
  "unknownPrograms": []
}
$ echo $?
0

Even a SIGN verdict refuses to call the transaction "safe": it states what was checked and hands intent verification back to the human. CLI exit codes mirror the verdict so scripts and agents can gate on them: 0 = SIGN, 10 = HOLD, 20 = REJECT.

Precision

A precision study against a frozen, offline corpus of 500 real benign mainnet transactions (from slots 428289500 and 428290000) + 37 synthetic malicious patterns across 7 attack families. Full methodology and per-fixture detail: see docs/precision-report.md.

Default mode (two-tier posture):

MetricValue
Benign corpus size500 transactions
Benign false-REJECT0
Benign SIGN rate18.4%
Benign HOLD rate81.6%
Malicious recall100% * (37 / 37)

* Across the curated 37-fixture corpus (illustrative, mostly-synthetic set, not a population sample); recall measures coverage of these fixtures only and does not mean every malicious transaction is caught. Zero benign transactions are false-REJECTed.

Demo fixtures

The 10 synthetic fixtures under skill/fixtures/ cover the main verdict paths. Each is a serialized base64 message generated from @solana/web3.js and cross-validated against both web3.js (v1) and @solana/kit (v2).

FixtureDescriptionExpected verdict
01_safe_sol_transfer.b640.01 SOL System Transfer (within threshold)SIGN
02_setauthority_reject.b64SPL Token SetAuthority (mint authority handoff)REJECT
03_bpf_upgrade_reject.b64BPF Loader Upgrade (bytecode replacement)REJECT
04_durable_nonce_drift.b64Durable nonce advance at ix0 (Drift replay vector)HOLD
05_approve_delegate_hold.b64SPL Token Approve (delegate spend grant)HOLD
06_close_account_hold.b64SPL Token CloseAccount (sweeps lamports)HOLD
07_large_transfer_hold.b64SOL System Transfer > 1 SOL thresholdHOLD
08_unknown_program_reject.b64Unknown program writing a value-bearing accountHOLD (default) / REJECT (--strict)
09_v0_alt_unverified.b64v0 message with Address Lookup Table references (offline)HOLD
10_token2022_permdelegate_hold.b64Token-2022 transfer with PermanentDelegate mint extensionHOLD

Each fixture has a committed *.verdict.json golden. Regenerate all goldens with npm run gen-goldens (review each decision before committing — the goldens are a security contract).

The danger catalog (37 primitives)

idprogramdetectionseveritymaps to loss
spl-set-authoritySPL TokenSetAuthority (6)REJECTmint/freeze/owner authority handoff
token2022-set-authorityToken-2022SetAuthority (6)REJECT+ extension authorities
token2022-permanent-delegateToken-2022PermanentDelegate (35)HOLDirrevocable seizure of any holder's tokens
bpf-upgradeBPF Loader UpgradeableUpgrade (3)REJECTbytecode replacement -> instant rug
bpf-set-upgrade-authorityBPF Loader UpgradeableSetAuthority (4)REJECTupgrade-authority handoff -> later rug
bpf-set-upgrade-authority-checkedBPF Loader UpgradeableSetAuthorityChecked (7)REJECTupgrade-authority handoff (checked)
bpf-closeBPF Loader UpgradeableClose (5)REJECTdestroys/drains buffer/programdata account
system-assignSystemAssign (1)REJECTreassigns account owner to arbitrary program
system-assign-with-seedSystemAssignWithSeed (10)REJECTseed-account ownership change
system-transfer-with-seedSystemTransferWithSeed (11)HOLDSOL outflow from a seed-derived account
durable-nonce-advanceSystemAdvanceNonceAccount (4) at ix0 onlyHOLDreplay/hold vector (Drift 2026); + authority change ⇒ REJECT
durable-nonce-initializeSystemInitialize/Authorize Nonce (6/7)HOLDsets/redirects nonce authority
stake-authorizeNative StakeAuthorize/AuthorizeChecked/WithSeedREJECTstake or withdraw authority handoff
stake-withdrawNative StakeWithdraw (4)HOLDSOL outflow from a stake account; REJECT under --strict
spl-approve-delegateSPL TokenApprove/ApproveChecked (4/13)HOLDdelegate spend -> silent drain
spl-close-accountSPL TokenCloseAccount (9)HOLDsweeps lamports to a destination
token2022-approve-delegateToken-2022Approve/ApproveChecked (4/13)HOLDdelegate spend -> silent drain
token2022-close-accountToken-2022CloseAccount (9)HOLDsweeps lamports to a destination
spl-freeze-accountSPL TokenFreezeAccount (10)HOLDfreezes a holder -> honeypot / denial
token2022-freeze-accountToken-2022FreezeAccount (10)HOLDfreezes a holder -> honeypot / denial
spl-mint-toSPL TokenMintTo/MintToChecked (7/14)HOLDsupply inflation -> dilute / dump
token2022-mint-toToken-2022MintTo/MintToChecked (7/14)HOLDsupply inflation -> dilute / dump
spl-burnSPL TokenBurn/BurnChecked (8/15)HOLDirreversible loss of the signer's tokens
token2022-burnToken-2022Burn/BurnChecked (8/15)HOLDirreversible loss of the signer's tokens
spl-withdraw-excess-lamportsSPL TokenWithdrawExcessLamports (38)HOLDsweeps excess SOL from a token account
token2022-withdraw-excess-lamportsToken-2022WithdrawExcessLamports (38)HOLDsweeps excess SOL from a token account
spl-unwrap-lamportsSPL TokenUnwrapLamports (45)HOLDSOL outflow from a wrapped-SOL account
token2022-unwrap-lamportsToken-2022UnwrapLamports (45)HOLDSOL outflow from a wrapped-SOL account
spl-batchSPL TokenBatch (255)HOLDbatched sub-instructions not individually decoded
token2022-batchToken-2022Batch (255)HOLDbatched sub-instructions not individually decoded
token2022-confidential-mintToken-2022ConfidentialMintBurn::Mint (42,3)HOLDconfidential supply inflation
token2022-confidential-burnToken-2022ConfidentialMintBurn::Burn (42,4)HOLDirreversible confidential burn
token2022-withdraw-withheld-feesToken-2022TransferFee::WithdrawWithheld (26,2/3)HOLDwithheld-fee token outflow
token2022-confidential-withdraw-withheld-feesToken-2022ConfTransferFee::WithdrawWithheld (37,1/2)HOLDwithheld-fee token outflow (confidential)
token2022-permissioned-burnToken-2022PermissionedBurn::Burn (46,1/2/3)HOLDirreversible permissioned token burn
system-withdraw-nonceSystemWithdrawNonceAccount (5)HOLDSOL drain from a nonce account
system-large-transferSystemTransfer (2) over thresholdHOLDdirect SOL outflow above threshold

See skill/references/danger-catalog.md for full rationale and skill/catalog/danger-primitives.json for the machine-readable source.

Scope

In scope (what sign-safe decides):

  • Decoding legacy and v0 serialized messages from opaque base64, with our own dependency-free wire parser.
  • Deriving signer / writable / readonly roles and flagging every Address-Lookup-Table reference as unverified (resolved with --rpc).
  • Classifying instructions against a 37-entry danger-primitive catalog (authority handoffs, program upgrades, durable nonces, delegate grants, account closes, Native Stake authority/withdrawal flows, large transfers) plus bounded ATA/Memo recognition and a pure Token-2022 TLV extension walker.
  • DeFi/NFT program registry (14 programs, catalog/program-registry.json): Metaplex Token Metadata, Metaplex Bubblegum (cNFT), Jupiter Aggregator v6, Orca Whirlpools, Raydium AMM v4, Pump.fun, Pump AMM/PumpSwap, Raydium CLMM, Raydium CPMM, Drift, Kamino klend, Meteora DLMM, Marginfi v2, Squads v4. Every Anchor (anchor-8) discriminator is recompute-locked (sha256("global:"+ixName)) by skill/test/registry-discriminators.test.ts and cross-verified against the canonical on-chain IDLs (audit + provenance in catalog/idl-sources.json). Safe instructions SIGN; dangerous instructions are labeled at the correct severity; unrecognized instructions on recognized programs are HOLD (never SIGN).
  • Transfer recipient surfacing: every SOL and SPL transfer surfaces the destination address in the outflow and the SIGN reason string. An outboundToNonSigner flag identifies when value is leaving to an external address.
  • Injectable blocklist: ctx.recipientBlocklist accepts a set of known-bad addresses; any transfer recipient, SPL Approve delegate, or SetAuthority new-authority that matches is REJECT.
  • Outbound-transfer policy (ctx.holdOutboundTransfers): escalate all external-recipient transfers to at least HOLD for strict signing policies.
  • Offline clear-signing of Squads v4 VaultTransaction proposals: when the PDA bytes are provided (or auto-fetched with --rpc), decoding inner instructions and classifying them against an 11-entry Anchor authority-mutation registry.
  • Durable-nonce escalation: a bare durable nonce is HOLD; combined with a REJECT-class finding it escalates to REJECT (the Drift-class composite). governanceContext makes even a bare durable nonce REJECT. --strict broadens the composite to trigger on any non-INFO finding.
  • Out-of-band digest: SHA-256 + 20-hex-char short code for cross-device byte identity confirmation (skill/src/digest.ts, exposed by the CLI --digest flag).
  • Computing the statically-declared signer outflow (lamports + SPL transfers).
  • Emitting a SIGN / HOLD / REJECT verdict and a machine-readable verdict.json with a verdict-mirroring exit code.

Out of scope (and what to use instead):

Not thisUse instead
A program source-code audit/audit-solana
A debugger for a landed/failed transaction/debug-user-tx
A replacement for core Solana dev knowledgesolana-dev-skill
Simulating runtime balance changes against live cluster statea simulation/simulateTransaction tool
Catching post-sign state changes or TOCTOU racessimulation (Blowfish / Phantom-style) + Lighthouse
Signing, broadcasting, or touching a private keyyour wallet — sign-safe never asks for a key
A guarantee that a SIGN'd transaction matches your intentyour own check of recipients and amounts

sign-safe is a static, offline (or optionally enriched), fail-closed pre-signing gate, not a runtime simulator and not a signer. It tells you what a blob is; you still confirm it is what you meant.

What sign-safe catches and what it does not (honest coverage)

Catches (HOLD or REJECT by static analysis):

CategoryHow it is caught
Owner/authority reassignmentsystem-assign, spl-set-authority, bpf-set-upgrade-authority, etc. — all REJECT
Durable-nonce non-expirydurable-nonce-advance at ix0 — always at least HOLD, escalates to REJECT when combined with a REJECT-class danger
Delegate / approval grantsspl-approve-delegate, token2022-approve-delegate — HOLD
Account closes and freezesspl-close-account, spl-freeze-account (and Token-2022 equivalents) — HOLD
Honeypot / program-upgradebpf-upgrade, bpf-close — REJECT
Squads inner authority transferInner update_admin / set_admin / etc. decoded from VaultTransaction PDA — REJECT
NFT / cNFT transfer (theft vector)Metaplex Transfer (disc 49), Bubblegum Transfer (disc a334c8e7…) — REJECT
NFT delegate grantMetaplex Delegate (disc 44), Bubblegum Delegate (disc 5a934bb2…) — HOLD
NFT burnMetaplex Burn (disc 41/29), Bubblegum Burn — REJECT
Unknown DeFi program (default: writing value)Unknown program present → HOLD; --strict: writing value → REJECT
Known DeFi program, unrecognized instructionRecognized (Jupiter/Orca/Raydium/etc.) but unlisted instruction — HOLD
Transfer recipient on drainer blocklistrecipientBlocklist injection — REJECT per hit
Outbound transfer policyholdOutboundTransfers flag — HOLD for any external-recipient transfer

Not caught by static analysis alone (use the indicated mitigation):

Not caughtWhyMitigation
A plain SOL transfer to an attackerSIGN by default — recipient is surfaced in the verdict, but sign-safe cannot know if the address is malicious without external informationProvide recipientBlocklist via enrich.ts reconRecipients() (community blocklist); verify the destination address yourself
Address-poisoning / lookalike mintsRequires recognizing visual similarity of base58 strings — beyond static decodingVisual UI hygiene; blocklist of known poison addresses
Economic/oracle outcomesToken price at signing is not in the bytesPair with transaction simulation (Blowfish / Phantom-style)
Post-sign state changes / TOCTOUIf on-chain state changes between gate time and landing, the gate cannot knowLighthouse or simulation with state pinning
Endpoint compromise (malicious injector)If the bytes are tampered before reaching sign-safe, the gate cannot knowHardware wallet or separate device; out-of-band digest (--digest) confirms byte identity across devices
A new dangerous instruction on a known program not yet in the catalogOnly catalogued primitives are flagged by nameAdd a data row to catalog/danger-primitives.json or catalog/program-registry.json (one-line change, no code edit)

One-line coverage summary: sign-safe catches owner-reassignment, durable nonces, approvals, closes, honeypots, hidden Squads inner instructions, and named NFT/DeFi dangers; a plain transfer to an attacker is SIGN by default (surfaced recipient + optional blocklist/policy mitigate it); address-poisoning, lookalike mints, economic-oracle effects, post-sign TOCTOU, and endpoint malware are out of scope and need reputation data, simulation, or endpoint security.

Pattern comparison: static analysis vs balance-delta-only defenses

The table below lists attack patterns that sign-safe detects from the signed bytes but that balance-delta-only defenses tend not to flag — because balance deltas are not meaningful until after simulation, and these patterns either have no balance delta at all (authority handoffs), or mask one (Squads hidden inner instructions, ALT-obscured accounts).

Attack patternFinding id(s)Why balance-delta-only defenses tend not to flag it
Token / mint authority handoffspl-set-authority, token2022-set-authorityNo SOL or token balance delta at signing time; the loss (minting, freezing, seizing) happens after the authority is transferred
Program upgrade authority transferbpf-set-upgrade-authorityNo on-chain state change visible at signing; the upgrade that drains users happens in a subsequent transaction
Account ownership reassignmentsystem-assign, system-assign-with-seedOwner-program change has no balance delta; the new owner exploits the account in a later transaction
Delegate approval for silent drainspl-approve-delegateApproval grants a spending right with no immediate balance change; the delegate calls transfer later, invisibly
Durable-nonce hold/replaydurable-nonce-advanceTransaction remains replayable indefinitely; no balance delta occurs until the attacker decides to submit the held transaction
ALT-obscured instruction accountsaltLookupsPresent + rolesUnverified flags → HOLDAccount roles cannot be determined without resolving the ALT on-chain; sign-safe flags this conservative state rather than silently passing
Squads hidden inner instructionsquads-execute-unverified (offline) / anchor-inner-update_admin (with PDA bytes)The outer VaultTransaction shell has no visible balance delta; the destructive inner CPI is only visible by decoding the PDA bytes

Each finding id listed above is present in skill/catalog/danger-primitives.json or derived from skill/catalog/anchor-danger.json. ALT-obscured instructions are a flag-driven path, not a catalog entry.

Why this skill is different: it actually runs, and it is tested

Most skills are prose. This one ships a small, pure-function TypeScript core with a deterministic, fully offline test suite (vitest + fast-check), 803 tests across 42 files (npm test):

  • 10 synthetic golden fixtures -- serialized messages built with @solana/web3.js, decoded by our own parser, verdicts deep-equal-checked against committed verdict.json goldens (regenerable, reviewed, with npm run gen-goldens).
  • 5 REAL mainnet fixtures (skill/fixtures/real/) -- captured once from api.mainnet-beta.solana.com, frozen as base64 of the signed message bytes with full provenance (signature, slot, cluster, capture date -- see each *.meta.json) and decoded offline at run time. No test makes a network call.
  • Differential cross-validation on EVERY fixture against two independent references -- @solana/web3.js (v1) and @solana/kit (v2) -- on version, header, static keys, blockhash, per-instruction program ids / account indexes / data, and ALT count; plus a disagreement ⇒ fail-closed (V10) guard.
  • Property-based (fast-check, seed 42): round-trip identity (encode(decode(b)) === b), fail-closed on arbitrary uint8Array, no trailing-byte tolerance, and compact-u16 invariants over [0, 65535].
  • Role-derivation goldens (SDK test_is_writable_index / test_is_maybe_writable, program-id demotion flip, multi-lookup ordering, reserved-key vs Incinerator).
  • Determinism, fail-closed adversarial inputs (truncation, trailing garbage, out-of-range index, unsupported version 0x81, empty/single-byte), prompt-injection (decoded data never interpolated, V8), and a no-network assertion (core modules import no http/https/net/fetch).

Install

The repo is self-contained — the core has no runtime dependency on any sibling skill, and there is no postinstall and no curl anywhere in it. It installs two ways: as a submodule inside a skills kit, or standalone.

As a submodule inside solana-ai-kit (kit integration)

Add sign-safe to an existing kit (e.g. solanabr/solana-ai-kit) as a git submodule, then commit the pointer:

# from the root of your fork of solanabr/solana-ai-kit
git submodule add https://github.com/lrafasouza/sign-safe-skill .claude/skills/ext/sign-safe
git commit -m "feat: add sign-safe signing-time safety gate as a submodule"

# anyone cloning the kit then pulls the skill in with:
git clone --recurse-submodules https://github.com/solanabr/solana-ai-kit
# or, in an existing checkout:
git submodule update --init --recursive

The kit picks up .claude/skills/ext/sign-safe/skill/SKILL.md (add a routing row to the hub .claude/skills/SKILL.md), the /sign-review command, and the signing-output rule. The skill references the core solana-dev-skill by name only (never by a relative path or a nested submodule), so it composes with the rest of the kit without creating a second, conflicting dependency graph.

Standalone (clone and run the gate locally)

git clone https://github.com/lrafasouza/sign-safe-skill sign-safe
cd sign-safe
npm install
npm run gen-fixtures   # (re)generate the 10 synthetic .b64 fixtures from @solana/web3.js
npm test               # vitest: golden + differential (web3.js + kit) + PBT + real fixtures + fail-closed

The real mainnet fixtures under skill/fixtures/real/ are committed (frozen bytes + provenance) and read offline; gen-fixtures does NOT touch them and no test makes a network call.

You can also drop the skill/, commands/, and rules/ directories directly under a Claude Code skills root (e.g. ~/.claude/skills/sign-safe/) if you are wiring it into Claude Code by hand rather than through a kit.

How it is tested

$ npm test            # vitest run -- the full suite (exits nonzero on any fail)

 ✓ skill/test/decode.test.ts                     (25 tests)   compact-u16 vectors/rejection, D19, versions, fail-closed
 ✓ skill/test/roles.test.ts                      (13 tests)   is_writable_index + demotion goldens, multi-lookup, reserved
 ✓ skill/test/classify.test.ts                   (21 tests)   Transfer/TransferChecked, SetAuthority, TLV, loader, routing
 ✓ skill/test/catalog-coverage.test.ts           (29 tests)   dangerous shapes never SIGN (SPL+T22 + confidential + permissioned)
 ✓ skill/test/verdict.test.ts                    (24 tests)   durable nonce ix0 gate, Drift composite, prompt-injection, V2
 ✓ skill/test/squads.test.ts                     (28 tests)   VaultTransaction borsh decode, discriminator math, ALT-unresolved fail-closed
 ✓ skill/test/squad-verdict.test.ts              (18 tests)   Squads execute verdict integration, nonce+unverified execute HOLD by default, strict/decoded-inner REJECT
 ✓ skill/test/digest.test.ts                     (14 tests)   SHA-256 digest, short-code format, determinism, out-of-band byte identity
 ✓ skill/test/pbt.test.ts                        ( 7 tests)   round-trip, fail-closed, no-trailing, compact-u16 (fast-check)
 ✓ skill/test/fixtures.test.ts                   (52 tests)   golden + web3.js + kit differential + disagreement + no-network
 ✓ skill/test/real-fixtures.test.ts              (14 tests)   REAL mainnet txs decoded offline + cross-validated
 ✓ skill/test/fulltx.test.ts                     ( 9 tests)   full signed-tx input stripped + fail-closed (W011 §7)
 ✓ skill/test/program-registry.test.ts           (57 tests)   DeFi/NFT registry: 14 programs; dangerous instructions named; unrecognized HOLD
 ✓ skill/test/recipient.test.ts                  ( 8 tests)   System + SPL Transfer recipient surfacing; outboundToNonSigner; ALT-unresolved marker
 ✓ skill/test/reputation.test.ts                 (16 tests)   blocklist REJECT; holdOutboundTransfers HOLD; screenAddresses unit; reconRecipients stub
 ✓ skill/test/legacy-runner.test.ts              ( 1 test )   runs the standalone node smoke runner
 ✓ skill/test/alt.test.ts                        (...)        ALT bincode decoder, resolvedAltTables channel, fail-closed on bad layout
 ✓ skill/test/rpc.test.ts                        (29 tests)   RPC fetcher; injectable stub; offline-identical without --rpc
 ✓ skill/test/cli-args.test.ts                   (17 tests)   CLI flag parsing: --rpc, --vault-pda, --strict, --threshold, --json, --digest
 ✓ skill/test/registry-discriminators.test.ts    (133 tests)  self-verifying: sha256("global:"+ixName)[..8] matches every registry entry
 ✓ skill/test/precision.test.ts                  (11 tests)   offline precision harness: 500 benign + 37 malicious, confusion matrix
 ✓ skill/test/schema.test.ts                     (14 tests)   verdict JSON Schema + MCP outputSchema contract
 ✓ skill/test/auxiliary-programs.test.ts         ( 7 tests)   ATA/Memo bounded recognition; external ATA/RecoverNested/invalid memo still HOLD
 ✓ skill/test/submission-assets.test.ts          ( 9 tests)   SECURITY/SUBMISSION/RUBRIC docs + evaluator demo/transcript/example proof
 ✓ skill/test/extract-vault-address.test.ts      ( 4 tests)   auto-extract Squads vault PDA from account index 2
 ... (additional files)

 Test Files  42 passed (42)
      Tests  803 passed (803)

There are two entry points: npm test (vitest, the full suite) and npm run test:fixtures (the dependency-light standalone node runner, usable in a minimal checkout and used as the CI determinism oracle). npm run gen-goldens regenerates the committed golden verdict.json from the current core (only after reviewing each decision — the goldens are a security contract).

Run in CI on every push/PR via .github/workflows/ci.yml (npm ci + type-check + npm test), across Node 20 and 22, with a determinism gate (two runs of the node runner must be byte-identical) and a fixture-drift guard (npm run gen-fixtures must not change any committed .b64).

The real fixtures are captured ONCE and committed; the suite reads the frozen bytes and makes no network calls at run time. Re-capturing is a manual, out-of-band step (see skill/fixtures/real/*.meta.json for provenance).

Solana libraries are used only for fixture generation and cross-validation, never by the core. The cross-check now covers all 10 fixtures against two independent, current implementations — @solana/web3.js (v1) and @solana/kit (v2, the modern stack) — using kit's getCompiledTransactionMessageDecoder on the same serialized bytes. The core decoder is our own dependency-free wire parser, so agreeing with both proves it is correct, not merely self-consistent or pinned to a single legacy library.

The banned-reassurance-phrase contract is executable, not just prose: skill/src/banned.ts is run over every verdict's narrative fields inside buildVerdict/rejectVerdict, so any reason or finding string that reintroduces "safe" / "no risk" / etc. fails loud (and the gate fails closed to REJECT).

Validation

A reviewer can reproduce the entire result from a clean clone with the following commands and no network access at test time:

npm install            # deps for generation + cross-validation only (no postinstall, no curl)
npm run gen-fixtures   # rebuild the 10 synthetic .b64 from @solana/web3.js (deterministic, byte-identical)
npm test               # 803 checks, 42 files, fully offline; exits nonzero on any failure
npm run demo:attack-pack # replay 37 curated malicious fixtures; fails on any SIGN
npm run verify:all     # build + tests + fixtures + attack replay + pack dry-run + production audit

Expected: Tests 803 passed (803), and git status clean afterward (the deterministic generator reproduces the committed .b64 byte-for-byte). To also confirm the type contract: npx tsc --noEmit (exit 0).

What those 803 checks actually validate:

Coverage areaWhereWhat it proves
Golden verdictsfixtures.test.tsAll 10 synthetic fixtures' verdicts deep-equal committed verdict.json goldens (the SIGN/HOLD/REJECT contract is frozen).
Dual-reference cross-validationfixtures.test.ts, real-fixtures.test.tsOur dependency-free decoder agrees with both @solana/web3.js (v1) and @solana/kit (v2) on version, header, static keys, blockhash, per-ix program id/accounts/data, and ALT count — on every fixture.
Property-basedpbt.test.tsfast-check (seed 42): encode(decode(b)) === b round-trip, fail-closed on arbitrary Uint8Array, no trailing-byte tolerance, compact-u16 invariants over [0, 65535].
Real mainnet fixturesreal-fixtures.test.ts5 frozen mainnet txs decoded offline and cross-validated; each carries a *.meta.json with signature, slot, cluster, and capture date for provenance.
SDK role / demotion goldensroles.test.tsTwo-layer writability against SDK semantics: is_writable_index partition, runtime program-id demotion flip (SIMD-0105), multi-lookup ordering, reserved-key set vs Incinerator, ALT accounts marked addressVerified: false.
Full-tx input + untrusted data (W011)fulltx.test.tsA full signed transaction (signatures + message) is detected, signatures stripped (never verified), and the inner message reaches the same verdict as the bare message; garbage fails closed.
Catalog coverage (never false-SIGN)catalog-coverage.test.tsDangerous shapes most easily missed — Token-2022 Approve/CloseAccount/Freeze/MintTo, Burn, System WithdrawNonceAccount, large CreateAccount — must never return SIGN.
Squads VaultTransaction decodesquads.test.tsDiscriminator math, borsh decode of frozen real mainnet PDA fixture, ALT-space program-id resolution (fail-closed null when index >= accountKeys.len), structural fail-closed.
Squads + verdict integrationsquad-verdict.test.tsExecute without inner bytes -> HOLD; with inner update_admin -> REJECT; durable-nonce + unverified execute -> HOLD by default, REJECT under --strict; governanceContext -> REJECT.
Transaction digestdigest.test.tsSHA-256 correctness, short-code format and determinism, out-of-band identity confirmation, no-network purity.
Fail-closed / adversarialdecode.test.ts, verdict.test.tsTruncation, trailing garbage, out-of-range index, unsupported version 0x81, empty/single-byte → REJECT; unresolved ALT never SIGN; prompt-injection (V8); banned reassurance phrases fail loud.
DeFi/NFT program registryprogram-registry.test.ts14 programs with verified per-instruction discriminators; safe instructions SIGN; dangerous instructions labeled; unrecognized-instruction HOLD (fail-closed); truly-unknown-program REJECT unchanged (strict) / HOLD (default).
Self-verifying discriminatorsregistry-discriminators.test.ts133 checks: sha256("global:"+ixName)[..8] recomputed live and compared against every entry in program-registry.json.
Recipient surfacingrecipient.test.tsSystem Transfer surfaces recipient base58 address, outboundToNonSigner true when non-signer, SIGN reason names destination; SPL Transfer, TransferChecked, ALT-sourced recipient marked unresolved.
Auxiliary native programsauxiliary-programs.test.tsATA Create/CreateIdempotent is INFO only when the same verified signer funds and owns it; valid UTF-8 Memo is INFO; ATA creation for distinct-signers/non-signer/unresolved wallets, RecoverNested, unknown ATA tags, and invalid Memo payloads remain HOLD.
Verdict schema + MCP contractschema.test.ts, mcp.test.tsEvery fixture verdict validates against skill/schema/verdict.schema.json; the MCP review_transaction tool advertises that schema as outputSchema.
Submission proof assetssubmission-assets.test.tsSECURITY.md, SUBMISSION.md, RUBRIC_CHECKLIST.md, DEMO.md, docs/evaluator-transcript.md, examples/guarded-sign-reject.ts, verify:all, and demo:attack-pack stay present and consistent with current evidence.
Blocklist + policyreputation.test.tsBlocklist REJECT on SOL transfer to known-bad address; SPL Approve delegate blocklist; holdOutboundTransfers escalation; reconRecipients injectable.
ALT decoding + resolutionalt.test.tsBincode layout decoder, resolvedAltTables channel pass-through, fail-closed on malformed account bytes, partial-table index out-of-range.
RPC fetcherrpc.test.tsJSON-RPC getAccountInfo injector; frozen stub; offline-identical behavior when no --rpc.
CLI argscli-args.test.tsFlag parsing correctness: --rpc, --vault-pda, --strict, --threshold, --json, --digest.
Precision harnessprecision.test.ts500 benign + 37 malicious offline corpus; 0 false-REJECTs on benign; 100% malicious recall; ALT sub-test.
Real on-chain attacksreal-attacks.test.tsThe 2 real Drift (Apr 2026, ~$285M) exploit transactions, replayed offline -> never SIGN (both HOLD: durable nonce + Squads). Provenance: docs/real-attacks.md.
RPC-adversarialrpc-adversarial.test.tsInjected/forged RPC enrichment (ALT, Squads PDA, Token-2022 mint) cannot remove or downgrade findings derived from the signed bytes; forged-ALT fails closed.
Fuzz (fail-closed)fuzz-fail-closed.test.tsByte-mutating an authority-transfer fixture never yields SIGN (exhaustive 1-byte scan: 0 / 44,288).
Registry discriminatorsregistry-discriminators.test.tsEvery anchor-8 discHex == sha256("global:"+ixName); ixNames cross-verified vs canonical on-chain IDLs (catalog/idl-sources.json).
Determinismrun.ts + CIThe standalone node runner's output is byte-identical across two runs (no timing/nondeterminism in the core).
No-networkfixtures.test.tsCore modules import no http/https/net/fetch; the suite makes zero network calls at run time.

Mutation testing: Stryker runs against skill/src/classify.ts and the score is reported honestly (not inflated) in docs/mutation-report.md — run with npm run mutation.

On npm audit: any advisories come exclusively from dev dependencies (the vitest/vite/esbuild toolchain and @solana/web3.js's transitive deps used only for fixture generation + cross-validation). The shipped runtime core has zero dependencies ("dependencies": {}), so none of these reach a consumer of the skill.

CI (.github/workflows/ci.yml) runs exactly this on every push and PR across Node 20 and 22, plus a determinism gate (two byte-identical runner runs) and a fixture-drift guard (gen-fixtures must not change any committed .b64). The real fixtures are committed, so CI never depends on the network — it just decodes the frozen bytes.

Verifying CI

The CI badge at the top of this file reflects the latest run status for the lrafasouza/sign-safe-skill repository. The CI workflow (.github/workflows/ci.yml) runs on every push to main/master and on pull requests; it is triggered on Node 20.x and Node 22.x, executes npm ci followed by npm run verify:all (build, tests, fixture runner, attack replay, pack dry-run, and production dependency audit), and additionally gates determinism (two byte-identical fixture-runner runs) and fixture-drift (regenerating .b64 files must produce no diff). The workflow reflects the latest run on the main branch.

See also docs/failure-recovery.md for a concrete mapping of failure modes to verdict outcomes and finding ids.

Repository structure

sign-safe-skill/
├── package.json            # build / test / gen-fixtures (no postinstall, no curl)
├── tsconfig.json           # strict, NodeNext, ES2022
├── LICENSE                 # MIT
├── README.md               # this file
├── CLAUDE.md               # how an agent should use this skill
├── commands/
│   └── sign-review.md      # the /sign-review command (frontmatter description)
├── rules/
│   └── signing-output.md   # verdict contract + banned reassurance phrases
└── skill/
    ├── SKILL.md            # thin router hub (frontmatter: name, description)
    ├── references/
    │   ├── verdict-contract.md        # verdict.json schema + decision rules
    │   ├── danger-catalog.md          # rationale for each catalog entry
    │   └── decode-notes.md            # message parse details, ALT conservatism
    ├── catalog/
    │   ├── danger-primitives.json     # 37-entry native-program danger catalog
    │   ├── anchor-danger.json         # 11-entry Anchor authority-mutation registry
    │   └── program-registry.json      # 14-program DeFi/NFT registry
    ├── schema/
    │   └── verdict.schema.json        # JSON Schema for sign-safe/verdict@1
    ├── src/
    │   ├── types.ts        # the shared contract (two-layer role model + RecipientRef)
    │   ├── decode.ts       # PURE base64 -> DecodedMessage (legacy + v0) + re-encoder
    │   ├── roles.ts        # PURE two-layer writability (partition + demotion) + reserved set
    │   ├── classify.ts     # PURE instruction x catalog + registry -> Finding[]
    │   ├── classify-inner.ts # PURE inner-instruction classifier (Squads VaultTransaction)
    │   ├── registry.ts     # PURE DeFi/NFT program registry lookup (program-registry.json)
    │   ├── reputation.ts   # PURE address-reputation blocklist screening (screenAddresses)
    │   ├── outflow.ts      # PURE statically-declared signer outflow + recipient surfacing
    │   ├── tlv.ts          # PURE Token-2022 mint/account TLV extension walker
    │   ├── alt.ts          # PURE ALT account bincode decoder (resolvedAltTables channel)
    │   ├── banned.ts       # PURE banned-reassurance-phrase enforcement
    │   ├── squads.ts       # PURE Squads v4 VaultTransaction borsh decoder
    │   ├── digest.ts       # PURE SHA-256 transaction digest + short-code
    │   ├── verdict.ts      # PURE Finding[] -> Verdict + verdict.json (Drift composite)
    │   ├── rpc.ts          # IMPURE JSON-RPC getAccountInfo fetcher (host layer)
    │   ├── enrich.ts       # IMPURE runtime hooks (NEVER imported by core/tests)
    │   └── cli.ts          # thin CLI wrapper (--rpc, --vault-pda, --strict, --json, --digest)
    ├── fixtures/
    │   ├── generate.ts     # builds the synthetic .b64 fixtures with @solana/web3.js
    │   ├── gen-goldens.ts  # regenerates the golden verdict.json from the core
    │   ├── NN_*.b64        # 10 synthetic serialized messages
    │   ├── NN_*.verdict.json   # 10 golden verdicts
    │   └── real/           # REAL mainnet fixtures (frozen .b64 + .meta.json provenance)
    │       └── accounts/   # frozen on-chain account bytes (Squads VaultTransaction PDA)
    ├── corpus/             # precision study: 500 benign + 37 malicious offline fixtures
    └── test/
        ├── helpers.ts               # offline message-byte builders + fixture loaders
        ├── decode.test.ts           # wire-format / compact-u16 / sanitization / fail-closed
        ├── roles.test.ts            # role-derivation goldens (partition + demotion)
        ├── classify.test.ts         # instruction classification + TLV
        ├── verdict.test.ts          # durable nonce / Drift composite / prompt-injection
        ├── squads.test.ts           # VaultTransaction decode, discriminator, ALT-unresolved, real fixture
        ├── squad-verdict.test.ts    # Squads+verdict integration, governanceContext, never-SIGN
        ├── digest.test.ts           # SHA-256 digest, short-code, determinism, out-of-band identity
        ├── pbt.test.ts              # fast-check property-based tests (seed 42)
        ├── fixtures.test.ts         # golden + web3.js/kit differential + no-network
        ├── real-fixtures.test.ts    # real mainnet txs decoded offline + cross-validated
        ├── program-registry.test.ts # DeFi/NFT registry: 14 programs, verified discriminators
        ├── recipient.test.ts        # recipient surfacing, outboundToNonSigner, ALT-unresolved
        ├── reputation.test.ts       # blocklist REJECT, holdOutboundTransfers HOLD, screenAddresses unit
        ├── legacy-runner.test.ts    # wraps run.ts so it is part of `npm test`
        ├── alt.test.ts              # ALT bincode decoder + resolvedAltTables channel
        ├── rpc.test.ts              # RPC fetcher + injectable stub + offline-identical
        ├── cli-args.test.ts         # CLI flag parsing correctness
        ├── registry-discriminators.test.ts  # self-verifying discriminators (133 checks)
        ├── precision.test.ts        # offline precision harness (500 benign + 37 malicious)
        ├── schema.test.ts           # verdict JSON Schema + MCP outputSchema contract
        ├── auxiliary-programs.test.ts # ATA/Memo bounded recognition
        ├── extract-vault-address.test.ts    # Squads PDA auto-extraction
        ├── fulltx.test.ts           # full signed-tx input stripped + fail-closed (W011)
        └── run.ts                   # standalone node smoke runner (npm run test:fixtures)

License

MIT -- see LICENSE.

↑↓ navigate openesc close