Skip to content

feat(prs-556): choose the ABI trust posture at deploy time - #421

Closed
pepe-anchor wants to merge 14 commits into
mainfrom
pepefigueira/prs-556-move-abi-trust-posture-from-per-request-behavior-to-a-deploy
Closed

feat(prs-556): choose the ABI trust posture at deploy time#421
pepe-anchor wants to merge 14 commits into
mainfrom
pepefigueira/prs-556-move-abi-trust-posture-from-per-request-behavior-to-a-deploy

Conversation

@pepe-anchor

@pepe-anchor pepe-anchor commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Why

PRS-556, part 2 of 2. Part 1 (#422) has merged, so this now targets main directly and is no longer stacked.

#406 made the parser accept caller-supplied ABI mappings with no signature instead of dropping them. That fixed a real bug, but it left acceptance of unsigned ABIs as the parser's unconditional default: any caller can omit the signature and get an unverified ABI decoded and rendered.

@prasanna flagged the posture itself as designed wrong, and the visibility mechanism #406 added does not work. The aggregated warn! per request is never seen by a signer, because Turnkey doesn't surface parser logs today. So the trust decision is both implicit and unauditable.

This moves it to where the deployer chooses it and the signer can check it: the parser's cmdline, which for the enclave binary lands verbatim in the pivotArgs of the manifest the operators approve.

What

Two mutually exclusive postures on parser_app:

--accept-unsigned-abis                       # today's behaviour, now an explicit opt-in
--accept-signatures-from-pubkey <hex>        # only allowlisted signers; unsigned is rejected. Repeatable

Neither flag and it refuses to start. Both and it refuses to start. There is no default: coming up in a mode nobody chose is the failure this replaces.

Threaded ParserConfig -> parse() -> create_registry() -> EthereumVisualSignConverter::with_policy, using the MetadataTrustPolicy type from #422.

tvc-deploy deploy enforces the same XOR and appends the chosen flag to pivotArgs, so the posture is part of the signed manifest rather than something a caller influences per request.

It also fails fast on the two ways the posture could still go wrong between choosing it and the enclave honouring it:

  • workflow_dispatch took a free-text app_id but let the posture default to blank, and blank meant --accept-unsigned-abis. Dispatching against a non-test app without filling the field would quietly deploy the permissive posture, which is the class of implicit choice this PR exists to remove. Dispatch now refuses to run without a stated posture, and takes the literal unsigned to opt into accept-unsigned on purpose. The PR-label path has no inputs context and only ever targets the dev TEST app, so it keeps the permissive default.
  • A malformed pubkey used to reach the manifest unchecked, and nothing looks at it until parser_app decodes it at startup. A typo cost a consensus round and an enclave that never reported healthy. tvc-deploy now validates the key before writing the manifest: SEC1 shape, then a real point decode via k256. Accepted prefixes are 02/03/05 at 33 bytes and 04 at 65. 05 is SEC1's compact tag and k256 accepts it via decompact, so rejecting it would have blocked a deployment whose key parser_app would have honoured. k256 was already in that crate's lockfile transitively, so the on-curve check costs one line and no new supply chain.

parser_grpc_server takes the same flags but keeps accept-unsigned as its default with a startup line saying so. It's the non-attested dev server, there's no manifest to audit the posture against, so requiring the flag would only break local dev without buying the property.

Existing launch paths (integration harness, src/Makefile, the tvc-deploy workflow) now pass --accept-unsigned-abis explicitly, so behaviour is unchanged. The choice is just no longer implicit.

From the review pass

The merge that brought main in resolved four files to this branch's side, superseding #422's per-request AbiExtraction / rejected_by_policy / unverified counters. Nothing outside those files consumed them, but the merge also took five tests with it, and that part was not deliberate:

  • test_register_installs_require_signed_posture is back. It was the only test that went through ChainPlugin::register, and without it, editing register back to EthereumVisualSignConverter::new() compiles clean, leaves the suite green, and silently drops parser_cli to accept-unsigned. Confirmed by making that exact edit and watching the restored test fail.
  • Two surviving tests had stopped testing what they name. test_try_extract_invalid_address_skipped had been switched to the require-signed policy while its fixture stayed unsigned, so the posture dropped the entry before the address was ever parsed: it passed with address validation removed entirely. test_accept_unsigned_does_not_enforce_signer_identity signed with the dev seed, which authorized_abi_signers() allowlists under cfg(test), so it would have held even if identity were being enforced.
  • create_registry only pinned the accept-unsigned direction, so a version that ignored config.abi_trust outright would still have passed. The require-signed mirror now fails unless the posture reaches the converter.
  • The transport layer covered two of the four posture and signature combinations, leaving "gates on signer identity" unproven against a real process. All four are covered now.
  • Five comments in cli_plugin.rs still described the CLI's dev-key signature as buying "verified rather than logged as unverified". That outcome no longer exists, and under the CLI's require-signed posture an unsigned entry is dropped outright, so the signature is load-bearing rather than bookkeeping.

MetadataTrustPolicy is now #[non_exhaustive], so a third posture can be added later without breaking downstream matches. Every match on it lives in signing.rs; consumers only construct variants, so nothing is forced into a wildcard arm.

Docs were the part this PR had invalidated without touching. create_registry gained a required argument while docs/wallet-integration/library/getting-started.mdx still showed the zero-arg form in two snippets, so the published snippet no longer compiled. The dApp page was worse than stale: it said the parser accepts any signature from any key and advised shipping unsigned ABIs until the signing pipeline stabilised, which against a require-signed deployment silently degrades rendering instead of easing a rollout. The gRPC server page presents that binary as a self-hosted deployment with a Dockerfile CMD and k8s spec that pass no arguments, and never mentioned a posture existed.

Deploy coupling

tools/tvc-deploy is not a member of the src/ workspace, so it has no compile-time link to the parser: a tvc-deploy change alone goes green in CI while still being a live hazard. Landing the pivotArgs flag before the binary that accepts it means qos_core sees an unknown cmdline arg and panics at startup. The flag and the binary that understands it ship together here, which is why the deploy tooling was not split out further.

One follow-up lives outside this repo: the weekly deploy driver in the pepe-parser-deploy plugin builds the tvc-deploy deploy ARGV with no posture flag, so it will abort at argument parsing once this lands. It fails closed, so there is no misdeploy risk, but it needs a matching change before the next weekly deploy.

Testing

The end-to-end evidence is four integration tests that send an identical request to parser_app instances differing only in cmdline. The ABI describes frobnicate(uint256,address), a selector no compiled-in visualizer knows, so decoding depends purely on whether the caller ABI was honoured:

test accept_unsigned_abis_deployment_decodes_unsigned_abi ... ok            # renders the decoded call
test require_signed_abis_deployment_drops_unsigned_abi ... ok               # falls back to raw hex
test require_signed_abis_deployment_decodes_allowlisted_signed_abi ... ok   # signed by the allowlisted key
test require_signed_abis_deployment_drops_foreign_signed_abi ... ok         # valid signature, wrong signer

The last two are what prove the strict posture gates on signer identity rather than merely on a signature being present.

The signed fixtures are checked in rather than produced at test time. Signing in the integration crate means depending on visualsign-ethereum/dev-signing, and because integration is a workspace member, Cargo would then unify that feature ON for parser_app in any workspace-wide invocation, which is what the keep-out-of-prod note on that feature warns against. ECDSA here is RFC6979 deterministic, so the fixtures are stable, and the regeneration recipe is in a comment beside them.

Unit coverage for deploy-time pubkey parsing (valid, compressed/uncompressed canonicalization, invalid key errors at startup rather than silently shrinking the allowlist), the parser_app flag XOR, tvc-deploy's pivotArgs assembly, the abi_metadata posture behaviour, and the pubkey check (02/03/05/04 accepted, truncated and non-hex rejected, wrong length for the prefix rejected, well-formed hex that is off the curve rejected).

Full suite:

LINT_EXIT=0    make lint   (clippy -D warnings, incl. parser_cli + diagnostics variants)
TEST_EXIT=0    make test   (39 suites, 1492 tests, 0 failures)
FMT_EXIT=0     cargo fmt --all -- --check

tools/tvc-deploy separately: 64 tests pass, clippy and fmt clean. Also checked parser_app under --no-default-features and each of the ethereum/solana/sui/tron features in isolation, since config.rs is feature-gated.

The workflow's posture selection was exercised for every input and event combination rather than just read:

workflow_dispatch + blank      -> hard fail, "abi_signer_pubkey is required"
workflow_dispatch + "unsigned" -> --accept-unsigned-abis
workflow_dispatch + pubkey     -> --accept-signatures-from-pubkey <key>
PR label + blank               -> --accept-unsigned-abis   (dev TEST app only)
PR label + pubkey              -> --accept-signatures-from-pubkey <key>

Manual smoke on the arg handling:

$ tvc-deploy deploy --app-id a ...
error: the following required arguments were not provided:
  <--accept-unsigned-abis|--accept-signatures-from-pubkey <HEX_PUBKEY>>

$ tvc-deploy deploy ... --accept-unsigned-abis --accept-signatures-from-pubkey 04ab
error: the argument '--accept-unsigned-abis' cannot be used with '--accept-signatures-from-pubkey <HEX_PUBKEY>'

$ tvc-deploy deploy ... --accept-signatures-from-pubkey 04aa
error: --accept-signatures-from-pubkey must be a 33-byte (02/03/05-prefixed) or 65-byte
       (04-prefixed) hex secp256k1 public key, got "04aa"

$ parser_grpc_server --accept-signatures-from-pubkey nothex
Error: "invalid ABI trust config: invalid secp256k1 public key: 'nothex'"

Rollback

Needs a redeploy, not just a revert. qos_core's parser rejects unknown cmdline args, so an older parser_app started against a manifest that still carries --accept-unsigned-abis panics at startup.

To roll back: revert the code, then create a deployment whose pivotArgs are back to just --host-ip / --host-port. No state, schema, or wire-format changes otherwise.

Same reason live deployments need a redeploy to pick this up: the flags live in the manifest, so there's no way to flip a running deployment (and, by design, no way to flip it per request).

Backwards compatibility

No proto or payload changes. Every existing launch path keeps its current behaviour, explicitly.

Three intentional incompatibilities, all the point of the change rather than side effects:

  • parser_app refuses to start without a posture instead of guessing.
  • workflow_dispatch on the tvc-deploy workflow now needs abi_signer_pubkey filled in, either a hex key or the literal unsigned. The PR-label path is unaffected.
  • create_registry takes a &ParserConfig. In-repo callers are updated and the published snippets now match.

Not in scope

Solana IDL mappings have the identical problem (extract_idl_mappings accepts unsigned IDLs unconditionally, same per-request default). Kept this ABI-only per the ticket. MetadataTrustPolicy is chain-neutral so extending it is mechanical, worth its own ticket.

PRS-555 is still open and unaffected. Worth restating what it means here, since it bounds what the strict posture buys operationally: a mapping dropped by the posture produces a payload byte-identical to a request that supplied no mappings at all, and the only signal is a log::warn! in a binary that declares no log dependency and initialises no logger. So a wallet that rotates its ABI signer degrades every transaction from decoded fields to raw calldata, silently. The outcome is content-safe rather than wrong, but it is not diagnosable from the response, and closing that needs a response-surface change rather than a parser change.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes caller-supplied Ethereum ABI trust a deploy-time choice instead of an implicit/per-request default by introducing a chain-neutral MetadataTrustPolicy, threading it through parser_app configuration and registry construction, and ensuring deployment tooling/workflows bake the chosen posture into TVC pivotArgs for out-of-band auditability.

Changes:

  • Introduces visualsign::signing::MetadataTrustPolicy and threads it through parser_app::config::ParserConfig into Ethereum converter construction.
  • Updates tvc-deploy and GitHub workflows to require/encode an explicit ABI trust posture at deploy time and validate signer pubkey shape early.
  • Adds/updates unit + integration tests to pin the behavior difference between permissive vs strict deployments.

Reviewed changes

Copilot reviewed 22 out of 23 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tools/tvc-deploy/src/main.rs Adds explicit deploy-time ABI trust flags, pivots args assembly, and SEC1 pubkey shape validation.
tools/tvc-deploy/README.md Documents the required ABI trust posture flags and their semantics.
src/visualsign/src/signing.rs Introduces MetadataTrustPolicy and helpers for accept-unsigned vs require-allowlisted-signer.
src/parser/grpc-server/src/main.rs Threads ParserConfig into parse calls and adds minimal CLI parsing for ABI trust posture.
src/parser/grpc-server/Cargo.toml Adds visualsign dependency to reference MetadataTrustPolicy.
src/parser/app/src/service.rs Stores deploy-time ParserConfig in the processor and passes it into parsing.
src/parser/app/src/routes/parse.rs Extends parse API to accept &ParserConfig and uses it for registry construction.
src/parser/app/src/registry.rs Threads config into registry creation and pins Ethereum converter posture via with_policy.
src/parser/app/src/lib.rs Exposes new config module.
src/parser/app/src/config.rs New deploy-time configuration module + posture resolution and validation.
src/parser/app/src/cli.rs Adds parser_app CLI posture flags and enforces XOR + non-empty selection at startup.
src/Makefile Updates local parser_app run target to pass --accept-unsigned-abis explicitly.
src/integration/tests/parser.rs Adds end-to-end tests proving the same request behaves differently based solely on deployment flags.
src/integration/src/lib.rs Updates integration harness to always start parser_app with an explicit ABI trust posture.
src/examples/library_integration_test.rs Updates example to construct registry with an explicit permissive posture.
src/chain_parsers/visualsign-ethereum/tests/lib_test.rs Updates tests to use with_policy and a strict “require-signed” helper converter.
src/chain_parsers/visualsign-ethereum/src/lib.rs Replaces allowlist-only model with MetadataTrustPolicy and updates constructors/call sites.
src/chain_parsers/visualsign-ethereum/src/cli_plugin.rs Makes CLI use strict posture with allowlisted dev signer key (consistent with CLI signing behavior).
src/chain_parsers/visualsign-ethereum/src/abi_metadata.rs Makes unsigned-acceptance posture-driven; signature integrity always verified when present; adds deploy-time allowlist parsing.
src/Cargo.lock Records dependency graph update for grpc-server → visualsign.
CLAUDE.md Documents the deploy-time ABI trust posture model and flags.
.github/workflows/tvc-deploy.yml Forces explicit posture for workflow_dispatch deployments; defaults remain for PR-label TEST path.
.github/workflows/stagex.yml Updates deployment manifest guidance to include required ABI posture args.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/parser/grpc-server/src/main.rs
pepe-anchor and others added 3 commits July 28, 2026 13:07
Accepting caller-supplied ABI mappings with no signature has been the
parser's unconditional default since #406. Any caller could omit the
signature and get an unverified ABI decoded and rendered, and the only
visibility was a warn! log that Turnkey does not surface, so nobody sees
it. The trust decision belongs to whoever deploys the parser, not to
whatever each request happens to contain.

This adds visualsign::signing::MetadataTrustPolicy, the XOR the extractor
now takes instead of a bare SignerAllowlist:

  AcceptUnsigned              accept entries with no signature
  RequireAllowlistedSigner    reject anything not signed by an allowed key

Deliberately no Default impl: a deployment has to say which one it runs.
Wiring the binaries to the cmdline comes in the next commit.

One behaviour change beyond plumbing. Under AcceptUnsigned a present
signature is verified for integrity but its signer is no longer checked
against the allowlist. The old combination was incoherent: in production
(no dev-signing, no env var) the allowlist was empty, so a correctly
signed ABI was rejected while the same ABI with the signature stripped
was accepted. An attacker facing an identity check just drops the
signature, so the check bought nothing while making the strictly worse
input the accepted one. Integrity is still enforced, which keeps the
"present-but-invalid signature signals tampering" property.

EthereumVisualSignConverter::new() keeps today's permissive behaviour for
library and example callers; parser_cli now runs require-signed against
the dev key it signs its own ABI files with, which is what
authorized_abi_signers() and VISUALSIGN_ETH_ABI_SIGNERS now exclusively
serve.

Solana IDL mappings have the same per-request default and are untouched
here; extending the policy to that path is mechanical and gets its own
ticket.

Co-Authored-By: Claude <noreply@anthropic.com>
The doc comments said a present signature "must still verify (integrity)",
which reads stronger than what the code delivers. The signature is checked
against the public key carried alongside it in the same untrusted
SignatureMetadata, so it only catches a tamperer who mutates abi.value and
leaves the signature and key alone. Someone who controls the whole entry
re-signs with a key of their own and passes.

That is still worth keeping, and the posture argument is unchanged. Just say
what it actually covers, so a future reader does not mistake it for
provenance.

Comment-only, no behaviour change.

Co-Authored-By: Claude <noreply@anthropic.com>
The same five-line with_policy(RequireAllowlistedSigner(...)) block was
copy-pasted at four test sites. One helper next to test_abi_signer_allowlist
instead, so the strict posture has a single definition and the tests read as
what they are asserting rather than how the converter is built.

Co-Authored-By: Claude <noreply@anthropic.com>
pepe-anchor and others added 2 commits July 28, 2026 13:11
Gives the policy from the previous commit a cmdline, so the posture a
deployment runs is fixed at startup and auditable out of band. parser_app
requires exactly one of:

  --accept-unsigned-abis                        today's behaviour, explicit
  --accept-signatures-from-pubkey <hex>         repeatable, rejects unsigned

Neither flag and it refuses to start; both and it refuses to start. That
is deliberate. Coming up in a mode nobody chose is the failure this
replaces, so there is no default to fall back on.

tvc-deploy enforces the same XOR and appends the chosen flag to the
deployment's pivotArgs, which is the part that matters for the signer: the
posture is in the manifest the operators approve, so it can be checked
against what they expect for that deployment instead of trusting a
per-request signal or logs the TEE never surfaces.

parser_grpc_server takes the same flags but keeps accept-unsigned as its
default, with a startup line saying so. It is the non-attested dev server:
there is no manifest to audit the posture against, so requiring the flag
would only break local dev without buying the property.

Two integration tests are the end-to-end evidence. They send an identical
request (an unsigned ABI for frobnicate(uint256,address), a selector no
compiled-in visualizer knows) to two parser_app instances that differ only
in cmdline. One renders the decoded call, the other falls back to raw hex.

Existing launch paths (integration harness, src/Makefile, the tvc-deploy
workflow) now pass --accept-unsigned-abis explicitly, so behaviour is
unchanged; the choice is just no longer implicit. Live deployments need a
redeploy to pick up a posture, since the flags live in the manifest.

Rollback needs a redeploy, not just a revert. qos_core's parser rejects
unknown cmdline args, so an older parser_app started with a manifest that
still carries --accept-unsigned-abis panics at startup. To roll back:
revert the code, then create a deployment whose pivotArgs are back to just
--host-ip / --host-port. No state or wire-format changes otherwise.

Co-Authored-By: Claude <noreply@anthropic.com>
Two ways the posture could go wrong between choosing it and the enclave
honouring it, both found in review of this branch.

The workflow_dispatch input took a free-text app_id but let the posture
default to blank, and blank meant --accept-unsigned-abis. So dispatching
against a non-test app without filling the field would quietly deploy the
permissive posture, which is the exact class of implicit choice this branch
exists to remove. Dispatch now refuses to run without a stated posture, and
takes the literal "unsigned" to opt into accept-unsigned on purpose. The
PR-label path has no inputs context and only ever targets the dev TEST app,
so it keeps the permissive default.

A malformed pubkey used to reach the manifest unchecked. Nothing looks at it
until parser_app decodes it at startup, so a typo cost a consensus round and
an enclave that never reports healthy. Validate the SEC1 shape before writing
the manifest instead. This crate has no elliptic-curve dependency, so the
check is format-only and says so: it catches truncated and typo'd input, not
a point that is off the curve.

The accepted prefixes are 02/03/05 for 33 bytes and 04 for 65. 05 is SEC1's
compact tag, and k256 accepts it via decompact, so rejecting it here would
have blocked a deployment whose key parser_app would have honoured.

Co-Authored-By: Claude <noreply@anthropic.com>
@pepe-anchor
pepe-anchor force-pushed the pepefigueira/prs-556-move-abi-trust-posture-from-per-request-behavior-to-a-deploy branch from ce93c29 to 7acaf0e Compare July 28, 2026 13:13
@pepe-anchor
pepe-anchor changed the base branch from main to pepefigueira/prs-556-abi-trust-policy-type July 28, 2026 13:13
@pepe-anchor pepe-anchor changed the title feat(parser): move ABI trust posture to a deploy-time flag feat(prs-556): choose the ABI trust posture at deploy time Jul 28, 2026
pepe-anchor added a commit that referenced this pull request Jul 28, 2026
The doc comments on `MetadataTrustPolicy` and the converter constructors
described the cmdline flags and the TVC `pivotArgs` wiring in present tense.
None of that exists yet: it lands in #421. Today only `parser_cli` constructs
an explicit posture.

Also say out loud, on `new()`, what the accept-unsigned posture changed. It
previously claimed to preserve the behaviour every in-process caller had, which
is not true for `parser_app`: its allowlist is empty in production, so an entry
signed by an unlisted key used to be rejected and is now accepted. That is the
deliberate correction described on `MetadataTrustPolicy`, but the constructor
doc should not imply nothing moved.

Comment-only, no behaviour change.

Co-Authored-By: Claude <noreply@anthropic.com>
pepe-anchor added a commit that referenced this pull request Jul 28, 2026
The three-arm `match` on `abi.signature` had a `None => {}` arm carrying no
meaning; an `if let ... else if` says the same thing in fewer lines. Behaviour
identical across all three reachable states.

The require-signed rejection warning named `--accept-signatures-from-pubkey`,
and the module and `authorized_abi_signers` docs described the deploy-time flags
as though they were already wired. They are not, that is #421. Drop the flag
name from the runtime message and mark the wiring as planned rather than
present.

Co-Authored-By: Claude <noreply@anthropic.com>
pepe-anchor added a commit that referenced this pull request Jul 28, 2026
A request whose every ABI mapping the posture refused was byte-identical to
one that supplied no mappings at all: no registry either way, raw selector
rendered either way, no error, no counter, no marker. The only thing
separating them was a log::warn!, and that is not a channel anyone can read
from the enclave. parser_app declares no log dependency and initialises no
logger, so those calls are compiled-in no-ops there rather than log lines
nobody happens to look at. Once #421 can flip a deployment to require-signed
against a caller that does not sign, total refusal becomes the normal failure
and it would surface nowhere.

try_extract_from_chain_metadata now returns AbiExtraction, carrying the
registry plus the counts, so the caller can tell the two apart. Rendering
them needs a payload field and is left to the follow-up; this is the
plumbing that gives the follow-up something to read.

The unverified counter also changes meaning, from "signature absent" to
"identity was not enforced". Under accept-unsigned the signer is never
checked against an allowlist, so an entry an attacker self-signed is exactly
as unattributed as one they left unsigned. Keying off signature.is_none()
reported zero unverified mappings for a request whose entire decode came
from an unverified caller ABI. That state is newly reachable in production:
on main the compiled-in allowlist is empty, so a signed-by-unlisted entry
was rejected and never reached the counter.

Malformed entries (bad address, oversized or unparseable JSON) are counted
separately from policy refusals, so the count keeps meaning "this deployment
refused you" rather than "something went wrong".

Co-Authored-By: Claude <noreply@anthropic.com>
pepe-anchor added a commit that referenced this pull request Jul 28, 2026
…uses it

signer_allowlist_from_hex has no non-test caller in the tree, in a PR whose
body says "library groundwork only, no flags and no deployment changes here".
Nobody can review whether its error strings and its empty-input rule match
#421's clap definition without reading #421, which is the wrong way round.

Its empty-input rule also contradicted the type it feeds, in three places:
MetadataTrustPolicy documents an empty allowlist as rejecting everything
(fail-closed), a signing.rs test exercises that state as valid, and
authorized_abi_signers documents the same, while this parser treated empty as
an error. So a deployment wanting the strictest posture, refuse every
caller-supplied ABI, could not express it through the parser shipped here.

Settling that contract belongs where the flag is defined and observable, not
here. The flag and its parser land together in #421.

Co-Authored-By: Claude <noreply@anthropic.com>
prasanna-anchorage pushed a commit that referenced this pull request Jul 31, 2026
* feat(prs-556): make ABI trust posture an explicit policy

Accepting caller-supplied ABI mappings with no signature has been the
parser's unconditional default since #406. Any caller could omit the
signature and get an unverified ABI decoded and rendered, and the only
visibility was a warn! log that Turnkey does not surface, so nobody sees
it. The trust decision belongs to whoever deploys the parser, not to
whatever each request happens to contain.

This adds visualsign::signing::MetadataTrustPolicy, the XOR the extractor
now takes instead of a bare SignerAllowlist:

  AcceptUnsigned              accept entries with no signature
  RequireAllowlistedSigner    reject anything not signed by an allowed key

Deliberately no Default impl: a deployment has to say which one it runs.
Wiring the binaries to the cmdline comes in the next commit.

One behaviour change beyond plumbing. Under AcceptUnsigned a present
signature is verified for integrity but its signer is no longer checked
against the allowlist. The old combination was incoherent: in production
(no dev-signing, no env var) the allowlist was empty, so a correctly
signed ABI was rejected while the same ABI with the signature stripped
was accepted. An attacker facing an identity check just drops the
signature, so the check bought nothing while making the strictly worse
input the accepted one. Integrity is still enforced, which keeps the
"present-but-invalid signature signals tampering" property.

EthereumVisualSignConverter::new() keeps today's permissive behaviour for
library and example callers; parser_cli now runs require-signed against
the dev key it signs its own ABI files with, which is what
authorized_abi_signers() and VISUALSIGN_ETH_ABI_SIGNERS now exclusively
serve.

Solana IDL mappings have the same per-request default and are untouched
here; extending the policy to that path is mechanical and gets its own
ticket.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(prs-556): narrow what integrity buys under accept-unsigned

The doc comments said a present signature "must still verify (integrity)",
which reads stronger than what the code delivers. The signature is checked
against the public key carried alongside it in the same untrusted
SignatureMetadata, so it only catches a tamperer who mutates abi.value and
leaves the signature and key alone. Someone who controls the whole entry
re-signs with a key of their own and passes.

That is still worth keeping, and the posture argument is unchanged. Just say
what it actually covers, so a future reader does not mistake it for
provenance.

Comment-only, no behaviour change.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(prs-556): extract require_signed_converter test helper

The same five-line with_policy(RequireAllowlistedSigner(...)) block was
copy-pasted at four test sites. One helper next to test_abi_signer_allowlist
instead, so the strict posture has a single definition and the tests read as
what they are asserting rather than how the converter is built.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(prs-556): pin the CLI's trust posture in one helper

The posture the CLI registers its converter under was built inline in
`register`, and nothing asserted what it was. A signed entry survives under
either posture (accept-unsigned still integrity-checks a signature that is
present), so the existing extraction assertions could not tell the two apart:
flipping the CLI to accept-unsigned left the whole suite green.

Extract `cli_trust_policy()` so the posture has one definition, use it in
`register` and in the test that exercises extraction, and assert directly that
it does not accept unsigned entries.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(prs-556): stop claiming the deploy-time flags already exist

The doc comments on `MetadataTrustPolicy` and the converter constructors
described the cmdline flags and the TVC `pivotArgs` wiring in present tense.
None of that exists yet: it lands in #421. Today only `parser_cli` constructs
an explicit posture.

Also say out loud, on `new()`, what the accept-unsigned posture changed. It
previously claimed to preserve the behaviour every in-process caller had, which
is not true for `parser_app`: its allowlist is empty in production, so an entry
signed by an unlisted key used to be rejected and is now accepted. That is the
deliberate correction described on `MetadataTrustPolicy`, but the constructor
doc should not imply nothing moved.

Comment-only, no behaviour change.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(prs-556): flatten the posture branch and narrow its warning

The three-arm `match` on `abi.signature` had a `None => {}` arm carrying no
meaning; an `if let ... else if` says the same thing in fewer lines. Behaviour
identical across all three reachable states.

The require-signed rejection warning named `--accept-signatures-from-pubkey`,
and the module and `authorized_abi_signers` docs described the deploy-time flags
as though they were already wired. They are not, that is #421. Drop the flag
name from the runtime message and mark the wiring as planned rather than
present.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(prs-556): stop the posture tests from passing vacuously

Three tests asserted the right thing for the wrong reason, so the properties
this PR exists to establish were not actually pinned. Each fix below was
confirmed by mutation: break the production code the test names, and the test
now fails where it previously stayed green.

`test_accept_unsigned_still_rejects_tampered_signature` swapped in
`[{"type":"function","name":"approve"}]` as the tampered body. That is not a
parseable ABI, so `register_embedded_abi` dropped the entry before
`validate_abi_signature` ever ran. Deleting the accept-unsigned integrity check
outright left the suite green. Swap in `OTHER_VALID_ABI`, a well-formed ABI that
differs from the signed one, so only the signature check can reject it.

`test_accept_unsigned_does_not_enforce_signer_identity` signed its fixture with
`CLI_DEV_SIGNING_KEY_SEED`, which `authorized_abi_signers()` allowlists under
`cfg(test)`. The entry therefore passed an identity check too, so restoring the
old incoherent "unsigned ok, signed-by-stranger rejected" pairing also left the
suite green, which is exactly what that test claims to prevent. Sign it with
`FOREIGN_SIGNER_SEED` instead, a key no allowlist here knows.

`test_try_extract_invalid_address_skipped` ran under require-signed with an
unsigned fixture, so the posture check dropped the entry and the invalid-address
guard stopped being load-bearing. Run it under accept-unsigned.

Also add `test_converter_honours_stored_require_signed_posture`: nothing pinned
that the converter honours the posture it was constructed with, because every
other metadata-ABI test supplies a signed mapping, which both postures accept.
It feeds one unsigned mapping to both and asserts they diverge, using a function
no built-in visualizer knows so the metadata ABI is the only thing that can
decode it.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(prs-556): carry the ABI refusal count out of extraction

A request whose every ABI mapping the posture refused was byte-identical to
one that supplied no mappings at all: no registry either way, raw selector
rendered either way, no error, no counter, no marker. The only thing
separating them was a log::warn!, and that is not a channel anyone can read
from the enclave. parser_app declares no log dependency and initialises no
logger, so those calls are compiled-in no-ops there rather than log lines
nobody happens to look at. Once #421 can flip a deployment to require-signed
against a caller that does not sign, total refusal becomes the normal failure
and it would surface nowhere.

try_extract_from_chain_metadata now returns AbiExtraction, carrying the
registry plus the counts, so the caller can tell the two apart. Rendering
them needs a payload field and is left to the follow-up; this is the
plumbing that gives the follow-up something to read.

The unverified counter also changes meaning, from "signature absent" to
"identity was not enforced". Under accept-unsigned the signer is never
checked against an allowlist, so an entry an attacker self-signed is exactly
as unattributed as one they left unsigned. Keying off signature.is_none()
reported zero unverified mappings for a request whose entire decode came
from an unverified caller ABI. That state is newly reachable in production:
on main the compiled-in allowlist is empty, so a signed-by-unlisted entry
was rejected and never reached the counter.

Malformed entries (bad address, oversized or unparseable JSON) are counted
separately from policy refusals, so the count keeps meaning "this deployment
refused you" rather than "something went wrong".

Co-Authored-By: Claude <noreply@anthropic.com>

* test(prs-556): pin the trust posture at both real registration sites

The posture a binary runs is decided entirely by which constructor its
registry builder calls, and nothing observed the result. Editing
parser_app's create_registry, or the CLI plugin's register, compiled clean
and left the whole suite green, so the one production behaviour change in
this PR was unpinned at both of its actual call sites.

The previous CLI assertion called cli_trust_policy() directly, which routes
around register: reverting register to new() left it passing. That is the
same gap it was written to close, one layer up. Both tests now go through a
TransactionConverterRegistry and convert a real transaction whose only
possible decoder is a caller-supplied unsigned ABI, so what they observe is
the converter that actually got registered.

Each was mutation-checked: revert the production line, confirm the test goes
red, restore.

parser_app's fixture transaction is a hardcoded hex string. RLP is
deterministic, so regenerating it costs nothing and it is not worth pulling
the alloy stack into that crate's dev-dependencies to rebuild the same bytes
at test time.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(prs-556): move the deploy-time signer parser to the PR that uses it

signer_allowlist_from_hex has no non-test caller in the tree, in a PR whose
body says "library groundwork only, no flags and no deployment changes here".
Nobody can review whether its error strings and its empty-input rule match
#421's clap definition without reading #421, which is the wrong way round.

Its empty-input rule also contradicted the type it feeds, in three places:
MetadataTrustPolicy documents an empty allowlist as rejecting everything
(fail-closed), a signing.rs test exercises that state as valid, and
authorized_abi_signers documents the same, while this parser treated empty as
an error. So a deployment wanting the strictest posture, refuse every
caller-supplied ABI, could not express it through the parser shipped here.

Settling that contract belongs where the flag is defined and observable, not
here. The flag and its parser land together in #421.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(prs-556): stop the Solana IDL path claiming Ethereum parity

extract_idl_mappings accepts unsigned IDLs while rejecting a correctly signed
IDL from an unlisted signer. This PR declares that exact pairing strictly
worse than either coherent posture and removes it from the Ethereum ABI path,
so the three comments describing the Solana behaviour as "parity with the
Ethereum ABI path" stopped being true with this change.

Left as-is behaviourally: MetadataTrustPolicy is chain-neutral so extending
it here is mechanical, but it is out of scope and wants its own ticket. The
comments now say it is a known gap, so a Solana reader does not read the
pairing as deliberate.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Base automatically changed from pepefigueira/prs-556-abi-trust-policy-type to main July 31, 2026 22:56
pepe-anchor and others added 6 commits August 3, 2026 10:57
Resolved content conflicts against #422 (`make ABI trust posture an explicit
policy type`), which landed on main as a per-request implementation of the same
feature this branch supersedes with a deploy-time posture.

Conflicts resolved (all in the ethereum converter + core signing layer):
- src/visualsign/src/signing.rs
- src/chain_parsers/visualsign-ethereum/src/abi_metadata.rs
- src/chain_parsers/visualsign-ethereum/src/cli_plugin.rs
- src/chain_parsers/visualsign-ethereum/src/lib.rs

These 4 files were kept at this branch's version: our branch is the coherent
deploy-time implementation the PR ships, and nothing outside these files
references #422's per-request-only `AbiExtraction`/`rejected_by_policy`/
`unverified` counts (verified by grep across the tree), so dropping that
unconsumed instrumentation is safe. main's other additions (visualsign-near
skeleton, solana diagnostics, parser_app wiring, tvc-deploy/smoke) auto-merged.

Also fixed a semantic auto-merge conflict git could not detect textually:
parser/app/src/registry.rs's `create_registry` takes a `&ParserConfig` (our
deploy-time signature), but #422's test still called it with no args. Updated
that test to build an explicit `AcceptUnsigned` config and pass it through, and
refreshed its doc comment now that the deploy-time flag has landed.

Verified: cargo check --all, cargo clippy --all-targets -D warnings, cargo fmt
--check, all unit tests (visualsign, visualsign-ethereum, parser_app,
parser_cli, diagnostics variants), and all 11 gRPC integration tests pass.
The merge that superseded #422 dropped `test_register_installs_require_signed_posture`
along with the instrumentation it was written beside. Nothing replaced it, so
`EthereumPlugin::register` ended up with no coverage at any layer: editing it back to
`EthereumVisualSignConverter::new()` compiles clean, leaves the suite green, and
silently drops parser_cli to accept-unsigned. That is the exact regression this PR
exists to make visible, so the guard goes back in. Verified by flipping `register` to
the permissive converter and watching it fail.

Two surviving tests had also stopped testing what they name:

`test_try_extract_invalid_address_skipped` was switched to the require-signed policy
while its fixture stayed unsigned, so the posture dropped the entry before the address
was ever parsed. It passed with address validation removed entirely. It runs under
accept-unsigned again, so the address parse is the only possible reason for the drop.

`test_accept_unsigned_does_not_enforce_signer_identity` signed with the dev seed, which
`authorized_abi_signers()` allowlists under cfg(test), so it would have held even if
identity were being enforced. It signs with a restored foreign seed no allowlist knows.

Also fills two coverage gaps the review surfaced. `create_registry` only pinned the
accept-unsigned direction, so a version that ignored `config.abi_trust` entirely would
have passed; the require-signed mirror now fails unless the posture reaches the
converter. At the transport layer only two of the four posture and signature
combinations were exercised, leaving "gates on signer identity" unproven against a real
process; the allowlisted-accept and foreign-signed-drop cases close that.

The signed fixtures are checked in rather than produced at test time. Signing in the
integration crate means depending on `visualsign-ethereum/dev-signing`, and because
`integration` is a workspace member Cargo would then unify that feature ON for
parser_app in any workspace-wide invocation, which is precisely what the
keep-out-of-prod note on that feature warns against. ECDSA here is RFC6979
deterministic, so the fixtures are stable, and the regeneration recipe is in a comment.

The comments in cli_plugin.rs described the CLI's dev-key signature as buying "verified
rather than logged as unverified". That outcome no longer exists, and under the CLI's
require-signed posture an unsigned entry is dropped outright, so the signature is
load-bearing rather than bookkeeping. Left as they were, they invited a future editor to
delete the signing step.

Co-Authored-By: Claude <noreply@anthropic.com>
…st is signed

`validate_signer_pubkey` only checked length, SEC1 tag and hex charset, so a
well-formed hex string that is not a point on secp256k1 passed, landed in
`pivotArgs`, and got quorum-signed into the deployment manifest. parser_app decodes
the key for real at enclave startup, so the mistake only surfaced there: a burned
consensus round plus a redeploy to fix a typo. Decoding it locally turns that into an
error before any network call, which is what the eager validation was already trying
to buy.

The stated reason for skipping the check was that this crate has no elliptic-curve
dependency, but k256 was already in the lockfile transitively, so adding it costs one
line and no new supply chain.

The existing fixtures used repeated hex digits, which are not on the curve, so they are
derived from a real key now. Worth knowing for anyone extending them: `02` followed by
64 'a' characters IS a valid point, so the off-curve case uses a value above the field
prime instead.

Also bounds the value echoed back in the error. Both this and `validate_digest` printed
operator input unbounded, so a mistaken paste of a whole file landed verbatim in CI
logs. Truncation is on char boundaries because the input need not be ASCII.

Co-Authored-By: Claude <noreply@anthropic.com>
`visualsign` is the core library crate, so every public enum is public API. Adding a
third posture later would break any downstream exhaustive match, and the cost of
preventing that is near zero today: every match on this enum lives in `signing.rs`
itself, and consumers only construct variants. Verified with a workspace-wide
`cargo check --all-targets`, which stays clean, so no caller is forced into a wildcard
arm that would quietly swallow a future variant.

Co-Authored-By: Claude <noreply@anthropic.com>
Nothing under docs/ was touched when `create_registry` gained a required
`&ParserConfig`, so the library getting-started page still showed the zero-arg form in
two snippets. Anyone copying the published snippet got a compile error, with no hint
that a trust posture has to be chosen. The same drift was in the add-a-chain guide,
which also pointed at `routes/parse.rs` for a function that lives in `registry.rs`.

The dApp page was worse than stale. It told authors the parser accepts any well-formed
signature from any key and advised shipping unsigned ABIs until their signing pipeline
was stable. Against a require-signed deployment the parser enforces the allowlist
itself and drops unsigned entries, so following that advice silently degrades rendering
rather than easing a rollout. It now splits the guidance by posture and says a dropped
entry is indistinguishable from one never sent.

The gRPC server page documents that binary as a self-hosted deployment, with a
Dockerfile CMD and a k8s spec that pass no arguments, and never mentioned that a
posture exists. Nothing there breaks, since the dev server defaults to accept-unsigned,
but a self-hoster ends up honouring unsigned caller ABIs without having chosen to. The
flag is now stated explicitly in both snippets so the choice lands in their deployment
config instead of being inherited from a default.

Co-Authored-By: Claude <noreply@anthropic.com>
The step's own comment states the convention is to pass inputs via env to avoid
shell injection from interpolated expressions, and the line right below it
interpolated one into the run block. `github.event_name` is GitHub-controlled, so
there is no live injection risk here, but leaving it invites the pattern to be copied
for a value that is not. GitHub already exports `$GITHUB_EVENT_NAME`, so this needs no
new env entry.

Co-Authored-By: Claude <noreply@anthropic.com>
…t messaging

- Add explicit k256 round-trip test for SEC1 compact (05) keys, proving no
  divergence between the format check and the actual curve decode. Improve the
  on-curve rejection error to name the SEC1 tag that was detected, so an
  operator seeing a 'does not decode to a point' error for a 05-prefixed key
  knows k256 saw the compact tag rather than mistaking it for a corrupted key.

- Include the allowlist size in the unsigned-ABI rejection log message under
  require-signed posture, matching MetadataTrustPolicy::Display. An empty
  allowlist now says 'no authorized signers configured' instead of implying
  signing would help.

Co-Authored-By: Claude <noreply@anthropic.com>
- Update validate_signer_pubkey doc comment to reflect two-phase validation (format + on-curve via k256)
- Guard against flag-as-value in gRPC server's hand-rolled arg parser
- cargo fmt fix in abi_metadata.rs
…deploy workflow

Merge the redundant elif/else branches in the bash script that both produced
--accept-unsigned-abis into a single [ -z ] || [ = unsigned ] guard. No
behavior change.
@pepe-anchor

Copy link
Copy Markdown
Contributor Author

@pepe-anchor pepe-anchor closed this Aug 6, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are a couple of correctness/robustness issues to address (notably tvc-deploy pubkey validation/manifest formatting not trimming whitespace consistently, and a brittle parser_app CLI test assertion tied to an internal error-variant string).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Review details

Suppressed comments (3)

tools/tvc-deploy/src/main.rs:491

  • validate_signer_pubkey rejects otherwise-valid keys if they include leading/trailing whitespace, but the runtime allowlist parsing (signer_allowlist_from_hex(entry.trim())) trims. This makes tvc-deploy deploy stricter than parser_app for the same input and can cause avoidable deploy failures from copy/paste artifacts.
fn validate_signer_pubkey(hex_str: &str) -> Result<()> {
    let stripped = hex_str
        .strip_prefix("0x")
        .or_else(|| hex_str.strip_prefix("0X"))
        .unwrap_or(hex_str);

tools/tvc-deploy/src/main.rs:333

  • pivot_args forwards signer pubkeys verbatim into pivotArgs. If the pubkey string contains leading/trailing whitespace (e.g. quoted env var), it will be recorded in the manifest even though the parser trims during decoding. Trimming here keeps the manifest canonical and consistent with runtime parsing.
    for key in &args.accept_signatures_from_pubkey {
        pivot.push("--accept-signatures-from-pubkey".to_string());
        pivot.push(key.clone());
    }

src/parser/app/src/cli.rs:253

  • This assertion depends on an internal error-variant name (MutuallyExclusiveInput) from qos_core's CLI parser, making the test brittle across dependency updates. It's enough to assert parsing fails, or (if you want a message check) assert the error mentions the conflicting flags instead.
        assert!(err.contains("MutuallyExclusiveInput"), "error: {err}");
  • Files reviewed: 26/29 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants