Skip to content

feat: Add signer abstraction via registry interface#7938

Open
nambrot-agent wants to merge 1 commit intomainfrom
feat/signer-abstraction
Open

feat: Add signer abstraction via registry interface#7938
nambrot-agent wants to merge 1 commit intomainfrom
feat/signer-abstraction

Conversation

@nambrot-agent
Copy link
Copy Markdown
Contributor

@nambrot-agent nambrot-agent commented Jan 28, 2026

Summary

Implement signer abstraction that allows CLI users to configure signers through registry files or URIs instead of passing private keys via --key.

Related PR: hyperlane-xyz/hyperlane-registry#1364

Motivation

Passing private keys via --key command-line argument has security issues:

  • Keys appear in shell history
  • Keys visible in process lists
  • No support for key rotation or environment-specific configs

This PR enables:

  1. Define signers in YAML configuration files with hierarchical defaults
  2. Reference environment variables for sensitive data
  3. Use external key management (GCP Secret Manager, Turnkey, Foundry keystores)
  4. Configure different signers per chain or protocol
  5. Extract keys for use with external tools (Foundry, etc.)

Implementation

SDK Changes (typescript/sdk/src/signers/)

config.ts - Signer type definitions with Zod schemas:

  • rawKey - Direct private key or env var reference
  • gcpSecret - GCP Secret Manager reference
  • foundryKeystore - Foundry-compatible encrypted keystore
  • turnkey - Turnkey secure enclave signing
  • Hierarchical defaults: chain > protocol > default

SignerFactory.ts - Create ethers Signers from config:

  • GCP secrets fetched via gcloud CLI (no SDK dependency)
  • Foundry keystores support ETH_PASSWORD env var (Foundry standard)
  • extractPrivateKey() for exporting keys to external tools

CLI Changes

context.ts - Load signer config from registry via getSignerConfiguration()

MultiProtocolSignerManager.ts - Resolve signers with fallback:

  1. Registry signer (chain-specific > protocol-specific > default)
  2. Strategy-provided signer
  3. --key argument fallback

registry.ts - New hyperlane registry signer-key command:

# Extract key for use with Foundry
KEY=$(hyperlane registry signer-key --registry gcp://project/secret)
cast send --private-key "$KEY" ...

# Get only address
hyperlane registry signer-key --registry gcp://project/secret --address-only

Usage Examples

# Use GCP-managed key (requires gcloud CLI)
hyperlane send message \
  --registry gcp://my-project/deployer-key \
  --origin ethereum --destination polygon

# Use Foundry keystore
export ETH_PASSWORD=~/.keystore-password
hyperlane send message \
  --registry foundry://my-account \
  --origin ethereum --destination polygon

# Use YAML config file
hyperlane send message \
  --registry ./my-registry \  # contains signers/ directory
  --origin ethereum --destination polygon

YAML Configuration Example

# signers/default.yaml
signers:
  dev:
    type: rawKey
    privateKeyEnvVar: DEV_KEY
  prod:
    type: gcpSecret
    project: my-project
    secretName: deployer-key

defaults:
  default:
    ref: dev
  chains:
    ethereum:
      ref: prod

Testing

  • Unit tests for signer config schemas
  • Unit tests for SignerFactory
  • E2E tests for registry signer integration
  • Manual testing with GCP and Foundry keystores

Documentation

  • typescript/cli/SIGNER_REGISTRY.md - Comprehensive guide

Checklist

  • SDK signer types and factory
  • CLI integration with registry
  • signer-key command for key extraction
  • Foundry-standard password handling (ETH_PASSWORD)
  • Documentation
  • Unit tests
  • E2E tests

Summary by CodeRabbit

Release Notes

  • New Features

    • Added signer registry configuration support for managing signers via YAML/JSON files instead of direct private keys
    • Introduced signer-key CLI command to extract and retrieve keys and addresses from configured registries
    • Support for multiple signer types: raw keys, GCP Secret Manager, Turnkey, and Foundry Keystore
  • Documentation

    • Added comprehensive signer registry guide with configuration examples and best practices
  • Tests

    • Added end-to-end tests validating signer registry integration and multi-registry merging
    • Added unit tests for signer factory and configuration validation

✏️ Tip: You can customize this high-level summary in your review settings.

Implement signer abstraction that allows CLI users to configure signers
through registry files or URIs instead of passing private keys via --key.

SDK Changes (typescript/sdk/src/signers/):
- config.ts: Define signer types (rawKey, turnkey, gcpSecret, foundryKeystore)
  with Zod schemas and hierarchical defaults (chain > protocol > default)
- SignerFactory.ts: Create ethers Signers from config objects
  - Support for raw keys (direct or env var)
  - GCP Secret Manager (via gcloud CLI, no SDK dependency)
  - Foundry keystores (with ETH_PASSWORD support per Foundry standard)
  - Turnkey secure enclaves
  - extractPrivateKey() for exporting keys to external tools
- index.ts: Export all signer types and factory

CLI Changes:
- context.ts: Load signer config from registry via getSignerConfiguration()
- MultiProtocolSignerManager.ts: Resolve signers from registry with fallback
  Resolution order: registry (chain > protocol > default) -> strategy -> --key
- registry.ts: Add 'hyperlane registry signer-key' command for extracting
  private keys for use with external tools (Foundry, etc.)

New Commands:
- hyperlane registry signer-key: Extract private key from registry signer
  --name: Get specific named signer
  --chain: Get signer for specific chain
  --address-only: Output only the address

Documentation:
- SIGNER_REGISTRY.md: Comprehensive guide for signer configuration

This feature works with hyperlane-xyz/hyperlane-registry PR for signer
registry URIs (gcp://, foundry://).
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 28, 2026

📝 Walkthrough

Walkthrough

This PR introduces a comprehensive signer registry system enabling CLI signers to be configured via registry files and URIs instead of direct private keys. It adds documentation, a new signer-key CLI command for key extraction, integration with signer initialization middleware, a SignerFactory for creating signers from multiple sources, configuration schemas for all signer types, and extensive E2E and unit tests validating registry-based signer resolution across chains and signer type variants.

Changes

Cohort / File(s) Summary
Documentation
typescript/cli/SIGNER_REGISTRY.md
Comprehensive guide covering registry concept, configuration structure, signer types (rawKey, gcpSecret, turnkey, foundryKeystore), resolution hierarchy, URIs, merging, key extraction, security best practices, and troubleshooting.
CLI Registry Command
typescript/cli/src/commands/registry.ts
Adds signer-key subcommand with --name, --chain, and --address-only flags to resolve signer configuration from registry and extract private keys or addresses; includes extractability validation and tailored error handling for non-extractable signer types.
Signer Integration
typescript/cli/src/context/context.ts, typescript/cli/src/context/strategies/signer/MultiProtocolSignerManager.ts
Loads signer configuration from registry in signerMiddleware and ensureEvmSignersForChains; MultiProtocolSignerManager implements private getSignerFromRegistry() to resolve signers by chain/protocol/default hierarchy and caches results before falling back to existing strategy logic.
SDK Signer Configuration
typescript/sdk/src/signers/config.ts
Defines SignerType enum and Zod schemas for RawKeySignerConfig, TurnkeySignerConfig, GCPSecretSignerConfig, FoundryKeystoreSignerConfig; includes SignerRef references, SignerDefaults hierarchies, and SignerConfiguration unions for registry-based signer specification.
SDK Signer Factory
typescript/sdk/src/signers/SignerFactory.ts
Implements SignerFactory with createSigner() dispatcher for all signer types, isExtractable() check, and extractPrivateKey() for RAW_KEY and GCP_SECRET signers; includes Foundry keystore password resolution, GCP secret fetching, and comprehensive error handling for I/O and external tool issues.
SDK Exports
typescript/sdk/src/index.ts, typescript/sdk/src/signers/index.ts
Re-exports signer configuration types, schemas, SignerFactory, ExtractedKey, EXTRACTABLE_SIGNER_TYPES, and related turnkey components for public SDK consumption.
Test Coverage
typescript/sdk/src/signers/config.test.ts, typescript/sdk/src/signers/SignerFactory.test.ts
Unit tests validating signer configuration schemas (RAW_KEY env var support, preference logic, missing field rejection), signer creation across all types, key extraction, and unknown type error handling.
E2E Test Integration
typescript/cli/src/tests/ethereum/signer-registry.e2e-test.ts
End-to-end test suite validating registry-based signer loading, env var support, chain-specific resolution, multi-registry merging, fallback behavior, and registry precedence over explicit --key flag.

Sequence Diagram(s)

sequenceDiagram
    participant CLI as CLI Command
    participant Registry as Signer Registry
    participant Factory as SignerFactory
    participant Provider as Ethers Provider
    participant Signer as Ethers Signer

    CLI->>Registry: resolveSignerConfig(name, chain)
    Note over Registry: Check chain-specific > protocol-specific > default
    alt Registry has config
        Registry-->>CLI: SignerConfig
        CLI->>Factory: createSigner(config, provider)
        alt Config type is RAW_KEY
            Factory->>Factory: resolvePrivateKey (direct or env var)
            Factory->>Signer: new Wallet(privateKey)
            Signer-->>Factory: Wallet instance
        else Config type is GCP_SECRET
            Factory->>Registry: fetchGCPSecret(project, name)
            Registry-->>Factory: secret string
            Factory->>Signer: new Wallet(secret)
            Signer-->>Factory: Wallet instance
        else Config type is TURNKEY/FOUNDRY
            Factory->>Provider: createSigner (external creation)
            Provider-->>Factory: Signer instance
        end
        Factory-->>CLI: Signer
    else Registry lacks config
        Registry-->>CLI: null
        CLI->>CLI: Fall back to --key or default strategy
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • antigremlin
  • yorhodes
  • ltyu
  • paulbalaji
  • Xaroz
  • xeno097

Poem

🧅 Layers, layers everywhere—
Signers stacked like onions fair,
Registry picks just the right one,
Chain-aware, the work gets done,
Keys flow freely, none the worse,
No more hardcoding—quite diverse!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: Add signer abstraction via registry interface' directly and concisely describes the main feature added—enabling signers via registry instead of direct keys.
Description check ✅ Passed The PR description is comprehensive, covering Summary, Motivation, Implementation details, Usage examples, Testing, Documentation, and a Checklist. All template sections are addressed with substantial content.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/signer-abstraction

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@typescript/cli/SIGNER_REGISTRY.md`:
- Around line 84-86: The README has a bare URL in the Requirements section;
update SIGNER_REGISTRY.md to wrap the URL
"https://cloud.google.com/sdk/docs/install" in proper Markdown (either angle
brackets like <https://cloud.google.com/sdk/docs/install> or use link text like
[gcloud CLI installation](https://cloud.google.com/sdk/docs/install)) so linters
won't flag the bare URL and rendering stays consistent.

In `@typescript/cli/src/commands/registry.ts`:
- Around line 295-321: The resolution currently checks defaults.chains and then
defaults.default but skips protocol-level defaults; update the resolution in
registry.ts (around signerConfig.defaults, signerOrRef and signerSource) to
derive the protocol for the provided chain (via your chain metadata lookup or
existing chain->protocol mapping) and then check defaults.protocols[protocol]
before falling back to defaults.default; set signerOrRef and signerSource
accordingly (e.g., "protocol '<protocol>' default") when a protocol-level signer
is chosen.

In `@typescript/cli/src/tests/ethereum/signer-registry.e2e-test.ts`:
- Around line 245-283: The test "should prefer registry signer over --key when
both are provided" currently never passes a --key flag so it doesn't verify
precedence; update the command invocation inside that it(...) block to add a
deliberately invalid --key value (e.g., a bad hex private key string) to the
shell command constructed by localTestRunCmdPrefix() so the command would fail
if the CLI used the flag; keep the same assertions (expect exitCode === 0 and
output includes 'Message ID:', 'Created signer for chain', and 'from registry
configuration') and leave cleanup of `${SIGNERS_DIR}/preferred.yaml` as-is.
- Around line 49-83: The test leaves a persistent signer config file
(writeYamlOrJson(`${SIGNERS_DIR}/default.yaml`, ...)) which can leak into other
tests; update the test to isolate signer configs by writing the signer file into
a per-test temporary directory (or remove the file in an afterEach/try/finally
cleanup) and point the command to that temp signer registry path (replace uses
of SIGNERS_DIR/SIGNER_REGISTRY_PATH accordingly), ensuring writeYamlOrJson's
output is cleaned up so default.yaml cannot affect subsequent tests.

In `@typescript/sdk/src/signers/config.ts`:
- Around line 25-31: RawKeySignerConfigSchema currently allows an empty config
because both privateKey and privateKeyEnvVar are optional; update
RawKeySignerConfigSchema (the z.object with type: z.literal(SignerType.RAW_KEY),
privateKey, privateKeyEnvVar) to add a zod refinement (use superRefine) that
checks at least one of privateKey or privateKeyEnvVar is present and emits a
clear error path (e.g., add errors for privateKeyEnvVar or privateKey) when
neither is provided so validation fails early instead of at runtime.

In `@typescript/sdk/src/signers/SignerFactory.ts`:
- Around line 319-376: The fetchGCPSecret function currently builds a shell
command and uses exec, which permits shell injection; replace the dynamic
command string and promisified exec with promisified execFile (imported from
'child_process') and pass gcloud as the executable with an args array like
['secrets', 'versions', 'access', 'latest', '--secret', secretName, '--project',
project] (preserve the maxBuffer option), then consume stdout/stderr the same
way and keep the existing error parsing/rewriting logic so all the current
permission/auth/not-found checks remain intact; ensure you update the variable
names (e.g., execAsync -> execFileAsync) and maintain logger.debug showing
project/secretName without executing them in a shell.
🧹 Nitpick comments (4)
typescript/sdk/src/signers/SignerFactory.test.ts (1)

92-108: TURNKEY test creates signer but doesn't validate it works.

The test creates a signer with dummy credentials and only checks it exists. That's fine for unit testing the factory's ability to instantiate, but maybe worth a wee comment explaining the limitation.

The dummy public key '0x' + '00'.repeat(33) works for the test, but it's not a valid compressed public key format (should be 33 bytes starting with 0x02 or 0x03 for compressed keys).

💡 Consider using a more realistic dummy key format
-          publicKey: '0x' + '00'.repeat(33), // Dummy compressed public key
+          publicKey: '0x02' + '00'.repeat(32), // Dummy compressed public key (33 bytes with valid prefix)
typescript/cli/src/context/strategies/signer/MultiProtocolSignerManager.ts (1)

247-258: Consider logging at debug level instead of info for successful creation.

The info-level log on line 251 will be emitted for every chain where a registry signer is used. If someone has many chains configured, this could be noisy. Debug level might be more appropriate here.

The warning on failure (line 254) is appropriate since it indicates something the user should investigate.

💬 Consider using debug level for success message
       const signer = await SignerFactory.createSigner(resolvedConfig, provider);
-      this.logger.info(`Created signer for chain ${chain} from registry configuration`);
+      this.logger.debug(`Created signer for chain ${chain} from registry configuration`);
       return signer as TypedSigner;
typescript/sdk/src/signers/config.ts (1)

136-147: Use ChainMap for per-chain defaults.
The chains field represents a per-chain mapping. Type it as ChainMap<SignerOrRef> in the inferred SignerDefaults type to align with SDK conventions and preserve chain-name guarantees across the surface. While the Zod schema validates correctly as-is, manually typing the result to use ChainMap improves consistency with other per-chain configurations in the SDK.

typescript/cli/src/commands/registry.ts (1)

275-348: Type resolvedConfig and signerOrRef to eliminate the any cast.

These variables flow through discriminated union logic that TypeScript can properly narrow with isSignerRef, but the implicit typing forces the (resolvedConfig as any).accountName cast. Declare resolvedConfig: SignerConfig and signerOrRef: SignerOrRef from @hyperlane-xyz/sdk—the union will narrow cleanly based on the type field, and the cast becomes unnecessary.

♻️ Type-safe adjustment
 import {
   EXTRACTABLE_SIGNER_TYPES,
   SignerFactory,
   SignerType,
   isSignerRef,
+  type SignerConfig,
+  type SignerOrRef,
 } from '@hyperlane-xyz/sdk';
@@
-    let resolvedConfig;
+    let resolvedConfig: SignerConfig;
     let signerSource: string;
@@
-      let signerOrRef;
+      let signerOrRef: SignerOrRef;
@@
-            `  cast wallet decrypt-keystore ${(resolvedConfig as any).accountName}\n`,
+            `  cast wallet decrypt-keystore ${resolvedConfig.accountName}\n`,

Comment on lines +84 to +86
Requirements:
- Install the `gcloud` CLI: https://cloud.google.com/sdk/docs/install
- Authenticate: `gcloud auth application-default login`
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.

⚠️ Potential issue | 🟡 Minor

Bare URL should be wrapped in proper markdown syntax.

The linter's got a point here - bare URLs can cause rendering issues in some markdown parsers. Nothing major, just a wee formatting thing.

📝 Wrap URL in angle brackets or use link syntax
 Requirements:
-- Install the `gcloud` CLI: https://cloud.google.com/sdk/docs/install
+- Install the `gcloud` CLI: <https://cloud.google.com/sdk/docs/install>
 - Authenticate: `gcloud auth application-default login`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Requirements:
- Install the `gcloud` CLI: https://cloud.google.com/sdk/docs/install
- Authenticate: `gcloud auth application-default login`
Requirements:
- Install the `gcloud` CLI: <https://cloud.google.com/sdk/docs/install>
- Authenticate: `gcloud auth application-default login`
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

85-85: Bare URL used

(MD034, no-bare-urls)

🤖 Prompt for AI Agents
In `@typescript/cli/SIGNER_REGISTRY.md` around lines 84 - 86, The README has a
bare URL in the Requirements section; update SIGNER_REGISTRY.md to wrap the URL
"https://cloud.google.com/sdk/docs/install" in proper Markdown (either angle
brackets like <https://cloud.google.com/sdk/docs/install> or use link text like
[gcloud CLI installation](https://cloud.google.com/sdk/docs/install)) so linters
won't flag the bare URL and rendering stays consistent.

Comment on lines +295 to +321
// Get the default signer (optionally for a specific chain)
const defaults = signerConfig.defaults;
if (!defaults) {
errorRed(
'❌ No default signer configured in registry.\n' +
'Specify a signer name with --name or configure defaults in the registry.',
);
process.exit(1);
}

// Resolution order: chain > protocol > default
let signerOrRef;
if (chain && defaults.chains?.[chain]) {
signerOrRef = defaults.chains[chain];
signerSource = `chain '${chain}' default`;
} else if (defaults.default) {
signerOrRef = defaults.default;
signerSource = 'default signer';
} else {
errorRed(
'❌ No default signer configured.\n' +
(chain
? `No signer configured for chain '${chain}' or as default.`
: 'Specify --chain or --name to select a signer.'),
);
process.exit(1);
}
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.

⚠️ Potential issue | 🟠 Major

Protocol-level defaults aren’t resolved.
The comment says chain → protocol → default, but defaults.protocols is skipped. That means protocol-only configs fail even when --chain is provided. Consider resolving protocol from chain metadata before falling back to default.

🔧 Suggested resolution order
-      // Resolution order: chain > protocol > default
-      let signerOrRef;
-      if (chain && defaults.chains?.[chain]) {
-        signerOrRef = defaults.chains[chain];
-        signerSource = `chain '${chain}' default`;
-      } else if (defaults.default) {
-        signerOrRef = defaults.default;
-        signerSource = 'default signer';
-      } else {
-        errorRed(
-          '❌ No default signer configured.\n' +
-            (chain
-              ? `No signer configured for chain '${chain}' or as default.`
-              : 'Specify --chain or --name to select a signer.'),
-        );
-        process.exit(1);
-      }
+      // Resolution order: chain > protocol > default
+      let signerOrRef;
+      if (chain) {
+        const chainMeta = context.chainMetadata[chain];
+        if (!chainMeta) {
+          errorRed(`❌ Unknown chain '${chain}'.`);
+          process.exit(1);
+        }
+        if (defaults.chains?.[chain]) {
+          signerOrRef = defaults.chains[chain];
+          signerSource = `chain '${chain}' default`;
+        } else if (defaults.protocols?.[chainMeta.protocol]) {
+          signerOrRef = defaults.protocols[chainMeta.protocol];
+          signerSource = `protocol '${chainMeta.protocol}' default`;
+        } else if (defaults.default) {
+          signerOrRef = defaults.default;
+          signerSource = 'default signer';
+        }
+      } else if (defaults.default) {
+        signerOrRef = defaults.default;
+        signerSource = 'default signer';
+      }
+
+      if (!signerOrRef) {
+        errorRed(
+          '❌ No default signer configured.\n' +
+            (chain
+              ? `No signer configured for chain '${chain}' or as default.`
+              : 'Specify --chain or --name to select a signer.'),
+        );
+        process.exit(1);
+      }
🤖 Prompt for AI Agents
In `@typescript/cli/src/commands/registry.ts` around lines 295 - 321, The
resolution currently checks defaults.chains and then defaults.default but skips
protocol-level defaults; update the resolution in registry.ts (around
signerConfig.defaults, signerOrRef and signerSource) to derive the protocol for
the provided chain (via your chain metadata lookup or existing chain->protocol
mapping) and then check defaults.protocols[protocol] before falling back to
defaults.default; set signerOrRef and signerSource accordingly (e.g., "protocol
'<protocol>' default") when a protocol-level signer is chosen.

Comment on lines +49 to +83
describe('rawKey signer from registry', () => {
it('should send message using signer from registry config file', async () => {
// Create signer configuration with rawKey type
const signerConfig = {
signers: {
deployer: {
type: 'rawKey',
privateKey: ANVIL_KEY,
},
},
defaults: {
default: { ref: 'deployer' },
},
};

writeYamlOrJson(`${SIGNERS_DIR}/default.yaml`, signerConfig);

// Run send message command with registry that includes signer config
// Note: We provide both the anvil registry (for chain metadata) and
// the signer registry (for signer config)
const { exitCode, stdout, stderr } =
await $`${localTestRunCmdPrefix()} hyperlane send message \
--registry ${REGISTRY_PATH} \
--registry ${SIGNER_REGISTRY_PATH} \
--origin ${CHAIN_NAME_2} \
--destination ${CHAIN_NAME_3} \
--verbosity debug \
--quick \
--yes`.nothrow();

const output = stdout + stderr;
expect(exitCode, `Command failed with output: ${output}`).to.equal(0);
expect(output).to.include('Message ID:');
expect(output).to.include(`Sent message from ${CHAIN_NAME_2}`);
});
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.

⚠️ Potential issue | 🟡 Minor

Isolate signer configs between tests.
default.yaml sticks around and can override later defaults depending on merge order, which can make later tests flaky. Clean it up per-test or use per-test temp dirs.

🔧 Suggested cleanup
       writeYamlOrJson(`${SIGNERS_DIR}/default.yaml`, signerConfig);
 
-      // Run send message command with registry that includes signer config
-      // Note: We provide both the anvil registry (for chain metadata) and
-      // the signer registry (for signer config)
-      const { exitCode, stdout, stderr } =
-        await $`${localTestRunCmdPrefix()} hyperlane send message \
-        --registry ${REGISTRY_PATH} \
-        --registry ${SIGNER_REGISTRY_PATH} \
-        --origin ${CHAIN_NAME_2} \
-        --destination ${CHAIN_NAME_3} \
-        --verbosity debug \
-        --quick \
-        --yes`.nothrow();
-
-      const output = stdout + stderr;
-      expect(exitCode, `Command failed with output: ${output}`).to.equal(0);
-      expect(output).to.include('Message ID:');
-      expect(output).to.include(`Sent message from ${CHAIN_NAME_2}`);
+      try {
+        // Run send message command with registry that includes signer config
+        // Note: We provide both the anvil registry (for chain metadata) and
+        // the signer registry (for signer config)
+        const { exitCode, stdout, stderr } =
+          await $`${localTestRunCmdPrefix()} hyperlane send message \
+          --registry ${REGISTRY_PATH} \
+          --registry ${SIGNER_REGISTRY_PATH} \
+          --origin ${CHAIN_NAME_2} \
+          --destination ${CHAIN_NAME_3} \
+          --verbosity debug \
+          --quick \
+          --yes`.nothrow();
+
+        const output = stdout + stderr;
+        expect(exitCode, `Command failed with output: ${output}`).to.equal(0);
+        expect(output).to.include('Message ID:');
+        expect(output).to.include(`Sent message from ${CHAIN_NAME_2}`);
+      } finally {
+        fs.unlinkSync(`${SIGNERS_DIR}/default.yaml`);
+      }
🤖 Prompt for AI Agents
In `@typescript/cli/src/tests/ethereum/signer-registry.e2e-test.ts` around lines
49 - 83, The test leaves a persistent signer config file
(writeYamlOrJson(`${SIGNERS_DIR}/default.yaml`, ...)) which can leak into other
tests; update the test to isolate signer configs by writing the signer file into
a per-test temporary directory (or remove the file in an afterEach/try/finally
cleanup) and point the command to that temp signer registry path (replace uses
of SIGNERS_DIR/SIGNER_REGISTRY_PATH accordingly), ensuring writeYamlOrJson's
output is cleaned up so default.yaml cannot affect subsequent tests.

Comment on lines +245 to +283
it('should prefer registry signer over --key when both are provided', async () => {
// Create signer config
const signerConfig = {
signers: {
'registry-signer': {
type: 'rawKey',
privateKey: ANVIL_KEY,
},
},
defaults: {
default: { ref: 'registry-signer' },
},
};
writeYamlOrJson(`${SIGNERS_DIR}/preferred.yaml`, signerConfig);

try {
// Provide a different (invalid) key via --key
// If registry signer is preferred, command should succeed
// If --key is used, it would fail due to invalid key
const { exitCode, stdout, stderr } =
await $`${localTestRunCmdPrefix()} hyperlane send message \
--registry ${REGISTRY_PATH} \
--registry ${SIGNER_REGISTRY_PATH} \
--origin ${CHAIN_NAME_2} \
--destination ${CHAIN_NAME_3} \
--verbosity debug \
--quick \
--yes`.nothrow();

const output = stdout + stderr;
// The registry signer should be used, so the command should succeed
expect(exitCode, `Command failed with output: ${output}`).to.equal(0);
expect(output).to.include('Message ID:');
expect(output).to.include('Created signer for chain');
expect(output).to.include('from registry configuration');
} finally {
fs.unlinkSync(`${SIGNERS_DIR}/preferred.yaml`);
}
});
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.

⚠️ Potential issue | 🟡 Minor

Test doesn’t actually pass --key yet.
The command omits --key, so it never verifies precedence. Add an invalid key to ensure the registry signer is truly preferred.

🔧 Minimal fix
       try {
         // Provide a different (invalid) key via --key
         // If registry signer is preferred, command should succeed
         // If --key is used, it would fail due to invalid key
+        const INVALID_KEY = '0x123';
         const { exitCode, stdout, stderr } =
           await $`${localTestRunCmdPrefix()} hyperlane send message \
           --registry ${REGISTRY_PATH} \
           --registry ${SIGNER_REGISTRY_PATH} \
           --origin ${CHAIN_NAME_2} \
           --destination ${CHAIN_NAME_3} \
+          --key ${INVALID_KEY} \
           --verbosity debug \
           --quick \
           --yes`.nothrow();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should prefer registry signer over --key when both are provided', async () => {
// Create signer config
const signerConfig = {
signers: {
'registry-signer': {
type: 'rawKey',
privateKey: ANVIL_KEY,
},
},
defaults: {
default: { ref: 'registry-signer' },
},
};
writeYamlOrJson(`${SIGNERS_DIR}/preferred.yaml`, signerConfig);
try {
// Provide a different (invalid) key via --key
// If registry signer is preferred, command should succeed
// If --key is used, it would fail due to invalid key
const { exitCode, stdout, stderr } =
await $`${localTestRunCmdPrefix()} hyperlane send message \
--registry ${REGISTRY_PATH} \
--registry ${SIGNER_REGISTRY_PATH} \
--origin ${CHAIN_NAME_2} \
--destination ${CHAIN_NAME_3} \
--verbosity debug \
--quick \
--yes`.nothrow();
const output = stdout + stderr;
// The registry signer should be used, so the command should succeed
expect(exitCode, `Command failed with output: ${output}`).to.equal(0);
expect(output).to.include('Message ID:');
expect(output).to.include('Created signer for chain');
expect(output).to.include('from registry configuration');
} finally {
fs.unlinkSync(`${SIGNERS_DIR}/preferred.yaml`);
}
});
it('should prefer registry signer over --key when both are provided', async () => {
// Create signer config
const signerConfig = {
signers: {
'registry-signer': {
type: 'rawKey',
privateKey: ANVIL_KEY,
},
},
defaults: {
default: { ref: 'registry-signer' },
},
};
writeYamlOrJson(`${SIGNERS_DIR}/preferred.yaml`, signerConfig);
try {
// Provide a different (invalid) key via --key
// If registry signer is preferred, command should succeed
// If --key is used, it would fail due to invalid key
const INVALID_KEY = '0x123';
const { exitCode, stdout, stderr } =
await $`${localTestRunCmdPrefix()} hyperlane send message \
--registry ${REGISTRY_PATH} \
--registry ${SIGNER_REGISTRY_PATH} \
--origin ${CHAIN_NAME_2} \
--destination ${CHAIN_NAME_3} \
--key ${INVALID_KEY} \
--verbosity debug \
--quick \
--yes`.nothrow();
const output = stdout + stderr;
// The registry signer should be used, so the command should succeed
expect(exitCode, `Command failed with output: ${output}`).to.equal(0);
expect(output).to.include('Message ID:');
expect(output).to.include('Created signer for chain');
expect(output).to.include('from registry configuration');
} finally {
fs.unlinkSync(`${SIGNERS_DIR}/preferred.yaml`);
}
});
🤖 Prompt for AI Agents
In `@typescript/cli/src/tests/ethereum/signer-registry.e2e-test.ts` around lines
245 - 283, The test "should prefer registry signer over --key when both are
provided" currently never passes a --key flag so it doesn't verify precedence;
update the command invocation inside that it(...) block to add a deliberately
invalid --key value (e.g., a bad hex private key string) to the shell command
constructed by localTestRunCmdPrefix() so the command would fail if the CLI used
the flag; keep the same assertions (expect exitCode === 0 and output includes
'Message ID:', 'Created signer for chain', and 'from registry configuration')
and leave cleanup of `${SIGNERS_DIR}/preferred.yaml` as-is.

Comment on lines +25 to +31
export const RawKeySignerConfigSchema = z.object({
type: z.literal(SignerType.RAW_KEY),
/** Direct private key (hex string with 0x prefix) */
privateKey: ZHash.optional(),
/** Environment variable name containing the private key */
privateKeyEnvVar: z.string().optional(),
});
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n typescript/sdk/src/signers/config.ts | head -50

Repository: hyperlane-xyz/hyperlane-monorepo

Length of output: 1899


🏁 Script executed:

rg "RawKeySignerConfigSchema" typescript/sdk/src/ -A 2 -B 2

Repository: hyperlane-xyz/hyperlane-monorepo

Length of output: 1653


🏁 Script executed:

rg "ChainMap" typescript/sdk/src/ --max-count 10

Repository: hyperlane-xyz/hyperlane-monorepo

Length of output: 24710


🌐 Web query:

Zod refine and superRefine for conditional required fields validation

💡 Result:

refine vs superRefine (what matters for “conditionally required”)

  • refine(predicate, options): returns one boolean pass/fail, and (practically) attaches one issue (one path). Good for simple checks. (valentinprugnaud.dev)
  • superRefine((data, ctx) => …): lets you add multiple issues, each with its own path, via ctx.addIssue(...). Best for cross-field + conditional required logic. (valentinprugnaud.dev)

Also important: refine/superRefine run after the object has passed the base shape/type checks (so you may not see your conditional errors until other required fields are fixed). (github.com)


Pattern: “Field B is required when Field A has value X”

Make B optional in the shape, then enforce requirement in superRefine:

import { z } from "zod";

const schema = z
  .object({
    type: z.enum(["Private", "Business"]),
    organisationName: z.string().optional(),
  })
  .superRefine((data, ctx) => {
    if (data.type === "Business" && !data.organisationName?.trim()) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: ["organisationName"],
        message: "Organisation name is required when type is Business",
      });
    }
  });

This is the standard Zod way to do conditional required fields (and lets you target the exact field via path). (valentinprugnaud.dev)


Pattern: “At least one of these fields is required” (and show errors on multiple fields)

Use superRefine and add an issue per field you want highlighted:

const schema = z
  .object({
    warehouses: z.array(z.string()).optional(),
    clusters: z.array(z.string()).optional(),
  })
  .superRefine((data, ctx) => {
    const w = data.warehouses?.length ?? 0;
    const c = data.clusters?.length ?? 0;

    if (w === 0 && c === 0) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: ["warehouses"],
        message: "Select at least one warehouse or cluster",
      });
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: ["clusters"],
        message: "Select at least one warehouse or cluster",
      });
    }
  });

Reason: refine({ path: [...] }) won’t reliably “broadcast” to multiple paths; superRefine is the recommended approach. (stackoverflow.com)


If you need conditional errors to appear even when other required fields fail

Because refinements run after base validation, you have a few common workarounds:

  1. Model the condition structurally with a discriminatedUnion (often best for forms with a “mode/type” field). (github.com)
  2. Move conditional logic into a nested object so it can validate independently of other top-level required fields. (github.com)
  3. (Less common) Use preprocessing / custom parsing, but it’s usually more complex than (1) or (2). (github.com)

Citations:


RawKey schema allows empty configs; add validation to require at least one key source.

Both privateKey and privateKeyEnvVar are optional, so { type: 'rawKey' } passes validation but fails at runtime. Add a refinement to require at least one:

Enforce at-least-one requirement
-export const RawKeySignerConfigSchema = z.object({
-  type: z.literal(SignerType.RAW_KEY),
-  /** Direct private key (hex string with 0x prefix) */
-  privateKey: ZHash.optional(),
-  /** Environment variable name containing the private key */
-  privateKeyEnvVar: z.string().optional(),
-});
+export const RawKeySignerConfigSchema = z
+  .object({
+    type: z.literal(SignerType.RAW_KEY),
+    /** Direct private key (hex string with 0x prefix) */
+    privateKey: ZHash.optional(),
+    /** Environment variable name containing the private key */
+    privateKeyEnvVar: z.string().optional(),
+  })
+  .refine(
+    (cfg) => !!cfg.privateKey || !!cfg.privateKeyEnvVar,
+    { message: 'Provide privateKey or privateKeyEnvVar' },
+  );

Consider using superRefine if you need path-specific error targeting.

🤖 Prompt for AI Agents
In `@typescript/sdk/src/signers/config.ts` around lines 25 - 31,
RawKeySignerConfigSchema currently allows an empty config because both
privateKey and privateKeyEnvVar are optional; update RawKeySignerConfigSchema
(the z.object with type: z.literal(SignerType.RAW_KEY), privateKey,
privateKeyEnvVar) to add a zod refinement (use superRefine) that checks at least
one of privateKey or privateKeyEnvVar is present and emits a clear error path
(e.g., add errors for privateKeyEnvVar or privateKey) when neither is provided
so validation fails early instead of at runtime.

Comment on lines +319 to +376
private static async fetchGCPSecret(
project: string,
secretName: string,
): Promise<string> {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);

const command = `gcloud secrets versions access latest --secret="${secretName}" --project="${project}"`;
logger.debug(`Fetching secret from GCP: ${project}/${secretName}`);

try {
const { stdout, stderr } = await execAsync(command, {
maxBuffer: 1024 * 1024, // 1MB buffer
});

if (stderr) {
logger.warn(`gcloud stderr: ${stderr}`);
}

const secret = stdout.trim();
if (!secret) {
throw new Error(`Secret ${secretName} is empty`);
}

return secret;
} catch (error: any) {
const errorMessage = error.stderr || error.message || String(error);

// Check for common gcloud errors and provide helpful messages
if (errorMessage.includes('command not found')) {
throw new Error(
'gcloud CLI not found. Install the Google Cloud SDK: https://cloud.google.com/sdk/docs/install',
);
}
if (
errorMessage.includes('PERMISSION_DENIED') ||
errorMessage.includes('does not have permission')
) {
throw new Error(
`Permission denied accessing secret ${project}/${secretName}. ` +
`Ensure you have roles/secretmanager.secretAccessor permission.`,
);
}
if (errorMessage.includes('NOT_FOUND')) {
throw new Error(
`Secret not found: ${project}/${secretName}. ` +
`Verify the project ID and secret name are correct.`,
);
}
if (errorMessage.includes('Could not load the default credentials')) {
throw new Error(
'GCP authentication required. Run: gcloud auth application-default login',
);
}

throw new Error(`Failed to fetch GCP secret: ${errorMessage}`);
}
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the actual file to confirm code and context
head -n 380 typescript/sdk/src/signers/SignerFactory.ts | tail -n 70

Repository: hyperlane-xyz/hyperlane-monorepo

Length of output: 2438


🏁 Script executed:

#!/bin/bash
# Search for where SignerConfiguration and GCP secrets are used
rg -n "getSignerConfiguration|SignerConfiguration" typescript/sdk/src -g "*.ts" -A 2 -B 2

Repository: hyperlane-xyz/hyperlane-monorepo

Length of output: 4355


🏁 Script executed:

#!/bin/bash
# Search for GCP secret references and gcloud invocations
rg -n "gcpSecret|GCP_SECRET|gcloud" typescript/sdk/src -g "*.ts" -B 3 -A 3

Repository: hyperlane-xyz/hyperlane-monorepo

Length of output: 10336


Switch from exec to execFile to prevent shell injection when invoking gcloud.

The code currently interpolates project and secretName directly into a shell command string. This allows shell injection if either value contains special characters (e.g., secret$(cmd) or proj; malicious). Since this code fetches cryptographic keys—security-critical operations—it must avoid shell parsing entirely. Use execFile with an args array instead, which executes the gcloud binary directly without invoking a shell.

🔒 Safer gcloud invocation
-    const { exec } = await import('child_process');
-    const { promisify } = await import('util');
-    const execAsync = promisify(exec);
-
-    const command = `gcloud secrets versions access latest --secret="${secretName}" --project="${project}"`;
+    const { execFile } = await import('child_process');
+    const { promisify } = await import('util');
+    const execFileAsync = promisify(execFile);
@@
-      const { stdout, stderr } = await execAsync(command, {
-        maxBuffer: 1024 * 1024, // 1MB buffer
-      });
+      const { stdout, stderr } = await execFileAsync(
+        'gcloud',
+        [
+          'secrets',
+          'versions',
+          'access',
+          'latest',
+          `--secret=${secretName}`,
+          `--project=${project}`,
+        ],
+        { maxBuffer: 1024 * 1024 }, // 1MB buffer
+      );
🤖 Prompt for AI Agents
In `@typescript/sdk/src/signers/SignerFactory.ts` around lines 319 - 376, The
fetchGCPSecret function currently builds a shell command and uses exec, which
permits shell injection; replace the dynamic command string and promisified exec
with promisified execFile (imported from 'child_process') and pass gcloud as the
executable with an args array like ['secrets', 'versions', 'access', 'latest',
'--secret', secretName, '--project', project] (preserve the maxBuffer option),
then consume stdout/stderr the same way and keep the existing error
parsing/rewriting logic so all the current permission/auth/not-found checks
remain intact; ensure you update the variable names (e.g., execAsync ->
execFileAsync) and maintain logger.debug showing project/secretName without
executing them in a shell.

@codecov
Copy link
Copy Markdown

codecov Bot commented Jan 28, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.02%. Comparing base (add6ee2) to head (4775bee).
⚠️ Report is 321 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #7938   +/-   ##
=======================================
  Coverage   77.02%   77.02%           
=======================================
  Files         117      117           
  Lines        2651     2651           
  Branches      244      244           
=======================================
  Hits         2042     2042           
  Misses        593      593           
  Partials       16       16           
Components Coverage Δ
core 87.80% <ø> (ø)
hooks 71.86% <ø> (ø)
isms 81.10% <ø> (ø)
token 86.67% <ø> (ø)
middlewares 84.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Labels

None yet

Projects

Status: In Review

Development

Successfully merging this pull request may close these issues.

2 participants