Skip to content

feat: Add signer registry URIs for GCP and Foundry keystores#1364

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

feat: Add signer registry URIs for GCP and Foundry keystores#1364
nambrot-agent wants to merge 1 commit intomainfrom
feat/signer-registry-uris

Conversation

@nambrot-agent
Copy link
Copy Markdown
Contributor

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

Summary

Add support for signer-specific registry URIs that allow users to configure signing keys without creating YAML configuration files.

Related PR: hyperlane-xyz/hyperlane-monorepo#7938

Motivation

Currently, CLI users must either:

  1. Pass --key <private-key> on the command line (exposes keys in shell history)
  2. Create YAML configuration files with signer definitions

This PR adds URI-based signer configuration for quick, secure key management:

  • gcp://project/secret-name - Load private key from GCP Secret Manager
  • foundry://account-name - Load key from Foundry keystore

Implementation

New Registry Types

GCPSignerRegistry (src/registry/GCPSignerRegistry.ts)

  • Parses gcp://project/secret-name URIs
  • Exposes the secret as a gcpSecret signer type
  • SignerFactory in SDK fetches actual key at runtime via gcloud CLI

FoundryKeystoreRegistry (src/registry/FoundryKeystoreRegistry.ts)

  • Parses foundry://accountName or foundry:///path/to/keystores/accountName URIs
  • Exposes keystore as foundryKeystore signer type
  • Supports Foundry-standard ETH_PASSWORD env var for password file path

IRegistry Interface Changes

Added signer-related methods to IRegistry:

  • getSigners(): Get all named signers
  • getSigner(id): Get a specific signer by name
  • getDefaultSigner(chainName?): Get the default signer
  • getSignerConfiguration(): Get full signer config with defaults

Usage Examples

# Use GCP-managed key
hyperlane send message \
  --registry https://github.com/hyperlane-xyz/hyperlane-registry \
  --registry gcp://my-project/deployer-key \
  --origin ethereum --destination polygon

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

Testing

  • Unit tests for GCPSignerRegistry (13 tests)
  • Unit tests for FoundryKeystoreRegistry (33 tests)
  • All tests passing

Checklist

  • New registry types implement full IRegistry interface
  • URI parsing is robust with helpful error messages
  • Unit tests cover all functionality
  • Documentation in code comments

Summary by CodeRabbit

Release Notes

  • New Features

    • Added support for managing signer configurations across the system.
    • Enabled integration with Google Cloud Platform for signer management.
    • Enabled integration with Foundry keystore for signer management.
    • Added capability to query and resolve signers by name or default.
  • Tests

    • Added comprehensive test coverage for new signer functionality and configurations.

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

Add support for signer-specific registry URIs that allow users to configure
signing keys without creating YAML configuration files:

- gcp://project/secret-name - Load private key from GCP Secret Manager
- foundry://account-name - Load key from Foundry keystore (~/.foundry/keystores)
- foundry:///path/to/keystores/account - Load from custom keystore path

New components:
- GCPSignerRegistry: Exposes GCP secrets as gcpSecret signer type
- FoundryKeystoreRegistry: Exposes Foundry keystores as foundryKeystore signer type
- Updated registry-utils.ts to detect and handle these URI schemes

Both registries implement IRegistry but only provide signer configuration -
chain metadata and warp routes return empty/null values. This allows them
to be merged with other registries that provide chain data.

Also adds signer-related methods to IRegistry interface:
- getSigners(): Get all named signers
- getSigner(id): Get a specific signer by name
- getDefaultSigner(chainName?): Get the default signer
- getSignerConfiguration(): Get full signer config with defaults

Includes unit tests for both new registry types.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 28, 2026

📝 Walkthrough

Walkthrough

This PR adds a multi-layered signer configuration system to the registry infrastructure. New specialized registries for GCP and Foundry keystores are introduced, along with signer resolution logic in the base and merged registry classes. The file system registry gains signer configuration loading and caching capabilities, with all pieces wired through updated exports.

Changes

