/** * CredentialsProvider — abstraction over per-vault, per-venue API credentials. * * Lucidly Pro vaults are strategist-deployed permissionless objects. Each vault has a strategist * EOA, and the strategist may configure off-chain venue credentials (HyperLiquid API key, etc.) * for their vault. The SDK has NO opinion on where those credentials live — it consumes them * through this interface. Concrete adapters land in callers that have a storage story: * * • CLI: probably env or `~/.lucidly/credentials/.json` (file-based) * • UI: probably IndexedDB encrypted with a strategist-supplied passphrase * • Server: probably AWS KMS / Doppler / 1Password Service Account / etc. * * The SDK ships: * • `CredentialsProvider` interface * • `EnvCredentialsProvider` — reads from process.env with deterministic naming (CLI/scripts) * • `InMemoryCredentialsProvider` — for tests * * Anything more sophisticated (file-based, KMS-backed) lives in the caller package. */ export interface VenueCredentials { /** * Credential payload. Shape is venue-specific: * • HyperLiquid: { apiSecret: "0x..."; accountAddress: "0x..." } (uses Ethereum-signed messages) * • Binance: { apiKey: "..."; apiSecret: "..." } (HMAC) * • Generic: freeform record, the venue-specific runner adapter destructures it */ [field: string]: string | undefined; } export interface CredentialsProvider { /** * Resolve credentials for a (vault, venue) pair. Return `null` if no credentials are * configured — the runner should treat that as a "skip api step" or surface a clear error, * depending on policy. * * The SDK never logs or stores the returned object; it's passed through to the venue adapter. */ getCredentials(vault: `0x${string}`, venue: string): Promise; /** * Optional: list venues for which credentials are configured for a given vault. Used by the * UI to show "credentials configured: hyperliquid, binance" hints. Implementations that can't * cheaply enumerate (e.g. KMS lookup-by-name) can omit and return []. */ listVenues?(vault: `0x${string}`): Promise; } // ─── EnvCredentialsProvider ────────────────────────────────────────────────── // // Looks up `LUCIDLY_CRED__` from process.env, optionally scoped per-vault via // `LUCIDLY_CRED___`. Conventions: // // Global-for-strategist: // LUCIDLY_CRED_HYPERLIQUID_API_SECRET=0x… // LUCIDLY_CRED_HYPERLIQUID_ACCOUNT_ADDRESS=0x… // // Per-vault override (first 8 chars of vault address, uppercase): // LUCIDLY_CRED_VAULT_DEADBEEF_HYPERLIQUID_API_SECRET=0x… // // Per-vault wins. Field names follow the venue's documented shape, uppercased + underscored. export class EnvCredentialsProvider implements CredentialsProvider { constructor(private readonly env: NodeJS.ProcessEnv = process.env) {} async getCredentials(vault: `0x${string}`, venue: string): Promise { const venueUpper = venue.toUpperCase(); const vaultPrefix = vault.slice(2, 10).toUpperCase(); // first 8 hex chars const perVaultPrefix = `LUCIDLY_CRED_VAULT_${vaultPrefix}_${venueUpper}_`; const globalPrefix = `LUCIDLY_CRED_${venueUpper}_`; const matched: VenueCredentials = {}; let hits = 0; for (const [k, v] of Object.entries(this.env)) { if (typeof v !== "string") continue; if (k.startsWith(perVaultPrefix)) { matched[envSuffixToField(k.slice(perVaultPrefix.length))] = v; hits++; } } // Fall back to global if nothing per-vault. if (hits === 0) { for (const [k, v] of Object.entries(this.env)) { if (typeof v !== "string") continue; if (k.startsWith(globalPrefix)) { matched[envSuffixToField(k.slice(globalPrefix.length))] = v; hits++; } } } return hits > 0 ? matched : null; } } /** Convert UPPER_SNAKE_CASE env-suffix → lowerCamelCase field name. */ function envSuffixToField(s: string): string { return s.toLowerCase().replace(/_([a-z])/g, (_m, c) => c.toUpperCase()); } // ─── InMemoryCredentialsProvider ───────────────────────────────────────────── // // Tests + browser-side use. Caller passes a fully-resolved map; no env, no fs, no IO. export class InMemoryCredentialsProvider implements CredentialsProvider { /** key = `${vault.toLowerCase()}::${venue.toLowerCase()}` */ private readonly store: Map; constructor(initial: Record> = {}) { this.store = new Map(); for (const [vault, byVenue] of Object.entries(initial)) { for (const [venue, creds] of Object.entries(byVenue)) { this.set(vault as `0x${string}`, venue, creds); } } } set(vault: `0x${string}`, venue: string, creds: VenueCredentials): void { this.store.set(this.key(vault, venue), creds); } async getCredentials(vault: `0x${string}`, venue: string): Promise { return this.store.get(this.key(vault, venue)) ?? null; } async listVenues(vault: `0x${string}`): Promise { const prefix = `${vault.toLowerCase()}::`; return Array.from(this.store.keys()) .filter((k) => k.startsWith(prefix)) .map((k) => k.slice(prefix.length)); } private key(vault: `0x${string}`, venue: string): string { return `${vault.toLowerCase()}::${venue.toLowerCase()}`; } }