Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.3.2",
"@open-wallet-standard/core": "^1.3.2",
Comment thread
SgtPooki marked this conversation as resolved.
Outdated
"@sentry/node": "^10.49.0",
"commander": "^14.0.3",
"datastore-core": "^11.0.4",
Expand Down
70 changes: 70 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/add/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export async function runAdd(options: AddOptions): Promise<AddResult | AddDryRun
// Initialize Synapse SDK
spinner.start('Initializing Synapse SDK...')

const config = parseCLIAuth(options)
const config = await parseCLIAuth(options)
if (dataSetMetadata) {
config.dataSetMetadata = dataSetMetadata
}
Expand Down
84 changes: 84 additions & 0 deletions src/core/ows/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* OpenWallet Standard (OWS) integration for filecoin-pin
*
* Resolves an OWS-managed wallet into a viem `Account` that the Synapse SDK
* can sign with directly. Private keys never leave the OWS core; this module
* just hands Synapse a signing surface (signMessage / signTransaction /
* signTypedData) backed by the OWS adapter.
*
* The adapter is loaded via dynamic `import()` because
* `@open-wallet-standard/core` is a napi-rs native binding without prebuilt
* artifacts for Windows or musl. A static import would crash CLI startup on
* those platforms even when the user never asks for OWS auth.
*
* @module core/ows
*/

import type { Account, Chain } from 'viem'

export interface OwsAccountOptions {
/** Wallet name or ID registered with the `ows` CLI / OWS core */
walletId: string
/** Target Filecoin chain (used to derive CAIP-2 chain ID) */
chain: Chain
/** Optional passphrase for keystore-encrypted wallets */
passphrase?: string
/** Optional account index within the wallet (defaults to 0) */
index?: number
/** Optional override for OWS vault path */
vaultPath?: string
}

interface OwsViemAdapter {
owsToViemAccount: (
walletNameOrId: string,
options?: { chain?: string; passphrase?: string; index?: number; vaultPath?: string }
) => Account
}

async function loadAdapter(): Promise<OwsViemAdapter> {
try {
return (await import('@open-wallet-standard/adapters/viem')) as unknown as OwsViemAdapter
} catch (err) {
const reason = err instanceof Error ? err.message : String(err)
throw new Error(
'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.\n' +
`Underlying load error: ${reason}`
)
}
}

const EVM_ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/

/**
* 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<Account> {
const { owsToViemAccount } = await loadAdapter()
const chainId = `eip155:${options.chain.id}`
const adapterOptions: Parameters<typeof owsToViemAccount>[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`.'
)
}
return account
}
3 changes: 2 additions & 1 deletion src/core/synapse/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).'
)
}
Expand Down
2 changes: 1 addition & 1 deletion src/import/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export async function runCarImport(options: ImportOptions): Promise<ImportResult
// Initialize Synapse SDK
spinner.start('Initializing Synapse SDK...')

const config = parseCLIAuth(options)
const config = await parseCLIAuth(options)
if (dataSetMetadata) {
config.dataSetMetadata = dataSetMetadata
}
Expand Down
2 changes: 1 addition & 1 deletion src/payments/auto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export async function runAutoSetup(options: PaymentSetupOptions): Promise<void>

try {
// Parse and validate authentication
const authConfig = parseCLIAuth(options)
const authConfig = await parseCLIAuth(options)

const logger = getCLILogger()
const synapse = await initializeSynapse(authConfig, logger)
Expand Down
2 changes: 1 addition & 1 deletion src/payments/deposit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export async function runDeposit(options: DepositOptions): Promise<void> {
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)
Expand Down
2 changes: 1 addition & 1 deletion src/payments/fund.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export async function runFund(options: FundOptions): Promise<void> {
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)
Expand Down
2 changes: 1 addition & 1 deletion src/payments/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,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)
Expand Down
2 changes: 1 addition & 1 deletion src/payments/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export async function showPaymentStatus(options: StatusOptions): Promise<void> {
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
Expand Down
2 changes: 1 addition & 1 deletion src/payments/withdraw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export async function runWithdraw(options: WithdrawOptions): Promise<void> {
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)
Expand Down
2 changes: 1 addition & 1 deletion src/rm/remove-all-pieces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export async function runRmAllPieces(options: RmAllPiecesOptions): Promise<RmAll
try {
spinner.start('Initializing Synapse SDK...')

const authConfig = parseCLIAuth(options)
const authConfig = await parseCLIAuth(options)
const synapse = await initializeSynapse(authConfig, logger)
const network = synapse.chain.name

Expand Down
2 changes: 1 addition & 1 deletion src/rm/remove-piece.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export async function runRmPiece(options: RmPieceOptions): Promise<RmPieceResult
try {
spinner.start('Initializing Synapse SDK...')

const authConfig = parseCLIAuth(options)
const authConfig = await parseCLIAuth(options)
const synapse = await initializeSynapse(authConfig, logger)
const network = synapse.chain.name

Expand Down
57 changes: 49 additions & 8 deletions src/utils/cli-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@

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'

/**
Expand All @@ -18,6 +20,10 @@ 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 */
Expand Down Expand Up @@ -54,13 +60,21 @@ 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<SynapseSetupConfig> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

continuing on my precedence suggestion below, the complexity in here now deserves a bunch of tests to show it does what you expect, and document what you expect too

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

preferences should be much more clear now

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

no more need for async in here, you can drop that and all of the await additions to make the diff shrink significantly

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

// For devnet, fall back to the devnet user's private key if none provided.
// OWS wallets take precedence over PRIVATE_KEY when explicitly supplied.
const privateKey = owsWalletId
? undefined
: options.privateKey || (isDevnet ? resolveDevnetConfig().privateKey : undefined)
const walletAddress = options.walletAddress
const sessionKey = options.sessionKey
const viewAddress = options.viewAddress
Expand All @@ -78,24 +92,51 @@ export function parseCLIAuth(options: CLIAuthOptions): SynapseSetupConfig {
chain = NETWORK_CHAINS.mainnet
}

// Build config incrementally; initializeSynapse() validates the final shape
// Resolve a single auth mode; initializeSynapse() validates the final shape.
// Precedence mirrors initializeSynapse: read-only, then session key, then an
// owner signer (OWS, then private key). View-only and session-key modes never
// use the owner account, so the OWS account (which lazily loads the native
// adapter and can fail on platforms without a prebuilt) is resolved only when
// an owner signer is actually needed.
const config: {
privateKey?: string
walletAddress?: string
sessionKey?: string
readOnly?: boolean
rpcUrl?: string
chain?: Chain
account?: Awaited<ReturnType<typeof getOwsAccount>>
} = {}

if (privateKey) config.privateKey = privateKey
if (viewAddress) {
config.walletAddress = viewAddress
config.readOnly = true
} else if (walletAddress && sessionKey) {
config.walletAddress = walletAddress
config.sessionKey = sessionKey
} else if (owsWalletId) {
Comment thread
SgtPooki marked this conversation as resolved.
Outdated
// The OWS adapter derives its CAIP-2 account hint (eip155:<chainId>) 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<typeof getOwsAccount>[0] = {
walletId: owsWalletId,
chain: owsChain,
}
// An empty OWS_WALLET_PASSPHRASE (common in CI env files) means "no
// passphrase", not a real passphrase value.
if (owsPassphrase != null && owsPassphrase !== '') owsOptions.passphrase = owsPassphrase
config.account = await getOwsAccount(owsOptions)
} else if (privateKey) {
config.privateKey = privateKey
} else if (walletAddress) {
// Only one half of session-key auth supplied; pass it through so
// initializeSynapse can emit its targeted "requires both" error.
config.walletAddress = walletAddress
} else if (sessionKey) {
config.sessionKey = sessionKey
}
if (sessionKey) config.sessionKey = sessionKey
if (rpcUrl) config.rpcUrl = rpcUrl
if (chain) config.chain = chain
return config as SynapseSetupConfig
Expand Down Expand Up @@ -249,7 +290,7 @@ export function getCLILogger() {
}

export async function getCliSynapse(options: CLIAuthOptions): Promise<Synapse> {
const authConfig = parseCLIAuth(options)
const authConfig = await parseCLIAuth(options)
const logger = getCLILogger()
return initializeSynapse(authConfig, logger)
}
Loading
Loading