Cohort / File(s) Summary
Type Definitions & Base Infrastructure
src/types.ts, src/registry/IRegistry.ts, src/registry/BaseRegistry.ts
Add signer type exports (SignerConfig, SignerConfigMap, SignerConfiguration) and four new interface methods to IRegistry for signer querying; BaseRegistry provides default null implementations.
File System Registry & Dispatch
src/fs/FileSystemRegistry.ts, src/fs/registry-utils.ts
FileSystemRegistry gains signer config loading from YAML with caching, default signer resolution (chain/protocol/default hierarchy), and signer reference resolution. Registry dispatch logic updated to detect and instantiate GCP and Foundry keystore registries before GitHub/HTTP fallbacks.
Specialized Signer Registries
src/registry/GCPSignerRegistry.ts, src/registry/FoundryKeystoreRegistry.ts
Two new partial registries with URI parsing (parseGCPUri, parseFoundryKeystoreUri), signer-focused APIs returning SignerConfig/SignerConfiguration, and no-op or error-throwing stubs for non-signer registry methods; track unsupported operations.
Merged Registry Signer Support
src/registry/MergedRegistry.ts
Adds signer config merging across sub-registries, getSigner/getSigners methods, and multi-level default signer resolution with chain/protocol/global precedence and signer reference support.
Public Exports
src/index.ts
Re-export GCPSignerRegistry, GCPSignerRegistryOptions, parseGCPUri, FoundryKeystoreRegistry, FoundryKeystoreRegistryOptions, parseFoundryKeystoreUri.
Test Coverage
test/unit/GCPSignerRegistry.test.ts, test/unit/FoundryKeystoreRegistry.test.ts
Comprehensive tests for URI parsing, constructor behavior, signer configuration generation, non-signer method stubs, and registry merging.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant MergedRegistry
    participant SubReg1 as SubRegistry<br/>(e.g., FileSystem)
    participant SubReg2 as SubRegistry<br/>(e.g., GCP)
    participant Config as Signer<br/>Config

    Client->>MergedRegistry: getDefaultSigner(chainName)
    MergedRegistry->>SubReg1: getSignerConfiguration()
    SubReg1-->>MergedRegistry: SignerConfiguration
    MergedRegistry->>SubReg2: getSignerConfiguration()
    SubReg2-->>MergedRegistry: SignerConfiguration
    MergedRegistry->>Config: Merge configs<br/>(SubReg2 overrides SubReg1)
    MergedRegistry->>Config: Check chain-specific<br/>default
    MergedRegistry->>Config: Fall back to protocol<br/>or global default
    MergedRegistry->>Config: Resolve signer<br/>references
    MergedRegistry-->>Client: SignerConfig
Loading
sequenceDiagram
    participant App
    participant RegistryDispatch as getRegistry()<br/>Dispatch Logic
    participant Parser as URI Parsers
    participant FKReg as FoundryKeystore<br/>Registry
    participant GCPReg as GCP Signer<br/>Registry

    App->>RegistryDispatch: getRegistry(uri)
    RegistryDispatch->>Parser: parseFoundryKeystoreUri(uri)
    alt Match: foundry://...
        Parser-->>RegistryDispatch: {accountName, keystorePath?}
        RegistryDispatch->>FKReg: new FoundryKeystoreRegistry(options)
        FKReg-->>RegistryDispatch: instance
    else No match
        RegistryDispatch->>Parser: parseGCPUri(uri)
        alt Match: gcp://...
            Parser-->>RegistryDispatch: {project, secretName}
            RegistryDispatch->>GCPReg: new GCPSignerRegistry(options)
            GCPReg-->>RegistryDispatch: instance
        else Fall through
            RegistryDispatch->>RegistryDispatch: Try GitHub/HTTP<br/>(existing logic)
        end
    end
    RegistryDispatch-->>App: IRegistry instance
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

This change introduces two new specialized registry classes with substantial logic, integrates them into the dispatch system, adds multi-registry signer merging with precedence rules, and extends the base registry contracts. Multiple files contain non-trivial signer resolution and URI parsing logic, though test coverage is comprehensive and homogeneous patterns help offset the diversity.

Suggested reviewers

  • paulbalaji

Poem

🧅 The registry gets itself a nice thick layer,
Signers stacked high like ogre's lair,
GCP and Foundry each take their seat,
Merged configs swirl, the precedence beat,
One default reigns when the choices compete. 🔑

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Description check ❓ Inconclusive The description covers the motivation, implementation details, and testing, but lacks explicit sections matching the template structure (Description, Backward compatibility, Testing sections are not properly formatted). Restructure the description to follow the template: add a brief 'Description' section header, clearly state 'Backward compatibility: Yes' as a section, and move testing details under a 'Testing' section header for consistency.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding signer registry URIs for GCP and Foundry keystores, which is the primary focus of the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ 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-registry-uris

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: 2

