Skip to content

feat: attested transaction lint diagnostics in SignablePayload #228

@shahan-khatchadourian-anchorage

Description

Summary

Add a lint framework for transaction parsing that produces structured diagnostics as part of the signed SignablePayload. Diagnostics are attested by the enclave's ephemeral key alongside the parsed fields, making them tamper-proof and auditable. Diagnostics are expressed as a field variant in SignablePayload, alongside existing variants like TextV2 or AmountV2.

Motivated by this comment on PR #204: instructions with corrupted account indices are currently silently dropped, hiding data integrity problems from the caller.

Design

Diagnostics in the signed payload

Diagnostics become part of SignablePayload — either as a new SignablePayloadField variant or a dedicated field on the struct. Either way, they're included in the deterministic JSON serialization, covered by the Borsh hash, and signed by the ephemeral P256 key.

This means:

  • The HSM/attester can verify what the parser flagged
  • A wallet cannot suppress or modify diagnostics without breaking the signature
  • Auditors can inspect diagnostics after the fact

Structured diagnostic format

Each diagnostic is a typed, machine-readable record designed to be consumed equally well by wallet UIs, CI tools, and AI agents:

```json
{
"rule": "transaction::oob_account_index",
"domain": "transaction",
"level": "warn",
"message": "instruction 2: account index 7 out of bounds (5 accounts available)",
"instruction_index": 2
}
```

Human-readable rendering is derived from this structure, not the other way around. AI tools can filter by domain, triage by severity, and locate by instruction index without parsing natural language.

Severity levels

  • Error — indicates a serious problem
  • Warn — notable issue, parsing continues
  • Allow — silently handled, not included in output

All rules are configurable

Every rule's severity can be overridden by the caller via the request. This is a reporting tool — the caller decides what severity level matters for their use case. Configuration comes from the request side (in `ChainMetadata` or `VisualSignOptions`), and the resulting diagnostics are attested in the response.

Lint configuration and the attestation flow

Lint configuration (severity overrides) would be part of the request metadata, which means it's included in the `metadata_digest` — the SHA-256 of the borsh-serialized chain metadata. The attestation flow becomes:

```
Wallet sends:
ParseRequest {
unsigned_payload,
chain,
chain_metadata (includes lint overrides)
}

Parser produces:
ParsedTransactionPayload {
parsed_payload <- JSON with fields + diagnostics (signed)
input_payload_digest <- SHA-256(unsigned_payload)
metadata_digest <- SHA-256(borsh(chain_metadata including lint config))
}
Signature <- ephemeral P256 signs SHA-256(borsh(ParsedTransactionPayload))
```

The HSM verifies the signature and can see:

  • What diagnostics the parser produced
  • What lint configuration the wallet requested (via metadata_digest)
  • That the diagnostics match the configuration — e.g., if a wallet set `oob_account_index: allow`, the HSM knows warnings were suppressed and can factor that into its signing decision

This means the lint configuration itself is attested. A wallet that suppresses warnings can't hide that fact from the HSM.

Complexity considerations for front-end teams

Adding lint configuration to the request increases surface area for wallet developers:

  • They must understand which rules exist and what severities to choose
  • They must handle new diagnostic fields in the response
  • Configuration that changes per-request adds testing burden

To manage this complexity:

  • Sensible defaults — if no overrides are sent, the parser uses built-in defaults. Wallets that don't care about lint configuration can ignore it entirely and still receive diagnostics at default severities.
  • Diagnostics are display-agnostic — UX teams decide how to render them (banner, inline warning, hidden, ignored). The parser doesn't prescribe display behavior.
  • Incremental adoption — wallets can start by ignoring diagnostics in the response and adopt them when ready. The fields are there but impose no rendering obligation.
  • The alternative is worse — without configurable diagnostics, silent data dropping (current behavior) gives front-end teams no signal at all. Even with added complexity, structured diagnostics are easier to handle than debugging why parsed output is missing instructions.

Domains

Rules are organized by domain, reflecting who owns the problem. Domains are chain-dependent — each chain crate defines its own rules within shared domain categories.

Example domains (not exhaustive — each chain defines what's relevant):

Domain Scope Example chains
`transaction` Raw transaction structure validity All
`decode` Instruction/calldata interpretation All
`account` Account metadata and resolution All
`wallet` Caller-provided data quality All
`idl` IDL content and structure Solana
`abi` ABI content and structure Ethereum

Example rules per domain

`transaction`

  • `empty_account_keys` — transaction has no account keys
  • `header_underflow` — header values exceed account array length
  • `instruction_count_mismatch` — decoded field count doesn't match instruction count
  • `oob_program_id` — instruction references nonexistent program
  • `oob_account_index` — instruction references nonexistent account

`idl` (Solana-specific)

  • `missing_discriminator` — instruction has no discriminator defined
  • `duplicate_discriminator` — multiple instructions share a discriminator
  • `undefined_type_reference` — instruction arg references a type not in `types[]`
  • `account_count_mismatch` — IDL defines different account count than instruction provides
  • `malformed_json` — IDL JSON fails to deserialize
  • `empty_instructions` — IDL has no instruction definitions

`decode`

  • `discriminator_mismatch` — instruction data doesn't match any IDL discriminator
  • `incomplete_args` — deserialization ran out of bytes
  • `trailing_bytes` — extra bytes after all args decoded
  • `type_resolution_failure` — nested/defined type can't be resolved

`account`

  • `unknown_program` — program ID doesn't match any known program or metadata
  • `missing_signer` — expected signer account not marked as signer
  • `duplicate_account` — same account appears multiple times with conflicting roles

`wallet`

  • `missing_idl_mapping` — program has no IDL (built-in or user-provided)
  • `idl_override_builtin` — user IDL replaces a built-in (informational)
  • `stale_idl` — IDL discriminators match nothing in the transaction

Architecture

  • `visualsign` core crate — defines diagnostic types (`LintDiagnostic`, `Severity`, `LintConfig`), the rule registration trait, and the `SignablePayloadField` variant or `SignablePayload` field for diagnostics
  • Chain crates — implement chain-specific rules using the framework
  • Deterministic serialization — diagnostics follow the same alphabetical ordering and ASCII constraints as all other `SignablePayload` content
  • Lint configuration on request side — severity overrides travel in `ChainMetadata` or `VisualSignOptions`, following the existing pattern of wallet-provided context

Current behavior this replaces

  • Corrupted data silently dropped (`filter_map` returning `None`)
  • Parse failures fall back to raw display with no indication something went wrong
  • Arithmetic guards (`saturating_sub`) mask problems without reporting them
  • None of these conditions are visible to the attester or auditor

Note on existing annotation types

`AnnotatedPayloadField` with `static_annotation` / `dynamic_annotation` exists in the codebase but is unused — all annotations are `None`, and `AnnotatedPayload` never appears in the output pipeline. The lint framework is a separate concern and should not reuse this dead infrastructure. Whether annotations need cleanup is a separate decision.

Considerations

Prior art

  • Clippy lint categories — severity-based rule organization with configurable levels
  • Clippy `--message-format=json` — structured machine-readable output

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions