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
18 changes: 11 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Bridges IPFS content to Filecoin storage providers with cryptographic guarantees

**Stack**: filecoin-pin → synapse-sdk → FOC contracts (FWSS, FilecoinPay, PDPVerifier, SPRegistry) + Curio.

**Status**: Calibration testnet only. Not production-ready.
**Status**: Supports Mainnet, Calibration testnet, and local devnet (foc-devnet). CLI defaults to Calibration.

## Design Philosophy

Expand All @@ -25,10 +25,12 @@ Bridges IPFS content to Filecoin storage providers with cryptographic guarantees
```
src/
├── cli.ts, server.ts # Commander.js CLI + Fastify server
├── add/, data-set/ # Command implementations
├── add/, import/, data-set/ # Command implementations
├── core/ # Published library (see package.json exports)
│ ├── car/ # CAR file handling (CARv1 streaming)
│ ├── payments/ # Payment setup/status
│ ├── metadata/ # Metadata normalization
│ ├── piece/ # Piece status queries
│ ├── synapse/ # SDK initialization patterns
│ ├── upload/ # Upload workflows
│ ├── unixfs/ # Helia integration, browser/node variants
Expand All @@ -50,13 +52,13 @@ src/

## Key Patterns

**Synapse SDK**: Initialize with callbacks (onProviderSelected, onDataSetResolved, onPieceAdded), upload returns {pieceCid, pieceId, provider}. See `src/core/synapse/index.ts`, `src/core/upload/synapse.ts`.
**Synapse SDK**: Initialize via `initializeSynapse()` in `src/core/synapse/index.ts`. Upload via `executeUpload()` in `src/core/upload/index.ts` with progress events (`onStored`, `onPullProgress`, `onCopyComplete`, `onPiecesAdded`, `onPiecesConfirmed`). Returns `{pieceCid, size, copies, failures}`.

**CAR files**: CARv1 streaming, handle 3 root cases (single/multiple/none), use zero CID for no roots. See `src/core/car/car-blockstore.ts`.

**UnixFS**: Helia for directory imports, chunking, CID calculation. See `src/core/unixfs/`.

**Payments**: `checkPaymentStatus()`, `setupPayments()` in `src/core/payments/index.ts`.
**Payments**: `getPaymentStatus()`, `setMaxAllowances()`, `validatePaymentCapacity()` in `src/core/payments/index.ts`.

## Biome Linting (Critical)

Expand Down Expand Up @@ -84,11 +86,13 @@ src/

## CLI & Environment

**Commands**: `payments setup --auto`, `add <file>`, `payments status`, `data-set <id>`, `server`
**Commands**: `payments setup --auto`, `add <path>`, `import <car-file>`, `payments status`, `data-set <id>`, `server`

**Required env**: `PRIVATE_KEY=0x...` (with USDFC tokens)
**Network**: `--network mainnet|calibration|devnet` (default: `calibration`). Devnet reads config from foc-devnet's `devnet-info.json` and auto-resolves private key and RPC URL.

**Optional**: `RPC_URL` (default: Calibration), `PORT`, `HOST`, `DATABASE_PATH`, `CAR_STORAGE_PATH`, `LOG_LEVEL`
**Required env**: `PRIVATE_KEY=0x...` (with USDFC tokens; not needed for devnet)

**Optional**: `NETWORK`, `RPC_URL`, `FOC_DEVNET_BASEDIR`, `DEVNET_INFO_PATH`, `DEVNET_USER_INDEX`, `PORT`, `HOST`, `DATABASE_PATH`, `CAR_STORAGE_PATH`, `LOG_LEVEL`

**Default data dirs for pinning server**: Linux `~/.local/share/filecoin-pin/`, macOS `~/Library/Application Support/filecoin-pin/`, Windows `%APPDATA%/filecoin-pin/`

Expand Down
30 changes: 25 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ The Pinning Server requires the use of environment variables, as detailed below.

### Network Selection

Filecoin Pin supports both **Mainnet** and **Calibration testnet**. By default, the CLI uses Calibration testnet during development.
Filecoin Pin supports **Mainnet**, **Calibration testnet**, and local **devnet** networks. By default, the CLI uses Calibration testnet.

**Using the CLI:**
```bash
Expand All @@ -177,6 +177,9 @@ filecoin-pin add myfile.txt --network mainnet