🤖 Fix all issues with AI agents
In `@src/fs/FileSystemRegistry.ts`:
- Around line 154-206: getSignerConfiguration has two issues: mergedConfig is
declared with let but never reassigned (ESLint), and signer file processing
order is non-deterministic; fix by declaring mergedConfig as const in
getSignerConfiguration() and sorting signerFiles (e.g., signerFiles.sort())
before the for loop so overrides are deterministic; keep existing merge logic
that spreads parsed.signers and parsed.defaults (including protocols/chains) and
preserve the try/catch/logger.warn behavior in the same function.

In `@src/fs/registry-utils.ts`:
- Around line 3-7: Currently malformed gcp:// or foundry:// URIs fall through to
file-path validation and produce misleading "Invalid file system path" errors;
change the logic that handles input URIs so that any input starting with
"gcp://" calls parseGCPUri and instantiates GCPSignerRegistry (using
GCPSignerRegistry constructor) and any input starting with "foundry://" calls
parseFoundryKeystoreUri and instantiates FoundryKeystoreRegistry (using
FoundryKeystoreRegistry constructor) so parse/constructor errors are surfaced
immediately; specifically, detect the scheme prefix before file-path validation,
invoke parseGCPUri/parseFoundryKeystoreUri and let or rethrow their errors (or
wrap them with a clearer message) instead of falling through to the file-path
branch.
🧹 Nitpick comments (3)
src/registry/MergedRegistry.ts (1)

160-182: Consider warning on signer ID overrides during merge.
Later registries silently replace earlier signer IDs; a small warning when a key already exists would make collisions easier to spot.

src/registry/GCPSignerRegistry.ts (1)

140-195: DRY up signer config construction.
The same signer shape is built in four methods; a private helper keeps them in sync if fields change.

♻️ Possible refactor
+  private buildSignerConfig(): SignerConfig {
+    return {
+      type: SignerType.GCP_SECRET,
+      project: this.project,
+      secretName: this.secretName,
+    };
+  }
...
-  getSignerConfiguration(): SignerConfiguration {
-    return {
-      signers: {
-        [this.signerName]: {
-          type: SignerType.GCP_SECRET,
-          project: this.project,
-          secretName: this.secretName,
-        },
-      },
-      defaults: {
-        default: { ref: this.signerName },
-      },
-    };
-  }
+  getSignerConfiguration(): SignerConfiguration {
+    return {
+      signers: { [this.signerName]: this.buildSignerConfig() },
+      defaults: { default: { ref: this.signerName } },
+    };
+  }
src/registry/FoundryKeystoreRegistry.ts (1)

194-254: Avoid any casts in buildSignerConfig.
You can keep type safety by using a local intersection type for the optional fields instead of (config as any).

🛠️ Example approach
+  private buildSignerConfig(): SignerConfig & {
+    keystorePath?: string;
+    passwordFile?: string;
+    passwordEnvVar?: string;
+  } {
     const config: SignerConfig = {
       type: SignerType.FOUNDRY_KEYSTORE,
       accountName: this.accountName,
     };
 
     if (this.keystorePath) {
-      (config as any).keystorePath = this.keystorePath;
+      config.keystorePath = this.keystorePath;
     }
 
     if (this.passwordFile) {
-      (config as any).passwordFile = this.passwordFile;
+      config.passwordFile = this.passwordFile;
     }
 
     if (this.passwordEnvVar) {
-      (config as any).passwordEnvVar = this.passwordEnvVar;
+      config.passwordEnvVar = this.passwordEnvVar;
     }
 
     return config;
   }

Comment on lines +154 to +206
/**
* Get the full signer configuration from the signers/ directory
* Parses all .yaml files in the signers/ directory and merges them
*/
getSignerConfiguration(): SignerConfiguration | null {
if (this.signerConfigCache) return this.signerConfigCache;

const signersPath = path.join(this.uri, this.getSignersPath());
if (!fs.existsSync(signersPath)) return null;

const signerFiles = this.listFiles(signersPath).filter(
(f) => f.endsWith('.yaml') || f.endsWith('.yml'),
);

if (signerFiles.length === 0) return null;

// Merge all signer config files
let mergedConfig: SignerConfiguration = {};

for (const filePath of signerFiles) {
try {
const data = fs.readFileSync(filePath, 'utf8');
const parsed = yamlParse(data) as SignerConfiguration;

// Merge signers
if (parsed.signers) {
mergedConfig.signers = { ...mergedConfig.signers, ...parsed.signers };
}

// Merge defaults (later files override earlier ones)
if (parsed.defaults) {
mergedConfig.defaults = {
...mergedConfig.defaults,
...parsed.defaults,
// Deep merge protocols and chains
protocols: {
...mergedConfig.defaults?.protocols,
...parsed.defaults.protocols,
},
chains: {
...mergedConfig.defaults?.chains,
...parsed.defaults.chains,
},
};
}
} catch (error) {
this.logger.warn(`Failed to parse signer config file ${filePath}: ${error}`);
}
}

this.signerConfigCache = mergedConfig;
return mergedConfig;
}
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

