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
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 inSignablePayload, alongside existing variants likeTextV2orAmountV2.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 newSignablePayloadFieldvariant 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:
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
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:
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:
To manage this complexity:
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):
Example rules per domain
`transaction`
`idl` (Solana-specific)
`decode`
`account`
`wallet`
Architecture
Current behavior this replaces
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