Skip to content

feat(ts): unified createSigner factory, config union, and resolveAddress#91

Merged
amilz merged 8 commits intomainfrom
feat/unified-create-signer
Apr 2, 2026
Merged

feat(ts): unified createSigner factory, config union, and resolveAddress#91
amilz merged 8 commits intomainfrom
feat/unified-create-signer

Conversation

@amilz
Copy link
Copy Markdown
Contributor

@amilz amilz commented Mar 27, 2026

Some helpers for our @solana/keychain umprella package for projects that plan to enable many signers.

Summary

  • Export createSigner(config) — unified factory that dispatches to the correct backend based on a backend discriminant field
  • Export KeychainSignerConfig discriminated union type and BackendName string literal union for config management
  • Export resolveAddress(config) — lightweight address lookup that skips full signer init for backends with the public key in config
  • Flat re-export all 10 individual config types from the umbrella package

Motivated by jup-ag/cli#13 where consumers hand-roll the same dispatch logic.

Before (what consumers build today)

// Every consumer writes this same registry...
type KeychainBackend = 'aws-kms' | 'cdp' | 'crossmint' | 'dfns' | 'fireblocks' | ...;

const BACKEND_REGISTRY: Record<KeychainBackend, BackendDef> = {
    'aws-kms': {
        requiredParams: ['keyId', 'publicKey'],
        create: async (params) => {
            const { createAwsKmsSigner } = await import('@solana/keychain-aws-kms');
            return createAwsKmsSigner({ keyId: params.keyId, publicKey: params.publicKey });
        },
    },
    privy: {
        requiredParams: ['appId', 'walletId'],
        create: async (params) => {
            const { createPrivySigner } = await import('@solana/keychain-privy');
            return createPrivySigner({ appId: params.appId, ... });
        },
    },
    // ...repeat for every backend
};

export async function createKeychainSigner(config: KeychainConfig): Promise<SolanaSigner> {
    const def = BACKEND_REGISTRY[config.backend];
    return def.create(config.params);
}

After

import { createSigner, resolveAddress } from '@solana/keychain';

// Create any signer‚ one function, fully typed
const signer = await createSigner({
    backend: 'privy',
    appId: 'your-app-id',
    appSecret: 'your-app-secret',
    walletId: 'your-wallet-id',
});

// Get address without full signer init (instant for config-based backends)
const address = await resolveAddress({
    backend: 'vault',
    vaultAddr: 'https://vault.example.com',
    vaultToken: 'hvs.xxx',
    keyName: 'my-key',
    publicKey: '4Nd1m...',
});

Config management

import type { KeychainSignerConfig, BackendName } from '@solana/keychain';

// Store configs as JSON — the backend field is the discriminant
const configs: KeychainSignerConfig[] = JSON.parse(readFileSync('signers.json', 'utf-8'));

// Type narrows automatically in switch/if blocks
for (const config of configs) {
    if (config.backend === 'fireblocks') {
        console.log(config.vaultAccountId); // TS knows this field exists
    }
}

Test plan

  • 44 unit tests (table-driven across all 10 backends) for createSigner and resolveAddress
  • pnpm run build — clean
  • pnpm run lint — clean
  • pnpm run typecheck — clean
  • agadoo treeshakability — passes
  • Exhaustive never check in default branches — compile-time failure if a backend is added to the union but not handled

Closes TOO-240, TOO-241, TOO-242

@amilz amilz requested a review from dev-jodee as a code owner March 27, 2026 20:00
@amilz amilz self-assigned this Mar 27, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 27, 2026

Greptile Summary

