Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
16 changes: 13 additions & 3 deletions src/add/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,24 @@ export async function runAdd(options: AddOptions): Promise<AddResult> {
// Initialize Synapse SDK (without storage context)
spinner.start('Initializing Synapse SDK...')

if (!options.privateKey) {
spinner.stop(`${pc.red('✗')} Private key required via --private-key or PRIVATE_KEY env`)
cancel('Add cancelled')
// Check for session key auth (env vars only for now)
const walletAddress = process.env.WALLET_ADDRESS
const sessionKey = process.env.SESSION_KEY

// Validate authentication (either standard or session key mode)
const hasStandardAuth = options.privateKey != null
const hasSessionKeyAuth = walletAddress != null && sessionKey != null

if (!hasStandardAuth && !hasSessionKeyAuth) {

Choose a reason for hiding this comment

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

this is duplicated in validateAuthConfig

Copy link
Member Author

Choose a reason for hiding this comment

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

yes, I know, it only exists to deal with TypeScript complaints, original version didn't have it, then linter made me make it jankier, there's probably a better way of dealing with this but this was my expedient version

@SgtPooki if you're going to be refactoring the typeguard for that function then maybe this can be solved at the same time?

spinner.stop(`${pc.red('✗')} Authentication required`)
cancel('Provide either PRIVATE_KEY or both WALLET_ADDRESS + SESSION_KEY env vars')
process.exit(1)
}

const config = {
privateKey: options.privateKey,
walletAddress,
sessionKey,
rpcUrl: options.rpcUrl || RPC_URLS.calibration.websocket,
// Other config fields not needed for add
port: 0,
Expand Down
2 changes: 1 addition & 1 deletion src/core/payments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export async function checkFILBalance(synapse: Synapse): Promise<{

try {
const provider = synapse.getProvider()
const signer = synapse.getSigner()
const signer = synapse.getClient() // owner wallet

Choose a reason for hiding this comment

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

can rename this to user or client

const address = await signer.getAddress()

// Get native token balance
Expand Down
41 changes: 41 additions & 0 deletions src/core/synapse/address-only-signer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Address-only signer for session key authentication
*
* This signer provides an address but throws on any signing operations.
* Used as the "owner" signer when authenticating with session keys,
* where the actual signing is done by the session key wallet.
*/

import { AbstractSigner, type Provider, type TransactionRequest } from 'ethers'

const cannotSign = (thing: string) =>
`Cannot sign ${thing} - this is an address-only signer for session key authentication. Signing operations should be performed by the session key.`

export class AddressOnlySigner extends AbstractSigner {
readonly address: string

Choose a reason for hiding this comment

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

surprising that ethers doesn't already have something like this

Copy link
Member Author

Choose a reason for hiding this comment

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

Hugo promises me that Viem solves this for us so we can separate read and write wallets so I'm hopeful that this file is temporary until we get those changes into Synapse.


constructor(address: string, provider?: Provider) {
super(provider)
this.address = address
}

async getAddress(): Promise<string> {
return this.address
}

connect(provider: Provider): AddressOnlySigner {
return new AddressOnlySigner(this.address, provider)
}

async signTransaction(_tx: TransactionRequest): Promise<string> {
throw new Error(cannotSign('transaction'))
}

async signMessage(_message: string | Uint8Array): Promise<string> {
throw new Error(cannotSign('message'))
}

async signTypedData(_domain: any, _types: Record<string, any[]>, _value: Record<string, any>): Promise<string> {
throw new Error(cannotSign('typed data'))
}
}
174 changes: 110 additions & 64 deletions src/core/synapse/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
ADD_PIECES_TYPEHASH,
CREATE_DATA_SET_TYPEHASH,
METADATA_KEYS,
type ProviderInfo,
RPC_URLS,
Expand All @@ -8,7 +10,9 @@ import {
Synapse,
type SynapseOptions,
} from '@filoz/synapse-sdk'
import { type Provider as EthersProvider, JsonRpcProvider, Wallet, WebSocketProvider } from 'ethers'
import type { Logger } from 'pino'
import { AddressOnlySigner } from './address-only-signer.js'

const WEBSOCKET_REGEX = /^ws(s)?:\/\//i

Expand Down Expand Up @@ -51,13 +55,22 @@ export interface Config {

/**
* Configuration for Synapse initialization
* Extends the main Config but makes privateKey required and rpcUrl optional
*
* Supports two authentication modes:
* 1. Standard: privateKey only
* 2. Session Key: walletAddress + sessionKey
*/
export interface SynapseSetupConfig extends Partial<Omit<Config, 'privateKey' | 'rpcUrl'>> {
/** Private key used for signing transactions. */
privateKey: string
export interface SynapseSetupConfig {
/** Private key for standard authentication (mutually exclusive with session key mode) */
privateKey?: string | undefined
/** Wallet address for session key mode (requires sessionKey) */
walletAddress?: string | undefined
/** Session key private key (requires walletAddress) */
sessionKey?: string | undefined
/** RPC endpoint for the target Filecoin network. Defaults to calibration. */
rpcUrl?: string | undefined
/** Optional override for WarmStorage contract address */
warmStorageAddress?: string | undefined
}

/**
Expand Down Expand Up @@ -136,89 +149,122 @@ export function resetSynapseService(): void {
activeProvider = null
}

/**
* Validate authentication configuration
*/
function validateAuthConfig(config: SynapseSetupConfig): 'standard' | 'session-key' {
const hasStandardAuth = config.privateKey != null
const hasSessionKeyAuth = config.walletAddress != null && config.sessionKey != null

if (!hasStandardAuth && !hasSessionKeyAuth) {
throw new Error('Authentication required: provide either a privateKey or walletAddress + sessionKey')
}

if (hasStandardAuth && hasSessionKeyAuth) {
throw new Error('Conflicting authentication: provide either a privateKey or walletAddress + sessionKey, not both')
}

return hasStandardAuth ? 'standard' : 'session-key'
}
Comment on lines +155 to +168
Copy link
Collaborator

Choose a reason for hiding this comment

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

we should use a typeguard and two strict types (descriminating union). maybe its mostly personal preference, but resolving optional types everywhere has always been a pain for me, but it should also prevent invalid permutations of a wide/loose interface. It also gives us better type validation at build time rather than runtime, makes refactoring/extending easier.. etc..

export type SynapseSetupConfig = SynapseSetupConfigPrivKey | SynapseSetupConfigSession

function isValidConfig(config: SynapseSetupConfig): config is SynapseSetupConfig { /* ... */ }

function isValidSessionConfig(config: SynapseSetupConfig): config is SynapseSetupConfigSession

function isValidPrivKeyConfig(config: SynapseSetupConfig): config is SynapseSetupConfigPrivKey

Copy link
Member Author

Choose a reason for hiding this comment

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

yeah, go for it, or just a boolean


/**
* Create ethers provider for the given RPC URL
*/
function createProvider(rpcURL: string): EthersProvider {
if (WEBSOCKET_REGEX.test(rpcURL)) {
return new WebSocketProvider(rpcURL)
}
return new JsonRpcProvider(rpcURL)
}

/**
* Setup and verify session key, throws if expired
*/
async function setupSessionKey(synapse: Synapse, sessionWallet: Wallet, logger: Logger): Promise<void> {
const sessionKey = synapse.createSessionKey(sessionWallet)

// Verify permissions - fail fast if expired or expiring soon
const expiries = await sessionKey.fetchExpiries([CREATE_DATA_SET_TYPEHASH, ADD_PIECES_TYPEHASH])
const now = Math.floor(Date.now() / 1000)
const bufferTime = 30 * 60 // 30 minutes in seconds
Copy link
Collaborator

Choose a reason for hiding this comment

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

30min is probably good for now, but do we need 30 minutes validity for the future? most operations take < 10min

Copy link
Member Author

Choose a reason for hiding this comment

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

I started this with 5 minutes, but then I thought that when we start dealing with larger files we're doing this check potentially very long before we even get to submit something to the chain, so let's leave a big buffer

const minValidTime = now + bufferTime
const createExpiry = Number(expiries[CREATE_DATA_SET_TYPEHASH])
const addExpiry = Number(expiries[ADD_PIECES_TYPEHASH])

if (createExpiry <= minValidTime || addExpiry <= minValidTime) {
throw new Error(
`Session key expired or expiring soon (requires 30+ minutes validity). CreateDataSet: ${new Date(createExpiry * 1000).toISOString()}, AddPieces: ${new Date(addExpiry * 1000).toISOString()}`
)
}

logger.info({ event: 'synapse.session_key.verified', createExpiry, addExpiry }, 'Session key verified')

synapse.setSession(sessionKey)
logger.info({ event: 'synapse.session_key.activated' }, 'Session key activated')
}

/**
* Initialize the Synapse SDK without creating storage context
*
* This function initializes the Synapse SDK connection without creating
* a storage context. This method is primarily a wrapper for handling our
* custom configuration needs and adding detailed logging.
* Supports two authentication modes:
* - Standard: privateKey only
* - Session Key: walletAddress + sessionKey
*
* @param config - Application configuration with privateKey and RPC URL
* @param config - Application configuration with authentication credentials
* @param logger - Logger instance for detailed operation tracking
* @returns Initialized Synapse instance
*/
export async function initializeSynapse(config: SynapseSetupConfig, logger: Logger): Promise<Synapse> {
try {
// Log the configuration status
logger.info(
{
hasPrivateKey: config.privateKey != null,
rpcUrl: config.rpcUrl,
},
'Initializing Synapse'
)
const authMode = validateAuthConfig(config)
const rpcURL = config.rpcUrl ?? RPC_URLS.calibration.websocket

// IMPORTANT: Private key is required for transaction signing
// In production, this should come from secure environment variables, or a wallet integration
const privateKey = config.privateKey
if (privateKey == null) {
const error = new Error('PRIVATE_KEY environment variable is required for Synapse integration')
logger.error(
{
event: 'synapse.init.failed',
error: error.message,
},
'Synapse initialization failed: missing PRIVATE_KEY'
)
throw error
}

logger.info({ event: 'synapse.init' }, 'Initializing Synapse SDK')

// Configure Synapse with network settings
// Network options: 314 (mainnet) or 314159 (calibration testnet)
const synapseOptions: SynapseOptions = {
privateKey,
rpcURL: config.rpcUrl ?? RPC_URLS.calibration.websocket, // Default to calibration testnet
}
logger.info({ event: 'synapse.init', authMode, rpcUrl: rpcURL }, 'Initializing Synapse SDK')

// Optional: Override the default Warm Storage contract address
// Useful for testing with custom deployments
if (config.warmStorageAddress != null) {
const synapseOptions: SynapseOptions = { rpcURL }
if (config.warmStorageAddress) {
synapseOptions.warmStorageAddress = config.warmStorageAddress
}

const synapse = await Synapse.create(synapseOptions)

// Store reference to the provider for cleanup if it's a WebSocket provider
if (synapseOptions.rpcURL && WEBSOCKET_REGEX.test(synapseOptions.rpcURL)) {
let synapse: Synapse

if (authMode === 'session-key') {
// Session key mode - validation guarantees these are defined
const walletAddress = config.walletAddress
const sessionKey = config.sessionKey
if (!walletAddress || !sessionKey) {
throw new Error('Internal error: session key config validated but values missing')
}

// Create provider and signers for session key mode
const provider = createProvider(rpcURL)
activeProvider = provider

const ownerSigner = new AddressOnlySigner(walletAddress, provider)
const sessionWallet = new Wallet(sessionKey, provider)

// Initialize with owner signer, then activate session key
synapse = await Synapse.create({ ...synapseOptions, signer: ownerSigner })

Choose a reason for hiding this comment

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

This will fail if this app does any payments stuff. Does it?

Copy link
Member Author

Choose a reason for hiding this comment

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

if you have a properly funded wallet then it should be fine, it's one of the reasons I didn't add any documentation or CLI args for this feature because I didn't want to have to deal with the complexity of that - it should only be used by wallets that have enough funds and are authorised at the right level

@SgtPooki added some help in #110 to make that problem a little less annoying, but now I think we're at the problem of avoiding the checks and ending up with reverts if we really don't have a set up wallet.

await setupSessionKey(synapse, sessionWallet, logger)
} else {
// Standard mode - validation guarantees privateKey is defined
const privateKey = config.privateKey
if (!privateKey) {
throw new Error('Internal error: standard auth validated but privateKey missing')
}

synapse = await Synapse.create({ ...synapseOptions, privateKey })
activeProvider = synapse.getProvider()
}

// Get network info for logging
const network = synapse.getNetwork()
logger.info(
{
event: 'synapse.init',
network,
rpcUrl: synapseOptions.rpcURL,
},
'Synapse SDK initialized'
)
logger.info({ event: 'synapse.init.success', network }, 'Synapse SDK initialized')
Copy link
Collaborator

Choose a reason for hiding this comment

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

this is overall much cleaner than previous..


// Store instance for cleanup
synapseInstance = synapse

return synapse
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logger.error(
{
event: 'synapse.init.failed',
error: errorMessage,
},
`Failed to initialize Synapse SDK: ${errorMessage}`
)
logger.error({ event: 'synapse.init.failed', error: errorMessage }, 'Failed to initialize Synapse SDK')
throw error
}
}
Expand Down
5 changes: 1 addition & 4 deletions src/core/unixfs/browser-car-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { unixfs } from '@helia/unixfs'
import { CarWriter } from '@ipld/car'
import { CarReader, CarWriter } from '@ipld/car'
import { CID } from 'multiformats/cid'
import { CARWritingBlockstore } from '../car/browser-car-blockstore.js'

Expand Down Expand Up @@ -251,9 +251,6 @@ async function updateRootCidInCar(carBytes: Uint8Array, rootCid: CID): Promise<U
// We need to replace the placeholder CID with the actual root CID
// The easiest way is to re-read the CAR and write a new one with the correct root

// Import CarReader to read the existing CAR
const { CarReader } = await import('@ipld/car')
Copy link
Collaborator

Choose a reason for hiding this comment

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

oops


const reader = await CarReader.fromBytes(carBytes)

// Create new CAR writer with correct root
Expand Down
16 changes: 13 additions & 3 deletions src/import/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,14 +165,24 @@ export async function runCarImport(options: ImportOptions): Promise<ImportResult
// Step 4: Initialize Synapse SDK (without storage context)
spinner.start('Initializing Synapse SDK...')

if (!options.privateKey) {
spinner.stop(`${pc.red('✗')} Private key required via --private-key or PRIVATE_KEY env`)
cancel('Import cancelled')
// Check for session key auth (env vars only for now)
const walletAddress = process.env.WALLET_ADDRESS
const sessionKey = process.env.SESSION_KEY

// Validate authentication (either standard or session key mode)
const hasStandardAuth = options.privateKey != null
const hasSessionKeyAuth = walletAddress != null && sessionKey != null

if (!hasStandardAuth && !hasSessionKeyAuth) {

Choose a reason for hiding this comment

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

this also duplicates the validation logic

Copy link
Member Author

Choose a reason for hiding this comment

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

same as above, I hate it, but not enough to wrestle with it

spinner.stop(`${pc.red('✗')} Authentication required`)
cancel('Provide either PRIVATE_KEY or both WALLET_ADDRESS + SESSION_KEY env vars')
process.exit(1)
}

const config = {
privateKey: options.privateKey,
walletAddress,
sessionKey,
rpcUrl: options.rpcUrl || RPC_URLS.calibration.websocket,
// Other config fields not needed for import
port: 0,
Expand Down
3 changes: 1 addition & 2 deletions src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { type Logger, pino } from 'pino'
import type { SynapseSetupConfig } from './core/synapse/index.js'

export function createLogger(config: Pick<SynapseSetupConfig, 'logLevel'>): Logger {
export function createLogger(config: { logLevel?: string }): Logger {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe this should be logLevel?: PinoLogLevels if they provide a logLevel type

return pino({
level: config.logLevel ?? 'info',
})
Expand Down
2 changes: 1 addition & 1 deletion src/test/unit/dataset-management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe('Dataset Management', () => {
privateKey: '0x0000000000000000000000000000000000000000000000000000000000000001',
rpcUrl: 'wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1',
}
logger = createLogger(config)
logger = createLogger({ logLevel: 'info' })
resetSynapseService()
vi.clearAllMocks()
})
Expand Down
4 changes: 3 additions & 1 deletion src/test/unit/import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,9 @@ describe('CAR Import', () => {
}

await expect(runCarImport(options)).rejects.toThrow('process.exit called')
expect(consoleMocks.error).toHaveBeenCalledWith('Import cancelled')
expect(consoleMocks.error).toHaveBeenCalledWith(
'Provide either PRIVATE_KEY or both WALLET_ADDRESS + SESSION_KEY env vars'
)
})

it('should use custom RPC URL if provided', async () => {
Expand Down
1 change: 1 addition & 0 deletions src/test/unit/payments-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ describe('Payment Setup Tests', () => {
mockSynapse = {
getProvider: vi.fn().mockReturnValue(mockProvider),
getSigner: vi.fn().mockReturnValue(mockSigner),
getClient: vi.fn().mockReturnValue(mockSigner),
getNetwork: vi.fn().mockReturnValue('calibration'),
getPaymentsAddress: vi.fn().mockReturnValue('0xpayments'),
getWarmStorageAddress: vi.fn().mockReturnValue('0xwarmstorage'),
Expand Down
Loading