Skip to content

Emit Solana intermediate output for Turnkey policy engine#282

Draft
prasanna-anchorage wants to merge 3 commits into
mainfrom
solana-intermediate-output
Draft

Emit Solana intermediate output for Turnkey policy engine#282
prasanna-anchorage wants to merge 3 commits into
mainfrom
solana-intermediate-output

Conversation

@prasanna-anchorage
Copy link
Copy Markdown
Contributor

@prasanna-anchorage prasanna-anchorage commented May 6, 2026

Summary

  • Adds optional bytes intermediate_output = 5 to ParsedTransactionPayload. For Solana, it carries a borsh-serialized, deterministic mirror of solana_parser::SolanaMetadata shaped to the solana.tx.* attributes Turnkey's Solana policy engine evaluates. Empty for other chains.
  • Refactors the VisualSignConverter trait to return ConversionResult { payload, intermediate_output: Option<Vec<u8>> }. The existing borsh-based ParsedTransactionPayload signature now covers the new field automatically — wallets can trust it the same way they trust parsed_payload.
  • Adds parser_cli --with-intermediate to decode and pretty-print the borsh blob.
  • Adds parser_cli --policy <EXPR> to evaluate Google CEL policy expressions against the parsed intermediate output and print PASS/DENY (process exits non-zero on any DENY, so it composes with CI).
  • Adds CEL-based unit tests asserting the schema is expressive enough for Turnkey's documented Solana policy patterns.
  • Schema lives in visualsign_solana::intermediate (a pub mod) so wallet code can borsh::from_slice directly.

This is a draft PoC — comments welcome on the schema shape and the trait change before we polish.

Why borsh + bytes (vs proto-typed nested messages)?

  • One new proto field, no new proto messages.
  • Schema in Rust → easier to iterate without proto codegen churn; both ends here are Rust.
  • Deterministic by construction (BTreeMap maps + alphabetized program_call_args_json), which keeps the existing borsh::to_vec(&payload) digest stable.
  • Trade-off: weaker schema evolution guarantees and no cross-language type info, but acceptable given consumer profile.

Schema (SolanaIntermediateOutput)

Mirrors solana_parser::SolanaMetadata minus signatures (unsigned tx). Per-instruction:

program_key, accounts[{ account_key, signer, writable }],
instruction_data_hex, address_table_lookups[],
parsed_instruction_data?: { instruction_name, discriminator,
                            named_accounts (BTreeMap), program_call_args_json,
                            idl_source ("BuiltIn"|"Custom"), idl_hash }

Plus top-level account_keys, program_keys, transfers, spl_transfers, recent_blockhash, address_table_lookups. Field names match Turnkey's documented solana.tx.* attributes.

CLI invocation (PoC)

Pretty-print the intermediate output:

cargo run --bin parser_cli -- --chain solana --network SOLANA_MAINNET \
  --output text --with-intermediate -t <BASE64_TX>

Trimmed sample (Jupiter swap fixture from the existing unit test):

=== Intermediate Output (Solana, policy schema) ===
SolanaIntermediateOutput {
    account_keys: [ "6DSxAQ2HdBLGYwa3AQf6hXXjNZ762p761ANxBDqrao5P", ... ],
    program_keys: [
        "11111111111111111111111111111111",
        "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4",
        "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
        "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
    ],
    instructions: [
        SolanaIntermediateInstruction {
            program_key: "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4",
            instruction_data_hex: "e517cb977ae3ad2a01000000110164...",
            parsed_instruction_data: Some(SolanaParsedInstructionDataIo {
                instruction_name: "route",
                discriminator: "e517cb977ae3ad2a",
                named_accounts: { "destination_mint": "EPjFWdd5...USDC", ... },
                program_call_args_json: "{\"in_amount\":1000000,\"slippage_bps\":50,...}",
                idl_source: "BuiltIn",
                idl_hash: "d189feae...",
            }),
            ...
        },
    ],
    transfers: [ SolTransfer { from: "6DSx...", to: "AEdS...", amount: "1000000" } ],
    ...
}

CEL policy evaluation (--policy)

The --policy <EXPR> flag (repeatable) compiles CEL against the deserialized intermediate output:

$ cargo run --bin parser_cli -- --chain solana --network SOLANA_MAINNET \
  --output text -t <BASE64_TX> \
  --policy "size(solana.tx.transfers) == 1" \
  --policy "solana.tx.transfers.all(t, t.from == '6DSxAQ2HdBLGYwa3AQf6hXXjNZ762p761ANxBDqrao5P')" \
  --policy "size(solana.tx.address_table_lookups) == 0"

=== Policy evaluation ===
[PASS] size(solana.tx.transfers) == 1
[PASS] solana.tx.transfers.all(t, t.from == '6DSxAQ2HdBLGYwa3AQf6hXXjNZ762p761ANxBDqrao5P')
[PASS] size(solana.tx.address_table_lookups) == 0

Process exits with code 2 if any policy denies, so it composes with CI.

The implementation uses cel-interpreter, a Rust port of Google's CEL spec — the same DSL Turnkey says their policy engine is based on. Caveats:

Turnkey docs Canonical CEL
xs.any(t, p) xs.exists(t, p)
xs.count size(xs)

Same semantics, slightly different surface. We could register a thin any alias on the evaluator if byte-identical Turnkey syntax matters; left out here for simplicity.

This is a schema-expressiveness check, not a Turnkey-faithful simulator. Real pass/deny still requires the server-side engine.

Sample policy expressions

Eight patterns adapted from the Turnkey announcement:

