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

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

Copy link
Copy Markdown
Contributor

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>
@prasanna-anchorage

Copy link
Copy Markdown
Contributor Author

Confidential Transfer (Token-2022) support — proposed intermediate fields + plan (PRS-252)

Flagging this while the intermediate_output schema is still in flux, since it's directly useful to us.

Context. PRS-252 builds Solana Token-2022 Confidential Transfer signing (CVM-assisted; the outbound transfer + a confidential→public withdraw are now validated end-to-end on regnet + live devnet). We want the HSM-signed CT path to ride this intermediate_output route rather than bespoke instruction parsing in the signing op — turnkey_raw_transaction_op already binds signing to the attested VSP parse, and withdraws are being routed through the VSP (FeatureFlagUseVSPForExecuteWithdraw). So CT just needs the VSP to understand the CT instructions.

Scope = a parser extension, not new crypto. The ZK proofs (equality / range / grouped-validity) are produced off-VSP (HSM cobuild + a keyless CVM). The VSP only needs to parse + surface the CT instruction fields for the Turnkey policy engine (CEL on solana.tx.*) + the VisualSign view.

Proposed CT fields in the intermediate schema (Token-2022 ConfidentialTransfer):

  • Withdraw / WithdrawWithSplitProofs (confidential → public): mint, source_token_account, owner, withdraw_amount (public u64), decimals, equality_proof_context_account, range_proof_context_account, new_decryptable_available_balance (opaque AE blob). The withdraw amount is plaintext, so policy can gate on it directly.
  • TransferWithSplitProofs (confidential → confidential): mint, source_token_account, destination_token_account, owner, the three context-state refs (equality, validity, range), auditor_elgamal_pubkey (when the mint is auditor-configured), new_decryptable_available_balance. Note the transfer amount is confidential (not in the instruction) — policy gates on structure / accounts / auditor-presence, not the amount.

First pass will be manually crafted. We intend a hand-written CT instruction parser/preset — explicit decode of the ConfidentialTransfer instruction discriminators + their account/data layout — rather than relying on generic program_call_args_json auto-decode. The CT instructions reference their proofs via context-state accounts and carry ZK-specific structure, so explicit handling gives a correct VisualSign + policy view.

Next step: a manually-crafted visualsign-solana CT preset against this schema (tracked under PRS-252). Happy to push a draft against visualsign_solana::intermediate here while the shape is still open — just wanted the CT field set on record now.

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