This PR adds three new exports to the @solana/keychain umbrella package: a unified createSigner(config) factory, a lightweight resolveAddress(config) helper, and the KeychainSignerConfig / BackendName types — eliminating the boilerplate dispatch logic consumers were writing by hand (see jup-ag/cli#13). All 10 backends are covered, flat config-type re-exports are added, and 44 table-driven unit tests validate dispatch, config stripping, error propagation, and address resolution.\n\nKey changes:\n- src/types.tsKeychainSignerConfig discriminated union (XxxSignerConfig & { backend: '...' }) and derived BackendName string literal union; no conflicts with pre-existing config fields.\n- src/create-signer.ts — exhaustive switch with compile-time never guard in the default branch; stripBackend helper cleanly removes the discriminant before forwarding to each factory.\n- src/resolve-address.ts — short-circuits synchronously for the five config-backed backends (aws-kms, gcp-kms, turnkey, vault, cdp) using assertIsAddress for runtime validation; delegates to createSigner for the five API-backed backends.\n- src/index.ts — flat re-exports for all 10 individual config types alongside the new entry points.\n- package.json@solana/addresses correctly added as a runtime dependency (needed for assertIsAddress); test script now runs vitest before typecheck.\n- README.md — updated to reflect the new recommended factory-first usage pattern.

Confidence Score: 5/5

Safe to merge — all remaining findings are P2 style/test-hygiene suggestions with no impact on runtime correctness.

The core logic is sound: the discriminated union has no field conflicts, the exhaustive switch provides compile-time coverage, and assertIsAddress guards runtime address validity. Both findings are P2 — a missing beforeEach reset in one test file (tests still pass due to deterministic ordering) and inconsistent await usage that has no behavioral difference without a surrounding try/catch.

typescript/packages/keychain/src/tests/resolve-address.test.ts — missing mock reset could make tests fragile if reordered.

Important Files Changed

Filename Overview
typescript/packages/keychain/src/create-signer.ts New unified factory dispatching to all 10 backend factories via a discriminated switch; exhaustive never-check in default branch; minor await consistency concern (P2)
typescript/packages/keychain/src/resolve-address.ts New address-resolver that short-circuits for config-held addresses (aws-kms, gcp-kms, turnkey, vault, cdp) and delegates to createSigner for API-backed backends; runtime assertIsAddress validation present
typescript/packages/keychain/src/types.ts Clean discriminated union of all 10 backend config types; BackendName derived correctly; no conflicts with existing config type fields
typescript/packages/keychain/src/tests/resolve-address.test.ts Table-driven tests cover all backends; missing beforeEach mock reset makes not.toHaveBeenCalled assertions order-dependent (P2)
typescript/packages/keychain/src/tests/create-signer.test.ts Thorough table-driven tests with beforeEach mock reset; verifies dispatch, config stripping, error propagation, and unknown-backend error code
typescript/packages/keychain/src/index.ts New flat re-exports for all 10 individual config types and the two new entry points; no naming collisions observed
typescript/packages/keychain/package.json Adds @solana/addresses runtime dependency (correct — assertIsAddress is used at runtime); test script now runs vitest before typecheck

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["createSigner(config)\nor resolveAddress(config)"] --> B{config.backend}

    B -->|aws-kms| C1["resolveAddress: return config.publicKey\ncreateAwsKmsSigner(stripBackend)"]
    B -->|gcp-kms| C2["resolveAddress: return config.publicKey\ncreateGcpKmsSigner(stripBackend)"]
    B -->|turnkey| C3["resolveAddress: return config.publicKey\ncreateTurnkeySigner(stripBackend)"]
    B -->|vault| C4["resolveAddress: return config.publicKey\ncreateVaultSigner(stripBackend)"]
    B -->|cdp| C5["resolveAddress: return config.address\ncreateCdpSigner(stripBackend)"]

    B -->|crossmint| D1["createCrossmintSigner → signer.address"]
    B -->|dfns| D2["createDfnsSigner → signer.address"]
    B -->|fireblocks| D3["createFireblocksSigner → signer.address"]
    B -->|para| D4["createParaSigner → signer.address"]
    B -->|privy| D5["createPrivySigner → signer.address"]

    B -->|unknown| E["throwSignerError(CONFIG_ERROR)"]

    subgraph Sync["Sync — address in config (no network call)"]
        C1
        C2
        C3
        C4
        C5
    end

    subgraph Async["Async — address fetched from remote API"]
        D1
        D2
        D3
        D4
        D5
    end
Loading

Reviews (1): Last reviewed commit: "docs(ts): update @solana/keychain README..." | Re-trigger Greptile

dev-jodee
dev-jodee previously approved these changes Mar 30, 2026
Base automatically changed from fix/keychain-a26sfr2-remediations to main April 2, 2026 12:09
@dev-jodee dev-jodee dismissed their stale review April 2, 2026 12:09

The base branch was changed.

@dev-jodee dev-jodee self-requested a review April 2, 2026 12:18
dev-jodee
dev-jodee previously approved these changes Apr 2, 2026
amilz added 6 commits April 2, 2026 06:09
Returns the signer address without full signing setup. Sync backends
(AWS KMS, CDP, GCP KMS, Turnkey, Vault) return the config-provided
address directly. Async backends fall back to createSigner().

Closes TOO-242
…union

Address review feedback: call assertIsAddress() instead of bare casts in
resolveAddress sync branches. Derive BackendName from KeychainSignerConfig
to prevent drift.
- Add @solana/addresses as direct dependency (was transitive-only)
- Replace raw Error with throwSignerError(CONFIG_ERROR) + never
  exhaustiveness check in default branches
- Add table-driven tests for createSigner (31 tests) and
  resolveAddress (13 tests) covering all 10 backends
Lead with createSigner/resolveAddress, add missing backends to table
(CDP, Dfns, GCP KMS), document KeychainSignerConfig and BackendName.
@amilz amilz force-pushed the feat/unified-create-signer branch from 0042e42 to bfe9894 Compare April 2, 2026 13:11
amilz added 2 commits April 2, 2026 06:15
Change @solana/addresses from ^6.0.1 to >=6.0.1 to match the
peerDependencies specifier and existing lockfile. Apply prettier
formatting to test files.
The dependency was added as ^6.0.1 but the lockfile still had the
peerDependencies specifier >=6.0.1. Regenerate lockfile to match.
@amilz amilz merged commit 66f47d8 into main Apr 2, 2026
56 checks passed
@amilz amilz deleted the feat/unified-create-signer branch April 2, 2026 13:26
amilz added a commit that referenced this pull request Apr 2, 2026
…ified factory

Align contributor-facing docs with PR #72 (audit remediations) and
PR #91 (unified createSigner factory):

- Replace sign_partial_transaction with SignTransactionResult enum
- Add HTTPS enforcement, HTTP timeout, and HttpClientConfig patterns
- Add TransactionUtil shared helpers (classify, add_signature, serialize)
- Document error redaction, sanitizeRemoteErrorResponse, safe JSON parsing
- Expand TS umbrella package section from 3 to 6 integration files
- Update security best practices (zeroize, Option<Pubkey>, no .expect())
amilz added a commit that referenced this pull request Apr 2, 2026
…ory (#96)

* docs: update add-signer guide and skill for audit remediations and unified factory

Align contributor-facing docs with PR #72 (audit remediations) and
PR #91 (unified createSigner factory):

- Replace sign_partial_transaction with SignTransactionResult enum
- Add HTTPS enforcement, HTTP timeout, and HttpClientConfig patterns
- Add TransactionUtil shared helpers (classify, add_signature, serialize)
- Document error redaction, sanitizeRemoteErrorResponse, safe JSON parsing
- Expand TS umbrella package section from 3 to 6 integration files
- Update security best practices (zeroize, Option<Pubkey>, no .expect())

* fix(docs): remove unused Duration import, add missing http_config param to test templates
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.

3 participants