Fix lint + make override order deterministic.
ESLint flags mergedConfig as never reassigned, and file order currently depends on filesystem traversal. Sorting makes overrides predictable.

🛠️ Suggested tweak
-    const signerFiles = this.listFiles(signersPath).filter(
-      (f) => f.endsWith('.yaml') || f.endsWith('.yml'),
-    );
+    const signerFiles = this.listFiles(signersPath)
+      .filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'))
+      .sort();
...
-    let mergedConfig: SignerConfiguration = {};
+    const mergedConfig: SignerConfiguration = {};
🧰 Tools
🪛 ESLint

[error] 171-171: 'mergedConfig' is never reassigned. Use 'const' instead.

(prefer-const)

🪛 GitHub Check: lint

[failure] 171-171:
'mergedConfig' is never reassigned. Use 'const' instead

🤖 Prompt for AI Agents
In `@src/fs/FileSystemRegistry.ts` around lines 154 - 206, getSignerConfiguration
has two issues: mergedConfig is declared with let but never reassigned (ESLint),
and signer file processing order is non-deterministic; fix by declaring
mergedConfig as const in getSignerConfiguration() and sorting signerFiles (e.g.,
signerFiles.sort()) before the for loop so overrides are deterministic; keep
existing merge logic that spreads parsed.signers and parsed.defaults (including
protocols/chains) and preserve the try/catch/logger.warn behavior in the same
function.

Comment thread src/fs/registry-utils.ts
Comment on lines +3 to +7
import { GCPSignerRegistry, parseGCPUri } from '../registry/GCPSignerRegistry.js';
import {
FoundryKeystoreRegistry,
parseFoundryKeystoreUri,
} from '../registry/FoundryKeystoreRegistry.js';
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

Handle invalid gcp:// or foundry:// URIs with scheme-specific errors.
Right now Line 83–97 only instantiates when parsing succeeds; malformed URIs fall through to file-path validation and throw “Invalid file system path,” which is pretty confusing for users. I'd route any gcp:// or foundry:// scheme straight to their registries so the constructor emits the proper error.

🛠️ Suggested fix
-import { GCPSignerRegistry, parseGCPUri } from '../registry/GCPSignerRegistry.js';
+import { GCPSignerRegistry } from '../registry/GCPSignerRegistry.js';
 import {
   FoundryKeystoreRegistry,
-  parseFoundryKeystoreUri,
 } from '../registry/FoundryKeystoreRegistry.js';
@@
-      // Check for GCP signer registry (gcp://project/secret-name)
-      if (parseGCPUri(uri)) {
+      // Check for GCP signer registry (gcp://project/secret-name)
+      if (uri.startsWith('gcp://')) {
         return new GCPSignerRegistry({
           uri,
           logger: childLogger,
         });
       }
 
       // Check for Foundry keystore registry (foundry://accountName or foundry:///path/accountName)
-      if (parseFoundryKeystoreUri(uri)) {
+      if (uri.startsWith('foundry://')) {
         return new FoundryKeystoreRegistry({
           uri,
           logger: childLogger,
         });
       }

Also applies to: 83-97

🤖 Prompt for AI Agents
In `@src/fs/registry-utils.ts` around lines 3 - 7, Currently malformed gcp:// or
foundry:// URIs fall through to file-path validation and produce misleading
"Invalid file system path" errors; change the logic that handles input URIs so
that any input starting with "gcp://" calls parseGCPUri and instantiates
GCPSignerRegistry (using GCPSignerRegistry constructor) and any input starting
with "foundry://" calls parseFoundryKeystoreUri and instantiates
FoundryKeystoreRegistry (using FoundryKeystoreRegistry constructor) so
parse/constructor errors are surfaced immediately; specifically, detect the
scheme prefix before file-path validation, invoke
parseGCPUri/parseFoundryKeystoreUri and let or rethrow their errors (or wrap
them with a clearer message) instead of falling through to the file-path branch.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants