diff --git a/package.json b/package.json index a809221a..48e54d79 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,8 @@ "@libp2p/tcp": "^11.0.17", "@multiformats/multiaddr": "^13.0.1", "@multiformats/multiaddr-to-uri": "^12.0.0", + "@open-wallet-standard/adapters": "^1.4.2", + "@open-wallet-standard/core": "^1.4.2", "@sentry/node": "^10.49.0", "commander": "^14.0.3", "datastore-core": "^11.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bfba3c5..524a5eaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,12 @@ importers: '@multiformats/multiaddr-to-uri': specifier: ^12.0.0 version: 12.0.0 + '@open-wallet-standard/adapters': + specifier: ^1.4.2 + version: 1.4.2(viem@2.48.4(typescript@6.0.3)(zod@4.3.6)) + '@open-wallet-standard/core': + specifier: ^1.4.2 + version: 1.4.2 '@sentry/node': specifier: ^10.49.0 version: 10.49.0 @@ -702,6 +708,44 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@open-wallet-standard/adapters@1.4.2': + resolution: {integrity: sha512-xXKBRmfNltnJH6BhTyTlpCExWga0bD16GNlx18sr7FbvIOh51CpF+6tn4z0lTNoz7VNAVM/jCS4UNL66V0II5w==} + peerDependencies: + '@solana/web3.js': '>=1.0.0' + '@tetherto/wdk-wallet': '>=1.0.0-beta.0' + viem: '>=2.0.0' + peerDependenciesMeta: + '@solana/web3.js': + optional: true + '@tetherto/wdk-wallet': + optional: true + viem: + optional: true + + '@open-wallet-standard/core-darwin-arm64@1.4.2': + resolution: {integrity: sha512-0bu36U0vGLqIn7rxHAJNA253VvV9xt+AbWLWf8NHxiDoLaZX0RSVyBd79g80Pyi6N9/KkgltDWAq4hfruOaz9A==} + cpu: [arm64] + os: [darwin] + + '@open-wallet-standard/core-darwin-x64@1.4.2': + resolution: {integrity: sha512-RvFMd6PhOT4UDH0NnybYiouLcjH9hPaP0i0I42R/mGnqNZq/ZwQ2h7jhFRu8hj213xV4LYfT2mWCnSp5eviMMg==} + cpu: [x64] + os: [darwin] + + '@open-wallet-standard/core-linux-arm64-gnu@1.4.2': + resolution: {integrity: sha512-srJiGO5A6mc1j3QU4Br3/sUZbikRi4T9c1wZdT8MBZcSFiJ5MONqyduMHQhfbd60jTJK73MeqDFXdKaBnxU9qg==} + cpu: [arm64] + os: [linux] + + '@open-wallet-standard/core-linux-x64-gnu@1.4.2': + resolution: {integrity: sha512-0Hqsv/qu/YAlQ9oKJ6c1ZwFhbzf4O58OSb9EK7v6Ji0Ih+SS4VX4X3shuikjQuBD3sNXYer5pLLA0OSGNYmFxw==} + cpu: [x64] + os: [linux] + + '@open-wallet-standard/core@1.4.2': + resolution: {integrity: sha512-alu69/tEeGYJhC1nyc7EGhJM6oqOU8XQv3S1qYe+LFYU/iHHi7QQrnTvNBesUkosccWf8TRZzhmGluRhaE44KQ==} + hasBin: true + '@opentelemetry/api-logs@0.207.0': resolution: {integrity: sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==} engines: {node: '>=8.0.0'} @@ -3205,6 +3249,32 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 + '@open-wallet-standard/adapters@1.4.2(viem@2.48.4(typescript@6.0.3)(zod@4.3.6))': + dependencies: + '@noble/curves': 1.9.1 + '@open-wallet-standard/core': 1.4.2 + optionalDependencies: + viem: 2.48.4(typescript@6.0.3)(zod@4.3.6) + + '@open-wallet-standard/core-darwin-arm64@1.4.2': + optional: true + + '@open-wallet-standard/core-darwin-x64@1.4.2': + optional: true + + '@open-wallet-standard/core-linux-arm64-gnu@1.4.2': + optional: true + + '@open-wallet-standard/core-linux-x64-gnu@1.4.2': + optional: true + + '@open-wallet-standard/core@1.4.2': + optionalDependencies: + '@open-wallet-standard/core-darwin-arm64': 1.4.2 + '@open-wallet-standard/core-darwin-x64': 1.4.2 + '@open-wallet-standard/core-linux-arm64-gnu': 1.4.2 + '@open-wallet-standard/core-linux-x64-gnu': 1.4.2 + '@opentelemetry/api-logs@0.207.0': dependencies: '@opentelemetry/api': 1.9.1 diff --git a/src/add/add.ts b/src/add/add.ts index b04ea532..7089e000 100644 --- a/src/add/add.ts +++ b/src/add/add.ts @@ -176,7 +176,7 @@ export async function runAdd(options: AddOptions): Promise Account +} + +interface OwsCore { + signTypedData: ( + wallet: string, + chain: string, + typedDataJson: string, + passphrase?: string | undefined, + index?: number | undefined, + vaultPath?: string | undefined + ) => { signature: string } +} + +const UNAVAILABLE_MESSAGE = + 'OpenWallet Standard is not available on this platform. ' + + '@open-wallet-standard/core ships napi-rs prebuilt binaries for linux-x64-gnu, ' + + 'linux-arm64-gnu, darwin-x64, and darwin-arm64 only (no Windows or musl/Alpine artifact today). ' + + 'Use --private-key / PRIVATE_KEY instead, or run on a supported platform.' + +function unavailable(err: unknown): Error { + const reason = err instanceof Error ? err.message : String(err) + return new Error(`${UNAVAILABLE_MESSAGE}\nUnderlying load error: ${reason}`) +} + +async function loadAdapter(): Promise { + try { + return (await import('@open-wallet-standard/adapters/viem')) as unknown as OwsViemAdapter + } catch (err) { + throw unavailable(err) + } +} + +async function loadCore(): Promise { + try { + return (await import('@open-wallet-standard/core')) as unknown as OwsCore + } catch (err) { + throw unavailable(err) + } +} + +const EVM_ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/ + +/** + * Encode a bigint as the OWS core's EIP-712 parser expects a uint value. + * + * The parser enforces two constraints: + * - Decimal uint values above 2^128 are rejected ("exceeds u128 range; use hex + * encoding"). synapse-sdk's `clientDataSetId` and `nonce` are `randU256()`, + * uniform over the full uint256 range, so hex is required in the general case. + * - Hex values must have an even number of digits ("bad uint hex: Odd number of + * digits"), so `0x0` and `0x1ab` are not valid. + * + * Even-length hex satisfies both and produces signatures identical to viem + * across 0, 2^128 - 1, 2^128, and 2^256 - 1. + * + * Negative values fall back to decimal. synapse-sdk uses only uint EIP-712 + * fields; the hex form here is unsigned. + */ +function bigintToOwsHex(value: bigint): string { + if (value < 0n) return value.toString() + const hex = value.toString(16) + return `0x${hex.length % 2 === 1 ? `0${hex}` : hex}` +} + +/** + * Build a viem `Account` backed by an OWS wallet. + * + * filecoin-pin signs via FEVM (Filecoin EVM), so we always request an + * `eip155:*` account from OWS. OWS wallets typically also expose a native + * Filecoin (`fil:*`, f1/f3 address) account derived from the same seed — + * that one is not used here, since synapse-sdk targets FEVM via viem. + * + * The returned account is a `LocalAccount` from viem's perspective; signing + * calls are delegated to the OWS native core, so the private key never + * materializes in the Node process. + */ +export async function getOwsAccount(options: OwsAccountOptions): Promise { + const { owsToViemAccount } = await loadAdapter() + const chainId = `eip155:${options.chain.id}` + const adapterOptions: Parameters[1] = { chain: chainId } + if (options.passphrase != null) adapterOptions.passphrase = options.passphrase + if (options.index != null) adapterOptions.index = options.index + if (options.vaultPath != null) adapterOptions.vaultPath = options.vaultPath + const account = owsToViemAccount(options.walletId, adapterOptions) + if (!EVM_ADDRESS_REGEX.test(account.address)) { + throw new Error( + `OWS returned a non-EVM address (${account.address}) for wallet "${options.walletId}". ` + + 'filecoin-pin signs via FEVM and requires an eip155 account. ' + + 'Check that the wallet has an eip155:* entry in `ows wallet list`.' + ) + } + + const core = await loadCore() + + /** + * Sign EIP-712 typed data through the OWS core, which takes the payload as a + * JSON string. Every typed-data message synapse-sdk signs carries bigints + * (`clientDataSetId`, `nonce`, `pieceIndex`, permit `value`/`deadline`), and + * viem hands a local account its message with those bigints intact (only its + * JSON-RPC path serializes). So this override does the serialization the core + * needs: encode bigints as even-length hex (see `bigintToOwsHex`) and include + * the `EIP712Domain` type. Only `signTypedData` needs this shaping; + * `signMessage` and `signTransaction` from the adapter encode their own input. + */ + const signTypedData = async (typedData: TypedDataDefinition): Promise<`0x${string}`> => { + // viem's signTypedData action adds EIP712Domain to `types` before an account + // sees the payload; a direct account.signTypedData() call does not. Add it + // here so the core resolves the domain type ("unknown type: EIP712Domain" + // otherwise). A caller-supplied EIP712Domain wins via the spread order. + const withDomain = { + ...typedData, + types: { + EIP712Domain: getTypesForEIP712Domain({ domain: typedData.domain }), + ...typedData.types, + }, + } + const serialized = JSON.stringify(withDomain, (_key, value) => + typeof value === 'bigint' ? bigintToOwsHex(value) : value + ) + const result = core.signTypedData( + options.walletId, + chainId, + serialized, + options.passphrase, + options.index, + options.vaultPath + ) + const signature = result.signature + return (signature.startsWith('0x') ? signature : `0x${signature}`) as `0x${string}` + } + + return { ...account, signTypedData } as Account +} diff --git a/src/core/synapse/index.ts b/src/core/synapse/index.ts index 31eca0d6..3f3cb479 100644 --- a/src/core/synapse/index.ts +++ b/src/core/synapse/index.ts @@ -227,7 +227,8 @@ export async function initializeSynapse(config: SynapseSetupConfig, logger?: Log ) } throw new Error( - 'No authentication provided. Supply a private key (--private-key / PRIVATE_KEY), ' + + 'No authentication provided. Supply an OWS wallet (--wallet / OWS_WALLET_ID), ' + + 'private key (--private-key / PRIVATE_KEY), ' + 'wallet address (--wallet-address / WALLET_ADDRESS), or session key (--session-key / SESSION_KEY).' ) } diff --git a/src/import/import.ts b/src/import/import.ts index 55b42577..57c8d5ec 100644 --- a/src/import/import.ts +++ b/src/import/import.ts @@ -246,7 +246,7 @@ export async function runCarImport(options: ImportOptions): Promise try { // Parse and validate authentication - const authConfig = parseCLIAuth(options) + const authConfig = await parseCLIAuth(options) const logger = getCLILogger() const synapse = await initializeSynapse(authConfig, logger) diff --git a/src/payments/deposit.ts b/src/payments/deposit.ts index 1953ce04..46228ef0 100644 --- a/src/payments/deposit.ts +++ b/src/payments/deposit.ts @@ -45,7 +45,7 @@ export async function runDeposit(options: DepositOptions): Promise { spinner.start('Connecting...') try { // Parse and validate authentication - const authConfig = parseCLIAuth(options) + const authConfig = await parseCLIAuth(options) const logger = getCLILogger() const synapse = await initializeSynapse(authConfig, logger) diff --git a/src/payments/fund.ts b/src/payments/fund.ts index b6e15800..3eddda54 100644 --- a/src/payments/fund.ts +++ b/src/payments/fund.ts @@ -246,7 +246,7 @@ export async function runFund(options: FundOptions): Promise { spinner.start('Connecting...') try { // Parse and validate authentication - const authConfig = parseCLIAuth(options) + const authConfig = await parseCLIAuth(options) const logger = getCLILogger() const synapse = await initializeSynapse(authConfig, logger) diff --git a/src/payments/interactive.ts b/src/payments/interactive.ts index 0c664f68..cff9b926 100644 --- a/src/payments/interactive.ts +++ b/src/payments/interactive.ts @@ -51,7 +51,15 @@ export async function runInteractiveSetup(options: PaymentSetupOptions): Promise // Get private key let privateKey = options.privateKey - if (!privateKey) { + // Only prompt for a private key when no signing auth mode was supplied. An + // OWS wallet or a complete session key signs in parseCLIAuth below, so + // prompting would add a private key as a second signing mode and trip the + // mutual-exclusion check. A view address (read-only) and a lone session-key + // half cannot run setup, so they do not suppress the prompt. + const nonEmpty = (value?: string): boolean => value != null && value !== '' + const hasSigningAuth = nonEmpty(options.wallet) || (nonEmpty(options.walletAddress) && nonEmpty(options.sessionKey)) + + if (!privateKey && !hasSigningAuth) { const input = await password({ message: 'Enter your private key', validate: (value) => { @@ -84,7 +92,7 @@ export async function runInteractiveSetup(options: PaymentSetupOptions): Promise // Initialize Synapse s.start('Initializing connection...') - const config = parseCLIAuth({ ...options, privateKey }) + const config = await parseCLIAuth({ ...options, privateKey }) const synapse = await initializeSynapse(config) const network = synapse.chain.name const address = getClientAddress(synapse) diff --git a/src/payments/status.ts b/src/payments/status.ts index bb8d4cc6..762eeb78 100644 --- a/src/payments/status.ts +++ b/src/payments/status.ts @@ -55,7 +55,7 @@ export async function showPaymentStatus(options: StatusOptions): Promise { spinner.start('Fetching current configuration...') try { - const authConfig = parseCLIAuth(options) + const authConfig = await parseCLIAuth(options) const logger = getCLILogger() const synapse = await initializeSynapse(authConfig, logger) const network = synapse.chain.name diff --git a/src/payments/withdraw.ts b/src/payments/withdraw.ts index faf96ba3..af6a4f7c 100644 --- a/src/payments/withdraw.ts +++ b/src/payments/withdraw.ts @@ -37,7 +37,7 @@ export async function runWithdraw(options: WithdrawOptions): Promise { spinner.start('Connecting...') try { // Parse and validate authentication - const authConfig = parseCLIAuth(options) + const authConfig = await parseCLIAuth(options) const logger = getCLILogger() const synapse = await initializeSynapse(authConfig, logger) diff --git a/src/rm/remove-all-pieces.ts b/src/rm/remove-all-pieces.ts index e253fdd0..23f80eb7 100644 --- a/src/rm/remove-all-pieces.ts +++ b/src/rm/remove-all-pieces.ts @@ -61,7 +61,7 @@ export async function runRmAllPieces(options: RmAllPiecesOptions): Promise ({ + getOwsAccount: vi.fn(async (opts: unknown) => ({ address: '0xf39Fd6', __ows: opts })), + resolveChainFromRpc: vi.fn(async () => ({ id: 314159, name: 'Filecoin Calibration' })), +})) +vi.mock('../../core/ows/index.js', () => ({ getOwsAccount })) +vi.mock('../../core/synapse/resolve-chain-from-rpc.js', () => ({ resolveChainFromRpc })) + +vi.mock('../../core/synapse/index.js', () => ({ + createTransport: vi.fn(() => ({ transport: true })), + initializeSynapse: vi.fn(), +})) + +// getRpcUrl() calls resolveDevnetConfig internally, so mock it at the source +// module (devnet-config) rather than the get-rpc-url re-export; get-rpc-url's +// internal reference and cli-auth's import both resolve to this mock. +const { resolveDevnetConfig } = vi.hoisted(() => ({ + resolveDevnetConfig: vi.fn(() => ({ + privateKey: '0xdevnetkey', + chain: { id: 31415926, name: 'Devnet', rpcUrls: { default: { http: ['http://127.0.0.1:8545'] } } }, + })), +})) +vi.mock('../../common/devnet-config.js', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, resolveDevnetConfig } +}) + +import type { AuthOptionSources, CLIAuthOptions } from '../../utils/cli-auth.js' +import { parseCLIAuth } from '../../utils/cli-auth.js' +import { addAuthOptions } from '../../utils/cli-options.js' + +const PK = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +/** Build options with an explicit source map so precedence is deterministic. */ +function withSources(base: CLIAuthOptions, sources: AuthOptionSources): CLIAuthOptions { + return { ...base, optionSources: sources } +} + +beforeEach(() => { + getOwsAccount.mockClear() + resolveChainFromRpc.mockClear() +}) + +describe('parseCLIAuth - single auth mode', () => { + it('resolves a private key into a private-key config', async () => { + const config = await parseCLIAuth({ privateKey: PK, network: 'calibration' }) + expect(config).toMatchObject({ privateKey: PK }) + expect(config).not.toHaveProperty('account') + expect(getOwsAccount).not.toHaveBeenCalled() + }) + + it('resolves an OWS wallet into an account config without loading a private key', async () => { + const config = await parseCLIAuth({ wallet: 'fil-test', network: 'calibration' }) + expect(getOwsAccount).toHaveBeenCalledTimes(1) + expect(config).toHaveProperty('account') + expect(config).not.toHaveProperty('privateKey') + }) + + it('resolves --view-address into read-only config', async () => { + const config = await parseCLIAuth({ viewAddress: '0xabc', network: 'calibration' }) + expect(config).toMatchObject({ walletAddress: '0xabc', readOnly: true }) + expect(getOwsAccount).not.toHaveBeenCalled() + }) + + it('resolves wallet-address + session-key into session-key config', async () => { + const config = await parseCLIAuth({ walletAddress: '0xowner', sessionKey: '0xsess', network: 'calibration' }) + expect(config).toMatchObject({ walletAddress: '0xowner', sessionKey: '0xsess' }) + }) + + it('passes through a lone wallet-address so initializeSynapse can report "requires both"', async () => { + const config = await parseCLIAuth({ walletAddress: '0xowner', network: 'calibration' }) + expect(config).toMatchObject({ walletAddress: '0xowner' }) + expect(config).not.toHaveProperty('sessionKey') + }) + + it('never resolves the OWS account for a non-signing mode', async () => { + await parseCLIAuth({ viewAddress: '0xabc', wallet: 'ignored', network: 'calibration' }).catch(() => undefined) + // (view + ows is a conflict; the point is the native adapter is not eagerly loaded) + expect(getOwsAccount).not.toHaveBeenCalled() + }) +}) + +describe('parseCLIAuth - precedence: explicit flag beats env', () => { + it('explicit --private-key wins over an env OWS wallet', async () => { + const config = await parseCLIAuth( + withSources( + { privateKey: PK, wallet: 'env-wallet', network: 'calibration' }, + { privateKey: 'cli', wallet: 'env' } + ) + ) + expect(config).toMatchObject({ privateKey: PK }) + expect(getOwsAccount).not.toHaveBeenCalled() + }) + + it('explicit --wallet wins over an env private key', async () => { + const config = await parseCLIAuth( + withSources( + { privateKey: PK, wallet: 'cli-wallet', network: 'calibration' }, + { privateKey: 'env', wallet: 'cli' } + ) + ) + expect(config).toHaveProperty('account') + expect(config).not.toHaveProperty('privateKey') + }) + + // A lone explicit session-key half must not outrank a complete env-sourced + // mode. Session-key competes only when BOTH halves are present. + it('a lone explicit --wallet-address does not beat an env OWS wallet', async () => { + const config = await parseCLIAuth( + withSources( + { walletAddress: '0xowner', wallet: 'env-wallet', network: 'calibration' }, + { walletAddress: 'cli', wallet: 'env' } + ) + ) + expect(config).toHaveProperty('account') + expect(config).not.toHaveProperty('walletAddress') + }) + + it('a lone explicit --session-key does not beat an env private key', async () => { + const config = await parseCLIAuth( + withSources( + { sessionKey: '0xsess', privateKey: PK, network: 'calibration' }, + { sessionKey: 'cli', privateKey: 'env' } + ) + ) + expect(config).toMatchObject({ privateKey: PK }) + expect(config).not.toHaveProperty('sessionKey') + }) + + it('a complete explicit session key still beats an env private key', async () => { + const config = await parseCLIAuth( + withSources( + { walletAddress: '0xowner', sessionKey: '0xsess', privateKey: PK, network: 'calibration' }, + { walletAddress: 'cli', sessionKey: 'cli', privateKey: 'env' } + ) + ) + expect(config).toMatchObject({ walletAddress: '0xowner', sessionKey: '0xsess' }) + expect(config).not.toHaveProperty('privateKey') + }) +}) + +describe('parseCLIAuth - precedence: conflicts', () => { + it('errors when two modes are supplied by explicit flags', async () => { + await expect( + parseCLIAuth( + withSources({ privateKey: PK, wallet: 'w', network: 'calibration' }, { privateKey: 'cli', wallet: 'cli' }) + ) + ).rejects.toThrow(/Conflicting authentication options/) + }) + + it('errors when two modes are supplied only by env, hinting to pass a flag', async () => { + await expect( + parseCLIAuth( + withSources({ privateKey: PK, wallet: 'w', network: 'calibration' }, { privateKey: 'env', wallet: 'env' }) + ) + ).rejects.toThrow(/Pass an explicit flag to disambiguate/) + }) + + it('treats a programmatic caller (no sources) as all-explicit, so two modes conflict', async () => { + await expect(parseCLIAuth({ privateKey: PK, wallet: 'w', network: 'calibration' })).rejects.toThrow( + /Conflicting authentication options/ + ) + }) + + it('reports conflicts in canonical order (read-only before private key)', async () => { + await expect( + parseCLIAuth( + withSources( + { viewAddress: '0xabc', privateKey: PK, network: 'calibration' }, + { + viewAddress: 'env', + privateKey: 'env', + } + ) + ) + ).rejects.toThrow(/--view-address\/VIEW_ADDRESS and --private-key\/PRIVATE_KEY/) + }) +}) + +describe('parseCLIAuth - OWS chain hint', () => { + it('uses the network chain when --network is set', async () => { + await parseCLIAuth({ wallet: 'fil-test', network: 'calibration' }) + const [opts] = getOwsAccount.mock.calls[0] as [{ chain: { id: number } }] + expect(opts.chain.id).toBe(314159) + expect(resolveChainFromRpc).not.toHaveBeenCalled() + }) + + it('probes the RPC endpoint for the chain when only --rpc-url is set', async () => { + await parseCLIAuth({ wallet: 'fil-test', rpcUrl: 'https://example.invalid/rpc' }) + expect(resolveChainFromRpc).toHaveBeenCalledTimes(1) + const [opts] = getOwsAccount.mock.calls[0] as [{ chain: { id: number } }] + expect(opts.chain.id).toBe(314159) + }) + + it('defaults the chain hint to mainnet when neither network nor rpc-url is set', async () => { + await parseCLIAuth({ wallet: 'fil-test' }) + const [opts] = getOwsAccount.mock.calls[0] as [{ chain: { id: number } }] + expect(opts.chain.id).toBe(314) + expect(resolveChainFromRpc).not.toHaveBeenCalled() + }) +}) + +describe('parseCLIAuth - OWS passphrase', () => { + it('forwards a real passphrase', async () => { + await parseCLIAuth({ wallet: 'fil-test', walletPassphrase: 'hunter2', network: 'calibration' }) + const [opts] = getOwsAccount.mock.calls[0] as [{ passphrase?: string }] + expect(opts.passphrase).toBe('hunter2') + }) + + it('treats an empty passphrase as not provided', async () => { + await parseCLIAuth({ wallet: 'fil-test', walletPassphrase: '', network: 'calibration' }) + const [opts] = getOwsAccount.mock.calls[0] as [{ passphrase?: string }] + expect(opts).not.toHaveProperty('passphrase') + }) +}) + +describe('parseCLIAuth - devnet fallback', () => { + it('uses the devnet key only when no auth mode is supplied', async () => { + const config = await parseCLIAuth({ network: 'devnet' }) + expect(config).toMatchObject({ privateKey: '0xdevnetkey' }) + expect(resolveDevnetConfig).toHaveBeenCalled() + }) + + it('does not use the devnet key when an OWS wallet is supplied', async () => { + const config = await parseCLIAuth({ network: 'devnet', wallet: 'fil-test' }) + expect(config).toHaveProperty('account') + expect(config).not.toHaveProperty('privateKey') + }) +}) + +describe('addAuthOptions - source collection hook', () => { + const ENV_KEYS = [ + 'PRIVATE_KEY', + 'OWS_WALLET_ID', + 'OWS_WALLET_PASSPHRASE', + 'WALLET_ADDRESS', + 'SESSION_KEY', + 'VIEW_ADDRESS', + ] + let saved: Record + + beforeEach(() => { + saved = {} + for (const key of ENV_KEYS) { + saved[key] = process.env[key] + delete process.env[key] + } + }) + + afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + }) + + async function captureSources(argv: string[]): Promise { + let captured: AuthOptionSources = {} + const command = new Command('add').exitOverride() + command.action((options) => { + captured = options.optionSources + }) + addAuthOptions(command) + await command.parseAsync(argv, { from: 'user' }) + return captured + } + + it('marks an explicit flag as cli and an env var as env', async () => { + process.env.OWS_WALLET_ID = 'env-wallet' + const sources = await captureSources(['--private-key', PK]) + expect(sources.privateKey).toBe('cli') + expect(sources.wallet).toBe('env') + }) + + it('omits options that were never supplied', async () => { + const sources = await captureSources(['--wallet', 'w']) + expect(sources.wallet).toBe('cli') + expect(sources).not.toHaveProperty('privateKey') + expect(sources).not.toHaveProperty('viewAddress') + }) +}) diff --git a/src/test/unit/ows.test.ts b/src/test/unit/ows.test.ts new file mode 100644 index 00000000..92d9c347 --- /dev/null +++ b/src/test/unit/ows.test.ts @@ -0,0 +1,139 @@ +import type { Chain } from 'viem' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Stub the OWS native binding + adapter so this suite runs on every CI platform +// (the native core has no Windows/musl prebuilt). We assert the serialization +// our getOwsAccount override performs, not the Rust signer itself. +const { owsToViemAccount, coreSignTypedData, adapterSignMessage } = vi.hoisted(() => ({ + owsToViemAccount: vi.fn(), + coreSignTypedData: vi.fn( + ( + _wallet: string, + _chain: string, + _typedDataJson: string, + _passphrase?: string, + _index?: number, + _vault?: string + ) => ({ + signature: 'abcd', + }) + ), + adapterSignMessage: vi.fn(), +})) + +vi.mock('@open-wallet-standard/adapters/viem', () => ({ owsToViemAccount })) +vi.mock('@open-wallet-standard/core', () => ({ signTypedData: coreSignTypedData })) + +import { getOwsAccount } from '../../core/ows/index.js' + +const calibration = { id: 314159, name: 'Filecoin Calibration' } as unknown as Chain + +const domain = { + name: 'FilecoinWarmStorageService', + version: '1', + chainId: 314159, + verifyingContract: '0x1111111111111111111111111111111111111111', +} as const + +beforeEach(() => { + owsToViemAccount.mockReset() + coreSignTypedData.mockReset() + coreSignTypedData.mockReturnValue({ signature: 'abcd' }) + owsToViemAccount.mockReturnValue({ + address: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', + signMessage: adapterSignMessage, + signTransaction: vi.fn(), + signTypedData: vi.fn(), + }) +}) + +async function signWith(message: Record, types: Record, primaryType: string) { + const account = await getOwsAccount({ walletId: 'fil-test', chain: calibration }) + const signature = await account.signTypedData?.({ domain, types, primaryType, message } as never) + const call = coreSignTypedData.mock.calls[0] + if (call == null) throw new Error('core.signTypedData was not called') + return { signature, serialized: JSON.parse(call[2]), call } +} + +describe('getOwsAccount - signTypedData bigint serialization', () => { + it('injects EIP712Domain and passes eip155 chain + walletId to the core', async () => { + const { call, serialized } = await signWith( + { clientDataSetId: 42n }, + { CreateDataSet: [{ name: 'clientDataSetId', type: 'uint256' }] }, + 'CreateDataSet' + ) + expect(call[0]).toBe('fil-test') + expect(call[1]).toBe('eip155:314159') + expect(serialized.types.EIP712Domain).toBeDefined() + }) + + it('encodes bigints as even-length hex so values above 2^128 are accepted', async () => { + const big = 2n ** 128n // one over the OWS decimal-uint limit + const { serialized } = await signWith( + { clientDataSetId: big, nonce: 0n, small: 42n }, + { + Msg: [ + { name: 'clientDataSetId', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'small', type: 'uint256' }, + ], + }, + 'Msg' + ) + // Even-length hex, round-trips to the original bigint. + expect(serialized.message.clientDataSetId).toMatch(/^0x([0-9a-f]{2})+$/) + expect(BigInt(serialized.message.clientDataSetId)).toBe(big) + // Zero and small values are also even-padded. + expect(serialized.message.nonce).toBe('0x00') + expect(serialized.message.small).toBe('0x2a') + }) + + it('hex-encodes bigints inside a uint256[] (SchedulePieceRemovals.pieceIds)', async () => { + const { serialized } = await signWith( + { clientDataSetId: 9n, pieceIds: [1n, 2n, 2n ** 200n] }, + { + SchedulePieceRemovals: [ + { name: 'clientDataSetId', type: 'uint256' }, + { name: 'pieceIds', type: 'uint256[]' }, + ], + }, + 'SchedulePieceRemovals' + ) + const ids = serialized.message.pieceIds as string[] + // Each element is even-length hex and round-trips to its original bigint. + expect(ids.every((h) => /^0x([0-9a-f]{2})+$/.test(h))).toBe(true) + expect(ids.map((h) => BigInt(h))).toEqual([1n, 2n, 2n ** 200n]) + }) + + it('handles a max uint256 without throwing', async () => { + const max = 2n ** 256n - 1n + const { serialized } = await signWith( + { clientDataSetId: max }, + { Msg: [{ name: 'clientDataSetId', type: 'uint256' }] }, + 'Msg' + ) + expect(BigInt(serialized.message.clientDataSetId)).toBe(max) + }) + + it('normalizes the returned signature with a 0x prefix', async () => { + coreSignTypedData.mockReturnValue({ signature: 'deadbeef' }) + const { signature } = await signWith( + { clientDataSetId: 1n }, + { Msg: [{ name: 'clientDataSetId', type: 'uint256' }] }, + 'Msg' + ) + expect(signature).toBe('0xdeadbeef') + }) + + it('leaves the adapter signMessage in place (only signTypedData is overridden)', async () => { + const account = await getOwsAccount({ walletId: 'fil-test', chain: calibration }) + expect(account.signMessage).toBe(adapterSignMessage) + }) +}) + +describe('getOwsAccount - address validation', () => { + it('rejects a non-EVM address', async () => { + owsToViemAccount.mockReturnValue({ address: 'f1abc', signMessage: vi.fn() }) + await expect(getOwsAccount({ walletId: 'fil-test', chain: calibration })).rejects.toThrow(/non-EVM address/) + }) +}) diff --git a/src/test/unit/run-interactive-setup.test.ts b/src/test/unit/run-interactive-setup.test.ts index 9b1f79a4..5ce481cc 100644 --- a/src/test/unit/run-interactive-setup.test.ts +++ b/src/test/unit/run-interactive-setup.test.ts @@ -1,10 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runInteractiveSetup } from '../../payments/interactive.js' -const { mockPassword, mockIsCancel, mockIsTTY } = vi.hoisted(() => ({ +const { mockPassword, mockIsCancel, mockIsTTY, mockParseCLIAuth, mockInitializeSynapse } = vi.hoisted(() => ({ mockPassword: vi.fn(), mockIsCancel: vi.fn(() => false), mockIsTTY: vi.fn(() => true), + mockParseCLIAuth: vi.fn(async (_options: { wallet?: string; privateKey?: string }) => ({})), + // Throw right after auth resolves so the flow stops before any network work. + mockInitializeSynapse: vi.fn(async () => { + throw new Error('__stop_after_auth__') + }), })) vi.mock('@clack/prompts', () => ({ @@ -23,6 +28,11 @@ vi.mock('../../utils/cli-logger.js', () => ({ isTTY: mockIsTTY, log: { line: vi.fn(), flush: vi.fn(), indent: vi.fn() }, })) +vi.mock('../../utils/cli-auth.js', () => ({ parseCLIAuth: mockParseCLIAuth })) +vi.mock('../../core/synapse/index.js', () => ({ + initializeSynapse: mockInitializeSynapse, + getClientAddress: vi.fn(() => '0x0000000000000000000000000000000000000000'), +})) describe('runInteractiveSetup exit codes', () => { const originalPrivateKey = process.env.PRIVATE_KEY @@ -49,4 +59,49 @@ describe('runInteractiveSetup exit codes', () => { expect(process.exitCode).toBe(2) }) + + it('does not prompt for a private key when an OWS wallet is supplied', async () => { + // Prompting here would inject a private key alongside --wallet and trip + // parseCLIAuth's mutual-exclusion check for interactive OWS setup. + await runInteractiveSetup({ wallet: 'fil-test', network: 'calibration' } as any).catch(() => undefined) + + expect(mockPassword).not.toHaveBeenCalled() + expect(mockParseCLIAuth).toHaveBeenCalledTimes(1) + const passed = mockParseCLIAuth.mock.calls[0]?.[0] + expect(passed?.wallet).toBe('fil-test') + expect(passed?.privateKey).toBeUndefined() + }) + + it('does not prompt for a private key when a complete session key is supplied', async () => { + await runInteractiveSetup({ walletAddress: '0xowner', sessionKey: '0xsess', network: 'calibration' } as any).catch( + () => undefined + ) + + expect(mockPassword).not.toHaveBeenCalled() + expect(mockParseCLIAuth).toHaveBeenCalledTimes(1) + }) + + it('still prompts for a private key when no auth mode is supplied', async () => { + mockPassword.mockResolvedValueOnce('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80') + + await runInteractiveSetup({ network: 'calibration' } as any).catch(() => undefined) + + expect(mockPassword).toHaveBeenCalledTimes(1) + }) + + it('still prompts when only a view address is supplied (read-only cannot run setup)', async () => { + mockPassword.mockResolvedValueOnce('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80') + + await runInteractiveSetup({ viewAddress: '0xreadonly', network: 'calibration' } as any).catch(() => undefined) + + expect(mockPassword).toHaveBeenCalledTimes(1) + }) + + it('still prompts when only a lone wallet-address is supplied (incomplete session key)', async () => { + mockPassword.mockResolvedValueOnce('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80') + + await runInteractiveSetup({ walletAddress: '0xowner', network: 'calibration' } as any).catch(() => undefined) + + expect(mockPassword).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/utils/cli-auth.ts b/src/utils/cli-auth.ts index 320792ad..fd1c46e1 100644 --- a/src/utils/cli-auth.ts +++ b/src/utils/cli-auth.ts @@ -7,10 +7,34 @@ import type { Chain, Synapse } from '@filoz/synapse-sdk' import { getRpcUrl, NETWORK_CHAINS, resolveDevnetConfig } from '../common/get-rpc-url.js' +import { getOwsAccount } from '../core/ows/index.js' import type { SynapseSetupConfig } from '../core/synapse/index.js' -import { initializeSynapse } from '../core/synapse/index.js' +import { createTransport, initializeSynapse } from '../core/synapse/index.js' +import { resolveChainFromRpc } from '../core/synapse/resolve-chain-from-rpc.js' import { createLogger } from '../logger.js' +/** + * Where a resolved auth option value came from. Mirrors Commander's + * `getOptionValueSource()`, narrowed to the two sources we distinguish for + * precedence: an explicit command-line flag versus an environment variable. + */ +export type AuthOptionSource = 'cli' | 'env' + +/** + * Per-option provenance for the mutually exclusive auth flags, keyed by the + * Commander attribute name. Populated by the `addAuthOptions` preAction hook + * (see `collectAuthOptionSources` in cli-options.ts) so `parseCLIAuth` can tell + * an explicit flag from an inherited env var. Absent for programmatic callers, + * which are treated as if every supplied value were an explicit flag. + */ +export interface AuthOptionSources { + privateKey?: AuthOptionSource + wallet?: AuthOptionSource + walletAddress?: AuthOptionSource + sessionKey?: AuthOptionSource + viewAddress?: AuthOptionSource +} + /** * Common CLI authentication options interface * Used across all commands that require authentication @@ -18,12 +42,21 @@ import { createLogger } from '../logger.js' export interface CLIAuthOptions { /** Private key for standard authentication */ privateKey?: string | undefined + /** OpenWallet Standard wallet name or ID (signs in-process, key stays in vault) */ + wallet?: string | undefined + /** Optional passphrase for an OWS-managed wallet */ + walletPassphrase?: string | undefined /** Wallet address for session key mode */ walletAddress?: string | undefined /** Session key private key */ sessionKey?: string | undefined /** View-only wallet address (no signing) */ viewAddress?: string | undefined + /** + * Provenance of the auth flags above, injected by the `addAuthOptions` + * preAction hook. Used only to resolve precedence; never forwarded to the SDK. + */ + optionSources?: AuthOptionSources | undefined /** Filecoin network: mainnet or calibration */ network?: string | undefined /** RPC endpoint URL (overrides network if specified) */ @@ -43,6 +76,62 @@ export interface CLIAuthOptions { dataSetIds?: string[] | undefined } +/** + * The mutually exclusive authentication modes, in precedence order. When more + * than one mode is supplied, resolution is by source, not by this order: an + * explicit flag always wins over an environment variable (see + * {@link resolveAuthMode}). This order only fixes how conflicts are reported and + * documents the canonical hierarchy. + * + * 1. `readOnly` - `--view-address` / `VIEW_ADDRESS` (query only, never signs) + * 2. `sessionKey` - `--wallet-address` + `--session-key` (delegated signer) + * 3. `ows` - `--wallet` / `OWS_WALLET_ID` (OWS-backed owner signer) + * 4. `privateKey` - `--private-key` / `PRIVATE_KEY` (raw-key owner signer) + * + * Devnet auto-resolves the devnet user's private key, but only when none of the + * above is supplied, so it is a fallback rather than a competing mode. + */ +type AuthMode = 'readOnly' | 'sessionKey' | 'ows' | 'privateKey' + +interface AuthModeCandidate { + mode: AuthMode + source: AuthOptionSource + /** Human-readable flag/env pair for conflict messages. */ + label: string +} + +/** + * Resolve which single auth mode to use from the modes that were supplied. + * + * Rules (see {@link AuthMode} for the mode list): + * - An explicit command-line flag always beats an environment variable, so a + * flag disambiguates against any env-sourced mode. + * - Two or more modes from explicit flags is a hard error (contradictory args). + * - With no explicit flag, two or more env-sourced modes is a hard error; the + * user must pass an explicit flag to disambiguate. + * - Exactly one supplied mode wins; none supplied returns `undefined` (caller + * applies the devnet fallback or lets initializeSynapse report missing auth). + * + * Programmatic callers that don't provide `optionSources` have every supplied + * value treated as an explicit flag, so any two modes still conflict. + */ +function resolveAuthMode(candidates: AuthModeCandidate[]): AuthMode | undefined { + const conflict = (modes: AuthModeCandidate[], envOnly: boolean): Error => { + const labels = modes.map((c) => c.label).join(' and ') + const hint = envOnly ? ' Pass an explicit flag to disambiguate.' : '' + return new Error(`Conflicting authentication options: ${labels}. Provide exactly one signing mode.${hint}`) + } + + const explicit = candidates.filter((c) => c.source === 'cli') + if (explicit.length > 1) throw conflict(explicit, false) + const [singleExplicit] = explicit + if (singleExplicit) return singleExplicit.mode + + // No explicit flag: every remaining candidate is env-sourced. + if (candidates.length > 1) throw conflict(candidates, true) + return candidates[0]?.mode +} + /** * Parse CLI authentication options into SynapseSetupConfig * @@ -54,18 +143,54 @@ export interface CLIAuthOptions { * @param options - CLI authentication options * @returns Synapse setup config (validation happens in initializeSynapse) */ -export function parseCLIAuth(options: CLIAuthOptions): SynapseSetupConfig { +export async function parseCLIAuth(options: CLIAuthOptions): Promise { const network = options.network?.toLowerCase().trim() const isDevnet = network === 'devnet' const hasRpcUrl = options.rpcUrl != null && options.rpcUrl !== '' - // For devnet, fall back to the devnet user's private key if none provided - const privateKey = options.privateKey || (isDevnet ? resolveDevnetConfig().privateKey : undefined) + // Env vars are bound to the Commander options via .env() (see cli-options.ts), + // so read everything from `options` rather than process.env here. + const owsWalletId = options.wallet + const owsPassphrase = options.walletPassphrase const walletAddress = options.walletAddress const sessionKey = options.sessionKey const viewAddress = options.viewAddress const rpcUrl = getRpcUrl(options) + const sources = options.optionSources + const nonEmpty = (value?: string): value is string => value != null && value !== '' + // Effective source of one option: its Commander provenance when known, + // otherwise treat a supplied value as an explicit flag (programmatic callers). + const sourceOf = (name: keyof AuthOptionSources, value?: string): AuthOptionSource | undefined => + nonEmpty(value) ? (sources?.[name] ?? 'cli') : undefined + // A mode spanning several options takes the strongest source among them: a + // single explicit flag makes the whole mode explicit. + const strongest = (...srcs: Array): AuthOptionSource | undefined => + srcs.includes('cli') ? 'cli' : srcs.includes('env') ? 'env' : undefined + + // Build the candidate list in canonical priority order (see AuthMode). + const candidates: AuthModeCandidate[] = [] + const readOnlySource = sourceOf('viewAddress', viewAddress) + if (readOnlySource) + candidates.push({ mode: 'readOnly', source: readOnlySource, label: '--view-address/VIEW_ADDRESS' }) + // Session-key mode competes for precedence only when BOTH halves are present. + // A lone --wallet-address or --session-key is a lowest-priority fallback + // (handled in the default branch), so it never outranks a complete mode like + // an OWS wallet or a private key. + const walletAddressSource = sourceOf('walletAddress', walletAddress) + const sessionKeySource = sourceOf('sessionKey', sessionKey) + const sessionSource = + walletAddressSource && sessionKeySource ? strongest(walletAddressSource, sessionKeySource) : undefined + if (sessionSource) + candidates.push({ mode: 'sessionKey', source: sessionSource, label: '--wallet-address/--session-key' }) + const owsSource = sourceOf('wallet', owsWalletId) + if (owsSource) candidates.push({ mode: 'ows', source: owsSource, label: '--wallet/OWS_WALLET_ID' }) + const privateKeySource = sourceOf('privateKey', options.privateKey) + if (privateKeySource) + candidates.push({ mode: 'privateKey', source: privateKeySource, label: '--private-key/PRIVATE_KEY' }) + + const mode = resolveAuthMode(candidates) + // --network and --rpc-url are mutually exclusive at the Commander level. Set the chain hint // only when --network was chosen; otherwise leave it undefined and let initializeSynapse probe // the RPC endpoint. When neither is supplied, default to mainnet. @@ -78,7 +203,9 @@ export function parseCLIAuth(options: CLIAuthOptions): SynapseSetupConfig { chain = NETWORK_CHAINS.mainnet } - // Build config incrementally; initializeSynapse() validates the final shape + // Build the config for the single resolved mode; initializeSynapse() validates + // the final shape. The OWS account (which lazily loads the native adapter and + // can fail on platforms without a prebuilt) is resolved only when its mode wins. const config: { privateKey?: string walletAddress?: string @@ -86,16 +213,53 @@ export function parseCLIAuth(options: CLIAuthOptions): SynapseSetupConfig { readOnly?: boolean rpcUrl?: string chain?: Chain + account?: Awaited> } = {} - if (privateKey) config.privateKey = privateKey - if (viewAddress) { - config.walletAddress = viewAddress - config.readOnly = true - } else if (walletAddress) { - config.walletAddress = walletAddress + switch (mode) { + case 'readOnly': + if (nonEmpty(viewAddress)) config.walletAddress = viewAddress + config.readOnly = true + break + case 'sessionKey': + // Both halves are present (that is what made this a competing candidate). + if (nonEmpty(walletAddress)) config.walletAddress = walletAddress + if (nonEmpty(sessionKey)) config.sessionKey = sessionKey + break + case 'ows': { + // The OWS adapter derives its CAIP-2 account hint (eip155:) from + // the chain. With --rpc-url the chain hint is intentionally left undefined + // (initializeSynapse probes the endpoint), so probe here too rather than + // guessing a network and requesting the wrong eip155 account. + const owsChain = chain ?? (rpcUrl ? await resolveChainFromRpc(createTransport(rpcUrl)) : NETWORK_CHAINS.mainnet) + const owsOptions: Parameters[0] = { + walletId: owsWalletId ?? '', + chain: owsChain, + } + // An empty OWS_WALLET_PASSPHRASE (common in CI env files) means "no + // passphrase", not a real passphrase value. + if (nonEmpty(owsPassphrase)) owsOptions.passphrase = owsPassphrase + config.account = await getOwsAccount(owsOptions) + break + } + case 'privateKey': + if (nonEmpty(options.privateKey)) config.privateKey = options.privateKey + break + default: { + // No complete auth mode won. Fallbacks, in priority order: devnet + // auto-key, then a lone session-key half passed through so + // initializeSynapse can emit its targeted "requires both" error. + const devnetKey = isDevnet ? resolveDevnetConfig().privateKey : undefined + if (nonEmpty(devnetKey)) { + config.privateKey = devnetKey + } else if (nonEmpty(walletAddress)) { + config.walletAddress = walletAddress + } else if (nonEmpty(sessionKey)) { + config.sessionKey = sessionKey + } + break + } } - if (sessionKey) config.sessionKey = sessionKey if (rpcUrl) config.rpcUrl = rpcUrl if (chain) config.chain = chain return config as SynapseSetupConfig @@ -249,7 +413,7 @@ export function getCLILogger() { } export async function getCliSynapse(options: CLIAuthOptions): Promise { - const authConfig = parseCLIAuth(options) + const authConfig = await parseCLIAuth(options) const logger = getCLILogger() return initializeSynapse(authConfig, logger) } diff --git a/src/utils/cli-options.ts b/src/utils/cli-options.ts index cfcd8faf..e5500068 100644 --- a/src/utils/cli-options.ts +++ b/src/utils/cli-options.ts @@ -9,8 +9,39 @@ import { parseUnits } from 'viem' import { MIN_RUNWAY_DAYS } from '../common/constants.js' import { normalizeNetworkName } from '../common/get-rpc-url.js' import { USDFC_DECIMALS } from '../core/payments/constants.js' +import type { AuthOptionSource, AuthOptionSources } from './cli-auth.js' import { log } from './cli-logger.js' +/** + * Commander attribute names for the mutually exclusive auth flags whose + * provenance {@link parseCLIAuth} needs to resolve precedence. + */ +const AUTH_OPTION_NAMES = ['privateKey', 'wallet', 'walletAddress', 'sessionKey', 'viewAddress'] as const + +/** + * Read each auth flag's provenance from a Commander command, narrowed to the + * `cli` (explicit flag) vs `env` (environment variable) distinction that + * precedence resolution cares about. Other sources (`default`, `config`, + * `implied`, unset) are omitted so `parseCLIAuth` treats them as absent. + * + * None of the auth flags declare a Commander `.default()`, which matters: + * parseCLIAuth's `sourceOf` treats a present value with no recorded source as an + * explicit flag. If a default is ever added to an auth option, its value would + * arrive with source `'default'` (dropped here) and then be misread as explicit, + * so record it as `'env'`-tier (or lower) precedence at that point rather than + * omitting it. + */ +export function collectAuthOptionSources(command: Command): AuthOptionSources { + const sources: AuthOptionSources = {} + for (const name of AUTH_OPTION_NAMES) { + const source = command.getOptionValueSource(name) + if (source === 'cli' || source === 'env') { + sources[name] = source as AuthOptionSource + } + } + return sources +} + /** * Option factories for flags declared on more than one command. Each pairs a * flag with its backing env var exactly once; only the description varies per @@ -77,11 +108,33 @@ export function addSigningAuthOptions(command: Command): Command { * ``` */ export function addAuthOptions(command: Command): Command { - addSigningAuthOptions(command).addOption( - new Option('--view-address
', 'View-only mode (no signing) for the specified wallet address').env( - 'VIEW_ADDRESS' + addSigningAuthOptions(command) + .addOption( + new Option( + '--wallet ', + 'OpenWallet Standard wallet name or ID (signs via OWS, key never leaves vault). Can also use OWS_WALLET_ID env.' + ).env('OWS_WALLET_ID') ) - ) + .addOption( + new Option( + '--wallet-passphrase ', + 'Passphrase for the OWS wallet (also OWS_WALLET_PASSPHRASE env)' + ).env('OWS_WALLET_PASSPHRASE') + ) + .addOption( + new Option('--view-address
', 'View-only mode (no signing) for the specified wallet address').env( + 'VIEW_ADDRESS' + ) + ) + + // Capture each auth flag's provenance (explicit flag vs env var) before the + // action runs, and stash it on the parsed options as `optionSources`. This is + // the only point where Commander's per-option source is available, so it lets + // parseCLIAuth() resolve precedence (explicit flag beats env) without every + // command action or runner having to thread the Command through. + command.hook('preAction', (_thisCommand, actionCommand) => { + actionCommand.setOptionValue('optionSources', collectAuthOptionSources(actionCommand)) + }) return addNetworkOptions(command).addOption( rpcUrlOption('RPC endpoint')