# Explicitly specify Calibration
filecoin-pin add myfile.txt --network calibration

# Use a local foc-devnet (reads config from devnet-info.json, details below)
filecoin-pin add myfile.txt --network devnet
```

**Using environment variables:**
Expand All @@ -196,13 +199,30 @@ filecoin-pin add myfile.txt
3. `--network` flag or `NETWORK` environment variable
4. Default to Calibration testnet

### Local Development with foc-devnet

When using `--network devnet`, Filecoin Pin reads connection details from a running [foc-devnet](https://github.com/filecoin-project/foc-devnet) instance:

- **Private key**: Automatically resolved from `devnet-info.json` (no `PRIVATE_KEY` needed)
- **RPC URL**: Read from the devnet chain configuration
- **Contract addresses**: Resolved from the devnet chain definition
- **IPNI verification**: Automatically skipped (no IPNI infrastructure on devnet)

**Environment variables for devnet:**

| Variable | Description | Default |
|----------|-------------|---------|
| `FOC_DEVNET_BASEDIR` | Override the foc-devnet base directory | `~/.foc-devnet` |
| `DEVNET_INFO_PATH` | Explicit path to `devnet-info.json` (overrides basedir) | `<basedir>/state/latest/devnet-info.json` |
| `DEVNET_USER_INDEX` | Which user from `devnet-info.json` to use | `0` |

### Common CLI Arguments

* `-h`, `--help`: Display help information for each command
* `-V`, `--version`: Output the version number
* `-v`, `--verbose`: Verbose output
* `--private-key`: Ethereum-style (`0x`) private key, funded with USDFC (required)
* `--network`: Filecoin network to use: `mainnet` or `calibration` (default: `calibration`)
* `--network`: Filecoin network to use: `mainnet`, `calibration`, or `devnet` (default: `calibration`)
* `--rpc-url`: Filecoin RPC endpoint (overrides `--network` if specified)

Other arguments are possible for individual commands, use `--help` to find out more.
Expand All @@ -214,10 +234,10 @@ Other arguments are possible for individual commands, use `--help` to find out m
PRIVATE_KEY=0x... # Ethereum private key with USDFC tokens

# Optional - Network Configuration
NETWORK=mainnet # Network to use: mainnet or calibration (default: calibration)
NETWORK=mainnet # Network to use: mainnet, calibration, or devnet (default: calibration)
RPC_URL=wss://... # Filecoin RPC endpoint (overrides NETWORK if specified)
# Mainnet: wss://wss.node.glif.io/apigw/lotus/rpc/v1
# Calibration: wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1
# Mainnet: wss://wss.node.glif.io/apigw/lotus/rpc/v1
# Calibration: wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1

# Optional for Pinning Server Daemon
PORT=3456 # Daemon server port
Expand Down
9 changes: 8 additions & 1 deletion src/add/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import { readFile, stat } from 'node:fs/promises'
import pc from 'picocolors'
import pino from 'pino'
import { warnAboutCDNPricingLimitations } from '../common/cdn-warning.js'
import { DEVNET_CHAIN_ID } from '../common/get-rpc-url.js'
import { displayUploadResults, performAutoFunding, performUpload, validatePaymentSetup } from '../common/upload-flow.js'
import { normalizeMetadataConfig } from '../core/metadata/index.js'
import { initializeSynapse } from '../core/synapse/index.js'
import { cleanupTempCar, createCarFromPath } from '../core/unixfs/index.js'
import { getNetworkSlug } from '../core/upload/index.js'
import { parseCLIAuth, parseContextSelectionOptions } from '../utils/cli-auth.js'
import { cancel, createSpinner, formatFileSize, intro, outro } from '../utils/cli-helpers.js'
import { log } from '../utils/cli-logger.js'
Expand Down Expand Up @@ -123,6 +125,7 @@ export async function runAdd(options: AddOptions): Promise<AddResult> {
if (withCDN) config.withCDN = true

const synapse = await initializeSynapse(config, logger)
const networkSlug = getNetworkSlug(synapse.chain)
const network = synapse.chain.name

spinner.stop(`${pc.green('✓')} Connected to ${pc.bold(network)}`)
Expand Down Expand Up @@ -164,11 +167,15 @@ export async function runAdd(options: AddOptions): Promise<AddResult> {
await validatePaymentSetup(synapse, carSize, spinner)
}

// Auto-skip IPNI on devnet (no IPNI infrastructure available)
const skipIpniVerification = options.skipIpniVerification || synapse.chain.id === DEVNET_CHAIN_ID

const uploadOptions: Parameters<typeof performUpload>[3] = {
contextType: 'add',
fileSize: carSize,
logger,
spinner,
skipIpniVerification,
...(pieceMetadata && { pieceMetadata }),
...(dataSetMetadata && { metadata: dataSetMetadata }),
...(options.count != null && { count: options.count }),
Expand Down Expand Up @@ -200,7 +207,7 @@ export async function runAdd(options: AddOptions): Promise<AddResult> {
failures: uploadResult.failures,
}

displayUploadResults(result, 'Add', network)
displayUploadResults(result, 'Add', network, networkSlug)

if (uploadResult.copies.length < requestedCopies) {
log.line('')
Expand Down
2 changes: 2 additions & 0 deletions src/add/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface AddOptions extends CLIAuthOptions {
pieceMetadata?: Record<string, string>
/** Data set metadata applied when creating or updating the storage context */
dataSetMetadata?: Record<string, string>
/** Skip IPNI advertisement verification after upload */
skipIpniVerification?: boolean
}

export interface AddResult {
Expand Down
3 changes: 2 additions & 1 deletion src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Command } from 'commander'
import { runAdd } from '../add/add.js'
import type { AddOptions } from '../add/types.js'
import { MIN_RUNWAY_DAYS } from '../common/constants.js'
import { addAuthOptions, addContextSelectionOptions } from '../utils/cli-options.js'
import { addAuthOptions, addContextSelectionOptions, addUploadOptions } from '../utils/cli-options.js'
import { addMetadataOptions, resolveMetadataOptions } from '../utils/cli-options-metadata.js'

export const addCommand = new Command('add')
Expand Down Expand Up @@ -39,4 +39,5 @@ addCommand.action(async (path: string, options: any) => {

addAuthOptions(addCommand)
addContextSelectionOptions(addCommand)
addUploadOptions(addCommand)
addMetadataOptions(addCommand, { includePieceMetadata: true, includeDataSetMetadata: true, includeErc8004: true })
3 changes: 2 additions & 1 deletion src/commands/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Command } from 'commander'
import { MIN_RUNWAY_DAYS } from '../common/constants.js'
import { runCarImport } from '../import/import.js'
import type { ImportOptions } from '../import/types.js'
import { addAuthOptions, addContextSelectionOptions } from '../utils/cli-options.js'
import { addAuthOptions, addContextSelectionOptions, addUploadOptions } from '../utils/cli-options.js'
import { addMetadataOptions, resolveMetadataOptions } from '../utils/cli-options-metadata.js'

export const importCommand = new Command('import')
Expand Down Expand Up @@ -37,4 +37,5 @@ export const importCommand = new Command('import')

addAuthOptions(importCommand)
addContextSelectionOptions(importCommand)
addUploadOptions(importCommand)
addMetadataOptions(importCommand, { includePieceMetadata: true, includeDataSetMetadata: true, includeErc8004: true })
6 changes: 6 additions & 0 deletions src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@
* Minimum runway in days to ensure WarmStorage can cover costs. Used when `--auto-fund` is passed to import or add commands
*/
export const MIN_RUNWAY_DAYS = 30

/**
* Chain ID for foc-devnet local Filecoin networks.
* Defined by the devnet's genesis configuration (matching synapse-core's toChain output).
*/
export const DEVNET_CHAIN_ID = 31415926
74 changes: 71 additions & 3 deletions src/common/get-rpc-url.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,70 @@
import { readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { toChain, validateDevnetInfo } from '@filoz/synapse-core/devnet'
import type { Chain } from '@filoz/synapse-sdk'
import { calibration, mainnet } from '@filoz/synapse-sdk'
import type { CLIAuthOptions } from '../utils/cli-auth.js'
import { DEVNET_CHAIN_ID } from './constants.js'

const NETWORK_CHAINS = {
mainnet,
calibration,
} as const
function getDefaultDevnetInfoPath(): string {
const baseDir = process.env.FOC_DEVNET_BASEDIR?.trim() || join(homedir(), '.foc-devnet')
return join(baseDir, 'state', 'latest', 'devnet-info.json')
}

interface DevnetConfig {
chain: Chain
privateKey: string | undefined
}

let cachedDevnetConfig: DevnetConfig | undefined

/**
* Load and cache devnet configuration from devnet-info.json.
*
* Reads the devnet info file, validates it, and builds a Chain via synapse-core's
* toChain(). The result is cached for the lifetime of the process so that
* getRpcUrl() and parseCLIAuth() share the same chain object.
*/
export function resolveDevnetConfig(): DevnetConfig {
if (cachedDevnetConfig) {
return cachedDevnetConfig
}

const devnetInfoPath = process.env.DEVNET_INFO_PATH || getDefaultDevnetInfoPath()
const userIndex = Number(process.env.DEVNET_USER_INDEX || '0')

let rawData: unknown
try {
rawData = JSON.parse(readFileSync(devnetInfoPath, 'utf8'))
} catch (error) {
throw new Error(
`Failed to read devnet info from ${devnetInfoPath}: ${error instanceof Error ? error.message : String(error)}. ` +
'Set DEVNET_INFO_PATH to the correct path, or ensure foc-devnet is running.'
)
}

const devnetInfo = validateDevnetInfo(rawData)
const { info } = devnetInfo

if (userIndex >= info.users.length) {
throw new Error(
`DEVNET_USER_INDEX=${userIndex} out of range (${info.users.length} user(s) available in devnet-info.json)`
)
}

const user = info.users[userIndex]

cachedDevnetConfig = {
chain: toChain(devnetInfo),
privateKey: user?.private_key_hex,
}
return cachedDevnetConfig
}

/**
* Get the RPC URL from the CLI options.
Expand All @@ -22,11 +82,19 @@ export function getRpcUrl(options: CLIAuthOptions): string {
return options.rpcUrl
}

// Try to use network flag
const network = options.network?.toLowerCase().trim()
if (network) {
if (network === 'devnet') {
const devnet = resolveDevnetConfig()
const rpcUrl = devnet.chain.rpcUrls.default.http[0]
if (!rpcUrl) {
throw new Error('No RPC URL available in devnet-info.json')
}
return rpcUrl
}

if (network !== 'mainnet' && network !== 'calibration') {
throw new Error(`Invalid network: "${network}". Must be "mainnet" or "calibration"`)
throw new Error(`Invalid network: "${network}". Must be "mainnet", "calibration", or "devnet"`)
}
const chain = NETWORK_CHAINS[network]
const wsUrl = chain.rpcUrls.default.webSocket?.[0]
Expand All @@ -43,4 +111,4 @@ export function getRpcUrl(options: CLIAuthOptions): string {
return defaultUrl
}

export { NETWORK_CHAINS }
export { DEVNET_CHAIN_ID, NETWORK_CHAINS }
21 changes: 16 additions & 5 deletions src/common/upload-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export interface UploadFlowOptions {

/** Data set metadata applied when creating or matching contexts. */
metadata?: Record<string, string>

/** Skip IPNI advertisement verification after upload */
skipIpniVerification?: boolean
}

export interface UploadFlowResult extends SynapseUploadResult {
Expand Down Expand Up @@ -315,6 +318,7 @@ export async function performUpload(
...(options.dataSetIds != null && { dataSetIds: options.dataSetIds }),
...(options.excludeProviderIds != null && { excludeProviderIds: options.excludeProviderIds }),
...(options.metadata != null && { metadata: options.metadata }),
...(options.skipIpniVerification && { ipniValidation: { enabled: false } }),
onProgress(event) {
switch (event.type) {
case 'onStored': {
Expand Down Expand Up @@ -357,8 +361,12 @@ export async function performUpload(
// Show per-SP transaction URL as indented line under the "added" message
const afterLines: string[] = []
if (event.data.txHash) {
const filfoxBase = network === 'mainnet' ? 'https://filfox.info' : `https://${network}.filfox.info`
afterLines.push(pc.gray(`Tx: ${filfoxBase}/en/message/${event.data.txHash}`))
if (network === 'devnet') {
afterLines.push(pc.gray(`Tx: ${event.data.txHash}`))
} else {
const filfoxBase = network === 'mainnet' ? 'https://filfox.info' : `https://${network}.filfox.info`
afterLines.push(pc.gray(`Tx: ${filfoxBase}/en/message/${event.data.txHash}`))
}
}
flow.completeOperation(commitId, `${roleLabel(role)} Piece added to Data Set (unconfirmed on-chain)`, {
type: 'success',
Expand Down Expand Up @@ -447,9 +455,10 @@ export function displayUploadResults(
failures: FailedCopy[]
},
operation: string,
network: string
networkDisplay: string,
networkSlug: string
): void {
log.line(`Network: ${pc.bold(network)}`)
log.line(`Network: ${pc.bold(networkDisplay)}`)
log.line('')

log.line(pc.bold(`${operation} Details`))
Expand All @@ -463,7 +472,9 @@ export function displayUploadResults(
if (result.size != null) {
log.indent(`Piece Size: ${formatFileSize(result.size)}`)
}
log.indent(`Explorer: ${pc.gray(`https://pdp.vxb.ai/${encodeURIComponent(network)}/piece/${result.pieceCid}`)}`)
if (networkSlug !== 'devnet') {
log.indent(`Explorer: ${pc.gray(`https://pdp.vxb.ai/${encodeURIComponent(networkSlug)}/piece/${result.pieceCid}`)}`)
}
log.line('')

if (result.copies.length > 0) {
Expand Down
8 changes: 5 additions & 3 deletions src/core/upload/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Chain, PDPProvider, Synapse } from '@filoz/synapse-sdk'
import { calibration, mainnet } from '@filoz/synapse-sdk'
import type { CID } from 'multiformats/cid'
import type { Logger } from 'pino'
import { DEVNET_CHAIN_ID } from '../../common/constants.js'
import {
checkAllowances,
checkFILBalance,
Expand Down Expand Up @@ -28,11 +30,11 @@ export { getDownloadURL, getServiceURL, uploadToSynapse } from './synapse.js'
*/
export function getNetworkSlug(chain: Chain): string {
switch (chain.id) {
case 314:
case mainnet.id:
return 'mainnet'
case 314159:
case calibration.id:
return 'calibration'
case 31415926:
case DEVNET_CHAIN_ID:
return 'devnet'
default:
return chain.name
Expand Down
Loading