# 1. Single-sender restriction
solana.tx.transfers.all(t, t.from == '<sender>')

# 2. Recipient allowlist (native + SPL)
solana.tx.transfers.all(t, t.to == '<a>' || t.to == '<b>') &&
solana.tx.spl_transfers.all(t, t.to == '<a>' || t.to == '<b>')

# 3. Single transaction control
size(solana.tx.transfers) == 1 &&
solana.tx.transfers.all(t, t.to == '<recipient>')

# 4. Block transfers to a denied address
!(solana.tx.transfers.exists(t, t.to == '<bad>') ||
  solana.tx.spl_transfers.exists(t, t.to == '<bad>'))

# 5. Disallow address-table lookups
size(solana.tx.address_table_lookups) == 0

# 6. Restrict program set
solana.tx.program_keys.all(p,
  p == '<jup>' || p == '<token>' || p == '<ata>' || p == '<system>')

# 7. SPL token allowlist (only USDC)
solana.tx.spl_transfers.all(t, t.token_mint == 'EPjFWdd5...')

# 8. IDL-aware: deny risky instructions on a specific program
!solana.tx.instructions.exists(i,
  i.program_key == '<jup>' &&
  i.parsed_instruction_data != null &&
  i.parsed_instruction_data.instruction_name == 'closeUserAccount')

Six of these are exercised in tests/policy_examples.rs against a real fixture (Jupiter swap), demonstrating the schema is expressive enough.

Determinism

The intermediate_output bytes are part of borsh::to_vec(&ParsedTransactionPayload), so the existing signature covers them. To keep the digest stable for identical inputs:

  • Vec fields preserve transaction order.
  • All maps are BTreeMap (deterministic borsh).
  • program_call_args_json is built from a BTreeMap<String, &Value> then serde_json::to_string, producing alphabetized JSON.

A determinism unit test in intermediate.rs round-trips borsh and asserts byte-identical output.

Test plan

  • make -C src lint — clippy clean
  • make -C src test — all unit + integration + new policy tests pass
  • CLI smoke-tested with a Jupiter swap fixture (sample above)
  • CEL policy evaluator wired into the CLI (sample above)
  • 6 CEL-based policy tests against a real Solana fixture
  • Add a Solana integration test in src/integration/tests/parser.rs that decodes parsed_transaction.payload.intermediate_output and asserts shape (follow-up)
  • Add an Ethereum integration test asserting intermediate_output.is_none() (follow-up)
  • Real-world validation against Turnkey's policy engine (sandbox round-trip or internal tooling)

Follow-ups

  • Bump solana_parser from anchorageoss/a0c554d to tkhq/main (e93f042) once solana-parser-fuzz-core rebases onto main; current pin already exposes the API we need.
  • Optional: register any / count aliases on the CEL evaluator for byte-identical Turnkey syntax.
  • Optional: extend integration tests to cover the new field.

🤖 Generated with Claude Code

prasanna-anchorage and others added 3 commits May 6, 2026 19:16
Adds an `intermediate_output: optional bytes` field to ParsedTransactionPayload
that carries a borsh-serialized chain-specific structured view of the
transaction, intended for downstream policy engines (Turnkey's Solana policy
engine in particular).

For Solana, the schema (visualsign_solana::intermediate::SolanaIntermediateOutput)
mirrors solana_parser::SolanaMetadata, scoped to the fields Turnkey's
policy DSL evaluates against (account_keys, program_keys, instructions,
transfers, spl_transfers, recent_blockhash, address_table_lookups, plus
parsed_instruction_data per instruction). Maps use BTreeMap and
program_call_args is rendered as canonical alphabetized JSON, so the
borsh blob is byte-deterministic — the request signature now covers it
without further code.

Other chains return None for intermediate_output; the bytes field is
covered by the existing borsh-based signature path in routes/parse.rs.

Adds a `--with-intermediate` CLI flag that decodes and pretty-prints the
borsh blob (Solana-only for now).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds cel-interpreter (Google's Common Expression Language, the spec
Turnkey says their policy engine is based on) as a dev-dep on
visualsign-solana and a regular dep on parser_cli.

Tests live in src/chain_parsers/visualsign-solana/tests/policy_examples.rs
and exercise 6 representative policy patterns from Turnkey's Solana
policy-engine announcement against a known Jupiter-swap fixture:
designated sender, single-recipient + count, blocked address,
program-set allowlist, no address-table-lookups, and IDL-aware
instruction-name check.

The CLI gains a `--policy <EXPR>` flag (repeatable). It decodes the
borsh intermediate output, binds it as `solana` in a CEL context, and
prints PASS/DENY per expression. Process exits non-zero if any policy
denies, suitable for CI gating.

Note: Turnkey's docs surface `.any(t, p)` / `.count` aliases that
canonical CEL doesn't ship with — use `.exists(t, p)` / `size(...)`
instead. Same semantics; see the tests/CLI help for the mapping.

These tests assert the schema is *expressive enough* to encode
Turnkey-style policies. Faithful pass/deny still requires Turnkey-side
evaluation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a "Combining rules" section to --policy CLI help and to the
policy_examples.rs module docs, plus a callout that engine-level
structure (effect: ALLOW/DENY, consensus formulas) lives above CEL and
is out of scope for this PoC. Surfaces:

- single-expression operators (&&, ||, !, ternary, .exists/.all/size)
- multi-flag CLI semantics: implicit AND, exit 2 on any DENY
- the gap between CEL conditions and full Turnkey-style policy engines

No code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant