-
Notifications
You must be signed in to change notification settings - Fork 20
feat(auth): add OpenWallet Standard wallet support #458
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 5 commits
698b2d2
c4e35e2
6f63e05
298092a
b8570f3
be0c773
1dbb700
829eff8
5b823ec
1953883
9b35c89
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,8 +7,9 @@ | |
|
|
||
| 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 { calibration, initializeSynapse } from '../core/synapse/index.js' | ||
| import { createLogger } from '../logger.js' | ||
|
|
||
| /** | ||
|
|
@@ -18,6 +19,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 */ | ||
|
|
@@ -54,13 +59,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> { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. preferences should be much more clear now |
||
| 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 | ||
|
|
@@ -78,24 +91,44 @@ 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) { | ||
|
SgtPooki marked this conversation as resolved.
Outdated
|
||
| const owsOptions: Parameters<typeof getOwsAccount>[0] = { | ||
| walletId: owsWalletId, | ||
| chain: chain ?? calibration, | ||
| } | ||
|
SgtPooki marked this conversation as resolved.
Outdated
|
||
| if (owsPassphrase != null) owsOptions.passphrase = owsPassphrase | ||
| config.account = await getOwsAccount(owsOptions) | ||
|
SgtPooki marked this conversation as resolved.
Outdated
|
||
| } 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 | ||
|
|
@@ -249,7 +282,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) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.